Compare commits

..

166 Commits

Author SHA1 Message Date
MythEclipse
8584030633 feat: automate .env deployment via GitHub Secrets and local file sync 2026-05-17 21:24:54 +07:00
MythEclipse
f35710db3f feat: add Docker configuration and automated deployment workflows for VPS orchestration 2026-05-17 21:23:50 +07:00
MythEclipse
0c7930bd01 chore: update streaming default quality settings to 1080p60 at 8mbps with ultrafast preset 2026-05-17 20:47:41 +07:00
MythEclipse
e22e620bae feat: add streaming demuxer tests and implement dual-stream output piping in streaming service 2026-05-17 20:44:13 +07:00
MythEclipse
eda32720c8 a 2026-05-17 19:39:46 +07:00
MythEclipse
c0f66c78a3 chore: update subproject commit to indicate dirty state 2026-05-17 18:24:17 +07:00
MythEclipse
b8a6f40b1b feat: enhance database initialization for test isolation and add transcoder metrics 2026-05-17 18:24:10 +07:00
MythEclipse
4931e6d1ca test: add unit tests for playTranscodedPreparedStream and transcoder functionality 2026-05-17 17:54:39 +07:00
MythEclipse
a3e6c4695a chore: remove test environment configuration file 2026-05-17 17:35:37 +07:00
MythEclipse
6de5342703 feat: refactor screen share controller to use Streamer for session management and simplify stream handling 2026-05-17 05:15:38 +07:00
MythEclipse
5a926dbd17 refactor: remove Discord-video-stream submodule and integrate streaming functionality 2026-05-17 05:10:46 +07:00
MythEclipse
7985efbef6 docs: add internal streamer replacement design 2026-05-17 05:00:04 +07:00
MythEclipse
71889ab689 chore: update Discord-video-stream subproject to latest commit 2026-05-17 04:52:20 +07:00
MythEclipse
518577d79d feat: enhance screen share controller with Streamer integration and voice channel management 2026-05-17 01:01:40 +07:00
MythEclipse
d04093ec6e feat: add admin authentication to media and voice routes for secure access 2026-05-17 00:39:29 +07:00
MythEclipse
05feb697f0 feat: implement admin authentication overlay and API integration for secure access to voice and media controls 2026-05-17 00:20:29 +07:00
MythEclipse
a5b5ccf5b0 refactor: enhance message ID handling in parseModerationResponse for precision loss and duplicates 2026-05-16 23:47:50 +07:00
MythEclipse
99ec528a03 refactor: remove unused getThreads function and related code from voice API and controller 2026-05-16 23:34:07 +07:00
MythEclipse
7dedac2094 refactor: clean up code structure and improve error handling in media controllers 2026-05-16 23:11:46 +07:00
MythEclipse
9b211f05cf refactor: change logger level from info to debug for voice activity detection 2026-05-16 21:56:14 +07:00
MythEclipse
4825dc6d4d feat: add mock-crc module for ESM compatibility 2026-05-16 21:52:13 +07:00
MythEclipse
9ad7d16a17 feat: expand activeTab options in SharedUIState to include messages, media, and review 2026-05-16 21:42:28 +07:00
MythEclipse
62d131cf14 feat: implement local audio streaming with controls in voice components 2026-05-16 21:08:39 +07:00
MythEclipse
82025a19b2 feat: migrate and redesign dashboard to modern React
- Full rewrite of legacy vanilla JS UI into React SPA
- Implement modern design system using Tailwind CSS and shadcn/ui primitives
- Create typed API modules and hooks for voice, media, and moderation
- Add new features: separated Music and Screen Share panels, Image Grid
- Implement unified WebSocket hook for real-time state and PCM audio
- Improve visualizer with smooth CSS transitions and live state sync
- Add __dirname polyfill for ES module compatibility
- Ensure responsive layout for mobile and desktop

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 19:17:34 +07:00
MythEclipse
3c7d722973 feat: separate music and screen share UI in media player
- Add mode selector buttons (Music / Screen Share)
- Create separate input panels for music and screen share
- Add screenSourceInput and screen share queue/skip/stop buttons
- Update queueMedia to include mode parameter (music/screen)
- Add queueScreen function for screen share mode
- Toggle between panels based on selected mode

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 18:22:09 +07:00
MythEclipse
70931576dc fix: add __dirname polyfill for ES modules
- Import fileURLToPath from node:url
- Define __dirname using import.meta.url for ES module compatibility
- Fixes ReferenceError when serving static files in production

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 18:18:39 +07:00
MythEclipse
8e1f5adaa4 feat: implement session-level recording with self/bot audio filtering
- Add RecordingSession tracking for guild-level audio mixing
- Filter out self user and bot users before subscribing to audio streams
- Register segments with session metadata for post-processing
- Finalize recording sessions on connection destroy or explicit stop
- Update test mocks to properly simulate stream chain and constructors
- Remove mock-crc import (no longer needed with current dependencies)
- Add 'type: module' to package.json for ES module support

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 18:15:18 +07:00
MythEclipse
2744e7035b feat: implement full session recording with muxing support
- Add session recording metadata and mux filter builder in src/recorder/sessionRecording.ts.
- Update SegmentMetadata to include recordingSessionId in src/types.ts and src/recorder/metadata.ts.
- Modify recorder lifecycle to track sessions, register segments, and finalize recordings on stop.
- Create tests for session recording functionality in tests/recorder/sessionRecording.test.ts and tests/recorder/metadata.test.ts.
- Document session recording design and implementation plan in docs/superpowers/specs/2026-05-16-session-full-recording-design.md and docs/superpowers/plans/2026-05-16-session-full-recording.md.
2026-05-16 17:59:17 +07:00
MythEclipse
8b33af8286 feat: prevent recording of audio from bot users 2026-05-16 15:52:10 +07:00
MythEclipse
d50ce8698f feat: implement media echo fix and YouTube screenshare design
- Introduced a new `ScreenShareController` to manage YouTube screenshare functionality.
- Updated `DiscordPlayer` to track ownership of audio streams, preventing conflicts between music playback and screenshare.
- Added error handling for various states including voice connection checks and media busy states.
- Created unit tests for `ScreenShareController` and `DiscordPlayer` ownership rules to ensure correct functionality.
- Added documentation for the new media echo fix and screenshare design.
2026-05-16 15:48:28 +07:00
MythEclipse
e32e092596 feat: enhance media handling and audio processing logic 2026-05-15 22:23:29 +07:00
MythEclipse
6ac4a5c11a feat: add installation script for yt-dlp and update package.json 2026-05-15 21:40:20 +07:00
MythEclipse
119258c2b0 feat: add additional built dependencies for media processing 2026-05-15 20:24:03 +07:00
MythEclipse
5abe5cc39f feat: update media input guidance
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 19:49:01 +07:00
MythEclipse
c954cc0406 feat: resolve youtube search and spotify media
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 19:46:43 +07:00
MythEclipse
95ea0cee75 feat: add play-dl search resolver
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 19:44:42 +07:00
MythEclipse
2e30a063d2 feat: add yt-dlp media helper
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 19:43:06 +07:00
MythEclipse
6aeabc690f feat: prepare media resolver source kinds
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 19:11:23 +07:00
MythEclipse
73901231c3 feat: update backlog sync logic to queue requests and adjust response structure 2026-05-15 18:53:12 +07:00
MythEclipse
61b07e4b01 feat: implement backlog sync cooldown mechanism and update related tests 2026-05-15 18:38:17 +07:00
MythEclipse
a97feb1e2a feat: implement split text and voice selection in configuration and UI
- Added a new implementation plan for separating text moderation and voice recording configurations.
- Introduced new configuration keys for text and voice guild/channel IDs with backward compatibility.
- Updated moderation capture and backlog sync to filter based on the new text-specific settings.
- Split shared UI state into distinct text and voice fields, ensuring backward compatibility.
- Enhanced the static dashboard to support separate selections for text and voice channels.
- Created a new media subsystem for audio playback, allowing users to queue, play, skip, and stop audio sources.
- Defined API routes for media control and integrated with existing voice functionalities.
2026-05-15 18:29:20 +07:00
MythEclipse
575302db57 fix: report backlog sync errors in dashboard 2026-05-15 18:10:30 +07:00
MythEclipse
76470a5129 fix: remove duplicate media dashboard handlers 2026-05-15 18:06:16 +07:00
MythEclipse
ff2792d403 style: format media music implementation 2026-05-15 18:04:39 +07:00
MythEclipse
192f83d31d feat: add dashboard media controls 2026-05-15 18:03:01 +07:00
MythEclipse
06b6db703c feat: wire media playback into webserver
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 17:52:16 +07:00
MythEclipse
94e497b7a6 feat: expose media playback routes
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 17:43:53 +07:00
MythEclipse
b00def2d4d fix: guard media controller playback transitions 2026-05-15 17:40:28 +07:00
MythEclipse
dbae042279 test: cover media controller conflicts and skip 2026-05-15 17:30:08 +07:00
MythEclipse
c509f48f95 feat: coordinate media playback state
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 17:27:27 +07:00
MythEclipse
1e0a00d82d fix: guard music playback process lifecycle 2026-05-15 17:23:36 +07:00
MythEclipse
9e07a0a1f3 feat: add ffmpeg music player
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 17:17:17 +07:00
MythEclipse
acb43b6dac fix: harden media source resolution 2026-05-15 17:11:26 +07:00
MythEclipse
93134a9793 feat: resolve media music sources
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 17:05:37 +07:00
MythEclipse
2194d4a8b6 fix: preserve failed media queue item 2026-05-15 17:01:51 +07:00
MythEclipse
3b6bf49160 feat: add media queue foundation 2026-05-15 16:56:50 +07:00
MythEclipse
d42d3f8def feat: add media queue foundation
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 16:55:16 +07:00
MythEclipse
ed438e6fc0 feat: split text and voice channel selection
Separate text moderation and voice recording guild/channel state so each workflow can persist and operate independently.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 15:58:38 +07:00
MythEclipse
6859eb3f50 docs: add Discord video stream vendor plan
Record the implementation plan for vendoring Discord-video-stream as a workspace dependency.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 09:29:50 +07:00
MythEclipse
5d015ea6e1 Merge branch 'worktree-selfbot-performance-optimization' 2026-05-15 09:01:47 +07:00
MythEclipse
12fdc713d9 feat: vendor Discord video stream workspace
Add Discord-video-stream as a vendored workspace dependency and wire it to use the local selfbot package for development.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 09:01:38 +07:00
MythEclipse
ff90f9e95c feat: add selfbot performance and feature optimization design document 2026-05-15 08:32:13 +07:00
MythEclipse
2c58e44c67 perf: tune selfbot runtime defaults
Apply low-memory client options and point the workspace vendor package at the optimized selfbot internals for more stable long-running moderation capture.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 08:26:43 +07:00
MythEclipse
bd03fe1817 fix: mark discord.js-selfbot-v13 subproject as dirty 2026-05-15 07:16:30 +07:00
MythEclipse
235c1120c2 feat: enhance moderation functionality with type improvements and global broadcaster integration 2026-05-15 07:13:37 +07:00
MythEclipse
930c399484 feat: enhance message capture and processing with backlog support 2026-05-15 06:52:20 +07:00
MythEclipse
958a6d7236 feat: implement vendor selfbot dependency modernization plan
- Update submodule `discord.js-selfbot-v13` to latest commit.
- Create implementation plan for modernizing the vendored dependency, focusing on toolchain replacement with Biome and runtime dependency auditing.
- Establish a design document outlining goals, scope, approach, toolchain design, runtime dependency design, and validation steps.
- Ensure public API compatibility and maintain existing functionality during modernization efforts.
2026-05-15 06:26:22 +07:00
MythEclipse
bbd65d369e feat: implement selfbot workspace submodule
- Add `discord.js-selfbot-v13` as a git submodule in `vendor/`
- Update `pnpm-workspace.yaml` to include the new submodule path
- Modify `.gitmodules` to track the submodule repository
- Change `discord.js-selfbot-v13` dependency in `package.json` to use `workspace:*`
- Create implementation and design documents for the submodule integration
2026-05-15 05:45:19 +07:00
MythEclipse
bbd3a88471 Merge branch 'worktree-library-modernization' 2026-05-15 04:34:41 +07:00
MythEclipse
ed10f45e8c fix: adapt retry logging to p-retry v8 2026-05-15 04:28:11 +07:00
MythEclipse
29fcde69e4 fix: adapt code to modernized libraries
- Migrate validation.ts from class-transformer/class-validator to Zod
- Apply Biome auto-fixes (import sorting, nodejs protocol)
- Fix duplicate type identifier in validation.ts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 04:25:06 +07:00
MythEclipse
0060d1ba95 chore: modernize dependency set
- Remove deprecated packages: class-transformer, class-validator, fluent-ffmpeg, @types/fluent-ffmpeg
- Update remaining packages to latest versions via pnpm update --latest
- @discordjs/voice: ^0.19.1 → ^0.19.2
- @snazzah/davey: ^0.1.10 → ^0.1.11
- libsodium-wrappers: ^0.8.2 → ^0.8.4
- p-retry: ^6.2.0 → ^8.0.0
- pino: ^9.4.0 → ^10.3.1
- sodium-native: ^4.3.2 → ^5.1.0
- @types/node: ^24.10.1 → ^25.8.0
- pino-pretty: ^10.3.1 → ^13.1.3
- tsx: ^4.20.6 → ^4.22.0
- vitest: latest (preserved)
- @biomejs/biome: latest (preserved)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 04:18:33 +07:00
MythEclipse
895b47890c refactor: invoke ffmpeg without deprecated wrapper 2026-05-14 23:42:11 +07:00
MythEclipse
e5aa398e5c refactor: use esm-safe database migrations
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 23:23:07 +07:00
MythEclipse
954faf6c5a refactor: validate user state with zod 2026-05-14 23:11:25 +07:00
MythEclipse
5010a4d1f1 docs: record dependency modernization audit 2026-05-14 23:01:18 +07:00
MythEclipse
203aa9a589 style: organize imports after dashboard rebuild 2026-05-14 21:19:43 +07:00
MythEclipse
196ca1b784 feat: serve React dashboard from public/app when available
Check for public/app/index.html before falling back to the old
public/index.html so the React moderation dashboard is served at
the root in production while preserving the legacy frontend during
the transition period.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 21:16:03 +07:00
MythEclipse
a812182218 fix: harden dashboard message state 2026-05-14 21:10:24 +07:00
MythEclipse
cb2cfc76f2 fix: import dashboard event type from websocket client 2026-05-14 21:03:38 +07:00
MythEclipse
ae02556f75 feat: add moderation review dashboard
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 21:01:57 +07:00
MythEclipse
fbf0f11db1 fix: align dashboard message api shape 2026-05-14 20:55:05 +07:00
MythEclipse
0a7965e1d4 fix: remove unused dashboard type import 2026-05-14 20:47:51 +07:00
MythEclipse
2d56012bf5 feat: add dashboard api clients 2026-05-14 20:46:21 +07:00
MythEclipse
c3f2d01e9b a 2026-05-14 20:27:35 +07:00
MythEclipse
298048aaaf fix: isolate frontend typescript config 2026-05-14 20:26:38 +07:00
MythEclipse
71a28085d2 feat: scaffold react dashboard 2026-05-14 20:24:41 +07:00
MythEclipse
da5e191b0f fix: make moderation index migration portable 2026-05-14 20:19:47 +07:00
MythEclipse
558d65342b feat: add moderation query indexes 2026-05-14 20:14:42 +07:00
MythEclipse
44368e646f fix: reanalyze edited messages 2026-05-14 20:10:16 +07:00
MythEclipse
dbfc6866f9 refactor: keep message capture on fast path 2026-05-14 20:03:02 +07:00
MythEclipse
d8aeefb739 fix: preserve ui state broadcasts in routes 2026-05-14 19:58:37 +07:00
MythEclipse
08cd515d8a fix: preserve ui state broadcasts in routes 2026-05-14 19:55:26 +07:00
MythEclipse
c81a499535 refactor: split api routes by concern 2026-05-14 19:46:47 +07:00
MythEclipse
3fb1fcb72c fix: remove unused analysis import 2026-05-14 19:41:18 +07:00
MythEclipse
243a18ecad fix: harden analysis queue scheduling 2026-05-14 19:39:25 +07:00
MythEclipse
f14e893cb7 feat: debounce ai analysis by conversation 2026-05-14 19:32:44 +07:00
MythEclipse
54cd4e0386 fix: type llm moderation test fixtures 2026-05-14 19:28:56 +07:00
MythEclipse
65ab5ecb32 fix: harden llm moderation parsing 2026-05-14 19:26:54 +07:00
MythEclipse
81253e4ffe feat: add strict llm moderation client 2026-05-14 19:21:15 +07:00
MythEclipse
ce35b335d0 test: cover conversation context edge cases 2026-05-14 19:16:46 +07:00
MythEclipse
2b4e2a7ab7 feat: add conversation context builder 2026-05-14 19:10:13 +07:00
MythEclipse
2d511e08db style: format message query changes 2026-05-14 19:06:17 +07:00
MythEclipse
13078e7c3c fix: resolve message query test diagnostics 2026-05-14 19:02:19 +07:00
MythEclipse
298fc968cf test: cover message query pagination 2026-05-14 19:01:01 +07:00
MythEclipse
7a8883f623 test: cover message query pagination 2026-05-14 19:00:03 +07:00
MythEclipse
0c54ac4ba8 test: cover message query pagination 2026-05-14 18:54:13 +07:00
MythEclipse
9f5f8a3090 feat: add cursor-based message queries 2026-05-14 18:48:02 +07:00
MythEclipse
8ab7aaa32d fix: harden moderation broadcaster 2026-05-14 18:44:47 +07:00
MythEclipse
01dc9b1836 feat: add typed moderation broadcaster
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 18:40:34 +07:00
MythEclipse
49d4bbf781 chore: remove obsolete migration documentation files 2026-05-14 16:40:43 +07:00
MythEclipse
4fbbc056bb docs: add postgresql setup completion documentation 2026-05-14 16:26:12 +07:00
MythEclipse
47ae7f8650 chore: remove temporary test files 2026-05-14 16:25:45 +07:00
MythEclipse
35269b5bef feat: configure postgresql as primary database with neon connection
- Updated .env to use PostgreSQL (neondb) instead of SQLite
- Updated drizzle.ts to support DATABASE_URL connection string
- Regenerated migrations for PostgreSQL syntax
- Bot successfully connects and operates with PostgreSQL
- All database operations working correctly
2026-05-14 16:25:39 +07:00
MythEclipse
c63a61460c docs: add comprehensive drizzle orm migration final summary 2026-05-14 16:21:35 +07:00
MythEclipse
9889d20edd feat: add programmatic migration runner for better PostgreSQL support 2026-05-14 16:21:02 +07:00
MythEclipse
b580430eb6 docs: add drizzle orm migration completion summary 2026-05-14 16:17:25 +07:00
MythEclipse
b9d0a06d01 fix: update drizzle config to read env vars directly for CLI compatibility 2026-05-14 16:14:48 +07:00
MythEclipse
b600dad011 fix: correct import ordering and update tests for drizzle-orm migration 2026-05-14 15:47:03 +07:00
MythEclipse
50d4517079 refactor: remove old database adapter files 2026-05-14 15:43:52 +07:00
MythEclipse
9ff0f0bede feat: update application initialization for drizzle 2026-05-14 15:43:16 +07:00
MythEclipse
1c4b0afbce refactor: migrate messageStore to drizzle-orm
- Replace all raw SQL queries in messageStore.ts with Drizzle ORM queries
- Remove DatabaseAdapter dependency from messageStore functions
- Update all function signatures to be async and remove db parameter
- Functions now use getDatabase() internally for database access
- Update all call sites in messageCapture.ts, attachmentUploader.ts, aiAnalyzer.ts, webserver.ts, and index.ts
- All functions remain backward compatible in behavior
- TypeScript typecheck passes with no errors
- All tests pass (11 passed)
2026-05-14 15:41:11 +07:00
MythEclipse
dfe3444018 refactor: migrate muxer-queue to drizzle-orm 2026-05-14 15:35:55 +07:00
MythEclipse
7e528a473b feat: create drizzle database client 2026-05-14 15:33:45 +07:00
MythEclipse
4e28cf9671 feat: add drizzle configuration and initial migrations 2026-05-14 15:33:10 +07:00
MythEclipse
52b36c963f feat: create drizzle schema definitions 2026-05-14 15:32:20 +07:00
MythEclipse
b833b6d978 feat: add drizzle-orm and drizzle-kit dependencies 2026-05-14 15:31:08 +07:00
MythEclipse
d1282f2f57 fix: organize imports and apply linting fixes 2026-05-14 15:02:23 +07:00
MythEclipse
1623c612c3 docs: add PostgreSQL migration guide 2026-05-14 15:00:35 +07:00
MythEclipse
8c9e8aa64d test: add PostgreSQL connection tests 2026-05-14 14:59:37 +07:00
MythEclipse
c5297da795 feat: initialize database adapter on startup 2026-05-14 14:58:28 +07:00
MythEclipse
dbc11bbd16 feat: add data migration script from SQLite to PostgreSQL 2026-05-14 14:57:08 +07:00
MythEclipse
3c918692cb refactor: update messageStore to use database adapter 2026-05-14 14:56:14 +07:00
MythEclipse
94a3acf12e refactor: update muxer-queue to use database adapter
- Replace direct better-sqlite3 imports with DatabaseAdapter pattern
- Make all muxer-queue functions async to support both SQLite and PostgreSQL
- Update database initialization to use adapter's getDatabase()
- Export DatabaseAdapter as SqliteDatabase for backward compatibility
- Update index.ts to handle async database initialization
- Update webserver.ts to await async database operations
- All functions now work with both SQLite and PostgreSQL backends
- Tests pass, no TypeScript errors
2026-05-14 14:55:21 +07:00
MythEclipse
84e20ae373 feat: add database adapter layer for SQLite/PostgreSQL abstraction 2026-05-14 14:46:35 +07:00
MythEclipse
caf90ea9e6 feat: add PostgreSQL client and migration runner 2026-05-14 14:45:21 +07:00
MythEclipse
818a059121 feat: add PostgreSQL config and dependencies 2026-05-14 14:44:01 +07:00
MythEclipse
0eee7b9390 fix: cap AI batch size and split failed batches
Reduce effective AI batch size so streaming requests finish before timeout. Keep token-based batching but cap each request to 80 messages or about 9k content tokens, and recursively split failed batches instead of marking the whole batch failed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 04:48:20 +07:00
MythEclipse
81bb9cc6ab perf: maximize AI batches by token budget
Batch AI moderation by estimated token budget instead of fixed message count. Send as many messages as fit within an 80k token request budget while keeping one concurrent API request. Include message metadata and chronological conversation context so the model can judge provocation and replies from surrounding discussion.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 04:42:28 +07:00
MythEclipse
4ff79bea73 chore: relax moderation prompt for casual chat
Remove unclear-message and low-quality-message warning criteria because this is a casual group. Keep short, ambiguous, informal, and light profanity messages clean unless they target someone or provoke conflict.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 04:36:59 +07:00
MythEclipse
9ff1261239 feat: merge warn and flagged messages into one review panel
Combine the right-side Warned and Flagged panels into a single Needs Review panel. Keep ordering by the existing newest-first message list.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 04:33:48 +07:00
MythEclipse
bb7e3885ac chore: remove channel topic rule from moderation prompt
Remove the channel topic/OOT rule from AI moderation criteria and renumber the remaining rules. WARN criteria no longer includes OOT.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 04:28:21 +07:00
MythEclipse
c31c4df15e docs: add detailed community rules to AI moderation prompt
- Expand system prompt with complete community rules (9 sections)
- Add specific examples for each rule category
- Clarify WARN vs FLAGGED decision criteria
- Include all prohibited content types and behaviors
- Provide clear guidance for AI analyzer on rule enforcement

Community rules now cover:
1. Jaga Sikap dan Hormati Sesama
2. Hindari Konflik
3. Gunakan Channel Sesuai Topik
4. Konten Eksplisit Dilarang
5. Jaga Privasi
6. Profil yang Sopan
7. Dilarang Spam dan Penipuan
8. Langsung ke Inti Pertanyaan
9. Diskusi Berkualitas

This ensures AI analyzer makes consistent moderation decisions based on actual community rules.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 04:24:19 +07:00
MythEclipse
93eb2303c7 feat: add warn category for minor rule violations
- Add "warn" status between "clean" and "flagged" for minor violations
- Update AI analyzer system prompt with community rules and warn category
- Warn: profanity, OOT, tone issues - requires warning but not deletion
- Flagged: NSFW, illegal, hacking, scam, harassment, violence, SARA - requires review/deletion
- Update types to support warn status in MessageRecord and AIAnalysisUpdate
- Update client UI to show three panels: All Messages, Warned, Flagged
- Warned messages show in right-top panel for quick review
- Flagged messages show in right-bottom panel for moderation action

This resolves:
- Need to distinguish between minor and severe violations
- Moderators can now warn users before taking action
- Better moderation workflow with three-tier system

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 04:23:11 +07:00
MythEclipse
54a4096323 feat: add two-column text tab layout with flagged messages panel
- Split text tab into two columns: All Messages (left) and Flagged (right)
- Left panel shows all captured messages
- Right panel shows only AI-flagged messages for quick review
- Flagged panel auto-populates when messages are analyzed
- Improves moderation workflow by separating flagged content

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 04:16:53 +07:00
MythEclipse
1c945b9cac fix: trigger backlog sync when text channel is selected
- Call POST /api/backlog-sync when user selects a text channel
- Backlog sync now runs automatically on channel selection
- Fetches messages from last 24 hours for selected channel only
- Prevents empty message list on first channel selection

This resolves:
- Empty message list when selecting channel
- Backlog sync not being triggered
- Messages not loading until manual refresh

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 04:12:33 +07:00
MythEclipse
0060c4a097 feat: batch AI analysis messages for faster processing
- Change runLLMAnalysis to accept array of texts instead of single text
- Batch up to 5 messages per AI request instead of 1 message per request
- drainQueue now collects batch before sending to AI API
- Reduces API calls by 5x and speeds up analysis significantly
- System prompt updated to handle batch JSON array responses

This resolves:
- Slow AI analysis (3 messages every 15 seconds)
- Too many API calls (one per message)
- Long queue backlog

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 04:08:41 +07:00
MythEclipse
5aa57f884f feat: add API endpoint for syncing selected channel backlog 2026-05-14 04:02:25 +07:00
MythEclipse
d5977c8845 fix: handle streaming JSON response from AI LLM API
- Fix fetchJson to extract JSON from streaming response text
- API returns text/event-stream with complete JSON object embedded
- Extract JSON by finding first { and last } in response
- Prevents "Unexpected non-whitespace character after JSON" parse errors
- Streaming response now properly parsed and analyzed

This resolves:
- AI analysis stuck on "[Streaming in progress...]"
- JSON parse failures on streaming responses
- AI analysis now completes successfully

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 04:00:31 +07:00
MythEclipse
6dc6a31ea7 fix: enforce max 1 concurrent AI LLM request
- Add activeRequests counter to track in-flight AI requests
- Limit concurrent requests to 1 (MAX_CONCURRENT_REQUESTS)
- drainQueue now waits if at max concurrency before processing next message
- Prevents overwhelming streaming LLM API with multiple concurrent requests

This resolves:
- AI LLM API overload from concurrent requests
- Streaming response conflicts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 03:54:12 +07:00
MythEclipse
54534fe84c perf: parallelize backlog sync with concurrency limit
- Sync channels in parallel with concurrency limit of 3 instead of sequentially
- Reduces backlog sync time from O(n) to O(n/3) for n channels
- Prevents overwhelming Discord API with too many concurrent requests
- Maintains per-channel message batching for memory efficiency

This resolves:
- Slow backlog sync performance
- Sequential channel processing bottleneck

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 03:52:21 +07:00
MythEclipse
eb27d36cce fix: remove Picser upload, use Discord URLs directly
- Skip attachment download/upload to Picser (was failing with 400 errors)
- Store Discord's original attachment URLs directly as uploaded_url
- Mark attachments as immediately uploaded with Discord URL
- Remove processAttachmentUpload call and unused attachmentUploader import
- Eliminates slow upload cycle and API failures

This resolves:
- Attachment upload 400 errors
- Performance slowdown from failed upload retries
- Unnecessary network overhead

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 03:51:48 +07:00
MythEclipse
0a5cedfed1 fix: resolve UI state reset and backlog sync hang
- Fix client startup order: fetch/apply server UI state BEFORE loadGuilds() to prevent overwriting persisted state with default guild
- Remove auto-post of first guild in loadGuilds() — let server state drive selection
- Refactor collectWatchableChannels() to collect text channels fast first, then discover threads in parallel with 5s per-channel timeout and 30s overall timeout to prevent blocking message sync
- Ignore /favicon.ico 404 in error logging to reduce noise

Fixes:
- UI state now persists across restart (was being overwritten by client startup race)
- Backlog sync no longer hangs on thread discovery (was blocking before message sync)
- Cleaner logs without favicon 404 errors

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 03:45:51 +07:00
MythEclipse
d4a4f737a8 feat: enhance backlog sync logging and implement UI state persistence 2026-05-14 03:17:07 +07:00
MythEclipse
6e203604ec feat: remove OpenAI moderation configuration and update AI analysis logic 2026-05-14 02:44:26 +07:00
MythEclipse
be6c9f8132 feat: add AI analysis integration with moderation and LLM processing 2026-05-14 02:31:16 +07:00
MythEclipse
b36d038eba feat: implement shared UI state management with API endpoints for state retrieval and updates 2026-05-14 01:45:27 +07:00
MythEclipse
a02d1fb7c0 chore: add pnpm workspace configuration with only built dependencies 2026-05-14 01:07:50 +07:00
MythEclipse
0bdab3b446 Refactor dashboard page: replace React component with static HTML, remove unused dashboardPage.tsx, and update webserver to serve new index.html 2026-05-14 00:14:25 +07:00
MythEclipse
cbfb99f755 debug: add listen path logging
- Log binary packet receipt and listen state
- Log listen toggle and AudioContext creation
- Log playPcm calls, packet size, user hash, and playback timing
- Helps trace why listen produces no audio
2026-05-13 23:04:56 +07:00
MythEclipse
bac42b1d53 fix: match listen logic exactly from aa85dd9
- Add CHANNELS constant (1 for mono)
- Use audioBuffer.length for loop instead of float32Array.length
- Use getChannelData(0) assignment pattern from aa85dd9
- Proper buffer frame calculation with CHANNELS divisor
2026-05-13 23:01:42 +07:00
MythEclipse
ff83e34b77 fix: restore working transmit and listen logic from aa85dd9
- Use createScriptProcessor instead of Tone.UserMedia for transmit
- Use analyser.getByteFrequencyData() for proper visualizer
- Keep per-user timing with userTimelines Map for listen
- Remove Tone.js dependencies from transmit
- Restore proper AudioContext usage
2026-05-13 22:58:59 +07:00
MythEclipse
94dc460fc7 fix: implement proper audio timing for listen with per-user timelines
- Add audioContextListen and userTimelines to state
- Create AudioContext on listen toggle
- Parse user ID hash from PCM packet header
- Track playback timing per user to prevent audio gaps
- Use proper AudioContext timing instead of Tone.now()
- Clear timelines when toggling listen off
2026-05-13 22:53:17 +07:00
MythEclipse
65dc73e903 fix: use Tone.js global object from CDN instead of ES module import
- Remove ES module import statement from dashboard.js
- Use global Tone object loaded via CDN
- Remove type=module from script tag
- Fixes module resolution error
2026-05-13 22:48:03 +07:00
MythEclipse
bc212333d8 feat: implement Tone.js for professional audio transmit and listen
- Replace AudioWorkletNode with Tone.UserMedia for microphone capture
- Use Tone.Analyser for waveform analysis and Tone.Meter for level detection
- Implement proper stereo audio capture with real-time PCM transmission
- Use Tone.context for audio playback with proper buffer handling
- Add Tone.js CDN to HTML template
- Convert dashboard.js to ES module for Tone.js import
- Improve audio quality and reliability with battle-tested library
2026-05-13 22:46:24 +07:00
MythEclipse
bd8e5b78d8 feat: replace ScriptProcessorNode with AudioWorkletNode for transmit
- Create audio-worklet.js with MicrophoneProcessor for audio capture
- Implement noise gate and RMS calculation in worklet
- Send PCM data via MessagePort to main thread
- Update startStreaming to use AudioWorkletNode instead of deprecated ScriptProcessorNode
- Remove WebCodecs decoder complexity from listen
- Keep simple PCM playback for listen feature
2026-05-13 22:40:39 +07:00
193 changed files with 32218 additions and 2735 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
dist
.env
.env.test
.git
.github
recordings
*.db
*.db-shm
*.db-wal
test_out.nut

View File

@@ -25,12 +25,21 @@ WEBSERVER_PORT=3000
VOICE_CONNECTION_TIMEOUT_MS=15000 VOICE_CONNECTION_TIMEOUT_MS=15000
RECONNECT_TIMEOUT_MS=5000 RECONNECT_TIMEOUT_MS=5000
# Voice Recording Selection
# VOICE_GUILD_ID falls back to legacy GUILD_ID when omitted.
GUILD_ID=legacy_voice_guild_id
VOICE_GUILD_ID=voice_guild_id
VOICE_CHANNEL_ID=voice_channel_id
# Logging Configuration # Logging Configuration
LOG_LEVEL=info LOG_LEVEL=info
NODE_ENV=development NODE_ENV=development
# Moderation Configuration # Moderation Configuration
MONITOR_GUILD_ID=your_guild_id_here # TEXT_GUILD_ID falls back to legacy MONITOR_GUILD_ID when omitted.
MONITOR_GUILD_ID=legacy_text_guild_id
TEXT_GUILD_ID=text_guild_id
TEXT_CHANNEL_ID=text_channel_id
PICSER_UPLOAD_URL=https://picser.asepharyana.tech/api/upload PICSER_UPLOAD_URL=https://picser.asepharyana.tech/api/upload
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000 ATTACHMENT_UPLOAD_TIMEOUT_MS=30000
ATTACHMENT_MAX_SIZE_MB=100 ATTACHMENT_MAX_SIZE_MB=100
@@ -38,3 +47,29 @@ ATTACHMENT_RETRY_ATTEMPTS=3
BACKLOG_SYNC_HOURS=24 BACKLOG_SYNC_HOURS=24
BACKLOG_SYNC_BATCH_SIZE=100 BACKLOG_SYNC_BATCH_SIZE=100
# AI Analysis Configuration
AI_ANALYSIS_ENABLED=false
AI_LLM_API_KEY=your_9router_key_here
AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1
AI_LLM_MODEL=free
AI_ANALYSIS_TIMEOUT_MS=30000
# Database Configuration
DATABASE_TYPE=sqlite
# DATABASE_TYPE=postgres
# PostgreSQL Configuration (used when DATABASE_TYPE=postgres)
# Option 1: Use DATABASE_URL for connection string
# DATABASE_URL=postgresql://user:password@localhost:5432/discord_bot
# Option 2: Use individual connection parameters
# POSTGRES_HOST=localhost
# POSTGRES_PORT=5432
# POSTGRES_USER=postgres
# POSTGRES_PASSWORD=your_password_here
# POSTGRES_DB=discord_bot
# PostgreSQL Connection Pool Configuration
# POSTGRES_POOL_MIN=2
# POSTGRES_POOL_MAX=10

35
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Deploy to VPS
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
env:
ENV_FILE: ${{ secrets.ENV_FILE }}
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USERNAME }}
key: ${{ secrets.VPS_SSH_KEY }}
envs: ENV_FILE
script: |
cd /opt/imphenbot || exit
# Pull latest changes
git pull origin main
# Write environment variables from GitHub Secrets
echo "$ENV_FILE" > .env
# Build and restart containers
docker-compose up -d --build

3
.gitignore vendored
View File

@@ -2,4 +2,7 @@ node_modules
recordings recordings
.env .env
dist/ dist/
public/app/
.muxer-queue.** .muxer-queue.**
.claude/
.env.test

4
.gitmodules vendored Normal file
View File

@@ -0,0 +1,4 @@
[submodule "vendor/discord.js-selfbot-v13"]
path = vendor/discord.js-selfbot-v13
url = ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git

View File

@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**Discord Moderation Watcher Bot** — A comprehensive monitoring bot that captures voice, text messages, and images from Discord servers. Records audio from voice channels, captures all text messages (new/edited/deleted) from channels and threads, and uploads attachments to external storage. All data stored in SQLite with real-time dashboard. **Discord Moderation Watcher Bot** — A comprehensive monitoring bot that captures voice, text messages, and images from Discord servers. Records audio from voice channels, captures all text messages (new/edited/deleted) from channels and threads, and uploads attachments to external storage. All data stored in SQLite with real-time dashboard.
Built with **Bun** + **discord.js-selfbot-v13** + **@discordjs/voice** + **Express** + **WebSocket**. Built with **Node.js/pnpm** + **discord.js-selfbot-v13** + **@discordjs/voice** + **Express** + **WebSocket**.
## Architecture ## Architecture
@@ -73,28 +73,28 @@ attachments (SQLite):
```bash ```bash
# Install dependencies # Install dependencies
bun install pnpm install
# Development (auto-restart on file changes) # Development (auto-restart on file changes)
bun run dev pnpm run dev
# Production # Production
bun run start pnpm run start
# Type checking # Type checking
bun run typecheck pnpm run typecheck
# Linting (Biome) # Linting (Biome)
bun run lint pnpm run lint
# Format code (Biome) # Format code (Biome)
bun run format pnpm run format
# Run tests # Run tests
bun run test pnpm run test
# Build TypeScript # Build TypeScript
bun run build pnpm run build
``` ```
## Configuration ## Configuration
@@ -136,7 +136,7 @@ All config via `.env` (see `.env.example`). Key variables:
## Testing ## Testing
Tests use **Vitest** in `tests/` directory. Run with `bun run test`. Tests use **Vitest** in `tests/` directory. Run with `pnpm run test`.
**Test Coverage:** **Test Coverage:**
- `tests/moderation/messageStore.test.ts` — Message store CRUD operations - `tests/moderation/messageStore.test.ts` — Message store CRUD operations
@@ -290,7 +290,7 @@ Handles SIGINT/SIGTERM/uncaughtException/unhandledRejection:
## Notes ## Notes
- Bot uses selfbot variant (user account) rather than standard bot token — check Discord ToS - Bot uses selfbot variant (user account) rather than standard bot token — check Discord ToS
- Opus decoding disabled on Bun without native opus to avoid crashes - Opus decoding requires native `@discordjs/opus` under Node.js
- OGG segments include metadata JSON for each segment (user info, timestamps, duration) - OGG segments include metadata JSON for each segment (user info, timestamps, duration)
- WebSocket broadcasts PCM in real-time; browser can transmit audio back to Discord - WebSocket broadcasts PCM in real-time; browser can transmit audio back to Discord
- Graceful shutdown ensures clean disconnection and resource cleanup - Graceful shutdown ensures clean disconnection and resource cleanup
@@ -327,7 +327,7 @@ Handles SIGINT/SIGTERM/uncaughtException/unhandledRejection:
- **Recorder subsystem** (`src/recorder/`): - **Recorder subsystem** (`src/recorder/`):
- `audioStream.ts` — Subscribes to Discord audio receiver, emits Opus packets - `audioStream.ts` — Subscribes to Discord audio receiver, emits Opus packets
- `decoder.ts` — Opus decoder with runtime checks (Bun vs Node), cooldown/rotation logic for web PCM broadcast - `decoder.ts` — Opus decoder with runtime checks, cooldown/rotation logic for web PCM broadcast
- `segment.ts` — Manages OGG file rotation (5s default segments per user) - `segment.ts` — Manages OGG file rotation (5s default segments per user)
- `metadata.ts` — Collects user/role info, creates segment metadata JSON - `metadata.ts` — Collects user/role info, creates segment metadata JSON
@@ -363,28 +363,28 @@ Each segment is 5s (configurable). Metadata JSON includes user info, roles, time
```bash ```bash
# Install dependencies # Install dependencies
bun install pnpm install
# Development (auto-restart on file changes) # Development (auto-restart on file changes)
bun run dev pnpm run dev
# Production # Production
bun run start pnpm run start
# Type checking # Type checking
bun run typecheck pnpm run typecheck
# Linting (Biome) # Linting (Biome)
bun run lint pnpm run lint
# Format code (Biome) # Format code (Biome)
bun run format pnpm run format
# Run tests # Run tests
bun run test pnpm run test
# Build TypeScript # Build TypeScript
bun run build pnpm run build
``` ```
## Configuration ## Configuration
@@ -404,9 +404,9 @@ All config via `.env` (see `.env.example`). Key variables:
## Testing ## Testing
Tests use **Vitest** in `tests/` directory. Run with `bun run test`. Tests use **Vitest** in `tests/` directory. Run with `pnpm run test`.
Example: `tests/decoder.test.ts` tests Opus decoder runtime detection (Bun vs Node, native opus availability). Example: `tests/decoder.test.ts` tests Opus decoder runtime detection and native opus availability.
## Code Style ## Code Style
@@ -507,7 +507,7 @@ These will likely require:
## Notes ## Notes
- Bot uses selfbot variant (user account) rather than standard bot token — check Discord ToS - Bot uses selfbot variant (user account) rather than standard bot token — check Discord ToS
- Opus decoding disabled on Bun without native opus to avoid crashes - Opus decoding requires native `@discordjs/opus` under Node.js
- OGG segments include metadata JSON for each segment (user info, timestamps, duration) - OGG segments include metadata JSON for each segment (user info, timestamps, duration)
- WebSocket broadcasts PCM in real-time; browser can transmit audio back to Discord - WebSocket broadcasts PCM in real-time; browser can transmit audio back to Discord
- Graceful shutdown ensures clean disconnection and resource cleanup - Graceful shutdown ensures clean disconnection and resource cleanup

36
Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
FROM node:22-bookworm-slim
# Install dependencies required by node-canvas, ffmpeg, and yt-dlp
RUN apt-get update && apt-get install -y \
ffmpeg \
python3 \
curl \
git \
make \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Install yt-dlp
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \
chmod a+rx /usr/local/bin/yt-dlp
# Enable pnpm
RUN corepack enable
WORKDIR /app
# Install dependencies first for better caching
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml* ./
RUN pnpm install --frozen-lockfile
# Copy the rest of the application
COPY . .
# Build step if required (e.g. build:web)
RUN pnpm run build:web || true
# Set node environment
ENV NODE_ENV=production
# Start the application
CMD ["pnpm", "run", "start"]

223
README.md
View File

@@ -1,103 +1,184 @@
# 🎙️ Discord Voice Recorder Bot # Discord Moderation Watcher Bot
Bot Discord yang **otomatis join ke voice channel** saat startup dan **merekam suara** semua pengguna yang bicara. File audio disimpan secara lokal dalam format `.ogg`. Bot monitoring Discord yang merekam voice channel, menangkap pesan teks, menyimpan attachment, menjalankan analisis opsional, dan menyediakan dashboard web real-time.
Dibangun dengan **Bun** + **discord.js** + **@discordjs/voice**. Stack utama: Node.js, pnpm, TypeScript, `discord.js-selfbot-v13`, `@discordjs/voice`, Express, WebSocket, Drizzle ORM, SQLite/PostgreSQL, React, Vite, Vitest, dan Biome.
--- ## Prasyarat
## 📋 Prasyarat - Node.js versi modern yang kompatibel dengan TypeScript dan Vite.
- pnpm 10.x. Repo ini dipin ke `pnpm@10.25.0`.
- FFmpeg tersedia di `PATH` untuk proses muxing audio dan playback media.
- `yt-dlp` tersedia di `PATH` untuk resolve audio YouTube, search result YouTube, dan Spotify track.
- Native audio dependencies dapat dibuild di mesin lokal (`@discordjs/opus`, `better-sqlite3`, `sodium-native`).
- [Bun](https://bun.sh) >= 1.0 Install FFmpeg:
- FFmpeg (untuk encoding audio)
```bash
# Ubuntu/Debian
sudo apt install ffmpeg
# Arch
sudo pacman -S ffmpeg
```
- Discord Bot dengan permission:
- `Connect` (join voice channel)
- `Use Voice Activity`
- `Read Messages/View Channels`
- Privileged Intents: **Server Members Intent** (aktifkan di Developer Portal)
---
## ⚙️ Setup
### 1. Clone & Install
```bash ```bash
cd /path/to/bot # Ubuntu/Debian
bun install sudo apt install ffmpeg
# Arch
sudo pacman -S ffmpeg
``` ```
### 2. Konfigurasi `.env` Install `yt-dlp`:
```bash ```bash
pnpm run install:yt-dlp
```
Script installer akan memakai package manager yang tersedia (`pacman`, `apt-get`, `dnf`, `brew`) atau fallback ke `pipx`/`pip`.
## Setup
```bash
pnpm install
cp .env.example .env cp .env.example .env
``` ```
Edit `.env`: Edit `.env` sesuai server yang dimonitor:
```env ```env
DISCORD_TOKEN=your_bot_token_here DISCORD_TOKEN=your_token_here
VOICE_CHANNEL_ID=your_voice_channel_id_here MONITOR_GUILD_ID=your_guild_id_here
GUILD_ID=your_guild_id_here
RECORDINGS_DIR=./recordings RECORDINGS_DIR=./recordings
WEBSERVER_PORT=3000
DATABASE_TYPE=sqlite
``` ```
**Cara mendapatkan ID:** Catatan: project ini memakai selfbot library, bukan bot token Discord standar. Pastikan penggunaan sesuai risiko dan aturan platform yang berlaku.
- Aktifkan **Developer Mode** di Discord (Settings → Advanced → Developer Mode)
- Klik kanan pada voice channel → **Copy Channel ID** → paste ke `VOICE_CHANNEL_ID`
- Klik kanan pada server/guild → **Copy Server ID** → paste ke `GUILD_ID`
- Token bot dari [Discord Developer Portal](https://discord.com/developers/applications) → Bot → Reset Token
### 3. Invite Bot ke Server ## Menjalankan
Di Developer Portal → OAuth2 → URL Generator:
- Scopes: `bot`
- Bot Permissions: `Connect`, `Use Voice Activity`, `View Channels`
Copy URL, buka di browser, pilih server.
---
## 🚀 Menjalankan Bot
```bash ```bash
# Development (auto-restart saat file berubah) # Bot/server utama dengan auto-restart
bun run dev pnpm run dev
# Production # Production-style start
bun run start pnpm run start
# Dashboard frontend dev server
pnpm run dev:web
``` ```
Bot akan langsung join ke voice channel yang ditentukan dalam `.env`. Dashboard build production disajikan dari `public/app` setelah menjalankan:
---
## 📁 Struktur File Rekaman
```bash
pnpm run build:web
``` ```
## Command Development
```bash
# Type checking
pnpm run typecheck
# Lint
pnpm run lint
# Format
pnpm run format
# Test
pnpm run test
# Build frontend + TypeScript
pnpm run build
# Install external yt-dlp CLI for YouTube/search/Spotify track playback
pnpm run install:yt-dlp
```
## Database
Default database adalah SQLite di `.muxer-queue.db`. PostgreSQL dapat dipakai dengan `DATABASE_TYPE=postgres` dan konfigurasi `DATABASE_URL` atau variabel `POSTGRES_*`.
```bash
# Generate migration Drizzle
pnpm run db:generate
# Jalankan migration via drizzle-kit
pnpm run db:migrate
# Jalankan migration programmatic
pnpm run db:migrate:programmatic
# Buka Drizzle Studio
pnpm run db:studio
```
## Fitur
- Voice recording ke segment `.ogg` per user.
- Metadata JSON per segment audio.
- Text message capture untuk pesan baru, edit, dan delete.
- Attachment capture dan upload ke endpoint Picser.
- SQLite/PostgreSQL via Drizzle ORM.
- REST API dan WebSocket untuk dashboard.
- Dashboard React untuk pesan, gambar, voice, media playback, dan moderation review.
- Media playback dari direct URL, file lokal, YouTube URL, search terms, dan Spotify track URL.
- Metrics Prometheus di endpoint server.
- Retry dengan backoff untuk operasi eksternal.
- AI moderation analysis opsional via konfigurasi `AI_*`.
## Struktur Rekaman
```text
recordings/ recordings/
├── 123456789-1709900000000.ogg # <user-id>-<timestamp>.ogg <user-id>/
├── 987654321-1709900001234.ogg <user-id>-<session-start>-0.ogg
└── ... <user-id>-<session-start>-0.json
<user-id>-<session-start>-1.ogg
<user-id>-<session-start>-1.json
``` ```
Setiap kali user bicara dan berhenti (>1 detik diam), satu file `.ogg` baru dibuat. Segment duration dikontrol oleh `RECORDING_SEGMENT_MS`.
--- ## Struktur Proyek
## 📁 Struktur Proyek
```text
src/
index.ts Entry point Discord client dan server
recorder.ts Voice recording pipeline
recorder/ Audio stream, decoder, segment metadata
moderation/ Message capture, storage, uploads, AI review
database/ Drizzle setup, schema, migrations
routes/ Express route modules
webserver.ts Express + WebSocket server
retry.ts Retry helper berbasis p-retry
audio/ffmpegProcess.ts Direct ffmpeg process wrapper
frontend/ React dashboard source
public/app/ Dashboard build output
tests/ Vitest tests
drizzle/migrations/ Database migrations
``` ```
bot/
├── src/ ## Konfigurasi Penting
│ ├── index.ts # Entry point — login & auto-join
│ └── recorder.ts # Core recording logic Lihat `.env.example` untuk daftar lengkap. Variabel utama:
├── recordings/ # File audio tersimpan (otomatis dibuat)
├── .env # Konfigurasi (buat dari .env.example) - `DISCORD_TOKEN` — token akun/client yang dipakai selfbot.
├── .env.example - `MONITOR_GUILD_ID` — guild yang dimonitor untuk moderation capture.
├── package.json - `RECORDINGS_DIR` — direktori output audio.
└── tsconfig.json - `WEBSERVER_PORT` — port HTTP/WebSocket.
- `DATABASE_TYPE``sqlite` atau `postgres`.
- `PICSER_UPLOAD_URL` — endpoint upload attachment.
- `AI_ANALYSIS_ENABLED` — aktifkan/nonaktifkan analisis AI.
- `AI_LLM_API_KEY`, `AI_LLM_BASE_URL`, `AI_LLM_MODEL` — konfigurasi provider LLM.
## Verifikasi Setelah Perubahan
Sebelum menjalankan lama atau deploy, jalankan:
```bash
pnpm install
pnpm run typecheck
pnpm run lint
pnpm run test
pnpm run build
``` ```
## Catatan Library Modernization
Project memakai Zod untuk validasi runtime, Drizzle untuk database, dan wrapper `node:child_process` langsung untuk FFmpeg. Library lama `class-transformer`, `class-validator`, dan `fluent-ffmpeg` sudah tidak dipakai.

View File

@@ -1,6 +1,6 @@
{ {
"files": { "files": {
"includes": ["src/**/*.ts", "tests/**/*.ts", "*.json", "*.ts"] "includes": ["src/**/*.ts", "tests/**/*.ts", "*.json", "*.ts", "!vendor/**"]
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
@@ -14,9 +14,6 @@
"style": { "style": {
"noNonNullAssertion": "warn", "noNonNullAssertion": "warn",
"useNodejsImportProtocol": "warn" "useNodejsImportProtocol": "warn"
},
"suspicious": {
"noExplicitAny": "warn"
} }
} }
} }

BIN
bun.lockb

Binary file not shown.

20
components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "frontend/src/styles.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "frontend/src/components",
"utils": "frontend/src/lib/utils",
"ui": "frontend/src/components/ui",
"lib": "frontend/src/lib",
"hooks": "frontend/src/hooks"
}
}

60
debug-screen.ts Normal file
View File

@@ -0,0 +1,60 @@
import type { ChildProcess } from "node:child_process";
import dotenv from "dotenv";
import { createYtDlp } from "./src/media/ytdlp.js";
import { prepareStream } from "./src/streaming/index.js";
dotenv.config();
async function test() {
const ytdlp = createYtDlp();
const url = "https://www.youtube.com/watch?v=aqz-KE-bpKQ"; // Small video
console.log("Getting direct video url...");
const directUrl = await ytdlp.getDirectVideoUrl(url);
console.log("Direct URL:", directUrl);
console.log("Preparing stream...");
const { command, output } = prepareStream(directUrl, {
logLevel: "debug",
customInputOptions: [
"-headers",
"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.3\r\nConnection: keep-alive\r\n",
],
});
const ffmpeg = command as ChildProcess;
ffmpeg.stderr?.on("data", (data: Buffer) => {
console.log("FFMPEG STDERR:", data.toString());
});
let bytesRead = 0;
output.on("data", (chunk: Buffer) => {
bytesRead += chunk.length;
console.log("Stream bytes:", bytesRead);
if (bytesRead > 1024 * 1024) {
ffmpeg.kill("SIGTERM");
}
});
try {
await new Promise<void>((resolve, reject) => {
ffmpeg.on("exit", (code) => {
if (code === 0 || code === null) {
resolve();
return;
}
reject(new Error(`ffmpeg exited with code ${code}`));
});
ffmpeg.on("error", reject);
});
} catch (error: unknown) {
console.error(
"Debug stream failed:",
error instanceof Error ? error.message : String(error),
);
}
process.exit(0);
}
test();

45
deploy.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/bin/bash
# Configuration for CLI deployment
VPS_HOST="45.127.35.244"
VPS_USER="root"
# Find first available private key in ~/.ssh or use specific one if you want to hardcode
SSH_KEY_PATH=$(find ~/.ssh -name "id_rsa" -o -name "id_ed25519" | head -n 1)
echo "🚀 Starting CLI deployment to $VPS_USER@$VPS_HOST..."
if [ -z "$SSH_KEY_PATH" ]; then
echo "⚠️ No SSH key found in ~/.ssh. Falling back to default SSH behavior."
SSH_CMD="ssh -o StrictHostKeyChecking=no $VPS_USER@$VPS_HOST"
RSYNC_CMD="rsync -avz --exclude-from='.dockerignore' -e 'ssh -o StrictHostKeyChecking=no'"
else
echo "🔑 Using SSH key: $SSH_KEY_PATH"
SSH_CMD="ssh -i $SSH_KEY_PATH -o StrictHostKeyChecking=no $VPS_USER@$VPS_HOST"
RSYNC_CMD="rsync -avz --exclude-from='.dockerignore' -e 'ssh -i $SSH_KEY_PATH -o StrictHostKeyChecking=no'"
fi
# Directory on the VPS where the app will be deployed
REMOTE_DIR="/opt/imphenbot"
echo "📦 Syncing files to VPS..."
$SSH_CMD "mkdir -p $REMOTE_DIR"
eval "$RSYNC_CMD ./ $VPS_USER@$VPS_HOST:$REMOTE_DIR"
if [ -f .env ]; then
echo "🔒 Copying local .env to VPS..."
if [ -z "$SSH_KEY_PATH" ]; then
scp -o StrictHostKeyChecking=no .env $VPS_USER@$VPS_HOST:$REMOTE_DIR/.env
else
scp -i $SSH_KEY_PATH -o StrictHostKeyChecking=no .env $VPS_USER@$VPS_HOST:$REMOTE_DIR/.env
fi
else
echo "⚠️ No local .env found to copy."
fi
echo "🔄 Rebuilding and restarting Docker containers..."
$SSH_CMD << EOF
cd $REMOTE_DIR
docker-compose up -d --build
EOF
echo "✅ Deployment complete!"

30
docker-compose.yml Normal file
View File

@@ -0,0 +1,30 @@
version: '3.8'
services:
app:
build: .
container_name: imphenbot-app
restart: unless-stopped
env_file:
- .env
volumes:
- ./recordings:/app/recordings
# Mapping SQLite database files if needed, or storing them in a dedicated volume.
# Assuming default config uses root directory for DB.
- ./.muxer-queue.db:/app/.muxer-queue.db
- ./.muxer-queue.db-shm:/app/.muxer-queue.db-shm
- ./.muxer-queue.db-wal:/app/.muxer-queue.db-wal
labels:
- "traefik.enable=true"
- "traefik.http.routers.imphenbot.rule=Host(`imphnen.asepharyana.tech`)"
- "traefik.http.routers.imphenbot.entrypoints=websecure"
- "traefik.http.routers.imphenbot.tls=true"
# Expose port to traefik (adjust if WEBSERVER_PORT differs)
- "traefik.http.services.imphenbot.loadbalancer.server.port=3000"
networks:
- app-shared-net
networks:
app-shared-net:
name: app-shared-net
external: true

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,704 @@
# Drizzle ORM Migration 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:** Replace raw SQL queries and manual database adapter with Drizzle ORM, providing type-safe database operations, automatic migrations, and better maintainability while supporting both SQLite and PostgreSQL.
**Architecture:** Replace the custom DatabaseAdapter pattern with Drizzle ORM's unified API. Define schema using Drizzle's TypeScript schema definitions. Replace all raw SQL queries in muxer-queue.ts and messageStore.ts with Drizzle query builder. Use Drizzle migrations for schema management. Maintain backward compatibility with existing data.
**Tech Stack:** drizzle-orm, drizzle-kit, better-sqlite3 (SQLite), postgres (PostgreSQL), TypeScript
---
## File Structure
**New files to create:**
- `src/database/schema.ts` — Drizzle schema definitions for all tables
- `src/database/drizzle.ts` — Drizzle database client initialization
- `drizzle.config.ts` — Drizzle Kit configuration
- `drizzle/migrations/` — Auto-generated migration files
**Modified files:**
- `src/muxer-queue.ts` — Replace raw SQL with Drizzle queries
- `src/moderation/messageStore.ts` — Replace raw SQL with Drizzle queries
- `src/database/adapter.ts` — Remove (no longer needed)
- `src/database/postgres.ts` — Remove (Drizzle handles this)
- `src/database/migrations.ts` — Remove (Drizzle handles this)
- `src/index.ts` — Update database initialization
- `src/webserver.ts` — Update database calls
- `package.json` — Add drizzle-orm, drizzle-kit dependencies
- `src/config.ts` — Keep PostgreSQL config variables
---
## Task 1: Add Drizzle Dependencies
**Files:**
- Modify: `package.json`
- [ ] **Step 1: Add drizzle-orm and drizzle-kit**
```bash
cd /mnt/code/bete && pnpm add drizzle-orm
```
Expected: drizzle-orm installed
- [ ] **Step 2: Add drizzle-kit as dev dependency**
```bash
cd /mnt/code/bete && pnpm add -D drizzle-kit
```
Expected: drizzle-kit installed
- [ ] **Step 3: Verify installation**
```bash
cd /mnt/code/bete && pnpm list drizzle-orm drizzle-kit
```
Expected: Both packages listed with versions
- [ ] **Step 4: Commit**
```bash
git add package.json pnpm-lock.yaml
git commit -m "feat: add drizzle-orm and drizzle-kit dependencies"
```
---
## Task 2: Create Drizzle Schema Definitions
**Files:**
- Create: `src/database/schema.ts`
- [ ] **Step 1: Create schema.ts with table definitions**
```typescript
import { pgTable, text, integer, bigint, real, index, foreignKey } from "drizzle-orm/pg-core";
import { sqliteTable, SQLiteInteger, SQLiteText } from "drizzle-orm/sqlite-core";
import { config } from "../config";
// Determine which table function to use based on database type
const tableFactory = config.DATABASE_TYPE === "postgres" ? pgTable : sqliteTable;
// Muxer Jobs Table
export const muxerJobs = tableFactory("muxer_jobs", {
id: text("id").primaryKey(),
data: text("data").notNull(),
status: text("status", { enum: ["pending", "processing", "completed", "failed"] }).notNull().default("pending"),
attempts: integer("attempts").notNull().default(0),
maxAttempts: integer("maxAttempts").notNull().default(3),
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull(),
error: text("error"),
}, (table) => ({
statusIdx: index("idx_muxer_jobs_status").on(table.status),
createdAtIdx: index("idx_muxer_jobs_createdAt").on(table.createdAt),
}));
// Messages Table
export const messages = tableFactory("messages", {
id: text("id").primaryKey(),
guild_id: text("guild_id").notNull(),
channel_id: text("channel_id").notNull(),
thread_id: text("thread_id"),
user_id: text("user_id").notNull(),
username: text("username").notNull(),
avatar_url: text("avatar_url"),
content: text("content").notNull(),
edited_content: text("edited_content"),
created_at: bigint("created_at", { mode: "number" }).notNull(),
edited_at: bigint("edited_at", { mode: "number" }),
deleted_at: bigint("deleted_at", { mode: "number" }),
type: text("type", { enum: ["text", "edited", "deleted"] }).notNull().default("text"),
metadata: text("metadata"),
ai_status: text("ai_status", { enum: ["pending", "clean", "warn", "flagged", "error"] }).notNull().default("pending"),
ai_moderation_flags: text("ai_moderation_flags"),
ai_moderation_score: real("ai_moderation_score"),
ai_moderation_raw: text("ai_moderation_raw"),
ai_analysis: text("ai_analysis"),
ai_analyzed_at: bigint("ai_analyzed_at", { mode: "number" }),
ai_error: text("ai_error"),
}, (table) => ({
channelIdx: index("idx_messages_channel").on(table.channel_id),
userIdx: index("idx_messages_user").on(table.user_id),
createdIdx: index("idx_messages_created").on(table.created_at),
threadIdx: index("idx_messages_thread").on(table.thread_id),
}));
// Attachments Table
export const attachments = tableFactory("attachments", {
id: text("id").primaryKey(),
message_id: text("message_id").notNull(),
guild_id: text("guild_id").notNull(),
channel_id: text("channel_id").notNull(),
thread_id: text("thread_id"),
user_id: text("user_id").notNull(),
filename: text("filename").notNull(),
size: integer("size").notNull(),
type: text("type").notNull(),
discord_url: text("discord_url").notNull(),
uploaded_url: text("uploaded_url"),
upload_status: text("upload_status", { enum: ["pending", "uploaded", "failed"] }).notNull().default("pending"),
upload_error: text("upload_error"),
created_at: bigint("created_at", { mode: "number" }).notNull(),
uploaded_at: bigint("uploaded_at", { mode: "number" }),
}, (table) => ({
channelIdx: index("idx_attachments_channel").on(table.channel_id),
messageIdx: index("idx_attachments_message").on(table.message_id),
statusIdx: index("idx_attachments_status").on(table.upload_status),
fk: foreignKey({
columns: [table.message_id],
foreignColumns: [messages.id],
}).onDelete("cascade"),
}));
// UI State Table
export const uiState = tableFactory("ui_state", {
key: text("key").primaryKey(),
value: text("value").notNull(),
updated_at: bigint("updated_at", { mode: "number" }).notNull(),
});
```
- [ ] **Step 2: Run typecheck**
```bash
cd /mnt/code/bete && pnpm run typecheck
```
Expected: No TypeScript errors
- [ ] **Step 3: Commit**
```bash
git add src/database/schema.ts
git commit -m "feat: create drizzle schema definitions"
```
---
## Task 3: Create Drizzle Configuration
**Files:**
- Create: `drizzle.config.ts`
- [ ] **Step 1: Create drizzle.config.ts**
```typescript
import { defineConfig } from "drizzle-kit";
import { config } from "./src/config";
export default defineConfig({
schema: "./src/database/schema.ts",
out: "./drizzle/migrations",
dialect: config.DATABASE_TYPE === "postgres" ? "postgresql" : "sqlite",
dbCredentials: config.DATABASE_TYPE === "postgres"
? {
host: config.POSTGRES_HOST,
port: config.POSTGRES_PORT,
user: config.POSTGRES_USER,
password: config.POSTGRES_PASSWORD,
database: config.POSTGRES_DB,
}
: {
url: `file:./.muxer-queue.db`,
},
});
```
- [ ] **Step 2: Add migration scripts to package.json**
```json
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
}
```
- [ ] **Step 3: Generate initial migration**
```bash
cd /mnt/code/bete && pnpm run db:generate
```
Expected: Migration files created in drizzle/migrations/
- [ ] **Step 4: Commit**
```bash
git add drizzle.config.ts package.json drizzle/
git commit -m "feat: add drizzle configuration and initial migrations"
```
---
## Task 4: Create Drizzle Database Client
**Files:**
- Create: `src/database/drizzle.ts`
- [ ] **Step 1: Create drizzle.ts**
```typescript
import { drizzle } from "drizzle-orm/node-postgres";
import { drizzle as drizzleSqlite } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";
import { Pool } from "pg";
import { config } from "../config";
import { createChildLogger } from "../logger";
import * as schema from "./schema";
const logger = createChildLogger("drizzle");
let db: ReturnType<typeof drizzle> | null = null;
export async function initializeDatabase() {
if (db) return db;
if (config.DATABASE_TYPE === "postgres") {
const pool = new Pool({
host: config.POSTGRES_HOST,
port: config.POSTGRES_PORT,
user: config.POSTGRES_USER,
password: config.POSTGRES_PASSWORD,
database: config.POSTGRES_DB,
min: config.POSTGRES_POOL_MIN,
max: config.POSTGRES_POOL_MAX,
});
db = drizzle(pool, { schema });
logger.info("PostgreSQL database initialized");
} else {
const sqlite = new Database(".muxer-queue.db");
sqlite.pragma("journal_mode = WAL");
db = drizzleSqlite(sqlite, { schema });
logger.info("SQLite database initialized");
}
return db;
}
export function getDatabase() {
if (!db) {
throw new Error("Database not initialized. Call initializeDatabase() first.");
}
return db;
}
export async function closeDatabase() {
if (db) {
// Drizzle doesn't have a close method, but we can close the underlying connection
if (config.DATABASE_TYPE === "postgres") {
// Pool will be closed when the process exits
logger.info("PostgreSQL connection pool will close on process exit");
} else {
logger.info("SQLite database closed");
}
db = null;
}
}
```
- [ ] **Step 2: Run typecheck**
```bash
cd /mnt/code/bete && pnpm run typecheck
```
Expected: No TypeScript errors
- [ ] **Step 3: Commit**
```bash
git add src/database/drizzle.ts
git commit -m "feat: create drizzle database client"
```
---
## Task 5: Migrate muxer-queue.ts to Drizzle
**Files:**
- Modify: `src/muxer-queue.ts`
- [ ] **Step 1: Replace imports**
Replace:
```typescript
import { getDatabase, DatabaseAdapter } from "./database/adapter";
```
With:
```typescript
import { getDatabase, initializeDatabase } from "./database/drizzle";
import { muxerJobs } from "./database/schema";
import { eq, asc, desc } from "drizzle-orm";
```
- [ ] **Step 2: Replace enqueueMuxerJob function**
Replace raw SQL with:
```typescript
export async function enqueueMuxerJob(data: MuxerJobData): Promise<string> {
try {
const db = getDatabase();
const jobId = `${data.userId}-${data.sessionId}`;
const now = Date.now();
await db.insert(muxerJobs).values({
id: jobId,
data: JSON.stringify(data),
status: "pending",
attempts: 0,
maxAttempts: 3,
createdAt: now,
updatedAt: now,
}).onConflictDoNothing();
logger.info({ jobId, userId: data.userId }, "Muxer job enqueued");
return jobId;
} catch (error) {
logger.error({ error: error instanceof Error ? error.message : String(error) }, "Failed to enqueue muxer job");
throw error;
}
}
```
- [ ] **Step 3: Replace getPendingJobs function**
```typescript
export async function getPendingJobs(): Promise<StoredJob[]> {
const db = getDatabase();
const rows = await db
.select()
.from(muxerJobs)
.where(eq(muxerJobs.status, "pending"))
.orderBy(asc(muxerJobs.createdAt))
.limit(10);
return rows.map((row) => ({
...row,
status: row.status as "pending" | "processing" | "completed" | "failed",
}));
}
```
- [ ] **Step 4: Replace updateJobStatus function**
```typescript
export async function updateJobStatus(
jobId: string,
status: "processing" | "completed" | "failed",
error?: string,
): Promise<void> {
const db = getDatabase();
const now = Date.now();
if (status === "failed") {
await db
.update(muxerJobs)
.set({
status,
attempts: muxerJobs.attempts + 1,
updatedAt: now,
error: error || null,
})
.where(eq(muxerJobs.id, jobId));
} else {
await db
.update(muxerJobs)
.set({ status, updatedAt: now })
.where(eq(muxerJobs.id, jobId));
}
logger.info({ jobId, status, error }, "Job status updated");
}
```
- [ ] **Step 5: Replace remaining functions similarly**
Replace `retryFailedJob`, `cleanupCompletedJobs`, `getJobStats` with Drizzle equivalents
- [ ] **Step 6: Update getPersistedValue and setPersistedValue**
Use Drizzle's uiState table instead of raw SQL
- [ ] **Step 7: Run tests**
```bash
cd /mnt/code/bete && pnpm run test
```
Expected: All tests pass
- [ ] **Step 8: Commit**
```bash
git add src/muxer-queue.ts
git commit -m "refactor: migrate muxer-queue to drizzle-orm"
```
---
## Task 6: Migrate messageStore.ts to Drizzle
**Files:**
- Modify: `src/moderation/messageStore.ts`
- [ ] **Step 1: Replace imports**
```typescript
import { getDatabase } from "../database/drizzle";
import { messages, attachments } from "../database/schema";
import { eq, or, desc, and } from "drizzle-orm";
```
- [ ] **Step 2: Replace insertMessage function**
```typescript
export async function insertMessage(message: MessageRecord): Promise<void> {
try {
const db = getDatabase();
await db.insert(messages).values(message).onConflictDoNothing();
logger.debug({ messageId: message.id }, "Message inserted");
} catch (error) {
logger.error({ messageId: message.id, error: error instanceof Error ? error.message : String(error) }, "Failed to insert message");
throw error;
}
}
```
- [ ] **Step 3: Replace updateMessageAsEdited function**
```typescript
export async function updateMessageAsEdited(
messageId: string,
editedContent: string,
editedAt: number,
): Promise<void> {
try {
const db = getDatabase();
await db
.update(messages)
.set({ edited_content: editedContent, edited_at: editedAt, type: "edited" })
.where(eq(messages.id, messageId));
logger.debug({ messageId }, "Message marked as edited");
} catch (error) {
logger.error({ messageId, error: error instanceof Error ? error.message : String(error) }, "Failed to update message as edited");
throw error;
}
}
```
- [ ] **Step 4: Replace getMessagesByChannel function**
```typescript
export async function getMessagesByChannel(
channelId: string,
limit: number = 50,
offset: number = 0,
): Promise<MessageRecord[]> {
try {
const db = getDatabase();
return await db
.select()
.from(messages)
.where(or(eq(messages.channel_id, channelId), eq(messages.thread_id, channelId)))
.orderBy(desc(messages.created_at))
.limit(limit)
.offset(offset);
} catch (error) {
logger.error({ channelId, error: error instanceof Error ? error.message : String(error) }, "Failed to get messages by channel");
throw error;
}
}
```
- [ ] **Step 5: Replace attachment functions similarly**
Replace `insertAttachment`, `getAttachmentsByChannel`, `updateAttachmentAsUploaded`, `updateAttachmentAsFailedUpload` with Drizzle equivalents
- [ ] **Step 6: Replace AI analysis functions**
Replace `updateMessageAIAnalysis`, `getPendingAIAnalysisMessages`, `getMessageById` with Drizzle equivalents
- [ ] **Step 7: Update function signatures**
Remove `db: DatabaseAdapter` parameter from all functions since they now use `getDatabase()` internally
- [ ] **Step 8: Run tests**
```bash
cd /mnt/code/bete && pnpm run test
```
Expected: All tests pass
- [ ] **Step 9: Commit**
```bash
git add src/moderation/messageStore.ts
git commit -m "refactor: migrate messageStore to drizzle-orm"
```
---
## Task 7: Update Application Initialization
**Files:**
- Modify: `src/index.ts`
- Modify: `src/webserver.ts`
- [ ] **Step 1: Update src/index.ts imports**
Replace:
```typescript
import { getDatabase } from "./database/adapter";
```
With:
```typescript
import { initializeDatabase } from "./database/drizzle";
```
- [ ] **Step 2: Update database initialization in index.ts**
```typescript
const db = await initializeDatabase();
logger.info({ type: config.DATABASE_TYPE }, "Database initialized");
```
- [ ] **Step 3: Update src/webserver.ts**
Replace any `getDatabase()` calls with the new Drizzle client
- [ ] **Step 4: Run typecheck**
```bash
cd /mnt/code/bete && pnpm run typecheck
```
Expected: No TypeScript errors
- [ ] **Step 5: Commit**
```bash
git add src/index.ts src/webserver.ts
git commit -m "feat: update application initialization for drizzle"
```
---
## Task 8: Remove Old Database Files
**Files:**
- Delete: `src/database/adapter.ts`
- Delete: `src/database/postgres.ts`
- Delete: `src/database/migrations.ts`
- [ ] **Step 1: Remove old adapter files**
```bash
cd /mnt/code/bete && rm src/database/adapter.ts src/database/postgres.ts src/database/migrations.ts
```
- [ ] **Step 2: Verify no imports remain**
```bash
grep -r "database/adapter\|database/postgres\|database/migrations" src/ --include="*.ts"
```
Expected: No results
- [ ] **Step 3: Commit**
```bash
git add -A
git commit -m "refactor: remove old database adapter files"
```
---
## Task 9: Final Testing and Verification
**Files:**
- Test all functionality
- [ ] **Step 1: Run full test suite**
```bash
cd /mnt/code/bete && pnpm run test
```
Expected: All tests pass
- [ ] **Step 2: Type check**
```bash
cd /mnt/code/bete && pnpm run typecheck
```
Expected: No TypeScript errors
- [ ] **Step 3: Lint**
```bash
cd /mnt/code/bete && pnpm run lint
```
Expected: No linting errors
- [ ] **Step 4: Test startup with SQLite**
```bash
cd /mnt/code/bete && timeout 10 pnpm run dev || true
```
Expected: Bot starts successfully, logs show "Database initialized"
- [ ] **Step 5: Verify git status**
```bash
git status
```
Expected: Clean working tree
- [ ] **Step 6: Final commit if needed**
```bash
git add -A
git commit -m "feat: complete drizzle-orm migration"
```
---
## Spec Coverage Checklist
- ✅ Replace raw SQL with Drizzle ORM
- ✅ Type-safe database operations
- ✅ Support both SQLite and PostgreSQL
- ✅ Automatic schema migrations
- ✅ All existing functionality preserved
- ✅ Backward compatible with existing data
- ✅ Cleaner, more maintainable code
- ✅ Better error handling
- ✅ Tests passing
- ✅ No TypeScript errors
---
Plan complete and saved to `/mnt/code/bete/docs/superpowers/plans/2026-05-14-drizzle-orm-migration.md`.
**Two execution options:**
**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration
**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
Which approach would you prefer?

View File

@@ -0,0 +1,183 @@
# Library Modernization 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:** Modernize runtime and development dependencies while preserving Discord monitoring, recording, database migration, dashboard, and test behavior.
**Architecture:** Treat modernization as dependency classification plus small source refactors. Remove redundant validation libraries by moving `src/validation.ts` to Zod, replace `fluent-ffmpeg` with a tiny direct `ffmpeg` process wrapper for the muxer scripts, and convert database migration code to ESM-safe imports. Keep high-risk Discord/audio/native packages unless audit proves a safe replacement exists.
**Tech Stack:** Node.js, pnpm, TypeScript, Zod, Drizzle ORM, better-sqlite3, pg, Express, ws, Vite, React, Vitest, Biome, Discord voice/audio packages.
---
## Dependency Audit Baseline
- Usage audit confirms `class-transformer`/`class-validator` are only used by `src/validation.ts`.
- Usage audit confirms `fluent-ffmpeg` is only used by `src/muxer.ts` and `src/muxer-aup3.ts`.
- `pnpm outdated --format table` reports `discord.js-selfbot-v13` and `fluent-ffmpeg` as deprecated.
- Outdated packages reported: `tsx`, `@types/node`, `p-retry`, `pino`, `pino-pretty`, `sodium-native`.
- Direct dependency classification: remove `class-transformer`, `class-validator`, `fluent-ffmpeg`, `@types/fluent-ffmpeg`; replace validation with Zod and ffmpeg wrapper with `node:child_process`; upgrade outdated packages; keep high-risk voice/audio packages unless a compatible replacement is proven.
## File Structure
- Modify `package.json`: dependency upgrades, removals, and script additions if needed.
- Modify `pnpm-lock.yaml`: regenerated by `pnpm install`.
- Modify `src/validation.ts`: replace `class-transformer` and `class-validator` with Zod.
- Modify `src/database/migrate.ts`: remove dynamic CommonJS `require` and `any` cast.
- Create `src/audio/ffmpegProcess.ts`: small wrapper around `node:child_process` for direct ffmpeg execution.
- Modify `src/muxer.ts`: use `runFfmpeg()` instead of `fluent-ffmpeg`.
- Modify `src/muxer-aup3.ts`: use `runFfmpeg()` instead of `fluent-ffmpeg`.
- Modify `src/recorder/decoder.ts`: keep `createRequire()` for optional native probing unless a better ESM-safe probe is identified during implementation.
- Add or modify tests under `tests/`: validation, migration helper behavior, and ffmpeg argument construction.
---
### Task 1: Capture Dependency Audit Baseline
**Files:**
- Modify: `docs/superpowers/plans/2026-05-14-library-modernization.md`
- Inspect: `package.json`
- Inspect: `pnpm-lock.yaml`
- Inspect: `src/**/*.ts`
- Inspect: `tests/**/*.ts`
- [x] **Step 1: List direct dependency usage**
Run:
```bash
grep -R "class-transformer\|class-validator\|fluent-ffmpeg\|@discordjs/opus\|@discordjs/voice\|@snazzah/davey\|discord.js-selfbot-v13\|libsodium-wrappers\|sodium-native\|prism-media\|drizzle-orm\|better-sqlite3\|pg\|express\|helmet\|p-retry\|pino\|pino-http\|prom-client\|react\|react-dom\|vite\|ws\|zod" -n src tests frontend package.json
```
Expected: output lists every direct package usage. Record the summary in the implementation notes during execution.
- [x] **Step 2: Check outdated dependencies**
Run:
```bash
pnpm outdated --format table
```
Expected: command exits non-zero if packages are outdated; use the table as audit input, not as failure.
- [x] **Step 3: Classify direct dependencies**
Use this classification as the starting point, adjusting only if Step 1 proves a package is unused or irreplaceable:
```text
remove: class-transformer, class-validator, fluent-ffmpeg, @types/fluent-ffmpeg
replace: class-transformer/class-validator -> zod; fluent-ffmpeg -> node:child_process ffmpeg wrapper
upgrade: @vitejs/plugin-react, better-sqlite3, discord.js-selfbot-v13, dotenv, drizzle-orm, express, helmet, libsodium-wrappers, p-retry, pg, pino, pino-http, prom-client, react, react-dom, sodium-native, vite, ws, zod, @biomejs/biome, @types/*, drizzle-kit, pino-pretty, tsx, vitest
keep unless compatible alternative is proven: @discordjs/opus, @discordjs/voice, @snazzah/davey, prism-media
```
- [x] **Step 4: Commit audit note if this task changes files**
If only commands were run, do not commit. If the plan is updated with audit notes, run:
```bash
git add docs/superpowers/plans/2026-05-14-library-modernization.md
git commit -m "docs: record dependency modernization audit"
```
Expected: commit succeeds only if a file changed.
---
### Task 2: Replace Class Validator Stack With Zod
**Files:**
- Modify: `src/validation.ts`
- Test: `tests/validation.test.ts`
- Modify later: `package.json`
- [ ] **Step 1: Write failing validation tests**
- [ ] **Step 2: Run validation tests to establish baseline**
- [ ] **Step 3: Replace implementation with Zod**
- [ ] **Step 4: Run validation tests**
- [ ] **Step 5: Commit validation refactor**
---
### Task 3: Convert Migration Code to ESM-Safe Drizzle Imports
**Files:**
- Modify: `src/database/migrate.ts`
- Test: `tests/database/migrate.test.ts`
- [ ] **Step 1: Extract SQLite database creation for testing**
- [ ] **Step 2: Add migration helper test**
- [ ] **Step 3: Run migration test**
- [ ] **Step 4: Run typecheck for migration typing**
- [ ] **Step 5: Commit migration refactor**
---
### Task 4: Replace Fluent FFmpeg With Direct Process Wrapper
**Files:**
- Create: `src/audio/ffmpegProcess.ts`
- Modify: `src/muxer.ts`
- Modify: `src/muxer-aup3.ts`
- Test: `tests/audio/ffmpegProcess.test.ts`
- [ ] **Step 1: Add ffmpeg wrapper tests**
- [ ] **Step 2: Run ffmpeg wrapper test to verify it fails**
- [ ] **Step 3: Implement ffmpeg process wrapper**
- [ ] **Step 4: Refactor `src/muxer.ts`**
- [ ] **Step 5: Refactor `src/muxer-aup3.ts`**
- [ ] **Step 6: Run ffmpeg wrapper tests**
- [ ] **Step 7: Run typecheck**
- [ ] **Step 8: Commit ffmpeg refactor**
---
### Task 5: Update Package Manifest and Lockfile
**Files:**
- Modify: `package.json`
- Modify: `pnpm-lock.yaml`
- [ ] **Step 1: Remove replaced packages**
- [ ] **Step 2: Upgrade dependencies interactively-free**
- [ ] **Step 3: Ensure package manager remains pnpm 10**
- [ ] **Step 4: Run install to verify lockfile**
- [ ] **Step 5: Commit dependency manifest changes**
---
### Task 6: Fix Upgrade Breakages
**Files:**
- Modify as needed: `src/**/*.ts`
- Modify as needed: `frontend/**/*.ts`
- Modify as needed: `frontend/**/*.tsx`
- Modify as needed: `tests/**/*.ts`
- Modify as needed: config files touched by upgraded tools
- [ ] **Step 1: Run typecheck**
- [ ] **Step 2: Run lint**
- [ ] **Step 3: Run tests**
- [ ] **Step 4: Run build**
- [ ] **Step 5: Commit breakage fixes**
---
### Task 7: Final Verification and Manual Dashboard Check
**Files:**
- No planned source changes
- [ ] **Step 1: Run full verification**
- [ ] **Step 2: Start dev server for dashboard check**
- [ ] **Step 3: Manually verify frontend build path if browser access is available**
- [ ] **Step 4: Check git status**
---
## Self-Review
- Spec coverage: audit, dependency classification, replacement/removal, ESM migration, lockfile regeneration, verification, and dashboard manual check are covered.
- Placeholder scan: no `TBD`, `TODO`, or unspecified implementation steps remain.
- Type consistency: helper names are consistent across tasks: `validateUserStateUpdate`, `initializeMigrationSqliteDatabase`, `buildMuxFfmpegArgs`, and `runFfmpeg`.

View File

@@ -0,0 +1,189 @@
# Discord Video Stream Vendor 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:** Add `@dank074/discord-video-stream` as a vendored workspace dependency backed by the SSH submodule remote `ssh://git@43.134.105.109:22222/exceed/Discord-video-stream.git`.
**Architecture:** Follow the existing `discord.js-selfbot-v13` pattern: keep third-party source under `vendor/`, track it as a git submodule, include it in `pnpm-workspace.yaml`, and consume it from the root app with `workspace:*`. Clone from the public GitHub repository for source availability, then set `.gitmodules` to the requested SSH mirror URL so future submodule operations use the private remote.
**Tech Stack:** Git submodules, pnpm workspaces, Node.js package metadata, TypeScript project verification.
---
## File Structure
- Modify `.gitmodules`: add `vendor/Discord-video-stream` submodule entry with SSH URL.
- Create submodule path `vendor/Discord-video-stream`: checkout public upstream `https://github.com/Discord-RE/Discord-video-stream.git` at current `master` HEAD.
- Modify `pnpm-workspace.yaml`: add `vendor/Discord-video-stream` to workspace packages.
- Modify `package.json`: add root dependency `"@dank074/discord-video-stream": "workspace:*"`.
- Modify `pnpm-lock.yaml`: update lockfile after `pnpm install`.
## Task 1: Add Vendor Submodule
**Files:**
- Modify: `.gitmodules`
- Create: `vendor/Discord-video-stream`
- [ ] **Step 1: Verify vendor path does not already exist**
Run:
```bash
test ! -e vendor/Discord-video-stream
```
Expected: exit code `0`. If it exists, stop and inspect it with `git status --short vendor/Discord-video-stream` before proceeding.
- [ ] **Step 2: Add the submodule from public source**
Run:
```bash
git submodule add https://github.com/Discord-RE/Discord-video-stream.git vendor/Discord-video-stream
```
Expected: Git creates `vendor/Discord-video-stream` and updates `.gitmodules`.
- [ ] **Step 3: Set submodule URL to requested SSH mirror**
Run:
```bash
git config -f .gitmodules submodule.vendor/Discord-video-stream.url ssh://git@43.134.105.109:22222/exceed/Discord-video-stream.git
git submodule sync vendor/Discord-video-stream
```
Expected: `.gitmodules` contains:
```ini
[submodule "vendor/Discord-video-stream"]
path = vendor/Discord-video-stream
url = ssh://git@43.134.105.109:22222/exceed/Discord-video-stream.git
```
- [ ] **Step 4: Verify package identity**
Run:
```bash
node -e "const p=require('./vendor/Discord-video-stream/package.json'); console.log(p.name)"
```
Expected output:
```text
@dank074/discord-video-stream
```
- [ ] **Step 5: Commit submodule metadata only when requested**
Do not commit unless the user explicitly asks. This session's user has asked to implement but has not asked for a commit for this task.
## Task 2: Wire pnpm Workspace Dependency
**Files:**
- Modify: `pnpm-workspace.yaml`
- Modify: `package.json`
- Modify: `pnpm-lock.yaml`
- [ ] **Step 1: Add workspace package path**
Modify `pnpm-workspace.yaml` to exactly:
```yaml
packages:
- .
- vendor/discord.js-selfbot-v13
- vendor/Discord-video-stream
onlyBuiltDependencies:
- '@discordjs/opus'
- better-sqlite3
- esbuild
```
- [ ] **Step 2: Add root dependency**
In `package.json`, add dependency under `dependencies`:
```json
"@dank074/discord-video-stream": "workspace:*"
```
Keep alphabetical-ish placement with scoped packages near the top, for example after `"@discordjs/voice"`.
- [ ] **Step 3: Install and update lockfile**
Run:
```bash
pnpm install
```
Expected: `pnpm-lock.yaml` updates and root dependency resolves to `link:vendor/Discord-video-stream`.
- [ ] **Step 4: Verify workspace resolution**
Run:
```bash
pnpm list @dank074/discord-video-stream --depth 0
```
Expected output includes:
```text
@dank074/discord-video-stream link:vendor/Discord-video-stream
```
## Task 3: Verify Project Health
**Files:**
- No new files unless fixes are required.
- [ ] **Step 1: Run typecheck**
Run:
```bash
pnpm run typecheck
```
Expected: PASS.
- [ ] **Step 2: Run tests**
Run:
```bash
pnpm run test
```
Expected: PASS.
- [ ] **Step 3: Run build**
Run:
```bash
pnpm run build
```
Expected: PASS.
- [ ] **Step 4: Inspect final status**
Run:
```bash
git status --short
git submodule status
```
Expected: root status shows `.gitmodules`, `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, and `vendor/Discord-video-stream` as changed/added. Submodule status includes `vendor/Discord-video-stream` at the checked-out commit.
## Self-Review
- Spec coverage: submodule creation is Task 1; workspace dependency wiring is Task 2; verification is Task 3.
- Placeholder scan: no TBD/TODO/fill-in steps remain.
- Type consistency: package name `@dank074/discord-video-stream`, path `vendor/Discord-video-stream`, and SSH URL are consistent across all tasks.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,713 @@
# Media YouTube and Spotify Resolver 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:** Extend media playback input so users can queue YouTube URLs, plain search queries, and Spotify track URLs that resolve to playable YouTube audio.
**Architecture:** Keep playback unchanged: `musicPlayer` still passes one resolved source to ffmpeg. Add resolver units that turn rich inputs into direct playable URLs before queueing: `play-dl` for YouTube search and Spotify metadata, `yt-dlp` wrapper for YouTube metadata/direct URL extraction when available. Spotify track support resolves metadata then searches YouTube; no Spotify playlist/album support in this phase.
**Tech Stack:** TypeScript, Vitest, Node `child_process`, `play-dl`, external `yt-dlp` command when installed, existing Express/media controller/music player.
---
## File Structure
- Modify `package.json` and `pnpm-lock.yaml` — add `play-dl` dependency.
- Modify `src/media/mediaTypes.ts` — extend `MediaSourceKind` with `youtube`, `spotify`, and `search`.
- Create `src/media/ytdlp.ts` — small wrapper around external `yt-dlp` for JSON metadata and direct audio URL extraction.
- Create `src/media/playDlResolver.ts` — wrapper around `play-dl` for YouTube search and Spotify track metadata.
- Modify `src/media/mediaResolver.ts` — compose local/direct URL/YouTube/search/Spotify resolution.
- Modify `public/index.html` — update input label/placeholder to mention YouTube, Spotify track, and search.
- Tests:
- `tests/media/ytdlp.test.ts`
- `tests/media/playDlResolver.test.ts`
- `tests/media/mediaResolver.test.ts`
---
### Task 1: Add play-dl and Media Source Kinds
**Files:**
- Modify: `package.json`
- Modify: `pnpm-lock.yaml`
- Modify: `src/media/mediaTypes.ts`
- Test: `tests/media/mediaResolver.test.ts`
- [ ] **Step 1: Write failing type expectation in resolver test**
Append to `tests/media/mediaResolver.test.ts`:
```ts
it("keeps direct URLs as generic URL sources", async () => {
await expect(
resolveMediaSource("https://cdn.example.com/song.mp3"),
).resolves.toMatchObject({
kind: "url",
source: "https://cdn.example.com/song.mp3",
});
});
```
This test should already pass before type changes; it protects existing behavior.
- [ ] **Step 2: Install play-dl**
Run:
```bash
pnpm -C /mnt/code/bete add play-dl
```
Expected: `package.json` contains `"play-dl"` in dependencies and `pnpm-lock.yaml` updates.
- [ ] **Step 3: Extend media source kinds**
Modify `src/media/mediaTypes.ts`:
```ts
export type MediaSourceKind = "url" | "local" | "youtube" | "spotify" | "search";
```
- [ ] **Step 4: Run protected resolver test and typecheck**
Run:
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/mediaResolver.test.ts
pnpm -C /mnt/code/bete run typecheck
```
Expected: PASS.
- [ ] **Step 5: Commit task 1**
```bash
git -C /mnt/code/bete add package.json pnpm-lock.yaml src/media/mediaTypes.ts tests/media/mediaResolver.test.ts
git -C /mnt/code/bete commit -m "feat: prepare media resolver source kinds"
```
---
### Task 2: yt-dlp Wrapper
**Files:**
- Create: `src/media/ytdlp.ts`
- Test: `tests/media/ytdlp.test.ts`
- [ ] **Step 1: Write failing yt-dlp tests**
Create `tests/media/ytdlp.test.ts`:
```ts
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { describe, expect, it, vi } from "vitest";
import { createYtDlp } from "../../src/media/ytdlp";
class FakeProcess extends EventEmitter {
stdout = new PassThrough();
stderr = new PassThrough();
}
describe("createYtDlp", () => {
it("reads YouTube metadata as JSON", async () => {
const proc = new FakeProcess();
const spawn = vi.fn(() => proc);
const ytdlp = createYtDlp({ spawn });
const result = ytdlp.getMetadata("https://youtu.be/video");
proc.stdout.write(JSON.stringify({ title: "Song Title", webpage_url: "https://youtube.com/watch?v=video" }));
proc.stdout.end();
proc.emit("close", 0);
await expect(result).resolves.toEqual({
title: "Song Title",
webpageUrl: "https://youtube.com/watch?v=video",
});
expect(spawn).toHaveBeenCalledWith("yt-dlp", [
"https://youtu.be/video",
"--dump-single-json",
"--no-playlist",
"--no-warnings",
"--quiet",
], { stdio: ["ignore", "pipe", "pipe"] });
});
it("reads direct audio URL", async () => {
const proc = new FakeProcess();
const ytdlp = createYtDlp({ spawn: vi.fn(() => proc) });
const result = ytdlp.getDirectAudioUrl("https://youtu.be/video");
proc.stdout.write("https://audio.example.com/stream\n");
proc.stdout.end();
proc.emit("close", 0);
await expect(result).resolves.toBe("https://audio.example.com/stream");
});
it("rejects when yt-dlp exits non-zero", async () => {
const proc = new FakeProcess();
const ytdlp = createYtDlp({ spawn: vi.fn(() => proc) });
const result = ytdlp.getMetadata("https://youtu.be/video");
proc.stderr.write("failed");
proc.stderr.end();
proc.emit("close", 1);
await expect(result).rejects.toThrow("yt-dlp failed with code 1");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/ytdlp.test.ts
```
Expected: FAIL because `src/media/ytdlp.ts` does not exist.
- [ ] **Step 3: Implement yt-dlp wrapper**
Create `src/media/ytdlp.ts`:
```ts
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { spawn as nodeSpawn } from "node:child_process";
export interface YtDlpMetadata {
title: string;
webpageUrl: string;
}
export interface YtDlpClient {
getMetadata(url: string): Promise<YtDlpMetadata>;
getDirectAudioUrl(url: string): Promise<string>;
}
export interface YtDlpDependencies {
spawn?: typeof nodeSpawn;
}
export function createYtDlp(dependencies: YtDlpDependencies = {}): YtDlpClient {
const spawn = dependencies.spawn ?? nodeSpawn;
return {
async getMetadata(url: string): Promise<YtDlpMetadata> {
const data = await runYtDlp(spawn, [
url,
"--dump-single-json",
"--no-playlist",
"--no-warnings",
"--quiet",
]);
const parsed = JSON.parse(data) as { title?: string; webpage_url?: string };
return {
title: parsed.title || url,
webpageUrl: parsed.webpage_url || url,
};
},
async getDirectAudioUrl(url: string): Promise<string> {
return runYtDlp(spawn, [
url,
"--get-url",
"--format",
"bestaudio[protocol^=http]/bestaudio/best",
"--no-playlist",
"--no-warnings",
"--quiet",
]).then((value) => value.trim().split("\n")[0] || url);
},
};
}
async function runYtDlp(
spawn: typeof nodeSpawn,
args: string[],
): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn("yt-dlp", args, {
stdio: ["ignore", "pipe", "pipe"],
}) as unknown as ChildProcessWithoutNullStreams;
let stdout = "";
let stderr = "";
proc.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
proc.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
proc.on("error", reject);
proc.on("close", (code) => {
if (code === 0) {
resolve(stdout);
return;
}
reject(new Error(`yt-dlp failed with code ${code}: ${stderr.trim()}`));
});
});
}
```
- [ ] **Step 4: Run yt-dlp tests and typecheck**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/ytdlp.test.ts
pnpm -C /mnt/code/bete run typecheck
```
Expected: PASS.
- [ ] **Step 5: Commit task 2**
```bash
git -C /mnt/code/bete add src/media/ytdlp.ts tests/media/ytdlp.test.ts
git -C /mnt/code/bete commit -m "feat: add yt-dlp media helper"
```
---
### Task 3: play-dl Resolver Wrapper
**Files:**
- Create: `src/media/playDlResolver.ts`
- Test: `tests/media/playDlResolver.test.ts`
- [ ] **Step 1: Write failing play-dl resolver tests**
Create `tests/media/playDlResolver.test.ts`:
```ts
import { describe, expect, it, vi } from "vitest";
import { createPlayDlResolver } from "../../src/media/playDlResolver";
describe("createPlayDlResolver", () => {
it("returns the first YouTube search result", async () => {
const resolver = createPlayDlResolver({
search: vi.fn(async () => [
{ title: "Song Result", url: "https://youtube.com/watch?v=abc" },
]),
spotify: vi.fn(),
});
await expect(resolver.searchYouTube("artist song")).resolves.toEqual({
title: "Song Result",
url: "https://youtube.com/watch?v=abc",
});
});
it("turns Spotify track metadata into a YouTube search query", async () => {
const resolver = createPlayDlResolver({
search: vi.fn(async () => [
{ title: "Artist - Track", url: "https://youtube.com/watch?v=track" },
]),
spotify: vi.fn(async () => ({
type: "track",
name: "Track",
artists: [{ name: "Artist" }],
})),
});
await expect(
resolver.resolveSpotifyTrack("https://open.spotify.com/track/123"),
).resolves.toEqual({
title: "Artist - Track",
url: "https://youtube.com/watch?v=track",
});
});
it("rejects Spotify playlists in this phase", async () => {
const resolver = createPlayDlResolver({
search: vi.fn(),
spotify: vi.fn(async () => ({ type: "playlist", name: "Playlist" })),
});
await expect(
resolver.resolveSpotifyTrack("https://open.spotify.com/playlist/123"),
).rejects.toThrow("Only Spotify track URLs are supported");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/playDlResolver.test.ts
```
Expected: FAIL because `src/media/playDlResolver.ts` does not exist.
- [ ] **Step 3: Implement play-dl wrapper**
Create `src/media/playDlResolver.ts`:
```ts
import play from "play-dl";
export interface PlayDlResult {
title: string;
url: string;
}
interface PlayDlSearchResult {
title?: string;
url?: string;
}
interface SpotifyTrackLike {
type?: string;
name?: string;
artists?: Array<{ name?: string }>;
}
export interface PlayDlDependencies {
search?: (query: string, options: { limit: number }) => Promise<PlayDlSearchResult[]>;
spotify?: (url: string) => Promise<SpotifyTrackLike>;
}
export function createPlayDlResolver(dependencies: PlayDlDependencies = {}) {
const search = dependencies.search ?? play.search;
const spotify = dependencies.spotify ?? play.spotify;
return {
async searchYouTube(query: string): Promise<PlayDlResult> {
const results = await search(query, { limit: 1 });
const first = results[0];
if (!first?.url) throw new Error(`No YouTube result found for ${query}`);
return {
title: first.title || query,
url: first.url,
};
},
async resolveSpotifyTrack(url: string): Promise<PlayDlResult> {
const track = await spotify(url);
if (track.type !== "track") {
throw new Error("Only Spotify track URLs are supported");
}
const artists = (track.artists || [])
.map((artist) => artist.name)
.filter(Boolean)
.join(" ");
const query = `${artists} ${track.name || ""} audio`.trim();
return this.searchYouTube(query);
},
};
}
```
- [ ] **Step 4: Run play-dl tests and typecheck**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/playDlResolver.test.ts
pnpm -C /mnt/code/bete run typecheck
```
Expected: PASS.
- [ ] **Step 5: Commit task 3**
```bash
git -C /mnt/code/bete add src/media/playDlResolver.ts tests/media/playDlResolver.test.ts
git -C /mnt/code/bete commit -m "feat: add play-dl search resolver"
```
---
### Task 4: Compose Resolver for YouTube, Search, and Spotify Track
**Files:**
- Modify: `src/media/mediaResolver.ts`
- Test: `tests/media/mediaResolver.test.ts`
- [ ] **Step 1: Write failing composed resolver tests**
Append to `tests/media/mediaResolver.test.ts`:
```ts
import { createMediaResolver } from "../../src/media/mediaResolver";
// Add inside describe("resolveMediaSource", ...):
it("resolves YouTube URLs with yt-dlp metadata", async () => {
const resolver = createMediaResolver({
ytdlp: {
getMetadata: vi.fn(async () => ({
title: "YouTube Song",
webpageUrl: "https://youtube.com/watch?v=abc",
})),
getDirectAudioUrl: vi.fn(async () => "https://audio.example.com/abc"),
},
playDlResolver: {
searchYouTube: vi.fn(),
resolveSpotifyTrack: vi.fn(),
},
});
await expect(resolver("https://youtu.be/abc")).resolves.toEqual({
source: "https://audio.example.com/abc",
title: "YouTube Song",
kind: "youtube",
});
});
it("resolves search queries to YouTube results", async () => {
const resolver = createMediaResolver({
ytdlp: {
getMetadata: vi.fn(),
getDirectAudioUrl: vi.fn(async () => "https://audio.example.com/search"),
},
playDlResolver: {
searchYouTube: vi.fn(async () => ({
title: "Search Result",
url: "https://youtube.com/watch?v=search",
})),
resolveSpotifyTrack: vi.fn(),
},
});
await expect(resolver("artist song")).resolves.toEqual({
source: "https://audio.example.com/search",
title: "Search Result",
kind: "search",
});
});
it("resolves Spotify track URLs through YouTube search", async () => {
const resolver = createMediaResolver({
ytdlp: {
getMetadata: vi.fn(),
getDirectAudioUrl: vi.fn(async () => "https://audio.example.com/spotify"),
},
playDlResolver: {
searchYouTube: vi.fn(),
resolveSpotifyTrack: vi.fn(async () => ({
title: "Spotify Match",
url: "https://youtube.com/watch?v=spotify",
})),
},
});
await expect(
resolver("https://open.spotify.com/track/123"),
).resolves.toEqual({
source: "https://audio.example.com/spotify",
title: "Spotify Match",
kind: "spotify",
});
});
```
Also update imports at the top:
```ts
import { describe, expect, it, vi } from "vitest";
import { createMediaResolver, resolveMediaSource } from "../../src/media/mediaResolver";
```
- [ ] **Step 2: Run test to verify it fails**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/mediaResolver.test.ts
```
Expected: FAIL because `createMediaResolver` does not exist.
- [ ] **Step 3: Implement composed resolver**
Modify `src/media/mediaResolver.ts` to export `createMediaResolver()` and keep `resolveMediaSource` as the default instance:
```ts
import { existsSync, statSync } from "node:fs";
import path from "node:path";
import { AppError } from "../errors";
import { createPlayDlResolver } from "./playDlResolver";
import type { ResolvedMediaSource } from "./mediaTypes";
import { createYtDlp, type YtDlpClient } from "./ytdlp";
type PlayDlResolver = ReturnType<typeof createPlayDlResolver>;
export interface MediaResolverDependencies {
ytdlp?: YtDlpClient;
playDlResolver?: PlayDlResolver;
}
export function createMediaResolver(
dependencies: MediaResolverDependencies = {},
) {
const ytdlp = dependencies.ytdlp ?? createYtDlp();
const playDlResolver = dependencies.playDlResolver ?? createPlayDlResolver();
return async function resolve(input: string): Promise<ResolvedMediaSource> {
const source = input.trim();
if (!source) {
throw new AppError("Media source is required", "MISSING_MEDIA_SOURCE", 400);
}
const url = parseUrl(source);
if (url && isYouTubeUrl(url)) {
const metadata = await ytdlp.getMetadata(source);
const directUrl = await ytdlp.getDirectAudioUrl(source);
return { source: directUrl, title: metadata.title, kind: "youtube" };
}
if (url && isSpotifyTrackUrl(url)) {
const result = await playDlResolver.resolveSpotifyTrack(source);
const directUrl = await ytdlp.getDirectAudioUrl(result.url);
return { source: directUrl, title: result.title, kind: "spotify" };
}
const urlSource = resolveUrlSource(source);
if (urlSource) return urlSource;
const localPath = path.resolve(source);
if (existsSync(localPath) && statSync(localPath).isFile()) {
return {
source: localPath,
title: path.basename(localPath),
kind: "local",
};
}
if (!url) {
const result = await playDlResolver.searchYouTube(source);
const directUrl = await ytdlp.getDirectAudioUrl(result.url);
return { source: directUrl, title: result.title, kind: "search" };
}
throw new AppError(
"Media source must be an HTTP(S) URL, YouTube URL, Spotify track URL, search query, or existing local file",
"UNSUPPORTED_MEDIA_SOURCE",
400,
);
};
}
export const resolveMediaSource = createMediaResolver();
function parseUrl(source: string): URL | null {
try {
return new URL(source);
} catch {
return null;
}
}
function isYouTubeUrl(url: URL): boolean {
return ["youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be"].includes(
url.hostname,
);
}
function isSpotifyTrackUrl(url: URL): boolean {
return url.hostname === "open.spotify.com" && url.pathname.startsWith("/track/");
}
function resolveUrlSource(source: string): ResolvedMediaSource | null {
const url = parseUrl(source);
if (!url) return null;
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
return {
source,
title: titleFromUrl(url),
kind: "url",
};
}
function titleFromUrl(url: URL): string {
const filename = decodeURIComponent(url.pathname.split("/").pop() || "");
return path.basename(filename) || url.hostname;
}
```
- [ ] **Step 4: Run resolver tests and typecheck**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/mediaResolver.test.ts
pnpm -C /mnt/code/bete run typecheck
```
Expected: PASS.
- [ ] **Step 5: Commit task 4**
```bash
git -C /mnt/code/bete add src/media/mediaResolver.ts tests/media/mediaResolver.test.ts
git -C /mnt/code/bete commit -m "feat: resolve youtube search and spotify media"
```
---
### Task 5: Dashboard Copy and Full Verification
**Files:**
- Modify: `public/index.html`
- [ ] **Step 1: Update media input copy**
Change the media input label and placeholder in `public/index.html` from:
```html
<label for="mediaSourceInput">Music URL / file path</label>
<input id="mediaSourceInput" type="text" placeholder="https://example.com/song.mp3">
```
to:
```html
<label for="mediaSourceInput">Music URL, YouTube, Spotify track, search, or file path</label>
<input id="mediaSourceInput" type="text" placeholder="YouTube URL, Spotify track, or search terms">
```
- [ ] **Step 2: Run full verification**
```bash
pnpm -C /mnt/code/bete run test
pnpm -C /mnt/code/bete run typecheck
pnpm -C /mnt/code/bete run lint
```
Expected: PASS.
- [ ] **Step 3: Manual verification**
Run:
```bash
pnpm -C /mnt/code/bete run dev
```
Manual checks:
1. Queue a direct MP3 URL: still plays.
2. Queue a local file path: still plays.
3. Queue a YouTube URL: resolves title and plays audio.
4. Queue plain search terms: resolves first YouTube result and plays audio.
5. Queue a Spotify track URL: resolves Spotify metadata, searches YouTube, and plays audio.
6. Queue a Spotify playlist URL: returns a clear unsupported error.
- [ ] **Step 4: Commit task 5**
```bash
git -C /mnt/code/bete add public/index.html
git -C /mnt/code/bete commit -m "feat: update media input guidance"
```
---
## Self-Review
Spec coverage:
- YouTube URL support: Task 2 + Task 4.
- Search query support: Task 3 + Task 4.
- Spotify track URL to YouTube support: Task 3 + Task 4.
- No Spotify playlist/album support: Task 3 explicitly rejects non-track Spotify types, Task 5 manual check covers playlist error.
- Dashboard copy: Task 5.
- Existing direct URL/local file behavior protected: Task 1 + existing tests.
Placeholder scan: no placeholders, TODOs, or vague test instructions remain.
Type consistency: `MediaSourceKind` includes `youtube`, `spotify`, and `search`; resolver returns those exact values; tests assert those values.

View File

@@ -0,0 +1,663 @@
# Selfbot Performance Feature Optimization 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:** Optimize the app's selfbot client runtime and vendor internals for lower memory pressure, safer REST retries, reduced voice cleanup leaks, faster gateway queue processing, and lightweight observability.
**Architecture:** Start with app-level client options because they are low-risk and immediately reduce cache pressure. Then patch vendor internals in isolated areas: REST manager/request handling, voice packet cleanup, and WebSocket shard queueing. Keep public imports and runtime APIs compatible with `discord.js-selfbot-v13` consumers.
**Tech Stack:** Node.js, TypeScript, CommonJS vendor package, discord.js-selfbot-v13 workspace dependency, Undici, Vitest, Biome, TypeScript.
---
## File Structure
- Modify `src/index.ts`: instantiate `Client` with low-memory cache/sweeper/REST options.
- Modify `vendor/discord.js-selfbot-v13/src/rest/RESTManager.js`: own per-client dispatcher state and super-properties cache helpers.
- Modify `vendor/discord.js-selfbot-v13/src/rest/APIRequest.js`: use per-client dispatcher and cached `x-super-properties` header.
- Modify `vendor/discord.js-selfbot-v13/src/rest/RequestHandler.js`: add backoff/jitter helper and debug telemetry for retry attempts.
- Modify `vendor/discord.js-selfbot-v13/src/client/voice/receiver/PacketHandler.js`: clear speaking timers and reduce RTP parse allocations.
- Modify `vendor/discord.js-selfbot-v13/src/client/websocket/WebSocketShard.js`: use cursor-backed gateway queue with compatible priority insertion and destroy cleanup.
- Create `tests/vendor/selfbotClientOptions.test.ts`: verify app client options factory if extracted.
- Create `tests/vendor/requestHandlerBackoff.test.ts`: verify retry delay calculation is bounded and grows.
- Create `tests/vendor/websocketQueue.test.ts`: verify FIFO, priority, and destroy queue reset semantics for the new queue helpers if exported/testable.
## Task 1: Extract and Test Low-Memory Client Options
**Files:**
- Create: `src/discordClientOptions.ts`
- Modify: `src/index.ts:4-25`
- Test: `tests/vendor/selfbotClientOptions.test.ts`
- [ ] **Step 1: Write the failing test**
Create `tests/vendor/selfbotClientOptions.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { createDiscordClientOptions } from "../../src/discordClientOptions";
describe("createDiscordClientOptions", () => {
it("uses low-memory message cache and active sweepers", () => {
const options = createDiscordClientOptions();
expect(options.restRequestTimeout).toBe(15_000);
expect(options.retryLimit).toBe(2);
expect(options.restGlobalRateLimit).toBe(45);
expect(options.sweepers).toEqual({
messages: { interval: 300, lifetime: 600 },
threads: { interval: 3600, lifetime: 14400 },
});
expect(options.partials).toEqual(["USER", "CHANNEL", "GUILD_MEMBER", "MESSAGE"]);
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `pnpm exec vitest run tests/vendor/selfbotClientOptions.test.ts`
Expected: FAIL with module not found for `src/discordClientOptions`.
- [ ] **Step 3: Add the client options factory**
Create `src/discordClientOptions.ts`:
```ts
import { Options } from "discord.js-selfbot-v13";
export function createDiscordClientOptions() {
return {
makeCache: Options.cacheWithLimits({
...Options.defaultMakeCacheSettings,
MessageManager: 25,
ReactionManager: 0,
ReactionUserManager: 0,
PresenceManager: 0,
}),
partials: ["USER", "CHANNEL", "GUILD_MEMBER", "MESSAGE"],
sweepers: {
messages: { interval: 300, lifetime: 600 },
threads: { interval: 3600, lifetime: 14400 },
},
restRequestTimeout: 15_000,
retryLimit: 2,
restGlobalRateLimit: 45,
};
}
```
- [ ] **Step 4: Use the factory in the app entry point**
Modify `src/index.ts`:
```ts
import { Client } from "discord.js-selfbot-v13";
import { config } from "./config";
import { closeDatabase, initializeDatabase } from "./database/drizzle";
import { createDiscordClientOptions } from "./discordClientOptions";
```
Replace:
```ts
const client = new Client();
```
with:
```ts
const client = new Client(createDiscordClientOptions());
```
- [ ] **Step 5: Run the focused test**
Run: `pnpm exec vitest run tests/vendor/selfbotClientOptions.test.ts`
Expected: PASS.
- [ ] **Step 6: Run typecheck**
Run: `pnpm run typecheck`
Expected: PASS. If TypeScript cannot type `Options` from the vendor package, add a local return type only if necessary; do not weaken the factory to `any`.
- [ ] **Step 7: Commit**
Run:
```bash
git add src/discordClientOptions.ts src/index.ts tests/vendor/selfbotClientOptions.test.ts
git commit -m "perf: tune selfbot client runtime options"
```
## Task 2: Add Per-Client REST Dispatcher and Cached Super Properties
**Files:**
- Modify: `vendor/discord.js-selfbot-v13/src/rest/RESTManager.js:1-69`
- Modify: `vendor/discord.js-selfbot-v13/src/rest/APIRequest.js:1-166`
- [ ] **Step 1: Add REST manager state**
Modify `vendor/discord.js-selfbot-v13/src/rest/RESTManager.js` imports:
```js
const { Collection } = require('@discordjs/collection');
const makeFetchCookie = require('fetch-cookie');
const { CookieJar } = require('tough-cookie');
const { buildConnector, Client: UndiciClient, ProxyAgent, fetch: fetchOriginal } = require('undici');
const APIRequest = require('./APIRequest');
const routeBuilder = require('./APIRouter');
const RequestHandler = require('./RequestHandler');
const { Error } = require('../errors');
const { ciphers } = require('../util/Constants');
const { Endpoints } = require('../util/Constants');
const Util = require('../util/Util');
```
- [ ] **Step 2: Add per-client dispatcher fields and helper methods**
Inside `RESTManager` constructor after `this.fetch = ...`, add:
```js
this.dispatcher = null;
this.superPropertiesSource = null;
this.superPropertiesHeader = null;
```
Add methods before `request(method, url, options = {})`:
```js
getDispatcher() {
if (this.dispatcher) return this.dispatcher;
const proxy = Util.checkUndiciProxyAgent(this.client.options.http.agent);
if (proxy) {
this.dispatcher = new ProxyAgent({
...proxy,
ciphers: ciphers.join(':'),
});
} else {
this.dispatcher = new UndiciClient('https://discord.com', {
connect: buildConnector({ ciphers: ciphers.join(':') }),
});
}
return this.dispatcher;
}
getSuperPropertiesHeader() {
const source = JSON.stringify(this.client.options.ws.properties);
if (source !== this.superPropertiesSource) {
this.superPropertiesSource = source;
this.superPropertiesHeader = Buffer.from(source, 'ascii').toString('base64');
}
return this.superPropertiesHeader;
}
```
- [ ] **Step 3: Remove module-global dispatcher from APIRequest**
Modify `vendor/discord.js-selfbot-v13/src/rest/APIRequest.js` imports to:
```js
const Buffer = require('node:buffer').Buffer;
const { setTimeout } = require('node:timers');
const { FormData } = require('undici');
```
Remove:
```js
const { FormData, buildConnector, Client, ProxyAgent } = require('undici');
const { ciphers } = require('../util/Constants');
const Util = require('../util/Util');
let agent = null;
```
- [ ] **Step 4: Use REST manager dispatcher and cached header**
In `APIRequest.make`, delete the `if (!agent) { ... }` block.
Replace the `x-super-properties` header construction with:
```js
'x-super-properties': this.rest.getSuperPropertiesHeader(),
```
Replace fetch dispatcher:
```js
dispatcher: agent,
```
with:
```js
dispatcher: this.rest.getDispatcher(),
```
- [ ] **Step 5: Run vendor lint through root lint**
Run: `pnpm run lint`
Expected: PASS or existing unrelated lint failures. If failures are in edited vendor files, fix them.
- [ ] **Step 6: Commit**
Run:
```bash
git add vendor/discord.js-selfbot-v13/src/rest/RESTManager.js vendor/discord.js-selfbot-v13/src/rest/APIRequest.js
git commit -m "perf: cache selfbot rest dispatcher metadata"
```
## Task 3: Add REST Retry Backoff With Jitter
**Files:**
- Modify: `vendor/discord.js-selfbot-v13/src/rest/RequestHandler.js:1-505`
- Test: `tests/vendor/requestHandlerBackoff.test.ts`
- [ ] **Step 1: Export a pure backoff helper for tests**
Add near the top of `vendor/discord.js-selfbot-v13/src/rest/RequestHandler.js` after `calculateReset`:
```js
function calculateRetryDelay(retryCount, random = Math.random) {
const base = 250;
const max = 5_000;
const exponential = Math.min(max, base * 2 ** Math.max(0, retryCount - 1));
return exponential + Math.floor(random() * base);
}
```
At the bottom, replace:
```js
module.exports = RequestHandler;
```
with:
```js
module.exports = RequestHandler;
module.exports.calculateRetryDelay = calculateRetryDelay;
```
- [ ] **Step 2: Write the focused helper test**
Create `tests/vendor/requestHandlerBackoff.test.ts`:
```ts
import { describe, expect, it } from "vitest";
const { calculateRetryDelay } = await import(
"../../vendor/discord.js-selfbot-v13/src/rest/RequestHandler.js"
);
describe("calculateRetryDelay", () => {
it("increases exponentially and applies bounded jitter", () => {
expect(calculateRetryDelay(1, () => 0)).toBe(250);
expect(calculateRetryDelay(2, () => 0)).toBe(500);
expect(calculateRetryDelay(3, () => 0)).toBe(1000);
expect(calculateRetryDelay(10, () => 0)).toBe(5000);
expect(calculateRetryDelay(1, () => 0.999)).toBe(499);
});
});
```
- [ ] **Step 3: Run the helper test**
Run: `pnpm exec vitest run tests/vendor/requestHandlerBackoff.test.ts`
Expected: PASS.
- [ ] **Step 4: Apply backoff to network errors**
In `RequestHandler.execute`, replace the catch block after `request.make(...)` with:
```js
} catch (error) {
if (request.retries === this.manager.client.options.retryLimit) {
throw new HTTPError(
error.message,
error.constructor.name,
error.status,
request,
);
}
request.retries++;
const delay = calculateRetryDelay(request.retries);
this.manager.client.emit(
DEBUG,
`[Request Handler] Retrying failed request after ${delay}ms.\n Method : ${request.method}\n Path : ${request.path}\n Route : ${request.route}\n Retry : ${request.retries}`,
);
await sleep(delay);
return this.execute(request);
}
```
- [ ] **Step 5: Apply backoff to 5xx responses**
In the 5xx block, replace:
```js
request.retries++;
return this.execute(request);
```
with:
```js
request.retries++;
const delay = calculateRetryDelay(request.retries);
this.manager.client.emit(
DEBUG,
`[Request Handler] Retrying server error after ${delay}ms.\n Method : ${request.method}\n Path : ${request.path}\n Route : ${request.route}\n Status : ${res.status}\n Retry : ${request.retries}`,
);
await sleep(delay);
return this.execute(request);
```
- [ ] **Step 6: Run focused and full tests**
Run: `pnpm exec vitest run tests/vendor/requestHandlerBackoff.test.ts`
Expected: PASS.
Run: `pnpm run test`
Expected: PASS.
- [ ] **Step 7: Commit**
Run:
```bash
git add vendor/discord.js-selfbot-v13/src/rest/RequestHandler.js tests/vendor/requestHandlerBackoff.test.ts
git commit -m "perf: back off selfbot rest retries"
```
## Task 4: Clean Voice Receiver Timers and Reduce RTP Buffer Work
**Files:**
- Modify: `vendor/discord.js-selfbot-v13/src/client/voice/receiver/PacketHandler.js:1-280`
- [ ] **Step 1: Patch AES decrypt concat allocation**
In `parseBuffer`, replace:
```js
packet = Buffer.concat([
decipheriv.update(encrypted),
decipheriv.final(),
]);
```
with:
```js
const updated = decipheriv.update(encrypted);
const final = decipheriv.final();
packet = final.length === 0 ? updated : Buffer.concat([updated, final]);
```
- [ ] **Step 2: Patch XChaCha auth tag concat allocation**
Replace:
```js
Buffer.concat([encrypted, authTag]),
```
with:
```js
buffer.subarray(headerSize, buffer.length - UNPADDED_NONCE_LENGTH),
```
- [ ] **Step 3: Add speaking timeout cleanup**
In `destroyAllStream()`, after clearing video streams, add:
```js
for (const timeout of this.speakingTimeouts.values()) {
clearTimeout(timeout);
}
const clearedSpeakingTimeouts = this.speakingTimeouts.size;
this.speakingTimeouts.clear();
this.emit('debug', {
message: 'Destroyed voice receiver streams',
audioStreams: this.streams.size,
videoStreams: this.videoStreams.size,
speakingTimeouts: clearedSpeakingTimeouts,
});
```
Then adjust ordering so the counts are captured before `streams.clear()` and `videoStreams.clear()`:
```js
destroyAllStream() {
const audioStreams = this.streams.size;
const videoStreams = this.videoStreams.size;
for (const stream of this.streams.values()) {
stream.stream.destroy();
}
this.streams.clear();
for (const stream of this.videoStreams.values()) {
stream.destroy();
}
this.videoStreams.clear();
for (const timeout of this.speakingTimeouts.values()) {
clearTimeout(timeout);
}
const speakingTimeouts = this.speakingTimeouts.size;
this.speakingTimeouts.clear();
this.emit('debug', {
message: 'Destroyed voice receiver streams',
audioStreams,
videoStreams,
speakingTimeouts,
});
}
```
- [ ] **Step 4: Run lint**
Run: `pnpm run lint`
Expected: PASS or only unrelated existing failures. Fix edited-file failures.
- [ ] **Step 5: Run tests**
Run: `pnpm run test`
Expected: PASS.
- [ ] **Step 6: Commit**
Run:
```bash
git add vendor/discord.js-selfbot-v13/src/client/voice/receiver/PacketHandler.js
git commit -m "perf: clean up selfbot voice receiver state"
```
## Task 5: Replace Gateway Queue Shift With Cursor Queue
**Files:**
- Modify: `vendor/discord.js-selfbot-v13/src/client/websocket/WebSocketShard.js:108-120,818-954`
- [ ] **Step 1: Add queue cursor metadata**
In the `ratelimit` object, change:
```js
queue: [],
```
To:
```js
queue: [],
queueOffset: 0,
```
- [ ] **Step 2: Update priority insertion**
Replace `send(data, important = false)` with:
```js
send(data, important = false) {
if (important) {
if (this.ratelimit.queueOffset === 0) {
this.ratelimit.queue.unshift(data);
} else {
this.ratelimit.queue[--this.ratelimit.queueOffset] = data;
}
} else {
this.ratelimit.queue.push(data);
}
this.processQueue();
}
```
- [ ] **Step 3: Update queue processing**
Replace `processQueue()` with:
```js
processQueue() {
if (this.ratelimit.remaining === 0) return;
if (this.ratelimit.queueOffset >= this.ratelimit.queue.length) return;
if (this.ratelimit.remaining === this.ratelimit.total) {
this.ratelimit.timer = setTimeout(() => {
this.ratelimit.remaining = this.ratelimit.total;
this.processQueue();
}, this.ratelimit.time).unref();
}
while (this.ratelimit.remaining > 0) {
const item = this.ratelimit.queue[this.ratelimit.queueOffset++];
if (!item) {
this._compactQueue();
return;
}
this._send(item);
this.ratelimit.remaining--;
}
this._compactQueue();
}
```
- [ ] **Step 4: Add queue compaction helper**
Add before `destroy(...)`:
```js
_compactQueue() {
if (this.ratelimit.queueOffset === 0) return;
if (this.ratelimit.queueOffset >= this.ratelimit.queue.length) {
this.ratelimit.queue.length = 0;
this.ratelimit.queueOffset = 0;
return;
}
if (this.ratelimit.queueOffset > 512) {
this.ratelimit.queue = this.ratelimit.queue.slice(this.ratelimit.queueOffset);
this.ratelimit.queueOffset = 0;
}
}
```
- [ ] **Step 5: Reset cursor on destroy**
In `destroy`, after:
```js
this.ratelimit.queue.length = 0;
```
Add:
```js
this.ratelimit.queueOffset = 0;
```
- [ ] **Step 6: Run lint and tests**
Run: `pnpm run lint`
Expected: PASS or only unrelated existing failures. Fix edited-file failures.
Run: `pnpm run test`
Expected: PASS.
- [ ] **Step 7: Commit**
Run:
```bash
git add vendor/discord.js-selfbot-v13/src/client/websocket/WebSocketShard.js
git commit -m "perf: optimize selfbot gateway send queue"
```
## Task 6: Final Verification and Manual Runtime Notes
**Files:**
- Modify only if verification exposes issues.
- [ ] **Step 1: Run full lint**
Run: `pnpm run lint`
Expected: PASS.
- [ ] **Step 2: Run full typecheck**
Run: `pnpm run typecheck`
Expected: PASS.
- [ ] **Step 3: Run full tests**
Run: `pnpm run test`
Expected: PASS.
- [ ] **Step 4: Run build**
Run: `pnpm run build`
Expected: PASS.
- [ ] **Step 5: Inspect git diff**
Run: `git diff --stat HEAD~5..HEAD` if each task was committed, or `git diff --stat` if not.
Expected: changes limited to app client options, vendor REST, vendor voice, vendor WebSocket, tests, and this plan/spec.
- [ ] **Step 6: Record manual Discord runtime limitation**
If no Discord token/runtime environment is available, final response must state:
```text
Automated verification passed. I could not perform live Discord runtime verification in this environment. Manual checks still needed: login, message capture, backlog sync, voice connect, voice record, disconnect, reconnect.
```
- [ ] **Step 7: Commit verification fixes only if needed**
If Step 1-4 required fixes, commit only those fixes:
```bash
git add <fixed-files>
git commit -m "fix: stabilize selfbot optimization verification"
```
## Self-Review
- Spec coverage: app runtime config is Task 1; REST dispatcher/header/backoff is Tasks 2-3; voice cleanup/allocation is Task 4; gateway queue is Task 5; verification/manual runtime note is Task 6.
- Placeholder scan: no TBD/TODO/fill-in steps remain; each code step includes concrete snippets and paths.
- Type consistency: `createDiscordClientOptions`, `calculateRetryDelay`, `getDispatcher`, `getSuperPropertiesHeader`, `_compactQueue`, and `queueOffset` are introduced before use and named consistently.

View File

@@ -0,0 +1,239 @@
# Selfbot Workspace Submodule 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:** Replace the npm `discord.js-selfbot-v13` dependency with a custom git submodule consumed through pnpm workspace resolution.
**Architecture:** The vendored selfbot library lives at `vendor/discord.js-selfbot-v13` as a git submodule. The root package depends on it with `workspace:*`, and `pnpm-workspace.yaml` includes both the root package and the vendored package while preserving the existing `onlyBuiltDependencies` settings.
**Tech Stack:** Git submodules, pnpm workspaces, TypeScript, existing Node.js package scripts.
---
## File Structure
- Create: `.gitmodules` if missing; otherwise modify it to include `vendor/discord.js-selfbot-v13`.
- Create: `vendor/discord.js-selfbot-v13` via `git submodule add`; do not create files in this directory manually.
- Modify: `pnpm-workspace.yaml` to add `packages` while preserving `onlyBuiltDependencies`.
- Modify: `package.json` dependency `discord.js-selfbot-v13` from `^3.7.1` to `workspace:*`.
- Modify: `pnpm-lock.yaml` by running pnpm, not by hand.
### Task 1: Add the selfbot repository as a submodule
**Files:**
- Create/Modify: `.gitmodules`
- Create: `vendor/discord.js-selfbot-v13`
- [ ] **Step 1: Confirm there is no existing submodule path**
Run:
```bash
git submodule status --recursive || true
test ! -e vendor/discord.js-selfbot-v13
```
Expected: either no existing submodule output, or output that does not include `vendor/discord.js-selfbot-v13`; the `test` command exits successfully.
- [ ] **Step 2: Add the upstream repository as a submodule**
Run:
```bash
git submodule add https://github.com/aiko-chan-ai/discord.js-selfbot-v13.git vendor/discord.js-selfbot-v13
```
Expected: git clones the repository into `vendor/discord.js-selfbot-v13` and creates or updates `.gitmodules`.
- [ ] **Step 3: Change the submodule remote to the internal SSH repository**
Run:
```bash
git -C vendor/discord.js-selfbot-v13 remote set-url origin ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git
git config -f .gitmodules submodule.vendor/discord.js-selfbot-v13.url ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git
git submodule sync vendor/discord.js-selfbot-v13
```
Expected: both the submodule checkout and `.gitmodules` use `ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git`.
- [ ] **Step 4: Verify submodule metadata**
Run:
```bash
git -C vendor/discord.js-selfbot-v13 remote get-url origin
git config -f .gitmodules --get submodule.vendor/discord.js-selfbot-v13.path
git config -f .gitmodules --get submodule.vendor/discord.js-selfbot-v13.url
```
Expected output:
```text
ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git
vendor/discord.js-selfbot-v13
ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git
```
### Task 2: Configure pnpm workspace resolution
**Files:**
- Modify: `pnpm-workspace.yaml`
- Modify: `package.json`
- [ ] **Step 1: Update pnpm workspace file**
Edit `pnpm-workspace.yaml` to exactly:
```yaml
packages:
- .
- vendor/discord.js-selfbot-v13
onlyBuiltDependencies:
- '@discordjs/opus'
- better-sqlite3
- esbuild
```
Expected: the existing `onlyBuiltDependencies` entries remain unchanged, and workspace packages now include root plus the vendored selfbot package.
- [ ] **Step 2: Update root dependency**
Edit `package.json` so the dependencies block contains:
```json
"discord.js-selfbot-v13": "workspace:*"
```
Expected: only the `discord.js-selfbot-v13` version source changes; the rest of `package.json` remains unchanged.
- [ ] **Step 3: Verify the submodule package name**
Run:
```bash
node -e "const p=require('./vendor/discord.js-selfbot-v13/package.json'); if (p.name !== 'discord.js-selfbot-v13') { throw new Error('unexpected package name: '+p.name) } console.log(p.name)"
```
Expected output:
```text
discord.js-selfbot-v13
```
### Task 3: Refresh dependency lockfile and install links
**Files:**
- Modify: `pnpm-lock.yaml`
- Modify: `node_modules` locally, not committed
- [ ] **Step 1: Refresh pnpm install state**
Run:
```bash
pnpm install
```
Expected: pnpm completes successfully and updates `pnpm-lock.yaml` so `discord.js-selfbot-v13` resolves from `link:vendor/discord.js-selfbot-v13` or equivalent workspace link notation.
- [ ] **Step 2: Verify pnpm resolves the workspace package**
Run:
```bash
pnpm list discord.js-selfbot-v13 --depth 0
```
Expected: output shows `discord.js-selfbot-v13` as a linked workspace dependency rather than the npm registry version.
- [ ] **Step 3: Inspect the lockfile entry**
Run:
```bash
grep -n "discord.js-selfbot-v13" pnpm-lock.yaml | head -20
```
Expected: the root importer entry for `discord.js-selfbot-v13` references `specifier: workspace:*` and a workspace/link version.
### Task 4: Validate root project compatibility
**Files:**
- Read-only validation for TypeScript project files.
- [ ] **Step 1: Run TypeScript validation**
Run:
```bash
pnpm run typecheck
```
Expected: command exits successfully.
- [ ] **Step 2: If typecheck fails because the submodule package is unbuilt, build the submodule**
Run only if Step 1 fails with missing compiled files or missing package entrypoint errors from `vendor/discord.js-selfbot-v13`:
```bash
pnpm --filter discord.js-selfbot-v13 install
npnpm --filter discord.js-selfbot-v13 run build
pnpm run typecheck
```
Expected: submodule package builds successfully and root typecheck passes.
If the package has no `build` script, inspect `vendor/discord.js-selfbot-v13/package.json` scripts and use the package's documented compile script, then rerun `pnpm run typecheck`.
- [ ] **Step 3: Run lint if typecheck passes**
Run:
```bash
pnpm run lint
```
Expected: command exits successfully or reports only pre-existing issues unrelated to `.gitmodules`, `package.json`, `pnpm-workspace.yaml`, or `pnpm-lock.yaml`.
### Task 5: Review git diff and prepare handoff
**Files:**
- Review: `.gitmodules`
- Review: `package.json`
- Review: `pnpm-workspace.yaml`
- Review: `pnpm-lock.yaml`
- Review: `vendor/discord.js-selfbot-v13` gitlink
- [ ] **Step 1: Review changed files**
Run:
```bash
git status --short
git diff -- .gitmodules package.json pnpm-workspace.yaml pnpm-lock.yaml
git diff --submodule
```
Expected: changes are limited to the design spec, plan, submodule metadata/gitlink, pnpm workspace config, root dependency, and lockfile. Existing unrelated `README.md` modifications remain untouched.
- [ ] **Step 2: Summarize validation evidence**
Record these command outcomes in the final response:
```text
pnpm install: PASS or FAIL with error summary
pnpm run typecheck: PASS or FAIL with error summary
pnpm run lint: PASS, FAIL with error summary, or NOT RUN with reason
```
- [ ] **Step 3: Do not commit unless explicitly asked**
No commit command should run unless the user explicitly asks for a commit. If the user asks, use the repository commit workflow and stage only relevant files.
## Self-Review
- Spec coverage: the plan covers submodule creation, remote replacement, workspace config, dependency rewrite, lockfile refresh, and validation.
- Placeholder scan: no TBD/TODO placeholders remain.
- Type consistency: package path, dependency name, and remote URL are consistent across tasks.

View File

@@ -0,0 +1,466 @@
# Split Text Voice Selection 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:** Separate text moderation guild/channel selection from voice recording guild/channel selection in config, backend state, and dashboard UI.
**Architecture:** Add explicit text and voice config keys while keeping legacy `MONITOR_GUILD_ID` and `GUILD_ID` as fallbacks. Split shared UI state into `selectedTextGuild`/`selectedTextChannel` and `selectedVoiceGuild`/`selectedVoiceChannel`, with backward-compatible migration from old persisted `selectedGuild`. Update capture/backlog to use text-specific settings and voice routes to update only voice-specific state.
**Tech Stack:** TypeScript, Zod config, Express routes, Discord selfbot client, Vitest, static dashboard JavaScript.
---
## File Structure
- Modify `src/config.ts`: add `TEXT_GUILD_ID`, `TEXT_CHANNEL_ID`, `VOICE_GUILD_ID`; derive effective text/voice IDs with legacy fallbacks.
- Modify `.env.example`: document split text/voice configuration.
- Modify `src/moderation/messageCapture.ts`: filter live capture by effective text guild and optional text channel.
- Modify `src/moderation/backlogSync.ts`: use effective text guild and optional text channel for readiness/on-demand sync.
- Modify `src/webserver.ts`: change `SharedUIState` to split text/voice guild fields and migrate old persisted state.
- Modify `src/routes/uiStateRoutes.ts`: update shared UI state type.
- Modify `src/routes/voiceRoutes.ts`: patch `selectedVoiceGuild` only on connect/disconnect.
- Modify `public/index.html`: add separate voice guild select and text guild select behavior.
- Tests: `tests/config.test.ts`, `tests/moderation/messageCapture.test.ts`, and a new UI state route/unit test if needed.
## Task 1: Split Config Defaults
**Files:**
- Modify: `src/config.ts`
- Modify: `.env.example`
- Test: `tests/config.test.ts`
- [ ] **Step 1: Write failing config tests**
Add tests to `tests/config.test.ts`:
```ts
it("derives split text and voice guild defaults from legacy config", async () => {
process.env = {
...originalEnv,
DISCORD_TOKEN: "token",
MONITOR_GUILD_ID: "legacy-text-guild",
GUILD_ID: "legacy-voice-guild",
VOICE_CHANNEL_ID: "voice-channel",
NODE_ENV: "test",
};
const { loadConfig } = await import("../src/config");
const config = loadConfig(process.env);
expect(config.TEXT_GUILD_ID).toBeUndefined();
expect(config.EFFECTIVE_TEXT_GUILD_ID).toBe("legacy-text-guild");
expect(config.EFFECTIVE_VOICE_GUILD_ID).toBe("legacy-voice-guild");
expect(config.VOICE_CHANNEL_ID).toBe("voice-channel");
});
it("uses explicit split text and voice config before legacy values", async () => {
process.env = {
...originalEnv,
DISCORD_TOKEN: "token",
MONITOR_GUILD_ID: "legacy-text-guild",
GUILD_ID: "legacy-voice-guild",
TEXT_GUILD_ID: "text-guild",
TEXT_CHANNEL_ID: "text-channel",
VOICE_GUILD_ID: "voice-guild",
VOICE_CHANNEL_ID: "voice-channel",
NODE_ENV: "test",
};
const { loadConfig } = await import("../src/config");
const config = loadConfig(process.env);
expect(config.EFFECTIVE_TEXT_GUILD_ID).toBe("text-guild");
expect(config.TEXT_CHANNEL_ID).toBe("text-channel");
expect(config.EFFECTIVE_VOICE_GUILD_ID).toBe("voice-guild");
});
```
- [ ] **Step 2: Run config tests red**
Run: `pnpm exec vitest run tests/config.test.ts`
Expected: FAIL because `EFFECTIVE_TEXT_GUILD_ID` and `EFFECTIVE_VOICE_GUILD_ID` do not exist.
- [ ] **Step 3: Add split config fields and derived values**
In `src/config.ts`, add schema fields near legacy guild config:
```ts
TEXT_GUILD_ID: z.string().min(1).optional(),
TEXT_CHANNEL_ID: z.string().min(1).optional(),
VOICE_GUILD_ID: z.string().min(1).optional(),
```
Change `loadConfig` to parse then return derived values:
```ts
const parsed = configSchema.parse(env);
return {
...parsed,
EFFECTIVE_TEXT_GUILD_ID: parsed.TEXT_GUILD_ID ?? parsed.MONITOR_GUILD_ID,
EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID ?? parsed.GUILD_ID,
};
```
Update `AppConfig` to include derived fields:
```ts
export type AppConfig = z.infer<typeof configSchema> & {
EFFECTIVE_TEXT_GUILD_ID?: string;
EFFECTIVE_VOICE_GUILD_ID?: string;
};
```
- [ ] **Step 4: Update `.env.example`**
Document:
```env
# Text moderation capture target. Falls back to MONITOR_GUILD_ID for compatibility.
TEXT_GUILD_ID=
TEXT_CHANNEL_ID=
# Voice recording default target. Falls back to GUILD_ID for compatibility.
VOICE_GUILD_ID=
VOICE_CHANNEL_ID=
```
Keep existing legacy keys with notes rather than deleting them.
- [ ] **Step 5: Run config tests green**
Run: `pnpm exec vitest run tests/config.test.ts`
Expected: PASS.
## Task 2: Apply Text Capture Guild/Channel Filtering
**Files:**
- Modify: `src/moderation/messageCapture.ts`
- Modify: `src/moderation/backlogSync.ts`
- Test: `tests/moderation/messageCapture.test.ts`
- [ ] **Step 1: Write failing channel filter test**
In `tests/moderation/messageCapture.test.ts`, mock config before importing `captureMessage` if needed or add a new test file `tests/moderation/messageCaptureFilter.test.ts` that imports a new exported helper.
Preferred helper test: create `tests/moderation/messageCaptureFilter.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { shouldCaptureMessageLocation } from "../../src/moderation/messageCapture";
describe("shouldCaptureMessageLocation", () => {
it("matches only configured text guild and optional channel", () => {
expect(
shouldCaptureMessageLocation(
{ guildId: "guild-1", channelId: "channel-1" },
{ guildId: "guild-1", channelId: "channel-1" },
),
).toBe(true);
expect(
shouldCaptureMessageLocation(
{ guildId: "guild-1", channelId: "channel-2" },
{ guildId: "guild-1", channelId: "channel-1" },
),
).toBe(false);
expect(
shouldCaptureMessageLocation(
{ guildId: "guild-2", channelId: "channel-1" },
{ guildId: "guild-1", channelId: "channel-1" },
),
).toBe(false);
});
});
```
- [ ] **Step 2: Run filter test red**
Run: `pnpm exec vitest run tests/moderation/messageCaptureFilter.test.ts`
Expected: FAIL because `shouldCaptureMessageLocation` does not exist.
- [ ] **Step 3: Add capture filter helper**
In `src/moderation/messageCapture.ts`, export:
```ts
export interface TextCaptureTarget {
guildId?: string;
channelId?: string;
}
export interface MessageLocationInput {
guildId?: string | null;
channelId?: string | null;
}
export function shouldCaptureMessageLocation(
message: MessageLocationInput,
target: TextCaptureTarget,
): boolean {
if (!message.guildId || message.guildId !== target.guildId) return false;
if (target.channelId && message.channelId !== target.channelId) return false;
return true;
}
```
Replace event checks:
```ts
if (
!shouldCaptureMessageLocation(message, {
guildId: config.EFFECTIVE_TEXT_GUILD_ID,
channelId: config.TEXT_CHANNEL_ID,
})
)
return;
```
Use the same helper for `messageUpdate` and `messageDelete`.
- [ ] **Step 4: Update backlog sync config**
In `src/moderation/backlogSync.ts`, replace readiness checks with `config.EFFECTIVE_TEXT_GUILD_ID` and log names with `TEXT_GUILD_ID`. If `config.TEXT_CHANNEL_ID` is present in `syncBacklogMessages`, verify the channel exists and call `syncSelectedChannelBacklog(client, guild.id, config.TEXT_CHANNEL_ID)` instead of only logging readiness.
- [ ] **Step 5: Run focused moderation tests**
Run: `pnpm exec vitest run tests/moderation/messageCapture.test.ts tests/moderation/messageCaptureFilter.test.ts`
Expected: PASS.
## Task 3: Split Shared UI State
**Files:**
- Modify: `src/webserver.ts`
- Modify: `src/routes/uiStateRoutes.ts`
- Modify: `src/routes/voiceRoutes.ts`
- Test: create `tests/routes/uiStateRoutes.test.ts` if no existing route test fits.
- [ ] **Step 1: Write state migration test**
Create `tests/routes/uiStateRoutes.test.ts` with a pure helper import if extracted. Add helper in Task 3 implementation.
```ts
import { describe, expect, it } from "vitest";
import { normalizeSharedUIState } from "../../src/webserver";
describe("normalizeSharedUIState", () => {
it("migrates legacy selectedGuild into split text and voice guilds", () => {
expect(
normalizeSharedUIState({
selectedGuild: "legacy-guild",
selectedVoiceChannel: "voice-channel",
selectedTextChannel: "text-channel",
}),
).toMatchObject({
selectedVoiceGuild: "legacy-guild",
selectedVoiceChannel: "voice-channel",
selectedTextGuild: "legacy-guild",
selectedTextChannel: "text-channel",
});
});
});
```
- [ ] **Step 2: Run state test red**
Run: `pnpm exec vitest run tests/routes/uiStateRoutes.test.ts`
Expected: FAIL because `normalizeSharedUIState` does not exist/export.
- [ ] **Step 3: Update shared state types**
In `src/routes/uiStateRoutes.ts` and `src/webserver.ts`, replace `selectedGuild` with:
```ts
selectedVoiceGuild: string;
selectedVoiceChannel: string;
selectedTextGuild: string;
selectedTextChannel: string;
```
Keep request patch compatibility by allowing `selectedGuild?: string` in the normalization helper input.
- [ ] **Step 4: Add normalizer and use it after persistence load**
In `src/webserver.ts`, export:
```ts
export function normalizeSharedUIState(value: Partial<SharedUIState> & { selectedGuild?: string }): SharedUIState {
const legacyGuild = value.selectedGuild ?? "";
return {
selectedVoiceGuild: value.selectedVoiceGuild ?? legacyGuild,
selectedVoiceChannel: value.selectedVoiceChannel ?? "",
selectedTextGuild: value.selectedTextGuild ?? legacyGuild,
selectedTextChannel: value.selectedTextChannel ?? "",
activeTab: value.activeTab === "text" ? "text" : "voice",
isListening: value.isListening ?? false,
isStreaming: value.isStreaming ?? false,
};
}
```
Use it in `initializeSharedUIState()`:
```ts
sharedUIState = normalizeSharedUIState(
await getPersistedValue("web-ui-state", defaultSharedUIState),
);
```
Update `patchSharedUIState` to accept `selectedVoiceGuild`, `selectedVoiceChannel`, `selectedTextGuild`, `selectedTextChannel`; if legacy `selectedGuild` arrives, set both guild fields.
- [ ] **Step 5: Update voice route patches**
In `src/routes/voiceRoutes.ts`, connect patch becomes:
```ts
selectedVoiceGuild: guildId,
selectedVoiceChannel: channelId,
```
Disconnect clears only:
```ts
selectedVoiceGuild: "",
selectedVoiceChannel: "",
```
Do not clear text guild/channel on voice disconnect.
- [ ] **Step 6: Run state tests**
Run: `pnpm exec vitest run tests/routes/uiStateRoutes.test.ts`
Expected: PASS.
## Task 4: Update Static Dashboard Selection
**Files:**
- Modify: `public/index.html`
- [ ] **Step 1: Replace state fields**
Change JS state fields:
```js
selectedVoiceGuild: '',
selectedVoiceChannel: '',
selectedTextGuild: '',
selectedTextChannel: '',
```
Remove direct reliance on `selectedGuild` except migration when applying server state.
- [ ] **Step 2: Add separate DOM selectors**
In the UI markup, provide separate select elements for voice guild and text guild. Use IDs:
```html
<select id="voiceGuildSelect"></select>
<select id="channelSelect"></select>
<select id="textGuildSelect"></select>
<select id="channelFilter"></select>
```
Update the `el` map to use `voiceGuildSelect` and `textGuildSelect`.
- [ ] **Step 3: Split channel loading functions**
Replace `loadChannels(guildId)` with:
```js
async function loadVoiceChannels(guildId) {
if (!guildId) return renderOptions(el.channelSelect, [], 'Select voice channel');
const voiceChannels = await apiRequest(`/api/guilds/${guildId}/voice-channels`);
renderOptions(el.channelSelect, voiceChannels, 'Select voice channel');
if (state.selectedVoiceChannel) el.channelSelect.value = state.selectedVoiceChannel;
}
async function loadTextChannels(guildId) {
if (!guildId) return renderOptions(el.channelFilter, [], 'Select channel');
const watchChannels = await apiRequest(`/api/guilds/${guildId}/channels`);
renderOptions(el.channelFilter, watchChannels, 'Select channel');
if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel;
apiRequest(`/api/guilds/${guildId}/threads`)
.then((threads) => {
appendOptions(el.channelFilter, threads);
if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel;
})
.catch((error) => showError(`Thread discovery failed: ${error.message}`));
}
```
- [ ] **Step 4: Split state application**
In `applyServerState`, compute:
```js
const nextVoiceGuild = next.selectedVoiceGuild || next.selectedGuild || '';
const nextTextGuild = next.selectedTextGuild || next.selectedGuild || '';
const voiceGuildChanged = nextVoiceGuild !== state.selectedVoiceGuild;
const textGuildChanged = nextTextGuild !== state.selectedTextGuild;
```
Load voice channels only when voice guild changes; load text channels only when text guild changes. Backlog sync uses `state.selectedTextGuild`.
- [ ] **Step 5: Split event listeners**
Use:
```js
el.voiceGuildSelect.addEventListener('change', () => postUIState({ selectedVoiceGuild: el.voiceGuildSelect.value, selectedVoiceChannel: '' }).catch((error) => showError(error.message)));
el.textGuildSelect.addEventListener('change', () => postUIState({ selectedTextGuild: el.textGuildSelect.value, selectedTextChannel: '' }).catch((error) => showError(error.message)));
el.channelSelect.addEventListener('change', () => postUIState({ selectedVoiceChannel: el.channelSelect.value }).catch((error) => showError(error.message)));
el.channelFilter.addEventListener('change', () => { const selectedTextChannel = el.channelFilter.value; const url = new URL(location.href); if (selectedTextChannel) url.searchParams.set('channel', selectedTextChannel); else url.searchParams.delete('channel'); if (el.textGuildSelect.value) url.searchParams.set('guild', el.textGuildSelect.value); history.replaceState({}, '', url); postUIState({ selectedTextChannel }).catch((error) => showError(error.message)); });
```
- [ ] **Step 6: Manual UI verification**
Run: `pnpm run build`
Expected: PASS. Then start the app if credentials are available and verify selecting voice guild does not reset text guild/channel and selecting text guild does not reset voice guild/channel.
## Task 5: Final Verification
**Files:**
- No planned edits unless verification fails.
- [ ] **Step 1: Run lint**
Run: `pnpm run lint`
Expected: PASS.
- [ ] **Step 2: Run typecheck**
Run: `pnpm run typecheck`
Expected: PASS.
- [ ] **Step 3: Run tests**
Run: `pnpm run test`
Expected: PASS.
- [ ] **Step 4: Run build**
Run: `pnpm run build`
Expected: PASS.
- [ ] **Step 5: Inspect status**
Run: `git status --short`
Expected: only intended files changed.
## Self-Review
- Spec coverage: config split is Task 1; capture/backlog filtering is Task 2; backend UI state split is Task 3; dashboard split is Task 4; verification is Task 5.
- Placeholder scan: no TBD/TODO/fill-in steps remain.
- Type consistency: split fields use `selectedVoiceGuild`, `selectedVoiceChannel`, `selectedTextGuild`, `selectedTextChannel` consistently.

View File

@@ -0,0 +1,488 @@
# Vendor Selfbot Dependency Modernization 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:** Aggressively modernize the vendored `discord.js-selfbot-v13` dependency by replacing its legacy toolchain with Biome and auditing runtime dependencies without changing the public API used by the root app.
**Architecture:** The vendor submodule remains a CommonJS package exported from `vendor/discord.js-selfbot-v13/src/index.js`. Tooling moves to Biome plus TypeScript/tsd validation, while runtime dependencies are changed only after usage evidence from `src`, `typings`, config, and scripts. Root workspace resolution remains `workspace:*` and is validated from the root after vendor changes.
**Tech Stack:** Node.js >=20.18, pnpm workspaces, Biome, TypeScript, tsd, CommonJS, git submodule.
---
## File Structure
- Modify: `vendor/discord.js-selfbot-v13/package.json` for scripts and dependencies.
- Create: `vendor/discord.js-selfbot-v13/biome.json` for vendor-specific Biome scope.
- Remove: `vendor/discord.js-selfbot-v13/.eslintrc.json`, `vendor/discord.js-selfbot-v13/.prettierrc.json`, `vendor/discord.js-selfbot-v13/tslint.json` after scripts no longer reference them.
- Modify: `vendor/discord.js-selfbot-v13/tsconfig.json` only if modern TypeScript validation requires config compatibility.
- Modify: `vendor/discord.js-selfbot-v13/src/**/*.js` only for required runtime dependency replacements or Biome-safe formatting fixes.
- Modify: `vendor/discord.js-selfbot-v13/typings/**/*.d.ts` only for TypeScript/tsd compatibility.
- Modify: `pnpm-lock.yaml` by running pnpm from the root, not by hand.
- Do not modify root app source files unless validation proves a compatibility issue from the vendor API.
### Task 1: Capture baseline and dependency usage evidence
**Files:**
- Read: `vendor/discord.js-selfbot-v13/package.json`
- Read: `vendor/discord.js-selfbot-v13/src/**/*.js`
- Read: `vendor/discord.js-selfbot-v13/typings/**/*.d.ts`
- [ ] **Step 1: Capture current vendor dependency lists**
Run:
```bash
node - <<'NODE'
const pkg = require('./vendor/discord.js-selfbot-v13/package.json');
console.log('dependencies');
for (const name of Object.keys(pkg.dependencies || {}).sort()) console.log(`${name} ${pkg.dependencies[name]}`);
console.log('devDependencies');
for (const name of Object.keys(pkg.devDependencies || {}).sort()) console.log(`${name} ${pkg.devDependencies[name]}`);
NODE
```
Expected: prints the current runtime and dev dependency names and versions.
- [ ] **Step 2: Capture runtime usage map**
Run:
```bash
node - <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const root = 'vendor/discord.js-selfbot-v13';
const deps = [
'@discordjs/builders',
'@discordjs/collection',
'@sapphire/async-queue',
'@sapphire/shapeshift',
'discord-api-types',
'fetch-cookie',
'find-process',
'otplib',
'prism-media',
'qrcode',
'tough-cookie',
'tree-kill',
'undici',
'werift-rtp',
'ws',
];
const files = [];
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (/\.(js|ts|d\.ts|json)$/.test(full)) files.push(full);
}
}
walk(path.join(root, 'src'));
walk(path.join(root, 'typings'));
for (const dep of deps) {
const hits = [];
for (const file of files) {
const text = fs.readFileSync(file, 'utf8');
if (text.includes(`require('${dep}`) || text.includes(`require("${dep}`) || text.includes(`from '${dep}`) || text.includes(`from "${dep}`)) hits.push(file);
}
console.log(`${dep}: ${hits.length ? hits.join(', ') : 'UNUSED'}`);
}
NODE
```
Expected usage classification based on current code:
```text
@discordjs/builders: vendor/discord.js-selfbot-v13/src/util/Formatters.js, vendor/discord.js-selfbot-v13/src/managers/ApplicationCommandManager.js, vendor/discord.js-selfbot-v13/typings/index.d.ts
@discordjs/collection: many src files and typings/index.d.ts
@sapphire/async-queue: vendor/discord.js-selfbot-v13/src/rest/RequestHandler.js
@sapphire/shapeshift: vendor/discord.js-selfbot-v13/src/structures/interfaces/TextBasedChannel.js
discord-api-types: vendor/discord.js-selfbot-v13/src/client/websocket/WebSocketManager.js, vendor/discord.js-selfbot-v13/typings/index.d.ts, vendor/discord.js-selfbot-v13/typings/rawDataTypes.d.ts
fetch-cookie: vendor/discord.js-selfbot-v13/src/rest/RESTManager.js
find-process: vendor/discord.js-selfbot-v13/src/client/voice/receiver/Recorder.js
otplib: vendor/discord.js-selfbot-v13/src/client/Client.js
prism-media: vendor/discord.js-selfbot-v13/src/client/voice/util/PlayInterface.js, vendor/discord.js-selfbot-v13/src/client/voice/player/MediaPlayer.js, vendor/discord.js-selfbot-v13/src/client/voice/receiver/Receiver.js
qrcode: vendor/discord.js-selfbot-v13/src/util/RemoteAuth.js
tough-cookie: vendor/discord.js-selfbot-v13/src/rest/RESTManager.js
tree-kill: vendor/discord.js-selfbot-v13/src/client/voice/receiver/Recorder.js
undici: vendor/discord.js-selfbot-v13/src/rest/APIRequest.js, vendor/discord.js-selfbot-v13/src/rest/RESTManager.js, vendor/discord.js-selfbot-v13/src/util/RemoteAuth.js, vendor/discord.js-selfbot-v13/src/util/Util.js, vendor/discord.js-selfbot-v13/src/util/DataResolver.js
werift-rtp: vendor/discord.js-selfbot-v13/src/client/voice/receiver/PacketHandler.js, vendor/discord.js-selfbot-v13/src/client/voice/receiver/Recorder.js
ws: vendor/discord.js-selfbot-v13/src/WebSocket.js, vendor/discord.js-selfbot-v13/src/util/RemoteAuth.js
```
- [ ] **Step 3: Confirm type assertion tests exist**
Run:
```bash
find vendor/discord.js-selfbot-v13 -maxdepth 3 -type f -name '*.test-d.ts' -print
```
Expected output includes:
```text
vendor/discord.js-selfbot-v13/typings/index.test-d.ts
```
Decision: keep `tsd` because `typings/index.test-d.ts` exists.
### Task 2: Replace vendor lint and format toolchain with Biome
**Files:**
- Modify: `vendor/discord.js-selfbot-v13/package.json`
- Create: `vendor/discord.js-selfbot-v13/biome.json`
- Remove: `vendor/discord.js-selfbot-v13/.eslintrc.json`
- Remove: `vendor/discord.js-selfbot-v13/.prettierrc.json`
- Remove: `vendor/discord.js-selfbot-v13/tslint.json`
- [ ] **Step 1: Update vendor scripts and dev dependencies**
Edit `vendor/discord.js-selfbot-v13/package.json` so `scripts` becomes:
```json
{
"all": "npm run build && npm publish",
"test": "npm run lint && npm run test:typescript && npm run docs:test",
"fix:all": "npm run format",
"test:typescript": "tsc --noEmit && tsd",
"lint": "biome check . --diagnostic-level=error",
"format": "biome format --write .",
"docs": "docgen --source src --custom docs/index.yml --output docs/main.json",
"docs:test": "docgen --source src --custom docs/index.yml",
"build": "npm run format && npm run docs"
}
```
In the same file, remove these dev dependencies:
```json
"dtslint": "^4.2.1",
"eslint": "^8.39.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-prettier": "^4.2.1",
"prettier": "^2.8.8",
"tslint": "^6.1.3"
```
Add this dev dependency if it is not already present in the vendor package:
```json
"@biomejs/biome": "latest"
```
Keep these dev dependencies:
```json
"@discordjs/docgen": "^0.11.1",
"@types/debug": "^4.1.12",
"@types/node": "^22.10.7",
"@types/ws": "^8.5.10",
"patch-package": "^8.0.0",
"tsd": "^0.32.0",
"typescript": "^5.5.4"
```
Expected: no package scripts reference `eslint`, `prettier`, `tslint`, or `dtslint`.
- [ ] **Step 2: Create vendor Biome config**
Create `vendor/discord.js-selfbot-v13/biome.json` with:
```json
{
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"files": {
"includes": ["src/**/*.js", "typings/**/*.ts", "*.json"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "always"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": false,
"style": {
"useNodejsImportProtocol": "warn"
},
"suspicious": {
"noExplicitAny": "warn"
}
}
}
}
```
Expected: vendor can run its own Biome config without relying on the root config.
- [ ] **Step 3: Remove legacy config files**
Run:
```bash
rm vendor/discord.js-selfbot-v13/.eslintrc.json vendor/discord.js-selfbot-v13/.prettierrc.json vendor/discord.js-selfbot-v13/tslint.json
```
Expected: the files are removed because no script references those tools.
- [ ] **Step 4: Verify legacy tool references are gone from package scripts**
Run:
```bash
node - <<'NODE'
const pkg = require('./vendor/discord.js-selfbot-v13/package.json');
const scripts = JSON.stringify(pkg.scripts || {});
for (const tool of ['eslint', 'prettier', 'tslint', 'dtslint']) {
if (scripts.includes(tool)) throw new Error(`legacy tool still referenced: ${tool}`);
}
console.log('legacy script references removed');
NODE
```
Expected output:
```text
legacy script references removed
```
### Task 3: Modernize runtime and dev dependency ranges with usage evidence
**Files:**
- Modify: `vendor/discord.js-selfbot-v13/package.json`
- Modify: `pnpm-lock.yaml` after root install
- [ ] **Step 1: Update used runtime dependencies to current compatible ranges**
Edit `vendor/discord.js-selfbot-v13/package.json` dependencies to these ranges unless a package manager reports a direct incompatibility during install:
```json
{
"@discordjs/builders": "^1.13.0",
"@discordjs/collection": "^2.1.1",
"@sapphire/async-queue": "^1.5.5",
"@sapphire/shapeshift": "^4.0.0",
"discord-api-types": "^0.38.38",
"fetch-cookie": "^3.1.0",
"find-process": "^2.0.0",
"otplib": "^12.0.1",
"prism-media": "^2.0.0-alpha.0",
"qrcode": "^1.5.4",
"tough-cookie": "^5.1.2",
"tree-kill": "^1.2.2",
"undici": "^7.16.0",
"werift-rtp": "^0.8.4",
"ws": "^8.20.0"
}
```
Expected: no runtime dependency is removed yet because all are currently used by source or typings.
- [ ] **Step 2: Update vendor dev dependency ranges**
Edit `vendor/discord.js-selfbot-v13/package.json` devDependencies to:
```json
{
"@biomejs/biome": "latest",
"@discordjs/docgen": "^0.11.1",
"@types/debug": "^4.1.12",
"@types/node": "^25.8.0",
"@types/ws": "^8.18.1",
"patch-package": "^8.0.1",
"tsd": "^0.33.0",
"typescript": "^5.9.3"
}
```
Expected: legacy lint/format/type-lint packages are absent.
- [ ] **Step 3: Refresh root workspace lockfile**
Run from `/mnt/code/bete`:
```bash
pnpm install
```
Expected: install completes and `pnpm-lock.yaml` updates the vendor importer dependency ranges.
- [ ] **Step 4: Verify removed dev packages are no longer vendor dependencies**
Run:
```bash
node - <<'NODE'
const pkg = require('./vendor/discord.js-selfbot-v13/package.json');
const all = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
for (const name of ['dtslint', 'eslint', 'eslint-config-prettier', 'eslint-plugin-import', 'eslint-plugin-prettier', 'prettier', 'tslint']) {
if (name in all) throw new Error(`legacy package still present: ${name}`);
}
console.log('legacy packages removed');
NODE
```
Expected output:
```text
legacy packages removed
```
### Task 4: Run vendor validation and make Biome-safe fixes
**Files:**
- Modify: `vendor/discord.js-selfbot-v13/src/**/*.js` only if Biome emits errors.
- Modify: `vendor/discord.js-selfbot-v13/typings/**/*.d.ts` only if TypeScript or tsd emits errors.
- Modify: `vendor/discord.js-selfbot-v13/biome.json` only if the configured scope is wrong.
- [ ] **Step 1: Run vendor Biome check**
Run:
```bash
pnpm --filter discord.js-selfbot-v13 run lint
```
Expected: either passes, or reports concrete Biome diagnostics in vendor files.
- [ ] **Step 2: Apply Biome formatting if lint reports formatting diagnostics**
Run only if Step 1 reports formatting diagnostics:
```bash
pnpm --filter discord.js-selfbot-v13 run format
pnpm --filter discord.js-selfbot-v13 run lint
```
Expected: formatting diagnostics are fixed. If lint still reports correctness errors, fix only the reported vendor lines without changing behavior, then rerun lint.
- [ ] **Step 3: Run vendor TypeScript/type validation**
Run:
```bash
pnpm --filter discord.js-selfbot-v13 run test:typescript
```
Expected: `tsc --noEmit && tsd` passes. If it fails due dependency type changes, fix typings or dependency ranges while preserving public API, then rerun.
- [ ] **Step 4: Run vendor test script**
Run:
```bash
pnpm --filter discord.js-selfbot-v13 run test
```
Expected: vendor test script passes. If `docs:test` fails due docgen compatibility unrelated to dependency modernization, record the error and run lint + `test:typescript` as the required validation gate.
### Task 5: Validate root workspace integration
**Files:**
- Modify: `pnpm-lock.yaml` only via pnpm.
- Read: root `package.json`, `src/**/*.ts`.
- [ ] **Step 1: Verify workspace dependency link**
Run:
```bash
pnpm list discord.js-selfbot-v13 --depth 0
```
Expected output includes:
```text
discord.js-selfbot-v13 link:vendor/discord.js-selfbot-v13
```
- [ ] **Step 2: Run root typecheck**
Run:
```bash
pnpm run typecheck
```
Expected: TypeScript exits successfully.
- [ ] **Step 3: Run root lint**
Run:
```bash
pnpm run lint
```
Expected: Biome checks root files successfully. If it scans nested generated worktrees under `.claude/worktrees`, remove only session-generated agent worktrees after confirming they are not needed, then rerun lint.
- [ ] **Step 4: Run root import smoke check**
Run:
```bash
node - <<'NODE'
const selfbot = require('discord.js-selfbot-v13');
for (const key of ['Client', 'Collection', 'WebSocket']) {
if (!(key in selfbot)) throw new Error(`missing export: ${key}`);
}
console.log('selfbot exports available');
NODE
```
Expected output:
```text
selfbot exports available
```
### Task 6: Review submodule and root diffs
**Files:**
- Review: `vendor/discord.js-selfbot-v13/package.json`
- Review: `vendor/discord.js-selfbot-v13/biome.json`
- Review: removed legacy config files
- Review: `pnpm-lock.yaml`
- Review: root submodule gitlink
- [ ] **Step 1: Review vendor status**
Run:
```bash
git -C vendor/discord.js-selfbot-v13 status --short
git -C vendor/discord.js-selfbot-v13 diff -- package.json biome.json tsconfig.json src typings .eslintrc.json .prettierrc.json tslint.json
```
Expected: vendor changes are limited to toolchain config, package metadata, lock-relevant dependency ranges, and any validation-driven source/typing fixes.
- [ ] **Step 2: Review root status**
Run:
```bash
git status --short
git diff -- package.json pnpm-workspace.yaml pnpm-lock.yaml .gitmodules docs/superpowers/specs/2026-05-15-vendor-selfbot-dependency-modernization-design.md docs/superpowers/plans/2026-05-15-vendor-selfbot-dependency-modernization.md
git diff --submodule
```
Expected: root changes include the existing submodule/workspace setup, this spec/plan, lockfile refresh, and the updated submodule gitlink. Existing unrelated `README.md` remains untouched.
- [ ] **Step 3: Do not push or commit without explicit user permission**
No commit, push, PR, or submodule remote update should run unless the user explicitly asks. If asked, commit inside the vendor submodule first, push that commit, then update the root submodule gitlink and commit root changes separately.
## Self-Review
- Spec coverage: the plan covers runtime dependency audit, Biome-only vendor toolchain, TypeScript/tsd validation, root install/typecheck/lint, and import smoke check.
- Placeholder scan: no TBD/TODO placeholders remain.
- Type consistency: all paths use `vendor/discord.js-selfbot-v13`, scripts use `biome`, `tsc`, and `tsd`, and the root dependency remains `workspace:*`.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,673 @@
# Session Full Recording 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:** Build background full-session OGG recording generation from voice join to leave while preserving existing per-user segment recordings.
**Architecture:** Add a focused session tracker that records session timing, participants, and per-user segment references. Add a session muxer that builds timeline-offset ffmpeg filters and writes `recordings/sessions/<sessionId>/session.json` plus `full.ogg`. Wire recorder lifecycle to create a session on join, register finished human segments, and finalize in the background on stop/destroy.
**Tech Stack:** TypeScript, Vitest, Node fs/path, ffmpeg via existing `buildMuxFfmpegArgs` and `runFfmpeg`, Discord voice receiver pipeline.
---
## File Structure
- Create `src/recorder/sessionRecording.ts`: session metadata types, session tracker, mux filter builder, and session finalization function.
- Modify `src/types.ts`: add `recordingSessionId` to per-user `SegmentMetadata`.
- Modify `src/recorder/metadata.ts`: accept and write shared `recordingSessionId` into segment metadata.
- Modify `src/recorder.ts`: create session on ready, skip bots as now, register segment metadata, finalize session in background on stop/destroy.
- Create `tests/recorder/sessionRecording.test.ts`: unit tests for session tracker, mux filter, empty session, and failed mux metadata.
- Modify `tests/recorder.test.ts`: assert bot/self users do not register session participants or subscriptions; add stop finalization trigger test with injected session finalizer if needed.
---
### Task 1: Session Recording Metadata and Mux Builder
**Files:**
- Create: `src/recorder/sessionRecording.ts`
- Test: `tests/recorder/sessionRecording.test.ts`
- [ ] **Step 1: Write failing tests for session tracker and mux filter**
Create `tests/recorder/sessionRecording.test.ts`:
```ts
import { describe, expect, it, vi } from "vitest";
import {
buildSessionMuxFilter,
createRecordingSession,
finalizeRecordingSession,
} from "../../src/recorder/sessionRecording";
import type { UserMetadata } from "../../src/types";
function user(overrides: Partial<UserMetadata> = {}): UserMetadata {
return {
userId: "user-1",
username: "Alice",
tag: "Alice#0001",
displayName: "Alice",
avatarUrl: "https://example.com/avatar.png",
bot: false,
roles: [],
highestRole: null,
joinedTimestamp: null,
...overrides,
};
}
describe("sessionRecording", () => {
it("tracks participants and segment refs", () => {
const session = createRecordingSession({
guildId: "guild",
channelId: "voice",
channelName: "Voice",
startTime: 1000,
recordingsDir: "/recordings",
});
session.registerSegment({
user: user(),
oggPath: "/recordings/user-1/1500.ogg",
jsonPath: "/recordings/user-1/1500.json",
startTime: 1500,
endTime: 2500,
});
const snapshot = session.snapshot(3000);
expect(snapshot).toMatchObject({
sessionId: "guild-voice-1000",
guildId: "guild",
channelId: "voice",
channelName: "Voice",
startTime: 1000,
endTime: 3000,
durationMs: 2000,
status: "pending",
participants: [{ userId: "user-1", username: "Alice" }],
segments: [
{
userId: "user-1",
oggPath: "/recordings/user-1/1500.ogg",
jsonPath: "/recordings/user-1/1500.json",
startTime: 1500,
endTime: 2500,
offsetMs: 500,
},
],
});
});
it("builds timeline-offset ffmpeg filter", () => {
const filter = buildSessionMuxFilter([
{ startTime: 1000 },
{ startTime: 2500 },
], 1000);
expect(filter).toBe(
"[0:a]adelay=0|0[pad0];[1:a]adelay=1500|1500[pad1];[pad0][pad1]amix=inputs=2:dropout_transition=0[out]",
);
});
it("writes empty metadata without running ffmpeg", async () => {
const session = createRecordingSession({
guildId: "guild",
channelId: "voice",
channelName: "Voice",
startTime: 1000,
recordingsDir: "/recordings",
});
const writeJson = vi.fn();
const mkdir = vi.fn();
const runFfmpeg = vi.fn();
await finalizeRecordingSession(session, {
endTime: 4000,
mkdir,
writeJson,
runFfmpeg,
});
expect(runFfmpeg).not.toHaveBeenCalled();
expect(mkdir).toHaveBeenCalledWith("/recordings/sessions/guild-voice-1000");
expect(writeJson).toHaveBeenCalledWith(
"/recordings/sessions/guild-voice-1000/session.json",
expect.objectContaining({ status: "empty", durationMs: 3000 }),
);
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run:
```bash
pnpm exec vitest run tests/recorder/sessionRecording.test.ts
```
Expected: FAIL because `src/recorder/sessionRecording.ts` does not exist.
- [ ] **Step 3: Implement session tracker and mux filter**
Create `src/recorder/sessionRecording.ts`:
```ts
import fs from "node:fs";
import path from "node:path";
import { buildMuxFfmpegArgs, runFfmpeg as defaultRunFfmpeg } from "../audio/ffmpegProcess";
import type { UserMetadata } from "../types";
export type SessionRecordingStatus = "pending" | "completed" | "failed" | "empty";
export interface RecordingSessionOptions {
guildId: string;
channelId: string;
channelName: string;
startTime: number;
recordingsDir: string;
}
export interface SessionSegmentInput {
user: UserMetadata;
oggPath: string;
jsonPath: string;
startTime: number;
endTime: number;
}
export interface SessionParticipant {
userId: string;
username: string;
tag: string;
displayName: string;
avatarUrl: string;
}
export interface SessionSegmentRef {
userId: string;
oggPath: string;
jsonPath: string;
startTime: number;
endTime: number;
durationMs: number;
offsetMs: number;
}
export interface SessionRecordingMetadata {
sessionId: string;
guildId: string;
channelId: string;
channelName: string;
startTime: number;
endTime: number;
durationMs: number;
status: SessionRecordingStatus;
outputFile: string | null;
participants: SessionParticipant[];
segments: SessionSegmentRef[];
error?: string;
}
export interface RecordingSession {
readonly sessionId: string;
readonly recordingsDir: string;
readonly startTime: number;
registerSegment(input: SessionSegmentInput): void;
snapshot(endTime: number): SessionRecordingMetadata;
}
export interface FinalizeRecordingSessionDependencies {
endTime?: number;
mkdir?: (dir: string) => void;
writeJson?: (file: string, metadata: SessionRecordingMetadata) => void;
runFfmpeg?: (args: string[]) => Promise<void>;
}
export function createRecordingSession(options: RecordingSessionOptions): RecordingSession {
const sessionId = `${options.guildId}-${options.channelId}-${options.startTime}`;
const participants = new Map<string, SessionParticipant>();
const segments: SessionSegmentRef[] = [];
return {
sessionId,
recordingsDir: options.recordingsDir,
startTime: options.startTime,
registerSegment(input: SessionSegmentInput): void {
participants.set(input.user.userId, {
userId: input.user.userId,
username: input.user.username,
tag: input.user.tag,
displayName: input.user.displayName,
avatarUrl: input.user.avatarUrl,
});
segments.push({
userId: input.user.userId,
oggPath: input.oggPath,
jsonPath: input.jsonPath,
startTime: input.startTime,
endTime: input.endTime,
durationMs: input.endTime - input.startTime,
offsetMs: input.startTime - options.startTime,
});
},
snapshot(endTime: number): SessionRecordingMetadata {
return {
sessionId,
guildId: options.guildId,
channelId: options.channelId,
channelName: options.channelName,
startTime: options.startTime,
endTime,
durationMs: endTime - options.startTime,
status: "pending",
outputFile: null,
participants: Array.from(participants.values()),
segments: [...segments],
};
},
};
}
export function buildSessionMuxFilter(
segments: Array<{ startTime: number }>,
sessionStartTime: number,
): string {
const filters = segments.map((segment, index) => {
const delayMs = Math.max(0, segment.startTime - sessionStartTime);
return `[${index}:a]adelay=${delayMs}|${delayMs}[pad${index}]`;
});
const inputs = segments.map((_, index) => `[pad${index}]`).join("");
filters.push(`${inputs}amix=inputs=${segments.length}:dropout_transition=0[out]`);
return filters.join(";");
}
export async function finalizeRecordingSession(
session: RecordingSession,
dependencies: FinalizeRecordingSessionDependencies = {},
): Promise<void> {
const endTime = dependencies.endTime ?? Date.now();
const sessionDir = path.join(session.recordingsDir, "sessions", session.sessionId);
const outputFile = path.join(sessionDir, "full.ogg");
const metadataFile = path.join(sessionDir, "session.json");
const mkdir = dependencies.mkdir ?? ((dir) => fs.mkdirSync(dir, { recursive: true }));
const writeJson =
dependencies.writeJson ??
((file, metadata) => fs.writeFileSync(file, JSON.stringify(metadata, null, 2)));
const runFfmpeg = dependencies.runFfmpeg ?? defaultRunFfmpeg;
mkdir(sessionDir);
const metadata = session.snapshot(endTime);
if (metadata.segments.length === 0) {
writeJson(metadataFile, { ...metadata, status: "empty" });
return;
}
try {
await runFfmpeg(
buildMuxFfmpegArgs({
inputs: metadata.segments.map((segment) => segment.oggPath),
filter: buildSessionMuxFilter(metadata.segments, metadata.startTime),
output: outputFile,
codec: "libopus",
}),
);
writeJson(metadataFile, {
...metadata,
status: "completed",
outputFile,
});
} catch (error) {
writeJson(metadataFile, {
...metadata,
status: "failed",
error: error instanceof Error ? error.message : String(error),
});
}
}
```
- [ ] **Step 4: Run tests**
Run:
```bash
pnpm exec vitest run tests/recorder/sessionRecording.test.ts
```
Expected: PASS.
- [ ] **Step 5: Commit Task 1**
Run:
```bash
git add src/recorder/sessionRecording.ts tests/recorder/sessionRecording.test.ts
git commit -m "feat: add recording session metadata"
```
---
### Task 2: Add Shared Recording Session ID to Segment Metadata
**Files:**
- Modify: `src/types.ts`
- Modify: `src/recorder/metadata.ts`
- Test: `tests/recorder/metadata.test.ts`
- [ ] **Step 1: Write failing metadata test**
Create `tests/recorder/metadata.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { createSegmentMetadata } from "../../src/recorder/metadata";
import type { SegmentState, UserMetadata } from "../../src/types";
const user: UserMetadata = {
userId: "user-1",
username: "Alice",
tag: "Alice#0001",
displayName: "Alice",
avatarUrl: "https://example.com/avatar.png",
bot: false,
roles: [],
highestRole: null,
joinedTimestamp: null,
};
const segment = {
index: 0,
startTime: 1500,
endTime: 2500,
filename: "/recordings/user-1/1500.ogg",
jsonFilename: "/recordings/user-1/1500.json",
} as SegmentState;
describe("createSegmentMetadata", () => {
it("includes shared recording session id", () => {
const metadata = createSegmentMetadata(
user,
segment,
"user-1-1500",
"guild-voice-1000",
1000,
5000,
);
expect(metadata).toMatchObject({
sessionId: "user-1-1500",
recordingSessionId: "guild-voice-1000",
sessionStartTime: 1000,
startTime: 1500,
endTime: 2500,
});
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
pnpm exec vitest run tests/recorder/metadata.test.ts
```
Expected: FAIL because `createSegmentMetadata` does not accept `recordingSessionId` yet.
- [ ] **Step 3: Update metadata type and function signature**
Modify `src/types.ts`:
```ts
export interface SegmentMetadata extends UserMetadata {
sessionId: string;
recordingSessionId: string;
sessionStartTime: number;
segmentIndex: number;
segmentMs: number;
startTime: number;
endTime: number;
durationMs: number;
filename: string;
}
```
Modify `src/recorder/metadata.ts` function signature and return object:
```ts
export function createSegmentMetadata(
user: UserMetadata,
segment: SegmentState,
sessionId: string,
recordingSessionId: string,
sessionStartTime: number,
recordingSegmentMs: number,
): SegmentMetadata {
const endTime = segment.endTime ?? Date.now();
return {
...user,
sessionId,
recordingSessionId,
sessionStartTime,
segmentIndex: segment.index,
segmentMs: recordingSegmentMs,
startTime: segment.startTime,
endTime,
durationMs: endTime - segment.startTime,
filename: path.basename(segment.filename),
};
}
```
- [ ] **Step 4: Update existing call sites**
In `src/recorder.ts`, update the call to include `recordingSession.sessionId` after the per-user `sessionId` argument:
```ts
const metadata = createSegmentMetadata(
userMetadata,
currentSegment,
sessionId,
recordingSession.sessionId,
sessionStartTime,
config.RECORDING_SEGMENT_MS,
);
```
- [ ] **Step 5: Run metadata tests and typecheck**
Run:
```bash
pnpm exec vitest run tests/recorder/metadata.test.ts
pnpm run typecheck
```
Expected: PASS.
- [ ] **Step 6: Commit Task 2**
Run:
```bash
git add src/types.ts src/recorder/metadata.ts src/recorder.ts tests/recorder/metadata.test.ts
git commit -m "feat: tag segments with recording session"
```
---
### Task 3: Wire Session Tracking into Recorder Lifecycle
**Files:**
- Modify: `src/recorder.ts`
- Modify: `tests/recorder.test.ts`
- [ ] **Step 1: Write failing recorder lifecycle tests**
Append to `tests/recorder.test.ts`:
```ts
it("finalizes the active recording session when stopped", async () => {
const { startRecording, stopRecording } = await import("../src/recorder");
const { getVoiceConnection } = await import("@discordjs/voice");
const destroy = vi.fn();
vi.mocked(getVoiceConnection).mockReturnValue({ destroy } as never);
await startRecording({ user: { id: "self-user" } } as never, createChannel() as never);
stopRecording("guild");
await new Promise((resolve) => setImmediate(resolve));
expect(destroy).toHaveBeenCalled();
});
```
Then add a test that emits a non-bot user and asserts `subscribe` is called once, while existing self/bot tests still assert zero subscriptions.
- [ ] **Step 2: Run recorder tests to verify failure if session APIs are missing**
Run:
```bash
pnpm exec vitest run tests/recorder.test.ts
```
Expected: FAIL until recorder imports and uses session recording APIs.
- [ ] **Step 3: Add active session map and finalize helper**
Modify `src/recorder.ts` imports:
```ts
import {
createRecordingSession,
finalizeRecordingSession,
type RecordingSession,
} from "./recorder/sessionRecording";
```
Add near `recordingsDir`:
```ts
const activeRecordingSessions = new Map<string, RecordingSession>();
function finalizeActiveRecordingSession(guildId: string): void {
const session = activeRecordingSessions.get(guildId);
if (!session) return;
activeRecordingSessions.delete(guildId);
finalizeRecordingSession(session).catch((error) => {
logger.error({ error }, "Failed to finalize recording session");
});
}
```
After connection reaches ready, create and store the session:
```ts
const recordingSession = createRecordingSession({
guildId: channel.guild.id,
channelId: channel.id,
channelName: channel.name,
startTime: Date.now(),
recordingsDir,
});
activeRecordingSessions.set(channel.guild.id, recordingSession);
```
In segment finish handler, after writing per-user JSON, register the segment:
```ts
recordingSession.registerSegment({
user: userMetadata,
oggPath: currentSegment.filename,
jsonPath: currentSegment.jsonFilename,
startTime: currentSegment.startTime,
endTime: metadata.endTime,
});
```
In `stopRecording(guildId)`, call `finalizeActiveRecordingSession(guildId)` before destroying connection.
In `connection.on(VoiceConnectionStatus.Destroyed, ...)`, call `finalizeActiveRecordingSession(channel.guild.id)`.
- [ ] **Step 4: Run recorder tests and typecheck**
Run:
```bash
pnpm exec vitest run tests/recorder.test.ts tests/recorder/sessionRecording.test.ts tests/recorder/metadata.test.ts
pnpm run typecheck
```
Expected: PASS.
- [ ] **Step 5: Commit Task 3**
Run:
```bash
git add src/recorder.ts tests/recorder.test.ts
git commit -m "feat: finalize recording sessions on disconnect"
```
---
### Task 4: Final Verification
**Files:**
- All changed recorder/session files.
- [ ] **Step 1: Run recorder-focused tests**
Run:
```bash
pnpm exec vitest run tests/recorder.test.ts tests/recorder/sessionRecording.test.ts tests/recorder/metadata.test.ts tests/audio/ffmpegProcess.test.ts
```
Expected: PASS.
- [ ] **Step 2: Run full test suite**
Run:
```bash
pnpm run test
```
Expected: PASS.
- [ ] **Step 3: Run typecheck**
Run:
```bash
pnpm run typecheck
```
Expected: PASS.
- [ ] **Step 4: Run lint**
Run:
```bash
pnpm run lint
```
Expected: PASS.
- [ ] **Step 5: Check git status**
Run:
```bash
git status --short
```
Expected: only intentional implementation, spec, and plan changes are present.
```

View File

@@ -0,0 +1,264 @@
# AI Message Flow + React Dashboard Redesign
## Goal
Rebuild the moderation watcher flow so message capture is fast, AI analysis is contextual and reliable, APIs are split by concern, and the dashboard is maintainable. The implementation may fully replace the current static frontend and reorganize backend modules, while preserving existing voice functionality.
## Current Problems
- Message capture, AI queuing, DB access, and WebSocket broadcasting are tightly coupled.
- AI analysis batches depend on array ordering, which can mismatch results when the model returns malformed or partial JSON.
- Pending analysis is polled globally, not grouped by conversation, so context is weak and request efficiency is inconsistent.
- `/api/messages` mixes text/image concerns and uses offset pagination, which gets slower and less stable as rows grow.
- Frontend is a large static HTML file with inline state, API, WebSocket, rendering, and audio code.
- WebSocket broadcast uses untyped `globalThis` hooks across modules.
## Backend Architecture
### Message ingestion
`messageCapture` becomes a narrow ingestion layer:
1. Filter Discord events by guild and author.
2. Normalize message payload and metadata.
3. Upsert message/attachment records.
4. Set or reset `ai_status` to `pending` for new or edited text.
5. Emit typed domain events for WebSocket broadcasting and analysis queueing.
It should not build prompts, manage AI batches, or query unrelated DB state.
### Store/repository layer
`messageStore` becomes the single message/attachment query boundary. It should expose focused functions:
- `upsertMessage`
- `markMessageEdited`
- `markMessageDeleted`
- `listMessages`
- `listReviewMessages`
- `getConversationContext`
- `claimPendingMessagesForChannel`
- `saveAnalysisResults`
- `insertAttachments`
Queries should use cursor pagination based on `(created_at, id)` instead of offset pagination. Common filters: `guildId`, `channelId`, `threadId`, `status`, `userId`, `q`, `limit`, `cursor`.
### Analysis queue
Add an `analysisQueue` module. It owns async AI processing and keeps capture fast.
- Queue key: `thread_id ?? channel_id`.
- Debounce: 13 seconds per key to group nearby messages.
- Batch pending messages by conversation key and token budget.
- Only one or a small fixed number of active LLM requests.
- Backlog worker feeds the same queue; no separate analysis path.
- Edits reset a message to `pending` and enqueue its conversation key.
If the process restarts, pending rows are recovered by a periodic lightweight scanner grouped by conversation key.
### Conversation context builder
Add `conversationContext` module:
- Input: conversation key + target pending messages.
- Fetch context before the first target message, normally 20 prior messages.
- Include target messages and close neighboring messages when within budget.
- Mark target messages explicitly in the prompt.
- Keep context scoped to one channel/thread to avoid irrelevant noise.
### LLM moderation client
Add `llmModerationClient` module:
- Own request shape, timeout, retry, JSON extraction, and validation.
- Prompt returns JSON keyed by `message_id`, not positional arrays.
- Expected response shape:
```json
{
"results": [
{
"message_id": "string",
"status": "clean|warn|flagged",
"flags": ["string"],
"score": 0.0,
"analysis": "Bahasa Indonesia summary + reason + suggested action"
}
]
}
```
- Reject unknown IDs, invalid statuses, invalid scores, and missing target IDs.
- On partial model failure, retry once with smaller batch. If still invalid, mark only affected target messages as `error`.
- Store raw batch request/response in one run record if the DB migration is included; otherwise store compact raw metadata per message.
## API Design
Split API by use case so reads remain fast and obvious.
### Message read APIs
- `GET /api/messages`
- Query: `guildId`, `channelId`, `threadId`, `cursor`, `limit`, `status`, `userId`, `q`.
- Returns: `{ data, nextCursor }`.
- Uses indexed cursor pagination.
- `GET /api/messages/:id`
- Returns one message with attachments and AI analysis.
- `GET /api/review`
- Query: `guildId`, optional `channelId`, `status=warn,flagged,error`, `cursor`, `limit`.
- Optimized for moderator review panel.
- `GET /api/attachments`
- Query: `channelId`, `threadId`, `cursor`, `limit`, `type`.
- Replaces image mode inside `/api/messages`.
### Analysis APIs
- `POST /api/messages/:id/reanalyze`
- Sets message to `pending` and queues its conversation.
- Returns `202 Accepted` with current message status.
- `POST /api/analysis/requeue-pending`
- Admin/manual recovery endpoint for pending/error rows.
- Returns count queued.
- `GET /api/analysis/status`
- Returns queue depth, active requests, last error, and pending counts.
### Discord sync APIs
- `POST /api/backlog-sync`
- Stays async-friendly: starts sync for guild/channel/thread and returns `202` with a job id or immediate summary if small.
- Sync inserts messages, then queues analysis through the same `analysisQueue`.
### Voice/control APIs
Keep existing voice APIs working, but move route registration into route modules:
- `routes/voiceRoutes.ts`
- `routes/messageRoutes.ts`
- `routes/analysisRoutes.ts`
- `routes/syncRoutes.ts`
- `routes/uiStateRoutes.ts`
`webserver.ts` should only create Express/WS server, install middleware, register routes, and start listening.
## WebSocket Design
Replace ad-hoc globals with a typed broadcaster module.
Events:
- `ui_state`
- `user_state`
- `message_created`
- `message_updated`
- `message_deleted`
- `message_analyzed`
- `attachment_created`
- `analysis_queue_status`
Backend modules call broadcaster functions; they do not touch WebSocket clients directly.
## Database Changes
Add or verify indexes:
- messages `(channel_id, created_at, id)`
- messages `(thread_id, created_at, id)`
- messages `(ai_status, created_at, id)`
- messages `(guild_id, ai_status, created_at, id)`
- attachments `(channel_id, created_at, id)`
- attachments `(thread_id, created_at, id)`
Optional but preferred:
- `ai_analysis_runs` table:
- `id`
- `conversation_key`
- `target_message_ids`
- `model`
- `request_tokens_estimate`
- `response_raw`
- `status`
- `error`
- `created_at`
- `completed_at`
This avoids duplicating large raw LLM responses into every message row.
## React/Vite Frontend
Replace static inline dashboard code with a TypeScript React app.
Suggested structure:
- `frontend/src/api/` — typed REST clients
- `frontend/src/ws/` — WebSocket client and event types
- `frontend/src/state/` — small hooks for selected guild/channel, messages, review queue, voice state
- `frontend/src/components/voice/` — existing voice control/audio components
- `frontend/src/components/messages/` — feed, message card, filters, detail drawer
- `frontend/src/components/review/` — needs-review list and analysis status
- `frontend/src/components/layout/` — shell/sidebar/status cards
UI layout:
- Left sidebar: guild, voice channel, text channel/thread, connection state.
- Main area: message feed with filters and load-more cursor pagination.
- Right panel: review queue for `warn`, `flagged`, and `error` messages.
- Detail drawer/modal: message metadata, attachments, AI rationale, raw flags, reanalyze action.
Voice features stay functionally equivalent. Audio capture/playback code can be moved into React hooks but should not be behaviorally rewritten unless needed.
Build integration:
- Add Vite dev/build scripts.
- Express serves the built app from a stable public directory in production.
- During development, either run Vite separately or proxy API/WS to Express.
## Performance Rules
- Message capture must not wait on AI.
- Read APIs use cursor pagination and indexes.
- AI batches are bounded by token estimate and message count.
- UI fetches initial pages, then patches via WebSocket.
- Backlog sync should not block dashboard interactions.
- Avoid storing full raw LLM response per message when a batch table is available.
## Error Handling
- Capture errors log and do not crash Discord client event handlers.
- AI request failures mark target messages `error` with a short reason.
- Invalid LLM JSON triggers retry/split before marking errors.
- API validation returns 400 with structured error code.
- WebSocket reconnect logic stays client-side.
- Manual reanalysis provides recovery for bad AI results.
## Testing
Backend:
- Unit tests for conversation context selection.
- Unit tests for LLM response parser and validation.
- Unit tests for queue batching/debounce behavior.
- Integration tests for message cursor pagination and review filters.
- Existing voice tests remain unchanged.
Frontend:
- Typecheck and Vite build.
- Component-level smoke tests may be added if test tooling is already practical.
- Manual browser verification: channel select, message feed, review panel, WebSocket updates, reanalyze action, voice controls.
## Implementation Scope
This is a full redesign of the message/AI/dashboard path. Voice recording and live audio behavior should be preserved unless a change is required to integrate the React dashboard.
Implementation should proceed incrementally:
1. Backend boundaries and typed broadcaster.
2. Store/query improvements and indexes.
3. Analysis queue/context/client rewrite.
4. Split API routes.
5. React/Vite dashboard.
6. Verification and cleanup of old static dashboard code.

View File

@@ -0,0 +1,110 @@
# Media Music Phase 1 Design
## Goal
Add a first media playback phase focused on play music: users can queue, play, skip, and stop audio sources from the dashboard while preserving the existing Discord voice recorder, browser microphone transmit, and moderation capture flows.
## Scope
Phase 1 implements audio-only playback and queue control. Share screen/video streaming is intentionally reserved for phase 2, but the controller shape should leave room for a later `screen` mode using the already vendored `@dank074/discord-video-stream` APIs seen in `MythEclipse/StreamBot`.
## Recommended Architecture
Create a small media subsystem under `src/media/`:
- `mediaTypes.ts` defines `MediaMode`, `MediaQueueItem`, `MediaState`, and request/response types.
- `mediaQueue.ts` owns in-memory queue operations: add, current, next, remove current, clear, snapshot.
- `mediaResolver.ts` resolves initial supported sources. Phase 1 should support direct HTTP(S) URLs and local file paths. YouTube/search can be added later because it requires adding or wrapping yt-dlp behavior.
- `musicPlayer.ts` converts a media source to Ogg Opus using ffmpeg and feeds the existing `discordPlayer.playStream()`.
- `mediaController.ts` coordinates queue state, voice connection assumptions, play/skip/stop, and WebSocket broadcast state.
The existing `VoiceController` remains the owner of joining/leaving voice channels. Phase 1 does not create a second voice connection path. Music playback requires the bot to already be connected through the existing voice UI or `/api/connect`; otherwise the media route returns `409 VOICE_NOT_CONNECTED`.
## Data Flow
1. Browser submits a source to `/api/media/queue` with `{ source }`.
2. `mediaResolver` validates and resolves the source into `{ source, title, kind }`.
3. `mediaQueue` appends a `MediaQueueItem`.
4. If no item is playing, `mediaController` starts playback of the current queue item.
5. `musicPlayer` spawns ffmpeg and outputs Ogg Opus to `discordPlayer.playStream()`.
6. When playback finishes, the controller removes the completed item and starts the next item.
7. State changes broadcast over the existing moderation broadcaster as a JSON WebSocket event, or via a small media broadcaster wrapper if that keeps types cleaner.
## API Design
Add `src/routes/mediaRoutes.ts` mounted under `/api`:
- `GET /api/media/status` returns `{ playing, current, queue }`.
- `POST /api/media/queue` accepts `{ source: string }`, queues it, and returns the updated state.
- `POST /api/media/skip` skips current item and starts the next if present.
- `POST /api/media/stop` stops playback and clears the queue.
All routes should use `AppError` for boundary validation. Empty source returns `400 MISSING_MEDIA_SOURCE`. No voice connection returns `409 VOICE_NOT_CONNECTED`.
## Dashboard Design
Add a compact Media card to the existing voice tab for phase 1:
- Source input: URL or local path.
- Buttons: Queue/Play, Skip, Stop.
- Current item label and queue list.
Do not add a separate full media tab yet. The voice tab already owns voice channel selection and connection state, so colocating music controls there reduces user confusion.
## Playback Details
Use ffmpeg directly or the existing `src/audio/ffmpegProcess.ts` helper if it already fits. The target stream should be Ogg Opus because `DiscordPlayer.playStream()` currently expects `StreamType.OggOpus`.
Recommended ffmpeg output shape:
- Input: local file or HTTP(S) URL.
- Output format: `ogg`.
- Audio codec: `libopus`.
- Sample rate: `48000`.
- Channels: `2`.
The controller owns an `AbortController` or child process handle so skip/stop can terminate ffmpeg. Stop must also call `discordPlayer.stop()` so the audio player releases the current resource.
## Concurrency Rules
- Only one media item plays at a time.
- Browser microphone transmit and music playback both use `discordPlayer`; phase 1 should disable music start while `isStreaming` is true, or stop browser transmit before playback. Prefer returning `409 BROWSER_STREAM_ACTIVE` to avoid surprising the user.
- Voice recording can continue while music plays because recording uses the receiver pipeline and music uses the player pipeline.
- Skip is serialized: concurrent skip calls should return the same resulting state or reject with `409 MEDIA_SKIP_IN_PROGRESS`.
## Error Handling
- Unsupported source format: `400 UNSUPPORTED_MEDIA_SOURCE`.
- ffmpeg spawn failure: current item becomes failed, playback advances to the next queued item if present.
- ffmpeg runtime failure: log stderr summary, mark item failed, advance queue.
- Stop is idempotent: stopping while idle returns current idle state.
## Tests
Unit tests should cover:
- Queue add/next/remove/clear behavior.
- Resolver accepts HTTP(S) URLs and existing local paths, rejects empty/unsupported input.
- Controller rejects playback when voice is not connected.
- Controller starts next item after completion.
- Skip aborts current playback and advances queue.
- Routes validate payloads and call controller methods.
Manual verification should cover:
- Connect to a voice channel, queue a short audio URL or local file, hear playback in Discord.
- Queue two items, confirm automatic advance.
- Skip moves to the next item.
- Stop clears playback and queue.
- Existing voice recording and text moderation still work after media playback.
## Phase 2 Compatibility
Phase 2 can add `MediaMode = "screen"` and a `screenSharePlayer.ts` using StreamBot's pattern:
- `new Streamer(client)`
- `streamer.joinVoice(guildId, channelId)` only if phase 2 decides to own its own connection path
- `prepareStream(source, videoOptions, signal)`
- `playStream(output, streamer, { type: "go-live" }, signal)`
Phase 1 should not instantiate `Streamer`; it should only reserve type and controller seams so adding screen share later does not rewrite queue/status APIs.

View File

@@ -0,0 +1,37 @@
# Selfbot Performance and Feature Optimization Design
## Goal
Improve `vendor/discord.js-selfbot-v13` and the app's Discord client setup for lower memory use, more stable REST behavior, lower voice hot-path allocation, and better observability while preserving the existing public API used by the bot.
## Scope
This is an aggressive optimization pass. It includes app-level client configuration plus internal vendor patches in REST, voice, and gateway queue handling. Changes must remain compatible with existing imports from `discord.js-selfbot-v13` and the current moderation/voice flows.
## App Runtime Configuration
`src/index.ts` will instantiate `Client` with explicit low-memory options instead of using `new Client()` defaults. Message cache will be reduced or disabled because captured messages are persisted to the database. Sweepers will remove old message/thread cache entries. REST retry/timeouts will remain conservative to avoid bursty backlog sync behavior.
## Vendor REST Improvements
`src/rest/APIRequest.js` currently uses a module-level Undici dispatcher and rebuilds expensive headers per request. The dispatcher will become per REST manager/client so proxy or client-specific settings cannot leak across clients. The `x-super-properties` header will be cached and reused while client properties remain unchanged. `RequestHandler` will add exponential backoff with jitter for network aborts and 5xx retries.
## Vendor Voice Improvements
`PacketHandler` will clean all speaking timeouts during stream destruction. Voice stream cleanup will clear audio and video stream maps reliably. RTP/decrypt hot-path allocation will be reduced where possible without changing emitted packet payloads or stream API behavior.
## Vendor Gateway Queue Improvements
`WebSocketShard` will replace repeated `Array.shift()` dequeue with a cursor-backed queue to avoid O(n) work under high gateway send volume. `send(data, important)` behavior will remain compatible, including priority insertion.
## Observability
Vendor internals will emit or debug useful operational data where it does not create noisy logs by default: REST retry/backoff attempts, voice stream cleanup counts, and gateway queue size/rate-limit state. The app can wire these later if needed.
## Error Handling
REST retry backoff must not bypass existing rate limit handling. Captcha and MFA retry paths keep their current behavior. Voice cleanup must ignore already-closed streams and never throw during disconnect. Gateway queue changes must clear queued state on destroy exactly as before.
## Testing
Run `pnpm run lint`, `pnpm run typecheck`, and `pnpm run test`. If runtime Discord login/voice testing cannot be performed in this environment, report that limitation explicitly and identify the manual test path: login, message capture, backlog sync, voice connect, voice record, disconnect/reconnect.

View File

@@ -0,0 +1,24 @@
# Selfbot Workspace Submodule Design
## Goal
Replace the npm-resolved `discord.js-selfbot-v13` dependency with a custom repository checked into this project as a git submodule and consumed through pnpm workspace resolution.
## Approach
Use `vendor/discord.js-selfbot-v13` as the submodule path. Initialize it from `https://github.com/aiko-chan-ai/discord.js-selfbot-v13.git`, then change the submodule repository `origin` remote to `ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git`.
Configure pnpm workspaces so the root project and the vendored package are both workspace packages. Change the root dependency from the npm version range to `workspace:*`, forcing pnpm to resolve `discord.js-selfbot-v13` from the submodule package.
## Files to Change
- `.gitmodules`: track the new submodule path and URL.
- `pnpm-workspace.yaml`: include the root package and `vendor/discord.js-selfbot-v13`.
- `package.json`: change `discord.js-selfbot-v13` to `workspace:*`.
- `pnpm-lock.yaml`: refresh dependency resolution after the workspace change.
## Validation
After the submodule and dependency changes, run `pnpm install` to update the workspace lockfile and links, then run `pnpm run typecheck` to confirm the app still resolves the selfbot package.
If the vendored package requires a build step before TypeScript can resolve it, use that package's own scripts and rerun root validation.

View File

@@ -0,0 +1,76 @@
# Vendor Selfbot Dependency Modernization Design
## Goal
Modernize `/mnt/code/bete/vendor/discord.js-selfbot-v13` aggressively by auditing runtime dependencies and replacing the legacy development toolchain with Biome, matching the root project style.
## Scope
This work targets the vendored `discord.js-selfbot-v13` submodule only, plus root lockfile/workspace updates required for the root app to consume it. The root app behavior and public import surface should remain compatible with the existing `discord.js-selfbot-v13` API.
## Approach
Audit all vendor `dependencies` and `devDependencies` against actual usage in `src`, `typings`, config files, and package scripts. Classify each package as keep, upgrade, remove, or replace. Apply changes aggressively, but only when usage evidence supports the change.
Replace the vendor's ESLint, Prettier, TSLint, and dtslint-based workflow with Biome. Keep TypeScript validation. Keep `tsd` only if the vendor has type assertion tests that `tsc --noEmit` cannot cover.
## Toolchain Design
Vendor scripts should use Biome for linting and formatting:
- `lint`: `biome check . --diagnostic-level=error`
- `format`: `biome format --write .`
- `test:typescript`: `tsc --noEmit` plus `tsd` only if type assertion tests exist
- `test`: run lint and TypeScript validation
Remove deprecated or redundant dev dependencies after scripts no longer reference them:
- `eslint`
- `eslint-config-prettier`
- `eslint-plugin-import`
- `eslint-plugin-prettier`
- `prettier`
- `tslint`
- `dtslint`
Add `@biomejs/biome` to the vendor dev dependencies unless the workspace can reliably use the root Biome package for the vendor scripts.
## Runtime Dependency Design
Runtime dependencies are reviewed one by one. Candidate packages include:
- `find-process`
- `tree-kill`
- `prism-media`
- `werift-rtp`
- `fetch-cookie`
- `tough-cookie`
- `qrcode`
- `otplib`
- `ws`
- `undici`
- `discord-api-types`
- `@discordjs/builders`
- `@discordjs/collection`
- `@sapphire/async-queue`
- `@sapphire/shapeshift`
For each dependency, search source usage before changing it. Remove unused packages. Upgrade packages that remain used. Replace packages when Node 20+ or a smaller maintained package covers the same use case without changing public behavior.
## Validation
Validation must run in both vendor and root contexts:
1. Vendor dependency install/update.
2. Vendor lint with Biome.
3. Vendor TypeScript/type validation.
4. Root `pnpm install` to refresh workspace lockfile.
5. Root `pnpm run typecheck`.
6. Root `pnpm run lint`.
7. Import smoke check from the root app to ensure `discord.js-selfbot-v13` still resolves through the workspace link.
## Stop Rules
Stop and ask before making a change that would intentionally alter the public `discord.js-selfbot-v13` API, require ESM-only migration for the library entrypoint, or remove a runtime feature that the root app could use.
If a dependency upgrade requires broad internal rewrites, document the blocker and present options instead of forcing a risky migration.

View File

@@ -0,0 +1,71 @@
# Media Echo Fix and YouTube Screenshare Design
## Context
Media playback currently uses the same `DiscordPlayer` instance as the browser audio bridge. The browser bridge is started during webserver startup and subscribes the shared player to the active voice connection. Music playback also uses that player. This shared ownership can let the bridge interfere with media playback and contribute to voice audio being reflected back during playback.
The project already includes `@dank074/discord-video-stream`, which supports Discord Go Live video streaming from a direct media URL or readable stream.
## Goals
- Prevent voice audio from being reflected back while music/media playback is active.
- Keep normal music playback behavior for existing `/api/media/queue` users.
- Add a YouTube screenshare path that streams video through Discord Go Live.
- Fail clearly when voice is not connected, another media mode is busy, or screenshare dependencies fail.
## Non-goals
- Replace the existing voice recorder pipeline.
- Disable message or voice monitoring during music playback.
- Build full production UI for screenshare controls in the first implementation.
- Add Discord integration tests that require a live account or server.
## Design
### Audio player ownership
`DiscordPlayer` will track which subsystem owns the active stream: `none`, `browser-bridge`, `music`, or `screen`. A caller may only start playback when the player has no owner or when the caller owns the current stream. This prevents the browser bridge from overwriting music or screen playback.
The browser bridge in `src/webserver.ts` will not start at server boot. It will be created lazily only when browser audio arrives and no media playback is active. When media playback starts, the bridge is stopped or left inactive so it cannot transmit captured audio back into Discord.
Music playback will claim the `music` owner before calling `playStream`. When music finishes or stops, ownership is released and browser audio may resume later if the browser sends new audio.
### Screenshare mode
The media queue endpoint will accept an optional `mode` field. If omitted, mode defaults to `music` to preserve existing API behavior. `mode: "screen"` starts a separate screenshare flow instead of audio-only music playback.
A new `ScreenShareController` will:
1. Verify a voice channel is connected.
2. Reject start if music or browser bridge owns playback, or if another screen stream is active.
3. Resolve a YouTube URL to a direct playable video URL through the existing yt-dlp utilities.
4. Use `@dank074/discord-video-stream` with `prepareStream(...)` and `playStream(..., { type: "go-live" })`.
5. Track active screen state and provide stop behavior.
Screenshare state will be exposed through media state as the active mode so the frontend can distinguish music from screen playback.
### Busy-state rules
- Music cannot start while screen is active.
- Screen cannot start while music is active.
- Browser bridge cannot start while music or screen is active.
- Stop stops the active media mode and releases ownership.
### Error handling
- `VOICE_NOT_CONNECTED`: media or screen requested before joining voice.
- `MEDIA_BUSY`: another active media mode owns playback.
- `SCREEN_STREAM_FAILED`: yt-dlp, stream preparation, or Go Live playback fails.
Errors should surface through existing Express error handling as JSON responses.
## Testing
- Unit test `DiscordPlayer` ownership rules: browser bridge cannot override music; music releases ownership on stop.
- Media controller tests: default mode remains music, screen mode is routed separately, and busy conflicts reject with `MEDIA_BUSY`.
- Route tests: `/api/media/queue` accepts optional `mode` and passes it to the controller.
- Screenshare controller tests mock yt-dlp and `@dank074/discord-video-stream`; no live Discord account is required.
## Rollout
Implement ownership first and verify existing music tests still pass. Then add mode parsing and the screenshare controller behind the same media route. UI changes can follow as a small enhancement after API behavior is stable.

View File

@@ -0,0 +1,87 @@
# Session Full Recording Design
## Context
The recorder currently writes per-user OGG segments under `recordings/<userId>/`. Each segment has JSON metadata with user identity, bot flag, segment timing, and filename. The requested addition is a second recording view: one full-session OGG from the time the bot joins a voice channel until it leaves, while preserving the current per-user recording files.
Bot/self audio is excluded before segment creation, so session-level output should only include human participants.
## Goals
- Track one recording session from successful voice join until disconnect/leave.
- Preserve existing per-user OGG segment behavior.
- Create a background full-session OGG/Opus mix after the session ends.
- Store session metadata with duration, participants, segment references, output status, and full recording path.
- Keep muxing failures isolated from voice connection shutdown.
## Non-goals
- Real-time mixed full-session recording.
- Replacing per-user segment recording.
- Dashboard UI for session playback in this phase.
- Database-backed mux job retries in this phase.
## Output structure
A completed session writes:
```text
recordings/
sessions/
<recordingSessionId>/
full.ogg
session.json
```
`recordingSessionId` is based on guild ID, channel ID, and session start time: `<guildId>-<channelId>-<sessionStartTime>`.
`session.json` contains:
- `sessionId`
- `guildId`
- `channelId`
- `channelName`
- `startTime`
- `endTime`
- `durationMs`
- `status`: `completed`, `failed`, or `empty`
- `outputFile`: relative path to `full.ogg` when present
- `participants`: non-bot users observed in the session
- `segments`: per-user segment metadata references with absolute timing
- `error`: failure message when muxing fails
Per-user segment JSON also records the shared `recordingSessionId` so full-session muxing can identify which files belong to the same join/leave session.
## Lifecycle
1. `startRecording()` creates a session object after the voice connection reaches ready state.
2. Each non-bot speaking user still gets the existing per-user `SegmentManager` flow.
3. Each finished segment is registered with the active session using its metadata path, OGG path, user ID, start time, and end time.
4. `stopRecording(guildId)` or connection destruction finalizes the active session with `endTime`.
5. Finalization starts muxing in the background and does not block disconnect.
6. Muxing writes `session.json` with `empty`, `completed`, or `failed` status.
## Muxing design
The post-processor reads all registered segment metadata for the session. It builds an ffmpeg `filter_complex` that delays each input by `segment.startTime - session.startTime` milliseconds, mixes all delayed inputs with `amix`, and encodes the result to OGG/Opus.
For a session with no human segments, muxing skips ffmpeg and writes `session.json` with `status: "empty"` and the full session duration.
For successful muxing, it writes `full.ogg` and `session.json` with `status: "completed"`.
For failed muxing, it writes `session.json` with `status: "failed"` and the error message.
## Error handling
- Failure to write `session.json` is logged and does not crash shutdown.
- ffmpeg failure is captured in metadata as `status: "failed"`.
- Missing or empty segment files are skipped from the mix and recorded as skipped references if needed.
- Background mux errors never reject `stopRecording()`.
## Testing
- Unit test session metadata creation from join to stop.
- Unit test bot/self users do not register participants or segments.
- Unit test mux filter generation with timeline offsets.
- Unit test empty sessions write `status: "empty"` without calling ffmpeg.
- Unit test stop triggers background finalization without awaiting ffmpeg.

View File

@@ -0,0 +1,85 @@
# Internal Streamer Replacement Design
## Summary
Replace the external `@dank074/discord-video-stream` dependency with an internal streaming module that uses `discord.js-selfbot-v13` private APIs to deliver the same screen share behavior (video + audio) with identical UI/API surface.
## Goals
- Maintain feature parity for screen share (video + audio, 720p @ 30fps, bitrate 2500/4000, H264, audio on).
- Keep existing UI and API contracts unchanged (`/api/media/queue` with `mode: "screen"`).
- Remove `@dank074/discord-video-stream` from dependencies and delete `vendor/Discord-video-stream`.
- Ensure clean lifecycle handling (start/stop, cleanup, error reporting).
## Non-Goals
- Rewriting WebRTC/RTP stack from scratch.
- Changing media queue behavior or UI layout.
- Adding new screen share modes or settings.
## Architecture Overview
Introduce a new internal module under `src/streaming/` that encapsulates:
- Voice/session management using private `discord.js-selfbot-v13` APIs.
- FFmpeg preparation for H264 + Opus (AnnexB video + Opus audio).
- Stream playback into the internal dispatcher.
`screenShareController` will depend on this module instead of `@dank074/discord-video-stream`.
## Components
### 1) Streaming Session Module (`src/streaming/`)
Proposed exports:
- `createStreamSession(client)`
- Joins or reuses voice connection for video streaming.
- Exposes a `session` object with `startVideo()`, `stopVideo()`, and `sendStream(stream)` hooks.
- `prepareFfmpegStream(source, opts)`
- Spawns ffmpeg with the same parameters used today.
- Returns `{ command, output }` (output is a Readable stream).
- `playPreparedStream(output, session)`
- Pipes the prepared stream into the internal dispatcher.
- Returns a promise that resolves when playback completes.
### 2) Screen Share Controller (`src/media/screenShareController.ts`)
- Replace Streamer/prepareStream/playStream with internal module usage.
- Keep the public API identical (`start(source)` returning `ScreenSharePlayback`).
### 3) Web Server Wiring (`src/webserver.ts`)
- Remove `Streamer` instantiation and dependencies.
- Pass only `getVoiceStatus` and new streaming module dependencies into `createScreenShareController`.
## Data Flow
1. User queues screen share via `/api/media/queue` with `mode: "screen"`.
2. `MediaController` calls `screenShareController.start(source)`.
3. `screenShareController` resolves URL, calls `prepareFfmpegStream`.
4. `createStreamSession` ensures voice connection and dispatcher ready.
5. `playPreparedStream` sends output to Discord.
6. On completion or stop, cleanup runs and state updates propagate.
## Error Handling
- Voice not connected: throw `VOICE_NOT_CONNECTED`.
- FFmpeg spawn/exit failure: throw `SCREEN_STREAM_FAILED`.
- Dispatcher error: stop stream, cleanup, log error, set state idle.
## Lifecycle Rules
- `start()` always stops any active stream first.
- `stop()` kills ffmpeg, stops dispatcher, and resets internal state.
- Completion resolves `done` promise and triggers cleanup.
## Testing Strategy
- Unit tests for `screenShareController`:
- Calls to `prepareFfmpegStream` and `playPreparedStream` on `start()`.
- Ensures `stop()` kills ffmpeg and ends session.
- Unit tests for `streaming` module:
- Session initialization and cleanup logic with mocked private APIs.
## Migration Steps
1. Implement `src/streaming/` module.
2. Update `screenShareController` to use internal module.
3. Remove `@dank074/discord-video-stream` imports and wiring.
4. Delete `vendor/Discord-video-stream` directory.
5. Update `package.json` dependencies.
6. Update tests.
## Risks
- Private `discord.js-selfbot-v13` APIs may change.
- Harder debugging if internal dispatcher behavior differs.
## Rollback Plan
- Revert to previous commit that restores `@dank074/discord-video-stream` and the vendor directory.

24
drizzle.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import { defineConfig } from "drizzle-kit";
const databaseType = process.env.DATABASE_TYPE || "sqlite";
const databaseUrl = process.env.DATABASE_URL;
export default defineConfig({
schema: "./src/database/schema.ts",
out: "./drizzle/migrations",
dialect: databaseType === "postgres" ? "postgresql" : "sqlite",
dbCredentials:
databaseType === "postgres"
? databaseUrl
? { url: databaseUrl }
: {
host: process.env.POSTGRES_HOST || "localhost",
port: parseInt(process.env.POSTGRES_PORT || "5432", 10),
user: process.env.POSTGRES_USER || "postgres",
password: process.env.POSTGRES_PASSWORD || "",
database: process.env.POSTGRES_DB || "moderation_bot",
}
: {
url: "file:./.muxer-queue.db",
},
});

View File

@@ -0,0 +1,69 @@
CREATE TABLE "attachments" (
"id" text PRIMARY KEY NOT NULL,
"message_id" text NOT NULL,
"guild_id" text NOT NULL,
"channel_id" text NOT NULL,
"thread_id" text,
"user_id" text NOT NULL,
"filename" text NOT NULL,
"size" integer NOT NULL,
"type" text NOT NULL,
"discord_url" text NOT NULL,
"uploaded_url" text,
"upload_status" text DEFAULT 'pending' NOT NULL,
"upload_error" text,
"created_at" bigint NOT NULL,
"uploaded_at" bigint
);
--> statement-breakpoint
CREATE TABLE "messages" (
"id" text PRIMARY KEY NOT NULL,
"guild_id" text NOT NULL,
"channel_id" text NOT NULL,
"thread_id" text,
"user_id" text NOT NULL,
"username" text NOT NULL,
"avatar_url" text,
"content" text NOT NULL,
"edited_content" text,
"created_at" bigint NOT NULL,
"edited_at" bigint,
"deleted_at" bigint,
"type" text DEFAULT 'text' NOT NULL,
"metadata" text,
"ai_status" text DEFAULT 'pending' NOT NULL,
"ai_moderation_flags" text,
"ai_moderation_score" real,
"ai_moderation_raw" text,
"ai_analysis" text,
"ai_analyzed_at" bigint,
"ai_error" text
);
--> statement-breakpoint
CREATE TABLE "muxer_jobs" (
"id" text PRIMARY KEY NOT NULL,
"data" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"attempts" integer DEFAULT 0 NOT NULL,
"maxAttempts" integer DEFAULT 3 NOT NULL,
"createdAt" bigint NOT NULL,
"updatedAt" bigint NOT NULL,
"error" text
);
--> statement-breakpoint
CREATE TABLE "ui_state" (
"key" text PRIMARY KEY NOT NULL,
"value" text NOT NULL,
"updated_at" bigint NOT NULL
);
--> statement-breakpoint
ALTER TABLE "attachments" ADD CONSTRAINT "fk_attachments_message_id" FOREIGN KEY ("message_id") REFERENCES "public"."messages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_attachments_channel" ON "attachments" USING btree ("channel_id");--> statement-breakpoint
CREATE INDEX "idx_attachments_message" ON "attachments" USING btree ("message_id");--> statement-breakpoint
CREATE INDEX "idx_attachments_status" ON "attachments" USING btree ("upload_status");--> statement-breakpoint
CREATE INDEX "idx_messages_channel" ON "messages" USING btree ("channel_id");--> statement-breakpoint
CREATE INDEX "idx_messages_user" ON "messages" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_messages_created" ON "messages" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "idx_messages_thread" ON "messages" USING btree ("thread_id");--> statement-breakpoint
CREATE INDEX "idx_muxer_jobs_status" ON "muxer_jobs" USING btree ("status");--> statement-breakpoint
CREATE INDEX "idx_muxer_jobs_createdAt" ON "muxer_jobs" USING btree ("createdAt");

View File

@@ -0,0 +1,22 @@
CREATE TABLE "ai_analysis_runs" (
"id" text PRIMARY KEY NOT NULL,
"conversation_key" text NOT NULL,
"target_message_ids" text NOT NULL,
"model" text NOT NULL,
"request_tokens_estimate" integer,
"response_raw" text,
"status" text DEFAULT 'pending' NOT NULL,
"error" text,
"created_at" bigint NOT NULL,
"completed_at" bigint
);
--> statement-breakpoint
CREATE INDEX "idx_ai_analysis_runs_conversation_key" ON "ai_analysis_runs" ("conversation_key");--> statement-breakpoint
CREATE INDEX "idx_ai_analysis_runs_status" ON "ai_analysis_runs" ("status");--> statement-breakpoint
CREATE INDEX "idx_ai_analysis_runs_created_at" ON "ai_analysis_runs" ("created_at");--> statement-breakpoint
CREATE INDEX "idx_attachments_channel_created" ON "attachments" ("channel_id","created_at","id");--> statement-breakpoint
CREATE INDEX "idx_attachments_thread_created" ON "attachments" ("thread_id","created_at","id");--> statement-breakpoint
CREATE INDEX "idx_messages_channel_created" ON "messages" ("channel_id","created_at","id");--> statement-breakpoint
CREATE INDEX "idx_messages_thread_created" ON "messages" ("thread_id","created_at","id");--> statement-breakpoint
CREATE INDEX "idx_messages_ai_status_created" ON "messages" ("ai_status","created_at","id");--> statement-breakpoint
CREATE INDEX "idx_messages_guild_ai_status_created" ON "messages" ("guild_id","ai_status","created_at","id");

View File

@@ -0,0 +1,511 @@
{
"id": "2b9e2347-dd99-4bf8-bbcb-f407af29ca83",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.attachments": {
"name": "attachments",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"message_id": {
"name": "message_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"guild_id": {
"name": "guild_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"channel_id": {
"name": "channel_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"thread_id": {
"name": "thread_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"discord_url": {
"name": "discord_url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"uploaded_url": {
"name": "uploaded_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"upload_status": {
"name": "upload_status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"upload_error": {
"name": "upload_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"uploaded_at": {
"name": "uploaded_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_attachments_channel": {
"name": "idx_attachments_channel",
"columns": [
{
"expression": "channel_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_attachments_message": {
"name": "idx_attachments_message",
"columns": [
{
"expression": "message_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_attachments_status": {
"name": "idx_attachments_status",
"columns": [
{
"expression": "upload_status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"fk_attachments_message_id": {
"name": "fk_attachments_message_id",
"tableFrom": "attachments",
"tableTo": "messages",
"columnsFrom": [
"message_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.messages": {
"name": "messages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"guild_id": {
"name": "guild_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"channel_id": {
"name": "channel_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"thread_id": {
"name": "thread_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"avatar_url": {
"name": "avatar_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true
},
"edited_content": {
"name": "edited_content",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"edited_at": {
"name": "edited_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"deleted_at": {
"name": "deleted_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'text'"
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ai_status": {
"name": "ai_status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"ai_moderation_flags": {
"name": "ai_moderation_flags",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ai_moderation_score": {
"name": "ai_moderation_score",
"type": "real",
"primaryKey": false,
"notNull": false
},
"ai_moderation_raw": {
"name": "ai_moderation_raw",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ai_analysis": {
"name": "ai_analysis",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ai_analyzed_at": {
"name": "ai_analyzed_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"ai_error": {
"name": "ai_error",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_messages_channel": {
"name": "idx_messages_channel",
"columns": [
{
"expression": "channel_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_messages_user": {
"name": "idx_messages_user",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_messages_created": {
"name": "idx_messages_created",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_messages_thread": {
"name": "idx_messages_thread",
"columns": [
{
"expression": "thread_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.muxer_jobs": {
"name": "muxer_jobs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"data": {
"name": "data",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"attempts": {
"name": "attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"maxAttempts": {
"name": "maxAttempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 3
},
"createdAt": {
"name": "createdAt",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"updatedAt": {
"name": "updatedAt",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_muxer_jobs_status": {
"name": "idx_muxer_jobs_status",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_muxer_jobs_createdAt": {
"name": "idx_muxer_jobs_createdAt",
"columns": [
{
"expression": "createdAt",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ui_state": {
"name": "ui_state",
"schema": "",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,799 @@
{
"id": "79348c3a-b314-4bfd-88f3-7ddfbea4427e",
"prevId": "2b9e2347-dd99-4bf8-bbcb-f407af29ca83",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.ai_analysis_runs": {
"name": "ai_analysis_runs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"conversation_key": {
"name": "conversation_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"target_message_ids": {
"name": "target_message_ids",
"type": "text",
"primaryKey": false,
"notNull": true
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true
},
"request_tokens_estimate": {
"name": "request_tokens_estimate",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"response_raw": {
"name": "response_raw",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"completed_at": {
"name": "completed_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_ai_analysis_runs_conversation_key": {
"name": "idx_ai_analysis_runs_conversation_key",
"columns": [
{
"expression": "conversation_key",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_ai_analysis_runs_status": {
"name": "idx_ai_analysis_runs_status",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_ai_analysis_runs_created_at": {
"name": "idx_ai_analysis_runs_created_at",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.attachments": {
"name": "attachments",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"message_id": {
"name": "message_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"guild_id": {
"name": "guild_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"channel_id": {
"name": "channel_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"thread_id": {
"name": "thread_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"discord_url": {
"name": "discord_url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"uploaded_url": {
"name": "uploaded_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"upload_status": {
"name": "upload_status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"upload_error": {
"name": "upload_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"uploaded_at": {
"name": "uploaded_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_attachments_channel": {
"name": "idx_attachments_channel",
"columns": [
{
"expression": "channel_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_attachments_message": {
"name": "idx_attachments_message",
"columns": [
{
"expression": "message_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_attachments_status": {
"name": "idx_attachments_status",
"columns": [
{
"expression": "upload_status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_attachments_channel_created": {
"name": "idx_attachments_channel_created",
"columns": [
{
"expression": "channel_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_attachments_thread_created": {
"name": "idx_attachments_thread_created",
"columns": [
{
"expression": "thread_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"fk_attachments_message_id": {
"name": "fk_attachments_message_id",
"tableFrom": "attachments",
"tableTo": "messages",
"columnsFrom": [
"message_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.messages": {
"name": "messages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"guild_id": {
"name": "guild_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"channel_id": {
"name": "channel_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"thread_id": {
"name": "thread_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"avatar_url": {
"name": "avatar_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true
},
"edited_content": {
"name": "edited_content",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"edited_at": {
"name": "edited_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"deleted_at": {
"name": "deleted_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'text'"
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ai_status": {
"name": "ai_status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"ai_moderation_flags": {
"name": "ai_moderation_flags",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ai_moderation_score": {
"name": "ai_moderation_score",
"type": "real",
"primaryKey": false,
"notNull": false
},
"ai_moderation_raw": {
"name": "ai_moderation_raw",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ai_analysis": {
"name": "ai_analysis",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ai_analyzed_at": {
"name": "ai_analyzed_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"ai_error": {
"name": "ai_error",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_messages_channel": {
"name": "idx_messages_channel",
"columns": [
{
"expression": "channel_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_messages_user": {
"name": "idx_messages_user",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_messages_created": {
"name": "idx_messages_created",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_messages_thread": {
"name": "idx_messages_thread",
"columns": [
{
"expression": "thread_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_messages_channel_created": {
"name": "idx_messages_channel_created",
"columns": [
{
"expression": "channel_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_messages_thread_created": {
"name": "idx_messages_thread_created",
"columns": [
{
"expression": "thread_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_messages_ai_status_created": {
"name": "idx_messages_ai_status_created",
"columns": [
{
"expression": "ai_status",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_messages_guild_ai_status_created": {
"name": "idx_messages_guild_ai_status_created",
"columns": [
{
"expression": "guild_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "ai_status",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.muxer_jobs": {
"name": "muxer_jobs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"data": {
"name": "data",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"attempts": {
"name": "attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"maxAttempts": {
"name": "maxAttempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 3
},
"createdAt": {
"name": "createdAt",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"updatedAt": {
"name": "updatedAt",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_muxer_jobs_status": {
"name": "idx_muxer_jobs_status",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_muxer_jobs_createdAt": {
"name": "idx_muxer_jobs_createdAt",
"columns": [
{
"expression": "createdAt",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ui_state": {
"name": "ui_state",
"schema": "",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1778750697764,
"tag": "0000_rare_kitty_pryde",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1778764447718,
"tag": "0001_curious_zodiak",
"breakpoints": true
}
]
}

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Discord Moderation Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

259
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,259 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { DashboardLayout } from "./components/layout/DashboardLayout";
import { MediaPanel } from "./components/media/MediaPanel";
import { MessagesPanel } from "./components/messages/MessagesPanel";
import { ReviewPanel } from "./components/review/ReviewPanel";
import { Tabs, TabsContent } from "./components/ui/tabs";
import { VoicePanel } from "./components/voice/VoicePanel";
import { AuthOverlay } from "./components/layout/AuthOverlay";
import { useDashboardSocket } from "./hooks/useDashboardSocket";
import { mergeMessages, useMessages } from "./hooks/useMessages";
import { useMediaControl } from "./hooks/useMediaControl";
import { useUIState } from "./hooks/useUIState";
import { useVoiceControl } from "./hooks/useVoiceControl";
import type { MessageRecord } from "./types/messages";
import type { DashboardTab } from "./types/ui";
import type { ActiveSpeaker } from "./types/voice";
const SAMPLE_RATE = 24000;
const CHANNELS = 1;
export default function App() {
const { uiState, setUIState, patchUIState } = useUIState();
const voice = useVoiceControl();
const media = useMediaControl();
const messages = useMessages();
const [activeSpeakers, setActiveSpeakers] = useState<ActiveSpeaker[]>([]);
const [levels, setLevels] = useState<number[]>(Array.from({ length: 32 }, () => 0.04));
const [isListening, setIsListening] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(!!localStorage.getItem("admin-password"));
const audioContextListenRef = useRef<AudioContext | null>(null);
const audioContextTransmitRef = useRef<AudioContext | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const processorRef = useRef<ScriptProcessorNode | null>(null);
const userTimelinesRef = useRef(new Map<number, number>());
const activeTab = uiState.activeTab || "voice";
const selectedVoiceGuild = uiState.selectedVoiceGuild || uiState.selectedGuild || "";
const selectedVoiceChannel = uiState.selectedVoiceChannel || "";
const selectedTextGuild = uiState.selectedTextGuild || uiState.selectedGuild || "";
const selectedTextChannel = uiState.selectedTextChannel || "";
const handleIncomingPcm = useCallback((data: ArrayBuffer) => {
const headerView = new DataView(data, 0, 4);
const userIdHash = headerView.getInt32(0, true);
const audioData = data.slice(4);
const int16Array = new Int16Array(audioData);
let sum = 0;
for (const sample of int16Array) sum += Math.abs(sample / 32768);
const average = int16Array.length ? sum / int16Array.length : 0;
setLevels((prev) => prev.map((_, index) => Math.max(0.04, average * (0.5 + Math.sin(index * 0.6 + Date.now() / 140) * 0.35 + 0.65) * 5)));
const audioContext = audioContextListenRef.current;
if (!isListening || !audioContext) return;
const float32Array = new Float32Array(int16Array.length);
for (let i = 0; i < int16Array.length; i++) float32Array[i] = int16Array[i] / 32768;
const audioBuffer = audioContext.createBuffer(CHANNELS, float32Array.length / CHANNELS, SAMPLE_RATE);
audioBuffer.getChannelData(0).set(float32Array);
const source = audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContext.destination);
const currentTime = audioContext.currentTime;
let nextStart = userTimelinesRef.current.get(userIdHash) || 0;
if (nextStart < currentTime) nextStart = currentTime + 0.05;
source.start(nextStart);
userTimelinesRef.current.set(userIdHash, nextStart + audioBuffer.duration);
}, [isListening]);
const socket = useDashboardSocket({
onUIState: (state) => setUIState((prev) => ({ ...prev, ...state })),
onUserState: setActiveSpeakers,
onMessageCreated: (message) => messages.setMessages((prev) => mergeMessages(prev, [message])),
onMessageUpdated: (message) => messages.setMessages((prev) => prev.map((item) => (item.id === message.id ? { ...item, ...message } as MessageRecord : item))),
onMessageDeleted: (message) => messages.setMessages((prev) => prev.map((item) => (item.id === message.id ? { ...item, type: "deleted" } : item))),
onMessageAnalyzed: (message) => messages.setMessages((prev) => mergeMessages(prev, [message])),
onAttachmentUploaded: () => messages.fetchMessages(selectedTextChannel).catch(() => undefined),
onMediaState: media.setMediaState,
onPcm: handleIncomingPcm,
});
const stopStreamingLocal = useCallback(() => {
setIsStreaming(false);
if (processorRef.current) {
processorRef.current.disconnect();
processorRef.current = null;
}
if (audioContextTransmitRef.current) {
audioContextTransmitRef.current.close();
audioContextTransmitRef.current = null;
}
if (streamRef.current) {
for (const track of streamRef.current.getTracks()) track.stop();
streamRef.current = null;
}
setLevels(Array.from({ length: 32 }, () => 0.04));
}, []);
const startStreamingLocal = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
setIsStreaming(true);
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
const audioContext = new AudioContextCtor({ sampleRate: SAMPLE_RATE });
audioContextTransmitRef.current = audioContext;
const source = audioContext.createMediaStreamSource(stream);
const processor = audioContext.createScriptProcessor(4096, 1, 1);
processorRef.current = processor;
source.connect(processor);
processor.connect(audioContext.destination);
processor.onaudioprocess = (event) => {
if (!socket.socketRef.current || socket.socketRef.current.readyState !== WebSocket.OPEN) return;
const inputData = event.inputBuffer.getChannelData(0);
const pcmData = new Int16Array(inputData.length);
for (let i = 0; i < inputData.length; i++) {
pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767;
}
socket.socketRef.current.send(pcmData.buffer);
// Update local levels from mic
let sum = 0;
for (let i = 0; i < inputData.length; i++) sum += Math.abs(inputData[i]);
const average = inputData.length ? sum / inputData.length : 0;
setLevels((prev) => prev.map((_, index) => Math.max(0.04, average * (0.5 + Math.sin(index * 0.6 + Date.now() / 140) * 0.35 + 0.65) * 5)));
};
} catch (err) {
console.error("Microphone access failed:", err);
setIsStreaming(false);
throw err;
}
}, [socket.socketRef]);
const toggleStreaming = useCallback(async () => {
if (isStreaming) {
stopStreamingLocal();
await patchUIState({ isStreaming: false });
} else {
await startStreamingLocal();
await patchUIState({ isStreaming: true });
}
}, [isStreaming, startStreamingLocal, stopStreamingLocal, patchUIState]);
useEffect(() => {
if (selectedVoiceGuild) {
voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
}
}, [selectedVoiceGuild]);
useEffect(() => {
if (selectedTextGuild) {
voice.loadTextTargets(selectedTextGuild).catch(() => undefined);
}
}, [selectedTextGuild]);
useEffect(() => {
if (selectedTextChannel) {
messages.fetchMessages(selectedTextChannel).catch(() => undefined);
}
}, [selectedTextChannel]);
const toggleListening = useCallback(async () => {
if (isListening) {
await audioContextListenRef.current?.suspend();
userTimelinesRef.current.clear();
setIsListening(false);
await patchUIState({ isListening: false });
return;
}
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
audioContextListenRef.current ??= new AudioContextCtor({ sampleRate: SAMPLE_RATE });
await audioContextListenRef.current.resume();
setIsListening(true);
await patchUIState({ isListening: true });
}, [isListening, patchUIState]);
const tabs = useMemo(() => ["voice", "media", "messages", "review"] as DashboardTab[], []);
return (
<DashboardLayout
activeTab={activeTab}
wsStatus={socket.status}
voiceStatus={voice.voiceStatus}
onTabChange={(tab) => patchUIState({ activeTab: tab })}
>
<div className="md:hidden">
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
<div className="mb-4 grid grid-cols-4 gap-2 rounded-2xl bg-muted p-1">
{tabs.map((tab) => (
<button key={tab} className={`rounded-xl px-2 py-2 text-xs font-medium ${activeTab === tab ? "bg-background text-foreground" : "text-muted-foreground"}`} onClick={() => patchUIState({ activeTab: tab })}>
{tab}
</button>
))}
</div>
</Tabs>
</div>
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
<TabsContent value="voice">
{!isAuthenticated ? (
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
) : (
<VoicePanel
guilds={voice.guilds}
channels={voice.voiceChannels}
selectedGuild={selectedVoiceGuild}
selectedChannel={selectedVoiceChannel}
status={voice.voiceStatus}
loading={voice.loading}
activeSpeakers={activeSpeakers}
levels={levels}
isListening={isListening}
isStreaming={isStreaming}
onGuildChange={(guildId) => patchUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: "" })}
onChannelChange={(channelId) => patchUIState({ selectedVoiceChannel: channelId })}
onJoin={() => voice.joinVoice(selectedVoiceGuild, selectedVoiceChannel)}
onDisconnect={() => voice.leaveVoice()}
onListenToggle={toggleListening}
onStreamingToggle={toggleStreaming}
/>
)}
</TabsContent>
<TabsContent value="media">
{!isAuthenticated ? (
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
) : (
<MediaPanel
state={media.mediaState}
loading={media.loading}
onQueueMusic={(source) => media.enqueue(source, "music")}
onStartScreen={(source) => media.enqueue(source, "screen")}
onSkip={media.skip}
onStop={media.stop}
onVolumeChange={media.setVolume}
/>
)}
</TabsContent>
<TabsContent value="messages">
<MessagesPanel
guilds={voice.guilds}
channels={voice.textChannels}
selectedGuild={selectedTextGuild}
selectedChannel={selectedTextChannel}
messages={messages.messages}
onGuildChange={(guildId) => patchUIState({ selectedTextGuild: guildId, selectedTextChannel: "" })}
onChannelChange={(channelId) => patchUIState({ selectedTextChannel: channelId })}
onReanalyze={messages.reanalyze}
/>
</TabsContent>
<TabsContent value="review">
<ReviewPanel messages={messages.messages} onReanalyze={messages.reanalyze} />
</TabsContent>
</Tabs>
</DashboardLayout>
);
}

8
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,8 @@
import { request } from "./client";
export async function login(password: string): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ password }),
});
}

View File

@@ -0,0 +1,96 @@
export type AIStatus = "pending" | "clean" | "warn" | "flagged" | "error";
export interface MessageRecord {
id: string;
guild_id: string;
channel_id: string;
thread_id: string | null;
user_id: string;
username: string;
avatar_url: string | null;
content: string;
edited_content: string | null;
created_at: number;
edited_at: number | null;
deleted_at: number | null;
type: "text" | "edited" | "deleted";
metadata: string | null;
ai_status?: AIStatus | null;
ai_moderation_flags?: string | null;
ai_moderation_score?: number | null;
ai_moderation_raw?: string | null;
ai_analysis?: string | null;
ai_analyzed_at?: number | null;
ai_error?: string | null;
}
export interface PageResult<T> {
data: T[];
nextCursor: string | null;
}
export type DashboardMessage = MessageRecord;
export interface Guild {
id: string;
name: string;
icon: string | null;
}
class ApiError extends Error {
code: string;
statusCode: number;
constructor(code: string, message: string, statusCode: number) {
super(message);
this.name = "ApiError";
this.code = code;
this.statusCode = statusCode;
}
}
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
const password = localStorage.getItem("admin-password");
const res = await fetch(path, {
headers: {
"Content-Type": "application/json",
...(password ? { "X-Admin-Password": password } : {}),
},
...init,
});
if (!res.ok) {
let message = res.statusText;
let code = "REQUEST_FAILED";
try {
const body = (await res.json()) as { error?: string; message?: string };
if (body.message) message = body.message;
if (body.error) code = body.error;
} catch {
// ignore parse errors
}
throw new ApiError(code, message, res.status);
}
return res.json() as Promise<T>;
}
export async function listMessages(
params: URLSearchParams,
): Promise<PageResult<MessageRecord>> {
return request<PageResult<MessageRecord>>(`/api/messages?${params}`);
}
export async function listReview(
params: URLSearchParams,
): Promise<PageResult<MessageRecord>> {
return request<PageResult<MessageRecord>>(`/api/review?${params}`);
}
export async function reanalyzeMessage(id: string): Promise<void> {
await request<void>(`/api/messages/${id}/reanalyze`, { method: "POST" });
}
export async function getGuilds(): Promise<Guild[]> {
return request<Guild[]>("/api/guilds");
}

28
frontend/src/api/media.ts Normal file
View File

@@ -0,0 +1,28 @@
import { request } from "./client";
import type { MediaMode, MediaState } from "../types/media";
export function getMediaStatus(): Promise<MediaState> {
return request<MediaState>('/api/media/status');
}
export function queueMedia(source: string, mode: MediaMode): Promise<MediaState> {
return request<MediaState>('/api/media/queue', {
method: 'POST',
body: JSON.stringify({ source, mode }),
});
}
export function skipMedia(): Promise<MediaState> {
return request<MediaState>('/api/media/skip', { method: 'POST' });
}
export function stopMedia(): Promise<MediaState> {
return request<MediaState>('/api/media/stop', { method: 'POST' });
}
export function setMediaVolume(volume: number): Promise<MediaState> {
return request<MediaState>('/api/media/volume', {
method: 'POST',
body: JSON.stringify({ volume }),
});
}

View File

@@ -0,0 +1,3 @@
import { listMessages, listReview, reanalyzeMessage } from "./client";
export { listMessages, listReview, reanalyzeMessage };

View File

@@ -0,0 +1,13 @@
import { request } from "./client";
import type { UIState } from "../types/ui";
export function getUIState(): Promise<UIState> {
return request<UIState>('/api/ui-state');
}
export function updateUIState(patch: Partial<UIState>): Promise<UIState> {
return request<UIState>('/api/ui-state', {
method: 'POST',
body: JSON.stringify(patch),
});
}

29
frontend/src/api/voice.ts Normal file
View File

@@ -0,0 +1,29 @@
import { request } from "./client";
import type { Channel, Guild, VoiceStatus } from "../types/voice";
export function getGuilds(): Promise<Guild[]> {
return request<Guild[]>('/api/guilds');
}
export function getVoiceChannels(guildId: string): Promise<Channel[]> {
return request<Channel[]>(`/api/guilds/${guildId}/voice-channels`);
}
export function getTextChannels(guildId: string): Promise<Channel[]> {
return request<Channel[]>(`/api/guilds/${guildId}/channels`);
}
export function getVoiceStatus(): Promise<VoiceStatus> {
return request<VoiceStatus>('/api/status');
}
export function connectVoice(guildId: string, channelId: string): Promise<VoiceStatus> {
return request<VoiceStatus>('/api/connect', {
method: 'POST',
body: JSON.stringify({ guildId, channelId }),
});
}
export function disconnectVoice(): Promise<VoiceStatus> {
return request<VoiceStatus>('/api/disconnect', { method: 'POST' });
}

View File

@@ -0,0 +1,62 @@
import { useState } from "react";
import { login } from "../../api/auth";
import { Button } from "../ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Input } from "../ui/input";
import { Lock } from "lucide-react";
interface AuthOverlayProps {
onAuthenticated: () => void;
}
export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
await login(password);
localStorage.setItem("admin-password", password);
onAuthenticated();
} catch (err) {
setError("Invalid password");
} finally {
setLoading(false);
}
};
return (
<div className="flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<Lock className="h-6 w-6" />
</div>
<CardTitle>Admin Access Required</CardTitle>
<CardDescription>Enter the admin password to access Voice and Media controls.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Input
type="password"
placeholder="Enter password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
/>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<Button type="submit" className="w-full" disabled={loading || !password}>
{loading ? "Authenticating..." : "Unlock Controls"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import type { ReactNode } from "react";
import type { DashboardTab } from "../../types/ui";
import type { VoiceStatus } from "../../types/voice";
import type { WebSocketStatus } from "../../hooks/useDashboardSocket";
import { Header } from "./Header";
import { Sidebar } from "./Sidebar";
interface DashboardLayoutProps {
activeTab: DashboardTab;
wsStatus: WebSocketStatus;
voiceStatus: VoiceStatus;
onTabChange: (tab: DashboardTab) => void;
children: ReactNode;
}
export function DashboardLayout({ activeTab, wsStatus, voiceStatus, onTabChange, children }: DashboardLayoutProps) {
return (
<div className="min-h-screen bg-background text-foreground">
<div className="flex min-h-screen">
<Sidebar activeTab={activeTab} onTabChange={onTabChange} />
<main className="flex min-w-0 flex-1 flex-col">
<Header activeTab={activeTab} wsStatus={wsStatus} voiceStatus={voiceStatus} />
<div className="flex-1 overflow-auto p-4 md:p-6 lg:p-8">{children}</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { Wifi, WifiOff } from "lucide-react";
import type { WebSocketStatus } from "../../hooks/useDashboardSocket";
import type { DashboardTab } from "../../types/ui";
import type { VoiceStatus } from "../../types/voice";
import { Badge } from "../ui/badge";
const titles: Record<DashboardTab, string> = {
voice: "Voice Control",
media: "Media Player",
messages: "Messages",
review: "Moderation Review",
};
interface HeaderProps {
activeTab: DashboardTab;
wsStatus: WebSocketStatus;
voiceStatus: VoiceStatus;
}
export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) {
return (
<header className="sticky top-0 z-10 border-b border-border bg-background/80 px-4 py-4 backdrop-blur md:px-8">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">{titles[activeTab]}</h1>
<p className="text-sm text-muted-foreground">Voice, media, and moderation in one dashboard.</p>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant={wsStatus === "connected" ? "success" : wsStatus === "error" ? "destructive" : "warning"}>
{wsStatus === "connected" ? <Wifi className="mr-1 h-3 w-3" /> : <WifiOff className="mr-1 h-3 w-3" />}
WebSocket {wsStatus}
</Badge>
<Badge variant={voiceStatus.connected ? "success" : "secondary"}>
Voice {voiceStatus.connected ? voiceStatus.activeChannelName || "connected" : "idle"}
</Badge>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,48 @@
import { Bot, MessageSquare, Music2, ShieldAlert, Volume2 } from "lucide-react";
import type { DashboardTab } from "../../types/ui";
import { cn } from "../../lib/utils";
import { Button } from "../ui/button";
const navItems: Array<{ id: DashboardTab; label: string; icon: typeof Volume2 }> = [
{ id: "voice", label: "Voice", icon: Volume2 },
{ id: "media", label: "Media", icon: Music2 },
{ id: "messages", label: "Messages", icon: MessageSquare },
{ id: "review", label: "Review", icon: ShieldAlert },
];
interface SidebarProps {
activeTab: DashboardTab;
onTabChange: (tab: DashboardTab) => void;
}
export function Sidebar({ activeTab, onTabChange }: SidebarProps) {
return (
<aside className="hidden w-72 shrink-0 border-r border-border bg-card/60 p-5 backdrop-blur md:block">
<div className="mb-8 flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/15 text-primary">
<Bot className="h-6 w-6" />
</div>
<div>
<div className="font-semibold tracking-tight">Bete Watcher</div>
<div className="text-xs text-muted-foreground">Discord control center</div>
</div>
</div>
<nav className="space-y-2">
{navItems.map((item) => {
const Icon = item.icon;
return (
<Button
key={item.id}
variant={activeTab === item.id ? "secondary" : "ghost"}
className={cn("w-full justify-start", activeTab === item.id && "bg-primary/15 text-primary")}
onClick={() => onTabChange(item.id)}
>
<Icon className="h-4 w-4" />
{item.label}
</Button>
);
})}
</nav>
</aside>
);
}

View File

@@ -0,0 +1,50 @@
import type { MediaState } from "../../types/media";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { MediaQueue } from "./MediaQueue";
import { MusicPlayer } from "./MusicPlayer";
import { ScreenShare } from "./ScreenShare";
interface MediaPanelProps {
state: MediaState;
loading: boolean;
onQueueMusic: (source: string) => void;
onStartScreen: (source: string) => void;
onSkip: () => void;
onStop: () => void;
onVolumeChange: (volume: number) => void;
}
export function MediaPanel({
state,
loading,
onQueueMusic,
onStartScreen,
onSkip,
onStop,
onVolumeChange,
}: MediaPanelProps) {
return (
<div className="grid gap-6 xl:grid-cols-[1fr_380px]">
<Tabs defaultValue="music" className="min-w-0">
<TabsList>
<TabsTrigger value="music">Music</TabsTrigger>
<TabsTrigger value="screen">Screen Share</TabsTrigger>
</TabsList>
<TabsContent value="music">
<MusicPlayer
loading={loading}
volume={state.musicVolume}
onVolumeChange={onVolumeChange}
onQueue={onQueueMusic}
onSkip={onSkip}
onStop={onStop}
/>
</TabsContent>
<TabsContent value="screen">
<ScreenShare loading={loading} onStart={onStartScreen} onSkip={onSkip} onStop={onStop} />
</TabsContent>
</Tabs>
<MediaQueue state={state} />
</div>
);
}

View File

@@ -0,0 +1,46 @@
import type { MediaState } from "../../types/media";
import { Badge } from "../ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
interface MediaQueueProps {
state: MediaState;
}
export function MediaQueue({ state }: MediaQueueProps) {
return (
<Card>
<CardHeader>
<CardTitle>Now Playing</CardTitle>
<CardDescription>Current item and queue state.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{state.current ? (
<div className="rounded-xl border border-primary/30 bg-primary/10 p-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium">{state.current.title}</div>
<div className="truncate text-xs text-muted-foreground">{state.current.source}</div>
</div>
<Badge variant={state.current.mode === "screen" ? "warning" : "success"}>{state.current.mode || "music"}</Badge>
</div>
</div>
) : (
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">No media playing.</div>
)}
<div className="space-y-2">
<div className="text-sm font-medium">Queue</div>
{state.queue.length === 0 ? (
<div className="text-sm text-muted-foreground">Queue is empty.</div>
) : (
state.queue.map((item, index) => (
<div key={`${item.source}-${index}`} className="rounded-lg border border-border bg-background/60 p-3 text-sm">
<div className="font-medium">{item.title}</div>
<div className="truncate text-xs text-muted-foreground">{item.source}</div>
</div>
))
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,84 @@
import { Music2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "../ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Input } from "../ui/input";
interface MusicPlayerProps {
loading: boolean;
volume: number;
onVolumeChange: (volume: number) => void;
onQueue: (source: string) => void;
onSkip: () => void;
onStop: () => void;
}
export function MusicPlayer({
loading,
volume,
onVolumeChange,
onQueue,
onSkip,
onStop,
}: MusicPlayerProps) {
const [source, setSource] = useState("");
const safeVolume = Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1;
const [draftVolume, setDraftVolume] = useState(Math.round(safeVolume * 100));
useEffect(() => {
setDraftVolume(Math.round(safeVolume * 100));
}, [safeVolume]);
useEffect(() => {
const normalized = draftVolume / 100;
if (Math.abs(normalized - safeVolume) < 0.001) return;
const timer = window.setTimeout(() => {
onVolumeChange(normalized);
}, 150);
return () => window.clearTimeout(timer);
}, [draftVolume, onVolumeChange, safeVolume]);
const submit = () => {
const trimmed = source.trim();
if (!trimmed) return;
onQueue(trimmed);
setSource("");
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Music2 className="h-5 w-5" /> Music Player</CardTitle>
<CardDescription>Play YouTube, Spotify tracks, search terms, or local files as audio.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
value={source}
onChange={(event) => setSource(event.target.value)}
onKeyDown={(event) => event.key === "Enter" && submit()}
placeholder="YouTube URL, Spotify track, or search terms"
/>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">Volume</span>
<span className="text-muted-foreground">{draftVolume}%</span>
</div>
<input
type="range"
min={0}
max={100}
step={1}
value={draftVolume}
onChange={(event) => setDraftVolume(Number(event.target.value))}
className="h-2 w-full cursor-pointer accent-primary"
/>
</div>
<div className="flex flex-wrap gap-2">
<Button disabled={loading || !source.trim()} onClick={submit}>Queue / Play</Button>
<Button variant="secondary" disabled={loading} onClick={onSkip}>Skip</Button>
<Button variant="destructive" disabled={loading} onClick={onStop}>Stop</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,45 @@
import { MonitorUp } from "lucide-react";
import { useState } from "react";
import { Button } from "../ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Input } from "../ui/input";
interface ScreenShareProps {
loading: boolean;
onStart: (source: string) => void;
onSkip: () => void;
onStop: () => void;
}
export function ScreenShare({ loading, onStart, onSkip, onStop }: ScreenShareProps) {
const [source, setSource] = useState("");
const submit = () => {
const trimmed = source.trim();
if (!trimmed) return;
onStart(trimmed);
setSource("");
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><MonitorUp className="h-5 w-5" /> Screen Share</CardTitle>
<CardDescription>Start screen-share playback from a URL or local file path.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
value={source}
onChange={(event) => setSource(event.target.value)}
onKeyDown={(event) => event.key === "Enter" && submit()}
placeholder="Screen share URL or local file path"
/>
<div className="flex flex-wrap gap-2">
<Button disabled={loading || !source.trim()} onClick={submit}>Start Screen Share</Button>
<Button variant="secondary" disabled={loading} onClick={onSkip}>Skip</Button>
<Button variant="destructive" disabled={loading} onClick={onStop}>Stop</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,43 @@
import type { MessageMetadata, MessageRecord } from "../../types/messages";
function parseMetadata(value: string | null): MessageMetadata {
if (!value) return {};
try {
return JSON.parse(value) as MessageMetadata;
} catch {
return {};
}
}
export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
const images = messages.flatMap((message) => {
const metadata = parseMetadata(message.metadata);
const attachments = metadata.attachments ?? [];
const embeds = metadata.embeds ?? [];
return [
...attachments
.filter((attachment) => attachment.url && (attachment.contentType?.startsWith("image/") || /\.(png|jpe?g|gif|webp)$/i.test(attachment.name)))
.map((attachment) => ({ url: attachment.url, title: attachment.name, message })),
...embeds
.flatMap((embed) => [embed.image, embed.thumbnail].filter(Boolean).map((url) => ({ url: url as string, title: embed.title || "embed image", message }))),
];
});
if (images.length === 0) {
return <div className="rounded-2xl border border-dashed border-border p-10 text-center text-sm text-muted-foreground">No images found.</div>;
}
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{images.map((image, index) => (
<a key={`${image.url}-${index}`} href={image.url} target="_blank" rel="noreferrer" className="group overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
<img src={image.url} alt={image.title} className="aspect-video w-full object-cover transition-transform group-hover:scale-105" />
<div className="p-3">
<div className="truncate text-sm font-medium">{image.title}</div>
<div className="truncate text-xs text-muted-foreground">{image.message.username}</div>
</div>
</a>
))}
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { RotateCw } from "lucide-react";
import type { MessageRecord } from "../../types/messages";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
export interface MessageCardProps {
message: MessageRecord;
onReanalyze: (id: string) => void;
}
function aiVariant(status: string) {
if (status === "clean") return "success";
if (status === "warn") return "warning";
if (status === "flagged" || status === "error") return "destructive";
return "secondary";
}
export function MessageCard({ message, onReanalyze }: MessageCardProps) {
const displayContent = message.edited_content ?? message.content;
const aiStatus = message.ai_status ?? "pending";
return (
<article className="rounded-2xl border border-border bg-card p-4 shadow-sm">
<div className="flex gap-3">
<img
src={message.avatar_url ?? "https://cdn.discordapp.com/embed/avatars/0.png"}
alt=""
className="h-10 w-10 rounded-full object-cover"
/>
<div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{message.username || message.user_id}</span>
<span className="text-xs text-muted-foreground">{new Date(message.created_at).toLocaleString()}</span>
{message.edited_at ? <Badge variant="outline">edited</Badge> : null}
{message.deleted_at ? <Badge variant="destructive">deleted</Badge> : null}
<Badge variant={aiVariant(aiStatus)}>{aiStatus}</Badge>
</div>
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90">
{displayContent || "(empty message)"}
</p>
{message.ai_analysis ? <div className="rounded-xl bg-muted p-3 text-sm text-muted-foreground">{message.ai_analysis}</div> : null}
{message.ai_error ? <div className="rounded-xl bg-destructive/10 p-3 text-sm text-destructive">AI error: {message.ai_error}</div> : null}
<Button size="sm" variant="outline" onClick={() => onReanalyze(message.id)} disabled={aiStatus === "pending"}>
<RotateCw className="h-3.5 w-3.5" />
Re-analyze
</Button>
</div>
</div>
</article>
);
}

View File

@@ -0,0 +1,25 @@
import type { MessageRecord } from "../../types/messages";
import { ScrollArea } from "../ui/scroll-area";
import { MessageCard } from "./MessageCard";
export interface MessageFeedProps {
messages: MessageRecord[];
onReanalyze: (id: string) => void;
emptyText?: string;
}
export function MessageFeed({ messages, onReanalyze, emptyText = "No messages found." }: MessageFeedProps) {
if (messages.length === 0) {
return <div className="rounded-2xl border border-dashed border-border p-10 text-center text-sm text-muted-foreground">{emptyText}</div>;
}
return (
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
<div className="space-y-3">
{messages.map((message) => (
<MessageCard key={message.id} message={message} onReanalyze={onReanalyze} />
))}
</div>
</ScrollArea>
);
}

View File

@@ -0,0 +1,66 @@
import type { Channel, Guild } from "../../types/voice";
import type { MessageRecord } from "../../types/messages";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Select } from "../ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { ImageGrid } from "./ImageGrid";
import { MessageFeed } from "./MessageFeed";
interface MessagesPanelProps {
guilds: Guild[];
channels: Channel[];
selectedGuild: string;
selectedChannel: string;
messages: MessageRecord[];
onGuildChange: (guildId: string) => void;
onChannelChange: (channelId: string) => void;
onReanalyze: (id: string) => void;
}
export function MessagesPanel({
guilds,
channels,
selectedGuild,
selectedChannel,
messages,
onGuildChange,
onChannelChange,
onReanalyze,
}: MessagesPanelProps) {
return (
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Message Source</CardTitle>
<CardDescription>Pick a guild and channel/thread to inspect captures.</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<Select
value={selectedGuild}
onChange={(event) => onGuildChange(event.target.value)}
placeholder="Select text guild"
options={guilds.map((guild) => ({ value: guild.id, label: guild.name }))}
/>
<Select
value={selectedChannel}
onChange={(event) => onChannelChange(event.target.value)}
placeholder="Select channel or thread"
options={channels.map((channel) => ({ value: channel.id, label: channel.name }))}
/>
</CardContent>
</Card>
<Tabs defaultValue="all">
<TabsList>
<TabsTrigger value="all">All Messages</TabsTrigger>
<TabsTrigger value="images">Images</TabsTrigger>
</TabsList>
<TabsContent value="all">
<MessageFeed messages={messages} onReanalyze={onReanalyze} emptyText={selectedChannel ? "No captures yet." : "Select a channel to view captures."} />
</TabsContent>
<TabsContent value="images">
<ImageGrid messages={messages} />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import type { MessageRecord } from "../../types/messages";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { MessageFeed } from "../messages/MessageFeed";
export interface ReviewPanelProps {
messages: MessageRecord[];
onReanalyze: (id: string) => void;
}
export function ReviewPanel({ messages, onReanalyze }: ReviewPanelProps) {
const reviewItems = messages.filter(
(message) =>
message.ai_status === "warn" ||
message.ai_status === "flagged" ||
message.ai_status === "error",
);
return (
<Card>
<CardHeader>
<CardTitle>Needs Review</CardTitle>
<CardDescription>{reviewItems.length} captured messages require attention.</CardDescription>
</CardHeader>
<CardContent>
<MessageFeed messages={reviewItems} onReanalyze={onReanalyze} emptyText="No warned, flagged, or errored messages." />
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,30 @@
import type * as React from "react";
import { cn } from "../../lib/utils";
type BadgeVariant = "default" | "secondary" | "destructive" | "outline" | "success" | "warning";
const variants: Record<BadgeVariant, string> = {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground",
success: "border-transparent bg-emerald-500/15 text-emerald-300",
warning: "border-transparent bg-amber-500/15 text-amber-300",
};
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: BadgeVariant;
}
export function Badge({ className, variant = "default", ...props }: BadgeProps) {
return (
<div
className={cn(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
variants[variant],
className,
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,48 @@
import { Slot } from "@radix-ui/react-slot";
import type * as React from "react";
import { cn } from "../../lib/utils";
type ButtonVariant = "default" | "secondary" | "destructive" | "outline" | "ghost";
type ButtonSize = "default" | "sm" | "lg" | "icon";
const variants: Record<ButtonVariant, string> = {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
};
const sizes: Record<ButtonSize, string> = {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
};
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
variant?: ButtonVariant;
size?: ButtonSize;
}
export function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
variants[variant],
sizes[size],
className,
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,26 @@
import type * as React from "react";
import { cn } from "../../lib/utils";
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("rounded-2xl border border-border bg-card text-card-foreground shadow-sm", className)} {...props} />;
}
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn("font-semibold leading-none tracking-tight", className)} {...props} />;
}
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
}
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-6 pt-0", className)} {...props} />;
}
export function CardFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex items-center p-6 pt-0", className)} {...props} />;
}

View File

@@ -0,0 +1,15 @@
import type * as React from "react";
import { cn } from "../../lib/utils";
export function Input({ className, type, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,30 @@
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import type * as React from "react";
import { cn } from "../../lib/utils";
export function ScrollArea({ className, children, ...props }: React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({ className, orientation = "vertical", ...props }: React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}

View File

@@ -0,0 +1,31 @@
import type * as React from "react";
import { cn } from "../../lib/utils";
export interface SelectOption {
value: string;
label: string;
}
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
options: SelectOption[];
placeholder?: string;
}
export function Select({ className, options, placeholder, ...props }: SelectProps) {
return (
<select
className={cn(
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
{placeholder ? <option value="">{placeholder}</option> : null}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}

View File

@@ -0,0 +1,35 @@
import * as TabsPrimitive from "@radix-ui/react-tabs";
import type * as React from "react";
import { cn } from "../../lib/utils";
export const Tabs = TabsPrimitive.Root;
export function TabsList({ className, ...props }: React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
className={cn("inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", className)}
{...props}
/>
);
}
export function TabsTrigger({ className, ...props }: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className,
)}
{...props}
/>
);
}
export function TabsContent({ className, ...props }: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
className={cn("mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", className)}
{...props}
/>
);
}

View File

@@ -0,0 +1,35 @@
import type { ActiveSpeaker } from "../../types/voice";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
interface ActiveSpeakersProps {
speakers: ActiveSpeaker[];
}
export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
return (
<Card>
<CardHeader>
<CardTitle>Active Speakers</CardTitle>
</CardHeader>
<CardContent>
{speakers.length === 0 ? (
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
No active speakers.
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{speakers.map((speaker, index) => (
<div key={speaker.userId || speaker.id || index} className="flex items-center gap-3 rounded-xl border border-border bg-background/60 p-3">
<img src={speaker.avatar} alt="" className="h-10 w-10 rounded-full object-cover" />
<div className="min-w-0">
<div className="truncate text-sm font-medium">{speaker.username}</div>
<div className="text-xs text-emerald-300">Speaking</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,18 @@
interface AudioVisualizerProps {
levels: number[];
}
export function AudioVisualizer({ levels }: AudioVisualizerProps) {
const bars = levels.length ? levels : Array.from({ length: 32 }, () => 0.04);
return (
<div className="flex h-40 items-end gap-1 rounded-2xl border border-border bg-background/60 p-4">
{bars.map((level, index) => (
<div
key={`${index}-${level}`}
className="flex-1 rounded-full bg-gradient-to-t from-primary/50 to-cyan-300 transition-all duration-150"
style={{ height: `${Math.max(6, Math.min(100, level * 100))}%` }}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,83 @@
import type { Channel, Guild, VoiceStatus } from "../../types/voice";
import { Button } from "../ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Select } from "../ui/select";
interface VoiceControlProps {
guilds: Guild[];
channels: Channel[];
selectedGuild: string;
selectedChannel: string;
status: VoiceStatus;
loading: boolean;
onGuildChange: (guildId: string) => void;
onChannelChange: (channelId: string) => void;
onJoin: () => void;
onDisconnect: () => void;
onListenToggle: () => void;
onStreamingToggle: () => void;
isListening: boolean;
isStreaming: boolean;
}
export function VoiceControl({
guilds,
channels,
selectedGuild,
selectedChannel,
status,
loading,
onGuildChange,
onChannelChange,
onJoin,
onDisconnect,
onListenToggle,
onStreamingToggle,
isListening,
isStreaming,
}: VoiceControlProps) {
return (
<Card>
<CardHeader>
<CardTitle>Voice Bridge</CardTitle>
<CardDescription>Join a Discord voice channel and monitor audio in real time.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">Guild</label>
<Select
value={selectedGuild}
onChange={(event) => onGuildChange(event.target.value)}
placeholder="Select guild"
options={guilds.map((guild) => ({ value: guild.id, label: guild.name }))}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Voice Channel</label>
<Select
value={selectedChannel}
onChange={(event) => onChannelChange(event.target.value)}
placeholder="Select voice channel"
options={channels.map((channel) => ({ value: channel.id, label: channel.name }))}
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button disabled={!selectedGuild || !selectedChannel || loading} onClick={onJoin}>
{status.connected ? "Reconnect" : "Join Voice"}
</Button>
<Button variant="destructive" disabled={!status.connected || loading} onClick={onDisconnect}>
Disconnect
</Button>
<Button variant={isListening ? "secondary" : "outline"} onClick={onListenToggle}>
{isListening ? "Stop Listening" : "Listen Live"}
</Button>
<Button variant={isStreaming ? "destructive" : "default"} onClick={onStreamingToggle}>
{isStreaming ? "Stop Transmitting" : "Start Transmitting"}
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,43 @@
import type { ActiveSpeaker, Channel, Guild, VoiceStatus } from "../../types/voice";
import { AudioVisualizer } from "./AudioVisualizer";
import { ActiveSpeakers } from "./ActiveSpeakers";
import { VoiceControl } from "./VoiceControl";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
interface VoicePanelProps {
guilds: Guild[];
channels: Channel[];
selectedGuild: string;
selectedChannel: string;
status: VoiceStatus;
loading: boolean;
activeSpeakers: ActiveSpeaker[];
levels: number[];
isListening: boolean;
isStreaming: boolean;
onGuildChange: (guildId: string) => void;
onChannelChange: (channelId: string) => void;
onJoin: () => void;
onDisconnect: () => void;
onListenToggle: () => void;
onStreamingToggle: () => void;
}
export function VoicePanel(props: VoicePanelProps) {
return (
<div className="grid gap-6">
<VoiceControl {...props} />
<div className="grid gap-6 xl:grid-cols-[1fr_360px]">
<Card>
<CardHeader>
<CardTitle>Live Audio Visualizer</CardTitle>
</CardHeader>
<CardContent>
<AudioVisualizer levels={props.levels} />
</CardContent>
</Card>
<ActiveSpeakers speakers={props.activeSpeakers} />
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { useEffect, useRef, useState } from "react";
import type { MessageRecord } from "../types/messages";
import type { MediaState } from "../types/media";
import type { UIState } from "../types/ui";
import type { ActiveSpeaker } from "../types/voice";
export type WebSocketStatus = "connecting" | "connected" | "disconnected" | "error";
export interface DashboardSocketHandlers {
onUIState?: (state: UIState) => void;
onUserState?: (users: ActiveSpeaker[]) => void;
onMessageCreated?: (message: MessageRecord) => void;
onMessageUpdated?: (message: Partial<MessageRecord> & { id: string }) => void;
onMessageDeleted?: (message: { id: string }) => void;
onMessageAnalyzed?: (message: MessageRecord) => void;
onAttachmentUploaded?: () => void;
onMediaState?: (state: MediaState) => void;
onPcm?: (data: ArrayBuffer) => void;
}
export function useDashboardSocket(handlers: DashboardSocketHandlers) {
const [status, setStatus] = useState<WebSocketStatus>("connecting");
const handlersRef = useRef(handlers);
const socketRef = useRef<WebSocket | null>(null);
handlersRef.current = handlers;
useEffect(() => {
let closed = false;
let reconnectTimer: number | null = null;
const connect = () => {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(`${protocol}//${location.host}/ws`);
socket.binaryType = "arraybuffer";
socketRef.current = socket;
setStatus("connecting");
socket.addEventListener("open", () => setStatus("connected"));
socket.addEventListener("error", () => setStatus("error"));
socket.addEventListener("close", () => {
setStatus("disconnected");
if (!closed) reconnectTimer = window.setTimeout(connect, 2500);
});
socket.addEventListener("message", (event) => {
if (event.data instanceof ArrayBuffer) {
handlersRef.current.onPcm?.(event.data);
return;
}
if (typeof event.data !== "string") return;
try {
const message = JSON.parse(event.data);
switch (message.type) {
case "ui_state":
handlersRef.current.onUIState?.(message.state);
break;
case "user_state":
handlersRef.current.onUserState?.(message.users || []);
break;
case "message_created":
handlersRef.current.onMessageCreated?.(message.data);
break;
case "message_updated":
handlersRef.current.onMessageUpdated?.(message.data);
break;
case "message_deleted":
handlersRef.current.onMessageDeleted?.(message.data);
break;
case "message_analyzed":
handlersRef.current.onMessageAnalyzed?.(message.data);
break;
case "attachment_uploaded":
handlersRef.current.onAttachmentUploaded?.();
break;
case "media_state":
handlersRef.current.onMediaState?.(message.state);
break;
}
} catch {
// ignore malformed socket messages
}
});
};
connect();
return () => {
closed = true;
if (reconnectTimer) window.clearTimeout(reconnectTimer);
socketRef.current?.close();
socketRef.current = null;
};
}, []);
return { status, socketRef };
}

View File

@@ -0,0 +1,97 @@
import { useCallback, useEffect, useState } from "react";
import {
getMediaStatus,
queueMedia,
setMediaVolume,
skipMedia,
stopMedia,
} from "../api/media";
import type { MediaMode, MediaState } from "../types/media";
const emptyMediaState: MediaState = {
playing: false,
musicVolume: 1,
current: null,
queue: [],
};
export function useMediaControl() {
const [mediaState, setMediaState] = useState<MediaState>(emptyMediaState);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refreshMedia = useCallback(async () => {
const state = await getMediaStatus();
setMediaState(state);
return state;
}, []);
const enqueue = useCallback(async (source: string, mode: MediaMode) => {
setLoading(true);
setError(null);
try {
const state = await queueMedia(source, mode);
setMediaState(state);
return state;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
throw err;
} finally {
setLoading(false);
}
}, []);
const skip = useCallback(async () => {
setLoading(true);
setError(null);
try {
const state = await skipMedia();
setMediaState(state);
return state;
} finally {
setLoading(false);
}
}, []);
const stop = useCallback(async () => {
setLoading(true);
setError(null);
try {
const state = await stopMedia();
setMediaState(state);
return state;
} finally {
setLoading(false);
}
}, []);
const setVolume = useCallback(async (volume: number) => {
setError(null);
try {
const state = await setMediaVolume(volume);
setMediaState(state);
return state;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
throw err;
}
}, []);
useEffect(() => {
refreshMedia().catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [refreshMedia]);
return {
mediaState,
setMediaState,
loading,
error,
refreshMedia,
enqueue,
skip,
stop,
setVolume,
};
}

View File

@@ -0,0 +1,58 @@
import { useCallback, useEffect, useState } from "react";
import { listMessages, reanalyzeMessage } from "../api/messages";
import type { MessageRecord } from "../types/messages";
export function mergeMessages(current: MessageRecord[], incoming: MessageRecord[]): MessageRecord[] {
const byId = new Map(current.map((message) => [message.id, message]));
for (const message of incoming) {
byId.set(message.id, { ...byId.get(message.id), ...message });
}
return Array.from(byId.values())
.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id))
.slice(0, 200);
}
export function useMessages() {
const [messages, setMessages] = useState<MessageRecord[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchMessages = useCallback(async (channelId?: string) => {
if (!channelId) {
setMessages([]);
return [];
}
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({ limit: "80" });
params.set("channel", channelId);
const result = await listMessages(params);
setMessages(result.data);
return result.data;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
throw err;
} finally {
setLoading(false);
}
}, []);
const reanalyze = useCallback(async (id: string) => {
setMessages((prev) =>
prev.map((message) =>
message.id === id
? { ...message, ai_status: "pending", ai_error: null, ai_analysis: null }
: message,
),
);
await reanalyzeMessage(id);
}, []);
useEffect(() => {
fetchMessages().catch(() => undefined);
}, [fetchMessages]);
return { messages, setMessages, loading, error, fetchMessages, reanalyze };
}

View File

@@ -0,0 +1,35 @@
import { useCallback, useEffect, useState } from "react";
import { getUIState, updateUIState } from "../api/uiState";
import type { UIState } from "../types/ui";
export function useUIState() {
const [uiState, setUIState] = useState<UIState>({ activeTab: "voice" });
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
getUIState()
.then((state) => {
if (!cancelled) setUIState({ activeTab: "voice", ...state });
})
.catch((err) => {
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const patchUIState = useCallback(async (patch: Partial<UIState>) => {
setUIState((prev) => ({ ...prev, ...patch }));
const next = await updateUIState(patch);
setUIState((prev) => ({ ...prev, ...next }));
return next;
}, []);
return { uiState, setUIState, patchUIState, loading, error };
}

View File

@@ -0,0 +1,104 @@
import { useCallback, useEffect, useState } from "react";
import {
connectVoice,
disconnectVoice,
getGuilds,
getTextChannels,
getVoiceChannels,
getVoiceStatus,
} from "../api/voice";
import type { Channel, Guild, VoiceStatus } from "../types/voice";
export function useVoiceControl() {
const [guilds, setGuilds] = useState<Guild[]>([]);
const [voiceChannels, setVoiceChannels] = useState<Channel[]>([]);
const [textChannels, setTextChannels] = useState<Channel[]>([]);
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus>({ connected: false });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refreshGuilds = useCallback(async () => {
setError(null);
const nextGuilds = await getGuilds();
setGuilds(nextGuilds);
return nextGuilds;
}, []);
const refreshVoiceStatus = useCallback(async () => {
const status = await getVoiceStatus();
setVoiceStatus(status);
return status;
}, []);
const loadVoiceChannels = useCallback(async (guildId: string) => {
if (!guildId) {
setVoiceChannels([]);
return [];
}
const channels = await getVoiceChannels(guildId);
setVoiceChannels(channels);
return channels;
}, []);
const loadTextTargets = useCallback(async (guildId: string) => {
if (!guildId) {
setTextChannels([]);
return [];
}
const channels = await getTextChannels(guildId);
setTextChannels(channels);
return channels;
}, []);
const joinVoice = useCallback(async (guildId: string, channelId: string) => {
setLoading(true);
setError(null);
try {
const status = await connectVoice(guildId, channelId);
setVoiceStatus(status);
return status;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
throw err;
} finally {
setLoading(false);
}
}, []);
const leaveVoice = useCallback(async () => {
setLoading(true);
setError(null);
try {
const status = await disconnectVoice();
setVoiceStatus(status);
return status;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
throw err;
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refreshGuilds().catch((err) => setError(err instanceof Error ? err.message : String(err)));
refreshVoiceStatus().catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [refreshGuilds, refreshVoiceStatus]);
return {
guilds,
voiceChannels,
textChannels,
voiceStatus,
loading,
error,
refreshGuilds,
refreshVoiceStatus,
loadVoiceChannels,
loadTextTargets,
joinVoice,
leaveVoice,
};
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

41
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,41 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 222 47% 7%;
--foreground: 210 40% 98%;
--card: 222 47% 10%;
--card-foreground: 210 40% 98%;
--primary: 199 89% 48%;
--primary-foreground: 210 40% 98%;
--secondary: 217 33% 17%;
--secondary-foreground: 210 40% 98%;
--muted: 217 33% 17%;
--muted-foreground: 215 20% 65%;
--accent: 217 33% 17%;
--accent-foreground: 210 40% 98%;
--destructive: 0 72% 51%;
--destructive-foreground: 210 40% 98%;
--border: 217 33% 20%;
--input: 217 33% 20%;
--ring: 199 89% 48%;
--radius: 0.85rem;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
html,
body,
#root {
min-height: 100%;
}
}

View File

@@ -0,0 +1,7 @@
export {};
declare global {
interface Window {
webkitAudioContext?: typeof AudioContext;
}
}

View File

@@ -0,0 +1,17 @@
export type MediaMode = "music" | "screen";
export interface MediaItem {
id?: string;
source: string;
title: string;
mode?: MediaMode;
durationMs?: number | null;
thumbnailUrl?: string | null;
}
export interface MediaState {
playing: boolean;
musicVolume: number;
current: MediaItem | null;
queue: MediaItem[];
}

View File

@@ -0,0 +1,29 @@
export type { AIStatus, MessageRecord, PageResult } from "../api/client";
export interface MessageMetadataAttachment {
name: string;
url: string;
size: number;
contentType?: string | null;
}
export interface MessageMetadataEmbed {
title?: string;
description?: string;
url?: string;
image?: string;
thumbnail?: string;
}
export interface MessageMetadataSticker {
name: string;
url: string;
}
export interface MessageMetadata {
attachments?: MessageMetadataAttachment[];
embeds?: MessageMetadataEmbed[];
stickers?: MessageMetadataSticker[];
reference?: { messageId?: string };
channel?: { threadName?: string };
}

12
frontend/src/types/ui.ts Normal file
View File

@@ -0,0 +1,12 @@
export type DashboardTab = "voice" | "media" | "messages" | "review";
export interface UIState {
selectedGuild?: string;
selectedVoiceGuild?: string;
selectedVoiceChannel?: string;
selectedTextGuild?: string;
selectedTextChannel?: string;
activeTab?: DashboardTab;
isListening?: boolean;
isStreaming?: boolean;
}

View File

@@ -0,0 +1,27 @@
export interface Guild {
id: string;
name: string;
icon?: string | null;
}
export interface Channel {
id: string;
name: string;
type?: string;
parentId?: string | null;
}
export interface VoiceStatus {
connected: boolean;
activeGuildId?: string | null;
activeChannelId?: string | null;
activeChannelName?: string | null;
}
export interface ActiveSpeaker {
id?: string;
userId?: string;
username: string;
avatar: string;
speaking: boolean;
}

32
frontend/src/ws/client.ts Normal file
View File

@@ -0,0 +1,32 @@
import type { MessageRecord } from "../api/client";
export type DashboardEvent =
| { type: "message_created"; data: MessageRecord }
| { type: "message_updated"; data: Partial<MessageRecord> & { id: string } }
| { type: "message_deleted"; data: { id: string; deleted_at: number } }
| { type: "message_analyzed"; data: MessageRecord }
| { type: "analysis_queue_status"; data: unknown }
| { type: "ui_state"; state: unknown }
| { type: "user_state"; users: unknown[] };
export function connectDashboardSocket(
onEvent: (event: DashboardEvent) => void,
): WebSocket {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = `${protocol}//${window.location.host}/ws`;
const ws = new WebSocket(url);
ws.addEventListener("message", (evt) => {
if (typeof evt.data === "string") {
try {
const event = JSON.parse(evt.data) as DashboardEvent;
onEvent(event);
} catch {
// ignore malformed JSON
}
}
// Binary frames (PCM audio) are ignored for now
});
return ws;
}

View File

@@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))" },
secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))" },
muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))" },
accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))" },
destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))" },
card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))" },
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [],
};

16
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "react-jsx",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*", "vite.config.ts"]
}

9
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
middlewareMode: false,
},
});

View File

@@ -2,38 +2,58 @@
"name": "discord-voice-recorder", "name": "discord-voice-recorder",
"version": "1.0.0", "version": "1.0.0",
"description": "Discord bot that joins a voice channel and records audio", "description": "Discord bot that joins a voice channel and records audio",
"type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"packageManager": "pnpm@10.25.0",
"scripts": { "scripts": {
"dev": "bun --watch src/index.ts", "dev": "tsx watch src/index.ts",
"start": "bun src/index.ts", "dev:server": "tsx watch src/index.ts",
"build": "tsc --outDir dist", "dev:web": "vite --host 0.0.0.0 frontend",
"start": "tsx src/index.ts",
"build": "pnpm run build:web && tsc --outDir dist",
"build:web": "vite build frontend --outDir ../public/app --emptyOutDir",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "biome check --diagnostic-level=error .", "lint": "biome check --diagnostic-level=error .",
"format": "biome format --write .", "format": "biome format --write .",
"test": "vitest run" "test": "vitest run",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:migrate:programmatic": "tsx src/database/migrate.ts",
"db:studio": "drizzle-kit studio",
"install:yt-dlp": "sh scripts/install-yt-dlp.sh"
}, },
"dependencies": { "dependencies": {
"@dank074/discord-video-stream": "^6.0.0",
"@discordjs/opus": "^0.10.0", "@discordjs/opus": "^0.10.0",
"@discordjs/voice": "^0.19.1", "@discordjs/voice": "^0.19.1",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@snazzah/davey": "^0.1.10", "@snazzah/davey": "^0.1.10",
"@types/pg": "^8.20.0",
"@vitejs/plugin-react": "^6.0.2",
"better-sqlite3": "^12.10.0", "better-sqlite3": "^12.10.0",
"class-transformer": "^0.5.1", "clsx": "^2.1.1",
"class-validator": "^0.15.1", "discord.js-selfbot-v13": "workspace:*",
"discord.js-selfbot-v13": "^3.7.1",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1", "express": "^5.2.1",
"fluent-ffmpeg": "^2.1.3",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"howler": "^2.2.4",
"libsodium-wrappers": "^0.8.2", "libsodium-wrappers": "^0.8.2",
"p-retry": "^6.2.0", "lucide-react": "^1.16.0",
"pino": "^9.4.0", "p-retry": "^8.0.0",
"pg": "^8.20.0",
"pino": "^10.3.1",
"pino-http": "^11.0.0", "pino-http": "^11.0.0",
"play-dl": "^1.9.7",
"prism-media": "2.0.0-alpha.0", "prism-media": "2.0.0-alpha.0",
"prom-client": "^15.1.3", "prom-client": "^15.1.3",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"sodium-native": "^4.3.2", "sodium-native": "^5.1.0",
"tailwind-merge": "^3.6.0",
"vite": "^8.0.13",
"ws": "^8.20.1", "ws": "^8.20.1",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
@@ -41,12 +61,16 @@
"@biomejs/biome": "latest", "@biomejs/biome": "latest",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/fluent-ffmpeg": "^2.1.28", "@types/node": "^25.8.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"bun-types": "latest", "autoprefixer": "^10.5.0",
"pino-pretty": "^10.3.1", "drizzle-kit": "^0.31.10",
"pino-pretty": "^13.1.3",
"postcss": "^8.5.14",
"tailwindcss": "^3.4.17",
"tsx": "^4.22.0",
"vitest": "latest" "vitest": "latest"
} }
} }

8924
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

12
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,12 @@
packages:
- .
- vendor/discord.js-selfbot-v13
onlyBuiltDependencies:
- '@discordjs/opus'
- '@lng2004/node-datachannel'
- better-sqlite3
- esbuild
- node-av
- sharp
- zeromq

5
postcss.config.js Normal file
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
autoprefixer: {},
},
};

View File

@@ -1,417 +0,0 @@
:root {
--bg: #080a0f;
--panel: rgba(18, 22, 32, 0.86);
--panel-strong: #121720;
--line: rgba(255, 255, 255, 0.12);
--text: #edf4ff;
--muted: #91a0b6;
--faint: #536176;
--cyan: #00e5ff;
--green: #39ff88;
--yellow: #ffe45e;
--red: #ff4f6d;
--blue: #6275ff;
--shadow: 0 24px 70px rgba(0, 0, 0, 0.46);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
color: var(--text);
font-family: Manrope, sans-serif;
background:
radial-gradient(circle at 12% 8%, rgba(0, 229, 255, 0.18), transparent 32rem),
radial-gradient(circle at 88% 0%, rgba(98, 117, 255, 0.2), transparent 28rem),
linear-gradient(145deg, #05060a, var(--bg));
overflow-x: hidden;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(255,255,255,0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: linear-gradient(to bottom, black, transparent 84%);
}
button, select { font: inherit; }
.shell {
width: min(1440px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 44px;
}
.hero {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 18px;
align-items: stretch;
margin-bottom: 18px;
}
.brand-card,
.status-card,
.tab-panel,
.content-card {
border: 1px solid var(--line);
background: var(--panel);
backdrop-filter: blur(18px);
box-shadow: var(--shadow);
}
.brand-card {
position: relative;
padding: 28px;
border-radius: 28px;
overflow: hidden;
min-height: 190px;
}
.brand-card::after {
content: "WATCHER";
position: absolute;
right: -14px;
bottom: -20px;
font-family: "Archivo Black", sans-serif;
font-size: clamp(58px, 9vw, 132px);
letter-spacing: -0.08em;
color: rgba(255,255,255,0.035);
line-height: 0.78;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
color: var(--cyan);
font: 700 12px/1 "JetBrains Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.18em;
}
.pulse {
width: 9px;
height: 9px;
border-radius: 999px;
background: var(--green);
box-shadow: 0 0 20px var(--green);
}
h1 {
margin: 0;
max-width: 840px;
font-family: "Archivo Black", sans-serif;
font-size: clamp(40px, 6vw, 82px);
line-height: 0.88;
letter-spacing: -0.06em;
text-transform: uppercase;
}
.subtitle {
margin: 18px 0 0;
max-width: 720px;
color: var(--muted);
font-size: 15px;
line-height: 1.7;
}
.status-card {
border-radius: 28px;
padding: 24px;
display: grid;
gap: 14px;
}
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 0;
border-bottom: 1px solid var(--line);
}
.status-row:last-child { border-bottom: 0; }
.status-label {
color: var(--muted);
font: 700 12px/1 "JetBrains Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.status-value {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text);
font: 700 13px/1 "JetBrains Mono", monospace;
}
.dot {
width: 9px;
height: 9px;
border-radius: 99px;
background: var(--faint);
}
.dot.on { background: var(--green); box-shadow: 0 0 16px var(--green); }
.dot.warn { background: var(--yellow); box-shadow: 0 0 16px var(--yellow); }
.tab-panel {
position: sticky;
top: 14px;
z-index: 5;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
border-radius: 24px;
margin-bottom: 18px;
}
.tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tab-btn {
border: 1px solid transparent;
color: var(--muted);
background: transparent;
border-radius: 16px;
padding: 12px 16px;
cursor: pointer;
font-weight: 800;
transition: 160ms ease;
}
.tab-btn:hover { color: var(--text); background: rgba(255,255,255,0.06); }
.tab-btn.active {
color: #061014;
background: linear-gradient(135deg, var(--cyan), var(--green));
box-shadow: 0 12px 28px rgba(0,229,255,0.18);
}
.filter-row {
display: flex;
align-items: center;
gap: 10px;
color: var(--muted);
font-size: 13px;
}
select {
min-width: 240px;
color: var(--text);
background: rgba(5,8,14,0.78);
border: 1px solid var(--line);
border-radius: 14px;
padding: 11px 14px;
outline: none;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 18px;
}
.tab-content { display: none; }
.tab-content.active { display: block; }
.voice-layout {
display: grid;
grid-template-columns: 380px 1fr;
gap: 18px;
}
.content-card {
border-radius: 28px;
padding: 22px;
}
.card-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.card-title h2 {
margin: 0;
font-family: "Archivo Black", sans-serif;
font-size: 26px;
letter-spacing: -0.04em;
text-transform: uppercase;
}
.mini {
color: var(--faint);
font: 700 11px/1 "JetBrains Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.field-group { display: grid; gap: 8px; margin-bottom: 14px; }
.field-group label { color: var(--muted); font-size: 13px; font-weight: 800; }
.button-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.btn {
border: 0;
border-radius: 16px;
padding: 13px 16px;
color: #061014;
cursor: pointer;
font-weight: 900;
transition: transform 140ms ease, filter 140ms ease;
}
.btn:hover { transform: translateY(-1px); filter: brightness(1.08); }
.btn-primary { background: linear-gradient(135deg, var(--cyan), var(--blue)); color: white; }
.btn-success { background: linear-gradient(135deg, var(--green), var(--cyan)); }
.btn-danger { background: linear-gradient(135deg, var(--red), #ff9a6b); color: white; }
.voice-status { color: var(--muted); font-size: 13px; margin-top: 12px; min-height: 20px; }
.visualizer {
display: flex;
align-items: flex-end;
gap: 5px;
height: 130px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 20px;
background: rgba(0,0,0,0.22);
overflow: hidden;
}
.bar {
flex: 1;
min-width: 5px;
border-radius: 999px;
height: 3px;
background: linear-gradient(to top, var(--blue), var(--cyan), var(--green));
box-shadow: 0 0 18px rgba(0,229,255,0.16);
}
.participants {
display: grid;
gap: 10px;
}
.user-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255,255,255,0.035);
}
.user-item.speaking { border-color: rgba(57,255,136,0.55); background: rgba(57,255,136,0.08); }
.user-item img { width: 34px; height: 34px; border-radius: 999px; }
.feed {
display: grid;
gap: 12px;
}
.event-card {
display: grid;
gap: 10px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 20px;
background: rgba(255,255,255,0.035);
}
.event-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.author {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.avatar {
width: 34px;
height: 34px;
border-radius: 999px;
background: linear-gradient(135deg, var(--blue), var(--cyan));
flex: 0 0 auto;
}
.avatar img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
.name { font-weight: 900; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.time { color: var(--faint); font: 600 11px/1 "JetBrains Mono", monospace; white-space: nowrap; }
.message-text { color: #dbe6f7; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
.sticker-strip, .attachment-strip { display: flex; gap: 10px; flex-wrap: wrap; }
.sticker-img { width: 96px; height: 96px; object-fit: contain; border-radius: 16px; background: rgba(0,0,0,0.22); border: 1px solid var(--line); padding: 8px; }
.attachment-chip { color: var(--cyan); text-decoration: none; border: 1px solid var(--line); border-radius: 14px; padding: 8px 10px; font: 700 12px/1 "JetBrains Mono", monospace; background: rgba(0,229,255,0.06); }
.embed-card { border-left: 4px solid var(--blue); border-radius: 16px; padding: 12px; background: rgba(98,117,255,0.08); display: grid; gap: 8px; }
.embed-title { font-weight: 900; color: var(--text); }
.embed-description { color: var(--muted); line-height: 1.5; white-space: pre-wrap; }
.embed-image { max-width: 360px; width: 100%; border-radius: 14px; border: 1px solid var(--line); }
.badges { display: flex; gap: 8px; flex-wrap: wrap; }
.badge {
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 9px;
color: var(--muted);
font: 700 10px/1 "JetBrains Mono", monospace;
text-transform: uppercase;
}
.badge.edit { color: var(--yellow); border-color: rgba(255,228,94,0.36); }
.badge.delete { color: var(--red); border-color: rgba(255,79,109,0.42); }
.filename { font-size: 13px; font-weight: 900; word-break: break-word; }
.link { color: var(--cyan); text-decoration: none; font-weight: 900; }
.link:hover { text-decoration: underline; }
.empty {
padding: 34px;
text-align: center;
color: var(--faint);
border: 1px dashed var(--line);
border-radius: 22px;
}
.error {
display: none;
margin-bottom: 14px;
padding: 12px 14px;
color: #ffd8df;
border: 1px solid rgba(255,79,109,0.5);
border-radius: 16px;
background: rgba(255,79,109,0.12);
}
@media (max-width: 980px) {
.hero, .voice-layout { grid-template-columns: 1fr; }
.tab-panel { align-items: stretch; flex-direction: column; }
.filter-row { align-items: stretch; flex-direction: column; }
select { width: 100%; min-width: 0; }
}

View File

@@ -1,528 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Moderation Dashboard</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #0f0f0f;
color: #e0e0e0;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
header {
margin-bottom: 30px;
border-bottom: 2px solid #333;
padding-bottom: 20px;
}
h1 {
font-size: 28px;
margin-bottom: 10px;
color: #fff;
}
.status {
display: flex;
gap: 20px;
font-size: 14px;
color: #999;
}
.status-item {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
}
.status-dot.connected {
background: #4ade80;
}
.controls {
display: flex;
gap: 15px;
margin-bottom: 20px;
align-items: center;
}
select {
padding: 8px 12px;
background: #1a1a1a;
border: 1px solid #333;
color: #e0e0e0;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
}
select:hover {
border-color: #555;
}
.tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 1px solid #333;
}
.tab {
padding: 12px 20px;
background: none;
border: none;
color: #999;
cursor: pointer;
font-size: 14px;
font-weight: 500;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab:hover {
color: #e0e0e0;
}
.tab.active {
color: #fff;
border-bottom-color: #4ade80;
}
.content {
display: none;
}
.content.active {
display: block;
}
.message-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.message-item {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
padding: 15px;
transition: all 0.2s;
}
.message-item:hover {
border-color: #555;
background: #222;
}
.message-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: #333;
flex-shrink: 0;
}
.avatar img {
width: 100%;
height: 100%;
border-radius: 50%;
}
.user-info {
flex: 1;
}
.username {
font-weight: 600;
color: #fff;
font-size: 14px;
}
.timestamp {
font-size: 12px;
color: #666;
}
.message-content {
color: #e0e0e0;
font-size: 14px;
line-height: 1.5;
margin-bottom: 10px;
word-break: break-word;
}
.message-meta {
display: flex;
gap: 15px;
font-size: 12px;
color: #666;
}
.badge {
display: inline-block;
padding: 2px 8px;
background: #333;
border-radius: 3px;
font-size: 11px;
color: #999;
}
.badge.edited {
background: #4a3a00;
color: #ffd700;
}
.badge.deleted {
background: #3a0000;
color: #ff6b6b;
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
}
.image-item {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
overflow: hidden;
transition: all 0.2s;
}
.image-item:hover {
border-color: #555;
}
.image-preview {
width: 100%;
height: 150px;
background: #0a0a0a;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-info {
padding: 12px;
border-top: 1px solid #333;
}
.image-filename {
font-size: 12px;
color: #e0e0e0;
margin-bottom: 8px;
word-break: break-all;
}
.image-meta {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #666;
}
.image-url {
color: #4ade80;
cursor: pointer;
text-decoration: none;
}
.image-url:hover {
text-decoration: underline;
}
.empty {
text-align: center;
padding: 40px;
color: #666;
}
.error {
background: #3a0000;
border: 1px solid #660000;
color: #ff6b6b;
padding: 12px;
border-radius: 4px;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>🛡️ Moderation Dashboard</h1>
<div class="status">
<div class="status-item">
<div class="status-dot" id="wsStatus"></div>
<span id="wsStatusText">Connecting...</span>
</div>
</div>
</header>
<div class="controls">
<label for="channelFilter">Filter by Channel:</label>
<select id="channelFilter">
<option value="">All Channels</option>
</select>
</div>
<div class="tabs">
<button class="tab active" data-tab="text">💬 Text Messages</button>
<button class="tab" data-tab="image">🖼️ Images</button>
<button class="tab" data-tab="voice">🎙️ Voice</button>
</div>
<div id="error" class="error" style="display: none;"></div>
<div id="text" class="content active">
<div class="message-list" id="textList"></div>
</div>
<div id="image" class="content">
<div class="image-grid" id="imageGrid"></div>
</div>
<div id="voice" class="content">
<div class="message-list" id="voiceList"></div>
</div>
</div>
<script>
const API_BASE = window.location.origin;
let ws = null;
let selectedChannel = '';
let messageCache = { text: [], image: [], voice: [] };
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
ws.onopen = () => {
updateWSStatus(true);
console.log('WebSocket connected');
};
ws.onmessage = (event) => {
try {
if (typeof event.data === 'string') {
const message = JSON.parse(event.data);
handleWebSocketMessage(message);
}
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
updateWSStatus(false);
};
ws.onclose = () => {
updateWSStatus(false);
setTimeout(connectWebSocket, 3000);
};
}
function updateWSStatus(connected) {
const dot = document.getElementById('wsStatus');
const text = document.getElementById('wsStatusText');
if (connected) {
dot.classList.add('connected');
text.textContent = 'Connected';
} else {
dot.classList.remove('connected');
text.textContent = 'Disconnected';
}
}
function handleWebSocketMessage(message) {
const { type, data } = message;
if (type === 'message_created') {
messageCache.text.unshift(data);
renderMessages();
} else if (type === 'message_updated') {
const msg = messageCache.text.find(m => m.id === data.id);
if (msg) {
msg.edited_content = data.edited_content;
msg.edited_at = data.edited_at;
}
renderMessages();
} else if (type === 'message_deleted') {
messageCache.text = messageCache.text.filter(m => m.id !== data.id);
renderMessages();
} else if (type === 'attachment_uploaded') {
messageCache.image.unshift(data);
renderImages();
}
}
async function fetchMessages() {
try {
const params = new URLSearchParams();
if (selectedChannel) params.append('channel', selectedChannel);
params.append('type', 'text');
params.append('limit', '50');
const response = await fetch(`${API_BASE}/api/messages?${params}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
messageCache.text = result.data || [];
renderMessages();
} catch (error) {
showError(`Failed to fetch messages: ${error.message}`);
}
}
async function fetchImages() {
try {
const params = new URLSearchParams();
if (selectedChannel) params.append('channel', selectedChannel);
params.append('type', 'image');
params.append('limit', '50');
const response = await fetch(`${API_BASE}/api/messages?${params}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
messageCache.image = result.data || [];
renderImages();
} catch (error) {
showError(`Failed to fetch images: ${error.message}`);
}
}
function renderMessages() {
const list = document.getElementById('textList');
if (messageCache.text.length === 0) {
list.innerHTML = '<div class="empty">No messages</div>';
return;
}
list.innerHTML = messageCache.text
.map(msg => `
<div class="message-item">
<div class="message-header">
<div class="avatar">
${msg.avatar_url ? `<img src="${msg.avatar_url}" alt="${msg.username}">` : ''}
</div>
<div class="user-info">
<div class="username">${escapeHtml(msg.username)}</div>
<div class="timestamp">${new Date(msg.created_at).toLocaleString()}</div>
</div>
</div>
<div class="message-content">${escapeHtml(msg.content)}</div>
<div class="message-meta">
${msg.type === 'edited' ? '<span class="badge edited">Edited</span>' : ''}
${msg.type === 'deleted' ? '<span class="badge deleted">Deleted</span>' : ''}
${msg.edited_at ? `<span class="badge">Edited at ${new Date(msg.edited_at).toLocaleString()}</span>` : ''}
</div>
</div>
`)
.join('');
}
function renderImages() {
const grid = document.getElementById('imageGrid');
if (messageCache.image.length === 0) {
grid.innerHTML = '<div class="empty">No images</div>';
return;
}
grid.innerHTML = messageCache.image
.map(img => `
<div class="image-item">
<div class="image-preview">
${img.uploaded_url ? `<img src="${img.uploaded_url}" alt="${img.filename}">` : '<span>Uploading...</span>'}
</div>
<div class="image-info">
<div class="image-filename">${escapeHtml(img.filename)}</div>
<div class="image-meta">
<span>${(img.size / 1024).toFixed(1)}KB</span>
${img.uploaded_url ? `<a href="${img.uploaded_url}" target="_blank" class="image-url">View</a>` : '<span>Pending</span>'}
</div>
</div>
</div>
`)
.join('');
}
function showError(message) {
const errorDiv = document.getElementById('error');
errorDiv.textContent = message;
errorDiv.style.display = 'block';
setTimeout(() => {
errorDiv.style.display = 'none';
}, 5000);
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
const tabName = tab.dataset.tab;
document.getElementById(tabName).classList.add('active');
if (tabName === 'text') fetchMessages();
else if (tabName === 'image') fetchImages();
});
});
document.getElementById('channelFilter').addEventListener('change', (e) => {
selectedChannel = e.target.value;
fetchMessages();
fetchImages();
});
connectWebSocket();
fetchMessages();
</script>
</body>
</html>

View File

@@ -1,559 +0,0 @@
const bootstrapData = JSON.parse(document.getElementById('__DASHBOARD_DATA__')?.textContent || '{}');
const state = {
socket: null,
activeTab: 'voice',
selectedChannel: bootstrapData.selectedChannelId || '',
text: bootstrapData.messages || [],
isStreaming: false,
isListening: false,
audioContextTransmit: null,
audioContextListen: null,
processor: null,
nextStartTime: 0,
noiseGateHold: 0,
};
const SAMPLE_RATE = 24000;
const NOISE_GATE_THRESHOLD = 0.01;
const NOISE_GATE_HOLD_FRAMES = 3;
const el = {
wsDot: document.getElementById('wsDot'),
wsStatusText: document.getElementById('wsStatusText'),
activeTabLabel: document.getElementById('activeTabLabel'),
errorBox: document.getElementById('errorBox'),
guildSelect: document.getElementById('guildSelect'),
channelSelect: document.getElementById('channelSelect'),
channelFilter: document.getElementById('channelFilter'),
joinVoiceBtn: document.getElementById('joinVoiceBtn'),
disconnectVoiceBtn: document.getElementById('disconnectVoiceBtn'),
voiceStatusText: document.getElementById('voiceStatusText'),
voiceStatusNote: document.getElementById('voiceStatusNote'),
toggleBtn: document.getElementById('toggleBtn'),
listenBtn: document.getElementById('listenBtn'),
listenStatus: document.getElementById('listenStatus'),
visualizer: document.getElementById('visualizer'),
userList: document.getElementById('userList'),
textList: document.getElementById('textList'),
};
for (let i = 0; i < 32; i++) {
const bar = document.createElement('div');
bar.className = 'bar';
el.visualizer.appendChild(bar);
}
const bars = [...document.querySelectorAll('.bar')];
async function apiRequest(url, options = {}) {
const response = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
...options,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(error.message || response.statusText);
}
return response.json();
}
function showError(message) {
el.errorBox.textContent = message;
el.errorBox.style.display = 'block';
setTimeout(() => { el.errorBox.style.display = 'none'; }, 4500);
}
function renderOptions(select, items, placeholder) {
select.replaceChildren();
const first = document.createElement('option');
first.value = '';
first.textContent = placeholder;
select.appendChild(first);
for (const item of items) {
const option = document.createElement('option');
option.value = item.id;
option.textContent = item.name;
select.appendChild(option);
}
}
async function loadGuilds() {
const guilds = bootstrapData.guilds || await apiRequest('/api/guilds');
renderOptions(el.guildSelect, guilds, 'Select guild');
const guildId = bootstrapData.selectedGuildId || guilds[0]?.id || '';
if (guildId) {
el.guildSelect.value = guildId;
await loadChannels(guildId);
}
}
async function loadChannels(guildId) {
const useBootstrap = guildId === bootstrapData.selectedGuildId;
const [voiceChannels, watchChannels] = await Promise.all([
useBootstrap && bootstrapData.voiceChannels ? bootstrapData.voiceChannels : apiRequest(`/api/guilds/${guildId}/voice-channels`),
useBootstrap && bootstrapData.watchChannels ? bootstrapData.watchChannels : apiRequest(`/api/guilds/${guildId}/channels`),
]);
renderOptions(el.channelSelect, voiceChannels, 'Select voice channel');
renderOptions(el.channelFilter, watchChannels, 'Select channel');
el.channelFilter.value = state.selectedChannel;
apiRequest(`/api/guilds/${guildId}/threads`)
.then((threads) => appendOptions(el.channelFilter, threads))
.catch((error) => showError(`Thread discovery failed: ${error.message}`));
}
function appendOptions(select, items) {
const existing = new Set([...select.options].map((option) => option.value));
for (const item of items) {
if (existing.has(item.id)) continue;
const option = document.createElement('option');
option.value = item.id;
option.textContent = item.name;
select.appendChild(option);
}
}
async function refreshStatus() {
try {
const status = await apiRequest('/api/status');
el.voiceStatusText.textContent = status.connected ? status.activeChannelName || 'Connected' : 'Not connected';
el.voiceStatusNote.textContent = status.connected ? `Connected to ${status.activeChannelName}` : 'Idle';
} catch (error) {
showError(error.message);
}
}
async function connectVoice() {
const guildId = el.guildSelect.value;
const channelId = el.channelSelect.value;
if (!guildId || !channelId) return showError('Select guild and voice channel first');
const status = await apiRequest('/api/connect', { method: 'POST', body: JSON.stringify({ guildId, channelId }) });
el.voiceStatusText.textContent = status.activeChannelName || 'Connected';
el.voiceStatusNote.textContent = `Connected to ${status.activeChannelName}`;
}
async function disconnectVoice() {
await apiRequest('/api/disconnect', { method: 'POST' });
await refreshStatus();
}
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
state.socket = new WebSocket(`${protocol}//${window.location.host}/ws`);
state.socket.binaryType = 'arraybuffer';
state.socket.onopen = () => {
el.wsDot.classList.add('on');
el.wsStatusText.textContent = 'Connected';
};
state.socket.onclose = () => {
el.wsDot.classList.remove('on');
el.wsStatusText.textContent = 'Reconnecting';
setTimeout(connectWebSocket, 2500);
};
state.socket.onerror = () => {
el.wsDot.classList.remove('on');
el.wsDot.classList.add('warn');
el.wsStatusText.textContent = 'Socket error';
};
state.socket.onmessage = (event) => {
if (typeof event.data === 'string') {
handleJsonEvent(event.data);
return;
}
if (state.isListening) playPcm(event.data);
};
}
function handleJsonEvent(raw) {
const message = JSON.parse(raw);
if (message.type === 'user_state') return renderUsers(message.users || []);
if (message.type === 'message_created') {
state.text.unshift(message.data);
renderText();
}
if (message.type === 'message_updated') {
const item = state.text.find((entry) => entry.id === message.data.id);
if (item) Object.assign(item, { edited_content: message.data.edited_content, edited_at: message.data.edited_at, type: 'edited' });
renderText();
}
if (message.type === 'message_deleted') {
const item = state.text.find((entry) => entry.id === message.data.id);
if (item) Object.assign(item, { deleted_at: message.data.deleted_at, type: 'deleted' });
renderText();
}
if (message.type === 'attachment_uploaded') fetchText();
}
function renderUsers(users) {
el.userList.replaceChildren();
if (users.length === 0) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = 'No active speakers';
el.userList.appendChild(empty);
return;
}
for (const user of users) {
const row = document.createElement('div');
row.className = `user-item${user.speaking ? ' speaking' : ''}`;
const img = document.createElement('img');
img.src = user.avatar || '';
img.alt = '';
const name = document.createElement('span');
name.textContent = user.username;
row.append(img, name);
el.userList.appendChild(row);
}
}
async function fetchText() {
if (!state.selectedChannel) return renderText();
const result = await apiRequest(`/api/messages?channel=${encodeURIComponent(state.selectedChannel)}&type=text&limit=80`);
state.text = result.data || [];
renderText();
}
function parseMetadata(value) {
if (!value) return {};
try { return JSON.parse(value); } catch { return {}; }
}
function renderText() {
el.textList.replaceChildren();
if (!state.selectedChannel) return appendEmpty(el.textList, 'Select channel to view text captures');
if (state.text.length === 0) return appendEmpty(el.textList, 'No text captures yet');
for (const msg of state.text) {
const metadata = parseMetadata(msg.metadata);
const card = document.createElement('article');
card.className = 'event-card';
const head = document.createElement('div');
head.className = 'event-head';
const author = document.createElement('div');
author.className = 'author';
const avatar = document.createElement('div');
avatar.className = 'avatar';
if (msg.avatar_url) {
const img = document.createElement('img');
img.src = msg.avatar_url;
img.alt = '';
avatar.appendChild(img);
}
const name = document.createElement('div');
name.className = 'name';
name.textContent = msg.username || msg.user_id;
author.append(avatar, name);
const time = document.createElement('div');
time.className = 'time';
time.textContent = new Date(msg.created_at).toLocaleString();
head.append(author, time);
const text = document.createElement('div');
text.className = 'message-text';
text.textContent = msg.edited_content || msg.content || '(empty message)';
const stickers = renderStickers(metadata.stickers || []);
const embeds = renderEmbeds(metadata.embeds || []);
const attachments = renderAttachments(metadata.attachments || []);
const badges = document.createElement('div');
badges.className = 'badges';
if (metadata.reference?.messageId) appendBadge(badges, 'reply', '');
if (msg.thread_id) appendBadge(badges, metadata.channel?.threadName ? `thread: ${metadata.channel.threadName}` : 'thread', '');
if (msg.edited_at) appendBadge(badges, 'edited', 'edit');
if (msg.deleted_at) appendBadge(badges, 'deleted', 'delete');
card.append(head, text);
if (stickers.childElementCount > 0) card.appendChild(stickers);
if (embeds.childElementCount > 0) card.appendChild(embeds);
if (attachments.childElementCount > 0) card.appendChild(attachments);
card.appendChild(badges);
el.textList.appendChild(card);
}
}
function renderStickers(stickers) {
const wrap = document.createElement('div');
wrap.className = 'sticker-strip';
for (const sticker of stickers) {
const img = document.createElement('img');
img.className = 'sticker-img';
img.src = sticker.url;
img.alt = sticker.name;
wrap.appendChild(img);
}
return wrap;
}
function renderEmbeds(embeds) {
const wrap = document.createElement('div');
wrap.className = 'feed';
for (const embed of embeds) {
const card = document.createElement('div');
card.className = 'embed-card';
if (embed.title) {
const title = document.createElement(embed.url ? 'a' : 'div');
title.className = 'embed-title';
title.textContent = embed.title;
if (embed.url) {
title.href = embed.url;
title.target = '_blank';
title.rel = 'noreferrer';
}
card.appendChild(title);
}
if (embed.description) {
const desc = document.createElement('div');
desc.className = 'embed-description';
desc.textContent = embed.description;
card.appendChild(desc);
}
for (const field of embed.fields || []) {
const fieldNode = document.createElement('div');
fieldNode.className = 'embed-description';
fieldNode.textContent = `${field.name}: ${field.value}`;
card.appendChild(fieldNode);
}
if (embed.image || embed.thumbnail) {
const img = document.createElement('img');
img.className = 'embed-image';
img.src = embed.image || embed.thumbnail;
img.alt = embed.title || 'embed image';
card.appendChild(img);
}
wrap.appendChild(card);
}
return wrap;
}
function renderAttachments(attachments) {
const wrap = document.createElement('div');
wrap.className = 'attachment-strip';
for (const attachment of attachments) {
const link = document.createElement('a');
link.className = 'attachment-chip';
link.href = attachment.url;
link.target = '_blank';
link.rel = 'noreferrer';
link.textContent = `${attachment.name} (${(attachment.size / 1024).toFixed(1)}KB)`;
wrap.appendChild(link);
}
return wrap;
}
function appendBadge(parent, label, className) {
const badge = document.createElement('span');
badge.className = `badge ${className}`;
badge.textContent = label;
parent.appendChild(badge);
}
function appendEmpty(parent, message) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = message;
parent.appendChild(empty);
}
async function startStreaming() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
state.audioContextTransmit = new AudioContext({ sampleRate: SAMPLE_RATE });
const source = state.audioContextTransmit.createMediaStreamSource(stream);
state.processor = state.audioContextTransmit.createScriptProcessor(2048, 1, 1);
source.connect(state.processor);
state.processor.connect(state.audioContextTransmit.destination);
state.processor.onaudioprocess = (event) => {
if (!state.isStreaming || state.socket?.readyState !== WebSocket.OPEN) return;
const input = event.inputBuffer.getChannelData(0);
let sum = 0;
for (let i = 0; i < input.length; i++) sum += input[i] * input[i];
const rms = Math.sqrt(sum / input.length);
if (rms < NOISE_GATE_THRESHOLD && state.noiseGateHold <= 0) return;
state.noiseGateHold = rms >= NOISE_GATE_THRESHOLD ? NOISE_GATE_HOLD_FRAMES : state.noiseGateHold - 1;
const pcm = new Int16Array(input.length);
for (let i = 0; i < input.length; i++) pcm[i] = Math.max(-1, Math.min(1, input[i])) * 32767;
state.socket.send(pcm.buffer);
updateVisualizer(rms);
};
state.isStreaming = true;
el.toggleBtn.textContent = 'Stop Transmitting';
} catch (error) {
showError(`Microphone error: ${error.message}`);
}
}
function stopStreaming() {
state.isStreaming = false;
state.processor?.disconnect();
state.audioContextTransmit?.close();
state.processor = null;
state.audioContextTransmit = null;
el.toggleBtn.textContent = 'Start Transmitting';
updateVisualizer(0);
}
function toggleListen() {
state.isListening = !state.isListening;
if (state.isListening) {
state.audioContextListen = new AudioContext({ sampleRate: 24000 });
state.nextStartTime = state.audioContextListen.currentTime;
initOpusDecoder();
el.listenBtn.textContent = 'Leave Listen Channel';
el.listenStatus.textContent = 'speaker on';
} else {
state.audioContextListen?.close();
state.audioContextListen = null;
if (state.opusDecoder) {
state.opusDecoder.close();
}
state.opusDecoder = null;
state.opusDecoderReady = false;
state.opusDecodeQueue = [];
el.listenBtn.textContent = 'Join Listen Channel';
el.listenStatus.textContent = 'speaker off';
}
}
async function initOpusDecoder() {
if (!window.AudioDecoder) {
showError('WebCodecs AudioDecoder not supported in this browser');
state.isListening = false;
el.listenBtn.textContent = 'Join Listen Channel';
el.listenStatus.textContent = 'speaker off';
return;
}
try {
state.opusDecoder = new AudioDecoder({
output: (audioData) => playAudioDataDirect(audioData),
error: (error) => {
console.error('Opus decode error:', error);
showError(`Opus decode error: ${error.message}`);
},
});
state.opusDecoder.configure({
codec: 'opus',
sampleRate: 48000,
numberOfChannels: 2,
});
state.opusDecoderReady = true;
processOpusQueue();
} catch (error) {
showError(`Failed to init Opus decoder: ${error.message}`);
state.isListening = false;
el.listenBtn.textContent = 'Join Listen Channel';
el.listenStatus.textContent = 'speaker off';
}
}
function playAudioDataDirect(audioData) {
if (!state.audioContextListen || !state.isListening) {
audioData.close();
return;
}
try {
const sampleRate = audioData.sampleRate;
const frameCount = audioData.numberOfFrames;
const numberOfChannels = audioData.numberOfChannels;
const audioBuffer = state.audioContextListen.createBuffer(
numberOfChannels,
frameCount,
sampleRate
);
for (let ch = 0; ch < numberOfChannels; ch++) {
const channelData = audioBuffer.getChannelData(ch);
const tempArray = new Float32Array(frameCount);
audioData.copyTo(tempArray, { planeIndex: ch });
channelData.set(tempArray);
}
const source = state.audioContextListen.createBufferSource();
source.buffer = audioBuffer;
source.connect(state.audioContextListen.destination);
const startAt = Math.max(state.nextStartTime, state.audioContextListen.currentTime);
source.start(startAt);
state.nextStartTime = startAt + audioBuffer.duration;
} catch (error) {
console.error('Play audio error:', error);
} finally {
audioData.close();
}
}
function decodeOpus(opusBuffer) {
if (!state.isListening || !state.opusDecoderReady) {
if (state.isListening) {
state.opusDecodeQueue.push(opusBuffer);
}
return;
}
try {
const chunk = new EncodedAudioChunk({
type: 'key',
timestamp: 0,
data: opusBuffer,
});
state.opusDecoder.decode(chunk);
} catch (error) {
console.error('Opus decode chunk error:', error);
}
}
function processOpusQueue() {
while (state.opusDecodeQueue.length > 0 && state.opusDecoderReady) {
const buffer = state.opusDecodeQueue.shift();
decodeOpus(buffer);
}
}
function playPcm(arrayBuffer) {
if (!state.audioContextListen) return;
const bytes = new Uint8Array(arrayBuffer);
if (bytes.byteLength <= 4) return;
const pcm = new Int16Array(bytes.buffer, bytes.byteOffset + 4, (bytes.byteLength - 4) / 2);
const audioBuffer = state.audioContextListen.createBuffer(1, pcm.length, 24000);
const channel = audioBuffer.getChannelData(0);
for (let i = 0; i < pcm.length; i++) channel[i] = pcm[i] / 32768;
const source = state.audioContextListen.createBufferSource();
source.buffer = audioBuffer;
source.connect(state.audioContextListen.destination);
const startAt = Math.max(state.nextStartTime, state.audioContextListen.currentTime);
source.start(startAt);
state.nextStartTime = startAt + audioBuffer.duration;
}
function updateVisualizer(level) {
bars.forEach((bar, index) => {
const wave = Math.sin(index * 0.55 + Date.now() / 140) * 0.35 + 0.65;
bar.style.height = `${Math.max(3, level * 190 * wave)}px`;
});
}
document.querySelectorAll('.tab-btn').forEach((button) => {
button.addEventListener('click', async () => {
document.querySelectorAll('.tab-btn').forEach((item) => item.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach((item) => item.classList.remove('active'));
button.classList.add('active');
state.activeTab = button.dataset.tab;
document.getElementById(state.activeTab).classList.add('active');
el.activeTabLabel.textContent = button.textContent;
if (state.activeTab === 'text') await fetchText();
});
});
el.guildSelect.addEventListener('change', () => loadChannels(el.guildSelect.value).catch((error) => showError(error.message)));
el.joinVoiceBtn.addEventListener('click', () => connectVoice().catch((error) => showError(error.message)));
el.disconnectVoiceBtn.addEventListener('click', () => disconnectVoice().catch((error) => showError(error.message)));
el.toggleBtn.addEventListener('click', () => state.isStreaming ? stopStreaming() : startStreaming());
el.listenBtn.addEventListener('click', toggleListen);
el.channelFilter.addEventListener('change', async () => {
state.selectedChannel = el.channelFilter.value;
const url = new URL(window.location.href);
if (state.selectedChannel) url.searchParams.set('channel', state.selectedChannel);
else url.searchParams.delete('channel');
if (el.guildSelect.value) url.searchParams.set('guild', el.guildSelect.value);
window.history.replaceState({}, '', url);
await fetchText().catch((error) => showError(error.message));
});
connectWebSocket();
loadGuilds().then(refreshStatus).catch((error) => showError(error.message));
setInterval(() => {
if (state.activeTab === 'text') fetchText().catch(() => {});
}, 7000);

View File

@@ -1 +0,0 @@
<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0; url=/"></head><body></body></html>

34
scripts/install-yt-dlp.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env sh
set -eu
if command -v yt-dlp >/dev/null 2>&1; then
echo "yt-dlp already installed: $(command -v yt-dlp)"
yt-dlp --version
exit 0
fi
if command -v pacman >/dev/null 2>&1; then
sudo pacman -S --needed yt-dlp
elif command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y yt-dlp
elif command -v dnf >/dev/null 2>&1; then
sudo dnf install -y yt-dlp
elif command -v brew >/dev/null 2>&1; then
brew install yt-dlp
elif command -v pipx >/dev/null 2>&1; then
pipx install yt-dlp
elif command -v python3 >/dev/null 2>&1; then
python3 -m pip install --user --upgrade yt-dlp
else
echo "Could not find pacman, apt-get, dnf, brew, pipx, or python3 to install yt-dlp." >&2
exit 1
fi
if ! command -v yt-dlp >/dev/null 2>&1; then
echo "yt-dlp installed but is not on PATH. Restart your shell or add the installer bin directory to PATH." >&2
exit 1
fi
echo "yt-dlp installed: $(command -v yt-dlp)"
yt-dlp --version

241
scripts/migrate-data.ts Normal file
View File

@@ -0,0 +1,241 @@
import path from "node:path";
import Database from "better-sqlite3";
import { createChildLogger } from "../src/logger";
import * as postgres from "../src/database/postgres";
const logger = createChildLogger("migrate-data");
interface MuxerJob {
id: string;
data: string;
status: string;
attempts: number;
maxAttempts: number;
createdAt: number;
updatedAt: number;
error?: string;
}
interface Message {
id: string;
guild_id: string;
channel_id: string;
thread_id?: string;
user_id: string;
username: string;
avatar_url?: string;
content: string;
edited_content?: string;
created_at: number;
edited_at?: number;
deleted_at?: number;
type: string;
metadata?: string;
ai_status: string;
ai_moderation_flags?: string;
ai_moderation_score?: number;
ai_moderation_raw?: string;
ai_analysis?: string;
ai_analyzed_at?: number;
ai_error?: string;
}
interface Attachment {
id: string;
message_id: string;
guild_id: string;
channel_id: string;
thread_id?: string;
user_id: string;
filename: string;
size: number;
type: string;
discord_url: string;
uploaded_url?: string;
upload_status: string;
upload_error?: string;
created_at: number;
uploaded_at?: number;
}
interface UiState {
key: string;
value: string;
updated_at: number;
}
async function migrateData(): Promise<void> {
let sqliteDb: Database.Database | null = null;
try {
logger.info("Starting data migration from SQLite to PostgreSQL");
// Open SQLite database
const dbPath = path.join(process.cwd(), ".muxer-queue.db");
sqliteDb = new Database(dbPath);
logger.info({ dbPath }, "SQLite database opened");
// Initialize PostgreSQL pool
const pool = postgres.getPool();
logger.info("PostgreSQL connection pool initialized");
// Migrate muxer_jobs table
logger.info("Migrating muxer_jobs table...");
const muxerJobsStmt = sqliteDb.prepare("SELECT * FROM muxer_jobs");
const muxerJobs = muxerJobsStmt.all() as MuxerJob[];
for (const job of muxerJobs) {
await postgres.query(
`INSERT INTO muxer_jobs (id, data, status, attempts, maxAttempts, createdAt, updatedAt, error)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (id) DO NOTHING`,
[
job.id,
job.data,
job.status,
job.attempts,
job.maxAttempts,
job.createdAt,
job.updatedAt,
job.error || null,
],
);
}
logger.info({ count: muxerJobs.length }, "Migrated muxer_jobs");
// Migrate messages table
logger.info("Migrating messages table...");
const messagesStmt = sqliteDb.prepare("SELECT * FROM messages");
const messages = messagesStmt.all() as Message[];
for (const msg of messages) {
await postgres.query(
`INSERT INTO messages (
id, guild_id, channel_id, thread_id, user_id, username, avatar_url,
content, edited_content, created_at, edited_at, deleted_at, type,
metadata, ai_status, ai_moderation_flags, ai_moderation_score,
ai_moderation_raw, ai_analysis, ai_analyzed_at, ai_error
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, $17, $18, $19, $20, $21
)
ON CONFLICT (id) DO NOTHING`,
[
msg.id,
msg.guild_id,
msg.channel_id,
msg.thread_id || null,
msg.user_id,
msg.username,
msg.avatar_url || null,
msg.content,
msg.edited_content || null,
msg.created_at,
msg.edited_at || null,
msg.deleted_at || null,
msg.type,
msg.metadata || null,
msg.ai_status,
msg.ai_moderation_flags || null,
msg.ai_moderation_score || null,
msg.ai_moderation_raw || null,
msg.ai_analysis || null,
msg.ai_analyzed_at || null,
msg.ai_error || null,
],
);
}
logger.info({ count: messages.length }, "Migrated messages");
// Migrate attachments table
logger.info("Migrating attachments table...");
const attachmentsStmt = sqliteDb.prepare("SELECT * FROM attachments");
const attachments = attachmentsStmt.all() as Attachment[];
for (const att of attachments) {
await postgres.query(
`INSERT INTO attachments (
id, message_id, guild_id, channel_id, thread_id, user_id, filename,
size, type, discord_url, uploaded_url, upload_status, upload_error,
created_at, uploaded_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
)
ON CONFLICT (id) DO NOTHING`,
[
att.id,
att.message_id,
att.guild_id,
att.channel_id,
att.thread_id || null,
att.user_id,
att.filename,
att.size,
att.type,
att.discord_url,
att.uploaded_url || null,
att.upload_status,
att.upload_error || null,
att.created_at,
att.uploaded_at || null,
],
);
}
logger.info({ count: attachments.length }, "Migrated attachments");
// Migrate ui_state table
logger.info("Migrating ui_state table...");
const uiStateStmt = sqliteDb.prepare("SELECT * FROM ui_state");
const uiStates = uiStateStmt.all() as UiState[];
for (const state of uiStates) {
await postgres.query(
`INSERT INTO ui_state (key, value, updated_at)
VALUES ($1, $2, $3)
ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
[state.key, state.value, state.updated_at],
);
}
logger.info({ count: uiStates.length }, "Migrated ui_state");
logger.info(
{
muxerJobs: muxerJobs.length,
messages: messages.length,
attachments: attachments.length,
uiState: uiStates.length,
},
"Data migration completed successfully",
);
} catch (error) {
logger.error(
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
},
"Data migration failed",
);
process.exit(1);
} finally {
// Close SQLite connection
if (sqliteDb) {
sqliteDb.close();
logger.info("SQLite database closed");
}
// Close PostgreSQL pool
await postgres.closePool();
logger.info("PostgreSQL connection pool closed");
}
}
// Run migration
migrateData().catch((error) => {
logger.error(
{
error: error instanceof Error ? error.message : String(error),
},
"Unhandled error in migration",
);
process.exit(1);
});

Some files were not shown because too many files have changed in this diff Show More