Skip to content

Commit a02c3c5

Browse files
authored
feat(src): auto-set waiting-for-user/team tags on help posts (#46)
* feat(src): auto-set waiting-for-user/team tags on help posts React to help post messages to keep a waiting tag in sync with the last interaction: a community member's message marks the post waiting-for-team, a Coder team member's message marks it waiting-for-user. On startup, reconcile the most recently active open help posts to recover any state missed while offline. Team membership is resolved from the new teamRoleIds config; new helpChannel.waitingForUserTag/waitingForTeamTag and startupCatchupLimit options are added. * feat(src/commands): add /update-thread to manually re-sync waiting tag Lets moderators (members with the Manage Channels permission) manually run the waiting-tag reconciliation on the current help post. The command is gated both at the Discord permission level and at runtime. * refactor(src/lib/discord/help): dedupe waiting-tag reconciliation Extract applyWaitingTagFromMessage so reconcileFromMessage and reconcileThread share the member resolution and team-detection logic. * docs(src/events/messages): fix help posts comment typo
1 parent c655de4 commit a02c3c5

8 files changed

Lines changed: 214 additions & 2 deletions

File tree

config.json.example

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,15 @@
44
"helpChannel": {
55
"closedTag": "1006926031434813500",
66
"id": "1006346052317753414",
7-
"openedTag": "1409314109773582487"
7+
"openedTag": "1409314109773582487",
8+
"waitingForUserTag": "1382030514571186327",
9+
"waitingForTeamTag": "1536933491315314868"
810
},
911

12+
"teamRoleId": "776843361662271568",
13+
14+
"startupCatchupLimit": 20,
15+
1016
"emojis": {
1117
"coder": "1387459034508034058",
1218
"linux": "1078434842309566575",

src/commands/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { default as product_notes } from "./product/notes.js";
1010

1111
import { default as close } from "./util/close.js";
1212
import { default as reopen } from "./util/reopen.js";
13+
import { default as updateThread } from "./util/update-thread.js";
1314
import { default as walkthrough } from "./util/walkthrough.js";
1415

1516
type AnyCommandBuilder =
@@ -27,7 +28,13 @@ const commandObject: {
2728
};
2829
} = {};
2930

30-
for (const command of [product_notes, close, reopen, walkthrough]) {
31+
for (const command of [
32+
product_notes,
33+
close,
34+
reopen,
35+
updateThread,
36+
walkthrough,
37+
]) {
3138
commandObject[command.data.name] = command;
3239
}
3340

src/commands/util/update-thread.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { config } from "@lib/config.js";
2+
import {
3+
getChannelFromInteraction,
4+
isHelpPost,
5+
} from "@lib/discord/channels.js";
6+
import { reconcileThread } from "@lib/discord/help.js";
7+
8+
import {
9+
type ChatInputCommandInteraction,
10+
type ThreadChannel,
11+
MessageFlags,
12+
PermissionFlagsBits,
13+
SlashCommandBuilder,
14+
} from "discord.js";
15+
16+
export default {
17+
data: new SlashCommandBuilder()
18+
.setName("update-thread")
19+
.setDescription(
20+
"Re-sync this help post's waiting tag with the last message",
21+
)
22+
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
23+
24+
execute: async (interaction: ChatInputCommandInteraction) => {
25+
const channel = await getChannelFromInteraction(interaction);
26+
27+
if (!(await isHelpPost(channel))) {
28+
await interaction.reply({
29+
content: `You can only run this command in a <#${config.helpChannel.id}> post.`,
30+
flags: [MessageFlags.Ephemeral],
31+
});
32+
return;
33+
}
34+
35+
const member = await interaction.guild.members.fetch(interaction.user.id);
36+
if (!member.permissions.has(PermissionFlagsBits.ManageChannels)) {
37+
await interaction.reply({
38+
content: "You do not have permission to run this command.",
39+
flags: [MessageFlags.Ephemeral],
40+
});
41+
return;
42+
}
43+
44+
await reconcileThread(channel as ThreadChannel);
45+
46+
await interaction.reply({
47+
content: "Updated this post's waiting tag.",
48+
flags: [MessageFlags.Ephemeral],
49+
});
50+
},
51+
};

src/events/messages.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { type Client, Events, MessageType } from "discord.js";
22

3+
import { isHelpPost } from "@lib/discord/channels.js";
4+
import { reconcileFromMessage } from "@lib/discord/help.js";
5+
36
export default function registerEvents(client: Client) {
47
return client.on(Events.MessageCreate, async (message) => {
58
// If the bot pins a message, then we delete the automatic announcement message
@@ -8,6 +11,12 @@ export default function registerEvents(client: Client) {
811
message.author.id === client.user.id
912
) {
1013
await message.delete();
14+
return;
15+
}
16+
17+
// Keep the help posts' waiting tag in sync with the latest interaction.
18+
if (message.inGuild() && (await isHelpPost(message.channel))) {
19+
await reconcileFromMessage(message);
1120
}
1221
});
1322
}

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { config } from "./lib/config.js";
2+
import { catchUpHelpPosts } from "./lib/discord/help.js";
23

34
import registerCommandEvents from "./events/commands.js";
45
import registerWalkthroughEvents from "./events/walkthrough.js";
@@ -42,6 +43,10 @@ client.once(Events.ClientReady, () => {
4243

4344
shufflePresence();
4445
setInterval(shufflePresence, config.presenceDelay);
46+
47+
catchUpHelpPosts(client).catch((err) =>
48+
console.error("Failed to catch up on help posts:", err),
49+
);
4550
});
4651

4752
client.login(config.token);

src/lib/config.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,18 @@ interface Config {
1010

1111
closedTag: string;
1212
openedTag: string;
13+
14+
waitingForUserTag: string;
15+
waitingForTeamTag: string;
1316
};
1417

18+
// Role that identifies Coder team members. Anyone without this role is
19+
// treated as a community member.
20+
teamRoleId: string;
21+
22+
// Number of most recently active open help posts to reconcile on startup.
23+
startupCatchupLimit: number;
24+
1525
emojis: {
1626
coder: string;
1727
linux: string;
@@ -40,6 +50,7 @@ export const { config, layers } = await loadConfig<Config>({
4050

4151
defaults: {
4252
presenceDelay: 10 * 60 * 1000,
53+
startupCatchupLimit: 20,
4354
},
4455
mandatory: [
4556
"token",
@@ -49,6 +60,10 @@ export const { config, layers } = await loadConfig<Config>({
4960
["helpChannel", "id"],
5061
["helpChannel", "closedTag"],
5162
["helpChannel", "openedTag"],
63+
["helpChannel", "waitingForUserTag"],
64+
["helpChannel", "waitingForTeamTag"],
65+
66+
"teamRoleId",
5267

5368
["emojis", "coder"],
5469
["emojis", "linux"],

src/lib/discord/help.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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+
}

src/lib/discord/users.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
import { config } from "@lib/config.js";
2+
3+
import type { GuildMember } from "discord.js";
4+
15
export function getClientIDFromToken(token: string): string {
26
return atob(token.split(".")[0]);
37
}
8+
9+
// A member is part of the Coder team if they hold the configured team role.
10+
// Everyone else is treated as a community member.
11+
export function isTeamMember(member: GuildMember): boolean {
12+
return member.roles.cache.has(config.teamRoleId);
13+
}

0 commit comments

Comments
 (0)