Skip to content

Commit

Permalink
add a number of features at once:
Browse files Browse the repository at this point in the history
1. Add self-destruct messages and cleanup
2. Auto-dedupe messages (delete duplicate messages from users)
3. add botask and botdoublemsg reactions
4. auto-deploy commands and emoji on startup
  • Loading branch information
kentcdodds committed Jul 22, 2022
1 parent a50a0d8 commit 6709cdf
Show file tree
Hide file tree
Showing 20 changed files with 245 additions and 11 deletions.
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,6 @@ COPY --from=build /myapp/package.json /myapp/package.json
COPY --from=build /myapp/start.sh /myapp/start.sh
COPY --from=build /myapp/prisma /myapp/prisma
COPY --from=build /myapp/bot /myapp/bot
COPY --from=build /myapp/scripts /myapp/scripts

ENTRYPOINT [ "./start.sh" ]
50 changes: 50 additions & 0 deletions bot/src/admin/cleanup-self-destruct-messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type * as TDiscord from "discord.js";
import { cleanupGuildOnInterval, getSelfDestructTime } from "./utils";

async function cleanup(guild: TDiscord.Guild) {
const channels = Array.from(guild.channels.cache.values()).filter((ch) =>
ch.isText()
) as Array<TDiscord.TextBasedChannel>;
if (!guild.client.user) return;

const botId = guild.client.user.id;
const promises = [];

for (const channel of channels) {
for (const message of Array.from(channel.messages.cache.values())) {
if (message.author.id === botId) {
const timeToSelfDestruct = getSelfDestructTime(message.content);
if (
typeof timeToSelfDestruct === "number" &&
message.createdAt.getTime() + timeToSelfDestruct < Date.now()
) {
promises.push(message.delete());
}
}
}
}

return Promise.all(promises);
}

async function setup(client: TDiscord.Client) {
// prime the message cache for all channels
// this is important for situations when the bot gets restarted after
// it had just sent a self-destruct chat
await Promise.all(
Array.from(client.guilds.cache.values()).map(async (guild) => {
const channels = Array.from(guild.channels.cache.values()).filter((ch) =>
ch.isText()
) as Array<TDiscord.TextBasedChannel>;
return Promise.all(
Array.from(channels.values()).map((channel) => {
return channel.messages.fetch({ limit: 30 });
})
);
})
);

cleanupGuildOnInterval(client, (guild) => cleanup(guild), 5000);
}

export { setup };
76 changes: 76 additions & 0 deletions bot/src/admin/dedupe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type * as TDiscord from "discord.js";
import { getMessageLink, sendBotMessageReply } from "./utils";

const sixHours = 1000 * 60 * 60 * 6;

function isLink(text: string) {
try {
// eslint-disable-next-line no-new
new URL(text.trim());
return true;
} catch {
return false;
}
}

async function dedupeMessages(message: TDiscord.Message) {
const { guild } = message;
if (!guild) return;

if (message.author.id === message.client.user?.id) return; // ignore the bot
if (!message.channel.isText()) return; // ignore non-text channels
if (message.content.length < 50) return; // ignore short messages
if (isLink(message.content)) return; // ignore links, gifs/blog posts/etc.

const channels = Array.from(
guild.channels.cache.filter((ch) => ch.isText()).values()
) as Array<TDiscord.TextBasedChannel>;

function msgFilter(msg: TDiscord.Message) {
return (
msg.id !== message.id && // not the EXACT same message
msg.author.id !== msg.client.user?.id && // not from the bot
msg.author.id === message.author.id && // from the same user
new Date().getTime() - msg.createdAt.getTime() < sixHours && // within the last six hours
msg.content.length > 50 && // longer than 50 characters
msg.content.toLowerCase() === message.content.toLowerCase() // same content
);
}

let duplicateMessage;
for (const channel of channels) {
duplicateMessage = Array.from(channel.messages.cache.values()).find(
msgFilter
);
if (duplicateMessage) break;
}

if (duplicateMessage) {
await message.delete();
const duplicateMessageLink = getMessageLink(duplicateMessage);

sendBotMessageReply(
message,
`
Hi ${message.author}, I deleted a message you just posted because it's a duplicate of this one: <${duplicateMessageLink}>. Please give it time for users to respond to your first post.
If you think your message is better suited in another channel please delete the first one then repost. Thank you.
`.trim()
);
}
}

