generated from nhcarrigan/template
Compare commits
No commits in common. "c05a33e8d2e98180b579b80678fc47ea12c1c9e9" and "7db3201aefc1dc8675de4e8ca590b0b8eb7c41b4" have entirely different histories.
c05a33e8d2
...
7db3201aef
@ -1,12 +0,0 @@
|
||||
{
|
||||
"extends": "@nhcarrigan",
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["src/modules/buttons/*.ts"],
|
||||
"rules": {
|
||||
"jsdoc/require-param": "off",
|
||||
"jsdoc/require-returns": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -1,5 +0,0 @@
|
||||
/node_modules/
|
||||
/prod/
|
||||
.env
|
||||
/logs/*.txt
|
||||
latestAirtableRecord.txt
|
@ -1 +0,0 @@
|
||||
"@nhcarrigan/prettier-config"
|
18
README.md
18
README.md
@ -1,10 +1,20 @@
|
||||
# Art for Palestine Bot
|
||||
# New Repository Template
|
||||
|
||||
This is a bot for the [Art for Palestine](https://art4palestine.org) charity event. It serves as a bridge between AirTable, Trello, and Discord.
|
||||
This template contains all of our basic files for a new GitHub repository. There is also a handy workflow that will create an issue on a new repository made from this template, with a checklist for the steps we usually take in setting up a new repository.
|
||||
|
||||
If you're starting a Node.JS project with TypeScript, we have a [specific template](https://github.com/naomi-lgbt/nodejs-typescript-template) for that purpose.
|
||||
|
||||
## Readme
|
||||
|
||||
Delete all of the above text (including this line), and uncomment the below text to use our standard readme template.
|
||||
|
||||
<!-- # Project Name
|
||||
|
||||
Project Description
|
||||
|
||||
## Live Version
|
||||
|
||||
This page is currently deployed. [View the live website.](https://discord.gg/kHNyb6Vyf8)
|
||||
This page is currently deployed. [View the live website.]
|
||||
|
||||
## Feedback and Bugs
|
||||
|
||||
@ -26,4 +36,4 @@ Copyright held by Naomi Carrigan.
|
||||
|
||||
## Contact
|
||||
|
||||
We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`.
|
||||
We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`. -->
|
||||
|
@ -1 +0,0 @@
|
||||
We need this for the ticket system.
|
58
package.json
58
package.json
@ -1,58 +0,0 @@
|
||||
{
|
||||
"name": "nodejs-typescript-template",
|
||||
"version": "2.0.0",
|
||||
"description": "A template for my nodejs projects",
|
||||
"main": "prod/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "eslint src test --max-warnings 0 && prettier src test --check",
|
||||
"start": "node -r dotenv/config prod/index.js",
|
||||
"test": "ts-mocha -u tdd test/**/*.spec.ts --recursive --exit --timeout 10000"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+ssh://git@github.com/naomi-lgbt/nodejs-typescript-template.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": "22",
|
||||
"pnpm": "9"
|
||||
},
|
||||
"keywords": [
|
||||
"template",
|
||||
"typescript",
|
||||
"eslint",
|
||||
"nodejs",
|
||||
"prettier"
|
||||
],
|
||||
"author": "Naomi Carrigan",
|
||||
"license": "SEE LICENSE IN https://docs.nhcarrigan.com/#/license",
|
||||
"bugs": {
|
||||
"url": "https://github.com/naomi-lgbt/nodejs-typescript-template/issues"
|
||||
},
|
||||
"homepage": "https://github.com/naomi-lgbt/nodejs-typescript-template#readme",
|
||||
"dependencies": {
|
||||
"@prisma/client": "5.13.0",
|
||||
"discord.js": "14.15.2",
|
||||
"dotenv": "16.4.5",
|
||||
"express": "4.19.2",
|
||||
"node-schedule": "2.1.1",
|
||||
"winston": "3.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nhcarrigan/eslint-config": "1.1.3",
|
||||
"@nhcarrigan/prettier-config": "1.0.1",
|
||||
"@nhcarrigan/typescript-config": "1.0.1",
|
||||
"@types/chai": "4.3.16",
|
||||
"@types/express": "4.17.21",
|
||||
"@types/mocha": "10.0.6",
|
||||
"@types/node": "18.19.33",
|
||||
"@types/node-schedule": "2.1.7",
|
||||
"chai": "4.4.1",
|
||||
"eslint": "8.57.0",
|
||||
"mocha": "10.4.0",
|
||||
"prettier": "2.8.8",
|
||||
"prisma": "5.13.0",
|
||||
"ts-mocha": "10.0.0",
|
||||
"typescript": "5.4.5"
|
||||
}
|
||||
}
|
3364
pnpm-lock.yaml
generated
3364
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
18
sample.env
18
sample.env
@ -1,18 +0,0 @@
|
||||
# Bot
|
||||
TOKEN=""
|
||||
MONGO_URI=""
|
||||
|
||||
# Hooks
|
||||
DEBUG=""
|
||||
COMM=""
|
||||
DIST=""
|
||||
NEWS=""
|
||||
|
||||
#Env
|
||||
NODE_ENV="development"
|
||||
|
||||
# Trello
|
||||
TRELLO_KEY=""
|
||||
TRELLO_TOKEN=""
|
||||
TRELLO_SECRET=""
|
||||
TRELLO_HOOK_CALLBACK=""
|
@ -1 +0,0 @@
|
||||
export const TicketSupportRole = "1173582640843063366";
|
@ -1,44 +0,0 @@
|
||||
import { Webhooks } from "./Webhooks";
|
||||
|
||||
export const Trello = {
|
||||
boardId: "",
|
||||
newCardListId: "6552f24264ac58e98f8b78cd",
|
||||
readyToSendListId: "6552f248af3c5fb5a81e4788",
|
||||
platformLabels: {
|
||||
discord: "6552f2b18af54bcca998abfe",
|
||||
twitter: "6552f2cb9f21432b29106558",
|
||||
email: "6552f2cee1258dfe159d48c8",
|
||||
},
|
||||
actionLabels: {
|
||||
donate: "6552f2d257c42c85759d0199",
|
||||
call: "6552f2d6d0e69a4c17f5f8ab",
|
||||
protest: "6552f2dbfa9f5ac4267b136a",
|
||||
},
|
||||
checklistId: "6552f66d91daf000137ed4d2",
|
||||
};
|
||||
|
||||
export const PlatformsToLabel: Record<string, string> = {
|
||||
Email: Trello.platformLabels.email,
|
||||
Discord: Trello.platformLabels.discord,
|
||||
Twitter: Trello.platformLabels.twitter,
|
||||
};
|
||||
|
||||
export const ActionsToLabel: Record<string, string> = {
|
||||
"Sent a donation": Trello.actionLabels.donate,
|
||||
"Called my representatives": Trello.actionLabels.call,
|
||||
"Attended a protest": Trello.actionLabels.protest,
|
||||
};
|
||||
|
||||
/**
|
||||
* Grabs the message from Discord, formats it into a Trello comment.
|
||||
*
|
||||
* @param {string} userName The name of the user that triggered the comment.
|
||||
* @returns {string} The formatted text to send.
|
||||
*/
|
||||
export const TrelloComments: {
|
||||
[key in Webhooks]: (userName: string) => string;
|
||||
} = {
|
||||
[Webhooks.NewCommissions]: (userName) => `Artwork claimed by ${userName}.`,
|
||||
[Webhooks.CompleteCommissions]: (userName) =>
|
||||
`Distribution claimed by ${userName}.`,
|
||||
};
|
@ -1,19 +0,0 @@
|
||||
import { User } from "discord.js";
|
||||
|
||||
export enum Webhooks {
|
||||
NewCommissions = "1172850885571911760",
|
||||
CompleteCommissions = "1173062416041525259",
|
||||
}
|
||||
|
||||
/**
|
||||
* Grabs the user from Discord, formats it based on the webhook ID.
|
||||
*
|
||||
* @param {User} u The user payload from Discord.
|
||||
* @returns {string} The formatted text to send.
|
||||
*/
|
||||
export const ClaimTexts: { [key in Webhooks]: (u: User) => string } = {
|
||||
[Webhooks.NewCommissions]: (u) =>
|
||||
`Thanks for agreeing to do this art, <@!${u.id}>~! When it's completed, upload the image here and I'll automatically close this thread!`,
|
||||
[Webhooks.CompleteCommissions]: (u) =>
|
||||
`Thanks for agreeing to deliver this art, <@!${u.id}>~! When it's completed, upload a confirmation image here and I'll automatically close this thread!`,
|
||||
};
|
@ -1,45 +0,0 @@
|
||||
import { Interaction } from "discord.js";
|
||||
|
||||
import { ExtendedClient } from "../interface/ExtendedClient";
|
||||
import { ticketClaimHandler } from "../modules/buttons/ticketClaim";
|
||||
import { ticketCloseHandler } from "../modules/buttons/ticketClose";
|
||||
import { ticketOpenHandler } from "../modules/buttons/ticketOpen";
|
||||
import { handleTicketModal } from "../modules/modals/handleTicketModal";
|
||||
|
||||
/**
|
||||
* Handles the logic for the interaction create event.
|
||||
*
|
||||
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||
* @param {Interaction} interaction The interaction payload from Discord.
|
||||
*/
|
||||
export const onInteractionCreate = async (
|
||||
bot: ExtendedClient,
|
||||
interaction: Interaction
|
||||
) => {
|
||||
try {
|
||||
if (interaction.isModalSubmit()) {
|
||||
if (interaction.customId === "ticket-modal") {
|
||||
await handleTicketModal(bot, interaction);
|
||||
}
|
||||
}
|
||||
|
||||
if (interaction.isButton()) {
|
||||
if (!interaction.inCachedGuild()) {
|
||||
return;
|
||||
}
|
||||
if (interaction.customId === "ticket") {
|
||||
await ticketOpenHandler(bot, interaction);
|
||||
}
|
||||
if (interaction.customId === "claim") {
|
||||
await ticketClaimHandler(bot, interaction);
|
||||
}
|
||||
if (interaction.customId === "close") {
|
||||
await ticketCloseHandler(bot, interaction);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
await bot.debug.send(
|
||||
`Error in interaction create event: ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
};
|
@ -1,20 +0,0 @@
|
||||
import { GuildMember } from "discord.js";
|
||||
|
||||
import { ExtendedClient } from "../interface/ExtendedClient";
|
||||
|
||||
/**
|
||||
* Handles the member add event from discord. Gives the new member
|
||||
* the `Members` role.
|
||||
*
|
||||
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||
* @param {GuildMember} member The member payload from Discord.
|
||||
*/
|
||||
export const onMemberAdd = async (bot: ExtendedClient, member: GuildMember) => {
|
||||
try {
|
||||
await member.roles.add("1173580547952484352");
|
||||
} catch (err) {
|
||||
await bot.debug.send(
|
||||
`Error on member add event: ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
};
|
@ -1,172 +0,0 @@
|
||||
import { ChannelType, Message, PermissionFlagsBits } from "discord.js";
|
||||
|
||||
import { ExtendedClient } from "../interface/ExtendedClient";
|
||||
import { logTicketMessage } from "../modules/logTicketMessage";
|
||||
import { startTicketPost } from "../modules/messages/startTicketPost";
|
||||
import { getMuteDurationUnit } from "../utils/getMuteDurationUnit";
|
||||
import { isValidWebhook } from "../utils/isValidWebhook";
|
||||
import { logHandler } from "../utils/logHandler";
|
||||
|
||||
/**
|
||||
* Handles the message create event. Adds reactions to messages from approved
|
||||
* webhooks.
|
||||
*
|
||||
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||
* @param {Message} message The message payload from Discord.
|
||||
*/
|
||||
export const onMessageCreate = async (
|
||||
bot: ExtendedClient,
|
||||
message: Message
|
||||
) => {
|
||||
try {
|
||||
if (!message.author.bot) {
|
||||
if (
|
||||
message.member?.permissions.has(PermissionFlagsBits.ManageGuild) &&
|
||||
message.content === "~tickets"
|
||||
) {
|
||||
await startTicketPost(bot, message);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!message.channel.isDMBased() &&
|
||||
message.channel.name.startsWith("ticket-")
|
||||
) {
|
||||
const id = message.channel.id;
|
||||
const cached = bot.ticketLogs[id];
|
||||
if (!cached) {
|
||||
return;
|
||||
}
|
||||
await logTicketMessage(bot, message, cached);
|
||||
}
|
||||
if (
|
||||
message.member?.permissions.has(PermissionFlagsBits.ModerateMembers)
|
||||
) {
|
||||
if (message.content.startsWith("!mute")) {
|
||||
const [, id, durationString, ...reason] =
|
||||
message.content.split(/\s+/);
|
||||
const target = await message.guild?.members.fetch(id);
|
||||
if (!target) {
|
||||
await message.reply("Target not found.");
|
||||
return;
|
||||
}
|
||||
const durationNumber = parseInt(
|
||||
durationString.replace(/\w$/, ""),
|
||||
10
|
||||
);
|
||||
const durationUnit = durationString.replace(/^\d+/, "");
|
||||
const conversion = getMuteDurationUnit(durationUnit);
|
||||
if (!conversion) {
|
||||
await message.reply(`Invalid unit ${durationUnit}.`);
|
||||
return;
|
||||
}
|
||||
const time = durationNumber * conversion;
|
||||
if (time > 1000 * 60 * 60 * 24 * 28) {
|
||||
await message.reply("Cannot time out user for more than 28 days.");
|
||||
return;
|
||||
}
|
||||
await target.timeout(time, reason.join(" ") || "No reason provided.");
|
||||
await message.reply({ content: "Done" });
|
||||
const logChannel = message.guild?.channels.cache.find(
|
||||
(c) => c.name === "bot-logs"
|
||||
);
|
||||
if (logChannel && "send" in logChannel) {
|
||||
await logChannel?.send({
|
||||
content: `<@!${message.author.id}> muted <@!${target.id}> (${
|
||||
target.id
|
||||
}) - ${durationString} for: ${
|
||||
reason.join(" ") ?? "No reason provided."
|
||||
}`,
|
||||
allowedMentions: {
|
||||
users: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
if (message.content.startsWith("!unmute")) {
|
||||
const [, id, ...reason] = message.content.split(/\s+/);
|
||||
const target = await message.guild?.members.fetch(id);
|
||||
if (!target) {
|
||||
await message.reply("Target not found.");
|
||||
return;
|
||||
}
|
||||
await target.timeout(null, reason.join(" ") || "No reason provided.");
|
||||
const logChannel = message.guild?.channels.cache.find(
|
||||
(c) => c.name === "bot-logs"
|
||||
);
|
||||
await message.reply({ content: "Done" });
|
||||
if (logChannel && "send" in logChannel) {
|
||||
await logChannel?.send({
|
||||
content: `<@!${message.author.id}> unmuted <@!${target.id}> (${
|
||||
target.id
|
||||
}) for: ${reason.join(" ") ?? "No reason provided."}`,
|
||||
allowedMentions: {
|
||||
users: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
message.channel.type === ChannelType.PublicThread &&
|
||||
message.author.id === message.channel.name
|
||||
) {
|
||||
if (message.channel.parentId === "1172568787330019340") {
|
||||
const { attachments } = message;
|
||||
if (!attachments.size) {
|
||||
return;
|
||||
}
|
||||
await message.reply({
|
||||
content:
|
||||
"Accepting this as submission and closing out art request. Thanks for your support!",
|
||||
});
|
||||
const starter = await message.channel.fetchStarterMessage();
|
||||
if (!starter) {
|
||||
await message.channel.send(
|
||||
`<@!465650873650118659>, couldn't load starter message for ${message.url}. Please port manually~!`
|
||||
);
|
||||
await message.channel.setArchived(true);
|
||||
return;
|
||||
}
|
||||
await message.channel.setArchived(true);
|
||||
await bot.dist.send({
|
||||
content: starter.content,
|
||||
files: [...attachments.values()],
|
||||
});
|
||||
}
|
||||
|
||||
if (message.channel.parentId === "1173061747737903315") {
|
||||
const { attachments } = message;
|
||||
if (!attachments.size) {
|
||||
return;
|
||||
}
|
||||
await message.reply({
|
||||
content:
|
||||
"Accepting this as delivery confirmation and closing out distribution request. Thanks for your support!",
|
||||
});
|
||||
await message.channel.setArchived(true);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
if (message.author.bot && !isValidWebhook(message.author.id)) {
|
||||
logHandler.log(
|
||||
"info",
|
||||
`Got bot message from ${message.author.id} but is not a valid webhook.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
logHandler.log("info", "Processing webhook message.");
|
||||
await message.react("âś…").catch(async (err) => {
|
||||
await bot.debug.send({
|
||||
content: `[Failed to add reaction:](<${message.url}>) ${err.message}`,
|
||||
});
|
||||
});
|
||||
logHandler.log("info", "Added reaction~!");
|
||||
} catch (err) {
|
||||
await bot.debug.send(
|
||||
`Error in message create event: ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
};
|
@ -1,77 +0,0 @@
|
||||
import {
|
||||
MessageReaction,
|
||||
PartialMessageReaction,
|
||||
PartialUser,
|
||||
TextChannel,
|
||||
ThreadAutoArchiveDuration,
|
||||
User,
|
||||
} from "discord.js";
|
||||
|
||||
import { ClaimTexts } from "../config/Webhooks";
|
||||
import { ExtendedClient } from "../interface/ExtendedClient";
|
||||
import { isValidWebhook } from "../utils/isValidWebhook";
|
||||
|
||||
/**
|
||||
* Handles the message reaction add. If added to a valid webhook, processes logic to send
|
||||
* DM to the first user who reacted, remove further reactions.
|
||||
*
|
||||
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||
* @param {MessageReaction | PartialMessageReaction} r The reaction payload from Discord.
|
||||
* @param {User | PartialUser} user The user who reacted.
|
||||
*/
|
||||
export const onReactionAdd = async (
|
||||
bot: ExtendedClient,
|
||||
r: MessageReaction | PartialMessageReaction,
|
||||
user: User | PartialUser
|
||||
) => {
|
||||
try {
|
||||
if (user.bot) {
|
||||
return;
|
||||
}
|
||||
const reaction = await r.fetch().catch(async (err) => {
|
||||
await bot.debug.send({
|
||||
content: `[Failed to fetch reactions:](<${r.message.url}>) ${err.message}`,
|
||||
});
|
||||
return null;
|
||||
});
|
||||
if (!reaction) {
|
||||
return;
|
||||
}
|
||||
const message = await r.message.fetch().catch(async (err) => {
|
||||
await bot.debug.send({
|
||||
content: `[Failed to fetch message:](<${r.message.url}>) ${err.message}`,
|
||||
});
|
||||
return null;
|
||||
});
|
||||
if (!message || !isValidWebhook(message.author.id)) {
|
||||
return;
|
||||
}
|
||||
if (reaction.count > 2) {
|
||||
await r.users.remove(user.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = r.message.channel as TextChannel;
|
||||
const claimedByUser = (await channel.threads.fetchActive()).threads.filter(
|
||||
(t) => t.name === user.id
|
||||
);
|
||||
if (claimedByUser.size >= 2) {
|
||||
await r.users.remove(user.id);
|
||||
await user.send(
|
||||
"You currently have 2 open art rewards. Please do not claim another until you have completed one of your outstanding art works."
|
||||
);
|
||||
return;
|
||||
}
|
||||
const thread = await channel.threads.create({
|
||||
autoArchiveDuration: ThreadAutoArchiveDuration.OneWeek,
|
||||
name: user.id,
|
||||
startMessage: message,
|
||||
});
|
||||
await thread.members.add(user.id);
|
||||
await thread.send(ClaimTexts[message.author.id](user as User));
|
||||
} catch (err) {
|
||||
await bot.debug.send(
|
||||
`Error in processing new reaction: ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
};
|
@ -1,45 +0,0 @@
|
||||
import { scheduleJob } from "node-schedule";
|
||||
|
||||
import { ExtendedClient } from "../interface/ExtendedClient";
|
||||
import { checkAirtableRecords } from "../modules/checkAirtableRecords";
|
||||
import { fetchMessages } from "../modules/messages/fetchMessages";
|
||||
import { sendUnclaimedArt } from "../modules/reminders/sendUnclaimedArt";
|
||||
import { sendUnclaimedDistros } from "../modules/reminders/sendUnclaimedDistros";
|
||||
import { sendUnfinishedArt } from "../modules/reminders/sendUnfinishedArt";
|
||||
import { sendUnfinishedDistros } from "../modules/reminders/sendUnfinsihedDistros";
|
||||
import { getNewsFeed } from "../utils/getNewsFeed";
|
||||
import { serve } from "../server/serve";
|
||||
|
||||
/**
|
||||
* Handles the logic when the bot is ready to receive Discord events.
|
||||
*
|
||||
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||
*/
|
||||
export const onReady = async (bot: ExtendedClient) => {
|
||||
try {
|
||||
await bot.debug.send("Bot is online and ready to help!");
|
||||
// donor channel
|
||||
await fetchMessages(bot, "1172568787330019340");
|
||||
// delivery channel
|
||||
await fetchMessages(bot, "1173061747737903315");
|
||||
await bot.debug.send("Fetching initial news feed...");
|
||||
await getNewsFeed(bot);
|
||||
await bot.debug.send("Fetching news posts every 10 minutes.");
|
||||
setInterval(async () => await getNewsFeed(bot), 1000 * 60 * 10);
|
||||
await bot.debug.send("Fetching new airtable submissions every 60 minutes.");
|
||||
setInterval(async () => await checkAirtableRecords(bot), 1000 * 60 * 60);
|
||||
|
||||
scheduleJob("0 9 * * 1,3,5", async () => {
|
||||
await sendUnclaimedArt(bot);
|
||||
await sendUnclaimedDistros(bot);
|
||||
});
|
||||
|
||||
scheduleJob("0 9 * * 6", async () => {
|
||||
await sendUnfinishedArt(bot);
|
||||
await sendUnfinishedDistros(bot);
|
||||
});
|
||||
await serve(bot);
|
||||
} catch (err) {
|
||||
await bot.debug.send(`Error on ready event: ${(err as Error).message}`);
|
||||
}
|
||||
};
|
73
src/index.ts
73
src/index.ts
@ -1,73 +0,0 @@
|
||||
import { execSync } from "child_process";
|
||||
|
||||
import { Client, Events, GatewayIntentBits, WebhookClient } from "discord.js";
|
||||
|
||||
import { onInteractionCreate } from "./events/onInteractionCreate";
|
||||
import { onMemberAdd } from "./events/onMemberAdd";
|
||||
import { onMessageCreate } from "./events/onMessageCreate";
|
||||
import { onReactionAdd } from "./events/onReactionAdd";
|
||||
import { onReady } from "./events/onReady";
|
||||
import { ExtendedClient } from "./interface/ExtendedClient";
|
||||
import { logHandler } from "./utils/logHandler";
|
||||
|
||||
(async () => {
|
||||
if (
|
||||
!process.env.TOKEN ||
|
||||
!process.env.DEBUG ||
|
||||
!process.env.COMM ||
|
||||
!process.env.DIST ||
|
||||
!process.env.NEWS ||
|
||||
!process.env.TICKET
|
||||
) {
|
||||
logHandler.log("error", "Missing environment variables.");
|
||||
return;
|
||||
}
|
||||
const bot = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessageReactions,
|
||||
GatewayIntentBits.MessageContent,
|
||||
GatewayIntentBits.GuildMembers,
|
||||
],
|
||||
}) as ExtendedClient;
|
||||
bot.debug = new WebhookClient({ url: process.env.DEBUG });
|
||||
bot.comm = new WebhookClient({ url: process.env.COMM });
|
||||
bot.dist = new WebhookClient({ url: process.env.DIST });
|
||||
bot.news = new WebhookClient({ url: process.env.NEWS });
|
||||
bot.ticket = new WebhookClient({ url: process.env.TICKET });
|
||||
bot.ticketLogs = {};
|
||||
|
||||
const commit = execSync("git rev-parse HEAD").toString().trim();
|
||||
|
||||
await bot.debug.send({
|
||||
content: `Bot is starting up.\nVersion: ${
|
||||
process.env.npm_package_version
|
||||
}\nCommit: [${commit.slice(
|
||||
0,
|
||||
7
|
||||
)}](<https://github.com/nhcarrigan/art-for-palestine-bot/commit/${commit}>)`,
|
||||
});
|
||||
|
||||
bot.on(Events.ClientReady, async () => {
|
||||
await onReady(bot);
|
||||
});
|
||||
|
||||
bot.on(Events.MessageCreate, async (message) => {
|
||||
await onMessageCreate(bot, message);
|
||||
});
|
||||
|
||||
bot.on(Events.MessageReactionAdd, async (r, user) => {
|
||||
await onReactionAdd(bot, r, user);
|
||||
});
|
||||
|
||||
bot.on(Events.GuildMemberAdd, async (member) => {
|
||||
await onMemberAdd(bot, member);
|
||||
});
|
||||
|
||||
bot.on(Events.InteractionCreate, async (interaction) => {
|
||||
await onInteractionCreate(bot, interaction);
|
||||
});
|
||||
|
||||
await bot.login(process.env.TOKEN);
|
||||
})();
|
@ -1,71 +0,0 @@
|
||||
export interface AirtableResponse {
|
||||
records: {
|
||||
id: string;
|
||||
createdTime: string;
|
||||
fields: {
|
||||
"Anything Else?"?: string;
|
||||
Reference: {
|
||||
id: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
url: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
type: string;
|
||||
thumbnails?: {
|
||||
small: {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
large: {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
full: {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
};
|
||||
}[];
|
||||
"Acknowledgement "?: boolean;
|
||||
"Email Address"?: string;
|
||||
"What would you like us to draw?"?: string;
|
||||
Action: string;
|
||||
Name: string;
|
||||
"Contact Method": string;
|
||||
Created: string;
|
||||
"Proof of Donation"?: {
|
||||
id: string;
|
||||
width: number;
|
||||
height: number;
|
||||
url: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
type: string;
|
||||
thumbnails: {
|
||||
small: {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
large: {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
full: {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
};
|
||||
}[];
|
||||
"Donation Amount"?: number;
|
||||
Fund?: string;
|
||||
Handle?: string;
|
||||
};
|
||||
}[];
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
import { ButtonInteraction } from "discord.js";
|
||||
|
||||
import { ExtendedClient } from "./ExtendedClient";
|
||||
|
||||
export type ButtonHandler = (
|
||||
Bot: ExtendedClient,
|
||||
interaction: ButtonInteraction<"cached">
|
||||
) => Promise<void>;
|
@ -1,11 +0,0 @@
|
||||
import { Client, WebhookClient } from "discord.js";
|
||||
|
||||
export interface ExtendedClient extends Client {
|
||||
debug: WebhookClient;
|
||||
comm: WebhookClient;
|
||||
dist: WebhookClient;
|
||||
news: WebhookClient;
|
||||
ticket: WebhookClient;
|
||||
lastArticle: number;
|
||||
ticketLogs: { [key: string]: string };
|
||||
}
|
@ -1,78 +0,0 @@
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
Embed,
|
||||
GuildMember,
|
||||
Message,
|
||||
} from "discord.js";
|
||||
|
||||
import { TicketSupportRole } from "../../config/Tickets";
|
||||
import { ButtonHandler } from "../../interface/ButtonHandler";
|
||||
|
||||
/**
|
||||
* Handles the process of claiming a ticket.
|
||||
*/
|
||||
export const ticketClaimHandler: ButtonHandler = async (bot, interaction) => {
|
||||
try {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
const { guild, message, member } = interaction;
|
||||
const { embeds } = message;
|
||||
const supportRole = await guild.roles.fetch(TicketSupportRole);
|
||||
|
||||
if (!supportRole) {
|
||||
await interaction.editReply("Cannot find support role!");
|
||||
return;
|
||||
}
|
||||
|
||||
const isSupport = (member as GuildMember).roles.cache.has(supportRole.id);
|
||||
|
||||
if (!isSupport) {
|
||||
await interaction.editReply({
|
||||
content: "Only support members can claim a ticket.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const ticketEmbed = embeds[0] as Embed;
|
||||
const updatedEmbed = {
|
||||
title: ticketEmbed.title || "Lost the Title",
|
||||
description: ticketEmbed.description || "Lost the Description",
|
||||
fields: [{ name: "Claimed by:", value: `<@${member.user.id}>` }],
|
||||
};
|
||||
|
||||
const claimButton = new ButtonBuilder()
|
||||
.setCustomId("claim")
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setLabel("Claim this ticket!")
|
||||
.setEmoji("âś‹")
|
||||
.setDisabled(true);
|
||||
const closeButton = new ButtonBuilder()
|
||||
.setCustomId("close")
|
||||
.setStyle(ButtonStyle.Danger)
|
||||
.setLabel("Close this ticket!")
|
||||
.setEmoji("🗑️");
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents([
|
||||
claimButton,
|
||||
closeButton,
|
||||
]);
|
||||
|
||||
await (message as Message).edit({
|
||||
embeds: [updatedEmbed],
|
||||
components: [row],
|
||||
});
|
||||
|
||||
await interaction.editReply(
|
||||
"I have marked you as responsible for this query."
|
||||
);
|
||||
} catch (err) {
|
||||
await bot.debug.send(
|
||||
`Error in ticket claim module: ${(err as Error).message}`
|
||||
);
|
||||
await interaction.editReply({
|
||||
content:
|
||||
"Forgive me, but I failed to complete your request. Please try again later.",
|
||||
});
|
||||
}
|
||||
};
|
@ -1,60 +0,0 @@
|
||||
import { GuildMember, EmbedBuilder, TextChannel } from "discord.js";
|
||||
|
||||
import { TicketSupportRole } from "../../config/Tickets";
|
||||
import { ButtonHandler } from "../../interface/ButtonHandler";
|
||||
import { generateLogs } from "../generateLogs";
|
||||
|
||||
/**
|
||||
* Handles closing a ticket.
|
||||
*/
|
||||
export const ticketCloseHandler: ButtonHandler = async (bot, interaction) => {
|
||||
try {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
const { guild, member, channel } = interaction;
|
||||
|
||||
if (!guild || !member || !channel) {
|
||||
await interaction.editReply({
|
||||
content: "Error finding the guild!",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const supportRole = await guild.roles.fetch(TicketSupportRole);
|
||||
|
||||
if (!supportRole) {
|
||||
await interaction.editReply("Cannot find support role!");
|
||||
return;
|
||||
}
|
||||
|
||||
const isSupport = (member as GuildMember).roles.cache.has(supportRole.id);
|
||||
|
||||
if (!isSupport) {
|
||||
await interaction.editReply({
|
||||
content: "Only support members can close a ticket.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const logEmbed = new EmbedBuilder();
|
||||
logEmbed.setTitle("Ticket Closed");
|
||||
logEmbed.setDescription(`Ticket closed by <@!${member.user.id}>`);
|
||||
logEmbed.addFields({
|
||||
name: "User",
|
||||
value:
|
||||
(channel as TextChannel)?.name.split("-").slice(1).join("-") ||
|
||||
"unknown",
|
||||
});
|
||||
|
||||
const logFile = await generateLogs(bot, channel.id);
|
||||
await bot.ticket.send({ embeds: [logEmbed], files: [logFile] });
|
||||
await channel.delete();
|
||||
} catch (err) {
|
||||
await bot.debug.send(
|
||||
`Error in close ticket module: ${(err as Error).message}`
|
||||
);
|
||||
await interaction.editReply({
|
||||
content:
|
||||
"Forgive me, but I failed to complete your request. Please try again later.",
|
||||
});
|
||||
}
|
||||
};
|
@ -1,41 +0,0 @@
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ModalBuilder,
|
||||
ModalActionRowComponentBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle,
|
||||
} from "discord.js";
|
||||
|
||||
import { ButtonHandler } from "../../interface/ButtonHandler";
|
||||
|
||||
/**
|
||||
* Generates the modal for opening a new ticket.
|
||||
*/
|
||||
export const ticketOpenHandler: ButtonHandler = async (bot, interaction) => {
|
||||
try {
|
||||
const ticketModal = new ModalBuilder()
|
||||
.setCustomId("ticket-modal")
|
||||
.setTitle("Ticket System");
|
||||
const reasonInput = new TextInputBuilder()
|
||||
.setCustomId("reason")
|
||||
.setLabel("Why are you opening this ticket?")
|
||||
.setStyle(TextInputStyle.Paragraph)
|
||||
.setRequired(true)
|
||||
.setMaxLength(1000);
|
||||
|
||||
const actionRow =
|
||||
new ActionRowBuilder<ModalActionRowComponentBuilder>().addComponents(
|
||||
reasonInput
|
||||
);
|
||||
ticketModal.addComponents(actionRow);
|
||||
await interaction.showModal(ticketModal);
|
||||
} catch (err) {
|
||||
await bot.debug.send(
|
||||
`Error in open ticket module: ${(err as Error).message}`
|
||||
);
|
||||
await interaction.editReply({
|
||||
content:
|
||||
"Forgive me, but I failed to complete your request. Please try again later.",
|
||||
});
|
||||
}
|
||||
};
|
@ -1,75 +0,0 @@
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
|
||||
import { AttachmentBuilder } from "discord.js";
|
||||
|
||||
import { AirtableResponse } from "../interface/AirtableResponse";
|
||||
import { ExtendedClient } from "../interface/ExtendedClient";
|
||||
|
||||
/**
|
||||
* Fetches the Airtable records and pipes new records to trello.
|
||||
*
|
||||
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||
*/
|
||||
export const checkAirtableRecords = async (bot: ExtendedClient) => {
|
||||
try {
|
||||
const latestId = await readFile(
|
||||
join(__dirname, "..", "..", "latestAirtableRecord.txt"),
|
||||
"utf-8"
|
||||
);
|
||||
const records = await fetch(
|
||||
`https://api.airtable.com/v0/${process.env.AIRTABLE_BASE_ID}/${process.env.AIRTABLE_TABLE_ID}?maxRecords=100&sort%5B0%5D%5Bfield%5D=Created&sort%5B0%5D%5Bdirection%5D=desc`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.AIRTABLE_KEY}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
const res = (await records.json()) as AirtableResponse;
|
||||
const alreadySeenIndex = res.records.findIndex((r) => r.id === latestId);
|
||||
if (alreadySeenIndex === 0) {
|
||||
return;
|
||||
}
|
||||
const newRecords = res.records.slice(0, alreadySeenIndex);
|
||||
const newLatestId = newRecords[0]?.id as string;
|
||||
|
||||
await writeFile(
|
||||
join(__dirname, "..", "..", "latestAirtableRecord.txt"),
|
||||
newLatestId,
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
for (const record of newRecords) {
|
||||
const {
|
||||
Name: name,
|
||||
Reference: images,
|
||||
"Contact Method": platform,
|
||||
Handle: handle,
|
||||
"What would you like us to draw?": note,
|
||||
"Anything Else?": additional,
|
||||
} = record.fields;
|
||||
const files: AttachmentBuilder[] = [];
|
||||
for (const imageUrl of images) {
|
||||
const image = await fetch(imageUrl.url);
|
||||
const imageBuffer = await image.arrayBuffer();
|
||||
const imageFinal = Buffer.from(imageBuffer);
|
||||
const file = new AttachmentBuilder(imageFinal, {
|
||||
name: "reference.png",
|
||||
});
|
||||
files.push(file);
|
||||
}
|
||||
await bot.comm.send({
|
||||
content: `**FOR:** ${name} - Contact via ${platform}: ${handle}\n\n${note?.slice(
|
||||
0,
|
||||
1000
|
||||
)}\n\n${additional?.slice(0, 1000)}\n\nReferences:`,
|
||||
files,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
await bot.debug.send(
|
||||
`Error in check airtable records: ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
};
|
@ -1,32 +0,0 @@
|
||||
import { writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
|
||||
import { ExtendedClient } from "../interface/ExtendedClient";
|
||||
|
||||
/**
|
||||
* Creates the initial ticket log file.
|
||||
*
|
||||
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||
* @param {string} channelId The ticket channel ID, used as a unique identifier.
|
||||
* @param {string} user The user tag of the person who opened the ticket.
|
||||
* @param {string} content The initial content of the log file.
|
||||
*/
|
||||
export const createLogFile = async (
|
||||
bot: ExtendedClient,
|
||||
channelId: string,
|
||||
user: string,
|
||||
content: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
bot.ticketLogs[channelId] = channelId;
|
||||
|
||||
await writeFile(
|
||||
join(process.cwd(), "logs", `${channelId}.txt`),
|
||||
`[${new Date().toLocaleString()}] - **TICKET CREATED**\n[${new Date().toLocaleString()}] - ${user}: ${content}\n`
|
||||
);
|
||||
} catch (err) {
|
||||
await bot.debug.send(
|
||||
`Error in create log file module: ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
};
|
@ -1,46 +0,0 @@
|
||||
import { readFile, unlink } from "fs/promises";
|
||||
import { join } from "path";
|
||||
|
||||
import { AttachmentBuilder } from "discord.js";
|
||||
|
||||
import { ExtendedClient } from "../interface/ExtendedClient";
|
||||
|
||||
/**
|
||||
* To run when a ticket is closed. Finds the ticket log file,
|
||||
* creates a message attachement with the logs, and deletes the file.
|
||||
*
|
||||
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||
* @param {string} channelId The channel ID of the ticket.
|
||||
* @returns {Promise<AttachmentBuilder>} The log file as a Discord attachment.
|
||||
*/
|
||||
export const generateLogs = async (
|
||||
bot: ExtendedClient,
|
||||
channelId: string
|
||||
): Promise<AttachmentBuilder> => {
|
||||
try {
|
||||
delete bot.ticketLogs[channelId];
|
||||
|
||||
const logs = await readFile(
|
||||
join(process.cwd(), "logs", `${channelId}.txt`),
|
||||
"utf8"
|
||||
).catch(() => "no logs found...");
|
||||
|
||||
const attachment = new AttachmentBuilder(Buffer.from(logs, "utf-8"), {
|
||||
name: "log.txt",
|
||||
});
|
||||
|
||||
await unlink(join(process.cwd(), "logs", `${channelId}.txt`)).catch(
|
||||
() => null
|
||||
);
|
||||
|
||||
return attachment;
|
||||
} catch (err) {
|
||||
await bot.debug.send(
|
||||
`Error in generate logs module: ${(err as Error).message}`
|
||||
);
|
||||
return new AttachmentBuilder(
|
||||
Buffer.from("An error occurred fetching these logs.", "utf-8"),
|
||||
{ name: "log.txt" }
|
||||
);
|
||||
}
|
||||
};
|
@ -1,39 +0,0 @@
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
|
||||
import { Message } from "discord.js";
|
||||
|
||||
import { ExtendedClient } from "../interface/ExtendedClient";
|
||||
|
||||
/**
|
||||
* Logs messages to a file to track ticket activity.
|
||||
*
|
||||
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||
* @param {Message} message The Discord message payload.
|
||||
* @param {string} logId The logId to identify the file.
|
||||
*/
|
||||
export const logTicketMessage = async (
|
||||
bot: ExtendedClient,
|
||||
message: Message,
|
||||
logId: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const logFile = await readFile(
|
||||
join(process.cwd(), "logs", `${logId}.txt`),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const parsedString = `[${new Date(
|
||||
message.createdTimestamp
|
||||
).toLocaleString()}] - ${message.author.tag}: ${message.content}\n`;
|
||||
|
||||
await writeFile(
|
||||
join(process.cwd(), "logs", `${logId}.txt`),
|
||||
logFile + parsedString
|
||||
);
|
||||
} catch (err) {
|
||||
await bot.debug.send(
|
||||
`Error in log ticket message module: ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
};
|
@ -1,40 +0,0 @@
|
||||
import { ExtendedClient } from "../../interface/ExtendedClient";
|
||||
|
||||
/**
|
||||
* Caches the messages from a specific channel. This should be done to ensure
|
||||
* all donation + delivery messages are cached and reaction payloads will be received.
|
||||
*
|
||||
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||
* @param {string} channelId The ID of the channel to cache.
|
||||
*/
|
||||
export const fetchMessages = async (bot: ExtendedClient, channelId: string) => {
|
||||
try {
|
||||
await bot.debug.send(`Caching messages for ${channelId}`);
|
||||
const guild = await bot.guilds.fetch("1172566005311090798");
|
||||
const channel = await guild.channels.fetch(channelId);
|
||||
|
||||
if (!channel || !("send" in channel)) {
|
||||
await bot.debug.send(`Failed to load <#${channelId}>~!`);
|
||||
return;
|
||||
}
|
||||
|
||||
let before: string | undefined = undefined;
|
||||
|
||||
await bot.debug.send("Fetching first wave of messages.");
|
||||
let messages = await channel.messages.fetch({ limit: 100 });
|
||||
|
||||
while (messages.size === 100) {
|
||||
before = messages.last()?.id;
|
||||
if (!before) {
|
||||
await bot.debug.send("Failed to get oldest messages from batch.");
|
||||
break;
|
||||
}
|
||||
await bot.debug.send(
|
||||
`Found more than 100 messages. Fetching next batch from ${before}`
|
||||
);
|
||||
messages = await channel.messages.fetch({ limit: 100, before });
|
||||
}
|
||||
} catch (err) {
|
||||
await bot.debug.send({ content: `` });
|
||||
}
|
||||
};
|
@ -1,48 +0,0 @@
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
EmbedBuilder,
|
||||
Message,
|
||||
} from "discord.js";
|
||||
|
||||
import { ExtendedClient } from "../../interface/ExtendedClient";
|
||||
|
||||
/**
|
||||
* Handles the logic to start a ticket post.
|
||||
*
|
||||
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||
* @param {Message} message The message payload from Discord.
|
||||
*/
|
||||
export const startTicketPost = async (
|
||||
bot: ExtendedClient,
|
||||
message: Message
|
||||
) => {
|
||||
try {
|
||||
const embed = new EmbedBuilder();
|
||||
embed.setTitle("Need Help?");
|
||||
embed.setDescription(
|
||||
`If you have concerns with behavior in this Discord server, click the button below to open a private ticket with the Moderation team.
|
||||
|
||||
If you want to contact the team for any other reason, you can message an Admin or send an email to \`contact@art4palestine.org\`.`
|
||||
);
|
||||
embed.setColor("#0099ff");
|
||||
|
||||
const button = new ButtonBuilder()
|
||||
.setLabel("Open a Ticket!")
|
||||
.setEmoji("âť”")
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
.setCustomId("ticket");
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(button);
|
||||
await message.channel.send({ embeds: [embed], components: [row] });
|
||||
} catch (err) {
|
||||
await bot.debug.send(
|
||||
`Error in start ticket post module: ${(err as Error).message}`
|
||||
);
|
||||
await message.reply({
|
||||
content:
|
||||
"Forgive me, but I failed to complete your request. Please try again later.",
|
||||
});
|
||||
}
|
||||
};
|
@ -1,82 +0,0 @@
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
ChannelType,
|
||||
EmbedBuilder,
|
||||
ModalSubmitInteraction,
|
||||
TextChannel,
|
||||
} from "discord.js";
|
||||
|
||||
import { TicketSupportRole } from "../../config/Tickets";
|
||||
import { ExtendedClient } from "../../interface/ExtendedClient";
|
||||
import { createLogFile } from "../createLogsFile";
|
||||
|
||||
/**
|
||||
* Handles responding to the ticket modal.
|
||||
*
|
||||
* @param {ExtendedClient} bot The bot's discord instance.
|
||||
* @param {ModalSubmitInteraction} interaction The interaction payload from Discord.
|
||||
*/
|
||||
export const handleTicketModal = async (
|
||||
bot: ExtendedClient,
|
||||
interaction: ModalSubmitInteraction
|
||||
) => {
|
||||
try {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
const { guild, user, channel } = interaction;
|
||||
const reason = interaction.fields.getTextInputValue("reason");
|
||||
|
||||
if (!guild || !channel || !("threads" in channel)) {
|
||||
await interaction.editReply(
|
||||
"Forgive me, but this can only be done within a server"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const claimButton = new ButtonBuilder()
|
||||
.setCustomId("claim")
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setLabel("Claim this ticket!")
|
||||
.setEmoji("âś‹");
|
||||
const closeButton = new ButtonBuilder()
|
||||
.setCustomId("close")
|
||||
.setStyle(ButtonStyle.Danger)
|
||||
.setLabel("Close this ticket!")
|
||||
.setEmoji("🗑️");
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents([
|
||||
claimButton,
|
||||
closeButton,
|
||||
]);
|
||||
|
||||
const ticketEmbed = new EmbedBuilder();
|
||||
ticketEmbed.setTitle("Ticket Created");
|
||||
ticketEmbed.setDescription(
|
||||
`<@!${user.id}> opened a ticket for:\n${reason}`
|
||||
);
|
||||
|
||||
const ticketThread = await (channel as TextChannel).threads.create({
|
||||
name: `ticket-${user.username}`,
|
||||
type: ChannelType.PrivateThread,
|
||||
});
|
||||
await ticketThread.members.add(user.id);
|
||||
await ticketThread.send({
|
||||
content: `<@&${TicketSupportRole}>, a ticket has been opened!`,
|
||||
embeds: [ticketEmbed],
|
||||
components: [row],
|
||||
});
|
||||
await createLogFile(bot, ticketThread.id, user.tag, reason);
|
||||
await interaction.editReply(
|
||||
"Your ticket channel has been created! Please head there and describe the issue you are having."
|
||||
);
|
||||
} catch (err) {
|
||||
await bot.debug.send(
|
||||
`Error in ticket modal module: ${(err as Error).message}`
|
||||
);
|
||||
await interaction.editReply({
|
||||
content:
|
||||
"Forgive me, but I failed to complete your request. Please try again later.",
|
||||
});
|
||||
}
|
||||
};
|
@ -1,40 +0,0 @@
|
||||
import { TextChannel } from "discord.js";
|
||||
|
||||
import { ExtendedClient } from "../../interface/ExtendedClient";
|
||||
|
||||
/**
|
||||
* Sends a reminder to claim the unclaimed art rewards.
|
||||
*
|
||||
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||
*/
|
||||
export const sendUnclaimedArt = async (bot: ExtendedClient) => {
|
||||
try {
|
||||
const guild = await bot.guilds.fetch("1172566005311090798");
|
||||
const artChannel = (await guild.channels.fetch(
|
||||
"1172568787330019340"
|
||||
)) as TextChannel;
|
||||
const messages = await artChannel.messages.fetch({
|
||||
after: "1241326793294610443",
|
||||
});
|
||||
const filtered = messages.filter((m) => !m.hasThread);
|
||||
|
||||
if (!filtered.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = await guild.channels.fetch("1172568865218252801");
|
||||
|
||||
if (!channel || !("send" in channel)) {
|
||||
await bot.debug.send(`Failed to find <#1172568865218252801>~!`);
|
||||
return;
|
||||
}
|
||||
|
||||
await channel.send(`## Unclaimed Art!
|
||||
|
||||
The following art rewards have not been claimed yet. If you have the capacity to do so, please consider taking one on.
|
||||
|
||||
${filtered.map((r) => `- ${r.url}`).join("\n")}`);
|
||||
} catch (err) {
|
||||
await bot.debug.send(`Cannot send unclaimed art reminder: ${err}`);
|
||||
}
|
||||
};
|
@ -1,40 +0,0 @@
|
||||
import { TextChannel } from "discord.js";
|
||||
|
||||
import { ExtendedClient } from "../../interface/ExtendedClient";
|
||||
|
||||
/**
|
||||
* Sends a reminder to claim the unclaimed distribution requests.
|
||||
*
|
||||
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||
*/
|
||||
export const sendUnclaimedDistros = async (bot: ExtendedClient) => {
|
||||
try {
|
||||
const guild = await bot.guilds.fetch("1172566005311090798");
|
||||
const artChannel = (await guild.channels.fetch(
|
||||
"1173061747737903315"
|
||||
)) as TextChannel;
|
||||
const messages = await artChannel.messages.fetch({
|
||||
after: "1241055357112025168",
|
||||
});
|
||||
const filtered = messages.filter((m) => !m.hasThread);
|
||||
|
||||
if (!filtered.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = await guild.channels.fetch("1173061809834565764");
|
||||
|
||||
if (!channel || !("send" in channel)) {
|
||||
await bot.debug.send(`Failed to find <#1173061809834565764>~!`);
|
||||
return;
|
||||
}
|
||||
|
||||
await channel.send(`## Unclaimed Distribtions!
|
||||
|
||||
The following art rewards have not been distributed yet. If you have the capacity to do so, please consider taking one on.
|
||||
|
||||
${filtered.map((r) => `- ${r.url}`).join("\n")}`);
|
||||
} catch (err) {
|
||||
await bot.debug.send(`Cannot send unclaimed distro reminder: ${err}`);
|
||||
}
|
||||
};
|
@ -1,25 +0,0 @@
|
||||
import { TextChannel } from "discord.js";
|
||||
|
||||
import { ExtendedClient } from "../../interface/ExtendedClient";
|
||||
|
||||
/**
|
||||
* Sends a reminder to finish claimed art rewards.
|
||||
*
|
||||
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||
*/
|
||||
export const sendUnfinishedArt = async (bot: ExtendedClient) => {
|
||||
try {
|
||||
const guild = await bot.guilds.fetch("1172566005311090798");
|
||||
const channel = (await guild.channels.fetch(
|
||||
"1172568787330019340"
|
||||
)) as TextChannel;
|
||||
const threads = await channel.threads.fetchActive();
|
||||
for (const thread of threads.threads.values()) {
|
||||
await thread.send({
|
||||
content: `<@!${thread.name}>, this art appears unfinished. When it's complete, please upload the image here and the thread will be processed.`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
await bot.debug.send(`Cannot send unfinished art reminder: ${err}`);
|
||||
}
|
||||
};
|
@ -1,25 +0,0 @@
|
||||
import { TextChannel } from "discord.js";
|
||||
|
||||
import { ExtendedClient } from "../../interface/ExtendedClient";
|
||||
|
||||
/**
|
||||
* Sends a reminder to finish claimed distribution requests.
|
||||
*
|
||||
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||
*/
|
||||
export const sendUnfinishedDistros = async (bot: ExtendedClient) => {
|
||||
try {
|
||||
const guild = await bot.guilds.fetch("1172566005311090798");
|
||||
const channel = (await guild.channels.fetch(
|
||||
"1173061747737903315"
|
||||
)) as TextChannel;
|
||||
const threads = await channel.threads.fetchActive();
|
||||
for (const thread of threads.threads.values()) {
|
||||
await thread.send({
|
||||
content: `<@!${thread.name}>, this distribution appears unconfirmed. When it's complete, please upload a confirmation image here and the thread will be processed.`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
await bot.debug.send(`Cannot send unfinished art reminder: ${err}`);
|
||||
}
|
||||
};
|
@ -1,94 +0,0 @@
|
||||
import { createHmac } from "crypto";
|
||||
import { readFile } from "fs/promises";
|
||||
import http from "http";
|
||||
import https from "https";
|
||||
|
||||
import express from "express";
|
||||
|
||||
import { Trello } from "../config/Trello";
|
||||
import { ExtendedClient } from "../interface/ExtendedClient";
|
||||
|
||||
/**
|
||||
* Instantiates the web server for listening to webhook payloads.
|
||||
*
|
||||
* TODO: Delete this entirely once all trello things are done.
|
||||
*
|
||||
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||
*/
|
||||
export const serve = async (bot: ExtendedClient) => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.get("/", (_req, res) => {
|
||||
res.header("Content-Type", "text/html");
|
||||
res.status(200).send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Art 4 Palestine Bot</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="A bot to facilitate our initiative to raise funds for Palestine relief charities by rewarding donors with art." />
|
||||
<script src="https://cdn.nhcarrigan.com/headers/index.js" async defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Art 4 Palestine Bot</h1>
|
||||
<section>
|
||||
<p>A bot to facilitate our initiative to raise funds for Palestine relief charities by rewarding donors with art.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Links</h2>
|
||||
<p>
|
||||
<a href="https://codeberg.org/nhcarrigan/a4p-bot">
|
||||
<i class="fa-solid fa-code"></i> Source Code
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://docs.nhcarrigan.com">
|
||||
<i class="fa-solid fa-book"></i> Documentation
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://chat.nhcarrigan.com">
|
||||
<i class="fa-solid fa-circle-info"></i> Support
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
</html>`);
|
||||
});
|
||||
|
||||
const httpServer = http.createServer(app);
|
||||
|
||||
httpServer.listen(10080, async () => {
|
||||
await bot.debug.send("http server listening on port 10080");
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
const privateKey = await readFile(
|
||||
"/etc/letsencrypt/live/afp.nhcarrigan.com/privkey.pem",
|
||||
"utf8"
|
||||
);
|
||||
const certificate = await readFile(
|
||||
"/etc/letsencrypt/live/afp.nhcarrigan.com/cert.pem",
|
||||
"utf8"
|
||||
);
|
||||
const ca = await readFile(
|
||||
"/etc/letsencrypt/live/afp.nhcarrigan.com/chain.pem",
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const credentials = {
|
||||
key: privateKey,
|
||||
cert: certificate,
|
||||
ca: ca,
|
||||
};
|
||||
|
||||
const httpsServer = https.createServer(credentials, app);
|
||||
|
||||
httpsServer.listen(10443, async () => {
|
||||
await bot.debug.send("https server listening on port 10443");
|
||||
});
|
||||
}
|
||||
};
|
@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Parses a unit string into units.
|
||||
*
|
||||
* @param {string} unit The unit string to parse.
|
||||
* @returns {number} The unit conversion rate, to convert a number to ms.
|
||||
*/
|
||||
export const getMuteDurationUnit = (unit: string): number | null => {
|
||||
switch (unit.toLowerCase()) {
|
||||
case "m":
|
||||
case "minute":
|
||||
case "min":
|
||||
case "minutes":
|
||||
return 1000 * 60;
|
||||
case "h":
|
||||
case "hours":
|
||||
case "hour":
|
||||
return 1000 * 60 * 60;
|
||||
case "d":
|
||||
case "day":
|
||||
case "days":
|
||||
return 1000 * 60 * 60 * 24;
|
||||
}
|
||||
return null;
|
||||
};
|
@ -1,119 +0,0 @@
|
||||
import { ExtendedClient } from "../interface/ExtendedClient";
|
||||
|
||||
const getHeadlineArticle = async (bot: ExtendedClient) => {
|
||||
try {
|
||||
const req = await fetch(
|
||||
"https://www.aljazeera.com/tag/israel-palestine-conflict/",
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Wp-site": "Naomi's Art for Palestine Bot",
|
||||
},
|
||||
}
|
||||
);
|
||||
const res = await req.text();
|
||||
const link = res.match(/<a target="" href="(.*)">Live\supdates<\/a>/);
|
||||
return link?.[1];
|
||||
} catch (err) {
|
||||
await bot.debug.send(
|
||||
`Error in fetching latest news post: ${(err as Error).message}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getArticleChildren = async (bot: ExtendedClient, slug: string) => {
|
||||
try {
|
||||
const req = await fetch(
|
||||
`https://www.aljazeera.com/graphql?wp-site=aje&operationName=SingleLiveBlogChildrensQuery&variables=%7B%22postName%22%3A%22${slug}%22%7D&extensions=%7B%7D`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Wp-site": "Naomi's Art for Palestine Bot",
|
||||
},
|
||||
}
|
||||
);
|
||||
const res = (await req.json()) as {
|
||||
data: {
|
||||
article: {
|
||||
children: number[];
|
||||
};
|
||||
};
|
||||
};
|
||||
return res.data.article.children;
|
||||
} catch (err) {
|
||||
await bot.debug.send(
|
||||
`Error in fetching article children: ${(err as Error).message}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getArticleInfo = async (bot: ExtendedClient, postId: number) => {
|
||||
try {
|
||||
const req = await fetch(
|
||||
`https://www.aljazeera.com/graphql?wp-site=aje&operationName=LiveBlogUpdateQuery&variables=%7B%22postID%22%3A${postId}%2C%22postType%22%3A%22liveblog-update%22%2C%22preview%22%3A%22%22%2C%22isAmp%22%3Afalse%7D&extensions=%7B%7D`
|
||||
);
|
||||
const res = (await req.json()) as {
|
||||
data: {
|
||||
posts: {
|
||||
link: string;
|
||||
title: string;
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
return res.data.posts;
|
||||
} catch (err) {
|
||||
await bot.debug.send(
|
||||
`Error in fetching article info: ${(err as Error).message}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the GraphQL feed from Aljazeera's Palestine live feed.
|
||||
*
|
||||
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||
*/
|
||||
export const getNewsFeed = async (bot: ExtendedClient) => {
|
||||
try {
|
||||
const headline = await getHeadlineArticle(bot);
|
||||
if (!headline) {
|
||||
return;
|
||||
}
|
||||
const children = await getArticleChildren(
|
||||
bot,
|
||||
headline.split("/").slice(-1).join()
|
||||
);
|
||||
if (!children) {
|
||||
return;
|
||||
}
|
||||
if (!bot.lastArticle) {
|
||||
bot.lastArticle = children[0];
|
||||
await bot.debug.send(
|
||||
`No cache found. Caching ${children[0]} and skipping run.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const laterChildren = children.slice(0, children.indexOf(bot.lastArticle));
|
||||
bot.lastArticle = children[0];
|
||||
for (const postId of laterChildren) {
|
||||
const article = await getArticleInfo(bot, postId);
|
||||
if (!article) {
|
||||
continue;
|
||||
}
|
||||
await bot.news.send(
|
||||
`[${article.title}](<https://www.aljazeera.com${article.link.replace(
|
||||
/\/$/,
|
||||
""
|
||||
)}?update=${article.id}>)\n--------`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
await bot.debug.send(
|
||||
`Error in fetching news feed: ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
};
|
@ -1,10 +0,0 @@
|
||||
import { Webhooks } from "../config/Webhooks";
|
||||
|
||||
/**
|
||||
* Checks if the webhook ID is present in the enum.
|
||||
*
|
||||
* @param {string} id The ID to check.
|
||||
* @returns {boolean} If the webhook ID is valid.
|
||||
*/
|
||||
export const isValidWebhook = (id: string): id is Webhooks =>
|
||||
(Object.values(Webhooks) as string[]).includes(id);
|
@ -1,24 +0,0 @@
|
||||
import { createLogger, format, transports, config } from "winston";
|
||||
|
||||
const { combine, timestamp, colorize, printf } = format;
|
||||
|
||||
/**
|
||||
* Standard log handler, using winston to wrap and format
|
||||
* messages. Call with `logHandler.log(level, message)`.
|
||||
*
|
||||
* @param {string} level - The log level to use.
|
||||
* @param {string} message - The message to log.
|
||||
*/
|
||||
export const logHandler = createLogger({
|
||||
levels: config.npm.levels,
|
||||
level: "silly",
|
||||
transports: [new transports.Console()],
|
||||
format: combine(
|
||||
timestamp({
|
||||
format: "YYYY-MM-DD HH:mm:ss",
|
||||
}),
|
||||
colorize(),
|
||||
printf((info) => `${info.level}: ${[info.timestamp]}: ${info.message}`)
|
||||
),
|
||||
exitOnError: false,
|
||||
});
|
@ -1,7 +0,0 @@
|
||||
import { assert } from "chai";
|
||||
|
||||
suite("This is an example test", () => {
|
||||
test("It uses the assert API", () => {
|
||||
assert.isTrue(true);
|
||||
});
|
||||
});
|
@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "@nhcarrigan/typescript-config",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./prod"
|
||||
},
|
||||
"exclude": ["./test"]
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user