feat: migrate away from trello (#2)

Closes #1

Reviewed-on: https://codeberg.org/nhcarrigan/a4p-bot/pulls/2
Co-authored-by: Naomi <commits@nhcarrigan.com>
Co-committed-by: Naomi <commits@nhcarrigan.com>
This commit is contained in:
Naomi Carrigan 2024-05-21 05:11:11 +00:00 committed by Naomi Carrigan
parent bbd6a710bb
commit a85b478dba
16 changed files with 2182 additions and 1856 deletions

View File

@ -15,7 +15,7 @@
}, },
"engines": { "engines": {
"node": "20", "node": "20",
"pnpm": "8" "pnpm": "9"
}, },
"keywords": [ "keywords": [
"template", "template",

3490
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +0,0 @@
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])
}

View File

@ -41,6 +41,4 @@ export const TrelloComments: {
[Webhooks.NewCommissions]: (userName) => `Artwork claimed by ${userName}.`, [Webhooks.NewCommissions]: (userName) => `Artwork claimed by ${userName}.`,
[Webhooks.CompleteCommissions]: (userName) => [Webhooks.CompleteCommissions]: (userName) =>
`Distribution claimed by ${userName}.`, `Distribution claimed by ${userName}.`,
[Webhooks.NewTest]: (userName) => `Artwork claimed by ${userName}.`,
[Webhooks.CompleteTest]: (userName) => `Distribution claimed by ${userName}.`,
}; };

View File

