feat: migrate from github

This commit is contained in:
Naomi Carrigan 2024-05-12 01:15:42 -07:00
commit bbd6a710bb
No known key found for this signature in database
GPG Key ID: 7019A402E94A9808
47 changed files with 4756 additions and 0 deletions

12
.eslintrc.json Normal file
View 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
View 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
View File

@ -0,0 +1,5 @@
/node_modules/
/prod/
.env
/logs/*.txt
latestAirtableRecord.txt

1
.prettierrc.json Normal file
View File

@ -0,0 +1 @@
"@nhcarrigan/prettier-config"

3
CODE_OF_CONDUCT.md Normal file
View 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
View File

@ -0,0 +1,3 @@
# Contributing
Our contributing guidelines can be found here: https://docs.nhcarrigan.com/#/contributing

5
LICENSE.md Normal file
View 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
View File

@ -0,0 +1,3 @@
# Privacy Policy
Our privacy policy can be found here: https://docs.nhcarrigan.com/#/privacy

29
README.md Normal file
View 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
View File

@ -0,0 +1,3 @@
# Security Policy
Our security policy can be found here: https://docs.nhcarrigan.com/#/security

3
TERMS.md Normal file
View 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
View File

@ -0,0 +1 @@
We need this for the ticket system.

58
package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

22
prisma/schema.prisma Normal file
View 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
View 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
View File

@ -0,0 +1 @@
export const TicketSupportRole = "1173582640843063366";

46
src/config/Trello.ts Normal file
View 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
View 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}`,
};

View 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
View 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}`
);
}
};

View 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
View 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
View 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
View 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);
})();

View 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;
};
}[];
}

View File

@ -0,0 +1,8 @@
import { ButtonInteraction } from "discord.js";
import { ExtendedClient } from "./ExtendedClient";
export type ButtonHandler = (
Bot: ExtendedClient,
interaction: ButtonInteraction<"cached">
) => Promise<void>;

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

View 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.",
});
}
};

View 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.",
});
}
};

View 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.",
});
}
};

View 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}`
);
}
};

View 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}`
);
}
};

View 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" }
);
}
};

View 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}`
);
}
};

View 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: `` });
}
};

View 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.",
});
}
};

View 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.",
});
}
};

View 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}`);
}
};

View 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
View 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");
});
}
};

View 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
View 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}`
);
}
};

View 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
View 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
View 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
View File

@ -0,0 +1,8 @@
{
"extends": "@nhcarrigan/typescript-config",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./prod"
},
"exclude": ["./test"]
}