Compare commits

...
191 Commits
Author SHA1 Message Date
TIANYAO ZHANG 253c0a46a1 Merge pull request #101 from ZHANGTIANYAO1/feat/guest-mode
Add guest mode (login-less WebUI access) (#83)
2026-06-25 16:20:55 +08:00
saopig1andClaude Opus 4.8 a1a70dea5d fix(guest): serialize concurrent queue-mutation playback per bot
The queue-mutating playback routes (play-now-song, play-next-song,
add-song, play-at) read queue position synchronously, mutate the queue,
then await resolveAndPlay() which suspends at an async URL fetch before
player.play(). With no serialization, two concurrent requests (normal in
login-less guest mode) interleave: the audible song (decided by URL-fetch
latency) can disagree with queue.currentIndex (decided by sync-block
ordering), corrupting "now playing" and causing skipped/duplicate songs.

Add a per-bot async serializer (BotInstance.runExclusive) and wrap the
critical region of all four routes in it. Single-request behavior and
every response shape / validation 400 are preserved; only the critical
region moved inside runExclusive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:10:38 +08:00
saopig1andClaude Opus 4.8 43c0175334 fix(guest): tear down guest WS on guest-mode config change
An open guest WebSocket stamped isGuest/botScope once at upgrade and
never rechecked them, so it kept streaming bot state after an admin
disabled guest mode or narrowed guestMode.bots. setupWebSocket now
returns { cleanup, refreshGuestPolicy }; POST /api/bot/settings invokes
refreshGuestPolicy after saving a guestMode change, force-closing guest
sockets when disabled and live re-scoping them otherwise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:05:14 +08:00
saopig1andClaude Opus 4.8 c1d73b6ba8 fix(guest): cap guest session TTL on touch
The sliding-refresh branch in validateAndTouch hardcoded SESSION_TTL_MS
(7d) for all roles, so a guest session created with GUEST_SESSION_TTL_MS
(1d) was wrongly bumped to 7d on the first touch after the touch
interval. Derive the touch TTL from row.role instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:02:44 +08:00
saopig1andClaude Opus 4.8 66230e6b43 fix(guest): normalize guestMode config on load + strict-boolean authorize gate
loadConfig now sanitizes guestMode the same way the write path does: bots is
coerced to "all" | string[] (numbers/objects/missing fall back to the default
"all"), and permissions are rebuilt from defaults with each known flag
strict-coerced to a boolean so a hand-edited/legacy/corrupt config.json can no
longer crash the gate or leak garbage index keys. The authorize guest gate now
uses === true instead of a truthy check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:59:42 +08:00
saopig1andClaude Opus 4.8 952f1fbad3 fix(guest): deny operator personal-data reads to guests
GET /recommend/songs, /personal/fm, and /user/playlists read the
operator's own logged-in music account; gate them with requireNotGuest
so login-less guests cannot see the operator's recommendations, FM, or
playlists. Generic search/browse stays open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:54:29 +08:00
saopig1andClaude Opus 4.8 365352cdd3 fix(guest): guard reserved __guest__ principal in user mgmt
The by-id user-management handlers use findById, which has no role
filter, so supplying the synthetic GUEST_USER_ID let an admin delete,
re-role, reset-password, and read/write permissions on the shared guest
principal (privilege-escalation / DoS / credential-login holes).

- web/api/users.ts: 404-guard every :id handler against GUEST_USER_ID
  (DELETE, reset-password, role, GET/PUT permissions).
- data/users.ts: defense-in-depth — setRoleIfNotLastAdmin and
  deleteUserIfNotLastAdmin return "not_found" for any role=guest row.
- web/api/session.ts: wrap POST /guest createSession in try/catch so a
  missing guest row yields 503 instead of an unhandled 500.
- Tests: data-layer guest-protection + users-router 404 by-id guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:54:24 +08:00
saopig1andClaude Opus 4.8 45414b3baa feat(guest): prune deleted bot from guest scope on removeBot
When a bot is deleted, prune its id from config.guestMode.bots (when an
array) and persist, mirroring the existing permissions.pruneBot(id)
member-access pruning. Thread CONFIG_PATH into BotManager so removeBot
can save the updated config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:58:46 +08:00
saopig1andClaude Opus 4.8 f142c514cd fix(guest): deny favorites + auth-status reads to guests; UI polish
Consolidated fix wave from the final whole-branch review of guest mode.

- FIX 1 (critical): gate /api/favorites mount with requireNotGuest — the
  router keys off req.user.id (shared __guest__ principal), so guests could
  read/write a shared favorites bucket. Added focused guest-deny tests.
- FIX 2: gate GET /api/auth/status and /api/auth/qrcode/status with
  requireNotGuest so config reads no longer leak to guests.
- FIX 3: requireAuthInline in createSessionRouter now rejects guest sessions
  with 401 once guest mode is disabled (mirrors createRequireAuth), so /me
  stops returning guest data after an admin disables the feature.
- FIX 4: Login guest button now sits BELOW the card (auth-page flex-direction
  column + guest-btn width 360px) instead of beside it.
- FIX 5: mobile mini-player transport buttons in App.vue are now per-button
  gated for guests (prev/play/next/mode/volume), mirroring Player.vue.
- FIX 6: refreshed stale "gated on player.control" seek comments in Player.vue
  and relabeled the now-stale quality-GET test.

npm test: 354/354 pass. npm run build: tsc + vue-tsc + vite all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:44:07 +08:00
saopig1andClaude Opus 4.8 e47fc76529 docs: document guest mode
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:25:46 +08:00
saopig1 0fe0e973e6 feat(web/settings): admin-only 游客模式 section (toggles + bot scope) 2026-06-25 12:20:53 +08:00
saopig1 28cff59a6f feat(web/queue): gate remove/clear by member capability or guest removeClear 2026-06-25 12:17:53 +08:00
saopig1 b17057cc41 feat(web/player): per-button transport gating honoring guest flags 2026-06-25 12:15:09 +08:00
saopig1 15fcb11f4f feat(web/store): route guest play to non-destructive play-now-song 2026-06-25 12:12:28 +08:00
saopig1 9d8c95b2f9 feat(web/songcard): gate play/playNext/add by member capability or guest flag 2026-06-25 12:09:46 +08:00
saopig1 e36a049216 feat(web/app): hide mobile settings tab for guests 2026-06-25 12:06:51 +08:00
saopig1 3042f87199 feat(web/navbar): hide settings cog for guests + 游客 badge 2026-06-25 12:06:26 +08:00
saopig1 a21a01f0dd feat(web/login): add Continue as guest entry when guest mode is on 2026-06-25 12:03:47 +08:00
saopig1 78cf516c4c feat(web/router): block guests from settings and setup routes 2026-06-25 12:01:10 +08:00
saopig1 0c59a9f84a feat(web/session): expose isGuest, guestCan, continueAsGuest, guestAllowed 2026-06-25 11:58:58 +08:00
saopig1 d2ab888114 feat(ws): scope guest WebSocket feed to allowed bots 2026-06-25 11:56:19 +08:00
saopig1 0073d7d612 feat(music): lock quality read from guests 2026-06-25 11:51:41 +08:00
saopig1andClaude Opus 4.8 e0acbf5457 feat(bot): lock settings reads from guests + persist guestMode
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:48:26 +08:00
saopig1andClaude Opus 4.8 d763043305 feat(player): unified authorize() gating + non-destructive guest play-now
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:43:59 +08:00
saopig1andClaude Opus 4.8 821fa0669d feat(mw): add unified authorize() gate and requireNotGuest
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:38:21 +08:00
saopig1 8fbc522d06 feat(session): guest login endpoint, guestAllowed, guest /me payload 2026-06-25 11:35:10 +08:00
saopig1andClaude Opus 4.8 271504eec1 feat(db): seed reserved guest principal idempotently
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:30:12 +08:00
saopig1andClaude Opus 4.8 c9a0719128 feat(auth): guest-aware requireAuth + disable invalidates guest sessions
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:25:26 +08:00
saopig1andClaude Opus 4.8 0514162824 feat(sessions): guest role + per-session TTL and cap bypass
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:19:21 +08:00
saopig1 60c8a5c993 feat(users): guest role + reserved guest principal, excluded from count/list 2026-06-25 11:14:47 +08:00
saopig1 fb7f187ede feat(config): add default-off guestMode block with deep-merge 2026-06-25 11:11:10 +08:00
saopig1 1fd5dbaba3 feat(permissions): guest permission types + resolve guest branch 2026-06-25 11:08:17 +08:00
saopig1andClaude Opus 4.8 3433ccb661 docs: guest-mode implementation plan (#83)
23 bite-sized TDD tasks with exact code: config + guest principal,
unified authorize() gate, route re-gating + non-destructive play-now,
settings/quality read-locks, WebSocket per-bot guest scoping, and the
full frontend (entry, gating, admin 游客模式 section).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:37:08 +08:00
saopig1andClaude Opus 4.8 694ff77712 docs: guest-mode (login-less WebUI access) design spec (#83)
Brainstorm-approved design for an optional, default-off guest mode:
- guest = anonymous, config-driven principal (no account)
- per-ability admin toggles (add-to-end default on; play-next/play-now/
  skip/transport/remove-clear/play-mode opt-in) + per-bot guest scope
- unified authorize() gate; settings always locked for guests;
  non-destructive guest "play now"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:20:49 +08:00
TIANYAO ZHANG 06aaec4ba5 Merge pull request #100 from Dr1mH4X/feat/channelid
feat: joinChannel via channelid
2026-06-24 22:58:45 +08:00
Dr1mH4X 62b5b09857 chore: add success log for numeric channel join 2026-06-23 02:45:09 +08:00
Dr1mH4X 05f090d83a chore: format 2026-06-23 02:40:54 +08:00
Dr1mH4X 4244695075 feat: Add channelId support to bot configuration and database 2026-06-23 02:37:13 +08:00
TIANYAO ZHANG 6f21b6354a Merge pull request #98 from ZHANGTIANYAO1/fix/autopause-resume-on-return
fix(auto-pause): auto-resume when a listener returns (event-driven)
2026-06-17 23:12:27 +08:00
saopig1andClaude Opus 4.8 3a34c01abb fix(auto-pause): auto-resume on a listener's return via clientEnter event
Follow-up to the auto-pause fix: resume never fired when someone came back.

Root cause (verified live against a TS3 server): the full-client library's
command/response channel is dead whenever >=2 clients are connected anywhere on
the server — clientlist, channellist and channelclientlist ALL time out
(confirmed even with the two clients in different channels). So the moment a
listener returns is exactly the moment occupancy can no longer be queried, and
the query-based refreshOccupancy() can never observe the return -> no resume.
Event channelID is also unusable (library reads notify `cid` but enter-view
carries `ctid`, so it's always 0), so per-channel membership can't be derived
from events either.

Fix (minimal, asymmetric): keep PAUSE on the authoritative clientlist path
(reliable precisely because it only succeeds when the bot is alone on the
server — the only state pause should fire), and arm RESUME directly from the
clientEnter push event. Because the bot only auto-pauses while alone, the sole
way occupancy can return while autoPaused is set is a fresh connection, which
arrives reliably as clientEnter. New pure predicate shouldResumeOnReturn() +
_resumeIfReturning() resume iff autoPaused && paused; the resume branch routes
through handleOccupancy(1) and NEVER pauses (userCount>0), so a spurious enter
can only harmlessly resume. The bot's own enter at connect is a no-op
(autoPaused is already false).

This deliberately does NOT adopt a full event-tracked peer set: events don't
reliably seed clients already present when the bot joins, so a count-from-events
==0 would reintroduce the false-pause bug we just fixed, and reconcile can't
heal it (clientlist only works when alone). Pause must trust only the
authoritative query; resume can trust the event.

Net semantics: pause when the server is empty (bot alone), resume when someone
connects. Channel granularity is impossible with this library. UI copy updated
to say "服务器" instead of "频道", and the Settings toggle default corrected to
false to match the backend default. cmdVote intentionally left as-is.

Verified live: auto-paused bot + a real client connecting -> resume fires with
no clientlist call in the path; bot's own enter and not-auto-paused enters do
not resume. 311 unit tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:11:43 +08:00
TIANYAO ZHANG ba11519fdb Merge pull request #97 from ZHANGTIANYAO1/fix/autopause-occupancy-unknown
fix(auto-pause): don't treat failed clientlist as empty channel; default OFF
2026-06-16 23:56:56 +08:00
saopig1andClaude Opus 4.8 d3fd547ea0 fix(auto-pause): never treat a failed clientlist as "channel empty"; default OFF
Auto-pause within the first seconds of playback (and re-pause after a manual
play) whenever a listener is actually in the channel.

Root cause (confirmed live against a TS3 server): the full-client
`clientlist -uid -away -voice -groups` command TIMES OUT when other clients are
present in the bot's channel. `getClientsInChannel()` catches the error and
returns `[]`, so the occupancy callers computed `userCount = [].length - 1 = -1`,
which `decideOccupancyAction` reads as `-1 <= 0` → "channel empty" → pause. With
the bot alone, clientlist succeeds (returns just the bot), so the bug only
surfaced when someone was listening — exactly the report.

Fix: a connected bot is always a member of its own channel, so a valid query
returns >= 1 (itself). A length of 0 therefore means the query FAILED, not that
the channel is empty. New pure helper `occupancyFromClientList()` maps a
0-length result to `null` ("occupancy unknown"); `refreshOccupancy()` and the
30s idle poller skip the auto-pause / idle-disconnect decision when the count is
unknown instead of mis-reading it as empty. This also removes a latent
false-positive idle-disconnect on the same failed query.

Also default `autoPauseOnEmpty` to OFF (occupancy detection is unreliable on
some servers); users can opt in from Settings.

Verified live with two clients in one channel: clientlist returns 0 → helper
returns null → no false pause (control: bot alone returns 1 → 0 others, normal).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:55:44 +08:00
TIANYAO ZHANG 75f09694a3 Merge pull request #96 from ZHANGTIANYAO1/fix/song-ref-url-corner-cases
fix(song-ref): NetEase collection URLs + trailing-punct ids (#90 follow-up)
2026-06-16 22:06:41 +08:00
saopig1andClaude Opus 4.8 6d56f1f371 fix(song-ref): don't misparse NetEase collection URLs / trailing-punct ids (#90 follow-up)
Corner-case review of the #90 play-by-id parser found two reachable issues:

- A NetEase playlist/album/artist/toplist/djradio share URL (which reuses ?id=)
  was matched as a SONG id, so pasting one into !play called getSongDetail() on
  a collection id and returned a confusing 'No song found' instead of falling
  back to a normal search. Guard the id= branch to exclude collection pages.
- The id: prefix captured trailing punctuation from a chat paste ('id:12345.' ->
  '12345.'), which then failed to resolve. Strip trailing .,;)] from the id.

Both fall back to safe behavior (plain search / clean id). Tests added for
collection URLs and pasted ids with punctuation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:06:17 +08:00
TIANYAO ZHANG 64328d7bdf Merge pull request #95 from ZHANGTIANYAO1/feat/play-by-id-and-search
feat(play): pick same-name songs via !search / #N / id: / URL (#90)
2026-06-16 21:52:36 +08:00
saopig1andClaude Opus 4.8 287dd240b1 feat(play): pick same-name songs via !search / #N / id: / URL (#90)
!play/!add/!playnext only ever searched with limit 1, so a same-name song could
never be reached from chat (e.g. 'Die For You' always returned the most popular
match, not The Weeknd's). Add three disambiguation paths via a shared resolver:

- !search <name> — list the top matches (numbered, with id), remembered per bot
- !play #N / !add #N — play/queue the Nth result of the last !search
- !play id:<id> and pasted NetEase/QQ/BiliBili song URLs — play an exact song

Pure parsing (parseSongRef / parseSelectionIndex) is unit-tested; plain-text
search keeps the historical top-hit behavior. WebUI search (20 results) already
allowed picking same-name songs and is unchanged.

Fixes #90

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:49:45 +08:00
TIANYAO ZHANG 22ec7328fa Merge pull request #94 from ZHANGTIANYAO1/docs/update-readme-merged-features
docs(readme): document new features (permissions/favorites/scope/auto-pause/QQ FM) + recent fixes
2026-06-16 21:26:36 +08:00
saopig1andClaude Opus 4.8 540641700d docs(readme): document permissions, favorites, scope, auto-pause, QQ FM + recent fixes
Update the README to reflect everything merged recently:
- feature list: fine-grained permissions (capabilities + per-bot allow-list),
  local favorites, dedicated-link scope, channel-empty auto-pause, QQ radar FM
- first-run + WebUI page table + !fm command (-q) + architecture tree (new modules)
- config section: config.json now lives in data/config.json (+ migration note),
  complete the example with idleTimeoutMinutes/publicUrl/trustProxy
- FAQ: fine-grained member permissions, favorites, dedicated link, auto-pause
- changelog: new entry for the feature batch + bug fixes #84/#86/#89

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:26:08 +08:00
saopig1 3422d45eeb Merge PR #93: fix(audio) smooth, monotonic volume curve (#84)
# Conflicts:
#	src/audio/player.test.ts
#	src/audio/player.ts
2026-06-16 16:29:27 +08:00
saopig1 f7626f40b3 Merge PR #92: fix(player) recover B站 long-stream playback stalls (#89) 2026-06-16 16:26:15 +08:00
saopig1 de2c956c31 Merge PR #91: fix(config) generate config.json under the persisted data dir (#86) 2026-06-16 16:26:15 +08:00
saopig1andClaude Opus 4.8 3802c90d2d fix(audio): smooth, monotonic volume curve (#84)
applyVolume() mapped 0-100 with a two-piece, discontinuous curve: gain =
(vol/100)*0.2 for vol<100 (so the whole 0-99 range only spanned 0..0.198, making
80->99 feel flat) then a raw passthrough at vol===100 (a ~5x jump to full
loudness). That produced the reported dead zone + sudden ear-blast at 100.

Replace it with a single continuous, strictly-monotonic curve
volumeToFactor(v) = 0.2*x + 0.8*x^8 (x = v/100): 0 at 0, exactly 1.0 at 100, no
flat region and no discontinuity, so the slider feels proportional and full
loudness is still reserved at 100. Extracted as an exported pure function and
unit-tested (boundaries, strict monotonicity, dead-zone removal, no jump at 100).

Note: per the maintainer's note on #84 the >80% suppression was intentional
ear-protection; this change makes the upper range (above ~75%) audibly louder
than before in exchange for a proportional slider — applied per maintainer
decision.

Fixes #84

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:21:09 +08:00
saopig1andClaude Opus 4.8 839f777a75 fix(player): recover B站 long-stream playback stalls instead of going silent (#89)
Two issues caused a long BiliBili stream to stop partway (~16 min) and never resume:

1. FFmpeg lacked -reconnect_at_eof. B站 CDN sessions can close the connection
   mid-file (premature EOF); without this flag FFmpeg treats that EOF as
   end-of-input and stops. Added it (HTTP only) so FFmpeg re-issues a Range
   request and finishes the stream.

2. The frame loop only ended a live-but-silent FFmpeg when within 5s of the song
   end (isNearEnd). Far from the end, emptyFrameAttempts grew unbounded, no
   trackEnd was emitted, and audio went permanently silent ('无法继续播放').
   Added a far-from-end stall watchdog (MAX_STALL_ATTEMPTS ~= 60s) via a pure,
   tested shouldEndOnStall() helper, so a genuinely dead stream advances instead
   of hanging — while a transient underrun on a healthy stream is left alone.

Tests: assert -reconnect_at_eof 1 is present (before -i) for HTTP and absent for
local files; shouldEndOnStall covers near-end fast end, far-from-end no-false-skip,
and far-from-end eventual recovery.

Fixes #89

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:46:08 +08:00
saopig1andClaude Opus 4.8 dd6affca6d fix(config): store config.json under the persisted data dir (#86)
CONFIG_PATH resolved to ROOT_DIR/config.json (/app/config.json in Docker), but only
DATA_DIR (/app/data) is the mounted volume — every other artifact (DB, cookies, logs,
avatars) already lives under DATA_DIR. So on first run the default config was written
into the ephemeral image layer (never appearing in the volume), and a manually-placed
data/config.json was ignored because the bot read/wrote the root path.

- Move CONFIG_PATH to DATA_DIR/config.json so it lands in the volume and manual edits
  take effect.
- Add migrateLegacyConfig(): one-time move of an existing root-level config.json into
  the data dir, so existing local installs keep their settings (no silent reset).
- Tests for first-run persistence + the three migration cases.
- README directory tree updated to data/config.json.

Fixes #86

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:33:59 +08:00
saopig1 bea2f92508 Merge PR #80: feat(perm) fine-grained account permissions
Conflict resolution + cross-PR integration:
- player.ts: kept #88's POST /:botId/fm route AND gated it with
  requirePermission('player.control') so the new control endpoint honors #80's
  permission model (it was added without gating).
- bot.ts: kept #81's relocated /settings routes (the relocation fixes the GET
  /settings shadow bug) and dropped #80's now-duplicate bottom copy; gated
  POST /settings with requirePermission('bot.manage').
- Navbar.vue: composed #82's dedicated-link scope with #80's permission filter —
  displayedBots is now the INTERSECTION (scope ∩ controllable allow-list).
- database.ts: kept BOTH new table sets (#87 favorite_playlists + #80
  user_permissions/user_bot_access).
- bot.test.ts: updated to createRequireAuth(sessions, permissions) for #80's new
  two-arg signature.

#80 review fixes (credential exposure / IDOR, adversarially verified):
- GET /:id/config now requires bot.manage + bot access AND redacts ts6ApiKey +
  identity from the response (was readable by any authenticated member).
- GET /:id and GET /:id/avatar now require bot access (were ungated read oracles).
2026-06-16 15:05:57 +08:00
saopig1 f19f56a666 fix(favorites): error handling + state hydration + input validation [#87 review]
- addFavorite/removeFavorite now wrap axios in try/catch: a 409 (already favorited,
  common on a stale heart) or 404 resyncs instead of throwing an unhandled promise
  rejection; other errors surface a toast.
- fetchHomeData refreshes favorites BEFORE the TTL cache-return (was appended after
  the early return, so warm-cache loads never refreshed); removed the now-redundant
  trailing call. App.vue onMounted also hydrates favorites so deep-links to Search/
  Playlist show correct hearts.
- favorites API: GET /check rejects non-string (array) query params with 400 instead
  of a 500; POST defaults req.body to {} so a missing JSON body yields the intended 400.
2026-06-16 14:53:18 +08:00
saopig1 140020f63a Merge PR #87: local favorites feature
# Conflicts:
#	web/src/stores/player.ts
2026-06-16 14:50:25 +08:00
saopig1 6e10764d28 fix(qq-fm): guard FM start when offline + reset radar page on re-login [#88 review]
- startFm() now refuses with 'Bot is not connected to TeamSpeak' before mutating the
  queue, so POST /api/player/:id/fm can no longer wipe the queue and flip the bot into
  FM mode while disconnected (the !fm chat command already had this guard).
- The /fm route's success detection also treats 'not connected' as a failure so the
  toast type is correct.
- QQMusicProvider.setCookie() resets radarPage to 1 so a re-login with a different
  account no longer inherits the previous account's radar pagination cursor.
2026-06-16 14:48:01 +08:00
saopig1 9bfe831022 Merge PR #88: feat(qq) QQ Music radar / personal FM stream 2026-06-16 14:45:51 +08:00
saopig1 bbdd4cbc78 fix(autopause): decouple auto-pause toggle from idle-timeout save [#81 review]
The checkbox @change was wired to saveIdleTimeout, which POSTed BOTH idleTimeoutMinutes
and autoPauseOnEmpty: toggling silently committed an unsaved idle edit, and an empty/
non-numeric idle field made the combined POST 400 (errors swallowed), leaving the
checkbox flipped but not persisted. Give the toggle its own saveAutoPause() sending only
the boolean; 保存 now sends only idleTimeoutMinutes.
2026-06-16 14:45:01 +08:00
saopig1 c57cd35f09 Merge PR #81: feat(autopause) pause when bot channel empties 2026-06-16 14:43:54 +08:00
saopig1 1a1f365cf1 fix(scope): clear scope when the scoped bot is removed [#82 review]
removeBotStatus (botRemoved WS frame or admin deleting the scoped bot) left
scopedBotId dangling: isScoped stayed true, displayedBots went empty, and activeBot
silently fell back to bots[0], locking the UI onto a phantom bot. Clear the scope
when the scoped bot disappears.
2026-06-16 14:43:18 +08:00
saopig1 d1544bab42 Merge PR #82: feat(scope) lock UI to a bot via dedicated link 2026-06-16 14:42:32 +08:00
lTinchl e0d17cf404 feat(qq): add radar FM stream 2026-06-06 21:09:57 +08:00
Kun-ovO b2de607391 本地收藏功能 2026-05-31 23:27:02 +08:00
saopig1 355793b7e6 fix(scope): keep mounting if initial navigation errors (parity with old unconditional mount) 2026-05-30 15:25:20 +08:00
saopig1 593b42830c fix(scope): await router.isReady before mount so refreshed ?bot locks the right bot 2026-05-30 15:22:51 +08:00
saopig1andClaude Opus 4.8 463a8e2f8a feat(scope): lock Navbar selector to scoped bot + apply scope on load
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:17:01 +08:00
saopig1 88ac7d2a68 feat(scope): dedicated link seeds ?bot scope instead of bare redirect 2026-05-30 15:14:54 +08:00
saopig1 53d28de17e feat(scope): router guard syncs + preserves ?bot across navigation 2026-05-30 15:11:42 +08:00
saopig1andClaude Opus 4.8 ca07ebc3b7 feat(scope): player store scopedBotId + resolveScopedBot helper
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:09:03 +08:00
saopig1 bf1fb1fd88 docs(plan): dedicated-link bot scoping implementation plan (#79 items 2,4) 2026-05-30 15:07:26 +08:00
saopig1andClaude Opus 4.8 846fb2c28c docs(spec): dedicated-link bot scoping + refresh fix design (#79 items 2,4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:06:09 +08:00
saopig1 34655e5f50 feat(autopause): autoPauseOnEmpty toggle in Settings 2026-05-30 14:58:35 +08:00
saopig1andClaude Opus 4.8 491bc53dec feat(autopause): expose autoPauseOnEmpty via /api/bot/settings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:55:18 +08:00
saopig1 8f5bb26b3a feat(autopause): re-emit client enter/leave/move for instant pause/resume 2026-05-30 14:50:52 +08:00
saopig1andClaude Opus 4.8 4ba4b013b0 feat(autopause): drive pause/resume from channel occupancy in BotInstance
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:46:50 +08:00
saopig1 484202e90d feat(autopause): pure occupancy-decision function 2026-05-30 14:44:28 +08:00
saopig1 9f0ac74fbc docs(plan): auto-pause on empty channel implementation plan (#79 item 3) 2026-05-30 14:43:38 +08:00
saopig1andClaude Opus 4.8 51c954993a docs(spec): auto-pause on empty channel design (#79 item 3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:42:13 +08:00
saopig1andClaude Opus 4.8 907a6651f5 fix(perm): access-check before bot-existence (no 403/404 leak); label permissions audit action
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:12:04 +08:00
saopig1andClaude Opus 4.8 1ca1ca9d0c test(perm): assert /me capabilities+bots; dry backfill token list
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:06:59 +08:00
saopig1andClaude Opus 4.8 d70664067c feat(perm): admin permission editor in user management
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:03:54 +08:00
saopig1 5177b41951 feat(perm): gate Queue.vue remove/clear/play-at controls by capability 2026-05-30 14:00:27 +08:00
saopig1 bb86f7e9ed feat(perm): gate idle-timeout + bot-profile settings on bot.manage 2026-05-30 13:56:46 +08:00
saopig1andClaude Opus 4.8 221f7c8dcf feat(perm): hide UI a member lacks capability for
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:55:31 +08:00
saopig1 cf76e0f69a feat(perm): frontend session capabilities + can()/canControlBot() 2026-05-30 13:51:34 +08:00
saopig1andClaude Opus 4.8 abf60141d9 feat(perm): one-time backfill of existing members to full access
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:47:44 +08:00
saopig1andClaude Opus 4.8 ce15f36e5e feat(perm): admin permissions API + audit + new-member basic tier
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:44:12 +08:00
saopig1andClaude Opus 4.8 1f0f162f66 feat(perm): filter GET /api/bot to allowed bots; prune access on bot delete
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:38:57 +08:00
saopig1andClaude Opus 4.8 cd6f2c6078 feat(perm): enforce capabilities + bot access on action routes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:32:08 +08:00
saopig1andClaude Opus 4.8 696b224f8d feat(perm): load capabilities + bot access onto req.user; expose via /me
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:21:24 +08:00
saopig1 7a8666efbb feat(perm): resolvePermissionContext (admin = super-user) 2026-05-30 13:17:06 +08:00
saopig1 f0c979ce71 docs(spec): use 'capabilities' consistently for req.user field 2026-05-30 13:15:44 +08:00
saopig1 d810a2ec0f test(perm): cover requireBotAccess 401 + missing-param cases 2026-05-30 13:15:01 +08:00
saopig1andClaude Opus 4.8 554501cc74 feat(perm): requirePermission + requireBotAccess middleware
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:10:39 +08:00
saopig1andClaude Opus 4.8 aaf6ba2ab4 feat(perm): permission store + capability tokens + tables
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:05:38 +08:00
saopig1andClaude Opus 4.8 547aaa304e docs(plan): account permissions implementation plan (#79-E)
11 TDD tasks: permission store + tables, requirePermission/requireBotAccess, req.user wiring, route enforcement, bot-list filtering, admin API + audit, one-time member backfill, and frontend gating + permission editor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:49:57 +08:00
saopig1andClaude Opus 4.8 f09a589940 docs(spec): fine-grained account permissions design (#79-E)
Capability flags (player.control/player.queue/bot.manage/platform.auth/quality) + per-member bot allow-list, layered under the existing member role; admin is super-user. Backend-enforced via requirePermission/requireBotAccess; existing members backfilled to full on upgrade, new members get a basic tier.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:44:24 +08:00
TIANYAO ZHANG a861b41809 Merge pull request #78 from ZHANGTIANYAO1/feat/shuffle-bag-random-modes
feat(queue): 随机循环改为洗牌袋,每首歌播完一轮再重复 (优化随机循环逻辑)
2026-05-29 22:16:35 +08:00
saopig1andClaude Opus 4.8 e9b3ba0075 feat(queue): shuffle-bag random modes so every song plays before repeating
随机循环 (rloop) used true random-with-replacement, so some songs repeated constantly while others were starved (issue #70). Both random modes now draw from a shuffle bag: every song plays exactly once per cycle in random order. They differ only at cycle end — 随机 (random) stops, 随机循环 (rloop) reshuffles and continues, excluding the just-played song from the first pick of the new cycle to avoid a back-to-back repeat across the boundary. Songs added mid-cycle stay eligible within the current cycle.

随机's visible behavior is unchanged (it already avoided in-cycle repeats); the two branches now share one selection path. Adds shuffle-bag tests (per-cycle permutation, even distribution, no cross-boundary repeat, mid-cycle add).

Closes #70

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 22:08:40 +08:00
TIANYAO ZHANG 0401534b88 Merge pull request #77 from ZHANGTIANYAO1/fix/webui-referrer-policy-csrf
fix(web): referrer-policy same-origin 修复扫码登录不弹二维码 + cookie 无法保存
2026-05-29 21:30:35 +08:00
saopig1andClaude Opus 4.8 f720da49d6 fix(web): referrer-policy same-origin so same-origin POSTs keep a real Origin
no-referrer downgraded the Origin header to the literal "null" on same-origin non-GET requests (per the WHATWG Fetch "Append a request Origin header" algorithm), which the /api/* csrfOriginCheck then rejected with 403 "bad origin" — silently breaking QR login, cookie save, and every other WebUI POST/PUT/DELETE/PATCH (playback, bot management, user admin). /api/session/* was unaffected because it mounts before the CSRF gate, which is why WebUI login still worked.

same-origin keeps the real Origin on same-origin requests (CSRF passes) while still sending no Referer cross-origin, so B站/NetEase/QQ CDN cover thumbnails keep loading. Adds referrer-policy.test.ts pinning the policy and a csrf.test.ts case for the Origin: "null" rejection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 21:25:20 +08:00
TIANYAO ZHANG 3abb468cca Merge pull request #75 from ZHANGTIANYAO1/fix/ui-overflow-and-textarea-resize
fix(web): long artist + B站 card grid + B站 image referer
2026-05-27 20:10:21 +08:00
saopig1andClaude Opus 4.7 c8daa14219 fix(web): set no-referrer at document level so B站 cover thumbnails load
Bilibili's CDN (i*.hdslb.com) returns 403 with `x-error-info:
RefererWhite` for image requests whose Referer is not on their
whitelist. `CoverArt.vue` already sets `referrerpolicy="no-referrer"`
on its `<img>` tag, BUT the `.cover-shadow` div renders the same URL
as a CSS `background-image`, which ignores the img attribute and uses
the document default policy (`strict-origin-when-cross-origin` in
modern Firefox/Chrome) — that sends `Referer: http://localhost:3000/`
and triggers the block.

Setting `<meta name="referrer" content="no-referrer">` in index.html
applies no-referrer site-wide: covers <img> tags, CSS background-image
fetches, and anywhere else CDNs check referer. Doesn't affect our
/api/* CSRF middleware because that uses Origin (still sent by the
browser), not Referer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:07:18 +08:00
saopig1andClaude Opus 4.7 81cd8a2bec fix(web): revert textarea + actual culprit was B站热门 card grid
Previous commit misidentified the second bug. Reverting the
Settings.vue `resize: vertical` → `resize: none` change — that
wasn't the issue.

Real fix: `.daily-card` (used by B站热门 and 每日推荐 sections in
Home.vue) is a CSS Grid cell with default `min-width: auto`, which
refuses to shrink below its content. A long Bilibili video title
inside `.daily-name` expanded the cell past its 1fr column, breaking
the 6-column grid and creating empty/black space on the right. The
existing `text-overflow: ellipsis` on `.daily-name` couldn't engage.

Adding `min-width: 0` to `.daily-card` lets the cell shrink to the
1fr grid track size, and the ellipsis truncation now works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:20:46 +08:00
saopig1andClaude Opus 4.7 35210cf570 fix(web): long artist name overflow + textarea resize artifact
- Player.vue: wrap artist text in a span with ellipsis. The previous
  text node sat directly inside the flex `.song-artist` container with
  no overflow handling, so a long author name expanded the container
  past its 240px parent and broke the bottom Player bar layout. Also
  add `min-width: 0 + overflow: hidden` to `.song-info` and
  `.song-artist`, and a `:title` attribute for the full text on hover.

- Settings.vue: change `resize: vertical` on the cookie textareas
  to `resize: none`. The browser's resize grip rendered as a stray
  black triangle at the bottom-right corner in dark theme, and
  dragging it caused visual artifacts on the right edge. The
  textareas keep their `rows="3"` default height.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:14:26 +08:00
TIANYAO ZHANG d2bad58aa8 Merge pull request #74 from ZHANGTIANYAO1/feat/webui-auth
Add WebUI authentication: multi-user, roles, audit log
2026-05-27 16:46:03 +08:00
saopig1 f73ca1f61b docs: README updates for WebUI auth feature + pre-auth upgrade guide 2026-05-27 16:40:41 +08:00
saopig1andClaude Opus 4.7 a0f290459d feat(auth): X-Frame-Options + CSP frame-ancestors clickjacking defence
Every response now carries:
  X-Frame-Options: DENY
  Content-Security-Policy: frame-ancestors 'none'

Prevents the WebUI from being embedded in a third-party iframe.
Combined with the existing CSRF Origin-host check, this closes the
last meaningful UI-redress surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:34:52 +08:00
saopig1 b6b9aa07bc feat(auth): atomic session cap + change-password UI + trustProxy docs 2026-05-27 16:29:16 +08:00
saopig1 a39fc25104 feat(auth): rate-limit /login+/setup, per-user session cap, periodic /me poll 2026-05-27 16:16:07 +08:00
saopig1 1a11489f2e fix(auth): atomic last-admin guards on role-change and delete 2026-05-27 15:55:11 +08:00
saopig1andClaude Opus 4.7 c0504d65a5 fix(web): localize user.role_changed audit label
Adds the missing case so role-change entries display in Chinese
instead of falling through to the generic key→target format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:43:09 +08:00
saopig1 780726a4e3 feat(web): role-aware UI (badge, selector, toggle button, hide admin-only sections for members) 2026-05-27 15:38:42 +08:00
saopig1andClaude Opus 4.7 a73f797bcb feat(auth): two-role permission system (admin/member)
Adds an admin/member role to WebUI auth. /api/users and /api/audit
are now gated by a requireAdmin middleware; all other authenticated
endpoints accept both roles. Schema migration defaults all existing
users to admin to preserve access. POST /api/users defaults new users
to member; first-run setup always creates an admin. Adds PATCH
/api/users/:id/role with last-admin demotion and deletion guards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:35:15 +08:00
saopig1 b0b61f8fce fix(auth): defensive audit-record + self-reset preserves current session 2026-05-27 15:16:55 +08:00
saopig1 7be4f13774 feat(web): operation audit log section in Settings 2026-05-27 15:06:54 +08:00
saopig1andClaude Sonnet 4.6 6af0e97f51 feat(auth): user-management audit log (table + record sites + /api/audit endpoint)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 15:04:19 +08:00
saopig1andClaude Sonnet 4.6 ceb24595e6 fix(auth): race-safe first-run setup + rolling cookie max-age refresh
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:54:19 +08:00
saopig1andClaude Sonnet 4.6 fb7feec5cf feat(web): user management section in Settings (list/create/delete/reset-password)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:44:42 +08:00
saopig1andClaude Sonnet 4.6 175b8e6065 feat(auth): add /api/users CRUD (list, create, delete, reset-password)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:41:58 +08:00
saopig1andClaude Opus 4.7 f46b37192f fix(web): break infinite recursion in apiFetch by capturing nativeFetch
apiFetch called window.fetch which installApiClient had reassigned to
call apiFetch — every request blew the stack. Capture the native fetch
at module load (before any wrap) and use it inside apiFetch.

Symptom: first-run / login redirect never fires because the router
guard hangs on session.refresh().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:34:56 +08:00
saopig1 a8b056d2aa fix(auth): WS Origin host check + Login next-param open-redirect guard 2026-05-27 14:02:33 +08:00
saopig1andClaude Sonnet 4.6 e148c1556e feat(web): show current user + logout button in nav
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:57:31 +08:00
saopig1 e2e888710a fix(web): exclude /api/session/* from 401 auto-refresh to prevent re-entrancy 2026-05-27 13:56:14 +08:00
saopig1andClaude Sonnet 4.6 7509814abc feat(web): install global fetch wrapper with credentials + 401 handling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:54:24 +08:00
saopig1andClaude Sonnet 4.6 0dc8746914 feat(web): add /first-run + /login routes with auth guard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:52:49 +08:00
saopig1 2560fc87c2 feat(web): add Login view 2026-05-27 13:51:20 +08:00
saopig1 6db35f704d feat(web): add useSession composable 2026-05-27 13:50:14 +08:00
saopig1andClaude Sonnet 4.6 490b2d57dc test(auth): verify ws upgrade gating end-to-end
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:49:05 +08:00
saopig1andClaude Sonnet 4.6 486979841e feat(auth): gate /api/* behind requireAuth + csrf; gate /ws via upgrade handler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:46:59 +08:00
saopig1andClaude Sonnet 4.6 ee5673a22f feat(auth): add /api/session router (setup, login, logout, me, change-password)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:42:00 +08:00
saopig1andClaude Sonnet 4.6 d1c9e14bf0 feat(auth): add csrfOriginCheck middleware
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:38:35 +08:00
saopig1andClaude Sonnet 4.6 17c11f0512 feat(auth): add requireAuth middleware
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:36:26 +08:00
saopig1 df5d125279 feat(auth): add shared validateSessionFromHeaders helper 2026-05-27 13:34:47 +08:00
saopig1andClaude Sonnet 4.6 5914f41ea1 feat(auth): add SessionStore with rolling renewal and at-rest token hashing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:33:22 +08:00
saopig1 68a2fb2943 refactor(users): use SQLITE_CONSTRAINT_UNIQUE error code instead of message text 2026-05-27 13:31:31 +08:00
saopig1andClaude Sonnet 4.6 34523cb00f feat(auth): add UserStore with bcryptjs password hashing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:29:39 +08:00
saopig1andClaude Sonnet 4.6 8ea1a64c59 feat(db): add users and sessions tables for WebUI auth
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:27:12 +08:00
saopig1 df79976933 deps: add bcryptjs + cookie-parser + supertest for WebUI auth 2026-05-27 13:25:32 +08:00
saopig1andClaude Opus 4.7 d3af918f53 docs(plan): WebUI authentication implementation plan
16 bite-sized tasks with TDD discipline:
- 10 backend (schema, users, sessions, middleware, /api/session router,
  server.ts wiring, WS upgrade gating + integration test)
- 5 frontend (useSession composable, Login + FirstRunSetup views,
  router guard, fetch wrapper, Navbar logout)
- 1 manual smoke test gate before PR

Notes /first-run as the admin-setup route since /setup is already taken
by the bot-creation wizard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:18:10 +08:00
saopig1andClaude Opus 4.7 f7c16888e7 docs(spec): WebUI authentication design
Spec for adding username+password auth to the WebUI to close the
unauthenticated-API exposure (all /api/* and /ws currently open).

Design: SQLite users + sessions tables, bcryptjs, 7-day rolling
HTTP-only cookie sessions, first-run setup wizard, Origin/Referer
CSRF check, WebSocket upgrade gated on the same session cookie.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:07:51 +08:00
TIANYAO ZHANG a2982948a7 Merge pull request #71 from EvolvedGhost/main
fix(player): 歌曲结尾后持续卡死不切换下一首歌
2026-05-25 18:56:14 +08:00
EvolvedGhost b7e1f9f30b fix(player): add force trackEnd when pcmBuffer less than PCM_FRAME_BYTES 2026-05-23 22:08:52 +08:00
TIANYAO ZHANG 7fc5186c24 Merge pull request #65 from ZHANGTIANYAO1/fix/bilibili-wbi-search
fix(bilibili): wbi-sign search params — legacy /search/type now anti-bot blocked
2026-05-16 22:31:55 +08:00
saopig1andClaude Opus 4.7 de92c8bcd4 fix(bilibili): wbi-sign search params — legacy /search/type now anti-bot blocked
Closes #64. Bilibili moved the unsigned /x/web-interface/search/type endpoint
behind their anti-bot wall; it now returns an HTML error page (出错啦!) even
with buvid3+buvid4 cookies, causing `play -b` to report "No results found".

Switch search() to /x/web-interface/wbi/search/type with proper wbi signing:
fetch img_key/sub_key from /nav, derive the mixin key via the standard
permutation, and sign each request with wts + w_rid (md5). Keys are cached
for 6h since they rotate ~daily. Other endpoints (view, playurl, popular,
top/rcmd) still work unsigned and are left unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:29:12 +08:00
TIANYAO ZHANG 6b143e1a56 Merge pull request #63 from XuVIIJay/pr/setup-scripts
安装脚本优化(对国内网络环境安装友好)
2026-05-16 02:02:10 +08:00
XuVIIJay 5728209573 fix(setup.sh): add Node.js and npm version check before install 2026-05-15 21:03:28 +08:00
XuVIIJay 02d8b39d75 fix(setup.bat): remove hardcoded personal Node.js paths 2026-05-15 20:56:59 +08:00
XuVIIJay f87aaaf8c3 feat(scripts): optimize setup/start scripts for China network 2026-05-15 20:42:51 +08:00
TIANYAO ZHANG 8861f39ecc Merge pull request #62 from NoSetViolin/main
fix(qq): switch search to c.y.qq.com client_search_cp with u.y.qq.com fallback
2026-05-14 22:44:41 +08:00
NoSetViolinandClaude Opus 4.7 6d5755ced7 feat(search): source filter bar, album search, and UX polish
- Add platform source filter bar (网易云/QQ/B站) above category tabs with
  localStorage persistence to remember user preference
- Remove "全部" option, single-source view only
- Increase album/playlist card column-gap to 28px for better spacing
- Sync search query to URL via router.replace so back-navigation from
  album/playlist detail pages restores search results
- Fix !album command: add name-based album search (matching !playlist
  behavior) so "!album APT." searches by name instead of treating it as ID

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 18:56:14 +08:00
NoSetViolinandClaude Opus 4.7 997bb17ceb feat(search): add tabbed category navigation + align NetEase result counts
- Replace single-page layout with pill-slider tabs (单曲/专辑/歌单) under
  the search box, showing only one category at a time with result counts
- NetEase album/playlist search limit raised from 5 to 10 to match QQ

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:56:18 +08:00
NoSetViolinandClaude Opus 4.7 ea6501ea81 feat(web): add platform badges to album and playlist cards in search
Album and playlist results now show source tags (网易云, QQ, B站, YouTube)
matching the existing SongCard platform badge style.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:34:26 +08:00
NoSetViolinandClaude Opus 4.7 1d2da33d3c fix(qq): apply musicu.fcg upstream fixes + add playlist search
Per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61:
- Remove searchid param (its presence now causes empty results)
- Enforce num_per_page >= 10 (lower values return empty)
- Fix search_type: 2 for albums, 3 for playlists (8 was "user")

Now uses the fixed musicu.fcg as primary (supports songs + albums +
playlists), with client_search_cp as fallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:28:12 +08:00
阿梓喵_あずにゃんandClaude Opus 4.7 0e68e287b7 fix(qq): switch search to c.y.qq.com client_search_cp with u.y.qq.com fallback
u.y.qq.com/cgi-bin/musicu.fcg started returning is_filter=-9 (empty results)
for unauthenticated requests. Primary search now uses the classic
c.y.qq.com/soso/fcgi-bin/client_search_cp endpoint, with the old musicu.fcg
path kept as a fallback in case the primary endpoint changes format or goes
down.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:55:21 +08:00
TIANYAO ZHANG ecdb2d253e Merge pull request #60 from XuVIIJay/feat/forward-stack
随机播放模式双向可回溯
2026-05-12 22:23:35 +08:00
XuVIIJay 8860347bfe Update README.md 2026-05-12 00:04:20 +08:00
XuVIIJay beb99f1d8e Update README.md 2026-05-11 23:49:34 +08:00
XuVIIJay 63f1ced2bc feat(queue): add forwardStack for reversible prev/next navigation
In Random/RandomLoop modes, prev now records the current position
to a forwardStack, and next pops from it first. This ensures prev→next
navigation is fully reversible for any number of steps (up to 50).

Also rebuild playedIndices in prev() so songs reached via backward
navigation become available for random selection again.
2026-05-11 23:45:04 +08:00
saopig1andClaude Opus 4.7 fa6013d22e docs(commands): surface existing !remove <position> in help and README
Closes #59. The command was already implemented but not advertised in
!help output or the README command table, so users assumed it was missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:56:49 +08:00
saopig1andClaude Opus 4.7 6b60792277 feat(web): custom avatar field in edit-bot modal
User reported that the edit-bot dialog had no avatar option (only
create-bot did). Reuses the same CustomAvatarRow component, which
auto-loads on mount and PUT/DELETEs on change. Each bot's avatar
is bound by botId — independent across bots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:23:21 +08:00
saopig1andClaude Opus 4.7 c562fe05b0 fix(player): start frame loop only after ffmpeg spawns in jdymusic path
PR #54's playViaPowerShellDownload called startFrameLoop() right after
spawning the PowerShell downloader, before ffmpeg existed. The loop's
"no ffmpeg + empty buffer → emit trackEnd" branch fired on the very
first tick (~25ms), skipping every jdymusic song. Visible as: each
PowerShell download sessionId logged "Track ended, advancing queue"
before the download completed, so the queue burned through every
track in a few seconds.

Move startFrameLoop() into spawnFfmpegFromFile() where ffmpeg is
known to be alive and producing PCM. state = "playing" still flips
in playViaPowerShellDownload so external observers see the right
status during download.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:04:27 +08:00
TIANYAO ZHANG 9efa818bbb Merge pull request #57 from ZHANGTIANYAO1/feat/album-search
feat(search): album section + album playback
2026-05-07 20:50:02 +08:00
TIANYAO ZHANG d757e69bf4 Merge pull request #56 from ZHANGTIANYAO1/feat/custom-bot-avatar
feat(profile): custom bot avatar
2026-05-07 20:49:58 +08:00
saopig1andClaude Opus 4.7 88ff62c829 fix(profile): apply custom avatar immediately on idle setCustomAvatar / connect
Review feedback on PR #56:

1. setCustomAvatar(buf) now triggers applyIdleAvatar when the bot is idle
   (currentSong=null OR avatarEnabled=false). Spec said this; original impl
   only stored the buffer, so a fresh upload from Settings was invisible
   until next stop event. Track currentSong in BotProfileManager for the
   idle check.

2. onConnect drops the !avatarEnabled guard — on a fresh connect there's
   no song playing yet, so the spec matrix wants the custom avatar shown
   regardless of sync. Previously bots reconnected with a stale TS3
   server-side avatar.

3. CustomAvatarRow: defer the initializing=false flip to nextTick so the
   load-time data-url assignment's queued watcher sees initializing=true
   and bails. Removes the redundant PUT-on-mount that echoed the just-
   loaded bytes back to the server.

4. BotInstance avatar load wrapped in try/catch — a corrupt/locked file
   no longer crashes startup; we log and continue with no custom avatar.

Tests rewritten: 10 cases covering the full behavior matrix
(idle vs playing × sync on/off × custom set/null × stop/connect).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:40:00 +08:00
saopig1andClaude Opus 4.7 8cccf2ed24 fix(web): album hero shows real album name from songs[0].album
The album detail endpoint intentionally 404s (no /api/music/album/:id/detail
route), so we fall through to the stub built from songs. The album name is
already on every song (Song.album), so use it instead of the literal "专辑".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:35:35 +08:00
saopig1andClaude Sonnet 4.6 f6b82b8e21 feat(web): show album + playlist sections in search
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:26:03 +08:00
saopig1andClaude Sonnet 4.6 51d7ce61bd feat(web): /album/:id route reusing Playlist view
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:24:20 +08:00
saopig1andClaude Sonnet 4.6 20f4cfacea feat(api): /search/all returns albums and playlists
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:21:07 +08:00
saopig1andClaude Sonnet 4.6 7d9081efc1 feat(qq): include albums in search results
Add mapQqAlbums pure helper (exported, TDD-covered) and wire a parallel
req_album sub-request (search_type: 8) into search(), populating the
albums field of SearchResult.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:20:05 +08:00
saopig1andClaude Sonnet 4.6 a2453784c1 feat(netease): include albums in search results
Add mapNeteaseAlbums pure helper and wire cloudsearch?type=10 as the third Promise.all arm in search(), replacing the hardcoded albums:[].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:17:46 +08:00
saopig1andClaude Sonnet 4.6 0f9c7b3c4c feat(web): custom avatar in create-bot + Settings
Adds AvatarUpload to the create-bot form (PUT on new bot id after POST)
and a CustomAvatarRow per-bot in the profile-toggles section (GET on
mount, PUT/DELETE on user action with initializing guard).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:14:34 +08:00
saopig1andClaude Sonnet 4.6 914180792d feat(web): AvatarUpload component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:11:43 +08:00
saopig1andClaude Sonnet 4.6 935656dc4f feat(api): /api/bot/:id/avatar GET/PUT/DELETE
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:10:06 +08:00
saopig1andClaude Sonnet 4.6 2dd6a9e20d feat(bot): load custom avatar on instance startup
Thread AvatarStore from index.ts → BotManager → BotInstance so every
BotInstance reads the persisted custom avatar from disk at construction
time and hands it to BotProfileManager via setCustomAvatar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:07:47 +08:00
saopig1andClaude Sonnet 4.6 ffa27d7224 feat(profile): custom avatar with idle/playback precedence
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:05:00 +08:00
saopig1andClaude Sonnet 4.6 edd0fc58eb feat(data): avatar file store helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:00:56 +08:00
saopig1andClaude Sonnet 4.6 366edf7843 feat(db): custom_avatar_path column + accessors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 19:59:20 +08:00
saopig1andClaude Opus 4.7 ff5502be2f docs(plan): custom avatar + album search implementation plans
Two independent plans for spec 2026-05-07; each yields its own PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:55:52 +08:00
saopig1andClaude Opus 4.7 4407cd0c67 docs(spec): custom bot avatar + album search design
Spec for issue #51: custom avatar with idle/playback precedence rules,
and surfacing albums in search results + album detail playback. Two
features grouped in one spec; will land as two separate PRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:49:53 +08:00
TIANYAO ZHANG b8c12e0291 Merge pull request #53 from L1uyQwQ/codex/mobile-responsive-controls
fix: improve mobile web responsive controls
2026-05-07 19:39:51 +08:00
TIANYAO ZHANG e7411ee7fb Merge pull request #55 from ZHANGTIANYAO1/fix/netease-ffmpeg-ua
fix(player): WinHTTP fallback for /jdymusic/ CDN that drops Node TCP
2026-05-07 19:37:58 +08:00
TIANYAO ZHANG ce88d12a60 Merge pull request #54 from ZHANGTIANYAO1/fix/jdymusic-powershell-fallback
fix(player): WinHTTP (PowerShell) fallback for /jdymusic/ CDN
2026-05-07 19:18:02 +08:00
saopig1andClaude Opus 4.7 719ceae303 fix(player): WinHTTP fallback for /jdymusic/ CDN that drops Node TCP
The old jdymusic CDN (e.g. m701/m801.music.126.net/.../jdymusic/obj/...)
intermittently drops connections from Node's HTTP stack — even with a
browser UA — while the same URL fetched via WinHTTP works. Newer paths
(/jd-musicrep-ts/, /ymusic/) do not have this restriction.

On Windows, dispatch /jdymusic/ URLs through `System.Net.WebClient` in
PowerShell into a temp file, then run ffmpeg against the file. Other
URLs and non-Windows platforms keep the direct ffmpeg + browser-UA path
from the previous commit. Also fix buildFfmpegArgs to omit HTTP-only
flags (-reconnect_*, -headers) when the input is a local file path —
new ffmpeg builds reject these as "Option not found".

stop() now also kills any in-flight PowerShell downloader and removes
the temp dir, so cancellation is clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:08:58 +08:00
saopig1andClaude Opus 4.7 52ece9321f fix(player): WinHTTP fallback for /jdymusic/ CDN that drops Node TCP
The old jdymusic CDN (e.g. m701/m801.music.126.net/.../jdymusic/obj/...)
intermittently drops connections from Node's HTTP stack — even with a
browser UA — while the same URL fetched via WinHTTP works. Newer paths
(/jd-musicrep-ts/, /ymusic/) do not have this restriction.

On Windows, dispatch /jdymusic/ URLs through `System.Net.WebClient` in
PowerShell into a temp file, then run ffmpeg against the file. Other
URLs and non-Windows platforms keep the direct ffmpeg + browser-UA path
from the previous commit. Also fix buildFfmpegArgs to omit HTTP-only
flags (-reconnect_*, -headers) when the input is a local file path —
new ffmpeg builds reject these as "Option not found".

stop() now also kills any in-flight PowerShell downloader and removes
the temp dir, so cancellation is clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:08:00 +08:00
L1uyQwQ 0bf34d8652 fix mobile web responsive controls 2026-05-07 16:58:31 +08:00
TIANYAO ZHANG 9179780afb Merge pull request #52 from ZHANGTIANYAO1/fix/netease-ffmpeg-ua 2026-05-07 16:33:25 +08:00
saopig1andClaude Opus 4.7 f76500cfb2 fix(player): set browser UA + Referer for Netease CDN to stop auto-skip
Netease's m701/m801.music.126.net CDN RST connections from FFmpeg's
default Lavf UA. The bot's frame loop treats an early ffmpeg exit as
trackEnd and advances the queue, which surfaced as songs auto-skipping
mid-playlist. Add a browser UA + music.163.com Referer for these URLs,
extract args into a tested buildFfmpegArgs(), and harden reconnect
flags (delay_max 5 -> 30, plus reconnect_on_network_error /
reconnect_on_http_error). Verified by A/B running ffmpeg against the
same fresh CDN URL: legacy args got 0 bytes + "End of file" reading
HTTP response; fixed args streamed the full track to completion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:18:23 +08:00
saopig1andClaude Opus 4.7 7eb96d9068 ci: publish multi-arch Docker image to GHCR on tag push
Add GitHub Actions workflow that builds linux/amd64 + linux/arm64
images via buildx + QEMU and pushes to ghcr.io/zhangtianyao1/teamspeak-music-bot
on v*.*.* tag pushes (or manual dispatch). Switch docker-compose.yml
to pull the prebuilt image so NAS / non-build environments can deploy
without a local toolchain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:21:37 +08:00
121 changed files with 20461 additions and 882 deletions

No files matched your search

-2
View File
@@ -19,8 +19,6 @@
"Read(//tmp/**)"
],
"deny": [
"Bash(git push * main)",
"Bash(git push * master)",
"Bash(git push --force *)",
"Bash(rm -rf /)"
]
+64
View File
@@ -0,0 +1,64 @@
name: Build and Publish Docker Image
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
tag:
description: 'Extra tag to publish (optional, e.g. "edge")'
required: false
default: ''
env:
REGISTRY: ghcr.io
IMAGE_NAME: zhangtianyao1/teamspeak-music-bot
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: scripts/docker/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+177 -21
View File
@@ -23,13 +23,17 @@
## 功能特性
- **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节
- **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
- **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
- **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环
- **实时歌词同步** — 歌词滚动显示,支持翻译歌词,服务端帧计数精确同步
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云与 **QQ 音乐雷达推荐**(`!fm -q`)
- **音质选择** — 标准(128k) / 较高(192k) / 极高(320k) / 无损(FLAC) / Hi-Res / 超清母带
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
@@ -155,6 +159,70 @@ sudo ./scripts/install.sh
>
> **如何判断是否需要迁移**:如果你是全新安装,或者你的机器人数据库中 `identity` 字段已经是空的,则**无需任何操作**。完成上述步骤后,按下面对应的系统升级步骤执行即可。
### 从 WebUI 无鉴权版本升级(重要)
本次更新引入了**强制 WebUI 鉴权**。从无鉴权旧版本升级后,**WebUI 必须先创建管理员账号才能使用**。所有 `/api/*` 端点(除少量公共白名单)和 `/ws` 现在都需要登录。
**升级行为**:
- 启动时数据库自动迁移:新增 `users`、`sessions`、`user_audit` 三张表;旧的 `bot_instances`、`play_history` 数据**完全保留**。
- 第一次打开 WebUI 自动跳转到 `/first-run` 引导创建首位管理员(角色固定为 `admin`)。
- 之后访问任何页面都会校验登录态,未登录跳转 `/login`。
**会话与 Cookie**:
- 登录态保存 7 天,每次请求滚动续期(活跃用户不会被踢出)。
- 同一账号最多保持 10 个并发会话(超过自动剔除最旧的)。
- Cookie 设置为 `HttpOnly; SameSite=Lax`,HTTPS 部署需配合 `trustProxy: true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。
**多用户与角色**:
- 角色 `admin`:完整权限(用户管理、审计、机器人、音乐平台、播放控制)。
- 角色 `member`:除"用户管理"和"操作审计"外的所有功能(适合给团队成员开通播放权)。
- 在 **设置 → 用户管理**(仅管理员)中添加 / 删除 / 重置密码 / 切换角色。
- 至少保留一个管理员:系统会阻止删除或降级最后一位管理员。
**游客模式 / Guest mode**:
让访客**无需账号密码**即可进入 WebUI 点歌,同时严格限制其可用能力。该功能**默认关闭**,只有管理员能开启。
- **开启方式**:管理员在 **设置 → 游客模式** 打开「允许游客访问」(仅管理员可见此区块)。开启后登录页会出现 **「以游客身份进入」** 按钮,访客点击即可创建游客会话,无需任何凭据。关闭游客模式后,所有游客会话立即失效。
- **逐项权限(7 个开关,管理员配置)**:除「添加到队列末尾」外**全部默认关闭**,按需逐项放开。
| 开关 | 字段 | 默认 |
|------|------|------|
| 添加到队列末尾 | `addToQueue` | **开** |
| 添加到下一首 | `playNext` | 关 |
| 立即播放(不清空队列) | `playNow` | 关 |
| 跳过当前歌曲 | `skip` | 关 |
| 暂停/继续/进度/音量 | `transport` | 关 |
| 移除/清空队列 | `removeClear` | 关 |
| 切换播放模式 / FM | `playMode` | 关 |
- **按机器人授权(游客作用域)**:可选择「全部机器人」或指定一份机器人白名单。作用域之外的机器人对游客**不可见、不可控**。
- **游客始终被禁止**:查看或修改任何设置、管理机器人、设置音乐平台账号 / 凭据、修改音质,以及访问用户管理与操作审计。这些限制不受上面 7 个开关影响,**永远锁死**。
- **复现 issue #83 的「下一首 only」需求**:在 **设置 → 游客模式** 中关闭「添加到队列末尾」并打开「添加到下一首」,游客便只能把歌曲加到下一首播放。
**如何重置忘记的管理员密码**:
如果你忘记了管理员密码,可以直接编辑 SQLite 数据库 `data/tsmusicbot.db`:
```bash
# 方案 1:清空所有用户,重新进入 first-run 流程
sqlite3 data/tsmusicbot.db "DELETE FROM users; DELETE FROM sessions;"
# 然后重启机器人,浏览器再次访问会自动进入 /first-run
# 方案 2:把指定用户重置为已知密码(密码 'changeme-now' 的 bcrypt 哈希示例如下)
# 先用 node 生成哈希:
node -e "console.log(require('bcryptjs').hashSync('changeme-now', 12))"
# 把输出贴到 SQL 里:
sqlite3 data/tsmusicbot.db "UPDATE users SET passwordHash='<paste-hash-here>' WHERE username='你的用户名';"
```
**反向代理用户特别注意**:如果通过 nginx / Caddy / Cloudflare 暴露 WebUI,**必须**在 `config.json` 中设置 `"trustProxy": true`,否则 Cookie 不会带 `Secure` 标志,且登录限流会把所有用户合并到同一个桶。详见下方 [反向代理部署注意事项](#反向代理部署注意事项)。
**旧版 `config.adminPassword` / `adminGroups`**:这两个配置项在旧版本中预留但从未实际启用(TS-side admin 命令权限的占位字段)。保留以避免破坏旧 `config.json`,但不再影响任何行为。可以放心忽略。
### Windows 用户
```
@@ -221,21 +289,27 @@ sudo systemctl start tsmusicbot
### 首次配置
1. 打开 **http://localhost:3000/setup** 进入设置向导
2. 填写 TeamSpeak 服务器地址(默认端口:9987)
3. 设置机器人昵称
4. (可选)扫码登录网易云/QQ音乐账号以播放 VIP 歌曲
1. 启动机器人后打开 **http://localhost:3000/**
- 全新部署:自动跳转 `/first-run`,填写用户名(3-32 字符)和密码(≥8 位)创建首位**管理员**账号
- 之后所有 WebUI 操作都需要登录,登录态保持 7 天(活动会滚动续期)
2. 在 **设置 → 机器人管理** 中点击"创建新实例",填写:
- TeamSpeak 服务器地址(无端口,仅主机名,例如 `ts.example.com`)
- 端口(默认 9987,自托管或非标准端口请填写实际值)
- 机器人昵称
- 可选:服务器密码、默认频道
3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / B 站账号(可选,登录后可播放 VIP 歌曲)
4. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员。成员默认可控制播放但无法管理其他用户;管理员还可为每个成员单独配置**能力**(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)和**可操作的机器人白名单**,未授权的机器人对该成员不可见、不可控
### WebUI 页面说明
| 页面 | 功能 |
|------|------|
| **首页** | 推荐歌单、每日推荐、私人FM、我的歌单 |
| **搜索** | 三平台统一搜索,结果标注网易云/QQ/B站来源 |
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌) |
| **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达)、我的歌单、收藏的歌单 |
| **搜索** | 三平台统一搜索,结果标注网易云/QQ/B站来源,可一键收藏歌单 |
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 |
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
| **历史** | 播放历史记录 |
| **设置** | 主题切换、机器人管理、三平台账号登录、音质选择、命令前缀 |
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停) / 三平台账号登录 / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) |
### TeamSpeak 文字命令
@@ -243,22 +317,27 @@ sudo systemctl start tsmusicbot
| 命令 | 说明 |
|------|------|
| `!play <歌名>` | 搜索并播放 |
| `!play <歌名>` | 搜索并播放(取最热门的匹配项) |
| `!play -q <歌名>` | 从 QQ 音乐搜索 |
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
| `!add <歌名>` | 添加到播放队列 |
| `!search <歌名>` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲 |
| `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) |
| `!play id:<id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接) |
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:<id>` / 链接) |
| `!pause` / `!resume` | 暂停 / 恢复播放 |
| `!next` / `!prev` | 下一首 / 上一首 |
| `!stop` | 停止播放并清空队列 |
| `!vol <0-100>` | 设置音量 |
| `!queue` | 查看播放队列 |
| `!remove <位置>` | 从队列中删除指定位置的歌曲(位置从 1 开始,见 `!queue`) |
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) |
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
| `!album <ID>` | 加载专辑 |
| `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-b`/`-y`) |
| `!fm` | 私人 FM(网易云,自动续播) |
| `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) |
| `!lyrics` | 显示当前歌词 |
| `!now` | 当前播放信息 |
| `!vote` | 投票跳过当前歌曲 |
@@ -293,10 +372,12 @@ teamspeak-music-bot/
│ │ ├── commands.ts # 文字命令解析器(前缀、别名、权限)
│ │ ├── instance.ts # Bot 实例(绑定 TS3 + 播放器 + 音源)
│ │ ├── manager.ts # 多实例生命周期管理
│ │ ├── auto-pause.ts # 频道无人自动暂停/恢复的决策逻辑
│ │ └── profile.ts # 机器人形象管理(头像/昵称/描述/Away/频道描述)
│ ├── data/ # 数据层
│ │ ├── config.ts # JSON 配置文件
│ │ └── database.ts # SQLite 数据库(播放历史、实例持久化)
│ │ ├── config.ts # JSON 配置文件(持久化到 data/config.json)
│ │ ├── permissions.ts # 细粒度能力 + 按机器人授权白名单
│ │ └── database.ts # SQLite 数据库(播放历史、实例、收藏、权限持久化)
│ ├── music/ # 音源服务
│ │ ├── provider.ts # 统一 MusicProvider 接口
│ │ ├── netease.ts # 网易云音乐适配器
@@ -313,10 +394,13 @@ teamspeak-music-bot/
│ ├── web/ # Web 后端
│ │ ├── server.ts # Express + WebSocket 服务
│ │ ├── websocket.ts # 实时状态广播
│ │ ├── middleware/ # requireAuth / requireAdmin / requirePermission / CSRF
│ │ └── api/ # REST API 路由
│ │ ├── bot.ts # 机器人管理 CRUD
│ │ ├── music.ts # 搜索/歌单/歌词/音质
│ │ ├── player.ts # 播放控制/队列/历史/跳转
│ │ ├── player.ts # 播放控制/队列/历史/跳转/FM
│ │ ├── favorites.ts # 本地收藏歌单 CRUD
│ │ ├── users.ts # 用户管理 + 成员权限
│ │ └── auth.ts # QR登录/Cookie/SMS
│ └── index.ts # 入口(启动所有服务)
├── web/src/ # 前端源码 (Vue 3)
@@ -332,11 +416,11 @@ teamspeak-music-bot/
│ └── docker/ # Docker 部署文件
│ ├── Dockerfile
│ └── docker-compose.yml
├── data/ # 运行时数据(自动创建,不上传)
│ ├── tsmusicbot.db # SQLite 数据库
│ ├── cookies/ # 登录 Cookie
│ └── logs/ # 日志文件
└── config.json # 配置文件(首次运行自动生成,不上传)
└── data/ # 运行时数据(自动创建,不上传)
├── config.json # 配置文件(首次运行自动生成,可手动编辑)
├── tsmusicbot.db # SQLite 数据库
├── cookies/ # 登录 Cookie
└── logs/ # 日志文件
```
## 技术栈
@@ -407,7 +491,7 @@ pip install -U yt-dlp
## 配置文件
`config.json` 在首次运行时自动生成,可手动编辑:
配置文件位于 **`data/config.json`**(与数据库、Cookie、日志同在持久化的 `data/` 目录,Docker 部署对应挂载卷),首次运行时自动生成,可手动编辑:
```json
{
@@ -421,10 +505,27 @@ pip install -U yt-dlp
"adminPassword": "",
"adminGroups": [],
"autoReturnDelay": 300,
"autoPauseOnEmpty": true
"autoPauseOnEmpty": false,
"idleTimeoutMinutes": 0,
"publicUrl": "",
"trustProxy": false
}
```
> **配置文件位置变更**:旧版本把 `config.json` 写在项目根目录(不在 Docker 挂载卷内,导致重启丢失、手动编辑不生效)。现在统一放在 `data/config.json`。升级时若检测到根目录存在旧的 `config.json`,会在首次启动时自动迁移到 `data/` 并保留你的设置,无需手动操作。
> **关于 `adminPassword` 和 `adminGroups`**:这两个字段保留是为了兼容旧 `config.json`,但当前版本未使用。WebUI 鉴权改为基于数据库的用户账号系统(见 [首次配置](#首次配置)),无需在 `config.json` 中设置密码。
### 反向代理部署注意事项
当 WebUI 部署在反向代理(nginx / Caddy / Cloudflare 等)之后时,请务必在 `config.json` 中设置 `"trustProxy": true`:
- **Cookie Secure 标志**:未启用 `trustProxy` 时,Express 无法从 `X-Forwarded-Proto` 正确判断请求实际是否为 HTTPS,会话 cookie 不会被标记为 `Secure`。
- **登录限流**:登录限流以 `req.ip` 为键,未启用 `trustProxy` 时所有请求都会被识别为代理本身的 IP,单个攻击者会拖累所有合法用户共用同一个限流桶。
- **审计日志的客户端 IP**(如果未来添加该字段)也需要 `trustProxy` 才能正确记录。
直接暴露端口(无代理)时无需启用该选项。
## 常见问题
**Q:支持 TeamSpeak 6 Server 吗?**
@@ -436,6 +537,9 @@ A:确保机器人和你在同一个频道。检查音量(`!vol 75`)。部
**Q:提示"无法获取播放链接"?**
A:在设置页面扫码登录音乐账号。许多歌曲需要登录后才能播放。
**Q:同名歌曲 `!play` 只能播到最热门的那首,怎么播放指定的版本?**
A:`!play <歌名>` 默认取最热门的匹配项。要播放同名的另一首,有三种方式:(1) 先 `!search <歌名>` 列出带序号的结果,再 `!play #序号` 选择;(2) `!play id:<歌曲id>` 按 id 精确播放;(3) 直接粘贴歌曲链接,如 `!play https://music.163.com/song?id=442867526`(也支持 QQ / B站 链接)。在 WebUI 中则可直接在搜索结果列表里点选任意同名歌曲。
**Q:如何更换机器人所在频道?**
A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指定默认频道。
@@ -464,6 +568,30 @@ A:YouTube 是可选音源,需要手动安装 `yt-dlp`。详见 [可选:You
**Q:如何更新到新版本?**
A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm start` 重新构建启动。Docker 用户执行 `docker-compose up -d --build`。
**Q:忘记管理员密码怎么办?**
A:直接操作 SQLite 数据库。最简单的办法是清空 `users` 表然后重新进入 first-run 流程:`sqlite3 data/tsmusicbot.db "DELETE FROM users; DELETE FROM sessions;"`,重启后浏览器会自动跳转 `/first-run` 让你重新创建管理员。详细方法见 [从 WebUI 无鉴权版本升级](#从-webui-无鉴权版本升级重要)。
**Q:成员(member)能做什么?不能做什么?**
A:成员默认可以:管理机器人(启动/停止/创建/编辑)、控制播放(搜索/播放/队列)、登录音乐平台账号、修改自己的密码。成员**始终不能**:管理其他用户、查看操作审计日志、降级或删除管理员。此外管理员可在 **设置 → 用户管理** 为每个成员单独**收紧权限**:勾选允许的能力(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)以及可操作的机器人白名单——未授权的能力会返回 403,未授权的机器人对该成员不可见也不可控。管理员不受任何限制。
**Q:收藏的歌单存在哪里?其他用户能看到吗?**
A:收藏按用户存储在本地 SQLite 数据库(`favorite_playlists` 表),仅本人可见,登录后跨设备同步。在首页、搜索结果或歌单页点击收藏图标即可增删。
**Q:什么是"专属链接"?怎么用?**
A:通过 `/bot/<机器人ID>` 打开 WebUI 会把界面锁定到该机器人(顶部显示"专属模式",刷新后保持),适合把单台机器人的控制页分享给特定用户。点击"退出"可返回多机器人视图。注意:专属链接只是 UI 层的锁定,真正的访问控制由成员权限(机器人白名单)在后端强制。
**Q:机器人播放时突然自动暂停了?**
A:这是"频道无人时自动暂停"功能:当机器人所在频道没有其他人时会自动暂停,有人加入后自动恢复,避免空播。该功能**默认关闭**,仅在你于 **设置 → 行为设置** 开启后生效;如需停用,在同一页面关闭即可。(占用检测依赖 TeamSpeak 的 `clientlist` 命令,部分服务器在频道有其他人时可能查询失败——此时机器人会按"占用情况未知"处理,不会误暂停。)
**Q:如何把某个用户从成员升级为管理员?**
A:管理员登录后进入 **设置 → 用户管理**,点击对应用户的"提升管理员"按钮即可。降级同理("降为成员"按钮)。系统会阻止降级最后一位管理员。
**Q:登录之后多久会自动退出?**
A:登录态有效期 7 天,活跃使用会滚动续期(每次受保护请求都会刷新过期时间)。同一账号最多保持 10 个并发会话(多设备登录时超过的会自动剔除最旧的会话)。
**Q:部署到公网后如何防止暴力登录?**
A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部署建议同时在反向代理(nginx `limit_req` / Caddy 等)层加一层限流,并启用 HTTPS。反向代理部署务必设置 `"trustProxy": true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。
## 参与贡献
1. Fork 本仓库
@@ -478,6 +606,34 @@ A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm
### 最新版本
**功能增强:细粒度权限 / 本地收藏 / 专属链接 / 自动暂停 / QQ 雷达 FM**
- **细粒度账号权限**(叠加在 admin / member 之上):管理员可为每个成员勾选 5 项能力(`player.control` / `player.queue` / `bot.manage` / `platform.auth` / `quality`)和按机器人授权白名单;所有变更路由由后端 `requirePermission` / `requireBotAccess` 中间件逐请求强制校验,未授权返回 403,未授权的机器人对成员不可见(列表过滤,无 403-vs-404 枚举泄漏)。已有成员经一次性迁移获得全部能力,新成员默认基础能力。
- **本地收藏歌单**:按用户存储的收藏(`favorite_playlists` 表 + `/api/favorites`),首页 / 搜索 / 歌单页一键收藏,跨设备同步。
- **专属链接(单机器人锁定)**:`/bot/<id>` 打开时锁定到单台机器人,`?bot=<id>` 随刷新保持;与权限白名单组合,机器人下拉只显示"作用域 ∩ 可控"的机器人。
- **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认关闭)。占用检测在 `clientlist` 查询失败时按"未知"处理而非"无人",避免有人在听时被误暂停。
- **QQ 音乐雷达 / 私人 FM**:`!fm -q` 或 WebUI 启动 QQ 雷达推荐流(失败回退"猜你喜欢"),FM 自动续播现支持任意平台。
**Bug 修复**
- **#86 config.json 未在首次运行生成**:配置文件改放到持久化的 `data/config.json`(旧版写在项目根目录,不在 Docker 卷内,导致重启丢失、手动编辑不生效);升级时自动把根目录旧配置迁移到 `data/` 并保留你的设置。
- **#89 B站长音频约 16 分钟被暂停且无法继续**:ffmpeg 增加 `-reconnect_at_eof`(B站 CDN 会在 token/会话到期时提前关闭连接造成 EOF),并新增"远离结尾的卡死看门狗"——彻底卡死的流会自动推进到下一首而不是永久静音。
- **#84 音量曲线不顺滑**:0–100 改为连续单调曲线 `0.2x + 0.8x^8`(消除 80–99 的"死区"与 100 处的突跳,满响度仍保留在 100)。
**WebUI 鉴权与权限系统**
- **首次运行强制创建管理员账号**:浏览器打开 WebUI 自动跳转 `/first-run`;之后所有 `/api/*`(除少量公共白名单:`/api/health`、`/api/config/public-url`、`/api/session/*`)和 `/ws` 都需要登录。详见 [更新升级 → 从 WebUI 无鉴权版本升级](#从-webui-无鉴权版本升级重要)。
- **两种角色:admin / member**。`member` 可以管理机器人、控制播放、登录音乐平台账号、修改自己密码,但不能管理其他用户或查看审计日志。`admin` 拥有全部权限。
- **用户管理 UI**:管理员在 设置 → 用户管理 可以增删用户、切换角色、重置密码。系统强制保留至少一位管理员。
- **操作审计日志**:管理员在 设置 → 操作审计 可以查看用户管理相关事件(创建、删除、密码重置、角色变更、首位管理员创建、自助修改密码)。
- **自助修改密码**:所有用户都可在 设置 → 账户 修改自己密码。
- **会话存储**:服务端 SQLite 表 `sessions`,存储 sha256(token);浏览器只持有原始 token cookie。7 天 TTL,每小时滚动续期。同账号最多 10 个并发会话(超出剔除最旧)。
- **登录限流**:每 IP 每分钟 5 次 `/login` + 3 次 `/setup`,命中返回 429 + `Retry-After`。
- **CSRF & 安全头**:所有 mutating 请求强制 `Origin`/`Referer` 同源;响应携带 `X-Frame-Options: DENY` 和 `Content-Security-Policy: frame-ancestors 'none'`(防点击劫持)。
- **配置变更**:反向代理部署务必 `"trustProxy": true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。`config.adminPassword` / `adminGroups` 字段保留以兼容旧 `config.json`,但不再影响任何行为。
### v0.x — Bot Profile 自动更新与协议层升级
**机器人形象自动更新(Bot Profile)**
- **播放时自动更新 TS 形象**:头像(专辑封面缩略图)、昵称(`♪ 歌名 - 歌手 - 原昵称`)、描述(歌曲信息)、Away 状态、频道描述、"正在播放"频道消息,全部随歌曲切换自动更新。
@@ -0,0 +1,520 @@
# Album Search & Playback Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Surface albums in search results and allow playing the whole album from the web UI. Currently `SearchResult.albums` is always `[]` and Search.vue only renders songs.
**Architecture:** Extend `search()` in netease + qq providers to populate `albums`. Aggregate them in `/search/all`. Add a new "专辑" (and "歌单") section to Search.vue. Reuse `Playlist.vue` as the album detail page by branching on `route.meta.kind` between `/playlist/:id` and `/album/:id` endpoints. The existing `getAlbumSongs(id)` and `/api/music/album/:id` endpoint already work.
**Tech Stack:** Node 20 + TS, Express 5, Vue 3 + Vue Router 4, axios. No new deps.
---
## Spec Reference
`docs/superpowers/specs/2026-05-07-custom-avatar-and-album-search-design.md` — section "专辑搜索".
## File Structure
| File | Action | Responsibility |
|---|---|---|
| `src/music/provider.ts` | Read-only | Verify `Album` and `SearchResult.albums` shape (no change expected) |
| `src/music/netease.ts` | Modify | `search()` adds a third parallel call (`type=10`) and maps albums |
| `src/music/qq.ts` | Modify | `search()` adds `req_album` section and maps albums |
| `src/music/netease.test.ts` | Modify | New tests for albums in search response |
| `src/music/qq.test.ts` | Modify (or create if absent) | Tests for albums in qq search |
| `src/web/api/music.ts` | Modify | `/search/all` returns `{songs, albums, playlists}` |
| `web/src/views/Search.vue` | Modify | Render albums + playlists sections |
| `web/src/views/Playlist.vue` | Modify | Branch endpoint by `route.meta.kind === 'album'` |
| `web/src/router/index.ts` | Modify | Add `/album/:id` route reusing Playlist component, set `meta.kind = 'album'` |
## Conventions
- TDD throughout. Each task: failing test → implement → verify → commit.
- Mock HTTP via existing fixtures pattern (look at `src/music/netease.test.ts` for setup).
- Keep all platform-specific quirks inside the provider class — no leaking into Search.vue logic.
---
### Task 1: netease.ts — fetch albums in search
**Files:**
- Modify: `src/music/netease.ts`
- Modify: `src/music/netease.test.ts`
- [ ] **Step 1: Read the existing test setup so we mock the same way**
```bash
grep -n 'cloudsearch\|MockAdapter\|axios.create\|mock\|nock\|fixture' src/music/netease.test.ts | head -20
```
- [ ] **Step 2: Write the failing test**
Append to `src/music/netease.test.ts` inside the existing `describe`:
```ts
it("populates SearchResult.albums from cloudsearch type=10", async () => {
// Adjust the fixture/mock helper to your existing test pattern.
// The test should: arrange a mock that returns a non-empty albums array
// for type=10, run search(), assert result.albums has the expected shape.
mockApi.onGet("/cloudsearch", { params: expect.objectContaining({ type: 10 }) }).reply(200, {
result: {
albums: [
{ id: 42, name: "Album A", picUrl: "https://x/p.jpg", artists: [{ name: "Artist X" }] },
],
},
});
mockApi.onGet("/cloudsearch", { params: expect.objectContaining({ type: 1 }) }).reply(200, { result: { songs: [] } });
mockApi.onGet("/cloudsearch", { params: expect.objectContaining({ type: 1000 }) }).reply(200, { result: { playlists: [] } });
const provider = makeProvider();
const r = await provider.search("foo", 5);
expect(r.albums).toEqual([
{ id: "42", name: "Album A", artist: "Artist X", coverUrl: "https://x/p.jpg", platform: "netease" },
]);
});
```
If the existing tests use a different mock library (e.g. `msw` or manual axios stubbing), translate the fixture above to match. Do not introduce new test deps.
- [ ] **Step 3: Run test to verify it fails**
Run: `npx vitest run src/music/netease.test.ts`
Expected: FAIL — `result.albums` is `[]`
- [ ] **Step 4: Implement the change**
In `src/music/netease.ts` `search()` (~line 93), change the `Promise.all` from 2 to 3 calls:
```ts
const [songRes, playlistRes, albumRes] = await Promise.all([
this.api.get("/cloudsearch", { params: { keywords: query, type: 1, limit, ...this.cookieParams } }),
this.api.get("/cloudsearch", { params: { keywords: query, type: 1000, limit: 5, ...this.cookieParams } }),
this.api.get("/cloudsearch", { params: { keywords: query, type: 10, limit: 5, ...this.cookieParams } }),
]);
```
After the existing `playlists: Playlist[] = ...` mapping, add:
```ts
const albums: Album[] = (albumRes.data?.result?.albums ?? []).map((a: any) => ({
id: String(a.id),
name: a.name ?? "",
artist: (a.artists ?? []).map((x: any) => x.name).join(" / "),
coverUrl: a.picUrl ?? "",
platform: "netease",
}));
```
Update the `return { songs, playlists, albums: [] }` to `return { songs, playlists, albums }`.
(Make sure `Album` is imported from `./provider.js`; if not yet imported, add it to the existing import.)
- [ ] **Step 5: Run test to verify it passes**
Run: `npx vitest run src/music/netease.test.ts`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add src/music/netease.ts src/music/netease.test.ts
git commit -m "feat(netease): include albums in search results"
```
---
### Task 2: qq.ts — fetch albums in search
**Files:**
- Modify: `src/music/qq.ts`
- Modify or Create: `src/music/qq.test.ts`
- [ ] **Step 1: Verify whether qq.test.ts exists**
```bash
ls src/music/qq.test.ts
```
If absent, create a minimal one mirroring `netease.test.ts` style: instantiate provider, mock the `qqDirectApi` axios instance, assert `r.albums.length > 0` after a `search()` call.
- [ ] **Step 2: Write the failing test**
Add to `src/music/qq.test.ts`:
```ts
it("populates SearchResult.albums from a parallel album search request", async () => {
// Mock returns an album list under req_album.data.body.album.list
mockApi.onGet("/cgi-bin/musicu.fcg").reply((cfg) => {
const data = JSON.parse(cfg.params?.data ?? "{}");
if (data.req_album) {
return [200, { req_album: { data: { body: { album: { list: [
{ albumMID: "abc", albumName: "Aero", singerName: "S", albumPic: "https://x/p.jpg" },
] } } } } }];
}
if (data.req_0) {
return [200, { req_0: { data: { body: { song: { list: [] } } } } }];
}
return [200, {}];
});
const provider = makeProvider();
const r = await provider.search("foo", 5);
expect(r.albums).toEqual([
{ id: "abc", name: "Aero", artist: "S", coverUrl: expect.stringContaining("https://"), platform: "qq" },
]);
});
```
Verify the actual QQ API response shape against a real call before finalizing the field names — `albumMID` vs `mid`, `albumPic` vs `pic`, etc. If unsure, log a real response once and freeze the shape in the fixture.
- [ ] **Step 3: Run test to verify it fails**
Run: `npx vitest run src/music/qq.test.ts`
Expected: FAIL — `r.albums` is `[]`
- [ ] **Step 4: Implement the change**
In `src/music/qq.ts` `search()` (~line 63), change `reqData` to include both `req_0` (songs) and `req_album` (albums):
```ts
const reqData = JSON.stringify({
req_0: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { searchid: "1", query, num_per_page: Math.min(limit, 50), search_type: 0 },
},
req_album: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { searchid: "1", query, num_per_page: 5, search_type: 8 },
},
});
```
After the existing `songs` mapping, add:
```ts
const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
const albums: Album[] = albumList.map((a: any) => ({
id: String(a.albumMID ?? a.mid ?? a.albumID ?? ""),
name: a.albumName ?? a.title ?? "",
artist: a.singerName ?? (a.singer ?? []).map((s: any) => s.name).join(" / "),
coverUrl: a.albumMID
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${a.albumMID}.jpg`
: (a.albumPic ?? ""),
platform: "qq",
}));
```
Change `return { songs, playlists: [], albums: [] }` to `return { songs, playlists: [], albums }`.
- [ ] **Step 5: Run test to verify it passes**
Run: `npx vitest run src/music/qq.test.ts`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add src/music/qq.ts src/music/qq.test.ts
git commit -m "feat(qq): include albums in search results"
```
---
### Task 3: /search/all — aggregate albums + playlists
**Files:**
- Modify: `src/web/api/music.ts`
- [ ] **Step 1: Look at the current aggregation**
In `src/web/api/music.ts` near line 40 the `/search/all` handler builds only `songs`. Extend it.
- [ ] **Step 2: Write a failing integration test (if test infra allows)**
If there's already a test file for music.ts, add a test that mocks the providers and asserts `res.body.albums.length > 0`. If not, skip and rely on Task 1+2 unit coverage + manual verification in Task 4.
- [ ] **Step 3: Aggregate albums + playlists**
Replace the existing `songs = ...` block + `res.json({ songs })` at lines ~54–62 with:
```ts
const songs = [
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []),
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
];
const albums = [
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
...(qqResult.status === "fulfilled" ? qqResult.value.albums : []),
];
const playlists = [
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []),
...(qqResult.status === "fulfilled" ? qqResult.value.playlists : []),
];
res.json({ songs, albums, playlists });
```
(Bilibili intentionally skipped for albums/playlists — no album concept; playlists likewise minor.)
- [ ] **Step 4: Verify by curl**
Build + run, then:
```bash
curl -s 'http://localhost:3000/api/music/search/all?q=Beyond' \
| python3 -c 'import json,sys;d=json.load(sys.stdin);print({k: len(v) for k, v in d.items()})'
```
Expected: `{'songs': N>0, 'albums': N>0, 'playlists': N>=0}`
- [ ] **Step 5: Commit**
```bash
git add src/web/api/music.ts
git commit -m "feat(api): /search/all returns albums and playlists"
```
---
### Task 4: Album route reusing Playlist.vue
**Files:**
- Modify: `web/src/router/index.ts`
- Modify: `web/src/views/Playlist.vue`
- [ ] **Step 1: Look at the current router config and Playlist load logic**
```bash
grep -n "path:\|component:\|meta" web/src/router/index.ts
grep -n "loadPlaylist\|/api/music/playlist\|onMounted" web/src/views/Playlist.vue
```
- [ ] **Step 2: Add /album/:id route**
In `web/src/router/index.ts`, find the `/playlist/:id` route entry. Right after it, add:
```ts
{
path: '/album/:id',
component: () => import('../views/Playlist.vue'),
meta: { kind: 'album' },
},
```
(If `/playlist/:id` is `meta:`-less, also add `meta: { kind: 'playlist' }` to it for symmetry.)
- [ ] **Step 3: Branch the endpoint inside Playlist.vue**
Find the load function (probably `onMounted(async () => { axios.get('/api/music/playlist/' + id, ...) })`). Refactor:
```ts
const route = useRoute();
const kind = (route.meta.kind as string) ?? 'playlist'; // 'playlist' | 'album'
const endpoint = kind === 'album' ? '/api/music/album/' : '/api/music/playlist/';
// ... use `${endpoint}${route.params.id}` ...
```
For the hero metadata, the playlist endpoint returns `{songs}` only (no top-level cover/title) — verify what the Album endpoint currently returns. If both only return `{songs}`, the existing Playlist.vue must already derive the cover from somewhere (probably the first song's coverUrl, or an additional `/api/music/playlist/:id/detail` call). Keep the existing pattern; if a separate detail call is needed for albums, fetch the metadata from `/api/music/song/<firstSong.id>` to get the album name + cover, OR add a thin `/api/music/album/:id/detail` endpoint that returns `{ name, coverUrl, description }`.
**Decision:** if Playlist.vue currently uses ONLY `/api/music/playlist/:id` and derives metadata from songs, do the same for albums (no new endpoint). If it calls a separate detail endpoint, add a matching `/api/music/album/:id/detail` returning `{ name, coverUrl }` from the first song's `album` and `coverUrl` fields.
- [ ] **Step 4: Verify in browser**
Run `cd web && npm run dev`. Visit `/album/<some-netease-album-id>` (pick one from a search). Expect: hero header + song list + play-all button — same UX as a playlist page.
- [ ] **Step 5: Commit**
```bash
git add web/src/router/index.ts web/src/views/Playlist.vue
git commit -m "feat(web): /album/:id route reusing Playlist view"
```
---
### Task 5: Search.vue — render albums + playlists sections
**Files:**
- Modify: `web/src/views/Search.vue`
- [ ] **Step 1: Read current Search.vue**
```bash
sed -n '1,120p' web/src/views/Search.vue
```
Identify: the `results.value = res.data.songs` line and the `<div v-else-if="results.length > 0">` block.
- [ ] **Step 2: Refactor to three result lists**
Replace the script:
```ts
import type { Song } from '../stores/player.js';
interface Album { id: string; name: string; artist: string; coverUrl: string; platform: string; }
interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
const songs = ref<Song[]>([]);
const albums = ref<Album[]>([]);
const playlists = ref<Playlist[]>([]);
const loading = ref(false);
const searched = ref(false);
async function doSearch() {
if (!query.value.trim()) return;
loading.value = true;
searched.value = true;
try {
const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
songs.value = res.data.songs ?? [];
albums.value = res.data.albums ?? [];
playlists.value = res.data.playlists ?? [];
} catch {
songs.value = []; albums.value = []; playlists.value = [];
} finally {
loading.value = false;
}
}
```
- [ ] **Step 3: Render the sections**
Replace the existing `<div v-else-if="results.length > 0" class="results">` block:
```vue
<template v-else-if="songs.length || albums.length || playlists.length">
<section v-if="albums.length" class="result-section">
<h2 class="section-title">专辑</h2>
<div class="card-grid">
<router-link
v-for="al in albums"
:key="`${al.platform}-${al.id}`"
:to="`/album/${al.id}?platform=${al.platform}`"
class="card hover-scale"
>
<CoverArt :url="al.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="card-name">{{ al.name }}</div>
<div class="card-sub">{{ al.artist }}</div>
</router-link>
</div>
</section>
<section v-if="playlists.length" class="result-section">
<h2 class="section-title">歌单</h2>
<div class="card-grid">
<router-link
v-for="pl in playlists"
:key="`${pl.platform}-${pl.id}`"
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
class="card hover-scale"
>
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="card-name">{{ pl.name }}</div>
</router-link>
</div>
</section>
<section v-if="songs.length" class="result-section">
<h2 class="section-title">单曲</h2>
<SongCard
v-for="(song, i) in songs"
:key="`${song.platform}-${song.id}`"
:song="song"
:index="i + 1"
:active="store.currentSong?.id === song.id"
@play="store.playSong(song)"
@playNext="store.playNextSong(song)"
@add="store.addSong(song)"
/>
</section>
</template>
<div v-else-if="searched" class="empty">未找到相关结果</div>
```
(Import `CoverArt`: `import CoverArt from '../components/CoverArt.vue';`.)
- [ ] **Step 4: Add minimal styles**
Append to the `<style lang="scss" scoped>` block:
```scss
.result-section {
margin-bottom: 32px;
.section-title { font-size: 18px; margin: 0 0 12px; opacity: 0.85; }
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
}
.card {
display: flex;
flex-direction: column;
gap: 6px;
text-decoration: none;
color: inherit;
.card-name { font-size: 14px; line-height: 1.3; max-height: 2.6em; overflow: hidden; }
.card-sub { font-size: 12px; opacity: 0.6; }
}
```
- [ ] **Step 5: Build + visually verify**
```bash
cd web && npm run build
```
Then `npm run dev` → search "周杰伦" → see three sections; click an album card → arrives at `/album/:id` with songs + play-all.
- [ ] **Step 6: Commit**
```bash
git add web/src/views/Search.vue
git commit -m "feat(web): show album + playlist sections in search"
```
---
### Task 6: Open PR
- [ ] **Step 1: Push branch**
```bash
git checkout -b feat/album-search
git push -u origin feat/album-search
```
- [ ] **Step 2: Create the PR**
```bash
gh pr create --title "feat(search): album section + album playback" --body "Closes part of #51 (album half).
## Summary
- netease.search() / qq.search() now populate SearchResult.albums
- /api/music/search/all returns albums + playlists alongside songs
- Search.vue renders three sections: 专辑 / 歌单 / 单曲
- /album/:id route reuses Playlist.vue with meta.kind='album'
- bilibili / youtube intentionally still return albums:[] (no album API)
## Test plan
- [x] vitest covers netease + qq search returning non-empty albums
- [x] curl /search/all?q=周杰伦 returns {songs, albums, playlists}
- [x] Manual: search → click album card → /album/:id → play all
🤖 Generated with [Claude Code](https://claude.com/claude-code)"
```
---
## Self-Review Checklist
- [x] Spec coverage: backend album search → Tasks 1+2; aggregator → Task 3; album detail route → Task 4; UI sections → Task 5
- [x] No "TBD"/placeholder text — every step shows the actual diff or command
- [x] Type names consistent: `Album` (capital A), `albums` (lowercase plural), `SearchResult.albums`
- [x] Bilibili/YouTube explicitly out of scope per spec — confirmed in Task 3 by skipping them in albums aggregation
- [x] Routes use `meta.kind` — same key referenced in Playlist.vue (Task 4) and `/album/:id` registration (Task 4 Step 2)
@@ -0,0 +1,903 @@
# Custom Bot Avatar Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let users upload a fixed avatar per bot. When `avatarEnabled=true`, the avatar follows the song cover during playback and reverts to the custom avatar (instead of clearing) on stop. When `avatarEnabled=false` and a custom avatar exists, the bot always shows the custom avatar.
**Architecture:** New SQLite column stores a relative file path; bytes live on disk under `data/avatars/<botId>.<ext>` (mirrors `data/cookies/`). `BotProfileManager` gains a `customAvatar` Buffer; the existing `clearAvatar()` becomes "restore custom or clear"; `onConnect()` immediately applies the custom avatar when sync is off. Three new REST endpoints (GET/PUT/DELETE) under `/api/bot/:id/avatar` accept base64 JSON (avoids adding multer; bump `express.json()` limit).
**Tech Stack:** Node 20 + TS + Express 5, better-sqlite3, Vue 3 + axios. No new runtime deps.
---
## Spec Reference
`docs/superpowers/specs/2026-05-07-custom-avatar-and-album-search-design.md` — section "自定义头像".
## File Structure
| File | Action | Responsibility |
|---|---|---|
| `src/data/database.ts` | Modify | Add `custom_avatar_path` column + migration + accessor methods |
| `src/data/avatars.ts` | **Create** | Read/write/delete avatar files under `data/avatars/` |
| `src/bot/profile.ts` | Modify | `customAvatar` field, `setCustomAvatar`, `applyIdleAvatar`, modify `clearAvatar`, modify `onConnect` |
| `src/bot/instance.ts` | Modify | Load custom avatar on start, pass to ProfileManager |
| `src/web/api/bot.ts` | Modify | Add GET/PUT/DELETE `/avatar` endpoints |
| `src/web/server.ts` | Modify | Bump `express.json()` limit to `400kb` |
| `src/index.ts` | Modify | Pass `AVATAR_DIR` to bot manager / API router |
| `src/data/database.test.ts` | Modify | Test custom avatar path persistence + migration idempotency |
| `src/data/avatars.test.ts` | **Create** | Unit tests for avatar store |
| `src/bot/profile.test.ts` | **Create** | Tests for new precedence logic with a mock TS3Client |
| `web/src/components/AvatarUpload.vue` | **Create** | Reusable avatar picker + preview + delete |
| `web/src/views/Settings.vue` | Modify | Add custom avatar row in profile features list; insert into create-bot and edit-bot forms |
## Conventions
- TDD: failing test → implement → verify → commit, every step.
- Commits use conventional format: `feat(profile):`, `feat(api):`, `feat(web):`, `test(...)`. Each task ends with one commit.
- Tests live in vitest (`npm test`).
- All paths absolute or relative to repo root.
---
### Task 1: DB migration + getter/setter for custom avatar path
**Files:**
- Modify: `src/data/database.ts`
- Modify: `src/data/database.test.ts`
- [ ] **Step 1: Write the failing test**
Add to `src/data/database.test.ts` after the existing tests (find the closing `});` of the last test case in the `describe` block, insert before it):
```ts
it("persists and clears customAvatarPath on a bot instance", () => {
const inst = {
id: "bot-1",
name: "B",
serverAddress: "x",
serverPort: 9987,
nickname: "n",
defaultChannel: "",
channelPassword: "",
autoStart: false,
serverProtocol: "",
ts6ApiKey: "",
serverPassword: "",
};
botDb.saveBotInstance(inst);
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull();
botDb.setCustomAvatarPath("bot-1", "avatars/bot-1.png");
expect(botDb.getCustomAvatarPath("bot-1")).toBe("avatars/bot-1.png");
botDb.setCustomAvatarPath("bot-1", null);
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull();
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/data/database.test.ts`
Expected: FAIL — `botDb.getCustomAvatarPath is not a function`
- [ ] **Step 3: Add the column to migration + interface + statements**
In `src/data/database.ts`:
1. Find `BotDatabase` interface (~line 54), add two methods before `close()`:
```ts
getCustomAvatarPath(botId: string): string | null;
setCustomAvatarPath(botId: string, path: string | null): void;
```
2. Find `migrateSchema()` (~line 66). After the `for (const col of profileCols)` loop, append:
```ts
if (!names.includes("custom_avatar_path")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN custom_avatar_path TEXT");
}
```
3. In `createDatabase()` after the existing `prepare(...)` calls (~line 180), add:
```ts
const selectCustomAvatar = db.prepare(
`SELECT custom_avatar_path FROM bot_instances WHERE id = ?`,
);
const updateCustomAvatar = db.prepare(
`UPDATE bot_instances SET custom_avatar_path = ? WHERE id = ?`,
);
```
4. Inside the returned object, add (before `close()`):
```ts
getCustomAvatarPath(botId) {
const row = selectCustomAvatar.get(botId) as { custom_avatar_path: string | null } | undefined;
return row?.custom_avatar_path ?? null;
},
setCustomAvatarPath(botId, path) {
updateCustomAvatar.run(path, botId);
},
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run src/data/database.test.ts`
Expected: PASS — all tests including the new one
- [ ] **Step 5: Commit**
```bash
git add src/data/database.ts src/data/database.test.ts
git commit -m "feat(db): custom_avatar_path column + accessors"
```
---
### Task 2: Avatar storage helper
**Files:**
- Create: `src/data/avatars.ts`
- Create: `src/data/avatars.test.ts`
- [ ] **Step 1: Write the failing test**
Create `src/data/avatars.test.ts`:
```ts
import { describe, it, expect, beforeEach } from "vitest";
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createAvatarStore } from "./avatars.js";
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "avatar-test-"));
});
describe("createAvatarStore", () => {
it("write returns a relative path under the store dir", () => {
const store = createAvatarStore(dir);
const buf = Buffer.from("fake-png");
const rel = store.write("bot-1", "image/png", buf);
expect(rel).toBe("bot-1.png");
expect(readFileSync(join(dir, "bot-1.png")).equals(buf)).toBe(true);
});
it("write picks correct extension for jpeg / webp", () => {
const store = createAvatarStore(dir);
expect(store.write("a", "image/jpeg", Buffer.from(""))).toBe("a.jpg");
expect(store.write("b", "image/webp", Buffer.from(""))).toBe("b.webp");
});
it("write rejects unsupported MIME types", () => {
const store = createAvatarStore(dir);
expect(() => store.write("c", "image/gif", Buffer.from(""))).toThrow(
/unsupported/i,
);
});
it("read returns the bytes for an existing file", () => {
const store = createAvatarStore(dir);
store.write("bot-1", "image/png", Buffer.from("hello"));
const buf = store.read("bot-1.png");
expect(buf?.equals(Buffer.from("hello"))).toBe(true);
});
it("read returns null when path is missing", () => {
const store = createAvatarStore(dir);
expect(store.read("missing.png")).toBeNull();
});
it("remove deletes the file (idempotent)", () => {
const store = createAvatarStore(dir);
store.write("bot-1", "image/png", Buffer.from("x"));
store.remove("bot-1.png");
expect(existsSync(join(dir, "bot-1.png"))).toBe(false);
expect(() => store.remove("bot-1.png")).not.toThrow();
});
it("write replaces any existing file for the same botId regardless of old extension", () => {
const store = createAvatarStore(dir);
store.write("bot-1", "image/png", Buffer.from("old"));
const rel = store.write("bot-1", "image/jpeg", Buffer.from("new"));
expect(rel).toBe("bot-1.jpg");
expect(existsSync(join(dir, "bot-1.png"))).toBe(false);
expect(existsSync(join(dir, "bot-1.jpg"))).toBe(true);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/data/avatars.test.ts`
Expected: FAIL — module not found
- [ ] **Step 3: Implement the store**
Create `src/data/avatars.ts`:
```ts
import { mkdirSync, writeFileSync, readFileSync, rmSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
const MIME_TO_EXT: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
};
export interface AvatarStore {
/** Returns the relative path written (e.g. "bot-1.png"). */
write(botId: string, mime: string, buffer: Buffer): string;
read(relPath: string): Buffer | null;
remove(relPath: string): void;
getDir(): string;
}
export function createAvatarStore(dir: string): AvatarStore {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return {
write(botId, mime, buffer) {
const ext = MIME_TO_EXT[mime];
if (!ext) throw new Error(`unsupported avatar MIME: ${mime}`);
// Remove any existing avatar for this bot regardless of extension.
for (const name of readdirSync(dir)) {
if (name.startsWith(`${botId}.`)) rmSync(join(dir, name), { force: true });
}
const rel = `${botId}.${ext}`;
writeFileSync(join(dir, rel), buffer);
return rel;
},
read(relPath) {
const full = join(dir, relPath);
if (!existsSync(full)) return null;
return readFileSync(full);
},
remove(relPath) {
rmSync(join(dir, relPath), { force: true });
},
getDir() {
return dir;
},
};
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run src/data/avatars.test.ts`
Expected: PASS — all 7 tests
- [ ] **Step 5: Commit**
```bash
git add src/data/avatars.ts src/data/avatars.test.ts
git commit -m "feat(data): avatar file store helper"
```
---
### Task 3: BotProfileManager — custom avatar precedence
**Files:**
- Modify: `src/bot/profile.ts`
- Create: `src/bot/profile.test.ts`
- [ ] **Step 1: Write the failing test**
Create `src/bot/profile.test.ts`:
```ts
import { describe, it, expect, beforeEach, vi } from "vitest";
import { BotProfileManager } from "./profile.js";
import type { TS3Client } from "../ts-protocol/client.js";
function makeMockTs(): TS3Client & {
uploadCalls: Buffer[];
clearCalls: number;
} {
const calls: Buffer[] = [];
let clears = 0;
const ts: any = {
uploadCalls: calls,
get clearCalls() { return clears; },
getHost: () => "127.0.0.1",
getHttpQuery: () => null,
fileTransferInitUpload: vi.fn().mockResolvedValue({}),
uploadFileData: vi.fn().mockImplementation(async (_h, _i, stream: any) => {
const chunks: Buffer[] = [];
for await (const c of stream) chunks.push(c as Buffer);
calls.push(Buffer.concat(chunks));
}),
fileTransferDeleteFile: vi.fn().mockResolvedValue(undefined),
sendCommandNoWait: vi.fn().mockImplementation(async (cmd: string) => {
if (/client_flag_avatar=$/.test(cmd)) clears++;
}),
};
return ts;
}
const noopLogger: any = { child: () => noopLogger, info: () => {}, debug: () => {}, warn: () => {}, error: () => {} };
const cfgOn = { avatarEnabled: true, descriptionEnabled: false, nicknameEnabled: false, awayStatusEnabled: false, channelDescEnabled: false, nowPlayingMsgEnabled: false };
const cfgOff = { ...cfgOn, avatarEnabled: false };
describe("BotProfileManager custom avatar precedence", () => {
let ts: ReturnType<typeof makeMockTs>;
beforeEach(() => { ts = makeMockTs(); });
it("on stop with custom avatar set + sync on, uploads custom (does not clear)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
const custom = Buffer.from([1, 2, 3, 4]);
pm.setCustomAvatar(custom);
await pm.onSongChange(null);
expect(ts.uploadCalls.at(-1)?.equals(custom)).toBe(true);
expect(ts.clearCalls).toBe(0);
});
it("on stop with no custom avatar, falls back to clear", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
await pm.onSongChange(null);
expect(ts.clearCalls).toBe(1);
expect(ts.uploadCalls.length).toBe(0);
});
it("on connect with sync off + custom avatar set, applies custom immediately", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
pm.setCustomAvatar(Buffer.from([9, 9]));
await pm.onConnect();
expect(ts.uploadCalls.length).toBe(1);
expect(ts.uploadCalls[0].equals(Buffer.from([9, 9]))).toBe(true);
});
it("on connect with sync off + no custom avatar, does not touch avatar", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
await pm.onConnect();
expect(ts.uploadCalls.length).toBe(0);
expect(ts.clearCalls).toBe(0);
});
it("setCustomAvatar(null) makes subsequent onSongChange(null) clear again", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.setCustomAvatar(Buffer.from([1]));
pm.setCustomAvatar(null);
await pm.onSongChange(null);
expect(ts.clearCalls).toBe(1);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/bot/profile.test.ts`
Expected: FAIL — `pm.setCustomAvatar is not a function` and/or `onConnect` not exported
- [ ] **Step 3: Look at the existing profile.ts to understand `onConnect` shape**
Run: `grep -n 'onConnect\|public async\|public ' src/bot/profile.ts | head -10`
`onConnect` likely already exists; if not, locate where reconnect resets state. Add or extend it.
- [ ] **Step 4: Implement `customAvatar`, `setCustomAvatar`, `applyIdleAvatar`; modify `clearAvatar` and `onConnect`**
In `src/bot/profile.ts`:
1. Inside the class, add fields next to `defaultNickname` (around line 27):
```ts
private customAvatar: Buffer | null = null;
```
2. After the `constructor`, add:
```ts
/** Set/clear the persistent idle avatar. Pass null to remove. */
setCustomAvatar(buffer: Buffer | null): void {
this.customAvatar = buffer;
}
```
3. Find `clearAvatar()` (~line 173). Change the body so that if `this.customAvatar` is set, we upload it instead of clearing the flag. Replace the existing method with:
```ts
private async clearAvatar(gen: number): Promise<void> {
if (this.customAvatar && this.customAvatar.length > 0) {
await this.applyIdleAvatar(gen);
return;
}
try {
await this.withTimeout(
this.tsClient.fileTransferDeleteFile(0n, ["/avatar"]),
FILE_TRANSFER_TIMEOUT_MS,
);
} catch {
// File may not exist or transfer timed out — that's fine
}
if (this.generation !== gen) return;
try {
await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar=");
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
```
4. Add a new private method right below `clearAvatar`:
```ts
private async applyIdleAvatar(gen: number): Promise<void> {
if (!this.customAvatar || this.customAvatar.length === 0) return;
if (this.permDenied.avatar) return;
try {
await this.withTimeout(this.doAvatarUpload(this.customAvatar), FILE_TRANSFER_TIMEOUT_MS);
if (this.generation !== gen) return;
this.logger.info({ bytes: this.customAvatar.length }, "Idle (custom) avatar applied");
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
```
5. Find `onConnect` (the existing method that resets per-feature flags). At its end, immediately after the `permDenied` reset, add:
```ts
if (!this.config.avatarEnabled && this.customAvatar) {
const gen = ++this.generation;
void this.applyIdleAvatar(gen);
}
```
If `onConnect` does not exist as a method, search for where reconnect resets `permDenied` and add the block there.
- [ ] **Step 5: Run test to verify it passes**
Run: `npx vitest run src/bot/profile.test.ts`
Expected: PASS — 5/5
Run also: `npx vitest run src/audio src/data src/bot` — confirm no regressions.
- [ ] **Step 6: Commit**
```bash
git add src/bot/profile.ts src/bot/profile.test.ts
git commit -m "feat(profile): custom avatar with idle/playback precedence"
```
---
### Task 4: Wire avatar load on bot start
**Files:**
- Modify: `src/bot/instance.ts`
- Modify: `src/bot/manager.ts` (if it constructs the instance)
- Modify: `src/index.ts`
- [ ] **Step 1: Confirm where `BotProfileManager` is constructed and how `BotInstance` receives DB**
Run: `grep -n 'new BotProfileManager\|profileManager =\|database\|botDb' src/bot/instance.ts src/bot/manager.ts | head -20`
Identify the BotInstance constructor params and verify that the DB and the avatar dir can flow in.
- [ ] **Step 2: Add `AVATAR_DIR` constant + `avatarStore` to `src/index.ts`**
Find where `COOKIE_DIR` / `createCookieStore` are set up (~line 48 in src/index.ts) and add directly after:
```ts
const AVATAR_DIR = process.env.AVATAR_DIR ?? join(DATA_DIR, "avatars");
const avatarStore = createAvatarStore(AVATAR_DIR);
```
(import as needed: `import { createAvatarStore } from "./data/avatars.js";`)
Pass `avatarStore` through to whatever constructs `BotManager` (and from there to `BotInstance`).
- [ ] **Step 3: In `BotInstance`, after `profileManager` is created, load the avatar from disk if any**
In `src/bot/instance.ts`, after `this.profileManager = new BotProfileManager(...)`:
```ts
const relPath = this.botDb.getCustomAvatarPath(this.id);
if (relPath) {
const buf = this.avatarStore.read(relPath);
if (buf) this.profileManager.setCustomAvatar(buf);
}
```
(Add `private botDb: BotDatabase` and `private avatarStore: AvatarStore` constructor params; thread them down from `BotManager.createBot()` / `BotManager` constructor.)
- [ ] **Step 4: Add `getProfileManager()` accessor if not present**
If grep already shows `getProfileManager(): BotProfileManager`, skip. Otherwise add a public method that returns `this.profileManager`.
- [ ] **Step 5: Build and run the existing tests**
Run: `npx tsc --noEmit`
Expected: no TS errors
Run: `npm test`
Expected: all green
- [ ] **Step 6: Commit**
```bash
git add src/index.ts src/bot/instance.ts src/bot/manager.ts
git commit -m "feat(bot): load custom avatar on instance startup"
```
---
### Task 5: REST endpoints for avatar upload / fetch / delete
**Files:**
- Modify: `src/web/server.ts` (json size limit)
- Modify: `src/web/api/bot.ts`
- [ ] **Step 1: Bump express.json size limit**
In `src/web/server.ts`, find `app.use(express.json())` (~line 46) and change to:
```ts
app.use(express.json({ limit: "400kb" }));
```
(Avatar payload is base64-encoded ≤200 KB → ~270 KB on the wire; 400 KB gives margin.)
- [ ] **Step 2: Write a failing API test (use supertest if not present, otherwise inline fetch)**
Run: `grep -E '"supertest"|"vitest"' package.json`
If supertest is not present, write the test using `node:http` raw client or skip API integration test and rely on manual + unit tests on Task 7. Don't add new deps unless approved.
If supertest IS present, add `src/web/api/bot.test.ts`:
```ts
import { describe, it, expect } from "vitest";
import request from "supertest";
import express from "express";
import { createBotRouter } from "./bot.js";
// ... build minimal app with mocked manager + DB + avatarStore
```
If not present: skip Step 2, jump to Step 3 and verify by manual curl in Step 5.
- [ ] **Step 3: Add the three endpoints**
In `src/web/api/bot.ts`, modify the factory signature to accept `avatarStore` and `botDb`:
```ts
export function createBotRouter(
botManager: BotManager,
config: BotConfig,
configPath: string,
logger: Logger,
botDb: BotDatabase,
avatarStore: AvatarStore,
): Router {
```
Inside the router, after the existing `/:id/config` GET, add:
```ts
router.get("/:id/avatar", (req, res) => {
const path = botDb.getCustomAvatarPath(req.params.id);
if (!path) { res.status(404).end(); return; }
const buf = avatarStore.read(path);
if (!buf) { res.status(404).end(); return; }
const ext = path.split(".").pop()!;
const mime = ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg";
res.set("Content-Type", mime);
res.set("Cache-Control", "no-cache");
res.send(buf);
});
router.put("/:id/avatar", (req, res) => {
const bot = botManager.getBot(req.params.id);
if (!bot && !botDb.getBotInstances().some((b) => b.id === req.params.id)) {
res.status(404).json({ error: "Bot not found" });
return;
}
const { dataUrl } = req.body as { dataUrl?: string };
if (typeof dataUrl !== "string") {
res.status(400).json({ error: "dataUrl required" });
return;
}
const m = /^data:(image\/(png|jpeg|webp));base64,(.+)$/.exec(dataUrl);
if (!m) {
res.status(400).json({ error: "dataUrl must be image/png|jpeg|webp base64" });
return;
}
const mime = m[1];
const buf = Buffer.from(m[3], "base64");
if (buf.length > 200 * 1024) {
res.status(413).json({ error: "avatar exceeds 200KB limit" });
return;
}
const rel = avatarStore.write(req.params.id, mime, buf);
botDb.setCustomAvatarPath(req.params.id, rel);
bot?.getProfileManager().setCustomAvatar(buf);
res.json({ path: rel });
});
router.delete("/:id/avatar", (req, res) => {
const path = botDb.getCustomAvatarPath(req.params.id);
if (path) avatarStore.remove(path);
botDb.setCustomAvatarPath(req.params.id, null);
const bot = botManager.getBot(req.params.id);
bot?.getProfileManager().setCustomAvatar(null);
res.status(204).end();
});
```
- [ ] **Step 4: Update the call site that constructs the router**
Search: `grep -n 'createBotRouter' src/`
In the call site (likely `src/web/server.ts` or `src/index.ts`), pass the new args. Fix the call signature.
- [ ] **Step 5: Manual smoke test**
Run: `npm run build && npm run start`
In another terminal:
```bash
# create a small valid PNG (1x1) base64
B64=$(node -e "console.log(Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,8,153,99,248,255,255,63,0,5,254,2,254,205,250,236,184,0,0,0,0,73,69,78,68,174,66,96,130]).toString('base64'))")
curl -X PUT http://localhost:3000/api/bot/<BOT_ID>/avatar \
-H 'Content-Type: application/json' \
-d "{\"dataUrl\":\"data:image/png;base64,$B64\"}"
curl http://localhost:3000/api/bot/<BOT_ID>/avatar -o /tmp/x.png
file /tmp/x.png
curl -X DELETE http://localhost:3000/api/bot/<BOT_ID>/avatar -i
```
Expected: PUT returns `{"path":"<id>.png"}`, GET returns the bytes, DELETE returns 204.
- [ ] **Step 6: Commit**
```bash
git add src/web/server.ts src/web/api/bot.ts src/index.ts
git commit -m "feat(api): /api/bot/:id/avatar GET/PUT/DELETE"
```
---
### Task 6: Frontend — `AvatarUpload.vue` component
**Files:**
- Create: `web/src/components/AvatarUpload.vue`
- [ ] **Step 1: Create the component**
```vue
<template>
<div class="avatar-upload">
<div class="preview" :class="{ empty: !previewUrl }">
<img v-if="previewUrl" :src="previewUrl" alt="avatar" />
<Icon v-else icon="mdi:account-circle-outline" />
</div>
<div class="actions">
<input
ref="fileInput"
type="file"
accept="image/png,image/jpeg,image/webp"
class="hidden"
@change="onFile"
/>
<button type="button" class="btn-sm" @click="fileInput?.click()">
{{ previewUrl ? '更换' : '上传' }}
</button>
<button v-if="previewUrl" type="button" class="btn-sm btn-danger" @click="clear">
删除
</button>
</div>
<p v-if="error" class="hint error">{{ error }}</p>
<p v-else class="hint">PNG / JPG / WebP,≤200 KB</p>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Icon } from '@iconify/vue';
const props = defineProps<{ modelValue: string | null }>();
const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>();
const previewUrl = ref<string | null>(props.modelValue);
const error = ref<string | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
watch(() => props.modelValue, (v) => { previewUrl.value = v; });
function onFile(ev: Event) {
const file = (ev.target as HTMLInputElement).files?.[0];
if (!file) return;
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
error.value = '仅支持 PNG / JPG / WebP';
return;
}
if (file.size > 200 * 1024) {
error.value = `图片 ${(file.size / 1024).toFixed(0)} KB 超过 200 KB 上限`;
return;
}
error.value = null;
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
previewUrl.value = dataUrl;
emit('update:modelValue', dataUrl);
};
reader.readAsDataURL(file);
}
function clear() {
previewUrl.value = null;
emit('update:modelValue', null);
if (fileInput.value) fileInput.value.value = '';
}
</script>
<style lang="scss" scoped>
.avatar-upload { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
.preview {
width: 80px; height: 80px; border-radius: 50%;
background: var(--bg-card); display: flex; align-items: center; justify-content: center;
overflow: hidden;
img { width: 100%; height: 100%; object-fit: cover; }
&.empty :deep(svg) { font-size: 48px; opacity: 0.4; }
}
.actions { display: flex; gap: 8px; }
.hidden { display: none; }
.hint { font-size: 12px; opacity: 0.6; margin: 0; }
.hint.error { color: var(--color-danger, #e85060); opacity: 1; }
.btn-danger { color: var(--color-danger, #e85060); }
</style>
```
- [ ] **Step 2: Verify the component compiles**
Run: `cd web && npx vue-tsc --noEmit`
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add web/src/components/AvatarUpload.vue
git commit -m "feat(web): AvatarUpload component"
```
---
### Task 7: Wire AvatarUpload into Settings.vue (create + edit + standalone row)
**Files:**
- Modify: `web/src/views/Settings.vue`
- [ ] **Step 1: Read the relevant Settings.vue regions**
```bash
grep -n '同步头像\|openEditBot\|saveEditBot\|createBot\|create-bot\|profile-features\|features.find' web/src/views/Settings.vue | head -20
```
Identify:
- Create-bot form template region (`<div class="create-bot">` block)
- Edit-bot modal/dialog template region
- The profile features table where `avatarEnabled` row lives
- [ ] **Step 2: Add component import + reactive state for avatar dataUrl on the create-bot form**
In the script setup region, near other `newBot*` refs:
```ts
import AvatarUpload from '../components/AvatarUpload.vue';
const newBotAvatar = ref<string | null>(null);
```
- [ ] **Step 3: Insert `<AvatarUpload v-model="newBotAvatar" />` into the create-bot form template**
In the `<div class="create-bot">` block, right before `<button class="btn-primary" @click="createBot">创建</button>`, add:
```vue
<div class="form-row">
<label>自定义头像(可选)</label>
<AvatarUpload v-model="newBotAvatar" />
</div>
```
- [ ] **Step 4: After successful `createBot()`, PUT the avatar if set**
Find the `createBot` async function. After the POST resolves and the bot id is known (`res.data.id` or similar), append:
```ts
if (newBotAvatar.value) {
await axios.put(`/api/bot/${res.data.id}/avatar`, { dataUrl: newBotAvatar.value });
}
newBotAvatar.value = null;
```
- [ ] **Step 5: Add an "自定义头像" row in the per-bot profile features table**
Find the profile-features table render (look for the `features` array iteration). The cleanest path: add a custom row OUTSIDE the array (since it isn't a boolean toggle). Right before `</template>` of the bot row, add:
```vue
<div class="feature-row">
<div class="feature-label">自定义头像</div>
<div class="feature-control">
<CustomAvatarRow :bot-id="bot.id" />
</div>
</div>
```
Where `CustomAvatarRow` is an inline-defined component or a small file `web/src/components/CustomAvatarRow.vue` that:
- Mounts → `axios.get(/api/bot/<id>/avatar, { responseType: 'blob' })` → previews if 200, ignore 404
- Wraps `<AvatarUpload>` and on `update:modelValue`:
- If string → `axios.put(/avatar, { dataUrl })`
- If null → `axios.delete(/avatar)`
Create `web/src/components/CustomAvatarRow.vue` with that logic; keep its body small (~50 lines).
- [ ] **Step 6: Build and visually verify**
Run: `cd web && npm run build` → no errors. Then `npm run dev` → open create-instance, upload PNG, create — verify the avatar appears on the bot in TS3 once it connects. Check edit/Settings flow.
- [ ] **Step 7: Commit**
```bash
git add web/src/views/Settings.vue web/src/components/CustomAvatarRow.vue
git commit -m "feat(web): custom avatar in create-bot + Settings"
```
---
### Task 8: Open PR
- [ ] **Step 1: Push the branch**
```bash
git checkout -b feat/custom-bot-avatar
git push -u origin feat/custom-bot-avatar
```
(If commits were already on `main`, instead create the branch from the first relevant commit and reset main: `git branch feat/custom-bot-avatar HEAD && git reset --hard origin/main && git checkout feat/custom-bot-avatar`. The exact sequence depends on the working state when starting.)
- [ ] **Step 2: Create the PR**
```bash
gh pr create --title "feat(profile): custom bot avatar" --body "Closes part of #51 (avatar half).
## Summary
- New /api/bot/:id/avatar GET/PUT/DELETE
- BotProfileManager: custom avatar acts as idle image; cover sync still wins during playback when avatarEnabled=true
- AvatarUpload component used in create-bot form and Settings per-bot row
- Bump express.json limit to 400kb to allow base64 payload
## Behavior matrix
| avatarEnabled | custom set | playing | stopped |
|---|---|---|---|
| ✓ | ✓ | cover | restore custom |
| ✓ | ✗ | cover | clear |
| ✗ | ✓ | custom | custom |
| ✗ | ✗ | no-op | no-op |
## Test plan
- [x] vitest covers DB, avatar store, ProfileManager precedence
- [x] Manual: upload PNG → bot avatar shows; play song → cover; stop → custom; delete → cleared
🤖 Generated with [Claude Code](https://claude.com/claude-code)"
```
---
## Self-Review Checklist
- [x] Each spec section has at least one task: precedence matrix → Task 3; storage → Task 2; DB → Task 1; API → Task 5; UI → Task 6+7
- [x] No "TBD" / "fill in" / "implement later" text in any step
- [x] Type names consistent: `AvatarStore` / `createAvatarStore` / `getCustomAvatarPath` / `setCustomAvatarPath` / `setCustomAvatar` (singular per call site)
- [x] All code blocks compile under existing TS/Vue config (express 5, vitest, vue 3 + iconify already in use)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,811 @@
# Account Permissions Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let an admin grant each member account a set of capabilities and a list of bots they may control, enforced on the backend.
**Architecture:** Capability tokens + per-member bot allow-list stored in two new SQLite tables, loaded onto `req.user` per request (live, no re-login), enforced by `requirePermission` / `requireBotAccess` middleware mirroring the existing `requireAdmin`. Admin stays a super-user. Existing members are backfilled to full access on upgrade; new members get a basic tier. The Vue UI hides what a member can't do and gives admins a permission editor.
**Tech Stack:** Node ESM + TypeScript, Express, better-sqlite3, Vitest + supertest, Vue 3 + Pinia.
**Spec:** `docs/superpowers/specs/2026-05-30-account-permissions-design.md`
**Conventions:** All file paths are repo-relative. Tests run with `npx vitest run <path>`. Backend is TDD (test first, watch fail, implement, watch pass, commit). Commit after each task.
---
## File Structure
**Create:**
- `src/data/permissions.ts` — capability constants + `PermissionStore` (tables accessed here)
- `src/data/permissions.test.ts` — store + constants tests
- `src/web/middleware/requirePermission.ts` — `requirePermission(cap)` + `requireBotAccess(param)`
- `src/web/middleware/requirePermission.test.ts` — middleware tests
**Modify:**
- `src/data/database.ts` — `initTables`: add the two tables + index; migration backfill of existing members
- `src/data/audit.ts` — add `"user.permissions_changed"` to `AuditAction`
- `src/web/middleware/requireAuth.ts` — widen `req.user`; load capabilities + bot access
- `src/web/auth/validateSession.ts` — (no change; just confirm) — actually unchanged
- `src/web/api/session.ts` — `/me` returns capabilities + bots; inline auth attaches them
- `src/web/server.ts` — construct `PermissionStore`, pass into routers/middleware
- `src/web/api/player.ts` — `requireBotAccess` on `/:botId`; per-route `requirePermission`
- `src/web/api/bot.ts` — `requirePermission("bot.manage")` + `requireBotAccess("id")`
- `src/web/api/auth.ts` — `requirePermission("platform.auth")`
- `src/web/api/music.ts` — `requirePermission("quality")` on the quality POST; filter `GET /api/bot`? no — bot list is in bot.ts
- `src/web/api/bot.ts` — filter `GET /` to allowed bots for members
- `src/web/api/users.ts` — `GET/PUT /api/users/:id/permissions`
- `src/bot/manager.ts` — `removeBot` calls `permissions.pruneBot(botId)`
- Frontend: `web/src/composables/useSession.ts`, `web/src/components/Navbar.vue`, `web/src/components/Player.vue`, `web/src/views/Settings.vue`, `web/src/stores/player.ts`
---
## Task 1: Capability constants + PermissionStore + tables
**Files:**
- Create: `src/data/permissions.ts`
- Create: `src/data/permissions.test.ts`
- Modify: `src/data/database.ts` (initTables)
- [ ] **Step 1: Write the failing test**
`src/data/permissions.test.ts`:
```typescript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { createDatabase, type BotDatabase } from "./database.js";
import { createPermissionStore } from "./permissions.js";
import { CAPABILITIES, BASIC_TIER_CAPABILITIES } from "./permissions.js";
describe("PermissionStore", () => {
let dbFile: string;
let db: BotDatabase;
beforeEach(() => {
dbFile = path.join(os.tmpdir(), `perm-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
db = createDatabase(dbFile);
// a user row is required for FK; insert directly
db.db.prepare(
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)"
).run("u1", "alice", "x", Date.now(), Date.now(), "member");
});
afterEach(() => {
db.close();
try { fs.rmSync(dbFile, { force: true }); } catch {}
try { fs.rmSync(dbFile + "-wal", { force: true }); } catch {}
try { fs.rmSync(dbFile + "-shm", { force: true }); } catch {}
});
it("exposes the five capability tokens and a basic tier", () => {
expect(CAPABILITIES).toEqual([
"player.control", "player.queue", "bot.manage", "platform.auth", "quality",
]);
expect(BASIC_TIER_CAPABILITIES).toEqual(["player.control", "player.queue"]);
});
it("defaults to no capabilities and no bots", () => {
const store = createPermissionStore(db.db);
expect(store.getCapabilities("u1")).toEqual([]);
expect(store.getBotAccess("u1")).toEqual([]);
});
it("round-trips capabilities and a specific bot list", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control", "quality"], bots: ["botA", "botB"] });
expect(store.getCapabilities("u1").sort()).toEqual(["player.control", "quality"]);
expect(store.getBotAccess("u1")).toEqual(["botA", "botB"]);
});
it("stores the all-bots flag as 'all'", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: "all" });
expect(store.getBotAccess("u1")).toBe("all");
});
it("setPermissions replaces prior capabilities and bots", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["botA"] });
store.setPermissions("u1", { capabilities: ["quality"], bots: "all" });
expect(store.getCapabilities("u1")).toEqual(["quality"]);
expect(store.getBotAccess("u1")).toBe("all");
});
it("ignores unknown capability tokens", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control", "bogus" as any], bots: [] });
expect(store.getCapabilities("u1")).toEqual(["player.control"]);
});
it("pruneBot removes a bot from every user's allow-list", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: [], bots: ["botA", "botB"] });
store.pruneBot("botA");
expect(store.getBotAccess("u1")).toEqual(["botB"]);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/data/permissions.test.ts`
Expected: FAIL — `createPermissionStore` / `CAPABILITIES` not found (module missing).
- [ ] **Step 3: Create `src/data/permissions.ts`**
```typescript
import type Database from "better-sqlite3";
export const CAPABILITIES = [
"player.control",
"player.queue",
"bot.manage",
"platform.auth",
"quality",
] as const;
export type Capability = (typeof CAPABILITIES)[number];
/** Marker token stored in user_permissions meaning "all bots, incl. future". */
export const BOTS_ALL = "bots.all";
/** Capabilities granted to a newly-created member by default. */
export const BASIC_TIER_CAPABILITIES: Capability[] = ["player.control", "player.queue"];
export function isCapability(x: string): x is Capability {
return (CAPABILITIES as readonly string[]).includes(x);
}
export type BotAccess = "all" | string[];
export interface PermissionStore {
getCapabilities(userId: string): Capability[];
getBotAccess(userId: string): BotAccess;
setPermissions(userId: string, input: { capabilities: string[]; bots: BotAccess }): void;
pruneBot(botId: string): void;
}
export function createPermissionStore(db: Database.Database): PermissionStore {
const selCaps = db.prepare("SELECT permission FROM user_permissions WHERE userId = ?");
const delCaps = db.prepare("DELETE FROM user_permissions WHERE userId = ?");
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
const selBots = db.prepare("SELECT botId FROM user_bot_access WHERE userId = ?");
const delBots = db.prepare("DELETE FROM user_bot_access WHERE userId = ?");
const insBot = db.prepare("INSERT OR IGNORE INTO user_bot_access (userId, botId) VALUES (?, ?)");
const pruneBotStmt = db.prepare("DELETE FROM user_bot_access WHERE botId = ?");
return {
getCapabilities(userId) {
return (selCaps.all(userId) as { permission: string }[])
.map((r) => r.permission)
.filter((p): p is Capability => isCapability(p));
},
getBotAccess(userId) {
const all = (selCaps.all(userId) as { permission: string }[]).some((r) => r.permission === BOTS_ALL);
if (all) return "all";
return (selBots.all(userId) as { botId: string }[]).map((r) => r.botId);
},
setPermissions(userId, input) {
const caps = input.capabilities.filter(isCapability);
const tx = db.transaction(() => {
delCaps.run(userId);
delBots.run(userId);
for (const c of caps) insCap.run(userId, c);
if (input.bots === "all") {
insCap.run(userId, BOTS_ALL);
} else {
for (const b of input.bots) insBot.run(userId, b);
}
});
tx();
},
pruneBot(botId) {
pruneBotStmt.run(botId);
},
};
}
```
- [ ] **Step 4: Add tables in `src/data/database.ts` initTables**
Find `initTables` (creates users/sessions/user_audit). Add, after the `user_audit` CREATE:
```typescript
db.exec(`
CREATE TABLE IF NOT EXISTS user_permissions (
userId TEXT NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (userId, permission),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_bot_access (
userId TEXT NOT NULL,
botId TEXT NOT NULL,
PRIMARY KEY (userId, botId),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId);
`);
```
(If `initTables` uses individual `db.exec` calls, match that style. The `BotDatabase` type already exposes `.db` and `.close()` — confirm by reading the file; the test uses `db.db` and `db.close()`.)
- [ ] **Step 5: Run tests to verify they pass**
Run: `npx vitest run src/data/permissions.test.ts`
Expected: PASS (7 tests).
- [ ] **Step 6: Commit**
```bash
git add src/data/permissions.ts src/data/permissions.test.ts src/data/database.ts
git commit -m "feat(perm): permission store + capability tokens + tables"
```
---
## Task 2: requirePermission + requireBotAccess middleware
**Files:**
- Create: `src/web/middleware/requirePermission.ts`
- Create: `src/web/middleware/requirePermission.test.ts`
- Modify: `src/web/middleware/requireAuth.ts` (widen `req.user`)
- [ ] **Step 1: Widen the `req.user` augmentation in `src/web/middleware/requireAuth.ts`**
Change the `declare module` block so `req.user` carries capabilities + bot access:
```typescript
declare module "express-serve-static-core" {
interface Request {
user?: {
id: string;
username: string;
role: "admin" | "member";
capabilities: Set<string>;
bots: "all" | Set<string>;
};
}
}
```
(The loading of these fields is done in Task 4 — for now this only widens the type. Existing assignments to `req.user` will fail to typecheck until Task 4; that is expected and Task 4 fixes them. If you need the build green between tasks, do Task 2 + Task 4 back-to-back before running `tsc`.)
- [ ] **Step 2: Write the failing middleware test**
`src/web/middleware/requirePermission.test.ts`:
```typescript
import { describe, it, expect } from "vitest";
import express from "express";
import request from "supertest";
import { requirePermission, requireBotAccess } from "./requirePermission.js";
function appWith(user: any) {
const app = express();
app.use((req, _res, next) => { (req as any).user = user; next(); });
app.post("/cap", requirePermission("quality"), (_req, res) => res.json({ ok: true }));
app.post("/bot/:botId", requireBotAccess("botId"), (_req, res) => res.json({ ok: true }));
return app;
}
const member = (caps: string[], bots: "all" | string[]) => ({
id: "u1", username: "a", role: "member",
capabilities: new Set(caps), bots: bots === "all" ? "all" : new Set(bots),
});
const admin = { id: "a", username: "admin", role: "admin", capabilities: new Set(), bots: "all" };
describe("requirePermission", () => {
it("401 when unauthenticated", async () => {
const app = express();
app.post("/cap", requirePermission("quality"), (_r, res) => res.json({ ok: true }));
expect((await request(app).post("/cap")).status).toBe(401);
});
it("403 when member lacks the capability", async () => {
expect((await request(appWith(member([], "all"))).post("/cap")).status).toBe(403);
});
it("200 when member has the capability", async () => {
expect((await request(appWith(member(["quality"], "all"))).post("/cap")).status).toBe(200);
});
it("200 for admin regardless of capabilities", async () => {
expect((await request(appWith(admin)).post("/cap")).status).toBe(200);
});
});
describe("requireBotAccess", () => {
it("200 when bots = all", async () => {
expect((await request(appWith(member([], "all"))).post("/bot/b1")).status).toBe(200);
});
it("200 when botId in allow-list", async () => {
expect((await request(appWith(member([], ["b1"]))).post("/bot/b1")).status).toBe(200);
});
it("403 when botId not in allow-list", async () => {
expect((await request(appWith(member([], ["b2"]))).post("/bot/b1")).status).toBe(403);
});
it("200 for admin", async () => {
expect((await request(appWith(admin)).post("/bot/b1")).status).toBe(200);
});
});
```
- [ ] **Step 3: Run test to verify it fails**
Run: `npx vitest run src/web/middleware/requirePermission.test.ts`
Expected: FAIL — module `./requirePermission.js` not found.
- [ ] **Step 4: Create `src/web/middleware/requirePermission.ts`**
```typescript
import type { Request, Response, NextFunction, RequestHandler } from "express";
export function requirePermission(capability: string): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; }
if (req.user.role === "admin" || req.user.capabilities.has(capability)) { next(); return; }
res.status(403).json({ error: "forbidden" });
};
}
export function requireBotAccess(paramName = "botId"): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; }
if (req.user.role === "admin" || req.user.bots === "all") { next(); return; }
const botId = req.params[paramName];
if (botId && req.user.bots.has(botId)) { next(); return; }
res.status(403).json({ error: "forbidden" });
};
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `npx vitest run src/web/middleware/requirePermission.test.ts`
Expected: PASS (8 tests).
- [ ] **Step 6: Commit**
```bash
git add src/web/middleware/requirePermission.ts src/web/middleware/requirePermission.test.ts src/web/middleware/requireAuth.ts
git commit -m "feat(perm): requirePermission + requireBotAccess middleware"
```
---
## Task 3: Effective-permissions resolver (admin = all)
**Files:**
- Modify: `src/data/permissions.ts` (add `resolveContext` helper)
- Modify: `src/data/permissions.test.ts` (add tests)
- [ ] **Step 1: Add failing tests** to `src/data/permissions.test.ts`:
```typescript
import { resolvePermissionContext } from "./permissions.js";
describe("resolvePermissionContext", () => {
it("admin gets all capabilities and all bots regardless of stored rows", () => {
const store = createPermissionStore(db.db);
const ctx = resolvePermissionContext("admin", "u1", store);
expect([...ctx.capabilities].sort()).toEqual([...CAPABILITIES].sort());
expect(ctx.bots).toBe("all");
});
it("member reflects stored capabilities + bot access", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["b1"] });
const ctx = resolvePermissionContext("member", "u1", store);
expect([...ctx.capabilities]).toEqual(["player.control"]);
expect(ctx.bots).toEqual(new Set(["b1"]));
});
});
```
- [ ] **Step 2: Run to verify fail**
Run: `npx vitest run src/data/permissions.test.ts`
Expected: FAIL — `resolvePermissionContext` not exported.
- [ ] **Step 3: Add to `src/data/permissions.ts`**
```typescript
export interface PermissionContext {
capabilities: Set<string>;
bots: "all" | Set<string>;
}
export function resolvePermissionContext(
role: "admin" | "member",
userId: string,
store: PermissionStore
): PermissionContext {
if (role === "admin") {
return { capabilities: new Set(CAPABILITIES), bots: "all" };
}
const access = store.getBotAccess(userId);
return {
capabilities: new Set(store.getCapabilities(userId)),
bots: access === "all" ? "all" : new Set(access),
};
}
```
- [ ] **Step 4: Run to verify pass**
Run: `npx vitest run src/data/permissions.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/data/permissions.ts src/data/permissions.test.ts
git commit -m "feat(perm): resolvePermissionContext (admin = super-user)"
```
---
## Task 4: Load permissions onto req.user (requireAuth + session inline + /me)
**Files:**
- Modify: `src/web/middleware/requireAuth.ts`
- Modify: `src/web/api/session.ts`
- Modify: `src/web/server.ts`
- [ ] **Step 1: Thread `PermissionStore` into `createRequireAuth`**
`src/web/middleware/requireAuth.ts` — change the factory signature and set the new fields:
```typescript
import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js";
export function createRequireAuth(sessions: SessionStore, permissions: PermissionStore): RequestHandler {
return function requireAuth(req, res, next) {
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
if (!result) {
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
res.status(401).json({ error: "unauthenticated" });
return;
}
const ctx = resolvePermissionContext(result.role, result.userId, permissions);
req.user = {
id: result.userId, username: result.username, role: result.role,
capabilities: ctx.capabilities, bots: ctx.bots,
};
const token = extractSessionToken(req.headers.cookie);
if (token) {
res.cookie(SESSION_COOKIE_NAME, token, {
httpOnly: true, sameSite: "lax", secure: req.secure, path: "/", maxAge: SESSION_TTL_MS,
});
}
next();
};
}
```
- [ ] **Step 2: Update `src/web/server.ts`**
Construct the store next to the others and pass it in:
```typescript
import { createPermissionStore } from "../data/permissions.js";
// ...
const permissions = createPermissionStore(options.database.db);
// ...
const requireAuth = createRequireAuth(sessions, permissions);
```
Keep `permissions` in scope — it's passed to routers in Tasks 5–7.
- [ ] **Step 3: Update session inline auth + `/me` in `src/web/api/session.ts`**
`createSessionRouter` must accept `permissions` and (a) attach capabilities in `requireAuthInline`, (b) include them in `/me`. Pass `permissions` from `server.ts` into `createSessionRouter(users, sessions, audit, logger, permissions)`. In the `/me` handler, return:
```typescript
const ctx = resolvePermissionContext(validation.role, validation.userId, permissions);
res.json({
id: validation.userId, username: validation.username, role: validation.role,
capabilities: [...ctx.capabilities],
bots: ctx.bots === "all" ? "all" : [...ctx.bots],
});
```
(Match the existing `/me` shape; just add `capabilities` + `bots`. Read the file to find the exact response object.)
- [ ] **Step 4: Verify build + existing tests**
Run: `npx tsc --noEmit`
Expected: exit 0 (the widened `req.user` is now populated everywhere it's read).
Run: `npx vitest run src/web`
Expected: PASS (existing auth/session/csrf tests still green; if a test constructs `createRequireAuth(sessions)` it must be updated to pass a `createPermissionStore(db)`).
- [ ] **Step 5: Commit**
```bash
git add src/web/middleware/requireAuth.ts src/web/server.ts src/web/api/session.ts
git commit -m "feat(perm): load capabilities + bot access onto req.user; expose via /me"
```
---
## Task 5: Enforce capabilities on the action routes
**Files:**
- Modify: `src/web/api/player.ts`, `src/web/api/bot.ts`, `src/web/api/auth.ts`, `src/web/api/music.ts`
- Modify: `src/web/api/player.test.ts` (or create `src/web/api/permissions-enforcement.test.ts`)
- [ ] **Step 1: Write a failing integration test** at `src/web/api/permissions-enforcement.test.ts` that builds the real app (or the relevant router) with a stubbed `req.user` and asserts:
- member without `player.control` → `POST /api/player/:botId/pause` → 403
- member with `player.control` + bot in allow-list → 200 (bot resolves)
- member with `player.control` but bot NOT in allow-list → 403
- member without `player.queue` → `POST /api/player/:botId/clear` → 403
- member without `bot.manage` → `POST /api/bot` → 403
- member without `platform.auth` → `POST /api/auth/cookie` → 403
- member without `quality` → `POST /api/music/quality` → 403
- admin → all 200/allowed
Use the same `appWith(user)` injection pattern as Task 2 (insert a middleware that sets `req.user` before the router) and a fake `BotManager`/providers so routes resolve. Model it on the existing `src/web/api/*.test.ts` setup (read one first for the harness).
- [ ] **Step 2: Run to verify fail** — `npx vitest run src/web/api/permissions-enforcement.test.ts` → FAIL (routes currently allow everyone).
- [ ] **Step 3: Apply gates.**
`src/web/api/player.ts` — the shared `/:botId` middleware already resolves the bot. Add bot-access there, and add per-action capability guards. Define the queue-capability routes vs control routes:
```typescript
import { requirePermission, requireBotAccess } from "../middleware/requirePermission.js";
// after the existing router.use("/:botId", resolveBot):
router.use("/:botId", requireBotAccess("botId"));
const control = requirePermission("player.control");
const queue = requirePermission("player.queue");
// control: play, pause, resume, next, prev, stop, seek, volume, mode, play-song, play-at, play-by-id, play-playlist, play-album, play-next-song
// queue: add, add-song, add-by-id, clear, playlist, /queue/:index (DELETE)
// Apply per route, e.g.:
router.post("/:botId/pause", control, async (req, res) => { /* existing */ });
router.post("/:botId/add", queue, async (req, res) => { /* existing */ });
router.delete("/:botId/queue/:index", queue, async (req, res) => { /* existing */ });
```
(Insert the `control`/`queue` middleware as the 2nd arg of each existing `router.post/delete`. Do not change handler bodies. `PUT /:botId/profile` → `requirePermission("bot.manage")`.)
`src/web/api/bot.ts` — gate management + per-bot:
```typescript
const manage = requirePermission("bot.manage");
router.post("/", manage, ...); // create (no botId)
router.put("/:id", manage, requireBotAccess("id"), ...);
router.delete("/:id", manage, requireBotAccess("id"), ...);
router.post("/:id/start", manage, requireBotAccess("id"), ...);
router.post("/:id/stop", manage, requireBotAccess("id"), ...);
router.put("/:id/avatar", manage, requireBotAccess("id"), ...);
router.delete("/:id/avatar", manage, requireBotAccess("id"), ...);
router.post("/settings", manage, ...); // global idle timeout
```
`src/web/api/auth.ts` — gate every mutating route with `requirePermission("platform.auth")`:
`POST /qrcode`, `POST /sms/send`, `POST /sms/verify`, `POST /cookie`. (Leave `GET /status`, `GET /qrcode/status` open — read-only.)
`src/web/api/music.ts` — gate the one mutating route:
`router.post("/quality", requirePermission("quality"), ...)`.
- [ ] **Step 4: Run to verify pass** — `npx vitest run src/web/api/permissions-enforcement.test.ts` → PASS. Then `npx vitest run src/web` → all green.
- [ ] **Step 5: Commit**
```bash
git add src/web/api/player.ts src/web/api/bot.ts src/web/api/auth.ts src/web/api/music.ts src/web/api/permissions-enforcement.test.ts
git commit -m "feat(perm): enforce capabilities + bot access on action routes"
```
---
## Task 6: Filter the bot list for members
**Files:**
- Modify: `src/web/api/bot.ts` (`GET /`)
- Modify: `src/bot/manager.ts` (`removeBot` → `permissions.pruneBot`)
- Modify: test from Task 5
- [ ] **Step 1: Add failing test** — member with `bots: ["b1"]` calling `GET /api/bot` sees only `b1`; admin sees all.
- [ ] **Step 2: Run → fail.**
- [ ] **Step 3: Implement.** In `GET /` of `bot.ts`:
```typescript
const all = getAllBots().map((b) => b.getStatus());
const u = req.user!;
const bots = u.role === "admin" || u.bots === "all"
? all
: all.filter((b) => (u.bots as Set<string>).has(b.id));
res.json({ bots });
```
In `src/bot/manager.ts`, give `BotManager` access to the `PermissionStore` (constructor param) and call `this.permissions.pruneBot(id)` inside `removeBot(id)` after deletion, so deleted bots drop out of allow-lists. Thread `permissions` from `index.ts`/`server.ts` into `BotManager`.
- [ ] **Step 4: Run → pass; `npx vitest run src/web src/bot` green.**
- [ ] **Step 5: Commit**
```bash
git add src/web/api/bot.ts src/bot/manager.ts src/web/api/permissions-enforcement.test.ts
git commit -m "feat(perm): filter GET /api/bot to allowed bots; prune access on bot delete"
```
---
## Task 7: Management API (GET/PUT permissions) + audit
**Files:**
- Modify: `src/data/audit.ts` (add action)
- Modify: `src/web/api/users.ts` (+ permissions endpoints; new-member default)
- Modify: `src/web/server.ts` (pass `permissions` into `createUsersRouter`)
- Create/extend: `src/web/api/users.test.ts`
- [ ] **Step 1: Add `"user.permissions_changed"`** to the `AuditAction` union in `src/data/audit.ts`.
- [ ] **Step 2: Write failing tests** for the users router (admin-only):
- `GET /api/users/:id/permissions` → `{ capabilities: [], bots: [] }` for a fresh member.
- `PUT /api/users/:id/permissions` with `{capabilities:["player.control"], bots:"all"}` → 200; subsequent GET reflects it; an audit row `user.permissions_changed` exists.
- `PUT` with an unknown capability token → it is dropped (not stored).
- New member created via `POST /api/users` → GET permissions returns basic tier (`["player.control","player.queue"]`, bots `"all"`).
- [ ] **Step 3: Run → fail.**
- [ ] **Step 4: Implement** in `src/web/api/users.ts` (router already admin-gated at mount). Accept `permissions: PermissionStore` param. Add:
```typescript
import { CAPABILITIES, isCapability, BASIC_TIER_CAPABILITIES } from "../../data/permissions.js";
router.get("/:id/permissions", (req, res) => {
const user = users.findById(req.params.id);
if (!user) { res.status(404).json({ error: "not_found" }); return; }
res.json({ capabilities: permissions.getCapabilities(user.id), bots: permissions.getBotAccess(user.id) });
});
router.put("/:id/permissions", (req, res) => {
const user = users.findById(req.params.id);
if (!user) { res.status(404).json({ error: "not_found" }); return; }
const body = req.body ?? {};
const caps = Array.isArray(body.capabilities) ? body.capabilities.filter(isCapability) : [];
const bots = body.bots === "all" ? "all" : (Array.isArray(body.bots) ? body.bots.map(String) : []);
permissions.setPermissions(user.id, { capabilities: caps, bots });
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
targetUserId: user.id, targetUsername: user.username,
action: "user.permissions_changed",
});
res.json({ success: true });
});
```
In the existing `POST /api/users` handler, after creating a member, seed the basic tier:
```typescript
if (created.role === "member") {
permissions.setPermissions(created.id, { capabilities: BASIC_TIER_CAPABILITIES, bots: "all" });
}
```
- [ ] **Step 5: Run → pass; `npx vitest run src/web` green.**
- [ ] **Step 6: Commit**
```bash
git add src/data/audit.ts src/web/api/users.ts src/web/server.ts src/web/api/users.test.ts
git commit -m "feat(perm): admin permissions API + audit + new-member basic tier"
```
---
## Task 8: One-time migration backfill (existing members → full)
**Files:**
- Modify: `src/data/database.ts` (`migrateSchema` or a dedicated backfill)
- Create: `src/data/permissions-migration.test.ts`
- [ ] **Step 1: Write failing test** — given a fresh db with an existing `member` user and NO permission rows, after `createDatabase()` runs the backfill, that member has all 5 capabilities + `bots.all`; an `admin` user gets nothing (bypasses). Backfill is idempotent (running twice does not duplicate / does not re-grant a member who was later restricted to empty).
Idempotency approach: store a one-shot marker. Use a `meta` row or check: only backfill members who currently have ZERO permission rows AND only on first introduction. Simplest robust marker: a row in a tiny `schema_meta(key TEXT PK, value TEXT)` table, key `perm_backfill_done`. If present, skip.
- [ ] **Step 2: Run → fail.**
- [ ] **Step 3: Implement** a `backfillMemberPermissions(db)` run once inside `createDatabase` after `initTables`:
```typescript
db.exec(`CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT)`);
const done = db.prepare("SELECT value FROM schema_meta WHERE key = 'perm_backfill_done'").get();
if (!done) {
const members = db.prepare("SELECT id FROM users WHERE role = 'member'").all() as { id: string }[];
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
const tx = db.transaction(() => {
for (const m of members) {
for (const c of ["player.control","player.queue","bot.manage","platform.auth","quality","bots.all"]) {
insCap.run(m.id, c);
}
}
db.prepare("INSERT INTO schema_meta (key, value) VALUES ('perm_backfill_done', ?)").run(String(Date.now()));
});
tx();
}
```
- [ ] **Step 4: Run → pass.**
- [ ] **Step 5: Commit**
```bash
git add src/data/database.ts src/data/permissions-migration.test.ts
git commit -m "feat(perm): one-time backfill of existing members to full access"
```
---
## Task 9: Frontend — session capabilities + helpers
**Files:**
- Modify: `web/src/composables/useSession.ts`
- [ ] **Step 1:** Extend the `User` type with `capabilities: string[]` and `bots: 'all' | string[]`; populate from `/api/session/me`, `/login`, `/setup` responses (the backend now returns them).
- [ ] **Step 2:** Add computed helpers:
```typescript
function can(cap: string): boolean {
const u = currentUser.value;
return !!u && (u.role === 'admin' || (u.capabilities ?? []).includes(cap));
}
function canControlBot(botId: string): boolean {
const u = currentUser.value;
if (!u) return false;
if (u.role === 'admin' || u.bots === 'all') return true;
return Array.isArray(u.bots) && u.bots.includes(botId);
}
```
Export `can` and `canControlBot` from the composable.
- [ ] **Step 3:** Manual check: log in as admin → `can('quality')` true; (after backend done) a restricted member → false. Build: `cd web && npx vue-tsc --noEmit`.
- [ ] **Step 4: Commit** `git add web/src/composables/useSession.ts && git commit -m "feat(perm): frontend session capabilities + can()/canControlBot()"`
---
## Task 10: Frontend — gate UI by capability + filter bots
**Files:**
- Modify: `web/src/components/Navbar.vue`, `web/src/components/Player.vue`, `web/src/views/Settings.vue`, `web/src/stores/player.ts`
- [ ] **Step 1:** Navbar bot selector: render only controllable bots — `v-for="bot in store.bots"` becomes a filtered computed `controllableBots = store.bots.filter(b => session.canControlBot(b.id))`. (The backend already filters `GET /api/bot`, so this is belt-and-suspenders + correctness if both lists diverge.) Ensure `store.activeBot` fallback never lands on a bot the user can't control.
- [ ] **Step 2:** Player.vue: wrap control buttons with `v-if="session.can('player.control')"` and queue actions with `v-if="session.can('player.queue')"`.
- [ ] **Step 3:** Settings.vue: wrap the platform login cards with `v-if="session.can('platform.auth')"`, the audio-quality control with `v-if="session.can('quality')"`, and bot create/edit/delete with `v-if="session.can('bot.manage')"`.
- [ ] **Step 4:** Manual verification (see Verification section). Build: `cd web && npx vue-tsc --noEmit`.
- [ ] **Step 5: Commit** `git add web/src/components/Navbar.vue web/src/components/Player.vue web/src/views/Settings.vue web/src/stores/player.ts && git commit -m "feat(perm): hide UI a member lacks capability for"`
---
## Task 11: Frontend — admin permission editor
**Files:**
- Modify: `web/src/views/Settings.vue` (User Management section)
- [ ] **Step 1:** In each member row of the admin User-Management list, add a "权限" button opening an editor (inline panel or dialog) with: 5 capability checkboxes (labels: 播放控制 / 队列管理 / 机器人管理 / 平台登录凭据 / 音质设置), and a bot allow-list — an "全部机器人" toggle plus, when off, a checkbox per bot from `store.bots`.
- [ ] **Step 2:** On open, `GET /api/users/:id/permissions`; on save, `PUT /api/users/:id/permissions` with `{capabilities, bots}` then re-fetch. Admin rows show "全部权限(管理员)" and no editor.
- [ ] **Step 3:** Manual verification. Build: `cd web && npx vue-tsc --noEmit`.
- [ ] **Step 4: Commit** `git add web/src/views/Settings.vue && git commit -m "feat(perm): admin permission editor in user management"`
---
## Final verification
- [ ] `npx tsc --noEmit` → exit 0
- [ ] `npx vitest run src/` → all green (clean-checkout-equivalent; ignore stale `dist/` twins — see note)
- [ ] `cd web && npx vue-tsc --noEmit` → exit 0
- [ ] `npm run build` → succeeds
- [ ] Manual (run the bot, log in): admin sees everything; create a member, restrict to `player.control` on one bot → member sees only that bot, can play/pause but cannot add to queue, cannot open platform login / quality / bot management; backend returns 403 on a forged request to a disallowed action (verify with curl + the member's session cookie).
> **Note (pre-existing):** `tsconfig.json` compiles `*.test.ts` into `dist/`, and vitest also runs the `dist/` twins after a build — so `npx vitest run` (no path) double-runs and can fail on stale artifacts. Scope verification to `npx vitest run src/`. (A separate cleanup PR could add `exclude: ['**/dist/**']` to a vitest config.)
## Out of scope (separate PRs, per spec)
#1 guest mode · #2 dedicated-link bot hiding UX · #3 auto-pause on empty channel · #4 dedicated-link refresh bug.
@@ -0,0 +1,196 @@
# Auto-pause on Empty Channel — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Auto-pause playback when the bot's channel empties (no disconnect) and auto-resume when someone returns — only resuming tracks we auto-paused — gated by the existing global `autoPauseOnEmpty` flag.
**Architecture:** A pure decision function decides pause/resume from (player state, autoPaused, flag, userCount). `BotInstance` owns an `autoPaused` flag and a `checkChannelOccupancy()` that the existing 30s idle poll AND new TS enter/leave/move events both call. The toggle is wired into `/api/bot/settings` + the Settings UI.
**Tech:** Node ESM + TS, Vitest, Express, Vue 3.
**Spec:** `docs/superpowers/specs/2026-05-30-autopause-empty-channel-design.md`
---
## Task 1: Pure occupancy-decision function
**Files:** Create `src/bot/auto-pause.ts`, `src/bot/auto-pause.test.ts`.
- [ ] **Step 1 — failing test** `src/bot/auto-pause.test.ts`:
```typescript
import { describe, it, expect } from "vitest";
import { decideOccupancyAction } from "./auto-pause.js";
describe("decideOccupancyAction", () => {
// (playerState, autoPaused, enabled, userCount) => "pause" | "resume" | "none"
it("pauses when empty while playing and enabled", () => {
expect(decideOccupancyAction("playing", false, true, 0)).toBe("pause");
});
it("does not pause when the feature is disabled", () => {
expect(decideOccupancyAction("playing", false, false, 0)).toBe("none");
});
it("does not pause when idle (nothing playing)", () => {
expect(decideOccupancyAction("idle", false, true, 0)).toBe("none");
});
it("does not pause when already paused", () => {
expect(decideOccupancyAction("paused", false, true, 0)).toBe("none");
});
it("resumes when re-populated and we auto-paused", () => {
expect(decideOccupancyAction("paused", true, true, 2)).toBe("resume");
});
it("does NOT resume a user-paused track on re-population", () => {
expect(decideOccupancyAction("paused", false, true, 2)).toBe("none");
});
it("does nothing when re-populated and already playing", () => {
expect(decideOccupancyAction("playing", false, true, 2)).toBe("none");
});
it("resume is independent of the enabled flag (we already auto-paused)", () => {
expect(decideOccupancyAction("paused", true, false, 1)).toBe("resume");
});
});
```
- [ ] **Step 2 — run, expect fail:** `npx vitest run src/bot/auto-pause.test.ts` → module missing.
- [ ] **Step 3 — implement** `src/bot/auto-pause.ts`:
```typescript
export type PlayerStateName = "idle" | "playing" | "paused";
export type OccupancyAction = "pause" | "resume" | "none";
/**
* Decide what auto-pause should do given the channel occupancy.
* - empty (userCount <= 0): pause iff enabled and currently playing.
* - re-populated (userCount > 0): resume iff we previously auto-paused and are still paused.
* `autoPaused` distinguishes our auto-pause from a user pause, so user pauses are never resumed.
*/
export function decideOccupancyAction(
playerState: PlayerStateName,
autoPaused: boolean,
enabled: boolean,
userCount: number,
): OccupancyAction {
const empty = userCount <= 0;
if (empty) {
if (enabled && playerState === "playing") return "pause";
return "none";
}
if (autoPaused && playerState === "paused") return "resume";
return "none";
}
```
- [ ] **Step 4 — run, expect pass:** `npx vitest run src/bot/auto-pause.test.ts` → 8 pass.
- [ ] **Step 5 — commit:** `git add src/bot/auto-pause.ts src/bot/auto-pause.test.ts && git commit -m "feat(autopause): pure occupancy-decision function"`
---
## Task 2: Wire decision into BotInstance (autoPaused flag + checkChannelOccupancy)
**Files:** Modify `src/bot/instance.ts`.
Context: `_startIdlePoller` (~lines 190-206) polls every 30s, computes `userCount = (await getClientsInChannel()).length - 1`, and calls `_scheduleIdleCheck()` (empty) / `_cancelIdleTimer()` (occupied). `cmdPause`/`cmdResume` (~484-494), `cmdStop` (~496-505), and the playback start (`cmdPlay`/resolveAndPlay) wrap `player`. There's an unused `channelUserCount` field (~line 68). The instance has `this.config` (BotConfig) and `this.player`.
- [ ] **Step 1 — add state + helper.** Add a private field `private autoPaused = false;`. Create a method that centralizes occupancy handling and is called with a freshly-computed userCount:
```typescript
import { decideOccupancyAction } from "./auto-pause.js";
private handleOccupancy(userCount: number): void {
// idle-disconnect (unchanged behavior)
if (userCount <= 0) this._scheduleIdleCheck();
else this._cancelIdleTimer();
// auto-pause
const action = decideOccupancyAction(
this.player.getState() as "idle" | "playing" | "paused",
this.autoPaused,
this.config.autoPauseOnEmpty,
userCount,
);
if (action === "pause") {
this.player.pause();
this.autoPaused = true;
this.emit("stateChange");
} else if (action === "resume") {
this.player.resume();
this.autoPaused = false;
this.emit("stateChange");
}
}
```
- [ ] **Step 2 — route the idle poller through it.** In `_startIdlePoller`, replace the inline `userCount`→schedule/cancel logic with: compute `userCount` then `this.handleOccupancy(userCount)`. (Keep the 30s interval + the same getClientsInChannel call + error handling.) Remove the now-redundant inline schedule/cancel branch (it lives in `handleOccupancy`).
- [ ] **Step 3 — clear autoPaused on user actions + lifecycle.** In `cmdPause`, `cmdResume`, `cmdStop`, and the play-start path (`cmdPlay`/wherever playback (re)starts), set `this.autoPaused = false`. In the `disconnected` handler and on (re)connect, set `this.autoPaused = false`. (These ensure a user pause is never auto-resumed and the flag resets across connections.)
- [ ] **Step 4 — `updateAutoPause`.** Add (mirrors `updateIdleTimeout`):
```typescript
updateAutoPause(enabled: boolean): void {
this.config.autoPauseOnEmpty = enabled;
// if turning off, leave current playback as-is; if a track was auto-paused, optionally resume:
if (!enabled && this.autoPaused && this.player.getState() === "paused") {
this.player.resume();
this.autoPaused = false;
this.emit("stateChange");
}
}
```
- [ ] **Step 5 — verify:** `npx tsc --noEmit` → exit 0. `npx vitest run src/bot src/audio` → pass (existing tests unaffected).
- [ ] **Step 6 — commit:** `git add src/bot/instance.ts && git commit -m "feat(autopause): drive pause/resume from channel occupancy in BotInstance"`
---
## Task 3: Re-emit TS member events for instant reaction
**Files:** Modify `src/ts-protocol/client.ts`, `src/bot/instance.ts`.
Context: `client.ts` forwards `textMessage`/`disconnected`/`connected` and only debug-logs `clientEnter` (~lines 219-224); `clientLeave`/`clientMoved` are not handled. `BotInstance.setupTsEvents()` (~lines 132-156) wires tsClient events.
- [ ] **Step 1 — re-emit in client.ts.** Where `clientEnter` is logged, also `this.emit("clientEnter", info)`. Add subscriptions for `clientLeave` and `clientMoved` that `this.emit(...)` them upward (match the existing forwarding style; just propagate, no payload transformation needed since the instance re-queries).
- [ ] **Step 2 — react in instance.ts.** In `setupTsEvents()`, add handlers: on `clientEnter` / `clientLeave` / `clientMoved`, call a small `async refreshOccupancy()` that does `const clients = await this.getClientsInChannel(); this.handleOccupancy(clients.length - 1);` (guarded with try/catch + only when connected). This gives near-instant pause/resume; the 30s poll remains the fallback.
- [ ] **Step 3 — verify:** `npx tsc --noEmit` → 0. `npx vitest run src/bot` → pass.
- [ ] **Step 4 — commit:** `git add src/ts-protocol/client.ts src/bot/instance.ts && git commit -m "feat(autopause): re-emit client enter/leave/move for instant pause/resume"`
---
## Task 4: API wiring for the toggle
**Files:** Modify `src/web/api/bot.ts`; add/extend a test.
Context: `GET /api/bot/settings` returns `{ idleTimeoutMinutes }`; `POST /api/bot/settings` validates `idleTimeoutMinutes`, sets `config.idleTimeoutMinutes`, `saveConfig`, then loops `botManager.getAllBots()` → `bot.updateIdleTimeout(...)`. This route is `requirePermission("bot.manage")`-gated.
- [ ] **Step 1 — failing API test** (extend the existing bot settings test or add one): `GET /api/bot/settings` returns `autoPauseOnEmpty` (boolean); `POST /api/bot/settings` with `{ autoPauseOnEmpty: false }` persists it (a follow-up GET reflects false) and calls `updateAutoPause` on bots. Model the harness on the existing settings test.
- [ ] **Step 2 — run, expect fail.**
- [ ] **Step 3 — implement.** In `GET /settings`, add `autoPauseOnEmpty: options.config.autoPauseOnEmpty` to the response. In `POST /settings`, if `typeof req.body.autoPauseOnEmpty === "boolean"`, set `config.autoPauseOnEmpty`, include it in the `saveConfig`, and loop bots calling `bot.updateAutoPause(config.autoPauseOnEmpty)`. Keep the existing `idleTimeoutMinutes` handling intact (handle both fields in one save).
- [ ] **Step 4 — verify:** `npx vitest run src/web` → pass; `npx tsc --noEmit` → 0.
- [ ] **Step 5 — commit:** `git add src/web/api/bot.ts <test> && git commit -m "feat(autopause): expose autoPauseOnEmpty via /api/bot/settings"`
---
## Task 5: Frontend toggle in Settings
**Files:** Modify `web/src/views/Settings.vue` (and the settings load/save it uses).
Context: The **行为设置** section (already `v-if="can('bot.manage')"`) holds the idle-timeout control, loaded via `loadIdleTimeout()` (GET /api/bot/settings) and saved via `saveIdleTimeout()` (POST). Read these first.
- [ ] **Step 1 — implement.** Add an `autoPauseOnEmpty` ref. In the settings load, populate it from the GET response. Add a checkbox/toggle in the 行为设置 section labelled e.g. "频道无人时自动暂停" bound to it, and include `autoPauseOnEmpty` in the POST payload of the save function (alongside `idleTimeoutMinutes`, or via its own save — match the existing pattern). Use existing form/toggle CSS classes.
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0; read template back for correctness.
- [ ] **Step 3 — commit:** `git add web/src/views/Settings.vue && git commit -m "feat(autopause): autoPauseOnEmpty toggle in Settings"`
---
## Final verification
- [ ] `npx tsc --noEmit` → 0
- [ ] `npx vitest run src/` → all pass
- [ ] `cd web && npx vue-tsc --noEmit` → 0
- [ ] `npm run build` → succeeds
- [ ] Manual: with a bot playing, leave its channel → music auto-pauses (no disconnect); rejoin → resumes. Manually pause, leave, rejoin → stays paused. Toggle off in Settings → no auto-pause.
@@ -0,0 +1,156 @@
# Dedicated-link Bot Scoping (+ refresh fix) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Opening a dedicated link locks the WebUI to that one bot (selector shows only it, switching disabled, with an explicit exit); the lock is carried in the URL (`?bot=<id>`) so it survives refresh — fixing item 4 too.
**Architecture:** A `scopedBotId` in the Pinia player store is the runtime lock; the URL query `?bot=<id>` is the durable source of truth. A router `beforeEach` syncs scope from the query and re-attaches `?bot` across in-app navigation while scoped. `BotRedirect` seeds it; Navbar renders the lock; graceful clear if the bot doesn't exist.
**Tech:** Vue 3 + Pinia + vue-router, TypeScript. (Frontend isn't unit-tested in this repo → verify via `vue-tsc` + manual; extract one pure helper to unit-test.)
**Spec:** `docs/superpowers/specs/2026-05-30-dedicated-link-scope-design.md`
---
## Task 1: Store scope state + pure resolve helper (with test)
**Files:** Modify `web/src/stores/player.ts`; create `web/src/stores/scope.ts` + `web/src/stores/scope.test.ts`.
READ `web/src/stores/player.ts` first: `activeBotId` state (~line 52), `setActiveBotId` action (~127-133), `fetchBots` (~196-198 default to bots[0]), `activeBot` getter (~73-75), and the localStorage pattern used by `theme` (~175-183) for reference (we are NOT using localStorage, but match code style).
- [ ] **Step 1 — pure helper + failing test.** Create `web/src/stores/scope.ts`:
```typescript
/** Given the desired scoped id (from ?bot) and the known bot ids, decide the
* effective scope. Returns the id if it exists, else null (graceful clear:
* a stale/forbidden id never locks the UI). */
export function resolveScopedBot(
requestedId: string | null | undefined,
knownBotIds: readonly string[],
): string | null {
if (!requestedId) return null;
return knownBotIds.includes(requestedId) ? requestedId : null;
}
```
`web/src/stores/scope.test.ts`:
```typescript
import { describe, it, expect } from "vitest";
import { resolveScopedBot } from "./scope.js";
describe("resolveScopedBot", () => {
it("returns null when no id requested", () => {
expect(resolveScopedBot(null, ["a", "b"])).toBeNull();
expect(resolveScopedBot(undefined, ["a"])).toBeNull();
expect(resolveScopedBot("", ["a"])).toBeNull();
});
it("returns the id when it exists in the bot list", () => {
expect(resolveScopedBot("b", ["a", "b"])).toBe("b");
});
it("clears (null) when the requested id is not a known bot", () => {
expect(resolveScopedBot("ghost", ["a", "b"])).toBeNull();
});
});
```
- [ ] **Step 2 — run, expect fail:** `npx vitest run web/src/stores/scope.test.ts` → module missing.
(Note: the repo's vitest runs from root; this test lives under web/. If the root vitest config doesn't include web/src, run it via the web workspace: `cd web && npx vitest run src/stores/scope.test.ts`. Use whichever picks it up; confirm it FAILS first.)
- [ ] **Step 3 — implement the helper** (code above).
- [ ] **Step 4 — add scope state to `web/src/stores/player.ts`:**
- state: `scopedBotId: null as string | null`.
- getter: `isScoped: (state) => state.scopedBotId !== null`.
- actions:
- `setScope(id: string)` → `this.scopedBotId = id;` and also set `this.activeBotId = id` (scoped == active), then ensure that bot's queue is loaded like `setActiveBotId` does.
- `clearScope()` → `this.scopedBotId = null;`.
- `applyScopeFromQuery(requestedId: string | null)` → uses `resolveScopedBot(requestedId, this.bots.map(b => b.id))`; if result non-null → `setScope(result)`; if null and a scope was requested → `clearScope()`. (Called after bots are loaded.)
- Guard `setActiveBotId(id)`: at the top, `if (this.scopedBotId !== null && id !== this.scopedBotId) return;` so switching is blocked while scoped.
- [ ] **Step 5 — run helper test, expect pass:** `cd web && npx vitest run src/stores/scope.test.ts` → 3 pass. `cd web && npx vue-tsc --noEmit` → exit 0.
- [ ] **Step 6 — commit:** `git add web/src/stores/scope.ts web/src/stores/scope.test.ts web/src/stores/player.ts && git commit -m "feat(scope): player store scopedBotId + resolveScopedBot helper"`
---
## Task 2: Router guard — sync scope from `?bot` + preserve across navigation
**Files:** Modify `web/src/router/index.ts`.
READ the file: the existing `beforeEach` (~lines 36-60) handles needsSetup/auth. Add scope handling AFTER auth resolves (so we don't fight the login redirect). Import the player store (use it inside the guard via `usePlayerStore()` — Pinia is active by the time navigation runs).
- [ ] **Step 1 — implement.** In `beforeEach`, after the existing auth/needsSetup logic decides the navigation is allowed to proceed to `to` (i.e., not redirecting to /login or /first-run), add:
```typescript
const store = usePlayerStore();
const qBot = typeof to.query.bot === "string" ? to.query.bot : null;
if (qBot) {
// entering/with a scope in the URL — store will validate against bots later
store.scopedBotId = qBot; // tentative; applyScopeFromQuery (after fetchBots) confirms/clears
return next();
}
if (store.scopedBotId) {
// scoped but this navigation dropped ?bot → re-attach so the lock survives in-app nav + refresh
if (to.query.bot !== store.scopedBotId) {
return next({ ...to, query: { ...to.query, bot: store.scopedBotId } });
}
}
return next();
```
(Adapt to the file's existing `next()` style — it may use `next(...)`/return. Ensure this runs only for allowed navigations, not when redirecting to /login. The exit action in Task 4 calls `store.clearScope()` BEFORE navigating to `/`, so `store.scopedBotId` is null and the re-attach branch is skipped — that's how exit works.)
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0. Re-read the guard to ensure no redirect loop (when `to.query.bot === store.scopedBotId`, it does NOT redirect again).
- [ ] **Step 3 — commit:** `git add web/src/router/index.ts && git commit -m "feat(scope): router guard syncs + preserves ?bot across navigation"`
---
## Task 3: BotRedirect seeds the URL scope
**Files:** Modify `web/src/views/BotRedirect.vue`.
READ it: onMounted reads `route.params.id`, ensures `store.fetchBots()`, finds the bot; if found `store.setActiveBotId(id)` + `router.replace('/')`; else shows not-found.
- [ ] **Step 1 — implement.** Change the found-branch to seed scope via the URL instead of bouncing to a bare `/`:
- keep the fetchBots + existence check,
- if found: `router.replace({ path: '/', query: { bot: botId } })` (the router guard + store will set the scope). Optionally also call `store.setScope(botId)` directly for immediacy.
- if not found: unchanged (show "机器人不存在或未加载").
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0.
- [ ] **Step 3 — commit:** `git add web/src/views/BotRedirect.vue && git commit -m "feat(scope): dedicated link seeds ?bot scope instead of bare redirect"`
---
## Task 4: Navbar lock UI + apply-scope-on-load
**Files:** Modify `web/src/components/Navbar.vue`, `web/src/App.vue`.
READ both: Navbar has `controllableBots` (computed) + the dropdown selector + `selectBot`; App.vue onMounted calls `playerStore.fetchBots()` (+ loadTheme/connect).
- [ ] **Step 1 — Navbar lock.** When `store.isScoped`:
- render only the scoped bot (a `displayedBots` computed → if scoped, `controllableBots.filter(b => b.id === store.scopedBotId)`, else `controllableBots`),
- disable the dropdown open / switching (no chevron, or make the trigger non-interactive) so the user can't switch,
- hide other bots' "copy link" affordances (only the scoped bot remains anyway),
- show a small "专属模式" badge and an "退出" button → `store.clearScope(); router.push('/')` (clear BEFORE navigating so the guard doesn't re-attach `?bot`). Import `useRouter` if not present.
When not scoped: behavior unchanged.
- [ ] **Step 2 — apply scope on load (App.vue).** After `fetchBots()` resolves in onMounted, call `playerStore.applyScopeFromQuery(routeBot)` where `routeBot` is the current `?bot` query (via `useRoute().query.bot` as string|null). This confirms a refreshed `?bot` against the loaded bots and sets activeBotId (or gracefully clears if the bot is gone). (If Task 2's guard already set `scopedBotId` tentatively, this validates it against the now-loaded bot list.)
- [ ] **Step 3 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0. Read templates back for valid syntax; confirm read-only displays aren't broken and the non-scoped path is unchanged.
- [ ] **Step 4 — commit:** `git add web/src/components/Navbar.vue web/src/App.vue && git commit -m "feat(scope): lock Navbar selector to scoped bot + apply scope on load"`
---
## Final verification
- [ ] `cd web && npx vue-tsc --noEmit` → exit 0
- [ ] `npx tsc --noEmit` → exit 0 (backend unaffected)
- [ ] `cd web && npx vitest run src/stores/scope.test.ts` (or root vitest if it includes web) → pass
- [ ] `npm run build` → succeeds
- [ ] Manual: open `/bot/<id>` → URL becomes `/?bot=<id>`, selector shows only that bot, switching disabled; **refresh → still locked** (item 4 fixed); navigate to Search → URL keeps `?bot`; refresh on Search → still locked; click 退出 → back to all bots (`/`, no `?bot`); open `/` directly → full multi-bot control; open `/?bot=<nonexistent>` → gracefully shows all bots (no lock).
## Notes
- Backend per-bot authorization (PR #80) is the real security boundary; this is a UX lock.
- No localStorage — URL is the source of truth, so the lock is shareable and self-clearing.
- Item 4 is fixed as a consequence of carrying `?bot` in the URL across refresh/navigation.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,149 @@
# 自定义机器人头像 + 专辑搜索/播放
**Date:** 2026-05-07
**Status:** Spec — pending implementation
**Issue:** [#51](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/51)
## Problem
Issue #51 的两个独立但同源的反馈:
1. **机器人头像无法固定** — 当前 `ProfileConfig.avatarEnabled` 控制是否同步专辑封面,但没有任何"上传一张固定头像"的入口。多 bot 房间里用户依赖头像辨识具体 bot,封面跟着歌变会让识别成本变高。
2. **网页端搜索不能播放整张专辑** — `SearchResult.albums` 类型字段存在但所有 provider 都返回 `[]`,搜索 API `/search/all` 只聚合 `songs`;Search.vue 里也只渲染 SongCard。Netease 后端 `getAlbumSongs` 已实现,唯独缺把搜索/UI 连起来。
两件事独立,分两个 PR;本 spec 同时覆盖两块以保持 #51 的单一 issue 关系。
## Goal
### 自定义头像
- "创建新实例"弹窗里有一个"自定义头像"上传/预览控件(PNG/JPG/WebP,≤200 KB,与 TS3 头像上限一致)
- Settings 已有的"同步头像"那行下面增加同等的"自定义头像"卡片,可以在已存在的 bot 上随时改/删
- 行为矩阵:
| `avatarEnabled` | 有自定义 | 播放时 | 停播时 |
|---|---|---|---|
| true | 是 | 跟当前歌曲封面 | **回到自定义** |
| true | 否 | 跟当前歌曲封面 | 清空(保持现状) |
| false | 是 | 一直显示自定义 | 一直显示自定义 |
| false | 否 | 不主动改 | 不主动改 |
### 专辑搜索
- 搜索结果里能看到"专辑"分区(先支持 Netease + QQ,bilibili/youtube 仍返回 `[]`)
- 点专辑卡片进入详情页 → 看到曲目列表 + 顶部"播放全部 / 加入队列"
## Out of Scope
- 头像格式自动转换(用户传 GIF/BMP 不接受,前端校验拒掉)
- 头像服务端自动 resize(本期保持"上传时校验大小",后期可加 sharp/jimp 但不在本期)
- 专辑搜索的多平台聚合排序(按 `netease → qq` 简单拼接,与现有 `songs` 聚合一致)
- 专辑详情页的"喜欢/收藏"按钮(playlist 详情页本身也没有)
- 专辑作为推荐位(Home 不出现"推荐专辑"这一栏)
- bilibili / youtube 的专辑概念(这两个平台无对应 API)
## Architecture
### 自定义头像
#### 存储
- 文件落地 `data/avatars/<botId>.<ext>`(仿 `data/cookies/<platform>.json`,Docker volume 友好)
- DB schema:`bot_instances` 表新增 `custom_avatar_path` TEXT NULL(存相对路径,如 `avatars/<botId>.png`),通过 `migrateSchema()` 迁移
- 加载时机:`BotProfileManager` 构造时把文件读到内存 `Buffer`,避免每次 stop 都读盘
#### 后端 API
新增 `src/web/api/bot.ts` 里(如不存在则在 `instance.ts` 同源处):
- `POST /api/bot/:id/avatar` (multipart) — 校验大小 ≤200 KB、MIME ∈ {png,jpeg,webp};写盘 + 更新 DB;广播给运行中实例(重新加载 buffer + 立即 `applyIdleAvatar()`)
- `DELETE /api/bot/:id/avatar` — 删盘 + 清 DB;运行中实例切回原 clear 语义
- `GET /api/bot/:id/avatar` — 直接 `res.sendFile`(带强 ETag)供前端预览
#### `BotProfileManager` 改动
新增字段 + 方法:
```ts
private customAvatar: Buffer | null = null;
setCustomAvatar(buf: Buffer | null): void;
private async applyIdleAvatar(gen: number): Promise<void>; // 上传 customAvatar
```
修改:
- `clearAvatar(gen)` → `if (this.customAvatar) { applyIdleAvatar(gen) } else { 当前逻辑 }`
- `onConnect()` 新增:`if (!avatarEnabled && customAvatar) applyIdleAvatar(gen)`
- `setCustomAvatar(buf)`:更新内存 buffer,并触发 `applyIdleAvatar` 一次(仅当当前应该显示 idle avatar 时,即没在播放或 avatarEnabled=false)
#### 前端
新组件 `web/src/components/AvatarUpload.vue`:
- props: `botId?` (上传时空表示走临时 base64 缓存)、`v-model:value`
- 拖拽 / 文件选择 / 预览圆框 / 删除按钮
- 内部 `axios.post('/api/bot/<id>/avatar', formData)` 或在创建表单里把 base64 与表单一同提交
接入点:
- 创建实例弹窗(搜索 `BotEditor.vue` 或类似)—— 表单提交后用返回的 botId 再 POST 头像;或者表单本身保存 base64 等创建完成后由后端解码落盘
- Settings.vue:在 features 列表中插入一行"自定义头像",右侧渲染 `<AvatarUpload :bot-id="botId" />`
### 专辑搜索 / 详情
#### 后端
`src/music/netease.ts` `search()`:
- 多发一个 `cloudsearch?type=10` 请求,把返回的 `result.albums[]` 映射成 `Album[]` 填进 `SearchResult.albums`
- 字段 `id` / `name` / `coverUrl` (`picUrl`) / `artist` (`artists[].name.join(' / ')`)
`src/music/qq.ts` `search()`:
- 在现有 `req_0` 旁增加 `req_album: { module: "music.search.SearchCgiService", method: "DoSearchForQQMusicDesktop", param: { searchid, query, search_type: 8 } }`,映射 `body.album.list[]`
`src/web/api/music.ts` `/search/all`:
- 在响应里增加 `albums` 和 `playlists`,与 `songs` 一同合并
`/album/:id` 已存在,无需改动。
#### 前端
- `web/src/views/Search.vue`:响应 schema 升级为 `{songs, albums, playlists}`;模板加入两个新分区("专辑"、"歌单"),各自一个简单的卡片网格(参考 Home.vue 的 `playlist-grid`)
- 新路由 `/album/:id` → 复用 `Playlist.vue`,把它的 `loadPlaylist()` 重构为根据 `route.path` 决定调 `/playlist/:id` 还是 `/album/:id`,或者新建 `Album.vue` 内部 import 同一个 `<PlaylistDetail />` 子组件
- **方案选择**:拆出 `<PlaylistDetail :endpoint="...">` 组件 + `Album.vue` / `Playlist.vue` 两个薄壳。当前 `Playlist.vue` 内部仅 ~60 行模板,单文件改造比新建 PlaylistDetail 子组件更小,先用最小改动:在 `Playlist.vue` 内根据 `route.meta.kind === 'album'` 切换 endpoint
- 路由:`router/index.ts` 加 `{ path: '/album/:id', component: Playlist, meta: { kind: 'album' } }`
### 拆分
**两个 PR:**
1. `feat(profile): custom bot avatar with idle/playback precedence`
- DB migration + ProfileManager 改动 + 上传 API + AvatarUpload.vue + 接入两个表单
2. `feat(search): album section in search results + album detail playback`
- netease/qq search 扩展 + /search/all + Search.vue 分区 + Playlist.vue 复用为 album
## Testing
### 自定义头像
- 单元:DB 迁移加 `custom_avatar_path` 列幂等;上传 API 校验大小/MIME;ProfileManager.applyIdleAvatar 在 onSongChange(null) 后被调用
- 集成:mock TS3Client 验证 fileTransferInitUpload 收到的 buffer 是 customAvatar
- 手动:本地起 bot 上传一张 png → 检查头像;播一首歌 → 头像切封面;停止 → 头像回到 png;关掉 avatarEnabled 重启 → 头像直接是 png
### 专辑搜索
- 单元:netease/qq `search()` 测试:响应包含 albums 字段,长度 > 0 (mock fixture 必须含 album 段)
- 集成:`/search/all` 响应 schema 包含 `albums`/`playlists`
- 手动:搜"周杰伦" → 看到歌曲 + 专辑 + 歌单三个分区;点专辑 → 详情页 → 播放全部 → 队列加上整张专辑
## Migration
DB 迁移:`bot_instances.custom_avatar_path` TEXT NULL,默认 NULL。已存在 bot 不受影响。
## Open Questions
- TS6 协议路径下 `fileTransferInitUpload` 是否一致?(既有 avatar 流程已经覆盖 TS3 + TS6,本期沿用同一路径,不单独验证)
- 头像超过 200 KB 时前端用 Canvas 自动 resize 还是直接拒?— **决定:拒,错误提示"请压缩到 200KB 以内"**,简单可控
@@ -0,0 +1,360 @@
# WebUI Authentication
**Date:** 2026-05-27
**Status:** Spec — pending implementation
**Branch:** `feat/webui-auth`
## Problem
WebUI 的所有后端端点和 WebSocket 当前没有任何鉴权:
- `src/web/server.ts` 注册的 `/api/bot`、`/api/player`、`/api/music`、`/api/auth`、`/api/config/public-url`、`/api/health`、`/ws` 均无中间件拦截。
- 静态前端通过 `express.static()` 直接对外提供。
后果:任何能访问 WebUI 端口(默认 `3000`)的人都能控制 bot、修改配置、操控播放,并触发对网易云 / QQ / Bilibili 的登录二维码流程。一旦 WebUI 端口暴露公网(无论是直接绑定 `0.0.0.0`、还是经 nginx 反代),即被任意访客接管。
## Goal
为 WebUI 增加用户名 + 密码登录,覆盖所有 HTTP `/api/*` 端点(除显式公共白名单)以及 `/ws` WebSocket,使未登录访客无法调用任何敏感接口或观察 bot 状态。
## Out of Scope(明确不做)
- 登录失败的限流 / 锁定(无 brute-force 防御;可放在反代层;后续 PR 单独做)
- 角色与权限(admin / viewer)—— 全员同权
- 密码重置流程(不挂邮件;仅提供登录后 `change-password`)
- 双因素认证(2FA)
- "记住我" / 绝对过期 vs 滑动过期的可配置
- 旧版"无鉴权"兼容开关(`requireAuth=false`)—— 合入后所有部署强制启用鉴权
- 现有 `config.adminPassword` 字段的迁移 —— 保留为未使用字段,避免破坏旧 `config.json`
## Non-functional Constraints
- 不引入需要原生编译的依赖(Windows 用户多,build tools 不稳定)。密码哈希用纯 JS 的 `bcryptjs`。
- Cookie 行为必须兼容现有 `trustProxy` 反代部署。
- 升级路径:旧用户首次启动新版本 → 自动进入 `/setup` 创建首位 admin;期间所有 `/api/*` 仍拒绝访问。期间不存在"裸奔窗口"。
- 后续维护者要能在不阅读 `requireAuth` 内部细节的情况下,把新路由挂到 `/api/*` 下并自动获得鉴权。
## Architecture
### 数据层(`src/data/`)
扩展 `src/data/database.ts` 的 schema-migration 块,新增两张表:
```sql
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, -- uuid v4
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
passwordHash TEXT NOT NULL, -- bcryptjs, 12 rounds
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, -- sha256(rawToken) hex
userId TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
createdAt INTEGER NOT NULL,
expiresAt INTEGER NOT NULL, -- ms epoch
lastSeenAt INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId);
CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt);
```
**为什么 `sessions.id` 存 sha256(token) 而不是 token 本身:** 若 SQLite 文件被泄露(备份、误传、磁盘扫描),原始 token 会让攻击者直接冒充任意已登录用户。存 hash 后只能爆破。代价仅是每次请求一次 sha256。
新模块:
`src/data/users.ts`
- `createUser(username, password): User` — 在事务里 INSERT;遇到 UNIQUE 冲突抛出 `UsernameTakenError`
- `findByUsername(username): User | null`
- `verifyPassword(plain, hash): Promise<boolean>` — bcryptjs compare
- `countUsers(): number` — 用于 `/needs-setup`
- `changePassword(userId, newPassword): void`
`src/data/sessions.ts`
- `createSession(userId): { token: string; expiresAt: number }` — 生成 32 字节随机 token(`crypto.randomBytes(32).toString('base64url')`),存 sha256
- `validateAndTouch(rawToken): { userId, username } | null` — 单次 SQL JOIN:查 session + user;过期 → 返回 null + 删除该行;否则若 `now - lastSeenAt > 1h` 则 UPDATE 滑动续期到 `now + 7d`
- `deleteSession(rawToken): void` — 退出
- `deleteAllForUser(userId, exceptToken?): void` — change-password 时调用,可保留当前会话
- `cleanupExpired(): void` — 定时任务
### HTTP 层(`src/web/`)
#### 新增中间件
`src/web/middleware/requireAuth.ts`
```
读取 req.cookies.tsmb_session
→ 缺失 → 401 { error: "unauthenticated" }
→ 调 sessions.validateAndTouch
→ null → 清 cookie + 401
→ 有效 → req.user = { id, username }; next()
```
`src/web/middleware/csrf.ts`
```
若 method ∈ {GET, HEAD, OPTIONS} → next()
否则要求 req.headers.origin || req.headers.referer 的 host 与 req.get('host') 一致
→ 不一致或两者都缺失 → 403 { error: "bad origin" }
```
#### 新路由:`src/web/api/session.ts`
挂在 `/api/session`,全部公共(不挂 requireAuth):
| Method | Path | 行为 |
|---|---|---|
| GET | `/needs-setup` | `{ needsSetup: users.countUsers() === 0 }` |
| POST | `/setup` | Body `{ username, password }`。在事务内再次检查 `countUsers() === 0`:是则 INSERT user + 立刻 createSession + Set-Cookie + 200 `{ id, username }`;否则 409 `{ error: "already initialized" }` |
| POST | `/login` | Body `{ username, password }`。匹配则 createSession + Set-Cookie + 200;不匹配则等待 250ms 后 401 `{ error: "invalid credentials" }`(常量时间延迟,降低用户名枚举风险) |
| POST | `/logout` | 删 session,清 cookie,204 |
| GET | `/me` | 走 requireAuth;返回 `{ id, username }` |
| POST | `/change-password` | 走 requireAuth;Body `{ oldPassword, newPassword }`;通过则 changePassword + deleteAllForUser(except 当前) + 204 |
> `/me` 与 `/change-password` 例外地需要 requireAuth —— 在路由内单独挂中间件,避免污染 `/api/session/*` 的公共属性。
#### Cookie 规范
- 名称:`tsmb_session`
- 值:32 字节 random → base64url
- 属性:`HttpOnly; SameSite=Lax; Path=/; Max-Age=604800`(7 天)
- `Secure` 标志:当 `req.secure === true`(依赖 `trustProxy` + `X-Forwarded-Proto`);本地 HTTP 调试时不加,避免 cookie 被丢弃
#### 装配顺序(`src/web/server.ts`)
```ts
app.use(express.json({ limit: "400kb" }));
app.use(cookieParser()); // 新增
// 公共
app.get("/api/health", …);
app.get("/api/config/public-url", …);
app.use("/api/session", createSessionRouter(...));
// 闸门(仅作用于下方注册的 /api/* 路由)
app.use("/api", csrfOriginCheck);
app.use("/api", requireAuth);
// 受保护
app.use("/api/bot", createBotRouter(...));
app.use("/api/music", createMusicRouter(...));
app.use("/api/player", createPlayerRouter(...));
app.use("/api/auth", createAuthRouter(...)); // 音乐平台 QR
// 静态 SPA(公共,前端自行判定登录态后跳转)
app.use(express.static(staticDir));
app.get(/^(?!\/api|\/ws)/, sendIndex);
```
> Express 的 `app.use` 仅对匹配前缀生效。公共路由先注册即可命中;之后的 `app.use("/api", …)` 闸门只在公共路由未匹配时执行,因此 `/api/health`、`/api/config/public-url`、`/api/session/*` 不会被闸门拦截。
#### 定时清理
`server.start()` 内启动 `setInterval(cleanupExpired, 60 * 60 * 1000)`,`server.stop()` 内 `clearInterval`。
### WebSocket 层(`src/web/websocket.ts` + `src/web/server.ts`)
改造为手动 upgrade:
```ts
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (req, socket, head) => {
if (req.url !== "/ws") { socket.destroy(); return; }
const session = validateCookieFromHeaders(req.headers.cookie);
if (!session) {
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
(ws as any).userId = session.userId;
wss.emit("connection", ws, req);
});
});
```
`validateCookieFromHeaders` 在 `src/web/auth/validateSession.ts` 提供,HTTP 中间件与 WS upgrade 共用同一实现,确保不会出现"HTTP 拒、WS 放行"或反之的偏差。
不需要在 upgrade 上单独做 CSRF:浏览器在跨站 WebSocket 请求里仍会带 Origin 头,可在 validate 之外顺手比对 `req.headers.origin` host 与 `req.headers.host` 一致;不一致直接拒绝。
### 前端层(`web/`)
#### 新视图
- `web/src/views/Login.vue` — 用户名 + 密码表单 → POST `/api/session/login` → 成功跳 `next` 或 `/`
- `web/src/views/FirstRunSetup.vue` — 同样表单 + 二次确认密码 → POST `/api/session/setup` → 成功后自动登录并跳 `/`
- 名称避免与既有 `Setup.vue`(bot 创建向导)冲突
#### Session 状态
新增 `web/src/composables/useSession.ts`:暴露 `currentUser: Ref<User|null>`、`refresh()`、`logout()`、`needsSetup: Ref<boolean>`。在 `App.vue` mount 时调用 `refresh()`。
#### 路由守卫(`web/src/router/index.ts`)
- 公共路由:`/login`、`/setup`
- 全局 `beforeEach`:
1. 先 `GET /api/session/needs-setup`(仅在 `needsSetup` 未知时拉一次并缓存)
2. `needsSetup === true` 且目标不是 `/setup` → `redirect('/setup')`
3. 否则 `GET /api/session/me`,401 且目标非公共路由 → `redirect('/login?next=<path>')`
#### API 客户端
- 所有 `fetch` 改为 `credentials: 'same-origin'`(若现有有 wrapper 则改一处;否则按文件逐个改 —— 实施时由 plan 列出)
- 包一层 401 拦截器:任意受保护请求返回 401 → 清 `currentUser` → `router.push('/login')`
#### UI
- 顶栏新增已登录用户名 + "退出"按钮(POST `/logout` → `router.push('/login')`)
- 修改密码入口暂放在已有的"设置"页签内(若无则新增极简 section)
### 依赖
新增到 `package.json`:
```
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.7"
```
不引入 `express-session`、`jsonwebtoken`、`passport` 等更大栈。
## Data Flow
### 首次启动
```
Browser → GET / → static SPA
SPA mounted → GET /api/session/needs-setup → { needsSetup: true }
SPA → router.replace('/setup')
User submits form → POST /api/session/setup
Server (TX): countUsers() === 0 → INSERT user → createSession → Set-Cookie → 200
SPA → currentUser refresh → router.replace('/')
```
### 已部署用户升级
旧 `config.adminPassword` 字段保留不动;首次启动新版本仍会因 `users` 表为空而进入 setup 流程 —— 旧字段不被采纳,避免歧义。
### 后续登录
```
SPA → GET /api/session/me → 401
SPA → router.replace('/login?next=/queue')
User submits → POST /api/session/login → Set-Cookie + 200
SPA → currentUser refresh → router.replace('/queue')
```
### 受保护请求
```
SPA → fetch('/api/bot', { credentials: 'same-origin' })
Server requireAuth: validateAndTouch(cookie)
→ ok → req.user 注入 → 业务路由处理
→ 不 ok → 401 → SPA 拦截器跳 /login
```
### WebSocket
```
SPA → new WebSocket(`${wsScheme}://${host}/ws`) // 浏览器自动带 cookie
Server upgrade handler: validateCookieFromHeaders
→ ok → handleUpgrade → connection event
→ 不 ok → HTTP 401 写回原始 socket → destroy
```
## Error Handling
| 场景 | HTTP 响应 | 备注 |
|---|---|---|
| 未带 cookie | 401 `{ error: "unauthenticated" }` | requireAuth |
| Cookie 解析失败 / token 不存在 | 401 + `Set-Cookie tsmb_session=; Max-Age=0` 清掉 | 自愈 |
| Session 过期 | 同上 + DELETE 该行 | validateAndTouch 内部完成 |
| 用户名/密码不匹配 | 401 `{ error: "invalid credentials" }` + 250ms 延迟 | 不区分"用户不存在"和"密码错"两类 |
| `setup` 时已存在用户 | 409 `{ error: "already initialized" }` | 防止重复初始化 |
| `setup` 用户名重复 | 在 `/setup` 流程中不可能(只允许 0 → 1) | |
| `change-password` 旧密码错 | 401 `{ error: "invalid credentials" }` | |
| CSRF Origin 不匹配 | 403 `{ error: "bad origin" }` | |
| WS 无 cookie / 校验失败 | 写回 HTTP/1.1 401 并 destroy socket | 在握手前拒绝,避免 onopen 假成功 |
所有错误响应统一 `{ error: string }` 形式,匹配现有 API 风格。
## Testing Strategy
### 单元(vitest)
`src/data/users.test.ts`
- createUser 成功后 findByUsername 命中(大小写不敏感)
- 重复 username 抛 UsernameTakenError
- verifyPassword 正反例
- changePassword 之后旧哈希不再验证通过
`src/data/sessions.test.ts`
- createSession 返回的 token 不是 DB 内 id(DB 内是 sha256(token))
- validateAndTouch 过期记录返回 null 且记录被删
- validateAndTouch 未过 1h 不写 DB;过 1h 后写 DB(用 `Date.now` mock 验证)
- deleteAllForUser(exceptToken) 保留指定会话
### 集成(vitest + supertest,真 SQLite in-memory)
`src/web/api/session.test.ts`
- empty DB → /needs-setup 返回 true;/setup 成功;/needs-setup 再调返回 false;二次 /setup 返回 409
- /login 成功后受保护路由 (`GET /api/bot`) 200;不带 cookie 401
- /logout 之后同一 cookie 调受保护路由 401
- /change-password 后 a) 旧密码 /login 失败 b) 新密码 /login 成功 c) 之前签发的其他 cookie 失效,当前 cookie 仍可用
`src/web/middleware/csrf.test.ts`
- 带匹配 Origin 的 POST 通过
- Origin 与 host 不匹配 → 403
- 同样规则适用 Referer
- GET 永远通过
`src/web/websocket.test.ts`(新增或扩展)
- 无 cookie 的 ws 握手 → 收到 HTTP 401,socket 关闭
- 带有效 cookie → 握手成功,收到 init 消息
- Session 删除后已建立的 ws **不会**被主动断(明确记录此妥协 —— 见 Trade-offs)
### 前端
不在本 PR 引入新的 e2e 框架。手动用例(在 PR 描述里列):
- 全新数据库启动 → 自动跳 /setup → 创建账户 → 进入主界面
- 退出 → 自动跳 /login
- 关闭浏览器 7 天内再开 → 仍登录
- 登录态下后端重启清空 sessions → 任意 API 调用 → 自动跳 /login
## Files Changed
```
src/data/database.ts (schema migration)
src/data/users.ts (new)
src/data/users.test.ts (new)
src/data/sessions.ts (new)
src/data/sessions.test.ts (new)
src/web/auth/validateSession.ts (new, shared by HTTP + WS)
src/web/middleware/requireAuth.ts (new)
src/web/middleware/csrf.ts (new)
src/web/middleware/csrf.test.ts (new)
src/web/api/session.ts (new)
src/web/api/session.test.ts (new)
src/web/server.ts (cookieParser + 公共白名单 + 闸门 + cleanup interval + WS upgrade 重构调用)
src/web/websocket.ts (移除被动 path 绑定;改为 handleUpgrade 模式)
src/web/websocket.test.ts (新增 / 扩展)
package.json (deps)
web/src/views/Login.vue (new)
web/src/views/FirstRunSetup.vue (new)
web/src/composables/useSession.ts (new)
web/src/router/index.ts (公共路由 + beforeEach 守卫)
web/src/api/*.ts (credentials: 'same-origin' + 401 拦截)
web/src/App.vue (顶栏 logout + 当前用户名)
```
## Trade-offs / 已知妥协
1. **会话失效不主动断 WS** —— 后台 deleteSession 后,已有 WS 仍在跑(直到客户端断或服务端进程重启)。原因:WS 长连接没有"每条消息再次鉴权"的廉价手段;为此引入会浪费时间。影响面有限:WS 只推状态、不接收 mutating 命令;所有写操作仍走 HTTP。
2. **无登录限流** —— 见 Out of Scope。若部署面向公网,建议在反代层加 limit(如 nginx `limit_req`)。
3. **`config.adminPassword` 留作未使用字段** —— 不迁移、不读取。后续 PR 可移除并加 schema migration。当前保留是为避免破坏旧 `config.json` 解析。
4. **单一管理员模型** —— 多用户表已存在,但 UI 当前不暴露增删用户。下一个 PR 再加用户管理界面。
5. **Origin/Referer CSRF 检查** —— 不是 token,但配合 `SameSite=Lax` 已能挡掉常规 CSRF 攻击。代价:会拒绝缺 Origin/Referer 的非浏览器客户端 POST 请求(如裸 curl)—— 这是预期行为。
@@ -0,0 +1,167 @@
# Fine-grained account permissions — design
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) (item E — the maintainer's permission-management idea)
**Date:** 2026-05-30
**Status:** Approved (brainstorm), pending implementation plan
## Scope
Issue #79 bundles five things. This spec covers **only item E**: allow an admin to
grant each non-admin (member) account a set of capabilities and a list of bots they
may control. The other items are handled in separate PRs and are **out of scope**
here:
- #1 Guest mode (login-less playback)
- #2 Dedicated-link hides other bots (subsumed conceptually by E's bot allow-list, but the link-specific UX is separate)
- #3 Auto-pause when channel empty
- #4 Dedicated link loses bot binding on refresh (a bug)
## Problem
Today the bot has a coarse two-role system: `admin | member` (single `role` column,
read live per request). `requireAdmin` gates only `/api/users` and `/api/audit`.
**Every other action — create/edit/delete bots, start/stop, all playback & queue
control, set platform login cookies, set audio quality — is open to any logged-in
member, on every bot.** Admins want to delegate limited control to members without
handing them full power.
## Decisions (from brainstorm)
1. **Model = capability flags + per-member bot allow-list** (not a per-bot×per-action
matrix, not role templates).
2. **Defaults:** on upgrade, existing members are backfilled with full capabilities +
all bots (no behavior change); newly-created members get a **basic tier**.
3. **Bot allow-list semantics:** an explicit "all bots" toggle OR a specific list;
empty list = no bots controllable. Members **cannot see** bots outside their
allow-list (hidden, not merely disabled).
4. **Capability set (5 toggles)** — see below; basic tier = playback + queue + all bots.
5. **Admin is a super-user** (bypasses all checks). The last admin cannot be demoted
(existing invariant preserved). Permission grants/revokes are written to the
existing audit log.
## Capability taxonomy
| Capability token | Covers | Scope |
|---|---|---|
| `player.control` | play/pause/resume/next/prev/stop/seek/volume/mode | per-bot (allow-list) |
| `player.queue` | search-add / clear / remove / play-at / playlist / album / play-song | per-bot (allow-list) |
| `bot.manage` | create / edit / delete / start / stop / avatar / profile / idle settings | global (create) + per-bot (operate a specific bot) |
| `platform.auth` | set NetEase/QQ/Bilibili cookie, QR, SMS | **global** (shared credentials) |
| `quality` | set audio quality per platform | **global** |
Bot scope is independent of capabilities: a member with `player.control` can only
exercise it on bots in their allow-list (or all, if the "all bots" flag is set).
`platform.auth` and `quality` are global capabilities with no bot scope.
**Basic tier** (new members): `{ player.control, player.queue }` + `bots.all = true`.
A new member can play/queue on every bot but cannot manage bots, change credentials,
or change quality.
## Data model (SQLite, additive — follows existing `CREATE TABLE IF NOT EXISTS` pattern)
```sql
-- capability tokens + the "all bots" flag (stored as token 'bots.all')
CREATE TABLE IF NOT EXISTS user_permissions (
userId TEXT NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (userId, permission),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
-- specific bot allow-list (only consulted when 'bots.all' is NOT present)
CREATE TABLE IF NOT EXISTS user_bot_access (
userId TEXT NOT NULL,
botId TEXT NOT NULL,
PRIMARY KEY (userId, botId),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId);
```
- Admins have no rows (they bypass). Only members are constrained.
- `bots.all` present ⇒ all bots (incl. future ones). Absent ⇒ only `user_bot_access`
rows; empty ⇒ none.
- `foreign_keys = ON` and WAL are already enabled; cascade-on-user-delete works.
- `user_bot_access.botId` references bot instance ids; when a bot is deleted, its
access rows should be cleaned up (either an FK to the bot table if one exists, or an
explicit cleanup in `BotManager.removeBot` / `PermissionStore.pruneBot(botId)`).
New `PermissionStore` in `src/data/permissions.ts` (mirrors `createUserStore` /
`createSessionStore`: prepared statements + an interface). Methods:
`getCapabilities(userId)`, `getBotAccess(userId)` → `'all' | string[]`,
`setPermissions(userId, { capabilities, bots })`, `pruneBot(botId)`.
## Backend enforcement (real 403 — not just hidden UI)
- **`req.user` widened** to carry `capabilities: Set<string>` and bot access. Loaded in
`requireAuth` (one extra lookup, or a JOIN in the session query). The same
`{id,username,role,capabilities,bots}` shape must be kept in sync in the three places
it is built today: `requireAuth.ts`, `session.ts` `requireAuthInline`, and the WS
upgrade handler in `server.ts` (WS only needs it if a push action becomes gated).
Because it's read live, permission changes take effect immediately (no re-login).
- **`requirePermission(cap)`** middleware (new, mirrors `requireAdmin.ts`): 401 if no
user; allow if `role === 'admin'` or `capabilities.has(cap)`; else 403.
- **`requireBotAccess`** helper: allow if admin or `bots.all` or botId ∈ access list;
else 403. Mounted on the player router's existing `/:botId` choke-point
(`src/web/api/player.ts`) and on each `:id` route in `src/web/api/bot.ts`
(start/stop/edit/delete/avatar/profile).
- **Route → capability mapping:**
- `/api/player/:botId/*` playback actions → `player.control` (+ `requireBotAccess`)
- `/api/player/:botId/*` queue actions → `player.queue` (+ `requireBotAccess`)
- `/api/bot` create, `/api/bot/:id` edit/delete, `/api/bot/:id/start|stop|avatar|profile`, `/api/bot/settings` → `bot.manage` (+ `requireBotAccess` for the `:id` ones)
- `/api/auth/*` (cookie/QR/SMS) → `platform.auth`
- `/api/music/quality` POST → `quality`
- **`GET /api/bot`** filters its result to the caller's allowed bots for members
(admins see all). This is what "hides" disallowed bots in the UI.
## Management API (admin-only, added to the existing users router)
- `GET /api/users/:id/permissions` → `{ capabilities: string[], bots: 'all' | string[] }`
- `PUT /api/users/:id/permissions` → body `{ capabilities, bots }`; validates tokens
against the known set and botIds against existing bots; writes audit
`user.permissions_changed`.
- `GET /api/session/me` is extended to include the **current** user's
`{ capabilities, bots }` so the frontend can gate UI. (admins report effectively-all.)
## Frontend
- `useSession` extends `User` with `capabilities` + bot scope and exposes
`can(cap)` and `canControlBot(botId)` helpers.
- **Navbar bot selector** filters `store.bots` to controllable bots (others hidden);
`activeBot` fallback and `fetchBots` default only ever land on an allowed bot.
- **Player / Settings** hide controls and whole sections a member lacks: platform
login, audio quality, and bot create/edit/delete are hidden without the matching
capability; playback/queue buttons hidden without `player.control` / `player.queue`.
- **Admin permission editor:** in the Settings → User Management list, each member row
gets a "权限" editor — capability checkboxes + a bot allow-list with an "全部机器人"
toggle. Saving calls `PUT /api/users/:id/permissions`.
## Defaults & migration
- New tables created idempotently in `initTables`.
- **One-time backfill** (guarded so it runs once): every existing `member` gets all
five capabilities + `bots.all`. Admins are skipped (they bypass). This preserves
current behavior for existing members on upgrade.
- **New member default** (`POST /api/users` with role member): capabilities
`{ player.control, player.queue }` + `bots.all` (basic tier).
- Pre-existing accounts default to `role = 'admin'` per the current schema — those are
super-users and unaffected.
## Testing (TDD)
- `PermissionStore` unit tests (set/get capabilities + bot access; `'all'` vs list vs
empty; `pruneBot`).
- `requirePermission` / `requireBotAccess` middleware tests (admin bypass; has/lacks
cap → 200/403; bot in/out of allow-list; `bots.all`).
- API tests: member without cap → 403; with cap → 200; bot not allowed → 403/hidden;
`GET /api/bot` filtered for members, full for admin; `PUT .../permissions` validates
+ audits.
- Migration test: existing members backfilled to full + `bots.all`; new member gets
basic tier.
## Non-goals
- No per-bot×per-capability matrix, no custom role templates (YAGNI).
- Guest mode, dedicated-link UX, auto-pause, and the refresh bug (#1–#4) are separate.
- No change to the admin/member role concept itself; this layers capabilities under
the existing `member` role.
@@ -0,0 +1,138 @@
# Auto-pause on empty channel — design
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) item 3
**Date:** 2026-05-30
**Status:** Approved (brainstorm), pending implementation plan
## Problem
When everyone leaves the bot's voice channel, music keeps playing to an empty room.
The maintainer wants an option to **auto-pause when the channel is empty** (no disconnect)
and resume when someone returns.
## Decisions (from brainstorm)
- **Global toggle**, reusing the **already-declared but currently dead** `config.autoPauseOnEmpty`
(`src/data/config.ts`, default `true`). No per-bot granularity (YAGNI).
- **Event-driven, near-instant** reaction (not the 30s poll alone) — subscribe to TS client
enter/leave/move events; keep the existing 30s idle poll as a fallback.
- **Auto-resume only what we auto-paused** — a user-paused track is never auto-resumed.
- Independent of the existing **idle-disconnect** (`idleTimeoutMinutes`): both share the same
emptiness signal but act independently (pause immediately; disconnect after N minutes).
## Current state (verified)
- `client.ts` `getClientsInChannel()` returns all clients in the bot's channel *including the
bot*; callers compute "others" as `length - 1`. No persistent roster.
- The library emits `clientEnter` / `clientLeave` / `clientMoved`; `client.ts` currently only
*logs* `clientEnter` and does not re-emit leave/moved.
- The idle poller in `instance.ts` (`_startIdlePoller`, every 30s) already computes
`userCount = getClientsInChannel().length - 1` and, when `<= 0`, schedules an
idle-disconnect after `idleTimeoutMinutes`.
- `player.pause()` / `player.resume()` already pause/resume **without disconnecting** (ffmpeg
stays alive, no voice sent). The player only knows `idle|playing|paused` — there is **no**
auto-vs-user-pause distinction today.
- `BotConfig.autoPauseOnEmpty` exists (default true) but is **read nowhere**.
## Design
### Occupancy signal (shared)
Extract the idle poller's count into one method on `BotInstance`:
`checkChannelOccupancy()` → queries `getClientsInChannel()`, computes `userCount = length - 1`,
and drives **both** the existing idle-disconnect timer (unchanged behavior) **and** the new
auto-pause logic below. It is called by:
1. the existing 30s poll (fallback), and
2. new TS event handlers.
### Event subscription
`client.ts`: subscribe to and **re-emit** `clientEnter`, `clientLeave`, `clientMoved` up to
`BotInstance`. `BotInstance.setupTsEvents()` calls `checkChannelOccupancy()` on each (a re-query
is simplest, since `clientLeave` carries no channel id). This gives near-instant pause/resume;
the poll remains as a safety net.
### Auto-pause logic (inside `checkChannelOccupancy`)
Add a private `autoPaused = false` flag to `BotInstance`.
- **Empty** (`userCount <= 0`): if `config.autoPauseOnEmpty` **and** `player.getState() === "playing"`
→ `player.pause()`, `autoPaused = true`, emit `stateChange`. (Idle-disconnect timer still
scheduled as today.)
- **Re-populated** (`userCount > 0`): if `autoPaused` **and** `player.getState() === "paused"`
→ `player.resume()`, `autoPaused = false`, emit `stateChange`. (Idle timer cancelled as today.)
### `autoPaused` bookkeeping (so user pauses are respected)
Clear `autoPaused = false` in `cmdPause`, `cmdResume`, `cmdStop`, `cmdPlay`, and on
connect/disconnect (the `disconnected` handler calls `player.stop()` → idle). Net effect: only a
track *we* auto-paused gets auto-resumed; a user-paused track stays paused when someone returns.
### Config wiring
- `GET /api/bot/settings`: include `autoPauseOnEmpty` in the payload (alongside `idleTimeoutMinutes`).
- `POST /api/bot/settings`: accept + validate a boolean `autoPauseOnEmpty`, `saveConfig`, and
propagate to live bots via a new `BotInstance.updateAutoPause(enabled)` (mirrors
`updateIdleTimeout`). Since the instance reads `this.config.autoPauseOnEmpty` live, propagation
can be as simple as updating the stored config reference / a field the check reads.
- Frontend `Settings.vue` → the **行为设置** section (already `bot.manage`-gated): add a toggle
for `autoPauseOnEmpty` next to the idle-timeout control; load it in the settings fetch and send
it on save.
## Components / files
- `src/ts-protocol/client.ts` — subscribe + re-emit `clientEnter`/`clientLeave`/`clientMoved`.
- `src/bot/instance.ts` — `autoPaused` field; `checkChannelOccupancy()` (refactored from the
idle poller, drives idle + auto-pause); event handlers; clear `autoPaused` in user commands +
connect/disconnect; `updateAutoPause(enabled)`.
- `src/web/api/bot.ts` — `GET`/`POST /settings` handle `autoPauseOnEmpty`.
- `web/src/views/Settings.vue` (+ player store settings load/save) — the toggle.
- `src/data/config.ts` — field already exists (no change beyond confirming default).
## Testing
- **Decision unit test (TDD):** extract the pause/resume decision into a testable method, e.g.
`applyOccupancy(userCount)` operating on an injected fake player (`getState`/`pause`/`resume`)
+ the `autoPaused` flag + the config flag. Cases: empty+playing+enabled → pause + `autoPaused`;
re-populated+`autoPaused`+paused → resume + clear; re-populated when NOT `autoPaused` (user
pause) → no resume; flag disabled → no pause; empty while idle (not playing) → no-op.
- **API test:** `GET`/`POST /api/bot/settings` round-trips `autoPauseOnEmpty` (validates boolean,
persists, propagates).
- Live TS event wiring is verified by code review + a manual run (can't unit-test a real server).
## Non-goals
- No per-bot toggle (global only). No change to idle-disconnect behavior. No new dependency.
- Reaction relies on events the bot can already see (same-channel members are always in view);
no extra channel subscription needed.
---
## Update (2026-06): occupancy is event-driven & server-wide, not channel-filtered
Live testing against a real TS3 server (with `@honeybbq/teamspeak-client` 0.2.2)
invalidated two assumptions above. Recording the corrected model here so nobody
reintroduces the old design:
- **Default is OFF**, not on. See `getDefaultConfig()` in `src/data/config.ts`
and the rationale comment there.
- **Query commands are unusable when others are present.** `clientlist`,
`channellist`, and `channelclientlist` ALL time out (~5–10s) whenever ≥2
clients are connected to the **server** (verified even when the two clients
are in *different* channels). They succeed only when the bot is the sole
client on the whole server. So `getClientsInChannel()` returns `[]` exactly
when occupancy matters, and `occupancyFromClientList(0)` returns `null`
("unknown") so callers skip the decision rather than mis-reading it as empty.
- **PAUSE** therefore only ever fires when the bot becomes alone on the server
(the one state where the query works). This is reliable and stays on the
query path (`refreshOccupancy()` + the 30s idle poller).
- **RESUME** is armed directly from the `clientEnter` push event
(`shouldResumeOnReturn()` + `_resumeIfReturning()` in `instance.ts`), NOT from
a query. Because the bot only auto-pauses while alone, the sole way occupancy
can return while `autoPaused` is set is a fresh connection — delivered as
`clientEnter`. The resume branch never pauses (userCount is always > 0).
- **Net semantics:** "pause when the server is empty (bot alone), resume when
someone connects." Channel granularity is **impossible** with this library:
`clientEnter`'s channel field is always `0` (library reads notify param `cid`
but enter-view carries `ctid`), and `clientMoved` delivery is flaky. Do NOT
attempt to layer `clientMoved.targetChannelID` channel-accuracy on top — it is
systematically wrong for direct-connect clients and reintroduces unreliability.
The correct path to true channel scoping is an upstream library fix.
- **Knock-on:** idle-disconnect shares the same signal and is likewise
server-wide. UI copy in `web/src/views/Settings.vue` was updated to say
"服务器" rather than "频道" to match. `cmdVote` was intentionally left on the
query path (out of scope; switching it would inherit the same timeout).
@@ -0,0 +1,65 @@
# Dedicated-link bot scoping (+ refresh fix) — design
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) items 2 and 4
**Date:** 2026-05-30
**Status:** Approved (brainstorm), pending implementation plan
## Problem
- **Item 2:** A dedicated link (`/bot/:id`) is meant to give someone control of *one* bot, but today it just sets the active bot and bounces to `/`; the user can still switch to any other bot from the top-right selector.
- **Item 4 (bug):** After opening a dedicated link, refreshing the page loses the bot — the UI falls back to the first bot.
Root cause (verified): `BotRedirect.vue` does `router.replace('/')` (dropping the id), and `activeBotId` is in-memory-only Pinia state with no persistence, so a reload resets it to `bots[0]`.
## Decision (from brainstorm: Q2 = URL-carried scope)
Carry the scoped bot in the **URL query** (`?bot=<id>`). One mechanism fixes **both** items: the URL is durable across refresh (item 4) and shareable/self-clearing, and the frontend locks the selector to the scoped bot (item 2). No localStorage sticky-lock; plain `/` (no `?bot`) = full control. Backend per-bot authorization (PR #80) remains the real boundary — this is a UX lock.
## Design
### Scope state (store)
Add to the player store:
- `scopedBotId: string | null` — the bot the UI is locked to.
- getter `isScoped` = `scopedBotId !== null`.
- action `setScope(id)` / `clearScope()`.
- `setActiveBotId(id)` becomes a no-op (or ignores) when `isScoped` and `id !== scopedBotId`, so stray switch attempts can't change bots.
### URL as the durable source of truth
- `BotRedirect.vue` (`/bot/:id`): instead of `router.replace('/')`, validate the bot exists, then `router.replace({ path: '/', query: { bot: id } })`. (Keeps the "clean" home URL but with `?bot=`.)
- **Router `beforeEach` guard** (the heart of it):
- If `to.query.bot` is present → `store.setScope(to.query.bot)` and continue.
- Else if `store.isScoped` (a scope is active and this navigation dropped the param) → redirect to the same route **with** `query.bot = store.scopedBotId` re-attached (so the lock survives in-app navigation to /search, /library, etc.).
- Else → no scope; continue.
This keeps `?bot=` on the URL for every route while scoped, so a refresh on *any* route re-establishes the lock → **fixes item 4**.
- On app load / after `fetchBots()`: apply `scopedBotId`/`?bot` to `activeBotId`; if the scoped bot doesn't exist or isn't in the user's allowed set, **clear the scope gracefully** (fall back to normal multi-bot view) rather than locking onto a dead id.
### Exit
- `clearScope()` sets `scopedBotId = null`; the exit affordance navigates to `/` *after* clearing, so the guard won't re-attach `?bot`. This is the only way to leave scoped mode (self-clearing, intentional).
### Navbar (the lock UI)
- When `isScoped`: the bot selector shows **only** the scoped bot, the dropdown/switching is disabled (no chevron / non-interactive), and other bots' "copy link" affordances are not shown.
- Show a small "专属模式" indicator with an "退出" control → `clearScope()` + navigate to `/`.
- When not scoped: unchanged (full selector over `controllableBots`).
### Active-bot coherence
Because every player action already routes through `activeBotId`, locking `activeBotId === scopedBotId` guarantees all controls affect only the scoped bot. The store's `activeBot` getter `bots[0]` fallback still degrades safely if the scoped id ever fails to match (combined with the graceful-clear above).
## Components / files
- `web/src/stores/player.ts` — `scopedBotId` state, `isScoped`, `setScope`/`clearScope`, guard in `setActiveBotId`, apply scope→active in `fetchBots`/init (graceful clear if missing).
- `web/src/router/index.ts` — `beforeEach` scope sync + `?bot` preservation.
- `web/src/views/BotRedirect.vue` — set scope + `replace({ path: '/', query: { bot: id } })`.
- `web/src/components/Navbar.vue` — locked selector + "专属模式/退出" affordance.
- `web/src/App.vue` — ensure scope is applied to `activeBotId` after `fetchBots` on load (if not already handled by the store/guard).
## Testing
Vue UI isn't unit-tested in this repo, so verification is `vue-tsc` + manual run. The **store scope logic is testable** if a lightweight test harness exists for Pinia stores; otherwise assert the pure pieces:
- `setActiveBotId` ignores a switch to a non-scoped bot while scoped; allows the scoped bot.
- `clearScope` resets state.
- A small helper for "resolve scope from query + bots list → {scopedBotId, activeBotId} or cleared-if-missing" can be extracted and unit-tested.
Manual: open `/bot/<id>` → locked to that bot, selector shows only it; refresh → still locked (item 4 fixed); navigate to Search then refresh → still locked; click 退出 → back to all bots; open `/` directly → full control (no lock).
## Non-goals
- No localStorage persistence (URL is the source of truth). No backend change (per-bot auth already exists in #80). No change to how dedicated links are generated (still `<base>/bot/<id>`); only what happens when one is opened.
@@ -0,0 +1,317 @@
# Guest mode (login-less WebUI access) — design
**Issue:** [#83](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/83) — "请求增加 WebUI 鉴权 guest 登录功能"
**Date:** 2026-06-24
**Status:** Approved (brainstorm), pending implementation plan
## Scope
Add an optional, **default-OFF** guest mode. When an admin enables it, anyone who can
reach the WebUI can enter **without logging in** ("以游客身份进入 / Continue as guest")
and use a restricted subset of playback/queue features. The admin chooses, per
deployment, exactly what guests may do (a set of toggles) and which bot(s) guests may
control. Guests can **never** view or change settings, manage bots, set platform
credentials, change audio quality, or see the user/audit admin panels.
This builds directly on the existing `admin | member` role + capability system
(`src/data/permissions.ts`, `requirePermission`, `useSession().can()`) and the existing
append-vs-play-next queue split (`PlayQueue.add` vs `addNext`). It does **not** rebuild
auth.
The original issue asked specifically that guest song requests go to "下一首" only.
That exact behavior is reproducible in this design by the admin turning the
"add to end" toggle **off** and the "play next" toggle **on** — it is one configuration
of a more general per-ability toggle model (chosen in brainstorm).
## Problem
Today every `/api/*` route past the session router requires a real account
(`requireAuth`). There is no anonymous/guest path: to let a friend queue a song, an
admin must create them a `member` account. The maintainer wants a low-friction,
admin-gated way to let untrusted visitors request music without an account, while
keeping all administration locked down.
## Decisions (from brainstorm)
1. **Guest = no-login.** A guest is an **anonymous, config-driven synthetic principal**
(`role: "guest"`), not a database user with a password. No favorites, no
change-password, short-lived session.
2. **Default OFF**, enforced **server-side** (the guest-session endpoint rejects when
the flag is off — never rely on hiding the button).
3. **Per-ability toggles**, not a single "mode". Every song action and control action is
its own admin switch. Default state when guest mode is first enabled: only
"add to end of queue" is ON; everything else OFF.
4. **Per-bot guest scope** (`"all"` or an explicit bot list), mirroring the existing
member bot allow-list. Guests cannot see or control out-of-scope bots — including
over WebSocket.
5. **Settings are always hidden AND server-blocked for guests** (view + change), closing
the two currently-ungated reads (`GET /api/bot/settings`, `GET /api/music/quality`).
6. **"Play now" for guests is non-destructive**: insert-next + skip to it, **never** the
existing clear-the-whole-queue `/play-song` behavior.
7. **One unified authorization gate** encapsulates admin/member/guest logic so the
existing member/admin capability system is left behavior-unchanged.
## Guest ability model
### Always allowed (baseline read-only — the point of the feature)
- Browse/search library, playlists, history, song detail, lyrics, cover art.
- See now-playing and the live queue (REST + WebSocket), **scoped to allowed bots**.
### Always denied (hard locks — not toggles)
- View **or** change any settings (idle timeout, auto-pause, theme persistence server-side, command prefix, etc.).
- Bot management (create/edit/delete/start/stop, bot config, avatar, profile).
- Music-platform login (`/api/auth/*`), audio quality (`/api/music/quality`).
- User management (`/api/users`), audit log (`/api/audit`), change-password.
### Admin-configurable toggles (`guestMode.permissions.*`, all default `false` except `addToQueue`)
| Flag | 中文 | Default | Backend route(s) gated |
|---|---|---|---|
| `addToQueue` | 添加到队列末尾 | **true** | `POST /:botId/add`, `/add-song`, `/add-by-id` |
| `playNext` | 添加到下一首 | false | `POST /:botId/play-next-song` |
| `playNow` | 立即播放(不清空队列) | false | new guest-safe play-now (insert-next + skip) |
| `skip` | 跳过当前歌曲 | false | `POST /:botId/next` |
| `transport` | 暂停/继续/进度/音量 | false | `POST /:botId/pause`, `/resume`, `/seek`, `/volume` |
| `removeClear` | 移除/清空队列 | false | `DELETE /:botId/queue/:index`, `POST /:botId/clear` |
| `playMode` | 切换播放模式 / FM | false | `POST /:botId/mode`, `/fm` |
Notes:
- Routes with **no** guest flag (e.g. `/prev`, `/stop`, `/play-song`, `/play-playlist`,
`/play-album`, `/play-at`, all of `/api/bot/*`, `/api/auth/*`, settings, users, audit)
are **never** reachable by guests — the gate denies any guest without an explicit flag.
This is the safe default: new routes are guest-denied unless deliberately opted in.
- `/play-song`, `/play-playlist`, `/play-album` call `queue.clear()` and must stay
guest-denied regardless of toggles (they would wipe everyone's queue).
## Config schema (`src/data/config.ts`)
```ts
export interface GuestPermissions {
addToQueue: boolean; // append to end
playNext: boolean; // 下一首 (insert after current)
playNow: boolean; // 立即播放: insert-next + skip-to-it (non-destructive)
skip: boolean; // skip current track
transport: boolean; // pause/resume/seek/volume
removeClear: boolean; // remove a queue item / clear the queue
playMode: boolean; // play mode (shuffle/repeat) + FM
}
export interface GuestModeConfig {
enabled: boolean; // master switch, default false
bots: "all" | string[]; // per-bot scope (botIds); default "all"
permissions: GuestPermissions;
}
// added to BotConfig:
guestMode: GuestModeConfig;
```
`getDefaultConfig()` returns:
```ts
guestMode: {
enabled: false,
bots: "all",
permissions: {
addToQueue: true, playNext: false, playNow: false,
skip: false, transport: false, removeClear: false, playMode: false,
},
}
```
**Merge hardening:** `loadConfig` currently does a shallow `{...defaults, ...partial}`,
which would drop `guestMode` sub-keys if a saved config only contains a partial
`guestMode`. `loadConfig` must **deep-merge `guestMode`** (and its `permissions`) over
the defaults so missing sub-keys are back-filled. Covered by a `config.test.ts` case.
**Bot deletion:** when a bot is removed, prune its id from `guestMode.bots` (if it's an
array) and persist — mirrors `PermissionStore.pruneBot(botId)` for members. Done in the
same `BotManager.removeBot` path that already prunes member access.
## Backend design
### Synthetic guest principal & session entry
- **Role union widened** to `"admin" | "member" | "guest"` (`UserRole` in
`src/data/users.ts`, the `req.user` augmentation in `requireAuth.ts`, the frontend
`User` type, and the role badge in Navbar).
- **Reserved guest user row.** A single fixed row (e.g. id `"__guest__"`, role `"guest"`,
an unusable password hash, username e.g. `"guest"`) is created idempotently by
migration. It exists only to satisfy the `sessions.userId` FK and the
`validateAndTouch` JOIN; it is excluded from user-management listings and the
last-admin guards (those count `role = 'admin'` only, so guests don't interfere).
- **Guest login endpoint:** `POST /api/session/guest`, mounted in the **public** block
(before `csrfOriginCheck`/`requireAuth`, like `/login` and `/setup`), rate-limited.
- If `config.guestMode.enabled` is false → `403 guest mode disabled`.
- Else `sessions.createSession("__guest__")` and set the same `tsmb_session` httpOnly
cookie. **Guest sessions use a short TTL** (e.g. `GUEST_SESSION_TTL_MS`, ~24h) and
**bypass `MAX_SESSIONS_PER_USER`** for the guest principal (otherwise guest #11
would evict guest #1). Expired guest sessions are already deleted on validation; an
optional periodic sweep can prune stale ones.
- **Disable = logout.** In `createRequireAuth`/`validateSession`, if a validated session
has `role === "guest"` but `config.guestMode.enabled` is now false, treat it as
unauthenticated (401). So flipping guest mode off immediately ends guest access.
- **Expose availability:** extend `GET /api/session/needs-setup` (or add a sibling
`GET /api/session/guest-config`) to return `guestAllowed: boolean` so the **public**
Login page can decide whether to show the guest button. This must not leak any other
config.
### Permission resolution
`resolvePermissionContext` gains a `guest` branch. Signature extended to receive the
live guest config:
```ts
resolvePermissionContext(role, userId, store, guestConfig?) => {
admin → { capabilities: all CAPABILITIES, bots: "all" }
member → stored caps + stored bots // unchanged
guest → {
capabilities: new Set(), // holds NO member capabilities
bots: guestConfig.bots === "all" ? "all" : new Set(guestConfig.bots),
guest: guestConfig.permissions, // resolved per-request from live config
}
}
```
`PermissionContext` and `req.user` gain an optional `guest?: GuestPermissions`. Because
`req.user` is rebuilt per request, toggling a permission or the bot scope takes effect on
the guest's next request (no re-login).
### Unified authorization gate (`src/web/middleware/authorize.ts`, new)
Replaces `requirePermission('x')` on **guest-reachable** routes:
```ts
authorize({ capability?: Capability, guestFlag?: keyof GuestPermissions })
// 401 if no req.user
// admin → next()
// guest → (req.user.guest?.[guestFlag] === true) ? next() : 403 // also 403 if no guestFlag
// member → (capability && req.user.capabilities.has(capability)) ? next() : 403
```
- Member/admin semantics are **identical** to today's `requirePermission`.
- A route with no `guestFlag` is automatically guest-denied (safe default).
- `requireBotAccess` is unchanged and already enforces the guest `bots` scope (guests
flow through `req.user.bots`).
- `requireAdmin` is unchanged (guests are non-admin → 403), so `/api/users` and
`/api/audit` stay locked.
### Route changes (`src/web/api/player.ts`, `bot.ts`, `music.ts`)
- Re-express guest-reachable player routes via `authorize({ capability, guestFlag })`:
- `/add`, `/add-song`, `/add-by-id` → `{ capability: "player.queue", guestFlag: "addToQueue" }`
- `/play-next-song` → `{ capability: "player.control", guestFlag: "playNext" }`
(members keep `player.control`; guests pass only via `playNext`)
- new guest-safe **play-now** → `{ capability: "player.control", guestFlag: "playNow" }`
- `/next` → `{ capability: "player.control", guestFlag: "skip" }`
- `/pause`,`/resume`,`/seek`,`/volume` → `{ capability: "player.control", guestFlag: "transport" }`
- `DELETE /queue/:index`, `/clear` → `{ capability: "player.queue", guestFlag: "removeClear" }`
- `/mode`, `/fm` → `{ capability: "player.control", guestFlag: "playMode" }`
- everything else stays `authorize({ capability })` (no guest flag) → guest-denied.
- **Guest-safe play-now**: a new behavior (own route, e.g. `POST /:botId/play-now`, or a
`mode:"now"` branch) that does `queue.addNext(song)` then advances to it (skip into the
inserted track) — **no `queue.clear()`**. Members/admins may also use it; the existing
destructive `/play-song` stays for the normal ▶ in non-guest UI. Exact wiring decided
in the plan.
- **Close ungated reads against guests:** `GET /api/bot/settings` and
`GET /api/music/quality` currently have no guard, so a guest could read config. Add a
small `requireNotGuest` guard (allow `admin` + `member`, deny `guest` → 403). This
**does not change member/admin behavior** — members keep their current read access; only
guests are newly denied. (Deliberately not a new member capability, to avoid touching
member semantics.)
### WebSocket (`src/web/websocket.ts`, `src/web/server.ts`)
- Guests authenticate over the WS upgrade unchanged (session cookie).
- **Add per-client bot-scope filtering** for guests: the upgrade handler already stamps
`ws.userId`; also resolve and stamp the client's bot scope (`"all"` or a Set). In
`setupWebSocket`, when sending `init` and broadcasting `stateChange` /
`botConnected/Disconnected/Removed`, **filter to bots the client may see**. For guests
with a scoped `bots` list, out-of-scope bots are omitted. Admin/member payloads are
unchanged (they resolve to `"all"` or their existing member scope — to avoid changing
member behavior, filtering may be applied **only when the client is a guest**; decided
in the plan).
### Settings write (`POST /api/bot/settings`)
Extend the existing settings writer (today only idle-timeout + auto-pause) to also accept
and persist the `guestMode` block (admin-only via `bot.manage`/`requireAdmin`), calling
`saveConfig`. Live effect: subsequent guest requests read the updated in-memory config.
## Frontend design
- **`useSession.ts`**: extend `User` with `role:'guest'` and a `guest?: GuestPermissions`
field (from `/api/session/me`). Add `isGuest` computed and `guestCan(flag)`; make `can`
guest-aware where it maps cleanly, but UI gating for guest-specific actions uses
`guestCan('addToQueue' | 'playNext' | ...)`. `canControlBot` already enforces the bot
scope and works for guests via the `bots` field.
- **Login page (`Login.vue`)**: when `guestAllowed`, show a prominent
"以游客身份进入 / Continue as guest" button calling a new `session.continueAsGuest()`
→ `POST /api/session/guest` → refresh → redirect to `?next` or home.
- **Router (`web/src/router/index.ts`)**: in the global `beforeEach`, block guests from
`/settings` and `/setup` (redirect to home). Default-off ⇒ when not a guest, behavior
is unchanged.
- **Navbar (`Navbar.vue`)**: hide the settings cog for guests; add a `游客` role badge
branch; the bot selector already filters via `canControlBot`, so scoped guests only see
allowed bots.
- **App shell (`App.vue`)**: hide the mobile `/settings` tab for guests; the mini-player
transport reduces to the guest's allowed actions.
- **SongCard / Queue / Player**: gate each action button by the matching `guestCan(flag)`
(e.g. show ▶/下一首/添加 per `playNow`/`playNext`/`addToQueue`; show skip/transport/
remove/clear/mode per their flags). Buttons a guest lacks are hidden, mirroring how
`Queue.vue` already gates on `can('player.queue')` / `can('player.control')`.
- **Settings → Guest mode admin section (`Settings.vue`)**: new admin-only panel: a
master enable switch, the 7 permission checkboxes (with 中文 labels), and a bot scope
control (an "全部机器人 / all bots" toggle + per-bot checkboxes) reusing the existing
member permission-editor bot allow-list UI. Saving calls `POST /api/bot/settings` with
the `guestMode` block.
## Defaults, migration & backward-compat
- `getDefaultConfig().guestMode.enabled = false` ⇒ **no behavior change** on upgrade;
existing installs see nothing until an admin opts in.
- Migration adds the reserved `__guest__` user row idempotently (guarded like the
existing `backfillMemberPermissions` `schema_meta` marker) and does **not** grant it
any `user_permissions` (guest authorization is config-driven, not row-driven).
- `loadConfig` deep-merges `guestMode` so older config files gain the new block with
defaults.
- Member/admin flows, capabilities, and the backfill are untouched.
## Testing (TDD)
- **Config**: `getDefaultConfig` includes `guestMode` default-off; `loadConfig`
deep-merges a partial `guestMode` (missing sub-keys back-filled); round-trips through
`saveConfig`.
- **`resolvePermissionContext` guest branch**: empty member capabilities; `bots` `"all"`
vs scoped Set; `guest` permissions object passthrough.
- **`authorize` gate**: admin bypass; member has/lacks capability → 200/403 (regression
parity with `requirePermission`); guest allowed only when the specific flag is true;
guest with no flag on a route → 403; guest on settings reads → 403.
- **Enforcement (mirror `permissions-enforcement.test.ts`)**: each toggle independently
opens exactly its route(s) for a guest and nothing else; `/play-song`/`/play-playlist`/
`/play-album` always 403 for guests; per-bot scope: guest 403 on out-of-scope `:botId`.
- **Session entry**: `POST /api/session/guest` → 403 when disabled, mints guest session
when enabled; guest session bypasses `MAX_SESSIONS_PER_USER`; disabling guest mode
invalidates existing guest sessions (401); guest TTL shorter than member TTL.
- **WS scope**: guest receives only in-scope bots' `init`/`stateChange`; reject upgrade
unchanged for no cookie.
- **Frontend** (where covered): `guestCan` gating; router blocks `/settings` for guests.
## Non-goals (YAGNI)
- No guest accounts/usernames, passwords, favorites, or persistence per guest.
- No per-guest individual identity or rate-limiting beyond the existing IP rate limits
(a basic abuse guard on `/api/session/guest` is in; richer abuse controls are future).
- No change to the `admin | member` capability semantics; guest is an additive,
config-driven third principal.
- No chat-command (TeamSpeak `!add`/`!playnext`) changes — guest mode is **WebUI-only**
(the issue is explicitly about WebUI 鉴权).
- Per-guest bot scoping beyond a single shared guest scope is out of scope (one guest
scope applies to all guests).
## Key files touched
Backend: `src/data/config.ts` (+test), `src/data/permissions.ts` (+test),
`src/data/users.ts` (role union, reserved guest row), `src/data/database.ts` (migration),
`src/data/sessions.ts` (guest TTL + cap bypass), `src/web/middleware/authorize.ts` (new,
+test), `src/web/middleware/requireNotGuest.ts` (new, small — for the config reads),
`src/web/api/session.ts` (guest endpoint, `/me`, `needs-setup`),
`src/web/api/player.ts` (re-gate + guest play-now), `src/web/api/bot.ts` (settings
read-lock + guestMode write), `src/web/api/music.ts` (quality read-lock),
`src/web/server.ts` + `src/web/websocket.ts` (WS scope), `src/web/auth/validateSession.ts`
(guest disable→401), enforcement tests.
Frontend: `web/src/composables/useSession.ts`, `web/src/views/Login.vue`,
`web/src/router/index.ts`, `web/src/components/Navbar.vue`, `web/src/App.vue`,
`web/src/components/SongCard.vue`, `web/src/components/Queue.vue`,
`web/src/components/Player.vue`, `web/src/views/Settings.vue`,
`web/src/stores/player.ts`.
+218
View File
@@ -14,8 +14,10 @@
"@koa/router": "^15.4.0",
"@sansenjian/qq-music-api": "^2.2.10",
"axios": "^1.14.0",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0",
"chalk": "^5.6.2",
"cookie-parser": "^1.4.7",
"express": "^5.2.1",
"ffmpeg-static": "^5.3.0",
"koa": "^3.2.0",
@@ -29,10 +31,14 @@
"yt-dlp-wrap": "^2.3.12"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6",
"@types/node": "^25.5.0",
"@types/supertest": "^6.0.3",
"@types/ws": "^8.18.1",
"supertest": "^7.2.2",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"vitest": "^4.1.2"
@@ -667,6 +673,29 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@paralleldrive/cuid2": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
"integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.1.5"
}
},
"node_modules/@paralleldrive/cuid2/node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -1152,6 +1181,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@@ -1194,6 +1230,23 @@
"@types/node": "*"
}
},
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cookiejar": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
"integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -1240,6 +1293,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
"integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
@@ -1285,6 +1345,30 @@
"@types/node": "*"
}
},
"node_modules/@types/superagent": {
"version": "8.1.10",
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.10.tgz",
"integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/cookiejar": "^2.1.5",
"@types/methods": "^1.1.4",
"@types/node": "*",
"form-data": "^4.0.0"
}
},
"node_modules/@types/supertest": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz",
"integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/methods": "^1.1.4",
"@types/superagent": "^8.1.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -1501,6 +1585,13 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/asap": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"dev": true,
"license": "MIT"
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
@@ -1608,6 +1699,12 @@
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "12.8.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
@@ -2011,6 +2108,16 @@
"node": ">= 0.8"
}
},
"node_modules/component-emitter": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2076,6 +2183,25 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
@@ -2085,6 +2211,13 @@
"node": ">=6.6.0"
}
},
"node_modules/cookiejar": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
"dev": true,
"license": "MIT"
},
"node_modules/cookies": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz",
@@ -2265,6 +2398,17 @@
"node": ">=8"
}
},
"node_modules/dezalgo": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
"dev": true,
"license": "ISC",
"dependencies": {
"asap": "^2.0.0",
"wrappy": "1"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
@@ -2596,6 +2740,13 @@
"node": ">=12.0.0"
}
},
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"dev": true,
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2744,6 +2895,24 @@
"node": ">= 0.6"
}
},
"node_modules/formidable": {
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
"integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@paralleldrive/cuid2": "^2.2.2",
"dezalgo": "^1.0.4",
"once": "^1.4.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"url": "https://ko-fi.com/tunnckoCore/commissions"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -5671,6 +5840,55 @@
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/superagent": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
"integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"component-emitter": "^1.3.1",
"cookiejar": "^2.1.4",
"debug": "^4.3.7",
"fast-safe-stringify": "^2.1.1",
"form-data": "^4.0.5",
"formidable": "^3.5.4",
"methods": "^1.1.2",
"mime": "2.6.0",
"qs": "^6.14.1"
},
"engines": {
"node": ">=14.18.0"
}
},
"node_modules/superagent/node_modules/mime": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
"dev": true,
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/supertest": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz",
"integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cookie-signature": "^1.2.2",
"methods": "^1.1.2",
"superagent": "^10.3.0"
},
"engines": {
"node": ">=14.18.0"
}
},
"node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
+6
View File
@@ -19,8 +19,10 @@
"@koa/router": "^15.4.0",
"@sansenjian/qq-music-api": "^2.2.10",
"axios": "^1.14.0",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0",
"chalk": "^5.6.2",
"cookie-parser": "^1.4.7",
"express": "^5.2.1",
"ffmpeg-static": "^5.3.0",
"koa": "^3.2.0",
@@ -34,10 +36,14 @@
"yt-dlp-wrap": "^2.3.12"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6",
"@types/node": "^25.5.0",
"@types/supertest": "^6.0.3",
"@types/ws": "^8.18.1",
"supertest": "^7.2.2",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"vitest": "^4.1.2"
+17 -6
View File
@@ -1,14 +1,16 @@
# TSMusicBot — Docker Compose
# 一键部署:docker-compose up -d
# 一键部署:docker-compose pull && docker-compose up -d
#
# 所有依赖已内置(Node.js, FFmpeg, Opus 编码器)无需安装任何额外软件
# FFmpeg 使用系统包管理器安装,确保容器内兼容性
# 默认从 GitHub Container Registry 拉取预构建镜像(amd64 + arm64),
# 无需本地编译,无需 Node.js / 构建工具链。
# 镜像内置:Node.js, FFmpeg, Opus 编码器,原生模块均已交叉编译。
#
# 如需指定版本,把 :latest 换成具体 tag(例如 :1.4.0)。
# 如需本地构建(开发或 fork),见底部注释。
services:
tsmusicbot:
build:
context: ../..
dockerfile: scripts/docker/Dockerfile
image: ghcr.io/zhangtianyao1/teamspeak-music-bot:latest
container_name: tsmusicbot
# Use host network so the bot can reach TS3 server on LAN
# If your TS3 server is on the same machine, this is required
@@ -27,3 +29,12 @@ services:
volumes:
tsmusicbot-data:
driver: local
# ------------------------------------------------------------------
# 本地构建(仅开发 / fork 场景使用):
# 把上面的 `image:` 行删掉,换成下面的 build 块即可。
#
# build:
# context: ../..
# dockerfile: scripts/docker/Dockerfile
# ------------------------------------------------------------------
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env node
/**
* Download native binaries (ffmpeg + @discordjs/opus) from npmmirror CDN.
* Called by setup.bat after npm install --ignore-scripts.
*
* Usage: node scripts/download-binaries.mjs [cdn_base_url]
*/
import { existsSync, mkdirSync, writeFileSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { createGunzip } from "node:zlib";
import { pipeline } from "node:stream/promises";
import { createWriteStream } from "node:fs";
import { get } from "node:https";
import { Readable } from "node:stream";
import { execSync } from "node:child_process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const CDN = process.argv[2] || "https://cdn.npmmirror.com/binaries";
const PLATFORM = process.platform;
const ARCH = process.arch;
const NODE_ABI = process.versions.modules;
function download(url) {
return new Promise((resolve, reject) => {
const req = get(url, { timeout: 120000 }, (res) => {
if (res.statusCode < 200 || res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode}: ${url}`));
return;
}
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve(Buffer.concat(chunks)));
});
req.on("error", reject);
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
});
}
function log(msg) {
console.log(` [binary] ${msg}`);
}
function isValidSize(filePath, minBytes) {
try { return statSync(filePath).size >= minBytes; } catch { return false; }
}
async function downloadFfmpeg() {
const ffDir = join(ROOT, "node_modules", "ffmpeg-static");
const ffName = PLATFORM === "win32" ? "ffmpeg.exe" : "ffmpeg";
const ffDest = join(ffDir, ffName);
if (!existsSync(ffDir)) { log("ffmpeg-static not installed, skipping"); return false; }
if (existsSync(ffDest)) {
if (isValidSize(ffDest, 50 * 1024 * 1024)) {
log("ffmpeg already exists, skipping");
return true;
}
log("ffmpeg exists but seems corrupted (too small), re-downloading...");
}
const url = `${CDN}/ffmpeg-static/b6.1.1/ffmpeg-${PLATFORM}-${ARCH}.gz`;
log("Downloading ffmpeg...");
const buf = await download(url);
await pipeline(Readable.from(buf), createGunzip(), createWriteStream(ffDest));
try { execSync(`chmod +x "${ffDest}"`); } catch {}
const size = ((await statSync(ffDest)).size / 1024 / 1024).toFixed(1);
log(`ffmpeg OK (${size} MB)`);
return true;
}
async function downloadOpus() {
const opusDir = join(ROOT, "node_modules", "@discordjs", "opus");
const prebuildName = `node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown`;
const opusDest = join(opusDir, "prebuild", prebuildName, "opus.node");
if (!existsSync(opusDir)) { log("@discordjs/opus not installed, skipping"); return false; }
if (existsSync(opusDest)) {
if (isValidSize(opusDest, 100 * 1024)) {
log("@discordjs/opus already exists, skipping");
return true;
}
log("@discordjs/opus exists but seems corrupted (too small), re-downloading...");
}
const url = `${CDN}/@discordjs/opus/v0.10.0/opus-v0.10.0-node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown.tar.gz`;
log("Downloading @discordjs/opus...");
try {
const buf = await download(url);
mkdirSync(dirname(opusDest), { recursive: true });
const require = createRequire(import.meta.url);
const tar = require("tar");
const tmpFile = join(tmpdir(), `discordjs-opus-${Date.now()}.tar.gz`);
writeFileSync(tmpFile, buf);
await tar.extract({ cwd: join(opusDir, "prebuild"), file: tmpFile });
log("@discordjs/opus OK");
return true;
} catch (err) {
log(`CDN download failed (${err.message}), trying to build from source...`);
try {
execSync("npm rebuild @discordjs/opus", { cwd: ROOT, stdio: "inherit" });
if (existsSync(opusDest) && isValidSize(opusDest, 100 * 1024)) {
log("@discordjs/opus built from source OK");
return true;
}
log("Source build completed but .node file not found");
return false;
} catch (buildErr) {
log(`Source build failed: ${buildErr.message}`);
log("Install build tools: sudo apt install build-essential (Ubuntu/Debian)");
log(" sudo yum groupinstall 'Development Tools' (CentOS/RHEL)");
return false;
}
}
}
async function downloadBetterSqlite3() {
const pkgDir = join(ROOT, "node_modules", "better-sqlite3");
const dest = join(pkgDir, "build", "Release", "better_sqlite3.node");
if (!existsSync(pkgDir)) { log("better-sqlite3 not installed, skipping"); return false; }
if (existsSync(dest)) {
if (isValidSize(dest, 500 * 1024)) {
log("better-sqlite3 already exists, skipping");
return true;
}
log("better-sqlite3 exists but seems corrupted (too small), re-downloading...");
}
const version = "12.8.0";
const url = `${CDN}/better-sqlite3/v${version}/better-sqlite3-v${version}-node-v${NODE_ABI}-${PLATFORM}-${ARCH}.tar.gz`;
log("Downloading better-sqlite3...");
const buf = await download(url);
const require = createRequire(import.meta.url);
const tar = require("tar");
const tmpFile = join(tmpdir(), `better-sqlite3-${Date.now()}.tar.gz`);
writeFileSync(tmpFile, buf);
mkdirSync(dirname(dest), { recursive: true });
await tar.extract({ cwd: pkgDir, file: tmpFile });
if (existsSync(dest)) {
log(`better-sqlite3 OK (${((await statSync(dest)).size / 1024).toFixed(0)} KB)`);
return true;
}
log("better-sqlite3 extracted but .node file not found at expected path");
return false;
}
try {
const results = await Promise.all([downloadFfmpeg(), downloadOpus(), downloadBetterSqlite3()]);
if (results.some(Boolean)) {
console.log(" [binary] All downloads complete");
}
} catch (e) {
console.error(` [binary] ERROR: ${e.message}`);
process.exit(1);
}
+294 -321
View File
@@ -1,321 +1,294 @@
@echo off
setlocal enabledelayedexpansion
chcp 65001 >nul
title TSMusicBot Setup
:: ============================================================
:: TSMusicBot Setup Script (Robust Edition)
:: - Auto-detect China network, switch to npmmirror
:: - Strict error checking at every step
:: - Detailed logging to setup.log
:: - Skip already-completed steps on re-run
:: ============================================================
set "SCRIPT_VERSION=2.0"
set "MIN_NODE_MAJOR=20"
set "LOG_FILE=%~dp0..\setup.log"
set "FAILED=0"
:: Resolve project root (one level up from scripts/)
cd /d "%~dp0.." || (
echo [FATAL] Cannot change to project directory.
pause
exit /b 1
)
set "PROJECT_ROOT=%cd%"
:: ---- Initialize log ----
echo. > "%LOG_FILE%"
call :log "============================================"
call :log " TSMusicBot Setup v%SCRIPT_VERSION%"
call :log " Started: %date% %time%"
call :log " Project root: %PROJECT_ROOT%"
call :log "============================================"
echo ============================================
echo TSMusicBot - First-Time Setup (Windows)
echo Version %SCRIPT_VERSION%
echo ============================================
echo.
echo Log file: %LOG_FILE%
echo.
:: ============================================================
:: Step 1: Check Node.js
:: ============================================================
call :step "1/6" "Checking Node.js"
where node >nul 2>&1
if errorlevel 1 (
call :error "Node.js not found in PATH."
echo.
echo Please install Node.js %MIN_NODE_MAJOR% LTS or newer from one of:
echo - https://nodejs.org/ ^(official^)
echo - https://nodejs.cn/ ^(China mirror, recommended for CN users^)
echo.
echo After installation:
echo 1. Close this window completely
echo 2. Open a NEW Command Prompt
echo 3. Run scripts\setup.bat again
echo.
pause
exit /b 1
)
:: Check Node version >= 20
for /f "tokens=1 delims=v." %%a in ('node --version 2^>nul') do set "NODE_RAW=%%a"
for /f "tokens=1 delims=v." %%a in ('node --version 2^>nul') do (
for /f "tokens=1 delims=." %%b in ("%%a") do set "NODE_MAJOR=%%b"
)
:: Robust version parse
for /f "delims=" %%v in ('node --version 2^>nul') do set "NODE_VER=%%v"
set "NODE_VER_NUM=%NODE_VER:v=%"
for /f "tokens=1 delims=." %%a in ("%NODE_VER_NUM%") do set "NODE_MAJOR=%%a"
call :log "Node.js version: %NODE_VER%"
echo [OK] Node.js found: %NODE_VER%
if %NODE_MAJOR% LSS %MIN_NODE_MAJOR% (
call :error "Node.js version too old. Need %MIN_NODE_MAJOR%+, found %NODE_VER%."
echo Please upgrade Node.js to version %MIN_NODE_MAJOR% LTS or newer.
pause
exit /b 1
)
echo.
:: ============================================================
:: Step 2: Check npm
:: ============================================================
call :step "2/6" "Checking npm"
where npm >nul 2>&1
if errorlevel 1 (
call :error "npm not found. This is unusual since Node.js is installed."
echo Please reinstall Node.js to fix this.
pause
exit /b 1
)
for /f "delims=" %%v in ('npm --version 2^>nul') do set "NPM_VER=%%v"
call :log "npm version: %NPM_VER%"
echo [OK] npm found: %NPM_VER%
echo.
:: ============================================================
:: Step 3: Detect network and configure mirror
:: ============================================================
call :step "3/6" "Checking network"
set "USE_MIRROR=0"
:: Try reaching npm registry with a short timeout
echo Testing connection to registry.npmjs.org...
call :log "Testing npm registry connectivity..."
:: Use curl if available (more reliable than ping for HTTPS)
where curl >nul 2>&1
if not errorlevel 1 (
curl -s -o nul -m 5 -w "%%{http_code}" https://registry.npmjs.org/ > "%TEMP%\npmtest.txt" 2>nul
set /p HTTP_CODE=<"%TEMP%\npmtest.txt"
del "%TEMP%\npmtest.txt" >nul 2>&1
if "!HTTP_CODE!"=="200" (
echo [OK] npm registry reachable.
call :log "npm registry HTTP 200 OK"
) else (
echo [WARN] npm registry slow or unreachable ^(code: !HTTP_CODE!^).
call :log "npm registry returned: !HTTP_CODE!"
set "USE_MIRROR=1"
)
) else (
:: Fallback to ping
ping -n 1 -w 3000 registry.npmjs.org >nul 2>&1
if errorlevel 1 (
echo [WARN] Cannot reach npm registry quickly.
set "USE_MIRROR=1"
) else (
echo [OK] npm registry reachable.
)
)
if "%USE_MIRROR%"=="1" (
echo.
echo Slow connection detected. Switching to China mirror ^(npmmirror.com^)...
call :log "Switching to npmmirror.com"
call npm config set registry https://registry.npmmirror.com >>"%LOG_FILE%" 2>&1
call npm config set disturl https://registry.npmmirror.com/-/binary/node >>"%LOG_FILE%" 2>&1
call npm config set electron_mirror https://registry.npmmirror.com/-/binary/electron/ >>"%LOG_FILE%" 2>&1
call npm config set sqlite3_binary_host_mirror https://registry.npmmirror.com/-/binary/better-sqlite3 >>"%LOG_FILE%" 2>&1
call npm config set node_sqlite3_binary_host_mirror https://registry.npmmirror.com/-/binary/better-sqlite3 >>"%LOG_FILE%" 2>&1
call npm config set sharp_binary_host https://registry.npmmirror.com/-/binary/sharp >>"%LOG_FILE%" 2>&1
call npm config set sharp_libvips_binary_host https://registry.npmmirror.com/-/binary/sharp-libvips >>"%LOG_FILE%" 2>&1
call npm config set FFMPEG_BINARIES_URL https://registry.npmmirror.com/-/binary/ffmpeg-static >>"%LOG_FILE%" 2>&1
call npm config set @discordjs:registry https://registry.npmmirror.com >>"%LOG_FILE%" 2>&1
echo [OK] Mirror configured.
)
echo.
:: ============================================================
:: Step 4: Install backend dependencies
:: ============================================================
call :step "4/6" "Installing backend dependencies"
if exist "node_modules\.package-lock.json" (
echo Found existing node_modules. Checking integrity...
call :log "Existing node_modules detected, running npm install to verify"
)
echo Running: npm install ^(this can take 5-15 minutes on slow networks^)
echo Press Ctrl+C to abort.
echo.
call npm install >>"%LOG_FILE%" 2>&1
if errorlevel 1 (
call :error "Backend npm install failed."
echo.
echo Common causes:
echo - Network timeout ^(retry with VPN or check %LOG_FILE%^)
echo - Native module compile failure ^(missing Python/VS Build Tools^)
echo - Disk space full
echo.
echo Try manually:
echo cd /d "%PROJECT_ROOT%"
echo npm install --verbose
echo.
pause
exit /b 1
)
echo [OK] Backend dependencies installed.
echo.
:: ============================================================
:: Step 5: Install frontend dependencies
:: ============================================================
call :step "5/6" "Installing frontend dependencies"
if not exist "web\package.json" (
call :error "web\package.json not found. Repository may be incomplete."
echo Please re-clone the repository:
echo git clone https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
pause
exit /b 1
)
echo Running: npm install ^(in web/ directory^)
echo.
pushd web >nul
call npm install >>"%LOG_FILE%" 2>&1
set "WEB_INSTALL_RESULT=!errorlevel!"
popd >nul
if !WEB_INSTALL_RESULT! neq 0 (
call :error "Frontend npm install failed ^(exit code !WEB_INSTALL_RESULT!^)."
echo.
echo Try manually:
echo cd /d "%PROJECT_ROOT%\web"
echo npm install --verbose
echo.
pause
exit /b 1
)
echo [OK] Frontend dependencies installed.
echo.
:: ============================================================
:: Step 6: Build project (backend + frontend)
:: ============================================================
call :step "6/6" "Building project"
echo Running: npm run build
echo.
call npm run build >>"%LOG_FILE%" 2>&1
if errorlevel 1 (
call :error "Build failed."
echo.
echo Check the log file for details: %LOG_FILE%
echo.
echo Try manually:
echo cd /d "%PROJECT_ROOT%"
echo npm run build
echo.
pause
exit /b 1
)
echo [OK] Build succeeded.
echo.
:: ============================================================
:: Verify build outputs
:: ============================================================
echo Verifying build outputs...
set "BUILD_OK=1"
if not exist "dist" (
call :error "dist/ directory missing after build."
set "BUILD_OK=0"
)
if not exist "web\dist" (
call :error "web\dist/ directory missing after build."
set "BUILD_OK=0"
)
if "!BUILD_OK!"=="0" (
echo.
echo Build completed but expected output is missing.
echo Check %LOG_FILE% for details.
pause
exit /b 1
)
echo [OK] Build outputs verified.
echo.
:: ============================================================
:: Optional: config.json hint
:: ============================================================
if not exist "config.json" (
echo [INFO] config.json will be auto-generated on first launch.
) else (
echo [OK] config.json already exists.
)
echo.
:: ============================================================
:: Done
:: ============================================================
call :log "Setup completed successfully at %date% %time%"
echo ============================================
echo Setup Complete!
echo ============================================
echo.
echo Next steps:
echo 1. Run: scripts\start.bat
echo 2. Open: http://localhost:3000
echo 3. Follow the in-browser setup wizard.
echo.
echo Setup log saved to: %LOG_FILE%
echo.
pause
exit /b 0
:: ============================================================
:: Subroutines
:: ============================================================
:step
echo ---- Step %~1: %~2 ----
call :log ""
call :log "---- Step %~1: %~2 ----"
goto :eof
:error
echo.
echo [ERROR] %~1
call :log "[ERROR] %~1"
goto :eof
:log
echo [%time%] %~1 >> "%LOG_FILE%"
goto :eof
@echo off
setlocal enabledelayedexpansion
chcp 65001 >nul
title TSMusicBot Setup
:: ============================================================
:: TSMusicBot Setup Script (Windows)
:: - Auto-detect China network, switch to npmmirror
:: - Download native binaries from CDN (避开 GitHub)
:: - 自动修复 PowerShell 环境变量
:: ============================================================
set "SCRIPT_VERSION=2.1"
set "MIN_NODE_MAJOR=20"
set "LOG_FILE=%~dp0..\setup.log"
set "FAILED=0"
:: Resolve project root (one level up from scripts/)
cd /d "%~dp0.." || (
echo [FATAL] Cannot change to project directory.
pause
exit /b 1
)
set "PROJECT_ROOT=%cd%"
:: ---- Initialize log ----
echo. > "%LOG_FILE%"
call :log "============================================"
call :log " TSMusicBot Setup v%SCRIPT_VERSION%"
call :log " Started: %date% %time%"
call :log " Project root: %PROJECT_ROOT%"
call :log "============================================"
echo ============================================
echo TSMusicBot - First-Time Setup (Windows)
echo Version %SCRIPT_VERSION%
echo ============================================
echo.
echo Log file: %LOG_FILE%
echo.
:: ============================================================
:: Step 1: Check Node.js
:: ============================================================
call :step "1/7" "Checking Node.js"
where node >nul 2>&1
if not errorlevel 1 goto :check_node_version
call :error "Node.js not found in PATH."
echo.
echo Please install Node.js %MIN_NODE_MAJOR% LTS or newer from:
echo https://nodejs.org/ (official)
echo https://nodejs.cn/ (China mirror, recommended)
echo.
pause
exit /b 1
:check_node_version
for /f "delims=" %%v in ('node --version 2^>nul') do set "NODE_VER=%%v"
for /f "tokens=1 delims=v." %%a in ("%NODE_VER%") do set "NODE_MAJOR=%%a"
call :log "Node.js version: %NODE_VER%"
echo [OK] Node.js found: %NODE_VER%
if %NODE_MAJOR% LSS %MIN_NODE_MAJOR% (
call :error "Node.js version too old. Need %MIN_NODE_MAJOR%+, found %NODE_VER%."
pause
exit /b 1
)
echo.
:: ============================================================
:: Step 2: Check npm
:: ============================================================
call :step "2/7" "Checking npm"
where npm >nul 2>&1
if errorlevel 1 (
call :error "npm not found."
pause
exit /b 1
)
for /f "delims=" %%v in ('npm --version 2^>nul') do set "NPM_VER=%%v"
call :log "npm version: %NPM_VER%"
echo [OK] npm found: %NPM_VER%
echo.
:: ============================================================
:: Step 3: Detect network and configure mirror
:: ============================================================
call :step "3/7" "Checking network"
set "USE_MIRROR=0"
set "MIRROR_REGISTRY=https://registry.npmjs.org"
echo Testing connection to npm registry...
call :log "Testing npm registry connectivity..."
ping -n 1 -w 4000 registry.npmjs.org >nul 2>&1
if errorlevel 1 (
echo [WARN] Cannot reach npm registry quickly, using China mirror.
call :log "npm registry unreachable via ping"
set "USE_MIRROR=1"
) else (
echo [OK] npm registry reachable.
call :log "npm registry reachable"
)
if "%USE_MIRROR%"=="1" (
echo.
echo [INFO] Using China mirror (npmmirror.com)
call :log "Switching to npmmirror.com"
set "MIRROR_REGISTRY=https://registry.npmmirror.com"
set "CDN_MIRROR=https://cdn.npmmirror.com/binaries"
) else (
set "CDN_MIRROR="
)
echo.
:: ============================================================
:: Step 4: Install backend dependencies (跳过二进制)
:: ============================================================
call :step "4/7" "Installing backend dependencies"
if exist "node_modules\.package-lock.json" (
echo Found existing node_modules. Checking integrity...
)
echo Running: npm install --ignore-scripts (跳过 GitHub 二进制下载)
echo.
call npm install --registry=%MIRROR_REGISTRY% --ignore-scripts >>"%LOG_FILE%" 2>&1
if errorlevel 1 (
call :error "Backend npm install failed."
echo Check the log: %LOG_FILE%
pause
exit /b 1
)
echo [OK] Backend dependencies installed.
echo.
:: ============================================================
:: Step 4b: Download native binaries from CDN
:: ============================================================
call :step "4b/7" "Downloading native binaries"
node scripts/download-binaries.mjs %CDN_MIRROR% >>"%LOG_FILE%" 2>&1
if errorlevel 1 (
echo [WARN] Binary download had issues. Check %LOG_FILE% for details.
) else (
echo [OK] Native binaries installed.
)
echo.
:: ============================================================
:: Step 5: Install frontend dependencies
:: ============================================================
call :step "5/7" "Installing frontend dependencies"
if not exist "web\package.json" (
call :error "web\package.json not found."
pause
exit /b 1
)
echo Running: npm install (in web/)
echo.
pushd web >nul
call npm install --registry=%MIRROR_REGISTRY% >>"%LOG_FILE%" 2>&1
set "WEB_INSTALL_RESULT=!errorlevel!"
popd >nul
if !WEB_INSTALL_RESULT! neq 0 (
call :error "Frontend npm install failed."
pause
exit /b 1
)
echo [OK] Frontend dependencies installed.
echo.
:: ============================================================
:: Step 6: Build project
:: ============================================================
call :step "6/7" "Building project"
echo Running: npm run build
echo.
call npm run build >>"%LOG_FILE%" 2>&1
if errorlevel 1 (
call :error "Build failed. Check: %LOG_FILE%"
pause
exit /b 1
)
echo [OK] Build succeeded.
echo.
:: ============================================================
:: Step 7: Ensure PowerShell in PATH (修复 jdymusic CDN 播放)
:: ============================================================
call :step "7/7" "Checking PowerShell PATH"
where powershell >nul 2>&1
if errorlevel 1 (
echo [WARN] PowerShell not found in PATH.
echo Attempting to fix...
set "POWERSHELL_PATH=C:\Windows\System32\WindowsPowerShell\v1.0"
if exist "!POWERSHELL_PATH!\powershell.exe" (
:: 为用户添加永久 PATH 环境变量
echo [INFO] Adding PowerShell to user PATH...
call setx PATH "!POWERSHELL_PATH!;%PATH%" >nul 2>&1
echo [OK] PowerShell added to PATH. Please restart your terminal.
) else (
echo [WARN] Could not find powershell.exe on this system.
echo If you encounter playback issues with some NetEase songs,
echo run: set PATH=%%PATH%%;C:\Windows\System32\WindowsPowerShell\v1.0\
echo before running scripts\start.bat
)
) else (
echo [OK] PowerShell found in PATH.
)
echo.
:: ============================================================
:: Verify build outputs
:: ============================================================
echo Verifying build outputs...
set "BUILD_OK=1"
if not exist "dist" (
call :error "dist/ directory missing after build."
set "BUILD_OK=0"
)
if not exist "web\dist" (
call :error "web\dist/ directory missing after build."
set "BUILD_OK=0"
)
if "!BUILD_OK!"=="0" (
echo Build completed but expected output is missing.
pause
exit /b 1
)
echo [OK] Build outputs verified.
echo.
if not exist "config.json" (
echo [INFO] config.json will be auto-generated on first launch.
) else (
echo [OK] config.json already exists.
)
echo.
:: ============================================================
:: Done
:: ============================================================
call :log "Setup completed successfully at %date% %time%"
echo ============================================
echo Setup Complete!
echo ============================================
echo.
echo Next steps:
echo 1. Run: scripts\start.bat
echo 2. Open: http://localhost:3000
echo.
echo Setup log: %LOG_FILE%
echo.
pause
exit /b 0
:: ============================================================
:: Subroutines
:: ============================================================
:step
echo ---- Step %~1: %~2 ----
call :log ""
call :log "---- Step %~1: %~2 ----"
goto :eof
:error
echo.
echo [ERROR] %~1
call :log "[ERROR] %~1"
goto :eof
:log
echo [%time%] %~1 >> "%LOG_FILE%"
goto :eof
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env bash
set -euo pipefail
#
# TSMusicBot Setup Script (Linux/macOS)
# - Auto-detect China network, switch to npmmirror
# - Download native binaries from CDN (避开 GitHub)
# - One-click setup, same as setup.bat for Windows
#
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
LOG_FILE="$PROJECT_DIR/setup.log"
echo "============================================"
echo " TSMusicBot - First-Time Setup (Linux)"
echo "============================================"
echo ""
echo "Log file: $LOG_FILE"
echo ""
# ---- Check Node.js ----
if ! command -v node &>/dev/null; then
echo "[ERROR] Node.js not found. Please install Node.js 20+ from https://nodejs.org"
echo " or https://nodejs.cn/ (China mirror)."
exit 1
fi
echo "[OK] Node.js $(node -v)"
if ! command -v npm &>/dev/null; then
echo "[ERROR] npm not found."
exit 1
fi
echo "[OK] npm v$(npm -v)"
echo ""
# ---- Detect China network ----
USE_MIRROR=0
MIRROR_REGISTRY="https://registry.npmjs.org"
CDN_MIRROR=""
echo "Testing connection to npm registry..."
if ping -c 1 -W 4 registry.npmjs.org &>/dev/null; then
echo "[OK] npm registry reachable."
else
echo "[WARN] Cannot reach npm registry, using China mirror."
USE_MIRROR=1
fi
if [ "$USE_MIRROR" = "1" ]; then
echo "[INFO] Using China mirror (npmmirror.com)"
MIRROR_REGISTRY="https://registry.npmmirror.com"
CDN_MIRROR="https://cdn.npmmirror.com/binaries"
export npm_config_registry="$MIRROR_REGISTRY"
fi
echo ""
# ---- Check build tools (needed for native module fallback) ----
if ! command -v gcc &>/dev/null && ! command -v clang &>/dev/null; then
echo "[INFO] No C compiler found. If CDN binaries are unavailable,"
echo " native modules may fail. Install build tools:"
echo " sudo apt install build-essential (Ubuntu/Debian)"
echo " sudo yum groupinstall 'Development Tools' (CentOS/RHEL)"
echo ""
fi
# ---- Step 1: Install dependencies (skip GitHub binaries) ----
echo "---- 1/5: Installing Node.js dependencies ----"
echo ""
cd "$PROJECT_DIR"
npm install --registry="$MIRROR_REGISTRY" --ignore-scripts 2>&1 | tee -a "$LOG_FILE"
echo "[OK] Dependencies installed."
echo ""
# ---- Step 2: Download native binaries from CDN ----
echo "---- 2/5: Downloading native binaries ----"
echo ""
if node scripts/download-binaries.mjs $CDN_MIRROR 2>&1 | tee -a "$LOG_FILE"; then
echo "[OK] Native binaries installed."
else
echo "[WARN] Some native binaries had issues (will try source build as fallback)."
fi
echo ""
# ---- Step 3: Install web panel dependencies ----
echo "---- 3/5: Installing web panel dependencies ----"
echo ""
if [ -f "web/package.json" ]; then
cd "$PROJECT_DIR/web"
npm install --registry="$MIRROR_REGISTRY" 2>&1 | tee -a "$LOG_FILE"
cd "$PROJECT_DIR"
echo "[OK] Web panel dependencies installed."
else
echo "[SKIP] web/package.json not found."
fi
echo ""
# ---- Step 4: Build project ----
echo "---- 4/5: Building project ----"
echo ""
npm run build 2>&1 | tee -a "$LOG_FILE"
echo "[OK] Build succeeded."
echo ""
# ---- Step 5: Verify ----
echo "---- 5/5: Verifying build ----"
echo ""
BUILD_OK=1
if [ ! -d "dist" ]; then
echo "[ERROR] dist/ directory missing."
BUILD_OK=0
fi
if [ -d "web" ] && [ ! -d "web/dist" ]; then
echo "[ERROR] web/dist/ directory missing."
BUILD_OK=0
fi
if [ "$BUILD_OK" = "0" ]; then
echo "Build completed but expected output is missing."
exit 1
fi
echo "[OK] Build outputs verified."
echo ""
if [ ! -f "config.json" ]; then
echo "[INFO] config.json will be auto-generated on first launch."
fi
echo ""
echo "============================================"
echo " Setup Complete!"
echo "============================================"
echo ""
echo "Next steps:"
echo " 1. Run: npm start"
echo " 2. Open: http://localhost:3000"
echo ""
echo "Setup log: $LOG_FILE"
echo ""
+43 -46
View File
@@ -1,47 +1,44 @@
@echo off
title TSMusicBot
echo Starting TSMusicBot...
echo.
@echo off
title TSMusicBot
echo Starting TSMusicBot...
echo.
:: Check if node is available
where node >nul 2>&1
if %errorlevel% neq 0 (
echo Node.js is not installed.
echo Run scripts\setup.bat first.
pause
exit /b 1
)
:: Resolve project root (one level up from scripts/)
cd /d "%~dp0.."
:: Check if dependencies are installed
if not exist "node_modules" (
echo Dependencies not found. Please run scripts\setup.bat first.
pause
exit /b 1
)
:: Check if build output exists
if not exist "dist" (
echo Build not found. Please run scripts\setup.bat first.
pause
exit /b 1
)
:: Ensure PowerShell is in PATH (fix for jdymusic CDN playback on some systems)
where powershell >nul 2>&1
if errorlevel 1 (
if exist "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" (
set "PATH=%PATH%;C:\Windows\System32\WindowsPowerShell\v1.0\"
)
)
:: Start the application
node dist/index.js
pause
:: Check if node is available
where node >nul 2>&1
if %errorlevel% neq 0 (
echo Node.js is not installed.
echo Run scripts\setup.bat for automatic installation, or install Node.js 20+ from https://nodejs.org
pause
exit /b 1
)
:: Resolve project root (one level up from scripts/)
cd /d "%~dp0.."
:: Install dependencies if needed
if not exist "node_modules" (
echo Installing dependencies...
call npm install --production
if %errorlevel% neq 0 (
echo Failed to install dependencies.
pause
exit /b 1
)
)
:: Build if dist/ doesn't exist
if not exist "dist" (
echo Building project...
call npx tsc
if %errorlevel% neq 0 (
echo Build failed.
pause
exit /b 1
)
)
:: FFmpeg is bundled via ffmpeg-static — no PATH check needed.
echo FFmpeg is bundled via node_modules (ffmpeg-static).
echo.
:: Start the application
node dist/index.js
pause
+106
View File
@@ -0,0 +1,106 @@
// Empirically verifies the PowerShell-download workaround for jdymusic CDN
// blocks Node.js HTTP. Runs A/B against the same fresh /jdymusic/ URL:
// A) ffmpeg direct with browser UA (the previous fix in this branch)
// B) PowerShell WebClient -> temp file -> ffmpeg from file (the new fix)
// Reports bytes received + exit code + stderr-tail for each.
import { spawn } from "node:child_process";
import { mkdtempSync, statSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildFfmpegArgs } from "../dist/audio/player.js";
const url = process.argv[2];
if (!url) {
console.error("usage: node scripts/test_jdymusic_powershell.mjs <jdymusic_url>");
process.exit(2);
}
if (!url.includes("/jdymusic/")) {
console.error("warning: this script targets /jdymusic/ URLs specifically");
}
const FFMPEG = "ffmpeg";
const BROWSER_UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const TIMEOUT_MS = 20_000;
function runFfmpeg(label, args, stdinSource) {
return new Promise((resolve) => {
const proc = spawn(FFMPEG, args, { stdio: [stdinSource ?? "ignore", "pipe", "pipe"] });
let bytes = 0;
let stderrTail = "";
let killed = false;
proc.stdout.on("data", (chunk) => { bytes += chunk.length; });
proc.stderr.on("data", (chunk) => { stderrTail = (stderrTail + chunk.toString()).slice(-1500); });
const timer = setTimeout(() => { killed = true; proc.kill("SIGTERM"); }, TIMEOUT_MS);
proc.on("exit", (code, signal) => {
clearTimeout(timer);
resolve({ label, bytes, code, signal, killed, stderrTail });
});
});
}
function downloadViaPowerShell(targetUrl, outFile) {
return new Promise((resolve) => {
const psScript = [
"$ErrorActionPreference = 'Stop'",
"$ProgressPreference = 'SilentlyContinue'",
"$wc = New-Object System.Net.WebClient",
"$wc.Headers.Add('User-Agent', $env:DL_UA)",
"$wc.Headers.Add('Referer', $env:DL_REFERER)",
"$wc.DownloadFile($env:DL_URL, $env:DL_OUT)",
].join("; ");
const ps = spawn(
"powershell",
["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", psScript],
{
env: {
...process.env,
DL_URL: targetUrl,
DL_OUT: outFile,
DL_UA: BROWSER_UA,
DL_REFERER: "https://music.163.com/",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
let stderr = "";
ps.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
ps.on("exit", (code) => resolve({ code, stderr }));
});
}
console.log(`URL: ${url}\n`);
console.log("[A] ffmpeg direct (browser UA via -headers)");
const a = await runFfmpeg("A", buildFfmpegArgs(url, 0));
console.log(` code=${a.code} bytes=${a.bytes} killed=${a.killed}`);
console.log(` stderr-tail: ${a.stderrTail.split("\n").slice(-3).join(" | ")}\n`);
console.log("[B] PowerShell WebClient -> temp file -> ffmpeg -i tempfile");
const tempDir = mkdtempSync(join(tmpdir(), "tsbot-jdymusic-test-"));
const tempFile = join(tempDir, "song.audio");
const psStart = Date.now();
const dl = await downloadViaPowerShell(url, tempFile);
const psMs = Date.now() - psStart;
if (dl.code !== 0) {
console.log(` PowerShell download FAILED: code=${dl.code}`);
console.log(` stderr: ${dl.stderr.slice(-500)}`);
rmSync(tempDir, { recursive: true, force: true });
process.exit(1);
}
const dlSize = statSync(tempFile).size;
console.log(` PowerShell downloaded ${dlSize} bytes in ${psMs}ms`);
const b = await runFfmpeg("B", buildFfmpegArgs(tempFile, 0));
console.log(` ffmpeg-from-file: code=${b.code} bytes=${b.bytes} killed=${b.killed}`);
console.log(` stderr-tail: ${b.stderrTail.split("\n").slice(-3).join(" | ")}\n`);
rmSync(tempDir, { recursive: true, force: true });
const aBlocked = a.bytes === 0 && !a.killed;
const bWorked = b.bytes > 100_000;
console.log(
`Verdict: direct ${aBlocked ? "BLOCKED" : "OK"} ; ` +
`powershell-then-ffmpeg ${bWorked ? "WORKED" : "FAILED"}`,
);
+73
View File
@@ -0,0 +1,73 @@
// Empirically tests whether the browser UA + Referer headers fix the
// connection resets we saw in bot.log against m701/m801.music.126.net.
//
// Spawns ffmpeg twice against the SAME fresh Netease CDN URL:
// A) old args from before the fix (no headers, -reconnect_delay_max 5)
// B) new args from after the fix (browser UA + Referer for music.126.net)
// and reports bytes received + exit code + stderr-tail for each.
import { spawn } from "node:child_process";
import { buildFfmpegArgs } from "../dist/audio/player.js";
const url = process.argv[2];
if (!url) {
console.error("usage: node scripts/test_netease_ua_fix.mjs <netease_cdn_url>");
process.exit(2);
}
const FFMPEG = "ffmpeg";
const TIMEOUT_MS = 15_000;
function legacyArgs(u) {
return [
"-reconnect", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "5",
"-i", u,
"-f", "s16le",
"-ar", "48000",
"-ac", "2",
"-acodec", "pcm_s16le",
"-",
];
}
function runFfmpeg(label, args) {
return new Promise((resolve) => {
const proc = spawn(FFMPEG, args, { stdio: ["ignore", "pipe", "pipe"] });
let bytes = 0;
let stderrTail = "";
let killed = false;
proc.stdout.on("data", (chunk) => {
bytes += chunk.length;
});
proc.stderr.on("data", (chunk) => {
stderrTail = (stderrTail + chunk.toString()).slice(-1500);
});
const timer = setTimeout(() => {
killed = true;
proc.kill("SIGTERM");
}, TIMEOUT_MS);
proc.on("exit", (code, signal) => {
clearTimeout(timer);
resolve({ label, bytes, code, signal, killed, stderrTail });
});
});
}
console.log(`URL: ${url}\n`);
const a = await runFfmpeg("A) legacy args (no UA)", legacyArgs(url));
console.log(`[A] code=${a.code} signal=${a.signal} killed=${a.killed} bytes=${a.bytes}`);
console.log(` stderr-tail:\n${a.stderrTail.split("\n").slice(-6).map((l) => " " + l).join("\n")}\n`);
const b = await runFfmpeg("B) fixed args (browser UA + Referer)", buildFfmpegArgs(url, 0));
console.log(`[B] code=${b.code} signal=${b.signal} killed=${b.killed} bytes=${b.bytes}`);
console.log(` stderr-tail:\n${b.stderrTail.split("\n").slice(-6).map((l) => " " + l).join("\n")}\n`);
const aFailed = a.bytes === 0 && !a.killed && a.code !== 0;
const bWorked = b.bytes > 100_000; // got real audio bytes
console.log(`Verdict: legacy ${aFailed ? "FAILED (no bytes, exit code 1)" : "??"} ; fixed ${bWorked ? "WORKED (received audio)" : "??"}`);
+202
View File
@@ -0,0 +1,202 @@
import { describe, it, expect } from "vitest";
import { mkdtempSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir, shouldEndOnStall, volumeToFactor } from "./player.js";
function getHeadersArg(args: string[]): string {
const idx = args.indexOf("-headers");
if (idx === -1) return "";
return args[idx + 1] ?? "";
}
describe("buildFfmpegArgs", () => {
it("includes browser User-Agent and Referer for Netease CDN URLs", () => {
const url = "http://m701.music.126.net/some/path/song.mp3?vuutv=abc";
const args = buildFfmpegArgs(url, 0);
const headers = getHeadersArg(args);
expect(headers).toContain("User-Agent:");
expect(headers).toContain("Mozilla/5.0");
expect(headers).toContain("Referer: https://music.163.com/");
});
it("keeps Bilibili Referer + UA for bilibili URLs", () => {
const url = "https://upos-sz-mirrorcoso1.bilivideo.com/foo/bar.mp3";
const args = buildFfmpegArgs(url, 0);
const headers = getHeadersArg(args);
expect(headers).toContain("Referer: https://www.bilibili.com");
expect(headers).toContain("User-Agent: Mozilla/5.0");
});
it("does not set custom headers for unknown URLs", () => {
const url = "https://example.com/song.mp3";
const args = buildFfmpegArgs(url, 0);
expect(args).not.toContain("-headers");
});
it("includes resilient reconnect flags for all URLs", () => {
const args = buildFfmpegArgs("https://example.com/song.mp3", 0);
expect(args).toContain("-reconnect");
expect(args).toContain("-reconnect_streamed");
expect(args).toContain("-reconnect_delay_max");
expect(args).toContain("-reconnect_on_network_error");
expect(args).toContain("-reconnect_on_http_error");
const idx = args.indexOf("-reconnect_delay_max");
expect(Number(args[idx + 1])).toBeGreaterThanOrEqual(30);
});
it("sets -reconnect_at_eof 1 (before -i) so long B站 streams resume after premature EOF (#89)", () => {
const args = buildFfmpegArgs("https://x.bilivideo.com/audio.m4s", 0);
const idx = args.indexOf("-reconnect_at_eof");
expect(idx).toBeGreaterThan(-1);
expect(args[idx + 1]).toBe("1");
expect(idx).toBeLessThan(args.indexOf("-i")); // input options must precede -i
});
it("inserts -ss before -i when seekSeconds > 0", () => {
const args = buildFfmpegArgs("https://example.com/song.mp3", 42);
const ssIdx = args.indexOf("-ss");
const iIdx = args.indexOf("-i");
expect(ssIdx).toBeGreaterThan(-1);
expect(args[ssIdx + 1]).toBe("42");
expect(ssIdx).toBeLessThan(iIdx);
});
it("does not insert -ss when seekSeconds is 0", () => {
const args = buildFfmpegArgs("https://example.com/song.mp3", 0);
expect(args).not.toContain("-ss");
});
it("omits HTTP-only flags when input is a local file path", () => {
const args = buildFfmpegArgs("C:/temp/song.mp3", 0);
expect(args).not.toContain("-reconnect");
expect(args).not.toContain("-reconnect_at_eof");
expect(args).not.toContain("-reconnect_on_network_error");
expect(args).not.toContain("-reconnect_on_http_error");
expect(args).not.toContain("-headers");
expect(args).toContain("-i");
expect(args[args.indexOf("-i") + 1]).toBe("C:/temp/song.mp3");
});
it("ends args with the input URL and PCM output spec", () => {
const url = "https://example.com/song.mp3";
const args = buildFfmpegArgs(url, 0);
const iIdx = args.indexOf("-i");
expect(args[iIdx + 1]).toBe(url);
expect(args).toContain("-f");
expect(args).toContain("s16le");
expect(args[args.length - 1]).toBe("-");
});
});
describe("volumeToFactor (#84 smooth volume curve)", () => {
it("is 0 at vol 0 and exactly 1.0 at vol 100 (full loudness still reserved at 100)", () => {
expect(volumeToFactor(0)).toBe(0);
expect(volumeToFactor(100)).toBe(1);
});
it("clamps out-of-range input", () => {
expect(volumeToFactor(-20)).toBe(0);
expect(volumeToFactor(150)).toBe(1);
});
it("is strictly monotonic across the whole range (no dead zone)", () => {
for (let v = 0; v < 100; v++) {
expect(volumeToFactor(v + 1)).toBeGreaterThan(volumeToFactor(v));
}
});
it("removes the old flat 80-99 dead zone", () => {
// Old mapping moved only 0.16 -> 0.198 across 80..99; new curve climbs clearly.
expect(volumeToFactor(99) - volumeToFactor(80)).toBeGreaterThan(0.3);
});
it("removes the discontinuity at 100 (old jump was ~0.8)", () => {
expect(volumeToFactor(100) - volumeToFactor(99)).toBeLessThan(0.1);
});
it("keeps the low range gentle", () => {
expect(volumeToFactor(50)).toBeLessThan(0.12);
});
});
describe("shouldUsePowerShellDownload", () => {
const jdymusicUrl =
"http://m801.music.126.net/20260507/abc/jdymusic/obj/xyz/song.mp3?vuutv=tok";
const newCdnUrl =
"http://m801.music.126.net/20260507/abc/jd-musicrep-ts/obj/xyz/song.mp3?vuutv=tok";
const ymusicUrl =
"http://m801.music.126.net/20260507/abc/ymusic/obj/xyz/song.mp3?vuutv=tok";
it("returns true for /jdymusic/ URL on win32", () => {
expect(shouldUsePowerShellDownload(jdymusicUrl, "win32")).toBe(true);
});
it("returns false for /jdymusic/ URL on linux", () => {
expect(shouldUsePowerShellDownload(jdymusicUrl, "linux")).toBe(false);
});
it("returns false for /jdymusic/ URL on darwin", () => {
expect(shouldUsePowerShellDownload(jdymusicUrl, "darwin")).toBe(false);
});
it("returns false for new-format /jd-musicrep-ts/ URL on win32", () => {
expect(shouldUsePowerShellDownload(newCdnUrl, "win32")).toBe(false);
});
it("returns false for /ymusic/ URL on win32", () => {
expect(shouldUsePowerShellDownload(ymusicUrl, "win32")).toBe(false);
});
it("returns false for unrelated URLs", () => {
expect(shouldUsePowerShellDownload("https://example.com/x.mp3", "win32")).toBe(false);
});
});
describe("cleanupTempDir", () => {
it("removes a directory and its contents", () => {
const dir = mkdtempSync(join(tmpdir(), "tsbot-test-"));
writeFileSync(join(dir, "song.mp3"), "fake-bytes");
expect(existsSync(dir)).toBe(true);
cleanupTempDir(dir);
expect(existsSync(dir)).toBe(false);
});
it("does not throw when directory does not exist", () => {
const missing = join(tmpdir(), "tsbot-test-does-not-exist-xyz");
expect(() => cleanupTempDir(missing)).not.toThrow();
});
it("does not throw when called twice", () => {
const dir = mkdtempSync(join(tmpdir(), "tsbot-test-"));
cleanupTempDir(dir);
expect(() => cleanupTempDir(dir)).not.toThrow();
});
});
describe("shouldEndOnStall (#89 mid-track stall watchdog)", () => {
const MAX_EMPTY = 250; // ~5s near-end threshold
const MAX_STALL = 3000; // ~60s far-from-end watchdog
it("ends quickly near the end once the empty threshold is reached (normal EOF)", () => {
expect(shouldEndOnStall(MAX_EMPTY, true, MAX_EMPTY, MAX_STALL)).toBe(true);
expect(shouldEndOnStall(MAX_EMPTY - 1, true, MAX_EMPTY, MAX_STALL)).toBe(false);
});
it("does NOT end far from the end at the near-end threshold (avoids false skips on transient underruns)", () => {
// This is the core regression: a brief underrun mid-song must not end the track.
expect(shouldEndOnStall(MAX_EMPTY, false, MAX_EMPTY, MAX_STALL)).toBe(false);
expect(shouldEndOnStall(MAX_STALL - 1, false, MAX_EMPTY, MAX_STALL)).toBe(false);
});
it("eventually ends far from the end once the long stall watchdog trips (dead stream recovers)", () => {
// The pre-fix bug: far-from-end stalls grew unbounded and never ended -> permanent silence.
expect(shouldEndOnStall(MAX_STALL, false, MAX_EMPTY, MAX_STALL)).toBe(true);
expect(shouldEndOnStall(MAX_STALL + 500, false, MAX_EMPTY, MAX_STALL)).toBe(true);
});
it("never ends before any threshold", () => {
expect(shouldEndOnStall(0, true, MAX_EMPTY, MAX_STALL)).toBe(false);
expect(shouldEndOnStall(10, false, MAX_EMPTY, MAX_STALL)).toBe(false);
});
});
+315 -13
View File
@@ -1,7 +1,9 @@
import { spawn, execSync, type ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { createRequire } from "node:module";
import { accessSync, chmodSync, constants } from "node:fs";
import { accessSync, chmodSync, constants, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createOpusEncoder, PCM_FRAME_BYTES, type Encoder } from "./encoder.js";
import type { Logger } from "../logger.js";
@@ -49,6 +51,100 @@ function getFfmpegCommand(): string {
return resolvedFfmpeg;
}
const BROWSER_UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
// Old jdymusic CDN paths (e.g. /jdymusic/obj/...) RST direct Node-stack
// requests on Windows; same URL works when fetched via WinHTTP. Empirically,
// /jd-musicrep-ts/ and /ymusic/ paths do not have this restriction.
export function shouldUsePowerShellDownload(
url: string,
platform: string = process.platform,
): boolean {
return platform === "win32" && url.includes("/jdymusic/");
}
export function cleanupTempDir(dir: string): void {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
// best-effort
}
}
export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
const args: string[] = [];
const isHttp = /^https?:\/\//i.test(url);
if (isHttp && (url.includes("bilivideo") || url.includes("bilibili"))) {
args.push(
"-headers",
`Referer: https://www.bilibili.com\r\nUser-Agent: ${BROWSER_UA}\r\n`,
);
} else if (isHttp && (url.includes("music.126.net") || url.includes("music.163.com"))) {
args.push(
"-headers",
`Referer: https://music.163.com/\r\nUser-Agent: ${BROWSER_UA}\r\n`,
);
}
if (isHttp) {
args.push(
"-reconnect", "1",
// Long B站 streams sit on a CDN whose session/token can close the
// connection mid-file (premature EOF). Without this, FFmpeg treats that
// EOF as end-of-input and stops ~partway through (see #89); with it, it
// re-issues a Range request from the current offset to finish the stream.
"-reconnect_at_eof", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "30",
"-reconnect_on_network_error", "1",
"-reconnect_on_http_error", "4xx,5xx",
);
}
if (seekSeconds > 0) args.push("-ss", String(seekSeconds));
args.push("-i", url, "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
return args;
}
/**
* Decide whether to end the current track when FFmpeg is still alive but has
* produced no decodable audio for `emptyAttempts` consecutive frame ticks.
*
* - Near the song end we end quickly (`maxEmptyAttempts`): a normal EOF.
* - Far from the end we wait much longer (`maxStallAttempts`) before giving up,
* so a transient buffer underrun on a healthy stream does NOT cause a false
* skip — but a genuinely dead stream (e.g. a long B站 stream whose CDN session
* expired mid-playback, #89) still recovers by advancing instead of going
* permanently silent.
*/
export function shouldEndOnStall(
emptyAttempts: number,
isNearEnd: boolean,
maxEmptyAttempts: number,
maxStallAttempts: number,
): boolean {
if (isNearEnd && emptyAttempts >= maxEmptyAttempts) return true;
if (emptyAttempts >= maxStallAttempts) return true;
return false;
}
/**
* Maps a 0-100 volume value to a linear PCM gain factor (#84).
*
* Continuous and strictly monotonic over [0,100]: 0 at vol 0 and exactly 1.0 at
* vol 100. The previous mapping was a two-piece step — gain = (vol/100)*0.2 for
* vol<100 (so the whole 0-99 range only spanned 0..0.198, making 80->99 feel
* flat) then a raw passthrough at vol===100 (a ~5x jump). This single curve keeps
* the low end gentle but ramps smoothly toward full loudness near the top, so the
* slider feels proportional with no dead zone and no discontinuity at 100.
*/
export function volumeToFactor(volume: number): number {
const x = Math.max(0, Math.min(100, volume)) / 100;
return 0.2 * x + 0.8 * Math.pow(x, 8);
}
export interface PlayerEvents {
frame: (opusFrame: Buffer) => void;
trackEnd: () => void;
@@ -80,6 +176,16 @@ export class AudioPlayer extends EventEmitter {
private static readonly MAX_CONSECUTIVE_FAILURES = 3;
private healthyFrames = 0;
private static readonly HEALTHY_FRAME_RESET = 50; // ~1 second of audio
private downloader: ChildProcess | null = null;
private currentTempDir: string | null = null;
private emptyFrameAttempts = 0;
private static readonly MAX_EMPTY_ATTEMPTS = 250; // ~5秒的20ms帧循环(增加容错)
// Far-from-end stall watchdog (#89): if FFmpeg is alive but produces no audio
// for this many consecutive frame ticks (~60s at 20ms/frame), treat the stream
// as dead and advance instead of staying silent forever. Set high so a normal
// transient underrun never trips it.
private static readonly MAX_STALL_ATTEMPTS = 3000;
private currentSongDuration = 0; // 当前歌曲总时长(秒)
constructor(logger: Logger) {
super();
@@ -87,7 +193,7 @@ export class AudioPlayer extends EventEmitter {
this.logger = logger;
}
play(url: string, seekSeconds = 0): void {
play(url: string, seekSeconds = 0, songDuration = 0): void {
// 1. 停止当前所有播放,自增 sessionId 屏蔽旧回调 (
this.stop();
@@ -98,6 +204,8 @@ export class AudioPlayer extends EventEmitter {
this.healthyFrames = 0;
this.ffmpegPaused = false;
this.spawnFailed = false;
this.emptyFrameAttempts = 0;
this.currentSongDuration = songDuration;
if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) {
this.logger.error({ failures: this.consecutiveFailures }, "FFmpeg failures limit reached");
@@ -106,13 +214,12 @@ export class AudioPlayer extends EventEmitter {
return;
}
const args: string[] = [];
if (url.includes("bilivideo") || url.includes("bilibili")) {
args.push("-headers", "Referer: https://www.bilibili.com\r\nUser-Agent: Mozilla/5.0...\r\n");
if (shouldUsePowerShellDownload(url)) {
this.playViaPowerShellDownload(url, seekSeconds, currentSessionId);
return;
}
args.push("-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5");
if (seekSeconds > 0) args.push("-ss", String(seekSeconds));
args.push("-i", url, "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
const args = buildFfmpegArgs(url, seekSeconds);
const ffmpegBin = getFfmpegCommand();
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
@@ -128,7 +235,7 @@ export class AudioPlayer extends EventEmitter {
if (this.sessionId !== currentSessionId) {
return;
}
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
if (this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER && !this.ffmpegPaused && this.ffmpeg?.stdout) {
this.ffmpeg.stdout.pause();
@@ -158,6 +265,131 @@ export class AudioPlayer extends EventEmitter {
this.startFrameLoop();
}
private playViaPowerShellDownload(url: string, seekSeconds: number, sessionId: number): void {
const tempDir = mkdtempSync(join(tmpdir(), "tsbot-jdymusic-"));
const tempFile = join(tempDir, "song.audio");
this.currentTempDir = tempDir;
const psScript = [
"$ErrorActionPreference = 'Stop'",
"$ProgressPreference = 'SilentlyContinue'",
"$wc = New-Object System.Net.WebClient",
"$wc.Headers.Add('User-Agent', $env:DL_UA)",
"$wc.Headers.Add('Referer', $env:DL_REFERER)",
"$wc.DownloadFile($env:DL_URL, $env:DL_OUT)",
].join("; ");
this.logger.debug({ sessionId, tempFile }, "Downloading via PowerShell (jdymusic CDN)");
const ps = spawn(
"powershell",
["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", psScript],
{
env: {
...process.env,
DL_URL: url,
DL_OUT: tempFile,
DL_UA: BROWSER_UA,
DL_REFERER: "https://music.163.com/",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
this.downloader = ps;
let stderrTail = "";
ps.stderr!.on("data", (chunk: Buffer) => {
stderrTail = (stderrTail + chunk.toString()).slice(-500);
});
ps.on("exit", (code, signal) => {
if (this.sessionId !== sessionId) {
cleanupTempDir(tempDir);
return;
}
this.downloader = null;
if (code !== 0) {
this.logger.warn({ code, signal, stderr: stderrTail }, "PowerShell download failed");
this.spawnFailed = true;
this.consecutiveFailures++;
this.state = "idle";
cleanupTempDir(tempDir);
this.currentTempDir = null;
this.emit("error", new Error(`PowerShell download exited ${code}`));
return;
}
this.spawnFfmpegFromFile(tempFile, seekSeconds, sessionId);
});
ps.on("error", (err) => {
if (this.sessionId !== sessionId) return;
this.downloader = null;
this.spawnFailed = true;
this.consecutiveFailures++;
cleanupTempDir(tempDir);
this.currentTempDir = null;
this.emit("error", err);
});
// Mark playing but DO NOT start the frame loop here — the loop's
// "no ffmpeg + empty buffer → trackEnd" branch would fire on the very
// first tick, before the PowerShell download even completes. The
// frame loop is started inside spawnFfmpegFromFile() once ffmpeg is
// alive and producing PCM.
this.state = "playing";
}
private spawnFfmpegFromFile(tempFile: string, seekSeconds: number, sessionId: number): void {
if (this.sessionId !== sessionId) {
if (this.currentTempDir) {
cleanupTempDir(this.currentTempDir);
this.currentTempDir = null;
}
return;
}
const args = buildFfmpegArgs(tempFile, seekSeconds);
const ffmpegBin = getFfmpegCommand();
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
const currentPid = this.ffmpeg.pid;
if (currentPid) {
globalActivePids.add(currentPid);
this.logger.debug({ pid: currentPid, sessionId }, "FFmpeg spawned (from temp file)");
}
const tempDirToCleanup = this.currentTempDir;
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
if (this.sessionId !== sessionId) return;
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
if (this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER && !this.ffmpegPaused && this.ffmpeg?.stdout) {
this.ffmpeg.stdout.pause();
this.ffmpegPaused = true;
}
});
this.ffmpeg.on("exit", (code, signal) => {
if (currentPid) globalActivePids.delete(currentPid);
this.logger.info({ pid: currentPid, code, signal }, "FFmpeg exited");
if (this.sessionId === sessionId) {
this.ffmpeg = null;
if (this.currentTempDir === tempDirToCleanup) this.currentTempDir = null;
}
if (tempDirToCleanup) cleanupTempDir(tempDirToCleanup);
});
this.ffmpeg.on("error", (err) => {
if (this.sessionId === sessionId) {
this.spawnFailed = true;
this.consecutiveFailures++;
this.emit("error", err);
}
});
// Now that ffmpeg is producing PCM, run the frame loop.
this.startFrameLoop();
}
stop(): void {
// 3. 递增 ID 是最有效的逻辑“隔离墙”
this.sessionId++;
@@ -169,13 +401,24 @@ export class AudioPlayer extends EventEmitter {
if (this.ffmpeg) {
const procToKill = this.ffmpeg;
const pidToKill = procToKill.pid;
this.ffmpeg = null;
this.ffmpeg = null;
if (pidToKill) {
this.forceCleanup(procToKill, pidToKill);
}
}
if (this.downloader) {
const ps = this.downloader;
this.downloader = null;
try { ps.kill("SIGTERM"); } catch { /* already gone */ }
}
if (this.currentTempDir) {
cleanupTempDir(this.currentTempDir);
this.currentTempDir = null;
}
this.ffmpegPaused = false;
this.spawnFailed = false;
this.state = "idle";
@@ -229,6 +472,60 @@ export class AudioPlayer extends EventEmitter {
if (this.state === "playing") this.sendNextFrame();
else if (this.state === "paused") this.nextFrameTime = performance.now();
// 检测pcmBuffer不足PCM_FRAME_BYTES导致连续循环卡死:
// 条件1: FFmpeg仍在运行但缓冲区不足一帧,且连续多次无法获取数据
// 条件2: 已播放时间接近歌曲结尾(最后5秒内)或未知时长
const elapsed = this.getElapsed();
const isNearEnd = this.currentSongDuration > 0
? (this.currentSongDuration - elapsed) <= 5 // 距离结尾不足5秒
: true; // 未知时长时保守处理
if (this.ffmpeg !== null && this.pcmBuffer.length < PCM_FRAME_BYTES) {
this.emptyFrameAttempts++;
// End the track when FFmpeg has gone silent: quickly if we're near the
// end (normal EOF), or after a much longer stall window if we're not
// (a dead/expired stream — #89 — so playback recovers instead of going
// permanently silent).
if (
shouldEndOnStall(
this.emptyFrameAttempts,
isNearEnd,
AudioPlayer.MAX_EMPTY_ATTEMPTS,
AudioPlayer.MAX_STALL_ATTEMPTS,
)
) {
this.logger.info({
sessionId: this.sessionId,
emptyAttempts: this.emptyFrameAttempts,
bufferSize: this.pcmBuffer.length,
elapsed: Math.round(elapsed),
duration: this.currentSongDuration,
remaining: Math.round(this.currentSongDuration - elapsed),
nearEnd: isNearEnd,
}, "FFmpeg stopped outputting data, ending track");
this.frameLoopRunning = false;
if (this.state !== "idle") {
this.state = "idle";
// 清理FFmpeg进程
if (this.ffmpeg) {
const procToKill = this.ffmpeg;
const pidToKill = procToKill.pid;
this.ffmpeg = null;
if (pidToKill) {
this.forceCleanup(procToKill, pidToKill);
}
}
this.consecutiveFailures = 0;
this.emit("trackEnd");
}
return;
}
} else {
// 成功获取数据或FFmpeg已结束,重置计数器
this.emptyFrameAttempts = 0;
}
if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) {
this.frameLoopRunning = false;
if (this.state !== "idle") {
@@ -270,8 +567,9 @@ export class AudioPlayer extends EventEmitter {
}
private applyVolume(pcm: Buffer): Buffer {
if (this.volume === 100) return Buffer.from(pcm);
const factor = (this.volume / 100) * 0.2;
const factor = volumeToFactor(this.volume);
// factor === 1 only at volume 100; skip the per-sample loop at full loudness.
if (factor >= 1) return Buffer.from(pcm);
const out = Buffer.alloc(pcm.length);
for (let i = 0; i < pcm.length; i += 2) {
let sample = Math.round(pcm.readInt16LE(i) * factor);
@@ -281,7 +579,11 @@ export class AudioPlayer extends EventEmitter {
}
getElapsed(): number { return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000; }
seek(seconds: number): void { if (this.currentUrl && Number.isFinite(seconds) && seconds >= 0) this.play(this.currentUrl, seconds); }
seek(seconds: number): void {
if (this.currentUrl && Number.isFinite(seconds) && seconds >= 0) {
this.play(this.currentUrl, seconds, this.currentSongDuration);
}
}
pause(): void { if (this.state === "playing") this.state = "paused"; }
resume(): void { if (this.state === "paused") { this.state = "playing"; this.nextFrameTime = performance.now(); } }
resetFailures(): void { this.consecutiveFailures = 0; }
+80
View File
@@ -484,4 +484,84 @@ describe("PlayQueue", () => {
expect(promoted?.id).toBe("x");
});
});
// Issue #70: 随机循环 (rloop) used true random-with-replacement, so some
// songs repeated often while others were starved. It should behave like a
// shuffle bag (NetEase/QQ style): play every song once per cycle in random
// order, then reshuffle and continue, avoiding an immediate cross-cycle repeat.
describe("random-loop shuffle bag (issue #70)", () => {
it("plays every song exactly once per cycle before repeating", () => {
queue.setMode(PlayMode.RandomLoop);
const N = 12;
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
queue.play();
const cycle1 = [queue.current()!.id];
for (let i = 0; i < N - 1; i++) cycle1.push(queue.next()!.id);
const cycle2: string[] = [];
for (let i = 0; i < N; i++) cycle2.push(queue.next()!.id);
// Each cycle is a full permutation of all N songs — zero repeats within
// a cycle, and both cycles cover the same complete set.
expect(new Set(cycle1).size).toBe(N);
expect(new Set(cycle2).size).toBe(N);
expect(new Set(cycle1)).toEqual(new Set(cycle2));
});
it("distributes plays evenly across songs over many cycles (no starvation)", () => {
queue.setMode(PlayMode.RandomLoop);
const N = 6;
const CYCLES = 20;
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
queue.play();
const counts = new Map<string, number>();
counts.set(queue.current()!.id, 1);
for (let i = 0; i < CYCLES * N - 1; i++) {
const id = queue.next()!.id;
counts.set(id, (counts.get(id) ?? 0) + 1);
}
// Shuffle bag => each song plays exactly CYCLES times. True random
// would skew heavily.
for (let i = 0; i < N; i++) {
expect(counts.get(`s${i}`)).toBe(CYCLES);
}
});
it("does not replay the same song across a cycle boundary", () => {
queue.setMode(PlayMode.RandomLoop);
const N = 5;
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
queue.play();
// Walk to the last song of cycle 1, then cross into cycle 2.
for (let i = 0; i < N - 1; i++) queue.next();
const lastOfCycle1 = queue.current()!.id;
const firstOfCycle2 = queue.next()!.id;
expect(firstOfCycle2).not.toBe(lastOfCycle1);
});
it("includes a song added mid-cycle within the current cycle", () => {
queue.setMode(PlayMode.RandomLoop);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.play(); // A
queue.next(); // B — both originals now played this cycle
queue.add(makeSong("C")); // added mid-cycle, still unplayed
// C is the only unplayed song, so it must come next (not a reshuffle).
expect(queue.next()?.id).toBe("C");
});
it("keeps looping forever with multiple songs (never returns null)", () => {
queue.setMode(PlayMode.RandomLoop);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.play();
for (let i = 0; i < 30; i++) {
expect(queue.next()).not.toBeNull();
}
});
});
});
+49 -17
View File
@@ -22,6 +22,7 @@ export class PlayQueue {
private mode: PlayMode = PlayMode.Sequential;
private playedIndices = new Set<number>();
private history: number[] = [];
private forwardStack: number[] = [];
private static readonly HISTORY_LIMIT = 50;
private pushHistory(idx: number): void {
@@ -100,12 +101,14 @@ export class PlayQueue {
this.currentIndex = -1;
this.playedIndices.clear();
this.history = [];
this.forwardStack = [];
}
play(): QueuedSong | null {
if (this.songs.length === 0) return null;
this.playedIndices.clear();
this.history = [];
this.forwardStack = [];
this.currentIndex = 0;
this.playedIndices.add(0);
return this.songs[0];
@@ -118,6 +121,7 @@ export class PlayQueue {
// shuffle from this point. History tracking is independent and
// unaffected by this clear.
this.playedIndices.clear();
this.forwardStack = [];
this.currentIndex = index;
this.playedIndices.add(index);
return this.songs[index];
@@ -139,12 +143,48 @@ export class PlayQueue {
this.currentIndex = (this.currentIndex + 1) % this.songs.length;
return this.songs[this.currentIndex];
}
case PlayMode.Random: {
case PlayMode.Random:
case PlayMode.RandomLoop: {
// 优先回到前进栈记录的位置(prev 退回的歌)
if (this.forwardStack.length > 0) {
const target = this.forwardStack.pop()!;
if (target !== this.currentIndex) {
this.pushHistory(this.currentIndex);
this.currentIndex = target;
this.playedIndices.add(target);
return this.songs[target];
}
}
// Shuffle bag: pick uniformly from the songs not yet played this
// cycle, so every song plays once before any repeats (NetEase/QQ
// style). Songs added mid-cycle aren't in playedIndices, so they're
// naturally eligible within the current cycle.
const unplayed: number[] = [];
for (let i = 0; i < this.songs.length; i++) {
if (!this.playedIndices.has(i)) unplayed.push(i);
}
if (unplayed.length === 0) return null;
if (unplayed.length === 0) {
// Cycle complete.
if (this.mode === PlayMode.Random) return null; // 随机:播完即停
// 随机循环:reshuffle and keep going forever.
if (this.songs.length === 1) {
this.pushHistory(this.currentIndex);
this.currentIndex = 0;
this.playedIndices = new Set([0]);
return this.songs[0];
}
// Start a fresh cycle: every song is eligible again, but exclude
// the song that just played from THIS pick only, so it doesn't
// repeat back-to-back across the boundary. It stays eligible for
// the rest of the new cycle, so every song still plays exactly once.
this.playedIndices = new Set();
for (let i = 0; i < this.songs.length; i++) {
if (i !== this.currentIndex) unplayed.push(i);
}
}
const nextIndex =
unplayed[Math.floor(Math.random() * unplayed.length)];
this.pushHistory(this.currentIndex);
@@ -152,33 +192,24 @@ export class PlayQueue {
this.playedIndices.add(nextIndex);
return this.songs[nextIndex];
}
case PlayMode.RandomLoop: {
if (this.songs.length === 1) {
this.pushHistory(this.currentIndex);
this.currentIndex = 0;
return this.songs[0];
}
let idx: number;
do {
idx = Math.floor(Math.random() * this.songs.length);
} while (idx === this.currentIndex);
this.pushHistory(this.currentIndex);
this.currentIndex = idx;
return this.songs[idx];
}
}
}
prev(): QueuedSong | null {
if (this.songs.length === 0) return null;
// 记录当前位置到前进栈,供 next 优先返回
if (this.currentIndex >= 0 && this.forwardStack.length < PlayQueue.HISTORY_LIMIT) {
this.forwardStack.push(this.currentIndex);
}
// Preferred: pop from the back-stack so prev means "the song I
// actually played before this one," not "the previous array slot."
while (this.history.length > 0) {
const idx = this.history.pop()!;
if (idx >= 0 && idx < this.songs.length) {
this.currentIndex = idx;
this.playedIndices.add(idx);
this.playedIndices = new Set([...this.history, this.currentIndex]);
return this.songs[idx];
}
// Stale entry (song removed) — keep popping.
@@ -227,6 +258,7 @@ export class PlayQueue {
this.mode = mode;
this.playedIndices.clear();
this.history = [];
this.forwardStack = [];
if (this.currentIndex >= 0) {
this.playedIndices.add(this.currentIndex);
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect } from "vitest";
import {
decideOccupancyAction,
occupancyFromClientList,
shouldResumeOnReturn,
} from "./auto-pause.js";
describe("decideOccupancyAction", () => {
it("pauses when empty while playing and enabled", () => {
expect(decideOccupancyAction("playing", false, true, 0)).toBe("pause");
});
it("does not pause when the feature is disabled", () => {
expect(decideOccupancyAction("playing", false, false, 0)).toBe("none");
});
it("does not pause when idle (nothing playing)", () => {
expect(decideOccupancyAction("idle", false, true, 0)).toBe("none");
});
it("does not pause when already paused", () => {
expect(decideOccupancyAction("paused", false, true, 0)).toBe("none");
});
it("resumes when re-populated and we auto-paused", () => {
expect(decideOccupancyAction("paused", true, true, 2)).toBe("resume");
});
it("does NOT resume a user-paused track on re-population", () => {
expect(decideOccupancyAction("paused", false, true, 2)).toBe("none");
});
it("does nothing when re-populated and already playing", () => {
expect(decideOccupancyAction("playing", false, true, 2)).toBe("none");
});
it("resume is independent of the enabled flag (we already auto-paused)", () => {
expect(decideOccupancyAction("paused", true, false, 1)).toBe("resume");
});
});
describe("occupancyFromClientList", () => {
it("returns null when the query failed (0 clients — bot itself is always present)", () => {
// This is the bug fix: a clientlist timeout makes getClientsInChannel()
// return [], which must be treated as "unknown", NOT as an empty channel.
expect(occupancyFromClientList(0)).toBeNull();
});
it("returns 0 other users when only the bot is in the channel", () => {
expect(occupancyFromClientList(1)).toBe(0);
});
it("excludes the bot itself from the count", () => {
expect(occupancyFromClientList(2)).toBe(1);
expect(occupancyFromClientList(5)).toBe(4);
});
it("never yields a negative count (guards the -1 that caused false pauses)", () => {
expect(occupancyFromClientList(-3)).toBeNull();
});
});
describe("shouldResumeOnReturn (event-driven auto-resume)", () => {
it("resumes when we auto-paused and are still paused", () => {
// The reported gap: someone returns after an auto-pause. clientlist can't
// confirm it (it times out while they're present), so we resume from the
// clientEnter event alone.
expect(shouldResumeOnReturn(true, "paused")).toBe(true);
});
it("does NOT resume a track the user paused by hand", () => {
expect(shouldResumeOnReturn(false, "paused")).toBe(false);
});
it("does nothing if already playing (e.g. the bot's own enter at connect)", () => {
// autoPaused is cleared to false on connect, so the bot's own clientEnter
// is a no-op; this also covers the playing/auto-paused-flag-stale case.
expect(shouldResumeOnReturn(false, "playing")).toBe(false);
expect(shouldResumeOnReturn(true, "playing")).toBe(false);
});
it("does nothing when idle (nothing to resume)", () => {
expect(shouldResumeOnReturn(true, "idle")).toBe(false);
expect(shouldResumeOnReturn(false, "idle")).toBe(false);
});
});
+67
View File
@@ -0,0 +1,67 @@
export type PlayerStateName = "idle" | "playing" | "paused";
export type OccupancyAction = "pause" | "resume" | "none";
/**
* Convert a channel client-list length into the number of *other* users, or
* `null` when occupancy can't be determined.
*
* A connected bot is always a member of its own channel, so a valid query
* returns at least 1 (the bot itself). A length of 0 therefore does NOT mean
* "empty channel" — it means the underlying `clientlist` query failed (e.g. the
* full-client `clientlist` command times out when other clients are present,
* and `getClientsInChannel()` returns `[]` on error). Treating that failure as
* "empty" is what caused playback to auto-pause within seconds whenever a
* listener was actually in the channel. When the result is indeterminate we
* return `null` so callers skip the auto-pause/idle decision entirely rather
* than mis-reading an unknown state as empty.
*/
export function occupancyFromClientList(clientCount: number): number | null {
if (clientCount <= 0) return null; // query failed → occupancy unknown
return clientCount - 1; // exclude the bot itself
}
/**
* Decide what auto-pause should do given channel occupancy.
* - empty (userCount <= 0): pause iff enabled and currently playing.
* - re-populated (userCount > 0): resume iff we previously auto-paused and are still paused.
* `autoPaused` distinguishes our auto-pause from a user pause, so user pauses are never resumed.
*/
export function decideOccupancyAction(
playerState: PlayerStateName,
autoPaused: boolean,
enabled: boolean,
userCount: number,
): OccupancyAction {
const empty = userCount <= 0;
if (empty) {
if (enabled && playerState === "playing") return "pause";
return "none";
}
if (autoPaused && playerState === "paused") return "resume";
return "none";
}
/**
* Whether a client-presence push event (a `clientEnter`) should trigger an
* auto-resume, WITHOUT consulting a clientlist query.
*
* Why event-driven: the full-client `clientlist`/`channellist` commands time
* out whenever ≥2 clients are connected to the server (a library limitation) —
* which is exactly the moment a listener returns. So occupancy cannot be
* re-queried to confirm the return; we must act on the push event itself.
* This is sound because the bot only ever auto-pauses while it is alone on the
* server (the sole state in which the occupancy query succeeds and pause
* fires). Therefore, while `autoPaused` is true, the only way occupancy can
* return is a fresh connection — delivered reliably as `clientEnter`.
*
* Gating on `autoPaused` (not merely "paused") guarantees we never revive a
* track the user paused by hand, and makes the bot's own `clientEnter` at
* connect a no-op (autoPaused is cleared to false on connect). This predicate
* NEVER pauses — pause stays on the authoritative clientlist path.
*/
export function shouldResumeOnReturn(
autoPaused: boolean,
playerState: PlayerStateName,
): boolean {
return autoPaused && playerState === "paused";
}
+112
View File
@@ -0,0 +1,112 @@
import { describe, it, expect } from "vitest";
import { BotInstance } from "./instance.js";
// Constructing a real BotInstance is heavy (spawns a TS3Client, AudioPlayer,
// reads avatars, etc.), and runExclusive only touches a single private field
// (`playGate`). So we exercise the ACTUAL shipped method via its prototype,
// bound to a minimal object carrying just that field. This proves the real
// serializer logic without standing up a full bot.
type Gate = { playGate: Promise<unknown> };
const runExclusive = BotInstance.prototype.runExclusive as <T>(
this: Gate,
fn: () => Promise<T>,
) => Promise<T>;
function makeGate(): Gate {
return { playGate: Promise.resolve() };
}
/** An explicit, timer-free deferred so ordering is deterministic. */
function deferred<T = void>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe("BotInstance.runExclusive — serialization", () => {
it("does not start fnB until fnA settles", async () => {
const gate = makeGate();
const order: string[] = [];
const gateA = deferred();
const pA = runExclusive.call(gate, async () => {
order.push("A-start");
await gateA.promise; // suspend A until we explicitly release it
order.push("A-end");
});
const pB = runExclusive.call(gate, async () => {
order.push("B-start");
order.push("B-end");
});
// Give the microtask queue a chance: B must NOT have started while A is
// still suspended on gateA.
await Promise.resolve();
await Promise.resolve();
expect(order).toEqual(["A-start"]);
gateA.resolve();
await pA;
await pB;
expect(order).toEqual(["A-start", "A-end", "B-start", "B-end"]);
});
it("runs fnB even if fnA rejects (chain survives rejection)", async () => {
const gate = makeGate();
const order: string[] = [];
const gateA = deferred();
const pA = runExclusive.call(gate, async () => {
order.push("A-start");
await gateA.promise;
throw new Error("A blew up");
});
const pB = runExclusive.call(gate, async () => {
order.push("B-start");
order.push("B-end");
return "B-result";
});
await Promise.resolve();
await Promise.resolve();
expect(order).toEqual(["A-start"]);
gateA.reject(new Error("A blew up"));
await expect(pA).rejects.toThrow("A blew up");
// B still runs, only after A has fully settled.
await expect(pB).resolves.toBe("B-result");
expect(order).toEqual(["A-start", "B-start", "B-end"]);
});
it("preserves call order across three serialized tasks", async () => {
const gate = makeGate();
const order: string[] = [];
const tasks = ["X", "Y", "Z"];
const promises = tasks.map((t) =>
runExclusive.call(gate, async () => {
order.push(`${t}-start`);
await Promise.resolve();
order.push(`${t}-end`);
}),
);
await Promise.all(promises);
expect(order).toEqual([
"X-start",
"X-end",
"Y-start",
"Y-end",
"Z-start",
"Z-end",
]);
});
});
+279 -62
View File
@@ -6,16 +6,23 @@ import {
} from "../ts-protocol/client.js";
import { AudioPlayer } from "../audio/player.js";
import { PlayQueue, PlayMode, type QueuedSong } from "../audio/queue.js";
import type { MusicProvider } from "../music/provider.js";
import type { MusicProvider, Song } from "../music/provider.js";
import {
parseCommand,
isAdminCommand,
type ParsedCommand,
} from "./commands.js";
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
import type { Logger } from "../logger.js";
import type { BotDatabase, ProfileConfig } from "../data/database.js";
import type { BotConfig } from "../data/config.js";
import { BotProfileManager } from "./profile.js";
import type { AvatarStore } from "../data/avatars.js";
import {
decideOccupancyAction,
occupancyFromClientList,
shouldResumeOnReturn,
} from "./auto-pause.js";
export interface BotInstanceOptions {
id: string;
@@ -28,6 +35,7 @@ export interface BotInstanceOptions {
database: BotDatabase;
config: BotConfig;
logger: Logger;
avatarStore: AvatarStore;
}
export interface BotStatus {
@@ -57,14 +65,20 @@ export class BotInstance extends EventEmitter {
private database: BotDatabase;
private config: BotConfig;
private logger: Logger;
private avatarStore: AvatarStore;
private connected = false;
private disconnectEmitted = false;
private voteSkipUsers = new Set<string>();
private isAdvancing = false;
private idleTimer: ReturnType<typeof setTimeout> | null = null;
private channelUserCount = 0;
private autoPaused = false;
private profileManager: BotProfileManager;
private isFmMode = false;
private fmProvider: MusicProvider | null = null;
/** Results of the most recent !search, for "#N" selection (issue #90). */
private lastSearchResults: Song[] = [];
private playGate: Promise<unknown> = Promise.resolve();
constructor(options: BotInstanceOptions) {
super();
@@ -77,6 +91,7 @@ export class BotInstance extends EventEmitter {
this.database = options.database;
this.config = options.config;
this.logger = options.logger.child({ botId: this.id });
this.avatarStore = options.avatarStore;
this.tsClient = new TS3Client(options.tsOptions, this.logger);
this.player = new AudioPlayer(this.logger);
@@ -90,6 +105,17 @@ export class BotInstance extends EventEmitter {
options.tsOptions.nickname,
);
// Best-effort: a corrupted/locked avatar file must not block bot startup.
try {
const relPath = this.database.getCustomAvatarPath(this.id);
if (relPath) {
const buf = this.avatarStore.read(relPath);
if (buf) this.profileManager.setCustomAvatar(buf);
}
} catch (err) {
this.logger.warn({ err }, "Failed to load custom avatar — skipping");
}
this.setupPlayerEvents();
this.setupTsEvents();
}
@@ -128,6 +154,8 @@ export class BotInstance extends EventEmitter {
// short-circuited on !this.connected, leaving player stuck as "playing".
this.connected = false;
this.player.stop();
// A lifecycle change must not leave a stale auto-resume armed.
this.autoPaused = false;
// Only emit externally once per lifecycle so clients don't see a
// duplicate "disconnected" after an explicit disconnect() call.
if (this.disconnectEmitted) return;
@@ -136,8 +164,57 @@ export class BotInstance extends EventEmitter {
});
this.tsClient.on("connected", () => {
// Fresh connection — clear any stale auto-pause flag from a prior session.
this.autoPaused = false;
this._startIdlePoller();
});
// React near-instantly to channel membership changes. The 30s idle
// poller remains the fallback if any of these events are missed.
//
// clientEnter additionally arms auto-RESUME directly from the event,
// because the occupancy query (clientlist) times out whenever another
// client is present — i.e. exactly when a listener returns — so it cannot
// be used to confirm the return. See _resumeIfReturning().
this.tsClient.on("clientEnter", () => {
this._resumeIfReturning();
void this.refreshOccupancy();
});
this.tsClient.on("clientLeave", () => void this.refreshOccupancy());
this.tsClient.on("clientMoved", () => void this.refreshOccupancy());
}
/**
* Resume playback when a listener returns after an auto-pause, driven by the
* clientEnter push event rather than a (timing-out) occupancy query.
*
* We only auto-pause while alone on the server, so `autoPaused` is a reliable
* "paused because empty" flag; any client appearing while it's set means a
* listener returned. Delegating to handleOccupancy(1) routes through
* decideOccupancyAction (resume iff autoPaused && paused) and also cancels the
* idle-disconnect timer. This path NEVER pauses — userCount is always > 0 —
* so a spurious or unrelated enter can only (harmlessly) resume, never stop
* playback. Pause remains exclusively on the authoritative clientlist path.
*/
private _resumeIfReturning(): void {
if (!this.connected) return;
if (shouldResumeOnReturn(this.autoPaused, this.player.getState())) {
this.handleOccupancy(1);
}
}
private async refreshOccupancy(): Promise<void> {
if (!this.connected) return;
try {
const clients = await this.tsClient.getClientsInChannel();
// A 0-length result means the clientlist query failed (the bot is always
// in its own channel) — occupancy is unknown, so don't act. Acting on it
// would mis-read it as "empty" and falsely auto-pause / idle-disconnect.
const userCount = occupancyFromClientList(clients.length);
if (userCount !== null) this.handleOccupancy(userCount);
} catch {
// ignore — the 30s poll is the fallback
}
}
async connect(): Promise<void> {
@@ -172,24 +249,53 @@ export class BotInstance extends EventEmitter {
if (minutes === 0) this._cancelIdleTimer();
}
/** 外部更新 autoPauseOnEmpty(由 API 保存时调用) */
updateAutoPause(enabled: boolean): void {
this.config.autoPauseOnEmpty = enabled;
if (!enabled && this.autoPaused && this.player.getState() === "paused") {
this.player.resume();
this.autoPaused = false;
this.emit("stateChange");
}
}
private _startIdlePoller(): void {
// 每 30 秒检查一次频道人数
const poll = async () => {
if (!this.connected) return;
try {
const clients = await this.tsClient.getClientsInChannel();
const userCount = clients.length - 1; // 排除 bot 自身
if (userCount <= 0) {
this._scheduleIdleCheck();
} else {
this._cancelIdleTimer();
}
// null = clientlist query failed (occupancy unknown) → don't act.
const userCount = occupancyFromClientList(clients.length);
if (userCount !== null) this.handleOccupancy(userCount);
} catch { /* ignore */ }
setTimeout(poll, 30_000);
};
setTimeout(poll, 30_000);
}
private handleOccupancy(userCount: number): void {
// idle-disconnect (unchanged behavior)
if (userCount <= 0) this._scheduleIdleCheck();
else this._cancelIdleTimer();
// auto-pause
const action = decideOccupancyAction(
this.player.getState(),
this.autoPaused,
this.config.autoPauseOnEmpty,
userCount,
);
if (action === "pause") {
this.player.pause();
this.autoPaused = true;
this.emit("stateChange");
} else if (action === "resume") {
this.player.resume();
this.autoPaused = false;
this.emit("stateChange");
}
}
private _scheduleIdleCheck(): void {
if (this.idleTimer !== null) return; // 已经在倒计时,不重复创建
const minutes = this.config.idleTimeoutMinutes ?? 0;
@@ -268,6 +374,9 @@ export class BotInstance extends EventEmitter {
throw new Error("Bot is not connected to TeamSpeak");
}
switch (cmd.name) {
case "search":
case "find":
return this.cmdSearch(cmd);
case "play":
return this.cmdPlay(cmd);
case "add":
@@ -304,7 +413,7 @@ export class BotInstance extends EventEmitter {
case "album":
return this.cmdAlbum(cmd);
case "fm":
return this.cmdFm();
return this.cmdFm(cmd);
case "artist":
return this.cmdArtist(cmd);
case "vote":
@@ -328,6 +437,11 @@ export class BotInstance extends EventEmitter {
return platform === "qq" ? this.qqProvider : this.neteaseProvider;
}
private disableFmMode(): void {
this.isFmMode = false;
this.fmProvider = null;
}
private getProvider(flags: Set<string>): MusicProvider {
if (flags.has("b")) return this.bilibiliProvider;
if (flags.has("q")) return this.qqProvider;
@@ -365,7 +479,10 @@ export class BotInstance extends EventEmitter {
return false;
}
song.url = url;
this.player.play(url);
this.player.play(url, 0, song.duration);
// Fresh playback (re)start — clear auto-pause so a later occupancy
// change won't try to "resume" a track the user already restarted.
this.autoPaused = false;
this.database.addPlayHistory({
botId: this.id,
songId: song.id,
@@ -375,10 +492,8 @@ export class BotInstance extends EventEmitter {
platform: song.platform,
coverUrl: song.coverUrl,
});
// Update bot presence (fire-and-forget — never blocks playback)
this.profileManager.onSongChange(song).catch((err) => {
this.logger.warn({ err }, "Profile update failed after song change");
});
// Keep TeamSpeak-side profile updates on the same path for play/next/FM.
await this.syncProfileToSong(song);
this.emit("stateChange");
return true;
} catch (err) {
@@ -387,36 +502,92 @@ export class BotInstance extends EventEmitter {
}
}
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !play <song name or URL>";
const provider = this.getProvider(cmd.flags);
const result = await provider.search(cmd.args, 1);
if (result.songs.length === 0)
return `No results found for: ${cmd.args}`;
private async syncProfileToSong(song: QueuedSong | null): Promise<void> {
try {
await this.profileManager.onSongChange(song);
} catch (err) {
this.logger.warn({ err }, "Profile update failed after song change");
}
}
const song = result.songs[0];
/**
* Resolve a !play/!add/!playnext argument into a single Song, supporting three
* forms (issue #90):
* 1) "#N" — the Nth result of the previous !search
* 2) id:<id> / URL — an exact song (disambiguates same-name songs)
* 3) plain text — search, returning the single most-popular hit (legacy)
*/
private async resolvePlayQuery(cmd: ParsedCommand): Promise<{ song?: Song; error?: string }> {
const args = (cmd.args ?? "").trim();
const p = this.config.commandPrefix;
// 1) "#N" — pick from the previous !search.
const sel = parseSelectionIndex(args);
if (sel !== null) {
if (this.lastSearchResults.length === 0)
return { error: `No recent search. Use ${p}search <name> first.` };
if (sel > this.lastSearchResults.length)
return { error: `Invalid selection #${sel}. ${p}search returned ${this.lastSearchResults.length} results.` };
return { song: this.lastSearchResults[sel - 1] };
}
// 2) id:/URL — fetch that exact song.
const ref = parseSongRef(args);
if (ref) {
const provider = ref.platform ? this.getProviderFor(ref.platform) : this.getProvider(cmd.flags);
const song = await provider.getSongDetail(ref.id);
if (!song) return { error: `No song found for ${ref.platform ?? provider.platform} id: ${ref.id}` };
return { song: { ...song, platform: provider.platform } };
}
// 3) Plain search term — single most-popular hit (historical behavior).
const provider = this.getProvider(cmd.flags);
const result = await provider.search(args, 1);
if (result.songs.length === 0) return { error: `No results found for: ${args}` };
return { song: { ...result.songs[0], platform: provider.platform } };
}
private async cmdSearch(cmd: ParsedCommand): Promise<string> {
const p = this.config.commandPrefix;
if (!cmd.args) return `Usage: ${p}search <name> [-q|-b|-y]`;
const provider = this.getProvider(cmd.flags);
const result = await provider.search(cmd.args, 8);
if (result.songs.length === 0) return `No results found for: ${cmd.args}`;
this.lastSearchResults = result.songs.map((s) => ({ ...s, platform: provider.platform }));
const lines = this.lastSearchResults.map(
(s, i) => `${i + 1}. ${s.name} - ${s.artist}${s.album ? ` 《${s.album}》` : ""} [id:${s.id}]`,
);
return [
`搜索结果(用 ${p}play #序号 播放,或 ${p}play id:<id>):`,
...lines,
].join("\n");
}
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return `Usage: ${this.config.commandPrefix}play <song name | #N | id:<id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error;
const song0 = song!;
this.queue.clear();
this.isFmMode = false;
this.queue.add({ ...song, platform: provider.platform });
this.disableFmMode();
this.queue.add({ ...song0 });
this.queue.play();
// Reset failure counter on user-initiated play
this.player.resetFailures();
const ok = await this.resolveAndPlay(this.queue.current()!);
if (!ok) return `Cannot play: ${song.name}`;
return `Now playing: ${song.name} - ${song.artist}`;
if (!ok) return `Cannot play: ${song0.name}`;
return `Now playing: ${song0.name} - ${song0.artist}`;
}
private async cmdAdd(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !add <song name>";
const provider = this.getProvider(cmd.flags);
const result = await provider.search(cmd.args, 1);
if (result.songs.length === 0)
return `No results found for: ${cmd.args}`;
if (!cmd.args) return `Usage: ${this.config.commandPrefix}add <song name | #N | id:<id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error;
const s = song!;
const song = result.songs[0];
const wasIdle = this.player.getState() === "idle";
this.queue.add({ ...song, platform: provider.platform });
this.queue.add({ ...s });
// If nothing was playing, start this newly-added song immediately.
// Matches /api/player/:id/add-by-id behavior so both add paths feel
@@ -426,21 +597,19 @@ export class BotInstance extends EventEmitter {
this.player.resetFailures();
await this.resolveAndPlay(this.queue.current()!);
this.emit("stateChange");
return `Now playing: ${song.name} - ${song.artist}`;
return `Now playing: ${s.name} - ${s.artist}`;
}
this.emit("stateChange");
return `Added to queue: ${song.name} - ${song.artist} (position ${this.queue.size()})`;
return `Added to queue: ${s.name} - ${s.artist} (position ${this.queue.size()})`;
}
private async cmdPlayNext(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !playnext <song name>";
const provider = this.getProvider(cmd.flags);
const result = await provider.search(cmd.args, 1);
if (result.songs.length === 0)
return `No results found for: ${cmd.args}`;
if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext <song name | #N | id:<id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error;
const s = song!;
const song = result.songs[0];
const wasIdle = this.player.getState() === "idle";
// Capture the slot addNext WILL insert at, before mutating the queue.
// addNext pushes when currentIndex<0 (slot = size); otherwise splices
@@ -451,37 +620,42 @@ export class BotInstance extends EventEmitter {
this.queue.getCurrentIndex() < 0
? this.queue.size()
: this.queue.getCurrentIndex() + 1;
this.queue.addNext({ ...song, platform: provider.platform });
this.queue.addNext({ ...s });
if (wasIdle) {
this.queue.playAt(insertedAt);
this.player.resetFailures();
const ok = await this.resolveAndPlay(this.queue.current()!);
this.emit("stateChange");
if (!ok) return `Cannot play: ${song.name}`;
return `Now playing: ${song.name} - ${song.artist}`;
if (!ok) return `Cannot play: ${s.name}`;
return `Now playing: ${s.name} - ${s.artist}`;
}
this.emit("stateChange");
return `Up next: ${song.name} - ${song.artist}`;
return `Up next: ${s.name} - ${s.artist}`;
}
private cmdPause(): string {
this.player.pause();
// User-initiated pause — clear auto-pause so occupancy won't auto-resume it.
this.autoPaused = false;
this.emit("stateChange");
return "Paused";
}
private cmdResume(): string {
this.player.resume();
// User-initiated resume — drop any auto-pause flag.
this.autoPaused = false;
this.emit("stateChange");
return "Resumed";
}
private cmdStop(): string {
this.player.stop();
this.autoPaused = false;
this.queue.clear();
this.isFmMode = false;
this.disableFmMode();
this.profileManager.onSongChange(null).catch((err) => {
this.logger.warn({ err }, "Profile restore failed on stop");
});
@@ -539,7 +713,7 @@ export class BotInstance extends EventEmitter {
private cmdClear(): string {
this.player.stop();
this.queue.clear();
this.isFmMode = false;
this.disableFmMode();
this.profileManager.onSongChange(null).catch((err) => {
this.logger.warn({ err }, "Profile restore failed on clear");
});
@@ -612,7 +786,7 @@ export class BotInstance extends EventEmitter {
if (songs.length === 0) return "Playlist is empty or not found";
this.queue.clear();
this.isFmMode = false;
this.disableFmMode();
for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform });
}
@@ -623,13 +797,31 @@ export class BotInstance extends EventEmitter {
}
private async cmdAlbum(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !album <album ID>";
if (!cmd.args) return "Usage: !album <album name or ID>";
const provider = this.getProvider(cmd.flags);
const songs = await provider.getAlbumSongs(cmd.args);
const id = this.extractId(cmd.args);
const isNumericId = /^\d+$/.test(cmd.args.trim());
let albumId: string;
if (isNumericId || id !== cmd.args) {
// Input is a numeric ID or URL containing an ID — use directly
albumId = id;
} else {
// Name-based search
const result = await provider.search(cmd.args);
const albums = result.albums ?? [];
if (albums.length === 0)
return `No albums found for: ${cmd.args}`;
albumId = albums[0].id;
}
const songs = await provider.getAlbumSongs(albumId);
if (songs.length === 0) return "Album is empty or not found";
this.queue.clear();
this.isFmMode = false;
this.disableFmMode();
for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform });
}
@@ -639,26 +831,38 @@ export class BotInstance extends EventEmitter {
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
}
private async cmdFm(): Promise<string> {
if (!this.neteaseProvider.getPersonalFm) {
return "Personal FM is only available for NetEase Cloud Music";
private async cmdFm(cmd: ParsedCommand): Promise<string> {
return this.startFm(this.getProvider(cmd.flags));
}
async startFm(provider: MusicProvider = this.neteaseProvider): Promise<string> {
// Match the !fm chat-command guard: refuse before mutating the queue when
// offline, so the web /fm route can't wipe the queue + flip into FM mode
// while nothing can actually play.
if (!this.connected) {
return "Bot is not connected to TeamSpeak";
}
const songs = await this.neteaseProvider.getPersonalFm();
if (!provider.getPersonalFm) {
return `Personal FM is not available for ${provider.platform}`;
}
const songs = await provider.getPersonalFm();
if (songs.length === 0)
return "No FM songs available (need to login first)";
this.queue.clear();
for (const song of songs) {
this.queue.add({ ...song, platform: "netease" });
this.queue.add({ ...song, platform: provider.platform });
}
this.queue.setMode(PlayMode.Random);
this.isFmMode = true;
this.fmProvider = provider;
this.player.resetFailures();
const first = this.queue.play();
if (first) await this.resolveAndPlay(first);
this.emit("stateChange");
return `Personal FM started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
const label = provider.platform === "qq" ? "QQ Radar FM" : "Personal FM";
return `${label} started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
}
private async cmdArtist(cmd: ParsedCommand): Promise<string> {
@@ -679,7 +883,7 @@ export class BotInstance extends EventEmitter {
}
this.queue.clear();
this.isFmMode = false;
this.disableFmMode();
for (const song of filtered) {
this.queue.add({ ...song, platform: provider.platform });
}
@@ -693,14 +897,15 @@ export class BotInstance extends EventEmitter {
}
private async refillFm(): Promise<void> {
if (!this.isFmMode || !this.neteaseProvider.getPersonalFm) return;
const provider = this.fmProvider;
if (!this.isFmMode || !provider?.getPersonalFm) return;
try {
const songs = await this.neteaseProvider.getPersonalFm();
const songs = await provider.getPersonalFm();
if (songs.length === 0) return;
for (const song of songs) {
this.queue.add({ ...song, platform: "netease" });
this.queue.add({ ...song, platform: provider.platform });
}
this.logger.debug({ count: songs.length }, "FM queue refilled");
this.logger.debug({ count: songs.length, platform: provider.platform }, "FM queue refilled");
} catch (err) {
this.logger.error({ err }, "Failed to refill FM queue");
}
@@ -752,17 +957,21 @@ export class BotInstance extends EventEmitter {
const p = this.config.commandPrefix;
return [
"TSMusicBot Commands:",
`${p}play <song> — Search and play`,
`${p}play <song> — Search and play (most popular match)`,
`${p}play -q <song> — Search from QQ Music`,
`${p}play -b <song> — Search from BiliBili`,
`${p}play -y <song> — Search from YouTube (yt-dlp)`,
`${p}add <song> — Add to queue`,
`${p}search <name> — List top matches to pick a specific (same-name) song`,
`${p}play #N — Play the Nth result of the last ${p}search`,
`${p}play id:<id> — Play an exact song by id / URL (NetEase·QQ·BiliBili)`,
`${p}add <song> — Add to queue (also accepts #N / id: / URL)`,
`${p}playnext <song> — Insert as next song (alias: ${p}pn)`,
`${p}pause/resume — Pause/resume`,
`${p}next/prev — Next/previous`,
`${p}stop — Stop and clear queue`,
`${p}vol <0-100> — Set volume`,
`${p}queue — Show queue`,
`${p}remove <pos> — Remove song at position (see ${p}queue)`,
`${p}mode <seq|loop|random|rloop> — Play mode`,
`${p}playlist <name or id> — Load playlist by name or ID`,
`${p}playlist -q <name or id> — Load playlist from QQ Music`,
@@ -843,6 +1052,14 @@ export class BotInstance extends EventEmitter {
return input;
}
/** Serialize queue-mutation + play sequences so concurrent requests can't
* interleave (audible track must match queue.currentIndex). */
runExclusive<T>(fn: () => Promise<T>): Promise<T> {
const next = this.playGate.then(fn, fn);
this.playGate = next.catch(() => {});
return next;
}
getStatus(): BotStatus {
return {
id: this.id,
+87
View File
@@ -0,0 +1,87 @@
import { describe, it, expect, afterEach } from "vitest";
import { join } from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { BotManager } from "./manager.js";
import { createDatabase, type BotDatabase } from "../data/database.js";
import { createPermissionStore } from "../data/permissions.js";
import { getDefaultConfig, loadConfig, saveConfig, type BotConfig } from "../data/config.js";
import type { Logger } from "../logger.js";
import type { MusicProvider } from "../music/provider.js";
import type { AvatarStore } from "../data/avatars.js";
// removeBot only calls logger.info; provide the full shape it could touch.
const stubLogger = {
info() {},
warn() {},
error() {},
debug() {},
child() {
return stubLogger;
},
} as unknown as Logger;
describe("BotManager.removeBot — guest scope pruning", () => {
const dirs: string[] = [];
let db: BotDatabase;
function makeTmpConfigPath(): string {
const dir = mkdtempSync(join(tmpdir(), "tsmusicbot-manager-test-"));
dirs.push(dir);
return join(dir, "config.json");
}
function makeManager(config: BotConfig, configPath: string): BotManager {
db = createDatabase(":memory:");
const permissions = createPermissionStore(db.db);
saveConfig(configPath, config);
return new BotManager(
{} as unknown as MusicProvider,
{} as unknown as MusicProvider,
{} as unknown as MusicProvider,
db,
config,
stubLogger,
{} as unknown as AvatarStore,
permissions,
configPath
);
}
afterEach(() => {
try {
db?.close();
} catch {
/* ignore */
}
for (const d of dirs) {
rmSync(d, { recursive: true, force: true });
}
dirs.length = 0;
});
it("prunes a deleted bot from guestMode.bots (array) and persists", async () => {
const configPath = makeTmpConfigPath();
const config = getDefaultConfig();
config.guestMode.bots = ["botA", "botB"];
const manager = makeManager(config, configPath);
await manager.removeBot("botA");
expect(config.guestMode.bots).toEqual(["botB"]);
// Persisted file must also reflect the prune.
expect(loadConfig(configPath).guestMode.bots).toEqual(["botB"]);
});
it('leaves guestMode.bots === "all" unchanged (no crash, no change)', async () => {
const configPath = makeTmpConfigPath();
const config = getDefaultConfig();
config.guestMode.bots = "all";
const manager = makeManager(config, configPath);
await manager.removeBot("botA");
expect(config.guestMode.bots).toBe("all");
expect(loadConfig(configPath).guestMode.bots).toBe("all");
});
});
+30 -4
View File
@@ -7,10 +7,12 @@ import {
import type { MusicProvider } from "../music/provider.js";
import { YouTubeProvider } from "../music/youtube.js";
import type { BotDatabase } from "../data/database.js";
import type { BotConfig } from "../data/config.js";
import { saveConfig, type BotConfig } from "../data/config.js";
import type { Logger } from "../logger.js";
import type { ServerProtocol } from "../ts-protocol/client.js";
import type { AvatarStore } from "../data/avatars.js";
import type { PermissionStore } from "../data/permissions.js";
/**
* Run bot.connect() with a hard deadline. If the handshake hangs (e.g. the
@@ -55,6 +57,7 @@ export interface CreateBotParams {
queryPort?: number;
nickname: string;
defaultChannel?: string;
channelId?: string;
channelPassword?: string;
autoStart?: boolean;
/** Force TS3 or TS6 protocol; omit or "unknown" for auto-detect. */
@@ -74,6 +77,9 @@ export class BotManager extends EventEmitter {
private database: BotDatabase;
private config: BotConfig;
private logger: Logger;
private avatarStore: AvatarStore;
private permissions: PermissionStore;
private configPath: string;
constructor(
neteaseProvider: MusicProvider,
@@ -81,7 +87,10 @@ export class BotManager extends EventEmitter {
bilibiliProvider: MusicProvider,
database: BotDatabase,
config: BotConfig,
logger: Logger
logger: Logger,
avatarStore: AvatarStore,
permissions: PermissionStore,
configPath: string
) {
super();
this.neteaseProvider = neteaseProvider;
@@ -91,6 +100,9 @@ export class BotManager extends EventEmitter {
this.database = database;
this.config = config;
this.logger = logger;
this.avatarStore = avatarStore;
this.permissions = permissions;
this.configPath = configPath;
}
async createBot(params: CreateBotParams): Promise<BotInstance> {
@@ -105,6 +117,7 @@ export class BotManager extends EventEmitter {
queryPort: params.queryPort ?? 10011,
nickname: params.nickname,
defaultChannel: params.defaultChannel,
channelId: params.channelId,
channelPassword: params.channelPassword,
serverPassword: params.serverPassword,
serverProtocol: params.serverProtocol,
@@ -117,6 +130,7 @@ export class BotManager extends EventEmitter {
database: this.database,
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
});
this.bots.set(id, bot);
@@ -129,6 +143,7 @@ export class BotManager extends EventEmitter {
serverPort: params.serverPort,
nickname: params.nickname,
defaultChannel: params.defaultChannel ?? "",
channelId: params.channelId ?? "",
channelPassword: params.channelPassword ?? "",
autoStart: params.autoStart ?? false,
serverProtocol: params.serverProtocol ?? "",
@@ -147,6 +162,12 @@ export class BotManager extends EventEmitter {
this.bots.delete(id);
}
this.database.deleteBotInstance(id);
this.permissions.pruneBot(id);
// Prune the deleted bot from the guest scope allow-list (mirrors permissions.pruneBot).
if (Array.isArray(this.config.guestMode.bots) && this.config.guestMode.bots.includes(id)) {
this.config.guestMode.bots = this.config.guestMode.bots.filter((b) => b !== id);
saveConfig(this.configPath, this.config);
}
this.emit("botInstanceRemoved", id);
this.logger.info({ botId: id }, "Bot instance removed");
}
@@ -163,6 +184,7 @@ export class BotManager extends EventEmitter {
serverPort: params.serverPort ?? existing.serverPort,
nickname: params.nickname ?? existing.nickname,
defaultChannel: params.defaultChannel ?? existing.defaultChannel,
channelId: params.channelId ?? existing.channelId,
channelPassword: params.channelPassword ?? existing.channelPassword,
serverProtocol: params.serverProtocol ?? existing.serverProtocol,
ts6ApiKey: params.ts6ApiKey ?? existing.ts6ApiKey,
@@ -221,6 +243,7 @@ export class BotManager extends EventEmitter {
// each connect and strips all previously granted groups.
identity: saved.identity || undefined,
defaultChannel: saved.defaultChannel || undefined,
channelId: saved.channelId || undefined,
channelPassword: saved.channelPassword || undefined,
serverPassword: saved.serverPassword || undefined,
serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined,
@@ -229,10 +252,11 @@ export class BotManager extends EventEmitter {
neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
youtubeProvider: this.youtubeProvider,
database: this.database,
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
});
this.bots.set(id, bot);
this.emit("botInstance", bot);
@@ -271,6 +295,7 @@ export class BotManager extends EventEmitter {
nickname: saved.nickname,
identity: saved.identity || undefined,
defaultChannel: saved.defaultChannel || undefined,
channelId: saved.channelId || undefined,
channelPassword: saved.channelPassword || undefined,
serverPassword: saved.serverPassword || undefined,
serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined,
@@ -279,10 +304,11 @@ export class BotManager extends EventEmitter {
neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
youtubeProvider: this.youtubeProvider,
database: this.database,
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
});
this.bots.set(saved.id, bot);
+147
View File
@@ -0,0 +1,147 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { BotProfileManager } from "./profile.js";
import type { TS3Client } from "../ts-protocol/client.js";
import type { QueuedSong } from "../audio/queue.js";
function makeMockTs(): TS3Client & {
uploadCalls: Buffer[];
clearCalls: number;
} {
const calls: Buffer[] = [];
let clears = 0;
const ts: any = {
uploadCalls: calls,
get clearCalls() { return clears; },
getHost: () => "127.0.0.1",
getHttpQuery: () => null,
fileTransferInitUpload: vi.fn().mockResolvedValue({}),
uploadFileData: vi.fn().mockImplementation(async (_h: any, _i: any, stream: any) => {
const chunks: Buffer[] = [];
for await (const c of stream) chunks.push(c as Buffer);
calls.push(Buffer.concat(chunks));
}),
fileTransferDeleteFile: vi.fn().mockResolvedValue(undefined),
sendCommandNoWait: vi.fn().mockImplementation(async (cmd: string) => {
if (/client_flag_avatar=$/.test(cmd)) clears++;
}),
};
return ts;
}
const noopLogger: any = { child: () => noopLogger, info: () => {}, debug: () => {}, warn: () => {}, error: () => {} };
const cfgOn = { avatarEnabled: true, descriptionEnabled: false, nicknameEnabled: false, awayStatusEnabled: false, channelDescEnabled: false, nowPlayingMsgEnabled: false };
const cfgOff = { ...cfgOn, avatarEnabled: false };
const fakeSong: QueuedSong = {
id: "1",
name: "X",
artist: "Y",
album: "Z",
platform: "netease",
url: "u",
coverUrl: "c",
duration: 100,
};
const flush = () => new Promise((r) => setImmediate(r));
describe("BotProfileManager custom avatar precedence", () => {
let ts: ReturnType<typeof makeMockTs>;
beforeEach(() => { ts = makeMockTs(); });
it("setCustomAvatar uploads immediately on a fresh idle bot (sync on)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.setCustomAvatar(Buffer.from([1, 2, 3]));
await flush();
expect(ts.uploadCalls.length).toBe(1);
expect(ts.uploadCalls[0].equals(Buffer.from([1, 2, 3]))).toBe(true);
});
it("setCustomAvatar uploads immediately when sync is off (always idle)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
pm.setCustomAvatar(Buffer.from([7]));
await flush();
expect(ts.uploadCalls.length).toBe(1);
});
it("setCustomAvatar while playing + sync on does NOT push (cover wins)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
// Simulate the bot playing a song. We can't actually run updateAvatar's
// full HTTP fetch path, but onSongChange records currentSong before
// updateAvatar runs, which is enough for this assertion.
void pm.onSongChange(fakeSong);
await flush();
const uploadsBefore = ts.uploadCalls.length;
pm.setCustomAvatar(Buffer.from([42]));
await flush();
expect(ts.uploadCalls.length).toBe(uploadsBefore); // no new upload
});
it("setCustomAvatar while playing + sync off DOES push (sync-off is idle)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
void pm.onSongChange(fakeSong);
await flush();
const uploadsBefore = ts.uploadCalls.length;
pm.setCustomAvatar(Buffer.from([42]));
await flush();
expect(ts.uploadCalls.length).toBe(uploadsBefore + 1);
});
it("setCustomAvatar(null) while idle clears the TS3 avatar", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.setCustomAvatar(Buffer.from([1]));
await flush();
const clearsBefore = ts.clearCalls;
pm.setCustomAvatar(null);
await flush();
expect(ts.clearCalls).toBe(clearsBefore + 1);
});
it("on stop with custom avatar set + sync on, restores custom (does not clear)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.setCustomAvatar(Buffer.from([1, 2, 3, 4]));
await flush();
const clearsBefore = ts.clearCalls;
await pm.onSongChange(null);
expect(ts.uploadCalls.at(-1)?.equals(Buffer.from([1, 2, 3, 4]))).toBe(true);
expect(ts.clearCalls).toBe(clearsBefore); // no extra clear
});
it("on stop with no custom avatar, falls back to clear", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
await pm.onSongChange(null);
expect(ts.clearCalls).toBe(1);
expect(ts.uploadCalls.length).toBe(0);
});
it("on connect with custom avatar set + sync ON, applies custom (spec matrix row 1)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.setCustomAvatar(Buffer.from([5, 5]));
await flush();
ts.uploadCalls.length = 0; // reset
pm.onConnect();
await flush();
expect(ts.uploadCalls.length).toBe(1);
expect(ts.uploadCalls[0].equals(Buffer.from([5, 5]))).toBe(true);
});
it("on connect with custom avatar set + sync OFF, applies custom", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
pm.setCustomAvatar(Buffer.from([9, 9]));
await flush();
ts.uploadCalls.length = 0;
pm.onConnect();
await flush();
expect(ts.uploadCalls.length).toBe(1);
expect(ts.uploadCalls[0].equals(Buffer.from([9, 9]))).toBe(true);
});
it("on connect with no custom avatar, does not touch avatar", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
pm.onConnect();
await flush();
expect(ts.uploadCalls.length).toBe(0);
expect(ts.clearCalls).toBe(0);
});
});
+51 -1
View File
@@ -25,6 +25,13 @@ export class BotProfileManager {
private logger: Logger;
private config: ProfileConfig;
private defaultNickname: string;
private customAvatar: Buffer | null = null;
/**
* Tracks the last song handed to onSongChange. null means stopped/idle.
* Used by setCustomAvatar to decide whether the new buffer should be
* pushed immediately (idle) or wait for the next stop event (playing).
*/
private currentSong: QueuedSong | null = null;
/** Per-feature permission-denied flags. Reset on reconnect. */
private permDenied = {
@@ -58,6 +65,26 @@ export class BotProfileManager {
// --- Public API ---
/**
* Set/clear the persistent idle avatar. Pass null to remove.
*
* If the bot is currently in an idle state (no song playing OR
* avatarEnabled is off), the new buffer is pushed to TS3 right away;
* otherwise the cover-art sync is in charge until the next stop event,
* at which point clearAvatar restores from this.customAvatar.
*/
setCustomAvatar(buffer: Buffer | null): void {
this.customAvatar = buffer;
const idle = this.currentSong === null || !this.config.avatarEnabled;
if (!idle) return;
const gen = ++this.generation;
if (buffer && buffer.length > 0) {
void this.applyIdleAvatar(gen);
} else {
void this.clearAvatar(gen);
}
}
/**
* Called when a new song starts playing (song != null) or playback
* stops (song == null).
@@ -71,6 +98,7 @@ export class BotProfileManager {
*/
async onSongChange(song: QueuedSong | null): Promise<void> {
const gen = ++this.generation;
this.currentSong = song;
// 1. Avatar first — file transfer uses its own response tracker and
// must run before sendCommandNoWait calls whose orphaned responses
@@ -91,6 +119,7 @@ export class BotProfileManager {
/** Reset permission-denied flags and bump generation on new connection. */
onConnect(): void {
this.generation++;
this.currentSong = null;
this.permDenied = {
avatar: false,
description: false,
@@ -99,6 +128,12 @@ export class BotProfileManager {
channelDesc: false,
nowPlayingMsg: false,
};
// No song is playing on a fresh connect, so the matrix says the
// custom avatar should be visible regardless of avatarEnabled.
if (this.customAvatar) {
const gen = this.generation;
void this.applyIdleAvatar(gen);
}
}
getConfig(): ProfileConfig {
@@ -171,6 +206,10 @@ export class BotProfileManager {
}
private async clearAvatar(gen: number): Promise<void> {
if (this.customAvatar && this.customAvatar.length > 0) {
await this.applyIdleAvatar(gen);
return;
}
try {
await this.withTimeout(
this.tsClient.fileTransferDeleteFile(0n, ["/avatar"]),
@@ -179,7 +218,6 @@ export class BotProfileManager {
} catch {
// File may not exist or transfer timed out — that's fine
}
// Bail if a newer song started while we were deleting
if (this.generation !== gen) return;
try {
await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar=");
@@ -188,6 +226,18 @@ export class BotProfileManager {
}
}
private async applyIdleAvatar(gen: number): Promise<void> {
if (!this.customAvatar || this.customAvatar.length === 0) return;
if (this.permDenied.avatar) return;
try {
await this.withTimeout(this.doAvatarUpload(this.customAvatar), FILE_TRANSFER_TIMEOUT_MS);
if (this.generation !== gen) return;
this.logger.info({ bytes: this.customAvatar.length }, "Idle (custom) avatar applied");
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
private async updateDescription(song: QueuedSong | null): Promise<void> {
if (!this.config.descriptionEnabled || this.permDenied.description) return;
try {
+69
View File
@@ -0,0 +1,69 @@
import { describe, it, expect } from "vitest";
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
describe("parseSongRef (#90 exact-song selection)", () => {
it("returns null for a plain search term", () => {
expect(parseSongRef("Die For You")).toBeNull();
expect(parseSongRef("周杰伦 晴天")).toBeNull();
expect(parseSongRef("")).toBeNull();
// A bare number is NOT treated as an id (a song may be named "2002").
expect(parseSongRef("2002")).toBeNull();
});
it("parses an explicit id: prefix with no platform (defer to flags)", () => {
expect(parseSongRef("id:185868")).toEqual({ id: "185868", platform: null });
expect(parseSongRef("ID: 004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: null });
});
it("strips trailing punctuation from a pasted id:", () => {
expect(parseSongRef("id:185868.")).toEqual({ id: "185868", platform: null });
expect(parseSongRef("id:185868)")).toEqual({ id: "185868", platform: null });
expect(parseSongRef("id:185868,")).toEqual({ id: "185868", platform: null });
});
it("does NOT treat NetEase collection (playlist/album/artist) URLs as a song id", () => {
// These reuse ?id= but are not songs — they should fall through to search,
// not misresolve to getSongDetail(collectionId) and error "no song".
expect(parseSongRef("https://music.163.com/playlist?id=123456")).toBeNull();
expect(parseSongRef("https://music.163.com/#/playlist?id=123456")).toBeNull();
expect(parseSongRef("https://music.163.com/album?id=123456")).toBeNull();
expect(parseSongRef("https://music.163.com/artist?id=185858")).toBeNull();
// A genuine song URL is still parsed.
expect(parseSongRef("https://music.163.com/song?id=185868")).toEqual({ id: "185868", platform: "netease" });
});
it("parses NetEase song URLs", () => {
expect(parseSongRef("https://music.163.com/song?id=185868")).toEqual({ id: "185868", platform: "netease" });
expect(parseSongRef("https://music.163.com/#/song?id=185868&userid=1")).toEqual({ id: "185868", platform: "netease" });
expect(parseSongRef("music.163.com/song/185868")).toEqual({ id: "185868", platform: "netease" });
});
it("parses QQ song URLs", () => {
expect(parseSongRef("https://y.qq.com/n/ryqq/songDetail/004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: "qq" });
expect(parseSongRef("https://y.qq.com/n/yqq/song/abc.html?songmid=004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: "qq" });
});
it("parses BiliBili BV ids (bare or in a URL)", () => {
expect(parseSongRef("BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: "bilibili" });
expect(parseSongRef("https://www.bilibili.com/video/BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: "bilibili" });
expect(parseSongRef("https://b23.tv/BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: "bilibili" });
});
});
describe("parseSelectionIndex (#90 pick from last search)", () => {
it("parses #N tokens (1-based)", () => {
expect(parseSelectionIndex("#1")).toBe(1);
expect(parseSelectionIndex("#2")).toBe(2);
expect(parseSelectionIndex("# 3")).toBe(3);
expect(parseSelectionIndex(" #10 ")).toBe(10);
});
it("rejects non-selections", () => {
expect(parseSelectionIndex("2")).toBeNull();
expect(parseSelectionIndex("#0")).toBeNull();
expect(parseSelectionIndex("#-1")).toBeNull();
expect(parseSelectionIndex("Die For You")).toBeNull();
expect(parseSelectionIndex("#2 extra")).toBeNull();
expect(parseSelectionIndex("")).toBeNull();
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* Parsing helpers for picking an EXACT song in a !play / !add / !playnext query,
* so same-name songs can be disambiguated instead of always getting the single
* most-popular search hit (issue #90).
*
* Two mechanisms:
* - A song reference: an explicit id / platform URL → play that exact song.
* - A selection index: "#N" → the Nth result of the previous !search.
*/
export interface SongRef {
id: string;
/**
* Platform inferred from a URL. `null` means the platform wasn't encoded in
* the reference (e.g. a bare `id:`), so the caller should fall back to the
* command's flags / default provider.
*/
platform: "netease" | "qq" | "bilibili" | null;
}
/**
* Detect an explicit song reference in a query. Recognizes:
* - `id:<id>` → platform from flags/default
* - NetEase song URL → music.163.com/song?id=N (also /#/song?id=N, /song/N)
* - QQ song URL → y.qq.com/.../songDetail/MID (or ?songmid=MID)
* - BiliBili BVID (bare or in a URL) → bilibili.com/video/BVxxxx, b23.tv, or BVxxxx
* Returns `null` for a plain search term (the common case).
*/
export function parseSongRef(raw: string): SongRef | null {
const q = (raw ?? "").trim();
if (!q) return null;
// Explicit "id:<id>" — platform decided by the command's flags/default.
// Strip trailing punctuation that tags along from a chat paste ("id:12345."
// / "id:12345)") — no supported id (numeric / BVID / mid) ends in those.
const idPrefix = /^id:\s*(\S+)$/i.exec(q);
if (idPrefix) return { id: idPrefix[1].replace(/[.,;)\]]+$/, ""), platform: null };
// BiliBili BV id, bare or inside a bilibili URL (NetEase ids are numeric, so
// a "BV..." token never collides with them).
const bv = /BV[0-9A-Za-z]{8,12}/.exec(q);
if (bv && (/^BV[0-9A-Za-z]{8,12}$/.test(q) || /bilibili\.com|b23\.tv/i.test(q))) {
return { id: bv[0], platform: "bilibili" };
}
// NetEase song URL. Only treat `id=N` as a SONG id when the URL is not a
// collection page (playlist/album/artist/toplist/djradio) — those reuse the
// same `id=` param but are NOT songs; getSongDetail() would 404 them into a
// confusing "no song" error instead of falling back to a normal search.
if (/music\.163\.com/i.test(q) && !/(playlist|album|artist|toplist|djradio)/i.test(q)) {
const m = /[?&#/]id=(\d+)/.exec(q) ?? /\/song\/(\d+)/.exec(q);
if (m) return { id: m[1], platform: "netease" };
}
// QQ song URL.
if (/y\.qq\.com/i.test(q)) {
const m = /songDetail\/([0-9A-Za-z]+)/.exec(q) ?? /[?&]songmid=([0-9A-Za-z]+)/i.exec(q);
if (m) return { id: m[1], platform: "qq" };
}
return null;
}
/**
* Detect a "#N" selection token (1-based) referencing the previous !search.
* Returns the positive integer, or `null` when the query isn't a selection.
*/
export function parseSelectionIndex(raw: string): number | null {
const m = /^#\s*(\d+)$/.exec((raw ?? "").trim());
if (!m) return null;
const n = parseInt(m[1], 10);
return Number.isFinite(n) && n > 0 ? n : null;
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createDatabase, type BotDatabase } from "./database.js";
import { createAuditStore, type AuditStore } from "./audit.js";
describe("AuditStore", () => {
let botDb: BotDatabase;
let audit: AuditStore;
beforeEach(() => {
botDb = createDatabase(":memory:");
audit = createAuditStore(botDb.db);
});
afterEach(() => botDb.close());
it("records and lists entries newest-first", async () => {
audit.record({
actorId: "a1", actorUsername: "alice",
targetUserId: "b1", targetUsername: "bob",
action: "user.created",
});
await new Promise((r) => setTimeout(r, 5));
audit.record({
actorId: "a1", actorUsername: "alice",
targetUserId: "b1", targetUsername: "bob",
action: "user.deleted",
});
const list = audit.list(10, 0);
expect(list).toHaveLength(2);
expect(list[0].action).toBe("user.deleted");
expect(list[1].action).toBe("user.created");
});
it("supports limit and offset", () => {
for (let i = 0; i < 5; i++) {
audit.record({
actorId: "a1", actorUsername: "alice",
targetUserId: null, targetUsername: null,
action: "user.password_changed",
});
}
expect(audit.list(2, 0)).toHaveLength(2);
expect(audit.list(2, 4)).toHaveLength(1);
expect(audit.list(10, 10)).toHaveLength(0);
});
it("stores nullable fields correctly", () => {
audit.record({
actorId: null, actorUsername: null,
targetUserId: "x", targetUsername: "deleted-user",
action: "admin.first_created",
});
const e = audit.list(1, 0)[0];
expect(e.actorId).toBeNull();
expect(e.actorUsername).toBeNull();
expect(e.targetUserId).toBe("x");
});
});
+58
View File
@@ -0,0 +1,58 @@
import type Database from "better-sqlite3";
export type AuditAction =
| "admin.first_created"
| "user.created"
| "user.deleted"
| "user.password_reset"
| "user.password_changed"
| "user.role_changed"
| "user.permissions_changed";
export interface AuditEntry {
id: number;
timestamp: number;
actorId: string | null;
actorUsername: string | null;
targetUserId: string | null;
targetUsername: string | null;
action: AuditAction;
}
export interface AuditRecordInput {
actorId: string | null;
actorUsername: string | null;
targetUserId: string | null;
targetUsername: string | null;
action: AuditAction;
}
export interface AuditStore {
record(input: AuditRecordInput): void;
list(limit: number, offset: number): AuditEntry[];
}
export function createAuditStore(db: Database.Database): AuditStore {
const insertStmt = db.prepare(
"INSERT INTO user_audit (timestamp, actorId, actorUsername, targetUserId, targetUsername, action) VALUES (?, ?, ?, ?, ?, ?)"
);
const listStmt = db.prepare(
"SELECT id, timestamp, actorId, actorUsername, targetUserId, targetUsername, action FROM user_audit ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?"
);
return {
record(input) {
insertStmt.run(
Date.now(),
input.actorId,
input.actorUsername,
input.targetUserId,
input.targetUsername,
input.action
);
},
list(limit, offset) {
return listStmt.all(limit, offset) as AuditEntry[];
},
};
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect, beforeEach } from "vitest";
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createAvatarStore } from "./avatars.js";
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "avatar-test-"));
});
describe("createAvatarStore", () => {
it("write returns a relative path under the store dir", () => {
const store = createAvatarStore(dir);
const buf = Buffer.from("fake-png");
const rel = store.write("bot-1", "image/png", buf);
expect(rel).toBe("bot-1.png");
expect(readFileSync(join(dir, "bot-1.png")).equals(buf)).toBe(true);
});
it("write picks correct extension for jpeg / webp", () => {
const store = createAvatarStore(dir);
expect(store.write("a", "image/jpeg", Buffer.from(""))).toBe("a.jpg");
expect(store.write("b", "image/webp", Buffer.from(""))).toBe("b.webp");
});
it("write rejects unsupported MIME types", () => {
const store = createAvatarStore(dir);
expect(() => store.write("c", "image/gif", Buffer.from(""))).toThrow(/unsupported/i);
});
it("read returns the bytes for an existing file", () => {
const store = createAvatarStore(dir);
store.write("bot-1", "image/png", Buffer.from("hello"));
const buf = store.read("bot-1.png");
expect(buf?.equals(Buffer.from("hello"))).toBe(true);
});
it("read returns null when path is missing", () => {
const store = createAvatarStore(dir);
expect(store.read("missing.png")).toBeNull();
});
it("remove deletes the file (idempotent)", () => {
const store = createAvatarStore(dir);
store.write("bot-1", "image/png", Buffer.from("x"));
store.remove("bot-1.png");
expect(existsSync(join(dir, "bot-1.png"))).toBe(false);
expect(() => store.remove("bot-1.png")).not.toThrow();
});
it("write replaces any existing file for the same botId regardless of old extension", () => {
const store = createAvatarStore(dir);
store.write("bot-1", "image/png", Buffer.from("old"));
const rel = store.write("bot-1", "image/jpeg", Buffer.from("new"));
expect(rel).toBe("bot-1.jpg");
expect(existsSync(join(dir, "bot-1.png"))).toBe(false);
expect(existsSync(join(dir, "bot-1.jpg"))).toBe(true);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { mkdirSync, writeFileSync, readFileSync, rmSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
const MIME_TO_EXT: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
};
export interface AvatarStore {
/** Returns the relative path written (e.g. "bot-1.png"). */
write(botId: string, mime: string, buffer: Buffer): string;
read(relPath: string): Buffer | null;
remove(relPath: string): void;
getDir(): string;
}
export function createAvatarStore(dir: string): AvatarStore {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return {
write(botId, mime, buffer) {
const ext = MIME_TO_EXT[mime];
if (!ext) throw new Error(`unsupported avatar MIME: ${mime}`);
for (const name of readdirSync(dir)) {
if (name.startsWith(`${botId}.`)) rmSync(join(dir, name), { force: true });
}
const rel = `${botId}.${ext}`;
writeFileSync(join(dir, rel), buffer);
return rel;
},
read(relPath) {
const full = join(dir, relPath);
if (!existsSync(full)) return null;
return readFileSync(full);
},
remove(relPath) {
rmSync(join(dir, relPath), { force: true });
},
getDir() {
return dir;
},
};
}
+128 -3
View File
@@ -1,8 +1,8 @@
import { describe, it, expect, afterEach } from "vitest";
import { join } from "node:path";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { getDefaultConfig, loadConfig, saveConfig } from "./config.js";
import { getDefaultConfig, loadConfig, saveConfig, migrateLegacyConfig } from "./config.js";
describe("config", () => {
const dirs: string[] = [];
@@ -49,6 +49,131 @@ describe("config", () => {
// defaults should fill in the rest
expect(loaded.theme).toBe("dark");
expect(loaded.commandPrefix).toBe("!");
expect(loaded.autoPauseOnEmpty).toBe(true);
// auto-pause defaults OFF (occupancy detection is unreliable on some servers)
expect(loaded.autoPauseOnEmpty).toBe(false);
});
// --- #86: config.json must live under (and be created in) the persisted data dir ---
it("first run writes config.json into the data dir and reads it back", () => {
const root = makeTmpDir();
const dataDir = join(root, "data");
const configPath = join(dataDir, "config.json"); // mirrors index.ts CONFIG_PATH
// Boot sequence: load (missing -> defaults) then save.
const config = loadConfig(configPath);
saveConfig(configPath, config);
expect(existsSync(configPath)).toBe(true);
// A subsequent hand-edited file under the SAME persisted path is honored.
writeFileSync(configPath, JSON.stringify({ webPort: 9999 }), "utf-8");
expect(loadConfig(configPath).webPort).toBe(9999);
});
it("migrates a legacy root config into the data dir, preserving values", () => {
const root = makeTmpDir();
const legacyPath = join(root, "config.json");
const newPath = join(root, "data", "config.json");
writeFileSync(legacyPath, JSON.stringify({ webPort: 4242, publicUrl: "http://x" }), "utf-8");
const migrated = migrateLegacyConfig(legacyPath, newPath);
expect(migrated).toBe(true);
expect(existsSync(newPath)).toBe(true);
expect(existsSync(legacyPath)).toBe(false); // legacy moved, not duplicated
const loaded = loadConfig(newPath);
expect(loaded.webPort).toBe(4242);
expect(loaded.publicUrl).toBe("http://x");
});
it("does NOT overwrite an existing data-dir config during migration", () => {
const root = makeTmpDir();
const legacyPath = join(root, "config.json");
const newPath = join(root, "data", "config.json");
writeFileSync(legacyPath, JSON.stringify({ webPort: 1111 }), "utf-8");
saveConfig(newPath, { ...getDefaultConfig(), webPort: 2222 });
const migrated = migrateLegacyConfig(legacyPath, newPath);
expect(migrated).toBe(false); // new location wins, untouched
expect(loadConfig(newPath).webPort).toBe(2222);
expect(existsSync(legacyPath)).toBe(true); // legacy left intact when not migrated
});
it("migration is a no-op when there is no legacy config", () => {
const root = makeTmpDir();
const migrated = migrateLegacyConfig(join(root, "config.json"), join(root, "data", "config.json"));
expect(migrated).toBe(false);
});
});
describe("guestMode config", () => {
it("defaults to disabled, all-bots, append-only", () => {
const c = getDefaultConfig();
expect(c.guestMode.enabled).toBe(false);
expect(c.guestMode.bots).toBe("all");
expect(c.guestMode.permissions).toEqual({
addToQueue: true, playNext: false, playNow: false,
skip: false, transport: false, removeClear: false, playMode: false,
});
});
it("deep-merges a partial guestMode so missing sub-keys are back-filled", () => {
const dir = mkdtempSync(join(tmpdir(), "tsmb-cfg-"));
const p = join(dir, "config.json");
writeFileSync(p, JSON.stringify({ guestMode: { enabled: true, permissions: { playNext: true } } }));
const c = loadConfig(p);
expect(c.guestMode.enabled).toBe(true);
expect(c.guestMode.bots).toBe("all"); // back-filled
expect(c.guestMode.permissions.playNext).toBe(true);
expect(c.guestMode.permissions.addToQueue).toBe(true); // back-filled default
expect(c.guestMode.permissions.skip).toBe(false); // back-filled default
rmSync(dir, { recursive: true, force: true });
});
// --- B1: loadConfig must sanitize a hand-edited/legacy/corrupt guestMode ---
function loadGuestMode(raw: unknown) {
const dir = mkdtempSync(join(tmpdir(), "tsmb-cfg-"));
const p = join(dir, "config.json");
writeFileSync(p, JSON.stringify(raw));
try {
return loadConfig(p).guestMode;
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
describe("bots normalization", () => {
it("a numeric bots value falls back to the default \"all\" (no crash)", () => {
const gm = loadGuestMode({ guestMode: { bots: 5 } });
expect(gm.bots).toBe("all");
});
it("an array bots value is filtered to strings only", () => {
const gm = loadGuestMode({ guestMode: { bots: ["a", 2, "b"] } });
expect(gm.bots).toEqual(["a", "b"]);
});
it("the literal \"all\" is preserved", () => {
const gm = loadGuestMode({ guestMode: { bots: "all" } });
expect(gm.bots).toBe("all");
});
});
describe("permissions coercion", () => {
it("a non-boolean truthy flag is coerced to false; a real true stays true", () => {
const gm = loadGuestMode({ guestMode: { permissions: { skip: 1, playNext: true } } });
expect(gm.permissions.skip).toBe(false);
expect(gm.permissions.playNext).toBe(true);
});
it("a string permissions value yields defaults with no numeric index keys", () => {
const gm = loadGuestMode({ guestMode: { permissions: "hacked" } });
// 7 known flags present at their defaults
expect(gm.permissions).toEqual({
addToQueue: true, playNext: false, playNow: false,
skip: false, transport: false, removeClear: false, playMode: false,
});
// no garbage index keys leaked from spreading a string
expect((gm.permissions as unknown as Record<string, unknown>)["0"]).toBeUndefined();
});
});
});
+95 -3
View File
@@ -1,5 +1,13 @@
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, rmSync } from "node:fs";
import { dirname } from "node:path";
import type { BotAccess, GuestPermissions } from "./permissions.js";
import { GUEST_PERMISSION_FLAGS } from "./permissions.js";
export interface GuestModeConfig {
enabled: boolean;
bots: BotAccess; // "all" | string[]
permissions: GuestPermissions;
}
export interface BotConfig {
webPort: number;
@@ -22,6 +30,7 @@ export interface BotConfig {
// (nginx/Caddy/Cloudflare). Required for correct protocol/host detection
// behind HTTPS-terminating proxies.
trustProxy: boolean;
guestMode: GuestModeConfig;
}
export function getDefaultConfig(): BotConfig {
@@ -36,10 +45,26 @@ export function getDefaultConfig(): BotConfig {
adminPassword: "",
adminGroups: [],
autoReturnDelay: 300,
autoPauseOnEmpty: true,
// Default OFF: occupancy detection relies on the full-client `clientlist`
// command, which is unreliable on some servers (it can time out when other
// clients are present). Users can opt in from the web UI.
autoPauseOnEmpty: false,
idleTimeoutMinutes: 0,
publicUrl: "",
trustProxy: false,
guestMode: {
enabled: false,
bots: "all",
permissions: {
addToQueue: true,
playNext: false,
playNow: false,
skip: false,
transport: false,
removeClear: false,
playMode: false,
},
},
};
}
@@ -48,7 +73,42 @@ export function loadConfig(path: string): BotConfig {
try {
const raw = readFileSync(path, "utf-8");
const partial = JSON.parse(raw) as Partial<BotConfig>;
return { ...defaults, ...partial };
// Normalize/sanitize guestMode on load. The WRITE path (POST /api/bot/settings)
// sanitizes too, but a hand-edited/legacy/corrupt config.json reaches the gate
// directly — so coerce it here as well, mirroring that write-path logic.
const partialGm = (partial.guestMode ?? {}) as Partial<GuestModeConfig>;
const gm: GuestModeConfig = {
...defaults.guestMode,
...partialGm,
// bots → "all" | string[]; anything else falls back to the default ("all").
bots:
partialGm.bots === "all"
? "all"
: Array.isArray(partialGm.bots)
? partialGm.bots.filter((id): id is string => typeof id === "string")
: defaults.guestMode.bots,
// permissions → defaults, then spread ONLY a plain object, then strict-coerce
// each known flag to a boolean (drops index keys + non-boolean values).
permissions: { ...defaults.guestMode.permissions },
};
const partialPerms = partialGm.permissions;
if (
partialPerms !== null &&
typeof partialPerms === "object" &&
!Array.isArray(partialPerms)
) {
Object.assign(gm.permissions, partialPerms);
}
for (const f of GUEST_PERMISSION_FLAGS) {
gm.permissions[f] = gm.permissions[f] === true;
}
return {
...defaults,
...partial,
guestMode: gm,
};
} catch {
return defaults;
}
@@ -58,3 +118,35 @@ export function saveConfig(path: string, config: BotConfig): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(config, null, 2), "utf-8");
}
/**
* One-time migration for the config location fix (#86).
*
* Older versions wrote config.json to the app/repo ROOT, which is NOT inside the
* persisted data directory (the Docker volume is mounted at data/). That meant the
* file never landed in the volume on first run and a manually-placed data/config.json
* was ignored. config.json now lives under the data dir alongside the DB/cookies/logs.
*
* If a legacy root-level config exists and the new data-dir config does not yet exist,
* move it so existing local installs keep their customized settings. Best-effort:
* any failure is swallowed and loadConfig falls back to defaults.
*
* @returns true if a legacy config was migrated, false otherwise.
*/
export function migrateLegacyConfig(legacyPath: string, newPath: string): boolean {
try {
if (legacyPath === newPath) return false;
if (existsSync(newPath)) return false; // new location already populated — leave it
if (!existsSync(legacyPath)) return false; // nothing to migrate
mkdirSync(dirname(newPath), { recursive: true });
copyFileSync(legacyPath, newPath); // copy first (works across filesystems)
try {
rmSync(legacyPath);
} catch {
/* leave the legacy file if it can't be removed; the new one wins */
}
return true;
} catch {
return false;
}
}
+78
View File
@@ -1,5 +1,9 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createDatabase, type BotDatabase, type BotInstance, type PlayHistoryEntry } from "./database.js";
import { createUserStore, GUEST_USER_ID } from "./users.js";
describe("database", () => {
let botDb: BotDatabase;
@@ -23,6 +27,30 @@ describe("database", () => {
expect(names).toContain("bot_instances");
});
it("creates users and sessions tables on init", () => {
const tables = botDb.db
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all() as Array<{ name: string }>;
const names = tables.map((t) => t.name);
expect(names).toContain("users");
expect(names).toContain("sessions");
const userCols = botDb.db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>;
const userColNames = userCols.map((c) => c.name).sort();
expect(userColNames).toEqual(["createdAt", "id", "passwordHash", "role", "updatedAt", "username"]);
const sessionCols = botDb.db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>;
const sessionColNames = sessionCols.map((c) => c.name).sort();
expect(sessionColNames).toEqual(["createdAt", "expiresAt", "id", "lastSeenAt", "userId"]);
});
it("creates user_audit table on init", () => {
const tables = botDb.db
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all() as Array<{ name: string }>;
expect(tables.map((t) => t.name)).toContain("user_audit");
});
it("records and retrieves play history", () => {
botDb.addPlayHistory({
botId: "bot1",
@@ -58,6 +86,7 @@ describe("database", () => {
serverPort: 9987,
nickname: "MusicBot",
defaultChannel: "Music",
channelId: "",
channelPassword: "",
autoStart: true,
serverProtocol: "",
@@ -87,6 +116,7 @@ describe("database", () => {
serverPort: 9987,
nickname: "MusicBot",
defaultChannel: "Music",
channelId: "",
channelPassword: "",
autoStart: false,
serverProtocol: "",
@@ -98,4 +128,52 @@ describe("database", () => {
expect(botDb.getBotInstances()).toHaveLength(0);
expect(botDb.deleteBotInstance("nonexistent")).toBe(false);
});
it("persists and clears customAvatarPath on a bot instance", () => {
const inst = {
id: "bot-1",
name: "B",
serverAddress: "x",
serverPort: 9987,
nickname: "n",
defaultChannel: "",
channelId: "",
channelPassword: "",
autoStart: false,
serverProtocol: "",
ts6ApiKey: "",
serverPassword: "",
};
botDb.saveBotInstance(inst);
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull();
botDb.setCustomAvatarPath("bot-1", "avatars/bot-1.png");
expect(botDb.getCustomAvatarPath("bot-1")).toBe("avatars/bot-1.png");
botDb.setCustomAvatarPath("bot-1", null);
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull();
});
});
describe("guest principal migration", () => {
it("creates exactly one reserved guest row, idempotently", () => {
const dir = mkdtempSync(join(tmpdir(), "tsmb-db-"));
const p = join(dir, "t.db");
const a = createDatabase(p); a.db.close();
const b = createDatabase(p); // run again — must not duplicate
const row = b.db.prepare("SELECT id, role FROM users WHERE id = ?").get(GUEST_USER_ID) as { id: string; role: string } | undefined;
expect(row?.role).toBe("guest");
const n = (b.db.prepare("SELECT COUNT(*) AS n FROM users WHERE role='guest'").get() as { n: number }).n;
expect(n).toBe(1);
b.db.close();
rmSync(dir, { recursive: true, force: true });
});
it("guest row does not break first-run detection (countUsers excludes it)", () => {
const dir = mkdtempSync(join(tmpdir(), "tsmb-db2-"));
const p = join(dir, "t.db");
const d = createDatabase(p);
const users = createUserStore(d.db);
expect(users.countUsers()).toBe(0); // guest excluded → still needs setup
d.db.close();
rmSync(dir, { recursive: true, force: true });
});
});
+184 -2
View File
@@ -1,4 +1,6 @@
import Database from "better-sqlite3";
import { CAPABILITIES, BOTS_ALL } from "./permissions.js";
import { GUEST_USER_ID, GUEST_USERNAME } from "./users.js";
export interface PlayHistoryEntry {
botId: string;
@@ -22,6 +24,7 @@ export interface BotInstance {
serverPort: number;
nickname: string;
defaultChannel: string;
channelId: string;
channelPassword: string;
autoStart: boolean;
/** "ts3" | "ts6" | "" (empty = auto-detect) */
@@ -51,6 +54,17 @@ export const DEFAULT_PROFILE_CONFIG: ProfileConfig = {
nowPlayingMsgEnabled: true,
};
export interface FavoritePlaylist {
id: number;
userId: string;
platform: string;
playlistId: string;
name: string;
coverUrl: string;
songCount: number;
createdAt: string;
}
export interface BotDatabase {
db: Database.Database;
addPlayHistory(entry: PlayHistoryEntry): void;
@@ -60,6 +74,12 @@ export interface BotDatabase {
deleteBotInstance(id: string): boolean;
getProfileConfig(botId: string): ProfileConfig;
saveProfileConfig(botId: string, config: ProfileConfig): void;
getCustomAvatarPath(botId: string): string | null;
setCustomAvatarPath(botId: string, path: string | null): void;
addFavorite(userId: string, playlist: { platform: string; playlistId: string; name: string; coverUrl: string; songCount: number }): void;
removeFavorite(userId: string, playlistId: string, platform: string): boolean;
getFavorites(userId: string): FavoritePlaylist[];
isFavorited(userId: string, playlistId: string, platform: string): boolean;
close(): void;
}
@@ -78,6 +98,9 @@ function migrateSchema(db: Database.Database): void {
if (!names.includes("serverPassword")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN serverPassword TEXT NOT NULL DEFAULT ''");
}
if (!names.includes("channelId")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN channelId TEXT NOT NULL DEFAULT ''");
}
// Profile feature flags
const profileCols = [
"profile_avatar_enabled",
@@ -92,6 +115,15 @@ function migrateSchema(db: Database.Database): void {
db.exec(`ALTER TABLE bot_instances ADD COLUMN ${col} INTEGER NOT NULL DEFAULT 1`);
}
}
if (!names.includes("custom_avatar_path")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN custom_avatar_path TEXT");
}
const userColumns = db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>;
const userColNames = userColumns.map((c) => c.name);
if (!userColNames.includes("role")) {
db.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'");
}
}
function initTables(db: Database.Database): void {
@@ -115,6 +147,7 @@ function initTables(db: Database.Database): void {
serverPort INTEGER NOT NULL,
nickname TEXT NOT NULL,
defaultChannel TEXT NOT NULL,
channelId TEXT NOT NULL DEFAULT '',
channelPassword TEXT NOT NULL,
autoStart INTEGER NOT NULL DEFAULT 0,
serverProtocol TEXT NOT NULL DEFAULT '',
@@ -122,14 +155,114 @@ function initTables(db: Database.Database): void {
serverPassword TEXT NOT NULL DEFAULT '',
identity TEXT
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
passwordHash TEXT NOT NULL,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
role TEXT NOT NULL DEFAULT 'admin'
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
createdAt INTEGER NOT NULL,
expiresAt INTEGER NOT NULL,
lastSeenAt INTEGER NOT NULL,
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId);
CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt);
CREATE TABLE IF NOT EXISTS user_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
actorId TEXT,
actorUsername TEXT,
targetUserId TEXT,
targetUsername TEXT,
action TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_audit_timestamp ON user_audit(timestamp DESC);
CREATE TABLE IF NOT EXISTS favorite_playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
userId TEXT NOT NULL,
platform TEXT NOT NULL,
playlistId TEXT NOT NULL,
name TEXT NOT NULL,
coverUrl TEXT NOT NULL DEFAULT '',
songCount INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(userId, platform, playlistId)
);
CREATE INDEX IF NOT EXISTS idx_favorites_userId ON favorite_playlists(userId);
CREATE TABLE IF NOT EXISTS user_permissions (
userId TEXT NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (userId, permission),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_bot_access (
userId TEXT NOT NULL,
botId TEXT NOT NULL,
PRIMARY KEY (userId, botId),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId);
`);
}
/**
* One-time backfill: existing `member` users created before the
* account-permissions feature are granted full access (all 5 capabilities +
* the `bots.all` marker), exactly once per database. Admins are skipped (they
* bypass permission checks). New members created after this runs are not
* affected — they get the basic tier via POST /api/users. A marker row in
* `schema_meta` makes this idempotent.
*/
export function backfillMemberPermissions(db: Database.Database): void {
db.exec(`CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT)`);
const done = db.prepare("SELECT value FROM schema_meta WHERE key = 'perm_backfill_done'").get();
if (done) return;
const members = db.prepare("SELECT id FROM users WHERE role = 'member'").all() as { id: string }[];
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
const tokens = [...CAPABILITIES, BOTS_ALL];
const tx = db.transaction(() => {
for (const m of members) {
for (const t of tokens) insCap.run(m.id, t);
}
db.prepare("INSERT INTO schema_meta (key, value) VALUES ('perm_backfill_done', ?)").run(String(members.length));
});
tx();
}
/**
* Ensure the reserved guest principal exists. Idempotent via the PK on
* `users.id`. This row only backs login-less guest sessions; it is excluded
* from countUsers()/listUsers() so it never interferes with first-run setup
* or the user-management UI, and holds an unusable password hash.
*/
export function ensureGuestUser(db: Database.Database): void {
const now = Date.now();
db.prepare(
"INSERT OR IGNORE INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, '!', ?, ?, 'guest')"
).run(GUEST_USER_ID, GUEST_USERNAME, now, now);
}
export function createDatabase(dbPath: string): BotDatabase {
const db = new Database(dbPath);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
initTables(db);
migrateSchema(db);
backfillMemberPermissions(db);
ensureGuestUser(db);
const insertHistory = db.prepare(`
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl)
@@ -141,14 +274,15 @@ export function createDatabase(dbPath: string): BotDatabase {
`);
const upsertInstance = db.prepare(`
INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart, serverProtocol, ts6ApiKey, serverPassword, identity)
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart, @serverProtocol, @ts6ApiKey, @serverPassword, @identity)
INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelId, channelPassword, autoStart, serverProtocol, ts6ApiKey, serverPassword, identity)
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelId, @channelPassword, @autoStart, @serverProtocol, @ts6ApiKey, @serverPassword, @identity)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
serverAddress = excluded.serverAddress,
serverPort = excluded.serverPort,
nickname = excluded.nickname,
defaultChannel = excluded.defaultChannel,
channelId = excluded.channelId,
channelPassword = excluded.channelPassword,
autoStart = excluded.autoStart,
serverProtocol = excluded.serverProtocol,
@@ -179,6 +313,27 @@ export function createDatabase(dbPath: string): BotDatabase {
WHERE id = @id
`);
const selectCustomAvatar = db.prepare(`SELECT custom_avatar_path FROM bot_instances WHERE id = ?`);
const updateCustomAvatar = db.prepare(`UPDATE bot_instances SET custom_avatar_path = ? WHERE id = ?`);
const insertFavorite = db.prepare(`
INSERT INTO favorite_playlists (userId, platform, playlistId, name, coverUrl, songCount)
VALUES (@userId, @platform, @playlistId, @name, @coverUrl, @songCount)
`);
const deleteFavorite = db.prepare(`
DELETE FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
`);
const selectFavorites = db.prepare(`
SELECT id, userId, platform, playlistId, name, coverUrl, songCount, createdAt
FROM favorite_playlists WHERE userId = ? ORDER BY createdAt DESC
`);
const checkFavorited = db.prepare(`
SELECT 1 FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
`);
return {
db,
@@ -208,6 +363,7 @@ export function createDatabase(dbPath: string): BotDatabase {
serverProtocol: r.serverProtocol ?? "",
ts6ApiKey: r.ts6ApiKey ?? "",
serverPassword: r.serverPassword ?? "",
channelId: r.channelId ?? "",
identity: r.identity ?? undefined,
}));
},
@@ -242,6 +398,32 @@ export function createDatabase(dbPath: string): BotDatabase {
});
},
getCustomAvatarPath(botId) {
const row = selectCustomAvatar.get(botId) as { custom_avatar_path: string | null } | undefined;
return row?.custom_avatar_path ?? null;
},
setCustomAvatarPath(botId, path) {
updateCustomAvatar.run(path, botId);
},
addFavorite(userId, playlist) {
insertFavorite.run({ userId, ...playlist });
},
removeFavorite(userId, playlistId, platform) {
const result = deleteFavorite.run(userId, playlistId, platform);
return result.changes > 0;
},
getFavorites(userId) {
return selectFavorites.all(userId) as FavoritePlaylist[];
},
isFavorited(userId, playlistId, platform) {
const row = checkFavorited.get(userId, playlistId, platform);
return row !== undefined;
},
close() {
db.close();
},
+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect, afterEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { createDatabase, backfillMemberPermissions, type BotDatabase } from "./database.js";
import { createPermissionStore, CAPABILITIES } from "./permissions.js";
describe("backfillMemberPermissions", () => {
let dbFile: string;
let db: BotDatabase;
function fresh() {
dbFile = path.join(os.tmpdir(), `mig-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
db = createDatabase(dbFile);
}
afterEach(() => {
db.close();
for (const s of ["", "-wal", "-shm"]) {
try {
fs.rmSync(dbFile + s, { force: true });
} catch {}
}
});
it("grants existing members full access + bots.all, skips admins, once", () => {
fresh();
// simulate a pre-feature DB: clear the marker that createDatabase set, add users, no perm rows
db.db.prepare("DELETE FROM schema_meta WHERE key = 'perm_backfill_done'").run();
const now = Date.now();
const ins = db.db.prepare(
"INSERT INTO users (id,username,passwordHash,createdAt,updatedAt,role) VALUES (?,?,?,?,?,?)"
);
ins.run("m1", "mem", "x", now, now, "member");
ins.run("a1", "adm", "x", now, now, "admin");
backfillMemberPermissions(db.db);
const store = createPermissionStore(db.db);
expect(store.getCapabilities("m1").sort()).toEqual([...CAPABILITIES].sort());
expect(store.getBotAccess("m1")).toBe("all");
expect(store.getCapabilities("a1")).toEqual([]);
expect(store.getBotAccess("a1")).toEqual([]);
});
it("is idempotent — running again does not change or re-grant", () => {
fresh();
db.db.prepare("DELETE FROM schema_meta WHERE key = 'perm_backfill_done'").run();
const now = Date.now();
db.db
.prepare("INSERT INTO users (id,username,passwordHash,createdAt,updatedAt,role) VALUES (?,?,?,?,?,?)")
.run("m1", "mem", "x", now, now, "member");
backfillMemberPermissions(db.db);
// member restricted afterwards
createPermissionStore(db.db).setPermissions("m1", { capabilities: [], bots: [] });
// second run must NOT re-grant (marker present)
backfillMemberPermissions(db.db);
expect(createPermissionStore(db.db).getCapabilities("m1")).toEqual([]);
});
});
+133
View File
@@ -0,0 +1,133 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { createDatabase, type BotDatabase } from "./database.js";
import { createPermissionStore } from "./permissions.js";
import { CAPABILITIES, BASIC_TIER_CAPABILITIES, resolvePermissionContext } from "./permissions.js";
describe("PermissionStore", () => {
let dbFile: string;
let db: BotDatabase;
beforeEach(() => {
dbFile = path.join(os.tmpdir(), `perm-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
db = createDatabase(dbFile);
db.db.prepare(
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)"
).run("u1", "alice", "x", Date.now(), Date.now(), "member");
});
afterEach(() => {
db.close();
try { fs.rmSync(dbFile, { force: true }); } catch {}
try { fs.rmSync(dbFile + "-wal", { force: true }); } catch {}
try { fs.rmSync(dbFile + "-shm", { force: true }); } catch {}
});
it("exposes the five capability tokens and a basic tier", () => {
expect(CAPABILITIES).toEqual([
"player.control", "player.queue", "bot.manage", "platform.auth", "quality",
]);
expect(BASIC_TIER_CAPABILITIES).toEqual(["player.control", "player.queue"]);
});
it("defaults to no capabilities and no bots", () => {
const store = createPermissionStore(db.db);
expect(store.getCapabilities("u1")).toEqual([]);
expect(store.getBotAccess("u1")).toEqual([]);
});
it("round-trips capabilities and a specific bot list", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control", "quality"], bots: ["botA", "botB"] });
expect(store.getCapabilities("u1").sort()).toEqual(["player.control", "quality"]);
expect(store.getBotAccess("u1")).toEqual(["botA", "botB"]);
});
it("stores the all-bots flag as 'all'", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: "all" });
expect(store.getBotAccess("u1")).toBe("all");
});
it("setPermissions replaces prior capabilities and bots", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["botA"] });
store.setPermissions("u1", { capabilities: ["quality"], bots: "all" });
expect(store.getCapabilities("u1")).toEqual(["quality"]);
expect(store.getBotAccess("u1")).toBe("all");
});
it("ignores unknown capability tokens", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control", "bogus" as any], bots: [] });
expect(store.getCapabilities("u1")).toEqual(["player.control"]);
});
it("pruneBot removes a bot from every user's allow-list", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: [], bots: ["botA", "botB"] });
store.pruneBot("botA");
expect(store.getBotAccess("u1")).toEqual(["botB"]);
});
describe("resolvePermissionContext", () => {
it("admin gets all capabilities and all bots regardless of stored rows", () => {
const store = createPermissionStore(db.db);
const ctx = resolvePermissionContext("admin", "u1", store);
expect([...ctx.capabilities].sort()).toEqual([...CAPABILITIES].sort());
expect(ctx.bots).toBe("all");
});
it("member reflects stored capabilities + bot access", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["b1"] });
const ctx = resolvePermissionContext("member", "u1", store);
expect([...ctx.capabilities]).toEqual(["player.control"]);
expect(ctx.bots).toEqual(new Set(["b1"]));
});
});
});
import { GUEST_PERMISSION_FLAGS } from "./permissions.js";
describe("resolvePermissionContext guest branch", () => {
const noStore = {
getCapabilities: () => [],
getBotAccess: () => [] as string[],
setPermissions: () => {},
pruneBot: () => {},
};
it("guest has no member capabilities and exposes the guest permissions + bots", () => {
const ctx = resolvePermissionContext("guest", "__guest__", noStore, {
bots: ["bot1"],
permissions: {
addToQueue: true, playNext: false, playNow: false,
skip: true, transport: false, removeClear: false, playMode: false,
},
});
expect([...ctx.capabilities]).toEqual([]);
expect(ctx.bots).toBeInstanceOf(Set);
expect((ctx.bots as Set<string>).has("bot1")).toBe(true);
expect(ctx.guest?.addToQueue).toBe(true);
expect(ctx.guest?.skip).toBe(true);
});
it("guest with bots:'all' resolves to 'all'", () => {
const ctx = resolvePermissionContext("guest", "__guest__", noStore, {
bots: "all",
permissions: {
addToQueue: true, playNext: false, playNow: false,
skip: false, transport: false, removeClear: false, playMode: false,
},
});
expect(ctx.bots).toBe("all");
});
it("exposes the 7 canonical flags", () => {
expect([...GUEST_PERMISSION_FLAGS].sort()).toEqual(
["addToQueue", "playMode", "playNext", "playNow", "removeClear", "skip", "transport"].sort()
);
});
});
+120
View File
@@ -0,0 +1,120 @@
import type Database from "better-sqlite3";
export const CAPABILITIES = [
"player.control",
"player.queue",
"bot.manage",
"platform.auth",
"quality",
] as const;
export type Capability = (typeof CAPABILITIES)[number];
/** Marker token stored in user_permissions meaning "all bots, incl. future". */
export const BOTS_ALL = "bots.all";
/** Capabilities granted to a newly-created member by default. */
export const BASIC_TIER_CAPABILITIES: Capability[] = ["player.control", "player.queue"];
export function isCapability(x: string): x is Capability {
return (CAPABILITIES as readonly string[]).includes(x);
}
export type BotAccess = "all" | string[];
export interface GuestPermissions {
addToQueue: boolean;
playNext: boolean;
playNow: boolean;
skip: boolean;
transport: boolean;
removeClear: boolean;
playMode: boolean;
}
export const GUEST_PERMISSION_FLAGS = [
"addToQueue",
"playNext",
"playNow",
"skip",
"transport",
"removeClear",
"playMode",
] as const;
export type GuestFlag = (typeof GUEST_PERMISSION_FLAGS)[number];
export interface PermissionStore {
getCapabilities(userId: string): Capability[];
getBotAccess(userId: string): BotAccess;
setPermissions(userId: string, input: { capabilities: string[]; bots: BotAccess }): void;
pruneBot(botId: string): void;
}
export function createPermissionStore(db: Database.Database): PermissionStore {
const selCaps = db.prepare("SELECT permission FROM user_permissions WHERE userId = ?");
const delCaps = db.prepare("DELETE FROM user_permissions WHERE userId = ?");
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
const selBots = db.prepare("SELECT botId FROM user_bot_access WHERE userId = ?");
const delBots = db.prepare("DELETE FROM user_bot_access WHERE userId = ?");
const insBot = db.prepare("INSERT OR IGNORE INTO user_bot_access (userId, botId) VALUES (?, ?)");
const pruneBotStmt = db.prepare("DELETE FROM user_bot_access WHERE botId = ?");
return {
getCapabilities(userId) {
return (selCaps.all(userId) as { permission: string }[])
.map((r) => r.permission)
.filter((p): p is Capability => isCapability(p));
},
getBotAccess(userId) {
const all = (selCaps.all(userId) as { permission: string }[]).some((r) => r.permission === BOTS_ALL);
if (all) return "all";
return (selBots.all(userId) as { botId: string }[]).map((r) => r.botId);
},
setPermissions(userId, input) {
const caps = input.capabilities.filter(isCapability);
const tx = db.transaction(() => {
delCaps.run(userId);
delBots.run(userId);
for (const c of caps) insCap.run(userId, c);
if (input.bots === "all") {
insCap.run(userId, BOTS_ALL);
} else {
for (const b of input.bots) insBot.run(userId, b);
}
});
tx();
},
pruneBot(botId) {
pruneBotStmt.run(botId);
},
};
}
export interface PermissionContext {
capabilities: Set<string>;
bots: "all" | Set<string>;
guest?: GuestPermissions;
}
export function resolvePermissionContext(
role: "admin" | "member" | "guest",
userId: string,
store: PermissionStore,
guest?: { bots: BotAccess; permissions: GuestPermissions }
): PermissionContext {
if (role === "admin") {
return { capabilities: new Set(CAPABILITIES), bots: "all" };
}
if (role === "guest") {
const bots = guest?.bots ?? [];
return {
capabilities: new Set<string>(),
bots: bots === "all" ? "all" : new Set(bots),
guest: guest?.permissions,
};
}
const access = store.getBotAccess(userId);
return {
capabilities: new Set(store.getCapabilities(userId)),
bots: access === "all" ? "all" : new Set(access),
};
}
+199
View File
@@ -0,0 +1,199 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createHash } from "node:crypto";
import { createDatabase, type BotDatabase } from "./database.js";
import { createUserStore, type UserStore } from "./users.js";
import { createSessionStore, type SessionStore, SESSION_TTL_MS, SESSION_TOUCH_INTERVAL_MS, MAX_SESSIONS_PER_USER, GUEST_SESSION_TTL_MS } from "./sessions.js";
function sha256(token: string) {
return createHash("sha256").update(token).digest("hex");
}
describe("SessionStore", () => {
let botDb: BotDatabase;
let users: UserStore;
let sessions: SessionStore;
let userId: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
users = createUserStore(botDb.db);
sessions = createSessionStore(botDb.db);
const u = await users.createUser("alice", "pw-alice", "admin");
userId = u.id;
});
afterEach(() => {
vi.useRealTimers();
botDb.close();
});
it("createSession returns a raw token whose sha256 matches the DB row id", () => {
const { token } = sessions.createSession(userId);
const row = botDb.db.prepare("SELECT id FROM sessions").get() as { id: string };
expect(row.id).toBe(sha256(token));
expect(row.id).not.toBe(token);
});
it("validateAndTouch returns the user for a fresh token", () => {
const { token } = sessions.createSession(userId);
const result = sessions.validateAndTouch(token);
expect(result).not.toBeNull();
expect(result!.userId).toBe(userId);
expect(result!.username).toBe("alice");
expect(result!.role).toBe("admin");
});
it("validateAndTouch returns null and deletes the row for an expired session", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const { token } = sessions.createSession(userId);
vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + SESSION_TTL_MS + 1000);
expect(sessions.validateAndTouch(token)).toBeNull();
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
expect(remaining).toBe(0);
});
it("validateAndTouch does not write the DB if called again within the touch interval", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const { token } = sessions.createSession(userId);
const before = botDb.db.prepare("SELECT lastSeenAt FROM sessions").get() as { lastSeenAt: number };
vi.advanceTimersByTime(SESSION_TOUCH_INTERVAL_MS - 1000);
sessions.validateAndTouch(token);
const after = botDb.db.prepare("SELECT lastSeenAt FROM sessions").get() as { lastSeenAt: number };
expect(after.lastSeenAt).toBe(before.lastSeenAt);
});
it("validateAndTouch writes lastSeenAt and extends expiresAt past the touch interval", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const { token, expiresAt: initialExpiry } = sessions.createSession(userId);
vi.advanceTimersByTime(SESSION_TOUCH_INTERVAL_MS + 1000);
sessions.validateAndTouch(token);
const row = botDb.db.prepare("SELECT lastSeenAt, expiresAt FROM sessions").get() as { lastSeenAt: number; expiresAt: number };
expect(row.lastSeenAt).toBe(Date.now());
expect(row.expiresAt).toBeGreaterThan(initialExpiry);
});
it("deleteSession removes the row", () => {
const { token } = sessions.createSession(userId);
sessions.deleteSession(token);
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
expect(remaining).toBe(0);
expect(sessions.validateAndTouch(token)).toBeNull();
});
it("deleteAllForUser keeps the exceptToken session", () => {
const a = sessions.createSession(userId);
const b = sessions.createSession(userId);
sessions.deleteAllForUser(userId, a.token);
expect(sessions.validateAndTouch(a.token)).not.toBeNull();
expect(sessions.validateAndTouch(b.token)).toBeNull();
});
it("cleanupExpired removes only expired rows", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
sessions.createSession(userId); // expires later
vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + SESSION_TTL_MS + 1000);
sessions.createSession(userId); // fresh
sessions.cleanupExpired();
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
expect(remaining).toBe(1);
});
it("createSession caps concurrent sessions per user at MAX_SESSIONS_PER_USER, evicting oldest", async () => {
// Create MAX + 2 sessions for the same user.
const tokens: string[] = [];
for (let i = 0; i < MAX_SESSIONS_PER_USER + 2; i++) {
tokens.push(sessions.createSession(userId).token);
await new Promise((r) => setTimeout(r, 2)); // stagger createdAt
}
const count = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
expect(count).toBe(MAX_SESSIONS_PER_USER);
// The first two should have been evicted, the last MAX remain
expect(sessions.validateAndTouch(tokens[0])).toBeNull();
expect(sessions.validateAndTouch(tokens[1])).toBeNull();
expect(sessions.validateAndTouch(tokens[tokens.length - 1])).not.toBeNull();
});
it("createSession respects cap under concurrent calls (no 1-over-cap race)", async () => {
// better-sqlite3 transactions are serialised at the engine level. Calling
// createSession N times sequentially via Promise.all proves atomic check+insert.
const N = MAX_SESSIONS_PER_USER + 3;
await Promise.all(Array.from({ length: N }, () => Promise.resolve(sessions.createSession(userId))));
const count = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
expect(count).toBe(MAX_SESSIONS_PER_USER);
});
});
describe("guest sessions", () => {
let botDb: BotDatabase;
let sessions: SessionStore;
beforeEach(() => {
botDb = createDatabase(":memory:");
sessions = createSessionStore(botDb.db);
// Create the synthetic guest user row to satisfy the sessions FK.
botDb.db
.prepare("INSERT OR IGNORE INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES ('__guest__','游客','!',?,?, 'guest')")
.run(Date.now(), Date.now());
});
afterEach(() => {
vi.useRealTimers();
botDb.close();
});
it("skipCap lets more than MAX_SESSIONS_PER_USER coexist for one principal", () => {
const tokens: string[] = [];
for (let i = 0; i < MAX_SESSIONS_PER_USER + 3; i++) {
tokens.push(sessions.createSession("__guest__", { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true }).token);
}
// The first token must STILL validate (not evicted).
expect(sessions.validateAndTouch(tokens[0])?.role).toBe("guest");
const n = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE userId='__guest__'").get() as { n: number }).n;
expect(n).toBe(MAX_SESSIONS_PER_USER + 3);
});
it("ttlMs sets a shorter expiry than the default", () => {
const { expiresAt } = sessions.createSession("__guest__", { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true });
expect(expiresAt).toBeLessThanOrEqual(Date.now() + GUEST_SESSION_TTL_MS + 50);
});
it("validateAndTouch refreshes a guest session to GUEST_SESSION_TTL_MS (1d), not SESSION_TTL_MS (7d)", () => {
const { token } = sessions.createSession("__guest__", { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true });
// Force the touch branch: backdate lastSeenAt past the touch interval.
botDb.db
.prepare("UPDATE sessions SET lastSeenAt = ? WHERE userId = '__guest__'")
.run(Date.now() - (SESSION_TOUCH_INTERVAL_MS + 1000));
const result = sessions.validateAndTouch(token);
expect(result?.role).toBe("guest");
const row = botDb.db
.prepare("SELECT expiresAt FROM sessions WHERE userId = '__guest__'")
.get() as { expiresAt: number };
// Should refresh to ~now + 1 day, NOT now + 7 days.
expect(row.expiresAt).toBeGreaterThan(Date.now() + GUEST_SESSION_TTL_MS - 5000);
expect(row.expiresAt).toBeLessThanOrEqual(Date.now() + GUEST_SESSION_TTL_MS + 5000);
// Sanity: well below the 7d window.
expect(row.expiresAt).toBeLessThan(Date.now() + SESSION_TTL_MS);
});
it("validateAndTouch still refreshes a non-guest (admin) session to SESSION_TTL_MS (7d) on touch", () => {
botDb.db
.prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES ('admin1','adminuser','!',?,?, 'admin')")
.run(Date.now(), Date.now());
const { token } = sessions.createSession("admin1");
botDb.db
.prepare("UPDATE sessions SET lastSeenAt = ? WHERE userId = 'admin1'")
.run(Date.now() - (SESSION_TOUCH_INTERVAL_MS + 1000));
const result = sessions.validateAndTouch(token);
expect(result?.role).toBe("admin");
const row = botDb.db
.prepare("SELECT expiresAt FROM sessions WHERE userId = 'admin1'")
.get() as { expiresAt: number };
// Refreshes to ~now + 7 days, NOT the 1d guest window.
expect(row.expiresAt).toBeGreaterThan(Date.now() + SESSION_TTL_MS - 5000);
expect(row.expiresAt).toBeLessThanOrEqual(Date.now() + SESSION_TTL_MS + 5000);
});
});
+110
View File
@@ -0,0 +1,110 @@
import { createHash, randomBytes } from "node:crypto";
import type Database from "better-sqlite3";
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
export const GUEST_SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 1 day — guests are short-lived
export const SESSION_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
export const MAX_SESSIONS_PER_USER = 10;
export interface SessionValidation {
userId: string;
username: string;
role: "admin" | "member" | "guest";
}
export interface SessionStore {
createSession(userId: string, opts?: { ttlMs?: number; skipCap?: boolean }): { token: string; expiresAt: number };
validateAndTouch(rawToken: string): SessionValidation | null;
deleteSession(rawToken: string): void;
deleteAllForUser(userId: string, exceptToken?: string): void;
cleanupExpired(): void;
}
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function createSessionStore(db: Database.Database): SessionStore {
const insertStmt = db.prepare(
"INSERT INTO sessions (id, userId, createdAt, expiresAt, lastSeenAt) VALUES (?, ?, ?, ?, ?)"
);
const selectStmt = db.prepare(`
SELECT s.id, s.userId, s.expiresAt, s.lastSeenAt, u.username, u.role
FROM sessions s INNER JOIN users u ON u.id = s.userId
WHERE s.id = ?
`);
const deleteByIdStmt = db.prepare("DELETE FROM sessions WHERE id = ?");
const touchStmt = db.prepare(
"UPDATE sessions SET lastSeenAt = ?, expiresAt = ? WHERE id = ?"
);
const deleteAllForUserStmt = db.prepare("DELETE FROM sessions WHERE userId = ?");
const deleteAllForUserExceptStmt = db.prepare(
"DELETE FROM sessions WHERE userId = ? AND id != ?"
);
const cleanupStmt = db.prepare("DELETE FROM sessions WHERE expiresAt < ?");
const countForUserStmt = db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE userId = ?");
const deleteOldestForUserStmt = db.prepare(
"DELETE FROM sessions WHERE id IN (SELECT id FROM sessions WHERE userId = ? ORDER BY createdAt ASC LIMIT ?)"
);
return {
createSession(userId, opts) {
// Cap concurrent sessions per user — oldest gets evicted on overflow.
// Wrap the count → delete → insert in a transaction so concurrent logins
// for the same user can't both pass the cap check and both insert,
// ending up 1 over cap (race window between count and insert).
const token = randomBytes(32).toString("base64url");
const id = hashToken(token);
const now = Date.now();
const expiresAt = now + (opts?.ttlMs ?? SESSION_TTL_MS);
const tx = db.transaction(() => {
if (!opts?.skipCap) {
const existing = (countForUserStmt.get(userId) as { n: number }).n;
if (existing >= MAX_SESSIONS_PER_USER) {
deleteOldestForUserStmt.run(userId, existing - MAX_SESSIONS_PER_USER + 1);
}
}
insertStmt.run(id, userId, now, expiresAt, now);
});
tx();
return { token, expiresAt };
},
validateAndTouch(rawToken) {
if (!rawToken) return null;
const id = hashToken(rawToken);
const row = selectStmt.get(id) as
| { id: string; userId: string; expiresAt: number; lastSeenAt: number; username: string; role: string }
| undefined;
if (!row) return null;
const now = Date.now();
if (row.expiresAt < now) {
deleteByIdStmt.run(id);
return null;
}
if (now - row.lastSeenAt > SESSION_TOUCH_INTERVAL_MS) {
// Refresh against the role's own TTL — guests are short-lived (1d) and
// must NOT be bumped to the member/admin 7d window on touch.
const ttl = row.role === "guest" ? GUEST_SESSION_TTL_MS : SESSION_TTL_MS;
touchStmt.run(now, now + ttl, id);
}
return { userId: row.userId, username: row.username, role: row.role as "admin" | "member" | "guest" };
},
deleteSession(rawToken) {
deleteByIdStmt.run(hashToken(rawToken));
},
deleteAllForUser(userId, exceptToken) {
if (exceptToken) {
deleteAllForUserExceptStmt.run(userId, hashToken(exceptToken));
} else {
deleteAllForUserStmt.run(userId);
}
},
cleanupExpired() {
cleanupStmt.run(Date.now());
},
};
}
+226
View File
@@ -0,0 +1,226 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createDatabase, type BotDatabase } from "./database.js";
import { createUserStore, UsernameTakenError, GUEST_USER_ID, GUEST_USERNAME, type UserStore } from "./users.js";
describe("UserStore", () => {
let botDb: BotDatabase;
let users: UserStore;
beforeEach(() => {
botDb = createDatabase(":memory:");
users = createUserStore(botDb.db);
});
afterEach(() => {
botDb.close();
});
it("countUsers is 0 on a fresh db", () => {
expect(users.countUsers()).toBe(0);
});
it("createUser stores the user and bumps countUsers", async () => {
const u = await users.createUser("alice", "pw-hunter2", "member");
expect(u.id).toMatch(/^[0-9a-f-]{36}$/);
expect(u.username).toBe("alice");
expect(users.countUsers()).toBe(1);
});
it("findByUsername is case-insensitive and returns null for missing", async () => {
await users.createUser("Alice", "pw-alice", "member");
expect(users.findByUsername("ALICE")).not.toBeNull();
expect(users.findByUsername("alice")).not.toBeNull();
expect(users.findByUsername("bob")).toBeNull();
});
it("createUser rejects duplicate usernames (case-insensitive)", async () => {
await users.createUser("Alice", "pw-alice", "member");
await expect(users.createUser("alice", "pw-alice-2", "member")).rejects.toBeInstanceOf(UsernameTakenError);
});
it("verifyPassword accepts correct password and rejects wrong one", async () => {
await users.createUser("alice", "correct-horse-battery-staple", "member");
const row = users.findByUsername("alice");
expect(row).not.toBeNull();
expect(await users.verifyPassword("correct-horse-battery-staple", row!.passwordHash)).toBe(true);
expect(await users.verifyPassword("wrong", row!.passwordHash)).toBe(false);
});
it("changePassword updates the hash so the old password no longer verifies", async () => {
const u = await users.createUser("alice", "old-pw-pw", "member");
await users.changePassword(u.id, "new-pw-pw");
const row = users.findByUsername("alice");
expect(await users.verifyPassword("old-pw-pw", row!.passwordHash)).toBe(false);
expect(await users.verifyPassword("new-pw-pw", row!.passwordHash)).toBe(true);
});
it("listUsers returns id+username+createdAt ascending, no password hash", async () => {
await users.createUser("alice", "pw-alice", "member");
await users.createUser("bob", "pw-bob-bob", "member");
const list = users.listUsers();
expect(list).toHaveLength(2);
expect(list[0].username).toBe("alice");
expect(list[1].username).toBe("bob");
expect(list[0]).not.toHaveProperty("passwordHash");
expect(list[0].id).toMatch(/^[0-9a-f-]{36}$/);
expect(typeof list[0].createdAt).toBe("number");
});
it("deleteUser removes the row and returns true; returns false for unknown id", async () => {
const u = await users.createUser("alice", "pw-alice", "member");
expect(users.deleteUser(u.id)).toBe(true);
expect(users.countUsers()).toBe(0);
expect(users.deleteUser("not-a-real-id")).toBe(false);
});
it("createFirstUser succeeds on empty db, returns null when a user already exists", async () => {
const a = await users.createFirstUser("alice", "pw-alice");
expect(a).not.toBeNull();
expect(a!.username).toBe("alice");
const b = await users.createFirstUser("bob", "pw-bob-bob");
expect(b).toBeNull();
expect(users.countUsers()).toBe(1);
});
it("createFirstUser is race-safe: concurrent calls produce exactly one user", async () => {
const [a, b, c] = await Promise.all([
users.createFirstUser("alice", "pw-alice"),
users.createFirstUser("bob", "pw-bob-bob"),
users.createFirstUser("charlie", "pw-charlie-pw"),
]);
const created = [a, b, c].filter((u) => u !== null);
expect(created).toHaveLength(1);
expect(users.countUsers()).toBe(1);
});
it("createFirstUser always creates an admin", async () => {
const u = await users.createFirstUser("alice", "pw-alice");
expect(u).not.toBeNull();
expect(u!.role).toBe("admin");
});
it("countAdmins reflects only role=admin", async () => {
await users.createUser("alice", "pw-alice", "admin");
await users.createUser("bob", "pw-bob-bob", "member");
expect(users.countUsers()).toBe(2);
expect(users.countAdmins()).toBe(1);
});
it("setRole changes the role and returns true; false for unknown id", async () => {
const u = await users.createUser("alice", "pw-alice", "member");
expect(users.setRole(u.id, "admin")).toBe(true);
expect(users.findById(u.id)!.role).toBe("admin");
expect(users.setRole("nope", "admin")).toBe(false);
});
it("listUsers includes role", async () => {
await users.createUser("alice", "pw-alice", "admin");
await users.createUser("bob", "pw-bob-bob", "member");
const list = users.listUsers();
const alice = list.find((u) => u.username === "alice")!;
const bob = list.find((u) => u.username === "bob")!;
expect(alice.role).toBe("admin");
expect(bob.role).toBe("member");
});
it("setRoleIfNotLastAdmin returns 'would_orphan' for the only admin being demoted", async () => {
const alice = await users.createUser("alice", "pw-alice", "admin");
expect(users.setRoleIfNotLastAdmin(alice.id, "member")).toBe("would_orphan");
expect(users.findById(alice.id)!.role).toBe("admin"); // unchanged
});
it("setRoleIfNotLastAdmin allows demotion when another admin exists", async () => {
const alice = await users.createUser("alice", "pw-alice", "admin");
await users.createUser("bob", "pw-bob-bob", "admin");
expect(users.setRoleIfNotLastAdmin(alice.id, "member")).toBe("ok");
expect(users.findById(alice.id)!.role).toBe("member");
});
it("setRoleIfNotLastAdmin returns 'not_found' for unknown id", () => {
expect(users.setRoleIfNotLastAdmin("not-a-real-id", "member")).toBe("not_found");
});
it("setRoleIfNotLastAdmin: concurrent demotions of two admins keep one admin", async () => {
const alice = await users.createUser("alice", "pw-alice", "admin");
const bob = await users.createUser("bob", "pw-bob-bob", "admin");
// Concurrent demotion of both
const [r1, r2] = await Promise.all([
Promise.resolve(users.setRoleIfNotLastAdmin(alice.id, "member")),
Promise.resolve(users.setRoleIfNotLastAdmin(bob.id, "member")),
]);
// Exactly one should succeed; the other gets "would_orphan"
const oks = [r1, r2].filter((r) => r === "ok").length;
const orphans = [r1, r2].filter((r) => r === "would_orphan").length;
expect(oks).toBe(1);
expect(orphans).toBe(1);
// System retains at least one admin
expect(users.countAdmins()).toBe(1);
});
it("deleteUserIfNotLastAdmin returns 'would_orphan' for the only admin", async () => {
const alice = await users.createUser("alice", "pw-alice", "admin");
expect(users.deleteUserIfNotLastAdmin(alice.id)).toBe("would_orphan");
expect(users.findById(alice.id)).not.toBeNull();
});
it("deleteUserIfNotLastAdmin allows deleting a member at any count", async () => {
await users.createUser("alice", "pw-alice", "admin");
const bob = await users.createUser("bob", "pw-bob-bob", "member");
expect(users.deleteUserIfNotLastAdmin(bob.id)).toBe("ok");
expect(users.findById(bob.id)).toBeNull();
});
it("deleteUserIfNotLastAdmin: concurrent deletes of two admins keep one admin", async () => {
const alice = await users.createUser("alice", "pw-alice", "admin");
const bob = await users.createUser("bob", "pw-bob-bob", "admin");
const [r1, r2] = await Promise.all([
Promise.resolve(users.deleteUserIfNotLastAdmin(alice.id)),
Promise.resolve(users.deleteUserIfNotLastAdmin(bob.id)),
]);
const oks = [r1, r2].filter((r) => r === "ok").length;
const orphans = [r1, r2].filter((r) => r === "would_orphan").length;
expect(oks).toBe(1);
expect(orphans).toBe(1);
expect(users.countAdmins()).toBe(1);
});
});
describe("guest row exclusion", () => {
let botDb: BotDatabase;
let users: UserStore;
beforeEach(() => {
botDb = createDatabase(":memory:");
users = createUserStore(botDb.db);
});
afterEach(() => {
botDb.close();
});
it("countUsers and listUsers ignore the reserved guest row", async () => {
await users.createUser("alice", "password123", "member");
// Insert the reserved guest row directly (mirrors the migration).
botDb.db.prepare(
"INSERT OR IGNORE INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, ?, ?, ?, 'guest')"
).run(GUEST_USER_ID, GUEST_USERNAME, "!", Date.now(), Date.now());
expect(users.countUsers()).toBe(1); // alice only
expect(users.listUsers().some((u) => u.id === GUEST_USER_ID)).toBe(false);
});
it("setRoleIfNotLastAdmin refuses to re-role the reserved guest principal", () => {
// The guest row is seeded by createDatabase via ensureGuestUser.
expect(users.findById(GUEST_USER_ID)!.role).toBe("guest"); // sanity
expect(users.setRoleIfNotLastAdmin(GUEST_USER_ID, "admin")).toBe("not_found");
// The guest row's role is unchanged.
expect(users.findById(GUEST_USER_ID)!.role).toBe("guest");
});
it("deleteUserIfNotLastAdmin refuses to delete the reserved guest principal", () => {
expect(users.findById(GUEST_USER_ID)).not.toBeNull(); // sanity
expect(users.deleteUserIfNotLastAdmin(GUEST_USER_ID)).toBe("not_found");
// The guest row still exists.
expect(users.findById(GUEST_USER_ID)).not.toBeNull();
});
});
+176
View File
@@ -0,0 +1,176 @@
import { randomUUID } from "node:crypto";
import type Database from "better-sqlite3";
import bcrypt from "bcryptjs";
const BCRYPT_ROUNDS = 12;
export type UserRole = "admin" | "member" | "guest";
/** Reserved synthetic principal for login-less guest sessions. The username is
* non-ASCII so it can never collide with an API-created account (which is
* validated against ^[A-Za-z0-9_\-.]{3,32}$). */
export const GUEST_USER_ID = "__guest__";
export const GUEST_USERNAME = "游客";
export interface UserRow {
id: string;
username: string;
passwordHash: string;
createdAt: number;
updatedAt: number;
role: UserRole;
}
export interface UserStore {
countUsers(): number;
countAdmins(): number;
createUser(username: string, password: string, role: UserRole): Promise<UserRow>;
createFirstUser(username: string, password: string): Promise<UserRow | null>;
findByUsername(username: string): UserRow | null;
findById(id: string): UserRow | null;
verifyPassword(plain: string, hash: string): Promise<boolean>;
changePassword(userId: string, newPassword: string): Promise<void>;
setRole(userId: string, role: UserRole): boolean;
setRoleIfNotLastAdmin(id: string, newRole: UserRole): "ok" | "not_found" | "would_orphan";
deleteUser(id: string): boolean;
deleteUserIfNotLastAdmin(id: string): "ok" | "not_found" | "would_orphan";
listUsers(): Array<{ id: string; username: string; createdAt: number; role: UserRole }>;
}
export class UsernameTakenError extends Error {
constructor(username: string) {
super(`username taken: ${username}`);
this.name = "UsernameTakenError";
}
}
export function createUserStore(db: Database.Database): UserStore {
const countStmt = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role != 'guest'");
const countAdminsStmt = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'");
const insertStmt = db.prepare(
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, ?, ?, ?, ?)"
);
const findByUsernameStmt = db.prepare(
"SELECT id, username, passwordHash, createdAt, updatedAt, role FROM users WHERE username = ? COLLATE NOCASE"
);
const findByIdStmt = db.prepare(
"SELECT id, username, passwordHash, createdAt, updatedAt, role FROM users WHERE id = ?"
);
const updatePasswordStmt = db.prepare(
"UPDATE users SET passwordHash = ?, updatedAt = ? WHERE id = ?"
);
const updateRoleStmt = db.prepare(
"UPDATE users SET role = ?, updatedAt = ? WHERE id = ?"
);
const listUsersStmt = db.prepare(
"SELECT id, username, createdAt, role FROM users WHERE role != 'guest' ORDER BY createdAt ASC"
);
const deleteUserStmt = db.prepare("DELETE FROM users WHERE id = ?");
return {
countUsers() {
return (countStmt.get() as { n: number }).n;
},
countAdmins() {
return (countAdminsStmt.get() as { n: number }).n;
},
async createUser(username, password, role) {
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
const id = randomUUID();
const now = Date.now();
try {
insertStmt.run(id, username, hash, now, now, role);
} catch (err) {
if (err && typeof err === "object" && (err as { code?: string }).code === "SQLITE_CONSTRAINT_UNIQUE") {
throw new UsernameTakenError(username);
}
throw err;
}
return { id, username, passwordHash: hash, createdAt: now, updatedAt: now, role };
},
async createFirstUser(username, password) {
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
const id = randomUUID();
const now = Date.now();
const run = db.transaction(() => {
const count = (countStmt.get() as { n: number }).n;
if (count !== 0) return null;
try {
insertStmt.run(id, username, hash, now, now, "admin");
} catch (err) {
if (err && typeof err === "object" && (err as { code?: string }).code === "SQLITE_CONSTRAINT_UNIQUE") {
return null;
}
throw err;
}
return { id, username, passwordHash: hash, createdAt: now, updatedAt: now, role: "admin" } as UserRow;
});
return run();
},
findByUsername(username) {
return (findByUsernameStmt.get(username) as UserRow | undefined) ?? null;
},
findById(id) {
return (findByIdStmt.get(id) as UserRow | undefined) ?? null;
},
verifyPassword(plain, hash) {
return bcrypt.compare(plain, hash);
},
async changePassword(userId, newPassword) {
const hash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
updatePasswordStmt.run(hash, Date.now(), userId);
},
setRole(userId, role) {
const result = updateRoleStmt.run(role, Date.now(), userId);
return result.changes > 0;
},
setRoleIfNotLastAdmin(id, newRole) {
const tx = db.transaction(() => {
const row = findByIdStmt.get(id) as UserRow | undefined;
if (!row) return "not_found" as const;
if (row.role === "guest") return "not_found" as const; // reserved synthetic principal
if (row.role === newRole) return "ok" as const; // no-op
if (row.role === "admin" && newRole === "member") {
const adminCount = (countAdminsStmt.get() as { n: number }).n;
if (adminCount <= 1) return "would_orphan" as const;
}
updateRoleStmt.run(newRole, Date.now(), id);
return "ok" as const;
});
return tx();
},
listUsers() {
return listUsersStmt.all() as Array<{ id: string; username: string; createdAt: number; role: UserRole }>;
},
deleteUser(id) {
const result = deleteUserStmt.run(id);
return result.changes > 0;
},
deleteUserIfNotLastAdmin(id) {
const tx = db.transaction(() => {
const row = findByIdStmt.get(id) as UserRow | undefined;
if (!row) return "not_found" as const;
if (row.role === "guest") return "not_found" as const; // reserved synthetic principal
if (row.role === "admin") {
const adminCount = (countAdminsStmt.get() as { n: number }).n;
if (adminCount <= 1) return "would_orphan" as const;
}
deleteUserStmt.run(id);
return "ok" as const;
});
return tx();
},
};
}
+20 -3
View File
@@ -1,6 +1,6 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadConfig, saveConfig } from "./data/config.js";
import { loadConfig, saveConfig, migrateLegacyConfig } from "./data/config.js";
import { createDatabase } from "./data/database.js";
import { createLogger } from "./logger.js";
import { createApiServerManager } from "./music/api-server.js";
@@ -8,19 +8,29 @@ import { NeteaseProvider } from "./music/netease.js";
import { QQMusicProvider } from "./music/qq.js";
import { BiliBiliProvider } from "./music/bilibili.js";
import { createCookieStore } from "./music/auth.js";
import { createAvatarStore } from "./data/avatars.js";
import { createPermissionStore } from "./data/permissions.js";
import { BotManager } from "./bot/manager.js";
import { createWebServer } from "./web/server.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(__dirname, "..");
const DATA_DIR = path.join(ROOT_DIR, "data");
const CONFIG_PATH = path.join(ROOT_DIR, "config.json");
// config.json lives under the persisted data dir (the Docker volume) alongside the
// DB/cookies/logs, so it survives container restarts and manual edits take effect
// (#86). LEGACY_CONFIG_PATH is the old root-level location we migrate from once.
const CONFIG_PATH = path.join(DATA_DIR, "config.json");
const LEGACY_CONFIG_PATH = path.join(ROOT_DIR, "config.json");
const DB_PATH = path.join(DATA_DIR, "tsmusicbot.db");
const LOG_DIR = path.join(DATA_DIR, "logs");
const COOKIE_DIR = path.join(DATA_DIR, "cookies");
const AVATAR_DIR = path.join(DATA_DIR, "avatars");
const STATIC_DIR = path.join(ROOT_DIR, "web", "dist");
async function main() {
// Migrate a pre-#86 root-level config.json into the data dir so existing
// installs keep their settings; no-op if already migrated or none exists.
migrateLegacyConfig(LEGACY_CONFIG_PATH, CONFIG_PATH);
const config = loadConfig(CONFIG_PATH);
saveConfig(CONFIG_PATH, config);
@@ -46,6 +56,7 @@ async function main() {
const bilibiliProvider = new BiliBiliProvider();
const cookieStore = createCookieStore(COOKIE_DIR);
const avatarStore = createAvatarStore(AVATAR_DIR);
const neteaseCookie = cookieStore.load("netease");
if (neteaseCookie) neteaseProvider.setCookie(neteaseCookie);
const qqCookie = cookieStore.load("qq");
@@ -53,13 +64,18 @@ async function main() {
const bilibiliCookie = cookieStore.load("bilibili");
if (bilibiliCookie) bilibiliProvider.setCookie(bilibiliCookie);
const permissions = createPermissionStore(db.db);
const botManager = new BotManager(
neteaseProvider,
qqProvider,
bilibiliProvider,
db,
config,
logger
logger,
avatarStore,
permissions,
CONFIG_PATH
);
await botManager.loadSavedBots();
@@ -70,6 +86,7 @@ async function main() {
qqProvider,
bilibiliProvider,
database: db,
avatarStore,
config,
configPath: CONFIG_PATH,
logger,
+65 -6
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import axios, { type AxiosInstance } from "axios";
import type {
MusicProvider,
@@ -15,6 +16,16 @@ const BILIBILI_HEADERS = {
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
};
// Permutation used by B站 to derive the wbi mixin key from img_key+sub_key.
const WBI_MIXIN_KEY_ENC_TAB = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61,
26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36,
20, 34, 44, 52,
];
const WBI_KEY_TTL_MS = 6 * 60 * 60 * 1000; // wbi keys rotate ~daily; refresh every 6h
export class BiliBiliProvider implements MusicProvider {
readonly platform = "bilibili" as const;
private api: AxiosInstance;
@@ -24,6 +35,8 @@ export class BiliBiliProvider implements MusicProvider {
private cidCache = new Map<string, number>();
private buvidCookie = ""; // anonymous session cookie (buvid3) for anti-412
private buvidInitialized = false;
private wbiMixinKey = "";
private wbiKeyFetchedAt = 0;
constructor() {
this.api = axios.create({
@@ -62,6 +75,50 @@ export class BiliBiliProvider implements MusicProvider {
return combined ? { Cookie: combined } : {};
}
/**
* Fetch wbi img_key/sub_key from /x/web-interface/nav and derive the
* mixin key used to sign search params. Required since B站 moved the
* search endpoint behind wbi signing — unsigned /search/type now
* returns an anti-bot HTML page.
*/
private async ensureWbiKeys(): Promise<void> {
if (this.wbiMixinKey && Date.now() - this.wbiKeyFetchedAt < WBI_KEY_TTL_MS) {
return;
}
const res = await this.api.get("/x/web-interface/nav", {
headers: this.cookieHeaders,
validateStatus: () => true, // nav returns -101 when not logged in but still includes wbi_img
});
const wbi = res.data?.data?.wbi_img;
const imgUrl: string = wbi?.img_url ?? "";
const subUrl: string = wbi?.sub_url ?? "";
const imgKey = imgUrl.split("/").pop()?.split(".")[0] ?? "";
const subKey = subUrl.split("/").pop()?.split(".")[0] ?? "";
if (!imgKey || !subKey) {
throw new Error("Bilibili wbi keys unavailable");
}
const raw = imgKey + subKey;
this.wbiMixinKey = WBI_MIXIN_KEY_ENC_TAB.map((i) => raw[i] ?? "")
.join("")
.slice(0, 32);
this.wbiKeyFetchedAt = Date.now();
}
/** Sign params for wbi-protected endpoints. Returns a new params object including wts and w_rid. */
private signWbi(params: Record<string, string | number>): Record<string, string> {
const withTs: Record<string, string> = {};
for (const [k, v] of Object.entries(params)) withTs[k] = String(v);
withTs.wts = String(Math.floor(Date.now() / 1000));
const sorted = Object.keys(withTs)
.sort()
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(withTs[k])}`)
.join("&");
withTs.w_rid = createHash("md5")
.update(sorted + this.wbiMixinKey)
.digest("hex");
return withTs;
}
setQuality(quality: string): void {
this.quality = quality;
}
@@ -91,12 +148,14 @@ export class BiliBiliProvider implements MusicProvider {
async search(query: string, limit = 20): Promise<SearchResult> {
await this.ensureBuvidCookie();
const res = await this.api.get("/x/web-interface/search/type", {
params: {
search_type: "video",
keyword: query,
page_size: limit,
},
await this.ensureWbiKeys();
const signed = this.signWbi({
search_type: "video",
keyword: query,
page_size: limit,
});
const res = await this.api.get("/x/web-interface/wbi/search/type", {
params: signed,
headers: this.cookieHeaders,
});
+29 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseLyrics } from "./netease.js";
import { parseLyrics, mapNeteaseAlbums } from "./netease.js";
describe("NetEase adapter", () => {
it("parses LRC format lyrics", () => {
@@ -28,4 +28,32 @@ describe("NetEase adapter", () => {
expect(lines[0].text).toBe("Hello world");
expect(lines[0].translation).toBe("你好世界");
});
it("mapNeteaseAlbums maps raw cloudsearch albums to Album shape", () => {
const raw = [
{
id: 42,
name: "Album A",
picUrl: "https://x/p.jpg",
artists: [{ name: "Artist X" }, { name: "Featured Y" }],
size: 12,
},
{
id: 99,
name: "Album B",
picUrl: "",
artists: [],
},
];
expect(mapNeteaseAlbums(raw)).toEqual([
{ id: "42", name: "Album A", artist: "Artist X / Featured Y", coverUrl: "https://x/p.jpg", songCount: 12, platform: "netease" },
{ id: "99", name: "Album B", artist: "", coverUrl: "", songCount: 0, platform: "netease" },
]);
});
it("mapNeteaseAlbums returns [] for empty/null input", () => {
expect(mapNeteaseAlbums([])).toEqual([]);
expect(mapNeteaseAlbums(null as any)).toEqual([]);
expect(mapNeteaseAlbums(undefined as any)).toEqual([]);
});
});
+21 -3
View File
@@ -8,6 +8,7 @@ import type {
SearchResult,
QrCodeResult,
AuthStatus,
Album,
} from "./provider.js";
export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
@@ -55,6 +56,18 @@ export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
return lines.sort((a, b) => a.time - b.time);
}
export function mapNeteaseAlbums(raw: any[] | null | undefined): Album[] {
if (!Array.isArray(raw)) return [];
return raw.map((a) => ({
id: String(a.id),
name: a.name ?? "",
artist: (a.artists ?? []).map((x: any) => x.name).join(" / "),
coverUrl: a.picUrl ?? "",
songCount: a.size ?? 0,
platform: "netease",
}));
}
// NetEase quality levels: standard(128k) higher(192k) exhigh(320k) lossless(flac) hires(hi-res) jyeffect jymaster
export const NETEASE_QUALITY_LEVELS = [
{ value: "standard", label: "标准 (128kbps)", bitrate: 128 },
@@ -91,7 +104,7 @@ export class NeteaseProvider implements MusicProvider {
}
async search(query: string, limit = 20): Promise<SearchResult> {
const [songRes, playlistRes] = await Promise.all([
const [songRes, playlistRes, albumRes] = await Promise.all([
this.api.get("/cloudsearch", {
params: { keywords: query, type: 1, limit, ...this.cookieParams },
}),
@@ -99,10 +112,13 @@ export class NeteaseProvider implements MusicProvider {
params: {
keywords: query,
type: 1000,
limit: 5,
limit: 10,
...this.cookieParams,
},
}),
this.api.get("/cloudsearch", {
params: { keywords: query, type: 10, limit: 10, ...this.cookieParams },
}),
]);
const songs: Song[] = (songRes.data?.result?.songs ?? []).map(
@@ -127,7 +143,9 @@ export class NeteaseProvider implements MusicProvider {
platform: "netease",
}));
return { songs, playlists, albums: [] };
const albums = mapNeteaseAlbums(albumRes.data?.result?.albums);
return { songs, playlists, albums };
}
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { mapQqAlbums, mapQqSongs } from "./qq.js";
describe("QQ adapter", () => {
it("mapQqSongs maps QQMusicApi-style song entries", () => {
const out = mapQqSongs([
{
mid: "001abc",
name: "Radar Song",
singer: [{ name: "Singer A" }, { name: "Singer B" }],
album: { name: "Album A", mid: "alb001" },
interval: 243,
},
]);
expect(out).toEqual([
{
id: "001abc",
name: "Radar Song",
artist: "Singer A / Singer B",
album: "Album A",
duration: 243,
coverUrl: "https://y.gtimg.cn/music/photo_new/T002R300x300M000alb001.jpg",
platform: "qq",
},
]);
});
it("mapQqAlbums maps albumMID-style raw entries", () => {
const raw = [
{
albumMID: "abc",
albumName: "Aero",
singerName: "Singer A",
},
{
albumMID: "xyz",
albumName: "Beta",
singer: [{ name: "Singer B" }, { name: "Singer C" }],
},
];
const out = mapQqAlbums(raw);
expect(out).toHaveLength(2);
expect(out[0]).toMatchObject({
id: "abc",
name: "Aero",
artist: "Singer A",
platform: "qq",
});
expect(out[0].coverUrl).toContain("T002R300x300M000abc.jpg");
expect(out[1].artist).toBe("Singer B / Singer C");
expect(out[1].coverUrl).toContain("xyz");
});
it("mapQqAlbums returns [] for empty/null input", () => {
expect(mapQqAlbums([])).toEqual([]);
expect(mapQqAlbums(null as any)).toEqual([]);
expect(mapQqAlbums(undefined as any)).toEqual([]);
});
it("mapQqAlbums falls back to albumPic when no albumMID", () => {
const raw = [{ albumName: "C", albumPic: "https://x/p.jpg", singerName: "S" }];
const out = mapQqAlbums(raw);
expect(out[0].coverUrl).toBe("https://x/p.jpg");
expect(out[0].id).toBe("");
});
});
+249 -55
View File
@@ -8,17 +8,29 @@ import type {
SearchResult,
QrCodeResult,
AuthStatus,
Album,
} from "./provider.js";
import { parseLyrics } from "./netease.js";
// Direct QQ Music API client — bypasses the local API server for search
// because @sansenjian/qq-music-api still uses the broken c.y.qq.com endpoint.
const qqDirectApi = axios.create({
// Primary search client: u.y.qq.com/cgi-bin/musicu.fcg (JSON sub-request
// batch). Was broken ca. 2026-05 due to two upstream API changes:
// 1. searchid param must NOT be present (causes all lists to be empty)
// 2. num_per_page must be >= 10 (lower values return empty)
// Both fixes applied per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
const qqMusicuApi = axios.create({
baseURL: "https://u.y.qq.com",
timeout: 10000,
headers: { referer: "https://y.qq.com" },
});
// Fallback search client: c.y.qq.com/soso/fcgi-bin/client_search_cp (classic
// endpoint, song + album only, no playlist support).
const qqSearchApi = axios.create({
baseURL: "https://c.y.qq.com",
timeout: 10000,
headers: { referer: "https://y.qq.com" },
});
// Direct client for c.y.qq.com endpoints (collected playlists / favorites).
// The bundled qq-music-api wrapper doesn't expose these endpoints.
const qqFavApi = axios.create({
@@ -27,6 +39,44 @@ const qqFavApi = axios.create({
headers: { referer: "https://y.qq.com/" },
});
export function mapQqSongs(raw: any[] | null | undefined): Song[] {
if (!Array.isArray(raw)) return [];
return raw.map((s) => {
const albumMid = s.album?.mid ?? s.album?.pmid ?? s.albummid ?? s.albumMid ?? "";
return {
id: String(s.mid ?? s.songmid ?? s.songMID ?? s.id ?? s.songid ?? s.songId ?? ""),
name: s.title ?? s.name ?? s.songname ?? "",
artist: (s.singer ?? s.singers ?? []).map((a: any) => a.name ?? a.title ?? "").filter(Boolean).join(" / "),
album: s.album?.name ?? s.album?.title ?? s.albumname ?? "",
duration: s.interval ?? Math.round((s.duration ?? 0) / 1000),
coverUrl: albumMid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumMid}.jpg`
: "",
platform: "qq" as const,
};
}).filter((s) => s.id);
}
export function mapQqAlbums(raw: any[] | null | undefined): Album[] {
if (!Array.isArray(raw)) return [];
return raw.map((a) => {
const id = String(a.albumMID ?? a.mid ?? a.albumID ?? "");
const artist = a.singerName
?? (Array.isArray(a.singer) ? a.singer.map((s: any) => s.name).join(" / ") : "");
const coverUrl = id
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${id}.jpg`
: (a.albumPic ?? "");
return {
id,
name: a.albumName ?? a.title ?? "",
artist,
coverUrl,
songCount: a.song_count ?? a.songCount ?? 0,
platform: "qq" as const,
};
});
}
function computeGtk(pSkey: string): number {
let hash = 5381;
for (let i = 0; i < pSkey.length; i++) {
@@ -40,6 +90,7 @@ export class QQMusicProvider implements MusicProvider {
private api: AxiosInstance;
private cookie = "";
private quality = "exhigh";
private radarPage = 1;
constructor(baseUrl: string) {
this.api = axios.create({
@@ -60,37 +111,140 @@ export class QQMusicProvider implements MusicProvider {
return this.cookie ? { cookie: this.cookie } : {};
}
async search(query: string, limit = 20): Promise<SearchResult> {
const reqData = JSON.stringify({
req_0: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: {
searchid: "1",
query,
num_per_page: Math.min(limit, 50),
},
private get directCookieHeaders(): Record<string, string> {
return this.cookie ? { Cookie: this.cookie } : {};
}
private buildMusicuPayload(module: string, method: string, param: Record<string, unknown>): Record<string, unknown> {
const uinMatch = /(?:^|; )(?:uin|qqmusic_uin)=o?0?(\d+)/.exec(this.cookie);
const pSkeyMatch = /(?:^|; )p_skey=([^;]+)/.exec(this.cookie);
return {
comm: {
ct: 24,
cv: 4747474,
platform: "yqq.json",
uin: uinMatch ? uinMatch[1] : "0",
g_tk: pSkeyMatch ? computeGtk(pSkeyMatch[1]) : 5381,
format: "json",
inCharset: "utf-8",
outCharset: "utf-8",
notice: 0,
need_new_code: 1,
},
});
const res = await qqDirectApi.get("/cgi-bin/musicu.fcg", {
params: { format: "json", data: reqData },
});
const list: any[] =
res.data?.req_0?.data?.body?.song?.list ?? [];
req_0: { module, method, param },
};
}
const songs: Song[] = list.map((s: any) => ({
id: String(s.mid ?? s.id),
name: s.title ?? s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.album?.name ?? s.album?.title ?? "",
duration: s.interval ?? 0,
coverUrl: s.album?.mid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
: "",
platform: "qq",
}));
async search(query: string, limit = 20): Promise<SearchResult> {
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
// (removed searchid, num_per_page >= 10, corrected search_type values).
const primary = await this.searchViaMusicuFcg(query, limit);
if (primary) return primary;
return { songs, playlists: [], albums: [] };
// Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
// no playlist support). Kept as redundancy.
return this.searchViaClientSearchCp(query, limit);
}
/** Primary search via u.y.qq.com/cgi-bin/musicu.fcg.
*
* Two upstream API changes (2026-05) required fixes:
* 1. Omit `searchid` — its presence now causes all lists to be empty.
* 2. `num_per_page` >= 10 — lower values return empty.
* 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */
private async searchViaMusicuFcg(
query: string,
limit: number
): Promise<SearchResult | null> {
try {
const numPerPage = Math.max(10, Math.min(limit, 50));
const reqData = JSON.stringify({
req_0: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: numPerPage, search_type: 0 },
},
req_album: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: 10, search_type: 2 },
},
req_playlist: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: 10, search_type: 3 },
},
});
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
params: { format: "json", data: reqData },
});
const songList: any[] =
res.data?.req_0?.data?.body?.song?.list ?? [];
if (songList.length === 0) return null;
const songs = mapQqSongs(songList);
const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
const albums = mapQqAlbums(albumList);
const playlistList: any[] = res.data?.req_playlist?.data?.body?.songlist?.list ?? [];
const playlists: Playlist[] = playlistList.map((p: any) => ({
id: String(p.dissid ?? p.id ?? ""),
name: p.dissname ?? p.title ?? "",
coverUrl: p.imgurl ?? p.logo ?? "",
songCount: p.songnum ?? p.song_count ?? 0,
platform: "qq" as const,
}));
return { songs, playlists, albums };
} catch {
return null;
}
}
/** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */
private async searchViaClientSearchCp(
query: string,
limit: number
): Promise<SearchResult> {
const songParams = {
w: query,
format: "json",
p: 1,
n: Math.min(limit, 50),
type: 0,
cr: 1,
};
const albumParams = {
w: query,
format: "json",
p: 1,
n: 5,
t: 8,
cr: 1,
};
const [songRes, albumRes] = await Promise.allSettled([
qqSearchApi.get("/soso/fcgi-bin/client_search_cp", { params: songParams }),
qqSearchApi.get("/soso/fcgi-bin/client_search_cp", { params: albumParams }),
]);
const songList: any[] =
songRes.status === "fulfilled"
? (songRes.value.data?.data?.song?.list ?? [])
: [];
const songs = mapQqSongs(songList);
const albumList: any[] =
albumRes.status === "fulfilled"
? (albumRes.value.data?.data?.album?.list ?? [])
: [];
const albums = mapQqAlbums(albumList);
return { songs, playlists: [], albums };
}
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
@@ -208,19 +362,7 @@ export class QQMusicProvider implements MusicProvider {
});
const cdlist = res.data?.response?.cdlist ?? [];
if (cdlist.length === 0) return [];
return (cdlist[0].songlist ?? []).map((s: any) => ({
id: String(s.mid ?? s.songmid ?? s.songid),
name: s.songname ?? s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.albumname ?? "",
duration: s.interval ?? 0,
coverUrl: s.album?.mid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: "",
platform: "qq",
}));
return mapQqSongs(cdlist[0].songlist ?? []);
}
async getPlaylistDetail(playlistId: string): Promise<PlaylistDetail | null> {
@@ -255,17 +397,7 @@ export class QQMusicProvider implements MusicProvider {
const res = await this.api.get("/getAlbumInfo", {
params: { albummid: albumId, ...this.cookieParams },
});
return (res.data?.response?.data?.list ?? []).map((s: any) => ({
id: String(s.songmid ?? s.songid),
name: s.songname ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.albumname ?? "",
duration: s.interval ?? 0,
coverUrl: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: "",
platform: "qq",
}));
return mapQqSongs(res.data?.response?.data?.list ?? []);
}
async getLyrics(songId: string): Promise<LyricLine[]> {
@@ -330,6 +462,9 @@ export class QQMusicProvider implements MusicProvider {
setCookie(cookie: string): void {
this.cookie = cookie;
// Reset radar pagination so a re-login (different account) starts from the
// first page rather than inheriting the previous account's cursor.
this.radarPage = 1;
}
getCookie(): string {
@@ -391,6 +526,65 @@ export class QQMusicProvider implements MusicProvider {
}
}
async getPersonalFm(): Promise<Song[]> {
const radarSongs = await this.getRadarRecommendSongs();
if (radarSongs.length > 0) return radarSongs;
return this.getGuessRecommendSongs();
}
private async getRadarRecommendSongs(): Promise<Song[]> {
try {
const page = this.radarPage;
const res = await qqMusicuApi.post(
"/cgi-bin/musicu.fcg",
this.buildMusicuPayload(
"music.recommend.TrackRelationServer",
"GetRadarSong",
{
Page: page,
ReqType: 0,
FavSongs: [],
EntranceSongs: [],
}
),
{ headers: { referer: "https://y.qq.com/", ...this.directCookieHeaders } }
);
const tracks = (res.data?.req_0?.data?.VecSongs ?? [])
.map((item: any) => item?.Track)
.filter(Boolean);
const songs = mapQqSongs(tracks);
if (songs.length > 0) {
this.radarPage = page + 1;
}
return songs;
} catch {
return [];
}
}
private async getGuessRecommendSongs(): Promise<Song[]> {
try {
const res = await qqMusicuApi.post(
"/cgi-bin/musicu.fcg",
this.buildMusicuPayload(
"music.radioProxy.MbTrackRadioSvr",
"get_radio_track",
{
id: 99,
num: 5,
from: 0,
scene: 0,
song_ids: [],
}
),
{ headers: { referer: "https://y.qq.com/", ...this.directCookieHeaders } }
);
return mapQqSongs(res.data?.req_0?.data?.Tracks ?? []);
} catch {
return [];
}
}
async getUserPlaylists(): Promise<Playlist[]> {
if (!this.cookie) return [];
const uinMatch = /(?:^|; )uin=o?0?(\d+)/.exec(this.cookie);
+33 -8
View File
@@ -12,6 +12,8 @@ import {
type Identity,
type TextMessage,
type ClientInfo,
type ClientLeftViewEvent,
type ClientMovedEvent,
type FileUploadInfo,
} from "@honeybbq/teamspeak-client";
import type { Logger } from "../logger.js";
@@ -44,6 +46,7 @@ export interface TS3ClientOptions {
nickname: string;
identity?: string; // Exported identity string, or undefined to generate new
defaultChannel?: string;
channelId?: string; // Numeric channel ID (takes precedence over defaultChannel)
channelPassword?: string;
serverPassword?: string;
/** Force a specific protocol instead of auto-detecting. */
@@ -221,6 +224,20 @@ export class TS3Client extends EventEmitter {
{ nickname: info.nickname, id: info.id },
"Client entered"
);
this.emit("clientEnter", info);
});
this.client.on("clientLeave", (ev: ClientLeftViewEvent) => {
this.logger.debug({ id: ev.id }, "Client left");
this.emit("clientLeave", ev);
});
this.client.on("clientMoved", (ev: ClientMovedEvent) => {
this.logger.debug(
{ id: ev.id, targetChannelID: ev.targetChannelID.toString() },
"Client moved"
);
this.emit("clientMoved", ev);
});
await this.client.connect();
@@ -238,8 +255,10 @@ export class TS3Client extends EventEmitter {
`Logged in (visible client, ${this.detectedProtocol.toUpperCase()} server)`,
);
// Join default channel if specified
if (this.options.defaultChannel) {
// Join channel by numeric ID (takes precedence) or by name
if (this.options.channelId) {
await this.joinChannel(this.options.channelId, this.options.channelPassword);
} else if (this.options.defaultChannel) {
await this.joinChannel(
this.options.defaultChannel,
this.options.channelPassword
@@ -252,6 +271,17 @@ export class TS3Client extends EventEmitter {
async joinChannel(channelName: string, password?: string): Promise<void> {
if (!this.client) return;
const isNumeric = /^\d+$/.test(channelName);
if (isNumeric) {
try {
await clientMove(this.client, this.clientId, BigInt(channelName), password);
this.logger.info({ channelName }, "Joined channel");
} catch (err) {
this.logger.error({ err, channelName }, "Failed to join channel");
}
return;
}
try {
const channels = await listChannels(this.client);
const channel = channels.find((ch) => ch.name === channelName);
@@ -261,12 +291,7 @@ export class TS3Client extends EventEmitter {
return;
}
await clientMove(
this.client,
this.clientId,
channel.id,
password
);
await clientMove(this.client, this.clientId, channel.id, password);
this.logger.info(
{ channelName, cid: channel.id.toString() },
"Joined channel"
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js";
import { createAuditStore } from "../../data/audit.js";
import { createPermissionStore } from "../../data/permissions.js";
import { getDefaultConfig } from "../../data/config.js";
import { createRequireAuth } from "../middleware/requireAuth.js";
import { createAuditRouter } from "./audit.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
describe("audit router", () => {
let botDb: BotDatabase;
let app: express.Express;
let cookie: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const audit = createAuditStore(botDb.db);
const permissions = createPermissionStore(botDb.db);
const alice = await users.createUser("alice", "pw-alice", "admin");
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
for (let i = 0; i < 3; i++) {
audit.record({
actorId: alice.id, actorUsername: "alice",
targetUserId: "x", targetUsername: "x",
action: "user.created",
});
}
app = express();
app.use(express.json());
app.use(cookieParser());
app.use("/api", createRequireAuth(sessions, permissions, () => getDefaultConfig().guestMode));
app.use("/api/audit", createAuditRouter(audit));
});
afterEach(() => botDb.close());
it("requires auth", async () => {
const res = await request(app).get("/api/audit");
expect(res.status).toBe(401);
});
it("returns entries newest-first", async () => {
const res = await request(app).get("/api/audit").set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.entries).toHaveLength(3);
});
it("honors limit query param", async () => {
const res = await request(app).get("/api/audit?limit=1").set("Cookie", cookie);
expect(res.body.entries).toHaveLength(1);
});
});
+18
View File
@@ -0,0 +1,18 @@
import { Router } from "express";
import type { AuditStore } from "../../data/audit.js";
export function createAuditRouter(audit: AuditStore): Router {
const router = Router();
router.get("/", (req, res) => {
const limit = clampInt(req.query.limit, 1, 500, 100);
const offset = clampInt(req.query.offset, 0, 100_000, 0);
res.json({ entries: audit.list(limit, offset) });
});
return router;
}
function clampInt(v: unknown, min: number, max: number, def: number): number {
const n = typeof v === "string" ? parseInt(v, 10) : NaN;
if (!Number.isFinite(n)) return def;
return Math.min(Math.max(n, min), max);
}
+8 -6
View File
@@ -3,6 +3,8 @@ import type { MusicProvider } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.js";
import type { CookieStore } from "../../music/auth.js";
import type { Logger } from "../../logger.js";
import { requirePermission } from "../middleware/requirePermission.js";
import { requireNotGuest } from "../middleware/requireNotGuest.js";
export function createAuthRouter(
neteaseProvider: MusicProvider,
@@ -22,7 +24,7 @@ export function createAuthRouter(
return platform === "qq" ? qqProvider : neteaseProvider;
}
router.get("/status", async (req, res) => {
router.get("/status", requireNotGuest, async (req, res) => {
try {
const platform = req.query.platform as string;
const provider = getProvider(platform);
@@ -35,7 +37,7 @@ export function createAuthRouter(
}
});
router.post("/qrcode", async (req, res) => {
router.post("/qrcode", requirePermission("platform.auth"), async (req, res) => {
try {
const { platform } = req.body;
const provider = getProvider(platform);
@@ -48,7 +50,7 @@ export function createAuthRouter(
}
});
router.get("/qrcode/status", async (req, res) => {
router.get("/qrcode/status", requireNotGuest, async (req, res) => {
try {
const { key, platform } = req.query;
if (!key) {
@@ -77,7 +79,7 @@ export function createAuthRouter(
}
});
router.post("/sms/send", async (req, res) => {
router.post("/sms/send", requirePermission("platform.auth"), async (req, res) => {
try {
const { phone } = req.body;
if (!phone) {
@@ -97,7 +99,7 @@ export function createAuthRouter(
}
});
router.post("/sms/verify", async (req, res) => {
router.post("/sms/verify", requirePermission("platform.auth"), async (req, res) => {
try {
const { phone, code } = req.body;
if (!phone || !code) {
@@ -118,7 +120,7 @@ export function createAuthRouter(
}
});
router.post("/cookie", (req, res) => {
router.post("/cookie", requirePermission("platform.auth"), (req, res) => {
const { platform, cookie } = req.body;
if (!cookie) {
res.status(400).json({ error: "cookie is required" });
+90
View File
@@ -0,0 +1,90 @@
import { describe, it, expect } from "vitest";
import express from "express";
import request from "supertest";
import pino from "pino";
import { createBotRouter } from "./bot.js";
const logger = pino({ level: "silent" });
// Fake bot whose getStatus() exposes its id, matching the real status shape.
function makeFakeBot(id: string) {
return {
id,
getStatus: () => ({ id }),
};
}
function makeBotManager() {
const b1 = makeFakeBot("b1");
const b2 = makeFakeBot("b2");
return {
getBot: (id: string) => (id === "b1" ? b1 : id === "b2" ? b2 : undefined),
getAllBots: () => [b1, b2],
getBotConfig: () => undefined,
createBot: async () => b1,
updateBot: () => {},
removeBot: async () => {},
startBot: async () => {},
stopBot: () => {},
} as any;
}
function makeApp(user: any) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => { (req as any).user = user; next(); });
app.use(
"/api/bot",
createBotRouter(
makeBotManager(),
{ idleTimeoutMinutes: 0 } as any,
"/tmp/config.json",
logger,
{ getBotInstances: () => [], getCustomAvatarPath: () => null, setCustomAvatarPath: () => {} } as any,
{ read: () => null, write: () => "x", remove: () => {} } as any,
),
);
return app;
}
const member = (bots: "all" | string[]) => ({
id: "u1",
username: "alice",
role: "member" as const,
capabilities: new Set<string>(),
bots: bots === "all" ? ("all" as const) : new Set(bots),
});
const admin = {
id: "a",
username: "admin",
role: "admin" as const,
capabilities: new Set<string>(),
bots: "all" as const,
};
describe("GET /api/bot bot-list filtering", () => {
it("member with bots:Set([b1]) sees only b1", async () => {
const app = makeApp(member(["b1"]));
const res = await request(app).get("/api/bot");
expect(res.status).toBe(200);
const ids = (res.body.bots as { id: string }[]).map((b) => b.id);
expect(ids).toEqual(["b1"]);
});
it("admin sees both b1 and b2", async () => {
const app = makeApp(admin);
const res = await request(app).get("/api/bot");
expect(res.status).toBe(200);
const ids = (res.body.bots as { id: string }[]).map((b) => b.id).sort();
expect(ids).toEqual(["b1", "b2"]);
});
it("member with bots:'all' sees both b1 and b2", async () => {
const app = makeApp(member("all"));
const res = await request(app).get("/api/bot");
expect(res.status).toBe(200);
const ids = (res.body.bots as { id: string }[]).map((b) => b.id).sort();
expect(ids).toEqual(["b1", "b2"]);
});
});
+216
View File
@@ -0,0 +1,216 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import pino from "pino";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js";
import { createAvatarStore } from "../../data/avatars.js";
import { createRequireAuth } from "../middleware/requireAuth.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createBotRouter } from "./bot.js";
import { getDefaultConfig, type BotConfig } from "../../data/config.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
import type { BotManager } from "../../bot/manager.js";
/** Records every updateIdleTimeout / updateAutoPause call so the test can assert propagation. */
function makeFakeBot() {
return {
idleTimeoutCalls: [] as number[],
autoPauseCalls: [] as boolean[],
updateIdleTimeout(minutes: number) {
this.idleTimeoutCalls.push(minutes);
},
updateAutoPause(enabled: boolean) {
this.autoPauseCalls.push(enabled);
},
};
}
describe("bot router /settings", () => {
let botDb: BotDatabase;
let app: express.Express;
let cookie: string;
let config: BotConfig;
let configPath: string;
let tmpDir: string;
let fakeBots: ReturnType<typeof makeFakeBot>[];
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const alice = await users.createUser("alice", "pw-alice", "admin");
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
tmpDir = mkdtempSync(join(tmpdir(), "botsettings-"));
configPath = join(tmpDir, "config.json");
config = { ...getDefaultConfig(), idleTimeoutMinutes: 15, autoPauseOnEmpty: true };
fakeBots = [makeFakeBot(), makeFakeBot()];
const fakeManager = {
getAllBots: () => fakeBots,
} as unknown as BotManager;
const avatarStore = createAvatarStore(tmpDir);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
app.use(
"/api/bot",
createBotRouter(fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore),
);
});
afterEach(() => {
botDb.close();
rmSync(tmpDir, { recursive: true, force: true });
});
it("requires auth", async () => {
const res = await request(app).get("/api/bot/settings");
expect(res.status).toBe(401);
});
it("GET /settings includes autoPauseOnEmpty reflecting config", async () => {
const res = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.idleTimeoutMinutes).toBe(15);
expect(res.body.autoPauseOnEmpty).toBe(true);
});
it("POST /settings with autoPauseOnEmpty:false persists and propagates to bots", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ autoPauseOnEmpty: false });
expect(res.status).toBe(200);
// in-memory config mutated
expect(config.autoPauseOnEmpty).toBe(false);
// propagated to every live bot
for (const bot of fakeBots) {
expect(bot.autoPauseCalls).toEqual([false]);
}
// follow-up GET reflects the new value
const followUp = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(followUp.body.autoPauseOnEmpty).toBe(false);
});
it("POST /settings still handles idleTimeoutMinutes (no regression)", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ idleTimeoutMinutes: 42 });
expect(res.status).toBe(200);
expect(config.idleTimeoutMinutes).toBe(42);
for (const bot of fakeBots) {
expect(bot.idleTimeoutCalls).toEqual([42]);
}
const followUp = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(followUp.body.idleTimeoutMinutes).toBe(42);
});
it("POST /settings handles both fields together", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ idleTimeoutMinutes: 7, autoPauseOnEmpty: false });
expect(res.status).toBe(200);
expect(config.idleTimeoutMinutes).toBe(7);
expect(config.autoPauseOnEmpty).toBe(false);
for (const bot of fakeBots) {
expect(bot.idleTimeoutCalls).toEqual([7]);
expect(bot.autoPauseCalls).toEqual([false]);
}
});
it("POST /settings with only autoPauseOnEmpty does not touch idleTimeout bots", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ autoPauseOnEmpty: false });
expect(res.status).toBe(200);
for (const bot of fakeBots) {
expect(bot.idleTimeoutCalls).toEqual([]);
expect(bot.autoPauseCalls).toEqual([false]);
}
});
it("POST /settings ignores non-boolean autoPauseOnEmpty without 400", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ idleTimeoutMinutes: 5, autoPauseOnEmpty: "yes" });
expect(res.status).toBe(200);
// idleTimeout still applied
expect(config.idleTimeoutMinutes).toBe(5);
// autoPause left at its prior value, not propagated
expect(config.autoPauseOnEmpty).toBe(true);
for (const bot of fakeBots) {
expect(bot.autoPauseCalls).toEqual([]);
}
});
});
describe("bot router /settings guest-mode gating + persistence", () => {
let tmpDir: string;
let configPath: string;
let config: BotConfig;
let botDb: BotDatabase;
beforeEach(() => {
botDb = createDatabase(":memory:");
tmpDir = mkdtempSync(join(tmpdir(), "botsettings-gm-"));
configPath = join(tmpDir, "config.json");
config = getDefaultConfig();
});
afterEach(() => {
botDb.close();
rmSync(tmpDir, { recursive: true, force: true });
});
/** Mounts createBotRouter with an injected req.user (no session/cookie). */
function mountBot(injectUser: () => unknown): express.Express {
const fakeManager = { getAllBots: () => [] } as unknown as BotManager;
const avatarStore = createAvatarStore(tmpDir);
const app = express();
app.use(express.json());
app.use((req, _res, next) => { (req as { user?: unknown }).user = injectUser(); next(); });
app.use(
"/api/bot",
createBotRouter(fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore),
);
return app;
}
it("GET /settings is 403 for guests and includes guestMode for admins", async () => {
const guestApp = mountBot(() => ({ role: "guest", guest: {} }));
expect((await request(guestApp).get("/api/bot/settings")).status).toBe(403);
const adminApp = mountBot(() => ({ role: "admin" }));
const res = await request(adminApp).get("/api/bot/settings");
expect(res.status).toBe(200);
expect(res.body.guestMode).toBeDefined();
expect(res.body.guestMode.enabled).toBe(false);
});
it("POST /settings persists a guestMode block", async () => {
const adminApp = mountBot(() => ({ role: "admin" }));
const res = await request(adminApp).post("/api/bot/settings").send({
guestMode: { enabled: true, bots: ["bot1"], permissions: { playNext: true } },
});
expect(res.status).toBe(200);
expect(res.body.guestMode.enabled).toBe(true);
expect(res.body.guestMode.bots).toEqual(["bot1"]);
expect(res.body.guestMode.permissions.playNext).toBe(true);
expect(res.body.guestMode.permissions.addToQueue).toBe(true); // untouched default
});
});
+162 -35
View File
@@ -1,23 +1,102 @@
import { Router } from "express";
import type { BotManager } from "../../bot/manager.js";
import type { BotConfig } from "../../data/config.js";
import type { BotConfig, GuestModeConfig } from "../../data/config.js";
import { saveConfig } from "../../data/config.js";
import type { Logger } from "../../logger.js";
import type { BotDatabase } from "../../data/database.js";
import type { AvatarStore } from "../../data/avatars.js";
import { requirePermission, requireBotAccess } from "../middleware/requirePermission.js";
import { requireNotGuest } from "../middleware/requireNotGuest.js";
import { GUEST_PERMISSION_FLAGS } from "../../data/permissions.js";
export function createBotRouter(
botManager: BotManager,
config: BotConfig,
configPath: string,
logger: Logger
logger: Logger,
botDb: BotDatabase,
avatarStore: AvatarStore,
onGuestPolicyChanged?: (cfg: GuestModeConfig) => void,
): Router {
const router = Router();
router.get("/", (_req, res) => {
const bots = botManager.getAllBots().map((b) => b.getStatus());
router.get("/", (req, res) => {
const all = botManager.getAllBots().map((b) => b.getStatus());
const u = req.user!;
const bots =
u.role === "admin" || u.bots === "all"
? all
: all.filter((b) => u.bots instanceof Set && u.bots.has(b.id));
res.json({ bots });
});
router.get("/:id", (req, res) => {
// GET /api/bot/settings — 读取全局 bot 行为设置
// NOTE: must be registered before "/:id" so it isn't shadowed by the param route.
router.get("/settings", requireNotGuest, (_req, res) => {
res.json({
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty,
guestMode: config.guestMode,
});
});
// POST /api/bot/settings — 保存全局 bot 行为设置 (gated: changing global bot
// behavior is a bot.manage operation, consistent with PR #80's permission model)
router.post("/settings", requirePermission("bot.manage"), (req, res) => {
const { idleTimeoutMinutes, autoPauseOnEmpty, guestMode } = req.body;
const hasIdle = idleTimeoutMinutes !== undefined;
if (hasIdle && (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0)) {
res.status(400).json({ error: "idleTimeoutMinutes must be a non-negative number" });
return;
}
const hasAutoPause = typeof autoPauseOnEmpty === "boolean";
if (hasIdle) config.idleTimeoutMinutes = idleTimeoutMinutes;
if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty;
const hasGuestMode = guestMode !== undefined && guestMode !== null && typeof guestMode === "object";
if (hasGuestMode) {
const gm = config.guestMode;
if (typeof guestMode.enabled === "boolean") gm.enabled = guestMode.enabled;
if (guestMode.bots === "all") {
gm.bots = "all";
} else if (Array.isArray(guestMode.bots)) {
gm.bots = guestMode.bots.filter((id: unknown): id is string => typeof id === "string");
}
if (guestMode.permissions && typeof guestMode.permissions === "object") {
for (const f of GUEST_PERMISSION_FLAGS) {
if (typeof guestMode.permissions[f] === "boolean") {
gm.permissions[f] = guestMode.permissions[f];
}
}
}
}
saveConfig(configPath, config);
// Guest-mode changed: tear down / re-scope in-flight guest WS sockets so a
// disabled or narrowed scope takes effect immediately (matches requireAuth's
// "disabling immediately invalidates in-flight guest sessions" invariant).
if (hasGuestMode) {
onGuestPolicyChanged?.(config.guestMode);
}
// 通知所有 bot 实例更新
for (const bot of botManager.getAllBots()) {
if (hasIdle) bot.updateIdleTimeout(config.idleTimeoutMinutes);
if (hasAutoPause) bot.updateAutoPause(config.autoPauseOnEmpty);
}
res.json({
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty,
guestMode: config.guestMode,
});
});
router.get("/:id", requireBotAccess("id"), (req, res) => {
const bot = botManager.getBot(req.params.id);
if (!bot) {
res.status(404).json({ error: "Bot not found" });
@@ -27,16 +106,83 @@ export function createBotRouter(
});
// Get saved config for a bot
router.get("/:id/config", (req, res) => {
router.get("/:id/config", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => {
const saved = botManager.getBotConfig(req.params.id);
if (!saved) {
res.status(404).json({ error: "Bot config not found" });
return;
}
res.json(saved);
// Never expose the TS identity / API key to the client; the edit form only
// consumes channel/server passwords.
const { ts6ApiKey: _ts6ApiKey, identity: _identity, ...safe } = saved as unknown as Record<string, unknown>;
res.json(safe);
});
router.post("/", async (req, res) => {
router.get("/:id/avatar", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => {
const path = botDb.getCustomAvatarPath(req.params.id);
if (!path) {
res.status(404).end();
return;
}
const buf = avatarStore.read(path);
if (!buf) {
res.status(404).end();
return;
}
const ext = path.split(".").pop() ?? "";
const mime = ext === "png"
? "image/png"
: ext === "webp"
? "image/webp"
: "image/jpeg";
res.set("Content-Type", mime);
res.set("Cache-Control", "no-cache");
res.send(buf);
});
router.put("/:id/avatar", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => {
const exists =
botManager.getBot(req.params.id) ||
botDb.getBotInstances().some((b) => b.id === req.params.id);
if (!exists) {
res.status(404).json({ error: "Bot not found" });
return;
}
const { dataUrl } = req.body as { dataUrl?: string };
if (typeof dataUrl !== "string") {
res.status(400).json({ error: "dataUrl required" });
return;
}
const m = /^data:(image\/(?:png|jpeg|webp));base64,(.+)$/.exec(dataUrl);
if (!m) {
res.status(400).json({ error: "dataUrl must be image/png|jpeg|webp base64" });
return;
}
const mime = m[1] as string;
const buf = Buffer.from(m[2] ?? "", "base64");
if (buf.length === 0) {
res.status(400).json({ error: "empty image" });
return;
}
if (buf.length > 200 * 1024) {
res.status(413).json({ error: "avatar exceeds 200KB limit" });
return;
}
const rel = avatarStore.write(req.params.id, mime, buf);
botDb.setCustomAvatarPath(req.params.id, rel);
botManager.getBot(req.params.id)?.getProfileManager().setCustomAvatar(buf);
res.json({ path: rel });
});
router.delete("/:id/avatar", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => {
const path = botDb.getCustomAvatarPath(req.params.id);
if (path) avatarStore.remove(path);
botDb.setCustomAvatarPath(req.params.id, null);
botManager.getBot(req.params.id)?.getProfileManager().setCustomAvatar(null);
res.status(204).end();
});
router.post("/", requirePermission("bot.manage"), async (req, res) => {
try {
const {
name,
@@ -44,6 +190,7 @@ export function createBotRouter(
serverPort,
nickname,
defaultChannel,
channelId,
channelPassword,
serverPassword,
autoStart,
@@ -60,6 +207,7 @@ export function createBotRouter(
serverPort: serverPort ?? 9987,
nickname,
defaultChannel,
channelId,
channelPassword,
serverPassword,
autoStart: autoStart ?? false,
@@ -72,17 +220,17 @@ export function createBotRouter(
});
// Update bot config (must be stopped first to apply connection changes)
router.put("/:id", async (req, res) => {
router.put("/:id", requirePermission("bot.manage"), requireBotAccess("id"), async (req, res) => {
try {
const bot = botManager.getBot(req.params.id);
if (!bot) {
res.status(404).json({ error: "Bot not found" });
return;
}
const { name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, serverPassword } = req.body;
const { name, serverAddress, serverPort, nickname, defaultChannel, channelId, channelPassword, serverPassword } = req.body;
// Update in database
botManager.updateBot(req.params.id, {
name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, serverPassword,
name, serverAddress, serverPort, nickname, defaultChannel, channelId, channelPassword, serverPassword,
});
res.json({ success: true });
} catch (err) {
@@ -91,7 +239,7 @@ export function createBotRouter(
}
});
router.delete("/:id", async (req, res) => {
router.delete("/:id", requirePermission("bot.manage"), requireBotAccess("id"), async (req, res) => {
try {
await botManager.removeBot(req.params.id);
res.json({ success: true });
@@ -100,7 +248,7 @@ export function createBotRouter(
}
});
router.post("/:id/start", async (req, res) => {
router.post("/:id/start", requirePermission("bot.manage"), requireBotAccess("id"), async (req, res) => {
try {
await botManager.startBot(req.params.id);
res.json({ success: true });
@@ -109,7 +257,7 @@ export function createBotRouter(
}
});
router.post("/:id/stop", (req, res) => {
router.post("/:id/stop", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => {
try {
botManager.stopBot(req.params.id);
res.json({ success: true });
@@ -117,27 +265,6 @@ export function createBotRouter(
res.status(500).json({ error: (err as Error).message });
}
});
// GET /api/bot/settings — 读取全局 bot 行为设置
router.get("/settings", (_req, res) => {
res.json({ idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0 });
});
// POST /api/bot/settings — 保存全局 bot 行为设置
router.post("/settings", (req, res) => {
const { idleTimeoutMinutes } = req.body;
if (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0) {
res.status(400).json({ error: "idleTimeoutMinutes must be a non-negative number" });
return;
}
config.idleTimeoutMinutes = idleTimeoutMinutes;
saveConfig(configPath, config);
// 通知所有 bot 实例更新定时器
for (const bot of botManager.getAllBots()) {
bot.updateIdleTimeout(idleTimeoutMinutes);
}
res.json({ ok: true });
});
return router;
}
+76
View File
@@ -0,0 +1,76 @@
import { Router } from "express";
import type { BotDatabase } from "../../data/database.js";
import type { Logger } from "../../logger.js";
export function createFavoritesRouter(database: BotDatabase, logger: Logger): Router {
const router = Router();
// GET /api/favorites — 获取当前用户的所有收藏
router.get("/", (req, res) => {
const userId = req.user!.id;
const favorites = database.getFavorites(userId);
res.json({ favorites });
});
// POST /api/favorites — 添加收藏
router.post("/", (req, res) => {
const userId = req.user!.id;
const { platform, playlistId, name, coverUrl, songCount } = req.body ?? {};
if (!platform || !playlistId || !name) {
res.status(400).json({ error: "platform, playlistId, name are required" });
return;
}
try {
database.addFavorite(userId, {
platform,
playlistId,
name,
coverUrl: coverUrl ?? "",
songCount: songCount ?? 0,
});
logger.info({ userId, platform, playlistId, name }, "Playlist favorited");
res.json({ success: true });
} catch (err: unknown) {
const e = err as { code?: string };
if (e?.code === "SQLITE_CONSTRAINT_UNIQUE") {
res.status(409).json({ error: "already favorited" });
return;
}
logger.error({ err }, "Failed to add favorite");
res.status(500).json({ error: "internal error" });
}
});
// DELETE /api/favorites/:id — 取消收藏(只允许删除自己的)
router.delete("/:id", (req, res) => {
const userId = req.user!.id;
const favId = parseInt(req.params.id, 10);
if (isNaN(favId)) {
res.status(400).json({ error: "invalid id" });
return;
}
const favorites = database.getFavorites(userId);
const fav = favorites.find((f) => f.id === favId);
if (!fav) {
res.status(404).json({ error: "favorite not found" });
return;
}
database.removeFavorite(userId, fav.playlistId, fav.platform);
logger.info({ userId, playlistId: fav.playlistId, platform: fav.platform }, "Playlist unfavorited");
res.json({ success: true });
});
// GET /api/favorites/check?platform=netease&playlistId=xxx — 检查是否已收藏
router.get("/check", (req, res) => {
const userId = req.user!.id;
const { platform, playlistId } = req.query;
if (typeof platform !== "string" || typeof playlistId !== "string") {
res.status(400).json({ error: "platform and playlistId required" });
return;
}
const favorited = database.isFavorited(userId, playlistId, platform);
res.json({ favorited });
});
return router;
}
+17 -9
View File
@@ -2,6 +2,8 @@ import { Router } from "express";
import type { MusicProvider } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.js";
import type { Logger } from "../../logger.js";
import { requirePermission } from "../middleware/requirePermission.js";
import { requireNotGuest } from "../middleware/requireNotGuest.js";
export function createMusicRouter(
neteaseProvider: MusicProvider,
@@ -52,14 +54,20 @@ export function createMusicRouter(
]);
const songs = [
...(neteaseResult.status === "fulfilled"
? neteaseResult.value.songs
: []),
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []),
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
];
const albums = [
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
...(qqResult.status === "fulfilled" ? qqResult.value.albums : []),
];
const playlists = [
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []),
...(qqResult.status === "fulfilled" ? qqResult.value.playlists : []),
];
res.json({ songs });
res.json({ songs, albums, playlists });
} catch (err) {
logger.error({ err }, "Unified search failed");
res.status(500).json({ error: (err as Error).message });
@@ -120,7 +128,7 @@ export function createMusicRouter(
}
});
router.get("/recommend/songs", async (req, res) => {
router.get("/recommend/songs", requireNotGuest, async (req, res) => {
try {
const provider = getProvider(req.query.platform as string);
if (!provider.getDailyRecommendSongs) {
@@ -135,7 +143,7 @@ export function createMusicRouter(
}
});
router.get("/personal/fm", async (req, res) => {
router.get("/personal/fm", requireNotGuest, async (req, res) => {
try {
const provider = getProvider(req.query.platform as string);
if (!provider.getPersonalFm) {
@@ -150,7 +158,7 @@ export function createMusicRouter(
}
});
router.get("/user/playlists", async (req, res) => {
router.get("/user/playlists", requireNotGuest, async (req, res) => {
try {
const provider = getProvider(req.query.platform as string);
if (!provider.getUserPlaylists) {
@@ -202,7 +210,7 @@ export function createMusicRouter(
});
// Get current quality
router.get("/quality", (_req, res) => {
router.get("/quality", requireNotGuest, (_req, res) => {
res.json({
netease: neteaseProvider.getQuality(),
qq: qqProvider.getQuality(),
@@ -211,7 +219,7 @@ export function createMusicRouter(
});
// Set quality
router.post("/quality", (req, res) => {
router.post("/quality", requirePermission("quality"), (req, res) => {
const { quality, platform } = req.body;
if (!quality) {
res.status(400).json({ error: "quality is required" });
+446
View File
@@ -0,0 +1,446 @@
import { describe, it, expect, beforeEach } from "vitest";
import express from "express";
import request from "supertest";
import pino from "pino";
import { createPlayerRouter } from "./player.js";
import { createBotRouter } from "./bot.js";
import { createAuthRouter } from "./auth.js";
import { createMusicRouter } from "./music.js";
import { createFavoritesRouter } from "./favorites.js";
import { requireNotGuest } from "../middleware/requireNotGuest.js";
const logger = pino({ level: "silent" });
// --- minimal stubs --------------------------------------------------------
const ALLOWED_BOT = "bot-allowed";
// A fake bot whose methods all no-op / return benign values so the real
// handlers run to completion without 500ing. We only assert that the
// permission/bot-access gate let the request THROUGH (status !== 403).
function makeFakeBot(id: string) {
return {
id,
executeCommand: async () => "ok",
getStatus: () => ({ id }),
getQueue: () => [],
getProfileManager: () => ({ getConfig: () => ({}), updateConfig: () => {}, setCustomAvatar: () => {} }),
};
}
function makeBotManager() {
const bot = makeFakeBot(ALLOWED_BOT);
return {
getBot: (id: string) => (id === ALLOWED_BOT ? bot : undefined),
getAllBots: () => [bot],
getBotConfig: () => undefined,
createBot: async () => bot,
updateBot: () => {},
removeBot: async () => {},
startBot: async () => {},
stopBot: () => {},
} as any;
}
function makeProvider() {
return {
platform: "netease",
getQuality: () => "high",
setQuality: () => {},
getAuthStatus: async () => ({ loggedIn: false }),
getQrCode: async () => ({ key: "k", url: "u" }),
getCookie: () => "c",
setCookie: () => {},
search: async () => ({ songs: [], albums: [], playlists: [] }),
} as any;
}
// Build one app mounting all four real routers, with req.user injected by a
// middleware placed BEFORE the routers (mimicking what requireAuth does).
function makeApp(user: any) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => { (req as any).user = user; next(); });
const botManager = makeBotManager();
const provider = makeProvider();
app.use("/api/player", createPlayerRouter(botManager, logger));
app.use(
"/api/bot",
createBotRouter(
botManager,
{ idleTimeoutMinutes: 0 } as any,
"/tmp/config.json",
logger,
{ getBotInstances: () => [], getCustomAvatarPath: () => null, setCustomAvatarPath: () => {} } as any,
{ read: () => null, write: () => "x", remove: () => {} } as any,
),
);
app.use("/api/auth", createAuthRouter(provider, provider, provider, logger));
app.use("/api/music", createMusicRouter(provider, provider, provider, logger));
return app;
}
const member = (caps: string[], bots: "all" | string[]) => ({
id: "u1",
username: "alice",
role: "member" as const,
capabilities: new Set(caps),
bots: bots === "all" ? ("all" as const) : new Set(bots),
});
const admin = {
id: "a",
username: "admin",
role: "admin" as const,
capabilities: new Set<string>(),
bots: "all" as const,
};
describe("permission enforcement on action routes", () => {
describe("player.control", () => {
it("403 for member WITHOUT player.control", async () => {
const app = makeApp(member([], [ALLOWED_BOT]));
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/pause`);
expect(res.status).toBe(403);
});
it("NOT 403 for member WITH player.control + bot in allow-list", async () => {
const app = makeApp(member(["player.control"], [ALLOWED_BOT]));
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/pause`);
expect(res.status).not.toBe(403);
});
it("403 for member WITH player.control but bot NOT in allow-list", async () => {
const app = makeApp(member(["player.control"], ["other-bot"]));
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/pause`);
expect(res.status).toBe(403);
});
});
describe("player.queue", () => {
it("403 for member WITHOUT player.queue", async () => {
const app = makeApp(member(["player.control"], [ALLOWED_BOT]));
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/clear`);
expect(res.status).toBe(403);
});
it("NOT 403 for member WITH player.queue", async () => {
const app = makeApp(member(["player.queue"], [ALLOWED_BOT]));
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/clear`);
expect(res.status).not.toBe(403);
});
});
describe("bot.manage", () => {
it("403 for member WITHOUT bot.manage on POST /api/bot", async () => {
const app = makeApp(member([], "all"));
const res = await request(app)
.post("/api/bot")
.send({ name: "n", serverAddress: "s", nickname: "nick" });
expect(res.status).toBe(403);
});
it("NOT 403 for member WITH bot.manage on POST /api/bot", async () => {
const app = makeApp(member(["bot.manage"], "all"));
const res = await request(app)
.post("/api/bot")
.send({ name: "n", serverAddress: "s", nickname: "nick" });
expect(res.status).not.toBe(403);
});
it("403 for member WITH bot.manage but bot NOT in allow-list on POST /api/bot/:id/start", async () => {
const app = makeApp(member(["bot.manage"], ["other-bot"]));
const res = await request(app).post(`/api/bot/${ALLOWED_BOT}/start`);
expect(res.status).toBe(403);
});
it("NOT 403 for member WITH bot.manage + bot in allow-list on POST /api/bot/:id/start", async () => {
const app = makeApp(member(["bot.manage"], [ALLOWED_BOT]));
const res = await request(app).post(`/api/bot/${ALLOWED_BOT}/start`);
expect(res.status).not.toBe(403);
});
});
describe("platform.auth", () => {
it("403 for member WITHOUT platform.auth on POST /api/auth/cookie", async () => {
const app = makeApp(member([], "all"));
const res = await request(app).post("/api/auth/cookie").send({ cookie: "c" });
expect(res.status).toBe(403);
});
it("NOT 403 for member WITH platform.auth on POST /api/auth/cookie", async () => {
const app = makeApp(member(["platform.auth"], "all"));
const res = await request(app).post("/api/auth/cookie").send({ cookie: "c" });
expect(res.status).not.toBe(403);
});
});
describe("quality", () => {
it("403 for member WITHOUT quality on POST /api/music/quality", async () => {
const app = makeApp(member([], "all"));
const res = await request(app).post("/api/music/quality").send({ quality: "high" });
expect(res.status).toBe(403);
});
it("NOT 403 for member WITH quality on POST /api/music/quality", async () => {
const app = makeApp(member(["quality"], "all"));
const res = await request(app).post("/api/music/quality").send({ quality: "high" });
expect(res.status).not.toBe(403);
});
it("GET /api/music/quality is 403 for guests, allowed for members", async () => {
const guestApp = makeApp(guest());
expect((await request(guestApp).get("/api/music/quality")).status).toBe(403);
const memberApp = makeApp(member([], "all"));
expect((await request(memberApp).get("/api/music/quality")).status).toBe(200);
});
});
// The operator's personal-account reads (their recommendations, FM, and
// playlists) must never leak to login-less guests. These routes are gated
// with requireNotGuest; generic search/browse stays open.
describe("operator personal-data reads are denied to guests", () => {
const personalRoutes = [
"/api/music/recommend/songs",
"/api/music/personal/fm",
"/api/music/user/playlists",
];
for (const route of personalRoutes) {
it(`GET ${route} is 403 for a guest`, async () => {
const app = makeApp(guest());
expect((await request(app).get(route)).status).toBe(403);
});
it(`GET ${route} is NOT 403 for a member`, async () => {
const app = makeApp(member([], "all"));
expect((await request(app).get(route)).status).not.toBe(403);
});
}
});
describe("read-only routes stay open", () => {
it("GET /api/auth/status not gated", async () => {
const app = makeApp(member([], "all"));
const res = await request(app).get("/api/auth/status");
expect(res.status).not.toBe(403);
});
it("GET /api/music/quality readable by members, denied to guests", async () => {
const app = makeApp(member([], "all"));
const res = await request(app).get("/api/music/quality");
expect(res.status).not.toBe(403);
});
it("GET /api/bot not gated", async () => {
const app = makeApp(member([], "all"));
const res = await request(app).get("/api/bot");
expect(res.status).not.toBe(403);
});
it("GET /api/auth/status and /api/auth/qrcode/status are 403 for guests", async () => {
const app = makeApp(guest());
expect((await request(app).get("/api/auth/status")).status).toBe(403);
expect((await request(app).get("/api/auth/qrcode/status?key=k")).status).toBe(403);
});
});
describe("admin bypasses every gate", () => {
let app: express.Express;
beforeEach(() => { app = makeApp(admin); });
it("player.control", async () => {
expect((await request(app).post(`/api/player/${ALLOWED_BOT}/pause`)).status).not.toBe(403);
});
it("player.queue", async () => {
expect((await request(app).post(`/api/player/${ALLOWED_BOT}/clear`)).status).not.toBe(403);
});
it("bot.manage POST /api/bot", async () => {
const res = await request(app).post("/api/bot").send({ name: "n", serverAddress: "s", nickname: "nick" });
expect(res.status).not.toBe(403);
});
it("bot.manage POST /api/bot/:id/start", async () => {
expect((await request(app).post(`/api/bot/${ALLOWED_BOT}/start`)).status).not.toBe(403);
});
it("platform.auth POST /api/auth/cookie", async () => {
expect((await request(app).post("/api/auth/cookie").send({ cookie: "c" })).status).not.toBe(403);
});
it("quality POST /api/music/quality", async () => {
expect((await request(app).post("/api/music/quality").send({ quality: "high" })).status).not.toBe(403);
});
});
});
// --------------------------------------------------------------------------
// Guest enforcement on the player routes. Guests carry per-flag permissions
// (req.user.guest) instead of capabilities; authorize() opens a route only
// when its guestFlag is set AND enabled. Routes with no guestFlag are denied
// to guests no matter which flags are on. We reuse makeApp() (it injects
// req.user and mounts the real player router over the fake bot manager) and
// assert purely on 403-vs-not-403 — a 200/500 from the fake bot both prove
// the gate let the request through.
// --------------------------------------------------------------------------
const SONG = { id: "1", platform: "netease", name: "x", artist: "y" };
// Build a guest user with all flags off, then override the ones passed in.
const guest = (perms: Partial<Record<string, boolean>> = {}) => ({
id: "__guest__",
username: "游客",
role: "guest" as const,
capabilities: new Set<string>(),
bots: "all" as const,
guest: {
addToQueue: false,
playNext: false,
playNow: false,
skip: false,
transport: false,
removeClear: false,
playMode: false,
...perms,
},
});
const mountGuest = (perms: Partial<Record<string, boolean>> = {}) => makeApp(guest(perms));
describe("guest enforcement on player routes", () => {
it("addToQueue flag gates POST /add, /add-song, /add-by-id", async () => {
const allow = mountGuest({ addToQueue: true });
const deny = mountGuest({ addToQueue: false });
for (const path of ["add", "add-song", "add-by-id"]) {
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/${path}`).send({ song: SONG, songId: "1", query: "x" })).status).not.toBe(403);
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/${path}`).send({ song: SONG, songId: "1", query: "x" })).status).toBe(403);
}
});
it("playNext flag gates /play-next-song", async () => {
expect((await request(mountGuest({ playNext: true })).post(`/api/player/${ALLOWED_BOT}/play-next-song`).send({ song: SONG })).status).not.toBe(403);
expect((await request(mountGuest({})).post(`/api/player/${ALLOWED_BOT}/play-next-song`).send({ song: SONG })).status).toBe(403);
});
it("playNow flag gates the new /play-now-song", async () => {
expect((await request(mountGuest({ playNow: true })).post(`/api/player/${ALLOWED_BOT}/play-now-song`).send({ song: SONG })).status).not.toBe(403);
expect((await request(mountGuest({})).post(`/api/player/${ALLOWED_BOT}/play-now-song`).send({ song: SONG })).status).toBe(403);
// playNext does NOT open play-now-song, and playNow does NOT open play-next-song.
expect((await request(mountGuest({ playNext: true })).post(`/api/player/${ALLOWED_BOT}/play-now-song`).send({ song: SONG })).status).toBe(403);
expect((await request(mountGuest({ playNow: true })).post(`/api/player/${ALLOWED_BOT}/play-next-song`).send({ song: SONG })).status).toBe(403);
});
it("skip flag gates /next", async () => {
expect((await request(mountGuest({ skip: true })).post(`/api/player/${ALLOWED_BOT}/next`)).status).not.toBe(403);
expect((await request(mountGuest({})).post(`/api/player/${ALLOWED_BOT}/next`)).status).toBe(403);
});
it("transport flag gates /pause, /resume, /seek, /volume", async () => {
const allow = mountGuest({ transport: true });
const deny = mountGuest({ transport: false });
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/pause`)).status).not.toBe(403);
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/resume`)).status).not.toBe(403);
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/seek`).send({ position: 0 })).status).not.toBe(403);
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/volume`).send({ volume: 50 })).status).not.toBe(403);
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/pause`)).status).toBe(403);
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/resume`)).status).toBe(403);
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/seek`).send({ position: 0 })).status).toBe(403);
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/volume`).send({ volume: 50 })).status).toBe(403);
});
it("playMode flag gates /mode, /fm", async () => {
const allow = mountGuest({ playMode: true });
const deny = mountGuest({ playMode: false });
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/mode`).send({ mode: "seq" })).status).not.toBe(403);
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/fm`).send({})).status).not.toBe(403);
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/mode`).send({ mode: "seq" })).status).toBe(403);
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/fm`).send({})).status).toBe(403);
});
it("removeClear flag gates /clear and DELETE /queue/:index", async () => {
const allow = mountGuest({ removeClear: true });
const deny = mountGuest({ removeClear: false });
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/clear`)).status).not.toBe(403);
expect((await request(allow).delete(`/api/player/${ALLOWED_BOT}/queue/0`)).status).not.toBe(403);
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/clear`)).status).toBe(403);
expect((await request(deny).delete(`/api/player/${ALLOWED_BOT}/queue/0`)).status).toBe(403);
});
it("each guest flag opens exactly its own route(s) — a single flag does not leak", async () => {
// With only addToQueue on, a transport route stays denied.
expect((await request(mountGuest({ addToQueue: true })).post(`/api/player/${ALLOWED_BOT}/pause`)).status).toBe(403);
// With only transport on, an add route stays denied.
expect((await request(mountGuest({ transport: true })).post(`/api/player/${ALLOWED_BOT}/add-song`).send({ song: SONG })).status).toBe(403);
});
it("guests are always denied /play, /prev, /stop, /play-song, /play-at, /play-playlist, /play-album, /playlist, /profile even with ALL flags on", async () => {
const all = mountGuest({
addToQueue: true,
playNext: true,
playNow: true,
skip: true,
transport: true,
removeClear: true,
playMode: true,
});
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/play`).send({ query: "x" })).status).toBe(403);
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/prev`)).status).toBe(403);
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/stop`)).status).toBe(403);
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/play-song`).send({ song: SONG })).status).toBe(403);
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/play-at`).send({ index: 0 })).status).toBe(403);
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/play-playlist`).send({ playlistId: "1" })).status).toBe(403);
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/play-album`).send({ albumId: "1" })).status).toBe(403);
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/playlist`).send({ playlistId: "1" })).status).toBe(403);
expect((await request(all).put(`/api/player/${ALLOWED_BOT}/profile`).send({})).status).toBe(403);
});
it("members are unaffected — player.queue still reaches /add-song", async () => {
const m = makeApp(member(["player.queue"], [ALLOWED_BOT]));
expect((await request(m).post(`/api/player/${ALLOWED_BOT}/add-song`).send({ song: SONG })).status).not.toBe(403);
});
});
// --------------------------------------------------------------------------
// Favorites are member-only: the router keys everything off req.user.id and
// all guests share the __guest__ principal, so a guest must never reach it.
// server.ts gates the mount with requireNotGuest; we mirror that mount here
// and assert a guest gets 403 (the requireNotGuest guard runs before any
// handler, so the fake database is never touched).
// --------------------------------------------------------------------------
function makeFavoritesApp(user: any) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => { (req as any).user = user; next(); });
const fakeDb = {
getFavorites: () => [],
addFavorite: () => {},
removeFavorite: () => {},
isFavorited: () => false,
} as any;
app.use("/api/favorites", requireNotGuest, createFavoritesRouter(fakeDb, logger));
return app;
}
describe("favorites are denied to guests", () => {
it("403 for a guest on GET /api/favorites", async () => {
const app = makeFavoritesApp(guest());
expect((await request(app).get("/api/favorites")).status).toBe(403);
});
it("403 for a guest on GET /api/favorites/check", async () => {
const app = makeFavoritesApp(guest());
expect((await request(app).get("/api/favorites/check?platform=netease&playlistId=x")).status).toBe(403);
});
it("403 for a guest on POST /api/favorites", async () => {
const app = makeFavoritesApp(guest());
const res = await request(app).post("/api/favorites").send({ platform: "netease", playlistId: "x", name: "n" });
expect(res.status).toBe(403);
});
it("NOT 403 for a member on GET /api/favorites", async () => {
const app = makeFavoritesApp(member([], "all"));
expect((await request(app).get("/api/favorites")).status).not.toBe(403);
});
});
+220 -71
View File
@@ -4,6 +4,8 @@ import type { BotDatabase } from "../../data/database.js";
import type { MusicProvider } from "../../music/provider.js";
import type { Logger } from "../../logger.js";
import { parseCommand } from "../../bot/commands.js";
import { requireBotAccess } from "../middleware/requirePermission.js";
import { authorize } from "../middleware/authorize.js";
export function createPlayerRouter(
botManager: BotManager,
@@ -15,6 +17,13 @@ export function createPlayerRouter(
): Router {
const router = Router();
// Access check runs BEFORE the existence/resolver check so a member who is
// not allowed a bot always gets a uniform 403 — whether or not the bot
// exists — instead of a 404 that would leak which bot IDs are real.
// requireBotAccess only needs req.params.botId and req.user (set by the
// global requireAuth mounted earlier), so it works before the resolver.
router.use("/:botId", requireBotAccess("botId"));
router.use("/:botId", (req, res, next) => {
const bot = botManager.getBot(req.params.botId);
if (!bot) {
@@ -33,7 +42,7 @@ export function createPlayerRouter(
return "";
};
router.post("/:botId/play", async (req, res) => {
router.post("/:botId/play", authorize({ capability: "player.control" }), async (req, res) => {
try {
const bot = (req as any).bot;
const { query, platform } = req.body;
@@ -53,7 +62,7 @@ export function createPlayerRouter(
}
});
router.post("/:botId/add", async (req, res) => {
router.post("/:botId/add", authorize({ capability: "player.queue", guestFlag: "addToQueue" }), async (req, res) => {
try {
const bot = (req as any).bot;
const { query, platform } = req.body;
@@ -80,14 +89,36 @@ export function createPlayerRouter(
}
};
router.post("/:botId/pause", simpleCommand("!pause"));
router.post("/:botId/resume", simpleCommand("!resume"));
router.post("/:botId/next", simpleCommand("!next"));
router.post("/:botId/prev", simpleCommand("!prev"));
router.post("/:botId/stop", simpleCommand("!stop"));
router.post("/:botId/clear", simpleCommand("!clear"));
router.post("/:botId/pause", authorize({ capability: "player.control", guestFlag: "transport" }), simpleCommand("!pause"));
router.post("/:botId/resume", authorize({ capability: "player.control", guestFlag: "transport" }), simpleCommand("!resume"));
router.post("/:botId/next", authorize({ capability: "player.control", guestFlag: "skip" }), simpleCommand("!next"));
router.post("/:botId/prev", authorize({ capability: "player.control" }), simpleCommand("!prev"));
router.post("/:botId/stop", authorize({ capability: "player.control" }), simpleCommand("!stop"));
router.post("/:botId/clear", authorize({ capability: "player.queue", guestFlag: "removeClear" }), simpleCommand("!clear"));
router.post("/:botId/volume", async (req, res) => {
router.post("/:botId/fm", authorize({ capability: "player.control", guestFlag: "playMode" }), async (req, res) => {
try {
const bot = (req as any).bot;
const { platform } = req.body;
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube"
? platform
: "netease"
);
const message = await bot.startFm(provider);
res.json({
ok:
!message.startsWith("No FM songs") &&
!message.includes("not available") &&
!message.includes("not connected"),
message,
});
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.post("/:botId/volume", authorize({ capability: "player.control", guestFlag: "transport" }), async (req, res) => {
try {
const bot = (req as any).bot;
const { volume } = req.body;
@@ -115,7 +146,7 @@ export function createPlayerRouter(
const VALID_MODES = new Set(["seq", "loop", "random", "rloop"]);
router.post("/:botId/mode", async (req, res) => {
router.post("/:botId/mode", authorize({ capability: "player.control", guestFlag: "playMode" }), async (req, res) => {
try {
const bot = (req as any).bot;
const { mode } = req.body;
@@ -140,7 +171,7 @@ export function createPlayerRouter(
});
// Seek to position
router.post("/:botId/seek", async (req, res) => {
router.post("/:botId/seek", authorize({ capability: "player.control", guestFlag: "transport" }), async (req, res) => {
try {
const bot = (req as any).bot;
const { position } = req.body; // seconds
@@ -164,7 +195,7 @@ export function createPlayerRouter(
res.json({ queue: bot.getQueue(), status: bot.getStatus() });
});
router.delete("/:botId/queue/:index", async (req, res) => {
router.delete("/:botId/queue/:index", authorize({ capability: "player.queue", guestFlag: "removeClear" }), async (req, res) => {
try {
const bot = (req as any).bot;
const cmd = parseCommand(`!remove ${req.params.index}`, "!")!;
@@ -176,7 +207,7 @@ export function createPlayerRouter(
});
// Jump to a specific index in the queue (without clearing it)
router.post("/:botId/play-at", async (req, res) => {
router.post("/:botId/play-at", authorize({ capability: "player.control" }), async (req, res) => {
try {
const bot = (req as any).bot;
const { index } = req.body;
@@ -184,33 +215,40 @@ export function createPlayerRouter(
res.status(400).json({ error: "index is required" });
return;
}
const queue = bot.getQueueManager();
// Validate the index BEFORE stopping current playback — otherwise an
// invalid index silently kills the user's current song and leaves the
// queue idle.
if (index >= queue.size()) {
res.status(400).json({ error: "Invalid queue index" });
// Serialize the index-validation + stop/reset/playAt/resolveAndPlay so a
// concurrent request can't interleave between mutating the queue and
// starting playback (audible track must match queue.currentIndex).
const result = await bot.runExclusive(async () => {
const queue = bot.getQueueManager();
// Validate the index BEFORE stopping current playback — otherwise an
// invalid index silently kills the user's current song and leaves the
// queue idle.
if (index >= queue.size()) {
return { status: 400 as const, body: { error: "Invalid queue index" } };
}
bot.getPlayer().stop();
bot.getPlayer().resetFailures();
const song = queue.playAt(index);
if (!song) {
return { status: 400 as const, body: { error: "Invalid queue index" } };
}
const ok = await bot.resolveAndPlay(song);
if (!ok) {
return { body: { message: `Cannot play: ${song.name}` } };
}
return { body: { message: `Now playing: ${song.name} - ${song.artist}` } };
});
if (result.status) {
res.status(result.status).json(result.body);
return;
}
bot.getPlayer().stop();
bot.getPlayer().resetFailures();
const song = queue.playAt(index);
if (!song) {
res.status(400).json({ error: "Invalid queue index" });
return;
}
const ok = await bot.resolveAndPlay(song);
if (!ok) {
res.json({ message: `Cannot play: ${song.name}` });
return;
}
res.json({ message: `Now playing: ${song.name} - ${song.artist}` });
res.json(result.body);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.post("/:botId/playlist", async (req, res) => {
router.post("/:botId/playlist", authorize({ capability: "player.queue" }), async (req, res) => {
try {
const bot = (req as any).bot;
const { playlistId, platform } = req.body;
@@ -227,7 +265,7 @@ export function createPlayerRouter(
// Play a playlist by ID — stores metadata only, resolves URL for first song
// Respects current play mode (random = pick random first song)
router.post("/:botId/play-playlist", async (req, res) => {
router.post("/:botId/play-playlist", authorize({ capability: "player.control" }), async (req, res) => {
try {
const bot = (req as any).bot;
const { playlistId, platform } = req.body;
@@ -313,8 +351,80 @@ export function createPlayerRouter(
}
});
// Play an album by ID — mirrors play-playlist but calls getAlbumSongs
router.post("/:botId/play-album", authorize({ capability: "player.control" }), async (req, res) => {
try {
const bot = (req as any).bot;
const { albumId, platform } = req.body;
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube"
? platform
: "netease"
);
// Stop current playback
bot.getPlayer().stop();
bot.getPlayer().resetFailures();
const songs = await provider.getAlbumSongs(albumId);
if (songs.length === 0) {
res.json({ message: "Album is empty" });
return;
}
// QQ-specific optimization: batch-resolve playable IDs to avoid
// wasting retries on region/copyright-restricted tracks.
let queueable: { id: string }[] = songs;
const totalCount = songs.length;
const qqLike = provider as { getPlayableSongIds?: (ids: string[]) => Promise<Set<string> | null> };
if (typeof qqLike.getPlayableSongIds === "function") {
const playable = await qqLike.getPlayableSongIds(songs.map((s: { id: string }) => s.id));
if (playable !== null) {
queueable = songs.filter((s: { id: string }) => playable.has(s.id));
}
}
if (queueable.length === 0) {
res.json({ ok: false, message: `专辑 ${totalCount} 首歌曲均无版权可播放(区域/版权限制)` });
return;
}
const queue = bot.getQueueManager();
queue.clear();
for (const song of queueable) {
queue.add({ ...song, platform: provider.platform });
}
const mode = queue.getMode();
let first;
if (mode === "random" || mode === "rloop") {
const idx = Math.floor(Math.random() * queue.size());
first = queue.playAt(idx);
} else {
first = queue.play();
}
let started = first ? await bot.resolveAndPlay(first) : false;
if (first && !started) {
started = await bot.playNext(20);
}
const playing = queue.current();
const loadedMsg = queueable.length < totalCount
? `已加载 ${queueable.length}/${totalCount} 首(其余区域/版权限制)`
: `已加载 ${queueable.length} 首`;
if (started && playing) {
res.json({ ok: true, message: `${loadedMsg},正在播放:${playing.name}` });
} else {
res.json({ ok: false, message: `${loadedMsg},但无法开始播放。` });
}
} catch (err) {
logger.error({ err }, "play-album failed");
res.status(500).json({ error: (err as Error).message });
}
});
// Play a single song by ID — resolves URL on demand
router.post("/:botId/play-song", async (req, res) => {
router.post("/:botId/play-song", authorize({ capability: "player.control" }), async (req, res) => {
try {
const bot = (req as any).bot;
const { song } = req.body;
@@ -342,7 +452,7 @@ export function createPlayerRouter(
// Insert a single song to play right after the current one.
// If nothing is playing, behaves like /play-song (start immediately).
router.post("/:botId/play-next-song", async (req, res) => {
router.post("/:botId/play-next-song", authorize({ capability: "player.control", guestFlag: "playNext" }), async (req, res) => {
try {
const bot = (req as any).bot;
const { song } = req.body;
@@ -350,37 +460,43 @@ export function createPlayerRouter(
res.status(400).json({ error: "song object with id and platform is required" });
return;
}
const queue = bot.getQueueManager();
const wasIdle = bot.getPlayer().getState() === "idle";
// Capture the slot addNext WILL insert at, before mutating the queue.
// addNext pushes when currentIndex<0 (slot = size); otherwise splices
// at currentIndex+1. Using size-1 after addNext was wrong when the
// queue had stale currentIndex>=0 while the player was idle (e.g.,
// after natural track end without queue.clear()).
const insertedAt =
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
queue.addNext(song);
// Serialize the queue mutation + playback so concurrent requests can't
// interleave (audible track must match queue.currentIndex).
const body = await bot.runExclusive(async () => {
const queue = bot.getQueueManager();
const wasIdle = bot.getPlayer().getState() === "idle";
// Capture the slot addNext WILL insert at, before mutating the queue.
// addNext pushes when currentIndex<0 (slot = size); otherwise splices
// at currentIndex+1. Using size-1 after addNext was wrong when the
// queue had stale currentIndex>=0 while the player was idle (e.g.,
// after natural track end without queue.clear()).
const insertedAt =
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
queue.addNext(song);
if (wasIdle) {
// Promote the just-added song to current and start it.
queue.playAt(insertedAt);
bot.getPlayer().resetFailures();
const ok = await bot.resolveAndPlay(queue.current()!);
if (!ok) {
res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` });
return;
if (wasIdle) {
// Promote the just-added song to current and start it.
queue.playAt(insertedAt);
bot.getPlayer().resetFailures();
const ok = await bot.resolveAndPlay(queue.current()!);
if (!ok) {
return { ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` };
}
return { ok: true, message: `正在播放:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` };
}
res.json({ ok: true, message: `正在播放:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
return;
}
res.json({ ok: true, message: `已加入下一首:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
return { ok: true, message: `已加入下一首:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` };
});
res.json(body);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.post("/:botId/add-song", async (req, res) => {
// Play a song "now" without clearing the queue: insert after current, then
// promote to current and start it. Non-destructive (unlike /play-song which
// clears the whole queue) — this is the guest-safe "play now".
router.post("/:botId/play-now-song", authorize({ capability: "player.control", guestFlag: "playNow" }), async (req, res) => {
try {
const bot = (req as any).bot;
const { song } = req.body;
@@ -388,27 +504,60 @@ export function createPlayerRouter(
res.status(400).json({ error: "song object with id and platform is required" });
return;
}
const queue = bot.getQueueManager();
const wasIdle = bot.getPlayer().getState() === "idle";
queue.add(song);
// If nothing was playing, start this newly-added song immediately.
if (wasIdle) {
queue.playAt(queue.size() - 1);
// Serialize the insert-after-current + promote + playback so concurrent
// requests can't interleave (audible track must match queue.currentIndex).
const body = await bot.runExclusive(async () => {
const queue = bot.getQueueManager();
const insertedAt =
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
queue.addNext(song);
queue.playAt(insertedAt);
bot.getPlayer().resetFailures();
await bot.resolveAndPlay(queue.current()!);
res.json({ message: `Now playing: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
const ok = await bot.resolveAndPlay(queue.current()!);
if (!ok) {
return { ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` };
}
return { ok: true, message: `正在播放:${song.name || "Unknown"} - ${song.artist || "Unknown"}` };
});
res.json(body);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.post("/:botId/add-song", authorize({ capability: "player.queue", guestFlag: "addToQueue" }), async (req, res) => {
try {
const bot = (req as any).bot;
const { song } = req.body;
if (!song || !song.id || !song.platform) {
res.status(400).json({ error: "song object with id and platform is required" });
return;
}
// Serialize the queue mutation + (possible) playback so concurrent
// requests can't interleave (audible track must match queue.currentIndex).
const body = await bot.runExclusive(async () => {
const queue = bot.getQueueManager();
const wasIdle = bot.getPlayer().getState() === "idle";
queue.add(song);
res.json({ message: `Added to queue: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'} (position ${queue.size()})` });
// If nothing was playing, start this newly-added song immediately.
if (wasIdle) {
queue.playAt(queue.size() - 1);
bot.getPlayer().resetFailures();
await bot.resolveAndPlay(queue.current()!);
return { message: `Now playing: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` };
}
return { message: `Added to queue: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'} (position ${queue.size()})` };
});
res.json(body);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// Add a song to queue by ID — metadata only
router.post("/:botId/add-by-id", async (req, res) => {
router.post("/:botId/add-by-id", authorize({ capability: "player.queue", guestFlag: "addToQueue" }), async (req, res) => {
try {
const bot = (req as any).bot;
const { songId, platform } = req.body;
@@ -446,7 +595,7 @@ export function createPlayerRouter(
res.json(bot.getProfileManager().getConfig());
});
router.put("/:botId/profile", (req, res) => {
router.put("/:botId/profile", authorize({ capability: "bot.manage" }), (req, res) => {
try {
const bot = (req as any).bot;
const pm = bot.getProfileManager();
+272
View File
@@ -0,0 +1,272 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import pino from "pino";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore, type UserStore } from "../../data/users.js";
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
import { createAuditStore } from "../../data/audit.js";
import { createPermissionStore } from "../../data/permissions.js";
import { getDefaultConfig, type GuestModeConfig } from "../../data/config.js";
import type { GuestPermissions, BotAccess } from "../../data/permissions.js";
import { createSessionRouter } from "./session.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
const app = express();
app.use(express.json());
app.use(cookieParser());
const audit = createAuditStore(botDb.db);
const permissions = createPermissionStore(botDb.db);
app.use(
"/api/session",
createSessionRouter(
users,
sessions,
audit,
pino({ level: "silent" }),
permissions,
() => getDefaultConfig().guestMode
)
);
return app;
}
function extractCookie(res: request.Response): string {
const header = res.headers["set-cookie"];
const arr = Array.isArray(header) ? header : header ? [header] : [];
const found = arr.find((c) => c.startsWith(`${SESSION_COOKIE_NAME}=`));
if (!found) throw new Error("no session cookie set");
return found.split(";")[0]; // "tsmb_session=xxxx"
}
describe("session router", () => {
let botDb: BotDatabase;
let users: UserStore;
let sessions: SessionStore;
let app: express.Express;
beforeEach(() => {
botDb = createDatabase(":memory:");
users = createUserStore(botDb.db);
sessions = createSessionStore(botDb.db);
app = makeApp(botDb, users, sessions);
});
afterEach(() => botDb.close());
it("GET /needs-setup returns true on an empty db", async () => {
const res = await request(app).get("/api/session/needs-setup");
expect(res.status).toBe(200);
expect(res.body).toEqual({ needsSetup: true, guestAllowed: false });
});
it("POST /setup creates the first admin, logs them in, and returns false from /needs-setup afterwards", async () => {
const setupRes = await request(app)
.post("/api/session/setup")
.send({ username: "alice", password: "hunter2-hunter2" });
expect(setupRes.status).toBe(200);
expect(setupRes.body.username).toBe("alice");
extractCookie(setupRes);
const needs = await request(app).get("/api/session/needs-setup");
expect(needs.body).toEqual({ needsSetup: false, guestAllowed: false });
});
it("POST /setup returns 409 once a user already exists", async () => {
await users.createUser("admin", "pw-admin-pw", "admin");
const res = await request(app)
.post("/api/session/setup")
.send({ username: "alice", password: "pw" });
expect(res.status).toBe(409);
expect(res.body).toEqual({ error: "already initialized" });
});
it("POST /login returns 401 with constant-time delay on bad credentials", async () => {
await users.createUser("alice", "correct-pw-pw", "admin");
const start = Date.now();
const res = await request(app)
.post("/api/session/login")
.send({ username: "alice", password: "wrong" });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "invalid credentials" });
expect(Date.now() - start).toBeGreaterThanOrEqual(200);
}, 10_000);
it("POST /login sets a session cookie on success", async () => {
await users.createUser("alice", "pw-alice", "admin");
const res = await request(app)
.post("/api/session/login")
.send({ username: "alice", password: "pw-alice" });
expect(res.status).toBe(200);
expect(res.body.username).toBe("alice");
extractCookie(res);
});
it("GET /me returns the current user when cookie is present, 401 otherwise", async () => {
await users.createUser("alice", "pw-alice", "admin");
const loginRes = await request(app)
.post("/api/session/login")
.send({ username: "alice", password: "pw-alice" });
const cookie = extractCookie(loginRes);
const me = await request(app).get("/api/session/me").set("Cookie", cookie);
expect(me.status).toBe(200);
expect(me.body.username).toBe("alice");
// alice is the first user (an admin), so /me exposes all capabilities and full bot access.
expect(Array.isArray(me.body.capabilities)).toBe(true);
expect(me.body.capabilities).toEqual(
expect.arrayContaining(["player.control", "player.queue", "bot.manage", "platform.auth", "quality"])
);
expect(me.body.bots).toBe("all");
const anon = await request(app).get("/api/session/me");
expect(anon.status).toBe(401);
});
it("POST /logout deletes the session and clears the cookie", async () => {
await users.createUser("alice", "pw-alice", "admin");
const loginRes = await request(app)
.post("/api/session/login")
.send({ username: "alice", password: "pw-alice" });
const cookie = extractCookie(loginRes);
const logout = await request(app).post("/api/session/logout").set("Cookie", cookie);
expect(logout.status).toBe(204);
const me = await request(app).get("/api/session/me").set("Cookie", cookie);
expect(me.status).toBe(401);
});
it("POST /change-password requires old password and invalidates other sessions", async () => {
const u = await users.createUser("alice", "old-pw-pw", "admin");
const cookieA = extractCookie(
await request(app).post("/api/session/login").send({ username: "alice", password: "old-pw-pw" })
);
const cookieB = extractCookie(
await request(app).post("/api/session/login").send({ username: "alice", password: "old-pw-pw" })
);
const wrongOld = await request(app)
.post("/api/session/change-password")
.set("Cookie", cookieA)
.send({ oldPassword: "WRONG", newPassword: "newpassword" });
expect(wrongOld.status).toBe(401);
const ok = await request(app)
.post("/api/session/change-password")
.set("Cookie", cookieA)
.send({ oldPassword: "old-pw-pw", newPassword: "newpassword" });
expect(ok.status).toBe(204);
const meA = await request(app).get("/api/session/me").set("Cookie", cookieA);
expect(meA.status).toBe(200);
const meB = await request(app).get("/api/session/me").set("Cookie", cookieB);
expect(meB.status).toBe(401);
expect(u.id).toBe(meA.body.id);
});
});
describe("session router — guest mode", () => {
let botDb: BotDatabase;
afterEach(() => botDb.close());
function makeApp(opts: {
guestEnabled: boolean;
guestPermissions?: GuestPermissions;
guestBots?: BotAccess;
}) {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const audit = createAuditStore(botDb.db);
const permissions = createPermissionStore(botDb.db);
const guestCfg: GuestModeConfig = {
enabled: opts.guestEnabled,
bots: opts.guestBots ?? getDefaultConfig().guestMode.bots,
permissions: opts.guestPermissions ?? getDefaultConfig().guestMode.permissions,
};
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(
"/api/session",
createSessionRouter(users, sessions, audit, pino({ level: "silent" }), permissions, () => guestCfg)
);
return { app, users, sessions };
}
it("POST /guest is 403 when guest mode disabled", async () => {
const { app } = makeApp({ guestEnabled: false });
const res = await request(app).post("/api/session/guest");
expect(res.status).toBe(403);
});
it("POST /guest mints a guest session when enabled, and /me reports role guest + flags", async () => {
const { app } = makeApp({
guestEnabled: true,
guestPermissions: {
addToQueue: true,
playNext: true,
playNow: false,
skip: false,
transport: false,
removeClear: false,
playMode: false,
},
guestBots: "all",
});
const login = await request(app).post("/api/session/guest");
expect(login.status).toBe(200);
expect(login.body.role).toBe("guest");
const cookie = login.headers["set-cookie"];
const me = await request(app).get("/api/session/me").set("Cookie", cookie);
expect(me.body.role).toBe("guest");
expect(me.body.guest.addToQueue).toBe(true);
expect(me.body.guest.playNext).toBe(true);
expect(me.body.capabilities).toEqual([]);
});
it("GET /needs-setup exposes guestAllowed", async () => {
const { app } = makeApp({ guestEnabled: true });
const res = await request(app).get("/api/session/needs-setup");
expect(res.body.guestAllowed).toBe(true);
});
it("GET /me returns 401 for a guest session once guest mode is disabled", async () => {
// Build an app whose guest config can be toggled at runtime, mirroring an
// admin flipping the setting mid-session (requireAuthInline must reject).
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const audit = createAuditStore(botDb.db);
const permissions = createPermissionStore(botDb.db);
const guestCfg: GuestModeConfig = {
enabled: true,
bots: getDefaultConfig().guestMode.bots,
permissions: getDefaultConfig().guestMode.permissions,
};
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(
"/api/session",
createSessionRouter(users, sessions, audit, pino({ level: "silent" }), permissions, () => guestCfg)
);
const login = await request(app).post("/api/session/guest");
expect(login.status).toBe(200);
const cookie = login.headers["set-cookie"];
// While enabled, /me works for the guest.
expect((await request(app).get("/api/session/me").set("Cookie", cookie)).status).toBe(200);
// Admin disables guest mode → the in-flight guest session is now invalid.
guestCfg.enabled = false;
expect((await request(app).get("/api/session/me").set("Cookie", cookie)).status).toBe(401);
});
});
+218
View File
@@ -0,0 +1,218 @@
import { Router } from "express";
import type { Request, Response, NextFunction } from "express";
import type { Logger } from "../../logger.js";
import type { UserStore } from "../../data/users.js";
import type { SessionStore } from "../../data/sessions.js";
import type { AuditStore } from "../../data/audit.js";
import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js";
import { SESSION_TTL_MS, GUEST_SESSION_TTL_MS } from "../../data/sessions.js";
import { GUEST_USER_ID, GUEST_USERNAME } from "../../data/users.js";
import type { GuestModeConfig } from "../../data/config.js";
import { SESSION_COOKIE_NAME, validateSessionFromHeaders, extractSessionToken } from "../auth/validateSession.js";
const FAILED_LOGIN_DELAY_MS = 250;
function setSessionCookie(res: Response, token: string): void {
res.cookie(SESSION_COOKIE_NAME, token, {
httpOnly: true,
sameSite: "lax",
secure: res.req.secure,
path: "/",
maxAge: SESSION_TTL_MS,
});
}
function clearSessionCookie(res: Response): void {
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isValidUsername(v: unknown): v is string {
return typeof v === "string" && /^[A-Za-z0-9_\-.]{3,32}$/.test(v);
}
function isValidPassword(v: unknown): v is string {
return typeof v === "string" && v.length >= 8 && v.length <= 200;
}
function parseTokenFromCookie(cookieHeader: string | undefined): string | null {
if (!cookieHeader) return null;
const match = cookieHeader
.split(";")
.map((p) => p.trim())
.find((p) => p.startsWith(`${SESSION_COOKIE_NAME}=`));
if (!match) return null;
return decodeURIComponent(match.slice(SESSION_COOKIE_NAME.length + 1));
}
export function createSessionRouter(
users: UserStore,
sessions: SessionStore,
audit: AuditStore,
logger: Logger,
permissions: PermissionStore,
getGuestConfig: () => GuestModeConfig
): Router {
const router = Router();
const requireAuthInline = (req: Request, res: Response, next: NextFunction) => {
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
if (!result) {
clearSessionCookie(res);
res.status(401).json({ error: "unauthenticated" });
return;
}
// A guest session is only valid while guest mode is enabled. Disabling it
// immediately invalidates any in-flight guest sessions (mirrors createRequireAuth).
if (result.role === "guest" && !getGuestConfig().enabled) {
clearSessionCookie(res);
res.status(401).json({ error: "unauthenticated" });
return;
}
req.user = { id: result.userId, username: result.username, role: result.role };
const token = extractSessionToken(req.headers.cookie);
if (token) setSessionCookie(res, token);
next();
};
router.get("/needs-setup", (_req, res) => {
res.json({ needsSetup: users.countUsers() === 0, guestAllowed: getGuestConfig().enabled });
});
router.post("/setup", async (req, res) => {
const { username, password } = req.body ?? {};
if (users.countUsers() !== 0) {
res.status(409).json({ error: "already initialized" });
return;
}
if (!isValidUsername(username) || !isValidPassword(password)) {
res.status(400).json({ error: "invalid username or password" });
return;
}
try {
const user = await users.createFirstUser(username, password);
if (!user) {
res.status(409).json({ error: "already initialized" });
return;
}
const { token } = sessions.createSession(user.id);
setSessionCookie(res, token);
try {
audit.record({
actorId: user.id, actorUsername: user.username,
targetUserId: user.id, targetUsername: user.username,
action: "admin.first_created",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "admin.first_created" }, "audit insert failed");
}
logger.info({ userId: user.id, username }, "First admin created");
res.json({ id: user.id, username: user.username, role: user.role });
} catch (err) {
logger.error({ err }, "setup failed");
res.status(500).json({ error: "internal" });
}
});
router.post("/login", async (req, res) => {
const { username, password } = req.body ?? {};
if (typeof username !== "string" || typeof password !== "string") {
res.status(400).json({ error: "invalid request" });
return;
}
const user = users.findByUsername(username);
const ok = user ? await users.verifyPassword(password, user.passwordHash) : false;
if (!user || !ok) {
await delay(FAILED_LOGIN_DELAY_MS);
res.status(401).json({ error: "invalid credentials" });
return;
}
const { token } = sessions.createSession(user.id);
setSessionCookie(res, token);
res.json({ id: user.id, username: user.username, role: user.role });
});
router.post("/guest", (_req, res) => {
const cfg = getGuestConfig();
if (!cfg.enabled) {
res.status(403).json({ error: "guest mode disabled" });
return;
}
let token: string;
try {
// If the reserved guest row is somehow missing, the session FK would
// throw; surface a clean 503 rather than letting it become a 500.
({ token } = sessions.createSession(GUEST_USER_ID, { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true }));
} catch (err) {
logger.error({ err }, "guest session creation failed");
res.status(503).json({ error: "guest unavailable" });
return;
}
setSessionCookie(res, token);
res.json({ id: GUEST_USER_ID, username: GUEST_USERNAME, role: "guest" });
});
router.post("/logout", (req, res) => {
const token = parseTokenFromCookie(req.headers.cookie);
if (token) {
sessions.deleteSession(token);
}
clearSessionCookie(res);
res.status(204).end();
});
router.get("/me", requireAuthInline, (req, res) => {
const user = req.user!;
const cfg = getGuestConfig();
const ctx = resolvePermissionContext(
user.role,
user.id,
permissions,
user.role === "guest" ? { bots: cfg.bots, permissions: cfg.permissions } : undefined
);
res.json({
id: user.id,
username: user.username,
role: user.role,
capabilities: [...ctx.capabilities],
bots: ctx.bots === "all" ? "all" : [...ctx.bots],
guest: ctx.guest ?? null,
});
});
router.post("/change-password", requireAuthInline, async (req, res) => {
const { oldPassword, newPassword } = req.body ?? {};
if (typeof oldPassword !== "string") {
res.status(400).json({ error: "invalid request" });
return;
}
const u = users.findById(req.user!.id);
if (!u || !(await users.verifyPassword(oldPassword, u.passwordHash))) {
await delay(FAILED_LOGIN_DELAY_MS);
res.status(401).json({ error: "invalid credentials" });
return;
}
if (!isValidPassword(newPassword)) {
res.status(400).json({ error: "invalid request" });
return;
}
await users.changePassword(u.id, newPassword);
const currentToken = parseTokenFromCookie(req.headers.cookie);
sessions.deleteAllForUser(u.id, currentToken ?? undefined);
try {
audit.record({
actorId: u.id, actorUsername: u.username,
targetUserId: u.id, targetUsername: u.username,
action: "user.password_changed",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "user.password_changed" }, "audit insert failed");
}
res.status(204).end();
});
return router;
}
+390
View File
@@ -0,0 +1,390 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import pino from "pino";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore, GUEST_USER_ID, type UserStore } from "../../data/users.js";
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
import { createAuditStore, type AuditStore } from "../../data/audit.js";
import { createPermissionStore, type PermissionStore } from "../../data/permissions.js";
import { getDefaultConfig } from "../../data/config.js";
import { createRequireAuth } from "../middleware/requireAuth.js";
import { createUsersRouter } from "./users.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
const app = express();
app.use(express.json());
app.use(cookieParser());
const permissions = createPermissionStore(botDb.db);
const requireAuth = createRequireAuth(sessions, permissions, () => getDefaultConfig().guestMode);
const audit = createAuditStore(botDb.db);
app.use("/api", requireAuth);
app.use("/api/users", createUsersRouter(users, sessions, audit, pino({ level: "silent" }), permissions));
return { app, permissions, audit };
}
describe("users router", () => {
let botDb: BotDatabase;
let users: UserStore;
let sessions: SessionStore;
let app: express.Express;
let permissions: PermissionStore;
let audit: AuditStore;
let aliceId: string;
let aliceCookie: string;
let bobId: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
users = createUserStore(botDb.db);
sessions = createSessionStore(botDb.db);
({ app, permissions, audit } = makeApp(botDb, users, sessions));
const alice = await users.createUser("alice", "pw-alice", "admin");
aliceId = alice.id;
aliceCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
const bob = await users.createUser("bob", "pw-bob-bob", "member");
bobId = bob.id;
});
afterEach(() => botDb.close());
it("requires auth for all routes", async () => {
expect((await request(app).get("/api/users")).status).toBe(401);
expect((await request(app).post("/api/users").send({ username: "x", password: "yyyyyyyy" })).status).toBe(401);
expect((await request(app).delete(`/api/users/${bobId}`)).status).toBe(401);
});
it("GET / lists users with id+username+createdAt, no password hash", async () => {
const res = await request(app).get("/api/users").set("Cookie", aliceCookie);
expect(res.status).toBe(200);
expect(res.body.users).toHaveLength(2);
for (const u of res.body.users) {
expect(u).toHaveProperty("id");
expect(u).toHaveProperty("username");
expect(u).toHaveProperty("createdAt");
expect(u).not.toHaveProperty("passwordHash");
}
});
it("POST / creates a user", async () => {
const res = await request(app)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "charlie", password: "charlie-pw" });
expect(res.status).toBe(201);
expect(res.body.username).toBe("charlie");
expect(users.countUsers()).toBe(3);
});
it("POST / returns 409 on duplicate username", async () => {
const res = await request(app)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "BOB", password: "another-pw" });
expect(res.status).toBe(409);
});
it("POST / returns 400 on invalid input", async () => {
const res = await request(app)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "x", password: "short" });
expect(res.status).toBe(400);
});
it("DELETE /:id removes the user and their sessions", async () => {
const bobToken = sessions.createSession(bobId).token;
const res = await request(app).delete(`/api/users/${bobId}`).set("Cookie", aliceCookie);
expect(res.status).toBe(204);
expect(users.countUsers()).toBe(1);
expect(sessions.validateAndTouch(bobToken)).toBeNull();
});
it("DELETE /:id of self returns 400", async () => {
const res = await request(app).delete(`/api/users/${aliceId}`).set("Cookie", aliceCookie);
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: "cannot delete self" });
expect(users.countUsers()).toBe(2);
});
it("DELETE /:id of nonexistent returns 404", async () => {
const res = await request(app).delete(`/api/users/not-a-real-id`).set("Cookie", aliceCookie);
expect(res.status).toBe(404);
});
it("POST /:id/reset-password updates the hash and invalidates target's sessions", async () => {
const bobToken = sessions.createSession(bobId).token;
const res = await request(app)
.post(`/api/users/${bobId}/reset-password`)
.set("Cookie", aliceCookie)
.send({ newPassword: "bob-new-pw" });
expect(res.status).toBe(204);
expect(sessions.validateAndTouch(bobToken)).toBeNull();
const bob = users.findByUsername("bob");
expect(await users.verifyPassword("bob-new-pw", bob!.passwordHash)).toBe(true);
expect(await users.verifyPassword("pw-bob-bob", bob!.passwordHash)).toBe(false);
});
it("POST /:id/reset-password 404 on unknown user", async () => {
const res = await request(app)
.post(`/api/users/not-a-real-id/reset-password`)
.set("Cookie", aliceCookie)
.send({ newPassword: "anything-here" });
expect(res.status).toBe(404);
});
it("POST /:id/reset-password 400 on short password", async () => {
const res = await request(app)
.post(`/api/users/${bobId}/reset-password`)
.set("Cookie", aliceCookie)
.send({ newPassword: "short" });
expect(res.status).toBe(400);
});
it("returns 201 even if audit insert fails (POST /api/users)", async () => {
// Build a broken audit store that throws on record()
const brokenAudit = {
record: () => { throw new Error("simulated disk-full"); },
list: () => [],
};
// Reassemble app with the broken audit
const localApp = express();
localApp.use(express.json());
localApp.use(cookieParser());
localApp.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
localApp.use(
"/api/users",
createUsersRouter(users, sessions, brokenAudit, pino({ level: "silent" }), createPermissionStore(botDb.db))
);
const res = await request(localApp)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "charlie", password: "charlie-pw" });
expect(res.status).toBe(201);
expect(users.countUsers()).toBe(3);
});
it("POST /:id/reset-password on self preserves the actor's current session", async () => {
// Alice resets her OWN password
const res = await request(app)
.post(`/api/users/${aliceId}/reset-password`)
.set("Cookie", aliceCookie)
.send({ newPassword: "alice-new-pw" });
expect(res.status).toBe(204);
// Alice's CURRENT session should still work
// (we'd need a protected endpoint to verify; use GET /api/users which is already mounted)
const followUp = await request(app).get("/api/users").set("Cookie", aliceCookie);
expect(followUp.status).toBe(200);
// The password hash IS updated (sanity check)
const alice = users.findById(aliceId);
expect(await users.verifyPassword("alice-new-pw", alice!.passwordHash)).toBe(true);
});
it("POST /:id/reset-password on another user does NOT preserve any of target's sessions", async () => {
const bobToken = sessions.createSession(bobId).token;
const res = await request(app)
.post(`/api/users/${bobId}/reset-password`)
.set("Cookie", aliceCookie)
.send({ newPassword: "bob-new-pw" });
expect(res.status).toBe(204);
// Bob's session should be dead
expect(sessions.validateAndTouch(bobToken)).toBeNull();
});
it("POST / defaults new user to role=member when role omitted", async () => {
const res = await request(app)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "carol", password: "pw-carol-pw" });
expect(res.status).toBe(201);
expect(res.body.role).toBe("member");
});
it("POST / accepts role=admin", async () => {
const res = await request(app)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "carol", password: "pw-carol-pw", role: "admin" });
expect(res.status).toBe(201);
expect(res.body.role).toBe("admin");
expect(users.countAdmins()).toBe(2);
});
it("PATCH /:id/role can change role between admin and member", async () => {
const res = await request(app)
.patch(`/api/users/${bobId}/role`)
.set("Cookie", aliceCookie)
.send({ role: "admin" });
expect(res.status).toBe(204);
expect(users.findById(bobId)!.role).toBe("admin");
});
it("PATCH /:id/role blocks demoting the last admin", async () => {
// alice is the only admin. Demoting her would leave 0 admins. Block.
const res = await request(app)
.patch(`/api/users/${aliceId}/role`)
.set("Cookie", aliceCookie)
.send({ role: "member" });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: "cannot demote last admin" });
});
it("PATCH /:id/role allows demoting an admin when other admins exist", async () => {
// Promote bob first
users.setRole(bobId, "admin");
// Now both are admins. Demoting alice should work.
const res = await request(app)
.patch(`/api/users/${aliceId}/role`)
.set("Cookie", aliceCookie)
.send({ role: "member" });
expect(res.status).toBe(204);
});
it("PATCH /:id/role 400 on invalid role", async () => {
const res = await request(app)
.patch(`/api/users/${bobId}/role`)
.set("Cookie", aliceCookie)
.send({ role: "superuser" });
expect(res.status).toBe(400);
});
it("PATCH /:id/role 404 on unknown user", async () => {
const res = await request(app)
.patch(`/api/users/not-a-real-id/role`)
.set("Cookie", aliceCookie)
.send({ role: "admin" });
expect(res.status).toBe(404);
});
it("GET /:id/permissions returns empty arrays for a fresh member", async () => {
const res = await request(app)
.get(`/api/users/${bobId}/permissions`)
.set("Cookie", aliceCookie);
expect(res.status).toBe(200);
expect(res.body).toEqual({ capabilities: [], bots: [] });
});
it("GET /:id/permissions 404 on unknown user", async () => {
const res = await request(app)
.get(`/api/users/not-a-real-id/permissions`)
.set("Cookie", aliceCookie);
expect(res.status).toBe(404);
});
it("PUT /:id/permissions sets permissions, persists, and audits", async () => {
const before = audit.list(100, 0).filter((e) => e.action === "user.permissions_changed");
expect(before).toHaveLength(0);
const put = await request(app)
.put(`/api/users/${bobId}/permissions`)
.set("Cookie", aliceCookie)
.send({ capabilities: ["player.control"], bots: "all" });
expect(put.status).toBe(200);
const get = await request(app)
.get(`/api/users/${bobId}/permissions`)
.set("Cookie", aliceCookie);
expect(get.status).toBe(200);
expect(get.body).toEqual({ capabilities: ["player.control"], bots: "all" });
const rows = audit.list(100, 0).filter((e) => e.action === "user.permissions_changed");
expect(rows).toHaveLength(1);
expect(rows[0].actorId).toBe(aliceId);
expect(rows[0].targetUserId).toBe(bobId);
});
it("PUT /:id/permissions drops unknown capability tokens", async () => {
const put = await request(app)
.put(`/api/users/${bobId}/permissions`)
.set("Cookie", aliceCookie)
.send({ capabilities: ["player.control", "bogus"], bots: [] });
expect(put.status).toBe(200);
expect(permissions.getCapabilities(bobId)).toEqual(["player.control"]);
});
it("PUT /:id/permissions 404 on unknown user", async () => {
const res = await request(app)
.put(`/api/users/not-a-real-id/permissions`)
.set("Cookie", aliceCookie)
.send({ capabilities: ["player.control"], bots: "all" });
expect(res.status).toBe(404);
});
it("POST / seeds the basic tier for a new member", async () => {
const res = await request(app)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "dave", password: "dave-pw-pw" });
expect(res.status).toBe(201);
const perms = await request(app)
.get(`/api/users/${res.body.id}/permissions`)
.set("Cookie", aliceCookie);
expect(perms.status).toBe(200);
expect(perms.body).toEqual({
capabilities: ["player.control", "player.queue"],
bots: "all",
});
});
it("POST / does NOT seed permissions for a new admin", async () => {
const res = await request(app)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "erin", password: "erin-pw-pw", role: "admin" });
expect(res.status).toBe(201);
const perms = await request(app)
.get(`/api/users/${res.body.id}/permissions`)
.set("Cookie", aliceCookie);
expect(perms.status).toBe(200);
expect(perms.body).toEqual({ capabilities: [], bots: [] });
});
// --- reserved guest principal is never mutable/visible via user mgmt -------
// The synthetic __guest__ row is seeded by createDatabase. findById has no
// role filter, so without the 404-guard these by-id handlers would operate
// on it (privilege-escalation / DoS / cred-login holes).
describe("reserved __guest__ principal is 404 on every by-id handler", () => {
it("DELETE /:id → 404 and the guest row survives", async () => {
const res = await request(app).delete(`/api/users/${GUEST_USER_ID}`).set("Cookie", aliceCookie);
expect(res.status).toBe(404);
expect(users.findById(GUEST_USER_ID)).not.toBeNull();
expect(users.findById(GUEST_USER_ID)!.role).toBe("guest");
});
it("PATCH /:id/role {role:'admin'} → 404 and the guest role is unchanged", async () => {
const res = await request(app)
.patch(`/api/users/${GUEST_USER_ID}/role`)
.set("Cookie", aliceCookie)
.send({ role: "admin" });
expect(res.status).toBe(404);
expect(users.findById(GUEST_USER_ID)!.role).toBe("guest");
});
it("POST /:id/reset-password → 404 (cannot give the guest a login)", async () => {
const res = await request(app)
.post(`/api/users/${GUEST_USER_ID}/reset-password`)
.set("Cookie", aliceCookie)
.send({ newPassword: "guest-new-pw" });
expect(res.status).toBe(404);
});
it("GET /:id/permissions → 404", async () => {
const res = await request(app)
.get(`/api/users/${GUEST_USER_ID}/permissions`)
.set("Cookie", aliceCookie);
expect(res.status).toBe(404);
});
it("PUT /:id/permissions → 404", async () => {
const res = await request(app)
.put(`/api/users/${GUEST_USER_ID}/permissions`)
.set("Cookie", aliceCookie)
.send({ capabilities: ["player.control"], bots: "all" });
expect(res.status).toBe(404);
});
});
});
+214
View File
@@ -0,0 +1,214 @@
import { Router } from "express";
import type { Logger } from "../../logger.js";
import type { UserStore } from "../../data/users.js";
import { UsernameTakenError, GUEST_USER_ID } from "../../data/users.js";
import type { SessionStore } from "../../data/sessions.js";
import type { AuditStore } from "../../data/audit.js";
import { isCapability, BASIC_TIER_CAPABILITIES, type PermissionStore } from "../../data/permissions.js";
import { extractSessionToken } from "../auth/validateSession.js";
function isValidUsername(v: unknown): v is string {
return typeof v === "string" && /^[A-Za-z0-9_\-.]{3,32}$/.test(v);
}
function isValidPassword(v: unknown): v is string {
return typeof v === "string" && v.length >= 8 && v.length <= 200;
}
export function createUsersRouter(
users: UserStore,
sessions: SessionStore,
audit: AuditStore,
logger: Logger,
permissions: PermissionStore
): Router {
const router = Router();
router.get("/", (_req, res) => {
res.json({ users: users.listUsers() });
});
router.post("/", async (req, res) => {
const { username, password, role: roleInput } = req.body ?? {};
if (!isValidUsername(username) || !isValidPassword(password)) {
res.status(400).json({ error: "invalid username or password" });
return;
}
const role: "admin" | "member" = roleInput === "admin" ? "admin" : "member";
try {
const u = await users.createUser(username, password, role);
if (u.role === "member") {
permissions.setPermissions(u.id, { capabilities: BASIC_TIER_CAPABILITIES, bots: "all" });
}
try {
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
targetUserId: u.id, targetUsername: u.username,
action: "user.created",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "user.created" }, "audit insert failed");
}
logger.info({ createdBy: req.user!.id, newUserId: u.id, username, role }, "User created");
res.status(201).json({ id: u.id, username: u.username, role: u.role });
} catch (err) {
if (err instanceof UsernameTakenError) {
res.status(409).json({ error: "username taken" });
return;
}
logger.error({ err }, "createUser failed");
res.status(500).json({ error: "internal" });
}
});
router.delete("/:id", (req, res) => {
const targetId = req.params.id;
if (targetId === GUEST_USER_ID) { res.status(404).json({ error: "not found" }); return; }
// Snapshot target's username BEFORE deletion for audit
const target = users.findById(targetId);
if (!target) {
res.status(404).json({ error: "not found" });
return;
}
if (targetId === req.user!.id) {
res.status(400).json({ error: "cannot delete self" });
return;
}
const result = users.deleteUserIfNotLastAdmin(targetId);
if (result === "not_found") {
res.status(404).json({ error: "not found" });
return;
}
if (result === "would_orphan") {
res.status(400).json({ error: "cannot delete last admin" });
return;
}
// FK CASCADE removes sessions; explicit call is belt-and-suspenders
sessions.deleteAllForUser(targetId);
try {
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
targetUserId: target.id, targetUsername: target.username,
action: "user.deleted",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "user.deleted" }, "audit insert failed");
}
logger.info({ deletedBy: req.user!.id, deletedUserId: targetId }, "User deleted");
res.status(204).end();
});
router.post("/:id/reset-password", async (req, res) => {
const { newPassword } = req.body ?? {};
if (!isValidPassword(newPassword)) {
res.status(400).json({ error: "invalid password" });
return;
}
const targetId = req.params.id;
if (targetId === GUEST_USER_ID) { res.status(404).json({ error: "not found" }); return; }
const target = users.findById(targetId);
if (!target) {
res.status(404).json({ error: "not found" });
return;
}
await users.changePassword(targetId, newPassword);
// Invalidate all sessions for the target user (except current actor's if it's the same user)
const exceptToken = targetId === req.user!.id
? (extractSessionToken(req.headers.cookie) ?? undefined)
: undefined;
sessions.deleteAllForUser(targetId, exceptToken);
try {
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
targetUserId: target.id, targetUsername: target.username,
action: "user.password_reset",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "user.password_reset" }, "audit insert failed");
}
logger.info({ resetBy: req.user!.id, targetUserId: targetId }, "Password reset");
res.status(204).end();
});
router.patch("/:id/role", (req, res) => {
const targetId = req.params.id;
if (targetId === GUEST_USER_ID) { res.status(404).json({ error: "not found" }); return; }
const { role: newRole } = req.body ?? {};
if (newRole !== "admin" && newRole !== "member") {
res.status(400).json({ error: "invalid role" });
return;
}
// Snapshot the target's old role and username for audit (BEFORE the atomic update,
// so we record what actually changed; if the user is gone we'll skip audit).
const targetBefore = users.findById(targetId);
if (!targetBefore) {
res.status(404).json({ error: "not found" });
return;
}
const result = users.setRoleIfNotLastAdmin(targetId, newRole);
if (result === "not_found") {
res.status(404).json({ error: "not found" });
return;
}
if (result === "would_orphan") {
res.status(400).json({ error: "cannot demote last admin" });
return;
}
// Only audit when the role actually changed
if (targetBefore.role !== newRole) {
try {
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
targetUserId: targetBefore.id, targetUsername: targetBefore.username,
action: "user.role_changed",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "user.role_changed" }, "audit insert failed");
}
logger.info({ actorId: req.user!.id, targetId, newRole }, "User role changed");
}
res.status(204).end();
});
router.get("/:id/permissions", (req, res) => {
if (req.params.id === GUEST_USER_ID) { res.status(404).json({ error: "not found" }); return; }
const user = users.findById(req.params.id);
if (!user) {
res.status(404).json({ error: "not_found" });
return;
}
res.json({
capabilities: permissions.getCapabilities(user.id),
bots: permissions.getBotAccess(user.id),
});
});
router.put("/:id/permissions", (req, res) => {
if (req.params.id === GUEST_USER_ID) { res.status(404).json({ error: "not found" }); return; }
const user = users.findById(req.params.id);
if (!user) {
res.status(404).json({ error: "not_found" });
return;
}
const body = req.body ?? {};
const caps: string[] = Array.isArray(body.capabilities)
? body.capabilities.filter(isCapability)
: [];
const bots: "all" | string[] =
body.bots === "all" ? "all" : Array.isArray(body.bots) ? body.bots.map(String) : [];
permissions.setPermissions(user.id, { capabilities: caps, bots });
try {
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
targetUserId: user.id, targetUsername: user.username,
action: "user.permissions_changed",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "user.permissions_changed" }, "audit insert failed");
}
logger.info({ actorId: req.user!.id, targetUserId: user.id }, "User permissions changed");
res.json({ success: true });
});
return router;
}
+38
View File
@@ -0,0 +1,38 @@
import type { SessionStore, SessionValidation } from "../../data/sessions.js";
export const SESSION_COOKIE_NAME = "tsmb_session";
/**
* Validate the session cookie carried on an arbitrary HTTP-like header bag.
* Used by Express middleware (req.headers.cookie) AND by the raw WebSocket
* upgrade handler (req.headers.cookie) — they share this exact behavior.
*/
export function validateSessionFromHeaders(
rawCookieHeader: string | undefined,
sessions: SessionStore
): SessionValidation | null {
if (!rawCookieHeader) return null;
const token = parseCookie(rawCookieHeader, SESSION_COOKIE_NAME);
if (!token) return null;
return sessions.validateAndTouch(token);
}
export function extractSessionToken(rawCookieHeader: string | undefined): string | null {
if (!rawCookieHeader) return null;
return parseCookie(rawCookieHeader, SESSION_COOKIE_NAME);
}
function parseCookie(header: string, name: string): string | null {
for (const part of header.split(";")) {
const trimmed = part.trim();
const eq = trimmed.indexOf("=");
if (eq < 1) continue;
if (trimmed.slice(0, eq) !== name) continue;
try {
return decodeURIComponent(trimmed.slice(eq + 1));
} catch {
return null;
}
}
return null;
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect, vi } from "vitest";
import { authorize } from "./authorize.js";
function run(user: any, opts: any) {
const req: any = { user };
const res: any = { statusCode: 0, body: null, status(c: number) { this.statusCode = c; return this; }, json(b: any) { this.body = b; return this; } };
const next = vi.fn();
authorize(opts)(req, res, next);
return { res, next };
}
describe("authorize", () => {
it("401 when unauthenticated", () => {
const { res, next } = run(undefined, { capability: "player.queue" });
expect(res.statusCode).toBe(401);
expect(next).not.toHaveBeenCalled();
});
it("admin always passes", () => {
const { next } = run({ role: "admin" }, { capability: "bot.manage" });
expect(next).toHaveBeenCalled();
});
it("member passes only with the capability", () => {
expect(run({ role: "member", capabilities: new Set(["player.queue"]) }, { capability: "player.queue" }).next).toHaveBeenCalled();
expect(run({ role: "member", capabilities: new Set() }, { capability: "player.queue" }).res.statusCode).toBe(403);
});
it("guest passes only when its flag is enabled", () => {
expect(run({ role: "guest", guest: { playNext: true } }, { capability: "player.control", guestFlag: "playNext" }).next).toHaveBeenCalled();
expect(run({ role: "guest", guest: { playNext: false } }, { capability: "player.control", guestFlag: "playNext" }).res.statusCode).toBe(403);
});
it("guest is denied on routes with no guestFlag (e.g. play-song)", () => {
expect(run({ role: "guest", guest: { addToQueue: true } }, { capability: "player.control" }).res.statusCode).toBe(403);
});
it("guest with a non-boolean truthy flag value (1) is denied (strict-boolean gate)", () => {
expect(run({ role: "guest", guest: { playNext: 1 } as any }, { guestFlag: "playNext" }).res.statusCode).toBe(403);
expect(run({ role: "guest", guest: { playNext: true } }, { guestFlag: "playNext" }).next).toHaveBeenCalled();
});
});
+29
View File
@@ -0,0 +1,29 @@
import type { Request, Response, NextFunction, RequestHandler } from "express";
import type { GuestFlag } from "../../data/permissions.js";
/**
* Unified authorization gate.
* - admin → always allowed (unchanged from requirePermission)
* - member → allowed iff it holds `capability` (unchanged from requirePermission)
* - guest → allowed iff `guestFlag` is set AND that flag is enabled in the
* guest's resolved permissions; a route with no `guestFlag` is
* denied to guests by default.
* Generic over the route-param shape `P` for the same reason requirePermission is.
*/
export function authorize<P = Record<string, string>>(opts: {
capability?: string;
guestFlag?: GuestFlag;
}): RequestHandler<P> {
return (req: Request<P>, res: Response, next: NextFunction) => {
const user = req.user;
if (!user) { res.status(401).json({ error: "unauthenticated" }); return; }
if (user.role === "admin") { next(); return; }
if (user.role === "guest") {
if (opts.guestFlag && user.guest?.[opts.guestFlag] === true) { next(); return; }
res.status(403).json({ error: "forbidden" });
return;
}
if (opts.capability && user.capabilities?.has(opts.capability)) { next(); return; }
res.status(403).json({ error: "forbidden" });
};
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect, beforeEach } from "vitest";
import express from "express";
import request from "supertest";
import { csrfOriginCheck } from "./csrf.js";
describe("csrfOriginCheck middleware", () => {
let app: express.Express;
beforeEach(() => {
app = express();
app.use(csrfOriginCheck);
app.get("/", (_req, res) => res.json({ ok: true }));
app.post("/", (_req, res) => res.json({ ok: true }));
});
it("allows safe methods (GET/HEAD/OPTIONS) without Origin", async () => {
const res = await request(app).get("/");
expect(res.status).toBe(200);
});
it("rejects POST without Origin or Referer", async () => {
const res = await request(app).post("/");
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: "bad origin" });
});
it("accepts POST when Origin host matches request host", async () => {
const res = await request(app)
.post("/")
.set("Host", "example.com")
.set("Origin", "https://example.com");
expect(res.status).toBe(200);
});
it("rejects POST when Origin host does not match request host", async () => {
const res = await request(app)
.post("/")
.set("Host", "example.com")
.set("Origin", "https://evil.com");
expect(res.status).toBe(403);
});
it("accepts POST when Referer host matches and Origin is absent", async () => {
const res = await request(app)
.post("/")
.set("Host", "example.com")
.set("Referer", "https://example.com/some/path");
expect(res.status).toBe(200);
});
it("rejects POST when Referer host does not match", async () => {
const res = await request(app)
.post("/")
.set("Host", "example.com")
.set("Referer", "https://evil.com/some/path");
expect(res.status).toBe(403);
});
// Documents the server side of the QR-login outage: a `no-referrer` document
// policy makes the browser send the literal `Origin: null` on same-origin
// POSTs, which this guard cannot parse a host from and therefore rejects.
// The fix lives in the frontend (referrer policy -> same-origin); this test
// pins the gate behavior so the interaction stays understood. See
// src/web/referrer-policy.test.ts.
it('rejects POST with the literal Origin: "null" (no-referrer downgrade)', async () => {
const res = await request(app)
.post("/")
.set("Host", "example.com")
.set("Origin", "null");
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: "bad origin" });
});
});
+35
View File
@@ -0,0 +1,35 @@
import type { Request, Response, NextFunction } from "express";
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
/**
* Same-origin CSRF protection. For mutating requests, the Origin or Referer
* header must indicate a host equal to the request's own host.
*
* SameSite=Lax on the session cookie blocks classic cross-site form posts;
* this header check covers the remaining attack surface.
*/
export function csrfOriginCheck(req: Request, res: Response, next: NextFunction): void {
if (SAFE_METHODS.has(req.method)) {
next();
return;
}
const expectedHost = req.get("host");
const originHeader = req.get("origin");
const refererHeader = req.get("referer");
const headerHost = hostOf(originHeader) ?? hostOf(refererHeader);
if (!headerHost || !expectedHost || headerHost !== expectedHost) {
res.status(403).json({ error: "bad origin" });
return;
}
next();
}
function hostOf(url: string | undefined): string | null {
if (!url) return null;
try {
return new URL(url).host;
} catch {
return null;
}
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect, beforeEach } from "vitest";
import express from "express";
import request from "supertest";
import { createRateLimit } from "./rateLimit.js";
describe("createRateLimit", () => {
let app: express.Express;
beforeEach(() => {
app = express();
// capacity=3, refill=1/sec → first 3 succeed, then 429 until refill.
app.use(createRateLimit({ capacity: 3, refillPerSec: 1 }));
app.get("/", (_req, res) => res.json({ ok: true }));
});
it("allows up to capacity bursts then rejects with 429", async () => {
expect((await request(app).get("/")).status).toBe(200);
expect((await request(app).get("/")).status).toBe(200);
expect((await request(app).get("/")).status).toBe(200);
const denied = await request(app).get("/");
expect(denied.status).toBe(429);
expect(denied.body).toEqual({ error: "rate limit exceeded" });
expect(denied.headers["retry-after"]).toBeDefined();
});
it("uses per-key buckets when keyFn is provided", async () => {
const customApp = express();
customApp.use(
createRateLimit({
capacity: 1,
refillPerSec: 0.001,
keyFn: (req) => req.get("x-user") ?? "anon",
})
);
customApp.get("/", (_req, res) => res.json({ ok: true }));
expect((await request(customApp).get("/").set("X-User", "alice")).status).toBe(200);
expect((await request(customApp).get("/").set("X-User", "alice")).status).toBe(429);
// Different user, separate bucket → still has a token.
expect((await request(customApp).get("/").set("X-User", "bob")).status).toBe(200);
});
});
+63
View File
@@ -0,0 +1,63 @@
import type { Request, Response, NextFunction, RequestHandler } from "express";
interface Bucket {
tokens: number;
lastRefillMs: number;
}
interface RateLimitOptions {
/** Bucket capacity (max burst). */
capacity: number;
/** Tokens refilled per second. */
refillPerSec: number;
/** Optional key function; defaults to req.ip. */
keyFn?: (req: Request) => string;
}
/**
* In-memory token-bucket rate limiter.
*
* Each unique key (default: req.ip) gets its own bucket. Refills continuously
* at `refillPerSec` up to `capacity`. Each request consumes 1 token; if no
* token is available, returns 429 with Retry-After.
*
* Buckets evict themselves after 10 minutes of inactivity to bound memory.
*/
export function createRateLimit(options: RateLimitOptions): RequestHandler {
const buckets = new Map<string, Bucket>();
const EVICT_AFTER_MS = 10 * 60 * 1000;
// Periodic eviction to bound memory under attack.
const evict = setInterval(() => {
const cutoff = Date.now() - EVICT_AFTER_MS;
for (const [k, b] of buckets) {
if (b.lastRefillMs < cutoff) buckets.delete(k);
}
}, 60_000);
// Unref the timer so it doesn't keep the process alive in tests.
if (typeof (evict as { unref?: () => void }).unref === "function") {
(evict as { unref: () => void }).unref();
}
const keyFn = options.keyFn ?? ((req) => req.ip ?? "unknown");
return function rateLimit(req: Request, res: Response, next: NextFunction): void {
const key = keyFn(req);
const now = Date.now();
let b = buckets.get(key);
if (!b) {
b = { tokens: options.capacity, lastRefillMs: now };
buckets.set(key, b);
}
const elapsedSec = (now - b.lastRefillMs) / 1000;
b.tokens = Math.min(options.capacity, b.tokens + elapsedSec * options.refillPerSec);
b.lastRefillMs = now;
if (b.tokens < 1) {
const waitSec = Math.ceil((1 - b.tokens) / options.refillPerSec);
res.setHeader("Retry-After", String(waitSec));
res.status(429).json({ error: "rate limit exceeded" });
return;
}
b.tokens -= 1;
next();
};
}
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js";
import { createPermissionStore } from "../../data/permissions.js";
import { getDefaultConfig } from "../../data/config.js";
import { createRequireAuth } from "./requireAuth.js";
import { requireAdmin } from "./requireAdmin.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
describe("requireAdmin middleware", () => {
let botDb: BotDatabase;
let app: express.Express;
let adminCookie: string;
let memberCookie: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const permissions = createPermissionStore(botDb.db);
const admin = await users.createUser("admin", "pw-admin-pw", "admin");
const member = await users.createUser("member", "pw-member-pw", "member");
adminCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(admin.id).token}`;
memberCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(member.id).token}`;
app = express();
app.use(cookieParser());
app.use(createRequireAuth(sessions, permissions, () => getDefaultConfig().guestMode));
app.use(requireAdmin);
app.get("/admin-only", (_req, res) => res.json({ ok: true }));
});
afterEach(() => botDb.close());
it("rejects unauthenticated requests with 401", async () => {
const res = await request(app).get("/admin-only");
expect(res.status).toBe(401);
});
it("rejects member with 403", async () => {
const res = await request(app).get("/admin-only").set("Cookie", memberCookie);
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: "forbidden" });
});
it("allows admin", async () => {
const res = await request(app).get("/admin-only").set("Cookie", adminCookie);
expect(res.status).toBe(200);
});
});
+13
View File
@@ -0,0 +1,13 @@
import type { Request, Response, NextFunction } from "express";
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
if (!req.user) {
res.status(401).json({ error: "unauthenticated" });
return;
}
if (req.user.role !== "admin") {
res.status(403).json({ error: "forbidden" });
return;
}
next();
}
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createRequireAuth } from "./requireAuth.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
describe("requireAuth middleware", () => {
let botDb: BotDatabase;
let app: express.Express;
let validToken: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const permissions = createPermissionStore(botDb.db);
const u = await users.createUser("alice", "pw-alice", "admin");
validToken = sessions.createSession(u.id).token;
app = express();
app.use(cookieParser());
app.use(
createRequireAuth(sessions, permissions, () => ({
enabled: true,
bots: "all",
permissions: {
addToQueue: true,
playNext: true,
playNow: true,
skip: true,
transport: true,
removeClear: true,
playMode: true,
},
}))
);
app.get("/protected", (req, res) => {
res.json({ ok: true, user: (req as any).user });
});
});
afterEach(() => {
botDb.close();
});
it("rejects requests without a session cookie", async () => {
const res = await request(app).get("/protected");
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "unauthenticated" });
});
it("rejects requests with an unknown session cookie", async () => {
const res = await request(app)
.get("/protected")
.set("Cookie", `${SESSION_COOKIE_NAME}=garbage`);
expect(res.status).toBe(401);
});
it("allows requests with a valid session cookie and attaches req.user", async () => {
const res = await request(app)
.get("/protected")
.set("Cookie", `${SESSION_COOKIE_NAME}=${validToken}`);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.user.username).toBe("alice");
expect(res.body.user.role).toBe("admin");
});
it("rolls the cookie max-age forward on successful auth", async () => {
const res = await request(app)
.get("/protected")
.set("Cookie", `${SESSION_COOKIE_NAME}=${validToken}`);
expect(res.status).toBe(200);
const setCookieHeaders = res.headers["set-cookie"];
const arr = Array.isArray(setCookieHeaders) ? setCookieHeaders : setCookieHeaders ? [setCookieHeaders] : [];
const refreshed = arr.find((c) => c.startsWith(`${SESSION_COOKIE_NAME}=`));
expect(refreshed).toBeDefined();
expect(refreshed!).toMatch(/Max-Age=\d+/);
});
// A guest session is rejected (401) when guest mode is disabled.
it("rejects a guest session when guest mode is disabled", () => {
const sessions: any = { validateAndTouch: () => ({ userId: "__guest__", username: "游客", role: "guest" }) };
const permissions: any = { getCapabilities: () => [], getBotAccess: () => [] };
const getGuestConfig = () => ({ enabled: false, bots: "all" as const, permissions: {} as any });
const mw = createRequireAuth(sessions, permissions, getGuestConfig);
const req: any = { headers: { cookie: "tsmb_session=x" } };
const res: any = { statusCode: 0, cleared: false, clearCookie() { this.cleared = true; }, status(c: number) { this.statusCode = c; return this; }, json() { return this; }, cookie() {} };
const next = vi.fn();
mw(req, res, next);
expect(res.statusCode).toBe(401);
expect(next).not.toHaveBeenCalled();
});
it("attaches guest permissions when guest mode is enabled", () => {
const sessions: any = { validateAndTouch: () => ({ userId: "__guest__", username: "游客", role: "guest" }) };
const permissions: any = { getCapabilities: () => [], getBotAccess: () => [] };
const perms = { addToQueue: true, playNext: false, playNow: false, skip: false, transport: false, removeClear: false, playMode: false };
const getGuestConfig = () => ({ enabled: true, bots: ["bot1"], permissions: perms });
const mw = createRequireAuth(sessions, permissions, getGuestConfig);
const req: any = { headers: { cookie: "tsmb_session=x" }, secure: false };
const res: any = { status() { return this; }, json() { return this; }, cookie() {}, clearCookie() {} };
const next = vi.fn();
mw(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.user.role).toBe("guest");
expect(req.user.guest.addToQueue).toBe(true);
expect(req.user.bots instanceof Set && req.user.bots.has("bot1")).toBe(true);
});
});
+71
View File
@@ -0,0 +1,71 @@
import type { Request, Response, NextFunction, RequestHandler } from "express";
import type { SessionStore } from "../../data/sessions.js";
import { SESSION_TTL_MS } from "../../data/sessions.js";
import { resolvePermissionContext, type PermissionStore, type GuestPermissions } from "../../data/permissions.js";
import type { GuestModeConfig } from "../../data/config.js";
import {
validateSessionFromHeaders,
extractSessionToken,
SESSION_COOKIE_NAME,
} from "../auth/validateSession.js";
declare module "express-serve-static-core" {
interface Request {
user?: {
id: string;
username: string;
role: "admin" | "member" | "guest";
capabilities?: Set<string>;
bots?: "all" | Set<string>;
guest?: GuestPermissions;
};
}
}
export function createRequireAuth(
sessions: SessionStore,
permissions: PermissionStore,
getGuestConfig: () => GuestModeConfig
): RequestHandler {
return function requireAuth(req: Request, res: Response, next: NextFunction) {
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
if (!result) {
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
res.status(401).json({ error: "unauthenticated" });
return;
}
// A guest session is only valid while guest mode is enabled. Disabling it
// immediately invalidates any in-flight guest sessions.
const guestCfg = getGuestConfig();
if (result.role === "guest" && !guestCfg.enabled) {
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
res.status(401).json({ error: "unauthenticated" });
return;
}
const ctx = resolvePermissionContext(
result.role,
result.userId,
permissions,
result.role === "guest" ? { bots: guestCfg.bots, permissions: guestCfg.permissions } : undefined
);
req.user = {
id: result.userId,
username: result.username,
role: result.role,
capabilities: ctx.capabilities,
bots: ctx.bots,
guest: ctx.guest,
};
const token = extractSessionToken(req.headers.cookie);
if (token) {
res.cookie(SESSION_COOKIE_NAME, token, {
httpOnly: true,
sameSite: "lax",
secure: req.secure,
path: "/",
maxAge: SESSION_TTL_MS,
});
}
next();
};
}
@@ -0,0 +1,19 @@
import { describe, it, expect, vi } from "vitest";
import { requireNotGuest } from "./requireNotGuest.js";
function run(user: any) {
const req: any = { user };
const res: any = { statusCode: 0, status(c: number) { this.statusCode = c; return this; }, json() { return this; } };
const next = vi.fn();
requireNotGuest(req, res, next);
return { res, next };
}
describe("requireNotGuest", () => {
it("401 when no user", () => { expect(run(undefined).res.statusCode).toBe(401); });
it("403 for guests", () => { expect(run({ role: "guest" }).res.statusCode).toBe(403); });
it("passes admins and members", () => {
expect(run({ role: "admin" }).next).toHaveBeenCalled();
expect(run({ role: "member" }).next).toHaveBeenCalled();
});
});
+9
View File
@@ -0,0 +1,9 @@
import type { Request, Response, NextFunction } from "express";
/** Allow admins and members; deny login-less guests (used for config reads
* that must never leak to guests, e.g. GET /api/bot/settings, GET /api/music/quality). */
export function requireNotGuest(req: Request, res: Response, next: NextFunction): void {
if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; }
if (req.user.role === "guest") { res.status(403).json({ error: "forbidden" }); return; }
next();
}
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import express from "express";
import request from "supertest";
import { requirePermission, requireBotAccess } from "./requirePermission.js";
function appWith(user: any) {
const app = express();
app.use((req, _res, next) => { (req as any).user = user; next(); });
app.post("/cap", requirePermission("quality"), (_req, res) => res.json({ ok: true }));
app.post("/bot/:botId", requireBotAccess("botId"), (_req, res) => res.json({ ok: true }));
return app;
}
const member = (caps: string[], bots: "all" | string[]) => ({
id: "u1", username: "a", role: "member",
capabilities: new Set(caps), bots: bots === "all" ? "all" : new Set(bots),
});
const admin = { id: "a", username: "admin", role: "admin", capabilities: new Set(), bots: "all" };
describe("requirePermission", () => {
it("401 when unauthenticated", async () => {
const app = express();
app.post("/cap", requirePermission("quality"), (_r, res) => res.json({ ok: true }));
expect((await request(app).post("/cap")).status).toBe(401);
});
it("403 when member lacks the capability", async () => {
expect((await request(appWith(member([], "all"))).post("/cap")).status).toBe(403);
});
it("200 when member has the capability", async () => {
expect((await request(appWith(member(["quality"], "all"))).post("/cap")).status).toBe(200);
});
it("200 for admin regardless of capabilities", async () => {
expect((await request(appWith(admin)).post("/cap")).status).toBe(200);
});
});
describe("requireBotAccess", () => {
it("200 when bots = all", async () => {
expect((await request(appWith(member([], "all"))).post("/bot/b1")).status).toBe(200);
});
it("200 when botId in allow-list", async () => {
expect((await request(appWith(member([], ["b1"]))).post("/bot/b1")).status).toBe(200);
});
it("403 when botId not in allow-list", async () => {
expect((await request(appWith(member([], ["b2"]))).post("/bot/b1")).status).toBe(403);
});
it("200 for admin", async () => {
expect((await request(appWith(admin)).post("/bot/b1")).status).toBe(200);
});
it("401 when unauthenticated", async () => {
const app = express();
app.post("/bot/:botId", requireBotAccess("botId"), (_r, res) => res.json({ ok: true }));
expect((await request(app).post("/bot/b1")).status).toBe(401);
});
it("403 when the route param is absent", async () => {
const app = express();
app.use((req, _res, next) => { (req as any).user = member([], ["b1"]); next(); });
app.post("/bot/:botId", requireBotAccess("nope"), (_r, res) => res.json({ ok: true }));
expect((await request(app).post("/bot/b1")).status).toBe(403);
});
});
+24
View File
@@ -0,0 +1,24 @@
import type { Request, Response, NextFunction, RequestHandler } from "express";
// Generic over the route-param shape (`P`) so Express can keep inferring
// `req.params` from the route string (e.g. `/:id` → `{ id: string }`) when
// these are passed as a per-route middleware argument. Pinning the default
// `ParamsDictionary` here would otherwise force the broad
// `string | string[]` param overload on every route they guard.
export function requirePermission<P = Record<string, string>>(capability: string): RequestHandler<P> {
return (req: Request<P>, res: Response, next: NextFunction) => {
if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; }
if (req.user.role === "admin" || req.user.capabilities?.has(capability)) { next(); return; }
res.status(403).json({ error: "forbidden" });
};
}
export function requireBotAccess<P = Record<string, string>>(paramName = "botId"): RequestHandler<P> {
return (req: Request<P>, res: Response, next: NextFunction) => {
if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; }
if (req.user.role === "admin" || req.user.bots === "all") { next(); return; }
const botId = (req.params as Record<string, string | undefined>)[paramName];
if (typeof botId === "string" && req.user.bots instanceof Set && req.user.bots.has(botId)) { next(); return; }
res.status(403).json({ error: "forbidden" });
};
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* Regression guard for the QR-login / cookie-save outage (and in fact every
* mutating WebUI action). On 2026-05-27 the WebUI-auth feature added the
* same-origin CSRF gate `app.use("/api", csrfOriginCheck)` in server.ts, and
* the same day a `<meta name="referrer" content="no-referrer">` was added to
* web/index.html so cross-origin CDN cover thumbnails would load.
*
* Those two changes conflict: per the WHATWG Fetch "Append a request Origin
* header" algorithm, the `no-referrer` policy sets the Origin header to the
* literal string "null" on same-origin non-GET requests. csrfOriginCheck then
* fails to parse a host (`new URL("null")` throws) and returns 403 "bad
* origin", so POST /api/auth/qrcode (and every other POST/PUT/DELETE under
* /api/* except /api/session/*) never reaches its handler.
*
* `same-origin` is the correct policy: it keeps the real Origin on same-origin
* requests (CSRF passes) while still sending no Referer cross-origin (CDN
* thumbnails keep loading). Never switch this back to `no-referrer`.
*/
describe("frontend referrer policy (CSRF / Origin-header regression)", () => {
const indexHtmlPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../web/index.html"
);
const html = fs.readFileSync(indexHtmlPath, "utf-8");
const referrerMeta = html.match(
/<meta\s+name=["']referrer["']\s+content=["']([^"']+)["']\s*\/?>/i
);
it("declares a referrer policy meta tag", () => {
expect(referrerMeta).not.toBeNull();
});
it("uses same-origin (NOT no-referrer, which sends Origin: null and 403s every POST)", () => {
expect(referrerMeta?.[1]).toBe("same-origin");
});
it("does not contain no-referrer anywhere in the document head", () => {
expect(html).not.toMatch(/content=["']no-referrer["']/i);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import express from "express";
import request from "supertest";
/**
* The clickjacking-defence middleware is mounted at the top of
* `createWebServer` in `server.ts`. This test asserts the exact behavior
* we expect from that middleware in isolation. The wiring inside
* `server.ts` is verified by code review (git diff).
*/
describe("security headers (anti-clickjacking)", () => {
function buildApp() {
const app = express();
app.use((_req, res, next) => {
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
next();
});
app.get("/", (_req, res) => res.json({ ok: true }));
app.post("/", (_req, res) => res.json({ ok: true }));
return app;
}
it("sets X-Frame-Options: DENY on GET responses", async () => {
const res = await request(buildApp()).get("/");
expect(res.status).toBe(200);
expect(res.headers["x-frame-options"]).toBe("DENY");
});
it("sets Content-Security-Policy frame-ancestors 'none' on GET responses", async () => {
const res = await request(buildApp()).get("/");
expect(res.headers["content-security-policy"]).toBe("frame-ancestors 'none'");
});
it("sets both headers on POST responses too", async () => {
const res = await request(buildApp()).post("/");
expect(res.headers["x-frame-options"]).toBe("DENY");
expect(res.headers["content-security-policy"]).toBe("frame-ancestors 'none'");
});
});
+140 -10
View File
@@ -1,18 +1,36 @@
import express from "express";
import http from "node:http";
import path from "node:path";
import cookieParser from "cookie-parser";
import { WebSocketServer } from "ws";
import type { BotManager } from "../bot/manager.js";
import type { MusicProvider } from "../music/provider.js";
import type { BotDatabase } from "../data/database.js";
import type { BotConfig } from "../data/config.js";
import type { BotConfig, GuestModeConfig } from "../data/config.js";
import type { Logger } from "../logger.js";
import type { CookieStore } from "../music/auth.js";
import type { AvatarStore } from "../data/avatars.js";
import { createBotRouter } from "./api/bot.js";
import { createMusicRouter } from "./api/music.js";
import { createPlayerRouter } from "./api/player.js";
import { createAuthRouter } from "./api/auth.js";
import { createSessionRouter } from "./api/session.js";
import { createUsersRouter } from "./api/users.js";
import { createAuditStore } from "../data/audit.js";
import { createAuditRouter } from "./api/audit.js";
import { createFavoritesRouter } from "./api/favorites.js";
import { setupWebSocket } from "./websocket.js";
import { createUserStore } from "../data/users.js";
import { createSessionStore } from "../data/sessions.js";
import { createPermissionStore } from "../data/permissions.js";
import { createRequireAuth } from "./middleware/requireAuth.js";
import { requireAdmin } from "./middleware/requireAdmin.js";
import { requireNotGuest } from "./middleware/requireNotGuest.js";
import { csrfOriginCheck } from "./middleware/csrf.js";
import { createRateLimit } from "./middleware/rateLimit.js";
import { validateSessionFromHeaders } from "./auth/validateSession.js";
const SESSION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
export interface WebServerOptions {
port: number;
@@ -25,6 +43,7 @@ export interface WebServerOptions {
configPath: string;
logger: Logger;
cookieStore?: CookieStore;
avatarStore: AvatarStore;
staticDir?: string;
}
@@ -39,20 +58,68 @@ export function createWebServer(options: WebServerOptions): WebServer {
const logger = options.logger.child({ component: "web" });
if (options.config.trustProxy) {
// Honor X-Forwarded-* from a reverse proxy (nginx/Caddy/Cloudflare).
app.set("trust proxy", true);
}
app.use(express.json());
// Security headers: prevent the WebUI from being embedded in a third-party
// iframe (clickjacking defence). CSP frame-ancestors is the modern equivalent
// of X-Frame-Options; both are set for compatibility across browsers.
app.use((_req, res, next) => {
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
next();
});
app.use(express.json({ limit: "400kb" }));
app.use(cookieParser());
const users = createUserStore(options.database.db);
const sessions = createSessionStore(options.database.db);
const audit = createAuditStore(options.database.db);
const permissions = createPermissionStore(options.database.db);
// ─── Public routes (no auth, no CSRF) ───────────────────────────────────
app.get("/api/health", (_req, res) => {
res.json({ status: "ok", version: "0.1.0" });
});
app.get("/api/config/public-url", (_req, res) => {
const raw = (options.config.publicUrl ?? "").trim();
res.json({ publicUrl: raw ? raw.replace(/\/+$/, "") : null });
});
// Anti-DoS: throttle expensive (bcrypt) auth endpoints.
// 5 req per minute per IP for /login (capacity 5, refill 5/60 = ~0.083/sec).
// 3 req per minute per IP for /setup (more limited; first-run is rare).
const loginLimit = createRateLimit({ capacity: 5, refillPerSec: 5 / 60 });
const setupLimit = createRateLimit({ capacity: 3, refillPerSec: 3 / 60 });
app.use("/api/session/login", loginLimit);
app.use("/api/session/setup", setupLimit);
app.use("/api/session", createSessionRouter(users, sessions, audit, logger, permissions, () => options.config.guestMode));
// ─── Gates for everything else under /api ───────────────────────────────
const requireAuth = createRequireAuth(sessions, permissions, () => options.config.guestMode);
app.use("/api", csrfOriginCheck);
app.use("/api", requireAuth);
// ─── Protected routes ───────────────────────────────────────────────────
// The bot router is mounted BEFORE setupWebSocket runs, but its /settings
// handler needs to trigger a guest-policy refresh on the (later-created) WS
// controller. Bridge the two with a mutable indirection that starts as a
// no-op and is wired to the real refreshGuestPolicy once the WS is set up.
let onGuestPolicyChanged: (cfg: GuestModeConfig) => void = () => {};
app.use(
"/api/bot",
createBotRouter(options.botManager, options.config, options.configPath, logger)
createBotRouter(
options.botManager,
options.config,
options.configPath,
logger,
options.database,
options.avatarStore,
(cfg) => onGuestPolicyChanged(cfg),
)
);
app.use(
"/api/music",
@@ -66,11 +133,13 @@ export function createWebServer(options: WebServerOptions): WebServer {
"/api/auth",
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore)
);
app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger));
app.get("/api/health", (_req, res) => {
res.json({ status: "ok", version: "0.1.0" });
});
// admin-only routes
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions));
app.use("/api/audit", requireAdmin, createAuditRouter(audit));
// ─── Static SPA (public) ────────────────────────────────────────────────
if (options.staticDir) {
app.use(express.static(options.staticDir));
app.get(/^(?!\/api|\/ws)/, (_req, res) => {
@@ -82,23 +151,84 @@ export function createWebServer(options: WebServerOptions): WebServer {
logger.error({ err }, "HTTP server error");
});
const wss = new WebSocketServer({ server, path: "/ws" });
// ─── WebSocket with manual upgrade auth ────────────────────────────────
const wss = new WebSocketServer({ noServer: true });
wss.on("error", (err) => {
logger.error({ err }, "WebSocket server error");
});
const cleanupWs = setupWebSocket(wss, options.botManager, logger);
server.on("upgrade", (req, socket, head) => {
if (req.url !== "/ws") {
socket.destroy();
return;
}
const reqHost = req.headers.host;
const originHeader = req.headers.origin;
if (originHeader) {
let originHost: string | null = null;
try {
originHost = new URL(originHeader).host;
} catch {
// fall through; treat as missing/invalid origin
}
if (!originHost || originHost !== reqHost) {
socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
socket.destroy();
return;
}
}
const result = validateSessionFromHeaders(req.headers.cookie as string | undefined, sessions);
if (!result) {
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
socket.destroy();
return;
}
// Guest sessions are only valid while guest mode is enabled.
if (result.role === "guest" && !options.config.guestMode.enabled) {
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
socket.destroy();
return;
}
const guestBots = options.config.guestMode.bots;
const botScope: "all" | Set<string> =
result.role === "guest"
? guestBots === "all" ? "all" : new Set(guestBots)
: "all";
wss.handleUpgrade(req, socket, head, (ws) => {
const w = ws as unknown as { userId: string; isGuest: boolean; botScope: "all" | Set<string> };
w.userId = result.userId;
w.isGuest = result.role === "guest";
w.botScope = botScope;
wss.emit("connection", ws, req);
});
});
const controller = setupWebSocket(wss, options.botManager, logger);
onGuestPolicyChanged = controller.refreshGuestPolicy;
// ─── Session cleanup interval ──────────────────────────────────────────
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
return {
async start(): Promise<void> {
return new Promise((resolve) => {
server.listen(options.port, () => {
logger.info({ port: options.port }, "Web server started");
cleanupTimer = setInterval(() => {
try {
sessions.cleanupExpired();
} catch (err) {
logger.error({ err }, "session cleanup failed");
}
}, SESSION_CLEANUP_INTERVAL_MS);
resolve();
});
});
},
stop(): void {
cleanupWs();
if (cleanupTimer) {
clearInterval(cleanupTimer);
cleanupTimer = null;
}
controller.cleanup();
wss.close();
server.close();
},
+181
View File
@@ -0,0 +1,181 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import http from "node:http";
import { WebSocketServer, WebSocket as WSClient } from "ws";
import { AddressInfo } from "node:net";
import { createDatabase, type BotDatabase } from "../data/database.js";
import { createUserStore } from "../data/users.js";
import { createSessionStore } from "../data/sessions.js";
import { validateSessionFromHeaders, SESSION_COOKIE_NAME } from "./auth/validateSession.js";
import { setupWebSocket } from "./websocket.js";
function buildServer(sessions: ReturnType<typeof createSessionStore>) {
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ noServer: true });
wss.on("connection", (ws) => ws.send("hello"));
server.on("upgrade", (req, socket, head) => {
if (req.url !== "/ws") return socket.destroy();
const r = validateSessionFromHeaders(req.headers.cookie as string | undefined, sessions);
if (!r) {
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
});
return { server, wss };
}
describe("WebSocket auth at upgrade", () => {
let botDb: BotDatabase;
let httpServer: http.Server;
let port: number;
let validToken: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const u = await users.createUser("alice", "pw-alice", "admin");
validToken = sessions.createSession(u.id).token;
const { server } = buildServer(sessions);
httpServer = server;
await new Promise<void>((resolve) => httpServer.listen(0, resolve));
port = (httpServer.address() as AddressInfo).port;
});
afterEach(async () => {
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
botDb.close();
});
it("rejects upgrade without cookie (server-side close before open)", async () => {
const ws = new WSClient(`ws://127.0.0.1:${port}/ws`);
const result = await new Promise<string>((resolve) => {
ws.on("open", () => resolve("opened"));
ws.on("unexpected-response", (_req, res) => resolve(`status:${res.statusCode}`));
ws.on("error", () => resolve("error"));
});
expect(result).toMatch(/^status:401$|^error$/);
});
it("accepts upgrade with a valid cookie", async () => {
const ws = new WSClient(`ws://127.0.0.1:${port}/ws`, {
headers: { Cookie: `${SESSION_COOKIE_NAME}=${validToken}` },
});
const msg = await new Promise<string>((resolve, reject) => {
ws.on("message", (data) => resolve(data.toString()));
ws.on("error", reject);
});
expect(msg).toBe("hello");
ws.close();
});
});
describe("WebSocket guest bot scope", () => {
it("guest init is filtered to the guest bot scope", () => {
const sent: any[] = [];
const fakeWs: any = {
readyState: 1,
isGuest: true,
botScope: new Set(["bot1"]),
send: (m: string) => sent.push(JSON.parse(m)),
on: () => {},
};
const fakeWss: any = {
on: (ev: string, cb: any) => {
if (ev === "connection") fakeWss._conn = cb;
},
};
const makeBot = (id: string) => ({
id,
getStatus: () => ({ id }),
getQueue: () => [],
on: () => {},
removeListener: () => {},
});
const botManager: any = {
getAllBots: () => [makeBot("bot1"), makeBot("bot2")],
on: () => {},
off: () => {},
removeListener: () => {},
};
const { cleanup } = setupWebSocket(fakeWss, botManager, {
debug() {},
error() {},
info() {},
warn() {},
} as any);
fakeWss._conn(fakeWs);
const init = sent.find((m) => m.type === "init");
expect(init.bots.map((b: any) => b.id)).toEqual(["bot1"]);
cleanup();
});
});
describe("WebSocket refreshGuestPolicy", () => {
function makeHarness() {
const clients: any[] = [];
const fakeWss: any = {
on: (ev: string, cb: any) => {
if (ev === "connection") fakeWss._conn = cb;
},
};
const botManager: any = {
getAllBots: () => [],
on: () => {},
off: () => {},
removeListener: () => {},
};
const logger = { debug() {}, error() {}, info() {}, warn() {} } as any;
const controller = setupWebSocket(fakeWss, botManager, logger);
// Connect fake sockets via the connection handler so they land in `clients`.
const connect = (ws: any) => {
clients.push(ws);
fakeWss._conn(ws);
};
return { controller, connect };
}
function makeFakeWs(opts: { isGuest: boolean; botScope?: "all" | Set<string> }) {
const closeCalls: Array<{ code?: number; reason?: string }> = [];
const ws: any = {
readyState: 1,
isGuest: opts.isGuest,
botScope: opts.botScope,
send: () => {},
on: () => {},
close: (code?: number, reason?: string) => closeCalls.push({ code, reason }),
};
return { ws, closeCalls };
}
it("disabling guest mode closes guest sockets but leaves non-guest sockets open", () => {
const { controller, connect } = makeHarness();
const guest = makeFakeWs({ isGuest: true, botScope: new Set(["bot1"]) });
const member = makeFakeWs({ isGuest: false, botScope: "all" });
connect(guest.ws);
connect(member.ws);
controller.refreshGuestPolicy({ enabled: false, bots: "all" });
expect(guest.closeCalls.length).toBe(1);
expect(guest.closeCalls[0].code).toBe(1008);
expect(member.closeCalls.length).toBe(0);
});
it("narrowing the guest scope live re-scopes open guest sockets", () => {
const { controller, connect } = makeHarness();
const guest = makeFakeWs({ isGuest: true, botScope: new Set(["bot1"]) });
connect(guest.ws);
controller.refreshGuestPolicy({ enabled: true, bots: ["bot2"] });
expect(guest.closeCalls.length).toBe(0);
expect(guest.ws.botScope instanceof Set).toBe(true);
expect(guest.ws.botScope.has("bot2")).toBe(true);
expect(guest.ws.botScope.has("bot1")).toBe(false);
});
});
+59 -14
View File
@@ -3,13 +3,34 @@ import type { BotManager } from "../bot/manager.js";
import type { BotInstance } from "../bot/instance.js";
import type { Logger } from "../logger.js";
export interface WebSocketController {
cleanup: () => void;
/**
* Re-apply the current guest-mode policy to every already-open guest socket.
* If guest mode is disabled, in-flight guest sockets are force-closed; otherwise
* each guest socket is live re-scoped so out-of-scope bots stop streaming.
*/
refreshGuestPolicy: (cfg: { enabled: boolean; bots: "all" | string[] }) => void;
}
export function setupWebSocket(
wss: WebSocketServer,
botManager: BotManager,
logger: Logger
): () => void {
): WebSocketController {
const clients = new Set<WebSocket>();
/**
* Whether a given bot is visible to a WebSocket client. Member/admin clients
* (non-guest) and guests with full scope see everything; scoped guests only
* see bots in their allowed set.
*/
function visibleToClient(ws: WebSocket, botId: string): boolean {
const w = ws as unknown as { isGuest?: boolean; botScope?: "all" | Set<string> };
if (!w.isGuest || w.botScope === "all" || !w.botScope) return true;
return w.botScope.has(botId);
}
/** Track which bot instances have listeners attached (keyed by id, storing ref) */
const attachedBots = new Map<string, {
bot: BotInstance;
@@ -22,7 +43,10 @@ export function setupWebSocket(
clients.add(ws);
logger.debug("WebSocket client connected");
const bots = botManager.getAllBots().map((b) => b.getStatus());
const bots = botManager
.getAllBots()
.filter((b) => visibleToClient(ws, b.id))
.map((b) => b.getStatus());
ws.send(JSON.stringify({ type: "init", bots }));
ws.on("close", () => {
@@ -36,15 +60,15 @@ export function setupWebSocket(
});
});
const broadcast = (data: object) => {
const broadcast = (data: object, botId?: string) => {
const message = JSON.stringify(data);
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
try {
client.send(message);
} catch {
clients.delete(client);
}
if (client.readyState !== WebSocket.OPEN) continue;
if (botId !== undefined && !visibleToClient(client, botId)) continue;
try {
client.send(message);
} catch {
clients.delete(client);
}
}
};
@@ -72,7 +96,7 @@ export function setupWebSocket(
botId: bot.id,
status: bot.getStatus(),
queue: bot.getQueue(),
});
}, bot.id);
};
const onConnected = () => {
@@ -80,7 +104,7 @@ export function setupWebSocket(
type: "botConnected",
botId: bot.id,
status: bot.getStatus(),
});
}, bot.id);
};
const onDisconnected = () => {
@@ -88,7 +112,7 @@ export function setupWebSocket(
type: "botDisconnected",
botId: bot.id,
status: bot.getStatus(),
});
}, bot.id);
};
bot.on("stateChange", onStateChange);
@@ -117,7 +141,7 @@ export function setupWebSocket(
// React when a bot is removed: detach its listener and tell clients to drop it
const onBotInstanceRemoved = (id: string) => {
detachBotListener(id);
broadcast({ type: "botRemoved", botId: id });
broadcast({ type: "botRemoved", botId: id }, id);
};
botManager.on("botInstanceRemoved", onBotInstanceRemoved);
@@ -136,7 +160,7 @@ export function setupWebSocket(
}, 5000);
ensureAllBotsAttached();
return () => {
const cleanup = () => {
clearInterval(intervalId);
botManager.removeListener("botInstance", onBotInstance);
botManager.removeListener("botInstanceRemoved", onBotInstanceRemoved);
@@ -145,4 +169,25 @@ export function setupWebSocket(
detachBotListener(id);
}
};
// When the admin changes guestMode (disable / narrow scope), already-open guest
// sockets must stop streaming immediately — their isGuest/botScope were stamped
// once at upgrade and would otherwise keep receiving bot state.
const refreshGuestPolicy = (cfg: { enabled: boolean; bots: "all" | string[] }) => {
for (const ws of clients) {
const w = ws as unknown as { isGuest?: boolean; botScope?: "all" | Set<string> };
if (!w.isGuest) continue;
if (!cfg.enabled) {
try {
ws.close(1008, "guest mode disabled");
} catch {
// socket may already be closing; ignore
}
} else {
w.botScope = cfg.bots === "all" ? "all" : new Set(cfg.bots);
}
}
};
return { cleanup, refreshGuestPolicy };
}
+19
View File
@@ -0,0 +1,19 @@
@echo off
title TSMusicBot
:: Check node
where node >nul 2>&1
if errorlevel 1 (
echo Node.js not found. Run scripts\setup.bat first.
pause
exit /b 1
)
echo Starting TSMusicBot...
echo WebUI: http://localhost:3000
echo Press Ctrl+C to stop.
echo.
node dist\index.js
pause
+12
View File
@@ -3,6 +3,18 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Bilibili / NetEase / QQ image CDNs reject requests whose Referer is not on
their whitelist, so we must not leak a Referer cross-origin. "same-origin"
does exactly that: full Referer for our own requests, none for cross-origin
ones — so cover thumbnails (<img> AND CSS background-image) still load.
Do NOT switch this back to "no-referrer": per the WHATWG Fetch spec
("Append a request Origin header") no-referrer downgrades the Origin header
to the literal string "null" on same-origin non-GET requests. The /api/*
CSRF guard (src/web/middleware/csrf.ts) then can't parse a host from it and
responds 403 "bad origin", silently breaking EVERY POST/PUT/DELETE — QR
login, cookie save, playback controls, bot management, user admin, etc.
"same-origin" keeps the real Origin on same-origin requests, so CSRF passes. -->
<meta name="referrer" content="same-origin">
<title>TSMusicBot</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;600;700;800&display=swap" rel="stylesheet">
+156 -11
View File
@@ -6,6 +6,7 @@
</main>
<Player />
<Toast />
<Queue class="mobile-queue" :open="mobileQueueOpen" @close="mobileQueueOpen = false" />
<!-- Mobile mini player -->
<div v-if="currentSong" class="m-player" @click="router.push('/lyrics')">
@@ -17,12 +18,38 @@
<div class="m-player-name">{{ currentSong.name }}</div>
<div class="m-player-artist">{{ currentSong.artist }}</div>
</div>
<button class="m-player-btn" @click.stop="playerStore.isPlaying ? playerStore.pause() : playerStore.resume()">
<Icon :icon="playerStore.isPlaying ? 'mdi:pause' : 'mdi:play'" />
</button>
<button class="m-player-btn" @click.stop="playerStore.next()">
<Icon icon="mdi:skip-next" />
</button>
<div class="m-player-controls" @click.stop>
<button v-if="can('player.control')" class="m-player-btn" @click="playerStore.prev()">
<Icon icon="mdi:skip-previous" />
</button>
<button v-if="canTransport" class="m-player-btn" @click="playerStore.isPlaying ? playerStore.pause() : playerStore.resume()">
<Icon :icon="playerStore.isPlaying ? 'mdi:pause' : 'mdi:play'" />
</button>
<button v-if="canSkip" class="m-player-btn" @click="playerStore.next()">
<Icon icon="mdi:skip-next" />
</button>
<button v-if="canModeCtl" class="m-player-btn" @click="cycleMobileMode">
<Icon :icon="mobileModeIcon" />
</button>
<button class="m-player-btn" @click="toggleMobileQueue">
<Icon icon="mdi:playlist-music" />
</button>
<button v-if="canTransport" class="m-player-btn" @click="toggleMobileVolume">
<Icon icon="mdi:volume-high" />
</button>
</div>
<div v-if="mobileVolumeOpen" class="m-volume-popover" @click.stop>
<Icon icon="mdi:volume-high" class="m-volume-icon" />
<input
type="range"
min="0"
max="100"
:value="mobileVolume"
class="m-volume-slider"
@input="onMobileVolumeChange"
/>
<span class="m-volume-value">{{ mobileVolume }}</span>
</div>
</div>
<!-- Mobile bottom tab bar -->
@@ -39,7 +66,7 @@
<Icon icon="mdi:music-box-multiple" class="tab-icon" />
<span class="tab-label">音乐库</span>
</RouterLink>
<RouterLink to="/settings" class="m-tab" :class="{ active: route.path.startsWith('/settings') }">
<RouterLink v-if="!session.isGuest.value" to="/settings" class="m-tab" :class="{ active: route.path.startsWith('/settings') }">
<Icon icon="mdi:cog" class="tab-icon" />
<span class="tab-label">设置</span>
</RouterLink>
@@ -53,17 +80,37 @@ import { useRoute, useRouter } from 'vue-router';
import { Icon } from '@iconify/vue';
import { usePlayerStore } from './stores/player.js';
import { useWebSocket } from './composables/useWebSocket.js';
import { useSession } from './composables/useSession.js';
import Navbar from './components/Navbar.vue';
import Player from './components/Player.vue';
import CoverArt from './components/CoverArt.vue';
import Toast from './components/Toast.vue';
import Queue from './components/Queue.vue';
const playerStore = usePlayerStore();
const session = useSession();
const { can, guestCan } = session;
// Mobile mini-player transport gating — mirrors components/Player.vue.
const canTransport = computed(() => can('player.control') || guestCan('transport'));
const canSkip = computed(() => can('player.control') || guestCan('skip'));
const canModeCtl = computed(() => can('player.control') || guestCan('playMode'));
const theme = computed(() => playerStore.theme);
const route = useRoute();
const router = useRouter();
const { connect } = useWebSocket();
const currentSong = computed(() => playerStore.currentSong);
const mobileVolume = computed(() => playerStore.activeBot?.volume ?? 75);
const mobileMode = computed(() => playerStore.activeBot?.playMode ?? 'seq');
const mobileModeOrder = ['seq', 'loop', 'random', 'rloop'];
const mobileModeIcons: Record<string, string> = {
seq: 'mdi:arrow-right',
loop: 'mdi:repeat',
random: 'mdi:shuffle',
rloop: 'mdi:repeat-once',
};
const mobileModeIcon = computed(() => mobileModeIcons[mobileMode.value] ?? mobileModeIcons.seq);
const mobileVolumeOpen = ref(false);
const mobileQueueOpen = ref(false);
const mobileProgressPct = ref(0);
let syncTimer: ReturnType<typeof setInterval> | null = null;
@@ -77,12 +124,47 @@ function updateMobileProgress() {
mobileRaf = requestAnimationFrame(updateMobileProgress);
}
onMounted(() => {
function onMobileVolumeChange(e: Event) {
const volume = Number((e.target as HTMLInputElement).value);
playerStore.setVolume(volume);
}
function toggleMobileVolume() {
mobileVolumeOpen.value = !mobileVolumeOpen.value;
if (mobileVolumeOpen.value) mobileQueueOpen.value = false;
}
function toggleMobileQueue() {
mobileQueueOpen.value = !mobileQueueOpen.value;
if (mobileQueueOpen.value) mobileVolumeOpen.value = false;
}
function cycleMobileMode() {
const currentIndex = mobileModeOrder.indexOf(mobileMode.value);
const nextMode = mobileModeOrder[(currentIndex + 1) % mobileModeOrder.length] ?? mobileModeOrder[0];
mobileVolumeOpen.value = false;
mobileQueueOpen.value = false;
playerStore.setMode(nextMode);
}
onMounted(async () => {
playerStore.loadTheme();
connect();
playerStore.fetchBots();
// Hydrate favorites once per session so deep-links / hard refreshes onto
// Search or Playlist render hearts correctly without first visiting Home.
// (fire-and-forget; fetchFavorites swallows the 401 when not yet logged in.)
playerStore.fetchFavorites();
syncTimer = setInterval(() => playerStore.syncElapsed(), 3000);
mobileRaf = requestAnimationFrame(updateMobileProgress);
// Reconcile the dedicated-link scope only after the bot list is known: the
// router guard sets scopedBotId tentatively from ?bot, but applyScopeFromQuery
// validates it against the loaded bots (locks if it exists, clears if stale).
await playerStore.fetchBots();
// Read from the authoritative current route (not a possibly-stale reactive
// snapshot) so the scope reconciles against the ?bot present at refresh time.
const routeBot = router.currentRoute.value.query.bot;
const qBot = typeof routeBot === 'string' ? routeBot : null;
playerStore.applyScopeFromQuery(qBot);
});
onUnmounted(() => {
@@ -132,6 +214,14 @@ onUnmounted(() => {
}
}
.mobile-queue {
display: none;
@media (max-width: 768px) {
display: flex;
}
}
.m-player-progress {
position: absolute;
top: 0;
@@ -151,6 +241,13 @@ onUnmounted(() => {
min-width: 0;
}
.m-player-controls {
display: flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.m-player-name {
font-size: 13px;
font-weight: 500;
@@ -168,16 +265,64 @@ onUnmounted(() => {
}
.m-player-btn {
width: 32px;
width: 28px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
font-size: 20px;
opacity: 0.85;
flex-shrink: 0;
}
.m-volume-popover {
position: absolute;
right: 8px;
bottom: calc(100% + 8px);
display: flex;
align-items: center;
gap: 8px;
width: min(260px, calc(100vw - 32px));
padding: 10px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
box-shadow: var(--shadow-dropdown);
cursor: default;
}
.m-volume-icon {
flex: 0 0 auto;
font-size: 18px;
color: var(--text-secondary);
}
.m-volume-slider {
flex: 1 1 auto;
min-width: 0;
height: 4px;
appearance: none;
background: var(--border-color);
border-radius: 2px;
outline: none;
&::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
background: var(--color-primary);
border-radius: 50%;
}
}
.m-volume-value {
flex: 0 0 30px;
font-size: 12px;
color: var(--text-secondary);
text-align: right;
font-variant-numeric: tabular-nums;
}
// Mobile bottom tab bar
.m-tabbar {
position: fixed;
+49
View File
@@ -0,0 +1,49 @@
import router from '../router/index.js';
import { useSession } from '../composables/useSession.js';
let installed = false;
const nativeFetch: typeof window.fetch = window.fetch.bind(window);
/**
* Wraps fetch so every call:
* - sends cookies (`credentials: 'same-origin'`)
* - on 401 from /api/*: clear local session, redirect to /login
*
* Always uses the captured native fetch, never the (possibly wrapped) global.
*/
export function apiFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
const merged: RequestInit = {
credentials: 'same-origin',
...init,
headers: { ...(init.headers ?? {}) },
};
return nativeFetch(input, merged).then(async (res) => {
if (res.status === 401 && shouldTriggerRefresh(input)) {
const session = useSession();
await session.refresh();
const current = router.currentRoute.value;
if (current.name !== 'login' && current.name !== 'first-run') {
await router.replace({ name: 'login', query: { next: current.fullPath } });
}
}
return res;
});
}
function shouldTriggerRefresh(input: RequestInfo | URL): boolean {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
return url.startsWith('/api/') && !url.startsWith('/api/session/');
}
/**
* Replaces window.fetch with apiFetch so existing call sites do not need to be touched.
* Call once at app startup.
*/
export function installApiClient(): void {
if (installed) return;
installed = true;
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
return apiFetch(input, init ?? {});
}) as typeof window.fetch;
(window as unknown as { __originalFetch?: typeof fetch }).__originalFetch = nativeFetch;
}
+99
View File
@@ -0,0 +1,99 @@
<template>
<div class="avatar-upload">
<div class="preview" :class="{ empty: !previewUrl }">
<img v-if="previewUrl" :src="previewUrl" alt="avatar" />
<Icon v-else icon="mdi:account-circle-outline" />
</div>
<div class="actions">
<input
ref="fileInput"
type="file"
accept="image/png,image/jpeg,image/webp"
class="hidden"
@change="onFile"
/>
<button type="button" class="btn-sm" @click="fileInput?.click()">
{{ previewUrl ? '更换' : '上传' }}
</button>
<button v-if="previewUrl" type="button" class="btn-sm btn-danger" @click="clear">
删除
</button>
</div>
<p v-if="error" class="hint error">{{ error }}</p>
<p v-else class="hint">PNG / JPG / WebP,≤200 KB</p>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Icon } from '@iconify/vue';
const props = defineProps<{ modelValue: string | null }>();
const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>();
const previewUrl = ref<string | null>(props.modelValue);
const error = ref<string | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
watch(() => props.modelValue, (v) => { previewUrl.value = v; });
function onFile(ev: Event) {
const file = (ev.target as HTMLInputElement).files?.[0];
if (!file) return;
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
error.value = '仅支持 PNG / JPG / WebP';
return;
}
if (file.size > 200 * 1024) {
error.value = `图片 ${(file.size / 1024).toFixed(0)} KB 超过 200 KB 上限`;
return;
}
error.value = null;
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
previewUrl.value = dataUrl;
emit('update:modelValue', dataUrl);
};
reader.readAsDataURL(file);
}
function clear() {
previewUrl.value = null;
emit('update:modelValue', null);
if (fileInput.value) fileInput.value.value = '';
}
</script>
<style lang="scss" scoped>
.avatar-upload { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
.preview {
width: 80px; height: 80px; border-radius: 50%;
background: var(--bg-card); display: flex; align-items: center; justify-content: center;
overflow: hidden;
img { width: 100%; height: 100%; object-fit: cover; }
&.empty :deep(svg) { font-size: 48px; opacity: 0.4; }
}
.actions { display: flex; gap: 8px; }
.hidden { display: none; }
.btn-sm {
padding: 6px 14px;
background: var(--hover-bg);
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 600;
transition: all var(--transition-fast);
&:hover { background: var(--color-primary); color: white; }
}
.btn-danger {
&:hover { background: #f44336; color: white; }
}
.hint { font-size: 12px; opacity: 0.6; margin: 0; }
.hint.error { color: #f44336; opacity: 1; }
</style>
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more