Add Quest Orbs plugin: auto-complete mobile video quests
This commit is contained in:
parent
213c5eecec
commit
bbde5467e9
5 changed files with 358 additions and 0 deletions
11
plugins/quest-orbs/manifest.json
Normal file
11
plugins/quest-orbs/manifest.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
16
plugins/quest-orbs/src/index.ts
Normal file
16
plugins/quest-orbs/src/index.ts
Normal file
|
|
@ -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
|
||||
};
|
||||
93
plugins/quest-orbs/src/lib/modules.ts
Normal file
93
plugins/quest-orbs/src/lib/modules.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { rawFindByStoreName } from "@shared/lib/rawFind";
|
||||
import { lazy } from "@shared/lib/lazy";
|
||||
|
||||
declare const window: any;
|
||||
|
||||
export interface RestResponse<T = any> {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
body: T;
|
||||
headers?: any;
|
||||
text?: string;
|
||||
retryAfter?: number;
|
||||
}
|
||||
|
||||
export interface RestClient {
|
||||
get(opts: { url: string; query?: any }): Promise<RestResponse>;
|
||||
post(opts: { url: string; body?: any }): Promise<RestResponse>;
|
||||
}
|
||||
|
||||
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<RestClient>(() => {
|
||||
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<any>(() => 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<QuestActions>(() => {
|
||||
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;
|
||||
});
|
||||
173
plugins/quest-orbs/src/lib/questCompleter.ts
Normal file
173
plugins/quest-orbs/src/lib/questCompleter.ts
Normal file
|
|
@ -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<void> {
|
||||
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<string, unknown>) {
|
||||
return {
|
||||
location: 12,
|
||||
is_targeted: false,
|
||||
metadata_sealed: null,
|
||||
traffic_metadata_sealed: quest.trafficMetadataSealed ?? null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async function enroll(rest: ReturnType<typeof getRestClient>, quest: any): Promise<void> {
|
||||
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<typeof getRestClient>, quest: any): Promise<boolean> {
|
||||
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<typeof getRestClient>, quest: any): Promise<ClaimOutcome> {
|
||||
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<QuestRunResult> {
|
||||
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;
|
||||
}
|
||||
65
plugins/quest-orbs/src/ui/Settings.tsx
Normal file
65
plugins/quest-orbs/src/ui/Settings.tsx
Normal file
|
|
@ -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<string | null>(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 (
|
||||
<SettingsScaffold>
|
||||
<TableRowGroup title="Quest Orbs">
|
||||
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
|
||||
<PrimaryButton
|
||||
label={running ? "Running..." : "Complete quests now"}
|
||||
disabled={running}
|
||||
onPress={run}
|
||||
/>
|
||||
{lastResult != null && (
|
||||
<Text style={{ marginTop: 8, fontSize: 12.5, opacity: 0.85, color: textColor() }} selectable>
|
||||
{lastResult}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TableRowGroup>
|
||||
<NoteBox>
|
||||
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.
|
||||
</NoteBox>
|
||||
</SettingsScaffold>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue