OC Checker

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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();
    }

})();