Compare commits

...

5 Commits

Author SHA1 Message Date
neo
43848f0c58 fix: author and header files 2026-05-01 10:25:52 -04:00
neo
ede00ab875 feat: add webhook and new settings 2026-04-28 00:25:48 -04:00
neo
4e7b4fe662 feat: harden webhook error handlin and escape sender labels 2026-04-28 00:25:17 -04:00
neoarz
734a6cdde3 feat: webhook support 2026-04-22 22:02:36 -04:00
neoarz
2d003eaaee feat: add option to not claim from yourself 2026-04-22 13:03:22 -04:00
8 changed files with 455 additions and 29 deletions

View File

@@ -1,8 +1,7 @@
# NitroSniper
[![Version](https://img.shields.io/badge/Version-1.1.0-5865F2)](https://github.com/neoarz/NitroSniper)
[![Gitea](https://img.shields.io/badge/Gitea-NitroSniper-6e738d?logo=gitea&logoColor=white)](https://6969.pro/neoarz/NitroSniper)
[![GitHub License](https://img.shields.io/github/license/neoarz/NitroSniper?color=%23e78284)](https://github.com/neoarz/NitroSniper/blob/main/LICENSE)
[![Gitea](https://img.shields.io/badge/Gitea-NitroSniper-5865F2?logo=gitea&logoColor=white)](https://6969.pro/neoarz/NitroSniper)
[![GitHub License](https://img.shields.io/github/license/neoarz/NitroSniper?color=%236e738d)](https://github.com/neoarz/NitroSniper/blob/main/LICENSE)
[![Sponsor Me](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/neoarz)
[![Status](https://img.shields.io/badge/Status-Working-73b369)](https://github.com/neoarz/NitroSniper)
@@ -30,4 +29,3 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
Make a github issue if you find any bugs or have any questions or preferably dm me on discord `neoarz`.
<br>
<sub>If you find this project useful, consider leaving a star, would mean alot!! ❤️</sub>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 MiB

120
index.tsx
View File

@@ -6,66 +6,136 @@ dm @neoarz if u need help or have any questions
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))
});
}
export default definePlugin({
name: "NitroSniper",
description: "Automatically redeems Nitro gift links sent in chat",
authors: [Devs.neoarz],
authors: [{
name: "neoarz",
id: 218675193592283137n
}],
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();
}
}

36
native.ts Normal file
View File

@@ -0,0 +1,36 @@
/*
Made with ❤️ by neoarz
I am not responsible for any damage caused by this plugin; use at your own risk
Vencord does not endorse/support this plugin (Works with Equicord as well)
dm @neoarz if u need help or have any questions
https://github.com/neoarz/NitroSniper
*/
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: error instanceof Error ? error.message : String(error)
};
}
}

61
settings.tsx Normal file
View File

@@ -0,0 +1,61 @@
/*
Made with ❤️ by neoarz
I am not responsible for any damage caused by this plugin; use at your own risk
Vencord does not endorse/support this plugin (Works with Equicord as well)
dm @neoarz if u need help or have any questions
https://github.com/neoarz/NitroSniper
*/
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 (
<Button
disabled={disabled}
onClick={() => {
void sendTestWebhook(webhookUrl)
.then(() => {
showToast("Test webhook sent successfully.", Toasts.Type.SUCCESS);
})
.catch((error: unknown) => {
showToast(getToastErrorMessage(error), 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
}
});

54
types.ts Normal file
View File

@@ -0,0 +1,54 @@
/*
Made with ❤️ by neoarz
I am not responsible for any damage caused by this plugin; use at your own risk
Vencord does not endorse/support this plugin (Works with Equicord as well)
dm @neoarz if u need help or have any questions
https://github.com/neoarz/NitroSniper
*/
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;
}

207
webhook.ts Normal file
View File

@@ -0,0 +1,207 @@
/*
Made with ❤️ by neoarz
I am not responsible for any damage caused by this plugin; use at your own risk
Vencord does not endorse/support this plugin (Works with Equicord as well)
dm @neoarz if u need help or have any questions
https://github.com/neoarz/NitroSniper
*/
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<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 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());
}