Skip to content

Forum based help system #222

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Dec 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ BOT_ADMINS=123,456
AUTOROLE=MSG_ID:ROLE_ID:EMOJI:AUTOREMOVE
# Another example: AUTOROLE=738932146978160661:728202487672078368:❌:false,738932146978160661:738936540465725520:✅:true

DATABASE_URL="localhost:5432/tsc-bot"
DATABASE_URL="postgres://tscbot:tscbot@localhost:5432/tscbot"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The provided example has no hope of working, this is what I had to set it to to connect to the docker created DB


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

ROLES_CHANNEL=

HELP_CATEGORY=
HOW_TO_GET_HELP_CHANNEL=
HOW_TO_GIVE_HELP_CHANNEL=
GENERAL_HELP_CHANNEL=

HELP_FORUM_CHANNEL=
HELP_REQUESTS_CHANNEL=

# Time in milliseconds before !helper can be run
TIME_BEFORE_HELPER_PING=
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# 2022-12-16

- Remove `!close`, update `!helper` to include thread tags.

# 2022-11-19

- Removed `HELP_CATEGORY`, `GENERAL_HELP_CHANNEL` environment variables.
- Added `HELP_FORUM_CHANNEL`, `HELP_REQUESTS_CHANNEL` environment variables.
- Updated how to get help and how to give help channel content to not use embeds.
- Updated to Discord.js 14, removed Cookiecord to prevent future delays in updating versions.
- The bot will now react on the configured autorole messages to indicate available roles.
- Unhandled rejections will now only be ignored if `NODE_ENV` is set to `production`.
Expand Down
10 changes: 8 additions & 2 deletions src/entities/HelpThread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,17 @@ export class HelpThread extends BaseEntity {
@Column({ nullable: true })
helperTimestamp?: string;

// When the title was last set
/**
* When the title was last set, exists only for backwards compat
* @deprecated
*/
@Column({ nullable: true })
titleSetTimestamp?: string;

// The id of the original message; nullable for backwards compat
/**
* The id of the original message, exists only for backwards compat
* @deprecated
*/
@Column({ nullable: true })
origMessageId?: string;
}
4 changes: 2 additions & 2 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ export const autorole = process.env.AUTOROLE!.split(',').map(x => {

export const dbUrl = process.env.DATABASE_URL!;

export const helpCategory = process.env.HELP_CATEGORY!;
export const howToGetHelpChannel = process.env.HOW_TO_GET_HELP_CHANNEL!;
export const howToGiveHelpChannel = process.env.HOW_TO_GIVE_HELP_CHANNEL!;
export const generalHelpChannel = process.env.GENERAL_HELP_CHANNEL!;
export const helpForumChannel = process.env.HELP_FORUM_CHANNEL!;
export const helpRequestsChannel = process.env.HELP_REQUESTS_CHANNEL!;

export const trustedRoleId = process.env.TRUSTED_ROLE_ID!;

Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { playgroundModule } from './modules/playground';
import { repModule } from './modules/rep';
import { twoslashModule } from './modules/twoslash';
import { snippetModule } from './modules/snippet';
import { helpThreadModule } from './modules/helpthread';
import { helpForumModule } from './modules/helpForum';

const client = new Client({
partials: [
Expand Down Expand Up @@ -45,7 +45,7 @@ client.on('ready', async () => {
for (const mod of [
autoroleModule,
etcModule,
helpThreadModule,
helpForumModule,
playgroundModule,
repModule,
twoslashModule,
Expand Down
16 changes: 10 additions & 6 deletions src/log.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {
Channel,
BaseGuildTextChannel,
Client,
GuildChannel,
GuildMember,
TextChannel,
ThreadChannel,
User,
} from 'discord.js';
import { inspect } from 'util';
Expand Down Expand Up @@ -72,7 +72,11 @@ const inspectUser = (user: User) =>
defineCustomUtilInspect(User, inspectUser);
defineCustomUtilInspect(GuildMember, member => inspectUser(member.user));

defineCustomUtilInspect(
GuildChannel,
channel => `#${channel.name}/${(channel as Channel).id}`,
);
const channels: Array<{ prototype: { name: string; id: string } }> = [
BaseGuildTextChannel,
ThreadChannel,
];

for (const ctor of channels) {
defineCustomUtilInspect(ctor, channel => `#${channel.name}/${channel.id}`);
}
212 changes: 212 additions & 0 deletions src/modules/helpForum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { ChannelType, ThreadChannel, TextChannel, Channel } from 'discord.js';
import { Bot } from '../bot';
import { HelpThread } from '../entities/HelpThread';
import {
helpForumChannel,
helpRequestsChannel,
howToGetHelpChannel,
howToGiveHelpChannel,
rolesChannelId,
timeBeforeHelperPing,
trustedRoleId,
} from '../env';
import { sendWithMessageOwnership } from '../util/send';

// Use a non-breaking space to force Discord to leave empty lines alone
const postGuidelines = listify(`
How To Get Help
- Create a new post here with your question.
- It's always ok to just ask your question; you don't need permission.
- Someone will (hopefully!) come along and help you.
\u200b
How To Get Better Help
- Explain what you want to happen and why…
- …and what actually happens, and your best guess at why.
- Include a short code sample and any error messages you got.
- Text is better than screenshots. Start code blocks with \`\`\`ts.
- If possible, create a minimal reproduction in the TypeScript Playground: <https://www.typescriptlang.org/play>.
- Send the full link in its own message; do not use a link shortener.
- For more tips, check out StackOverflow's guide on asking good questions: <https://stackoverflow.com/help/how-to-ask>
\u200b
If You Haven't Gotten Help
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.
`);

const howToGiveHelp = listify(`
How To Give Help
- The channel sidebar on the left will list threads you have joined.
- You can scroll through the channel to see all recent questions.

How To Give *Better* Help
- Get yourself the <@&${trustedRoleId}> role at <#${rolesChannelId}>
- (If you don't like the pings, you can disable role mentions for the server.)

Useful Snippets
- \`!screenshot\` — for if an asker posts a screenshot of code
- \`!ask\` — for if an asker only posts "can I get help?"
`);

export async function helpForumModule(bot: Bot) {
const channel = await bot.client.guilds.cache
.first()
?.channels.fetch(helpForumChannel)!;
if (channel?.type !== ChannelType.GuildForum) {
console.error(`Expected ${helpForumChannel} to be a forum channel.`);
return;
}
const forumChannel = channel;

const helpRequestChannel = await bot.client.guilds.cache
.first()
?.channels.fetch(helpRequestsChannel)!;
if (!helpRequestChannel?.isTextBased()) {
console.error(`Expected ${helpRequestChannel} to be a text channel.`);
return;
}

await forumChannel.setTopic(postGuidelines);

bot.client.on('threadCreate', async thread => {
const owner = await thread.fetchOwner();
if (!owner?.user || !isHelpThread(thread)) return;
console.log(
'Received new question from',
owner.user.tag,
'in thread',
thread.id,
);

await HelpThread.create({
threadId: thread.id,
ownerId: owner.user.id,
}).save();
});

bot.client.on('threadDelete', async thread => {
if (!isHelpThread(thread)) return;
await HelpThread.delete({
threadId: thread.id,
});
});

bot.registerCommand({
aliases: ['helper', 'helpers'],
description: 'Help System: Ping the @Helper role from a help thread',
async listener(msg, comment) {
if (!isHelpThread(msg.channel)) {
return sendWithMessageOwnership(
msg,
':warning: You may only ping helpers from a help thread',
);
}

const thread = msg.channel;
const threadData = await getHelpThread(thread.id);

// Ensure the user has permission to ping helpers
const isAsker = msg.author.id === threadData.ownerId;
const isTrusted = bot.getTrustedMemberError(msg) === undefined; // No error if trusted

if (!isAsker && !isTrusted) {
return sendWithMessageOwnership(
msg,
':warning: Only the asker can ping helpers',
);
}

const askTime = thread.createdTimestamp;
const pingAllowedAfter =
+(threadData.helperTimestamp ?? askTime ?? Date.now()) +
timeBeforeHelperPing;

// Ensure they've waited long enough
// Trusted members (who aren't the asker) are allowed to disregard the timeout
if (isAsker && Date.now() < pingAllowedAfter) {
return sendWithMessageOwnership(
msg,
`:warning: Please wait a bit longer. You can ping helpers <t:${Math.ceil(
pingAllowedAfter / 1000,
)}:R>.`,
);
}

const tagStrings = thread.appliedTags.flatMap(t => {
const tag = forumChannel.availableTags.find(at => at.id === t);
if (!tag) return [];
if (!tag.emoji) return tag.name;

const emoji = tag.emoji.id
? `<:${tag.emoji.name}:${tag.emoji.id}>`
: tag.emoji.name;
return `${emoji} ${tag.name}`;
});
const tags = tagStrings ? `(${tagStrings.join(', ')})` : '';

// The beacons are lit, Gondor calls for aid
await Promise.all([
helpRequestChannel.send(
`<@&${trustedRoleId}> ${msg.channel} ${tags} ${
isTrusted ? comment : ''
}`,
),
msg.react('✅'),
HelpThread.update(thread.id, {
helperTimestamp: Date.now().toString(),
}),
]);
},
});

bot.registerAdminCommand({
aliases: ['htgh'],
async listener(msg) {
if (!bot.isMod(msg.member)) return;
if (
msg.channel.id !== howToGetHelpChannel &&
msg.channel.id !== howToGiveHelpChannel
) {
return;
}
(await msg.channel.messages.fetch()).forEach(x => x.delete());
const message =
msg.channel.id === howToGetHelpChannel
? postGuidelines
: howToGiveHelp;
msg.channel.send(message);
},
});

async function getHelpThread(threadId: string) {
const threadData = await HelpThread.findOneBy({ threadId });

if (!threadData) {
// Thread was created when the bot was down.
const thread = await forumChannel.threads.fetch(threadId);
if (!thread) {
throw new Error('Not a forum thread ID');
}
return await HelpThread.create({
threadId,
ownerId: thread.ownerId!,
}).save();
}

return threadData;
}

function isHelpThread(
channel: ThreadChannel | Channel,
): channel is ThreadChannel & { parent: TextChannel } {
return (
channel instanceof ThreadChannel &&
channel.parent?.id === forumChannel.id
);
}
}

function listify(text: string) {
// A zero-width space (necessary to prevent discord from trimming the leading whitespace), followed by a three non-breaking spaces.
const indent = '\u200b\u00a0\u00a0\u00a0';
const bullet = '•';
return text.replace(/^(\s*)-/gm, `$1${bullet}`).replace(/\t/g, indent);
}
Loading