feat: split text and voice channel selection
Separate text moderation and voice recording guild/channel state so each workflow can persist and operate independently. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
6859eb3f50
commit
ed438e6fc0
+10
-1
@@ -25,12 +25,21 @@ WEBSERVER_PORT=3000
|
||||
VOICE_CONNECTION_TIMEOUT_MS=15000
|
||||
RECONNECT_TIMEOUT_MS=5000
|
||||
|
||||
# Voice Recording Selection
|
||||
# VOICE_GUILD_ID falls back to legacy GUILD_ID when omitted.
|
||||
GUILD_ID=legacy_voice_guild_id
|
||||
VOICE_GUILD_ID=voice_guild_id
|
||||
VOICE_CHANNEL_ID=voice_channel_id
|
||||
|
||||
# Logging Configuration
|
||||
LOG_LEVEL=info
|
||||
NODE_ENV=development
|
||||
|
||||
# Moderation Configuration
|
||||
MONITOR_GUILD_ID=your_guild_id_here
|
||||
# TEXT_GUILD_ID falls back to legacy MONITOR_GUILD_ID when omitted.
|
||||
MONITOR_GUILD_ID=legacy_text_guild_id
|
||||
TEXT_GUILD_ID=text_guild_id
|
||||
TEXT_CHANNEL_ID=text_channel_id
|
||||
PICSER_UPLOAD_URL=https://picser.asepharyana.tech/api/upload
|
||||
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000
|
||||
ATTACHMENT_MAX_SIZE_MB=100
|
||||
|
||||
+25
-16
File diff suppressed because one or more lines are too long
+13
-2
@@ -6,6 +6,9 @@ const configSchema = z
|
||||
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
|
||||
VOICE_CHANNEL_ID: z.string().min(1).optional(),
|
||||
GUILD_ID: z.string().min(1).optional(),
|
||||
TEXT_GUILD_ID: z.string().min(1).optional(),
|
||||
TEXT_CHANNEL_ID: z.string().min(1).optional(),
|
||||
VOICE_GUILD_ID: z.string().min(1).optional(),
|
||||
VERBOSE: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -98,11 +101,19 @@ const configSchema = z
|
||||
}
|
||||
});
|
||||
|
||||
export type AppConfig = z.infer<typeof configSchema>;
|
||||
export type AppConfig = z.infer<typeof configSchema> & {
|
||||
EFFECTIVE_TEXT_GUILD_ID?: string;
|
||||
EFFECTIVE_VOICE_GUILD_ID?: string;
|
||||
};
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
try {
|
||||
return configSchema.parse(env);
|
||||
const parsed = configSchema.parse(env);
|
||||
return {
|
||||
...parsed,
|
||||
EFFECTIVE_TEXT_GUILD_ID: parsed.TEXT_GUILD_ID ?? parsed.MONITOR_GUILD_ID,
|
||||
EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID ?? parsed.GUILD_ID,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const messages = error.issues
|
||||
|
||||
@@ -54,20 +54,26 @@ async function syncChannelMessages(
|
||||
}
|
||||
|
||||
export async function syncBacklogMessages(client: Client): Promise<void> {
|
||||
if (!config.MONITOR_GUILD_ID) {
|
||||
logger.warn("MONITOR_GUILD_ID not configured, skipping backlog sync");
|
||||
const textGuildId = config.EFFECTIVE_TEXT_GUILD_ID;
|
||||
if (!textGuildId) {
|
||||
logger.warn("TEXT_GUILD_ID not configured, skipping backlog sync");
|
||||
return;
|
||||
}
|
||||
|
||||
const guild = client.guilds.cache.get(config.MONITOR_GUILD_ID);
|
||||
const guild = client.guilds.cache.get(textGuildId);
|
||||
if (!guild) {
|
||||
logger.warn(
|
||||
{ guildId: config.MONITOR_GUILD_ID },
|
||||
"Monitor guild not found, skipping backlog sync",
|
||||
{ guildId: textGuildId },
|
||||
"Text guild not found, skipping backlog sync",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.TEXT_CHANNEL_ID) {
|
||||
await syncSelectedChannelBacklog(client, guild.id, config.TEXT_CHANNEL_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ guildId: guild.id },
|
||||
"Backlog sync ready (will sync on-demand per selected channel)",
|
||||
|
||||
@@ -30,6 +30,32 @@ function getModerationBroadcaster(): ModerationBroadcaster | undefined {
|
||||
return (globalThis as ModerationGlobal).moderationBroadcaster;
|
||||
}
|
||||
|
||||
export interface TextCaptureTarget {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
}
|
||||
|
||||
export interface MessageLocationInput {
|
||||
guildId?: string | null;
|
||||
channelId?: string | null;
|
||||
}
|
||||
|
||||
export function shouldCaptureMessageLocation(
|
||||
message: MessageLocationInput,
|
||||
target: TextCaptureTarget,
|
||||
): boolean {
|
||||
if (!message.guildId || message.guildId !== target.guildId) return false;
|
||||
if (target.channelId && message.channelId !== target.channelId) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function getTextCaptureTarget(): TextCaptureTarget {
|
||||
return {
|
||||
guildId: config.EFFECTIVE_TEXT_GUILD_ID,
|
||||
channelId: config.TEXT_CHANNEL_ID,
|
||||
};
|
||||
}
|
||||
|
||||
export async function captureMessage(
|
||||
message: Message,
|
||||
type: "text" | "edited" | "deleted",
|
||||
@@ -110,7 +136,7 @@ export async function captureMessage(
|
||||
|
||||
export function registerMessageCapture(client: Client): void {
|
||||
client.on("messageCreate", async (message) => {
|
||||
if (!message.guildId || message.guildId !== config.MONITOR_GUILD_ID) return;
|
||||
if (!shouldCaptureMessageLocation(message, getTextCaptureTarget())) return;
|
||||
if (message.author?.bot) return;
|
||||
|
||||
try {
|
||||
@@ -127,7 +153,7 @@ export function registerMessageCapture(client: Client): void {
|
||||
});
|
||||
|
||||
client.on("messageUpdate", async (_oldMessage, newMessage) => {
|
||||
if (!newMessage.guildId || newMessage.guildId !== config.MONITOR_GUILD_ID)
|
||||
if (!shouldCaptureMessageLocation(newMessage, getTextCaptureTarget()))
|
||||
return;
|
||||
if (newMessage.author?.bot) return;
|
||||
|
||||
@@ -166,7 +192,7 @@ export function registerMessageCapture(client: Client): void {
|
||||
});
|
||||
|
||||
client.on("messageDelete", async (message) => {
|
||||
if (!message.guildId || message.guildId !== config.MONITOR_GUILD_ID) return;
|
||||
if (!shouldCaptureMessageLocation(message, getTextCaptureTarget())) return;
|
||||
if (!message.author) return;
|
||||
|
||||
try {
|
||||
|
||||
@@ -5,17 +5,22 @@ import { createChildLogger } from "../logger";
|
||||
const logger = createChildLogger("ui-state-routes");
|
||||
|
||||
export interface SharedUIState {
|
||||
selectedGuild: string;
|
||||
selectedVoiceGuild: string;
|
||||
selectedVoiceChannel: string;
|
||||
selectedTextGuild: string;
|
||||
selectedTextChannel: string;
|
||||
activeTab: "voice" | "text";
|
||||
isListening: boolean;
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
export type SharedUIStatePatch = Partial<SharedUIState> & {
|
||||
selectedGuild?: string;
|
||||
};
|
||||
|
||||
export interface UIStateRouteOptions {
|
||||
getSharedUIState: () => SharedUIState;
|
||||
patchSharedUIState: (patch: Partial<SharedUIState>) => SharedUIState;
|
||||
patchSharedUIState: (patch: SharedUIStatePatch) => SharedUIState;
|
||||
}
|
||||
|
||||
export function createUIStateRoutes(options: UIStateRouteOptions): Router {
|
||||
@@ -35,7 +40,7 @@ export function createUIStateRoutes(options: UIStateRouteOptions): Router {
|
||||
// POST /api/ui-state - Update UI state
|
||||
router.post("/ui-state", (req, res, next) => {
|
||||
try {
|
||||
const patch = req.body as Partial<SharedUIState>;
|
||||
const patch = req.body as SharedUIStatePatch;
|
||||
const updated = patchSharedUIState(patch);
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
|
||||
@@ -128,7 +128,7 @@ export function createVoiceRoutes(
|
||||
// Update UI state and broadcast to connected clients
|
||||
if (patchSharedUIState && broadcaster) {
|
||||
const updatedState = patchSharedUIState({
|
||||
selectedGuild: guildId,
|
||||
selectedVoiceGuild: guildId,
|
||||
selectedVoiceChannel: channelId,
|
||||
});
|
||||
broadcaster.uiState(updatedState);
|
||||
@@ -150,7 +150,7 @@ export function createVoiceRoutes(
|
||||
// Update UI state and broadcast to connected clients
|
||||
if (patchSharedUIState && broadcaster) {
|
||||
const updatedState = patchSharedUIState({
|
||||
selectedGuild: "",
|
||||
selectedVoiceGuild: "",
|
||||
selectedVoiceChannel: "",
|
||||
});
|
||||
broadcaster.uiState(updatedState);
|
||||
|
||||
+35
-5
@@ -37,17 +37,23 @@ type VoiceGlobals = typeof globalThis & {
|
||||
};
|
||||
|
||||
interface SharedUIState {
|
||||
selectedGuild: string;
|
||||
selectedVoiceGuild: string;
|
||||
selectedVoiceChannel: string;
|
||||
selectedTextGuild: string;
|
||||
selectedTextChannel: string;
|
||||
activeTab: "voice" | "text";
|
||||
isListening: boolean;
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
type SharedUIStatePatch = Partial<SharedUIState> & {
|
||||
selectedGuild?: string;
|
||||
};
|
||||
|
||||
const defaultSharedUIState: SharedUIState = {
|
||||
selectedGuild: "",
|
||||
selectedVoiceGuild: "",
|
||||
selectedVoiceChannel: "",
|
||||
selectedTextGuild: "",
|
||||
selectedTextChannel: "",
|
||||
activeTab: "voice",
|
||||
isListening: false,
|
||||
@@ -56,21 +62,45 @@ const defaultSharedUIState: SharedUIState = {
|
||||
|
||||
let sharedUIState: SharedUIState = { ...defaultSharedUIState };
|
||||
|
||||
export function normalizeSharedUIState(
|
||||
value: SharedUIStatePatch,
|
||||
): SharedUIState {
|
||||
const legacyGuild = value.selectedGuild ?? "";
|
||||
return {
|
||||
selectedVoiceGuild: value.selectedVoiceGuild ?? legacyGuild,
|
||||
selectedVoiceChannel: value.selectedVoiceChannel ?? "",
|
||||
selectedTextGuild: value.selectedTextGuild ?? legacyGuild,
|
||||
selectedTextChannel: value.selectedTextChannel ?? "",
|
||||
activeTab: value.activeTab === "text" ? "text" : "voice",
|
||||
isListening: value.isListening ?? false,
|
||||
isStreaming: value.isStreaming ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
async function initializeSharedUIState() {
|
||||
sharedUIState = await getPersistedValue("web-ui-state", defaultSharedUIState);
|
||||
sharedUIState = normalizeSharedUIState(
|
||||
await getPersistedValue("web-ui-state", defaultSharedUIState),
|
||||
);
|
||||
}
|
||||
|
||||
function getSharedUIState(): SharedUIState {
|
||||
return { ...sharedUIState };
|
||||
}
|
||||
|
||||
function patchSharedUIState(patch: Partial<SharedUIState>) {
|
||||
function patchSharedUIState(patch: SharedUIStatePatch) {
|
||||
if (typeof patch.selectedGuild === "string") {
|
||||
sharedUIState.selectedGuild = patch.selectedGuild;
|
||||
sharedUIState.selectedVoiceGuild = patch.selectedGuild;
|
||||
sharedUIState.selectedTextGuild = patch.selectedGuild;
|
||||
}
|
||||
if (typeof patch.selectedVoiceGuild === "string") {
|
||||
sharedUIState.selectedVoiceGuild = patch.selectedVoiceGuild;
|
||||
}
|
||||
if (typeof patch.selectedVoiceChannel === "string") {
|
||||
sharedUIState.selectedVoiceChannel = patch.selectedVoiceChannel;
|
||||
}
|
||||
if (typeof patch.selectedTextGuild === "string") {
|
||||
sharedUIState.selectedTextGuild = patch.selectedTextGuild;
|
||||
}
|
||||
if (typeof patch.selectedTextChannel === "string") {
|
||||
sharedUIState.selectedTextChannel = patch.selectedTextChannel;
|
||||
}
|
||||
|
||||
@@ -29,4 +29,44 @@ describe("loadConfig", () => {
|
||||
expect(config.RECORDINGS_DIR).toBe("./recordings");
|
||||
expect(config.NODE_ENV).toBe("test");
|
||||
});
|
||||
|
||||
it("derives split text and voice guild defaults from legacy config", async () => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
DISCORD_TOKEN: "token",
|
||||
MONITOR_GUILD_ID: "legacy-text-guild",
|
||||
GUILD_ID: "legacy-voice-guild",
|
||||
VOICE_CHANNEL_ID: "voice-channel",
|
||||
NODE_ENV: "test",
|
||||
};
|
||||
|
||||
const { loadConfig } = await import("../src/config");
|
||||
const config = loadConfig(process.env);
|
||||
|
||||
expect(config.TEXT_GUILD_ID).toBeUndefined();
|
||||
expect(config.EFFECTIVE_TEXT_GUILD_ID).toBe("legacy-text-guild");
|
||||
expect(config.EFFECTIVE_VOICE_GUILD_ID).toBe("legacy-voice-guild");
|
||||
expect(config.VOICE_CHANNEL_ID).toBe("voice-channel");
|
||||
});
|
||||
|
||||
it("uses explicit split text and voice config before legacy values", async () => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
DISCORD_TOKEN: "token",
|
||||
MONITOR_GUILD_ID: "legacy-text-guild",
|
||||
GUILD_ID: "legacy-voice-guild",
|
||||
TEXT_GUILD_ID: "text-guild",
|
||||
TEXT_CHANNEL_ID: "text-channel",
|
||||
VOICE_GUILD_ID: "voice-guild",
|
||||
VOICE_CHANNEL_ID: "voice-channel",
|
||||
NODE_ENV: "test",
|
||||
};
|
||||
|
||||
const { loadConfig } = await import("../src/config");
|
||||
const config = loadConfig(process.env);
|
||||
|
||||
expect(config.EFFECTIVE_TEXT_GUILD_ID).toBe("text-guild");
|
||||
expect(config.TEXT_CHANNEL_ID).toBe("text-channel");
|
||||
expect(config.EFFECTIVE_VOICE_GUILD_ID).toBe("voice-guild");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldCaptureMessageLocation } from "../../src/moderation/messageCapture";
|
||||
|
||||
describe("shouldCaptureMessageLocation", () => {
|
||||
it("matches only configured text guild and optional channel", () => {
|
||||
expect(
|
||||
shouldCaptureMessageLocation(
|
||||
{ guildId: "guild-1", channelId: "channel-1" },
|
||||
{ guildId: "guild-1", channelId: "channel-1" },
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldCaptureMessageLocation(
|
||||
{ guildId: "guild-1", channelId: "channel-2" },
|
||||
{ guildId: "guild-1", channelId: "channel-1" },
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldCaptureMessageLocation(
|
||||
{ guildId: "guild-2", channelId: "channel-1" },
|
||||
{ guildId: "guild-1", channelId: "channel-1" },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSyncRoutes } from "../../src/routes/syncRoutes";
|
||||
|
||||
const syncSelectedChannelBacklog = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../src/moderation/backlogSync", () => ({
|
||||
syncSelectedChannelBacklog,
|
||||
}));
|
||||
|
||||
describe("createSyncRoutes", () => {
|
||||
beforeEach(() => {
|
||||
syncSelectedChannelBacklog.mockReset();
|
||||
});
|
||||
|
||||
it("syncs the selected guild and channel from the request", async () => {
|
||||
syncSelectedChannelBacklog.mockResolvedValue(3);
|
||||
const router = createSyncRoutes({} as never);
|
||||
const route = router.stack.find(
|
||||
(layer) => layer.route?.path === "/backlog-sync",
|
||||
);
|
||||
const handler = route?.route?.stack[0]?.handle;
|
||||
|
||||
const json = vi.fn();
|
||||
const next = vi.fn();
|
||||
|
||||
await handler?.(
|
||||
{
|
||||
body: { guildId: "selected-guild", channelId: "selected-channel" },
|
||||
} as Request,
|
||||
{ json } as unknown as Response,
|
||||
next,
|
||||
);
|
||||
|
||||
expect(syncSelectedChannelBacklog).toHaveBeenCalledWith(
|
||||
{},
|
||||
"selected-guild",
|
||||
"selected-channel",
|
||||
);
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
channelId: "selected-channel",
|
||||
messagesSync: 3,
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+3
-3
@@ -13,8 +13,8 @@ describe("Discord video stream workspace dependencies", () => {
|
||||
expect(videoStreamPackage.devDependencies?.["discord.js-selfbot-v13"]).toBe(
|
||||
"workspace:*",
|
||||
);
|
||||
expect(videoStreamPackage.peerDependencies?.["discord.js-selfbot-v13"]).toBe(
|
||||
"^3.6.0",
|
||||
);
|
||||
expect(
|
||||
videoStreamPackage.peerDependencies?.["discord.js-selfbot-v13"],
|
||||
).toBe("^3.6.0");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user