Plugin

FiveCordRPC

how to expose yourself

index.ts
Download

Source

src/plugins/vencordRPC/index.ts
1/*
2 * FiveCord โ€” a Discord client mod
3 * Copyright (c) 2025 FiveCord
4 * SPDX-License-Identifier: GPL-3.0-or-later
5 */
6
7import { definePluginSettings } from "@api/Settings";
8import { Devs } from "@utils/constants";
9import { isTruthy } from "@utils/guards";
10import definePlugin, { OptionType } from "@utils/types";
11import { findByPropsLazy, findStoreLazy } from "@webpack";
12import { ApplicationAssetUtils, ChannelStore, FluxDispatcher, GuildStore, PresenceStore, RelationshipStore, SelectedChannelStore, SelectedGuildStore, UserStore } from "@webpack/common";
13import { FluxStore } from "@webpack/types";
14import { Channel } from "discord-types/general";
15
16const presenceStore = findByPropsLazy("getLocalPresence");
17const GuildMemberCountStore = findStoreLazy("GuildMemberCountStore") as FluxStore & { getMemberCount(guildId: string): number | null; };
18const ChannelMemberStore = findStoreLazy("ChannelMemberStore") as FluxStore & {
19 getProps(guildId: string, channelId: string): { groups: { count: number; id: string; }[]; };
20};
21const VoiceStates = findByPropsLazy("getVoiceStatesForChannel");
22const chino = "https:class="ts-cmt">//i.imgur.com/Dsa2rQy.png";
23const shiggy = "https:class="ts-cmt">//i.imgur.com/MgUzhs0.gif";
24const wysi = "https:class="ts-cmt">//i.imgur.com/uKtXde9.gif";
25
26async function getApplicationAsset(key: string): Promise<string> {
27 if (/https?:\/\/(cdn|media)\.discordapp\.(com|net)\/attachments\class="ts-cmt">//.test(key)) return "mp:" + key.replace(/https?:\/\/(cdn|media)\.discordapp\.(com|net)\//, "");
28 return (await ApplicationAssetUtils.fetchAssetIds(settings.store.appID!, [key]))[0];
29}
30
31interface ActivityAssets {
32 large_image?: string;
33 large_text?: string;
34 small_image?: string;
35 small_text?: string;
36}
37
38interface Activity {
39 state?: string;
40 details?: string;
41 timestamps?: {
42 start?: number;
43 end?: number;
44 };
45 assets?: ActivityAssets;
46 buttons?: Array<string>;
47 name: string;
48 application_id: string;
49 metadata?: {
50 button_urls?: Array<string>;
51 };
52 type: ActivityType;
53 url?: string;
54 flags: number;
55}
56
57const enum ActivityType {
58 PLAYING = 0,
59 STREAMING = 1,
60 LISTENING = 2,
61 WATCHING = 3,
62 COMPETING = 5
63}
64
65const enum TimestampMode {
66 NONE,
67 NOW,
68 TIME,
69 CUSTOM,
70}
71
72const settings = definePluginSettings({
73 appID: {
74 type: OptionType.STRING,
75 description: "Application ID (required)",
76 onChange: onChange,
77 isValid: (value: string) => {
78 if (!value) return "Application ID is required.";
79 if (value && !/^\d+$/.test(value)) return "Application ID must be a number.";
80 return true;
81 }
82 },
83 userAvatarAsSmallImage: {
84 type: OptionType.BOOLEAN,
85 description: "Use your avatar as small image",
86 onChange: onChange,
87 default: false
88 },
89 exposeDmsUsername: {
90 type: OptionType.BOOLEAN,
91 description: "Expose current DMs username",
92 onChange: onChange,
93 default: false
94 },
95 type: {
96 type: OptionType.SELECT,
97 description: "Activity type",
98 onChange: onChange,
99 options: [
100 {
101 label: "Playing",
102 value: ActivityType.PLAYING,
103 default: true
104 },
105 {
106 label: "Streaming",
107 value: ActivityType.STREAMING
108 },
109 {
110 label: "Listening",
111 value: ActivityType.LISTENING
112 },
113 {
114 label: "Watching",
115 value: ActivityType.WATCHING
116 },
117 {
118 label: "Competing",
119 value: ActivityType.COMPETING
120 }
121 ]
122 },
123 streamLink: {
124 type: OptionType.STRING,
125 description: "Twitch.tv or Youtube.com link (only for Streaming activity type)",
126 onChange: onChange,
127 disabled: isStreamLinkDisabled,
128 isValid: isStreamLinkValid
129 },
130 timestampMode: {
131 type: OptionType.SELECT,
132 description: "Timestamp mode",
133 onChange: onChange,
134 options: [
135 {
136 label: "None",
137 value: TimestampMode.NONE,
138 default: true
139 },
140 {
141 label: "Since discord open",
142 value: TimestampMode.NOW
143 },
144 {
145 label: "Same as your current time",
146 value: TimestampMode.TIME
147 },
148 {
149 label: "Custom",
150 value: TimestampMode.CUSTOM
151 }
152 ]
153 },
154 startTime: {
155 type: OptionType.NUMBER,
156 description: "Start timestamp (only for custom timestamp mode)",
157 onChange: onChange,
158 disabled: isTimestampDisabled,
159 isValid: (value: number) => {
160 if (value && value < 0) return "Start timestamp must be greater than 0.";
161 return true;
162 }
163 },
164 endTime: {
165 type: OptionType.NUMBER,
166 description: "End timestamp (only for custom timestamp mode)",
167 onChange: onChange,
168 disabled: isTimestampDisabled,
169 isValid: (value: number) => {
170 if (value && value < 0) return "End timestamp must be greater than 0.";
171 return true;
172 }
173 }
174});
175
176function onChange() {
177 setRpc(true);
178 setRpc();
179}
180
181function isStreamLinkDisabled() {
182 return settings.store.type !== ActivityType.STREAMING;
183}
184
185function isStreamLinkValid(value: string) {
186 if (!isStreamLinkDisabled() && !/https?:\/\/(www\.)?(twitch\.tv|youtube\.com)\/\w+/.test(value)) return "Streaming link must be a valid URL.";
187 return true;
188}
189
190function isTimestampDisabled() {
191 return settings.store.timestampMode !== TimestampMode.CUSTOM;
192}
193
194// //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
195
196function onlineFriendCount(): number {
197 let onlineFriends = 0;
198 const relationships = RelationshipStore.getRelationships();
199 for (const id in relationships) {
200 if (relationships[id] === 1 && PresenceStore.getStatus(id) !== "offline") onlineFriends++;
201 }
202 return onlineFriends;
203}
204
205function totalFriendCount(): number {
206 return Object.values(RelationshipStore.getRelationships()).filter(r => r === 1).length;
207}
208
209function memberCount(): string {
210 const channelId = SelectedChannelStore.getChannelId();
211 const guildId = SelectedGuildStore.getGuildId();
212 const { groups } = ChannelMemberStore.getProps(guildId, channelId);
213 const total = GuildMemberCountStore.getMemberCount(guildId);
214
215 if (total == null)
216 return "";
217
218 const online =
219 (groups.length === 1 && groups[0].id === "unknown")
220 ? 0
221 : groups.reduce((count, curr) => count + (curr.id === "offline" ? 0 : curr.count), 0);
222
223 return online === 0 ? `${total} members` : `${online} online / ${total} total`;
224}
225
226function getChannelIconURL(channel: Channel): string {
227 if (channel.icon) return `https:class="ts-cmt">//cdn.discordapp.com/channel-icons/${channel.id}/${channel.icon}.webp?size=128`;
228 return chino;
229}
230
231async function createActivity(): Promise<Activity | undefined> {
232
233 const {
234 appID,
235 userAvatarAsSmallImage,
236 exposeDmsUsername,
237 streamLink,
238 timestampMode,
239 startTime,
240 endTime,
241 } = settings.store;
242
243 let { type } = settings.store;
244
245 let appName = "FiveCord";
246 let details = "";
247 let state = "";
248 let imageBig = "";
249 const imageBigTooltip = undefined;
250 let imageSmall = "";
251 const imageSmallTooltip = undefined;
252 let buttonOneText: string | undefined;
253 let buttonOneURL: string | undefined;
254 let buttonTwoText: string | undefined;
255 let buttonTwoURL: string | undefined;
256
257
258 const channelId = SelectedChannelStore.getChannelId();
259 const guildId = SelectedGuildStore.getGuildId();
260 const voiceId = SelectedChannelStore.getVoiceChannelId();
261 const currentUser = UserStore.getCurrentUser();
262 if (userAvatarAsSmallImage) imageSmall = currentUser.getAvatarURL(undefined, undefined, true) || chino;
263
264 if (!channelId) {
265 appName = "Friends List";
266 details = `${onlineFriendCount()} online / ${totalFriendCount()} total`;
267 state = `${GuildStore.getGuildCount()} servers`;
268 imageBig = chino;
269 } else {
270 if (channelId === "@home" || channelId === "customize-community" || channelId === "channel-browser" || channelId === "onboarding") {
271 appName = channelId === "@home" ? "Server Guide" : channelId === "customize-community" ? "Channels & Roles" : channelId === "channel-browser" ? "Browse Channels" : "Onboarding";
272 const guild = GuildStore.getGuild(guildId);
273 if (guild) {
274 details = guild.name;
275 state = memberCount();
276 imageBig = guild.getIconURL(128, true) || chino;
277 if (guild.vanityURLCode) {
278 buttonOneText = `Join ${guild.name.slice(0, 26)}`;
279 buttonOneURL = `https:class="ts-cmt">//discord.gg/${guild.vanityURLCode}`;
280 }
281 }
282 } else {
283
284 const channel = ChannelStore.getChannel(channelId);
285
286 if (channel.isDM()) {
287 const recipient = UserStore.getUser(channel.recipients[0]);
288 appName = exposeDmsUsername ? `${recipient.username}&#039;s DM` : "Direct Messages";
289 details = `${onlineFriendCount()} online / ${totalFriendCount()} total`;
290 state = `${GuildStore.getGuildCount()} servers`;
291 imageBig = recipient.getAvatarURL(undefined, undefined, true) || chino;
292 }
293
294 if (channel.isGroupDM()) {
295 appName = channel.name || "Group DM";
296 details = `${channel.recipients.length + 1} members`;
297 imageBig = getChannelIconURL(channel);
298 }
299
300 const guild = GuildStore.getGuild(guildId);
301 if (guild) {
302 appName = `#${channel.name}`;
303 details = guild.name;
304 state = memberCount();
305 imageBig = guild.getIconURL(128, true) || chino;
306 if (guild.vanityURLCode) {
307 buttonOneText = `Join ${guild.name.slice(0, 31 - 5)}`;
308 buttonOneURL = `https:class="ts-cmt">//discord.gg/${guild.vanityURLCode}`;
309 }
310 }
311 }
312 }
313
314 if (voiceId) {
315 const voiceChannel = ChannelStore.getChannel(voiceId);
316 const voiceGuild = GuildStore.getGuild(voiceChannel.guild_id);
317 const voiceMemberCount = Object.keys(VoiceStates.getVoiceStatesForChannel(voiceChannel.id)).length;
318 buttonTwoText = `๐Ÿ”Š ${voiceGuild.name.slice(0, 31 - 6 - voiceMemberCount.toString().length)} [${voiceMemberCount}]`;
319 buttonTwoURL = `https:class="ts-cmt">//discordapp.com/channels/${voiceGuild.id}/${voiceChannel.id}`;
320
321 if (!buttonOneText) {
322 buttonOneText = buttonTwoText;
323 buttonOneURL = buttonTwoURL;
324 buttonTwoText = undefined;
325 buttonTwoURL = undefined;
326 }
327 }
328
329 if ((new Date().getHours() === 7 || new Date().getHours() === 19) && new Date().getMinutes() === 27) {
330 type = ActivityType.PLAYING;
331 appName = "WHEN YOU SEE IT";
332 imageBig = wysi;
333 details = "7:27 ๐Ÿ‘ˆ";
334 state = "";
335 }
336
337 // //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
338
339 const activity: Activity = {
340 application_id: appID || "0",
341 name: appName,
342 state,
343 details,
344 type,
345 flags: 1 << 0,
346 };
347
348 if (type === ActivityType.STREAMING) activity.url = streamLink;
349
350 switch (timestampMode) {
351 case TimestampMode.NOW:
352 activity.timestamps = {
353 start: Date.now()
354 };
355 break;
356 case TimestampMode.TIME:
357 activity.timestamps = {
358 start: Date.now() - (new Date().getHours() * 3600 + new Date().getMinutes() * 60 + new Date().getSeconds()) * 1000
359 };
360 break;
361 case TimestampMode.CUSTOM:
362 if (startTime || endTime) {
363 activity.timestamps = {};
364 if (startTime) activity.timestamps.start = startTime;
365 if (endTime) activity.timestamps.end = endTime;
366 }
367 break;
368 case TimestampMode.NONE:
369 default:
370 break;
371 }
372
373 if (buttonOneText) {
374 activity.buttons = [
375 buttonOneText,
376 buttonTwoText
377 ].filter(isTruthy);
378
379 activity.metadata = {
380 button_urls: [
381 buttonOneURL,
382 buttonTwoURL
383 ].filter(isTruthy)
384 };
385 }
386
387 if (imageBig) {
388 activity.assets = {
389 large_image: await getApplicationAsset(imageBig),
390 large_text: imageBigTooltip || undefined
391 };
392 }
393
394 if (imageSmall) {
395 activity.assets = {
396 ...activity.assets,
397 small_image: await getApplicationAsset(imageSmall),
398 small_text: imageSmallTooltip || undefined
399 };
400 }
401
402
403 for (const k in activity) {
404 if (k === "type") continue;
405 const v = activity[k];
406 if (!v || v.length === 0)
407 delete activity[k];
408 }
409
410 return activity;
411}
412
413let timeout: NodeJS.Timeout | null = null;
414
415async function setRpc(disable?: boolean) {
416 const activities: any = presenceStore.getActivities();
417 const activity: Activity | undefined = !activities.length || (activities.length === 1 && activities[0].application_id === settings.store.appID) ? await createActivity() : undefined;
418
419 FluxDispatcher.dispatch({
420 type: "LOCAL_ACTIVITY_UPDATE",
421 activity: !disable ? activity : null,
422 socketId: "CustomRPC",
423 });
424
425 if (!disable) {
426 timeout = setTimeout(() => setRpc(), 4000);
427 } else if (timeout) {
428 clearTimeout(timeout);
429 timeout = null;
430 }
431}
432
433export default definePlugin({
434 name: "FiveCordRPC",
435 description: "how to expose yourself",
436 authors: [Devs.FiveCord],
437 start: setRpc,
438 stop: () => setRpc(true),
439 settings
440});
441