Files
dc-recorder/docs/superpowers/plans/2026-05-15-split-text-voice-selection.md
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

15 KiB

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:

  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:

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:

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:

export type AppConfig = z.infer<typeof configSchema> & {
  EFFECTIVE_TEXT_GUILD_ID?: string;
  EFFECTIVE_VOICE_GUILD_ID?: string;
};
  • Step 4: Update .env.example

Document:

# 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:

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:

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:

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.

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:

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:

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():

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:

selectedVoiceGuild: guildId,
selectedVoiceChannel: channelId,

Disconnect clears only:

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:

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:

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

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:

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:

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.