Compare commits

..

19 Commits

Author SHA1 Message Date
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
30 changed files with 3263 additions and 47 deletions

View File

@@ -25,12 +25,21 @@ WEBSERVER_PORT=3000
VOICE_CONNECTION_TIMEOUT_MS=15000
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
LOG_LEVEL=info
NODE_ENV=development
# 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
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000
ATTACHMENT_MAX_SIZE_MB=100

File diff suppressed because it is too large Load Diff

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,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

@@ -24,7 +24,7 @@
<nav class="tab-panel">
<div class="tabs"><button class="tab-btn active" data-tab="voice">Voice</button><button class="tab-btn" data-tab="text">Text</button></div>
<div class="filter-row"><span>Channel / Thread</span><select id="channelFilter"><option value="">Select channel</option></select></div>
<div class="filter-row"><span>Text Guild</span><select id="textGuildSelect"><option value="">Select guild</option></select><span>Channel / Thread</span><select id="channelFilter"><option value="">Select channel</option></select></div>
</nav>
<div id="errorBox" class="error"></div>
@@ -33,7 +33,7 @@
<div class="voice-layout">
<div class="content-card">
<div class="card-title"><h2>Voice Control</h2><span class="mini">bridge</span></div>
<div class="field-group"><label for="guildSelect">Guild</label><select id="guildSelect"><option value="">Select guild</option></select></div>
<div class="field-group"><label for="voiceGuildSelect">Voice Guild</label><select id="voiceGuildSelect"><option value="">Select guild</option></select></div>
<div class="field-group"><label for="channelSelect">Voice Channel</label><select id="channelSelect"><option value="">Select voice channel</option></select></div>
<div class="button-row"><button id="joinVoiceBtn" class="btn btn-success">Join</button><button id="disconnectVoiceBtn" class="btn btn-danger">Disconnect</button></div>
<div class="voice-status" id="voiceStatusNote">Idle</div>
@@ -43,6 +43,12 @@
<div style="display:grid;gap:12px;grid-template-columns:1fr 1fr;margin-bottom:14px"><button id="toggleBtn" class="btn btn-primary">Start Transmitting</button><button id="listenBtn" class="btn btn-success">Join Listen Channel</button></div>
<div class="visualizer" id="visualizer"></div>
</div>
<div class="content-card">
<div class="card-title"><h2>Media</h2><span class="mini" id="mediaStatus">Idle</span></div>
<div class="field-group"><label for="mediaSourceInput">Music URL / file path</label><input id="mediaSourceInput" type="text" placeholder="https://example.com/song.mp3"></div>
<div class="button-row"><button id="queueMediaBtn" class="btn btn-primary">Queue / Play</button><button id="skipMediaBtn" class="btn btn-success">Skip</button><button id="stopMediaBtn" class="btn btn-danger">Stop</button></div>
<div id="mediaQueueList" class="feed"><div class="empty">No media queued</div></div>
</div>
</div>
<div class="content-card" style="margin-top:18px"><div class="card-title"><h2>Participants</h2><span class="mini">speaking now</span></div><div id="userList" class="participants"></div></div>
</section>
@@ -53,8 +59,9 @@
<script>
const state = {
socket: null,
selectedGuild: '',
selectedVoiceGuild: '',
selectedVoiceChannel: '',
selectedTextGuild: '',
selectedTextChannel: '',
activeTab: 'voice',
text: [],
@@ -67,11 +74,12 @@
processor: null,
userTimelines: new Map(),
applyingServerState: false,
media: { playing: false, current: null, queue: [] },
};
const SAMPLE_RATE = 24000;
const CHANNELS = 1;
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'), reviewList: document.getElementById('reviewList')
wsDot: document.getElementById('wsDot'), wsStatusText: document.getElementById('wsStatusText'), activeTabLabel: document.getElementById('activeTabLabel'), errorBox: document.getElementById('errorBox'), voiceGuildSelect: document.getElementById('voiceGuildSelect'), textGuildSelect: document.getElementById('textGuildSelect'), 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'), reviewList: document.getElementById('reviewList'), mediaSourceInput: document.getElementById('mediaSourceInput'), mediaStatus: document.getElementById('mediaStatus'), queueMediaBtn: document.getElementById('queueMediaBtn'), skipMediaBtn: document.getElementById('skipMediaBtn'), stopMediaBtn: document.getElementById('stopMediaBtn'), mediaQueueList: document.getElementById('mediaQueueList')
};
for (let i = 0; i < 32; i++) { const bar = document.createElement('div'); bar.className = 'bar'; el.visualizer.appendChild(bar); }
const bars = [...document.querySelectorAll('.bar')];
@@ -85,38 +93,45 @@
function appendBadge(parent, label, className) { const badge = document.createElement('span'); badge.className = `badge ${className}`; badge.textContent = label; parent.appendChild(badge); }
function parseMetadata(value) { if (!value) return {}; try { return JSON.parse(value); } catch { return {}; } }
async function loadGuilds() { const guilds = await apiRequest('/api/guilds'); renderOptions(el.guildSelect, guilds, 'Select guild'); if (state.selectedGuild) { el.guildSelect.value = state.selectedGuild; await loadChannels(state.selectedGuild); } }
async function loadChannels(guildId) { if (!guildId) return; const [voiceChannels, watchChannels] = await Promise.all([apiRequest(`/api/guilds/${guildId}/voice-channels`), apiRequest(`/api/guilds/${guildId}/channels`)]); renderOptions(el.channelSelect, voiceChannels, 'Select voice channel'); renderOptions(el.channelFilter, watchChannels, 'Select channel'); if (state.selectedVoiceChannel) el.channelSelect.value = state.selectedVoiceChannel; 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}`)); }
async function loadGuilds() { const guilds = await apiRequest('/api/guilds'); renderOptions(el.voiceGuildSelect, guilds, 'Select guild'); renderOptions(el.textGuildSelect, guilds, 'Select guild'); if (state.selectedVoiceGuild) { el.voiceGuildSelect.value = state.selectedVoiceGuild; await loadVoiceChannels(state.selectedVoiceGuild); } if (state.selectedTextGuild) { el.textGuildSelect.value = state.selectedTextGuild; await loadTextChannels(state.selectedTextGuild); } }
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}`)); }
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'); await postUIState({ selectedGuild: guildId, selectedVoiceChannel: channelId }); 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 connectVoice() { const guildId = el.voiceGuildSelect.value; const channelId = el.channelSelect.value; if (!guildId || !channelId) return showError('Select guild and voice channel first'); await postUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: channelId }); 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 = location.protocol === 'https:' ? 'wss:' : 'ws:'; state.socket = new WebSocket(`${protocol}//${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 (event.data instanceof ArrayBuffer) { handleIncomingPCM(event.data); return; } try { handleJsonEvent(event.data); } catch {} }; }
function handleJsonEvent(raw) { const message = JSON.parse(raw); if (message.type === 'ui_state') return applyServerState(message.state); 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(); if (message.type === 'message_analyzed') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, message.data); renderText(); } }
function handleJsonEvent(raw) { const message = JSON.parse(raw); if (message.type === 'ui_state') return applyServerState(message.state); 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(); if (message.type === 'message_analyzed') { const item = state.text.find((entry) => entry.id === message.data.id); if (item) Object.assign(item, message.data); renderText(); } if (message.type === 'media_state') { state.media = message.state; renderMedia(); } }
async function applyServerState(next) {
if (!next || state.applyingServerState) return;
state.applyingServerState = true;
const guildChanged = next.selectedGuild !== state.selectedGuild;
const nextVoiceGuild = next.selectedVoiceGuild || next.selectedGuild || '';
const nextTextGuild = next.selectedTextGuild || next.selectedGuild || '';
const voiceGuildChanged = nextVoiceGuild !== state.selectedVoiceGuild;
const textGuildChanged = nextTextGuild !== state.selectedTextGuild;
const textChanged = next.selectedTextChannel !== state.selectedTextChannel;
state.selectedGuild = next.selectedGuild || '';
state.selectedVoiceGuild = nextVoiceGuild;
state.selectedVoiceChannel = next.selectedVoiceChannel || '';
state.selectedTextGuild = nextTextGuild;
state.selectedTextChannel = next.selectedTextChannel || '';
state.activeTab = next.activeTab || 'voice';
state.isListening = !!next.isListening;
state.isStreaming = !!next.isStreaming;
el.guildSelect.value = state.selectedGuild;
if (guildChanged && state.selectedGuild) await loadChannels(state.selectedGuild);
el.voiceGuildSelect.value = state.selectedVoiceGuild;
el.textGuildSelect.value = state.selectedTextGuild;
if (voiceGuildChanged) await loadVoiceChannels(state.selectedVoiceGuild);
if (textGuildChanged) await loadTextChannels(state.selectedTextGuild);
el.channelSelect.value = state.selectedVoiceChannel;
el.channelFilter.value = state.selectedTextChannel;
applyActiveTab(state.activeTab);
if (textChanged || state.activeTab === 'text') {
if (state.selectedTextChannel && state.selectedGuild) {
if ((textChanged || textGuildChanged) && state.selectedTextChannel && state.selectedTextGuild) {
await apiRequest('/api/backlog-sync', {
method: 'POST',
body: JSON.stringify({ guildId: state.selectedGuild, channelId: state.selectedTextChannel }),
}).catch((error) => logger.warn('Backlog sync failed:', error.message));
body: JSON.stringify({ guildId: state.selectedTextGuild, channelId: state.selectedTextChannel }),
}).catch((error) => showError(`Backlog sync failed: ${error.message}`));
}
if (textChanged || textGuildChanged || state.activeTab === 'text') {
await fetchText().catch((error) => showError(error.message));
}
await reconcileListenState();
@@ -142,16 +157,27 @@
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', () => postUIState({ activeTab: button.dataset.tab }).catch((error) => showError(error.message))); });
el.guildSelect.addEventListener('change', () => postUIState({ selectedGuild: el.guildSelect.value, selectedVoiceChannel: '', selectedTextChannel: '' }).catch((error) => showError(error.message)));
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.joinVoiceBtn.addEventListener('click', () => connectVoice().catch((error) => showError(error.message)));
el.disconnectVoiceBtn.addEventListener('click', () => disconnectVoice().catch((error) => showError(error.message)));
el.toggleBtn.addEventListener('click', () => postUIState({ isStreaming: !state.isStreaming }).catch((error) => showError(error.message)));
el.listenBtn.addEventListener('click', () => postUIState({ isListening: !state.isListening }).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.guildSelect.value) url.searchParams.set('guild', el.guildSelect.value); history.replaceState({}, '', url); postUIState({ selectedTextChannel }).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)); });
async function fetchMediaStatus() { state.media = await apiRequest('/api/media/status'); renderMedia(); }
async function queueMedia() { const source = el.mediaSourceInput.value.trim(); if (!source) return showError('Enter a music URL or local file path'); state.media = await apiRequest('/api/media/queue', { method: 'POST', body: JSON.stringify({ source }) }); el.mediaSourceInput.value = ''; renderMedia(); }
async function skipMedia() { state.media = await apiRequest('/api/media/skip', { method: 'POST' }); renderMedia(); }
async function stopMedia() { state.media = await apiRequest('/api/media/stop', { method: 'POST' }); renderMedia(); }
function renderMedia() { el.mediaQueueList.replaceChildren(); const current = state.media.current; el.mediaStatus.textContent = current ? `Playing ${current.title}` : 'Idle'; if (current) { const item = document.createElement('div'); item.className = 'event-card'; item.textContent = `Now: ${current.title}`; el.mediaQueueList.appendChild(item); } for (const queued of state.media.queue || []) { const item = document.createElement('div'); item.className = 'event-card'; item.textContent = queued.title; el.mediaQueueList.appendChild(item); } if (!current && (!state.media.queue || state.media.queue.length === 0)) appendEmpty(el.mediaQueueList, 'No media queued'); }
el.queueMediaBtn.addEventListener('click', () => queueMedia().catch((error) => showError(error.message)));
el.skipMediaBtn.addEventListener('click', () => skipMedia().catch((error) => showError(error.message)));
el.stopMediaBtn.addEventListener('click', () => stopMedia().catch((error) => showError(error.message)));
connectWebSocket();
apiRequest('/api/ui-state').then(applyServerState).then(() => loadGuilds()).then(refreshStatus).catch((error) => showError(error.message));
apiRequest('/api/ui-state').then(applyServerState).then(() => loadGuilds()).then(refreshStatus).then(fetchMediaStatus).catch((error) => showError(error.message));
setInterval(() => { if (state.activeTab === 'text') fetchText().catch(() => {}); }, 7000);
</script>
</body>

