feat: webhook support

This commit is contained in:
neoarz
2026-04-22 22:02:36 -04:00
parent 2d003eaaee
commit 734a6cdde3
5 changed files with 407 additions and 35 deletions

127
index.tsx
View File

@@ -6,53 +6,113 @@ dm @neoarz if u need help or have any questions
https://github.com/neoarz/NitroSniper https://github.com/neoarz/NitroSniper
*/ */
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import { Logger } from "@utils/Logger"; 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 { findByPropsLazy } from "@webpack";
import { UserStore } from "@webpack/common"; 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 logger = new Logger("NitroSniper");
const GiftActions = findByPropsLazy("redeemGiftCode"); 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 startTime = 0;
let claiming = false; let claiming = false;
const codeQueue: string[] = []; const claimQueue: ClaimRequest[] = [];
function resetState() { function resetState() {
startTime = Date.now(); startTime = Date.now();
codeQueue.length = 0; claimQueue.length = 0;
claiming = false; 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() { function processQueue() {
if (claiming || !codeQueue.length) return; if (claiming) return;
const request = claimQueue.shift();
if (!request) return;
claiming = true; claiming = true;
const code = codeQueue.shift()!;
GiftActions.redeemGiftCode({ GiftActions.redeemGiftCode({
code, code: request.code,
onRedeemed: () => { onRedeemed: () => handleClaimSuccess(request),
logger.log(`Successfully redeemed code: ${code}`); onError: (error: Error) => handleClaimFailure(request, error)
claiming = false;
processQueue();
},
onError: (err: Error) => {
logger.error(`Failed to redeem code: ${code}`, err);
claiming = false;
processQueue();
}
}); });
} }
@@ -69,16 +129,13 @@ export default definePlugin({
}, },
flux: { flux: {
MESSAGE_CREATE({ message }) { MESSAGE_CREATE({ message }: { message: Message; }) {
if (!message.content) return; if (!message.content || shouldSkipMessage(message) || isMessageOlderThanStart(message)) return;
if (settings.store.ignoreOwnGiftLinks && message.author?.id === UserStore.getCurrentUser()?.id) return;
const match = message.content.match(/(?:discord\.gift\/|discord\.com\/gifts?\/)([a-zA-Z0-9]{16,24})/); const request = createClaimRequest(message);
if (!match) return; if (!request) return;
if (new Date(message.timestamp).getTime() < startTime) return; claimQueue.push(request);
codeQueue.push(match[1]);
processQueue(); processQueue();
} }
} }

28
native.ts Normal file
View File

@@ -0,0 +1,28 @@
import { IpcMainInvokeEvent } from "electron";
import type { NativeWebhookResponse } from "./types";
export async function sendWebhook(_: IpcMainInvokeEvent, webhookUrl: string, payload: string): Promise<NativeWebhookResponse> {
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)
};
}
}

47
settings.tsx Normal file
View File

@@ -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 (
<Button
disabled={disabled}
onClick={() => {
void sendTestWebhook(webhookUrl)
.then(() => {
showToast("Test webhook sent successfully.", Toasts.Type.SUCCESS);
})
.catch((error: Error) => {
showToast(error.message, Toasts.Type.FAILURE);
});
}}
>
Send Test Webhook
</Button>
);
}
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
}
});

50
types.ts Normal file
View File

@@ -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;
}

190
webhook.ts Normal file
View File

@@ -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<typeof import("./native")> | 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());
}