diff --git a/plugins/quest-orbs/manifest.json b/plugins/quest-orbs/manifest.json deleted file mode 100644 index 64c7c8b..0000000 --- a/plugins/quest-orbs/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "Quest Orbs", - "description": "WARNING: THIS CAN GET YOUR ACCOUNT BANNED. Automates completion of mobile-video Discord Quests and claims the Orb rewards - real-time paced (no accelerated/faked timestamps), so the traffic mirrors a real client, but this is still client-modification automation that violates Discord's Terms of Service and can get the account it runs on suspended or banned. If claiming a reward triggers Discord's captcha challenge, this plugin never tries to solve or bypass it - it stops immediately and you complete that captcha yourself, directly in Discord's normal Quests screen. Use only on an account you're willing to risk.", - "authors": [ - { "name": "Rosie" } - ], - "main": "src/index.ts", - "vendetta": { - "icon": "ic_reward_redeemed" - } -} diff --git a/plugins/quest-orbs/src/index.ts b/plugins/quest-orbs/src/index.ts deleted file mode 100644 index f895e60..0000000 --- a/plugins/quest-orbs/src/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { logger } from "@vendetta"; - -import Settings from "./ui/Settings"; -import { completeAllVideoQuests } from "./lib/questCompleter"; - -export default { - onLoad: () => { - // Fire-and-forget: never blocks Discord's own boot, and a failure here (e.g. QuestStore - // not populated yet this session) is logged, not thrown - onLoad must not crash the app. - completeAllVideoQuests().catch((e) => { - logger.error(`[QuestOrbs] Auto-run on load failed: ${e}`); - }); - }, - onUnload: () => {}, - settings: Settings -}; diff --git a/plugins/quest-orbs/src/lib/modules.ts b/plugins/quest-orbs/src/lib/modules.ts deleted file mode 100644 index 68525a0..0000000 --- a/plugins/quest-orbs/src/lib/modules.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { rawFindByStoreName } from "@shared/lib/rawFind"; -import { lazy } from "@shared/lib/lazy"; - -declare const window: any; - -export interface RestResponse { - ok: boolean; - status: number; - body: T; - headers?: any; - text?: string; - retryAfter?: number; -} - -export interface RestClient { - get(opts: { url: string; query?: any }): Promise; - post(opts: { url: string; body?: any }): Promise; -} - -export interface QuestActions { - fetchCurrentQuests?: (...args: any[]) => any; -} - -// rawFindByProps only checks that named properties exist, not that they're real functions - a -// decoy module can match with e.g. "get" set to null. Also requiring a real getAPIBaseURL function -// plus a V8APIError/V6OrEarlierAPIError export rules those decoys out. -export const getRestClient = lazy(() => { - const modules = window?.modules; - if (!modules) return undefined; - - for (const id in modules) { - const def = modules[id]; - if (!def?.isInitialized) continue; - const exports = def.publicModule?.exports; - if (!exports) continue; - - for (const candidate of [exports, exports.default]) { - if (!candidate) continue; - if ( - typeof candidate.get === "function" && - typeof candidate.post === "function" && - typeof candidate.getAPIBaseURL === "function" && - (candidate.V8APIError || candidate.V6OrEarlierAPIError) - ) { - return candidate as RestClient; - } - } - } - - return undefined; -}); - -export const getQuestStore = lazy(() => rawFindByStoreName("QuestStore")); - -// Only used for an optional, best-effort refresh - completion itself goes through direct REST -// calls, not this module. Calling a function on this exports object directly (as an external -// caller) works fine; PATCHING it wouldn't, since same-chunk internal calls bypass the exports -// object. -export const getQuestActions = lazy(() => { - const modules = window?.modules; - if (!modules) return undefined; - - for (const id in modules) { - const def = modules[id]; - if (!def?.isInitialized) continue; - const exports = def.publicModule?.exports; - if (!exports) continue; - - for (const candidate of [exports, exports.default]) { - if (!candidate) continue; - if ( - typeof candidate.enrollInQuest === "function" && - typeof candidate.updateVideoProgress === "function" && - typeof candidate.claimQuestReward === "function" && - typeof candidate.fetchCurrentQuests === "function" - ) { - return candidate as QuestActions; - } - } - } - - return undefined; -}); diff --git a/plugins/quest-orbs/src/lib/questCompleter.ts b/plugins/quest-orbs/src/lib/questCompleter.ts deleted file mode 100644 index 3290422..0000000 --- a/plugins/quest-orbs/src/lib/questCompleter.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { logger } from "@vendetta"; -import { showToast } from "@vendetta/ui/toasts"; -import { getAssetIDByName } from "@vendetta/ui/assets"; - -import { getRestClient, getQuestStore, getQuestActions } from "./modules"; - -const TAG = "[QuestOrbs]"; - -// Matches Discord's own ~7-8s video-progress heartbeat cadence - real-time paced, never sped up. -const HEARTBEAT_INTERVAL_MS = 8000; - -export interface QuestRunResult { - completed: string[]; - needsManualClaim: string[]; - failed: string[]; - skipped: number; -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function isVideoQuest(quest: any): boolean { - return !!quest?.config?.taskConfigV2?.tasks?.WATCH_VIDEO_ON_MOBILE; -} - -function isAlreadyClaimed(quest: any): boolean { - return !!quest?.userStatus?.claimedAt; -} - -function questName(quest: any): string { - return quest?.config?.messages?.questName ?? quest?.id ?? "unknown quest"; -} - -// Discord's wire format is snake_case, distinct from QuestStore's camelCase quest objects. -function questActionBody(quest: any, extra?: Record) { - return { - location: 12, - is_targeted: false, - metadata_sealed: null, - traffic_metadata_sealed: quest.trafficMetadataSealed ?? null, - ...extra, - }; -} - -async function enroll(rest: ReturnType, quest: any): Promise { - await rest!.post({ - url: `/quests/${quest.id}/enroll`, - body: questActionBody(quest), - }); -} - -// Sends real-time-paced heartbeats until completed_at flips non-null. -async function watchVideo(rest: ReturnType, quest: any): Promise { - const target: number = quest.config.taskConfigV2.tasks.WATCH_VIDEO_ON_MOBILE.target; - let elapsed = 0; - - while (elapsed < target) { - await sleep(HEARTBEAT_INTERVAL_MS); - elapsed = Math.min(elapsed + HEARTBEAT_INTERVAL_MS / 1000, target); - - const res = await rest!.post({ - url: `/quests/${quest.id}/video-progress`, - body: { timestamp: elapsed }, - }); - - if (res?.body?.completed_at) return true; - } - - return false; -} - -type ClaimOutcome = "claimed" | "captcha" | "error"; - -// Detects a captcha_key response and stops - never attempts to solve or bypass it. Checks both a -// thrown error and a resolved-but-not-ok response, since which path the REST client takes on a -// non-2xx isn't pinned down. -async function claim(rest: ReturnType, quest: any): Promise { - try { - const res = await rest!.post({ - url: `/quests/${quest.id}/claim-reward`, - body: questActionBody(quest, { platform: 0 }), - }); - - if (res?.ok) return "claimed"; - if (res?.body?.captcha_key) return "captcha"; - return "error"; - } catch (e: any) { - if (e?.body?.captcha_key || e?.captcha_key) return "captcha"; - logger.error(`${TAG} claim-reward request failed for "${questName(quest)}": ${e}`); - return "error"; - } -} - -// Best-effort refresh so Discord's UI doesn't show stale data after completing quests via direct -// REST calls instead of Discord's own action creators. Purely cosmetic. -function refreshQuestStore(): void { - try { - getQuestActions()?.fetchCurrentQuests?.(); - } catch (e) { - logger.error(`${TAG} best-effort QuestStore refresh failed (non-fatal): ${e}`); - } -} - -export async function completeAllVideoQuests(): Promise { - const result: QuestRunResult = { completed: [], needsManualClaim: [], failed: [], skipped: 0 }; - - const rest = getRestClient(); - const questStore = getQuestStore(); - if (!rest || !questStore?.quests) { - logger.error(`${TAG} REST client or QuestStore not available yet - Discord hasn't loaded the Quests feature in this session`); - return result; - } - - const quests: any[] = Array.from(questStore.quests.values()).filter(isVideoQuest); - - for (const quest of quests) { - try { - if (isAlreadyClaimed(quest)) { - result.skipped++; - continue; - } - - if (!quest.userStatus?.enrolledAt) { - await enroll(rest, quest); - } - - if (!quest.userStatus?.completedAt) { - const done = await watchVideo(rest, quest); - if (!done) { - result.failed.push(questName(quest)); - continue; - } - } - - const outcome = await claim(rest, quest); - if (outcome === "claimed") { - result.completed.push(questName(quest)); - } else if (outcome === "captcha") { - result.needsManualClaim.push(questName(quest)); - showToast(`"${questName(quest)}" needs manual claim (captcha) - open it in Discord`, getAssetIDByName("ic_warning_24px")); - } else { - result.failed.push(questName(quest)); - } - } catch (e) { - logger.error(`${TAG} Failed on quest "${questName(quest)}": ${e}`); - result.failed.push(questName(quest)); - } - } - - if (result.completed.length > 0) refreshQuestStore(); - - return result; -} diff --git a/plugins/quest-orbs/src/ui/Settings.tsx b/plugins/quest-orbs/src/ui/Settings.tsx deleted file mode 100644 index abba371..0000000 --- a/plugins/quest-orbs/src/ui/Settings.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { React, ReactNative } from "@vendetta/metro/common"; -import SettingsScaffold from "@shared/ui/SettingsScaffold"; -import PrimaryButton from "@shared/ui/PrimaryButton"; -import NoteBox from "@shared/ui/NoteBox"; -import { TableRowGroup } from "@shared/ui/table"; -import { resolveSemanticColorSafe } from "@shared/lib/color"; - -import { completeAllVideoQuests, QuestRunResult } from "../lib/questCompleter"; - -const { View, Text } = ReactNative; - -const textColor = () => resolveSemanticColorSafe(["TEXT_NORMAL", "TEXT_DEFAULT"], "#dbdee1"); -const warningColor = () => resolveSemanticColorSafe(["TEXT_DANGER", "TEXT_NORMAL"], "#f0b232"); - -// Deliberately the first thing on this screen, not folded into the NoteBox below it - this is a -// real ban risk, not fine print. -function BanWarning() { - return ( - - - WARNING: THIS CAN GET YOUR ACCOUNT BANNED - - - Automating Quest completion is a form of client modification, which Discord's Terms - of Service prohibit. Discord can and does detect and act on this kind of automation - - using this plugin risks suspension or a permanent ban on the account it runs on. - Nothing here disguises the traffic as anything other than what it is; it just skips - the tedium of manually sitting through each video.{"\n\n"} - This plugin never solves or bypasses a captcha. When claiming a reward triggers - Discord's own captcha challenge, this plugin stops immediately and leaves that quest - for you - you open it in Discord's normal Quests screen and complete the captcha - yourself, exactly as if this plugin weren't involved at all.{"\n\n"} - Use only on an account you're willing to risk, and understand this is unsupported, - unofficial automation with no guarantee Discord won't act on it. - - - ); -} - -function summarize(result: QuestRunResult): string { - const parts: string[] = []; - if (result.completed.length) parts.push(`${result.completed.length} completed: ${result.completed.join(", ")}`); - if (result.needsManualClaim.length) parts.push(`${result.needsManualClaim.length} need manual claim (captcha): ${result.needsManualClaim.join(", ")}`); - if (result.failed.length) parts.push(`${result.failed.length} failed: ${result.failed.join(", ")}`); - parts.push(`${result.skipped} already claimed`); - return parts.join("\n"); -} - -export default function Settings() { - const [running, setRunning] = React.useState(false); - const [lastResult, setLastResult] = React.useState(null); - - const run = async () => { - if (running) return; - setRunning(true); - setLastResult(null); - try { - const result = await completeAllVideoQuests(); - setLastResult(summarize(result)); - } catch (e) { - setLastResult(`Run failed: ${e}`); - } finally { - setRunning(false); - } - }; - - return ( - - - - - - {lastResult != null && ( - - {lastResult} - - )} - - - - Also runs automatically once when Discord loads. Only handles mobile video-watch - quests, paced in real time (no sped-up/faked progress) - see the warning above for - what "automatically" does and doesn't cover. - - - ); -}