generated from nhcarrigan/template
feat: migrate from github
This commit is contained in:
commit
bbd6a710bb
12
.eslintrc.json
Normal file
12
.eslintrc.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@nhcarrigan",
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["src/modules/buttons/*.ts"],
|
||||
"rules": {
|
||||
"jsdoc/require-param": "off",
|
||||
"jsdoc/require-returns": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
8
.gitattributes
vendored
Normal file
8
.gitattributes
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text eol=LF
|
||||
*.ts text
|
||||
*.spec.ts text
|
||||
|
||||
# Ignore binary files >:(
|
||||
*.png binary
|
||||
*.jpg binary
|
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/node_modules/
|
||||
/prod/
|
||||
.env
|
||||
/logs/*.txt
|
||||
latestAirtableRecord.txt
|
1
.prettierrc.json
Normal file
1
.prettierrc.json
Normal file
@ -0,0 +1 @@
|
||||
"@nhcarrigan/prettier-config"
|
3
CODE_OF_CONDUCT.md
Normal file
3
CODE_OF_CONDUCT.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Code of Conduct
|
||||
|
||||
Our Code of Conduct can be found here: https://docs.nhcarrigan.com/#/coc
|
3
CONTRIBUTING.md
Normal file
3
CONTRIBUTING.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Contributing
|
||||
|
||||
Our contributing guidelines can be found here: https://docs.nhcarrigan.com/#/contributing
|
5
LICENSE.md
Normal file
5
LICENSE.md
Normal file
@ -0,0 +1,5 @@
|
||||
# License
|
||||
|
||||
This software is licensed under our [global software license](https://docs.nhcarrigan.com/#/license).
|
||||
|
||||
Copyright held by Naomi Carrigan.
|
3
PRIVACY.md
Normal file
3
PRIVACY.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Privacy Policy
|
||||
|
||||
Our privacy policy can be found here: https://docs.nhcarrigan.com/#/privacy
|
29
README.md
Normal file
29
README.md
Normal file
@ -0,0 +1,29 @@
|
||||
# Art for Palestine Bot
|
||||
|
||||
This is a bot for the [Art for Palestine](https://art4palestine.org) charity event. It serves as a bridge between AirTable, Trello, and Discord.
|
||||
|
||||
## Live Version
|
||||
|
||||
This page is currently deployed. [View the live website.](https://discord.gg/kHNyb6Vyf8)
|
||||
|
||||
## Feedback and Bugs
|
||||
|
||||
If you have feedback or a bug report, please feel free to open a GitHub issue!
|
||||
|
||||
## Contributing
|
||||
|
||||
If you would like to contribute to the project, you may create a Pull Request containing your proposed changes and we will review it as soon as we are able! Please review our [contributing guidelines](CONTRIBUTING.md) first.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Before interacting with our community, please read our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
## License
|
||||
|
||||
This software is licensed under our [global software license](https://docs.nhcarrigan.com/#/license).
|
||||
|
||||
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`.
|
3
SECURITY.md
Normal file
3
SECURITY.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Security Policy
|
||||
|
||||
Our security policy can be found here: https://docs.nhcarrigan.com/#/security
|
3
TERMS.md
Normal file
3
TERMS.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Terms of Service
|
||||
|
||||
Our Terms of Service can be found here: https://docs.nhcarrigan.com/#/terms
|
1
logs/.gitkeep
Normal file
1
logs/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
We need this for the ticket system.
|
58
package.json
Normal file
58
package.json
Normal file
@ -0,0 +1,58 @@
|
||||
{
|
||||
"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": "20",
|
||||
"pnpm": "8"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
2948
pnpm-lock.yaml
generated
Normal file
2948
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
prisma/schema.prisma
Normal file
22
prisma/schema.prisma
Normal file
@ -0,0 +1,22 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mongodb"
|
||||
url = env("MONGO_URI")
|
||||
}
|
||||
|
||||
model rewards {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
trelloId String
|
||||
messageId String
|
||||
createdAt Int @default(0)
|
||||
claimedBy String @default("") // user who claimed
|
||||
completed Boolean @default(false)
|
||||
|
||||
@@unique([trelloId], map: "trello")
|
||||
@@unique([messageId], map: "message")
|
||||
@@index([claimedBy, completed])
|
||||
@@index([claimedBy])
|
||||
}
|
18
sample.env
Normal file
18
sample.env
Normal file
@ -0,0 +1,18 @@
|
||||
# Bot
|
||||
TOKEN=""
|
||||
MONGO_URI=""
|
||||
|
||||
# Hooks
|
||||
DEBUG=""
|
||||
COMM=""
|
||||
DIST=""
|
||||
NEWS=""
|
||||
|
||||
#Env
|
||||
NODE_ENV="development"
|
||||
|
||||
# Trello
|
||||
TRELLO_KEY=""
|
||||
TRELLO_TOKEN=""
|
||||
TRELLO_SECRET=""
|
||||
TRELLO_HOOK_CALLBACK=""
|
1
src/config/Tickets.ts
Normal file
1
src/config/Tickets.ts
Normal file
@ -0,0 +1 @@
|
||||
export const TicketSupportRole = "1173582640843063366";
|
46
src/config/Trello.ts
Normal file
46
src/config/Trello.ts
Normal file
@ -0,0 +1,46 @@
|
||||
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}.`,
|
||||
[Webhooks.NewTest]: (userName) => `Artwork claimed by ${userName}.`,
|
||||
[Webhooks.CompleteTest]: (userName) => `Distribution claimed by ${userName}.`,
|
||||
};
|
25
src/config/Webhooks.ts
Normal file
25
src/config/Webhooks.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Message } from "discord.js";
|
||||
|
||||
export enum Webhooks {
|
||||
NewCommissions = "1172850885571911760",
|
||||
CompleteCommissions = "1173062416041525259",
|
||||
NewTest = "1173009530511171634",
|
||||
CompleteTest = "946487942526935060",
|
||||
}
|
||||
|
||||
/**
|
||||
* Grabs the message from Discord, formats it based on the webhook ID.
|
||||
*
|
||||
* @param {Message} message The message payload from Discord.
|
||||
* @returns {string} The formatted text to send.
|
||||
*/
|
||||
export const DMTexts: { [key in Webhooks]: (message: Message) => string } = {
|
||||
[Webhooks.NewCommissions]: (message) =>
|
||||
`[Here is the donation commission you claimed~!](${message.url})\n> ${message.content}`,
|
||||
[Webhooks.CompleteCommissions]: (message) =>
|
||||
`[Thank you for agreeing to deliver the following commission~!](${message.url})\n> ${message.content}`,
|
||||
[Webhooks.NewTest]: (message) =>
|
||||
`[Here is the donation commission you claimed~!](${message.url})\n> ${message.content}`,
|
||||
[Webhooks.CompleteTest]: (message) =>
|
||||
`[Thank you for agreeing to deliver the following commission~!](${message.url})\n> ${message.content}`,
|
||||
};
|
45
src/events/onInteractionCreate.ts
Normal file
45
src/events/onInteractionCreate.ts
Normal file
@ -0,0 +1,45 @@
|
||||
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}`
|
||||
);
|
||||
}
|
||||
};
|
20
src/events/onMemberAdd.ts
Normal file
20
src/events/onMemberAdd.ts
Normal file
@ -0,0 +1,20 @@
|
||||
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}`
|
||||
);
|
||||
}
|
||||
};
|
130
src/events/onMessageCreate.ts
Normal file
130
src/events/onMessageCreate.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import { 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: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
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}`
|
||||
);
|
||||
}
|
||||
};
|
109
src/events/onReactionAdd.ts
Normal file
109
src/events/onReactionAdd.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import {
|
||||
MessageReaction,
|
||||
PartialMessageReaction,
|
||||
PartialUser,
|
||||
User,
|
||||
} from "discord.js";
|
||||
|
||||
import { TrelloComments } from "../config/Trello";
|
||||
import { DMTexts } 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 claimedByUser = await bot.db.rewards.findMany({
|
||||
where: {
|
||||
claimedBy: user.id,
|
||||
completed: false,
|
||||
},
|
||||
});
|
||||
if (claimedByUser.length >= 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 files = [...message.attachments.values()];
|
||||
await user
|
||||
.send({
|
||||
content: DMTexts[message.author.id](message),
|
||||
files,
|
||||
})
|
||||
.catch(async (err) => {
|
||||
await bot.debug.send({
|
||||
content: `[Failed to DM ${user.username}:](<${r.message.url}>) ${err.message}`,
|
||||
});
|
||||
});
|
||||
const trelloId =
|
||||
message.content.split("You can ignore this it's just for the bot. ")[1] ||
|
||||
message.content.split(
|
||||
"You can ignore this ID it's just for the bot: "
|
||||
)[1];
|
||||
if (trelloId) {
|
||||
await fetch(
|
||||
`https://api.trello.com/1/cards/${trelloId}/actions/comments?text=${TrelloComments[
|
||||
message.author.id
|
||||
](user.displayName || user.username || user.id)}&key=${
|
||||
process.env.TRELLO_KEY
|
||||
}&token=${process.env.TRELLO_TOKEN}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
await bot.db.rewards.update({
|
||||
where: {
|
||||
trelloId,
|
||||
},
|
||||
data: {
|
||||
claimedBy: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
await bot.debug.send(
|
||||
`Error in processing new reaction: ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
};
|
42
src/events/onReady.ts
Normal file
42
src/events/onReady.ts
Normal file
@ -0,0 +1,42 @@
|
||||
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 { sendUnfinishedArt } from "../modules/reminders/sendUnfinishedArt";
|
||||
import { serve } from "../server/serve";
|
||||
import { getNewsFeed } from "../utils/getNewsFeed";
|
||||
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
|
||||
scheduleJob("0 9 * * 6", async () => {
|
||||
await sendUnfinishedArt(bot);
|
||||
});
|
||||
|
||||
await serve(bot);
|
||||
} catch (err) {
|
||||
await bot.debug.send(`Error on ready event: ${(err as Error).message}`);
|
||||
}
|
||||
};
|
76
src/index.ts
Normal file
76
src/index.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { execSync } from "child_process";
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
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.db = new PrismaClient();
|
||||
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 = {};
|
||||
await bot.db.$connect();
|
||||
|
||||
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);
|
||||
})();
|
71
src/interface/AirtableResponse.ts
Normal file
71
src/interface/AirtableResponse.ts
Normal file
@ -0,0 +1,71 @@
|
||||
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;
|
||||
};
|
||||
}[];
|
||||
}
|
8
src/interface/ButtonHandler.ts
Normal file
8
src/interface/ButtonHandler.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { ButtonInteraction } from "discord.js";
|
||||
|
||||
import { ExtendedClient } from "./ExtendedClient";
|
||||
|
||||
export type ButtonHandler = (
|
||||
Bot: ExtendedClient,
|
||||
interaction: ButtonInteraction<"cached">
|
||||
) => Promise<void>;
|
13
src/interface/ExtendedClient.ts
Normal file
13
src/interface/ExtendedClient.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { Client, WebhookClient } from "discord.js";
|
||||
|
||||
export interface ExtendedClient extends Client {
|
||||
db: PrismaClient;
|
||||
debug: WebhookClient;
|
||||
comm: WebhookClient;
|
||||
dist: WebhookClient;
|
||||
news: WebhookClient;
|
||||
ticket: WebhookClient;
|
||||
lastArticle: number;
|
||||
ticketLogs: { [key: string]: string };
|
||||
}
|
78
src/modules/buttons/ticketClaim.ts
Normal file
78
src/modules/buttons/ticketClaim.ts
Normal file
@ -0,0 +1,78 @@
|
||||
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.",
|
||||
});
|
||||
}
|
||||
};
|
60
src/modules/buttons/ticketClose.ts
Normal file
60
src/modules/buttons/ticketClose.ts
Normal file
@ -0,0 +1,60 @@
|
||||
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.",
|
||||
});
|
||||
}
|
||||
};
|
41
src/modules/buttons/ticketOpen.ts
Normal file
41
src/modules/buttons/ticketOpen.ts
Normal file
@ -0,0 +1,41 @@
|
||||
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.",
|
||||
});
|
||||
}
|
||||
};
|
182
src/modules/checkAirtableRecords.ts
Normal file
182
src/modules/checkAirtableRecords.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
|
||||
import { AttachmentBuilder } from "discord.js";
|
||||
|
||||
import { ActionsToLabel, PlatformsToLabel, Trello } from "../config/Trello";
|
||||
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,
|
||||
Action: action,
|
||||
"Contact Method": platform,
|
||||
Handle: handle,
|
||||
"Anything Else?": note,
|
||||
"Email Address": email,
|
||||
"What would you like us to draw?": request,
|
||||
} = 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);
|
||||
}
|
||||
|
||||
if (!process.env.TRELLO_TOKEN || !process.env.TRELLO_KEY) {
|
||||
await bot.debug.send({
|
||||
content:
|
||||
"Cannot create card on Trello. Missing environment variables.",
|
||||
});
|
||||
await bot.comm.send({
|
||||
content: `${name} | Trello failed to generate. | References attached below:`,
|
||||
files,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const cardRes = await fetch(
|
||||
`https://api.trello.com/1/cards?idList=${Trello.newCardListId}&key=${
|
||||
process.env.TRELLO_KEY
|
||||
}&token=${process.env.TRELLO_TOKEN}&name=${encodeURIComponent(
|
||||
name
|
||||
)}&desc=${encodeURIComponent(
|
||||
`${name} helped us with: ${action}\n\nPlease contact them at ${
|
||||
platform === "Email" ? email : `${handle} on ${platform}`
|
||||
} to update them on art.\n\nUSER NOTE: ${note}\nART REQUEST: ${request}`
|
||||
)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
const card = (await cardRes.json()) as { id: string; url: string };
|
||||
await fetch(
|
||||
`https://api.trello.com/1/cards/${card.id}/attachments?key=${
|
||||
process.env.TRELLO_KEY
|
||||
}&token=${
|
||||
process.env.TRELLO_TOKEN
|
||||
}&name=reference.png&url=${encodeURIComponent(
|
||||
images[0]?.url ?? "https://cdn.naomi.lgbt/banner.png"
|
||||
)}&setCover=true`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
for (const imageUrl of images.slice(1)) {
|
||||
await fetch(
|
||||
`https://api.trello.com/1/cards/${card.id}/attachments?key=${
|
||||
process.env.TRELLO_KEY
|
||||
}&token=${
|
||||
process.env.TRELLO_TOKEN
|
||||
}&name=reference.png&url=${encodeURIComponent(imageUrl.url)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
// Use this if we want to add checklist to every card :3
|
||||
// await fetch(
|
||||
// `https://api.trello.com/1/cards/${
|
||||
// card.id
|
||||
// }/checklists?idChecklistSource=${
|
||||
// Trello.checklistId
|
||||
// }&name=${encodeURIComponent("To Do")}&key=${
|
||||
// process.env.TRELLO_KEY
|
||||
// }&token=${process.env.TRELLO_TOKEN}`,
|
||||
// {
|
||||
// method: "POST",
|
||||
// headers: {
|
||||
// accept: "application/json",
|
||||
// },
|
||||
// }
|
||||
// );
|
||||
if (PlatformsToLabel[platform]) {
|
||||
await fetch(
|
||||
`https://api.trello.com/1/cards/${card.id}/idLabels?value=${PlatformsToLabel[platform]}&key=${process.env.TRELLO_KEY}&token=${process.env.TRELLO_TOKEN}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
if (ActionsToLabel[action]) {
|
||||
await fetch(
|
||||
`https://api.trello.com/1/cards/${card.id}/idLabels?value=${ActionsToLabel[action]}&key=${process.env.TRELLO_KEY}&token=${process.env.TRELLO_TOKEN}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
const msg = await bot.comm.send({
|
||||
content: `${name} | [Trello](<${card.url}>) | References attached below.\nYou can ignore this ID it's just for the bot: ${card.id}`,
|
||||
files,
|
||||
});
|
||||
await bot.db.rewards.create({
|
||||
data: {
|
||||
trelloId: card.id,
|
||||
createdAt: Date.now(),
|
||||
claimedBy: "",
|
||||
completed: false,
|
||||
messageId: msg.id,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
await bot.debug.send(
|
||||
`Error in check airtable records: ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
};
|
32
src/modules/createLogsFile.ts
Normal file
32
src/modules/createLogsFile.ts
Normal file
@ -0,0 +1,32 @@
|
||||
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}`
|
||||
);
|
||||
}
|
||||
};
|
46
src/modules/generateLogs.ts
Normal file
46
src/modules/generateLogs.ts
Normal file
@ -0,0 +1,46 @@
|
||||
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" }
|
||||
);
|
||||
}
|
||||
};
|
39
src/modules/logTicketMessage.ts
Normal file
39
src/modules/logTicketMessage.ts
Normal file
@ -0,0 +1,39 @@
|
||||
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}`
|
||||
);
|
||||
}
|
||||
};
|
40
src/modules/messages/fetchMessages.ts
Normal file
40
src/modules/messages/fetchMessages.ts
Normal file
@ -0,0 +1,40 @@
|
||||
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: `` });
|
||||
}
|
||||
};
|
48
src/modules/messages/startTicketPost.ts
Normal file
48
src/modules/messages/startTicketPost.ts
Normal file
@ -0,0 +1,48 @@
|
||||
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.",
|
||||
});
|
||||
}
|
||||
};
|
82
src/modules/modals/handleTicketModal.ts
Normal file
82
src/modules/modals/handleTicketModal.ts
Normal file
@ -0,0 +1,82 @@
|
||||
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.",
|
||||
});
|
||||
}
|
||||
};
|
40
src/modules/reminders/sendUnclaimedArt.ts
Normal file
40
src/modules/reminders/sendUnclaimedArt.ts
Normal file
@ -0,0 +1,40 @@
|
||||
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 unclaimed = await bot.db.rewards.findMany({
|
||||
where: {
|
||||
claimedBy: "",
|
||||
},
|
||||
});
|
||||
if (!unclaimed.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const guild = await bot.guilds.fetch("1172566005311090798");
|
||||
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.
|
||||
|
||||
${unclaimed
|
||||
.map(
|
||||
(r) =>
|
||||
`- https://discord.com/channels/1172566005311090798/1172568787330019340/${r.messageId}`
|
||||
)
|
||||
.join("\n")}`);
|
||||
} catch (err) {
|
||||
await bot.debug.send(`Cannot send unclaimed art reminder: ${err}`);
|
||||
}
|
||||
};
|
43
src/modules/reminders/sendUnfinishedArt.ts
Normal file
43
src/modules/reminders/sendUnfinishedArt.ts
Normal file
@ -0,0 +1,43 @@
|
||||
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 unfinished = await bot.db.rewards.findMany({
|
||||
where: {
|
||||
claimedBy: {
|
||||
not: "",
|
||||
},
|
||||
completed: false,
|
||||
},
|
||||
});
|
||||
if (!unfinished.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const guild = await bot.guilds.fetch("1172566005311090798");
|
||||
const channel = await guild.channels.fetch("1172568865218252801");
|
||||
|
||||
if (!channel || !("send" in channel)) {
|
||||
await bot.debug.send(`Failed to find <#1172568865218252801>~!`);
|
||||
return;
|
||||
}
|
||||
|
||||
await channel.send(`## Unfinished Art!
|
||||
|
||||
The following art rewards have not been completed yet. We would like to keep a turnaround time of a couple of weeks, but your health is also important! So if you have claimed one of these and don't have the capacity to finish it, let Angel or Naomi know.
|
||||
|
||||
${unfinished
|
||||
.map(
|
||||
(r) =>
|
||||
`- https://discord.com/channels/1172566005311090798/1172568787330019340/${r.messageId}`
|
||||
)
|
||||
.join("\n")}`);
|
||||
} catch (err) {
|
||||
await bot.debug.send(`Cannot send unfinished art reminder: ${err}`);
|
||||
}
|
||||
};
|
125
src/server/serve.ts
Normal file
125
src/server/serve.ts
Normal file
@ -0,0 +1,125 @@
|
||||
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.
|
||||
*
|
||||
* @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.status(302).redirect("https://art4palestine.org/");
|
||||
});
|
||||
|
||||
/**
|
||||
* Trello expects a 200 head status.
|
||||
*/
|
||||
app.head("/trello", (_req, res) => {
|
||||
res.status(200).send("OK~");
|
||||
});
|
||||
|
||||
app.post("/trello", async (req, res) => {
|
||||
const secret = process.env.TRELLO_SECRET;
|
||||
if (!secret) {
|
||||
res.status(500).send("Missing trello key~!");
|
||||
await bot.debug.send({
|
||||
content:
|
||||
"Received a request to the trello endpoint, but key is not configured.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const headerHash = req.headers["x-trello-webhook"];
|
||||
const getDigest = (s: string) =>
|
||||
createHmac("sha1", secret).update(s).digest("base64");
|
||||
const content = JSON.stringify(req.body) + process.env.TRELLO_HOOK_CALLBACK;
|
||||
const contentHash = getDigest(content);
|
||||
if (headerHash !== contentHash) {
|
||||
res.status(403).send("Invalid hash!");
|
||||
await bot.debug.send({
|
||||
content:
|
||||
"Received a request to the trello endpoint, but hash was incorrect.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.status(200).send("OK~!");
|
||||
const oldList = req.body.action?.data?.old?.idList;
|
||||
const newList = req.body.action?.data?.card?.idList;
|
||||
const cardId = req.body.action?.data?.card?.id;
|
||||
if (
|
||||
!oldList ||
|
||||
!newList ||
|
||||
!cardId ||
|
||||
oldList === newList ||
|
||||
newList !== Trello.readyToSendListId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cardRes = await fetch(
|
||||
`https://api.trello.com/1/cards/${cardId}?key=${process.env.TRELLO_KEY}&token=${process.env.TRELLO_TOKEN}`
|
||||
);
|
||||
const card = (await cardRes.json()) as {
|
||||
id: string;
|
||||
url: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
};
|
||||
const contact = card.desc
|
||||
.split(/\n+/g)
|
||||
.find((s) => s.startsWith("Please contact them"));
|
||||
await bot.dist.send({
|
||||
content: `${card.name} | [Trello Card](<${card.url}>) | ${contact}\nYou can ignore this ID it's just for the bot: ${card.id}`,
|
||||
});
|
||||
await bot.db.rewards.update({
|
||||
where: {
|
||||
trelloId: card.id,
|
||||
},
|
||||
data: {
|
||||
completed: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
}
|
||||
};
|
24
src/utils/getMuteDurationUnit.ts
Normal file
24
src/utils/getMuteDurationUnit.ts
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 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;
|
||||
};
|
119
src/utils/getNewsFeed.ts
Normal file
119
src/utils/getNewsFeed.ts
Normal file
@ -0,0 +1,119 @@
|
||||
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}`
|
||||
);
|
||||
}
|
||||
};
|
10
src/utils/isValidWebhook.ts
Normal file
10
src/utils/isValidWebhook.ts
Normal file
@ -0,0 +1,10 @@
|
||||
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);
|
24
src/utils/logHandler.ts
Normal file
24
src/utils/logHandler.ts
Normal file
@ -0,0 +1,24 @@
|
||||
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,
|
||||
});
|
7
test/index.spec.ts
Normal file
7
test/index.spec.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { assert } from "chai";
|
||||
|
||||
suite("This is an example test", () => {
|
||||
test("It uses the assert API", () => {
|
||||
assert.isTrue(true);
|
||||
});
|
||||
});
|
8
tsconfig.json
Normal file
8
tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@nhcarrigan/typescript-config",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./prod"
|
||||
},
|
||||
"exclude": ["./test"]
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user