refactor(backend): remove auth for public API
Deploy to VPS / deploy (push) Failing after 35s

- Remove auth module (auth.routes.ts, /api/auth/login)
- Remove adminAuth middleware from voice and media routes
- Remove adminAuth() function from shared middlewares
- Remove auth-related e2e test
- Clean up .env.example
This commit is contained in:
asepharyana
2026-07-26 11:33:45 +07:00
parent f9f1313ccd
commit 831fddd1bf
56 changed files with 6 additions and 25239 deletions
@@ -1,975 +0,0 @@
# Aggressive Cleanup 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:** Clean up Bun/TypeScript Discord voice recorder with Biome, Vitest, stricter types, modular recorder code, and verification scripts.
**Architecture:** Keep runtime behavior same while moving pure logic into focused modules. `src/recorder.ts` remains public API or re-exports recorder module to minimize import churn; new `src/recorder/*` files own config parsing, metadata creation, decoder lifecycle, segment lifecycle, and stream orchestration.
**Tech Stack:** Bun, TypeScript strict mode, Biome, Vitest, discord.js-selfbot-v13, @discordjs/voice, prism-media.
---
## File Map
- Modify `package.json`: add scripts and dev dependencies.
- Create `biome.json`: formatter/linter config.
- Create `vitest.config.ts`: Bun-compatible Vitest config.
- Modify `tsconfig.json`: include tests/config if needed, keep strict mode.
- Modify `src/config.ts`: typed env/config parsing.
- Create `src/types.ts`: shared recorder-facing types.
- Create `src/recorder/metadata.ts`: user metadata and segment metadata builders.
- Create `src/recorder/decoder.ts`: Opus decoder lifecycle.
- Create `src/recorder/segment.ts`: OGG segment lifecycle.
- Create `src/recorder/audioStream.ts`: subscribe/event wiring helpers.
- Modify `src/recorder.ts`: orchestrator using new modules.
- Create `tests/config.test.ts`, `tests/recorder/metadata.test.ts`, `tests/recorder/decoder.test.ts`, `tests/recorder/segment.test.ts`.
---
### Task 1: Tooling setup
**Files:**
- Modify: `package.json`
- Create: `biome.json`
- Create: `vitest.config.ts`
- Modify: `tsconfig.json`
- [ ] **Step 1: Add dependencies**
Run:
```bash
bun add -d @biomejs/biome vitest
```
Expected: `package.json` and lockfile update.
- [ ] **Step 2: Update scripts in `package.json`**
Set scripts to include:
```json
{
"dev": "bun --watch src/index.ts",
"start": "bun src/index.ts",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"format": "biome format --write .",
"test": "vitest run"
}
```
- [ ] **Step 3: Create `biome.json`**
```json
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"files": {
"includes": ["src/**/*.ts", "tests/**/*.ts", "*.json", "*.ts"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "warn"
}
}
}
}
```
- [ ] **Step 4: Create `vitest.config.ts`**
```ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
},
});
```
- [ ] **Step 5: Verify tooling commands**
Run:
```bash
bun run typecheck
bun run lint
bun run test
```
Expected: typecheck may pass or show existing issues; lint may show format/type warnings; test may pass with no tests or fail if Vitest needs config adjustment. Fix only setup errors in this task.
- [ ] **Step 6: Commit**
```bash
git add package.json bun.lockb biome.json vitest.config.ts tsconfig.json
git commit -m "chore: add code quality tooling"
```
---
### Task 2: Config parsing tests and implementation
**Files:**
- Modify: `src/config.ts`
- Create: `tests/config.test.ts`
- [ ] **Step 1: Write failing tests**
Create `tests/config.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { parseBoolean, parsePositiveNumber } from "../src/config";
describe("config parsers", () => {
it("parses boolean values", () => {
expect(parseBoolean("true", false)).toBe(true);
expect(parseBoolean("false", true)).toBe(false);
expect(parseBoolean(undefined, true)).toBe(true);
});
it("parses positive numbers", () => {
expect(parsePositiveNumber("5000", 0)).toBe(5000);
expect(parsePositiveNumber("0", 123)).toBe(123);
expect(parsePositiveNumber("bad", 123)).toBe(123);
expect(parsePositiveNumber(undefined, 123)).toBe(123);
});
});
```
- [ ] **Step 2: Run failing test**
Run:
```bash
bun run test tests/config.test.ts
```
Expected: FAIL because `parseBoolean` and `parsePositiveNumber` are not exported.
- [ ] **Step 3: Implement config helpers**
Update `src/config.ts` to export:
```ts
export interface AppConfig {
verbose: boolean;
recordingsDir: string;
recordingSegmentMs: number;
decoderRotateMs: number;
decoderCooldownMs: number;
}
export function parseBoolean(value: string | undefined, fallback: boolean): boolean {
if (value === "true") return true;
if (value === "false") return false;
return fallback;
}
export function parsePositiveNumber(value: string | undefined, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
return {
verbose: parseBoolean(env.VERBOSE, false),
recordingsDir: env.RECORDINGS_DIR ?? "./recordings",
recordingSegmentMs: parsePositiveNumber(env.RECORDING_SEGMENT_MS, 5_000),
decoderRotateMs: parsePositiveNumber(env.DECODER_ROTATE_MS, 5_000),
decoderCooldownMs: 30_000,
};
}
export const config = loadConfig();
```
Preserve any existing exports by folding them into `AppConfig` if needed.
- [ ] **Step 4: Run config tests**
Run:
```bash
bun run test tests/config.test.ts
```
Expected: PASS.
- [ ] **Step 5: Run typecheck**
Run:
```bash
bun run typecheck
```
Expected: PASS or only unrelated existing errors. Fix config-related errors.
- [ ] **Step 6: Commit**
```bash
git add src/config.ts tests/config.test.ts
git commit -m "refactor: type application config"
```
---
### Task 3: Shared recorder types
**Files:**
- Create: `src/types.ts`
- [ ] **Step 1: Create shared types**
Create `src/types.ts`:
```ts
import type fs from "node:fs";
import type prism from "prism-media";
export interface RoleMetadata {
id: string;
name: string;
position: number;
}
export interface UserMetadata {
userId: string;
username: string;
tag: string;
displayName: string;
avatarUrl: string;
bot: boolean;
roles: RoleMetadata[];
highestRole: RoleMetadata | null;
joinedTimestamp: number | null;
}
export interface SegmentState {
index: number;
startTime: number;
endTime: number | null;
filename: string;
jsonFilename: string;
oggStream: prism.opus.OggLogicalBitstream;
out: fs.WriteStream;
}
export interface SegmentMetadata extends UserMetadata {
sessionId: string;
sessionStartTime: number;
segmentIndex: number;
segmentMs: number;
startTime: number;
endTime: number;
durationMs: number;
filename: string;
}
export interface PcmBroadcaster {
broadcastPcmToWeb?: (chunk: Buffer, userId: string) => void;
updateActiveUser?: (userId: string, data: { username: string; avatar: string; speaking: boolean }) => void;
}
```
- [ ] **Step 2: Run typecheck**
Run:
```bash
bun run typecheck
```
Expected: PASS. If prism type export fails, use `unknown` for `oggStream` plus local narrowed calls in segment implementation.
- [ ] **Step 3: Commit**
```bash
git add src/types.ts
git commit -m "refactor: add recorder domain types"
```
---
### Task 4: Metadata tests and implementation
**Files:**
- Create: `src/recorder/metadata.ts`
- Create: `tests/recorder/metadata.test.ts`
- [ ] **Step 1: Write tests**
Create `tests/recorder/metadata.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { createSegmentMetadata } from "../../src/recorder/metadata";
import type { SegmentState, UserMetadata } from "../../src/types";
const user: UserMetadata = {
userId: "123",
username: "alice",
tag: "alice#0001",
displayName: "Alice",
avatarUrl: "https://cdn.discordapp.com/embed/avatars/0.png",
bot: false,
roles: [{ id: "role", name: "Admin", position: 1 }],
highestRole: { id: "role", name: "Admin", position: 1 },
joinedTimestamp: 100,
};
const segment = {
index: 2,
startTime: 1_000,
endTime: 2_500,
filename: "/tmp/2500.ogg",
jsonFilename: "/tmp/2500.json",
oggStream: {} as SegmentState["oggStream"],
out: {} as SegmentState["out"],
};
describe("createSegmentMetadata", () => {
it("combines user and segment data", () => {
const metadata = createSegmentMetadata(user, segment, "session-1", 900, 5_000);
expect(metadata).toMatchObject({
userId: "123",
username: "alice",
sessionId: "session-1",
sessionStartTime: 900,
segmentIndex: 2,
segmentMs: 5_000,
startTime: 1_000,
endTime: 2_500,
durationMs: 1_500,
filename: "2500.ogg",
});
});
});
```
- [ ] **Step 2: Run failing test**
```bash
bun run test tests/recorder/metadata.test.ts
```
Expected: FAIL because module does not exist.
- [ ] **Step 3: Implement metadata module**
Create `src/recorder/metadata.ts`:
```ts
import path from "node:path";
import type { Client, VoiceChannel } from "discord.js-selfbot-v13";
import type { SegmentMetadata, SegmentState, UserMetadata } from "../types";
export async function collectUserMetadata(
client: Client,
userId: string,
channel: VoiceChannel,
): Promise<UserMetadata> {
const user = client.users.cache.get(userId) ?? (await client.users.fetch(userId).catch(() => null));
const member = channel.guild.members.cache.get(userId) ?? (await channel.guild.members.fetch(userId).catch(() => null));
const username = user?.username ?? "Unknown User";
const roles =
member?.roles.cache
.filter((role) => role.id !== channel.guild.id)
.sort((a, b) => b.position - a.position)
.map((role) => ({ id: role.id, name: role.name, position: role.position })) ?? [];
return {
userId,
username,
tag: user?.tag ?? "Unknown#0000",
displayName: member?.displayName ?? username,
avatarUrl: user?.displayAvatarURL({ format: "png", size: 64 }) ?? "https://cdn.discordapp.com/embed/avatars/0.png",
bot: user?.bot ?? false,
roles,
highestRole: roles[0] ?? null,
joinedTimestamp: member?.joinedTimestamp ?? null,
};
}
export function createSegmentMetadata(
user: UserMetadata,
segment: SegmentState,
sessionId: string,
sessionStartTime: number,
recordingSegmentMs: number,
): SegmentMetadata {
const endTime = segment.endTime ?? Date.now();
return {
...user,
sessionId,
sessionStartTime,
segmentIndex: segment.index,
segmentMs: recordingSegmentMs,
startTime: segment.startTime,
endTime,
durationMs: endTime - segment.startTime,
filename: path.basename(segment.filename),
};
}
```
- [ ] **Step 4: Run tests and typecheck**
```bash
bun run test tests/recorder/metadata.test.ts
bun run typecheck
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/recorder/metadata.ts tests/recorder/metadata.test.ts
git commit -m "refactor: extract recorder metadata builders"
```
---
### Task 5: Decoder tests and implementation
**Files:**
- Create: `src/recorder/decoder.ts`
- Create: `tests/recorder/decoder.test.ts`
- [ ] **Step 1: Write tests using fake decoder factory**
Create `tests/recorder/decoder.test.ts`:
```ts
import { describe, expect, it, vi } from "vitest";
import { OpusDecoder } from "../../src/recorder/decoder";
class FakeDecoder {
handlers = new Map<string, (...args: unknown[]) => void>();
destroyed = false;
writes: Buffer[] = [];
on(event: string, handler: (...args: unknown[]) => void) {
this.handlers.set(event, handler);
return this;
}
write(chunk: Buffer) {
this.writes.push(chunk);
}
removeAllListeners() {
this.handlers.clear();
}
destroy() {
this.destroyed = true;
}
}
describe("OpusDecoder", () => {
it("creates decoder lazily and writes chunks", () => {
const fake = new FakeDecoder();
const decoder = new OpusDecoder({ cooldownMs: 30_000, rotateMs: 5_000, createDecoder: () => fake as never, onData: vi.fn() });
decoder.write(Buffer.from([1, 2, 3]));
expect(fake.writes).toHaveLength(1);
});
it("destroys and recreates after rotation timeout", () => {
vi.useFakeTimers();
const created: FakeDecoder[] = [];
const decoder = new OpusDecoder({
cooldownMs: 30_000,
rotateMs: 5_000,
createDecoder: () => {
const fake = new FakeDecoder();
created.push(fake);
return fake as never;
},
onData: vi.fn(),
});
decoder.write(Buffer.from([1]));
vi.advanceTimersByTime(5_001);
decoder.rotateIfNeeded();
decoder.write(Buffer.from([2]));
expect(created).toHaveLength(2);
expect(created[0].destroyed).toBe(true);
vi.useRealTimers();
});
});
```
- [ ] **Step 2: Run failing test**
```bash
bun run test tests/recorder/decoder.test.ts
```
Expected: FAIL because module does not exist.
- [ ] **Step 3: Implement decoder module**
Create `src/recorder/decoder.ts`:
```ts
import prism from "prism-media";
export interface OpusDecoderOptions {
cooldownMs: number;
rotateMs: number;
createDecoder?: () => prism.opus.Decoder;
onData: (pcm: Buffer) => void;
}
export class OpusDecoder {
private decoder: prism.opus.Decoder | null = null;
private disabledUntil = 0;
private createdAt = 0;
private readonly cooldownMs: number;
private readonly rotateMs: number;
private readonly createDecoderFn: () => prism.opus.Decoder;
private readonly onData: (pcm: Buffer) => void;
constructor(options: OpusDecoderOptions) {
this.cooldownMs = options.cooldownMs;
this.rotateMs = options.rotateMs;
this.onData = options.onData;
this.createDecoderFn =
options.createDecoder ??
(() => new prism.opus.Decoder({ frameSize: 960, channels: 2, rate: 48_000 }));
}
rotateIfNeeded(): void {
if (!this.decoder || this.rotateMs <= 0) return;
if (Date.now() - this.createdAt < this.rotateMs) return;
this.destroy();
this.ensureDecoder();
}
write(chunk: Buffer): void {
const decoder = this.ensureDecoder();
if (!decoder) return;
try {
decoder.write(chunk);
} catch (error) {
console.warn("[recorder] Opus decoder write failed, cooling down:", error);
this.coolDown();
}
}
destroy(): void {
if (!this.decoder) return;
this.decoder.removeAllListeners();
this.decoder.destroy();
this.decoder = null;
this.createdAt = 0;
}
private ensureDecoder(): prism.opus.Decoder | null {
if (this.decoder) return this.decoder;
if (Date.now() < this.disabledUntil) return null;
try {
const decoder = this.createDecoderFn();
decoder.on("data", this.onData);
decoder.on("error", (error) => {
console.warn("[recorder] Opus decoder error, cooling down:", error);
this.coolDown();
});
this.decoder = decoder;
this.createdAt = Date.now();
return decoder;
} catch (error) {
console.warn("[recorder] Opus decoder init failed, cooling down:", error);
this.disabledUntil = Date.now() + this.cooldownMs;
return null;
}
}
private coolDown(): void {
this.disabledUntil = Date.now() + this.cooldownMs;
this.destroy();
}
}
```
- [ ] **Step 4: Run tests and typecheck**
```bash
bun run test tests/recorder/decoder.test.ts
bun run typecheck
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/recorder/decoder.ts tests/recorder/decoder.test.ts
git commit -m "refactor: extract opus decoder lifecycle"
```
---
### Task 6: Segment tests and implementation
**Files:**
- Create: `src/recorder/segment.ts`
- Create: `tests/recorder/segment.test.ts`
- [ ] **Step 1: Write tests for pure filename and rotation decision helpers**
Create `tests/recorder/segment.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { buildSegmentPaths, shouldRotateSegment } from "../../src/recorder/segment";
describe("buildSegmentPaths", () => {
it("creates matching ogg and json paths", () => {
expect(buildSegmentPaths("/tmp/user", 123)).toEqual({
filename: "/tmp/user/123.ogg",
jsonFilename: "/tmp/user/123.json",
});
});
});
describe("shouldRotateSegment", () => {
it("rotates only when segment limit is exceeded", () => {
expect(shouldRotateSegment(1_000, 1_499, 500)).toBe(false);
expect(shouldRotateSegment(1_000, 1_500, 500)).toBe(true);
expect(shouldRotateSegment(1_000, 2_000, 0)).toBe(false);
});
});
```
- [ ] **Step 2: Run failing test**
```bash
bun run test tests/recorder/segment.test.ts
```
Expected: FAIL because module does not exist.
- [ ] **Step 3: Implement segment helpers and manager**
Create `src/recorder/segment.ts`:
```ts
import fs from "node:fs";
import path from "node:path";
import prism from "prism-media";
import type { SegmentState } from "../types";
export function buildSegmentPaths(userDir: string, startTime: number): { filename: string; jsonFilename: string } {
return {
filename: path.join(userDir, `${startTime}.ogg`),
jsonFilename: path.join(userDir, `${startTime}.json`),
};
}
export function shouldRotateSegment(startTime: number, now: number, recordingSegmentMs: number): boolean {
return recordingSegmentMs > 0 && now - startTime >= recordingSegmentMs;
}
export class SegmentManager {
private currentSegment: SegmentState | null = null;
private segmentIndex = 0;
constructor(
private readonly userDir: string,
private readonly recordingSegmentMs: number,
) {}
open(oggPacketStream: NodeJS.ReadableStream): SegmentState {
const index = this.segmentIndex++;
const startTime = Date.now();
const { filename, jsonFilename } = buildSegmentPaths(this.userDir, startTime);
const oggStream = new prism.opus.OggLogicalBitstream({
opusHead: new prism.opus.OpusHead({ channelCount: 2, sampleRate: 48_000 }),
pageSizeControl: { maxPackets: 10 },
crc: true,
});
const out = fs.createWriteStream(filename);
oggPacketStream.pipe(oggStream).pipe(out);
this.currentSegment = { index, startTime, endTime: null, filename, jsonFilename, oggStream, out };
return this.currentSegment;
}
close(oggPacketStream: NodeJS.ReadableStream): SegmentState | null {
if (!this.currentSegment) return null;
const segment = this.currentSegment;
segment.endTime = Date.now();
oggPacketStream.unpipe(segment.oggStream);
segment.oggStream.end();
this.currentSegment = null;
return segment;
}
rotateIfNeeded(oggPacketStream: NodeJS.ReadableStream): SegmentState | null {
if (!this.currentSegment) return null;
if (!shouldRotateSegment(this.currentSegment.startTime, Date.now(), this.recordingSegmentMs)) return null;
this.close(oggPacketStream);
return this.open(oggPacketStream);
}
getCurrent(): SegmentState | null {
return this.currentSegment;
}
}
```
- [ ] **Step 4: Run tests and typecheck**
```bash
bun run test tests/recorder/segment.test.ts
bun run typecheck
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/recorder/segment.ts tests/recorder/segment.test.ts
git commit -m "refactor: extract recording segment manager"
```
---
### Task 7: Audio stream helper
**Files:**
- Create: `src/recorder/audioStream.ts`
- [ ] **Step 1: Create stream helper**
Create `src/recorder/audioStream.ts`:
```ts
import { EndBehaviorType, type VoiceReceiver } from "@discordjs/voice";
export interface AudioStreamHandlers {
onPacket: (chunk: Buffer) => void;
onEnd: () => void;
onError: (error: Error) => void;
}
export function subscribeToAudioStream(
receiver: VoiceReceiver,
userId: string,
handlers: AudioStreamHandlers,
): NodeJS.ReadableStream {
const audioStream = receiver.subscribe(userId, {
end: {
behavior: EndBehaviorType.AfterSilence,
duration: 3_000,
},
});
audioStream.on("data", handlers.onPacket);
audioStream.on("end", handlers.onEnd);
audioStream.on("error", handlers.onError);
return audioStream;
}
```
- [ ] **Step 2: Run typecheck**
```bash
bun run typecheck
```
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add src/recorder/audioStream.ts
git commit -m "refactor: extract audio stream subscription"
```
---
### Task 8: Refactor `src/recorder.ts` orchestration
**Files:**
- Modify: `src/recorder.ts`
- [ ] **Step 1: Replace inline helpers with modules**
Edit `src/recorder.ts`:
- Import `collectUserMetadata`, `createSegmentMetadata`.
- Import `OpusDecoder`.
- Import `SegmentManager`.
- Import `subscribeToAudioStream`.
- Use `config.recordingsDir`, `config.recordingSegmentMs`, `config.decoderRotateMs`, `config.decoderCooldownMs`.
- Keep `startRecording(client, channel)` and `stopRecording(guildId)` exports unchanged.
- Remove packet debug logging `Pkt #...`.
- Keep current global web update behavior via `globalThis as PcmBroadcaster`.
Core packet handler shape:
```ts
const broadcaster = globalThis as typeof globalThis & PcmBroadcaster;
const userMetadata = await collectUserMetadata(client, userId, channel);
const segmentManager = new SegmentManager(userDir, config.recordingSegmentMs);
const decoder = new OpusDecoder({
cooldownMs: config.decoderCooldownMs,
rotateMs: config.decoderRotateMs,
onData: (pcm) => {
if (!broadcaster.broadcastPcmToWeb) return;
const outBuf = Buffer.alloc(pcm.length / 4);
for (let i = 0; i < outBuf.length / 2; i++) {
outBuf.writeInt16LE(pcm.readInt16LE(i * 8), i * 2);
}
broadcaster.broadcastPcmToWeb(outBuf, userId);
},
});
const audioStream = subscribeToAudioStream(receiver, userId, {
onPacket: (chunk) => {
if (chunk.length < 8) return;
segmentManager.rotateIfNeeded(oggPacketStream);
if (!broadcaster.broadcastPcmToWeb) return;
decoder.rotateIfNeeded();
decoder.write(chunk);
},
onEnd: () => {
const segment = segmentManager.close(oggPacketStream);
decoder.destroy();
if (segment) {
const metadata = createSegmentMetadata(userMetadata, segment, sessionId, sessionStartTime, config.recordingSegmentMs);
fs.writeFileSync(segment.jsonFilename, JSON.stringify(metadata, null, 2));
}
broadcaster.updateActiveUser?.(userId, { username: userMetadata.username, avatar: userMetadata.avatarUrl, speaking: false });
},
onError: (error) => {
segmentManager.close(oggPacketStream);
decoder.destroy();
console.error(`[recorder] Audio Stream error ${userId}:`, error.message);
},
});
```
- [ ] **Step 2: Preserve metadata writes on segment finish**
If existing behavior writes JSON when `out` finishes, attach `out.on("finish", ...)` in `SegmentManager.open()` caller after opening current segment. Ensure every closed segment gets JSON metadata, including rotated segments.
- [ ] **Step 3: Run typecheck**
```bash
bun run typecheck
```
Expected: PASS. Fix type errors by narrowing types, not adding `any` unless third-party library lacks exported type.
- [ ] **Step 4: Run tests**
```bash
bun run test
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/recorder.ts src/recorder/audioStream.ts src/recorder/decoder.ts src/recorder/metadata.ts src/recorder/segment.ts src/types.ts
git commit -m "refactor: modularize recorder orchestration"
```
---
### Task 9: Format and lint cleanup
**Files:**
- Modify: all formatted TypeScript/config files touched by Biome.
- [ ] **Step 1: Run formatter**
```bash
bun run format
```
Expected: files formatted.
- [ ] **Step 2: Run linter**
```bash
bun run lint
```
Expected: PASS or actionable warnings. Fix warnings that are in touched code.
- [ ] **Step 3: Run typecheck and tests**
```bash
bun run typecheck
bun run test
```
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add .
git commit -m "style: format and lint codebase"
```
---
### Task 10: Manual runtime verification
**Files:**
- No required code changes unless verification finds bug.
- [ ] **Step 1: Start app**
Run:
```bash
bun run start
```
Expected: app starts, logs bot ready or fails only due missing env credentials.
- [ ] **Step 2: If env exists, verify recording flow**
Manual steps:
1. Join configured Discord voice channel.
2. Speak for >3 seconds.
3. Confirm `.ogg` file and `.json` metadata are created under `RECORDINGS_DIR`.
4. Keep speaking past `RECORDING_SEGMENT_MS`; confirm segment rotation creates multiple files.
5. Stop app with Ctrl-C; confirm graceful shutdown log.
- [ ] **Step 3: Commit fixes if needed**
```bash
git add src tests package.json biome.json vitest.config.ts tsconfig.json
git commit -m "fix: preserve recorder runtime behavior"
```
Only run if code changed.
---
### Task 11: Final verification
**Files:**
- No changes expected.
- [ ] **Step 1: Run full verification**
```bash
bun run format
bun run lint
bun run typecheck
bun run test
git status --short
```
Expected: formatter stable, lint PASS, typecheck PASS, tests PASS, git status clean or only intentional uncommitted runtime artifacts excluded by `.gitignore`.
- [ ] **Step 2: Review diff summary**
```bash
git log --oneline -8
git diff HEAD~8...HEAD --stat
```
Expected: commits show tooling, config, types, metadata, decoder, segment, stream, recorder refactor, formatting.
- [ ] **Step 3: Report result**
Report:
- Commands run and pass/fail status.
- Runtime verification status.
- Any remaining risks, especially Discord runtime behavior if not manually tested with credentials.
---
## Self-Review
- Spec coverage: tooling, config, shared types, metadata, decoder, segment, audio stream, recorder orchestration, tests, lint/format, and runtime verification are covered.
- Placeholder scan: no TBD/TODO placeholders.
- Type consistency: `UserMetadata`, `SegmentState`, `SegmentMetadata`, `PcmBroadcaster`, `OpusDecoder`, `SegmentManager`, and `subscribeToAudioStream` names are consistent across tasks.
@@ -1,64 +0,0 @@
# Backlog Sync Rich Metadata 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:** Fetch prior Discord messages up to 24 hours on startup, persist rich Discord-client-like metadata, and render rich message content in homepage tabs.
**Architecture:** Add `messageMetadata.ts` for reusable extraction, `backlogSync.ts` for bounded startup history fetch, reuse existing store/uploader. UI reads metadata JSON and renders stickers, embeds, attachments/replies/thread badges.
**Tech Stack:** Bun, TypeScript, discord.js-selfbot-v13, bun:sqlite, Express/WebSocket, vanilla HTML/CSS/JS.
---
### Task 1: Extract rich message metadata
**Files:**
- Create: `src/moderation/messageMetadata.ts`
- Modify: `src/moderation/messageCapture.ts`
- [ ] Create helper functions: `getMessageLocation`, `getStickerMetadata`, `getEmbedMetadata`, `getAttachmentMetadata`, `getMessageMetadata`, `getDisplayContent`.
- [ ] Replace duplicate capture helper logic with imports from `messageMetadata.ts`.
- [ ] Verify: `bun run typecheck`.
### Task 2: Make message inserts idempotent
**Files:**
- Modify: `src/moderation/messageStore.ts`
- [ ] Change message insert to `INSERT OR IGNORE` so backlog sync and live events do not conflict.
- [ ] Change attachment insert to `INSERT OR IGNORE`.
- [ ] Verify: `bun run typecheck && bun run test`.
### Task 3: Add backlog sync
**Files:**
- Create: `src/moderation/backlogSync.ts`
- Modify: `src/index.ts`
- Modify: `src/config.ts`
- Modify: `.env.example`
- [ ] Add config: `BACKLOG_SYNC_HOURS=24`, `BACKLOG_SYNC_BATCH_SIZE=100`.
- [ ] Fetch text/thread channels from monitored guild on ready.
- [ ] For each channel/thread, page `channel.messages.fetch({ limit, before })` until older than cutoff.
- [ ] Store messages with rich metadata and attachments.
- [ ] Start sync after registering live capture; run async and log progress.
- [ ] Verify: `bun run typecheck && bun run test`.
### Task 4: Render richer UI
**Files:**
- Modify: `public/index.html`
- [ ] Render metadata embeds as embed cards.
- [ ] Render attachments as inline previews/links in Text tab.
- [ ] Render reply and thread badges.
- [ ] Keep sticker rendering.
- [ ] Verify static JS syntax by typecheck/tests where applicable.
### Task 5: Final verification
**Files:** all touched files
- [ ] Run `bun run typecheck`.
- [ ] Run `bun run test`.
- [ ] Verify short DB init with `bun -e 'import("./src/muxer-queue.ts").then((m)=>{const db=m.getDatabase(); db.close(); console.log("sqlite ok")})'`.
@@ -1,149 +0,0 @@
# One-Port WebSocket 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:** Serve Express HTTP endpoints, static frontend, and WebSocket traffic on one `WEBSERVER_PORT` using WebSocket path `/ws`.
**Architecture:** `src/webserver.ts` should create one `http.Server` from the Express app, attach `WebSocketServer` to that same server with `path: "/ws"`, and remove `port + 1`. `public/index.html` should connect to `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws` so dev, production, and reverse proxy setups use the same host and port.
**Tech Stack:** TypeScript, Express, Node HTTP server, `ws`, Bun scripts, Biome, TypeScript compiler.
---
## File Structure
- Modify `src/webserver.ts`: change WebSocket server construction and logs from separate port to shared HTTP server path `/ws`.
- Modify `public/index.html`: change browser WebSocket URL from hardcoded `:3001` to same-origin `/ws`.
- No new files required.
---
### Task 1: Attach WebSocket to Existing HTTP Server
**Files:**
- Modify: `src/webserver.ts`
- [ ] **Step 1: Update WebSocket server creation**
Replace this code in `src/webserver.ts`:
```ts
const wsPort = port + 1;
const wss = new WebSocketServer({ port: wsPort, host: "0.0.0.0" });
wsLogger.info({ wsPort }, "WebSocket server listening");
```
With:
```ts
const wsPath = "/ws";
const wss = new WebSocketServer({ server, path: wsPath });
wsLogger.info({ port, wsPath }, "WebSocket server listening");
```
- [ ] **Step 2: Update connection log**
Replace this code in `src/webserver.ts`:
```ts
wsLogger.info({ wsPort }, "New WebSocket connection");
```
With:
```ts
wsLogger.info({ port, wsPath }, "New WebSocket connection");
```
- [ ] **Step 3: Run typecheck**
Run:
```bash
bun run typecheck
```
Expected: command exits `0`.
---
### Task 2: Update Browser WebSocket URL
**Files:**
- Modify: `public/index.html`
- [ ] **Step 1: Replace hardcoded WebSocket port**
Replace this code in `public/index.html`:
```js
socket = new WebSocket(`ws://${window.location.hostname}:3001`);
```
With:
```js
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
socket = new WebSocket(`${wsProtocol}//${window.location.host}/ws`);
```
- [ ] **Step 2: Run lint and build**
Run:
```bash
bun run lint && bun run build
```
Expected: both commands exit `0`.
---
### Task 3: Verify One-Port Behavior
**Files:**
- Verify: `src/webserver.ts`
- Verify: `public/index.html`
- [ ] **Step 1: Start dev server**
Run:
```bash
bun run dev
```
Expected logs include Express web interface on configured port and WebSocket server listening with `{ port: 3000, wsPath: "/ws" }`.
- [ ] **Step 2: Browser smoke test**
Open:
```text
http://localhost:3000
```
Expected: page loads and browser WebSocket connects to:
```text
ws://localhost:3000/ws
```
- [ ] **Step 3: Endpoint smoke test**
Run:
```bash
curl http://localhost:3000/health
curl http://localhost:3000/metrics
```
Expected: `/health` returns JSON and `/metrics` returns Prometheus text.
---
## Self-Review
- Spec coverage: Covers one-port HTTP/WebSocket server, `/ws` path, frontend URL update, and verification.
- Placeholder scan: No TBD/TODO placeholders.
- Type consistency: Uses `wsPath` in both server creation and logs; frontend connects to `/ws`.
@@ -1,60 +0,0 @@
# React SSR Dashboard Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace static client-rendered homepage with React server-side rendering while keeping live WebSocket/voice behavior as progressive enhancement.
**Architecture:** Express `GET /` builds dashboard data, renders React component to HTML with `react-dom/server`, injects bootstrap JSON for client script. CSS/JS move to static assets; React owns initial markup only, lightweight browser JS handles tab switching, voice bridge, WebSocket updates, and async thread discovery.
**Tech Stack:** React, ReactDOM server, Bun, Express, TypeScript, vanilla browser JS for progressive enhancement.
---
### Task 1: Add React dependencies
**Files:**
- Modify: `package.json`
- Modify: `bun.lockb`
- [ ] Run `bun add react react-dom`.
- [ ] Run `bun add -d @types/react @types/react-dom`.
- [ ] Verify `bun run typecheck`.
### Task 2: Extract dashboard assets
**Files:**
- Create: `public/dashboard.css`
- Create: `public/dashboard.js`
- Modify: `public/index.html`
- [ ] Move current `<style>` content to `dashboard.css`.
- [ ] Move current `<script>` content to `dashboard.js`.
- [ ] Keep client behavior independent from static HTML.
### Task 3: Create React SSR renderer
**Files:**
- Create: `src/web/dashboardPage.tsx`
- [ ] Create `DashboardPage` React component accepting: guilds, voiceChannels, watchChannels, selectedChannel, messages, status.
- [ ] Render same Voice/Text layout as current homepage.
- [ ] Render message cards server-side from DB metadata.
- [ ] Export `renderDashboardPage(props)` returning full HTML with CSS/JS links and bootstrap JSON.
### Task 4: Wire Express SSR route
**Files:**
- Modify: `src/webserver.ts`
- [ ] Add `GET /` before static middleware fallback or before static index handling.
- [ ] Build props from `voiceController` and `getMessagesByChannel`.
- [ ] Respect query `?guild=<id>&channel=<id>`.
- [ ] Render HTML with `renderDashboardPage`.
### Task 5: Verify
**Files:** all touched files
- [ ] Run `bun run typecheck`.
- [ ] Run `bun run test`.
- [ ] Run short SSR import smoke if possible.
@@ -1,38 +0,0 @@
# Separate Thread Discovery 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:** Keep channel listing fast and move expensive active/archived thread discovery to a separate endpoint loaded asynchronously by the homepage.
**Architecture:** `VoiceController` exposes cache-only channels and network-backed threads separately. `webserver.ts` adds `/api/guilds/:guildId/threads`. `public/index.html` loads channels first, then appends thread options after thread endpoint returns.
**Tech Stack:** TypeScript, discord.js-selfbot-v13, Express, vanilla JS.
---
### Task 1: Add thread discovery method
**Files:**
- Modify: `src/voiceController.ts`
- [ ] Add `listThreads(guildId)` that fetches active and archived threads per parent text channel.
- [ ] Keep `listWatchableChannels` cache-only.
- [ ] Verify typecheck.
### Task 2: Add thread API endpoint
**Files:**
- Modify: `src/webserver.ts`
- [ ] Add `GET /api/guilds/:guildId/threads`.
- [ ] Return thread summaries.
- [ ] Verify typecheck.
### Task 3: Update homepage dropdown loading
**Files:**
- Modify: `public/index.html`
- [ ] `loadChannels` fetches `/channels` first and renders immediately.
- [ ] Then fetches `/threads` async and appends thread options.
- [ ] Verify typecheck/tests.
@@ -1,440 +0,0 @@
# Web Interactive Voice Connect Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace startup auto-connect with web UI guild/channel selection and make voice connection cleanup/reconnect more stable.
**Architecture:** Add a `VoiceController` module that owns active voice state, connect/disconnect, guild/channel listing, and player binding. `src/index.ts` only logs in and starts webserver after Discord ready. `src/webserver.ts` exposes JSON APIs used by dropdown controls in `public/index.html`. UI rendering uses DOM methods, not raw HTML injection.
**Tech Stack:** TypeScript, discord.js-selfbot-v13, @discordjs/voice, Express, WebSocket, plain browser JavaScript, Bun, Vitest, Biome.
---
## File Structure
- Create `src/voiceController.ts`: active connection state, guild/channel listing, connect/disconnect.
- Modify `src/config.ts`: make `GUILD_ID` and `VOICE_CHANNEL_ID` optional.
- Modify `src/index.ts`: remove auto-connect; start webserver with Discord client and voice controller.
- Modify `src/recorder.ts`: return voice connection from `startRecording`; use configured silence duration; keep bounded reconnect behavior.
- Modify `src/webserver.ts`: add API routes and JSON error handling.
- Modify `public/index.html`: add connection panel and dropdown behavior.
- Modify `tests/config.test.ts`: assert optional guild/channel config.
---
### Task 1: Config Optional Guild/Channel
**Files:**
- Modify: `src/config.ts`
- Modify: `tests/config.test.ts`
- [ ] **Step 1: Update config test**
Add these assertions after `expect(config.NODE_ENV).toBe("test");` in `tests/config.test.ts`:
```ts
expect(config.GUILD_ID).toBeUndefined();
expect(config.VOICE_CHANNEL_ID).toBeUndefined();
```
- [ ] **Step 2: Verify RED**
Run:
```bash
bun run test tests/config.test.ts
```
Expected: FAIL because `GUILD_ID` and `VOICE_CHANNEL_ID` are still required.
- [ ] **Step 3: Make config optional**
In `src/config.ts`, replace:
```ts
VOICE_CHANNEL_ID: z.string().min(1, "VOICE_CHANNEL_ID is required"),
GUILD_ID: z.string().min(1, "GUILD_ID is required"),
```
With:
```ts
VOICE_CHANNEL_ID: z.string().min(1).optional(),
GUILD_ID: z.string().min(1).optional(),
```
- [ ] **Step 4: Verify GREEN**
Run:
```bash
bun run test tests/config.test.ts
```
Expected: PASS.
---
### Task 2: Voice Controller Module
**Files:**
- Create: `src/voiceController.ts`
- Modify: `src/recorder.ts`
- [ ] **Step 1: Create `src/voiceController.ts`**
```ts
import { getVoiceConnection, type VoiceConnection } from "@discordjs/voice";
import type { Client, Guild, VoiceChannel } from "discord.js-selfbot-v13";
import { AppError } from "./errors";
import { createChildLogger } from "./logger";
import { discordPlayer } from "./player";
import { startRecording, stopRecording } from "./recorder";
const logger = createChildLogger("voice-controller");
export interface VoiceStatus {
ready: boolean;
connected: boolean;
activeGuildId: string | null;
activeChannelId: string | null;
activeChannelName: string | null;
}
export interface GuildSummary {
id: string;
name: string;
}
export interface VoiceChannelSummary {
id: string;
name: string;
}
export class VoiceController {
private activeGuildId: string | null = null;
private activeChannelId: string | null = null;
private activeChannelName: string | null = null;
private connecting = false;
constructor(private readonly client: Client) {}
getStatus(): VoiceStatus {
const connection = this.activeGuildId
? getVoiceConnection(this.activeGuildId)
: undefined;
return {
ready: this.client.isReady(),
connected: Boolean(connection),
activeGuildId: this.activeGuildId,
activeChannelId: this.activeChannelId,
activeChannelName: this.activeChannelName,
};
}
listGuilds(): GuildSummary[] {
return this.client.guilds.cache
.map((guild) => ({ id: guild.id, name: guild.name }))
.sort((a, b) => a.name.localeCompare(b.name));
}
async listVoiceChannels(guildId: string): Promise<VoiceChannelSummary[]> {
const guild = this.getGuild(guildId);
await guild.channels.fetch().catch(() => null);
return guild.channels.cache
.filter((channel) => channel.type === "GUILD_VOICE")
.map((channel) => ({ id: channel.id, name: channel.name }))
.sort((a, b) => a.name.localeCompare(b.name));
}
async connect(guildId: string, channelId: string): Promise<VoiceStatus> {
if (!this.client.isReady()) {
throw new AppError("Discord client is not ready", "CLIENT_NOT_READY", 409);
}
if (this.connecting) {
throw new AppError("Voice connection is already in progress", "CONNECT_IN_PROGRESS", 409);
}
this.connecting = true;
try {
await this.disconnect();
const guild = this.getGuild(guildId);
const channel =
guild.channels.cache.get(channelId) ??
(await guild.channels.fetch(channelId).catch(() => null));
if (!channel) {
throw new AppError("Voice channel not found", "VOICE_CHANNEL_NOT_FOUND", 404);
}
if (channel.type !== "GUILD_VOICE") {
throw new AppError("Selected channel is not a voice channel", "INVALID_CHANNEL_TYPE", 400);
}
const connection = await startRecording(this.client, channel as VoiceChannel);
if (!connection) {
throw new AppError("Failed to connect to voice channel", "VOICE_CONNECT_FAILED", 500);
}
discordPlayer.setConnection(connection as VoiceConnection);
this.activeGuildId = guildId;
this.activeChannelId = channelId;
this.activeChannelName = channel.name;
logger.info({ guildId, channelId, channelName: channel.name }, "Voice connected");
return this.getStatus();
} finally {
this.connecting = false;
}
}
async disconnect(): Promise<VoiceStatus> {
if (this.activeGuildId) {
stopRecording(this.activeGuildId);
}
discordPlayer.pause();
this.activeGuildId = null;
this.activeChannelId = null;
this.activeChannelName = null;
return this.getStatus();
}
private getGuild(guildId: string): Guild {
const guild = this.client.guilds.cache.get(guildId);
if (!guild) {
throw new AppError("Guild not found", "GUILD_NOT_FOUND", 404);
}
return guild;
}
}
```
- [ ] **Step 2: Update recorder return type**
In `src/recorder.ts`, import `type VoiceConnection` from `@discordjs/voice`, change `startRecording` return type to `Promise<VoiceConnection | null>`, return `null` on connect failure, and return `connection` as final line of the function.
- [ ] **Step 3: Use configured silence duration**
In `src/recorder.ts`, replace hardcoded `duration: 3000` in `receiver.subscribe` with:
```ts
duration: config.AUDIO_STREAM_SILENCE_DURATION_MS,
```
- [ ] **Step 4: Run typecheck**
Run:
```bash
bun run typecheck
```
Expected: PASS after later call sites updated.
---
### Task 3: Startup Without Auto-Join
**Files:**
- Modify: `src/index.ts`
- [ ] **Step 1: Refactor startup**
Update `src/index.ts` so it:
```ts
import { VoiceController } from "./voiceController";
const client = new Client();
const voiceController = new VoiceController(client);
```
Remove `voiceChannelId`, `guildId`, auto guild fetch, auto channel fetch, `startRecording`, and `getVoiceConnection` setup from `client.on("ready")`.
Set ready handler to:
```ts
client.on("ready", async () => {
logger.info({ user: client.user?.tag }, "Bot logged in");
startWebserver(config.WEBSERVER_PORT, client, voiceController);
});
```
- [ ] **Step 2: Refactor shutdown**
In `gracefulShutdown`, replace guild-specific stop/destroy logic with:
```ts
logger.info("Stopping voice connection...");
await voiceController.disconnect();
```
Keep player pause and client destroy.
---
### Task 4: Webserver Voice APIs
**Files:**
- Modify: `src/webserver.ts`
- [ ] **Step 1: Update signature and imports**
Add imports:
```ts
import type { Client } from "discord.js-selfbot-v13";
import { AppError } from "./errors";
import type { VoiceController } from "./voiceController";
```
Change function signature:
```ts
export function startWebserver(
port: number = 3000,
_client: Client,
voiceController: VoiceController,
) {
```
- [ ] **Step 2: Enable JSON**
Add after pino HTTP middleware:
```ts
app.use(express.json());
```
- [ ] **Step 3: Add API routes**
Add after `/metrics`:
```ts
app.get("/api/status", (_req, res) => {
res.json(voiceController.getStatus());
});
app.get("/api/guilds", (_req, res) => {
res.json(voiceController.listGuilds());
});
app.get("/api/guilds/:guildId/voice-channels", async (req, res, next) => {
try {
res.json(await voiceController.listVoiceChannels(req.params.guildId));
} catch (error) {
next(error);
}
});
app.post("/api/connect", async (req, res, next) => {
try {
const { guildId, channelId } = req.body as { guildId?: string; channelId?: string };
if (!guildId || !channelId) {
throw new AppError("guildId and channelId are required", "MISSING_CONNECT_FIELDS", 400);
}
res.json(await voiceController.connect(guildId, channelId));
} catch (error) {
next(error);
}
});
app.post("/api/disconnect", async (_req, res, next) => {
try {
res.json(await voiceController.disconnect());
} catch (error) {
next(error);
}
});
```
- [ ] **Step 4: Add API error handler before `server.listen`**
```ts
app.use(
(
error: Error,
_req: express.Request,
res: express.Response,
_next: express.NextFunction,
) => {
if (error instanceof AppError) {
res.status(error.statusCode).json({ error: error.code, message: error.message });
return;
}
wsLogger.error({ error }, "Unhandled webserver error");
res.status(500).json({ error: "INTERNAL_SERVER_ERROR", message: "Internal server error" });
},
);
```
---
### Task 5: Frontend Dropdown UI
**Files:**
- Modify: `public/index.html`
- [ ] **Step 1: Add connection panel markup**
Add a panel above transmit/listen controls with selects `guildSelect`, `channelSelect`, buttons `joinVoiceBtn`, `disconnectVoiceBtn`, and text `voiceStatusText`.
- [ ] **Step 2: Add DOM-safe JS**
Add helpers that use `document.createElement`, `textContent`, and `appendChild` for dropdown options. Do not use raw `innerHTML` with guild/channel names.
Use this safe select renderer:
```js
function renderSelect(select, items, placeholder) {
select.replaceChildren();
const placeholderOption = document.createElement('option');
placeholderOption.value = '';
placeholderOption.textContent = placeholder;
select.appendChild(placeholderOption);
for (const item of items) {
const option = document.createElement('option');
option.value = item.id;
option.textContent = item.name;
select.appendChild(option);
}
}
```
- [ ] **Step 3: Wire API calls**
Use `/api/guilds`, `/api/guilds/:guildId/voice-channels`, `/api/connect`, `/api/disconnect`, and `/api/status` to populate and update UI.
---
### Task 6: Verification
**Files:**
- Verify all modified files.
- [ ] **Step 1: Automated verification**
Run:
```bash
bun run test && bun run typecheck && bun run lint && bun run build
```
Expected: PASS.
- [ ] **Step 2: Manual smoke test**
Run:
```bash
bun run dev
```
Open `http://localhost:3000`. Confirm guild dropdown loads, channels load after guild selection, Join connects, Disconnect leaves, and mic/listen still work after joining.
---
## Self-Review
- Spec coverage: Covers optional config, no startup auto-connect, dropdown guild/channel UI, API endpoints, connect/disconnect, and safer cleanup.
- Placeholder scan: No TBD/TODO placeholders.
- Type consistency: `VoiceController` method names match API routes and frontend calls.
- Security: Dropdown rendering uses DOM methods instead of raw HTML for remote guild/channel names.
@@ -1,163 +0,0 @@
# Web Mic Noise Suppression 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:** Reduce background noise from the browser microphone before audio is sent to Discord.
**Architecture:** Use native browser audio constraints for echo cancellation, noise suppression, and auto gain control at `getUserMedia` capture time. Add a lightweight RMS noise gate inside the existing `onaudioprocess` transmit loop so quiet background noise becomes silence before PCM is sent over WebSocket.
**Tech Stack:** Browser MediaDevices API, Web Audio API, plain JavaScript in `public/index.html`, existing Bun/TypeScript verification scripts.
---
## File Structure
- Modify `public/index.html`: update mic capture constraints and add local RMS noise gate constants/helpers inside the existing script.
- No new dependencies.
- No server changes required.
---
### Task 1: Enable Browser-Level Audio Processing
**Files:**
- Modify: `public/index.html`
- [ ] **Step 1: Update microphone constraints**
Replace:
```js
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
```
With:
```js
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
channelCount: 1,
sampleRate: SAMPLE_RATE,
},
});
```
- [ ] **Step 2: Run lint**
Run:
```bash
bun run lint
```
Expected: exits `0`.
---
### Task 2: Add Lightweight RMS Noise Gate
**Files:**
- Modify: `public/index.html`
- [ ] **Step 1: Add threshold constants near audio constants**
Add after:
```js
const CHANNELS = 1;
```
This code:
```js
const NOISE_GATE_THRESHOLD = 0.01;
const NOISE_GATE_HOLD_FRAMES = 3;
let noiseGateHold = 0;
```
- [ ] **Step 2: Add RMS helper function before `startStreaming()`**
Add before:
```js
async function startStreaming() {
```
This function:
```js
function calculateRms(samples) {
let sum = 0;
for (let i = 0; i < samples.length; i++) {
sum += samples[i] * samples[i];
}
return Math.sqrt(sum / samples.length);
}
```
- [ ] **Step 3: Apply gate before PCM conversion**
Replace:
```js
const inputData = e.inputBuffer.getChannelData(0);
const pcmData = new Int16Array(inputData.length);
for (let i = 0; i < inputData.length; i++) {
pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767;
}
socket.send(pcmData.buffer);
```
With:
```js
const inputData = e.inputBuffer.getChannelData(0);
const rms = calculateRms(inputData);
if (rms >= NOISE_GATE_THRESHOLD) {
noiseGateHold = NOISE_GATE_HOLD_FRAMES;
} else if (noiseGateHold > 0) {
noiseGateHold--;
}
const pcmData = new Int16Array(inputData.length);
for (let i = 0; i < inputData.length; i++) {
const sample = noiseGateHold > 0 ? inputData[i] : 0;
pcmData[i] = Math.max(-1, Math.min(1, sample)) * 32767;
}
socket.send(pcmData.buffer);
```
- [ ] **Step 4: Reset gate on stop**
Add inside `stopStreaming()` after:
```js
isStreaming = false;
```
This line:
```js
noiseGateHold = 0;
```
- [ ] **Step 5: Run verification**
Run:
```bash
bun run test && bun run typecheck && bun run lint && bun run build
```
Expected: all commands exit `0`.
---
## Self-Review
- Spec coverage: Browser native noise suppression and JS noise gate are both covered.
- Placeholder scan: No placeholders or TODOs.
- Type consistency: Uses existing `SAMPLE_RATE`, `CHANNELS`, and `onaudioprocess` pipeline.
File diff suppressed because it is too large Load Diff
@@ -1,704 +0,0 @@
# Drizzle ORM Migration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace raw SQL queries and manual database adapter with Drizzle ORM, providing type-safe database operations, automatic migrations, and better maintainability while supporting both SQLite and PostgreSQL.
**Architecture:** Replace the custom DatabaseAdapter pattern with Drizzle ORM's unified API. Define schema using Drizzle's TypeScript schema definitions. Replace all raw SQL queries in muxer-queue.ts and messageStore.ts with Drizzle query builder. Use Drizzle migrations for schema management. Maintain backward compatibility with existing data.
**Tech Stack:** drizzle-orm, drizzle-kit, better-sqlite3 (SQLite), postgres (PostgreSQL), TypeScript
---
## File Structure
**New files to create:**
- `src/database/schema.ts` — Drizzle schema definitions for all tables
- `src/database/drizzle.ts` — Drizzle database client initialization
- `drizzle.config.ts` — Drizzle Kit configuration
- `drizzle/migrations/` — Auto-generated migration files
**Modified files:**
- `src/muxer-queue.ts` — Replace raw SQL with Drizzle queries
- `src/moderation/messageStore.ts` — Replace raw SQL with Drizzle queries
- `src/database/adapter.ts` — Remove (no longer needed)
- `src/database/postgres.ts` — Remove (Drizzle handles this)
- `src/database/migrations.ts` — Remove (Drizzle handles this)
- `src/index.ts` — Update database initialization
- `src/webserver.ts` — Update database calls
- `package.json` — Add drizzle-orm, drizzle-kit dependencies
- `src/config.ts` — Keep PostgreSQL config variables
---
## Task 1: Add Drizzle Dependencies
**Files:**
- Modify: `package.json`
- [ ] **Step 1: Add drizzle-orm and drizzle-kit**
```bash
cd /mnt/code/bete && pnpm add drizzle-orm
```
Expected: drizzle-orm installed
- [ ] **Step 2: Add drizzle-kit as dev dependency**
```bash
cd /mnt/code/bete && pnpm add -D drizzle-kit
```
Expected: drizzle-kit installed
- [ ] **Step 3: Verify installation**
```bash
cd /mnt/code/bete && pnpm list drizzle-orm drizzle-kit
```
Expected: Both packages listed with versions
- [ ] **Step 4: Commit**
```bash
git add package.json pnpm-lock.yaml
git commit -m "feat: add drizzle-orm and drizzle-kit dependencies"
```
---
## Task 2: Create Drizzle Schema Definitions
**Files:**
- Create: `src/database/schema.ts`
- [ ] **Step 1: Create schema.ts with table definitions**
```typescript
import { pgTable, text, integer, bigint, real, index, foreignKey } from "drizzle-orm/pg-core";
import { sqliteTable, SQLiteInteger, SQLiteText } from "drizzle-orm/sqlite-core";
import { config } from "../config";
// Determine which table function to use based on database type
const tableFactory = config.DATABASE_TYPE === "postgres" ? pgTable : sqliteTable;
// Muxer Jobs Table
export const muxerJobs = tableFactory("muxer_jobs", {
id: text("id").primaryKey(),
data: text("data").notNull(),
status: text("status", { enum: ["pending", "processing", "completed", "failed"] }).notNull().default("pending"),
attempts: integer("attempts").notNull().default(0),
maxAttempts: integer("maxAttempts").notNull().default(3),
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull(),
error: text("error"),
}, (table) => ({
statusIdx: index("idx_muxer_jobs_status").on(table.status),
createdAtIdx: index("idx_muxer_jobs_createdAt").on(table.createdAt),
}));
// Messages Table
export const messages = tableFactory("messages", {
id: text("id").primaryKey(),
guild_id: text("guild_id").notNull(),
channel_id: text("channel_id").notNull(),
thread_id: text("thread_id"),
user_id: text("user_id").notNull(),
username: text("username").notNull(),
avatar_url: text("avatar_url"),
content: text("content").notNull(),
edited_content: text("edited_content"),
created_at: bigint("created_at", { mode: "number" }).notNull(),
edited_at: bigint("edited_at", { mode: "number" }),
deleted_at: bigint("deleted_at", { mode: "number" }),
type: text("type", { enum: ["text", "edited", "deleted"] }).notNull().default("text"),
metadata: text("metadata"),
ai_status: text("ai_status", { enum: ["pending", "clean", "warn", "flagged", "error"] }).notNull().default("pending"),
ai_moderation_flags: text("ai_moderation_flags"),
ai_moderation_score: real("ai_moderation_score"),
ai_moderation_raw: text("ai_moderation_raw"),
ai_analysis: text("ai_analysis"),
ai_analyzed_at: bigint("ai_analyzed_at", { mode: "number" }),
ai_error: text("ai_error"),
}, (table) => ({
channelIdx: index("idx_messages_channel").on(table.channel_id),
userIdx: index("idx_messages_user").on(table.user_id),
createdIdx: index("idx_messages_created").on(table.created_at),
threadIdx: index("idx_messages_thread").on(table.thread_id),
}));
// Attachments Table
export const attachments = tableFactory("attachments", {
id: text("id").primaryKey(),
message_id: text("message_id").notNull(),
guild_id: text("guild_id").notNull(),
channel_id: text("channel_id").notNull(),
thread_id: text("thread_id"),
user_id: text("user_id").notNull(),
filename: text("filename").notNull(),
size: integer("size").notNull(),
type: text("type").notNull(),
discord_url: text("discord_url").notNull(),
uploaded_url: text("uploaded_url"),
upload_status: text("upload_status", { enum: ["pending", "uploaded", "failed"] }).notNull().default("pending"),
upload_error: text("upload_error"),
created_at: bigint("created_at", { mode: "number" }).notNull(),
uploaded_at: bigint("uploaded_at", { mode: "number" }),
}, (table) => ({
channelIdx: index("idx_attachments_channel").on(table.channel_id),
messageIdx: index("idx_attachments_message").on(table.message_id),
statusIdx: index("idx_attachments_status").on(table.upload_status),
fk: foreignKey({
columns: [table.message_id],
foreignColumns: [messages.id],
}).onDelete("cascade"),
}));
// UI State Table
export const uiState = tableFactory("ui_state", {
key: text("key").primaryKey(),
value: text("value").notNull(),
updated_at: bigint("updated_at", { mode: "number" }).notNull(),
});
```
- [ ] **Step 2: Run typecheck**
```bash
cd /mnt/code/bete && pnpm run typecheck
```
Expected: No TypeScript errors
- [ ] **Step 3: Commit**
```bash
git add src/database/schema.ts
git commit -m "feat: create drizzle schema definitions"
```
---
## Task 3: Create Drizzle Configuration
**Files:**
- Create: `drizzle.config.ts`
- [ ] **Step 1: Create drizzle.config.ts**
```typescript
import { defineConfig } from "drizzle-kit";
import { config } from "./src/config";
export default defineConfig({
schema: "./src/database/schema.ts",
out: "./drizzle/migrations",
dialect: config.DATABASE_TYPE === "postgres" ? "postgresql" : "sqlite",
dbCredentials: config.DATABASE_TYPE === "postgres"
? {
host: config.POSTGRES_HOST,
port: config.POSTGRES_PORT,
user: config.POSTGRES_USER,
password: config.POSTGRES_PASSWORD,
database: config.POSTGRES_DB,
}
: {
url: `file:./.muxer-queue.db`,
},
});
```
- [ ] **Step 2: Add migration scripts to package.json**
```json
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
}
```
- [ ] **Step 3: Generate initial migration**
```bash
cd /mnt/code/bete && pnpm run db:generate
```
Expected: Migration files created in drizzle/migrations/
- [ ] **Step 4: Commit**
```bash
git add drizzle.config.ts package.json drizzle/
git commit -m "feat: add drizzle configuration and initial migrations"
```
---
## Task 4: Create Drizzle Database Client
**Files:**
- Create: `src/database/drizzle.ts`
- [ ] **Step 1: Create drizzle.ts**
```typescript
import { drizzle } from "drizzle-orm/node-postgres";
import { drizzle as drizzleSqlite } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";
import { Pool } from "pg";
import { config } from "../config";
import { createChildLogger } from "../logger";
import * as schema from "./schema";
const logger = createChildLogger("drizzle");
let db: ReturnType<typeof drizzle> | null = null;
export async function initializeDatabase() {
if (db) return db;
if (config.DATABASE_TYPE === "postgres") {
const pool = new Pool({
host: config.POSTGRES_HOST,
port: config.POSTGRES_PORT,
user: config.POSTGRES_USER,
password: config.POSTGRES_PASSWORD,
database: config.POSTGRES_DB,
min: config.POSTGRES_POOL_MIN,
max: config.POSTGRES_POOL_MAX,
});
db = drizzle(pool, { schema });
logger.info("PostgreSQL database initialized");
} else {
const sqlite = new Database(".muxer-queue.db");
sqlite.pragma("journal_mode = WAL");
db = drizzleSqlite(sqlite, { schema });
logger.info("SQLite database initialized");
}
return db;
}
export function getDatabase() {
if (!db) {
throw new Error("Database not initialized. Call initializeDatabase() first.");
}
return db;
}
export async function closeDatabase() {
if (db) {
// Drizzle doesn't have a close method, but we can close the underlying connection
if (config.DATABASE_TYPE === "postgres") {
// Pool will be closed when the process exits
logger.info("PostgreSQL connection pool will close on process exit");
} else {
logger.info("SQLite database closed");
}
db = null;
}
}
```
- [ ] **Step 2: Run typecheck**
```bash
cd /mnt/code/bete && pnpm run typecheck
```
Expected: No TypeScript errors
- [ ] **Step 3: Commit**
```bash
git add src/database/drizzle.ts
git commit -m "feat: create drizzle database client"
```
---
## Task 5: Migrate muxer-queue.ts to Drizzle
**Files:**
- Modify: `src/muxer-queue.ts`
- [ ] **Step 1: Replace imports**
Replace:
```typescript
import { getDatabase, DatabaseAdapter } from "./database/adapter";
```
With:
```typescript
import { getDatabase, initializeDatabase } from "./database/drizzle";
import { muxerJobs } from "./database/schema";
import { eq, asc, desc } from "drizzle-orm";
```
- [ ] **Step 2: Replace enqueueMuxerJob function**
Replace raw SQL with:
```typescript
export async function enqueueMuxerJob(data: MuxerJobData): Promise<string> {
try {
const db = getDatabase();
const jobId = `${data.userId}-${data.sessionId}`;
const now = Date.now();
await db.insert(muxerJobs).values({
id: jobId,
data: JSON.stringify(data),
status: "pending",
attempts: 0,
maxAttempts: 3,
createdAt: now,
updatedAt: now,
}).onConflictDoNothing();
logger.info({ jobId, userId: data.userId }, "Muxer job enqueued");
return jobId;
} catch (error) {
logger.error({ error: error instanceof Error ? error.message : String(error) }, "Failed to enqueue muxer job");
throw error;
}
}
```
- [ ] **Step 3: Replace getPendingJobs function**
```typescript
export async function getPendingJobs(): Promise<StoredJob[]> {
const db = getDatabase();
const rows = await db
.select()
.from(muxerJobs)
.where(eq(muxerJobs.status, "pending"))
.orderBy(asc(muxerJobs.createdAt))
.limit(10);
return rows.map((row) => ({
...row,
status: row.status as "pending" | "processing" | "completed" | "failed",
}));
}
```
- [ ] **Step 4: Replace updateJobStatus function**
```typescript
export async function updateJobStatus(
jobId: string,
status: "processing" | "completed" | "failed",
error?: string,
): Promise<void> {
const db = getDatabase();
const now = Date.now();
if (status === "failed") {
await db
.update(muxerJobs)
.set({
status,
attempts: muxerJobs.attempts + 1,
updatedAt: now,
error: error || null,
})
.where(eq(muxerJobs.id, jobId));
} else {
await db
.update(muxerJobs)
.set({ status, updatedAt: now })
.where(eq(muxerJobs.id, jobId));
}
logger.info({ jobId, status, error }, "Job status updated");
}
```
- [ ] **Step 5: Replace remaining functions similarly**
Replace `retryFailedJob`, `cleanupCompletedJobs`, `getJobStats` with Drizzle equivalents
- [ ] **Step 6: Update getPersistedValue and setPersistedValue**
Use Drizzle's uiState table instead of raw SQL
- [ ] **Step 7: Run tests**
```bash
cd /mnt/code/bete && pnpm run test
```
Expected: All tests pass
- [ ] **Step 8: Commit**
```bash
git add src/muxer-queue.ts
git commit -m "refactor: migrate muxer-queue to drizzle-orm"
```
---
## Task 6: Migrate messageStore.ts to Drizzle
**Files:**
- Modify: `src/moderation/messageStore.ts`
- [ ] **Step 1: Replace imports**
```typescript
import { getDatabase } from "../database/drizzle";
import { messages, attachments } from "../database/schema";
import { eq, or, desc, and } from "drizzle-orm";
```
- [ ] **Step 2: Replace insertMessage function**
```typescript
export async function insertMessage(message: MessageRecord): Promise<void> {
try {
const db = getDatabase();
await db.insert(messages).values(message).onConflictDoNothing();
logger.debug({ messageId: message.id }, "Message inserted");
} catch (error) {
logger.error({ messageId: message.id, error: error instanceof Error ? error.message : String(error) }, "Failed to insert message");
throw error;
}
}
```
- [ ] **Step 3: Replace updateMessageAsEdited function**
```typescript
export async function updateMessageAsEdited(
messageId: string,
editedContent: string,
editedAt: number,
): Promise<void> {
try {
const db = getDatabase();
await db
.update(messages)
.set({ edited_content: editedContent, edited_at: editedAt, type: "edited" })
.where(eq(messages.id, messageId));
logger.debug({ messageId }, "Message marked as edited");
} catch (error) {
logger.error({ messageId, error: error instanceof Error ? error.message : String(error) }, "Failed to update message as edited");
throw error;
}
}
```
- [ ] **Step 4: Replace getMessagesByChannel function**
```typescript
export async function getMessagesByChannel(
channelId: string,
limit: number = 50,
offset: number = 0,
): Promise<MessageRecord[]> {
try {
const db = getDatabase();
return await db
.select()
.from(messages)
.where(or(eq(messages.channel_id, channelId), eq(messages.thread_id, channelId)))
.orderBy(desc(messages.created_at))
.limit(limit)
.offset(offset);
} catch (error) {
logger.error({ channelId, error: error instanceof Error ? error.message : String(error) }, "Failed to get messages by channel");
throw error;
}
}
```
- [ ] **Step 5: Replace attachment functions similarly**
Replace `insertAttachment`, `getAttachmentsByChannel`, `updateAttachmentAsUploaded`, `updateAttachmentAsFailedUpload` with Drizzle equivalents
- [ ] **Step 6: Replace AI analysis functions**
Replace `updateMessageAIAnalysis`, `getPendingAIAnalysisMessages`, `getMessageById` with Drizzle equivalents
- [ ] **Step 7: Update function signatures**
Remove `db: DatabaseAdapter` parameter from all functions since they now use `getDatabase()` internally
- [ ] **Step 8: Run tests**
```bash
cd /mnt/code/bete && pnpm run test
```
Expected: All tests pass
- [ ] **Step 9: Commit**
```bash
git add src/moderation/messageStore.ts
git commit -m "refactor: migrate messageStore to drizzle-orm"
```
---
## Task 7: Update Application Initialization
**Files:**
- Modify: `src/index.ts`
- Modify: `src/webserver.ts`
- [ ] **Step 1: Update src/index.ts imports**
Replace:
```typescript
import { getDatabase } from "./database/adapter";
```
With:
```typescript
import { initializeDatabase } from "./database/drizzle";
```
- [ ] **Step 2: Update database initialization in index.ts**
```typescript
const db = await initializeDatabase();
logger.info({ type: config.DATABASE_TYPE }, "Database initialized");
```
- [ ] **Step 3: Update src/webserver.ts**
Replace any `getDatabase()` calls with the new Drizzle client
- [ ] **Step 4: Run typecheck**
```bash
cd /mnt/code/bete && pnpm run typecheck
```
Expected: No TypeScript errors
- [ ] **Step 5: Commit**
```bash
git add src/index.ts src/webserver.ts
git commit -m "feat: update application initialization for drizzle"
```
---
## Task 8: Remove Old Database Files
**Files:**
- Delete: `src/database/adapter.ts`
- Delete: `src/database/postgres.ts`
- Delete: `src/database/migrations.ts`
- [ ] **Step 1: Remove old adapter files**
```bash
cd /mnt/code/bete && rm src/database/adapter.ts src/database/postgres.ts src/database/migrations.ts
```
- [ ] **Step 2: Verify no imports remain**
```bash
grep -r "database/adapter\|database/postgres\|database/migrations" src/ --include="*.ts"
```
Expected: No results
- [ ] **Step 3: Commit**
```bash
git add -A
git commit -m "refactor: remove old database adapter files"
```
---
## Task 9: Final Testing and Verification
**Files:**
- Test all functionality
- [ ] **Step 1: Run full test suite**
```bash
cd /mnt/code/bete && pnpm run test
```
Expected: All tests pass
- [ ] **Step 2: Type check**
```bash
cd /mnt/code/bete && pnpm run typecheck
```
Expected: No TypeScript errors
- [ ] **Step 3: Lint**
```bash
cd /mnt/code/bete && pnpm run lint
```
Expected: No linting errors
- [ ] **Step 4: Test startup with SQLite**
```bash
cd /mnt/code/bete && timeout 10 pnpm run dev || true
```
Expected: Bot starts successfully, logs show "Database initialized"
- [ ] **Step 5: Verify git status**
```bash
git status
```
Expected: Clean working tree
- [ ] **Step 6: Final commit if needed**
```bash
git add -A
git commit -m "feat: complete drizzle-orm migration"
```
---
## Spec Coverage Checklist
- ✅ Replace raw SQL with Drizzle ORM
- ✅ Type-safe database operations
- ✅ Support both SQLite and PostgreSQL
- ✅ Automatic schema migrations
- ✅ All existing functionality preserved
- ✅ Backward compatible with existing data
- ✅ Cleaner, more maintainable code
- ✅ Better error handling
- ✅ Tests passing
- ✅ No TypeScript errors
---
Plan complete and saved to `/mnt/code/bete/docs/superpowers/plans/2026-05-14-drizzle-orm-migration.md`.
**Two execution options:**
**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration
**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
Which approach would you prefer?
@@ -1,183 +0,0 @@
# Library Modernization Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Modernize runtime and development dependencies while preserving Discord monitoring, recording, database migration, dashboard, and test behavior.
**Architecture:** Treat modernization as dependency classification plus small source refactors. Remove redundant validation libraries by moving `src/validation.ts` to Zod, replace `fluent-ffmpeg` with a tiny direct `ffmpeg` process wrapper for the muxer scripts, and convert database migration code to ESM-safe imports. Keep high-risk Discord/audio/native packages unless audit proves a safe replacement exists.
**Tech Stack:** Node.js, pnpm, TypeScript, Zod, Drizzle ORM, better-sqlite3, pg, Express, ws, Vite, React, Vitest, Biome, Discord voice/audio packages.
---
## Dependency Audit Baseline
- Usage audit confirms `class-transformer`/`class-validator` are only used by `src/validation.ts`.
- Usage audit confirms `fluent-ffmpeg` is only used by `src/muxer.ts` and `src/muxer-aup3.ts`.
- `pnpm outdated --format table` reports `discord.js-selfbot-v13` and `fluent-ffmpeg` as deprecated.
- Outdated packages reported: `tsx`, `@types/node`, `p-retry`, `pino`, `pino-pretty`, `sodium-native`.
- Direct dependency classification: remove `class-transformer`, `class-validator`, `fluent-ffmpeg`, `@types/fluent-ffmpeg`; replace validation with Zod and ffmpeg wrapper with `node:child_process`; upgrade outdated packages; keep high-risk voice/audio packages unless a compatible replacement is proven.
## File Structure
- Modify `package.json`: dependency upgrades, removals, and script additions if needed.
- Modify `pnpm-lock.yaml`: regenerated by `pnpm install`.
- Modify `src/validation.ts`: replace `class-transformer` and `class-validator` with Zod.
- Modify `src/database/migrate.ts`: remove dynamic CommonJS `require` and `any` cast.
- Create `src/audio/ffmpegProcess.ts`: small wrapper around `node:child_process` for direct ffmpeg execution.
- Modify `src/muxer.ts`: use `runFfmpeg()` instead of `fluent-ffmpeg`.
- Modify `src/muxer-aup3.ts`: use `runFfmpeg()` instead of `fluent-ffmpeg`.
- Modify `src/recorder/decoder.ts`: keep `createRequire()` for optional native probing unless a better ESM-safe probe is identified during implementation.
- Add or modify tests under `tests/`: validation, migration helper behavior, and ffmpeg argument construction.
---
### Task 1: Capture Dependency Audit Baseline
**Files:**
- Modify: `docs/superpowers/plans/2026-05-14-library-modernization.md`
- Inspect: `package.json`
- Inspect: `pnpm-lock.yaml`
- Inspect: `src/**/*.ts`
- Inspect: `tests/**/*.ts`
- [x] **Step 1: List direct dependency usage**
Run:
```bash
grep -R "class-transformer\|class-validator\|fluent-ffmpeg\|@discordjs/opus\|@discordjs/voice\|@snazzah/davey\|discord.js-selfbot-v13\|libsodium-wrappers\|sodium-native\|prism-media\|drizzle-orm\|better-sqlite3\|pg\|express\|helmet\|p-retry\|pino\|pino-http\|prom-client\|react\|react-dom\|vite\|ws\|zod" -n src tests frontend package.json
```
Expected: output lists every direct package usage. Record the summary in the implementation notes during execution.
- [x] **Step 2: Check outdated dependencies**
Run:
```bash
pnpm outdated --format table
```
Expected: command exits non-zero if packages are outdated; use the table as audit input, not as failure.
- [x] **Step 3: Classify direct dependencies**
Use this classification as the starting point, adjusting only if Step 1 proves a package is unused or irreplaceable:
```text
remove: class-transformer, class-validator, fluent-ffmpeg, @types/fluent-ffmpeg
replace: class-transformer/class-validator -> zod; fluent-ffmpeg -> node:child_process ffmpeg wrapper
upgrade: @vitejs/plugin-react, better-sqlite3, discord.js-selfbot-v13, dotenv, drizzle-orm, express, helmet, libsodium-wrappers, p-retry, pg, pino, pino-http, prom-client, react, react-dom, sodium-native, vite, ws, zod, @biomejs/biome, @types/*, drizzle-kit, pino-pretty, tsx, vitest
keep unless compatible alternative is proven: @discordjs/opus, @discordjs/voice, @snazzah/davey, prism-media
```
- [x] **Step 4: Commit audit note if this task changes files**
If only commands were run, do not commit. If the plan is updated with audit notes, run:
```bash
git add docs/superpowers/plans/2026-05-14-library-modernization.md
git commit -m "docs: record dependency modernization audit"
```
Expected: commit succeeds only if a file changed.
---
### Task 2: Replace Class Validator Stack With Zod
**Files:**
- Modify: `src/validation.ts`
- Test: `tests/validation.test.ts`
- Modify later: `package.json`
- [ ] **Step 1: Write failing validation tests**
- [ ] **Step 2: Run validation tests to establish baseline**
- [ ] **Step 3: Replace implementation with Zod**
- [ ] **Step 4: Run validation tests**
- [ ] **Step 5: Commit validation refactor**
---
### Task 3: Convert Migration Code to ESM-Safe Drizzle Imports
**Files:**
- Modify: `src/database/migrate.ts`
- Test: `tests/database/migrate.test.ts`
- [ ] **Step 1: Extract SQLite database creation for testing**
- [ ] **Step 2: Add migration helper test**
- [ ] **Step 3: Run migration test**
- [ ] **Step 4: Run typecheck for migration typing**
- [ ] **Step 5: Commit migration refactor**
---
### Task 4: Replace Fluent FFmpeg With Direct Process Wrapper
**Files:**
- Create: `src/audio/ffmpegProcess.ts`
- Modify: `src/muxer.ts`
- Modify: `src/muxer-aup3.ts`
- Test: `tests/audio/ffmpegProcess.test.ts`
- [ ] **Step 1: Add ffmpeg wrapper tests**
- [ ] **Step 2: Run ffmpeg wrapper test to verify it fails**
- [ ] **Step 3: Implement ffmpeg process wrapper**
- [ ] **Step 4: Refactor `src/muxer.ts`**
- [ ] **Step 5: Refactor `src/muxer-aup3.ts`**
- [ ] **Step 6: Run ffmpeg wrapper tests**
- [ ] **Step 7: Run typecheck**
- [ ] **Step 8: Commit ffmpeg refactor**
---
### Task 5: Update Package Manifest and Lockfile
**Files:**
- Modify: `package.json`
- Modify: `pnpm-lock.yaml`
- [ ] **Step 1: Remove replaced packages**
- [ ] **Step 2: Upgrade dependencies interactively-free**
- [ ] **Step 3: Ensure package manager remains pnpm 10**
- [ ] **Step 4: Run install to verify lockfile**
- [ ] **Step 5: Commit dependency manifest changes**
---
### Task 6: Fix Upgrade Breakages
**Files:**
- Modify as needed: `src/**/*.ts`
- Modify as needed: `frontend/**/*.ts`
- Modify as needed: `frontend/**/*.tsx`
- Modify as needed: `tests/**/*.ts`
- Modify as needed: config files touched by upgraded tools
- [ ] **Step 1: Run typecheck**
- [ ] **Step 2: Run lint**
- [ ] **Step 3: Run tests**
- [ ] **Step 4: Run build**
- [ ] **Step 5: Commit breakage fixes**
---
### Task 7: Final Verification and Manual Dashboard Check
**Files:**
- No planned source changes
- [ ] **Step 1: Run full verification**
- [ ] **Step 2: Start dev server for dashboard check**
- [ ] **Step 3: Manually verify frontend build path if browser access is available**
- [ ] **Step 4: Check git status**
---
## Self-Review
- Spec coverage: audit, dependency classification, replacement/removal, ESM migration, lockfile regeneration, verification, and dashboard manual check are covered.
- Placeholder scan: no `TBD`, `TODO`, or unspecified implementation steps remain.
- Type consistency: helper names are consistent across tasks: `validateUserStateUpdate`, `initializeMigrationSqliteDatabase`, `buildMuxFfmpegArgs`, and `runFfmpeg`.
@@ -1,189 +0,0 @@
# Discord Video Stream Vendor Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add `@dank074/discord-video-stream` as a vendored workspace dependency backed by the SSH submodule remote `ssh://git@43.134.105.109:22222/exceed/Discord-video-stream.git`.
**Architecture:** Follow the existing `discord.js-selfbot-v13` pattern: keep third-party source under `vendor/`, track it as a git submodule, include it in `pnpm-workspace.yaml`, and consume it from the root app with `workspace:*`. Clone from the public GitHub repository for source availability, then set `.gitmodules` to the requested SSH mirror URL so future submodule operations use the private remote.
**Tech Stack:** Git submodules, pnpm workspaces, Node.js package metadata, TypeScript project verification.
---
## File Structure
- Modify `.gitmodules`: add `vendor/Discord-video-stream` submodule entry with SSH URL.
- Create submodule path `vendor/Discord-video-stream`: checkout public upstream `https://github.com/Discord-RE/Discord-video-stream.git` at current `master` HEAD.
- Modify `pnpm-workspace.yaml`: add `vendor/Discord-video-stream` to workspace packages.
- Modify `package.json`: add root dependency `"@dank074/discord-video-stream": "workspace:*"`.
- Modify `pnpm-lock.yaml`: update lockfile after `pnpm install`.
## Task 1: Add Vendor Submodule
**Files:**
- Modify: `.gitmodules`
- Create: `vendor/Discord-video-stream`
- [ ] **Step 1: Verify vendor path does not already exist**
Run:
```bash
test ! -e vendor/Discord-video-stream
```
Expected: exit code `0`. If it exists, stop and inspect it with `git status --short vendor/Discord-video-stream` before proceeding.
- [ ] **Step 2: Add the submodule from public source**
Run:
```bash
git submodule add https://github.com/Discord-RE/Discord-video-stream.git vendor/Discord-video-stream
```
Expected: Git creates `vendor/Discord-video-stream` and updates `.gitmodules`.
- [ ] **Step 3: Set submodule URL to requested SSH mirror**
Run:
```bash
git config -f .gitmodules submodule.vendor/Discord-video-stream.url ssh://git@43.134.105.109:22222/exceed/Discord-video-stream.git
git submodule sync vendor/Discord-video-stream
```
Expected: `.gitmodules` contains:
```ini
[submodule "vendor/Discord-video-stream"]
path = vendor/Discord-video-stream
url = ssh://git@43.134.105.109:22222/exceed/Discord-video-stream.git
```
- [ ] **Step 4: Verify package identity**
Run:
```bash
node -e "const p=require('./vendor/Discord-video-stream/package.json'); console.log(p.name)"
```
Expected output:
```text
@dank074/discord-video-stream
```
- [ ] **Step 5: Commit submodule metadata only when requested**
Do not commit unless the user explicitly asks. This session's user has asked to implement but has not asked for a commit for this task.
## Task 2: Wire pnpm Workspace Dependency
**Files:**
- Modify: `pnpm-workspace.yaml`
- Modify: `package.json`
- Modify: `pnpm-lock.yaml`
- [ ] **Step 1: Add workspace package path**
Modify `pnpm-workspace.yaml` to exactly:
```yaml
packages:
- .
- vendor/discord.js-selfbot-v13
- vendor/Discord-video-stream
onlyBuiltDependencies:
- '@discordjs/opus'
- better-sqlite3
- esbuild
```
- [ ] **Step 2: Add root dependency**
In `package.json`, add dependency under `dependencies`:
```json
"@dank074/discord-video-stream": "workspace:*"
```
Keep alphabetical-ish placement with scoped packages near the top, for example after `"@discordjs/voice"`.
- [ ] **Step 3: Install and update lockfile**
Run:
```bash
pnpm install
```
Expected: `pnpm-lock.yaml` updates and root dependency resolves to `link:vendor/Discord-video-stream`.
- [ ] **Step 4: Verify workspace resolution**
Run:
```bash
pnpm list @dank074/discord-video-stream --depth 0
```
Expected output includes:
```text
@dank074/discord-video-stream link:vendor/Discord-video-stream
```
## Task 3: Verify Project Health
**Files:**
- No new files unless fixes are required.
- [ ] **Step 1: Run typecheck**
Run:
```bash
pnpm run typecheck
```
Expected: PASS.
- [ ] **Step 2: Run tests**
Run:
```bash
pnpm run test
```
Expected: PASS.
- [ ] **Step 3: Run build**
Run:
```bash
pnpm run build
```
Expected: PASS.
- [ ] **Step 4: Inspect final status**
Run:
```bash
git status --short
git submodule status
```
Expected: root status shows `.gitmodules`, `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, and `vendor/Discord-video-stream` as changed/added. Submodule status includes `vendor/Discord-video-stream` at the checked-out commit.
## Self-Review
- Spec coverage: submodule creation is Task 1; workspace dependency wiring is Task 2; verification is Task 3.
- Placeholder scan: no TBD/TODO/fill-in steps remain.
- Type consistency: package name `@dank074/discord-video-stream`, path `vendor/Discord-video-stream`, and SSH URL are consistent across all tasks.
File diff suppressed because it is too large Load Diff
@@ -1,713 +0,0 @@
# Media YouTube and Spotify Resolver Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Extend media playback input so users can queue YouTube URLs, plain search queries, and Spotify track URLs that resolve to playable YouTube audio.
**Architecture:** Keep playback unchanged: `musicPlayer` still passes one resolved source to ffmpeg. Add resolver units that turn rich inputs into direct playable URLs before queueing: `play-dl` for YouTube search and Spotify metadata, `yt-dlp` wrapper for YouTube metadata/direct URL extraction when available. Spotify track support resolves metadata then searches YouTube; no Spotify playlist/album support in this phase.
**Tech Stack:** TypeScript, Vitest, Node `child_process`, `play-dl`, external `yt-dlp` command when installed, existing Express/media controller/music player.
---
## File Structure
- Modify `package.json` and `pnpm-lock.yaml` — add `play-dl` dependency.
- Modify `src/media/mediaTypes.ts` — extend `MediaSourceKind` with `youtube`, `spotify`, and `search`.
- Create `src/media/ytdlp.ts` — small wrapper around external `yt-dlp` for JSON metadata and direct audio URL extraction.
- Create `src/media/playDlResolver.ts` — wrapper around `play-dl` for YouTube search and Spotify track metadata.
- Modify `src/media/mediaResolver.ts` — compose local/direct URL/YouTube/search/Spotify resolution.
- Modify `public/index.html` — update input label/placeholder to mention YouTube, Spotify track, and search.
- Tests:
- `tests/media/ytdlp.test.ts`
- `tests/media/playDlResolver.test.ts`
- `tests/media/mediaResolver.test.ts`
---
### Task 1: Add play-dl and Media Source Kinds
**Files:**
- Modify: `package.json`
- Modify: `pnpm-lock.yaml`
- Modify: `src/media/mediaTypes.ts`
- Test: `tests/media/mediaResolver.test.ts`
- [ ] **Step 1: Write failing type expectation in resolver test**
Append to `tests/media/mediaResolver.test.ts`:
```ts
it("keeps direct URLs as generic URL sources", async () => {
await expect(
resolveMediaSource("https://cdn.example.com/song.mp3"),
).resolves.toMatchObject({
kind: "url",
source: "https://cdn.example.com/song.mp3",
});
});
```
This test should already pass before type changes; it protects existing behavior.
- [ ] **Step 2: Install play-dl**
Run:
```bash
pnpm -C /mnt/code/bete add play-dl
```
Expected: `package.json` contains `"play-dl"` in dependencies and `pnpm-lock.yaml` updates.
- [ ] **Step 3: Extend media source kinds**
Modify `src/media/mediaTypes.ts`:
```ts
export type MediaSourceKind = "url" | "local" | "youtube" | "spotify" | "search";
```
- [ ] **Step 4: Run protected resolver test and typecheck**
Run:
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/mediaResolver.test.ts
pnpm -C /mnt/code/bete run typecheck
```
Expected: PASS.
- [ ] **Step 5: Commit task 1**
```bash
git -C /mnt/code/bete add package.json pnpm-lock.yaml src/media/mediaTypes.ts tests/media/mediaResolver.test.ts
git -C /mnt/code/bete commit -m "feat: prepare media resolver source kinds"
```
---
### Task 2: yt-dlp Wrapper
**Files:**
- Create: `src/media/ytdlp.ts`
- Test: `tests/media/ytdlp.test.ts`
- [ ] **Step 1: Write failing yt-dlp tests**
Create `tests/media/ytdlp.test.ts`:
```ts
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { describe, expect, it, vi } from "vitest";
import { createYtDlp } from "../../src/media/ytdlp";
class FakeProcess extends EventEmitter {
stdout = new PassThrough();
stderr = new PassThrough();
}
describe("createYtDlp", () => {
it("reads YouTube metadata as JSON", async () => {
const proc = new FakeProcess();
const spawn = vi.fn(() => proc);
const ytdlp = createYtDlp({ spawn });
const result = ytdlp.getMetadata("https://youtu.be/video");
proc.stdout.write(JSON.stringify({ title: "Song Title", webpage_url: "https://youtube.com/watch?v=video" }));
proc.stdout.end();
proc.emit("close", 0);
await expect(result).resolves.toEqual({
title: "Song Title",
webpageUrl: "https://youtube.com/watch?v=video",
});
expect(spawn).toHaveBeenCalledWith("yt-dlp", [
"https://youtu.be/video",
"--dump-single-json",
"--no-playlist",
"--no-warnings",
"--quiet",
], { stdio: ["ignore", "pipe", "pipe"] });
});
it("reads direct audio URL", async () => {
const proc = new FakeProcess();
const ytdlp = createYtDlp({ spawn: vi.fn(() => proc) });
const result = ytdlp.getDirectAudioUrl("https://youtu.be/video");
proc.stdout.write("https://audio.example.com/stream\n");
proc.stdout.end();
proc.emit("close", 0);
await expect(result).resolves.toBe("https://audio.example.com/stream");
});
it("rejects when yt-dlp exits non-zero", async () => {
const proc = new FakeProcess();
const ytdlp = createYtDlp({ spawn: vi.fn(() => proc) });
const result = ytdlp.getMetadata("https://youtu.be/video");
proc.stderr.write("failed");
proc.stderr.end();
proc.emit("close", 1);
await expect(result).rejects.toThrow("yt-dlp failed with code 1");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/ytdlp.test.ts
```
Expected: FAIL because `src/media/ytdlp.ts` does not exist.
- [ ] **Step 3: Implement yt-dlp wrapper**
Create `src/media/ytdlp.ts`:
```ts
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { spawn as nodeSpawn } from "node:child_process";
export interface YtDlpMetadata {
title: string;
webpageUrl: string;
}
export interface YtDlpClient {
getMetadata(url: string): Promise<YtDlpMetadata>;
getDirectAudioUrl(url: string): Promise<string>;
}
export interface YtDlpDependencies {
spawn?: typeof nodeSpawn;
}
export function createYtDlp(dependencies: YtDlpDependencies = {}): YtDlpClient {
const spawn = dependencies.spawn ?? nodeSpawn;
return {
async getMetadata(url: string): Promise<YtDlpMetadata> {
const data = await runYtDlp(spawn, [
url,
"--dump-single-json",
"--no-playlist",
"--no-warnings",
"--quiet",
]);
const parsed = JSON.parse(data) as { title?: string; webpage_url?: string };
return {
title: parsed.title || url,
webpageUrl: parsed.webpage_url || url,
};
},
async getDirectAudioUrl(url: string): Promise<string> {
return runYtDlp(spawn, [
url,
"--get-url",
"--format",
"bestaudio[protocol^=http]/bestaudio/best",
"--no-playlist",
"--no-warnings",
"--quiet",
]).then((value) => value.trim().split("\n")[0] || url);
},
};
}
async function runYtDlp(
spawn: typeof nodeSpawn,
args: string[],
): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn("yt-dlp", args, {
stdio: ["ignore", "pipe", "pipe"],
}) as unknown as ChildProcessWithoutNullStreams;
let stdout = "";
let stderr = "";
proc.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
proc.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
proc.on("error", reject);
proc.on("close", (code) => {
if (code === 0) {
resolve(stdout);
return;
}
reject(new Error(`yt-dlp failed with code ${code}: ${stderr.trim()}`));
});
});
}
```
- [ ] **Step 4: Run yt-dlp tests and typecheck**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/ytdlp.test.ts
pnpm -C /mnt/code/bete run typecheck
```
Expected: PASS.
- [ ] **Step 5: Commit task 2**
```bash
git -C /mnt/code/bete add src/media/ytdlp.ts tests/media/ytdlp.test.ts
git -C /mnt/code/bete commit -m "feat: add yt-dlp media helper"
```
---
### Task 3: play-dl Resolver Wrapper
**Files:**
- Create: `src/media/playDlResolver.ts`
- Test: `tests/media/playDlResolver.test.ts`
- [ ] **Step 1: Write failing play-dl resolver tests**
Create `tests/media/playDlResolver.test.ts`:
```ts
import { describe, expect, it, vi } from "vitest";
import { createPlayDlResolver } from "../../src/media/playDlResolver";
describe("createPlayDlResolver", () => {
it("returns the first YouTube search result", async () => {
const resolver = createPlayDlResolver({
search: vi.fn(async () => [
{ title: "Song Result", url: "https://youtube.com/watch?v=abc" },
]),
spotify: vi.fn(),
});
await expect(resolver.searchYouTube("artist song")).resolves.toEqual({
title: "Song Result",
url: "https://youtube.com/watch?v=abc",
});
});
it("turns Spotify track metadata into a YouTube search query", async () => {
const resolver = createPlayDlResolver({
search: vi.fn(async () => [
{ title: "Artist - Track", url: "https://youtube.com/watch?v=track" },
]),
spotify: vi.fn(async () => ({
type: "track",
name: "Track",
artists: [{ name: "Artist" }],
})),
});
await expect(
resolver.resolveSpotifyTrack("https://open.spotify.com/track/123"),
).resolves.toEqual({
title: "Artist - Track",
url: "https://youtube.com/watch?v=track",
});
});
it("rejects Spotify playlists in this phase", async () => {
const resolver = createPlayDlResolver({
search: vi.fn(),
spotify: vi.fn(async () => ({ type: "playlist", name: "Playlist" })),
});
await expect(
resolver.resolveSpotifyTrack("https://open.spotify.com/playlist/123"),
).rejects.toThrow("Only Spotify track URLs are supported");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/playDlResolver.test.ts
```
Expected: FAIL because `src/media/playDlResolver.ts` does not exist.
- [ ] **Step 3: Implement play-dl wrapper**
Create `src/media/playDlResolver.ts`:
```ts
import play from "play-dl";
export interface PlayDlResult {
title: string;
url: string;
}
interface PlayDlSearchResult {
title?: string;
url?: string;
}
interface SpotifyTrackLike {
type?: string;
name?: string;
artists?: Array<{ name?: string }>;
}
export interface PlayDlDependencies {
search?: (query: string, options: { limit: number }) => Promise<PlayDlSearchResult[]>;
spotify?: (url: string) => Promise<SpotifyTrackLike>;
}
export function createPlayDlResolver(dependencies: PlayDlDependencies = {}) {
const search = dependencies.search ?? play.search;
const spotify = dependencies.spotify ?? play.spotify;
return {
async searchYouTube(query: string): Promise<PlayDlResult> {
const results = await search(query, { limit: 1 });
const first = results[0];
if (!first?.url) throw new Error(`No YouTube result found for ${query}`);
return {
title: first.title || query,
url: first.url,
};
},
async resolveSpotifyTrack(url: string): Promise<PlayDlResult> {
const track = await spotify(url);
if (track.type !== "track") {
throw new Error("Only Spotify track URLs are supported");
}
const artists = (track.artists || [])
.map((artist) => artist.name)
.filter(Boolean)
.join(" ");
const query = `${artists} ${track.name || ""} audio`.trim();
return this.searchYouTube(query);
},
};
}
```
- [ ] **Step 4: Run play-dl tests and typecheck**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/playDlResolver.test.ts
pnpm -C /mnt/code/bete run typecheck
```
Expected: PASS.
- [ ] **Step 5: Commit task 3**
```bash
git -C /mnt/code/bete add src/media/playDlResolver.ts tests/media/playDlResolver.test.ts
git -C /mnt/code/bete commit -m "feat: add play-dl search resolver"
```
---
### Task 4: Compose Resolver for YouTube, Search, and Spotify Track
**Files:**
- Modify: `src/media/mediaResolver.ts`
- Test: `tests/media/mediaResolver.test.ts`
- [ ] **Step 1: Write failing composed resolver tests**
Append to `tests/media/mediaResolver.test.ts`:
```ts
import { createMediaResolver } from "../../src/media/mediaResolver";
// Add inside describe("resolveMediaSource", ...):
it("resolves YouTube URLs with yt-dlp metadata", async () => {
const resolver = createMediaResolver({
ytdlp: {
getMetadata: vi.fn(async () => ({
title: "YouTube Song",
webpageUrl: "https://youtube.com/watch?v=abc",
})),
getDirectAudioUrl: vi.fn(async () => "https://audio.example.com/abc"),
},
playDlResolver: {
searchYouTube: vi.fn(),
resolveSpotifyTrack: vi.fn(),
},
});
await expect(resolver("https://youtu.be/abc")).resolves.toEqual({
source: "https://audio.example.com/abc",
title: "YouTube Song",
kind: "youtube",
});
});
it("resolves search queries to YouTube results", async () => {
const resolver = createMediaResolver({
ytdlp: {
getMetadata: vi.fn(),
getDirectAudioUrl: vi.fn(async () => "https://audio.example.com/search"),
},
playDlResolver: {
searchYouTube: vi.fn(async () => ({
title: "Search Result",
url: "https://youtube.com/watch?v=search",
})),
resolveSpotifyTrack: vi.fn(),
},
});
await expect(resolver("artist song")).resolves.toEqual({
source: "https://audio.example.com/search",
title: "Search Result",
kind: "search",
});
});
it("resolves Spotify track URLs through YouTube search", async () => {
const resolver = createMediaResolver({
ytdlp: {
getMetadata: vi.fn(),
getDirectAudioUrl: vi.fn(async () => "https://audio.example.com/spotify"),
},
playDlResolver: {
searchYouTube: vi.fn(),
resolveSpotifyTrack: vi.fn(async () => ({
title: "Spotify Match",
url: "https://youtube.com/watch?v=spotify",
})),
},
});
await expect(
resolver("https://open.spotify.com/track/123"),
).resolves.toEqual({
source: "https://audio.example.com/spotify",
title: "Spotify Match",
kind: "spotify",
});
});
```
Also update imports at the top:
```ts
import { describe, expect, it, vi } from "vitest";
import { createMediaResolver, resolveMediaSource } from "../../src/media/mediaResolver";
```
- [ ] **Step 2: Run test to verify it fails**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/mediaResolver.test.ts
```
Expected: FAIL because `createMediaResolver` does not exist.
- [ ] **Step 3: Implement composed resolver**
Modify `src/media/mediaResolver.ts` to export `createMediaResolver()` and keep `resolveMediaSource` as the default instance:
```ts
import { existsSync, statSync } from "node:fs";
import path from "node:path";
import { AppError } from "../errors";
import { createPlayDlResolver } from "./playDlResolver";
import type { ResolvedMediaSource } from "./mediaTypes";
import { createYtDlp, type YtDlpClient } from "./ytdlp";
type PlayDlResolver = ReturnType<typeof createPlayDlResolver>;
export interface MediaResolverDependencies {
ytdlp?: YtDlpClient;
playDlResolver?: PlayDlResolver;
}
export function createMediaResolver(
dependencies: MediaResolverDependencies = {},
) {
const ytdlp = dependencies.ytdlp ?? createYtDlp();
const playDlResolver = dependencies.playDlResolver ?? createPlayDlResolver();
return async function resolve(input: string): Promise<ResolvedMediaSource> {
const source = input.trim();
if (!source) {
throw new AppError("Media source is required", "MISSING_MEDIA_SOURCE", 400);
}
const url = parseUrl(source);
if (url && isYouTubeUrl(url)) {
const metadata = await ytdlp.getMetadata(source);
const directUrl = await ytdlp.getDirectAudioUrl(source);
return { source: directUrl, title: metadata.title, kind: "youtube" };
}
if (url && isSpotifyTrackUrl(url)) {
const result = await playDlResolver.resolveSpotifyTrack(source);
const directUrl = await ytdlp.getDirectAudioUrl(result.url);
return { source: directUrl, title: result.title, kind: "spotify" };
}
const urlSource = resolveUrlSource(source);
if (urlSource) return urlSource;
const localPath = path.resolve(source);
if (existsSync(localPath) && statSync(localPath).isFile()) {
return {
source: localPath,
title: path.basename(localPath),
kind: "local",
};
}
if (!url) {
const result = await playDlResolver.searchYouTube(source);
const directUrl = await ytdlp.getDirectAudioUrl(result.url);
return { source: directUrl, title: result.title, kind: "search" };
}
throw new AppError(
"Media source must be an HTTP(S) URL, YouTube URL, Spotify track URL, search query, or existing local file",
"UNSUPPORTED_MEDIA_SOURCE",
400,
);
};
}
export const resolveMediaSource = createMediaResolver();
function parseUrl(source: string): URL | null {
try {
return new URL(source);
} catch {
return null;
}
}
function isYouTubeUrl(url: URL): boolean {
return ["youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be"].includes(
url.hostname,
);
}
function isSpotifyTrackUrl(url: URL): boolean {
return url.hostname === "open.spotify.com" && url.pathname.startsWith("/track/");
}
function resolveUrlSource(source: string): ResolvedMediaSource | null {
const url = parseUrl(source);
if (!url) return null;
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
return {
source,
title: titleFromUrl(url),
kind: "url",
};
}
function titleFromUrl(url: URL): string {
const filename = decodeURIComponent(url.pathname.split("/").pop() || "");
return path.basename(filename) || url.hostname;
}
```
- [ ] **Step 4: Run resolver tests and typecheck**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/mediaResolver.test.ts
pnpm -C /mnt/code/bete run typecheck
```
Expected: PASS.
- [ ] **Step 5: Commit task 4**
```bash
git -C /mnt/code/bete add src/media/mediaResolver.ts tests/media/mediaResolver.test.ts
git -C /mnt/code/bete commit -m "feat: resolve youtube search and spotify media"
```
---
### Task 5: Dashboard Copy and Full Verification
**Files:**
- Modify: `public/index.html`
- [ ] **Step 1: Update media input copy**
Change the media input label and placeholder in `public/index.html` from:
```html
<label for="mediaSourceInput">Music URL / file path</label>
<input id="mediaSourceInput" type="text" placeholder="https://example.com/song.mp3">
```
to:
```html
<label for="mediaSourceInput">Music URL, YouTube, Spotify track, search, or file path</label>
<input id="mediaSourceInput" type="text" placeholder="YouTube URL, Spotify track, or search terms">
```
- [ ] **Step 2: Run full verification**
```bash
pnpm -C /mnt/code/bete run test
pnpm -C /mnt/code/bete run typecheck
pnpm -C /mnt/code/bete run lint
```
Expected: PASS.
- [ ] **Step 3: Manual verification**
Run:
```bash
pnpm -C /mnt/code/bete run dev
```
Manual checks:
1. Queue a direct MP3 URL: still plays.
2. Queue a local file path: still plays.
3. Queue a YouTube URL: resolves title and plays audio.
4. Queue plain search terms: resolves first YouTube result and plays audio.
5. Queue a Spotify track URL: resolves Spotify metadata, searches YouTube, and plays audio.
6. Queue a Spotify playlist URL: returns a clear unsupported error.
- [ ] **Step 4: Commit task 5**
```bash
git -C /mnt/code/bete add public/index.html
git -C /mnt/code/bete commit -m "feat: update media input guidance"
```
---
## Self-Review
Spec coverage:
- YouTube URL support: Task 2 + Task 4.
- Search query support: Task 3 + Task 4.
- Spotify track URL to YouTube support: Task 3 + Task 4.
- No Spotify playlist/album support: Task 3 explicitly rejects non-track Spotify types, Task 5 manual check covers playlist error.
- Dashboard copy: Task 5.
- Existing direct URL/local file behavior protected: Task 1 + existing tests.
Placeholder scan: no placeholders, TODOs, or vague test instructions remain.
Type consistency: `MediaSourceKind` includes `youtube`, `spotify`, and `search`; resolver returns those exact values; tests assert those values.
@@ -1,663 +0,0 @@
# Selfbot Performance Feature Optimization Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Optimize the app's selfbot client runtime and vendor internals for lower memory pressure, safer REST retries, reduced voice cleanup leaks, faster gateway queue processing, and lightweight observability.
**Architecture:** Start with app-level client options because they are low-risk and immediately reduce cache pressure. Then patch vendor internals in isolated areas: REST manager/request handling, voice packet cleanup, and WebSocket shard queueing. Keep public imports and runtime APIs compatible with `discord.js-selfbot-v13` consumers.
**Tech Stack:** Node.js, TypeScript, CommonJS vendor package, discord.js-selfbot-v13 workspace dependency, Undici, Vitest, Biome, TypeScript.
---
## File Structure
- Modify `src/index.ts`: instantiate `Client` with low-memory cache/sweeper/REST options.
- Modify `vendor/discord.js-selfbot-v13/src/rest/RESTManager.js`: own per-client dispatcher state and super-properties cache helpers.
- Modify `vendor/discord.js-selfbot-v13/src/rest/APIRequest.js`: use per-client dispatcher and cached `x-super-properties` header.
- Modify `vendor/discord.js-selfbot-v13/src/rest/RequestHandler.js`: add backoff/jitter helper and debug telemetry for retry attempts.
- Modify `vendor/discord.js-selfbot-v13/src/client/voice/receiver/PacketHandler.js`: clear speaking timers and reduce RTP parse allocations.
- Modify `vendor/discord.js-selfbot-v13/src/client/websocket/WebSocketShard.js`: use cursor-backed gateway queue with compatible priority insertion and destroy cleanup.
- Create `tests/vendor/selfbotClientOptions.test.ts`: verify app client options factory if extracted.
- Create `tests/vendor/requestHandlerBackoff.test.ts`: verify retry delay calculation is bounded and grows.
- Create `tests/vendor/websocketQueue.test.ts`: verify FIFO, priority, and destroy queue reset semantics for the new queue helpers if exported/testable.
## Task 1: Extract and Test Low-Memory Client Options
**Files:**
- Create: `src/discordClientOptions.ts`
- Modify: `src/index.ts:4-25`
- Test: `tests/vendor/selfbotClientOptions.test.ts`
- [ ] **Step 1: Write the failing test**
Create `tests/vendor/selfbotClientOptions.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { createDiscordClientOptions } from "../../src/discordClientOptions";
describe("createDiscordClientOptions", () => {
it("uses low-memory message cache and active sweepers", () => {
const options = createDiscordClientOptions();
expect(options.restRequestTimeout).toBe(15_000);
expect(options.retryLimit).toBe(2);
expect(options.restGlobalRateLimit).toBe(45);
expect(options.sweepers).toEqual({
messages: { interval: 300, lifetime: 600 },
threads: { interval: 3600, lifetime: 14400 },
});
expect(options.partials).toEqual(["USER", "CHANNEL", "GUILD_MEMBER", "MESSAGE"]);
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `pnpm exec vitest run tests/vendor/selfbotClientOptions.test.ts`
Expected: FAIL with module not found for `src/discordClientOptions`.
- [ ] **Step 3: Add the client options factory**
Create `src/discordClientOptions.ts`:
```ts
import { Options } from "discord.js-selfbot-v13";
export function createDiscordClientOptions() {
return {
makeCache: Options.cacheWithLimits({
...Options.defaultMakeCacheSettings,
MessageManager: 25,
ReactionManager: 0,
ReactionUserManager: 0,
PresenceManager: 0,
}),
partials: ["USER", "CHANNEL", "GUILD_MEMBER", "MESSAGE"],
sweepers: {
messages: { interval: 300, lifetime: 600 },
threads: { interval: 3600, lifetime: 14400 },
},
restRequestTimeout: 15_000,
retryLimit: 2,
restGlobalRateLimit: 45,
};
}
```
- [ ] **Step 4: Use the factory in the app entry point**
Modify `src/index.ts`:
```ts
import { Client } from "discord.js-selfbot-v13";
import { config } from "./config";
import { closeDatabase, initializeDatabase } from "./database/drizzle";
import { createDiscordClientOptions } from "./discordClientOptions";
```
Replace:
```ts
const client = new Client();
```
with:
```ts
const client = new Client(createDiscordClientOptions());
```
- [ ] **Step 5: Run the focused test**
Run: `pnpm exec vitest run tests/vendor/selfbotClientOptions.test.ts`
Expected: PASS.
- [ ] **Step 6: Run typecheck**
Run: `pnpm run typecheck`
Expected: PASS. If TypeScript cannot type `Options` from the vendor package, add a local return type only if necessary; do not weaken the factory to `any`.
- [ ] **Step 7: Commit**
Run:
```bash
git add src/discordClientOptions.ts src/index.ts tests/vendor/selfbotClientOptions.test.ts
git commit -m "perf: tune selfbot client runtime options"
```
## Task 2: Add Per-Client REST Dispatcher and Cached Super Properties
**Files:**
- Modify: `vendor/discord.js-selfbot-v13/src/rest/RESTManager.js:1-69`
- Modify: `vendor/discord.js-selfbot-v13/src/rest/APIRequest.js:1-166`
- [ ] **Step 1: Add REST manager state**
Modify `vendor/discord.js-selfbot-v13/src/rest/RESTManager.js` imports:
```js
const { Collection } = require('@discordjs/collection');
const makeFetchCookie = require('fetch-cookie');
const { CookieJar } = require('tough-cookie');
const { buildConnector, Client: UndiciClient, ProxyAgent, fetch: fetchOriginal } = require('undici');
const APIRequest = require('./APIRequest');
const routeBuilder = require('./APIRouter');
const RequestHandler = require('./RequestHandler');
const { Error } = require('../errors');
const { ciphers } = require('../util/Constants');
const { Endpoints } = require('../util/Constants');
const Util = require('../util/Util');
```
- [ ] **Step 2: Add per-client dispatcher fields and helper methods**
Inside `RESTManager` constructor after `this.fetch = ...`, add:
```js
this.dispatcher = null;
this.superPropertiesSource = null;
this.superPropertiesHeader = null;
```
Add methods before `request(method, url, options = {})`:
```js
getDispatcher() {
if (this.dispatcher) return this.dispatcher;
const proxy = Util.checkUndiciProxyAgent(this.client.options.http.agent);
if (proxy) {
this.dispatcher = new ProxyAgent({
...proxy,
ciphers: ciphers.join(':'),
});
} else {
this.dispatcher = new UndiciClient('https://discord.com', {
connect: buildConnector({ ciphers: ciphers.join(':') }),
});
}
return this.dispatcher;
}
getSuperPropertiesHeader() {
const source = JSON.stringify(this.client.options.ws.properties);
if (source !== this.superPropertiesSource) {
this.superPropertiesSource = source;
this.superPropertiesHeader = Buffer.from(source, 'ascii').toString('base64');
}
return this.superPropertiesHeader;
}
```
- [ ] **Step 3: Remove module-global dispatcher from APIRequest**
Modify `vendor/discord.js-selfbot-v13/src/rest/APIRequest.js` imports to:
```js
const Buffer = require('node:buffer').Buffer;
const { setTimeout } = require('node:timers');
const { FormData } = require('undici');
```
Remove:
```js
const { FormData, buildConnector, Client, ProxyAgent } = require('undici');
const { ciphers } = require('../util/Constants');
const Util = require('../util/Util');
let agent = null;
```
- [ ] **Step 4: Use REST manager dispatcher and cached header**
In `APIRequest.make`, delete the `if (!agent) { ... }` block.
Replace the `x-super-properties` header construction with:
```js
'x-super-properties': this.rest.getSuperPropertiesHeader(),
```
Replace fetch dispatcher:
```js
dispatcher: agent,
```
with:
```js
dispatcher: this.rest.getDispatcher(),
```
- [ ] **Step 5: Run vendor lint through root lint**
Run: `pnpm run lint`
Expected: PASS or existing unrelated lint failures. If failures are in edited vendor files, fix them.
- [ ] **Step 6: Commit**
Run:
```bash
git add vendor/discord.js-selfbot-v13/src/rest/RESTManager.js vendor/discord.js-selfbot-v13/src/rest/APIRequest.js
git commit -m "perf: cache selfbot rest dispatcher metadata"
```
## Task 3: Add REST Retry Backoff With Jitter
**Files:**
- Modify: `vendor/discord.js-selfbot-v13/src/rest/RequestHandler.js:1-505`
- Test: `tests/vendor/requestHandlerBackoff.test.ts`
- [ ] **Step 1: Export a pure backoff helper for tests**
Add near the top of `vendor/discord.js-selfbot-v13/src/rest/RequestHandler.js` after `calculateReset`:
```js
function calculateRetryDelay(retryCount, random = Math.random) {
const base = 250;
const max = 5_000;
const exponential = Math.min(max, base * 2 ** Math.max(0, retryCount - 1));
return exponential + Math.floor(random() * base);
}
```
At the bottom, replace:
```js
module.exports = RequestHandler;
```
with:
```js
module.exports = RequestHandler;
module.exports.calculateRetryDelay = calculateRetryDelay;
```
- [ ] **Step 2: Write the focused helper test**
Create `tests/vendor/requestHandlerBackoff.test.ts`:
```ts
import { describe, expect, it } from "vitest";
const { calculateRetryDelay } = await import(
"../../vendor/discord.js-selfbot-v13/src/rest/RequestHandler.js"
);
describe("calculateRetryDelay", () => {
it("increases exponentially and applies bounded jitter", () => {
expect(calculateRetryDelay(1, () => 0)).toBe(250);
expect(calculateRetryDelay(2, () => 0)).toBe(500);
expect(calculateRetryDelay(3, () => 0)).toBe(1000);
expect(calculateRetryDelay(10, () => 0)).toBe(5000);
expect(calculateRetryDelay(1, () => 0.999)).toBe(499);
});
});
```
- [ ] **Step 3: Run the helper test**
Run: `pnpm exec vitest run tests/vendor/requestHandlerBackoff.test.ts`
Expected: PASS.
- [ ] **Step 4: Apply backoff to network errors**
In `RequestHandler.execute`, replace the catch block after `request.make(...)` with:
```js
} catch (error) {
if (request.retries === this.manager.client.options.retryLimit) {
throw new HTTPError(
error.message,
error.constructor.name,
error.status,
request,
);
}
request.retries++;
const delay = calculateRetryDelay(request.retries);
this.manager.client.emit(
DEBUG,
`[Request Handler] Retrying failed request after ${delay}ms.\n Method : ${request.method}\n Path : ${request.path}\n Route : ${request.route}\n Retry : ${request.retries}`,
);
await sleep(delay);
return this.execute(request);
}
```
- [ ] **Step 5: Apply backoff to 5xx responses**
In the 5xx block, replace:
```js
request.retries++;
return this.execute(request);
```
with:
```js
request.retries++;
const delay = calculateRetryDelay(request.retries);
this.manager.client.emit(
DEBUG,
`[Request Handler] Retrying server error after ${delay}ms.\n Method : ${request.method}\n Path : ${request.path}\n Route : ${request.route}\n Status : ${res.status}\n Retry : ${request.retries}`,
);
await sleep(delay);
return this.execute(request);
```
- [ ] **Step 6: Run focused and full tests**
Run: `pnpm exec vitest run tests/vendor/requestHandlerBackoff.test.ts`
Expected: PASS.
Run: `pnpm run test`
Expected: PASS.
- [ ] **Step 7: Commit**
Run:
```bash
git add vendor/discord.js-selfbot-v13/src/rest/RequestHandler.js tests/vendor/requestHandlerBackoff.test.ts
git commit -m "perf: back off selfbot rest retries"
```
## Task 4: Clean Voice Receiver Timers and Reduce RTP Buffer Work
**Files:**
- Modify: `vendor/discord.js-selfbot-v13/src/client/voice/receiver/PacketHandler.js:1-280`
- [ ] **Step 1: Patch AES decrypt concat allocation**
In `parseBuffer`, replace:
```js
packet = Buffer.concat([
decipheriv.update(encrypted),
decipheriv.final(),
]);
```
with:
```js
const updated = decipheriv.update(encrypted);
const final = decipheriv.final();
packet = final.length === 0 ? updated : Buffer.concat([updated, final]);
```
- [ ] **Step 2: Patch XChaCha auth tag concat allocation**
Replace:
```js
Buffer.concat([encrypted, authTag]),
```
with:
```js
buffer.subarray(headerSize, buffer.length - UNPADDED_NONCE_LENGTH),
```
- [ ] **Step 3: Add speaking timeout cleanup**
In `destroyAllStream()`, after clearing video streams, add:
```js
for (const timeout of this.speakingTimeouts.values()) {
clearTimeout(timeout);
}
const clearedSpeakingTimeouts = this.speakingTimeouts.size;
this.speakingTimeouts.clear();
this.emit('debug', {
message: 'Destroyed voice receiver streams',
audioStreams: this.streams.size,
videoStreams: this.videoStreams.size,
speakingTimeouts: clearedSpeakingTimeouts,
});
```
Then adjust ordering so the counts are captured before `streams.clear()` and `videoStreams.clear()`:
```js
destroyAllStream() {
const audioStreams = this.streams.size;
const videoStreams = this.videoStreams.size;
for (const stream of this.streams.values()) {
stream.stream.destroy();
}
this.streams.clear();
for (const stream of this.videoStreams.values()) {
stream.destroy();
}
this.videoStreams.clear();
for (const timeout of this.speakingTimeouts.values()) {
clearTimeout(timeout);
}
const speakingTimeouts = this.speakingTimeouts.size;
this.speakingTimeouts.clear();
this.emit('debug', {
message: 'Destroyed voice receiver streams',
audioStreams,
videoStreams,
speakingTimeouts,
});
}
```
- [ ] **Step 4: Run lint**
Run: `pnpm run lint`
Expected: PASS or only unrelated existing failures. Fix edited-file failures.
- [ ] **Step 5: Run tests**
Run: `pnpm run test`
Expected: PASS.
- [ ] **Step 6: Commit**
Run:
```bash
git add vendor/discord.js-selfbot-v13/src/client/voice/receiver/PacketHandler.js
git commit -m "perf: clean up selfbot voice receiver state"
```
## Task 5: Replace Gateway Queue Shift With Cursor Queue
**Files:**
- Modify: `vendor/discord.js-selfbot-v13/src/client/websocket/WebSocketShard.js:108-120,818-954`
- [ ] **Step 1: Add queue cursor metadata**
In the `ratelimit` object, change:
```js
queue: [],
```
To:
```js
queue: [],
queueOffset: 0,
```
- [ ] **Step 2: Update priority insertion**
Replace `send(data, important = false)` with:
```js
send(data, important = false) {
if (important) {
if (this.ratelimit.queueOffset === 0) {
this.ratelimit.queue.unshift(data);
} else {
this.ratelimit.queue[--this.ratelimit.queueOffset] = data;
}
} else {
this.ratelimit.queue.push(data);
}
this.processQueue();
}
```
- [ ] **Step 3: Update queue processing**
Replace `processQueue()` with:
```js
processQueue() {
if (this.ratelimit.remaining === 0) return;
if (this.ratelimit.queueOffset >= this.ratelimit.queue.length) return;
if (this.ratelimit.remaining === this.ratelimit.total) {
this.ratelimit.timer = setTimeout(() => {
this.ratelimit.remaining = this.ratelimit.total;
this.processQueue();
}, this.ratelimit.time).unref();
}
while (this.ratelimit.remaining > 0) {
const item = this.ratelimit.queue[this.ratelimit.queueOffset++];
if (!item) {
this._compactQueue();
return;
}
this._send(item);
this.ratelimit.remaining--;
}
this._compactQueue();
}
```
- [ ] **Step 4: Add queue compaction helper**
Add before `destroy(...)`:
```js
_compactQueue() {
if (this.ratelimit.queueOffset === 0) return;
if (this.ratelimit.queueOffset >= this.ratelimit.queue.length) {
this.ratelimit.queue.length = 0;
this.ratelimit.queueOffset = 0;
return;
}
if (this.ratelimit.queueOffset > 512) {
this.ratelimit.queue = this.ratelimit.queue.slice(this.ratelimit.queueOffset);
this.ratelimit.queueOffset = 0;
}
}
```
- [ ] **Step 5: Reset cursor on destroy**
In `destroy`, after:
```js
this.ratelimit.queue.length = 0;
```
Add:
```js
this.ratelimit.queueOffset = 0;
```
- [ ] **Step 6: Run lint and tests**
Run: `pnpm run lint`
Expected: PASS or only unrelated existing failures. Fix edited-file failures.
Run: `pnpm run test`
Expected: PASS.
- [ ] **Step 7: Commit**
Run:
```bash
git add vendor/discord.js-selfbot-v13/src/client/websocket/WebSocketShard.js
git commit -m "perf: optimize selfbot gateway send queue"
```
## Task 6: Final Verification and Manual Runtime Notes
**Files:**
- Modify only if verification exposes issues.
- [ ] **Step 1: Run full lint**
Run: `pnpm run lint`
Expected: PASS.
- [ ] **Step 2: Run full typecheck**
Run: `pnpm run typecheck`
Expected: PASS.
- [ ] **Step 3: Run full tests**
Run: `pnpm run test`
Expected: PASS.
- [ ] **Step 4: Run build**
Run: `pnpm run build`
Expected: PASS.
- [ ] **Step 5: Inspect git diff**
Run: `git diff --stat HEAD~5..HEAD` if each task was committed, or `git diff --stat` if not.
Expected: changes limited to app client options, vendor REST, vendor voice, vendor WebSocket, tests, and this plan/spec.
- [ ] **Step 6: Record manual Discord runtime limitation**
If no Discord token/runtime environment is available, final response must state:
```text
Automated verification passed. I could not perform live Discord runtime verification in this environment. Manual checks still needed: login, message capture, backlog sync, voice connect, voice record, disconnect, reconnect.
```
- [ ] **Step 7: Commit verification fixes only if needed**
If Step 1-4 required fixes, commit only those fixes:
```bash
git add <fixed-files>
git commit -m "fix: stabilize selfbot optimization verification"
```
## Self-Review
- Spec coverage: app runtime config is Task 1; REST dispatcher/header/backoff is Tasks 2-3; voice cleanup/allocation is Task 4; gateway queue is Task 5; verification/manual runtime note is Task 6.
- Placeholder scan: no TBD/TODO/fill-in steps remain; each code step includes concrete snippets and paths.
- Type consistency: `createDiscordClientOptions`, `calculateRetryDelay`, `getDispatcher`, `getSuperPropertiesHeader`, `_compactQueue`, and `queueOffset` are introduced before use and named consistently.
@@ -1,239 +0,0 @@
# Selfbot Workspace Submodule Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the npm `discord.js-selfbot-v13` dependency with a custom git submodule consumed through pnpm workspace resolution.
**Architecture:** The vendored selfbot library lives at `vendor/discord.js-selfbot-v13` as a git submodule. The root package depends on it with `workspace:*`, and `pnpm-workspace.yaml` includes both the root package and the vendored package while preserving the existing `onlyBuiltDependencies` settings.
**Tech Stack:** Git submodules, pnpm workspaces, TypeScript, existing Node.js package scripts.
---
## File Structure
- Create: `.gitmodules` if missing; otherwise modify it to include `vendor/discord.js-selfbot-v13`.
- Create: `vendor/discord.js-selfbot-v13` via `git submodule add`; do not create files in this directory manually.
- Modify: `pnpm-workspace.yaml` to add `packages` while preserving `onlyBuiltDependencies`.
- Modify: `package.json` dependency `discord.js-selfbot-v13` from `^3.7.1` to `workspace:*`.
- Modify: `pnpm-lock.yaml` by running pnpm, not by hand.
### Task 1: Add the selfbot repository as a submodule
**Files:**
- Create/Modify: `.gitmodules`
- Create: `vendor/discord.js-selfbot-v13`
- [ ] **Step 1: Confirm there is no existing submodule path**
Run:
```bash
git submodule status --recursive || true
test ! -e vendor/discord.js-selfbot-v13
```
Expected: either no existing submodule output, or output that does not include `vendor/discord.js-selfbot-v13`; the `test` command exits successfully.
- [ ] **Step 2: Add the upstream repository as a submodule**
Run:
```bash
git submodule add https://github.com/aiko-chan-ai/discord.js-selfbot-v13.git vendor/discord.js-selfbot-v13
```
Expected: git clones the repository into `vendor/discord.js-selfbot-v13` and creates or updates `.gitmodules`.
- [ ] **Step 3: Change the submodule remote to the internal SSH repository**
Run:
```bash
git -C vendor/discord.js-selfbot-v13 remote set-url origin ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git
git config -f .gitmodules submodule.vendor/discord.js-selfbot-v13.url ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git
git submodule sync vendor/discord.js-selfbot-v13
```
Expected: both the submodule checkout and `.gitmodules` use `ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git`.
- [ ] **Step 4: Verify submodule metadata**
Run:
```bash
git -C vendor/discord.js-selfbot-v13 remote get-url origin
git config -f .gitmodules --get submodule.vendor/discord.js-selfbot-v13.path
git config -f .gitmodules --get submodule.vendor/discord.js-selfbot-v13.url
```
Expected output:
```text
ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git
vendor/discord.js-selfbot-v13
ssh://git@43.134.105.109:22222/exceed/discord.js-selfbot.git
```
### Task 2: Configure pnpm workspace resolution
**Files:**
- Modify: `pnpm-workspace.yaml`
- Modify: `package.json`
- [ ] **Step 1: Update pnpm workspace file**
Edit `pnpm-workspace.yaml` to exactly:
```yaml
packages:
- .
- vendor/discord.js-selfbot-v13
onlyBuiltDependencies:
- '@discordjs/opus'
- better-sqlite3
- esbuild
```
Expected: the existing `onlyBuiltDependencies` entries remain unchanged, and workspace packages now include root plus the vendored selfbot package.
- [ ] **Step 2: Update root dependency**
Edit `package.json` so the dependencies block contains:
```json
"discord.js-selfbot-v13": "workspace:*"
```
Expected: only the `discord.js-selfbot-v13` version source changes; the rest of `package.json` remains unchanged.
- [ ] **Step 3: Verify the submodule package name**
Run:
```bash
node -e "const p=require('./vendor/discord.js-selfbot-v13/package.json'); if (p.name !== 'discord.js-selfbot-v13') { throw new Error('unexpected package name: '+p.name) } console.log(p.name)"
```
Expected output:
```text
discord.js-selfbot-v13
```
### Task 3: Refresh dependency lockfile and install links
**Files:**
- Modify: `pnpm-lock.yaml`
- Modify: `node_modules` locally, not committed
- [ ] **Step 1: Refresh pnpm install state**
Run:
```bash
pnpm install
```
Expected: pnpm completes successfully and updates `pnpm-lock.yaml` so `discord.js-selfbot-v13` resolves from `link:vendor/discord.js-selfbot-v13` or equivalent workspace link notation.
- [ ] **Step 2: Verify pnpm resolves the workspace package**
Run:
```bash
pnpm list discord.js-selfbot-v13 --depth 0
```
Expected: output shows `discord.js-selfbot-v13` as a linked workspace dependency rather than the npm registry version.
- [ ] **Step 3: Inspect the lockfile entry**
Run:
```bash
grep -n "discord.js-selfbot-v13" pnpm-lock.yaml | head -20
```
Expected: the root importer entry for `discord.js-selfbot-v13` references `specifier: workspace:*` and a workspace/link version.
### Task 4: Validate root project compatibility
**Files:**
- Read-only validation for TypeScript project files.
- [ ] **Step 1: Run TypeScript validation**
Run:
```bash
pnpm run typecheck
```
Expected: command exits successfully.
- [ ] **Step 2: If typecheck fails because the submodule package is unbuilt, build the submodule**
Run only if Step 1 fails with missing compiled files or missing package entrypoint errors from `vendor/discord.js-selfbot-v13`:
```bash
pnpm --filter discord.js-selfbot-v13 install
npnpm --filter discord.js-selfbot-v13 run build
pnpm run typecheck
```
Expected: submodule package builds successfully and root typecheck passes.
If the package has no `build` script, inspect `vendor/discord.js-selfbot-v13/package.json` scripts and use the package's documented compile script, then rerun `pnpm run typecheck`.
- [ ] **Step 3: Run lint if typecheck passes**
Run:
```bash
pnpm run lint
```
Expected: command exits successfully or reports only pre-existing issues unrelated to `.gitmodules`, `package.json`, `pnpm-workspace.yaml`, or `pnpm-lock.yaml`.
### Task 5: Review git diff and prepare handoff
**Files:**
- Review: `.gitmodules`
- Review: `package.json`
- Review: `pnpm-workspace.yaml`
- Review: `pnpm-lock.yaml`
- Review: `vendor/discord.js-selfbot-v13` gitlink
- [ ] **Step 1: Review changed files**
Run:
```bash
git status --short
git diff -- .gitmodules package.json pnpm-workspace.yaml pnpm-lock.yaml
git diff --submodule
```
Expected: changes are limited to the design spec, plan, submodule metadata/gitlink, pnpm workspace config, root dependency, and lockfile. Existing unrelated `README.md` modifications remain untouched.
- [ ] **Step 2: Summarize validation evidence**
Record these command outcomes in the final response:
```text
pnpm install: PASS or FAIL with error summary
pnpm run typecheck: PASS or FAIL with error summary
pnpm run lint: PASS, FAIL with error summary, or NOT RUN with reason
```
- [ ] **Step 3: Do not commit unless explicitly asked**
No commit command should run unless the user explicitly asks for a commit. If the user asks, use the repository commit workflow and stage only relevant files.
## Self-Review
- Spec coverage: the plan covers submodule creation, remote replacement, workspace config, dependency rewrite, lockfile refresh, and validation.
- Placeholder scan: no TBD/TODO placeholders remain.
- Type consistency: package path, dependency name, and remote URL are consistent across tasks.
@@ -1,466 +0,0 @@
# 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.
@@ -1,488 +0,0 @@
# Vendor Selfbot Dependency Modernization Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Aggressively modernize the vendored `discord.js-selfbot-v13` dependency by replacing its legacy toolchain with Biome and auditing runtime dependencies without changing the public API used by the root app.
**Architecture:** The vendor submodule remains a CommonJS package exported from `vendor/discord.js-selfbot-v13/src/index.js`. Tooling moves to Biome plus TypeScript/tsd validation, while runtime dependencies are changed only after usage evidence from `src`, `typings`, config, and scripts. Root workspace resolution remains `workspace:*` and is validated from the root after vendor changes.
**Tech Stack:** Node.js >=20.18, pnpm workspaces, Biome, TypeScript, tsd, CommonJS, git submodule.
---
## File Structure
- Modify: `vendor/discord.js-selfbot-v13/package.json` for scripts and dependencies.
- Create: `vendor/discord.js-selfbot-v13/biome.json` for vendor-specific Biome scope.
- Remove: `vendor/discord.js-selfbot-v13/.eslintrc.json`, `vendor/discord.js-selfbot-v13/.prettierrc.json`, `vendor/discord.js-selfbot-v13/tslint.json` after scripts no longer reference them.
- Modify: `vendor/discord.js-selfbot-v13/tsconfig.json` only if modern TypeScript validation requires config compatibility.
- Modify: `vendor/discord.js-selfbot-v13/src/**/*.js` only for required runtime dependency replacements or Biome-safe formatting fixes.
- Modify: `vendor/discord.js-selfbot-v13/typings/**/*.d.ts` only for TypeScript/tsd compatibility.
- Modify: `pnpm-lock.yaml` by running pnpm from the root, not by hand.
- Do not modify root app source files unless validation proves a compatibility issue from the vendor API.
### Task 1: Capture baseline and dependency usage evidence
**Files:**
- Read: `vendor/discord.js-selfbot-v13/package.json`
- Read: `vendor/discord.js-selfbot-v13/src/**/*.js`
- Read: `vendor/discord.js-selfbot-v13/typings/**/*.d.ts`
- [ ] **Step 1: Capture current vendor dependency lists**
Run:
```bash
node - <<'NODE'
const pkg = require('./vendor/discord.js-selfbot-v13/package.json');
console.log('dependencies');
for (const name of Object.keys(pkg.dependencies || {}).sort()) console.log(`${name} ${pkg.dependencies[name]}`);
console.log('devDependencies');
for (const name of Object.keys(pkg.devDependencies || {}).sort()) console.log(`${name} ${pkg.devDependencies[name]}`);
NODE
```
Expected: prints the current runtime and dev dependency names and versions.
- [ ] **Step 2: Capture runtime usage map**
Run:
```bash
node - <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const root = 'vendor/discord.js-selfbot-v13';
const deps = [
'@discordjs/builders',
'@discordjs/collection',
'@sapphire/async-queue',
'@sapphire/shapeshift',
'discord-api-types',
'fetch-cookie',
'find-process',
'otplib',
'prism-media',
'qrcode',
'tough-cookie',
'tree-kill',
'undici',
'werift-rtp',
'ws',
];
const files = [];
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (/\.(js|ts|d\.ts|json)$/.test(full)) files.push(full);
}
}
walk(path.join(root, 'src'));
walk(path.join(root, 'typings'));
for (const dep of deps) {
const hits = [];
for (const file of files) {
const text = fs.readFileSync(file, 'utf8');
if (text.includes(`require('${dep}`) || text.includes(`require("${dep}`) || text.includes(`from '${dep}`) || text.includes(`from "${dep}`)) hits.push(file);
}
console.log(`${dep}: ${hits.length ? hits.join(', ') : 'UNUSED'}`);
}
NODE
```
Expected usage classification based on current code:
```text
@discordjs/builders: vendor/discord.js-selfbot-v13/src/util/Formatters.js, vendor/discord.js-selfbot-v13/src/managers/ApplicationCommandManager.js, vendor/discord.js-selfbot-v13/typings/index.d.ts
@discordjs/collection: many src files and typings/index.d.ts
@sapphire/async-queue: vendor/discord.js-selfbot-v13/src/rest/RequestHandler.js
@sapphire/shapeshift: vendor/discord.js-selfbot-v13/src/structures/interfaces/TextBasedChannel.js
discord-api-types: vendor/discord.js-selfbot-v13/src/client/websocket/WebSocketManager.js, vendor/discord.js-selfbot-v13/typings/index.d.ts, vendor/discord.js-selfbot-v13/typings/rawDataTypes.d.ts
fetch-cookie: vendor/discord.js-selfbot-v13/src/rest/RESTManager.js
find-process: vendor/discord.js-selfbot-v13/src/client/voice/receiver/Recorder.js
otplib: vendor/discord.js-selfbot-v13/src/client/Client.js
prism-media: vendor/discord.js-selfbot-v13/src/client/voice/util/PlayInterface.js, vendor/discord.js-selfbot-v13/src/client/voice/player/MediaPlayer.js, vendor/discord.js-selfbot-v13/src/client/voice/receiver/Receiver.js
qrcode: vendor/discord.js-selfbot-v13/src/util/RemoteAuth.js
tough-cookie: vendor/discord.js-selfbot-v13/src/rest/RESTManager.js
tree-kill: vendor/discord.js-selfbot-v13/src/client/voice/receiver/Recorder.js
undici: vendor/discord.js-selfbot-v13/src/rest/APIRequest.js, vendor/discord.js-selfbot-v13/src/rest/RESTManager.js, vendor/discord.js-selfbot-v13/src/util/RemoteAuth.js, vendor/discord.js-selfbot-v13/src/util/Util.js, vendor/discord.js-selfbot-v13/src/util/DataResolver.js
werift-rtp: vendor/discord.js-selfbot-v13/src/client/voice/receiver/PacketHandler.js, vendor/discord.js-selfbot-v13/src/client/voice/receiver/Recorder.js
ws: vendor/discord.js-selfbot-v13/src/WebSocket.js, vendor/discord.js-selfbot-v13/src/util/RemoteAuth.js
```
- [ ] **Step 3: Confirm type assertion tests exist**
Run:
```bash
find vendor/discord.js-selfbot-v13 -maxdepth 3 -type f -name '*.test-d.ts' -print
```
Expected output includes:
```text
vendor/discord.js-selfbot-v13/typings/index.test-d.ts
```
Decision: keep `tsd` because `typings/index.test-d.ts` exists.
### Task 2: Replace vendor lint and format toolchain with Biome
**Files:**
- Modify: `vendor/discord.js-selfbot-v13/package.json`
- Create: `vendor/discord.js-selfbot-v13/biome.json`
- Remove: `vendor/discord.js-selfbot-v13/.eslintrc.json`
- Remove: `vendor/discord.js-selfbot-v13/.prettierrc.json`
- Remove: `vendor/discord.js-selfbot-v13/tslint.json`
- [ ] **Step 1: Update vendor scripts and dev dependencies**
Edit `vendor/discord.js-selfbot-v13/package.json` so `scripts` becomes:
```json
{
"all": "npm run build && npm publish",
"test": "npm run lint && npm run test:typescript && npm run docs:test",
"fix:all": "npm run format",
"test:typescript": "tsc --noEmit && tsd",
"lint": "biome check . --diagnostic-level=error",
"format": "biome format --write .",
"docs": "docgen --source src --custom docs/index.yml --output docs/main.json",
"docs:test": "docgen --source src --custom docs/index.yml",
"build": "npm run format && npm run docs"
}
```
In the same file, remove these dev dependencies:
```json
"dtslint": "^4.2.1",
"eslint": "^8.39.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-prettier": "^4.2.1",
"prettier": "^2.8.8",
"tslint": "^6.1.3"
```
Add this dev dependency if it is not already present in the vendor package:
```json
"@biomejs/biome": "latest"
```
Keep these dev dependencies:
```json
"@discordjs/docgen": "^0.11.1",
"@types/debug": "^4.1.12",
"@types/node": "^22.10.7",
"@types/ws": "^8.5.10",
"patch-package": "^8.0.0",
"tsd": "^0.32.0",
"typescript": "^5.5.4"
```
Expected: no package scripts reference `eslint`, `prettier`, `tslint`, or `dtslint`.
- [ ] **Step 2: Create vendor Biome config**
Create `vendor/discord.js-selfbot-v13/biome.json` with:
```json
{
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"files": {
"includes": ["src/**/*.js", "typings/**/*.ts", "*.json"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "always"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": false,
"style": {
"useNodejsImportProtocol": "warn"
},
"suspicious": {
"noExplicitAny": "warn"
}
}
}
}
```
Expected: vendor can run its own Biome config without relying on the root config.
- [ ] **Step 3: Remove legacy config files**
Run:
```bash
rm vendor/discord.js-selfbot-v13/.eslintrc.json vendor/discord.js-selfbot-v13/.prettierrc.json vendor/discord.js-selfbot-v13/tslint.json
```
Expected: the files are removed because no script references those tools.
- [ ] **Step 4: Verify legacy tool references are gone from package scripts**
Run:
```bash
node - <<'NODE'
const pkg = require('./vendor/discord.js-selfbot-v13/package.json');
const scripts = JSON.stringify(pkg.scripts || {});
for (const tool of ['eslint', 'prettier', 'tslint', 'dtslint']) {
if (scripts.includes(tool)) throw new Error(`legacy tool still referenced: ${tool}`);
}
console.log('legacy script references removed');
NODE
```
Expected output:
```text
legacy script references removed
```
### Task 3: Modernize runtime and dev dependency ranges with usage evidence
**Files:**
- Modify: `vendor/discord.js-selfbot-v13/package.json`
- Modify: `pnpm-lock.yaml` after root install
- [ ] **Step 1: Update used runtime dependencies to current compatible ranges**
Edit `vendor/discord.js-selfbot-v13/package.json` dependencies to these ranges unless a package manager reports a direct incompatibility during install:
```json
{
"@discordjs/builders": "^1.13.0",
"@discordjs/collection": "^2.1.1",
"@sapphire/async-queue": "^1.5.5",
"@sapphire/shapeshift": "^4.0.0",
"discord-api-types": "^0.38.38",
"fetch-cookie": "^3.1.0",
"find-process": "^2.0.0",
"otplib": "^12.0.1",
"prism-media": "^2.0.0-alpha.0",
"qrcode": "^1.5.4",
"tough-cookie": "^5.1.2",
"tree-kill": "^1.2.2",
"undici": "^7.16.0",
"werift-rtp": "^0.8.4",
"ws": "^8.20.0"
}
```
Expected: no runtime dependency is removed yet because all are currently used by source or typings.
- [ ] **Step 2: Update vendor dev dependency ranges**
Edit `vendor/discord.js-selfbot-v13/package.json` devDependencies to:
```json
{
"@biomejs/biome": "latest",
"@discordjs/docgen": "^0.11.1",
"@types/debug": "^4.1.12",
"@types/node": "^25.8.0",
"@types/ws": "^8.18.1",
"patch-package": "^8.0.1",
"tsd": "^0.33.0",
"typescript": "^5.9.3"
}
```
Expected: legacy lint/format/type-lint packages are absent.
- [ ] **Step 3: Refresh root workspace lockfile**
Run from `/mnt/code/bete`:
```bash
pnpm install
```
Expected: install completes and `pnpm-lock.yaml` updates the vendor importer dependency ranges.
- [ ] **Step 4: Verify removed dev packages are no longer vendor dependencies**
Run:
```bash
node - <<'NODE'
const pkg = require('./vendor/discord.js-selfbot-v13/package.json');
const all = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
for (const name of ['dtslint', 'eslint', 'eslint-config-prettier', 'eslint-plugin-import', 'eslint-plugin-prettier', 'prettier', 'tslint']) {
if (name in all) throw new Error(`legacy package still present: ${name}`);
}
console.log('legacy packages removed');
NODE
```
Expected output:
```text
legacy packages removed
```
### Task 4: Run vendor validation and make Biome-safe fixes
**Files:**
- Modify: `vendor/discord.js-selfbot-v13/src/**/*.js` only if Biome emits errors.
- Modify: `vendor/discord.js-selfbot-v13/typings/**/*.d.ts` only if TypeScript or tsd emits errors.
- Modify: `vendor/discord.js-selfbot-v13/biome.json` only if the configured scope is wrong.
- [ ] **Step 1: Run vendor Biome check**
Run:
```bash
pnpm --filter discord.js-selfbot-v13 run lint
```
Expected: either passes, or reports concrete Biome diagnostics in vendor files.
- [ ] **Step 2: Apply Biome formatting if lint reports formatting diagnostics**
Run only if Step 1 reports formatting diagnostics:
```bash
pnpm --filter discord.js-selfbot-v13 run format
pnpm --filter discord.js-selfbot-v13 run lint
```
Expected: formatting diagnostics are fixed. If lint still reports correctness errors, fix only the reported vendor lines without changing behavior, then rerun lint.
- [ ] **Step 3: Run vendor TypeScript/type validation**
Run:
```bash
pnpm --filter discord.js-selfbot-v13 run test:typescript
```
Expected: `tsc --noEmit && tsd` passes. If it fails due dependency type changes, fix typings or dependency ranges while preserving public API, then rerun.
- [ ] **Step 4: Run vendor test script**
Run:
```bash
pnpm --filter discord.js-selfbot-v13 run test
```
Expected: vendor test script passes. If `docs:test` fails due docgen compatibility unrelated to dependency modernization, record the error and run lint + `test:typescript` as the required validation gate.
### Task 5: Validate root workspace integration
**Files:**
- Modify: `pnpm-lock.yaml` only via pnpm.
- Read: root `package.json`, `src/**/*.ts`.
- [ ] **Step 1: Verify workspace dependency link**
Run:
```bash
pnpm list discord.js-selfbot-v13 --depth 0
```
Expected output includes:
```text
discord.js-selfbot-v13 link:vendor/discord.js-selfbot-v13
```
- [ ] **Step 2: Run root typecheck**
Run:
```bash
pnpm run typecheck
```
Expected: TypeScript exits successfully.
- [ ] **Step 3: Run root lint**
Run:
```bash
pnpm run lint
```
Expected: Biome checks root files successfully. If it scans nested generated worktrees under `.claude/worktrees`, remove only session-generated agent worktrees after confirming they are not needed, then rerun lint.
- [ ] **Step 4: Run root import smoke check**
Run:
```bash
node - <<'NODE'
const selfbot = require('discord.js-selfbot-v13');
for (const key of ['Client', 'Collection', 'WebSocket']) {
if (!(key in selfbot)) throw new Error(`missing export: ${key}`);
}
console.log('selfbot exports available');
NODE
```
Expected output:
```text
selfbot exports available
```
### Task 6: Review submodule and root diffs
**Files:**
- Review: `vendor/discord.js-selfbot-v13/package.json`
- Review: `vendor/discord.js-selfbot-v13/biome.json`
- Review: removed legacy config files
- Review: `pnpm-lock.yaml`
- Review: root submodule gitlink
- [ ] **Step 1: Review vendor status**
Run:
```bash
git -C vendor/discord.js-selfbot-v13 status --short
git -C vendor/discord.js-selfbot-v13 diff -- package.json biome.json tsconfig.json src typings .eslintrc.json .prettierrc.json tslint.json
```
Expected: vendor changes are limited to toolchain config, package metadata, lock-relevant dependency ranges, and any validation-driven source/typing fixes.
- [ ] **Step 2: Review root status**
Run:
```bash
git status --short
git diff -- package.json pnpm-workspace.yaml pnpm-lock.yaml .gitmodules docs/superpowers/specs/2026-05-15-vendor-selfbot-dependency-modernization-design.md docs/superpowers/plans/2026-05-15-vendor-selfbot-dependency-modernization.md
git diff --submodule
```
Expected: root changes include the existing submodule/workspace setup, this spec/plan, lockfile refresh, and the updated submodule gitlink. Existing unrelated `README.md` remains untouched.
- [ ] **Step 3: Do not push or commit without explicit user permission**
No commit, push, PR, or submodule remote update should run unless the user explicitly asks. If asked, commit inside the vendor submodule first, push that commit, then update the root submodule gitlink and commit root changes separately.
## Self-Review
- Spec coverage: the plan covers runtime dependency audit, Biome-only vendor toolchain, TypeScript/tsd validation, root install/typecheck/lint, and import smoke check.
- Placeholder scan: no TBD/TODO placeholders remain.
- Type consistency: all paths use `vendor/discord.js-selfbot-v13`, scripts use `biome`, `tsc`, and `tsd`, and the root dependency remains `workspace:*`.
File diff suppressed because it is too large Load Diff
@@ -1,673 +0,0 @@
# Session Full Recording Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build background full-session OGG recording generation from voice join to leave while preserving existing per-user segment recordings.
**Architecture:** Add a focused session tracker that records session timing, participants, and per-user segment references. Add a session muxer that builds timeline-offset ffmpeg filters and writes `recordings/sessions/<sessionId>/session.json` plus `full.ogg`. Wire recorder lifecycle to create a session on join, register finished human segments, and finalize in the background on stop/destroy.
**Tech Stack:** TypeScript, Vitest, Node fs/path, ffmpeg via existing `buildMuxFfmpegArgs` and `runFfmpeg`, Discord voice receiver pipeline.
---
## File Structure
- Create `src/recorder/sessionRecording.ts`: session metadata types, session tracker, mux filter builder, and session finalization function.
- Modify `src/types.ts`: add `recordingSessionId` to per-user `SegmentMetadata`.
- Modify `src/recorder/metadata.ts`: accept and write shared `recordingSessionId` into segment metadata.
- Modify `src/recorder.ts`: create session on ready, skip bots as now, register segment metadata, finalize session in background on stop/destroy.
- Create `tests/recorder/sessionRecording.test.ts`: unit tests for session tracker, mux filter, empty session, and failed mux metadata.
- Modify `tests/recorder.test.ts`: assert bot/self users do not register session participants or subscriptions; add stop finalization trigger test with injected session finalizer if needed.
---
### Task 1: Session Recording Metadata and Mux Builder
**Files:**
- Create: `src/recorder/sessionRecording.ts`
- Test: `tests/recorder/sessionRecording.test.ts`
- [ ] **Step 1: Write failing tests for session tracker and mux filter**
Create `tests/recorder/sessionRecording.test.ts`:
```ts
import { describe, expect, it, vi } from "vitest";
import {
buildSessionMuxFilter,
createRecordingSession,
finalizeRecordingSession,
} from "../../src/recorder/sessionRecording";
import type { UserMetadata } from "../../src/types";
function user(overrides: Partial<UserMetadata> = {}): UserMetadata {
return {
userId: "user-1",
username: "Alice",
tag: "Alice#0001",
displayName: "Alice",
avatarUrl: "https://example.com/avatar.png",
bot: false,
roles: [],
highestRole: null,
joinedTimestamp: null,
...overrides,
};
}
describe("sessionRecording", () => {
it("tracks participants and segment refs", () => {
const session = createRecordingSession({
guildId: "guild",
channelId: "voice",
channelName: "Voice",
startTime: 1000,
recordingsDir: "/recordings",
});
session.registerSegment({
user: user(),
oggPath: "/recordings/user-1/1500.ogg",
jsonPath: "/recordings/user-1/1500.json",
startTime: 1500,
endTime: 2500,
});
const snapshot = session.snapshot(3000);
expect(snapshot).toMatchObject({
sessionId: "guild-voice-1000",
guildId: "guild",
channelId: "voice",
channelName: "Voice",
startTime: 1000,
endTime: 3000,
durationMs: 2000,
status: "pending",
participants: [{ userId: "user-1", username: "Alice" }],
segments: [
{
userId: "user-1",
oggPath: "/recordings/user-1/1500.ogg",
jsonPath: "/recordings/user-1/1500.json",
startTime: 1500,
endTime: 2500,
offsetMs: 500,
},
],
});
});
it("builds timeline-offset ffmpeg filter", () => {
const filter = buildSessionMuxFilter([
{ startTime: 1000 },
{ startTime: 2500 },
], 1000);
expect(filter).toBe(
"[0:a]adelay=0|0[pad0];[1:a]adelay=1500|1500[pad1];[pad0][pad1]amix=inputs=2:dropout_transition=0[out]",
);
});
it("writes empty metadata without running ffmpeg", async () => {
const session = createRecordingSession({
guildId: "guild",
channelId: "voice",
channelName: "Voice",
startTime: 1000,
recordingsDir: "/recordings",
});
const writeJson = vi.fn();
const mkdir = vi.fn();
const runFfmpeg = vi.fn();
await finalizeRecordingSession(session, {
endTime: 4000,
mkdir,
writeJson,
runFfmpeg,
});
expect(runFfmpeg).not.toHaveBeenCalled();
expect(mkdir).toHaveBeenCalledWith("/recordings/sessions/guild-voice-1000");
expect(writeJson).toHaveBeenCalledWith(
"/recordings/sessions/guild-voice-1000/session.json",
expect.objectContaining({ status: "empty", durationMs: 3000 }),
);
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run:
```bash
pnpm exec vitest run tests/recorder/sessionRecording.test.ts
```
Expected: FAIL because `src/recorder/sessionRecording.ts` does not exist.
- [ ] **Step 3: Implement session tracker and mux filter**
Create `src/recorder/sessionRecording.ts`:
```ts
import fs from "node:fs";
import path from "node:path";
import { buildMuxFfmpegArgs, runFfmpeg as defaultRunFfmpeg } from "../audio/ffmpegProcess";
import type { UserMetadata } from "../types";
export type SessionRecordingStatus = "pending" | "completed" | "failed" | "empty";
export interface RecordingSessionOptions {
guildId: string;
channelId: string;
channelName: string;
startTime: number;
recordingsDir: string;
}
export interface SessionSegmentInput {
user: UserMetadata;
oggPath: string;
jsonPath: string;
startTime: number;
endTime: number;
}
export interface SessionParticipant {
userId: string;
username: string;
tag: string;
displayName: string;
avatarUrl: string;
}
export interface SessionSegmentRef {
userId: string;
oggPath: string;
jsonPath: string;
startTime: number;
endTime: number;
durationMs: number;
offsetMs: number;
}
export interface SessionRecordingMetadata {
sessionId: string;
guildId: string;
channelId: string;
channelName: string;
startTime: number;
endTime: number;
durationMs: number;
status: SessionRecordingStatus;
outputFile: string | null;
participants: SessionParticipant[];
segments: SessionSegmentRef[];
error?: string;
}
export interface RecordingSession {
readonly sessionId: string;
readonly recordingsDir: string;
readonly startTime: number;
registerSegment(input: SessionSegmentInput): void;
snapshot(endTime: number): SessionRecordingMetadata;
}
export interface FinalizeRecordingSessionDependencies {
endTime?: number;
mkdir?: (dir: string) => void;
writeJson?: (file: string, metadata: SessionRecordingMetadata) => void;
runFfmpeg?: (args: string[]) => Promise<void>;
}
export function createRecordingSession(options: RecordingSessionOptions): RecordingSession {
const sessionId = `${options.guildId}-${options.channelId}-${options.startTime}`;
const participants = new Map<string, SessionParticipant>();
const segments: SessionSegmentRef[] = [];
return {
sessionId,
recordingsDir: options.recordingsDir,
startTime: options.startTime,
registerSegment(input: SessionSegmentInput): void {
participants.set(input.user.userId, {
userId: input.user.userId,
username: input.user.username,
tag: input.user.tag,
displayName: input.user.displayName,
avatarUrl: input.user.avatarUrl,
});
segments.push({
userId: input.user.userId,
oggPath: input.oggPath,
jsonPath: input.jsonPath,
startTime: input.startTime,
endTime: input.endTime,
durationMs: input.endTime - input.startTime,
offsetMs: input.startTime - options.startTime,
});
},
snapshot(endTime: number): SessionRecordingMetadata {
return {
sessionId,
guildId: options.guildId,
channelId: options.channelId,
channelName: options.channelName,
startTime: options.startTime,
endTime,
durationMs: endTime - options.startTime,
status: "pending",
outputFile: null,
participants: Array.from(participants.values()),
segments: [...segments],
};
},
};
}
export function buildSessionMuxFilter(
segments: Array<{ startTime: number }>,
sessionStartTime: number,
): string {
const filters = segments.map((segment, index) => {
const delayMs = Math.max(0, segment.startTime - sessionStartTime);
return `[${index}:a]adelay=${delayMs}|${delayMs}[pad${index}]`;
});
const inputs = segments.map((_, index) => `[pad${index}]`).join("");
filters.push(`${inputs}amix=inputs=${segments.length}:dropout_transition=0[out]`);
return filters.join(";");
}
export async function finalizeRecordingSession(
session: RecordingSession,
dependencies: FinalizeRecordingSessionDependencies = {},
): Promise<void> {
const endTime = dependencies.endTime ?? Date.now();
const sessionDir = path.join(session.recordingsDir, "sessions", session.sessionId);
const outputFile = path.join(sessionDir, "full.ogg");
const metadataFile = path.join(sessionDir, "session.json");
const mkdir = dependencies.mkdir ?? ((dir) => fs.mkdirSync(dir, { recursive: true }));
const writeJson =
dependencies.writeJson ??
((file, metadata) => fs.writeFileSync(file, JSON.stringify(metadata, null, 2)));
const runFfmpeg = dependencies.runFfmpeg ?? defaultRunFfmpeg;
mkdir(sessionDir);
const metadata = session.snapshot(endTime);
if (metadata.segments.length === 0) {
writeJson(metadataFile, { ...metadata, status: "empty" });
return;
}
try {
await runFfmpeg(
buildMuxFfmpegArgs({
inputs: metadata.segments.map((segment) => segment.oggPath),
filter: buildSessionMuxFilter(metadata.segments, metadata.startTime),
output: outputFile,
codec: "libopus",
}),
);
writeJson(metadataFile, {
...metadata,
status: "completed",
outputFile,
});
} catch (error) {
writeJson(metadataFile, {
...metadata,
status: "failed",
error: error instanceof Error ? error.message : String(error),
});
}
}
```
- [ ] **Step 4: Run tests**
Run:
```bash
pnpm exec vitest run tests/recorder/sessionRecording.test.ts
```
Expected: PASS.
- [ ] **Step 5: Commit Task 1**
Run:
```bash
git add src/recorder/sessionRecording.ts tests/recorder/sessionRecording.test.ts
git commit -m "feat: add recording session metadata"
```
---
### Task 2: Add Shared Recording Session ID to Segment Metadata
**Files:**
- Modify: `src/types.ts`
- Modify: `src/recorder/metadata.ts`
- Test: `tests/recorder/metadata.test.ts`
- [ ] **Step 1: Write failing metadata test**
Create `tests/recorder/metadata.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { createSegmentMetadata } from "../../src/recorder/metadata";
import type { SegmentState, UserMetadata } from "../../src/types";
const user: UserMetadata = {
userId: "user-1",
username: "Alice",
tag: "Alice#0001",
displayName: "Alice",
avatarUrl: "https://example.com/avatar.png",
bot: false,
roles: [],
highestRole: null,
joinedTimestamp: null,
};
const segment = {
index: 0,
startTime: 1500,
endTime: 2500,
filename: "/recordings/user-1/1500.ogg",
jsonFilename: "/recordings/user-1/1500.json",
} as SegmentState;
describe("createSegmentMetadata", () => {
it("includes shared recording session id", () => {
const metadata = createSegmentMetadata(
user,
segment,
"user-1-1500",
"guild-voice-1000",
1000,
5000,
);
expect(metadata).toMatchObject({
sessionId: "user-1-1500",
recordingSessionId: "guild-voice-1000",
sessionStartTime: 1000,
startTime: 1500,
endTime: 2500,
});
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
pnpm exec vitest run tests/recorder/metadata.test.ts
```
Expected: FAIL because `createSegmentMetadata` does not accept `recordingSessionId` yet.
- [ ] **Step 3: Update metadata type and function signature**
Modify `src/types.ts`:
```ts
export interface SegmentMetadata extends UserMetadata {
sessionId: string;
recordingSessionId: string;
sessionStartTime: number;
segmentIndex: number;
segmentMs: number;
startTime: number;
endTime: number;
durationMs: number;
filename: string;
}
```
Modify `src/recorder/metadata.ts` function signature and return object:
```ts
export function createSegmentMetadata(
user: UserMetadata,
segment: SegmentState,
sessionId: string,
recordingSessionId: string,
sessionStartTime: number,
recordingSegmentMs: number,
): SegmentMetadata {
const endTime = segment.endTime ?? Date.now();
return {
...user,
sessionId,
recordingSessionId,
sessionStartTime,
segmentIndex: segment.index,
segmentMs: recordingSegmentMs,
startTime: segment.startTime,
endTime,
durationMs: endTime - segment.startTime,
filename: path.basename(segment.filename),
};
}
```
- [ ] **Step 4: Update existing call sites**
In `src/recorder.ts`, update the call to include `recordingSession.sessionId` after the per-user `sessionId` argument:
```ts
const metadata = createSegmentMetadata(
userMetadata,
currentSegment,
sessionId,
recordingSession.sessionId,
sessionStartTime,
config.RECORDING_SEGMENT_MS,
);
```
- [ ] **Step 5: Run metadata tests and typecheck**
Run:
```bash
pnpm exec vitest run tests/recorder/metadata.test.ts
pnpm run typecheck
```
Expected: PASS.
- [ ] **Step 6: Commit Task 2**
Run:
```bash
git add src/types.ts src/recorder/metadata.ts src/recorder.ts tests/recorder/metadata.test.ts
git commit -m "feat: tag segments with recording session"
```
---
### Task 3: Wire Session Tracking into Recorder Lifecycle
**Files:**
- Modify: `src/recorder.ts`
- Modify: `tests/recorder.test.ts`
- [ ] **Step 1: Write failing recorder lifecycle tests**
Append to `tests/recorder.test.ts`:
```ts
it("finalizes the active recording session when stopped", async () => {
const { startRecording, stopRecording } = await import("../src/recorder");
const { getVoiceConnection } = await import("@discordjs/voice");
const destroy = vi.fn();
vi.mocked(getVoiceConnection).mockReturnValue({ destroy } as never);
await startRecording({ user: { id: "self-user" } } as never, createChannel() as never);
stopRecording("guild");
await new Promise((resolve) => setImmediate(resolve));
expect(destroy).toHaveBeenCalled();
});
```
Then add a test that emits a non-bot user and asserts `subscribe` is called once, while existing self/bot tests still assert zero subscriptions.
- [ ] **Step 2: Run recorder tests to verify failure if session APIs are missing**
Run:
```bash
pnpm exec vitest run tests/recorder.test.ts
```
Expected: FAIL until recorder imports and uses session recording APIs.
- [ ] **Step 3: Add active session map and finalize helper**
Modify `src/recorder.ts` imports:
```ts
import {
createRecordingSession,
finalizeRecordingSession,
type RecordingSession,
} from "./recorder/sessionRecording";
```
Add near `recordingsDir`:
```ts
const activeRecordingSessions = new Map<string, RecordingSession>();
function finalizeActiveRecordingSession(guildId: string): void {
const session = activeRecordingSessions.get(guildId);
if (!session) return;
activeRecordingSessions.delete(guildId);
finalizeRecordingSession(session).catch((error) => {
logger.error({ error }, "Failed to finalize recording session");
});
}
```
After connection reaches ready, create and store the session:
```ts
const recordingSession = createRecordingSession({
guildId: channel.guild.id,
channelId: channel.id,
channelName: channel.name,
startTime: Date.now(),
recordingsDir,
});
activeRecordingSessions.set(channel.guild.id, recordingSession);
```
In segment finish handler, after writing per-user JSON, register the segment:
```ts
recordingSession.registerSegment({
user: userMetadata,
oggPath: currentSegment.filename,
jsonPath: currentSegment.jsonFilename,
startTime: currentSegment.startTime,
endTime: metadata.endTime,
});
```
In `stopRecording(guildId)`, call `finalizeActiveRecordingSession(guildId)` before destroying connection.
In `connection.on(VoiceConnectionStatus.Destroyed, ...)`, call `finalizeActiveRecordingSession(channel.guild.id)`.
- [ ] **Step 4: Run recorder tests and typecheck**
Run:
```bash
pnpm exec vitest run tests/recorder.test.ts tests/recorder/sessionRecording.test.ts tests/recorder/metadata.test.ts
pnpm run typecheck
```
Expected: PASS.
- [ ] **Step 5: Commit Task 3**
Run:
```bash
git add src/recorder.ts tests/recorder.test.ts
git commit -m "feat: finalize recording sessions on disconnect"
```
---
### Task 4: Final Verification
**Files:**
- All changed recorder/session files.
- [ ] **Step 1: Run recorder-focused tests**
Run:
```bash
pnpm exec vitest run tests/recorder.test.ts tests/recorder/sessionRecording.test.ts tests/recorder/metadata.test.ts tests/audio/ffmpegProcess.test.ts
```
Expected: PASS.
- [ ] **Step 2: Run full test suite**
Run:
```bash
pnpm run test
```
Expected: PASS.
- [ ] **Step 3: Run typecheck**
Run:
```bash
pnpm run typecheck
```
Expected: PASS.
- [ ] **Step 4: Run lint**
Run:
```bash
pnpm run lint
```
Expected: PASS.
- [ ] **Step 5: Check git status**
Run:
```bash
git status --short
```
Expected: only intentional implementation, spec, and plan changes are present.
```
@@ -1,361 +0,0 @@
# Robust Moderation & Test Improvements Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or the plan-runner to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Create a robust and fault-tolerant parsing mechanism for LLM moderation responses, implement image attachment capping/prioritization to prevent 400 Bad Request errors, fix floating-point/exponential Snowflake precision loss, and fix existing failing dev/streaming tests.
**Architecture:**
1. Improve parsing in `src/moderation/llmModerationClient.ts` to first try extracting JSON from Markdown blocks, then exhaustively scan start (`{`) and end (`}`) braces.
2. Group, sort, and cap image attachments to at most 8 elements, prioritizing targets over context.
3. Align existing failing tests in `tests/media/ytdlp.test.ts` and `tests/streaming/playTranscode.test.ts` to their runtime implementations.
**Tech Stack:** TypeScript, Node.js, Vitest, Pino Logger
---
### Task 1: Add Robust JSON Parsing and Capping to llmModerationClient
**Files:**
- Modify: `src/moderation/llmModerationClient.ts`
- Test: `tests/moderation/llmModerationClient.test.ts`
- [ ] **Step 1: Update parseModerationResponse and runModerationAnalysis**
We will implement `extractJson` helper and update `parseModerationResponse` to use it. We will also sort and cap the image attachments in `runModerationAnalysis`.
Modify `src/moderation/llmModerationClient.ts`:
```typescript
/**
* Helper to extract a JSON object from a potentially conversational or markdown-wrapped string.
* It first scans for markdown json code blocks, then falls back to trying all start/end brace pairs from largest to smallest.
*/
export function extractJson(content: string): any {
// 1. Try to find markdown json code blocks: ```json ... ``` or ``` ... ```
const codeBlockRegex = /```(?:json)?\s*([\s\S]*?)\s*```/g;
let match;
while ((match = codeBlockRegex.exec(content)) !== null) {
const codeContent = match[1].trim();
try {
const parsed = JSON.parse(codeContent);
if (parsed && typeof parsed === "object") {
return parsed;
}
} catch (e) {
// Continue to next code block
}
}
// 2. If no code blocks parse successfully, try scanning for {...} pairs
const openBraces: number[] = [];
const closeBraces: number[] = [];
for (let i = 0; i < content.length; i++) {
if (content[i] === "{") openBraces.push(i);
if (content[i] === "}") closeBraces.push(i);
}
// Try pairs from largest span to smallest
for (const start of openBraces) {
for (let j = closeBraces.length - 1; j >= 0; j--) {
const end = closeBraces[j];
if (end > start) {
const candidate = content.substring(start, end + 1);
try {
const parsed = JSON.parse(candidate);
if (parsed && typeof parsed === "object") {
return parsed;
}
} catch (e) {
// ignore and try next
}
}
}
}
throw new Error("No JSON object found in response");
}
```
Replace the JSON parsing block inside `parseModerationResponse` with:
```typescript
// Extract and parse JSON object
const parsed = extractJson(content);
// Validate structure
if (!parsed || typeof parsed !== "object" || !("results" in parsed)) {
throw new Error("Response missing 'results' array");
}
```
Update `runModerationAnalysis` image attachment sorting & capping:
```typescript
// Check for image attachments to support multimodal analysis
const targetIdSet = new Set(targets.map((t) => t.id));
const imageAttachments = (attachments || [])
.filter(
(att) =>
(att.uploaded_url || att.discord_url) && att.type.startsWith("image/"),
)
.sort((a, b) => {
const aIsTarget = targetIdSet.has(a.message_id) ? 1 : 0;
const bIsTarget = targetIdSet.has(b.message_id) ? 1 : 0;
if (aIsTarget !== bIsTarget) {
return bIsTarget - aIsTarget; // Target messages first
}
return b.created_at - a.created_at; // Most recent first
})
.slice(0, 8); // Cap at 8 to prevent LLM API limits (e.g. Nemotron/Omni models 8-image limit)
```
- [ ] **Step 2: Run existing tests to verify they pass**
Run: `pnpm run test tests/moderation/llmModerationClient.test.ts`
Expected: PASS
- [ ] **Step 3: Add new unit tests to verify robust JSON parsing and image capping**
Add the following tests inside `describe("parseModerationResponse", ...)` in `tests/moderation/llmModerationClient.test.ts`:
```typescript
it("extracts JSON correctly from complex conversational output with thinking blocks containing braces", () => {
const content = `Based on the messages, I will analyze them.
<thinking>
The JSON structure should be:
{
"results": [ ... ]
}
</thinking>
Here is the results array:
{
"results": [
{
"message_id": "m1",
"status": "clean",
"flags": [],
"score": 0.2,
"analysis": "Benign"
}
]
}`;
const result = parseModerationResponse(content, ["m1"]);
expect(result).toHaveLength(1);
expect(result[0].messageId).toBe("m1");
});
it("extracts JSON from markdown code block wrapping", () => {
const content = `Sure! Here is the JSON structure:
\`\`\`json
{
"results": [
{
"message_id": "m1",
"status": "clean",
"flags": [],
"score": 0.2,
"analysis": "Benign"
}
]
}
\`\`\``;
const result = parseModerationResponse(content, ["m1"]);
expect(result).toHaveLength(1);
expect(result[0].messageId).toBe("m1");
});
```
Add the following test inside `describe("runModerationAnalysis", ...)` in `tests/moderation/llmModerationClient.test.ts`:
```typescript
it("caps image attachments to 8 and prioritizes targets over context", async () => {
const mockResponse = {
choices: [
{
message: {
content: JSON.stringify({
results: [
{
message_id: "m1",
status: "clean",
flags: [],
score: 0.1,
analysis: "OK",
},
],
}),
},
},
],
};
global.fetch = vi.fn().mockImplementation((url: string) => {
if (url.includes("picser.tech") || url.includes("discord.com")) {
return Promise.resolve({
ok: true,
arrayBuffer: async () => Buffer.from("fake-bytes").buffer,
});
}
return Promise.resolve({
ok: true,
text: async () => JSON.stringify(mockResponse),
json: async () => mockResponse,
});
});
const createAttachment = (id: string, msgId: string, createdAt: number) => ({
id,
message_id: msgId,
guild_id: "guild123",
channel_id: "channel123",
thread_id: null,
user_id: "user123",
filename: `${id}.png`,
size: 500,
type: "image/png",
discord_url: `https://discord.com/${id}.png`,
uploaded_url: `https://picser.tech/${id}.png`,
upload_status: "uploaded" as const,
upload_error: null,
created_at: createdAt,
uploaded_at: createdAt,
});
// 10 attachments total (3 targets, 7 context)
const attachments = [
createAttachment("c1", "context1", 100),
createAttachment("c2", "context2", 200),
createAttachment("t1", "m1", 300), // Target 1
createAttachment("c3", "context3", 400),
createAttachment("t2", "m1", 500), // Target 2
createAttachment("c4", "context4", 600),
createAttachment("c5", "context5", 700),
createAttachment("t3", "m1", 800), // Target 3
createAttachment("c6", "context6", 900),
createAttachment("c7", "context7", 1000),
];
await runModerationAnalysis({
targets: [createMessageRecord({ id: "m1" })],
contextText: "test context",
attachments,
});
const fetchCalls = (global.fetch as any).mock.calls;
// Should download exactly 8 images (since it's capped at 8)
// Target attachments (t3, t2, t1) must be fetched, then context in descending order of created_at:
// Sorted order: t3 (800), t2 (500), t1 (300), c7 (1000), c6 (900), c5 (700), c4 (600), c3 (400)
// Excluded: c2 (200), c1 (100)
const downloadedUrls = fetchCalls
.slice(0, 8)
.map((call: any) => call[0]);
expect(downloadedUrls).toContain("https://picser.tech/t3.png");
expect(downloadedUrls).toContain("https://picser.tech/t2.png");
expect(downloadedUrls).toContain("https://picser.tech/t1.png");
expect(downloadedUrls).toContain("https://picser.tech/c7.png");
expect(downloadedUrls).not.toContain("https://picser.tech/c1.png");
});
```
- [ ] **Step 4: Run all moderation client tests**
Run: `pnpm run test tests/moderation/llmModerationClient.test.ts`
Expected: PASS
- [ ] **Step 5: Commit changes**
```bash
git add src/moderation/llmModerationClient.ts tests/moderation/llmModerationClient.test.ts
git commit -m "feat: implement robust JSON parsing and multimodal image capping"
```
---
### Task 2: Align ytdlp and playTranscode tests
**Files:**
- Modify: `tests/media/ytdlp.test.ts`
- Modify: `tests/streaming/playTranscode.test.ts`
- [ ] **Step 1: Fix ytdlp.test.ts**
Update the expected arguments in the mock assertion to expect `best[protocol^=http]/best` for video format.
Modify `tests/media/ytdlp.test.ts`:
```typescript
it("reads direct video URL", async () => {
const proc = new FakeProcess();
const spawn = vi.fn(() => proc);
const ytdlp = createYtDlp({ spawn });
const result = ytdlp.getDirectVideoUrl("https://youtu.be/video");
proc.stdout.write("https://video.example.com/stream\n");
proc.stdout.end();
proc.emit("close", 0);
await expect(result).resolves.toBe("https://video.example.com/stream");
expect(spawn).toHaveBeenCalledWith(
"yt-dlp",
[
"https://youtu.be/video",
"--get-url",
"--format",
"best[protocol^=http]/best",
"--no-playlist",
"--no-warnings",
"--quiet",
],
{ stdio: ["ignore", "pipe", "pipe"] },
);
});
```
- [ ] **Step 2: Run ytdlp unit test**
Run: `pnpm run test tests/media/ytdlp.test.ts`
Expected: PASS
- [ ] **Step 3: Fix playTranscode.test.ts**
Modify `tests/streaming/playTranscode.test.ts` to check if `readable.on` exists before calling it:
```typescript
play: vi.fn().mockImplementation(async (readable) => {
// consume a bit from readable to simulate playback
if (readable && typeof readable.on === "function") {
readable.on("data", (d: Buffer) => {});
}
// resolve after a short delay
await new Promise((r) => setTimeout(r, 5));
}),
```
- [ ] **Step 4: Run playTranscode unit test**
Run: `pnpm run test tests/streaming/playTranscode.test.ts`
Expected: PASS
- [ ] **Step 5: Commit changes**
```bash
git add tests/media/ytdlp.test.ts tests/streaming/playTranscode.test.ts
git commit -m "fix: align ytdlp and playTranscode tests with actual implementations"
```
---
### Task 3: Final Verification and Clean Build
**Files:**
- None (verification only)
- [ ] **Step 1: Run all test suites**
Run: `pnpm run test`
Expected: PASS (140/140 tests pass)
- [ ] **Step 2: Run typechecker**
Run: `pnpm run typecheck`
Expected: No type errors
- [ ] **Step 3: Run Biome linter and formatter**
Run: `pnpm run lint`
Expected: No linter errors
Run: `pnpm run format`
Expected: No formatting changes
File diff suppressed because it is too large Load Diff
@@ -1,597 +0,0 @@
# Deprecated Dependency Removal 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:** Remove deprecated packages from `pnpm-lock.yaml` where maintained replacements exist, and vendor/submodule only when no clean replacement exists.
**Architecture:** Treat dependency cleanup as package graph surgery: identify owner, change one dependency source at a time, reinstall, verify lockfile, then run project checks. Existing vendored workspaces stay source of truth for selfbot and discord-video-stream patches.
**Tech Stack:** Node.js, pnpm workspaces, TypeScript, Vitest, Biome, git submodules.
---
## File Structure
- Modify: `package.json` — root dependency versions, workspace references, `pnpm.onlyBuiltDependencies`, optional `pnpm.overrides` if needed.
- Modify: `pnpm-workspace.yaml` — workspace package list for any new submodule/vendor package.
- Modify: `pnpm-lock.yaml` — regenerated by `pnpm install` only.
- Modify: `vendor/discord-video-stream/package.json` — dev dependency on `discord.js-selfbot-v13` should use workspace package instead of registry.
- Modify: `vendor/discord.js-selfbot-v13/package.json` — replace deprecated `otplib@12` chain if compatible.
- Possibly create: `vendor/<package-name>` — only if package has no maintained replacement and strict cleanup still needs local patching.
## Deprecated Package Owners
Known current owners:
```text
drizzle-kit -> @esbuild-kit/esm-loader -> @esbuild-kit/core-utils
discord.js-selfbot-v13 -> otplib@12 -> @otplib/plugin-crypto, @otplib/plugin-thirty-two, @otplib/preset-default
@discordjs/opus -> @discordjs/node-pre-gyp -> npmlog, are-we-there-yet, gauge, rimraf@3, glob@7, inflight
better-sqlite3 -> prebuild-install
@lng2004/node-datachannel -> prebuild-install
```
## Task 1: Establish Baseline
**Files:**
- Read: `package.json`
- Read: `pnpm-workspace.yaml`
- Read: `vendor/discord-video-stream/package.json`
- Read: `vendor/discord.js-selfbot-v13/package.json`
- [ ] **Step 1: Capture current git state**
Run:
```bash
git status --short
```
Expected: includes existing intended changes for `package.json`, `pnpm-workspace.yaml`, `pnpm-lock.yaml`, `.gitmodules`, and `vendor/discord-video-stream`. Do not revert user changes.
- [ ] **Step 2: Capture dependency owners**
Run:
```bash
pnpm why discord.js-selfbot-v13 @esbuild-kit/core-utils @esbuild-kit/esm-loader @otplib/plugin-crypto @otplib/plugin-thirty-two @otplib/preset-default are-we-there-yet fs-then-native gauge inflight lodash.pick npmlog prebuild-install stream-connect test-value
```
Expected: output maps deprecated packages to direct owners. Save relevant owner names in notes for next tasks.
- [ ] **Step 3: Capture install warning baseline**
Run:
```bash
pnpm install
```
Expected: install completes. Warnings may mention deprecated transitive packages.
- [ ] **Step 4: Capture baseline checks**
Run:
```bash
pnpm run typecheck
pnpm run test
```
Expected: both pass before dependency changes. If failing, stop and report exact failures before continuing.
## Task 2: Patch `discord-video-stream` Workspace Selfbot Reference
**Files:**
- Modify: `vendor/discord-video-stream/package.json`
- Modify: `pnpm-lock.yaml`
- [ ] **Step 1: Inspect current devDependency**
Open `vendor/discord-video-stream/package.json` and find:
```json
"discord.js-selfbot-v13": "^3.7.1"
```
Expected: exists under `devDependencies`.
- [ ] **Step 2: Replace devDependency with workspace reference**
Change that entry to:
```json
"discord.js-selfbot-v13": "workspace:*"
```
Keep peer dependency unchanged:
```json
"peerDependencies": {
"discord.js-selfbot-v13": "^3.6.0"
}
```
- [ ] **Step 3: Reinstall**
Run:
```bash
pnpm install
```
Expected: lockfile uses local workspace for `discord.js-selfbot-v13` in `vendor/discord-video-stream` importer.
- [ ] **Step 4: Verify no registry selfbot fetch from discord-video-stream**
Run:
```bash
pnpm why discord.js-selfbot-v13
```
Expected: root and discord-video-stream both point to `link:vendor/discord.js-selfbot-v13` or workspace link.
- [ ] **Step 5: Run checks**
Run:
```bash
pnpm run typecheck
pnpm run test
```
Expected: both pass.
## Task 3: Upgrade or Patch `otplib` Chain in Vendored Selfbot
**Files:**
- Modify: `vendor/discord.js-selfbot-v13/package.json`
- Modify: `pnpm-lock.yaml`
- [ ] **Step 1: Locate selfbot otplib dependency**
Run:
```bash
node -e "const p=require('./vendor/discord.js-selfbot-v13/package.json'); console.log(p.dependencies?.otplib || p.devDependencies?.otplib)"
```
Expected: prints `^12.x` or `12.x`.
- [ ] **Step 2: Check latest otplib version**
Run:
```bash
npm view otplib version deprecated --json
```
Expected: latest version is not deprecated.
- [ ] **Step 3: Change selfbot dependency to otplib latest major**
In `vendor/discord.js-selfbot-v13/package.json`, replace existing `otplib` dependency value with latest non-deprecated major range. Example if latest is 13.x:
```json
"otplib": "^13.0.0"
```
Do not change package name or selfbot exports.
- [ ] **Step 4: Reinstall**
Run:
```bash
pnpm install
```
Expected: install completes. `@otplib/plugin-crypto`, `@otplib/plugin-thirty-two`, and `@otplib/preset-default` should disappear if otplib v13 no longer pulls them.
- [ ] **Step 5: Verify otplib deprecated plugins gone**
Run:
```bash
pnpm why @otplib/plugin-crypto @otplib/plugin-thirty-two @otplib/preset-default
```
Expected: no dependency path for those packages. If they remain under `otplib`, inspect latest otplib metadata and stop before vendoring otplib.
- [ ] **Step 6: Run checks**
Run:
```bash
pnpm run typecheck
pnpm run test
```
Expected: both pass. If selfbot code breaks due otplib API changes, revert only the otplib version change and report API mismatch.
## Task 4: Upgrade `drizzle-kit` to Remove `@esbuild-kit/*`
**Files:**
- Modify: `package.json`
- Modify: `pnpm-lock.yaml`
- [ ] **Step 1: Check current and latest drizzle-kit**
Run:
```bash
node -e "const p=require('./package.json'); console.log(p.devDependencies['drizzle-kit'])"
npm view drizzle-kit version deprecated --json
```
Expected: latest version is not deprecated.
- [ ] **Step 2: Update root devDependency if newer version exists**
If latest version is newer than `0.31.10`, change `package.json` devDependency:
```json
"drizzle-kit": "^<latest-version>"
```
Example:
```json
"drizzle-kit": "^0.32.0"
```
Use actual latest version from npm output.
- [ ] **Step 3: Reinstall**
Run:
```bash
pnpm install
```
Expected: install completes.
- [ ] **Step 4: Verify esbuild-kit packages gone**
Run:
```bash
pnpm why @esbuild-kit/core-utils @esbuild-kit/esm-loader
```
Expected: no dependency path. If latest `drizzle-kit` still pulls them, keep latest only if project checks pass; do not vendor `drizzle-kit` unless user confirms dev-only strictness.
- [ ] **Step 5: Run drizzle commands**
Run:
```bash
pnpm run db:generate -- --help
pnpm run db:migrate -- --help
```
Expected: commands print help or usage without crashing. Do not run actual migrations.
- [ ] **Step 6: Run checks**
Run:
```bash
pnpm run typecheck
pnpm run test
```
Expected: both pass.
## Task 5: Remove or Replace Direct `@discordjs/opus`
**Files:**
- Modify: `package.json`
- Modify: `pnpm-lock.yaml`
- Inspect: `src/recorder/decoder.ts`
- Inspect: `tests/decoder.test.ts`
- [ ] **Step 1: Find project usage of `@discordjs/opus`**
Run:
```bash
grep -R "@discordjs/opus\|OpusEncoder\|OpusDecoder" -n src tests package.json
```
Expected: usage locations show whether direct package is imported by project code or only required indirectly by `prism-media`.
- [ ] **Step 2: Check `@discordjs/voice` encryption/audio requirements**
Run:
```bash
pnpm why @discordjs/opus prism-media
```
Expected: shows `@discordjs/voice` and root dependency relationships.
- [ ] **Step 3: Test removal in package manifest**
Remove root dependency line from `package.json`:
```json
"@discordjs/opus": "^0.10.0",
```
Do not edit code yet.
- [ ] **Step 4: Reinstall**
Run:
```bash
pnpm install
```
Expected: install completes. If install or peer resolution fails, restore `@discordjs/opus` and continue to Step 8.
- [ ] **Step 5: Run decoder-specific tests**
Run:
```bash
pnpm run test -- tests/decoder.test.ts
```
Expected: tests pass or skip native opus gracefully. If tests fail because native opus is required by project behavior, restore `@discordjs/opus` and continue to Step 8.
- [ ] **Step 6: Run full checks**
Run:
```bash
pnpm run typecheck
pnpm run test
```
Expected: both pass.
- [ ] **Step 7: Verify deprecated node-pre-gyp chain gone**
Run:
```bash
pnpm why @discordjs/node-pre-gyp npmlog are-we-there-yet gauge rimraf glob inflight
```
Expected: no dependency path through `@discordjs/opus`. If gone, task complete.
- [ ] **Step 8: If `@discordjs/opus` is required, try maintained alternatives**
Run:
```bash
npm view opusscript version deprecated --json
npm view @evan/opus version deprecated --json
```
Expected: identify non-deprecated candidate. Do not switch unless package supports same runtime path used by `prism-media` or direct project imports.
- [ ] **Step 9: If no compatible maintained alternative exists, vendor decision checkpoint**
Stop and report:
```text
@discordjs/opus still required. No compatible maintained replacement verified. Next action requires cloning/vendoring smallest package owner or accepting deprecated native install chain.
```
Do not clone without user confirmation of target repository.
## Task 6: Evaluate `prebuild-install` Owners
**Files:**
- Modify: `package.json` only if safe upgrade exists
- Modify: `vendor/discord-video-stream/package.json` only if safe upgrade exists
- Modify: `pnpm-lock.yaml`
- [ ] **Step 1: Check owner versions**
Run:
```bash
npm view better-sqlite3 version deprecated --json
npm view @lng2004/node-datachannel version deprecated --json
node -e "const root=require('./package.json'); const dvs=require('./vendor/discord-video-stream/package.json'); console.log({betterSqlite3: root.dependencies['better-sqlite3'], nodeDatachannel: dvs.dependencies['@lng2004/node-datachannel']})"
```
Expected: latest versions known.
- [ ] **Step 2: Upgrade `better-sqlite3` if newer version exists**
If latest is newer than current, change `package.json`:
```json
"better-sqlite3": "^<latest-version>"
```
Use actual latest version.
- [ ] **Step 3: Upgrade `@lng2004/node-datachannel` if newer version exists**
If latest is newer than current and package name still matches discord-video-stream requirements, change `vendor/discord-video-stream/package.json`:
```json
"@lng2004/node-datachannel": "<latest-version>"
```
Use exact latest version only if upstream uses exact published builds.
- [ ] **Step 4: Reinstall**
Run:
```bash
pnpm install
```
Expected: native dependencies install or reuse existing builds successfully.
- [ ] **Step 5: Verify `prebuild-install` status**
Run:
```bash
pnpm why prebuild-install
```
Expected: either no dependency path, or only native owners remain.
- [ ] **Step 6: Run checks**
Run:
```bash
pnpm run typecheck
pnpm run test
```
Expected: both pass.
- [ ] **Step 7: If `prebuild-install` remains**
Stop and report owner paths. Do not vendor native packages unless no maintained version removes it and user explicitly wants native submodule maintenance.
## Task 7: Final Deprecation Audit
**Files:**
- Modify: `pnpm-lock.yaml` through install only
- [ ] **Step 1: Run clean install audit**
Run:
```bash
pnpm install
```
Expected: no deprecated package warnings where maintained replacements were applied.
- [ ] **Step 2: Run owner query for all known deprecated names**
Run:
```bash
pnpm why discord.js-selfbot-v13 @esbuild-kit/core-utils @esbuild-kit/esm-loader @otplib/plugin-crypto @otplib/plugin-thirty-two @otplib/preset-default are-we-there-yet fs-then-native gauge inflight lodash.pick npmlog prebuild-install stream-connect test-value @discordjs/node-pre-gyp
```
Expected: no paths for packages removed by prior tasks. Remaining paths must be only approved unavoidable native/package-owner cases.
- [ ] **Step 3: Check npm deprecation metadata for remaining suspect packages**
Run:
```bash
node - <<'NODE'
const {execFileSync}=require('child_process');
const pkgs=['discord.js-selfbot-v13','@esbuild-kit/core-utils','@esbuild-kit/esm-loader','@otplib/plugin-crypto','@otplib/plugin-thirty-two','@otplib/preset-default','are-we-there-yet','fs-then-native','gauge','inflight','lodash.pick','npmlog','prebuild-install','stream-connect','test-value','@discordjs/node-pre-gyp'];
for (const p of pkgs) {
try {
const out=execFileSync('npm',['view',p,'deprecated','--json'],{encoding:'utf8'}).trim();
if (out && out !== 'null') console.log(`${p}: ${JSON.parse(out)}`);
} catch {
console.log(`${p}: npm view failed`);
}
}
NODE
```
Expected: command prints metadata only. Compare printed package names with `pnpm why` output.
- [ ] **Step 4: Run final checks**
Run:
```bash
pnpm run typecheck
pnpm run test
pnpm run lint
```
Expected: all pass.
## Task 8: Document Remaining Unavoidable Deprecated Packages
**Files:**
- Modify: `docs/superpowers/specs/2026-05-19-deprecated-dependency-removal-design.md`
- [ ] **Step 1: If no deprecated packages remain, append success note**
Append this section:
```markdown
## Final Audit Result
All known deprecated packages from the initial audit were removed from the active pnpm dependency graph.
```
- [ ] **Step 2: If deprecated packages remain, append owner note**
Append this section with actual owner paths from `pnpm why`:
```markdown
## Final Audit Result
Remaining deprecated packages after maintained upgrade attempts:
- `<package>` remains via `<owner path>`. Reason: `<no maintained replacement found | native install chain still used by latest upstream | user-approved vendored package>`.
These are candidates for future vendoring/submodule patching if strict lockfile cleanup remains required.
```
- [ ] **Step 3: Run final git diff review**
Run:
```bash
git diff -- package.json pnpm-workspace.yaml pnpm-lock.yaml vendor/discord-video-stream/package.json vendor/discord.js-selfbot-v13/package.json docs/superpowers/specs/2026-05-19-deprecated-dependency-removal-design.md
```
Expected: diff contains only dependency migration changes and audit note.
## Task 9: Commit Checkpoint Only If User Requests Commit
**Files:**
- Stage only changed package/spec/submodule files relevant to dependency cleanup.
- [ ] **Step 1: Show status**
Run:
```bash
git status --short
```
Expected: changed files match work done.
- [ ] **Step 2: Ask before committing**
Ask user whether to commit. Do not commit unless explicitly requested.
- [ ] **Step 3: If user asks to commit, create commit**
Use exact changed file list, not `git add -A`. Commit message:
```bash
git commit -m "$(cat <<'EOF'
chore: remove deprecated dependency graph entries
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```
Expected: commit succeeds without bypassing hooks.
## Self-Review
- Spec coverage: plan covers replace-first, workspace patches, submodule/vendor checkpoint, verification, and final audit documentation.
- Placeholder scan: no TBD/TODO placeholders; steps include exact commands and expected outcomes.
- Type consistency: paths and package names match current workspace files and dependency owners.
@@ -1,652 +0,0 @@
# Winston Logging Refactor Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace Pino logging with Winston, normalize log levels, and write readable console plus JSON file logs without changing application behavior.
**Architecture:** Keep `src/logger.ts` as the single logging entry point. Rebuild it around one Winston logger with child context support, centralized metadata/error serialization, colorized console output, and JSON file transports. Keep call-site changes minimal and only adjust imports/usages that fail after the backend swap.
**Tech Stack:** TypeScript, Node.js ESM, Winston, Zod config validation, pnpm, Vitest, Biome.
---
## File Structure
- Modify: `package.json` — replace Pino dependencies with Winston.
- Modify: `pnpm-lock.yaml` — update via pnpm after dependency changes.
- Modify: `src/config.ts` — expand `LOG_LEVEL` enum to Winston npm levels.
- Modify: `src/logger.ts` — replace Pino implementation with Winston implementation.
- Modify: `.gitignore` — ignore runtime `logs/` directory.
- Test: `tests/logger.test.ts` — add focused logger behavior tests for level validation, error serialization, and file output format helpers.
- Inspect and modify only if needed: files importing `createChildLogger` or `logger`.
## Important Git Note
The prior design-spec commit unexpectedly included vendor submodule entries that were already staged in the working tree. Before making implementation commits, run `git status --short` and do not stage unrelated vendor or existing user changes. Stage only files touched by this plan.
---
### Task 1: Add Logger Tests Before Migration
**Files:**
- Create: `tests/logger.test.ts`
- Modify: none
- [ ] **Step 1: Write tests for Winston-compatible logger behavior**
Create `tests/logger.test.ts` with this content:
```ts
import { describe, expect, it } from "vitest";
import { formatLogMetadataForTest, serializeLogValueForTest } from "../src/logger";
class TestError extends Error {
public code = "TEST_CODE";
public statusCode = 418;
constructor() {
super("test failure");
this.name = "TestError";
}
}
describe("logger serialization", () => {
it("serializes Error values with stable fields", () => {
const serialized = serializeLogValueForTest(new TestError());
expect(serialized).toMatchObject({
name: "TestError",
message: "test failure",
code: "TEST_CODE",
statusCode: 418,
});
expect(serialized).toHaveProperty("stack");
});
it("serializes nested error metadata keys", () => {
const error = new TestError();
expect(formatLogMetadataForTest({ error, err: error, reason: error })).toMatchObject({
error: {
name: "TestError",
message: "test failure",
code: "TEST_CODE",
statusCode: 418,
},
err: {
name: "TestError",
message: "test failure",
code: "TEST_CODE",
statusCode: 418,
},
reason: {
name: "TestError",
message: "test failure",
code: "TEST_CODE",
statusCode: 418,
},
});
});
it("preserves plain metadata", () => {
expect(
formatLogMetadataForTest({ context: "bot", signal: "SIGINT", count: 2 }),
).toEqual({ context: "bot", signal: "SIGINT", count: 2 });
});
});
```
- [ ] **Step 2: Run test to verify current implementation fails**
Run:
```bash
pnpm vitest run tests/logger.test.ts
```
Expected: FAIL because `formatLogMetadataForTest` and `serializeLogValueForTest` are not exported from `src/logger.ts` yet.
- [ ] **Step 3: Commit failing tests only**
```bash
git add tests/logger.test.ts
git commit -m "test: cover logger metadata serialization"
```
Expected: commit contains only `tests/logger.test.ts`.
---
### Task 2: Update Dependencies and Config Schema
**Files:**
- Modify: `package.json`
- Modify: `pnpm-lock.yaml`
- Modify: `src/config.ts`
- [ ] **Step 1: Update package dependencies**
Run:
```bash
pnpm remove pino pino-http pino-pretty
pnpm add winston
```
Expected:
- `package.json` dependencies no longer include `pino` or `pino-http`.
- `package.json` devDependencies no longer include `pino-pretty`.
- `package.json` dependencies include `winston`.
- `pnpm-lock.yaml` updates accordingly.
- [ ] **Step 2: Expand `LOG_LEVEL` validation**
In `src/config.ts`, replace the current `LOG_LEVEL` line:
```ts
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
```
with:
```ts
LOG_LEVEL: z
.enum(["error", "warn", "info", "http", "verbose", "debug", "silly"])
.default("info"),
```
- [ ] **Step 3: Run targeted config tests**
Run:
```bash
pnpm vitest run tests/config.test.ts
```
Expected: PASS.
- [ ] **Step 4: Commit dependency and config changes**
```bash
git add package.json pnpm-lock.yaml src/config.ts
git commit -m "chore: switch logging dependency to winston"
```
Expected: commit contains only dependency files and `src/config.ts`.
---
### Task 3: Replace `src/logger.ts` With Winston Implementation
**Files:**
- Modify: `src/logger.ts`
- [ ] **Step 1: Replace logger implementation**
Replace entire `src/logger.ts` with:
```ts
import fs from "node:fs";
import path from "node:path";
import winston from "winston";
const isDev = process.env.NODE_ENV !== "production";
const logLevel = process.env.LOG_LEVEL || (isDev ? "debug" : "info");
const logsDir = path.resolve(process.cwd(), "logs");
fs.mkdirSync(logsDir, { recursive: true });
type LogMetadata = Record<string, unknown>;
type SerializedError = {
name: string;
message: string;
stack?: string;
code?: unknown;
statusCode?: unknown;
} & Record<string, unknown>;
const serializeError = (error: Error): SerializedError => {
const serialized: SerializedError = {
name: error.name,
message: error.message,
};
if (error.stack) {
serialized.stack = error.stack;
}
const errorWithFields = error as Error & {
code?: unknown;
statusCode?: unknown;
[key: string]: unknown;
};
if (errorWithFields.code !== undefined) {
serialized.code = errorWithFields.code;
}
if (errorWithFields.statusCode !== undefined) {
serialized.statusCode = errorWithFields.statusCode;
}
for (const [key, value] of Object.entries(errorWithFields)) {
if (serialized[key] === undefined) {
serialized[key] = value;
}
}
return serialized;
};
const serializeLogValue = (value: unknown): unknown => {
if (value instanceof Error) {
return serializeError(value);
}
if (Array.isArray(value)) {
return value.map(serializeLogValue);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, nestedValue]) => [
key,
serializeLogValue(nestedValue),
]),
);
}
return value;
};
const formatLogMetadata = (metadata: LogMetadata): LogMetadata => {
return Object.fromEntries(
Object.entries(metadata).map(([key, value]) => [key, serializeLogValue(value)]),
);
};
const metadataFormat = winston.format((info) => {
const { level, message, timestamp, ...metadata } = info;
return {
level,
message,
timestamp,
...formatLogMetadata(metadata),
};
});
const consoleFormat = winston.format.printf((info) => {
const { level, message, timestamp, context, ...metadata } = info;
const contextLabel = context ? ` [${String(context)}]` : "";
const metadataText = Object.keys(metadata).length
? ` ${JSON.stringify(metadata)}`
: "";
return `${timestamp} ${level}${contextLabel}: ${message}${metadataText}`;
});
export const logger = winston.createLogger({
level: logLevel,
levels: winston.config.npm.levels,
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
metadataFormat(),
),
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.timestamp(),
metadataFormat(),
consoleFormat,
),
}),
new winston.transports.File({
filename: path.join(logsDir, "app.log"),
format: winston.format.json(),
}),
new winston.transports.File({
filename: path.join(logsDir, "error.log"),
level: "error",
format: winston.format.json(),
}),
],
});
export const createChildLogger = (context: string) => {
return logger.child({ context });
};
export const serializeLogValueForTest = serializeLogValue;
export const formatLogMetadataForTest = formatLogMetadata;
```
- [ ] **Step 2: Run logger tests**
Run:
```bash
pnpm vitest run tests/logger.test.ts
```
Expected: PASS.
- [ ] **Step 3: Run TypeScript check**
Run:
```bash
pnpm run typecheck
```
Expected: either PASS or type errors only from logger call sites needing Winston-compatible adjustments.
- [ ] **Step 4: Commit Winston logger implementation**
If typecheck passes:
```bash
git add src/logger.ts
git commit -m "refactor: replace pino logger with winston"
```
Expected: commit contains only `src/logger.ts`.
If typecheck fails in logger call sites, do not commit yet. Continue to Task 4, then commit `src/logger.ts` together with required call-site fixes.
---
### Task 4: Fix Logger Call Sites Only If Needed
**Files:**
- Inspect: `src/**/*.ts`
- Modify only files with TypeScript errors from Task 3.
- [ ] **Step 1: Find logger imports and usages**
Run:
```bash
grep -rn "createChildLogger\|from \"./logger\"\|from \"../logger\"\|logger\.\|log\." src --include="*.ts"
```
Expected: list of logger imports and method calls.
- [ ] **Step 2: Fix pino-style calls if TypeScript requires it**
Use this mapping only where needed:
Before:
```ts
logger.info({ signal }, "Graceful shutdown initiated");
```
After:
```ts
logger.info("Graceful shutdown initiated", { signal });
```
Before:
```ts
logger.error({ error }, "Failed to initialize app");
```
After:
```ts
logger.error("Failed to initialize app", { error });
```
Before:
```ts
logger.warn({ error }, "Backlog sync failed");
```
After:
```ts
logger.warn("Backlog sync failed", { error });
```
Plain string calls remain unchanged:
```ts
logger.info("Creating Discord client");
logger.debug("Queue empty, no playback started");
```
- [ ] **Step 3: Re-run TypeScript check**
Run:
```bash
pnpm run typecheck
```
Expected: PASS.
- [ ] **Step 4: Commit call-site fixes**
```bash
git add src/logger.ts src/index.ts src/webserver.ts src/middleware.ts src/voiceController.ts src/media/mediaController.ts src/media/screenShareController.ts src/moderation/broadcaster.ts src/moderation/messageCapture.ts src/streaming/transcoder.ts
git commit -m "refactor: normalize logger call sites for winston"
```
Expected: commit stages only files actually changed. If some listed files are unchanged, `git add` is harmless.
---
### Task 5: Ignore Runtime Log Files
**Files:**
- Modify: `.gitignore`
- [ ] **Step 1: Check whether `logs/` is already ignored**
Run:
```bash
grep -n "^logs/$" .gitignore || true
```
Expected: either one matching line or no output.
- [ ] **Step 2: Add `logs/` if missing**
If no output from Step 1, append:
```gitignore
logs/
```
- [ ] **Step 3: Verify ignore rule**
Run:
```bash
git check-ignore logs/app.log
```
Expected output:
```text
logs/app.log
```
- [ ] **Step 4: Commit ignore rule**
```bash
git add .gitignore
git commit -m "chore: ignore runtime log files"
```
Expected: commit contains only `.gitignore`. If `.gitignore` already ignored `logs/`, skip this commit.
---
### Task 6: Verify No Pino Usage Remains
**Files:**
- Inspect: repository source and package files
- [ ] **Step 1: Search for Pino imports/usages**
Run:
```bash
grep -rn "pino\|pino-http\|pino-pretty" src package.json pnpm-lock.yaml --exclude-dir=node_modules || true
```
Expected: no output. If output appears only in old comments/docs outside runtime files, remove or update those comments/docs if they are part of the changed scope.
- [ ] **Step 2: Search for Winston dependency**
Run:
```bash
grep -n '"winston"' package.json
```
Expected output contains one `winston` dependency line.
- [ ] **Step 3: Run focused tests**
Run:
```bash
pnpm vitest run tests/logger.test.ts tests/config.test.ts
```
Expected: PASS.
- [ ] **Step 4: Commit cleanup if any files changed**
```bash
git status --short
git add <only-files-changed-by-this-task>
git commit -m "chore: remove remaining pino references"
```
Expected: commit only if Step 1 required changes.
---
### Task 7: Runtime Log Output Verification
**Files:**
- No source changes expected
- [ ] **Step 1: Run one-shot logger command**
Run:
```bash
node --import tsx -e 'import { logger } from "./src/logger.ts"; logger.info("logger smoke info", { context: "smoke", value: 1 }); logger.error("logger smoke error", { error: new Error("smoke failure") }); await new Promise((resolve) => setTimeout(resolve, 250));'
```
Expected:
- Console shows readable timestamped logs.
- Info line includes `smoke` context metadata.
- Error line includes serialized error metadata.
- [ ] **Step 2: Verify app log file exists and contains JSON**
Run:
```bash
test -s logs/app.log && node -e 'const fs = require("fs"); const line = fs.readFileSync("logs/app.log", "utf8").trim().split("\n").at(-1); const parsed = JSON.parse(line); if (!parsed.level || !parsed.message) process.exit(1); console.log(parsed.level + ":" + parsed.message);'
```
Expected output includes:
```text
error:logger smoke error
```
- [ ] **Step 3: Verify error log file exists and contains error JSON**
Run:
```bash
test -s logs/error.log && node -e 'const fs = require("fs"); const line = fs.readFileSync("logs/error.log", "utf8").trim().split("\n").at(-1); const parsed = JSON.parse(line); if (parsed.level !== "error") process.exit(1); console.log(parsed.level + ":" + parsed.message);'
```
Expected output:
```text
error:logger smoke error
```
---
### Task 8: Full Validation
**Files:**
- No source changes expected unless validation reveals issues.
- [ ] **Step 1: Run typecheck**
Run:
```bash
pnpm run typecheck
```
Expected: PASS.
- [ ] **Step 2: Run test suite**
Run:
```bash
pnpm run test
```
Expected: PASS.
- [ ] **Step 3: Run lint**
Run:
```bash
pnpm run lint
```
Expected: PASS.
- [ ] **Step 4: Inspect final git status**
Run:
```bash
git status --short
```
Expected: no uncommitted changes from this plan except ignored `logs/` files. Existing unrelated user changes may still appear; do not stage them.
- [ ] **Step 5: Final commit if validation fixes were needed**
If validation required fixes:
```bash
git add <only-files-changed-by-validation-fixes>
git commit -m "fix: stabilize winston logging migration"
```
Expected: commit contains only validation fixes.
---
## Self-Review Notes
- Spec coverage: dependency swap, `LOG_LEVEL`, centralized logger, console/file output, error serialization, Pino removal, and validation all have tasks.
- Placeholder scan: no TBD/TODO/fill-in placeholders remain; `<only-files-changed-by-this-task>` and similar are explicit safety instructions to avoid staging unrelated user changes.
- Type consistency: test helper names match `src/logger.ts` exports; Winston npm levels match `src/config.ts`; file names match spec.
File diff suppressed because it is too large Load Diff
@@ -1,102 +0,0 @@
# Phase 4: Live/Voice Feature — 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.
**Goal:** Implement the Live panel — voice connection, audio visualization, music/screen playback, recordings, PCM streaming, and mic transmit.
**Architecture:** Canvas 2D via `web-sys::CanvasRenderingContext2d` for real-time audio vis. `web_sys::AudioContext` + `AudioBufferSourceNode` for PCM playback. `web_sys::MediaDevices::get_user_media` for mic capture. WS binary frames parsed from `[u32 userId][i16 samples]` format.
**Tech Stack:** Leptos 0.7 CSR, `web-sys` (Canvas, AudioContext, MediaDevices), `wasm-bindgen`, `js-sys`.
## Global Constraints
- Branch: `leptos-rewrite` at `/mnt/code/bete/.worktrees/leptos-rewrite/`
- Use `use leptos::prelude::*;` (NOT `use leptos::*;`)
- Canvas elements use `web-sys` not Leptos abstractions
- Binary WS frames: `DataView` on `ArrayBuffer` received in `WsContext.on_binary` callback
- No new heavy dependencies (avoid `cpal`, `rodio` — these don't compile to WASM)
---
## File Structure
```
services/frontend-leptos/frontend/src/
└── features/
└── live/
├── mod.rs # LivePanel (composition)
├── components/
│ ├── mod.rs
│ ├── voice_connection_card.rs # Guild/channel select + connect/disconnect
│ ├── active_speakers.rs # Speaking users list
│ ├── audio_visualizer.rs # Canvas 32-bar viz
│ ├── mic_level_meter.rs # Horizontal level bar
│ ├── now_playing.rs # Current media + queue
│ ├── music_sub_panel.rs # Music playlist controls
│ ├── screen_sub_panel.rs # Screenshare controls
│ ├── recordings_sub_panel.rs # Voice recordings list
│ └── waveform_player.rs # Canvas + AudioContext player
├── hooks/
│ ├── mod.rs
│ ├── use_voice_control.rs # Voice connection commands
│ ├── use_media_control.rs # Media player commands
│ ├── use_audio_playback.rs # PCM → AudioContext pipeline
│ └── use_audio_transmit.rs # Mic → WS binary frames
└── audio/
├── mod.rs
├── pcm_decoder.rs # Opus/PCM decode logic
└── ring_buffer.rs # Audio ring buffer for streaming
```
**Files to modify:**
- `services/frontend-leptos/frontend/src/lib.rs` — add `pub mod features;` (already done, add `pub mod live;`)
- `services/frontend-leptos/frontend/src/app.rs` — wire `LivePanel` as tab content
- `services/frontend-leptos/frontend/src/app.css` — add live-panel CSS classes
- `services/frontend-leptos/frontend/Cargo.toml` — add any new web-sys features
---
### Task 1: Module skeleton + voice control hook
- Create: `features/live/{mod.rs, components/mod.rs, hooks/mod.rs}`
- Create: `features/live/hooks/use_voice_control.rs` — guilds, channels, connect/disconnect
- Create: `features/live/hooks/use_media_control.rs` — queue, skip, stop, volume
- Modify: `lib.rs` — add `pub mod live;`
### Task 2: VoiceConnectionCard + ActiveSpeakers
- Create: `features/live/components/voice_connection_card.rs`
- Create: `features/live/components/active_speakers.rs`
- Port from React: guild selector, voice channel selector, join/disconnect buttons, speaker list
### Task 3: AudioVisualizer + MicLevelMeter
- Create: `features/live/components/audio_visualizer.rs` — Canvas 32-bar frequency vis
- Create: `features/live/components/mic_level_meter.rs` — 0-100% bar
- Key: `CanvasRenderingContext2d::fill_rect` per frame via `requestAnimationFrame`
### Task 4: Music + Screen sub-panels
- Create: `features/live/components/now_playing.rs`
- Create: `features/live/components/music_sub_panel.rs`
- Create: `features/live/components/screen_sub_panel.rs`
- Port from React: queue display, volume slider, URL inputs, control buttons
### Task 5: RecordingsSubPanel + WaveformPlayer
- Create: `features/live/components/recordings_sub_panel.rs` — paginated recordings list
- Create: `features/live/components/waveform_player.rs` — Canvas waveform + AudioContext
- Key: `AudioContext::decode_audio_data` for fetch-playback
### Task 6: PCM audio playback + mic transmit
- Create: `features/live/hooks/use_audio_playback.rs` — binary WS frames → AudioContext
- Create: `features/live/hooks/use_audio_transmit.rs` — getUserMedia → WS
- Create: `features/live/audio/pcm_decoder.rs`
- Create: `features/live/audio/ring_buffer.rs`
### Task 7: LivePanel composition + wiring
- Rewrite: `features/live/mod.rs` — assemble all components
- Modify: `app.rs` — wire LivePanel, add CSS classes
- Test: `cargo check` + `trunk build --release`
@@ -1,556 +0,0 @@
# Phase 3: Messages Feature — 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:** Implement the complete Messages panel — message list with infinite scroll, user-grouped cards, Discord emoji rendering, AI status filtering, full-text search, image grid, reanalyze actions, and WS-driven real-time updates.
**Architecture:** Leptos signals + Resources for state and data fetching. `web-sys` `IntersectionObserver` for infinite scroll sentinel. `ws::context::WsContext` drives real-time message_created/updated/deleted/analyzed events. Components follow the existing `ui/*` + `layout/*` patterns. CSS classes defined in Phase 2's `app.css`.
**Tech Stack:** Leptos 0.7 CSR, `gloo-net` fetch, `web-sys` (IntersectionObserver, console), `serde`/`serde_json`, `lucide-leptos` icons, plain CSS.
## Global Constraints
- Branch: `leptos-rewrite` at `/mnt/code/bete/.worktrees/leptos-rewrite/`
- Use `use leptos::prelude::*;` (NOT `use leptos::*;`)
- Combine duplicate `class=` attributes into single `format!()` strings
- `#[serde(rename_all = "camelCase")]` on structs where backend sends camelCase
- All timestamps are `i64` (millis since epoch), not strings
- Rust types must match the JSON wire format from backend exactly
---
## File Structure
```
services/frontend-leptos/frontend/src/
├── features/
│ ├── mod.rs # mod messages;
│ └── messages/
│ ├── mod.rs # MessagesPanel component
│ ├── components/
│ │ ├── mod.rs # mod message_feed message_card image_grid;
│ │ ├── message_feed.rs # Infinite scroll, user grouping
│ │ ├── message_card.rs # User-grouped message card
│ │ └── image_grid.rs # Masonry-like image grid
│ └── hooks/
│ ├── mod.rs # mod use_messages;
│ └── use_messages.rs # Data fetching, pagination, reanalyze
```
**Files to modify:**
- `services/frontend-leptos/frontend/src/lib.rs` — add `pub mod features;`
- `services/frontend-leptos/frontend/src/app.rs` — wire `MessagesPanel` as default tab content
- `services/frontend-leptos/frontend/src/app.css` — add message-specific CSS classes
**Files to create (8 new):**
- `services/frontend-leptos/frontend/src/features/mod.rs`
- `services/frontend-leptos/frontend/src/features/messages/mod.rs`
- `services/frontend-leptos/frontend/src/features/messages/components/mod.rs`
- `services/frontend-leptos/frontend/src/features/messages/components/message_feed.rs`
- `services/frontend-leptos/frontend/src/features/messages/components/message_card.rs`
- `services/frontend-leptos/frontend/src/features/messages/components/image_grid.rs`
- `services/frontend-leptos/frontend/src/features/messages/hooks/mod.rs`
- `services/frontend-leptos/frontend/src/features/messages/hooks/use_messages.rs`
---
### Task 1: Module skeleton + use_messages hook
**Files:**
- Create: `services/frontend-leptos/frontend/src/features/mod.rs`
- Create: `services/frontend-leptos/frontend/src/features/messages/mod.rs`
- Create: `services/frontend-leptos/frontend/src/features/messages/components/mod.rs`
- Create: `services/frontend-leptos/frontend/src/features/messages/hooks/mod.rs`
- Create: `services/frontend-leptos/frontend/src/features/messages/hooks/use_messages.rs`
- Modify: `services/frontend-leptos/frontend/src/lib.rs` — add `pub mod features;`
- Modify: `services/frontend-leptos/frontend/src/features/messages/mod.rs` — add placeholder `MessagesPanel` component
**Interfaces:**
- Consumes: `shared_types::message::{MessageRecord, PageResult}` from shared-types crate, `api::messages::{get_messages, search_messages, reanalyze_message, reanalyze_batch}` from API client
- Produces: `MessagesState` struct returned by `use_messages()` that later tasks wire into `MessagesPanel`
**Step 1: Create module files**
`services/frontend-leptos/frontend/src/features/mod.rs`:
```rust
pub mod messages;
```
`services/frontend-leptos/frontend/src/features/messages/hooks/mod.rs`:
```rust
pub mod use_messages;
```
`services/frontend-leptos/frontend/src/features/messages/components/mod.rs`:
```rust
pub mod message_feed;
pub mod message_card;
pub mod image_grid;
```
`services/frontend-leptos/frontend/src/features/messages/mod.rs` (placeholder for now):
```rust
use leptos::prelude::*;
pub mod components;
pub mod hooks;
#[component]
pub fn MessagesPanel() -> impl IntoView {
view! {
<div>"Messages Panel (loading...)"</div>
}
}
```
**Step 2: Create use_messages hook**
`services/frontend-leptos/frontend/src/features/messages/hooks/use_messages.rs`:
This hook ports the React `useMessages` logic. It manages:
- `messages: RwSignal<Vec<MessageRecord>>` — the message list, sorted by created_at desc
- `loading: Signal<bool>` — loading state
- `loading_more: RwSignal<bool>` — infinite scroll loading
- `cursor: RwSignal<Option<String>>` — pagination cursor
- `has_more: Signal<bool>` — derived from cursor
- `error: RwSignal<Option<String>>` — last error message
- `current_guild: RwSignal<Option<String>>` — current guild ID
- `fetch_messages(guild_id)` — initial fetch (replaces messages)
- `load_more()` — append next page
- `reanalyze(id)` — optimistic status flip + API call + revert on failure
- `reanalyze_all_errors()` — batch reanalyze error messages
The `mergeMessages` function ported from React:
```rust
pub fn merge_messages(current: &[MessageRecord], incoming: &[MessageRecord]) -> Vec<MessageRecord> {
let mut by_id: std::collections::HashMap<&str, &MessageRecord> = current.iter().map(|m| (m.id.as_str(), m)).collect();
for msg in incoming {
by_id.insert(msg.id.as_str(), msg);
}
let mut merged: Vec<MessageRecord> = by_id.into_values().cloned().collect();
merged.sort_by(|a, b| b.created_at.cmp(&a.created_at).then_with(|| b.id.cmp(&a.id)));
merged
}
```
**Step 3: Verify compilation**
Run: `cargo check --manifest-path services/frontend-leptos/Cargo.toml` from worktree root.
**Step 4: Commit**
```bash
git add services/frontend-leptos/frontend/src/features/ services/frontend-leptos/frontend/src/lib.rs
git commit -m "feat(leptos): messages module skeleton + use_messages hook"
```
---
### Task 2: MessageFeed + message grouping
**Files:**
- Create: `services/frontend-leptos/frontend/src/features/messages/components/message_feed.rs`
- Modify: `services/frontend-leptos/frontend/src/features/messages/components/mod.rs` — already done in Task 1
**Interfaces:**
- Consumes: `MessageRecord` from shared-types, `message_card::MessageCard` and `message_card::MessageCardSkeleton` from next task
- Produces: `MessageFeed` component with props: `messages: Vec<MessageRecord>`, `on_reanalyze: Callback<String>`, `empty_text: String`, `has_more: bool`, `loading_more: bool`, `on_load_more: Callback<()>`, `loading: bool`
**Key logic (ported from React `MessageFeed`):**
Group messages by same user within 5-minute window:
```rust
const GROUP_WINDOW_MS: i64 = 5 * 60 * 1000;
fn group_messages(messages: &[MessageRecord]) -> Vec<Vec<MessageRecord>> {
let mut groups: Vec<Vec<MessageRecord>> = Vec::new();
for msg in messages {
if let Some(last_group) = groups.last_mut() {
let same_user = last_group.first().map(|m| m.user_id == msg.user_id).unwrap_or(false);
let same_window = last_group.last().map(|m| (m.created_at - msg.created_at).abs() < GROUP_WINDOW_MS).unwrap_or(false);
if same_user && same_window {
last_group.push(msg.clone());
continue;
}
}
groups.push(vec![msg.clone()]);
}
groups
}
```
**Infinite scroll sentinel (IntersectionObserver):**
In Leptos, use `web_sys::IntersectionObserver` via `create_effect` + `on_cleanup`. Create a sentinel `<div>` at the bottom of the list. When it intersects, call `on_load_more`.
```rust
let sentinel_ref = create_node_ref::<html::Div>();
create_effect(move |_| {
let Some(node) = sentinel_ref.get() else { return };
let callback = Closure::<dyn Fn(Vec<js_sys::Object>)>::new(move |entries: Vec<js_sys::Object>| {
// entries.is_intersecting → call on_load_more
});
let observer = IntersectionObserver::new(callback.as_ref().unchecked_ref()).unwrap();
observer.observe(&node);
// cleanup: observer.disconnect() on on_cleanup
});
```
**Step 1: Create message_feed.rs** with grouping + sentinel + skeleton
**Step 2: Verify compilation**
**Step 3: Commit**
```bash
git commit -m "feat(leptos): MessageFeed with user grouping and infinite scroll"
```
---
### Task 3: MessageCard — message rendering
**Files:**
- Create: `services/frontend-leptos/frontend/src/features/messages/components/message_card.rs`
**Interfaces:**
- Consumes: `MessageRecord`, `api::messages::get_message_detail` (for replied-to messages), `ui::status_badge::StatusBadge`
- Produces: `MessageCard` component (props: `messages: Vec<MessageRecord>`, `on_reanalyze: Callback<String>`), `MessageCardSkeleton` component
**Key logic (ported from React `MessageCard.tsx`, 561 lines):**
1. **Header:** Avatar (img from `avatar_url`), username, channel/thread info, time ago
2. **Content:** Render message with custom Discord emoji substitution (`<a:name:id>` → CDN `<img>`)
3. **Edit/delete indicators:** Pencil icon if `edited_at` present, Trash2 icon if `deleted_at` present
4. **Attachments:** Images, videos, other files with download links
5. **Sticker:** Display sticker image if metadata contains stickers
6. **Embeds:** Show title + thumbnail from metadata embeds
7. **Reply context:** If `metadata.channel.thread_id` present, fetch the parent message
8. **AI analysis box:** StatusBadge, severity badge, confidence bar, categories, recommended action, error message, reanalyze button
**Discord custom emoji regex:**
```rust
use regex::Regex;
fn render_content(content: &str) -> Vec<HtmlElement> {
// <a:name:id> → animated GIF
// <:name:id> → static PNG
// URL: https://cdn.discordapp.com/emojis/{id}.{ext}?size=128
}
```
Note: `regex` crate needs to be added to `Cargo.toml` as a dependency.
**Step 1: Add `regex = "1"` to frontend Cargo.toml**
**Step 2: Create message_card.rs** with all sub-sections
**Step 3: Verify compilation**
**Step 4: Commit**
```bash
git commit -m "feat(leptos): MessageCard with Discord emoji, attachments, AI analysis display"
```
---
### Task 4: ImageGrid
**Files:**
- Create: `services/frontend-leptos/frontend/src/features/messages/components/image_grid.rs`
**Interfaces:**
- Consumes: `MessageRecord` (extracts image URLs from attachments, stickers, embeds)
- Produces: `ImageGrid` component (props: `messages: Vec<MessageRecord>`)
**Key logic (ported from React `ImageGrid.tsx`, 132 lines):**
Extract images from message metadata:
```rust
fn extract_images(messages: &[MessageRecord]) -> Vec<String> {
let mut urls = Vec::new();
for msg in messages {
if let Some(meta) = &msg.metadata {
// attachments with image MIME
if let Some(atts) = &meta.attachments {
for att in atts {
if let Some(ct) = &att.content_type {
if ct.starts_with("image/") || ct.starts_with("video/") {
urls.push(att.url.clone());
}
}
}
}
// sticker images
if let Some(stickers) = &meta.stickers {
for s in stickers {
if let Some(url) = &s.url {
urls.push(url.clone());
}
}
}
// embed thumbnails/images
if let Some(embeds) = &meta.embeds {
for e in embeds {
if let Some(img) = &e.image { urls.push(img.url.clone()); }
if let Some(thumb) = &e.thumbnail { urls.push(thumb.url.clone()); }
}
}
}
}
urls.sort();
urls.dedup();
urls
}
```
Render as CSS grid of `<img>` elements with click-to-fullsize.
**Step 1: Create image_grid.rs**
**Step 2: Verify compilation**
**Step 3: Commit**
```bash
git commit -m "feat(leptos): ImageGrid component for message media"
```
---
### Task 5: MessagesPanel — full composition
**Files:**
- Modify: `services/frontend-leptos/frontend/src/features/messages/mod.rs` — replace placeholder with full panel
- Modify: `services/frontend-leptos/frontend/src/app.rs` — wire `MessagesPanel` as default tab content
- Modify: `services/frontend-leptos/frontend/src/app.css` — add message-specific CSS classes
**Interfaces:**
- Consumes: `use_messages::use_messages`, `MessageFeed`, `ImageGrid`, `ui::tabs::*`, `ui::badge::Badge`, `ui::input::Input`, `ui::button::Button`, `ui::card::*`, `app::UiContext`, `app::AuthContext`, `ws::context::WsContext`
**Key logic (ported from React `MessagesPanel.tsx`, 316 lines):**
1. **State:** `ai_filter`, `search_query`, `search_results`, `show_search`, `view_tab` ("all" | "images"), `retrying_all`, `retried_count`
2. **Search:** Debounced search via `api::messages::search_messages`
3. **Stats badges:** total, clean, flagged, error, pending, deleted, edited
4. **AI filter chips:** all, analyzed, clean, flagged, error, pending
5. **View tabs:** All (MessageFeed) | Images (ImageGrid)
6. **WS integration:** `message_created` → prepend, `message_updated` → merge, `message_deleted` → remove, `message_analyzed` → update fields
7. **Reanalyze:** Individual + batch error reanalyze
**CSS classes to add in `app.css`:**
```css
.filter-chip { /* pill-shaped filter button */ }
.filter-chip.active { /* active state */ }
.message-stats { /* flex row of stat badges */ }
.search-bar { /* search input with icon */ }
.messages-panel { /* main container */ }
```
**Step 1: Create message-specific CSS**
**Step 2: Replace MessagesPanel with full implementation**
**Step 3: Wire into app.rs as default tab**
**Step 4: Add WS event handlers for real-time message updates**
**Step 5: Verify build**
```bash
cd services/frontend-leptos/frontend && /home/asephs/.cargo/bin/trunk build --release
```
**Step 6: Commit**
```bash
git commit -m "feat(leptos): MessagesPanel with search, filters, real-time WS updates"
```
---
## Task Dependency Graph
```
Task 1 (skeleton + hook)
├→ Task 2 (MessageFeed)
│ └→ Task 3 (MessageCard) ──┐
└→ Task 4 (ImageGrid) ─────────┤
└→ Task 5 (MessagesPanel composition)
```
- Tasks 2 and 4 can be dispatched **in parallel** (independent components)
- Task 3 depends on Task 2 (MessageFeed imports MessageCard)
- Task 5 depends on all of Tasks 2, 3, 4
---
## CSS Classes Needed (append to `app.css`)
```css
/* ── Messages Panel ──────────────────────────────────── */
.messages-panel {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.message-stats {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.filter-chip {
padding: 0.25rem 0.75rem;
border-radius: var(--radius-full);
font-size: 0.75rem;
font-weight: 500;
border: 1px solid var(--surface-border);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.filter-chip:hover {
color: var(--text-primary);
background: var(--surface-overlay);
}
.filter-chip.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.search-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.search-row .input {
flex: 1;
min-width: 200px;
}
/* ── Message Card ────────────────────────────────────── */
.message-card {
background: var(--surface-base);
border: 1px solid var(--surface-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.message-card-header {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4) var(--space-6);
border-bottom: 1px solid var(--surface-border);
}
.message-card-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.message-card-username {
font-weight: 600;
font-size: 0.9375rem;
}
.message-card-meta {
font-size: 0.75rem;
color: var(--text-tertiary);
}
.message-card-body {
padding: var(--space-4) var(--space-6);
}
.message-row {
padding: var(--space-2) 0;
border-bottom: 1px solid var(--surface-border);
}
.message-row:last-child {
border-bottom: none;
}
.message-timestamp {
font-size: 0.6875rem;
color: var(--text-tertiary);
min-width: 48px;
}
.message-content {
font-size: 0.875rem;
line-height: 1.5;
word-break: break-word;
}
.message-content .custom-emoji {
display: inline-block;
height: 22px;
width: 22px;
vertical-align: middle;
object-fit: contain;
}
/* ── AI Analysis Box ─────────────────────────────────── */
.ai-analysis-box {
margin-top: var(--space-2);
padding: var(--space-3);
background: var(--surface-overlay);
border-radius: var(--radius-md);
border: 1px solid var(--surface-border);
}
.ai-analysis-label {
font-size: 0.75rem;
color: var(--text-tertiary);
margin-bottom: var(--space-1);
}
.ai-analysis-text {
font-size: 0.8125rem;
line-height: 1.4;
}
.ai-confidence-bar {
height: 4px;
background: var(--surface-border);
border-radius: 2px;
margin-top: var(--space-2);
overflow: hidden;
}
.ai-confidence-fill {
height: 100%;
border-radius: 2px;
background: var(--color-primary);
transition: width var(--transition-normal);
}
/* ── Image Grid ──────────────────────────────────────── */
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--space-2);
}
.image-grid-item {
aspect-ratio: 1;
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
transition: opacity var(--transition-fast);
}
.image-grid-item:hover {
opacity: 0.85;
}
.image-grid-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
```
---
## Key Reference Files
- React MessagesPanel: `services/frontend/src/features/messages/index.tsx` (316 lines)
- React useMessages: `services/frontend/src/features/messages/hooks/useMessages.ts` (162 lines)
- React MessageFeed: `services/frontend/src/features/messages/components/MessageFeed.tsx` (120 lines)
- React MessageCard: `services/frontend/src/features/messages/components/MessageCard.tsx` (561 lines)
- React ImageGrid: `services/frontend/src/features/messages/components/ImageGrid.tsx` (132 lines)
- Leptos shared-types: `services/frontend-leptos/shared-types/src/message.rs`
- Leptos API client: `services/frontend-leptos/frontend/src/api/messages.rs`
- Leptos WS context: `services/frontend-leptos/frontend/src/ws/context.rs`
- Leptos app shell: `services/frontend-leptos/frontend/src/app.rs`
File diff suppressed because it is too large Load Diff
@@ -1,512 +0,0 @@
# Frontend Dependency Upgrade 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:** Upgrade the Bete frontend to the newest feasible Rust/WASM dependency and build-tool surface, including pre-releases when verified.
**Architecture:** The frontend is a Leptos CSR WASM app in the `services/frontend` Cargo workspace. The upgrade is intentionally narrow: update manifests/tooling, refresh `Cargo.lock`, repair only compiler-required compatibility issues, and verify both local Trunk builds and the production proxy Docker build path.
**Tech Stack:** Rust, Cargo workspace, Leptos CSR, Trunk, wasm-bindgen/web-sys, Docker proxy image, pnpm root scripts.
## Global Constraints
- Attempt newest visible releases, including pre-releases, for the main frontend stack.
- Prefer the newest version that passes verification over forcing a broken latest version.
- Initial target versions: `leptos = "0.9.0-alpha"`, `leptos-use = "0.19"`, `lucide-leptos = "3.23"`, `trunk = "0.22.0-beta.1"`.
- The current frontend toolchain is `nightly-2026-06-01`; raise it if needed to satisfy Rust `1.90.0` requirements from Trunk beta.
- Preserve dashboard behavior; do not redesign UI, change backend APIs, or do unrelated refactors.
- Keep edits scoped to manifests, lockfile, build tooling, and compatibility changes directly caused by the upgrade.
- Do not create implementation commits unless the user explicitly grants commit permission for implementation changes in this session.
---
## File Structure
- Modify: `services/frontend/rust-toolchain.toml` — pins the Rust toolchain and `wasm32-unknown-unknown` target for local frontend builds.
- Modify: `services/frontend/frontend/Cargo.toml` — direct dependency declarations for the Leptos CSR app.
- Modify: `services/frontend/shared-types/Cargo.toml` — direct dependency declarations for frontend-shared serde types.
- Modify: `services/frontend/Cargo.lock` — resolved frontend Cargo workspace dependency graph.
- Modify: `infra/docker/Dockerfile.proxy` — production build path that compiles the frontend WASM bundle and serves `dist` from nginx.
- Inspect only unless needed: `.gitlab-ci.yml` — CI already builds the proxy Docker image; only modify if Dockerfile changes require CI variables or arguments.
- Compatibility edits may modify Rust files under `services/frontend/frontend/src/**/*.rs` only when the upgraded crates require API syntax changes.
---
### Task 1: Establish baseline and choose deterministic build tool pins
**Files:**
- Inspect: `services/frontend/rust-toolchain.toml`
- Inspect: `services/frontend/frontend/Cargo.toml`
- Inspect: `infra/docker/Dockerfile.proxy`
- Modify: none in this task
**Interfaces:**
- Consumes: Existing frontend workspace and root scripts.
- Produces: Baseline command results and confirmed target pins for Task 2.
- [ ] **Step 1: Record current git state**
```bash
git status --short
```
Expected: clean except for this plan file if it has not yet been committed. If there are unrelated user changes, stop and report them before editing dependency files.
- [ ] **Step 2: Run current frontend typecheck baseline**
```bash
pnpm run typecheck:web
```
Expected: PASS before dependency edits. If this fails before edits, save the output and report that the baseline is already broken.
- [ ] **Step 3: Run current frontend release build baseline**
```bash
pnpm run build:web
```
Expected: PASS before dependency edits. If this fails before edits, save the output and report that the baseline is already broken.
- [ ] **Step 4: Confirm latest target metadata**
```bash
cargo info leptos
cargo info leptos-use
cargo info lucide-leptos
cargo info trunk
```
Expected: metadata includes at least these target versions unless newer releases appeared during implementation:
```text
leptos 0.9.0-alpha
leptos-use 0.19.0
lucide-leptos 3.23.0
trunk 0.22.0-beta.1
```
If newer versions appear, use the same max-feasible policy: attempt the newest visible version, including pre-release, then fall back only if verification fails.
---
### Task 2: Update frontend toolchain and deterministic Docker Trunk install
**Files:**
- Modify: `services/frontend/rust-toolchain.toml`
- Modify: `infra/docker/Dockerfile.proxy`
- Inspect: `.gitlab-ci.yml`
**Interfaces:**
- Consumes: Target Trunk version from Task 1.
- Produces: A deterministic local/Docker build-tool surface for dependency compilation.
- [ ] **Step 1: Replace the frontend toolchain pin**
Edit `services/frontend/rust-toolchain.toml` to this content:
```toml
[toolchain]
channel = "nightly-2026-07-04"
components = ["rust-src", "rustc-dev"]
targets = ["wasm32-unknown-unknown"]
```
Rationale: the frontend currently has no `#![feature(...)]` gates, but keeping nightly avoids changing compiler channel semantics while moving past the Rust `1.90.0` requirement advertised by Trunk beta.
- [ ] **Step 2: Verify the selected toolchain exists**
```bash
cd services/frontend && rustup show active-toolchain && rustc --version
```
Expected: active toolchain is `nightly-2026-07-04` or rustup installs it and then reports a Rust version at or above `1.90.0-nightly`.
If `nightly-2026-07-04` is unavailable, use the newest installed or installable nightly at or after 2026-07-04 that satisfies Rust `1.90.0` and update `services/frontend/rust-toolchain.toml` to that exact date.
- [ ] **Step 3: Pin Trunk in the production proxy Dockerfile**
In `infra/docker/Dockerfile.proxy`, replace:
```dockerfile
RUN cargo install trunk --locked
```
with:
```dockerfile
RUN cargo install trunk --version 0.22.0-beta.1 --locked
```
- [ ] **Step 4: Inspect CI for required changes**
```bash
grep -n "build-proxy\|Dockerfile.proxy\|SERVICE_NAME" .gitlab-ci.yml
```
Expected: CI builds `infra/docker/Dockerfile.$SERVICE_NAME` with `SERVICE_NAME: proxy`, so no `.gitlab-ci.yml` change is required.
If CI does not build `Dockerfile.proxy`, update the CI job so `build-proxy` builds `infra/docker/Dockerfile.proxy` and keep the existing image tags.
- [ ] **Step 5: Check formatting of edited non-Rust files**
```bash
git diff -- services/frontend/rust-toolchain.toml infra/docker/Dockerfile.proxy .gitlab-ci.yml
```
Expected: diff only changes the toolchain channel and Trunk install version unless CI inspection revealed a real mismatch.
---
### Task 3: Upgrade Cargo manifests and refresh the frontend lockfile
**Files:**
- Modify: `services/frontend/frontend/Cargo.toml`
- Modify: `services/frontend/shared-types/Cargo.toml`
- Modify: `services/frontend/Cargo.lock`
**Interfaces:**
- Consumes: Toolchain and Docker Trunk pin from Task 2.
- Produces: Updated Cargo dependency declarations and resolved lockfile for Task 4.
- [ ] **Step 1: Update direct frontend app dependencies**
Edit the `[dependencies]` section in `services/frontend/frontend/Cargo.toml` to keep the existing dependency list and set these version requirements:
```toml
leptos = { version = "0.9.0-alpha", features = ["csr"] }
leptos-use = "0.19"
lucide-leptos = "3.23"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = [
"WebSocket",
"MessageEvent",
"CloseEvent",
"ErrorEvent",
"CanvasRenderingContext2d",
"AudioContext",
"AudioBuffer",
"AudioBufferSourceNode",
"AudioDestinationNode",
"AudioNode",
"AudioProcessingEvent",
"MediaStreamAudioSourceNode",
"ScriptProcessorNode",
"Window",
"Document",
"Element",
"HtmlElement",
"HtmlSelectElement",
"KeyboardEvent",
"Storage",
"IntersectionObserver",
"ResizeObserver",
"Url",
"Headers",
"Request",
"RequestInit",
"RequestMode",
"Response",
"HtmlInputElement",
"HtmlAudioElement",
"HtmlCanvasElement",
"MediaDevices",
"MediaStream",
"MediaStreamConstraints",
"MediaStreamTrack",
"Navigator",
"console",
] }
gloo-net = "0.6"
gloo-timers = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde-wasm-bindgen = "0.6"
wasm-logger = "0.2"
console_error_panic_hook = "0.1"
regex = "1"
```
Do not remove `shared-types = { path = "../shared-types" }`.
- [ ] **Step 2: Keep shared-types serde on latest compatible major**
Confirm `services/frontend/shared-types/Cargo.toml` still contains:
```toml
[dependencies]
serde = { version = "1", features = ["derive"] }
```
No change is required unless `cargo update` reports a resolver issue.
- [ ] **Step 3: Refresh the frontend workspace lockfile**
```bash
cd services/frontend && cargo update
```
Expected: `services/frontend/Cargo.lock` updates to resolved versions compatible with the new manifest requirements.
- [ ] **Step 4: Check direct resolved versions**
```bash
cargo tree --manifest-path services/frontend/frontend/Cargo.toml -e normal --depth 1
```
Expected: direct tree includes the newest feasible resolved versions, ideally:
```text
leptos v0.9.0-alpha
leptos-use v0.19.0
lucide-leptos v3.23.0
```
If Cargo cannot resolve `leptos 0.9.0-alpha` with `leptos-use 0.19`, try the newest mutually compatible pair and record the resolver error and fallback pair in the final report.
---
### Task 4: Run compiler-driven compatibility repairs
**Files:**
- Modify as needed: `services/frontend/frontend/src/**/*.rs`
- Modify as needed: `services/frontend/frontend/Cargo.toml` only for documented fallback versions
- Modify as needed: `services/frontend/Cargo.lock` after fallback changes
**Interfaces:**
- Consumes: Upgraded dependency graph from Task 3.
- Produces: A compiling Leptos frontend with behavior-preserving source compatibility fixes.
- [ ] **Step 1: Run the upgraded typecheck**
```bash
pnpm run typecheck:web
```
Expected: either PASS, or FAIL with concrete compiler errors from upgraded Leptos/tooling.
- [ ] **Step 2: Apply compatibility fix pattern A for Leptos mount changes if needed**
If the compiler reports that `leptos::mount::mount_to_body(app::App)` no longer matches the expected signature, edit `services/frontend/frontend/src/lib.rs` from:
```rust
// Mount the Leptos app to the body
leptos::mount::mount_to_body(app::App);
```
to:
```rust
// Mount the Leptos app to the body
leptos::mount::mount_to_body(|| leptos::view! { <app::App /> });
```
Then rerun:
```bash
pnpm run typecheck:web
```
Expected: the mount signature error disappears.
- [ ] **Step 3: Apply compatibility fix pattern B for removed empty-view hacks if needed**
If the compiler reports errors around statements like `let _: () = view! { <></> };` or converting `()` into a view, replace the empty branch with an explicit empty view.
For `services/frontend/frontend/src/features/live/components/voice_connection_card.rs`, replace:
```rust
} else {
let _: () = view! { <></> };
().into_any()
}
```
with:
```rust
} else {
view! { <></> }.into_any()
}
```
Then rerun:
```bash
pnpm run typecheck:web
```
Expected: the empty-view conversion error disappears.
- [ ] **Step 4: Apply compatibility fix pattern C for signal constructor changes if needed**
If the compiler reports that `RwSignal::new(...)` is unavailable or deprecated as an error under Leptos alpha, convert local signal creation to the Leptos function form.
Example replacement in `services/frontend/frontend/src/app.rs`:
```rust
let auth = AuthContext {
authenticated: RwSignal::new(false),
password: RwSignal::new(String::new()),
};
```
becomes:
```rust
let auth = AuthContext {
authenticated: RwSignal::new(false),
password: RwSignal::new(String::new()),
};
```
Expected: no edit is needed unless the compiler makes this an error. If it is an error and Leptos documents a replacement such as `RwSignal::new_with_options` or `signal`, apply the smallest mechanical change consistently to all reported sites and rerun `pnpm run typecheck:web`.
- [ ] **Step 5: Apply compatibility fix pattern D for event and property macro changes if needed**
If the compiler reports `view!` macro errors around event/property syntax, keep the current behavior and update only the reported syntax. Typical sites include:
```rust
on:click=move |_| active_tab.set(tab4.clone())
prop:value=selected_guild
class:btn=true
style:background=move || if active_tab.get() == tab2 { "var(--surface-overlay)" } else { "" }
```
For each compiler-reported macro error, change only the syntax required by the new macro and rerun:
```bash
pnpm run typecheck:web
```
Expected: each macro error is removed without changing UI classes, inline styles, event behavior, or signal reads.
- [ ] **Step 6: Decide fallback if alpha migration exceeds scope**
If `leptos 0.9.0-alpha` causes broad API breakage across many components and the typecheck cannot be repaired with behavior-preserving mechanical edits, fall back to newest stable Leptos visible in `cargo info leptos` or documented by Cargo metadata.
Use these commands to test the fallback:
```bash
cd services/frontend
cargo update -p leptos --precise 0.8.14
cargo update
cd ../..
pnpm run typecheck:web
```
Expected: fallback version typechecks after minimal compatibility repairs. Record the attempted alpha error summary and selected fallback in the final report.
- [ ] **Step 7: Finish with a passing typecheck**
```bash
pnpm run typecheck:web
```
Expected: PASS.
---
### Task 5: Verify release build, lint/test scope, and production Docker path
**Files:**
- Inspect: `package.json`
- Inspect: `.gitlab-ci.yml`
- Inspect/modify only if required: `infra/docker/Dockerfile.proxy`
**Interfaces:**
- Consumes: Compiling upgraded frontend from Task 4.
- Produces: Verification evidence for the final response.
- [ ] **Step 1: Run the frontend release build**
```bash
pnpm run build:web
```
Expected: PASS and Trunk writes release assets to `services/frontend/frontend/dist`.
- [ ] **Step 2: Run root lint if relevant to changed files**
```bash
pnpm run lint
```
Expected: PASS. If Biome does not cover the changed Rust/TOML/Docker files and running lint is not useful, report that it was skipped and why.
- [ ] **Step 3: Run tests if relevant**
```bash
pnpm run test
```
Expected: PASS if tests are runnable in this environment. If tests are unrelated to the frontend Rust workspace or fail for pre-existing service reasons, report the exact result and do not claim all tests pass.
- [ ] **Step 4: Verify the production proxy Docker build path if Docker is available**
```bash
docker build -f infra/docker/Dockerfile.proxy .
```
Expected: PASS and the build reaches the nginx runner stage after compiling the frontend WASM bundle.
If Docker is unavailable, permission-denied, or impractical in this environment, report that Docker verification was skipped with the exact error and rely on the passing local Trunk release build as the minimum production-path proxy.
- [ ] **Step 5: Inspect final dependency and build-tool diff**
```bash
git diff -- services/frontend/rust-toolchain.toml services/frontend/frontend/Cargo.toml services/frontend/shared-types/Cargo.toml services/frontend/Cargo.lock infra/docker/Dockerfile.proxy .gitlab-ci.yml services/frontend/frontend/src
```
Expected: diff contains only dependency/toolchain/build pin changes and compatibility edits required by the upgrade.
---
### Task 6: Final review and report
**Files:**
- Inspect: all changed files from `git status --short`
- Modify: none unless review finds a concrete issue
**Interfaces:**
- Consumes: Verification evidence from Task 5.
- Produces: Final user-facing summary with versions, fallbacks, and verification results.
- [ ] **Step 1: Summarize changed files**
```bash
git status --short
```
Expected: changed files are limited to the plan, frontend manifests/lockfile/toolchain, Dockerfile/CI if needed, and frontend Rust compatibility edits if needed.
- [ ] **Step 2: Check resolved direct frontend versions**
```bash
cargo tree --manifest-path services/frontend/frontend/Cargo.toml -e normal --depth 1
```
Expected: output shows the final resolved versions for `leptos`, `leptos-use`, `lucide-leptos`, and support crates.
- [ ] **Step 3: Prepare final response**
Include this information:
```text
- Dependency targets attempted: leptos, leptos-use, lucide-leptos, trunk.
- Final resolved versions: copied from cargo tree/cargo metadata.
- Toolchain version: copied from services/frontend/rust-toolchain.toml and rustc --version.
- Fallbacks: none, or exact attempted version -> selected version with reason.
- Verification: exact commands run and PASS/FAIL/SKIPPED status.
- Docker: built successfully, or skipped with exact environment limitation.
```
- [ ] **Step 4: Stop before committing implementation changes unless permission was granted**
```bash
git diff --stat
```
Expected: final diff is ready for the user to review. Do not run `git commit` unless the user explicitly asks for a commit.
@@ -1,703 +0,0 @@
# Gitea Migration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Migrate this repository to `MythEclipse/GMW` on self-hosted Gitea and add a Gitea Actions CI/CD workflow that lint/builds the project and deploys with the existing deploy script.
**Architecture:** Keep deployment provider-neutral by making `deploy.sh` consume shell environment variables only, then have Gitea Actions prepare the build artifacts, `.env`, and temporary SSH key before calling `./deploy.sh --no-build`. Treat Gitea repository creation, remote migration, push, and workflow-run verification as the final outward-facing step after local lint/build verification succeeds.
**Tech Stack:** Bash, Gitea Actions YAML, `tea` CLI, git, pnpm 11.1.3, Node.js 22, Rust stable, Trunk 0.22.0-beta.1, PostgreSQL/Redis-backed existing services.
## Global Constraints
- Target Gitea owner/repo: `MythEclipse/GMW`.
- Target SSH remote: `ssh://git@git.imrnes.team:22222/MythEclipse/GMW.git`.
- Workflow path: `.gitea/workflows/deploy.yml`.
- Workflow trigger: push to `main`.
- Use user-level Gitea secrets only: `VPS_HOST`, `VPS_USER`, `VPS_SSH_KEY`, `PRODUCTION_ENV`.
- Do not create duplicate repo-level secrets.
- Do not commit `.env`.
- Do not print secret values to logs.
- Use existing `deploy.sh` as the deploy entrypoint.
- Keep `deploy.sh` provider-neutral: no GitHub/GitLab/Gitea CLI dependency for deployment.
- Verification must include lint, builds, remote URL, push, and Gitea workflow status.
---
## File Structure
- Modify `deploy.sh`
- Responsibility: local/CI deployment entrypoint that builds optional artifacts, validates SSH configuration from environment variables, copies artifact directories to the VPS bind mounts, restarts containers, and cleans temporary key files.
- Boundary: does not know or call any CI provider CLI; accepts deployment inputs from shell env only.
- Create `.gitea/workflows/deploy.yml`
- Responsibility: Gitea Actions pipeline for checkout, dependency setup, lint/build, secret materialization, and deployment through `./deploy.sh --no-build`.
- Boundary: CI runner orchestration only; no remote Docker registry migration and no repo-level secret creation.
- Use existing `docs/superpowers/specs/2026-07-08-gitea-migration-design.md`
- Responsibility: approved design reference. No implementation changes needed.
- Use git remote configuration
- Responsibility: set `origin` to the new Gitea SSH URL and push current code to branch `main`.
- Boundary: no change to `.env`, no repo-level secret creation.
---
### Task 1: Make `deploy.sh` provider-neutral
**Files:**
- Modify: `deploy.sh:6-99`
**Interfaces:**
- Consumes: environment variables `VPS_HOST`, `VPS_USER`, `VPS_SSH_KEY`, optional `ADMIN_PASSWORD`.
- Produces: a provider-neutral executable deployment script with these behaviors:
- `./deploy.sh --help` prints usage without requiring secrets.
- `./deploy.sh --no-build` validates `VPS_HOST`, `VPS_USER`, and `VPS_SSH_KEY`.
- `VPS_SSH_KEY` may be either an existing key-file path or raw private-key contents.
- raw private-key contents are written to a temp file, used as SSH identity, and deleted on exit.
- [ ] **Step 1: Inspect current deploy script around the provider-specific section**
Run:
```bash
sed -n '1,115p' deploy.sh
```
Expected: The output includes comments that mention GitLab CLI and a `fetch_ci_var()` helper using `glab api`.
- [ ] **Step 2: Replace the header comments, validation, and SSH key handling**
Edit `deploy.sh` so lines 6-99 are equivalent to this complete block. Preserve the existing config variables and service-flag parsing that are already present in the script.
```bash
#!/bin/bash
# ─── Bete Deploy Script ──────────────────────────────────────────────────────
# Builds selected services locally and deploys compiled JS/WASM artifacts to the
# VPS via bind-mounted host directories. Changes survive container restarts
# because the Docker containers use bind mounts, not docker exec tar-pipes.
#
# This script is CI-provider neutral. GitHub/GitLab/Gitea workflows and local
# shells must provide deployment credentials through environment variables.
#
# Usage:
# ./deploy.sh # build + deploy all services
# ./deploy.sh --frontend # frontend WASM only
# ./deploy.sh --backend # backend JS only
# ./deploy.sh --gateway # discord-gateway JS only
# ./deploy.sh --all # same as no-flag (default)
# ./deploy.sh --no-build # skip builds, just copy files
# ./deploy.sh --help # show this message
#
# Required env:
# VPS_HOST — VPS IP/hostname
# VPS_USER — SSH user
# VPS_SSH_KEY — path to SSH private key or raw private-key contents
#
# Optional env:
# ADMIN_PASSWORD — verify backend health after deploy
# ──────────────────────────────────────────────────────────────────────────────
set -eu
# ── Config ────────────────────────────────────────────────────────────────────
APP_DIR="/opt/imphenbot"
COMPOSE_FILE="infra/docker/docker-compose.yml"
# Local build output directories (relative to repo root)
FRONTEND_DIST="services/frontend/frontend/dist"
BACKEND_DIST="services/backend/dist"
GATEWAY_DIST="services/discord-gateway/dist"
# Remote bind-mount paths (must match infra/docker/docker-compose.yml volumes)
REMOTE_BASE="${APP_DIR}/infra/docker"
REMOTE_FRONTEND="${REMOTE_BASE}/frontend-dist"
REMOTE_BACKEND="${REMOTE_BASE}/backend-dist"
REMOTE_GATEWAY="${REMOTE_BASE}/gateway-dist"
# ── Parse args ────────────────────────────────────────────────────────────────
DO_BUILD=true
DO_ALL=false
DO_FRONTEND=false
DO_BACKEND=false
DO_GATEWAY=false
for arg in "$@"; do
case "$arg" in
--help|-h)
sed -n '2,/^$/ s/^# //p' "$0"
exit 0
;;
--all|--full) DO_ALL=true ;;
--frontend) DO_FRONTEND=true ;;
--backend) DO_BACKEND=true ;;
--gateway) DO_GATEWAY=true ;;
--no-build) DO_BUILD=false ;;
esac
done
# If no service flag given, or --all, default to all
if ! $DO_FRONTEND && ! $DO_BACKEND && ! $DO_GATEWAY || $DO_ALL; then
DO_FRONTEND=true
DO_BACKEND=true
DO_GATEWAY=true
fi
# ── Validate and prepare SSH key ─────────────────────────────────────────────
: "${VPS_HOST:?VPS_HOST not set}"
: "${VPS_USER:?VPS_USER not set}"
: "${VPS_SSH_KEY:?VPS_SSH_KEY not set}"
TEMP_SSH_KEY=""
cleanup() {
if [ -n "$TEMP_SSH_KEY" ]; then
rm -f "$TEMP_SSH_KEY"
fi
}
trap cleanup EXIT
if [ -f "$VPS_SSH_KEY" ]; then
SSH_KEY_PATH="$VPS_SSH_KEY"
else
TEMP_SSH_KEY=$(mktemp)
printf '%s\n' "$VPS_SSH_KEY" > "$TEMP_SSH_KEY"
chmod 600 "$TEMP_SSH_KEY"
SSH_KEY_PATH="$TEMP_SSH_KEY"
fi
SSH_DEST="${VPS_USER}@${VPS_HOST}"
SSH_OPTS="-i $SSH_KEY_PATH -o StrictHostKeyChecking=accept-new"
```
Important preservation notes:
- Keep the existing helper functions after this block:
```bash
vps() { ssh $SSH_OPTS "$SSH_DEST" "$@"; }
log() { echo "→ $*"; }
ok() { echo "✓ $*"; }
die() { echo "✗ $*"; exit 1; }
```
- Keep the existing build and artifact deployment logic below the helper functions.
- Remove the old `fetch_ci_var()` function and all `glab` fallback calls.
- Remove the old cleanup block that only deleted `VPS_SSH_KEY` when it started with `/tmp/`; the new `trap cleanup EXIT` handles only keys created by this script.
- [ ] **Step 3: Remove the obsolete bottom cleanup block**
Delete this old block from the bottom of `deploy.sh` if it remains:
```bash
# ── Cleanup temp SSH key ─────────────────────────────────────────────────────
if [[ "$VPS_SSH_KEY" == /tmp/* ]]; then
rm -f "$VPS_SSH_KEY"
fi
```
Expected: There is exactly one cleanup mechanism: the `trap cleanup EXIT` block added near validation.
- [ ] **Step 4: Verify shell syntax**
Run:
```bash
bash -n deploy.sh
```
Expected: exit code 0 and no output.
- [ ] **Step 5: Verify help works without secrets**
Run:
```bash
env -u VPS_HOST -u VPS_USER -u VPS_SSH_KEY ./deploy.sh --help
```
Expected: exit code 0. Output mentions provider-neutral env requirements and lists `--frontend`, `--backend`, `--gateway`, `--all`, `--no-build`.
- [ ] **Step 6: Verify missing env fails fast without leaking secrets**
Run:
```bash
env -u VPS_HOST -u VPS_USER -u VPS_SSH_KEY ./deploy.sh --no-build
```
Expected: non-zero exit. Output includes `VPS_HOST not set`. It must not include any private-key content.
- [ ] **Step 7: Commit deploy script change**
Run:
```bash
git add deploy.sh
git commit -m "ci: make deploy script provider neutral"
```
Expected: commit succeeds and includes only `deploy.sh`.
---
### Task 2: Add Gitea Actions deploy workflow
**Files:**
- Create: `.gitea/workflows/deploy.yml`
**Interfaces:**
- Consumes: `deploy.sh` interface from Task 1 and user-level Gitea secrets `VPS_HOST`, `VPS_USER`, `VPS_SSH_KEY`, `PRODUCTION_ENV`.
- Produces: a Gitea Actions workflow that runs on pushes to `main`, performs lint/build, writes `.env` from `PRODUCTION_ENV`, prepares a temp SSH key, and deploys via `./deploy.sh --no-build`.
- [ ] **Step 1: Create the workflow directory**
Run:
```bash
mkdir -p .gitea/workflows
```
Expected: `.gitea/workflows` exists.
- [ ] **Step 2: Write `.gitea/workflows/deploy.yml`**
Create `.gitea/workflows/deploy.yml` with exactly this content:
```yaml
name: Deploy to VPS
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
curl \
libssl-dev \
pkg-config \
python3
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 11.1.3
run_install: false
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- name: Install Trunk
run: |
cargo install trunk --version 0.22.0-beta.1 --locked
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm run lint
- name: Build backend
run: pnpm run build:backend
- name: Build discord gateway
run: pnpm run build:discord-gateway
- name: Build frontend
run: pnpm run build:web
- name: Prepare production environment file
run: |
umask 077
printf '%s\n' '${{ secrets.PRODUCTION_ENV }}' | tr -d '\r' > .env
chmod 600 .env
- name: Prepare SSH key
run: |
umask 077
key_file="${RUNNER_TEMP:-/tmp}/bete-vps-key"
printf '%s\n' '${{ secrets.VPS_SSH_KEY }}' > "$key_file"
chmod 600 "$key_file"
echo "VPS_SSH_KEY=$key_file" >> "$GITHUB_ENV"
- name: Deploy
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
run: ./deploy.sh --no-build
```
Notes:
- Gitea Actions exposes the same `$GITHUB_ENV` file convention for action environment exports.
- The workflow writes secret values to files but never echoes secret contents.
- The workflow intentionally calls `deploy.sh --no-build` because all builds already ran in earlier CI steps.
- No repo-level secrets are created by this file.
- [ ] **Step 3: Check workflow file is tracked and `.env` is not tracked**
Run:
```bash
git status --short
```
Expected: output includes `.gitea/workflows/deploy.yml`. Output does not include `.env`.
- [ ] **Step 4: Commit workflow**
Run:
```bash
git add .gitea/workflows/deploy.yml
git commit -m "ci: add gitea deploy workflow"
```
Expected: commit succeeds and includes only `.gitea/workflows/deploy.yml`.
---
### Task 3: Run local verification before pushing
**Files:**
- No file changes expected.
**Interfaces:**
- Consumes: committed `deploy.sh` and `.gitea/workflows/deploy.yml` from Tasks 1 and 2.
- Produces: verified local lint/build status and a clean working tree before remote migration.
- [ ] **Step 1: Verify working tree before tests**
Run:
```bash
git status --short
```
Expected: no output.
- [ ] **Step 2: Install dependencies**
Run:
```bash
pnpm install --frozen-lockfile
```
Expected: exit code 0. If pnpm reports the lockfile is up to date and dependencies are already installed, that is acceptable.
- [ ] **Step 3: Run lint**
Run:
```bash
pnpm run lint
```
Expected: exit code 0. Biome reports no errors.
- [ ] **Step 4: Build backend**
Run:
```bash
pnpm run build:backend
```
Expected: exit code 0 and `services/backend/dist` exists.
- [ ] **Step 5: Build discord gateway**
Run:
```bash
pnpm run build:discord-gateway
```
Expected: exit code 0 and `services/discord-gateway/dist` exists.
- [ ] **Step 6: Build frontend**
Run:
```bash
pnpm run build:web
```
Expected: exit code 0 and `services/frontend/frontend/dist` exists.
- [ ] **Step 7: Re-run deploy script syntax and help checks**
Run:
```bash
bash -n deploy.sh
env -u VPS_HOST -u VPS_USER -u VPS_SSH_KEY ./deploy.sh --help >/tmp/bete-deploy-help.txt
grep -E 'VPS_HOST|VPS_USER|VPS_SSH_KEY|--no-build' /tmp/bete-deploy-help.txt
```
Expected: all commands exit 0. The grep output shows the expected env names and flag names, not secret values.
- [ ] **Step 8: Verify `.env` is not tracked**
Run:
```bash
git status --short -- .env
```
Expected: no output.
- [ ] **Step 9: Commit any verification-only artifact cleanup if needed**
If build commands changed generated files that are tracked, inspect them before committing. Run:
```bash
git status --short
```
Expected: no output or only ignored build directories. Do not commit `.env` or build artifact directories unless they are already tracked and intentionally changed.
---
### Task 4: Create or verify the Gitea repository and push `main`
**Files:**
- No source file changes expected.
- Git config changes: remote `origin` points to `ssh://git@git.imrnes.team:22222/MythEclipse/GMW.git`.
**Interfaces:**
- Consumes: local commits from Tasks 1 and 2, verified state from Task 3, authenticated `tea` CLI, SSH access to `git.imrnes.team:22222`.
- Produces: remote Gitea repository `MythEclipse/GMW`, remote `origin` set correctly, local branch `main`, and pushed `main` branch.
- [ ] **Step 1: Check whether the target repo exists**
Run:
```bash
tea api /repos/MythEclipse/GMW >/tmp/gitea-gmw-repo.json
```
Expected if repo exists: exit code 0 and JSON is written to `/tmp/gitea-gmw-repo.json`.
Expected if repo does not exist: non-zero exit or 404. Continue to Step 2.
- [ ] **Step 2: Create the repo only if Step 1 reported it missing**
Run only if the repository was missing:
```bash
tea repos create --owner MythEclipse --name GMW --private --description "Bete Discord moderation watcher"
```
Expected: exit code 0 and repository `MythEclipse/GMW` exists. This step does not create action secrets.
If `tea repos create` fails because the repository already exists, treat it as already-created and continue.
- [ ] **Step 3: Set `origin` to the Gitea SSH URL**
Run:
```bash
if git remote get-url origin >/dev/null 2>&1; then
git remote set-url origin ssh://git@git.imrnes.team:22222/MythEclipse/GMW.git
else
git remote add origin ssh://git@git.imrnes.team:22222/MythEclipse/GMW.git
fi
```
Expected: exit code 0.
- [ ] **Step 4: Preserve old GitLab remote as `gitlab` if only the old `GMW` remote exists**
Run:
```bash
if git remote get-url GMW >/dev/null 2>&1 && ! git remote get-url gitlab >/dev/null 2>&1; then
git remote rename GMW gitlab
fi
```
Expected: exit code 0. This keeps the old GitLab URL available for reference while making `origin` the Gitea remote.
- [ ] **Step 5: Verify remotes**
Run:
```bash
git remote -v
```
Expected: output includes exactly this URL for both `origin` fetch and push:
```text
origin ssh://git@git.imrnes.team:22222/MythEclipse/GMW.git (fetch)
origin ssh://git@git.imrnes.team:22222/MythEclipse/GMW.git (push)
```
It is acceptable if an additional `gitlab` remote points to the old GitLab URL.
- [ ] **Step 6: Create or switch to local `main` at current HEAD**
Run:
```bash
current_head=$(git rev-parse HEAD)
if git show-ref --verify --quiet refs/heads/main; then
git switch main
git reset --hard "$current_head"
else
git switch -c main
fi
```
Expected: local branch is `main` and points to the same commit that was previously checked out.
- [ ] **Step 7: Push `main` to Gitea**
Run:
```bash
git push -u origin main
```
Expected: push succeeds. Remote branch `main` exists on `MythEclipse/GMW`.
- [ ] **Step 8: Confirm the current branch and remote tracking**
Run:
```bash
git branch --show-current
git status --short --branch
```
Expected: current branch is `main`, and branch status shows it tracks `origin/main` with no uncommitted changes.
---
### Task 5: Verify Gitea Actions activation and report results
**Files:**
- No source file changes expected.
**Interfaces:**
- Consumes: pushed `main` branch and Gitea workflow from Task 4.
- Produces: evidence that Gitea registered workflow runs, plus a final report with any failures accurately described.
- [ ] **Step 1: Try the native `tea actions runs list` command**
Run:
```bash
tea actions runs list --repo MythEclipse/GMW --branch main --limit 5 --output json >/tmp/gitea-gmw-runs.json
```
Expected if supported: exit code 0 and `/tmp/gitea-gmw-runs.json` contains recent workflow runs.
- [ ] **Step 2: If Step 1 fails, use the API fallback required by the user**
Run only if Step 1 fails:
```bash
tea api /repos/MythEclipse/GMW/actions/runs >/tmp/gitea-gmw-runs.json
```
Expected: exit code 0 and `/tmp/gitea-gmw-runs.json` contains workflow run data.
- [ ] **Step 3: Inspect run summary without printing secrets**
Run:
```bash
python3 - <<'PY'
import json
from pathlib import Path
path = Path('/tmp/gitea-gmw-runs.json')
data = json.loads(path.read_text())
runs = data.get('workflow_runs', data if isinstance(data, list) else [])
for run in runs[:5]:
print({
'id': run.get('id'),
'name': run.get('name') or run.get('display_title'),
'event': run.get('event'),
'branch': run.get('head_branch') or run.get('branch'),
'status': run.get('status'),
'conclusion': run.get('conclusion'),
'created_at': run.get('created_at'),
})
PY
```
Expected: a concise summary of workflow runs with IDs, status, and conclusion. No secret values are printed.
- [ ] **Step 4: If the workflow is queued or running, wait briefly and re-check**
Run:
```bash
sleep 20
tea actions runs list --repo MythEclipse/GMW --branch main --limit 5 --output json >/tmp/gitea-gmw-runs.json || tea api /repos/MythEclipse/GMW/actions/runs >/tmp/gitea-gmw-runs.json
python3 - <<'PY'
import json
from pathlib import Path
data = json.loads(Path('/tmp/gitea-gmw-runs.json').read_text())
runs = data.get('workflow_runs', data if isinstance(data, list) else [])
for run in runs[:5]:
print({
'id': run.get('id'),
'status': run.get('status'),
'conclusion': run.get('conclusion'),
'branch': run.get('head_branch') or run.get('branch'),
})
PY
```
Expected: status is visible. It may still be queued/running if the runner is busy.
- [ ] **Step 5: Final status report**
Report these exact items to the user:
```text
- Repo target: MythEclipse/GMW
- Origin remote: <output of git remote get-url origin>
- Current branch: <output of git branch --show-current>
- Local lint: pass/fail with command used
- Backend build: pass/fail with command used
- Discord gateway build: pass/fail with command used
- Frontend build: pass/fail with command used
- Push to Gitea: pass/fail
- Gitea workflow: active status or exact failure/queued/running state
- Secrets: used user-level secret names only; no repo-level secrets created
```
Do not claim deployment succeeded unless the workflow run has a successful conclusion or deployment was otherwise verified from the run status.
---
## Self-Review
- Spec coverage: Task 1 covers provider-neutral `deploy.sh`; Task 2 covers `.gitea/workflows/deploy.yml`, main trigger, lint/build, user-level secrets, and `.env` generation; Task 3 covers local lint/build/syntax verification; Task 4 covers repo creation, origin remote, `main`, and push; Task 5 covers Gitea workflow activation and fallback to `tea api /repos/MythEclipse/GMW/actions/runs`.
- Placeholder scan: No `TBD`, `TODO`, "implement later", or incomplete validation steps remain.
- Interface consistency: `deploy.sh` consumes `VPS_HOST`, `VPS_USER`, and `VPS_SSH_KEY`; the workflow writes `VPS_SSH_KEY` to `$GITHUB_ENV` and passes `VPS_HOST`/`VPS_USER` as env to the deploy step. The workflow writes `.env` from `PRODUCTION_ENV` but does not pass that secret to `deploy.sh`, matching the approved design.