OC Checker

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

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например 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();
    }

})();