|
| 1 | +import { config } from "@lib/config.js"; |
| 2 | +import { isTeamMember } from "@lib/discord/users.js"; |
| 3 | + |
| 4 | +import { |
| 5 | + type Client, |
| 6 | + type GuildMember, |
| 7 | + type Message, |
| 8 | + type ThreadChannel, |
| 9 | + ChannelType, |
| 10 | + MessageType, |
| 11 | +} from "discord.js"; |
| 12 | + |
| 13 | +// Message types that represent an actual interaction from a person, as opposed |
| 14 | +// to system notices (pins, joins, etc). |
| 15 | +const humanMessageTypes = new Set([MessageType.Default, MessageType.Reply]); |
| 16 | + |
| 17 | +function isHumanMessage(message: Message): boolean { |
| 18 | + return !message.author.bot && humanMessageTypes.has(message.type); |
| 19 | +} |
| 20 | + |
| 21 | +// Picks the waiting tag for a help post based on who sent the last message. |
| 22 | +// When the last interaction comes from a community member the team still needs |
| 23 | +// to respond, so we apply waitingForTeamTag; when it comes from the Coder team |
| 24 | +// we apply waitingForUserTag. Adding one always removes the other. |
| 25 | +export async function applyWaitingTag( |
| 26 | + thread: ThreadChannel, |
| 27 | + lastFromTeam: boolean, |
| 28 | +): Promise<void> { |
| 29 | + const { waitingForUserTag, waitingForTeamTag, closedTag } = |
| 30 | + config.helpChannel; |
| 31 | + |
| 32 | + // Leave closed posts untouched. |
| 33 | + if (thread.appliedTags.includes(closedTag)) return; |
| 34 | + |
| 35 | + const desired = lastFromTeam ? waitingForUserTag : waitingForTeamTag; |
| 36 | + const opposite = lastFromTeam ? waitingForTeamTag : waitingForUserTag; |
| 37 | + |
| 38 | + const alreadyCorrect = |
| 39 | + thread.appliedTags.includes(desired) && |
| 40 | + !thread.appliedTags.includes(opposite); |
| 41 | + if (alreadyCorrect) return; |
| 42 | + |
| 43 | + // Forum posts allow at most 5 tags. Keep the desired tag and drop the |
| 44 | + // opposite one, trimming any overflow from the least recent tags. |
| 45 | + const nextTags = [ |
| 46 | + desired, |
| 47 | + ...thread.appliedTags.filter((t) => t !== desired && t !== opposite), |
| 48 | + ].slice(0, 5); |
| 49 | + |
| 50 | + await thread.setAppliedTags(nextTags, "Help post waiting state"); |
| 51 | +} |
| 52 | + |
| 53 | +async function resolveMember(message: Message): Promise<GuildMember | null> { |
| 54 | + if (message.member) return message.member; |
| 55 | + |
| 56 | + try { |
| 57 | + return await message.guild?.members.fetch(message.author.id); |
| 58 | + } catch { |
| 59 | + return null; |
| 60 | + } |
| 61 | +} |
| 62 | + |
| 63 | +// Applies the waiting tag for a help post based on who sent the given message. |
| 64 | +async function applyWaitingTagFromMessage( |
| 65 | + thread: ThreadChannel, |
| 66 | + message: Message, |
| 67 | +): Promise<void> { |
| 68 | + const member = await resolveMember(message); |
| 69 | + await applyWaitingTag(thread, member ? isTeamMember(member) : false); |
| 70 | +} |
| 71 | + |
| 72 | +// Reconciles a single help post from a freshly received message. |
| 73 | +export async function reconcileFromMessage(message: Message): Promise<void> { |
| 74 | + if (!isHumanMessage(message)) return; |
| 75 | + await applyWaitingTagFromMessage(message.channel as ThreadChannel, message); |
| 76 | +} |
| 77 | + |
| 78 | +// Reconciles a help post by inspecting its most recent human message. |
| 79 | +export async function reconcileThread(thread: ThreadChannel): Promise<void> { |
| 80 | + const messages = await thread.messages.fetch({ limit: 10 }); |
| 81 | + const lastHuman = messages.find(isHumanMessage); |
| 82 | + if (!lastHuman) return; |
| 83 | + await applyWaitingTagFromMessage(thread, lastHuman); |
| 84 | +} |
| 85 | + |
| 86 | +// On startup, reconcile the most recently active open help posts so their |
| 87 | +// waiting tag reflects the last interaction even if messages were missed while |
| 88 | +// the bot was offline. |
| 89 | +export async function catchUpHelpPosts(client: Client): Promise<void> { |
| 90 | + const forum = await client.channels.fetch(config.helpChannel.id); |
| 91 | + if (!forum || forum.type !== ChannelType.GuildForum) return; |
| 92 | + |
| 93 | + const { threads } = await forum.threads.fetchActive(); |
| 94 | + |
| 95 | + const openPosts = [...threads.values()] |
| 96 | + .filter((t) => !t.appliedTags.includes(config.helpChannel.closedTag)) |
| 97 | + .sort((a, b) => |
| 98 | + (b.lastMessageId ?? "").localeCompare(a.lastMessageId ?? ""), |
| 99 | + ) |
| 100 | + .slice(0, config.startupCatchupLimit); |
| 101 | + |
| 102 | + for (const thread of openPosts) { |
| 103 | + try { |
| 104 | + await reconcileThread(thread); |
| 105 | + } catch (err) { |
| 106 | + console.error(`Failed to reconcile help post ${thread.id}:`, err); |
| 107 | + } |
| 108 | + } |
| 109 | +} |
0 commit comments