Skip to content

Commit d801807

Browse files
authored
Merge pull request #222 from Gerrit0/help-forum
Forum based help system
2 parents e250206 + ce8dc95 commit d801807

File tree

10 files changed

+249
-512
lines changed

10 files changed

+249
-512
lines changed

.env.example

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ BOT_ADMINS=123,456
55
AUTOROLE=MSG_ID:ROLE_ID:EMOJI:AUTOREMOVE
66
# Another example: AUTOROLE=738932146978160661:728202487672078368:❌:false,738932146978160661:738936540465725520:✅:true
77

8-
DATABASE_URL="localhost:5432/tsc-bot"
8+
DATABASE_URL="postgres://tscbot:tscbot@localhost:5432/tscbot"
99

1010
# Role given to trusted members, not full moderators, but can use some commands which
1111
# are not given to all server members.
@@ -15,10 +15,11 @@ RULES_CHANNEL=
1515

1616
ROLES_CHANNEL=
1717

18-
HELP_CATEGORY=
1918
HOW_TO_GET_HELP_CHANNEL=
2019
HOW_TO_GIVE_HELP_CHANNEL=
21-
GENERAL_HELP_CHANNEL=
20+
21+
HELP_FORUM_CHANNEL=
22+
HELP_REQUESTS_CHANNEL=
2223

2324
# Time in milliseconds before !helper can be run
2425
TIME_BEFORE_HELPER_PING=

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1+
# 2022-12-16
2+
3+
- Remove `!close`, update `!helper` to include thread tags.
4+
15
# 2022-11-19
26

7+
- Removed `HELP_CATEGORY`, `GENERAL_HELP_CHANNEL` environment variables.
8+
- Added `HELP_FORUM_CHANNEL`, `HELP_REQUESTS_CHANNEL` environment variables.
9+
- Updated how to get help and how to give help channel content to not use embeds.
310
- Updated to Discord.js 14, removed Cookiecord to prevent future delays in updating versions.
411
- The bot will now react on the configured autorole messages to indicate available roles.
512
- Unhandled rejections will now only be ignored if `NODE_ENV` is set to `production`.

src/entities/HelpThread.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,17 @@ export class HelpThread extends BaseEntity {
1212
@Column({ nullable: true })
1313
helperTimestamp?: string;
1414

15-
// When the title was last set
15+
/**
16+
* When the title was last set, exists only for backwards compat
17+
* @deprecated
18+
*/
1619
@Column({ nullable: true })
1720
titleSetTimestamp?: string;
1821

19-
// The id of the original message; nullable for backwards compat
22+
/**
23+
* The id of the original message, exists only for backwards compat
24+
* @deprecated
25+
*/
2026
@Column({ nullable: true })
2127
origMessageId?: string;
2228
}

