From 441ab4831f07409eee2575cb4ef5bd341720d63b Mon Sep 17 00:00:00 2001 From: EJClaw Date: Sat, 22 Aug 2026 12:06:03 +0900 Subject: [PATCH] feat(bot+dashboard): whitelist/blacklist listen filter (users + roles) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- dave/bot.mjs | 22 +++++++++++ dave/filter.mjs | 18 +++++++++ wsai/bot_control.py | 30 ++++++++++++++ wsai/dashboard.py | 96 +++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 dave/filter.mjs diff --git a/dave/bot.mjs b/dave/bot.mjs index 1904591..875d1ee 100644 --- a/dave/bot.mjs +++ b/dave/bot.mjs @@ -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}`); } } diff --git a/dave/filter.mjs b/dave/filter.mjs new file mode 100644 index 0000000..b86900c --- /dev/null +++ b/dave/filter.mjs @@ -0,0 +1,18 @@ +// Listen filter (whitelist/blacklist) — pure, so it is unit-testable without +// booting the Discord client. +// +// Rules: +// - a blacklisted user OR a user with a blacklisted role is always excluded; +// - if any whitelist (user or role) is set, only whitelisted speakers pass; +// - otherwise everyone passes. +export function isAllowed(userId, roleIds, lists) { + const L = lists || {}; + const idSet = (arr) => new Set((arr || []).map((x) => x.id)); + const roles = new Set(roleIds || []); + const hasRole = (arr) => (arr || []).some((r) => roles.has(r.id)); + if (idSet(L.blacklistUsers).has(userId) || hasRole(L.blacklistRoles)) return false; + const wlUsers = L.whitelistUsers || []; + const wlRoles = L.whitelistRoles || []; + if (wlUsers.length === 0 && wlRoles.length === 0) return true; + return idSet(wlUsers).has(userId) || hasRole(wlRoles); +} diff --git a/wsai/bot_control.py b/wsai/bot_control.py index c05d494..294be6d 100644 --- a/wsai/bot_control.py +++ b/wsai/bot_control.py @@ -29,6 +29,36 @@ class BotControl: self._state: dict[str, Any] = {"connected": False, "ts": 0.0} self._commands: deque[dict[str, Any]] = deque() self._cmd_id = 0 + # Per-guild listen filter. Empty whitelist => listen to everyone; + # blacklist always excludes. Users and roles both supported. + self._lists: dict[str, dict[str, Any]] = {} + + # -- whitelist / blacklist (per guild) ------------------------------- # + @staticmethod + def _empty_lists() -> dict[str, Any]: + return {"whitelistUsers": [], "blacklistUsers": [], + "whitelistRoles": [], "blacklistRoles": []} + + def get_lists(self, guild_id: str) -> dict[str, Any]: + with self._lock: + return dict(self._lists.get(guild_id) or self._empty_lists()) + + def all_lists(self) -> dict[str, Any]: + with self._lock: + return {g: dict(v) for g, v in self._lists.items()} + + def set_lists(self, guild_id: str, lists: dict[str, Any]) -> dict[str, Any]: + clean = self._empty_lists() + for key in clean: + items = lists.get(key) or [] + # Normalise to [{id, name}] and drop anything without an id. + clean[key] = [ + {"id": str(it["id"]), "name": str(it.get("name", it["id"]))} + for it in items if isinstance(it, dict) and it.get("id") + ] + with self._lock: + self._lists[guild_id] = clean + return clean # -- bot -> dashboard (state push) ----------------------------------- # def report(self, state: dict[str, Any]) -> None: diff --git a/wsai/dashboard.py b/wsai/dashboard.py index 6a3ad96..c70c9ee 100644 --- a/wsai/dashboard.py +++ b/wsai/dashboard.py @@ -78,6 +78,11 @@ def _make_handler(dash: "Dashboard"): self._send_json(dash.bot.state()) elif path == "/api/bot/commands": self._send_json({"commands": dash.bot.drain()}) + elif path == "/api/bot/lists": + import urllib.parse as _up + q = _up.parse_qs(self.path.split("?", 1)[1] if "?" in self.path else "") + gid = (q.get("guildId", [""])[0]) + self._send_json({"ok": True, "guildId": gid, "lists": dash.bot.get_lists(gid)}) elif path == "/events": self._stream_events() else: @@ -102,6 +107,8 @@ def _make_handler(dash: "Dashboard"): self._handle_bot_report() elif path == "/api/bot/select": self._handle_bot_select() + elif path == "/api/bot/lists": + self._handle_bot_lists() else: self._send(404, b"not found", "text/plain; charset=utf-8") @@ -229,9 +236,10 @@ def _make_handler(dash: "Dashboard"): self._send_json({"ok": False, "error": "invalid JSON"}, 400) return dash.bot.report(data) - # Hand the bot any queued commands in the same round trip so it does - # not have to poll a second endpoint. - self._send_json({"ok": True, "commands": dash.bot.drain()}) + # Hand the bot any queued commands + the current listen filters in the + # same round trip so it does not have to poll extra endpoints. + self._send_json({"ok": True, "commands": dash.bot.drain(), + "lists": dash.bot.all_lists()}) def _handle_bot_select(self) -> None: """UI picked a server/voice channel → queue a join (or leave) command.""" @@ -251,6 +259,21 @@ def _make_handler(dash: "Dashboard"): monitor.log("info", "음성채널 나가기 요청") self._send_json({"ok": True, "commandId": cid}) + def _handle_bot_lists(self) -> None: + """Save the whitelist/blacklist (users + roles) for a guild.""" + raw = self._read_body() + try: + data = json.loads(raw.decode("utf-8")) if raw else {} + guild_id = (data.get("guildId") or "").strip() + if not guild_id: + raise ValueError("guildId required") + except (ValueError, AttributeError) as exc: + self._send_json({"ok": False, "error": str(exc)}, 400) + return + saved = dash.bot.set_lists(guild_id, data.get("lists") or {}) + monitor.log("info", f"청취 화이트/블랙리스트 업데이트 (guild={guild_id})") + self._send_json({"ok": True, "guildId": guild_id, "lists": saved}) + def _handle_log_mutate(self, action: str) -> None: """Per-line log delete/edit by event id.""" raw = self._read_body() @@ -627,6 +650,24 @@ PAGE = r""" background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px 16px; font-size:13px;box-shadow:0 10px 30px rgba(0,0,0,.4);opacity:0;transition:opacity .2s} .toast.show{opacity:1} + /* 화이트/블랙리스트 팝업 */ + .lst-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:0 0 10px} + .lst-row select,.lst-row input{background:var(--panel2);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:7px 10px;font-size:13px} + .lst-search{flex:1;min-width:140px} + .lst-results{max-height:210px;overflow:auto;border:1px solid var(--line);border-radius:10px;margin:0 0 12px} + .lst-item{display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid #16202b;font-size:13px} + .lst-item:last-child{border-bottom:none} + .lst-item .nm{flex:1} + .lst-item .rl{color:var(--muted);font-size:11px} + .mini{padding:3px 8px;font-size:11.5px;border-radius:7px;cursor:pointer;border:1px solid var(--line);background:#173042;color:var(--fg)} + .mini.w{border-color:#1f5236;color:#9ff0bd} + .mini.b{border-color:#5c2530;color:#ffb3bb} + .chips{display:flex;flex-wrap:wrap;gap:6px;margin:4px 0 12px} + .chip{display:inline-flex;gap:6px;align-items:center;background:var(--panel2);border:1px solid var(--line);border-radius:999px;padding:3px 10px;font-size:12px} + .chip.w{border-color:#1f5236} + .chip.b{border-color:#5c2530} + .chip button{background:none;border:none;color:var(--muted);cursor:pointer;padding:0} + .lst-h{font-size:12px;color:var(--muted);margin:8px 0 4px;font-weight:600} /* Bottom-docked VSCode-style terminal log panel */ main{padding-bottom:46px} .logdock{position:fixed;left:0;right:0;bottom:0;z-index:15;background:#0a0e13; @@ -679,6 +720,8 @@ PAGE = r""" 봇: 연결 안 됨 + +