diff --git a/.env.example b/.env.example index 57d5a709..4a203d3e 100644 --- a/.env.example +++ b/.env.example @@ -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" # Role given to trusted members, not full moderators, but can use some commands which # are not given to all server members. @@ -15,10 +15,13 @@ 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= +HELP_FORUM_OPEN_TAG=Open +HELP_FORUM_RESOLVED_TAG=Resolved # Time in milliseconds before !helper can be run TIME_BEFORE_HELPER_PING= diff --git a/CHANGELOG.md b/CHANGELOG.md index e8006176..a6f7e39b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/src/bot.ts b/src/bot.ts index fdcd1abf..18409314 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -74,17 +74,19 @@ export class Bot { return botAdmins.includes(user.id); } - getTrustedMemberError(msg: Message) { + isTrusted(msg: Message) { if (!msg.guild || !msg.member || !msg.channel.isTextBased()) { - return ":warning: you can't use that command here."; + return false; } if ( !msg.member.roles.cache.has(trustedRoleId) && !msg.member.permissions.has('ManageMessages') ) { - return ":warning: you don't have permission to use that command."; + return false; } + + return true; } async getTargetUser(msg: Message): Promise { diff --git a/src/entities/HelpThread.ts b/src/entities/HelpThread.ts index 87e92eb6..16d01393 100644 --- a/src/entities/HelpThread.ts +++ b/src/entities/HelpThread.ts @@ -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; } diff --git a/src/env.ts b/src/env.ts index b25e5671..21ac0922 100644 --- a/src/env.ts +++ b/src/env.ts @@ -16,10 +16,13 @@ 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 helpForumOpenTagName = process.env.HELP_FORUM_OPEN_TAG!; +export const helpForumResolvedTagName = process.env.HELP_FORUM_RESOLVED_TAG!; export const trustedRoleId = process.env.TRUSTED_ROLE_ID!; diff --git a/src/index.ts b/src/index.ts index 061ea43e..a69fc9c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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: [ @@ -45,7 +45,7 @@ client.on('ready', async () => { for (const mod of [ autoroleModule, etcModule, - helpThreadModule, + helpForumModule, playgroundModule, repModule, twoslashModule, diff --git a/src/log.ts b/src/log.ts index 50b5df73..3ad24085 100644 --- a/src/log.ts +++ b/src/log.ts @@ -1,9 +1,9 @@ import { - Channel, + BaseGuildTextChannel, Client, - GuildChannel, GuildMember, TextChannel, + ThreadChannel, User, } from 'discord.js'; import { inspect } from 'util'; @@ -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}`); +} diff --git a/src/modules/helpForum.ts b/src/modules/helpForum.ts new file mode 100644 index 00000000..dd3d2614 --- /dev/null +++ b/src/modules/helpForum.ts @@ -0,0 +1,325 @@ +import { + ChannelType, + ThreadChannel, + TextChannel, + Channel, + ForumChannel, + Message, +} from 'discord.js'; +import { Bot } from '../bot'; +import { HelpThread } from '../entities/HelpThread'; +import { + helpForumChannel, + helpForumOpenTagName, + helpForumResolvedTagName, + helpRequestsChannel, + howToGetHelpChannel, + howToGiveHelpChannel, + rolesChannelId, + timeBeforeHelperPing, + trustedRoleId, +} from '../env'; +import { sendWithMessageOwnership } from '../util/send'; + +const MAX_TAG_COUNT = 5; + +// Use a non-breaking space to force Discord to leave empty lines alone +const postGuidelines = (here = true) => + listify(` +**How To Get Help** +- Create a new post ${ + here ? 'here' : `in <#${helpForumChannel}>` + } 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. +- When your question is resolved, type \`!resolved\`. +\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: . + - 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: +\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 posts 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.) +- As a <@&${trustedRoleId}>, you can: + - React to a help post to add tags. + - If a post appears to be resolved, run \`!resolved\` to mark it as such. + - *Only do this if the asker has indicated that their question has been resolved.* + - Conversely, you can run \`!reopen\` if the asker has follow-up questions. + +**Useful Snippets** +- \`!screenshot\` — for if an asker posts a screenshot of code +- \`!ask\` — for if an asker only posts "can I get help?" +`); + +const helperResolve = (owner: string, helper: string) => ` +<@${owner}> +Because your issue seemed to be resolved, this post was marked as resolved by <@${helper}>. +If your issue is not resolved, **you can reopen this post by running \`!reopen\`**. +*If you have a different question, make a new post in <#${helpForumChannel}>.* +`; + +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 openTag = getTag(forumChannel, helpForumOpenTagName); + const resolvedTag = getTag(forumChannel, helpForumResolvedTagName); + + 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(); + + await setStatus(thread, openTag); + }); + + 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 post', + async listener(msg, comment) { + if (!isHelpThread(msg.channel)) { + return sendWithMessageOwnership( + msg, + ':warning: You may only ping helpers from a help post', + ); + } + + 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.isTrusted(msg); + + 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 .`, + ); + } + + 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.registerCommand({ + aliases: ['resolved', 'resolve', 'close', 'closed', 'done', 'solved'], + description: 'Help System: Mark a post as resolved', + async listener(msg) { + changeStatus(msg, true); + }, + }); + + bot.registerCommand({ + aliases: ['reopen', 'open', 'unresolved', 'unresolve'], + description: 'Help System: Reopen a resolved post', + async listener(msg) { + changeStatus(msg, false); + }, + }); + + bot.client.on('messageReactionAdd', async reaction => { + const message = reaction.message; + const thread = await message.channel.fetch(); + if (!isHelpThread(thread)) { + return; + } + const initial = await thread.fetchStarterMessage(); + if (initial?.id !== message.id) return; + const tag = forumChannel.availableTags.find( + t => + t.emoji && + !t.moderated && + t.emoji.id === reaction.emoji.id && + t.emoji.name === reaction.emoji.name, + ); + if (!tag) return; + if (thread.appliedTags.length < MAX_TAG_COUNT) { + await thread.setAppliedTags([...thread.appliedTags, tag.id]); + } + await reaction.remove(); + }); + + async function changeStatus(msg: Message, resolved: boolean) { + const thread = msg.channel; + if (!isHelpThread(thread)) { + return sendWithMessageOwnership( + msg, + ':warning: Can only be run in a help post', + ); + } + + const threadData = await getHelpThread(thread.id); + const isAsker = msg.author.id === threadData.ownerId; + const isTrusted = bot.isTrusted(msg); + + if (!isAsker && !isTrusted) { + return sendWithMessageOwnership( + msg, + ':warning: Only the asker can change the status of a help post', + ); + } + + await setStatus(thread, resolved ? resolvedTag : openTag); + await msg.react('✅'); + + if (resolved && !isAsker) { + await thread.send(helperResolve(thread.ownerId!, msg.author.id)); + } + } + + bot.registerAdminCommand({ + aliases: ['htgh'], + async listener(msg) { + 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(false) + : howToGiveHelp; + // Force a blank line at the beginning of the message for compact-mode users + msg.channel.send(`** **\n` + message.trim()); + }, + }); + + 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 getTag(channel: ForumChannel, name: string) { + const tag = channel.availableTags.find(x => x.name === name); + if (!tag) throw new Error(`Could not find tag ${name}`); + return tag.id; + } + + async function setStatus(thread: ThreadChannel, tag: string) { + let tags = thread.appliedTags.filter( + x => x !== openTag && x !== resolvedTag, + ); + if (tags.length === MAX_TAG_COUNT) { + tags = tags.slice(0, -1); + } + await thread.setAppliedTags([tag, ...tags]); + } +} + +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); +} diff --git a/src/modules/helpthread.ts b/src/modules/helpthread.ts deleted file mode 100644 index f1de11fe..00000000 --- a/src/modules/helpthread.ts +++ /dev/null @@ -1,476 +0,0 @@ -import { - ThreadAutoArchiveDuration, - TextChannel, - ThreadChannel, - EmbedBuilder, - GuildMember, - MessageType, - Channel, -} from 'discord.js'; -import { Bot } from '../bot'; -import { HelpThread } from '../entities/HelpThread'; -import { - trustedRoleId, - helpCategory, - timeBeforeHelperPing, - GREEN, - BLOCKQUOTE_GREY, - generalHelpChannel, - howToGetHelpChannel, - howToGiveHelpChannel, - rolesChannelId, -} from '../env'; -import { sendWithMessageOwnership } from '../util/send'; - -const threadExpireEmbed = new EmbedBuilder() - .setColor(BLOCKQUOTE_GREY) - .setTitle('This help thread expired.').setDescription(` -If your question was not resolved, you can make a new thread by simply asking your question again. \ -Consider rephrasing the question to maximize your chance of getting a good answer. \ -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). -`); - -const helperCloseEmbed = (member: GuildMember) => - new EmbedBuilder().setColor(BLOCKQUOTE_GREY).setDescription(` -Because your issue seemed to be resolved, this thread was closed by ${member}. - -If your issue is not resolved, **you can post another message here and the thread will automatically re-open**. - -*If you have a different question, just ask in <#${generalHelpChannel}>.* -`); - -const closedEmoji = '☑️'; - -const helpInfo = (channel: TextChannel) => - new EmbedBuilder() - .setColor(GREEN) - .setDescription(channel.topic ?? 'Ask your questions here!'); - -const howToGetHelpEmbeds = () => [ - new EmbedBuilder() - .setColor(GREEN) - .setTitle('How To Get Help') - .setDescription( - listify(` -- Post your question to one of the channels in this category. - - If you're not sure which channel is best, just post in <#${generalHelpChannel}>. - - It's always ok to just ask your question; you don't need permission. -- Our bot will make a thread dedicated to answering your channel. -- Someone will (hopefully!) come along and help you. -- When your question is resolved, type \`!close\`. -`), - ), - new EmbedBuilder() - .setColor(GREEN) - .setTitle('How To Get *Better* Help') - .setDescription( - listify(` -- 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. -- Run \`!title \` to make your help thread easier to spot. -- For more tips, check out StackOverflow's guide on **[asking good questions](https://stackoverflow.com/help/how-to-ask)**. -`), - ), - new EmbedBuilder() - .setColor(GREEN) - .setTitle("If You Haven't Gotten Help") - .setDescription( - ` -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 howToGiveHelpEmbeds = () => [ - new EmbedBuilder() - .setColor(GREEN) - .setTitle('How To Give Help') - .setDescription( - listify(` -- There are a couple ways you can browse help threads: - - The channel sidebar on the left will list threads you have joined. - - You can scroll through the channel to see all recent questions. - - The bot will mark closed questions with ${closedEmoji}. - - In the channel, you can click the *⌗*\u2004icon at the top right to view threads by title. - -`), - ), - new EmbedBuilder() - .setColor(GREEN) - .setTitle('How To Give *Better* Help') - .setDescription( - listify(` -- Get yourself the <@&${trustedRoleId}> role at <#${rolesChannelId}> - - (If you don't like the pings, you can disable role mentions for the server.) -- As a <@&${trustedRoleId}>, you can: - - Run \`!title \` to set/update the thread title. - - This will assist other helpers in finding the thread. - - Also, it means your help is more accessible to others in the future. - - If a thread appears to be resolved, run \`!close\` to close it. - - *Only do this if the asker has indicated that their question has been resolved.* -`), - ), - new EmbedBuilder() - .setColor(GREEN) - .setTitle('Useful Snippets') - .setDescription( - listify(` -- \`!screenshot\` — for if an asker posts a screenshot of code -- \`!ask\` — for if an asker only posts "can I get help?" -`), - ), -]; - -const helpThreadWelcomeMessage = (owner: GuildMember) => ` -${owner} This thread is for your question; type \`!title \`. \ -When it's resolved, please type \`!close\`. \ -See <#${howToGetHelpChannel}> for info on how to get better help. -`; - -// The rate limit for thread naming is 2 time / 10 mins, tracked per thread -const titleSetCooldown = 5 * 60 * 1000; - -const threadExpireHours = ThreadAutoArchiveDuration.OneDay; -const threadCheckInterval = 60 * 60 * 1000; - -export function helpThreadModule(bot: Bot) { - const { client } = bot; - - const helpInfoLocks = new Map>(); - const manuallyArchivedThreads = new Set(); - - client.on('messageCreate', async msg => { - if (!isHelpChannel(msg.channel)) return; - if (msg.author.bot) return; - console.log( - 'Received new question from', - msg.author, - 'in', - msg.channel, - ); - updateHelpInfo(msg.channel); - let thread = await msg.startThread({ - name: `Help ${msg.member?.nickname ?? msg.author.username}`, - autoArchiveDuration: threadExpireHours, - }); - thread.send(helpThreadWelcomeMessage(msg.member!)); - await HelpThread.create({ - threadId: thread.id, - ownerId: msg.author.id, - origMessageId: msg.id, - }).save(); - console.log(`Created a new help thread for`, msg.author); - }); - - client.on('threadUpdate', async thread => { - if ( - !isHelpThread(thread) || - !thread.archived || - ((await thread.fetch()) as ThreadChannel).archived - ) - return; - const threadData = (await HelpThread.findOneBy({ - threadId: thread.id, - }))!; - if (!threadData.origMessageId) return; - try { - const origMessage = await thread.parent.messages.fetch( - threadData.origMessageId, - ); - origMessage.reactions - .resolve(closedEmoji) - ?.users.remove(client.user.id); - } catch { - // Asker deleted original message - } - }); - - checkThreads(); - function checkThreads() { - setTimeout(checkThreads, threadCheckInterval); - bot.client.guilds.cache.forEach(guild => { - guild.channels.cache.forEach(async channel => { - if (!isHelpChannel(channel)) return; - const threads = await channel.threads.fetchActive(); - threads.threads.forEach(async thread => { - const time = - Date.now() - - (await thread.messages.fetch({ limit: 1 })).first()! - .createdTimestamp; - if (time >= threadExpireHours * 60 * 1000) { - onThreadExpire(thread).catch(console.error); - } - }); - }); - }); - } - - client.on('threadUpdate', async thread => { - if ( - !isHelpThread(thread) || - !(await thread.fetch()).archived || - manuallyArchivedThreads.delete(thread.id) - ) - return; - await onThreadExpire(thread); - }); - - client.on('messageCreate', msg => { - if ( - isHelpChannel(msg.channel) && - msg.type === MessageType.ChannelPinnedMessage - ) { - msg.delete(); - } - }); - - bot.registerCommand({ - aliases: ['close', 'closed', 'resolved', 'resolve', 'done', 'solved'], - description: 'Help System: Close an active help thread', - async listener(msg) { - if (!isHelpThread(msg.channel)) - return await sendWithMessageOwnership( - msg, - ':warning: This can only be run in a help thread', - ); - - let thread: ThreadChannel = msg.channel; - const threadData = (await HelpThread.findOneBy({ - threadId: thread.id, - }))!; - - const isOwner = threadData.ownerId === msg.author.id; - - if ( - isOwner || - msg.member?.roles.cache.has(trustedRoleId) || - bot.isMod(msg.member) - ) { - console.log(`Closing help thread:`, thread); - await msg.react('✅'); - if (!isOwner) - await msg.channel.send({ - content: `<@${threadData.ownerId}>`, - embeds: [helperCloseEmbed(msg.member!)], - }); - manuallyArchivedThreads.add(thread.id); - await archiveThread(thread); - } else { - return await sendWithMessageOwnership( - msg, - ':warning: You have to be the asker to close the thread.', - ); - } - }, - }); - - 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 HelpThread.findOneBy({ - threadId: 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 .`, - ); - } - - // The beacons are lit, Gondor calls for aid - await Promise.all([ - thread.parent!.send( - `<@&${trustedRoleId}> ${msg.channel} ${ - isTrusted ? comment : '' - }`, - ), - updateHelpInfo(thread.parent!), - msg.react('✅'), - HelpThread.update(thread.id, { - helperTimestamp: Date.now().toString(), - }), - ]); - }, - }); - - bot.registerCommand({ - aliases: ['title'], - description: 'Help System: Rename a help thread', - async listener(msg, title) { - const m = /^<#(\d+)>\s*([^]*)/.exec(title); - let thread: Omit | undefined = msg.channel; - if (m) { - thread = msg.guild?.channels.cache.get(m[1])!; - title = m[2]; - } - if (!thread || !isHelpThread(thread)) - return sendWithMessageOwnership( - msg, - ':warning: This can only be run in a help thread', - ); - if (!title) - return sendWithMessageOwnership(msg, ':warning: Missing title'); - const threadData = (await HelpThread.findOneBy({ - threadId: thread.id, - }))!; - if ( - msg.author.id !== threadData.ownerId && - !msg.member!.roles.cache.has(trustedRoleId) - ) { - return sendWithMessageOwnership( - msg, - ':warning: Only the asker and helpers can set the title', - ); - } - - const titleSetAllowedAfter = - +(threadData.titleSetTimestamp ?? 0) + titleSetCooldown; - if ( - threadData.titleSetTimestamp && - Date.now() < titleSetAllowedAfter - ) { - return sendWithMessageOwnership( - msg, - `:warning: You can set the title again `, - ); - } - - const owner = await msg.guild!.members.fetch(threadData.ownerId); - const username = owner.nickname ?? owner.user.username; - await Promise.all([ - HelpThread.update(thread.id, { - titleSetTimestamp: Date.now() + '', - }), - // Truncate if longer than 100, the max thread title length - thread.setName(`${username} - ${title}`.slice(0, 100)), - ]); - if (thread !== msg.channel) { - await msg.react('✅'); - } - }, - }); - - bot.registerAdminCommand({ - aliases: ['htgh'], - async listener(msg) { - if ( - msg.channel.id !== howToGetHelpChannel && - msg.channel.id !== howToGiveHelpChannel - ) { - return; - } - (await msg.channel.messages.fetch()).forEach(x => x.delete()); - const embeds = - msg.channel.id === howToGetHelpChannel - ? howToGetHelpEmbeds() - : howToGiveHelpEmbeds(); - msg.channel.send({ embeds }); - }, - }); - - async function onThreadExpire(thread: ThreadChannel) { - const threadData = (await HelpThread.findOneBy({ - threadId: thread.id, - }))!; - console.log(`Help thread expired:`, thread); - await thread.send({ - content: `<@${threadData.ownerId}>`, - embeds: [threadExpireEmbed], - }); - manuallyArchivedThreads.add(thread.id); - await archiveThread(thread); - } - - function updateHelpInfo(channel: TextChannel) { - helpInfoLocks.set( - channel.id, - (helpInfoLocks.get(channel.id) ?? Promise.resolve()).then( - async () => { - await Promise.all([ - ...( - await channel.messages.fetchPinned() - ).map(x => x.delete()), - channel - .send({ embeds: [helpInfo(channel)] }) - .then(x => x.pin()), - ]); - }, - ), - ); - } - - async function archiveThread(thread: ThreadChannel) { - await thread.setArchived(true); - const threadData = (await HelpThread.findOneBy({ - threadId: thread.id, - }))!; - if (!threadData.origMessageId) return; - try { - const origMessage = await thread.parent!.messages.fetch( - threadData.origMessageId, - ); - await origMessage.react(closedEmoji); - } catch { - // Asker deleted original message - } - } -} - -export function isHelpChannel(channel: unknown): channel is TextChannel { - return ( - channel instanceof TextChannel && - channel.parentId == helpCategory && - channel.id !== howToGetHelpChannel && - channel.id !== howToGiveHelpChannel - ); -} - -export function isHelpThread( - channel: unknown, -): channel is ThreadChannel & { parent: TextChannel } { - return channel instanceof ThreadChannel && isHelpChannel(channel.parent!); -} - -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); -} diff --git a/src/modules/playground.ts b/src/modules/playground.ts index dc5fe805..593f7ba7 100644 --- a/src/modules/playground.ts +++ b/src/modules/playground.ts @@ -13,13 +13,8 @@ import { PlaygroundLinkMatch, } from '../util/codeBlocks'; import { LimitedSizeMap } from '../util/limitedSizeMap'; -import { - addMessageOwnership, - getResponseChannel, - sendWithMessageOwnership, -} from '../util/send'; +import { addMessageOwnership, sendWithMessageOwnership } from '../util/send'; import { fetch } from 'undici'; -import { isHelpChannel } from './helpthread'; import { Bot } from '../bot'; const PLAYGROUND_BASE = 'https://www.typescriptlang.org/play/#code/'; @@ -60,7 +55,7 @@ export async function playgroundModule(bot: Bot) { const exec = matchPlaygroundLink(msg.content); if (!exec) return; const embed = createPlaygroundEmbed(msg.author, exec); - if (exec.isWholeMatch && !isHelpChannel(msg.channel)) { + if (exec.isWholeMatch) { // Message only contained the link await sendWithMessageOwnership(msg, { embeds: [embed], @@ -68,8 +63,7 @@ export async function playgroundModule(bot: Bot) { await msg.delete(); } else { // Message also contained other characters - const channel = await getResponseChannel(msg); - const botMsg = await channel.send({ + const botMsg = await msg.channel.send({ embeds: [embed], content: `${msg.author} Here's a shortened URL of your playground link! You can remove the full link from your message.`, }); diff --git a/src/util/send.ts b/src/util/send.ts index b4f630f4..9a7d6fdd 100644 --- a/src/util/send.ts +++ b/src/util/send.ts @@ -5,7 +5,6 @@ import { PartialMessage, User, } from 'discord.js'; -import { isHelpChannel } from '../modules/helpthread'; import { LimitedSizeMap } from './limitedSizeMap'; const messageToUserId = new LimitedSizeMap< @@ -15,22 +14,12 @@ const messageToUserId = new LimitedSizeMap< export const DELETE_EMOJI = '🗑️'; -export async function getResponseChannel(message: Message) { - const channel = message.channel; - if (!isHelpChannel(channel)) return channel; - while (!message.thread) { - message = await message.fetch(); - } - return message.thread; -} - export async function sendWithMessageOwnership( message: Message, toSend: string | MessagePayload | MessageCreateOptions, onDelete?: () => void, ) { - const channel = await getResponseChannel(message); - const sent = await channel.send(toSend); + const sent = await message.channel.send(toSend); await addMessageOwnership(sent, message.author, onDelete); }