Skip to content

Commit ac1a023

Browse files
authored
feat(route): discord quests (DIYgod#20911)
* feat(route): discord quests * fix(route): handle missing Discord authorization config
1 parent 4c7e027 commit ac1a023

File tree

3 files changed

+335
-0
lines changed

3 files changed

+335
-0
lines changed

lib/routes/discord/discord-api.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1+
import crypto from 'node:crypto';
2+
13
import type { RESTGetAPIChannelMessagesQuery, RESTGetAPIChannelMessagesResult, RESTGetAPIChannelResult, RESTGetAPIGuildChannelsResult, RESTGetAPIGuildResult } from 'discord-api-types/rest/v10';
24
import type { APIMessage } from 'discord-api-types/v10';
35

46
import { config } from '@/config';
57
import cache from '@/utils/cache';
68
import ofetch from '@/utils/ofetch';
79

10+
import type { QuestResponse } from './types';
11+
812
export const baseUrl = 'https://discord.com';
913
const apiUrl = `${baseUrl}/api/v10`;
1014

@@ -90,3 +94,37 @@ export const searchGuildMessages = (guildId: string, authorization: string, para
9094
config.cache.routeExpire,
9195
false
9296
) as Promise<SearchGuildMessagesResult>;
97+
98+
export const getQuests = (authorization: string) =>
99+
cache.tryGet(
100+
'discord:quests',
101+
() =>
102+
ofetch<QuestResponse>(`${apiUrl}/quests/@me`, {
103+
headers: {
104+
authorization,
105+
'X-Super-Properties': Buffer.from(
106+
JSON.stringify({
107+
os: 'Windows',
108+
browser: 'Chrome',
109+
device: '',
110+
system_locale: 'en-GB',
111+
has_client_mods: false,
112+
browser_user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
113+
browser_version: '120.0',
114+
os_version: '10',
115+
referrer: '',
116+
referring_domain: '',
117+
referrer_current: '',
118+
referring_domain_current: '',
119+
release_channel: 'stable',
120+
client_build_number: Math.floor(Math.random() * (500000 - 400000 + 1)) + 400000,
121+
client_event_source: null,
122+
client_launch_id: crypto.randomUUID(),
123+
client_app_state: 'unfocused',
124+
})
125+
).toString('base64'),
126+
},
127+
}),
128+
config.cache.routeExpire,
129+
false
130+
) as Promise<QuestResponse>;

lib/routes/discord/quest.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { config } from '@/config';
2+
import ConfigNotFoundError from '@/errors/types/config-not-found';
3+
import type { DataItem, Route } from '@/types';
4+
import { parseDate } from '@/utils/parse-date';
5+
6+
import { baseUrl, getQuests } from './discord-api';
7+
8+
export const route: Route = {
9+
path: '/quests',
10+
categories: ['social-media'],
11+
example: '/discord/quests',
12+
features: {
13+
requireConfig: [
14+
{
15+
name: 'DISCORD_AUTHORIZATION',
16+
description: 'Discord authorization header',
17+
},
18+
],
19+
requirePuppeteer: false,
20+
antiCrawler: false,
21+
supportBT: false,
22+
supportPodcast: false,
23+
supportScihub: false,
24+
},
25+
radar: [
26+
{
27+
source: ['discord.com/quest-home'],
28+
},
29+
],
30+
name: 'Quests',
31+
maintainers: ['TonyRL'],
32+
handler,
33+
};
34+
35+
async function handler() {
36+
const { authorization } = config.discord || {};
37+
if (!authorization) {
38+
throw new ConfigNotFoundError('Discord RSS is disabled due to the lack of authorization config');
39+
}
40+
41+
const questData = await getQuests(authorization);
42+
43+
const items = questData.quests.map((quest) => {
44+
const tasks = Object.values(quest.config.task_config.tasks).map((task) => task.event_name);
45+
return {
46+
title: `${quest.config.messages.quest_name} - Claim ${quest.config.rewards_config.rewards[0].messages.name}`,
47+
description: tasks.join(', '),
48+
author: quest.config.messages.game_publisher,
49+
pubDate: parseDate(quest.config.starts_at),
50+
category: tasks,
51+
link: quest.config.application.link.split('?')[0],
52+
guid: quest.id,
53+
};
54+
});
55+
56+
return {
57+
title: 'Available Quests - Discord',
58+
link: `${baseUrl}/quest-home`,
59+
item: items satisfies DataItem[],
60+
};
61+
}

lib/routes/discord/types.d.ts

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
interface Application {
2+
link: string;
3+
id: string;
4+
name: string;
5+
}
6+
7+
interface Assets {
8+
hero: string;
9+
hero_video: string | null;
10+
quest_bar_hero: string;
11+
quest_bar_hero_video: string | null;
12+
game_tile: string;
13+
logotype: string;
14+
game_tile_light: string;
15+
game_tile_dark: string;
16+
logotype_light: string;
17+
logotype_dark: string;
18+
}
19+
20+
interface Colors {
21+
primary: string;
22+
secondary: string;
23+
}
24+
25+
interface Messages {
26+
quest_name: string;
27+
game_title: string;
28+
game_publisher: string;
29+
}
30+
31+
interface TaskEvent {
32+
event_name: string;
33+
target: number;
34+
external_ids: string[];
35+
}
36+
37+
interface TaskEventNoExternalIds {
38+
event_name: string;
39+
target: number;
40+
external_ids: never[];
41+
}
42+
43+
interface TaskConfig {
44+
type: number;
45+
join_operator: string;
46+
tasks: {
47+
PLAY_ON_XBOX?: TaskEvent;
48+
PLAY_ON_DESKTOP?: TaskEventNoExternalIds;
49+
PLAY_ON_PLAYSTATION?: TaskEvent;
50+
WATCH_VIDEO_ON_MOBILE?: TaskEventNoExternalIds;
51+
WATCH_VIDEO?: TaskEventNoExternalIds;
52+
};
53+
}
54+
55+
interface ApplicationRef {
56+
id: string;
57+
}
58+
59+
interface TaskV2Base {
60+
type: string;
61+
target: number;
62+
}
63+
64+
interface PlayTask extends TaskV2Base {
65+
applications: ApplicationRef[];
66+
external_ids?: string[];
67+
}
68+
69+
interface VideoAsset {
70+
url: string;
71+
width: number;
72+
height: number;
73+
thumbnail: string;
74+
caption?: string;
75+
transcript?: string;
76+
}
77+
78+
interface VideoTaskAssets {
79+
video: VideoAsset;
80+
video_low_res: VideoAsset;
81+
video_hls: VideoAsset;
82+
}
83+
84+
interface VideoTaskMessages {
85+
video_title: string;
86+
video_end_cta_title?: string;
87+
video_end_cta_subtitle?: string;
88+
video_end_cta_button_label?: string;
89+
}
90+
91+
interface WatchVideoTask extends TaskV2Base {
92+
assets: VideoTaskAssets;
93+
messages: VideoTaskMessages;
94+
}
95+
96+
interface TaskConfigV2 {
97+
tasks: {
98+
PLAY_ON_XBOX?: PlayTask;
99+
PLAY_ON_DESKTOP?: PlayTask;
100+
PLAY_ON_PLAYSTATION?: PlayTask;
101+
WATCH_VIDEO_ON_MOBILE?: WatchVideoTask;
102+
WATCH_VIDEO?: WatchVideoTask;
103+
};
104+
join_operator: string;
105+
}
106+
107+
interface RewardMessages {
108+
name: string;
109+
name_with_article: string;
110+
redemption_instructions_by_platform: {
111+
'0': string;
112+
};
113+
}
114+
115+
interface Reward {
116+
type: number;
117+
sku_id: string;
118+
messages: RewardMessages;
119+
orb_quantity?: number;
120+
asset?: string;
121+
asset_video?: null;
122+
approximate_count?: null;
123+
redemption_link?: null;
124+
expires_at?: string;
125+
expires_at_premium?: null;
126+
expiration_mode?: number;
127+
}
128+
129+
interface RewardsConfig {
130+
assignment_method: number;
131+
rewards: Reward[];
132+
rewards_expire_at: string;
133+
platforms: number[];
134+
}
135+
136+
interface CTAConfig {
137+
link: string;
138+
button_label: string;
139+
android?: {
140+
android_app_id: string;
141+
};
142+
ios?: {
143+
ios_app_id: string;
144+
};
145+
subtitle?: string;
146+
}
147+
148+
interface VideoMetadataMessages {
149+
video_title: string;
150+
video_end_cta_button_label: string;
151+
video_end_cta_title?: string;
152+
video_end_cta_subtitle?: string;
153+
}
154+
155+
interface VideoMetadataAssets {
156+
video_player_video_hls: string;
157+
video_player_video: string;
158+
video_player_thumbnail: string;
159+
video_player_video_low_res: string;
160+
video_player_caption: string | null;
161+
video_player_transcript: string | null;
162+
quest_bar_preview_video: null;
163+
quest_bar_preview_thumbnail: null;
164+
quest_home_video: null;
165+
}
166+
167+
interface VideoMetadata {
168+
messages: VideoMetadataMessages;
169+
assets: VideoMetadataAssets;
170+
}
171+
172+
interface QuestConfig {
173+
id: string;
174+
config_version: number;
175+
starts_at: string;
176+
expires_at: string;
177+
features: number[];
178+
application: Application;
179+
assets: Assets;
180+
colors: Colors;
181+
messages: Messages;
182+
task_config: TaskConfig;
183+
task_config_v2: TaskConfigV2;
184+
rewards_config: RewardsConfig;
185+
share_policy: string;
186+
cta_config: CTAConfig;
187+
video_metadata?: VideoMetadata;
188+
}
189+
190+
interface ProgressHeartbeat {
191+
last_beat_at: string;
192+
expires_at: null;
193+
}
194+
195+
interface ProgressTask {
196+
value: number;
197+
event_name: string;
198+
updated_at: string;
199+
completed_at: string;
200+
heartbeat: ProgressHeartbeat | null;
201+
}
202+
203+
interface UserProgress {
204+
PLAY_ON_DESKTOP?: ProgressTask;
205+
PLAY_ON_XBOX?: ProgressTask;
206+
PLAY_ON_PLAYSTATION?: ProgressTask;
207+
WATCH_VIDEO_ON_MOBILE?: ProgressTask;
208+
WATCH_VIDEO?: ProgressTask;
209+
}
210+
211+
interface UserStatus {
212+
user_id: string;
213+
quest_id: string;
214+
enrolled_at: string;
215+
completed_at: string;
216+
claimed_at: string;
217+
claimed_tier: null;
218+
last_stream_heartbeat_at: null;
219+
stream_progress_seconds: number;
220+
dismissed_quest_content: number;
221+
progress: UserProgress;
222+
}
223+
224+
interface Quest {
225+
id: string;
226+
config: QuestConfig;
227+
user_status: UserStatus | null;
228+
targeted_content: never[];
229+
preview: boolean;
230+
traffic_metadata_raw: string;
231+
traffic_metadata_sealed: string;
232+
}
233+
234+
export interface QuestResponse {
235+
quests: Quest[];
236+
}

0 commit comments

Comments
 (0)