refactor(backend): remove auth for public API
Deploy to VPS / deploy (push) Failing after 35s
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:
@@ -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.
|
||||
Reference in New Issue
Block a user