src/env.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ export const autorole = process.env.AUTOROLE!.split(',').map(x => {
1616

1717
export const dbUrl = process.env.DATABASE_URL!;
1818

19-
export const helpCategory = process.env.HELP_CATEGORY!;
2019
export const howToGetHelpChannel = process.env.HOW_TO_GET_HELP_CHANNEL!;
2120
export const howToGiveHelpChannel = process.env.HOW_TO_GIVE_HELP_CHANNEL!;
22-
export const generalHelpChannel = process.env.GENERAL_HELP_CHANNEL!;
21+
export const helpForumChannel = process.env.HELP_FORUM_CHANNEL!;
22+
export const helpRequestsChannel = process.env.HELP_REQUESTS_CHANNEL!;
2323

2424
export const trustedRoleId = process.env.TRUSTED_ROLE_ID!;
2525

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { playgroundModule } from './modules/playground';
1313
import { repModule } from './modules/rep';
1414
import { twoslashModule } from './modules/twoslash';
1515
import { snippetModule } from './modules/snippet';
16-
import { helpThreadModule } from './modules/helpthread';
16+
import { helpForumModule } from './modules/helpForum';
1717

1818
const client = new Client({
1919
partials: [
@@ -45,7 +45,7 @@ client.on('ready', async () => {
4545
for (const mod of [
4646
autoroleModule,
4747
etcModule,
48-
helpThreadModule,
48+
helpForumModule,
4949
playgroundModule,
5050
repModule,
5151
twoslashModule,

src/log.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import {
2-
Channel,
2+
BaseGuildTextChannel,
33
Client,
4-
GuildChannel,
54
GuildMember,
65
TextChannel,
6+
ThreadChannel,
77
User,
88
} from 'discord.js';
99
import { inspect } from 'util';
@@ -72,7 +72,11 @@ const inspectUser = (user: User) =>
7272
defineCustomUtilInspect(User, inspectUser);
7373
defineCustomUtilInspect(GuildMember, member => inspectUser(member.user));
7474

75-
defineCustomUtilInspect(
76-
GuildChannel,
77-
channel => `#${channel.name}/${(channel as Channel).id}`,
78-
);
75+
const channels: Array<{ prototype: { name: string; id: string } }> = [
76+
BaseGuildTextChannel,
77+
ThreadChannel,
78+
];
79+
80+
for (const ctor of channels) {
81+
defineCustomUtilInspect(ctor, channel => `#${channel.name}/${channel.id}`);
82+
}

src/modules/helpForum.ts

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { ChannelType, ThreadChannel, TextChannel, Channel } from 'discord.js';
2+
import { Bot } from '../bot';
3+
import { HelpThread } from '../entities/HelpThread';
4+
import {
5+
helpForumChannel,
6+
helpRequestsChannel,
7+
howToGetHelpChannel,
8+
howToGiveHelpChannel,
9+
rolesChannelId,
10+
timeBeforeHelperPing,
11+
trustedRoleId,
12+
} from '../env';
13+
import { sendWithMessageOwnership } from '../util/send';
14+
15+
// Use a non-breaking space to force Discord to leave empty lines alone
16+
const postGuidelines = listify(`
17+
How To Get Help
18+
- Create a new post here with your question.
19+
- It's always ok to just ask your question; you don't need permission.
20+
- Someone will (hopefully!) come along and help you.
21+
\u200b
22+
How To Get Better Help
23+
- Explain what you want to happen and why…
24+
- …and what actually happens, and your best guess at why.
25+
- Include a short code sample and any error messages you got.
26+
- Text is better than screenshots. Start code blocks with \`\`\`ts.
27+
- If possible, create a minimal reproduction in the TypeScript Playground: <https://www.typescriptlang.org/play>.
28+
- Send the full link in its own message; do not use a link shortener.
29+
- For more tips, check out StackOverflow's guide on asking good questions: <https://stackoverflow.com/help/how-to-ask>
30+
\u200b
31+
If You Haven't Gotten Help
32+
Usually someone will try to answer and help solve the issue within a few hours. If not, and if you have followed the bullets above, you can ping helpers by running !helper.
33+
`);
34+
35+
const howToGiveHelp = listify(`
36+
How To Give Help
37+
- The channel sidebar on the left will list threads you have joined.
38+
- You can scroll through the channel to see all recent questions.
39+
40+
How To Give *Better* Help
41+
- Get yourself the <@&${trustedRoleId}> role at <#${rolesChannelId}>
42+
- (If you don't like the pings, you can disable role mentions for the server.)
43+
44+
Useful Snippets
45+
- \`!screenshot\` — for if an asker posts a screenshot of code
46+
- \`!ask\` — for if an asker only posts "can I get help?"
47+
`);
48+
49+
export async function helpForumModule(bot: Bot) {
50+
const channel = await bot.client.guilds.cache
51+
.first()
52+
?.channels.fetch(helpForumChannel)!;
53+
if (channel?.type !== ChannelType.GuildForum) {
54+
console.error(`Expected ${helpForumChannel} to be a forum channel.`);
55+
return;
56+
}
57+
const forumChannel = channel;
58+
59+
const helpRequestChannel = await bot.client.guilds.cache
60+
.first()
61+
?.channels.fetch(helpRequestsChannel)!;
62+
if (!helpRequestChannel?.isTextBased()) {
63+
console.error(`Expected ${helpRequestChannel} to be a text channel.`);
64+
return;
65+
}
66+
67+
await forumChannel.setTopic(postGuidelines);
68+
69+
bot.client.on('threadCreate', async thread => {
70+
const owner = await thread.fetchOwner();
71+
if (!owner?.user || !isHelpThread(thread)) return;
72+
console.log(
73+
'Received new question from',
74+
owner.user.tag,
75+
'in thread',
76+
thread.id,
77+
);
78+
79+
await HelpThread.create({
80+
threadId: thread.id,
81+
ownerId: owner.user.id,
82+
}).save();
83+
});
84+
85+
bot.client.on('threadDelete', async thread => {
86+
if (!isHelpThread(thread)) return;
87+
await HelpThread.delete({
88+
threadId: thread.id,
89+
});
90+
});
91+
92+
bot.registerCommand({
93+
aliases: ['helper', 'helpers'],
94+
description: 'Help System: Ping the @Helper role from a help thread',
95+
async listener(msg, comment) {
96+
if (!isHelpThread(msg.channel)) {
97+
return sendWithMessageOwnership(
98+
msg,
99+
':warning: You may only ping helpers from a help thread',
100+
);
101+
}
102+
103+
const thread = msg.channel;
104+
const threadData = await getHelpThread(thread.id);
105+
106+
// Ensure the user has permission to ping helpers
107+
const isAsker = msg.author.id === threadData.ownerId;
108+
const isTrusted = bot.getTrustedMemberError(msg) === undefined; // No error if trusted
109+
110+
if (!isAsker && !isTrusted) {
111+
return sendWithMessageOwnership(
112+
msg,
113+
':warning: Only the asker can ping helpers',
114+
);
115+
}
116+
117+
const askTime = thread.createdTimestamp;
118+
const pingAllowedAfter =
119+
+(threadData.helperTimestamp ?? askTime ?? Date.now()) +
120+
timeBeforeHelperPing;
121+
122+
// Ensure they've waited long enough
123+
// Trusted members (who aren't the asker) are allowed to disregard the timeout
124+
if (isAsker && Date.now() < pingAllowedAfter) {
125+
return sendWithMessageOwnership(
126+
msg,
127+
`:warning: Please wait a bit longer. You can ping helpers <t:${Math.ceil(
128+
pingAllowedAfter / 1000,
129+
)}:R>.`,
130+
);
131+
}
132+
133+
const tagStrings = thread.appliedTags.flatMap(t => {
134+
const tag = forumChannel.availableTags.find(at => at.id === t);
135+
if (!tag) return [];
136+
if (!tag.emoji) return tag.name;
137+
138+
const emoji = tag.emoji.id
139+
? `<:${tag.emoji.name}:${tag.emoji.id}>`
140+
: tag.emoji.name;
141+
return `${emoji} ${tag.name}`;
142+
});
143+
const tags = tagStrings ? `(${tagStrings.join(', ')})` : '';
144+
145+
// The beacons are lit, Gondor calls for aid
146+
await Promise.all([
147+
helpRequestChannel.send(
148+
`<@&${trustedRoleId}> ${msg.channel} ${tags} ${
149+
isTrusted ? comment : ''
150+
}`,
151+
),
152+
msg.react('✅'),
153+
HelpThread.update(thread.id, {
154+
helperTimestamp: Date.now().toString(),
155+
}),
156+
]);
157+
},
158+
});
159+
160+
bot.registerAdminCommand({
161+
aliases: ['htgh'],
162+
async listener(msg) {
163+
if (!bot.isMod(msg.member)) return;
164+
if (
165+
msg.channel.id !== howToGetHelpChannel &&
166+
msg.channel.id !== howToGiveHelpChannel
167+
) {
168+
return;
169+
}
170+
(await msg.channel.messages.fetch()).forEach(x => x.delete());
171+
const message =
172+
msg.channel.id === howToGetHelpChannel
173+
? postGuidelines
174+
: howToGiveHelp;
175+
msg.channel.send(message);
176+
},
177+
});
178+
179+
async function getHelpThread(threadId: string) {
180+
const threadData = await HelpThread.findOneBy({ threadId });
181+
182+
if (!threadData) {
183+
// Thread was created when the bot was down.
184+
const thread = await forumChannel.threads.fetch(threadId);
185+
if (!thread) {
186+
throw new Error('Not a forum thread ID');
187+
}
188+
return await HelpThread.create({
189+
threadId,
190+
ownerId: thread.ownerId!,
191+
}).save();
192+
}
193+
194+
return threadData;
195+
}
196+
197+
function isHelpThread(
198+
channel: ThreadChannel | Channel,
199+
): channel is ThreadChannel & { parent: TextChannel } {
200+
return (
201+
channel instanceof ThreadChannel &&
202+
channel.parent?.id === forumChannel.id
203+
);
204+
}
205+
}
206+
207+
function listify(text: string) {
208+
// A zero-width space (necessary to prevent discord from trimming the leading whitespace), followed by a three non-breaking spaces.
209+
const indent = '\u200b\u00a0\u00a0\u00a0';
210+
const bullet = '•';
211+
return text.replace(/^(\s*)-/gm, `$1${bullet}`).replace(/\t/g, indent);
212+
}

0 commit comments

Comments
 (0)