diff --git a/localization.yml b/localization.yml new file mode 100644 index 0000000..1d7e205 --- /dev/null +++ b/localization.yml @@ -0,0 +1,201 @@ +# Console errors + +roles_channel_doesnt_exists: Roles channel ($(rolesChannelId)) does not exist or is not text based. +roles_message_doesnt_exists: Role message does not exist for $(id) +to_be_forum_channel: Expected $(channel) to be a forum channel. +to_be_text_channel: Expected $(channel) to be a text channel. + +# Console info + +role_gave: Gave role $(role) to $(member) +role_removed: Removed role $(role) from $(member) +searching_algolia: Searching algolia for $(content) +algolia_response: 'Algolia response: $(data)' +new_request: | + Received new question from + $(owner) + in thread + $(thread) +creating_rep: Creating a Rep with recipient $(recipient) +received_rep: Received rep reaction on $(message) +querying_for_rep: Querying database for existing Rep +rep_exist_found: Found existing rep $(rep) +user_is_recipient: User is recipient; removing reaction +existing_amount_is: Existing amount is $(count) +incremented_amount: Incremented amount to $(amount) +recipient_is_bot: Recipient is bot; checking for message ownership +no_message_owner: No message owner recorded; removing reaction +message_owner_is: Message owner is $(owner) +decremented_rep: | + Decremented rep amount to + $(amount) + for message + $(id) + +# Commands + +command_ping: + description: See if the bot is alive + listener_message: 'pong. :ping_pong:' + +handbook_command: + description: Search the TypeScript Handbook + listener: + no_results_found: ':x: No results found for that query' + +help_command: + description: Sends what you're looking at right now + listener: + snippet_created: A custom snippet created by $(owner) + snippet_not_created: Run the first snippet that matches that pattern + command_not_found: ':x: Command not found' + +helpers_command: + description: 'Help System: Ping the @Helper role from a help post' + listener: + not_help_channel: ':warning: You may only ping helpers from a help post' + only_asker: ':warning: Only the asker can ping helpers' + pls_wait: ':warning: Please wait a bit longer. You can ping helpers $(time)' + +playground_command: + description: Shorten a TypeScript playground link + listener: + log: Playground $(content) + not_code: ":warning: couldn't find a codeblock!" + +resolved_command: + description: 'Help System: Mark a post as resolved' + +reopen_command: + description: 'Help System: Reopen a resolved post' + +reputation_command: + description: 'Reputation: Give a different user some reputation points' + +history_command: + description: "Reputation: View a user's reputation history" + listener: + cannot_find: Unable to find user to give rep + +leaderboard_command: + description: 'Reputation: See who has the most reputation' + listener: + invalid_period: ':x: Invalid period (expected one of $(of))' + +snippets_command: + description: 'Snippet: List snippets matching an optional filter' + +snippet_command: + description: 'Snippet: Create or edit a snippet' + listener: + not_name: ':x: You have to supply a name for the command' + dont_have_permissions: ":x: You don't have permission to create a global snippet" + cannot_edit: ":x: Cannot edit another user's snippet" + second_arg: ':x: Second argument must be a valid discord message link or snippet id' + have_reply: ':x: You have to reply or link to a comment to make it a snippet' + cannot_generate: ':x: Cannot generate a snippet from that message' + +delete_snippet_command: + description: 'Snippet: Delete a snippet you own' + listener: + not_found: ':x: No snippet found with that id' + cannot_delete: ":x: Cannot delete another user's snippet" + snippet_deleted_log: Deleted snippet $(id) for $(author) + snippet_deleted_msg: ':white_check_mark: Deleted snippet' + +twoslash_command: + description: 'Twoslash: Run twoslash on the latest codeblock, optionally returning the quick infos of specified symbols. You can use ts@4.8.3 or ts@next to run a specific version.' + listener: + cannot_find: ':x: Could not find that version of TypeScript' + not_codeblocks: ':warning: could not find any TypeScript codeblocks in the past 10 messages' + invalid_symbol: You need to give me a valid symbol name to look for! + +# Events + +polling_finished: 'Polling finished; result: $(pollingResultStr)' + +# Embeds + +handbook_embed: + title: The TypeScript Handbook + url: https://www.typescriptlang.org/docs/handbook/intro.html + footer: + text: You can search with `!handbook ` + +help_command_embed: + title: Bot Usage + description: Hello $(username)! Here is a list of all commands in me! To get detailed description on any specific command, do `help ` + +playground_command_embed: + title: View in Playground + +playground_link_embed: + title: Playground Link + +# Messages + +forum_how_to_get_help: | + **How To Get Help** + - Create a new post $(post) 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. + +forum_how_to_give_help: | + **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 $(trusted) role at $(channel) + - (If you don't like the pings, you can disable role mentions for the server.) + - As a $(trusted), 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?" + +helper_resolve: | + $(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 $(channel).* + +change_status: + not_help_thread: ':warning: Can only be run in a help post' + only_asker: ':warning: Only the asker can change the status of a help post' + +shortened_url_playground: "$(author) Here's a shortened URL of your playground link! You can remove the full link from your message." +you_can_spec_lines: You can choose specific lines to embed by selecting them before copying the link. + +# Modules + +mod_module: + job_discord_message: "$(author) We don't do job posts here; see $(rules)" + job_console_message: Deleted job post message from $(author) + spam_message: | + Kicked + $(author) + for spam and deleted + $(messages) + identical messaged + +get_ts_module: + downloading: 'Downloading typescript@$(version)' + doesnt_exists: typescript@$(version) does not exist diff --git a/package.json b/package.json index fbd94ea..c85498d 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,13 @@ "description": "Typescript Community Bot", "main": "dist/index.js", "dependencies": { + "@types/js-yaml": "^4.0.9", "@typescript/twoslash": "^3.2.1", "algoliasearch": "^4.14.2", "discord.js": "^14.6.0", "dotenv-safe": "^8.2.0", "html-entities": "^2.3.3", + "js-yaml": "^4.1.0", "lz-string": "^1.4.4", "npm-registry-fetch": "^14.0.2", "parse-duration": "^1.0.2", diff --git a/src/index.ts b/src/index.ts index a69fc9c..30ddc94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,8 @@ import { repModule } from './modules/rep'; import { twoslashModule } from './modules/twoslash'; import { snippetModule } from './modules/snippet'; import { helpForumModule } from './modules/helpForum'; +import { Localization } from './modules/localization'; +import path from 'path'; const client = new Client({ partials: [ @@ -37,6 +39,10 @@ const client = new Client({ getDB().then(() => client.login(token)); +export const LOCALIZATION = new Localization( + path.resolve(process.cwd(), 'localization.yml'), +); + client.on('ready', async () => { const bot = new Bot(client); console.log(`Logged in as ${client.user?.tag}`); diff --git a/src/modules/autorole.ts b/src/modules/autorole.ts index dcbc4dc..4fa2e5c 100644 --- a/src/modules/autorole.ts +++ b/src/modules/autorole.ts @@ -1,11 +1,14 @@ import { Bot } from '../bot'; import { autorole, rolesChannelId } from '../env'; +import { LOCALIZATION } from '../index'; export async function autoroleModule({ client }: Bot) { const channel = await client.channels.fetch(rolesChannelId); if (!channel?.isTextBased()) { console.error( - `Roles channel (${rolesChannelId}) does not exist or is not text based.`, + LOCALIZATION.getLocalizedText('roles_channel_doesnt_exists', { + rolesChannelId, + }), ); return; } @@ -13,7 +16,11 @@ export async function autoroleModule({ client }: Bot) { for (const ar of autorole) { const msg = await channel.messages.fetch(ar.msgID); if (!msg) { - console.error(`Role message does not exist for ${ar.msgID}`); + console.error( + LOCALIZATION.getLocalizedText('roles_message_doesnt_exists', { + id: ar.msgID, + }), + ); } await msg?.react(ar.emoji); } @@ -32,7 +39,12 @@ export async function autoroleModule({ client }: Bot) { if (ar.autoRemove) await reaction.users.remove(user.id); const member = await msg.guild.members.fetch(user.id); await member.roles.add(ar.roleID); - console.log('Gave role', ar.roleID, 'to', member); + console.log( + LOCALIZATION.getLocalizedText('gave_role', { + role: ar.roleID, + member, + }), + ); if (!reaction.users.cache.has(client.user.id)) { await msg.react(reaction.emoji); } @@ -53,7 +65,12 @@ export async function autoroleModule({ client }: Bot) { continue; const member = await msg.guild.members.fetch(user.id); await member.roles.remove(ar.roleID); - console.log('Removed role', ar.roleID, 'from', member); + console.log( + LOCALIZATION.getLocalizedText('role_removed', { + role: ar.roleID, + member, + }), + ); } }); } diff --git a/src/modules/etc.ts b/src/modules/etc.ts index 92c3328..a9766fd 100644 --- a/src/modules/etc.ts +++ b/src/modules/etc.ts @@ -12,6 +12,7 @@ import { DELETE_EMOJI, ownsBotMessage, } from '../util/send'; +import { LOCALIZATION } from '../index'; const emojiRegex = /<:\w+?:(\d+?)>|(\p{Emoji_Presentation})/gu; @@ -20,9 +21,11 @@ const defaultPollEmojis = ['✅', '❌', '🤷']; export function etcModule(bot: Bot) { bot.registerCommand({ aliases: ['ping'], - description: 'See if the bot is alive', + description: LOCALIZATION.getLocalizedText('command_ping.description'), async listener(msg) { - await msg.channel.send('pong. :ping_pong:'); + await msg.channel.send( + LOCALIZATION.getLocalizedText('command_ping.listener_message'), + ); }, }); @@ -81,7 +84,9 @@ export function etcModule(bot: Bot) { .map(([emoji, count]) => `${count} ${emoji}`) .join(' '); await suggestion.reply({ - content: `Polling finished; result: ${pollingResultStr}`, + content: LOCALIZATION.getLocalizedText('polling_finished', { + pollingResultStr, + }), }); }); diff --git a/src/modules/handbook.ts b/src/modules/handbook.ts index ae8115f..7c23666 100644 --- a/src/modules/handbook.ts +++ b/src/modules/handbook.ts @@ -4,6 +4,7 @@ import { sendWithMessageOwnership } from '../util/send'; import { TS_BLUE } from '../env'; import { decode } from 'html-entities'; import { Bot } from '../bot'; +import { LOCALIZATION } from '../index'; const ALGOLIA_APP_ID = 'BGCDYOIYZ5'; const ALGOLIA_API_KEY = '37ee06fa68db6aef451a490df6df7c60'; @@ -18,14 +19,18 @@ type AlgoliaResult = { const HANDBOOK_EMBED = new EmbedBuilder() .setColor(TS_BLUE) - .setTitle('The TypeScript Handbook') - .setURL('https://www.typescriptlang.org/docs/handbook/intro.html') - .setFooter({ text: 'You can search with `!handbook `' }); + .setTitle(LOCALIZATION.getLocalizedText('handbook_embed.title')) + .setURL(LOCALIZATION.getLocalizedText('handbook_embed.url')) + .setFooter({ + text: LOCALIZATION.getLocalizedText('handbook_embed.footer.text'), + }); export async function handbookModule(bot: Bot) { bot.registerCommand({ aliases: ['handbook', 'hb'], - description: 'Search the TypeScript Handbook', + description: LOCALIZATION.getLocalizedText( + 'handbook_command.description', + ), async listener(msg, content) { if (!content) { return await sendWithMessageOwnership(msg, { @@ -33,7 +38,9 @@ export async function handbookModule(bot: Bot) { }); } - console.log('Searching algolia for', [content]); + console.log( + LOCALIZATION.getLocalizedText('searching_algolia', { content }), + ); const data = await algolia.search([ { indexName: ALGOLIA_INDEX_NAME, @@ -44,12 +51,16 @@ export async function handbookModule(bot: Bot) { }, }, ]); - console.log('Algolia response:', data); + console.log( + LOCALIZATION.getLocalizedText('algolia_response', { data }), + ); const hit = data.results[0].hits[0]; if (!hit) return await sendWithMessageOwnership( msg, - ':x: No results found for that query', + LOCALIZATION.getLocalizedText( + 'handbook_command.listener.no_results_found', + ), ); const hierarchyParts = [0, 1, 2, 3, 4, 5, 6] .map(i => hit.hierarchy[`lvl${i}`]) diff --git a/src/modules/help.ts b/src/modules/help.ts index c5ff24a..d6bd0b1 100644 --- a/src/modules/help.ts +++ b/src/modules/help.ts @@ -2,6 +2,7 @@ import { EmbedBuilder } from 'discord.js'; import { Bot, CommandRegistration } from '../bot'; import { Snippet } from '../entities/Snippet'; import { sendWithMessageOwnership } from '../util/send'; +import { LOCALIZATION } from '../index'; function getCategoryHelp(cat: string, commands: Iterable) { const out: string[] = []; @@ -37,7 +38,7 @@ function getCommandCategories(commands: Iterable) { export function helpModule(bot: Bot) { bot.registerCommand({ aliases: ['help', 'commands', 'h'], - description: "Sends what you're looking at right now", + description: LOCALIZATION.getLocalizedText('help_command.description'), async listener(msg) { const cmdTrigger = msg.content.split(/\s/)[1]; @@ -49,9 +50,16 @@ export function helpModule(bot: Bot) { name: msg.guild.name, iconURL: msg.guild.iconURL() || undefined, }) - .setTitle('Bot Usage') + .setTitle( + LOCALIZATION.getLocalizedText( + 'help_command_embed.title', + ), + ) .setDescription( - `Hello ${msg.author.username}! Here is a list of all commands in me! To get detailed description on any specific command, do \`help \``, + LOCALIZATION.getLocalizedText( + 'help_command_embed.description', + { username: msg.author.username }, + ), ); for (const cat of getCommandCategories(bot.commands.values())) { @@ -80,19 +88,25 @@ export function helpModule(bot: Bot) { }); if (snippet) cmd = { - description: `A custom snippet created by <@${snippet.owner}>`, + description: LOCALIZATION.getLocalizedText( + 'help_command.listener.snippet_created', + { owner: `<@${snippet.owner}>` }, + ), }; else cmd = { - description: - 'Run the first snippet that matches that pattern', + description: LOCALIZATION.getLocalizedText( + 'help_command.listener.snippet_not_created', + ), }; } if (!cmd.description) return await sendWithMessageOwnership( msg, - `:x: Command not found`, + LOCALIZATION.getLocalizedText( + 'help_command.listener.command_not_found', + ), ); const embed = new EmbedBuilder().setTitle( diff --git a/src/modules/helpForum.ts b/src/modules/helpForum.ts index dd3d261..6034a03 100644 --- a/src/modules/helpForum.ts +++ b/src/modules/helpForum.ts @@ -20,65 +20,42 @@ import { trustedRoleId, } from '../env'; import { sendWithMessageOwnership } from '../util/send'; +import { LOCALIZATION } from '../index'; 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}>.* -`; + listify( + LOCALIZATION.getLocalizedText('forum_how_to_get_help', { + post: here ? 'here' : `in <#${helpForumChannel}>`, + }), + ); + +const howToGiveHelp = listify( + LOCALIZATION.getLocalizedText('how_to_give_help', { + channel: `<#${rolesChannelId}>`, + trusted: `<@&${trustedRoleId}>`, + }), +); + +const helperResolve = (owner: string, helper: string) => + LOCALIZATION.getLocalizedText('helper_resolve', { + owner: `<@${owner}>`, + helper: `<@${helper}>`, + channel: `<#${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.`); + console.error( + LOCALIZATION.getLocalizedText('to_be_forum_channel', { + channel: helpForumChannel, + }), + ); return; } const forumChannel = channel; @@ -89,7 +66,11 @@ export async function helpForumModule(bot: Bot) { .first() ?.channels.fetch(helpRequestsChannel)!; if (!helpRequestChannel?.isTextBased()) { - console.error(`Expected ${helpRequestChannel} to be a text channel.`); + console.error( + LOCALIZATION.getLocalizedText('to_be_text_channel', { + channel: helpRequestChannel, + }), + ); return; } @@ -99,10 +80,10 @@ export async function helpForumModule(bot: Bot) { const owner = await thread.fetchOwner(); if (!owner?.user || !isHelpThread(thread)) return; console.log( - 'Received new question from', - owner.user.tag, - 'in thread', - thread.id, + LOCALIZATION.getLocalizedText('new_request', { + owner: owner.user.tag, + thread: thread.id, + }), ); await HelpThread.create({ @@ -122,12 +103,16 @@ export async function helpForumModule(bot: Bot) { bot.registerCommand({ aliases: ['helper', 'helpers'], - description: 'Help System: Ping the @Helper role from a help post', + description: LOCALIZATION.getLocalizedText( + 'helpers_command.description', + ), async listener(msg, comment) { if (!isHelpThread(msg.channel)) { return sendWithMessageOwnership( msg, - ':warning: You may only ping helpers from a help post', + LOCALIZATION.getLocalizedText( + 'helpers_command.listener.not_help_channel', + ), ); } @@ -141,7 +126,9 @@ export async function helpForumModule(bot: Bot) { if (!isAsker && !isTrusted) { return sendWithMessageOwnership( msg, - ':warning: Only the asker can ping helpers', + LOCALIZATION.getLocalizedText( + 'helpers_command.listener.only_asker', + ), ); } @@ -155,9 +142,14 @@ export async function helpForumModule(bot: Bot) { if (isAsker && Date.now() < pingAllowedAfter) { return sendWithMessageOwnership( msg, - `:warning: Please wait a bit longer. You can ping helpers .`, + LOCALIZATION.getLocalizedText( + 'helpers_command.listener.pls_wait', + { + time: `.`, + }, + ), ); } @@ -190,7 +182,9 @@ export async function helpForumModule(bot: Bot) { bot.registerCommand({ aliases: ['resolved', 'resolve', 'close', 'closed', 'done', 'solved'], - description: 'Help System: Mark a post as resolved', + description: LOCALIZATION.getLocalizedText( + 'resolved_command.description', + ), async listener(msg) { changeStatus(msg, true); }, @@ -198,7 +192,9 @@ export async function helpForumModule(bot: Bot) { bot.registerCommand({ aliases: ['reopen', 'open', 'unresolved', 'unresolve'], - description: 'Help System: Reopen a resolved post', + description: LOCALIZATION.getLocalizedText( + 'reopen_command.description', + ), async listener(msg) { changeStatus(msg, false); }, @@ -231,7 +227,7 @@ export async function helpForumModule(bot: Bot) { if (!isHelpThread(thread)) { return sendWithMessageOwnership( msg, - ':warning: Can only be run in a help post', + LOCALIZATION.getLocalizedText('change_status.not_help_thread'), ); } @@ -242,7 +238,7 @@ export async function helpForumModule(bot: Bot) { if (!isAsker && !isTrusted) { return sendWithMessageOwnership( msg, - ':warning: Only the asker can change the status of a help post', + LOCALIZATION.getLocalizedText('change_status.only_asker'), ); } diff --git a/src/modules/localization.ts b/src/modules/localization.ts new file mode 100644 index 0000000..de07898 --- /dev/null +++ b/src/modules/localization.ts @@ -0,0 +1,73 @@ +import * as fs from 'fs'; +import * as yaml from 'js-yaml'; + +export type Rename = Record; + +export class Localization { + private cache: any = null; + + public constructor(public path: string) { + this.cache = this.getLocalization(); + } + + private getLocalization(): any { + const fileContents = fs.readFileSync(this.path, 'utf8'); + return yaml.load(fileContents); + } + + private getMessage( + localization: any, + field: string, + rename: Record = {}, + ): string { + const keys: string[] = field.split('.'); + let message: any = localization; + for (const key of keys) { + if (message && typeof message === 'object' && key in message) { + message = message[key]; + } else { + return `No translation (${field})`; + } + } + + if (!message) return `No translation (${field})`; + + let localizedMessage: string = message; + + localizedMessage = localizedMessage.replace(/<>/g, '»'); + localizedMessage = localizedMessage.replace(/--/g, '—'); + + for (const [key, value] of Object.entries(rename)) { + localizedMessage = localizedMessage.replace( + new RegExp(`\\$\\(${key}\\)`, 'g'), + value, + ); + } + + return localizedMessage; + } + + public getLocalizedText(field: string, rename: Rename = {}): string { + return this.getMessage(this.cache, field, rename); + } + + public getForceLocalizedText(field: string, rename: Rename = {}): string { + return this.getMessage(this.getLocalization(), field, rename); + } + + public getSafeMessage(field: string, rename: Rename = {}): string | null { + const text = this.getMessage(this.cache, field, rename); + if (text == `No translation (${field})`) return null; + return text; + } + + public getForceSafeMessage( + field: string, + rename: Rename = {}, + ): string | null { + const text = this.getForceLocalizedText(field, rename); + if (text == `No translation (${field})`) return null; + return text; + } +} diff --git a/src/modules/mod.ts b/src/modules/mod.ts index af215eb..8106823 100644 --- a/src/modules/mod.ts +++ b/src/modules/mod.ts @@ -1,6 +1,7 @@ import { Message, Snowflake, User } from 'discord.js'; import { Bot } from '../bot'; import { rulesChannelId } from '../env'; +import { LOCALIZATION } from '../index'; // Most job posts are in this format: // > [FOR HIRE][REMOTE][SOMETHING ELSE] @@ -24,9 +25,16 @@ export function modModule({ client }: Bot) { if (msg.author.bot || !jobPostRegex.test(msg.content)) return; await msg.delete(); await msg.channel.send( - `${msg.author} We don't do job posts here; see <#${rulesChannelId}>`, + LOCALIZATION.getLocalizedText('mod_module.job_discord_message', { + author: msg.author, + rules: `<#${rulesChannelId}>`, + }), + ); + console.log( + LOCALIZATION.getLocalizedText('mod_module.job_console_message', { + author: msg.author, + }), ); - console.log('Deleted job post message from', msg.author); }); client.on('messageCreate', async msg => { @@ -49,11 +57,10 @@ export function modModule({ client }: Bot) { ...recentMessageInfo.messages.map(msg => void msg.delete()), ]); console.log( - 'Kicked', - msg.author, - 'for spam and deleted', - recentMessageInfo.messages.length, - 'identical messages', + LOCALIZATION.getLocalizedText('mod_module.spam_messages', { + author: msg.author, + messages: recentMessageInfo.messages.length, + }), ); } } else { diff --git a/src/modules/playground.ts b/src/modules/playground.ts index 593f7ba..817f9a7 100644 --- a/src/modules/playground.ts +++ b/src/modules/playground.ts @@ -16,6 +16,7 @@ import { LimitedSizeMap } from '../util/limitedSizeMap'; import { addMessageOwnership, sendWithMessageOwnership } from '../util/send'; import { fetch } from 'undici'; import { Bot } from '../bot'; +import { LOCALIZATION } from '../index'; const PLAYGROUND_BASE = 'https://www.typescriptlang.org/play/#code/'; const LINK_SHORTENER_ENDPOINT = 'https://tsplay.dev/api/short'; @@ -27,9 +28,16 @@ export async function playgroundModule(bot: Bot) { bot.registerCommand({ aliases: ['playground', 'pg', 'playg'], - description: 'Shorten a TypeScript playground link', + description: LOCALIZATION.getLocalizedText( + 'playground_command.description', + ), async listener(msg, content) { - console.log('Playground', msg.content); + console.log( + LOCALIZATION.getLocalizedText( + 'playground_command.listener.log', + { content: msg.content }, + ), + ); let code: string | undefined = content; @@ -38,12 +46,18 @@ export async function playgroundModule(bot: Bot) { if (!code) return sendWithMessageOwnership( msg, - ":warning: couldn't find a codeblock!", + LOCALIZATION.getLocalizedText( + 'playground_command.listener.not_code', + ), ); } const embed = new EmbedBuilder() .setURL(PLAYGROUND_BASE + compressToEncodedURIComponent(code)) - .setTitle('View in Playground') + .setTitle( + LOCALIZATION.getLocalizedText( + 'playground_command_embed.title', + ), + ) .setColor(TS_BLUE); await sendWithMessageOwnership(msg, { embeds: [embed] }); }, @@ -65,7 +79,10 @@ export async function playgroundModule(bot: Bot) { // Message also contained other characters 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.`, + content: LOCALIZATION.getLocalizedText( + 'shortened_url_playground', + { author: msg.author }, + ), }); editedLongLink.set(msg.id, botMsg); await addMessageOwnership(botMsg, msg.author); @@ -111,7 +128,7 @@ function createPlaygroundEmbed( ) { const embed = new EmbedBuilder() .setColor(TS_BLUE) - .setTitle('Playground Link') + .setTitle(LOCALIZATION.getLocalizedText('playground_link_embed.title')) .setAuthor({ name: author.tag, iconURL: author.displayAvatarURL() }) .setURL(url); @@ -170,7 +187,7 @@ function createPlaygroundEmbed( embed.setDescription('**Preview:**' + makeCodeBlock(content)); if (!startLine && !endLine) { embed.setFooter({ - text: 'You can choose specific lines to embed by selecting them before copying the link.', + text: LOCALIZATION.getLocalizedText('you_can_spec_lines'), }); } } diff --git a/src/modules/rep.ts b/src/modules/rep.ts index ac9c138..9eb00fd 100644 --- a/src/modules/rep.ts +++ b/src/modules/rep.ts @@ -5,6 +5,7 @@ import { Rep } from '../entities/Rep'; import { sendPaginatedMessage } from '../util/sendPaginatedMessage'; import { getMessageOwner, sendWithMessageOwnership } from '../util/send'; import { Bot } from '../bot'; +import { LOCALIZATION } from '../index'; // The Chinese is outside the group on purpose, because CJK languages don't have word bounds. Therefore we only look for key characters @@ -21,7 +22,9 @@ export function repModule(bot: Bot) { msg: Pick, { recipient, initialGiver }: Pick, ) { - console.log('Creating a Rep with recipient', recipient); + console.log( + LOCALIZATION.getLocalizedText('creating_rep', { recipient }), + ); return Rep.create({ messageId: msg.id, @@ -67,39 +70,57 @@ export function repModule(bot: Bot) { const msg = reaction.message; const author = (await msg.fetch()).author; - console.log('Received rep reaction on', msg.id); + console.log( + LOCALIZATION.getLocalizedText('received_rep', { message: msg.id }), + ); if (user.id === author.id) { return removeReaction(); } - console.log('Querying database for existing Rep'); + console.log(LOCALIZATION.getLocalizedText('querying_for_rep')); let existingRep = await Rep.findOne({ where: { messageId: msg.id } }); if (existingRep) { - console.log('Found existing Rep', existingRep); + console.log( + LOCALIZATION.getLocalizedText('rep_exist_found', { + rep: existingRep, + }), + ); if (user.id === existingRep.recipient) { - console.log('User is recipient; removing reaction'); + console.log(LOCALIZATION.getLocalizedText('user_is_recipient')); return removeReaction(); } - console.log('Existing amount is', existingRep.amount); + console.log( + LOCALIZATION.getLocalizedText('existing_amount_is', { + count: existingRep.amount, + }), + ); existingRep.amount++; existingRep.save(); - console.log('Incremented amount to', existingRep.amount); + console.log( + LOCALIZATION.getLocalizedText('incremented_amount', { + amount: existingRep.amount, + }), + ); return; } let recipient = author.id; if (recipient == client.user.id) { - console.log('Recipient is bot; checking for message ownership'); + console.log(LOCALIZATION.getLocalizedText('recipient_is_bot')); let altRecipient = getMessageOwner(msg); if (!altRecipient) { - console.log('No message owner recorded; removing reaction'); + console.log(LOCALIZATION.getLocalizedText('no_message_owner')); return removeReaction(); } - console.log('Message owner is', altRecipient); + console.log( + LOCALIZATION.getLocalizedText('message_owner_is', { + owner: altRecipient, + }), + ); recipient = altRecipient; } @@ -134,10 +155,10 @@ export function repModule(bot: Bot) { await rep.save(); console.log( - 'Decremented rep amount to', - rep.amount, - 'for message', - rep.messageId, + LOCALIZATION.getLocalizedText('decremented_rep', { + amount: rep.amount, + id: rep.messageId, + }), ); }); @@ -147,7 +168,9 @@ export function repModule(bot: Bot) { bot.registerCommand({ aliases: ['rep'], - description: 'Reputation: Give a different user some reputation points', + description: LOCALIZATION.getLocalizedText( + 'reputation_command.description', + ), async listener(msg) { const targetMember = msg.content.split(/\s/)[1]; @@ -176,7 +199,9 @@ export function repModule(bot: Bot) { bot.registerCommand({ aliases: ['history'], - description: "Reputation: View a user's reputation history", + description: LOCALIZATION.getLocalizedText( + 'history_command.description', + ), async listener(msg) { if (!msg.member) return; let user = await bot.getTargetUser(msg); @@ -184,7 +209,9 @@ export function repModule(bot: Bot) { if (!user) { await sendWithMessageOwnership( msg, - 'Unable to find user to give rep', + LOCALIZATION.getLocalizedText( + 'history_command.listener.cannot_find', + ), ); return; } @@ -232,7 +259,9 @@ export function repModule(bot: Bot) { bot.registerCommand({ aliases: ['leaderboard', 'lb'], - description: 'Reputation: See who has the most reputation', + description: LOCALIZATION.getLocalizedText( + 'leaderboard_command.description', + ), async listener(msg) { const periods = { 'rolling-hour': ['(past hour)', Date.now() - 60 * 60 * 1000], @@ -266,9 +295,14 @@ export function repModule(bot: Bot) { if (!(period in periods)) return await sendWithMessageOwnership( msg, - `:x: Invalid period (expected one of ${Object.keys(periods) - .map(x => `\`${x}\``) - .join(', ')})`, + LOCALIZATION.getLocalizedText( + 'leaderboard_command.listener.invalid_period', + { + of: `${Object.keys(periods) + .map(x => `\`${x}\``) + .join(', ')}`, + }, + ), ); const [text, dateMin] = periods[period as keyof typeof periods]; const topEmojis = [ diff --git a/src/modules/snippet.ts b/src/modules/snippet.ts index 096ab0b..43cd738 100644 --- a/src/modules/snippet.ts +++ b/src/modules/snippet.ts @@ -5,6 +5,7 @@ import { sendWithMessageOwnership } from '../util/send'; import { getReferencedMessage } from '../util/getReferencedMessage'; import { splitCustomCommand } from '../util/customCommand'; import { Bot } from '../bot'; +import { LOCALIZATION } from '../index'; // https://stackoverflow.com/a/3809435 const LINK_REGEX = @@ -52,8 +53,10 @@ export function snippetModule(bot: Bot) { }); bot.registerCommand({ - description: 'Snippet: List snippets matching an optional filter', aliases: ['listSnippets', 'snippets', 'snips'], + description: LOCALIZATION.getLocalizedText( + 'snippets_command.description', + ), async listener(msg, specifier) { const limit = 20; const matches = await interpretSpecifier( @@ -87,8 +90,10 @@ export function snippetModule(bot: Bot) { }); bot.registerCommand({ - description: 'Snippet: Create or edit a snippet', aliases: ['snip', 'snippet', 'createSnippet'], + description: LOCALIZATION.getLocalizedText( + 'snippet_command.description', + ), async listener(msg, content) { if (!msg.member) return; @@ -100,7 +105,9 @@ export function snippetModule(bot: Bot) { if (!name) { return await sendWithMessageOwnership( msg, - ':x: You have to supply a name for the command', + LOCALIZATION.getLocalizedText( + 'snippet_command.listener.not_name', + ), ); } @@ -114,7 +121,9 @@ export function snippetModule(bot: Bot) { if (!id.includes(':') && !bot.isMod(msg.member)) return await sendWithMessageOwnership( msg, - ":x: You don't have permission to create a global snippet", + LOCALIZATION.getLocalizedText( + 'snippet_command.listener.dont_have_permissions', + ), ); if ( @@ -124,7 +133,9 @@ export function snippetModule(bot: Bot) { ) return await sendWithMessageOwnership( msg, - ":x: Cannot edit another user's snippet", + LOCALIZATION.getLocalizedText( + 'snippet_command.listener.cannot_edit', + ), ); const title = `\`!${id}\`: `; @@ -143,7 +154,9 @@ export function snippetModule(bot: Bot) { if (!referencedSnippet) return await sendWithMessageOwnership( msg, - ':x: Second argument must be a valid discord message link or snippet id', + LOCALIZATION.getLocalizedText( + 'snippet_command.listener.second_arg', + ), ); data = { ...referencedSnippet, @@ -161,7 +174,9 @@ export function snippetModule(bot: Bot) { if (!sourceMessage) return await sendWithMessageOwnership( msg, - ':x: You have to reply or link to a comment to make it a snippet', + LOCALIZATION.getLocalizedText( + 'snippet_command.listener.have_reply', + ), ); const description = sourceMessage.content; @@ -192,7 +207,9 @@ export function snippetModule(bot: Bot) { if (!data) { return await sendWithMessageOwnership( msg, - ':x: Cannot generate a snippet from that message', + LOCALIZATION.getLocalizedText( + 'snippet_command.listener.cannot_generate', + ), ); } @@ -208,24 +225,40 @@ export function snippetModule(bot: Bot) { }); bot.registerCommand({ - description: 'Snippet: Delete a snippet you own', aliases: ['deleteSnip'], + description: LOCALIZATION.getLocalizedText( + 'delete_snippet_command.description', + ), async listener(msg, id) { if (!msg.member) return; const snippet = await Snippet.findOneBy({ id }); if (!snippet) return await sendWithMessageOwnership( msg, - ':x: No snippet found with that id', + LOCALIZATION.getLocalizedText( + 'delete_snippet_command.listener.not_found', + ), ); if (!bot.isMod(msg.member) && snippet.owner !== msg.author.id) return await sendWithMessageOwnership( msg, - ":x: Cannot delete another user's snippet", + LOCALIZATION.getLocalizedText( + 'delete_snippet_command.listener.cannot_delete', + ), ); await snippet.remove(); - console.log(`Deleted snippet ${id} for`, msg.author); - sendWithMessageOwnership(msg, ':white_check_mark: Deleted snippet'); + console.log( + LOCALIZATION.getLocalizedText( + 'delete_snippet_command.listener.snippet_deleted_log', + { id, author: msg.author }, + ), + ); + sendWithMessageOwnership( + msg, + LOCALIZATION.getLocalizedText( + 'delete_snippet_command.listener.snippet_deleted_msg', + ), + ); }, }); diff --git a/src/modules/twoslash.ts b/src/modules/twoslash.ts index 0c41aaa..164b34c 100644 --- a/src/modules/twoslash.ts +++ b/src/modules/twoslash.ts @@ -6,6 +6,7 @@ import { sendWithMessageOwnership } from '../util/send'; import { getTypeScriptModule, TypeScript } from '../util/getTypeScriptModule'; import { splitCustomCommand } from '../util/customCommand'; import { Bot } from '../bot'; +import { LOCALIZATION } from '../index'; const defaultCompilerOptions: CompilerOptions = { target: ScriptTarget.ESNext, @@ -21,9 +22,10 @@ function redactNoErrorTruncation(code: string) { export function twoslashModule(bot: Bot) { bot.registerCommand({ - description: - 'Twoslash: Run twoslash on the latest codeblock, optionally returning the quick infos of specified symbols. You can use ts@4.8.3 or ts@next to run a specific version.', aliases: ['twoslash', 'ts'], + description: LOCALIZATION.getLocalizedText( + 'twoslash_command.description', + ), async listener(msg, content) { await twoslash(msg, 'latest', content); }, @@ -60,7 +62,9 @@ async function twoslash(msg: Message, version: string, content: string) { if (!tsModule) return await sendWithMessageOwnership( msg, - ':x: Could not find that version of TypeScript', + LOCALIZATION.getLocalizedText( + 'twoslash_command.listener.cannot_find', + ), ); const code = await findCode(msg); @@ -68,7 +72,9 @@ async function twoslash(msg: Message, version: string, content: string) { if (!code) return await sendWithMessageOwnership( msg, - `:warning: could not find any TypeScript codeblocks in the past 10 messages`, + LOCALIZATION.getLocalizedText( + 'twoslash_command.listener.not_codeblocks', + ), ); if (!content) return await twoslashBlock(msg, code, tsModule); @@ -76,7 +82,9 @@ async function twoslash(msg: Message, version: string, content: string) { if (!/^\s*([_$a-zA-Z][_$0-9a-zA-Z]*\b\s*)+/.test(content)) { return sendWithMessageOwnership( msg, - 'You need to give me a valid symbol name to look for!', + LOCALIZATION.getLocalizedText( + 'twoslash_command.listener.invalid_symbol', + ), ); } diff --git a/src/util/getTypeScriptModule.ts b/src/util/getTypeScriptModule.ts index 9bdb88b..6d8185a 100644 --- a/src/util/getTypeScriptModule.ts +++ b/src/util/getTypeScriptModule.ts @@ -4,6 +4,7 @@ import tar from 'tar'; import path from 'path'; import os from 'os'; import { once } from 'events'; +import { LOCALIZATION } from '../index'; export type TypeScript = typeof import('typescript'); @@ -29,12 +30,18 @@ let tsPackageData: export async function getTypeScriptModule( version: string | null, ): Promise { - console.log(`Downloading typescript@${version}`); + console.log( + LOCALIZATION.getLocalizedText('get_ts_module.downloading', { version }), + ); version = await resolveVersion(version); if (!version) { - console.log(`typescript@${version} does not exist`); + console.log( + LOCALIZATION.getLocalizedText('get_ts_module.doesnt_exists', { + version, + }), + ); return null; } diff --git a/yarn.lock b/yarn.lock index e625c25..8d88b8f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -234,6 +234,11 @@ dependencies: dotenv "^8.2.0" +"@types/js-yaml@^4.0.9": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.9.tgz#cd82382c4f902fed9691a2ed79ec68c5898af4c2" + integrity sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg== + "@types/lz-string@1.3.34": version "1.3.34" resolved "https://registry.yarnpkg.com/@types/lz-string/-/lz-string-1.3.34.tgz#69bfadde419314b4a374bf2c8e58659c035ed0a5"