Skip to content

Commit

Permalink
Archive helper: Add a helper for archiving old threads in a channel (#…
Browse files Browse the repository at this point in the history
…285)

The command `\help me archive <channel>` goes through all unarchived
threads >14 days old and tags me to investigate if they're archivable.
This is going to be a manual process because life is hard 😉

Also included here is a full rework of the thread management script into
multiple subscripts that are more focused.
  • Loading branch information
Shadowfiend authored Jan 4, 2024
2 parents 60d359c + ce59b24 commit fefc635
Show file tree
Hide file tree
Showing 8 changed files with 299 additions and 146 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ChannelType, Client, TextChannel } from "discord.js"
import { Client, TextChannel } from "discord.js"
import { Robot } from "hubot"
import moment from "moment"

Expand All @@ -18,15 +18,36 @@ export default async function webhookDiscord(
discordClient: Client,
robot: Robot,
) {
robot.hear(/blow everything up/, async (msg) => {
robot.hear(/help me archive (.+)/, async (msg) => {
const archiveChannelName = msg.match[1]

const guild = discordClient.guilds.cache.first()
if (guild === undefined) {
msg.send("No guild found.")
msg.send("Failed to resolve Discord server.")
return
}
const channels = await guild.channels.fetch()
const archiveThreshold = weekdaysBefore(moment(), 4)
channels

const archiveChannel =
channels.get(archiveChannelName) ??
channels.find(
(channel) =>
channel !== null &&
channel.isTextBased() &&
!channel.isDMBased() &&
channel.name.toLowerCase() === archiveChannelName.toLowerCase(),
) ??
undefined

if (archiveChannel === undefined) {
msg.send("No matching channel found.")
return
}

const archiveThreshold = weekdaysBefore(moment(), 14)

// channels
Array.from([archiveChannel])
.filter(
(channel): channel is TextChannel =>
channel !== null && channel.isTextBased() && channel.viewable,
Expand All @@ -52,20 +73,9 @@ export default async function webhookDiscord(
lastActivity.isBefore(archiveThreshold),
)

const message = `Threads to archive for ${
channel.name
}:\n- ${threadsWithDates
.map(
({ thread, lastActivity }) =>
`${
thread.type === ChannelType.PrivateThread
? "[private]"
: thread.name
}: ${lastActivity.toLocaleString()}`,
)
.join("\n- ")}`
console.log(message)
msg.reply(message)
threadsWithDates[0]?.thread?.send(
"@ogshadowfiend check archive status here, please.",
)
} catch (err) {
console.error(

Check warning on line 80 in discord-scripts/help-me-archive.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected console statement

Check warning on line 80 in discord-scripts/help-me-archive.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected console statement

Check warning on line 80 in discord-scripts/help-me-archive.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected console statement
`Error for ${channel.name}: `,
Expand Down
157 changes: 56 additions & 101 deletions discord-scripts/thread-management.ts
Original file line number Diff line number Diff line change
@@ -1,110 +1,65 @@
import { ChannelType, Client } from "discord.js"
import { isInRecreationalCategory } from "../lib/discord/utils.ts"
import fs from "fs"
import { Client } from "discord.js"
import path from "path"
import { Robot } from "hubot"
import { fileURLToPath } from "url"
import { DiscordEventHandlers } from "../lib/discord/utils.ts"

// Emoji used to suggest a thread.
const THREAD_EMOJI = "🧵"

export default function manageThreads(discordClient: Client) {
// When a thread is created, join it.
//
// Additionally, quietly tag a role so that all members of it are subscribed
// to the thread (they may later leave the thread to opt out). The role that
// is tagged is, in order:
//
// - If the containing channel's category is recreational, no role.
// - If the containnig channel has a role with a matching name, that role
// (e.g., a message to #tech will tag a Tech role if it exists).
// - If the containing channel's category has a role with a matching name, that role
// (e.g., a message to #taho-standup inside the Taho category will tag the
// Taho role if it exists).
// - If the containing channel's category is General and the channel is
// #main, @everyone.
discordClient.on("threadCreate", async (thread) => {
await thread.join()

if (isInRecreationalCategory(thread)) {
return
}

const { guild: server, parent: containingChannel } = thread

if (
thread.type === ChannelType.PrivateThread &&
containingChannel?.name?.toLowerCase() !== "operations"
) {
await thread.send(
"Private threads should largely only be used for discussions around " +
"confidential topics like legal and hiring. They should as a result " +
"almost always be created in #operations; if you know you're " +
"breaking both rules on purpose, go forth and conquer, but otherwise " +
"please start the thread there. I'm also going to auto-tag the " +
"appropriate roles now, which may compromise the privacy of the " +
"thread (**all members of the role who have access to this channel " +
"will have access to the thread**).",
)
}

const placeholder = await thread.send("<placeholder>")

const matchingRole = server.roles.cache.find(
(role) =>
role.name.toLowerCase() === containingChannel?.name.toLowerCase(),
)

if (matchingRole !== undefined) {
await placeholder.edit(matchingRole.toString())
return
}

const categoryChannel = containingChannel?.parent
const categoryMatchingRole = server.roles.cache.find(
(role) => role.name.toLowerCase() === categoryChannel?.name.toLowerCase(),
export default function manageThreads(discordClient: Client, robot: Robot) {
fs.readdirSync(
path.join(
path.dirname(fileURLToPath(import.meta.url)),
"./thread-management",
),
)
.sort()
.filter(
(file) =>
[".ts", ".js"].includes(path.extname(file)) && !file.startsWith("_"),
)
.forEach(async (file) => {
try {
const threadManagementScript: { default: DiscordEventHandlers } =
await import(
path.join("..", "discord-scripts", "thread-management", file)
)

if (categoryMatchingRole !== undefined) {
await placeholder.edit(categoryMatchingRole.toString())
return
}
Object.entries(threadManagementScript.default).forEach(
([event, handler]) => {
discordClient.on(event, (...args) => {
const finalArgs = [...args, robot]
// @ts-expect-error We are doing some shenanigans here that TS can't
// handle to always pass a robot as the last parameter to the
// handler.
return handler(...finalArgs)
})
},
)

// Monstrous, delete the useless placeholder and pray for our soul.
// Placeholder code as we figure out the best way to handle the General
// category.
await placeholder.delete()
})
if ("setup" in threadManagementScript) {
;(
threadManagementScript.setup as (
robot: Robot,
client: Client,
) => Promise<void>
).call(undefined, robot, discordClient)
}

// Remind users to create a thread with a reacji for reply chains longer than
// 1 reply. Skip for messages in the recreational category.
discordClient.on("messageCreate", async (message) => {
// If we're already in a thread or this is the recreational category, do
// nothing.
const { channel } = message
if (channel.isThread() || isInRecreationalCategory(channel)) {
return
}
robot.logger.info(`Loaded Discord thread management script ${file}.`)
} catch (error) {
const stackString =
// Errors may have a stack trace, or not---anyone's guess!
// eslint-disable-next-line @typescript-eslint/no-explicit-any
"stack" in (error as any) ? `\n${(error as any).stack}` : ""

// If this message is not in reply to anything, do nothing.
if (
message.reference === null ||
message.reference.messageId === undefined
) {
return
}
const errorJson = JSON.stringify(error, null, 2)

// If the message replied to is not in reply to anythinbg, still do nothing.
const repliedMessage = await message.fetchReference()
if (
repliedMessage.reference === null ||
repliedMessage.reference.messageId === undefined
) {
return
}
const errorDescription =
errorJson.trim().length > 0 ? errorJson : String(error)

// Okay, now we've got a chain of two replies, suggest a thread via reacji
// on the original message---if it is indeed the original message in the
// chain.
const potentialOriginalMessage = await repliedMessage.fetchReference()
if (potentialOriginalMessage.reference === null) {
message.react(THREAD_EMOJI)
}
})
robot.logger.error(
`Failed to load Discord script ${file}: ${errorDescription}${stackString}`,
)
}
})
}
84 changes: 84 additions & 0 deletions discord-scripts/thread-management/auto-join.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { AnyThreadChannel } from "discord.js"
import {
DiscordEventHandlers,
isInRecreationalCategory,
} from "../../lib/discord/utils.ts"

// When a thread is created, join it.
//
// Additionally, quietly tag a role so that all members of it are subscribed
// to the thread (they may later leave the thread to opt out). The role that
// is tagged is, in order:
//
// - If the containing channel's category is recreational, no role.
// - If the containnig channel has a role with a matching name, that role
// (e.g., a message to #tech will tag a Tech role if it exists).
// - If the containing channel's category has a role with a matching name, that role
// (e.g., a message to #taho-standup inside the Taho category will tag the
// Taho role if it exists).
// - If the containing channel's category is General and the channel is
// #main, @everyone.
//
// Quiet tags are achieved by dropping a placeholder message and then editing
// it to mention the right role. Discord's behavior in this scenario is not to
// ping the role, but to add all its members to the thread.
async function autoJoinThread(
thread: AnyThreadChannel<boolean>,
): Promise<void> {
await thread.join()

if (isInRecreationalCategory(thread)) {
return
}

const { guild: server, parent: containingChannel } = thread

const placeholder = await thread.send("<placeholder>")

const matchingRole = server.roles.cache.find(
(role) => role.name.toLowerCase() === containingChannel?.name.toLowerCase(),
)

if (matchingRole !== undefined) {
await placeholder.edit(matchingRole.toString())
return
}

const categoryChannel = containingChannel?.parent
const categoryMatchingRole = server.roles.cache.find(
(role) => role.name.toLowerCase() === categoryChannel?.name.toLowerCase(),
)

if (categoryMatchingRole !== undefined) {
await placeholder.edit(categoryMatchingRole.toString())
return
}

if (
categoryChannel?.name?.toLowerCase()?.endsWith("general") === true &&
containingChannel?.name?.toLowerCase()?.endsWith("main") === true
) {
await placeholder.edit(server.roles.everyone.toString())
}

if (
categoryChannel?.name?.toLowerCase()?.endsWith("general") === true &&
containingChannel?.name?.toLowerCase()?.endsWith("bifrost") === true
) {
// The everyone role does not work the way other roles work; in particular,
// it does _not_ add everyone to the thread. Instead, it just sits there,
// looking pretty.
await placeholder.edit(server.roles.everyone.toString())
}

// If we hit this spot, be a monster and delete the useless placeholder and
// pray for our soul. Placeholder code as we figure out the best way to
// handle the General category.
await placeholder.delete()
}

const eventHandlers: DiscordEventHandlers = {
threadCreate: autoJoinThread,
}

export default eventHandlers
34 changes: 34 additions & 0 deletions discord-scripts/thread-management/private-threads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { AnyThreadChannel, channelMention } from "discord.js"
import { isPrivate } from "../../lib/discord/utils.ts"

const PRIVATE_THREAD_CHANNEL = { id: "1079520580228894771", name: "operations" }

async function privateThreadAdmonishment(
thread: AnyThreadChannel<boolean>,
): Promise<void> {
const { parent: containingChannel } = thread

if (
isPrivate(thread) &&
containingChannel?.id?.toLowerCase() !== PRIVATE_THREAD_CHANNEL.id
) {
await thread.send(
"Private threads should largely only be used for discussions around " +
"confidential topics like legal and hiring. They should as a result " +
`almost always be created in ${channelMention(
PRIVATE_THREAD_CHANNEL.id,
)}; if you know you're ` +
"breaking both rules on purpose, go forth and conquer, but otherwise " +
"please start the thread there. I'm also going to auto-tag the " +
"appropriate roles now, which may compromise the privacy of the " +
"thread (**all members of the role who have access to this channel " +
"will have access to the thread**).",
)
}
}

const eventHandlers = {
threadCreate: privateThreadAdmonishment,
}

export default eventHandlers
Loading

0 comments on commit fefc635

Please sign in to comment.