@ -1,25 +1,19 @@
import { Message } from "discord.js"; import { User } from "discord.js";
export enum Webhooks { export enum Webhooks {
NewCommissions = "1172850885571911760", NewCommissions = "1172850885571911760",
CompleteCommissions = "1173062416041525259", CompleteCommissions = "1173062416041525259",
NewTest = "1173009530511171634",
CompleteTest = "946487942526935060",
} }
/** /**
* Grabs the message from Discord, formats it based on the webhook ID. * Grabs the user from Discord, formats it based on the webhook ID.
* *
* @param {Message} message The message payload from Discord. * @param {User} u The user payload from Discord.
* @returns {string} The formatted text to send. * @returns {string} The formatted text to send.
*/ */
export const DMTexts: { [key in Webhooks]: (message: Message) => string } = { export const ClaimTexts: { [key in Webhooks]: (u: User) => string } = {
[Webhooks.NewCommissions]: (message) => [Webhooks.NewCommissions]: (u) =>
`[Here is the donation commission you claimed~!](${message.url})\n> ${message.content}`, `Thanks for agreeing to do this art, <@!${u.id}>~! When it's completed, upload the image here and I'll automatically close this thread!`,
[Webhooks.CompleteCommissions]: (message) => [Webhooks.CompleteCommissions]: (u) =>
`[Thank you for agreeing to deliver the following commission~!](${message.url})\n> ${message.content}`, `Thanks for agreeing to deliver this art, <@!${u.id}>~! When it's completed, upload a confirmation image here and I'll automatically close this thread!`,
[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

@ -1,4 +1,9 @@
import { Message, PermissionFlagsBits } from "discord.js"; import {
ChannelType,
Message,
PermissionFlagsBits,
TextChannel,
} from "discord.js";
import { ExtendedClient } from "../interface/ExtendedClient"; import { ExtendedClient } from "../interface/ExtendedClient";
import { logTicketMessage } from "../modules/logTicketMessage"; import { logTicketMessage } from "../modules/logTicketMessage";
@ -106,6 +111,48 @@ export const onMessageCreate = async (
} }
} }
} }
if (message.channel.type === ChannelType.PublicThread) {
if (message.channel.parentId === "1172568787330019340") {
const { attachments } = message;
if (!attachments.size) {
return;
}
await message.reply({
content:
"Accepting this as submission and closing out art request. Thanks for your support!",
});
const starter = await message.channel.fetchStarterMessage();
const distChannel = (await message.channel.guild.channels.fetch(
"1173061747737903315"
)) as TextChannel;
if (!starter || !distChannel) {
await message.channel.send(
"<@!465650873650118659>, couldn't port this to distributor channel. Please port manually~!"
);
await message.channel.setArchived(true);
return;
}
await message.channel.setArchived(true);
await distChannel.send({
content: starter.content,
files: [...attachments.values()],
});
}
if (message.channel.parentId === "1173061747737903315") {
const { attachments } = message;
if (!attachments.size) {
return;
}
await message.reply({
content:
"Accepting this as delivery confirmation and closing out distribution request. Thanks for your support!",
});
await message.channel.setArchived(true);
}
}
return; return;
} }
if (message.author.bot && !isValidWebhook(message.author.id)) { if (message.author.bot && !isValidWebhook(message.author.id)) {

View File

@ -2,11 +2,12 @@ import {
MessageReaction, MessageReaction,
PartialMessageReaction, PartialMessageReaction,
PartialUser, PartialUser,
TextChannel,
ThreadAutoArchiveDuration,
User, User,
} from "discord.js"; } from "discord.js";
import { TrelloComments } from "../config/Trello"; import { ClaimTexts } from "../config/Webhooks";
import { DMTexts } from "../config/Webhooks";
import { ExtendedClient } from "../interface/ExtendedClient"; import { ExtendedClient } from "../interface/ExtendedClient";
import { isValidWebhook } from "../utils/isValidWebhook"; import { isValidWebhook } from "../utils/isValidWebhook";
@ -49,58 +50,24 @@ export const onReactionAdd = async (
await r.users.remove(user.id); await r.users.remove(user.id);
return; return;
} }
const claimedByUser = await bot.db.rewards.findMany({
where: { const channel = r.message.channel as TextChannel;
claimedBy: user.id, const claimedByUser = (await channel.threads.fetchActive()).threads.filter(
completed: false, (t) => t.name === user.id
}, );
}); if (claimedByUser.size >= 2) {
if (claimedByUser.length >= 2) {
await r.users.remove(user.id); await r.users.remove(user.id);
await user.send( 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." "You currently have 2 open art rewards. Please do not claim another until you have completed one of your outstanding art works."
); );
return; return;
} }
const files = [...message.attachments.values()]; const thread = await channel.threads.create({
await user autoArchiveDuration: ThreadAutoArchiveDuration.OneWeek,
.send({ name: user.id,
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}`,
}); });
}); await thread.members.add(user.id);
const trelloId = await thread.send(ClaimTexts[message.author.id](user as User));
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) { } catch (err) {
await bot.debug.send( await bot.debug.send(
`Error in processing new reaction: ${(err as Error).message}` `Error in processing new reaction: ${(err as Error).message}`

View File

@ -4,8 +4,9 @@ import { ExtendedClient } from "../interface/ExtendedClient";
import { checkAirtableRecords } from "../modules/checkAirtableRecords"; import { checkAirtableRecords } from "../modules/checkAirtableRecords";
import { fetchMessages } from "../modules/messages/fetchMessages"; import { fetchMessages } from "../modules/messages/fetchMessages";
import { sendUnclaimedArt } from "../modules/reminders/sendUnclaimedArt"; import { sendUnclaimedArt } from "../modules/reminders/sendUnclaimedArt";
import { sendUnclaimedDistros } from "../modules/reminders/sendUnclaimedDistros";
import { sendUnfinishedArt } from "../modules/reminders/sendUnfinishedArt"; import { sendUnfinishedArt } from "../modules/reminders/sendUnfinishedArt";
import { serve } from "../server/serve"; import { sendUnfinishedDistros } from "../modules/reminders/sendUnfinsihedDistros";
import { getNewsFeed } from "../utils/getNewsFeed"; import { getNewsFeed } from "../utils/getNewsFeed";
/** /**
@ -29,13 +30,13 @@ export const onReady = async (bot: ExtendedClient) => {
scheduleJob("0 9 * * 1,3,5", async () => { scheduleJob("0 9 * * 1,3,5", async () => {
await sendUnclaimedArt(bot); await sendUnclaimedArt(bot);
await sendUnclaimedDistros(bot);
}); });
scheduleJob("0 9 * * 6", async () => { scheduleJob("0 9 * * 6", async () => {
await sendUnfinishedArt(bot); await sendUnfinishedArt(bot);
await sendUnfinishedDistros(bot);
}); });
await serve(bot);
} catch (err) { } catch (err) {
await bot.debug.send(`Error on ready event: ${(err as Error).message}`); await bot.debug.send(`Error on ready event: ${(err as Error).message}`);
} }

View File

@ -1,6 +1,5 @@
import { execSync } from "child_process"; import { execSync } from "child_process";
import { PrismaClient } from "@prisma/client";
import { Client, Events, GatewayIntentBits, WebhookClient } from "discord.js"; import { Client, Events, GatewayIntentBits, WebhookClient } from "discord.js";
import { onInteractionCreate } from "./events/onInteractionCreate"; import { onInteractionCreate } from "./events/onInteractionCreate";
@ -32,14 +31,12 @@ import { logHandler } from "./utils/logHandler";
GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMembers,
], ],
}) as ExtendedClient; }) as ExtendedClient;
bot.db = new PrismaClient();
bot.debug = new WebhookClient({ url: process.env.DEBUG }); bot.debug = new WebhookClient({ url: process.env.DEBUG });
bot.comm = new WebhookClient({ url: process.env.COMM }); bot.comm = new WebhookClient({ url: process.env.COMM });
bot.dist = new WebhookClient({ url: process.env.DIST }); bot.dist = new WebhookClient({ url: process.env.DIST });
bot.news = new WebhookClient({ url: process.env.NEWS }); bot.news = new WebhookClient({ url: process.env.NEWS });
bot.ticket = new WebhookClient({ url: process.env.TICKET }); bot.ticket = new WebhookClient({ url: process.env.TICKET });
bot.ticketLogs = {}; bot.ticketLogs = {};
await bot.db.$connect();
const commit = execSync("git rev-parse HEAD").toString().trim(); const commit = execSync("git rev-parse HEAD").toString().trim();

View File

@ -1,8 +1,6 @@
import { PrismaClient } from "@prisma/client";
import { Client, WebhookClient } from "discord.js"; import { Client, WebhookClient } from "discord.js";
export interface ExtendedClient extends Client { export interface ExtendedClient extends Client {
db: PrismaClient;
debug: WebhookClient; debug: WebhookClient;
comm: WebhookClient; comm: WebhookClient;
dist: WebhookClient; dist: WebhookClient;

View File

@ -3,7 +3,6 @@ import { join } from "path";
import { AttachmentBuilder } from "discord.js"; import { AttachmentBuilder } from "discord.js";
import { ActionsToLabel, PlatformsToLabel, Trello } from "../config/Trello";
import { AirtableResponse } from "../interface/AirtableResponse"; import { AirtableResponse } from "../interface/AirtableResponse";
import { ExtendedClient } from "../interface/ExtendedClient"; import { ExtendedClient } from "../interface/ExtendedClient";
@ -44,12 +43,9 @@ export const checkAirtableRecords = async (bot: ExtendedClient) => {
const { const {
Name: name, Name: name,
Reference: images, Reference: images,
Action: action,
"Contact Method": platform, "Contact Method": platform,
Handle: handle, Handle: handle,
"Anything Else?": note, "Anything Else?": note,
"Email Address": email,
"What would you like us to draw?": request,
} = record.fields; } = record.fields;
const files: AttachmentBuilder[] = []; const files: AttachmentBuilder[] = [];
for (const imageUrl of images) { for (const imageUrl of images) {
@ -61,119 +57,12 @@ export const checkAirtableRecords = async (bot: ExtendedClient) => {
}); });
files.push(file); 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({ await bot.comm.send({
content: `${name} | Trello failed to generate. | References attached below:`, content: `**FOR:** ${name} - Contact via ${platform}: ${handle}\n\n${note}\n\nReferences:`,
files, files,
}); });
return; 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) { } catch (err) {
await bot.debug.send( await bot.debug.send(
`Error in check airtable records: ${(err as Error).message}` `Error in check airtable records: ${(err as Error).message}`

View File

@ -1,3 +1,5 @@
import { TextChannel } from "discord.js";
import { ExtendedClient } from "../../interface/ExtendedClient"; import { ExtendedClient } from "../../interface/ExtendedClient";
/** /**
@ -7,16 +9,19 @@ import { ExtendedClient } from "../../interface/ExtendedClient";
*/ */
export const sendUnclaimedArt = async (bot: ExtendedClient) => { export const sendUnclaimedArt = async (bot: ExtendedClient) => {
try { try {
const unclaimed = await bot.db.rewards.findMany({ const guild = await bot.guilds.fetch("1172566005311090798");
where: { const artChannel = (await guild.channels.fetch(
claimedBy: "", "1172568787330019340"
}, )) as TextChannel;
const messages = await artChannel.messages.fetch({
after: "1241326793294610443",
}); });
if (!unclaimed.length) { const filtered = messages.filter((m) => !m.hasThread);
if (!filtered.size) {
return; return;
} }
const guild = await bot.guilds.fetch("1172566005311090798");
const channel = await guild.channels.fetch("1172568865218252801"); const channel = await guild.channels.fetch("1172568865218252801");
if (!channel || !("send" in channel)) { if (!channel || !("send" in channel)) {
@ -28,12 +33,7 @@ export const sendUnclaimedArt = async (bot: ExtendedClient) => {
The following art rewards have not been claimed yet. If you have the capacity to do so, please consider taking one on. The following art rewards have not been claimed yet. If you have the capacity to do so, please consider taking one on.
${unclaimed ${filtered.map((r) => `- ${r.url}`).join("\n")}`);
.map(
(r) =>
`- https://discord.com/channels/1172566005311090798/1172568787330019340/${r.messageId}`
)
.join("\n")}`);
} catch (err) { } catch (err) {
await bot.debug.send(`Cannot send unclaimed art reminder: ${err}`); await bot.debug.send(`Cannot send unclaimed art reminder: ${err}`);
} }

View File

@ -0,0 +1,40 @@
import { TextChannel } from "discord.js";
import { ExtendedClient } from "../../interface/ExtendedClient";
/**
* Sends a reminder to claim the unclaimed distribution requests.
*
* @param {ExtendedClient} bot The bot's Discord instance.
*/
export const sendUnclaimedDistros = async (bot: ExtendedClient) => {
try {
const guild = await bot.guilds.fetch("1172566005311090798");
const artChannel = (await guild.channels.fetch(
"1173061747737903315"
)) as TextChannel;
const messages = await artChannel.messages.fetch({
after: "1241055357112025168",
});
const filtered = messages.filter((m) => !m.hasThread);
if (!filtered.size) {
return;
}
const channel = await guild.channels.fetch("1173061809834565764");
if (!channel || !("send" in channel)) {
await bot.debug.send(`Failed to find <#1173061809834565764>~!`);
return;
}
await channel.send(`## Unclaimed Distribtions!
The following art rewards have not been distributed yet. If you have the capacity to do so, please consider taking one on.
${filtered.map((r) => `- ${r.url}`).join("\n")}`);
} catch (err) {
await bot.debug.send(`Cannot send unclaimed distro reminder: ${err}`);
}
};

