diff --git a/assets/icon.png b/assets/icon.png deleted file mode 100644 index 6211aef..0000000 Binary files a/assets/icon.png and /dev/null differ diff --git a/assets/sunset.png b/assets/sunset.png deleted file mode 100644 index 3a47468..0000000 Binary files a/assets/sunset.png and /dev/null differ diff --git a/index.tsx b/index.tsx index ec9269b..6d1617a 100644 --- a/index.tsx +++ b/index.tsx @@ -9,39 +9,108 @@ https://github.com/neoarz/NitroSniper import { Devs } from "@utils/constants"; import { Logger } from "@utils/Logger"; 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, 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"); 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 toError(error: unknown) { + return error instanceof Error ? error : new Error(String(error)); +} + +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; + + const authorId = message.author?.id; + const authorAvatar = message.author?.avatar; + + return { + code, + authorId, + authorName: message.author?.globalName ?? message.author?.username, + authorUsername: message.author?.username, + authorAvatarUrl: authorId && authorAvatar + ? `https://cdn.discordapp.com/avatars/${authorId}/${authorAvatar}.png?size=128` + : undefined, + channelId: message.channel_id, + guildId: message.guild_id, + messageId: message.id + }; +} + +function notifyClaim(result: WebhookResult, request: ClaimRequest) { + void sendClaimWebhook( + settings.store.webhookUrl, + 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: unknown) => handleClaimFailure(request, toError(error)) }); } @@ -51,21 +120,20 @@ export default definePlugin({ authors: [Devs.neoarz], tags: ["Chat", "Utility"], searchTerms: ["nitro", "gift", "redeem", "snipe"], + settings, start() { resetState(); }, flux: { - MESSAGE_CREATE({ message }) { - if (!message.content) 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..1739a3d --- /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: error instanceof Error ? error.message : String(error) + }; + } +} diff --git a/settings.tsx b/settings.tsx new file mode 100644 index 0000000..ec6580b --- /dev/null +++ b/settings.tsx @@ -0,0 +1,53 @@ +import { definePluginSettings } from "@api/Settings"; +import { OptionType } from "@utils/types"; +import { Button, showToast, Toasts } from "@webpack/common"; + +import { sendTestWebhook } from "./webhook"; + +function getToastErrorMessage(error: unknown) { + return error instanceof Error + ? error.message + : "Failed to send test 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..6b6f304 --- /dev/null +++ b/types.ts @@ -0,0 +1,46 @@ +export interface ClaimRequest { + code: string; + authorId?: string; + authorName?: string; + authorUsername?: string; + authorAvatarUrl?: string; + channelId?: string; + guildId?: string; + messageId?: 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..2ca6002 --- /dev/null +++ b/webhook.ts @@ -0,0 +1,199 @@ +import type { PluginNative } from "@utils/types"; + +import type { + ClaimRequest, + 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 escapeMarkdown(value: string) { + return value.replace(/([\\`*_{}\[\]()#+\-.!|>~])/g, "\\$1"); +} + +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 ? `[${escapeMarkdown(label)}](${profileUrl})` : escapeMarkdown(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 buildEmbedAuthor(request: ClaimRequest) { + const name = request.authorName ?? request.authorUsername; + if (!name) return undefined; + + return { + name, + icon_url: request.authorAvatarUrl + }; +} + +function buildClaimEmbed(result: WebhookResult, request: ClaimRequest): WebhookEmbed { + const presentation = getResultPresentation(result); + + return { + title: presentation.title, + color: presentation.color, + fields: buildClaimFields(request), + timestamp: new Date().toISOString(), + author: buildEmbedAuthor(request), + 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(result: WebhookResult, request: ClaimRequest): WebhookPayload { + return createPayload([ + buildClaimEmbed(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, + result: WebhookResult, + request: ClaimRequest +) { + const url = parseWebhookUrl(webhookUrl); + if (!url) return; + + await postWebhook(url, buildClaimWebhookPayload(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()); +}