├── Procfile ├── docs ├── assets │ ├── createNewApp.png │ ├── configureDynos.png │ ├── connectToGitHub.png │ ├── manualDeployment.png │ ├── automaticDeployment.png │ └── environmentVariables.png └── heroku.md ├── types ├── index.ts ├── slashCommandConfig.ts ├── events.ts └── types.ts ├── events ├── index.ts ├── ready.ts ├── interactionCreate.ts └── messageReactionAdd.ts ├── test-utils ├── mock-server.ts ├── handlers │ ├── index.ts │ └── discord.ts ├── jest.env.ts ├── jest.setup.ts ├── InternalDiscordManager.ts └── setup.ts ├── .example.env ├── utils ├── index.ts ├── discordIdChecker.ts ├── getCleanDevID.ts ├── error.ts ├── urlChecker.ts ├── normalizeString.ts ├── classes.ts ├── resourceBuilder.ts ├── twitterHandle.ts └── airTableCalls.ts ├── jest.config.js ├── tsconfig.json ├── .github └── workflows │ └── main.yml ├── slashCommands ├── index.ts ├── addContributor.ts ├── addTag.ts ├── addCategory.ts ├── addBlockchain.ts ├── addGlossary.ts ├── addAuthor.ts └── addResource.ts ├── index.ts ├── __tests__ ├── commands │ └── addTag.ts └── twitterHandle.test.ts ├── package.json ├── .eslintrc.json ├── .gitignore └── README.md /Procfile: -------------------------------------------------------------------------------- 1 | worker: node dist/index.js 2 | -------------------------------------------------------------------------------- /docs/assets/createNewApp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-DAO/devie-bot/HEAD/docs/assets/createNewApp.png -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './events'; 2 | export * from './types'; 3 | export * from './slashCommandConfig' 4 | -------------------------------------------------------------------------------- /docs/assets/configureDynos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-DAO/devie-bot/HEAD/docs/assets/configureDynos.png -------------------------------------------------------------------------------- /docs/assets/connectToGitHub.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-DAO/devie-bot/HEAD/docs/assets/connectToGitHub.png -------------------------------------------------------------------------------- /docs/assets/manualDeployment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-DAO/devie-bot/HEAD/docs/assets/manualDeployment.png -------------------------------------------------------------------------------- /events/index.ts: -------------------------------------------------------------------------------- 1 | export * from './interactionCreate'; 2 | export * from './messageReactionAdd'; 3 | export * from './ready'; 4 | -------------------------------------------------------------------------------- /docs/assets/automaticDeployment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-DAO/devie-bot/HEAD/docs/assets/automaticDeployment.png -------------------------------------------------------------------------------- /docs/assets/environmentVariables.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-DAO/devie-bot/HEAD/docs/assets/environmentVariables.png -------------------------------------------------------------------------------- /test-utils/mock-server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from 'msw/node' 2 | import { handlers } from './handlers' 3 | 4 | export const server = setupServer(...handlers); 5 | -------------------------------------------------------------------------------- /test-utils/handlers/index.ts: -------------------------------------------------------------------------------- 1 | import { handlers as discordHandlers } from './discord' 2 | 3 | const allHandlers = [ 4 | ...discordHandlers, 5 | ] 6 | 7 | export { allHandlers as handlers } 8 | -------------------------------------------------------------------------------- /.example.env: -------------------------------------------------------------------------------- 1 | AIRTABLE_BASE= 2 | AIRTABLE_TOKEN= 3 | DISCORD_TOKEN= 4 | GUILD_ID= 5 | CLIENT_ID= 6 | 7 | CURATE_FROM= 8 | POST_TO= 9 | POST_THRESHOLD=5 10 | -------------------------------------------------------------------------------- /utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './airTableCalls'; 2 | export * from './classes'; 3 | export * from './discordIdChecker'; 4 | export * from './getCleanDevID'; 5 | export * from './resourceBuilder'; 6 | export * from './urlChecker'; 7 | -------------------------------------------------------------------------------- /utils/discordIdChecker.ts: -------------------------------------------------------------------------------- 1 | export default function isDiscordId(potentialDiscordId: string) { 2 | // Regex: /([0-9]{18})/ -> Ensures that ID is all numbers and is 18 digits long 3 | return /([0-9]{18})/.test(potentialDiscordId) 4 | } -------------------------------------------------------------------------------- /utils/getCleanDevID.ts: -------------------------------------------------------------------------------- 1 | export function getCleanDevID(devDAOID: string | null): number | undefined { 2 | if (devDAOID) { 3 | if (devDAOID.startsWith('#')) { 4 | devDAOID = devDAOID.slice(1); 5 | } 6 | return parseInt(devDAOID, 10); 7 | } 8 | 9 | return undefined; 10 | } 11 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | modulePathIgnorePatterns: ['/dist/'], 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | setupFiles: ['/test-utils/jest.env.ts'], 7 | setupFilesAfterEnv: ['/test-utils/jest.setup.ts'], 8 | }; 9 | -------------------------------------------------------------------------------- /test-utils/jest.env.ts: -------------------------------------------------------------------------------- 1 | process.env.DISCORD_TOKEN = 'FAKE_BOT_TOKEN'; 2 | process.env.AIRTABLE_TOKEN = 'FAKE_AIRTABLE_TOKEN'; 3 | process.env.AIRTABLE_BASE = 'FAKE_TABLE_NAME'; 4 | process.env.GUILD_ID = 'DEV_DAO_ID'; 5 | process.env.CLIENT_ID = 'FAKE_CLIENT_ID'; 6 | process.env.CURATE_FROM = '123'; 7 | process.env.POST_TO = '123'; 8 | process.env.POST_THRESHOLD = '123'; 9 | -------------------------------------------------------------------------------- /types/slashCommandConfig.ts: -------------------------------------------------------------------------------- 1 | import { RESTPostAPIApplicationCommandsJSONBody } from 'discord-api-types'; 2 | import { CommandInteraction } from 'discord.js'; 3 | 4 | export interface SlashCommandConfig { 5 | name: string; 6 | roles: string[]; 7 | commandJSON: () => RESTPostAPIApplicationCommandsJSONBody; 8 | execute: (interaction: CommandInteraction) => Promise; 9 | } 10 | -------------------------------------------------------------------------------- /utils/error.ts: -------------------------------------------------------------------------------- 1 | const HANDLED_ERROR_NAME = 'HandledError'; 2 | 3 | export function isHandledError(value: unknown): value is HandledError { 4 | return value instanceof Error && value.name === HANDLED_ERROR_NAME; 5 | } 6 | 7 | export default class HandledError extends Error { 8 | constructor(message: string) { 9 | super(message); 10 | this.name = HANDLED_ERROR_NAME; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /utils/urlChecker.ts: -------------------------------------------------------------------------------- 1 | export function isValidUrl(userInput: string) { 2 | let url_string; 3 | try { 4 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 5 | // @ts-ignore 6 | url_string = new URL(userInput); 7 | } 8 | catch (_) { 9 | return false; 10 | } 11 | return url_string.protocol === 'http:' || url_string.protocol === 'https:'; 12 | } -------------------------------------------------------------------------------- /utils/normalizeString.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * titleize makes the first letter of every word uppercase and the rest lowercase. 3 | * i.e.: 4 | * titleize("jAnE dOe") => "Jane Doe" 5 | * titleize("jane doe") => "Jane Doe" 6 | */ 7 | function titleize(value: string) { 8 | return value.toLowerCase().replace(/(?:^|\s|-)\S/g, x => x.toUpperCase()) 9 | } 10 | 11 | export function normalizeString(value:string) { 12 | return titleize(value.trim()); 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "es2021" 5 | ], 6 | "module": "commonjs", 7 | "target": "es2021", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "types": [ 13 | "node", 14 | "jest" 15 | ], 16 | "sourceMap": true, 17 | "inlineSourceMap": false, 18 | "inlineSources": true, 19 | "declaration": false, 20 | "outDir": "./dist", 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test-utils/jest.setup.ts: -------------------------------------------------------------------------------- 1 | import { InternalDiscordManager } from './setup'; 2 | import { server } from './mock-server'; 3 | 4 | beforeEach(() => jest.spyOn(Date, 'now')) 5 | beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) 6 | afterAll(() => server.close()); 7 | afterEach(() => { 8 | server.resetHandlers() 9 | InternalDiscordManager.cleanup() 10 | jest.restoreAllMocks() 11 | if (jest.isMockFunction(setTimeout)) { 12 | jest.runOnlyPendingTimers() 13 | jest.useRealTimers() 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Lint, build and test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | action: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-node@v2 17 | with: 18 | node-version: '16' 19 | - name: Install modules 20 | run: npm install 21 | - name: Run lint script 22 | run: npm run lint 23 | - name: Run test 24 | run: npm run test 25 | -------------------------------------------------------------------------------- /events/ready.ts: -------------------------------------------------------------------------------- 1 | import { Client } from 'discord.js'; 2 | import { ReadyEventConfig } from '../types'; 3 | 4 | export const name = 'ready'; 5 | export const once = true; 6 | 7 | export const ReadyEvent: ReadyEventConfig = { 8 | name: 'ready', 9 | once: true, 10 | execute: async (client: Client) => { 11 | if (client.user) { 12 | client.user.setActivity('gas fees going up', { type: 'WATCHING' }); 13 | client.user.setStatus('online'); 14 | console.log(`Ready! Logged in as ${client.user.tag}`); 15 | } 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /slashCommands/index.ts: -------------------------------------------------------------------------------- 1 | import { AddAuthorCommand } from './addAuthor'; 2 | import { AddBlockchainCommand } from './addBlockchain'; 3 | import { AddCategoryCommand } from './addCategory'; 4 | import { AddContributorCommand } from './addContributor'; 5 | import { AddGlossaryCommand } from './addGlossary'; 6 | import { AddResourceCommand } from './addResource'; 7 | import { AddTagCommand } from './addTag'; 8 | 9 | export const AllCommands = [ 10 | AddAuthorCommand, 11 | AddBlockchainCommand, 12 | AddCategoryCommand, 13 | AddContributorCommand, 14 | AddGlossaryCommand, 15 | AddResourceCommand, 16 | AddTagCommand, 17 | ]; 18 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { Intents, Collection } from 'discord.js'; 2 | import { discordClient } from './utils/classes'; 3 | import dotenv from 'dotenv'; 4 | 5 | dotenv.config() 6 | 7 | const botToken = process.env.DISCORD_TOKEN 8 | const client = new discordClient({ 9 | intents: [ 10 | Intents.FLAGS.GUILDS, 11 | Intents.FLAGS.GUILD_MESSAGES, 12 | Intents.FLAGS.DIRECT_MESSAGES, 13 | Intents.FLAGS.GUILD_MESSAGE_REACTIONS, 14 | ], 15 | partials: ['MESSAGE', 'CHANNEL', 'REACTION'], 16 | }); 17 | 18 | (async () => { 19 | console.log('Loading bot'); 20 | client.commands = new Collection(); 21 | await client.loadCommandsToServer(); 22 | client.loadCommandsToClient(); 23 | client.loadEventsToClient(); 24 | client.login(botToken); 25 | })(); 26 | -------------------------------------------------------------------------------- /__tests__/commands/addTag.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionTypes } from 'discord.js/typings/enums'; 2 | import { AddTagCommand } from '../../slashCommands/addTag'; 3 | import { setup, InternalDiscordManager } from '../../test-utils/setup'; 4 | 5 | describe('Command add-tag', () => { 6 | it('should not run if tag is null', async () => { 7 | const { createCommandInteraction } = await setup(); 8 | const interaction = createCommandInteraction( 9 | 'add-tag', 10 | [{ name: 'tag', type: ApplicationCommandOptionTypes.STRING, value: null }], 11 | ); 12 | 13 | await AddTagCommand.execute(interaction); 14 | 15 | const capturedMessageFromMockServer = InternalDiscordManager.interaction[interaction.id]; 16 | expect(capturedMessageFromMockServer.content).toEqual('Tag missing, please try again.'); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /types/events.ts: -------------------------------------------------------------------------------- 1 | import { ClientEvents } from 'discord.js'; 2 | 3 | export interface EventConfig { 4 | name: K, 5 | once: boolean, 6 | execute: (...args: ClientEvents[K]) => Promise 7 | } 8 | 9 | export interface ReadyEventConfig extends EventConfig<'ready'> { 10 | name: 'ready', 11 | once: true, 12 | execute: (...args: ClientEvents['ready']) => Promise 13 | } 14 | 15 | export interface InteractionCreateEventConfig extends EventConfig<'interactionCreate'> { 16 | name: 'interactionCreate', 17 | once: false, 18 | execute: (...args: ClientEvents['interactionCreate']) => Promise 19 | } 20 | 21 | export interface MessageReactionAddEventConfig extends EventConfig<'messageReactionAdd'> { 22 | name: 'messageReactionAdd', 23 | once: false, 24 | execute: (...args: ClientEvents['messageReactionAdd']) => Promise 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "engines": { 3 | "node": "16.6.0" 4 | }, 5 | "scripts": { 6 | "fmt": "eslint . --fix --ext .ts", 7 | "lint": "tsc --noEmit && eslint . --ext .ts", 8 | "dev": "ts-node index.ts", 9 | "build": "tsc", 10 | "postinstall": "tsc", 11 | "test": "jest" 12 | }, 13 | "dependencies": { 14 | "@discordjs/builders": "^0.8.1", 15 | "@discordjs/rest": "^0.1.0-canary.0", 16 | "airtable": "^0.11.1", 17 | "discord-api-types": "^0.24.0", 18 | "discord.js": "^13.3.0", 19 | "dotenv": "^10.0.0" 20 | }, 21 | "devDependencies": { 22 | "@types/jest": "^27.0.2", 23 | "@typescript-eslint/eslint-plugin": "^5.2.0", 24 | "@typescript-eslint/parser": "^5.2.0", 25 | "eslint": "^8.1.0", 26 | "jest": "^27.3.1", 27 | "msw": "^0.35.0", 28 | "ts-jest": "^27.0.7", 29 | "ts-node": "^10.4.0", 30 | "typescript": "^4.4.4" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /docs/heroku.md: -------------------------------------------------------------------------------- 1 | # Heroku 2 | 3 | ## Setup 4 | 5 | Create an account on [heroku](https://heroku.com). 6 | After signing in create a new app. 7 | 8 | ![create app](./assets/createNewApp.png) 9 | 10 | Select the `Settings` tab and configure the environment variables to connect to your discord bot and server. 11 | ![environment variables](./assets/environmentVariables.png) 12 | 13 | Connect the application to the [GitHub](https://github.com) code repository. 14 | ![connect to github](./assets/connectToGitHub.png) 15 | 16 | Deploy the application manually. 17 | ![manual deployment](./assets/manualDeployment.png) 18 | 19 | Select the `Resources` tab an configure the dynos to run the `worker` command. Edit the `web` dyno and disable it. Edit the `worker` dyno and enable it. 20 | ![dyno configuration](./assets/configureDynos.png) 21 | 22 | Configure automatic deployments. 23 | ![manual deployment](./assets/automaticDeployment.png) 24 | -------------------------------------------------------------------------------- /types/types.ts: -------------------------------------------------------------------------------- 1 | export type LookupItem = { 2 | id: string; 3 | name: string; 4 | } 5 | 6 | export type Resource = { 7 | contributor: string; 8 | author: string; 9 | title: string; 10 | summary: string; 11 | source: string; 12 | level: string; 13 | mediaType: string; 14 | blockchain?: string[]; 15 | category: string[]; 16 | tags: string[]; 17 | } 18 | 19 | export type Author = { 20 | name: string; 21 | isDaoMember: boolean; 22 | twitterUrl: string; 23 | youtubeUrl: string; 24 | } 25 | 26 | export type Glossary = { 27 | id: string; 28 | term: string; 29 | definition: string; 30 | } 31 | 32 | export type Contributor = { 33 | discordId: string; 34 | devDaoId?: string[]; 35 | ethAddress?: string; 36 | solAddress?: string; 37 | resources?: Resource[]; 38 | glossaries?: Glossary[]; 39 | author?: Author; 40 | blockchains?: LookupItem[]; 41 | categories?: LookupItem[]; 42 | tags?: LookupItem[]; 43 | } 44 | 45 | export type UTwitterHandleResponse = IValidTwitterHandleResponse | IInvalidTwitterHandleResponse; 46 | 47 | export interface IValidTwitterHandleResponse { 48 | isValid: true; 49 | handle: string; 50 | URL: string; 51 | } 52 | 53 | export interface IInvalidTwitterHandleResponse { 54 | isValid: false; 55 | } 56 | -------------------------------------------------------------------------------- /test-utils/InternalDiscordManager.ts: -------------------------------------------------------------------------------- 1 | import type * as TDiscord from 'discord.js' 2 | type DiscordManagerType = { 3 | channels: Record< 4 | string, 5 | {guild_id: string; id: string; type: number; deleted?: boolean} 6 | > 7 | guilds: Record 8 | clients: Array 9 | reactions: Record> 10 | interaction: Record, 11 | cleanup: () => void 12 | } 13 | 14 | /** 15 | * this is used by the API to get channel and guild by their ids. 16 | * This is useful to clean up all things after each test and 17 | * to enable API to access guilds and channels because we don't have a DB 18 | **/ 19 | const InternalDiscordManager: DiscordManagerType = { 20 | channels: {}, 21 | guilds: {}, 22 | clients: [], 23 | // map of message IDs to a map of reaction emoji names to the MessageReaction 24 | reactions: {}, 25 | interaction: {}, 26 | cleanup: () => { 27 | InternalDiscordManager.channels = {} 28 | InternalDiscordManager.guilds = {} 29 | InternalDiscordManager.reactions = {} 30 | InternalDiscordManager.clients.forEach(client => client.destroy()) 31 | }, 32 | } 33 | 34 | export { InternalDiscordManager } 35 | -------------------------------------------------------------------------------- /utils/classes.ts: -------------------------------------------------------------------------------- 1 | import { Client, Collection } from 'discord.js'; 2 | import { REST } from '@discordjs/rest'; 3 | import { Routes } from 'discord-api-types/v9'; 4 | import { AllCommands } from '../slashCommands'; 5 | import { ReadyEvent, MessageReactionAddEvent, InteractionCreateEvent } from '../events'; 6 | import { SlashCommandConfig } from '../types'; 7 | 8 | export class discordClient extends Client { 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | [key: string]: any 11 | commands: Collection = new Collection(); 12 | 13 | async loadCommandsToServer(): Promise { 14 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 15 | const rest = new REST({ version: '9' }).setToken(process!.env.DISCORD_TOKEN!) 16 | try { 17 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 18 | await rest.put(Routes.applicationGuildCommands(process.env.CLIENT_ID!, process!.env.GUILD_ID!), { 19 | body: AllCommands.map(command => command.commandJSON()), 20 | }); 21 | console.log('registered / commands'); 22 | } 23 | catch (error) { 24 | console.error(error); 25 | } 26 | } 27 | 28 | loadCommandsToClient() { 29 | for (let index = 0; index < AllCommands.length; index++) { 30 | const command = AllCommands[index]; 31 | this.commands.set(command.name, command); 32 | } 33 | } 34 | 35 | loadEventsToClient() { 36 | this.once(ReadyEvent.name, ReadyEvent.execute); 37 | this.on(InteractionCreateEvent.name, InteractionCreateEvent.execute); 38 | this.on(MessageReactionAddEvent.name, MessageReactionAddEvent.execute); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "parser": "@typescript-eslint/parser", 11 | "parserOptions": { 12 | "ecmaVersion": 2021, 13 | "sourceType": "module" 14 | }, 15 | "plugins": [ 16 | "@typescript-eslint" 17 | ], 18 | "rules": { 19 | "arrow-spacing": ["warn", { "before": true, "after": true }], 20 | "brace-style": ["error", "stroustrup", { "allowSingleLine": true }], 21 | "comma-dangle": ["error", "always-multiline"], 22 | "comma-spacing": "error", 23 | "comma-style": "error", 24 | "curly": ["error", "multi-line", "consistent"], 25 | "dot-location": ["error", "property"], 26 | "handle-callback-err": "off", 27 | "keyword-spacing": "error", 28 | "max-nested-callbacks": ["error", { "max": 4 }], 29 | "max-statements-per-line": ["error", { "max": 2 }], 30 | "no-console": "off", 31 | "no-empty-function": "error", 32 | "no-floating-decimal": "error", 33 | "no-inline-comments": "error", 34 | "no-lonely-if": "error", 35 | "no-multi-spaces": "error", 36 | "no-multiple-empty-lines": ["error", { "max": 2, "maxEOF": 1, "maxBOF": 0 }], 37 | "no-shadow": ["error", { "allow": ["err", "resolve", "reject"] }], 38 | "no-trailing-spaces": ["error"], 39 | "no-var": "error", 40 | "object-curly-spacing": ["error", "always"], 41 | "prefer-const": "error", 42 | "quotes": ["error", "single"], 43 | "space-before-blocks": "error", 44 | "space-before-function-paren": ["error", { 45 | "anonymous": "never", 46 | "named": "never", 47 | "asyncArrow": "always" 48 | }], 49 | "space-in-parens": "error", 50 | "space-infix-ops": "error", 51 | "space-unary-ops": "error", 52 | "spaced-comment": "error", 53 | "yoda": "error" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /__tests__/twitterHandle.test.ts: -------------------------------------------------------------------------------- 1 | import { createTwitterHandle } from '../utils/twitterHandle'; 2 | 3 | const emoji = '😀😃😄😂'; 4 | 5 | const inputs = [ 6 | { input: 'http://www.twitter.com/Developer_DAO', shouldBeValid: true }, 7 | { input: 'https://www.twitter.com/Developer_DAO', shouldBeValid: true }, 8 | { input: 'http://twitter.com/Developer_DAO', shouldBeValid: true }, 9 | { input: 'https://twitter.com/Developer_DAO', shouldBeValid: true }, 10 | { input: 'http://www.twitter.com/Developer_DAO/', shouldBeValid: true }, 11 | { input: 'http://www.twitter.com/Developer_DAO?test=fail', shouldBeValid: true }, 12 | { input: 'http://www.twitter.com/Developer_DAO?test=fail?test=fail?test=fail?test=fail', shouldBeValid: true }, 13 | { input: '@Developer_DAO', shouldBeValid: true }, 14 | { input: 'Developer_DAO', shouldBeValid: true }, 15 | { input: ' Developer_DAO ', shouldBeValid: true }, 16 | { input: ' Developer_DAO', shouldBeValid: true }, 17 | { input: 'Developer_DAO ', shouldBeValid: true }, 18 | { input: 'http:/www.twitter.com/Developer_DAO', shouldBeValid: true }, 19 | { input: 'htp://www.twitter.com/Developer_DAO', shouldBeValid: true }, 20 | { input: '//www.twitter.com/Developer_DAO', shouldBeValid: true }, 21 | { input: 'ftp://www.twitter.com/Developer_DAO', shouldBeValid: true }, 22 | { input: 'http:\\\\www.twitter.com/Developer_DAO', shouldBeValid: true }, 23 | { input: 'http://www.twitter.com/Developer _DAO', shouldBeValid: true }, 24 | { input: '@Developer _DAO', shouldBeValid: true }, 25 | { input: 'http://www.twitter.com/' + emoji, shouldBeValid: false }, 26 | { input: '@😀😃😄😂', shouldBeValid: false }, 27 | { input: '😀😃😄😂', shouldBeValid: false }, 28 | ]; 29 | 30 | describe('Twitter handle', () => { 31 | test.each(inputs)('$input', ({ input, shouldBeValid }) => { 32 | const twitterHandleResult = createTwitterHandle(input); 33 | expect(twitterHandleResult.isValid).toEqual(shouldBeValid); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /events/interactionCreate.ts: -------------------------------------------------------------------------------- 1 | import { GuildMemberRoleManager, Interaction } from 'discord.js'; 2 | import { discordClient } from '../utils'; 3 | import { InteractionCreateEventConfig } from '../types'; 4 | 5 | export const InteractionCreateEvent: InteractionCreateEventConfig = { 6 | name: 'interactionCreate', 7 | once: false, 8 | execute: async (interaction: Interaction) => { 9 | if (!interaction.isCommand() || interaction.user.bot) return; 10 | 11 | if (interaction.client instanceof discordClient) { 12 | const command = interaction.client.commands.get(interaction.commandName); 13 | 14 | if (!command) return; 15 | try { 16 | if (command.roles.length > 0) { 17 | if (interaction.member.roles instanceof GuildMemberRoleManager) { 18 | const hasRole = interaction.member.roles.cache.some(role => command.roles.includes(role.name.toLowerCase())); 19 | if (!hasRole) { 20 | return await interaction.reply({ content: 'You do not have permission to execute this command!', ephemeral: true }); 21 | } 22 | } 23 | else { 24 | const hasRole = interaction.member.roles.some((role) => command.roles.includes(role.toLowerCase())); 25 | if (!hasRole) { 26 | return await interaction.reply({ content: 'You do not have permission to execute this command!', ephemeral: true }); 27 | } 28 | } 29 | } 30 | await command.execute(interaction) 31 | console.log(`${interaction.user.tag} triggered an interaction.`); 32 | } 33 | catch (error) { 34 | console.error(error) 35 | try { 36 | await interaction.reply({ content: 'There was an error while executing this command!', ephemeral: true }); 37 | } 38 | catch (e) { 39 | console.error(e); 40 | } 41 | } 42 | } 43 | }, 44 | } 45 | -------------------------------------------------------------------------------- /events/messageReactionAdd.ts: -------------------------------------------------------------------------------- 1 | import { MessageReaction, PartialMessageReaction, TextChannel } from 'discord.js'; 2 | import { MessageReactionAddEventConfig } from '../types'; 3 | 4 | const { CURATE_FROM, POST_TO, POST_THRESHOLD } = process.env; 5 | if (CURATE_FROM === undefined) { 6 | throw new Error('Bot cannot function properly without `CURATE_FROM` being set.'); 7 | } 8 | 9 | if (POST_TO === undefined) { 10 | throw new Error('Bot cannot function properly without `POST_TO` being set.'); 11 | } 12 | 13 | if (POST_THRESHOLD === undefined) { 14 | throw new Error('Bot cannot function properly without `POST_THRESHOLD` being set.'); 15 | } 16 | 17 | const channelIds = CURATE_FROM.split(','); 18 | 19 | export const MessageReactionAddEvent: MessageReactionAddEventConfig = { 20 | name: 'messageReactionAdd', 21 | once: false, 22 | execute: async (reaction: MessageReaction | PartialMessageReaction) => { 23 | try { 24 | console.log(reaction) 25 | // When a reaction is received, check if the structure is partial 26 | if (reaction.partial) { 27 | await reaction.fetch(); 28 | } 29 | 30 | const { message, emoji, count } = reaction; 31 | const { channelId, author } = message; 32 | 33 | const sendMessage = () => { 34 | return channelIds.includes(channelId) && emoji.name === '📰' && count === parseInt(POST_THRESHOLD, 10); 35 | }; 36 | 37 | if (sendMessage()) { 38 | const channel = reaction.client.channels.cache.get(POST_TO) as TextChannel; 39 | if (channel) { 40 | const authorName = author ? author.username : 'unknown'; 41 | const formattedMessage = `This post received ${POST_THRESHOLD} 📰 reactions and will be considered for the newsletter! (Shared by @${authorName})\n${message.content}`; 42 | channel.send(formattedMessage); 43 | } 44 | } 45 | } 46 | catch (error) { 47 | console.error('Something went wrong when fetching the message:', error); 48 | return; 49 | } 50 | }, 51 | } 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | .env.production 76 | 77 | # parcel-bundler cache (https://parceljs.org/) 78 | .cache 79 | .parcel-cache 80 | 81 | # Next.js build output 82 | .next 83 | out 84 | 85 | # Nuxt.js build / generate output 86 | .nuxt 87 | dist 88 | 89 | # Gatsby files 90 | .cache/ 91 | # Comment in the public line in if your project uses Gatsby and not Next.js 92 | # https://nextjs.org/blog/next-9-1#public-directory-support 93 | # public 94 | 95 | # vuepress build output 96 | .vuepress/dist 97 | 98 | # Serverless directories 99 | .serverless/ 100 | 101 | # FuseBox cache 102 | .fusebox/ 103 | 104 | # DynamoDB Local files 105 | .dynamodb/ 106 | 107 | # TernJS port file 108 | .tern-port 109 | 110 | # Stores VSCode versions used for testing VSCode extensions 111 | .vscode-test 112 | 113 | # yarn v2 114 | .yarn/cache 115 | .yarn/unplugged 116 | .yarn/build-state.yml 117 | .yarn/install-state.gz 118 | .pnp.* 119 | -------------------------------------------------------------------------------- /utils/resourceBuilder.ts: -------------------------------------------------------------------------------- 1 | import { LookupItem, Resource } from '../types'; 2 | 3 | export class ResourceBuilderError extends Error {} 4 | 5 | export class ResourceBuilder { 6 | public contributor?: string; 7 | public author?: LookupItem; 8 | public title?: string; 9 | public summary?: string; 10 | public source?: string; 11 | public level?: string; 12 | public mediaType?: string; 13 | public blockchain?: LookupItem[]; 14 | public category?: LookupItem[]; 15 | public tags?: LookupItem[]; 16 | 17 | public build(): Resource { 18 | if (this.contributor == null || this.contributor === undefined) { 19 | throw new ResourceBuilderError('Unable to build incomplete Resource') 20 | } 21 | if (this.author == null || this.author === undefined) { 22 | throw new ResourceBuilderError('Unable to build incomplete Resource') 23 | } 24 | if (this.title == null || this.title === undefined || this.title.trim().length === 0) { 25 | throw new ResourceBuilderError('Unable to build incomplete Resource') 26 | } 27 | if (this.summary == null || this.summary === undefined || this.summary.trim().length === 0) { 28 | throw new ResourceBuilderError('Unable to build incomplete Resource') 29 | } 30 | if (this.source == null || this.source === undefined || this.source.trim().length === 0) { 31 | throw new ResourceBuilderError('Unable to build incomplete Resource') 32 | } 33 | if (this.level == null || this.level === undefined || this.level.trim().length === 0) { 34 | throw new ResourceBuilderError('Unable to build incomplete Resource') 35 | } 36 | if (this.mediaType == null || this.mediaType === undefined || this.mediaType.trim().length === 0) { 37 | throw new ResourceBuilderError('Unable to build incomplete Resource') 38 | } 39 | if (this.category == null || this.category === undefined) { 40 | throw new ResourceBuilderError('Unable to build incomplete Resource') 41 | } 42 | if (this.tags == null || this.tags === undefined) { 43 | throw new ResourceBuilderError('Unable to build incomplete Resource') 44 | } 45 | if (this.blockchain == null || this.blockchain === undefined) { 46 | throw new ResourceBuilderError('Unable to build incomplete Resource') 47 | } 48 | return { 49 | contributor: this.contributor, 50 | author: this.author.id, 51 | title: this.title.trim(), 52 | summary: this.summary.trim(), 53 | source: this.source.trim(), 54 | level: this.level.trim(), 55 | mediaType: this.mediaType.trim(), 56 | blockchain: this.blockchain.filter(bc => bc.id.toLowerCase() !== 'n/a').map(bc => bc.id), 57 | category: this.category.filter(c => c.id.toLowerCase() !== 'n/a').map(c => c.id), 58 | tags: this.tags.filter(t => t.id.toLowerCase() !== 'n/a').map(t => t.id), 59 | }; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /slashCommands/addContributor.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from '@discordjs/builders'; 2 | import { CommandInteraction } from 'discord.js'; 3 | import { SlashCommandConfig } from '../types'; 4 | import { isContributor, createContributor, getCleanDevID, isAirtableError } from '../utils'; 5 | import { isHandledError } from '../utils/error'; 6 | import { createTwitterHandle } from '../utils/twitterHandle'; 7 | 8 | export const AddContributorCommand: SlashCommandConfig = { 9 | name: 'add-contributor', 10 | roles: ['dev'], 11 | commandJSON: () => new SlashCommandBuilder() 12 | .setName('add-contributor') 13 | .setDescription('Add yourself to the contributor list') 14 | .addStringOption( 15 | option => option.setRequired(false) 16 | .setName('devdao-id') 17 | .setDescription('Enter your DevDAO ID (e.g. #2468)')) 18 | .addStringOption( 19 | option => option.setRequired(false) 20 | .setName('twitter') 21 | .setDescription('Enter your twitter handle (e.g. @developer_dao')).toJSON(), 22 | execute: async (interaction: CommandInteraction) => { 23 | if (await isContributor(interaction.user)) { 24 | interaction.reply({ content: 'Sorry! You can not add yourself because you are already a contributor!', ephemeral: true }); 25 | return; 26 | } 27 | 28 | const devDAOID = interaction.options.getString('devdao-id'); 29 | const devID = getCleanDevID(devDAOID); 30 | 31 | if (devID && isNaN(devID)) { 32 | return interaction.reply({ content: 'The DevDAO ID you provided is not valid, please try again.', ephemeral: true }); 33 | } 34 | 35 | let twitterHandle = interaction.options.getString('twitter'); 36 | if (twitterHandle) { 37 | const twitterResponse = createTwitterHandle(twitterHandle); 38 | if (!twitterResponse.isValid) { 39 | return interaction.reply({ content: 'The twitter handle you provided is not valid, please try again.', ephemeral: true }); 40 | } 41 | else { 42 | twitterHandle = twitterResponse.URL; 43 | } 44 | } 45 | try { 46 | await createContributor(interaction.user, devID, twitterHandle ?? undefined); 47 | interaction.reply({ content: 'You added yourself as a contributer! Congrats', ephemeral: true }); 48 | } 49 | catch (error) { 50 | let errorMessage = 'There was an error saving. Please try again.'; 51 | if (isAirtableError(error)) { 52 | errorMessage = 'There was an error from Airtable. Please try again.'; 53 | } 54 | if (isHandledError(error)) { 55 | errorMessage = error.message; 56 | } 57 | 58 | try { 59 | await interaction.followUp({ content: errorMessage, ephemeral: true }); 60 | } 61 | catch (e) { 62 | console.log('Error trying to follow up add-contributor', e); 63 | } 64 | } 65 | return; 66 | }, 67 | } 68 | -------------------------------------------------------------------------------- /utils/twitterHandle.ts: -------------------------------------------------------------------------------- 1 | // Twitter handles can only have ascii alphanumeric character and are limited to a length of 15 2 | 3 | import { UTwitterHandleResponse } from '../types'; 4 | 5 | // Handles _used_ to be limited to 20 characters, so there may be some out there that are that long 6 | function validTwitterUser(username: string): boolean { 7 | return /^[a-zA-Z0-9_]{1,20}$/.test(username); 8 | } 9 | 10 | function buildTwitterURL(handle: string): string { 11 | return `https://www.twitter.com/${handle}`; 12 | } 13 | 14 | /** 15 | * Attempts to extract a twitter handle from the input 16 | * 17 | * @param {string} handle The value received as user input 18 | * 19 | * @return {string} The result of cleaning the input 20 | */ 21 | function sanitiseTwitterHandle(handle: string): string { 22 | let clean = handle.trim(); 23 | // Remove any trailing '/'s 24 | if (clean.endsWith('/')) { 25 | clean = clean.slice(0, -1); 26 | } 27 | 28 | // Remove any URL Parameters 29 | while (clean.lastIndexOf('?') > 0) { 30 | clean = clean.slice(0, clean.lastIndexOf('?')); 31 | } 32 | 33 | // Remove everyhing part from the last part of the URL 34 | if (clean.lastIndexOf('/') > 0) { 35 | clean = clean.slice(clean.lastIndexOf('/') + 1); 36 | } 37 | 38 | // Remove any characters remaining that are not valid in a twitter handle (including the '@') 39 | return clean.replace(/\W/g, ''); 40 | } 41 | 42 | /** 43 | * Attempts to create a twitter handle and twitter URL from the provided input 44 | * 45 | * @example 46 | * createTwitterHandle('Developer_DAO'); 47 | * // return { isValid: true, handle: 'Developer_DAO', URL: 'https://www.twitter.com/Developer_DAO } 48 | * @example 49 | * createTwitterHandle('@Developer_DAO'); 50 | * // return { isValid: true, handle: 'Developer_DAO', URL: 'https://www.twitter.com/Developer_DAO } 51 | * @example 52 | * createTwitterHandle('http://www.twitter.com/Developer_DAO'); 53 | * // return { isValid: true, handle: 'Developer_DAO', URL: 'https://www.twitter.com/Developer_DAO } 54 | * @example 55 | * createTwitterHandle('this is really long and isnt a valid handle'); 56 | * // return { isValid: false } 57 | * @param {string} input This should be either a full twitter URL e.g. https://www.twitter.com/Developer_DAO or a valid twitter handle e.g. Developer_DAO or @Developer_DAO 58 | * @return {UTwitterHandleResponse} When valid inputs are entered returns isValid = true along with the handle and the URL for the twitter account. 59 | */ 60 | export function createTwitterHandle(input: string): UTwitterHandleResponse { 61 | const cleanHandle = sanitiseTwitterHandle(input); 62 | const validHandle = validTwitterUser(cleanHandle); 63 | if (validHandle) { 64 | const twitterUrl = buildTwitterURL(cleanHandle); 65 | return { isValid: true, handle: cleanHandle, URL: twitterUrl }; 66 | } 67 | else { 68 | return { isValid: false }; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DEVIE Bot 🤖 2 | >it's probably nothing 3 | 4 | ## TL;DR 5 | 6 | Devie is a discord bot by the devs for the devs. Currently acts as an aggregator of content for the #newsletter team. Also, this bot will allow members of the DAO to use `/slash` commands in Discord to interact with our Airtable knowledgebase. Will be expanded on greatly in the future. ✨ 7 | 8 | ## How it Works 9 | 10 | 1. Upvote posts in either `#🔮-probably-nothing` or `#learning-resources` by reacting with a 📰 (:newspaper:) 11 | 2. Five 📰 will trigger the bot to post into the `#newsletter` channel (might be updated) 12 | 3. The team will use these votes to further curate the newsletter 🤙 13 | 14 | ## Development Setup 15 | 16 | 1. Fork the [`devie-bot` repository](https://github.com/Developer-DAO/devie-bot) into your GitHub account 17 | 18 | 2. Clone the fork to your local system 19 | 20 | ```bash 21 | git clone git@github.com:YOUR_USER_NAME/devie-bot.git 22 | ``` 23 | 24 | 3. Install Node modules 25 | 26 | ```bash 27 | npm install 28 | ``` 29 | 30 | 4. Create a `.env` file at the root of the project 31 | 32 | ```bash 33 | touch .env 34 | ``` 35 | 36 | 5. Follow [this tutorial](https://discordjs.guide/preparations/setting-up-a-bot-application.html) to create a Discord bot. Then, update your `.env` with the `DISCORD_TOKEN`, `CLIENT_ID` values created during the tutorial. 37 | 38 | ```bash 39 | # .env 40 | 41 | DISCORD_TOKEN=abc 42 | CLIENT_ID=123 43 | ``` 44 | 45 | 6. We also need to add our `GUILD_ID` to the `.env` file. We also need `POST_TO`, `CURATE_FROM` and `POST_THRESHOLD` for the curation portion. In discord, with developer mode enabled, right clicking any avatar or channel name will present a `Copy ID` option. 46 | 47 | ```bash 48 | # .env 49 | 50 | GUILD_ID=xyz 51 | POST_TO=id 52 | CURATE_FROM=id,id,id 53 | POST_THRESHOLD=5 54 | ``` 55 | 56 | 7. Start the bot server 57 | 58 | ```bash 59 | npm run dev 60 | ``` 61 | 62 | Now, you can test out the slash commands you've created in the Discord server where you installed the bot. 63 | 64 | ## Commands 65 | 66 | These are the current commands the bot supports: 67 | 68 | - `/add-author` - Add an author to the knowledge base 69 | - `/add-blockchain` - Add a blockchain to the knowledge base 70 | - `/add-category` - Add a category to the knowledge base 71 | - `/add-contributor` - Add a category to the knowledge base 72 | - `/add-resource` - Add a new resource to the knowledge base 73 | - `/add-tag` - Add a tag to the knowledge base 74 | - `/add-glossary` - Add a glossary term and description to the knowledge base 75 | 76 | ## Linting 77 | 78 | To check if your code will compile and is linted appropriately, you can run: 79 | 80 | ``` 81 | npm run lint 82 | ``` 83 | 84 | ## Production deployment 85 | 86 | The bot is deployed to [Heroku](https://heroku.com) 87 | 88 | See [Heroku](docs/heroku.md) for more information. 89 | 90 | ## Support 91 | 92 | Please reach out in the bot channel for support: `DAO PROJECTS > discord-bot` 93 | -------------------------------------------------------------------------------- /slashCommands/addTag.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from '@discordjs/builders'; 2 | import { CommandInteraction, Message, MessageActionRow, MessageButton } from 'discord.js'; 3 | import { SlashCommandConfig } from '../types'; 4 | import { createTag, findTagByName, isAirtableError } from '../utils'; 5 | import { isHandledError } from '../utils/error'; 6 | 7 | export const AddTagCommand: SlashCommandConfig = { 8 | name: 'add-tag', 9 | roles: ['dev'], 10 | commandJSON: () => new SlashCommandBuilder() 11 | .setName('add-tag') 12 | .setDescription('Adds a tag to the knowledge base') 13 | .addStringOption( 14 | option => option.setRequired(true) 15 | .setName('tag') 16 | .setDescription('Enter a tag')).toJSON(), 17 | execute: async (interaction: CommandInteraction) => { 18 | const tag = interaction.options.getString('tag') 19 | const REPLY = { 20 | YES: 'yes', 21 | NO: 'no', 22 | }; 23 | 24 | const noButton = new MessageButton() 25 | .setCustomId(REPLY.NO) 26 | .setLabel('Cancel') 27 | .setStyle('DANGER'); 28 | const yesButton = new MessageButton() 29 | .setCustomId(REPLY.YES) 30 | .setLabel('Yes, add tag') 31 | .setStyle('PRIMARY'); 32 | const buttonRow = new MessageActionRow() 33 | .addComponents( 34 | noButton, 35 | yesButton, 36 | ); 37 | 38 | if (tag === undefined || tag == null) { 39 | await interaction.reply({ content: 'Tag missing, please try again.', ephemeral: true }); 40 | return; 41 | } 42 | 43 | await interaction.reply({ 44 | content: `Are you sure you want to add \`${tag.trim()}\`?`, 45 | components: [buttonRow], 46 | ephemeral: true, 47 | }); 48 | 49 | const interactionMessage = await interaction.fetchReply(); 50 | 51 | if (!(interactionMessage instanceof Message)) { return; } 52 | 53 | const buttonReply = await interactionMessage.awaitMessageComponent({ componentType: 'BUTTON' }); 54 | if (!buttonReply) { 55 | return; 56 | } 57 | 58 | const buttonSelected = buttonReply.customId; 59 | buttonReply.update({ components: [] }); 60 | if (buttonSelected === REPLY.NO) { 61 | buttonReply.followUp({ 62 | content: `"${tag.trim()}" was not added`, 63 | ephemeral: true, 64 | }) 65 | return; 66 | } 67 | else { 68 | try { 69 | const foundTag = await findTagByName(tag.trim()); 70 | if (foundTag) { 71 | await interaction.editReply('This tag is already registered.'); 72 | } 73 | else { 74 | await createTag(tag.trim()); 75 | await interaction.editReply('Thank you. The tag has been added.'); 76 | } 77 | } 78 | catch (error) { 79 | let errorMessage = 'There was an error saving. Please try again.'; 80 | if (isAirtableError(error)) { 81 | errorMessage = 'There was an error from Airtable. Please try again.'; 82 | } 83 | if (isHandledError(error)) { 84 | errorMessage = error.message; 85 | } 86 | 87 | try { 88 | await interaction.followUp({ content: errorMessage, ephemeral: true }); 89 | } 90 | catch (e) { 91 | console.log('Error trying to follow up add-tag', e); 92 | } 93 | } 94 | } 95 | }, 96 | } 97 | -------------------------------------------------------------------------------- /test-utils/handlers/discord.ts: -------------------------------------------------------------------------------- 1 | import type * as TDiscord from 'discord.js' 2 | import { rest } from 'msw' 3 | import { SnowflakeUtil } from 'discord.js' 4 | import { InternalDiscordManager } from '../setup' 5 | 6 | /** 7 | * Handlers to mock the calls to the discord API, 8 | * which are used to set up the client for testing. 9 | */ 10 | const handlers = [ 11 | rest.post('*/api/:apiVersion/guilds/:guild/channels', (req, res, ctx) => { 12 | const createdChannel = { 13 | id: SnowflakeUtil.generate(), 14 | guild_id: req.params.guild, 15 | ...(req.body as {type: number}), 16 | } 17 | 18 | InternalDiscordManager.channels[createdChannel.id] = { 19 | ...createdChannel, 20 | } 21 | 22 | return res(ctx.status(200), ctx.json(createdChannel)) 23 | }), 24 | rest.put( 25 | '*/api/:apiVersion/guilds/:guildId/members/:memberId/roles/:roleId', 26 | (req, res, ctx) => { 27 | const { guildId, memberId, roleId } = req.params 28 | requiredParam(guildId, 'guildId param required') 29 | requiredParam(memberId, 'memberId param required') 30 | requiredParam(roleId, 'roleId param required') 31 | 32 | const guild = InternalDiscordManager.guilds[guildId]; 33 | if (!guild) { 34 | throw new Error(`No guild with the ID of ${guildId}`) 35 | } 36 | 37 | const user = Array.from(guild.members.cache.values()).find( 38 | guildMember => guildMember.user.id === memberId, 39 | ) as TDiscord.GuildMember & {_roles: Array} 40 | 41 | const assignedRole = guild.roles.cache.get(roleId) 42 | if (!assignedRole) { 43 | throw new Error(`No role with the ID of ${roleId}`) 44 | } 45 | user._roles.push(assignedRole.id) 46 | return res(ctx.status(200), ctx.json({ id: memberId })) 47 | }, 48 | ), 49 | rest.patch( 50 | '*/api/:apiVersion/guilds/:guildId/members/:memberId', 51 | (req, res, ctx) => { 52 | const { guildId, memberId } = req.params 53 | const { roles } = req.body as { roles: string[] }; 54 | requiredParam(guildId, 'guildId param required') 55 | requiredParam(memberId, 'memberId param required') 56 | 57 | const guild = InternalDiscordManager.guilds[guildId]; 58 | if (!guild) { 59 | throw new Error(`No guild with the ID of ${guildId}`) 60 | } 61 | 62 | const user = Array.from(guild.members.cache.values()).find( 63 | guildMember => guildMember.user.id === memberId, 64 | ) as TDiscord.GuildMember & {_roles: Array} 65 | user._roles = roles; 66 | 67 | return res(ctx.status(200), ctx.json({ id: memberId })) 68 | }, 69 | ), 70 | rest.post('*/api/:apiVersion/interactions/:interactionId//callback', (req, res, ctx) => { 71 | const { interactionId } = req.params; 72 | const { type, data: { content } } = req.body as { type: TDiscord.InteractionType, data: { content: string } }; 73 | requiredParam(interactionId, 'interactionId param required') 74 | 75 | InternalDiscordManager.interaction[interactionId] = { 76 | type, 77 | content, 78 | }; 79 | 80 | return res(ctx.status(200), ctx.json({})) 81 | }), 82 | ] 83 | 84 | function requiredParam( 85 | value: unknown, 86 | message: string, 87 | ): asserts value is string { 88 | if (typeof value !== 'string') throw new Error(message) 89 | } 90 | 91 | export { handlers } 92 | -------------------------------------------------------------------------------- /slashCommands/addCategory.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from '@discordjs/builders'; 2 | import { CommandInteraction, Message, MessageActionRow, MessageButton } from 'discord.js'; 3 | import { SlashCommandConfig } from '../types'; 4 | import { createCategory, findCategoryByName, isAirtableError } from '../utils'; 5 | import { isHandledError } from '../utils/error'; 6 | 7 | export const AddCategoryCommand: SlashCommandConfig = { 8 | name: 'add-category', 9 | roles: ['dev'], 10 | commandJSON: () => new SlashCommandBuilder() 11 | .setName('add-category') 12 | .setDescription('Adds a category to the knowledge base') 13 | .addStringOption( 14 | option => option.setRequired(true) 15 | .setName('category') 16 | .setDescription('Enter a category')).toJSON(), 17 | execute: async (interaction: CommandInteraction) => { 18 | const category = interaction.options.getString('category') 19 | const REPLY = { 20 | YES: 'yes', 21 | NO: 'no', 22 | }; 23 | 24 | const noButton = new MessageButton() 25 | .setCustomId(REPLY.NO) 26 | .setLabel('Cancel') 27 | .setStyle('DANGER'); 28 | const yesButton = new MessageButton() 29 | .setCustomId(REPLY.YES) 30 | .setLabel('Add category') 31 | .setStyle('PRIMARY'); 32 | const buttonRow = new MessageActionRow() 33 | .addComponents( 34 | noButton, 35 | yesButton, 36 | ); 37 | 38 | if (category === undefined || category == null) { 39 | interaction.reply({ content: 'Category missing, please try again.', ephemeral: true }); 40 | return; 41 | } 42 | 43 | await interaction.reply({ 44 | content: `Are you sure you want to add \`${category.trim()}\`?`, 45 | components: [buttonRow], 46 | ephemeral: true, 47 | }); 48 | 49 | const interactionMessage = await interaction.fetchReply(); 50 | 51 | if (!(interactionMessage instanceof Message)) { return; } 52 | 53 | const buttonReply = await interaction.channel?.awaitMessageComponent({ componentType: 'BUTTON' }); 54 | if (!buttonReply) { 55 | return; 56 | } 57 | 58 | const buttonSelected = buttonReply.customId; 59 | buttonReply.update({ components: [] }); 60 | if (buttonSelected === REPLY.NO) { 61 | buttonReply.followUp({ 62 | content: `"${category.trim()}" was not added`, 63 | ephemeral: true, 64 | }) 65 | return; 66 | } 67 | else { 68 | try { 69 | const foundCategory = await findCategoryByName(category.trim()); 70 | if (foundCategory) { 71 | await interaction.editReply('This category is already registered.'); 72 | } 73 | else { 74 | await createCategory(category.trim()); 75 | await interaction.editReply('Thank you. The category has been added.'); 76 | } 77 | } 78 | catch (e) { 79 | let errorMessage = 'There was an error saving. Please try again.'; 80 | if (isAirtableError(e)) { 81 | errorMessage = 'There was an error from Airtable. Please try again.'; 82 | } 83 | if (isHandledError(e)) { 84 | errorMessage = e.message; 85 | } 86 | 87 | try { 88 | await interaction.followUp({ content: errorMessage, ephemeral: true }); 89 | } 90 | catch (error) { 91 | console.log('Error trying to follow up add-category', error); 92 | } 93 | } 94 | } 95 | }, 96 | } 97 | -------------------------------------------------------------------------------- /slashCommands/addBlockchain.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from '@discordjs/builders'; 2 | import { CommandInteraction, Message, MessageActionRow, MessageButton } from 'discord.js'; 3 | import { SlashCommandConfig } from '../types'; 4 | import { createBlockchain, findBlockchainByName, isAirtableError } from '../utils'; 5 | import { isHandledError } from '../utils/error'; 6 | 7 | export const AddBlockchainCommand: SlashCommandConfig = { 8 | name: 'add-blockchain', 9 | roles: ['dev'], 10 | commandJSON: () => new SlashCommandBuilder() 11 | .setName('add-blockchain') 12 | .setDescription('Adds a blockchain to the knowledge base') 13 | .addStringOption( 14 | option => option.setRequired(true) 15 | .setName('blockchain') 16 | .setDescription('Enter a blockchain')) 17 | .addStringOption( 18 | option => option.setRequired(false) 19 | .setName('website') 20 | .setDescription('Enter blockchain website')).toJSON(), 21 | execute: async (interaction: CommandInteraction) => { 22 | const blockchain = interaction.options.getString('blockchain'); 23 | const website = interaction.options.getString('website'); 24 | const REPLY = { 25 | YES: 'yes', 26 | NO: 'no', 27 | }; 28 | 29 | const noButton = new MessageButton() 30 | .setCustomId(REPLY.NO) 31 | .setLabel('Cancel') 32 | .setStyle('DANGER'); 33 | const yesButton = new MessageButton() 34 | .setCustomId(REPLY.YES) 35 | .setLabel('Add blockchain') 36 | .setStyle('PRIMARY'); 37 | const buttonRow = new MessageActionRow() 38 | .addComponents( 39 | noButton, 40 | yesButton, 41 | ); 42 | 43 | if (blockchain === undefined || blockchain == null) { 44 | interaction.reply({ content: 'Blockchain missing, please try again.', ephemeral: true }); 45 | return; 46 | } 47 | 48 | await interaction.reply({ 49 | content: `Are you sure you want to add \`${blockchain.trim()}\`?`, 50 | components: [buttonRow], 51 | ephemeral: true, 52 | }); 53 | 54 | const interactionMessage = await interaction.fetchReply(); 55 | 56 | if (!(interactionMessage instanceof Message)) { return; } 57 | 58 | const buttonReply = await interactionMessage.awaitMessageComponent({ componentType: 'BUTTON' }); 59 | if (!buttonReply) { 60 | return; 61 | } 62 | 63 | const buttonSelected = buttonReply.customId; 64 | buttonReply.update({ components: [] }); 65 | if (buttonSelected === REPLY.NO) { 66 | buttonReply.followUp({ 67 | content: `"${blockchain.trim()}" was not added`, 68 | ephemeral: true, 69 | }) 70 | return; 71 | } 72 | else { 73 | try { 74 | const foundChain = await findBlockchainByName(blockchain.trim()); 75 | if (foundChain) { 76 | await interaction.editReply('This blockchain is already registered.'); 77 | } 78 | else { 79 | await createBlockchain(blockchain.trim(), website ? website.trim() : website); 80 | await interaction.editReply('Thank you. The blockchain has been added.'); 81 | } 82 | } 83 | catch (error) { 84 | let errorMessage = 'There was an error saving. Please try again.'; 85 | if (isAirtableError(error)) { 86 | errorMessage = 'There was an error from Airtable. Please try again.'; 87 | } 88 | if (isHandledError(error)) { 89 | errorMessage = error.message; 90 | } 91 | 92 | try { 93 | await interaction.followUp({ content: errorMessage, ephemeral: true }); 94 | } 95 | catch (e) { 96 | console.log('Error trying to follow up add-blockchain', e); 97 | } 98 | } 99 | } 100 | }, 101 | } 102 | -------------------------------------------------------------------------------- /test-utils/setup.ts: -------------------------------------------------------------------------------- 1 | import type * as TDiscord from 'discord.js'; 2 | import Discord, { SnowflakeUtil, CommandInteraction } from 'discord.js'; 3 | import { InternalDiscordManager } from './InternalDiscordManager'; 4 | 5 | export const client = new Discord.Client({ intents: [] }); 6 | Object.assign(client, { 7 | token: process.env.DISCORD_TOKEN, 8 | // @ts-expect-error -- private constructor 9 | user: new Discord.ClientUser(client, { 10 | id: SnowflakeUtil.generate(), 11 | bot: true, 12 | username: 'BOT', 13 | }), 14 | }); 15 | 16 | function createFakeClient() { 17 | // @ts-expect-error -- private constructor 18 | const guild = new Discord.Guild(client, { 19 | id: SnowflakeUtil.generate(), 20 | name: 'Developer_DAO', 21 | }); 22 | 23 | client.guilds.cache.set(guild.id, guild); 24 | InternalDiscordManager.guilds[guild.id] = guild 25 | 26 | const createRole = (name: string): TDiscord.Role => { 27 | // @ts-expect-error -- private constructor 28 | const role = new Discord.Role( 29 | client, 30 | { id: guild.id, name }, 31 | guild, 32 | ); 33 | 34 | guild.roles.cache.set(guild.id, role); 35 | 36 | return role; 37 | }; 38 | 39 | const createMember = async (username: string, role: TDiscord.Role, options = {}): Promise => { 40 | // @ts-expect-error -- private constructor 41 | const newMember = new Discord.GuildMember(client, { nick: username }, guild) 42 | // @ts-expect-error -- private constructor 43 | newMember.user = new Discord.User(client, { 44 | id: SnowflakeUtil.generate(), 45 | username, 46 | discriminator: client.users.cache.size, 47 | ...options, 48 | }); 49 | 50 | guild.members.cache.set(newMember.id, newMember); 51 | client.users.cache.set(newMember.id, newMember.user); 52 | 53 | await newMember.roles.add([role]); 54 | return newMember 55 | }; 56 | 57 | const createChannel = async (name: string, type = 'category') => { 58 | const channel = await guild.channels.create(name, { 59 | type, 60 | }); 61 | 62 | guild.channels.cache.set(channel.id, channel); 63 | 64 | return channel; 65 | }; 66 | 67 | const createCommandInteraction = (channel: TDiscord.Channel, member: TDiscord.GuildMember, devRole: TDiscord.Role) => 68 | (command: string, options: unknown[] = []): TDiscord.CommandInteraction => { 69 | // @ts-expect-error -- private constructor 70 | return new CommandInteraction(client, { 71 | id: SnowflakeUtil.generate(), 72 | application_id: '', 73 | guild: guild, 74 | guild_id: guild.id, 75 | type: 2, 76 | token: '', 77 | version: 1, 78 | channel_id: channel.id, 79 | // When using `member` directly, the creation was not working 80 | // for some reason. Changing to this hardcoded object worked. 81 | member: { 82 | permissions: '', 83 | deaf: false, 84 | mute: false, 85 | joined_at: '0', 86 | roles: [devRole.id], 87 | user: { 88 | id: member.user.id, 89 | discriminator: member.user.discriminator, 90 | username: member.user.username, 91 | avatar: member.user.avatar, 92 | }, 93 | }, 94 | user: member.user, 95 | data: { 96 | id: SnowflakeUtil.generate(), 97 | name: command, 98 | options, 99 | resolved: {}, 100 | guild_id: guild.id, 101 | }, 102 | }); 103 | }; 104 | 105 | function cleanup() { 106 | InternalDiscordManager.cleanup() 107 | } 108 | 109 | InternalDiscordManager.clients.push(client) 110 | 111 | return { 112 | client, 113 | guild, 114 | createRole, 115 | createMember, 116 | createChannel, 117 | createCommandInteraction, 118 | cleanup, 119 | }; 120 | } 121 | 122 | async function setup() { 123 | const { 124 | createRole, 125 | createMember, 126 | createChannel, 127 | createCommandInteraction, 128 | cleanup, 129 | } = createFakeClient(); 130 | 131 | const devRole = createRole('dev'); 132 | const member = await createMember('fakeUser', devRole); 133 | const channel = await createChannel('intro'); 134 | return { 135 | createCommandInteraction: createCommandInteraction(channel, member, devRole), 136 | cleanup, 137 | }; 138 | } 139 | 140 | export { InternalDiscordManager, setup }; 141 | -------------------------------------------------------------------------------- /slashCommands/addGlossary.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from '@discordjs/builders'; 2 | import { CommandInteraction, Message, MessageActionRow, MessageButton } from 'discord.js'; 3 | import { createGlossaryTerm, findGlossaryTermByName, isAirtableError } from '../utils'; 4 | import { isValidUrl } from '../utils/urlChecker'; 5 | import { isHandledError } from '../utils/error'; 6 | import { SlashCommandConfig } from '../types'; 7 | 8 | export const AddGlossaryCommand: SlashCommandConfig = { 9 | name: 'add-glossary', 10 | roles: ['dev'], 11 | commandJSON: () => new SlashCommandBuilder() 12 | .setName('add-glossary') 13 | .setDescription('Adds a glossary term to the knowledge base') 14 | .addStringOption( 15 | option => option.setRequired(true) 16 | .setName('term') 17 | .setDescription('Enter a term')) 18 | .addStringOption( 19 | option => option.setRequired(true) 20 | .setName('description') 21 | .setDescription('Enter a description for the term')) 22 | .addStringOption( 23 | option => option.setRequired(true) 24 | .setName('website') 25 | .setDescription('Enter a link to a resource where people can find out more info on this term.')).toJSON(), 26 | execute: async (interaction: CommandInteraction) => { 27 | const term = interaction.options.getString('term'); 28 | const description = interaction.options.getString('description'); 29 | const website = interaction.options.getString('website'); 30 | const REPLY = { 31 | YES: 'yes', 32 | NO: 'no', 33 | }; 34 | 35 | const noButton = new MessageButton() 36 | .setCustomId(REPLY.NO) 37 | .setLabel('Cancel') 38 | .setStyle('DANGER'); 39 | const yesButton = new MessageButton() 40 | .setCustomId(REPLY.YES) 41 | .setLabel('Add term') 42 | .setStyle('PRIMARY'); 43 | const buttonRow = new MessageActionRow() 44 | .addComponents( 45 | noButton, 46 | yesButton, 47 | ); 48 | 49 | if (term === undefined || term == null) { 50 | interaction.reply({ content: 'Term missing, please try again.', ephemeral: true }); 51 | return; 52 | } 53 | 54 | if (description === undefined || description == null) { 55 | interaction.reply({ content: 'Description missing, please try again.', ephemeral: true }); 56 | return; 57 | } 58 | 59 | if (website === undefined || website == null || !isValidUrl(website.trim())) { 60 | interaction.reply({ content: 'Website missing or invalid link, please try again.', ephemeral: true }); 61 | return; 62 | } 63 | 64 | await interaction.reply({ 65 | content: `Are you sure you want to add \`${term.trim()}\`?`, 66 | components: [buttonRow], 67 | ephemeral: true, 68 | }); 69 | 70 | const interactionMessage = await interaction.fetchReply(); 71 | 72 | if (!(interactionMessage instanceof Message)) { return; } 73 | 74 | const buttonReply = await interaction.channel?.awaitMessageComponent({ componentType: 'BUTTON' }); 75 | if (!buttonReply) { 76 | return; 77 | } 78 | 79 | const buttonSelected = buttonReply.customId; 80 | buttonReply.update({ components: [] }); 81 | if (buttonSelected === REPLY.NO) { 82 | buttonReply.followUp({ 83 | content: `"${term.trim()}" was not added`, 84 | ephemeral: true, 85 | }) 86 | return; 87 | } 88 | else { 89 | try { 90 | const foundChain = await findGlossaryTermByName(term.trim()); 91 | if (foundChain) { 92 | await interaction.editReply('This term is already registered.'); 93 | } 94 | else { 95 | await createGlossaryTerm(term.trim(), description.trim(), website.trim()); 96 | await interaction.editReply('Thank you. The term has been added.'); 97 | } 98 | } 99 | catch (error) { 100 | let errorMessage = 'There was an error saving. Please try again.'; 101 | if (isAirtableError(error)) { 102 | errorMessage = 'There was an error from Airtable. Please try again.'; 103 | } 104 | if (isHandledError(error)) { 105 | errorMessage = error.message; 106 | } 107 | 108 | try { 109 | await interaction.followUp({ content: errorMessage, ephemeral: true }); 110 | } 111 | catch (e) { 112 | console.log('Error trying to follow up add-glossary', e); 113 | } 114 | } 115 | } 116 | }, 117 | } 118 | -------------------------------------------------------------------------------- /slashCommands/addAuthor.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from '@discordjs/builders'; 2 | import { CommandInteraction, Message, MessageActionRow, MessageButton, MessageEmbed } from 'discord.js'; 3 | import { Author as AuthorInfo, SlashCommandConfig } from '../types'; 4 | import { createAuthor, isAirtableError } from '../utils/airTableCalls'; 5 | import HandledError, { isHandledError } from '../utils/error'; 6 | import { createTwitterHandle } from '../utils/twitterHandle'; 7 | import { isValidUrl } from '../utils/urlChecker'; 8 | 9 | function getSanitizedAuthorInfo(interaction: CommandInteraction): AuthorInfo { 10 | const authorName = interaction.options.getString('author'); 11 | const isDaoMember = interaction.options.getBoolean('is_dao_member'); 12 | const twitterUrl = interaction.options.getString('twitter_url'); 13 | const youtubeUrl = interaction.options.getString('youtube_url'); 14 | 15 | if (authorName === null || isDaoMember === null) { 16 | // The discord bot *should* handle this, but just in case. 17 | throw new HandledError('Missing required fields'); 18 | } 19 | 20 | return { 21 | name: authorName, 22 | isDaoMember, 23 | twitterUrl: twitterUrl || '', 24 | youtubeUrl: youtubeUrl || '', 25 | } 26 | } 27 | 28 | export const AddAuthorCommand: SlashCommandConfig = { 29 | name: 'add-author', 30 | roles: ['dev'], 31 | commandJSON: () => { 32 | return new SlashCommandBuilder() 33 | .setName('add-author') 34 | .setDescription('Adds an author to the knowledge base') 35 | .addStringOption( 36 | option => option.setRequired(true) 37 | .setName('author') 38 | .setDescription('Enter the author full name'), 39 | ) 40 | .addBooleanOption(option => 41 | option.setRequired(true) 42 | .setName('is_dao_member') 43 | .setDescription('Are they a Developer DAO member?'), 44 | ) 45 | .addStringOption( 46 | option => option.setRequired(false) 47 | .setName('twitter_url') 48 | .setDescription('Enter their Twitter URL'), 49 | ) 50 | .addStringOption( 51 | option => option.setRequired(false) 52 | .setName('youtube_url') 53 | .setDescription('Enter their YouTube URL'), 54 | ).toJSON() 55 | }, 56 | execute: async (interaction: CommandInteraction) => { 57 | const REPLY = { 58 | YES: 'yes', 59 | NO: 'no', 60 | }; 61 | 62 | const author = getSanitizedAuthorInfo(interaction); 63 | const { name, isDaoMember, youtubeUrl } = author; 64 | let { twitterUrl } = author; 65 | 66 | const invalidUrlType = []; 67 | 68 | if (twitterUrl !== '') { 69 | const twitterResponse = createTwitterHandle(twitterUrl); 70 | if (twitterResponse.isValid) { 71 | twitterUrl = twitterResponse.URL; 72 | author.twitterUrl = twitterUrl; 73 | } 74 | else { 75 | invalidUrlType.push(`Twitter URL (${twitterUrl})`); 76 | } 77 | } 78 | 79 | if (youtubeUrl !== '' && !isValidUrl(youtubeUrl)) { 80 | invalidUrlType.push(`YouTube URL (${youtubeUrl})`); 81 | } 82 | 83 | if (invalidUrlType.length > 0) { 84 | await interaction.reply({ 85 | content: `Invalid ${invalidUrlType.join(' and')}`, 86 | ephemeral: true, 87 | }); 88 | return; 89 | } 90 | 91 | const blankSpaceField = '\u200b'; 92 | const authorEmbed = new MessageEmbed() 93 | .setColor('#0099ff') 94 | .setTitle('Add author?') 95 | .setDescription('Please review the information.') 96 | .addField(blankSpaceField, blankSpaceField) 97 | .addFields( 98 | { name: 'Name', value: name }, 99 | { name: 'Author is Dao Member?', value: `${isDaoMember ? 'Yes' : 'No'}` }, 100 | { name: 'Twitter URL', value: `${twitterUrl === '' ? 'Not provided' : twitterUrl}` }, 101 | { name: 'Youtube URL', value: `${youtubeUrl === '' ? 'Not provided' : youtubeUrl}` }, 102 | ) 103 | .addField(blankSpaceField, blankSpaceField) 104 | .setTimestamp(); 105 | 106 | const noButton = new MessageButton() 107 | .setCustomId(REPLY.NO) 108 | .setLabel('No, cancel') 109 | .setStyle('DANGER'); 110 | const yesButton = new MessageButton() 111 | .setCustomId(REPLY.YES) 112 | .setLabel('Yes, add author') 113 | .setStyle('PRIMARY'); 114 | const buttonRow = new MessageActionRow() 115 | .addComponents( 116 | noButton, 117 | yesButton, 118 | ); 119 | 120 | await interaction.reply({ 121 | embeds: [authorEmbed], 122 | components: [buttonRow], 123 | ephemeral: true, 124 | }); 125 | 126 | const interactionMessage = await interaction.fetchReply(); 127 | 128 | if (!(interactionMessage instanceof Message)) { return; } 129 | 130 | const buttonReply = await interactionMessage.awaitMessageComponent({ componentType: 'BUTTON' }); 131 | if (!buttonReply) { 132 | return; 133 | } 134 | 135 | const buttonSelected = buttonReply.customId; 136 | buttonReply.update({ embeds: [authorEmbed], components: [] }); 137 | if (buttonSelected === REPLY.NO) { 138 | buttonReply.followUp({ 139 | content: `"${name}" was not added`, 140 | ephemeral: true, 141 | }) 142 | return; 143 | } 144 | 145 | try { 146 | await createAuthor(author); 147 | await interaction.followUp({ content: `"${name}" was added as an author`, ephemeral: true }); 148 | } 149 | catch (e) { 150 | let errorMessage = 'There was an error saving. Please try again.'; 151 | if (isAirtableError(e)) { 152 | errorMessage = 'There was an error from Airtable. Please try again.'; 153 | } 154 | if (isHandledError(e)) { 155 | errorMessage = e.message; 156 | } 157 | 158 | try { 159 | await interaction.followUp({ content: errorMessage, ephemeral: true }); 160 | } 161 | catch (error) { 162 | console.log('Error trying to follow up add-author', error); 163 | } 164 | } 165 | }, 166 | } 167 | -------------------------------------------------------------------------------- /utils/airTableCalls.ts: -------------------------------------------------------------------------------- 1 | import Airtable, { FieldSet, Table } from 'airtable'; 2 | import AirtableError from 'airtable/lib/airtable_error'; 3 | import { User } from 'discord.js'; 4 | import dotenv from 'dotenv' 5 | import { Author as AuthorInfo, LookupItem, Resource } from '../types'; 6 | import HandledError from './error'; 7 | import { normalizeString } from './normalizeString'; 8 | 9 | dotenv.config() 10 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 11 | const base = new Airtable({ apiKey: process.env.AIRTABLE_TOKEN! }).base(process.env.AIRTABLE_BASE!) 12 | 13 | const TABLES = { 14 | AUTHOR: () => base('Author'), 15 | CONTRIBUTOR: () => base('Contributor'), 16 | TAGS: () => base('Tags'), 17 | CATEGORY: () => base('Category'), 18 | BLOCKCHAIN: () => base('Blockchain'), 19 | RESOURCES: () => base('Resource'), 20 | GLOSSARY: () => base('Glossary'), 21 | }; 22 | 23 | export async function isContributor(user: User) { 24 | const foundUser = await findContributor(user); 25 | if (foundUser) { 26 | return true; 27 | } 28 | else { 29 | return false; 30 | } 31 | } 32 | 33 | export async function createContributor(user: User, nftID?: number, twitterHandle?: string, ethWalletAddress?: string) { 34 | const table = TABLES.CONTRIBUTOR(); 35 | const records = await table.create([ 36 | { 37 | 'fields': { 38 | 'Discord Handle': `${user.username}:${user.discriminator}`, 39 | 'Discord ID': `${user.id}`, 40 | 'DevDAO ID': nftID, 41 | 'Twitter Handle': twitterHandle ?? '', 42 | 'ETH Wallet Address': ethWalletAddress ?? '', 43 | }, 44 | }, 45 | ]); 46 | 47 | if (records.length === 1) { 48 | const addedRecord = records[0]; 49 | return addedRecord.getId(); 50 | } 51 | } 52 | 53 | async function findAuthor(name: string) { 54 | return await TABLES.AUTHOR().select({ 55 | filterByFormula: `{Name}="${name}"`, 56 | }).all(); 57 | } 58 | 59 | export async function createAuthor(author: AuthorInfo) { 60 | const { name, isDaoMember, twitterUrl, youtubeUrl } = author; 61 | const authorList = await findAuthor(name); 62 | if (authorList.length) { 63 | throw new HandledError(`Author ${name} already exists`); 64 | } 65 | 66 | const record = { 67 | Name: normalizeString(name), 68 | 'Developer DAO Member': isDaoMember, 69 | Twitter: twitterUrl || '', 70 | YouTube: youtubeUrl || '', 71 | Resource: [], 72 | }; 73 | 74 | await TABLES.AUTHOR().create([{ fields: record }]); 75 | } 76 | 77 | export function isAirtableError(value: unknown): value is AirtableError { 78 | return value instanceof AirtableError; 79 | } 80 | 81 | export async function createTag(tag: string) { 82 | const records = await TABLES.TAGS().create([ 83 | { 84 | 'fields': { 85 | Name: tag, 86 | }, 87 | }, 88 | ]); 89 | records?.forEach((record) => console.log(record.getId())); 90 | } 91 | 92 | export async function createCategory(category: string) { 93 | const records = await TABLES.CATEGORY().create([ 94 | { 95 | 'fields': { 96 | Name: category, 97 | }, 98 | }, 99 | ]) 100 | records?.forEach((record) => console.log(record.getId())); 101 | } 102 | 103 | export async function createBlockchain(blockchain: string, website: string | null) { 104 | const records = await TABLES.BLOCKCHAIN().create([ 105 | { 106 | 'fields': { 107 | Name: blockchain, 108 | Website: website ?? '', 109 | }, 110 | }, 111 | ]) 112 | records?.forEach((record) => console.log(record.getId())); 113 | } 114 | 115 | export async function createGlossaryTerm(term: string, description: string, website: string) { 116 | const records = await TABLES.GLOSSARY().create([ 117 | { 118 | 'fields': { 119 | Name: term, 120 | Description: description, 121 | 'Learn more link': website, 122 | }, 123 | }, 124 | ]) 125 | records?.forEach((record) => console.log(record.getId())); 126 | } 127 | 128 | export async function createResource(resource: Resource) { 129 | try { 130 | await TABLES.RESOURCES().create([ 131 | { 132 | fields: { 133 | Title: resource.title, 134 | Source: resource.source, 135 | Summary: resource.summary, 136 | Level: resource.level, 137 | Blockchain: resource.blockchain, 138 | Category: resource.category, 139 | Tags: resource.tags, 140 | 'Media Type': resource.mediaType, 141 | Author: [resource.author], 142 | Contributor: [resource.contributor], 143 | }, 144 | }, 145 | ]); 146 | return { success: true }; 147 | } 148 | catch (error) { 149 | console.error(error); 150 | return { success: false, error }; 151 | } 152 | } 153 | 154 | export async function findContributor(user: User) { 155 | // const discordHandle = `${user.username}:${user.discriminator}`; 156 | const discordID = `${user.id}`; 157 | const records = await TABLES.CONTRIBUTOR().select({ 158 | // filterByFormula: `{Discord Handle} = '${discordHandle}'`, 159 | filterByFormula: `{Discord Id} = '${discordID}'`, 160 | maxRecords: 1, 161 | }).all(); 162 | if (records) { 163 | return records[0]; 164 | } 165 | } 166 | 167 | export function readAuthors(): Promise { 168 | return readLookup(TABLES.AUTHOR()); 169 | } 170 | 171 | export function readTags(): Promise { 172 | return readLookup(TABLES.TAGS()); 173 | } 174 | 175 | export function readCategory(): Promise { 176 | return readLookup(TABLES.CATEGORY()); 177 | } 178 | 179 | export function readBlockchain(): Promise { 180 | return readLookup(TABLES.BLOCKCHAIN()); 181 | } 182 | 183 | export function readLookup(table: Table
): Promise { 184 | return new Promise((resolve, reject) => { 185 | const items: LookupItem[] = []; 186 | table.select({ 187 | maxRecords: 25, 188 | sort: [{ field: 'Name', direction: 'asc' }], 189 | }).eachPage(function page(records, fetchNextPage) { 190 | records.forEach(record => { 191 | const name = record.get('Name'); 192 | const id = record.id; 193 | items.push({ 194 | id, 195 | name: `${name}`, 196 | }); 197 | }); 198 | 199 | fetchNextPage(); 200 | }, function done(err) { 201 | if (err) { 202 | console.error(err); 203 | reject(err); 204 | } 205 | resolve(items); 206 | }); 207 | }) 208 | } 209 | 210 | export async function findResourceByUrl(url: string): Promise { 211 | const records = await TABLES.RESOURCES().select({ 212 | filterByFormula: `LOWER({Source}) = '${url.toLowerCase()}'`, 213 | }).all(); 214 | if (records && records.length > 0) { 215 | const first = records[0]; 216 | return { name: `${first.get('name')}`, id: first.id }; 217 | } 218 | else { 219 | return undefined; 220 | } 221 | } 222 | 223 | export function findAuthorByName(name: string): Promise { 224 | return findLookupItemByName(TABLES.AUTHOR(), name); 225 | } 226 | 227 | export function findTagByName(name: string): Promise { 228 | return findLookupItemByName(TABLES.TAGS(), name); 229 | } 230 | 231 | export function findCategoryByName(name: string): Promise { 232 | return findLookupItemByName(TABLES.CATEGORY(), name); 233 | } 234 | 235 | export function findBlockchainByName(name: string): Promise { 236 | return findLookupItemByName(TABLES.BLOCKCHAIN(), name); 237 | } 238 | 239 | export function findGlossaryTermByName(name: string): Promise { 240 | return findLookupItemByName(TABLES.GLOSSARY(), name); 241 | } 242 | 243 | export async function findLookupItemByName(table: Table
, name: string): Promise { 244 | const records = await table.select({ 245 | filterByFormula: `LOWER({Name}) = '${name.toLowerCase()}'`, 246 | }).all(); 247 | if (records && records.length > 0) { 248 | const first = records[0]; 249 | return { name: `${first.get('Name')}`, id: first.id }; 250 | } 251 | else { 252 | return undefined; 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /slashCommands/addResource.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder, inlineCode } from '@discordjs/builders'; 2 | import { CommandInteraction, Message, MessageActionRow, MessageButton, MessageEmbed, MessageSelectMenu } from 'discord.js'; 3 | import { LookupItem, SlashCommandConfig } from '../types'; 4 | import { isHandledError } from '../utils/error'; 5 | import { createResource, findContributor, findResourceByUrl, isAirtableError, isContributor, isValidUrl, readAuthors, readBlockchain, readCategory, readTags, ResourceBuilder } from '../utils/index'; 6 | 7 | export const AddResourceCommand: SlashCommandConfig = { 8 | name: 'add-resource', 9 | roles: ['dev'], 10 | commandJSON: () => new SlashCommandBuilder() 11 | .setName('add-resource') 12 | .setDescription('Adds a resource to the Developer DAO knowledge base') 13 | .addStringOption( 14 | option => option.setRequired(true) 15 | .setName('url') 16 | .setDescription('Enter a link to a resource')) 17 | .addStringOption( 18 | option => option.setRequired(true) 19 | .setName('title') 20 | .setDescription('Enter the resource title')) 21 | .addStringOption( 22 | option => option.setRequired(true) 23 | .setName('summary') 24 | .setDescription('Enter the resource summary')) 25 | .addStringOption(option => 26 | option.setName('level') 27 | .setDescription('The resource level') 28 | .setRequired(true) 29 | .addChoice('Beginner', 'Beginner') 30 | .addChoice('Intermediate', 'Intermediate') 31 | .addChoice('Advanced', 'Advanced')) 32 | .addStringOption(option => 33 | option.setName('media') 34 | .setDescription('Media type') 35 | .setRequired(true) 36 | .addChoice('Article', 'Article') 37 | .addChoice('Video', 'Video') 38 | .addChoice('Paid Course', 'Paid Course') 39 | .addChoice('Free Course', 'Free Course')).toJSON(), 40 | execute: async (interaction: CommandInteraction) => { 41 | let contributor; 42 | if (!await isContributor(interaction.user)) { 43 | await interaction.reply({ content: `it looks like you are not a contributor yet!\nPlease add yourself using: ${inlineCode('/add-contributor')}`, ephemeral: true }) 44 | return 45 | } 46 | else { 47 | const contributorResponse = await findContributor(interaction.user); 48 | if (contributorResponse) { 49 | contributor = contributorResponse.getId(); 50 | } 51 | } 52 | 53 | const url = interaction.options.getString('url'); 54 | if (!url) { 55 | interaction.reply({ content: 'URL required, please try submitting again.', ephemeral: true }); 56 | return; 57 | } 58 | 59 | if (!isValidUrl(url)) { 60 | interaction.reply({ content: 'Invalid URL provided, please check it before submitting again.', ephemeral: true }); 61 | return; 62 | } 63 | 64 | const foundResouce = await findResourceByUrl(url); 65 | if (foundResouce) { 66 | interaction.reply({ content: 'A resource with this URL already exists.', ephemeral: true }); 67 | return; 68 | } 69 | 70 | await interaction.deferReply({ ephemeral: true }); 71 | 72 | const resource = getSanitizedResourceInfo(interaction); 73 | resource.contributor = contributor; 74 | 75 | const resourceEmbed = buildEmbed(resource); 76 | const tags = await readTags(); 77 | tags.unshift({ name: '', id: 'N/A' }); 78 | const tagsOptions = tags.map(tag => ({ label: tag.name, value: tag.id })); 79 | const tagsRow = new MessageActionRow().addComponents( 80 | new MessageSelectMenu() 81 | .setCustomId('tags') 82 | .setPlaceholder('Select tags') 83 | .setMaxValues(Math.min(tagsOptions.length, 25)) 84 | .addOptions(tagsOptions), 85 | ); 86 | 87 | const blockchain = await readBlockchain(); 88 | blockchain.unshift({ name: '', id: 'N/A' }); 89 | const blockchainOptions = blockchain.map(bc => ({ label: bc.name, value: bc.id })); 90 | const blockchainRow = new MessageActionRow().addComponents( 91 | new MessageSelectMenu() 92 | .setCustomId('blockchain') 93 | .setPlaceholder('Select blockchain') 94 | .setMaxValues(Math.min(blockchainOptions.length, 25)) 95 | .addOptions(blockchainOptions), 96 | ); 97 | 98 | const categories = await readCategory(); 99 | categories.unshift({ name: '', id: 'N/A' }); 100 | const categoryOptions = categories.map(category => ({ label: category.name, value: category.id })); 101 | const categoryRow = new MessageActionRow().addComponents( 102 | new MessageSelectMenu() 103 | .setCustomId('category') 104 | .setPlaceholder('Select categories') 105 | .setMaxValues(Math.min(categoryOptions.length, 25)) 106 | .addOptions(categoryOptions), 107 | ); 108 | 109 | const authors = await readAuthors(); 110 | const authorOptions = authors.map(author => ({ label: author.name, value: author.id })); 111 | const authorRow = new MessageActionRow().addComponents( 112 | new MessageSelectMenu() 113 | .setCustomId('author') 114 | .setPlaceholder('Select author') 115 | .addOptions(authorOptions), 116 | ); 117 | 118 | const selectionRows = [authorRow, blockchainRow, categoryRow, tagsRow]; 119 | 120 | const interactionMessage = await interaction.editReply({ 121 | embeds: [resourceEmbed], 122 | content: 'Tell me about the resource', 123 | components: selectionRows, 124 | }); 125 | 126 | if (!(interactionMessage instanceof Message)) { return; } 127 | 128 | const collector = interactionMessage.createMessageComponentCollector({ 129 | maxComponents: 5, 130 | time: 120_000, 131 | componentType: 'SELECT_MENU', 132 | }); 133 | 134 | collector?.on('collect', async (menuInteraction) => { 135 | switch (menuInteraction.customId) { 136 | case 'category': { 137 | resource.category = menuInteraction.values.map((v: string) => { 138 | const lookupItem = categories.find((value) => value.id === v); 139 | return lookupItem ?? { name: 'Unknown', id: v }; 140 | }); 141 | break; 142 | } 143 | case 'tags': { 144 | resource.tags = menuInteraction.values.map((v: string) => { 145 | const lookupItem = tags.find((value) => value.id === v); 146 | return lookupItem ?? { name: 'Unknown', id: v }; 147 | }); 148 | break; 149 | } 150 | case 'blockchain': { 151 | resource.blockchain = menuInteraction.values.map((v: string) => { 152 | const lookupItem = blockchain.find((value) => value.id === v); 153 | return lookupItem ?? { name: 'Unknown', id: v }; 154 | }); 155 | break; 156 | } 157 | case 'author': { 158 | if (menuInteraction.values.length === 1) { 159 | const selectedItemId = menuInteraction.values[0]; 160 | const lookupItem = authors.find((value) => value.id === selectedItemId); 161 | resource.author = lookupItem ?? { name: 'Unknown', id: selectedItemId }; 162 | } 163 | else { 164 | // No idea how you would get here really or what to do 165 | } 166 | break; 167 | } 168 | } 169 | 170 | const menuRows = []; 171 | 172 | if (resource.author === undefined) { 173 | menuRows.push(authorRow); 174 | } 175 | if (resource.blockchain === undefined || resource.blockchain.length === 0) { 176 | menuRows.push(blockchainRow); 177 | } 178 | if (resource.category === undefined || resource.category.length === 0) { 179 | menuRows.push(categoryRow); 180 | } 181 | if (resource.tags === undefined || resource.tags.length === 0) { 182 | menuRows.push(tagsRow); 183 | } 184 | 185 | const updatedEmbed = buildEmbed(resource); 186 | menuInteraction.update({ components: menuRows }) 187 | interaction.editReply({ embeds: [updatedEmbed] }); 188 | 189 | if (menuRows.length === 0) { 190 | collector?.stop(); 191 | } 192 | }); 193 | 194 | collector?.on('end', async () => { 195 | const REPLY = { 196 | YES: 'yes', 197 | NO: 'no', 198 | }; 199 | const noButton = new MessageButton() 200 | .setCustomId(REPLY.NO) 201 | .setLabel('Cancel') 202 | .setStyle('DANGER'); 203 | const yesButton = new MessageButton() 204 | .setCustomId(REPLY.YES) 205 | .setLabel('Add resource') 206 | .setStyle('PRIMARY'); 207 | const buttonRow = new MessageActionRow() 208 | .addComponents( 209 | noButton, 210 | yesButton, 211 | ); 212 | 213 | await interaction.editReply({ 214 | components: [buttonRow], 215 | }); 216 | 217 | const buttonReply = await interaction.channel?.awaitMessageComponent({ componentType: 'BUTTON' }); 218 | if (!buttonReply) { 219 | return; 220 | } 221 | 222 | const buttonSelected = buttonReply.customId; 223 | buttonReply.update({ embeds: [resourceEmbed], components: [] }); 224 | if (buttonSelected === REPLY.NO) { 225 | buttonReply.followUp({ 226 | content: `"${resource.title}" was not added`, 227 | ephemeral: true, 228 | }) 229 | return; 230 | } 231 | else { 232 | try { 233 | const result = await createResource(resource.build()); 234 | if (result.success) { 235 | interaction.editReply({ 236 | content: 'Resource was added. Thank you for your contribution', 237 | embeds: [], 238 | components: [], 239 | }); 240 | } 241 | else { 242 | interaction.editReply({ 243 | content: 'Resource addition failed. ${error}', 244 | embeds: [], 245 | components: [], 246 | }); 247 | } 248 | } 249 | catch (error) { 250 | let errorMessage = 'There was an error saving. Please try again.'; 251 | if (isAirtableError(error)) { 252 | errorMessage = 'There was an error from Airtable. Please try again.'; 253 | } 254 | if (isHandledError(error)) { 255 | errorMessage = error.message; 256 | } 257 | 258 | try { 259 | await interaction.followUp({ content: errorMessage, ephemeral: true }); 260 | } 261 | catch (e) { 262 | console.log('Error trying to follow up add-resource', e); 263 | } 264 | } 265 | } 266 | }) 267 | }, 268 | } 269 | 270 | function getSanitizedResourceInfo(interaction: CommandInteraction): ResourceBuilder { 271 | const source = interaction.options.getString('url') ?? ''; 272 | const title = interaction.options.getString('title') ?? ''; 273 | const summary = interaction.options.getString('summary') ?? ''; 274 | const level = interaction.options.getString('level') ?? ''; 275 | const mediaType = interaction.options.getString('media') ?? ''; 276 | 277 | const builder = new ResourceBuilder(); 278 | builder.title = title; 279 | builder.source = source; 280 | builder.summary = summary; 281 | builder.level = level; 282 | builder.mediaType = mediaType; 283 | 284 | return builder; 285 | } 286 | 287 | function buildSelectionResponse(title: string, selections?: LookupItem[]): string { 288 | if (selections && selections.length > 0) { 289 | if (selections.filter(bc => bc.id.toLowerCase() === 'n/a').length > 0) { 290 | return 'SKIPPED'; 291 | } 292 | else { 293 | return selections.map(b => b.name).join(', '); 294 | } 295 | } 296 | else { 297 | return title; 298 | } 299 | } 300 | 301 | function buildEmbed(resource: ResourceBuilder) { 302 | const resourceEmbed = new MessageEmbed().setColor('#0099ff'); 303 | resourceEmbed.setAuthor(resource.author ? resource.author.name : 'Author'); 304 | resourceEmbed.setTitle(resource.title ?? 'Title'); 305 | resourceEmbed.setURL(resource.source ?? 'Source'); 306 | resourceEmbed.setDescription(resource.summary ?? 'Summary'); 307 | 308 | const blockChains = buildSelectionResponse('Blockchain', resource.blockchain); 309 | const categories = buildSelectionResponse('Categories', resource.category); 310 | const tags = buildSelectionResponse('Tags', resource.tags); 311 | 312 | resourceEmbed.setFields([ 313 | { name: 'level', value: resource.level ?? 'Level', inline: true }, 314 | { name: 'mediatype', value: resource.mediaType ?? 'Media Type', inline: true }, 315 | { name: 'blockchain', value: blockChains, inline: false }, 316 | { name: 'category', value: categories, inline: true }, 317 | { name: 'tags', value: tags, inline: true }, 318 | ]); 319 | return resourceEmbed; 320 | } 321 | --------------------------------------------------------------------------------