|
| 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