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
|
VOICE_CONNECTION_TIMEOUT_MS=15000
|
||||||
RECONNECT_TIMEOUT_MS=5000
|
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
|
# Logging Configuration
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
# Moderation Configuration
|
# 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
|
PICSER_UPLOAD_URL=https://picser.asepharyana.tech/api/upload
|
||||||
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000
|
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000
|
||||||
ATTACHMENT_MAX_SIZE_MB=100
|
ATTACHMENT_MAX_SIZE_MB=100
|
||||||
|
|||||||
+25
-16
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
<nav class="tab-panel">
|
<nav class="tab-panel">
|
||||||
<div class="tabs"><button class="tab-btn active" data-tab="voice">Voice</button><button class="tab-btn" data-tab="text">Text</button></div>
|
<div class="tabs"><button class="tab-btn active" data-tab="voice">Voice</button><button class="tab-btn" data-tab="text">Text</button></div>
|
||||||
<div class="filter-row"><span>Channel / Thread</span><select id="channelFilter"><option value="">Select channel</option></select></div>
|
<div class="filter-row"><span>Text Guild</span><select id="textGuildSelect"><option value="">Select guild</option></select><span>Channel / Thread</span><select id="channelFilter"><option value="">Select channel</option></select></div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div id="errorBox" class="error"></div>
|
<div id="errorBox" class="error"></div>
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<div class="voice-layout">
|
<div class="voice-layout">
|
||||||
<div class="content-card">
|
<div class="content-card">
|
||||||
<div class="card-title"><h2>Voice Control</h2><span class="mini">bridge</span></div>
|
<div class="card-title"><h2>Voice Control</h2><span class="mini">bridge</span></div>
|
||||||
<div class="field-group"><label for="guildSelect">Guild</label><select id="guildSelect"><option value="">Select guild</option></select></div>
|
<div class="field-group"><label for="voiceGuildSelect">Voice Guild</label><select id="voiceGuildSelect"><option value="">Select guild</option></select></div>
|
||||||
<div class="field-group"><label for="channelSelect">Voice Channel</label><select id="channelSelect"><option value="">Select voice channel</option></select></div>
|
<div class="field-group"><label for="channelSelect">Voice Channel</label><select id="channelSelect"><option value="">Select voice channel</option></select></div>
|
||||||
<div class="button-row"><button id="joinVoiceBtn" class="btn btn-success">Join</button><button id="disconnectVoiceBtn" class="btn btn-danger">Disconnect</button></div>
|
<div class="button-row"><button id="joinVoiceBtn" class="btn btn-success">Join</button><button id="disconnectVoiceBtn" class="btn btn-danger">Disconnect</button></div>
|
||||||
<div class="voice-status" id="voiceStatusNote">Idle</div>
|
<div class="voice-status" id="voiceStatusNote">Idle</div>
|
||||||
@@ -53,8 +53,9 @@
|
|||||||
<script>
|
<script>
|
||||||
const state = {
|
const state = {
|
||||||
socket: null,
|
socket: null,
|
||||||
selectedGuild: '',
|
selectedVoiceGuild: '',
|
||||||
selectedVoiceChannel: '',
|
selectedVoiceChannel: '',
|
||||||
|
selectedTextGuild: '',
|
||||||
selectedTextChannel: '',
|
selectedTextChannel: '',
|
||||||
activeTab: 'voice',
|
activeTab: 'voice',
|
||||||
text: [],
|
text: [],
|
||||||
@@ -71,7 +72,7 @@
|
|||||||
const SAMPLE_RATE = 24000;
|
const SAMPLE_RATE = 24000;
|
||||||
const CHANNELS = 1;
|
const CHANNELS = 1;
|
||||||
const el = {
|
const el = {
|
||||||
wsDot: document.getElementById('wsDot'), wsStatusText: document.getElementById('wsStatusText'), activeTabLabel: document.getElementById('activeTabLabel'), errorBox: document.getElementById('errorBox'), guildSelect: document.getElementById('guildSelect'), channelSelect: document.getElementById('channelSelect'), channelFilter: document.getElementById('channelFilter'), joinVoiceBtn: document.getElementById('joinVoiceBtn'), disconnectVoiceBtn: document.getElementById('disconnectVoiceBtn'), voiceStatusText: document.getElementById('voiceStatusText'), voiceStatusNote: document.getElementById('voiceStatusNote'), toggleBtn: document.getElementById('toggleBtn'), listenBtn: document.getElementById('listenBtn'), listenStatus: document.getElementById('listenStatus'), visualizer: document.getElementById('visualizer'), userList: document.getElementById('userList'), textList: document.getElementById('textList'), reviewList: document.getElementById('reviewList')
|
wsDot: document.getElementById('wsDot'), wsStatusText: document.getElementById('wsStatusText'), activeTabLabel: document.getElementById('activeTabLabel'), errorBox: document.getElementById('errorBox'), voiceGuildSelect: document.getElementById('voiceGuildSelect'), textGuildSelect: document.getElementById('textGuildSelect'), channelSelect: document.getElementById('channelSelect'), channelFilter: document.getElementById('channelFilter'), joinVoiceBtn: document.getElementById('joinVoiceBtn'), disconnectVoiceBtn: document.getElementById('disconnectVoiceBtn'), voiceStatusText: document.getElementById('voiceStatusText'), voiceStatusNote: document.getElementById('voiceStatusNote'), toggleBtn: document.getElementById('toggleBtn'), listenBtn: document.getElementById('listenBtn'), listenStatus: document.getElementById('listenStatus'), visualizer: document.getElementById('visualizer'), userList: document.getElementById('userList'), textList: document.getElementById('textList'), reviewList: document.getElementById('reviewList')
|
||||||
};
|
};
|
||||||
for (let i = 0; i < 32; i++) { const bar = document.createElement('div'); bar.className = 'bar'; el.visualizer.appendChild(bar); }
|
for (let i = 0; i < 32; i++) { const bar = document.createElement('div'); bar.className = 'bar'; el.visualizer.appendChild(bar); }
|
||||||
const bars = [...document.querySelectorAll('.bar')];
|
const bars = [...document.querySelectorAll('.bar')];
|
||||||
@@ -85,10 +86,11 @@
|
|||||||
function appendBadge(parent, label, className) { const badge = document.createElement('span'); badge.className = `badge ${className}`; badge.textContent = label; parent.appendChild(badge); }
|
function appendBadge(parent, label, className) { const badge = document.createElement('span'); badge.className = `badge ${className}`; badge.textContent = label; parent.appendChild(badge); }
|
||||||
function parseMetadata(value) { if (!value) return {}; try { return JSON.parse(value); } catch { return {}; } }
|
function parseMetadata(value) { if (!value) return {}; try { return JSON.parse(value); } catch { return {}; } }
|
||||||
|
|
||||||
async function loadGuilds() { const guilds = await apiRequest('/api/guilds'); renderOptions(el.guildSelect, guilds, 'Select guild'); if (state.selectedGuild) { el.guildSelect.value = state.selectedGuild; await loadChannels(state.selectedGuild); } }
|
async function loadGuilds() { const guilds = await apiRequest('/api/guilds'); renderOptions(el.voiceGuildSelect, guilds, 'Select guild'); renderOptions(el.textGuildSelect, guilds, 'Select guild'); if (state.selectedVoiceGuild) { el.voiceGuildSelect.value = state.selectedVoiceGuild; await loadVoiceChannels(state.selectedVoiceGuild); } if (state.selectedTextGuild) { el.textGuildSelect.value = state.selectedTextGuild; await loadTextChannels(state.selectedTextGuild); } }
|
||||||
async function loadChannels(guildId) { if (!guildId) return; const [voiceChannels, watchChannels] = await Promise.all([apiRequest(`/api/guilds/${guildId}/voice-channels`), apiRequest(`/api/guilds/${guildId}/channels`)]); renderOptions(el.channelSelect, voiceChannels, 'Select voice channel'); renderOptions(el.channelFilter, watchChannels, 'Select channel'); if (state.selectedVoiceChannel) el.channelSelect.value = state.selectedVoiceChannel; 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}`)); }
|
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}`)); }
|
||||||
async function refreshStatus() { try { const status = await apiRequest('/api/status'); el.voiceStatusText.textContent = status.connected ? status.activeChannelName || 'Connected' : 'Not connected'; el.voiceStatusNote.textContent = status.connected ? `Connected to ${status.activeChannelName}` : 'Idle'; } catch (error) { showError(error.message); } }
|
async function refreshStatus() { try { const status = await apiRequest('/api/status'); el.voiceStatusText.textContent = status.connected ? status.activeChannelName || 'Connected' : 'Not connected'; el.voiceStatusNote.textContent = status.connected ? `Connected to ${status.activeChannelName}` : 'Idle'; } catch (error) { showError(error.message); } }
|
||||||
async function connectVoice() { const guildId = el.guildSelect.value; const channelId = el.channelSelect.value; if (!guildId || !channelId) return showError('Select guild and voice channel first'); await postUIState({ selectedGuild: guildId, selectedVoiceChannel: channelId }); const status = await apiRequest('/api/connect', { method: 'POST', body: JSON.stringify({ guildId, channelId }) }); el.voiceStatusText.textContent = status.activeChannelName || 'Connected'; el.voiceStatusNote.textContent = `Connected to ${status.activeChannelName}`; }
|
async function connectVoice() { const guildId = el.voiceGuildSelect.value; const channelId = el.channelSelect.value; if (!guildId || !channelId) return showError('Select guild and voice channel first'); await postUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: channelId }); const status = await apiRequest('/api/connect', { method: 'POST', body: JSON.stringify({ guildId, channelId }) }); el.voiceStatusText.textContent = status.activeChannelName || 'Connected'; el.voiceStatusNote.textContent = `Connected to ${status.activeChannelName}`; }
|
||||||
async function disconnectVoice() { await apiRequest('/api/disconnect', { method: 'POST' }); await refreshStatus(); }
|
async function disconnectVoice() { await apiRequest('/api/disconnect', { method: 'POST' }); await refreshStatus(); }
|
||||||
|
|
||||||
function connectWebSocket() { const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; state.socket = new WebSocket(`${protocol}//${location.host}/ws`); state.socket.binaryType = 'arraybuffer'; state.socket.onopen = () => { el.wsDot.classList.add('on'); el.wsStatusText.textContent = 'Connected'; }; state.socket.onclose = () => { el.wsDot.classList.remove('on'); el.wsStatusText.textContent = 'Reconnecting'; setTimeout(connectWebSocket, 2500); }; state.socket.onerror = () => { el.wsDot.classList.remove('on'); el.wsDot.classList.add('warn'); el.wsStatusText.textContent = 'Socket error'; }; state.socket.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { handleIncomingPCM(event.data); return; } try { handleJsonEvent(event.data); } catch {} }; }
|
function connectWebSocket() { const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; state.socket = new WebSocket(`${protocol}//${location.host}/ws`); state.socket.binaryType = 'arraybuffer'; state.socket.onopen = () => { el.wsDot.classList.add('on'); el.wsStatusText.textContent = 'Connected'; }; state.socket.onclose = () => { el.wsDot.classList.remove('on'); el.wsStatusText.textContent = 'Reconnecting'; setTimeout(connectWebSocket, 2500); }; state.socket.onerror = () => { el.wsDot.classList.remove('on'); el.wsDot.classList.add('warn'); el.wsStatusText.textContent = 'Socket error'; }; state.socket.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { handleIncomingPCM(event.data); return; } try { handleJsonEvent(event.data); } catch {} }; }
|
||||||
@@ -97,24 +99,30 @@
|
|||||||
async function applyServerState(next) {
|
async function applyServerState(next) {
|
||||||
if (!next || state.applyingServerState) return;
|
if (!next || state.applyingServerState) return;
|
||||||
state.applyingServerState = true;
|
state.applyingServerState = true;
|
||||||
const guildChanged = next.selectedGuild !== state.selectedGuild;
|
const nextVoiceGuild = next.selectedVoiceGuild || next.selectedGuild || '';
|
||||||
|
const nextTextGuild = next.selectedTextGuild || next.selectedGuild || '';
|
||||||
|
const voiceGuildChanged = nextVoiceGuild !== state.selectedVoiceGuild;
|
||||||
|
const textGuildChanged = nextTextGuild !== state.selectedTextGuild;
|
||||||
const textChanged = next.selectedTextChannel !== state.selectedTextChannel;
|
const textChanged = next.selectedTextChannel !== state.selectedTextChannel;
|
||||||
state.selectedGuild = next.selectedGuild || '';
|
state.selectedVoiceGuild = nextVoiceGuild;
|
||||||
state.selectedVoiceChannel = next.selectedVoiceChannel || '';
|
state.selectedVoiceChannel = next.selectedVoiceChannel || '';
|
||||||
|
state.selectedTextGuild = nextTextGuild;
|
||||||
state.selectedTextChannel = next.selectedTextChannel || '';
|
state.selectedTextChannel = next.selectedTextChannel || '';
|
||||||
state.activeTab = next.activeTab || 'voice';
|
state.activeTab = next.activeTab || 'voice';
|
||||||
state.isListening = !!next.isListening;
|
state.isListening = !!next.isListening;
|
||||||
state.isStreaming = !!next.isStreaming;
|
state.isStreaming = !!next.isStreaming;
|
||||||
el.guildSelect.value = state.selectedGuild;
|
el.voiceGuildSelect.value = state.selectedVoiceGuild;
|
||||||
if (guildChanged && state.selectedGuild) await loadChannels(state.selectedGuild);
|
el.textGuildSelect.value = state.selectedTextGuild;
|
||||||
|
if (voiceGuildChanged) await loadVoiceChannels(state.selectedVoiceGuild);
|
||||||
|
if (textGuildChanged) await loadTextChannels(state.selectedTextGuild);
|
||||||
el.channelSelect.value = state.selectedVoiceChannel;
|
el.channelSelect.value = state.selectedVoiceChannel;
|
||||||
el.channelFilter.value = state.selectedTextChannel;
|
el.channelFilter.value = state.selectedTextChannel;
|
||||||
applyActiveTab(state.activeTab);
|
applyActiveTab(state.activeTab);
|
||||||
if (textChanged || state.activeTab === 'text') {
|
if (textChanged || textGuildChanged || state.activeTab === 'text') {
|
||||||
if (state.selectedTextChannel && state.selectedGuild) {
|
if (state.selectedTextChannel && state.selectedTextGuild) {
|
||||||
await apiRequest('/api/backlog-sync', {
|
await apiRequest('/api/backlog-sync', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ guildId: state.selectedGuild, channelId: state.selectedTextChannel }),
|
body: JSON.stringify({ guildId: state.selectedTextGuild, channelId: state.selectedTextChannel }),
|
||||||
}).catch((error) => logger.warn('Backlog sync failed:', error.message));
|
}).catch((error) => logger.warn('Backlog sync failed:', error.message));
|
||||||
}
|
}
|
||||||
await fetchText().catch((error) => showError(error.message));
|
await fetchText().catch((error) => showError(error.message));
|
||||||
@@ -142,13 +150,14 @@
|
|||||||
function updateVisualizer(level) { bars.forEach((bar, index) => { const wave = Math.sin(index * 0.55 + Date.now() / 140) * 0.35 + 0.65; bar.style.height = `${Math.max(3, level * 190 * wave)}px`; }); }
|
function updateVisualizer(level) { bars.forEach((bar, index) => { const wave = Math.sin(index * 0.55 + Date.now() / 140) * 0.35 + 0.65; bar.style.height = `${Math.max(3, level * 190 * wave)}px`; }); }
|
||||||
|
|
||||||
document.querySelectorAll('.tab-btn').forEach((button) => { button.addEventListener('click', () => postUIState({ activeTab: button.dataset.tab }).catch((error) => showError(error.message))); });
|
document.querySelectorAll('.tab-btn').forEach((button) => { button.addEventListener('click', () => postUIState({ activeTab: button.dataset.tab }).catch((error) => showError(error.message))); });
|
||||||
el.guildSelect.addEventListener('change', () => postUIState({ selectedGuild: el.guildSelect.value, selectedVoiceChannel: '', selectedTextChannel: '' }).catch((error) => showError(error.message)));
|
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.channelSelect.addEventListener('change', () => postUIState({ selectedVoiceChannel: el.channelSelect.value }).catch((error) => showError(error.message)));
|
||||||
el.joinVoiceBtn.addEventListener('click', () => connectVoice().catch((error) => showError(error.message)));
|
el.joinVoiceBtn.addEventListener('click', () => connectVoice().catch((error) => showError(error.message)));
|
||||||
el.disconnectVoiceBtn.addEventListener('click', () => disconnectVoice().catch((error) => showError(error.message)));
|
el.disconnectVoiceBtn.addEventListener('click', () => disconnectVoice().catch((error) => showError(error.message)));
|
||||||
el.toggleBtn.addEventListener('click', () => postUIState({ isStreaming: !state.isStreaming }).catch((error) => showError(error.message)));
|
el.toggleBtn.addEventListener('click', () => postUIState({ isStreaming: !state.isStreaming }).catch((error) => showError(error.message)));
|
||||||
el.listenBtn.addEventListener('click', () => postUIState({ isListening: !state.isListening }).catch((error) => showError(error.message)));
|
el.listenBtn.addEventListener('click', () => postUIState({ isListening: !state.isListening }).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.guildSelect.value) url.searchParams.set('guild', el.guildSelect.value); history.replaceState({}, '', url); postUIState({ selectedTextChannel }).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)); });
|
||||||
|
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
apiRequest('/api/ui-state').then(applyServerState).then(() => loadGuilds()).then(refreshStatus).catch((error) => showError(error.message));
|
apiRequest('/api/ui-state').then(applyServerState).then(() => loadGuilds()).then(refreshStatus).catch((error) => showError(error.message));
|
||||||
|
|||||||
+13
-2
@@ -6,6 +6,9 @@ const configSchema = z
|
|||||||
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
|
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
|
||||||
VOICE_CHANNEL_ID: z.string().min(1).optional(),
|
VOICE_CHANNEL_ID: z.string().min(1).optional(),
|
||||||
GUILD_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
|
VERBOSE: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.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 {
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
const messages = error.issues
|
const messages = error.issues
|
||||||
|
|||||||
@@ -54,20 +54,26 @@ async function syncChannelMessages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function syncBacklogMessages(client: Client): Promise<void> {
|
export async function syncBacklogMessages(client: Client): Promise<void> {
|
||||||
if (!config.MONITOR_GUILD_ID) {
|
const textGuildId = config.EFFECTIVE_TEXT_GUILD_ID;
|
||||||
logger.warn("MONITOR_GUILD_ID not configured, skipping backlog sync");
|
if (!textGuildId) {
|
||||||
|
logger.warn("TEXT_GUILD_ID not configured, skipping backlog sync");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const guild = client.guilds.cache.get(config.MONITOR_GUILD_ID);
|
const guild = client.guilds.cache.get(textGuildId);
|
||||||
if (!guild) {
|
if (!guild) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ guildId: config.MONITOR_GUILD_ID },
|
{ guildId: textGuildId },
|
||||||
"Monitor guild not found, skipping backlog sync",
|
"Text guild not found, skipping backlog sync",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config.TEXT_CHANNEL_ID) {
|
||||||
|
await syncSelectedChannelBacklog(client, guild.id, config.TEXT_CHANNEL_ID);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{ guildId: guild.id },
|
{ guildId: guild.id },
|
||||||
"Backlog sync ready (will sync on-demand per selected channel)",
|
"Backlog sync ready (will sync on-demand per selected channel)",
|
||||||
|
|||||||
@@ -30,6 +30,32 @@ function getModerationBroadcaster(): ModerationBroadcaster | undefined {
|
|||||||
return (globalThis as ModerationGlobal).moderationBroadcaster;
|
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(
|
export async function captureMessage(
|
||||||
message: Message,
|
message: Message,
|
||||||
type: "text" | "edited" | "deleted",
|
type: "text" | "edited" | "deleted",
|
||||||
@@ -110,7 +136,7 @@ export async function captureMessage(
|
|||||||
|
|
||||||
export function registerMessageCapture(client: Client): void {
|
export function registerMessageCapture(client: Client): void {
|
||||||
client.on("messageCreate", async (message) => {
|
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;
|
if (message.author?.bot) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -127,7 +153,7 @@ export function registerMessageCapture(client: Client): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
client.on("messageUpdate", async (_oldMessage, newMessage) => {
|
client.on("messageUpdate", async (_oldMessage, newMessage) => {
|
||||||
if (!newMessage.guildId || newMessage.guildId !== config.MONITOR_GUILD_ID)
|
if (!shouldCaptureMessageLocation(newMessage, getTextCaptureTarget()))
|
||||||
return;
|
return;
|
||||||
if (newMessage.author?.bot) return;
|
if (newMessage.author?.bot) return;
|
||||||
|
|
||||||
@@ -166,7 +192,7 @@ export function registerMessageCapture(client: Client): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
client.on("messageDelete", async (message) => {
|
client.on("messageDelete", async (message) => {
|
||||||
if (!message.guildId || message.guildId !== config.MONITOR_GUILD_ID) return;
|
if (!shouldCaptureMessageLocation(message, getTextCaptureTarget())) return;
|
||||||
if (!message.author) return;
|
if (!message.author) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,17 +5,22 @@ import { createChildLogger } from "../logger";
|
|||||||
const logger = createChildLogger("ui-state-routes");
|
const logger = createChildLogger("ui-state-routes");
|
||||||
|
|
||||||
export interface SharedUIState {
|
export interface SharedUIState {
|
||||||
selectedGuild: string;
|
selectedVoiceGuild: string;
|
||||||
selectedVoiceChannel: string;
|
selectedVoiceChannel: string;
|
||||||
|
selectedTextGuild: string;
|
||||||
selectedTextChannel: string;
|
selectedTextChannel: string;
|
||||||
activeTab: "voice" | "text";
|
activeTab: "voice" | "text";
|
||||||
isListening: boolean;
|
isListening: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SharedUIStatePatch = Partial<SharedUIState> & {
|
||||||
|
selectedGuild?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface UIStateRouteOptions {
|
export interface UIStateRouteOptions {
|
||||||
getSharedUIState: () => SharedUIState;
|
getSharedUIState: () => SharedUIState;
|
||||||
patchSharedUIState: (patch: Partial<SharedUIState>) => SharedUIState;
|
patchSharedUIState: (patch: SharedUIStatePatch) => SharedUIState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUIStateRoutes(options: UIStateRouteOptions): Router {
|
export function createUIStateRoutes(options: UIStateRouteOptions): Router {
|
||||||
@@ -35,7 +40,7 @@ export function createUIStateRoutes(options: UIStateRouteOptions): Router {
|
|||||||
// POST /api/ui-state - Update UI state
|
// POST /api/ui-state - Update UI state
|
||||||
router.post("/ui-state", (req, res, next) => {
|
router.post("/ui-state", (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const patch = req.body as Partial<SharedUIState>;
|
const patch = req.body as SharedUIStatePatch;
|
||||||
const updated = patchSharedUIState(patch);
|
const updated = patchSharedUIState(patch);
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function createVoiceRoutes(
|
|||||||
// Update UI state and broadcast to connected clients
|
// Update UI state and broadcast to connected clients
|
||||||
if (patchSharedUIState && broadcaster) {
|
if (patchSharedUIState && broadcaster) {
|
||||||
const updatedState = patchSharedUIState({
|
const updatedState = patchSharedUIState({
|
||||||
selectedGuild: guildId,
|
selectedVoiceGuild: guildId,
|
||||||
selectedVoiceChannel: channelId,
|
selectedVoiceChannel: channelId,
|
||||||
});
|
});
|
||||||
broadcaster.uiState(updatedState);
|
broadcaster.uiState(updatedState);
|
||||||
@@ -150,7 +150,7 @@ export function createVoiceRoutes(
|
|||||||
// Update UI state and broadcast to connected clients
|
// Update UI state and broadcast to connected clients
|
||||||
if (patchSharedUIState && broadcaster) {
|
if (patchSharedUIState && broadcaster) {
|
||||||
const updatedState = patchSharedUIState({
|
const updatedState = patchSharedUIState({
|
||||||
selectedGuild: "",
|
selectedVoiceGuild: "",
|
||||||
selectedVoiceChannel: "",
|
selectedVoiceChannel: "",
|
||||||
});
|
});
|
||||||
broadcaster.uiState(updatedState);
|
broadcaster.uiState(updatedState);
|
||||||
|
|||||||
+35
-5
@@ -37,17 +37,23 @@ type VoiceGlobals = typeof globalThis & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface SharedUIState {
|
interface SharedUIState {
|
||||||
selectedGuild: string;
|
selectedVoiceGuild: string;
|
||||||
selectedVoiceChannel: string;
|
selectedVoiceChannel: string;
|
||||||
|
selectedTextGuild: string;
|
||||||
selectedTextChannel: string;
|
selectedTextChannel: string;
|
||||||
activeTab: "voice" | "text";
|
activeTab: "voice" | "text";
|
||||||
isListening: boolean;
|
isListening: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SharedUIStatePatch = Partial<SharedUIState> & {
|
||||||
|
selectedGuild?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const defaultSharedUIState: SharedUIState = {
|
const defaultSharedUIState: SharedUIState = {
|
||||||
selectedGuild: "",
|
selectedVoiceGuild: "",
|
||||||
selectedVoiceChannel: "",
|
selectedVoiceChannel: "",
|
||||||
|
selectedTextGuild: "",
|
||||||
selectedTextChannel: "",
|
selectedTextChannel: "",
|
||||||
activeTab: "voice",
|
activeTab: "voice",
|
||||||
isListening: false,
|
isListening: false,
|
||||||
@@ -56,21 +62,45 @@ const defaultSharedUIState: SharedUIState = {
|
|||||||
|
|
||||||
let sharedUIState: SharedUIState = { ...defaultSharedUIState };
|
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() {
|
async function initializeSharedUIState() {
|
||||||
sharedUIState = await getPersistedValue("web-ui-state", defaultSharedUIState);
|
sharedUIState = normalizeSharedUIState(
|
||||||
|
await getPersistedValue("web-ui-state", defaultSharedUIState),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSharedUIState(): SharedUIState {
|
function getSharedUIState(): SharedUIState {
|
||||||
return { ...sharedUIState };
|
return { ...sharedUIState };
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchSharedUIState(patch: Partial<SharedUIState>) {
|
function patchSharedUIState(patch: SharedUIStatePatch) {
|
||||||
if (typeof patch.selectedGuild === "string") {
|
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") {
|
if (typeof patch.selectedVoiceChannel === "string") {
|
||||||
sharedUIState.selectedVoiceChannel = patch.selectedVoiceChannel;
|
sharedUIState.selectedVoiceChannel = patch.selectedVoiceChannel;
|
||||||
}
|
}
|
||||||
|
if (typeof patch.selectedTextGuild === "string") {
|
||||||
|
sharedUIState.selectedTextGuild = patch.selectedTextGuild;
|
||||||
|
}
|
||||||
if (typeof patch.selectedTextChannel === "string") {
|
if (typeof patch.selectedTextChannel === "string") {
|
||||||
sharedUIState.selectedTextChannel = patch.selectedTextChannel;
|
sharedUIState.selectedTextChannel = patch.selectedTextChannel;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,4 +29,44 @@ describe("loadConfig", () => {
|
|||||||
expect(config.RECORDINGS_DIR).toBe("./recordings");
|
expect(config.RECORDINGS_DIR).toBe("./recordings");
|
||||||
expect(config.NODE_ENV).toBe("test");
|
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(
|
expect(videoStreamPackage.devDependencies?.["discord.js-selfbot-v13"]).toBe(
|
||||||
"workspace:*",
|
"workspace:*",
|
||||||
);
|
);
|
||||||
expect(videoStreamPackage.peerDependencies?.["discord.js-selfbot-v13"]).toBe(
|
expect(
|
||||||
"^3.6.0",
|
videoStreamPackage.peerDependencies?.["discord.js-selfbot-v13"],
|
||||||
);
|
).toBe("^3.6.0");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user