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:
@@ -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"""<!DOCTYPE html>
|
||||
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"""<!DOCTYPE html>
|
||||
<span class="botinfo" id="botinfo"><span class="dot off"></span>봇: 연결 안 됨</span>
|
||||
<label>서버 <select id="guildSel"><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>
|
||||
</section>
|
||||
<section class="sttbox" id="sttbox" style="display:none">
|
||||
@@ -1031,6 +1074,53 @@ $('vcSel').onchange = async ()=>{
|
||||
toast(channelId?'음성채널 참여 요청을 보냈습니다':'나가기 요청을 보냈습니다');
|
||||
}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(){
|
||||
try{ renderBot(await (await fetch('/api/bot/state')).json()); }catch(e){}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user