├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ ├── bug-report.md │ └── crash-report.md ├── CONTRIBUTING.md ├── Dockerfile ├── src ├── config.ts ├── commands │ ├── setloggingroom.ts │ ├── unbridgeroom.ts │ ├── bridgeroom.ts │ ├── deleteuser.ts │ ├── userinfo.ts │ ├── clearstrikes.ts │ ├── unlock.ts │ ├── strike.ts │ ├── lock.ts │ ├── unban.ts │ ├── lint.ts │ ├── unmute.ts │ ├── whosent.ts │ ├── adduser.ts │ ├── kick.ts │ ├── ban.ts │ ├── mute.ts │ ├── handler.ts │ └── discord_handler.ts ├── index.ts ├── lookupUser.ts └── log.ts ├── tsconfig.json ├── graimdb.json.example ├── package.json ├── .gitignore ├── config └── default.yml.example ├── tslint.json ├── README.md ├── setup.md └── LICENSE /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | First off - thank you! 3 | 4 | There are a few small ground rules to contributing to graim: 5 | - format your code with Prettier - if it makes a stupid formatting decision, feel free to add an exception to it 6 | - use TypeScript, not JavaScript 7 | - make sure your contributions do not needlessly complicate other parts of the bot 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14 AS builder 2 | 3 | WORKDIR /src 4 | COPY . /src 5 | 6 | RUN npm ci 7 | RUN npm run build 8 | 9 | FROM node:14 10 | 11 | ENV NODE_ENV=production 12 | WORKDIR /bot 13 | 14 | COPY --from=builder /src/lib /bot/lib 15 | COPY --from=builder /src/package*.json /bot 16 | COPY --from=builder /src/config /bot/config 17 | 18 | RUN npm ci 19 | 20 | VOLUME /bot/config 21 | 22 | CMD ["node", "lib/index.js"] 23 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import * as config from "config"; 2 | 3 | interface IConfig { 4 | prefix: string; 5 | verbose: boolean; 6 | loglevel: string; 7 | homeserverUrl: string; 8 | appserviceHS: string; 9 | accessToken: string; 10 | autoJoin: boolean; 11 | discordToken: string; 12 | discordClient: string; 13 | discordGuild: string; 14 | discordMutedRole: string; 15 | } 16 | 17 | export default config; 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "emitDecoratorMetadata": true, 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "target": "es2015", 8 | "noImplicitAny": false, 9 | "sourceMap": true, 10 | "outDir": "./lib", 11 | "types": [ 12 | "node" 13 | ] 14 | }, 15 | "include": [ 16 | "./src/**/*" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /graimdb.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "rooms": {}, 3 | "logchannel": "", 4 | "mods": { 5 | "example": "Moderator" 6 | }, 7 | "users": [ 8 | { 9 | "name": "example", 10 | "matrix": "example:example.org", 11 | "discord": "966488436041203712", 12 | "strikes": [ 13 | { 14 | "time": "1651195881454", 15 | "action": "kick", 16 | "reason": "No reason specified." 17 | } 18 | ] 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a standard bug 4 | title: "[BUG]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Logs** 24 | Send the output of the bot 25 | 26 | **Desktop (please complete the following information):** 27 | - Node.js version 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/crash-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Crash report 3 | about: Report a crash 4 | title: "[CRASH]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Logs** 18 | If applicable, add logs to help explain your problem. 19 | 20 | **Desktop (please complete the following information):** 21 | - Node.js version 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | 26 | 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graim", 3 | "version": "1.2.3", 4 | "author": "luphoria", 5 | "license": "Apache-2.0", 6 | "description": "matrix <=> discord moderation with the power of matrix-appservice-discord", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/luphoria/graim.git" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/luphoria/graim/issues" 13 | }, 14 | "homepage": "https://gra.im", 15 | "keywords": [ 16 | "matrix", 17 | "bot", 18 | "discord", 19 | "matrix-appservice-discord", 20 | "graim" 21 | ], 22 | "main": "lib/index.js", 23 | "scripts": { 24 | "build": "tsc", 25 | "lint": "tslint --project ./tsconfig.json -t stylish", 26 | "start:dev": "npm run build && node lib/index.js" 27 | }, 28 | "dependencies": { 29 | "config": "^3.3.6", 30 | "discord.js": "^13.6.0", 31 | "escape-html": "^1.0.3", 32 | "js-yaml": "^4.1.0", 33 | "matrix-bot-sdk": "^0.6.0-beta.4" 34 | }, 35 | "devDependencies": { 36 | "@types/node": "^14.18.5", 37 | "tslint": "^6.1.3", 38 | "typescript": "^4.5.4" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config/* 2 | graimdb.json 3 | !config/default.yml.example 4 | lib/ 5 | storage/ 6 | 7 | /.idea 8 | 9 | /db 10 | 11 | # Logs 12 | logs 13 | *.log 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # TypeScript v1 declaration files 50 | typings/ 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Optional REPL history 59 | .node_repl_history 60 | 61 | # Output of 'npm pack' 62 | *.tgz 63 | 64 | # Yarn Integrity file 65 | .yarn-integrity 66 | 67 | # dotenv environment variables file 68 | .env 69 | 70 | # next.js build output 71 | .next 72 | -------------------------------------------------------------------------------- /src/commands/setloggingroom.ts: -------------------------------------------------------------------------------- 1 | // -=- SYNTAX : ;bridgeroom 2 | import { 3 | MatrixClient, 4 | MessageEvent, 5 | MessageEventContent, 6 | } from "matrix-bot-sdk"; 7 | import { db, lookup_user, saveDB } from "../lookupUser"; 8 | import { log } from "../log"; 9 | export async function runSetLoggingRoomCommand( 10 | roomId: string, 11 | event: MessageEvent, 12 | client: MatrixClient, 13 | formatted_body: string 14 | ) { 15 | console.log(formatted_body); 16 | try { 17 | if (!lookup_user(event.sender).moderator) { 18 | return client.sendMessage(roomId, { 19 | body: "You aren't a moderator!", 20 | msgtype: "m.notice", 21 | format: "org.matrix.custom.html", 22 | formatted_body: "You aren't a moderator!", 23 | }); 24 | } 25 | 26 | db.logto = roomId; 27 | saveDB(db); 28 | log( 29 | { 30 | info: "Set logging room", 31 | room: roomId, 32 | caller: event.sender, 33 | }, 34 | false, 35 | client 36 | ); 37 | 38 | return client.sendMessage(roomId, { 39 | body: `Set logging channel to ${roomId}`, 40 | msgtype: "m.notice", 41 | format: "org.matrix.custom.html", 42 | formatted_body: `Set logging channel to ${roomId}`, 43 | }); 44 | } catch (err) { 45 | console.log(err); 46 | return client.sendMessage(roomId, { 47 | body: "Something went wrong running this command", 48 | msgtype: "m.notice", 49 | format: "org.matrix.custom.html", 50 | formatted_body: "Something went wrong running this command", 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /config/default.yml.example: -------------------------------------------------------------------------------- 1 | # The prefix for every command 2 | prefix: ";" 3 | 4 | # Where the homeserver's Client-Server API is located. Typically this 5 | # is where clients would be connecting to in order to send messages. 6 | homeserverUrl: "https://matrix.org" 7 | 8 | # If you turn on the room logging, then this boolean determines 9 | # whether graim will log the more verbose detailed information. 10 | verbose: false 11 | 12 | # The amount of logging in the console output. You shouldn't 13 | # worry about this unless you want it to look pretty. 14 | # Possible values: "DEBUG", "ERROR", "INFO", "TRACE", "WARN" 15 | loglevel: "debug" 16 | 17 | # The homeserver used for matrix-appservice-t2bot. Probably t2bot.io. 18 | appserviceHS: "t2bot.io" 19 | 20 | # An access token for the bot to use. Learn how to get an access token 21 | # at https://t2bot.io/docs/access_tokens 22 | accessToken: "MATRIX_ACCESS_TOKEN" 23 | 24 | # This is the access token for your Discord bot. 25 | discordToken: "DISCORD_ACCESS_TOKEN" 26 | 27 | # This is the client ID for your Discord bot. You can get this from 28 | # the developer panel, or right-click the bot and get "Copy ID". 29 | discordClient: "DISCORD_CLIENT_ID" 30 | 31 | # This is the Discord guild/server that graim will be active in. 32 | discordGuild: "DISCORD_GUILD_ID" 33 | 34 | # This is the role on Discord, presumably that doesn't let users speak 35 | discordMutedRole: "DISCORD_ROLE_ID" 36 | 37 | # Whether or not to autojoin rooms when invited. 38 | # Set this to true when you are initially setting up your bot, but 39 | # aside from that, you probably want it set to false. 40 | autoJoin: false 41 | -------------------------------------------------------------------------------- /src/commands/unbridgeroom.ts: -------------------------------------------------------------------------------- 1 | // -=- SYNTAX : ;unbridgeroom 2 | import { 3 | MatrixClient, 4 | MessageEvent, 5 | MessageEventContent, 6 | } from "matrix-bot-sdk"; 7 | import { db, lookup_user, saveDB } from "../lookupUser"; 8 | import { log } from "../log"; 9 | export async function runUnbridgeCommand( 10 | roomId: string, 11 | event: MessageEvent, 12 | client: MatrixClient, 13 | formatted_body: string 14 | ) { 15 | console.log(formatted_body); 16 | try { 17 | if (!lookup_user(event.sender).moderator) { 18 | return client.sendMessage(roomId, { 19 | body: "You aren't a moderator!", 20 | msgtype: "m.notice", 21 | format: "org.matrix.custom.html", 22 | formatted_body: "You aren't a moderator!", 23 | }); 24 | } 25 | 26 | delete db.rooms[roomId]; // sync channel and room 27 | saveDB(db); 28 | log( 29 | { 30 | info: "Unbridged room from channel", 31 | room: roomId, 32 | caller: event.sender, 33 | }, 34 | false, 35 | client 36 | ); 37 | return client.sendMessage(roomId, { 38 | body: `Removed bridge for ${roomId}`, 39 | msgtype: "m.notice", 40 | format: "org.matrix.custom.html", 41 | formatted_body: `Removed bridge for ${roomId}`, 42 | }); 43 | } catch (err) { 44 | console.log(err); 45 | return client.sendMessage(roomId, { 46 | body: "Something went wrong running this command", 47 | msgtype: "m.notice", 48 | format: "org.matrix.custom.html", 49 | formatted_body: "Something went wrong running this command", 50 | }); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AutojoinRoomsMixin, 3 | LogLevel, 4 | LogService, 5 | MatrixClient, 6 | RichConsoleLogger, 7 | } from "matrix-bot-sdk"; 8 | import config from "./config"; 9 | import CommandHandler from "./commands/handler"; 10 | 11 | // First things first: let's make the logs a bit prettier. 12 | LogService.setLogger(new RichConsoleLogger()); 13 | 14 | // For now let's also make sure to log everything (for debugging) 15 | LogService.setLevel(LogLevel[config.loglevel] || LogLevel.DEBUG); 16 | 17 | // Also let's mute Metrics, so we don't get *too* much noise 18 | LogService.muteModule("Metrics"); 19 | 20 | // Print something so we know the bot is working 21 | LogService.info("index", "Bot starting..."); 22 | 23 | // Prevent graim from responding to old messages w/sync 24 | export const startedWhen = Date.now(); 25 | 26 | process.on('unhandledRejection', (err) => { 27 | console.error(err); 28 | }); 29 | 30 | // This is the startup closure where we give ourselves an async context 31 | (async function () { 32 | // Now create the client 33 | const client = new MatrixClient(config.homeserverUrl, config.accessToken); 34 | 35 | // Set up autojoin 36 | if (config.autoJoin) { 37 | // Not using the in-SDK solution, so that I can also join all rooms in a space. 38 | client.on("room.invite", async (roomId) => { 39 | // Join room 40 | await client.joinRoom(roomId).then(async () => { 41 | // Grab room state event and filter just the space children 42 | (await client.getRoomState(roomId)) 43 | .filter((ev) => ev["type"] == "m.space.child") 44 | .forEach((spaceChild) => { 45 | // Join each child 46 | return client.joinRoom( 47 | spaceChild["state_key"], 48 | spaceChild["content"]["via"] 49 | ); 50 | }); 51 | }); 52 | }); 53 | } 54 | 55 | // Prepare the command handler 56 | const commands = new CommandHandler(client); 57 | await commands.start(); 58 | 59 | LogService.info("index", "Starting sync..."); 60 | await client.start(); // This blocks until the bot is killed 61 | })(); 62 | -------------------------------------------------------------------------------- /src/commands/bridgeroom.ts: -------------------------------------------------------------------------------- 1 | // -=- SYNTAX : ;bridgeroom 2 | import { 3 | MatrixClient, 4 | MessageEvent, 5 | MessageEventContent, 6 | } from "matrix-bot-sdk"; 7 | import { db, lookup_user, saveDB } from "../lookupUser"; 8 | import { COMMAND_PREFIX } from "./handler"; 9 | import { log } from "../log"; 10 | export async function runBridgeCommand( 11 | roomId: string, 12 | event: MessageEvent, 13 | args: string[], 14 | client: MatrixClient, 15 | formatted_body: string 16 | ) { 17 | console.log(formatted_body); 18 | try { 19 | if (!lookup_user(event.sender).moderator) { 20 | return client.sendMessage(roomId, { 21 | body: "You aren't a moderator!", 22 | msgtype: "m.notice", 23 | format: "org.matrix.custom.html", 24 | formatted_body: "You aren't a moderator!", 25 | }); 26 | } 27 | 28 | if (!args[1]) { 29 | // they did not reply with at least the full number of required args 30 | return client.sendMessage(roomId, { 31 | body: "Usage: " + COMMAND_PREFIX + "bridgeroom ", 32 | msgtype: "m.notice", 33 | format: "org.matrix.custom.html", 34 | formatted_body: 35 | "Usage: " + COMMAND_PREFIX + "bridgeroom <channel id>", 36 | }); 37 | } 38 | 39 | db.rooms[roomId] = args[1]; // sync channel and room 40 | saveDB(db); 41 | log( 42 | { 43 | info: "Bridged room to channel", 44 | room: roomId, 45 | channel: args[1], 46 | caller: event.sender, 47 | }, 48 | false, 49 | client 50 | ); 51 | 52 | return client.sendMessage(roomId, { 53 | body: `Bridged ${roomId} to ${args[1]}`, 54 | msgtype: "m.notice", 55 | format: "org.matrix.custom.html", 56 | formatted_body: `Bridged ${roomId} to ${args[1]}`, 57 | }); 58 | } catch (err) { 59 | console.log(err); 60 | return client.sendMessage(roomId, { 61 | body: "Something went wrong running this command", 62 | msgtype: "m.notice", 63 | format: "org.matrix.custom.html", 64 | formatted_body: "Something went wrong running this command", 65 | }); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "class-name": false, 4 | "comment-format": [ 5 | true 6 | ], 7 | "curly": false, 8 | "eofline": false, 9 | "forin": false, 10 | "indent": [ 11 | true, 12 | "spaces" 13 | ], 14 | "label-position": true, 15 | "max-line-length": false, 16 | "member-access": false, 17 | "member-ordering": [ 18 | true, 19 | "static-after-instance", 20 | "variables-before-functions" 21 | ], 22 | "no-arg": true, 23 | "no-bitwise": false, 24 | "no-console": false, 25 | "no-construct": true, 26 | "no-debugger": true, 27 | "no-duplicate-variable": true, 28 | "no-empty": false, 29 | "no-eval": true, 30 | "no-inferrable-types": true, 31 | "no-shadowed-variable": true, 32 | "no-string-literal": false, 33 | "no-switch-case-fall-through": true, 34 | "no-trailing-whitespace": true, 35 | "no-unused-expression": true, 36 | "no-use-before-declare": false, 37 | "no-var-keyword": true, 38 | "object-literal-sort-keys": false, 39 | "one-line": [ 40 | true, 41 | "check-open-brace", 42 | "check-catch", 43 | "check-else", 44 | "check-whitespace" 45 | ], 46 | "quotemark": false, 47 | "radix": true, 48 | "semicolon": [ 49 | "always" 50 | ], 51 | "triple-equals": [], 52 | "typedef-whitespace": [ 53 | true, 54 | { 55 | "call-signature": "nospace", 56 | "index-signature": "nospace", 57 | "parameter": "nospace", 58 | "property-declaration": "nospace", 59 | "variable-declaration": "nospace" 60 | } 61 | ], 62 | "variable-name": false, 63 | "whitespace": [ 64 | true, 65 | "check-branch", 66 | "check-decl", 67 | "check-operator", 68 | "check-separator", 69 | "check-type" 70 | ] 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/commands/deleteuser.ts: -------------------------------------------------------------------------------- 1 | // -=- SYNTAX : ;deleteuser 2 | import { 3 | MatrixClient, 4 | MessageEvent, 5 | MessageEventContent, 6 | } from "matrix-bot-sdk"; 7 | import * as htmlEscape from "escape-html"; 8 | import { db, lookup_user, saveDB } from "../lookupUser"; 9 | import { COMMAND_PREFIX } from "./handler"; 10 | import { log } from "../log"; 11 | export async function runDeleteUserCommand( 12 | roomId: string, 13 | event: MessageEvent, 14 | args: string[], 15 | client: MatrixClient, 16 | formatted_body: string 17 | ) { 18 | console.log(formatted_body); 19 | 20 | if (!lookup_user(event.sender).moderator) { 21 | return client.sendMessage(roomId, { 22 | body: "You aren't a moderator!", 23 | msgtype: "m.notice", 24 | format: "org.matrix.custom.html", 25 | formatted_body: "You aren't a moderator!", 26 | }); 27 | } 28 | 29 | if (!args[1]) { 30 | return client.sendMessage(roomId, { 31 | body: "Usage: " + COMMAND_PREFIX + "deleteuser ", 32 | msgtype: "m.notice", 33 | format: "org.matrix.custom.html", 34 | formatted_body: 35 | "Usage: " + COMMAND_PREFIX + "deleteuser <graim_name>", 36 | }); 37 | } 38 | 39 | let lookup = lookup_user(args[1]); 40 | 41 | if (!lookup.graim_name) { 42 | return client.sendMessage(roomId, { 43 | body: "User " + args[1] + " doesn't seem to exist..", 44 | msgtype: "m.notice", 45 | format: "org.matrix.custom.html", 46 | formatted_body: 47 | "User " + htmlEscape(args[1]) + " doesn't seem to exist..", 48 | }); 49 | } 50 | 51 | delete db.users[lookup.graim_name]; 52 | if (lookup.moderator) delete db.mods[lookup.graim_name]; 53 | saveDB(db); 54 | 55 | log( 56 | { 57 | info: "Deleted user", 58 | user: lookup.graim_name, 59 | moderator: lookup.moderator, 60 | caller: event.sender, 61 | }, 62 | false, 63 | client 64 | ); 65 | 66 | return client.sendMessage(roomId, { 67 | body: `Successfully removed ${lookup.graim_name} from the graim db!`, 68 | msgtype: "m.notice", 69 | format: "org.matrix.custom.html", 70 | formatted_body: `Successfully removed ${htmlEscape( 71 | lookup.graim_name 72 | )} from the graim db!`, 73 | }); 74 | } 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graim 2 | matrix <=> discord moderation with the power of matrix-appservice-discord 3 | 4 | ## Social 5 | graim's website is viewable at [gra.im](https://gra.im). 6 | You can join the graim [Matrix space](https://matrix.to/#/#graim:gra.im) as well as the [Discord server](https://discord.gg/MV7fDb4AKy). *the main matrix room is @ [#general:gra.im](https://matrix.to/#/#general:gra.im) 7 | 8 | ## Configure/Install 9 | Check out the [configuration guide](./setup.md). 10 | 11 | ## TODOs 12 | - Bug fixing 13 | - Better guides 14 | 15 | ## Features 16 | - moderation syncs across rooms 17 | - Kick, ban + unban 18 | - Mute 19 | - Strike system 20 | - Automatically attribute moderation history to graimdb 21 | - Discord moderators may use `whosent` to discover what matrix user is behind a Discord message 22 | - Userinfo command 23 | - Database management commands (add/remove user, add/remove moderator) 24 | 25 | 26 | ## How 27 | graim is built with the intention of being Matrix-first. There are a few reasons for this: 28 | - Discord API sucks 29 | - Discord webhooks have incredibly little data attributed to them 30 | - I <3 Matrix 31 | 32 | Every Discord user, via matrix-appservice-discord, is given its own user (i.e. `@_discord_:matrix.org`. So, graim listens for commands only from Matrix users - because Discord users are Matrix users by proxy. 33 | Simply tie a server to a group of rooms, then tie each Matrix user to a Discord account packaged in one "graim user" :D 34 | 35 | ## Credits 36 | The bot itself - [luphoria](https://luphoria.com) 37 | 38 | Any other [contributors](https://github.com/luphoria/graim/contributors) 39 | 40 | Built with **major help** from [turt2live/matrix-bot-sdk-bot-template](https://github.com/turt2live/matrix-bot-sdk-bot-template) by Travis Ralston 41 | 42 | Uses the dependencies: 43 | - [matrix-bot-sdk](https://github.com/turt2live/matrix-bot-sdk) 44 | - [discord.js](https://discord.js.org/) 45 | - [escape-html](https://github.com/component/escape-html) 46 | - [config](https://github.com/lorenwest/node-config) 47 | - [js-yaml](https://github.com/nodeca/js-yaml) 48 | 49 | Special thanks: 50 | - **HalfShot** for **[matrix-appservice-discord](https://github.com/Half-Shot/matrix-appservice-discord)** for the Discord <-> Matrix bridge itself 51 | - **[Travis Ralston](https://github.com/turt2live)** for the tons of free resources which I used a LOT of 52 | -------------------------------------------------------------------------------- /src/commands/userinfo.ts: -------------------------------------------------------------------------------- 1 | // -=- SYNTAX : ;userinfo 2 | import { 3 | MatrixClient, 4 | MessageEvent, 5 | MessageEventContent, 6 | } from "matrix-bot-sdk"; 7 | import * as htmlEscape from "escape-html"; 8 | import { lookup_user, mentionPillFor } from "../lookupUser"; 9 | import { log } from "../log"; 10 | export async function runUserinfoCommand( 11 | roomId: string, 12 | event: MessageEvent, 13 | args: string[], 14 | client: MatrixClient, 15 | formatted_body: string 16 | ) { 17 | let user = args[1]; 18 | 19 | if (!args[1]) { 20 | user = event.sender; 21 | } 22 | 23 | if (formatted_body) { 24 | if (formatted_body.includes('') 30 | ) || user; 31 | } 32 | } 33 | 34 | let lookup = lookup_user(user); 35 | 36 | if (!lookup.graim_name) { 37 | // not in graim db 38 | return client.sendMessage(roomId, { 39 | body: "I don't think that user is in the graim database!", 40 | msgtype: "m.notice", 41 | format: "org.matrix.custom.html", 42 | formatted_body: "I don't think that user is in the graim database!", 43 | }); 44 | } 45 | 46 | console.log(lookup); 47 | 48 | let modOnlyLookup = ""; 49 | if (lookup_user(event.sender).moderator) { 50 | if (lookup.strikes.length > 0) { 51 | modOnlyLookup = "\n\nStrikes:\n"; 52 | for (let i = 0; i < lookup.strikes.length; i++) { 53 | modOnlyLookup += 54 | new Date(lookup.strikes[i]["time"]).toLocaleDateString() + 55 | ": " + 56 | lookup.strikes[i]["reason"] + 57 | " [" + 58 | lookup.strikes[i]["action"] + 59 | "]\n"; 60 | } 61 | } 62 | } 63 | 64 | return client.sendMessage(roomId, { 65 | body: `User: ${lookup.graim_name} 66 | Matrix: ${lookup.user_matrix} 67 | Discord: ${lookup.user_discord} (${lookup.user_discord}) 68 | Moderator? ${lookup.moderator ? "Yes" : "No"}${modOnlyLookup}`, 69 | msgtype: "m.notice", 70 | format: "org.matrix.custom.html", 71 | formatted_body: `User: ${lookup.graim_name} 72 | Matrix: ${htmlEscape(lookup.user_matrix)} 73 | Discord: ${(await mentionPillFor(lookup.user_discord)).html} (${htmlEscape( 74 | lookup.user_discord 75 | )}) 76 | Moderator? ${lookup.moderator ? "Yes" : "No"}${htmlEscape(modOnlyLookup)}`, 77 | }); 78 | } 79 | -------------------------------------------------------------------------------- /src/commands/clearstrikes.ts: -------------------------------------------------------------------------------- 1 | // -=- SYNTAX : ;strike [reason] 2 | import { 3 | MatrixClient, 4 | MessageEvent, 5 | MessageEventContent, 6 | } from "matrix-bot-sdk"; 7 | import * as htmlEscape from "escape-html"; 8 | import { lookup_user, db, saveDB } from "../lookupUser"; 9 | const util = require("util"); 10 | import { log } from "../log"; 11 | export async function runClearStrikesCommand( 12 | roomId: string, 13 | event: MessageEvent, 14 | args: string[], 15 | client: MatrixClient, 16 | formatted_body: string 17 | ) { 18 | if (!lookup_user(event.sender).moderator) { 19 | return client.sendMessage(roomId, { 20 | body: "You aren't a moderator!", 21 | msgtype: "m.notice", 22 | format: "org.matrix.custom.html", 23 | formatted_body: "You aren't a moderator!", 24 | }); 25 | } 26 | 27 | let user = args[1] || ""; 28 | if (formatted_body) { 29 | // sanity check - MentionPill cannot exist without a formatted body 30 | if (formatted_body.includes('') 35 | ) || user; 36 | } 37 | } 38 | 39 | let lookup = lookup_user(user); 40 | 41 | if (!lookup.graim_name) { 42 | // not in graim db 43 | return client.sendMessage(roomId, { 44 | body: "I don't think that user is in the graim database!", 45 | msgtype: "m.notice", 46 | format: "org.matrix.custom.html", 47 | formatted_body: "I don't think that user is in the graim database!", 48 | }); 49 | } 50 | 51 | console.log( 52 | util.inspect( 53 | db.users.filter((dbuser) => { 54 | return dbuser.name == lookup.graim_name; 55 | }), 56 | true, 57 | null, 58 | true 59 | ) 60 | ); 61 | 62 | db.users.filter((dbuser) => { 63 | return dbuser.name == lookup.graim_name; 64 | })[0].strikes = []; 65 | 66 | saveDB(db); 67 | 68 | log( 69 | { 70 | info: "Cleared user strikes", 71 | user: lookup.graim_name, 72 | caller: event.sender, 73 | }, 74 | false, 75 | client 76 | ); 77 | let strikes = db.users.filter((dbuser) => { 78 | return dbuser.name == lookup.graim_name; 79 | })[0].strikes; 80 | 81 | return client.sendMessage(roomId, { 82 | body: `Cleared all strikes for ${lookup.graim_name}.\nCurrent strike count: ${strikes.length}`, 83 | msgtype: "m.notice", 84 | format: "org.matrix.custom.html", 85 | formatted_body: `Cleared all strikes for ${lookup.graim_name}.
Current strike count: ${strikes.length}`, 86 | }); 87 | } 88 | -------------------------------------------------------------------------------- /src/commands/unlock.ts: -------------------------------------------------------------------------------- 1 | // -=- SYNTAX : ;unlock [room] 2 | import { 3 | MatrixClient, 4 | MessageEvent, 5 | MessageEventContent, 6 | } from "matrix-bot-sdk"; 7 | import { lookup_user, db } from "../lookupUser"; 8 | import { guild } from "./discord_handler"; 9 | import { log } from "../log"; 10 | export async function runUnlockCommand( 11 | roomId: string, 12 | event: MessageEvent, 13 | args: string[], 14 | client: MatrixClient, 15 | formatted_body: string 16 | ) { 17 | if (!lookup_user(event.sender).moderator) { 18 | return client.sendMessage(roomId, { 19 | body: "You aren't a moderator!", 20 | msgtype: "m.notice", 21 | format: "org.matrix.custom.html", 22 | formatted_body: "You aren't a moderator!", 23 | }); 24 | } 25 | 26 | let commandString = args.join(" "); 27 | let cmd; 28 | let channel = null; 29 | let room = roomId; 30 | 31 | if (args[1]) { 32 | commandString = formatted_body.replace( 33 | /
(.*?)<\/a>/g, 34 | "" 35 | ); 36 | cmd = commandString.split(" "); 37 | room = (await client.resolveRoom("#" + cmd[1])) || null; 38 | console.log(cmd); 39 | if (!cmd[1].indexOf("_discord_")) { 40 | cmd[1] = Object.keys(db.rooms).find( 41 | (key) => db.rooms[key] === cmd[1].substring(28, 46) 42 | ); 43 | room = (await client.resolveRoom(cmd[1])) || null; 44 | } 45 | } 46 | channel = db.rooms[room] || null; 47 | console.log(channel); 48 | console.log(room); 49 | let error = false; 50 | 51 | let power_levels = await client 52 | .getRoomStateEvent(room, "m.room.power_levels", "") 53 | .catch((err) => { 54 | console.log(err); 55 | error = true; 56 | }); 57 | if (error) { 58 | return client.sendMessage(roomId, { 59 | body: "Something went wrong", 60 | msgtype: "m.notice", 61 | format: "org.matrix.custom.html", 62 | formatted_body: "Something went wrong", 63 | }); 64 | } 65 | power_levels["events_default"] = 0; // default 66 | client 67 | .sendStateEvent(room, "m.room.power_levels", "", power_levels) 68 | .catch((err) => console.log(err)); 69 | 70 | if (channel) { 71 | channel = guild.channels.cache.get(channel); 72 | channel.permissionOverwrites 73 | .edit(channel.guild.id, { 74 | SEND_MESSAGES: true, 75 | ATTACH_FILES: true, 76 | }) 77 | .catch((err) => console.log(err)); 78 | } 79 | 80 | log( 81 | { 82 | info: "Unlocked room", 83 | channel: channel || null, 84 | room: room, 85 | caller: event.sender, 86 | }, 87 | false, 88 | client 89 | ); 90 | 91 | return client.sendMessage(room, { 92 | body: "Channel is unlocked. :)", 93 | msgtype: "m.notice", 94 | format: "org.matrix.custom.html", 95 | formatted_body: "Channel is unlocked. :)", 96 | }); 97 | } 98 | -------------------------------------------------------------------------------- /src/commands/strike.ts: -------------------------------------------------------------------------------- 1 | // -=- SYNTAX : ;strike [reason] 2 | import { 3 | MatrixClient, 4 | MessageEvent, 5 | MessageEventContent, 6 | } from "matrix-bot-sdk"; 7 | import * as htmlEscape from "escape-html"; 8 | import { lookup_user, db, saveDB } from "../lookupUser"; 9 | import { log } from "../log"; 10 | const util = require("util"); 11 | 12 | export async function runStrikeUserCommand( 13 | roomId: string, 14 | event: MessageEvent, 15 | args: string[], 16 | client: MatrixClient, 17 | formatted_body: string 18 | ) { 19 | if (!lookup_user(event.sender).moderator) { 20 | return client.sendMessage(roomId, { 21 | body: "You aren't a moderator!", 22 | msgtype: "m.notice", 23 | format: "org.matrix.custom.html", 24 | formatted_body: "You aren't a moderator!", 25 | }); 26 | } 27 | 28 | let user = args[1] || ""; 29 | if (formatted_body) { 30 | // sanity check - MentionPill cannot exist without a formatted body 31 | if (formatted_body.includes('') 36 | ) || user; 37 | } 38 | } 39 | 40 | let lookup = lookup_user(user); 41 | 42 | if (!lookup.graim_name) { 43 | // not in graim db 44 | return client.sendMessage(roomId, { 45 | body: "I don't think that user is in the graim database!", 46 | msgtype: "m.notice", 47 | format: "org.matrix.custom.html", 48 | formatted_body: "I don't think that user is in the graim database!", 49 | }); 50 | } 51 | 52 | let reason = args.slice(2).join(" ") || "No reason specified."; 53 | 54 | console.log( 55 | util.inspect( 56 | db.users.filter((dbuser) => { 57 | return dbuser.name == lookup.graim_name; 58 | }), 59 | true, 60 | null, 61 | true 62 | ) 63 | ); 64 | 65 | db.users 66 | .filter((dbuser) => { 67 | return dbuser.name == lookup.graim_name; 68 | })[0] 69 | .strikes.push({ 70 | time: Date.now(), 71 | action: "strike", 72 | reason: reason, 73 | }); 74 | 75 | saveDB(db); 76 | 77 | let strikes = db.users.filter((dbuser) => { 78 | return dbuser.name == lookup.graim_name; 79 | })[0].strikes; 80 | 81 | log( 82 | { 83 | info: "Striked user", 84 | user: lookup.graim_name, 85 | reason: htmlEscape(reason), 86 | caller: event.sender, 87 | }, 88 | false, 89 | client 90 | ); 91 | 92 | return client.sendMessage(roomId, { 93 | body: `Striked ${lookup.graim_name}: ${reason}.\nCurrent strike count: ${strikes.length}`, 94 | msgtype: "m.notice", 95 | format: "org.matrix.custom.html", 96 | formatted_body: `Striked ${lookup.graim_name}: ${htmlEscape( 97 | reason 98 | )}.
Current strike count: ${strikes.length}`, 99 | }); 100 | } 101 | -------------------------------------------------------------------------------- /src/lookupUser.ts: -------------------------------------------------------------------------------- 1 | import { MentionPill } from "matrix-bot-sdk"; 2 | import config from "./config"; 3 | const file = require("fs"); 4 | export let db = JSON.parse(file.readFileSync("./graimdb.json")); 5 | if (!db.rooms) db.rooms = {}; 6 | if (!db.mods) db.mods = {}; 7 | if (!db.users) db.users = []; 8 | if (!db.logto) db.logto = ""; 9 | 10 | export const saveDB = (json) => { 11 | // Overwrite graimdb.json 12 | file.writeFileSync("./graimdb.json", JSON.stringify(json)); 13 | }; 14 | 15 | export const user_discordId = (user: string) => { 16 | // Returns a valid Discord ID if the Matrix ID was a bridged Discord user 17 | user = user.replace("@", ""); 18 | if ( 19 | !isNaN(+user.split(":")[0].substring(9)) && // !isNaN(+"string") makes sure that the substring is a number (like a snowflake ID). 20 | user.split(":")[0].substring(9).length >= 18 && 21 | user.split(":")[1] == config.appserviceHS // makes sure that the Matrix ID is based in the correct homeserver 22 | ) { 23 | return user.split(":")[0].substring(9); 24 | } 25 | 26 | if (!isNaN(+user) && user.length >= 18) { 27 | return user; 28 | } 29 | return null; 30 | }; 31 | 32 | export const mentionPillFor = async (user: string) => { 33 | console.log(user); // "@luphoria:matrix.org" 34 | 35 | if (!isNaN(+user) && user.length >= 18) { 36 | // user is a Discord ID 37 | user = "_discord_" + user + ":" + config.appserviceHS; // recreate Discord MentionPill 38 | } else if (!user.includes(":")) { 39 | user = lookup_user(user).user_matrix || user; 40 | } 41 | 42 | user = "@" + user.replace("@", ""); // ensures that there is ONE @ at the beginning 43 | 44 | let mention = await MentionPill.forUser(user); 45 | return mention; 46 | }; 47 | 48 | export const lookup_user = (name: string) => { 49 | // Reverses a user from graim's db based on any format data given from the user 50 | let graim_name: string; 51 | let user_matrix: string; 52 | let user_discord: string; 53 | let moderator: boolean; 54 | let strikes: []; 55 | 56 | // Is there any reason it prioritizes matrix -> discord -> graim? 57 | db.users.forEach((_user) => { 58 | // iterate through all db users 59 | if (_user.name == name) { 60 | // the name must have been a Graim identifier 61 | console.log("Graim user FOUND in db"); 62 | graim_name = _user.name; 63 | } else if ( 64 | user_discordId(name) == _user.discord 65 | ) { 66 | // the name must have been a Discord ID 67 | console.log("Discord user FOUND in db"); 68 | graim_name = _user.name; 69 | } else if (_user.matrix == name.replace("@", "")) { 70 | // the name must have been a Matrix ID 71 | console.log("Matrix user FOUND in db"); 72 | graim_name = _user.name; 73 | } else { 74 | return; 75 | } 76 | 77 | user_matrix = "@" + _user.matrix; 78 | user_discord = _user.discord; 79 | strikes = _user.strikes; 80 | }); 81 | 82 | moderator = db.mods[graim_name] ? true : false; // if the username is in the moderator list, it is a moderator. 83 | 84 | return { 85 | graim_name: graim_name, 86 | user_matrix: user_matrix, 87 | user_discord: user_discord, 88 | moderator: moderator, 89 | strikes: strikes, 90 | }; 91 | }; 92 | -------------------------------------------------------------------------------- /src/commands/lock.ts: -------------------------------------------------------------------------------- 1 | // -=- SYNTAX : ;lock [room] 2 | import { 3 | MatrixClient, 4 | MessageEvent, 5 | MessageEventContent, 6 | } from "matrix-bot-sdk"; 7 | import { lookup_user, db } from "../lookupUser"; 8 | import { guild } from "./discord_handler"; 9 | import { COMMAND_PREFIX } from "./handler"; 10 | import { log } from "../log"; 11 | export async function runLockCommand( 12 | roomId: string, 13 | event: MessageEvent, 14 | args: string[], 15 | client: MatrixClient, 16 | formatted_body: string 17 | ) { 18 | if (!lookup_user(event.sender).moderator) { 19 | return client.sendMessage(roomId, { 20 | body: "You aren't a moderator!", 21 | msgtype: "m.notice", 22 | format: "org.matrix.custom.html", 23 | formatted_body: "You aren't a moderator!", 24 | }); 25 | } 26 | 27 | let commandString = args.join(" "); 28 | let cmd; 29 | let channel = null; 30 | let room = roomId; 31 | 32 | if (args[1]) { 33 | commandString = formatted_body.replace( 34 | /
(.*?)<\/a>/g, 35 | "" 36 | ); 37 | cmd = commandString.split(" "); 38 | room = (await client.resolveRoom("#" + cmd[1])) || null; 39 | console.log(cmd); 40 | if (!cmd[1].indexOf("_discord_")) { 41 | cmd[1] = Object.keys(db.rooms).find( 42 | (key) => db.rooms[key] === cmd[1].substring(28, 46) 43 | ); 44 | room = (await client.resolveRoom(cmd[1])) || null; 45 | } 46 | } 47 | 48 | channel = db.rooms[room] || null; 49 | console.log(channel); 50 | console.log(room); 51 | let error = false; 52 | 53 | let power_levels = await client 54 | .getRoomStateEvent(room, "m.room.power_levels", "") 55 | .catch((err) => { 56 | console.log(err); 57 | error = true; 58 | }); 59 | if (error) { 60 | return client.sendMessage(roomId, { 61 | body: "Something went wrong", 62 | msgtype: "m.notice", 63 | format: "org.matrix.custom.html", 64 | formatted_body: "Something went wrong", 65 | }); 66 | } 67 | power_levels["events_default"] = 2; // higher than default 68 | client 69 | .sendStateEvent(room, "m.room.power_levels", "", power_levels) 70 | .catch((err) => console.log(err)); 71 | 72 | let warn = ""; 73 | 74 | if (channel) { 75 | channel = guild.channels.cache.get(channel); 76 | channel.permissionOverwrites 77 | .edit(channel.guild.id, { 78 | SEND_MESSAGES: false, 79 | ATTACH_FILES: false, 80 | }) 81 | .catch((err) => console.log(err)); 82 | } else { 83 | warn = 84 | "\nNOTE: This room is not bridged in graim! For the lock to propagate to Discord, it must be attached to a Discord channel. See " + 85 | COMMAND_PREFIX + 86 | "bridgeroom."; 87 | } 88 | 89 | log( 90 | { 91 | info: "Locked room", 92 | channel: channel || null, 93 | room: room, 94 | caller: event.sender, 95 | }, 96 | false, 97 | client 98 | ); 99 | 100 | return client.sendMessage(room, { 101 | body: "Channel is locked. :(" + warn, 102 | msgtype: "m.notice", 103 | format: "org.matrix.custom.html", 104 | formatted_body: "Channel is locked. :(" + warn, 105 | }); 106 | } 107 | -------------------------------------------------------------------------------- /src/log.ts: -------------------------------------------------------------------------------- 1 | import config from "./config"; 2 | import { MatrixClient } from "matrix-bot-sdk"; 3 | import { db, lookup_user, mentionPillFor } from "./lookupUser"; 4 | 5 | export const log = async (log: {}, verbose: boolean, client: MatrixClient) => { 6 | try { 7 | if (!db.logto) return false; 8 | let toLog = "'~' '~' '~' '~' '~' '~' '~'\n\n"; 9 | if (verbose) { 10 | if (config.verbose) { 11 | for (let i = 0; i < Object.keys(log).length; i++) { 12 | let act = Object.keys(log)[i]; 13 | switch (act) { 14 | case "info": 15 | toLog += "" + log[act] + "\n"; 16 | break; 17 | case "caller": 18 | toLog += 19 | "Sent by " + 20 | (lookup_user(log[act]).graim_name + " (" + log[act] + ")" || 21 | log[act]) + 22 | "\n"; 23 | break; 24 | case "user": 25 | await mentionPillFor(log[act]).then((pill) => { 26 | toLog += "user: " + pill.html + " (" + log[act] + ")\n"; 27 | console.log("-------\n" + toLog); 28 | }); 29 | break; 30 | default: 31 | toLog += act + ": " + log[act] + "\n"; 32 | break; 33 | } 34 | } 35 | toLog += "\n'~' '~' '~' '~' '~' '~' '~'"; 36 | console.log(toLog); 37 | console.log( 38 | toLog.replace( 39 | /|<\/b>|(.*?)<\/a>/g, 40 | "" 41 | ) 42 | ); 43 | return client.sendMessage(db.logto, { 44 | body: toLog.replace( 45 | /|<\/b>|(.*?)<\/a>/g, 46 | "" 47 | ), 48 | msgtype: "m.notice", 49 | format: "org.matrix.custom.html", 50 | formatted_body: toLog, 51 | }); 52 | } 53 | } else { 54 | for (let i = 0; i < Object.keys(log).length; i++) { 55 | let act = Object.keys(log)[i]; 56 | switch (act) { 57 | case "info": 58 | toLog += "" + log[act] + "\n"; 59 | break; 60 | case "caller": 61 | toLog += 62 | "Sent by " + 63 | (lookup_user(log[act]).graim_name + " (" + log[act] + ")" || 64 | log[act]) + 65 | "\n"; 66 | break; 67 | case "user": 68 | await mentionPillFor(log[act]).then((pill) => { 69 | toLog += "user: " + pill.html + " (" + log[act] + ")\n"; 70 | console.log("-------\n" + toLog); 71 | }); 72 | break; 73 | default: 74 | toLog += act + ": " + log[act] + "\n"; 75 | break; 76 | } 77 | } 78 | toLog += "\n'~' '~' '~' '~' '~' '~' '~'"; 79 | console.log(toLog); 80 | console.log( 81 | toLog.replace( 82 | /|<\/b>|(.*?)<\/a>/g, 83 | "" 84 | ) 85 | ); 86 | return client.sendMessage(db.logto, { 87 | body: toLog.replace( 88 | /|<\/b>|(.*?)<\/a>/g, 89 | "" 90 | ), 91 | msgtype: "m.notice", 92 | format: "org.matrix.custom.html", 93 | formatted_body: toLog, 94 | }); 95 | } 96 | } catch (err) { 97 | console.log(err); 98 | } 99 | }; 100 | -------------------------------------------------------------------------------- /src/commands/unban.ts: -------------------------------------------------------------------------------- 1 | // TODO: log [reason] 2 | // -=- SYNTAX : ;unban [reason] 3 | import { 4 | MatrixClient, 5 | MessageEvent, 6 | MentionPill, 7 | MessageEventContent, 8 | } from "matrix-bot-sdk"; 9 | import * as htmlEscape from "escape-html"; 10 | import { lookup_user, mentionPillFor, user_discordId } from "../lookupUser"; 11 | import { guild } from "./discord_handler"; 12 | import { rooms } from "./handler"; 13 | import { log } from "../log"; 14 | export async function runUnbanCommand( 15 | roomId: string, 16 | event: MessageEvent, 17 | args: string[], 18 | client: MatrixClient, 19 | formatted_body: string 20 | ) { 21 | if (!lookup_user(event.sender).moderator) { 22 | return client.sendMessage(roomId, { 23 | body: "You aren't a moderator!", 24 | msgtype: "m.notice", 25 | format: "org.matrix.custom.html", 26 | formatted_body: "You aren't a moderator!", 27 | }); 28 | } 29 | console.log(`=======\n${formatted_body}\n========`); 30 | 31 | let mentioned = false; // did the user provide a MentionPill or a plain-text@messa.ge? 32 | let user = args[1] || null; 33 | let reason = args.slice(2).join(" ") || "No reason specified."; 34 | if (formatted_body) { 35 | // sanity check 36 | if (formatted_body.includes('') 42 | ) || user; 43 | } 44 | } 45 | 46 | let lookup = lookup_user(user); 47 | 48 | let user_matrix = lookup.user_matrix; 49 | let graim_name = lookup.graim_name; 50 | let user_discord = lookup.user_discord; 51 | 52 | if (!user_matrix) { 53 | // not in graim's db 54 | if (user_discordId(user)) { 55 | // if user was bridged from Discord 56 | guild.members 57 | .unban(user_discordId(user)) 58 | .catch((err) => console.log(err)); 59 | } 60 | 61 | rooms.forEach((roomId) => { 62 | client.unbanUser(user, roomId); 63 | }); 64 | 65 | let mention = await mentionPillFor(user); 66 | 67 | log( 68 | { 69 | info: "Striked user", 70 | user: user, 71 | reason: htmlEscape(reason), 72 | caller: event.sender, 73 | }, 74 | false, 75 | client 76 | ); 77 | 78 | return client.sendMessage(roomId, { 79 | body: "Unbanned " + mention.text + " for reason '" + reason + "'!", 80 | msgtype: "m.notice", 81 | format: "org.matrix.custom.html", 82 | formatted_body: 83 | "Unbanned " + 84 | mention.html + 85 | " for reason '" + 86 | htmlEscape(reason) + 87 | "'!", 88 | }); 89 | } 90 | 91 | rooms.forEach((roomId) => { 92 | client.unbanUser(user_matrix, roomId); 93 | }); 94 | guild.members.unban(user_discord).catch((err) => console.log(err)); 95 | 96 | log( 97 | { 98 | info: "Unbanned user", 99 | user: lookup.graim_name, 100 | reason: htmlEscape(reason), 101 | caller: event.sender, 102 | }, 103 | false, 104 | client 105 | ); 106 | 107 | return client.sendMessage(roomId, { 108 | body: "Unbanned " + graim_name + " for reason '" + reason + "'!", 109 | msgtype: "m.notice", 110 | format: "org.matrix.custom.html", 111 | formatted_body: 112 | "Unbanned " + 113 | graim_name + 114 | " for reason '" + 115 | htmlEscape(reason) + 116 | "'!", 117 | }); 118 | } 119 | -------------------------------------------------------------------------------- /src/commands/lint.ts: -------------------------------------------------------------------------------- 1 | // -=- SYNTAX : ;lint 2 | import { MatrixClient } from "matrix-bot-sdk"; 3 | import * as htmlEscape from "escape-html"; 4 | import { db } from "../lookupUser"; 5 | import { COMMAND_PREFIX, rooms } from "./handler"; 6 | import config from "../config"; 7 | export async function runLintCommand(roomId: string, client: MatrixClient) { 8 | // ~~ LINT ~~ 9 | 10 | let res = "Linting list:\n\n\n"; 11 | let totalErrors = 0; 12 | let totalWarns = 0; 13 | 14 | if (Object.keys(db.mods).length < 1) { 15 | res += 16 | " - It seems that there is currently no moderator. To add one, use " + 17 | COMMAND_PREFIX + 18 | "adduser!\n\n"; 19 | totalErrors += 1; 20 | } 21 | if (!db.rooms) { 22 | res += 23 | " - It seems you haven't bridged the channels to rooms with graim. Without bridging with graimdb as well, you cannot use " + 24 | COMMAND_PREFIX + 25 | "lock. To bridge the rooms, use " + 26 | COMMAND_PREFIX + 27 | "bridgeroom!\n\n"; 28 | totalWarns += 1; 29 | } 30 | if (!db.logto) { 31 | res += 32 | " - It seems that there is no room for graim to log to. To add one, run the command " + 33 | COMMAND_PREFIX + 34 | "setloggingroom in whatever room/channel you would like it to log to!\n\n"; 35 | totalWarns += 1; 36 | } 37 | 38 | if (config.autoJoin) { 39 | res += 40 | " - It seems that autojoin is currently enabled. While there is no known vulnerability, there is a potential that graim could be exploited with it. It is recommended to disable this once you have added it to all rooms!\n\n"; 41 | totalWarns += 1; 42 | } 43 | 44 | let graimModeratorFound = false; 45 | let powerLevelMisconfigureFound = false; 46 | for (let i = 0; i < rooms.length; i++) { 47 | let power_levels = await client.getRoomStateEvent( 48 | rooms[i], 49 | "m.room.power_levels", 50 | "" 51 | ); 52 | let uidPower = power_levels["users"][await client.getUserId()]; 53 | if (uidPower < 50) { 54 | if (power_levels["events_default"] <= uidPower) { 55 | graimModeratorFound = true; 56 | totalErrors += 1; 57 | client.sendMessage(rooms[i], { 58 | body: "It seems I'm not properly configured in this room! I need to be a Moderator (power level 50) in all rooms to work!", 59 | msgtype: "m.notice", 60 | format: "org.matrix.custom.html", 61 | formatted_body: 62 | "It seems I'm not properly configured in this room! I need to be a Moderator (power level 50) in all rooms to work!", 63 | }); 64 | } 65 | } 66 | if (power_levels["events"]["m.room.power_levels"] > 50) { 67 | if (power_levels["events_default"] <= uidPower) { 68 | powerLevelMisconfigureFound = true; 69 | totalErrors += 1; 70 | client.sendMessage(rooms[i], { 71 | body: 'It seems this room is improperly configured! Edit the room permissions and alter "Change Permissions" (m.room.power_levels) to be "Moderator" (power level 50).', 72 | msgtype: "m.notice", 73 | format: "org.matrix.custom.html", 74 | formatted_body: 75 | 'It seems this room is improperly configured! Edit the room permissions and alter "Change Permissions" (m.room.power_levels) to be "Moderator" (power level 50).', 76 | }); 77 | } 78 | } 79 | } 80 | if (graimModeratorFound) 81 | res += 82 | " - Certain room(s) have graim's power level below 50 (Moderator). I sent a message in each offending room. You will need to set the bot as a moderator to use it!\n\n"; 83 | if (powerLevelMisconfigureFound) 84 | res += 85 | ' - Certain room(s) are not properly configured for graim! The "Change permissions" setting (m.room.power_levels) must be set to "Moderator" (or power level 50 or below)! I tried to send a message in each offending room. If no messages showed up, make sure I can send messages everywhere in the first place!\n\n'; 86 | 87 | res += "Total errors: " + totalErrors + "\nTotal warnings: " + totalWarns; 88 | 89 | return client.sendMessage(roomId, { 90 | body: res, 91 | msgtype: "m.notice", 92 | format: "org.matrix.custom.html", 93 | formatted_body: htmlEscape(res), 94 | }); 95 | } 96 | -------------------------------------------------------------------------------- /setup.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | If you want to run graim for your own community, this guide should be the one-stop spot to getting everything set up! 3 | 4 | ## Prerequisites 5 | - some server (a PC that stays online will do fine) - must have `node`, `git`, `npm` 6 | - a Discord account 7 | - a Discord server 8 | - one Matrix room for every Discord channel you would like to bridge 9 | 10 | ## Getting started 11 | First, you will need to create a Discord bot and a Matrix account (which the bot will use). 12 | 13 | ### Add the bridge to your communities 14 | Follow the tutorial [here](https://t2bot.io/discord/). You may also run your own bridge, and it is supported; however, that is advanced and out of the scope of this tutorial 15 | 16 | ## Download graim 17 | Open your terminal or command prompt and type these commands: 18 | ``` 19 | git clone https://github.com/luphoria/graim 20 | cd graim 21 | npm install 22 | ``` 23 | Graim is now installed to your device - but now it needs some configuration. 24 | 25 | ### Create the Discord bot 26 | - Navigate to [Discord's application panel](https://discord.com/developers/applications). 27 | - Create a new application. 28 | - Go to the Bot section, make note of the token and client ID - you'll need them later. 29 | - Make sure you have checked "Server Members Intent"! 30 | 31 | ### Create the Matrix bot user 32 | - Create a Matrix account like any other. Give it whatever name you'd like. 33 | - Enter the user settings and find the "Access Token". 34 | - Copy the access token. **Do not share your access token.** Treat it like your password. 35 | 36 | ## Configure graim 37 | To begin, you are going to need to run some commands. 38 | ``` 39 | cp graimdb.json.example graimdb.json 40 | cd config/default.yaml.example default.yaml 41 | ``` 42 | Now, open config/default.yaml in your preferred text editor. 43 | - set `accessToken` to whatever your Matrix user's access token is. 44 | - set `autoJoin` to `true` (you will want to disable this after setup but it isn't a big deal). 45 | - Set `discordToken` to your Discord bot's access token. 46 | - Set `discordClient` to your Discord application's "client ID" or "application ID". 47 | - Set `discordGuild` to the Discord guild (server) you want it to be active in. 48 | - Set `discordMutedRole` to a role in aforementioned guild, which presumably prevents the user from speaking. 49 | - Configure anything else you want given your situation. 50 | 51 | Graim is now ready for you to run it! Go back to your terminal, and run `npm run start:dev`. 52 | 53 | Wait a few seconds, and you should see the bot is working! 54 | 55 | ## Invite graim 56 | Now, you have to actually add graim to the communities it is moderating. 57 | 58 | ### Adding the Discord bot 59 | - Copy your Discord bot's "application ID" or "client ID" (same variable as `discordClient`). 60 | - Paste that ID in the following link: `https://discordapp.com/api/oauth2/authorize?client_id=`<YOUR CLIENT ID HERE>`&scope=bot&permissions=8` 61 | - Complete the steps as Discord autofills them. 62 | - Once graim is in your server, ensure that the `graim` role in your roles list is placed higher than the `muted` role - otherwise, graim will crash if you try to mute. 63 | 64 | ### Adding the Matrix bot 65 | - Invite the bot to every room needed, as you would any other user 66 | - For each room, make the bot a `Moderator` (power level 50), and adjust `Change permissions` in the room to `Moderator` - this will allow it to change other users' power levels to -1, effectively muting them. Additionally, it is highly recommended you change `Notify everyone` to `Admin` - otherwise a user could potentially elevate their permissions via the bot to ping @room. 67 | 68 | ### Adding yourself to the moderator list 69 | In order to use any graim commands, you first need to be considered a moderator by graim itself. 70 | 71 | - Run `;adduser @user:matrixexample.org @discorduser moderator` from an Admin (power level 100) account on Matrix. 72 | 73 | NOTE: Any new moderators you add will also be able to run `adduser` - whether they have power level 100 or not. 74 | 75 | ### graim-side room bridging 76 | In order for the `lock` command to function, you must run `bridgeroom ` in all rooms/channels. 77 | 78 | ### Logging room 79 | You will probably want a logging room. This can be a public or private room, but either way it must be bridged to Discord. To set a room as the designated logging room, run the command `setloggingroom` to implement it. 80 | 81 | ### Lint 82 | Lastly, just make sure you've set everything up right! Run the command `lint` in any channel, and it will tell you any misconfigurations. 83 | 84 | **Congratulations, you have set up graim!** 85 | -------------------------------------------------------------------------------- /src/commands/unmute.ts: -------------------------------------------------------------------------------- 1 | // TODO: add [reason] 2 | // -=- SYNTAX : ;unmute 3 | import { 4 | MatrixClient, 5 | MessageEvent, 6 | MessageEventContent, 7 | } from "matrix-bot-sdk"; 8 | import * as htmlEscape from "escape-html"; 9 | import { user_discordId, lookup_user, mentionPillFor } from "../lookupUser"; 10 | import { guild, mute_role } from "./discord_handler"; 11 | import { COMMAND_PREFIX, rooms } from "./handler"; 12 | import { log } from "../log"; 13 | export async function runUnmuteCommand( 14 | roomId: string, 15 | event: MessageEvent, 16 | args: string[], 17 | client: MatrixClient, 18 | formatted_body: string 19 | ) { 20 | if (!lookup_user(event.sender).moderator) { 21 | return client.sendMessage(roomId, { 22 | body: "You aren't a moderator!", 23 | msgtype: "m.notice", 24 | format: "org.matrix.custom.html", 25 | formatted_body: "You aren't a moderator!", 26 | }); 27 | } 28 | 29 | if (!args[1]) { 30 | // user didn't provide the required number of args 31 | return client.sendMessage(roomId, { 32 | body: "Usage: " + COMMAND_PREFIX + "unmute ", 33 | msgtype: "m.notice", 34 | format: "org.matrix.custom.html", 35 | formatted_body: "Usage: " + COMMAND_PREFIX + "unmute <user>", 36 | }); 37 | } 38 | let commandString = args.join(" "); 39 | if (formatted_body) { 40 | commandString = formatted_body.replace( 41 | /(.*?)<\/a>/g, 42 | "" 43 | ); 44 | } 45 | 46 | let command = commandString.split(" "); 47 | 48 | let reason = command.slice(2).join(" ") || "No reason specified."; 49 | let user = command[1] || ""; // we default to an empty string because it causes non-fatal errors. 50 | 51 | let lookup: { 52 | graim_name: string; 53 | user_matrix: string; 54 | user_discord: string; 55 | moderator: boolean; 56 | }; 57 | 58 | lookup = lookup_user(user); 59 | 60 | if (!lookup.graim_name) { 61 | if (user_discordId(user)) { 62 | let user_discord = await guild.members 63 | .fetch(user_discordId(user)) 64 | .catch((err) => console.log(err)); 65 | if (user_discord) 66 | user_discord.roles.remove(mute_role).catch((err) => console.log(err)); 67 | } 68 | else { 69 | rooms.forEach((roomId) => { 70 | try { 71 | if (user.includes("@") && user.includes(":")) { // TODO TODO TODO use real mxid validator 72 | client.setUserPowerLevel(user, roomId, 0); 73 | } 74 | } 75 | catch (err) {console.info(err)} 76 | }); 77 | } 78 | 79 | let mention = await mentionPillFor(user); 80 | 81 | log( 82 | { 83 | info: "Unmuted user", 84 | user: user, 85 | reason: htmlEscape(reason), 86 | caller: event.sender, 87 | }, 88 | false, 89 | client 90 | ); 91 | 92 | return client.sendMessage(roomId, { 93 | body: "Unmuted " + mention.text + " for reason " + reason + "!", 94 | msgtype: "m.notice", 95 | format: "org.matrix.custom.html", 96 | formatted_body: 97 | "Unmuted " + 98 | mention.html + 99 | " for reason " + 100 | htmlEscape(reason) + 101 | "!", 102 | }); 103 | } 104 | 105 | try { 106 | lookup_user(args[1]); 107 | } catch { 108 | return client.sendMessage(roomId, { 109 | body: "I don't think that user is in the graim database!", 110 | msgtype: "m.notice", 111 | format: "org.matrix.custom.html", 112 | formatted_body: "I don't think that user is in the graim database!", 113 | }); 114 | } 115 | rooms.forEach((roomId) => { 116 | client.setUserPowerLevel(lookup.user_matrix, roomId, 0); 117 | }); 118 | 119 | let user_discord = await guild.members 120 | .fetch(lookup.user_discord) 121 | .catch((err) => console.log(err)); 122 | user_discord.roles.remove(mute_role).catch((err) => console.log(err)); 123 | 124 | log( 125 | { 126 | info: "Unmuted user", 127 | user: lookup.graim_name, 128 | reason: htmlEscape(reason), 129 | caller: event.sender, 130 | }, 131 | false, 132 | client 133 | ); 134 | 135 | return client.sendMessage(roomId, { 136 | body: "Unmuted " + lookup.graim_name + " for reason " + reason + "!", 137 | msgtype: "m.notice", 138 | format: "org.matrix.custom.html", 139 | formatted_body: 140 | "Unmuted " + 141 | lookup.graim_name + 142 | " for reason " + 143 | htmlEscape(reason) + 144 | "!", 145 | }); 146 | } 147 | -------------------------------------------------------------------------------- /src/commands/whosent.ts: -------------------------------------------------------------------------------- 1 | // -=- SYNTAX : ;whosent 2 | import { MatrixClient } from "matrix-bot-sdk"; 3 | import * as htmlEscape from "escape-html"; 4 | import { guild } from "./discord_handler"; 5 | import { COMMAND_PREFIX } from "./handler"; 6 | import { user_discordId } from "../lookupUser" 7 | 8 | export async function runWhoSentCommand( 9 | roomId: string, 10 | args: string[], 11 | client: MatrixClient 12 | ) { 13 | try { 14 | if (!args[1]) { 15 | // user didn't provide the required number of arguments 16 | return client.sendMessage(roomId, { 17 | body: "Usage: " + COMMAND_PREFIX + "whosent ", 18 | msgtype: "m.notice", 19 | format: "org.matrix.custom.html", 20 | formatted_body: 21 | "Usage: " + COMMAND_PREFIX + "whosent ", 22 | }); 23 | } 24 | let command = args[1].split("/"); // just an easy way to parse the URL 25 | let input = { 26 | guild: command[4], 27 | channel: command[5], 28 | message: command[6], 29 | }; 30 | let channel = guild.channels.cache.get(input.channel); // fetch the channel from ID 31 | let msg = await channel.messages.fetch(input.message); // fetch the message from the channel by ID 32 | 33 | let possibleMatches = []; 34 | 35 | const members = await client.getRoomMembers(roomId); 36 | for (let member of members) { 37 | if (member.content.displayname == msg.author.username) { 38 | possibleMatches.push(member.sender.slice(1)); 39 | } 40 | } 41 | 42 | if (possibleMatches.length !== 1) { 43 | client.sendMessage(roomId, { 44 | // give some reception while the user waits - search api takes time 45 | body: `Searching . . .`, 46 | msgtype: "m.notice", 47 | format: "org.matrix.custom.html", 48 | formatted_body: `Searching . . .`, 49 | }); 50 | 51 | let search = await client.doRequest( 52 | // there is no search function in the Matrix bot SDK so we are directly fetching from API. 53 | "POST", 54 | "/_matrix/client/r0/search", 55 | undefined, 56 | { 57 | search_categories: { 58 | room_events: { 59 | search_term: msg.content.replace( 60 | /[^ ]*[^a-z^A-Z^0-9\^_ :.()#,"'?=[]].*/gmi, 61 | "" 62 | ), // REGEX: search api doesn't really like special characters, so let's sanitize it for better results. 63 | filter: { limit: 1 }, 64 | order_by: "recent", 65 | event_context: { 66 | before_limit: 0, 67 | after_limit: 0, 68 | include_profile: true, 69 | }, 70 | }, 71 | }, 72 | } 73 | ); 74 | 75 | try { 76 | // get the sender of the first result of that search 77 | let sender_mxid = search["search_categories"]["room_events"]["results"][0]["result"]["sender"]; 78 | let display_name = // get the display name of the sender of the first result of that search 79 | search["search_categories"]["room_events"]["results"][0]["context"][ 80 | "profile_info" 81 | ][sender_mxid]["displayname"]; 82 | 83 | if (msg.author.username == display_name) { 84 | if (user_discordId(sender_mxid) == null) { 85 | possibleMatches = [sender_mxid].slice(1); 86 | } 87 | } 88 | } catch { 89 | // Running through the trees in her dreams, she trips over jagged roots, becomes tangled in the overgrown brush. The birds in the sky warn her that her memories are dose behind. Twisted branches reach for her, the earth rises up to swallow her as pain echoes through the woods, lingering in the leaves. 90 | } 91 | } 92 | 93 | let ret: string; 94 | if (possibleMatches.length > 0) { 95 | ret = "Matches: @" + possibleMatches.join(", @"); 96 | } else { 97 | ret = "Sorry, but the query returned no results :(\nYou'll probably have to log on Matrix for this one."; 98 | } 99 | return client.sendMessage(roomId, { 100 | body: ret, 101 | msgtype: "m.notice", 102 | format: "org.matrix.custom.html", 103 | formatted_body: htmlEscape(ret), 104 | }); 105 | } catch { 106 | return client.sendMessage(roomId, { 107 | body: "Something went wrong running this command", 108 | msgtype: "m.notice", 109 | format: "org.matrix.custom.html", 110 | formatted_body: "Something went wrong running this command", 111 | }); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/commands/adduser.ts: -------------------------------------------------------------------------------- 1 | // -=- SYNTAX : ;adduser <@matrix_name> <@discord_name> [moderator] 2 | import { 3 | MatrixClient, 4 | MessageEvent, 5 | MessageEventContent, 6 | } from "matrix-bot-sdk"; 7 | import * as htmlEscape from "escape-html"; 8 | import { 9 | user_discordId, 10 | db, 11 | lookup_user, 12 | saveDB, 13 | mentionPillFor, 14 | } from "../lookupUser"; 15 | import { COMMAND_PREFIX } from "./handler"; 16 | import { log } from "../log"; 17 | export async function runAddUserCommand( 18 | roomId: string, 19 | event: MessageEvent, 20 | args: string[], 21 | client: MatrixClient, 22 | formatted_body: string 23 | ) { 24 | console.log(formatted_body); 25 | log({ INFO: "User " + event.sender + " ran command adduser." }, true, client); 26 | try { 27 | if (!lookup_user(event.sender).moderator) { 28 | let power_levels = await client.getRoomStateEvent( 29 | roomId, 30 | "m.room.power_levels", 31 | "" 32 | ); 33 | console.log(power_levels["users"]); 34 | if ( 35 | power_levels["users"]?.[event.sender] < 100 || 36 | !power_levels["users"][event.sender] 37 | ) { 38 | // checks if user is an Admin 39 | return client.sendMessage(roomId, { 40 | body: "You aren't a moderator!", 41 | msgtype: "m.notice", 42 | format: "org.matrix.custom.html", 43 | formatted_body: "You aren't a moderator!", 44 | }); 45 | } 46 | if (Object.keys(db.mods).length > 0) { 47 | // make sure a malicious user cannot hax with autojoin on 48 | return client.sendMessage(roomId, { 49 | body: "It seems that there is already a moderator in the database.\nIf this is a mistake, please manually clear graimdb.json", 50 | msgtype: "m.notice", 51 | format: "org.matrix.custom.html", 52 | formatted_body: 53 | "It seems that there is already a moderator in the database.
If this is a mistake, please manually clear graimdb.json", 54 | }); 55 | } 56 | } 57 | 58 | if (!args[3]) { 59 | // they did not reply with at least the full number of required args 60 | return client.sendMessage(roomId, { 61 | body: 62 | "Usage: " + 63 | COMMAND_PREFIX + 64 | "adduser <@matrix_user> <@discord_user> [moderator]", 65 | msgtype: "m.notice", 66 | format: "org.matrix.custom.html", 67 | formatted_body: 68 | "Usage: " + 69 | COMMAND_PREFIX + 70 | "adduser <graim_name> <@matrix_user> <@discord_user> [moderator]", 71 | }); 72 | } 73 | 74 | let command = event.content.body.split(" "); 75 | 76 | if (formatted_body) { 77 | // simple sanity check - MentionPill requires formatted body. This isn't really necessary 78 | if (formatted_body.includes('
(.*?)<\/a>/g, "") // REGEX: removes all content from MentionPill HTML except the MXID 82 | .split(" "); 83 | } 84 | } 85 | 86 | let user = { 87 | name: command[1], 88 | matrix: decodeURIComponent(command[2]).replace("@", ""), // don't store the `@` of Matrix users in the db 89 | discord: user_discordId(decodeURIComponent(command[3])), 90 | strikes: [], 91 | }; 92 | let moderator = command[4] == "moderator" ? true : false; // TODO: make a real ranking for admins vs. moderators 93 | 94 | if (lookup_user(user.name).graim_name) { 95 | return client.sendMessage(roomId, { 96 | body: "User " + user.name + " already exists!", 97 | msgtype: "m.notice", 98 | format: "org.matrix.custom.html", 99 | formatted_body: "User " + htmlEscape(user.name) + " already exists!", 100 | }); 101 | } 102 | 103 | db.users.push(user); // add user object to the existing db list 104 | if (moderator) db.mods[user.name] = "Moderator"; // add username to moderator list 105 | saveDB(db); 106 | 107 | log( 108 | { 109 | info: "Added user", 110 | user: user.name, 111 | matrix: user.matrix, 112 | discord: user.discord, 113 | moderator: moderator ? "Yes" : "No", 114 | caller: event.sender, 115 | }, 116 | false, 117 | client 118 | ); 119 | 120 | return client.sendMessage(roomId, { 121 | body: `User: ${user.name} 122 | Matrix: @${user.matrix} 123 | Discord: ${user.discord} (${user.discord}) 124 | Moderator? ${moderator ? "Yes" : "No"}`, 125 | msgtype: "m.notice", 126 | format: "org.matrix.custom.html", 127 | formatted_body: `User: ${user.name} 128 | Matrix: @${htmlEscape(user.matrix)} 129 | Discord: ${(await mentionPillFor(user.discord)).html} (${htmlEscape( 130 | user.discord 131 | )}) 132 | Moderator? ${moderator ? "Yes" : "No"}`, 133 | }); 134 | } catch (err) { 135 | console.log(err); 136 | return client.sendMessage(roomId, { 137 | body: "Something went wrong running this command", 138 | msgtype: "m.notice", 139 | format: "org.matrix.custom.html", 140 | formatted_body: "Something went wrong running this command", 141 | }); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/commands/kick.ts: -------------------------------------------------------------------------------- 1 | // -=- SYNTAX : ;kick [reason] 2 | import { 3 | MatrixClient, 4 | MessageEvent, 5 | MentionPill, 6 | MessageEventContent, 7 | } from "matrix-bot-sdk"; 8 | import * as htmlEscape from "escape-html"; 9 | import { 10 | user_discordId, 11 | lookup_user, 12 | db, 13 | saveDB, 14 | mentionPillFor, 15 | } from "../lookupUser"; 16 | import { guild } from "./discord_handler"; 17 | import { rooms } from "./handler"; 18 | import { log } from "../log"; 19 | export async function runKickCommand( 20 | roomId: string, 21 | event: MessageEvent, 22 | args: string[], 23 | client: MatrixClient, 24 | formatted_body: string 25 | ) { 26 | console.log(`=======\n${formatted_body}\n========`); 27 | 28 | if (!lookup_user(event.sender).moderator) { 29 | return client.sendMessage(roomId, { 30 | body: "You aren't a moderator!", 31 | msgtype: "m.notice", 32 | format: "org.matrix.custom.html", 33 | formatted_body: "You aren't a moderator!", 34 | }); 35 | } 36 | 37 | let user = args[1] || ""; 38 | let reason = args.slice(2).join(" ") || "No reason specified."; 39 | if (formatted_body) { 40 | // sanity check - MentionPill cannot exist without a formatted body 41 | if (formatted_body.includes('') 46 | ) || user; 47 | } 48 | } 49 | 50 | let lookup = lookup_user(user); 51 | 52 | let user_matrix = lookup.user_matrix; 53 | let graim_name = lookup.graim_name; 54 | 55 | if (!graim_name) { 56 | // there is no registered graim user 57 | if (user_discordId(user)) { 58 | // if mention was a valid Discord user ID 59 | let user_discord = await guild.members 60 | .fetch(user_discordId(user)) 61 | .catch((err) => console.log(err)); // fetch discord user 62 | if (user_discord) { 63 | if (user_discord.kickable) { 64 | user_discord 65 | .kick(event.sender + ": " + reason) 66 | .catch((err) => console.log(err)); 67 | log( 68 | { 69 | info: "Kicked user (discord)", 70 | user: user + " (" + user_discordId(user) + ")", 71 | reason: htmlEscape(reason), 72 | caller: event.sender, 73 | }, 74 | true, 75 | client 76 | ); 77 | } 78 | } 79 | } 80 | rooms.forEach((roomId) => { 81 | client.kickUser( 82 | user, 83 | roomId, 84 | event.sender + " told me to! :D => " + htmlEscape(reason) 85 | ).catch((err) => console.error(err));; 86 | log( 87 | { 88 | info: "Kicked user (matrix)", 89 | user: user, 90 | reason: htmlEscape(reason), 91 | caller: event.sender, 92 | }, 93 | true, 94 | client 95 | ); 96 | }); 97 | 98 | let mention = await mentionPillFor(user); 99 | 100 | return client.sendMessage(roomId, { 101 | body: "Kicked " + mention.text + " for reason '" + reason + "'!", 102 | msgtype: "m.notice", 103 | format: "org.matrix.custom.html", 104 | formatted_body: 105 | "Kicked " + 106 | mention.html + 107 | " for reason '" + 108 | htmlEscape(reason) + 109 | "'!", 110 | }); 111 | } 112 | 113 | rooms.forEach((roomId) => { 114 | client.kickUser( 115 | user_matrix, 116 | roomId, 117 | event.sender + " told me to! :D => " + htmlEscape(reason) 118 | ); 119 | log( 120 | { 121 | info: "Kicked user (matrix)", 122 | user: user_matrix, 123 | reason: htmlEscape(reason), 124 | caller: event.sender, 125 | }, 126 | true, 127 | client 128 | ); 129 | }); 130 | 131 | let user_discord = await guild.members 132 | .fetch(lookup.user_discord) 133 | .catch((err) => console.log(err)); // fetch discord user 134 | if (user_discord.kickable) { 135 | user_discord 136 | .kick(event.sender + ": " + reason) 137 | .catch((err) => console.log(err)); 138 | log( 139 | { 140 | info: "Kicked user (discord)", 141 | user: lookup.graim_name + " (" + lookup.user_discord + ")", 142 | reason: htmlEscape(reason), 143 | caller: event.sender, 144 | }, 145 | true, 146 | client 147 | ); 148 | } 149 | db.users 150 | .filter((dbuser) => { 151 | return dbuser.name == lookup.graim_name; 152 | })[0] 153 | .strikes.push({ 154 | time: Date.now(), 155 | action: "kick", 156 | reason: reason, 157 | }); 158 | 159 | saveDB(db); 160 | 161 | log( 162 | { 163 | info: "Kicked user", 164 | user: lookup.graim_name, 165 | reason: htmlEscape(reason), 166 | caller: event.sender, 167 | }, 168 | false, 169 | client 170 | ); 171 | 172 | let strikes = db.users.filter((dbuser) => { 173 | return dbuser.name == lookup.graim_name; 174 | })[0].strikes; 175 | 176 | return client.sendMessage(roomId, { 177 | body: 178 | "Kicked " + 179 | graim_name + 180 | " for reason '" + 181 | reason + 182 | "'!\nCurrent strike count: " + 183 | strikes.length, 184 | msgtype: "m.notice", 185 | format: "org.matrix.custom.html", 186 | formatted_body: 187 | "Kicked " + 188 | graim_name + 189 | " for reason '" + 190 | htmlEscape(reason) + 191 | "'!
Current strike count: " + 192 | strikes.length, 193 | }); 194 | } 195 | -------------------------------------------------------------------------------- /src/commands/ban.ts: -------------------------------------------------------------------------------- 1 | // -=- SYNTAX : ;ban [reason] 2 | import { 3 | MatrixClient, 4 | MessageEvent, 5 | MentionPill, 6 | MessageEventContent, 7 | } from "matrix-bot-sdk"; 8 | import * as htmlEscape from "escape-html"; 9 | import { 10 | user_discordId, 11 | lookup_user, 12 | db, 13 | saveDB, 14 | mentionPillFor, 15 | } from "../lookupUser"; 16 | import { guild } from "./discord_handler"; 17 | import { rooms } from "./handler"; 18 | import { log } from "../log"; 19 | export async function runBanCommand( 20 | roomId: string, 21 | event: MessageEvent, 22 | args: string[], 23 | client: MatrixClient, 24 | formatted_body: string 25 | ) { 26 | console.log(`=======\n${formatted_body}\n========`); 27 | 28 | if (!lookup_user(event.sender).moderator) { 29 | return client.sendMessage(roomId, { 30 | body: "You aren't a moderator!", 31 | msgtype: "m.notice", 32 | format: "org.matrix.custom.html", 33 | formatted_body: "You aren't a moderator!", 34 | }); 35 | } 36 | 37 | let user = args[1] || ""; 38 | let reason = args.slice(2).join(" ") || "No reason specified."; // everything after the username 39 | if (formatted_body) { 40 | if (formatted_body.includes('
') 46 | ) || user; 47 | } 48 | } 49 | 50 | let lookup = lookup_user(user); 51 | 52 | let user_matrix = lookup.user_matrix; 53 | let graim_name = lookup.graim_name; 54 | 55 | if (!graim_name) { 56 | // the lookup returned no results 57 | // sanity check before we try to lookup the Discord ID 58 | if (user_discordId(user)) { 59 | // if the user is a Discord-bridged one 60 | let user_discord = await guild.members 61 | .fetch(user_discordId(user)) 62 | .catch((err) => console.error(err)); // get the discord user 63 | if (user_discord) { 64 | if (user_discord.bannable) { 65 | user_discord 66 | .ban({ reason: event.sender + ": " + reason }) 67 | .catch((err) => console.error(err)); 68 | log( 69 | { 70 | info: "Banned user (discord)", 71 | user: user + " (" + user_discordId(user) + ")", 72 | reason: htmlEscape(reason), 73 | caller: event.sender, 74 | }, 75 | true, 76 | client 77 | ); 78 | } 79 | } 80 | } else { 81 | rooms.forEach((roomId) => { 82 | client.banUser( 83 | user, 84 | roomId, 85 | event.sender + " told me to! :D => " + htmlEscape(reason) 86 | ).catch((err) => console.error(err)); 87 | log( 88 | { 89 | info: "Banned user (matrix)", 90 | user: user, 91 | roomId: roomId, 92 | reason: htmlEscape(reason), 93 | caller: event.sender, 94 | }, 95 | true, 96 | client 97 | ); 98 | }); 99 | } 100 | 101 | let mention = await mentionPillFor(user); 102 | 103 | return client.sendMessage(roomId, { 104 | body: "Banned " + mention.text + " for reason '" + reason + "'!", 105 | msgtype: "m.notice", 106 | format: "org.matrix.custom.html", 107 | formatted_body: 108 | "Banned " + 109 | mention.html + 110 | " for reason '" + 111 | htmlEscape(reason) + 112 | "'!", 113 | }); 114 | } 115 | rooms.forEach((roomId) => { 116 | client.banUser( 117 | user_matrix, 118 | roomId, 119 | event.sender + " told me to! :D => " + htmlEscape(reason) 120 | ); 121 | log( 122 | { 123 | info: "Banned user (matrix)", 124 | user: user_matrix, 125 | roomId: roomId, 126 | reason: htmlEscape(reason), 127 | caller: event.sender, 128 | }, 129 | true, 130 | client 131 | ); 132 | }); 133 | 134 | let user_discord = await guild.members 135 | .fetch(lookup.user_discord) 136 | .catch((err) => console.error(err)); // get the discord user 137 | if (user_discord) { 138 | if (user_discord.bannable) { 139 | user_discord 140 | .ban({ reason: event.sender + ": " + reason }) 141 | .catch((err) => console.error(err)); 142 | log( 143 | { 144 | info: "Banned user (discord)", 145 | user: user_discord, 146 | reason: htmlEscape(reason), 147 | caller: event.sender, 148 | }, 149 | true, 150 | client 151 | ); 152 | } 153 | } 154 | 155 | db.users 156 | .filter((dbuser) => { 157 | return dbuser.name == lookup.graim_name; 158 | })[0] 159 | .strikes.push({ 160 | time: Date.now(), 161 | action: "ban", 162 | reason: reason, 163 | }); 164 | 165 | saveDB(db); 166 | 167 | let strikes = db.users.filter((dbuser) => { 168 | return dbuser.name == lookup.graim_name; 169 | })[0].strikes; 170 | 171 | log( 172 | { 173 | info: "Banned user", 174 | user: lookup.graim_name, 175 | reason: htmlEscape(reason), 176 | caller: event.sender, 177 | }, 178 | false, 179 | client 180 | ); 181 | 182 | return client.sendMessage(roomId, { 183 | body: 184 | "Banned " + 185 | graim_name + 186 | " for reason '" + 187 | reason + 188 | "'!\nCurrent strikes: " + 189 | strikes.length, 190 | msgtype: "m.notice", 191 | format: "org.matrix.custom.html", 192 | formatted_body: 193 | "Banned " + 194 | graim_name + 195 | " for reason '" + 196 | htmlEscape(reason) + 197 | "'!
Current strikes: " + 198 | strikes.length, 199 | }); 200 | } 201 | -------------------------------------------------------------------------------- /src/commands/mute.ts: -------------------------------------------------------------------------------- 1 | // -=- SYNTAX : ;mute [time (smhd)] [reason] 2 | import { 3 | MatrixClient, 4 | MessageEvent, 5 | MentionPill, 6 | MessageEventContent, 7 | } from "matrix-bot-sdk"; 8 | import * as htmlEscape from "escape-html"; 9 | import { 10 | user_discordId, 11 | lookup_user, 12 | db, 13 | saveDB, 14 | mentionPillFor, 15 | } from "../lookupUser"; 16 | import { guild, mute_role } from "./discord_handler"; 17 | import { COMMAND_PREFIX, rooms } from "./handler"; 18 | import { log } from "../log"; 19 | const ms = require("ms"); 20 | 21 | export async function runMuteCommand( 22 | roomId: string, 23 | event: MessageEvent, 24 | args: string[], 25 | client: MatrixClient, 26 | formatted_body: string 27 | ) { 28 | if (!lookup_user(event.sender).moderator) { 29 | return client.sendMessage(roomId, { 30 | body: "You aren't a moderator!", 31 | msgtype: "m.notice", 32 | format: "org.matrix.custom.html", 33 | formatted_body: "You aren't a moderator!", 34 | }); 35 | } 36 | 37 | if (!args[1]) { 38 | // user provided no arguments 39 | return client.sendMessage(roomId, { 40 | body: 41 | "Usage: " + 42 | COMMAND_PREFIX + 43 | "mute [time (1 day if not specified)]", 44 | msgtype: "m.notice", 45 | format: "org.matrix.custom.html", 46 | formatted_body: 47 | "Usage: " + 48 | COMMAND_PREFIX + 49 | "mute <user> [time (1 day if not specified)]", 50 | }); 51 | } 52 | let commandString = args.join(" "); 53 | 54 | if (formatted_body) { 55 | commandString = formatted_body.replace( 56 | /
(.*?)<\/a>/g, 57 | "" 58 | ); 59 | } 60 | 61 | let command = commandString.split(" "); 62 | 63 | let reason = command.slice(3).join(" ") || "No reason specified."; 64 | let user = command[1] || ""; // we default to an empty string because it causes non-fatal errors. 65 | 66 | let lookup; 67 | let msToUnmute; 68 | let unmuteTimeProvided = false; 69 | 70 | try { 71 | msToUnmute = ms(command[2]); 72 | unmuteTimeProvided = true; 73 | } catch { 74 | msToUnmute = undefined; 75 | unmuteTimeProvided = false; 76 | } 77 | if (!msToUnmute) { 78 | msToUnmute = ms("1d"); 79 | reason = 80 | commandString.split(" ").slice(2).join(" ") || "No reason specified."; 81 | } 82 | 83 | lookup = lookup_user(user); 84 | 85 | if (!lookup.graim_name) { 86 | if (user_discordId(user)) { 87 | let user_discord = await guild.members.fetch(user_discordId(user)); // fetch the discord user 88 | if (user_discord) 89 | user_discord.roles.add(mute_role).catch((err) => console.error(err)); 90 | setTimeout(() => { 91 | user_discord.roles.remove(mute_role).catch((err) => console.error(err)); 92 | }, msToUnmute); 93 | } 94 | else { 95 | rooms.forEach((roomId) => { 96 | if (user.includes("@") && user.includes(":")) { // TODO TODO TODO use real mxid validator 97 | client.setUserPowerLevel(user, roomId, -1).catch((err) => console.error(err)); 98 | } 99 | }); 100 | 101 | setTimeout(() => { 102 | // once this time has passed, undo the mute! 103 | rooms.forEach((roomId) => { 104 | if (user.includes("@") && user.includes(":")) { // TODO TODO TODO use real mxid validator 105 | client.setUserPowerLevel(user, roomId, 0).catch((err) => console.error(err)); 106 | } 107 | }); 108 | }, msToUnmute); 109 | } 110 | 111 | let mention = await mentionPillFor(user); 112 | 113 | log( 114 | { 115 | info: "Muted user", 116 | user: user, 117 | reason: htmlEscape(reason), 118 | length: 119 | (unmuteTimeProvided ? command[2] : "1d") + " (" + msToUnmute + " ms)", 120 | caller: event.sender, 121 | }, 122 | false, 123 | client 124 | ); 125 | 126 | return client.sendMessage(roomId, { 127 | body: "Muted " + mention.text + " for reason " + reason + "!", 128 | msgtype: "m.notice", 129 | format: "org.matrix.custom.html", 130 | formatted_body: 131 | "Muted " + 132 | mention.html + 133 | " for reason " + 134 | htmlEscape(reason) + 135 | "!", 136 | }); 137 | } 138 | 139 | try { 140 | lookup_user(args[1]); 141 | } catch { 142 | return client.sendMessage(roomId, { 143 | body: "I don't think that user is in the graim database!", 144 | msgtype: "m.notice", 145 | format: "org.matrix.custom.html", 146 | formatted_body: "I don't think that user is in the graim database!", 147 | }); 148 | } 149 | 150 | rooms.forEach((roomId) => { 151 | if (user.includes("@") && user.includes(":")) { // TODO TODO TODO use real mxid validator 152 | client.setUserPowerLevel(lookup.user_matrix, roomId, -1).catch((err) => console.error(err)); 153 | } 154 | }); 155 | let user_discord = await guild.members.fetch(lookup.user_discord); 156 | user_discord.roles.add(mute_role).catch((err) => console.error(err)); 157 | 158 | setTimeout(() => { 159 | // once this time has passed, undo the mute! 160 | rooms.forEach((roomId) => { 161 | if (user.includes("@") && user.includes(":")) { // TODO TODO TODO use real mxid validator 162 | client.setUserPowerLevel(lookup.user_matrix, roomId, 0).catch((err) => console.error(err)); 163 | } 164 | }); 165 | user_discord.roles.remove(mute_role).catch((err) => console.error(err)); 166 | }, msToUnmute); 167 | 168 | db.users 169 | .filter((dbuser) => { 170 | return dbuser.name == lookup.graim_name; 171 | })[0] 172 | .strikes.push({ 173 | time: Date.now(), 174 | action: "mute", 175 | reason: reason, 176 | }); 177 | 178 | saveDB(db); 179 | 180 | let strikes = db.users.filter((dbuser) => { 181 | return dbuser.name == lookup.graim_name; 182 | })[0].strikes; 183 | 184 | log( 185 | { 186 | info: "Muted user", 187 | user: lookup.graim_name, 188 | reason: htmlEscape(reason), 189 | length: 190 | (unmuteTimeProvided ? command[2] : "1d") + " (" + msToUnmute + " ms)", 191 | caller: event.sender, 192 | }, 193 | false, 194 | client 195 | ); 196 | 197 | return client.sendMessage(roomId, { 198 | body: 199 | "Muted " + 200 | lookup.graim_name + 201 | " for reason " + 202 | reason + 203 | "!\nCurrent strikes: " + 204 | strikes.length, 205 | msgtype: "m.notice", 206 | format: "org.matrix.custom.html", 207 | formatted_body: 208 | "Muted " + 209 | lookup.graim_name + 210 | " for reason " + 211 | htmlEscape(reason) + 212 | "!
Current strikes: " + 213 | strikes.length, 214 | }); 215 | } 216 | -------------------------------------------------------------------------------- /src/commands/handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LogService, 3 | MatrixClient, 4 | MessageEvent, 5 | RichReply, 6 | UserID, 7 | } from "matrix-bot-sdk"; 8 | import { runKickCommand } from "./kick"; 9 | import { runBanCommand } from "./ban"; 10 | import { runUnbanCommand } from "./unban"; 11 | import config from "../config"; 12 | import * as htmlEscape from "escape-html"; 13 | import { runUserinfoCommand } from "./userinfo"; 14 | import { runMuteCommand } from "./mute"; 15 | import { runUnmuteCommand } from "./unmute"; 16 | import { runAddUserCommand } from "./adduser"; 17 | import { runDeleteUserCommand } from "./deleteuser"; 18 | import { startedWhen } from "../index"; 19 | import { runWhoSentCommand } from "./whosent"; 20 | import { runStrikeUserCommand } from "./strike"; 21 | import { runClearStrikesCommand } from "./clearstrikes"; 22 | import { runLockCommand } from "./lock"; 23 | import { runUnlockCommand } from "./unlock"; 24 | import { runBridgeCommand } from "./bridgeroom"; 25 | import { runUnbridgeCommand } from "./unbridgeroom"; 26 | import { runSetLoggingRoomCommand } from "./setloggingroom"; 27 | import { runLintCommand } from "./lint"; 28 | 29 | // The prefix required to trigger the bot. The bot will also respond 30 | // to being pinged directly. 31 | export const COMMAND_PREFIX = config.prefix; 32 | export let rooms; 33 | // This is where all of our commands will be handled 34 | export default class CommandHandler { 35 | // Just some variables so we can cache the bot's display name and ID 36 | // for command matching later. 37 | private displayName: string; 38 | private userId: string; 39 | private localpart: string; 40 | 41 | constructor(private client: MatrixClient) {} 42 | 43 | public async start() { 44 | // Populate the variables above (async) 45 | await this.prepareProfile(); 46 | 47 | // Set up the event handler 48 | this.client.on("room.message", this.onMessage.bind(this)); 49 | } 50 | 51 | private async prepareProfile() { 52 | this.userId = await this.client.getUserId(); 53 | this.localpart = new UserID(this.userId).localpart; 54 | 55 | try { 56 | const profile = await this.client.getUserProfile(this.userId); 57 | rooms = await this.client.getJoinedRooms(); 58 | if (profile && profile["displayname"]) 59 | this.displayName = profile["displayname"]; 60 | } catch (e) { 61 | // Non-fatal error - we'll just log it and move on. 62 | LogService.warn("CommandHandler", e); 63 | } 64 | } 65 | 66 | private async onMessage(roomId: string, ev: any) { 67 | const event = new MessageEvent(ev); 68 | if (event.isRedacted) return; // Ignore redacted events that come through 69 | if (event.sender === this.userId) return; // Ignore ourselves 70 | if (event.messageType !== "m.text") return; // Ignore non-text messages 71 | if (startedWhen > event.timestamp) return; 72 | 73 | // Ensure that the event is a command before going on. We allow people to ping 74 | // the bot as well as using our COMMAND_PREFIX. 75 | const prefixes = [ 76 | COMMAND_PREFIX, 77 | `${this.localpart}:`, 78 | `${this.displayName}:`, 79 | `${this.userId}:`, 80 | ]; 81 | const prefixUsed = prefixes.find((p) => event.textBody.startsWith(p)); 82 | if (!prefixUsed) return; // Not a command (as far as we're concerned) 83 | 84 | // Check to see what the arguments were to the command 85 | const args = event.textBody.substring(prefixUsed.length).trim().split(" "); 86 | const formatted_body = 87 | event.content?.["format"] === "org.matrix.custom.html" 88 | ? event.content?.["formatted_body"] 89 | : null; 90 | // Try and figure out what command the user ran 91 | try { 92 | switch (args[0]) { 93 | case "lint": 94 | runLintCommand(roomId, this.client); 95 | break; 96 | case "lock": 97 | runLockCommand(roomId, event, args, this.client, formatted_body); 98 | break; 99 | case "unlock": 100 | runUnlockCommand(roomId, event, args, this.client, formatted_body); 101 | break; 102 | case "kick": 103 | runKickCommand(roomId, event, args, this.client, formatted_body); 104 | break; 105 | case "ban": 106 | runBanCommand(roomId, event, args, this.client, formatted_body); 107 | break; 108 | case "unban": 109 | runUnbanCommand(roomId, event, args, this.client, formatted_body); 110 | break; 111 | case "userinfo": 112 | runUserinfoCommand(roomId, event, args, this.client, formatted_body); 113 | break; 114 | case "mute": 115 | runMuteCommand(roomId, event, args, this.client, formatted_body); 116 | break; 117 | case "unmute": 118 | runUnmuteCommand(roomId, event, args, this.client, formatted_body); 119 | break; 120 | case "adduser": 121 | runAddUserCommand(roomId, event, args, this.client, formatted_body); 122 | break; 123 | case "deleteuser": 124 | runDeleteUserCommand( 125 | roomId, 126 | event, 127 | args, 128 | this.client, 129 | formatted_body 130 | ); 131 | break; 132 | case "bridgeroom": 133 | runBridgeCommand(roomId, event, args, this.client, formatted_body); 134 | break; 135 | case "unbridgeroom": 136 | runUnbridgeCommand(roomId, event, this.client, formatted_body); 137 | break; 138 | case "setloggingroom": 139 | runSetLoggingRoomCommand(roomId, event, this.client, formatted_body); 140 | break; 141 | case "strike": 142 | runStrikeUserCommand( 143 | roomId, 144 | event, 145 | args, 146 | this.client, 147 | formatted_body 148 | ); 149 | break; 150 | case "clearstrikes": 151 | runClearStrikesCommand( 152 | roomId, 153 | event, 154 | args, 155 | this.client, 156 | formatted_body 157 | ); 158 | break; 159 | case "whosent": 160 | runWhoSentCommand(roomId, args, this.client); 161 | break; 162 | case "help": 163 | const help = 164 | `${COMMAND_PREFIX}lint - Tells you any possible misconfigurations\n` + 165 | `\n` + 166 | `${COMMAND_PREFIX}lock [room] - Locks room/channel\n` + 167 | `${COMMAND_PREFIX}unlock [room] - Unlocks room/channel\n` + 168 | `${COMMAND_PREFIX}kick [reason] - Kicks a user\n` + 169 | `${COMMAND_PREFIX}ban [reason] - Bans a user\n` + 170 | `${COMMAND_PREFIX}unban [reason] - Unbans a user\n` + 171 | `${COMMAND_PREFIX}mute [time:s,m,h,d] [reason] - Mutes a user\n` + 172 | `${COMMAND_PREFIX}unmute [reason] - Unmutes a user\n` + 173 | `${COMMAND_PREFIX}strike [reason] - Strikes a user\n` + 174 | `${COMMAND_PREFIX}clearstrikes [reason] - Clears all strikes from a user\n` + 175 | `\n` + 176 | `${COMMAND_PREFIX}adduser [moderator] - Adds user to graim database (for syncing moderation)\n` + 177 | `${COMMAND_PREFIX}deleteuser - Removes a user from graim database\n` + 178 | `${COMMAND_PREFIX}bridgeroom - Bridges a Discord channel to a Matrix room (for lock, unlock)\n` + 179 | `${COMMAND_PREFIX}unbridgeroom - Removes a room's bridge from the graim db\n` + 180 | `${COMMAND_PREFIX}setloggingroom - Sets the room to send logs to\n` + 181 | `${COMMAND_PREFIX}whosent - tells you what Matrix user sent a message\n` + 182 | `${COMMAND_PREFIX}userinfo [user] - Provides information about the user`; 183 | 184 | const text = `Help menu:\n${help}`; 185 | const html = `Help menu:
${htmlEscape(
186 |             help
187 |           )}
`.replace(/\\n/g, "
"); 188 | const reply = RichReply.createFor(roomId, ev, text, html); // Note that we're using the raw event, not the parsed one! 189 | reply["msgtype"] = "m.notice"; // Bots should always use notices 190 | return this.client.sendMessage(roomId, reply); 191 | } 192 | } catch (e) { 193 | // Log the error 194 | LogService.error("CommandHandler", e); 195 | 196 | // Tell the user there was a problem 197 | const message = "There was an error processing your command"; 198 | return this.client.replyNotice(roomId, ev, message); 199 | } 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/commands/discord_handler.ts: -------------------------------------------------------------------------------- 1 | import Discord = require("discord.js"); 2 | export const discord_client = new Discord.Client({ 3 | intents: [ 4 | // Discord API requires you explicitly request for each part of the API you want to access 5 | Discord.Intents.FLAGS.GUILDS, 6 | Discord.Intents.FLAGS.GUILD_MESSAGES, 7 | Discord.Intents.FLAGS.GUILD_MEMBERS, 8 | ], 9 | }); 10 | import config from "../config"; 11 | 12 | let nightly_songs = [ 13 | // :) 14 | "Deca - Waiting", 15 | "Bread - Dismal Day", 16 | "Bread Club - Late Night Fever", 17 | "Felix Rabito - Anna Muse", 18 | "Mindy Gledhill - Rabbit Hole", 19 | "Stoop Kids - Cup", 20 | "August Kuper - Headache", 21 | "Trash Panda - Heartbreak Pulsar", 22 | "The Happy Fits - Best Tears", 23 | "Brother Moses - Older", 24 | "FLEXPROPHET, Toast - outchea", 25 | "SN0WCRASH - Switching Off", 26 | "carpetgarden - Can Ghosts Be Gay?", 27 | "MANWOLVES - Georgia Peach", 28 | "dodie - Would You Be So Kind", 29 | "Sarah and the Sundays - Moving On", 30 | "Carver Commodore - Stranger Things", 31 | "King Shelter - Everything Hurts", 32 | "King Shelter - Everything Hurts", 33 | "King Shelter - Everything Hurts", 34 | 'Carver Commodore - Tell Me What You Want (I Want It) (Will "FRANKLIN" Chapman Remix)', 35 | "Conan Gray - Heather", 36 | "Felix Rabito - Bread", 37 | "Aesop Rock - None Shall Pass", 38 | "Kill Bill, Rav - lovedrug (off that)", 39 | "Piché, Clara Hannigan - Euphoria and Nosebleeds", 40 | "Fury Weekend, Scandroid - Euphoria", 41 | "marinelli - Turtleneck Sweater", 42 | "Watsky - Exquisite Corpse", 43 | "atlas - her", 44 | "The Breadheads - Ghosts", 45 | "Rejjie Snow - Relax", 46 | "Biosphere, khai dreams - Inner Peace", 47 | "People Under the Stairs - Selfish Destruction", 48 | "Rav - Wings", 49 | "Deca - Breadcrumbs", 50 | "Felix Rabito - Cure", 51 | "Toast - May Not Need Him", 52 | "Felix Rabito - Stay Away", 53 | "Deca - The Way In", 54 | "Carolyn Cleary - Mr. Happy Smile", 55 | "People Under the Stairs - The Sound of a Memory", 56 | "DROELOE - zZz", 57 | "The Notorious B.I.G. - Suicidal Thoughts", 58 | "King Shelter - People Change", 59 | "Bread Club - Predictable", 60 | "Destiny Rogers - Euphoria", 61 | "Mindy Gledhill - Boo Hoo!", 62 | "King Shelter - Dreams", 63 | "imkyami - Dead Plants", 64 | "King Shelter - Holy Ghost", 65 | "Rav - walls", 66 | "Felix Rabito - Gold", 67 | "King Shelter - Blue Pigz", 68 | "People Under the Stairs - Keepin It Live", 69 | "VHS - Eagle", 70 | "Rav - i don't even remember recording this but whatever i don't even care to be honest", 71 | "The Notorious B.I.G. - Warning (2007)", 72 | "love-sadKID - cash", 73 | "Felix Rabito - Kombucha Blues", 74 | "King Shelter - Hope & Smoke", 75 | "Rav - Tachyon", 76 | "Sugar Pine 7 - These Drugs", 77 | "King Shelter - CALAMITY", 78 | "K.A.A.N. - KAANCEPTS 2", 79 | "King Shelter - Preoccupy", 80 | "Deca - Salome", 81 | "Belaganas - Crumbs", 82 | "samsa - haunt me", 83 | "King Shelter - Failure", 84 | "Anarbor - Whiskey In Hell", 85 | "Deca - Silverline", 86 | "Kill Bill, Rav - dirge", 87 | "King Shelter - Gholy Host", 88 | "Ice Cube - It Was A Good Day", 89 | "Nas - Death Row East", 90 | "Good Kid - Tell Me You Know", 91 | "AJ Super - Nightmares", 92 | "khai dreams - Nice Colors", 93 | "Good Kid - Alchemist", 94 | "Rav - dying at the speed of light (a verse done a second time)", 95 | "King Shelter - Bag of Bones", 96 | "Anarbor - 18", 97 | "Good Kid - Everything Everything", 98 | "Al Bowlly - Heartaches", 99 | "Tally Hall - The Bidding", 100 | "Rav - You Fuckers Were Asking For This One", 101 | "The Rare Occasions - Origami", 102 | "King Shelter - Luvsub", 103 | "Passion Pit - Eyes As Candles", 104 | "Elton John - I'm Still Standing", 105 | "Noisettes - Don't Give Up", 106 | "Deca - Delilah", 107 | "Felix Rabito - Storm", 108 | "Brother Moses - Please Stop", 109 | "Big Data ft. Joywave - Dangerous", 110 | "The ME Band - Loaf of Bread Man", 111 | "1990nowhere - Grim Mary", 112 | "The Chordettes - Pink Shoe Laces", 113 | "Invisible Inc. - Same Way", 114 | "The Rare Occasions - Notion", 115 | "iamkyami - concrete rose", 116 | "Rav, Kill Bill, Scuare - Molasses", 117 | "atlas - sunshine", 118 | "khai dreams - in love", 119 | "Roderick Porter - i didn't realize how empty my bed was until you left", 120 | "King Shelter - Creature Preacher", 121 | "Ivory Hours - Boys Club", 122 | "Kill Bill - Dream Eater", 123 | "Joyner Lucas - The Problem", 124 | "Kuwada - Starlight", 125 | "King Shelter - Paradigm", 126 | "atlas - ayla", 127 | "People Under the Stairs - Plunken Em", 128 | "Al Bowlly - The Very Thought of You", 129 | "WOODKID - Run Boy Run", 130 | "The Doobie Brothers - What a Fool Believes", 131 | "Passion Pit - Lifted Up (1985)", 132 | "lando - sweater", 133 | "Andy Leon - Breadcrumbs", 134 | "Tally Hall - Hymn for a Scarecrow", 135 | "WOODKID - Goliath", 136 | "Rick Astley - Unwanted", 137 | "grouptherapy - raise it up!", 138 | "Rav - A Better Place", 139 | "omniboi - amnesia", 140 | "atlas - in between", 141 | "Jeff Touhy - Bourbon Street", 142 | "Passion Pit - Sleepyhead", 143 | "Good Kid - Orbit", 144 | "Rav - My Time", 145 | "atlas - valentine", 146 | "Bready - Boy (don't try)", 147 | "The Treacherous Three, Spoonie Gee - The New Rap Language", 148 | "samsa, atlas - anthropocene", 149 | "K.A.A.N. - Dispatch", 150 | "The Oxford Choir - The Music of Stillness [SATB]", 151 | "Good Kid - Atlas", 152 | "fun. - Some Nights", 153 | "Møbius - Stings", 154 | "King Shelter - Sellout", 155 | "Deca - Edenville", 156 | "Felix Rabito - Petrified", 157 | "Kill Bill - About Last Night", 158 | "Skout - Sting", 159 | "if.else - High Hopes", 160 | "Manic Morrow - Bind", 161 | "Passion Pit - Moth's Wings (stripped down)", 162 | "Felix Rabito - Please", 163 | "Kaius - Twisted", 164 | "Deca - Gabriel Ratchet", 165 | "Rav - Stasis Tank", 166 | "Møbius - Mind", 167 | "atlas - early graves", 168 | "samsa - solo", 169 | "King Shelter - Antidote", 170 | "Jara, Near Tears - Switchblade", 171 | "People Under the Stairs - The Wiz", 172 | "New Move - When Did We Stop", 173 | "Bear Ghost - Sirens", 174 | "Ren - Money Game", 175 | "Shutups - NSA", 176 | "atlas - call failed", 177 | "bo en - sometimes", 178 | "Röyksopp - Vision One", 179 | "exociety - EXP Share", 180 | "Ren - Jenny's Tale", 181 | "wych elm - scolds bridle", 182 | "Aesop Rock - Lice", 183 | "Shutups - Yellowjacket", 184 | "Kill Bill - ib", 185 | "Al Bowlly - They Say", 186 | "Møbius - Snowflakes", 187 | "AJR - 3 O'Clock Things", 188 | "Passion Pit - Whole Life Story", 189 | "meltycanon - brittle", 190 | "Ren - Screech's Tale", 191 | "Deca - False Light", 192 | "if.else - Self Driving", 193 | "Max Sensibar - I'm Not Gonna Hold Your Hand", 194 | "Kill Bill - DoNotDisturb", 195 | "atlas - they/them", 196 | "The Technicolors - Howl", 197 | "Wes Park - If Nothing Else Mattered", 198 | "Sister. - Fighter", 199 | "love-sadKID, Kill Bill - a lesson in silence", 200 | "Deca - Skyward", 201 | "Daniel Caesar - Blessed", 202 | "atlas - you're my world", 203 | "Belaganas - Lean On Me", 204 | "Good Kid - Pox", 205 | "Ren, Chinchilla - How To Be Me", 206 | "King Shelter - Pick Your Poison", 207 | "Bready - Where Did The Time Go (do i move too slowly?)", 208 | "Kill Kill - Cigarettes", 209 | "Tally Hall - &", 210 | "Nas - Memory Lane", 211 | "Kill Bill, Rav - when i am successful i'mma buy a neo geo", 212 | "Louis Armstrong - What a Wonderful World", 213 | "joe p - leaves", 214 | "Bruno Major - Old Fashioned", 215 | "Rav - ANXIETY PERSISTS [a verse done once]", 216 | "Ren, Sam Tompkins - Blind Eyed (live)", 217 | "gianni and kyle - do u even miss me at all?", 218 | "Kill Bill - snowdancer", 219 | "Bread - Everything I Own", 220 | "Good Kid - Drifting", 221 | "ARMORS - Revolvers", 222 | "Daniela Andrade - Creep", 223 | "1990nowhere - Watergun", 224 | "Belaganas - Silk", 225 | "Rob Cantor - Old Bike", 226 | "love-sadKID - Stalemate", 227 | "Night Talks - Green", 228 | "People Under the Stairs - Acid Raindrops", 229 | "Tally Hall - Just Apathy", 230 | "Rav - sociesuicide", 231 | "Feed Me Jack - Promiscuity", 232 | "Max Sensibar - Bright Side", 233 | "Good Kid - Nomu", 234 | "King Shelter - Gimme Knowledge", 235 | "K.A.A.N. - Reaper", 236 | "fun. - Carry On", 237 | "senses - When It Rains", 238 | "Frank Sinatra - Pennies From Heaven", 239 | "atlas - chamomile", 240 | "Bready - I Don't Wanna (hear what you gotta say)", 241 | "The Rare Occasions - Futureproof", 242 | "OCS, atlas - Rocketman", 243 | "Tally Hall - A Lady", 244 | "Ren, Eden Nash - Ocean", 245 | "Kill Bill - What To Say", 246 | "ARMORS - Comatose", 247 | "Brother Moses - Someone Make It Stop!", 248 | "Rav - this was a demo for this one song but i couldn't find the more up to date version so this one will have to do sorry", 249 | "People Under the Stairs - Mid-City Fiesta", 250 | "Made Violent - On My Own", 251 | "Tally Hall - Hidden In the Sand", 252 | "Made Violent - Two Tone Hair", 253 | "Skee-Lo - I Wish", 254 | "Ren, Eden Nash - Humble", 255 | "Lauryn Hill - Ex-Factor", 256 | "Deca - Clockwork", 257 | "Jim Yosef - Hate You", 258 | "Louis Armstrong - A Kiss to Build a Dream On", 259 | "King Shelter - Dust of L.A.", 260 | "Belaganas - Room 4 U", 261 | "Aesop Rock - Catacomb Kids", 262 | "atlas - back then", 263 | "Rob Cantor - I'm Gonna Win", 264 | "Ren - love music 3", 265 | "Jeff Tuohy - Monogamy", 266 | "Bails - Questionnaire For an Idiot", 267 | "shiey - element", 268 | "People Under the Stairs - Tales of Kidd Drunkadelic", 269 | "King Shelter - I've Seen Worse", 270 | "Boba Boyz - Thai Tea Trippin'", 271 | "Tally Hall - Fate of the Stars", 272 | "The Rare Occasions - The Fold", 273 | "Rav - Neuroframe 14", 274 | "shiey - no lanes", 275 | "Belaganas - Hashtag.AD", 276 | "Rob Cantor - Perfect", 277 | "Good Kid - Faster", 278 | "Al Bowlly - Blow Blow Thou Winter Wind", 279 | "atlas - final form!", 280 | "Lauryn Hill - I Used to Love Him", 281 | "Kill Bill, Rav, Square - down.exe", 282 | "Ella Fitzgerald - They Can't Take That Away From Me", 283 | "Dagny - Wearing Nothing", 284 | "Near Tears - Was It Worth The Love Song", 285 | "atlas - broken record", 286 | "Fats'e - salty!", 287 | "The Moxies - Main Street Drive-In", 288 | "Eminem - Remember Me?", 289 | "Ren - Blind Eyed", 290 | "King Shelter - Goodbye Horses", 291 | "The Rare Occasions - notion (acoustic)", 292 | "Passion Pit - You Have The Right", 293 | "Ren, Chinchilla - Chalk Outlines", 294 | "Rav - Me? Never", 295 | "King Shelter - Searching for Alchemy", 296 | "Ryan Leahan - Steal My Bike", 297 | "Watsky - Strong As An Oak", 298 | "People Under the Stairs - Let The Record Show", 299 | ]; 300 | 301 | export let guild; 302 | export let mute_role; 303 | 304 | const rotate_status = () => { 305 | // :) 306 | discord_client.user.setActivity( 307 | nightly_songs[Math.floor(Math.random() * nightly_songs.length)], 308 | { type: "LISTENING" } 309 | ); 310 | setTimeout(rotate_status, 120000); // 2 mins 311 | }; 312 | 313 | discord_client.on("ready", () => { 314 | console.info("Discord bot started! Logged in: " + discord_client.user.tag); 315 | rotate_status(); 316 | guild = discord_client.guilds.resolve(config.discordGuild); // get the graim guild 317 | mute_role = guild.roles.cache.get(config.discordMutedRole); // get the muted role 318 | }); 319 | 320 | discord_client.login(config.discordToken); 321 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------