View File

@@ -6,6 +6,9 @@ const configSchema = z
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
VOICE_CHANNEL_ID: z.string().min(1).optional(),
GUILD_ID: z.string().min(1).optional(),
TEXT_GUILD_ID: z.string().min(1).optional(),
TEXT_CHANNEL_ID: z.string().min(1).optional(),
VOICE_GUILD_ID: z.string().min(1).optional(),
VERBOSE: z
.string()
.optional()
@@ -98,11 +101,19 @@ const configSchema = z
}
});
export type AppConfig = z.infer<typeof configSchema>;
export type AppConfig = z.infer<typeof configSchema> & {
EFFECTIVE_TEXT_GUILD_ID?: string;
EFFECTIVE_VOICE_GUILD_ID?: string;
};
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
try {
return configSchema.parse(env);
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,
};
} catch (error) {
if (error instanceof z.ZodError) {
const messages = error.issues

View File

@@ -0,0 +1,139 @@
import { AppError } from "../errors";
import { discordPlayer } from "../player";
import { MediaQueue } from "./mediaQueue";
import { resolveMediaSource } from "./mediaResolver";
import type {
MediaState,
MusicPlayback,
MusicPlayer,
ResolvedMediaSource,
} from "./mediaTypes";
import { createMusicPlayer } from "./musicPlayer";
export interface MediaControllerDependencies {
isVoiceConnected?: () => boolean;
isBrowserStreaming?: () => boolean;
resolveMediaSource?: (source: string) => Promise<ResolvedMediaSource>;
musicPlayer?: MusicPlayer;
onStateChange?: (state: MediaState) => void;
}
export class MediaController {
private readonly queueStore = new MediaQueue();
private readonly musicPlayer: MusicPlayer;
private playback: MusicPlayback | null = null;
private playbackToken = 0;
private skipInProgress = false;
constructor(private readonly dependencies: MediaControllerDependencies = {}) {
this.musicPlayer = dependencies.musicPlayer ?? createMusicPlayer();
}
getState(): MediaState {
const snapshot = this.queueStore.snapshot();
return {
playing: snapshot.current?.status === "playing",
...snapshot,
};
}
async queue(source: string): Promise<MediaState> {
this.assertCanStart();
const resolved = await (
this.dependencies.resolveMediaSource ?? resolveMediaSource
)(source);
this.queueStore.add(resolved);
this.startNextIfIdle();
return this.emitState();
}
async skip(): Promise<MediaState> {
if (this.skipInProgress) {
throw new AppError(
"Skip already in progress",
"MEDIA_SKIP_IN_PROGRESS",
409,
);
}
this.skipInProgress = true;
try {
this.playbackToken++;
this.playback?.stop();
this.playback = null;
this.queueStore.completeCurrent();
this.startNextIfIdle();
return this.emitState();
} finally {
this.skipInProgress = false;
}
}
async stop(): Promise<MediaState> {
this.playbackToken++;
this.playback?.stop();
this.playback = null;
this.queueStore.clear();
return this.emitState();
}
private assertCanStart(): void {
const isVoiceConnected =
this.dependencies.isVoiceConnected ?? (() => discordPlayer.isConnected());
if (!isVoiceConnected()) {
throw new AppError(
"Connect to a voice channel before playing media",
"VOICE_NOT_CONNECTED",
409,
);
}
if (this.dependencies.isBrowserStreaming?.()) {
throw new AppError(
"Stop browser microphone streaming before playing media",
"BROWSER_STREAM_ACTIVE",
409,
);
}
}
private startNextIfIdle(): void {
if (this.playback) return;
const item = this.queueStore.startNext();
if (!item) return;
const token = ++this.playbackToken;
try {
this.playback = this.musicPlayer.play(item);
} catch {
this.queueStore.failCurrent();
this.playback = null;
this.startNextIfIdle();
this.emitState();
return;
}
this.playback.done.then(
() => this.finishCurrent(token, false),
() => this.finishCurrent(token, true),
);
}
private finishCurrent(token: number, failed: boolean): void {
if (token !== this.playbackToken) return;
this.playback = null;
if (failed) {
this.queueStore.failCurrent();
} else {
this.queueStore.completeCurrent();
}
this.startNextIfIdle();
this.emitState();
}
private emitState(): MediaState {
const state = this.getState();
this.dependencies.onStateChange?.(state);
return state;
}
}

59
src/media/mediaQueue.ts Normal file
View File

@@ -0,0 +1,59 @@
import type {
MediaQueueItem,
MediaState,
ResolvedMediaSource,
} from "./mediaTypes";
export class MediaQueue {
private current: MediaQueueItem | null = null;
private readonly items: MediaQueueItem[] = [];
constructor(
private readonly createId: () => string = () => crypto.randomUUID(),
private readonly now = () => Date.now(),
) {}
add(source: ResolvedMediaSource, requestedBy = "dashboard"): MediaQueueItem {
const item: MediaQueueItem = {
id: this.createId(),
mode: "music",
requestedBy,
addedAt: this.now(),
status: "queued",
...source,
};
this.items.push(item);
return { ...item };
}
startNext(): MediaQueueItem | null {
if (this.current) return { ...this.current };
const next = this.items.shift();
if (!next) return null;
this.current = { ...next, status: "playing" };
return { ...this.current };
}
completeCurrent(): void {
this.current = null;
}
failCurrent(): MediaQueueItem | null {
if (!this.current) return null;
const failed = { ...this.current, status: "failed" as const };
this.current = null;
return failed;
}
clear(): void {
this.current = null;
this.items.length = 0;
}
snapshot(): Pick<MediaState, "current" | "queue"> {
return {
current: this.current ? { ...this.current } : null,
queue: this.items.map((item) => ({ ...item })),
};
}
}

View File

@@ -0,0 +1,53 @@
import { existsSync, statSync } from "node:fs";
import path from "node:path";
import { AppError } from "../errors";
import type { ResolvedMediaSource } from "./mediaTypes";
export async function resolveMediaSource(
input: string,
): Promise<ResolvedMediaSource> {
const source = input.trim();
if (!source) {
throw new AppError("Media source is required", "MISSING_MEDIA_SOURCE", 400);
}
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",
};
}
throw new AppError(
"Media source must be an HTTP(S) URL or existing local file",
"UNSUPPORTED_MEDIA_SOURCE",
400,
);
}
function resolveUrlSource(source: string): ResolvedMediaSource | null {
let url: URL;
try {
url = new URL(source);
} catch {
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;
}

40
src/media/mediaTypes.ts Normal file
View File

@@ -0,0 +1,40 @@
import type { Readable } from "node:stream";
export type MediaMode = "music" | "screen";
export type MediaSourceKind = "url" | "local";
export type MediaQueueItemStatus = "queued" | "playing" | "failed";
export interface ResolvedMediaSource {
source: string;
title: string;
kind: MediaSourceKind;
}
export interface MediaQueueItem extends ResolvedMediaSource {
id: string;
mode: MediaMode;
requestedBy: string;
addedAt: number;
status: MediaQueueItemStatus;
}
export interface MediaState {
playing: boolean;
current: MediaQueueItem | null;
queue: MediaQueueItem[];
}
export interface MusicPlayback {
done: Promise<void>;
stop(): void;
}
export interface MusicPlayer {
play(source: ResolvedMediaSource): MusicPlayback;
}
export interface DiscordAudioPlayer {
isConnected(): boolean;
playStream(stream: Readable): void;
stop(): void;
}

79
src/media/musicPlayer.ts Normal file
View File

@@ -0,0 +1,79 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { spawn as nodeSpawn } from "node:child_process";
import { discordPlayer } from "../player";
import type {
DiscordAudioPlayer,
MusicPlayback,
MusicPlayer,
ResolvedMediaSource,
} from "./mediaTypes";
export interface MusicPlayerDependencies {
spawn?: typeof nodeSpawn;
discordPlayer?: DiscordAudioPlayer;
}
export function createMusicPlayer(
dependencies: MusicPlayerDependencies = {},
): MusicPlayer {
const spawn = dependencies.spawn ?? nodeSpawn;
const audioPlayer = dependencies.discordPlayer ?? discordPlayer;
return {
play(source: ResolvedMediaSource): MusicPlayback {
if (!audioPlayer.isConnected()) {
throw new Error("Discord audio player is not connected");
}
const proc = spawn("ffmpeg", buildFfmpegArgs(source.source), {
stdio: ["ignore", "pipe", "pipe"],
}) as unknown as ChildProcessWithoutNullStreams;
proc.stderr.resume();
audioPlayer.playStream(proc.stdout);
let stopped = false;
const done = new Promise<void>((resolve, reject) => {
proc.on("error", reject);
proc.stdout.on("error", reject);
proc.on("close", (code) => {
if (code === 0 || stopped) {
resolve();
return;
}
reject(new Error(`ffmpeg exited with code ${code}`));
});
});
return {
done,
stop() {
if (stopped) return;
stopped = true;
proc.kill("SIGTERM");
audioPlayer.stop();
},
};
},
};
}
export function buildFfmpegArgs(source: string): string[] {
return [
"-hide_banner",
"-loglevel",
"warning",
"-i",
source,
"-vn",
"-acodec",
"libopus",
"-ar",
"48000",
"-ac",
"2",
"-f",
"ogg",
"pipe:1",
];
}

View File

@@ -54,20 +54,26 @@ async function syncChannelMessages(
}
export async function syncBacklogMessages(client: Client): Promise<void> {
if (!config.MONITOR_GUILD_ID) {
logger.warn("MONITOR_GUILD_ID not configured, skipping backlog sync");
const textGuildId = config.EFFECTIVE_TEXT_GUILD_ID;
if (!textGuildId) {
logger.warn("TEXT_GUILD_ID not configured, skipping backlog sync");
return;
}
const guild = client.guilds.cache.get(config.MONITOR_GUILD_ID);
const guild = client.guilds.cache.get(textGuildId);
if (!guild) {
logger.warn(
{ guildId: config.MONITOR_GUILD_ID },
"Monitor guild not found, skipping backlog sync",
{ guildId: textGuildId },
"Text guild not found, skipping backlog sync",
);
return;
}
if (config.TEXT_CHANNEL_ID) {
await syncSelectedChannelBacklog(client, guild.id, config.TEXT_CHANNEL_ID);
return;
}
logger.info(
{ guildId: guild.id },
"Backlog sync ready (will sync on-demand per selected channel)",

View File

@@ -3,6 +3,7 @@ import { createChildLogger } from "../logger";
import type {
AnalysisQueueStatus,
AttachmentRecord,
MediaState,
MessageRecord,
ModerationWsEvent,
} from "./types";
@@ -72,6 +73,9 @@ export function createBroadcaster() {
analysisQueueStatus(data: AnalysisQueueStatus) {
sendJson(clients, { type: "analysis_queue_status", data });
},
mediaState(state: MediaState) {
sendJson(clients, { type: "media_state", state });
},
};
}

View File

@@ -30,6 +30,32 @@ function getModerationBroadcaster(): ModerationBroadcaster | undefined {
return (globalThis as ModerationGlobal).moderationBroadcaster;
}
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;
}
function getTextCaptureTarget(): TextCaptureTarget {
return {
guildId: config.EFFECTIVE_TEXT_GUILD_ID,
channelId: config.TEXT_CHANNEL_ID,
};
}
export async function captureMessage(
message: Message,
type: "text" | "edited" | "deleted",
@@ -110,7 +136,7 @@ export async function captureMessage(
export function registerMessageCapture(client: Client): void {
client.on("messageCreate", async (message) => {
if (!message.guildId || message.guildId !== config.MONITOR_GUILD_ID) return;
if (!shouldCaptureMessageLocation(message, getTextCaptureTarget())) return;
if (message.author?.bot) return;
try {
@@ -127,7 +153,7 @@ export function registerMessageCapture(client: Client): void {
});
client.on("messageUpdate", async (_oldMessage, newMessage) => {
if (!newMessage.guildId || newMessage.guildId !== config.MONITOR_GUILD_ID)
if (!shouldCaptureMessageLocation(newMessage, getTextCaptureTarget()))
return;
if (newMessage.author?.bot) return;
@@ -166,7 +192,7 @@ export function registerMessageCapture(client: Client): void {
});
client.on("messageDelete", async (message) => {
if (!message.guildId || message.guildId !== config.MONITOR_GUILD_ID) return;
if (!shouldCaptureMessageLocation(message, getTextCaptureTarget())) return;
if (!message.author) return;
try {

View File

@@ -92,6 +92,27 @@ export interface AnalysisResult {
analysis: string;
}
export type MediaMode = "music" | "screen";
export type MediaSourceKind = "url" | "local";
export type MediaQueueItemStatus = "queued" | "playing" | "failed";
export interface MediaQueueItem {
id: string;
mode: MediaMode;
source: string;
title: string;
kind: MediaSourceKind;
requestedBy: string;
addedAt: number;
status: MediaQueueItemStatus;
}
export interface MediaState {
playing: boolean;
current: MediaQueueItem | null;
queue: MediaQueueItem[];
}
export type ModerationWsEvent =
| { type: "ui_state"; state: unknown }
| { type: "user_state"; users: unknown[] }
@@ -100,7 +121,8 @@ export type ModerationWsEvent =
| { type: "message_deleted"; data: { id: string; deleted_at: number } }
| { type: "message_analyzed"; data: MessageRecord }
| { type: "attachment_created"; data: AttachmentRecord }
| { type: "analysis_queue_status"; data: AnalysisQueueStatus };
| { type: "analysis_queue_status"; data: AnalysisQueueStatus }
| { type: "media_state"; state: MediaState };
export interface AnalysisQueueStatus {
queuedConversations: number;

View File

@@ -29,6 +29,10 @@ export class DiscordPlayer {
this.connection.subscribe(this.player);
}
public isConnected(): boolean {
return this.connection !== null;
}
public playStream(stream: Readable) {
console.log("[player] Starting new audio stream...");

55
src/routes/mediaRoutes.ts Normal file
View File

@@ -0,0 +1,55 @@
import type { Router } from "express";
import express from "express";
import { AppError } from "../errors";
import type { MediaController } from "../media/mediaController";
export type MediaRouteController = Pick<
MediaController,
"getState" | "queue" | "skip" | "stop"
>;
export function createMediaRoutes(controller: MediaRouteController): Router {
const router = express.Router();
router.get("/media/status", (_req, res, next) => {
try {
res.json(controller.getState());
} catch (error) {
next(error);
}
});
router.post("/media/queue", async (req, res, next) => {
try {
const { source } = req.body as { source?: string };
if (!source) {
throw new AppError(
"Media source is required",
"MISSING_MEDIA_SOURCE",
400,
);
}
res.json(await controller.queue(source));
} catch (error) {
next(error);
}
});
router.post("/media/skip", async (_req, res, next) => {
try {
res.json(await controller.skip());
} catch (error) {
next(error);
}
});
router.post("/media/stop", async (_req, res, next) => {
try {
res.json(await controller.stop());
} catch (error) {
next(error);
}
});
return router;
}

View File

@@ -6,6 +6,24 @@ import { createChildLogger } from "../logger";
import { syncSelectedChannelBacklog } from "../moderation/backlogSync";
const logger = createChildLogger("sync-routes");
const BACKLOG_SYNC_COOLDOWN_MS = 5 * 60 * 1000;
const recentBacklogSyncs = new Map<string, number>();
export function shouldSkipRecentBacklogSync(
guildId: string,
channelId: string,
now = Date.now(),
): boolean {
const key = `${guildId}:${channelId}`;
const lastSync = recentBacklogSyncs.get(key);
if (lastSync && now - lastSync < BACKLOG_SYNC_COOLDOWN_MS) return true;
recentBacklogSyncs.set(key, now);
return false;
}
export function clearRecentBacklogSyncs(): void {
recentBacklogSyncs.clear();
}
export function createSyncRoutes(client: Client): Router {
const router = express.Router();
@@ -26,6 +44,17 @@ export function createSyncRoutes(client: Client): Router {
);
}
if (shouldSkipRecentBacklogSync(guildId, channelId)) {
logger.debug({ guildId, channelId }, "Skipping recent backlog sync");
res.json({
success: true,
channelId,
messagesSync: 0,
skipped: true,
});
return;
}
logger.info({ guildId, channelId }, "Starting backlog sync");
const count = await syncSelectedChannelBacklog(
@@ -43,6 +72,7 @@ export function createSyncRoutes(client: Client): Router {
success: true,
channelId,
messagesSync: count,
skipped: false,
});
} catch (error) {
next(error);

View File

@@ -5,17 +5,22 @@ import { createChildLogger } from "../logger";
const logger = createChildLogger("ui-state-routes");
export interface SharedUIState {
selectedGuild: string;
selectedVoiceGuild: string;
selectedVoiceChannel: string;
selectedTextGuild: string;
selectedTextChannel: string;
activeTab: "voice" | "text";
isListening: boolean;
isStreaming: boolean;
}
export type SharedUIStatePatch = Partial<SharedUIState> & {
selectedGuild?: string;
};
export interface UIStateRouteOptions {
getSharedUIState: () => SharedUIState;
patchSharedUIState: (patch: Partial<SharedUIState>) => SharedUIState;
patchSharedUIState: (patch: SharedUIStatePatch) => SharedUIState;
}
export function createUIStateRoutes(options: UIStateRouteOptions): Router {
@@ -35,7 +40,7 @@ export function createUIStateRoutes(options: UIStateRouteOptions): Router {
// POST /api/ui-state - Update UI state
router.post("/ui-state", (req, res, next) => {
try {
const patch = req.body as Partial<SharedUIState>;
const patch = req.body as SharedUIStatePatch;
const updated = patchSharedUIState(patch);
res.json(updated);
} catch (error) {

View File

@@ -128,7 +128,7 @@ export function createVoiceRoutes(
// Update UI state and broadcast to connected clients
if (patchSharedUIState && broadcaster) {
const updatedState = patchSharedUIState({
selectedGuild: guildId,
selectedVoiceGuild: guildId,
selectedVoiceChannel: channelId,
});
broadcaster.uiState(updatedState);
@@ -150,7 +150,7 @@ export function createVoiceRoutes(
// Update UI state and broadcast to connected clients
if (patchSharedUIState && broadcaster) {
const updatedState = patchSharedUIState({
selectedGuild: "",
selectedVoiceGuild: "",
selectedVoiceChannel: "",
});
broadcaster.uiState(updatedState);

View File

@@ -8,12 +8,14 @@ import * as prism from "prism-media";
import { WebSocketServer } from "ws";
import { AppError } from "./errors";
import { createChildLogger, logger } from "./logger";
import { MediaController } from "./media/mediaController";
import { getMetrics, uptimeGauge } from "./metrics";
import { createBroadcaster } from "./moderation/broadcaster";
import type { ModerationBroadcaster } from "./moderation/types";
import { getPersistedValue, setPersistedValue } from "./muxer-queue";
import { discordPlayer } from "./player";
import { createAnalysisRoutes } from "./routes/analysisRoutes";
import { createMediaRoutes } from "./routes/mediaRoutes";
import { createMessageRoutes } from "./routes/messageRoutes";
import { createSyncRoutes } from "./routes/syncRoutes";
import { createUIStateRoutes } from "./routes/uiStateRoutes";
@@ -37,17 +39,23 @@ type VoiceGlobals = typeof globalThis & {
};
interface SharedUIState {
selectedGuild: string;
selectedVoiceGuild: string;
selectedVoiceChannel: string;
selectedTextGuild: string;
selectedTextChannel: string;
activeTab: "voice" | "text";
isListening: boolean;
isStreaming: boolean;
}
type SharedUIStatePatch = Partial<SharedUIState> & {
selectedGuild?: string;
};
const defaultSharedUIState: SharedUIState = {
selectedGuild: "",
selectedVoiceGuild: "",
selectedVoiceChannel: "",
selectedTextGuild: "",
selectedTextChannel: "",
activeTab: "voice",
isListening: false,
@@ -56,21 +64,45 @@ const defaultSharedUIState: SharedUIState = {
let sharedUIState: SharedUIState = { ...defaultSharedUIState };
export function normalizeSharedUIState(
value: SharedUIStatePatch,
): 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,
};
}
async function initializeSharedUIState() {
sharedUIState = await getPersistedValue("web-ui-state", defaultSharedUIState);
sharedUIState = normalizeSharedUIState(
await getPersistedValue("web-ui-state", defaultSharedUIState),
);
}
function getSharedUIState(): SharedUIState {
return { ...sharedUIState };
}
function patchSharedUIState(patch: Partial<SharedUIState>) {
function patchSharedUIState(patch: SharedUIStatePatch) {
if (typeof patch.selectedGuild === "string") {
sharedUIState.selectedGuild = patch.selectedGuild;
sharedUIState.selectedVoiceGuild = patch.selectedGuild;
sharedUIState.selectedTextGuild = patch.selectedGuild;
}
if (typeof patch.selectedVoiceGuild === "string") {
sharedUIState.selectedVoiceGuild = patch.selectedVoiceGuild;
}
if (typeof patch.selectedVoiceChannel === "string") {
sharedUIState.selectedVoiceChannel = patch.selectedVoiceChannel;
}
if (typeof patch.selectedTextGuild === "string") {
sharedUIState.selectedTextGuild = patch.selectedTextGuild;
}
if (typeof patch.selectedTextChannel === "string") {
sharedUIState.selectedTextChannel = patch.selectedTextChannel;
}
@@ -130,6 +162,12 @@ export async function startWebserver(
const broadcaster = createBroadcaster();
(globalThis as VoiceGlobals).moderationBroadcaster = broadcaster;
const mediaController = new MediaController({
isVoiceConnected: () => voiceController.getStatus().connected,
isBrowserStreaming: () => sharedUIState.isStreaming,
onStateChange: (state) => broadcaster.mediaState(state),
});
// Security headers. CSP disabled because the current static UI uses inline scripts/styles.
app.use(
helmet({
@@ -204,6 +242,7 @@ export async function startWebserver(
app.use("/api", createMessageRoutes());
app.use("/api", createAnalysisRoutes());
app.use("/api", createSyncRoutes(_client));
app.use("/api", createMediaRoutes(mediaController));
// Inbound: Discord PCM → tagged chunks → browser
(globalThis as VoiceGlobals).broadcastPcmToWeb = (
@@ -339,6 +378,12 @@ export async function startWebserver(
}),
);
ws.send(JSON.stringify({ type: "ui_state", state: getSharedUIState() }));
ws.send(
JSON.stringify({
type: "media_state",
state: mediaController.getState(),
}),
);
ws.on("message", (data: Buffer | ArrayBuffer | Buffer[]) => {
if (!Buffer.isBuffer(data)) return;

View File

@@ -29,4 +29,44 @@ describe("loadConfig", () => {
expect(config.RECORDINGS_DIR).toBe("./recordings");
expect(config.NODE_ENV).toBe("test");
});
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");
});
});

View File

@@ -0,0 +1,214 @@
import { describe, expect, it, vi } from "vitest";
import { AppError } from "../../src/errors";
import { MediaController } from "../../src/media/mediaController";
import type {
MusicPlayback,
MusicPlayer,
ResolvedMediaSource,
} from "../../src/media/mediaTypes";
function deferred() {
let resolve!: () => void;
let reject!: (error: Error) => void;
const promise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function source(input: string): ResolvedMediaSource {
return { source: input, title: input.split("/").pop() || input, kind: "url" };
}
describe("MediaController", () => {
it("rejects queue playback when voice is not connected", async () => {
const controller = new MediaController({
isVoiceConnected: () => false,
isBrowserStreaming: () => false,
resolveMediaSource: async () => source("https://example.com/song.mp3"),
musicPlayer: { play: vi.fn() },
});
await expect(
controller.queue("https://example.com/song.mp3"),
).rejects.toMatchObject({
code: "VOICE_NOT_CONNECTED",
statusCode: 409,
} satisfies Partial<AppError>);
});
it("rejects queue playback while browser streaming is active", async () => {
const controller = new MediaController({
isVoiceConnected: () => true,
isBrowserStreaming: () => true,
resolveMediaSource: async () => source("https://example.com/song.mp3"),
musicPlayer: { play: vi.fn() },
});
await expect(
controller.queue("https://example.com/song.mp3"),
).rejects.toMatchObject({
code: "BROWSER_STREAM_ACTIVE",
statusCode: 409,
} satisfies Partial<AppError>);
});
it("queues and starts the first item", async () => {
const done = deferred();
const playback: MusicPlayback = { done: done.promise, stop: vi.fn() };
const musicPlayer: MusicPlayer = { play: vi.fn(() => playback) };
const controller = new MediaController({
isVoiceConnected: () => true,
isBrowserStreaming: () => false,
resolveMediaSource: async () => source("https://example.com/song.mp3"),
musicPlayer,
});
const state = await controller.queue("https://example.com/song.mp3");
expect(state.playing).toBe(true);
expect(state.current?.title).toBe("song.mp3");
expect(musicPlayer.play).toHaveBeenCalledWith(state.current);
});
it("advances to the next item when playback finishes", async () => {
const first = deferred();
const second = deferred();
const musicPlayer: MusicPlayer = {
play: vi
.fn()
.mockReturnValueOnce({ done: first.promise, stop: vi.fn() })
.mockReturnValueOnce({ done: second.promise, stop: vi.fn() }),
};
const controller = new MediaController({
isVoiceConnected: () => true,
isBrowserStreaming: () => false,
resolveMediaSource: async (input) => source(input),
musicPlayer,
});
await controller.queue("https://example.com/first.mp3");
await controller.queue("https://example.com/second.mp3");
first.resolve();
await new Promise((resolve) => setImmediate(resolve));
expect(controller.getState().current?.title).toBe("second.mp3");
});
it("skips current playback and starts the next item", async () => {
const currentStop = vi.fn();
const nextPlayback = deferred();
const musicPlayer: MusicPlayer = {
play: vi
.fn()
.mockReturnValueOnce({
done: new Promise<void>(() => {}),
stop: currentStop,
})
.mockReturnValueOnce({ done: nextPlayback.promise, stop: vi.fn() }),
};
const controller = new MediaController({
isVoiceConnected: () => true,
isBrowserStreaming: () => false,
resolveMediaSource: async (input) => source(input),
musicPlayer,
});
await controller.queue("https://example.com/first.mp3");
await controller.queue("https://example.com/second.mp3");
const state = await controller.skip();
expect(currentStop).toHaveBeenCalled();
expect(state.current?.title).toBe("second.mp3");
});
it("ignores stale completion after skip starts the next item", async () => {
const first = deferred();
const second = deferred();
const third = deferred();
const musicPlayer: MusicPlayer = {
play: vi
.fn()
.mockReturnValueOnce({ done: first.promise, stop: vi.fn() })
.mockReturnValueOnce({ done: second.promise, stop: vi.fn() })
.mockReturnValueOnce({ done: third.promise, stop: vi.fn() }),
};
const controller = new MediaController({
isVoiceConnected: () => true,
isBrowserStreaming: () => false,
resolveMediaSource: async (input) => source(input),
musicPlayer,
});
await controller.queue("https://example.com/first.mp3");
await controller.queue("https://example.com/second.mp3");
await controller.queue("https://example.com/third.mp3");
await controller.skip();
first.resolve();
await new Promise((resolve) => setImmediate(resolve));
expect(controller.getState().current?.title).toBe("second.mp3");
expect(musicPlayer.play).toHaveBeenCalledTimes(2);
});
it("advances when player throws while starting an item", async () => {
const second = deferred();
const musicPlayer: MusicPlayer = {
play: vi
.fn()
.mockImplementationOnce(() => {
throw new Error("not connected");
})
.mockReturnValueOnce({ done: second.promise, stop: vi.fn() }),
};
const controller = new MediaController({
isVoiceConnected: () => true,
isBrowserStreaming: () => false,
resolveMediaSource: async (input) => source(input),
musicPlayer,
});
await controller.queue("https://example.com/first.mp3");
await controller.queue("https://example.com/second.mp3");
expect(controller.getState().current?.title).toBe("second.mp3");
});
it("stops current playback and clears the queue", async () => {
const stop = vi.fn();
const controller = new MediaController({
isVoiceConnected: () => true,
isBrowserStreaming: () => false,
resolveMediaSource: async (input) => source(input),
musicPlayer: {
play: vi.fn(() => ({ done: new Promise<void>(() => {}), stop })),
},
});
await controller.queue("https://example.com/song.mp3");
const state = await controller.stop();
expect(stop).toHaveBeenCalled();
expect(state).toEqual({ playing: false, current: null, queue: [] });
});
it("emits state changes", async () => {
const onStateChange = vi.fn();
const controller = new MediaController({
isVoiceConnected: () => true,
isBrowserStreaming: () => false,
resolveMediaSource: async (input) => source(input),
musicPlayer: {
play: vi.fn(() => ({ done: new Promise(() => {}), stop: vi.fn() })),
},
onStateChange,
});
await controller.queue("https://example.com/song.mp3");
expect(onStateChange).toHaveBeenCalledWith(
expect.objectContaining({ playing: true }),
);
});
});

View File

@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { MediaQueue } from "../../src/media/mediaQueue";
import type { ResolvedMediaSource } from "../../src/media/mediaTypes";
function source(
overrides: Partial<ResolvedMediaSource> = {},
): ResolvedMediaSource {
return {
source: "https://example.com/audio.ogg",
title: "audio.ogg",
kind: "url",
...overrides,
};
}
describe("MediaQueue", () => {
it("adds items with stable queue metadata", () => {
const queue = new MediaQueue(
() => "item-1",
() => 1700000000000,
);
const item = queue.add(source(), "tester");
expect(item).toMatchObject({
id: "item-1",
mode: "music",
source: "https://example.com/audio.ogg",
title: "audio.ogg",
kind: "url",
requestedBy: "tester",
addedAt: 1700000000000,
status: "queued",
});
expect(queue.snapshot()).toEqual({ current: null, queue: [item] });
});
it("marks the next queued item as playing", () => {
const queue = new MediaQueue(
() => "item-1",
() => 1700000000000,
);
const item = queue.add(source(), "tester");
expect(queue.startNext()).toEqual({ ...item, status: "playing" });
expect(queue.snapshot()).toEqual({
current: { ...item, status: "playing" },
queue: [],
});
});
it("removes current item and starts following item", () => {
let id = 0;
const queue = new MediaQueue(
() => `item-${++id}`,
() => 1700000000000,
);
queue.add(source({ title: "first" }), "tester");
queue.add(source({ title: "second" }), "tester");
queue.startNext();
queue.completeCurrent();
const next = queue.startNext();
expect(next?.title).toBe("second");
expect(queue.snapshot().queue).toEqual([]);
});
it("returns the failed current item", () => {
const queue = new MediaQueue(
() => "item-1",
() => 1700000000000,
);
const item = queue.add(source(), "tester");
queue.startNext();
expect(queue.failCurrent()).toEqual({ ...item, status: "failed" });
expect(queue.snapshot()).toEqual({ current: null, queue: [] });
});
it("clears current and queued items", () => {
const queue = new MediaQueue(
() => "item-1",
() => 1700000000000,
);
queue.add(source(), "tester");
queue.startNext();
queue.clear();
expect(queue.snapshot()).toEqual({ current: null, queue: [] });
});
});

View File

@@ -0,0 +1,71 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { AppError } from "../../src/errors";
import { resolveMediaSource } from "../../src/media/mediaResolver";
describe("resolveMediaSource", () => {
it("accepts http URLs", async () => {
await expect(
resolveMediaSource("https://example.com/music.mp3"),
).resolves.toEqual({
source: "https://example.com/music.mp3",
title: "music.mp3",
kind: "url",
});
});
it("accepts existing local files", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "media-resolver-"));
const file = path.join(dir, "song.ogg");
writeFileSync(file, "audio");
await expect(resolveMediaSource(file)).resolves.toEqual({
source: file,
title: "song.ogg",
kind: "local",
});
});
it("rejects empty sources", async () => {
await expect(resolveMediaSource(" ")).rejects.toMatchObject({
code: "MISSING_MEDIA_SOURCE",
statusCode: 400,
} satisfies Partial<AppError>);
});
it("sanitizes URL titles", async () => {
await expect(
resolveMediaSource("https://example.com/%2e%2e%2fsecret.mp3"),
).resolves.toMatchObject({
title: "secret.mp3",
kind: "url",
});
});
it("rejects unsupported sources", async () => {
await expect(resolveMediaSource("not a url or file")).rejects.toMatchObject(
{
code: "UNSUPPORTED_MEDIA_SOURCE",
statusCode: 400,
} satisfies Partial<AppError>,
);
});
it("rejects non-http URL sources", async () => {
await expect(
resolveMediaSource("file:///tmp/song.mp3"),
).rejects.toMatchObject({
code: "UNSUPPORTED_MEDIA_SOURCE",
statusCode: 400,
} satisfies Partial<AppError>);
});
it("rejects malformed http URLs as unsupported sources", async () => {
await expect(resolveMediaSource("https://[invalid")).rejects.toMatchObject({
code: "UNSUPPORTED_MEDIA_SOURCE",
statusCode: 400,
} satisfies Partial<AppError>);
});
});

View File

@@ -0,0 +1,104 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { describe, expect, it, vi } from "vitest";
import type { DiscordAudioPlayer } from "../../src/media/mediaTypes";
import { createMusicPlayer } from "../../src/media/musicPlayer";
class FakeProcess extends EventEmitter {
stdout = new PassThrough();
stderr = new PassThrough();
killed = false;
kill = vi.fn(() => {
this.killed = true;
this.emit("close", 0);
return true;
});
}
describe("createMusicPlayer", () => {
it("spawns ffmpeg as Ogg Opus and passes stdout to Discord", async () => {
const proc = new FakeProcess();
const spawn = vi.fn(() => proc);
const discordPlayer: DiscordAudioPlayer = {
isConnected: () => true,
playStream: vi.fn(),
stop: vi.fn(),
};
const player = createMusicPlayer({ spawn, discordPlayer });
const playback = player.play({
source: "https://example.com/song.mp3",
title: "song.mp3",
kind: "url",
});
proc.emit("close", 0);
await playback.done;
expect(spawn).toHaveBeenCalledWith(
"ffmpeg",
[
"-hide_banner",
"-loglevel",
"warning",
"-i",
"https://example.com/song.mp3",
"-vn",
"-acodec",
"libopus",
"-ar",
"48000",
"-ac",
"2",
"-f",
"ogg",
"pipe:1",
],
{ stdio: ["ignore", "pipe", "pipe"] },
);
expect(discordPlayer.playStream).toHaveBeenCalledWith(proc.stdout);
});
it("rejects playback when Discord is not connected", () => {
const spawn = vi.fn(() => new FakeProcess());
const discordPlayer: DiscordAudioPlayer = {
isConnected: () => false,
playStream: vi.fn(),
stop: vi.fn(),
};
const player = createMusicPlayer({ spawn, discordPlayer });
expect(() =>
player.play({
source: "/tmp/song.ogg",
title: "song.ogg",
kind: "local",
}),
).toThrow("Discord audio player is not connected");
expect(spawn).not.toHaveBeenCalled();
});
it("kills ffmpeg and stops Discord playback once", () => {
const proc = new FakeProcess();
const discordPlayer: DiscordAudioPlayer = {
isConnected: () => true,
playStream: vi.fn(),
stop: vi.fn(),
};
const player = createMusicPlayer({
spawn: vi.fn(() => proc),
discordPlayer,
});
const playback = player.play({
source: "/tmp/song.ogg",
title: "song.ogg",
kind: "local",
});
playback.stop();
playback.stop();
expect(proc.kill).toHaveBeenCalledTimes(1);
expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
expect(discordPlayer.stop).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,27 @@
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);
});
});

View File

@@ -0,0 +1,90 @@
import type { Request, Response } from "express";
import { describe, expect, it, vi } from "vitest";
import { createMediaRoutes } from "../../src/routes/mediaRoutes";
function getHandler(
router: ReturnType<typeof createMediaRoutes>,
path: string,
method: string,
) {
const layer = router.stack.find((item) => item.route?.path === path);
return layer?.route?.stack.find((item) => item.method === method)?.handle;
}
describe("createMediaRoutes", () => {
it("returns media status", async () => {
const controller = {
getState: vi.fn(() => ({ playing: false, current: null, queue: [] })),
queue: vi.fn(),
skip: vi.fn(),
stop: vi.fn(),
};
const handler = getHandler(
createMediaRoutes(controller),
"/media/status",
"get",
);
const json = vi.fn();
await handler?.({} as Request, { json } as unknown as Response, vi.fn());
expect(json).toHaveBeenCalledWith({
playing: false,
current: null,
queue: [],
});
});
it("queues a source", async () => {
const state = { playing: true, current: null, queue: [] };
const controller = {
getState: vi.fn(),
queue: vi.fn(async () => state),
skip: vi.fn(),
stop: vi.fn(),
};
const handler = getHandler(
createMediaRoutes(controller),
"/media/queue",
"post",
);
const json = vi.fn();
await handler?.(
{ body: { source: "https://example.com/song.mp3" } } as Request,
{ json } as unknown as Response,
vi.fn(),
);
expect(controller.queue).toHaveBeenCalledWith(
"https://example.com/song.mp3",
);
expect(json).toHaveBeenCalledWith(state);
});
it("passes missing source errors to Express", async () => {
const controller = {
getState: vi.fn(),
queue: vi.fn(),
skip: vi.fn(),
stop: vi.fn(),
};
const handler = getHandler(
createMediaRoutes(controller),
"/media/queue",
"post",
);
const next = vi.fn();
await handler?.(
{ body: {} } as Request,
{ json: vi.fn() } as unknown as Response,
next,
);
expect(next.mock.calls[0][0]).toMatchObject({
code: "MISSING_MEDIA_SOURCE",
statusCode: 400,
});
});
});

View File

@@ -0,0 +1,97 @@
import type { Request, Response } from "express";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
clearRecentBacklogSyncs,
createSyncRoutes,
shouldSkipRecentBacklogSync,
} from "../../src/routes/syncRoutes";
const syncSelectedChannelBacklog = vi.hoisted(() => vi.fn());
vi.mock("../../src/moderation/backlogSync", () => ({
syncSelectedChannelBacklog,
}));
describe("createSyncRoutes", () => {
beforeEach(() => {
syncSelectedChannelBacklog.mockReset();
clearRecentBacklogSyncs();
});
it("syncs the selected guild and channel from the request", async () => {
syncSelectedChannelBacklog.mockResolvedValue(3);
const router = createSyncRoutes({} as never);
const route = router.stack.find(
(layer) => layer.route?.path === "/backlog-sync",
);
const handler = route?.route?.stack[0]?.handle;
const json = vi.fn();
const next = vi.fn();
await handler?.(
{
body: { guildId: "selected-guild", channelId: "selected-channel" },
} as Request,
{ json } as unknown as Response,
next,
);
expect(syncSelectedChannelBacklog).toHaveBeenCalledWith(
{},
"selected-guild",
"selected-channel",
);
expect(json).toHaveBeenCalledWith({
success: true,
channelId: "selected-channel",
messagesSync: 3,
skipped: false,
});
expect(next).not.toHaveBeenCalled();
});
it("skips repeated sync requests during the cooldown window", async () => {
expect(shouldSkipRecentBacklogSync("guild", "channel", 1000)).toBe(false);
expect(shouldSkipRecentBacklogSync("guild", "channel", 1001)).toBe(true);
});
it("allows repeated sync requests after the cooldown window", async () => {
expect(shouldSkipRecentBacklogSync("guild", "channel", 1000)).toBe(false);
expect(shouldSkipRecentBacklogSync("guild", "channel", 301001)).toBe(false);
});
it("does not call Discord backlog sync for repeated requests", async () => {
syncSelectedChannelBacklog.mockResolvedValue(3);
const router = createSyncRoutes({} as never);
const route = router.stack.find(
(layer) => layer.route?.path === "/backlog-sync",
);
const handler = route?.route?.stack[0]?.handle;
await handler?.(
{
body: { guildId: "selected-guild", channelId: "selected-channel" },
} as Request,
{ json: vi.fn() } as unknown as Response,
vi.fn(),
);
const json = vi.fn();
await handler?.(
{
body: { guildId: "selected-guild", channelId: "selected-channel" },
} as Request,
{ json } as unknown as Response,
vi.fn(),
);
expect(syncSelectedChannelBacklog).toHaveBeenCalledTimes(1);
expect(json).toHaveBeenCalledWith({
success: true,
channelId: "selected-channel",
messagesSync: 0,
skipped: true,
});
});
});

View File

@@ -13,8 +13,8 @@ describe("Discord video stream workspace dependencies", () => {
expect(videoStreamPackage.devDependencies?.["discord.js-selfbot-v13"]).toBe(
"workspace:*",
);
expect(videoStreamPackage.peerDependencies?.["discord.js-selfbot-v13"]).toBe(
"^3.6.0",
);
expect(
videoStreamPackage.peerDependencies?.["discord.js-selfbot-v13"],
).toBe("^3.6.0");
});
});