You've already forked NitroSniper
mirror of
https://github.com/neoarz/NitroSniper.git
synced 2026-05-11 13:15:37 +02:00
Compare commits
4 Commits
8f885d8db1
...
ede00ab875
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ede00ab875 | ||
| 4e7b4fe662 | |||
|
|
734a6cdde3 | ||
|
|
2d003eaaee |
BIN
assets/icon.png
BIN
assets/icon.png
Binary file not shown.
|
Before Width: | Height: | Size: 4.8 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.8 MiB |
114
index.tsx
114
index.tsx
@@ -9,39 +9,108 @@ https://github.com/neoarz/NitroSniper
|
|||||||
import { Devs } from "@utils/constants";
|
import { Devs } from "@utils/constants";
|
||||||
import { Logger } from "@utils/Logger";
|
import { Logger } from "@utils/Logger";
|
||||||
import definePlugin 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 { 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 logger = new Logger("NitroSniper");
|
||||||
const GiftActions = findByPropsLazy("redeemGiftCode");
|
const GiftActions = findByPropsLazy("redeemGiftCode");
|
||||||
|
|
||||||
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 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() {
|
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: unknown) => handleClaimFailure(request, toError(error))
|
||||||
claiming = false;
|
|
||||||
processQueue();
|
|
||||||
},
|
|
||||||
onError: (err: Error) => {
|
|
||||||
logger.error(`Failed to redeem code: ${code}`, err);
|
|
||||||
claiming = false;
|
|
||||||
processQueue();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,21 +120,20 @@ export default definePlugin({
|
|||||||
authors: [Devs.neoarz],
|
authors: [Devs.neoarz],
|
||||||
tags: ["Chat", "Utility"],
|
tags: ["Chat", "Utility"],
|
||||||
searchTerms: ["nitro", "gift", "redeem", "snipe"],
|
searchTerms: ["nitro", "gift", "redeem", "snipe"],
|
||||||
|
settings,
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
resetState();
|
resetState();
|
||||||
},
|
},
|
||||||
|
|
||||||
flux: {
|
flux: {
|
||||||
MESSAGE_CREATE({ message }) {
|
MESSAGE_CREATE({ message }: { message: Message; }) {
|
||||||
if (!message.content) return;
|
if (!message.content || shouldSkipMessage(message) || isMessageOlderThanStart(message)) 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
28
native.ts
Normal 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: error instanceof Error ? error.message : String(error)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
53
settings.tsx
Normal file
53
settings.tsx
Normal file
@@ -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 (
|
||||||
|
<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
|
||||||
|
}
|
||||||
|
});
|
||||||
46
types.ts
Normal file
46
types.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
199
webhook.ts
Normal file
199
webhook.ts
Normal file
@@ -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<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());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user