(async () => {
"use strict";
/* ── config (Safe for users to edit) ────────────────────────── */
const CONFIG = {
NAME: "Orion",
VERSION: "v4.9.7",
THEME: "#5865F2", // discord blurple
SUCCESS: "#3BA55C",
WARN: "#faa61a",
ERR: "#f04747",
HIDE_ACTIVITY: false, // suppress RPC status from friends list
MAX_LOG_ITEMS: 60 // UI log limit
};
/* ── internal system limits (DO NOT EDIT) ─────────────────── */
const SYS = Object.freeze({
MAX_TIME: 25 * 60 * 1000, // hard abort per task (25 min)
HEARTBEAT_GRACE: 90 * 1000, // GAME/STREAM: give up if Discord sends no heartbeat
MAX_TASK_FAILURES: 5, // consecutive network failures
MAX_RETRIES: 3, // 429/5xx transient error retries
IS_DESKTOP: typeof window.DiscordNative !== 'undefined'
});
// mutable runtime state lives here, CONFIG stays read-only
const RUNTIME = {
running: true,
cleanups: new Set(), // tracks active event listeners for safe shutdown
autoEnroll: true, // whether to auto-enroll in quests before execution
autoClaim: false, // whether to try auto-claiming quest rewards
playSound: false, // whether to play an audio cue on quest completion
randomDelay: false // whether to inject randomized 1-30min idle gaps between quests (anti-detection)
};
/* ── audio cue ───────────────────────────────────────────────── */
const Sound = {
_ctx: null,
play(type) {
if (!RUNTIME.playSound) return;
try {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return;
// reuse one context; Chromium caps ~6 concurrent AudioContexts and a long
// run with sound on would otherwise hit the cap and go silently dead.
if (!this._ctx || this._ctx.state === 'closed') this._ctx = new Ctx();
const ctx = this._ctx;
const o = ctx.createOscillator();
const g = ctx.createGain();
o.connect(g); g.connect(ctx.destination);
o.type = 'sine';
const t0 = ctx.currentTime;
if (type === 'done') {
// C5 E5 G5 arpeggio
o.frequency.setValueAtTime(523.25, t0);
o.frequency.setValueAtTime(659.25, t0 + 0.12);
o.frequency.setValueAtTime(783.99, t0 + 0.24);
g.gain.setValueAtTime(0.55, t0);
g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.55);
o.start(t0); o.stop(t0 + 0.6);
} else {
o.frequency.value = 880; // A5
g.gain.setValueAtTime(0.45, t0);
g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.18);
o.start(t0); o.stop(t0 + 0.2);
}
} catch (_) { }
}
};
/* ── OAuth consent gate ──────────────────────────────────────
Shown before the ACHIEVEMENT bypass OAuth-authorizes a quest's
app on the user's account. Per-app, per-run, in-memory only —
never persisted, so it can't silently authorize forever. Default
decline: Cancel / Esc / backdrop / 60s idle all resolve false, so
an AFK user never authorizes anything. CSP-safe (addEventListener,
app name via textContent so it can't inject markup).
── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── */
const Consent = {
_granted: new Set(),
TIMEOUT: 60000,
SCOPES: ['identify', 'applications.commands', 'applications.entitlements'],
ask(appId, appName) {
if (this._granted.has(appId)) return Promise.resolve(true);
return new Promise(resolve => {
const ov = document.createElement('div');
ov.id = 'orion-consent';
ov.style.cssText = 'position:fixed;inset:0;z-index:1002;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.6);font-family:var(--font-primary);';
ov.innerHTML = `
Authorize application?
To complete this achievement quest, Orion needs to OAuth-authorize this app on your Discord account:
Scopes requested:
The app's backend receives a real authorization code. Orion revokes the grant immediately after the quest is marked complete. Discord enforcement against quest automation can affect the whole account.
Don't ask again for this app this session
Cancel
Authorize
`;
document.body.appendChild(ov);
ov.querySelector('#oc-app').textContent = appName ? `${appName} (${appId})` : `App ${appId}`;
const ul = ov.querySelector('#oc-scopes');
this.SCOPES.forEach(s => { const li = document.createElement('li'); li.textContent = s; ul.appendChild(li); });
let done = false;
const finish = v => {
if (done) return; done = true;
clearTimeout(timer); document.removeEventListener('keydown', onKey);
if (v && ov.querySelector('#oc-remember').checked) this._granted.add(appId);
ov.remove(); resolve(v);
};
const onKey = e => { if (e.key === 'Escape') finish(false); };
ov.querySelector('#oc-yes').addEventListener('click', () => finish(true));
ov.querySelector('#oc-no').addEventListener('click', () => finish(false));
ov.addEventListener('mousedown', e => { if (e.target === ov) finish(false); });
document.addEventListener('keydown', onKey);
const timer = setTimeout(() => finish(false), this.TIMEOUT);
});
}
};
const ICONS = Object.freeze({
OPT: ` `,
BOLT: ` `,
VIDEO: ` `,
GAME: ` `,
STREAM: ` `,
ACTIVITY: ` `,
CHECK: ` `,
CLOCK: ` `,
STOP: ` `
});
const CONST = Object.freeze({
ID: "1412491570820812933", // blacklisted quest — known to break enrollment
EVT: Object.freeze({
HEARTBEAT: "QUESTS_SEND_HEARTBEAT_SUCCESS",
GAME: "RUNNING_GAMES_CHANGE",
RPC: "LOCAL_ACTIVITY_UPDATE"
})
});
// bail early if another instance is already running in this tab
if (window.orionLock) {
const existingUI = document.getElementById('orion-ui');
if (existingUI) existingUI.style.display = 'flex';
return console.warn(`[${CONFIG.NAME}] Already running.`);
}
window.orionLock = true;
/* ── util ──────────────────────────────────────────────────── */
const sleep = ms => new Promise(r => setTimeout(r, ms));
const rnd = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
// Escape server-controlled strings before they touch innerHTML. Quest and reward names
// come from Discord API fields (questName, reward name) and can carry markup; a crafted
// title like "> would otherwise inject into the dashboard DOM —
// the exact inline-injection Discord's CSP exists to stop.
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
// expiresAt is occasionally missing/malformed mid-rollout. new Date(undefined) is NaN and
// `NaN > now` is always false, which would silently drop the quest with no log. Treat an
// unparseable expiry as "not expired" so we attempt the quest instead of hiding it.
const notExpired = q => { const e = new Date(q.config?.expiresAt ?? 0).getTime(); return Number.isNaN(e) || e > Date.now(); };
/* ── error classification ─────────────────────────────────── */
// Traffic uses this to decide: retry, skip, or propagate.
// 429/5xx = transient → backoff & retry. 4xx = permanent → skip quest.
const ErrorHandler = {
RETRYABLE: new Set([429, 500, 502, 503, 504, 408]),
CLIENT_ERRORS: new Set([400, 403, 404, 409, 410]),
classify(error) {
const status = error?.status ?? error?.statusCode;
return {
isRetryable: this.RETRYABLE.has(status),
isClientError: this.CLIENT_ERRORS.has(status),
status,
message: error?.message ?? error?.body?.message ?? `HTTP ${status ?? 'UNKNOWN'}`
};
},
// 404 = quest removed server-side, 403 = region/permission, 410 = gone
isSkippableQuest(error) {
const status = error?.status;
return status === 404 || status === 403 || status === 410;
}
};
/* ── UI + logger ────────────────────────────────────────────
Injects a draggable dashboard into Discord's DOM.
Doubles as task-state store — render() rebuilds on every update.
── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── */
const Logger = {
root: null, tasks: new Map(), tickerId: null,
init() {
const oldUI = document.getElementById('orion-ui'); if (oldUI) oldUI.remove();
const oldStyle = document.getElementById('orion-styles'); if (oldStyle) oldStyle.remove();
const style = document.createElement('style');
style.id = 'orion-styles';
style.innerHTML = `
@keyframes slideIn { from { transform: translateY(-20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@keyframes fadeOut { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.95); margin: 0; padding: 0; height: 0; border: none; } }
#orion-ui {
position: fixed; top: 32px; left: auto; right: 20px; width: 380px;
max-height: 53vh;
background: var(--background-base-low); color: var(--text-default);
border: 1px solid var(--border-subtle); border-radius: var(--radius-lg);
box-shadow: var(--shadow-button-overlay); z-index: 1001;
font-family: var(--font-primary);
overflow: hidden; animation: slideIn 0.3s ease;
display: flex; flex-direction: column; box-sizing: border-box;
user-select: none;-webkit-app-region: no-drag;
}
#orion-head { padding: 12px 16px; background: var(--background-mod-muted); flex: 0 0 auto; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-subtle); cursor: grab; }
#orion-head.dragging { cursor: grabbing; background: var(--control-secondary-background-default); }
#orion-title { font-weight: 700; font-size: 15px; color: var(--text-strong); display: flex; align-items: center; gap: 8px; }
#orion-title svg { color: var(--text-brand); }
.dev-credit { font-size: 12px; margin-left: -4px; padding-top: 2px; font-weight: 500; color: var(--text-muted); }
#orion-controls { display: flex; gap: 10px; align-items: center; }
.ctrl-btn { cursor: pointer; transition: 0.2s; display: flex; align-items: center; }
.ctrl-hide, .ctrl-opts { font-size: 11px; font-weight: 600; color: var(--text-muted); }
.ctrl-hide:hover, .ctrl-opts:hover { color: var(--text-default); }
.ctrl-stop { font-size: 11px; font-weight: 700; gap: 4px; padding: 3px 8px 3px 6px; border-radius: var(--radius-sm); background: transparent; border: 1px solid var(--control-critical-primary-background-default); color: var(--control-critical-primary-background-default); }
.ctrl-stop:hover { background: var(--control-critical-primary-background-default); color: #fff; }
#orion-logs { padding: 10px 14px; background: var(--background-base-lower); flex: 0 0 auto; font-family: 'Consolas', 'Monaco', monospace; font-size: 11px; height: 110px; overflow-y: auto; border-top: 1px solid var(--border-subtle); scroll-behavior: smooth; }
.log-item { margin-bottom: 6px; display: flex; gap: 8px; line-height: 1.4; padding-bottom: 4px; }
.log-ts { opacity: 0.5; min-width: 50px; font-size: 10px; }
.c-info { color: var(--text-feedback-info); opacity: .8; } .c-success { color: var(--text-feedback-positive); } .c-err { color: var(--text-feedback-critical); } .c-warn { color: var(--text-feedback-warning); } .c-debug { color: #949ba4; }
#orion-body { flex: 1 1 auto; padding: 12px; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; }
#orion-picker-form { display: flex; flex-direction: column; min-height: 0; }
#orion-ui ::-webkit-scrollbar { width: 4px; height: 4px; }
#orion-ui ::-webkit-scrollbar-track { background: transparent; }
#orion-ui ::-webkit-scrollbar-thumb { background: var(--scrollbar-auto-scrollbar-color-thumb); border-radius: 4px; }
.task-card {
--state-color: var(--ansi-bright-blue);
--icon-bg-opacity: 15%;
--icon-color: var(--state-color);
display: flex; gap: 12px; padding: 10px 12px; margin-bottom: 8px; align-items: center;
background: var(--control-secondary-background-default);
border-radius: var(--radius-sm); border: 1px solid var(--border-muted);
border-left: 4px solid var(--state-color);
box-shadow: var(--shadow-low); transition: 0.3s; flex-shrink: 0;
}
.task-card.removing { animation: fadeOut 0.4s forwards; }
.task-card.done { --state-color: var(--ansi-green); --icon-bg-opacity: 100%; --icon-color: #fff; }
.task-card.failed { --state-color: var(--ansi-red); }
.task-card.pending { --state-color: var(--ansi-bright-yellow); }
.task-icon { position: relative; width: 40px; height: 40px; border-radius: 50%; flex: 0 0 auto; background-color: color-mix(in srgb, var(--state-color) var(--icon-bg-opacity), transparent); display: flex; align-items: center; justify-content: center; }
.task-card.running .task-icon::before { content: ''; position: absolute; inset: 0; border-radius: 50%; z-index: 1; background: conic-gradient(lch(71 59 139) 0% var(--p, 0%), var(--border-subtle) var(--p, 0%) 100%); -webkit-mask-image: radial-gradient(circle at center, transparent 16px, black 17px); mask-image: radial-gradient(circle at center, transparent 16px, black 17px); }
.task-icon-inner { z-index: 2; color: var(--icon-color); display: flex; transition: filter 0.2s, opacity 0.2s; }
.task-card.running:hover .task-icon-inner { filter: blur(2px); opacity: 0.3; }
.task-icon-overlay { position: absolute; inset: 0; z-index: 3; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 800; color: var(--text-default); opacity: 0; transition: opacity 0.2s; pointer-events: none; }
.task-card.running:hover .task-icon-overlay { opacity: 1; }
.task-info { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; justify-content: center; }
.task-status { font-size: 10px; font-weight: 800; color: var(--state-color); text-transform: uppercase; letter-spacing: 0.5px; }
.task-name { font-size: 13px; font-weight: 700; color: var(--text-strong); letter-spacing: 0.2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.2; }
.task-meta { font-size: 11px; font-weight: 700; color: var(--text-muted); display: flex; justify-content: space-between; }
.task-actions { flex: 0 0 auto; display: flex; align-items: center; margin-left: 4px; }
.claim-btn, .goto-btn { padding: 6px 10px; border: none; border-radius: var(--radius-sm); font-size: 11px; font-weight: 700; cursor: pointer; transition: 0.2s; text-transform: uppercase; letter-spacing: 0.2px; white-space: nowrap; font-family: inherit; color: #fff; }
.claim-btn { background: var(--control-connected-background-default); }
.claim-btn:hover:not(:disabled) { background: var(--control-connected-background-hover); }
.claim-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.claim-btn.failed { background: var(--control-secondary-background-active); color: var(--text-default); }
.goto-btn { background: var(--control-primary-background-default); }
.goto-btn:hover:not(:disabled) { background: var(--control-primary-background-hover); }
.picker-section-title { font-size: 11px; font-weight: 700; color: var(--text-muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; flex-shrink: 0; }
.reward-filters { display: flex; gap: 6px; margin-bottom: 12px; flex-wrap: wrap; flex-shrink: 0; }
.reward-filter, .type-filter { background-color: transparent; border: 2px solid; padding: 4px 10px; border-radius: 24px; font-size: 10px; font-weight: 600; cursor: pointer; transition: 0.2s; color: var(--text-subtle); font-family: inherit; }
.reward-filter:hover, .type-filter:hover { background-color: color-mix(in srgb, currentColor 25%, transparent); }
.reward-filter.off, .type-filter.off { background: transparent; color: var(--text-muted); opacity: 0.4; }
.picker-quest-list { display: flex; flex-direction: column; gap: 8px; flex: 1 1 auto; min-height: 50px; overflow-y: auto; padding-right: 4px; }
.quest-pick { display: flex; gap: 12px; padding: 10px; background: var(--control-secondary-background-default); border-radius: var(--radius-sm); border: 1px solid var(--border-muted); border-left-width: 4px; cursor: pointer; transition: 0.2s; align-items: center; user-select: none; flex-shrink: 0; }
.quest-pick:hover { filter: brightness(1.15); }
.quest-pick.hidden { display: none !important; }
.native-cb { appearance: none; width: 20px; height: 20px; margin: 0; flex-shrink: 0; border: 1px solid var(--checkbox-border-default); border-radius: var(--radius-xs); background: transparent; cursor: pointer; transition: 0.15s; display: grid; place-content: center; }
.native-cb::before {
content: ''; width: 12px; height: 12px; opacity: 0; transition: 0.1s;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
background-size: contain; background-repeat: no-repeat; background-position: center;
}
.native-cb:checked { background: var(--checkbox-background-selected-default); border-color: var(--checkbox-border-selected-default); }
.native-cb:checked::before { opacity: 1; }
.picker-options { display: flex; flex-direction: column; gap: 6px; flex-shrink: 0; }
.orion-option { display: flex; justify-content: space-between; align-items: center; background: var(--control-secondary-background-default); padding: 8px 12px; border-radius: var(--radius-sm); border: 1px solid var(--border-muted); }
.orion-option-label { font-size: 13px; font-weight: 500; color: var(--text-default); }
.native-toggle { appearance: none; width: 40px; height: 20px; margin: 0; flex-shrink: 0; background: var(--control-secondary-background-default); border-radius: 12px; cursor: pointer; position: relative; transition: 0.2s; border: 1px solid var(--border-muted); }
.native-toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; background: white; border-radius: 50%; box-shadow: var(--shadow-low); transition: 0.2s; }
.native-toggle:checked { background: var(--control-primary-background-default); }
.native-toggle:checked::after { transform: translateX(20px); }
.picker-actions { display: flex; gap: 10px; padding-top: 12px; flex-shrink: 0; }
.quest-pick-btn { flex: 1; padding: 10px; border: 1px solid; border-radius: var(--radius-sm, 8px); font-size: 13px; font-weight: 700; cursor: pointer; transition: 0.2s; display: flex; align-items: center; justify-content: center; gap: 6px; font-family: inherit; color: #fff;}
.quest-pick-btn.start { background-color: var(--control-connected-background-default); border-color: var(--control-connected-border-default); }
.quest-pick-btn.start:hover:not(:disabled) { background: var(--control-connected-background-hover); border-color: var(--control-connected-border-hover); }
.quest-pick-btn.deselect { background-color: var(--control-secondary-background-default); border-color: var(--control-secondary-border-default); color: var(--text-default); }
.quest-pick-btn.deselect:hover:not(:disabled) { background: var(--control-secondary-background-hover); }
.quest-pick-btn:disabled { opacity: 0.5; cursor: not-allowed; }
`;
document.head.appendChild(style);
this.root = document.createElement('div');
this.root.id = 'orion-ui';
this.root.innerHTML = `
${ICONS.BOLT} ${CONFIG.NAME}
by syntt_
${CONFIG.VERSION}
${ICONS.STOP} STOP
HIDE
${ICONS.OPT}
`;
document.body.appendChild(this.root);
const head = document.getElementById('orion-head');
let collapsed = false;
head.addEventListener('dblclick', e => {
if (e.target.closest('.ctrl-btn')) return;
collapsed = !collapsed;
this.root.style.height = collapsed ? '50px' : '';
});
head.addEventListener('mousedown', e => {
if (e.target.closest('.ctrl-btn')) return;
head.classList.add('dragging');
const startX = e.clientX, startY = e.clientY;
const rect = this.root.getBoundingClientRect();
const initialLeft = rect.left, initialTop = rect.top;
this.root.style.left = `${initialLeft}px`;
this.root.style.top = `${initialTop}px`;
this.root.style.right = 'auto';
e.preventDefault();
const onMouseMove = ev => {
this.root.style.left = `${Math.max(0, Math.min(initialLeft + (ev.clientX - startX), window.innerWidth - this.root.offsetWidth))}px`;
this.root.style.top = `${Math.max(0, Math.min(initialTop + (ev.clientY - startY), window.innerHeight - 50))}px`;
};
const onMouseUp = () => {
head.classList.remove('dragging');
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
document.getElementById('orion-body').addEventListener('click', async (e) => {
if (e.target.classList.contains('goto-btn')) {
if (Mods.Router) Mods.Router.transitionTo('/quest-home');
return;
}
if (e.target.classList.contains('claim-btn')) {
const btn = e.target;
if (btn.disabled) return;
const questId = btn.getAttribute('data-id');
const taskData = this.tasks.get(questId);
if (!taskData) return;
btn.innerText = "WAITING...";
btn.disabled = true;
btn.style.opacity = "0.5";
// save state so render() respects it
this.updateTask(questId, { ...taskData, claimState: 'WAITING' });
try {
const claimRes = await Tasks.claimReward(questId);
if (claimRes?.body?.claimed_at) {
btn.innerText = "CLAIMED!";
this.log(`[Claim] Reward for "${taskData.name}" claimed successfully!`, 'success');
this.updateTask(questId, { ...taskData, status: "CLAIMED", claimable: false, claimState: null });
setTimeout(() => this.removeTask(questId), 2000);
}
} catch (err) {
this.log(`[Claim] Action required for "${taskData.name}". Check Discord UI for captcha.`, 'warn');
// formally update state to FAILED so render() locks it permanently
this.updateTask(questId, { ...taskData, claimState: 'FAILED' });
}
}
});
document.getElementById('orion-close').onclick = () => this.toggle();
document.getElementById('orion-stop').onclick = () => this.shutdown();
document.addEventListener('keydown', e => (e.key === '>' || (e.shiftKey && e.key === '.')) && this.toggle());
// gear toggles the picker options panel. Those nodes only exist during the picker
// phase, so look them up at click time and no-op otherwise. Registered once here
// (not in showQuestPicker) so a fresh listener doesn't stack every time it opens.
document.getElementById('orion-opts').addEventListener('click', () => {
const panel = document.getElementById('orion-options-panel');
if (!panel) return;
const open = panel.style.display === 'none';
panel.style.display = open ? '' : 'none';
const list = document.getElementById('orion-quest-list');
if (list) list.style.display = open ? 'none' : '';
const actions = document.querySelector('#orion-picker-form .picker-actions');
if (actions) actions.style.display = open ? 'none' : '';
});
this.startTicker();
},
toggle() { this.root.style.display = this.root.style.display === 'none' ? 'flex' : 'none'; },
shutdown() {
if (!RUNTIME.running) return;
RUNTIME.running = false;
this.log("[System] Stopping script & cleaning up...", "warn");
if (this.tickerId) clearInterval(this.tickerId);
for (const cleanupFn of RUNTIME.cleanups) {
try { cleanupFn(); } catch (e) { this.log(`[Cleanup] ${e.message}`, 'debug'); }
}
RUNTIME.cleanups.clear();
Patcher.clean();
setTimeout(() => {
const styles = document.getElementById('orion-styles');
if (styles) styles.remove();
if (this.root?.parentElement) this.root.remove();
window.orionLock = false;
}, 1000);
},
_getPct(t) {
if (t.done) return 100;
if (t.pending || t.failed || !t.max) return 0;
return Math.min(100, (t.cur / t.max) * 100);
},
// Cosmetic smoothing between real progress updates. VIDEO and ACTIVITY compute their
// own progress locally, so free-running +1/s matches reality closely enough.
// GAME/STREAM don't: their only source of truth is Discord's heartbeat reply, which
// lands every ~30s. Free-running there invented a moving bar even when Discord was
// reporting nothing at all — it reached 100% while the quest sat at 0% in Discord's
// own UI (issue #43). So they extrapolate strictly from the last heartbeat: the bar
// is smooth while beats arrive, and frozen at the last known value when they stop.
// ACHIEVEMENT is a milestone count, not seconds — never ticked.
SERVER_DRIVEN: ["GAME", "STREAM"],
startTicker() {
if (this.tickerId) clearInterval(this.tickerId);
this.tickerId = setInterval(() => {
if (!RUNTIME.running) return clearInterval(this.tickerId);
for (const [id, task] of this.tasks.entries()) {
if (task.status !== "RUNNING" || task.type === "ACHIEVEMENT") continue;
let cur;
if (this.SERVER_DRIVEN.includes(task.type)) {
if (task.serverAt == null) continue; // no heartbeat yet — show nothing
cur = Math.min(task.serverCur + (Date.now() - task.serverAt) / 1000, task.max);
} else {
cur = Math.min(task.cur + 1, task.max);
}
this.updateTask(id, { cur });
}
}, 1000);
},
updateTask(id, data) {
const oldData = this.tasks.get(id);
const isPending = data.status === "PENDING" || data.status === "QUEUE";
const isDone = data.status === "COMPLETED" || data.status === "CLAIMED";
const isFailed = data.status === "FAILED";
const newData = { ...oldData, ...data, done: isDone, pending: isPending, failed: isFailed };
this.tasks.set(id, newData);
if (oldData && oldData.status === newData.status && oldData.removing === newData.removing &&
oldData.claimable === newData.claimable && oldData.claimState === newData.claimState &&
oldData.actionRequired === newData.actionRequired) {
const card = document.getElementById(`orion-task-${id}`);
if (card) {
const pct = this._getPct(newData);
const iconContainer = card.querySelector('.task-icon');
if (iconContainer) iconContainer.style.setProperty('--p', `${pct}%`);
const overlay = card.querySelector('.task-icon-overlay');
if (overlay) overlay.textContent = `${Math.floor(pct)}%`;
const progressText = card.querySelector('.progress-text');
if (progressText) {
const unit = newData.type === 'ACHIEVEMENT' ? '' : 's';
progressText.textContent = `${Math.min(Math.floor(newData.cur), newData.max)} / ${newData.max}${unit}`;
}
return;
}
}
this.render();
},
removeTask(id) {
if (this.tasks.has(id)) {
this.tasks.get(id).removing = true;
this.render();
setTimeout(() => { this.tasks.delete(id); this.render(); }, 500);
}
},
log(msg, type = 'info') {
const colors = { info: "#5865F2", success: "#3BA55C", warn: "#faa61a", err: "#f04747", debug: "#999" };
console.log(`%c[ORION] %c${msg}`, `color: ${CONFIG.THEME}; font-weight: bold;`, `color: ${colors[type] || colors.info}`);
try {
const box = document.getElementById('orion-logs');
if (box && type !== 'debug') {
const el = document.createElement('div'); el.className = `log-item c-${type}`;
el.innerHTML = `${new Date().toLocaleTimeString().split(' ')[0]} ${esc(msg)} `;
box.appendChild(el); box.scrollTop = box.scrollHeight;
while (box.children.length > CONFIG.MAX_LOG_ITEMS) box.firstChild.remove();
}
} catch (e) { console.debug('[Logger] DOM error:', e.message); }
},
render() {
if (document.getElementById('orion-picker-form')) return;
const body = document.getElementById('orion-body');
if (!body) return;
if (!this.tasks.size) return body.innerHTML = `Waiting for tasks...
`;
const sorted = [...this.tasks.entries()].sort((a, b) => {
const ta = a[1], tb = b[1];
if (ta.done !== tb.done) return ta.done ? 1 : -1;
if (ta.failed !== tb.failed) return ta.failed ? 1 : -1;
if (ta.pending !== tb.pending) return ta.pending ? 1 : -1;
// among active tasks, highest progress first
if (!ta.done && !ta.pending && !tb.done && !tb.pending) {
const pctA = ta.max ? ta.cur / ta.max : 0;
const pctB = tb.max ? tb.cur / tb.max : 0;
return pctB - pctA;
}
return 0;
});
body.innerHTML = sorted.map(([id, t]) => {
const pct = t.pending || t.failed ? 0 : Math.min(100, (t.cur / t.max) * 100).toFixed(1);
// state-based icons (done/failed/pending) win over type-based icons.
const icon =
t.done ? ICONS.CHECK :
t.failed ? ICONS.STOP :
t.pending ? ICONS.CLOCK :
t.type === 'VIDEO' ? ICONS.VIDEO :
t.type === 'ACHIEVEMENT' ? ICONS.ACTIVITY :
t.type?.includes('GAME') ? ICONS.GAME :
t.type?.includes('STREAM') ? ICONS.STREAM :
ICONS.BOLT;
let statusText = t.status === 'CLAIMED' ? 'CLAIMED' : t.done ? 'COMPLETED' : t.status;
let progressLabel = t.pending ? 'In Queue' : t.failed ? 'Aborted' : 'Progress';
const unit = t.type === 'ACHIEVEMENT' ? '' : 's';
let actionBtn = '';
if (t.claimable) {
if (t.claimState === 'WAITING') actionBtn = `WAITING... `;
else if (t.claimState === 'FAILED') actionBtn = `ACTION REQUIRED `;
else actionBtn = `CLAIM REWARD `;
} else if (t.actionRequired === 'ENROLL') {
statusText = 'ACTION REQUIRED'; progressLabel = 'Accept quest in Discord';
actionBtn = `GO TO QUESTS `;
} else if (t.type === 'ACHIEVEMENT' && t.status === 'RUNNING') {
statusText = 'ACTION REQUIRED'; progressLabel = 'Please, complete manually';
actionBtn = `GO TO QUESTS `;
}
const stateClass = t.done ? 'done' : t.failed ? 'failed' : t.pending ? 'pending' : 'running';
const removingClass = t.removing ? 'removing' : '';
let taskMetaHtml = '';
if (!t.done) {
taskMetaHtml = `
${progressLabel}
${actionBtn ? '' : `${Math.min(Math.floor(t.cur), t.max)} / ${t.max}${unit} `}
`;
}
return `
${icon}
${stateClass === 'running' ? `
${Math.floor(pct)}%
` : ''}
${statusText}
${esc(t.name)}
${taskMetaHtml}
${actionBtn ? `
${actionBtn}
` : ''}
`;
}).join('');
},
showQuestPicker(quests) {
return new Promise((resolve) => {
const body = document.getElementById('orion-body');
const logs = document.getElementById('orion-logs');
const closePicker = (data) => {
if (logs) logs.style.display = 'block';
if (body) { body.classList.remove('picker-mode'); body.innerHTML = ''; }
resolve(data);
};
if (!body) return closePicker({ selectedQuests: new Set(), autoEnroll: false, autoClaim: false, playSound: false });
if (logs) logs.style.display = 'none';
const items = [];
const rewardTypes = new Map();
const questTypes = new Set();
const REWARD_META = { 1: { label: "IN-GAME", color: "#e67e22" }, 3: { label: "AVATAR DECORATION", color: "#a358f2" }, 4: { label: "ORBS", color: "#5865F2" } };
const REWARD_FALLBACK = { label: "OTHER", color: "#949ba4" };
quests.forEach(q => {
const cfg = q.config?.taskConfig ?? q.config?.taskConfigV2;
if (!cfg?.tasks) return;
const typeData = Tasks.detectType(cfg, q.config?.application?.id);
if (!typeData) return;
// exclude desktop-only quests (play/stream) on any non-desktop clients
if (!SYS.IS_DESKTOP && (typeData.type === 'GAME' || typeData.type === 'STREAM')) return;
const rw = q.config?.rewardsConfig?.rewards?.[0];
const rewardType = rw?.type ?? 0;
const rewardText = rw?.messages?.name ?? "Unknown Reward";
const meta = REWARD_META[rewardType] ?? REWARD_FALLBACK;
const displayType = typeData.type === 'WATCH_VIDEO' ? 'VIDEO' : typeData.type;
questTypes.add(displayType);
if (!rewardTypes.has(rewardType)) {
rewardTypes.set(rewardType, { label: meta.label, count: 0, type: rewardType, color: meta.color });
}
rewardTypes.get(rewardType).count++;
items.push({
id: q.id,
name: q.config?.messages?.questName ?? "Unknown Quest",
type: displayType,
rewardType,
rewardText,
color: meta.color
});
});
if (!items.length) return closePicker({ selectedQuests: new Set(), autoEnroll: false, autoClaim: false, playSound: false });
const buildCard = (q) => `
${esc(q.name)}
${esc(q.type)}
${esc(q.rewardText)}
`;
const buildToggle = (name, label, isChecked) => `
${label}
`;
body.innerHTML = `
`;
const form = document.getElementById('orion-picker-form');
const selectAllBtn = document.getElementById('select-all-btn');
const startBtn = document.getElementById('start-btn');
const getVisibleCheckboxes = () => Array.from(form.querySelectorAll('.quest-pick input[type="checkbox"]'))
.filter(cb => !cb.closest('.quest-pick').classList.contains('hidden'));
const syncUI = () => {
const visibleCbs = getVisibleCheckboxes();
const totalChecked = visibleCbs.filter(cb => cb.checked).length;
const startBtnText = document.getElementById('start-btn-text');
if (startBtnText) startBtnText.textContent = `START (${totalChecked})`;
startBtn.disabled = totalChecked === 0;
if (visibleCbs.length === 0) {
selectAllBtn.disabled = true;
selectAllBtn.textContent = 'SELECT ALL';
} else {
selectAllBtn.disabled = false;
selectAllBtn.textContent = visibleCbs.every(cb => cb.checked) ? 'DESELECT ALL' : 'SELECT ALL';
}
const noQuestsMsg = document.getElementById('orion-no-quests');
if (noQuestsMsg) {
noQuestsMsg.style.display = visibleCbs.length === 0 ? 'block' : 'none';
}
};
form.addEventListener('change', (e) => { if (e.target.name === 'quests') syncUI(); });
const activeRewards = new Set([...rewardTypes.keys()].map(String));
const activeTypes = new Set([...questTypes]);
const applyFilters = () => {
form.querySelectorAll('.quest-pick').forEach(el => {
const rt = el.getAttribute('data-rt');
const qt = el.getAttribute('data-qt');
el.classList.toggle('hidden', !(activeRewards.has(rt) && activeTypes.has(qt)));
});
syncUI();
};
const FILTER_KINDS = [
{ cls: 'reward-filter', attr: 'data-rt', set: activeRewards },
{ cls: 'type-filter', attr: 'data-qt', set: activeTypes }
];
form.addEventListener('click', (e) => {
const kind = FILTER_KINDS.find(k => e.target.classList.contains(k.cls));
if (kind) {
e.preventDefault();
const value = e.target.getAttribute(kind.attr);
e.target.classList.toggle('off');
if (e.target.classList.contains('off')) kind.set.delete(value);
else kind.set.add(value);
applyFilters();
return;
}
if (e.target.id === 'select-all-btn') {
e.preventDefault();
const visibleCbs = getVisibleCheckboxes();
if (visibleCbs.length === 0) return;
const shouldCheck = !visibleCbs.every(cb => cb.checked);
visibleCbs.forEach(cb => { cb.checked = shouldCheck; });
syncUI();
}
});
form.addEventListener('submit', (e) => {
e.preventDefault();
const selected = getVisibleCheckboxes().filter(cb => cb.checked);
if (selected.length === 0) return;
const data = new FormData(form);
closePicker({
selectedQuests: new Set(selected.map(cb => cb.value)),
autoEnroll: data.has('autoEnroll'),
autoClaim: data.has('autoClaim'),
playSound: data.has('playSound'),
randomDelay: data.has('randomDelay')
});
});
// apply layout lock and sync initial button states
body.classList.add('picker-mode');
syncUI();
});
}
};
/* ── request queue ────────────────────────────────────────────
FIFO queue processed one-at-a-time to respect rate limits.
Retryable errors (429, 5xx) re-queue with exponential backoff.
Client errors (4xx) reject immediately — caller decides what to do.
── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── */
const Traffic = {
queue: [], processing: false,
async enqueue(url, body) {
if (!RUNTIME.running) return Promise.reject(new Error("Stopped"));
return new Promise((resolve, reject) => {
this.queue.push({ url, body, resolve, reject, attempts: 0 });
this.process();
});
},
async process() {
if (this.processing || this.queue.length === 0) return;
this.processing = true;
while (this.queue.length > 0) {
if (!RUNTIME.running) {
this.queue.forEach(req => req.reject(new Error("Shutdown")));
this.queue = [];
this.processing = false;
return;
}
const req = this.queue.shift();
try {
const res = await Mods.API.post({ url: req.url, body: req.body });
req.resolve(res);
} catch (e) {
const err = ErrorHandler.classify(e);
if (err.isRetryable && req.attempts < SYS.MAX_RETRIES) {
req.attempts++;
const delay = (e.body?.retry_after ?? Math.pow(2, req.attempts)) * 1000;
const isGlobal = e.body?.global === true;
Logger.log(`[Network] Retry ${req.attempts}/${SYS.MAX_RETRIES} in ${(delay / 1000).toFixed(1)}s (HTTP ${err.status})`, 'warn');
const retryJitter = rnd(200, 800);
if (isGlobal) {
// Freeze queue on global rate limits to prevent API abuse
this.queue.unshift(req);
await sleep(delay + retryJitter);
} else {
// Non-blocking retry for endpoint-specific limits
setTimeout(() => {
if (RUNTIME.running) {
this.queue.push(req);
this.process();
} else {
// settle on shutdown so an awaiter doesn't hang until the task timeout
req.reject(new Error('Shutdown'));
}
}, delay + retryJitter);
}
} else if (err.isClientError) {
Logger.log(`[Network] HTTP ${err.status}: ${req.url}`, 'debug');
req.reject(e);
} else {
Logger.log(`[Network] Request to ${req.url} failed: ${err.message}`, 'err');
req.reject(e);
}
}
await sleep(rnd(1200, 1800)); // delay between API calls
}
this.processing = false;
}
};
/* ── store patching ───────────────────────────────────────────
Monkey-patches Discord's RunStore/StreamStore so the client
believes a game process is running. Fake PIDs, exePaths, and
RPC payloads are injected and cleaned up on task completion.
── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── */
let Mods = {}; // populated by loadModules() — holds Discord webpack internals
const Patcher = {
games: [], real: {}, active: false,
// Overriding getRunningGames alone is no longer enough. Canary derives quest
// eligibility from the "visible"/"candidate" views, and a game absent from those
// never gets a heartbeat scheduled — the quest sits at 0% forever (issue #43).
// Older builds don't expose all of these, so each is patched only if present.
PATCHED: ['getRunningGames', 'getGameForPID', 'getVisibleGame', 'getVisibleRunningGames',
'getRunningDiscordApplicationIds', 'getCandidateGames'],
// stash originals so we can restore them on cleanup
init(Store) {
if (!Store) return;
this.real = {};
for (const name of this.PATCHED) {
if (typeof Store[name] === 'function') this.real[name] = Store[name];
}
const absent = this.PATCHED.filter(n => !this.real[n]);
if (absent.length) Logger.log(`[Patcher] Store lacks ${absent.join(', ')} — not patching those.`, 'debug');
},
// swap between real and patched store methods
toggle(on) {
const S = Mods.RunStore;
const real = this.real;
if (on && !this.active) {
S.getRunningGames = () => [...real.getRunningGames.call(S), ...this.games];
S.getGameForPID = (pid) => this.games.find(g => g.pid === pid) || real.getGameForPID.call(S, pid);
// our game wins as "the" visible one — that's the whole point of the spoof
if (real.getVisibleGame) S.getVisibleGame = () => this.games[0] ?? real.getVisibleGame.call(S);
if (real.getVisibleRunningGames) S.getVisibleRunningGames = () => [...real.getVisibleRunningGames.call(S), ...this.games];
if (real.getCandidateGames) S.getCandidateGames = () => [...real.getCandidateGames.call(S), ...this.games];
if (real.getRunningDiscordApplicationIds) {
S.getRunningDiscordApplicationIds = () => {
const ids = real.getRunningDiscordApplicationIds.call(S);
const ours = this.games.map(g => String(g.id));
// shape varies by build — preserve whichever collection came back
return ids instanceof Set ? new Set([...ids, ...ours]) : [...(ids ?? []), ...ours];
};
}
this.active = true;
} else if (!on && this.active) {
for (const [name, fn] of Object.entries(real)) S[name] = fn;
this.active = false;
}
},
add(g) {
if (this.games.some(x => x.pid === g.pid)) return;
this.games.push(g);
this.toggle(true);
this.dispatch([g], []);
this.rpc(g);
},
remove(g) {
const before = this.games.length;
this.games = this.games.filter(x => x.pid !== g.pid);
if (this.games.length === before) return;
this.dispatch([], [g]);
if (!this.games.length) {
this.toggle(false);
this.rpc(null);
} else {
this.rpc(this.games[0]);
}
},
// callers pass arrays; don't re-wrap (a re-wrap turned [] into [[]] and emitted
// malformed added/removed shapes to any Flux consumer). Matches the Vencord port.
dispatch(added, removed) {
Mods.Dispatcher?.dispatch({
type: CONST.EVT.GAME,
added,
removed,
games: Mods.RunStore.getRunningGames()
});
},
rpc(g) {
if (CONFIG.HIDE_ACTIVITY && g) return;
try {
Mods.Dispatcher?.dispatch({
type: CONST.EVT.RPC,
socketId: null,
// use a fake PID (9999) and null activity to clear the playing status
pid: g ? g.pid : 9999,
activity: g ? {
application_id: g.id,
name: g.name,
type: 0,
details: null,
state: null,
timestamps: { start: g.start },
icon: g.icon,
assets: null
} : null
});
} catch (e) {
Logger.log(`[RPC Cleanup] ${e.message}`, 'debug');
}
},
clean() {
this.games = [];
this.toggle(false);
this.rpc(null);
}
};
/* ── task handlers ────────────────────────────────────────────
Each quest type (VIDEO, GAME, STREAM, ACTIVITY) has its own
handler. GAME/STREAM share a generic() path that patches stores
and listens for heartbeat events. VIDEO and ACTIVITY poll in a
loop instead. Failed quest IDs go into `skipped` so we don't
re-attempt them on the next cycle.
── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── */
const Tasks = {
skipped: new Set(), // quest IDs that returned 4xx — no point retrying
// userStatus.progress is a plain object over REST, but dispatched payloads go through
// the client's own transform first, so the shape isn't ours to assume. Defensive: if
// it ever arrives as a Map, indexing with [] would read undefined and silently look
// like "no progress".
readProgress(userStatus, key) {
const p = userStatus?.progress;
const entry = p instanceof Map ? p.get(key) : p?.[key];
return entry?.value ?? userStatus?.streamProgressSeconds ?? 0;
},
sanitize(name) { return name.replace(/[^a-zA-Z0-9 ]/g, "").trim().replace(/\s+/g, " "); },
// Newer quest configs (taskConfigV2) carry the app per task as
// tasks[key].applications[]; older ones had a single config.application.id.
// A GAME quest built with the wrong id produces a fake process Discord can't
// match to the quest, so it never schedules a heartbeat (issue #43).
appIdFor(cfg, keyName, legacyAppId) {
return cfg?.tasks?.[keyName]?.applications?.[0]?.id ?? legacyAppId ?? null;
},
// match task keys from quest config to our handler types
// order matters — ACHIEVEMENT_IN_ACTIVITY must match before generic ACTIVITY
detectType(cfg, applicationId) {
const taskKeys = Object.keys(cfg.tasks);
const typeMap = [
{ key: "PLAY", type: "GAME" },
{ key: "STREAM", type: "STREAM" },
{ key: "VIDEO", type: "WATCH_VIDEO" },
{ key: "ACHIEVEMENT_IN_ACTIVITY", type: "ACHIEVEMENT" },
{ key: "ACTIVITY", type: "ACTIVITY" }
];
for (const { key, type } of typeMap) {
const keyName = taskKeys.find(k => k.includes(key));
if (keyName) {
return {
type, keyName,
target: cfg.tasks[keyName]?.target ?? 0,
appId: this.appIdFor(cfg, keyName, applicationId)
};
}
}
if (applicationId) {
return {
type: "GAME", keyName: "PLAY_ON_DESKTOP",
target: cfg.tasks[taskKeys[0]]?.target ?? 0,
appId: applicationId
};
}
return null;
},
// pull real exe metadata from Discord's app registry; falls back to synthetic paths
async fetchGameData(appId, appName) {
try {
const res = await Mods.API.get({ url: `/applications/public?application_ids=${appId}` });
const appData = res?.body?.[0];
const exeEntry = appData?.executables?.find(x => x.os === "win32");
const rawExe = exeEntry ? exeEntry.name.replace(">", "") : `${this.sanitize(appName)}.exe`;
const cleanName = this.sanitize(appData?.name || appName);
return {
name: appData?.name || appName,
icon: appData?.icon,
exeName: rawExe,
cmdLine: `C:\\Program Files\\${cleanName}\\${rawExe}`,
exePath: `c:/program files/${cleanName.toLowerCase()}/${rawExe}`,
id: appId
};
} catch (e) {
Logger.log(`[FetchGame] Fallback for ${appName}: ${e?.message ?? e}`, 'debug');
const cleanName = this.sanitize(appName);
const safeExe = `${cleanName.replace(/\s+/g, "")}.exe`;
return {
name: appName, exeName: safeExe,
cmdLine: `C:\\Program Files\\${cleanName}\\${safeExe}`,
exePath: `c:/program files/${cleanName.toLowerCase()}/${safeExe}`,
id: appId
};
}
},
async claimReward(questId) {
return await Mods.API.post({
url: `/quests/${questId}/claim-reward`,
body: { platform: 0, location: 11, is_targeted: false, metadata_raw: null, metadata_sealed: null, traffic_metadata_raw: null, traffic_metadata_sealed: null }
});
},
// safely aborts a broken or timed-out task, marks it as FAILED in the UI,
// and adds it to the skip list to prevent infinite retry loops
failTask(q, t, reason) {
const currentProgress = Logger.tasks.get(q.id)?.cur ?? 0;
Logger.updateTask(q.id, { name: t.name, type: t.type, cur: currentProgress, max: t.target, status: "FAILED" });
Logger.log(`[Task] Aborted "${t.name}": ${reason}`, 'err');
Tasks.skipped.add(q.id);
setTimeout(() => Logger.removeTask(q.id), 2000);
},
// sends fake video-progress timestamps until Discord marks the quest done
async VIDEO(q, t, s) {
// read progress from actual task key, fall back to type name
let cur = s?.progress?.[t.keyName]?.value ?? s?.progress?.[t.type]?.value ?? 0;
let failCount = 0;
Logger.updateTask(q.id, { name: t.name, type: "VIDEO", cur, max: t.target, status: "RUNNING" });
const startTime = Date.now();
let calls = 0;
// Simulate initial player buffer ping
if (cur === 0) {
await sleep(rnd(200, 350));
cur = 0.2 + (Math.random() * 0.05);
try {
await Traffic.enqueue(`/quests/${q.id}/video-progress`, { timestamp: Number(cur.toFixed(6)) });
calls++;
} catch (e) { Logger.log(`[Video] Initial ping failed: ${e.message}`, 'debug'); }
}
while (cur < t.target && RUNTIME.running) {
// 2x faster than Discord's native 7-9.5s player cadence
const delayMs = rnd(3500, 4750);
await sleep(delayMs);
// calculate elapsed time with execution jitter
const elapsedSec = (delayMs / 1000) + (Math.random() * 0.02 - 0.01);
cur += elapsedSec;
// match Discord's 6-decimal float format
const payloadTs = Number(Math.min(t.target, cur).toFixed(6));
try {
const r = await Traffic.enqueue(`/quests/${q.id}/video-progress`, { timestamp: payloadTs });
calls++;
// sync with server if it reports higher progress
const serverVal = r?.body?.progress?.[t.keyName]?.value ?? r?.body?.progress?.WATCH_VIDEO?.value;
if (serverVal > cur) cur = Math.min(t.target, serverVal);
if (r?.body?.completed_at) break;
failCount = 0;
} catch (e) {
failCount++;
const err = ErrorHandler.classify(e);
if (err.isClientError) {
Logger.log(`[Task] Video quest unavailable (HTTP ${err.status}). Skipping.`, 'warn');
return Tasks.failTask(q, t, `Client Error ${err.status}`);
}
if (failCount >= SYS.MAX_TASK_FAILURES) {
return Tasks.failTask(q, t, 'Too many network failures');
}
Logger.log(`[Task] VIDEO progress failed (${failCount}/${SYS.MAX_TASK_FAILURES}): ${err.message}`, 'debug');
}
Logger.updateTask(q.id, { name: t.name, type: "VIDEO", cur, max: t.target, status: "RUNNING" });
if (Date.now() - startTime > SYS.MAX_TIME) {
return Tasks.failTask(q, t, 'Timeout exceeded');
}
}
if (RUNTIME.running) {
Logger.log(`[Task] VIDEO "${t.name}" done in ${calls} API calls`, 'debug');
Tasks.finish(q, t);
}
},
GAME(q, t, s) { return Tasks.generic(q, t, "GAME", "PLAY_ON_DESKTOP", s); },
STREAM(q, t, s) { return Tasks.generic(q, t, "STREAM", "STREAM_ON_DESKTOP", s); },
// shared path for GAME/STREAM — injects fake process, subscribes to heartbeat events
async generic(q, t, type, fallbackKey, s) {
if (!RUNTIME.running) return;
// Prefer the key detected from the quest config. detectType matches task keys by
// substring, so a renamed variant (a PLAY_ON_DESKTOP_V2, say) still resolves —
// but reading progress under a hardcoded legacy name would return undefined and
// pin the task at 0 until the safety timer kills it.
const key = t.keyName || fallbackKey;
const gameData = await this.fetchGameData(t.appId, t.name);
return new Promise(resolve => {
const pid = rnd(2500, 12500) * 4;
const game = {
id: gameData.id, name: gameData.name, icon: gameData.icon,
pid, pidPath: [pid], processName: gameData.name, start: Date.now(),
exeName: gameData.exeName, exePath: gameData.exePath, cmdLine: gameData.cmdLine,
executables: [{ os: 'win32', name: gameData.exeName, is_launcher: false }],
windowHandle: 0, fullscreenType: 0, overlay: true, sandboxed: false,
hidden: false, isLauncher: false
};
let cleanupHook;
let cleaned = false;
let safetyTimer;
let watchdogTimer;
let beats = 0;
if (type === "STREAM") {
const real = Mods.StreamStore?.getStreamerActiveStreamMetadata;
if (Mods.StreamStore) {
Mods.StreamStore.getStreamerActiveStreamMetadata = () => ({ id: gameData.id, pid, sourceName: gameData.name });
}
// restore unconditionally when the store exists — assigning back an
// undefined `real` is the correct revert. Guarding on `real` being truthy
// would leave our fake installed when the method was undefined at patch time.
cleanupHook = () => { if (Mods.StreamStore) Mods.StreamStore.getStreamerActiveStreamMetadata = real; };
} else {
Patcher.add(game);
cleanupHook = () => Patcher.remove(game);
}
// Seed from progress the server already holds. Painting 0 here made a resumed
// quest look like it had restarted from scratch until the next heartbeat
// (~30s later) corrected it.
const seeded = Tasks.readProgress(s, key);
Logger.updateTask(q.id, { name: t.name, type, cur: seeded, max: t.target, status: "RUNNING" });
Logger.log(`[Task] Started ${type}: ${gameData.name}`, 'info');
const finish = () => {
if (cleaned) return;
cleaned = true;
clearTimeout(safetyTimer);
clearTimeout(watchdogTimer);
try { cleanupHook(); } catch (e) { Logger.log(`[Task] Cleanup: ${e.message}`, 'debug'); }
try { Mods.Dispatcher?.unsubscribe(CONST.EVT.HEARTBEAT, check); } catch (e) {
Logger.log(`[Dispatcher] Unsubscribe failed: ${e.message}`, 'debug');
}
RUNTIME.cleanups.delete(finish);
};
safetyTimer = setTimeout(() => {
if (RUNTIME.running) Tasks.failTask(q, t, 'Timeout exceeded (25m)');
finish();
resolve();
}, SYS.MAX_TIME);
// Discord drives these quests: it sends /quests/{id}/heartbeat itself while it
// believes the game runs, and we only read the replies. If it never accepts the
// injected process, no heartbeat ever arrives and the task would sit "RUNNING"
// for the full 25 minutes with nothing happening. Give up after 90s (3 missed
// beats at the usual ~30s cadence) and say why.
// Re-armed on every beat rather than checked once, so it also catches a quest that
// beats a few times and then goes silent. A one-shot `beats > 0` test would let that
// sit RUNNING for the full 25 minutes while the ticker extrapolated the bar to 100%,
// which is the same misleading display issue #43 was about.
const armWatchdog = () => {
clearTimeout(watchdogTimer);
watchdogTimer = setTimeout(() => {
if (cleaned || !RUNTIME.running) return;
Logger.log(beats === 0
? `[Task] Discord never reported progress for "${t.name}" — it isn't accepting the injected process on this client. Nothing to wait for.`
: `[Task] Discord stopped reporting progress for "${t.name}" after ${beats} update(s). Giving up instead of idling.`, 'err');
Tasks.failTask(q, t, 'No heartbeat from Discord');
finish();
resolve();
}, SYS.HEARTBEAT_GRACE);
};
armWatchdog();
const check = (d) => {
if (!RUNTIME.running) { finish(); resolve(); return; }
if (d?.questId !== q.id) return;
beats++;
armWatchdog();
const prog = Tasks.readProgress(d.userStatus, key);
// anchor for the ticker: it extrapolates from here until the next beat
Logger.updateTask(q.id, {
name: t.name, type, cur: prog, max: t.target, status: "RUNNING",
serverCur: prog, serverAt: Date.now()
});
if (prog >= t.target) {
finish();
Tasks.finish(q, t);
resolve();
}
};
Mods.Dispatcher?.subscribe(CONST.EVT.HEARTBEAT, check);
RUNTIME.cleanups.add(finish);
});
},
// OAuth2 → discordsays.com bypass for ACHIEVEMENT_IN_ACTIVITY.
// Discord trusts the activity backend to validate progress, so a forged
// POST from an authorized session is accepted. Flow:
// 1) /oauth2/authorize the quest's app (returns code in location URL)
// 2) /applications/{appId}/proxy-tickets (returns proxy ticket)
// 3) POST {appId}.discordsays.com/.proxy/acf/authorize {code} → DS token
// 4) POST {appId}.discordsays.com/.proxy/acf/quest/progress {progress: target}
// 5) /oauth2/tokens + DELETE to clean up the grant
//
// Steps 3-4 are blocked by Discord's renderer CSP (connect-src doesn't
// include *.discordsays.com). _bypassPost picks the best available
// transport that escapes CSP: VencordNative if our plugin is installed,
// DiscordNative if it exposes anything, else direct fetch.
// POST to a CSP-restricted URL via the best available transport.
// Returns { ok, status, body } on success, throws { status, body } on HTTP error
// and TypeError on CSP/network. Probes in order: localhost relay (no mod needed
// — see tools/orion-relay/), VencordNative plugin (CSP-free via IPC to main
// process), DiscordNative HTTP candidates (if any exist), raw fetch.
_relayUrl: 'http://127.0.0.1:43210',
_relayProbe: null,
// Cache the in-flight probe promise so concurrent callers (video runs at
// concurrency 2) all await the same result, instead of a second caller seeing
// a half-set flag and getting undefined (falsy) back.
_probeRelay() {
return this._relayProbe ??= (async () => {
try {
const r = await Promise.race([
fetch(`${this._relayUrl}/health`, { method: 'GET', redirect: 'error' }),
new Promise((_, reject) => setTimeout(() => reject(new Error('probe timeout')), 800))
]);
if (r.ok) Logger.log('[Bypass] Orion Relay detected on 127.0.0.1:43210.', 'info');
return r.ok;
} catch (_) {
return false;
}
})();
},
async _bypassPost(url, headers, jsonBody) {
// 1) Localhost relay (tools/orion-relay/) — CSP allows http://127.0.0.1:*,
// the relay forwards to discordsays.com from outside the browser sandbox.
// This is the no-mod path: standalone userscript + tiny helper.
if (await this._probeRelay()) {
const r = await fetch(`${this._relayUrl}/proxy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, headers, body: jsonBody }),
redirect: 'error'
});
if (!r.ok) throw { status: r.status, body: await r.text() };
const result = await r.json();
if (!result.ok) throw { status: result.status, body: result.body };
return result;
}
// 2) Vencord plugin native module — works if user has OrionQuests Vencord plugin
// installed (`native.ts` at the repo root). Routes through Electron main process.
try {
const helper = window.VencordNative?.pluginHelpers?.OrionQuests;
if (helper) {
const u = new URL(url);
const appId = u.hostname.split('.')[0];
const questId = headers['X-Discord-Quest-ID'];
const referrer = headers['Referer'];
if (u.pathname.endsWith('/acf/authorize')) {
const { code } = JSON.parse(jsonBody);
const r = await helper.discordsaysAuthorize({ appId, questId, authCode: code, referrer });
if (!r.ok) throw { status: r.status, body: r.body };
return { ok: true, status: r.status, body: r.body };
}
if (u.pathname.endsWith('/acf/quest/progress')) {
const { progress } = JSON.parse(jsonBody);
const token = headers['X-Auth-Token'];
const r = await helper.discordsaysProgress({ appId, questId, token, target: progress, referrer });
if (!r.ok) throw { status: r.status, body: r.body };
return { ok: true, status: r.status, body: r.body };
}
}
} catch (e) {
if (e?.status) throw e;
Logger.log(`[Bypass] VencordNative path errored: ${e?.message ?? e}`, 'debug');
}
// 3) DiscordNative HTTP probe. Best-effort — no documented method exists,
// but new Discord builds occasionally expose one. We probe several
// plausible paths and use the first that's a callable function.
const dn = window.DiscordNative;
if (dn) {
const probes = [
() => dn.http?.makeRequest,
() => dn.fileManager?.fetchURL,
() => dn.processUtils?.fetch,
() => dn.app?.makeRequest,
];
for (const probe of probes) {
try {
const fn = probe();
if (typeof fn === 'function') {
const r = await fn.call(dn, { method: 'POST', url, headers, body: jsonBody });
if (r && (r.status || r.statusCode)) {
const status = r.status ?? r.statusCode;
return { ok: status >= 200 && status < 300, status, body: r.body ?? r.responseText ?? '' };
}
}
} catch (_) { /* try next probe */ }
}
}
// 4) Direct fetch — works on web Discord (no CSP) or relaxed clients.
// CSP-blocked on Discord Desktop: throws TypeError "Failed to fetch".
// redirect:'error' so a 3xx from discordsays can't bounce our auth token
// and proxy-ticket Referer to an arbitrary host.
const res = await fetch(url, { method: 'POST', headers, body: jsonBody, redirect: 'error' });
const body = await res.text();
if (!res.ok) throw { status: res.status, body };
return { ok: true, status: res.status, body };
},
async bypassAchievement(q, t) {
// taskConfigV2 moved the app off config.application and onto the task, so reading
// the legacy field alone resolves null on every current quest and this bailed out
// before it ever tried. t.appId already carries whatever Tasks.appIdFor resolved,
// so prefer it and keep the legacy read as the last fallback (issue #43).
const appId = t.appId || q.config?.application?.id;
if (!appId) return false;
// appId is interpolated straight into discordsays URLs. Refuse anything
// non-numeric so a malformed/hostile id can't redirect the request elsewhere.
if (!/^\d+$/.test(String(appId))) {
Logger.log(`[Bypass] Refusing non-numeric appId "${appId}".`, 'warn');
return false;
}
// Snapshot the grants this app already has BEFORE we authorize, so cleanup
// revokes only the grant we create and never one the user made themselves.
// The snapshot is a PRECONDITION: if it fails we abort before authorizing, so we
// never create a grant we can't later identify and revoke.
let preGrantIds;
try {
const before = await Mods.API.get({ url: '/oauth2/tokens' });
preGrantIds = new Set((before?.body || []).filter(tk => tk.application?.id === appId).map(tk => tk.id));
} catch (e) {
Logger.log(`[Bypass] Couldn't snapshot existing grants; aborting so we never leave an un-revocable authorization: ${e?.message}`, 'warn');
return false;
}
try {
// Consent gate: never OAuth-authorize a third-party app without the user's
// explicit, informed OK. Resolve the app's display name for the prompt; a
// decline aborts before the irreversible authorize. preGrantIds is already a
// Set here (snapshot failure returned above), so no grant leaks on decline.
let appName = null;
try { const a = await Mods.API.get({ url: `/applications/public?application_ids=${appId}` }); appName = a?.body?.[0]?.name ?? null; } catch (_) { }
if (!(await Consent.ask(appId, appName))) {
Logger.log(`[Bypass] Consent declined for "${t.name}". Not authorizing the app.`, 'warn');
return false;
}
Logger.log(`[Bypass] Trying Discord Says auth flow for "${t.name}"...`, 'info');
const authRes = await Mods.API.post({
url: '/oauth2/authorize',
query: {
response_type: 'code',
client_id: appId,
scope: 'identify applications.commands applications.entitlements'
},
body: {
permissions: '0',
authorize: true,
integration_type: 1,
location_context: { guild_id: '10000', channel_id: '10000', channel_type: 10000 }
}
});
const location = authRes?.body?.location;
if (!location) throw new Error('no location in /oauth2/authorize response');
const authCode = new URL(location).searchParams.get('code');
if (!authCode) throw new Error('no code in authorize location');
const ticketRes = await Mods.API.post({ url: `/applications/${appId}/proxy-tickets`, body: {} });
const proxyTicket = ticketRes?.body?.ticket;
if (!proxyTicket) throw new Error('no proxy ticket');
const referrer = `https://${appId}.discordsays.com/?instance_id=example-cl-instance&platform=desktop&discord_proxy_ticket=${encodeURIComponent(proxyTicket)}`;
const dsAuthRes = await Tasks._bypassPost(
`https://${appId}.discordsays.com/.proxy/acf/authorize`,
{ 'Content-Type': 'application/json', 'X-Auth-Token': '', 'X-Discord-Quest-ID': q.id, 'Referer': referrer },
JSON.stringify({ code: authCode })
);
let dsToken;
try { dsToken = JSON.parse(dsAuthRes.body)?.token; }
catch { throw new Error('discordsays returned non-JSON: ' + String(dsAuthRes.body).slice(0, 120)); }
if (!dsToken) throw new Error('no discordsays token');
await Tasks._bypassPost(
`https://${appId}.discordsays.com/.proxy/acf/quest/progress`,
{ 'Content-Type': 'application/json', 'X-Auth-Token': dsToken, 'X-Discord-Quest-ID': q.id, 'Referer': referrer },
JSON.stringify({ progress: t.target })
);
Logger.log(`[Bypass] Success — "${t.name}" completed via Discord Says.`, 'success');
return true;
} catch (e) {
// Discord renderer CSP blocks connect-src to *.discordsays.com. fetch() throws
// TypeError "Failed to fetch" without a status. There's no userscript workaround
// — the bypass needs a main-process HTTP client (Vencord plugin native module).
if (e instanceof TypeError && /failed to fetch|networkerror/i.test(e.message)) {
Logger.log(`[Bypass] Discord's CSP blocks the script from reaching discordsays.com. Use the Vencord plugin port for the auto-bypass — userscript can't bypass CSP. Skipping "${t.name}".`, 'warn');
return false;
}
// Discord's API client rejects with {status, body:{code,message}}, not Error — stringify properly
const code = e?.body?.code;
// 50165 = Cannot launch Age-Gated Activity — activity is age-gated or has been delisted
if (code === 50165) {
Logger.log(`[Bypass] "${t.name}" can't be launched (age-gated or delisted). Discord blocks the proxy ticket — nothing we can do.`, 'warn');
return false;
}
const parts = [];
if (e?.status) parts.push(`HTTP ${e.status}`);
if (code) parts.push(`code ${code}`);
if (e?.body?.message) parts.push(e.body.message);
else if (e?.message) parts.push(e.message);
else if (typeof e === 'string') parts.push(e);
else if (e) { try { parts.push(JSON.stringify(e).slice(0, 200)); } catch { parts.push(String(e)); } }
Logger.log(`[Bypass] Failed: ${parts.join(' — ') || 'unknown'}`, 'warn');
return false;
} finally {
// Revoke ONLY the grant we created, diffed against the pre-flow snapshot.
// Runs whether the progress call succeeded or threw, so a failed bypass
// never leaves the app authorized on the user's account.
if (preGrantIds) {
try {
const after = await Mods.API.get({ url: '/oauth2/tokens' });
const ours = (after?.body || []).filter(tk => tk.application?.id === appId && !preGrantIds.has(tk.id));
for (const g of ours) await Mods.API.del({ url: `/oauth2/tokens/${g.id}` });
} catch (e) {
Logger.log(`[Bypass] Deauthorize cleanup non-fatal: ${e?.message}`, 'debug');
}
}
}
},
// ACHIEVEMENT_IN_ACTIVITY — target is usually 1 (a milestone, not seconds).
// 1) heartbeat spoof (works for some quests)
// 2) discordsays OAuth bypass (silver bullet)
// 3) skip on failure — no more 25-min passive wait
async ACHIEVEMENT(q, t) {
Logger.updateTask(q.id, { name: t.name, type: "ACHIEVEMENT", cur: 0, max: t.target, status: "RUNNING" });
// attempt active heartbeat spoofing
let chan = null;
try {
chan = Mods.ChanStore?.getSortedPrivateChannels()?.[0]?.id
?? Object.values(Mods.GuildChanStore?.getAllGuilds() ?? {}).find(g => g?.VOCAL?.length)?.VOCAL?.[0]?.channel?.id;
} catch (e) { Logger.log(`[Achievement] Channel lookup: ${e.message}`, 'debug'); }
if (chan) {
Logger.log(`[Task] Attempting heartbeat spoofing for "${t.name}"...`, 'info');
const key = `call:${chan}:${rnd(1000, 9999)}`;
let cur = 0;
let failCount = 0;
while (cur < t.target && RUNTIME.running) {
try {
const r = await Traffic.enqueue(`/quests/${q.id}/heartbeat`, { stream_key: key, terminal: false });
cur = r?.body?.progress?.[t.keyName]?.value ?? r?.body?.progress?.ACHIEVEMENT_IN_ACTIVITY?.value ?? cur;
Logger.updateTask(q.id, { name: t.name, type: "ACHIEVEMENT", cur, max: t.target, status: "RUNNING" });
failCount = 0;
if (cur >= t.target) {
try { await Traffic.enqueue(`/quests/${q.id}/heartbeat`, { stream_key: key, terminal: true }); }
catch (_) { }
break;
}
} catch (e) {
failCount++;
const err = ErrorHandler.classify(e);
if (err.isClientError) {
Logger.log(`[Achievement] Heartbeat rejected (HTTP ${err.status}). Falling back to passive mode.`, 'warn');
break;
}
if (failCount >= SYS.MAX_TASK_FAILURES) {
Logger.log(`[Achievement] Too many failures. Falling back to passive mode.`, 'warn');
break;
}
}
await sleep(rnd(19000, 22000));
}
if (cur >= t.target && RUNTIME.running) return Tasks.finish(q, t);
}
// heartbeat path failed or skipped — try the Discord Says auth bypass
if (!RUNTIME.running) return;
const bypassed = await Tasks.bypassAchievement(q, t);
if (bypassed) return Tasks.finish(q, t);
// both auto-paths failed: skip the quest. no more 25-min passive wait.
if (!RUNTIME.running) return;
Logger.log(`[Task] Skipping "${t.name}" — no auto-completion path worked (heartbeat rejected, bypass blocked). Likely age-gated/delisted on your account.`, 'warn');
return Tasks.failTask(q, t, 'Cannot auto-complete');
},
// heartbeat loop against a voice channel to simulate activity participation
async ACTIVITY(q, t) {
let chan = null;
try {
chan = Mods.ChanStore?.getSortedPrivateChannels()?.[0]?.id
?? Object.values(Mods.GuildChanStore?.getAllGuilds() ?? {}).find(g => g?.VOCAL?.length)?.VOCAL?.[0]?.channel?.id;
} catch (e) {
Logger.log(`[Task] ACTIVITY channel lookup error: ${e.message}`, 'debug');
}
if (!chan) {
return Tasks.failTask(q, t, 'No voice channel found');
}
const key = `call:${chan}:${rnd(1000, 9999)}`;
let cur = 0;
let failCount = 0;
Logger.updateTask(q.id, { name: t.name, type: "ACTIVITY", cur, max: t.target, status: "RUNNING" });
const startTime = Date.now();
while (cur < t.target && RUNTIME.running) {
try {
const r = await Traffic.enqueue(`/quests/${q.id}/heartbeat`, { stream_key: key, terminal: false });
cur = r?.body?.progress?.[t.keyName]?.value ?? r?.body?.progress?.PLAY_ACTIVITY?.value ?? cur + 20;
Logger.updateTask(q.id, { name: t.name, type: "ACTIVITY", cur, max: t.target, status: "RUNNING" });
failCount = 0;
if (cur >= t.target) {
try { await Traffic.enqueue(`/quests/${q.id}/heartbeat`, { stream_key: key, terminal: true }); }
catch (e) { Logger.log(`[ACTIVITY] Final heartbeat failed: ${e?.message}`, 'debug'); }
break;
}
} catch (e) {
failCount++;
const err = ErrorHandler.classify(e);
if (err.isClientError) {
Logger.log(`[Task] Activity quest unavailable (HTTP ${err.status}). Skipping.`, 'warn');
return Tasks.failTask(q, t, `Client Error ${err.status}`);
}
if (failCount >= SYS.MAX_TASK_FAILURES) {
return Tasks.failTask(q, t, 'Too many network failures');
}
Logger.log(`[Task] ACTIVITY heartbeat failed (${failCount}/${SYS.MAX_TASK_FAILURES}): ${err.message}`, 'debug');
}
if (Date.now() - startTime > SYS.MAX_TIME) {
return Tasks.failTask(q, t, 'Timeout exceeded');
}
await sleep(rnd(19000, 22000));
}
if (RUNTIME.running && cur >= t.target) Tasks.finish(q, t);
},
async finish(q, t) {
Logger.updateTask(q.id, { name: t.name, type: t.type, cur: t.target, max: t.target, status: "COMPLETED" });
Logger.log(`[Task] Completed "${t.name}"!`, 'success');
Sound.play('tick');
try {
if (typeof Notification !== 'undefined') {
// ask lazily on the first completion, not on paste (a permission prompt
// before the user has even picked a quest is intrusive).
if (Notification.permission === 'default') { try { await Notification.requestPermission(); } catch (_) { } }
if (Notification.permission === 'granted') {
new Notification("Orion: Quest Completed", { body: t.name, icon: "https://cdn.discordapp.com/emojis/1120042457007792168.webp", tag: `orion-${q.id}` });
}
}
} catch (e) { Logger.log(`[Notification] ${e.message}`, 'debug'); }
if (RUNTIME.autoClaim) {
try {
await sleep(rnd(2500, 6000));
if (!RUNTIME.running) return;
// optimistic claim — try without captcha, show button if challenged
const claimRes = await this.claimReward(q.id);
if (claimRes?.body?.claimed_at) {
Logger.log(`[Claim] Reward for "${t.name}" claimed automatically!`, 'success');
Logger.updateTask(q.id, { name: t.name, type: t.type, cur: t.target, max: t.target, status: "CLAIMED" });
setTimeout(() => Logger.removeTask(q.id), 2000); // ms before clearing finished tasks
return;
}
} catch (e) {
// captcha required or other error — fall through to claim button
const needsCaptcha = e?.body?.captcha_key || e?.body?.captcha_sitekey;
if (needsCaptcha) {
Logger.log(`[Claim] Captcha required for "${t.name}". Use UI button.`, 'warn');
} else {
Logger.log(`[Claim] Auto-claim failed for "${t.name}": ${e?.body?.message ?? e?.message}`, 'err');
}
}
}
// show claim button instead of auto-removing
Logger.updateTask(q.id, { name: t.name, type: t.type, cur: t.target, max: t.target, status: "COMPLETED", claimable: true, questId: q.id });
}
};
/* ── webpack module extraction ───────────────────────────────
Uses getName() as a stable discriminator for Flux stores.
Real stores return their name (e.g. "QuestStore"), fakes
return "[object Object]". No hardcoded property paths needed.
Dispatcher and API use structural checks instead.
── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── */
function loadModules() {
try {
// === VENCORD USAGE ===
if (typeof window.Vencord !== 'undefined' && window.Vencord.Webpack) {
Logger.log('[System] Vencord detected. Using Vencord Webpack API...', 'info');
const W = window.Vencord.Webpack;
let routerModule;
try {
const m = W.findByCode('transitionTo -');
if (m) {
for (const prop of [m, m.default, ...Object.values(m)]) {
if (typeof prop === 'function' && prop.toString().includes('transitionTo -')) {
routerModule = { transitionTo: prop };
break;
}
}
}
} catch (e) { }
Mods = {
QuestStore: W.findStore('QuestStore') || W.findStore('QuestsStore'),
RunStore: W.findStore('RunningGameStore'),
StreamStore: W.findStore('ApplicationStreamingStore'),
ChanStore: W.findStore('ChannelStore'),
GuildChanStore: W.findStore('GuildChannelStore'),
Dispatcher: W.Common?.FluxDispatcher || W.findByProps('dispatch', 'subscribe', 'flushWaitQueue'),
API: W.Common?.RestAPI || W.findByProps('get', 'post', 'del'),
Router: routerModule
};
const required = ['QuestStore', 'API', 'Dispatcher', 'RunStore'];
const missing = required.filter(k => !Mods[k]);
if (missing.length === 0) {
const optional =['StreamStore', 'ChanStore', 'GuildChanStore', 'Router'];
optional.forEach(k => { if (!Mods[k]) Logger.log(`[System] Optional module '${k}' not found. Features may be limited.`, 'warn'); });
Patcher.init(Mods.RunStore);
return true;
}
Logger.log(`[System] Vencord extraction missed: ${missing.join(', ')}. Falling back to native...`, 'warn');
}
// === NATIVE FALLBACK (Canary / PTB without mods) ===
if (typeof webpackChunkdiscord_app === 'undefined') {
throw new Error("Webpack chunk not found - is this running inside Discord?");
}
// The push callback fires once per registered webpack runtime. Discord ships
// Sentry's stripped runtime alongside the real one — Sentry's `req.c` is tiny.
// Pick the require with the largest cache so we ignore the Sentry instance.
let req;
webpackChunkdiscord_app.push([[Symbol()], {}, (r) => {
const cur = Object.keys(req?.c || {}).length;
const incoming = Object.keys(r?.c || {}).length;
if (incoming > cur) req = r;
}]);
webpackChunkdiscord_app.pop();
if (!req?.c) throw new Error("Module registry not available - Discord build incompatible (see issue #20)");
const modules = Object.values(req.c);
// real Flux stores have constructor.displayName set to their class name
// fakes have displayName "Object" — this check never triggers Proxy traps
function findStore(storeName) {
for (const m of modules) {
try {
const exp = m?.exports;
if (!exp || typeof exp !== 'object') continue;
for (const key of Object.keys(exp)) {
const prop = exp[key];
if (prop && typeof prop === 'object'
&& prop.__proto__?.constructor?.displayName === storeName) {
return prop;
}
}
} catch { }
}
return undefined;
}
// Dispatcher has _subscriptions + subscribe on proto, no valid getName
function findDispatcher() {
for (const m of modules) {
try {
const exp = m?.exports;
if (!exp || typeof exp !== 'object') continue;
for (const key of Object.keys(exp)) {
const prop = exp[key];
if (prop && prop._subscriptions
&& typeof prop.subscribe === 'function'
&& typeof prop.dispatch === 'function'
&& typeof prop.__proto__?.flushWaitQueue === 'function') {
return prop;
}
}
} catch { }
}
return undefined;
}
// Discord's API client has .del (not .delete) — this distinguishes it
// from generic HTTP wrappers. Also has get/post/put/patch as own props.
function findAPI() {
for (const m of modules) {
try {
const exp = m?.exports;
if (!exp || typeof exp !== 'object') continue;
for (const key of Object.keys(exp)) {
const prop = exp[key];
if (prop && typeof prop.get === 'function'
&& typeof prop.post === 'function'
&& typeof prop.del === 'function'
&& !prop._dispatcher) {
return prop;
}
}
} catch { }
}
return undefined;
}
// Navigation functions are exported standalone and minified.
// transitionTo is identified by searching its source code for the "transitionTo -" signature.
function findRouter() {
for (const m of modules) {
try {
const exp = m?.exports;
if (!exp) continue;
for (const prop of [exp, exp.default, ...Object.values(exp)]) {
if (typeof prop === 'function' && prop.toString().includes('transitionTo -')) {
return { transitionTo: prop };
}
}
} catch { }
}
return undefined;
}
Mods = {
QuestStore: findStore('QuestStore'),
RunStore: findStore('RunningGameStore'),
StreamStore: findStore('ApplicationStreamingStore'),
ChanStore: findStore('ChannelStore'),
GuildChanStore: findStore('GuildChannelStore'),
Dispatcher: findDispatcher(),
API: findAPI(),
Router: findRouter()
};
const required = ['QuestStore', 'API', 'Dispatcher', 'RunStore'];
const missing = required.filter(k => !Mods[k]);
if (missing.length > 0) throw new Error(`Core modules not found: ${missing.join(', ')}`);
const optional = ['StreamStore', 'ChanStore', 'GuildChanStore', 'Router'];
optional.forEach(k => { if (!Mods[k]) Logger.log(`[System] Optional module '${k}' not found. Features may be limited.`, 'warn'); });
Patcher.init(Mods.RunStore);
return true;
} catch (e) {
Logger.log(`[System] Module loading error: ${e.message ?? e}`, 'err');
console.error(e);
return false;
}
}
/* ── main loop ─────────────────────────────────────────────── */
// run async tasks concurrently up to a specified limit
async function runConcurrent(tasks, limit) {
const executing = new Set();
for (const task of tasks) {
if (!RUNTIME.running) break;
const p = task().finally(() => executing.delete(p));
executing.add(p);
await sleep(rnd(1500, 4000)); // stagger initialization to avoid API bursts
if (executing.size >= limit) {
await Promise.race(executing);
}
}
// use allSettled to prevent a single rejection from crashing the batch
return Promise.allSettled(executing);
}
async function main() {
Logger.init();
if (!loadModules()) return Logger.log('[System] Failed to load Discord modules. Aborting.', 'err');
// show quest picker and wait for user selection
const getQuests = () => {
const q = Mods.QuestStore.quests;
return q instanceof Map ? [...q.values()] : Object.values(q);
};
let quests = getQuests().filter(q =>
!q.userStatus?.completedAt
&& notExpired(q)
&& q.id !== CONST.ID
&& !Tasks.skipped.has(q.id)
);
if (!quests.length) {
Logger.log('[System] All available quests are completed!', 'success');
return Logger.shutdown();
}
const pickerResult = await Logger.showQuestPicker(quests);
if (!RUNTIME.running) return;
// Propagate UI options to global runtime state
RUNTIME.autoEnroll = pickerResult.autoEnroll;
RUNTIME.autoClaim = pickerResult.autoClaim;
RUNTIME.playSound = pickerResult.playSound;
RUNTIME.randomDelay = pickerResult.randomDelay;
if (pickerResult.selectedQuests.size === 0) {
Logger.log('[System] No quests selected. Shutting down.', 'info');
return Logger.shutdown();
}
let loopCount = 1;
while (RUNTIME.running) {
try {
Logger.log(`[Cycle] Starting loop #${loopCount}...`, 'info');
quests = getQuests();
// Filter out completed, expired, blacklisted, skipped, AND unselected quests
const active = quests.filter(q =>
pickerResult.selectedQuests.has(q.id)
&& !q.userStatus?.completedAt
&& notExpired(q)
&& q.id !== CONST.ID
&& !Tasks.skipped.has(q.id)
);
if (!active.length) { Logger.log('[System] All available quests are completed!', 'success'); Sound.play('done'); break; }
const queues = { video: [], game: [] };
active.forEach(q => {
try {
const cfg = q.config?.taskConfig ?? q.config?.taskConfigV2;
if (!cfg?.tasks || typeof cfg.tasks !== 'object') {
Logger.log(`[Quest] ${q.id} has invalid task config. Skipping.`, 'warn');
return;
}
const typeData = Tasks.detectType(cfg, q.config?.application?.id);
if (!typeData) {
Logger.log(`[Quest] Unknown task type: ${q.config?.messages?.questName ?? q.id}`, 'warn');
return;
}
if (!SYS.IS_DESKTOP && (typeData.type === 'GAME' || typeData.type === 'STREAM')) {
Logger.log(`[Quest] "${q.config?.messages?.questName}" requires desktop app. Skipping.`, 'warn');
return;
}
const { type, keyName, target, appId } = typeData;
if (target <= 0) {
Logger.log(`[Quest] Invalid target (${target}) for ${q.id}. Skipping.`, 'warn');
return;
}
// GAME/STREAM impersonate a specific application. Without a real id the
// fake process is unidentifiable and Discord silently never counts it —
// skip loudly instead of running a task that can't finish (issue #43).
if ((type === 'GAME' || type === 'STREAM') && !appId) {
Logger.log(`[Quest] "${q.config?.messages?.questName ?? q.id}" has no application id in its config — can't spoof the game. Skipping.`, 'warn');
Tasks.skipped.add(q.id);
return;
}
const tInfo = {
id: q.id,
appId: appId ?? 0,
name: q.config?.messages?.questName ?? "Unknown Quest",
target,
type,
keyName // actual task key from config (e.g. WATCH_VIDEO_ON_MOBILE)
};
// handle disabled auto-enroll (wait for user)
if (!q.userStatus?.enrolledAt && !RUNTIME.autoEnroll) {
Logger.updateTask(tInfo.id, {
name: tInfo.name, type: tInfo.type, cur: 0, max: tInfo.target,
status: "PENDING", actionRequired: 'ENROLL'
});
return; // skip execution queue, wait for next cycle
}
if (Logger.tasks.has(q.id) && Logger.tasks.get(q.id).status === "RUNNING") return;
// clear the action button if user enrolled manually
Logger.updateTask(tInfo.id, {
name: tInfo.name, type: tInfo.type, cur: 0, max: tInfo.target,
status: "QUEUE", actionRequired: null
});
const taskFunc = async () => {
// JIT enrollment (only if autoEnroll is true or user already enrolled)
if (!q.userStatus?.enrolledAt) {
Logger.log(`[Enroll] Accepting quest: ${tInfo.name}`, 'info');
try {
await Traffic.enqueue(`/quests/${q.id}/enroll`, { location: 11, is_targeted: false });
await sleep(rnd(800, 1500));
} catch (e) {
const err = ErrorHandler.classify(e);
if (ErrorHandler.isSkippableQuest(e)) {
Tasks.skipped.add(q.id);
Logger.log(`[Enroll] ${tInfo.name} unavailable (${err.status}). Skipping.`, 'warn');
} else {
Logger.log(`[Enroll] Failed for ${tInfo.name}: ${err.message}`, 'err');
}
return Tasks.failTask(q, tInfo, `Enrollment failed`);
}
}
if (type === "WATCH_VIDEO") return Tasks.VIDEO(q, tInfo, q.userStatus);
if (type === "ACHIEVEMENT") return Tasks.ACHIEVEMENT(q, tInfo);
const runner = type === "STREAM" ? Tasks.STREAM : (type === "ACTIVITY" ? Tasks.ACTIVITY : Tasks.GAME);
return runner(q, tInfo, q.userStatus);
};
if (type === "WATCH_VIDEO") queues.video.push(taskFunc);
else queues.game.push(taskFunc);
} catch (e) {
Logger.log(`[Quest] Error processing ${q.id}: ${e.message}`, 'err');
}
});
const totalTasks = queues.video.length + queues.game.length;
if (totalTasks > 0) {
Logger.log(`[Cycle] Processing: ${queues.video.length} videos, ${queues.game.length} games.`, 'info');
const pGames = runConcurrent(queues.game, 1);
const pVideos = runConcurrent(queues.video, 2);
await Promise.all([pGames, pVideos]);
} else {
if (active.length === 0) { Logger.log('[System] All available quests are completed!', 'success'); break; }
else await sleep(rnd(4000, 6000)); // idle loop wait
}
if (!RUNTIME.running) break;
// anti-detection: opt-in randomized idle gap between cycles. each cycle
// typically completes 1+ quests, so this throttles overall quest velocity.
if (RUNTIME.randomDelay) {
const delayMs = rnd(60000, 1800000); // 1-30 minutes
Logger.log(`[Cycle] Loop #${loopCount} complete. Random delay: ${Math.round(delayMs / 60000)}m before rescan.`, 'info');
await sleep(delayMs);
} else {
Logger.log(`[Cycle] Loop #${loopCount} complete. Waiting before rescan...`, 'info');
await sleep(rnd(2500, 4500));
}
loopCount++;
} catch (cycleError) {
Logger.log(`[Cycle] Error in loop #${loopCount}: ${cycleError?.message ?? cycleError}`, 'err');
console.error(cycleError);
await sleep(3000);
loopCount++;
}
}
// Keep the UI alive if there are unclaimed rewards waiting — losing the
// dashboard the moment the queue finishes means users miss the CLAIM
// buttons (especially when auto-claim is off). User can click STOP manually.
const hasUnclaimed = [...Logger.tasks.values()].some(t => t.claimable && !t.removing);
if (hasUnclaimed) {
Logger.log('[System] Quest cycle finished. Claim your rewards above, then click STOP.', 'info');
return; // skip shutdown; the STOP button still works
}
Logger.shutdown();
}
main().catch(e => {
const msg = e?.message ?? e?.toString?.() ?? "Unknown fatal error";
console.error('[Orion Fatal]', e);
try { Logger.log(`[System] FATAL: ${msg}`, 'err'); } catch (_) { }
Logger.shutdown();
// Failsafe: release lock unconditionally so user can retry without reloading tab
setTimeout(() => { window.orionLock = false; }, 1500);
});
})();