drrrkari Users Overlay

Overlay

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         drrrkari Users Overlay
// @namespace    http://tampermonkey.net/
// @version      7.7
// @description  Overlay
// @match        https://drrrkari.com/room/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const STORAGE = {
        KEYWORDS: 'drrrkari_kick_keywords',
        ENCIP_HISTORY: 'drrrkari_encip_history',
        ALL_USERS: 'drrrkari_all_users_history',
        TALK_CACHE: 'drrrkari_talk_cache',
        UPDATE_INTERVAL: 'drrrkari_update_interval'
    };

    const storage = {
        get: (key, def) => JSON.parse(localStorage.getItem(key) || JSON.stringify(def)),
        set: (key, val) => localStorage.setItem(key, JSON.stringify(val))
    };

    const COLORS = ['#0ff', '#ff0', '#f0f', '#f80', '#8ff', '#ff8'];
    const BAN_URL = 'https://drrrkari.com/room/?ajax=1';
    let updateIntervalMs = Math.max(100, Math.min(5000, (storage.get(STORAGE.UPDATE_INTERVAL, 0.1) * 1000)));
    let intervalId = null;
    let lastUpdateTime = Date.now();

    const overlay = document.createElement('div');
    Object.assign(overlay.style, {
        position: 'fixed', top: '5px', right: '10px',
        backgroundColor: 'rgba(0,0,0,0.85)', color: '#0f0',
        padding: '12px', borderRadius: '8px', fontFamily: 'monospace',
        fontSize: '13px', zIndex: '2147483647', height: '95vh', maxWidth: '380px',
        overflowY: 'hidden', boxShadow: '0 0 10px rgba(0,255,0,0.5)',
        border: '1px solid #0f0', display: 'flex', flexDirection: 'column'
    });
    document.body.appendChild(overlay);

    const helpBtn = document.createElement('div');
    helpBtn.textContent = '?';
    Object.assign(helpBtn.style, {
        position: 'absolute', top: '8px', right: '58px',
        width: '24px', height: '24px', backgroundColor: '#0f0', color: '#000',
        borderRadius: '50%', textAlign: 'center', lineHeight: '24px',
        fontWeight: 'bold', fontSize: '16px', cursor: 'pointer', zIndex: '10'
    });
    overlay.appendChild(helpBtn);

    const settingsBtn = document.createElement('div');
    settingsBtn.textContent = '⚙️';
    Object.assign(settingsBtn.style, {
        position: 'absolute', top: '8px', right: '28px',
        width: '24px', height: '24px', backgroundColor: '#0f0', color: '#000',
        borderRadius: '50%', textAlign: 'center', lineHeight: '24px',
        fontWeight: 'bold', fontSize: '16px', cursor: 'pointer', zIndex: '10'
    });
    overlay.appendChild(settingsBtn);

    const modal = document.createElement('div');
    Object.assign(modal.style, {
        display: 'none', position: 'fixed', top: '50%', left: '50%',
        transform: 'translate(-50%, -50%)', backgroundColor: 'rgba(0,0,0,0.95)',
        color: '#0f0', padding: '20px', border: '2px solid #0f0', borderRadius: '10px',
        zIndex: '2147483648', maxWidth: '440px', maxHeight: '80vh', overflowY: 'auto',
        boxShadow: '0 0 20px rgba(0,255,0,0.8)', fontSize: '14px'
    });
    modal.innerHTML = `
        <div style="position:relative;">
            <div id="m-title" style="text-align:center;font-weight:bold;font-size:16px;margin-bottom:12px;padding-right:30px;"></div>
            <button id="m-close" style="position:absolute;top:0;right:0;padding:4px 10px;background:#f00;color:#fff;border:none;border-radius:50%;cursor:pointer;font-weight:bold;font-size:14px;">×</button>
        </div>
        <div id="m-content" style="margin:15px 0;"></div>
    `;
    document.body.appendChild(modal);

    const mTitle = modal.querySelector('#m-title');
    const mContent = modal.querySelector('#m-content');
    modal.querySelector('#m-close').onclick = () => modal.style.display = 'none';

    function openModal(title, contentHTML) {
        mTitle.textContent = title;
        mContent.innerHTML = contentHTML;
        modal.style.display = 'block';
    }

    const keywordHelpHTML = `
        <div style="line-height:1.6;">
            <strong>Auto BAN キーワードの使い方</strong><br><br>
            ・<strong>通常キーワード</strong><br>
             例: <code>たぬき</code><br>
             → 名前の一部に「たぬき」を含むユーザーを自動BAN(通常BAN)<br><br>
            ・<strong>永久BAN(再入室不可)</strong><br>
             例: <code>たぬき id</code>(末尾に半角スペース+id)<br>
             → 名前一致 + block=1 で永久BAN<br><br>
            ・<strong>ENCIP完全一致BAN</strong><br>
             例: <code>@encip MxIg32hjSnqUcE5bV6dwQg==</code><br>
             → そのENCIPのユーザーを名前に関係なく自動BAN<br><br>
            ・<strong>ENCIP + 永久BAN</strong><br>
             例: <code>@encip MxIg32hjSnqUcE5bV6dwQg== id</code><br>
             → ENCIP一致 + block=1 で永久BAN<br><br>
            <strong>注意</strong>:<br>
            ・<code>id</code>の前には必ず半角スペースが必要です<br>
            ・<code>たぬきid</code>(スペースなし)は通常の名前一致BANになります<br>
            ・リストの×ボタンでいつでも削除可能
        </div>
    `;

    helpBtn.onclick = () => openModal('Auto BAN キーワードの使い方', keywordHelpHTML);

    settingsBtn.onclick = () => {
        const currentSec = (updateIntervalMs / 1000).toFixed(1);
        const nextUpdate = new Date(lastUpdateTime + updateIntervalMs);
        const jstNext = new Date(nextUpdate.getTime() + 9*60*60*1000);
        const timeStr = jstNext.toLocaleTimeString('ja-JP', {hour: '2-digit', minute: '2-digit', second: '2-digit'});

        openModal('更新頻度設定', `
            <div style="text-align:center;">
                <p>更新間隔(秒)を入力(0.1〜5.0)</p>
                <input id="interval-input" type="number" min="0.1" max="5.0" step="0.1" value="${currentSec}" style="width:100px;padding:6px;background:#222;color:#0f0;border:1px solid #0f0;border-radius:4px;text-align:center;">
                <p style="margin-top:12px;"><button id="apply-interval" style="padding:6px 12px;background:#0f0;color:#000;border:none;border-radius:6px;cursor:pointer;font-weight:bold;">適用</button></p>
                <hr style="border-color:#0f0;margin:20px 0;">
                <p>現在の更新間隔: <strong>${currentSec}秒</strong></p>
                <p>次回更新予定: <strong>${timeStr}</strong></p>
                <p style="margin-top:12px;"><button id="test-update" style="padding:6px 12px;background:#ff0;color:#000;border:none;border-radius:6px;cursor:pointer;font-weight:bold;">テスト</button></p>
            </div>
        `);

        const input = document.getElementById('interval-input');
        const applyBtn = document.getElementById('apply-interval');
        const testBtn = document.getElementById('test-update');

        const apply = () => {
            let val = parseFloat(input.value);
            if (isNaN(val) || val < 0.1 || val > 5.0) {
                alert('0.1〜5.0の範囲で入力してください');
                return;
            }
            updateIntervalMs = val * 1000;
            storage.set(STORAGE.UPDATE_INTERVAL, val);
            clearInterval(intervalId);
            lastUpdateTime = Date.now();
            intervalId = setInterval(() => {
                lastUpdateTime = Date.now();
                fetchUsersAndAutoBan();
            }, updateIntervalMs);
            modal.style.display = 'none';
        };

        applyBtn.onclick = apply;
        input.onkeydown = e => e.key === 'Enter' && apply();
        input.focus();
        input.select();

        testBtn.onclick = () => {
            const testSec = (updateIntervalMs / 1000).toFixed(1);
            alert(`テスト開始!\n\n${testSec}秒後に「更新テスト完了!」と表示されます。\n(実際の更新処理も実行されます)`);

            setTimeout(() => {
                openModal('更新テスト完了!', `
                    <div style="text-align:center;">
                        <p style="font-size:18px;color:#0f0;">✅ 更新テスト完了!</p>
                        <p>設定された間隔(${testSec}秒)で正しく更新されました。</p>
                    </div>
                `);
                fetchUsersAndAutoBan();
            }, updateIntervalMs);
        };
    };

    overlay.addEventListener('click', e => {
        if (e.target.dataset.history) {
            const names = JSON.parse(e.target.dataset.history);
            openModal('過去の名前履歴', names.map(n => `<div style="padding:4px 0;">・ ${n}</div>`).join(''));
        }
    });

    function setupAccordionAndButtons() {
        mContent.querySelectorAll('.acc-h').forEach(h => {
            h.onclick = e => {
                if (e.target.classList.contains('copy-btn') || e.target.classList.contains('delete-btn') || e.target.classList.contains('talk-btn')) return;
                const b = h.nextElementSibling;
                b.style.display = b.style.display === 'block' ? 'none' : 'block';
            };
        });

        mContent.addEventListener('click', e => {
            if (e.target.classList.contains('copy-btn')) {
                const encip = e.target.dataset.encip;
                navigator.clipboard.writeText(encip).then(() => {
                    const old = e.target.textContent;
                    e.target.textContent = 'コピー済み!';
                    e.target.style.background = '#00f';
                    setTimeout(() => {
                        e.target.textContent = old;
                        e.target.style.background = '#0f0';
                    }, 1000);
                }).catch(() => alert('コピー失敗'));
            } else if (e.target.classList.contains('delete-btn')) {
                const encip = e.target.dataset.encip;
                if (confirm(`ENCIP ${encip.substring(0,20)}... の履歴と発言を削除しますか?`)) {
                    let history = storage.get(STORAGE.ENCIP_HISTORY, {});
                    let talkCache = storage.get(STORAGE.TALK_CACHE, []);
                    const names = history[encip]?.names || [];
                    talkCache = talkCache.filter(t => !names.includes(t.name));
                    delete history[encip];
                    storage.set(STORAGE.ENCIP_HISTORY, history);
                    storage.set(STORAGE.TALK_CACHE, talkCache);
                    showAllDuplicates();
                }
            } else if (e.target.id === 'clear-all-duplicates') {
                if (confirm('すべての重複ユーザー履歴と関連発言を削除しますか?')) {
                    storage.set(STORAGE.ENCIP_HISTORY, {});
                    storage.set(STORAGE.TALK_CACHE, []);
                    showAllDuplicates();
                }
            } else if (e.target.classList.contains('talk-btn') || e.target.classList.contains('name-link')) {
                const name = e.target.dataset.name;
                const talks = storage.get(STORAGE.TALK_CACHE, []).filter(t => t.name === name);
                const formatted = talks.reverse().map(t => {
                    const time = new Date(t.time * 1000);
                    const jstTime = new Date(time.getTime() + 9*60*60*1000);
                    const hours = jstTime.getHours().toString().padStart(2, '0');
                    const mins = jstTime.getMinutes().toString().padStart(2, '0');
                    return `<div style="padding:4px 0;">・ ${hours}:${mins} ${t.message}</div>`;
                }).join('');
                openModal(`発言履歴 - ${name}`, formatted || '<div style="color:#888;">この名前での発言はありません</div>');
            }
        }, { once: false });
    }

    function showAllDuplicates() {
        let history = storage.get(STORAGE.ENCIP_HISTORY, {});
        const now = new Date();
        const jstOffset = 9 * 60 * 60 * 1000;
        const jstNow = new Date(now.getTime() + jstOffset);
        const today = jstNow.toISOString().split('T')[0];

        Object.keys(history).forEach(encip => {
            if (history[encip].lastUpdate !== today) {
                delete history[encip];
            }
        });
        storage.set(STORAGE.ENCIP_HISTORY, history);

        const duplicates = Object.entries(history)
            .filter(([,d]) => d.names.length > 1)
            .sort(([,a], [,b]) => b.names.length - a.names.length);

        const clearAllBtn = '<button id="clear-all-duplicates" style="padding:6px 12px;background:#f00;color:#fff;border:none;border-radius:6px;cursor:pointer;font-weight:bold;float:left;">全てクリア</button>';

        const talkCache = storage.get(STORAGE.TALK_CACHE, []);

        openModal(
            duplicates.length ? `すべての重複ユーザー (${duplicates.length}件)` : '重複ユーザーなし',
            `<div style="margin-bottom:12px;">${clearAllBtn}</div><div style="clear:both;"></div>` +
            (duplicates.length === 0
                ? '<div style="text-align:center;color:#888;">過去に名前を変えたユーザーはいません</div>'
                : duplicates.map(([encip, d]) => `
                    <div style="margin-bottom:8px;border:1px solid #0f0;border-radius:6px;overflow:hidden;">
                        <div class="acc-h" style="background:rgba(0,100,0,0.5);padding:8px;cursor:pointer;font-weight:bold;display:flex;align-items:center;">
                            <button class="copy-btn" data-encip="${encip}" style="margin-right:6px;padding:2px 6px;background:#0f0;color:#000;border:none;border-radius:4px;cursor:pointer;font-size:11px;">コピー</button>
                            <button class="delete-btn" data-encip="${encip}" style="margin-right:6px;padding:2px 6px;background:#f00;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px;">削除</button>
                            <span>ENCIP: ${encip.substring(0,20)}... (${d.names.length}名)</span>
                        </div>
                        <div class="acc-b" style="display:none;padding:8px;background:rgba(0,255,0,0.05);">
                            ${d.names.map(name => {
                                const count = talkCache.filter(t => t.name === name).length;
                                return `<div class="name-link" data-name="${name}" style="cursor:pointer;color:#ff0;padding:4px 0;font-weight:bold;">・ ${name} (${count}回)</div>`;
                            }).join('')}
                        </div>
                    </div>
                `).join(''))
        );
        setupAccordionAndButtons();
    }

    function showAllUsers() {
        let users = storage.get(STORAGE.ALL_USERS, []).slice().reverse();
        const talkCache = storage.get(STORAGE.TALK_CACHE, []);

        openModal(
            `すべてのユーザー履歴 (${users.length}件)`,
            users.length === 0
                ? '<div style="text-align:center;color:#888;">記録されたユーザーはいません</div>' + '<div style="text-align:center;margin-top:20px;"><button id="clear-all-users" style="padding:8px 16px;background:#f00;color:#fff;border:none;border-radius:6px;cursor:pointer;font-weight:bold;">履歴をクリア</button></div>'
                : users.map(u => {
                    const count = talkCache.filter(t => t.name === u.name).length;
                    return `
                    <div style="margin-bottom:8px;border:1px solid #0f0;border-radius:6px;overflow:hidden;">
                        <div class="acc-h" style="background:rgba(0,80,100,0.5);padding:8px;cursor:pointer;font-weight:bold;display:flex;align-items:center;justify-content:space-between;">
                            <span>名前: ${u.name} (${count}回)</span>
                            <button class="talk-btn" data-name="${u.name}" style="padding:2px 8px;background:#ff0;color:#000;border:none;border-radius:4px;cursor:pointer;font-size:11px;font-weight:bold;">発言</button>
                        </div>
                        <div class="acc-b" style="display:none;padding:8px;background:rgba(0,255,255,0.05);">
                            <div><strong>ID:</strong> ${u.id || 'N/A'}</div>
                            <div style="word-break:break-all;"><strong>ENCIP:</strong> ${u.encip || 'N/A'}</div>
                        </div>
                    </div>
                `;
                }).join('') + '<div style="text-align:center;margin-top:20px;"><button id="clear-all-users" style="padding:8px 16px;background:#f00;color:#fff;border:none;border-radius:6px;cursor:pointer;font-weight:bold;">履歴をクリア</button></div>'
        );
        setupAccordionAndButtons();

        document.getElementById('clear-all-users')?.addEventListener('click', () => {
            if (confirm('すべてのユーザー履歴と関連発言を完全に削除しますか?')) {
                storage.set(STORAGE.ALL_USERS, []);
                storage.set(STORAGE.TALK_CACHE, []);
                showAllUsers();
            }
        });
    }

    const btnContainer = document.createElement('div');
    btnContainer.style.cssText = 'margin-top:auto;display:flex;gap:8px;flex-wrap:wrap;';

    const dupBtn = document.createElement('button');
    dupBtn.textContent = 'すべての重複ユーザー';
    dupBtn.onclick = showAllDuplicates;

    const allBtn = document.createElement('button');
    allBtn.textContent = 'すべてのユーザー';
    allBtn.onclick = showAllUsers;

    [dupBtn, allBtn].forEach(b => {
        Object.assign(b.style, {
            padding:'8px', color:'#fff', border:'none', borderRadius:'6px',
            cursor:'pointer', fontWeight:'bold', fontSize:'13px', flex:'1'
        });
    });
    dupBtn.style.background = '#008f00';
    allBtn.style.background = '#006f88';

    btnContainer.append(dupBtn, allBtn);

    const kickSection = document.createElement('div');
    kickSection.innerHTML = '<div style="margin-bottom:8px;font-weight:bold;color:#ff0;">● Auto BAN Keywords</div>';

    const kwInput = document.createElement('input');
    kwInput.type = 'text';
    kwInput.placeholder = '';
    Object.assign(kwInput.style, {width:'68%',padding:'4px',background:'#222',color:'#0f0',border:'1px solid #0f0',borderRadius:'4px'});

    const kwAdd = document.createElement('button');
    kwAdd.textContent = '追加';
    Object.assign(kwAdd.style, {marginLeft:'4px',padding:'4px 8px',background:'#0f0',color:'#000',border:'none',borderRadius:'4px',cursor:'pointer'});

    const kwList = document.createElement('div');
    kwList.style.marginTop = '8px';

    function renderKeywordList() {
        const kws = storage.get(STORAGE.KEYWORDS, []);
        kwList.innerHTML = kws.length === 0
            ? ''
            : kws.map(kw => {
                let disp = kw;
                if (kw.startsWith('@encip ')) {
                    const enc = kw.replace('@encip ', '');
                    disp = `@encip ${enc.substring(0,16)}...${enc.endsWith(' id') ? ' id' : ''}`;
                }
                return `
                    <div style="display:flex;align-items:center;margin-bottom:4px;">
                        <span style="word-break:break-all;">${disp}</span>
                        <button style="margin-left:auto;padding:0 6px;background:#f00;color:#fff;border:none;border-radius:4px;cursor:pointer;"
                                onclick="removeKeyword('${kw.replace(/'/g, "\\'")}')">×</button>
                    </div>
                `;
            }).join('');
    }

    window.removeKeyword = kw => {
        const kws = storage.get(STORAGE.KEYWORDS, []).filter(k => k !== kw);
        storage.set(STORAGE.KEYWORDS, kws);
        renderKeywordList();
    };

    kwAdd.onclick = () => {
        const val = kwInput.value.trim();
        if (val && !storage.get(STORAGE.KEYWORDS, []).includes(val)) {
            const kws = storage.get(STORAGE.KEYWORDS, []);
            kws.push(val);
            storage.set(STORAGE.KEYWORDS, kws);
            renderKeywordList();
            kwInput.value = '';
        }
    };

    kickSection.append(kwInput, kwAdd, kwList);

    const usersHeader = document.createElement('div');
    usersHeader.style.marginTop = '16px';
    usersHeader.innerHTML = '<div style="margin-bottom:8px;font-weight:bold;color:#0f0;">● Users in Room (0)</div>';

    const usersContainer = document.createElement('div');
    usersContainer.style.flex = '1';
    usersContainer.style.overflowY = 'auto';

    overlay.append(kickSection, usersHeader, usersContainer, btnContainer);
    renderKeywordList();

    const banUser = async (userId, auto = false, permanent = false) => {
        // 手動BAN時は確認なし、Auto BANも当然なし
        try {
            const body = `ban_user=${encodeURIComponent(userId)}${permanent ? '&block=1' : ''}`;
            const res = await fetch(BAN_URL, {method:'POST', credentials:'include', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body});
            if (!auto) alert(res.ok ? `${permanent ? '永久' : ''}BAN成功` : 'BAN失敗');
            fetchUsersAndAutoBan();
        } catch (e) {
            if (!auto) alert('BANエラー: ' + e.message);
        }
    };
    window.banUser = banUser;

    let talkCache = storage.get(STORAGE.TALK_CACHE, []);
    let nameToEncip = {};

    async function fetchUsersAndAutoBan() {
        try {
            const res = await fetch('https://drrrkari.com/ajax.php', {
                method: 'POST', credentials: 'include',
                headers: {'Content-Type': 'application/x-www-form-urlencoded'},
                body: 'a=update'
            });
            if (!res.ok) throw new Error('Update failed');
            const data = await res.json();
            const users = data.users ? Object.values(data.users) : [];
            const talks = data.talks || [];

            usersHeader.innerHTML = `<div style="margin-bottom:8px;font-weight:bold;color:#0f0;">● Users in Room (${users.length})</div>`;
            if (users.length === 0) { usersContainer.innerHTML = 'No users'; return; }

            let encipHist = storage.get(STORAGE.ENCIP_HISTORY, {});
            let allHist = storage.get(STORAGE.ALL_USERS, []);
            const keywords = storage.get(STORAGE.KEYWORDS, []);

            users.forEach(u => u.name && u.encip && (nameToEncip[u.name] = u.encip));

            talks.forEach(t => {
                if (t.message && t.name && !t.message.startsWith('ーー')) {
                    const entry = {name: t.name, message: t.message, time: t.time};
                    if (!talkCache.some(c => c.time === entry.time && c.message === entry.message && c.name === entry.name)) {
                        talkCache.push(entry);
                        storage.set(STORAGE.TALK_CACHE, talkCache);
                    }
                }
            });

            const nameRules = {};
            const encipRules = {};
            keywords.forEach(k => {
                if (k.startsWith('@encip ')) {
                    let val = k.replace('@encip ', '').trim();
                    const perm = val.endsWith(' id');
                    if (perm) val = val.slice(0, -3).trim();
                    encipRules[val] = perm;
                } else {
                    let val = k.trim();
                    const perm = val.endsWith(' id');
                    if (perm) val = val.slice(0, -3).trim();
                    nameRules[val.toLowerCase()] = perm;
                }
            });

            const existing = {};
            Array.from(usersContainer.children).forEach(el => el.dataset.id && (existing[el.dataset.id] = el));

            const current = {};

            users.forEach(u => {
                const uid = u.id || 'no-id-' + (u.encip || Math.random());
                current[uid] = true;

                const entry = {name: u.name || 'Unknown', id: u.id || null, encip: u.encip || null};
                if (!allHist.some(e => e.name === entry.name && e.id === entry.id && e.encip === entry.encip)) {
                    allHist.push(entry);
                    storage.set(STORAGE.ALL_USERS, allHist);
                }

                const nameLow = (u.name || '').toLowerCase();
                let ban = false, perm = false;
                Object.keys(nameRules).forEach(k => { if (nameLow.includes(k)) { ban = true; perm = nameRules[k]; } });
                // ENCIP BAN修正:u.encip が存在する場合は完全一致判定
                if (u.encip && encipRules[u.encip]) {
                    ban = true;
                    perm = encipRules[u.encip];
                }
                if (ban && u.id) banUser(u.id, true, perm);

                if (u.encip) {
                    const e = u.encip;
                    if (!encipHist[e]) encipHist[e] = {names: []};
                    if (!encipHist[e].names.includes(u.name)) encipHist[e].names.push(u.name);
                    const now = new Date();
                    const jstOffset = 9 * 60 * 60 * 1000;
                    const jstNow = new Date(now.getTime() + jstOffset);
                    encipHist[e].lastUpdate = jstNow.toISOString().split('T')[0];
                    storage.set(STORAGE.ENCIP_HISTORY, encipHist);
                }

                let nameHTML = u.name || 'Unknown';
                if (u.encip && encipHist[u.encip]?.names.length > 1) {
                    const idx = encipHist[u.encip].names.indexOf(u.name) % COLORS.length;
                    const hist = JSON.stringify(encipHist[u.encip].names);
                    nameHTML = `<span style="color:${COLORS[idx]};font-weight:bold;cursor:pointer;text-decoration:underline;" data-history='${hist}'>${u.name}</span>`;
                }

                const banBtn = u.id
                    ? `<button style="margin-left:8px;padding:2px 6px;font-size:11px;background:#f00;color:#fff;border:none;border-radius:4px;cursor:pointer;" onclick="banUser('${u.id}', false)">BAN</button>`
                    : '<span style="color:#888;">(no ID)</span>';

                const html = `<div style="margin-bottom:8px;padding:6px;background:rgba(255,255,255,0.05);border-radius:4px;">
                    <strong>${nameHTML}</strong>${banBtn}<br>ID: ${u.id || 'N/A'}<br>ENCIP: ${u.encip || 'N/A'}
                </div>`;

                let el = existing[uid];
                if (el) {
                    if (el.innerHTML !== html) el.innerHTML = html;
                } else {
                    el = document.createElement('div');
                    el.dataset.id = uid;
                    el.innerHTML = html;
                    usersContainer.appendChild(el);
                }
            });

            Object.keys(existing).forEach(id => !current[id] && usersContainer.removeChild(existing[id]));

        } catch (e) {
            usersContainer.innerHTML = `<div style="color:#f00;">Error: ${e.message}</div>`;
            console.error(e);
        }
    }

    fetchUsersAndAutoBan();
    intervalId = setInterval(() => {
        lastUpdateTime = Date.now();
        fetchUsersAndAutoBan();
    }, updateIntervalMs);
})();