function setup(client: TDiscord.Client) {
// prime the message cache for relevant channels
const guild = client.guilds.cache.find(({ name }) => name === "KCD");
if (!guild) return;
const channels = Array.from(
guild.channels.cache.filter((ch) => ch.isText()).values()
) as Array<TDiscord.TextBasedChannel>;
for (const channel of channels) {
// ignore the returned promise. Fire and forget.
void channel.messages.fetch({ limit: 30 });
}
}

export { dedupeMessages as handleNewMessage, setup };
11 changes: 11 additions & 0 deletions bot/src/admin/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type * as TDiscord from "discord.js";
import * as dedupeMessages from "./dedupe";
import * as selfDestruct from "./cleanup-self-destruct-messages";

function setup(client: TDiscord.Client) {
client.on("message", dedupeMessages.handleNewMessage);
dedupeMessages.setup(client);
selfDestruct.setup(client);
}

export { setup };
1 change: 1 addition & 0 deletions bot/src/admin/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "../utils";
36 changes: 27 additions & 9 deletions bot/src/reactions/reactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ type ReactionFn = {
const reactions: Record<string, ReactionFn> = {
bothelp: help,
botreport: report,
botremixide: remixide,
botremixmusic: remixmusic,
botreportresume: reportresume,
botremixide: remixIDE,
botremixmusic: remixMusic,
botreportresume: reportResume,
botask: ask,
botdoublemsg: doubleMessage,
} as const;

async function help(messageReaction: TDiscord.MessageReaction) {
Expand Down Expand Up @@ -134,35 +136,51 @@ async function report(messageReaction: TDiscord.MessageReaction) {
}
report.description = "Reports a message to the server moderators to look at.";

async function remixide(messageReaction: TDiscord.MessageReaction) {
async function remixIDE(messageReaction: TDiscord.MessageReaction) {
void messageReaction.remove();
messageReaction.message.reply(
`
Hello 👋 I think you may be in the wrong place. This discord server is all about the Remix Web Framework which you can learn about at <https://remix.run>. You may have mixed this up with the Remix IDE (<https://remix-project.org/>) which is a completely different project. If that's the case, please delete your message. If not, can you please clarify? Thanks!
`.trim()
);
}
remixide.description =
remixIDE.description =
"Replies to the message explaining that this is not the Remix IDE discord server.";

async function remixmusic(messageReaction: TDiscord.MessageReaction) {
async function remixMusic(messageReaction: TDiscord.MessageReaction) {
void messageReaction.remove();
messageReaction.message.reply(
`
Hello 👋 I think you may be in the wrong place. This discord server is all about the Remix Web Framework which you can learn about at <https://remix.run>. You may have mixed this up with the idea of a "Musical Remix" which is cool, but not what this place is for. If that's the case, please delete your message. If not, can you please clarify? Thanks!
`.trim()
);
}
remixmusic.description = `Replies to the message explaining that this is not a "Remix Music" discord server.`;
remixMusic.description = `Replies to the message explaining that this is not a "Remix Music" discord server.`;

async function reportresume(messageReaction: TDiscord.MessageReaction) {
async function reportResume(messageReaction: TDiscord.MessageReaction) {
void messageReaction.remove();
messageReaction.message.reply(
`
Hello 👋 This channel is for employers to post open job opportunities for Remix developers, not for job seekers to post their resume. Thanks!
`.trim()
);
}
reportresume.description = `Replies to the message explaining that this channel is not for posting your resume, but for employers post open Remix opportunities.`;
reportResume.description = `Replies to the message explaining that this channel is not for posting your resume, but for employers post open Remix opportunities.`;

async function ask(messageReaction: TDiscord.MessageReaction) {
void messageReaction.remove();
await messageReaction.message.reply(
`We appreciate your question and we'll do our best to help you when we can. Could you please give us more details? Please follow the guidelines in <https://rmx.as/ask> (especially the part about making a <https://rmx.as/repro>) and then we'll try to answer your question.`
);
}
ask.description = `Replies to the message asking for more details about a question.`;

async function doubleMessage(messageReaction: TDiscord.MessageReaction) {
void messageReaction.remove();
await messageReaction.message.reply(
`Please avoid posting the same thing in multiple channels. Choose the best channel, and wait for a response there. Please delete the other message to avoid fragmenting the answers and causing confusion. Thanks!`
);
}
doubleMessage.description = `Replies to the message telling the user to avoid posting the same question in multiple channels.`;

export default reactions;
2 changes: 2 additions & 0 deletions bot/src/start.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as Discord from "discord.js";
import * as commands from "./commands";
import * as reactions from "./reactions";
import * as admin from "./admin";

export async function start() {
const client = new Discord.Client({
Expand All @@ -15,6 +16,7 @@ export async function start() {
// setup all parts of the bot here
commands.setup(client);
reactions.setup(client);
admin.setup(client);

console.log("logging in...");
await client.login(process.env.DISCORD_BOT_TOKEN);
Expand Down
77 changes: 75 additions & 2 deletions bot/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type * as TDiscord from "discord.js";
import { HTTPError } from "discord.js";
import { getBotLogChannel } from "./channels";
import { getBotLogChannel, getTalkToBotsChannel } from "./channels";
import { setIntervalAsync } from "set-interval-async/dynamic";

export const getMessageLink = (
Expand Down Expand Up @@ -66,7 +66,7 @@ export function botLog(
}

// read up on dynamic setIntervalAsync here: https://github.com/ealmansi/set-interval-async#dynamic-and-fixed-setintervalasync
function cleanupGuildOnInterval(
export function cleanupGuildOnInterval(
client: TDiscord.Client,
cb: (client: TDiscord.Guild) => Promise<unknown>,
interval: number
Expand Down Expand Up @@ -109,6 +109,79 @@ async function hasReactionFromUser(
return usersWhoReacted.some((user) => user.id === host.id);
}

export async function sendBotMessageReply(
msg: TDiscord.Message,
reply: string
) {
const { guild, channel } = msg;
if (!guild) return;

const botsChannel = getTalkToBotsChannel(guild);
if (!botsChannel) return;

if (botsChannel.id === channel.id) {
// if they sent this from the bot's channel then we'll just send the reply
return botsChannel.send(reply);
} else {
// otherwise, we'll send the reply in the bots channel and let them know
// where they can get the reply.
const botMsg = await botsChannel.send(
`
_Replying to ${msg.author} <${getMessageLink(msg)}>_
${reply}
`.trim()
);
if (channel.isText()) {
return sendSelfDestructMessage(
channel,
`Hey ${msg.author}, I sent you a message here: ${getMessageLink(
botMsg
)}`,
{ time: 7, units: "seconds" }
);
}
}
}
const timeToMs = {
seconds: (t: number) => t * 1000,
minutes: (t: number) => t * 1000 * 60,
hours: (t: number) => t * 1000 * 60 * 60,
days: (t: number) => t * 1000 * 60 * 60 * 24,
weeks: (t: number) => t * 1000 * 60 * 60 * 24 * 7,
};

export async function sendSelfDestructMessage(
channel: TDiscord.TextBasedChannel,
messageContent: string,
{
time = 10,
units = "seconds",
}: { time?: number; units?: keyof typeof timeToMs } = {}
) {
return channel.send(
`
${messageContent}
_This message will self-destruct in about ${time} ${units}_
`.trim()
);
}

export function getSelfDestructTime(messageContent: string) {
const supportedUnits = Object.keys(timeToMs).join("|");
const regex = new RegExp(
`self-destruct in about (?<time>\\d+) (?<units>${supportedUnits})`,
"i"
);
const match = messageContent.match(regex);
if (!match) return null;
const { units, time } = match.groups as {
time: string;
units: keyof typeof timeToMs;
};
return timeToMs[units](Number(time));
}

export * from "./build-info";
export * as colors from "./colors";
export * from "./channels";
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
Binary file added scripts/bot-emoji/botreport.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added scripts/bot-emoji/botreportresume.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
2 changes: 2 additions & 0 deletions start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@

set -ex
npx prisma migrate deploy
node ./scripts/deploy-commands.js
node ./scripts/deploy-emoji.js
npm run start

0 comments on commit 6709cdf

Please sign in to comment.