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:
22
dave/bot.mjs
22
dave/bot.mjs
@@ -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
18
dave/filter.mjs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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){}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user