diff --git a/index.tsx b/index.tsx index 92d383f..3697090 100644 --- a/index.tsx +++ b/index.tsx @@ -6,53 +6,113 @@ dm @neoarz if u need help or have any questions https://github.com/neoarz/NitroSniper */ -import { definePluginSettings } from "@api/Settings"; import { Devs } from "@utils/constants"; import { Logger } from "@utils/Logger"; -import definePlugin, { OptionType } from "@utils/types"; +import definePlugin from "@utils/types"; +import { Message } from "@vencord/discord-types"; import { findByPropsLazy } from "@webpack"; import { UserStore } from "@webpack/common"; +import { settings } from "./settings"; +import type { ClaimRequest, FinderProfile, WebhookResult } from "./types"; +import { sendClaimWebhook } from "./webhook"; + +const GIFT_LINK_REGEX = /(?:discord\.gift\/|discord\.com\/gifts?\/)([a-zA-Z0-9]{16,24})/; + const logger = new Logger("NitroSniper"); const GiftActions = findByPropsLazy("redeemGiftCode"); -const settings = definePluginSettings({ - ignoreOwnGiftLinks: { - type: OptionType.BOOLEAN, - description: "Do not redeem Nitro gift links from messages sent by you.", - default: false, - restartNeeded: false - } -}); - let startTime = 0; let claiming = false; -const codeQueue: string[] = []; +const claimQueue: ClaimRequest[] = []; function resetState() { startTime = Date.now(); - codeQueue.length = 0; + claimQueue.length = 0; claiming = false; } +function isOwnMessage(message: Message) { + return message.author?.id === UserStore.getCurrentUser()?.id; +} + +function shouldSkipMessage(message: Message) { + return settings.store.ignoreOwnGiftLinks && isOwnMessage(message); +} + +function isMessageOlderThanStart(message: Message) { + return new Date(message.timestamp).getTime() < startTime; +} + +function extractGiftCode(content: string) { + return content.match(GIFT_LINK_REGEX)?.[1] ?? null; +} + +function createClaimRequest(message: Message): ClaimRequest | null { + const code = message.content ? extractGiftCode(message.content) : null; + if (!code) return null; + + return { + code, + authorId: message.author?.id, + authorName: message.author?.globalName ?? message.author?.username, + authorUsername: message.author?.username, + channelId: message.channel_id, + guildId: message.guild_id, + messageId: message.id + }; +} + +function getFinderProfile(): FinderProfile { + const currentUser = UserStore.getCurrentUser(); + + return { + name: currentUser?.globalName ?? currentUser?.username ?? "NitroSniper", + iconUrl: currentUser?.avatar + ? `https://cdn.discordapp.com/avatars/${currentUser.id}/${currentUser.avatar}.png?size=128` + : undefined + }; +} + +function notifyClaim(result: WebhookResult, request: ClaimRequest) { + void sendClaimWebhook( + settings.store.webhookUrl, + getFinderProfile(), + result, + request + ).catch(webhookError => { + logger.error("Failed to send NitroSniper webhook notification", webhookError); + }); +} + +function continueQueue() { + claiming = false; + processQueue(); +} + +function handleClaimSuccess(request: ClaimRequest) { + logger.log(`Successfully redeemed code: ${request.code}`); + notifyClaim("claimed", request); + continueQueue(); +} + +function handleClaimFailure(request: ClaimRequest, error: Error) { + logger.error(`Failed to redeem code: ${request.code}`, error); + notifyClaim("failed", request); + continueQueue(); +} + function processQueue() { - if (claiming || !codeQueue.length) return; + if (claiming) return; + + const request = claimQueue.shift(); + if (!request) return; claiming = true; - const code = codeQueue.shift()!; - GiftActions.redeemGiftCode({ - code, - onRedeemed: () => { - logger.log(`Successfully redeemed code: ${code}`); - claiming = false; - processQueue(); - }, - onError: (err: Error) => { - logger.error(`Failed to redeem code: ${code}`, err); - claiming = false; - processQueue(); - } + code: request.code, + onRedeemed: () => handleClaimSuccess(request), + onError: (error: Error) => handleClaimFailure(request, error) }); } @@ -69,16 +129,13 @@ export default definePlugin({ }, flux: { - MESSAGE_CREATE({ message }) { - if (!message.content) return; - if (settings.store.ignoreOwnGiftLinks && message.author?.id === UserStore.getCurrentUser()?.id) return; + MESSAGE_CREATE({ message }: { message: Message; }) { + if (!message.content || shouldSkipMessage(message) || isMessageOlderThanStart(message)) return; - const match = message.content.match(/(?:discord\.gift\/|discord\.com\/gifts?\/)([a-zA-Z0-9]{16,24})/); - if (!match) return; + const request = createClaimRequest(message); + if (!request) return; - if (new Date(message.timestamp).getTime() < startTime) return; - - codeQueue.push(match[1]); + claimQueue.push(request); processQueue(); } } diff --git a/native.ts b/native.ts new file mode 100644 index 0000000..0622410 --- /dev/null +++ b/native.ts @@ -0,0 +1,28 @@ +import { IpcMainInvokeEvent } from "electron"; + +import type { NativeWebhookResponse } from "./types"; + +export async function sendWebhook(_: IpcMainInvokeEvent, webhookUrl: string, payload: string): Promise { + try { + const url = new URL(webhookUrl); + url.searchParams.set("wait", "true"); + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: payload + }); + + return { + status: response.status, + data: await response.text() + }; + } catch (error) { + return { + status: -1, + data: String(error) + }; + } +} diff --git a/settings.tsx b/settings.tsx new file mode 100644 index 0000000..0ffb965 --- /dev/null +++ b/settings.tsx @@ -0,0 +1,47 @@ +import { definePluginSettings } from "@api/Settings"; +import { OptionType } from "@utils/types"; +import { Button, showToast, Toasts } from "@webpack/common"; + +import { sendTestWebhook } from "./webhook"; + +function TestWebhookButton() { + const { webhookUrl } = settings.use(["webhookUrl"]); + const disabled = webhookUrl.trim().length === 0; + + return ( + + ); +} + +export const settings = definePluginSettings({ + ignoreOwnGiftLinks: { + type: OptionType.BOOLEAN, + description: "Do not redeem Nitro gift links from messages sent by you.", + default: false, + restartNeeded: false + }, + webhookUrl: { + type: OptionType.STRING, + description: "Discord webhook URL to notify after each redeem attempt. Leave empty to disable.", + default: "", + restartNeeded: false + }, + testWebhook: { + type: OptionType.COMPONENT, + description: "Send a test message to the configured webhook.", + component: TestWebhookButton + } +}); diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..157a973 --- /dev/null +++ b/types.ts @@ -0,0 +1,50 @@ +export interface ClaimRequest { + code: string; + authorId?: string; + authorName?: string; + authorUsername?: string; + channelId?: string; + guildId?: string; + messageId?: string; +} + +export interface FinderProfile { + name: string; + iconUrl?: string; +} + +export type WebhookResult = "claimed" | "failed"; + +export interface WebhookField { + name: string; + value: string; + inline?: boolean; +} + +export interface WebhookEmbed { + title: string; + color: number; + description?: string; + fields?: WebhookField[]; + timestamp: string; + author?: { + name: string; + icon_url?: string; + }; + footer?: { + text: string; + }; +} + +export interface WebhookPayload { + username: string; + embeds: WebhookEmbed[]; + allowed_mentions: { + parse: string[]; + }; +} + +export interface NativeWebhookResponse { + status: number; + data: string; +} diff --git a/webhook.ts b/webhook.ts new file mode 100644 index 0000000..499f0c2 --- /dev/null +++ b/webhook.ts @@ -0,0 +1,190 @@ +import type { PluginNative } from "@utils/types"; + +import type { + ClaimRequest, + FinderProfile, + WebhookEmbed, + WebhookField, + WebhookPayload, + WebhookResult +} from "./types"; + +const SUCCESS_COLOR = 0x43b581; +const FAILURE_COLOR = 0xf04747; +const TEST_COLOR = 0x5865f2; +const WEBHOOK_NAME = "NitroSniper"; + +function parseWebhookUrl(webhookUrl: string) { + const trimmed = webhookUrl.trim(); + if (!trimmed) return null; + + try { + return new URL(trimmed); + } catch { + throw new Error("Webhook URL is invalid."); + } +} + +function getNative() { + const native = (globalThis as any).VencordNative?.pluginHelpers?.NitroSniper as PluginNative | undefined; + if (!native) { + throw new Error("Webhook sending requires desktop native support."); + } + + return native; +} + +function createPayload(embeds: WebhookEmbed[]): WebhookPayload { + return { + username: WEBHOOK_NAME, + embeds, + allowed_mentions: { + parse: [] + } + }; +} + +function buildUserProfileUrl(userId?: string) { + return userId ? `https://discord.com/users/${userId}` : null; +} + +function buildMessageUrl(request: ClaimRequest) { + if (!request.channelId || !request.messageId) return null; + + return `https://discordapp.com/channels/${request.guildId ?? "@me"}/${request.channelId}/${request.messageId}`; +} + +function buildAuthorField(request: ClaimRequest): WebhookField | null { + const label = request.authorName ?? request.authorUsername ?? request.authorId; + if (!label) return null; + + const profileUrl = buildUserProfileUrl(request.authorId); + return { + name: "Code sent by:", + value: profileUrl ? `[${label}](${profileUrl})` : label, + inline: false + }; +} + +function buildMessageField(request: ClaimRequest): WebhookField | null { + const messageUrl = buildMessageUrl(request); + if (!messageUrl) return null; + + return { + name: "Message:", + value: `[Posted here!](${messageUrl})`, + inline: false + }; +} + +function buildClaimFields(request: ClaimRequest) { + return [ + buildAuthorField(request), + buildMessageField(request) + ].filter((field): field is WebhookField => field != null); +} + +function getResultPresentation(result: WebhookResult) { + switch (result) { + case "claimed": + return { + title: "Yay! Claimed a Nitro!", + color: SUCCESS_COLOR + }; + case "failed": + default: + return { + title: "Failed to claim nitro", + color: FAILURE_COLOR + }; + } +} + +function buildClaimEmbed(finder: FinderProfile, result: WebhookResult, request: ClaimRequest): WebhookEmbed { + const presentation = getResultPresentation(result); + + return { + title: presentation.title, + color: presentation.color, + fields: buildClaimFields(request), + timestamp: new Date().toISOString(), + author: { + name: finder.name, + icon_url: finder.iconUrl + }, + footer: { + text: WEBHOOK_NAME + } + }; +} + +function buildTestWebhookPayload(): WebhookPayload { + return createPayload([ + { + title: "NitroSniper Webhook Test", + color: TEST_COLOR, + description: "Your NitroSniper webhook is configured correctly.", + timestamp: new Date().toISOString(), + footer: { + text: WEBHOOK_NAME + } + } + ]); +} + +function buildClaimWebhookPayload(finder: FinderProfile, result: WebhookResult, request: ClaimRequest): WebhookPayload { + return createPayload([ + buildClaimEmbed(finder, result, request) + ]); +} + +function parseWebhookError(data: string, status: number) { + if (!data) { + return `Webhook request failed with status ${status}.`; + } + + try { + const body = JSON.parse(data) as { message?: string; errors?: unknown; }; + const detail = [ + body.message, + body.errors ? JSON.stringify(body.errors) : null + ] + .filter(Boolean) + .join(" "); + + return detail + ? `Webhook request failed with status ${status}: ${detail}` + : `Webhook request failed with status ${status}.`; + } catch { + return `Webhook request failed with status ${status}: ${data}`; + } +} + +async function postWebhook(url: URL, payload: WebhookPayload) { + const { status, data } = await getNative().sendWebhook(url.toString(), JSON.stringify(payload)); + + if (status < 200 || status >= 300) { + throw new Error(parseWebhookError(data, status)); + } +} + +export async function sendClaimWebhook( + webhookUrl: string, + finder: FinderProfile, + result: WebhookResult, + request: ClaimRequest +) { + const url = parseWebhookUrl(webhookUrl); + if (!url) return; + + await postWebhook(url, buildClaimWebhookPayload(finder, result, request)); +} + +export async function sendTestWebhook(webhookUrl: string) { + const url = parseWebhookUrl(webhookUrl); + if (!url) { + throw new Error("Webhook URL is empty."); + } + + await postWebhook(url, buildTestWebhookPayload()); +}