View File

@ -1,3 +1,5 @@
import { TextChannel } from "discord.js";
import { ExtendedClient } from "../../interface/ExtendedClient"; import { ExtendedClient } from "../../interface/ExtendedClient";
/** /**
@ -7,36 +9,16 @@ import { ExtendedClient } from "../../interface/ExtendedClient";
*/ */
export const sendUnfinishedArt = async (bot: ExtendedClient) => { export const sendUnfinishedArt = async (bot: ExtendedClient) => {
try { 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 guild = await bot.guilds.fetch("1172566005311090798");
const channel = await guild.channels.fetch("1172568865218252801"); const channel = (await guild.channels.fetch(
"1172568787330019340"
if (!channel || !("send" in channel)) { )) as TextChannel;
await bot.debug.send(`Failed to find <#1172568865218252801>~!`); const threads = await channel.threads.fetchActive();
return; for (const thread of threads.threads.values()) {
await thread.send({
content: `<@!${thread.name}>, this art appears unfinished. When it's complete, please upload the image here and the thread will be processed.`,
});
} }
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) { } catch (err) {
await bot.debug.send(`Cannot send unfinished art reminder: ${err}`); await bot.debug.send(`Cannot send unfinished art reminder: ${err}`);
} }

View File

@ -0,0 +1,25 @@
import { TextChannel } from "discord.js";
import { ExtendedClient } from "../../interface/ExtendedClient";
/**
* Sends a reminder to finish claimed distribution requests.
*
* @param {ExtendedClient} bot The bot's Discord instance.
*/
export const sendUnfinishedDistros = async (bot: ExtendedClient) => {
try {
const guild = await bot.guilds.fetch("1172566005311090798");
const channel = (await guild.channels.fetch(
"1173061747737903315"
)) as TextChannel;
const threads = await channel.threads.fetchActive();
for (const thread of threads.threads.values()) {
await thread.send({
content: `<@!${thread.name}>, this distribution appears unconfirmed. When it's complete, please upload a confirmation image here and the thread will be processed.`,
});
}
} catch (err) {
await bot.debug.send(`Cannot send unfinished art reminder: ${err}`);
}
};

View File

@ -11,6 +11,8 @@ import { ExtendedClient } from "../interface/ExtendedClient";
/** /**
* Instantiates the web server for listening to webhook payloads. * Instantiates the web server for listening to webhook payloads.
* *
* TODO: Delete this entirely once all trello things are done.
*
* @param {ExtendedClient} bot The bot's Discord instance. * @param {ExtendedClient} bot The bot's Discord instance.
*/ */
export const serve = async (bot: ExtendedClient) => { export const serve = async (bot: ExtendedClient) => {
@ -80,14 +82,6 @@ export const serve = async (bot: ExtendedClient) => {
await bot.dist.send({ 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}`, 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); const httpServer = http.createServer(app);