Compare commits

...
Author SHA1 Message Date
saopig1andClaude Opus 4.8 45f5d236c1 fix(web): tick player time every frame and keep lyrics in sync (#107)
`store.elapsed` is a Pinia getter (a cached Vue computed) that interpolates
with `Date.now()`. Because `Date.now()` is not a reactive dependency, the
computed only re-ran on WebSocket pushes / the 3s server poll, so the bottom
progress bar jumped ~3s at a time and lyric highlighting lagged ~half a line —
even though the consumers read it from a 60fps requestAnimationFrame loop.

Add a pure `interpolateElapsed()` helper and a non-cached `liveElapsed()` store
action. The per-frame consumers now call `liveElapsed()` so the value advances
every frame instead of returning a frozen cache:
- web/src/components/Player.vue  (desktop progress bar, rAF)
- web/src/App.vue                (mobile progress bar, rAF)
- web/src/views/Lyrics.vue       (lyric highlight, 500ms interval)

pause() now freezes at the live value rather than a possibly-stale cached one.
The `elapsed` getter is refactored onto the same helper (behaviour unchanged).

Adds web/src/stores/elapsed.test.ts covering the time-advancing interpolation,
paused freeze, no-anchor, and duration-clamp cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:17:53 +08:00
TIANYAO ZHANG ea6820204d Merge pull request #108 from Fa1nttt/feature/local-audio-upload
feat: add local audio upload playback 增加本地音频上传播放功能
2026-06-30 16:03:32 +08:00
saopig1andClaude Opus 4.8 e849db2286 fix(local-audio): reference-aware cleanup, upload quota, stricter validation
Uploaded local files were deleted whenever a track left the current slot,
with no check on whether the file was still needed — causing data loss in
several flows. Replace with reference-aware cleanup: a file is deleted only
once it has been played AND is no longer referenced by ANY bot's queue
(BotManager.getReferencedLocalSongIds wired into the provider via
setInUseResolver), with the sweep run AFTER each queue mutation.

Fixes:
- play-song replay no longer deletes the file it is about to play
- loop / repeat-all / prev no longer destroy uploads mid-cycle
- a shared upload queued on multiple bots is not deleted while still in use
- !play / play-playlist / play-album clean the whole replaced queue, and an
  empty/failed playlist/album load keeps the previous queue + files intact
- bound disk use with an upload quota (evict oldest UNREFERENCED files)
- validate uploads by extension against the audio whitelist (never trust the
  client Content-Type); the stored extension is always a known audio type

Deletion now unlinks the file FIRST and drops the record only on success,
with a bounded non-blocking retry for briefly-locked files (Windows/ffmpeg),
so a failed unlink never orphans a file or diverges index.json. The quota
never evicts the just-uploaded file, and long filenames keep their extension.

Adds src/music/local.test.ts covering the cleanup lifecycle, quota eviction,
and upload validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:58:22 +08:00
Fa1nttt e12cbf8863 feat: add local audio upload playback 2026-06-30 14:08:42 +08:00
TIANYAO ZHANG 4e148302fc Merge pull request #106 from ZHANGTIANYAO1/fix/guest-play-collection
fix(guest): allow Play All for guests via a dedicated playCollection permission (#103)
2026-06-29 17:39:50 +08:00
saopig1 9c861f487d docs: add playCollection to the guest-permission table (#103) 2026-06-29 12:16:17 +08:00
saopig1 70c0273ae7 fix(guest): add playCollection permission so guests can Play All playlist/album (#103)
- New guest flag playCollection (default OFF), gates play-playlist/play-album
- Keeps playNow's non-destructive semantics intact (Play All clears the queue)
- Admin-toggleable in Settings → 游客模式; default-off, backward-compatible
- Frontend: gate the 播放全部 button on the flag + surface 403 as a toast
  instead of failing silently (the silent-failure half of the issue)
2026-06-29 12:12:47 +08:00
TIANYAO ZHANG e2fa288f48 Merge pull request #105 from Slldyd2077/feat/song-vip-flag
feat: expose vip flag & trial duration on Song for trial-only playback
2026-06-29 11:59:08 +08:00
TIANYAO ZHANG 59a9e742c8 Merge pull request #104 from ZHANGTIANYAO1/feat/ts-command-permissions
feat: TeamSpeak chat-command permission control (adminGroups)
2026-06-28 23:41:34 +08:00
saopig1 31d3830791 test(ts-protocol): smoke-test getClientServerGroups query string + client_servergroups parse 2026-06-28 23:40:30 +08:00
Slldyd2077 d1bd010260 Merge remote-tracking branch 'upstream/main' into feat/song-vip-flag
# Conflicts:
#	src/bot/instance.ts
2026-06-28 21:31:17 +08:00
Slldyd2077 fbb127a86d feat: resolve trial-only playback via trialDuration/effectiveDuration
VIP songs for non-VIP accounts return a ~30s trial fragment. The player used the full duration for isNearEnd, so the trial end didn't trigger auto-advance (~60s stall), and currentSong.duration stayed full, leaving the UI progress stuck.

- provider.ts: SongUrlResult {url, trialDuration?}; getSongUrl signature
- netease.ts: parseNeteaseTrial (freeTrialInfo start/end in seconds) + getSongUrl
- qq.ts: parseQqTrial (isTryout/tryEnd) + getSongUrl
- bilibili/youtube: getSongUrl returns {url}
- instance.ts: resolveAndPlay uses effectiveDuration = trialDuration ?? duration -> nearEnd at trial end -> native auto-advance; BotStatus.effectiveDuration
- VIP account: freeTrialInfo absent -> full duration -> full playback (no toggle)

Backward compatible (optional fields; getSongUrl has a single caller, updated).
Tests: parseTrial assertions (seconds/alias/ms-fallback). 14 pass.
2026-06-28 21:06:52 +08:00
Slldyd2077 7b2bd0ea6a feat: expose vip flag on Song for trial-only detection
Add optional vip?: boolean to the Song interface so downstream clients
(e.g. PowerfulTS) can mark copyright-restricted songs that non-VIP users
can only play as a trial fragment, before playback starts.

- provider.ts: add optional vip?: boolean (backward compatible)
- netease.ts: extract mapNeteaseSongs() pure fn; map fee to vip
  (1=VIP, 4=album-only). fee=8 (free low-quality) is excluded because
  it plays in full, just at lower quality.
- qq.ts: mapQqSongs() maps pay.payplay/paytrackprice to vip (one fix
  covers all callers); getDailyRecommendSongs inline mapping too.
- tests: vip mapping assertions for netease fee (1/4=vip, 0/8=free) and
  qq pay fields.
2026-06-28 17:12:57 +08:00
saopig1andClaude Opus 4.8 8e5e9c810e fix(bot): resolve sender server groups live + server-wide for the admin-command gate
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:51:06 +08:00
saopig1 e104093614 fix: sanitize adminGroups on config load + final-review cleanups 2026-06-26 21:12:56 +08:00
saopig1 b387d6581e docs: TS chat-command permission implementation plan 2026-06-26 20:57:39 +08:00
saopig1 17ab477af6 docs: correct stale adminGroups references now that the feature ships 2026-06-26 20:53:56 +08:00
saopig1 10e29476f4 docs: document TeamSpeak chat-command permission control 2026-06-26 20:51:08 +08:00
saopig1 215e328f17 feat(web): admin-only command-permission (adminGroups) settings section 2026-06-26 20:47:34 +08:00
saopig1 3346286ffd feat(api): read/write adminGroups in bot settings endpoints 2026-06-26 20:44:29 +08:00
saopig1 72ffd44f68 feat(bot): gate admin chat commands on adminGroups with fallback + deny reply 2026-06-26 20:40:01 +08:00
saopig1 b090a8ec21 feat(ts-protocol): surface invokerGroups on TS3TextMessage via pure mapper 2026-06-26 20:35:31 +08:00
saopig1 f98ce47c52 feat(commands): add canRunCommand gate helper + admin-set source of truth 2026-06-26 20:32:45 +08:00
saopig1andClaude Opus 4.8 0c7f7e128b docs: TS chat-command permission control design spec
Binary admin gate keyed on TS server groups (config.adminGroups),
opt-in/backward-compatible (empty = no enforcement), gated in the
chat handler (executeCommand stays agnostic so WebUI is unaffected),
with adminGroups editable from the WebUI settings. Completes the
unused adminGroups/ADMIN_COMMANDS scaffold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 23:04:58 +08:00
TIANYAO ZHANG 0cc77fdee0 Merge pull request #102 from ZHANGTIANYAO1/feat/guest-mode
docs: surface guest mode in feature list + reflect shipped behavior
2026-06-25 16:25:34 +08:00
saopig1andClaude Opus 4.8 3b2b2185a5 docs: surface guest mode in feature list + reflect shipped behavior
- Add a 游客模式 bullet to the top-level 功能特性 list.
- Note guests share one short-lived anonymous identity, and that
  disabling/narrowing takes effect live (incl. open WebSockets).
- Expand the always-denied list to include favorites, change-password,
  and the operator's personal platform-account data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:24:25 +08:00
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
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
99 changed files with 11979 additions and 598 deletions

No files matched your search

+112 -23
View File
@@ -23,14 +23,19 @@
## 功能特性
- **WebUI 鉴权(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员),bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节
- **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节
- **游客模式(免登录点歌,默认关闭)** — 管理员可选择允许访客**无需账号密码**进入 WebUI 点歌,并逐项配置游客权限(8 个开关,默认仅「添加到队列末尾」开启)与可控机器人白名单;游客无法查看 / 修改任何设置、管理机器人或访问用户管理。开启后登录页出现 **「以游客身份进入」**。详见下文 **「游客模式 / Guest mode」** 小节
- **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
- **专属链接(单机器人锁定)** — 通过 `/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站热门视频和个性化推荐(登录后更准确)
@@ -179,6 +184,28 @@ sudo ./scripts/install.sh
- 在 **设置 → 用户管理**(仅管理员)中添加 / 删除 / 重置密码 / 切换角色。
- 至少保留一个管理员:系统会阻止删除或降级最后一位管理员。
**游客模式 / Guest mode**:
让访客**无需账号密码**即可进入 WebUI 点歌,同时严格限制其可用能力。该功能**默认关闭**,只有管理员能开启。
- **开启方式**:管理员在 **设置 → 游客模式** 打开「允许游客访问」(仅管理员可见此区块)。开启后登录页会出现 **「以游客身份进入」** 按钮,访客点击即可创建游客会话,无需任何凭据。游客共享同一匿名身份、会话有效期较短(约 1 天)。关闭游客模式(或缩小机器人作用域)后立即生效,所有在线游客会话——包括正在连接的实时 WebSocket——会被立刻断开 / 重新限制。
- **逐项权限(8 个开关,管理员配置)**:除「添加到队列末尾」外**全部默认关闭**,按需逐项放开。
| 开关 | 字段 | 默认 |
|------|------|------|
| 添加到队列末尾 | `addToQueue` | **开** |
| 添加到下一首 | `playNext` | 关 |
| 立即播放(不清空队列) | `playNow` | 关 |
| 跳过当前歌曲 | `skip` | 关 |
| 暂停/继续/进度/音量 | `transport` | 关 |
| 移除/清空队列 | `removeClear` | 关 |
| 切换播放模式 / FM | `playMode` | 关 |
| 播放整个歌单/专辑 | `playCollection` | 关 |
- **按机器人授权(游客作用域)**:可选择「全部机器人」或指定一份机器人白名单。作用域之外的机器人对游客**不可见、不可控**。
- **游客始终被禁止**:查看或修改任何设置、管理机器人、设置音乐平台账号 / 凭据、修改音质、收藏歌单、修改密码、访问用户管理与操作审计,以及读取机器人主人的私人歌单 / 私人 FM / 每日推荐等平台账号数据。这些限制不受上面 8 个开关影响,**永远锁死**。
- **复现 issue #83 的「下一首 only」需求**:在 **设置 → 游客模式** 中关闭「添加到队列末尾」并打开「添加到下一首」,游客便只能把歌曲加到下一首播放。
**如何重置忘记的管理员密码**:
如果你忘记了管理员密码,可以直接编辑 SQLite 数据库 `data/tsmusicbot.db`:
@@ -197,7 +224,7 @@ sqlite3 data/tsmusicbot.db "UPDATE users SET passwordHash='<paste-hash-here>' WH
**反向代理用户特别注意**:如果通过 nginx / Caddy / Cloudflare 暴露 WebUI,**必须**在 `config.json` 中设置 `"trustProxy": true`,否则 Cookie 不会带 `Secure` 标志,且登录限流会把所有用户合并到同一个桶。详见下方 [反向代理部署注意事项](#反向代理部署注意事项)。
**旧版 `config.adminPassword` / `adminGroups`**:这两个配置项在旧版本中预留但从未实际启用(TS-side admin 命令权限的占位字段)。保留以避免破坏旧 `config.json`,但不再影响任何行为。可以放心忽略。
**`config.adminGroups`(现已启用)**:用于限制管理类聊天命令(`stop`/`clear`/`remove`/`move`/`vol`/`mode`)只能由指定 TeamSpeak 服务器组的成员运行;为空时不做任何限制(向后兼容)。详见 [TeamSpeak 命令权限](#teamspeak-命令权限管理类命令限制)。`config.adminPassword` 则是旧版预留字段,当前版本未使用,保留以兼容旧 `config.json`,可以放心忽略。
### Windows 用户
@@ -274,18 +301,18 @@ sudo systemctl start tsmusicbot
- 机器人昵称
- 可选:服务器密码、默认频道
3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / B 站账号(可选,登录后可播放 VIP 歌曲)
4. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员,成员账号可以控制播放但无法管理其他用户
4. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员。成员默认可控制播放但无法管理其他用户;管理员还可为每个成员单独配置**能力**(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)和**可操作的机器人白名单**,未授权的机器人对该成员不可见、不可控
### WebUI 页面说明
| 页面 | 功能 |
|------|------|
| **首页** | 推荐歌单、每日推荐、私人FM、我的歌单 |
| **搜索** | 三平台统一搜索,结果标注网易云/QQ/B站来源 |
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌) |
| **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达)、我的歌单、收藏的歌单 |
| **搜索** | 三平台统一搜索,结果标注网易云/QQ/B站来源,可一键收藏歌单 |
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 |
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
| **历史** | 播放历史记录 |
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 三平台账号登录 / 音质选择 / 命令前缀 / 用户管理(仅管理员)/ 操作审计(仅管理员) |
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停) / 三平台账号登录 / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) |
### TeamSpeak 文字命令
@@ -293,11 +320,14 @@ 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` | 停止播放并清空队列 |
@@ -310,6 +340,7 @@ sudo systemctl start tsmusicbot
| `!album <ID>` | 加载专辑 |
| `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-b`/`-y`) |
| `!fm` | 私人 FM(网易云,自动续播) |
| `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) |
| `!lyrics` | 显示当前歌词 |
| `!now` | 当前播放信息 |
| `!vote` | 投票跳过当前歌曲 |
@@ -318,6 +349,27 @@ sudo systemctl start tsmusicbot
> 命令前缀默认为 `!`,可在设置页面修改。支持别名:`!p` = `!play`,`!s` = `!skip`,`!n` = `!next`
### TeamSpeak 命令权限(管理类命令限制)
默认情况下,频道里任何人都能运行所有聊天命令。你可以把一组「管理类」命令限制为只有特定 TeamSpeak 服务器组的成员才能运行:
- 受限命令:`stop`、`clear`、`remove`、`move`、`vol`、`mode`
- 其余命令(点歌、队列、跳过、歌词等)始终对所有人开放
- **默认不限制**:管理服务器组列表为空时,所有命令对所有人开放(向后兼容)
**配置方式**
- 网页端:设置 → 命令权限,填写允许的服务器组 ID(逗号分隔),保存即时生效。
- 或编辑 `config.json` 的 `adminGroups`(数字数组),例如 `"adminGroups": [6, 8]`。
填入任意服务器组 ID 后,限制立即开启:只有属于这些组之一的用户才能运行受限命令,其他人会收到「⛔ 需要管理员权限(该命令仅限管理员服务器组)」的提示。
> 提示(fail-closed):当受限命令来自一个机器人当前看不到其服务器组的发送者(例如不在机器人所在频道的私聊),机器人会尝试查询其分组;若仍无法确定,则拒绝执行。
**如何查看服务器组 ID**
在 TeamSpeak 客户端中打开「权限 → 服务器组」(Permissions → Server Groups)对话框,选中某个组后,其 ID 会显示在标题栏/状态栏;或在服务器组管理界面中查看每个组对应的数字 ID。把需要授权的组 ID 填入上面的设置即可。
### 音质等级
| 等级 | 码率 | 格式 | 说明 |
@@ -344,10 +396,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 # 网易云音乐适配器
@@ -364,10 +418,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)
@@ -383,11 +440,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/ # 日志文件
```
## 技术栈
@@ -458,7 +515,7 @@ pip install -U yt-dlp
## 配置文件
`config.json` 在首次运行时自动生成,可手动编辑:
配置文件位于 **`data/config.json`**(与数据库、Cookie、日志同在持久化的 `data/` 目录,Docker 部署对应挂载卷),首次运行时自动生成,可手动编辑:
```json
{
@@ -472,11 +529,16 @@ pip install -U yt-dlp
"adminPassword": "",
"adminGroups": [],
"autoReturnDelay": 300,
"autoPauseOnEmpty": true
"autoPauseOnEmpty": false,
"idleTimeoutMinutes": 0,
"publicUrl": "",
"trustProxy": false
}
```
> **关于 `adminPassword` 和 `adminGroups`**:这两个字段保留是为了兼容旧 `config.json`,但当前版本未使用。WebUI 鉴权改为基于数据库的用户账号系统(见 [首次配置](#首次配置)),无需在 `config.json` 中设置密码。
> **配置文件位置变更**:旧版本把 `config.json` 写在项目根目录(不在 Docker 挂载卷内,导致重启丢失、手动编辑不生效)。现在统一放在 `data/config.json`。升级时若检测到根目录存在旧的 `config.json`,会在首次启动时自动迁移到 `data/` 并保留你的设置,无需手动操作。
> **关于 `adminPassword` 和 `adminGroups`**:`adminGroups` 现已启用,用于限制管理类聊天命令只能由指定 TeamSpeak 服务器组运行(为空 = 不限制),详见 [TeamSpeak 命令权限](#teamspeak-命令权限管理类命令限制)。`adminPassword` 仍为旧版预留字段、当前版本未使用——WebUI 鉴权改为基于数据库的用户账号系统(见 [首次配置](#首次配置)),无需在 `config.json` 中设置密码。
### 反向代理部署注意事项
@@ -499,6 +561,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 <频道名>` 命令,或在设置页面创建机器人时指定默认频道。
@@ -531,7 +596,16 @@ A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm
A:直接操作 SQLite 数据库。最简单的办法是清空 `users` 表然后重新进入 first-run 流程:`sqlite3 data/tsmusicbot.db "DELETE FROM users; DELETE FROM sessions;"`,重启后浏览器会自动跳转 `/first-run` 让你重新创建管理员。详细方法见 [从 WebUI 无鉴权版本升级](#从-webui-无鉴权版本升级重要)。
**Q:成员(member)能做什么?不能做什么?**
A:成员可以:管理机器人(启动/停止/创建/编辑)、控制播放(搜索/播放/队列)、登录音乐平台账号、修改自己的密码。成员**不能**:管理其他用户、查看操作审计日志、降级或删除管理员。
A:成员默认可以:管理机器人(启动/停止/创建/编辑)、控制播放(搜索/播放/队列)、登录音乐平台账号、修改自己的密码。成员**始终不能**:管理其他用户、查看操作审计日志、降级或删除管理员。此外管理员可在 **设置 → 用户管理** 为每个成员单独**收紧权限**:勾选允许的能力(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)以及可操作的机器人白名单——未授权的能力会返回 403,未授权的机器人对该成员不可见也不可控。管理员不受任何限制。
**Q:收藏的歌单存在哪里?其他用户能看到吗?**
A:收藏按用户存储在本地 SQLite 数据库(`favorite_playlists` 表),仅本人可见,登录后跨设备同步。在首页、搜索结果或歌单页点击收藏图标即可增删。
**Q:什么是"专属链接"?怎么用?**
A:通过 `/bot/<机器人ID>` 打开 WebUI 会把界面锁定到该机器人(顶部显示"专属模式",刷新后保持),适合把单台机器人的控制页分享给特定用户。点击"退出"可返回多机器人视图。注意:专属链接只是 UI 层的锁定,真正的访问控制由成员权限(机器人白名单)在后端强制。
**Q:机器人播放时突然自动暂停了?**
A:这是"频道无人时自动暂停"功能:当机器人所在频道没有其他人时会自动暂停,有人加入后自动恢复,避免空播。该功能**默认关闭**,仅在你于 **设置 → 行为设置** 开启后生效;如需停用,在同一页面关闭即可。(占用检测依赖 TeamSpeak 的 `clientlist` 命令,部分服务器在频道有其他人时可能查询失败——此时机器人会按"占用情况未知"处理,不会误暂停。)
**Q:如何把某个用户从成员升级为管理员?**
A:管理员登录后进入 **设置 → 用户管理**,点击对应用户的"提升管理员"按钮即可。降级同理("降为成员"按钮)。系统会阻止降级最后一位管理员。
@@ -556,6 +630,21 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
### 最新版本
**功能增强:细粒度权限 / 本地收藏 / 本地音频上传 / 专属链接 / 自动暂停 / QQ 雷达 FM**
- **细粒度账号权限**(叠加在 admin / member 之上):管理员可为每个成员勾选 5 项能力(`player.control` / `player.queue` / `bot.manage` / `platform.auth` / `quality`)和按机器人授权白名单;所有变更路由由后端 `requirePermission` / `requireBotAccess` 中间件逐请求强制校验,未授权返回 403,未授权的机器人对成员不可见(列表过滤,无 403-vs-404 枚举泄漏)。已有成员经一次性迁移获得全部能力,新成员默认基础能力。
- **本地收藏歌单**:按用户存储的收藏(`favorite_playlists` 表 + `/api/favorites`),首页 / 搜索 / 歌单页一键收藏,跨设备同步。
- **本地音频上传播放**:搜索页支持拖拽 / 选择本地音频上传(保存到 `data/local-audio`),上传后可像普通歌曲一样播放、下一首播放或加入队列;设置 → 行为设置 中新增「本地音频播放」开关,关闭后拒绝新的本地上传和本地歌曲播放请求。播放结束或停止 / 清空 / 替换队列时会从服务端删除已接收文件并更新索引。
- **专属链接(单机器人锁定)**:`/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-无鉴权版本升级重要)。
@@ -566,7 +655,7 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
- **会话存储**:服务端 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`,但不再影响任何行为。
- **配置变更**:反向代理部署务必 `"trustProxy": true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。`config.adminGroups` 现已启用,用于限制管理类聊天命令只能由指定 TeamSpeak 服务器组运行(为空 = 不限制,详见 [TeamSpeak 命令权限](#teamspeak-命令权限管理类命令限制));`config.adminPassword` 仍为旧版预留字段,保留以兼容旧 `config.json`,当前未使用。
### v0.x — Bot Profile 自动更新与协议层升级
@@ -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,840 @@
# TeamSpeak chat-command permission control — 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:** Gate a fixed set of "admin" TeamSpeak chat commands (`stop`, `clear`, `remove`, `move`, `vol`, `mode`) behind configured TS server-group IDs, opt-in and backward-compatible, configurable from the WebUI and `config.json`.
**Architecture:** A pure helper `canRunCommand(name, invokerGroups, adminGroups)` decides allow/deny. The chat handler `handleTextMessage` (NOT the WebUI-shared `executeCommand`) consults it before executing, performs a best-effort group lookup when the sender's groups weren't delivered with the event, fails closed, and replies on deny. The privileged groups live in the already-declared `config.adminGroups`, surfaced through the existing `GET/POST /api/bot/settings` endpoints and an admin-only Settings.vue section.
**Tech Stack:** Node 20, TypeScript (ESM), Express 5, Vitest + supertest (backend), Vue 3 + `vue-tsc` (frontend), `@honeybbq/teamspeak-client`.
## Global Constraints
- **ESM import specifiers:** every relative import ends in `.js` even in `.ts` files (e.g. `import { canRunCommand } from "./commands.js"`).
- **Admin command set (exact, single source of truth):** `stop`, `clear`, `remove`, `move`, `vol`, `mode`. Everything else is public. (Note: `follow` is intentionally NOT admin — it becomes public.)
- **Enforcement is opt-in / backward-compatible:** `config.adminGroups === []` (the default) ⇒ no enforcement; admin commands stay open to everyone exactly as today.
- **Fail closed:** an admin command, with enforcement on, whose sender groups cannot be determined (even after fallback) is **denied**.
- **Group-id normalization:** `invokerGroups` are strings, `adminGroups` are numbers — compare as the same type so `"6"` matches `6`.
- **Denial reply text (exact):** `⛔ 需要管理员权限(该命令仅限管理员服务器组)`.
- **`adminGroups` validation:** array of non-negative integers; filter out everything else; ignore a non-array value entirely.
- **Live config:** `BotInstance` shares the same `config` object the router mutates; the gate reads `this.config.adminGroups` live (no restart, no propagation call).
- **Per-task tests:** run `npx vitest run <file>` (targets `.ts` directly). Before any full `npm test`, run `rm -rf dist` first — a stale untracked `dist/` makes vitest double-run compiled `.test.js` copies (known environment quirk). The repo path contains spaces (`/c/Users/saopig1/Music/teamspeak music bot`) — quote it.
- **Frontend type-check:** `cd web && npx vue-tsc --noEmit` (must be clean).
- **TDD + frequent commits:** every task is red→green→commit. Keep project `tsc`/`vitest` green after each task.
---
### Task 1: `canRunCommand` helper + admin-set as single source of truth
**Files:**
- Modify: `src/bot/commands.ts` (lines 8-16 sets; line 59-61 `isAdminCommand`)
- Test: `src/bot/commands.test.ts` (append a new `describe` block)
**Interfaces:**
- Consumes: nothing from other tasks.
- Produces:
- `export const ADMIN_COMMANDS: Set<string>` = `{stop, clear, remove, move, vol, mode}`
- `export function isAdminCommand(commandName: string): boolean` (unchanged signature)
- `export function canRunCommand(commandName: string, invokerGroups: readonly (string | number)[], adminGroups: readonly number[]): boolean` — consumed by Task 3.
- [ ] **Step 1: Write the failing tests**
Append to `src/bot/commands.test.ts`:
```ts
import { canRunCommand, isAdminCommand } from "./commands.js";
describe("isAdminCommand classification", () => {
it("treats stop/clear/remove/move/vol/mode as admin", () => {
for (const c of ["stop", "clear", "remove", "move", "vol", "mode"]) {
expect(isAdminCommand(c)).toBe(true);
}
});
it("treats follow and play as NOT admin", () => {
expect(isAdminCommand("follow")).toBe(false);
expect(isAdminCommand("play")).toBe(false);
});
});
describe("canRunCommand", () => {
it("allows any public command regardless of groups", () => {
expect(canRunCommand("play", [], [6])).toBe(true);
expect(canRunCommand("follow", [], [6])).toBe(true);
});
it("allows admin command when enforcement is off (empty adminGroups)", () => {
expect(canRunCommand("stop", [], [])).toBe(true);
});
it("allows admin command when an invoker group matches (string vs number)", () => {
expect(canRunCommand("stop", ["6"], [6])).toBe(true);
expect(canRunCommand("stop", [6], [6])).toBe(true);
expect(canRunCommand("vol", ["8", "6"], [6])).toBe(true);
});
it("denies admin command when no invoker group matches", () => {
expect(canRunCommand("stop", ["8"], [6])).toBe(false);
});
it("denies admin command when invoker has no groups and enforcement is on", () => {
expect(canRunCommand("clear", [], [6])).toBe(false);
});
});
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `npx vitest run "src/bot/commands.test.ts"`
Expected: FAIL — `canRunCommand` is not exported / not a function.
- [ ] **Step 3: Implement the helper and tighten the admin set**
In `src/bot/commands.ts`, delete the dead `PUBLIC_COMMANDS` export (nothing imports it; the admin set is the sole source of truth), set `ADMIN_COMMANDS` to the exact spec set (drop `follow`), and add `canRunCommand`. The file becomes:
```ts
export interface ParsedCommand {
name: string;
args: string;
rawArgs: string[];
flags: Set<string>;
}
/**
* The fixed set of "admin" chat commands. This is the SINGLE source of truth
* for which commands the permission gate restricts; reclassifying a command is
* a one-line edit here. Everything not in this set is public.
*/
export const ADMIN_COMMANDS = new Set([
"stop", "clear", "remove", "move", "vol", "mode",
]);
export function parseCommand(
message: string,
prefix: string,
aliases: Record<string, string> = {},
): ParsedCommand | null {
const trimmed = message.trim();
if (!trimmed.startsWith(prefix)) return null;
const withoutPrefix = trimmed.slice(prefix.length);
if (!withoutPrefix) return null;
const parts = withoutPrefix.split(/\s+/);
let name = parts[0].toLowerCase();
if (aliases[name]) {
name = aliases[name];
}
const flags = new Set<string>();
const argParts: string[] = [];
for (let i = 1; i < parts.length; i++) {
if (
parts[i].startsWith("-") &&
parts[i].length === 2 &&
/[a-zA-Z]/.test(parts[i][1])
) {
flags.add(parts[i][1].toLowerCase());
} else {
argParts.push(parts[i]);
}
}
return {
name,
args: argParts.join(" "),
rawArgs: argParts,
flags,
};
}
export function isAdminCommand(commandName: string): boolean {
return ADMIN_COMMANDS.has(commandName);
}
/**
* Decide whether a chat command may run, given the invoker's TS server groups
* and the configured admin groups. Pure + synchronous so it is trivially unit
* tested and reused by the async gate in BotInstance.
*
* Allowed iff: (1) it is a public command, OR (2) enforcement is off
* (adminGroups empty), OR (3) some invoker group is in adminGroups.
* invokerGroups (strings from TS) and adminGroups (numbers) are normalized to
* strings before comparison so "6" matches 6.
*/
export function canRunCommand(
commandName: string,
invokerGroups: readonly (string | number)[],
adminGroups: readonly number[],
): boolean {
if (!isAdminCommand(commandName)) return true;
if (adminGroups.length === 0) return true;
const admin = new Set(adminGroups.map((g) => String(g)));
return invokerGroups.some((g) => admin.has(String(g)));
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `npx vitest run "src/bot/commands.test.ts"`
Expected: PASS (parser tests + the new classification/canRunCommand tests).
- [ ] **Step 5: Verify nothing else imported the deleted symbol**
Run: `grep -rn "PUBLIC_COMMANDS" src/`
Expected: no matches (confirms the deletion is safe).
- [ ] **Step 6: Commit**
```bash
git add "src/bot/commands.ts" "src/bot/commands.test.ts"
git commit -m "feat(commands): add canRunCommand gate helper + admin-set source of truth"
```
---
### Task 2: Surface `invokerGroups` on `TS3TextMessage`
**Files:**
- Modify: `src/ts-protocol/client.ts` (interface lines 58-64; mapping lines 205-214)
- Test: `src/ts-protocol/text-message.test.ts` (new)
**Interfaces:**
- Consumes: nothing from other tasks.
- Produces:
- `TS3TextMessage` gains `invokerGroups: string[]`.
- `export function toTS3TextMessage(msg: TextMessage): TS3TextMessage` — a pure mapper, used by the `textMessage` event handler and unit-testable. Consumed (the field) by Task 3.
- [ ] **Step 1: Write the failing test**
Create `src/ts-protocol/text-message.test.ts`:
```ts
import { describe, it, expect } from "vitest";
import { toTS3TextMessage } from "./client.js";
import type { TextMessage } from "@honeybbq/teamspeak-client";
function makeMsg(over: Partial<TextMessage> = {}): TextMessage {
return {
invokerName: "Alice",
invokerUID: "uid-abc",
message: "!stop",
invokerGroups: ["6", "8"],
targetMode: 2,
targetID: 0n,
invokerID: 5,
...over,
};
}
describe("toTS3TextMessage", () => {
it("maps core fields and stringifies invokerID", () => {
const r = toTS3TextMessage(makeMsg());
expect(r.invokerName).toBe("Alice");
expect(r.invokerId).toBe("5");
expect(r.invokerUid).toBe("uid-abc");
expect(r.message).toBe("!stop");
expect(r.targetMode).toBe(2);
});
it("preserves the sender's server groups", () => {
expect(toTS3TextMessage(makeMsg({ invokerGroups: ["6"] })).invokerGroups).toEqual(["6"]);
});
it("defaults missing invokerGroups to an empty array", () => {
const partial = {
invokerName: "Bob",
invokerUID: "u",
message: "!stop",
targetMode: 1,
targetID: 0n,
invokerID: 7,
} as unknown as TextMessage;
expect(toTS3TextMessage(partial).invokerGroups).toEqual([]);
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npx vitest run "src/ts-protocol/text-message.test.ts"`
Expected: FAIL — `toTS3TextMessage` is not exported.
- [ ] **Step 3: Add the field and the pure mapper, and use it in the handler**
In `src/ts-protocol/client.ts`, extend the interface (add `invokerGroups`):
```ts
export interface TS3TextMessage {
invokerName: string;
invokerId: string;
invokerUid: string;
message: string;
targetMode: number; // 1=private, 2=channel, 3=server
invokerGroups: string[]; // sender's TS server-group ids; [] when not in view cache
}
```
Add the pure mapper just below the interface (still above the `TS3Client` class):
```ts
/**
* Map the library's TextMessage to our wrapper. Preserves invokerGroups (the
* sender's TS server groups), which the library populates only when the sender
* is in the bot's client-view cache; otherwise it is []. Used by the chat
* command permission gate.
*/
export function toTS3TextMessage(msg: TextMessage): TS3TextMessage {
return {
invokerName: msg.invokerName,
invokerId: String(msg.invokerID),
invokerUid: msg.invokerUID,
message: msg.message,
targetMode: msg.targetMode,
invokerGroups: msg.invokerGroups ?? [],
};
}
```
Replace the inline mapping inside `this.client.on("textMessage", ...)` (currently lines 205-214) with a call to the mapper:
```ts
this.client.on("textMessage", (msg: TextMessage) => {
this.emit("textMessage", toTS3TextMessage(msg));
});
```
(`TextMessage` is already imported at the top of the file.)
- [ ] **Step 4: Run the test to verify it passes**
Run: `npx vitest run "src/ts-protocol/text-message.test.ts"`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add "src/ts-protocol/client.ts" "src/ts-protocol/text-message.test.ts"
git commit -m "feat(ts-protocol): surface invokerGroups on TS3TextMessage via pure mapper"
```
---
### Task 3: Permission gate in `handleTextMessage` (fallback lookup + fail-closed + denial reply)
**Files:**
- Modify: `src/bot/instance.ts` (imports lines 10-14; add a module constant; `handleTextMessage` lines 317-349; add two private methods)
- Test: `src/bot/instance.test.ts` (append a new `describe` block)
**Interfaces:**
- Consumes:
- `canRunCommand(commandName, invokerGroups, adminGroups)` from `./commands.js` (Task 1).
- `TS3TextMessage.invokerGroups: string[]` (Task 2).
- Existing `this.tsClient.getClientsInChannel(): Promise<ClientInfo[]>` where each `ClientInfo` has `id: number` and `serverGroups: string[]` (library already parses these).
- Existing `this.tsClient.sendTextMessage(message: string, targetMode?: number): Promise<void>`.
- Produces:
- `export const COMMAND_DENIED_MESSAGE: string` (exported so the test can assert it).
- Private `isCommandAllowed(commandName, msg)` and `lookupInvokerGroups(invokerId)` (exercised via prototype in the test).
- [ ] **Step 1: Write the failing tests**
Append to `src/bot/instance.test.ts`:
```ts
import { vi } from "vitest";
import { COMMAND_DENIED_MESSAGE } from "./instance.js";
import type { TS3TextMessage } from "../ts-protocol/client.js";
/** Minimal `this` carrying only what handleTextMessage's gate path touches.
* The gate methods live on the prototype and are attached here so calls like
* `this.isCommandAllowed(...)` resolve against this same object. */
function makeGateCtx(opts: {
adminGroups?: number[];
clients?: Array<{ id: number; serverGroups: string[] }>;
}) {
const ctx: any = {
config: { commandPrefix: "!", commandAliases: {}, adminGroups: opts.adminGroups ?? [] },
logger: { info: vi.fn(), error: vi.fn() },
tsClient: {
sendTextMessage: vi.fn(async () => {}),
getClientsInChannel: vi.fn(async () => opts.clients ?? []),
},
executeCommand: vi.fn(async () => null),
isCommandAllowed: (BotInstance.prototype as any).isCommandAllowed,
lookupInvokerGroups: (BotInstance.prototype as any).lookupInvokerGroups,
};
return ctx;
}
function makeMsg(message: string, invokerGroups: string[] = [], invokerId = "5"): TS3TextMessage {
return { invokerName: "Tester", invokerId, invokerUid: "uid", message, targetMode: 2, invokerGroups };
}
const handleTextMessage = (BotInstance.prototype as any).handleTextMessage as (
this: unknown,
msg: TS3TextMessage,
) => Promise<void>;
describe("BotInstance.handleTextMessage — command permission gate", () => {
it("runs a public command even with enforcement on", async () => {
const ctx = makeGateCtx({ adminGroups: [6] });
await handleTextMessage.call(ctx, makeMsg("!play 晴天"));
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
expect(ctx.tsClient.sendTextMessage).not.toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
});
it("runs an admin command when enforcement is off (empty adminGroups)", async () => {
const ctx = makeGateCtx({ adminGroups: [] });
await handleTextMessage.call(ctx, makeMsg("!stop"));
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
});
it("runs an admin command when the event carried a matching group", async () => {
const ctx = makeGateCtx({ adminGroups: [6] });
await handleTextMessage.call(ctx, makeMsg("!stop", ["6"]));
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
expect(ctx.tsClient.getClientsInChannel).not.toHaveBeenCalled(); // no fallback needed
});
it("denies an admin command when known groups do not match (no fallback, with reply)", async () => {
const ctx = makeGateCtx({ adminGroups: [6] });
await handleTextMessage.call(ctx, makeMsg("!stop", ["8"]));
expect(ctx.executeCommand).not.toHaveBeenCalled();
expect(ctx.tsClient.getClientsInChannel).not.toHaveBeenCalled();
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
});
it("falls back to a group lookup when the event carried no groups, and allows on match", async () => {
const ctx = makeGateCtx({ adminGroups: [6], clients: [{ id: 5, serverGroups: ["6"] }] });
await handleTextMessage.call(ctx, makeMsg("!stop", [], "5"));
expect(ctx.tsClient.getClientsInChannel).toHaveBeenCalledTimes(1);
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
});
it("fails closed when the fallback finds the client but no matching group", async () => {
const ctx = makeGateCtx({ adminGroups: [6], clients: [{ id: 5, serverGroups: ["8"] }] });
await handleTextMessage.call(ctx, makeMsg("!stop", [], "5"));
expect(ctx.executeCommand).not.toHaveBeenCalled();
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
});
it("fails closed when the fallback cannot find the client at all", async () => {
const ctx = makeGateCtx({ adminGroups: [6], clients: [] });
await handleTextMessage.call(ctx, makeMsg("!stop", [], "5"));
expect(ctx.executeCommand).not.toHaveBeenCalled();
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
});
});
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `npx vitest run "src/bot/instance.test.ts"`
Expected: FAIL — `COMMAND_DENIED_MESSAGE` is not exported; `isCommandAllowed`/`lookupInvokerGroups` are undefined.
- [ ] **Step 3: Implement the gate**
In `src/bot/instance.ts`, change the commands import (lines 10-14) from `isAdminCommand` to `canRunCommand`:
```ts
import {
parseCommand,
canRunCommand,
type ParsedCommand,
} from "./commands.js";
```
Add a module-level constant just after the imports (above `export interface BotInstanceOptions`):
```ts
/** Reply sent when a non-admin invokes an admin-only chat command. */
export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
```
Replace `handleTextMessage` (lines 317-349) so the dead stub becomes the real gate:
```ts
private async handleTextMessage(msg: TS3TextMessage): Promise<void> {
const parsed = parseCommand(
msg.message,
this.config.commandPrefix,
this.config.commandAliases
);
if (!parsed) return;
if (!(await this.isCommandAllowed(parsed.name, msg))) {
this.logger.info(
{ command: parsed.name, invoker: msg.invokerName },
"Command denied: invoker not in adminGroups"
);
try {
await this.tsClient.sendTextMessage(COMMAND_DENIED_MESSAGE);
} catch (sendErr) {
this.logger.error({ err: sendErr }, "Failed to send permission-denied message to chat");
}
return;
}
this.logger.info(
{ command: parsed.name, args: parsed.args, invoker: msg.invokerName },
"Command received"
);
try {
const response = await this.executeCommand(parsed, msg);
if (response) {
await this.tsClient.sendTextMessage(response);
}
} catch (err) {
this.logger.error({ err, command: parsed.name }, "Command execution error");
try {
await this.tsClient.sendTextMessage(
`Error: ${(err as Error).message}`
);
} catch (sendErr) {
this.logger.error({ err: sendErr }, "Failed to send error message to chat");
}
}
}
/**
* Decide whether a chat command may run for this sender. Reads adminGroups
* live from this.config (the router mutates the same object). Only performs
* the async group lookup when the synchronous decision is "deny because the
* event carried no groups" — i.e. an admin command, enforcement on, and
* empty invokerGroups. Fails closed if groups remain undeterminable.
*/
private async isCommandAllowed(commandName: string, msg: TS3TextMessage): Promise<boolean> {
const adminGroups = this.config.adminGroups;
if (canRunCommand(commandName, msg.invokerGroups, adminGroups)) return true;
// Here: admin command, enforcement on, and the provided groups did not match.
// If the event actually carried groups, this is a genuine deny — no lookup.
if (msg.invokerGroups.length > 0) return false;
// Groups unknown (sender not in the view cache): one targeted lookup, then
// re-decide. canRunCommand([], …) is false ⇒ fail-closed when still unknown.
const groups = await this.lookupInvokerGroups(msg.invokerId);
return canRunCommand(commandName, groups, adminGroups);
}
/**
* Best-effort lookup of a sender's server groups by client id, via the
* channel client list (whose entries already carry parsed serverGroups).
* Returns [] when the client can't be found or the query fails (→ deny).
*/
private async lookupInvokerGroups(invokerId: string): Promise<string[]> {
const clid = Number(invokerId);
if (!Number.isFinite(clid) || clid <= 0) return [];
try {
const clients = await this.tsClient.getClientsInChannel();
const match = clients.find((c) => c.id === clid);
return match?.serverGroups ?? [];
} catch {
return [];
}
}
```
- [ ] **Step 4: Run the gate tests to verify they pass**
Run: `npx vitest run "src/bot/instance.test.ts"`
Expected: PASS (existing `runExclusive` tests + the 7 new gate tests).
- [ ] **Step 5: Confirm the live-config invariant**
Confirm `BotInstance` reads `adminGroups` from the shared, mutable config — not a copy. The constructor stores `this.config = options.config` (line 91 region) and the router (`src/web/api/bot.ts`) mutates that same object; no propagation call is needed. Quick check:
Run: `grep -n "this.config = options.config\|this.config.adminGroups" "src/bot/instance.ts"`
Expected: shows the assignment and the gate read (proves the gate uses the live reference).
- [ ] **Step 6: Commit**
```bash
git add "src/bot/instance.ts" "src/bot/instance.test.ts"
git commit -m "feat(bot): gate admin chat commands on adminGroups with fallback + deny reply"
```
---
### Task 4: Read/write `adminGroups` in the settings endpoints
**Files:**
- Modify: `src/web/api/bot.ts` (GET `/settings` lines 35-41; POST `/settings` lines 45-97)
- Test: `src/web/api/bot.test.ts` (append `it` cases to the first `describe("bot router /settings", …)` block)
**Interfaces:**
- Consumes: existing `config.adminGroups: number[]` (already declared in `src/data/config.ts`, default `[]`).
- Produces: `GET /api/bot/settings` returns `adminGroups: number[]`; `POST /api/bot/settings` accepts, validates, persists, and echoes `adminGroups`.
- [ ] **Step 1: Write the failing tests**
Append these `it` cases inside the existing first `describe("bot router /settings", …)` block in `src/web/api/bot.test.ts` (it already wires `app`, `config`, and an admin `cookie`):
```ts
it("GET /settings includes adminGroups reflecting config", async () => {
config.adminGroups = [6, 8];
const res = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.adminGroups).toEqual([6, 8]);
});
it("POST /settings persists a validated adminGroups and GET returns it", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ adminGroups: [6, 8] });
expect(res.status).toBe(200);
expect(res.body.adminGroups).toEqual([6, 8]);
expect(config.adminGroups).toEqual([6, 8]);
const followUp = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(followUp.body.adminGroups).toEqual([6, 8]);
});
it("POST /settings filters invalid adminGroups entries (negative, non-integer, non-number)", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ adminGroups: [6, -1, 2.5, "x", 8] });
expect(res.status).toBe(200);
expect(config.adminGroups).toEqual([6, 8]);
});
it("POST /settings ignores a non-array adminGroups (leaves config unchanged)", async () => {
config.adminGroups = [6];
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ adminGroups: "6" });
expect(res.status).toBe(200);
expect(config.adminGroups).toEqual([6]);
});
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `npx vitest run "src/web/api/bot.test.ts"`
Expected: FAIL — `res.body.adminGroups` is `undefined`; the POST does not persist `adminGroups`.
- [ ] **Step 3: Extend the GET handler**
In `src/web/api/bot.ts`, add `adminGroups` to the GET `/settings` response (the handler at lines 35-41):
```ts
router.get("/settings", requireNotGuest, (_req, res) => {
res.json({
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty,
adminGroups: config.adminGroups ?? [],
guestMode: config.guestMode,
});
});
```
- [ ] **Step 4: Extend the POST handler**
In the POST `/settings` handler: (a) pull `adminGroups` out of `req.body`; (b) validate + assign before `saveConfig`; (c) echo it in the response. Change the destructuring line (46):
```ts
const { idleTimeoutMinutes, autoPauseOnEmpty, guestMode, adminGroups } = req.body;
```
Add this block just before `saveConfig(configPath, config);` (line 77):
```ts
if (Array.isArray(adminGroups)) {
config.adminGroups = adminGroups.filter(
(g: unknown): g is number =>
typeof g === "number" && Number.isInteger(g) && g >= 0,
);
}
```
Add `adminGroups` to BOTH `res.json({ … })` bodies in this handler (the success response near line 92, and — if present — keep them consistent):
```ts
res.json({
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty,
adminGroups: config.adminGroups ?? [],
guestMode: config.guestMode,
});
```
- [ ] **Step 5: Run the tests to verify they pass**
Run: `npx vitest run "src/web/api/bot.test.ts"`
Expected: PASS (existing settings/guest-mode tests + the 4 new adminGroups tests).
- [ ] **Step 6: Commit**
```bash
git add "src/web/api/bot.ts" "src/web/api/bot.test.ts"
git commit -m "feat(api): read/write adminGroups in bot settings endpoints"
```
---
### Task 5: Admin-only "命令权限" section in Settings.vue
**Files:**
- Modify: `web/src/views/Settings.vue` (template: add a section after the Guest Mode section, before the Bot Profile section ~line 506; script: add state + handlers near the guest-mode block ~line 1093; hydrate in `loadIdleTimeout` ~line 1024)
**Interfaces:**
- Consumes: `GET /api/bot/settings` → `adminGroups: number[]`; `POST /api/bot/settings` with `{ adminGroups: number[] }` (Task 4). Existing `session.isAdmin.value`.
- Produces: UI only.
- [ ] **Step 1: Add the template section**
In `web/src/views/Settings.vue`, insert this `<section>` immediately AFTER the closing `</section>` of the Guest Mode block (the one whose title is `游客模式`, ends ~line 505) and BEFORE the `<!-- Bot Profile … -->` section:
```html
<!-- Command Permissions (admin only) -->
<section v-if="session.isAdmin.value" class="settings-section">
<h2 class="section-title">命令权限</h2>
<p class="profile-section-hint">
限制谁能在 TeamSpeak 聊天里运行管理类命令(stop / clear / remove / move / vol / mode)。
填写允许的服务器组 ID(逗号分隔)。留空 = 不限制,所有人可用。如何查看服务器组 ID 见 README。
</p>
<div class="setting-row">
<div class="prefix-input-wrap">
<input v-model="adminGroupsText" class="input input-sm" placeholder="如 6, 8" />
<button class="btn-primary" :disabled="adminGroupsSaving" @click="saveAdminGroups">
{{ adminGroupsSaving ? '保存中…' : '保存' }}
</button>
</div>
</div>
</section>
```
- [ ] **Step 2: Add the script state + handlers**
In the `<script setup>` block, add this just after the guest-mode block (after `saveGuestMode` closes, ~line 1093):
```ts
// --- Command permissions (admin only) ---
const adminGroupsText = ref('');
const adminGroupsSaving = ref(false);
function applyAdminGroupsFromServer(groups: unknown) {
if (Array.isArray(groups)) {
adminGroupsText.value = groups.filter((g) => typeof g === 'number').join(', ');
}
}
function parseAdminGroups(text: string): number[] {
return text
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0)
.map((s) => Number(s))
.filter((n) => Number.isInteger(n) && n >= 0);
}
async function saveAdminGroups() {
adminGroupsSaving.value = true;
try {
const res = await axios.post('/api/bot/settings', { adminGroups: parseAdminGroups(adminGroupsText.value) });
applyAdminGroupsFromServer(res.data?.adminGroups);
} catch { /* ignore */ } finally {
adminGroupsSaving.value = false;
}
}
```
- [ ] **Step 3: Hydrate on load**
In `loadIdleTimeout` (the existing function ~lines 1024-1031), add the hydrate call alongside `applyGuestModeFromServer`:
```ts
async function loadIdleTimeout() {
try {
const res = await axios.get('/api/bot/settings');
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false;
applyGuestModeFromServer(res.data.guestMode);
applyAdminGroupsFromServer(res.data.adminGroups);
} catch { /* ignore */ }
}
```
- [ ] **Step 4: Type-check the frontend**
Run: `cd "web" && npx vue-tsc --noEmit`
Expected: no errors.
- [ ] **Step 5: Commit**
```bash
git add "web/src/views/Settings.vue"
git commit -m "feat(web): admin-only command-permission (adminGroups) settings section"
```
---
### Task 6: Document the feature in the README
**Files:**
- Modify: `README.md`
**Interfaces:**
- Consumes: nothing (docs).
- Produces: user-facing documentation of the feature + how to find TS server-group IDs.
- [ ] **Step 1: Locate the insertion point**
Run: `grep -n "游客模式\|Guest\|权限\|adminGroups" "README.md"`
Expected: shows the guest-mode / permissions area. Insert the new subsection immediately after the guest-mode documentation block (or, if there is a dedicated permissions/features section, at its end).
- [ ] **Step 2: Add the documentation block**
Insert this markdown at the chosen point:
```markdown
### TeamSpeak 命令权限(管理类命令限制)
默认情况下,频道里任何人都能运行所有聊天命令。你可以把一组「管理类」命令限制为只有特定 TeamSpeak 服务器组的成员才能运行:
- 受限命令:`stop`、`clear`、`remove`、`move`、`vol`、`mode`
- 其余命令(点歌、队列、跳过、歌词等)始终对所有人开放
- **默认不限制**:管理服务器组列表为空时,所有命令对所有人开放(向后兼容)
**配置方式**
- 网页端:设置 → 命令权限,填写允许的服务器组 ID(逗号分隔),保存即时生效。
- 或编辑 `config.json` 的 `adminGroups`(数字数组),例如 `"adminGroups": [6, 8]`。
填入任意服务器组 ID 后,限制立即开启:只有属于这些组之一的用户才能运行受限命令,其他人会收到「⛔ 需要管理员权限」的提示。
> 提示(fail-closed):当受限命令来自一个机器人当前看不到其服务器组的发送者(例如不在机器人所在频道的私聊),机器人会尝试查询其分组;若仍无法确定,则拒绝执行。
**如何查看服务器组 ID**
在 TeamSpeak 客户端中打开「权限 → 服务器组」(Permissions → Server Groups)对话框,选中某个组后,其 ID 会显示在标题栏/状态栏;或在服务器组管理界面中查看每个组对应的数字 ID。把需要授权的组 ID 填入上面的设置即可。
```
- [ ] **Step 3: Sanity-check the docs render**
Run: `grep -n "命令权限\|adminGroups" "README.md"`
Expected: shows the newly added section.
- [ ] **Step 4: Commit**
```bash
git add "README.md"
git commit -m "docs: document TeamSpeak chat-command permission control"
```
---
## Final verification (after all tasks)
- [ ] Remove stale compiled output, then run the full suite:
```bash
rm -rf dist
npm test
```
Expected: all tests pass (the new `canRunCommand`, `toTS3TextMessage`, gate, and `adminGroups` settings tests included).
- [ ] Full build (backend `tsc` + frontend `vue-tsc` + vite):
```bash
npm run build
```
Expected: SUCCESS (no type errors).
@@ -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`.
@@ -0,0 +1,116 @@
# TeamSpeak chat-command permission control — design
**Origin:** User request — "给 ts 命令也加上权限控制" (give the TS chat commands permission control too, like the WebUI already has). Completes the unused `adminGroups` scaffold the original authors left behind.
**Date:** 2026-06-25
**Status:** Approved (brainstorm), pending implementation plan
## Scope
Add permission control to **TeamSpeak chat commands** (`!play`, `!add`, `!stop`, …). Today any client in a channel with the bot can run any command; only the WebUI path is permission-gated. This adds a **binary admin gate** keyed on the sender's **TS server groups**: a fixed set of "admin" commands may be restricted to members of configured admin server-groups, while all other commands stay public. Enforcement is **opt-in and backward-compatible** — it activates only once an admin lists their server-group ID(s).
The privileged server-groups are configured in `config.adminGroups` (already declared, currently unused) and become editable from the WebUI.
## Problem
`src/bot/commands.ts` already declares `PUBLIC_COMMANDS` / `ADMIN_COMMANDS` sets and an `isAdminCommand()` helper, and `src/bot/instance.ts:325` has the stub `// TODO: Check if invoker is in adminGroups` — but none of it gates anything. `config.adminGroups: number[]` (`src/data/config.ts:21,46`) is documented as a legacy placeholder and read nowhere. So chat commands are unauthenticated: anyone can `!stop`, `!clear`, `!remove`, move the bot, change volume/mode. The WebUI, by contrast, gates everything via `authorize()` at the HTTP layer.
`executeCommand` (`instance.ts:351`) is **shared** by the chat handler and the WebUI player router; the WebUI gates at the HTTP layer, so the chat gate must live in the **chat handler**, never inside `executeCommand` (else the already-gated WebUI would be double-gated).
## Decisions (from brainstorm)
1. **Binary admin gate**, not per-group capabilities and not a whole-bot allowlist. Reuses the existing `adminGroups` scaffold.
2. **Admin command set (fixed, one source of truth):** `stop`, `clear`, `remove`, `move`, `vol`, `mode`. Everything else is public. The set lives in one constant so reclassifying a command is a one-line change.
3. **Default = open / opt-in (backward-compatible):** when `config.adminGroups` is empty (the default), there is **no enforcement** — admin commands stay open to everyone, exactly as today. Enforcement turns on only when `adminGroups` is non-empty.
4. **Identity key = TS server groups**, matched against `adminGroups`.
5. **Fail-closed on undeterminable groups:** if an admin command arrives, enforcement is on, and the sender's groups cannot be determined (even after a fallback lookup), **deny**.
6. **Reply on deny:** the bot sends the sender a brief permission-denied message (silent denial is confusing; the bot already replies to commands).
7. **Config surface:** `adminGroups` becomes editable from an admin-only WebUI Settings section, live-applied via the existing `/api/bot/settings` endpoint; `config.json` continues to work.
## Permission model
Tier definitions live in `src/bot/commands.ts` (repurpose the existing dead sets; the admin set is the source of truth):
- **Admin commands:** `stop`, `clear`, `remove`, `move`, `vol`, `mode`.
- **Public commands:** all others (`play`, `add`, `playnext`/`pn`, `skip`/`next`, `prev`, `pause`, `resume`, `now`, `queue`/`list`, `lyrics`, `vote`, `help`, `search`/`find`, `playlist`, `album`, `artist`, `fm`).
**Enforcement rule** — a command is **allowed** iff:
1. it is a public command, **OR**
2. `config.adminGroups` is empty (enforcement off), **OR**
3. the sender's server groups ∩ `config.adminGroups` ≠ ∅.
Otherwise it is **denied** (no execution; a denial reply is sent).
Expressed as a pure, unit-testable helper (no TS/async dependency):
```ts
// returns true = allowed, false = denied
function canRunCommand(
commandName: string,
invokerGroups: readonly (string | number)[],
adminGroups: readonly number[]
): boolean
```
- not an admin command → `true`.
- admin command, `adminGroups.length === 0` → `true` (enforcement off).
- admin command, non-empty `adminGroups` → `true` iff any `invokerGroups` value (normalized to number/string consistently) is in `adminGroups`, else `false`.
> Note: `invokerGroups` from TS are strings; `adminGroups` are numbers. Normalize both sides (compare as the same type) to avoid `"6" !== 6` bugs.
## Identity resolution
The TS library already delivers the sender's server groups on each chat event (`TextMessage.invokerGroups: string[]` in `@honeybbq/teamspeak-client`), but the wrapper type `TS3TextMessage` (`src/ts-protocol/client.ts:58-64`) and its mapping (`client.ts:205-214`) **drop** it.
Changes:
1. Add `invokerGroups: string[]` to `TS3TextMessage` and populate it from `msg.invokerGroups` in the mapping.
2. **Availability caveat:** `invokerGroups` is populated only when the sender's client is in the bot's local cache (typically same channel / in view). For a private message from an unseen client, it is `[]`.
3. **Fallback lookup (only when needed):** in the gate, if the command is admin-gated **and** enforcement is on **and** `invokerGroups` is empty, perform a targeted lookup of the sender's groups keyed on `invokerId` (clid) — reuse the already-wrapped `getClientsInChannel()` (`client.ts:314-323`, whose `ClientInfo` carries `serverGroups`), or add a thin wrapper around the library's `getClientInfo(client, clid)` for a precise `clientinfo` query. This query is skipped entirely for public commands, when enforcement is off, and when the event already carried groups (the common "listener in the channel types `!stop`" case).
4. **Fail-closed:** if after the fallback the groups are still unknown, deny the admin command.
## Enforcement seam
In `handleTextMessage` (`src/bot/instance.ts:317`), replace the dead stub at `instance.ts:325-327` with the real check, placed after `parseCommand` succeeds and **before** `executeCommand` (`instance.ts:335`):
- compute `allowed` via `canRunCommand(parsed.name, msg.invokerGroups, this.config.adminGroups)`, performing the async fallback lookup only when the synchronous check is "deny due to empty groups on an admin command with enforcement on";
- if denied → send the denial reply to `msg` (respecting its `targetMode`/sender) and return without executing;
- if allowed → `executeCommand(parsed, msg)` as today.
`executeCommand` stays permission-agnostic, so the WebUI path is unaffected.
**Live config:** `BotInstance` already holds the shared `config` object by reference (passed through `BotInstanceOptions`); `POST /api/bot/settings` mutates that same object in place, so reading `this.config.adminGroups` in the gate reflects edits immediately — no restart, no re-wiring. (Implementation must confirm the instance reads `adminGroups` from the live `config` reference, not a copied-at-construction value.)
## Denied UX
The bot replies to the sender with a short bilingual-ish message, e.g. `⛔ 需要管理员权限(该命令仅限管理员服务器组)`, via the same reply mechanism the command handlers already use, honoring the message's `targetMode` (private vs channel). No execution occurs.
## Config surface
**Backend** (`src/web/api/bot.ts`): extend the existing settings endpoints (already admin-gated: `GET` behind `requireNotGuest`, `POST` behind `requirePermission("bot.manage")`):
- `GET /api/bot/settings` → also return `adminGroups: number[]`.
- `POST /api/bot/settings` → also accept `adminGroups`; validate it is an array of non-negative integers (filter/reject otherwise), assign to `config.adminGroups`, `saveConfig`. Reuses the in-place-mutation + `saveConfig` pattern already used for idle-timeout/auto-pause/guestMode, so it is live-applied.
**Frontend** (`web/src/views/Settings.vue`): a new admin-only section **"命令权限 / Command permissions"** (`v-if="session.isAdmin.value"`), mirroring the idle-timeout/guest-mode sections:
- a text input for comma-separated server-group IDs (parsed to `number[]`, ignoring blanks/non-numbers), a Save button calling `POST /api/bot/settings`, hydrated by the existing `loadIdleTimeout()` GET;
- hint: "仅这些组可运行 stop/clear/remove/move/vol/mode;留空 = 不限制(所有人可用)。如何查看服务器组 ID 见 README。"
**`config.json`**: `adminGroups` continues to work for file-based config.
## Testing
- **`canRunCommand` unit tests** (`src/bot/commands.test.ts` or a new file): public command always allowed; admin command with empty `adminGroups` allowed; admin command with a matching group allowed; admin command with no matching group denied; string-vs-number normalization (`["6"]` matches `[6]`).
- **Handler gate tests:** a denied admin command does NOT call `executeCommand` and triggers a denial reply; an allowed admin command (matching group) and any public command DO call `executeCommand`. (Use a fake `msg` + a `config` with `adminGroups` set; stub the reply + `executeCommand`.)
- **Fallback path:** admin command with empty `invokerGroups` + enforcement on triggers the group lookup; if the lookup yields a matching group → allowed; if it yields nothing → denied (fail-closed).
- **Settings round-trip** (`src/web/api/bot.test.ts`): `POST /api/bot/settings` persists a validated `adminGroups`; `GET` returns it; invalid values (non-array, negative, non-integer) are rejected/filtered.
- **Frontend:** `vue-tsc --noEmit` clean.
## Non-goals (YAGNI)
- No per-group capability map and no whole-bot allowlist (binary admin gate only).
- No per-command customization of the admin/public split in the UI (the set is a code constant; reclassifying is a one-line edit).
- No server-group picker UI (admin types IDs; a picker that lists the bot's visible groups is a possible future enhancement).
- No new chat *management* commands.
- No change to the WebUI authorization model or `executeCommand` semantics.
## Key files touched
Backend: `src/bot/commands.ts` (admin-set constant + `canRunCommand` helper, repurpose the dead sets; +test), `src/bot/instance.ts` (gate in `handleTextMessage`, denial reply, live `adminGroups`), `src/ts-protocol/client.ts` (surface `invokerGroups` on `TS3TextMessage`; possibly a `getClientInfo` wrapper for the fallback), `src/web/api/bot.ts` (settings read/write `adminGroups`; +test). Possibly `src/data/config.ts` (no schema change; `adminGroups` already exists).
Frontend: `web/src/views/Settings.vue` (admin-only 命令权限 section).
Docs: `README.md` (document the feature + how to find TS server-group IDs).
+68 -1
View File
@@ -2,7 +2,7 @@ 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 } from "./player.js";
import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir, shouldEndOnStall, volumeToFactor } from "./player.js";
function getHeadersArg(args: string[]): string {
const idx = args.indexOf("-headers");
@@ -45,6 +45,14 @@ describe("buildFfmpegArgs", () => {
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");
@@ -62,6 +70,7 @@ describe("buildFfmpegArgs", () => {
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");
@@ -80,6 +89,37 @@ describe("buildFfmpegArgs", () => {
});
});
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";
@@ -133,3 +173,30 @@ describe("cleanupTempDir", () => {
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);
});
});
+66 -7
View File
@@ -91,6 +91,11 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
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",
@@ -103,6 +108,43 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
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;
@@ -138,6 +180,11 @@ export class AudioPlayer extends EventEmitter {
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) {
@@ -436,16 +483,27 @@ export class AudioPlayer extends EventEmitter {
if (this.ffmpeg !== null && this.pcmBuffer.length < PCM_FRAME_BYTES) {
this.emptyFrameAttempts++;
// 只有同时满足:达到空帧阈值 + 接近结尾,才判定为播放结束
if (this.emptyFrameAttempts >= AudioPlayer.MAX_EMPTY_ATTEMPTS && isNearEnd) {
this.logger.info({
// 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)
}, "FFmpeg stopped outputting data near end, ending track");
remaining: Math.round(this.currentSongDuration - elapsed),
nearEnd: isNearEnd,
}, "FFmpeg stopped outputting data, ending track");
this.frameLoopRunning = false;
if (this.state !== "idle") {
this.state = "idle";
@@ -509,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);
+1 -1
View File
@@ -10,7 +10,7 @@ export interface QueuedSong {
name: string;
artist: string;
album: string;
platform: "netease" | "qq" | "bilibili" | "youtube";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
url?: string; // resolved lazily at play time
coverUrl: string;
duration: number; // seconds
+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";
}
+34 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseCommand } from "./commands.js";
import { parseCommand, canRunCommand, isAdminCommand } from "./commands.js";
describe("Command Parser", () => {
it("parses simple command", () => {
@@ -60,3 +60,36 @@ describe("Command Parser", () => {
expect(result!.args).toBe("3");
});
});
describe("isAdminCommand classification", () => {
it("treats stop/clear/remove/move/vol/mode as admin", () => {
for (const c of ["stop", "clear", "remove", "move", "vol", "mode"]) {
expect(isAdminCommand(c)).toBe(true);
}
});
it("treats follow and play as NOT admin", () => {
expect(isAdminCommand("follow")).toBe(false);
expect(isAdminCommand("play")).toBe(false);
});
});
describe("canRunCommand", () => {
it("allows any public command regardless of groups", () => {
expect(canRunCommand("play", [], [6])).toBe(true);
expect(canRunCommand("follow", [], [6])).toBe(true);
});
it("allows admin command when enforcement is off (empty adminGroups)", () => {
expect(canRunCommand("stop", [], [])).toBe(true);
});
it("allows admin command when an invoker group matches (string vs number)", () => {
expect(canRunCommand("stop", ["6"], [6])).toBe(true);
expect(canRunCommand("stop", [6], [6])).toBe(true);
expect(canRunCommand("vol", ["8", "6"], [6])).toBe(true);
});
it("denies admin command when no invoker group matches", () => {
expect(canRunCommand("stop", ["8"], [6])).toBe(false);
});
it("denies admin command when invoker has no groups and enforcement is on", () => {
expect(canRunCommand("clear", [], [6])).toBe(false);
});
});
+27 -7
View File
@@ -5,14 +5,13 @@ export interface ParsedCommand {
flags: Set<string>;
}
export const PUBLIC_COMMANDS = new Set([
"play", "add", "queue", "list", "now", "lyrics", "vote", "help",
"playlist", "album", "fm", "prev", "next", "skip", "pause", "resume",
"artist",
]);
/**
* The fixed set of "admin" chat commands. This is the SINGLE source of truth
* for which commands the permission gate restricts; reclassifying a command is
* a one-line edit here. Everything not in this set is public.
*/
export const ADMIN_COMMANDS = new Set([
"stop", "clear", "move", "vol", "mode", "follow", "remove",
"stop", "clear", "remove", "move", "vol", "mode",
]);
export function parseCommand(
@@ -59,3 +58,24 @@ export function parseCommand(
export function isAdminCommand(commandName: string): boolean {
return ADMIN_COMMANDS.has(commandName);
}
/**
* Decide whether a chat command may run, given the invoker's TS server groups
* and the configured admin groups. Pure + synchronous so it is trivially unit
* tested and reused by the async gate in BotInstance.
*
* Allowed iff: (1) it is a public command, OR (2) enforcement is off
* (adminGroups empty), OR (3) some invoker group is in adminGroups.
* invokerGroups (strings from TS) and adminGroups (numbers) are normalized to
* strings before comparison so "6" matches 6.
*/
export function canRunCommand(
commandName: string,
invokerGroups: readonly (string | number)[],
adminGroups: readonly number[],
): boolean {
if (!isAdminCommand(commandName)) return true;
if (adminGroups.length === 0) return true;
const admin = new Set(adminGroups.map((g) => String(g)));
return invokerGroups.some((g) => admin.has(String(g)));
}
+212
View File
@@ -0,0 +1,212 @@
import { describe, it, expect, vi } from "vitest";
import { BotInstance, COMMAND_DENIED_MESSAGE } from "./instance.js";
import type { TS3TextMessage } from "../ts-protocol/client.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",
]);
});
});
/** Minimal `this` carrying only what handleTextMessage's gate path touches.
* The gate methods live on the prototype and are attached here so calls like
* `this.isCommandAllowed(...)` resolve against this same object. */
function makeGateCtx(opts: {
adminGroups?: number[];
lookupGroups?: string[];
lookupThrows?: boolean;
}) {
const ctx: any = {
config: { commandPrefix: "!", commandAliases: {}, adminGroups: opts.adminGroups ?? [] },
logger: { info: vi.fn(), error: vi.fn() },
tsClient: {
sendTextMessage: vi.fn(async () => {}),
getClientServerGroups: vi.fn(async () => {
if (opts.lookupThrows) throw new Error("query failed");
return opts.lookupGroups ?? [];
}),
},
executeCommand: vi.fn(async () => null),
isCommandAllowed: (BotInstance.prototype as any).isCommandAllowed,
lookupInvokerGroups: (BotInstance.prototype as any).lookupInvokerGroups,
};
return ctx;
}
function makeMsg(message: string, invokerGroups: string[] = [], invokerId = "5"): TS3TextMessage {
return { invokerName: "Tester", invokerId, invokerUid: "uid", message, targetMode: 2, invokerGroups };
}
const handleTextMessage = (BotInstance.prototype as any).handleTextMessage as (
this: unknown,
msg: TS3TextMessage,
) => Promise<void>;
describe("BotInstance.handleTextMessage — command permission gate", () => {
it("runs a public command with no group lookup, even under enforcement", async () => {
const ctx = makeGateCtx({ adminGroups: [6] });
await handleTextMessage.call(ctx, makeMsg("!play 晴天", ["6"]));
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
expect(ctx.tsClient.getClientServerGroups).not.toHaveBeenCalled();
expect(ctx.tsClient.sendTextMessage).not.toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
});
it("runs an admin command with no lookup when enforcement is off", async () => {
const ctx = makeGateCtx({ adminGroups: [] });
await handleTextMessage.call(ctx, makeMsg("!stop"));
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
expect(ctx.tsClient.getClientServerGroups).not.toHaveBeenCalled();
});
it("allows an enforced admin command when the live lookup returns a matching group", async () => {
const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: ["6"] });
await handleTextMessage.call(ctx, makeMsg("!stop"));
expect(ctx.tsClient.getClientServerGroups).toHaveBeenCalledTimes(1);
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
});
it("denies an enforced admin command when the live lookup has no matching group", async () => {
const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: ["8"] });
await handleTextMessage.call(ctx, makeMsg("!stop"));
expect(ctx.executeCommand).not.toHaveBeenCalled();
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
});
it("fails closed when the live lookup returns no groups", async () => {
const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: [] });
await handleTextMessage.call(ctx, makeMsg("!stop"));
expect(ctx.executeCommand).not.toHaveBeenCalled();
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
});
it("fails closed when the live lookup throws", async () => {
const ctx = makeGateCtx({ adminGroups: [6], lookupThrows: true });
await handleTextMessage.call(ctx, makeMsg("!stop"));
expect(ctx.executeCommand).not.toHaveBeenCalled();
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
});
it("ignores stale event groups: a demoted sender (cached match) is denied by the live lookup", async () => {
const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: ["8"] });
await handleTextMessage.call(ctx, makeMsg("!stop", ["6"]));
expect(ctx.executeCommand).not.toHaveBeenCalled();
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
});
it("uses live groups, not stale event groups: a freshly-promoted sender is allowed", async () => {
const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: ["6"] });
await handleTextMessage.call(ctx, makeMsg("!stop", ["8"]));
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
});
it("resolves out-of-channel senders server-wide: empty event groups but a matching live group → allowed", async () => {
const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: ["6"] });
await handleTextMessage.call(ctx, makeMsg("!stop", [], "5"));
expect(ctx.tsClient.getClientServerGroups).toHaveBeenCalledTimes(1);
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
});
});
+377 -68
View File
@@ -6,17 +6,26 @@ 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,
canRunCommand,
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";
/** Reply sent when a non-admin invokes an admin-only chat command. */
export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
export interface BotInstanceOptions {
id: string;
@@ -26,6 +35,7 @@ export interface BotInstanceOptions {
qqProvider: MusicProvider;
bilibiliProvider: MusicProvider;
youtubeProvider: MusicProvider;
localProvider?: MusicProvider;
database: BotDatabase;
config: BotConfig;
logger: Logger;
@@ -43,6 +53,8 @@ export interface BotStatus {
volume: number;
playMode: PlayMode;
elapsed: number; // ground truth elapsed seconds from frame count
/** 当前曲实际播放时长(秒)。试听片段=试听秒数;完整曲=duration。缺失时前端回退 currentSong.duration。 */
effectiveDuration?: number;
}
export class BotInstance extends EventEmitter {
@@ -56,6 +68,7 @@ export class BotInstance extends EventEmitter {
private qqProvider: MusicProvider;
private bilibiliProvider: MusicProvider;
private youtubeProvider: MusicProvider;
private localProvider: MusicProvider;
private database: BotDatabase;
private config: BotConfig;
private logger: Logger;
@@ -66,8 +79,15 @@ export class BotInstance extends EventEmitter {
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[] = [];
/** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */
private effectiveDuration: number | undefined;
private playGate: Promise<unknown> = Promise.resolve();
constructor(options: BotInstanceOptions) {
super();
@@ -77,6 +97,7 @@ export class BotInstance extends EventEmitter {
this.qqProvider = options.qqProvider;
this.bilibiliProvider = options.bilibiliProvider;
this.youtubeProvider = options.youtubeProvider;
this.localProvider = options.localProvider ?? options.neteaseProvider;
this.database = options.database;
this.config = options.config;
this.logger = options.logger.child({ botId: this.id });
@@ -129,6 +150,41 @@ export class BotInstance extends EventEmitter {
});
}
isLocalAudioEnabled(): boolean {
return this.config.localAudioEnabled !== false;
}
/**
* Reference-aware cleanup of uploaded local audio files. Delegates to the
* local provider, which deletes a file only when it has been played AND is
* no longer referenced by ANY bot's queue — so loop replays, prev, the song
* being re-started, and the same upload queued on another bot are all safe.
* Call this AFTER the queue mutation, so released songs are unreferenced
* (and deleted) while songs that remain queued are preserved.
*/
cleanupQueuedLocalSongs(reason: string): void {
this.sweepLocalAudio(reason);
}
private sweepLocalAudio(reason: string): void {
const provider = this.localProvider as MusicProvider & {
sweepUnreferenced?: () => string[];
};
if (typeof provider.sweepUnreferenced !== "function") return;
try {
const deleted = provider.sweepUnreferenced();
if (deleted.length) {
this.logger.info({ count: deleted.length, reason }, "Cleaned up local audio files");
}
} catch (err) {
this.logger.warn({ err, reason }, "Local audio cleanup failed");
}
}
private isSameSong(a: QueuedSong | Song | null | undefined, b: QueuedSong | Song | null | undefined): boolean {
return !!a && !!b && a.platform === b.platform && a.id === b.id;
}
private setupTsEvents(): void {
this.tsClient.on("textMessage", (msg: TS3TextMessage) => {
this.handleTextMessage(msg).catch((err) => {
@@ -143,6 +199,10 @@ export class BotInstance extends EventEmitter {
// short-circuited on !this.connected, leaving player stuck as "playing".
this.connected = false;
this.player.stop();
this.queue.clear();
this.sweepLocalAudio("disconnected");
// 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;
@@ -151,8 +211,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> {
@@ -173,6 +282,8 @@ export class BotInstance extends EventEmitter {
disconnect(): void {
this._cancelIdleTimer();
this.player.stop();
this.queue.clear();
this.sweepLocalAudio("disconnected");
this.connected = false;
if (!this.disconnectEmitted) {
this.disconnectEmitted = true;
@@ -187,24 +298,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;
@@ -231,8 +371,17 @@ export class BotInstance extends EventEmitter {
);
if (!parsed) return;
if (isAdminCommand(parsed.name)) {
// TODO: Check if invoker is in adminGroups
if (!(await this.isCommandAllowed(parsed.name, msg))) {
this.logger.info(
{ command: parsed.name, invoker: msg.invokerName },
"Command denied: invoker not in adminGroups"
);
try {
await this.tsClient.sendTextMessage(COMMAND_DENIED_MESSAGE);
} catch (sendErr) {
this.logger.error({ err: sendErr }, "Failed to send permission-denied message to chat");
}
return;
}
this.logger.info(
@@ -257,6 +406,41 @@ export class BotInstance extends EventEmitter {
}
}
/**
* Decide whether a chat command may run for this sender. Reads adminGroups
* live from this.config. Public commands and the enforcement-off case are
* allowed with NO query. For an ENFORCED admin command we resolve the
* sender's CURRENT server groups with a targeted server-wide lookup rather
* than trusting the text event's cached groups — those are empty for
* out-of-channel senders and stale after a live promotion/demotion. Fails
* closed when the groups can't be determined.
*/
private async isCommandAllowed(commandName: string, msg: TS3TextMessage): Promise<boolean> {
const adminGroups = this.config.adminGroups;
// Public command, or enforcement off → allow without any lookup.
// (canRunCommand with empty groups is true iff the command is public OR
// adminGroups is empty.)
if (canRunCommand(commandName, [], adminGroups)) return true;
// Enforced admin command: authoritative decision uses freshly-resolved,
// server-wide groups. Fail closed if they can't be determined.
const groups = await this.lookupInvokerGroups(msg.invokerId);
return canRunCommand(commandName, groups, adminGroups);
}
/**
* Resolve the sender's current server groups by client id, server-wide.
* Returns [] on a bad id or query failure (→ fail-closed deny upstream).
*/
private async lookupInvokerGroups(invokerId: string): Promise<string[]> {
const clid = Number(invokerId);
if (!Number.isFinite(clid) || clid <= 0) return [];
try {
return await this.tsClient.getClientServerGroups(clid);
} catch {
return [];
}
}
async executeCommand(
cmd: ParsedCommand,
msg?: TS3TextMessage
@@ -283,6 +467,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":
@@ -319,7 +506,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":
@@ -337,12 +524,18 @@ export class BotInstance extends EventEmitter {
}
}
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube"): MusicProvider {
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube" | "local"): MusicProvider {
if (platform === "bilibili") return this.bilibiliProvider;
if (platform === "youtube") return this.youtubeProvider;
if (platform === "local") return this.localProvider;
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;
@@ -356,14 +549,18 @@ export class BotInstance extends EventEmitter {
this.logger.warn({ songId: song.id, name: song.name }, "resolveAndPlay called on disconnected bot — skipping");
return false;
}
if (song.platform === "local" && !this.isLocalAudioEnabled()) {
this.logger.warn({ songId: song.id, name: song.name }, "Local audio playback disabled — refusing track");
return false;
}
// Clear any accumulated skip votes — every fresh track starts with a
// clean slate, regardless of which code path loaded it (cmdPlay,
// cmdPlaylist, cmdAlbum, cmdFm, trackEnd auto-advance, etc.).
this.voteSkipUsers.clear();
const provider = this.getProviderFor(song.platform);
try {
const url = await provider.getSongUrl(song.id);
if (!url) {
const result = await provider.getSongUrl(song.id);
if (!result?.url) {
this.logger.warn({ songId: song.id, name: song.name }, "No URL available, skipping");
return false;
}
@@ -379,8 +576,13 @@ export class BotInstance extends EventEmitter {
);
return false;
}
song.url = url;
this.player.play(url, 0, song.duration);
song.url = result.url;
// 试听片段用试听时长(让 player nearEnd 正确触发自动切歌);完整曲回退 song.duration
this.effectiveDuration = result.trialDuration ?? song.duration;
this.player.play(result.url, 0, this.effectiveDuration);
// 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,
@@ -390,10 +592,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) {
@@ -402,36 +602,100 @@ 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!;
const previous = this.queue.current();
if (previous && !this.isSameSong(previous, song0)) {
this.player.stop();
}
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}`;
// Sweep AFTER the new song is queued+resolved: the replaced songs are no
// longer referenced (and get deleted), but song0 — if it is the same local
// upload that was already playing — stays referenced and is preserved.
this.sweepLocalAudio("replaced");
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
@@ -441,21 +705,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
@@ -466,37 +728,43 @@ 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.sweepLocalAudio("stopped");
this.disableFmMode();
this.profileManager.onSongChange(null).catch((err) => {
this.logger.warn({ err }, "Profile restore failed on stop");
});
@@ -554,7 +822,8 @@ export class BotInstance extends EventEmitter {
private cmdClear(): string {
this.player.stop();
this.queue.clear();
this.isFmMode = false;
this.sweepLocalAudio("queue_cleared");
this.disableFmMode();
this.profileManager.onSongChange(null).catch((err) => {
this.logger.warn({ err }, "Profile restore failed on clear");
});
@@ -567,6 +836,9 @@ export class BotInstance extends EventEmitter {
if (isNaN(index) || index < 0) return "Usage: !remove <number>";
const removed = this.queue.remove(index);
if (!removed) return "Invalid position";
// Sweep after the entry is gone — the file is deleted only if no other
// queue position (or bot) still references this upload.
this.sweepLocalAudio("removed_from_queue");
this.emit("stateChange");
return `Removed: ${removed.name}`;
}
@@ -626,13 +898,15 @@ export class BotInstance extends EventEmitter {
const songs = await provider.getPlaylistSongs(playlistId);
if (songs.length === 0) return "Playlist is empty or not found";
this.player.stop();
this.queue.clear();
this.isFmMode = false;
this.disableFmMode();
for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform });
}
const first = this.queue.play();
if (first) await this.resolveAndPlay(first);
this.sweepLocalAudio("queue_replaced");
this.emit("stateChange");
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
}
@@ -661,37 +935,53 @@ export class BotInstance extends EventEmitter {
const songs = await provider.getAlbumSongs(albumId);
if (songs.length === 0) return "Album is empty or not found";
this.player.stop();
this.queue.clear();
this.isFmMode = false;
this.disableFmMode();
for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform });
}
const first = this.queue.play();
if (first) await this.resolveAndPlay(first);
this.sweepLocalAudio("queue_replaced");
this.emit("stateChange");
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.player.stop();
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.sweepLocalAudio("queue_replaced");
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> {
@@ -711,8 +1001,9 @@ export class BotInstance extends EventEmitter {
filtered = result.songs.slice(0, 20);
}
this.player.stop();
this.queue.clear();
this.isFmMode = false;
this.disableFmMode();
for (const song of filtered) {
this.queue.add({ ...song, platform: provider.platform });
}
@@ -721,19 +1012,21 @@ export class BotInstance extends EventEmitter {
const first = this.queue.play();
if (first) await this.resolveAndPlay(first);
this.sweepLocalAudio("queue_replaced");
this.emit("stateChange");
return `Artist mode: ${cmd.args} — ${filtered.length} songs loaded. Now playing: ${first?.name ?? "unknown"}`;
}
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");
}
@@ -785,11 +1078,14 @@ 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`,
@@ -822,10 +1118,10 @@ export class BotInstance extends EventEmitter {
async playNext(maxRetries = 3): Promise<boolean> {
if (this.isAdvancing || !this.connected) return false;
this.isAdvancing = true;
let started = false;
try {
this.voteSkipUsers.clear();
const next = this.queue.next();
let started = false;
if (next) {
started = await this.resolveAndPlay(next);
if (!started) {
@@ -865,6 +1161,10 @@ export class BotInstance extends EventEmitter {
this.emit("stateChange");
return started;
} finally {
// Reference-aware sweep: a finished local song that still sits in the
// queue (sequential history, loop/repeat, or queued on another bot) is
// preserved; only uploads no longer referenced anywhere are deleted.
this.sweepLocalAudio("playback_finished");
this.isAdvancing = false;
}
}
@@ -877,6 +1177,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,
@@ -889,6 +1197,7 @@ export class BotInstance extends EventEmitter {
volume: this.player.getVolume(),
playMode: this.queue.getMode(),
elapsed: this.player.getElapsed(),
effectiveDuration: this.effectiveDuration,
};
}
+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");
});
});
+45 -2
View File
@@ -7,11 +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
@@ -56,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. */
@@ -72,10 +74,13 @@ export class BotManager extends EventEmitter {
private qqProvider: MusicProvider;
private bilibiliProvider: MusicProvider;
private youtubeProvider: MusicProvider;
private localProvider: MusicProvider;
private database: BotDatabase;
private config: BotConfig;
private logger: Logger;
private avatarStore: AvatarStore;
private permissions: PermissionStore;
private configPath: string;
constructor(
neteaseProvider: MusicProvider,
@@ -84,17 +89,29 @@ export class BotManager extends EventEmitter {
database: BotDatabase,
config: BotConfig,
logger: Logger,
avatarStore: AvatarStore
avatarStore: AvatarStore,
permissions: PermissionStore,
configPath: string,
localProvider?: MusicProvider
) {
super();
this.neteaseProvider = neteaseProvider;
this.qqProvider = qqProvider;
this.bilibiliProvider = bilibiliProvider;
this.youtubeProvider = new YouTubeProvider();
this.localProvider = localProvider ?? neteaseProvider;
// Let the local provider see which uploads are still referenced by any
// bot's queue, so it never deletes a file another queue/bot still needs.
const referenceable = this.localProvider as Partial<{
setInUseResolver: (resolver: () => Set<string>) => void;
}>;
referenceable.setInUseResolver?.(() => this.getReferencedLocalSongIds());
this.database = database;
this.config = config;
this.logger = logger;
this.avatarStore = avatarStore;
this.permissions = permissions;
this.configPath = configPath;
}
async createBot(params: CreateBotParams): Promise<BotInstance> {
@@ -109,6 +126,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,
@@ -118,6 +136,7 @@ export class BotManager extends EventEmitter {
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider,
database: this.database,
config: this.config,
logger: this.logger,
@@ -134,6 +153,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 ?? "",
@@ -152,6 +172,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");
}
@@ -168,6 +194,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,
@@ -193,6 +220,18 @@ export class BotManager extends EventEmitter {
return Array.from(this.bots.values());
}
/** Local upload ids still referenced by any bot's queue. The local provider
* uses this to avoid deleting a file another queue/bot is still using. */
getReferencedLocalSongIds(): Set<string> {
const ids = new Set<string>();
for (const bot of this.bots.values()) {
for (const song of bot.getQueueManager().list()) {
if (song.platform === "local") ids.add(song.id);
}
}
return ids;
}
async startBot(id: string): Promise<void> {
const oldBot = this.bots.get(id);
if (!oldBot) throw new Error(`Bot ${id} not found`);
@@ -226,6 +265,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,
@@ -235,6 +275,7 @@ export class BotManager extends EventEmitter {
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider,
database: this.database,
config: this.config,
logger: this.logger,
@@ -277,6 +318,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,
@@ -286,6 +328,7 @@ export class BotManager extends EventEmitter {
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider,
database: this.database,
config: this.config,
logger: this.logger,
+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;
}
+2 -1
View File
@@ -6,7 +6,8 @@ export type AuditAction =
| "user.deleted"
| "user.password_reset"
| "user.password_changed"
| "user.role_changed";
| "user.role_changed"
| "user.permissions_changed";
export interface AuditEntry {
id: number;
+156 -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,159 @@ 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,
playCollection: 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" } });
// all known flags present at their defaults
expect(gm.permissions).toEqual({
addToQueue: true, playNext: false, playNow: false,
skip: false, transport: false, removeClear: false, playMode: false,
playCollection: false,
});
// no garbage index keys leaked from spreading a string
expect((gm.permissions as unknown as Record<string, unknown>)["0"]).toBeUndefined();
});
});
});
describe("adminGroups normalization", () => {
function loadAdminGroups(raw: unknown) {
const dir = mkdtempSync(join(tmpdir(), "tsmb-cfg-"));
const p = join(dir, "config.json");
writeFileSync(p, JSON.stringify(raw));
try {
return loadConfig(p).adminGroups;
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
it("defaults to [] when absent", () => {
expect(loadAdminGroups({})).toEqual([]);
});
it("keeps valid non-negative integers", () => {
expect(loadAdminGroups({ adminGroups: [6, 8] })).toEqual([6, 8]);
});
it("filters out negatives, non-integers and non-numbers", () => {
expect(loadAdminGroups({ adminGroups: [6, -1, 2.5, "8", null] })).toEqual([6]);
});
it("a non-array value falls back to the default [] (no crash)", () => {
expect(loadAdminGroups({ adminGroups: "6" })).toEqual([]);
});
});
+110 -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;
@@ -14,6 +22,8 @@ export interface BotConfig {
autoReturnDelay: number;
autoPauseOnEmpty: boolean;
idleTimeoutMinutes: number;
/** Enable uploading and playback of server-stored local audio files. */
localAudioEnabled: boolean;
// Public base URL used when generating share links (e.g. the bot专属链接).
// Leave empty to use the browser's current origin. Example:
// "https://music.example.com" or "http://1.2.3.4:3000"
@@ -22,6 +32,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 +47,28 @@ 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,
localAudioEnabled: true,
publicUrl: "",
trustProxy: false,
guestMode: {
enabled: false,
bots: "all",
permissions: {
addToQueue: true,
playNext: false,
playNow: false,
skip: false,
transport: false,
removeClear: false,
playMode: false,
playCollection: false,
},
},
};
}
@@ -48,7 +77,53 @@ 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;
}
// Sanitize adminGroups on load too: the WebUI write path filters it, but a
// hand-edited / legacy / corrupt config.json reaches the command gate
// directly. Keep only non-negative integers; a non-array falls back to the
// default []. Mirrors the guestMode sanitization above.
const adminGroups = Array.isArray(partial.adminGroups)
? partial.adminGroups.filter(
(g): g is number => typeof g === "number" && Number.isInteger(g) && g >= 0,
)
: defaults.adminGroups;
return {
...defaults,
...partial,
adminGroups,
guestMode: gm,
};
} catch {
return defaults;
}
@@ -58,3 +133,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;
}
}
+32
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;
@@ -82,6 +86,7 @@ describe("database", () => {
serverPort: 9987,
nickname: "MusicBot",
defaultChannel: "Music",
channelId: "",
channelPassword: "",
autoStart: true,
serverProtocol: "",
@@ -111,6 +116,7 @@ describe("database", () => {
serverPort: 9987,
nickname: "MusicBot",
defaultChannel: "Music",
channelId: "",
channelPassword: "",
autoStart: false,
serverProtocol: "",
@@ -131,6 +137,7 @@ describe("database", () => {
serverPort: 9987,
nickname: "n",
defaultChannel: "",
channelId: "",
channelPassword: "",
autoStart: false,
serverProtocol: "",
@@ -145,3 +152,28 @@ describe("database", () => {
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 });
});
});
+130 -3
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;
@@ -6,7 +8,7 @@ export interface PlayHistoryEntry {
songName: string;
artist: string;
album: string;
platform: "netease" | "qq" | "bilibili" | "youtube";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
coverUrl: 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;
@@ -62,6 +76,10 @@ export interface BotDatabase {
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;
}
@@ -80,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",
@@ -126,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 '',
@@ -165,15 +187,82 @@ function initTables(db: Database.Database): void {
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)
@@ -185,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,
@@ -226,6 +316,24 @@ export function createDatabase(dbPath: string): BotDatabase {
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,
@@ -255,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,
}));
},
@@ -297,6 +406,24 @@ export function createDatabase(dbPath: string): BotDatabase {
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([]);
});
});
+135
View File
@@ -0,0 +1,135 @@
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,
playCollection: 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,
playCollection: false,
},
});
expect(ctx.bots).toBe("all");
});
it("exposes the 8 canonical flags", () => {
expect([...GUEST_PERMISSION_FLAGS].sort()).toEqual(
["addToQueue", "playCollection", "playMode", "playNext", "playNow", "removeClear", "skip", "transport"].sort()
);
});
});
+123
View File
@@ -0,0 +1,123 @@
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;
/** Load + play an entire playlist/album (clears the queue). Issue #103. */
playCollection: boolean;
}
export const GUEST_PERMISSION_FLAGS = [
"addToQueue",
"playNext",
"playNow",
"skip",
"transport",
"removeClear",
"playMode",
"playCollection",
] 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),
};
}
+72 -1
View File
@@ -2,7 +2,7 @@ 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 } from "./sessions.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");
@@ -126,3 +126,74 @@ describe("SessionStore", () => {
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);
});
});
+15 -9
View File
@@ -2,17 +2,18 @@ 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";
role: "admin" | "member" | "guest";
}
export interface SessionStore {
createSession(userId: string): { token: string; expiresAt: number };
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;
@@ -47,7 +48,7 @@ export function createSessionStore(db: Database.Database): SessionStore {
);
return {
createSession(userId) {
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,
@@ -55,11 +56,13 @@ export function createSessionStore(db: Database.Database): SessionStore {
const token = randomBytes(32).toString("base64url");
const id = hashToken(token);
const now = Date.now();
const expiresAt = now + SESSION_TTL_MS;
const expiresAt = now + (opts?.ttlMs ?? SESSION_TTL_MS);
const tx = db.transaction(() => {
const existing = (countForUserStmt.get(userId) as { n: number }).n;
if (existing >= MAX_SESSIONS_PER_USER) {
deleteOldestForUserStmt.run(userId, existing - MAX_SESSIONS_PER_USER + 1);
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);
});
@@ -80,9 +83,12 @@ export function createSessionStore(db: Database.Database): SessionStore {
return null;
}
if (now - row.lastSeenAt > SESSION_TOUCH_INTERVAL_MS) {
touchStmt.run(now, now + SESSION_TTL_MS, id);
// 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" };
return { userId: row.userId, username: row.username, role: row.role as "admin" | "member" | "guest" };
},
deleteSession(rawToken) {
+41 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createDatabase, type BotDatabase } from "./database.js";
import { createUserStore, UsernameTakenError, type UserStore } from "./users.js";
import { createUserStore, UsernameTakenError, GUEST_USER_ID, GUEST_USERNAME, type UserStore } from "./users.js";
describe("UserStore", () => {
let botDb: BotDatabase;
@@ -184,3 +184,43 @@ describe("UserStore", () => {
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();
});
});
+11 -3
View File
@@ -4,7 +4,13 @@ import bcrypt from "bcryptjs";
const BCRYPT_ROUNDS = 12;
export type UserRole = "admin" | "member";
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;
@@ -39,7 +45,7 @@ export class UsernameTakenError extends Error {
}
export function createUserStore(db: Database.Database): UserStore {
const countStmt = db.prepare("SELECT COUNT(*) AS n FROM users");
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 (?, ?, ?, ?, ?, ?)"
@@ -57,7 +63,7 @@ export function createUserStore(db: Database.Database): UserStore {
"UPDATE users SET role = ?, updatedAt = ? WHERE id = ?"
);
const listUsersStmt = db.prepare(
"SELECT id, username, createdAt, role FROM users ORDER BY createdAt ASC"
"SELECT id, username, createdAt, role FROM users WHERE role != 'guest' ORDER BY createdAt ASC"
);
const deleteUserStmt = db.prepare("DELETE FROM users WHERE id = ?");
@@ -131,6 +137,7 @@ export function createUserStore(db: Database.Database): UserStore {
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;
@@ -155,6 +162,7 @@ export function createUserStore(db: Database.Database): UserStore {
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;
+20 -3
View File
@@ -1,28 +1,38 @@
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";
import { NeteaseProvider } from "./music/netease.js";
import { QQMusicProvider } from "./music/qq.js";
import { BiliBiliProvider } from "./music/bilibili.js";
import { LocalMusicProvider } from "./music/local.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 LOCAL_AUDIO_DIR = path.join(DATA_DIR, "local-audio");
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 neteaseProvider = new NeteaseProvider(apiServer.getNeteaseBaseUrl());
const qqProvider = new QQMusicProvider(apiServer.getQQMusicBaseUrl());
const bilibiliProvider = new BiliBiliProvider();
const localProvider = new LocalMusicProvider(LOCAL_AUDIO_DIR);
const cookieStore = createCookieStore(COOKIE_DIR);
const avatarStore = createAvatarStore(AVATAR_DIR);
@@ -56,6 +67,8 @@ async function main() {
const bilibiliCookie = cookieStore.load("bilibili");
if (bilibiliCookie) bilibiliProvider.setCookie(bilibiliCookie);
const permissions = createPermissionStore(db.db);
const botManager = new BotManager(
neteaseProvider,
qqProvider,
@@ -63,7 +76,10 @@ async function main() {
db,
config,
logger,
avatarStore
avatarStore,
permissions,
CONFIG_PATH,
localProvider
);
await botManager.loadSavedBots();
@@ -73,6 +89,7 @@ async function main() {
neteaseProvider,
qqProvider,
bilibiliProvider,
localProvider,
database: db,
avatarStore,
config,
+4 -2
View File
@@ -3,6 +3,7 @@ import axios, { type AxiosInstance } from "axios";
import type {
MusicProvider,
Song,
SongUrlResult,
Playlist,
LyricLine,
SearchResult,
@@ -231,7 +232,7 @@ export class BiliBiliProvider implements MusicProvider {
return this.cidCache.get(bvid) ?? null;
}
async getSongUrl(songId: string, _quality?: string): Promise<string | null> {
async getSongUrl(songId: string, _quality?: string): Promise<SongUrlResult | null> {
const cid = await this.getCid(songId);
if (!cid) return null;
@@ -253,7 +254,8 @@ export class BiliBiliProvider implements MusicProvider {
(b.bandwidth ?? 0) > (a.bandwidth ?? 0) ? b : a
);
return best.baseUrl ?? best.base_url ?? null;
const biliUrl = best.baseUrl ?? best.base_url;
return biliUrl ? { url: biliUrl } : null;
} catch {
return null;
}
+221
View File
@@ -0,0 +1,221 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { LocalMusicProvider } from "./local.js";
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "local-audio-test-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
// Seed real files + an index.json so we can exercise the cleanup lifecycle
// without invoking the ffmpeg duration probe that uploadAudio runs.
function makeRecord(id: string, bytes = 16) {
const filePath = join(dir, `${id}.mp3`);
writeFileSync(filePath, Buffer.alloc(bytes, 1));
return {
id,
name: id,
artist: "本地上传",
album: "本地音乐",
duration: 0,
coverUrl: "",
platform: "local" as const,
filePath,
originalName: `${id}.mp3`,
uploadedAt: "1970-01-01T00:00:00.000Z",
size: bytes,
mimeType: "audio/mpeg",
};
}
function seed(records: ReturnType<typeof makeRecord>[]) {
writeFileSync(join(dir, "index.json"), JSON.stringify(records), "utf8");
}
describe("LocalMusicProvider cleanup lifecycle", () => {
it("sweep keeps referenced and never-played files, deletes only played+unreferenced", async () => {
const a = makeRecord("a");
const b = makeRecord("b");
const c = makeRecord("c");
seed([a, b, c]);
const p = new LocalMusicProvider(dir);
const refs = new Set<string>(["a"]); // "a" still sits in a queue somewhere
p.setInUseResolver(() => refs);
await p.getSongUrl("a"); // played, but referenced
await p.getSongUrl("b"); // played and unreferenced
// "c" was never played (e.g. uploaded but not queued)
const deleted = p.sweepUnreferenced();
expect(deleted).toEqual(["b"]);
expect(existsSync(a.filePath)).toBe(true); // referenced → kept
expect(existsSync(b.filePath)).toBe(false); // played + unreferenced → deleted
expect(existsSync(c.filePath)).toBe(true); // never played → kept
});
it("a played song still in the queue survives the sweep and stays replayable (loop / prev)", async () => {
const a = makeRecord("a");
seed([a]);
const p = new LocalMusicProvider(dir);
const refs = new Set<string>(["a"]); // loop queue still references it
p.setInUseResolver(() => refs);
await p.getSongUrl("a"); // first pass plays it
p.sweepUnreferenced(); // "playback_finished" sweep
expect(existsSync(a.filePath)).toBe(true);
expect((await p.getSongUrl("a"))?.url).toBe(a.filePath); // next loop pass works
});
it("re-playing a queued local song does not delete it (play-song order)", async () => {
const a = makeRecord("a");
seed([a]);
const p = new LocalMusicProvider(dir);
// Mirror the fixed endpoint order: the song is (re)added to the queue
// BEFORE the sweep runs, so it is referenced when we sweep.
const refs = new Set<string>(["a"]);
p.setInUseResolver(() => refs);
await p.getSongUrl("a"); // played once
p.sweepUnreferenced(); // sweep fired after the replay re-queued it
expect(existsSync(a.filePath)).toBe(true);
expect(await p.getSongUrl("a")).not.toBeNull();
});
it("deletes a played file once it leaves every queue", async () => {
const a = makeRecord("a");
seed([a]);
const p = new LocalMusicProvider(dir);
let refs = new Set<string>(["a"]);
p.setInUseResolver(() => refs);
await p.getSongUrl("a");
p.sweepUnreferenced();
expect(existsSync(a.filePath)).toBe(true); // still queued
refs = new Set<string>(); // queue cleared
p.sweepUnreferenced();
expect(existsSync(a.filePath)).toBe(false); // now removed
expect(await p.getSongUrl("a")).toBeNull();
});
it("never deletes anything when the reference resolver throws", async () => {
const a = makeRecord("a");
seed([a]);
const p = new LocalMusicProvider(dir);
p.setInUseResolver(() => {
throw new Error("manager unavailable");
});
await p.getSongUrl("a");
expect(p.sweepUnreferenced()).toEqual([]);
expect(existsSync(a.filePath)).toBe(true);
});
});
describe("LocalMusicProvider upload validation", () => {
it("rejects a spoofed Content-Type with a non-audio extension", async () => {
const p = new LocalMusicProvider(dir);
await expect(
p.uploadAudio({
buffer: Buffer.from("malicious"),
originalName: "evil.exe",
mimeType: "application/octet-stream",
}),
).rejects.toThrow();
});
it("rejects an unknown extension even when the mime claims audio", async () => {
const p = new LocalMusicProvider(dir);
await expect(
p.uploadAudio({
buffer: Buffer.from("x"),
originalName: "evil.html",
mimeType: "audio/mpeg",
}),
).rejects.toThrow();
});
it("rejects an empty file", async () => {
const p = new LocalMusicProvider(dir);
await expect(
p.uploadAudio({ buffer: Buffer.alloc(0), originalName: "a.mp3" }),
).rejects.toThrow();
});
});
describe("LocalMusicProvider quota", () => {
it("evicts oldest unreferenced uploads beyond maxFiles", async () => {
const a = makeRecord("a");
const b = makeRecord("b");
seed([b, a]); // newest-first: b newer than a
const p = new LocalMusicProvider(dir, { maxFiles: 2 });
p.setInUseResolver(() => new Set<string>());
// Upload a third valid file → over the 2-file cap → evict the oldest ("a").
await p.uploadAudio({
buffer: Buffer.alloc(16, 7),
originalName: "c.mp3",
mimeType: "audio/mpeg",
});
expect(existsSync(a.filePath)).toBe(false); // oldest evicted
expect(existsSync(b.filePath)).toBe(true);
const result = await p.search("");
expect(result.songs.map((s) => s.id).sort()).not.toContain("a");
});
it("does not evict a referenced upload even when over the cap", async () => {
const a = makeRecord("a");
const b = makeRecord("b");
seed([b, a]);
const p = new LocalMusicProvider(dir, { maxFiles: 1 });
p.setInUseResolver(() => new Set<string>(["a"])); // "a" is queued
await p.uploadAudio({
buffer: Buffer.alloc(16, 7),
originalName: "c.mp3",
mimeType: "audio/mpeg",
});
expect(existsSync(a.filePath)).toBe(true); // protected: still queued
});
it("never evicts the just-uploaded file, even when every older file is referenced", async () => {
const a = makeRecord("a");
seed([a]);
const p = new LocalMusicProvider(dir, { maxFiles: 1 });
p.setInUseResolver(() => new Set<string>(["a"])); // the only older file is queued
const song = await p.uploadAudio({
buffer: Buffer.alloc(16, 7),
originalName: "c.mp3",
mimeType: "audio/mpeg",
});
// The returned song must actually exist and be playable — not a phantom.
expect(await p.getSongUrl(song.id)).not.toBeNull();
});
});
describe("LocalMusicProvider filename handling", () => {
it("accepts a long filename without dropping its extension", async () => {
const p = new LocalMusicProvider(dir);
const longName = "x".repeat(300) + ".mp3";
// Must not throw the "unsupported format" error — the extension survives.
const song = await p.uploadAudio({
buffer: Buffer.alloc(16, 1),
originalName: longName,
mimeType: "audio/mpeg",
});
expect(song.id).toBeTruthy();
expect(await p.getSongUrl(song.id)).not.toBeNull();
});
});
+391
View File
@@ -0,0 +1,391 @@
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import crypto from "node:crypto";
import type {
Album,
AuthStatus,
LyricLine,
MusicProvider,
Playlist,
PlaylistDetail,
QrCodeResult,
SearchResult,
Song,
SongUrlResult,
} from "./provider.js";
const require = createRequire(import.meta.url);
const ffmpegPath: string | null = require("ffmpeg-static");
const AUDIO_EXTENSIONS = new Set([
".mp3",
".flac",
".wav",
".m4a",
".aac",
".ogg",
".opus",
".webm",
".wma",
".alac",
".aiff",
".ape",
]);
const DEFAULT_MAX_FILES = 200;
const DEFAULT_MAX_TOTAL_BYTES = 5 * 1024 * 1024 * 1024; // 5 GiB
export interface LocalMusicProviderOptions {
/** Max number of uploaded files kept on disk (oldest unreferenced evicted). */
maxFiles?: number;
/** Max total bytes of uploaded files kept on disk. */
maxTotalBytes?: number;
}
interface LocalSongRecord extends Song {
filePath: string;
originalName: string;
uploadedAt: string;
size: number;
mimeType: string;
}
function safeFileName(name: string): string {
const base = path.basename(name || "audio")
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_")
.replace(/\s+/g, " ")
.trim();
if (!base) return "audio";
// Cap the total length but ALWAYS preserve the extension — truncating the
// whole string would drop a trailing ".mp3" on a long filename and make the
// file fail extension validation.
const ext = path.extname(base);
const stem = ext ? base.slice(0, base.length - ext.length) : base;
const safeStem = stem.slice(0, Math.max(1, 160 - ext.length)) || "audio";
return `${safeStem}${ext}`;
}
function titleFromFileName(name: string): string {
return safeFileName(name).replace(/\.[^.]+$/, "") || "本地音频";
}
async function probeDurationSeconds(filePath: string): Promise<number> {
return new Promise((resolve) => {
const ffmpeg = spawn(ffmpegPath || "ffmpeg", ["-hide_banner", "-i", filePath], {
stdio: ["ignore", "ignore", "pipe"],
});
let stderr = "";
const timeout = setTimeout(() => {
ffmpeg.kill("SIGKILL");
resolve(0);
}, 5000);
ffmpeg.stderr.on("data", (chunk) => {
stderr += chunk.toString("utf8");
});
ffmpeg.on("error", () => {
clearTimeout(timeout);
resolve(0);
});
ffmpeg.on("close", () => {
clearTimeout(timeout);
const match = stderr.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
if (!match) {
resolve(0);
return;
}
const hours = Number(match[1]);
const minutes = Number(match[2]);
const seconds = Number(match[3]);
const total = hours * 3600 + minutes * 60 + seconds;
resolve(Number.isFinite(total) ? Math.round(total) : 0);
});
});
}
export class LocalMusicProvider implements MusicProvider {
readonly platform = "local" as const;
private readonly uploadDir: string;
private readonly indexPath: string;
private records: LocalSongRecord[] = [];
private readonly maxFiles: number;
private readonly maxTotalBytes: number;
/** Ids that have been resolved for playback at least once; only these are
* eligible for reference-aware cleanup, so freshly uploaded files that are
* not yet queued/played survive in the search list. */
private playedIds = new Set<string>();
/** Returns the set of local song ids still referenced by any bot's queue.
* Deletion never removes a file whose id this set contains. */
private inUseResolver: () => Set<string> = () => new Set<string>();
/** Ids with an in-flight retry-delete scheduled (file briefly locked, e.g.
* ffmpeg on Windows still releasing a just-stopped track). */
private retrying = new Set<string>();
constructor(uploadDir: string, options: LocalMusicProviderOptions = {}) {
this.uploadDir = uploadDir;
this.indexPath = path.join(uploadDir, "index.json");
this.maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
this.maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
mkdirSync(uploadDir, { recursive: true });
this.loadIndex();
}
/** Wire the resolver the BotManager uses to report which uploads are still
* queued anywhere. Must be set before any cleanup can delete files. */
setInUseResolver(resolver: () => Set<string>): void {
this.inUseResolver = resolver;
}
private referencedIds(): Set<string> | null {
try {
return this.inUseResolver() ?? new Set<string>();
} catch {
// Resolver failure → references unknown → refuse to delete anything.
return null;
}
}
private loadIndex(): void {
try {
const raw = readFileSync(this.indexPath, "utf8");
const parsed = JSON.parse(raw) as LocalSongRecord[];
this.records = Array.isArray(parsed)
? parsed.filter((r) => r && typeof r.id === "string" && typeof r.filePath === "string")
: [];
} catch {
this.records = [];
}
}
private saveIndex(): void {
writeFileSync(this.indexPath, JSON.stringify(this.records, null, 2), "utf8");
}
async uploadAudio(input: {
buffer: Buffer;
originalName: string;
mimeType?: string;
}): Promise<Song> {
const originalName = safeFileName(input.originalName || "audio");
const ext = path.extname(originalName).toLowerCase();
// Validate by the (sanitised) file extension only — never trust the
// client-supplied Content-Type. This also guarantees the STORED extension
// is one of the known audio types, so a spoofed header cannot persist an
// arbitrary-extension blob on disk.
if (!AUDIO_EXTENSIONS.has(ext)) {
throw new Error("只支持常见音频文件,如 mp3、flac、wav、m4a、ogg、opus、aac、webm 等");
}
if (!input.buffer || input.buffer.length === 0) {
throw new Error("上传文件为空");
}
const id = crypto.randomUUID();
const storedName = `${id}${ext}`;
const filePath = path.join(this.uploadDir, storedName);
writeFileSync(filePath, input.buffer);
const duration = await probeDurationSeconds(filePath);
const song: LocalSongRecord = {
id,
name: titleFromFileName(originalName),
artist: "本地上传",
album: "本地音乐",
duration,
coverUrl: "",
platform: "local",
filePath,
originalName,
uploadedAt: new Date().toISOString(),
size: input.buffer.length,
mimeType: input.mimeType || "application/octet-stream",
};
this.records.unshift(song);
this.saveIndex();
// Never evict the file we just accepted, even if every older file is still
// queued — returning success for a file we deleted would be a phantom entry.
this.enforceQuota(id);
return this.toSong(song);
}
private toSong(record: LocalSongRecord): Song {
const { filePath: _filePath, originalName: _originalName, uploadedAt: _uploadedAt, size: _size, mimeType: _mimeType, ...song } = record;
return song;
}
async search(query: string, limit = 20): Promise<SearchResult> {
const q = query.trim().toLowerCase();
const songs = this.records
.filter((r) => existsSync(r.filePath))
.filter((r) => !q || `${r.name} ${r.artist} ${r.album} ${r.originalName}`.toLowerCase().includes(q))
.slice(0, limit)
.map((r) => this.toSong(r));
return { songs, playlists: [], albums: [] };
}
async getSongUrl(songId: string): Promise<SongUrlResult | null> {
const record = this.records.find((r) => r.id === songId);
if (!record || !existsSync(record.filePath)) return null;
// A song that is actually resolved for playback becomes eligible for
// cleanup once it is no longer referenced by any queue.
this.playedIds.add(songId);
return { url: record.filePath };
}
async getSongDetail(songId: string): Promise<Song | null> {
const record = this.records.find((r) => r.id === songId);
return record && existsSync(record.filePath) ? this.toSong(record) : null;
}
/**
* Reference-aware cleanup: delete only files that have been played at least
* once AND are no longer referenced by any bot's queue. Safe to call after
* any queue mutation — a file still queued anywhere (loop replay, prev,
* the song being re-started, the same upload queued on another bot) is kept.
* Returns the ids that were deleted.
*/
sweepUnreferenced(): string[] {
const inUse = this.referencedIds();
if (!inUse) return [];
const deleted: string[] = [];
for (let i = this.records.length - 1; i >= 0; i--) {
const r = this.records[i];
if (!this.playedIds.has(r.id) || inUse.has(r.id)) continue;
if (this.unlinkRecordAt(i)) {
deleted.push(r.id);
} else {
// File still locked (e.g. ffmpeg just-stopped on Windows) — keep the
// record and retry shortly; never orphan it or abort the rest.
this.scheduleRetry(r.id);
}
}
if (deleted.length) this.saveIndex();
return deleted;
}
/** Evict oldest, never-referenced uploads until under the file-count and
* total-byte caps. Bounds disk use from uploads that are never played.
* `protectId` is never evicted (the file just uploaded in this same call). */
private enforceQuota(protectId?: string): void {
if (this.records.length <= this.maxFiles &&
this.totalBytes() <= this.maxTotalBytes) {
return;
}
const inUse = this.referencedIds();
if (!inUse) return; // can't safely evict without knowing references
let count = this.records.length;
let bytes = this.totalBytes();
let changed = false;
for (let i = this.records.length - 1;
i >= 0 && (count > this.maxFiles || bytes > this.maxTotalBytes);
i--) {
const r = this.records[i];
if (inUse.has(r.id) || r.id === protectId) continue; // never evict these
const size = r.size || 0;
if (this.unlinkRecordAt(i)) {
count--;
bytes -= size;
changed = true;
}
}
if (changed) this.saveIndex();
}
/**
* Delete the backing file for records[index] and drop the record from memory.
* Deletes the FILE FIRST, then mutates state only on success, so a failed
* unlink leaves the record intact (file + index stay consistent) instead of
* orphaning the file. Returns true if the file is gone (deleted or already
* absent), false if it is still present (locked). Never throws; does NOT
* persist the index — callers batch saveIndex().
*/
private unlinkRecordAt(index: number): boolean {
const r = this.records[index];
try {
rmSync(r.filePath, { force: true });
} catch {
// rmSync force:true only swallows ENOENT; EBUSY/EPERM/EACCES throw. If
// the file genuinely vanished anyway, fall through and drop the record.
if (existsSync(r.filePath)) return false;
}
this.records.splice(index, 1);
this.playedIds.delete(r.id);
this.retrying.delete(r.id);
return true;
}
/** Schedule a bounded, non-blocking retry to delete a briefly-locked file.
* Uses unref'd timers so it never keeps the process alive. */
private scheduleRetry(id: string, attempt = 1): void {
if (attempt === 1 && this.retrying.has(id)) return;
this.retrying.add(id);
const MAX_ATTEMPTS = 6;
const timer = setTimeout(() => {
const index = this.records.findIndex((r) => r.id === id);
if (index < 0) { this.retrying.delete(id); return; } // already removed
const inUse = this.referencedIds();
if (!inUse || inUse.has(id)) { this.retrying.delete(id); return; } // unknown or re-queued
if (this.unlinkRecordAt(index)) {
this.saveIndex();
} else if (attempt < MAX_ATTEMPTS) {
this.scheduleRetry(id, attempt + 1);
} else {
this.retrying.delete(id); // give up; next sweep/quota will retry
}
}, 500 * attempt);
if (typeof timer.unref === "function") timer.unref();
}
private totalBytes(): number {
return this.records.reduce((n, r) => n + (r.size || 0), 0);
}
setQuality(_quality: string): void {
// 本地文件按原始音质播放。
}
getQuality(): string {
return "original";
}
async getPlaylistSongs(_playlistId: string): Promise<Song[]> {
return [];
}
async getRecommendPlaylists(): Promise<Playlist[]> {
return [];
}
async getAlbumSongs(_albumId: string): Promise<Song[]> {
return [];
}
async getLyrics(_songId: string): Promise<LyricLine[]> {
return [];
}
async getQrCode(): Promise<QrCodeResult> {
throw new Error("Local music does not require login");
}
async checkQrCodeStatus(_key: string): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
return "expired";
}
setCookie(_cookie: string): void {
// no-op
}
getCookie(): string {
return "";
}
async getAuthStatus(): Promise<AuthStatus> {
return { loggedIn: true, nickname: "本地音乐" };
}
async getPlaylistDetail(_playlistId: string): Promise<PlaylistDetail | null> {
return null;
}
}
+37 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseLyrics, mapNeteaseAlbums } from "./netease.js";
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial } from "./netease.js";
describe("NetEase adapter", () => {
it("parses LRC format lyrics", () => {
@@ -56,4 +56,40 @@ describe("NetEase adapter", () => {
expect(mapNeteaseAlbums(null as any)).toEqual([]);
expect(mapNeteaseAlbums(undefined as any)).toEqual([]);
});
it("mapNeteaseSongs maps fee to vip flag (1/4 = vip, 0/8 = free)", () => {
const raw = [
{ id: 1, name: "VIP", ar: [{ name: "A" }], al: { name: "Al", picUrl: "p" }, dt: 180000, fee: 1 },
{ id: 2, name: "Album-only", ar: [], al: { name: "Al", picUrl: "" }, dt: 0, fee: 4 },
{ id: 3, name: "Free", ar: [], al: {}, dt: 0, fee: 0 },
{ id: 4, name: "Free low-quality", ar: [], al: {}, dt: 0, fee: 8 },
];
const out = mapNeteaseSongs(raw);
expect(out[0].vip).toBe(true);
expect(out[1].vip).toBe(true);
expect(out[2].vip).toBe(false);
expect(out[3].vip).toBe(false); // fee=8 plays in full (low quality), NOT vip
});
it("mapNeteaseSongs accepts artists/album/duration aliases (personal_fm shape)", () => {
const out = mapNeteaseSongs([
{ id: 9, name: "FM", artists: [{ name: "B" }], album: { name: "Al2", picUrl: "p2" }, duration: 200000, fee: 0 },
]);
expect(out[0]).toMatchObject({ artist: "B", album: "Al2", coverUrl: "p2", vip: false });
});
it("parseNeteaseTrial maps freeTrialInfo to trial seconds", () => {
// 无试听(VIP/免费)
expect(parseNeteaseTrial({})).toBeUndefined();
expect(parseNeteaseTrial({ freeTrialInfo: null })).toBeUndefined();
// 标准秒
expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 30 } })).toBe(30);
expect(parseNeteaseTrial({ freeTrialInfo: { start: 5, end: 35 } })).toBe(30);
// 别名容忍 begin/trialBegin
expect(parseNeteaseTrial({ freeTrialInfo: { begin: 0, end: 30 } })).toBe(30);
// 毫秒兜底(end>1000)
expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 30000 } })).toBe(30);
// 异常 end<=start
expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 0 } })).toBeUndefined();
});
});
+39 -60
View File
@@ -2,6 +2,7 @@ import axios, { type AxiosInstance } from "axios";
import type {
MusicProvider,
Song,
SongUrlResult,
Playlist,
PlaylistDetail,
LyricLine,
@@ -68,6 +69,33 @@ export function mapNeteaseAlbums(raw: any[] | null | undefined): Album[] {
}));
}
export function mapNeteaseSongs(raw: any[] | null | undefined): Song[] {
if (!Array.isArray(raw)) return [];
return raw.map((s: any) => ({
id: String(s.id),
name: s.name,
artist: (s.ar ?? s.artists ?? []).map((a: any) => a.name).join(" / "),
album: s.al?.name ?? s.album?.name ?? "",
duration: Math.round((s.dt ?? s.duration ?? 0) / 1000),
coverUrl: s.al?.picUrl ?? s.album?.picUrl ?? "",
platform: "netease",
// fee: 0=free, 1=VIP, 4=album-only, 8=free low-quality (plays in full, NOT vip)
vip: s.fee === 1 || s.fee === 4,
}));
}
/** 解析网易云 freeTrialInfo → 试听秒数;无片段(VIP/免费)返回 undefined。
* 真实字段 {start,end} 单位秒;容忍 begin/trialBegin 别名 + 毫秒兜底(end>1000)。 */
export function parseNeteaseTrial(item: any): number | undefined {
const t = item?.freeTrialInfo;
if (!t || typeof t !== "object") return undefined;
const start = Number(t.start ?? t.begin ?? t.trialBegin ?? 0);
const end = Number(t.end ?? t.trialEnd);
if (!Number.isFinite(end) || end <= start) return undefined;
const secs = end > 1000 ? (end - start) / 1000 : end - start;
return Math.round(secs);
}
// 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 },
@@ -121,17 +149,7 @@ export class NeteaseProvider implements MusicProvider {
}),
]);
const songs: Song[] = (songRes.data?.result?.songs ?? []).map(
(s: any) => ({
id: String(s.id),
name: s.name,
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
album: s.al?.name ?? "",
duration: Math.round((s.dt ?? 0) / 1000),
coverUrl: s.al?.picUrl ?? "",
platform: "netease",
})
);
const songs: Song[] = mapNeteaseSongs(songRes.data?.result?.songs);
const playlists: Playlist[] = (
playlistRes.data?.result?.playlists ?? []
@@ -148,44 +166,29 @@ export class NeteaseProvider implements MusicProvider {
return { songs, playlists, albums };
}
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
async getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null> {
const level = quality ?? this.quality;
const res = await this.api.get("/song/url/v1", {
params: { id: songId, level, ...this.cookieParams },
});
return res.data?.data?.[0]?.url ?? null;
const item = res.data?.data?.[0];
const url = item?.url;
if (!url) return null;
return { url, trialDuration: parseNeteaseTrial(item) };
}
async getSongDetail(songId: string): Promise<Song | null> {
const res = await this.api.get("/song/detail", {
params: { ids: songId, ...this.cookieParams },
});
const s = res.data?.songs?.[0];
if (!s) return null;
return {
id: String(s.id),
name: s.name,
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
album: s.al?.name ?? "",
duration: Math.round((s.dt ?? 0) / 1000),
coverUrl: s.al?.picUrl ?? "",
platform: "netease",
};
return mapNeteaseSongs(res.data?.songs)[0] ?? null;
}
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
const res = await this.api.get("/playlist/track/all", {
params: { id: playlistId, ...this.cookieParams },
});
return (res.data?.songs ?? []).map((s: any) => ({
id: String(s.id),
name: s.name,
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
album: s.al?.name ?? "",
duration: Math.round((s.dt ?? 0) / 1000),
coverUrl: s.al?.picUrl ?? "",
platform: "netease",
}));
return mapNeteaseSongs(res.data?.songs);
}
async getRecommendPlaylists(): Promise<Playlist[]> {
@@ -205,15 +208,7 @@ export class NeteaseProvider implements MusicProvider {
const res = await this.api.get("/album", {
params: { id: albumId, ...this.cookieParams },
});
return (res.data?.songs ?? []).map((s: any) => ({
id: String(s.id),
name: s.name,
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
album: s.al?.name ?? "",
duration: Math.round((s.dt ?? 0) / 1000),
coverUrl: s.al?.picUrl ?? "",
platform: "netease",
}));
return mapNeteaseSongs(res.data?.songs);
}
async getLyrics(songId: string): Promise<LyricLine[]> {
@@ -312,30 +307,14 @@ export class NeteaseProvider implements MusicProvider {
const res = await this.api.get("/personal_fm", {
params: { ...this.cookieParams },
});
return (res.data?.data ?? []).map((s: any) => ({
id: String(s.id),
name: s.name,
artist: (s.artists ?? []).map((a: any) => a.name).join(" / "),
album: s.album?.name ?? "",
duration: Math.round((s.duration ?? 0) / 1000),
coverUrl: s.album?.picUrl ?? "",
platform: "netease",
}));
return mapNeteaseSongs(res.data?.data);
}
async getDailyRecommendSongs(): Promise<Song[]> {
const res = await this.api.get("/recommend/songs", {
params: { ...this.cookieParams },
});
return (res.data?.data?.dailySongs ?? []).map((s: any) => ({
id: String(s.id),
name: s.name,
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
album: s.al?.name ?? "",
duration: Math.round((s.dt ?? 0) / 1000),
coverUrl: s.al?.picUrl ?? "",
platform: "netease",
}));
return mapNeteaseSongs(res.data?.data?.dailySongs);
}
async getPlaylistDetail(playlistId: string): Promise<PlaylistDetail | null> {
+15 -5
View File
@@ -5,19 +5,29 @@ export interface Song {
album: string;
duration: number; // seconds
coverUrl: string;
platform: "netease" | "qq" | "bilibili" | "youtube";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
/** VIP / copyright-restricted: non-VIP users can only play a trial fragment
* (NetEase fee=1 VIP / fee=4 album-only, or QQ pay.payplay/paytrackprice=1). */
vip?: boolean;
}
export interface SongWithUrl extends Song {
url: string;
}
/** getSongUrl 解析结果。trialDuration 缺省 = 完整可播放(VIP 账号 / 免费曲)。 */
export interface SongUrlResult {
url: string;
/** 试听片段时长(秒)。VIP/免费曲为 undefined → 调用方回退完整 duration。 */
trialDuration?: number;
}
export interface Playlist {
id: string;
name: string;
coverUrl: string;
songCount: number;
platform: "netease" | "qq" | "bilibili" | "youtube";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
}
export interface PlaylistDetail {
@@ -34,7 +44,7 @@ export interface Album {
artist: string;
coverUrl: string;
songCount: number;
platform: "netease" | "qq" | "bilibili" | "youtube";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
}
export interface LyricLine {
@@ -62,10 +72,10 @@ export interface AuthStatus {
}
export interface MusicProvider {
readonly platform: "netease" | "qq" | "bilibili" | "youtube";
readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
search(query: string, limit?: number): Promise<SearchResult>;
getSongUrl(songId: string, quality?: string): Promise<string | null>;
getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>;
setQuality(quality: string): void;
getQuality(): string;
getSongDetail(songId: string): Promise<Song | null>;
+52 -1
View File
@@ -1,7 +1,58 @@
import { describe, it, expect } from "vitest";
import { mapQqAlbums } from "./qq.js";
import { mapQqAlbums, mapQqSongs, parseQqTrial } 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",
vip: false,
},
]);
});
it("mapQqSongs maps pay field to vip flag", () => {
const out = mapQqSongs([
{ mid: "v1", name: "VIP playplay", singer: [], album: {}, interval: 100, pay: { payplay: 1, paytrackprice: 0 } },
{ mid: "v2", name: "VIP trackprice", singer: [], album: {}, interval: 100, pay: { payplay: 0, paytrackprice: 1 } },
{ mid: "f1", name: "Free", singer: [], album: {}, interval: 100, pay: { payplay: 0, paytrackprice: 0 } },
{ mid: "f2", name: "No pay field", singer: [], album: {}, interval: 100 },
]);
expect(out[0].vip).toBe(true);
expect(out[1].vip).toBe(true);
expect(out[2].vip).toBe(false);
expect(out[3].vip).toBe(false);
});
it("parseQqTrial maps isTryout/tryout to trial seconds", () => {
// 非试听(VIP/免费)
expect(parseQqTrial({ isTryout: 0 })).toBeUndefined();
expect(parseQqTrial({})).toBeUndefined();
// 试听(秒)
expect(parseQqTrial({ isTryout: 1, tryBegin: 0, tryEnd: 30 })).toBe(30);
expect(parseQqTrial({ tryout: true, begin: 0, end: 45 })).toBe(45);
// 毫秒兜底
expect(parseQqTrial({ isTryout: 1, tryBegin: 0, tryEnd: 30000 })).toBe(30);
// 异常
expect(parseQqTrial({ isTryout: 1, tryEnd: 0 })).toBeUndefined();
});
it("mapQqAlbums maps albumMID-style raw entries", () => {
const raw = [
{
+127 -49
View File
@@ -2,6 +2,7 @@ import axios, { type AxiosInstance } from "axios";
import type {
MusicProvider,
Song,
SongUrlResult,
Playlist,
PlaylistDetail,
LyricLine,
@@ -39,6 +40,37 @@ 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,
vip: s.pay?.payplay === 1 || s.pay?.paytrackprice === 1 || false,
};
}).filter((s) => s.id);
}
/** 解析 QQ 试听标记 → 试听秒数;非试听(VIP/免费)返回 undefined。
* 字段 isTryout===1 / tryout===true + tryBegin/tryEnd;容忍 begin/start 别名 + 毫秒兜底。 */
export function parseQqTrial(playUrl: any): number | undefined {
if (!playUrl || typeof playUrl !== "object") return undefined;
if (playUrl.isTryout !== 1 && playUrl.tryout !== true) return undefined;
const begin = Number(playUrl.tryBegin ?? playUrl.begin ?? playUrl.start ?? 0);
const end = Number(playUrl.tryEnd ?? playUrl.end);
if (!Number.isFinite(end) || end <= begin) return undefined;
const secs = end > 1000 ? (end - begin) / 1000 : end - begin;
return Math.round(secs);
}
export function mapQqAlbums(raw: any[] | null | undefined): Album[] {
if (!Array.isArray(raw)) return [];
return raw.map((a) => {
@@ -72,6 +104,7 @@ export class QQMusicProvider implements MusicProvider {
private api: AxiosInstance;
private cookie = "";
private quality = "exhigh";
private radarPage = 1;
constructor(baseUrl: string) {
this.api = axios.create({
@@ -92,6 +125,30 @@ export class QQMusicProvider implements MusicProvider {
return this.cookie ? { cookie: this.cookie } : {};
}
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,
},
req_0: { module, method, param },
};
}
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
@@ -141,17 +198,7 @@ export class QQMusicProvider implements MusicProvider {
res.data?.req_0?.data?.body?.song?.list ?? [];
if (songList.length === 0) return null;
const songs: Song[] = songList.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",
}));
const songs = mapQqSongs(songList);
const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
const albums = mapQqAlbums(albumList);
@@ -203,17 +250,7 @@ export class QQMusicProvider implements MusicProvider {
? (songRes.value.data?.data?.song?.list ?? [])
: [];
const songs: Song[] = songList.map((s: any) => ({
id: String(s.songmid ?? s.songid ?? ""),
name: s.songname ?? s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.albumname ?? s.album?.name ?? "",
duration: s.interval ?? 0,
coverUrl: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: "",
platform: "qq",
}));
const songs = mapQqSongs(songList);
const albumList: any[] =
albumRes.status === "fulfilled"
@@ -224,13 +261,13 @@ export class QQMusicProvider implements MusicProvider {
return { songs, playlists: [], albums };
}
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
async getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null> {
try {
const res = await this.api.get("/getMusicPlay", {
params: { songmid: songId, quality: quality ?? this.quality, ...this.cookieParams },
});
const playUrl = res.data?.data?.playUrl?.[songId];
if (playUrl?.url) return playUrl.url;
if (playUrl?.url) return { url: playUrl.url, trialDuration: parseQqTrial(playUrl) };
} catch {
// try with songid
try {
@@ -238,7 +275,7 @@ export class QQMusicProvider implements MusicProvider {
params: { songid: songId, quality: quality ?? this.quality, ...this.cookieParams },
});
const playUrl = res.data?.data?.playUrl?.[songId];
if (playUrl?.url) return playUrl.url;
if (playUrl?.url) return { url: playUrl.url, trialDuration: parseQqTrial(playUrl) };
} catch {
// ignore
}
@@ -339,19 +376,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> {
@@ -386,17 +411,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[]> {
@@ -461,6 +476,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 {
@@ -516,12 +534,72 @@ export class QQMusicProvider implements MusicProvider {
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
: "",
platform: "qq",
vip: s.pay?.payplay === 1 || s.pay?.paytrackprice === 1 || false,
}));
} catch {
return [];
}
}
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);
+3 -2
View File
@@ -7,6 +7,7 @@ import type {
MusicProvider,
Song,
SongWithUrl,
SongUrlResult,
Playlist,
Album,
SearchResult,
@@ -134,7 +135,7 @@ export class YouTubeProvider implements MusicProvider {
}
}
async getSongUrl(songId: string): Promise<string | null> {
async getSongUrl(songId: string): Promise<SongUrlResult | null> {
try {
const url = `https://www.youtube.com/watch?v=${songId}`;
const raw = await runYtDlp([
@@ -146,7 +147,7 @@ export class YouTubeProvider implements MusicProvider {
"--quiet",
], 45_000);
const audioUrl = raw.trim().split("\n")[0];
return audioUrl || null;
return audioUrl ? { url: audioUrl } : null;
} catch {
return null;
}
+85
View File
@@ -0,0 +1,85 @@
import { describe, it, expect, vi } from "vitest";
import pino from "pino";
import { TS3Client } from "./client.js";
/**
* Integration "smoke test" for the admin-command gate's group resolution.
*
* It drives the REAL TS3Client.getClientServerGroups → library getClientInfo
* path against a stubbed underlying client, so it exercises the actual
* `clientinfo clid=<id>` query string and the real `client_servergroups`
* parsing — the pieces that were previously only verified by reading the code.
*
* What this CANNOT cover (inherently server-side, needs a live TS server):
* whether a real server returns groups for a client in a DIFFERENT channel.
* The stub models the server-wide answer (groups returned regardless of
* channel); the failure modes below confirm we fail closed when it doesn't.
*/
function makeClient(): TS3Client {
return new TS3Client(
{ host: "localhost", port: 9987, queryPort: 10011, nickname: "TestBot" },
pino({ level: "silent" }),
);
}
/** Inject a fake low-level client carrying a canned clientinfo response. */
function withFakeClient(
ts: TS3Client,
respond: (cmd: string) => Record<string, string>[] | Promise<Record<string, string>[]>,
): string[] {
const calls: string[] = [];
const fake = {
execCommandWithResponse: vi.fn(async (cmd: string) => {
calls.push(cmd);
return respond(cmd);
}),
};
(ts as unknown as { client: unknown }).client = fake;
return calls;
}
describe("TS3Client.getClientServerGroups — live query + parse smoke test", () => {
it("issues `clientinfo clid=<id>` and parses comma-separated client_servergroups", async () => {
const ts = makeClient();
const calls = withFakeClient(ts, () => [
{ client_nickname: "Alice", cid: "99", client_servergroups: "6,8" },
]);
const groups = await ts.getClientServerGroups(5);
expect(groups).toEqual(["6", "8"]);
// Exact query the bot sends to resolve a sender's groups, by client id.
expect(calls[0]).toBe("clientinfo clid=5");
});
it("parses a single-group response", async () => {
const ts = makeClient();
withFakeClient(ts, () => [{ client_servergroups: "6" }]);
expect(await ts.getClientServerGroups(5)).toEqual(["6"]);
});
it("returns [] when the client carries no server groups (empty field)", async () => {
const ts = makeClient();
withFakeClient(ts, () => [{ client_nickname: "Bob", client_servergroups: "" }]);
expect(await ts.getClientServerGroups(7)).toEqual([]);
});
it("returns [] when the server-groups field is absent", async () => {
const ts = makeClient();
withFakeClient(ts, () => [{ client_nickname: "Carol" }]);
expect(await ts.getClientServerGroups(7)).toEqual([]);
});
it("fails closed (returns []) when the query throws / client id is unknown", async () => {
const ts = makeClient();
withFakeClient(ts, () => {
throw new Error("invalid clientID");
});
expect(await ts.getClientServerGroups(999)).toEqual([]);
});
it("returns [] when not connected (no underlying client)", async () => {
const ts = makeClient();
expect(await ts.getClientServerGroups(5)).toEqual([]);
});
});
+73 -16
View File
@@ -8,10 +8,13 @@ import {
listChannels,
listClients,
clientMove,
getClientInfo,
fileTransferDeleteFile,
type Identity,
type TextMessage,
type ClientInfo,
type ClientLeftViewEvent,
type ClientMovedEvent,
type FileUploadInfo,
} from "@honeybbq/teamspeak-client";
import type { Logger } from "../logger.js";
@@ -44,6 +47,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. */
@@ -58,6 +62,24 @@ export interface TS3TextMessage {
invokerUid: string;
message: string;
targetMode: number; // 1=private, 2=channel, 3=server
invokerGroups: string[]; // sender's TS server-group ids; [] when not in view cache
}
/**
* Map the library's TextMessage to our wrapper. Preserves invokerGroups (the
* sender's TS server groups), which the library populates only when the sender
* is in the bot's client-view cache; otherwise it is []. Used by the chat
* command permission gate.
*/
export function toTS3TextMessage(msg: TextMessage): TS3TextMessage {
return {
invokerName: msg.invokerName,
invokerId: String(msg.invokerID),
invokerUid: msg.invokerUID,
message: msg.message,
targetMode: msg.targetMode,
invokerGroups: msg.invokerGroups ?? [],
};
}
export class TS3Client extends EventEmitter {
@@ -200,14 +222,7 @@ export class TS3Client extends EventEmitter {
});
this.client.on("textMessage", (msg: TextMessage) => {
const tsMsg: TS3TextMessage = {
invokerName: msg.invokerName,
invokerId: String(msg.invokerID),
invokerUid: msg.invokerUID,
message: msg.message,
targetMode: msg.targetMode,
};
this.emit("textMessage", tsMsg);
this.emit("textMessage", toTS3TextMessage(msg));
});
this.client.on("disconnected", (err) => {
@@ -221,6 +236,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 +267,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 +283,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 +303,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"
@@ -297,6 +334,26 @@ export class TS3Client extends EventEmitter {
}
}
/**
* Resolve a client's CURRENT server groups by client id, server-wide (works
* regardless of channel/view) via a targeted `clientinfo` query. The raw
* `client_servergroups` field is a comma-separated list (same field
* `listClients` parses). Returns [] if the client can't be resolved or the
* query fails, so callers fail closed.
*/
async getClientServerGroups(clid: number): Promise<string[]> {
if (!this.client) return [];
try {
const info = await getClientInfo(this.client, clid);
// `client_servergroups`: comma-separated server-group ids (verified in
// @honeybbq/teamspeak-client dist/index.mjs; listClients parses the same).
const raw = info.client_servergroups ?? "";
return raw ? raw.split(",") : [];
} catch {
return [];
}
}
// --- Raw command & file transfer pass-through ---
async execCommand(cmd: string): Promise<void> {
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { toTS3TextMessage } from "./client.js";
import type { TextMessage } from "@honeybbq/teamspeak-client";
function makeMsg(over: Partial<TextMessage> = {}): TextMessage {
return {
invokerName: "Alice",
invokerUID: "uid-abc",
message: "!stop",
invokerGroups: ["6", "8"],
targetMode: 2,
targetID: 0n,
invokerID: 5,
...over,
};
}
describe("toTS3TextMessage", () => {
it("maps core fields and stringifies invokerID", () => {
const r = toTS3TextMessage(makeMsg());
expect(r.invokerName).toBe("Alice");
expect(r.invokerId).toBe("5");
expect(r.invokerUid).toBe("uid-abc");
expect(r.message).toBe("!stop");
expect(r.targetMode).toBe(2);
});
it("preserves the sender's server groups", () => {
expect(toTS3TextMessage(makeMsg({ invokerGroups: ["6"] })).invokerGroups).toEqual(["6"]);
});
it("defaults missing invokerGroups to an empty array", () => {
const partial = {
invokerName: "Bob",
invokerUID: "u",
message: "!stop",
targetMode: 1,
targetID: 0n,
invokerID: 7,
} as unknown as TextMessage;
expect(toTS3TextMessage(partial).invokerGroups).toEqual([]);
});
});
+4 -1
View File
@@ -6,6 +6,8 @@ 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";
@@ -20,6 +22,7 @@ describe("audit router", () => {
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++) {
@@ -32,7 +35,7 @@ describe("audit router", () => {
app = express();
app.use(express.json());
app.use(cookieParser());
app.use("/api", createRequireAuth(sessions));
app.use("/api", createRequireAuth(sessions, permissions, () => getDefaultConfig().guestMode));
app.use("/api/audit", createAuditRouter(audit));
});
+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"]);
});
});
+254
View File
@@ -0,0 +1,254 @@
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([]);
}
});
it("GET /settings includes adminGroups reflecting config", async () => {
config.adminGroups = [6, 8];
const res = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.adminGroups).toEqual([6, 8]);
});
it("POST /settings persists a validated adminGroups and GET returns it", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ adminGroups: [6, 8] });
expect(res.status).toBe(200);
expect(res.body.adminGroups).toEqual([6, 8]);
expect(config.adminGroups).toEqual([6, 8]);
const followUp = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(followUp.body.adminGroups).toEqual([6, 8]);
});
it("POST /settings filters invalid adminGroups entries (negative, non-integer, non-number)", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ adminGroups: [6, -1, 2.5, "x", 8] });
expect(res.status).toBe(200);
expect(config.adminGroups).toEqual([6, 8]);
});
it("POST /settings ignores a non-array adminGroups (leaves config unchanged)", async () => {
config.adminGroups = [6];
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ adminGroups: "6" });
expect(res.status).toBe(200);
expect(config.adminGroups).toEqual([6]);
});
});
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
});
});
+109 -37
View File
@@ -1,10 +1,13 @@
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,
@@ -13,15 +16,100 @@ export function createBotRouter(
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,
localAudioEnabled: config.localAudioEnabled,
adminGroups: config.adminGroups ?? [],
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, localAudioEnabled, guestMode, adminGroups } = 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";
const hasLocalAudioEnabled = typeof localAudioEnabled === "boolean";
if (hasIdle) config.idleTimeoutMinutes = idleTimeoutMinutes;
if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty;
if (hasLocalAudioEnabled) config.localAudioEnabled = localAudioEnabled;
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];
}
}
}
}
if (Array.isArray(adminGroups)) {
config.adminGroups = adminGroups.filter(
(g: unknown): g is number =>
typeof g === "number" && Number.isInteger(g) && g >= 0,
);
}
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,
localAudioEnabled: config.localAudioEnabled,
adminGroups: config.adminGroups ?? [],
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" });
@@ -31,16 +119,19 @@ 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.get("/:id/avatar", (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();
@@ -62,7 +153,7 @@ export function createBotRouter(
res.send(buf);
});
router.put("/:id/avatar", (req, res) => {
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);
@@ -96,7 +187,7 @@ export function createBotRouter(
res.json({ path: rel });
});
router.delete("/:id/avatar", (req, res) => {
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);
@@ -104,7 +195,7 @@ export function createBotRouter(
res.status(204).end();
});
router.post("/", async (req, res) => {
router.post("/", requirePermission("bot.manage"), async (req, res) => {
try {
const {
name,
@@ -112,6 +203,7 @@ export function createBotRouter(
serverPort,
nickname,
defaultChannel,
channelId,
channelPassword,
serverPassword,
autoStart,
@@ -128,6 +220,7 @@ export function createBotRouter(
serverPort: serverPort ?? 9987,
nickname,
defaultChannel,
channelId,
channelPassword,
serverPassword,
autoStart: autoStart ?? false,
@@ -140,17 +233,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) {
@@ -159,7 +252,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 });
@@ -168,7 +261,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 });
@@ -177,7 +270,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 });
@@ -185,27 +278,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;
}
+81 -8
View File
@@ -1,23 +1,85 @@
import { Router } from "express";
import express, { Router } from "express";
import type { MusicProvider } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.js";
import type { Logger } from "../../logger.js";
import type { BotConfig } from "../../data/config.js";
import { requirePermission } from "../middleware/requirePermission.js";
import { requireNotGuest } from "../middleware/requireNotGuest.js";
import { authorize } from "../middleware/authorize.js";
export function createMusicRouter(
neteaseProvider: MusicProvider,
qqProvider: MusicProvider,
bilibiliProvider: MusicProvider,
logger: Logger
logger: Logger,
localProvider?: MusicProvider,
config?: BotConfig
): Router {
const router = Router();
const youtubeProvider: MusicProvider = new YouTubeProvider();
function isLocalAudioEnabled(): boolean {
return config?.localAudioEnabled !== false;
}
function getProvider(platform?: string): MusicProvider {
if (platform === "bilibili") return bilibiliProvider;
if (platform === "youtube") return youtubeProvider;
if (platform === "local" && localProvider) return localProvider;
return platform === "qq" ? qqProvider : neteaseProvider;
}
router.post(
"/local/upload",
authorize({ capability: "player.queue", guestFlag: "addToQueue" }),
(_req, res, next) => {
if (!isLocalAudioEnabled()) {
res.status(403).json({ error: "本地音频播放已关闭" });
return;
}
next();
},
express.raw({
type: ["audio/*", "video/webm", "application/octet-stream"],
limit: "200mb",
}),
async (req, res) => {
try {
if (!localProvider) {
res.status(501).json({ error: "Local upload is not configured" });
return;
}
const uploadCapable = localProvider as MusicProvider & {
uploadAudio?: (input: { buffer: Buffer; originalName: string; mimeType?: string }) => Promise<unknown>;
};
if (typeof uploadCapable.uploadAudio !== "function") {
res.status(501).json({ error: "Local upload is not supported" });
return;
}
if (!Buffer.isBuffer(req.body)) {
res.status(400).json({ error: "raw audio body is required" });
return;
}
const headerName = req.header("x-filename") || req.header("x-file-name") || "audio";
let originalName = headerName;
try {
originalName = decodeURIComponent(headerName);
} catch {
// Keep the raw header value if it is not URI encoded.
}
const song = await uploadCapable.uploadAudio({
buffer: req.body,
originalName,
mimeType: req.header("content-type") || undefined,
});
res.json({ song });
} catch (err) {
logger.warn({ err }, "Local audio upload failed");
res.status(400).json({ error: (err as Error).message });
}
},
);
router.get("/search", async (req, res) => {
try {
const { q, platform, limit } = req.query;
@@ -25,6 +87,10 @@ export function createMusicRouter(
res.status(400).json({ error: "q (query) is required" });
return;
}
if (platform === "local" && !isLocalAudioEnabled()) {
res.json({ songs: [], playlists: [], albums: [] });
return;
}
const provider = getProvider(platform as string);
const result = await provider.search(
q as string,
@@ -45,16 +111,18 @@ export function createMusicRouter(
return;
}
const parsedLimit = parseInt(limit as string) || 20;
const [neteaseResult, qqResult, bilibiliResult] = await Promise.allSettled([
const [neteaseResult, qqResult, bilibiliResult, localResult] = await Promise.allSettled([
neteaseProvider.search(q as string, parsedLimit),
qqProvider.search(q as string, parsedLimit),
bilibiliProvider.search(q as string, parsedLimit),
localProvider && isLocalAudioEnabled() ? localProvider.search(q as string, parsedLimit) : Promise.resolve({ songs: [], albums: [], playlists: [] }),
]);
const songs = [
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []),
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
...(localResult.status === "fulfilled" ? localResult.value.songs : []),
];
const albums = [
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
@@ -74,6 +142,10 @@ export function createMusicRouter(
router.get("/song/:id", async (req, res) => {
try {
if (req.query.platform === "local" && !isLocalAudioEnabled()) {
res.status(403).json({ error: "本地音频播放已关闭" });
return;
}
const provider = getProvider(req.query.platform as string);
const song = await provider.getSongDetail(req.params.id);
if (!song) {
@@ -126,7 +198,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) {
@@ -141,7 +213,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) {
@@ -156,7 +228,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) {
@@ -208,16 +280,17 @@ 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(),
bilibili: bilibiliProvider.getQuality(),
local: localProvider?.getQuality() ?? "original",
});
});
// 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" });
+458
View File
@@ -0,0 +1,458 @@
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,
playCollection: 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("playCollection flag gates /play-playlist, /play-album (issue #103)", async () => {
const allow = mountGuest({ playCollection: true });
const deny = mountGuest({ playCollection: false });
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-playlist`).send({ playlistId: "1" })).status).not.toBe(403);
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-album`).send({ albumId: "1" })).status).not.toBe(403);
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/play-playlist`).send({ playlistId: "1" })).status).toBe(403);
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/play-album`).send({ albumId: "1" })).status).toBe(403);
// playCollection does NOT leak into the destructive single-song / queue ops.
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play`).send({ query: "x" })).status).toBe(403);
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-song`).send({ song: SONG })).status).toBe(403);
});
it("guests are always denied /play, /prev, /stop, /play-song, /play-at, /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,
playCollection: 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}/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);
});
});
+205 -76
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,17 @@ export function createPlayerRouter(
return "";
};
router.post("/:botId/play", async (req, res) => {
function isLocalAudioDisabled(bot: any, platform: unknown): boolean {
return platform === "local" &&
typeof bot.isLocalAudioEnabled === "function" &&
!bot.isLocalAudioEnabled();
}
function rejectDisabledLocalAudio(res: any): void {
res.status(403).json({ error: "本地音频播放已关闭" });
}
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 +72,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 +99,40 @@ 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;
if (isLocalAudioDisabled(bot, platform)) {
rejectDisabledLocalAudio(res);
return;
}
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
? 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 +160,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 +185,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 +209,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 +221,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 +229,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,14 +279,18 @@ 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", guestFlag: "playCollection" }), async (req, res) => {
try {
const bot = (req as any).bot;
const { playlistId, platform } = req.body;
if (isLocalAudioDisabled(bot, platform)) {
rejectDisabledLocalAudio(res);
return;
}
// Use the bot's own provider lookup — it already knows about youtube,
// which the router's constructor params did not.
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube"
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
? platform
: "netease"
);
@@ -277,6 +333,10 @@ export function createPlayerRouter(
for (const song of queueable) {
queue.add({ ...song, platform: provider.platform });
}
// Sweep AFTER the queue is rebuilt: the previous queue's local uploads are
// released and deleted, but an empty/failed playlist (early return above)
// leaves the previous queue — and its files — intact.
bot.cleanupQueuedLocalSongs?.("queue_replaced");
// Use queue.play() for sequential, or pick random index for random modes
const mode = queue.getMode();
@@ -314,12 +374,16 @@ export function createPlayerRouter(
});
// Play an album by ID — mirrors play-playlist but calls getAlbumSongs
router.post("/:botId/play-album", async (req, res) => {
router.post("/:botId/play-album", authorize({ capability: "player.control", guestFlag: "playCollection" }), async (req, res) => {
try {
const bot = (req as any).bot;
const { albumId, platform } = req.body;
if (isLocalAudioDisabled(bot, platform)) {
rejectDisabledLocalAudio(res);
return;
}
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube"
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
? platform
: "netease"
);
@@ -355,6 +419,8 @@ export function createPlayerRouter(
for (const song of queueable) {
queue.add({ ...song, platform: provider.platform });
}
// Sweep AFTER the queue is rebuilt (see play-playlist).
bot.cleanupQueuedLocalSongs?.("queue_replaced");
const mode = queue.getMode();
let first;
@@ -386,7 +452,7 @@ export function createPlayerRouter(
});
// 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;
@@ -394,13 +460,21 @@ export function createPlayerRouter(
res.status(400).json({ error: "song object with id and platform is required" });
return;
}
if (isLocalAudioDisabled(bot, song.platform)) {
rejectDisabledLocalAudio(res);
return;
}
const queue = bot.getQueueManager();
bot.getPlayer().stop();
queue.clear();
queue.add(song);
queue.play();
bot.getPlayer().resetFailures();
const ok = await bot.resolveAndPlay(queue.current()!);
// Sweep AFTER the new song is queued+resolved, so replaying a local song
// that was still in the queue doesn't delete the file we're about to play.
bot.cleanupQueuedLocalSongs?.("queue_replaced");
if (!ok) {
res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` });
return;
@@ -414,7 +488,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;
@@ -422,37 +496,47 @@ 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);
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;
}
res.json({ ok: true, message: `正在播放:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
if (isLocalAudioDisabled(bot, song.platform)) {
rejectDisabledLocalAudio(res);
return;
}
// 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);
res.json({ ok: true, message: `已加入下一首:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
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'}` };
}
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;
@@ -460,32 +544,77 @@ 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);
bot.getPlayer().resetFailures();
await bot.resolveAndPlay(queue.current()!);
res.json({ message: `Now playing: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
if (isLocalAudioDisabled(bot, song.platform)) {
rejectDisabledLocalAudio(res);
return;
}
// 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();
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 });
}
});
res.json({ message: `Added to queue: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'} (position ${queue.size()})` });
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;
}
if (isLocalAudioDisabled(bot, song.platform)) {
rejectDisabledLocalAudio(res);
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);
// 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;
if (isLocalAudioDisabled(bot, platform)) {
rejectDisabledLocalAudio(res);
return;
}
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube"
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
? platform
: "netease"
);
@@ -518,7 +647,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();
+125 -3
View File
@@ -7,6 +7,9 @@ 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";
@@ -15,7 +18,18 @@ function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
app.use(express.json());
app.use(cookieParser());
const audit = createAuditStore(botDb.db);
app.use("/api/session", createSessionRouter(users, sessions, audit, pino({ level: "silent" })));
const permissions = createPermissionStore(botDb.db);
app.use(
"/api/session",
createSessionRouter(
users,
sessions,
audit,
pino({ level: "silent" }),
permissions,
() => getDefaultConfig().guestMode
)
);
return app;
}
@@ -45,7 +59,7 @@ describe("session router", () => {
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 });
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 () => {
@@ -57,7 +71,7 @@ describe("session router", () => {
extractCookie(setupRes);
const needs = await request(app).get("/api/session/needs-setup");
expect(needs.body).toEqual({ needsSetup: false });
expect(needs.body).toEqual({ needsSetup: false, guestAllowed: false });
});
it("POST /setup returns 409 once a user already exists", async () => {
@@ -100,6 +114,12 @@ describe("session router", () => {
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);
@@ -149,3 +169,105 @@ describe("session router", () => {
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,
playCollection: 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);
});
});
+51 -4
View File
@@ -4,7 +4,10 @@ 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 { SESSION_TTL_MS } from "../../data/sessions.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;
@@ -49,7 +52,9 @@ export function createSessionRouter(
users: UserStore,
sessions: SessionStore,
audit: AuditStore,
logger: Logger
logger: Logger,
permissions: PermissionStore,
getGuestConfig: () => GuestModeConfig
): Router {
const router = Router();
@@ -60,6 +65,13 @@ export function createSessionRouter(
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);
@@ -67,7 +79,7 @@ export function createSessionRouter(
};
router.get("/needs-setup", (_req, res) => {
res.json({ needsSetup: users.countUsers() === 0 });
res.json({ needsSetup: users.countUsers() === 0, guestAllowed: getGuestConfig().enabled });
});
router.post("/setup", async (req, res) => {
@@ -123,6 +135,26 @@ export function createSessionRouter(
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) {
@@ -133,7 +165,22 @@ export function createSessionRouter(
});
router.get("/me", requireAuthInline, (req, res) => {
res.json(req.user);
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) => {
+141 -8
View File
@@ -4,9 +4,11 @@ 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 { createUserStore, GUEST_USER_ID, type UserStore } from "../../data/users.js";
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
import { createAuditStore } from "../../data/audit.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";
@@ -15,11 +17,12 @@ function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
const app = express();
app.use(express.json());
app.use(cookieParser());
const requireAuth = createRequireAuth(sessions);
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" })));
return app;
app.use("/api/users", createUsersRouter(users, sessions, audit, pino({ level: "silent" }), permissions));
return { app, permissions, audit };
}
describe("users router", () => {
@@ -27,6 +30,8 @@ describe("users router", () => {
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;
@@ -35,7 +40,7 @@ describe("users router", () => {
botDb = createDatabase(":memory:");
users = createUserStore(botDb.db);
sessions = createSessionStore(botDb.db);
app = makeApp(botDb, users, sessions);
({ 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}`;
@@ -148,10 +153,10 @@ describe("users router", () => {
const localApp = express();
localApp.use(express.json());
localApp.use(cookieParser());
localApp.use("/api", createRequireAuth(sessions));
localApp.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
localApp.use(
"/api/users",
createUsersRouter(users, sessions, brokenAudit, pino({ level: "silent" }))
createUsersRouter(users, sessions, brokenAudit, pino({ level: "silent" }), createPermissionStore(botDb.db))
);
const res = await request(localApp)
.post("/api/users")
@@ -254,4 +259,132 @@ describe("users router", () => {
.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);
});
});
});
+50 -2
View File
@@ -1,9 +1,10 @@
import { Router } from "express";
import type { Logger } from "../../logger.js";
import type { UserStore } from "../../data/users.js";
import { UsernameTakenError } 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 {
@@ -18,7 +19,8 @@ export function createUsersRouter(
users: UserStore,
sessions: SessionStore,
audit: AuditStore,
logger: Logger
logger: Logger,
permissions: PermissionStore
): Router {
const router = Router();
@@ -35,6 +37,9 @@ export function createUsersRouter(
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,
@@ -58,6 +63,7 @@ export function createUsersRouter(
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) {
@@ -99,6 +105,7 @@ export function createUsersRouter(
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" });
@@ -125,6 +132,7 @@ export function createUsersRouter(
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" });
@@ -162,5 +170,45 @@ export function createUsersRouter(
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;
}
+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" });
};
}
+15
View File
@@ -55,4 +55,19 @@ describe("csrfOriginCheck middleware", () => {
.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" });
});
});
+4 -1
View File
@@ -5,6 +5,8 @@ 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";
@@ -19,13 +21,14 @@ describe("requireAdmin middleware", () => {
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));
app.use(createRequireAuth(sessions, permissions, () => getDefaultConfig().guestMode));
app.use(requireAdmin);
app.get("/admin-only", (_req, res) => res.json({ ok: true }));
});
+49 -2
View File
@@ -1,10 +1,11 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
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";
@@ -17,12 +18,28 @@ describe("requireAuth middleware", () => {
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));
app.use(
createRequireAuth(sessions, permissions, () => ({
enabled: true,
bots: "all",
permissions: {
addToQueue: true,
playNext: true,
playNow: true,
skip: true,
transport: true,
removeClear: true,
playMode: true,
playCollection: true,
},
}))
);
app.get("/protected", (req, res) => {
res.json({ ok: true, user: (req as any).user });
});
@@ -66,4 +83,34 @@ describe("requireAuth middleware", () => {
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, playCollection: 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);
});
});
+37 -3
View File
@@ -1,6 +1,8 @@
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,
@@ -9,11 +11,22 @@ import {
declare module "express-serve-static-core" {
interface Request {
user?: { id: string; username: string; role: "admin" | "member" };
user?: {
id: string;
username: string;
role: "admin" | "member" | "guest";
capabilities?: Set<string>;
bots?: "all" | Set<string>;
guest?: GuestPermissions;
};
}
}
export function createRequireAuth(sessions: SessionStore): RequestHandler {
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) {
@@ -21,7 +34,28 @@ export function createRequireAuth(sessions: SessionStore): RequestHandler {
res.status(401).json({ error: "unauthenticated" });
return;
}
req.user = { id: result.userId, username: result.username, role: result.role };
// 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, {
@@ -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);
});
});
+36 -8
View File
@@ -6,7 +6,7 @@ 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";
@@ -18,11 +18,14 @@ 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";
@@ -35,6 +38,7 @@ export interface WebServerOptions {
neteaseProvider: MusicProvider;
qqProvider: MusicProvider;
bilibiliProvider: MusicProvider;
localProvider: MusicProvider;
database: BotDatabase;
config: BotConfig;
configPath: string;
@@ -73,6 +77,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
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) => {
@@ -92,14 +97,19 @@ export function createWebServer(options: WebServerOptions): WebServer {
app.use("/api/session/login", loginLimit);
app.use("/api/session/setup", setupLimit);
app.use("/api/session", createSessionRouter(users, sessions, audit, logger));
app.use("/api/session", createSessionRouter(users, sessions, audit, logger, permissions, () => options.config.guestMode));
// ─── Gates for everything else under /api ───────────────────────────────
const requireAuth = createRequireAuth(sessions);
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(
@@ -109,11 +119,12 @@ export function createWebServer(options: WebServerOptions): WebServer {
logger,
options.database,
options.avatarStore,
(cfg) => onGuestPolicyChanged(cfg),
)
);
app.use(
"/api/music",
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger)
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config)
);
app.use("/api/player", createPlayerRouter(
options.botManager, logger, options.database,
@@ -123,8 +134,10 @@ 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));
// admin-only routes
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger));
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions));
app.use("/api/audit", requireAdmin, createAuditRouter(audit));
// ─── Static SPA (public) ────────────────────────────────────────────────
@@ -170,12 +183,27 @@ export function createWebServer(options: WebServerOptions): WebServer {
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) => {
(ws as unknown as { userId: string }).userId = result.userId;
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 cleanupWs = setupWebSocket(wss, options.botManager, logger);
const controller = setupWebSocket(wss, options.botManager, logger);
onGuestPolicyChanged = controller.refreshGuestPolicy;
// ─── Session cleanup interval ──────────────────────────────────────────
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
@@ -201,7 +229,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
clearInterval(cleanupTimer);
cleanupTimer = null;
}
cleanupWs();
controller.cleanup();
wss.close();
server.close();
},
+107
View File
@@ -7,6 +7,7 @@ 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();
@@ -72,3 +73,109 @@ describe("WebSocket auth at upgrade", () => {
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 -4
View File
@@ -3,10 +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.
Setting no-referrer at the document level covers <img> tags AND CSS background-image fetches.
Our own /api/* CSRF check uses Origin (not Referer), so this doesn't break auth. -->
<meta name="referrer" content="no-referrer">
<!-- 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">
+30 -9
View File
@@ -19,22 +19,22 @@
<div class="m-player-artist">{{ currentSong.artist }}</div>
</div>
<div class="m-player-controls" @click.stop>
<button class="m-player-btn" @click="playerStore.prev()">
<button v-if="can('player.control')" class="m-player-btn" @click="playerStore.prev()">
<Icon icon="mdi:skip-previous" />
</button>
<button class="m-player-btn" @click="playerStore.isPlaying ? playerStore.pause() : playerStore.resume()">
<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 class="m-player-btn" @click="playerStore.next()">
<button v-if="canSkip" class="m-player-btn" @click="playerStore.next()">
<Icon icon="mdi:skip-next" />
</button>
<button class="m-player-btn" @click="cycleMobileMode">
<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 class="m-player-btn" @click="toggleMobileVolume">
<button v-if="canTransport" class="m-player-btn" @click="toggleMobileVolume">
<Icon icon="mdi:volume-high" />
</button>
</div>
@@ -66,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>
@@ -80,6 +80,7 @@ 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';
@@ -87,6 +88,12 @@ 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();
@@ -111,8 +118,10 @@ let mobileRaf: number | null = null;
function updateMobileProgress() {
const duration = currentSong.value?.duration ?? 0;
// liveElapsed() recomputes each frame; the cached `elapsed` getter would
// leave the mobile bar frozen between server pushes (#107).
mobileProgressPct.value = duration > 0
? Math.min((playerStore.elapsed / duration) * 100, 100)
? Math.min((playerStore.liveElapsed() / duration) * 100, 100)
: 0;
mobileRaf = requestAnimationFrame(updateMobileProgress);
}
@@ -140,12 +149,24 @@ function cycleMobileMode() {
playerStore.setMode(nextMode);
}
onMounted(() => {
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(() => {
+95 -5
View File
@@ -10,8 +10,21 @@
</div>
<div class="nav-right">
<!-- Bot selector (always shown when at least one bot exists) -->
<div v-if="store.bots.length > 0" class="bot-selector" ref="selectorRef">
<!-- Scoped (dedicated link): static label locked to the one bot, no switching -->
<div v-if="store.isScoped" class="bot-selector scoped" ref="selectorRef">
<div class="bot-selector-btn static">
<span class="bot-dot" :class="{ online: activeBot?.connected }" />
<span class="bot-selector-name">{{ activeBot?.name ?? '专属机器人' }}</span>
<span v-if="activeBot?.playing && !activeBot?.paused" class="bot-state-mini playing">▶</span>
<span v-else-if="activeBot?.paused" class="bot-state-mini paused">⏸</span>
<span class="scope-badge">专属模式</span>
</div>
<button class="scope-exit-btn" @click="exitScope" title="退出专属模式">退出</button>
</div>
<!-- Normal: full selector with switching (shown when at least one
controllable bot exists — scope ∩ permission via displayedBots) -->
<div v-else-if="displayedBots.length > 0" class="bot-selector" ref="selectorRef">
<button class="bot-selector-btn" @click="dropdownOpen = !dropdownOpen">
<span class="bot-dot" :class="{ online: activeBot?.connected }" />
<span class="bot-selector-name">{{ activeBot?.name ?? '选择机器人' }}</span>
@@ -22,7 +35,7 @@
<div v-if="dropdownOpen" class="bot-dropdown">
<div class="bot-dropdown-header">机器人</div>
<div
v-for="bot in store.bots"
v-for="bot in displayedBots"
:key="bot.id"
class="bot-card"
:class="{ active: bot.id === store.activeBotId }"
@@ -85,14 +98,14 @@
</div>
</div>
<RouterLink to="/settings" class="settings-btn">
<RouterLink v-if="!session.isGuest.value" to="/settings" class="settings-btn">
<Icon icon="mdi:cog" />
</RouterLink>
<div v-if="session.currentUser.value" class="nav-user">
<span class="nav-user-name">{{ session.currentUser.value.username }}</span>
<span class="nav-user-role" :class="`role-${session.currentUser.value.role}`">
{{ session.currentUser.value.role === 'admin' ? '管理员' : '成员' }}
{{ session.currentUser.value.role === 'admin' ? '管理员' : session.currentUser.value.role === 'guest' ? '游客' : '成员' }}
</span>
<button class="nav-user-logout" @click="onLogout" title="退出">
<Icon icon="mdi:logout" />
@@ -131,13 +144,27 @@ import { useSession } from '../composables/useSession.js';
const store = usePlayerStore();
const session = useSession();
const { canControlBot } = session;
const navRouter = useRouter();
async function onLogout() {
await session.logout();
navRouter.replace({ name: 'login' });
}
// Belt-and-suspenders: the backend already scopes store.bots to the allowed
// set for members, but filtering here keeps the UI correct if an admin (who
// sees all bots) is constrained, or if the list ever isn't pre-filtered.
const controllableBots = computed(() => store.bots.filter((b) => canControlBot(b.id)));
const activeBot = computed(() => store.activeBot);
// The bots shown in the selector are the INTERSECTION of the permission
// allow-list (controllableBots) and the dedicated-link scope: while scoped the
// selector is locked to the single scoped bot, otherwise the full controllable
// list is shown and switching is allowed.
const displayedBots = computed(() =>
store.isScoped
? controllableBots.value.filter((b) => b.id === store.scopedBotId)
: controllableBots.value,
);
const dropdownOpen = ref(false);
const selectorRef = ref<HTMLElement | null>(null);
const togglingBots = ref<Record<string, boolean>>({});
@@ -156,6 +183,14 @@ function selectBot(id: string) {
dropdownOpen.value = false;
}
// Leave dedicated-link mode. Clear scope BEFORE navigating so the router guard
// (which re-attaches ?bot from scopedBotId) sees a null scope and lets us out.
function exitScope() {
store.clearScope();
dropdownOpen.value = false;
navRouter.push('/');
}
function resolveBaseUrl(): string {
const base = publicBaseUrl.value;
if (base && /^https?:\/\//i.test(base)) return base.replace(/\/+$/, '');
@@ -370,6 +405,60 @@ onUnmounted(() => {
}
}
/* Scoped (dedicated-link) selector: locked, non-interactive label + exit */
.bot-selector.scoped {
display: flex;
align-items: center;
gap: 8px;
}
.bot-selector-btn.static {
cursor: default;
&:hover {
background: var(--hover-bg);
border-color: var(--border-color);
}
}
.scope-badge {
font-size: 10px;
font-weight: 700;
color: var(--color-primary);
padding: 2px 6px;
border-radius: 4px;
background: var(--color-primary-15);
flex-shrink: 0;
white-space: nowrap;
@media (max-width: 768px) {
display: none;
}
}
.scope-exit-btn {
padding: 8px 14px;
font-size: 12px;
font-weight: 600;
border-radius: var(--radius-md);
background: var(--hover-bg);
border: 1px solid var(--border-color);
color: var(--text-primary);
cursor: pointer;
white-space: nowrap;
transition: background var(--transition-fast), border-color var(--transition-fast);
&:hover {
background: var(--bg-card);
border-color: var(--color-primary);
}
@media (max-width: 768px) {
padding: 6px 10px;
font-size: 11px;
}
}
.bot-state-mini {
font-size: 14px;
&.playing { color: var(--color-online); }
@@ -680,4 +769,5 @@ onUnmounted(() => {
}
.role-admin { background: rgba(99, 145, 226, 0.18); color: #6391e2; }
.role-member { background: rgba(150, 150, 150, 0.18); color: var(--text-secondary); }
.role-guest { background: rgba(150, 150, 150, 0.18); color: var(--text-secondary); }
</style>
+49 -25
View File
@@ -3,9 +3,10 @@
<Queue :open="showQueue" @close="showQueue = false" />
<div class="player-bar frosted-glass">
<!-- Progress bar -->
<!-- Progress bar (read-only display; seek interaction gated on transport / canTransport) -->
<div
class="progress-bar-container"
:class="{ 'no-seek': !canTransport }"
ref="progressBarRef"
@click="onProgressClick"
@mousemove="onProgressHover"
@@ -37,32 +38,38 @@
<div class="player-center">
<span class="time-display time-current">{{ formatTime(currentElapsed) }}</span>
<button class="control-btn" @click="store.prev()">
<Icon icon="mdi:skip-previous" />
</button>
<button class="play-btn" @click="togglePlay">
<Icon :icon="store.isPlaying ? 'mdi:pause' : 'mdi:play'" />
</button>
<button class="control-btn" @click="store.next()">
<Icon icon="mdi:skip-next" />
</button>
<button class="control-btn mode-btn" @click="cycleMode" :title="modeLabel">
<Icon :icon="modeIcon" />
<span class="mode-label">{{ modeLabel }}</span>
</button>
<!-- Transport controls: per-button gating honoring guest flags -->
<template v-if="canControl || canTransport || canSkip || canModeCtl">
<button v-if="canControl" class="control-btn" @click="store.prev()">
<Icon icon="mdi:skip-previous" />
</button>
<button v-if="canTransport" class="play-btn" @click="togglePlay">
<Icon :icon="store.isPlaying ? 'mdi:pause' : 'mdi:play'" />
</button>
<button v-if="canSkip" class="control-btn" @click="store.next()">
<Icon icon="mdi:skip-next" />
</button>
<button v-if="canModeCtl" class="control-btn mode-btn" @click="cycleMode" :title="modeLabel">
<Icon :icon="modeIcon" />
<span class="mode-label">{{ modeLabel }}</span>
</button>
</template>
<span class="time-display time-total">{{ formatTime(currentSong?.duration ?? 0) }}</span>
</div>
<div class="player-right">
<Icon icon="mdi:volume-high" class="volume-icon" />
<input
type="range"
min="0"
max="100"
:value="activeBot?.volume ?? 75"
@change="onVolumeChange"
class="volume-slider"
/>
<!-- Volume gated on transport -->
<template v-if="canTransport">
<Icon icon="mdi:volume-high" class="volume-icon" />
<input
type="range"
min="0"
max="100"
:value="activeBot?.volume ?? 75"
@change="onVolumeChange"
class="volume-slider"
/>
</template>
<button class="control-btn" :class="{ active: showQueue }" @click="showQueue = !showQueue">
<Icon icon="mdi:playlist-music" />
</button>
@@ -79,6 +86,7 @@ import { computed, ref, onMounted, onUnmounted } from 'vue';
import { Icon } from '@iconify/vue';
import { useRoute, useRouter } from 'vue-router';
import { usePlayerStore } from '../stores/player.js';
import { useSession } from '../composables/useSession.js';
import CoverArt from './CoverArt.vue';
import Queue from './Queue.vue';
@@ -86,6 +94,12 @@ const route = useRoute();
const router = useRouter();
const showQueue = ref(false);
const { can, guestCan } = useSession();
const canControl = computed(() => can('player.control'));
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 store = usePlayerStore();
const activeBot = computed(() => store.activeBot);
const currentSong = computed(() => store.currentSong);
@@ -121,8 +135,9 @@ function formatTime(seconds: number): string {
}
function updateProgress() {
// Use store.elapsed which interpolates from server ground truth
currentElapsed.value = store.elapsed;
// liveElapsed() (an action, not the cached `elapsed` getter) re-interpolates
// from the server anchor on every frame so the clock ticks each second (#107).
currentElapsed.value = store.liveElapsed();
const duration = currentSong.value?.duration ?? 0;
progressPercent.value = duration > 0
@@ -133,6 +148,7 @@ function updateProgress() {
}
async function onProgressClick(e: MouseEvent) {
if (!canTransport.value) return; // seek gated on transport (canTransport)
const bar = progressBarRef.value;
if (!bar) return;
const rect = bar.getBoundingClientRect();
@@ -237,6 +253,14 @@ function cycleMode() {
.progress-bar-bg { height: 4px; }
.progress-bar-thumb { opacity: 1; transform: scale(1); }
}
&.no-seek {
cursor: default;
&:hover {
.progress-bar-bg { height: 2px; }
.progress-bar-thumb { opacity: 0; transform: scale(0); }
}
}
}
.progress-bar-bg {
+8 -5
View File
@@ -3,10 +3,10 @@
<div class="queue-header">
<h3 class="queue-title">播放队列</h3>
<span class="queue-count">{{ botQueue.length }} 首</span>
<button
v-if="botQueue.length > 0"
class="clear-btn"
@click="clearAndStop"
<button
v-if="botQueue.length > 0 && (can('player.control') || guestCan('removeClear'))"
class="clear-btn"
@click="clearAndStop"
title="清空队列并停止播放"
>
<Icon icon="mdi:stop-circle-outline" />
@@ -33,7 +33,7 @@
<div class="queue-song-name">{{ song.name }}</div>
<div class="queue-song-artist">{{ song.artist }}</div>
</div>
<button class="remove-btn" @click="removeSong(i)" title="移除">
<button v-if="can('player.queue') || guestCan('removeClear')" class="remove-btn" @click="removeSong(i)" title="移除">
<Icon icon="mdi:close" />
</button>
</div>
@@ -46,6 +46,7 @@ import { watch, computed } from 'vue';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore } from '../stores/player.js';
import { useSession } from '../composables/useSession.js';
import CoverArt from './CoverArt.vue';
const props = defineProps<{
@@ -57,6 +58,7 @@ defineEmits<{
}>();
const store = usePlayerStore();
const { can, guestCan } = useSession();
const botQueue = computed(() => store.queue);
// Fetch queue when panel opens
@@ -65,6 +67,7 @@ watch(() => props.open, (isOpen) => {
});
async function playAtIndex(index: number) {
if (!can('player.control')) return;
await store.playAtIndex(index);
await store.fetchQueue();
}
+18 -6
View File
@@ -1,5 +1,5 @@
<template>
<div class="song-card" :class="{ active }" @dblclick="$emit('play')">
<div class="song-card" :class="{ active }" @dblclick="showPlay && $emit('play')">
<div class="song-index">{{ index }}</div>
<CoverArt :url="song.coverUrl" :size="36" :radius="6" />
<div class="song-info">
@@ -7,21 +7,21 @@
<span class="song-name">{{ song.name }}</span>
<span
class="platform-badge"
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : 'badge-netease'"
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : '网易云' }}</span>
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : song.platform === 'local' ? 'badge-local' : 'badge-netease'"
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : '网易云' }}</span>
</div>
<div class="song-artist">{{ song.artist }}</div>
</div>
<div class="song-album">{{ song.album }}</div>
<div class="song-duration">{{ formatDuration(song.duration) }}</div>
<div class="song-actions">
<button class="action-btn" @click.stop="$emit('play')" title="播放">
<button v-if="showPlay" class="action-btn" @click.stop="$emit('play')" title="播放">
<Icon icon="mdi:play" />
</button>
<button class="action-btn" @click.stop="$emit('playNext')" title="下一首播放">
<button v-if="showPlayNext" class="action-btn" @click.stop="$emit('playNext')" title="下一首播放">
<Icon icon="mdi:playlist-play" />
</button>
<button class="action-btn" @click.stop="$emit('add')" title="添加到队列">
<button v-if="showAdd" class="action-btn" @click.stop="$emit('add')" title="添加到队列">
<Icon icon="mdi:playlist-plus" />
</button>
</div>
@@ -29,9 +29,11 @@
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { Icon } from '@iconify/vue';
import CoverArt from './CoverArt.vue';
import { Song } from '../stores/player.js';
import { useSession } from '../composables/useSession.js';
defineProps<{
song: Song;
@@ -39,6 +41,11 @@ defineProps<{
active?: boolean;
}>();
const { can, guestCan } = useSession();
const showPlay = computed(() => can('player.control') || guestCan('playNow'));
const showPlayNext = computed(() => can('player.control') || guestCan('playNext'));
const showAdd = computed(() => can('player.queue') || guestCan('addToQueue'));
defineEmits<{
play: [];
playNext: [];
@@ -128,6 +135,11 @@ function formatDuration(seconds: number): string {
color: var(--brand-youtube);
}
.badge-local {
background: var(--color-primary-10);
color: var(--color-primary);
}
.song-artist {
font-size: 12px;
color: var(--text-secondary);
+43 -1
View File
@@ -3,11 +3,15 @@ import { ref, computed, readonly } from "vue";
interface User {
id: string;
username: string;
role: 'admin' | 'member';
role: 'admin' | 'member' | 'guest';
capabilities?: string[];
bots?: "all" | string[];
guest?: Record<string, boolean> | null;
}
const currentUser = ref<User | null>(null);
const needsSetup = ref<boolean | null>(null); // null = unknown / not fetched yet
const guestAllowed = ref(false);
const ready = ref(false);
let pollTimer: ReturnType<typeof setInterval> | null = null;
@@ -35,6 +39,7 @@ async function refreshNeedsSetup(): Promise<void> {
if (res.ok) {
const body = await res.json();
needsSetup.value = Boolean(body.needsSetup);
guestAllowed.value = Boolean(body.guestAllowed);
}
}
@@ -70,6 +75,18 @@ async function login(username: string, password: string): Promise<void> {
throw new Error(body.error ?? `login failed (${res.status})`);
}
currentUser.value = (await res.json()) as User;
// Login response omits capabilities/bots; fetch the authoritative ones from /me.
await refreshMe();
}
async function continueAsGuest(): Promise<void> {
const res = await fetch("/api/session/guest", { method: "POST", credentials: "same-origin" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `guest entry failed (${res.status})`);
}
currentUser.value = (await res.json()) as User;
await refreshMe(); // authoritative role + guest flags + bots
}
async function setup(username: string, password: string): Promise<void> {
@@ -85,6 +102,8 @@ async function setup(username: string, password: string): Promise<void> {
}
currentUser.value = (await res.json()) as User;
needsSetup.value = false;
// Setup response omits capabilities/bots; fetch the authoritative ones from /me.
await refreshMe();
}
async function logout(): Promise<void> {
@@ -93,16 +112,39 @@ async function logout(): Promise<void> {
currentUser.value = null;
}
function can(cap: string): boolean {
const u = currentUser.value;
return !!u && (u.role === "admin" || (u.capabilities ?? []).includes(cap));
}
function guestCan(flag: string): boolean {
const u = currentUser.value;
return !!u && u.role === "guest" && !!u.guest && u.guest[flag] === true;
}
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 function useSession() {
return {
currentUser: readonly(currentUser),
needsSetup: readonly(needsSetup),
guestAllowed: readonly(guestAllowed),
isAuthenticated: computed(() => currentUser.value !== null),
isAdmin: computed(() => currentUser.value?.role === 'admin'),
isGuest: computed(() => currentUser.value?.role === 'guest'),
ready: readonly(ready),
refresh,
login,
logout,
setup,
continueAsGuest,
can,
guestCan,
canControlBot,
};
}
+7 -1
View File
@@ -11,4 +11,10 @@ installApiClient();
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount('#app');
// Wait for the initial navigation (and the beforeEach guard that reads ?bot)
// to fully resolve before mounting, so the reactive route query is populated
// when App.onMounted runs and the dedicated-bot scope locks the right bot.
// .catch keeps parity with the old unconditional mount: if the initial
// navigation errors (e.g. a transient network failure in the auth guard),
// still render the shell rather than leaving a blank page.
router.isReady().catch(() => {}).then(() => app.mount('#app'));
+23
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useSession } from '../composables/useSession.js';
import { usePlayerStore } from '../stores/player.js';
const router = createRouter({
history: createWebHistory(),
@@ -56,6 +57,28 @@ router.beforeEach(async (to) => {
if (!session.isAuthenticated.value) {
return { name: 'login', query: { next: to.fullPath } };
}
// Guests may never reach settings/setup, even by typing the URL.
const GUEST_BLOCKED = new Set(['settings', 'setup']);
if (session.isGuest.value && GUEST_BLOCKED.has(to.name as string)) {
return { name: 'home' };
}
// Navigation is allowed to proceed to `to` past here (auth/setup redirects above take precedence).
// Sync + preserve the dedicated-link scope carried by ?bot.
const store = usePlayerStore();
const qBot = typeof to.query.bot === 'string' && to.query.bot ? to.query.bot : null;
if (qBot) {
// URL carries a scope — set tentatively; App.vue's applyScopeFromQuery (after fetchBots) validates/clears it.
store.scopedBotId = qBot;
return true;
}
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 { path: to.path, query: { ...to.query, bot: store.scopedBotId }, hash: to.hash };
}
}
return true;
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { interpolateElapsed, type TimingState } from "./player.js";
afterEach(() => {
vi.restoreAllMocks();
});
function timing(partial: Partial<TimingState>): TimingState {
return { serverElapsed: 0, serverSyncTime: 0, wasPlaying: false, ...partial };
}
describe("interpolateElapsed", () => {
it("returns serverElapsed before playback has a sync anchor", () => {
expect(interpolateElapsed(timing({ serverElapsed: 12, wasPlaying: false }), false, Infinity)).toBe(12);
// wasPlaying but no sync time yet
expect(interpolateElapsed(timing({ serverElapsed: 5, wasPlaying: true, serverSyncTime: 0 }), false, Infinity)).toBe(5);
});
it("advances with wall-clock time while playing (regression: must not be frozen)", () => {
const spy = vi.spyOn(Date, "now");
const t = timing({ serverElapsed: 30, serverSyncTime: 10_000, wasPlaying: true });
spy.mockReturnValue(10_000);
expect(interpolateElapsed(t, false, Infinity)).toBeCloseTo(30, 5);
spy.mockReturnValue(11_000); // +1s
expect(interpolateElapsed(t, false, Infinity)).toBeCloseTo(31, 5);
spy.mockReturnValue(13_500); // +3.5s — distinct from the 1s reading
expect(interpolateElapsed(t, false, Infinity)).toBeCloseTo(33.5, 5);
});
it("freezes at serverElapsed while paused", () => {
vi.spyOn(Date, "now").mockReturnValue(99_000);
const t = timing({ serverElapsed: 42, serverSyncTime: 10_000, wasPlaying: true });
expect(interpolateElapsed(t, true, Infinity)).toBe(42);
});
it("clamps to maxDuration", () => {
vi.spyOn(Date, "now").mockReturnValue(1_000_000);
const t = timing({ serverElapsed: 100, serverSyncTime: 1_000, wasPlaying: true });
expect(interpolateElapsed(t, false, 180)).toBe(180);
});
});
+193 -19
View File
@@ -1,5 +1,7 @@
import { defineStore } from 'pinia';
import axios from 'axios';
import { resolveScopedBot } from './scope.js';
import { useSession } from '../composables/useSession.js';
export interface Song {
id: string;
@@ -8,7 +10,7 @@ export interface Song {
album: string;
duration: number;
coverUrl: string;
platform: 'netease' | 'qq' | 'bilibili' | 'youtube';
platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local';
}
export type Source = 'netease' | 'qq';
@@ -34,7 +36,18 @@ export interface PlaylistItem {
platform: string;
}
interface TimingState {
export interface FavoritePlaylist {
id: number;
userId: string;
platform: string;
playlistId: string;
name: string;
coverUrl: string;
songCount: number;
createdAt: string;
}
export interface TimingState {
serverElapsed: number;
serverSyncTime: number;
wasPlaying: boolean;
@@ -46,10 +59,40 @@ function defaultTiming(): TimingState {
return { serverElapsed: 0, serverSyncTime: 0, wasPlaying: false };
}
/**
* Interpolate the live elapsed seconds from the last server anchor.
*
* This is a PURE function (its only time source is `Date.now()`), deliberately
* kept OUT of the Pinia getter so it can be called fresh every animation frame.
* The `elapsed` getter is a Vue `computed` and caches its result until a
* REACTIVE dependency changes — but `Date.now()` is not reactive, so a getter
* only re-runs on a WebSocket push / server poll (every few seconds). Reading
* the getter from a requestAnimationFrame loop therefore returns a frozen value
* and the clock appears to jump ~3s at a time (issue #107). Per-frame consumers
* must call this helper (via the `liveElapsed` action) instead.
*/
export function interpolateElapsed(
timing: TimingState,
isPaused: boolean,
maxDuration: number,
): number {
// No live anchor yet, or paused: report the frozen server position.
if (!timing.wasPlaying || timing.serverSyncTime === 0 || isPaused) {
return Math.min(timing.serverElapsed, maxDuration);
}
return Math.min(
timing.serverElapsed + (Date.now() - timing.serverSyncTime) / 1000,
maxDuration,
);
}
export const usePlayerStore = defineStore('player', {
state: () => ({
bots: [] as BotStatus[],
activeBotId: null as string | null,
/** When set, the UI is locked to a single bot (dedicated link, from ?bot).
* Source of truth is the URL — never persisted to localStorage. */
scopedBotId: null as string | null,
/** Per-bot queues keyed by botId */
queues: {} as Record<string, Song[]>,
/** Per-bot timing state keyed by botId */
@@ -64,6 +107,9 @@ export const usePlayerStore = defineStore('player', {
authStatus: { netease: false, qq: false },
lastFetchTime: 0,
// Favorited playlists (fetched from server, isolated per WebUI user)
favoritedPlaylists: [] as FavoritePlaylist[],
// Transient notification for surfacing failures (e.g., "song not playable")
// to a global Toast. Bumped `id` triggers re-render of the same message.
notification: null as { id: number; message: string; type: 'error' | 'info' } | null,
@@ -73,6 +119,10 @@ export const usePlayerStore = defineStore('player', {
activeBot(): BotStatus | null {
return this.bots.find((b) => b.id === this.activeBotId) ?? this.bots[0] ?? null;
},
/** True when the UI is locked to a single bot via a dedicated link. */
isScoped(): boolean {
return this.scopedBotId !== null;
},
currentSong(): Song | null {
return this.activeBot?.currentSong ?? null;
},
@@ -88,15 +138,19 @@ export const usePlayerStore = defineStore('player', {
if (!botId) return [];
return this.queues[botId] ?? [];
},
/** Interpolated elapsed for the active bot */
/**
* Interpolated elapsed for the active bot. NOTE: as a Pinia getter this is
* a Vue `computed` and is CACHED — it only re-runs when a reactive
* dependency changes, so it does NOT tick every second on its own. Use it
* for one-off reactive reads; per-frame consumers (progress bar, lyrics)
* must call the `liveElapsed` action so the clock advances smoothly (#107).
*/
elapsed(): number {
const botId = this.activeBotId ?? this.bots[0]?.id;
if (!botId || !this.activeBot?.currentSong) return 0;
const timing = this.timings[botId] ?? defaultTiming();
const maxDuration = this.activeBot.currentSong.duration || Infinity;
if (!timing.wasPlaying || timing.serverSyncTime === 0) return Math.min(timing.serverElapsed, maxDuration);
if (this.isPaused) return Math.min(timing.serverElapsed, maxDuration);
return Math.min(timing.serverElapsed + (Date.now() - timing.serverSyncTime) / 1000, maxDuration);
return interpolateElapsed(timing, this.isPaused, maxDuration);
},
/** Sources that are currently logged in. Order: netease before qq. */
availableSources(): Source[] {
@@ -108,6 +162,21 @@ export const usePlayerStore = defineStore('player', {
},
actions: {
/**
* Live elapsed seconds for the active bot, recomputed on every call. Unlike
* the `elapsed` getter (a cached computed), this is an action, so it is NOT
* memoised — call it from requestAnimationFrame / interval loops so the
* progress bar and lyrics advance every frame instead of jumping on each
* server push (#107).
*/
liveElapsed(): number {
const botId = this.activeBotId ?? this.bots[0]?.id;
if (!botId || !this.activeBot?.currentSong) return 0;
const timing = this.timings[botId] ?? defaultTiming();
const maxDuration = this.activeBot.currentSong.duration || Infinity;
return interpolateElapsed(timing, this.isPaused, maxDuration);
},
_getTiming(botId: string): TimingState {
if (!this.timings[botId]) {
this.timings[botId] = defaultTiming();
@@ -125,6 +194,8 @@ export const usePlayerStore = defineStore('player', {
},
setActiveBotId(id: string) {
// While scoped to a dedicated link, switching bots is blocked.
if (this.scopedBotId !== null && id !== this.scopedBotId) return;
this.activeBotId = id;
// Fetch queue for newly active bot if we don't have it yet
if (!this.queues[id]) {
@@ -132,6 +203,32 @@ export const usePlayerStore = defineStore('player', {
}
},
/** Lock the UI to a single bot (dedicated link). Sets scope first so the
* setActiveBotId guard does not block the switch to the scoped bot. */
setScope(id: string) {
this.scopedBotId = id;
this.activeBotId = id;
// Lazily fetch this bot's queue, mirroring setActiveBotId.
if (!this.queues[id]) {
this.fetchQueue();
}
},
clearScope() {
this.scopedBotId = null;
},
/** Reconcile the scope with the desired id from the URL (?bot). A stale or
* forbidden id resolves to null and clears the scope rather than locking. */
applyScopeFromQuery(requestedId: string | null) {
const r = resolveScopedBot(requestedId, this.bots.map((b) => b.id));
if (r) {
this.setScope(r);
} else if (requestedId) {
this.clearScope();
}
},
updateBotStatus(botId: string, status: BotStatus) {
const prev = this.bots.find((b) => b.id === botId);
const prevSongId = prev?.currentSong?.id;
@@ -166,6 +263,11 @@ export const usePlayerStore = defineStore('player', {
this.bots = this.bots.filter((b) => b.id !== botId);
delete this.queues[botId];
delete this.timings[botId];
// If the bot we were locked to is gone, drop the scope so the UI does not
// stay 'locked' onto a phantom (activeBot would silently fall back to bots[0]).
if (this.scopedBotId === botId) {
this.clearScope();
}
},
setQueue(botId: string, queue: Song[]) {
@@ -279,7 +381,10 @@ export const usePlayerStore = defineStore('player', {
async playSong(song: Song) {
if (!this.activeBotId) return;
const res = await axios.post(`/api/player/${this.activeBotId}/play-song`, { song });
// Guests use the non-destructive "play now" (insert-next + skip) so they
// can't wipe everyone else's queue; members/admins keep the normal behavior.
const endpoint = useSession().isGuest.value ? 'play-now-song' : 'play-song';
const res = await axios.post(`/api/player/${this.activeBotId}/${endpoint}`, { song });
if (res.data?.ok === false && res.data?.message) {
this.notify(res.data.message, 'error');
}
@@ -314,29 +419,40 @@ export const usePlayerStore = defineStore('player', {
async playPlaylist(playlistId: string, platform = 'netease') {
if (!this.activeBotId) return;
const res = await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform });
if (res.data?.message) {
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
try {
const res = await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform });
if (res.data?.message) {
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
}
this._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction();
} catch (e: any) {
// A 403 here means a guest lacks the "play entire collection" permission
// (issue #103) — surface it instead of failing silently.
this.notify(e?.response?.status === 403 ? '没有权限播放整个歌单' : '播放歌单失败', 'error');
}
this._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction();
},
async playAlbum(albumId: string, platform = 'netease') {
if (!this.activeBotId) return;
const res = await axios.post(`/api/player/${this.activeBotId}/play-album`, { albumId, platform });
if (res.data?.message) {
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
try {
const res = await axios.post(`/api/player/${this.activeBotId}/play-album`, { albumId, platform });
if (res.data?.message) {
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
}
this._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction();
} catch (e: any) {
this.notify(e?.response?.status === 403 ? '没有权限播放整个专辑' : '播放专辑失败', 'error');
}
this._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction();
},
async pause() {
if (!this.activeBotId) return;
// Freeze elapsed at current interpolated value
// Freeze elapsed at the current LIVE interpolated value. Using the cached
// `elapsed` getter here could snapshot a value up to a few seconds stale.
this._setTiming(this.activeBotId, {
serverElapsed: this.elapsed,
serverElapsed: this.liveElapsed(),
wasPlaying: false,
});
await axios.post(`/api/player/${this.activeBotId}/pause`);
@@ -397,6 +513,60 @@ export const usePlayerStore = defineStore('player', {
if (bot) bot.playMode = mode;
},
async startFm(platform: Source = 'netease') {
if (!this.activeBotId) return;
const res = await axios.post(`/api/player/${this.activeBotId}/fm`, { platform });
if (res.data?.message) {
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
}
this._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction();
this.fetchQueue();
},
async fetchFavorites() {
try {
const res = await axios.get('/api/favorites');
this.favoritedPlaylists = res.data.favorites ?? [];
} catch {
// not critical
}
},
async addFavorite(playlist: { platform: string; playlistId: string; name: string; coverUrl: string; songCount: number }) {
try {
await axios.post('/api/favorites', playlist);
await this.fetchFavorites();
this.notify('已收藏', 'info');
} catch (err: any) {
// 409 = already favorited (e.g. stale heart); just resync so the UI converges.
if (err?.response?.status === 409) {
await this.fetchFavorites();
return;
}
this.notify('收藏失败', 'error');
}
},
async removeFavorite(id: number) {
try {
await axios.delete(`/api/favorites/${id}`);
await this.fetchFavorites();
this.notify('已取消收藏', 'info');
} catch (err: any) {
// 404 = already gone; resync. Otherwise report failure.
if (err?.response?.status === 404) {
await this.fetchFavorites();
return;
}
this.notify('取消收藏失败', 'error');
}
},
isFavorited(playlistId: string, platform: string): boolean {
return this.favoritedPlaylists.some((f) => f.playlistId === playlistId && f.platform === platform);
},
async fetchHomeData() {
// Always check auth status first — if it changed since the cached
// fetch (e.g., user logged in/out as a different account), the
@@ -414,6 +584,10 @@ export const usePlayerStore = defineStore('player', {
this.authStatus.netease = newAuth.netease;
this.authStatus.qq = newAuth.qq;
// Favorites are user-local and cheap; always refresh them, even on a
// home-data cache hit, so hearts stay correct across tabs/sessions.
this.fetchFavorites();
// Cache hit only if auth is unchanged AND within TTL.
if (
!authChanged &&
+16
View File
@@ -0,0 +1,16 @@
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();
});
});
+10
View File
@@ -0,0 +1,10 @@
/** 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;
}
+2 -2
View File
@@ -22,8 +22,8 @@ onMounted(async () => {
}
const bot = store.bots.find((b) => b.id === botId);
if (bot) {
store.setActiveBotId(botId);
router.replace('/');
store.setScope(botId);
router.replace({ path: '/', query: { bot: botId } });
} else {
notFound.value = true;
}
+41 -16
View File
@@ -21,7 +21,7 @@
<!-- 私人FM -->
<section class="section">
<h2 class="section-title">私人FM</h2>
<div class="fm-card hover-scale" @click="playFm">
<div class="fm-card hover-scale" @click="playFm('netease')">
<div class="fm-icon-wrapper">
<Icon icon="mdi:radio" class="fm-icon" />
</div>
@@ -31,6 +31,16 @@
</div>
<Icon icon="mdi:play-circle" class="fm-play-icon" />
</div>
<div v-if="store.authStatus.qq" class="fm-card hover-scale" @click="playFm('qq')">
<div class="fm-icon-wrapper qq">
<Icon icon="mdi:radar" class="fm-icon" />
</div>
<div class="fm-info">
<div class="fm-title">QQ音乐雷达</div>
<div class="fm-desc">猜你喜欢 / 雷达推荐歌曲流</div>
</div>
<Icon icon="mdi:play-circle" class="fm-play-icon" />
</div>
</section>
<!-- 每日推荐 -->
@@ -72,6 +82,27 @@
</div>
</section>
<!-- 我的收藏 -->
<section class="section" v-if="store.favoritedPlaylists.length > 0">
<h2 class="section-title">
<Icon icon="mdi:heart" style="color: var(--color-primary)" />
我的收藏
<span class="section-count">{{ store.favoritedPlaylists.length }}</span>
</h2>
<div class="playlist-grid">
<RouterLink
v-for="fav in store.favoritedPlaylists"
:key="fav.id"
:to="`/playlist/${fav.playlistId}?platform=${fav.platform}`"
class="playlist-card hover-scale"
>
<CoverArt :url="fav.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="playlist-name">{{ fav.name }}</div>
<div class="playlist-count">{{ fav.songCount }} 首</div>
</RouterLink>
</div>
</section>
<!-- 我的歌单 -->
<section class="section" v-if="userAvailable.length > 0">
<h2 class="section-title">
@@ -125,9 +156,9 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { RouterLink } from 'vue-router';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore, type Song, type Source } from '../stores/player.js';
import { usePlayerStore, type Source } from '../stores/player.js';
import { loadTabSource, saveTabSource } from '../stores/sourceTabs.js';
import CoverArt from '../components/CoverArt.vue';
import SourceTabs from '../components/SourceTabs.vue';
@@ -180,19 +211,8 @@ const visibleUserPlaylists = computed(() =>
: currentUserPlaylists.value.slice(0, USER_PLAYLIST_LIMIT)
);
async function playFm() {
try {
const res = await axios.get('/api/music/personal/fm');
const songs: Song[] = res.data.songs;
if (songs.length > 0) {
await store.play(songs[0].name, songs[0].platform);
for (let i = 1; i < songs.length; i++) {
await store.addToQueue(songs[i].name, songs[i].platform);
}
}
} catch {
// Ignore
}
async function playFm(platform: Source) {
await store.startFm(platform);
}
onMounted(() => {
@@ -318,6 +338,7 @@ onMounted(() => {
border-radius: var(--radius-lg);
cursor: pointer;
transition: background var(--transition-fast);
margin-bottom: 12px;
&:hover {
background: var(--hover-bg);
@@ -333,6 +354,10 @@ onMounted(() => {
align-items: center;
justify-content: center;
flex-shrink: 0;
&.qq {
background: linear-gradient(135deg, var(--brand-qq), #17a2b8);
}
}
.fm-icon {
+22
View File
@@ -2,6 +2,27 @@
<div class="library-page">
<h1 class="page-title">音乐库</h1>
<!-- 我的收藏 -->
<section class="section" v-if="store.favoritedPlaylists.length > 0">
<h2 class="section-title">
<Icon icon="mdi:heart" style="color: var(--color-primary)" />
我的收藏
<span class="section-count">{{ store.favoritedPlaylists.length }}</span>
</h2>
<div class="playlist-grid">
<RouterLink
v-for="fav in store.favoritedPlaylists"
:key="fav.id"
:to="`/playlist/${fav.playlistId}?platform=${fav.platform}`"
class="playlist-card hover-scale"
>
<CoverArt :url="fav.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="playlist-name">{{ fav.name }}</div>
<div class="playlist-count">{{ fav.songCount }} 首</div>
</RouterLink>
</div>
</section>
<!-- 我的歌单 -->
<section class="section" v-if="userAvailable.length > 0">
<h2 class="section-title">
@@ -51,6 +72,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { RouterLink } from 'vue-router';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore, type Song, type Source } from '../stores/player.js';
+32
View File
@@ -13,6 +13,15 @@
<p v-if="error" class="auth-error">{{ error }}</p>
<button type="submit" :disabled="loading">{{ loading ? '登录中…' : '登录' }}</button>
</form>
<button
v-if="session.guestAllowed.value"
type="button"
class="guest-btn"
:disabled="loading"
@click="enterAsGuest"
>
以游客身份进入
</button>
</div>
</template>
@@ -43,12 +52,28 @@ async function submit() {
loading.value = false;
}
}
async function enterAsGuest() {
error.value = '';
loading.value = true;
try {
await session.continueAsGuest();
const rawNext = typeof route.query.next === 'string' ? route.query.next : '/';
const next = rawNext.startsWith('/') && !rawNext.startsWith('//') ? rawNext : '/';
router.replace(next);
} catch (e) {
error.value = (e as Error).message;
} finally {
loading.value = false;
}
}
</script>
<style scoped lang="scss">
.auth-page {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--bg-primary);
@@ -75,4 +100,11 @@ async function submit() {
}
.auth-card button:disabled { opacity: 0.6; cursor: progress; }
.auth-error { color: #e26a6a; font-size: 13px; margin: 0; }
.guest-btn {
width: 360px; height: 38px; margin-top: 4px; border-radius: var(--radius-sm);
background: transparent; color: var(--text-secondary);
border: 1px solid var(--border-color); cursor: pointer;
}
.guest-btn:hover { color: var(--text-primary); }
.guest-btn:disabled { opacity: 0.6; cursor: progress; }
</style>
+3 -1
View File
@@ -136,7 +136,9 @@ function scrollToActiveLine(idx: number) {
function syncLyrics() {
if (!store.isPlaying || lines.value.length === 0) return;
const elapsed = store.elapsed;
// liveElapsed() (action) is recomputed now; the cached `elapsed` getter only
// refreshed on server pushes, leaving highlights ~half a line behind (#107).
const elapsed = store.liveElapsed();
const idx = findActiveLine(elapsed);
// Only update when the active line actually changes
if (idx !== activeLine.value && idx >= 0) {
+87 -5
View File
@@ -16,10 +16,21 @@
<div class="playlist-stats">
{{ songs.length }} 首歌曲
</div>
<button class="play-all-btn" @click="playAll">
<Icon icon="mdi:play" />
播放全部
</button>
<div class="playlist-actions">
<button v-if="canPlayAll" class="play-all-btn" @click="playAll">
<Icon icon="mdi:play" />
播放全部
</button>
<button
v-if="kind === 'playlist'"
class="fav-btn"
:class="{ favorited }"
@click="toggleFavorite"
>
<Icon :icon="favorited ? 'mdi:heart' : 'mdi:heart-outline'" />
{{ favorited ? '已收藏' : '收藏' }}
</button>
</div>
</div>
</div>
@@ -43,16 +54,22 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore } from '../stores/player.js';
import { useSession } from '../composables/useSession.js';
import CoverArt from '../components/CoverArt.vue';
import SongCard from '../components/SongCard.vue';
const store = usePlayerStore();
const route = useRoute();
const { can, guestCan } = useSession();
// "Play all" loads + plays the whole collection (clears the queue). Members
// need player.control; guests need the playCollection flag (issue #103).
const canPlayAll = computed(() => can('player.control') || guestCan('playCollection'));
import { Song } from '../stores/player.js';
@@ -69,6 +86,7 @@ const kind = (route.meta.kind as string) ?? 'playlist'; // 'playlist' | 'album'
const playlist = ref<PlaylistDetail | null>(null);
const songs = ref<Song[]>([]);
const loading = ref(true);
const favorited = ref(false);
async function playAll() {
const id = route.params.id as string;
@@ -126,8 +144,35 @@ onMounted(async () => {
}
}
songs.value = songList;
// Check favorite status after songs are resolved
if (kind === 'playlist') {
favorited.value = store.isFavorited(id, platform);
}
loading.value = false;
});
async function toggleFavorite() {
const id = route.params.id as string;
const platform = (route.query.platform as string) || 'netease';
if (favorited.value) {
const fav = store.favoritedPlaylists.find((f) => f.playlistId === id && f.platform === platform);
if (fav) {
await store.removeFavorite(fav.id);
favorited.value = false;
}
} else {
await store.addFavorite({
platform,
playlistId: id,
name: playlist.value?.name ?? '未知歌单',
coverUrl: playlist.value?.coverUrl ?? '',
songCount: songs.value.length,
});
favorited.value = true;
}
}
</script>
<style lang="scss" scoped>
@@ -193,6 +238,43 @@ onMounted(async () => {
&:active { transform: scale(0.96); }
}
.playlist-actions {
display: flex;
align-items: center;
gap: 12px;
}
.fav-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
font-size: 14px;
font-weight: 500;
transition: all var(--transition-fast);
cursor: pointer;
&:hover {
color: var(--color-primary);
border-color: var(--color-primary);
background: var(--color-primary-8);
}
&.favorited {
color: #e74c3c;
border-color: #e74c3c;
background: rgba(231, 76, 60, 0.08);
&:hover {
background: rgba(231, 76, 60, 0.15);
}
}
}
.song-list {
display: flex;
flex-direction: column;
+292 -7
View File
@@ -16,6 +16,41 @@
autofocus
/>
</div>
<div
v-if="localAudioEnabled"
class="local-upload"
:class="{ dragging: isDragging, uploading }"
@dragenter.prevent="isDragging = true"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
>
<Icon icon="mdi:tray-arrow-up" class="upload-icon" />
<div class="upload-copy">
<div class="upload-title">拖拽本地音频到这里上传</div>
<div class="upload-subtitle">支持 mp3、flac、wav、m4a、ogg、opus、aac、webm 等格式,上传后可直接播放或加入队列</div>
</div>
<button class="upload-btn" :disabled="uploading" @click="fileInput?.click()">
{{ uploading ? '上传中...' : '选择音频' }}
</button>
<input
ref="fileInput"
class="file-input"
type="file"
multiple
accept="audio/*,.mp3,.flac,.wav,.m4a,.aac,.ogg,.opus,.webm,.wma,.alac,.aiff,.ape"
@change="handleFileSelect"
/>
</div>
<div v-else class="local-upload disabled">
<Icon icon="mdi:music-off" class="upload-icon" />
<div class="upload-copy">
<div class="upload-title">本地音频播放已关闭</div>
<div class="upload-subtitle">管理员可在「设置 → 行为设置 → 本地音频播放」中开启。</div>
</div>
</div>
<div v-if="uploadMessage" class="upload-message" :class="uploadMessageType">{{ uploadMessage }}</div>
</div>
<div v-if="loading" class="loading">搜索中...</div>
@@ -37,6 +72,12 @@
:class="{ active: selectedSource === 'bilibili' }"
@click="selectedSource = 'bilibili'"
>B站</button>
<button
v-if="hasLocalSongs"
class="source-btn"
:class="{ active: selectedSource === 'local' }"
@click="selectedSource = 'local'"
>本地</button>
</div>
<div class="tab-bar">
@@ -48,7 +89,7 @@
单曲<span class="tab-count">{{ filteredSongs.length }}</span>
</button>
<button
v-if="selectedSource !== 'bilibili'"
v-if="selectedSource !== 'bilibili' && selectedSource !== 'local'"
class="tab"
:class="{ active: activeTab === 'albums' }"
@click="activeTab = 'albums'"
@@ -56,7 +97,7 @@
专辑<span class="tab-count">{{ filteredAlbums.length }}</span>
</button>
<button
v-if="selectedSource !== 'bilibili'"
v-if="selectedSource !== 'bilibili' && selectedSource !== 'local'"
class="tab"
:class="{ active: activeTab === 'playlists' }"
@click="activeTab = 'playlists'"
@@ -92,6 +133,13 @@
class="card hover-scale"
>
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<button
class="fav-badge"
:class="{ favorited: isFav(pl) }"
@click.prevent.stop="toggleFavPlaylist(pl)"
>
<Icon :icon="isFav(pl) ? 'mdi:heart' : 'mdi:heart-outline'" />
</button>
<div class="card-name">
{{ pl.name }}
<span class="platform-badge" :class="badgeClass(pl.platform)">{{ badgeLabel(pl.platform) }}</span>
@@ -134,17 +182,19 @@ const router = useRouter();
const SOURCE_STORAGE_KEY = 'search-source';
function loadSource(): 'netease' | 'qq' | 'bilibili' {
type SearchSource = 'netease' | 'qq' | 'bilibili' | 'local';
function loadSource(): SearchSource {
try {
const stored = localStorage.getItem(SOURCE_STORAGE_KEY);
if (stored === 'netease' || stored === 'qq' || stored === 'bilibili') return stored;
if (stored === 'netease' || stored === 'qq' || stored === 'bilibili' || stored === 'local') return stored;
} catch { /* localStorage blocked */ }
return 'netease';
}
const query = ref((route.query.q as string) || '');
const activeTab = ref<'songs' | 'albums' | 'playlists'>('songs');
const selectedSource = ref<'netease' | 'qq' | 'bilibili'>(loadSource());
const selectedSource = ref<SearchSource>(loadSource());
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
@@ -154,6 +204,12 @@ const allAlbums = ref<Album[]>([]);
const allPlaylists = ref<Playlist[]>([]);
const loading = ref(false);
const searched = ref(false);
const uploading = ref(false);
const isDragging = ref(false);
const uploadMessage = ref('');
const uploadMessageType = ref<'info' | 'error'>('info');
const fileInput = ref<HTMLInputElement | null>(null);
const localAudioEnabled = ref(true);
const filteredSongs = computed(() =>
allSongs.value.filter((s) => s.platform === selectedSource.value)
@@ -167,18 +223,39 @@ const filteredPlaylists = computed(() =>
allPlaylists.value.filter((p) => p.platform === selectedSource.value)
);
const hasLocalSongs = computed(() => localAudioEnabled.value && allSongs.value.some((s) => s.platform === 'local'));
// Persist source preference
watch(selectedSource, (src) => {
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
});
// B站 has no albums/playlists — force songs tab when switching to B站
// B站 / 本地上传没有专辑和歌单页签,切换时强制回到单曲。
watch(selectedSource, (src) => {
if (src === 'bilibili' && activeTab.value !== 'songs') {
if ((src === 'bilibili' || src === 'local') && activeTab.value !== 'songs') {
activeTab.value = 'songs';
}
});
function isFav(pl: { id: string; platform: string }): boolean {
return store.isFavorited(pl.id, pl.platform);
}
async function toggleFavPlaylist(pl: { id: string; platform: string; name: string; coverUrl: string; songCount?: number }) {
if (isFav(pl)) {
const fav = store.favoritedPlaylists.find((f) => f.playlistId === pl.id && f.platform === pl.platform);
if (fav) await store.removeFavorite(fav.id);
} else {
await store.addFavorite({
platform: pl.platform,
playlistId: pl.id,
name: pl.name,
coverUrl: pl.coverUrl,
songCount: pl.songCount ?? 0,
});
}
}
async function doSearch() {
if (!query.value.trim()) return;
loading.value = true;
@@ -197,10 +274,83 @@ async function doSearch() {
}
}
function isAudioFile(file: File): boolean {
return file.type.startsWith('audio/') || /\.(mp3|flac|wav|m4a|aac|ogg|opus|webm|wma|alac|aiff|ape)$/i.test(file.name);
}
async function uploadLocalFiles(fileList: File[]) {
if (!localAudioEnabled.value) {
uploadMessageType.value = 'error';
uploadMessage.value = '本地音频播放已关闭';
return;
}
const files = fileList.filter(isAudioFile);
if (files.length === 0) {
uploadMessageType.value = 'error';
uploadMessage.value = '没有找到可上传的音频文件';
return;
}
uploading.value = true;
uploadMessageType.value = 'info';
uploadMessage.value = `正在上传 ${files.length} 个文件...`;
const uploaded: Song[] = [];
const failed: string[] = [];
for (const file of files) {
try {
const res = await axios.post('/api/music/local/upload', file, {
headers: {
'Content-Type': file.type || 'application/octet-stream',
'X-Filename': encodeURIComponent(file.name),
},
maxBodyLength: Infinity,
});
if (res.data?.song) uploaded.push(res.data.song as Song);
} catch (err: any) {
failed.push(`${file.name}: ${err?.response?.data?.error || '上传失败'}`);
}
}
if (uploaded.length > 0) {
const uploadedKeys = new Set(uploaded.map((s) => `${s.platform}-${s.id}`));
allSongs.value = [
...uploaded,
...allSongs.value.filter((s) => !uploadedKeys.has(`${s.platform}-${s.id}`)),
];
selectedSource.value = 'local';
activeTab.value = 'songs';
searched.value = true;
uploadMessageType.value = failed.length ? 'error' : 'info';
uploadMessage.value = failed.length
? `已上传 ${uploaded.length} 个,失败 ${failed.length} 个:${failed[0]}`
: `已上传 ${uploaded.length} 个本地音频`;
} else {
uploadMessageType.value = 'error';
uploadMessage.value = failed[0] || '上传失败';
}
uploading.value = false;
}
function handleDrop(event: DragEvent) {
isDragging.value = false;
const files = Array.from(event.dataTransfer?.files ?? []);
uploadLocalFiles(files);
}
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
uploadLocalFiles(Array.from(input.files ?? []));
input.value = '';
}
function badgeLabel(platform: string): string {
if (platform === 'qq') return 'QQ';
if (platform === 'bilibili') return 'B站';
if (platform === 'youtube') return 'YouTube';
if (platform === 'local') return '本地';
return '网易云';
}
@@ -208,10 +358,24 @@ function badgeClass(platform: string): string {
if (platform === 'qq') return 'badge-qq';
if (platform === 'bilibili') return 'badge-bilibili';
if (platform === 'youtube') return 'badge-youtube';
if (platform === 'local') return 'badge-local';
return 'badge-netease';
}
async function loadLocalAudioSetting() {
try {
const res = await axios.get('/api/bot/settings');
localAudioEnabled.value = res.data.localAudioEnabled ?? true;
if (!localAudioEnabled.value && selectedSource.value === 'local') {
selectedSource.value = 'netease';
}
} catch {
// Guests may not be allowed to read settings; backend still enforces the switch.
}
}
onMounted(() => {
loadLocalAudioSetting();
if (query.value) doSearch();
});
</script>
@@ -232,6 +396,86 @@ onMounted(() => {
margin-bottom: 24px;
}
.local-upload {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
border: 1px dashed var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-card);
transition: border-color var(--transition-fast), background var(--transition-fast), transform var(--transition-fast);
&.dragging {
border-color: var(--color-primary);
background: var(--color-primary-10);
transform: translateY(-1px);
}
&.uploading {
opacity: 0.8;
}
}
.upload-icon {
flex-shrink: 0;
font-size: 28px;
color: var(--color-primary);
}
.upload-copy {
flex: 1;
min-width: 0;
}
.upload-title {
font-size: 14px;
font-weight: var(--fw-semi);
color: var(--text-primary);
}
.upload-subtitle {
margin-top: 3px;
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.4;
}
.upload-btn {
flex-shrink: 0;
padding: 8px 14px;
border-radius: var(--radius-sm);
background: var(--color-primary);
color: #fff;
font-size: 13px;
font-weight: var(--fw-semi);
cursor: pointer;
&:disabled {
cursor: not-allowed;
opacity: 0.65;
}
}
.file-input {
display: none;
}
.local-upload.disabled {
opacity: 0.65;
border-style: solid;
}
.upload-message {
margin-top: 8px;
font-size: 12px;
color: var(--text-secondary);
&.error {
color: #e74c3c;
}
}
.search-input-wrap {
display: flex;
align-items: center;
@@ -356,6 +600,7 @@ onMounted(() => {
gap: 16px 28px;
}
.card {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
@@ -394,4 +639,44 @@ onMounted(() => {
background: var(--brand-youtube-12);
color: var(--brand-youtube);
}
.badge-local {
background: var(--color-primary-10);
color: var(--color-primary);
}
.fav-badge {
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
color: rgba(255, 255, 255, 0.7);
font-size: 16px;
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-fast), color var(--transition-fast);
z-index: 2;
.card:hover & {
opacity: 1;
}
&.favorited {
color: #e74c3c;
opacity: 1;
}
&:hover {
color: #e74c3c;
background: rgba(0, 0, 0, 0.7);
}
}
</style>
+426 -44
View File
@@ -50,8 +50,8 @@
<p v-if="ownPwSuccess" class="user-success">{{ ownPwSuccess }}</p>
</section>
<!-- Bot Management -->
<section class="settings-section">
<!-- Bot Management (create/edit/delete/start-stop) requires bot.manage -->
<section v-if="can('bot.manage')" class="settings-section">
<h2 class="section-title">机器人管理</h2>
<div class="bot-list">
<div v-for="bot in store.bots" :key="bot.id" class="bot-item">
@@ -98,8 +98,12 @@
</div>
</div>
<div class="form-group">
<label>默认频道(可选)</label>
<input v-model="editForm.defaultChannel" class="input" placeholder="音乐频道" />
<label>默认频道名称(可选)</label>
<input v-model="editForm.defaultChannel" :disabled="!!editForm.channelId" class="input" :class="{ disabled: !!editForm.channelId }" placeholder="音乐频道" />
</div>
<div class="form-group">
<label>默认频道ID(可选)</label>
<input v-model="editForm.channelId" :disabled="!!editForm.defaultChannel" class="input" :class="{ disabled: !!editForm.defaultChannel }" placeholder="如 12" />
</div>
<div class="form-group">
<label>频道密码(可选)</label>
@@ -142,8 +146,12 @@
<input v-model="newBotNickname" class="input" placeholder="MusicBot" />
</div>
<div class="form-group">
<label>默认频道(可选)</label>
<input v-model="newBotChannel" class="input" placeholder="音乐频道" />
<label>默认频道名称(可选)</label>
<input v-model="newBotChannel" :disabled="!!newBotChannelId" class="input" :class="{ disabled: !!newBotChannelId }" placeholder="音乐频道" />
</div>
<div class="form-group">
<label>默认频道ID(可选)</label>
<input v-model="newBotChannelId" :disabled="!!newBotChannel" class="input" :class="{ disabled: !!newBotChannel }" placeholder="如 12" />
</div>
<div class="form-group">
<label>服务器密码(可选)</label>
@@ -157,8 +165,8 @@
</div>
</section>
<!-- Music Account - QR Code Login -->
<section class="settings-section">
<!-- Music Account - QR Code Login (platform auth) requires platform.auth -->
<section v-if="can('platform.auth')" class="settings-section">
<h2 class="section-title">音乐账号</h2>
<!-- NetEase -->
@@ -371,8 +379,8 @@
</div>
</section>
<!-- Audio Quality -->
<section class="settings-section">
<!-- Audio Quality requires quality -->
<section v-if="can('quality')" class="settings-section">
<h2 class="section-title">音质设置</h2>
<div class="setting-row">
<div class="setting-label">
@@ -408,16 +416,16 @@
</div>
</div>
</section>
<!-- Idle Timeout -->
<section class="settings-section">
<section v-if="can('bot.manage')" class="settings-section">
<h2 class="section-title">行为设置</h2>
<div class="setting-row">
<div class="setting-label">
<Icon icon="mdi:timer-off-outline" class="setting-icon" />
<div>
<div>闲置自动退出</div>
<div style="font-size:12px; opacity:0.6; margin-top:2px">频道无人时,机器人自动断开的等待时间(0 = 不退出)</div>
<div style="font-size:12px; opacity:0.6; margin-top:2px">服务器上没有其他人时,机器人自动断开的等待时间(0 = 不退出)</div>
</div>
</div>
<div class="prefix-input-wrap">
@@ -433,10 +441,101 @@
<button class="btn-primary" @click="saveIdleTimeout">保存</button>
</div>
</div>
<label class="profile-toggle behavior-toggle">
<div class="profile-toggle-text">
<div class="profile-toggle-label">无人时自动暂停播放</div>
<div class="profile-toggle-hint">服务器上只剩机器人自己时自动暂停,有人连接后自动继续播放(受协议限制,占用判断以整个服务器为准,无法精确到单个频道)</div>
</div>
<input
v-model="autoPauseOnEmpty"
type="checkbox"
class="profile-toggle-switch"
@change="saveAutoPause"
/>
</label>
<label class="profile-toggle behavior-toggle">
<div class="profile-toggle-text">
<div class="profile-toggle-label">本地音频播放</div>
<div class="profile-toggle-hint">开启后允许在搜索页拖拽/选择本地音频上传并播放;关闭后会拒绝新的本地上传和本地歌曲播放请求。</div>
</div>
<input
v-model="localAudioEnabled"
type="checkbox"
class="profile-toggle-switch"
@change="saveLocalAudioEnabled"
/>
</label>
</section>
<!-- Guest Mode (admin only) -->
<section v-if="session.isAdmin.value" class="settings-section">
<h2 class="section-title">游客模式</h2>
<p class="profile-section-hint">开启后,访客无需登录即可进入并点歌(默认关闭)。游客永远无法查看或修改设置。下面逐项决定游客可用的能力。</p>
<label class="profile-toggle behavior-toggle">
<div class="profile-toggle-text">
<div class="profile-toggle-label">允许游客访问</div>
<div class="profile-toggle-hint">登录页会出现「以游客身份进入」。关闭后所有游客会话立即失效。</div>
</div>
<input v-model="guestMode.enabled" type="checkbox" class="profile-toggle-switch" />
</label>
<div v-if="guestMode.enabled" class="perm-group">
<div class="perm-group-title">游客权限</div>
<div class="perm-checks">
<label v-for="f in GUEST_FLAGS" :key="f.token" class="perm-check">
<input type="checkbox" v-model="guestMode.permissions[f.token]" />
{{ f.label }}
</label>
</div>
</div>
<div v-if="guestMode.enabled" class="perm-group">
<div class="perm-group-title">可控制的机器人</div>
<label class="perm-check">
<input type="checkbox" v-model="guestMode.botsAll" />
全部机器人
</label>
<div v-if="!guestMode.botsAll" class="perm-checks perm-bots">
<label v-for="bot in store.bots" :key="bot.id" class="perm-check">
<input
type="checkbox"
:checked="guestMode.selectedBotIds.includes(bot.id)"
@change="toggleGuestBot(bot.id, ($event.target as HTMLInputElement).checked)"
/>
{{ bot.name }}
</label>
<span v-if="store.bots.length === 0" class="user-empty">还没有机器人。</span>
</div>
</div>
<div class="form-actions">
<button class="btn-primary" :disabled="guestSaving" @click="saveGuestMode">
{{ guestSaving ? '保存中…' : '保存' }}
</button>
</div>
</section>
<!-- Command Permissions (admin only) -->
<section v-if="session.isAdmin.value" class="settings-section">
<h2 class="section-title">命令权限</h2>
<p class="profile-section-hint">
限制谁能在 TeamSpeak 聊天里运行管理类命令(stop / clear / remove / move / vol / mode)。
填写允许的服务器组 ID(逗号分隔)。留空 = 不限制,所有人可用。如何查看服务器组 ID 见 README。
</p>
<div class="setting-row">
<div class="prefix-input-wrap">
<input v-model="adminGroupsText" class="input input-sm" placeholder="如 6, 8" />
<button class="btn-primary" :disabled="adminGroupsSaving" @click="saveAdminGroups">
{{ adminGroupsSaving ? '保存中…' : '保存' }}
</button>
</div>
</div>
</section>
<!-- Bot Profile (TeamSpeak Behavior) -->
<section class="settings-section">
<section v-if="can('bot.manage')" class="settings-section">
<h2 class="section-title">机器人 Profile(TeamSpeak 行为)</h2>
<p class="profile-section-hint">控制 bot 在 TeamSpeak 上自动同步歌曲信息的方式。⚠️ 标记的项会触发频道里所有人的提示音。</p>
<div v-if="store.bots.length === 0" class="empty-hint">还没有机器人,先在上面创建一个。</div>
@@ -492,38 +591,96 @@
<section v-if="session.isAdmin.value" class="settings-section">
<h2 class="section-title">用户管理</h2>
<div class="user-list">
<div v-for="u in userList" :key="u.id" class="user-item">
<div class="user-info">
<div class="user-name">
{{ u.username }}
<span class="user-role-badge" :class="`role-${u.role}`">
{{ u.role === 'admin' ? '管理员' : '成员' }}
</span>
<span v-if="session.currentUser.value && u.id === session.currentUser.value.id" class="user-self-badge">本人</span>
<div v-for="u in userList" :key="u.id" class="user-row-wrap">
<div class="user-item">
<div class="user-info">
<div class="user-name">
{{ u.username }}
<span class="user-role-badge" :class="`role-${u.role}`">
{{ u.role === 'admin' ? '管理员' : '成员' }}
</span>
<span v-if="session.currentUser.value && u.id === session.currentUser.value.id" class="user-self-badge">本人</span>
</div>
<div class="user-created">创建于 {{ formatDate(u.createdAt) }}</div>
</div>
<div class="user-actions">
<span v-if="u.role === 'admin'" class="perm-admin-label">全部权限(管理员)</span>
<button
v-else
class="btn-sm"
:class="{ 'btn-primary': permEditingId === u.id }"
@click="onTogglePermEditor(u)"
>
<Icon icon="mdi:shield-key" /> 权限
</button>
<button class="btn-sm" @click="openResetPassword(u)">
<Icon icon="mdi:lock-reset" /> 重置密码
</button>
<button
class="btn-sm"
:disabled="changingRoleId === u.id || isLastAdmin(u)"
:title="isLastAdmin(u) ? '不能降级唯一的管理员' : (u.role === 'admin' ? '降级为成员' : '提升为管理员')"
@click="onToggleRole(u)"
>
<Icon icon="mdi:account-cog" />
{{ u.role === 'admin' ? '降为成员' : '提升管理员' }}
</button>
<button
class="btn-sm btn-delete"
:disabled="!!(session.currentUser.value && u.id === session.currentUser.value.id) || isLastAdmin(u)"
:title="session.currentUser.value && u.id === session.currentUser.value.id ? '不能删除自己' : (isLastAdmin(u) ? '不能删除唯一的管理员' : '')"
@click="onDeleteUser(u)"
>
<Icon icon="mdi:delete" />
</button>
</div>
<div class="user-created">创建于 {{ formatDate(u.createdAt) }}</div>
</div>
<div class="user-actions">
<button class="btn-sm" @click="openResetPassword(u)">
<Icon icon="mdi:lock-reset" /> 重置密码
</button>
<button
class="btn-sm"
:disabled="changingRoleId === u.id || isLastAdmin(u)"
:title="isLastAdmin(u) ? '不能降级唯一的管理员' : (u.role === 'admin' ? '降级为成员' : '提升为管理员')"
@click="onToggleRole(u)"
>
<Icon icon="mdi:account-cog" />
{{ u.role === 'admin' ? '降为成员' : '提升管理员' }}
</button>
<button
class="btn-sm btn-delete"
:disabled="!!(session.currentUser.value && u.id === session.currentUser.value.id) || isLastAdmin(u)"
:title="session.currentUser.value && u.id === session.currentUser.value.id ? '不能删除自己' : (isLastAdmin(u) ? '不能删除唯一的管理员' : '')"
@click="onDeleteUser(u)"
>
<Icon icon="mdi:delete" />
</button>
<!-- Inline permission editor (members only) -->
<div v-if="permEditingId === u.id" class="perm-editor">
<div v-if="permLoading" class="user-empty">加载权限中…</div>
<template v-else>
<div class="perm-group">
<div class="perm-group-title">能力</div>
<div class="perm-checks">
<label v-for="cap in CAPABILITIES" :key="cap.token" class="perm-check">
<input
type="checkbox"
:checked="permDraft.capabilities.includes(cap.token)"
@change="toggleCapability(cap.token, ($event.target as HTMLInputElement).checked)"
/>
{{ cap.label }}
</label>
</div>
</div>
<div class="perm-group">
<div class="perm-group-title">机器人</div>
<label class="perm-check">
<input type="checkbox" v-model="permDraft.botsAll" />
全部机器人
</label>
<div v-if="!permDraft.botsAll" class="perm-checks perm-bots">
<label v-for="bot in store.bots" :key="bot.id" class="perm-check">
<input
type="checkbox"
:checked="permDraft.selectedBotIds.includes(bot.id)"
@change="toggleBotSelection(bot.id, ($event.target as HTMLInputElement).checked)"
/>
{{ bot.name }}
</label>
<span v-if="store.bots.length === 0" class="user-empty">还没有机器人。</span>
</div>
</div>
<p v-if="permError" class="user-error">{{ permError }}</p>
<div class="form-actions">
<button class="btn-sm" @click="permEditingId = null">取消</button>
<button class="btn-sm btn-primary" :disabled="permSaving" @click="onSavePermissions(u)">
{{ permSaving ? '保存中…' : '保存' }}
</button>
</div>
</template>
</div>
</div>
<div v-if="userList.length === 0 && !userLoadError" class="user-empty">加载中…</div>
@@ -615,6 +772,7 @@ const newBotServer = ref('');
const newBotPort = ref(9987);
const newBotNickname = ref('MusicBot');
const newBotChannel = ref('');
const newBotChannelId = ref('');
const newBotServerPassword = ref('');
const newBotAvatar = ref<string | null>(null);
@@ -626,6 +784,7 @@ const editForm = reactive({
serverPort: 9987,
nickname: '',
defaultChannel: '',
channelId: '',
channelPassword: '',
serverPassword: '',
});
@@ -783,6 +942,7 @@ async function createBot() {
serverPort: newBotPort.value || 9987,
nickname: newBotNickname.value || newBotName.value,
defaultChannel: newBotChannel.value || undefined,
channelId: newBotChannelId.value || undefined,
serverPassword: newBotServerPassword.value || undefined,
autoStart: false,
});
@@ -798,6 +958,7 @@ async function createBot() {
newBotPort.value = 9987;
newBotNickname.value = 'MusicBot';
newBotChannel.value = '';
newBotChannelId.value = '';
newBotServerPassword.value = '';
newBotAvatar.value = null;
await store.fetchBots();
@@ -831,6 +992,7 @@ async function openEditBot(bot: any) {
editForm.serverPort = res.data.serverPort ?? 9987;
editForm.nickname = res.data.nickname ?? '';
editForm.defaultChannel = res.data.defaultChannel ?? '';
editForm.channelId = res.data.channelId ?? '';
editForm.channelPassword = res.data.channelPassword ?? '';
editForm.serverPassword = res.data.serverPassword ?? '';
} catch {
@@ -839,6 +1001,7 @@ async function openEditBot(bot: any) {
editForm.serverPort = 9987;
editForm.nickname = bot.name;
editForm.defaultChannel = '';
editForm.channelId = '';
editForm.channelPassword = '';
editForm.serverPassword = '';
}
@@ -885,11 +1048,18 @@ async function savePrefix() {
// Idle timeout
const idleTimeout = ref(0);
// Defaults OFF to match the backend default (config.ts getDefaultConfig).
const autoPauseOnEmpty = ref(false);
const localAudioEnabled = ref(true);
async function loadIdleTimeout() {
try {
const res = await axios.get('/api/bot/settings');
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false;
localAudioEnabled.value = res.data.localAudioEnabled ?? true;
applyGuestModeFromServer(res.data.guestMode);
applyAdminGroupsFromServer(res.data.adminGroups);
} catch { /* ignore */ }
}
@@ -899,6 +1069,99 @@ async function saveIdleTimeout() {
} catch { /* ignore */ }
}
async function saveAutoPause() {
try {
await axios.post('/api/bot/settings', { autoPauseOnEmpty: autoPauseOnEmpty.value });
} catch { /* ignore */ }
}
async function saveLocalAudioEnabled() {
try {
const res = await axios.post('/api/bot/settings', { localAudioEnabled: localAudioEnabled.value });
localAudioEnabled.value = res.data.localAudioEnabled ?? localAudioEnabled.value;
} catch { /* ignore */ }
}
// --- Guest mode (admin only) ---
const GUEST_FLAGS: { token: string; label: string }[] = [
{ token: 'addToQueue', label: '添加到队列末尾' },
{ token: 'playNext', label: '添加到下一首' },
{ token: 'playNow', label: '立即播放(不清空队列)' },
{ token: 'skip', label: '跳过当前歌曲' },
{ token: 'transport', label: '暂停/继续/进度/音量' },
{ token: 'removeClear', label: '移除/清空队列' },
{ token: 'playMode', label: '切换播放模式 / FM' },
{ token: 'playCollection', label: '播放整个歌单/专辑' },
];
const guestMode = reactive<{ enabled: boolean; botsAll: boolean; selectedBotIds: string[]; permissions: Record<string, boolean> }>({
enabled: false,
botsAll: true,
selectedBotIds: [],
permissions: { addToQueue: true, playNext: false, playNow: false, skip: false, transport: false, removeClear: false, playMode: false, playCollection: false },
});
const guestSaving = ref(false);
function applyGuestModeFromServer(gm: any) {
if (!gm) return;
guestMode.enabled = Boolean(gm.enabled);
guestMode.botsAll = gm.bots === 'all';
guestMode.selectedBotIds = Array.isArray(gm.bots) ? [...gm.bots] : [];
for (const f of GUEST_FLAGS) {
guestMode.permissions[f.token] = Boolean(gm.permissions?.[f.token]);
}
}
function toggleGuestBot(id: string, checked: boolean) {
const has = guestMode.selectedBotIds.includes(id);
if (checked && !has) guestMode.selectedBotIds.push(id);
else if (!checked && has) guestMode.selectedBotIds = guestMode.selectedBotIds.filter((b) => b !== id);
}
async function saveGuestMode() {
guestSaving.value = true;
try {
const res = await axios.post('/api/bot/settings', {
guestMode: {
enabled: guestMode.enabled,
bots: guestMode.botsAll ? 'all' : [...guestMode.selectedBotIds],
permissions: { ...guestMode.permissions },
},
});
applyGuestModeFromServer(res.data?.guestMode);
} catch { /* ignore */ } finally {
guestSaving.value = false;
}
}
// --- Command permissions (admin only) ---
const adminGroupsText = ref('');
const adminGroupsSaving = ref(false);
function applyAdminGroupsFromServer(groups: unknown) {
if (Array.isArray(groups)) {
adminGroupsText.value = groups.filter((g) => typeof g === 'number').join(', ');
}
}
function parseAdminGroups(text: string): number[] {
return text
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0)
.map((s) => Number(s))
.filter((n) => Number.isInteger(n) && n >= 0);
}
async function saveAdminGroups() {
adminGroupsSaving.value = true;
try {
const res = await axios.post('/api/bot/settings', { adminGroups: parseAdminGroups(adminGroupsText.value) });
applyAdminGroupsFromServer(res.data?.adminGroups);
} catch { /* ignore */ } finally {
adminGroupsSaving.value = false;
}
}
// --- Bot Profile config ---
interface ProfileConfig {
avatarEnabled: boolean;
@@ -967,6 +1230,7 @@ async function updateProfile(botId: string, key: keyof ProfileConfig, value: boo
// --- User Management ---
const session = useSession();
const { can } = session;
// --- Own password change (available to all authenticated users) ---
const ownPw = reactive({ old: '', new: '', confirm: '' });
@@ -1132,6 +1396,91 @@ async function onConfirmReset() {
}
}
// --- Per-user permission editor (members only) ---
const CAPABILITIES: { token: string; label: string }[] = [
{ token: 'player.control', label: '播放控制' },
{ token: 'player.queue', label: '队列管理' },
{ token: 'bot.manage', label: '机器人管理' },
{ token: 'platform.auth', label: '平台登录凭据' },
{ token: 'quality', label: '音质设置' },
];
const permEditingId = ref<string | null>(null);
const permLoading = ref(false);
const permSaving = ref(false);
const permError = ref('');
const permDraft = reactive<{ capabilities: string[]; botsAll: boolean; selectedBotIds: string[] }>({
capabilities: [],
botsAll: true,
selectedBotIds: [],
});
async function onTogglePermEditor(u: UserListEntry) {
if (permEditingId.value === u.id) {
permEditingId.value = null;
return;
}
permEditingId.value = u.id;
permError.value = '';
permLoading.value = true;
permDraft.capabilities = [];
permDraft.botsAll = true;
permDraft.selectedBotIds = [];
try {
const res = await fetch(`/api/users/${u.id}/permissions`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
permDraft.capabilities = Array.isArray(body.capabilities) ? [...body.capabilities] : [];
if (body.bots === 'all') {
permDraft.botsAll = true;
permDraft.selectedBotIds = [];
} else {
permDraft.botsAll = false;
permDraft.selectedBotIds = Array.isArray(body.bots) ? [...body.bots] : [];
}
} catch (e) {
permError.value = (e as Error).message;
} finally {
permLoading.value = false;
}
}
function toggleCapability(token: string, checked: boolean) {
const has = permDraft.capabilities.includes(token);
if (checked && !has) permDraft.capabilities.push(token);
else if (!checked && has) permDraft.capabilities = permDraft.capabilities.filter((t) => t !== token);
}
function toggleBotSelection(id: string, checked: boolean) {
const has = permDraft.selectedBotIds.includes(id);
if (checked && !has) permDraft.selectedBotIds.push(id);
else if (!checked && has) permDraft.selectedBotIds = permDraft.selectedBotIds.filter((b) => b !== id);
}
async function onSavePermissions(u: UserListEntry) {
permSaving.value = true;
permError.value = '';
try {
const res = await fetch(`/api/users/${u.id}/permissions`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
capabilities: [...permDraft.capabilities],
bots: permDraft.botsAll ? 'all' : [...permDraft.selectedBotIds],
}),
});
if (!res.ok && res.status !== 204) {
const b = await res.json().catch(() => ({}));
throw new Error(b.error ?? `HTTP ${res.status}`);
}
permEditingId.value = null;
} catch (e) {
permError.value = (e as Error).message;
} finally {
permSaving.value = false;
}
}
function formatDate(ms: number): string {
const d = new Date(ms);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
@@ -1182,6 +1531,7 @@ function describeAction(e: AuditEntry): string {
case 'user.password_reset': return `重置 ${target} 的密码`;
case 'user.password_changed': return `修改自己的密码`;
case 'user.role_changed': return `变更 ${target} 的角色`;
case 'user.permissions_changed': return `权限变更 → ${target}`;
default: return `${e.action} → ${target}`;
}
}
@@ -1469,6 +1819,10 @@ onUnmounted(() => {
font-size: 13px;
outline: none;
&:focus { border-color: var(--color-primary); }
&.disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.input-sm { max-width: 80px; }
@@ -1794,6 +2148,12 @@ onUnmounted(() => {
align-items: flex-start;
}
// Standalone toggle inside 行为设置 (not part of a bordered list)
.behavior-toggle {
border-bottom: none;
padding-top: 4px;
}
@media (max-width: 768px) {
.profile-bot-header {
padding: 14px 12px;
@@ -1904,6 +2264,28 @@ onUnmounted(() => {
.role-member { background: rgba(150, 150, 150, 0.18); color: var(--text-secondary); }
.user-role-select { flex: 0 0 110px; }
.user-row-wrap { display: flex; flex-direction: column; gap: 0; }
.perm-admin-label { font-size: 12px; color: var(--text-secondary); align-self: center; }
.perm-editor {
margin-top: -2px;
padding: 12px;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 12px;
}
.perm-group { display: flex; flex-direction: column; gap: 8px; }
.perm-group-title { font-size: 13px; font-weight: 500; color: var(--text-primary); }
.perm-checks { display: flex; flex-wrap: wrap; gap: 8px 16px; }
.perm-bots { padding-left: 16px; }
.perm-check {
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--text-secondary); cursor: pointer;
}
.perm-check input { cursor: pointer; }
// --- Account section (own password change) ---
.account-info-card {
display: flex; flex-direction: column; gap: 8px;
+12 -2
View File
@@ -46,8 +46,12 @@
<input v-model="nickname" placeholder="MusicBot" class="input" />
</div>
<div class="form-group">
<label>默认频道 (可选)</label>
<input v-model="defaultChannel" placeholder="音乐频道" class="input" />
<label>默认频道名称(可选)</label>
<input v-model="defaultChannel" :disabled="!!channelId" placeholder="音乐频道" class="input" :class="{ disabled: !!channelId }" />
</div>
<div class="form-group">
<label>默认频道ID(可选)</label>
<input v-model="channelId" :disabled="!!defaultChannel" placeholder="如 12" class="input" :class="{ disabled: !!defaultChannel }" />
</div>
<div class="btn-row">
<button class="btn-secondary" @click="currentStep = 0">上一步</button>
@@ -87,6 +91,7 @@ const serverAddress = ref('');
const serverPort = ref(9987);
const nickname = ref('MusicBot');
const defaultChannel = ref('');
const channelId = ref('');
async function createBotAndNext() {
try {
@@ -96,6 +101,7 @@ async function createBotAndNext() {
serverPort: serverPort.value,
nickname: nickname.value,
defaultChannel: defaultChannel.value,
channelId: channelId.value || undefined,
autoStart: true,
});
currentStep.value = 2;
@@ -193,6 +199,10 @@ async function createBotAndNext() {
&:focus {
border-color: var(--color-primary);
}
&.disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.btn-primary {