OC Checker

Sends list of members not currently in OCs to discord webhook.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         OC Checker
// @namespace    http://tampermonkey.net/
// @version      1.3
// @description  Sends list of members not currently in OCs to discord webhook.
// @author       Deviyl[3722358]
// @license      MIT
// @icon         https://raw.githubusercontent.com/deviyl/icon/refs/heads/main/devicon-modified.png
// @match        https://www.torn.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @connect      api.torn.com
// @connect      discord.com
// ==/UserScript==

// Donations are always appreciated if you find this helpful. <3

/* TORN API DISCLOSURE & USAGE:
    This script requires your Torn API key to retrieve authorized game data.
    Your API key is stored locally in your browser only and is never transmitted, shared, or sent to any external server. All API requests are made directly from your browser to Torn's official API.
    You may revoke your API key at any time via your Torn account settings.
*/

(function () {
    'use strict';

    // -------------------------
    // CONFIGURATION
    // -------------------------
    const SIGNUP_URL = 'https://www.torn.com/factions.php?step=your&type=1#/tab=crimes';

    // -------------------------
    // STYLES
    // -------------------------
    const style = document.createElement('style');
    style.textContent = `
        #oc-checker-wrapper { position: fixed; bottom: 20px; left: 20px; z-index: 99999; display: flex; align-items: stretch; background: #5865F2; border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); overflow: hidden; }
        #oc-checker-btn { padding: 10px 16px; background: transparent; color: #ffffff; border: none; font-size: 14px; font-weight: bold; cursor: pointer; transition: background 0.2s; white-space: nowrap; }
        #oc-checker-btn:hover { background: rgba(0,0,0,0.15); }
        #oc-checker-btn:disabled { opacity: 0.6; cursor: not-allowed; pointer-events: none; }
        #oc-checker-cog { padding: 10px 12px; background: transparent; border: none; border-left: 1px solid rgba(255,255,255,0.25); font-size: 14px; color: #ffffff; cursor: pointer; transition: background 0.2s; display: flex; align-items: center; }
        #oc-checker-cog:hover { background: rgba(0,0,0,0.15); }
        #oc-checker-modal { position: fixed; bottom: 70px; left: 20px; z-index: 100000; background: #1a1a1a; color: #fff; border: 2px solid #333; border-radius: 8px; padding: 12px; width: 340px; max-width: 90vw; box-shadow: 0 4px 10px rgba(0, 0, 0, 0.6); }
        #oc-checker-modal h3 { margin-top: 0; }
        #oc-checker-modal input[type=text] { width: 100%; padding: 6px; margin: 4px 0 10px 0; border-radius: 4px; border: 1px solid #555; background: #111; color: #fff; box-sizing: border-box; }
        #oc-checker-modal input[type=text]::placeholder { color: #666; }
        .oc-btn-row { display: flex; gap: 8px; margin-top: 4px; }
        .oc-btn { flex: 1; padding: 6px; border: none; border-radius: 4px; color: white; cursor: pointer; font-weight: bold; }
        .oc-btn:hover { opacity: 0.85; }
        .oc-close { position: absolute; top: 8px; right: 10px; font-size: 16px; color: #999; cursor: pointer; }
        .oc-schedule-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
        .oc-schedule-label { display: flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer; }
        .oc-schedule-label input[type=checkbox] { width: auto; margin: 0; cursor: pointer; }
        .oc-schedule-time { display: flex; align-items: center; gap: 6px; }
        .oc-schedule-time input[type=text] { width: 60px !important; margin: 0 !important; text-align: center; }
        .oc-schedule-tct { font-size: 11px; color: #888; }
    `;

    // -------------------------
    // UTILITIES
    // -------------------------
    function getProfileLink(name, id) {
        if (!name || !id) return 'Unknown User';
        return `[${name} [${id}]](https://www.torn.com/profiles.php?XID=${id})`;
    }

    function setButtonState(btn, text) {
        if (!btn) return;
        btn.textContent = text;
        btn.disabled = false;
        setTimeout(() => { btn.textContent = '🔔 Check OC & Notify Discord'; }, 60 * 1000);
    }

    function getTodayUTCString() {
        const now = new Date();
        return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}-${String(now.getUTCDate()).padStart(2, '0')}`;
    }

    function delay(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    function parseScheduleTime(raw) {
        const cleaned = raw.replace(':', '').trim();
        if (!/^\d{3,4}$/.test(cleaned)) return '';
        const padded = cleaned.padStart(4, '0');
        return `${padded.slice(0, 2)}:${padded.slice(2)}`;
    }

    function getMsUntilNextScheduledTime(hhmm) {
        const [hh, mm] = hhmm.split(':').map(Number);
        const now = new Date();
        let next = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hh, mm, 0));
        if (next <= now) next = new Date(next.getTime() + 24 * 60 * 60 * 1000);
        return next - now;
    }

    // -------------------------
    // SETTINGS MODAL
    // -------------------------
    function toggleSettingsModal() {
        const existing = document.getElementById('oc-checker-modal');
        if (existing) { existing.remove(); return; }

        const modal = document.createElement('div');
        modal.id = 'oc-checker-modal';

        modal.innerHTML = `
            <span class="oc-close" id="oc-checker-modal-close">✕</span>
            <h3>OC Checker Settings</h3>
            <label>Torn API Key</label>
            <input type="text" id="oc-modal-api" placeholder="Public API key">
            <label>Discord Webhook</label>
            <input type="text" id="oc-modal-webhook" placeholder="Webhook URL">
            <div class="oc-schedule-row">
                <label class="oc-schedule-label">
                    <input type="checkbox" id="oc-modal-schedule-enabled">
                    Enable daily scheduled ping
                </label>
                <div class="oc-schedule-time">
                    <input type="text" id="oc-modal-schedule-time" placeholder="hh:mm" maxlength="5">
                    <span class="oc-schedule-tct">TCT</span>
                </div>
            </div>
            <div class="oc-btn-row">
                <button class="oc-btn" id="oc-modal-save-exit" style="background: #4e5058;">Save & Exit</button>
                <button class="oc-btn" id="oc-modal-save-ping" style="background: #5865F2;">Save & Ping</button>
            </div>
        `;

        document.body.appendChild(modal);

        document.getElementById('oc-modal-api').value = GM_getValue('oc_checker_api_key', '');
        document.getElementById('oc-modal-webhook').value = GM_getValue('oc_checker_webhook', '');
        document.getElementById('oc-modal-schedule-enabled').checked = GM_getValue('oc_checker_schedule_enabled', false);
        document.getElementById('oc-modal-schedule-time').value = GM_getValue('oc_checker_schedule_time', '');

        document.getElementById('oc-checker-modal-close').onclick = () => modal.remove();

        document.getElementById('oc-modal-save-exit').onclick = () => {
            const apiKey = document.getElementById('oc-modal-api').value.trim();
            const webhook = document.getElementById('oc-modal-webhook').value.trim();
            if (!apiKey || !webhook) return;
            GM_setValue('oc_checker_api_key', apiKey);
            GM_setValue('oc_checker_webhook', webhook);
            GM_setValue('oc_checker_schedule_enabled', document.getElementById('oc-modal-schedule-enabled').checked);
            GM_setValue('oc_checker_schedule_time', parseScheduleTime(document.getElementById('oc-modal-schedule-time').value));
            modal.remove();
        };

        document.getElementById('oc-modal-save-ping').onclick = () => {
            const apiKey = document.getElementById('oc-modal-api').value.trim();
            const webhook = document.getElementById('oc-modal-webhook').value.trim();
            if (!apiKey || !webhook) return;
            GM_setValue('oc_checker_api_key', apiKey);
            GM_setValue('oc_checker_webhook', webhook);
            GM_setValue('oc_checker_schedule_enabled', document.getElementById('oc-modal-schedule-enabled').checked);
            GM_setValue('oc_checker_schedule_time', parseScheduleTime(document.getElementById('oc-modal-schedule-time').value));
            modal.remove();
            runCheck();
        };

        document.getElementById('oc-modal-api').focus();
    }

    // -------------------------
    // API
    // -------------------------
    function fetchMembers(apiKey) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: `https://api.torn.com/v2/faction/members?key=${apiKey}`,
                onload: (res) => {
                    try {
                        const data = JSON.parse(res.responseText);
                        if (data.error) return reject(`API Error ${data.error.code}: ${data.error.error}`);
                        resolve(data.members || []);
                    } catch (e) {
                        reject('Failed to parse API response.');
                    }
                },
                onerror: () => reject('Network error reaching Torn API.'),
            });
        });
    }

    function fetchDiscordId(apiKey, userId) {
        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: `https://api.torn.com/v2/user/${userId}/discord?key=${apiKey}`,
                onload: (res) => {
                    try {
                        const data = JSON.parse(res.responseText);
                        resolve(data?.discord?.discord_id || '');
                    } catch (e) {
                        resolve('');
                    }
                },
                onerror: () => resolve(''),
            });
        });
    }

    function sendDiscord(webhook, message) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'POST',
                url: webhook,
                headers: { 'Content-Type': 'application/json' },
                data: JSON.stringify({ content: message }),
                onload: (res) => {
                    if (res.status >= 200 && res.status < 300) resolve();
                    else reject(`Discord responded with status ${res.status}`);
                },
                onerror: () => reject('Network error reaching Discord webhook.'),
            });
        });
    }

    // -------------------------
    // CORE LOGIC
    // -------------------------
    async function runCheck() {
        const apiKey = GM_getValue('oc_checker_api_key', '');
        const webhook = GM_getValue('oc_checker_webhook', '');
        const btn = document.getElementById('oc-checker-btn');

        if (btn) { btn.textContent = '⏳ Checking...'; btn.disabled = true; }

        try {
            const members = await fetchMembers(apiKey);
            const notInOC = members.filter(m => m.is_in_oc === false && m.position !== 'Recruit' && m.status.state !== 'Fallen');

            if (notInOC.length === 0) {
                setButtonState(btn, '✅ All members in an OC!');
                return;
            }

            const memberLines = [];
            for (const m of notInOC) {
                const discordId = await fetchDiscordId(apiKey, m.id);
                const profileLink = getProfileLink(m.name, m.id);
                memberLines.push(discordId ? `<@${discordId}> -- ${profileLink}` : profileLink);
                await delay(300);
            }

            const header = `The following members are not currently participating in an organized crime [Click here to join!](${SIGNUP_URL}) :::`;
            await sendDiscord(webhook, `${header}\n${memberLines.join('\n')}`);
            setButtonState(btn, `✅ Notified ${notInOC.length} member(s)`);

        } catch (err) {
            console.error('[OC Checker]', err);
            setButtonState(btn, '❌ Error — check console');
        }
    }

    // -------------------------
    // UI
    // -------------------------
    function injectUI() {
        if (document.getElementById('oc-checker-wrapper')) return;

        const wrapper = document.createElement('div');
        wrapper.id = 'oc-checker-wrapper';

        const btn = document.createElement('button');
        btn.id = 'oc-checker-btn';
        btn.textContent = '🔔 Check OC & Notify Discord';
        btn.addEventListener('click', () => {
            if (!GM_getValue('oc_checker_api_key', '') || !GM_getValue('oc_checker_webhook', '')) {
                toggleSettingsModal();
            } else {
                runCheck();
            }
        });

        const cog = document.createElement('button');
        cog.id = 'oc-checker-cog';
        cog.title = 'Settings';
        cog.textContent = '⚙️';
        cog.addEventListener('click', toggleSettingsModal);

        wrapper.appendChild(btn);
        wrapper.appendChild(cog);

        if (window.innerWidth < 600) {
            wrapper.style.cssText = 'position: static; display: inline-flex; margin: -100px 4px 100px;';
            const footer = document.querySelector('div.footer');
            if (footer) {
                footer.parentNode.insertBefore(wrapper, footer);
            } else {
                document.body.appendChild(wrapper);
            }
        } else {
            document.body.appendChild(wrapper);
        }
    }

    // -------------------------
    // SCHEDULER
    // -------------------------
    async function scheduledCheck() {
        if (!GM_getValue('oc_checker_schedule_enabled', false)) return;
        if (GM_getValue('oc_checker_last_fired', '') === getTodayUTCString()) return;
        if (!GM_getValue('oc_checker_api_key', '') || !GM_getValue('oc_checker_webhook', '')) return;
        await delay(Math.floor(Math.random() * 10000));
        if (GM_getValue('oc_checker_last_fired', '') === getTodayUTCString()) return;
        GM_setValue('oc_checker_last_fired', getTodayUTCString());
        await runCheck();
    }

    function scheduledTimePassed() {
        const scheduleTime = GM_getValue('oc_checker_schedule_time', '');
        const hhmm = scheduleTime || '00:00';
        const [hh, mm] = hhmm.split(':').map(Number);
        const now = new Date();
        const scheduled = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hh, mm, 0));
        return now >= scheduled;
    }

    function startScheduler() {
        const hhmm = GM_getValue('oc_checker_schedule_time', '') || '00:00';
        if (scheduledTimePassed()) scheduledCheck();
        const msUntilNext = getMsUntilNextScheduledTime(hhmm);
        setTimeout(() => {
            scheduledCheck();
            setInterval(scheduledCheck, 24 * 60 * 60 * 1000);
        }, msUntilNext);
    }

    // -------------------------
    // INITIALIZATION
    // -------------------------
    document.head.appendChild(style);

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => { injectUI(); startScheduler(); });
    } else {
        injectUI();
        startScheduler();
    }

})();