// ==UserScript== // @name CCO Favourites notifier // @namespace http://tampermonkey.net/ // @version 4.0 // @description Discord notifications for favorited skins via API interception. One-time GUI setup. // @author Jamie // @match https://case-clicker.com/* // @run-at document-start // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant unsafeWindow // @connect discord.com // @connect discordapp.com // ==/UserScript== (function() { 'use strict'; // --- CONFIGURATION --- const BOT_NAME = "CCO Autofavourite notifier"; const BOT_AVATAR = "https://lh3.googleusercontent.com/a/ACg8ocJkongVbvAgUgHie_W66n8pAEAf7sMiOAmTS8WqfmW504xgxV0=s96-c"; const TARGET_ENDPOINT = "/api/open/case"; const STEAM_IMG_BASE = "https://community.cloudflare.steamstatic.com/economy/image/"; let WEBHOOK_URL = GM_getValue('webhook_url', ''); // --- GUI STYLES --- GM_addStyle(` #cco-setup-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 9999; display: flex; align-items: center; justify-content: center; font-family: sans-serif; } #cco-setup-modal { background: #25262b; border: 1px solid #373a40; border-radius: 8px; padding: 24px; width: 400px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); color: #C1C2C5; } #cco-setup-modal h2 { margin-top: 0; color: #fff; font-size: 18px; } #cco-setup-modal p { font-size: 13px; line-height: 1.4; color: #909296; } #cco-webhook-input { width: 100%; padding: 10px; margin: 15px 0; background: #1a1b1e; border: 1px solid #373a40; border-radius: 4px; color: #fff; font-size: 12px; box-sizing: border-box; } #cco-save-btn { width: 100%; padding: 10px; background: #4c6ef5; border: none; border-radius: 4px; color: #fff; cursor: pointer; font-weight: 600; transition: background 0.2s; } #cco-save-btn:hover { background: #3b5bdb; } `); function showSetupGUI() { const tryMount = () => { if (!document.body) { setTimeout(tryMount, 50); return; } const overlay = document.createElement('div'); overlay.id = 'cco-setup-overlay'; overlay.innerHTML = `

Setup Required

Please enter your Discord Webhook URL to begin receiving notifications for favorited skins.

