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, NoSubscriberBehavior,
} from '@discordjs/voice'; } from '@discordjs/voice';
import prism from 'prism-media'; import prism from 'prism-media';
import { isAllowed } from './filter.mjs';
// ---------- config ---------- // ---------- config ----------
function loadEnvFile() { function loadEnvFile() {
@@ -164,6 +165,7 @@ const perUser = new Map(); // userId -> { opusPackets, pcmFrames }
let currentGuildId = null, currentChannelId = null, currentChannelName = null; let currentGuildId = null, currentChannelId = null, currentChannelName = null;
const speakingSet = new Set(); // userIds currently speaking (for participant list) const speakingSet = new Set(); // userIds currently speaking (for participant list)
const activeSubs = new Set(); // userIds with an in-flight receive subscription 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. // Attach the bot's audio player (so it can speak) to a fresh connection.
function setupPlayer(connection) { function setupPlayer(connection) {
@@ -178,6 +180,16 @@ function setupReceiver(connection) {
receiver.speaking.on('start', (userId) => { receiver.speaking.on('start', (userId) => {
speakingSet.add(userId); speakingSet.add(userId);
if (userId === client.user.id || activeSubs.has(userId)) return; 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); activeSubs.add(userId);
if (!perUser.has(userId)) perUser.set(userId, { opusPackets: 0, pcmFrames: 0 }); if (!perUser.has(userId)) perUser.set(userId, { opusPackets: 0, pcmFrames: 0 });
const opusStream = receiver.subscribe(userId, { const opusStream = receiver.subscribe(userId, {
@@ -248,6 +260,15 @@ function buildState() {
voiceChannels: [...g.channels.cache.values()] voiceChannels: [...g.channels.cache.values()]
.filter((c) => c.isVoiceBased()) .filter((c) => c.isVoiceBased())
.map((c) => ({ id: c.id, name: c.name })), .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 = []; let members = [];
if (currentGuildId && currentChannelId) { if (currentGuildId && currentChannelId) {
@@ -281,6 +302,7 @@ async function reportLoop() {
}); });
j = await r.json(); j = await r.json();
} catch { return; } // dashboard down: keep running, retry next tick } 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 || [])) { for (const cmd of (j?.commands || [])) {
try { await handleCommand(cmd); } catch (e) { log(`command ${cmd?.type} failed: ${e.message}`); } try { await handleCommand(cmd); } catch (e) { log(`command ${cmd?.type} failed: ${e.message}`); }
} }

18
dave/filter.mjs Normal file
View File

@@ -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);
}

View File

@@ -29,6 +29,36 @@ class BotControl:
self._state: dict[str, Any] = {"connected": False, "ts": 0.0} self._state: dict[str, Any] = {"connected": False, "ts": 0.0}
self._commands: deque[dict[str, Any]] = deque() self._commands: deque[dict[str, Any]] = deque()
self._cmd_id = 0 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) ----------------------------------- # # -- bot -> dashboard (state push) ----------------------------------- #
def report(self, state: dict[str, Any]) -> None: def report(self, state: dict[str, Any]) -> None:

View File

@@ -78,6 +78,11 @@ def _make_handler(dash: "Dashboard"):
self._send_json(dash.bot.state()) self._send_json(dash.bot.state())
elif path == "/api/bot/commands": elif path == "/api/bot/commands":
self._send_json({"commands": dash.bot.drain()}) 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": elif path == "/events":
self._stream_events() self._stream_events()
else: else:
@@ -102,6 +107,8 @@ def _make_handler(dash: "Dashboard"):
self._handle_bot_report() self._handle_bot_report()
elif path == "/api/bot/select": elif path == "/api/bot/select":
self._handle_bot_select() self._handle_bot_select()
elif path == "/api/bot/lists":
self._handle_bot_lists()
else: else:
self._send(404, b"not found", "text/plain; charset=utf-8") 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) self._send_json({"ok": False, "error": "invalid JSON"}, 400)
return return
dash.bot.report(data) dash.bot.report(data)
# Hand the bot any queued commands in the same round trip so it does # Hand the bot any queued commands + the current listen filters in the
# not have to poll a second endpoint. # same round trip so it does not have to poll extra endpoints.
self._send_json({"ok": True, "commands": dash.bot.drain()}) self._send_json({"ok": True, "commands": dash.bot.drain(),
"lists": dash.bot.all_lists()})
def _handle_bot_select(self) -> None: def _handle_bot_select(self) -> None:
"""UI picked a server/voice channel → queue a join (or leave) command.""" """UI picked a server/voice channel → queue a join (or leave) command."""
@@ -251,6 +259,21 @@ def _make_handler(dash: "Dashboard"):
monitor.log("info", "음성채널 나가기 요청") monitor.log("info", "음성채널 나가기 요청")
self._send_json({"ok": True, "commandId": cid}) 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: def _handle_log_mutate(self, action: str) -> None:
"""Per-line log delete/edit by event id.""" """Per-line log delete/edit by event id."""
raw = self._read_body() raw = self._read_body()
@@ -627,6 +650,24 @@ PAGE = r"""<!DOCTYPE html>
background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px 16px; 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} font-size:13px;box-shadow:0 10px 30px rgba(0,0,0,.4);opacity:0;transition:opacity .2s}
.toast.show{opacity:1} .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 */ /* Bottom-docked VSCode-style terminal log panel */
main{padding-bottom:46px} main{padding-bottom:46px}
.logdock{position:fixed;left:0;right:0;bottom:0;z-index:15;background:#0a0e13; .logdock{position:fixed;left:0;right:0;bottom:0;z-index:15;background:#0a0e13;
@@ -679,6 +720,8 @@ PAGE = r"""<!DOCTYPE html>
<span class="botinfo" id="botinfo"><span class="dot off"></span>봇: 연결 안 됨</span> <span class="botinfo" id="botinfo"><span class="dot off"></span>봇: 연결 안 됨</span>
<label>서버 <select id="guildSel"><option value="">없음</option></select></label> <label>서버 <select id="guildSel"><option value="">없음</option></select></label>
<label>음성채널 <select id="vcSel"><option value="">없음</option></select></label> <label>음성채널 <select id="vcSel"><option value="">없음</option></select></label>
<button id="wlBtn" class="btn hbtn">화이트리스트</button>
<button id="blBtn" class="btn hbtn">블랙리스트</button>
<span class="parts" id="parts"></span> <span class="parts" id="parts"></span>
</section> </section>
<section class="sttbox" id="sttbox" style="display:none"> <section class="sttbox" id="sttbox" style="display:none">
@@ -1031,6 +1074,53 @@ $('vcSel').onchange = async ()=>{
toast(channelId?'음성채널 참여 요청을 보냈습니다':'나가기 요청을 보냈습니다'); toast(channelId?'음성채널 참여 요청을 보냈습니다':'나가기 요청을 보냈습니다');
}catch(e){ toast('요청 실패: '+e); } }catch(e){ toast('요청 실패: '+e); }
}; };
// --- 화이트/블랙리스트 팝업 (유저·역할 검색 → 화이트/블랙 추가·제거) ------ #
const LKEY = {wu:'whitelistUsers', bu:'blacklistUsers', wr:'whitelistRoles', br:'blacklistRoles'};
async function openLists(){
const guildId = $('guildSel').value || (botState&&botState.current&&botState.current.guildId) || '';
if(!guildId){ toast('먼저 서버를 선택하세요'); return; }
const g = ((botState&&botState.guilds)||[]).find(x=>x.id===guildId) || {members:[],roles:[]};
let lists;
try{ lists = (await (await fetch('/api/bot/lists?guildId='+encodeURIComponent(guildId))).json()).lists; }
catch(e){ lists = {whitelistUsers:[],blacklistUsers:[],whitelistRoles:[],blacklistRoles:[]}; }
openModal('청취 화이트/블랙리스트', '<button class="btn primary" id="lstSave">저장</button>');
$('modalBody').innerHTML =
'<p class="modal-note">화이트리스트에 넣으면 그 대상만 청취(비어있으면 전체 청취), 블랙리스트는 제외됩니다. 유저/역할별로 추가할 수 있어요.</p>'
+'<div class="lst-row"><select id="lstType"><option value="user">유저</option><option value="role">역할</option></select>'
+'<input id="lstSearch" class="lst-search" placeholder="이름으로 검색"></div>'
+'<div class="lst-results" id="lstResults"></div>'
+'<div class="lst-h">화이트리스트 (그 대상만 청취)</div><div class="chips" id="chipsW"></div>'
+'<div class="lst-h">블랙리스트 (제외)</div><div class="chips" id="chipsB"></div>';
const has=(arr,id)=>(arr||[]).some(x=>x.id===id);
function add(kind,item){ const k=LKEY[kind]; if(!has(lists[k],item.id)) lists[k].push(item); renderChips(); }
function rm(k,id){ lists[k]=(lists[k]||[]).filter(x=>x.id!==id); renderChips(); }
function renderResults(){
const type=$('lstType').value, q=$('lstSearch').value.trim().toLowerCase();
const src = type==='user' ? (g.members||[]) : (g.roles||[]);
const rows = src.filter(x=>!q || (x.name||'').toLowerCase().includes(q)).slice(0,100);
$('lstResults').innerHTML = rows.length ? rows.map(x=>
'<div class="lst-item"><span class="nm">'+esc(x.name)+(x.bot?' <span class="rl">(봇)</span>':'')+'</span>'
+'<button class="mini w" data-k="'+(type==='user'?'wu':'wr')+'" data-id="'+esc(x.id)+'" data-nm="'+esc(x.name)+'">+화이트</button>'
+'<button class="mini b" data-k="'+(type==='user'?'bu':'br')+'" data-id="'+esc(x.id)+'" data-nm="'+esc(x.name)+'">+블랙</button></div>'
).join('') : '<div class="lst-item"><span class="rl">결과 없음 · 봇이 아는 멤버/역할만 검색됩니다</span></div>';
}
const chip=(k,cls,x)=>'<span class="chip '+cls+'">'+esc(x.name)+' <button data-k="'+k+'" data-id="'+esc(x.id)+'">✕</button></span>';
const empty='<span class="rl" style="color:var(--muted);font-size:12px">비어있음</span>';
function renderChips(){
$('chipsW').innerHTML = [...lists.whitelistUsers.map(x=>chip('whitelistUsers','w',x)),...lists.whitelistRoles.map(x=>chip('whitelistRoles','w',x))].join('') || (empty+' (전체 청취)');
$('chipsB').innerHTML = [...lists.blacklistUsers.map(x=>chip('blacklistUsers','b',x)),...lists.blacklistRoles.map(x=>chip('blacklistRoles','b',x))].join('') || empty;
}
$('lstType').onchange=renderResults; $('lstSearch').oninput=renderResults;
$('lstResults').onclick=(e)=>{ const b=e.target.closest('.mini'); if(!b)return; add(b.dataset.k,{id:b.dataset.id,name:b.dataset.nm}); };
$('chipsW').onclick=$('chipsB').onclick=(e)=>{ const b=e.target.closest('button'); if(!b)return; rm(b.dataset.k,b.dataset.id); };
$('lstSave').onclick=async()=>{
try{ await fetch('/api/bot/lists',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({guildId,lists})}); toast('청취 필터 저장됨 · 봇에 곧 반영'); closeModal(); }
catch(e){ toast('저장 실패: '+e); }
};
renderResults(); renderChips();
}
$('wlBtn').onclick = openLists; $('blBtn').onclick = openLists;
async function pollBot(){ async function pollBot(){
try{ renderBot(await (await fetch('/api/bot/state')).json()); }catch(e){} try{ renderBot(await (await fetch('/api/bot/state')).json()); }catch(e){}
} }