feat(bot+dashboard): whitelist/blacklist listen filter (users + roles)

- Per-guild listen filter stored in the control plane (BotControl) with
  GET/POST /api/bot/lists; the filter also rides along in the bot report
  response so the bot always has the latest config.
- 화이트리스트/블랙리스트 popup: search the guild's members OR roles (type
  selector), add/remove to white/black, save. Whitelist = listen to only those
  (empty = everyone); blacklist = exclude. Reuses the shared 뒤로가기 modal.
- Bot reports guild roles + known members for the search UI, and filters
  incoming audio via a pure, unit-tested isAllowed() (dave/filter.mjs):
  blacklist always excludes; a non-empty whitelist restricts; else everyone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-08-22 12:06:03 +09:00
parent 7eb590b729
commit 441ab4831f
4 changed files with 163 additions and 3 deletions

View File

@@ -36,6 +36,7 @@ import {
NoSubscriberBehavior,
} from '@discordjs/voice';
import prism from 'prism-media';
import { isAllowed } from './filter.mjs';
// ---------- config ----------
function loadEnvFile() {
@@ -164,6 +165,7 @@ const perUser = new Map(); // userId -> { opusPackets, pcmFrames }
let currentGuildId = null, currentChannelId = null, currentChannelName = null;
const speakingSet = new Set(); // userIds currently speaking (for participant list)
const activeSubs = new Set(); // userIds with an in-flight receive subscription
let listsByGuild = {}; // guildId -> {whitelistUsers, blacklistUsers, whitelistRoles, blacklistRoles}
// Attach the bot's audio player (so it can speak) to a fresh connection.
function setupPlayer(connection) {
@@ -178,6 +180,16 @@ function setupReceiver(connection) {
receiver.speaking.on('start', (userId) => {
speakingSet.add(userId);
if (userId === client.user.id || activeSubs.has(userId)) return;
// Whitelist/blacklist: skip speakers we are configured not to listen to.
const lists = listsByGuild[currentGuildId];
if (lists) {
const member = client.guilds.cache.get(currentGuildId)?.members.cache.get(userId);
const roleIds = member ? [...member.roles.cache.keys()] : [];
if (!isAllowed(userId, roleIds, lists)) {
logThrottled(`skip:${userId}`, `skip user=${userId} (listen filter)`);
return;
}
}
activeSubs.add(userId);
if (!perUser.has(userId)) perUser.set(userId, { opusPackets: 0, pcmFrames: 0 });
const opusStream = receiver.subscribe(userId, {
@@ -248,6 +260,15 @@ function buildState() {
voiceChannels: [...g.channels.cache.values()]
.filter((c) => c.isVoiceBased())
.map((c) => ({ id: c.id, name: c.name })),
// Roles (minus @everyone) and known members, so the dashboard's
// whitelist/blacklist popup can search by user OR role.
roles: [...g.roles.cache.values()]
.filter((r) => r.id !== g.id && r.name !== '@everyone')
.map((r) => ({ id: r.id, name: r.name })),
members: [...g.members.cache.values()].slice(0, 200).map((m) => ({
id: m.id, name: m.displayName, bot: m.user?.bot === true,
roleIds: [...m.roles.cache.keys()],
})),
}));
let members = [];
if (currentGuildId && currentChannelId) {
@@ -281,6 +302,7 @@ async function reportLoop() {
});
j = await r.json();
} catch { return; } // dashboard down: keep running, retry next tick
if (j?.lists) listsByGuild = j.lists; // latest whitelist/blacklist config
for (const cmd of (j?.commands || [])) {
try { await handleCommand(cmd); } catch (e) { log(`command ${cmd?.type} failed: ${e.message}`); }
}