From bbde5467e9f11e13826af60f824e0f40c41a29a7 Mon Sep 17 00:00:00 2001 From: Ally Founder Date: Sat, 8 Aug 2026 16:55:07 -0400 Subject: [PATCH] Add Quest Orbs plugin: auto-complete mobile video quests --- plugins/quest-orbs/manifest.json | 11 ++ plugins/quest-orbs/src/index.ts | 16 ++ plugins/quest-orbs/src/lib/modules.ts | 93 ++++++++++ plugins/quest-orbs/src/lib/questCompleter.ts | 173 +++++++++++++++++++ plugins/quest-orbs/src/ui/Settings.tsx | 65 +++++++ 5 files changed, 358 insertions(+) create mode 100644 plugins/quest-orbs/manifest.json create mode 100644 plugins/quest-orbs/src/index.ts create mode 100644 plugins/quest-orbs/src/lib/modules.ts create mode 100644 plugins/quest-orbs/src/lib/questCompleter.ts create mode 100644 plugins/quest-orbs/src/ui/Settings.tsx diff --git a/plugins/quest-orbs/manifest.json b/plugins/quest-orbs/manifest.json new file mode 100644 index 0000000..4e74010 --- /dev/null +++ b/plugins/quest-orbs/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "Quest Orbs", + "description": "Automatically enrolls in and completes mobile-video Discord Quests (real-time paced, no accelerated/faked timestamps) and claims the Orb rewards. If a claim gets blocked by a captcha challenge, it stops and tells you to claim that one manually in Discord's normal UI - it never tries to solve or bypass a captcha.", + "authors": [ + { "name": "everestmcarthur" } + ], + "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 new file mode 100644 index 0000000..f895e60 --- /dev/null +++ b/plugins/quest-orbs/src/index.ts @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..1a6bc16 --- /dev/null +++ b/plugins/quest-orbs/src/lib/modules.ts @@ -0,0 +1,93 @@ +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 on a module, not that they're real + * functions - live-verified against this exact codebase that a decoy module matches + * ("getAPIBaseURL"+"get"+"post") with "get" set to null, throwing "null is not a function" the + * moment it's actually called. Discord's real internal REST client additionally has + * getAPIBaseURL as a real function AND a V8APIError/V6OrEarlierAPIError export the decoy lacks - + * this combo was confirmed live with an actual GET /quests/@me call before being trusted here. + */ +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 (see questCompleter.ts) - completion itself goes + * through direct REST calls (verified live end-to-end), not this module. Live-verified: patching + * this module's exports to OBSERVE Discord's own internal calls does not work (same-chunk closure + * calls bypass the exports object - see project_hook_mutation_doesnt_reach_internal_calls), but + * that limitation is specific to patching/observing internal calls. A plugin calling a function on + * this exports object directly, as an external caller, goes through the property lookup normally + * and is unaffected by that limitation. + */ +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 new file mode 100644 index 0000000..9517529 --- /dev/null +++ b/plugins/quest-orbs/src/lib/questCompleter.ts @@ -0,0 +1,173 @@ +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]"; + +// Live-verified against a real WATCH_VIDEO_ON_MOBILE completion: Discord's own client sends a +// video-progress heartbeat roughly every 7-8s while the video is actually playing. Matching that +// cadence keeps this real-time paced (timestamps reflect genuinely elapsed time, never faked/sped +// up), per the explicit "real-time paced, safer" design choice. +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"; +} + +/** + * Request bodies are live-verified exact shapes from a captured real enroll/claim call - Discord's + * wire format is snake_case (distinct from QuestStore's client-normalized camelCase quest + * objects). traffic_metadata_sealed is copied verbatim from the quest object, not generated. + */ +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 the response reports completion. Response bodies here are + * raw server wire format (snake_case) since this calls the REST client directly, not Discord's own + * action creators - live-verified: completed_at (top-level) flips non-null exactly when the target + * duration is reached. + */ +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"; + +/** + * A first real claim attempt during discovery came back HTTP 400 with a captcha_key/captcha_sitekey + * body (hCaptcha) - this function detects that shape and stops, it never attempts to solve or + * bypass it. Whether Discord's REST client rejects the promise or resolves with ok:false on a + * non-2xx wasn't pinned down live (both code paths are handled defensively here), so this checks + * both a thrown error and a resolved-but-not-ok response for the captcha shape. + */ +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 own UI (Quest Dock, Quests screen) doesn't show stale data + * after this plugin completes quests via direct REST calls instead of Discord's own action + * creators/dispatcher. Calling this function directly (not patching it) is a normal external call + * through the exports object, unaffected by the same-chunk-closure limitation that blocks + * PATCHING it - see modules.ts. Purely cosmetic: never blocks the real completion flow. */ +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 new file mode 100644 index 0000000..7f0db4f --- /dev/null +++ b/plugins/quest-orbs/src/ui/Settings.tsx @@ -0,0 +1,65 @@ +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"); + +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). If claiming a reward hits a + captcha challenge, that quest is left for you to claim manually in Discord's normal + Quests screen - this never tries to solve or bypass a captcha. + + + ); +}