Skip to content

Commit 3564291

Browse files
committed
rework help thread system
1 parent 6183fc6 commit 3564291

File tree

4 files changed

+253
-98
lines changed

4 files changed

+253
-98
lines changed

src/db.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { dbUrl } from './env';
33
import { RepUser } from './entities/RepUser';
44
import { RepGive } from './entities/RepGive';
55
import { HelpUser } from './entities/HelpUser';
6+
import { HelpThread } from './entities/HelpThread';
67
import { Snippet } from './entities/Snippet';
78

89
let db: Connection | undefined;
@@ -27,7 +28,7 @@ export async function getDB() {
2728
url: dbUrl,
2829
synchronize: true,
2930
logging: false,
30-
entities: [RepUser, RepGive, HelpUser, Snippet],
31+
entities: [RepUser, RepGive, HelpUser, HelpThread, Snippet],
3132
...extraOpts,
3233
});
3334
console.log('Connected to DB');

src/entities/HelpThread.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { BaseEntity, Column, Entity, PrimaryColumn } from 'typeorm';
2+
3+
@Entity()
4+
export class HelpThread extends BaseEntity {
5+
@PrimaryColumn()
6+
threadId!: string;
7+
8+
@Column()
9+
ownerId!: string;
10+
11+
// When @helper was last pinged
12+
@Column({ nullable: true })
13+
helperTimestamp?: string;
14+
}

src/modules/helpthread.ts

Lines changed: 174 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,168 @@
11
import { command, Module, listener } from 'cookiecord';
22
import { ThreadAutoArchiveDuration } from 'discord-api-types';
3-
import { Message, TextChannel, Channel, ThreadChannel } from 'discord.js';
4-
import { trustedRoleId, helpCategory, timeBeforeHelperPing } from '../env';
3+
import {
4+
Message,
5+
TextChannel,
6+
Channel,
7+
ThreadChannel,
8+
MessageEmbed,
9+
} from 'discord.js';
10+
import { threadId } from 'worker_threads';
11+
import { HelpThread } from '../entities/HelpThread';
12+
import {
13+
trustedRoleId,
14+
helpCategory,
15+
timeBeforeHelperPing,
16+
GREEN,
17+
BLOCKQUOTE_GREY,
18+
} from '../env';
519
import { isTrustedMember } from '../util/inhibitors';
20+
import { LimitedSizeMap } from '../util/limitedSizeMap';
21+
import { sendWithMessageOwnership } from '../util/send';
622

