├── .nvmrc ├── .env.example ├── .eslintignore ├── .dockerignore ├── .prettierrc ├── .husky ├── commit-msg └── pre-commit ├── domain ├── service │ ├── loggerService.ts │ ├── contentAggregatorService │ │ ├── contentAggregatorService.ts │ │ └── post.ts │ ├── kataService │ │ ├── kataService.ts │ │ └── kataLeaderboardUser.ts │ ├── questionTrackingService.ts │ ├── chatService.ts │ ├── channelResolver.ts │ ├── commandUseCaseResolver.ts │ └── interactionResolver.ts ├── repository │ └── messageRepository.ts └── exception │ └── useCaseNotFound.ts ├── nodemon.json ├── application ├── usecases │ ├── sendMessageToChannel │ │ ├── sendMessageToChannelInput.ts │ │ └── sendMessageToChannelUseCase.ts │ ├── sendCodewarsLeaderboardToChannel │ │ └── sendCodewarsLeaderboardToChannelInput.ts │ ├── sendWelcomeMessageUseCase.ts │ ├── rejectAnonymousQuestionUseCase.ts │ ├── sendAnonymousQuestion │ │ └── sendAnonymousQuestionUseCase.ts │ └── approveAnonymousQuestionUseCase.ts └── command │ ├── onlyCodeQuestionsCommand.ts │ ├── dontAskToAskCommand.ts │ ├── codewarsLeaderboardCommand.ts │ └── anonymousQuestionCommand.ts ├── vitest.config.ts ├── docker-compose.yaml ├── infrastructure ├── service │ ├── consoleLoggerService.ts │ ├── codewarsKataService.ts │ ├── lemmyContentAggregatorService.ts │ └── discordChatService.ts └── repository │ └── fileMessageRepository.ts ├── commitlint.config.ts ├── Dockerfile.dev ├── assets └── phrases │ ├── intro.json │ └── welcoming.json ├── types.ts ├── tsconfig.json ├── .editorconfig ├── fly.toml ├── .github └── workflows │ └── lint-and-test.yml ├── .eslintrc.json ├── Dockerfile ├── vitest ├── application │ ├── usecases │ │ ├── sendMessageToChannelUseCase.spec.ts │ │ ├── rejectAnonymousQuestionUseCase.spec.ts │ │ ├── sendWelcomeMessageUseCase.spec.ts │ │ └── approveAnonymousQuestionUseCase.spec.ts │ └── command │ │ └── codewarsLeaderboardCommand.spec.ts └── service │ └── commandUseCaseResolver.spec.ts ├── package.json ├── README.md ├── .gitpod.yml ├── .gitignore ├── CONTRIBUTING.md ├── index.ts ├── ARCHITECTURE.md └── LICENSE /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.19.0 2 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DISCORD_TOKEN= -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "printWidth": 120, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit ${1} 5 | -------------------------------------------------------------------------------- /domain/service/loggerService.ts: -------------------------------------------------------------------------------- 1 | export default interface LoggerService { 2 | log(...args: unknown[]): void; 3 | } 4 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["index.ts"], 3 | "ignore": ["src/**/*.spec.ts"], 4 | "exec": "ts-node index.ts" 5 | } 6 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx pretty-quick --staged && npm exec concurrently npm:test:once npm:lint 5 | -------------------------------------------------------------------------------- /application/usecases/sendMessageToChannel/sendMessageToChannelInput.ts: -------------------------------------------------------------------------------- 1 | export interface SendMessageToChannelInput { 2 | message: string; 3 | channelId: string; 4 | } 5 | -------------------------------------------------------------------------------- /domain/repository/messageRepository.ts: -------------------------------------------------------------------------------- 1 | export default interface MessageRepository { 2 | getRandomIntroMessage(): string; 3 | getRandomWelcomingMessage(): string; 4 | } 5 | -------------------------------------------------------------------------------- /application/usecases/sendCodewarsLeaderboardToChannel/sendCodewarsLeaderboardToChannelInput.ts: -------------------------------------------------------------------------------- 1 | export interface SendCodewarsLeaderboardToChannelInput { 2 | channelId: string; 3 | } 4 | -------------------------------------------------------------------------------- /domain/service/contentAggregatorService/contentAggregatorService.ts: -------------------------------------------------------------------------------- 1 | import Post from "./post"; 2 | 3 | export default interface ContentAggregatorService { 4 | fetchLastPosts(): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /domain/service/kataService/kataService.ts: -------------------------------------------------------------------------------- 1 | import KataLeaderboardUser from "./kataLeaderboardUser"; 2 | 3 | export default interface KataService { 4 | getLeaderboard(): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /domain/exception/useCaseNotFound.ts: -------------------------------------------------------------------------------- 1 | export default class UseCaseNotFound extends Error { 2 | byCommand(command: string) { 3 | return new UseCaseNotFound(`Use case for command "${command}" not found`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ["vitest/**/*.spec.ts"], 6 | exclude: ["dist/**", "node_modules/**"], 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | app: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile.dev 8 | volumes: 9 | - ./:/usr/src/welcome_bot:cached 10 | - /usr/src/welcome_bot/node_modules 11 | -------------------------------------------------------------------------------- /infrastructure/service/consoleLoggerService.ts: -------------------------------------------------------------------------------- 1 | import LoggerService from "../../domain/service/loggerService"; 2 | 3 | export default class ConsoleLoggerService implements LoggerService { 4 | log(...args: unknown[]) { 5 | console.log(...args); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /commitlint.config.ts: -------------------------------------------------------------------------------- 1 | import type { UserConfig } from "@commitlint/types"; 2 | 3 | const Configuration: UserConfig = { 4 | /* 5 | * Resolve and load @commitlint/config-conventional from node_modules. 6 | * Referenced packages must be installed 7 | */ 8 | extends: ["@commitlint/config-conventional"], 9 | }; 10 | 11 | export default Configuration; 12 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # Specify the parent image from which we build 2 | FROM node:18.9.0-alpine3.16 3 | 4 | ENV NODE_ENV=development 5 | 6 | # Set the working directory 7 | RUN mkdir -p /usr/src/welcome_bot 8 | WORKDIR /usr/src/welcome_bot 9 | 10 | # Install app dependencies 11 | COPY package*.json ./ 12 | 13 | RUN npm install 14 | RUN npm install -g nodemon 15 | COPY . . 16 | 17 | # Run the application 18 | CMD [ "npm", "run", "start.dev" ] -------------------------------------------------------------------------------- /assets/phrases/intro.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Bem vindo/a {MEMBER_ID}", 3 | "Olá {MEMBER_ID}", 4 | "Ora com que então boa tarde {MEMBER_ID}", 5 | "{MEMBER_ID} Nerd alert", 6 | "Bons olhos te vejam {MEMBER_ID}", 7 | "{MEMBER_ID} saca daí um abraço", 8 | "Conseguiste {MEMBER_ID}", 9 | "Welcome to the Wired {MEMBER_ID}", 10 | "Buenos dias Matosinhos! Eu e o {MEMBER_ID} estamos aqui na praia. Alto sol", 11 | "Viva camarada {MEMBER_ID} pá" 12 | ] 13 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | import { Message } from "discord.js"; 2 | 3 | export interface ChatMember { 4 | id: string; 5 | } 6 | 7 | export interface Context { 8 | channelId: string; 9 | message?: Message; 10 | } 11 | 12 | export enum ChannelSlug { 13 | ENTRANCE = "ENTRANCE", 14 | JOBS = "JOBS", 15 | MODERATION = "MODERATION", 16 | QUESTIONS = "QUESTIONS", 17 | } 18 | 19 | export type CommandMessages = { 20 | [command: string]: string; 21 | }; 22 | 23 | export interface Command { 24 | name: string; 25 | execute(context: Context): Promise; 26 | } 27 | -------------------------------------------------------------------------------- /application/usecases/sendMessageToChannel/sendMessageToChannelUseCase.ts: -------------------------------------------------------------------------------- 1 | import ChatService from "../../../domain/service/chatService"; 2 | import { SendMessageToChannelInput } from "./sendMessageToChannelInput"; 3 | 4 | export default class SendMessageToChannelUseCase { 5 | private chatService: ChatService; 6 | 7 | constructor({ chatService }: { chatService: ChatService }) { 8 | this.chatService = chatService; 9 | } 10 | 11 | async execute({ message, channelId }: SendMessageToChannelInput): Promise { 12 | await this.chatService.sendMessageToChannel(message, channelId); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /domain/service/kataService/kataLeaderboardUser.ts: -------------------------------------------------------------------------------- 1 | export default class KataLeaderboardUser { 2 | private username: string; 3 | 4 | private score: number; 5 | 6 | private points: number[]; 7 | 8 | constructor({ username, score, points }: { username: string; score: number; points: number[] }) { 9 | this.username = username; 10 | this.score = score; 11 | this.points = points; 12 | } 13 | 14 | getUsername(): string { 15 | return this.username; 16 | } 17 | 18 | getScore(): number { 19 | return this.score; 20 | } 21 | 22 | getPoints(): number[] { 23 | return this.points; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /domain/service/questionTrackingService.ts: -------------------------------------------------------------------------------- 1 | export default class QuestionTrackingService { 2 | private pendingQuestions: Map = new Map(); // userId -> questionId 3 | 4 | hasPendingQuestion(userId: string): boolean { 5 | return this.pendingQuestions.has(userId); 6 | } 7 | 8 | trackQuestion(userId: string, questionId: string): void { 9 | this.pendingQuestions.set(userId, questionId); 10 | } 11 | 12 | removeQuestion(userId: string): void { 13 | this.pendingQuestions.delete(userId); 14 | } 15 | 16 | getQuestionId(userId: string): string | undefined { 17 | return this.pendingQuestions.get(userId); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /domain/service/chatService.ts: -------------------------------------------------------------------------------- 1 | export interface EmbedOptions { 2 | title?: string; 3 | description?: string; 4 | color?: number; 5 | fields?: Array<{ name: string; value: string; inline?: boolean }>; 6 | footer?: { text: string; iconURL?: string }; 7 | } 8 | 9 | export interface ButtonOptions { 10 | customId: string; 11 | label: string; 12 | style: "PRIMARY" | "SECONDARY" | "SUCCESS" | "DANGER"; 13 | } 14 | 15 | export default interface ChatService { 16 | sendMessageToChannel(message: string, channelId: string): Promise; 17 | 18 | sendEmbedWithButtons(channelId: string, embedOptions: EmbedOptions, buttons: ButtonOptions[]): Promise; 19 | } 20 | -------------------------------------------------------------------------------- /domain/service/channelResolver.ts: -------------------------------------------------------------------------------- 1 | import { ChannelSlug } from "../../types"; 2 | 3 | const fallbackChannelIds: Record = { 4 | [ChannelSlug.ENTRANCE]: "855861944930402344", 5 | [ChannelSlug.JOBS]: "876826576749215744", 6 | [ChannelSlug.MODERATION]: "987719981443723266", 7 | [ChannelSlug.QUESTIONS]: "1065751368809324634", 8 | }; 9 | 10 | export default class ChannelResolver { 11 | getBySlug(slug: ChannelSlug): string { 12 | const channelId = process.env[`CHANNEL_${slug}`] || fallbackChannelIds[slug]; 13 | 14 | if (!channelId) { 15 | throw new Error(`Channel ID for "${slug}" not found`); 16 | } 17 | 18 | return channelId; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "commonjs", 5 | "outDir": "./dist/", 6 | "strict": true, 7 | "moduleResolution": "node", 8 | "importHelpers": true, 9 | "experimentalDecorators": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "allowSyntheticDefaultImports": true, 13 | "resolveJsonModule": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "removeComments": true, 16 | "typeRoots": ["node_modules/@types"], 17 | "sourceMap": false, 18 | "baseUrl": "./", 19 | "paths": { 20 | "@/*": ["./*"] 21 | } 22 | }, 23 | "exclude": ["node_modules", "dist"] 24 | } 25 | -------------------------------------------------------------------------------- /application/command/onlyCodeQuestionsCommand.ts: -------------------------------------------------------------------------------- 1 | import { Command, Context } from "../../types"; 2 | import ChatService from "../../domain/service/chatService"; 3 | 4 | export default class OnlyCodeQuestionsCommand implements Command { 5 | readonly name = "!oc"; 6 | 7 | private chatService: ChatService; 8 | 9 | private readonly message: string = 10 | ":warning: Este servidor é APENAS para questões relacionadas com programação! :warning:"; 11 | 12 | constructor(chatService: ChatService) { 13 | this.chatService = chatService; 14 | } 15 | 16 | async execute(context: Context): Promise { 17 | await this.chatService.sendMessageToChannel(this.message, context.channelId); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /application/command/dontAskToAskCommand.ts: -------------------------------------------------------------------------------- 1 | import { Command, Context } from "../../types"; 2 | import ChatService from "../../domain/service/chatService"; 3 | 4 | export default class DontAskToAskCommand implements Command { 5 | readonly name = "!ja"; 6 | 7 | private readonly message: string = 8 | "Olá! Experimenta fazer a pergunta diretamente e contar o que já tentaste! Sabe mais aqui :point_right: https://dontasktoask.com/pt-pt/"; 9 | 10 | private chatService: ChatService; 11 | 12 | constructor(chatService: ChatService) { 13 | this.chatService = chatService; 14 | } 15 | 16 | async execute(context: Context): Promise { 17 | await this.chatService.sendMessageToChannel(this.message, context.channelId); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | # Matches multiple files with brace expansion notation 12 | # Set default charset 13 | [*.{js}] 14 | charset = utf-8 15 | indent_style = space 16 | indent_size = 2 17 | 18 | # Tab indentation (no size specified) 19 | [Makefile] 20 | indent_style = tab 21 | 22 | # Indentation override for all JS under assets/phrases directory 23 | [assets/phrases/**.json] 24 | indent_style = space 25 | indent_size = 2 26 | 27 | # Matches the exact files either package.json or .travis.yml 28 | [{package.json,.gitpod.yml,docker-compose.yaml}] 29 | indent_style = space 30 | indent_size = 2 31 | 32 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for devpt-discord-bot on 2022-12-09T01:46:00+02:00 2 | 3 | app = "devpt-discord-bot" 4 | kill_signal = "SIGINT" 5 | kill_timeout = 5 6 | processes = [] 7 | 8 | [env] 9 | 10 | [experimental] 11 | allowed_public_ports = [] 12 | auto_rollback = true 13 | 14 | [[services]] 15 | http_checks = [] 16 | internal_port = 8080 17 | processes = ["app"] 18 | protocol = "tcp" 19 | script_checks = [] 20 | [services.concurrency] 21 | hard_limit = 25 22 | soft_limit = 20 23 | type = "connections" 24 | 25 | [[services.ports]] 26 | force_https = true 27 | handlers = ["http"] 28 | port = 80 29 | 30 | [[services.ports]] 31 | handlers = ["tls", "http"] 32 | port = 443 33 | 34 | [[services.tcp_checks]] 35 | grace_period = "1s" 36 | interval = "15s" 37 | restart_limit = 0 38 | timeout = "2s" 39 | -------------------------------------------------------------------------------- /.github/workflows/lint-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Lint & test 2 | 3 | on: 4 | pull_request: 5 | types: [opened, reopened, synchronize] 6 | 7 | jobs: 8 | lint-and-test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v4 13 | 14 | - name: Set up Node.js 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: "20" 18 | 19 | - name: Cache npm dependencies 20 | uses: actions/cache@v4 21 | with: 22 | path: ~/.npm 23 | key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }} 24 | restore-keys: | 25 | ${{ runner.os }}-node- 26 | ${{ runner.os }}- 27 | 28 | - name: Install dependencies 29 | run: npm ci 30 | 31 | - name: Concurrently lint and test 32 | run: npm exec concurrently npm:lint npm:test 33 | -------------------------------------------------------------------------------- /infrastructure/repository/fileMessageRepository.ts: -------------------------------------------------------------------------------- 1 | import intro from "../../assets/phrases/intro.json"; 2 | import welcoming from "../../assets/phrases/welcoming.json"; 3 | import MessageRepository from "../../domain/repository/messageRepository"; 4 | 5 | interface Phrases { 6 | intro: Array; 7 | welcoming: Array; 8 | } 9 | 10 | const config = { phrases: {} as Phrases }; 11 | config.phrases.intro = intro; 12 | config.phrases.welcoming = welcoming; 13 | 14 | const getRandomStringFromCollection = (collection: Array) => 15 | collection[Math.floor(Math.random() * collection.length)].trim(); 16 | 17 | export default class FileMessageRepository implements MessageRepository { 18 | getRandomIntroMessage = () => getRandomStringFromCollection(config.phrases.intro).trim(); 19 | 20 | getRandomWelcomingMessage = () => getRandomStringFromCollection(config.phrases.welcoming).trim(); 21 | } 22 | -------------------------------------------------------------------------------- /domain/service/commandUseCaseResolver.ts: -------------------------------------------------------------------------------- 1 | import { Command, Context } from "../../types"; 2 | import LoggerService from "./loggerService"; 3 | 4 | export default class CommandUseCaseResolver { 5 | private commands: Command[]; 6 | 7 | private loggerService: LoggerService; 8 | 9 | constructor({ commands, loggerService }: { commands: Command[]; loggerService: LoggerService }) { 10 | this.loggerService = loggerService; 11 | 12 | this.commands = commands; 13 | } 14 | 15 | async resolveByCommand(command: string, context: Context): Promise { 16 | this.loggerService.log(`Command received: "${command}"`); 17 | 18 | const commandInstance = this.commands.find((cmd) => cmd.name === command); 19 | 20 | if (!commandInstance) { 21 | this.loggerService.log(`Command not found: "${command}"`); 22 | return false; 23 | } 24 | 25 | await commandInstance.execute(context); 26 | return true; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "airbnb-base", 8 | "airbnb-typescript/base", 9 | "prettier", 10 | "eslint:recommended", 11 | "plugin:@typescript-eslint/eslint-recommended", 12 | "plugin:@typescript-eslint/recommended", 13 | "plugin:import/typescript" 14 | ], 15 | "overrides": [], 16 | "parser": "@typescript-eslint/parser", 17 | "parserOptions": { 18 | "ecmaVersion": "latest", 19 | "sourceType": "module", 20 | "project": "./tsconfig.json" 21 | }, 22 | "plugins": ["prettier", "@typescript-eslint"], 23 | "settings": { 24 | "react": { 25 | // https://github.com/DRD4-7R/eslint-config-7r-building/issues/1 26 | "version": "999.999.999" 27 | } 28 | }, 29 | "rules": { 30 | "prettier/prettier": ["error", { "endOfLine": "auto" }], 31 | "no-console": 0, 32 | "class-methods-use-this": 0 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:bullseye as builder 2 | 3 | ARG NODE_VERSION=16.14.0 4 | 5 | RUN apt-get update; apt install -y curl 6 | RUN curl https://get.volta.sh | bash 7 | ENV VOLTA_HOME /root/.volta 8 | ENV PATH /root/.volta/bin:$PATH 9 | RUN volta install node@${NODE_VERSION} 10 | 11 | ####################################################################### 12 | 13 | RUN mkdir /app 14 | WORKDIR /app 15 | 16 | # NPM will not install any package listed in "devDependencies" when NODE_ENV is set to "production", 17 | # to install all modules: "npm install --production=false". 18 | # Ref: https://docs.npmjs.com/cli/v9/commands/npm-install#description 19 | 20 | ENV NODE_ENV production 21 | 22 | COPY . . 23 | 24 | RUN npm install --production=false && npm run build 25 | FROM debian:bullseye 26 | 27 | LABEL fly_launch_runtime="nodejs" 28 | 29 | COPY --from=builder /root/.volta /root/.volta 30 | COPY --from=builder /app /app 31 | 32 | WORKDIR /app 33 | ENV NODE_ENV production 34 | ENV PATH /root/.volta/bin:$PATH 35 | 36 | CMD [ "npm", "run", "start" ] 37 | -------------------------------------------------------------------------------- /domain/service/contentAggregatorService/post.ts: -------------------------------------------------------------------------------- 1 | export default class Post { 2 | private authorName: string; 3 | 4 | private title: string; 5 | 6 | private link: string; 7 | 8 | private description: string; 9 | 10 | private createdAt: Date; 11 | 12 | constructor({ 13 | authorName, 14 | title, 15 | link, 16 | description, 17 | createdAt, 18 | }: { 19 | authorName: string; 20 | title: string; 21 | link: string; 22 | description: string; 23 | createdAt: Date; 24 | }) { 25 | this.authorName = authorName; 26 | this.title = title; 27 | this.link = link; 28 | this.description = description; 29 | this.createdAt = createdAt; 30 | } 31 | 32 | getAuthorName(): string { 33 | return this.authorName; 34 | } 35 | 36 | getTitle(): string { 37 | return this.title; 38 | } 39 | 40 | getLink(): string { 41 | return this.link; 42 | } 43 | 44 | getDescription(): string { 45 | return this.description; 46 | } 47 | 48 | getCreatedAt(): Date { 49 | return this.createdAt; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /infrastructure/service/codewarsKataService.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import KataLeaderboardUser from "../../domain/service/kataService/kataLeaderboardUser"; 3 | import KataService from "../../domain/service/kataService/kataService"; 4 | 5 | interface LeaderboardUser { 6 | username: string; 7 | score: number; 8 | points: number[]; 9 | } 10 | 11 | interface LeaderboardChallenge { 12 | kata: string; 13 | year: number; 14 | week: number; 15 | } 16 | 17 | interface LeaderboardResponse { 18 | challenges: LeaderboardChallenge[]; 19 | users: LeaderboardUser[]; 20 | } 21 | 22 | export default class CodewarsKataService implements KataService { 23 | private baseUrl = "https://codewars.devpt.co"; 24 | 25 | async getLeaderboard(): Promise { 26 | const response = await fetch(`${this.baseUrl}/index.json`); 27 | const data = (await response.json()) as LeaderboardResponse; 28 | const leaderboard = data.users.map( 29 | (user: LeaderboardUser) => 30 | new KataLeaderboardUser({ 31 | username: user.username, 32 | score: user.score, 33 | points: user.points, 34 | }) 35 | ); 36 | 37 | return leaderboard; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /vitest/application/usecases/sendMessageToChannelUseCase.spec.ts: -------------------------------------------------------------------------------- 1 | import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; 2 | import { Client } from "discord.js"; 3 | import SendMessageToChannelUseCase from "../../../application/usecases/sendMessageToChannel/sendMessageToChannelUseCase"; 4 | import DiscordChatService from "../../../infrastructure/service/discordChatService"; 5 | import ChatService from "../../../domain/service/chatService"; 6 | 7 | describe("send message to channel use case", () => { 8 | beforeEach(() => { 9 | vi.mock("../../../infrastructure/service/discordChatService", () => ({ 10 | default: function mockDefault() { 11 | return { 12 | sendMessageToChannel: () => {}, 13 | }; 14 | }, 15 | })); 16 | 17 | vi.mock("discord.js", () => ({ 18 | Client: vi.fn(), 19 | })); 20 | }); 21 | 22 | it("should send message to channel in chatService", async () => { 23 | const discordClient = new Client({ intents: [] }); 24 | const chatService: ChatService = new DiscordChatService(discordClient); 25 | 26 | const spy = vi.spyOn(chatService, "sendMessageToChannel"); 27 | 28 | await new SendMessageToChannelUseCase({ 29 | chatService, 30 | }).execute({ 31 | message: ":point_right: https://dontasktoask.com/pt-pt/", 32 | channelId: "855861944930402344", 33 | }); 34 | 35 | expect(spy).toHaveBeenCalledTimes(1); 36 | expect(spy).toHaveBeenCalledWith(":point_right: https://dontasktoask.com/pt-pt/", "855861944930402344"); 37 | }); 38 | 39 | afterEach(() => { 40 | vi.resetAllMocks(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /infrastructure/service/lemmyContentAggregatorService.ts: -------------------------------------------------------------------------------- 1 | import Post from "../../domain/service/contentAggregatorService/post"; 2 | import ContentAggregatorService from "../../domain/service/contentAggregatorService/contentAggregatorService"; 3 | 4 | interface LemmyPostAuthor { 5 | display_name: string; 6 | name: string; 7 | } 8 | 9 | interface LemmyPostData { 10 | featured_community: boolean; 11 | featured_local: boolean; 12 | name: string; 13 | ap_id: string; 14 | body: string; 15 | published: string; 16 | } 17 | 18 | interface LemmyPost { 19 | creator: LemmyPostAuthor; 20 | post: LemmyPostData; 21 | } 22 | 23 | interface LemmyApiResponse { 24 | posts: LemmyPost[]; 25 | } 26 | 27 | export default class LemmyContentAggregatorService implements ContentAggregatorService { 28 | private feedUrl = "https://lemmy.pt/api/v3/post/list?community_name=devpt&limit=10&page=1&sort=New"; 29 | 30 | async fetchLastPosts(): Promise { 31 | let unpinnedPosts: Post[] = []; 32 | 33 | try { 34 | const response = await fetch(this.feedUrl); 35 | const data: LemmyApiResponse = await response.json(); 36 | 37 | unpinnedPosts = data.posts 38 | .filter((item: LemmyPost) => !item.post.featured_community && !item.post.featured_local) 39 | .map( 40 | (item: LemmyPost) => 41 | new Post({ 42 | authorName: item.creator.display_name || item.creator.name, 43 | title: item.post.name, 44 | link: item.post.ap_id, 45 | description: item.post.body, 46 | createdAt: new Date(item.post.published), 47 | }) 48 | ); 49 | } catch (e) { 50 | // Ignoring for now. 51 | } 52 | 53 | return unpinnedPosts; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /application/usecases/sendWelcomeMessageUseCase.ts: -------------------------------------------------------------------------------- 1 | import LoggerService from "../../domain/service/loggerService"; 2 | import MessageRepository from "../../domain/repository/messageRepository"; 3 | import ChatService from "../../domain/service/chatService"; 4 | import { ChatMember, ChannelSlug } from "../../types"; 5 | import ChannelResolver from "../../domain/service/channelResolver"; 6 | 7 | const replacePlaceholders = (phrases: string, memberID: string) => { 8 | const memberIdTag = `<@${memberID}>`; 9 | return phrases.replace("{MEMBER_ID}", memberIdTag); 10 | }; 11 | 12 | export default class SendWelcomeMessageUseCase { 13 | private messageRepository: MessageRepository; 14 | 15 | private chatService: ChatService; 16 | 17 | private loggerService: LoggerService; 18 | 19 | private channelResolver: ChannelResolver; 20 | 21 | constructor({ 22 | messageRepository, 23 | chatService, 24 | loggerService, 25 | channelResolver, 26 | }: { 27 | messageRepository: MessageRepository; 28 | chatService: ChatService; 29 | loggerService: LoggerService; 30 | channelResolver: ChannelResolver; 31 | }) { 32 | this.messageRepository = messageRepository; 33 | this.chatService = chatService; 34 | this.loggerService = loggerService; 35 | this.channelResolver = channelResolver; 36 | } 37 | 38 | async execute(member: ChatMember): Promise { 39 | this.loggerService.log("Member joined the server!"); 40 | 41 | const introPhrase = this.messageRepository.getRandomIntroMessage(); 42 | const welcomingPhrase = this.messageRepository.getRandomWelcomingMessage(); 43 | const finalPhrase = replacePlaceholders(`${introPhrase}${welcomingPhrase}`, member.id); 44 | 45 | this.loggerService.log(`[NEW JOIN] ${finalPhrase}`); 46 | 47 | this.chatService.sendMessageToChannel(finalPhrase, this.channelResolver.getBySlug(ChannelSlug.ENTRANCE)); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "build": "tsc", 4 | "start": "node dist/index.js", 5 | "start.dev": "nodemon", 6 | "lint": "eslint . --ext .ts", 7 | "lint-and-fix": "eslint . --ext .ts --fix", 8 | "format": "prettier --write .", 9 | "prepare": "node -e \"try { require('husky').install() } catch (e) {if (e.code !== 'MODULE_NOT_FOUND') throw e}\"", 10 | "test": "vitest", 11 | "test:once": "vitest run", 12 | "coverage": "vitest run --coverage" 13 | }, 14 | "engines": { 15 | "node": ">=20.19.0" 16 | }, 17 | "dependencies": { 18 | "cron": "^2.3.1", 19 | "discord.js": "^14.16.3", 20 | "dotenv": "^16.0.1", 21 | "node-fetch": "^2.6.6" 22 | }, 23 | "devDependencies": { 24 | "@babel/eslint-parser": "^7.18.9", 25 | "@babel/plugin-syntax-import-assertions": "^7.18.6", 26 | "@commitlint/cli": "^17.3.0", 27 | "@commitlint/config-conventional": "^17.3.0", 28 | "@types/jest": "28.1.6", 29 | "@types/node": "^22.19.1", 30 | "@types/node-fetch": "^2.6.2", 31 | "@typescript-eslint/eslint-plugin": "^5.37.0", 32 | "@typescript-eslint/parser": "^5.37.0", 33 | "conventional-changelog-atom": "^2.0.8", 34 | "cross-env": "7.0.3", 35 | "eslint": "^8.23.1", 36 | "eslint-config-airbnb": "^19.0.4", 37 | "eslint-config-airbnb-base": "^15.0.0", 38 | "eslint-config-airbnb-typescript": "^17.0.0", 39 | "eslint-config-prettier": "^8.5.0", 40 | "eslint-plugin-import": "^2.26.0", 41 | "eslint-plugin-jsx-a11y": "^6.6.0", 42 | "eslint-plugin-prettier": "^5.2.1", 43 | "eslint-plugin-react": "^7.30.1", 44 | "eslint-plugin-react-hooks": "^4.6.0", 45 | "husky": "8.0.1", 46 | "jest": "28.1.3", 47 | "jest-cli": "28.1.3", 48 | "prettier": "^3.3.3", 49 | "ts-node": "^10.9.2", 50 | "typescript": "^5.9.3", 51 | "vite": "^7.2.4", 52 | "vitest": "^4.0.14", 53 | "vitest-mock-extended": "^3.1.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /vitest/service/commandUseCaseResolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; 2 | import CommandUseCaseResolver from "../../domain/service/commandUseCaseResolver"; 3 | import { Command, Context } from "../../types"; 4 | 5 | describe("CommandUseCaseResolver", () => { 6 | let commandUseCaseResolver: CommandUseCaseResolver; 7 | const mockContext: Context = { 8 | channelId: "test-channel", 9 | }; 10 | 11 | const mockCommandJa: Command = { 12 | name: "!ja", 13 | execute: vi.fn(), 14 | }; 15 | 16 | const mockCommandOc: Command = { 17 | name: "!oc", 18 | execute: vi.fn(), 19 | }; 20 | 21 | const mockCommandCwl: Command = { 22 | name: "!cwl", 23 | execute: vi.fn(), 24 | }; 25 | 26 | beforeEach(async () => { 27 | commandUseCaseResolver = new CommandUseCaseResolver({ 28 | commands: [mockCommandJa, mockCommandCwl, mockCommandOc], 29 | loggerService: { log: vi.fn() }, 30 | }); 31 | }); 32 | 33 | it("should invoke for !ja command", async () => { 34 | await commandUseCaseResolver.resolveByCommand("!ja", mockContext); 35 | 36 | expect(() => commandUseCaseResolver.resolveByCommand("!ja", mockContext)).not.toThrow(); 37 | }); 38 | 39 | it("should invoke for !oc command", async () => { 40 | await commandUseCaseResolver.resolveByCommand("!oc", mockContext); 41 | 42 | expect(() => commandUseCaseResolver.resolveByCommand("!oc", mockContext)).not.toThrow(); 43 | }); 44 | 45 | it("should invoke for !cwl command", async () => { 46 | await commandUseCaseResolver.resolveByCommand("!cwl", mockContext); 47 | 48 | expect(() => commandUseCaseResolver.resolveByCommand("!cwl", mockContext)).not.toThrow(); 49 | }); 50 | 51 | it("should return false for unknown command", async () => { 52 | await expect(commandUseCaseResolver.resolveByCommand("!unknown", mockContext)).resolves.toBe(false); 53 | }); 54 | 55 | afterEach(() => { 56 | vi.resetAllMocks(); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Discord Bot devPT 2 | 3 | ![](https://avatars.githubusercontent.com/u/79173787?s=200&v=4) 4 | 5 | [![language](https://img.shields.io/badge/language-TypeScript-3178C6)](https://www.typescriptlang.org/) [![node_major_version](https://img.shields.io/badge/node_major_version-20-5fa04e)](https://nodejs.org/en) [![license](https://img.shields.io/github/license/devpt-org/discord-bot)](LICENSE) 6 | 7 | ## Descrição 8 | 9 | Projeto criado em conjunto por elementos do devPT, com o intuito de criar um bot para adicionar funcionalidades extra ao servidor. 10 | 11 | O devPT é uma comunidade de língua portuguesa sem fins lucrativos para developers, disponibilizando um espaço em comum para engenheiros, estudantes e curiosos da área do desenvolvimento de software para se encontrarem e desenvolverem iniciativas Open-Source. 12 | 13 | ## Indice 14 | 15 | - [Utilização](#utilização) 16 | - [Guia de Contribuição](#guia-de-contribuição) 17 | - [Licença](#licença) 18 | - [Contactos](#contactos) 19 | - [Agradecimentos](#agradecimentos) 20 | 21 | ## Utilização 22 | 23 | O bot está disponível no nosso discord [devPT](https://devpt.co/discord) e apresenta as seguintes funcionalidades / comandos: 24 | 25 | - `!ja` - gera uma mensagem relacionada com "não perguntes para perguntar". 26 | - `!oc` - gera uma mensagem com o aviso de que o servidor é apenas para questões relacionadas com programação. 27 | - `!cwl` - gera uma mensagem que exibe o 'leaderboard' do CodeWars. 28 | - Mensagem na entrada de novos utilizadores 29 | 30 | ## Guia de Contribuição 31 | 32 | Para detalhes sobre o nosso código de conduta e o processo para submeter um `pull request` neste projeto, por favor consulte o nosso [Guia de Contribuição](CONTRIBUTING.md). 33 | 34 | ## Licença 35 | 36 | Este projeto está disponível gratuitamente para uso não comercial. Para mais informação, por favor consulte a nossa [Licença](LICENSE) 37 | 38 | ## Contactos 39 | 40 | [devPT](https://devpt.co/discord) 41 | 42 | ## Agradecimentos 43 | 44 | 45 | 46 | 47 | 48 | [Voltar ao início](#top) 49 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | # Auto-restore Gitpod environment variables 3 | # https://www.gitpod.io/guides/automate-env-files-with-gitpod-environment-variables 4 | - name: Restore .env file 5 | command: | 6 | if [ -f .env ]; then 7 | # If this workspace already has a .env, don't override it 8 | # Local changes survive a workspace being opened and closed 9 | # but they will not persist between separate workspaces for the same repo 10 | echo "Found .env in workspace" 11 | else 12 | if [ -z "${DOTENV}" ]; then 13 | # There is no $DOTENV from a previous workspace 14 | # Default to the example .env 15 | echo "Setting example .env" 16 | cp .env.example .env 17 | else 18 | # After making changes to .env, run this line to persist it to $DOTENV 19 | # gp env DOTENV="$(base64 .env | tr -d '\n')" 20 | # 21 | # Environment variables set this way are shared between all your workspaces for this repo 22 | # The lines below will read $DOTENV and print a .env file 23 | echo "Restoring .env from Gitpod" 24 | echo "${DOTENV}" | base64 -d > .env 25 | fi 26 | fi 27 | - name: Start bot 28 | init: npm install 29 | command: npm run start 30 | 31 | # Gitpod suggested commands to add github support to Gitpod. 32 | # This should make using Gitpod easier and faster. 33 | github: 34 | prebuilds: 35 | # enable for the master/default branch (defaults to true) 36 | master: true 37 | # enable for all branches in this repo (defaults to false) 38 | branches: false 39 | # enable for pull requests coming from this repo (defaults to true) 40 | pullRequests: true 41 | # enable for pull requests coming from forks (defaults to false) 42 | pullRequestsFromForks: true 43 | # add a "Review in Gitpod" button as a comment to pull requests (defaults to true) 44 | addComment: true 45 | # add a "Review in Gitpod" button to pull requests (defaults to false) 46 | addBadge: false 47 | # add a label once the prebuild is ready to pull requests (defaults to false) 48 | addLabel: prebuilt-in-gitpod 49 | -------------------------------------------------------------------------------- /vitest/application/usecases/rejectAnonymousQuestionUseCase.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 | import type { Mock } from "vitest"; 3 | import { ButtonInteraction, EmbedBuilder } from "discord.js"; 4 | import RejectAnonymousQuestionUseCase from "../../../application/usecases/rejectAnonymousQuestionUseCase"; 5 | import ChatService from "../../../domain/service/chatService"; 6 | import LoggerService from "../../../domain/service/loggerService"; 7 | import QuestionTrackingService from "../../../domain/service/questionTrackingService"; 8 | 9 | describe("RejectAnonymousQuestionUseCase", () => { 10 | let chatService: ChatService; 11 | let loggerService: LoggerService; 12 | let questionTrackingService: QuestionTrackingService; 13 | let interaction: ButtonInteraction; 14 | 15 | beforeEach(() => { 16 | chatService = { 17 | sendMessageToChannel: vi.fn(), 18 | } as unknown as ChatService; 19 | 20 | loggerService = { 21 | log: vi.fn(), 22 | } as unknown as LoggerService; 23 | 24 | questionTrackingService = { 25 | removeQuestion: vi.fn(), 26 | } as unknown as QuestionTrackingService; 27 | 28 | interaction = { 29 | update: vi.fn(), 30 | } as unknown as ButtonInteraction; 31 | }); 32 | 33 | afterEach(() => { 34 | vi.resetAllMocks(); 35 | }); 36 | 37 | it("notifies user of rejection, updates embed and clears tracking", async () => { 38 | const useCase = new RejectAnonymousQuestionUseCase({ 39 | chatService, 40 | loggerService, 41 | questionTrackingService, 42 | }); 43 | 44 | await useCase.execute({ 45 | questionId: "321", 46 | moderatorId: "mod-9", 47 | interactionId: "reject_321_dm-77_user-55", 48 | questionContent: "Will it blend?", 49 | interaction, 50 | }); 51 | 52 | expect(chatService.sendMessageToChannel).toHaveBeenCalledWith( 53 | "A tua pergunta anónima foi rejeitada pelos moderadores.", 54 | "dm-77" 55 | ); 56 | 57 | expect(questionTrackingService.removeQuestion).toHaveBeenCalledWith("user-55"); 58 | 59 | expect(interaction.update).toHaveBeenCalledTimes(1); 60 | const payload = (interaction.update as unknown as Mock).mock.calls[0][0]; 61 | expect(payload.components).toEqual([]); 62 | expect(payload.embeds[0]).toBeInstanceOf(EmbedBuilder); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /application/command/codewarsLeaderboardCommand.ts: -------------------------------------------------------------------------------- 1 | import { SendCodewarsLeaderboardToChannelInput } from "application/usecases/sendCodewarsLeaderboardToChannel/sendCodewarsLeaderboardToChannelInput"; 2 | import { Command } from "../../types"; 3 | import KataService from "../../domain/service/kataService/kataService"; 4 | import ChatService from "../../domain/service/chatService"; 5 | import KataLeaderboardUser from "../../domain/service/kataService/kataLeaderboardUser"; 6 | 7 | export default class CodewarsLeaderboardCommand implements Command { 8 | readonly name = "!cwl"; 9 | 10 | private chatService: ChatService; 11 | 12 | private kataService: KataService; 13 | 14 | constructor(chatService: ChatService, kataService: KataService) { 15 | this.chatService = chatService; 16 | this.kataService = kataService; 17 | } 18 | 19 | private formatLeaderboard(leaderboard: KataLeaderboardUser[]): string { 20 | let output = "```"; 21 | let position = 1; 22 | 23 | const leaderboardTotalEntriesToShow = 10; 24 | const leaderboardEntries = leaderboard.slice(0, leaderboardTotalEntriesToShow); 25 | const leaderboardEntriesLeft = leaderboard.length - leaderboardTotalEntriesToShow; 26 | 27 | leaderboardEntries.forEach((user: KataLeaderboardUser) => { 28 | const pointsCollection = user.getPoints().map((points: number) => points || 0); 29 | 30 | output += `${position}. ${user.getUsername()} - ${user.getScore()} - [${pointsCollection.join(",")}] points 31 | `; 32 | 33 | position += 1; 34 | }); 35 | 36 | output += "```"; 37 | 38 | if (leaderboardEntriesLeft > 1) { 39 | output += ` 40 | ... e ${leaderboardEntriesLeft} outras participações em https://codewars.devpt.co`; 41 | } else if (leaderboardEntriesLeft === 1) { 42 | output += ` 43 | ... e 1 outra participação em https://codewars.devpt.co`; 44 | } 45 | 46 | return output; 47 | } 48 | 49 | async execute({ channelId }: SendCodewarsLeaderboardToChannelInput): Promise { 50 | const leaderboard = await this.kataService.getLeaderboard(); 51 | 52 | if (leaderboard.length === 0) { 53 | this.chatService.sendMessageToChannel("Ainda não existem participantes nesta ediçăo do desafio.", channelId); 54 | return; 55 | } 56 | 57 | const formattedLeaderboard = this.formatLeaderboard(leaderboard); 58 | this.chatService.sendMessageToChannel(formattedLeaderboard, channelId); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /application/command/anonymousQuestionCommand.ts: -------------------------------------------------------------------------------- 1 | import { Command, Context } from "../../types"; 2 | import ChatService from "../../domain/service/chatService"; 3 | import LoggerService from "../../domain/service/loggerService"; 4 | import ChannelResolver from "../../domain/service/channelResolver"; 5 | import SendAnonymousQuestionUseCase from "../usecases/sendAnonymousQuestion/sendAnonymousQuestionUseCase"; 6 | import QuestionTrackingService from "../../domain/service/questionTrackingService"; 7 | 8 | export default class AnonymousQuestionCommand implements Command { 9 | readonly name = "!pergunta"; 10 | 11 | private readonly chatService: ChatService; 12 | 13 | private readonly loggerService: LoggerService; 14 | 15 | private readonly channelResolver: ChannelResolver; 16 | 17 | private readonly questionTrackingService: QuestionTrackingService; 18 | 19 | constructor( 20 | chatService: ChatService, 21 | loggerService: LoggerService, 22 | channelResolver: ChannelResolver, 23 | questionTrackingService: QuestionTrackingService 24 | ) { 25 | this.chatService = chatService; 26 | this.loggerService = loggerService; 27 | this.channelResolver = channelResolver; 28 | this.questionTrackingService = questionTrackingService; 29 | } 30 | 31 | async execute(context: Context): Promise { 32 | if (!context.message) { 33 | return; 34 | } 35 | 36 | const { message } = context; 37 | 38 | const isDM = !message.inGuild(); 39 | 40 | if (!isDM) { 41 | this.chatService.sendMessageToChannel("Este comando só pode ser usado em mensagens diretas.", context.channelId); 42 | return; 43 | } 44 | 45 | const questionContent = message.content.replace(/!pergunta\s+/i, "").trim(); 46 | 47 | if (!questionContent) { 48 | this.chatService.sendMessageToChannel( 49 | "Por favor, forneçe uma pergunta após o comando. Exemplo: `!pergunta Como faço para...`", 50 | message.channelId 51 | ); 52 | return; 53 | } 54 | 55 | const sendAnonymousQuestionUseCase = new SendAnonymousQuestionUseCase({ 56 | chatService: this.chatService, 57 | loggerService: this.loggerService, 58 | channelResolver: this.channelResolver, 59 | questionTrackingService: this.questionTrackingService, 60 | }); 61 | 62 | await sendAnonymousQuestionUseCase.execute({ 63 | userId: message.author.id, 64 | username: message.author.username, 65 | questionContent, 66 | dmChannelId: message.channelId, 67 | }); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # VS Code 2 | .vscode 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | node_modules/ 46 | jspm_packages/ 47 | 48 | # Snowpack dependency directory (https://snowpack.dev/) 49 | web_modules/ 50 | 51 | # TypeScript cache 52 | *.tsbuildinfo 53 | 54 | # Optional npm cache directory 55 | .npm 56 | 57 | # Optional eslint cache 58 | .eslintcache 59 | 60 | # Optional stylelint cache 61 | .stylelintcache 62 | 63 | # Microbundle cache 64 | .rpt2_cache/ 65 | .rts2_cache_cjs/ 66 | .rts2_cache_es/ 67 | .rts2_cache_umd/ 68 | 69 | # Optional REPL history 70 | .node_repl_history 71 | 72 | # Output of 'npm pack' 73 | *.tgz 74 | 75 | # Yarn Integrity file 76 | .yarn-integrity 77 | 78 | # dotenv environment variable files 79 | .env 80 | .env.development.local 81 | .env.test.local 82 | .env.production.local 83 | .env.local 84 | 85 | # parcel-bundler cache (https://parceljs.org/) 86 | .cache 87 | .parcel-cache 88 | 89 | # Next.js build output 90 | .next 91 | out 92 | 93 | # Nuxt.js build / generate output 94 | .nuxt 95 | dist 96 | 97 | # Gatsby files 98 | .cache/ 99 | # Comment in the public line in if your project uses Gatsby and not Next.js 100 | # https://nextjs.org/blog/next-9-1#public-directory-support 101 | # public 102 | 103 | # vuepress build output 104 | .vuepress/dist 105 | 106 | # vuepress v2.x temp and cache directory 107 | .temp 108 | .cache 109 | 110 | # Docusaurus cache and generated files 111 | .docusaurus 112 | 113 | # Serverless directories 114 | .serverless/ 115 | 116 | # FuseBox cache 117 | .fusebox/ 118 | 119 | # DynamoDB Local files 120 | .dynamodb/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v2 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .yarn/install-state.gz 133 | .pnp.* -------------------------------------------------------------------------------- /vitest/application/usecases/sendWelcomeMessageUseCase.spec.ts: -------------------------------------------------------------------------------- 1 | import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; 2 | import { Client } from "discord.js"; 3 | import SendWelcomeMessageUseCase from "../../../application/usecases/sendWelcomeMessageUseCase"; 4 | import DiscordChatService from "../../../infrastructure/service/discordChatService"; 5 | import FileMessageRepository from "../../../infrastructure/repository/fileMessageRepository"; 6 | import ConsoleLoggerService from "../../../infrastructure/service/consoleLoggerService"; 7 | import { ChatMember } from "../../../types"; 8 | import ChatService from "../../../domain/service/chatService"; 9 | import ChannelResolver from "../../../domain/service/channelResolver"; 10 | 11 | describe("send welcome message to channel use case", () => { 12 | beforeEach(() => { 13 | vi.mock("../../../infrastructure/repository/fileMessageRepository", () => ({ 14 | default: function mockDefault() { 15 | return { 16 | getRandomIntroMessage: () => "Olá {MEMBER_ID}", 17 | getRandomWelcomingMessage: () => ", bem-vindo!", 18 | }; 19 | }, 20 | })); 21 | 22 | vi.mock("../../../infrastructure/service/discordChatService", () => ({ 23 | default: function mockDefault() { 24 | return { 25 | sendMessageToChannel: () => {}, 26 | }; 27 | }, 28 | })); 29 | 30 | vi.mock("discord.js", () => ({ 31 | Client: vi.fn(), 32 | })); 33 | 34 | vi.mock("../../../infrastructure/service/consoleLoggerService", () => ({ 35 | default: function mockDefault() { 36 | return { 37 | log: () => {}, 38 | }; 39 | }, 40 | })); 41 | 42 | vi.mock("../../../domain/service/channelResolver", () => ({ 43 | default: function mockDefault() { 44 | return { 45 | getBySlug: () => "855861944930402344", 46 | }; 47 | }, 48 | })); 49 | }); 50 | 51 | it("should send message to channel in chatService", async () => { 52 | const discordClient = new Client({ intents: [] }); 53 | const chatService: ChatService = new DiscordChatService(discordClient); 54 | 55 | const spy = vi.spyOn(chatService, "sendMessageToChannel"); 56 | 57 | const chatMember: ChatMember = { 58 | id: "1234567890", 59 | }; 60 | 61 | await new SendWelcomeMessageUseCase({ 62 | messageRepository: new FileMessageRepository(), 63 | chatService, 64 | loggerService: new ConsoleLoggerService(), 65 | channelResolver: new ChannelResolver(), 66 | }).execute(chatMember); 67 | 68 | expect(spy).toHaveBeenCalledTimes(1); 69 | expect(spy).toHaveBeenCalledWith("Olá <@1234567890>, bem-vindo!", "855861944930402344"); 70 | }); 71 | 72 | afterEach(() => { 73 | vi.resetAllMocks(); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribuição 2 | 3 | Antes de mais, obrigado por considerares participar neste projeto. 4 | 5 | Neste documento encontram-se instruções detalhadas para que possas contribuir em conformidade com a estrutura e organização do projecto. Estas instruções não são para ser consideradas regras rígidas mas sim uma orientação generalizada do que esperamos quando alguém contribui, utiliza o bom senso quando estiveres a contribuir para o projecto. 6 | 7 | #### Tabela de conteúdo 8 | 9 | 1. [Código de conduta](#código-de-conduta) 10 | 2. [Arquitetura](#arquitetura) 11 | 3. [Linguagem e versões](#linguagem-e-versões) 12 | 4. [Como contribuir](#como-contribuir) 13 | 5. [Guia de estilos](#guia-de-estilos) 14 | - [Código](#código) 15 | - [Issues](#issues) 16 | - [Mensagens de commit](#mensagens-de-commit) 17 | 18 | ## Código de conduta 19 | 20 | Ao participares neste projeto, esperamos que tenhas em consideração as seguintes regras: 21 | 22 | - Respeito pelo próximo 23 | - Uso de linguagem inclusiva e acolhedora 24 | - Aceitação de crítica construtiva 25 | - Foco no que é melhor para a comunidade 26 | 27 | ## Arquitetura 28 | 29 | - Este projeto segue, na sua grande maioria, o estilo de _Domain-Driven Design_ (DDD). Para mais informação consulta o nosso documento sobre [Arquitetura](ARCHITECTURE) 30 | 31 | ## Linguagem e versões 32 | 33 | - A linguagem utilizada é Typescript, e tem Node e NPM como dependências principais. As versões necessárias podem ser consultadas no [ReadMe](README). 34 | 35 | Para instalar as dependências executa o comando `npm install`. 36 | 37 | ## Como contribuir 38 | 39 | - Encontra um issue que te sentes capaz de ajudar. Se for a primeira contribuição, issues marcados com `bom primeiro issue` são normalmente considerados bons para principiantes. 40 | - Faz `fork` deste repositório para a tua conta pessoal. 41 | - Depois podes utilizar o `git` para fazer um `clone` para a tua máquina pessoal. 42 | - Cria um novo branch `git checkout -b novo-nome-branch`. 43 | - Faz as modificações que achas necessárias. 44 | - Faz commit do teu código para a origem do teu `branch`. 45 | - Cria um `pull request` no github para que possa ser revisto pela equipa. 46 | - Se receberes comentários ajusta o teu código e faz novos commits. 47 | - Quando for aprovado, o teu código vai ser `merged` com o `branch` main. 48 | 49 | ## Guia de estilos 50 | 51 | #### Código 52 | 53 | - O código deve ser escrito seguindo o estilo já presente no repositório. 54 | 55 | #### Issues 56 | 57 | - **Utiliza um titulo claro e descritivo** no issue para identificar a sugestão. 58 | - **Fornece uma descrição exaustiva da melhoria sugerida** usando o máximo detalhe possível. 59 | - Se aplicável, descreve os passos para replicar o issue. 60 | 61 | #### Mensagens de commit 62 | 63 | - Inclui referência ao Issue em questão se aplicável. 64 | -------------------------------------------------------------------------------- /infrastructure/service/discordChatService.ts: -------------------------------------------------------------------------------- 1 | import { ActionRowBuilder, ButtonBuilder, ButtonStyle, Client, EmbedBuilder, TextBasedChannel } from "discord.js"; 2 | import ChatService, { ButtonOptions, EmbedOptions } from "../../domain/service/chatService"; 3 | 4 | export default class DiscordChatService implements ChatService { 5 | constructor(private client: Client) {} 6 | 7 | async sendMessageToChannel(message: string, channelId: string): Promise { 8 | const channel = await this.client.channels.fetch(channelId); 9 | 10 | if (channel === null) { 11 | throw new Error(`Channel with id ${channelId} not found!`); 12 | } 13 | 14 | if (!channel.isTextBased() || !("send" in channel)) { 15 | throw new Error(`Channel with id ${channelId} is not a text channel!`); 16 | } 17 | 18 | const textChannel = channel as TextBasedChannel & { send: (content: string) => Promise }; 19 | await textChannel.send(message); 20 | } 21 | 22 | private mapButtonStyle(style: ButtonOptions["style"]): ButtonStyle { 23 | switch (style) { 24 | case "PRIMARY": 25 | return ButtonStyle.Primary; 26 | case "SECONDARY": 27 | return ButtonStyle.Secondary; 28 | case "SUCCESS": 29 | return ButtonStyle.Success; 30 | case "DANGER": 31 | return ButtonStyle.Danger; 32 | default: 33 | return ButtonStyle.Secondary; 34 | } 35 | } 36 | 37 | async sendEmbedWithButtons(channelId: string, embedOptions: EmbedOptions, buttons: ButtonOptions[]): Promise { 38 | const channel = await this.client.channels.fetch(channelId); 39 | 40 | if (channel === null) { 41 | throw new Error(`Channel with id ${channelId} not found!`); 42 | } 43 | 44 | if (!channel.isTextBased() || !("send" in channel)) { 45 | throw new Error(`Channel with id ${channelId} is not a text channel!`); 46 | } 47 | 48 | const textChannel = channel as TextBasedChannel & { send: (content: unknown) => Promise }; 49 | 50 | const embed = new EmbedBuilder() 51 | .setTitle(embedOptions.title ?? "") 52 | .setDescription(embedOptions.description ?? "") 53 | .setColor(embedOptions.color ?? 0x0099ff); 54 | 55 | if (embedOptions.fields) { 56 | embed.setFields(embedOptions.fields); 57 | } 58 | 59 | if (embedOptions.footer) { 60 | embed.setFooter({ text: embedOptions.footer.text, iconURL: embedOptions.footer.iconURL }); 61 | } 62 | 63 | const row = new ActionRowBuilder().addComponents( 64 | ...buttons.map((button) => 65 | new ButtonBuilder() 66 | .setCustomId(button.customId) 67 | .setLabel(button.label) 68 | .setStyle(this.mapButtonStyle(button.style)) 69 | ) 70 | ); 71 | 72 | await textChannel.send({ embeds: [embed], components: [row] }); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /vitest/application/usecases/approveAnonymousQuestionUseCase.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 | import type { Mock } from "vitest"; 3 | import { ButtonInteraction, EmbedBuilder } from "discord.js"; 4 | import ApproveAnonymousQuestionUseCase from "../../../application/usecases/approveAnonymousQuestionUseCase"; 5 | import ChatService from "../../../domain/service/chatService"; 6 | import ChannelResolver from "../../../domain/service/channelResolver"; 7 | import LoggerService from "../../../domain/service/loggerService"; 8 | import QuestionTrackingService from "../../../domain/service/questionTrackingService"; 9 | 10 | describe("ApproveAnonymousQuestionUseCase", () => { 11 | let chatService: ChatService; 12 | let loggerService: LoggerService; 13 | let channelResolver: ChannelResolver; 14 | let questionTrackingService: QuestionTrackingService; 15 | let interaction: ButtonInteraction; 16 | 17 | beforeEach(() => { 18 | chatService = { 19 | sendMessageToChannel: vi.fn(), 20 | } as unknown as ChatService; 21 | 22 | loggerService = { 23 | log: vi.fn(), 24 | } as unknown as LoggerService; 25 | 26 | channelResolver = { 27 | getBySlug: vi.fn(() => "questions-channel"), 28 | } as unknown as ChannelResolver; 29 | 30 | questionTrackingService = { 31 | removeQuestion: vi.fn(), 32 | } as unknown as QuestionTrackingService; 33 | 34 | interaction = { 35 | guildId: "guild-1", 36 | update: vi.fn(), 37 | } as unknown as ButtonInteraction; 38 | }); 39 | 40 | afterEach(() => { 41 | vi.resetAllMocks(); 42 | }); 43 | 44 | it("publishes to public channel, notifies the user and clears tracking", async () => { 45 | const useCase = new ApproveAnonymousQuestionUseCase({ 46 | chatService, 47 | loggerService, 48 | channelResolver, 49 | questionTrackingService, 50 | }); 51 | 52 | await useCase.execute({ 53 | questionId: "123", 54 | moderatorId: "mod-1", 55 | interactionId: "approve_123_dm-99_user-42", 56 | questionContent: "What is the airspeed?", 57 | interaction, 58 | }); 59 | 60 | expect(chatService.sendMessageToChannel).toHaveBeenCalledWith( 61 | "**Pergunta anónima:**\n\nWhat is the airspeed?", 62 | "questions-channel" 63 | ); 64 | 65 | expect(chatService.sendMessageToChannel).toHaveBeenCalledWith( 66 | expect.stringContaining("aprovada e publicada"), 67 | "dm-99" 68 | ); 69 | 70 | expect(questionTrackingService.removeQuestion).toHaveBeenCalledWith("user-42"); 71 | 72 | expect(interaction.update).toHaveBeenCalledTimes(1); 73 | const payload = (interaction.update as unknown as Mock).mock.calls[0][0]; 74 | expect(payload.components).toEqual([]); 75 | expect(payload.embeds[0]).toBeInstanceOf(EmbedBuilder); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /application/usecases/rejectAnonymousQuestionUseCase.ts: -------------------------------------------------------------------------------- 1 | import { ButtonInteraction, EmbedBuilder } from "discord.js"; 2 | import ChatService from "../../domain/service/chatService"; 3 | import LoggerService from "../../domain/service/loggerService"; 4 | import QuestionTrackingService from "../../domain/service/questionTrackingService"; 5 | 6 | interface RejectAnonymousQuestionUseCaseOptions { 7 | chatService: ChatService; 8 | loggerService: LoggerService; 9 | questionTrackingService: QuestionTrackingService; 10 | } 11 | 12 | interface RejectAnonymousQuestionParams { 13 | questionId: string; 14 | moderatorId: string; 15 | interactionId: string; 16 | questionContent: string; 17 | interaction: ButtonInteraction; 18 | reason?: string; 19 | } 20 | 21 | export default class RejectAnonymousQuestionUseCase { 22 | private chatService: ChatService; 23 | 24 | private loggerService: LoggerService; 25 | 26 | private questionTrackingService: QuestionTrackingService; 27 | 28 | constructor({ chatService, loggerService, questionTrackingService }: RejectAnonymousQuestionUseCaseOptions) { 29 | this.chatService = chatService; 30 | this.loggerService = loggerService; 31 | this.questionTrackingService = questionTrackingService; 32 | } 33 | 34 | async execute({ 35 | questionId, 36 | moderatorId, 37 | interactionId, 38 | questionContent, 39 | interaction, 40 | }: RejectAnonymousQuestionParams) { 41 | this.loggerService.log(`Rejeitando a pergunta ${questionId} pelo moderador ${moderatorId}`); 42 | 43 | const customIdParts = interactionId.split("_"); 44 | const dmChannelId = customIdParts[2]; 45 | const userId = customIdParts[3]; 46 | 47 | if (dmChannelId) { 48 | await this.chatService.sendMessageToChannel( 49 | `A tua pergunta anónima foi rejeitada pelos moderadores.`, 50 | dmChannelId 51 | ); 52 | } 53 | 54 | try { 55 | const updatedEmbed = new EmbedBuilder() 56 | .setTitle("Pergunta Anónima Rejeitada") 57 | .setDescription(questionContent) 58 | .setColor(0xff0000) // Red 59 | .addFields([ 60 | { name: "ID", value: questionId || "N/A", inline: true }, 61 | { name: "Rejeitado por", value: moderatorId ? `<@${moderatorId}>` : "Desconhecido", inline: true }, 62 | ]) 63 | .setFooter({ text: "Esta pergunta foi rejeitada" }); 64 | 65 | await interaction.update({ 66 | embeds: [updatedEmbed], 67 | components: [], 68 | }); 69 | } catch (error) { 70 | this.loggerService.log(`Erro ao atualizar mensagem de moderação: ${error}`); 71 | } 72 | 73 | if (userId) { 74 | this.questionTrackingService.removeQuestion(userId); 75 | this.loggerService.log(`Pergunta ${questionId} removida do tracking para o user ${userId}`); 76 | } 77 | 78 | return { success: true }; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /application/usecases/sendAnonymousQuestion/sendAnonymousQuestionUseCase.ts: -------------------------------------------------------------------------------- 1 | import LoggerService from "../../../domain/service/loggerService"; 2 | import ChatService from "../../../domain/service/chatService"; 3 | import ChannelResolver from "../../../domain/service/channelResolver"; 4 | import { ChannelSlug } from "../../../types"; 5 | import QuestionTrackingService from "../../../domain/service/questionTrackingService"; 6 | 7 | export default class SendAnonymousQuestionUseCase { 8 | private chatService: ChatService; 9 | 10 | private loggerService: LoggerService; 11 | 12 | private channelResolver: ChannelResolver; 13 | 14 | private questionTrackingService: QuestionTrackingService; 15 | 16 | constructor({ 17 | chatService, 18 | loggerService, 19 | channelResolver, 20 | questionTrackingService, 21 | }: { 22 | chatService: ChatService; 23 | loggerService: LoggerService; 24 | channelResolver: ChannelResolver; 25 | questionTrackingService: QuestionTrackingService; 26 | }) { 27 | this.chatService = chatService; 28 | this.loggerService = loggerService; 29 | this.channelResolver = channelResolver; 30 | this.questionTrackingService = questionTrackingService; 31 | } 32 | 33 | async execute({ 34 | userId, 35 | username, 36 | questionContent, 37 | dmChannelId, 38 | }: { 39 | userId: string; 40 | username: string; 41 | questionContent: string; 42 | dmChannelId: string; 43 | }): Promise { 44 | if (this.questionTrackingService.hasPendingQuestion(userId)) { 45 | await this.chatService.sendMessageToChannel( 46 | "Já tens uma pergunta pendente. Por favor, aguarda até que seja aprovada ou rejeitada antes de enviar outra.", 47 | dmChannelId 48 | ); 49 | return; 50 | } 51 | 52 | this.loggerService.log(`Pergunta anónima recebida de ${username}: ${questionContent}`); 53 | 54 | const questionId = Date.now().toString(); 55 | 56 | const moderationChannelId = this.channelResolver.getBySlug(ChannelSlug.MODERATION); 57 | 58 | const approveCustomId = `approve_${questionId}_${dmChannelId}_${userId}`; 59 | const rejectCustomId = `reject_${questionId}_${dmChannelId}_${userId}`; 60 | 61 | await this.chatService.sendEmbedWithButtons( 62 | moderationChannelId, 63 | { 64 | title: "Nova Pergunta Anónima", 65 | description: questionContent, 66 | color: 0x3498db, // Blue 67 | fields: [ 68 | { name: "ID", value: questionId, inline: true }, 69 | { name: "Enviado por", value: username, inline: true }, 70 | ], 71 | footer: { text: "Pergunta anónima - Moderação necessária" }, 72 | }, 73 | [ 74 | { 75 | customId: approveCustomId, 76 | label: "Aprovar", 77 | style: "SUCCESS", 78 | }, 79 | { 80 | customId: rejectCustomId, 81 | label: "Rejeitar", 82 | style: "DANGER", 83 | }, 84 | ] 85 | ); 86 | 87 | this.questionTrackingService.trackQuestion(userId, questionId); 88 | 89 | await this.chatService.sendMessageToChannel( 90 | "A tua pergunta anónima foi recebida e será analisada pelos moderadores.", 91 | dmChannelId 92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /assets/phrases/welcoming.json: -------------------------------------------------------------------------------- 1 | [ 2 | ", o que te traz por cá?", 3 | ", como estás?", 4 | ", já viste o sol hoje?", 5 | ", já viste a luz do dia?", 6 | ". Esse JavaScript, como é que está?", 7 | ". Esse Pascal, está em dia?", 8 | ". E esse curso, quando é que se acaba?", 9 | ". Deixa-me adivinhar, estás com um problema no router?", 10 | ". Deixa-me adivinhar, estás com um problema na impressora?", 11 | ", também fizeste daqueles bootcamps e agora és o supra-sumo do ?", 12 | ". Java ou JavaScript?", 13 | ". Então? Ideia milionária e procuras developers grátis?", 14 | ". Deixa-me adivinhar, tens uma ideia para um website?", 15 | ". Então, ainda não leste o enunciado mas precisas de ajuda com o tpc?", 16 | ". Lá encontraste um server de developers e agora queres que te façam o tpc?", 17 | ". Já experimentaste compilar outra vez?", 18 | ". Já experimentaste desligar e voltar a ligar?", 19 | ". A única solução que vejo para isso é formatar...", 20 | ". E quê? Já falas melhor em Python que em português?", 21 | ". Então e qual a linguagem que queres aprender?", 22 | ", se já viste talvez aqui tenhas mais sorte!", 23 | ", ufa, foi por pouco que não te apanhávamos!", 24 | ", já contaste ao patinho?", 25 | ", ", 26 | ". Se o Google não ajudar, força nisso!", 27 | ". Se quiseres aprender a programar, podes contar com o nosso apoio!", 28 | ". Acho que estás a merecer um !ja", 29 | ". Não... O curso é feito por ti, não por nós!", 30 | ". Sim, é mesmo um servidor de geeks!", 31 | ". Então já te fartaste do StackOverflow?", 32 | ". Se estás perdido parabéns! Não pioraste as coisas pelo menos!", 33 | ". Os canais são muitos, já a qualidade...", 34 | ". Os melhores também já cá estiveram!", 35 | ". Não, HTML não é uma linguagem de programação, ok?", 36 | ". Sim, HTML é uma linguagem de programação, ok?", 37 | ". Já contas a partir do 0?", 38 | ". Como assim não funciona? Está a funcionar no meu PC...", 39 | ". Eu � Unicode.", 40 | ". Como tratas um bug de JavaScript? Consola-o.", 41 | ". Há precisamente 10 tipos de pessoas que entendem binário.", 42 | ". Sabes o que o HTML disse ao CSS? \"Gosto do teu estilo\".", 43 | ". Ajuda-me! A minha impressora deixou de funcionar.", 44 | ". Não sabes como criar uma função anónima? Lambe, duh.", 45 | ". Contava-te uma piada sobre ciclos infinitos mas nunca mais acabava.", 46 | ". Adoro premir a tecla F5... É refrescante.", 47 | ". Estás interessado em juntar-te à nossa jovem e dinâmica equipa?", 48 | ". Se calhar falta um pouco de estudo de estruturas de dados. ", 49 | ". Dúvidas de Fortran? Penso que estejas no local certo, mas nem sei ao certo...", 50 | ". Já tentaste ver se não escreveste um \".lenght()\"?", 51 | ", se tens por hábito transformar café em código estás no sítio certo!", 52 | ", quantos programadores são necessários para mudar uma lâmpada?", 53 | ". Existem 10 tipos de programadores, em qual te enquadras?", 54 | ", podes hackear o facebook por mim?", 55 | ", error 404...", 56 | ", exception ArrayIndexOutOfBounds!", 57 | ", olá Mundo.", 58 | ", porque é que não funciona?", 59 | ", já abriste a janela?", 60 | ", obrigado pela tua presença!", 61 | ", tudo joia?" 62 | ] 63 | -------------------------------------------------------------------------------- /domain/service/interactionResolver.ts: -------------------------------------------------------------------------------- 1 | import { ButtonInteraction } from "discord.js"; 2 | import ChatService from "./chatService"; 3 | import ChannelResolver from "./channelResolver"; 4 | import LoggerService from "./loggerService"; 5 | import ApproveAnonymousQuestionUseCase from "../../application/usecases/approveAnonymousQuestionUseCase"; 6 | import RejectAnonymousQuestionUseCase from "../../application/usecases/rejectAnonymousQuestionUseCase"; 7 | import QuestionTrackingService from "./questionTrackingService"; 8 | 9 | interface InteractionResolverOptions { 10 | chatService: ChatService; 11 | loggerService: LoggerService; 12 | channelResolver: ChannelResolver; 13 | questionTrackingService: QuestionTrackingService; 14 | } 15 | 16 | export default class InteractionResolver { 17 | private readonly chatService: ChatService; 18 | 19 | private readonly loggerService: LoggerService; 20 | 21 | private readonly channelResolver: ChannelResolver; 22 | 23 | private readonly questionTrackingService: QuestionTrackingService; 24 | 25 | constructor({ chatService, loggerService, channelResolver, questionTrackingService }: InteractionResolverOptions) { 26 | this.chatService = chatService; 27 | this.loggerService = loggerService; 28 | this.channelResolver = channelResolver; 29 | this.questionTrackingService = questionTrackingService; 30 | } 31 | 32 | async resolveButtonInteraction(interaction: ButtonInteraction): Promise { 33 | const { customId } = interaction; 34 | const [action, questionId] = customId.split("_"); 35 | 36 | try { 37 | const { message } = interaction; 38 | let questionContent = "Pergunta anónima"; 39 | 40 | if (message.embeds && message.embeds.length > 0) { 41 | questionContent = message.embeds[0].description || questionContent; 42 | } 43 | 44 | if (action === "approve") { 45 | const approveUseCase = new ApproveAnonymousQuestionUseCase({ 46 | chatService: this.chatService, 47 | loggerService: this.loggerService, 48 | channelResolver: this.channelResolver, 49 | questionTrackingService: this.questionTrackingService, 50 | }); 51 | 52 | await approveUseCase.execute({ 53 | questionId, 54 | moderatorId: interaction.user.id, 55 | interactionId: customId, 56 | questionContent, 57 | interaction, 58 | }); 59 | } else if (action === "reject") { 60 | const rejectUseCase = new RejectAnonymousQuestionUseCase({ 61 | chatService: this.chatService, 62 | loggerService: this.loggerService, 63 | questionTrackingService: this.questionTrackingService, 64 | }); 65 | 66 | await rejectUseCase.execute({ 67 | questionId, 68 | moderatorId: interaction.user.id, 69 | interactionId: customId, 70 | questionContent, 71 | interaction, 72 | }); 73 | } 74 | } catch (error) { 75 | this.loggerService.log(`Erro ao processar interação de botão: ${error}`); 76 | 77 | if (!interaction.replied && !interaction.deferred) { 78 | try { 79 | await interaction.reply({ 80 | content: "Ocorreu um erro ao processar esta ação.", 81 | ephemeral: true, 82 | }); 83 | } catch (replyError) { 84 | this.loggerService.log(`Não foi possível responder à interação: ${replyError}`); 85 | } 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /application/usecases/approveAnonymousQuestionUseCase.ts: -------------------------------------------------------------------------------- 1 | import { ButtonInteraction, EmbedBuilder } from "discord.js"; 2 | import ChatService from "../../domain/service/chatService"; 3 | import ChannelResolver from "../../domain/service/channelResolver"; 4 | import LoggerService from "../../domain/service/loggerService"; 5 | import { ChannelSlug } from "../../types"; 6 | import QuestionTrackingService from "../../domain/service/questionTrackingService"; 7 | 8 | interface ApproveAnonymousQuestionUseCaseOptions { 9 | chatService: ChatService; 10 | loggerService: LoggerService; 11 | channelResolver: ChannelResolver; 12 | questionTrackingService: QuestionTrackingService; 13 | } 14 | 15 | interface ApproveAnonymousQuestionParams { 16 | questionId: string; 17 | moderatorId: string; 18 | interactionId: string; 19 | questionContent: string; 20 | interaction: ButtonInteraction; 21 | } 22 | 23 | export default class ApproveAnonymousQuestionUseCase { 24 | private chatService: ChatService; 25 | 26 | private loggerService: LoggerService; 27 | 28 | private channelResolver: ChannelResolver; 29 | 30 | private questionTrackingService: QuestionTrackingService; 31 | 32 | constructor({ 33 | chatService, 34 | loggerService, 35 | channelResolver, 36 | questionTrackingService, 37 | }: ApproveAnonymousQuestionUseCaseOptions) { 38 | this.chatService = chatService; 39 | this.loggerService = loggerService; 40 | this.channelResolver = channelResolver; 41 | this.questionTrackingService = questionTrackingService; 42 | } 43 | 44 | async execute({ 45 | questionId, 46 | moderatorId, 47 | interactionId, 48 | questionContent, 49 | interaction, 50 | }: ApproveAnonymousQuestionParams) { 51 | this.loggerService.log(`A aprovar a pergunta ${questionId} pelo moderador ${moderatorId}`); 52 | 53 | const customIdParts = interactionId.split("_"); 54 | const dmChannelId = customIdParts[2]; 55 | const userId = customIdParts[3]; 56 | 57 | const publicChannelId = this.channelResolver.getBySlug(ChannelSlug.QUESTIONS); 58 | await this.chatService.sendMessageToChannel(`**Pergunta anónima:**\n\n${questionContent}`, publicChannelId); 59 | 60 | const publicMessageLink = interaction.guildId 61 | ? `https://discord.com/channels/${interaction.guildId}/${publicChannelId}/` 62 | : ""; 63 | 64 | if (dmChannelId) { 65 | await this.chatService.sendMessageToChannel( 66 | `A tua pergunta anónima foi aprovada e publicada no canal <#${publicChannelId}>!${ 67 | publicMessageLink ? `\n\nVê aqui: ${publicMessageLink}` : "" 68 | }`, 69 | dmChannelId 70 | ); 71 | } 72 | 73 | try { 74 | const updatedEmbed = new EmbedBuilder() 75 | .setTitle("Pergunta Anónima Aprovada") 76 | .setDescription(questionContent) 77 | .setColor(0x00ff00) // Green 78 | .setFields([ 79 | { name: "ID", value: questionId, inline: true }, 80 | { name: "Aprovado por", value: `<@${moderatorId}>`, inline: true }, 81 | ]) 82 | .setFooter({ text: "Esta pergunta foi aprovada e publicada" }); 83 | 84 | if (interaction.message && "edit" in interaction.message) { 85 | await interaction.message.edit({ 86 | embeds: [updatedEmbed], 87 | components: [], 88 | }); 89 | } else { 90 | await interaction.update({ 91 | embeds: [updatedEmbed], 92 | components: [], 93 | }); 94 | } 95 | } catch (error) { 96 | this.loggerService.log(`Erro ao atualizar mensagem de moderação: ${error}`); 97 | } 98 | 99 | if (userId) { 100 | this.questionTrackingService.removeQuestion(userId); 101 | this.loggerService.log(`Pergunta ${questionId} removida do tracking para o user ${userId}`); 102 | } 103 | 104 | return { success: true }; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /vitest/application/command/codewarsLeaderboardCommand.spec.ts: -------------------------------------------------------------------------------- 1 | import { vi, describe, it, expect, beforeEach } from "vitest"; 2 | import { MockProxy, mock } from "vitest-mock-extended"; 3 | import CodewarsLeaderboardCommand from "../../../application/command/codewarsLeaderboardCommand"; 4 | import ChatService from "../../../domain/service/chatService"; 5 | import KataService from "../../../domain/service/kataService/kataService"; 6 | import KataLeaderboardUser from "../../../domain/service/kataService/kataLeaderboardUser"; 7 | 8 | describe("send codewars leaderboard to channel use case", () => { 9 | let mockChatService: MockProxy; 10 | let mockKataService: MockProxy; 11 | 12 | beforeEach(() => { 13 | mockChatService = mock(); 14 | 15 | mockChatService.sendMessageToChannel.mockResolvedValue(); 16 | 17 | mockKataService = mock(); 18 | }); 19 | 20 | it("should send a message to channel in chatService referring there aren't still any participants", async () => { 21 | mockKataService.getLeaderboard.mockReturnValueOnce(Promise.resolve([])); 22 | 23 | const spy = vi.spyOn(mockChatService, "sendMessageToChannel"); 24 | 25 | await new CodewarsLeaderboardCommand(mockChatService, mockKataService).execute({ 26 | channelId: "855861944930402342", 27 | }); 28 | 29 | expect(spy).toHaveBeenCalledTimes(1); 30 | expect(spy).toHaveBeenCalledWith("Ainda não existem participantes nesta ediçăo do desafio.", "855861944930402342"); 31 | }); 32 | 33 | it("should send a message to channel in chatService with a leaderboard with 12 participants plus a message to check more", async () => { 34 | mockKataService.getLeaderboard.mockReturnValueOnce( 35 | Promise.resolve([ 36 | new KataLeaderboardUser({ username: "utilizador-1", score: 4, points: [3, 0, 1] }), 37 | new KataLeaderboardUser({ username: "utilizador-2", score: 4, points: [0, 3, 1] }), 38 | new KataLeaderboardUser({ username: "utilizador-3", score: 4, points: [0, 0, 4] }), 39 | new KataLeaderboardUser({ username: "utilizador-4", score: 3, points: [3, 0, 0] }), 40 | new KataLeaderboardUser({ username: "utilizador-5", score: 3, points: [0, 3, 0] }), 41 | new KataLeaderboardUser({ username: "utilizador-6", score: 3, points: [0, 0, 3] }), 42 | new KataLeaderboardUser({ username: "utilizador-7", score: 2, points: [2, 0, 0] }), 43 | new KataLeaderboardUser({ username: "utilizador-8", score: 2, points: [0, 2, 0] }), 44 | new KataLeaderboardUser({ username: "utilizador-9", score: 2, points: [0, 0, 2] }), 45 | new KataLeaderboardUser({ username: "utilizador-10", score: 1, points: [1, 0, 0] }), 46 | new KataLeaderboardUser({ username: "utilizador-11", score: 1, points: [0, 1, 0] }), 47 | new KataLeaderboardUser({ username: "utilizador-12", score: 1, points: [0, 0, 1] }), 48 | ]) 49 | ); 50 | 51 | const spy = vi.spyOn(mockChatService, "sendMessageToChannel"); 52 | 53 | await new CodewarsLeaderboardCommand(mockChatService, mockKataService).execute({ 54 | channelId: "855861944930402342", 55 | }); 56 | 57 | expect(spy).toHaveBeenCalledTimes(1); 58 | expect(spy).toHaveBeenCalledWith( 59 | `\`\`\`1. utilizador-1 - 4 - [3,0,1] points 60 | 2. utilizador-2 - 4 - [0,3,1] points 61 | 3. utilizador-3 - 4 - [0,0,4] points 62 | 4. utilizador-4 - 3 - [3,0,0] points 63 | 5. utilizador-5 - 3 - [0,3,0] points 64 | 6. utilizador-6 - 3 - [0,0,3] points 65 | 7. utilizador-7 - 2 - [2,0,0] points 66 | 8. utilizador-8 - 2 - [0,2,0] points 67 | 9. utilizador-9 - 2 - [0,0,2] points 68 | 10. utilizador-10 - 1 - [1,0,0] points 69 | \`\`\`\ 70 | 71 | ... e 2 outras participações em https://codewars.devpt.co`, 72 | "855861944930402342" 73 | ); 74 | }); 75 | 76 | it("should send a message to channel in chatService with a leaderboard with 3 participants without a footer message", async () => { 77 | mockKataService.getLeaderboard.mockReturnValueOnce( 78 | Promise.resolve([ 79 | new KataLeaderboardUser({ username: "utilizador-1", score: 4, points: [3, 0, 1] }), 80 | new KataLeaderboardUser({ username: "utilizador-2", score: 4, points: [0, 3, 1] }), 81 | new KataLeaderboardUser({ username: "utilizador-3", score: 1, points: [0, 0, 1] }), 82 | ]) 83 | ); 84 | 85 | const spy = vi.spyOn(mockChatService, "sendMessageToChannel"); 86 | 87 | await new CodewarsLeaderboardCommand(mockChatService, mockKataService).execute({ 88 | channelId: "855861944930402342", 89 | }); 90 | 91 | expect(spy).toHaveBeenCalledTimes(1); 92 | expect(spy).toHaveBeenCalledWith( 93 | `\`\`\`1. utilizador-1 - 4 - [3,0,1] points 94 | 2. utilizador-2 - 4 - [0,3,1] points 95 | 3. utilizador-3 - 1 - [0,0,1] points 96 | \`\`\``, 97 | "855861944930402342" 98 | ); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { Client, Events, GatewayIntentBits, GuildMember, Message, Partials } from "discord.js"; 2 | import * as dotenv from "dotenv"; 3 | import { CronJob } from "cron"; 4 | import SendWelcomeMessageUseCase from "./application/usecases/sendWelcomeMessageUseCase"; 5 | import FileMessageRepository from "./infrastructure/repository/fileMessageRepository"; 6 | import ChatService from "./domain/service/chatService"; 7 | import DiscordChatService from "./infrastructure/service/discordChatService"; 8 | import ConsoleLoggerService from "./infrastructure/service/consoleLoggerService"; 9 | import MessageRepository from "./domain/repository/messageRepository"; 10 | import LoggerService from "./domain/service/loggerService"; 11 | import CommandUseCaseResolver from "./domain/service/commandUseCaseResolver"; 12 | import ChannelResolver from "./domain/service/channelResolver"; 13 | import InteractionResolver from "./domain/service/interactionResolver"; 14 | import KataService from "./domain/service/kataService/kataService"; 15 | import CodewarsKataService from "./infrastructure/service/codewarsKataService"; 16 | import ContentAggregatorService from "./domain/service/contentAggregatorService/contentAggregatorService"; 17 | import LemmyContentAggregatorService from "./infrastructure/service/lemmyContentAggregatorService"; 18 | import QuestionTrackingService from "./domain/service/questionTrackingService"; 19 | import CodewarsLeaderboardCommand from "./application/command/codewarsLeaderboardCommand"; 20 | import DontAskToAskCommand from "./application/command/dontAskToAskCommand"; 21 | import OnlyCodeQuestionsCommand from "./application/command/onlyCodeQuestionsCommand"; 22 | import AnonymousQuestionCommand from "./application/command/anonymousQuestionCommand"; 23 | import { Command } from "./types"; 24 | 25 | dotenv.config(); 26 | 27 | const { DISCORD_TOKEN } = process.env; 28 | 29 | const client = new Client({ 30 | intents: [ 31 | GatewayIntentBits.Guilds, 32 | GatewayIntentBits.GuildMembers, 33 | GatewayIntentBits.GuildMessages, 34 | GatewayIntentBits.DirectMessages, 35 | GatewayIntentBits.MessageContent, 36 | ], 37 | partials: [Partials.Channel], 38 | }); 39 | 40 | const messageRepository: MessageRepository = new FileMessageRepository(); 41 | const chatService: ChatService = new DiscordChatService(client); 42 | const loggerService: LoggerService = new ConsoleLoggerService(); 43 | const channelResolver: ChannelResolver = new ChannelResolver(); 44 | const questionTrackingService: QuestionTrackingService = new QuestionTrackingService(); 45 | const kataService: KataService = new CodewarsKataService(); 46 | const lemmyContentAggregatorService: ContentAggregatorService = new LemmyContentAggregatorService(); 47 | const commands: Command[] = [ 48 | new CodewarsLeaderboardCommand(chatService, kataService), 49 | new DontAskToAskCommand(chatService), 50 | new OnlyCodeQuestionsCommand(chatService), 51 | new AnonymousQuestionCommand(chatService, loggerService, channelResolver, questionTrackingService), 52 | ]; 53 | const useCaseResolver = new CommandUseCaseResolver({ 54 | commands, 55 | loggerService, 56 | }); 57 | 58 | const interactionResolver = new InteractionResolver({ 59 | chatService, 60 | loggerService, 61 | channelResolver, 62 | questionTrackingService, 63 | }); 64 | 65 | const checkForNewPosts = async () => { 66 | loggerService.log("Checking for new posts on content aggregator..."); 67 | 68 | const FEED_CHANNEL_ID = "829694016156205056"; 69 | const lastPosts = await lemmyContentAggregatorService.fetchLastPosts(); 70 | 71 | const now = new Date(); 72 | 73 | const last5MinutesPosts = lastPosts.filter((post) => { 74 | const diff = Math.abs(now.getTime() - post.getCreatedAt().getTime()); 75 | const minutes = Math.floor(diff / 1000 / 60); 76 | return minutes <= 5; 77 | }); 78 | 79 | const avoidEmbedInLink = (str: string): string => { 80 | // convert links to avoid embed (by wrapping them in <>) 81 | const regex = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g; 82 | return str.replace(regex, "[$1](<$2>)"); 83 | }; 84 | 85 | last5MinutesPosts.forEach((post) => { 86 | const title = avoidEmbedInLink(post.getTitle()); 87 | 88 | const message = `Novo post no Lemmy: **${title}** (*${post?.getAuthorName()}*) 89 | 90 | 👉 Ver em: ${post?.getLink()} 91 | `; 92 | 93 | chatService.sendMessageToChannel(message, FEED_CHANNEL_ID); 94 | }); 95 | 96 | loggerService.log(`Published ${last5MinutesPosts.length} new posts!`); 97 | }; 98 | 99 | const setupCron = () => { 100 | /* eslint-disable no-new */ 101 | new CronJob( 102 | // run every 5 minutes 103 | "0 */5 * * * *", 104 | checkForNewPosts, 105 | null, 106 | true, 107 | "UTC" 108 | ); 109 | }; 110 | 111 | client.once(Events.ClientReady, () => { 112 | loggerService.log("Ready!"); 113 | setupCron(); 114 | }); 115 | 116 | client.login(DISCORD_TOKEN); 117 | 118 | client.on(Events.GuildMemberAdd, (member: GuildMember) => 119 | new SendWelcomeMessageUseCase({ 120 | messageRepository, 121 | chatService, 122 | loggerService, 123 | channelResolver, 124 | }).execute(member) 125 | ); 126 | 127 | client.on(Events.MessageCreate, async (message: Message) => { 128 | const COMMAND_PREFIX = "!"; 129 | 130 | if (!message.content.startsWith(COMMAND_PREFIX)) return; 131 | 132 | const command = message.content.split(" ")[0]; 133 | 134 | try { 135 | await useCaseResolver.resolveByCommand(command, { 136 | channelId: message.channel.id, 137 | message, 138 | }); 139 | } catch (error: unknown) { 140 | loggerService.log(error); 141 | } 142 | }); 143 | 144 | client.on(Events.InteractionCreate, async (interaction) => { 145 | if (interaction.isButton()) { 146 | await interactionResolver.resolveButtonInteraction(interaction); 147 | } 148 | }); 149 | -------------------------------------------------------------------------------- /ARCHITECTURE.md: -------------------------------------------------------------------------------- 1 | # Arquitetura do Discord Bot devPT 2 | 3 | ## Introdução 4 | 5 | Este documento descreve a arquitetura do Discord Bot devPT. O objetivo deste documento é fornecer uma visão geral da arquitetura e dos padrões de design do bot. 6 | 7 | ## Arquitetura 8 | 9 | O projeto segue, de forma não purista, algumas das ideias de _Domain-Driven Design_ (DDD), de _Hexagonal Architecture_ (também conhecido como _Ports and Adapters_) e _Feature Driven Development_ (aplicação de UseCases). 10 | 11 | ![](https://jmgarridopaz.github.io/assets/images/hexagonalarchitecture/figure1.png) 12 | 13 | Esta arquitetura facilita a escalabilidade, manutenção e implementação de práticas de testes ao permitir a injeção de diferentes dependências, cada qual responsável única e exclusivamente pela sua responsabilidade, respeitando o _Single-Responsability Principle (SRP)_. 14 | 15 | A Arquitetura Hexagonal remete para a ideia de que uma aplicação pode ter diversos condutores (_drivers_), no caso deste bot sendo: 16 | 17 | - Mensagens digitados num canal que façam match a determinado padrão (`!ja`, `!oc`) 18 | - Comandos built-in do Discord 19 | - Entrada de um novo utilizador no servidor 20 | - Reação a emojis 21 | - Testes 22 | 23 | Utilizando uma abordagem de UseCases, permitimos que: 24 | 25 | - Nos seja possível testar as nossas implementações independentemente de repositórios ou serviços (podemos facilmente trocar um MysqlUserRepository para um MemoryUserRepository) 26 | - Construamos soluções em resposta ao domínio e não à implementação (ex.: uma boa implementação poderia permitir-nos utilizar um SlackChatService ao invés de um DiscordChatService, limitando-me a implementar a interface ChatService e trocar a implementação pretendida) 27 | 28 | ## Camadas 29 | 30 | ### Domínio 31 | 32 | Numa modelagem de domínio, o domínio é o conjunto de conceitos que são relevantes para o negócio. 33 | 34 | Para que sejamos sucintos, manteremos ao longo deste capítulo o foco na análise tática do DDD, com isto recomendando uma leitura em detalhe sobre a análise estratégica. 35 | 36 | Sucintamente, utilizamos no projeto conceitos de domínio como: 37 | 38 | 1. Entidades - qualquer entidade que seja passível de ser identificada (por norma através de um ID/UUID). Uma entidade só deve ser construída caso seja considerada válida na sua totalidade (se necessário podem e devem ser feitas validações no seu _constructor_). Ex.: User; Channel 39 | 40 | 2. Repositórios - contratos (de agora em diante chamadas de interfaces) que acordam com o "mundo exterior" que tipo de operações se podem realizar num banco de persistência com Entidades. Ex.: UserRepository; ChannelRepository 41 | 42 | 3. Services - interfaces que permitem a abstração de sistemas externos para que sejam orquestradas e efetuadas operações que possam envolver Entidades de domínio e eventualmente Value Objects. Ex.: ChatService; LoggerService 43 | 44 | 4. Value Objects - qualquer entidade que não seja identificável e que as suas instâncias sejam facilmente substituíveis umas pelas outras. Ex.: `new Cor('rosa')` / `new Cor('azul')`; `new Name('João')` / `new Name('Pedro')`; 45 | 46 | - Esta é uma visão **MUITO** dilatada dos conceitos de DDD. Por exemplo, na realidade existem (entre outros tantos conceitos) dois tipos de serviços (Domínio e Aplicação), quando na realidade apenas utilizamos um em prol da simplificação deste projeto de escopo mais reduzido. 47 | 48 | ### Aplicação 49 | 50 | A camada de aplicação é por norma referenciada como a orquestradora. Utilizando _Feature Driven Development_, a nossa implementação baseia-se em UseCases. 51 | 52 | Cada UseCase corresponde a uma funcionalidade da aplicação, pelo que deve ser por norma criada uma implementação para cada uma - a não ser que esta seja de tal forma obviamente semelhante que não faça sentido a duplicação da funcionalidade (p.ex.: comando `!ja` e comando `!oc` - na realidade, ambos enviam uma mensagem para um canal, apesar de a mensagem ser ligeiramente diferente, o processamento do UseCase é exatamente o mesmo, e como tal o seu teste será exatamente igual). 53 | 54 | É ela que recebe `Request` (também conhecido como `Input`), e exporta `Response` (ou `Output`). 55 | 56 | Entre o `Request` e o `Response` ela é responsável por fazer executar os comandos necessários para fazer cumprir o UseCase referido. 57 | 58 | Um UseCase deve depender de interfaces (de serviços ou repositórios) - segundo o Depency Inversion Principle (DIP) -, para que consiga realizar a sua ação. 59 | 60 | ### Infraestrutura 61 | 62 | A infraestrutura concentra-se em implementar cada contrato, escondendo os detalhes de cada implementação. 63 | 64 | Um ChatService para enviar uma mensagem precisará por exemplo de uma mensagem (string) e de um User (entidade de domínio). Uma implementação DiscordChatService e SlackChatService devem receber exatamente estes mesmos dados, e deverão sempre comunicar com o exterior utilizando entidades de domínio (tipos criados para o efeito na camada de domínio), mesmo que para isso seja necessário executar qualquer tipo de mapeamento. 65 | 66 | ## Tecnologias 67 | 68 | O bot é desenvolvido com recurso a TypeScript, utilizando Discord.js como biblioteca de comunicação com a API do Discord. 69 | 70 | Em produção o bot é atualmente executado no Heroku (em vias de ser portado para outro serviço) e pode ser executado localmente com recurso ao Docker. Num futuro ideal, o ambiente de produção será também ele executado através do mesmo Dockerfile que alimenta o ambiente de desenvolvimento, através de multi-stage builds. 71 | 72 | Para a execução de testes, o bot utiliza o Vitest. 73 | 74 | ## Testes 75 | 76 | Cada teste deverá dar mock de cada dependência que não seja relevante para o teste em questão. 77 | 78 | Num cenário ideal, no desenvolvimento de uma nova funcionalidade, o programador deverá começar pelos testes para que tenha uma visão clara do que pretende atingir com a adição de um novo UseCase, ou com a respetiva alteração de código. 79 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # GNU AFFERO GENERAL PUBLIC LICENSE 2 | 3 | Version 3, 19 November 2007 4 | 5 | Copyright (C) 2007 Free Software Foundation, Inc. 6 | 7 | 8 | Everyone is permitted to copy and distribute verbatim copies of this 9 | license document, but changing it is not allowed. 10 | 11 | ## Preamble 12 | 13 | The GNU Affero General Public License is a free, copyleft license for 14 | software and other kinds of works, specifically designed to ensure 15 | cooperation with the community in the case of network server software. 16 | 17 | The licenses for most software and other practical works are designed 18 | to take away your freedom to share and change the works. By contrast, 19 | our General Public Licenses are intended to guarantee your freedom to 20 | share and change all versions of a program--to make sure it remains 21 | free software for all its users. 22 | 23 | When we speak of free software, we are referring to freedom, not 24 | price. Our General Public Licenses are designed to make sure that you 25 | have the freedom to distribute copies of free software (and charge for 26 | them if you wish), that you receive source code or can get it if you 27 | want it, that you can change the software or use pieces of it in new 28 | free programs, and that you know you can do these things. 29 | 30 | Developers that use our General Public Licenses protect your rights 31 | with two steps: (1) assert copyright on the software, and (2) offer 32 | you this License which gives you legal permission to copy, distribute 33 | and/or modify the software. 34 | 35 | A secondary benefit of defending all users' freedom is that 36 | improvements made in alternate versions of the program, if they 37 | receive widespread use, become available for other developers to 38 | incorporate. Many developers of free software are heartened and 39 | encouraged by the resulting cooperation. However, in the case of 40 | software used on network servers, this result may fail to come about. 41 | The GNU General Public License permits making a modified version and 42 | letting the public access it on a server without ever releasing its 43 | source code to the public. 44 | 45 | The GNU Affero General Public License is designed specifically to 46 | ensure that, in such cases, the modified source code becomes available 47 | to the community. It requires the operator of a network server to 48 | provide the source code of the modified version running there to the 49 | users of that server. Therefore, public use of a modified version, on 50 | a publicly accessible server, gives the public access to the source 51 | code of the modified version. 52 | 53 | An older license, called the Affero General Public License and 54 | published by Affero, was designed to accomplish similar goals. This is 55 | a different license, not a version of the Affero GPL, but Affero has 56 | released a new version of the Affero GPL which permits relicensing 57 | under this license. 58 | 59 | The precise terms and conditions for copying, distribution and 60 | modification follow. 61 | 62 | ## TERMS AND CONDITIONS 63 | 64 | ### 0. Definitions. 65 | 66 | "This License" refers to version 3 of the GNU Affero General Public 67 | License. 68 | 69 | "Copyright" also means copyright-like laws that apply to other kinds 70 | of works, such as semiconductor masks. 71 | 72 | "The Program" refers to any copyrightable work licensed under this 73 | License. Each licensee is addressed as "you". "Licensees" and 74 | "recipients" may be individuals or organizations. 75 | 76 | To "modify" a work means to copy from or adapt all or part of the work 77 | in a fashion requiring copyright permission, other than the making of 78 | an exact copy. The resulting work is called a "modified version" of 79 | the earlier work or a work "based on" the earlier work. 80 | 81 | A "covered work" means either the unmodified Program or a work based 82 | on the Program. 83 | 84 | To "propagate" a work means to do anything with it that, without 85 | permission, would make you directly or secondarily liable for 86 | infringement under applicable copyright law, except executing it on a 87 | computer or modifying a private copy. Propagation includes copying, 88 | distribution (with or without modification), making available to the 89 | public, and in some countries other activities as well. 90 | 91 | To "convey" a work means any kind of propagation that enables other 92 | parties to make or receive copies. Mere interaction with a user 93 | through a computer network, with no transfer of a copy, is not 94 | conveying. 95 | 96 | An interactive user interface displays "Appropriate Legal Notices" to 97 | the extent that it includes a convenient and prominently visible 98 | feature that (1) displays an appropriate copyright notice, and (2) 99 | tells the user that there is no warranty for the work (except to the 100 | extent that warranties are provided), that licensees may convey the 101 | work under this License, and how to view a copy of this License. If 102 | the interface presents a list of user commands or options, such as a 103 | menu, a prominent item in the list meets this criterion. 104 | 105 | ### 1. Source Code. 106 | 107 | The "source code" for a work means the preferred form of the work for 108 | making modifications to it. "Object code" means any non-source form of 109 | a work. 110 | 111 | A "Standard Interface" means an interface that either is an official 112 | standard defined by a recognized standards body, or, in the case of 113 | interfaces specified for a particular programming language, one that 114 | is widely used among developers working in that language. 115 | 116 | The "System Libraries" of an executable work include anything, other 117 | than the work as a whole, that (a) is included in the normal form of 118 | packaging a Major Component, but which is not part of that Major 119 | Component, and (b) serves only to enable use of the work with that 120 | Major Component, or to implement a Standard Interface for which an 121 | implementation is available to the public in source code form. A 122 | "Major Component", in this context, means a major essential component 123 | (kernel, window system, and so on) of the specific operating system 124 | (if any) on which the executable work runs, or a compiler used to 125 | produce the work, or an object code interpreter used to run it. 126 | 127 | The "Corresponding Source" for a work in object code form means all 128 | the source code needed to generate, install, and (for an executable 129 | work) run the object code and to modify the work, including scripts to 130 | control those activities. However, it does not include the work's 131 | System Libraries, or general-purpose tools or generally available free 132 | programs which are used unmodified in performing those activities but 133 | which are not part of the work. For example, Corresponding Source 134 | includes interface definition files associated with source files for 135 | the work, and the source code for shared libraries and dynamically 136 | linked subprograms that the work is specifically designed to require, 137 | such as by intimate data communication or control flow between those 138 | subprograms and other parts of the work. 139 | 140 | The Corresponding Source need not include anything that users can 141 | regenerate automatically from other parts of the Corresponding Source. 142 | 143 | The Corresponding Source for a work in source code form is that same 144 | work. 145 | 146 | ### 2. Basic Permissions. 147 | 148 | All rights granted under this License are granted for the term of 149 | copyright on the Program, and are irrevocable provided the stated 150 | conditions are met. This License explicitly affirms your unlimited 151 | permission to run the unmodified Program. The output from running a 152 | covered work is covered by this License only if the output, given its 153 | content, constitutes a covered work. This License acknowledges your 154 | rights of fair use or other equivalent, as provided by copyright law. 155 | 156 | You may make, run and propagate covered works that you do not convey, 157 | without conditions so long as your license otherwise remains in force. 158 | You may convey covered works to others for the sole purpose of having 159 | them make modifications exclusively for you, or provide you with 160 | facilities for running those works, provided that you comply with the 161 | terms of this License in conveying all material for which you do not 162 | control copyright. Those thus making or running the covered works for 163 | you must do so exclusively on your behalf, under your direction and 164 | control, on terms that prohibit them from making any copies of your 165 | copyrighted material outside their relationship with you. 166 | 167 | Conveying under any other circumstances is permitted solely under the 168 | conditions stated below. Sublicensing is not allowed; section 10 makes 169 | it unnecessary. 170 | 171 | ### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 172 | 173 | No covered work shall be deemed part of an effective technological 174 | measure under any applicable law fulfilling obligations under article 175 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 176 | similar laws prohibiting or restricting circumvention of such 177 | measures. 178 | 179 | When you convey a covered work, you waive any legal power to forbid 180 | circumvention of technological measures to the extent such 181 | circumvention is effected by exercising rights under this License with 182 | respect to the covered work, and you disclaim any intention to limit 183 | operation or modification of the work as a means of enforcing, against 184 | the work's users, your or third parties' legal rights to forbid 185 | circumvention of technological measures. 186 | 187 | ### 4. Conveying Verbatim Copies. 188 | 189 | You may convey verbatim copies of the Program's source code as you 190 | receive it, in any medium, provided that you conspicuously and 191 | appropriately publish on each copy an appropriate copyright notice; 192 | keep intact all notices stating that this License and any 193 | non-permissive terms added in accord with section 7 apply to the code; 194 | keep intact all notices of the absence of any warranty; and give all 195 | recipients a copy of this License along with the Program. 196 | 197 | You may charge any price or no price for each copy that you convey, 198 | and you may offer support or warranty protection for a fee. 199 | 200 | ### 5. Conveying Modified Source Versions. 201 | 202 | You may convey a work based on the Program, or the modifications to 203 | produce it from the Program, in the form of source code under the 204 | terms of section 4, provided that you also meet all of these 205 | conditions: 206 | 207 | - a) The work must carry prominent notices stating that you modified 208 | it, and giving a relevant date. 209 | - b) The work must carry prominent notices stating that it is 210 | released under this License and any conditions added under 211 | section 7. This requirement modifies the requirement in section 4 212 | to "keep intact all notices". 213 | - c) You must license the entire work, as a whole, under this 214 | License to anyone who comes into possession of a copy. This 215 | License will therefore apply, along with any applicable section 7 216 | additional terms, to the whole of the work, and all its parts, 217 | regardless of how they are packaged. This License gives no 218 | permission to license the work in any other way, but it does not 219 | invalidate such permission if you have separately received it. 220 | - d) If the work has interactive user interfaces, each must display 221 | Appropriate Legal Notices; however, if the Program has interactive 222 | interfaces that do not display Appropriate Legal Notices, your 223 | work need not make them do so. 224 | 225 | A compilation of a covered work with other separate and independent 226 | works, which are not by their nature extensions of the covered work, 227 | and which are not combined with it such as to form a larger program, 228 | in or on a volume of a storage or distribution medium, is called an 229 | "aggregate" if the compilation and its resulting copyright are not 230 | used to limit the access or legal rights of the compilation's users 231 | beyond what the individual works permit. Inclusion of a covered work 232 | in an aggregate does not cause this License to apply to the other 233 | parts of the aggregate. 234 | 235 | ### 6. Conveying Non-Source Forms. 236 | 237 | You may convey a covered work in object code form under the terms of 238 | sections 4 and 5, provided that you also convey the machine-readable 239 | Corresponding Source under the terms of this License, in one of these 240 | ways: 241 | 242 | - a) Convey the object code in, or embodied in, a physical product 243 | (including a physical distribution medium), accompanied by the 244 | Corresponding Source fixed on a durable physical medium 245 | customarily used for software interchange. 246 | - b) Convey the object code in, or embodied in, a physical product 247 | (including a physical distribution medium), accompanied by a 248 | written offer, valid for at least three years and valid for as 249 | long as you offer spare parts or customer support for that product 250 | model, to give anyone who possesses the object code either (1) a 251 | copy of the Corresponding Source for all the software in the 252 | product that is covered by this License, on a durable physical 253 | medium customarily used for software interchange, for a price no 254 | more than your reasonable cost of physically performing this 255 | conveying of source, or (2) access to copy the Corresponding 256 | Source from a network server at no charge. 257 | - c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | - d) Convey the object code by offering access from a designated 263 | place (gratis or for a charge), and offer equivalent access to the 264 | Corresponding Source in the same way through the same place at no 265 | further charge. You need not require recipients to copy the 266 | Corresponding Source along with the object code. If the place to 267 | copy the object code is a network server, the Corresponding Source 268 | may be on a different server (operated by you or a third party) 269 | that supports equivalent copying facilities, provided you maintain 270 | clear directions next to the object code saying where to find the 271 | Corresponding Source. Regardless of what server hosts the 272 | Corresponding Source, you remain obligated to ensure that it is 273 | available for as long as needed to satisfy these requirements. 274 | - e) Convey the object code using peer-to-peer transmission, 275 | provided you inform other peers where the object code and 276 | Corresponding Source of the work are being offered to the general 277 | public at no charge under subsection 6d. 278 | 279 | A separable portion of the object code, whose source code is excluded 280 | from the Corresponding Source as a System Library, need not be 281 | included in conveying the object code work. 282 | 283 | A "User Product" is either (1) a "consumer product", which means any 284 | tangible personal property which is normally used for personal, 285 | family, or household purposes, or (2) anything designed or sold for 286 | incorporation into a dwelling. In determining whether a product is a 287 | consumer product, doubtful cases shall be resolved in favor of 288 | coverage. For a particular product received by a particular user, 289 | "normally used" refers to a typical or common use of that class of 290 | product, regardless of the status of the particular user or of the way 291 | in which the particular user actually uses, or expects or is expected 292 | to use, the product. A product is a consumer product regardless of 293 | whether the product has substantial commercial, industrial or 294 | non-consumer uses, unless such uses represent the only significant 295 | mode of use of the product. 296 | 297 | "Installation Information" for a User Product means any methods, 298 | procedures, authorization keys, or other information required to 299 | install and execute modified versions of a covered work in that User 300 | Product from a modified version of its Corresponding Source. The 301 | information must suffice to ensure that the continued functioning of 302 | the modified object code is in no case prevented or interfered with 303 | solely because modification has been made. 304 | 305 | If you convey an object code work under this section in, or with, or 306 | specifically for use in, a User Product, and the conveying occurs as 307 | part of a transaction in which the right of possession and use of the 308 | User Product is transferred to the recipient in perpetuity or for a 309 | fixed term (regardless of how the transaction is characterized), the 310 | Corresponding Source conveyed under this section must be accompanied 311 | by the Installation Information. But this requirement does not apply 312 | if neither you nor any third party retains the ability to install 313 | modified object code on the User Product (for example, the work has 314 | been installed in ROM). 315 | 316 | The requirement to provide Installation Information does not include a 317 | requirement to continue to provide support service, warranty, or 318 | updates for a work that has been modified or installed by the 319 | recipient, or for the User Product in which it has been modified or 320 | installed. Access to a network may be denied when the modification 321 | itself materially and adversely affects the operation of the network 322 | or violates the rules and protocols for communication across the 323 | network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | ### 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders 351 | of that material) supplement the terms of this License with terms: 352 | 353 | - a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | - b) Requiring preservation of specified reasonable legal notices or 356 | author attributions in that material or in the Appropriate Legal 357 | Notices displayed by works containing it; or 358 | - c) Prohibiting misrepresentation of the origin of that material, 359 | or requiring that modified versions of such material be marked in 360 | reasonable ways as different from the original version; or 361 | - d) Limiting the use for publicity purposes of names of licensors 362 | or authors of the material; or 363 | - e) Declining to grant rights under trademark law for use of some 364 | trade names, trademarks, or service marks; or 365 | - f) Requiring indemnification of licensors and authors of that 366 | material by anyone who conveys the material (or modified versions 367 | of it) with contractual assumptions of liability to the recipient, 368 | for any liability that these contractual assumptions directly 369 | impose on those licensors and authors. 370 | 371 | All other non-permissive additional terms are considered "further 372 | restrictions" within the meaning of section 10. If the Program as you 373 | received it, or any part of it, contains a notice stating that it is 374 | governed by this License along with a term that is a further 375 | restriction, you may remove that term. If a license document contains 376 | a further restriction but permits relicensing or conveying under this 377 | License, you may add to a covered work material governed by the terms 378 | of that license document, provided that the further restriction does 379 | not survive such relicensing or conveying. 380 | 381 | If you add terms to a covered work in accord with this section, you 382 | must place, in the relevant source files, a statement of the 383 | additional terms that apply to those files, or a notice indicating 384 | where to find the applicable terms. 385 | 386 | Additional terms, permissive or non-permissive, may be stated in the 387 | form of a separately written license, or stated as exceptions; the 388 | above requirements apply either way. 389 | 390 | ### 8. Termination. 391 | 392 | You may not propagate or modify a covered work except as expressly 393 | provided under this License. Any attempt otherwise to propagate or 394 | modify it is void, and will automatically terminate your rights under 395 | this License (including any patent licenses granted under the third 396 | paragraph of section 11). 397 | 398 | However, if you cease all violation of this License, then your license 399 | from a particular copyright holder is reinstated (a) provisionally, 400 | unless and until the copyright holder explicitly and finally 401 | terminates your license, and (b) permanently, if the copyright holder 402 | fails to notify you of the violation by some reasonable means prior to 403 | 60 days after the cessation. 404 | 405 | Moreover, your license from a particular copyright holder is 406 | reinstated permanently if the copyright holder notifies you of the 407 | violation by some reasonable means, this is the first time you have 408 | received notice of violation of this License (for any work) from that 409 | copyright holder, and you cure the violation prior to 30 days after 410 | your receipt of the notice. 411 | 412 | Termination of your rights under this section does not terminate the 413 | licenses of parties who have received copies or rights from you under 414 | this License. If your rights have been terminated and not permanently 415 | reinstated, you do not qualify to receive new licenses for the same 416 | material under section 10. 417 | 418 | ### 9. Acceptance Not Required for Having Copies. 419 | 420 | You are not required to accept this License in order to receive or run 421 | a copy of the Program. Ancillary propagation of a covered work 422 | occurring solely as a consequence of using peer-to-peer transmission 423 | to receive a copy likewise does not require acceptance. However, 424 | nothing other than this License grants you permission to propagate or 425 | modify any covered work. These actions infringe copyright if you do 426 | not accept this License. Therefore, by modifying or propagating a 427 | covered work, you indicate your acceptance of this License to do so. 428 | 429 | ### 10. Automatic Licensing of Downstream Recipients. 430 | 431 | Each time you convey a covered work, the recipient automatically 432 | receives a license from the original licensors, to run, modify and 433 | propagate that work, subject to this License. You are not responsible 434 | for enforcing compliance by third parties with this License. 435 | 436 | An "entity transaction" is a transaction transferring control of an 437 | organization, or substantially all assets of one, or subdividing an 438 | organization, or merging organizations. If propagation of a covered 439 | work results from an entity transaction, each party to that 440 | transaction who receives a copy of the work also receives whatever 441 | licenses to the work the party's predecessor in interest had or could 442 | give under the previous paragraph, plus a right to possession of the 443 | Corresponding Source of the work from the predecessor in interest, if 444 | the predecessor has it or can get it with reasonable efforts. 445 | 446 | You may not impose any further restrictions on the exercise of the 447 | rights granted or affirmed under this License. For example, you may 448 | not impose a license fee, royalty, or other charge for exercise of 449 | rights granted under this License, and you may not initiate litigation 450 | (including a cross-claim or counterclaim in a lawsuit) alleging that 451 | any patent claim is infringed by making, using, selling, offering for 452 | sale, or importing the Program or any portion of it. 453 | 454 | ### 11. Patents. 455 | 456 | A "contributor" is a copyright holder who authorizes use under this 457 | License of the Program or a work on which the Program is based. The 458 | work thus licensed is called the contributor's "contributor version". 459 | 460 | A contributor's "essential patent claims" are all patent claims owned 461 | or controlled by the contributor, whether already acquired or 462 | hereafter acquired, that would be infringed by some manner, permitted 463 | by this License, of making, using, or selling its contributor version, 464 | but do not include claims that would be infringed only as a 465 | consequence of further modification of the contributor version. For 466 | purposes of this definition, "control" includes the right to grant 467 | patent sublicenses in a manner consistent with the requirements of 468 | this License. 469 | 470 | Each contributor grants you a non-exclusive, worldwide, royalty-free 471 | patent license under the contributor's essential patent claims, to 472 | make, use, sell, offer for sale, import and otherwise run, modify and 473 | propagate the contents of its contributor version. 474 | 475 | In the following three paragraphs, a "patent license" is any express 476 | agreement or commitment, however denominated, not to enforce a patent 477 | (such as an express permission to practice a patent or covenant not to 478 | sue for patent infringement). To "grant" such a patent license to a 479 | party means to make such an agreement or commitment not to enforce a 480 | patent against the party. 481 | 482 | If you convey a covered work, knowingly relying on a patent license, 483 | and the Corresponding Source of the work is not available for anyone 484 | to copy, free of charge and under the terms of this License, through a 485 | publicly available network server or other readily accessible means, 486 | then you must either (1) cause the Corresponding Source to be so 487 | available, or (2) arrange to deprive yourself of the benefit of the 488 | patent license for this particular work, or (3) arrange, in a manner 489 | consistent with the requirements of this License, to extend the patent 490 | license to downstream recipients. "Knowingly relying" means you have 491 | actual knowledge that, but for the patent license, your conveying the 492 | covered work in a country, or your recipient's use of the covered work 493 | in a country, would infringe one or more identifiable patents in that 494 | country that you have reason to believe are valid. 495 | 496 | If, pursuant to or in connection with a single transaction or 497 | arrangement, you convey, or propagate by procuring conveyance of, a 498 | covered work, and grant a patent license to some of the parties 499 | receiving the covered work authorizing them to use, propagate, modify 500 | or convey a specific copy of the covered work, then the patent license 501 | you grant is automatically extended to all recipients of the covered 502 | work and works based on it. 503 | 504 | A patent license is "discriminatory" if it does not include within the 505 | scope of its coverage, prohibits the exercise of, or is conditioned on 506 | the non-exercise of one or more of the rights that are specifically 507 | granted under this License. You may not convey a covered work if you 508 | are a party to an arrangement with a third party that is in the 509 | business of distributing software, under which you make payment to the 510 | third party based on the extent of your activity of conveying the 511 | work, and under which the third party grants, to any of the parties 512 | who would receive the covered work from you, a discriminatory patent 513 | license (a) in connection with copies of the covered work conveyed by 514 | you (or copies made from those copies), or (b) primarily for and in 515 | connection with specific products or compilations that contain the 516 | covered work, unless you entered into that arrangement, or that patent 517 | license was granted, prior to 28 March 2007. 518 | 519 | Nothing in this License shall be construed as excluding or limiting 520 | any implied license or other defenses to infringement that may 521 | otherwise be available to you under applicable patent law. 522 | 523 | ### 12. No Surrender of Others' Freedom. 524 | 525 | If conditions are imposed on you (whether by court order, agreement or 526 | otherwise) that contradict the conditions of this License, they do not 527 | excuse you from the conditions of this License. If you cannot convey a 528 | covered work so as to satisfy simultaneously your obligations under 529 | this License and any other pertinent obligations, then as a 530 | consequence you may not convey it at all. For example, if you agree to 531 | terms that obligate you to collect a royalty for further conveying 532 | from those to whom you convey the Program, the only way you could 533 | satisfy both those terms and this License would be to refrain entirely 534 | from conveying the Program. 535 | 536 | ### 13. Remote Network Interaction; Use with the GNU General Public License. 537 | 538 | Notwithstanding any other provision of this License, if you modify the 539 | Program, your modified version must prominently offer all users 540 | interacting with it remotely through a computer network (if your 541 | version supports such interaction) an opportunity to receive the 542 | Corresponding Source of your version by providing access to the 543 | Corresponding Source from a network server at no charge, through some 544 | standard or customary means of facilitating copying of software. This 545 | Corresponding Source shall include the Corresponding Source for any 546 | work covered by version 3 of the GNU General Public License that is 547 | incorporated pursuant to the following paragraph. 548 | 549 | Notwithstanding any other provision of this License, you have 550 | permission to link or combine any covered work with a work licensed 551 | under version 3 of the GNU General Public License into a single 552 | combined work, and to convey the resulting work. The terms of this 553 | License will continue to apply to the part which is the covered work, 554 | but the work with which it is combined will remain governed by version 555 | 3 of the GNU General Public License. 556 | 557 | ### 14. Revised Versions of this License. 558 | 559 | The Free Software Foundation may publish revised and/or new versions 560 | of the GNU Affero General Public License from time to time. Such new 561 | versions will be similar in spirit to the present version, but may 562 | differ in detail to address new problems or concerns. 563 | 564 | Each version is given a distinguishing version number. If the Program 565 | specifies that a certain numbered version of the GNU Affero General 566 | Public License "or any later version" applies to it, you have the 567 | option of following the terms and conditions either of that numbered 568 | version or of any later version published by the Free Software 569 | Foundation. If the Program does not specify a version number of the 570 | GNU Affero General Public License, you may choose any version ever 571 | published by the Free Software Foundation. 572 | 573 | If the Program specifies that a proxy can decide which future versions 574 | of the GNU Affero General Public License can be used, that proxy's 575 | public statement of acceptance of a version permanently authorizes you 576 | to choose that version for the Program. 577 | 578 | Later license versions may give you additional or different 579 | permissions. However, no additional obligations are imposed on any 580 | author or copyright holder as a result of your choosing to follow a 581 | later version. 582 | 583 | ### 15. Disclaimer of Warranty. 584 | 585 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 586 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 587 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT 588 | WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT 589 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 590 | A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND 591 | PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE 592 | DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR 593 | CORRECTION. 594 | 595 | ### 16. Limitation of Liability. 596 | 597 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 598 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR 599 | CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 600 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES 601 | ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT 602 | NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR 603 | LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM 604 | TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER 605 | PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 606 | 607 | ### 17. Interpretation of Sections 15 and 16. 608 | 609 | If the disclaimer of warranty and limitation of liability provided 610 | above cannot be given local legal effect according to their terms, 611 | reviewing courts shall apply local law that most closely approximates 612 | an absolute waiver of all civil liability in connection with the 613 | Program, unless a warranty or assumption of liability accompanies a 614 | copy of the Program in return for a fee. 615 | 616 | END OF TERMS AND CONDITIONS 617 | 618 | ## How to Apply These Terms to Your New Programs 619 | 620 | If you develop a new program, and you want it to be of the greatest 621 | possible use to the public, the best way to achieve this is to make it 622 | free software which everyone can redistribute and change under these 623 | terms. 624 | 625 | To do so, attach the following notices to the program. It is safest to 626 | attach them to the start of each source file to most effectively state 627 | the exclusion of warranty; and each file should have at least the 628 | "copyright" line and a pointer to where the full notice is found. 629 | 630 | 631 | Copyright (C) 632 | 633 | This program is free software: you can redistribute it and/or modify 634 | it under the terms of the GNU Affero General Public License as 635 | published by the Free Software Foundation, either version 3 of the 636 | License, or (at your option) any later version. 637 | 638 | This program is distributed in the hope that it will be useful, 639 | but WITHOUT ANY WARRANTY; without even the implied warranty of 640 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 641 | GNU Affero General Public License for more details. 642 | 643 | You should have received a copy of the GNU Affero General Public License 644 | along with this program. If not, see . 645 | 646 | Also add information on how to contact you by electronic and paper 647 | mail. 648 | 649 | If your software can interact with users remotely through a computer 650 | network, you should also make sure that it provides a way for users to 651 | get its source. For example, if your program is a web application, its 652 | interface could display a "Source" link that leads users to an archive 653 | of the code. There are many ways you could offer source, and different 654 | solutions will be better for different programs; see section 13 for 655 | the specific requirements. 656 | 657 | You should also get your employer (if you work as a programmer) or 658 | school, if any, to sign a "copyright disclaimer" for the program, if 659 | necessary. For more information on this, and how to apply and follow 660 | the GNU AGPL, see . 661 | --------------------------------------------------------------------------------