`; document.body.appendChild(overlay); document.getElementById('cco-save-btn').onclick = function() { const val = document.getElementById('cco-webhook-input').value.trim(); if (val.startsWith('https://discord.com/api/webhooks/')) { GM_setValue('webhook_url', val); WEBHOOK_URL = val; overlay.remove(); console.log(`%c[CCO] Webhook saved. Notifier active.`, "color: #00ff00; font-weight: bold;"); } else { alert("Please enter a valid Discord Webhook URL."); } }; }; tryMount(); } function buildPayload(item) { // rarityColor is a hex string like "eb4b4b" — parse to int for Discord let embedColor = 5814783; // fallback blurple if (item.rarityColor) { const parsed = parseInt(item.rarityColor, 16); if (!isNaN(parsed)) embedColor = parsed; } const isKnife = item.type === "Knife" || item.knifeType !== null; const hasStickers = Array.isArray(item.stickers) && item.stickers.length > 0; let displayTitle = item.name || "Unknown Item"; if (hasStickers) displayTitle += " (event)"; const priceStr = typeof item.price === 'number' ? `$${item.price.toFixed(2)}` : "Unknown"; const floatStr = typeof item.float === 'number' ? item.float.toFixed(10) : "Unknown"; const fields = [ { name: "Wear", value: item.exterior || "Unknown", inline: true }, { name: "Price", value: `**${priceStr}**`, inline: true }, { name: "Rarity", value: item.rarity || "Unknown", inline: true } ]; if (hasStickers) { // Stickers shape is unknown in this dataset — handle both string array and object array defensively const stickerNames = item.stickers.map(s => { if (typeof s === 'string') return s; return s.name || s.stickerName || JSON.stringify(s); }); fields.push({ name: "Stickers", value: stickerNames.join(' • '), inline: false }); } fields.push({ name: "Float", value: `\`${floatStr}\``, inline: false }); if (item.origin && item.origin.name) { fields.push({ name: "From", value: item.origin.name, inline: true }); } const imgUrl = item.iconUrl ? STEAM_IMG_BASE + item.iconUrl : null; const embed = { title: displayTitle, color: embedColor, fields: fields, footer: { text: `${BOT_NAME} • ${new Date().toLocaleTimeString()}`, icon_url: BOT_AVATAR } }; if (imgUrl) embed.thumbnail = { url: imgUrl }; // Mark knives/StatTrak in author field for visibility const tags = []; if (isKnife) tags.push("★ Knife"); if (item.statTrak) tags.push("StatTrak™"); if (item.souvenir) tags.push("Souvenir"); if (tags.length > 0) embed.author = { name: tags.join(" • ") }; return { username: BOT_NAME, avatar_url: BOT_AVATAR, embeds: [embed] }; } function sendToDiscord(item) { if (!WEBHOOK_URL) return; const payload = buildPayload(item); GM_xmlhttpRequest({ method: "POST", url: WEBHOOK_URL, headers: { "Content-Type": "application/json" }, data: JSON.stringify(payload), onerror: (e) => console.error("[CCO] Discord webhook error:", e) }); } // Tracks recently-sent item IDs to prevent fetch+XHR firing the same notification twice const recentlySent = new Map(); const DEDUPE_WINDOW_MS = 10000; // 10s is plenty — a single case open finishes in <1s function handleResponseBody(bodyText) { if (!bodyText) return; let data; try { data = JSON.parse(bodyText); } catch (e) { return; } const favorited = []; if (Array.isArray(data.favoritedSkins)) { favorited.push(...data.favoritedSkins); } if (Array.isArray(data.openedSkins)) { favorited.push(...data.openedSkins.filter(s => s && s.isFavorite)); } if (favorited.length === 0) return; const now = Date.now(); // Sweep expired entries for (const [id, ts] of recentlySent) { if (now - ts > DEDUPE_WINDOW_MS) recentlySent.delete(id); } favorited.forEach(item => { const id = item && item._id; if (id && recentlySent.has(id)) return; // already sent very recently if (id) recentlySent.set(id, now); try { sendToDiscord(item); } catch (e) { console.error("[CCO] Error sending item:", e, item); } }); } // --- FETCH INTERCEPT --- // Other userscripts on this site (cco-float-rank-highlighter, ZSB hubs) also wrap fetch. // We need to hook unsafeWindow.fetch (the page's real fetch) and re-apply if anything overwrites us. const targetWindow = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; function installFetchHook() { const current = targetWindow.fetch; if (current && current.__ccoHooked) return; // already wrapped by us const original = current; const wrapped = function(input, init) { const url = (typeof input === 'string') ? input : (input && input.url) || ''; const isTarget = url.includes(TARGET_ENDPOINT); const promise = original.apply(this, arguments); if (!isTarget) return promise; return promise.then(response => { try { response.clone().text() .then(text => handleResponseBody(text)) .catch(e => console.warn('[CCO] body read failed:', e)); } catch (e) { console.warn('[CCO] clone failed:', e); } return response; }); }; wrapped.__ccoHooked = true; targetWindow.fetch = wrapped; } installFetchHook(); // Re-install periodically in case another script wraps over us later setInterval(installFetchHook, 1000); // --- XHR INTERCEPT --- const xhrProto = targetWindow.XMLHttpRequest && targetWindow.XMLHttpRequest.prototype; if (xhrProto && !xhrProto.__ccoHooked) { const origOpen = xhrProto.open; const origSend = xhrProto.send; xhrProto.open = function(method, url) { this._ccoUrl = url; return origOpen.apply(this, arguments); }; xhrProto.send = function() { if (this._ccoUrl && this._ccoUrl.includes && this._ccoUrl.includes(TARGET_ENDPOINT)) { this.addEventListener('load', () => { try { handleResponseBody(this.responseText); } catch (e) { /* swallow */ } }); } return origSend.apply(this, arguments); }; xhrProto.__ccoHooked = true; } // --- MAIN ENTRY POINT --- if (!WEBHOOK_URL) { // Defer GUI until DOM is ready, but interception is already live if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', showSetupGUI); } else { showSetupGUI(); } } })();