7-
const PINGED_HELPER_MESSAGE = '✅ Pinged helpers';
23+
const THREAD_EXPIRE_MESSAGE = new MessageEmbed()
24+
.setColor(BLOCKQUOTE_GREY)
25+
.setTitle('This help thread expired.').setDescription(`
26+
If your question was not resolved, you can make a new thread by simply asking your question again. \
27+
Consider rephrasing the question to maximize your chance of getting a good answer. \
28+
If you're not sure how, have a look through [StackOverflow's guide on asking a good question](https://stackoverflow.com/help/how-to-ask).
29+
`);
30+
31+
// A zero-width space (necessary to prevent discord from trimming the leading whitespace), followed by a three non-breaking spaces.
32+
const indent = '\u200b\u00a0\u00a0\u00a0';
33+
34+
const HELP_INFO = (channel: TextChannel) =>
35+
new MessageEmbed().setColor(GREEN).setTitle('How To Get Help')
36+
.setDescription(`
37+
${
38+
channel.topic
39+
? `This channel is for ${
40+
channel.topic[0].toLowerCase() +
41+
channel.topic.slice(1).split('\n')[0]
42+
}`
43+
: ''
44+
}
45+
46+
**To get help:**
47+
• Post your question to this channel.
48+
${indent}• It's always ok to just ask your question; you don't need permission.
49+
• Our bot will make a thread dedicated to answering your channel.
50+
• Someone will (hopefully!) come along and help you.
51+
• When your question is resolved, type \`!tclose\`.
52+
53+
**For better & faster answers:**
54+
• Explain what you want to happen and why…
55+
${indent}• …and what actually happens, and your best guess at why.
56+
• Include a short code sample and error messages, if you got any.
57+
${indent}• Text is better than screenshots. Start code blocks with ${'\\`\\`\\`ts'}.
58+
• If possible, create a minimal reproduction in the **[TypeScript Playground](https://www.typescriptlang.org/play)**.
59+
${indent}• Send the full link in its own message. Do not use a link shortener.
60+
• Run \`!title <brief description>\` to make your help thread easier to spot.
61+
62+
For more tips, check out StackOverflow's guide on **[asking good questions](https://stackoverflow.com/help/how-to-ask)**.
63+
64+
Usually someone will try to answer and help solve the issue within a few hours. \
65+
If not, and **if you have followed the bullets above**, you may ping helpers by running \`!helper\`. \
66+
Please allow extra time at night in America/Europe.
67+
`);
68+
69+
const helpInfoLocks = new Map<string, Promise<void>>();
70+
const manuallyArchivedThreads = new LimitedSizeMap<string, void>(100);
871

972
export class HelpThreadModule extends Module {
10-
@listener({ event: 'threadCreate' })
11-
async onNewThread(thread: ThreadChannel) {
12-
if (!this.isHelpThread(thread)) return;
13-
this.fixThreadExpiry(thread);
73+
@listener({ event: 'messageCreate' })
74+
async onNewQuestion(msg: Message) {
75+
if (!this.isHelpChannel(msg.channel)) return;
76+
if (msg.author.id === this.client.user!.id) return;
77+
this.updateHelpInfo(msg.channel);
78+
let thread = await msg.startThread({
79+
name: msg.member?.nickname ?? msg.author.username,
80+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
81+
});
82+
thread.setLocked(true);
83+
await HelpThread.create({
84+
threadId: thread.id,
85+
ownerId: msg.author.id,
86+
}).save();
1487
}
88+
1589
@listener({ event: 'threadUpdate' })
16-
async onThreadUpdated(thread: ThreadChannel) {
17-
if (!this.isHelpThread(thread)) return;
18-
this.fixThreadExpiry((await thread.fetch()) as ThreadChannel);
90+
async onThreadExpire(thread: ThreadChannel) {
91+
if (
92+
!this.isHelpThread(thread) ||
93+
manuallyArchivedThreads.has(thread.id) ||
94+
!((await thread.fetch()) as ThreadChannel).archived
95+
)
96+
return;
97+
await thread.send({ embeds: [THREAD_EXPIRE_MESSAGE] });
98+
await this.closeThread(thread);
1999
}
20100

21-
private async fixThreadExpiry(thread: ThreadChannel) {
22-
if (thread.autoArchiveDuration !== ThreadAutoArchiveDuration.OneDay)
23-
await thread.setAutoArchiveDuration(
24-
ThreadAutoArchiveDuration.OneDay,
101+
@command()
102+
async tclose(msg: Message) {
103+
if (!this.isHelpThread(msg.channel)) return;
104+
105+
const threadData = (await HelpThread.findOne(msg.channel.id))!;
106+
107+
if (
108+
threadData.ownerId === msg.author.id ||
109+
msg.member?.permissions.has('MANAGE_MESSAGES')
110+
) {
111+
await this.closeThread(msg.channel);
112+
} else {
113+
return await msg.channel.send(
114+
':warning: you have to be the asker to close the thread.',
25115
);
116+
}
117+
}
118+
119+
private async closeThread(thread: ThreadChannel) {
120+
manuallyArchivedThreads.set(thread.id);
121+
await thread.setArchived(true, 'grrr');
122+
await HelpThread.delete(thread.id);
123+
}
124+
125+
private updateHelpInfo(channel: TextChannel) {
126+
helpInfoLocks.set(
127+
channel.id,
128+
(helpInfoLocks.get(channel.id) ?? Promise.resolve()).then(
129+
async () => {
130+
await Promise.all([
131+
...(await channel.messages.fetchPinned()).map(x =>
132+
x.delete(),
133+
),
134+
channel
135+
.send({ embeds: [HELP_INFO(channel)] })
136+
.then(x => x.pin()),
137+
]);
138+
},
139+
),
140+
);
141+
}
142+
143+
@listener({ event: 'messageCreate' })
144+
deletePinMessage(msg: Message) {
145+
if (
146+
this.isHelpChannel(msg.channel) &&
147+
msg.type === 'CHANNEL_PINNED_MESSAGE'
148+
)
149+
msg.delete();
150+
}
151+
152+
private isHelpChannel(
153+
channel: Omit<Channel, 'partial'>,
154+
): channel is TextChannel {
155+
return (
156+
channel instanceof TextChannel && channel.parentId == helpCategory
157+
);
26158
}
27159

28160
private isHelpThread(
29161
channel: Omit<Channel, 'partial'>,
30162
): channel is ThreadChannel & { parent: TextChannel } {
31163
return (
32164
channel instanceof ThreadChannel &&
33-
channel.parent instanceof TextChannel &&
34-
channel.parent.parentId == helpCategory
165+
this.isHelpChannel(channel.parent!)
35166
);
36167
}
37168

@@ -41,51 +172,60 @@ export class HelpThreadModule extends Module {
41172
})
42173
async helper(msg: Message) {
43174
if (!this.isHelpThread(msg.channel)) {
44-
return msg.channel.send(
175+
return sendWithMessageOwnership(
176+
msg,
45177
':warning: You may only ping helpers from a help thread',
46178
);
47179
}
48180

49181
const thread = msg.channel;
182+
const threadData = (await HelpThread.findOne(thread.id))!;
50183

51184
// Ensure the user has permission to ping helpers
52-
const isAsker = thread.ownerId === msg.author.id;
185+
const isAsker = msg.author.id === threadData.ownerId;
53186
const isTrusted =
54187
(await isTrustedMember(msg, this.client)) === undefined; // No error if trusted
55188

56189
if (!isAsker && !isTrusted) {
57-
return msg.channel.send(
190+
return sendWithMessageOwnership(
191+
msg,
58192
':warning: Only the asker can ping helpers',
59193
);
60194
}
61195

62196
const askTime = thread.createdTimestamp;
63-
// Find the last time helpers were called, to avoid multiple successive !helper pings.
64-
// It's possible that the last helper ping would not be within the most recent 20 messages,
65-
// while still being more recent than allowed, but in that case the person has likely already
66-
// recieved help, so them asking for further help is less likely to be spammy.
67-
const lastHelperPing = (
68-
await thread.messages.fetch({ limit: 20 })
69-
).find(
70-
msg =>
71-
msg.author.id === this.client.user!.id &&
72-
msg.content === PINGED_HELPER_MESSAGE,
73-
)?.createdTimestamp;
74197
const pingAllowedAfter =
75-
(lastHelperPing ?? askTime) + timeBeforeHelperPing;
198+
+(threadData.helperTimestamp ?? askTime) + timeBeforeHelperPing;
76199

77200
// Ensure they've waited long enough
78201
// Trusted members (who aren't the asker) are allowed to disregard the timeout
79202
if (isAsker && Date.now() < pingAllowedAfter) {
80-
return msg.channel.send(
203+
return sendWithMessageOwnership(
204+
msg,
81205
`:warning: Please wait a bit longer. You can ping helpers <t:${Math.ceil(
82206
pingAllowedAfter / 1000,
83207
)}:R>.`,
84208
);
85209
}
86210

87211
// The beacons are lit, Gondor calls for aid
88-
thread.parent.send(`<@&${trustedRoleId}> ${msg.channel}`);
89-
thread.send(PINGED_HELPER_MESSAGE);
212+
await Promise.all([
213+
thread.parent.send(`<@&${trustedRoleId}> ${msg.channel}`),
214+
this.updateHelpInfo(thread.parent),
215+
msg.react('✅'),
216+
HelpThread.update(thread.id, {
217+
helperTimestamp: Date.now().toString(),
218+
}),
219+
]);
220+
}
221+
222+
@command({ single: true })
223+
async title(msg: Message, title: string) {
224+
if (!this.isHelpThread(msg.channel)) return;
225+
if (!title) return sendWithMessageOwnership(msg, ':x: Missing title');
226+
let username = msg.member?.nickname ?? msg.author.username;
227+
if (msg.channel.name !== username)
228+
return sendWithMessageOwnership(msg, ':x: Already set thread name');
229+
msg.channel.setName(`${username} - ${title}`);
90230
}
91231
}

0 commit comments

Comments
 (0)