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:
MythEclipse
2026-05-15 15:58:38 +07:00
co-authored by Claude Opus 4.7
parent 6859eb3f50
commit ed438e6fc0
12 changed files with 250 additions and 40 deletions
+13 -2
View File
@@ -6,6 +6,9 @@ const configSchema = z
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
VOICE_CHANNEL_ID: z.string().min(1).optional(),
GUILD_ID: z.string().min(1).optional(),
TEXT_GUILD_ID: z.string().min(1).optional(),
TEXT_CHANNEL_ID: z.string().min(1).optional(),
VOICE_GUILD_ID: z.string().min(1).optional(),
VERBOSE: z
.string()
.optional()
@@ -98,11 +101,19 @@ const configSchema = z
}
});
export type AppConfig = z.infer<typeof configSchema>;
export type AppConfig = z.infer<typeof configSchema> & {
EFFECTIVE_TEXT_GUILD_ID?: string;
EFFECTIVE_VOICE_GUILD_ID?: string;
};
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
try {
return configSchema.parse(env);
const parsed = configSchema.parse(env);
return {
...parsed,
EFFECTIVE_TEXT_GUILD_ID: parsed.TEXT_GUILD_ID ?? parsed.MONITOR_GUILD_ID,
EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID ?? parsed.GUILD_ID,
};
} catch (error) {
if (error instanceof z.ZodError) {
const messages = error.issues
+11 -5
View File
@@ -54,20 +54,26 @@ async function syncChannelMessages(
}
export async function syncBacklogMessages(client: Client): Promise<void> {
if (!config.MONITOR_GUILD_ID) {
logger.warn("MONITOR_GUILD_ID not configured, skipping backlog sync");
const textGuildId = config.EFFECTIVE_TEXT_GUILD_ID;
if (!textGuildId) {
logger.warn("TEXT_GUILD_ID not configured, skipping backlog sync");
return;
}
const guild = client.guilds.cache.get(config.MONITOR_GUILD_ID);
const guild = client.guilds.cache.get(textGuildId);
if (!guild) {
logger.warn(
{ guildId: config.MONITOR_GUILD_ID },
"Monitor guild not found, skipping backlog sync",
{ guildId: textGuildId },
"Text guild not found, skipping backlog sync",
);
return;
}
if (config.TEXT_CHANNEL_ID) {
await syncSelectedChannelBacklog(client, guild.id, config.TEXT_CHANNEL_ID);
return;
}
logger.info(
{ guildId: guild.id },
"Backlog sync ready (will sync on-demand per selected channel)",
+29 -3
View File
@@ -30,6 +30,32 @@ function getModerationBroadcaster(): ModerationBroadcaster | undefined {
return (globalThis as ModerationGlobal).moderationBroadcaster;
}
export interface TextCaptureTarget {
guildId?: string;
channelId?: string;
}
export interface MessageLocationInput {
guildId?: string | null;
channelId?: string | null;
}
export function shouldCaptureMessageLocation(
message: MessageLocationInput,
target: TextCaptureTarget,
): boolean {
if (!message.guildId || message.guildId !== target.guildId) return false;
if (target.channelId && message.channelId !== target.channelId) return false;
return true;
}
function getTextCaptureTarget(): TextCaptureTarget {
return {
guildId: config.EFFECTIVE_TEXT_GUILD_ID,
channelId: config.TEXT_CHANNEL_ID,
};
}
export async function captureMessage(
message: Message,
type: "text" | "edited" | "deleted",
@@ -110,7 +136,7 @@ export async function captureMessage(
export function registerMessageCapture(client: Client): void {
client.on("messageCreate", async (message) => {
if (!message.guildId || message.guildId !== config.MONITOR_GUILD_ID) return;
if (!shouldCaptureMessageLocation(message, getTextCaptureTarget())) return;
if (message.author?.bot) return;
try {
@@ -127,7 +153,7 @@ export function registerMessageCapture(client: Client): void {
});
client.on("messageUpdate", async (_oldMessage, newMessage) => {
if (!newMessage.guildId || newMessage.guildId !== config.MONITOR_GUILD_ID)
if (!shouldCaptureMessageLocation(newMessage, getTextCaptureTarget()))
return;
if (newMessage.author?.bot) return;
@@ -166,7 +192,7 @@ export function registerMessageCapture(client: Client): void {
});
client.on("messageDelete", async (message) => {
if (!message.guildId || message.guildId !== config.MONITOR_GUILD_ID) return;
if (!shouldCaptureMessageLocation(message, getTextCaptureTarget())) return;
if (!message.author) return;
try {
+8 -3
View File
@@ -5,17 +5,22 @@ import { createChildLogger } from "../logger";
const logger = createChildLogger("ui-state-routes");
export interface SharedUIState {
selectedGuild: string;
selectedVoiceGuild: string;
selectedVoiceChannel: string;
selectedTextGuild: string;
selectedTextChannel: string;
activeTab: "voice" | "text";
isListening: boolean;
isStreaming: boolean;
}
export type SharedUIStatePatch = Partial<SharedUIState> & {
selectedGuild?: string;
};
export interface UIStateRouteOptions {
getSharedUIState: () => SharedUIState;
patchSharedUIState: (patch: Partial<SharedUIState>) => SharedUIState;
patchSharedUIState: (patch: SharedUIStatePatch) => SharedUIState;
}
export function createUIStateRoutes(options: UIStateRouteOptions): Router {
@@ -35,7 +40,7 @@ export function createUIStateRoutes(options: UIStateRouteOptions): Router {
// POST /api/ui-state - Update UI state
router.post("/ui-state", (req, res, next) => {
try {
const patch = req.body as Partial<SharedUIState>;
const patch = req.body as SharedUIStatePatch;
const updated = patchSharedUIState(patch);
res.json(updated);
} catch (error) {
+2 -2
View File
@@ -128,7 +128,7 @@ export function createVoiceRoutes(
// Update UI state and broadcast to connected clients
if (patchSharedUIState && broadcaster) {
const updatedState = patchSharedUIState({
selectedGuild: guildId,
selectedVoiceGuild: guildId,
selectedVoiceChannel: channelId,
});
broadcaster.uiState(updatedState);
@@ -150,7 +150,7 @@ export function createVoiceRoutes(
// Update UI state and broadcast to connected clients
if (patchSharedUIState && broadcaster) {
const updatedState = patchSharedUIState({
selectedGuild: "",
selectedVoiceGuild: "",
selectedVoiceChannel: "",
});
broadcaster.uiState(updatedState);
+35 -5
View File
@@ -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;
}