├── .nvmrc ├── .npmrc ├── src ├── v2 │ ├── utils │ │ ├── index.ts │ │ ├── unary.ts │ │ ├── flatten.ts │ │ ├── merge.ts │ │ ├── string.ts │ │ ├── callOrValue.ts │ │ ├── valueOrCall.ts │ │ ├── castArray.ts │ │ ├── asyncCatch.ts │ │ ├── content_format.ts │ │ ├── pipe.ts │ │ ├── some.ts │ │ ├── pluck.ts │ │ ├── chunkUntil.ts │ │ ├── clampStr.ts │ │ ├── sets.ts │ │ ├── errors.ts │ │ ├── partition.ts │ │ ├── reactions.ts │ │ ├── map.ts │ │ ├── codeBlockCapturer.ts │ │ ├── emojis.ts │ │ ├── delayedMessageAutoDeletion.ts │ │ ├── DeferredPromise.ts │ │ ├── build-command-string.ts │ │ ├── pluralize.ts │ │ ├── search.ts │ │ ├── normalizeCommand.ts │ │ ├── useData.test.ts │ │ ├── useData.ts │ │ └── urlTools.ts │ ├── modules │ │ ├── mod │ │ │ ├── index.ts │ │ │ └── commands │ │ │ │ ├── onboardingMsg.ts │ │ │ │ ├── onboardingBegin.ts │ │ │ │ ├── index.ts │ │ │ │ └── roles.ts │ │ ├── roles │ │ │ ├── index.ts │ │ │ ├── utils │ │ │ │ ├── generateRoleSelect.ts │ │ │ │ └── getAddRemoveRoles.ts │ │ │ ├── consts │ │ │ │ ├── notifyRoles.ts │ │ │ │ └── roles.ts │ │ │ ├── events │ │ │ │ ├── handleAutoCompleteRole.ts │ │ │ │ └── handleAddRemoveRole.ts │ │ │ └── commands │ │ │ │ ├── change.ts │ │ │ │ ├── suggest.ts │ │ │ │ └── index.ts │ │ └── onboarding │ │ │ ├── utils │ │ │ ├── streamFilter.ts │ │ │ ├── sneakPin.ts │ │ │ ├── getThread.ts │ │ │ ├── limitToWebDevServer.ts │ │ │ ├── getMessagesUntil.ts │ │ │ ├── onboardingStart.ts │ │ │ └── continueOnboarding.ts │ │ │ ├── steps │ │ │ ├── handleOnboarded.ts │ │ │ ├── handleStart.ts │ │ │ ├── handleIntroduction.ts │ │ │ └── handleRoleSelection.ts │ │ │ ├── events │ │ │ ├── handleMemberLeave.ts │ │ │ ├── handleIntroductionMsg.ts │ │ │ ├── handleSkipIntro.ts │ │ │ ├── handleRulesAgree.ts │ │ │ ├── handleThreadArchived.ts │ │ │ ├── handleNotifyRolesSelected.ts │ │ │ ├── handleRoleSelected.ts │ │ │ └── handleNewMember.ts │ │ │ ├── db │ │ │ └── user_state.ts │ │ │ ├── consts │ │ │ └── rules.ts │ │ │ └── index.ts │ ├── user_context │ │ ├── ban.ts │ │ └── index.ts │ ├── message_context │ │ ├── ban.ts │ │ └── index.ts │ ├── env.ts │ ├── commands │ │ ├── please │ │ │ ├── handlers │ │ │ │ ├── justask.ts │ │ │ │ ├── code.ts │ │ │ │ ├── english.ts │ │ │ │ └── format │ │ │ │ │ ├── index.ts │ │ │ │ │ └── exampleFns.ts │ │ │ └── index.ts │ │ ├── whyno │ │ │ ├── handlers │ │ │ │ ├── channel.ts │ │ │ │ ├── jquery.ts │ │ │ │ └── sass.ts │ │ │ └── index.ts │ │ ├── about │ │ │ ├── handlers │ │ │ │ ├── lockfile.ts │ │ │ │ ├── flexbox.ts │ │ │ │ ├── vscode.ts │ │ │ │ └── modules.ts │ │ │ └── index.ts │ │ ├── npm │ │ │ └── types.ts │ │ ├── db_model.ts │ │ ├── post │ │ │ ├── env.ts │ │ │ ├── questions.ts │ │ │ └── questions.v2.ts │ │ ├── resource │ │ │ ├── handlers │ │ │ │ └── javascript.ts │ │ │ └── index.ts │ │ ├── shitpost │ │ │ └── index.ts │ │ ├── warn │ │ │ └── index.ts │ │ ├── php │ │ │ └── index.ts │ │ └── mdn │ │ │ └── index.ts │ ├── helpful_role │ │ ├── db_model.ts │ │ ├── index.ts │ │ ├── point_handler.ts │ │ └── point_decay.ts │ ├── autorespond │ │ ├── code_parsing │ │ │ ├── hasVarInSource.ts │ │ │ └── index.ts │ │ ├── thanks │ │ │ ├── db_model.ts │ │ │ ├── checker.test.ts │ │ │ ├── createResponse.ts │ │ │ ├── checker.ts │ │ │ ├── thanks.ts │ │ │ └── thankyou.ts │ │ ├── justask.ts │ │ ├── deprecatedCommands.ts │ │ └── html_parsing │ │ │ └── index.ts │ ├── cache │ │ ├── model.ts │ │ ├── index.ts │ │ └── cacheFns.ts │ ├── spam_filter │ │ ├── handler.ts │ │ └── index.ts │ └── index.ts ├── index.ts ├── types.d.ts ├── enums.ts └── env.ts ├── .eslintignore ├── renovate.json ├── .gitignore ├── .prettierrc ├── logo.png ├── jest.config.js ├── .dockerignore ├── webdev-support-bot-demo.gif ├── .vscode ├── extensions.json └── settings.json ├── .mergify.yml ├── docker-compose.dev.yml ├── Dockerfile ├── tsconfig.json ├── .github └── workflows │ ├── build-docker-image.yaml │ └── test.yaml ├── .eslintrc.cjs ├── LICENSE ├── .env.example ├── docs └── HELPFUL_USER_MODULE.md ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.12.2 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | safe-exact=true -------------------------------------------------------------------------------- /src/v2/utils/index.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/v2/modules/mod/index.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/v2/user_context/ban.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/v2/message_context/ban.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/v2/modules/roles/index.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import './v2/index.js'; 2 | -------------------------------------------------------------------------------- /src/v2/env.ts: -------------------------------------------------------------------------------- 1 | export * from '../env.js'; 2 | -------------------------------------------------------------------------------- /src/v2/modules/onboarding/utils/streamFilter.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/__fixtures__ 2 | **/__snapshots__ -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .env 3 | package-lock.json 4 | /build -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "arrowParens": "avoid" 4 | } 5 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/r-webdev/webdev-support-bot/HEAD/logo.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | }; 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .env 3 | package-lock.json 4 | /build 5 | .git/ 6 | docs/ 7 | .vscode/ 8 | .github/ -------------------------------------------------------------------------------- /webdev-support-bot-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/r-webdev/webdev-support-bot/HEAD/webdev-support-bot-demo.gif -------------------------------------------------------------------------------- /src/v2/utils/unary.ts: -------------------------------------------------------------------------------- 1 | export function unary(fn: (firstArg: T, ...restArgs: never[]) => U) { 2 | return (arg: T): U => fn(arg); 3 | } 4 | -------------------------------------------------------------------------------- /src/v2/utils/flatten.ts: -------------------------------------------------------------------------------- 1 | export function* flatten(iter: Iterable>): IterableIterator { 2 | for (const item of iter) { 3 | yield* item; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/v2/utils/merge.ts: -------------------------------------------------------------------------------- 1 | export function* merge(...iterables: Iterable[]): IterableIterator { 2 | for (const iterable of iterables) { 3 | yield* iterable; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/v2/commands/please/handlers/justask.ts: -------------------------------------------------------------------------------- 1 | export const justAsk = [ 2 | 'justask', 3 | `**Don't ask to ask. Just ask.** 4 | Here's why https://sol.gfxile.net/dontask.html`, 5 | ] as const; 6 | -------------------------------------------------------------------------------- /src/v2/utils/string.ts: -------------------------------------------------------------------------------- 1 | export const capitalize = (str: string): string => 2 | str 3 | .split(' ') 4 | .map(s => `${s[0].toUpperCase()}${s.slice(1).toLowerCase()}`) 5 | .join(' '); 6 | -------------------------------------------------------------------------------- /src/v2/utils/callOrValue.ts: -------------------------------------------------------------------------------- 1 | export function callOrValue(item: unknown, ...args: unknown[]): T { 2 | if (typeof item === 'function') { 3 | return item(...args); 4 | } 5 | return item as T; 6 | } 7 | -------------------------------------------------------------------------------- /src/v2/utils/valueOrCall.ts: -------------------------------------------------------------------------------- 1 | export function valueOrCall(valueOrFn: T | (() => T)): T { 2 | return valueOrFn instanceof Function ? valueOrFn() : valueOrFn; 3 | } 4 | 5 | export type ValueOrNullary = T | (() => T); 6 | -------------------------------------------------------------------------------- /src/v2/utils/castArray.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * If item is an array return it else wrap it in an array 3 | * 4 | * WIIIILLSOOOOON 5 | * @param item 6 | */ 7 | export function castArray(item: T | T[]): T[] { 8 | return Array.isArray(item) ? item : [item]; 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "wix.vscode-import-cost", 6 | "eamodio.gitlens", 7 | "kisstkondoros.vscode-gutter-preview", 8 | "github.vscode-pull-request-github" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /src/v2/utils/asyncCatch.ts: -------------------------------------------------------------------------------- 1 | export function asyncCatch( 2 | fn: (...args: T) => Promise 3 | ): (...args: T) => Promise { 4 | return async (...args: T): Promise => { 5 | try { 6 | return await fn(...args); 7 | } catch (error) { 8 | console.error(error); 9 | } 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/v2/commands/whyno/handlers/channel.ts: -------------------------------------------------------------------------------- 1 | export const channel: [string, string] = [ 2 | 'channel', 3 | `> Why isn't there a channel for \`foo\`? 4 | 5 | It's usually because we don't get enough questions or discussion on foo to warrant a dedicated channel. If we see an uptick in conversations around foo, we will discuss opening a channel.`, 6 | ]; 7 | -------------------------------------------------------------------------------- /src/v2/utils/content_format.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from 'discord.js'; 2 | 3 | const linebreakPattern = /\n/gimu; 4 | 5 | export const generateCleanContent = (msg: Message): string => 6 | msg.cleanContent.replace(linebreakPattern, ' ').toLowerCase(); 7 | 8 | export const stripMarkdownQuote = (msg: string): string => 9 | msg.replace(/^> .+$/gmu, ''); 10 | -------------------------------------------------------------------------------- /src/v2/utils/pipe.ts: -------------------------------------------------------------------------------- 1 | type PipeFunctions = [ 2 | (input: Input) => unknown, 3 | ...Function[], 4 | (input: unknown) => Output 5 | ]; 6 | 7 | export function pipe( 8 | fns: PipeFunctions 9 | ): (input: Input) => Output { 10 | return (item: Input) => fns.reduce((input, fn) => fn(input), item) as Output; 11 | } 12 | -------------------------------------------------------------------------------- /src/v2/utils/some.ts: -------------------------------------------------------------------------------- 1 | export function some( 2 | predicate: (item: T, index: number, iter: Iterable) => unknown 3 | ) { 4 | return function (iter: Iterable): boolean { 5 | let i = 0; 6 | for (const item of iter) { 7 | if (predicate(item, i++, iter)) { 8 | return true; 9 | } 10 | } 11 | return false; 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /src/v2/commands/about/handlers/lockfile.ts: -------------------------------------------------------------------------------- 1 | export const lockfile: [string, string] = [ 2 | 'lockfile', 3 | `**How to reset your lockfile:** 4 | 5 | 1. Remove your lockfile — it is either \`package-lock.json\` or when using yarn \`yarn.lock\` 6 | 2. Delete the node_modules directory 7 | 3. Install the dependencies again with either \`npm install\` or \`yarn\` 8 | `, 9 | ]; 10 | -------------------------------------------------------------------------------- /src/v2/commands/about/handlers/flexbox.ts: -------------------------------------------------------------------------------- 1 | export const flexbox: [string, string] = [ 2 | 'flexbox', 3 | `**Useful Resources to learn about Flexbox:** 4 | 5 | 1. Flexbox Froggy, Interactive Tutorial: 6 | 2. Flexbox Guide, Text Guide: 7 | 3. What The Flexbox, Video Course: `, 8 | ]; 9 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: automatic merge for Dependabot pull requests 3 | conditions: 4 | - author=dependabot-preview[bot] 5 | actions: 6 | merge: 7 | method: merge 8 | - name: automatic merge for Imgbot pull requests 9 | conditions: 10 | - author=imgbot[bot] 11 | actions: 12 | merge: 13 | method: merge -------------------------------------------------------------------------------- /src/v2/utils/pluck.ts: -------------------------------------------------------------------------------- 1 | export function* pluckʹ, V, K extends PropertyKey>( 2 | iter: Iterable, 3 | str: K 4 | ): Iterable { 5 | for (const item of iter) { 6 | yield item[str]; 7 | } 8 | } 9 | 10 | export function pluck(key: K) { 11 | return = Record>( 12 | iter: Iterable 13 | ): Iterable => pluckʹ(iter, key); 14 | } 15 | -------------------------------------------------------------------------------- /src/v2/helpful_role/db_model.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const { model, Schema } = mongoose; 4 | 5 | const schema = new Schema({ 6 | guild: { 7 | required: true, 8 | type: String, 9 | }, 10 | points: { 11 | default: 0, 12 | type: Number, 13 | }, 14 | user: { 15 | required: true, 16 | type: String, 17 | }, 18 | }); 19 | 20 | export default model('helpfulRoleMember', schema); 21 | -------------------------------------------------------------------------------- /src/v2/modules/onboarding/utils/sneakPin.ts: -------------------------------------------------------------------------------- 1 | import { Message, MessageType } from 'discord.js'; 2 | 3 | export const sneakPin = async (msg: Message): Promise => { 4 | const awaitedPinned = msg.channel.awaitMessages({ 5 | filter: msg => msg.type === MessageType.ChannelPinnedMessage, 6 | max: 1, 7 | }); 8 | 9 | await msg.pin(); 10 | 11 | const pinned = await awaitedPinned; 12 | await pinned.first().delete(); 13 | }; 14 | -------------------------------------------------------------------------------- /src/v2/modules/onboarding/utils/getThread.ts: -------------------------------------------------------------------------------- 1 | import type { Guild, TextChannel, ThreadChannel } from 'discord.js'; 2 | 3 | import { ONBOARDING_CHANNEL } from '../../../env.js'; 4 | 5 | export async function getThread( 6 | guild: Guild, 7 | id: string 8 | ): Promise { 9 | const channel = (await guild.channels.fetch( 10 | ONBOARDING_CHANNEL 11 | )) as TextChannel; 12 | 13 | return channel.threads.fetch(id); 14 | } 15 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ChatInputApplicationCommandData, 3 | ChatInputCommandInteraction, 4 | Client, 5 | CommandInteraction, 6 | Guild, 7 | } from 'discord.js'; 8 | 9 | export type CommandDataWithHandler = ChatInputApplicationCommandData & { 10 | handler: (client: Client, interaction: ChatInputCommandInteraction) => Promise; 11 | onAttach?: (client: Client) => void; 12 | guildValidate?: (guild: Guild) => boolean; 13 | }; 14 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | services: 2 | mongodb: 3 | image: mongo:7.0 4 | container_name: webdev-support-bot-mongo-dev 5 | restart: unless-stopped 6 | environment: 7 | MONGO_INITDB_ROOT_USERNAME: admin 8 | MONGO_INITDB_ROOT_PASSWORD: password 9 | MONGO_INITDB_DATABASE: webdev-support-bot 10 | ports: 11 | - "27017:27017" 12 | volumes: 13 | - mongodb_dev_data:/data/db 14 | 15 | volumes: 16 | mongodb_dev_data: 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.12.2-slim AS deps 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json ./ 6 | COPY tsconfig.json ./ 7 | COPY yarn.lock ./ 8 | COPY src ./src 9 | 10 | RUN yarn install --frozen-lockfile 11 | RUN npm run build 12 | 13 | FROM node:20.12.2-slim 14 | 15 | WORKDIR /app 16 | 17 | ENV NODE_ENV=production 18 | 19 | COPY package.json ./ 20 | COPY yarn.lock ./ 21 | RUN yarn install --frozen-lockfile && rm -rf /usr/local/share/.cache 22 | COPY --from=deps /app/build . 23 | 24 | CMD ["node","index.js"] 25 | -------------------------------------------------------------------------------- /src/v2/modules/onboarding/utils/limitToWebDevServer.ts: -------------------------------------------------------------------------------- 1 | import { SERVER_ID } from '../../../env.js'; 2 | 3 | export function limitToWebDevServer< 4 | HasGuildId extends { guild: { id: string } }, 5 | Output, 6 | VArgs extends readonly unknown[] 7 | >( 8 | fn: (g: HasGuildId, ...vargs: VArgs) => Output 9 | ): (a: HasGuildId, ...vargs: VArgs) => Output { 10 | return (x: HasGuildId, ...args: VArgs) => { 11 | if (x.guild.id !== SERVER_ID) { 12 | return; 13 | } 14 | 15 | return fn(x, ...args); 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/v2/utils/chunkUntil.ts: -------------------------------------------------------------------------------- 1 | export function chunkUntil( 2 | fn: (currentChunk: T[], next: T, index: number) => boolean 3 | ) { 4 | return function* (iter: Iterable): Iterable { 5 | let current = []; 6 | let index = 0; 7 | for (const item of iter) { 8 | if (fn(current, item, index++)) { 9 | yield current; 10 | current = [item]; 11 | } else { 12 | current.push(item); 13 | } 14 | } 15 | 16 | if (current.length > 0) { 17 | yield current; 18 | } 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ESNext", "ESNext.Intl"], 4 | "allowJs": true, 5 | "target": "esnext", 6 | "module": "esnext", 7 | "moduleResolution": "node", 8 | "sourceMap": true, 9 | "outDir": "./build", 10 | "rootDir": "./src", 11 | "typeRoots": ["./node_modules/@types", "./src/*.d.ts"], 12 | "skipLibCheck": true, 13 | "esModuleInterop": true 14 | }, 15 | "include": ["./src/**/*.ts", "./src/*.d.ts"], 16 | "exclude": ["node_modules", "./src/**/__fixtures__/*"] 17 | } 18 | -------------------------------------------------------------------------------- /src/v2/utils/clampStr.ts: -------------------------------------------------------------------------------- 1 | export const clampLength = (str: string, maxLength: number): string => { 2 | if (str.length > maxLength) { 3 | return `${str.slice(0, maxLength - 3)}...`; 4 | } 5 | return str; 6 | }; 7 | 8 | export const clampLengthMiddle = (str: string, maxLength: number): string => { 9 | if (str.length > maxLength) { 10 | const firstHalf = str.slice(0, maxLength / 2 - 3); 11 | const secondHalf = str.slice(str.length - maxLength / 2); 12 | return `${firstHalf}...${secondHalf}`; 13 | } 14 | return str; 15 | }; 16 | -------------------------------------------------------------------------------- /src/v2/utils/sets.ts: -------------------------------------------------------------------------------- 1 | import { filter } from 'domyno'; 2 | 3 | export function intersection(a: Iterable, b: Iterable): Set { 4 | const bSet = b instanceof Set ? b : new Set(b); 5 | return new Set(filter(x => bSet.has(x))(a)); 6 | } 7 | 8 | export function union(a: Iterable, b: Iterable): Set { 9 | return new Set([...a, ...b]); 10 | } 11 | 12 | export function difference(a: Iterable, b: Iterable): Set { 13 | const bSet = b instanceof Set ? b : new Set(b); 14 | return new Set(filter(x => !bSet.has(x))(a)); 15 | } 16 | -------------------------------------------------------------------------------- /src/v2/helpful_role/index.ts: -------------------------------------------------------------------------------- 1 | import type { MessageReaction, User } from 'discord.js'; 2 | import type { Document } from 'mongoose'; 3 | 4 | import { IS_PROD } from '../env.js'; 5 | import { thanks } from '../utils/emojis.js'; 6 | import pointHandler from './point_handler.js'; 7 | 8 | /** 9 | * If you are not sure what the unicode for a certain emoji is, 10 | * consult the emojipedia. https://emojipedia.org/ 11 | */ 12 | export const allowedEmojis = ['🆙', '⬆️', '⏫', '🔼', thanks]; 13 | 14 | export type IUser = { 15 | user?: string; 16 | points?: number; 17 | } & Document; 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.fontLigatures": true, 3 | "workbench.tree.renderIndentGuides": "always", 4 | "editor.suggestSelection": "first", 5 | "references.preferredLocation": "view", 6 | "editor.suggest.maxVisibleSuggestions": 5, 7 | "editor.rulers": [80], 8 | "editor.formatOnSave": true, 9 | "eslint.packageManager": "yarn", 10 | "search.exclude": { 11 | "**/node_modules": true, 12 | "**/build": true, 13 | "**/dist": true 14 | }, 15 | "editor.codeActionsOnSave": { 16 | "source.fixAll.eslint": "always" 17 | }, 18 | "deno.enable": false 19 | } 20 | -------------------------------------------------------------------------------- /src/v2/modules/roles/utils/generateRoleSelect.ts: -------------------------------------------------------------------------------- 1 | import { ActionRowBuilder, MessageActionRowComponentBuilder, RoleSelectMenuBuilder } from 'discord.js'; 2 | 3 | export function generateRoleSelect( 4 | placeholder: string, 5 | customId: string, 6 | roles: string[] 7 | ): ActionRowBuilder { 8 | return new ActionRowBuilder().addComponents( 9 | new RoleSelectMenuBuilder() 10 | .setDefaultRoles(...roles) 11 | .setCustomId(customId) 12 | .setMinValues(1) 13 | .setPlaceholder(placeholder) 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/v2/modules/roles/utils/getAddRemoveRoles.ts: -------------------------------------------------------------------------------- 1 | import type { GuildMember } from 'discord.js'; 2 | 3 | import { partitionʹ } from '../../../utils/partition.js'; 4 | import { NOTIFY_ROLES } from '../consts/notifyRoles.js'; 5 | import { ROLES } from '../consts/roles.js'; 6 | 7 | const roleNames = new Set([...ROLES, ...NOTIFY_ROLES].map(x => x.name)); 8 | 9 | export const getAddRemoveRoles = ( 10 | member: GuildMember 11 | ): [string[], string[]] => { 12 | const existingRoles = new Set(member.roles.cache.map(x => x.name)); 13 | 14 | return partitionʹ(role => !existingRoles.has(role), roleNames); 15 | }; 16 | -------------------------------------------------------------------------------- /src/v2/commands/about/handlers/vscode.ts: -------------------------------------------------------------------------------- 1 | import { vscode as vscode_emoji } from '../../../utils/emojis.js'; 2 | 3 | export const vscode: [string, string] = [ 4 | 'vscode', 5 | [ 6 | `> 💡 consider using a lightweight, customizeable and monthly updated editor such as`, 7 | '> ', 8 | vscode_emoji 9 | ? `> ${vscode_emoji} Visual Studio Code - ` 10 | : '> Visual Studio Code - ', 11 | '> ', 12 | "> It's free & available cross platform and next to WebStorm the go to editor in this industry.", 13 | ].join('\n'), 14 | ]; 15 | -------------------------------------------------------------------------------- /src/v2/modules/mod/commands/onboardingMsg.ts: -------------------------------------------------------------------------------- 1 | import type { CommandInteraction } from 'discord.js'; 2 | 3 | export async function setupOnboardingMsg( 4 | interaction: CommandInteraction 5 | ): Promise { 6 | await interaction.deferReply({ ephemeral: true }); 7 | 8 | interaction.channel.send({ 9 | content: ` 10 | :wave: Hi! If you're in this channel then you should have been invited to a private thread to begin the onboarding. If not, please give it a moment as the bot might be down or busy. 11 | `, 12 | }); 13 | 14 | await interaction.editReply({ 15 | content: 'Done.', 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /src/v2/utils/errors.ts: -------------------------------------------------------------------------------- 1 | import { REPO_LINK } from '../env.js'; 2 | 3 | export const invalidResponse = 4 | 'sorry, your request could not be processed. Please try again at a later time.'; 5 | export const noResults = (search: string): string => 6 | `sorry, could not find anything for \`${search}\`.`; 7 | export const unknownError = `sorry, something went wrong. If this issue persists, please file an issue at ${REPO_LINK}`; 8 | export const missingRightsDeletion = 9 | 'insufficient permissions: unable to delete message.'; 10 | export const userNotFound = 11 | 'sorry, your user ID cannot be found within the database.'; 12 | -------------------------------------------------------------------------------- /src/v2/autorespond/code_parsing/hasVarInSource.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript'; 2 | 3 | export function hasVarInSource(source: string): boolean { 4 | // Build a program using the set of root file names in fileNames 5 | const sourceFile = ts.createSourceFile( 6 | 'file.ts', 7 | source, 8 | ts.ScriptTarget.Latest, 9 | true 10 | ); 11 | return !!sourceFile.forEachChild(visit); 12 | } 13 | 14 | function visit(node: ts.Node) { 15 | if (node.kind === ts.SyntaxKind.VariableDeclarationList) { 16 | return node.getFirstToken().kind === ts.SyntaxKind.VarKeyword; 17 | } 18 | 19 | return node.forEachChild(visit); 20 | } 21 | -------------------------------------------------------------------------------- /src/v2/commands/please/handlers/code.ts: -------------------------------------------------------------------------------- 1 | const sites = [ 2 | ` - requires account`, 3 | '', 4 | '', 5 | '', 6 | '', 7 | ].join('\n'); 8 | 9 | export const code: [string, string] = [ 10 | 'code', 11 | ` 12 | It would be much easier to help you if we could see (parts) of your code! 13 | Try reproducing your issue on one of these sites, save and then link it here: 14 | 15 | ${sites} 16 | 17 | Sometimes trying to recreate a problem outside of your project already helps you tracking down the issue on your own. 18 | `, 19 | ]; 20 | -------------------------------------------------------------------------------- /src/v2/utils/partition.ts: -------------------------------------------------------------------------------- 1 | export function partitionʹ( 2 | predicate: (item: T, index: number, iter: Iterable) => U, 3 | iter: Iterable 4 | ): [T[], T[]] { 5 | const success = []; 6 | const failed = []; 7 | let i = 0; 8 | for (const item of iter) { 9 | const arr = predicate(item, i++, iter) ? success : failed; 10 | arr.push(item); 11 | } 12 | return [success, failed]; 13 | } 14 | 15 | export function partition( 16 | predicate: (item: T, index: number, iter: Iterable) => U 17 | ) { 18 | return function (iter: Iterable): [T[], T[]] { 19 | return partitionʹ(predicate, iter); 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/build-docker-image.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | 8 | jobs: 9 | docker: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Login to DockerHub 13 | uses: docker/login-action@v1 14 | with: 15 | registry: ghcr.io 16 | username: r-webdev 17 | password: ${{ secrets.GITHUB_TOKEN }} 18 | - name: Build and push 19 | id: docker_build 20 | uses: docker/build-push-action@v2 21 | with: 22 | push: true 23 | tags: ghcr.io/r-webdev/support-bot:stable,ghcr.io/r-webdev/support-bot:latest 24 | -------------------------------------------------------------------------------- /src/v2/modules/roles/consts/notifyRoles.ts: -------------------------------------------------------------------------------- 1 | export const NOTIFY_ROLES = [ 2 | { 3 | name: 'Community Updates', 4 | description: 'Get notified for community updates', 5 | emoji: '📢', 6 | }, 7 | { 8 | name: 'Event Announcements', 9 | description: "Get notified for when we're doing events", 10 | emoji: '📆', 11 | }, 12 | { 13 | name: 'WebJam Announcements', 14 | description: "Get notified for when we're doing WebJams", 15 | emoji: '⏳', 16 | }, 17 | { 18 | name: 'Gamer Group', 19 | description: 20 | 'Get notified when people want to play something, usually Jackbox', 21 | emoji: '🎮', 22 | }, 23 | ]; 24 | -------------------------------------------------------------------------------- /src/v2/modules/roles/events/handleAutoCompleteRole.ts: -------------------------------------------------------------------------------- 1 | import type { Interaction } from 'discord.js'; 2 | 3 | import { ROLES } from '../consts/roles.js'; 4 | 5 | export const handleAutoCompleteRole = async ( 6 | interaction: Interaction 7 | ): Promise => { 8 | if (interaction.isAutocomplete()) { 9 | const focused = interaction.options.getFocused(); 10 | const listedRoles = ROLES.filter(roles => 11 | roles.name.toLowerCase().includes(focused.toLowerCase()) 12 | ); 13 | 14 | await interaction.respond( 15 | listedRoles.map(x => ({ 16 | name: x.name, 17 | value: x.name, 18 | })) 19 | ); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /src/v2/commands/npm/types.ts: -------------------------------------------------------------------------------- 1 | export type Links = { 2 | npm: string; 3 | homepage: string; 4 | repository: string; 5 | bugs: string; 6 | }; 7 | 8 | export type Author = { 9 | name: string; 10 | email: string; 11 | }; 12 | 13 | export type Publisher = { 14 | username: string; 15 | email: string; 16 | }; 17 | 18 | export type Maintainer = { 19 | username: string; 20 | email: string; 21 | }; 22 | 23 | export type NPMResponse = { 24 | name: string; 25 | scope: string; 26 | version: string; 27 | description: string; 28 | keywords: string[]; 29 | date: Date; 30 | links: Links; 31 | author: Author; 32 | publisher: Publisher; 33 | maintainers: Maintainer[]; 34 | }; 35 | -------------------------------------------------------------------------------- /src/v2/commands/please/handlers/english.ts: -------------------------------------------------------------------------------- 1 | export const english = [ 2 | 'english', 3 | `English only please - انجليزي فقط من فضلك - Само на английски - 请只说英语 - Pouze v angličtině - Kun engelsk takk - Angla Nur Bonvolu - Vain englanti - Anglais seulement s'il vous plait - Bitte nur Englisch - Μόνο Αγγλικά - Hanya Bahasa Inggris - Solo inglese per favore - 英語のみ - 영어로만 해주세요 - Tikai angļu valodā, lūdzu - Tik angliškai Prašau - Solo inglés por favor - Пожалуйста, только на английском - Iba v angličtine - Sadece ingilizce lütfen - Тільки англійською мовою - Proszę tylko po angielsku 4 | 5 | This allows us to effectively moderate and gives everyone an opportunity to join in on the conversation.`, 6 | ] as const; 7 | -------------------------------------------------------------------------------- /src/v2/commands/db_model.ts: -------------------------------------------------------------------------------- 1 | import type { Document } from 'mongoose'; 2 | import mongoose from 'mongoose'; 3 | 4 | const { model, Schema } = mongoose; 5 | 6 | const schema = new Schema({ 7 | guild: { 8 | type: String, 9 | }, 10 | name: { 11 | required: true, 12 | type: String, 13 | }, 14 | commandId: { 15 | required: true, 16 | type: String, 17 | }, 18 | applicationId: { 19 | required: true, 20 | type: String, 21 | }, 22 | } as const); 23 | 24 | export const Command = model('commands', schema); 25 | 26 | export type CommandType = Document & { 27 | guild?: string; 28 | name: string; 29 | commandId: string; 30 | applicationId: string; 31 | }; 32 | -------------------------------------------------------------------------------- /src/v2/utils/reactions.ts: -------------------------------------------------------------------------------- 1 | import type { MessageReaction, User } from 'discord.js'; 2 | 3 | export const validReactions = { 4 | deletion: '❌', 5 | // order is important here 6 | indices: ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟'], 7 | }; 8 | 9 | export const reactionFilterBuilder = 10 | (initialMessageAuthorId: string, currentlyValidEmojis: string[]) => 11 | ({ emoji: { name } }: MessageReaction, user: User): boolean => 12 | user.id === initialMessageAuthorId && 13 | // validate reaction via whitelist 14 | currentlyValidEmojis.includes(name); 15 | 16 | export const awaitReactionConfig = { 17 | errors: ['time'], 18 | max: 1, 19 | time: 60 * 1000, 20 | }; 21 | -------------------------------------------------------------------------------- /src/v2/utils/map.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * same as Array#map but works on all iterables, returns an iterable 3 | * 4 | * @export 5 | * @template T input iterable 6 | * @template U output iterable 7 | * @param {(item: T, index: number, iter: Iterable) => U} fn 8 | * @param {Iterable} iter 9 | */ 10 | export function* mapʹ( 11 | fn: (item: T, index: number, iter: Iterable) => U, 12 | iter: Iterable 13 | ): IterableIterator { 14 | let i = 0; 15 | for (const item of iter) { 16 | yield fn(item, i++, iter); 17 | } 18 | } 19 | 20 | export function map( 21 | fn: (item: T, index: number, iter: Iterable) => U 22 | ) { 23 | return (iter: Iterable): IterableIterator => mapʹ(fn, iter); 24 | } 25 | -------------------------------------------------------------------------------- /src/v2/cache/model.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const { model, Schema } = mongoose; 4 | 5 | const schema = new Schema({ 6 | guild: { 7 | required: true, 8 | type: String, 9 | }, 10 | type: { 11 | type: String, 12 | required: true, 13 | }, 14 | timestamp: { 15 | type: Number, 16 | default: Date.now(), 17 | required: true, 18 | }, 19 | user: { 20 | required: true, 21 | type: String, 22 | }, 23 | meta: { 24 | type: Schema.Types.Mixed, 25 | }, 26 | }); 27 | 28 | export const GenericCache = model('GenericCache', schema); 29 | 30 | export type GenericCacheType = { 31 | guild: string; 32 | type: string; 33 | timestamp: number; 34 | user: string; 35 | meta?: unknown; 36 | }; 37 | -------------------------------------------------------------------------------- /src/v2/user_context/index.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandType, Client } from 'discord.js'; 2 | import { Collection } from 'discord.js'; 3 | 4 | // quick responses 5 | // base commands 6 | // meme commands 7 | 8 | const guildCommands = new Collection([]); // placeholder for now 9 | 10 | export const registerUserContextMenu = async ( 11 | client: Client 12 | ): Promise => { 13 | const existingCommands = await client.application.commands.fetch(); 14 | existingCommands.sweep(x => x.type !== ApplicationCommandType.User); 15 | 16 | client.application.commands.set([]); 17 | 18 | client.on('interactionCreate', interaction => { 19 | if (!interaction.isUserContextMenuCommand()) { 20 | return; 21 | } 22 | console.log({ interaction }); 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /src/v2/message_context/index.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandType, Client } from 'discord.js'; 2 | import { Collection } from 'discord.js'; 3 | 4 | // quick responses 5 | // base commands 6 | // meme commands 7 | 8 | const guildCommands = new Collection([]); // placeholder for now 9 | 10 | export const registerMessageContextMenu = async ( 11 | client: Client 12 | ): Promise => { 13 | const existingCommands = await client.application.commands.fetch(); 14 | existingCommands.sweep(x => x.type !== ApplicationCommandType.Message); 15 | 16 | client.application.commands.set([]); 17 | 18 | client.on('interactionCreate', interaction => { 19 | 20 | if (!interaction.isMessageContextMenuCommand()) { 21 | return; 22 | } 23 | console.log(interaction); 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /src/v2/utils/codeBlockCapturer.ts: -------------------------------------------------------------------------------- 1 | const BACKTICKS = '```'; 2 | 3 | export type CodeBlockData = { 4 | code: string; 5 | language: string; 6 | }; 7 | 8 | export function createCodeBlockCapturer( 9 | langs: string[] = [] 10 | ): (str: string) => Iterable { 11 | const langAlts = langs.join('|'); 12 | return function* (str: string): Iterable { 13 | const langRegex = new RegExp( 14 | String.raw`${BACKTICKS}(?${langAlts})\n(?[\s\S]+?)\n${BACKTICKS}`, 15 | 'gui' 16 | ); 17 | const matches = str.matchAll(langRegex); 18 | for (const { 19 | groups: { language, code }, 20 | } of matches) { 21 | yield { 22 | language: language ?? '', 23 | code: code ?? '', 24 | }; 25 | } 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/v2/modules/onboarding/steps/handleOnboarded.ts: -------------------------------------------------------------------------------- 1 | import type { Guild, GuildMember } from 'discord.js'; 2 | 3 | import type { UserStateType } from '../db/user_state'; 4 | import { getThread } from '../utils/getThread.js'; 5 | 6 | export async function handleOnboarded( 7 | guild: Guild, 8 | member: GuildMember, 9 | oldState: UserStateType, 10 | fromStart: boolean 11 | ): Promise { 12 | const thread = await getThread(guild, oldState.threadId); 13 | 14 | if (!fromStart) { 15 | thread.send(`🎉 That's it! We hope you enjoy your time here ${member.toString()}. If you want to update your roles again, you can do that here: <#460881799430537237>. 16 | 17 | You have access to this thread and the onboarding channel until .`); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/v2/utils/emojis.ts: -------------------------------------------------------------------------------- 1 | export const website = ':globe_with_meridians:'; 2 | export const forks = ':fork_and_knife:'; 3 | export const star = ':star:'; 4 | export const warning = ':warning:'; 5 | export const license = ':notepad_spiral:'; 6 | export const language = ':writing_hand:'; 7 | export const yes = ':white_check_mark:'; 8 | export const no = ':x:'; 9 | export const keywords = ':key:'; 10 | export const dependencies = ':chains:'; 11 | export const light = ':bulb:'; 12 | export const neutral_face = ':neutral_face:'; 13 | export const point_up = ':point_up:'; 14 | export const gear = ':gear:'; 15 | export const exclamation = ':exclamation:'; 16 | export const paintbrush = ':paintbrush:'; 17 | export const art = ':art:'; 18 | export const vscode = `<:vscode:865053185705115648>`; 19 | export const thanks = `<:thanks:788569154805825549>`; 20 | -------------------------------------------------------------------------------- /src/v2/modules/onboarding/utils/getMessagesUntil.ts: -------------------------------------------------------------------------------- 1 | import type { Message, TextChannel } from 'discord.js'; 2 | import { filter } from 'domyno'; 3 | 4 | export async function* getMessagesUntil( 5 | channel: TextChannel, 6 | date: Date, 7 | limit = 100 8 | ): AsyncIterableIterator { 9 | let before; 10 | const timestamp = date.getTime(); 11 | 12 | const msgAfter = filter( 13 | (msg: Message) => msg.createdTimestamp > timestamp 14 | ); 15 | 16 | while (true) { 17 | // this is fine this is a async generator function 18 | // eslint-disable-next-line no-await-in-loop 19 | const messages = await channel.messages.fetch({ limit, before }); 20 | if (messages.size === 0) { 21 | return; 22 | } 23 | yield* msgAfter(messages.values()); 24 | before = messages.last().id; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/v2/modules/mod/commands/onboardingBegin.ts: -------------------------------------------------------------------------------- 1 | import type { CommandInteraction} from 'discord.js'; 2 | 3 | import { 4 | NEW_USER_ROLE, 5 | ONBOARDING_CHANNEL, 6 | JOIN_LOG_CHANNEL, 7 | INTRO_CHANNEL, 8 | INTRO_ROLE, 9 | } from '../../../env.js'; 10 | // import { attach } from '../../onboarding/index'; 11 | // import { setOnboardingStart } from '../../onboarding/utils/onboardingStart'; 12 | 13 | export async function debugOnboarding( 14 | interaction: CommandInteraction 15 | ): Promise { 16 | interaction.reply({ 17 | content: ` 18 | DEBUG: 19 | New User Role: <@&${NEW_USER_ROLE}> 20 | Onboarding Channel: <#${ONBOARDING_CHANNEL}> 21 | Join Log Channel: <#${JOIN_LOG_CHANNEL}> 22 | Intro Channel: <#${INTRO_CHANNEL}> 23 | New User Role: <@&${INTRO_ROLE}> 24 | `, 25 | }); 26 | 27 | // const foo = await setOnboardingStart(); 28 | 29 | // await attach(interaction.client); 30 | } 31 | -------------------------------------------------------------------------------- /src/v2/commands/post/env.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IS_PROD, 3 | POST_LIMITER_IN_HOURS, // Used for informing the user about the limiter 4 | AWAIT_MESSAGE_TIMEOUT as AMT, // Renamed for shadowing 5 | MINIMAL_AMOUNT_OF_WORDS as MAOW, // Renamed for shadowing as well 6 | } from '../../env.js'; 7 | 8 | // seconds to ms 9 | const AWAIT_MESSAGE_TIMEOUT = Number.parseInt(AMT) * 1000; 10 | 11 | // convert hours into seconds (H*60*60) 12 | const POST_LIMITER = IS_PROD 13 | ? Number.parseInt(POST_LIMITER_IN_HOURS) * 3600 14 | : 0.01 * 3600; // Shorten limiter to 30 seconds for development purposes 15 | 16 | // convert string to int 17 | const MINIMAL_AMOUNT_OF_WORDS = Number.parseInt(MAOW); 18 | 19 | export { MINIMAL_AMOUNT_OF_WORDS, POST_LIMITER, AWAIT_MESSAGE_TIMEOUT }; 20 | 21 | export { 22 | MOD_CHANNEL, 23 | JOB_POSTINGS_CHANNEL, 24 | MINIMAL_COMPENSATION, 25 | POST_LIMITER_IN_HOURS, 26 | } from '../../env.js'; 27 | -------------------------------------------------------------------------------- /src/v2/autorespond/thanks/db_model.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from 'mongoose'; 2 | 3 | 4 | const schema = new Schema( 5 | { 6 | guild: { 7 | required: true, 8 | type: String, 9 | }, 10 | channel: { 11 | required: true, 12 | type: String, 13 | }, 14 | thanker: { 15 | required: true, 16 | type: String, 17 | }, 18 | thankees: { 19 | type: [String], 20 | required: true, 21 | }, 22 | responseMsgId: { 23 | type: String, 24 | }, 25 | }, 26 | { 27 | timestamps: true, 28 | } 29 | ); 30 | 31 | export const ThanksInteraction = model( 32 | 'thanksMessageInteraction', 33 | schema 34 | ); 35 | export type ThanksInteractionType = { 36 | guild: string; 37 | thanker: string; 38 | channel: string; 39 | thankees: string[]; 40 | responseMsgId: string; 41 | createdAt: Date; 42 | updatedAt: Date; 43 | }; 44 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | const { createConfig } = require('eslint-config-galex/dist/createConfig'); 2 | const { getDependencies } = require('eslint-config-galex/dist/getDependencies'); 3 | const { 4 | createTypeScriptOverride, 5 | } = require('eslint-config-galex/dist/overrides/typescript'); 6 | 7 | const tsOverride = createTypeScriptOverride({ 8 | ...getDependencies(), 9 | rules: { 10 | '@typescript-eslint/no-floating-promises': 0, 11 | '@typescript-eslint/no-misused-promises': 0, 12 | }, 13 | }); 14 | 15 | module.exports = createConfig({ 16 | overrides: [tsOverride], 17 | root: true, 18 | rules: { 19 | 'no-empty': 0, 20 | 'no-void': 0, 21 | // this shit isn't properly supported in TS or node, its too early 22 | 'unicorn/prefer-string-replace-all': 0, 23 | 'no-eq-null': 0, 24 | eqeqeq: ['error', 'always', { null: 'ignore' }], 25 | 'no-bitwise': 0, 26 | // stuff 27 | 'unicorn/import-index': 'off', 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /src/v2/utils/delayedMessageAutoDeletion.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from 'discord.js'; 2 | 3 | import { missingRightsDeletion } from './errors.js'; 4 | 5 | const THIRTY_SECONDS_IN_MS = 30 * 1000; 6 | 7 | export const delayedMessageAutoDeletion = ( 8 | msg: Message, 9 | timeout = THIRTY_SECONDS_IN_MS 10 | ): void => { 11 | // required so tests on CI dont crash 12 | if (msg) { 13 | setTimeout(async () => { 14 | try { 15 | await msg.delete(); 16 | } catch (error) { 17 | // eslint-disable-next-line no-console 18 | console.warn("Couldn't delete message", error); 19 | 20 | try { 21 | await msg.edit(missingRightsDeletion); 22 | } catch { 23 | // eslint-disable-next-line no-console 24 | console.info( 25 | "Couldn't edit message after trying to delete, probably removed by someone else." 26 | ); 27 | } 28 | } 29 | }, timeout); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /src/v2/modules/roles/commands/change.ts: -------------------------------------------------------------------------------- 1 | import type { CommandInteraction, GuildMember } from 'discord.js'; 2 | 3 | import { generateRoleSelect } from '../utils/generateRoleSelect.js'; 4 | import { getAddRemoveRoles } from '../utils/getAddRemoveRoles.js'; 5 | 6 | export function change(interaction: CommandInteraction): void { 7 | const [addRoles, removeRoles] = getAddRemoveRoles( 8 | interaction.member as GuildMember 9 | ); 10 | interaction.reply({ 11 | ephemeral: true, 12 | content: 'Please select the roles you wish to add or remove', 13 | components: [ 14 | addRoles.length > 0 && 15 | generateRoleSelect( 16 | 'Which roles would you like to join?', 17 | 'roles🤔add', 18 | addRoles 19 | ), 20 | removeRoles.length > 0 && 21 | generateRoleSelect( 22 | 'Which roles would you like to leave?', 23 | 'roles🤔remove', 24 | removeRoles 25 | ), 26 | ].filter(Boolean), 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /src/v2/modules/roles/commands/suggest.ts: -------------------------------------------------------------------------------- 1 | import { ButtonStyle, CommandInteraction, GuildMember } from 'discord.js'; 2 | import { ActionRowBuilder, ButtonBuilder, MessageActionRowComponentBuilder } from 'discord.js'; 3 | 4 | export async function suggest(interaction: CommandInteraction): Promise { 5 | await interaction.deferReply({}); 6 | 7 | const member = interaction.member as GuildMember; 8 | const user = interaction.options.get('user').user; 9 | const role = interaction.options.get('role', true).value as string; 10 | 11 | interaction.editReply({ 12 | content: `Hey${user ? ` ${user}` : '' 13 | }, ${member.toString()} is suggesting that you join the ${role} role.`, 14 | components: [ 15 | new ActionRowBuilder().addComponents( 16 | new ButtonBuilder() 17 | .setLabel(`Join ${role} Role`) 18 | .setCustomId(`roles🤔add🤔${role}`) 19 | .setStyle(ButtonStyle.Primary) 20 | ), 21 | ], 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/v2/autorespond/justask.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from 'discord.js'; 2 | 3 | import { stripMarkdownQuote } from '../utils/content_format.js'; 4 | 5 | const rePeople = String.raw`(?:any|some) (?:one|1+)`; 6 | const reHas = String.raw`(?:ha(?:s|ve)|got)`; 7 | const reQuantifier = String.raw`(?:any?|some|much)`; 8 | const reHistory = String.raw`(?: prior| past)`; 9 | 10 | const heuristicJustAskRegex = new RegExp( 11 | String.raw`${rePeople}(?: here)? ${reHas}(?: ${reQuantifier}${reHistory}?)? experiences?`.replace( 12 | ' ', 13 | String.raw`\s*` 14 | ), 15 | 'ui' 16 | ); 17 | 18 | export function detectVagueQuestion(msg: Message): boolean { 19 | const content = stripMarkdownQuote(msg.cleanContent); 20 | if (content.split(' ').length < 50 && heuristicJustAskRegex.test(content)) { 21 | msg.reply(` 22 | **Don't ask to ask. Just Ask** 23 | Here's why https://sol.gfxile.net/dontask.html 24 | 25 | (This was an automated response) 26 | `); 27 | return true; 28 | } 29 | return false; 30 | } 31 | -------------------------------------------------------------------------------- /src/v2/autorespond/thanks/checker.test.ts: -------------------------------------------------------------------------------- 1 | import checker from './checker.js'; 2 | 3 | describe('thanks checked', () => { 4 | test.each([ 5 | ['naab', false], 6 | ['thanks friend', true], 7 | ['Thanks friend', true], 8 | ['ty friend', true], 9 | ['tHaNKs fRiEnDO', true], 10 | ['tY fRiEnDO', true], 11 | ['time for a putty party', false], 12 | ['"ty, friend"', true], 13 | ['"nty, friend"', false], 14 | ['"no ty, friend"', false], 15 | ['"no thanks, friend"', false], 16 | [ 17 | 'https://github.com/ljosberinn/webdev-support-bot/blob/master/src/thanks/index.ts', 18 | false, 19 | ], 20 | ['merci beaucoup', true], 21 | ['non merci', false], 22 | ['danke', true], 23 | ['谢谢', true], 24 | ['nein danke', false], 25 | ['Еще раз спасибо за вашу помощь', true], 26 | ['cheers love, the cavalries here', true], 27 | ])('acts only on appropriate input (case %s)', (string, result) => { 28 | expect(checker(string)).toBe(result); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/v2/commands/whyno/handlers/jquery.ts: -------------------------------------------------------------------------------- 1 | export const jquery: [string, string] = [ 2 | 'jquery', 3 | `**Why you shouldn't use jQuery:** 4 | 1. jQuery is a legacy library. Standardized features like querySelector, CSS animations, and fetch make many of its features obsolete. 5 | 2. Using jQuery over standard features is a waste of bandwidth. 6 | 3. Because jQuery is so bloated, using jQuery often means using jQuery for everything, which means you learn less about standard web development. 7 | 4. jQuery has fallen out of fashion, and full frameworks (React, Angular, Vue) are more popular. 8 | 5. With modern frameworks you declare a state and your UI reflects your state. Rather than with jQuery having to manage both the state and the UI all at the same time. 9 | 6. jQuery's cross-browser support can be substituted with the few polyfills you actually need. This also makes it easier to update when features become better supported. 10 | 7. If you really just want a shorthand for querySelectorAll, consider bling dot js`, 11 | ]; 12 | -------------------------------------------------------------------------------- /src/v2/cache/index.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from 'discord.js'; 2 | 3 | import { upsert, get } from './cacheFns.js'; 4 | 5 | export * from './cacheFns.js'; 6 | 7 | type ConditionLimit = { delay: number; type: string; meta?: unknown }; 8 | 9 | export function limitFnByUser< 10 | T extends (...args: unknown[]) => boolean | Promise 11 | >(fn: T, { delay, type = `${fn.name}|${delay}` }: ConditionLimit) { 12 | return async (msg: Message): Promise => { 13 | const user = msg.author.id; 14 | const guild = msg.guild?.id; 15 | 16 | if (!guild) { 17 | return; 18 | } 19 | 20 | const prev = await get({ type, guild, user }); 21 | 22 | if (prev) { 23 | return; 24 | } 25 | 26 | const result = await fn(msg); 27 | 28 | if (result) { 29 | await upsert({ 30 | guild, 31 | user, 32 | type, 33 | expiresIn: delay, 34 | meta: typeof result === 'object' ? result : undefined, 35 | }); 36 | } 37 | 38 | return result; 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 20.12.2 15 | 16 | - name: Cache npm dependencies 17 | uses: actions/cache@v3 18 | env: 19 | cache-name: cache-npm 20 | with: 21 | path: node_modules 22 | key: ${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }} 23 | restore-keys: | 24 | ${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }} 25 | ${{ env.cache-name }}- 26 | 27 | - name: Install dependencies 28 | run: yarn install --frozen-lockfile 29 | 30 | # These are temporarily allowed to fail. 31 | # "Temporarily" 32 | - name: Lint 33 | run: npm run lint 34 | continue-on-error: true 35 | - name: Test 36 | run: npm run test 37 | continue-on-error: true 38 | -------------------------------------------------------------------------------- /src/v2/modules/onboarding/utils/onboardingStart.ts: -------------------------------------------------------------------------------- 1 | import { get, upsert } from '../../../cache/index.js'; 2 | import type { GenericCacheType } from '../../../cache/model.js'; 3 | import { SERVER_ID } from '../../../env.js'; 4 | 5 | type OnboardingStartCache = { 6 | meta: { 7 | onboardingStart: number; 8 | }; 9 | } & GenericCacheType; 10 | 11 | const _getOnboardingCache = (): Promise => 12 | get({ 13 | guild: SERVER_ID, 14 | type: 'ONBOARDING_START', 15 | user: '', 16 | }) as unknown as Promise; 17 | 18 | export const getOnboardingStart = async (): Promise => { 19 | const cache = await _getOnboardingCache(); 20 | 21 | return cache?.meta.onboardingStart; 22 | }; 23 | 24 | export const setOnboardingStart = (): Promise => 25 | upsert({ 26 | expiresAt: Number.MAX_SAFE_INTEGER, 27 | guild: SERVER_ID, 28 | type: 'ONBOARDING_START', 29 | user: '', 30 | meta: { 31 | onboardingStart: Date.now(), 32 | }, 33 | }) as unknown as Promise; 34 | -------------------------------------------------------------------------------- /src/v2/modules/onboarding/events/handleMemberLeave.ts: -------------------------------------------------------------------------------- 1 | import type { GuildMember } from 'discord.js'; 2 | 3 | import { SERVER_ID } from '../../../env.js'; 4 | import { UserState } from '../db/user_state.js'; 5 | 6 | export const handleMemberLeave = async (member: GuildMember): Promise => { 7 | const { user, roles } = member; 8 | 9 | const oldState = await UserState.findOne({ 10 | guild: SERVER_ID, 11 | userId: user.id, 12 | }); 13 | 14 | const memRoles = roles.cache.filter(x => x.name !== '@everyone'); 15 | 16 | if (!oldState) { 17 | if (memRoles.size === 0) { 18 | return; 19 | } 20 | UserState.create({ 21 | guild: SERVER_ID, 22 | userId: user.id, 23 | state: 'ONBOARDED', 24 | rolesOnLeave: memRoles.map(x => ({ name: x.name, id: x.id })), 25 | }); 26 | return; 27 | } 28 | 29 | if (memRoles.size === 0 && !oldState.threadId) { 30 | await oldState.deleteOne(); 31 | return; 32 | } 33 | oldState.rolesOnLeave = roles.cache 34 | .filter(x => x.name !== '@everyone') 35 | .map(x => ({ name: x.name, id: x.id })); 36 | oldState.save(); 37 | }; 38 | -------------------------------------------------------------------------------- /src/v2/utils/DeferredPromise.ts: -------------------------------------------------------------------------------- 1 | export class DeferredPromise extends Promise { 2 | readonly #resolve: (value: T) => void; 3 | 4 | readonly #reject: (reason: unknown) => void; 5 | 6 | #resolved = false; 7 | 8 | #rejected = false; 9 | 10 | public constructor() { 11 | let resolver; 12 | let rejector; 13 | 14 | super((resolve, reject) => { 15 | resolver = resolve; 16 | rejector = reject; 17 | }); 18 | 19 | this.#resolve = resolver; 20 | this.#reject = rejector; 21 | } 22 | 23 | public static get [Symbol.species](): PromiseConstructor { 24 | return Promise; 25 | } 26 | 27 | public get settled(): boolean { 28 | return this.#resolved || this.#rejected; 29 | } 30 | 31 | public get resolved(): boolean { 32 | return this.#resolved; 33 | } 34 | 35 | public get rejected(): boolean { 36 | return this.#rejected; 37 | } 38 | 39 | public resolve(value: T): void { 40 | this.#resolved = true; 41 | this.#resolve(value); 42 | } 43 | 44 | public reject(reason: unknown): void { 45 | this.#rejected = true; 46 | this.#reject(reason); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Gerrit Alex 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/v2/autorespond/code_parsing/index.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from 'discord.js'; 2 | 3 | import { createCodeBlockCapturer } from '../../utils/codeBlockCapturer.js'; 4 | import { pipe } from '../../utils/pipe.js'; 5 | import { pluck } from '../../utils/pluck.js'; 6 | import { some } from '../../utils/some.js'; 7 | import { hasVarInSource } from './hasVarInSource.js'; 8 | 9 | const jsCodeBlocks = createCodeBlockCapturer([ 10 | 'js', 11 | 'javascript', 12 | 'ts', 13 | 'typescript', 14 | ]); 15 | 16 | const getFirstVar = pipe([jsCodeBlocks, pluck('code'), some(hasVarInSource)]); 17 | 18 | const messageFor = (userId: string) => ` 19 | Hey <@${userId}>, I've noticed you're using \`var\` in a code snippet. 20 | Unless you've got a very good reason to, it's highly recommended that you use \`let\` or \`const\`. Preferably \`const\` if it won't be reassigned.`; 21 | 22 | export function detectVar(msg: Message): boolean { 23 | if (msg.author.id === msg.client.user.id) { 24 | return; 25 | } 26 | 27 | const { content, channel, author } = msg; 28 | 29 | if (getFirstVar(content)) { 30 | channel.send(messageFor(author.id)); 31 | return true; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/v2/utils/build-command-string.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationCommandOptionType, 3 | ChatInputCommandInteraction, 4 | } from 'discord.js'; 5 | 6 | export const buildCommandString = ( 7 | interaction: ChatInputCommandInteraction, 8 | ): string => { 9 | const commandName = interaction.commandName; 10 | const options: string[] = []; 11 | 12 | interaction.options.data.forEach(option => { 13 | const value = option.value; 14 | 15 | switch (option.type) { 16 | case ApplicationCommandOptionType.User: 17 | options.push(`${option.name}: ${value}`); 18 | break; 19 | case ApplicationCommandOptionType.String: 20 | options.push(`${option.name}: ${value}`); 21 | break; 22 | case ApplicationCommandOptionType.Integer: 23 | case ApplicationCommandOptionType.Number: 24 | options.push(`${option.name}: ${value}`); 25 | break; 26 | case ApplicationCommandOptionType.Boolean: 27 | options.push(`${option.name}: ${value}`); 28 | break; 29 | default: 30 | options.push(`${option.name}: ${value}`); 31 | } 32 | }); 33 | 34 | return `/${commandName} ${options.join(' ')}`; 35 | }; 36 | -------------------------------------------------------------------------------- /src/v2/commands/about/handlers/modules.ts: -------------------------------------------------------------------------------- 1 | import { createMarkdownCodeBlock } from '../../../utils/discordTools.js'; 2 | 3 | export const modules: [string, string] = [ 4 | 'modules', 5 | `Unless you're supporting ancient legacy systems, always add \`type="module"\` to all your script tags: 6 | ${createMarkdownCodeBlock( 7 | '', 8 | 'html' 9 | )} 10 | **It will**: 11 | — As the name suggests, allow you to import modules, which makes it easier to organize your code. 12 | — Enable strict mode by default. This makes your code run faster, and reports more runtime errors instead of silently ignoring them. 13 | — Execute your code only after the DOM has initialized, which makes DOM manipulation easier. Thanks to this, you won't need to listen to onload/readystatechange events. 14 | — Prevent top level variables from implicitly polluting the global namespace. 15 | — Allow you to use top-level await in supported engines 16 | — Load and parse your code asynchronously, which improves load performance. 17 | **TL;DR**: There's no reason not to add it in when developing for modern browsers, and it makes programming JS a lot more pleasant.`, 18 | ]; 19 | -------------------------------------------------------------------------------- /src/v2/utils/pluralize.ts: -------------------------------------------------------------------------------- 1 | import { callOrValue } from './callOrValue.js'; 2 | 3 | type PluralizeFunction = { 4 | ( 5 | strs: TemplateStringsArray, 6 | ...exprs: (((n: number) => string) | string | { toString(): string })[] 7 | ): (n: number) => string; 8 | s: (n: number) => string; 9 | mapper: typeof mapper; 10 | n: (x: T) => T; 11 | }; 12 | 13 | const listFormatter = new Intl.ListFormat(); 14 | 15 | const pluralize = (( 16 | strs: TemplateStringsArray, 17 | ...exprs: (((n: number) => string) | string | { toString(): string })[] 18 | ) => { 19 | return (n: number) => 20 | strs.reduce((acc, item, i) => { 21 | const exp = exprs[i - 1]; 22 | if (Array.isArray(exp)) { 23 | return acc + listFormatter.format(exp) + item; 24 | } 25 | return `${acc}${callOrValue(exp, n)}${item}`; 26 | }); 27 | }) as PluralizeFunction; 28 | 29 | pluralize.s = mapper({ 1: '' }, 's'); 30 | pluralize.mapper = mapper; 31 | pluralize.n = x => x; 32 | 33 | export { pluralize, pluralize as _, mapper }; 34 | 35 | function mapper( 36 | map: Record, 37 | defaultStr: string 38 | ): (n: number) => string { 39 | return (n: number) => map[n] ?? defaultStr; 40 | } 41 | -------------------------------------------------------------------------------- /src/enums.ts: -------------------------------------------------------------------------------- 1 | export enum InteractionType { 2 | PING = 1, 3 | APPLICATION_COMMAND = 2, 4 | } 5 | 6 | export enum InteractionResponseType { 7 | PONG = 1, 8 | /** 9 | * @deprecated 10 | */ 11 | ACKNOWLEDGE = 2, 12 | /** 13 | * @deprecated 14 | */ 15 | CHANNEL_MESSAGE = 3, 16 | CHANNEL_MESSAGE_WITH_SOURCE = 4, 17 | ACKNOWLEDGE_WITH_SOURCE = 5, 18 | } 19 | 20 | export enum ApplicationCommandOptionType { 21 | SUB_COMMAND = 1, 22 | SUB_COMMAND_GROUP = 2, 23 | STRING = 3, 24 | INTEGER = 4, 25 | BOOLEAN = 5, 26 | USER = 6, 27 | CHANNEL = 7, 28 | ROLE = 8, 29 | } 30 | 31 | export enum Days { 32 | Sunday = 0, 33 | Monday = 1, 34 | Tuesday = 2, 35 | Wednesday = 3, 36 | Thursday = 4, 37 | Friday = 5, 38 | Saturday = 6, 39 | } 40 | 41 | export enum Months { 42 | January = 0, 43 | February = 1, 44 | March = 2, 45 | April = 3, 46 | May = 4, 47 | June = 5, 48 | July = 6, 49 | August = 7, 50 | September = 8, 51 | October = 9, 52 | November = 10, 53 | December = 11, 54 | } 55 | 56 | /* https://discord.com/developers/docs/topics/opcodes-and-status-codes#json-json-error-codes */ 57 | export enum DiscordAPIErrorCode { 58 | UnknownMember = 10007, 59 | UnknownUser = 10013, 60 | } 61 | -------------------------------------------------------------------------------- /src/v2/modules/onboarding/db/user_state.ts: -------------------------------------------------------------------------------- 1 | import type { Document } from 'mongoose'; 2 | import mongoose from 'mongoose'; 3 | 4 | const { model, Schema } = mongoose; 5 | const schema = new Schema( 6 | { 7 | guild: { 8 | type: String, 9 | required: true, 10 | }, 11 | userId: { 12 | type: String, 13 | required: true, 14 | }, 15 | rolesOnLeave: { 16 | type: [ 17 | { 18 | name: String, 19 | id: String, 20 | }, 21 | ], 22 | }, 23 | rulesAgreedDate: { 24 | type: Date, 25 | }, 26 | state: { 27 | type: String, 28 | required: true, 29 | }, 30 | threadId: { 31 | type: String, 32 | }, 33 | }, 34 | { timestamps: true } 35 | ); 36 | 37 | export const UserState = model('user_state', schema); 38 | export type OnboardingState = 39 | | 'START' 40 | | 'INTRODUCTION' 41 | | 'ROLE_SELECTION' 42 | | 'ONBOARDED'; 43 | 44 | export type UserStateType = Document & { 45 | guild: string; 46 | userId: string; 47 | rulesAgreedDate?: Date; 48 | rolesOnLeave?: { name: string; id: string }[]; 49 | state: OnboardingState; 50 | threadId?: string; 51 | createdAt: Date; 52 | updatedAt: Date; 53 | }; 54 | -------------------------------------------------------------------------------- /src/v2/commands/please/handlers/format/index.ts: -------------------------------------------------------------------------------- 1 | import { LINE_SEPARATOR, exampleFns } from './exampleFns.js'; 2 | 3 | const getRandomArbitrary = (min: number, max: number) => 4 | Math.floor(Math.random() * (max - min + 1)) + min; 5 | 6 | const getSnippetElements = () => 7 | [ 8 | '> \\`\\`\\`js', 9 | '> // copy this and enter your code instead', 10 | exampleFns[getRandomArbitrary(0, exampleFns.length - 1)] 11 | .toString() 12 | .split(LINE_SEPARATOR) 13 | .map(line => `> ${line}`) 14 | .join(LINE_SEPARATOR), 15 | '> \\`\\`\\`', 16 | ].join(LINE_SEPARATOR); 17 | 18 | const otherLanguageExamples = ['php', 'css', 'html', 'ts', 'sql', 'md'] 19 | .map(str => `\`${str}\``) 20 | .join(', '); 21 | 22 | export const format: [string, () => string] = [ 23 | 'format', 24 | (): string => ` 25 | 👆 Did you know you can add syntax highlighting to your code in Discord? 26 | https://cdn.discordapp.com/attachments/550768098660188191/834795086126121010/2021-04-22_10-16-33.gif 27 | ${getSnippetElements()} 28 | 29 | You can replace \`js\` with other languages too, e.g. ${otherLanguageExamples} and so on... 30 | To properly _format_ your code, try pasting it in here first: https://prettier.io/playground/ 31 | `, 32 | ]; 33 | -------------------------------------------------------------------------------- /src/v2/modules/onboarding/events/handleIntroductionMsg.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from 'discord.js'; 2 | 3 | import { INTRO_CHANNEL, NEW_USER_ROLE } from '../../../env.js'; 4 | import { UserState } from '../db/user_state.js'; 5 | import { continueOnboarding } from '../utils/continueOnboarding.js'; 6 | import { getThread } from '../utils/getThread.js'; 7 | 8 | export const handleIntroductionMsg = async (msg: Message): Promise => { 9 | if ( 10 | msg.channelId === INTRO_CHANNEL && 11 | msg.member.roles.cache.some(x => x.id === NEW_USER_ROLE) 12 | ) { 13 | const oldState = await UserState.findOne({ 14 | guild: msg.guild.id, 15 | userId: msg.author.id, 16 | }); 17 | 18 | const thread = await getThread(msg.guild, oldState.threadId); 19 | 20 | const pinned = await thread.messages.fetchPinned(); 21 | const introMsg = pinned.last(); 22 | 23 | introMsg.edit({ 24 | components: [], 25 | }); 26 | introMsg.reply({ 27 | content: `Thanks for introducing yourself ${msg.member}!`, 28 | }); 29 | 30 | oldState.state = 'ROLE_SELECTION'; 31 | await Promise.all([oldState.save(), introMsg.unpin()]); 32 | 33 | continueOnboarding(msg.guild, msg.member, oldState, false); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /src/v2/modules/onboarding/utils/continueOnboarding.ts: -------------------------------------------------------------------------------- 1 | import type { GuildMember, Guild, TextChannel } from 'discord.js'; 2 | 3 | import { ONBOARDING_CHANNEL } from '../../../env.js'; 4 | import type { UserStateType } from '../db/user_state'; 5 | import { handleIntroduction } from '../steps/handleIntroduction.js'; 6 | import { handleOnboarded } from '../steps/handleOnboarded.js'; 7 | import { handleRoleSelection } from '../steps/handleRoleSelection.js'; 8 | import { handleStart } from '../steps/handleStart.js'; 9 | 10 | export async function continueOnboarding( 11 | guild: Guild, 12 | member: GuildMember, 13 | oldState: UserStateType, 14 | fromStart = false 15 | ): Promise { 16 | const channel = guild.channels.resolve(ONBOARDING_CHANNEL) as TextChannel; 17 | 18 | switch (oldState.state) { 19 | case 'START': 20 | handleStart(guild, member, oldState, fromStart); 21 | return; 22 | case 'INTRODUCTION': 23 | handleIntroduction(guild, member, oldState, fromStart); 24 | return; 25 | case 'ROLE_SELECTION': 26 | handleRoleSelection(guild, member, oldState, fromStart); 27 | return; 28 | case 'ONBOARDED': 29 | handleOnboarded(guild, member, oldState, fromStart); 30 | return; 31 | default: 32 | console.error("Shouldn't have gotten here D:"); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/v2/modules/roles/consts/roles.ts: -------------------------------------------------------------------------------- 1 | export const ROLES = [ 2 | { 3 | name: '.NET', 4 | emoji: '💻', 5 | }, 6 | { 7 | name: 'CSS', 8 | emoji: '💻', 9 | }, 10 | { 11 | name: 'Go', 12 | emoji: '💻', 13 | }, 14 | { 15 | name: 'HTML', 16 | emoji: '💻', 17 | }, 18 | { 19 | name: 'Java', 20 | emoji: '💻', 21 | }, 22 | { 23 | name: 'JS', 24 | emoji: '💻', 25 | }, 26 | { 27 | name: 'PHP', 28 | emoji: '💻', 29 | }, 30 | { 31 | name: 'Python', 32 | emoji: '💻', 33 | }, 34 | { 35 | name: 'TS', 36 | emoji: '💻', 37 | }, 38 | 39 | { 40 | name: 'Angular', 41 | emoji: '🛠', 42 | }, 43 | { 44 | name: 'React', 45 | emoji: '🛠', 46 | }, 47 | { 48 | name: 'Svelte', 49 | emoji: '🛠', 50 | }, 51 | { 52 | name: 'Vue', 53 | emoji: '🛠', 54 | }, 55 | { 56 | name: 'WordPress', 57 | emoji: '🛠', 58 | }, 59 | { 60 | name: 'Databases', 61 | emoji: '🗃', 62 | }, 63 | { 64 | name: 'DevOps', 65 | emoji: '🗃', 66 | }, 67 | { 68 | name: 'SEO', 69 | emoji: '🗃', 70 | }, 71 | { 72 | name: 'Graphic Design', 73 | emoji: '🎨', 74 | }, 75 | { 76 | name: 'UX', 77 | emoji: '🎨', 78 | }, 79 | // { 80 | // name: 'All Development', 81 | // }, 82 | ]; 83 | -------------------------------------------------------------------------------- /src/v2/utils/search.ts: -------------------------------------------------------------------------------- 1 | // 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | 4 | export function search( 5 | fn: (path: PropertyKey[], value: any) => any 6 | ): (obj: any, skipOnMatch?: boolean) => Iterable<[PropertyKey[], unknown]> { 7 | function* _recursiveSearch( 8 | value: any, 9 | path: PropertyKey[] = [], 10 | seen: Set = new Set(), 11 | skipOnMatch = false 12 | ) { 13 | if (fn(path, value)) { 14 | yield [path, value]; 15 | } 16 | 17 | if (value == null || typeof value !== 'object') { 18 | return; 19 | } 20 | 21 | seen.add(value); 22 | 23 | if (Array.isArray(value)) { 24 | const len = value.length; 25 | for (let i = 0; i < len; i++) { 26 | const item = value[i]; 27 | if (seen.has(item)) { 28 | continue; 29 | } 30 | yield* _recursiveSearch(item, [...path, i], seen, skipOnMatch); 31 | } 32 | return; 33 | } 34 | 35 | for (const [key, val] of Object.entries(value)) { 36 | if (seen.has(val)) { 37 | continue; 38 | } 39 | yield* _recursiveSearch(val, [...path, key], seen, skipOnMatch); 40 | } 41 | } 42 | return function* (obj: any, skipOnMatch = false) { 43 | const seen = new Set(); 44 | yield* _recursiveSearch(obj, [], seen, skipOnMatch); 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /src/v2/autorespond/deprecatedCommands.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from 'discord.js'; 2 | 3 | import { applicationCommands, guildCommands } from '../commands/index.js'; 4 | 5 | const diffCommands = new Map( 6 | Object.entries({ 7 | jquery: 'about jquery', 8 | vscode: 'about vscode', 9 | justask: 'please justask', 10 | modules: 'about modules', 11 | flexbox: 'about flexbox', 12 | lockfile: 'about lockfile', 13 | formatting: 'please format', 14 | format: 'please format', 15 | points: 'points get', 16 | code: 'please code', 17 | leaderboard: 'points leaderboard', 18 | }) 19 | ); 20 | 21 | const regex = new RegExp( 22 | `^!(${[ 23 | ...new Set([ 24 | ...applicationCommands.keys(), 25 | ...guildCommands.keys(), 26 | ...diffCommands.keys(), 27 | ]), 28 | ].join('|')})(?: |$)`, 29 | 'iu' 30 | ); 31 | 32 | export function handleDeprecatedCommands(msg: Message): boolean { 33 | const match = regex.exec(msg.content); 34 | if (match) { 35 | const [, command] = match; 36 | const cmd = command.toLowerCase(); 37 | 38 | msg.reply( 39 | `It looks like you're attempting to use a command. The web dev bot commands are now using the discord slash commands. Give \`/${ 40 | diffCommands.get(cmd) ?? cmd 41 | }\` a go!` 42 | ); 43 | return true; 44 | } 45 | return false; 46 | } 47 | -------------------------------------------------------------------------------- /src/v2/commands/resource/handlers/javascript.ts: -------------------------------------------------------------------------------- 1 | import { collect } from 'domyno'; 2 | 3 | import { map } from '../../../utils/map.js'; 4 | import { pipe } from '../../../utils/pipe.js'; 5 | 6 | const BACKTICKS = '```'; 7 | 8 | type ResourceDescription = { 9 | title: string; 10 | url: string; 11 | description: string; 12 | }; 13 | 14 | const mapTransform = map( 15 | ({ title, url, description }) => 16 | ` 17 | **${title}** - <${url}> 18 | ${BACKTICKS} 19 | ${description} 20 | ${BACKTICKS} 21 | `.trim() 22 | ); 23 | 24 | const transform = pipe, string>([ 25 | mapTransform, 26 | collect, 27 | (arr: string[]) => arr.join('\n'), 28 | ]); 29 | 30 | const resources = [ 31 | { 32 | title: 'Javascript.Info', 33 | url: 'https://javascript.info', 34 | description: 35 | 'A beginners guide to Javascript, setup like a course and completely free', 36 | }, 37 | { 38 | title: 'Mozilla Developer Network (MDN)', 39 | url: 'https://developer.mozilla.org/en-US/docs/Web/', 40 | description: 'Unofficial Web Documentation, completely free', 41 | }, 42 | ]; 43 | 44 | export const javascript: [string, { content: string }] = [ 45 | 'javascript', 46 | { 47 | content: ` 48 | **Javascript Resources** 49 | Below is a set of useful javascript resources 50 | --- 51 | ${transform(resources)}`, 52 | }, 53 | ]; 54 | -------------------------------------------------------------------------------- /src/v2/modules/onboarding/steps/handleStart.ts: -------------------------------------------------------------------------------- 1 | import { ButtonStyle, Guild, GuildMember, MessageActionRowComponentBuilder } from 'discord.js'; 2 | import { ActionRowBuilder, ButtonBuilder } from 'discord.js'; 3 | 4 | import { UserStateType } from '../db/user_state'; 5 | import { getThread } from '../utils/getThread.js'; 6 | 7 | export async function handleStart( 8 | guild: Guild, 9 | member: GuildMember, 10 | oldState: UserStateType, 11 | fromStart: boolean 12 | ): Promise { 13 | const thread = await getThread(guild, oldState.threadId); 14 | const pinned = await thread.messages.fetchPinned(); 15 | 16 | const rulesMsg = pinned.last(); 17 | 18 | setTimeout(async () => { 19 | await rulesMsg.edit({ 20 | components: [ 21 | new ActionRowBuilder().addComponents([ 22 | new ButtonBuilder() 23 | .setStyle(ButtonStyle.Success) 24 | .setLabel('I have read, and agree to follow the rules') 25 | .setCustomId('onboarding🤔rules_agreed'), 26 | ]), 27 | ], 28 | }); 29 | }, 15_000); 30 | 31 | if (fromStart) { 32 | await pinned.last().reply({ 33 | content: `Hey ${member.toString()}, seems like something went wrong during your onboarding, this could be because you left during it or the bot was down. You should be able to continue from here.`, 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/v2/commands/please/handlers/format/exampleFns.ts: -------------------------------------------------------------------------------- 1 | // This file is literally just containing code to use in examples 2 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 3 | export const LINE_SEPARATOR = '\n'; 4 | 5 | function formatFn(fn: Function) { 6 | return fn 7 | .toString() 8 | .split(LINE_SEPARATOR) 9 | .map(line => `> ${line}`) 10 | .join(LINE_SEPARATOR); 11 | } 12 | 13 | function annoyTitan() { 14 | // eslint-disable-next-line no-console 15 | console.log('react > vue'); 16 | } 17 | 18 | function getActualName(user: string) { 19 | const map = { 20 | emnudge: 'imnudeguy', 21 | gerrit: 'gerratata', 22 | innovati: 'innevada', 23 | }; 24 | 25 | return map[user] ?? 'no match found'; 26 | } 27 | 28 | /** 29 | * @deprecated 30 | */ 31 | function typeOfNestor() { 32 | return 'naab'; 33 | } 34 | 35 | function getTheBestOfSteves() { 36 | return 'DLSteve'; 37 | } 38 | 39 | const typeMap = {}; 40 | 41 | function getEstimatedPayment(type, rate, hours) { 42 | if (type === 'equity') { 43 | return 0; 44 | } 45 | 46 | return rate * hours * typeMap[type]; 47 | } 48 | 49 | function getBenzFavoriteWord() { 50 | return 'oop'; 51 | } 52 | 53 | export const exampleFns = [ 54 | formatFn, 55 | annoyTitan, 56 | getActualName, 57 | typeOfNestor, 58 | getTheBestOfSteves, 59 | getBenzFavoriteWord, 60 | getEstimatedPayment, 61 | ]; 62 | -------------------------------------------------------------------------------- /src/v2/modules/onboarding/events/handleSkipIntro.ts: -------------------------------------------------------------------------------- 1 | import type { Interaction, GuildMember } from 'discord.js'; 2 | 3 | import { UserState } from '../db/user_state.js'; 4 | import { continueOnboarding } from '../utils/continueOnboarding.js'; 5 | import { getThread } from '../utils/getThread.js'; 6 | 7 | export const handleSkipIntro = async ( 8 | interaction: Interaction 9 | ): Promise => { 10 | if (!interaction.isButton()) { 11 | return; 12 | } 13 | 14 | const [type, subtype] = interaction.customId.split('🤔'); 15 | 16 | if (type !== 'onboarding' || subtype !== 'skip_intro') { 17 | return; 18 | } 19 | 20 | const oldState = await UserState.findOne({ 21 | guild: interaction.guild.id, 22 | userId: interaction.user.id, 23 | }); 24 | 25 | const thread = await getThread(interaction.guild, oldState.threadId); 26 | 27 | const pinned = await thread.messages.fetchPinned(); 28 | const introMsg = pinned.last(); 29 | 30 | try { 31 | await interaction.reply( 32 | 'No worries, you can always introduce yourself later' 33 | ); 34 | } catch {} 35 | 36 | try { 37 | await introMsg?.edit({ 38 | components: [], 39 | }); 40 | } catch {} 41 | 42 | oldState.state = 'ROLE_SELECTION'; 43 | 44 | await Promise.all([oldState.save(), introMsg.unpin()]); 45 | 46 | continueOnboarding( 47 | interaction.guild, 48 | interaction.member as GuildMember, 49 | oldState, 50 | false 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /src/v2/commands/resource/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationCommandOptionChoiceData, 3 | ApplicationCommandOptionType, 4 | Client, 5 | CommandInteraction, 6 | } from 'discord.js'; 7 | 8 | import { CommandDataWithHandler } from '../../../types'; 9 | import { map } from '../../utils/map.js'; 10 | import { ValueOrNullary } from '../../utils/valueOrCall.js'; 11 | import { valueOrCall } from '../../utils/valueOrCall.js'; 12 | import { javascript } from './handlers/javascript.js'; 13 | 14 | const resourceMessages = new Map>([ 15 | javascript, 16 | ]); 17 | 18 | const mapTransformToChoices = map( 19 | (item: string): ApplicationCommandOptionChoiceData => ({ 20 | name: item, 21 | value: item, 22 | }) 23 | ); 24 | 25 | export const resourceInteraction: CommandDataWithHandler = { 26 | description: 'Quick response for asking someone to please use something', 27 | handler: async (client: Client, interaction: CommandInteraction) => { 28 | const content = resourceMessages.get(interaction.options.get('for').value as string); 29 | 30 | if (content) { 31 | interaction.reply(valueOrCall(content)); 32 | } 33 | }, 34 | name: 'resource', 35 | options: [ 36 | { 37 | choices: [...mapTransformToChoices(resourceMessages.keys())], 38 | description: 'what are you looking to find resources for', 39 | name: 'for', 40 | required: true, 41 | type: ApplicationCommandOptionType.String, 42 | }, 43 | ], 44 | }; 45 | -------------------------------------------------------------------------------- /src/v2/modules/onboarding/events/handleRulesAgree.ts: -------------------------------------------------------------------------------- 1 | import { ButtonStyle, GuildMember, Interaction, Message, MessageActionRowComponentBuilder } from 'discord.js'; 2 | import { ActionRowBuilder, ButtonBuilder, MessageComponentBuilder } from 'discord.js'; 3 | 4 | import { UserState } from '../db/user_state.js'; 5 | import { continueOnboarding } from '../utils/continueOnboarding.js'; 6 | 7 | export const handleRulesAgree = async ( 8 | interaction: Interaction 9 | ): Promise => { 10 | if (!interaction.isButton()) { 11 | return; 12 | } 13 | 14 | const [type, subType] = interaction.customId.split('🤔'); 15 | if (type !== 'onboarding' || subType !== 'rules_agreed') { 16 | return; 17 | } 18 | 19 | const message = interaction.message as Message; 20 | 21 | await message.edit({ 22 | components: [ 23 | new ActionRowBuilder().addComponents( 24 | new ButtonBuilder() 25 | .setStyle(ButtonStyle.Success) 26 | .setLabel(`You've agreed to the rules.`) 27 | .setCustomId('onboarding🤔rules_agreed') 28 | .setDisabled(true), 29 | ), 30 | ], 31 | }); 32 | interaction.reply('Great! Just a couple more steps.'); 33 | 34 | const oldState = await UserState.findOne({ 35 | guild: interaction.guild.id, 36 | userId: interaction.user.id, 37 | }); 38 | 39 | oldState.state = 'INTRODUCTION'; 40 | oldState.rulesAgreedDate = new Date(); 41 | await oldState.save(); 42 | 43 | await message.unpin(); 44 | 45 | continueOnboarding( 46 | interaction.guild, 47 | interaction.member as GuildMember, 48 | oldState 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /src/v2/modules/onboarding/events/handleThreadArchived.ts: -------------------------------------------------------------------------------- 1 | import type { ThreadChannel } from 'discord.js'; 2 | 3 | import { INTRO_ROLE, ONBOARDING_CHANNEL, NEW_USER_ROLE } from '../../../env.js'; 4 | import { UserState } from '../db/user_state.js'; 5 | 6 | export async function handleThreadArchived( 7 | oldThread: ThreadChannel, 8 | thread: ThreadChannel 9 | ): Promise { 10 | // ignore it if it's not an archive change 11 | if (oldThread.parentId !== ONBOARDING_CHANNEL) { 12 | return; 13 | } 14 | if (oldThread.archived || !thread.archived) { 15 | return; 16 | } 17 | 18 | const userState = await UserState.findOne({ 19 | threadId: oldThread.id, 20 | }); 21 | 22 | if (!userState) { 23 | return; 24 | } 25 | 26 | const member = await oldThread.guild.members.fetch(userState.userId); 27 | 28 | if (!member) { 29 | return; 30 | } 31 | 32 | if (userState.state === 'ONBOARDED') { 33 | member.roles.remove([NEW_USER_ROLE, INTRO_ROLE]); 34 | } else { 35 | try { 36 | const dmChannel = await member.createDM(); 37 | await dmChannel.send({ 38 | content: `You've been kicked from the Web Dev Discord Server as you did not complete onboarding in the alloted time. If you'd like to join again, here is a new invite: https://discord.gg/web`, 39 | }); 40 | } catch (error) { 41 | console.error(`Failed to create DM thread:`, error); 42 | } 43 | try { 44 | await userState?.deleteOne(); 45 | } catch { 46 | console.error('Failed to delete user state'); 47 | } 48 | 49 | member.kick(`User did not complete onboarding in the alloted time.`); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/v2/cache/cacheFns.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 2 | import { GenericCache } from './model.js'; 3 | 4 | export type CacheUpsertOptions = { 5 | guild: string; 6 | type: string; 7 | user: string; 8 | meta?: unknown; 9 | } & ( 10 | | { 11 | expiresIn: number; 12 | } 13 | | { expiresAt: number } 14 | ); 15 | 16 | export type CacheFindOptions = { 17 | type: string; 18 | user: string; 19 | guild: string; 20 | }; 21 | 22 | export async function get({ type, user, guild }: CacheFindOptions) { 23 | await _purge(); 24 | return GenericCache.findOne({ 25 | type, 26 | user, 27 | guild, 28 | timestamp: { $gte: Date.now() }, 29 | }); 30 | } 31 | 32 | export async function upsert(options: CacheUpsertOptions) { 33 | const { guild, type, user, meta } = options; 34 | 35 | // Cannot use destructuring due to the properties being optional and TS not liking it 36 | // eslint-disable-next-line unicorn/consistent-destructuring 37 | const expireTime = 38 | 'expiresAt' in options ? options.expiresAt : Date.now() + options.expiresIn; 39 | 40 | const result = await GenericCache.findOneAndUpdate( 41 | { 42 | guild, 43 | type, 44 | user, 45 | timestamp: { 46 | $gte: expireTime, 47 | }, 48 | }, 49 | { meta, timestamp: expireTime }, 50 | { upsert: true } 51 | ); 52 | await _purge(); 53 | return result; 54 | } 55 | 56 | export async function clear({ guild, type, user }: CacheFindOptions) { 57 | await _purge(); 58 | return GenericCache.deleteOne({ guild, type, user }); 59 | } 60 | 61 | async function _purge() { 62 | return GenericCache.deleteMany({ 63 | timestamp: { $lt: Date.now() }, 64 | }); 65 | } 66 | -------------------------------------------------------------------------------- /src/v2/modules/onboarding/steps/handleIntroduction.ts: -------------------------------------------------------------------------------- 1 | import { ButtonStyle, Guild, GuildMember, MessageActionRowComponentBuilder } from 'discord.js'; 2 | import { ActionRowBuilder, ButtonBuilder } from 'discord.js'; 3 | 4 | import { INTRO_CHANNEL, INTRO_ROLE } from '../../../env.js'; 5 | import { UserStateType } from '../db/user_state'; 6 | import { getThread } from '../utils/getThread.js'; 7 | import { sneakPin } from '../utils/sneakPin.js'; 8 | 9 | export async function handleIntroduction( 10 | guild: Guild, 11 | member: GuildMember, 12 | oldState: UserStateType, 13 | fromStart: boolean 14 | ): Promise { 15 | const thread = await getThread(guild, oldState.threadId); 16 | const pinned = await thread.messages.fetchPinned(); 17 | 18 | if (pinned.size === 0) { 19 | const msg = await thread.send({ 20 | content: `:point_left: You should now have access to the <#${INTRO_CHANNEL}> channel. It would be great if you could introduce yourself in that channel. We understand if you don't want to, however, so feel free to skip this step now, or do it later`, 21 | components: [ 22 | new ActionRowBuilder().addComponents([ 23 | new ButtonBuilder() 24 | .setLabel('Skip') 25 | .setStyle(ButtonStyle.Danger) 26 | .setEmoji('⏩') 27 | .setCustomId('onboarding🤔skip_intro'), 28 | ]), 29 | ], 30 | }); 31 | 32 | await sneakPin(msg); 33 | 34 | member.roles.add(INTRO_ROLE); 35 | } 36 | 37 | if (fromStart) { 38 | await pinned.last().reply({ 39 | content: `Hey ${member.toString()}, seems like something went wrong during your onboarding, this could be because you left during it or the bot was down. You should be able to continue from here.`, 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/v2/autorespond/html_parsing/index.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from 'discord.js'; 2 | import { filter } from 'domyno'; 3 | 4 | import { createCodeBlockCapturer } from '../../utils/codeBlockCapturer.js'; 5 | import { map } from '../../utils/map.js'; 6 | import { pipe } from '../../utils/pipe.js'; 7 | import { pluck } from '../../utils/pluck.js'; 8 | import { _ } from '../../utils/pluralize.js'; 9 | import { hasDeprecatedHTMLElementInSource } from './hasDeprecated.js'; 10 | 11 | const jsCodeBlocks = createCodeBlockCapturer(['html']); 12 | 13 | const getDeprecatedElements = pipe>([ 14 | jsCodeBlocks, 15 | pluck('code'), 16 | map(hasDeprecatedHTMLElementInSource), 17 | filter(Boolean), 18 | ]); 19 | 20 | const mdnDeprecatedElUri = 21 | ' 24 | ``; 25 | 26 | export function detectDeprecatedHTML(msg: Message): boolean { 27 | if (msg.author.id === msg.client.user.id) { 28 | return; 29 | } 30 | 31 | const { content, channel, author } = msg; 32 | 33 | const deprecated = [...getDeprecatedElements(content)].flat(1); 34 | if (deprecated.length > 0) { 35 | const deprecatedTags = deprecated.map(([item]) => `\`<${item}>\``); 36 | const template = _`Hey <@!${author.id}>, I've noticed you're using ${ 37 | _.n 38 | } deprecated element${_.s}. ${deprecatedTags} ${isA} deprecated element${ 39 | _.s 40 | }. Consider reading up on the alternatives here:\n${x => 41 | x > 5 ? mdnDeprecatedElUri : deprecated.map(mdnLink).join('\n')}`; 42 | channel.send(template(deprecated.length)); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/v2/modules/roles/commands/index.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionType } from 'discord.js'; 2 | import type { CommandDataWithHandler } from '../../../../types'; 3 | import { SERVER_ID } from '../../../env.js'; 4 | import { handleAddRemoveRole } from '../events/handleAddRemoveRole.js'; 5 | import { handleAutoCompleteRole } from '../events/handleAutoCompleteRole.js'; 6 | import { change } from './change.js'; 7 | import { suggest } from './suggest.js'; 8 | 9 | export const roleCommands: CommandDataWithHandler = { 10 | name: 'roles', 11 | description: 'update your roles', 12 | async handler(client, interaction) { 13 | switch (interaction.options.getSubcommand()) { 14 | case 'suggest': 15 | await suggest(interaction); 16 | break; 17 | case 'change': 18 | change(interaction); 19 | break; 20 | } 21 | }, 22 | onAttach(client) { 23 | client.on('interactionCreate', handleAddRemoveRole); 24 | 25 | client.on('interactionCreate', handleAutoCompleteRole); 26 | }, 27 | guildValidate: guild => guild.id === SERVER_ID, 28 | options: [ 29 | { 30 | name: 'suggest', 31 | type: ApplicationCommandOptionType.Subcommand, 32 | description: 'Suggest a role to a user', 33 | options: [ 34 | { 35 | name: 'role', 36 | type: ApplicationCommandOptionType.String, 37 | description: 'role', 38 | autocomplete: true, 39 | required: true, 40 | }, 41 | { 42 | name: 'user', 43 | type: ApplicationCommandOptionType.User, 44 | description: 'The user you want to suggested the roles to', 45 | }, 46 | ], 47 | }, 48 | { 49 | name: 'change', 50 | type: ApplicationCommandOptionType.Subcommand, 51 | description: 'Change your roles', 52 | }, 53 | ], 54 | }; 55 | -------------------------------------------------------------------------------- /src/v2/commands/please/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationCommandOptionChoiceData, 3 | ApplicationCommandOptionType, 4 | Client, 5 | CommandInteraction, 6 | } from 'discord.js'; 7 | 8 | import type { CommandDataWithHandler } from '../../../types'; 9 | import { map } from '../../utils/map.js'; 10 | import type { ValueOrNullary } from '../../utils/valueOrCall.js'; 11 | import { valueOrCall } from '../../utils/valueOrCall.js'; 12 | import { code } from './handlers/code.js'; 13 | import { english } from './handlers/english.js'; 14 | import { format } from './handlers/format/index.js'; 15 | import { justAsk } from './handlers/justask.js'; 16 | 17 | const pleaseMessages = new Map>([ 18 | format, 19 | code, 20 | justAsk, 21 | english, 22 | ]); 23 | 24 | const mapTransformToChoices = map( 25 | (item: string): ApplicationCommandOptionChoiceData => ({ 26 | name: item, 27 | value: item, 28 | }) 29 | ); 30 | 31 | export const pleaseInteraction: CommandDataWithHandler = { 32 | name: 'please', 33 | description: 'Quick response for asking someone to please use something', 34 | handler: async (client: Client, interaction: CommandInteraction) => { 35 | const content = pleaseMessages.get(interaction.options.get('topic').value as string); 36 | const user = interaction.options.get('user').user; 37 | 38 | if (content) { 39 | await interaction.reply( 40 | `${user ? `${user.toString()}\n` : ''}${valueOrCall(content).trim()}` 41 | ); 42 | } 43 | }, 44 | options: [ 45 | { 46 | name: 'topic', 47 | description: 'The topic to ask about', 48 | choices: [...mapTransformToChoices(pleaseMessages.keys())], 49 | required: true, 50 | type: ApplicationCommandOptionType.String, 51 | }, 52 | { 53 | name: 'user', 54 | description: 'Optional Person to Tag', 55 | type: ApplicationCommandOptionType.User, 56 | }, 57 | ], 58 | }; 59 | -------------------------------------------------------------------------------- /src/v2/utils/normalizeCommand.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationCommandChannelOptionData, 3 | ApplicationCommandChoicesData, 4 | ApplicationCommandData, 5 | ApplicationCommandNonOptionsData, 6 | ApplicationCommandOptionData, 7 | ApplicationCommandOptionType, 8 | ApplicationCommandSubCommandData, 9 | ApplicationCommandSubGroupData, 10 | ApplicationCommandType, 11 | PermissionsBitField, 12 | } from 'discord.js'; 13 | 14 | export function normalizeApplicationCommandData< 15 | T extends ApplicationCommandData 16 | >(cmd: T): T { 17 | if (!('type' in cmd) || cmd.type === ApplicationCommandType.ChatInput) { 18 | return { 19 | ...cmd, 20 | type: 'CHAT_INPUT', 21 | defaultPermission: cmd.defaultMemberPermissions ?? PermissionsBitField.Default, 22 | options: (cmd.options ?? []).map(normalizeApplicationOptionData), 23 | }; 24 | } 25 | return { ...cmd }; 26 | } 27 | 28 | function normalizeApplicationOptionData< 29 | T extends 30 | | ApplicationCommandOptionData 31 | | ApplicationCommandSubCommandData 32 | | ApplicationCommandSubGroupData 33 | | ApplicationCommandNonOptionsData 34 | | ApplicationCommandChoicesData 35 | | ApplicationCommandChannelOptionData 36 | >(option: T): T { 37 | if (option.type === ApplicationCommandOptionType.Subcommand) { 38 | return { 39 | ...option, 40 | options: (option.options ?? []).map(normalizeApplicationOptionData), 41 | required: (option as unknown as { required: boolean }).required ?? false, 42 | }; 43 | } 44 | 45 | if (option.type === ApplicationCommandOptionType.SubcommandGroup) { 46 | return { 47 | ...option, 48 | options: (option.options ?? []).map(normalizeApplicationOptionData), 49 | required: (option as unknown as { required: boolean }).required ?? false, 50 | }; 51 | } 52 | 53 | return { 54 | ...option, 55 | required: (option as unknown as { required: boolean }).required ?? false, 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /src/v2/commands/whyno/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationCommandOptionChoiceData, 3 | ApplicationCommandOptionType, 4 | Client, 5 | CommandInteraction, 6 | } from 'discord.js'; 7 | 8 | import { CommandDataWithHandler } from '../../../types'; 9 | import { map } from '../../utils/map.js'; 10 | import { ValueOrNullary } from '../../utils/valueOrCall.js'; 11 | import { valueOrCall } from '../../utils/valueOrCall.js'; 12 | import { channel } from './handlers/channel.js'; 13 | import { jquery } from './handlers/jquery.js'; 14 | 15 | const whynoMessages = new Map>([ 16 | jquery, 17 | channel, 18 | ]); 19 | 20 | const mapTransformToChoices = map( 21 | (item: string): ApplicationCommandOptionChoiceData => ({ 22 | name: item, 23 | value: item, 24 | }) 25 | ); 26 | 27 | export const whynoInteraction: CommandDataWithHandler = { 28 | description: 'Quick response for common "why no" or "why not..." questions', 29 | handler: async ( 30 | client: Client, 31 | interaction: CommandInteraction 32 | ): Promise => { 33 | const topic = interaction.options.get('topic').value as string; 34 | const user = interaction.options.get('tag').user; 35 | const content = whynoMessages.get(topic); 36 | 37 | if (content) { 38 | interaction.reply( 39 | `${user ? `${user.toString()}\n` : ''} ${valueOrCall(content).trim()}` 40 | ); 41 | return; 42 | } 43 | 44 | interaction.reply(`An error occured when trying to call \`/whyno ${topic}`); 45 | }, 46 | name: 'whyno', 47 | options: [ 48 | { 49 | choices: [...mapTransformToChoices(whynoMessages.keys())], 50 | description: 'The topic in question', 51 | name: 'topic', 52 | required: true, 53 | type: ApplicationCommandOptionType.String, 54 | }, 55 | { 56 | name: 'tag', 57 | description: 'Optional Person to Tag', 58 | type: ApplicationCommandOptionType.User, 59 | }, 60 | ], 61 | }; 62 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DUMMY_TOKEN= 2 | REPO_LINK=https://github.com/ljosberinn/webdev-support-bot 3 | 4 | MOD_CHANNEL= 5 | NUMBER_OF_ALLOWED_MESSAGES=5 6 | CACHE_REVALIDATION_IN_SECONDS=5 7 | FINAL_CACHE_EXPIRATION_IN_SECONDS=30 8 | 9 | JOB_POSTINGS_CHANNEL= 10 | AWAIT_MESSAGE_TIMEOUT= # Seconds, but the API uses miliseconds 11 | MINIMAL_COMPENSATION= 12 | MINIMAL_AMOUNT_OF_WORDS= # Used for checking if the job description contains at least this amount of words 13 | POST_LIMITER_IN_HOURS= 14 | 15 | API_CACHE_ENTRIES_LIMIT=100 16 | API_CACHE_REVALIDATION_WINDOW_IN_SECONDS=300 # No need to hammer the API cache frequently 17 | API_CACHE_EXPIRATION_IN_SECONDS=10800 # Default to 3 hours. 18 | 19 | MONGO_URI=mongodb://localhost:27017/ 20 | # Get the ID by going to server settings -> roles and right-clicking on the role, selecting 'Copy ID' 21 | HELPFUL_ROLE_ID= 22 | HELPFUL_ROLE_EXEMPT_ID= # Role for users which are permament helpers. Paired with the helper role 23 | # The amount of points which the user has to accumulate for it to receive the role 24 | HELPFUL_ROLE_POINT_THRESHOLD=10 25 | # Timer (in hours) value for the point decay system. This value has to be an integer 26 | POINT_DECAY_TIMER=24 27 | MOD_ROLE_ID= 28 | ADMIN_ROLE_ID= 29 | SERVER_ID= # Local environment server ID 30 | 31 | POINT_LIMITER_IN_MINUTES= 32 | # Used for checking if the user has given a point to another user within the timeframe provided 33 | 34 | VAR_DETECT_LIMIT=1800000 35 | JUST_ASK_DETECT_LIMIT=86400000 36 | 37 | # Required for onboarding feature 38 | NEW_USER_ROLE= 39 | ONBOARDING_CHANNEL= 40 | JOIN_LOG_CHANNEL= 41 | INTRO_CHANNEL= 42 | INTRO_ROLE= 43 | REPEL_ROLE_ID=1002411741776461844 # The ID of the role that is used for MiniMods 44 | REPEL_DEFAULT_DELETE_COUNT=20 # The number of messages to delete when using the repel command 45 | REPEL_LOG_CHANNEL_ID=1403558160144531589 # The channel where the repel command logs are sent 46 | REPEL_DEFAULT_TIMEOUT=6 # Default timeout for the repel command in HOURS 47 | MODERATORS_ROLE_IDS=465222496891699200 # Comma-separated list of moderator role IDs 48 | -------------------------------------------------------------------------------- /src/v2/modules/mod/commands/index.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionType, PermissionFlagsBits, PermissionsBitField } from 'discord.js'; 2 | import type { CommandDataWithHandler } from '../../../../types'; 3 | import { SERVER_ID } from '../../../env.js'; 4 | import { debugOnboarding } from './onboardingBegin.js'; 5 | import { setupOnboardingMsg } from './onboardingMsg.js'; 6 | import { setupRoles } from './roles.js'; 7 | 8 | export const setupCommands: CommandDataWithHandler = { 9 | name: 'setup', 10 | description: 'Setup Commands', 11 | async handler(client, interaction) { 12 | const cmd = [ 13 | interaction.options.getSubcommandGroup(), 14 | interaction.options.getSubcommand(), 15 | ].join('.'); 16 | switch (cmd) { 17 | case 'roles.message': 18 | await setupRoles(interaction); 19 | break; 20 | case 'onboarding.debug': 21 | debugOnboarding(interaction); 22 | break; 23 | 24 | case 'onboarding.message': 25 | setupOnboardingMsg(interaction); 26 | break; 27 | } 28 | }, 29 | guildValidate: guild => guild.id === SERVER_ID, 30 | defaultMemberPermissions: PermissionsBitField.Flags.Administrator, 31 | options: [ 32 | { 33 | name: 'roles', 34 | type: ApplicationCommandOptionType.SubcommandGroup, 35 | description: 'Post the role change post here', 36 | options: [ 37 | { 38 | name: 'message', 39 | type: ApplicationCommandOptionType.Subcommand, 40 | description: 'Post the onboarding command here', 41 | }, 42 | ], 43 | }, 44 | { 45 | name: 'onboarding', 46 | type: ApplicationCommandOptionType.SubcommandGroup, 47 | description: 'onboarding setup commands', 48 | options: [ 49 | { 50 | name: 'message', 51 | type: ApplicationCommandOptionType.Subcommand, 52 | description: 'Post the onboarding command here', 53 | }, 54 | { name: 'debug', type: ApplicationCommandOptionType.Subcommand, description: 'For now... debug info' }, 55 | ], 56 | }, 57 | ], 58 | }; 59 | -------------------------------------------------------------------------------- /src/v2/commands/about/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationCommandOptionChoiceData, 3 | ApplicationCommandOptionType, 4 | Client, 5 | CommandInteraction, 6 | } from 'discord.js'; 7 | 8 | import type { CommandDataWithHandler } from '../../../types'; 9 | import { map } from '../../utils/map.js'; 10 | import type { ValueOrNullary } from '../../utils/valueOrCall.js'; 11 | import { valueOrCall } from '../../utils/valueOrCall.js'; 12 | import { flexbox } from './handlers/flexbox.js'; 13 | import { lockfile } from './handlers/lockfile.js'; 14 | import { modules } from './handlers/modules.js'; 15 | import { vscode } from './handlers/vscode.js'; 16 | 17 | const aboutMessages = new Map>([ 18 | vscode, 19 | modules, 20 | flexbox, 21 | lockfile, 22 | ]); 23 | 24 | const mapTransformToChoices = map( 25 | (item: string): ApplicationCommandOptionChoiceData => ({ 26 | name: item, 27 | value: item, 28 | }) 29 | ); 30 | 31 | export const aboutInteraction: CommandDataWithHandler = { 32 | name: 'about', 33 | options: [ 34 | { 35 | choices: [...mapTransformToChoices(aboutMessages.keys())], 36 | description: 'The topic to ask about', 37 | name: 'topic', 38 | required: true, 39 | type: ApplicationCommandOptionType.String, 40 | }, 41 | { 42 | name: 'tag', 43 | description: 'Optional Person to Tag', 44 | type: ApplicationCommandOptionType.User, 45 | }, 46 | ], 47 | description: 48 | 'Quick response for common "why" or "Tell me about..." questions', 49 | handler: async ( 50 | client: Client, 51 | interaction: CommandInteraction 52 | ): Promise => { 53 | const topic = interaction.options.get('topic').value as string; 54 | const user = interaction.options.get('tag').user; 55 | const content = aboutMessages.get(topic); 56 | 57 | if (content) { 58 | interaction.reply( 59 | `${user ? `${user.toString()}\n` : ''} ${valueOrCall(content).trim()}` 60 | ); 61 | return; 62 | } 63 | 64 | interaction.reply(`An error occured when trying to call \`/about ${topic}`); 65 | }, 66 | }; 67 | -------------------------------------------------------------------------------- /src/v2/autorespond/thanks/createResponse.ts: -------------------------------------------------------------------------------- 1 | import { ButtonBuilder, MessageReplyOptions } from 'discord.js'; 2 | import { ActionRowBuilder, UserSelectMenuBuilder } from 'discord.js'; 3 | import { User, MessagePayload, ButtonStyle } from 'discord.js'; 4 | import type { EmbedField, Collection, MessageActionRowComponentBuilder } from 'discord.js'; 5 | 6 | import { clampLength } from '../../utils/clampStr.js'; 7 | import { createEmbed } from '../../utils/discordTools.js'; 8 | 9 | export function createResponse( 10 | thankedUsers: Collection, 11 | authorId: string 12 | ): MessageReplyOptions { 13 | const title = `Point${thankedUsers.size === 1 ? '' : 's'} received!`; 14 | 15 | const description = `<@!${authorId}> has given a point to ${thankedUsers.size === 1 16 | ? `<@!${thankedUsers.first().id}>` 17 | : 'the users mentioned below' 18 | }!`; 19 | 20 | const fields: EmbedField[] = 21 | thankedUsers.size > 1 22 | ? [...thankedUsers].map(([, u], i) => ({ 23 | inline: false, 24 | name: `${(i + 1).toString()}.`, 25 | value: `<@!${u.id}>`, 26 | })) 27 | : []; 28 | 29 | const output = createEmbed({ 30 | description, 31 | fields, 32 | footerText: 33 | 'Thank a helpful member by replying "thanks @username" or saying "thanks" in a reply or thread.', 34 | provider: 'helper', 35 | title, 36 | }).embed; 37 | 38 | return { 39 | embeds: [output], 40 | components: [ 41 | new ActionRowBuilder().addComponents( 42 | thankedUsers.size > 1 43 | ? new UserSelectMenuBuilder() 44 | .setCustomId(`thanks🤔${authorId}🤔select`) 45 | .setPlaceholder('Accidentally Thank someone? Un-thank them here!') 46 | .setMinValues(1) 47 | .setDefaultUsers( 48 | ...thankedUsers.keys() 49 | ) 50 | : new ButtonBuilder() 51 | .setCustomId(`thanks🤔${authorId}🤔${thankedUsers.first().id}`) 52 | .setStyle(ButtonStyle.Secondary) 53 | .setLabel('This was an accident, UNDO!') 54 | ), 55 | ], 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/v2/spam_filter/handler.ts: -------------------------------------------------------------------------------- 1 | // import type { TextChannel, GuildChannel } from 'discord.js'; 2 | 3 | // import { MOD_CHANNEL } from '../env.js'; 4 | // import { createEmbed, createMarkdownBash } from '../utils/discordTools.js'; 5 | 6 | // import type { SpammerMetadata } from './index.js'; 7 | 8 | // type ModChannel = TextChannel & Pick; 9 | 10 | // const spamFilterHandler = async ({ 11 | // userID, 12 | // username, 13 | // discriminator, 14 | // channelId, 15 | // channelName, 16 | // msgID, 17 | // guild, 18 | // }: SpammerMetadata) => { 19 | // const targetChannel = guild.channels.cache.find( 20 | // ({ name }) => name === MOD_CHANNEL 21 | // ) as ModChannel; 22 | 23 | // if (!targetChannel) { 24 | // // eslint-disable-next-line no-console 25 | // console.warn(`channel ${MOD_CHANNEL} does not exist on this server`); 26 | // return; 27 | // } 28 | 29 | // const url = `https://discordapp.com/channels/${guild.id}/${channelId}/${msgID}`; 30 | // const user = `@${username}#${discriminator}`; 31 | 32 | // try { 33 | // await targetChannel.send( 34 | // createEmbed({ 35 | // author: { name: userID }, 36 | // description: 'Spam has been detected on the server.', 37 | // fields: [ 38 | // { inline: true, name: 'User', value: user }, 39 | // { 40 | // inline: true, 41 | // name: 'Channel', 42 | // value: channelName, 43 | // }, 44 | // { 45 | // inline: false, 46 | // name: 'Command', 47 | // value: createMarkdownBash( 48 | // `?mute ${user} 5h Spamming in #${channelName}` 49 | // ), 50 | // }, 51 | // { inline: false, name: 'Message Link', value: url }, 52 | // ], 53 | // footerText: 'Spam Filter', 54 | // provider: 'spam', 55 | // title: 'Alert!', 56 | // url, 57 | // }) 58 | // ); 59 | // } catch (error) { 60 | // // eslint-disable-next-line no-console 61 | // console.error(error); 62 | // } 63 | // }; 64 | 65 | // export default spamFilterHandler; 66 | -------------------------------------------------------------------------------- /src/v2/modules/onboarding/events/handleNotifyRolesSelected.ts: -------------------------------------------------------------------------------- 1 | import { ActionRowBuilder, ButtonBuilder, ComponentType, GuildMember, Interaction, Message, MessageActionRowComponent, MessageActionRowComponentBuilder } from 'discord.js'; 2 | 3 | import { UserState } from '../db/user_state.js'; 4 | import { continueOnboarding } from '../utils/continueOnboarding.js'; 5 | 6 | export const handleNotifyRolesSelected = async ( 7 | interaction: Interaction 8 | ): Promise => { 9 | if (!interaction.isSelectMenu()) { 10 | return; 11 | } 12 | 13 | const [type, subType] = interaction.customId.split('🤔'); 14 | if (type !== 'onboarding' || subType !== 'notify_roles') { 15 | return; 16 | } 17 | 18 | const message = interaction.message as Message; 19 | const member = interaction.member as GuildMember; 20 | const picked = new Set(interaction.values); 21 | 22 | const { guild, user } = interaction; 23 | 24 | const pickedRoles = guild.roles.cache.filter(x => picked.has(x.name)); 25 | 26 | member.roles.add(pickedRoles); 27 | interaction.reply({ 28 | content: `:loudspeaker: I've given you the roles to notify you about ${new Intl.ListFormat().format( 29 | [...pickedRoles.values()].map(x => x.name) 30 | )}`, 31 | }); 32 | const msg = interaction.message as Message; 33 | 34 | msg.edit({ 35 | components: msg.components.map(component => { 36 | if (component.type === ComponentType.ActionRow) { 37 | return new ActionRowBuilder(component) 38 | .setComponents(...component.components.map(x => { 39 | if (x.type === ComponentType.Button) { 40 | return new ButtonBuilder(x).setDisabled(true) 41 | } 42 | return x as unknown as MessageActionRowComponentBuilder 43 | })) 44 | } 45 | 46 | return component 47 | }), 48 | }); 49 | 50 | const oldState = await UserState.findOne({ 51 | guild: guild.id, 52 | userId: user.id, 53 | }); 54 | 55 | if (oldState.state !== 'ONBOARDED') { 56 | oldState.state = 'ONBOARDED'; 57 | await oldState.save(); 58 | 59 | await message.unpin(); 60 | 61 | continueOnboarding(guild, member, oldState); 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /docs/HELPFUL_USER_MODULE.md: -------------------------------------------------------------------------------- 1 | # Helpful User Module 2 | 3 | > System for adding and managing points for users. 4 | 5 | ### Usage: 6 | 7 | Users can receive points, and once they exceed a certain amount of points, which is set as the `HELPFUL_ROLE_POINT_THRESHOLD` environmental variable, will receive the `Helpful` role. 8 | 9 | The way points can be given out are: 10 | 11 | 1. Reacting to a message 12 | 2. "Thanking" the user 13 | 14 | Users can receive multiple points per day. 15 | 16 | ##### 1. Reacting to a message: 17 | 18 | For a point to be awarded to a user, the reaction emoji has to be one of the following: 19 | 20 | 1. `✅` 21 | 2. `✔️` 22 | 3. `☑️` 23 | 4. `🆙` 24 | 5. `⬆️` 25 | 6. `⏫` 26 | 7. `🔼` 27 | 28 | ##### 2. "Thanking" the user 29 | 30 | If any message contains one of the abbreviations listed below, and a user mention, the module will add a point to the user being mentioned. 31 | 32 | For example, if the user `Test#0000 - Test in further correspondence` has provided a solution which helped another user, that person can "thank" `Test` by sending a message in the channel which looks something along the lines of this: 33 | 34 | `Thanks for the help, @Test#0000` 35 | 36 | The example above is one of the many ways you can "thank" a user. 37 | 38 | For confirmation sake, the bot will send out a message in the channel stating that you, as the user, have "thanked" `Test`. 39 | 40 | ### Commands: 41 | 42 | This module introduces two commands: 43 | 44 | 1. `!points` - Tells you how many points you have accumulated. 45 | 2. `!leaderboard` - Displays the top 10 helpful users. 46 | 47 | ### Admin: 48 | 49 | For administrative purposes, flags have been added to the `!points` command. These are only usable by users that have either the `Moderator` or the `Admin` role, hence the introduction of the `MOD_ROLE_ID` and `ADMIN_ROLE_ID` environmental variables. 50 | 51 | The `!decay` command, along with it's own flag has been added for administrative purposes to the module. 52 | 53 | 1. `!points check ` - Displays the amount of points the user has accumulated. 54 | 2. `!points reset ` - Resets the user's points back to 0. 55 | 3. `!points set ` - Manually set the amount of points to a user. 56 | 4. `!decay` - Tells the time when the next point decay will occur. 57 | 5. `!decay force` - Forces a point decay to occur. -------------------------------------------------------------------------------- /src/v2/commands/post/questions.ts: -------------------------------------------------------------------------------- 1 | import { MINIMAL_COMPENSATION, MINIMAL_AMOUNT_OF_WORDS } from './env.js'; 2 | 3 | const isNotEmpty = (str: string): boolean => str.length > 0; 4 | 5 | const allowCertainAnswers = (allowed: string[], answer: string): boolean => 6 | allowed.includes(answer); 7 | 8 | /* 9 | Since checking if the input string is empty is not practical for this use-case, 10 | this function checks if the provided input has at the very least `MINIMAL_AMOUNT_OF_WORDS` words in it. 11 | */ 12 | const isNotShort = (str: string): boolean => 13 | str.split(' ').length >= MINIMAL_AMOUNT_OF_WORDS; 14 | 15 | const questions = new Map( 16 | Object.entries({ 17 | remote: { 18 | body: 'Type `yes` if your position is remote and `no` if it requires a location.', 19 | validate: (answer: string): boolean => 20 | allowCertainAnswers(['yes', 'no'], answer), 21 | }, 22 | location: { 23 | body: 'Provide the location in a single message. If you wish not to share the location reply with `no`.', 24 | validate: isNotEmpty, 25 | }, 26 | description: { 27 | body: 'With a single message provide a short description of the job.\nTypically job postings include a description of the job, estimated hours, technical knowledge requirements, scope, and desired qualifications.', 28 | validate: isNotShort, 29 | }, 30 | compensation_type: { 31 | body: 'Type `project` if your compensation amount is for the project or type `hourly` if your compensation amount is for an hourly rate.', 32 | validate: (answer: string): boolean => 33 | allowCertainAnswers(['project', 'hourly'], answer), 34 | }, 35 | compensation: { 36 | body: 'Provide the compensation amount for this job using **only** numbers.', 37 | validate: (answer: string): boolean => { 38 | const value = Number.parseFloat(answer.split('$').join('')); 39 | const minimalCompensation = Number.parseFloat(MINIMAL_COMPENSATION); 40 | return !Number.isNaN(value) && value >= minimalCompensation; 41 | }, 42 | }, 43 | contact: { 44 | body: 'Provide the method that applicants should apply for your job (e.g., DM, email, website application, etc.) and any additional information that you think would be helpful to potential applicants.', 45 | validation: isNotEmpty, 46 | }, 47 | }) 48 | ); 49 | 50 | export default questions; 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord-mdn-bot", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "engines": { 8 | "node": "20.12.2" 9 | }, 10 | "scripts": { 11 | "dev": "tsx watch -r dotenv/config ./src/index.ts", 12 | "start": "node build/index.js", 13 | "build": "tsc", 14 | "lint": "eslint src && tsc --noEmit", 15 | "test": "jest", 16 | "test:watch": "jest --watch", 17 | "test:ci": "jest --ci", 18 | "lint:fix": "eslint --fix src && prettier --write src", 19 | "lint:types": "tsc --noEmit", 20 | "install:clean": "rm -rf node_modules && rm yarn.lock && yarn", 21 | "docker:dev:up": "docker compose -f docker-compose.dev.yml up -d", 22 | "docker:dev:down": "docker compose -f docker-compose.dev.yml down" 23 | }, 24 | "keywords": [], 25 | "author": "", 26 | "license": "ISC", 27 | "dependencies": { 28 | "@mdn/browser-compat-data": "5.5.49", 29 | "@sentry/node": "8.27.0", 30 | "compare-versions": "6.1.1", 31 | "cross-env": "^7.0.3", 32 | "date-fns": "3.6.0", 33 | "discord.js": "14.21.0", 34 | "dom-parser": "1.1.5", 35 | "domyno": "1.0.1", 36 | "fuse.js": "7.0.0", 37 | "html-entities": "2.5.2", 38 | "lodash-es": "4.17.21", 39 | "mongoose": "8.5.4", 40 | "node-cache": "5.1.2", 41 | "node-fetch": "3.3.2", 42 | "node-html-parser": "6.1.13", 43 | "typescript": "5.5.4" 44 | }, 45 | "devDependencies": { 46 | "@sentry/types": "8.27.0", 47 | "@types/dom-parser": "0.1.4", 48 | "@types/html-entities": "1.3.4", 49 | "@types/jest": "29.5.12", 50 | "@types/mongoose": "5.11.97", 51 | "@types/node": "22.5.0", 52 | "@types/node-fetch": "3.0.3", 53 | "dotenv": "16.4.5", 54 | "eslint": "8.57.0", 55 | "eslint-config-galex": "4.5.2", 56 | "husky": "9.1.5", 57 | "jest": "29.7.0", 58 | "lint-staged": "15.2.9", 59 | "prettier": "3.3.3", 60 | "ts-jest": "29.2.5", 61 | "tsx": "^4.19.0" 62 | }, 63 | "husky": { 64 | "hooks": { 65 | "pre-commit": "lint-staged" 66 | } 67 | }, 68 | "lint-staged": { 69 | "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}": "prettier --write", 70 | "*.js": "eslint --fix" 71 | }, 72 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 73 | } 74 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | export const ENV = process.env.NODE_ENV || 'development'; 2 | export const IS_PROD = process.env.NODE_ENV === 'production'; 3 | 4 | export const SERVER_ID = IS_PROD ? '434487340535382016' : process.env.SERVER_ID; 5 | 6 | export const { DUMMY_TOKEN } = process.env; 7 | export const { DISCORD_TOKEN } = process.env; 8 | export const { REPO_LINK } = process.env; 9 | 10 | export const { MOD_CHANNEL } = process.env; 11 | export const { NUMBER_OF_ALLOWED_MESSAGES } = process.env; 12 | export const { CACHE_REVALIDATION_IN_SECONDS } = process.env; 13 | export const { FINAL_CACHE_EXPIRATION_IN_SECONDS } = process.env; 14 | 15 | export const { JOB_POSTINGS_CHANNEL } = process.env; 16 | export const { AWAIT_MESSAGE_TIMEOUT } = process.env; 17 | export const { MINIMAL_COMPENSATION } = process.env; 18 | export const { MINIMAL_AMOUNT_OF_WORDS } = process.env; 19 | export const { POST_LIMITER_IN_HOURS } = process.env; 20 | 21 | export const { API_CACHE_ENTRIES_LIMIT } = process.env; 22 | export const { API_CACHE_EXPIRATION_IN_SECONDS } = process.env; 23 | export const { API_CACHE_REVALIDATION_WINDOW_IN_SECONDS } = process.env; 24 | 25 | export const { MONGO_URI } = process.env; 26 | export const { HELPFUL_ROLE_ID } = process.env; 27 | export const { HELPFUL_ROLE_EXEMPT_ID } = process.env; 28 | export const { HELPFUL_ROLE_POINT_THRESHOLD } = process.env; 29 | export const { POINT_DECAY_TIMER } = process.env; 30 | export const { ADMIN_ROLE_ID } = process.env; 31 | export const { MOD_ROLE_ID } = process.env; 32 | 33 | export const { POINT_LIMITER_IN_MINUTES } = process.env; 34 | export const VAR_DETECT_LIMIT = 35 | Number.parseInt(process.env.VAR_DETECT_LIMIT) || 1_800_000; 36 | 37 | export const JUST_ASK_DETECT_LIMIT = 38 | Number.parseInt(process.env.JUST_ASK_DETECT_LIMIT) || 86_400_000; 39 | 40 | export const { NEW_USER_ROLE } = process.env; 41 | export const { ONBOARDING_CHANNEL } = process.env; 42 | export const { JOIN_LOG_CHANNEL } = process.env; 43 | export const { INTRO_CHANNEL } = process.env; 44 | export const { INTRO_ROLE } = process.env; 45 | 46 | export const { REPEL_ROLE_ID } = process.env; 47 | export const REPEL_DEFAULT_DELETE_COUNT = 48 | Number.parseInt(process.env.REPEL_DEFAULT_DELETE_COUNT) || 20; 49 | export const { REPEL_LOG_CHANNEL_ID } = process.env; 50 | export const REPEL_DEFAULT_TIMEOUT = 51 | Number.parseInt(process.env.REPEL_DEFAULT_TIMEOUT) || 6; 52 | 53 | export const MODERATORS_ROLE_IDS = process.env.MODERATORS_ROLE_IDS 54 | ? process.env.MODERATORS_ROLE_IDS.split(',') 55 | : undefined; 56 | -------------------------------------------------------------------------------- /src/v2/modules/onboarding/events/handleRoleSelected.ts: -------------------------------------------------------------------------------- 1 | import { ActionRowBuilder, ComponentType, MessageActionRowComponentBuilder, type GuildMember, type Interaction, type Message, StringSelectMenuBuilder } from 'discord.js'; 2 | 3 | import { UserState } from '../db/user_state.js'; 4 | import { continueOnboarding } from '../utils/continueOnboarding.js'; 5 | 6 | export const handleRoleSelected = async ( 7 | interaction: Interaction 8 | ): Promise => { 9 | if (!interaction.isStringSelectMenu() && !interaction.isButton()) { 10 | return; 11 | } 12 | 13 | const [type, subType, name] = interaction.customId.split('🤔'); 14 | if (type !== 'onboarding' || subType !== 'roles') { 15 | return; 16 | } 17 | 18 | const message = interaction.message as Message; 19 | const member = interaction.member as GuildMember; 20 | const picked = new Set(interaction.isButton() ? [name] : interaction?.values); 21 | 22 | const { guild, user } = interaction; 23 | const pickedRoles = guild.roles.cache.filter( 24 | x => picked.has(x.name) || x.name === 'All Development' 25 | ); 26 | 27 | await member.roles.add(pickedRoles); 28 | 29 | const oldState = await UserState.findOne({ 30 | guild: guild.id, 31 | userId: user.id, 32 | }); 33 | 34 | const msg = interaction.message as Message; 35 | 36 | await interaction.reply({ 37 | content: `I've given you the roles to give you access the channels for ${new Intl.ListFormat().format( 38 | [...pickedRoles.values()].map(x => x.name) 39 | )}`, 40 | }); 41 | 42 | await msg.edit({ 43 | components: msg.components.map(component => { 44 | if (component.type === ComponentType.ActionRow) { 45 | return new ActionRowBuilder(component) 46 | .setComponents( 47 | component.components.map(subComponent => { 48 | if (subComponent.type === ComponentType.StringSelect) { 49 | return new StringSelectMenuBuilder(subComponent).setDisabled(true) 50 | } 51 | // This feels wrong, there really should be a better way to edit a component...surely 52 | return subComponent as unknown as MessageActionRowComponentBuilder 53 | }) 54 | ) 55 | } 56 | return component; 57 | }), 58 | }); 59 | 60 | if (oldState.state !== 'ONBOARDED') { 61 | oldState.state = 'ONBOARDED'; 62 | await oldState.save(); 63 | 64 | await message.unpin(); 65 | 66 | continueOnboarding(guild, member, oldState); 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /src/v2/utils/useData.test.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from 'node-fetch'; 2 | import fetch from 'node-fetch'; 3 | 4 | import useData from './useData.js'; 5 | 6 | jest.mock('node-fetch'); 7 | const urlGen = () => `http://example.com/?q=${Date.now()}`; 8 | 9 | describe('useData', () => { 10 | const headers = { headers: {} }; 11 | const fetchMock = fetch as jest.MockedFunction; 12 | 13 | beforeEach(jest.clearAllMocks); 14 | 15 | test('returns errors when response is not ok', async () => { 16 | const url = urlGen(); 17 | 18 | fetchMock.mockResolvedValue({ 19 | ok: false, 20 | } as unknown as Response); 21 | 22 | const response = await useData(url, 'text'); 23 | 24 | expect(fetchMock).toBeCalledWith(url, headers); 25 | expect(response).toStrictEqual({ 26 | error: true, 27 | json: null, 28 | text: null, 29 | }); 30 | }); 31 | 32 | test.each([ 33 | [ 34 | 'json', 35 | () => { 36 | const jsonMock = jest.fn(); 37 | jsonMock.mockResolvedValue({ test: 'cached' }); 38 | 39 | fetchMock.mockResolvedValue({ 40 | json: jsonMock, 41 | ok: true, 42 | } as unknown as Response); 43 | 44 | return jsonMock; 45 | }, 46 | lastResponse => { 47 | expect(lastResponse.json).toStrictEqual({ test: 'cached' }); 48 | }, 49 | ], 50 | [ 51 | 'text', 52 | () => { 53 | const textMock = jest.fn(); 54 | textMock.mockResolvedValue('text'); 55 | 56 | fetchMock.mockResolvedValue({ 57 | ok: true, 58 | text: textMock, 59 | } as unknown as Response); 60 | 61 | return textMock; 62 | }, 63 | lastResponse => { 64 | expect(lastResponse.text).toBe('text'); 65 | }, 66 | ], 67 | ] as const)( 68 | 'should cache entries for type: `%s`', 69 | async (type: Parameters['1'], mock, assertResponse) => { 70 | const url = urlGen(); 71 | const mockTarget = mock(); 72 | 73 | const response = await useData(url, type); 74 | 75 | expect(fetchMock).toBeCalledWith(url, headers); 76 | 77 | assertResponse(response); 78 | 79 | const allCachedResponses = await Promise.all([ 80 | useData(url, type), 81 | useData(url, type), 82 | useData(url, type), 83 | useData(url, type), 84 | ]); 85 | 86 | expect(mockTarget).toBeCalledTimes(1); 87 | expect(fetchMock).toBeCalledTimes(1); 88 | 89 | allCachedResponses.forEach(lastResponse => { 90 | assertResponse(lastResponse); 91 | }); 92 | } 93 | ); 94 | }); 95 | -------------------------------------------------------------------------------- /src/v2/autorespond/thanks/checker.ts: -------------------------------------------------------------------------------- 1 | import { map } from '../../utils/map.js'; 2 | import { merge } from '../../utils/merge.js'; 3 | import { partition } from '../../utils/partition.js'; 4 | import nothanks from './nothanks.js'; 5 | import nothankyou from './nothankyou.js'; 6 | import thanks from './thanks.js'; 7 | import thankyou from './thankyou.js'; 8 | 9 | type ThankDef = typeof thanks[number]; 10 | 11 | const wordBoundaryBefore = String.raw`(?<=^|$|\P{L})`; 12 | const wordBoundaryAfter = String.raw`(?=^|$|\P{L})`; 13 | 14 | const wordBoundarableRegex = 15 | /\p{Changes_When_Uppercased}|\p{Changes_When_Lowercased}/u; 16 | const nonNegativableEnglish = ['cheers']; 17 | const english = [ 18 | 'ty', 19 | 'tyvm', 20 | 'thanks', 21 | 'thx', 22 | 'tnx', 23 | 'thank', 24 | 'thnaks', 25 | 'thankyou', 26 | 'thanku', 27 | 'thnkyou', 28 | 'tysm', 29 | 'thanx', 30 | 'thnx', 31 | ]; 32 | const negativeEnglish = [ 33 | ...english.flatMap(word => [ 34 | `n ${word}`, 35 | `n${word}`, 36 | `no${word}`, 37 | `no ${word}`, 38 | ]), 39 | 'no need to thank', 40 | 'thanks,? but no thanks', 41 | 'thanks for nothing', 42 | ]; 43 | 44 | const partitionWrap = partition( 45 | (str: string) => !!wordBoundarableRegex.test(str) 46 | ); 47 | 48 | const mapTextFromDefs = map((def: ThankDef) => 49 | removeDiacritics(def.text.toLocaleLowerCase(def.symbol)) 50 | ); 51 | 52 | const removeDiacritics = (str: string) => 53 | str.normalize('NFD').replace(/\p{Diacritic}/gu, ''); 54 | 55 | const [thanksWrappable, thanksUnwrappable] = partitionWrap( 56 | new Set(merge(mapTextFromDefs(thanks), mapTextFromDefs(thankyou))) 57 | ); 58 | 59 | const [noThanksWrappable, noThanksUnwrappable] = partitionWrap( 60 | new Set(merge(mapTextFromDefs(nothanks), mapTextFromDefs(nothankyou))) 61 | ); 62 | 63 | const wrapThanksSet = new Set([ 64 | ...english, 65 | ...nonNegativableEnglish, 66 | ...thanksWrappable, 67 | ]); 68 | const wrapNoThanksSet = new Set([...negativeEnglish, ...noThanksWrappable]); 69 | 70 | const or = (iter: Iterable) => `(?:${[...iter].join('|')})`; 71 | 72 | const thanksRegex = new RegExp( 73 | String.raw`(? { 87 | thanksRegex.lastIndex = -1; 88 | return thanksRegex.exec( 89 | removeDiacritics(str).replace(/\s+/u, ' ').replace(noThanksRegex, '') 90 | ); 91 | }; 92 | const keywordValidator = (str: string): boolean => { 93 | return Boolean(hasThanks(str)); 94 | }; 95 | 96 | export default keywordValidator; 97 | -------------------------------------------------------------------------------- /src/v2/modules/roles/events/handleAddRemoveRole.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ButtonInteraction, 3 | GuildMember, 4 | Interaction, 5 | Role, 6 | } from 'discord.js'; 7 | 8 | import { generateRoleSelect } from '../utils/generateRoleSelect.js'; 9 | import { getAddRemoveRoles } from '../utils/getAddRemoveRoles.js'; 10 | 11 | const listFormatter = new Intl.ListFormat(); 12 | 13 | async function toggle(interaction: ButtonInteraction, role: Role) { 14 | const member = interaction.member as GuildMember; 15 | const isAdd = !member.roles.resolve(role.id); 16 | if (isAdd) { 17 | await member.roles.add(role.id); 18 | } else { 19 | await member.roles.remove(role.id); 20 | } 21 | 22 | return interaction.reply({ 23 | ephemeral: true, 24 | content: `I've ${isAdd ? 'added you to' : 'removed you from'} the ${role.name 25 | } role.`, 26 | }); 27 | } 28 | 29 | export const handleAddRemoveRole = async ( 30 | interaction: Interaction 31 | ): Promise => { 32 | const member = interaction.member as GuildMember; 33 | if (!interaction.isButton() && !interaction.isSelectMenu()) { 34 | return; 35 | } 36 | 37 | const [type, subtype, role] = interaction.customId.split('🤔'); 38 | if (type !== 'roles') { 39 | return; 40 | } 41 | 42 | const roleNames = new Set( 43 | interaction.isButton() ? [role] : interaction.values 44 | ); 45 | 46 | if (roleNames.size === 0) { 47 | return; 48 | } 49 | 50 | const roles = member.guild.roles.cache 51 | .filter(x => roleNames.has(x.name)) 52 | .map(x => x); 53 | 54 | if (subtype === 'add') { 55 | await member.roles.add(roles); 56 | } else if (subtype === 'remove') { 57 | await member.roles.remove(roles); 58 | } else { 59 | toggle(interaction as ButtonInteraction, roles[0]); 60 | return 61 | } 62 | 63 | if (interaction.isStringSelectMenu()) { 64 | const [addRoles, removeRoles] = getAddRemoveRoles( 65 | interaction.member as GuildMember 66 | ); 67 | 68 | interaction.update({ 69 | content: `✅ You've been ${subtype === 'add' ? 'added to' : 'removed from' 70 | } ${listFormatter.format(roleNames)}`, 71 | components: [ 72 | addRoles.length > 0 && 73 | generateRoleSelect( 74 | 'Which roles would you like to join?', 75 | 'roles🤔add', 76 | addRoles 77 | ), 78 | removeRoles.length > 0 && 79 | generateRoleSelect( 80 | 'Which roles would you like to leave?', 81 | 'roles🤔remove', 82 | removeRoles 83 | ), 84 | ].filter(Boolean), 85 | }); 86 | } else { 87 | interaction.reply({ 88 | ephemeral: true, 89 | content: `You've been ${subtype === 'add' ? 'added to' : 'removed from' 90 | } ${listFormatter.format(roleNames)}`, 91 | }); 92 | } 93 | }; 94 | -------------------------------------------------------------------------------- /src/v2/commands/whyno/handlers/sass.ts: -------------------------------------------------------------------------------- 1 | export const sass: [string, string] = [ 2 | 'sass', 3 | ` 4 | Sass (and Less and Stylus and other custom languages that compile to CSS) have some design flaws that end up limiting what you can do with CSS. If you have a codebase that still depends on them it would be a good time to isolate and minimize the code you have that uses them and strategize how you'll migrate away from them. If your project doesn't have have them, or you haven't yet learned them - it might be a good idea, thinking ahead, to just skip that and get into a pure CSS workflow. Don't worry, you can still preprocess CSS, the secret is writing and handling valid CSS at every step in your workflow so all the tools work together 5 | Some flaws Sass has that get in the way: 6 | - It's not actually compatible with CSS syntax. It's not a super-set, it's an entirely different syntax and because it's incompatible with real CSS, there's an infinite variety of things you might have or want in CSS that can't even be present in any stylesheet Sass looks at 7 | - Some of the things Sass adds to its custom syntax are things CSS has a native syntax for, some of these understood by browsers or are things we have no doubt browsers aim to support, but to use Sass (or any other language) to provide these features means you'll be supporting those styles yourself forever 8 | - Sass (and all preprocessors) are only capable of making ahead-of-time optimizations, and the power of what they can do ends before the CSS stylesheet they output is in the browser - if you look at where modern CSS tooling is at today there's a lot happening in browsers as well. Only CSS works in browsers, so by using Sass at all you're taking steps away from these other tools that are more and more useful as we build more complex things 9 | - Ultimately Sass leads to you extend CSS (and thus think about CSS) using terms and things that aren't in CSS, so it can make it harder to reason about what you're actually needing to get done. For example, CSS has custom properties and custom functions, Sass has 'mixins'. What's a mixin? In the present where we can define custom properties, and in the future when we can define custom functions, which 'mixins' from Sass should be custom properties, and which should be functions? Etc. It's not leading you to reason better about extending CSS, it's actually teaching you a different language and as time moves on, its mechanisms for extending itself are a lot weaker than the things CSS already has, and the ways they will be made customizable (i.e. it doesn't look like the shape of what CSS can do) 10 | It leads you to imperative solutions to extending a declarative language (to put it into programming terms), it's like writing macros to output huge amounts of for loops in another language, instead of writing some kind of higher-level abstraction in that other language that wouldn't require those for loops to exist at all to get the job done 11 | `.trim(), 12 | ]; 13 | -------------------------------------------------------------------------------- /src/v2/modules/mod/commands/roles.ts: -------------------------------------------------------------------------------- 1 | import { ButtonStyle, CommandInteraction, MessageActionRowComponentBuilder } from 'discord.js'; 2 | import { ActionRowBuilder, ButtonBuilder, EmbedBuilder } from 'discord.js'; 3 | import { chunk } from 'domyno'; 4 | 5 | import { map } from '../../../utils/map.js'; 6 | import { pipe } from '../../../utils/pipe.js'; 7 | import { NOTIFY_ROLES } from '../../roles/consts/notifyRoles.js'; 8 | import { ROLES } from '../../roles/consts/roles.js'; 9 | 10 | const generateButtons = (roles: typeof ROLES | typeof NOTIFY_ROLES) => 11 | roles.map(item => 12 | new ButtonBuilder() 13 | .setCustomId(`roles🤔toggle🤔${item.name}`) 14 | .setLabel(item.name) 15 | .setStyle(ButtonStyle.Secondary) 16 | .setEmoji(item.emoji) 17 | ); 18 | const chunkAndRowify = pipe< 19 | Iterable, 20 | Iterable> 21 | >([ 22 | chunk(5), 23 | map((buttonBuilders: ButtonBuilder[]) => new ActionRowBuilder().addComponents(...buttonBuilders)) 24 | ]); 25 | 26 | export async function setupRoles( 27 | interaction: CommandInteraction 28 | ): Promise { 29 | await interaction.deferReply({ ephemeral: true }); 30 | 31 | interaction.channel.send({ 32 | content: 33 | 'You will need embeds enabled to interact with several features in this server.', 34 | embeds: [ 35 | new EmbedBuilder() 36 | .setTitle('Assign Yourself Roles Below') 37 | .setDescription( 38 | `Click on the reaction that corresponds with the role you're interested in.` 39 | ) 40 | .setColor('Green'), 41 | new EmbedBuilder() 42 | .setColor('Green') 43 | .setTitle('⭐ IMPORTANT: Access Role-Locked Channels') 44 | .setDescription( 45 | 'In order to see any general channel, you must have at least one role from the list below (excluding Community Announcement roles). If you would like access to a technology-specific channel, you must add that role to your profile. Add as many roles as you like.' 46 | ), 47 | new EmbedBuilder() 48 | .setColor('Yellow') 49 | .setDescription( 50 | `:warning: Note: You can add and remove roles faster using the \`/roles change\` command` 51 | ), 52 | ], 53 | components: [ 54 | ...chunkAndRowify([ 55 | ...generateButtons(ROLES), 56 | new ButtonBuilder() 57 | .setCustomId('roles🤔toggle🤔All Development') 58 | .setLabel('All Channels') 59 | .setStyle(ButtonStyle.Secondary) 60 | .setEmoji('🤓'), 61 | ]), 62 | ], 63 | }); 64 | 65 | await interaction.channel.send({ 66 | content: 67 | 'We also have some roles for if you wish to be notified about various optional announcements, which you can opt into here:', 68 | components: [...chunkAndRowify(generateButtons(NOTIFY_ROLES))], 69 | }); 70 | 71 | await interaction.editReply({ 72 | content: 'Done.', 73 | }); 74 | } 75 | -------------------------------------------------------------------------------- /src/v2/modules/onboarding/consts/rules.ts: -------------------------------------------------------------------------------- 1 | export const rules = [ 2 | { 3 | title: `Be respectful.`, 4 | description: `Remember the human! No insulting, no bullying, no dogpiling, no name-calling. Do speak in a way that builds up and encourages both the people you are speaking with and the server as a whole. `, 5 | }, 6 | { 7 | title: `Follow the Discord Terms of Service and Community Guidelines.`, 8 | description: `This includes but is not limited to asking about hacking, cheating, modifications to the discord client, and any discussion about piracy.`, 9 | }, 10 | { 11 | title: `Keep discussions limited to the most appropriate channel.`, 12 | description: `If multiple channels fit the criteria, pick one; if unsure, feel free to ask. Job postings must be posted in job-postings using the /post command.`, 13 | }, 14 | { 15 | title: `Do not share NSFW content.`, 16 | description: `Our users aren't all 18+`, 17 | }, 18 | { 19 | title: `Do not spam.`, 20 | description: `This includes but is not limited to: within the same channel, across multiple channels, or using large images`, 21 | }, 22 | { 23 | title: `Do not self-promote without contributing to the community.`, 24 | description: `Joining the community with the purpose to promote your own projects or business is disrespectful.`, 25 | }, 26 | { 27 | title: `Do not reveal any personal information about another user.`, 28 | description: `Additionally, we encourage you to consider what you share about yourself and what consequences sharing personal information may have. `, 29 | }, 30 | { 31 | title: `Do not DM users without prior permission.`, 32 | description: `Ensure that you have explicit permission to DM a user before DMing them.`, 33 | }, 34 | { 35 | title: `Do not use inappropriate usernames.`, 36 | description: `This includes, but is not limited to: hard-to-tag, impersonations, advertisements, offensive, hoisting, or excessively changed names.`, 37 | }, 38 | { 39 | title: `Do not attempt to mass ping.`, 40 | description: `In general, please DM @:police_officer: Modmail to report issues.`, 41 | }, 42 | { 43 | title: `Do not participate in academic dishonesty.`, 44 | description: `You can ask for help understanding homework but we will not do the work for you. `, 45 | }, 46 | { 47 | title: `Do not post shortened URLs.`, 48 | description: `We don't want to have to check every shortened url to see if it's malicious`, 49 | }, 50 | { 51 | title: `Do not upload code directly to Discord.`, 52 | description: `Use the /please code command to find third-party sites that can effectively share your code with others.`, 53 | }, 54 | { 55 | title: `Do not DM users to avoid enforcement of these rules.`, 56 | description: `Self explanatory.`, 57 | }, 58 | { 59 | title: `Do not advertise Web3 technologies.`, 60 | description: `You may discuss Web3 technologies, but you cannot share links to or solicit work for these technologies.`, 61 | }, 62 | ]; 63 | -------------------------------------------------------------------------------- /src/v2/modules/onboarding/steps/handleRoleSelection.ts: -------------------------------------------------------------------------------- 1 | import { ButtonStyle, Guild, GuildMember } from 'discord.js'; 2 | import { ButtonBuilder, MessageActionRowComponentBuilder, StringSelectMenuBuilder } from 'discord.js'; 3 | import { ActionRowBuilder } from 'discord.js'; 4 | 5 | import { NOTIFY_ROLES } from '../../roles/consts/notifyRoles.js'; 6 | import { ROLES } from '../../roles/consts/roles.js'; 7 | import type { UserStateType } from '../db/user_state'; 8 | import { getThread } from '../utils/getThread.js'; 9 | import { sneakPin } from '../utils/sneakPin.js'; 10 | 11 | export async function handleRoleSelection( 12 | guild: Guild, 13 | member: GuildMember, 14 | oldState: UserStateType, 15 | fromStart: boolean 16 | ): Promise { 17 | const thread = await getThread(guild, oldState.threadId); 18 | const pinned = await thread.messages.fetchPinned(); 19 | if (pinned.size > 0) { 20 | return; 21 | } 22 | const rolesMsg = await thread.send({ 23 | content: `**Final Step!** 24 | 25 | We have quite a few channels, so to gain access to them, you'll need to opt in to viewing them. Fortunately, that's the step you're on now. Use the select box below to pick which channels you'd like to see, or hit the button to opt in to viewing all the channels.`, 26 | components: [ 27 | new ActionRowBuilder().addComponents([ 28 | new StringSelectMenuBuilder() 29 | .addOptions( 30 | ROLES.map(x => ({ 31 | label: x.name, 32 | value: x.name, 33 | })) 34 | ) 35 | .setCustomId('onboarding🤔roles') 36 | .setMinValues(1) 37 | .setMaxValues(ROLES.length) 38 | .setPlaceholder("Pick which roles you're interested in"), 39 | ]), 40 | new ActionRowBuilder().addComponents( 41 | new ButtonBuilder() 42 | .setCustomId('onboarding🤔roles🤔All Development') 43 | .setLabel('View All Development Channels') 44 | .setStyle(ButtonStyle.Primary) 45 | ), 46 | ], 47 | }); 48 | 49 | const notificationRoles = await thread.send({ 50 | content: 51 | 'We also have some roles for if you wish to be notified about various optional announcements, which you can opt into here:', 52 | components: [ 53 | new ActionRowBuilder().addComponents([ 54 | new StringSelectMenuBuilder() 55 | .addOptions( 56 | NOTIFY_ROLES.map(x => ({ 57 | label: x.name, 58 | value: x.name, 59 | emoji: x.emoji, 60 | description: x.description, 61 | })) 62 | ) 63 | .setCustomId('onboarding🤔notify_roles') 64 | .setMinValues(1) 65 | .setMaxValues(NOTIFY_ROLES.length), 66 | ]), 67 | ], 68 | }); 69 | 70 | await sneakPin(rolesMsg); 71 | await sneakPin(notificationRoles); 72 | if (fromStart) { 73 | await rolesMsg.reply({ 74 | content: `Hey ${member.toString()}, seems like something went wrong during your onboarding, this could be because you left during it or the bot was down. You should be able to continue from here.`, 75 | }); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/v2/commands/shitpost/index.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionChoiceData, ApplicationCommandOptionType } from 'discord.js'; 2 | 3 | import type { CommandDataWithHandler } from '../../../types'; 4 | import { 5 | ADMIN_ROLE_ID, 6 | HELPFUL_ROLE_ID, 7 | MOD_ROLE_ID, 8 | SERVER_ID, 9 | } from '../../env.js'; 10 | import { map } from '../../utils/map.js'; 11 | import type { ValueOrNullary } from '../../utils/valueOrCall.js'; 12 | import { valueOrCall } from '../../utils/valueOrCall.js'; 13 | import { flexbox } from '../about/handlers/flexbox.js'; 14 | import { lockfile } from '../about/handlers/lockfile.js'; 15 | import { modules } from '../about/handlers/modules.js'; 16 | import { vscode } from '../about/handlers/vscode.js'; 17 | import { code } from '../please/handlers/code.js'; 18 | import { format } from '../please/handlers/format/index.js'; 19 | import { jquery } from '../whyno/handlers/jquery.js'; 20 | 21 | const aboutMessages = new Map>([ 22 | jquery, 23 | vscode, 24 | modules, 25 | format, 26 | code, 27 | flexbox, 28 | lockfile, 29 | ]); 30 | 31 | const shitpostReplacements = { 32 | jquery: /jquery/giu, 33 | vscode: /visual studio code|vscode/giu, 34 | modules: /module/giu, 35 | sass: /sass|scss/giu, 36 | format: /code|sql/giu, 37 | code: /code/giu, 38 | flexbox: /flexbox/giu, 39 | lockfile: /lockfile|package|node/giu, 40 | }; 41 | 42 | const mapTransformToChoices = map( 43 | (item: string): ApplicationCommandOptionChoiceData => ({ 44 | name: item, 45 | value: item, 46 | }) 47 | ); 48 | 49 | export const shitpostInteraction: CommandDataWithHandler = { 50 | description: 51 | 'A fun little shitpost command using some of the about/please/whyno commands', 52 | guildValidate: guild => guild.id === SERVER_ID, 53 | handler: async (client, interaction) => { 54 | const topic = interaction.options.get('topic').value as string; 55 | const replacement = interaction.options.get('replacement').value as string; 56 | const content = aboutMessages.get(topic); 57 | const { roles } = interaction.member; 58 | 59 | if (canUseCommand(roles)) { 60 | await interaction.reply({ 61 | ephemeral: true, 62 | content: 'This is only available to helpful members', 63 | }); 64 | return; 65 | } 66 | 67 | if (/<@[!&]?\d+>/u.test(replacement)) { 68 | await interaction.reply({ 69 | content: "Please don't try to tag users with this feature!", 70 | ephemeral: true, 71 | }); 72 | return; 73 | } 74 | 75 | if (content) { 76 | const shitpostContent = valueOrCall(content).replace( 77 | shitpostReplacements[topic], 78 | replacement 79 | ); 80 | interaction.reply(shitpostContent); 81 | } 82 | }, 83 | name: 'shitpost', 84 | options: [ 85 | { 86 | choices: [...mapTransformToChoices(aboutMessages.keys())], 87 | description: 'The topic to ask about', 88 | name: 'topic', 89 | required: true, 90 | type: ApplicationCommandOptionType.String, 91 | }, 92 | { 93 | name: 'replacement', 94 | description: 'Replacement word to use', 95 | required: true, 96 | type: ApplicationCommandOptionType.String, 97 | }, 98 | ], 99 | }; 100 | 101 | function canUseCommand(roles) { 102 | if (Array.isArray(roles)) { 103 | if ( 104 | ![HELPFUL_ROLE_ID, MOD_ROLE_ID, ADMIN_ROLE_ID].some(role => 105 | roles.includes(role) 106 | ) 107 | ) { 108 | return true; 109 | } 110 | } else { 111 | return ![HELPFUL_ROLE_ID, MOD_ROLE_ID, ADMIN_ROLE_ID].some(role => 112 | roles.cache.has(HELPFUL_ROLE_ID) 113 | ); 114 | } 115 | return false; 116 | } 117 | -------------------------------------------------------------------------------- /src/v2/spam_filter/index.ts: -------------------------------------------------------------------------------- 1 | import type { Message, Guild, GuildChannel } from 'discord.js'; 2 | 3 | import { 4 | NUMBER_OF_ALLOWED_MESSAGES, 5 | CACHE_REVALIDATION_IN_SECONDS, 6 | FINAL_CACHE_EXPIRATION_IN_SECONDS, 7 | } from '../env.js'; 8 | import { Cache } from '../utils/Cache.js'; 9 | 10 | const numberOfAllowedMessages = Number.parseInt(NUMBER_OF_ALLOWED_MESSAGES); 11 | const cacheRevalidationWindow = 12 | Number.parseInt(CACHE_REVALIDATION_IN_SECONDS) * 1000; 13 | 14 | export const cache = new Cache({ 15 | checkperiod: cacheRevalidationWindow, 16 | stdTTL: Number.parseInt(FINAL_CACHE_EXPIRATION_IN_SECONDS), 17 | }); 18 | 19 | /** 20 | * - Check the time elapsed between the first message and the last one. 21 | * - If the time difference is less than or equal to the timer, return true 22 | * - Else, return false. 23 | */ 24 | const isSurpassingSpamThreshold = (timestamps: number[]) => { 25 | if (timestamps.length <= numberOfAllowedMessages) { 26 | return false; 27 | } 28 | 29 | const creationOfFirstMessage = timestamps[0]; 30 | const creationOfLastMessages = timestamps[timestamps.length - 1]; 31 | 32 | const difference = creationOfLastMessages - creationOfFirstMessage; 33 | 34 | return difference <= cacheRevalidationWindow; 35 | }; 36 | 37 | export type SpammerMetadata = null | { 38 | userID: string; 39 | username: string; 40 | discriminator: string; 41 | channelId: string; 42 | channelName: string; 43 | msgID: string; 44 | guild: Guild; 45 | }; 46 | 47 | type CacheEntry = { 48 | wasRecentlyWarned: boolean; 49 | timestamps: number[]; 50 | }; 51 | 52 | /** 53 | * - Implement a simple cache, in which each message lives in for 10 seconds 54 | * - The key for the cache will be set to the user ID 55 | * - If a user sends `numberOfAllowedMessages` in the span of the `timeWindow`, call the ~~c~~mods 56 | */ 57 | const spamFilter = ({ 58 | channel, 59 | id: msgID, 60 | guild, 61 | author: { bot, id: userID, username, discriminator }, 62 | }: Message): SpammerMetadata => { 63 | // Bail if the user is a bot 64 | if (bot) { 65 | return; 66 | } 67 | 68 | const previousEntry: CacheEntry = cache.get(userID); 69 | const now = Date.now(); 70 | 71 | if (!previousEntry) { 72 | // create entry and bail 73 | cache.set(userID, { 74 | timestamps: [now], 75 | wasRecentlyWarned: false, 76 | }); 77 | return null; 78 | } 79 | 80 | const { wasRecentlyWarned, timestamps } = previousEntry; 81 | 82 | // prevent spam by the bot itself 83 | if (wasRecentlyWarned) { 84 | // keep this cache set active so we don't warn about the same user until its 85 | // been resolved 86 | cache.set(userID, { 87 | ...previousEntry, 88 | timestamps: [...timestamps.slice(1), now], 89 | }); 90 | return null; 91 | } 92 | 93 | if (!isSurpassingSpamThreshold(timestamps)) { 94 | const newTimestamps = [ 95 | ...(timestamps.length <= numberOfAllowedMessages 96 | ? timestamps 97 | : timestamps.slice(1)), 98 | now, 99 | ]; 100 | 101 | cache.set(userID, { 102 | ...previousEntry, 103 | timestamps: newTimestamps, 104 | }); 105 | return null; 106 | } 107 | 108 | // remember this user was warned to prevent the bot from spamming about this user 109 | cache.set(userID, { 110 | ...previousEntry, 111 | timestamps: [...timestamps, now], 112 | wasRecentlyWarned: true, 113 | }); 114 | 115 | // return metadata about spammer 116 | return { 117 | channelId: channel.id, 118 | channelName: (channel as GuildChannel).name, 119 | discriminator, 120 | guild, 121 | msgID, 122 | userID, 123 | username, 124 | }; 125 | }; 126 | 127 | export default spamFilter; 128 | -------------------------------------------------------------------------------- /src/v2/modules/onboarding/events/handleNewMember.ts: -------------------------------------------------------------------------------- 1 | import { GuildMember, Guild, TextChannel, ButtonStyle, ChannelType } from 'discord.js'; 2 | import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, MessageActionRowComponentBuilder } from 'discord.js'; 3 | 4 | import { NEW_USER_ROLE, ONBOARDING_CHANNEL, SERVER_ID } from '../../../env.js'; 5 | import { rules } from '../consts/rules.js'; 6 | import { UserState } from '../db/user_state.js'; 7 | import { continueOnboarding } from '../utils/continueOnboarding.js'; 8 | import { sneakPin } from '../utils/sneakPin.js'; 9 | 10 | export const handleNewMember = async (member: GuildMember): Promise => { 11 | const { guild, user, roles } = member; 12 | 13 | const oldState = await UserState.findOne({ 14 | guild: SERVER_ID, 15 | userId: user.id, 16 | }); 17 | 18 | if (oldState?.rolesOnLeave) { 19 | const guildRoles = await guild.roles.fetch(); 20 | const ids = new Set(oldState.rolesOnLeave.map(({ id }) => id)); 21 | const names = new Set(oldState.rolesOnLeave.map(({ name }) => name)); 22 | roles.set(guildRoles.filter(x => names.has(x.name) || ids.has(x.id))); 23 | } 24 | 25 | if (oldState?.state === 'ONBOARDED') { 26 | return; // Don't reonboard people who have been onboarded 27 | } 28 | 29 | await roles.add(NEW_USER_ROLE); 30 | 31 | if (!oldState) { 32 | await beginOnboarding(guild, member); 33 | return; 34 | } 35 | 36 | await continueOnboarding(guild, member, oldState, true); 37 | 38 | // False Positive 39 | // eslint-disable-next-line no-promise-executor-return 40 | await new Promise(resolve => setTimeout(resolve, 10_000)); 41 | }; 42 | 43 | async function beginOnboarding(guild: Guild, member: GuildMember) { 44 | const onboardingChannel = guild.channels.resolve( 45 | ONBOARDING_CHANNEL 46 | ) as TextChannel; 47 | 48 | const thread = await createOnboardingThread(onboardingChannel, member); 49 | 50 | const state = await UserState.create({ 51 | guild: guild.id, 52 | userId: member.user.id, 53 | state: 'START', 54 | threadId: thread.id, 55 | }); 56 | await thread.send( 57 | `Hi ${member.toString()}, welcome to ${guild.name 58 | }! Before you can get access to the rest of the server, we just need to go over a few things. 59 | 60 | First, to be able to interact with several of the features of this server, you **will** need embeds enabled. 61 | Second:` 62 | ); 63 | 64 | const rulesMsg = await thread.send({ 65 | embeds: [ 66 | new EmbedBuilder() 67 | .setTitle('📋 Our Community rules') 68 | .setColor('Gold') 69 | .addFields( 70 | rules.map((x, i) => ({ 71 | name: `${i + 1}. ${x.title}`, 72 | value: x.description, 73 | })) 74 | ), 75 | ], 76 | components: [ 77 | new ActionRowBuilder().addComponents([ 78 | new ButtonBuilder() 79 | .setStyle(ButtonStyle.Secondary) 80 | .setLabel('Just giving you a bit of time to read the rules...') 81 | .setEmoji('⏲') 82 | .setCustomId('onboarding🤔rules_agreed') 83 | .setDisabled(true), 84 | ]), 85 | ], 86 | }); 87 | 88 | await sneakPin(rulesMsg); 89 | await continueOnboarding(guild, member, state, false); 90 | } 91 | async function createOnboardingThread( 92 | onboardingChannel: TextChannel, 93 | member: GuildMember 94 | ) { 95 | const obj = { 96 | name: `Hi ${member.displayName}! 👋`, 97 | reason: `Onboarding ${member.toString()}`, 98 | } 99 | try { 100 | return await onboardingChannel.threads.create({ ...obj, type: ChannelType.PrivateThread }); 101 | } catch { 102 | return await onboardingChannel.threads.create({ ...obj, type: ChannelType.PublicThread }); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Webdev Support Bot 2 | 3 | [![dependencies][dependencies-image] ][dependencies-url] 4 | [![devdependencies][devdependencies-image] ][devdependencies-url] 5 | 6 | [dependencies-image]: https://david-dm.org/ljosberinn/webdev-support-bot.png 7 | [dependencies-url]: https://david-dm.org/ljosberinn/webdev-support-bot 8 | [devdependencies-image]: https://david-dm.org/ljosberinn/webdev-support-bot/dev-status.png 9 | [devdependencies-url]: https://david-dm.org/ljosberinn/webdev-support-bot#info=devDependencies 10 | 11 |

12 | 13 |

14 | 15 | Bot providing multiple commands to query common sites used during development or helping people on Discord. 16 | 17 | Supports 18 | 19 | `!github` via `GitHub API`, 20 | 21 | `!composer` via `packagist API`, 22 | 23 | `!npm` via unofficial `npmjs.com API`, 24 | 25 | `!mdn` via parsing [Mozilla Developer Network](http://developer.mozilla.org/), 26 | 27 | `!caniuse` via unofficial `caniuse API` and [@mdn/browser-combat-data](https://github.com/mdn/browser-compat-data), 28 | 29 | `!bundlephobia` via unofficial `bundlephobia API`, 30 | 31 | `!jquery` as explanation on why not to use jquery, 32 | 33 | `!php` via parsing [official PHP Docs](http://php.net/). 34 | 35 | ## Usage / TLDR 36 | 37 | ```bash 38 | # tag it in discord to receive general help 39 | @bot --help 40 | # provides an example each 41 | !mdn --help 42 | !caniuse --help 43 | !composer --help 44 | !npm --help 45 | !github --help 46 | !bundlephobia --help 47 | ``` 48 | 49 | ```bash 50 | # queries MDN with 51 | !mdn 52 | ``` 53 | 54 | ```bash 55 | # queries caniuse with 56 | !caniuse 57 | ``` 58 | 59 | ```bash 60 | # queries packagist with 61 | !composer 62 | ``` 63 | 64 | ```bash 65 | # queries npm with 66 | !npm 67 | ``` 68 | 69 | ```bash 70 | # queries github with 71 | !github 72 | ``` 73 | 74 | ```bash 75 | # queries bundlephobia with 76 | !bundlephobia 77 | ``` 78 | 79 | - single-result queries will directly show the result 80 | - reacting with a number will filter the result 81 | - reacting with the _red_ or _black_ `x` will remove the request 82 | 83 | ## Description 84 | 85 | By default, shows the first ten results of any given query, unless only one result was found. 86 | 87 | Reacting with a number corresponding to the list entry will filter the list and edit the original message, providing more specific information. 88 | 89 | ## Add to your server by... 90 | 91 | ...accessing [this link](https://discordapp.com/api/oauth2/authorize?client_id=649967864425611274&scope=bot&permissions=1). 92 | 93 | ## Demo 94 | 95 |

96 | 97 |

98 | 99 | ## Development 100 | 101 | ```bash 102 | git clone https://github.com/ljosberinn/webdev-support-bot/ 103 | 104 | cd webdev-support-bot 105 | 106 | cp .env.example .env # and enter a token 107 | 108 | 109 | yarn install # or npm install 110 | code . 111 | 112 | yarn docker:dev:up 113 | yarn dev # or npm dev 114 | 115 | # or be fancy with a one-liner 116 | git clone https://github.com/ljosberinn/webdev-support-bot/ && cd webdev-support-bot && cp .env.example .env && yarn install && code . && yarn docker:dev:up && yarn dev 117 | ``` 118 | 119 | ## Environment variables 120 | 121 | In development, you generally want to take the `.env.example` and rename it to `.env`. You also _shouldn't_ commit your `.env` file. If you make any changes to the environment variables, you should update `.env.example`. accordingly. 122 | 123 | ### Running tests: 124 | 125 | ```bash 126 | $ npm test 127 | ``` 128 | 129 | ## Found a bug/want to contribute? 130 | 131 | Please head over to [GitHub](https://github.com/ljosberinn/webdev-support-bot/issues). 132 | -------------------------------------------------------------------------------- /src/v2/helpful_role/point_handler.ts: -------------------------------------------------------------------------------- 1 | import type { Message, GuildMember } from 'discord.js'; 2 | 3 | import { 4 | IS_PROD, 5 | HELPFUL_ROLE_ID, 6 | HELPFUL_ROLE_POINT_THRESHOLD, 7 | POINT_LIMITER_IN_MINUTES, 8 | HELPFUL_ROLE_EXEMPT_ID, 9 | } from '../env.js'; 10 | import { startTime } from '../index.js'; 11 | import { cache } from '../spam_filter/index.js'; 12 | import { createEmbed } from '../utils/discordTools.js'; 13 | import HelpfulRoleMember from './db_model.js'; 14 | 15 | import type { IUser } from '.'; 16 | 17 | const grantHelpfulRole = async (user: GuildMember, msg: Message) => { 18 | // Check if the user has the role 19 | if (user.roles.cache.some(r => r.id === HELPFUL_ROLE_ID)) { 20 | return; 21 | } 22 | 23 | // Add the role to the user 24 | await user.roles.add(HELPFUL_ROLE_ID); 25 | 26 | // Send notification message 27 | await msg.channel.send({ 28 | embeds: [ 29 | createEmbed({ 30 | description: `<@!${user.id}> has been granted the <@&${HELPFUL_ROLE_ID}> role!`, 31 | footerText: 'Helpful Role Handler', 32 | provider: 'helper', 33 | title: 'A user has received the Helpful role!', 34 | }).embed, 35 | ], 36 | }); 37 | }; 38 | 39 | export const generatePointsCacheEntryKey = ( 40 | receivingUserID: string, 41 | pointGiverUserID: string 42 | ): string => `point-${receivingUserID}-${pointGiverUserID}`; 43 | 44 | const pointHandler = async ( 45 | userID: string, 46 | msg: Message, 47 | reactionHandlerUserID: string = null 48 | ): Promise => { 49 | const pointGiverUserID = reactionHandlerUserID || msg.author.id; 50 | 51 | const cacheKey = generatePointsCacheEntryKey(userID, pointGiverUserID); 52 | 53 | const guildMember = msg.guild.members.cache.find(u => u.id === userID); 54 | 55 | // Break if there's no user or the user is a bot. 56 | if (!guildMember || guildMember.user.bot) { 57 | return; 58 | } 59 | 60 | const entry: number = cache.get(cacheKey); 61 | 62 | // Check if the message's been created before the bot's startup 63 | if (startTime > msg.createdAt) { 64 | return; 65 | } 66 | 67 | // Check if the user's on cooldown to give a point to the message author/mentioned user 68 | if (entry) { 69 | const diff = Math.round( 70 | Number.parseInt(POINT_LIMITER_IN_MINUTES) - (Date.now() - entry) / 60_000 71 | ); 72 | 73 | const dm = await msg.guild.members.cache.get(pointGiverUserID).createDM(); 74 | 75 | dm.send( 76 | `You cannot give a point to <@!${userID}>. Please try again in ${diff} minute${ 77 | diff === 1 ? '' : 's' 78 | }.` 79 | ); 80 | 81 | return; 82 | } 83 | 84 | const details = { 85 | guild: msg.guild.id, 86 | user: userID, 87 | }; 88 | 89 | let user: IUser = await HelpfulRoleMember.findOne(details); 90 | if (!user) { 91 | user = await HelpfulRoleMember.create(details); 92 | } 93 | 94 | // Add a point to the user 95 | user.points++; 96 | 97 | // Cache the action 98 | cache.set( 99 | cacheKey, 100 | Date.now(), 101 | Number.parseInt(POINT_LIMITER_IN_MINUTES) * 60 102 | ); 103 | 104 | // Check if the user has enough points to be given the helpful role 105 | if ( 106 | !guildMember.roles.cache.has(HELPFUL_ROLE_EXEMPT_ID) && 107 | user.points >= Number.parseInt(HELPFUL_ROLE_POINT_THRESHOLD) 108 | ) { 109 | await grantHelpfulRole(guildMember, msg); 110 | } 111 | 112 | try { 113 | const updated = await user.save(); 114 | 115 | if (!IS_PROD) { 116 | // eslint-disable-next-line no-console 117 | console.log(`${updated.id} => ${updated.points}`); 118 | } 119 | } catch (error) { 120 | // eslint-disable-next-line no-console 121 | console.error('user.save():', error); 122 | } 123 | }; 124 | 125 | export default pointHandler; 126 | -------------------------------------------------------------------------------- /src/v2/modules/onboarding/index.ts: -------------------------------------------------------------------------------- 1 | import { Client, Guild, GuildMember, Message, MessageType, PermissionFlagsBits, PermissionOverwriteManager, PermissionsBitField, Role, TextChannel } from 'discord.js'; 2 | 3 | import { SERVER_ID } from '../../env.js'; 4 | import { NEW_USER_ROLE, ONBOARDING_CHANNEL, JOIN_LOG_CHANNEL } from '../../env.js'; 5 | import { UserState } from './db/user_state.js'; 6 | import { handleIntroductionMsg } from './events/handleIntroductionMsg.js'; 7 | import { handleMemberLeave } from './events/handleMemberLeave.js'; 8 | import { handleNewMember } from './events/handleNewMember.js'; 9 | import { handleNotifyRolesSelected } from './events/handleNotifyRolesSelected.js'; 10 | import { handleRoleSelected } from './events/handleRoleSelected.js'; 11 | import { handleRulesAgree } from './events/handleRulesAgree.js'; 12 | import { handleSkipIntro } from './events/handleSkipIntro.js'; 13 | import { handleThreadArchived } from './events/handleThreadArchived.js'; 14 | import { getMessagesUntil } from './utils/getMessagesUntil.js'; 15 | import { limitToWebDevServer } from './utils/limitToWebDevServer.js'; 16 | import { getOnboardingStart } from './utils/onboardingStart.js'; 17 | 18 | export async function attach(client: Client): Promise { 19 | const guild = client.guilds.resolve(SERVER_ID); 20 | const role = guild.roles.cache.get(NEW_USER_ROLE); 21 | addNewRolePermissions(guild, role); 22 | 23 | client.on('guildMemberAdd', limitToWebDevServer(handleNewMember)); 24 | client.on('interactionCreate', limitToWebDevServer(handleRoleSelected)); 25 | client.on( 26 | 'interactionCreate', 27 | limitToWebDevServer(handleNotifyRolesSelected) 28 | ); 29 | client.on('interactionCreate', limitToWebDevServer(handleRulesAgree)); 30 | client.on('interactionCreate', limitToWebDevServer(handleSkipIntro)); 31 | client.on('messageCreate', limitToWebDevServer(handleIntroductionMsg)); 32 | client.on('guildMemberRemove', limitToWebDevServer(handleMemberLeave)); 33 | client.on('threadUpdate', limitToWebDevServer(handleThreadArchived)); 34 | 35 | client.on('interactionCreate', async interaction => { 36 | if (!interaction.isButton()) { 37 | return; 38 | } 39 | 40 | const [type, subtype] = interaction.customId.split('🤔'); 41 | 42 | if (type === 'debug' && subtype === 'new_user') { 43 | await UserState.deleteOne({ 44 | userId: interaction.user.id, 45 | }); 46 | handleNewMember(interaction.member as GuildMember); 47 | } 48 | }); 49 | 50 | await playCatchup(guild); 51 | } 52 | async function playCatchup(guild) { 53 | const catchUpFrom = await getOnboardingStart(); 54 | 55 | if (!catchUpFrom) { 56 | return; 57 | } 58 | 59 | const joinLogChannel = (await guild.channels.fetch( 60 | JOIN_LOG_CHANNEL 61 | )) as TextChannel; 62 | const userState = await UserState.findOne({}).sort('updatedAt'); 63 | 64 | const userMap = new Map(); 65 | for await (const message of getMessagesUntil( 66 | joinLogChannel, 67 | userState?.updatedAt ?? new Date(catchUpFrom) 68 | )) { 69 | if ( 70 | message.type === MessageType.UserJoin && 71 | message.member && 72 | !userMap.has(message.member.id) 73 | ) { 74 | userMap.set(message.member.id, message); 75 | } 76 | } 77 | 78 | for (const [, message] of userMap) { 79 | handleNewMember(message.member); 80 | } 81 | } 82 | 83 | function addNewRolePermissions(guild: Guild, role: Role) { 84 | const permissionFlags = PermissionsBitField.Default & ~PermissionsBitField.Flags.ViewChannel 85 | const permissions = new PermissionsBitField(permissionFlags) 86 | 87 | for (const [, channel] of guild.channels.cache) { 88 | if ( 89 | 'permissionOverwrites' in channel && 90 | channel.id !== ONBOARDING_CHANNEL 91 | ) { 92 | try { 93 | channel.permissionOverwrites.create(role, permissions.serialize()); 94 | } catch (error) { 95 | console.error(error); 96 | } 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/v2/utils/useData.ts: -------------------------------------------------------------------------------- 1 | import { addBreadcrumb } from '@sentry/node'; 2 | import type { 3 | HeadersInit, 4 | RequestInfo, 5 | RequestInit, 6 | Response, 7 | } from 'node-fetch'; 8 | import fetch from 'node-fetch'; 9 | 10 | import { 11 | API_CACHE_ENTRIES_LIMIT, 12 | API_CACHE_EXPIRATION_IN_SECONDS, 13 | API_CACHE_REVALIDATION_WINDOW_IN_SECONDS, 14 | } from '../env.js'; 15 | import { Cache } from './Cache.js'; 16 | 17 | const apiCache = new Cache({ 18 | checkperiod: Number.parseInt(API_CACHE_REVALIDATION_WINDOW_IN_SECONDS, 10), 19 | maxKeys: Number.parseInt(API_CACHE_ENTRIES_LIMIT, 10), 20 | stdTTL: Number.parseInt(API_CACHE_EXPIRATION_IN_SECONDS, 10), 21 | }); 22 | 23 | type ResponseTypes = 'json' | 'text'; 24 | 25 | type UnknownData = 26 | | { 27 | error: true; 28 | json: null; 29 | text: null; 30 | } 31 | | { 32 | error: false; 33 | text: null; 34 | json: T; 35 | } 36 | | { 37 | error: false; 38 | json: null; 39 | text: string; 40 | }; 41 | 42 | type ResponseMapper = (response: Response) => Promise; 43 | type FetchWithFormat = ( 44 | url: RequestInfo, 45 | init?: RequestInit 46 | ) => Promise; 47 | 48 | /** 49 | * Given a cache key and a function that maps the fetch response to a arbitrary 50 | * data structure, return a function when invoked, lower cases the cache key and 51 | * returns the cached formatted response when found. 52 | * 53 | * If the cache entry is _not_ found, delegate the call to an actual fetch implementation 54 | * to make the HTTP request. This response is then formatted with the function 55 | * above and saved into the cache. HTTP requests times/durations are also logged. 56 | */ 57 | const doFetch: ( 58 | cacheKey: string, 59 | mapper: ResponseMapper 60 | ) => FetchWithFormat = ( 61 | cacheKey, 62 | mapper 63 | ): FetchWithFormat => { 64 | const casedCacheKey = cacheKey.toLowerCase(); 65 | const cachedResponse = apiCache.get(casedCacheKey); 66 | 67 | if (cachedResponse) { 68 | return (async () => cachedResponse) as FetchWithFormat; 69 | } 70 | 71 | return async (url, fetchOptions) => { 72 | addBreadcrumb({ 73 | category: 'query', 74 | data: typeof url === 'string' ? { url } : undefined, 75 | level: 'info', 76 | timestamp: Date.now(), 77 | }); 78 | 79 | // this should be fine? 80 | // eslint-disable-next-line @typescript-eslint/no-base-to-string 81 | const timeLabel = `Time took for url=${encodeURIComponent(url.toString())}`; 82 | // eslint-disable-next-line no-console 83 | console.time(timeLabel); 84 | const response = await fetch(url, fetchOptions); 85 | // eslint-disable-next-line no-console 86 | console.timeEnd(timeLabel); 87 | const formattedResponse = await mapper(response); 88 | apiCache.set(casedCacheKey, formattedResponse); 89 | return formattedResponse as TParsedResponse; 90 | }; 91 | }; 92 | 93 | const responseMapper: ( 94 | type: ResponseTypes 95 | ) => (response: Response) => Promise> = 96 | (type: string) => 97 | async response => { 98 | if (!response.ok) { 99 | return { 100 | error: true, 101 | json: null, 102 | text: null, 103 | } as unknown as Promise>; 104 | } 105 | 106 | if (type === 'json') { 107 | const json = await response.json(); 108 | return { 109 | error: false, 110 | json, 111 | text: null, 112 | } as unknown as Promise>; 113 | } 114 | 115 | if (type === 'text') { 116 | const text = await response.text(); 117 | 118 | return { 119 | error: false, 120 | json: null, 121 | text, 122 | } as unknown as Promise>; 123 | } 124 | }; 125 | 126 | const useData = ( 127 | url: string, 128 | type: ResponseTypes = 'json', 129 | headers: HeadersInit = {} 130 | ): Promise> => { 131 | return doFetch(url, responseMapper(type))(url, { headers }); 132 | }; 133 | 134 | export default useData; 135 | -------------------------------------------------------------------------------- /src/v2/commands/warn/index.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionType, Client, CommandInteraction } from 'discord.js'; 2 | import Fuse, { FuseResult } from 'fuse.js'; 3 | 4 | import type { CommandDataWithHandler } from '../../../types'; 5 | import { asyncCatch } from '../../utils/asyncCatch.js'; 6 | import { pluckʹ } from '../../utils/pluck.js'; 7 | import { _ } from '../../utils/pluralize.js'; 8 | 9 | const listFormatter = new Intl.ListFormat(); 10 | const rulesId = '904060678699089950'; 11 | 12 | const rules = [ 13 | 'Disrespectful', 14 | 'Breaking Discord TOS', 15 | 'Inppropriate Channel', 16 | 'NSFW', 17 | 'Spam', 18 | 'Self promotion', 19 | 'Doxxing', 20 | 'Unsolicited DMs', 21 | 'Inappropriate Username', 22 | 'Attempted Mass pinging', 23 | 'Academic dishonesty', 24 | 'Shortened Urls', 25 | 'Uploading code', 26 | 'DM Rule Avoidance', 27 | ].map((item, index) => ({ name: item, value: String(index + 1) })); 28 | const keywords = []; 29 | 30 | const fuse = new Fuse(rules, { 31 | includeScore: true, 32 | keys: ['name', 'value'], 33 | }); 34 | 35 | export const warn: CommandDataWithHandler = { 36 | description: 'Quick response for common "why no" or "why not..." questions', 37 | handler: async ( 38 | client: Client, 39 | interaction: CommandInteraction 40 | ): Promise => { 41 | const user = interaction.options.get('user').user; 42 | const rules = (interaction.options.get('rules').value as string).split('|'); 43 | const reason = interaction.options.get('reason').value as string; 44 | 45 | const warnMessage = _`Rule${_.s} ${rules}. Please read the <#${rulesId}>. ${reason ?? '' 46 | }`; 47 | 48 | await interaction.reply(`Debug Received: 49 | user: ${user} 50 | rules: ${rules} 51 | reason: ${reason} 52 | warn msg: ${warnMessage(rules.length)} 53 | `); 54 | }, 55 | onAttach(client) { 56 | client.on( 57 | 'interactionCreate', 58 | asyncCatch(async interaction => { 59 | if (!interaction.isAutocomplete()) { 60 | return; 61 | } 62 | const result = interaction.options.getFocused(); 63 | console.log(result); 64 | const resultsParts = result 65 | .split(/&| and |,|\|/giu) 66 | .filter(x => x.trim()); 67 | if (resultsParts.length === 0) { 68 | return interaction.respond(rules); 69 | } 70 | const results = resultsParts 71 | .map(x => fuse.search(x)) 72 | .filter(x => x.length); 73 | const perms = removeRepeated<{ value: string; name: string }>( 74 | permutations(results) 75 | ); 76 | const values = perms.map(item => comb(item.map(x => x.item))); 77 | await interaction.respond(values); 78 | }) 79 | ); 80 | }, 81 | name: 'warn', 82 | options: [ 83 | { 84 | name: 'user', 85 | description: 'Person to warn', 86 | type: ApplicationCommandOptionType.User, 87 | required: true, 88 | }, 89 | { 90 | name: 'rules', 91 | type: ApplicationCommandOptionType.String, 92 | description: 'What rule(s) is the user breaking', 93 | autocomplete: true, 94 | required: true, 95 | }, 96 | { 97 | name: 'reason', 98 | type: ApplicationCommandOptionType.String, 99 | description: 'The reason the user is getting warned', 100 | }, 101 | ], 102 | }; 103 | 104 | function permutations(items: T[][]) { 105 | if (items.length === 0) { 106 | return []; 107 | } 108 | const [item, ...rest] = items; 109 | if (rest.length === 0) { 110 | return item.map(x => [x]); 111 | } 112 | 113 | const latterPers = permutations(rest); 114 | 115 | return item.flatMap(item => latterPers.map(x => [item, ...x])); 116 | } 117 | 118 | function comb(items: { name: string; value: string }[]) { 119 | console.log(items); 120 | const names = [...pluckʹ(items, 'name')]; 121 | const values = [...pluckʹ(items, 'value')]; 122 | 123 | return { 124 | name: names.join(', '), 125 | value: values.join('|'), 126 | }; 127 | } 128 | 129 | function removeRepeated(arr: FuseResult[][]) { 130 | console.log(arr); 131 | return arr.filter(x => { 132 | const s = new Set(x.map(i => i.refIndex)); 133 | if (s.size !== x.length) { 134 | return false; 135 | } 136 | return true; 137 | }); 138 | } 139 | -------------------------------------------------------------------------------- /src/v2/helpful_role/point_decay.ts: -------------------------------------------------------------------------------- 1 | import type { EmbedField, TextChannel, Guild } from 'discord.js'; 2 | 3 | import { get, upsert } from '../cache/cacheFns.js'; 4 | import { 5 | POINT_DECAY_TIMER, 6 | MOD_CHANNEL, 7 | HELPFUL_ROLE_POINT_THRESHOLD, 8 | HELPFUL_ROLE_EXEMPT_ID, 9 | SERVER_ID, 10 | HELPFUL_ROLE_ID, 11 | } from '../env.js'; 12 | import { createEmbed } from '../utils/discordTools.js'; 13 | import { capitalize } from '../utils/string.js'; 14 | import HelpfulRoleMember from './db_model.js'; 15 | 16 | import type { IUser } from '.'; 17 | 18 | type DecayCache = { 19 | type: 'POINT_DECAY'; 20 | user: ''; 21 | guild: string; 22 | meta: { 23 | lastDecay: number; 24 | }; 25 | }; 26 | const HOUR_IN_MS = 3_600_000; 27 | 28 | let lastDecay; 29 | let timeout; 30 | 31 | export const decay = async ({ 32 | guild, 33 | userId, 34 | }: { 35 | guild: Guild; 36 | userId?: string; 37 | }): Promise => { 38 | try { 39 | console.log(guild) 40 | const users: IUser[] = await HelpfulRoleMember.find({ 41 | guild: guild.id, 42 | points: { 43 | $gt: 0, 44 | }, 45 | }); 46 | 47 | for (const user of users) { 48 | const currentPoints = user.points ?? 0; 49 | const decay = Math.ceil(currentPoints / 100); 50 | const nextPoints = currentPoints - decay; 51 | 52 | user.points = nextPoints > 0 ? nextPoints : 0; 53 | 54 | // This isn't time sensitive and it's better for us to save some than none here 55 | // eslint-disable-next-line no-await-in-loop 56 | await user.save(); 57 | 58 | const member = guild.members.cache.get(user.user); 59 | 60 | if ( 61 | user.points < Number.parseInt(HELPFUL_ROLE_POINT_THRESHOLD) && 62 | !member?.roles.cache.has(HELPFUL_ROLE_EXEMPT_ID) 63 | ) { 64 | member?.roles.remove(HELPFUL_ROLE_ID); 65 | } 66 | } 67 | 68 | const modChannel = guild.channels.cache.find( 69 | c => c.id === MOD_CHANNEL 70 | ) as TextChannel; 71 | 72 | const fields: EmbedField[] = [ 73 | { 74 | inline: false, 75 | name: 'Forced?', 76 | value: capitalize(`${!!userId}`), 77 | }, 78 | userId && { 79 | inline: false, 80 | name: 'Admin/Moderator', 81 | value: `<@!${userId}>`, 82 | }, 83 | ].filter(Boolean); 84 | 85 | await modChannel.send({ 86 | embeds: [ 87 | createEmbed({ 88 | description: `The point decay affected ${users.length} user${users.length === 1 ? '' : 's' 89 | }.`, 90 | fields, 91 | footerText: 'Point Decay System', 92 | provider: 'spam', 93 | title: 'Point Decay Alert', 94 | }).embed, 95 | ], 96 | }); 97 | } catch (error) { 98 | // eslint-disable-next-line no-console 99 | console.error('catch -> decay(msg):', error); 100 | } 101 | }; 102 | 103 | export const getTimeDiffToDecay = async (): Promise<{ 104 | diff: number; 105 | timestamp: number; 106 | }> => { 107 | if (!lastDecay) { 108 | return new Promise(resolve => { 109 | setTimeout(async () => { 110 | resolve(await getTimeDiffToDecay()); 111 | }, 500); 112 | }); 113 | } 114 | 115 | const timestamp = Date.now(); 116 | 117 | return { 118 | diff: (timestamp - lastDecay) / HOUR_IN_MS, 119 | timestamp, 120 | }; 121 | }; 122 | 123 | const pointDecaySystem = async (decayData: { 124 | guild: Guild; 125 | userId?: string; 126 | }): Promise => { 127 | const { diff, timestamp } = await getTimeDiffToDecay(); 128 | const timer = Number.parseFloat(POINT_DECAY_TIMER); 129 | 130 | if (diff >= timer) { 131 | await saveLastDecay(timestamp); 132 | 133 | await decay(decayData); 134 | 135 | setDecayTimeout( 136 | decayData.guild, 137 | (Number.parseFloat(POINT_DECAY_TIMER) ?? 0.5) * HOUR_IN_MS 138 | ); 139 | } else { 140 | setDecayTimeout(decayData.guild, (timer - diff) * HOUR_IN_MS); 141 | } 142 | }; 143 | 144 | function setDecayTimeout(guild: Guild, ms: number) { 145 | clearTimeout(timeout); 146 | 147 | timeout = setTimeout(() => { 148 | pointDecaySystem({ guild }); 149 | }, ms); 150 | } 151 | 152 | export default pointDecaySystem; 153 | 154 | export const loadLastDecayFromDB = async (): Promise => { 155 | const cache = (await get({ 156 | type: 'POINT_DECAY', 157 | user: '', 158 | guild: SERVER_ID, 159 | })) as DecayCache; 160 | 161 | lastDecay = cache?.meta?.lastDecay ?? -1; 162 | }; 163 | 164 | export const saveLastDecay = async ( 165 | timestamp: number = Date.now() 166 | ): Promise => { 167 | await upsert({ 168 | expiresAt: Number.MAX_SAFE_INTEGER, 169 | guild: SERVER_ID, 170 | type: 'POINT_DECAY', 171 | user: '', 172 | meta: { 173 | lastDecay: timestamp, 174 | }, 175 | }); 176 | 177 | lastDecay = timestamp; 178 | }; 179 | -------------------------------------------------------------------------------- /src/v2/commands/php/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/prefer-query-selector */ 2 | import { 3 | ApplicationCommandOptionType, 4 | ButtonInteraction, 5 | ButtonStyle, 6 | Client, 7 | CommandInteraction, 8 | ComponentType, 9 | Message, 10 | MessageActionRowComponentBuilder, 11 | StringSelectMenuInteraction, 12 | } from 'discord.js'; 13 | import { ButtonBuilder, ActionRowBuilder, StringSelectMenuBuilder, } from 'discord.js'; 14 | import type { Node } from 'dom-parser'; 15 | import DOMParser, { parseFromString } from 'dom-parser'; 16 | import { decode } from 'html-entities'; 17 | 18 | import type { CommandDataWithHandler } from '../../../types'; 19 | import { invalidResponse, unknownError } from '../../../v2/utils/errors.js'; 20 | import { clampLength, clampLengthMiddle } from '../../utils/clampStr.js'; 21 | import { buildDirectUrl, getSearchUrl } from '../../utils/urlTools.js'; 22 | import useData from '../../utils/useData.js'; 23 | 24 | const provider = 'php'; 25 | 26 | type ParseResult = { 27 | isDirect: boolean; 28 | results: DOMParser.Node[]; 29 | }; 30 | 31 | /** 32 | * 33 | * @param {any} result [document.parseFromString return type] 34 | */ 35 | const extractMetadataFromResult = (result: Node) => { 36 | const titleElement = result.textContent; 37 | 38 | const title = decode(titleElement); 39 | 40 | const url = buildDirectUrl(provider, result.getAttribute('href')); 41 | 42 | return { 43 | title, 44 | url, 45 | }; 46 | }; 47 | 48 | const textParser = (text: string): ParseResult => { 49 | const document = parseFromString(text); 50 | 51 | // Check if we were directed directly to the result. 52 | const isDirect = document.getElementById('quickref_functions') === null; 53 | if (isDirect) { 54 | return { 55 | isDirect, 56 | results: [], 57 | }; 58 | } 59 | 60 | const results = document 61 | .getElementById('quickref_functions') 62 | .getElementsByTagName('li') 63 | .slice(0, 10); 64 | 65 | return { 66 | isDirect, 67 | results, 68 | }; 69 | }; 70 | 71 | const requester = async ( 72 | searchTerm: string 73 | ): Promise<{ error: boolean; text: string; searchUrl: string }> => { 74 | const searchUrl = getSearchUrl(provider, searchTerm); 75 | const response = await useData(searchUrl, 'text'); 76 | return { 77 | ...response, 78 | searchUrl, 79 | }; 80 | }; 81 | 82 | const makeRequest = requester; 83 | const parseText = textParser; 84 | const metadataExtractor = extractMetadataFromResult; 85 | 86 | const handler = async ( 87 | client: Client, 88 | interaction: CommandInteraction 89 | ): Promise => { 90 | const searchTerm = interaction.options.get('query').value as string; 91 | const defer = interaction.deferReply(); 92 | try { 93 | const { error, text, searchUrl } = await makeRequest(searchTerm); 94 | if (error) { 95 | await defer; 96 | await interaction.editReply(invalidResponse); 97 | return; 98 | } 99 | 100 | const { isDirect, results } = parseText(text); 101 | if (isDirect) { 102 | await defer; 103 | await interaction.editReply(buildDirectUrl(provider, searchTerm)); 104 | return; 105 | } 106 | 107 | const msgId = Math.random().toString(16); 108 | 109 | const selectRow = new ActionRowBuilder().addComponents( 110 | new StringSelectMenuBuilder() 111 | .setCustomId(`php🤔${msgId}🤔select`) 112 | .setMaxValues(5) 113 | .setMinValues(1) 114 | .addOptions( 115 | results.map(({ firstChild: link }, index) => { 116 | const { title, url } = metadataExtractor(link); 117 | 118 | return { 119 | label: clampLengthMiddle(title, 25), 120 | description: clampLength(url, 50), 121 | value: String(index), 122 | }; 123 | }) 124 | ) 125 | ); 126 | 127 | const buttonRow = new ActionRowBuilder().addComponents( 128 | new ButtonBuilder() 129 | .setLabel('Cancel') 130 | .setStyle(ButtonStyle.Secondary) 131 | .setCustomId(`mdn🤔${msgId}🤔cancel`) 132 | ); 133 | 134 | await defer; 135 | const int = (await interaction.editReply({ 136 | content: 'Please pick 1 - 5 options below to display', 137 | components: [selectRow, buttonRow], 138 | })) 139 | 140 | const interactionCollector = int.createMessageComponentCollector< 141 | ComponentType.Button | ComponentType.StringSelect 142 | >({ 143 | filter: item => 144 | item.user.id === interaction.user.id && 145 | item.customId.startsWith(`php🤔${msgId}`), 146 | }); 147 | 148 | interactionCollector.once( 149 | 'collect', 150 | async (interaction: ButtonInteraction | StringSelectMenuInteraction) => { 151 | await interaction.deferUpdate(); 152 | if (interaction.isButton()) { 153 | await int.delete(); 154 | return; 155 | } 156 | const urls = interaction.values.map( 157 | x => metadataExtractor(results[x].firstChild).url 158 | ); 159 | 160 | await interaction.editReply({ 161 | components: [], 162 | content: urls.join('\n'), 163 | }); 164 | } 165 | ); 166 | } catch (error) { 167 | // eslint-disable-next-line no-console 168 | console.error(error); 169 | await interaction.reply(unknownError); 170 | } 171 | }; 172 | 173 | export const phpCommand: CommandDataWithHandler = { 174 | name: 'php', 175 | description: 'search and link something from php.net', 176 | handler, 177 | options: [ 178 | { 179 | name: 'query', 180 | description: 'The search query for php', 181 | type: ApplicationCommandOptionType.String, 182 | required: true, 183 | }, 184 | ], 185 | }; 186 | -------------------------------------------------------------------------------- /src/v2/commands/mdn/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationCommandOptionType, 3 | ButtonInteraction, 4 | Client, 5 | CommandInteraction, 6 | Message, 7 | MessageActionRowComponentBuilder, 8 | StringSelectMenuInteraction, 9 | } from 'discord.js'; 10 | import { ButtonBuilder, ButtonStyle, StringSelectMenuBuilder } from 'discord.js'; 11 | import { 12 | Collection, 13 | ActionRowBuilder, 14 | EmbedBuilder, 15 | ComponentType 16 | } from 'discord.js'; 17 | import { URL } from 'url'; 18 | 19 | import type { CommandDataWithHandler } from '../../../types'; 20 | import { clampLength, clampLengthMiddle } from '../../utils/clampStr.js'; 21 | import { 22 | invalidResponse, 23 | noResults, 24 | unknownError, 25 | } from '../../utils/errors.js'; 26 | import { getSearchUrl } from '../../utils/urlTools.js'; 27 | import useData from '../../utils/useData.js'; 28 | 29 | const list = new Intl.ListFormat(); 30 | const provider = 'mdn'; 31 | 32 | type SearchResponse = { 33 | query: string; 34 | locale: string; 35 | page: number; 36 | pages: number; 37 | starts: number; 38 | end: number; 39 | next: string; 40 | previous: string | null; 41 | count: number; 42 | filter: { 43 | name: string; 44 | slug: string; 45 | options: { 46 | name: string; 47 | slug: string; 48 | count: number; 49 | active: boolean; 50 | urls: { 51 | active: string; 52 | inactive: string; 53 | }; 54 | }[]; 55 | }[]; 56 | documents: { 57 | title: string; 58 | slug: string; 59 | locale: string; 60 | summary: string; 61 | }[]; 62 | }; 63 | 64 | const fetch: typeof useData = useData; 65 | 66 | const buildDirectUrl = (path: string) => 67 | new URL(path, 'https://developer.mozilla.org/en-US/docs/').toString(); 68 | 69 | const mdnHandler = async ( 70 | client: Client, 71 | interaction: CommandInteraction 72 | ): Promise => { 73 | const searchTerm: string = interaction.options.get('query').value as string; 74 | const deferral = await interaction.deferReply({ ephemeral: true }); 75 | try { 76 | const url = getSearchUrl(provider, searchTerm); 77 | const { error, json } = await fetch(url, 'json'); 78 | 79 | if (error) { 80 | await interaction.editReply({ 81 | content: invalidResponse, 82 | }); 83 | return; 84 | } 85 | 86 | if (json.documents.length === 0) { 87 | await interaction.editReply({ 88 | content: noResults(searchTerm), 89 | }); 90 | return; 91 | } 92 | 93 | const msgId = Math.random().toString(16); 94 | const collection = new Collection( 95 | json.documents.map(item => [item.slug, item]) 96 | ); 97 | const selectRow = new ActionRowBuilder().addComponents( 98 | new StringSelectMenuBuilder() 99 | .setCustomId(`mdn🤔${msgId}🤔select`) 100 | .setPlaceholder('Pick one to 5 options to display') 101 | .setMinValues(1) 102 | .setMaxValues(5) 103 | .addOptions( 104 | json.documents.map(({ title, summary, slug }) => ({ 105 | label: clampLengthMiddle(title, 25), 106 | description: clampLength(summary, 50), 107 | value: slug, 108 | })) 109 | ) 110 | ); 111 | const buttonRow = new ActionRowBuilder().addComponents( 112 | new ButtonBuilder() 113 | .setLabel('Cancel') 114 | .setStyle(ButtonStyle.Secondary) 115 | .setCustomId(`mdn🤔${msgId}🤔cancel`) 116 | ); 117 | 118 | const int = (await interaction.editReply({ 119 | content: 'Please pick 1 - 5 options below to display', 120 | components: [selectRow, buttonRow], 121 | })) 122 | 123 | const interactionCollector = int.createMessageComponentCollector< 124 | ComponentType.StringSelect | ComponentType.Button 125 | >({ 126 | filter: item => 127 | item.user.id === interaction.user.id && 128 | item.customId.startsWith(`mdn🤔${msgId}`), 129 | }); 130 | 131 | interactionCollector.once( 132 | 'collect', 133 | async (interaction: ButtonInteraction | StringSelectMenuInteraction) => { 134 | await interaction.deferUpdate(); 135 | if (interaction.isButton()) { 136 | await int.delete(); 137 | return; 138 | } 139 | const valueSet = new Set(interaction.values); 140 | const values = collection.filter((_, key) => valueSet.has(key)); 141 | 142 | await interaction.editReply({ 143 | content: `Displaying Results for ${list.format( 144 | values.map(({ title }) => title) 145 | )}`, 146 | components: [], 147 | }); 148 | 149 | interaction.channel.send({ 150 | content: `Results for "${searchTerm}"`, 151 | embeds: values.map(({ title, summary, slug }) => 152 | new EmbedBuilder() 153 | .setTitle(`${maybeClippy()} ${title}`) 154 | .setDescription( 155 | summary 156 | .split('\n') 157 | .map(item => item.trim()) 158 | .join(' ') 159 | ) 160 | .setURL(buildDirectUrl(slug)) 161 | .setColor('White') 162 | ), 163 | }); 164 | } 165 | ); 166 | } catch (error) { 167 | console.error(error); 168 | interaction.editReply(unknownError); 169 | } 170 | }; 171 | 172 | export const mdnCommand: CommandDataWithHandler = { 173 | name: 'mdn', 174 | description: 'search mdn', 175 | handler: async (client, interaction): Promise => { 176 | await mdnHandler(client, interaction); 177 | }, 178 | options: [ 179 | { 180 | name: 'query', 181 | description: 'query', 182 | type: ApplicationCommandOptionType.String, 183 | required: true, 184 | }, 185 | ], 186 | }; 187 | 188 | function maybeClippy() { 189 | return Math.random() <= 0.01 ? '<:clippy:865257202915082254>' : '🔗'; 190 | } 191 | -------------------------------------------------------------------------------- /src/v2/commands/post/questions.v2.ts: -------------------------------------------------------------------------------- 1 | import { ButtonStyle } from 'discord.js'; 2 | import { MultistepForm, MultiStepFormStep } from '../../utils/MultistepForm.js'; 3 | import { createMarkdownCodeBlock } from '../../utils/discordTools.js'; 4 | import { 5 | MINIMAL_COMPENSATION, 6 | MINIMAL_AMOUNT_OF_WORDS, 7 | POST_LIMITER_IN_HOURS, 8 | } from './env.js'; 9 | 10 | const isNotEmpty = (str: string): boolean => str.replace(/\W+/u, '').length > 0; 11 | const isNotTooLong = length => (str: string) => { 12 | const isLessThan1000 = str.length < length; 13 | if (!isLessThan1000) { 14 | return `Your message is ${str.length} characters long, the limit is ${length} characters, please shorten your input`; 15 | } 16 | return true; 17 | }; 18 | const and = 19 | (...fns: ((input: T) => K)[]) => 20 | (input: T): K | true => { 21 | for (const fn of fns) { 22 | const item: unknown = fn(input); 23 | if (item !== true) { 24 | return item as K; 25 | } 26 | } 27 | return true; 28 | }; 29 | const dollarFormat = new Intl.NumberFormat('en-US', { 30 | style: 'currency', 31 | currency: 'USD', 32 | }); 33 | /* 34 | Since checking if the input string is empty is not practical for this use-case, 35 | this function checks if the provided input has at the very least `MINIMAL_AMOUNT_OF_WORDS` words in it. 36 | */ 37 | const isNotShort = (str: string): boolean => 38 | str.split(' ').length >= MINIMAL_AMOUNT_OF_WORDS; 39 | 40 | const greeterMessage = 41 | `Please adhere to the following guidelines when creating a job posting: 42 | ${createMarkdownCodeBlock( 43 | ` 44 | 1. Your job must provide monetary compensation.\n 45 | 2. Your job must not be related to cryptocurrency, blockchain, NFTs, Web3 technologies, or gambling in any way.\n 46 | 3. Your job must provide at least $${MINIMAL_COMPENSATION} in compensation.\n 47 | 4. You can only post a job once every ${Number.parseInt(POST_LIMITER_IN_HOURS, 10) === 1 48 | ? 'hour' 49 | : `${POST_LIMITER_IN_HOURS} hours` 50 | }.\n 51 | 5. You agree not to abuse our job posting service or circumvent any server rules, and you understand that doing so will result in a ban.\n`, 52 | 'md' 53 | )} 54 | To continue, have the following information available: 55 | ${createMarkdownCodeBlock( 56 | ` 57 | 1. Job location information (optional).\n 58 | 2. A short description of the job posting with no special formatting (at least ${MINIMAL_AMOUNT_OF_WORDS} words long).\n 59 | 3. The amount of compensation in USD for the job.\n 60 | 4. Contact information for potential job seekers to apply for your job.`, 61 | 'md' 62 | )} 63 | If your compensation is deemed unfair by the moderation team, your job posting will be removed.` 64 | .split('\n') 65 | .map(item => item.trim()) 66 | .join('\n'); 67 | 68 | export const questions = { 69 | guidelines: { 70 | type: 'button', 71 | body: greeterMessage, 72 | buttons: [ 73 | { label: 'I Agree', value: 'ok', style: ButtonStyle.Success }, 74 | { label: 'Cancel', value: 'cancel', style: ButtonStyle.Danger }, 75 | ], 76 | buttonDelay: 5000, 77 | next: value => (value === 'ok' ? 'remote' : MultistepForm.cancelled), 78 | }, 79 | remote: { 80 | type: 'button', 81 | body: 'Is this position remote or on-site?', 82 | buttons: [ 83 | { 84 | label: 'Remote', 85 | value: 'remote', 86 | }, 87 | { 88 | label: 'On-site', 89 | value: 'onsite', 90 | }, 91 | ], 92 | next: (value: string): string => 93 | value === 'onsite' ? 'location' : 'description', 94 | }, 95 | location: { 96 | type: 'text', 97 | body: 'Provide the location in a single message. If you wish not to share the location reply with `no`.', 98 | validate: and(isNotEmpty, isNotTooLong(30)), 99 | next: (value: string): string => 'description', 100 | }, 101 | description: { 102 | type: 'text', 103 | body: 'With a single message provide a short description of the job.\nTypically job postings include a description of the job, estimated hours, technical knowledge requirements, scope, and desired qualifications.', 104 | validate: and(isNotShort, isNotTooLong(1000)), 105 | next: (value: string): string => 'compensation_type', 106 | }, 107 | compensation_type: { 108 | type: 'button', 109 | body: 'Type `project` if your compensation amount is for the project or type `hourly` if your compensation amount is for an hourly rate.', 110 | buttons: [ 111 | { 112 | label: 'Project', 113 | value: 'project', 114 | }, 115 | { 116 | label: 'Hourly', 117 | value: 'hourly', 118 | }, 119 | { 120 | label: 'Salary', 121 | value: 'salary', 122 | }, 123 | ], 124 | next: (value: string): string => 'compensation', 125 | }, 126 | compensation: { 127 | type: 'text', 128 | body: 'Provide the compensation amount for this job using **only** numbers.', 129 | validate: (answer: string): boolean | string => { 130 | const value = Number(answer.split('$').join('')); 131 | const minimalCompensation = Number.parseFloat(MINIMAL_COMPENSATION); 132 | 133 | if (Number.isNaN(value)) { 134 | return `\`${answer}\` could not be parsed as a number`; 135 | } 136 | 137 | if (value < minimalCompensation) { 138 | return `The minimum compensation is ${dollarFormat.format( 139 | minimalCompensation 140 | )}.`; 141 | } 142 | 143 | return true; 144 | }, 145 | format: (value: string): string => 146 | dollarFormat.format(Number.parseFloat(value.split('$').join(''))), 147 | next: () => 'contact', 148 | }, 149 | contact: { 150 | type: 'text', 151 | body: 'Provide the method that applicants should apply for your job (e.g., DM, email, website application, etc.) and any additional information that you think would be helpful to potential applicants.', 152 | validate: and(isNotEmpty, isNotTooLong(100)), 153 | }, 154 | } as const satisfies Record; 155 | -------------------------------------------------------------------------------- /src/v2/index.ts: -------------------------------------------------------------------------------- 1 | import { init } from '@sentry/node'; 2 | import { ChannelType, Message } from 'discord.js'; 3 | import { Client, IntentsBitField } from 'discord.js'; 4 | import mongoose from 'mongoose'; 5 | 6 | import { 7 | DISCORD_TOKEN, 8 | IS_PROD, 9 | DUMMY_TOKEN, 10 | MONGO_URI, 11 | SERVER_ID, 12 | ENV, 13 | VAR_DETECT_LIMIT, 14 | JUST_ASK_DETECT_LIMIT, 15 | } from '../env.js'; 16 | import { detectVar } from './autorespond/code_parsing/index.js'; 17 | import { handleDeprecatedCommands } from './autorespond/deprecatedCommands.js'; 18 | import { detectDeprecatedHTML } from './autorespond/html_parsing/index.js'; 19 | import { detectVagueQuestion } from './autorespond/justask.js'; 20 | import isThanksMessage from './autorespond/thanks/checker.js'; 21 | import { 22 | handleThanks, 23 | attachUndoThanksListener, 24 | } from './autorespond/thanks/index.js'; 25 | import { 26 | attachThreadClose, 27 | attachThreadThanksHandler, 28 | } from './autorespond/thanks/threadThanks.js'; 29 | import { limitFnByUser } from './cache/index.js'; 30 | import { registerCommands } from './commands/index.js'; 31 | import pointDecaySystem, { 32 | loadLastDecayFromDB, 33 | } from './helpful_role/point_decay.js'; 34 | import { registerMessageContextMenu } from './message_context/index.js'; 35 | import { attach as attachOnboarding } from './modules/onboarding/index.js'; 36 | import { getOnboardingStart } from './modules/onboarding/utils/onboardingStart.js'; 37 | import { registerUserContextMenu } from './user_context/index.js'; 38 | import { stripMarkdownQuote } from './utils/content_format.js'; 39 | 40 | const NON_COMMAND_MSG_TYPES = new Set([ 41 | ChannelType.GuildText, 42 | ChannelType.PrivateThread, 43 | ChannelType.PublicThread, 44 | ]); 45 | 46 | if (IS_PROD) { 47 | init({ 48 | dsn: 'https://9902d087a01f4d8883daad5d59d90736@o163592.ingest.sentry.io/5307626', 49 | }); 50 | } 51 | 52 | // This date is used to check if the message's been created before the bot's started 53 | export const startTime = new Date(); 54 | loadLastDecayFromDB(); 55 | const client = new Client({ 56 | intents: [ 57 | 'Guilds', 58 | 'GuildMembers', 59 | 'GuildBans', 60 | 'GuildEmojisAndStickers', 61 | 'MessageContent', 62 | 'GuildIntegrations', 63 | 'GuildWebhooks', 64 | 'GuildInvites', 65 | 'GuildVoiceStates', 66 | 'GuildPresences', 67 | 'GuildMessages', 68 | 'GuildMessageReactions', 69 | 'GuildMessageTyping', 70 | 'DirectMessages', 71 | 'DirectMessageReactions', 72 | 'DirectMessageTyping', 73 | ], 74 | }); 75 | 76 | const blacklistedServer = new Set([ 77 | '264445053596991498', // Discord Bot List 78 | '448549361119395850', // some random bot test server 79 | '657145936207806465', // nazi stuff 80 | ]); 81 | 82 | client.on('ready', () => { 83 | // eslint-disable-next-line no-console 84 | console.log(`Logged in as ${client.user.tag}!\nEnvironment: ${ENV}`); 85 | }); 86 | 87 | client.once('ready', async (): Promise => { 88 | pointDecaySystem({ 89 | guild: client.guilds.resolve(SERVER_ID), 90 | }); 91 | registerCommands(client); 92 | registerUserContextMenu(client); 93 | registerMessageContextMenu(client); 94 | attachUndoThanksListener(client); 95 | attachThreadThanksHandler(client); 96 | attachThreadClose(client); 97 | if (await getOnboardingStart()) { 98 | console.info('Onboarding functionality added'); 99 | attachOnboarding(client); 100 | } else { 101 | console.info('Onboarding functionality not added'); 102 | } 103 | 104 | void client.user.setActivity(`@${client.user.username} --help`); 105 | 106 | await Promise.all( 107 | client.guilds.cache 108 | .filter(guild => blacklistedServer.has(guild.id)) 109 | .map(guild => guild.leave()), 110 | ); 111 | 112 | // eslint-disable-next-line no-console 113 | console.table( 114 | client.guilds.cache.map(({ name, id, joinedAt, memberCount }) => ({ 115 | id, 116 | joinedAt: joinedAt.toLocaleDateString(), 117 | memberCount, 118 | name, 119 | })), 120 | ); 121 | 122 | try { 123 | await client.user.setAvatar('./logo.png'); 124 | } catch {} 125 | }); 126 | 127 | const detectVarLimited = limitFnByUser(detectVar, { 128 | delay: VAR_DETECT_LIMIT, 129 | type: 'VAR_CHECK', 130 | }); 131 | 132 | const detectHTMLLimited = limitFnByUser(detectDeprecatedHTML, { 133 | delay: VAR_DETECT_LIMIT, 134 | type: 'DEP_HTML_CHECK', 135 | }); 136 | 137 | const detectJustAsk = limitFnByUser(detectVagueQuestion, { 138 | delay: JUST_ASK_DETECT_LIMIT, 139 | type: 'JUST_ASK', 140 | }); 141 | 142 | const detectDeprecatedCommands = limitFnByUser(handleDeprecatedCommands, { 143 | type: 'DEPRECATED_COMMANDS', 144 | delay: VAR_DETECT_LIMIT, // gonna reuse this as its just a temp measure 145 | }); 146 | 147 | const isWebdevAndWebDesignServer = (msg: Message) => 148 | msg.guild?.id === SERVER_ID || false; 149 | 150 | client.on('messageCreate', msg => { 151 | if (msg.author.bot) { 152 | return; 153 | } 154 | 155 | if (NON_COMMAND_MSG_TYPES.has(msg.channel.type) && msg.guild) { 156 | handleNonCommandGuildMessages(msg); 157 | } 158 | }); 159 | 160 | const handleNonCommandGuildMessages = async (msg: Message) => { 161 | const quoteLessContent = stripMarkdownQuote(msg.content); 162 | if (msg.author.bot) { 163 | return; 164 | } 165 | if (isWebdevAndWebDesignServer(msg) && isThanksMessage(quoteLessContent)) { 166 | handleThanks(msg); 167 | } 168 | await detectDeprecatedCommands(msg); 169 | await detectJustAsk(msg); 170 | await detectVarLimited(msg); 171 | await detectHTMLLimited(msg); 172 | }; 173 | 174 | // Establish a connection with the database 175 | export const dbConnect = async (): Promise => { 176 | try { 177 | await mongoose.connect(MONGO_URI, {}); 178 | // eslint-disable-next-line no-console 179 | console.log('MongoDB connection established.'); 180 | } catch (error) { 181 | // eslint-disable-next-line no-console 182 | console.error('mongoose.connect():', error); 183 | } 184 | }; 185 | 186 | dbConnect(); 187 | 188 | try { 189 | client.login(IS_PROD ? DISCORD_TOKEN : DUMMY_TOKEN); 190 | } catch { 191 | // eslint-disable-next-line no-console 192 | console.error('Boot Error: token invalid'); 193 | } 194 | -------------------------------------------------------------------------------- /src/v2/autorespond/thanks/thanks.ts: -------------------------------------------------------------------------------- 1 | // We're just treeting this like a giant JSON file but in TS 2 | /* eslint-disable import/no-anonymous-default-export, import/no-default-export */ 3 | export default [ 4 | { language: 'Arabic', symbol: 'ar', text: 'شكر' }, 5 | { language: 'Dutch', symbol: 'nl', text: 'bedankt' }, 6 | { language: 'French', symbol: 'fr', text: 'Merci' }, 7 | { language: 'German', symbol: 'de', text: 'Vielen Dank' }, 8 | { language: 'Italian', symbol: 'it', text: 'Grazie' }, 9 | { language: 'Japanese', symbol: 'ja', text: 'ありがとう' }, 10 | { language: 'Russian', symbol: 'ru', text: 'Спасибо' }, 11 | { language: 'Spanish', symbol: 'es', text: 'Gracias' }, 12 | { language: 'Bengali', symbol: 'bn', text: 'ধন্যবাদ' }, 13 | { language: 'Bulgarian', symbol: 'bg', text: 'Благодаря' }, 14 | { language: 'Catalan', symbol: 'ca', text: 'gràcies' }, 15 | { language: 'Chinese Simplified', symbol: 'zh-cn', text: '谢谢' }, 16 | { language: 'Chinese Traditional', symbol: 'zh-tw', text: '謝謝' }, 17 | { language: 'Croatian', symbol: 'hr', text: 'Hvala' }, 18 | { language: 'Czech', symbol: 'cs', text: 'dík' }, 19 | { language: 'Danish', symbol: 'da', text: 'tak' }, 20 | { language: 'Esperanto', symbol: 'eo', text: 'Dankon' }, 21 | { language: 'Estonian', symbol: 'et', text: 'aitäh' }, 22 | { language: 'Filipino', symbol: 'tl', text: 'salamat' }, 23 | { language: 'Finnish', symbol: 'fi', text: 'Kiitos' }, 24 | { language: 'Greek', symbol: 'el', text: 'ευχαριστώ' }, 25 | { language: 'Hebrew', symbol: 'iw', text: 'תודה' }, 26 | { language: 'Indonesian', symbol: 'id', text: 'Terima kasih' }, 27 | { language: 'Korean', symbol: 'ko', text: '감사' }, 28 | { language: 'Latvian', symbol: 'lv', text: 'Paldies' }, 29 | { language: 'Lithuanian', symbol: 'lt', text: 'dėkoju' }, 30 | { language: 'Malay', symbol: 'ms', text: 'terima kasih' }, 31 | { language: 'Malayalam', symbol: 'ml', text: 'നന്ദി' }, 32 | { language: 'Marathi', symbol: 'mr', text: 'धन्यवाद' }, 33 | { language: 'Norwegian', symbol: 'no', text: 'Takk' }, 34 | { language: 'Polish', symbol: 'pl', text: 'dzięki' }, 35 | { language: 'Portuguese', symbol: 'pt', text: 'obrigado' }, 36 | { language: 'Romanian', symbol: 'ro', text: 'Mulțumiri' }, 37 | { language: 'Serbian', symbol: 'sr', text: 'Хвала' }, 38 | { language: 'Slovak', symbol: 'sk', text: 'Vďaka' }, 39 | { language: 'Slovenian', symbol: 'sl', text: 'hvala' }, 40 | { language: 'Swedish', symbol: 'sv', text: 'tack' }, 41 | { language: 'Tajik', symbol: 'tg', text: 'ташаккур' }, 42 | { language: 'Tamil', symbol: 'ta', text: 'நன்றி' }, 43 | { language: 'Telugu', symbol: 'te', text: 'ధన్యవాదాలు' }, 44 | { language: 'Thai', symbol: 'th', text: 'ขอบคุณ' }, 45 | { language: 'Turkish', symbol: 'tr', text: 'Teşekkürler' }, 46 | { language: 'Ukrainian', symbol: 'uk', text: 'Дякую' }, 47 | { language: 'Urdu', symbol: 'ur', text: 'شکریہ' }, 48 | { language: 'Vietnamese', symbol: 'vi', text: 'cảm ơn' }, 49 | { language: 'Afrikaans', symbol: 'af', text: 'dankie' }, 50 | { language: 'Albanian', symbol: 'sq', text: 'Faleminderit' }, 51 | { language: 'Amharic', symbol: 'am', text: 'አመሰግናለሁ' }, 52 | { language: 'Armenian', symbol: 'hy', text: 'շնորհակալություն' }, 53 | { language: 'Azerbaijani', symbol: 'az', text: 'təşəkkürlər' }, 54 | { language: 'Basque', symbol: 'eu', text: 'eskerrik asko' }, 55 | { language: 'Belarusian', symbol: 'be', text: 'дзякуй' }, 56 | { language: 'Bosnian', symbol: 'bs', text: 'hvala' }, 57 | { language: 'Cebuano', symbol: 'ceb', text: 'salamat' }, 58 | { language: 'Chichewa', symbol: 'ny', text: 'zikomo' }, 59 | { language: 'Corsican', symbol: 'co', text: 'Grazie' }, 60 | { language: 'Frisian', symbol: 'fy', text: 'tank' }, 61 | { language: 'Galician', symbol: 'gl', text: 'grazas' }, 62 | { language: 'Georgian', symbol: 'ka', text: 'მადლობა' }, 63 | { language: 'Gujarati', symbol: 'gu', text: 'આભાર' }, 64 | { language: 'Haitian Creole', symbol: 'ht', text: 'mèsi' }, 65 | { language: 'Hausa', symbol: 'ha', text: 'godiya' }, 66 | { language: 'Hawaiian', symbol: 'haw', text: 'mahalo' }, 67 | { language: 'Hindi', symbol: 'hi', text: 'धन्यवाद' }, 68 | { language: 'Hmong', symbol: 'hmn', text: 'ua tsaug' }, 69 | { language: 'Hungarian', symbol: 'hu', text: 'Kösz' }, 70 | { language: 'Icelandic', symbol: 'is', text: 'takk fyrir' }, 71 | { language: 'Igbo', symbol: 'ig', text: 'daalụ' }, 72 | { language: 'Irish', symbol: 'ga', text: 'go raibh maith agat' }, 73 | { language: 'Javanese', symbol: 'jw', text: 'matur nuwun' }, 74 | { language: 'Kannada', symbol: 'kn', text: 'ಧನ್ಯವಾದಗಳು' }, 75 | { language: 'Kazakh', symbol: 'kk', text: 'рахмет' }, 76 | { language: 'Khmer', symbol: 'km', text: 'សូមអរគុណ' }, 77 | { language: 'Kyrgyz', symbol: 'ky', text: 'рахмат' }, 78 | { language: 'Lao', symbol: 'lo', text: 'ຂອບໃຈ' }, 79 | { language: 'Latin', symbol: 'la', text: 'gratias ago' }, 80 | { language: 'Luxembourgish', symbol: 'lb', text: 'Merci' }, 81 | { language: 'Macedonian', symbol: 'mk', text: 'благодарам' }, 82 | { language: 'Malagasy', symbol: 'mg', text: 'Misaotra' }, 83 | { language: 'Maltese', symbol: 'mt', text: 'grazzi' }, 84 | { language: 'Maori', symbol: 'mi', text: 'whakawhetai' }, 85 | { language: 'Mongolian', symbol: 'mn', text: 'баярлалаа' }, 86 | { language: 'Myanmar', symbol: 'my', text: 'ကျေးဇူးတင်ပါတယ်' }, 87 | { language: 'Nepali', symbol: 'ne', text: 'धन्यवाद' }, 88 | { language: 'Pashto', symbol: 'ps', text: 'مننه' }, 89 | { language: 'Persian', symbol: 'fa', text: 'با تشکر' }, 90 | { language: 'Samoan', symbol: 'sm', text: 'faʻafetai' }, 91 | { language: 'Scots Gaelic', symbol: 'gd', text: 'mòran taing' }, 92 | { language: 'Sesotho', symbol: 'st', text: 'kea leboha' }, 93 | { language: 'Shona', symbol: 'sn', text: 'ndatenda' }, 94 | { language: 'Sindhi', symbol: 'sd', text: 'مهرباني' }, 95 | { language: 'Sinhala', symbol: 'si', text: 'ස්තූතියි' }, 96 | { language: 'Somali', symbol: 'so', text: 'mahadsanid' }, 97 | { language: 'Sundanese', symbol: 'su', text: 'hatur nuhun' }, 98 | { language: 'Swahili', symbol: 'sw', text: 'asante' }, 99 | { language: 'Uzbek', symbol: 'uz', text: 'rahmat' }, 100 | { language: 'Welsh', symbol: 'cy', text: 'diolch' }, 101 | { language: 'Xhosa', symbol: 'xh', text: 'enkosi' }, 102 | { language: 'Yiddish', symbol: 'yi', text: 'דאַנקען' }, 103 | { language: 'Yoruba', symbol: 'yo', text: 'o ṣeun' }, 104 | { language: 'Zulu', symbol: 'zu', text: 'ngiyabonga' }, 105 | ]; 106 | -------------------------------------------------------------------------------- /src/v2/autorespond/thanks/thankyou.ts: -------------------------------------------------------------------------------- 1 | // We're just treeting this like a giant JSON file but in TS 2 | /* eslint-disable import/no-anonymous-default-export, import/no-default-export */ 3 | export default [ 4 | { language: 'Arabic', symbol: 'ar', text: 'شكرا' }, 5 | { language: 'Dutch', symbol: 'nl', text: 'dank je' }, 6 | { language: 'French', symbol: 'fr', text: 'Merci' }, 7 | { language: 'German', symbol: 'de', text: 'Danke' }, 8 | { language: 'Italian', symbol: 'it', text: 'grazie' }, 9 | { language: 'Japanese', symbol: 'ja', text: 'ありがとうございました' }, 10 | { language: 'Russian', symbol: 'ru', text: 'благодарю вас' }, 11 | { language: 'Spanish', symbol: 'es', text: 'gracias' }, 12 | { language: 'Bengali', symbol: 'bn', text: 'ধন্যবাদ' }, 13 | { language: 'Bulgarian', symbol: 'bg', text: 'Благодаря ти' }, 14 | { language: 'Catalan', symbol: 'ca', text: 'gràcies' }, 15 | { language: 'Chinese Simplified', symbol: 'zh-cn', text: '谢谢' }, 16 | { language: 'Chinese Traditional', symbol: 'zh-tw', text: '謝謝' }, 17 | { language: 'Croatian', symbol: 'hr', text: 'Hvala vam' }, 18 | { language: 'Czech', symbol: 'cs', text: 'Děkuji' }, 19 | { language: 'Danish', symbol: 'da', text: 'tak skal du have' }, 20 | { language: 'Esperanto', symbol: 'eo', text: 'Dankon' }, 21 | { language: 'Estonian', symbol: 'et', text: 'aitäh' }, 22 | { language: 'Filipino', symbol: 'tl', text: 'Salamat' }, 23 | { language: 'Finnish', symbol: 'fi', text: 'Kiitos' }, 24 | { language: 'Greek', symbol: 'el', text: 'ευχαριστώ' }, 25 | { language: 'Hebrew', symbol: 'iw', text: 'תודה' }, 26 | { language: 'Indonesian', symbol: 'id', text: 'Terima kasih' }, 27 | { language: 'Korean', symbol: 'ko', text: '감사합니다' }, 28 | { language: 'Latvian', symbol: 'lv', text: 'Paldies' }, 29 | { language: 'Lithuanian', symbol: 'lt', text: 'Ačiū' }, 30 | { language: 'Malay', symbol: 'ms', text: 'terima kasih' }, 31 | { language: 'Malayalam', symbol: 'ml', text: 'നന്ദി' }, 32 | { language: 'Marathi', symbol: 'mr', text: 'धन्यवाद' }, 33 | { language: 'Norwegian', symbol: 'no', text: 'Takk skal du ha' }, 34 | { language: 'Polish', symbol: 'pl', text: 'Dziękuję Ci' }, 35 | { language: 'Portuguese', symbol: 'pt', text: 'obrigado' }, 36 | { language: 'Romanian', symbol: 'ro', text: 'mulțumesc' }, 37 | { language: 'Serbian', symbol: 'sr', text: 'Хвала вам' }, 38 | { language: 'Slovak', symbol: 'sk', text: 'Ďakujem' }, 39 | { language: 'Slovenian', symbol: 'sl', text: 'Hvala vam' }, 40 | { language: 'Swedish', symbol: 'sv', text: 'tack' }, 41 | { language: 'Tajik', symbol: 'tg', text: 'сипос' }, 42 | { language: 'Tamil', symbol: 'ta', text: 'நன்றி' }, 43 | { language: 'Telugu', symbol: 'te', text: 'ధన్యవాదాలు' }, 44 | { language: 'Thai', symbol: 'th', text: 'ขอขอบคุณ' }, 45 | { language: 'Turkish', symbol: 'tr', text: 'teşekkür ederim' }, 46 | { language: 'Ukrainian', symbol: 'uk', text: 'спасибі' }, 47 | { language: 'Urdu', symbol: 'ur', text: 'آپ کا شکریہ' }, 48 | { language: 'Vietnamese', symbol: 'vi', text: 'cảm ơn bạn' }, 49 | { language: 'Afrikaans', symbol: 'af', text: 'Dankie' }, 50 | { language: 'Albanian', symbol: 'sq', text: 'faleminderit' }, 51 | { language: 'Amharic', symbol: 'am', text: 'አመሰግናለሁ' }, 52 | { language: 'Armenian', symbol: 'hy', text: 'շնորհակալություն' }, 53 | { language: 'Azerbaijani', symbol: 'az', text: 'çox sağ ol' }, 54 | { language: 'Basque', symbol: 'eu', text: 'eskerrik asko' }, 55 | { language: 'Belarusian', symbol: 'be', text: 'Дзякуй' }, 56 | { language: 'Bosnian', symbol: 'bs', text: 'hvala ti' }, 57 | { language: 'Cebuano', symbol: 'ceb', text: 'salamat' }, 58 | { language: 'Chichewa', symbol: 'ny', text: 'Zikomo' }, 59 | { language: 'Corsican', symbol: 'co', text: 'à ringrazià ti' }, 60 | { language: 'Frisian', symbol: 'fy', text: 'Dankewol' }, 61 | { language: 'Galician', symbol: 'gl', text: 'grazas' }, 62 | { language: 'Georgian', symbol: 'ka', text: 'გმადლობთ' }, 63 | { language: 'Gujarati', symbol: 'gu', text: 'આભાર' }, 64 | { language: 'Haitian Creole', symbol: 'ht', text: 'mèsi' }, 65 | { language: 'Hausa', symbol: 'ha', text: 'na gode' }, 66 | { language: 'Hawaiian', symbol: 'haw', text: 'mahalo' }, 67 | { language: 'Hindi', symbol: 'hi', text: 'धन्यवाद' }, 68 | { language: 'Hmong', symbol: 'hmn', text: 'ua tsaug' }, 69 | { language: 'Hungarian', symbol: 'hu', text: 'köszönöm' }, 70 | { language: 'Icelandic', symbol: 'is', text: 'Þakka þér fyrir' }, 71 | { language: 'Igbo', symbol: 'ig', text: 'Daalụ' }, 72 | { language: 'Irish', symbol: 'ga', text: 'go raibh maith agat' }, 73 | { language: 'Javanese', symbol: 'jw', text: 'matur nuwun' }, 74 | { language: 'Kannada', symbol: 'kn', text: 'ಧನ್ಯವಾದಗಳು' }, 75 | { language: 'Kazakh', symbol: 'kk', text: 'Рақмет сізге' }, 76 | { language: 'Khmer', symbol: 'km', text: 'សូមអរគុណ' }, 77 | { language: 'Kurdish', symbol: 'ku', text: 'sipas ji were' }, 78 | { language: 'Kyrgyz', symbol: 'ky', text: 'рахмат сага' }, 79 | { language: 'Lao', symbol: 'lo', text: 'ຂອບ​ໃຈ' }, 80 | { language: 'Latin', symbol: 'la', text: 'gratias tibi' }, 81 | { language: 'Luxembourgish', symbol: 'lb', text: 'Merci' }, 82 | { language: 'Macedonian', symbol: 'mk', text: 'Ви благодарам' }, 83 | { language: 'Malagasy', symbol: 'mg', text: 'Misaotra anao' }, 84 | { language: 'Maltese', symbol: 'mt', text: 'Grazzi' }, 85 | { language: 'Maori', symbol: 'mi', text: 'kia ora' }, 86 | { language: 'Mongolian', symbol: 'mn', text: 'баярлалаа' }, 87 | { language: 'Myanmar', symbol: 'my', text: 'ကျေးဇူးတင်ပါတယ်' }, 88 | { language: 'Nepali', symbol: 'ne', text: 'धन्यवाद' }, 89 | { language: 'Pashto', symbol: 'ps', text: 'له تاسو مننه' }, 90 | { language: 'Persian', symbol: 'fa', text: 'متشکرم' }, 91 | { language: 'Samoan', symbol: 'sm', text: 'faafetai' }, 92 | { language: 'Scots Gaelic', symbol: 'gd', text: 'Tapadh leat' }, 93 | { language: 'Sesotho', symbol: 'st', text: 'kea leboha' }, 94 | { language: 'Shona', symbol: 'sn', text: 'waita hako' }, 95 | { language: 'Sindhi', symbol: 'sd', text: 'توهان جي مهرباني' }, 96 | { language: 'Sinhala', symbol: 'si', text: 'ඔබට ස්තුතියි' }, 97 | { language: 'Somali', symbol: 'so', text: 'mahadsanid' }, 98 | { language: 'Sundanese', symbol: 'su', text: 'hatur nuhun' }, 99 | { language: 'Swahili', symbol: 'sw', text: 'Asante' }, 100 | { language: 'Uzbek', symbol: 'uz', text: 'rahmat' }, 101 | { language: 'Welsh', symbol: 'cy', text: 'Diolch' }, 102 | { language: 'Xhosa', symbol: 'xh', text: 'enkosi' }, 103 | { language: 'Yiddish', symbol: 'yi', text: 'אדאנק' }, 104 | { language: 'Yoruba', symbol: 'yo', text: 'e dupe' }, 105 | { language: 'Zulu', symbol: 'zu', text: 'Ngiyabonga' }, 106 | ]; 107 | -------------------------------------------------------------------------------- /src/v2/utils/urlTools.ts: -------------------------------------------------------------------------------- 1 | import type { CommandInteraction } from 'discord.js'; 2 | import type { HeadersInit } from 'node-fetch'; 3 | 4 | import type { Provider } from './discordTools'; 5 | import { noResults, invalidResponse } from './errors.js'; 6 | import useData from './useData.js'; 7 | 8 | const SEARCH_TERM = '%SEARCH%'; 9 | const TERM = '%TERM%'; 10 | 11 | type ProviderMap = { 12 | [key in Provider]: { 13 | search: string; 14 | color: number; 15 | createTitle: (term: string) => string; 16 | icon: string; 17 | help: string; 18 | direct?: string; 19 | getExtendedInfoUrl?: (pkg: string) => string; 20 | }; 21 | }; 22 | 23 | export const providers: ProviderMap = { 24 | helper: { 25 | color: 0xe6_7e_22, 26 | createTitle: () => '', 27 | direct: '', 28 | getExtendedInfoUrl: () => '', 29 | help: '', 30 | icon: '', 31 | search: '', 32 | }, 33 | bundlephobia: { 34 | color: 0xff_ff_ff, 35 | createTitle: (searchTerm: string) => 36 | `Bundlephobia results for *${searchTerm}*`, 37 | direct: `https://bundlephobia.com/result?p=${TERM}`, 38 | getExtendedInfoUrl: (pkg: string) => 39 | `https://bundlephobia.com/api/size?package=${pkg}&record=true`, 40 | help: '!bundlephobia @chakra-ui/core', 41 | icon: 'https://bundlephobia.com/android-chrome-192x192.png', 42 | search: `https://api.npms.io/v2/search/suggestions?q=${SEARCH_TERM}`, 43 | }, 44 | caniuse: { 45 | color: 0xdb_56_00, 46 | createTitle: (searchTerm: string) => `CanIUse results for *${searchTerm}*`, 47 | direct: `https://caniuse.com/#feat=${TERM}`, 48 | getExtendedInfoUrl: (text: string) => 49 | `https://caniuse.com/process/get_feat_data.php?type=support-data&feat=${text}`, 50 | help: '!caniuse IntersectionObserver', 51 | icon: 'https://caniuse.com/img/favicon-128.png', 52 | search: `https://caniuse.com/process/query.php?search=${SEARCH_TERM}`, 53 | }, 54 | composer: { 55 | color: 0xf2_8d_1a, 56 | createTitle: (searchTerm: string) => `Packagist results for ${searchTerm}`, 57 | direct: `https://packagist.org/packages/${TERM}`, 58 | getExtendedInfoUrl: (pkg: string) => 59 | `https://packagist.org/packages/${pkg}.json`, 60 | help: '!composer sentry', 61 | icon: 'https://packagist.org/bundles/packagistweb/img/logo-small.png', 62 | search: `https://packagist.org/search.json?q=${SEARCH_TERM}`, 63 | }, 64 | github: { 65 | color: 0x24_29_2e, 66 | createTitle: (searchTerm: string) => `GitHub results for *${searchTerm}*`, 67 | direct: `https://github.com/${TERM}`, 68 | help: '!github react', 69 | icon: 'https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png', 70 | search: `https://api.github.com/search/repositories?q=${SEARCH_TERM}`, 71 | }, 72 | mdn: { 73 | color: 0x83_d0_f2, 74 | createTitle: (searchTerm: string) => `MDN results for *${searchTerm}*`, 75 | direct: `https://developer.mozilla.org${TERM}`, 76 | help: '!mdn localStorage', 77 | icon: 'https://avatars0.githubusercontent.com/u/7565578', 78 | search: `https://developer.mozilla.org/api/v1/search?q=${SEARCH_TERM}&locale=en-US`, 79 | }, 80 | npm: { 81 | color: 0xfb_3e_44, 82 | createTitle: (searchTerm: string) => `NPM results for *${searchTerm}*`, 83 | help: '!npm react', 84 | icon: 'https://avatars0.githubusercontent.com/u/6078720', 85 | search: `https://www.npmjs.com/search/suggestions?q=${SEARCH_TERM}`, 86 | }, 87 | php: { 88 | color: 0x88_92_bf, 89 | createTitle: (searchTerm: string) => `PHP.net results for *${searchTerm}*`, 90 | direct: `https://www.php.net/${TERM}`, 91 | help: '!php echo', 92 | icon: 'https://www.php.net/images/logos/php-logo.svg', 93 | search: `https://www.php.net/${SEARCH_TERM}`, 94 | }, 95 | }; 96 | 97 | export const HELP_KEYWORD = '--help'; 98 | export const FORMATTING_KEYWORD = '!formatting'; 99 | export const FORMATTING_KEYWORD_ALT = '!format'; 100 | export const CODE_KEYWORD = '!code'; 101 | export const VSCODE_KEYWORD = '!vscode'; 102 | export const JOB_POSTING_KEYWORD = '!post'; 103 | export const JQUERY_KEYWORD = '!jquery'; 104 | export const POINTS_KEYWORD = '!points'; 105 | export const LEADERBOARD_KEYWORD = '!leaderboard'; 106 | export const DECAY_KEYWORD = '!decay'; 107 | export const MODULE_KEYWORD = '!modules'; 108 | export const LOCKFILE_KEYWORD = '!lockfile'; 109 | export const FLEXBOX_KEYWORD = '!flexbox'; 110 | 111 | /** 112 | * dynamic regExp matching all possible Object.keys(providers) as keyword 113 | */ 114 | export const KEYWORD_REGEXP = new RegExp( 115 | '^!(%PLACEHOLDER%)\\s+'.replace( 116 | '%PLACEHOLDER%', 117 | Object.keys(providers) 118 | .reduce((carry, keyword) => [...carry, keyword], []) 119 | .join('|') 120 | ), 121 | 'iu' 122 | ); 123 | 124 | export const getSearchUrl = (provider: Provider, search: string): string => { 125 | if (providers[provider]) { 126 | return providers[provider].search.replace(SEARCH_TERM, encodeURI(search)); 127 | } 128 | 129 | throw new Error(`provider not implemeted: ${provider}`); 130 | }; 131 | 132 | export const buildDirectUrl = (provider: Provider, href: string): string => { 133 | if (providers[provider]) { 134 | return providers[provider].direct.replace(TERM, href.replace(/^\//u, '')); 135 | } 136 | 137 | throw new Error(`provider not implemeted: ${provider}`); 138 | }; 139 | 140 | export const getExtendedInfoUrl = ( 141 | provider: Provider, 142 | term: string 143 | ): string => { 144 | if (providers[provider]?.getExtendedInfoUrl) { 145 | return providers[provider].getExtendedInfoUrl(term); 146 | } 147 | 148 | throw new Error( 149 | `provider or provider.getExtendedInfoUrl not implemented at provider: ${provider}` 150 | ); 151 | }; 152 | 153 | type GetDataParams = { 154 | msg: CommandInteraction; 155 | provider: Provider; 156 | searchTerm: string; 157 | sanitizeData?: (data: unknown) => Partial; 158 | isInvalidData: (data: unknown) => boolean; 159 | headers?: HeadersInit; 160 | }; 161 | 162 | export const getData = async ({ 163 | msg: interaction, 164 | provider, 165 | searchTerm, 166 | sanitizeData, 167 | isInvalidData, 168 | headers, 169 | }: GetDataParams): Promise> => { 170 | const searchUrl = getSearchUrl(provider, searchTerm); 171 | const { error, json: data } = await useData(searchUrl, 'json', headers); 172 | 173 | if (error) { 174 | interaction.reply(invalidResponse); 175 | return; 176 | } 177 | 178 | const sanitizedData = sanitizeData ? sanitizeData(data) : data; 179 | 180 | if (isInvalidData(sanitizedData)) { 181 | try { 182 | await interaction.reply(noResults(searchTerm)); 183 | } catch { 184 | interaction.editReply(noResults(searchTerm)); 185 | } 186 | 187 | // delayedMessageAutoDeletion(sentMessage); 188 | return; 189 | } 190 | 191 | return sanitizedData; 192 | }; 193 | --------------------------------------------------------------------------------