├── .github └── workflows │ └── main.yml ├── .gitignore ├── README.md ├── data ├── admin_data.json ├── command_data.json ├── endpoints.json ├── forum_data.json ├── ignored_react_data.json ├── info_data.json ├── intro_data.json ├── message.markdown ├── office_hours_data.json ├── page_differ.json ├── riot_keys.json ├── techblog_data.json ├── thinking_data.json ├── ttt_scores.json └── version_data.json ├── package-lock.json ├── package.json ├── private └── shared_settings.json ├── settings ├── command_list.json └── shared_settings.json ├── src ├── Admin.ts ├── ApiSchema.ts ├── ApiStatus.ts ├── ApiStatusApi.ts ├── ApiUrlInterpreter.ts ├── AutoReact.ts ├── Botty.ts ├── CategorisedMessage.ts ├── CommandController.ts ├── ESports.ts ├── Endpoint.ts ├── FileBackedObject.ts ├── GameData.ts ├── Info.ts ├── InteractionManager.ts ├── JoinArguments.ts ├── KeyFinder.ts ├── LevenshteinDistance.ts ├── Logger.ts ├── PageDiffer.ts ├── Pickem.ts ├── RiotAPILibraries.ts ├── SharedSettings.ts ├── SpamKiller.ts ├── TechBlog │ └── xml_parser.ts ├── Techblog.ts ├── UserIntroduction.ts ├── VersionChecker.ts ├── app.ts └── vendor │ ├── html2plaintext.d.ts │ ├── turndown-plugin-gfm.d.ts │ └── turndown.d.ts ├── test └── GameData.spec.ts ├── tsconfig.json └── tslint.json /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Compile check 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v3 11 | - name: Setup node 12 | uses: actions/setup-node@v3 13 | with: 14 | node-version: 18.x 15 | cache: 'npm' 16 | - name: Install Packages 17 | run: npm install 18 | - name: Build Botty 19 | run: npm run compile 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # Webstorm 61 | .idea 62 | 63 | # Visual Studio Code 64 | .vscode 65 | 66 | # Compiled typescript sources. 67 | dist/ 68 | 69 | # Private bot files that shouldnt be shared 70 | private/ 71 | www/ 72 | 73 | # Mac 74 | .DS_Store 75 | 76 | data/*.json -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BottyMcBotface 2 | 3 | The Riot Games API Discord Bot. This is the bot that helps out with your questions regarding the Riot Games API. 4 | 5 | ## Available commands 6 | 7 | - **!esports**: Prints out a list of esports games. 8 | - **!help**: Prints all the commands 9 | - **!lib, !libs, !libraries**: Print a list of the public libraries 10 | - **!note, !notes**: Prints all the notes, or the content of a note if used with a parameter 11 | - **!pickem**: Prints out information about your pickems. 12 | - **!status, !apistatus**: Prints the status of the Riot Games API 13 | - **!toggle_default_thinking**: Changes bottys reaction from the normal thinking emote, to one of the custom ones (or opposite) 14 | - **!toggle_react**: Decides if botty reacts to your messages 15 | - **!update_schema, !updateschema**: Loads an updated api url schema 16 | - **!welcome**: Prints out a copy of the welcome message. 17 | 18 | ##### Admin-only commands 19 | 20 | - **!active_keys, !activekeys**: Prints a list of keys that botty have seen, and that are still active 21 | - **!refresh_thinking**: Reloads the thinking emojis 22 | - **!restart, !reboot**: Restarts Botty. 23 | - **!toggle_command**: Enables or disables commands (!toggle_command {command}) 24 | 25 | ## Modules currently in use 26 | 27 | - **CommandController**: As the name indicates, this controls the commands and makes sure other API commands have a working listener that can be toggled on or off during runtime by administrators. 28 | - **APISchema**: Controls the Riot API schema that is used by the API url extension and other modules. 29 | - **JoinMessaging**: Sends a message on join to new users so that they can get started right away. 30 | - **Logger**: A simple module that hooks console.log, warning and error so that we get any errors the bot outputs without having to check out the terminal it is running on. 31 | - **KeyFinder**: Scans messages for keys, and then keeps track of them. 32 | - **Techblog**: Updates #external-activity with posts on the Tech Blog. 33 | - **ApiUrlInterpreter**: Scans for API Urls and tries identifying issues. Otherwise, posts what the result of that API call would be. 34 | - **VersionChecker**: Checks out if League of Legends or DDragon has updated to a new version. If so, posts in #external-activity. 35 | - **Info**: Saves all kinds of notes and outputs them on request. This is the !note command. 36 | - **AutoReact**: A small module that reacts to specific messages made by users. 37 | - **ApiStatus**: Keeps track of the API status. 38 | - **RiotAPILibraries**: Can output the Riot API Libraries from the article on the forum via GitHub. 39 | - **Esports**: Handles the data collection and output for !esports 40 | - **Pickem**: Handles the data collection and output for !pickem 41 | 42 | ## Setting up 43 | 44 | 1. First, clone the repository by clicking the green "Clone or download" button, or alternatively, use `git clone git@github.com:Querijn/BottyMcBotface.git`. 45 | 2. I recommend using VS Code, as it has excellent Typescript support. It works fine without. I will explain both how to run this in a regular terminal and VS Code. 46 | 3. Open the root folder in the terminal. This is the folder that contains the `src`, `private`, `settings` and `data` folder. You can open VS Code in this folder by typing `code .` (Or alternatively, `code ` from anywhere) 47 | 4. Type `npm install` in the terminal. This will install all the packages that this application uses. 48 | 5. While that is running, you can go over to the private folder and open up `shared_settings.json`. This is the settings json that contains all the information that is specific to your running bot. You can put any JSON in here that is also in `settings/shared_settings.json`, because the private version will override settings over the global one. Do not commit your `private/shared_settings.json`. 49 | 6. The `server` is the Discord Guild/Server that your bot needs to focus on. It will work outside of this context, but it will try to find specific channels for use in here. This requires you to either setup a Discord server that looks like the official one, or to join [our own recreation](https://discord.gg/zTJYKkA) (requires some potential elevated actions): . The id for this server is `342988445498474498`. You can get an ID by enabling Developer mode in Discord (`User settings > Appearance > Advanced > Developer Mode`) and then rightclick on whatever server you need the ID of. 50 | 7. `botty.discord.key` needs to be your Discord Bot key. Go to [Discord's My Apps](https://discordapp.com/developers/applications/me) and [create a bot](https://discordapp.com/developers/applications/me/create). if you've already created one, skip to step 10. 51 | 8. Give it a name and a nice icon. Click create. 52 | 9. On the following page, click `Create a Bot User`. Confirm the following popup. 53 | 10. On this page, click `Reveal Token` under `Bot`. This will give you your key! It will look something like `NDY1MDcwNTg5NDkzOTAzMzYw.DiIKfg.6tZKdh7rgYQWNZIqsjaogVb56v8`. Put it in the shared_settings.json variable for the discord key. You're already setup to connect, but there are some changes required to make sure you can run the bot correctly. The forum and GitHub settings you can leave the same. 54 | 11. If you're making changes to the `RiotAPILibraries` module, you'll need to change them to your GitHub API password and your username. If you have forum administrator access, you can change your user to the format seen in the settings. Enter the forum password below that. 55 | 12. Then go to the app.ts. This is the entry point for the bot, and also contains every single module used. For simplicity, you can turn off everything but the module you're working on and the `CommandController`. Every command for the modules you've shut down need to be shutdown as well. Note: the Info module is for the `!note` commands. It was renamed to note because of a conflict with another bot existing previously. 56 | 13. Run the application with `npm run start` or `tslint -p . && tsc -p . && node ./dist/app.js`. The first command does not lint, and the second one does. Checking lint errors is required before committing code, but not required for building. 57 | -------------------------------------------------------------------------------- /data/admin_data.json: -------------------------------------------------------------------------------- 1 | { "tickets": {}, "muted": {} } 2 | -------------------------------------------------------------------------------- /data/command_data.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /data/endpoints.json: -------------------------------------------------------------------------------- 1 | {"lastUpdate":"2018-06-02T23:02:07.292Z","endpoints":["champion-mastery-v3","champion-v3","league-v3","lol-static-data-v3","lol-status-v3","match-v3","spectator-v3","summoner-v3","third-party-code-v3","tournament-stub-v3","tournament-v3"]} -------------------------------------------------------------------------------- /data/forum_data.json: -------------------------------------------------------------------------------- 1 | {"Last":{"question":0,"answer":0,"comment":0,"kbentry":0}} -------------------------------------------------------------------------------- /data/ignored_react_data.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /data/intro_data.json: -------------------------------------------------------------------------------- 1 | {"messages":{"IntroLine1":"704195432397668402","Rule5_1":"704195433181741138","Rule5_2":"704195434570055685","Rule5_3":"704195438466826361","Rule1":"704195440186228817","Rule3":"704195460251779102","Final":"704195461807996978"}} -------------------------------------------------------------------------------- /data/message.markdown: -------------------------------------------------------------------------------- 1 | Hey there! I'm Botty, let me be the first to welcome you to the Riot API Discord :) 2 | 3 | First and foremost: **This is not a helpdesk. We cannot ban or unban any Riot Games account. We cannot buff or nerf any champion. We cannot help you with technical issues in your game.** The Rioters here *do not work on the games*. If you have problems with League of Legends or Teamfight Tactics, please contact support. https://support.riotgames.com/hc/en-us 4 | 5 | Here's some tips to help you on your way: 6 | 1. Be sure to read the rules! #rules-faq 7 | 2. If you have any questions about the Riot API for LoL, I recommend you post them in #lol-dev, or for TFT or LoR, in #tft-dev or #lor-dev. Please don't ask if you can ask a question, just ask it! If someone knows the answer, they will respond, while "Can I ask a question?" will rarely be replied to. 8 | 3. If your English is not that great, you can ask a translator, which have yellow names. Feel free to mention them with `@Translator `. 9 | 4. The pins in the Riot API channel might hold some resources that are not on DDragon. If they're not in the pins, be sure to check out CommunityDragon; https://www.communitydragon.org/ 10 | 5. I can help you find issues in your url, and also show you the results. Just paste any Riot API url and I will work my magic. Make sure that your key is not in the URL! 11 | 6. You can check the status of the API with `!status`. 12 | 7. Even though Riot Games doesn't endorse any libraries, we want you to be able to contact the developers of libraries, if available. For example, you can mention the Cassiopeia devs with `@Cassiopeia devs`. 13 | 14 | The commands I have right now are: 15 | -------------------------------------------------------------------------------- /data/office_hours_data.json: -------------------------------------------------------------------------------- 1 | {"questions":[],"isOpen":false,"lastCloseMessage":"419320162496544768","nextId":1} -------------------------------------------------------------------------------- /data/page_differ.json: -------------------------------------------------------------------------------- 1 | {"hashes":{"article202294884":-1090264497,"pagehttps://www.riotgames.com/en/legal":-1751093520}} -------------------------------------------------------------------------------- /data/riot_keys.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /data/techblog_data.json: -------------------------------------------------------------------------------- 1 | {"Last":0} -------------------------------------------------------------------------------- /data/thinking_data.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /data/ttt_scores.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /data/version_data.json: -------------------------------------------------------------------------------- 1 | {"latestGameVersion":"8.1","latestDataDragonVersion":"8.1.1"} -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "botty-mcbotface", 3 | "description": "A bot for the Riot Games API Discord.", 4 | "repository": { 5 | "url": "git@github.com:Querijn/BottyMcBotface.git", 6 | "type": "git" 7 | }, 8 | "scripts": { 9 | "compile": "tsc -p .", 10 | "start": "tsc -p . && node ./dist/app.js", 11 | "test": "mocha -r ts-node/register test/**/*.spec.ts" 12 | }, 13 | "author": "Querijn Heijmans ", 14 | "license": "MIT", 15 | "dependencies": { 16 | "@types/node": "^17.0.23", 17 | "@types/node-fetch": "^2.6.1", 18 | "bufferutil": "^4.0.0", 19 | "cheerio": "^1.0.0-rc.10", 20 | "crc-32": "^1.2.0", 21 | "discord.js": "^14.8.0", 22 | "fast-xml-parser": "^4.4.1", 23 | "feed-reader": "^6.1.3", 24 | "fs-extra": "^7.0.1", 25 | "html2plaintext": "^2.1.4", 26 | "moment": "^2.29.4", 27 | "node-fetch": "^2.6.1", 28 | "pretty-ms": "^3.0.0", 29 | "striptags": "^3.2.0", 30 | "typescript": "^4.6.3", 31 | "uws": "9.14.0", 32 | "xregexp": "^4.2.0" 33 | }, 34 | "devDependencies": { 35 | "@types/chai": "^4.2.4", 36 | "@types/cheerio": "^0.22.8", 37 | "@types/fs-extra": "^5.0.4", 38 | "@types/mocha": "^5.2.7", 39 | "@types/pretty-ms": "^3.0.0", 40 | "@types/ws": "^7.2.4", 41 | "@types/xregexp": "^3.0.29", 42 | "chai": "^4.2.0", 43 | "mocha": "^10.8.2", 44 | "ts-mockito": "^2.5.0", 45 | "ts-node": "^8.9.0", 46 | "tslint": "^6.1.1", 47 | "typemoq": "^2.1.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /private/shared_settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "server": { 3 | "guildId": "342988445498474498", 4 | "guruChannel": "gurus" 5 | }, 6 | "botty": { 7 | "discord": { 8 | "key": "", 9 | "owner": 95265940081475584 10 | }, 11 | "forum": { 12 | "username": " ()", 13 | "password": "" 14 | }, 15 | "github": { 16 | "username": "Querijn", 17 | "password": "" 18 | }, 19 | "isProduction": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /settings/command_list.json: -------------------------------------------------------------------------------- 1 | { 2 | "botty": { 3 | "restart": { 4 | "admin": true, 5 | "aliases": [ 6 | "restart", 7 | "reboot" 8 | ], 9 | "description": "Restarts Botty." 10 | } 11 | }, 12 | "gamedata": { 13 | "lookup": { 14 | "aliases": [ 15 | "lookup", 16 | "search", 17 | "item", 18 | "perk", 19 | "rune", 20 | "champion", 21 | "champ", 22 | "summonerspell", 23 | "summ", 24 | "spell" 25 | ], 26 | "description": "Search for information on that content in the game" 27 | } 28 | }, 29 | "games": { 30 | "ttt": { 31 | "aliases": [ 32 | "ttt" 33 | ], 34 | "description": "Starts a game of TTT versus that player" 35 | } 36 | }, 37 | "admin": { 38 | "mute": { 39 | "admin": true, 40 | "aliases": [ 41 | "mute" 42 | ], 43 | "description": "Mutes a user" 44 | }, 45 | "ban": { 46 | "admin": true, 47 | "aliases": [ 48 | "ban" 49 | ], 50 | "description": "Ban one or several users with a specific note or message. " 51 | }, 52 | "kick": { 53 | "admin": true, 54 | "aliases": [ 55 | "kick" 56 | ], 57 | "description": "Kick one or several users with a specific note or message. " 58 | }, 59 | "unmute": { 60 | "admin": true, 61 | "aliases": [ 62 | "unmute" 63 | ], 64 | "description": "Unmutes a user" 65 | }, 66 | "ticket": { 67 | "admin": true, 68 | "aliases": [ 69 | "ticket", 70 | "tickets" 71 | ], 72 | "description": "List previous offenses of a user or add a new one." 73 | } 74 | }, 75 | "esports": { 76 | "date": { 77 | "aliases": [ 78 | "esports" 79 | ], 80 | "description": "Print out esport games on that date" 81 | }, 82 | "pickem": { 83 | "aliases": [ 84 | "pickem", 85 | "pick_em", 86 | "pick_ems", 87 | "pickems" 88 | ], 89 | "description": "Print out your pickems" 90 | } 91 | }, 92 | "controller": { 93 | "toggle": { 94 | "admin": true, 95 | "aliases": [ 96 | "toggle_command" 97 | ], 98 | "description": "Enables or disables commands (!toggle_command {command})" 99 | }, 100 | "help": { 101 | "aliases": [ 102 | "help" 103 | ], 104 | "description": "Prints all the commands" 105 | } 106 | }, 107 | "apiSchema": { 108 | "updateSchema": { 109 | "admin": true, 110 | "aliases": [ 111 | "update_schema", 112 | "updateschema" 113 | ], 114 | "description": "Loads an updated api url schema", 115 | "cooldown": 300000 116 | } 117 | }, 118 | "keyFinder": { 119 | "admin": true, 120 | "aliases": [ 121 | "active_keys", 122 | "activekeys" 123 | ], 124 | "description": "prints a list of keys that botty have seen, and that are still active" 125 | }, 126 | "info": { 127 | "note": { 128 | "aliases": [ 129 | "note", 130 | "notes" 131 | ], 132 | "description": "Prints all the notes, or the content of a note if used with a parameter" 133 | }, 134 | "all": { 135 | "aliases": [ 136 | "*" 137 | ], 138 | "description": "Prints all the notes, or the content of a note if used with a parameter", 139 | "prefix": "." 140 | } 141 | }, 142 | "welcome": { 143 | "aliases": [ 144 | "welcome" 145 | ], 146 | "description": "Prints out a copy of the welcome message.", 147 | "cooldown": 300000 148 | }, 149 | "riotApiLibraries": { 150 | "aliases": [ 151 | "lib", 152 | "libs", 153 | "libraries" 154 | ], 155 | "description": "Print a list of the public libraries" 156 | }, 157 | "autoReact": { 158 | "toggle_default_thinking": { 159 | "aliases": [ 160 | "toggle_default_thinking" 161 | ], 162 | "description": "Changes bottys reaction from the normal thinking emote, to one of the custom ones (or opposite)" 163 | }, 164 | "toggle_react": { 165 | "aliases": [ 166 | "toggle_react" 167 | ], 168 | "description": "Decides if botty reacts to your messages" 169 | }, 170 | "refresh_thinking": { 171 | "admin": true, 172 | "aliases": [ 173 | "refresh_thinking" 174 | ], 175 | "description": "Reloads the thinking emojis" 176 | } 177 | }, 178 | "apiStatus": { 179 | "aliases": [ 180 | "status", 181 | "apistatus" 182 | ], 183 | "description": "Prints the status of the Riot Games API", 184 | "cooldown": 300000 185 | }, 186 | "endpointManager": { 187 | "endpoint": { 188 | "admin": false, 189 | "aliases": [ 190 | "endpoint" 191 | ], 192 | "description": "Gives a link to a single Riot API endpoint" 193 | }, 194 | "endpoints": { 195 | "admin": false, 196 | "aliases": [ 197 | "endpoints", 198 | "endpointlist", 199 | "endpoint_list" 200 | ], 201 | "description": "Shows a list of all Riot API endpoints." 202 | } 203 | } 204 | } -------------------------------------------------------------------------------- /settings/shared_settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "server": { 3 | "guildId": "342988445498474498", 4 | "guruChannel": "gurus", 5 | "guruLogChannel": "content-alerts", 6 | "introChannel": "new-users" 7 | }, 8 | "botty": { 9 | "discord": { 10 | "key": "", 11 | "owner": 132268173432061952 12 | }, 13 | "honeypot": { 14 | "token": "", 15 | "owner": 132268173432061952 16 | }, 17 | "forum": { 18 | "username": " ()", 19 | "password": "" 20 | }, 21 | "riotApi": { 22 | "key": "" 23 | }, 24 | "webServer": { 25 | "relativeFolderLocation": "www/", 26 | "relativeLiveLocation": "https://irule.at/riot/api/" 27 | }, 28 | "github": { 29 | "username": "Querijn", 30 | "password": "" 31 | }, 32 | "isProduction": false, 33 | "appName": "app" 34 | }, 35 | "lookup": { 36 | "refreshTimeout": 86400000, 37 | "championUrl": "https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json", 38 | "skinUrl": "https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/skins.json", 39 | "perkUrl": "https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/perks.json", 40 | "itemUrl": "https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/items.json", 41 | "summonerSpellUrl": "https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/summoner-spells.json", 42 | "confidence": 3, 43 | "maxGuessCount": 10 44 | }, 45 | "commands": { 46 | "default_prefix": "!", 47 | "adminRoles": [ 48 | "187653195038720000", 49 | "187653430372728833", 50 | "350692046631206912", 51 | "347539422943903755", 52 | "363439216841064449", 53 | "348450566571032576", 54 | "865105658009485333" 55 | ] 56 | }, 57 | "admin": { 58 | "muteRoleId": "428943553167884307", 59 | "muteRoleName": "muted", 60 | "muteTimeout": 86400000, 61 | "keepAdminCommandsChannels": [ 62 | "865107613992616007", 63 | "866816996900864041", 64 | "429367396420157460" 65 | ] 66 | }, 67 | "esports": { 68 | "printChannel": "lol-esports", 69 | "updateTimeout": 18000000, 70 | "printToChannelTimeout": 43200000 71 | }, 72 | "techBlog": { 73 | "checkInterval": 300000, 74 | "channel": "tech-articles", 75 | "url": "https://engineering.riotgames.com/news/feed" 76 | }, 77 | "keyFinder": { 78 | "reportChannel": "gurus" 79 | }, 80 | "forum": { 81 | "checkInterval": 10000, 82 | "channel": "external-activity", 83 | "url": "http://discussion.developer.riotgames.com/" 84 | }, 85 | "pageDiffer": { 86 | "checkInterval": 600000, 87 | "channel": "external-activity", 88 | "pages": [{ 89 | "name": "Champion Update Schedule", 90 | "ident": "202294884", 91 | "type": "article" 92 | }, 93 | { 94 | "name": "Legal Jibber Jabber", 95 | "ident": "https://www.riotgames.com/en/legal", 96 | "type": "page" 97 | } 98 | ], 99 | "articleHost": "https://support-leagueoflegends.riotgames.com/api/v2/help_center/en-us/articles/{id}.json", 100 | "embedImageUrl": "https://irule.at/riot/api/morello.png" 101 | }, 102 | "info": { 103 | "allowedRoles": [ 104 | "187653195038720000", 105 | "187653430372728833", 106 | "350692046631206912", 107 | "347539422943903755" 108 | ], 109 | "command": "note", 110 | "maxScore": 2, 111 | "maxListeners": 15 112 | }, 113 | "riotApiLibraries": { 114 | "noLanguage": "No libraries found for language: ", 115 | "languageList": "I have libraries for one of the following languages: {languages}\nChoose one you like and reply with `!libs <...tags>`.", 116 | "githubErrorList": "I tried to get the languages from Github, but Github replied with status code ", 117 | "githubErrorLanguage": "I tried to get the language from Github, but Github replied with status code ", 118 | "baseURL": "https://api.github.com/repos/WxWatch/riot-api-libraries/contents/libraries/", 119 | "aliases": { 120 | "javascript": [ 121 | "js", 122 | "node", 123 | "nodejs" 124 | ], 125 | "typescript": [ 126 | "ts" 127 | ], 128 | "c-sharp": [ 129 | "c#", 130 | "cs", 131 | "csharp" 132 | ], 133 | "objective-c": [ 134 | "objc" 135 | ], 136 | "python": [ 137 | "py" 138 | ] 139 | }, 140 | "channelTopics": { 141 | "lol": [ 142 | "v4" 143 | ], 144 | "lcu": [ 145 | "lcu" 146 | ], 147 | "ingame": [ 148 | "ingame" 149 | ], 150 | "tft": [ 151 | "tft" 152 | ], 153 | "lor": [ 154 | "lor" 155 | ], 156 | "val": [ 157 | "val" 158 | ], 159 | "rso": [ 160 | "rso" 161 | ] 162 | }, 163 | "checkInterval": 10800000 164 | }, 165 | "autoReact": { 166 | "emoji": "408527155891273738" 167 | }, 168 | "versionChecker": { 169 | "checkInterval": 1800000, 170 | "channel": "external-activity", 171 | "dataDragonThumbnail": "https://cdn.discordapp.com/attachments/187652476080488449/357422324015955970/ddragon.png", 172 | "gameThumbnail": "https://cdn.discordapp.com/attachments/358285665571700736/1383086208699203695/lollogo.png" 173 | }, 174 | "logger": { 175 | "server": "342988445498474498", 176 | "dev": { 177 | "logChannel": "dev-bot-log", 178 | "errorChannel": "dev-bot-error" 179 | }, 180 | "prod": { 181 | "logChannel": "prod-bot-log", 182 | "errorChannel": "prod-bot-error" 183 | } 184 | }, 185 | "apiStatus": { 186 | "checkInterval": 300000, 187 | "apiOnFireThreshold": 0.1, 188 | "statusUrl": "http://querijn.codes/api_status/1.1/", 189 | "onFireImages": [ 190 | "https://media.giphy.com/media/dbtDDSvWErdf2/giphy.gif", 191 | "https://media.giphy.com/media/nLhdSinRtaL2E/giphy.gif", 192 | "https://i.imgur.com/Ufq47yf.gif", 193 | "https://i.imgur.com/6NfmQ.jpg", 194 | "https://media.giphy.com/media/NTur7XlVDUdqM/giphy.gif", 195 | "https://media.tenor.com/images/784e4d03df8e65819495495c78782070/tenor.gif", 196 | "https://i.kym-cdn.com/photos/images/original/000/000/130/disaster-girl.jpg", 197 | "https://media0.giphy.com/media/nrXif9YExO9EI/source.gif", 198 | "https://thumbs.gfycat.com/AlarmingNaiveBluemorphobutterfly-small.gif" 199 | ] 200 | }, 201 | "onJoin": { 202 | "messageFile": "data/message.markdown" 203 | }, 204 | "apiUrlInterpreter": { 205 | "timeOutDuration": 10800000 206 | }, 207 | "endpoint": { 208 | "updateInterval": 86400, 209 | "timeOutDuration": 10800000, 210 | "baseUrl": "https://developer.riotgames.com/apis#", 211 | "maxDistance": 3, 212 | "aliases": { 213 | "lol-status-v4": [ 214 | "league-status" 215 | ] 216 | } 217 | }, 218 | "pickem": { 219 | "leaderboardUrl": "https://pickem.lolesports.com/api/get_vs_list_rankings/{listId}", 220 | "groupPickUrl": "https://pickem.lolesports.com/api/get_group_picks/series/{series}/user/{user}", 221 | "bracketsUrl": "https://pickem.lolesports.com/api/get_bracket_picks/series/{series}/user/{user}", 222 | "pointsUrl": "https://pickem.lolesports.com/api/get_points/series/{series}?user=", 223 | "worldsId": 7, 224 | "blankId": 1743991, 225 | "listId": [918098], 226 | "updateTimeout": 3600000, 227 | "printMode": "BRACKET" 228 | }, 229 | "spam": { 230 | "allowedUrls": [ 231 | "riotgames.com", 232 | "leagueoflegends.com" 233 | ], 234 | "blockedUrls": [ 235 | "t.me" 236 | ], 237 | "ignoredRoles": [ 238 | "1055545240058073138", 239 | "1097223791836414073" 240 | ], 241 | "duplicateMessageThreshold": 4, 242 | "duplicateMessageTime": 30, 243 | "floodMessageThreshold": 3, 244 | "floodMessageTime": 4 245 | }, 246 | "userIntro": { 247 | "icon": { 248 | "default": "🇬🇧", 249 | "fr": "🇫🇷", 250 | "es": "🇪🇸", 251 | "de": "🇩🇪" 252 | }, 253 | "role": { 254 | "id": "511998818288402441", 255 | "name": "new" 256 | }, 257 | "lines": [ 258 | { 259 | "id": "IntroLine1", 260 | "lineTranslation": { 261 | "default": "If you wish to have access to this Discord server, you must first agree to all of the rules below. You can agree with them by clicking the :white_check_mark: underneath each.", 262 | "fr": "Pour accéder au serveur Discord, vous devez d'abord accepter les règles ci-dessous. Pour accepter, cliquez sur le :white_check_mark: sous chaque règle.", 263 | "es": "Si deseas tener acceso a este servidor de Discord, primero debes aceptar todas las reglas escritas abajo. Puedes aceptarlas haciendo click al :white_check_mark: debajo de cada una.", 264 | "de": "Um Zugang zu diesem Discord-Server zu erhalten, musst du zunächst den Regeln zustimmen. Klicke dazu auf das :white_check_mark: unterhalb der Nachricht." 265 | }, 266 | "type": "intro" 267 | }, 268 | { 269 | "id": "Rule5_1", 270 | "lineTranslation": { 271 | "default": "I understand nobody here can help me with issues with any of Riot Games' games, reports, bans or installers. I understand this is not Riot Games' support, which is located at . I know you can alternatively send an email to support@riotgames.com. This server is purely for interaction with the Riot API, Client API, or in-game/replay API.", 272 | "fr": "Je comprends que personne sur ce serveur ne peut m'aider pour des problèmes avec un des jeu Riot Games, des signalements, bannissements ou installateurs. Je comprends que ce n'est pas le support de Riot Games, qui est ici : . Je sais qu'à la place, je peux envoyer un mail à support@riotgames.com. Ce serveur est réservé aux échanges sur la Riot API, la Client API et les in-game/replay API.", 273 | "es": "Entiendo que nadie de aqui puede ayudarme con problemas relacionados con reportes, baneos o instalaciones de los juegos de Riot Games. Entiendo que esto no es el soporte de Riot Games, el cual esta localizado en . Reconozco que alternativamente puedo enviar un correo a support@riotgames.com. Este servidor es para pura interacción con el API de Riot, API del Cliente de LoL, o el API de in-game/replay.", 274 | "de": "Ich verstehe, dass dieser Discord Server nicht bei Problemen rund um den Client, Bans, Reports oder die Installation von Riot Games Spielen helfen kann. Den offiziellen Riot Support finde ich unter oder über E-Mail unter support@riotgames.com. Dieser Server ist ausschließlich für Interaktionen Zwecks der Riot-API, der Client-API und der Ingame-/Replay-API gedacht." 275 | }, 276 | "type": "rule" 277 | }, 278 | { 279 | "id": "Rule5_2", 280 | "lineTranslation": { 281 | "default": "I will not send messages or mention the Rioters, unless explicitly given permission for, or when I already were in an active conversation with that Rioter.", 282 | "fr": "Je ne vais pas envoyer de message aux Rioters ou les mentionner, sauf autorisation explicite, ou si je suis déjà en conversation avec le Rioter concerné.", 283 | "es": "No mandare mensajes o mencionare a los Rioters, a menos que me hayan dado permiso explícitamente para hacerlo, o cuando ya hubiera estado en una conversación activa con ese Rioter.", 284 | "de": "Ich werde keine Riot-Mitarbeiter direkt anschreiben oder in meinen Nachrichten taggen, solange ich nicht explizit die Erlaubnis dazu erhalten habe oder ich nicht auf eine aktive Unterhaltung antworte." 285 | }, 286 | "type": "rule" 287 | }, 288 | { 289 | "id": "Rule5_3", 290 | "lineTranslation": { 291 | "default": "If I have an issue that only a Rioter can provide a solution for, I will type `!ask`, followed by my question (for instance, `!ask It seems my application is stuck whenever I request something (HTTP 512); it does not happen on my developer key. The message in the JSON told me to contact the administrator!`) These questions will be answered during Office Hours.", 292 | "fr": "Si j'ai un problème que seul un Rioter peut résoudre, je vais taper `!ask` suivi de ma question en anglais (par exemple, `!ask It seems my application is stuck whenever I request something (HTTP 512); it does not happen on my developer key. The message in the JSON told me to contact the administrator!`). Pour de m'aider à traduire, je peux mentionner `@Translator - French`. Les Rioters répondront à ces questions pendant les \"Office Hours\".", 293 | "es": "Si tengo un problema en el que solo un Rioter pueda encontrar la solución, escribiré el comando `!ask`, seguido de mi pregunta (por ejemplo, `!ask Parece que mi aplicación esta atorada cuando hago una request (HTTP 512); esto no pasa con mi llave de desarrollo. El mensaje en el JSON me dijo que contactara al administrador!) Estas preguntas serán respondidas durante Office Hours.`", 294 | "de": "Wenn ich eine Frage habe die nur ein Rioter beantworten kann, werde ich stattdessen die `!ask` Funktion verwenden (z.B. `!ask Meine Anwendung hat Probleme Anfragen an die API zu verarbeiten (HTTP 512); Die Serverantwort hat mich auf Kontakt mit einem Admin verwiesen`) Diese Fragen werden üblicherweise während der Office Hours beantwortet." 295 | }, 296 | "type": "rule" 297 | }, 298 | { 299 | "id": "Rule1", 300 | "lineTranslation": { 301 | "default": "I will be respectful. I won't spam, advertise or place NSFW (Not Safe For Work) content. My username, nickname and profile avatar are OK to use, and not offensive.", 302 | "fr": "Je serai courtois et respectueux. Je ne vais pas spammer, faire de la pub ou poster du contenu inapproprié ou NSFW (Not Safe For Work). Mon nom d'utilisateur, pseudo et image de profil ne sont ni inappropriés, ni offensants.", 303 | "es": "Seré respetuoso. No haré Spam, publicitaré o pondré contenido NSFW (No seguro para el trabajo). Mi usuario, alias y avatar están en ese lineamiento, y no son ofensivos.", 304 | "de": "Ich werde mich respektvoll verhalten. Ich werde weder spammen, werben noch Inhalte posten die als NSFW (Nicht jugendfrei/ unpassend fürs Arbeitsumfeld) klassifiziert werden könnten. Mein Benutzername, Spitzname und Profilbild sind entsprechend auch weder anstößig noch disrespektvoll." 305 | }, 306 | "type": "rule" 307 | }, 308 | { 309 | "id": "Rule3", 310 | "lineTranslation": { 311 | "default": "If I run into an issue, I will find the appropriate channel, and search for my issue first. I will also check the pins in the Discord channel.", 312 | "fr": "Si je rencontre un problème, je vais d'abord me rendre dans le canal concerné et y chercher mon problème. Je vais aussi vérifier les messages épinglés du canal Discord.", 313 | "es": "Si corro con algún problema, primero encontrare el canal apropiado donde buscar mi problema. También checare los mensajes fijados en el canal de Discord.", 314 | "de": "Falls ich ein Problem habe werde ich im entsprechenden Channel erst nach dem Problem suchen und dabei auch besonders auf die angehefteten Nachrichten achten." 315 | }, 316 | "type": "rule" 317 | }, 318 | { 319 | "id": "Final", 320 | "lineTranslation": { 321 | "default": "If I ignore any of the rules above, I understand I might get kicked from the server.", 322 | "fr": "Je comprends que je pourrai être exclu du serveur si je ne respecte l'une de ces règles.", 323 | "es": "Si ignoro cualquiera de las reglas escritas arriba, entiendo que podré ser echado del servidor.", 324 | "de": "Ich verstehe, dass das Ignorieren der Regeln zu meinem Ausschluss vom Server führen kann." 325 | }, 326 | "type": "rule" 327 | } 328 | ] 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /src/ApiSchema.ts: -------------------------------------------------------------------------------- 1 | import Discord = require("discord.js"); 2 | import fetch from "node-fetch"; 3 | import XRegExp = require("xregexp"); 4 | 5 | import { levenshteinDistance } from "./LevenshteinDistance"; 6 | import { SharedSettings } from "./SharedSettings"; 7 | 8 | import { clearTimeout, setTimeout } from "timers"; 9 | 10 | export class Path { 11 | /** 12 | * Constructs a regex that will match an API endpoint and store each path parameter in a named group. 13 | */ 14 | public static constructRegex(path: string, methodSchema: any): RegExp { 15 | // Allow an optional trailing slash 16 | let regex = path + "/?"; 17 | // Escape slashes 18 | regex = regex.replace(/\//g, "\\/"); 19 | // Replace each parameter with a named regex group 20 | while (regex.match(/\{(.*?)\}/)) 21 | regex = regex.replace(/\{(.*?)\}/, (match, p2) => `(?<${p2}>[^\\/?\\s]*)`); 22 | 23 | return XRegExp(regex + "$"); 24 | } 25 | 26 | public name: string; 27 | public methodType: "GET" | "POST"; 28 | public regex: RegExp; 29 | public pathParameters: Map = new Map(); 30 | public queryParameters: Map = new Map(); 31 | /** Indicates if Botty may make calls to the API method. This will be `false` if Botty doesn't have access to the API or if there are other concerns. */ 32 | public canUse: boolean; 33 | 34 | public constructor(name: string, methodSchema: any, methodType: "GET" | "POST") { 35 | this.name = name; 36 | this.methodType = methodType; 37 | this.canUse = !methodSchema.operationId.startsWith("tournament-v4"); 38 | 39 | this.regex = Path.constructRegex(name, methodSchema); 40 | 41 | if (methodSchema.parameters) { 42 | for (const parameter of methodSchema.parameters) { 43 | this.addParameter(parameter.in, parameter); 44 | } 45 | } 46 | } 47 | 48 | public addParameter(type: "query" | "path", parameterSchema: any) { 49 | const parameter = new Parameter(parameterSchema); 50 | 51 | let map: Map; 52 | if (type === "path") { 53 | map = this.pathParameters; 54 | } else if (type === "query") { 55 | map = this.queryParameters; 56 | } else if (type === "header") { 57 | return; // TODO 58 | } else { 59 | console.warn(`Unknown parameter location "${type}"`); 60 | return; 61 | } 62 | 63 | map.set(parameter.name, parameter); 64 | } 65 | } 66 | 67 | export class Parameter { 68 | public name: string; 69 | public required: boolean; 70 | public type: ParameterType; 71 | 72 | public constructor(parameterSchema: any) { 73 | this.name = parameterSchema.name; 74 | this.required = parameterSchema.required; 75 | this.type = ParameterType.getParameterType(parameterSchema.schema.type); 76 | } 77 | } 78 | 79 | export class ParameterType { 80 | public static getParameterType(type: string): ParameterType { 81 | switch (type) { 82 | case "integer": 83 | return ParameterType.PARAMETER_TYPES.INTEGER; 84 | case "string": 85 | return ParameterType.PARAMETER_TYPES.STRING; 86 | case "boolean": 87 | return ParameterType.PARAMETER_TYPES.BOOLEAN; 88 | case "array": 89 | return ParameterType.PARAMETER_TYPES.SET; 90 | default: 91 | console.warn(`No suitable ParameterType found for parameter "${type}" - defaulting to any type`); 92 | return ParameterType.PARAMETER_TYPES.ANY; 93 | } 94 | } 95 | 96 | private static PARAMETER_TYPES = { 97 | ANY: new ParameterType("anything", (value) => true), 98 | STRING: new ParameterType("a string", (value) => !Array.isArray(value)), 99 | INTEGER: new ParameterType("an integer", (value) => Number.isInteger(+value)), 100 | BOOLEAN: new ParameterType("a boolean", (value) => value === "true" || value === "false"), 101 | SET: new ParameterType("a set specified like `paramName=value1¶mName=value2`", (value) => { 102 | if (Array.isArray(value)) return true; 103 | // Check if a common delimiter was erroneously specified. If one wasn't, it means the value is likely just a single element in a set. 104 | return !(value.includes(",") || value.includes("+")); 105 | }), 106 | }; 107 | 108 | /** A human readable description (e.g. "a positive integer") */ 109 | public description: string; 110 | 111 | constructor(description: string, isValidValue: (value: string) => boolean) { 112 | this.description = description; 113 | this.isValidValue = isValidValue; 114 | } 115 | 116 | /** A function that returns a boolean indicating if the specified value is a valid value for this type of parameter */ 117 | public isValidValue(value: string | string[]): boolean { return false; } 118 | } 119 | 120 | export class APISchema { 121 | public paths: Path[] = []; 122 | public platforms: string[]; 123 | 124 | private sharedSettings: SharedSettings; 125 | private timeOut: NodeJS.Timer | null; 126 | 127 | constructor(sharedSettings: SharedSettings) { 128 | this.sharedSettings = sharedSettings; 129 | this.updateSchema(); 130 | } 131 | 132 | public getClosestPlatform(platformParam: string) { 133 | const validPlatform = this.platforms.includes(platformParam.toLowerCase()); 134 | if (validPlatform) return null; 135 | 136 | return this.platforms.map(p => { 137 | return { 138 | platform: p, 139 | distance: levenshteinDistance(platformParam, p), 140 | }; 141 | }).sort((a, b) => a.distance - b.distance)[0].platform; 142 | } 143 | 144 | public async onUpdateSchemaRequest(message: Discord.Message, isAdmin: boolean, command: string, args: string[]) { 145 | const replyMessagePromise = message.channel.send("Updating schema.."); 146 | 147 | console.log(`${message.author.username} requested a schema update.`); 148 | await this.updateSchema(); 149 | 150 | const newMessage = "Updated schema."; 151 | const replyMessage = await replyMessagePromise; 152 | 153 | // Could be an array? Would be weird. 154 | if (Array.isArray(replyMessage)) { 155 | console.warn("replyMessage is an array, what do you know?"); 156 | replyMessage.forEach(m => m.edit(newMessage)); 157 | } else replyMessage.edit(newMessage); 158 | } 159 | 160 | public async updateSchema() { 161 | try { 162 | const response = await fetch(`http://www.mingweisamuel.com/riotapi-schema/openapi-3.0.0.json`, { 163 | method: "GET", 164 | headers: { 165 | "Accept": "application/json", 166 | "Content-Type": "application/json", 167 | }, 168 | }); 169 | 170 | if (response.status !== 200) { 171 | console.error("HTTP Error trying to get schema: " + response.status); 172 | return; 173 | } 174 | 175 | // TODO: schema type 176 | const schema = (await response.json()); 177 | 178 | this.platforms = schema.servers[0].variables.platform.enum; 179 | this.paths = []; 180 | 181 | for (const pathName in schema.paths) { 182 | const pathSchema = schema.paths[pathName]; 183 | const methodSchema = pathSchema.get ? pathSchema.get : pathSchema.post; 184 | 185 | if (!methodSchema) continue; // Only handle GET/POST 186 | 187 | this.paths.push(new Path(pathName, methodSchema, pathSchema.get ? "GET" : "POST")); 188 | } 189 | 190 | // This fixes the issue where it would match getAllChampionsMasteries before a specific champion mastery (which starts the same but has extra parameters) 191 | this.paths = this.paths.sort((a, b) => b.name.length - a.name.length); 192 | } catch (e) { 193 | console.error("Schema fetch error: " + e.message); 194 | } 195 | 196 | if (this.timeOut) { 197 | clearTimeout(this.timeOut); 198 | this.timeOut = null; 199 | } 200 | this.timeOut = setTimeout(this.updateSchema.bind(this), this.sharedSettings.apiUrlInterpreter.timeOutDuration); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/ApiStatus.ts: -------------------------------------------------------------------------------- 1 | import * as prettyMs from "pretty-ms"; 2 | import APIStatusAPI, { APIStatus } from "./ApiStatusApi"; 3 | import { SharedSettings } from "./SharedSettings"; 4 | 5 | import Discord = require("discord.js"); 6 | 7 | /** 8 | * Map from API name (e.g. champion-mastery-v4) to the string used in the embed field. 9 | * Only APIs with issues (troubled/down) are stored. 10 | * 11 | * @interface ApiStates 12 | */ 13 | interface ApiStates { 14 | [key: string]: string; 15 | } 16 | 17 | interface StatusEmbedState { 18 | api: ApiStates; 19 | onFire: boolean; 20 | allApisOK: boolean; 21 | allApisIssues: boolean; 22 | } 23 | 24 | export default class ApiStatus { 25 | private bot: Discord.Client; 26 | private sharedSettings: SharedSettings; 27 | private apiStatusAPI: APIStatusAPI; 28 | 29 | private lastCheckTime: number; 30 | private currentStatus: StatusEmbedState; 31 | 32 | constructor(sharedSettings: SharedSettings) { 33 | this.sharedSettings = sharedSettings; 34 | this.lastCheckTime = 0; 35 | this.apiStatusAPI = new APIStatusAPI(this.sharedSettings.apiStatus.statusUrl, this.sharedSettings.apiStatus.checkInterval); 36 | } 37 | 38 | public async onStatus(message: Discord.Message, isAdmin: boolean, command: string, args: string[]) { 39 | const apiStatus = await this.getApiStatus(); 40 | 41 | const fields: { 42 | name: string, 43 | value: string, 44 | inline: boolean, 45 | }[] = []; 46 | 47 | for (const api in apiStatus.api) { 48 | fields.push({ name: api, value: apiStatus.api[api], inline: true }); 49 | } 50 | 51 | if (!apiStatus.allApisIssues) { 52 | fields.push({ 53 | inline: true, 54 | name: apiStatus.allApisOK ? "All APIs" : "All other APIs", 55 | value: ":white_check_mark:", 56 | }); 57 | } 58 | 59 | // fixes formatting with empty fields 60 | while (fields.length % 3 !== 0) { 61 | fields.push({ 62 | inline: true, 63 | name: "\u200b", 64 | value: "\u200b", 65 | }); 66 | } 67 | 68 | const embedContent: any = { 69 | author: { 70 | icon_url: "http://ddragon.leagueoflegends.com/cdn/7.20.2/img/champion/Heimerdinger.png", 71 | name: "API Status", 72 | url: "https://developer.riotgames.com/api-status/", 73 | }, 74 | color: 0xe74c3c, 75 | fields, 76 | }; 77 | 78 | if (apiStatus.onFire) { 79 | embedContent.image = { url: this.pickRandomOnFireImage() }; 80 | } 81 | 82 | try { 83 | await message.channel.send({ embeds: [embedContent] }); 84 | } 85 | catch (e) { 86 | if (e instanceof Discord.DiscordAPIError) { 87 | console.error(`Received DiscordAPIError while returning the current API status: ${e.code} "${e.message}"`); 88 | } 89 | else { 90 | console.error(`Received unknown error while returning the current API status: ${e}"`); 91 | } 92 | } 93 | } 94 | 95 | private async getApiStatus(): Promise { 96 | // cache embed state 97 | const timeDiff = Date.now() - this.lastCheckTime; 98 | if (timeDiff > this.sharedSettings.apiStatus.checkInterval) { 99 | const apiStatus = await this.apiStatusAPI.getApiStatus(); 100 | this.currentStatus = this.parseApiStatus(apiStatus); 101 | this.lastCheckTime = Date.now(); 102 | } 103 | 104 | return this.currentStatus; 105 | } 106 | 107 | private parseApiStatus(apiStatus: APIStatus): StatusEmbedState { 108 | const cacheObject: { [key: string]: any } = {}; 109 | 110 | let onFire = false; 111 | let allApisOK = true; 112 | let allApisIssues = false; 113 | let apiIssuesCounter = 0; 114 | let apiCounter = 0; 115 | const statusEmbed: StatusEmbedState = { api: {}, onFire: false, allApisOK: true, allApisIssues: false }; 116 | 117 | for (const api in apiStatus) { 118 | const regionStates: { troubled: string[], up: string[], down: string[] } = { troubled: [], up: [], down: [] }; 119 | let regionCounter = 0; 120 | 121 | for (const region in apiStatus[api]) { 122 | const regionState = apiStatus[api][region]; 123 | regionStates[regionState.state].push(region); 124 | regionCounter++; 125 | } 126 | 127 | cacheObject[api] = {}; 128 | let retStr = ""; 129 | if (regionStates.troubled.length > 0) { 130 | allApisOK = false; 131 | retStr += ":warning: " + this.joinRegions(regionStates.troubled) + "\n"; 132 | } 133 | if (regionStates.down.length > 0) { 134 | allApisOK = false; 135 | retStr += ":x: " + this.joinRegions(regionStates.down) + "\n"; 136 | } 137 | if (regionStates.up.length > 0) { 138 | allApisIssues = false; 139 | } 140 | apiIssuesCounter += regionStates.troubled.length + regionStates.down.length; 141 | apiCounter += regionStates.troubled.length + regionStates.down.length + regionStates.up.length; 142 | 143 | // API on fire, if all regions for one api have issues 144 | if (regionStates.troubled.length + regionStates.down.length === regionCounter) { 145 | onFire = true; 146 | } 147 | 148 | // only add api if it has issues 149 | if (regionStates.troubled.length + regionStates.down.length > 0) { 150 | statusEmbed.api[api] = retStr; 151 | } 152 | } 153 | 154 | const issueRate = ((apiIssuesCounter / apiCounter) || 0); 155 | statusEmbed.onFire = onFire || issueRate > this.sharedSettings.apiStatus.apiOnFireThreshold; 156 | statusEmbed.allApisOK = allApisOK; 157 | statusEmbed.allApisIssues = allApisIssues; 158 | return statusEmbed; 159 | } 160 | 161 | private joinRegions(arr: string[]): string { 162 | let retStr = ""; 163 | for (let j = 0; j < arr.length; j++) { 164 | const a = arr[j]; 165 | retStr += a + (j < arr.length - 1 ? ", " : ""); 166 | 167 | if (j % 4 === 3) { 168 | retStr += "\n"; 169 | } 170 | } 171 | 172 | if (retStr.length === 0) 173 | return "None"; 174 | return retStr; 175 | } 176 | 177 | private pickRandomOnFireImage(): string { 178 | // get rand in [0, number of on fire images - 1] 179 | const rand = Math.floor(Math.random() * this.sharedSettings.apiStatus.onFireImages.length); 180 | return this.sharedSettings.apiStatus.onFireImages[rand]; 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/ApiStatusApi.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | 3 | type RegionState = "up" | "troubled" | "down"; 4 | 5 | export interface RegionStatus { 6 | uptime: string; 7 | performance: string; 8 | state: RegionState; 9 | } 10 | export interface EndpointStatus { 11 | [key: string]: RegionStatus; 12 | } 13 | 14 | export interface APIStatus { 15 | [key: string]: EndpointStatus; 16 | } 17 | 18 | export default class ApiStatusApi { 19 | private apiUrl: string; 20 | private cached: APIStatus; 21 | private lastUpdate: number; 22 | private cacheDuration: number; 23 | 24 | public constructor(apiUrl: string, cacheDuration: number) { 25 | this.apiUrl = apiUrl; 26 | this.lastUpdate = 0; 27 | this.cacheDuration = cacheDuration; 28 | } 29 | 30 | public async getApiStatus(): Promise { 31 | if (Date.now() - this.lastUpdate < this.cacheDuration) { 32 | return this.cached; 33 | } 34 | 35 | const resp = await fetch(this.apiUrl, { 36 | method: "GET", 37 | headers: { 38 | "Accept": "application/json", 39 | "Content-Type": "application/json", 40 | }, 41 | }); 42 | 43 | if (resp.status !== 200) throw new Error(`[ApiStatus] Received status code ${resp.status}`); 44 | this.cached = await resp.json(); 45 | this.lastUpdate = Date.now(); 46 | return this.cached; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/ApiUrlInterpreter.ts: -------------------------------------------------------------------------------- 1 | import Discord = require("discord.js"); 2 | import fs = require("fs"); 3 | import fetch from "node-fetch"; 4 | import prettyMs = require("pretty-ms"); 5 | import XRegExp = require("xregexp"); 6 | 7 | import { ENODATA } from "constants"; 8 | import { Response } from "node-fetch"; 9 | import { platform } from "os"; 10 | import { clearTimeout, setTimeout } from "timers"; 11 | 12 | import { APISchema, Path } from "./ApiSchema"; 13 | import { fileBackedObject } from "./FileBackedObject"; 14 | import { levenshteinDistance } from "./LevenshteinDistance"; 15 | import { PersonalSettings, SharedSettings } from "./SharedSettings"; 16 | 17 | class RatelimitResult { 18 | public rateLimit: number; 19 | public startTime: number | null; 20 | 21 | constructor(rateLimit: number, startTime: number | null) { 22 | this.rateLimit = rateLimit; 23 | this.startTime = startTime; 24 | } 25 | } 26 | 27 | export default class ApiUrlInterpreter { 28 | private static ratelimitErrorMs = 100; 29 | /** 30 | * Matches Riot API call for each match found: 31 | * The 1st group is named "https" and indicates if the URL is using HTTP or HTTPS (it will be "s" or "S" if HTTPS, and empty if HTTP). 32 | * The 2nd group is named "platform" and is the platform ID (e.g "na1"). 33 | * The 3rd group is named "path" and is the URL path (e.g. "/lol/summoner/v4/summoners/902572087429847093845790243"). 34 | * The 4th group is named "query" and is the URL query (e.g. "api_key=aaaaaa&ayy=lmao"). If there is no URL query, this group will not exist. 35 | * https://regex101.com/r/WGLmBG/10/ 36 | */ 37 | private static API_CALL_REGEX = XRegExp("(?:http(?s?):\\/\\/(?\\w+)\\.api\\.riotgames\\.com)(?\\/[^\\s?]*)(?:\\?(?\\S*))?", "gim"); 38 | 39 | private bot: Discord.Client; 40 | private sharedSettings: SharedSettings; 41 | private personalSettings: PersonalSettings; 42 | private apiSchema: APISchema; 43 | private iterator: number = 1; 44 | 45 | private applicationRatelimitLastTime: number = 0; 46 | private methodRatelimitLastTime: { [method: string]: number } = {}; 47 | private applicationStartTime: number = 0; 48 | private methodStartTime: { [method: string]: number } = {}; 49 | 50 | private fetchSettings: object; 51 | 52 | constructor(bot: Discord.Client, sharedSettings: SharedSettings, apiSchema: APISchema) { 53 | console.log("Requested API URL Interpreter extension.."); 54 | 55 | this.sharedSettings = sharedSettings; 56 | this.personalSettings = sharedSettings.botty; 57 | this.apiSchema = apiSchema; 58 | console.log("Successfully loaded API URL Interpreter settings."); 59 | 60 | this.fetchSettings = { 61 | headers: { 62 | "X-Riot-Token": this.personalSettings.riotApi.key, 63 | }, 64 | }; 65 | 66 | this.bot = bot; 67 | this.bot.on("ready", this.onBot.bind(this)); 68 | this.bot.on("messageCreate", this.onMessage.bind(this)); 69 | } 70 | 71 | public onBot() { 72 | console.log("API URL Interpreter extension loaded."); 73 | } 74 | 75 | private onMessage(message: Discord.Message) { 76 | if (message.author.bot) return; 77 | 78 | // const urls = message.content.match(ApiUrlInterpreter.API_CALL_REGEX); 79 | const urls = XRegExp.match(message.content, ApiUrlInterpreter.API_CALL_REGEX); 80 | if (!urls) return; 81 | for (const url of urls) { 82 | this.testRiotApiUrl(url, message); 83 | } 84 | } 85 | 86 | private async testRiotApiUrl(url: string, message: Discord.Message) { 87 | // The type must be `any` because the regex contains named groups 88 | const urlMatch: any = XRegExp.exec(url, ApiUrlInterpreter.API_CALL_REGEX); 89 | 90 | /** Indicates if there is a problem with this URL that guarantees the call will fail. */ 91 | let fatalError = false; 92 | const mistakes = []; 93 | 94 | // Check if the URL is using HTTPS 95 | // `urlMatch.https` will be empty (falsy) if HTTP is being used 96 | if (!urlMatch.https) { 97 | mistakes.push(`- This URL is using HTTP. All API calls must be made over HTTPS.`); 98 | fatalError = true; 99 | } 100 | 101 | // Check if the platform is valid 102 | const platformId: string = urlMatch.platform; 103 | if (!this.apiSchema.platforms.includes(platformId.toLowerCase())) { 104 | // Get closest platform if incorrect 105 | const closestPlatform = this.apiSchema.getClosestPlatform(platformId); 106 | mistakes.push(`- The platform \`${platformId}\` is invalid, did you mean: \`${closestPlatform}\`? Expected one of the following values: \`${this.apiSchema.platforms.join(", ")}\``); 107 | fatalError = true; 108 | } 109 | 110 | // Check if the path is valid and validate parameters 111 | let path: Path | null = null; 112 | for (const testPath of this.apiSchema.paths) { 113 | // The type must be `any` because the regex contains named groups 114 | const pathMatch: any = XRegExp.exec(urlMatch.path, testPath.regex); 115 | if (!pathMatch || pathMatch.length === 0) { 116 | continue; 117 | } 118 | path = testPath; 119 | 120 | // Check if path parameters are valid 121 | for (const param of testPath.pathParameters.values()) { 122 | const paramValue: string = pathMatch[param.name]; 123 | if (paramValue) { 124 | if (!param.type.isValidValue(paramValue)) { 125 | mistakes.push(`- The value \`${paramValue}\` is not applicable for \`${param.name}\`: the value must be ${param.type.description}.`); 126 | fatalError = true; 127 | } 128 | } 129 | // There's no need to check if path params are missing since the regex wouldn't have matched if they were. 130 | } 131 | 132 | const queryParams: Map = new Map(); 133 | if (urlMatch.query) { 134 | for (const pair of urlMatch.query.split("&")) { 135 | const [key, value] = pair.split("="); 136 | // If a key is specified multiple times, it means the value is a set 137 | if (queryParams.has(key)) { 138 | // Turn the parameter into a set if it isn't already one 139 | if (!Array.isArray(queryParams.get(key))) { 140 | queryParams.set(key, [queryParams.get(key) as string]); 141 | } 142 | (queryParams.get(key) as string[]).push(value); 143 | } else { 144 | queryParams.set(key, value); 145 | } 146 | } 147 | } 148 | 149 | // Check if specified query parameters are of the correct type, and all required parameters are specified 150 | for (const param of path.queryParameters.values()) { 151 | const paramValue = queryParams.get(param.name); 152 | if (paramValue) { 153 | if (!param.type.isValidValue(paramValue)) { 154 | mistakes.push(`- The value \`${paramValue}\` is not applicable for \`${param.name}\`: the value must be ${param.type.description}.`); 155 | } 156 | } else if (param.required) { 157 | mistakes.push(`- The query parameter \`${param.name}\` is required but was not specified.`); 158 | } 159 | } 160 | 161 | const validParams = Array.from(path.queryParameters.keys()); 162 | // Check if any specified query parameters don't do exist for this method 163 | for (const key of queryParams.keys()) { 164 | // The `api_key` parameter is always valid 165 | if (key === "api_key") continue; 166 | if (!validParams.includes(key)) { 167 | mistakes.push(`- The specified query parameter \`${key}\` does not exist for this method. Although this shouldn't stop the request from working, it means that the request likely won't do what you want it to do.`); 168 | // This is not a fatal error 169 | } 170 | } 171 | 172 | break; 173 | } 174 | if (!path) { 175 | mistakes.push(`- This URL does not appear to be using a valid endpoint`); 176 | fatalError = true; 177 | } 178 | 179 | if (mistakes.length !== 0) { 180 | const replyMessageContent = `The API call ${url} seems to have ${mistakes.length} mistake${mistakes.length !== 1 ? "s" : ""}:\n` + mistakes.join("\n"); 181 | message.channel.send(replyMessageContent); 182 | } 183 | if (fatalError) return; 184 | 185 | if (!path!.canUse) { 186 | message.channel.send(`I cannot make an API call to ${url} for you (likely because I don't have access to this endpoint)`); 187 | return; 188 | } 189 | 190 | const replyMessages = await message.channel.send(`Making a request to ${path!.name}`); 191 | const replyMessage = Array.isArray(replyMessages) ? replyMessages[0] : replyMessages; 192 | 193 | await this.makeRequest(path!, platformId, url, replyMessage); 194 | } 195 | 196 | private async makeRequest(path: Path, region: string, url: string, message: Discord.Message) { 197 | 198 | const currentTime = Date.now(); 199 | if (currentTime < this.applicationRatelimitLastTime) { 200 | const timeDiff = prettyMs(this.applicationRatelimitLastTime - currentTime, { verbose: true }); 201 | message.edit(`We are ratelimited on our application, please wait ${timeDiff}.`); 202 | return; 203 | } 204 | 205 | const servicedMethodName = `${region}:${path.name}`; 206 | if (this.methodRatelimitLastTime[servicedMethodName] && currentTime < this.methodRatelimitLastTime[servicedMethodName]) { 207 | const timeDiff = prettyMs(this.methodRatelimitLastTime[servicedMethodName] - currentTime, { verbose: true }); 208 | message.edit(`We are ratelimited by the method (${servicedMethodName}), please wait ${timeDiff}.`); 209 | return; 210 | } 211 | 212 | try { 213 | const resp = await fetch(url, this.fetchSettings); 214 | this.handleResponse(resp, message, url, servicedMethodName); 215 | } catch (e) { 216 | console.error(`Error handling the API call: ${e.message}`); 217 | } 218 | } 219 | 220 | private async handleResponse(resp: Response, message: Discord.Message, url: string, servicedMethodName: string) { 221 | if (resp === null) { 222 | console.warn(`Not handling ratelimits due to missing response.`); 223 | return; 224 | } 225 | 226 | // Set start times 227 | if (this.applicationStartTime === 0) { 228 | this.applicationStartTime = Date.now(); 229 | } 230 | if (!this.methodStartTime[servicedMethodName]) { 231 | this.methodStartTime[servicedMethodName] = Date.now(); 232 | } 233 | 234 | // Update application ratelimit 235 | { 236 | const countHeader = resp.headers.get("x-app-rate-limit-count"); 237 | const limitHeader = resp.headers.get("x-app-rate-limit"); 238 | 239 | if (countHeader && limitHeader) { 240 | 241 | const appCountStrings = countHeader.split(","); 242 | const appLimitStrings = limitHeader.split(","); 243 | const appResult = this.handleRatelimit("application", servicedMethodName, this.applicationStartTime, appCountStrings, appLimitStrings); 244 | 245 | if (appResult) { 246 | this.applicationRatelimitLastTime = appResult.rateLimit; 247 | if (appResult.startTime !== null) this.applicationStartTime = appResult.startTime; 248 | } 249 | } 250 | } 251 | 252 | // Update method ratelimit 253 | { 254 | const countHeader = resp.headers.get("x-method-rate-limit-count"); 255 | const limitHeader = resp.headers.get("x-method-rate-limit"); 256 | 257 | if (countHeader && limitHeader) { 258 | 259 | const methodCountStrings = countHeader.split(","); 260 | const methodLimitStrings = limitHeader.split(","); 261 | const methodResult = this.handleRatelimit("method", servicedMethodName, this.methodStartTime[servicedMethodName], methodCountStrings, methodLimitStrings); 262 | 263 | if (methodResult) { 264 | this.methodRatelimitLastTime[servicedMethodName] = methodResult.rateLimit; 265 | if (methodResult.startTime !== null) this.methodStartTime[servicedMethodName] = methodResult.startTime; 266 | } 267 | } 268 | } 269 | 270 | if (resp.status !== 200) { 271 | message.edit(`The Riot API responded to ${url} with ${resp.status} ${resp.statusText}.`); 272 | return; 273 | } 274 | 275 | try { 276 | const curIterator = this.iterator; 277 | const fileName = `${curIterator}.json`; 278 | 279 | const json = { 280 | url, 281 | method: servicedMethodName, 282 | result: await resp.json(), 283 | }; 284 | 285 | const buffer = Buffer.from(JSON.stringify(json.result, null, 2), 'utf-8'); 286 | const attachment = new Discord.AttachmentBuilder(buffer, { name: 'response.json' }); 287 | await message.channel.send({ content: `Response for ${url}:`, files: [attachment] }); 288 | 289 | this.iterator = (this.iterator % 50) + 1; 290 | } catch (e) { 291 | message.edit("Eh, something went wrong trying to upload this :(").catch((reason) => { 292 | console.error(`Error occurred trying to edit the message when the upload failed, reason: ${reason}\nreason for failed upload: ${e}`); 293 | }); 294 | console.error(`Error trying to save the result of an API call: ${e.message}`); 295 | } 296 | } 297 | 298 | private handleRatelimit(ratelimitType: string, methodName: string, startTime: number, countStrings: string[], limitStrings: string[]): RatelimitResult | null { 299 | 300 | let found = false; 301 | let longestSpreadTime = 0; 302 | let resultStartTime: number | null = 0; 303 | let resultRatelimit = 0; 304 | 305 | for (const cString of countStrings) { 306 | const splitCount = cString.split(":"); 307 | const count = parseInt(splitCount[0].trim(), 10); 308 | const time = parseInt(splitCount[1].trim(), 10); 309 | 310 | const limit = limitStrings.find((e) => e.indexOf(`:${time}`) !== -1); 311 | if (!limit) { 312 | console.warn(`Unable to find limits for the ${ratelimitType} ratelimit with time being ${time} on a result of ${methodName}.`); 313 | continue; 314 | } 315 | 316 | const splitLimit = limit.split(":"); 317 | const max = parseInt(splitLimit[0].trim(), 10); 318 | 319 | if (count + 1 >= max) { 320 | console.warn(`Hit ${ratelimitType} ratelimit with ${methodName}.`); 321 | return new RatelimitResult(startTime + time * 1000 + ApiUrlInterpreter.ratelimitErrorMs, startTime + time * 1000 + ApiUrlInterpreter.ratelimitErrorMs); 322 | } 323 | 324 | const spreadTime = 1 / (max / time); // Find the slowest ratelimit. 325 | if (spreadTime > longestSpreadTime) { 326 | 327 | found = true; 328 | longestSpreadTime = spreadTime; 329 | 330 | const delay = spreadTime * 1000 + ApiUrlInterpreter.ratelimitErrorMs; 331 | resultRatelimit = Date.now() + delay; 332 | if (count <= 1) resultStartTime = Date.now(); 333 | else resultStartTime = null; 334 | } 335 | } 336 | 337 | if (found === false) return null; 338 | return new RatelimitResult(resultRatelimit, resultStartTime); 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /src/AutoReact.ts: -------------------------------------------------------------------------------- 1 | import { fileBackedObject } from "./FileBackedObject"; 2 | import { SharedSettings } from "./SharedSettings"; 3 | import InteractionManager, { InteractionCommandData } from "./InteractionManager"; 4 | import Discord = require("discord.js"); 5 | 6 | export default class AutoReact { 7 | private thinkingUsers: string[]; 8 | private ignoreUsers: string[]; 9 | private thinkingEmojis: Discord.Emoji[] = []; 10 | private greetingEmoji: Discord.Emoji; 11 | private sharedSettings: SharedSettings; 12 | private bot: Discord.Client; 13 | private interactionManager; 14 | 15 | constructor(bot: Discord.Client, interactionManager: InteractionManager, sharedSettings: SharedSettings, userFile: string, ignoreFile: string) { 16 | console.log("Requested Thinking extension.."); 17 | 18 | this.sharedSettings = sharedSettings; 19 | this.bot = bot; 20 | this.interactionManager = interactionManager; 21 | 22 | this.thinkingUsers = fileBackedObject(userFile); 23 | console.log("Successfully loaded original thinking user file."); 24 | 25 | this.ignoreUsers = fileBackedObject(ignoreFile); 26 | console.log("Successfully loaded ignore reaction file."); 27 | 28 | this.bot.on("ready", this.onConnect.bind(this)); 29 | this.bot.on("messageCreate", this.onMessage.bind(this)); 30 | 31 | this.registerInteractionCommands(); 32 | } 33 | public registerInteractionCommands() { 34 | const commands : InteractionCommandData[] = []; 35 | 36 | const toggleReactionCommand = new Discord.SlashCommandBuilder() 37 | .setName("toggle_react") 38 | .setDescription("Toggles reacting to greeting messages") 39 | .toJSON(); 40 | commands.push({body: toggleReactionCommand, adminOnly: false, handler: this.onInteraction.bind(this)}); 41 | 42 | const toggleThinkingCommand = new Discord.SlashCommandBuilder() 43 | .setName("toggle_thinking") 44 | .setDescription("Toggles adding thinking reacts to your message").toJSON(); 45 | commands.push({body: toggleThinkingCommand, adminOnly: false, handler: this.onInteraction.bind(this)}); 46 | 47 | const refreshThinkingCommand = new Discord.SlashCommandBuilder() 48 | .setName("refresh_thinking") 49 | .setDescription("Refresh thinking emojis").toJSON(); 50 | commands.push({body: refreshThinkingCommand, adminOnly: true, handler: this.onInteraction.bind(this)}); 51 | 52 | commands.forEach(cmd => this.interactionManager.addSlashCommand(cmd.body, true, false, cmd.handler)) 53 | } 54 | public onConnect() { 55 | this.refreshThinkingEmojis(); 56 | 57 | const emoji = this.bot.emojis.cache.get(this.sharedSettings.autoReact.emoji); 58 | if (emoji instanceof Discord.Emoji) { 59 | this.greetingEmoji = emoji; 60 | this.bot.on("messageCreate", this.onGreeting.bind(this)); 61 | console.log("Bot has succesfully loaded greetings."); 62 | } else { 63 | console.error(`Unable to find the greeting emoji '${this.sharedSettings.autoReact.emoji}'.`); 64 | } 65 | } 66 | 67 | public onToggleDefault(message: Discord.Message, isAdmin: boolean, command: string, args: string[]) { 68 | this.onToggleThinkingRequest(message, message.author.id); 69 | } 70 | 71 | public onRefreshThinking(message: Discord.Message, isAdmin: boolean, command: string, args: string[]) { 72 | message.reply("reloading thinking emojis."); 73 | this.refreshThinkingEmojis(); 74 | } 75 | 76 | public onToggleReact(message: Discord.Message, isAdmin: boolean, command: string, args: string[]) { 77 | this.onToggleReactRequest(message, message.author.id); 78 | } 79 | public onInteraction(interaction: Discord.CommandInteraction, isAdmin?: false) { 80 | switch (interaction.commandName) { 81 | case "refresh_thinking": 82 | if (!isAdmin) return interaction.reply({content: "You don't have permission to use this command", ephemeral: true}) 83 | this.refreshThinkingEmojis(); 84 | interaction.reply({content: "Refreshed thinking emojis", ephemeral: true}); 85 | break; 86 | case "toggle_thinking": 87 | this.onToggleThinkingRequest(interaction, interaction.user.id) 88 | break; 89 | case "toggle_react": 90 | this.onToggleReactRequest(interaction, interaction.user.id); 91 | break; 92 | } 93 | } 94 | private onMessage(message: Discord.Message) { 95 | // Only react to people not on list 96 | if (this.ignoreUsers.indexOf(message.author.id) !== -1) return; 97 | 98 | if (!message.content.includes("🤔")) { 99 | 100 | // If it's not the regular thinking emoji, maybe it's one of our custom ones? 101 | const emojiIds = /<:(.*?):([0-9]+)>/g.exec(message.content); 102 | if (!emojiIds) return; 103 | 104 | let found = false; 105 | for (let i = 2; i < emojiIds.length; i += 3) { 106 | const emojiFound = emojiIds[i]; 107 | if (!this.thinkingEmojis.some((e: Discord.Emoji) => e.id === emojiFound)) { 108 | continue; 109 | } 110 | 111 | found = true; 112 | break; 113 | } 114 | 115 | if (!found) return; 116 | } 117 | 118 | // If original thinking user 119 | if (this.thinkingUsers.indexOf(message.author.id) !== -1) { 120 | message.react("🤔").catch(() => {}); 121 | return; 122 | } 123 | 124 | // Otherwise use our custom ones 125 | const emoji = message.guild!.emojis.cache.filter((x: Discord.Emoji) => x.name !== null && this.isThinkingEmojiName(x.name)).random(); 126 | if (emoji) { 127 | message.react(emoji).catch(() => {}); 128 | return; 129 | } 130 | } 131 | 132 | private onToggleReactRequest(message: Discord.Message | Discord.CommandInteraction, authorId: string) { 133 | 134 | const reactIndex = this.ignoreUsers.indexOf(authorId); 135 | const resp = (message instanceof Discord.CommandInteraction) ? {content: "", ephemeral: true} : {content: ""}; 136 | 137 | // Add 138 | if (reactIndex === -1) { 139 | this.ignoreUsers.push(authorId); 140 | resp.content = "I will no longer react to your messages"; 141 | message.reply(resp); 142 | return; 143 | } 144 | 145 | // Remove 146 | this.ignoreUsers.splice(reactIndex, 1); 147 | resp.content = "I will now react to your messages" 148 | message.reply(resp); 149 | } 150 | 151 | private onToggleThinkingRequest(message: Discord.Message | Discord.CommandInteraction, authorId: string) { 152 | 153 | const thinkIndex = this.thinkingUsers.indexOf(authorId); 154 | const resp = (message instanceof Discord.CommandInteraction) ? {content: "", ephemeral: true} : {content: ""}; 155 | // Add 156 | if (thinkIndex === -1) { 157 | this.thinkingUsers.push(authorId); 158 | resp.content = "I will now only reply with default thinking emojis."; 159 | message.reply(resp); 160 | return; 161 | } 162 | 163 | // Remove 164 | this.thinkingUsers.splice(thinkIndex, 1); 165 | resp.content = "I will no longer only reply with default thinking emojis." 166 | message.reply(resp); 167 | } 168 | 169 | private onGreeting(message: Discord.Message) { 170 | 171 | if (message.author.bot) return; 172 | const greeting = message.content.toLowerCase(); 173 | 174 | const words = [ 175 | // Russian 176 | "privet", "preevyet", "privyet", 177 | "zdrastvooyte", "dobraye ootro", 178 | "привет", 179 | // ASBO 180 | "oi", "ey", 181 | // English 182 | "hello", "hi", "hey", 183 | "good morning", "goodmorning", 184 | "good evening", "goodevening", 185 | "good night", "goodnight", 186 | "good day", "goodday", 187 | "top of the morning", 188 | // French 189 | "bonjour", "salut", "coucou", 190 | // Spanish 191 | "buenos días", "buenos dias", 192 | "buenas tardes", "buenas noches", 193 | "muy buenos", "hola", "saludos", 194 | // Portuguese 195 | "ola", "olá", "boa tarde", "bom dia", "boa noite", 196 | // Hindi 197 | "namaste", "suprabhātam", 198 | "śubha sandhyā", "śubha rātri", 199 | // Bengali 200 | "nomoskar", "shubho shokal", 201 | "shubho oporanno", "shubho shondha", 202 | // Japanese 203 | "おはよう ございます", "こんにちは", 204 | "ohayou gozaimasu", "konichiwa", 205 | "ohayō gozaimasu", "konnichiwa", 206 | "こんばんは", "おやすみ なさい", 207 | "konbanwa", "oyasumi nasai", 208 | // Dutch 209 | "hallo", "hoi", "hey", 210 | "goede morgen", "goedemorgen", 211 | "goedenavond", 212 | "goedenacht", "goede nacht", 213 | "goedendag", "houdoe", 214 | // Montenegrian 215 | "zdravo", "ćao", "hej", 216 | "dobro jutro", "jutro", 217 | "dobro veče", "laku noć", 218 | "dobar dan", "dobar dan", 219 | // indonesian 220 | "selamat pagi", 221 | ]; 222 | 223 | const endChars = [ 224 | " ", "!", ",", ".", 225 | ]; 226 | 227 | // Determine if the greeting is just the greeting, or ends in punctuation and not "his" 228 | const shouldReact = words.some(x => { 229 | if (greeting === x) { return true; } 230 | 231 | const endChar = greeting.charAt(x.length); 232 | return greeting.startsWith(x) && endChars.findIndex(y => y === endChar) !== -1; 233 | }); 234 | 235 | if (!shouldReact) { 236 | return; 237 | } 238 | 239 | if (this.ignoreUsers.indexOf(message.author.id) !== -1) { 240 | return; 241 | } 242 | 243 | message.react(`${this.greetingEmoji.id}`).catch(() => {}); 244 | } 245 | 246 | private isThinkingEmojiName(emojiName: string) { 247 | return emojiName.toLowerCase().includes("think") || emojiName.toLowerCase().includes("thonk"); 248 | } 249 | 250 | private refreshThinkingEmojis() { 251 | this.thinkingEmojis = Array.from(this.bot.emojis.cache.filter((x: Discord.Emoji) => x.name != null && this.isThinkingEmojiName(x.name)).values()) 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /src/Botty.ts: -------------------------------------------------------------------------------- 1 | import { fileBackedObject } from "./FileBackedObject"; 2 | import { PersonalSettings, SharedSettings } from "./SharedSettings"; 3 | import { levenshteinDistance } from "./LevenshteinDistance"; 4 | 5 | import Discord = require("discord.js"); 6 | import { GatewayIntentBits, GuildMember, Partials } from "discord.js"; 7 | 8 | import { exec } from "child_process"; 9 | 10 | export interface BottySettings { 11 | Discord: { 12 | Key: string; 13 | Owner: number; 14 | }; 15 | } 16 | 17 | export default class Botty { 18 | public readonly client = new Discord.Client({ intents: [ 19 | GatewayIntentBits.DirectMessages, 20 | GatewayIntentBits.DirectMessageReactions, 21 | GatewayIntentBits.GuildMembers, 22 | GatewayIntentBits.GuildMessages, 23 | GatewayIntentBits.GuildMessageReactions, 24 | GatewayIntentBits.GuildModeration, 25 | GatewayIntentBits.Guilds, 26 | GatewayIntentBits.MessageContent, 27 | ], partials: [Partials.Channel]}); 28 | private personalSettings: PersonalSettings; 29 | private sharedSettings: SharedSettings; 30 | 31 | constructor(sharedSettings: SharedSettings) { 32 | this.personalSettings = sharedSettings.botty; 33 | this.sharedSettings = sharedSettings; 34 | console.log("Successfully loaded bot settings."); 35 | this.client.setMaxListeners(25); 36 | 37 | this.client 38 | .on("error", console.error) 39 | .on("warn", console.warn) 40 | // .on("debug", console.log) 41 | .on("ready", this.onConnect.bind(this)); 42 | 43 | this.initListeners(); 44 | } 45 | 46 | public start() { 47 | return this.client.login(this.personalSettings.discord.key); 48 | } 49 | 50 | public async onRestart(message: Discord.Message, isAdmin: boolean, command: string, args: string[]) { 51 | 52 | if (!isAdmin) return; 53 | 54 | await message.channel.send("Restarting..."); 55 | exec("pm2 restart " + this.personalSettings.appName, (err, stdout, stderr) => { 56 | if (err) { 57 | console.error(err.message); 58 | return; 59 | } 60 | 61 | if (stdout.length !== 0) console.log(`onRestart: ${stdout}`); 62 | if (stderr.length !== 0) console.error(`onRestart: ${stderr}`); 63 | }); 64 | } 65 | 66 | private initListeners() { 67 | this.client.on("guildMemberAdd", member => console.log(`${member.displayName}#${member.user?.discriminator} (${member.id}) joined the server.`)); 68 | this.client.on("guildMemberRemove", member => console.log(`${member.displayName}#${member.user?.discriminator} (${member.id}) left (or was removed) from the server.`)); 69 | 70 | this.client.on("guildBanAdd", (guildBan: Discord.GuildBan) => console.log(`${guildBan.user.username}#${guildBan.user.discriminator} (${guildBan.user.id}) has been banned from ${guildBan.guild.name}.`)); 71 | this.client.on("guildBanRemove", (guildBan: Discord.GuildBan) => console.log(`${guildBan.user.username}#${guildBan.user.discriminator} (${guildBan.user.id}) has been unbanned from ${guildBan.guild.name}.`)); 72 | 73 | this.client.on("messageDelete", (message: Discord.Message) => { 74 | 75 | if (message.author.bot) return; // Ignore bot in general 76 | if (message.channel.type === Discord.ChannelType.DM) return; // Don't output DMs 77 | 78 | console.log(`${message.author.username}'s (${message.author.id}) message in ${message.channel} was deleted. Contents: \n${message.cleanContent}\n`); 79 | }); 80 | 81 | this.client.on('voiceStateUpdate', (oldMember, newMember) => { 82 | let newUserChannel = newMember.channel; 83 | let oldUserChannel = oldMember.channel; 84 | 85 | let member = newMember.member || oldMember.member; 86 | 87 | if (newUserChannel) { 88 | console.log(`${member?.user.username}'s (${oldMember.id}) joined voice channel ${newUserChannel}\n`); 89 | } 90 | if (oldUserChannel) { 91 | console.log(`${member?.user.username}'s (${oldMember.id}) left voice channel ${oldUserChannel}\n`); 92 | } 93 | }); 94 | 95 | this.client.on("messageUpdate", (oldMessage: Discord.Message, newMessage: Discord.Message) => { 96 | 97 | if (levenshteinDistance(oldMessage.content, newMessage.content) === 0) return; // To prevent page turning and embed loading to appear in changelog 98 | if (oldMessage.author.bot) return; // Ignore bot in general 99 | if (oldMessage.channel.type === Discord.ChannelType.DM) return; // Don't output DMs 100 | 101 | console.log(`${oldMessage.author.username}'s message in ${oldMessage.channel} was changed from: \n${oldMessage.cleanContent}\n\nTo:\n${newMessage.cleanContent}`); 102 | }); 103 | 104 | this.client.on("guildMemberUpdate", (oldMember: GuildMember, newMember: GuildMember) => { 105 | 106 | if (oldMember.displayName !== newMember.displayName) { 107 | console.log(`${oldMember.displayName} changed his display name to ${newMember.displayName}.`); 108 | } 109 | 110 | if (oldMember.nickname !== newMember.nickname) { 111 | console.log(`${oldMember.nickname} changed his nickname to ${newMember.nickname}.`); 112 | } 113 | 114 | if (oldMember.user.discriminator !== newMember.user.discriminator) { 115 | console.log(`${oldMember.displayName} changed his discriminator from ${oldMember.user.discriminator} to ${newMember.user.discriminator}.`); 116 | } 117 | }); 118 | console.log("Initialised listeners."); 119 | } 120 | 121 | private onConnect() { 122 | console.log("Bot is logged in and ready."); 123 | 124 | const guild = this.client.guilds.cache.get(this.sharedSettings.server.guildId); 125 | if (!guild) { 126 | console.error(`Botty: Incorrect setting for the server: ${this.sharedSettings.server}`); 127 | 128 | const guilds = this.client.guilds.cache.map(g => ` - ${g.name} (${g.id})\n`); 129 | console.error(`The available guilds are:\n${guilds}`); 130 | return; 131 | } 132 | 133 | // Set correct nickname 134 | if (guild.members.me) { 135 | guild.members.me.setNickname(this.personalSettings.isProduction ? "Botty McBotface" : ""); 136 | } 137 | } 138 | 139 | } 140 | -------------------------------------------------------------------------------- /src/CategorisedMessage.ts: -------------------------------------------------------------------------------- 1 | import Discord = require("discord.js"); 2 | 3 | export default class CategorisedMessage { 4 | 5 | private categoryList: { [emoji: string]: Discord.EmbedBuilder }; 6 | private currentPage: Discord.EmbedBuilder; 7 | 8 | constructor(messages: { [emoji: string]: Discord.EmbedBuilder }) { 9 | this.categoryList = messages; 10 | 11 | for (const page in messages) { 12 | this.currentPage = messages[page]; 13 | break; 14 | } 15 | } 16 | 17 | public setPage(emoji: Discord.Emoji): Discord.EmbedBuilder { 18 | this.currentPage = this.categoryList[emoji.identifier]; 19 | 20 | return this.currentPage; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/CommandController.ts: -------------------------------------------------------------------------------- 1 | import Discord = require("discord.js"); 2 | import { fileBackedObject } from "./FileBackedObject"; 3 | import { SharedSettings } from "./SharedSettings"; 4 | import url = require("url"); 5 | import fs = require("fs-extra"); 6 | import fetch from "node-fetch"; 7 | 8 | type SingleCommand = (message: Discord.Message, isAdmin: boolean, command: string, args: string[], separators: string[]) => void; 9 | 10 | export interface CommandHolder { 11 | identifier: string; 12 | command: Command; 13 | handler: SingleCommand; 14 | prefix: string; 15 | cooldown: number; 16 | lastUsed: number; 17 | } 18 | 19 | enum CommandStatus { 20 | ENABLED = 1, DISABLED = 0, 21 | } 22 | 23 | export interface Command { 24 | aliases: string[]; 25 | description: string; 26 | prefix: string; 27 | admin: boolean; 28 | cooldown: number; 29 | } 30 | 31 | export interface CommandList { 32 | controller: { 33 | toggle: Command; 34 | help: Command; 35 | }; 36 | gamedata: { 37 | lookup: Command; 38 | }; 39 | admin: { 40 | unmute: Command; 41 | mute: Command; 42 | ticket: Command; 43 | ban: Command; 44 | kick: Command; 45 | }; 46 | esports: { 47 | date: Command; 48 | pickem: Command; 49 | }; 50 | games: { 51 | ttt: Command; 52 | }; 53 | botty: { 54 | restart: Command; 55 | }; 56 | apiSchema: { 57 | updateSchema: Command; 58 | }; 59 | keyFinder: Command; 60 | welcome: Command; 61 | autoReact: { 62 | toggle_default_thinking: Command; 63 | toggle_react: Command; 64 | refresh_thinking: Command; 65 | }; 66 | info: { 67 | all: Command, 68 | note: Command, 69 | }; 70 | riotApiLibraries: Command; 71 | apiStatus: Command; 72 | endpointManager: { 73 | endpoint: Command; 74 | endpoints: Command; 75 | }; 76 | } 77 | 78 | export default class CommandController { 79 | 80 | private sharedSettings: SharedSettings; 81 | private commands: CommandHolder[] = []; 82 | private commandStatuses: { [commandName: string]: CommandStatus } = {}; 83 | private client: Discord.Client; 84 | 85 | constructor(bot: Discord.Client, sharedSettings: SharedSettings, commandData: string) { 86 | this.sharedSettings = sharedSettings; 87 | this.client = bot; 88 | 89 | this.commandStatuses = fileBackedObject(commandData, "www/" + commandData); 90 | 91 | bot.on("messageCreate", this.handleCommands.bind(this)); 92 | } 93 | 94 | public onToggle(message: Discord.Message, isAdmin: boolean, command: string, args: string[]) { 95 | if (args.length !== 1) return; 96 | 97 | const filtered = this.commands.filter(handler => handler.command.aliases.some(alias => handler.prefix + alias === args[0])); 98 | if (filtered.length === 0) { 99 | message.channel.send(`No command with the name ${args[0]} was found.`); 100 | return; 101 | } 102 | 103 | for (const handler of filtered) { 104 | this.commandStatuses[handler.identifier] = (this.getStatus(handler) === CommandStatus.DISABLED ? CommandStatus.ENABLED : CommandStatus.DISABLED); 105 | message.channel.send(`${handler.prefix + handler.command.aliases.join("/")} is now ${this.getStatus(handler) === CommandStatus.ENABLED ? "enabled" : "disabled"}.`); 106 | } 107 | } 108 | 109 | public getHelp(isAdmin: boolean = false): Discord.EmbedBuilder[] { 110 | const toString = (holder: CommandHolder) => { 111 | 112 | let title = ""; 113 | let desc = ""; 114 | 115 | if (this.getStatus(holder) === CommandStatus.DISABLED) { 116 | title += "~~"; 117 | } 118 | 119 | title += `\`${holder.prefix}${holder.command.aliases}\``; 120 | 121 | if (this.getStatus(holder) === CommandStatus.DISABLED) { 122 | title += "~~"; 123 | } 124 | 125 | desc += `${holder.command.description}`; 126 | if (this.getStatus(holder) === CommandStatus.DISABLED) { 127 | desc += " (command is disabled)"; 128 | } 129 | 130 | return { title, desc }; 131 | }; 132 | 133 | const mapped = this.commands 134 | // ignore "*" commands 135 | .filter(holder => holder.command.aliases.some(a => a !== "*")) 136 | // hide admin commands if not admin 137 | .filter(holder => isAdmin || !holder.command.admin) 138 | .map(holder => toString(holder)) 139 | .sort((a, b) => a.title.localeCompare(b.title)); 140 | 141 | const data: Discord.EmbedBuilder[] = []; 142 | let pageIndex = 0; 143 | let embed: Discord.EmbedBuilder; 144 | 145 | for (let i = 0; i < mapped.length; i++) { 146 | // rich embeds have a 25 field limit 147 | if (i % 25 === 0) { 148 | embed = new Discord.EmbedBuilder({ title: `Commands (page ${++pageIndex})` }); 149 | data.push(embed); 150 | } 151 | 152 | embed!.addFields({name: mapped[i].title, value: mapped[i].desc}); 153 | } 154 | return data; 155 | } 156 | 157 | public onHelp(message: Discord.Message, isAdmin: boolean, command: string, args: string[]) { 158 | 159 | if (args[0] !== "here") { 160 | message.channel.send(`An introduction to Botty can be found here: <${this.sharedSettings.botty.webServer.relativeLiveLocation}>\nYou can find all commands to use here: <${url.resolve(this.sharedSettings.botty.webServer.relativeLiveLocation, "commands")}>`); 161 | return; 162 | } 163 | 164 | const data = this.getHelp(isAdmin); 165 | data.forEach(embed => message.channel.send({embeds: [embed] })); 166 | } 167 | 168 | public registerCommand(newCommand: Command, commandHandler: SingleCommand) { 169 | this.commands.push({ 170 | identifier: commandHandler.name, 171 | command: { ...newCommand, aliases: newCommand.aliases.map(i => i.toLowerCase()) }, 172 | cooldown: newCommand.cooldown || 0, 173 | handler: commandHandler, 174 | prefix: newCommand.prefix || this.sharedSettings.commands.default_prefix, 175 | lastUsed: 0, 176 | }); 177 | } 178 | 179 | private handleCommands(message: Discord.Message) { 180 | if (message.author.bot) return; 181 | 182 | const messageContent = message.content.replace(/(\s){2,}/g, "$1"); 183 | const parts = messageContent.split(/\s/g); 184 | 185 | const prefix = parts[0][0]; 186 | const command = parts[0].substr(1).toLowerCase(); 187 | const isAdmin = !!(message.member && this.sharedSettings.commands.adminRoles.some(x => message.member!.roles.cache.has(x))); 188 | 189 | // Collect the separators 190 | const separators: string[] = []; 191 | let partSize = 0; 192 | for (let i = 0; i < parts.length - 1; i++) { 193 | partSize += parts[i].length; // Get the char at the end of the word 194 | separators.push(messageContent.charAt(partSize)); 195 | partSize++; // Make sure you add the length of the separator too 196 | } 197 | 198 | this.commands.forEach(holder => { 199 | 200 | if (holder.prefix !== prefix) return; 201 | if (!isAdmin) { 202 | if (this.getStatus(holder) === CommandStatus.DISABLED) return; 203 | if (holder.command.admin) return; 204 | } 205 | 206 | // handlers that register the "*" command will get all commands with that prefix (unless they already have gotten it once) 207 | 208 | const args = parts.slice(1); 209 | if (holder.command.aliases.some(x => x === command)) { 210 | if (!this.checkCooldown(holder, message, isAdmin)) return; 211 | holder.handler.call(null, message, isAdmin, command, args, separators); 212 | } else if (holder.command.aliases.some(x => x === "*")) { 213 | if (!this.checkCooldown(holder, message, isAdmin)) return; 214 | holder.handler.call(null, message, isAdmin, "*", Array().concat(command, args)); 215 | } 216 | }); 217 | } 218 | 219 | private checkCooldown(holder: CommandHolder, message: Discord.Message, isAdmin: boolean): boolean { 220 | if (!holder.cooldown) return true; 221 | if (isAdmin) return true; 222 | 223 | const last = holder.lastUsed; 224 | const wait = holder.cooldown; 225 | const now = Date.now(); 226 | const remaining = last + wait - now; 227 | 228 | if (remaining > 0) { 229 | message.channel.send(`This command is currently on cooldown. (${Math.floor(remaining / 1000)} seconds remaining)`); 230 | return false; 231 | } 232 | 233 | holder.lastUsed = now; 234 | return true; 235 | } 236 | 237 | private getStatus(holder: CommandHolder): CommandStatus { 238 | return this.commandStatuses[holder.identifier]; 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/ESports.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import Discord = require("discord.js"); 3 | import { SharedSettings } from "./SharedSettings"; 4 | import { clearTimeout, setTimeout } from "timers"; 5 | import * as CheerioAPI from "cheerio"; 6 | import * as momentjs from "moment"; 7 | import InteractionManager from "./InteractionManager"; 8 | 9 | interface ESportsAPIReturnData { 10 | resultsHtml: string; 11 | fixturesHtml: string; 12 | resultsMonths: string; 13 | fixturesMonths: string; 14 | } 15 | 16 | interface ESportsLeagueSchedule { 17 | league: string; 18 | url: string | undefined; 19 | time: string; 20 | teamA: string; 21 | teamB: string; 22 | } 23 | 24 | interface EsportsAPILeagueResponseEntry { 25 | id: string; 26 | slug: string; 27 | name: string; 28 | region: string; 29 | image: string; 30 | priority: number; 31 | } 32 | 33 | interface EsportsAPILeagueResponse { 34 | data: { 35 | leagues: EsportsAPILeagueResponseEntry[]; 36 | }; 37 | } 38 | 39 | interface EsportsAPIEventListItem { 40 | startTime: string; 41 | 42 | match: { 43 | teams: { 44 | code: string; 45 | image: string; 46 | }[]; 47 | }; 48 | 49 | league: { 50 | id: string; 51 | slug: string; 52 | name: string; 53 | }; 54 | } 55 | 56 | interface EsportsAPIEventListResponse { 57 | data: { 58 | esports: { 59 | events: EsportsAPIEventListItem[]; 60 | }; 61 | }; 62 | } 63 | 64 | export default class ESportsAPI { 65 | private bot: Discord.Client; 66 | private settings: SharedSettings; 67 | private esportsChannel: Discord.GuildBasedChannel | null = null; 68 | 69 | private schedule: Map> = new Map(); 70 | private postInfoTimeOut: NodeJS.Timer | null; 71 | private loadDataTimeOut: NodeJS.Timer | null; 72 | 73 | constructor(bot: Discord.Client, settings: SharedSettings, interactionManager?: InteractionManager) { 74 | this.bot = bot; 75 | this.settings = settings; 76 | 77 | bot.on("ready", async () => { 78 | 79 | const channel = this.settings.esports.printChannel; 80 | const guild = this.bot.guilds.cache.get(this.settings.server.guildId); 81 | this.esportsChannel = guild!.channels.cache.find((c) => c.name === channel && c.type === Discord.ChannelType.GuildText) || null; 82 | if (this.esportsChannel == null) { 83 | if (this.settings.botty.isProduction) { 84 | console.error("Esports API ran into an error: We don't have an esports channel but we're on production!"); 85 | } 86 | else { 87 | this.esportsChannel = await guild!.channels.create({name: channel, type: Discord.ChannelType.GuildText }); 88 | } 89 | } 90 | try { 91 | await this.loadData(); 92 | //this.postInfo(true); 93 | } 94 | catch (e) { 95 | console.error(e); 96 | } 97 | }); 98 | const command = new Discord.SlashCommandBuilder() 99 | .setName("esports") 100 | .setDescription("Displays esports matches") 101 | .addStringOption(opt => opt.setName("date") 102 | .setDescription("Today/Tomorrow or future date in mm/dd or yyyy/mm/dd format") 103 | .setRequired(false) 104 | ).toJSON(); 105 | interactionManager?.addSlashCommand(command, true, false, this.onInteraction.bind(this)); 106 | } 107 | 108 | public async onCheckNext(message: Discord.Message, isAdmin: boolean, command: string, args: string[]) { 109 | 110 | if (message.guild && this.esportsChannel && message.channel.id !== this.esportsChannel.id) { 111 | message.channel.send(`To avoid spoilers, this command is restricted to ${this.esportsChannel.name}.`); 112 | return; 113 | } 114 | 115 | if (args.length === 0) args = ["today"]; 116 | if (args.length !== 1) return; 117 | 118 | let date = this.strToDate(args[0]); 119 | if (date == false) return message.channel.send("The date you specified didn't match the format needed. (MM/DD or YYYY/MM/DD)"); 120 | 121 | date = date.split(" ").map(part => part.padStart(2, "0")).join(" "); 122 | const jsDate = new Date(date); 123 | const now = new Date(); 124 | now.setHours(0, 0, 0, 0); 125 | if (jsDate < now) { 126 | message.channel.send("The date has to be in the future."); 127 | return; 128 | } 129 | 130 | const schedule = this.schedule.get(date); 131 | this.sendPrintout(message.channel as Discord.TextChannel, schedule, date, false); 132 | } 133 | 134 | public onInteraction(interaction: Discord.CommandInteraction) { 135 | let ephemeral = true; 136 | let date: string | false; 137 | let dateOption = interaction.options.get("date")?.value as string || ""; 138 | if (interaction.guild && interaction.channelId == this.esportsChannel?.id) ephemeral = false; 139 | 140 | date = this.strToDate(dateOption); 141 | if (date == false) return interaction.reply({content: "The date you specified didn't match the format needed. (MM/DD or YYYY/MM/DD)", ephemeral: true}); 142 | 143 | const embed = this.getPrintout(this.schedule.get(date), date) as Discord.InteractionReplyOptions; 144 | embed.ephemeral = ephemeral; 145 | interaction.reply(embed); 146 | } 147 | 148 | private postInfo(isUpdateMessage: boolean = false) { 149 | if (!this.esportsChannel) { 150 | console.error(`Esports: Unable to find channel #${this.esportsChannel}`); 151 | return; 152 | } 153 | 154 | const tomorrow = new Date(); 155 | tomorrow.setDate(tomorrow.getDate() + 1); 156 | tomorrow.setHours(0); 157 | tomorrow.setMinutes(0); 158 | 159 | let tellDate = ""; 160 | 161 | // filter to only show new games (up-to one day in advance) 162 | const prints: Map = new Map(); 163 | for (const [dateKey, entries] of this.schedule.entries()) { 164 | if (new Date(dateKey) > tomorrow) break; 165 | 166 | tellDate = dateKey; 167 | for (const [league, entryList] of entries) { 168 | for (const item of entryList) { 169 | 170 | const time = momentjs(item.time, "YYYY MM DD HH:mm"); 171 | if (time.isBefore(new Date())) continue; 172 | 173 | if (!prints.get(league)) { 174 | prints.set(league, []); 175 | } 176 | 177 | prints.get(league)!.push(item); 178 | } 179 | } 180 | } 181 | 182 | this.sendPrintout(this.esportsChannel as Discord.TextChannel, prints, tellDate, isUpdateMessage); 183 | 184 | if (this.postInfoTimeOut) { 185 | clearTimeout(this.postInfoTimeOut); 186 | this.postInfoTimeOut = null; 187 | } 188 | //this.postInfoTimeOut = setTimeout(this.postInfo.bind(this, true), this.settings.esports.printToChannelTimeout); 189 | } 190 | 191 | private strToDate(strDate: string) { 192 | let date; 193 | const fullCheck = /\d{4}\/\d{1,2}\/\d{1,2}/; 194 | const curYearCheck = /\d{1,2}\/\d{1,2}/; 195 | 196 | if (strDate == "") strDate = "today"; 197 | 198 | const data = strDate.trim().split(/[\/ -]/g); 199 | // YYYY/MM/DD 200 | if (fullCheck.test(strDate)) { 201 | date = `${data[0]} ${parseInt(data[1], 10)} ${parseInt(data[2], 10)}`; 202 | } 203 | 204 | // MM/DD 205 | else if (curYearCheck.test(strDate)) { 206 | const currentYear = new Date().getFullYear(); 207 | date = `${currentYear} ${parseInt(data[0], 10)} ${parseInt(data[1], 10)}`; 208 | } 209 | 210 | else if (strDate.toLowerCase() === "today") { 211 | const today = new Date(); 212 | date = `${today.getFullYear()} ${today.getMonth() + 1} ${today.getDate()}`; 213 | } 214 | 215 | else if (strDate.toLowerCase() === "tomorrow") { 216 | const tomorrow = new Date(); 217 | tomorrow.setDate(tomorrow.getDate() + 1); 218 | date = `${tomorrow.getFullYear()} ${tomorrow.getMonth() + 1} ${tomorrow.getDate()}`; 219 | } 220 | // No match 221 | else return false; 222 | 223 | date = date.split(" ").map(d => d.padStart(2, "0")).join(" "); 224 | return date; 225 | } 226 | 227 | private getPrintout(data: Map | undefined, date: string) { 228 | date = (date.replace(/ /g, "/") || momentjs().format("YYYY/M/D")); 229 | 230 | if (!data || data.size === 0) { 231 | return {content: `No games played on ${date}`} as Discord.MessageCreateOptions; 232 | } 233 | 234 | const embed = new Discord.EmbedBuilder(); 235 | embed.setTitle(`Games being played ${date}:`); 236 | embed.setColor(0x9b311a); 237 | 238 | for (const [league, games] of data) { 239 | 240 | let output = ""; 241 | for (const game of games.slice(0, 10)) { 242 | const date = new Date(game.time); 243 | output += `${game.teamA} vs ${game.teamB}, (${game.time})\n`; 244 | } 245 | 246 | if (output.trim().length === 0) 247 | continue; 248 | 249 | embed.addFields({ 250 | name: league, 251 | value: output + `[More about ${league} here](${this.getUrlByLeague(games[0])})\n`, 252 | inline: false 253 | }); 254 | } 255 | return {embeds: [embed]} as Discord.MessageCreateOptions; 256 | } 257 | 258 | private sendPrintout(channel: Discord.TextChannel, data: Map | undefined, date: string, isUpdateMessage: boolean) { 259 | const payload = this.getPrintout(data, date); 260 | 261 | if (payload.content) return (isUpdateMessage) ? undefined : channel.send(payload); 262 | channel.send(payload).catch(console.error); 263 | } 264 | 265 | private async loadData() { 266 | if (this.loadDataTimeOut) { 267 | clearTimeout(this.loadDataTimeOut); 268 | this.loadDataTimeOut = null; 269 | } 270 | this.loadDataTimeOut = setTimeout(this.loadData.bind(this), this.settings.esports.updateTimeout); 271 | 272 | const leagueLists = await (await fetch("https://esports-api.lolesports.com/persisted/gw/getLeagues?hl=en-US", { 273 | headers: { "x-api-key": "0TvQnueqKa5mxJntVWt0w4LpLfEkrV1Ta8rQBb9Z" }, 274 | })).json() as EsportsAPILeagueResponse; 275 | 276 | const leagueIds = leagueLists.data.leagues.map(l => l.id).reduce((prev, next) => prev + "," + next, ""); 277 | 278 | const events = await (await fetch(`https://esports-api.lolesports.com/persisted/gw/getEventList?hl=en-US&leagueId=${leagueIds}`, { 279 | headers: { "x-api-key": "0TvQnueqKa5mxJntVWt0w4LpLfEkrV1Ta8rQBb9Z" }, 280 | })).json() as EsportsAPIEventListResponse; 281 | 282 | const schedule: Map> = new Map(); 283 | events.data.esports.events.forEach(e => { 284 | const gameData: ESportsLeagueSchedule = { 285 | league: e.league.slug, 286 | url: e.league.slug, 287 | time: e.startTime, 288 | teamA: e.match.teams[0].code, 289 | teamB: e.match.teams[1].code, 290 | }; 291 | 292 | const dateSplit = e.startTime.split("-"); 293 | const realDate = `${dateSplit[0]} ${dateSplit[1]} ${dateSplit[2].split("T")[0]}`; 294 | 295 | if (!schedule.has(realDate)) { 296 | schedule.set(realDate, new Map()); 297 | } 298 | 299 | if (!schedule.get(realDate)!.has(e.league.name)) { 300 | schedule.get(realDate)!.set(e.league.name, []); 301 | } 302 | 303 | schedule.get(realDate)!.get(e.league.name)!.push(gameData); 304 | }); 305 | 306 | this.schedule = schedule; 307 | } 308 | 309 | private getUrlByLeague(leagueName: ESportsLeagueSchedule) { 310 | 311 | return "https://lolesports.com/schedule?leagues=" + leagueName.url; 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /src/Endpoint.ts: -------------------------------------------------------------------------------- 1 | import Discord = require("discord.js"); 2 | import fetch from "node-fetch"; 3 | import { fileBackedObject } from "./FileBackedObject"; 4 | import { PersonalSettings, SharedSettings } from "./SharedSettings"; 5 | import { levenshteinDistance } from "./LevenshteinDistance"; 6 | import { setTimeout } from "timers"; 7 | 8 | type EndpointName = string; 9 | type Endpoints = EndpointName[]; 10 | 11 | interface EndpointsState { 12 | endpoints: Endpoints; 13 | lastUpdate: Date; 14 | } 15 | 16 | /** 17 | * Posts links to api endpoints 18 | * 19 | */ 20 | export default class Endpoint { 21 | private endpoints: EndpointsState; 22 | private baseUrl: string; 23 | private maxDistance: number; 24 | private aliases: { [key: string]: string[] }; 25 | private timeOut: NodeJS.Timer | null; 26 | private timeOutDuration: number; 27 | 28 | public constructor(sharedSettings: SharedSettings, endpointFile: string) { 29 | console.log("Requested Endpoint extension."); 30 | this.baseUrl = sharedSettings.endpoint.baseUrl; 31 | this.maxDistance = sharedSettings.endpoint.maxDistance; 32 | this.aliases = sharedSettings.endpoint.aliases || {}; 33 | this.timeOutDuration = sharedSettings.endpoint.timeOutDuration; 34 | this.endpoints = fileBackedObject(endpointFile, "www/" + endpointFile); 35 | this.updateEndpoints(); 36 | } 37 | 38 | public onEndpoint(message: Discord.Message, isAdmin: boolean, command: string, args: string[]) { 39 | if (this.endpoints.endpoints === undefined) { 40 | message.channel.send("Sorry, endpoints are not yet initialized!"); 41 | return; 42 | } 43 | 44 | // If message is empty, send back list 45 | const argsString = args.reduce((prev, current) => prev + current, ""); 46 | if (argsString.length === 0) { 47 | return this.onList(message, isAdmin, command, args); 48 | } 49 | 50 | let minDist = Infinity; 51 | let minEndpoint = null; 52 | 53 | for (const endpoint of this.endpoints.endpoints) { 54 | const dist = this.calculateEndpointAndSynonymsDist(endpoint, argsString); 55 | if (dist < minDist) { 56 | minDist = dist; 57 | minEndpoint = endpoint; 58 | } 59 | } 60 | 61 | if (minEndpoint != null && minDist < this.maxDistance) { 62 | message.channel.send(this.baseUrl + minEndpoint); 63 | } else { 64 | message.channel.send("Could not find requested endpoint!"); 65 | } 66 | } 67 | 68 | public onList(message: Discord.Message, isAdmin: boolean, command: string, args: string[]) { 69 | if (this.endpoints.endpoints === undefined) { 70 | message.channel.send("Sorry, endpoints are not yet initialized!"); 71 | return; 72 | } 73 | 74 | let content = "The Riot API has the following endpoints available:\n"; 75 | 76 | for (const endpoint of this.endpoints.endpoints) { 77 | const newContent = `- ${endpoint}: ${this.baseUrl + endpoint}\n`; 78 | if (content.length + newContent.length > 2000) { 79 | message.channel.send(content); 80 | content = ""; 81 | } 82 | content += newContent; 83 | } 84 | if (content.length == 0) return; 85 | message.channel.send(content); 86 | } 87 | 88 | /** 89 | * Levenshtein Distance with some simple modifications for endpoint & input 90 | * @param endpoint 91 | * @param input 92 | */ 93 | private calculateDistance(endpoint: string, input: string): number { 94 | endpoint = endpoint.replace(new RegExp("-", "g"), " "); 95 | input = input.replace(new RegExp("-", "g"), " "); 96 | return levenshteinDistance(endpoint, input); 97 | } 98 | 99 | /** 100 | * 101 | * @param endpoint 102 | * @param compareString 103 | * @returns minimum distance between endpoint/endpoint synonym and compare string 104 | */ 105 | private calculateEndpointAndSynonymsDist(endpoint: string, argsString: string): number { 106 | const endpointComponents = endpoint.split("-"); 107 | const version = endpointComponents[endpointComponents.length - 1]; 108 | let compareString = argsString; 109 | if (!argsString.endsWith(version)) { 110 | compareString = argsString + "-" + version; 111 | } 112 | 113 | let dist = this.calculateDistance(endpoint, compareString); 114 | 115 | if (endpoint.startsWith("lol")) { 116 | const aliasDist = this.calculateDistance(endpoint.replace("lol", ""), compareString); 117 | dist = Math.min(dist, aliasDist); 118 | } 119 | 120 | // check for manually defined aliases 121 | if (!this.aliases || !this.aliases[endpoint]) { 122 | return dist; 123 | } 124 | 125 | for (const alias of this.aliases[endpoint]) { 126 | const aliasDist = this.calculateDistance(alias + "-" + version, compareString); 127 | dist = Math.min(dist, aliasDist); 128 | } 129 | return dist; 130 | } 131 | 132 | private async updateEndpoints() { 133 | try { 134 | const response = await fetch(`http://www.mingweisamuel.com/riotapi-schema/openapi-3.0.0.json`, { 135 | method: "GET", 136 | headers: { 137 | "Accept": "application/json", 138 | "Content-Type": "application/json", 139 | }, 140 | }); 141 | 142 | if (response.status !== 200) { 143 | console.error("HTTP Error trying to get schema: " + response.status); 144 | return; 145 | } 146 | 147 | const schema = await response.json(); 148 | const paths = schema.paths; 149 | 150 | const endpointSet = new Set(); 151 | 152 | for (const path in paths) { 153 | const endpointName = paths[path]["x-endpoint"]; // match-v4 154 | endpointSet.add(endpointName); 155 | } 156 | 157 | this.endpoints.lastUpdate = new Date(); 158 | // we have to create a copy of the set because the file backed object proxy does not serialize sets properly 159 | this.endpoints.endpoints = new Array(endpointSet.size); 160 | { 161 | let i = 0; 162 | for (const endpoint of endpointSet) { 163 | this.endpoints.endpoints[i] = endpoint; 164 | i++; 165 | } 166 | } 167 | console.log("Updated endpoints!", this.endpoints.endpoints); 168 | } catch (e) { 169 | console.error("Schema fetch error: " + e.message); 170 | } 171 | 172 | if (this.timeOut) { 173 | clearTimeout(this.timeOut); 174 | this.timeOut = null; 175 | } 176 | 177 | this.timeOut = setTimeout(this.updateEndpoints.bind(this), this.timeOutDuration); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/FileBackedObject.ts: -------------------------------------------------------------------------------- 1 | import fs = require("fs-extra"); 2 | import path = require("path"); 3 | 4 | export function fileBackedObject(location: string, backupLocation: string | null = null): T { 5 | const contents = fs.readFileSync(location, "utf8"); 6 | const obj = JSON.parse(contents); 7 | 8 | if (backupLocation) 9 | fs.ensureDirSync(path.dirname(backupLocation)); 10 | 11 | return generateProxy(obj, location, backupLocation); 12 | } 13 | 14 | export function isObject(item: any) { 15 | return (item && typeof item === "object" && !Array.isArray(item)); 16 | } 17 | 18 | export default function mergeDeep(target: any, source: any) { 19 | const output = Object.assign({}, target); 20 | if (isObject(target) && isObject(source)) { 21 | Object.keys(source).forEach(key => { 22 | if (isObject(source[key])) { 23 | if (!(key in target)) 24 | Object.assign(output, { [key]: source[key] }); 25 | else 26 | output[key] = mergeDeep(target[key], source[key]); 27 | } else { 28 | Object.assign(output, { [key]: source[key] }); 29 | } 30 | }); 31 | } 32 | return output; 33 | } 34 | 35 | export function overrideFileBackedObject(location: string, overwriteLocation: string): T { 36 | const defaults = fs.readFileSync(location, "utf-8"); 37 | const overwrite = fs.readFileSync(overwriteLocation, "utf-8"); 38 | const defaultsData = JSON.parse(defaults); 39 | const overwriteData = JSON.parse(overwrite); 40 | const obj = mergeDeep(defaultsData, overwriteData); 41 | 42 | return generateProxy(obj, location); 43 | } 44 | 45 | function generateProxy(obj: T, location: string, backupLocation: string | null = null): T { 46 | const proxy = { 47 | set(object: any, property: string, value: any, receiver: any) { 48 | Reflect.set(object, property, value, receiver); 49 | const data = JSON.stringify(obj); 50 | fs.writeFileSync(location, data); 51 | if (backupLocation) 52 | fs.writeFileSync(backupLocation, data); 53 | return true; 54 | }, 55 | 56 | get(object: any, property: string, receiver: any): any { 57 | const child = Reflect.get(object, property, receiver); 58 | if (!child || typeof child !== "object") return child; 59 | return new Proxy(child, proxy); 60 | }, 61 | }; 62 | 63 | return new Proxy(obj, proxy); 64 | } 65 | -------------------------------------------------------------------------------- /src/InteractionManager.ts: -------------------------------------------------------------------------------- 1 | import Discord = require("discord.js"); 2 | import { SharedSettings } from "./SharedSettings"; 3 | import Botty from "./Botty"; 4 | import EventEmitter = require('events'); 5 | 6 | export interface InteractionCommandData { 7 | body: Discord.RESTPatchAPIApplicationCommandJSONBody, 8 | global?: boolean | true | false, // wtf? 9 | adminOnly: false | true, 10 | handler: BottyCommandInteraction, 11 | }; 12 | export type BottyCommandInteraction = (interaction: Discord.CommandInteraction | Discord.AutocompleteInteraction, admin?: false | true) => any; 13 | export interface InteractionInterface { 14 | getInteractionCommands() : InteractionCommandData[] 15 | } 16 | 17 | export default class InteractionManager { 18 | private initialized: true | false | "failed" = false; 19 | private initializePromise: Promise; 20 | private clientId: string; 21 | private sharedSettings: SharedSettings; 22 | private rest: Discord.REST; 23 | private globalCommands: Discord.RESTGetAPIApplicationCommandsResult; 24 | private handlers = new Map(); // Commands by id 25 | private commands: InteractionCommandData[] = []; // Commands by name 26 | private botty: Botty; 27 | 28 | public constructor(botty: Botty, settings: SharedSettings) { 29 | this.botty = botty; 30 | this.rest = new Discord.REST().setToken(settings.botty.discord.key); 31 | this.sharedSettings = settings; 32 | //this.clientId = this.sharedSettings.botty.discord.clientId; 33 | 34 | //botty.client.on("ready", this.initialize.bind(this)); 35 | this.initializePromise = this.initialize().catch(console.error); 36 | botty.client.on("interactionCreate", this.onInteraction.bind(this)); 37 | } 38 | 39 | private async initialize() 40 | { 41 | if (!this.clientId) { 42 | console.warn("InteractionManager: Client ID not set in shared_settings.json, will try to use user id"); 43 | // Wait until connected to discord to continue 44 | if (!this.botty.client.readyAt) await new Promise((resolve) => this.botty.client.once('ready', resolve)); 45 | if (this.botty.client.user && this.botty.client.user.id) this.clientId = this.botty.client.user.id 46 | } 47 | const adminCommands = [ 48 | new Discord.SlashCommandBuilder() 49 | .setName("refresh_commands") 50 | .setDescription("Refreshes all slash commands").toJSON() 51 | ]; 52 | 53 | // Find global commands 54 | try { 55 | this.globalCommands = await this.rest.get(Discord.Routes.applicationCommands(this.clientId)) as Discord.RESTGetAPIApplicationCommandsResult; 56 | this.initialized = true; 57 | // Add admin commands related to interaction manager 58 | for (const adminCommand of adminCommands) { 59 | this.addSlashCommand(adminCommand, true, true, this.onInteraction.bind(this)) 60 | } 61 | } 62 | catch (e) { 63 | console.error(e); 64 | } 65 | } 66 | 67 | public async addSlashCommand(command: Discord.RESTPostAPIChatInputApplicationCommandsJSONBody | Discord.RESTPatchAPIApplicationCommandJSONBody, global: boolean, adminOnly: boolean, handler: BottyCommandInteraction): Promise { 68 | const commandData: InteractionCommandData = {body: command, global, adminOnly, handler}; 69 | this.commands.push(commandData); 70 | if (!this.initialized) await this.initializePromise; 71 | if (this.initialized === "failed") throw new Error("Cannot add new command because InteractionManager failed to load"); 72 | if (commandData.global) { 73 | // Check if command is already registered 74 | const restCommand = this.globalCommands.find((c) => c.name === commandData.body.name) 75 | if (restCommand) this.handlers.set(restCommand.id, commandData) // Already was registered 76 | else this.handlers.set(await this.addGlobalSlashCommandREST(commandData), commandData) 77 | return; 78 | } 79 | } 80 | 81 | private async addGlobalSlashCommandREST(command: InteractionCommandData) { 82 | return (await (this.rest.post(Discord.Routes.applicationCommands(this.clientId), {body: command.body})) as Discord.APIApplicationCommand).id 83 | } 84 | 85 | public onRefresh(interaction: Discord.CommandInteraction, admin = false) { 86 | if (interaction.commandName !== "refresh_commands") { return; } 87 | if (!admin) interaction.reply({content: "You don't have permission to use this command", ephemeral: true}); 88 | 89 | this.rest.put(Discord.Routes.applicationCommands(this.clientId), {body: []}).then(() => { 90 | this.handlers.clear(); 91 | const localCommands = this.commands 92 | this.commands = []; 93 | localCommands.forEach(this.addSlashCommand.bind(this)); 94 | interaction.reply({content: "Done", ephemeral: true}); 95 | }).catch(e => interaction.reply({content: "Failed to refresh commands: " + e, ephemeral: true})); 96 | } 97 | 98 | public onInteraction(interaction: Discord.BaseInteraction) { 99 | let admin = false; 100 | if (interaction.guild) { 101 | const member = interaction.guild.members.cache.find(guildMember => interaction.user.id === guildMember.user.id) 102 | admin = member?.roles.cache.some((role) => this.sharedSettings.commands.adminRoles.includes(role.id)) || false; 103 | } 104 | if (interaction instanceof Discord.CommandInteraction) { 105 | const handler = this.handlers.get(interaction.commandId); 106 | // Interactions from REST probably aren't loaded, fall back to using name for now 107 | if (handler === undefined) { 108 | const possibleInteraction = this.findInteractionByName(interaction.commandName); 109 | console.warn("InteractionManager: Interaction requested, but in a funky state"); 110 | try { 111 | possibleInteraction?.handler(interaction, admin); 112 | } 113 | catch (e) { 114 | if (!interaction.replied) interaction.reply({content: "The command returned an error", ephemeral: true}); 115 | console.error(e); 116 | } 117 | return; 118 | } 119 | try { 120 | handler.handler(interaction, admin as any); 121 | } 122 | catch (e) { 123 | interaction.reply({content: "The command returned an error", ephemeral: true}); 124 | console.error(e); 125 | } 126 | } 127 | else if (interaction instanceof Discord.AutocompleteInteraction) { 128 | const handler = this.handlers.get(interaction.commandId); 129 | handler?.handler(interaction, admin); 130 | } 131 | } 132 | private findInteractionByName(name: string) { 133 | return this.commands.find(c => c.body.name === name); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/JoinArguments.ts: -------------------------------------------------------------------------------- 1 | export default function joinArguments(args: string[], separators: string[], index: number = 0): string { 2 | let result = ""; 3 | 4 | if (args.length < separators.length) { 5 | console.error(`Expected [ ${args.join(", ")} ] (${args.length}) to be the same length as [ ${separators.join(", ")} ] (${separators.length})!`); 6 | } 7 | 8 | const len = args.length < separators.length ? args.length : separators.length; // Just a precaution 9 | 10 | for (let i = index; i <= len; i++) { 11 | if (i < separators.length) { 12 | if (i !== index) { 13 | result += separators[i]; 14 | } 15 | } 16 | 17 | if (i < args.length) { 18 | result += args[i]; 19 | } 20 | } 21 | 22 | return result; 23 | } 24 | -------------------------------------------------------------------------------- /src/KeyFinder.ts: -------------------------------------------------------------------------------- 1 | import { fileBackedObject } from "./FileBackedObject"; 2 | import { SharedSettings } from "./SharedSettings"; 3 | 4 | import { clearTimeout, setTimeout } from "timers"; 5 | 6 | import Discord = require("discord.js"); 7 | import fetch from "node-fetch"; 8 | 9 | export default class KeyFinder { 10 | private sharedSettings: SharedSettings; 11 | private keys: FoundKeyInfo[]; 12 | private bot: Discord.Client; 13 | private channel?: Discord.TextChannel = undefined; 14 | 15 | private timeOut: NodeJS.Timer | null = null; 16 | 17 | constructor(bot: Discord.Client, sharedSettings: SharedSettings, keyFile: string) { 18 | console.log("Requested KeyFinder extension.."); 19 | 20 | this.sharedSettings = sharedSettings; 21 | console.log("Successfully loaded KeyFinder settings file."); 22 | 23 | this.keys = fileBackedObject(keyFile); 24 | console.log("Successfully loaded KeyFinder key file."); 25 | 26 | this.bot = bot; 27 | 28 | this.bot.on("ready", async () => { 29 | 30 | const guild = this.bot.guilds.cache.get(this.sharedSettings.server.guildId); 31 | if (guild == null) { 32 | console.error(`KeyFinder: Unable to find server with ID: ${this.sharedSettings.server}`); 33 | return; 34 | } 35 | 36 | const channel = guild.channels.cache.find(c => c.name === this.sharedSettings.keyFinder.reportChannel) as Discord.TextChannel; 37 | if (channel == null) { 38 | if (this.sharedSettings.botty.isProduction) { 39 | console.error(`KeyFinder: Unable to find channel: ${this.sharedSettings.keyFinder.reportChannel}`); 40 | return; 41 | } 42 | this.channel = await guild!.channels.create({name: this.sharedSettings.keyFinder.reportChannel, type: Discord.ChannelType.GuildText }) as Discord.TextChannel; 43 | } 44 | else { 45 | this.channel = channel; 46 | } 47 | 48 | console.log("KeyFinder extension loaded."); 49 | this.testAllKeys(); 50 | }); 51 | this.bot.on("messageCreate", this.onMessage.bind(this)); 52 | } 53 | 54 | public onMessage(incomingMessage: Discord.Message) { 55 | if (incomingMessage.author.id === this.bot.user!.id || !incomingMessage.guild) return; 56 | 57 | this.findKey(`<@${incomingMessage.author.id}>`, incomingMessage.content, `<#${incomingMessage.channel.id}>`, incomingMessage.createdTimestamp); 58 | } 59 | 60 | public onKeyList(message: Discord.Message, isAdmin: boolean, command: string, args: string[]) { 61 | // only allow this command if it was sent in the reporting channel (#moderators) 62 | if (!this.channel) return; 63 | if (message.channel.id !== this.channel.id) return; 64 | 65 | if (this.keys.length === 0) { 66 | message.reply("I haven't found any keys."); 67 | return; 68 | } 69 | 70 | let outgoingMessage = `I've found ${this.keys.length} key${this.keys.length === 1 ? "" : "s"} that ${this.keys.length === 1 ? "is" : "are"} still active:\n`; 71 | for (const keyInfo of this.keys) outgoingMessage += `- \`${keyInfo.apiKey}\` (posted by ${keyInfo.user} in ${keyInfo.location} on ${new Date(keyInfo.timestamp)}). Rate limit: \`${keyInfo.rateLimit}\`\n`; 72 | 73 | message.reply(outgoingMessage); 74 | } 75 | 76 | /** 77 | * Checks if an AnswerHubAPI key is valid 78 | * @param key The AnswerHubAPI key to test 79 | * @async 80 | * @returns The value of the "X-App-Rate-Limit" header ('undefined' if a header is not included in the response) if the key yields a non-403 response code, or 'null' if the key yields a 403 response code 81 | * @throws {Error} Thrown if the AnswerHubAPI call cannot be completed or results in a status code other than 200 or 403 82 | */ 83 | public async testKey(key: string): Promise { 84 | const resp = await fetch("https://kr.api.riotgames.com/lol/league/v4/masterleagues/by-queue/RANKED_SOLO_5x5", { 85 | headers: { 86 | "X-Riot-Token": key, 87 | }, 88 | }); 89 | 90 | const rateLimit = resp.headers.get("x-app-rate-limit"); 91 | if (resp.status === 401) { 92 | const body = await resp.json(); 93 | 94 | if (body.status?.message == "Unknown apikey") { 95 | return null; 96 | } 97 | else { 98 | console.warn(`Got weird response body while checking key \`${key}\`:${JSON.stringify(body)}`); 99 | } 100 | } 101 | else if (resp.status !== 403 && rateLimit === null) { 102 | 103 | const availableHeaders: string[] = []; 104 | resp.headers.forEach((value: string, header: string) => availableHeaders.push(`${header}: ${value}`)); 105 | 106 | console.log(`Key Rate-limit headers for \`${key}\` are missing from a call with status code ${resp.status}. Available headers: \`\`\`${availableHeaders.join("\n")}\`\`\``); 107 | return "Fake Headers"; 108 | } 109 | 110 | const existingKey = this.keys.find(k => k.apiKey === key); 111 | if (existingKey && rateLimit) { 112 | existingKey.rateLimit = rateLimit; 113 | } 114 | 115 | return resp.status === 403 ? null : rateLimit; 116 | } 117 | 118 | /** 119 | * Tests all keys to see if they are still active, removing deactivated keys from the list and logging a message for each one 120 | */ 121 | public async testAllKeys() { 122 | for (let i = 0; i < this.keys.length; i++) { 123 | const keyInfo = this.keys[i]; 124 | const header = await this.testKey(keyInfo.apiKey); 125 | 126 | if (header !== null) continue; 127 | 128 | this.keys.splice(i, 1); 129 | 130 | const message = `Key \`${keyInfo.apiKey}\` returns 403 Forbidden now, removing it from my database.`; 131 | console.warn(message); 132 | if (this.channel) this.channel.send(message); 133 | } 134 | 135 | if (this.timeOut !== null) clearTimeout(this.timeOut); 136 | this.timeOut = setTimeout(this.testAllKeys.bind(this), 60000); 137 | } 138 | 139 | /** 140 | * Checks if a message contains a working AnswerHubAPI key. If a working key is found (that had not already been found), moderators will be alerted and the key will be tracked 141 | * @param user The user who sent the message (used when reporting found keys). If the key was posted on AnswerHub, this should be their username; if the key was posted in Discord, this should be a string to tag them (e.g. "<@178320409303842817>") 142 | * @param message The message to check for an AnswerHubAPI key. Where the key was posted. If the key was posted on AnswerHub, this should be a link to the post; if the key was posted in Discord, this should be a string to tag the channel (e.g. "<#187652476080488449>") 143 | * @param location Where the message was sent (used when reporting found keys) 144 | * @param timestamp When the key was posted (in milliseconds since the Unix epoch) 145 | * @async 146 | * @returns 'true' if a working AnswerHubAPI key was found in the message, 'false' if one wasn't 147 | */ 148 | public async findKey(user: string, message: string, location: string, timestamp: number): Promise { 149 | const matches = message.match(/RGAPI-[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}/ig); 150 | if (!matches) return false; 151 | 152 | let found = false; 153 | for (const match of matches) { 154 | 155 | const limit = await this.testKey(match); 156 | const existing = this.keys.find(x => x.apiKey === match); 157 | if (existing) { 158 | // we've already seen the key, check for other keys 159 | console.log(`Found duplicate of a known key at ${location} posted by ${user}: \`${match}\`. Key rate limit: \`${limit}\`.`); 160 | continue; 161 | } 162 | 163 | found = found || limit !== null; 164 | if (limit == null) { 165 | // key is invalid, check for other keys 166 | console.log(`Found inactive key in ${location} posted by ${user}: \`${match}\`. Key rate limit: \`${limit}\`.`); 167 | continue; 168 | } 169 | 170 | this.keys.push({ 171 | apiKey: match, 172 | rateLimit: limit, 173 | user, 174 | location, 175 | timestamp, 176 | }); 177 | 178 | const response = `Found a key in ${location} posted by ${user}: \`${match}\`. Key rate limit: \`${limit}\`.`; 179 | console.warn(response); 180 | if (this.channel) this.channel.send(response); 181 | break; 182 | } 183 | 184 | return found; 185 | } 186 | } 187 | 188 | interface FoundKeyInfo { 189 | apiKey: string; 190 | /** The person who posted the key. If the key was posted on AnswerHub, this will be their username; if the key was posted in Discord, this will be a string to tag them (e.g. "<@178320409303842817>") */ 191 | user: string; 192 | /** Where the key was posted. If the key was posted on AnswerHub, this will be a link to the post; if the key was posted in Discord, this will be a string to tag the channel (e.g. "<#187652476080488449>") */ 193 | location: string; 194 | /** When the key was posted (in milliseconds since the Unix epoch) */ 195 | timestamp: number; 196 | /** The key rate limit (in the same form as the "X-App-Rate-Limit" header) */ 197 | rateLimit: string; 198 | } 199 | -------------------------------------------------------------------------------- /src/LevenshteinDistance.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Counts the substitutions needed to transform a into b 3 | * source adapted from: https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows 4 | * @param a first string 5 | * @param b second string 6 | */ 7 | export function levenshteinDistance(a: string, b: string): number { 8 | if (a === b) { 9 | return 0; 10 | } 11 | 12 | if (a.length === 0) { 13 | return b.length; 14 | } 15 | 16 | if (b.length === 0) { 17 | return a.length; 18 | } 19 | 20 | let v0 = []; 21 | const v1 = []; 22 | 23 | for (let i = 0; i < b.length + 1; i++) { 24 | v0[i] = i; 25 | v1[i] = 0; 26 | } 27 | 28 | for (let i = 0; i < a.length; i++) { 29 | v1[0] = i + 1; 30 | 31 | for (let j = 0; j < b.length; j++) { 32 | const cost = a[i] === b[j] ? 0 : 1; 33 | 34 | const deletionCost = v0[j + 1] + 1; 35 | const insertCost = v1[j] + 1; 36 | const substituteCost = v0[j] + cost; 37 | const minCost = Math.min(Math.min(deletionCost, insertCost), substituteCost); 38 | 39 | v1[j + 1] = minCost; 40 | } 41 | v0 = v1.slice(); 42 | } 43 | 44 | return v1[b.length]; 45 | } 46 | 47 | /** 48 | * Counts the substitutions needed to transform a into b for each element in b, and returns the lowest matching score 49 | */ 50 | export function levenshteinDistanceArray(a: string, b: string[]): number { 51 | return Math.min(...b.map(x => levenshteinDistance(a, x))); 52 | } 53 | -------------------------------------------------------------------------------- /src/Logger.ts: -------------------------------------------------------------------------------- 1 | import { fileBackedObject } from "./FileBackedObject"; 2 | import { SharedSettings } from "./SharedSettings"; 3 | 4 | import Discord = require("discord.js"); 5 | 6 | /** 7 | * Log handler. 8 | * 9 | * @export 10 | * @class Logger 11 | */ 12 | export default class Logger { 13 | private bot: Discord.Client; 14 | private errorChannel: Discord.TextChannel; 15 | private logChannel: Discord.TextChannel; 16 | private sharedSettings: SharedSettings; 17 | 18 | private loaded: boolean = false; 19 | private oldLog: (message?: any, ...optionalParams: any[]) => void; 20 | private oldError: (message?: any, ...optionalParams: any[]) => void; 21 | private oldWarning: (message?: any, ...optionalParams: any[]) => void; 22 | 23 | constructor(bot: Discord.Client, sharedSettings: SharedSettings) { 24 | console.log("Requested Logger extension.."); 25 | this.bot = bot; 26 | 27 | this.sharedSettings = sharedSettings; 28 | 29 | this.bot.on("ready", this.onBot.bind(this)); 30 | } 31 | 32 | public async onBot() { 33 | const guild = this.bot.guilds.cache.get(this.sharedSettings.logger.server); 34 | if (!guild) { 35 | console.error(`Logger: Unable to find server with ID: ${this.sharedSettings.logger.server}`); 36 | return; 37 | } 38 | 39 | const isProduction = this.sharedSettings.botty.isProduction; 40 | let environment: { 41 | errorChannel: string; 42 | logChannel: string; 43 | }; 44 | 45 | if (isProduction && this.sharedSettings.logger.prod) { 46 | environment = this.sharedSettings.logger.prod; 47 | } 48 | 49 | else if (!isProduction && this.sharedSettings.logger.dev) { 50 | environment = this.sharedSettings.logger.dev; 51 | } 52 | 53 | // Fallback to old style 54 | else /*if (!this.sharedSettings.logger.prod && !this.sharedSettings.logger.dev)*/ { 55 | environment = this.sharedSettings.logger; 56 | } 57 | 58 | const errorChannel = guild.channels.cache.find(c => c.name === environment.errorChannel); 59 | if (!errorChannel || !(errorChannel instanceof Discord.TextChannel)) { 60 | console.error(`Logger: Incorrect setting for the error channel: ${environment.errorChannel}, isProduction: ${isProduction}`); 61 | return; 62 | } 63 | this.errorChannel = errorChannel as Discord.TextChannel; 64 | 65 | let logChannel = guild.channels.cache.find(c => c.name === environment.logChannel); 66 | if (!logChannel || !(logChannel instanceof Discord.TextChannel)) { 67 | if (this.sharedSettings.botty.isProduction) { 68 | console.error(`Logger: Incorrect setting for the log channel: ${environment.logChannel}, isProduction: ${isProduction}`); 69 | return; 70 | } 71 | else { 72 | logChannel = await guild!.channels.create({name: environment.logChannel, type: Discord.ChannelType.GuildText}) as Discord.TextChannel; 73 | } 74 | } 75 | this.logChannel = logChannel as Discord.TextChannel; 76 | 77 | this.onLoad(); 78 | console.log("Logger extension loaded."); 79 | } 80 | 81 | public onLoad() { 82 | if (this.loaded) return; 83 | 84 | this.oldLog = console.log; 85 | this.oldError = console.error; 86 | this.oldWarning = console.warn; 87 | 88 | console.log = this.onLog.bind(this); 89 | console.error = this.onError.bind(this); 90 | console.warn = this.onWarning.bind(this); 91 | 92 | this.loaded = true; 93 | } 94 | 95 | public onUnload() { 96 | if (!this.loaded) return; 97 | 98 | console.log = this.oldLog; 99 | console.error = this.oldError; 100 | console.warn = this.oldWarning; 101 | 102 | this.loaded = false; 103 | } 104 | 105 | public onLog(message?: any, ...optionalParams: any[]) { 106 | this.oldLog(message, ...optionalParams); 107 | this.logToDiscord("Log", message, optionalParams); 108 | } 109 | 110 | public onWarning(message?: any, ...optionalParams: any[]) { 111 | this.oldWarning(message, ...optionalParams); 112 | this.logToDiscord("Warning", message, optionalParams); 113 | } 114 | 115 | public onError(message?: any, ...optionalParams: any[]) { 116 | this.oldError(message, ...optionalParams); 117 | this.logToDiscord("Error", message, optionalParams); 118 | } 119 | 120 | private logToDiscord(type: "Error" | "Warning" | "Log", message?: any, ...optionalParams: any[]) { 121 | const logChannel = (type == "Log") ? this.logChannel : this.errorChannel; 122 | const chunks = `[${(new Date()).toUTCString()}] ${type}: ${message.toString()}`.match(/.{1,2000}/sg) || [message.toString()]; 123 | chunks.forEach(chunk => { logChannel.send(chunk).catch(e => this.oldError(`Error trying to send an ${type.toLocaleLowerCase()} message: ${e.toString()}`))}); 124 | for (let i = 0; i < optionalParams.length; i++) { 125 | if (optionalParams.reduce((accumulator, currentValue) => accumulator + currentValue.length, 0) == 0) return; 126 | const chunks = `[${(new Date()).toUTCString()}] ${type} param ${(i + 1)}: ${optionalParams.toString()}`.match(/.{1,2000}/sg) || [message.toString()]; 127 | chunks.forEach(chunk => { logChannel.send(chunk).catch(e => this.oldError(`Error trying to send an ${type.toLocaleLowerCase()} message: ${e.toString()}`))}); 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/PageDiffer.ts: -------------------------------------------------------------------------------- 1 | import { fileBackedObject } from "./FileBackedObject"; 2 | import { SharedSettings, PageType, PageDifferPage } from "./SharedSettings"; 3 | import { clearTimeout, setTimeout } from "timers"; 4 | 5 | import Discord = require("discord.js"); 6 | import crc32 = require("crc-32"); 7 | import fs = require("fs"); 8 | import fetch, { FetchError } from "node-fetch"; 9 | import h2p = require("html2plaintext"); 10 | 11 | interface PageDifferData { 12 | hashes: { [page: string]: number }; 13 | } 14 | 15 | /** 16 | * Checks differences in pages 17 | * 18 | * @export 19 | * @class PageDiffer 20 | */ 21 | export default class PageDiffer { 22 | private bot: Discord.Client; 23 | private channel: Discord.TextChannel; 24 | private sharedSettings: SharedSettings; 25 | private data: PageDifferData; 26 | private timeOut: NodeJS.Timer | null; 27 | 28 | constructor(bot: Discord.Client, sharedSettings: SharedSettings, pageDiffFile: string) { 29 | console.log("Requested PageDiffer extension.."); 30 | this.bot = bot; 31 | 32 | this.sharedSettings = sharedSettings; 33 | this.data = fileBackedObject(pageDiffFile); 34 | 35 | this.bot.on("ready", this.onBot.bind(this)); 36 | } 37 | 38 | public async onBot() { 39 | const guild = this.bot.guilds.cache.get(this.sharedSettings.server.guildId); 40 | if (!guild) { 41 | console.error(`PageDiffer: Unable to find server with ID: ${this.sharedSettings.server}`); 42 | return; 43 | } 44 | 45 | let channel = guild.channels.cache.find(c => c.name === this.sharedSettings.pageDiffer.channel); 46 | if (!channel || !(channel instanceof Discord.TextChannel)) { 47 | if (this.sharedSettings.botty.isProduction) { 48 | console.error(`PageDiffer: Unable to find channel: ${this.sharedSettings.pageDiffer.channel}`); 49 | return; 50 | } 51 | else { 52 | channel = await guild!.channels.create({name: this.sharedSettings.pageDiffer.channel, type: Discord.ChannelType.GuildText}); 53 | } 54 | } 55 | 56 | this.channel = channel as Discord.TextChannel; 57 | console.log("PageDiffer extension loaded."); 58 | 59 | this.checkPages(); 60 | } 61 | 62 | private getFetchUrl(page: PageDifferPage) { 63 | 64 | switch (page.type) { 65 | case PageType.Article: 66 | return this.sharedSettings.pageDiffer.articleHost.replace(/{id}/g, page.ident); // In the case of an article, page.ident is an ID number 67 | 68 | case PageType.Page: 69 | return page.ident; // In the case of a page, page.ident is the URL. 70 | 71 | default: 72 | throw new Error("getFetchUrl was given an undefined page type"); 73 | } 74 | } 75 | 76 | private async checkPages() { 77 | 78 | for (const page of this.sharedSettings.pageDiffer.pages) { 79 | 80 | const fetchUrl = this.getFetchUrl(page); 81 | try { 82 | const resp = await fetch(fetchUrl); 83 | if (resp.status !== 200) { 84 | console.warn(`PageDiffer got an unusual HTTP status code checking for ${page.type} "${page.name}". "${fetchUrl}" returns ${resp.status}.`); 85 | continue; 86 | } 87 | 88 | let body = ""; 89 | let pageLocation = page.ident; 90 | switch (page.type) { 91 | case PageType.Article: { 92 | const article = (await resp.json()).article; 93 | pageLocation = article.html_url; 94 | body = article.body; 95 | break; 96 | } 97 | 98 | case PageType.Page: { 99 | body = await resp.text(); 100 | break; 101 | } 102 | } 103 | 104 | const diffBody = h2p(body).replace(/[\W_]+/g, ""); 105 | const hash = crc32.str(diffBody); 106 | if (this.data.hashes[page.type + page.ident] === hash) continue; 107 | 108 | // Make sure the folders are there 109 | if (!fs.existsSync("www")) fs.mkdirSync("www"); 110 | if (!fs.existsSync("www/pages")) fs.mkdirSync("www/pages"); 111 | 112 | const cleanName = page.name.toLowerCase().replace(/[\W_]+/g, "-"); 113 | const folderName = "pages/" + cleanName + "/"; 114 | const hasDiff = fs.existsSync("www/" + folderName); 115 | if (!hasDiff) fs.mkdirSync("www/" + folderName); 116 | 117 | // Save the file and the info about the file 118 | const curTime = Date.now(); 119 | fs.writeFileSync("www/" + folderName + curTime + "_info.json", JSON.stringify(page)); 120 | fs.writeFileSync("www/" + folderName + curTime + ".html", body); 121 | fs.writeFileSync("www/" + folderName + curTime + "_debug.txt", diffBody); 122 | fs.writeFileSync("www/" + folderName + "index.json", JSON.stringify(fs.readdirSync("www/" + folderName).filter(f => f.endsWith(".html")).map(f => f.replace(/.html/g, "")))); 123 | this.data.hashes[page.type + page.ident] = hash; 124 | 125 | // Get to the posting part, if we have a difference 126 | if (!hasDiff) continue; 127 | 128 | const embed = new Discord.EmbedBuilder() 129 | .setColor(0xffca95) 130 | .setTitle(`The ${page.type} "${page.name}" has changed`) 131 | .setDescription(`Something has changed on "${page.name}".\n\nYou can see the current version here: ${pageLocation}\n\nYou can check out the difference here: ${this.sharedSettings.botty.webServer.relativeLiveLocation + folderName}`) 132 | .setURL(this.sharedSettings.botty.webServer.relativeLiveLocation + folderName) 133 | .setThumbnail(this.sharedSettings.pageDiffer.embedImageUrl); 134 | 135 | this.channel.send({ embeds: [embed] }); 136 | } 137 | catch (e) { 138 | console.warn(`PageDiffer got an error checking for ${page.type} "${page.name}". "${fetchUrl}": ${e}`); 139 | } 140 | } 141 | if (this.timeOut !== null) clearTimeout(this.timeOut); 142 | this.timeOut = setTimeout(this.checkPages.bind(this), this.sharedSettings.pageDiffer.checkInterval); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/Pickem.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import Discord = require("discord.js"); 3 | import { SharedSettings } from "./SharedSettings"; 4 | import joinArguments from "./JoinArguments"; 5 | import { clearTimeout, setTimeout } from "timers"; 6 | 7 | export enum PickemPrintMode { 8 | GROUP = "GROUP", BRACKET = "BRACKET", BOTH = "BOTH", 9 | } 10 | 11 | interface PickemLeaderboardEntry { 12 | id: number; 13 | rank: number; 14 | points: number; 15 | gameName: string; 16 | tagLine: string; 17 | } 18 | 19 | interface PickemLeaderboard { 20 | listSeriesId: number; 21 | listCreatorId: number; 22 | listName: string; 23 | secretToken: string | null; 24 | hasPoints: boolean; 25 | hasSocialMediaLinks: boolean; 26 | modifiable: boolean; 27 | shareable: boolean; 28 | leavable: boolean; 29 | promoted: boolean; 30 | stageToRankings: { [key: string]: PickemLeaderboardEntry[]; }; 31 | } 32 | 33 | interface PickemTeam { 34 | shortName: string; 35 | name: string; 36 | logoUrl: string; 37 | wins: number; 38 | losses: number; 39 | } 40 | 41 | interface PickemGroup { 42 | name: string; 43 | userPoints: number; 44 | teams: PickemTeam[]; 45 | } 46 | 47 | interface PickemUser { 48 | gameName: string; 49 | tagLine: string; 50 | id: number; 51 | } 52 | 53 | interface PickemUserPoints { 54 | gameName: string; 55 | tagLine: string; 56 | id: number; 57 | groupPoints: number; 58 | bracketPoints: number; 59 | totalPoints: number; 60 | } 61 | 62 | interface PickemGroupPick { 63 | user: PickemUser; 64 | groups: PickemGroup[]; 65 | } 66 | 67 | interface PickemBracketTeam { 68 | id: number; 69 | name: string; 70 | shortName: string; 71 | logoUrl: string; 72 | largeLogoUrl: string; 73 | profileUrl: string; 74 | groupStageWins: number; 75 | groupStageLosses: number; 76 | } 77 | 78 | interface PickemBracketRoundMatch { 79 | slotNumber: number; 80 | firstTeamWins: number; 81 | secondTeamWins: number; 82 | actualFirstTeamId: number; 83 | actualSecondTeamId: number; 84 | predictedFirstTeamId: number; 85 | predictedSecondTeamId: number; 86 | predictedWinnerId: number; 87 | actualWinnerId: number; 88 | } 89 | 90 | interface PickemBracketRound { 91 | roundNumber: number; 92 | matches: PickemBracketRoundMatch[]; 93 | } 94 | 95 | interface PickemBracketPick { 96 | teams: PickemBracketTeam[]; 97 | rounds: PickemBracketRound[]; 98 | points: number; 99 | user: PickemUser; 100 | } 101 | 102 | interface PickemPick { 103 | bracket: PickemBracketPick; 104 | group: PickemGroupPick; 105 | summoner: { 106 | name: string; 107 | id: number; 108 | }; 109 | } 110 | 111 | export default class Pickem { 112 | private bot: Discord.Client; 113 | private settings: SharedSettings; 114 | private esportsChannel: Discord.GuildBasedChannel | null = null; 115 | private currentMemberList: PickemUser[] = []; 116 | 117 | constructor(bot: Discord.Client, settings: SharedSettings) { 118 | this.bot = bot; 119 | this.settings = settings; 120 | 121 | bot.on("ready", async () => { 122 | setTimeout(async () => { 123 | const channel = this.settings.esports.printChannel; 124 | 125 | const guild = this.bot.guilds.cache.get(this.settings.server.guildId); 126 | this.esportsChannel = guild!.channels.cache.find(c => c.name === channel && c.type === Discord.ChannelType.GuildText) || null; 127 | if (this.esportsChannel == null) { 128 | if (this.settings.botty.isProduction) { 129 | console.error("Pickem ran into an error: We don't have an e-sports channel but we're on production!"); 130 | } 131 | else { 132 | await guild!.channels.create({name: channel, type: Discord.ChannelType.GuildText }); 133 | } 134 | } 135 | }, 1000); 136 | 137 | const ids = this.settings.pickem.listId; 138 | for (const id of ids) { 139 | await this.updateUserList(String(id)); 140 | } 141 | }); 142 | } 143 | 144 | public async getCorrectPickem(): Promise { 145 | const anyGroup = await this.getGroupPicks(this.settings.pickem.worldsId, this.settings.pickem.blankId); 146 | const anyBracket = await this.getBracketPicks(this.settings.pickem.worldsId, this.settings.pickem.blankId); 147 | anyBracket.points = Number.POSITIVE_INFINITY; 148 | 149 | const best: PickemGroupPick = { user: { gameName: "The Correct Choice", tagLine: "CORRECT", id: -1 }, groups: [] }; 150 | for (const group of anyGroup.groups) { 151 | best.groups.push({ name: group.name, teams: group.teams.sort(this.pickemTeamCompareFunction), userPoints: Number.POSITIVE_INFINITY }); 152 | } 153 | 154 | return { bracket: anyBracket, group: best, summoner: { name: "The Correct Choice", id: -1 } }; 155 | } 156 | 157 | public pickemTeamCompareFunction(a: PickemTeam, b: PickemTeam): number { 158 | if (a.wins !== b.wins) return b.wins - a.wins; 159 | if (a.losses === b.losses) return a.name.localeCompare(b.name); 160 | return a.losses - b.losses; 161 | } 162 | 163 | public async getPickemPoints(series: number, users: number[]): Promise { 164 | let url = this.settings.pickem.pointsUrl; 165 | url = url.replace("{series}", String(this.settings.pickem.worldsId)); 166 | 167 | for (const user of users) { 168 | url += user; 169 | url += "&user="; 170 | } 171 | 172 | const data = await fetch(url); 173 | return (await data.json()) as PickemUserPoints[]; 174 | } 175 | 176 | public async updateUserList(listId: string) { 177 | const url = this.settings.pickem.leaderboardUrl.replace("{listId}", listId); 178 | const data = await fetch(url); 179 | const leaderboard: PickemLeaderboard = await data.json(); 180 | this.currentMemberList = leaderboard.stageToRankings["both"]; 181 | 182 | setTimeout(this.updateUserList.bind(this, listId), this.settings.pickem.updateTimeout); 183 | } 184 | 185 | public async printLeaderboard(channel: Discord.TextChannel) { 186 | const ids: number[] = []; 187 | this.currentMemberList.forEach(i => ids.push(i.id)); 188 | 189 | const points = await this.getPickemPoints(this.settings.pickem.worldsId, ids); 190 | const sorted = points.sort((a, b) => b.totalPoints - a.totalPoints); 191 | 192 | const embed = new Discord.EmbedBuilder(); 193 | embed.setTitle("Top scores:"); 194 | 195 | let list = ""; 196 | let place = 1; 197 | for (let i = 0; i < 5; i++) { 198 | if (i > 0) { 199 | if (sorted[i - 1].totalPoints !== sorted[i].totalPoints) { 200 | place = i + 1; 201 | } 202 | } 203 | 204 | list += `${place}. ${sorted[i].gameName}: ${sorted[i].totalPoints}\n`; 205 | } 206 | 207 | embed.addFields([{name: "Leaderboard", value: list}]); 208 | channel.send({ embeds: [embed] }); 209 | } 210 | 211 | public async getGroupPicks(series: number, user: number): Promise { 212 | let url = this.settings.pickem.groupPickUrl; 213 | url = url.replace("{series}", String(series)); 214 | url = url.replace("{user}", String(user)); 215 | 216 | return (await (await fetch(url)).json()); 217 | } 218 | 219 | public async getBracketPicks(series: number, user: number): Promise { 220 | let url = this.settings.pickem.bracketsUrl; 221 | url = url.replace("{series}", String(series)); 222 | url = url.replace("{user}", String(user)); 223 | 224 | return (await (await fetch(url)).json()); 225 | } 226 | 227 | public generateEmbedGroupPickem(match: PickemGroupPick) { 228 | const embed = new Discord.EmbedBuilder(); 229 | embed.setTitle(match.user.id >= 0 ? `${match.user.gameName}'s pickem` : "Current standings"); 230 | let formattingIndex = 0; 231 | for (const group of match.groups) { 232 | let value = ""; 233 | let index = 1; 234 | for (const team of group.teams) { 235 | value += `${index++}. ${team.name} (${team.wins}-${team.losses})\n`; 236 | } 237 | const pointsField = group.userPoints !== Number.POSITIVE_INFINITY ? `(${group.userPoints} points)` : ""; 238 | embed.addFields([{name: `${group.name} ${pointsField}`, value: value, inline: true}]); 239 | 240 | if (++formattingIndex % 2 === 0) { 241 | embed.addFields([{name: '\u200b', value: '\u200b'}]); 242 | } 243 | } 244 | 245 | return embed; 246 | } 247 | 248 | public getTeamShortFromId(id: number, teams: PickemBracketTeam[]): string { 249 | const team = teams.find(t => t.id === id); 250 | if (team) { 251 | return team.shortName.padEnd(4); 252 | } 253 | return "".padEnd(4); 254 | } 255 | 256 | public generateBracket(pick: PickemBracketPick) { 257 | if (pick.rounds.length !== 3 || pick.rounds[0].matches.length !== 4) 258 | return null; 259 | 260 | const firstMatch = pick.rounds[0].matches[0]; 261 | const secondMatch = pick.rounds[0].matches[1]; 262 | const thirdMatch = pick.rounds[0].matches[2]; 263 | const forthMatch = pick.rounds[0].matches[3]; 264 | const fifthMatch = pick.rounds[1].matches[0]; 265 | const sixthMatch = pick.rounds[1].matches[1]; 266 | const seventhMatch = pick.rounds[2].matches[0]; 267 | 268 | const t11 = this.getTeamShortFromId(firstMatch.actualFirstTeamId, pick.teams); 269 | const t12 = this.getTeamShortFromId(firstMatch.actualSecondTeamId, pick.teams); 270 | const t13 = this.getTeamShortFromId(secondMatch.actualFirstTeamId, pick.teams); 271 | const t14 = this.getTeamShortFromId(secondMatch.actualSecondTeamId, pick.teams); 272 | const t15 = this.getTeamShortFromId(thirdMatch.actualFirstTeamId, pick.teams); 273 | const t16 = this.getTeamShortFromId(thirdMatch.actualSecondTeamId, pick.teams); 274 | const t17 = this.getTeamShortFromId(forthMatch.actualFirstTeamId, pick.teams); 275 | const t18 = this.getTeamShortFromId(forthMatch.actualSecondTeamId, pick.teams); 276 | 277 | let t21 = this.getTeamShortFromId(fifthMatch.predictedFirstTeamId, pick.teams); 278 | let t22 = this.getTeamShortFromId(fifthMatch.predictedSecondTeamId, pick.teams); 279 | let t23 = this.getTeamShortFromId(sixthMatch.predictedFirstTeamId, pick.teams); 280 | let t24 = this.getTeamShortFromId(sixthMatch.predictedSecondTeamId, pick.teams); 281 | let t31 = this.getTeamShortFromId(seventhMatch.predictedFirstTeamId, pick.teams); 282 | let t32 = this.getTeamShortFromId(seventhMatch.predictedSecondTeamId, pick.teams); 283 | let t41 = this.getTeamShortFromId(seventhMatch.predictedWinnerId, pick.teams); 284 | 285 | if (pick.points === Number.POSITIVE_INFINITY) { 286 | t21 = this.getTeamShortFromId(fifthMatch.actualFirstTeamId, pick.teams); 287 | t22 = this.getTeamShortFromId(fifthMatch.actualSecondTeamId, pick.teams); 288 | t23 = this.getTeamShortFromId(sixthMatch.actualFirstTeamId, pick.teams); 289 | t24 = this.getTeamShortFromId(sixthMatch.actualSecondTeamId, pick.teams); 290 | t31 = this.getTeamShortFromId(seventhMatch.actualFirstTeamId, pick.teams); 291 | t32 = this.getTeamShortFromId(seventhMatch.actualSecondTeamId, pick.teams); 292 | t41 = this.getTeamShortFromId(seventhMatch.actualWinnerId, pick.teams); 293 | } 294 | 295 | const pointPrint = pick.points !== Number.POSITIVE_INFINITY ? pick.points + " Points!" : ""; 296 | 297 | const lines: string[] = []; 298 | lines[0] = `╺ ${t11}━┓`; 299 | lines[1] = ` ┣━ ${t21}━┓`; 300 | lines[2] = `╺ ${t12}━┛ ┃`; 301 | lines[3] = ` ┣━━ ${t31}━┓`; 302 | lines[4] = `╺ ${t13}━┓ ┃ ┃`; 303 | lines[5] = ` ┣━ ${t22}━┛ ┃`; 304 | lines[6] = `╺ ${t14}━┛ ┃`; 305 | lines[7] = ` ┣━ ${t41}━╸ ${pointPrint}`; 306 | lines[8] = `╺ ${t15}━┓ ┃`; 307 | lines[9] = ` ┣━ ${t23}━┓ ┃`; 308 | lines[10] = `╺ ${t16}━┛ ┃ ┃`; 309 | lines[11] = ` ┣━━ ${t32}━┛`; 310 | lines[12] = `╺ ${t17}━┓ ┃`; 311 | lines[13] = ` ┣━ ${t24}━┛`; 312 | lines[14] = `╺ ${t18}━┛`; 313 | 314 | return "```" + lines.join("\n") + "```"; 315 | } 316 | 317 | public async onPickem(message: Discord.Message, isAdmin: boolean, command: string, args: string[], separators: string[]) { 318 | 319 | if (!(message.channel instanceof Discord.DMChannel) && this.esportsChannel && message.channel.id !== this.esportsChannel.id) { 320 | message.channel.send(`To avoid spoilers, this command is restricted to #${this.esportsChannel.name}.`); 321 | return; 322 | } 323 | 324 | if (args.length > 1) args[0] = joinArguments(args, separators); 325 | 326 | if (args.length === 0) { 327 | const bestPick = await this.getCorrectPickem(); 328 | this.doPrint(message.channel as Discord.TextChannel, bestPick.group, bestPick.bracket); 329 | return; 330 | } 331 | 332 | if (args[0] === "leaderboard") { 333 | this.printLeaderboard(message.channel as Discord.TextChannel); 334 | return; 335 | } 336 | 337 | const match = this.currentMemberList.filter(a => a.gameName.replace(/\s/g, "").toLowerCase() === args[0].replace(/\s/g, "").toLowerCase())[0]; 338 | if (match) { 339 | const group = await this.getGroupPicks(this.settings.pickem.worldsId, match.id); 340 | const bracket = await this.getBracketPicks(this.settings.pickem.worldsId, match.id); 341 | this.doPrint(message.channel as Discord.TextChannel, group, bracket); 342 | return; 343 | } 344 | message.channel.send("No pickem with that summoner name found.."); 345 | } 346 | 347 | public doPrint(channel: Discord.TextChannel, group: PickemGroupPick, bracket: PickemBracketPick) { 348 | const bracketOutput = this.generateBracket(bracket); 349 | if (bracketOutput === null) 350 | channel.send({ embeds: [this.generateEmbedGroupPickem(group)] }); 351 | else 352 | channel.send(bracketOutput); 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /src/RiotAPILibraries.ts: -------------------------------------------------------------------------------- 1 | import { SharedSettings } from "./SharedSettings"; 2 | 3 | import Discord = require("discord.js"); 4 | import fetch from "node-fetch"; 5 | 6 | import { clearTimeout, setTimeout } from "timers"; 7 | 8 | interface LinkStruct { 9 | self: string; 10 | git: string; 11 | html: string; 12 | } 13 | 14 | interface GithubAPIStruct { 15 | name: string; 16 | path: string; 17 | sha: string; 18 | size: number; 19 | url: string; 20 | html_url: string; 21 | git_url: string; 22 | download_url: string; 23 | type: string; 24 | _links: LinkStruct; 25 | } 26 | interface GithubAPILibraryStruct { 27 | stargazers_count: number; 28 | description: string; 29 | } 30 | 31 | interface APILibraryLink { 32 | name: string; 33 | url: string; 34 | } 35 | 36 | interface APILibraryStruct { 37 | owner: string; 38 | repo: string; 39 | language: string; 40 | description: string; 41 | links: APILibraryLink[]; 42 | metadata: any; 43 | tags: string[]; 44 | } 45 | 46 | interface LibraryDescription { 47 | valid: boolean; 48 | stars: number; 49 | library: APILibraryStruct | null; 50 | links: string[]; 51 | } 52 | 53 | export default class RiotAPILibraries { 54 | private settings: SharedSettings; 55 | 56 | private lastCall: number; 57 | 58 | private fetchSettings: object; 59 | private languageList: string[] = []; 60 | private timeOut: NodeJS.Timer | null; 61 | 62 | constructor(settings: SharedSettings) { 63 | const personalSettings = settings.botty; 64 | this.settings = settings; 65 | this.fetchSettings = { 66 | headers: { 67 | "Accept": "application/json", 68 | "Authorization": `Basic ${Buffer.from(personalSettings.github.username + ":" + personalSettings.github.password).toString("base64")}`, 69 | "Content-Type": "application/json", 70 | }, 71 | }; 72 | 73 | this.initList(); 74 | } 75 | 76 | public onLibs(message: Discord.Message, isAdmin: boolean, command: string, args: string[]) { 77 | let topics: string[] = ["v4"]; // Default tag applies to all channels that don't have specific tags and when no tags are specified in the command 78 | if ("name" in message.channel) { 79 | for (const [topic, tags] of Object.entries(this.settings.riotApiLibraries.channelTopics)) { 80 | if (message.channel.name.toLowerCase().includes(topic)) { 81 | topics = tags 82 | break; 83 | } 84 | } 85 | } 86 | 87 | if (args.length === 0) { 88 | return this.getList(message); 89 | } 90 | 91 | if (args.length > 1) { 92 | topics = args.slice(1).map(x => x.toLowerCase()); // Set the tags to the ones specified in the command 93 | } 94 | 95 | const param = args[0].toLowerCase(); 96 | 97 | if (param === "list") { 98 | return this.getList(message); 99 | } 100 | 101 | return this.getListForLanguage(message, param, topics); 102 | } 103 | 104 | private async describeAPILibrary(json: GithubAPIStruct, tags: string[] = ["v4"]): Promise { 105 | 106 | const libraryResponse = await fetch(json.download_url); 107 | const libraryInfo: APILibraryStruct = await libraryResponse.json(); 108 | 109 | const hasAllTags = tags.every(tag => libraryInfo.tags?.includes(tag)); 110 | if (!hasAllTags) { 111 | return { stars: 0, valid: false, library: null, links: [] }; 112 | } 113 | 114 | const repoResponsePromise = fetch(`https://api.github.com/repos/${libraryInfo.owner}/${libraryInfo.repo}`, this.fetchSettings); 115 | 116 | // Make a list of the links 117 | const githubLink = `github.com/${libraryInfo.owner}/${libraryInfo.repo}`; 118 | let links = libraryInfo.links ? libraryInfo.links.map(link => `[${link.name}](${link.url})`) : []; // Can be empty array or null, sigh 119 | 120 | if (links.length === 0 || links.every(l => l.indexOf(githubLink) === -1)) { 121 | // Make sure there is at least the github link 122 | links = [`[Github](https://${githubLink})`].concat(links); 123 | } 124 | 125 | const repoResponse = await repoResponsePromise; 126 | const repoInfo: GithubAPILibraryStruct = await repoResponse.json(); 127 | 128 | if(!libraryInfo.description){ 129 | libraryInfo.description = repoInfo.description; 130 | } 131 | 132 | return { 133 | library: libraryInfo, 134 | links, 135 | stars: repoInfo.stargazers_count, 136 | valid: true, 137 | }; 138 | } 139 | 140 | private async initList() { 141 | try { 142 | const response = await fetch(this.settings.riotApiLibraries.baseURL, this.fetchSettings); 143 | if (response.status !== 200) { 144 | console.error(this.settings.riotApiLibraries.githubErrorList + response.status); 145 | return; 146 | } 147 | 148 | const languageNames = (await response.json() as GithubAPIStruct[]).map(x => x.name); 149 | 150 | this.languageList = []; 151 | for (const language of languageNames) { 152 | try { 153 | const libraries = await this.getLibrariesForLanguage(language); 154 | if (libraries.length === 0) continue; 155 | this.languageList.push(language); 156 | } 157 | catch (e) { 158 | console.warn(`Unable to fetch library data for language ${language}: ${e}`); 159 | } 160 | } 161 | 162 | console.log("Riot API library languages updated: " + this.languageList.join(", ")); 163 | } 164 | catch (e) { 165 | console.warn(`Unable to fetch all library data: ${e}`); 166 | } 167 | 168 | if (this.timeOut) { 169 | clearTimeout(this.timeOut); 170 | this.timeOut = null; 171 | } 172 | this.timeOut = setTimeout(this.initList.bind(this), this.settings.riotApiLibraries.checkInterval); 173 | } 174 | 175 | private async getList(message: Discord.Message) { 176 | const reply = this.settings.riotApiLibraries.languageList.replace("{languages}", "`" + this.languageList.join(", ") + "`"); 177 | message.channel.send(reply); 178 | } 179 | 180 | private async getLibrariesForLanguage(language: string, tags: string[] = ["v4"]): Promise { 181 | const response = await fetch(this.settings.riotApiLibraries.baseURL + language, this.fetchSettings); 182 | switch (response.status) { 183 | case 200: { 184 | // continue 185 | break; 186 | } 187 | case 404: { 188 | throw new Error(`I found no libraries for ${language}.`); 189 | } 190 | default: { 191 | throw new Error(this.settings.riotApiLibraries.githubErrorLanguage + response.status); 192 | } 193 | } 194 | 195 | const libraryList = await response.json(); 196 | if (!Array.isArray(libraryList) || libraryList.length === 0 || !libraryList[0].sha) { 197 | throw new Error(this.settings.riotApiLibraries.noLanguage + language); 198 | } 199 | const promises = libraryList.map(lib => this.describeAPILibrary(lib, tags)); 200 | const libraryDescriptions = (await Promise.all(promises)) 201 | .filter(l => l.valid && l.library); // Only valid ones 202 | 203 | return libraryDescriptions; 204 | } 205 | 206 | private async getListForLanguage(message: Discord.Message, language: string, tags: string[] = ["v4"]): Promise { 207 | 208 | // Check if alias 209 | for (const [key, values] of Object.entries(this.settings.riotApiLibraries.aliases)) { 210 | if (values.find(self => self.toLowerCase() === language)) { 211 | return this.getListForLanguage(message, key, tags); 212 | } 213 | } 214 | 215 | const editMessagePromise = message.channel.send(`Fetching the list of libraries for ${language}, this post will be edited with the result.`); 216 | 217 | let libraryDescriptions: LibraryDescription[] = []; 218 | try { 219 | libraryDescriptions = (await this.getLibrariesForLanguage(language, tags)) 220 | .sort((a, b) => b.stars - a.stars); // Sort by stars 221 | } 222 | catch (e) { 223 | message.channel.send(e.message); 224 | return; 225 | } 226 | 227 | const embed = new Discord.EmbedBuilder({ title: `List of libraries for ${language}:` }); 228 | for (const desc of libraryDescriptions) { 229 | if (!desc.library) { 230 | // https://github.com/Microsoft/TypeScript/issues/18562 231 | continue; 232 | } 233 | embed.addFields([{ 234 | name: `${desc.library.repo} (★ ${desc.stars ? desc.stars : "0"})`, 235 | value: `${desc.library.description ? desc.library.description + "\n" : " "}${desc.links.join(", ")}` 236 | }]); 237 | } 238 | 239 | let editMessage = await editMessagePromise; 240 | if (Array.isArray(editMessage)) { editMessage = editMessage[0]; } 241 | 242 | if (libraryDescriptions.length === 0) { 243 | editMessage.edit(`No up-to-date libraries found for ${language} tagged with \`${tags.join("\`, \`")}\``); 244 | return; 245 | } 246 | 247 | editMessage.edit({ embeds: [embed] }); 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /src/SharedSettings.ts: -------------------------------------------------------------------------------- 1 | import { PickemPrintMode } from "./Pickem"; 2 | 3 | export interface PersonalSettings { 4 | forum: { 5 | username: string; 6 | password: string; 7 | }; 8 | 9 | discord: { 10 | key: string, 11 | owner: number; 12 | }; 13 | riotApi: { 14 | key: string; 15 | }; 16 | webServer: { 17 | relativeFolderLocation: string; 18 | relativeLiveLocation: string; 19 | }; 20 | github: { 21 | username: string; 22 | password: string; 23 | }; 24 | 25 | isProduction: boolean; 26 | appName: string; 27 | } 28 | 29 | export enum PageType { 30 | Page = "page", 31 | Article = "article", 32 | } 33 | 34 | export interface PageDifferPage { 35 | name: string; 36 | ident: string; 37 | type: PageType; 38 | } 39 | 40 | interface IntroLine { 41 | id: string; 42 | lineTranslation: { [lang: string]: string }; 43 | type: "rule" | "intro"; 44 | } 45 | 46 | export interface SharedSettings { 47 | server: { 48 | guildId: string; 49 | guruChannel: string; 50 | guruLogChannel: string; 51 | introChannel: string; 52 | }; 53 | 54 | botty: PersonalSettings; 55 | 56 | lookup: { 57 | refreshTimeout: number; 58 | championUrl: string; 59 | skinUrl: string; 60 | perkUrl: string; 61 | itemUrl: string; 62 | summonerSpellUrl: string; 63 | confidence: number; 64 | maxGuessCount: number; 65 | }; 66 | 67 | admin: { 68 | muteRoleId: string; 69 | muteRoleName: string; 70 | muteTimeout: number; 71 | keepAdminCommandsChannels: string[]; 72 | }; 73 | 74 | esports: { 75 | printChannel: string; 76 | updateTimeout: number; 77 | printToChannelTimeout: number; 78 | }; 79 | 80 | commands: { 81 | default_prefix: string, 82 | adminRoles: string[]; 83 | }; 84 | 85 | techBlog: { 86 | checkInterval: number, 87 | channel: string, 88 | url: string, 89 | }; 90 | 91 | keyFinder: { 92 | reportChannel: string, 93 | }; 94 | 95 | forum: { 96 | checkInterval: number, 97 | channel: string, 98 | url: string, 99 | }; 100 | 101 | pageDiffer: { 102 | checkInterval: number, 103 | channel: string, 104 | pages: PageDifferPage[], 105 | articleHost: string, 106 | embedImageUrl: string, 107 | }; 108 | 109 | autoReact: { 110 | emoji: string, 111 | }; 112 | 113 | info: { 114 | allowedRoles: string[], 115 | command: string, 116 | maxScore: number, 117 | maxListeners: number, 118 | }; 119 | 120 | riotApiLibraries: { 121 | noLanguage: string, 122 | languageList: string, 123 | githubErrorList: string, 124 | githubErrorLanguage: string, 125 | baseURL: string, 126 | aliases: { [key: string]: string[] }, 127 | channelTopics: { [key: string]: string[] }, 128 | checkInterval: number, 129 | }; 130 | 131 | versionChecker: { 132 | checkInterval: number, 133 | channel: string, 134 | gameThumbnail: string, 135 | dataDragonThumbnail: string, 136 | }; 137 | 138 | logger: { 139 | server: string, 140 | 141 | prod: { 142 | errorChannel: string, 143 | logChannel: string, 144 | }, 145 | 146 | dev: { 147 | errorChannel: string, 148 | logChannel: string, 149 | } 150 | 151 | errorChannel: string, 152 | logChannel: string, 153 | }; 154 | 155 | apiStatus: { 156 | checkInterval: number, 157 | apiOnFireThreshold: number, 158 | statusUrl: string, 159 | onFireImages: string[], 160 | }; 161 | 162 | onJoin: { 163 | messageFile: string; 164 | }; 165 | 166 | apiUrlInterpreter: { 167 | timeOutDuration: number; 168 | }; 169 | 170 | endpoint: { 171 | updateInterval: number, 172 | timeOutDuration: number, 173 | baseUrl: string, 174 | maxDistance: number, 175 | aliases: { [key: string]: string[] }, 176 | }; 177 | 178 | pickem: { 179 | // this is an id that only has the riotdev pickem leaderboards, in this case "stelar7" 180 | blankId: number, 181 | worldsId: number, 182 | listId: number[], 183 | updateTimeout: number, 184 | leaderboardUrl: string, 185 | pointsUrl: string, 186 | // not used yet, as the data format for the brackets is unknown.. 187 | bracketsUrl: string, 188 | groupPickUrl: string; 189 | printMode: PickemPrintMode; 190 | }; 191 | 192 | spam: { 193 | allowedUrls: string[]; 194 | blockedUrls: string[]; 195 | ignoredRoles: string[]; 196 | floodMessageThreshold: number; 197 | floodMessageTime: number; 198 | duplicateMessageThreshold: number; 199 | duplicateMessageTime: number; 200 | }; 201 | 202 | userIntro: { 203 | role: { 204 | id: string; 205 | name: string; 206 | }; 207 | icon: { [lang: string]: string }; 208 | lines: IntroLine[]; 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/TechBlog/xml_parser.ts: -------------------------------------------------------------------------------- 1 | const { XMLParser } = require("fast-xml-parser"); 2 | 3 | const parser = new XMLParser(); 4 | export function parseXmlString(xmlString: string) { 5 | return parser.parse(xmlString, "text/xml"); 6 | } -------------------------------------------------------------------------------- /src/Techblog.ts: -------------------------------------------------------------------------------- 1 | import { fileBackedObject } from "./FileBackedObject"; 2 | import { SharedSettings } from "./SharedSettings"; 3 | import fetch from "node-fetch"; 4 | import Discord = require("discord.js"); 5 | import { parseXmlString } from "./TechBlog/xml_parser"; 6 | 7 | export interface TechblogData { 8 | Last: number; 9 | } 10 | 11 | export default class Techblog { 12 | private bot: Discord.Client; 13 | private sharedSettings: SharedSettings; 14 | private data: TechblogData; 15 | private channel: Discord.ForumChannel; 16 | 17 | constructor(bot: Discord.Client, sharedSettings: SharedSettings, dataFile: string) { 18 | this.sharedSettings = sharedSettings; 19 | 20 | this.data = fileBackedObject(dataFile, "www/" + dataFile); 21 | console.log("Successfully loaded TechblogReader data file."); 22 | 23 | this.bot = bot; 24 | 25 | this.bot.on("ready", async () => { 26 | if (!this.data.Last) this.data.Last = Date.now(); 27 | 28 | const guild = this.bot.guilds.cache.get(this.sharedSettings.server.guildId); 29 | if (!guild) { 30 | console.error(`TechBlog: Unable to find server with ID: ${this.sharedSettings.server}`); 31 | return; 32 | } 33 | 34 | this.channel = guild.channels.cache.find(c => c.name === this.sharedSettings.techBlog.channel) as Discord.ForumChannel; 35 | if (!this.channel) { 36 | if (this.sharedSettings.botty.isProduction) { 37 | console.error(`TechBlog: Unable to find channel: ${this.sharedSettings.techBlog.channel}`); 38 | return; 39 | } 40 | else { 41 | this.channel = await guild!.channels.create({name: this.sharedSettings.techBlog.channel, type: Discord.ChannelType.GuildForum }) as Discord.ForumChannel; 42 | } 43 | } 44 | 45 | console.log("TechblogReader extension loaded."); 46 | 47 | this.checkFeed(); 48 | setInterval(() => { 49 | this.checkFeed(); 50 | }, this.sharedSettings.techBlog.checkInterval); 51 | }); 52 | } 53 | 54 | private async checkFeed() { 55 | try { 56 | let response = await fetch(this.sharedSettings.techBlog.url, { "headers": { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:98.0) Gecko/20100101 Firefox/98.0" }, }); 57 | if (response.ok == false) { 58 | console.warn(`Unable to fetch TechBlog from '${this.sharedSettings.techBlog.url}': ${response.statusText}`); 59 | return; 60 | } 61 | 62 | const xmlText = await response.text(); 63 | const asJson = parseXmlString(xmlText); 64 | const results = asJson.rss.channel; 65 | 66 | for (const article of results.item.reverse()) { // Old to new 67 | const timestamp = new Date(article.pubDate).getTime(); 68 | if (timestamp > this.data.Last) { 69 | this.channel.threads.create({ name: article.title, message: { content: article.link, } }); 70 | this.data.Last = timestamp; 71 | } 72 | } 73 | } 74 | catch (error) { 75 | console.error("Error reading tech blog RSS feed:", error); 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/UserIntroduction.ts: -------------------------------------------------------------------------------- 1 | import { fileBackedObject } from "./FileBackedObject"; 2 | import { SharedSettings } from "./SharedSettings"; 3 | import CommandController from "./CommandController"; 4 | const { performance } = require('perf_hooks'); 5 | 6 | import Discord = require("discord.js"); 7 | import fs = require("fs"); 8 | 9 | class UserIntroductionData { 10 | messages: { [id: string]: string } 11 | }; 12 | 13 | interface UserSaveData { 14 | handled: boolean; 15 | rulesAccepted: { [lang: string]: string[] }; 16 | firstRuleAccepted: number; 17 | }; 18 | 19 | export default class UserIntroduction { 20 | private bot: Discord.Client; 21 | private sharedSettings: SharedSettings; 22 | private messageContents: string; 23 | private commandContents: Discord.EmbedBuilder[]; 24 | private commandController: CommandController; 25 | private channels: { [lang: string]: Discord.TextChannel | null } = {}; 26 | private data: UserIntroductionData; 27 | private role: Discord.Role; 28 | private ruleMessages: { [lang: string]: Discord.Message[] } = {}; 29 | private userSaveData: { [userId: string]: UserSaveData } = {}; 30 | private languages: string[] = []; 31 | 32 | constructor(bot: Discord.Client, commandController: CommandController, sharedSettings: SharedSettings, dataFile: string) { 33 | console.log("Requested UserIntroduction extension.."); 34 | this.bot = bot; 35 | 36 | this.sharedSettings = sharedSettings; 37 | this.data = fileBackedObject(dataFile); 38 | this.commandController = commandController; 39 | 40 | this.bot.on("ready", this.onBot.bind(this)); 41 | } 42 | 43 | sendWelcome(user: Discord.GuildMember | Discord.PartialGuildMember) { 44 | user.send(this.messageContents) 45 | .then(() => this.commandContents.forEach(embed => user.send({ embeds: [embed] }))) 46 | .catch((e) => console.log(`Error: Cannot send the welcome message to ${user.nickname} (${e})`)); 47 | console.log(`Welcomed ${user.displayName}.`); 48 | } 49 | 50 | onUser(user: Discord.GuildMember | Discord.PartialGuildMember) { 51 | user.roles.add(this.role); 52 | } 53 | 54 | async onReaction(messageReaction: Discord.MessageReaction, user: Discord.User) { 55 | if (user.bot) 56 | return; 57 | 58 | const channel = Object.values(this.channels).find(c => c && c.id === messageReaction.message.channel.id); 59 | if (!channel) { 60 | console.log("Got a reaction, but the channel does not exist."); 61 | return; 62 | } 63 | 64 | // Get language 65 | let channelNameParts = channel.name.split("-"); 66 | let channelLanguage = channelNameParts[channelNameParts.length - 1]; 67 | 68 | let origNameParts = this.sharedSettings.server.introChannel.split("-"); // "new-users" -> "users" + "new-users-fr" -> "fr" 69 | channelLanguage = channelLanguage !== origNameParts[origNameParts.length - 1] ? channelLanguage : "default"; 70 | 71 | // Check if it was a reaction to one of our rule messages 72 | const rule = this.ruleMessages[channelLanguage].find(m => m.id === messageReaction.message.id); 73 | if (!rule) { 74 | console.log(`Got a reaction and the channel, but could not find the rule belonging to the message "${messageReaction.message.content}".`); 75 | return; 76 | } 77 | 78 | this.channels[channelLanguage] = (await channel.fetch()); 79 | let member = await channel.members.find(u => u.id == user.id); 80 | if (!member) { 81 | console.error(`Unable to evaluate ${user.username}, because he seems to be unable to be found in the channel?`); 82 | return; 83 | } 84 | 85 | member = await member.fetch(); 86 | 87 | // Did he have the role? 88 | const hasRole = member.roles.cache.some(r => r.id == this.role.id); 89 | if (!hasRole) { 90 | console.log(`${user.username} reacted to ${messageReaction.message.id} but he does not have the role. ${this.role.id} -> ${this.role.name}`); 91 | return; 92 | } 93 | 94 | // Initialise save data 95 | if (!this.userSaveData[user.id]) { 96 | this.userSaveData[user.id] = { 97 | handled: false, 98 | rulesAccepted: {}, 99 | firstRuleAccepted: performance.now() 100 | }; 101 | } 102 | if (!this.userSaveData[user.id].rulesAccepted[channelLanguage]) 103 | this.userSaveData[user.id].rulesAccepted[channelLanguage] = []; 104 | this.userSaveData[user.id].rulesAccepted[channelLanguage].push(rule.id); 105 | const count = this.userSaveData[user.id].rulesAccepted[channelLanguage].length; 106 | 107 | console.log(`${user.username} accepted ${count}/${this.ruleMessages[channelLanguage].length} ${channelLanguage} rules.`); 108 | if (count != this.ruleMessages[channelLanguage].length) 109 | return; 110 | 111 | // If we're here, the user accepted all messages 112 | if (this.userSaveData[user.id] && this.userSaveData[user.id].handled) // Check if we've handled the user 113 | return; 114 | 115 | this.userSaveData[user.id].handled = true; 116 | 117 | const acceptUser = () => { 118 | if (!member) { // Typescript claims this can happen but I disagree 119 | console.error(`Member was undefined -> ${user.username}`); 120 | return; 121 | } 122 | 123 | console.log(`${user.username} was accepted to our server`); 124 | member.roles.remove(this.role); 125 | this.sendWelcome(member); 126 | delete this.userSaveData[user.id]; 127 | } 128 | 129 | // See if we can fetch the time they accepted the first rule. 130 | const firstRuleAccepted = this.userSaveData[user.id].firstRuleAccepted; 131 | if (!firstRuleAccepted) { 132 | console.log(`Could not see when ${user.username} started accepting the rules.. Just accepting it, I guess.`); 133 | acceptUser(); 134 | return; 135 | } 136 | 137 | // Calculate time taken 138 | const timeTaken = performance.now() - firstRuleAccepted; 139 | if (timeTaken > 30 * 1000) { 140 | console.log(`${user.username} took ${timeTaken / 1000} seconds to read all the rules, instantly accepting him.`); 141 | acceptUser(); 142 | return; 143 | } 144 | 145 | // If they took less than 30 seconds, impose a penalty twice the duration of what they had left to wait. 146 | const timePenalty = (30 * 1000 - timeTaken) * 2; // Basically, wait out the rest, but twice as long. 147 | console.log(`${user.username} was pretty fast on reading all the rules (${timeTaken / 1000} seconds), so we're accepting him into the server in ${timePenalty / 1000} seconds.`); 148 | const message = await channel.send(`${user}, you were pretty fast with reading all those rules! I'll add you in a bit, make sure you read all the rules!`); 149 | setTimeout(() => { 150 | message.delete(); 151 | acceptUser(); 152 | }, timePenalty); 153 | } 154 | 155 | private async writeAllRules(language: string, channel: Discord.TextChannel) { 156 | try { 157 | await channel.bulkDelete(100); 158 | } 159 | catch (e) { 160 | console.error(`Unable to delete all messages in ${channel.name}`); 161 | } 162 | 163 | // First link all the channels 164 | let firstMessage = ""; 165 | for (let otherLanguage of this.languages) 166 | if (otherLanguage != language) 167 | firstMessage += `${this.sharedSettings.userIntro.icon[otherLanguage]} => ${this.channels[otherLanguage]}\n`; 168 | await channel.send(firstMessage); 169 | 170 | const messages: { [id: string]: Discord.Message } = {}; 171 | for (let line of this.sharedSettings.userIntro.lines) { 172 | const message = await channel.send(line.lineTranslation[language]); 173 | this.data.messages[line.id] = message.id; 174 | messages[line.id] = message; 175 | 176 | if (line.type == "rule") 177 | await message.react('✅'); 178 | } 179 | 180 | return messages; 181 | } 182 | 183 | public async onBot() { 184 | this.messageContents = fs.readFileSync(this.sharedSettings.onJoin.messageFile, "utf8").toString(); 185 | this.commandContents = this.commandController.getHelp(); 186 | 187 | fs.copyFileSync(this.sharedSettings.onJoin.messageFile, "www/" + this.sharedSettings.onJoin.messageFile); 188 | 189 | this.bot.on("guildMemberAdd", (u) => this.onUser(u)); 190 | 191 | const guild = this.bot.guilds.cache.get(this.sharedSettings.server.guildId); 192 | if (!guild) { 193 | console.error(`UserIntroduction: Unable to find server with ID: ${this.sharedSettings.server}`); 194 | return; 195 | } 196 | 197 | let role: Discord.Role | undefined; 198 | if (this.sharedSettings.userIntro.role.id) 199 | role = guild.roles.cache.get(this.sharedSettings.userIntro.role.id); 200 | 201 | if (!role) { 202 | role = guild.roles.cache.find((r) => r.name === this.sharedSettings.userIntro.role.name); 203 | if (!role) { 204 | console.error(`UserIntroduction: Unable to find the role!`); 205 | return; 206 | } 207 | 208 | console.log("New user role id = " + role.id); 209 | } 210 | this.role = role; 211 | 212 | // Count all translated lines. 213 | const languages: { [lang: string]: number } = {}; 214 | for (let line of this.sharedSettings.userIntro.lines) { 215 | for (let language of Object.keys(line.lineTranslation)) { 216 | if (!languages[language]) 217 | languages[language] = 1; 218 | else 219 | languages[language]++; 220 | } 221 | } 222 | if (!Object.values(languages).every(e => e == this.sharedSettings.userIntro.lines.length)) { 223 | let message = ""; 224 | for (let [key, value] of Object.entries(languages)) 225 | message += `${key}: ${value}`; 226 | console.error(`UserIntroduction: Missing some translations! Counts => ${message}`); 227 | return; 228 | } 229 | this.languages = Object.keys(languages); 230 | 231 | for (let language in languages) { 232 | let channelName = this.sharedSettings.server.introChannel; 233 | if (language != "default") 234 | channelName += "-" + language; 235 | 236 | let channel = guild.channels.cache.find(c => c.name === channelName); 237 | if (!channel) { 238 | console.error(`UserIntroduction: Unable to find user intro channel ${channelName}!`); 239 | return; 240 | } 241 | 242 | if (!(channel instanceof Discord.TextChannel)) { 243 | console.error(`UserIntroduction: channel is not a text channel!`); 244 | return; 245 | } 246 | this.channels[language] = channel as Discord.TextChannel; 247 | } 248 | 249 | for (let language in languages) { 250 | let channelName = this.sharedSettings.server.introChannel; 251 | if (language != "default") 252 | channelName += "-" + language; 253 | 254 | const channel = this.channels[language]; 255 | if (!channel) { 256 | console.error(`UserIntroduction: Unable to find user intro channel ${channelName} after fetching them!`); 257 | return; 258 | } 259 | 260 | // Link up our messages. 261 | const ruleMessages = await this.writeAllRules(language, channel); 262 | for (let line of this.sharedSettings.userIntro.lines) { 263 | const messageId = this.data.messages[line.id]; 264 | if (!messageId) { 265 | console.error("Unexpected issue: Noticed rules aren't writing up correctly (missing rules). Linked messages aren't working."); 266 | this.channels[language] = null; 267 | return; 268 | } 269 | 270 | let message = ruleMessages[line.id]; 271 | 272 | if (!message) { 273 | console.error("Unexpected issue: Noticed rules aren't writing up correctly (missing rules). Linked messages aren't working."); 274 | this.channels[language] = null; 275 | return; 276 | } 277 | 278 | if (line.type != "rule") // Just store rules rules 279 | delete ruleMessages[line.id]; 280 | } 281 | 282 | this.ruleMessages[language] = Object.values(ruleMessages); 283 | } 284 | 285 | this.bot.on("messageReactionAdd", (messageReaction: Discord.MessageReaction, user: Discord.User) => this.onReaction(messageReaction, user)); 286 | console.log(`UserIntroduction extension loaded. ${this.ruleMessages.length} rule messages are added: (${this.ruleMessages["default"].map(r => r.id).join(", ")})\n\n${this.ruleMessages["default"].join("\n")}`); 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /src/VersionChecker.ts: -------------------------------------------------------------------------------- 1 | import Discord = require("discord.js"); 2 | import fetch from "node-fetch"; 3 | import cheerio = require("cheerio"); 4 | 5 | import { fileBackedObject } from "./FileBackedObject"; 6 | import { SharedSettings } from "./SharedSettings"; 7 | 8 | export interface VersionCheckerData { 9 | latestGameVersion: string; 10 | latestDataDragonVersion: string; 11 | } 12 | interface BladeItem { 13 | title: string; 14 | publishedAt: string; 15 | action: { 16 | type: string; 17 | payload: { 18 | url: string; 19 | } 20 | } 21 | } 22 | export default class VersionChecker { 23 | private bot: Discord.Client; 24 | private sharedSettings: SharedSettings; 25 | private data: VersionCheckerData; 26 | private channel: Discord.TextChannel; 27 | 28 | constructor(bot: Discord.Client, sharedSettings: SharedSettings, dataFile: string) { 29 | console.log("Requested VersionChecker extension.."); 30 | 31 | this.sharedSettings = sharedSettings; 32 | console.log("Successfully loaded VersionChecker settings."); 33 | 34 | this.data = fileBackedObject(dataFile, "www/" + dataFile); 35 | console.log("Successfully loaded VersionChecker data file."); 36 | 37 | this.bot = bot; 38 | this.bot.on("ready", this.onBot.bind(this)); 39 | } 40 | 41 | private async onBot() { 42 | const guild = this.bot.guilds.cache.get(this.sharedSettings.server.guildId); 43 | if (!guild) { 44 | console.error(`VersionChecker: Unable to find server with ID: ${this.sharedSettings.server}`); 45 | return; 46 | } 47 | 48 | let channel = guild.channels.cache.find(c => c.name === this.sharedSettings.forum.channel); 49 | if (!channel || !(channel instanceof Discord.TextChannel)) { 50 | if (this.sharedSettings.botty.isProduction) { 51 | console.error(`VersionChecker: Unable to find external activity channel!`); 52 | return; 53 | } 54 | else { 55 | channel = await guild!.channels.create({name: this.sharedSettings.forum.channel, type: Discord.ChannelType.GuildText }); 56 | } 57 | } 58 | 59 | this.channel = channel as Discord.TextChannel; 60 | console.log("VersionChecker extension loaded."); 61 | this.onUpdate(); 62 | } 63 | 64 | private async updateDataDragonVersion() { 65 | try { 66 | const response = await fetch(`http://ddragon.leagueoflegends.com/api/versions.json`, { 67 | method: "GET", 68 | headers: { 69 | "Accept": "application/json", 70 | "Content-Type": "application/json", 71 | }, 72 | }); 73 | 74 | if (response.status !== 200) console.log("HTTP Error trying to read ddragon version: " + response.status); 75 | 76 | const dataDragonVersion = await response.json(); 77 | 78 | if (dataDragonVersion[0] === this.data.latestDataDragonVersion) { 79 | return; 80 | } 81 | 82 | // new version 83 | // TODO: Maybe check for higher version, denote type of update? (patch/etc) 84 | this.data.latestDataDragonVersion = dataDragonVersion[0]; 85 | const downloadLink = `http://ddragon.leagueoflegends.com/cdn/dragontail-${this.data.latestDataDragonVersion}.tgz`; 86 | 87 | const embed = new Discord.EmbedBuilder() 88 | .setColor(0x42f456) 89 | .setTitle("New DDragon version!") 90 | .setDescription(`Version ${this.data.latestDataDragonVersion} of DDragon has hit the CDN.\nThe download is available here:\n${downloadLink}`) 91 | .setThumbnail(this.sharedSettings.versionChecker.dataDragonThumbnail); 92 | 93 | this.channel.send({ embeds: [embed] }); 94 | } catch (e) { 95 | console.error("Ddragon fetch error: " + e.message); 96 | } 97 | } 98 | 99 | private async updateGameVersion() { 100 | try { 101 | let latestNotesItem: BladeItem; 102 | let lastPostedPatchNotesItem: BladeItem | undefined; 103 | let lastPostedPatchNotes = this.data.latestGameVersion; 104 | let lastPostedPatchNotesDate: Date = new Date(NaN); 105 | let patchNotes: BladeItem[] = []; 106 | const gameUpdatesPage = await fetch("https://www.leagueoflegends.com/en-us/news/game-updates/", { 107 | method: "GET" 108 | }); 109 | const gameUpdatesPageHtml = cheerio.load(await gameUpdatesPage.text()); 110 | let gameUpdatesPageJson = gameUpdatesPageHtml("#__NEXT_DATA__").html(); 111 | 112 | if (!gameUpdatesPage.ok) { 113 | throw new Error(`Got status code ${gameUpdatesPage.status} while trying to get the game updates page`) 114 | } 115 | else if (!gameUpdatesPageJson) { 116 | throw new Error("Failed to find JSON on the LoL game updates page"); 117 | } 118 | let json = JSON.parse(gameUpdatesPageJson); 119 | // Check if we have something that looks like the game updates page 120 | if (!json.props.pageProps.page.blades) { 121 | throw new Error("Got a JSON that doesn't seem to be the game updates page"); 122 | } 123 | // Find which blade has the articles 124 | for (const blade of json.props.pageProps.page.blades) { 125 | if (blade.type == "articleCardGrid") { 126 | patchNotes = blade.items.filter((bladeItem: BladeItem) => bladeItem.title.match(/^Patch ((20)?\d{2}\.S[1-3]\.\d{1,2}|\d{2}\.\d{1,2}) Notes$/i)); 127 | break; 128 | } 129 | } 130 | if (patchNotes && patchNotes.length > 0) { 131 | latestNotesItem = patchNotes.reduce((latest, current) => new Date(current.publishedAt) > new Date(latest.publishedAt) ? current : latest); 132 | lastPostedPatchNotesItem = patchNotes.find(bladeItem => bladeItem.title == `Patch ${lastPostedPatchNotes} Notes`); 133 | if (lastPostedPatchNotesItem) { 134 | lastPostedPatchNotesDate = new Date(lastPostedPatchNotesItem.publishedAt); 135 | } 136 | // Maybe the patch note version is too old to still be on page? 137 | if (isNaN(lastPostedPatchNotesDate.getTime())) { 138 | console.error(`Couldn't find publish date for Patch ${lastPostedPatchNotes}. Latest found title is ${latestNotesItem}, updating latestGameVersion but not making post`); 139 | this.data.latestGameVersion = latestNotesItem.title.split(" ")[1]; 140 | return; 141 | } 142 | if (new Date(latestNotesItem.publishedAt) > lastPostedPatchNotesDate) { 143 | this.data.latestGameVersion = latestNotesItem.title.split(" ")[1]; 144 | const embed = new Discord.EmbedBuilder() 145 | .setColor(0xf442e5) 146 | .setTitle("New League of Legends version!") 147 | .setDescription(`Version ${this.data.latestGameVersion} of League of Legends has posted its patch notes. You can expect the game to update soon.\n\nYou can find the notes here:\nhttps://www.leagueoflegends.com${latestNotesItem.action.payload.url}`) 148 | .setURL("https://www.leagueoflegends.com" + latestNotesItem.action.payload.url) 149 | .setThumbnail(this.sharedSettings.versionChecker.gameThumbnail); 150 | 151 | this.channel.send({ embeds: [embed] }); 152 | } 153 | 154 | } 155 | else { 156 | console.error("Failed to find/parse the JSON on the game update page"); 157 | } 158 | } 159 | catch (e){ 160 | console.error(e); 161 | } 162 | } 163 | 164 | private async onUpdate() { 165 | await this.updateDataDragonVersion(); 166 | await this.updateGameVersion(); 167 | 168 | setTimeout(this.onUpdate.bind(this), this.sharedSettings.versionChecker.checkInterval); 169 | } 170 | 171 | get ddragonVersion(): string { 172 | return this.data.latestDataDragonVersion; 173 | } 174 | 175 | get gameVersion(): string { 176 | return this.data.latestGameVersion; 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import Botty from "./Botty"; 2 | 3 | import ApiStatus from "./ApiStatus"; 4 | import ApiUrlInterpreter from "./ApiUrlInterpreter"; 5 | import AutoReact from "./AutoReact"; 6 | import CommandController from "./CommandController"; 7 | import Info from "./Info"; 8 | import KeyFinder from "./KeyFinder"; 9 | import Logger from "./Logger"; 10 | import RiotAPILibraries from "./RiotAPILibraries"; 11 | import Techblog from "./Techblog"; 12 | import VersionChecker from "./VersionChecker"; 13 | import ESportsAPI from "./ESports"; 14 | import Endpoint from "./Endpoint"; 15 | import PageDiffer from "./PageDiffer"; 16 | import { APISchema } from "./ApiSchema"; 17 | import { CommandList } from "./CommandController"; 18 | import { overrideFileBackedObject, fileBackedObject } from "./FileBackedObject"; 19 | import { SharedSettings } from "./SharedSettings"; 20 | import SpamKiller from "./SpamKiller"; 21 | import Admin from "./Admin"; 22 | import GameData from "./GameData"; 23 | import InteractionManager from "./InteractionManager"; 24 | 25 | // Load and initialise settings 26 | const sharedSettings = overrideFileBackedObject("settings/shared_settings.json", "private/shared_settings.json"); 27 | const commandList = fileBackedObject("settings/command_list.json", "www/data/command_list.json"); 28 | const bot = new Botty(sharedSettings); 29 | 30 | // Load extensions 31 | const interactionManager = new InteractionManager(bot, sharedSettings); 32 | const controller = new CommandController(bot.client, sharedSettings, "data/command_data.json"); 33 | const apiSchema = new APISchema(sharedSettings); 34 | const logger = new Logger(bot.client, sharedSettings); 35 | const keyFinder = new KeyFinder(bot.client, sharedSettings, "data/riot_keys.json"); 36 | const techblog = new Techblog(bot.client, sharedSettings, "data/techblog_data.json"); 37 | const apiUrlInterpreter = new ApiUrlInterpreter(bot.client, sharedSettings, apiSchema); 38 | const versionChecker = new VersionChecker(bot.client, sharedSettings, "data/version_data.json"); 39 | const notes = new Info(bot, interactionManager, sharedSettings, "data/info_data.json", versionChecker); 40 | const admin = new Admin(bot.client, sharedSettings, "data/admin_data.json", notes); 41 | const react = new AutoReact(bot.client, interactionManager, sharedSettings, "data/thinking_data.json", "data/ignored_react_data.json"); 42 | const status = new ApiStatus(sharedSettings); 43 | const libraries = new RiotAPILibraries(sharedSettings); 44 | const esports = new ESportsAPI(bot.client, sharedSettings, interactionManager); 45 | const endpoint = new Endpoint(sharedSettings, "data/endpoints.json"); 46 | const pageDiffer = new PageDiffer(bot.client, sharedSettings, "data/page_differ.json"); 47 | const spamKiller = new SpamKiller(bot.client, sharedSettings); 48 | const gameData = new GameData(bot.client, sharedSettings); 49 | 50 | // Commands controller commands 51 | controller.registerCommand(commandList.controller.toggle, controller.onToggle.bind(controller)); 52 | controller.registerCommand(commandList.controller.help, controller.onHelp.bind(controller)); 53 | 54 | // Botty commands 55 | controller.registerCommand(commandList.botty.restart, bot.onRestart.bind(bot)); 56 | 57 | // gamedata commands 58 | controller.registerCommand(commandList.gamedata.lookup, gameData.onLookup.bind(gameData)); 59 | 60 | // Admin commands 61 | controller.registerCommand(commandList.admin.unmute, admin.onUnmute.bind(admin)); 62 | controller.registerCommand(commandList.admin.mute, admin.onMute.bind(admin)); 63 | controller.registerCommand(commandList.admin.ticket, admin.onTicket.bind(admin)); 64 | controller.registerCommand(commandList.admin.kick, admin.onKick.bind(admin)); 65 | controller.registerCommand(commandList.admin.ban, admin.onBan.bind(admin)); 66 | 67 | // Esport commands 68 | controller.registerCommand(commandList.esports.date, esports.onCheckNext.bind(esports)); 69 | 70 | // API schema commands 71 | controller.registerCommand(commandList.apiSchema.updateSchema, apiSchema.onUpdateSchemaRequest.bind(apiSchema)); 72 | 73 | // Keyfinder commands 74 | controller.registerCommand(commandList.keyFinder, keyFinder.onKeyList.bind(keyFinder)); 75 | 76 | // Info commands 77 | controller.registerCommand(commandList.info.note, notes.onNote.bind(notes)); 78 | controller.registerCommand(commandList.info.all, notes.onAll.bind(notes)); 79 | 80 | // Auto react commands 81 | controller.registerCommand(commandList.autoReact.toggle_default_thinking, react.onToggleDefault.bind(react)); 82 | controller.registerCommand(commandList.autoReact.refresh_thinking, react.onRefreshThinking.bind(react)); 83 | controller.registerCommand(commandList.autoReact.toggle_react, react.onToggleReact.bind(react)); 84 | 85 | // API status commands 86 | controller.registerCommand(commandList.apiStatus, status.onStatus.bind(status)); 87 | 88 | // Riot API libraries commands. 89 | controller.registerCommand(commandList.riotApiLibraries, libraries.onLibs.bind(libraries)); 90 | 91 | // Endpoint commands 92 | controller.registerCommand(commandList.endpointManager.endpoint, endpoint.onEndpoint.bind(endpoint)); 93 | controller.registerCommand(commandList.endpointManager.endpoints, endpoint.onList.bind(endpoint)); 94 | 95 | // start bot 96 | bot.start().catch((reason: any) => { 97 | console.error(`Unable to run botty: ${reason}.`); 98 | }); 99 | -------------------------------------------------------------------------------- /src/vendor/html2plaintext.d.ts: -------------------------------------------------------------------------------- 1 | declare module "html2plaintext" { 2 | 3 | function h2s(url: string): string; 4 | export = h2s; 5 | } -------------------------------------------------------------------------------- /src/vendor/turndown-plugin-gfm.d.ts: -------------------------------------------------------------------------------- 1 | declare module "turndown-plugin-gfm" { 2 | import TurndownService = require("turndown"); 3 | 4 | function gfm(service: TurndownService): void; 5 | 6 | export { gfm as gfm }; 7 | } 8 | -------------------------------------------------------------------------------- /src/vendor/turndown.d.ts: -------------------------------------------------------------------------------- 1 | declare module "turndown" { 2 | class TurndownService { 3 | constructor(options?: Options) 4 | 5 | public addRule(key: string, rule: Rule): this 6 | public keep(filter: Filter): this 7 | public remove(filter: Filter): this 8 | public use(plugins: Plugin | Array): this 9 | 10 | public turndown(html: string | Node): string 11 | } 12 | 13 | interface Options { 14 | headingStyle?: "setext" | "atx" 15 | hr?: string 16 | bulletListMarker?: "-" | "+" | "*" 17 | emDelimiter?: "_" | "*" 18 | codeBlockStyle?: "indented" | "fenced" 19 | fence?: "```" | "~~~" 20 | strongDelimiter?: "__" | "**" 21 | linkStyle?: "inlined" | "referenced" 22 | linkReferenceStyle?: "full" | "collapsed" | "shortcut" 23 | 24 | keepReplacement?: ReplacementFunction 25 | blankReplacement?: ReplacementFunction 26 | defaultReplacement?: ReplacementFunction 27 | } 28 | 29 | interface Rule { 30 | filter: Filter 31 | replacement?: ReplacementFunction 32 | } 33 | 34 | type Plugin = (service: TurndownService) => void 35 | 36 | type Filter = TagName | Array | FilterFunction 37 | type FilterFunction = (node: HTMLElement, options: Options) => boolean 38 | 39 | type ReplacementFunction = ( 40 | content: string, 41 | node: HTMLElement, 42 | options: Options, 43 | ) => string 44 | 45 | type Node = HTMLElement | Document | DocumentFragment 46 | type TagName = keyof HTMLElementTagNameMap 47 | 48 | export = TurndownService; 49 | } -------------------------------------------------------------------------------- /test/GameData.spec.ts: -------------------------------------------------------------------------------- 1 | import Discord = require("discord.js"); 2 | 3 | import { should } from "chai"; 4 | should(); 5 | import * as TypeMoq from "typemoq"; 6 | 7 | import GameData from "../src/GameData"; 8 | import { SharedSettings } from "../src/SharedSettings"; 9 | 10 | describe("GameData", () => { 11 | describe("#sortSearch(...)", () => { 12 | const mockClient = TypeMoq.Mock.ofType(Discord.Client); 13 | mockClient.callBase = true; 14 | mockClient.setup(c => c.on(TypeMoq.It.isAny(), TypeMoq.It.isAny())); 15 | 16 | const mockSettings = TypeMoq.Mock.ofType(); 17 | 18 | const data = new GameData(mockClient.object, mockSettings.object); 19 | 20 | const sortSearchTestHelper: typeof data.sortSearch = ( 21 | search, smaller, larger, 22 | ) => { 23 | const res = data.sortSearch(search, smaller, larger); 24 | res.should.be.lessThan(0); 25 | 26 | const res2 = data.sortSearch(search, larger, smaller); 27 | res2.should.be.greaterThan(0); 28 | 29 | return 0; 30 | }; 31 | 32 | it("should return 0 for equal the same object", () => { 33 | const res = data.sortSearch( 34 | "fgsgfds", 35 | { 36 | item: {id: 1, name: "1"}, 37 | score: 1, 38 | }, 39 | { 40 | item: {id: 1, name: "1"}, 41 | score: 1, 42 | }, 43 | ); 44 | 45 | res.should.equal(0); 46 | }); 47 | 48 | it("should return an object with a score of 0", () => { 49 | const smaller = {item: {id: 1, name: "1"}, score: 0}; 50 | const larger = {item: {id: 2, name: "2"}, score: 1}; 51 | 52 | sortSearchTestHelper("fff", smaller, larger); 53 | }); 54 | 55 | it("should check name for exact match after score", () => { 56 | const equal = {item: {id: 1, name: "AAA"}, score: 1}; 57 | const notEqual = {item: {id: 2, name: "abcaaa"}, score: 1}; 58 | 59 | sortSearchTestHelper("aaa", equal, notEqual); 60 | }); 61 | 62 | it("should check if name starts with search after score", () => { 63 | const equal = {item: {id: 1, name: "ChoGath"}, score: 3}; 64 | const notEqual = {item: {id: 2, name: "dfdfcho"}, score: 3}; 65 | 66 | sortSearchTestHelper("cho", equal, notEqual); 67 | }); 68 | 69 | it("should check if name contains the search string after score", () => { 70 | const equal = {item: {id: 1, name: "ChoGath"}, score: 3}; 71 | const notEqual = {item: {id: 2, name: "ChGth"}, score: 3}; 72 | 73 | sortSearchTestHelper("gath", equal, notEqual); 74 | }); 75 | 76 | it("should check key for exact match after name", () => { 77 | const equal = {item: {id: 1, key: "cho", name: "ff"}, score: 3}; 78 | const notEqual = {item: {id: 2, key: "gath", name: "fdfd"}, score: 3}; 79 | 80 | sortSearchTestHelper("cho", equal, notEqual); 81 | }); 82 | 83 | it("should check if key starts with the search after name", () => { 84 | const equal = {item: {id: 1, key: "chogath", name: "ff"}, score: 3}; 85 | const notEqual = {item: {id: 2, key: "gathcho", name: "fdfd"}, score: 3}; 86 | 87 | sortSearchTestHelper("cho", equal, notEqual); 88 | }); 89 | 90 | it("should check if key contains the search after name", () => { 91 | const equal = {item: {id: 1, key: "chogath", name: "ff"}, score: 3}; 92 | const notEqual = {item: {id: 2, key: "chgth", name: "fdfd"}, score: 3}; 93 | 94 | sortSearchTestHelper("gath", equal, notEqual); 95 | }); 96 | 97 | it("should check id for exact match after name and key", () => { 98 | const equal = {item: {id: 1, key: "abc", name: "ff"}, score: 3}; 99 | const notEqual = {item: {id: 2, key: "def", name: "fdfd"}, score: 3}; 100 | 101 | sortSearchTestHelper("1", equal, notEqual); 102 | }); 103 | 104 | // todo: this seems... dumb AF. Why would you want a partial search by ID...? 105 | it("should check if id starts with search after name and key", () => { 106 | const equal = {item: {id: 12, name: "ff"}, score: 3}; 107 | const notEqual = {item: {id: 22, name: "fdfd"}, score: 3}; 108 | 109 | sortSearchTestHelper("1", equal, notEqual); 110 | }); 111 | 112 | // todo: this seems... dumb AF. Why would you want a partial search by ID...? 113 | it("should check if id contains search after name and key", () => { 114 | const equal = {item: {id: 21, name: "ff"}, score: 3}; 115 | const notEqual = {item: {id: 22, name: "fdfd"}, score: 3}; 116 | 117 | sortSearchTestHelper("1", equal, notEqual); 118 | }); 119 | 120 | it("should check score after checking all other possible matches", () => { 121 | const smaller = {item: {id: 123, name: "ff"}, score: 3}; 122 | const larger = {item: {id: 321, name: "fdfd"}, score: 6}; 123 | 124 | sortSearchTestHelper("hello", smaller, larger); 125 | }); 126 | 127 | it("should alphabetize by name when scores equal", () => { 128 | const smaller = {item: {id: 1, name: "aaabc"}, score: 1}; 129 | const larger = {item: {id: 1, name: "aaaDEF"}, score: 1}; 130 | 131 | sortSearchTestHelper("hello", smaller, larger); 132 | }); 133 | 134 | it("should alphabetize by key when scores and name equal", () => { 135 | const smaller = {item: {id: 1, key: "aaabc", name: "aa"}, score: 1}; 136 | const larger = {item: {id: 1, key: "aaaDEF", name: "aa"}, score: 1}; 137 | 138 | sortSearchTestHelper("hello", smaller, larger); 139 | }); 140 | 141 | it("should order by id when scores and name equal and no key provided", () => { 142 | const smaller = {item: {id: 1, name: "aa"}, score: 1}; 143 | const larger = {item: {id: 2, name: "aa"}, score: 1}; 144 | 145 | sortSearchTestHelper("hello", smaller, larger); 146 | }); 147 | 148 | it("should order by id when scores, name, and key equal", () => { 149 | const smaller = {item: {id: 1, key: "bb", name: "aa"}, score: 1}; 150 | const larger = {item: {id: 2, key: "bb", name: "aa"}, score: 1}; 151 | 152 | sortSearchTestHelper("hello", smaller, larger); 153 | }); 154 | 155 | mockClient.object.destroy(); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "strictNullChecks": true, 5 | "alwaysStrict": true, 6 | "outDir": "dist", 7 | "module": "commonjs", 8 | "target": "es2017", 9 | "sourceMap": true 10 | }, 11 | "include": ["src/**/*"], 12 | "exclude": ["node_modules"] 13 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": {}, 7 | "rules": { 8 | "no-eval": false, 9 | "ordered-imports": false, 10 | "interface-name": false, 11 | "no-empty-interface": false, 12 | "no-console": false, 13 | "no-namespace": false, 14 | "forin": false, 15 | "arrow-parens": false, 16 | "max-classes-per-file": false, 17 | "no-string-literal": false, 18 | "array-type": { 19 | "options": [ 20 | "array" 21 | ] 22 | }, 23 | "object-literal-sort-keys": false, 24 | "curly": false, 25 | "one-line": false, 26 | "max-line-length": { 27 | "options": [ 28 | 280 29 | ] 30 | } 31 | }, 32 | "rulesDirectory": [] 33 | } --------------------------------------------------------------------------------