├── tests ├── __init__.py ├── test_util_timing.py ├── test_util_redis.py ├── test_util_input.py └── test_types.py ├── frontend ├── logs │ └── .keep ├── src │ ├── static │ │ ├── js │ │ │ ├── archive.js │ │ │ ├── app.js │ │ │ ├── script.js │ │ │ ├── metisMenu.min.js │ │ │ └── dataTables.bootstrap.min.js │ │ ├── fonts │ │ │ ├── fontawesome-webfont.eot │ │ │ ├── fontawesome-webfont.ttf │ │ │ ├── fontawesome-webfont.woff │ │ │ └── fontawesome-webfont.woff2 │ │ ├── css │ │ │ ├── metisMenu.min.css │ │ │ ├── solarized-dark.css │ │ │ ├── dataTables.responsive.css │ │ │ └── dataTables.bootstrap.css │ │ └── assets │ │ │ ├── ic_overflow_vertical_grey_16px.svg │ │ │ └── ic_reactions_grey_16px.svg │ ├── config │ │ ├── docker.js │ │ ├── development.js │ │ └── production.js │ ├── models │ │ ├── base.js │ │ ├── user.js │ │ └── guild.js │ ├── components │ │ ├── guild_stats.js │ │ ├── page_header.js │ │ ├── login.js │ │ ├── topbar.js │ │ ├── app.js │ │ ├── guilds_table.js │ │ ├── guild_overview.js │ │ ├── sidebar.js │ │ ├── dashboard.js │ │ ├── guild_infractions.js │ │ └── guild_config_edit.js │ ├── index.js │ ├── index.html │ └── state.js ├── .npmrc ├── .gitignore ├── .babelrc ├── Dockerfile ├── serve.js └── package.json ├── rowboat ├── views │ ├── __init__.py │ ├── users.py │ ├── auth.py │ ├── dashboard.py │ └── guilds.py ├── plugins │ ├── modlog │ │ ├── __init__.py │ │ └── pump.py │ ├── __init__.py │ ├── stats.py │ ├── internal.py │ ├── reddit.py │ └── censor.py ├── config.py ├── models │ ├── migrations │ │ ├── 0006_user_admin.py │ │ ├── 0003_nullable_avatar.py │ │ ├── 0006_new_guild_config.py │ │ ├── 0010_add_message_command.py │ │ ├── 0012_add_channel_type.py │ │ ├── 0012_rowboat_premium.py │ │ ├── 0005_add_message_attachments.py │ │ ├── 0007_add_infractions_metadata.py │ │ ├── 0002_non_nullable_guild_fields.py │ │ ├── 0008_add_starboard_block.py │ │ ├── 0001_add_guild_fields.py │ │ ├── 0004_fix_infractions_enum.py │ │ ├── 0011_use_more_arrays.py │ │ ├── 0009_use_arrays_idiot.py │ │ └── __init__.py │ ├── __init__.py │ ├── tags.py │ ├── event.py │ ├── channel.py │ └── notification.py ├── util │ ├── escape.py │ ├── gevent.py │ ├── decos.py │ ├── stats.py │ ├── redis.py │ ├── __init__.py │ ├── timing.py │ ├── leakybucket.py │ ├── input.py │ ├── zalgo.py │ └── images.py ├── redis.py ├── types │ ├── plugin.py │ ├── guild.py │ └── __init__.py ├── templates │ ├── archive.html │ ├── base.html │ └── dashboard.html ├── __init__.py ├── tasks │ ├── backfill.py │ └── __init__.py ├── web.py ├── sql.py └── constants.py ├── docs ├── .gitignore ├── SUMMARY.md ├── book.json └── README.md ├── .gitignore ├── README.md ├── .travis.yml ├── docker ├── database │ ├── Dockerfile │ ├── initdb.sh │ └── postgres-healthcheck.sh └── nulld.py ├── Dockerfile ├── data ├── badwords.txt └── actions_simple.yaml ├── Makefile ├── config.example.yaml ├── LICENSE ├── docker-compose.yml ├── requirements.txt ├── manage.py └── CHANGELOG.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/logs/.keep: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /rowboat/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/static/js/archive.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=true 2 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | _book/ 3 | -------------------------------------------------------------------------------- /docs/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | * [About](README.md) 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/config/docker.js: -------------------------------------------------------------------------------- 1 | export var STATS_ENABLED = false; 2 | -------------------------------------------------------------------------------- /frontend/src/config/development.js: -------------------------------------------------------------------------------- 1 | export var STATS_ENABLED = true; 2 | -------------------------------------------------------------------------------- /frontend/src/config/production.js: -------------------------------------------------------------------------------- 1 | export var STATS_ENABLED = false; 2 | -------------------------------------------------------------------------------- /rowboat/plugins/modlog/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import ModLogPlugin, Actions 2 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | node_modules/ 3 | public/ 4 | logs/*.json 5 | .cache/ 6 | dist/ 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | rowboat-*.json 2 | config.yaml 3 | *.pyc 4 | .venv/ 5 | *.log 6 | .data 7 | /src/ 8 | .vscode/ -------------------------------------------------------------------------------- /frontend/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": ["transform-react-jsx"] 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jetski 2 | 3 | Jetski is a moderation and utilitarian bot based on [Rowboat](https://github.com/b1naryth1ef/rowboat). 4 | -------------------------------------------------------------------------------- /frontend/src/static/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThaTiemsz/jetski/HEAD/frontend/src/static/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /frontend/src/static/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThaTiemsz/jetski/HEAD/frontend/src/static/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /frontend/src/static/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThaTiemsz/jetski/HEAD/frontend/src/static/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /rowboat/config.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | 3 | with open('config.yaml', 'r') as f: 4 | loaded = yaml.safe_load(f.read()) 5 | locals().update(loaded) 6 | -------------------------------------------------------------------------------- /frontend/src/static/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThaTiemsz/jetski/HEAD/frontend/src/static/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | cache: pip 4 | 5 | services: 6 | - redis-server 7 | 8 | python: 9 | - '2.7' 10 | 11 | script: 'py.test tests' 12 | -------------------------------------------------------------------------------- /frontend/src/models/base.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'eventemitter3'; 2 | 3 | export default class BaseModel { 4 | constructor() { 5 | this.events = new EventEmitter(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /docker/database/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:9.6.3 2 | ENV POSTGRES_USER rowboat 3 | COPY postgres-healthcheck.sh /usr/local/bin/ 4 | COPY initdb.sh /docker-entrypoint-initdb.d/ 5 | HEALTHCHECK CMD ["postgres-healthcheck.sh"] 6 | -------------------------------------------------------------------------------- /docs/book.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Rowboat", 3 | "plugins": ["prism", "-highlight", "hints"], 4 | "pluginsConfig": { 5 | "anchorjs": { 6 | "placement": "left", 7 | "visible": "always" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /docker/database/initdb.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | psql -v ON_ERROR_STOP=1 --username "postgres" -d rowboat -c "CREATE EXTENSION hstore;" 5 | psql -v ON_ERROR_STOP=1 --username "postgres" -d rowboat -c "CREATE EXTENSION pg_trgm;" 6 | -------------------------------------------------------------------------------- /docker/nulld.py: -------------------------------------------------------------------------------- 1 | import socket 2 | 3 | UDP_IP = "0.0.0.0" 4 | UDP_PORT = 8125 5 | 6 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 7 | sock.bind((UDP_IP, UDP_PORT)) 8 | 9 | while True: 10 | sock.recvfrom(1024) 11 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8.5.0 2 | 3 | RUN mkdir /opt/frontend 4 | 5 | ADD package.json /opt/frontend 6 | ADD package-lock.json /opt/frontend 7 | RUN cd /opt/frontend && npm install 8 | 9 | ADD src /opt/frontend/src 10 | WORKDIR /opt/frontend 11 | -------------------------------------------------------------------------------- /rowboat/models/migrations/0006_user_admin.py: -------------------------------------------------------------------------------- 1 | from rowboat.models.migrations import Migrate 2 | from rowboat.models.user import User 3 | 4 | 5 | @Migrate.only_if(Migrate.missing, User, 'admin') 6 | def add_guild_columns(m): 7 | m.add_columns(User, User.admin) 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:2.7.13 2 | 3 | ENV PYTHONUNBUFFERED 1 4 | ENV ENV docker 5 | 6 | RUN mkdir /opt/rowboat 7 | 8 | ADD requirements.txt /opt/rowboat/ 9 | RUN pip install -r /opt/rowboat/requirements.txt 10 | 11 | ADD . /opt/rowboat/ 12 | WORKDIR /opt/rowboat 13 | -------------------------------------------------------------------------------- /rowboat/models/migrations/0003_nullable_avatar.py: -------------------------------------------------------------------------------- 1 | from rowboat.models.migrations import Migrate 2 | from rowboat.models.guild import User 3 | 4 | 5 | @Migrate.only_if(Migrate.non_nullable, User, 'avatar') 6 | def alter_guild_columns(m): 7 | m.drop_not_nulls(User, User.avatar) 8 | -------------------------------------------------------------------------------- /rowboat/models/migrations/0006_new_guild_config.py: -------------------------------------------------------------------------------- 1 | from rowboat.models.migrations import Migrate 2 | from rowboat.models.guild import Guild 3 | 4 | 5 | @Migrate.only_if(Migrate.missing, Guild, 'config_raw') 6 | def add_guild_columns(m): 7 | m.add_columns(Guild, Guild.config_raw) 8 | -------------------------------------------------------------------------------- /rowboat/models/migrations/0010_add_message_command.py: -------------------------------------------------------------------------------- 1 | from rowboat.models.migrations import Migrate 2 | from rowboat.models.message import Message 3 | 4 | 5 | @Migrate.only_if(Migrate.missing, Message, 'command') 6 | def add_guild_columns(m): 7 | m.add_columns(Message, Message.command) 8 | -------------------------------------------------------------------------------- /rowboat/models/migrations/0012_add_channel_type.py: -------------------------------------------------------------------------------- 1 | from rowboat.models.migrations import Migrate 2 | from rowboat.models.channel import Channel 3 | 4 | 5 | @Migrate.only_if(Migrate.missing, Channel, 'type_') 6 | def add_channel_type_column(m): 7 | m.add_columns(Channel, Channel.type_) 8 | -------------------------------------------------------------------------------- /rowboat/models/migrations/0012_rowboat_premium.py: -------------------------------------------------------------------------------- 1 | from rowboat.models.migrations import Migrate 2 | from rowboat.models.guild import Guild 3 | 4 | 5 | @Migrate.only_if(Migrate.missing, Guild, 'premium_sub_id') 6 | def add_channel_type_column(m): 7 | m.add_columns(Guild, Guild.premium_sub_id) 8 | -------------------------------------------------------------------------------- /rowboat/models/__init__.py: -------------------------------------------------------------------------------- 1 | from rowboat.models.channel import * 2 | from rowboat.models.event import * 3 | from rowboat.models.guild import * 4 | from rowboat.models.message import * 5 | from rowboat.models.notification import * 6 | from rowboat.models.user import * 7 | from rowboat.models.tags import * 8 | -------------------------------------------------------------------------------- /rowboat/models/migrations/0005_add_message_attachments.py: -------------------------------------------------------------------------------- 1 | from rowboat.models.migrations import Migrate 2 | from rowboat.models.message import Message 3 | 4 | 5 | @Migrate.only_if(Migrate.missing, Message, 'attachments') 6 | def add_guild_columns(m): 7 | m.add_columns(Message, Message.attachments) 8 | -------------------------------------------------------------------------------- /rowboat/models/migrations/0007_add_infractions_metadata.py: -------------------------------------------------------------------------------- 1 | from rowboat.models.migrations import Migrate 2 | from rowboat.models.user import Infraction 3 | 4 | 5 | @Migrate.only_if(Migrate.missing, Infraction, 'metadata') 6 | def add_guild_columns(m): 7 | m.add_columns(Infraction, Infraction.metadata) 8 | -------------------------------------------------------------------------------- /frontend/src/components/guild_stats.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import {globalState} from '../state'; 3 | import {withRouter} from 'react-router'; 4 | import {PREMIUM_ENABLED} from '../config/docker'; 5 | 6 | export default class GuildStats extends Component { 7 | render() { 8 | return (

TEST

); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /data/badwords.txt: -------------------------------------------------------------------------------- 1 | anal 2 | anus 3 | ballsack 4 | blowjob 5 | blow job 6 | boner 7 | clitoris 8 | cock 9 | cunt 10 | dick 11 | dildo 12 | dyke 13 | fag 14 | fuck 15 | jizz 16 | labia 17 | muff 18 | nigger 19 | nigga 20 | penis 21 | piss 22 | pussy 23 | scrotum 24 | sex 25 | shit 26 | slut 27 | smegma 28 | spunk 29 | twat 30 | vagina 31 | wank 32 | whore 33 | -------------------------------------------------------------------------------- /rowboat/models/migrations/0002_non_nullable_guild_fields.py: -------------------------------------------------------------------------------- 1 | from rowboat.models.migrations import Migrate 2 | from rowboat.models.guild import Guild 3 | 4 | 5 | @Migrate.only_if(Migrate.nullable, Guild, 'owner_id') 6 | def alter_guild_columns(m): 7 | m.add_not_nulls(Guild, 8 | Guild.owner_id, 9 | Guild.name, 10 | Guild.region) 11 | -------------------------------------------------------------------------------- /rowboat/util/escape.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | transformations = { 4 | re.escape(c): '\\' + c for c in ('*', '`', '_', '~~', '\\', '||') 5 | } 6 | 7 | def replace(obj): 8 | return transformations.get(re.escape(obj.group(0)), '') 9 | 10 | def E(text): 11 | pattern = re.compile('|'.join(transformations.keys())) 12 | text = pattern.sub(replace, text) 13 | 14 | return text 15 | -------------------------------------------------------------------------------- /rowboat/redis.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import os 4 | import json 5 | 6 | import redis 7 | 8 | ENV = os.getenv('ENV', 'local') 9 | 10 | if ENV == 'docker': 11 | rdb = redis.Redis(db=0, host='redis') 12 | else: 13 | rdb = redis.Redis(db=11) 14 | 15 | 16 | def emit(typ, **kwargs): 17 | kwargs['type'] = typ 18 | rdb.publish('actions', json.dumps(kwargs)) 19 | -------------------------------------------------------------------------------- /frontend/src/components/page_header.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | class PageHeader extends Component { 4 | render() { 5 | return ( 6 |
7 |
8 |

{this.props.name}

9 |
10 |
11 | ); 12 | } 13 | } 14 | 15 | export default PageHeader; 16 | -------------------------------------------------------------------------------- /rowboat/models/migrations/0008_add_starboard_block.py: -------------------------------------------------------------------------------- 1 | from rowboat.models.migrations import Migrate 2 | from rowboat.models.message import StarboardEntry 3 | 4 | 5 | @Migrate.only_if(Migrate.missing, StarboardEntry, 'blocked_stars') 6 | def add_guild_columns(m): 7 | m.add_columns( 8 | StarboardEntry, 9 | StarboardEntry.blocked_stars, 10 | StarboardEntry.blocked 11 | ) 12 | -------------------------------------------------------------------------------- /rowboat/types/plugin.py: -------------------------------------------------------------------------------- 1 | import six 2 | 3 | from rowboat.types import SlottedModel 4 | 5 | 6 | class PluginConfig(SlottedModel): 7 | def load(self, obj, *args, **kwargs): 8 | obj = { 9 | k: v for k, v in six.iteritems(obj) 10 | if k in self._fields and not self._fields[k].metadata.get('private') 11 | } 12 | return super(PluginConfig, self).load(obj, *args, **kwargs) 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | up: 2 | docker-compose up -d 3 | 4 | restart: 5 | docker-compose restart 6 | 7 | stop: 8 | docker-compose stop 9 | 10 | down: 11 | docker-compose down 12 | 13 | build: 14 | docker-compose up -d --no-deps --build 15 | 16 | cli: 17 | docker-compose exec bot /bin/bash 18 | 19 | worker-logs: 20 | docker-compose exec workers tail -F worker-0.log 21 | 22 | logs: 23 | docker-compose logs -f --tail="1500" $(image) 24 | -------------------------------------------------------------------------------- /frontend/serve.js: -------------------------------------------------------------------------------- 1 | const proxy = require('http-proxy-middleware'); 2 | const Bundler = require('parcel-bundler'); 3 | const express = require('express'); 4 | 5 | const bundler = new Bundler(['./src/index.html'], { 6 | cache: false 7 | }); 8 | const app = express(); 9 | 10 | app.use('/api', proxy({ target: 'http://web:8686' })); 11 | 12 | app.use(bundler.middleware()); 13 | 14 | app.listen(Number(process.env.PORT || 8080)); 15 | -------------------------------------------------------------------------------- /rowboat/models/migrations/0001_add_guild_fields.py: -------------------------------------------------------------------------------- 1 | from rowboat.models.migrations import Migrate 2 | from rowboat.models.guild import Guild 3 | 4 | 5 | @Migrate.only_if(Migrate.missing, Guild, 'owner_id') 6 | def add_guild_columns(m): 7 | m.add_columns(Guild, 8 | Guild.owner_id, 9 | Guild.name, 10 | Guild.icon, 11 | Guild.splash, 12 | Guild.region, 13 | Guild.last_ban_sync) 14 | -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | function init() { 5 | // HMR requires that this be a require() 6 | let App = require('./components/app').default; 7 | const archive = document.getElementById('archive'); 8 | ReactDOM.render(, archive ? archive : document.getElementById('app')); 9 | } 10 | 11 | init(); 12 | 13 | if (module.hot) module.hot.accept('./components/app', init); 14 | -------------------------------------------------------------------------------- /rowboat/util/gevent.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import gevent 4 | 5 | 6 | def wait_many(*args, **kwargs): 7 | def _async(): 8 | for awaitable in args: 9 | awaitable.wait() 10 | 11 | gevent.spawn(_async).get(timeout=kwargs.get('timeout', None)) 12 | 13 | if kwargs.get('track_exceptions', True): 14 | from rowboat import raven_client 15 | for awaitable in args: 16 | if awaitable.exception: 17 | raven_client.captureException(exc_info=awaitable.exc_info) 18 | -------------------------------------------------------------------------------- /rowboat/util/decos.py: -------------------------------------------------------------------------------- 1 | from flask import g, jsonify 2 | from httplib import FORBIDDEN 3 | 4 | import functools 5 | 6 | 7 | def _authed(func): 8 | @functools.wraps(func) 9 | def deco(*args, **kwargs): 10 | if not hasattr(g, 'user') or not g.user: 11 | return jsonify({'error': 'Authentication Required'}), FORBIDDEN 12 | 13 | return func(*args, **kwargs) 14 | return deco 15 | 16 | 17 | def authed(func=None): 18 | if callable(func): 19 | return _authed(func) 20 | else: 21 | return functools.partial(_authed) 22 | -------------------------------------------------------------------------------- /docker/database/postgres-healthcheck.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eo pipefail 3 | 4 | host="$(hostname -i || echo '127.0.0.1')" 5 | user="${POSTGRES_USER:-postgres}" 6 | db="${POSTGRES_DB:-$POSTGRES_USER}" 7 | export PGPASSWORD="${POSTGRES_PASSWORD:-}" 8 | 9 | args=( 10 | # force postgres to not use the local unix socket (test "external" connectibility) 11 | --host "$host" 12 | --username "$user" 13 | --dbname "$db" 14 | --quiet --no-align --tuples-only 15 | ) 16 | 17 | if select="$(echo 'SELECT 1' | psql "${args[@]}")" && [ "$select" = '1' ]; then 18 | exit 0 19 | fi 20 | 21 | exit 1 22 | -------------------------------------------------------------------------------- /rowboat/util/stats.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from contextlib import contextmanager 4 | from datadog import statsd 5 | 6 | 7 | def to_tags(obj=None, **kwargs): 8 | if obj: 9 | kwargs.update(obj) 10 | return ['{}:{}'.format(k, v) for k, v in kwargs.items()] 11 | 12 | 13 | @contextmanager 14 | def timed(metricname, tags=None): 15 | start = time.time() 16 | try: 17 | yield 18 | except: 19 | raise 20 | finally: 21 | if tags and isinstance(tags, dict): 22 | tags = to_tags(tags) 23 | statsd.timing(metricname, (time.time() - start) * 1000, tags=tags) 24 | -------------------------------------------------------------------------------- /rowboat/models/tags.py: -------------------------------------------------------------------------------- 1 | from peewee import ( 2 | BigIntegerField, TextField, DateTimeField, CompositeKey, IntegerField 3 | ) 4 | from datetime import datetime 5 | 6 | from rowboat.sql import BaseModel 7 | 8 | 9 | @BaseModel.register 10 | class Tag(BaseModel): 11 | guild_id = BigIntegerField() 12 | author_id = BigIntegerField() 13 | 14 | name = TextField() 15 | content = TextField() 16 | times_used = IntegerField(default=0) 17 | 18 | created_at = DateTimeField(default=datetime.utcnow) 19 | 20 | class Meta: 21 | db_table = 'tags' 22 | primary_key = CompositeKey('guild_id', 'name') 23 | -------------------------------------------------------------------------------- /rowboat/models/migrations/0004_fix_infractions_enum.py: -------------------------------------------------------------------------------- 1 | from holster.enum import Enum 2 | 3 | from rowboat.models.migrations import Migrate 4 | 5 | BeforeTypes = Enum( 6 | 'KICK', 7 | 'TEMPBAN', 8 | 'SOFTBAN', 9 | 'BAN', 10 | ) 11 | 12 | AfterTypes = Enum( 13 | 'MUTE', 14 | 'KICK', 15 | 'TEMPBAN', 16 | 'SOFTBAN', 17 | 'BAN', 18 | bitmask=False, 19 | ) 20 | 21 | 22 | @Migrate.always() 23 | def alter_guild_columns(m): 24 | for typ in BeforeTypes.attrs: 25 | m.execute('UPDATE infractions SET type=%s WHERE type=%s', ( 26 | AfterTypes[typ.name].index, typ.index 27 | )) 28 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Rowboat 2 | 3 | Rowboat is a multi-purpose moderation and utility bot for Discord. It was built to help maintain and moderate extremely large servers, and thus it bears a unique and refined feature-set. 4 | 5 | ## Dynamic Configuration 6 | 7 | Rowboat allows for in-depth configuration of it's plugins and commands through a single dynamic YAML configuration definition. Through the web interface administrators of a server can modify and update this configuration, which is reloaded instantly. This configuration format allows moderators and administrators extreme control over their server, while remaining agile for situations where critical adjustments need to be made instantly. 8 | 9 | -------------------------------------------------------------------------------- /tests/test_util_timing.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from gevent.event import AsyncResult 4 | 5 | from datetime import datetime, timedelta 6 | from rowboat.util.timing import Eventual 7 | 8 | 9 | class TestEventual(unittest.TestCase): 10 | def test_eventual_accuracy(self): 11 | result = AsyncResult() 12 | should_be_called_at = None 13 | 14 | def f(): 15 | result.set(datetime.utcnow()) 16 | 17 | e = Eventual(f) 18 | should_be_called_at = datetime.utcnow() + timedelta(milliseconds=100) 19 | e.set_next_schedule(should_be_called_at) 20 | called_at = result.get() 21 | self.assertGreater(called_at, should_be_called_at) 22 | -------------------------------------------------------------------------------- /frontend/src/static/js/app.js: -------------------------------------------------------------------------------- 1 | function notify(level, msg) { 2 | $(".alert").remove(); 3 | var div = $('
' + msg + '
'); 4 | $("#page-wrapper").prepend(div); 5 | div.delay(6000).fadeOut(); 6 | } 7 | 8 | /* if (document.querySelector("div.panel-primary")) { 9 | setTimeout(async() => { 10 | const res = await fetch("/api/stats", { method: "GET" }); 11 | const stats = await res.json(); 12 | const color = { 13 | messages: "primary", 14 | guilds: "green", 15 | users: "yellow", 16 | channels: "red" 17 | }; 18 | for (const key in stats) { 19 | $(`div.panel-${color[key]} .huge`).text(stats[key]); 20 | } 21 | }, 1500); 22 | } */ -------------------------------------------------------------------------------- /tests/test_util_redis.py: -------------------------------------------------------------------------------- 1 | import time 2 | import unittest 3 | 4 | from gevent import monkey; monkey.patch_all() 5 | 6 | from rowboat.redis import rdb 7 | from rowboat.util.redis import RedisSet 8 | 9 | 10 | class TestRedisSet(unittest.TestCase): 11 | def test_basic_set(self): 12 | rdb.delete('TESTING:test-set') 13 | s1 = RedisSet(rdb, 'TESTING:test-set') 14 | s2 = RedisSet(rdb, 'TESTING:test-set') 15 | 16 | s1.add('1') 17 | s2.add('2') 18 | s1.add('3') 19 | s2.add('4') 20 | s1.add('4') 21 | s2.remove('4') 22 | s1.remove('3') 23 | s1.add('6') 24 | 25 | time.sleep(1) 26 | 27 | self.assertEquals(s1._set, s2._set) 28 | -------------------------------------------------------------------------------- /rowboat/templates/archive.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Jetski Archives 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {% block realbody %} 15 |
16 | 17 | 18 | 19 | 20 | {% block scripts %} 21 | {% endblock %} 22 | {% endblock %} 23 | 24 | 25 | -------------------------------------------------------------------------------- /rowboat/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import subprocess 4 | 5 | from disco.util.logging import LOG_FORMAT 6 | from raven import Client 7 | from raven.transport.gevent import GeventedHTTPTransport 8 | 9 | ENV = os.getenv('ENV', 'local') 10 | DSN = os.getenv('DSN') 11 | REV = subprocess.check_output(['git', 'rev-parse', 'HEAD']).strip() 12 | 13 | VERSION = '1.3.0' 14 | 15 | raven_client = Client( 16 | DSN, 17 | ignore_exceptions=[ 18 | 'KeyboardInterrupt', 19 | ], 20 | release=REV, 21 | environment=ENV, 22 | transport=GeventedHTTPTransport, 23 | ) 24 | 25 | # Log things to file 26 | file_handler = logging.FileHandler('rowboat.log') 27 | log = logging.getLogger() 28 | file_handler.setFormatter(logging.Formatter(LOG_FORMAT)) 29 | log.addHandler(file_handler) 30 | -------------------------------------------------------------------------------- /frontend/src/static/css/metisMenu.min.css: -------------------------------------------------------------------------------- 1 | /* 2 | * metismenu - v1.1.3 3 | * Easy menu jQuery plugin for Twitter Bootstrap 3 4 | * https://github.com/onokumus/metisMenu 5 | * 6 | * Made by Osman Nuri Okumus 7 | * Under MIT License 8 | */ 9 | 10 | .arrow{float:right;line-height:1.42857}.glyphicon.arrow:before{content:"\e079"}.active>a>.glyphicon.arrow:before{content:"\e114"}.fa.arrow:before{content:"\f104"}.active>a>.fa.arrow:before{content:"\f107"}.plus-times{float:right}.fa.plus-times:before{content:"\f067"}.active>a>.fa.plus-times{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=1);-webkit-transform:rotate(45deg);-moz-transform:rotate(45deg);-ms-transform:rotate(45deg);-o-transform:rotate(45deg);transform:rotate(45deg)}.plus-minus{float:right}.fa.plus-minus:before{content:"\f067"}.active>a>.fa.plus-minus:before{content:"\f068"} -------------------------------------------------------------------------------- /tests/test_util_input.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from datetime import datetime, timedelta 4 | 5 | from rowboat.util.input import parse_duration 6 | 7 | 8 | class TestRuleMatcher(unittest.TestCase): 9 | def test_basic_durations(self): 10 | dt = parse_duration('1w2d3h4m5s') 11 | self.assertTrue(dt < (datetime.utcnow() + timedelta(days=10))) 12 | self.assertTrue(dt > (datetime.utcnow() + timedelta(days=7))) 13 | 14 | def test_source_durations(self): 15 | origin = datetime.utcnow() + timedelta(days=17) 16 | dt = parse_duration('1w2d3h4m5s', source=origin) 17 | compare = (origin - datetime.utcnow()) + datetime.utcnow() 18 | self.assertTrue(dt < (compare + timedelta(days=10))) 19 | self.assertTrue(dt > (compare + timedelta(days=7))) 20 | 21 | def test_invalid_duration(self): 22 | self.assertEquals(parse_duration('mmmmm', safe=True), None) 23 | -------------------------------------------------------------------------------- /rowboat/views/users.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, g, jsonify 2 | 3 | from rowboat.models.guild import Guild 4 | from rowboat.util.decos import authed 5 | 6 | users = Blueprint('users', __name__, url_prefix='/api/users') 7 | 8 | 9 | @users.route('/@me') 10 | @authed 11 | def users_me(): 12 | return jsonify(g.user.serialize(us=True)) 13 | 14 | 15 | @users.route('/@me/guilds') 16 | @authed 17 | def users_me_guilds(): 18 | if g.user.admin: 19 | guilds = list(Guild.select().where( 20 | (Guild.enabled == True) 21 | )) 22 | else: 23 | guilds = list(Guild.select( 24 | Guild, 25 | Guild.config['web'][str(g.user.user_id)].alias('role') 26 | ).where( 27 | (~(Guild.config['web'][str(g.user.user_id)] >> None)) & 28 | (Guild.enabled == True) 29 | )) 30 | 31 | return jsonify([ 32 | guild.serialize() for guild in guilds 33 | ]) 34 | -------------------------------------------------------------------------------- /rowboat/models/migrations/0011_use_more_arrays.py: -------------------------------------------------------------------------------- 1 | from rowboat.models.migrations import Migrate 2 | from rowboat.models.guild import GuildMemberBackup, GuildEmoji 3 | 4 | 5 | @Migrate.only_if(Migrate.missing, GuildEmoji, 'roles_new') 6 | def add_guild_emoji_columns(m): 7 | m.add_columns( 8 | GuildEmoji, 9 | GuildEmoji.roles_new, 10 | ) 11 | 12 | 13 | @Migrate.only_if(Migrate.missing, GuildMemberBackup, 'roles_new') 14 | def add_guild_member_backup_columns(m): 15 | m.add_columns( 16 | GuildMemberBackup, 17 | GuildMemberBackup.roles_new 18 | ) 19 | 20 | 21 | @Migrate.always() 22 | def backfill(m): 23 | m.backfill_column( 24 | GuildEmoji, 25 | [GuildEmoji.roles], 26 | [GuildEmoji.roles_new]) 27 | 28 | m.backfill_column( 29 | GuildMemberBackup, 30 | [GuildMemberBackup.roles], 31 | [GuildMemberBackup.roles_new], 32 | pkeys=[GuildMemberBackup.user_id, GuildMemberBackup.guild_id]) 33 | -------------------------------------------------------------------------------- /tests/test_types.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from rowboat.types import rule_matcher 4 | 5 | 6 | class SubObject(object): 7 | key = 'value' 8 | 9 | 10 | class TestObject(object): 11 | name = 'test' 12 | group = 'lol' 13 | sub = SubObject() 14 | 15 | lmao = [1, 2, 3] 16 | 17 | 18 | class TestRuleMatcher(unittest.TestCase): 19 | def test_basic_rules(self): 20 | rules = [ 21 | {'sub.key': 'value', 'out': 1}, 22 | {'name': 'test', 'out': 2}, 23 | {'name': {'length': 4}, 'out': 3}, 24 | {'group': 'lol', 'out': 4}, 25 | {'group': 'wtf', 'out': 5}, 26 | {'name': {'length': 5}, 'out': 6}, 27 | ] 28 | 29 | matches = list(rule_matcher(TestObject(), rules)) 30 | self.assertEqual(matches, [1, 2, 3, 4]) 31 | 32 | def test_catch_all(self): 33 | rules = [ 34 | {'out': 1} 35 | ] 36 | 37 | matches = list(rule_matcher(TestObject(), rules)) 38 | self.assertEqual(matches, [1]) 39 | -------------------------------------------------------------------------------- /config.example.yaml: -------------------------------------------------------------------------------- 1 | token: '' 2 | 3 | manhole_enable: true 4 | manhole_bind: 127.0.0.1:7171 5 | 6 | max_reconnects: 0 7 | guild_subscriptions: false 8 | intents: 14319 9 | 10 | DSN: '' 11 | 12 | web: 13 | SECRET_KEY: '' 14 | discord: 15 | CLIENT_ID: '' 16 | REDIRECT_URI: 'http://WEBSITE.com/api/auth/discord/callback' 17 | CLIENT_SECRET: '' 18 | API_BASE_URL: 'https://discordapp.com/api' 19 | TOKEN_URL: 'https://discordapp.com/api/oauth2/token' 20 | AUTH_URL: 'https://discordapp.com/oauth2/authorize' 21 | 22 | state: 23 | sync_guild_members: false 24 | 25 | bot: 26 | commands_enabled: false 27 | plugins: 28 | - rowboat.plugins.core 29 | - rowboat.plugins.modlog 30 | - rowboat.plugins.admin 31 | - rowboat.plugins.spam 32 | - rowboat.plugins.censor 33 | - rowboat.plugins.reddit 34 | - rowboat.plugins.starboard 35 | - rowboat.plugins.utilities 36 | - rowboat.plugins.sql 37 | - rowboat.plugins.internal 38 | - rowboat.plugins.stats 39 | - rowboat.plugins.tags 40 | -------------------------------------------------------------------------------- /rowboat/models/event.py: -------------------------------------------------------------------------------- 1 | from peewee import ( 2 | BigIntegerField, CharField, DateTimeField, CompositeKey 3 | ) 4 | from datetime import datetime, timedelta 5 | from playhouse.postgres_ext import BinaryJSONField 6 | 7 | from rowboat.sql import BaseModel 8 | 9 | 10 | @BaseModel.register 11 | class Event(BaseModel): 12 | session = CharField() 13 | seq = BigIntegerField() 14 | 15 | timestamp = DateTimeField(default=datetime.utcnow) 16 | event = CharField() 17 | data = BinaryJSONField() 18 | 19 | class Meta: 20 | db_table = 'events' 21 | primary_key = CompositeKey('session', 'seq') 22 | indexes = ( 23 | (('timestamp', ), False), 24 | (('event', ), False), 25 | ) 26 | 27 | @classmethod 28 | def truncate(cls, hours=12): 29 | return cls.delete().where( 30 | (cls.timestamp < (datetime.utcnow() - timedelta(hours=hours))) 31 | ).execute() 32 | 33 | @classmethod 34 | def prepare(cls, session, event): 35 | return { 36 | 'session': session, 37 | 'seq': event['s'], 38 | 'timestamp': datetime.utcnow(), 39 | 'event': event['t'], 40 | 'data': event['d'], 41 | } 42 | -------------------------------------------------------------------------------- /frontend/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Jetski Dashboard 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "axios": "^0.19.0", 8 | "babel-plugin-transform-object-rest-spread": "^6.23.0", 9 | "babel-preset-react": "^6.24.1", 10 | "eventemitter3": "^2.0.3", 11 | "express": "^4.16.4", 12 | "highlight.js": "^9.13.1", 13 | "http-proxy-middleware": "^0.19.1", 14 | "lodash": "^4.17.15", 15 | "moment": "^2.22.2", 16 | "moment-timezone": "^0.5.25", 17 | "parcel-bundler": "^1.12.4", 18 | "punycode": "^2.1.1", 19 | "react": "^16.7.0", 20 | "react-ace": "^6.1.4", 21 | "react-countup": "^4.0.0-alpha.6", 22 | "react-dom": "^16.7.0", 23 | "react-router": "^4.1.2", 24 | "react-router-dom": "^4.1.2", 25 | "react-table": "^6.5.3", 26 | "recharts": "^1.0.0-apha.5", 27 | "simple-markdown": "^0.7.0" 28 | }, 29 | "devDependencies": { 30 | "@babel/core": "^7.2.2", 31 | "@babel/preset-env": "^7.2.3", 32 | "babel-plugin-transform-react-jsx": "^6.24.1" 33 | }, 34 | "scripts": { 35 | "serve": "node serve.js", 36 | "build": "NODE_ENV=production npm i && parcel build src/index.html --no-cache", 37 | "dev": "NODE_ENV=development npm i && parcel build src/index.html && npm run serve" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /rowboat/tasks/backfill.py: -------------------------------------------------------------------------------- 1 | from . import task, get_client 2 | from rowboat.models.message import Message 3 | from disco.types.channel import MessageIterator 4 | 5 | 6 | @task(max_concurrent=1, max_queue_size=10, global_lock=lambda guild_id: guild_id) 7 | def backfill_guild(task, guild_id): 8 | client = get_client() 9 | for channel in client.api.guilds_channels_list(guild_id).values(): 10 | backfill_channel.queue(channel.id) 11 | 12 | 13 | @task(max_concurrent=6, max_queue_size=500, global_lock=lambda channel_id: channel_id) 14 | def backfill_channel(task, channel_id): 15 | client = get_client() 16 | channel = client.api.channels_get(channel_id) 17 | 18 | # Hack the state 19 | client.state.channels[channel.id] = channel 20 | if channel.guild_id: 21 | client.state.guilds[channel.guild_id] = client.api.guilds_get(channel.guild_id) 22 | 23 | scanned = 0 24 | inserted = 0 25 | 26 | msgs_iter = MessageIterator(client, channel, bulk=True, after=1, direction=MessageIterator.Direction.DOWN) 27 | for chunk in msgs_iter: 28 | if not chunk: 29 | break 30 | 31 | scanned += len(chunk) 32 | inserted += len(Message.from_disco_message_many(chunk, safe=True)) 33 | 34 | task.log.info('Completed backfill on channel %s, %s scanned and %s inserted', channel_id, scanned, inserted) 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2017 Andrei Zbikowski 4 | Copyright (c) 2017-2018 Tiemen 5 | Copyright (c) 2018 Justin 6 | Copyright (c) 2018 "Dooley_labs" 7 | Copyright (c) 2018 "OGNovuh" 8 | Copyright (c) 2018 "Terminator966" 9 | Copyright (c) 2018 "Xenthys" 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining a 12 | copy of this software and associated documentation files (the "Software"), 13 | to deal in the Software without restriction, including without limitation 14 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 15 | and/or sell copies of the Software, and to permit persons to whom the 16 | Software is furnished to do so, subject to the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be included in 19 | all copies or substantial portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 22 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 26 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 27 | DEALINGS IN THE SOFTWARE. 28 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | db: 4 | build: ./docker/database/ 5 | volumes: 6 | - postgres:/var/lib/postgresql/data:rw 7 | redis: 8 | image: redis:3.2 9 | command: redis-server --appendonly yes 10 | volumes: 11 | - ./.data:/data 12 | statsd: 13 | image: python:2.7.13 14 | volumes: 15 | - .:/opt/rowboat 16 | command: python /opt/rowboat/docker/nulld.py 17 | ports: 18 | - "8125:8125" 19 | stop_signal: SIGKILL 20 | web: 21 | build: . 22 | command: python manage.py serve -r 23 | volumes: 24 | - .:/opt/rowboat 25 | ports: 26 | - "8686:8686" 27 | depends_on: 28 | - db 29 | - redis 30 | - statsd 31 | frontend: 32 | build: ./frontend/ 33 | environment: 34 | - NODE_ENV=docker 35 | command: npm run serve 36 | volumes: 37 | - ./frontend:/opt/frontend 38 | ports: 39 | - "8080:8080" 40 | depends_on: 41 | - web 42 | bot: 43 | build: . 44 | command: python manage.py bot -e docker 45 | volumes: 46 | - .:/opt/rowboat 47 | ports: 48 | - "7171:7171" 49 | depends_on: 50 | - web 51 | workers: 52 | build: . 53 | command: python manage.py workers 54 | volumes: 55 | - .:/opt/rowboat 56 | depends_on: 57 | - web 58 | volumes: 59 | postgres: {} 60 | -------------------------------------------------------------------------------- /frontend/src/models/user.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import {globalState} from '../state'; 3 | import BaseModel from './base'; 4 | import Guild from './guild'; 5 | 6 | export default class User extends BaseModel { 7 | constructor(obj) { 8 | super(); 9 | 10 | this.id = obj.id; 11 | this.username = obj.username; 12 | this.discriminator = obj.discriminator; 13 | this.avatar = obj.avatar; 14 | this.bot = obj.bot; 15 | this.admin = obj.admin; 16 | 17 | this.guilds = null; 18 | this.guildsPromise = null; 19 | } 20 | 21 | getGuilds(refresh = false) { 22 | if (this.guilds && !refresh) { 23 | return new Promise((resolve) => resolve(this.guilds)); 24 | } 25 | 26 | if (this.guildsPromise) { 27 | return new Promise((resolve) => this.guildsPromise.then((guilds) => resolve(guilds))); 28 | } 29 | 30 | this.guildsPromise = new Promise((resolve) => { 31 | axios.get('/api/users/@me/guilds').then((res) => { 32 | let guilds = res.data.map((guildData) => { 33 | return new Guild(guildData); 34 | }); 35 | 36 | this.guilds = {} 37 | for (let guild of guilds) { 38 | this.guilds[guild.id] = guild; 39 | } 40 | 41 | this.events.emit('guilds.set', this.guilds); 42 | resolve(this.guilds); 43 | this.guildsPromise = null; 44 | }); 45 | }); 46 | return this.guildsPromise; 47 | } 48 | } 49 | 50 | -------------------------------------------------------------------------------- /frontend/src/components/login.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Redirect } from 'react-router-dom' 3 | import {globalState} from '../state'; 4 | 5 | export default class Login extends Component { 6 | constructor() { 7 | super(); 8 | 9 | this.state = { 10 | user: globalState.user, 11 | }; 12 | 13 | globalState.events.on('user.set', (user) => { 14 | this.setState({user: user}); 15 | }); 16 | 17 | globalState.init(); 18 | } 19 | 20 | render() { 21 | if (this.state.user) { 22 | return ; 23 | } 24 | 25 | return ( 26 |
27 |
28 |
29 |
30 |
31 |

Login with Discord

32 |
33 |
34 | 35 | 39 | 40 |
41 |
42 |
43 |
44 |
45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /rowboat/web.py: -------------------------------------------------------------------------------- 1 | import os; os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' 2 | 3 | import logging 4 | 5 | from flask import Flask, g, session 6 | from holster.flask_ext import Holster 7 | 8 | from rowboat import ENV 9 | from rowboat.sql import init_db 10 | from rowboat.models.user import User 11 | from rowboat.types.guild import PluginsConfig 12 | 13 | from yaml import safe_load 14 | 15 | rowboat = Holster(Flask(__name__)) 16 | logging.getLogger('peewee').setLevel(logging.DEBUG) 17 | 18 | 19 | @rowboat.app.before_first_request 20 | def before_first_request(): 21 | init_db(ENV) 22 | 23 | PluginsConfig.force_load_plugin_configs() 24 | 25 | with open('config.yaml', 'r') as f: 26 | data = safe_load(f) 27 | 28 | rowboat.app.config.update(data['web']) 29 | rowboat.app.secret_key = data['web']['SECRET_KEY'] 30 | rowboat.app.config['token'] = data.get('token') 31 | 32 | 33 | @rowboat.app.before_request 34 | def check_auth(): 35 | g.user = None 36 | 37 | if 'uid' in session: 38 | g.user = User.with_id(session['uid']) 39 | 40 | 41 | @rowboat.app.after_request 42 | def save_auth(response): 43 | if g.user and 'uid' not in session: 44 | session['uid'] = g.user.id 45 | elif not g.user and 'uid' in session: 46 | del session['uid'] 47 | 48 | return response 49 | 50 | 51 | @rowboat.app.context_processor 52 | def inject_data(): 53 | return dict( 54 | user=g.user, 55 | ) 56 | -------------------------------------------------------------------------------- /frontend/src/components/topbar.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import Sidebar from './sidebar'; 3 | import {globalState} from '../state'; 4 | import {withRouter} from 'react-router'; 5 | 6 | class Topbar extends Component { 7 | constructor() { 8 | super(); 9 | this.state = { 10 | showAllGuilds: globalState.showAllGuilds, 11 | }; 12 | 13 | globalState.events.on('showAllGuilds.set', (value) => this.setState({showAllGuilds: value})); 14 | } 15 | 16 | onLogoutClicked() { 17 | globalState.logout().then(() => { 18 | this.props.history.push('/login'); 19 | }); 20 | } 21 | 22 | onExpandClicked() { 23 | globalState.showAllGuilds = !globalState.showAllGuilds; 24 | } 25 | 26 | render() { 27 | const expandIcon = this.state.showAllGuilds ? 'fa fa-folder-open-o fa-fw' : ' fa fa-folder-o fa-fw'; 28 | 29 | return( 30 | 42 | ); 43 | } 44 | } 45 | 46 | export default withRouter(Topbar); 47 | -------------------------------------------------------------------------------- /rowboat/sql.py: -------------------------------------------------------------------------------- 1 | import os 2 | import psycogreen.gevent; psycogreen.gevent.patch_psycopg() 3 | 4 | from peewee import Proxy, OP, Model 5 | from peewee import Expression 6 | from playhouse.postgres_ext import PostgresqlExtDatabase 7 | 8 | REGISTERED_MODELS = [] 9 | 10 | # Create a database proxy we can setup post-init 11 | database = Proxy() 12 | 13 | 14 | OP['IRGX'] = 'irgx' 15 | 16 | 17 | def pg_regex_i(lhs, rhs): 18 | return Expression(lhs, OP.IRGX, rhs) 19 | 20 | 21 | PostgresqlExtDatabase.register_ops({OP.IRGX: '~*'}) 22 | 23 | 24 | class BaseModel(Model): 25 | class Meta: 26 | database = database 27 | 28 | @staticmethod 29 | def register(cls): 30 | REGISTERED_MODELS.append(cls) 31 | return cls 32 | 33 | 34 | def init_db(env): 35 | if env == 'docker': 36 | database.initialize(PostgresqlExtDatabase( 37 | 'rowboat', 38 | host='db', 39 | user='postgres', 40 | port=int(os.getenv('PG_PORT', 5432)), 41 | autorollback=True)) 42 | else: 43 | database.initialize(PostgresqlExtDatabase( 44 | 'rowboat', 45 | user='rowboat', 46 | port=int(os.getenv('PG_PORT', 5432)), 47 | autorollback=True)) 48 | 49 | for model in REGISTERED_MODELS: 50 | model.create_table(True) 51 | 52 | if hasattr(model, 'SQL'): 53 | database.execute_sql(model.SQL) 54 | 55 | 56 | def reset_db(): 57 | init_db() 58 | 59 | for model in REGISTERED_MODELS: 60 | model.drop_table(True) 61 | model.create_table(True) 62 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pycparser==2.19 2 | setuptools 3 | wheel==0.32.3 4 | arrow==0.10.0 5 | cairocffi==0.8.0 6 | CairoSVG==1.0.19 7 | cffi==1.9.1 8 | click==6.7 9 | contextlib2==0.5.4 10 | cssselect==1.0.1 11 | dill==0.2.6 12 | -e git+https://github.com/ThaTiemsz/disco.git@1f27c822f0df3a0e58c8564281a6fc8e47b31673#egg=disco-py 13 | # disco-py==0.0.11-rc.8 14 | Distance==0.1.3 15 | emoji==0.3.9 16 | enum34==1.1.6 17 | erlpack==0.3.2 18 | Flask==0.12.4 19 | future==0.16.0 20 | futures==3.0.5 21 | # greenlet==0.4.11 22 | # holster==1.0.11 23 | httplib2==0.10.3 24 | humanize==0.5.1 25 | inflection==0.3.1 26 | influxdb==4.0.0 27 | itsdangerous==0.24 28 | Jinja2==2.9.5 29 | lxml==3.7.2 30 | markovify==0.5.4 31 | MarkupSafe==0.23 32 | oauth2client==3.0.0 33 | oauthlib==2.0.1 34 | olefile==0.44 35 | packaging==16.8 36 | Pillow==6.2.0 37 | ply==3.8 38 | protobuf==3.2.0 39 | psycogreen==1.0 40 | psycopg2==2.6.2 41 | pyasn1==0.2.3 42 | pyasn1-modules==0.0.8 43 | # pycparser==2.17 44 | pygal==2.3.1 45 | pyparsing==2.2.0 46 | pyquery==1.2.17 47 | python-dateutil==2.6.0 48 | pytz==2016.10 49 | PyYAML==5.1.1 50 | raven==5.32.0 51 | redis==2.10.5 52 | regex==2017.2.8 53 | requests-oauthlib==0.8.0 54 | rsa==3.4.2 55 | ruamel.ordereddict==0.4.9 56 | ruamel.yaml==0.13.14 57 | tinycss==0.4 58 | typing==3.5.3.0 59 | tzlocal==1.3 60 | Unidecode==1.1.1 61 | # websocket-client==0.37.0 62 | Werkzeug==0.16.0 63 | # wheel==0.24.0 64 | xxhash==1.0.1 65 | fuzzywuzzy==0.15.0 66 | pytest==3.0.7 67 | python-Levenshtein==0.12.0 68 | -e git+https://github.com/b1naryth1ef/peewee.git@3f1f843ac2d94c212053f46d95ae7040e2f5d753#egg=peewee 69 | gevent_inotifyx==0.1.1 70 | datadog==0.16.0 71 | -------------------------------------------------------------------------------- /frontend/src/static/css/solarized-dark.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Orginal Style from ethanschoonover.com/solarized (c) Jeremy Hull 4 | 5 | */ 6 | 7 | .hljs { 8 | display: block; 9 | overflow-x: auto; 10 | padding: 0.5em; 11 | background: #002b36; 12 | color: #839496; 13 | } 14 | 15 | .hljs-comment, 16 | .hljs-quote { 17 | color: #586e75; 18 | } 19 | 20 | /* Solarized Green */ 21 | .hljs-keyword, 22 | .hljs-selector-tag, 23 | .hljs-addition { 24 | color: #859900; 25 | } 26 | 27 | /* Solarized Cyan */ 28 | .hljs-number, 29 | .hljs-string, 30 | .hljs-meta .hljs-meta-string, 31 | .hljs-literal, 32 | .hljs-doctag, 33 | .hljs-regexp { 34 | color: #2aa198; 35 | } 36 | 37 | /* Solarized Blue */ 38 | .hljs-title, 39 | .hljs-section, 40 | .hljs-name, 41 | .hljs-selector-id, 42 | .hljs-selector-class { 43 | color: #268bd2; 44 | } 45 | 46 | /* Solarized Yellow */ 47 | .hljs-attribute, 48 | .hljs-attr, 49 | .hljs-variable, 50 | .hljs-template-variable, 51 | .hljs-class .hljs-title, 52 | .hljs-type { 53 | color: #b58900; 54 | } 55 | 56 | /* Solarized Orange */ 57 | .hljs-symbol, 58 | .hljs-bullet, 59 | .hljs-subst, 60 | .hljs-meta, 61 | .hljs-meta .hljs-keyword, 62 | .hljs-selector-attr, 63 | .hljs-selector-pseudo, 64 | .hljs-link { 65 | color: #cb4b16; 66 | } 67 | 68 | /* Solarized Red */ 69 | .hljs-built_in, 70 | .hljs-deletion { 71 | color: #dc322f; 72 | } 73 | 74 | .hljs-formula { 75 | background: #073642; 76 | } 77 | 78 | .hljs-emphasis { 79 | font-style: italic; 80 | } 81 | 82 | .hljs-strong { 83 | font-weight: bold; 84 | } -------------------------------------------------------------------------------- /frontend/src/static/assets/ic_overflow_vertical_grey_16px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ic_overflow_vertical_grey_16px 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /rowboat/util/redis.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import gevent 4 | 5 | from gevent.lock import Semaphore 6 | 7 | 8 | class RedisSet(object): 9 | def __init__(self, rdb, key_name): 10 | self.rdb = rdb 11 | self.key_name = key_name 12 | self.update_key_name = u'redis-set:{}'.format(self.key_name) 13 | 14 | self._set = rdb.smembers(key_name) 15 | self._lock = Semaphore() 16 | self._ps = self.rdb.pubsub() 17 | self._ps.subscribe(self.update_key_name) 18 | 19 | self._inst = gevent.spawn(self._listener) 20 | 21 | def __contains__(self, other): 22 | return other in self._set 23 | 24 | def add(self, key): 25 | if key in self._set: 26 | return 27 | 28 | with self._lock: 29 | self.rdb.sadd(self.key_name, key) 30 | self._set.add(key) 31 | self.rdb.publish(self.update_key_name, u'A{}'.format(key)) 32 | 33 | def remove(self, key): 34 | if key not in self._set: 35 | return 36 | 37 | with self._lock: 38 | self.rdb.srem(self.key_name, key) 39 | self._set.remove(key) 40 | self.rdb.publish(self.update_key_name, u'R{}'.format(key)) 41 | 42 | def _listener(self): 43 | for item in self._ps.listen(): 44 | if item['type'] != 'message': 45 | continue 46 | 47 | with self._lock: 48 | op, data = item['data'][0], item['data'][1:] 49 | 50 | if op == 'A': 51 | if data not in self._set: 52 | self._set.add(data) 53 | elif op == 'R': 54 | if data in self._set: 55 | self._set.remove(data) 56 | -------------------------------------------------------------------------------- /rowboat/models/channel.py: -------------------------------------------------------------------------------- 1 | from peewee import (BigIntegerField, SmallIntegerField, CharField, TextField, BooleanField) 2 | 3 | from rowboat.sql import BaseModel 4 | from rowboat.models.message import Message 5 | 6 | 7 | @BaseModel.register 8 | class Channel(BaseModel): 9 | channel_id = BigIntegerField(primary_key=True) 10 | guild_id = BigIntegerField(null=True) 11 | name = CharField(null=True, index=True) 12 | topic = TextField(null=True) 13 | type_ = SmallIntegerField(null=True) 14 | 15 | # First message sent in the channel 16 | first_message_id = BigIntegerField(null=True) 17 | deleted = BooleanField(default=False) 18 | 19 | class Meta: 20 | db_table = 'channels' 21 | 22 | @classmethod 23 | def generate_first_message_id(cls, channel_id): 24 | try: 25 | return Message.select(Message.id).where( 26 | (Message.channel_id == channel_id) 27 | ).order_by(Message.id.asc()).limit(1).get().id 28 | except Message.DoesNotExist: 29 | return None 30 | 31 | @classmethod 32 | def from_disco_channel(cls, channel): 33 | # Upsert channel information 34 | channel = list(cls.insert( 35 | channel_id=channel.id, 36 | guild_id=channel.guild.id if channel.guild else None, 37 | name=channel.name or None, 38 | topic=channel.topic or None, 39 | type_=channel.type, 40 | ).upsert(target=cls.channel_id).returning(cls.first_message_id).execute())[0] 41 | 42 | # Update the first message ID 43 | if not channel.first_message_id: 44 | cls.update( 45 | first_message_id=cls.generate_first_message_id(channel.id) 46 | ).where(cls.channel_id == channel.id).execute() 47 | -------------------------------------------------------------------------------- /frontend/src/static/js/script.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Start Bootstrap - SB Admin 2 v3.3.7+1 (http://startbootstrap.com/template-overviews/sb-admin-2) 3 | * Copyright 2013-2016 Start Bootstrap 4 | * Licensed under MIT (https://github.com/BlackrockDigital/startbootstrap/blob/gh-pages/LICENSE) 5 | */ 6 | $(function() { 7 | $('#side-menu').metisMenu(); 8 | }); 9 | 10 | //Loads the correct sidebar on window load, 11 | //collapses the sidebar on window resize. 12 | // Sets the min-height of #page-wrapper to window size 13 | $(function() { 14 | $(window).bind("load resize", function() { 15 | var topOffset = 50; 16 | var width = (this.window.innerWidth > 0) ? this.window.innerWidth : this.screen.width; 17 | if (width < 768) { 18 | $('div.navbar-collapse').addClass('collapse'); 19 | topOffset = 100; // 2-row-menu 20 | } else { 21 | $('div.navbar-collapse').removeClass('collapse'); 22 | } 23 | 24 | var height = ((this.window.innerHeight > 0) ? this.window.innerHeight : this.screen.height) - 1; 25 | height = height - topOffset; 26 | if (height < 1) height = 1; 27 | if (height > topOffset) { 28 | $("#page-wrapper").css("min-height", (height) + "px"); 29 | } 30 | }); 31 | 32 | var url = window.location; 33 | // var element = $('ul.nav a').filter(function() { 34 | // return this.href == url; 35 | // }).addClass('active').parent().parent().addClass('in').parent(); 36 | var element = $('ul.nav a').filter(function() { 37 | return this.href == url; 38 | }).addClass('active').parent(); 39 | 40 | while (true) { 41 | if (element.is('li')) { 42 | element = element.parent().addClass('in').parent(); 43 | } else { 44 | break; 45 | } 46 | } 47 | }); 48 | -------------------------------------------------------------------------------- /rowboat/types/guild.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from holster.enum import Enum 4 | 5 | from rowboat.types import Model, SlottedModel, Field, DictField, text, raw, rule_matcher 6 | 7 | CooldownMode = Enum( 8 | 'GUILD', 9 | 'CHANNEL', 10 | 'USER', 11 | ) 12 | 13 | 14 | class PluginConfigObj(object): 15 | client = None 16 | 17 | 18 | class PluginsConfig(Model): 19 | def __init__(self, inst, obj): 20 | self.client = None 21 | self.load_into(inst, obj) 22 | 23 | @classmethod 24 | def parse(cls, obj, *args, **kwargs): 25 | inst = PluginConfigObj() 26 | cls(inst, obj) 27 | return inst 28 | 29 | @classmethod 30 | def force_load_plugin_configs(cls): 31 | """ 32 | This function can be called to ensure that this class will have all its 33 | attributes properly loaded, as they are dynamically set when plugin configs 34 | are defined. 35 | """ 36 | plugins = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'plugins') 37 | for name in os.listdir(plugins): 38 | __import__('rowboat.plugins.{}'.format( 39 | name.rsplit('.', 1)[0] 40 | )) 41 | 42 | 43 | class CommandOverrideConfig(SlottedModel): 44 | disabled = Field(bool, default=False) 45 | level = Field(int) 46 | 47 | 48 | class CommandsConfig(SlottedModel): 49 | prefix = Field(str, default='') 50 | mention = Field(bool, default=False) 51 | overrides = Field(raw) 52 | 53 | def get_command_override(self, command): 54 | return rule_matcher(command, self.overrides or []) 55 | 56 | 57 | class GuildConfig(SlottedModel): 58 | nickname = Field(text) 59 | commands = Field(CommandsConfig, default=None, create=False) 60 | levels = DictField(int, int) 61 | plugins = Field(PluginsConfig.parse) 62 | -------------------------------------------------------------------------------- /rowboat/models/migrations/0009_use_arrays_idiot.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from rowboat.models.migrations import Migrate 4 | from rowboat.models.message import Message 5 | 6 | from rowboat.sql import database as db 7 | 8 | 9 | def backfill_column(table, old_columns, new_columns): 10 | total = table.select().count() 11 | 12 | q = table.select( 13 | table._meta.primary_key, 14 | *old_columns 15 | ).tuples() 16 | 17 | idx = 0 18 | modified = 0 19 | 20 | start = time.time() 21 | with db.transaction() as txn: 22 | for values in q: 23 | idx += 1 24 | 25 | if idx % 10000 == 0: 26 | print '[%ss] Backfilling %s %s/%s (wrote %s)' % (time.time() - start, str(table), idx, total, modified) 27 | 28 | if modified % 1000: 29 | txn.commit() 30 | 31 | obj = {new_column.name: values[i + 1] for i, new_column in enumerate(new_columns)} 32 | if not any(obj.values()): 33 | continue 34 | 35 | modified += 1 36 | table.update( 37 | **{new_column.name: values[i + 1] for i, new_column in enumerate(new_columns)} 38 | ).where(table._meta.primary_key == values[0]).execute() 39 | 40 | txn.commit() 41 | print 'DONE, %s scanned %s written' % (idx, modified) 42 | 43 | 44 | @Migrate.only_if(Migrate.missing, Message, 'mentions_new') 45 | def add_guild_columns(m): 46 | m.add_columns( 47 | Message, 48 | Message.mentions_new, 49 | Message.emojis_new, 50 | Message.attachments_new, 51 | Message.embeds, 52 | ) 53 | 54 | 55 | @Migrate.always() 56 | def backfill_data(m): 57 | backfill_column( 58 | Message, 59 | [Message.mentions, Message.emojis, Message.attachments], 60 | [Message.mentions_new, Message.emojis_new, Message.attachments_new]) 61 | -------------------------------------------------------------------------------- /frontend/src/static/js/metisMenu.min.js: -------------------------------------------------------------------------------- 1 | /* 2 | * metismenu - v1.1.3 3 | * Easy menu jQuery plugin for Twitter Bootstrap 3 4 | * https://github.com/onokumus/metisMenu 5 | * 6 | * Made by Osman Nuri Okumus 7 | * Under MIT License 8 | */ 9 | !function(a,b,c){function d(b,c){this.element=a(b),this.settings=a.extend({},f,c),this._defaults=f,this._name=e,this.init()}var e="metisMenu",f={toggle:!0,doubleTapToGo:!1};d.prototype={init:function(){var b=this.element,d=this.settings.toggle,f=this;this.isIE()<=9?(b.find("li.active").has("ul").children("ul").collapse("show"),b.find("li").not(".active").has("ul").children("ul").collapse("hide")):(b.find("li.active").has("ul").children("ul").addClass("collapse in"),b.find("li").not(".active").has("ul").children("ul").addClass("collapse")),f.settings.doubleTapToGo&&b.find("li.active").has("ul").children("a").addClass("doubleTapToGo"),b.find("li").has("ul").children("a").on("click."+e,function(b){return b.preventDefault(),f.settings.doubleTapToGo&&f.doubleTapToGo(a(this))&&"#"!==a(this).attr("href")&&""!==a(this).attr("href")?(b.stopPropagation(),void(c.location=a(this).attr("href"))):(a(this).parent("li").toggleClass("active").children("ul").collapse("toggle"),void(d&&a(this).parent("li").siblings().removeClass("active").children("ul.in").collapse("hide")))})},isIE:function(){for(var a,b=3,d=c.createElement("div"),e=d.getElementsByTagName("i");d.innerHTML="",e[0];)return b>4?b:a},doubleTapToGo:function(a){var b=this.element;return a.hasClass("doubleTapToGo")?(a.removeClass("doubleTapToGo"),!0):a.parent().children("ul").length?(b.find(".doubleTapToGo").removeClass("doubleTapToGo"),a.addClass("doubleTapToGo"),!1):void 0},remove:function(){this.element.off("."+e),this.element.removeData(e)}},a.fn[e]=function(b){return this.each(function(){var c=a(this);c.data(e)&&c.data(e).remove(),c.data(e,new d(this,b))}),this}}(jQuery,window,document); -------------------------------------------------------------------------------- /frontend/src/static/js/dataTables.bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | DataTables Bootstrap 3 integration 3 | ©2011-2014 SpryMedia Ltd - datatables.net/license 4 | */ 5 | (function(){var f=function(c,b){c.extend(!0,b.defaults,{dom:"<'row'<'col-sm-6'l><'col-sm-6'f>><'row'<'col-sm-12'tr>><'row'<'col-sm-6'i><'col-sm-6'p>>",renderer:"bootstrap"});c.extend(b.ext.classes,{sWrapper:"dataTables_wrapper form-inline dt-bootstrap",sFilterInput:"form-control input-sm",sLengthSelect:"form-control input-sm"});b.ext.renderer.pageButton.bootstrap=function(g,f,p,k,h,l){var q=new b.Api(g),r=g.oClasses,i=g.oLanguage.oPaginate,d,e,o=function(b,f){var j,m,n,a,k=function(a){a.preventDefault(); 6 | c(a.currentTarget).hasClass("disabled")||q.page(a.data.action).draw(!1)};j=0;for(m=f.length;j",{"class":r.sPageButton+" "+ 7 | e,"aria-controls":g.sTableId,tabindex:g.iTabIndex,id:0===p&&"string"===typeof a?g.sTableId+"_"+a:null}).append(c("",{href:"#"}).html(d)).appendTo(b),g.oApi._fnBindAction(n,{action:a},k))}};o(c(f).empty().html('
    ').children("ul"),k)};b.TableTools&&(c.extend(!0,b.TableTools.classes,{container:"DTTT btn-group",buttons:{normal:"btn btn-default",disabled:"disabled"},collection:{container:"DTTT_dropdown dropdown-menu",buttons:{normal:"",disabled:"disabled"}},print:{info:"DTTT_print_info"}, 8 | select:{row:"active"}}),c.extend(!0,b.TableTools.DEFAULTS.oTags,{collection:{container:"ul",button:"li",liner:"a"}}))};"function"===typeof define&&define.amd?define(["jquery","datatables"],f):"object"===typeof exports?f(require("jquery"),require("datatables")):jQuery&&f(jQuery,jQuery.fn.dataTable)})(window,document); 9 | -------------------------------------------------------------------------------- /rowboat/util/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import re 4 | import yaml 5 | from collections import OrderedDict 6 | 7 | from datetime import datetime 8 | from gevent.local import local 9 | 10 | # Invisible space that can be used to escape mentions 11 | ZERO_WIDTH_SPACE = u'\u200B' 12 | 13 | # Replacement grave accent that can be used to escape codeblocks 14 | MODIFIER_GRAVE_ACCENT = u'\u02CB' 15 | 16 | 17 | def ordered_load(stream, Loader=yaml.Loader, object_pairs_hook=OrderedDict): 18 | class OrderedLoader(Loader): 19 | pass 20 | 21 | def construct_mapping(loader, node): 22 | loader.flatten_mapping(node) 23 | return object_pairs_hook(loader.construct_pairs(node)) 24 | OrderedLoader.add_constructor( 25 | yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, 26 | construct_mapping) 27 | return yaml.load(stream, OrderedLoader) 28 | 29 | 30 | INVITE_DOMAIN_RE = re.compile(r'(discord.gg|discordapp.com/invite)') 31 | 32 | 33 | def C(txt, codeblocks=False): 34 | # Do some basic safety checks: 35 | txt = txt.replace('@', '@' + ZERO_WIDTH_SPACE) 36 | 37 | if codeblocks: 38 | txt = escape_codeblocks(txt) 39 | 40 | return INVITE_DOMAIN_RE.sub('\g<0>' + ZERO_WIDTH_SPACE, txt) 41 | 42 | 43 | def escape_codeblocks(txt): 44 | return txt.replace('`', MODIFIER_GRAVE_ACCENT) 45 | 46 | 47 | class LocalProxy(object): 48 | def __init__(self): 49 | self.local = local() 50 | 51 | def set(self, other): 52 | self.local.obj = other 53 | 54 | def get(self): 55 | return self.local.obj 56 | 57 | def __getattr__(self, attr): 58 | return getattr(self.local.obj, attr) 59 | 60 | 61 | class MetaException(Exception): 62 | def __init__(self, msg, metadata=None): 63 | self.msg = msg 64 | self.metadata = metadata 65 | super(MetaException, self).__init__(msg) 66 | 67 | 68 | def default_json(obj): 69 | if isinstance(obj, datetime): 70 | return obj.isoformat() 71 | return TypeError('Type %s is not serializable' % type(obj)) 72 | -------------------------------------------------------------------------------- /rowboat/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Jetski 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {% block realbody %} 23 |
    24 | {% if g.user %} 25 | 28 | {% endif %} 29 | 30 |
    31 | {% block body %} 32 | {% endblock %} 33 |
    34 | 35 |
    36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | {% block scripts %} 47 | {% endblock %} 48 | {% endblock %} 49 | 50 | 51 | -------------------------------------------------------------------------------- /rowboat/util/timing.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import time 4 | import gevent 5 | 6 | from gevent.lock import Semaphore 7 | from datetime import datetime 8 | 9 | 10 | class Eventual(object): 11 | """ 12 | A function that will be triggered eventually. 13 | """ 14 | 15 | def __init__(self, func): 16 | self.func = func 17 | self._next = None 18 | self._t = None 19 | 20 | def wait(self, nxt): 21 | def f(): 22 | wait_time = (self._next - datetime.utcnow()) 23 | gevent.sleep(wait_time.seconds + (wait_time.microseconds / 1000000.0)) 24 | self._next = None 25 | gevent.spawn(self.func) 26 | 27 | if self._t: 28 | self._t.kill() 29 | 30 | self._next = nxt 31 | self._t = gevent.spawn(f) 32 | 33 | def trigger(self): 34 | if self._t: 35 | self._t.kill() 36 | self._next = None 37 | gevent.spawn(self.func) 38 | 39 | def set_next_schedule(self, date): 40 | if date < datetime.utcnow(): 41 | return gevent.spawn(self.trigger) 42 | 43 | if not self._next or date < self._next: 44 | self.wait(date) 45 | 46 | 47 | class Debounce(object): 48 | def __init__(self, func, default, hardlimit, **kwargs): 49 | self.func = func 50 | self.default = default 51 | self.hardlimit = hardlimit 52 | self.kwargs = kwargs 53 | 54 | self._start = time.time() 55 | self._lock = Semaphore() 56 | self._t = gevent.spawn(self.wait) 57 | 58 | def active(self): 59 | return self._t is not None 60 | 61 | def wait(self): 62 | gevent.sleep(self.default) 63 | 64 | with self._lock: 65 | self.func(**self.kwargs) 66 | self._t = None 67 | 68 | def touch(self): 69 | if self._t: 70 | with self._lock: 71 | if self._t: 72 | self._t.kill() 73 | self._t = None 74 | else: 75 | self._start = time.time() 76 | 77 | if time.time() - self._start > self.hardlimit: 78 | gevent.spawn(self.func, **self.kwargs) 79 | return 80 | 81 | self._t = gevent.spawn(self.wait) 82 | -------------------------------------------------------------------------------- /rowboat/util/leakybucket.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | def get_ms_time(): 5 | return int(time.time() * 1000) 6 | 7 | # function(keys=[rl_key], args=[time.time() - (time_period * max_actions), time.time()] 8 | INCR_SCRIPT = ''' 9 | local key = KEYS[1] 10 | 11 | -- Clear out expired water drops 12 | redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", ARGV[2]) 13 | 14 | -- Add our keys 15 | for i=1,ARGV[1] do 16 | redis.call("ZADD", KEYS[1], ARGV[3], ARGV[3] + i) 17 | end 18 | 19 | redis.call("EXPIRE", KEYS[1], ARGV[4]) 20 | 21 | return redis.call("ZCOUNT", KEYS[1], "-inf", "+inf") 22 | ''' 23 | 24 | GET_SCRIPT = ''' 25 | local key = KEYS[1] 26 | 27 | -- Clear out expired water drops 28 | redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", ARGV[1]) 29 | 30 | return redis.call("ZCOUNT", KEYS[1], "-inf", "+inf") 31 | ''' 32 | 33 | 34 | class LeakyBucket(object): 35 | def __init__(self, redis, key_fmt, max_actions, time_period): 36 | self.redis = redis 37 | self.key_fmt = key_fmt 38 | self.max_actions = max_actions 39 | self.time_period = time_period 40 | 41 | self._incr_script = self.redis.register_script(INCR_SCRIPT) 42 | self._get_script = self.redis.register_script(GET_SCRIPT) 43 | 44 | def incr(self, key, amount=1): 45 | key = self.key_fmt.format(key) 46 | return int(self._incr_script( 47 | keys=[key], 48 | args=[ 49 | amount, 50 | get_ms_time() - self.time_period, 51 | get_ms_time(), 52 | (self.time_period * 2) / 1000, 53 | ])) 54 | 55 | def check(self, key, amount=1): 56 | count = self.incr(key, amount) 57 | if count >= self.max_actions: 58 | return False 59 | return True 60 | 61 | def get(self, key): 62 | return int(self._get_script(self.key_fmt.format(key))) 63 | 64 | def clear(self, key): 65 | self.redis.zremrangebyscore(self.key_fmt.format(key), '-inf', '+inf') 66 | 67 | def count(self, key): 68 | return self.redis.zcount(self.key_fmt.format(key), '-inf', '+inf') 69 | 70 | def size(self, key): 71 | res = map(int, self.redis.zrangebyscore(self.key_fmt.format(key), '-inf', '+inf')) 72 | if len(res) <= 1: 73 | return 0 74 | return (res[-1] - res[0]) / 1000.0 75 | -------------------------------------------------------------------------------- /rowboat/models/notification.py: -------------------------------------------------------------------------------- 1 | import json 2 | import arrow 3 | 4 | from datetime import datetime 5 | from holster.enum import Enum 6 | from peewee import IntegerField, DateTimeField 7 | from playhouse.postgres_ext import BinaryJSONField, BooleanField 8 | 9 | from rowboat.sql import BaseModel 10 | from rowboat.redis import rdb 11 | 12 | NotificationTypes = Enum( 13 | GENERIC=1, 14 | CONNECT=2, 15 | RESUME=3, 16 | GUILD_JOIN=4, 17 | GUILD_LEAVE=5, 18 | ) 19 | 20 | 21 | @BaseModel.register 22 | class Notification(BaseModel): 23 | Types = NotificationTypes 24 | 25 | type_ = IntegerField(db_column='type') 26 | metadata = BinaryJSONField(default={}) 27 | read = BooleanField(default=False) 28 | created_at = DateTimeField(default=datetime.utcnow) 29 | 30 | class Meta: 31 | db_table = 'notifications' 32 | 33 | indexes = ( 34 | (('created_at', 'read'), False), 35 | ) 36 | 37 | @classmethod 38 | def get_unreads(cls, limit=25): 39 | return cls.select().where( 40 | cls.read == 0, 41 | ).order_by( 42 | cls.created_at.asc() 43 | ).limit(limit) 44 | 45 | @classmethod 46 | def dispatch(cls, typ, **kwargs): 47 | obj = cls.create( 48 | type_=typ, 49 | metadata=kwargs 50 | ) 51 | 52 | rdb.publish('notifications', json.dumps(obj.to_user())) 53 | return obj 54 | 55 | def to_user(self): 56 | data = {} 57 | 58 | data['id'] = self.id 59 | data['date'] = arrow.get(self.created_at).humanize() 60 | 61 | if self.type_ == self.Types.GENERIC: 62 | data['title'] = self.metadata.get('title', 'Generic Notification') 63 | data['content'] = self.metadata.get('content', '').format(m=self.metadata) 64 | elif self.type_ == self.Types.CONNECT: 65 | data['title'] = u'{} connected'.format( 66 | 'Production' if self.metadata['env'] == 'prod' else 'Testing') 67 | data['content'] = ', '.join(self.metadata.get('trace', [])) 68 | elif self.type_ == self.Types.RESUME: 69 | data['title'] = u'{} resumed'.format( 70 | 'Production' if self.metadata['env'] == 'prod' else 'Testing') 71 | data['content'] = ', '.join(self.metadata.get('trace', [])) 72 | 73 | return data 74 | -------------------------------------------------------------------------------- /rowboat/util/input.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from disco.bot.command import CommandError 3 | 4 | 5 | UNITS = { 6 | 's': lambda v: v, 7 | 'm': lambda v: v * 60, 8 | 'h': lambda v: v * 60 * 60, 9 | 'd': lambda v: v * 60 * 60 * 24, 10 | 'w': lambda v: v * 60 * 60 * 24 * 7, 11 | } 12 | 13 | 14 | def parse_duration(raw, source=None, negative=False, safe=False): 15 | if not raw: 16 | if safe: 17 | return None 18 | raise CommandError('Invalid duration') 19 | 20 | value = 0 21 | digits = '' 22 | 23 | for char in raw: 24 | if char.isdigit(): 25 | digits += char 26 | continue 27 | 28 | if char not in UNITS or not digits: 29 | if safe: 30 | return None 31 | raise CommandError('Invalid duration') 32 | 33 | value += UNITS[char](int(digits)) 34 | digits = '' 35 | 36 | if negative: 37 | value = value * -1 38 | 39 | return (source or datetime.utcnow()) + timedelta(seconds=value + 1) 40 | 41 | def humanize_duration(duration, format='full'): 42 | now = datetime.utcnow() 43 | if isinstance(duration, timedelta): 44 | if duration.total_seconds() > 0: 45 | duration = datetime.today() + duration 46 | else: 47 | duration = datetime.utcnow() - timedelta(seconds=duration.total_seconds()) 48 | diff_delta = duration - now 49 | diff = int(diff_delta.total_seconds()) 50 | 51 | minutes, seconds = divmod(diff, 60) 52 | hours, minutes = divmod(minutes, 60) 53 | days, hours = divmod(hours, 24) 54 | weeks, days = divmod(days, 7) 55 | units = [weeks, days, hours, minutes, seconds] 56 | 57 | if format == 'full': 58 | unit_strs = ['week', 'day', 'hour', 'minute', 'second'] 59 | elif format == 'short': 60 | unit_strs = ['w', 'd', 'h', 'm', 's'] 61 | 62 | expires = [] 63 | for x in range(0, 5): 64 | if units[x] == 0: 65 | continue 66 | else: 67 | if format == 'short': 68 | expires.append('{}{}'.format(units[x], unit_strs[x])) 69 | elif units[x] > 1: 70 | expires.append('{} {}s'.format(units[x], unit_strs[x])) 71 | else: 72 | expires.append('{} {}'.format(units[x], unit_strs[x])) 73 | 74 | return ', '.join(expires) 75 | -------------------------------------------------------------------------------- /rowboat/util/zalgo.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | ZALGO = [ 4 | u'\u030d', 5 | u'\u030e', 6 | u'\u0304', 7 | u'\u0305', 8 | u'\u033f', 9 | u'\u0311', 10 | u'\u0306', 11 | u'\u0310', 12 | u'\u0352', 13 | u'\u0357', 14 | u'\u0351', 15 | u'\u0307', 16 | u'\u0308', 17 | u'\u030a', 18 | u'\u0342', 19 | u'\u0343', 20 | u'\u0344', 21 | u'\u034a', 22 | u'\u034b', 23 | u'\u034c', 24 | u'\u0303', 25 | u'\u0302', 26 | u'\u030c', 27 | u'\u0350', 28 | u'\u0300', 29 | u'\u030b', 30 | u'\u030f', 31 | u'\u0312', 32 | u'\u0313', 33 | u'\u0314', 34 | u'\u033d', 35 | u'\u0309', 36 | u'\u0363', 37 | u'\u0364', 38 | u'\u0365', 39 | u'\u0366', 40 | u'\u0367', 41 | u'\u0368', 42 | u'\u0369', 43 | u'\u036a', 44 | u'\u036b', 45 | u'\u036c', 46 | u'\u036d', 47 | u'\u036e', 48 | u'\u036f', 49 | u'\u033e', 50 | u'\u035b', 51 | u'\u0346', 52 | u'\u031a', 53 | u'\u0315', 54 | u'\u031b', 55 | u'\u0340', 56 | u'\u0341', 57 | u'\u0358', 58 | u'\u0321', 59 | u'\u0322', 60 | u'\u0327', 61 | u'\u0328', 62 | u'\u0334', 63 | u'\u0335', 64 | u'\u0336', 65 | u'\u034f', 66 | u'\u035c', 67 | u'\u035d', 68 | u'\u035e', 69 | u'\u035f', 70 | u'\u0360', 71 | u'\u0362', 72 | u'\u0338', 73 | u'\u0337', 74 | u'\u0361', 75 | u'\u0489', 76 | u'\u0316', 77 | u'\u0317', 78 | u'\u0318', 79 | u'\u0319', 80 | u'\u031c', 81 | u'\u031d', 82 | u'\u031e', 83 | u'\u031f', 84 | u'\u0320', 85 | u'\u0324', 86 | u'\u0325', 87 | u'\u0326', 88 | u'\u0329', 89 | u'\u032a', 90 | u'\u032b', 91 | u'\u032c', 92 | u'\u032d', 93 | u'\u032e', 94 | u'\u032f', 95 | u'\u0330', 96 | u'\u0331', 97 | u'\u0332', 98 | u'\u0333', 99 | u'\u0339', 100 | u'\u033a', 101 | u'\u033b', 102 | u'\u033c', 103 | u'\u0345', 104 | u'\u0347', 105 | u'\u0348', 106 | u'\u0349', 107 | u'\u034d', 108 | u'\u034e', 109 | u'\u0353', 110 | u'\u0354', 111 | u'\u0355', 112 | u'\u0356', 113 | u'\u0359', 114 | u'\u035a', 115 | u'\u0323', 116 | ] 117 | 118 | ZALGO_RE = re.compile(u'|'.join(ZALGO)) 119 | -------------------------------------------------------------------------------- /rowboat/views/auth.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, g, current_app, session, jsonify, redirect, request 2 | from requests_oauthlib import OAuth2Session 3 | 4 | from rowboat.models.user import User 5 | from rowboat.util.decos import authed 6 | 7 | auth = Blueprint('auth', __name__, url_prefix='/api/auth') 8 | 9 | 10 | def token_updater(token): 11 | pass 12 | 13 | 14 | def make_discord_session(token=None, state=None, scope=None): 15 | return OAuth2Session( 16 | client_id=current_app.config['discord']['CLIENT_ID'], 17 | token=token, 18 | state=state, 19 | scope=scope, 20 | redirect_uri=current_app.config['discord']['REDIRECT_URI'], 21 | auto_refresh_kwargs={ 22 | 'client_id': current_app.config['discord']['CLIENT_ID'], 23 | 'client_secret': current_app.config['discord']['CLIENT_SECRET'], 24 | }, 25 | auto_refresh_url=current_app.config['discord']['TOKEN_URL'], 26 | token_updater=token_updater) 27 | 28 | 29 | @auth.route('/logout', methods=['POST']) 30 | @authed 31 | def auth_logout(): 32 | g.user = None 33 | return jsonify({}) 34 | 35 | 36 | @auth.route('/discord') 37 | def auth_discord(): 38 | discord = make_discord_session(scope=('identify', )) 39 | auth_url, state = discord.authorization_url(current_app.config['discord']['AUTH_URL']) 40 | session['state'] = state 41 | return redirect(auth_url) 42 | 43 | 44 | @auth.route('/discord/callback') 45 | def auth_discord_callback(): 46 | if request.values.get('error'): 47 | return request.values['error'] 48 | 49 | if 'state' not in session: 50 | return 'no state', 400 51 | 52 | discord = make_discord_session(state=session['state']) 53 | token = discord.fetch_token( 54 | current_app.config['discord']['TOKEN_URL'], 55 | client_secret=current_app.config['discord']['CLIENT_SECRET'], 56 | authorization_response=request.url) 57 | 58 | discord = make_discord_session(token=token) 59 | 60 | data = discord.get(current_app.config['discord']['API_BASE_URL'] + '/users/@me').json() 61 | user = User.with_id(data['id']) 62 | 63 | if not user: 64 | return 'Unknown User', 403 65 | 66 | # if not user.admin: 67 | # return 'Invalid User', 403 68 | 69 | g.user = user 70 | 71 | return redirect('/') 72 | 73 | 74 | @auth.route('/@me') 75 | @authed 76 | def auth_me(): 77 | return jsonify(g.user) 78 | -------------------------------------------------------------------------------- /frontend/src/components/app.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import {globalState} from '../state'; 4 | import Topbar from './topbar'; 5 | import Dashboard from './dashboard'; 6 | import Login from './login'; 7 | import GuildOverview from './guild_overview'; 8 | import GuildConfigEdit from './guild_config_edit'; 9 | import GuildInfractions from './guild_infractions'; 10 | import GuildStats from './guild_stats'; 11 | import Archive from './archive'; 12 | import { BrowserRouter, Route, Switch, Redirect } from 'react-router-dom'; 13 | 14 | class AppWrapper extends Component { 15 | constructor() { 16 | super(); 17 | 18 | this.state = { 19 | ready: globalState.ready, 20 | user: globalState.user, 21 | }; 22 | 23 | if (!globalState.ready) { 24 | globalState.events.on('ready', () => { 25 | this.setState({ 26 | ready: true, 27 | }); 28 | }); 29 | 30 | globalState.events.on('user.set', (user) => { 31 | this.setState({ 32 | user: user, 33 | }); 34 | }); 35 | 36 | globalState.init(); 37 | } 38 | } 39 | 40 | render() { 41 | if (!this.state.ready) { 42 | return

    Loading...

    ; 43 | } 44 | 45 | if (this.state.ready && !this.state.user) { 46 | return ; 47 | } 48 | 49 | return ( 50 |
    51 | 52 |
    53 | 54 |
    55 |
    56 | ); 57 | } 58 | } 59 | 60 | function wrapped(component) { 61 | function result(props) { 62 | return ; 63 | } 64 | return result; 65 | } 66 | 67 | export default function router() { 68 | return ( 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /frontend/src/static/assets/ic_reactions_grey_16px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ic_reactions_grey_16px 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /rowboat/plugins/modlog/pump.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import gevent 4 | from disco.api.http import APIException 5 | from disco.util.logging import LoggingClass 6 | 7 | 8 | class ModLogPump(LoggingClass): 9 | def __init__(self, channel, sleep_duration=5): 10 | self.channel = channel 11 | self.sleep_duration = sleep_duration 12 | self._buffer = [] 13 | self._have = gevent.event.Event() 14 | self._quiescent_period = None 15 | self._lock = gevent.lock.Semaphore() 16 | 17 | self._greenlet = gevent.spawn(self._emitter_loop) 18 | 19 | def _start_emitter(self, greenlet=None): 20 | self.log.warning('Restarting emitter for ModLogPump %s' % self.channel) 21 | self._greenlet = gevent.spawn(self._emitter_loop) 22 | self._greenlet.link_exception(self._start_emitter) 23 | 24 | def __del__(self): 25 | if self._greenlet: 26 | self._greenlet.kill() 27 | 28 | def _emitter_loop(self): 29 | while True: 30 | self._have.wait() 31 | 32 | backoff = False 33 | 34 | with self.channel.client.api.capture() as responses: 35 | try: 36 | self._emit() 37 | except APIException as e: 38 | # Message send is disabled 39 | if e.code == 40004: 40 | backoff = True 41 | except Exception: 42 | self.log.exception('Exception when executing ModLogPump._emit: ') 43 | 44 | if responses.rate_limited: 45 | backoff = True 46 | 47 | # If we need to backoff, set a quiescent period that will batch 48 | # requests for the next 60 seconds. 49 | if backoff: 50 | self._quiescent_period = time.time() + 60 51 | 52 | if self._quiescent_period: 53 | if self._quiescent_period < time.time(): 54 | self._quiescent_period = None 55 | else: 56 | gevent.sleep(self.sleep_duration) 57 | 58 | if not self._buffer: 59 | self._have.clear() 60 | 61 | def _emit(self): 62 | with self._lock: 63 | msg = self._get_next_message() 64 | if not msg: 65 | return 66 | 67 | self.channel.send_message(msg) 68 | 69 | def _get_next_message(self): 70 | data = '' 71 | 72 | while self._buffer: 73 | payload = self._buffer.pop(0) 74 | if len(data) + (len(payload) + 1) > 2000: 75 | break 76 | 77 | data += '\n' 78 | data += payload 79 | 80 | return data 81 | 82 | def send(self, payload): 83 | with self._lock: 84 | self._buffer.append(payload) 85 | self._have.set() 86 | -------------------------------------------------------------------------------- /frontend/src/components/guilds_table.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { globalState, state } from '../state'; 3 | import { Link } from 'react-router-dom'; 4 | import sortBy from 'lodash/sortBy'; 5 | 6 | class GuildTableRowActions extends Component { 7 | render(props, state) { 8 | let parts = []; 9 | 10 | parts.push( 11 | 12 | 14 | 15 | ); 16 | 17 | parts.push( 18 | 19 | 21 | 22 | ); 23 | 24 | parts.push( 25 | 26 | 28 | 29 | ); 30 | 31 | if (globalState.user && globalState.user.admin) { 32 | parts.push( 33 |
    34 | 36 | 37 | ); 38 | } 39 | 40 | return ( 41 |
    42 | {parts} 43 |
    44 | ); 45 | } 46 | 47 | onDelete() { 48 | this.props.guild.delete().then(() => { 49 | window.location.reload(); 50 | }); 51 | } 52 | } 53 | 54 | class GuildTableRow extends Component { 55 | render() { 56 | return ( 57 | 58 | {this.props.guild.id} 59 | {this.props.guild.name} 60 | 61 | 62 | ); 63 | } 64 | } 65 | 66 | class GuildsTable extends Component { 67 | render() { 68 | if (!this.props.guilds) { 69 | return

    Loading...

    ; 70 | } 71 | 72 | let guilds = sortBy(Object.values(this.props.guilds), (i) => i.id); 73 | 74 | var rows = []; 75 | guilds.map((guild) => { 76 | rows.push(); 77 | }); 78 | 79 | return ( 80 |
    81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | {rows} 91 | 92 |
    IDNameActions
    93 |
    94 | ); 95 | } 96 | } 97 | 98 | export default GuildsTable; 99 | -------------------------------------------------------------------------------- /rowboat/constants.py: -------------------------------------------------------------------------------- 1 | import re 2 | import yaml 3 | from disco.types.user import GameType, Status, UserFlags 4 | from disco.types.guild import PremiumTier 5 | 6 | # Emojis 7 | GREEN_TICK_EMOJI_ID = 318468935047446529 8 | RED_TICK_EMOJI_ID = 318468934938394626 9 | GREEN_TICK_EMOJI = 'green_tick:{}'.format(GREEN_TICK_EMOJI_ID) 10 | RED_TICK_EMOJI = 'red_tick:{}'.format(RED_TICK_EMOJI_ID) 11 | STAR_EMOJI = u'\U00002B50' 12 | STATUS_EMOJI = { 13 | Status.ONLINE: ':status_online:318468935362281472', 14 | Status.IDLE: ':status_away:318468935387316234', 15 | Status.DND: ':status_dnd:318468935336984576', 16 | Status.OFFLINE: ':status_offline:318468935391641600', 17 | GameType.STREAMING: ':status_streaming:318468935450099712', 18 | } 19 | SNOOZE_EMOJI = u'\U0001f4a4' 20 | CHANNEL_CATEGORY_EMOJI = ':ChannelCategory:587359827504791563' 21 | TEXT_CHANNEL_EMOJI = ':TextChannel:587359827416580096' 22 | VOICE_CHANNEL_EMOJI = ':VoiceChannel:587359827051675672' 23 | ROLE_EMOJI = ':Role:587359847146848277' 24 | EMOJI_EMOJI = ':Emoji:587359846651920425' 25 | PREMIUM_GUILD_TIER_EMOJI = { 26 | PremiumTier.NONE: ':PremiumGuildTier0:587359938708504606', 27 | PremiumTier.TIER_1: ':PremiumGuildTier1:587359941854232576', 28 | PremiumTier.TIER_2: ':PremiumGuildTier2:587359948577701905', 29 | PremiumTier.TIER_3: ':PremiumGuildTier3:587359955565281315', 30 | } 31 | PREMIUM_GUILD_ICON_EMOJI = ':PremiumGuildIcon:587359857171103744' 32 | BADGE_EMOJI = { 33 | UserFlags.STAFF: ':staff:699078007192551444', 34 | UserFlags.PARTNER: ':partner:699078007184425040', 35 | UserFlags.HS_EVENTS: ':hypesquad_events:699078007326900265', 36 | UserFlags.HS_BRAVERY: ':hypesquad_bravery:699078006764732458', 37 | UserFlags.HS_BRILLIANCE: ':hypesquad_brilliance:699078006936961126', 38 | UserFlags.HS_BALANCE: ':hypesquad_balance:699078006915727442', 39 | UserFlags.BUG_HUNTER_LEVEL_1: ':bughunter1:699078007087824916', 40 | UserFlags.BUG_HUNTER_LEVEL_2: ':bughunter2:699078007179968613', 41 | UserFlags.VERIFIED_DEVELOPER: ':verified_developer:699078007150739486', 42 | UserFlags.EARLY_SUPPORTER: ':early_supporter:699078007133962280', 43 | } 44 | 45 | 46 | # Regexes 47 | INVITE_LINK_RE = re.compile(r'(discordapp.com/invite|discord.me|discord.gg)(?:/#)?(?:/invite)?/([a-z0-9\-]+)', re.I) 48 | URL_RE = re.compile(r'(https?://[^\s]+)') 49 | EMOJI_RE = re.compile(r'') 50 | USER_MENTION_RE = re.compile('<@!?([0-9]+)>') 51 | 52 | # IDs and such 53 | ROWBOAT_GUILD_ID = 318696775173013515 54 | ROWBOAT_USER_ROLE_ID = 318780548304863238 55 | ROWBOAT_CONTROL_CHANNEL = 318697653984690177 56 | ROWBOAT_SPAM_CONTROL_CHANNEL = 433701333938339858 57 | 58 | # Discord Error codes 59 | ERR_UNKNOWN_MESSAGE = 10008 60 | 61 | # Etc 62 | YEAR_IN_SEC = 60 * 60 * 24 * 365 63 | CDN_URL = 'https://twemoji.maxcdn.com/v/12.1.4/72x72/{}.png' 64 | 65 | # Loaded from files 66 | with open('data/badwords.txt', 'r') as f: 67 | BAD_WORDS = f.readlines() 68 | 69 | # Merge in any overrides in the config 70 | with open('config.yaml', 'r') as f: 71 | loaded = yaml.safe_load(f.read()) 72 | locals().update(loaded.get('constants', {})) 73 | -------------------------------------------------------------------------------- /rowboat/views/dashboard.py: -------------------------------------------------------------------------------- 1 | import json 2 | import subprocess 3 | 4 | from flask import Blueprint, request, g, make_response, jsonify, render_template 5 | from datetime import datetime 6 | 7 | from rowboat.redis import rdb 8 | from rowboat.models.message import Message, MessageArchive 9 | from rowboat.models.guild import Guild 10 | from rowboat.models.user import User 11 | from rowboat.models.channel import Channel 12 | from rowboat.util.decos import authed 13 | 14 | dashboard = Blueprint('dash', __name__) 15 | 16 | 17 | def pretty_number(i): 18 | if i > 1000000: 19 | return '%.2fm' % (i / 1000000.0) 20 | elif i > 10000: 21 | return '%.2fk' % (i / 1000.0) 22 | return str(i) 23 | 24 | 25 | class ServerSentEvent(object): 26 | def __init__(self, data): 27 | self.data = data 28 | self.event = None 29 | self.id = None 30 | self.desc_map = { 31 | self.data: "data", 32 | self.event: "event", 33 | self.id: "id" 34 | } 35 | 36 | def encode(self): 37 | if not self.data: 38 | return "" 39 | lines = ["%s: %s" % (v, k) for k, v in self.desc_map.iteritems() if k] 40 | return "%s\n\n" % "\n".join(lines) 41 | 42 | 43 | @dashboard.route('/api/stats') 44 | def stats(): 45 | stats = json.loads(rdb.get('web:dashboard:stats') or '{}') 46 | 47 | if not stats or 'refresh' in request.args: 48 | # stats['messages'] = pretty_number(Message.select().count()) 49 | # stats['guilds'] = pretty_number(Guild.select().count()) 50 | # stats['users'] = pretty_number(User.select().count()) 51 | # stats['channels'] = pretty_number(Channel.select().count()) 52 | stats['messages'] = Message.select().count() 53 | stats['guilds'] = Guild.select().count() 54 | stats['users'] = User.select().count() 55 | stats['channels'] = Channel.select().count() 56 | 57 | rdb.setex('web:dashboard:stats', json.dumps(stats), 300) 58 | 59 | return jsonify(stats) 60 | 61 | 62 | @dashboard.route('/api/archive/.') 63 | def archive(aid, fmt): 64 | try: 65 | archive = MessageArchive.select().where( 66 | (MessageArchive.archive_id == aid) & 67 | (MessageArchive.expires_at > datetime.utcnow()) 68 | ).get() 69 | except MessageArchive.DoesNotExist: 70 | return 'Invalid or Expires Archive ID', 404 71 | 72 | mime_type = None 73 | if fmt == 'json': 74 | mime_type == 'application/json' 75 | elif fmt == 'txt': 76 | mime_type = 'text/plain' 77 | elif fmt == 'csv': 78 | mime_type = 'text/csv' 79 | 80 | if fmt == 'html': 81 | return render_template('archive.html') 82 | 83 | res = make_response(archive.encode(fmt)) 84 | res.headers['Content-Type'] = mime_type 85 | return res 86 | 87 | 88 | @dashboard.route('/api/deploy', methods=['POST']) 89 | @authed 90 | def deploy(): 91 | if not g.user.admin: 92 | return '', 401 93 | 94 | subprocess.Popen(['git', 'pull', 'origin', 'master']).wait() 95 | rdb.publish('actions', json.dumps({ 96 | 'type': 'RESTART', 97 | })) 98 | return '', 200 99 | -------------------------------------------------------------------------------- /frontend/src/models/guild.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import {globalState} from '../state'; 3 | import BaseModel from './base'; 4 | 5 | export default class Guild extends BaseModel { 6 | constructor(obj) { 7 | super(); 8 | this.fromData(obj); 9 | this.config = null; 10 | } 11 | 12 | fromData(obj) { 13 | this.id = obj.id; 14 | this.ownerID = obj.owner_id; 15 | this.name = obj.name; 16 | this.icon = obj.icon; 17 | this.splash = obj.splash; 18 | this.region = obj.region; 19 | this.enabled = obj.enabled; 20 | this.whitelist = obj.whitelist; 21 | this.role = obj.role; 22 | this.events.emit('update', this); 23 | } 24 | 25 | update() { 26 | return new Promise((resolve, reject) => { 27 | axios.get(`/api/guilds/${this.id}`).then((res) => { 28 | this.fromData(res.data); 29 | resolve(res.data); 30 | }).catch((err) => { 31 | reject(err.response.data); 32 | }); 33 | }); 34 | } 35 | 36 | getConfig(refresh = false) { 37 | if (this.config && !refresh) { 38 | return new Promise((resolve) => { 39 | resolve(this.config); 40 | }); 41 | } 42 | 43 | return new Promise((resolve, reject) => { 44 | axios.get(`/api/guilds/${this.id}/config`).then((res) => { 45 | resolve(res.data); 46 | }).catch((err) => { 47 | reject(); 48 | }); 49 | }); 50 | } 51 | 52 | getConfigHistory(id) { 53 | return new Promise((resolve, reject) => { 54 | axios.get(`/api/guilds/${id}/config/history`).then((res) => { 55 | let data = res.data 56 | data = data.map(obj => { 57 | obj.created_timestamp = Math.floor(new Date(obj.created_at).getTime() / 1000); 58 | obj.user.discriminator = String(obj.user.discriminator).padStart(4, "0"); 59 | return obj; 60 | }); 61 | resolve(data); 62 | }).catch((err) => { 63 | reject(); 64 | }); 65 | }); 66 | } 67 | 68 | putConfig(config) { 69 | return new Promise((resolve, reject) => { 70 | axios.post(`/api/guilds/${this.id}/config`, {config: config}).then((res) => { 71 | resolve(); 72 | }).catch((err) => { 73 | reject(err.response.data); 74 | }); 75 | }); 76 | } 77 | 78 | getInfractions(page, limit, sorted, filtered) { 79 | let params = {page, limit}; 80 | 81 | if (sorted) { 82 | params.sorted = JSON.stringify(sorted) 83 | } 84 | 85 | if (filtered) { 86 | params.filtered = JSON.stringify(filtered) 87 | } 88 | 89 | return new Promise((resolve, reject) => { 90 | axios.get(`/api/guilds/${this.id}/infractions`, {params: params}).then((res) => { 91 | resolve(res.data); 92 | }).catch((err) => { 93 | reject(err.response.data); 94 | }); 95 | }); 96 | } 97 | 98 | delete() { 99 | return new Promise((resolve, reject) => { 100 | axios.delete(`/api/guilds/${this.id}`).then((res) => { 101 | resolve(); 102 | }).catch((err) => { 103 | reject(err.response.data); 104 | }) 105 | }); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /rowboat/types/__init__.py: -------------------------------------------------------------------------------- 1 | import fnmatch 2 | 3 | from disco.types.base import Model, SlottedModel, Field, ListField, DictField, text, snowflake 4 | 5 | __all__ = [ 6 | 'Model', 'SlottedModel', 'Field', 'ListField', 'DictField', 'text', 'snowflake', 'channel', 'raw', 7 | 'rule_matcher', 'lower', 8 | ] 9 | 10 | 11 | def lower(raw): 12 | return unicode(raw).lower() 13 | 14 | 15 | def raw(obj): 16 | return obj 17 | 18 | 19 | def ChannelField(raw): 20 | # Non-integers must be channel names 21 | if isinstance(raw, basestring) and raw: 22 | if raw[0] == '#': 23 | return raw[1:] 24 | elif not raw[0].isdigit(): 25 | return raw 26 | return snowflake(raw) 27 | 28 | 29 | def UserField(raw): 30 | return snowflake(raw) 31 | 32 | 33 | class RuleException(Exception): 34 | pass 35 | 36 | 37 | _FUNCS = { 38 | 'length': lambda a: len(a), 39 | } 40 | 41 | _FILTERS = { 42 | 'eq': ((str, unicode, int, float, list, tuple, set), lambda a, b: a == b), 43 | 'gt': ((int, float), lambda a, b: a > b), 44 | 'lt': ((int, float), lambda a, b: a < b), 45 | 'gte': ((int, float), lambda a, b: a >= b), 46 | 'lte': ((int, float), lambda a, b: a <= b), 47 | 'match': ((str, unicode), lambda a, b: fnmatch.fnmatch(a, b)), 48 | 'contains': ((list, tuple, set), lambda a, b: a.contains(b)), 49 | } 50 | 51 | 52 | def get_object_path(obj, path): 53 | if '.' not in path: 54 | return getattr(obj, path) 55 | key, rest = path.split('.', 1) 56 | return get_object_path(getattr(obj, key), rest) 57 | 58 | 59 | def _check_filter(filter_name, filter_data, value): 60 | if filter_name in _FUNCS: 61 | new_value = _FUNCS[filter_name](value) 62 | if isinstance(filter_data, dict): 63 | return all([_check_filter(k, v, new_value) for k, v in filter_data.items()]) 64 | return new_value == filter_data 65 | 66 | negate = False 67 | if filter_name.startswith('not_'): 68 | negate = True 69 | filter_name = filter_name[4:] 70 | 71 | if filter_name not in _FILTERS: 72 | raise RuleException('unknown filter {}'.format(filter_name)) 73 | 74 | typs, filt = _FILTERS[filter_name] 75 | if not isinstance(value, typs): 76 | raise RuleException('invalid type for filter, have {} but want {}'.format( 77 | type(value), typs, 78 | )) 79 | 80 | if negate: 81 | return not filt(value, filter_data) 82 | return filt(value, filter_data) 83 | 84 | 85 | def rule_matcher(obj, rules, output_key='out'): 86 | for rule in rules: 87 | for field_name, field_rule in rule.items(): 88 | if field_name == output_key: 89 | continue 90 | 91 | field_value = get_object_path(obj, field_name) 92 | 93 | if isinstance(field_rule, dict): 94 | field_matched = True 95 | 96 | for rule_filter, b in field_rule.items(): 97 | field_matched = _check_filter(rule_filter, b, field_value) 98 | 99 | if not field_matched: 100 | break 101 | elif field_value != field_rule: 102 | break 103 | else: 104 | yield rule.get(output_key, True) 105 | -------------------------------------------------------------------------------- /frontend/src/state.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'eventemitter3'; 2 | import axios from 'axios'; 3 | 4 | import User from './models/user'; 5 | 6 | class State { 7 | constructor() { 8 | this.events = new EventEmitter(); 9 | this.user = null; 10 | this.ready = false; 11 | this.stats = null; 12 | 13 | this._currentGuild = null; 14 | } 15 | 16 | set showAllGuilds(value) { 17 | window.localStorage.setItem('state.showAllGuilds', value); 18 | this.events.emit('showAllGuilds.set', value); 19 | } 20 | 21 | get showAllGuilds() { 22 | return JSON.parse(window.localStorage.getItem('state.showAllGuilds') || 'false'); 23 | } 24 | 25 | set currentGuild(guild) { 26 | this._currentGuild = guild; 27 | this.events.emit('currentGuild.set', guild); 28 | } 29 | 30 | get currentGuild() { 31 | return this._currentGuild; 32 | } 33 | 34 | init() { 35 | if (this.ready) return; 36 | 37 | this.getCurrentUser().then((user) => { 38 | this.ready = true; 39 | this.events.emit('ready'); 40 | user.getGuilds(); 41 | this.getStats(); 42 | }).catch(() => { 43 | this.ready = true; 44 | this.events.emit('ready'); 45 | }); 46 | } 47 | 48 | getGuild(guildID) { 49 | return new Promise((resolve, reject) => { 50 | this.getCurrentUser().then((user) => { 51 | user.getGuilds().then((guilds) => { 52 | if (guildID in guilds) { 53 | resolve(guilds[guildID]); 54 | } else { 55 | reject(null); 56 | } 57 | }); 58 | }); 59 | }); 60 | } 61 | 62 | getCurrentUser(refresh = false) { 63 | // If the user is already set, just fire the callback 64 | if (this.user && !refresh) { 65 | return new Promise((resolve) => { 66 | resolve(this.user); 67 | }); 68 | } 69 | 70 | return new Promise((resolve, reject) => { 71 | axios.get('/api/users/@me').then((res) => { 72 | this.user = new User(res.data); 73 | this.events.emit('user.set', this.user); 74 | resolve(this.user); 75 | }).catch((err) => { 76 | reject(); 77 | }); 78 | }); 79 | } 80 | 81 | getStats(cb) { 82 | return new Promise((resolve, reject) => { 83 | axios.get('/api/stats').then((res) => { 84 | this.stats = res.data; 85 | resolve(this.stats); 86 | }).catch((err) => { 87 | reject(); 88 | }) 89 | }) 90 | } 91 | 92 | getArchive(archiveID) { 93 | return new Promise((resolve, reject) => { 94 | axios.get(`/api/archive/${archiveID}.json`).then((res) => { 95 | resolve(res.data); 96 | }).catch((err) => { 97 | reject(); 98 | }); 99 | }); 100 | } 101 | 102 | deploy() { 103 | return new Promise((resolve) => { 104 | axios.post('/api/deploy').then((res) => { 105 | resolve(); 106 | }).catch((err) => { 107 | reject(); 108 | }); 109 | }); 110 | } 111 | 112 | logout() { 113 | return new Promise((resolve) => { 114 | axios.post('/api/auth/logout').then((res) => { 115 | this.user = null; 116 | this.events.emit('user.set', this.user); 117 | resolve(); 118 | }); 119 | }); 120 | } 121 | }; 122 | 123 | export var globalState = new State; 124 | -------------------------------------------------------------------------------- /rowboat/util/images.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from math import sqrt 3 | import random 4 | 5 | 6 | Point = namedtuple('Point', ('coords', 'n', 'ct')) 7 | Cluster = namedtuple('Cluster', ('points', 'center', 'n')) 8 | 9 | 10 | def get_points(img): 11 | points = [] 12 | w, h = img.size 13 | for count, color in img.getcolors(w * h): 14 | points.append(Point(color, 3, count)) 15 | return points 16 | 17 | 18 | def rtoh(rgb): 19 | return '%s' % ''.join(('%02x' % p for p in rgb)) 20 | 21 | 22 | def get_dominant_colors(img, n=3): 23 | try: 24 | img.thumbnail((1024, 1024)) 25 | w, h = img.size 26 | 27 | points = get_points(img) 28 | clusters = kmeans(points, n, 1) 29 | rgbs = [map(int, c.center.coords) for c in clusters] 30 | return map(rtoh, rgbs) 31 | except: 32 | return [0x00000] 33 | 34 | 35 | def get_dominant_colors_user(user, url=None): 36 | import requests 37 | from rowboat.redis import rdb 38 | from PIL import Image 39 | from six import BytesIO 40 | 41 | key = 'avatar:color:{}'.format(user.avatar) 42 | if rdb.exists(key): 43 | return int(rdb.get(key)) 44 | else: 45 | r = requests.get(url or user.avatar_url) 46 | try: 47 | r.raise_for_status() 48 | except: 49 | return 0 50 | color = int(str(get_dominant_colors(Image.open(BytesIO(r.content)))[0]), 16) 51 | rdb.set(key, color) 52 | return color 53 | 54 | 55 | def get_dominant_colors_guild(guild, url=None): 56 | import requests 57 | from rowboat.redis import rdb 58 | from PIL import Image 59 | from six import BytesIO 60 | 61 | key = 'guild:color:{}'.format(guild.icon) 62 | if rdb.exists(key): 63 | return int(rdb.get(key)) 64 | else: 65 | r = requests.get(url or guild.icon_url) 66 | try: 67 | r.raise_for_status() 68 | except: 69 | return 0 70 | color = int(str(get_dominant_colors(Image.open(BytesIO(r.content)))[0]), 16) 71 | rdb.set(key, color) 72 | return color 73 | 74 | 75 | def euclidean(p1, p2): 76 | return sqrt(sum([ 77 | (p1.coords[i] - p2.coords[i]) ** 2 for i in range(p1.n) 78 | ])) 79 | 80 | 81 | def calculate_center(points, n): 82 | vals = [0.0 for i in range(n)] 83 | plen = 0 84 | for p in points: 85 | plen += p.ct 86 | for i in range(n): 87 | vals[i] += (p.coords[i] * p.ct) 88 | return Point([(v / plen) for v in vals], n, 1) 89 | 90 | 91 | def kmeans(points, k, min_diff): 92 | clusters = [Cluster([p], p, p.n) for p in random.sample(points, k)] 93 | 94 | while 1: 95 | plists = [[] for i in range(k)] 96 | 97 | for p in points: 98 | smallest_distance = float('Inf') 99 | for i in range(k): 100 | distance = euclidean(p, clusters[i].center) 101 | if distance < smallest_distance: 102 | smallest_distance = distance 103 | idx = i 104 | plists[idx].append(p) 105 | 106 | diff = 0 107 | for i in range(k): 108 | old = clusters[i] 109 | center = calculate_center(plists[i], old.n) 110 | new = Cluster(plists[i], center, old.n) 111 | clusters[i] = new 112 | diff = max(diff, euclidean(old.center, new.center)) 113 | 114 | if diff < min_diff: 115 | break 116 | 117 | return clusters 118 | -------------------------------------------------------------------------------- /frontend/src/static/css/dataTables.responsive.css: -------------------------------------------------------------------------------- 1 | table.dataTable.dtr-inline.collapsed > tbody > tr > td:first-child, 2 | table.dataTable.dtr-inline.collapsed > tbody > tr > th:first-child { 3 | position: relative; 4 | padding-left: 30px; 5 | cursor: pointer; 6 | } 7 | table.dataTable.dtr-inline.collapsed > tbody > tr > td:first-child:before, 8 | table.dataTable.dtr-inline.collapsed > tbody > tr > th:first-child:before { 9 | top: 8px; 10 | left: 4px; 11 | height: 16px; 12 | width: 16px; 13 | display: block; 14 | position: absolute; 15 | color: white; 16 | border: 2px solid white; 17 | border-radius: 16px; 18 | text-align: center; 19 | line-height: 14px; 20 | box-shadow: 0 0 3px #444; 21 | box-sizing: content-box; 22 | content: '+'; 23 | background-color: #31b131; 24 | } 25 | table.dataTable.dtr-inline.collapsed > tbody > tr > td:first-child.dataTables_empty:before, 26 | table.dataTable.dtr-inline.collapsed > tbody > tr > th:first-child.dataTables_empty:before { 27 | display: none; 28 | } 29 | table.dataTable.dtr-inline.collapsed > tbody > tr.parent > td:first-child:before, 30 | table.dataTable.dtr-inline.collapsed > tbody > tr.parent > th:first-child:before { 31 | content: '-'; 32 | background-color: #d33333; 33 | } 34 | table.dataTable.dtr-inline.collapsed > tbody > tr.child td:before { 35 | display: none; 36 | } 37 | table.dataTable.dtr-inline.collapsed.compact > tbody > tr > td:first-child, 38 | table.dataTable.dtr-inline.collapsed.compact > tbody > tr > th:first-child { 39 | padding-left: 27px; 40 | } 41 | table.dataTable.dtr-inline.collapsed.compact > tbody > tr > td:first-child:before, 42 | table.dataTable.dtr-inline.collapsed.compact > tbody > tr > th:first-child:before { 43 | top: 5px; 44 | left: 4px; 45 | height: 14px; 46 | width: 14px; 47 | border-radius: 14px; 48 | line-height: 12px; 49 | } 50 | table.dataTable.dtr-column > tbody > tr > td.control, 51 | table.dataTable.dtr-column > tbody > tr > th.control { 52 | position: relative; 53 | cursor: pointer; 54 | } 55 | table.dataTable.dtr-column > tbody > tr > td.control:before, 56 | table.dataTable.dtr-column > tbody > tr > th.control:before { 57 | top: 50%; 58 | left: 50%; 59 | height: 16px; 60 | width: 16px; 61 | margin-top: -10px; 62 | margin-left: -10px; 63 | display: block; 64 | position: absolute; 65 | color: white; 66 | border: 2px solid white; 67 | border-radius: 16px; 68 | text-align: center; 69 | line-height: 14px; 70 | box-shadow: 0 0 3px #444; 71 | box-sizing: content-box; 72 | content: '+'; 73 | background-color: #31b131; 74 | } 75 | table.dataTable.dtr-column > tbody > tr.parent td.control:before, 76 | table.dataTable.dtr-column > tbody > tr.parent th.control:before { 77 | content: '-'; 78 | background-color: #d33333; 79 | } 80 | table.dataTable > tbody > tr.child { 81 | padding: 0.5em 1em; 82 | } 83 | table.dataTable > tbody > tr.child:hover { 84 | background: transparent !important; 85 | } 86 | table.dataTable > tbody > tr.child ul { 87 | display: inline-block; 88 | list-style-type: none; 89 | margin: 0; 90 | padding: 0; 91 | } 92 | table.dataTable > tbody > tr.child ul li { 93 | border-bottom: 1px solid #efefef; 94 | padding: 0.5em 0; 95 | } 96 | table.dataTable > tbody > tr.child ul li:first-child { 97 | padding-top: 0; 98 | } 99 | table.dataTable > tbody > tr.child ul li:last-child { 100 | border-bottom: none; 101 | } 102 | table.dataTable > tbody > tr.child span.dtr-title { 103 | display: inline-block; 104 | min-width: 75px; 105 | font-weight: bold; 106 | } 107 | -------------------------------------------------------------------------------- /frontend/src/components/guild_overview.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import {globalState} from '../state'; 3 | import {withRouter} from 'react-router'; 4 | 5 | class GuildWidget extends Component { 6 | render() { 7 | const source = `https://discordapp.com/api/guilds/${this.props.guildID}/widget.png?style=banner2`; 8 | return ((Guild must have widget enabled)); 9 | } 10 | } 11 | 12 | class GuildIcon extends Component { 13 | render() { 14 | if (this.props.guildIcon) { 15 | const source = `https://cdn.discordapp.com/icons/${this.props.guildID}/${this.props.guildIcon}.png`; 16 | return No Icon; 17 | } else { 18 | return No Icon; 19 | } 20 | } 21 | } 22 | 23 | class GuildSplash extends Component { 24 | render() { 25 | if (this.props.guildSplash) { 26 | const source = `https://cdn.discordapp.com/splashes/${this.props.guildID}/${this.props.guildSplash}.png`; 27 | return No Splash; 28 | } else { 29 | return No Splash; 30 | } 31 | } 32 | } 33 | 34 | class GuildOverviewInfoTable extends Component { 35 | render() { 36 | return ( 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |
    ID{this.props.guild.id}
    Owner{this.props.guild.ownerID}
    Region{this.props.guild.region}
    Icon
    Splash
    62 | ); 63 | } 64 | } 65 | 66 | export default class GuildOverview extends Component { 67 | constructor() { 68 | super(); 69 | 70 | this.state = { 71 | guild: null, 72 | }; 73 | } 74 | 75 | ensureGuild() { 76 | globalState.getGuild(this.props.params.gid).then((guild) => { 77 | guild.events.on('update', (guild) => this.setState({guild})); 78 | globalState.currentGuild = guild; 79 | this.setState({guild}); 80 | }).catch((err) => { 81 | console.error('Failed to load guild', this.props.params.gid, err); 82 | }); 83 | } 84 | 85 | componentWillUnmount() { 86 | globalState.currentGuild = null; 87 | } 88 | 89 | render() { 90 | if (!this.state.guild || this.state.guild.id != this.props.params.gid) { 91 | this.ensureGuild(); 92 | return

    Loading...

    ; 93 | } 94 | 95 | const OverviewTable = withRouter(GuildOverviewInfoTable); 96 | 97 | return (
    98 |
    99 |
    100 |
    101 |
    102 | Guild Banner 103 |
    104 |
    105 | 106 |
    107 |
    108 |
    109 |
    110 | Guild Info 111 |
    112 |
    113 |
    114 | 115 |
    116 |
    117 |
    118 |
    119 |
    120 |
    ); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /frontend/src/components/sidebar.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Link } from 'react-router-dom' 3 | import {globalState} from '../state'; 4 | import {STATS_ENABLED} from '../config/docker'; 5 | 6 | class SidebarLink extends Component { 7 | render () { 8 | const iconClass = `fa fa-${this.props.icon} fa-fw`; 9 | 10 | if (this.props.external) { 11 | return ( 12 |
  • 13 | 14 | {this.props.text} 15 | 16 |
  • 17 | ); 18 | } else { 19 | return ( 20 |
  • 21 | 22 | {this.props.text} 23 | 24 |
  • 25 | ); 26 | } 27 | } 28 | } 29 | 30 | 31 | class GuildLinks extends Component { 32 | render() { 33 | let links = []; 34 | 35 | if (this.props.active) { 36 | links.push( 37 | 38 | ); 39 | 40 | links.push( 41 | 42 | ); 43 | 44 | links.push( 45 | 46 | ); 47 | 48 | if (STATS_ENABLED) { 49 | links.push( 50 | 51 | ); 52 | } 53 | } 54 | 55 | return ( 56 |
  • 57 | 58 | {this.props.guild.name} 59 | 60 |
      61 | {links} 62 |
    63 |
  • 64 | ); 65 | } 66 | } 67 | 68 | 69 | class Sidebar extends Component { 70 | constructor() { 71 | super(); 72 | 73 | this.state = { 74 | guilds: null, 75 | currentGuildID: globalState.currentGuild ? globalState.currentGuild.id : null, 76 | showAllGuilds: globalState.showAllGuilds, 77 | }; 78 | 79 | globalState.events.on('showAllGuilds.set', (value) => this.setState({showAllGuilds: value})); 80 | 81 | globalState.getCurrentUser().then((user) => { 82 | user.getGuilds().then((guilds) => { 83 | this.setState({guilds}); 84 | }); 85 | }); 86 | 87 | globalState.events.on('currentGuild.set', (guild) => { 88 | this.setState({currentGuildID: guild ? guild.id : null}); 89 | }); 90 | } 91 | 92 | render() { 93 | let sidebarLinks = []; 94 | 95 | sidebarLinks.push( 96 | 97 | ); 98 | 99 | sidebarLinks.push( 100 | 101 | ); 102 | 103 | sidebarLinks.push( 104 | 105 | ); 106 | 107 | if (this.state.guilds) { 108 | for (let guild of Object.values(this.state.guilds)) { 109 | // Only show the active guild for users with a lot of them 110 | if ( 111 | !this.state.showAllGuilds && 112 | Object.keys(this.state.guilds).length > 10 && 113 | guild.id != this.state.currentGuildID 114 | ) continue; 115 | sidebarLinks.push(); 116 | } 117 | } 118 | 119 | return (
    120 |
    121 |
      122 | {sidebarLinks} 123 |
    124 |
    125 |
    ); 126 | } 127 | } 128 | 129 | export default Sidebar; 130 | -------------------------------------------------------------------------------- /rowboat/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import uuid 3 | import logging 4 | import time 5 | import os 6 | import gevent 7 | 8 | from gevent.lock import Semaphore 9 | from rowboat.redis import rdb 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | TASKS = {} 14 | 15 | 16 | def get_client(): 17 | from disco.client import ClientConfig, Client 18 | from rowboat.config import token 19 | 20 | config = ClientConfig() 21 | config.token = token 22 | config.max_reconnects = 0 23 | return Client(config) 24 | 25 | 26 | def task(*args, **kwargs): 27 | """ 28 | Register a new task. 29 | """ 30 | def deco(f): 31 | task = Task(f.__name__, f, *args, **kwargs) 32 | 33 | if f.__name__ in TASKS: 34 | raise Exception("Conflicting task name: %s" % f.__name__) 35 | 36 | TASKS[f.__name__] = task 37 | return task 38 | return deco 39 | 40 | 41 | class Task(object): 42 | def __init__(self, name, method, max_concurrent=None, buffer_time=None, max_queue_size=25, global_lock=None): 43 | self.name = name 44 | self.method = method 45 | self.max_concurrent = max_concurrent 46 | self.max_queue_size = max_queue_size 47 | self.buffer_time = buffer_time 48 | self.global_lock = global_lock 49 | 50 | self.log = log 51 | 52 | def __call__(self, *args, **kwargs): 53 | return self.method(self, *args, **kwargs) 54 | 55 | def queue(self, *args, **kwargs): 56 | # Make sure we have space 57 | if self.max_queue_size and (rdb.llen('task_queue:%s' % self.name) or 0) > self.max_queue_size: 58 | raise Exception("Queue for task %s is full!" % self.name) 59 | 60 | task_id = str(uuid.uuid4()) 61 | rdb.rpush('task_queue:%s' % self.name, json.dumps({ 62 | 'id': task_id, 63 | 'args': args, 64 | 'kwargs': kwargs 65 | })) 66 | return task_id 67 | 68 | 69 | class TaskRunner(object): 70 | def __init__(self, name, task): 71 | self.name = name 72 | self.task = task 73 | self.lock = Semaphore(task.max_concurrent) 74 | 75 | def process(self, job): 76 | log.info('[%s] Running job %s...', job['id'], self.name) 77 | start = time.time() 78 | 79 | try: 80 | self.task(*job['args'], **job['kwargs']) 81 | if self.task.buffer_time: 82 | time.sleep(self.task.buffer_time) 83 | except: 84 | log.exception('[%s] Failed in %ss', job['id'], time.time() - start) 85 | 86 | log.info('[%s] Completed in %ss', job['id'], time.time() - start) 87 | 88 | def run(self, job): 89 | lock = None 90 | if self.task.global_lock: 91 | lock = rdb.lock('{}:{}'.format( 92 | self.task.name, 93 | self.task.global_lock( 94 | *job['args'], 95 | **job['kwargs'] 96 | ) 97 | )) 98 | lock.acquire() 99 | 100 | if self.task.max_concurrent: 101 | self.lock.acquire() 102 | 103 | self.process(job) 104 | 105 | if lock: 106 | lock.release() 107 | 108 | if self.task.max_concurrent: 109 | self.lock.release() 110 | 111 | 112 | class TaskWorker(object): 113 | def __init__(self): 114 | self.load() 115 | self.queues = ['task_queue:' + i for i in TASKS.keys()] 116 | self.runners = {k: TaskRunner(k, v) for k, v in TASKS.items()} 117 | self.active = True 118 | 119 | def load(self): 120 | for f in os.listdir(os.path.dirname(os.path.abspath(__file__))): 121 | if f.endswith('.py') and not f.startswith('__'): 122 | __import__('rowboat.tasks.' + f.rsplit('.')[0]) 123 | 124 | def run(self): 125 | log.info('Running TaskManager on %s queues...', len(self.queues)) 126 | 127 | while self.active: 128 | chan, job = rdb.blpop(self.queues) 129 | job_name = chan.split(':', 1)[1] 130 | job = json.loads(job) 131 | 132 | if job_name not in TASKS: 133 | log.error("Cannot handle task %s", job_name) 134 | continue 135 | 136 | gevent.spawn(self.runners[job_name].run, job) 137 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from gevent import monkey; monkey.patch_all() 3 | 4 | from werkzeug.serving import run_with_reloader 5 | from gevent import pywsgi as wsgi 6 | from rowboat import ENV 7 | from rowboat.web import rowboat 8 | from rowboat.sql import init_db 9 | from disco.util.logging import LOG_FORMAT 10 | from yaml import safe_load 11 | 12 | import os 13 | import copy 14 | import click 15 | import signal 16 | import logging 17 | import gevent 18 | import subprocess 19 | 20 | 21 | class BotSupervisor(object): 22 | def __init__(self, env={}): 23 | self.proc = None 24 | self.env = env 25 | self.bind_signals() 26 | self.start() 27 | 28 | def bind_signals(self): 29 | signal.signal(signal.SIGUSR1, self.handle_sigusr1) 30 | 31 | def handle_sigusr1(self, signum, frame): 32 | print 'SIGUSR1 - RESTARTING' 33 | gevent.spawn(self.restart) 34 | 35 | def start(self): 36 | env = copy.deepcopy(os.environ) 37 | env.update(self.env) 38 | self.proc = subprocess.Popen(['python', '-m', 'disco.cli', '--config', 'config.yaml'], env=env) 39 | 40 | def stop(self): 41 | self.proc.terminate() 42 | 43 | def restart(self): 44 | try: 45 | self.stop() 46 | except: 47 | pass 48 | 49 | self.start() 50 | 51 | def run_forever(self): 52 | while True: 53 | self.proc.wait() 54 | gevent.sleep(5) 55 | 56 | 57 | @click.group() 58 | def cli(): 59 | logging.getLogger().setLevel(logging.INFO) 60 | 61 | 62 | @cli.command() 63 | @click.option('--reloader/--no-reloader', '-r', default=False) 64 | def serve(reloader): 65 | def run(): 66 | wsgi.WSGIServer(('0.0.0.0', 8686), rowboat.app).serve_forever() 67 | 68 | if reloader: 69 | run_with_reloader(run) 70 | else: 71 | run() 72 | 73 | 74 | @cli.command() 75 | @click.option('--env', '-e', default='local') 76 | def bot(env): 77 | with open('config.yaml', 'r') as f: 78 | config = safe_load(f) 79 | 80 | supervisor = BotSupervisor(env={ 81 | 'ENV': env, 82 | 'DSN': config['DSN'], 83 | }) 84 | supervisor.run_forever() 85 | 86 | 87 | @cli.command() 88 | @click.option('--worker-id', '-w', default=0) 89 | def workers(worker_id): 90 | from rowboat.tasks import TaskWorker 91 | 92 | # Log things to file 93 | file_handler = logging.FileHandler('worker-%s.log' % worker_id) 94 | log = logging.getLogger() 95 | file_handler.setFormatter(logging.Formatter(LOG_FORMAT)) 96 | log.addHandler(file_handler) 97 | 98 | for logname in ['peewee', 'requests']: 99 | logging.getLogger(logname).setLevel(logging.INFO) 100 | 101 | init_db(ENV) 102 | TaskWorker().run() 103 | 104 | 105 | @cli.command('add-global-admin') 106 | @click.argument('user-id') 107 | def add_global_admin(user_id): 108 | from rowboat.redis import rdb 109 | from rowboat.models.user import User 110 | init_db(ENV) 111 | rdb.sadd('global_admins', user_id) 112 | User.update(admin=True).where(User.user_id == user_id).execute() 113 | print 'Ok, added {} as a global admin'.format(user_id) 114 | 115 | 116 | @cli.command('wh-add') 117 | @click.argument('guild-id') 118 | @click.argument('flag') 119 | def add_whitelist(guild_id, flag): 120 | from rowboat.models.guild import Guild 121 | init_db(ENV) 122 | 123 | flag = Guild.WhitelistFlags.get(flag) 124 | if not flag: 125 | print 'Invalid flag' 126 | return 127 | 128 | try: 129 | guild = Guild.get(guild_id=guild_id) 130 | except Guild.DoesNotExist: 131 | print 'No guild exists with that id' 132 | return 133 | 134 | if guild.is_whitelisted(flag): 135 | print 'This guild already has this flag' 136 | return 137 | 138 | guild.whitelist.append(int(flag)) 139 | guild.save() 140 | guild.emit('GUILD_UPDATE') 141 | print 'Added flag' 142 | 143 | 144 | @cli.command('wh-rmv') 145 | @click.argument('guild-id') 146 | @click.argument('flag') 147 | def rmv_whitelist(guild_id, flag): 148 | from rowboat.models.guild import Guild 149 | init_db(ENV) 150 | 151 | flag = Guild.WhitelistFlags.get(flag) 152 | if not flag: 153 | print 'Invalid flag' 154 | return 155 | 156 | try: 157 | guild = Guild.get(guild_id=guild_id) 158 | except Guild.DoesNotExist: 159 | print 'No guild exists with that id' 160 | return 161 | 162 | if not guild.is_whitelisted(flag): 163 | print 'This guild doesn\'t have this flag' 164 | return 165 | 166 | guild.whitelist.remove(int(flag)) 167 | guild.save() 168 | guild.emit('GUILD_UPDATE') 169 | print 'Removed flag' 170 | 171 | 172 | if __name__ == '__main__': 173 | cli() 174 | -------------------------------------------------------------------------------- /rowboat/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | from disco.bot import Plugin 2 | from disco.types.base import Unset 3 | from disco.api.http import APIException 4 | from disco.bot.command import CommandEvent 5 | from disco.bot.plugin import register_plugin_base_class 6 | from disco.gateway.events import GatewayEvent 7 | 8 | from rowboat import raven_client 9 | from rowboat.util import MetaException 10 | from rowboat.types import Field 11 | from rowboat.types.guild import PluginsConfig 12 | 13 | 14 | class SafePluginInterface(object): 15 | def __init__(self, plugin): 16 | self.plugin = plugin 17 | 18 | def __getattr__(self, name): 19 | def wrapped(*args, **kwargs): 20 | if not self.plugin: 21 | return None 22 | 23 | return getattr(self.plugin, name)(*args, **kwargs) 24 | return wrapped 25 | 26 | 27 | class RavenPlugin(object): 28 | """ 29 | The RavenPlugin base plugin class manages tracking exceptions on a plugin 30 | level, by hooking the `handle_exception` function from disco. 31 | """ 32 | def handle_exception(self, greenlet, event): 33 | extra = {} 34 | 35 | if isinstance(greenlet.exception, MetaException): 36 | extra.update(greenlet.exception.metadata) 37 | 38 | if isinstance(greenlet.exception, APIException): 39 | extra['status_code'] = greenlet.exception.response.status_code 40 | extra['code'] = greenlet.exception.code 41 | extra['msg'] = greenlet.exception.msg 42 | extra['content'] = greenlet.exception.response.content 43 | 44 | if isinstance(event, CommandEvent): 45 | extra['command'] = { 46 | 'name': event.name, 47 | 'plugin': event.command.plugin.__class__.__name__, 48 | 'content': event.msg.content, 49 | } 50 | extra['author'] = event.msg.author.to_dict(), 51 | extra['channel'] = { 52 | 'id': event.channel.id, 53 | 'name': event.channel.name, 54 | } 55 | 56 | if event.guild: 57 | extra['guild'] = { 58 | 'id': event.guild.id, 59 | 'name': event.guild.name, 60 | } 61 | elif isinstance(event, GatewayEvent): 62 | try: 63 | extra['event'] = { 64 | 'name': event.__class__.__name__, 65 | 'data': event.to_dict(), 66 | } 67 | except: 68 | pass 69 | 70 | raven_client.captureException(exc_info=greenlet.exc_info, extra=extra) 71 | 72 | 73 | class BasePlugin(RavenPlugin, Plugin): 74 | """ 75 | A BasePlugin is simply a normal Disco plugin, but aliased so we have more 76 | control. BasePlugins do not have hooked/altered events, unlike a RowboatPlugin. 77 | """ 78 | pass 79 | 80 | 81 | class RowboatPlugin(RavenPlugin, Plugin): 82 | """ 83 | A plugin which wraps events to load guild configuration. 84 | """ 85 | global_plugin = False 86 | 87 | def get_safe_plugin(self, name): 88 | return SafePluginInterface(self.bot.plugins.get(name)) 89 | 90 | @classmethod 91 | def with_config(cls, config_cls): 92 | def deco(plugin_cls): 93 | name = plugin_cls.__name__.replace('Plugin', '').lower() 94 | PluginsConfig._fields[name] = Field(config_cls, default=Unset) 95 | PluginsConfig._fields[name].name = name 96 | # PluginsConfig._fields[name].default = None 97 | return plugin_cls 98 | return deco 99 | 100 | @property 101 | def name(self): 102 | return self.__class__.__name__.replace('Plugin', '').lower() 103 | 104 | def call(self, query, *args, **kwargs): 105 | plugin_name, method_name = query.split('.', 1) 106 | 107 | plugin = self.bot.plugins.get(plugin_name) 108 | if not plugin: 109 | raise Exception('Cannot resolve plugin %s (%s)' % (plugin_name, query)) 110 | 111 | method = getattr(plugin, method_name, None) 112 | if not method: 113 | raise Exception('Cannot resolve method %s for plugin %s' % (method_name, plugin_name)) 114 | 115 | return method(*args, **kwargs) 116 | 117 | 118 | register_plugin_base_class(BasePlugin) 119 | register_plugin_base_class(RowboatPlugin) 120 | 121 | 122 | class CommandResponse(Exception): 123 | EMOJI = None 124 | 125 | def __init__(self, response): 126 | if self.EMOJI: 127 | response = u':{}: {}'.format(self.EMOJI, response) 128 | self.response = response 129 | 130 | 131 | class CommandFail(CommandResponse): 132 | EMOJI = 'no_entry_sign' 133 | 134 | 135 | class CommandSuccess(CommandResponse): 136 | EMOJI = 'ballot_box_with_check' 137 | -------------------------------------------------------------------------------- /rowboat/models/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | import time 2 | import operator 3 | 4 | from playhouse.migrate import PostgresqlMigrator, migrate 5 | from rowboat import ENV 6 | from rowboat.sql import database, init_db 7 | 8 | COLUMN_EXISTS_SQL = ''' 9 | SELECT 1 10 | FROM information_schema.columns 11 | WHERE table_name=%s and column_name=%s; 12 | ''' 13 | 14 | GET_NULLABLE_SQL = ''' 15 | SELECT is_nullable 16 | FROM information_schema.columns 17 | WHERE table_name=%s and column_name=%s; 18 | ''' 19 | 20 | 21 | class Migrate(object): 22 | def __init__(self, rules, func): 23 | self.rules = rules 24 | self.func = func 25 | self.actions = [] 26 | self.raw_actions = [] 27 | self.m = PostgresqlMigrator(database) 28 | 29 | def run(self): 30 | conn = database.obj.get_conn() 31 | 32 | for rule in self.rules: 33 | with conn.cursor() as cur: 34 | if not rule(cur): 35 | return 36 | 37 | self.func(self) 38 | self.apply() 39 | 40 | def apply(self): 41 | print 'Applying {} actions'.format(len(self.actions)) 42 | migrate(*self.actions) 43 | 44 | print 'Executing {} raw queries'.format(len(self.raw_actions)) 45 | conn = database.obj.get_conn() 46 | for query, args in self.raw_actions: 47 | with conn.cursor() as cur: 48 | cur.execute(query, args) 49 | conn.commit() 50 | 51 | def add_columns(self, table, *fields): 52 | for field in fields: 53 | self.actions.append(self.m.add_column(table._meta.db_table, field.name, field)) 54 | 55 | def rename_column(self, table, field, new_name): 56 | self.actions.append(self.m.rename_column(table._meta.db_table, field.name, new_name)) 57 | 58 | def drop_not_nulls(self, table, *fields): 59 | for field in fields: 60 | self.actions.append(self.m.drop_not_null(table._meta.db_table, field.name)) 61 | 62 | def add_not_nulls(self, table, *fields): 63 | for field in fields: 64 | self.actions.append(self.m.add_not_null(table._meta.db_table, field.name)) 65 | 66 | def execute(self, query, params=None): 67 | self.raw_actions.append((query, params or [])) 68 | 69 | def backfill_column(self, table, old_columns, new_columns, pkeys=None, cast_funcs=None): 70 | total = table.select().count() 71 | 72 | if not pkeys: 73 | pkeys = [table._meta.primary_key] 74 | 75 | q = table.select( 76 | *(pkeys + old_columns) 77 | ).tuples() 78 | 79 | idx = 0 80 | modified = 0 81 | 82 | start = time.time() 83 | with database.transaction() as txn: 84 | for values in q: 85 | idx += 1 86 | 87 | if idx % 10000 == 0: 88 | print '[%ss] Backfilling %s %s/%s (wrote %s)' % (time.time() - start, str(table), idx, total, modified) 89 | 90 | if modified % 1000: 91 | txn.commit() 92 | 93 | obj = { 94 | new_column.name: cast_funcs[new_column](values[i + len(pkeys)]) 95 | if cast_funcs and new_column in cast_funcs else values[i] + len(pkeys) 96 | for i, new_column in enumerate(new_columns) 97 | } 98 | if not any(obj.values()): 99 | continue 100 | 101 | modified += 1 102 | table.update( 103 | **{new_column.name: values[i + len(pkeys)] for i, new_column in enumerate(new_columns)} 104 | ).where( 105 | reduce(operator.and_, [(iz == values[i]) for i, iz in enumerate(pkeys)]) 106 | ).execute() 107 | 108 | txn.commit() 109 | print 'DONE, %s scanned %s written' % (idx, modified) 110 | 111 | @staticmethod 112 | def missing(table, field): 113 | def rule(cursor): 114 | cursor.execute(COLUMN_EXISTS_SQL, (table._meta.db_table, field)) 115 | if len(cursor.fetchall()) == 0: 116 | return True 117 | return False 118 | return rule 119 | 120 | @staticmethod 121 | def nullable(table, field): 122 | def rule(cursor): 123 | cursor.execute(GET_NULLABLE_SQL, (table._meta.db_table, field)) 124 | return cursor.fetchone()[0] == 'YES' 125 | return rule 126 | 127 | @staticmethod 128 | def non_nullable(table, field): 129 | def rule(cursor): 130 | cursor.execute(GET_NULLABLE_SQL, (table._meta.db_table, field)) 131 | return cursor.fetchone()[0] == 'NO' 132 | return rule 133 | 134 | @classmethod 135 | def only_if(cls, check, table, *fields): 136 | def deco(func): 137 | rules = [check(table, i) for i in fields] 138 | cls(rules, func).run() 139 | return deco 140 | 141 | @classmethod 142 | def always(cls): 143 | def deco(func): 144 | cls([lambda c: True], func).run() 145 | return deco 146 | 147 | init_db(ENV) 148 | -------------------------------------------------------------------------------- /rowboat/plugins/stats.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from datadog import initialize, statsd 4 | from collections import defaultdict 5 | from disco.types.user import Status 6 | 7 | from rowboat import ENV 8 | from rowboat.plugins import BasePlugin as Plugin 9 | 10 | 11 | def to_tags(obj): 12 | return [u'{}:{}'.format(k, v) for k, v in obj.items()] 13 | 14 | 15 | class StatsPlugin(Plugin): 16 | global_plugin = True 17 | 18 | def load(self, ctx): 19 | super(StatsPlugin, self).load(ctx) 20 | if ENV == 'docker': 21 | initialize(statsd_host='statsd', statsd_port=8125) 22 | else: 23 | initialize(statsd_host='localhost', statsd_port=8125) 24 | 25 | self.nonce = 0 26 | self.nonces = {} 27 | self.unhooked_send_message = self.client.api.channels_messages_create 28 | self.client.api.channels_messages_create = self.send_message_hook 29 | 30 | def unload(self, ctx): 31 | self.client.api.channels_messages_create = self.unhooked_send_message 32 | super(StatsPlugin, self).unload(ctx) 33 | 34 | def send_message_hook(self, *args, **kwargs): 35 | self.nonce += 1 36 | kwargs['nonce'] = self.nonce 37 | self.nonces[self.nonce] = time.time() 38 | return self.unhooked_send_message(*args, **kwargs) 39 | 40 | @Plugin.listen('') 41 | def on_gateway_event(self, event): 42 | metadata = { 43 | 'event': event.__class__.__name__, 44 | } 45 | 46 | if hasattr(event, 'guild_id'): 47 | metadata['guild_id'] = event.guild_id 48 | elif hasattr(event, 'guild') and event.guild: 49 | metadata['guild_id'] = event.guild.id 50 | 51 | statsd.increment('gateway.events.received', tags=to_tags(metadata)) 52 | 53 | @Plugin.schedule(120, init=False) 54 | def track_state(self): 55 | # Track presence across all our guilds 56 | for guild in self.state.guilds.values(): 57 | member_status = defaultdict(int) 58 | for member in guild.members.values(): 59 | if member.user.presence and member.user.presence.status: 60 | member_status[member.user.presence.status] += 1 61 | else: 62 | member_status[Status.OFFLINE] += 1 63 | 64 | for k, v in member_status.items(): 65 | statsd.gauge('guild.presence.{}'.format(str(k).lower()), v, tags=to_tags({'guild_id': guild.id})) 66 | 67 | statsd.gauge('guild.members', len(guild.members), tags=to_tags({ 68 | 'guild_id': guild.id, 69 | })) 70 | 71 | # Track some information about discos internal state 72 | statsd.gauge('disco.state.dms', len(self.state.dms)) 73 | statsd.gauge('disco.state.guilds', len(self.state.guilds)) 74 | statsd.gauge('disco.state.channels', len(self.state.channels)) 75 | statsd.gauge('disco.state.users', len(self.state.users)) 76 | statsd.gauge('disco.state.voice_states', len(self.state.voice_states)) 77 | 78 | @Plugin.listen('MessageCreate') 79 | def on_message_create(self, event): 80 | tags = { 81 | 'channel_id': event.channel_id, 82 | 'author_id': event.author.id, 83 | } 84 | 85 | if event.guild: 86 | tags['guild_id'] = event.guild.id 87 | 88 | if event.author.id == self.client.state.me.id: 89 | if event.nonce in self.nonces: 90 | statsd.timing( 91 | 'latency.message_send', 92 | time.time() - self.nonces[event.nonce], 93 | tags=to_tags(tags) 94 | ) 95 | del self.nonces[event.nonce] 96 | 97 | statsd.increment('guild.messages.create', tags=to_tags(tags)) 98 | 99 | @Plugin.listen('MessageUpdate') 100 | def on_message_update(self, event): 101 | tags = { 102 | 'channel_id': event.channel_id, 103 | 'author_id': event.author.id, 104 | } 105 | 106 | if event.guild: 107 | tags['guild_id'] = event.guild.id 108 | 109 | statsd.increment('guild.messages.update', tags=to_tags(tags)) 110 | 111 | @Plugin.listen('MessageDelete') 112 | def on_message_delete(self, event): 113 | tags = { 114 | 'channel_id': event.channel_id, 115 | } 116 | 117 | statsd.increment('guild.messages.delete', tags=to_tags(tags)) 118 | 119 | @Plugin.listen('MessageReactionAdd') 120 | def on_message_reaction_add(self, event): 121 | statsd.increment('guild.messages.reactions.add', tags=to_tags({ 122 | 'channel_id': event.channel_id, 123 | 'user_id': event.user_id, 124 | 'emoji_id': event.emoji.id, 125 | 'emoji_name': event.emoji.name, 126 | })) 127 | 128 | @Plugin.listen('MessageReactionRemove') 129 | def on_message_reaction_remove(self, event): 130 | statsd.increment('guild.messages.reactions.remove', tags=to_tags({ 131 | 'channel_id': event.channel_id, 132 | 'user_id': event.user_id, 133 | 'emoji_id': event.emoji.id, 134 | 'emoji_name': event.emoji.name, 135 | })) 136 | -------------------------------------------------------------------------------- /data/actions_simple.yaml: -------------------------------------------------------------------------------- 1 | CHANNEL_CREATE: 2 | emoji: pen_ballpoint 3 | format: 'channel {e.mention} was created' 4 | 5 | CHANNEL_DELETE: 6 | emoji: wastebasket 7 | format: 'channel #{e.name} was deleted' 8 | 9 | CATEGORY_CREATE: 10 | emoji: pen_ballpoint 11 | format: 'category #{e.name} was created' 12 | 13 | CATEGORY_DELETE: 14 | emoji: wastebasket 15 | format: 'category #{e.name} was deleted' 16 | 17 | GUILD_BAN_ADD: 18 | emoji: rotating_light 19 | format: '{e.user!c} (`{e.user.id}`) was banned' 20 | 21 | GUILD_SOFTBAN_ADD: 22 | emoji: rotating_light 23 | format: '{e.user!c} (`{e.user.id}`) was soft-banned by **{actor!c}**: `{reason!s}`' 24 | 25 | GUILD_TEMPBAN_ADD: 26 | emoji: rotating_light 27 | format: '{e.user!c} (`{e.user.id}`) was temp-banned until {expires} by **{actor!c}**: `{reason!s}`' 28 | 29 | GUILD_BAN_REMOVE: 30 | emoji: rotating_light 31 | format: '{e.user!c} (`{e.user.id}`) was unbanned' 32 | 33 | GUILD_MEMBER_ADD: 34 | emoji: inbox_tray 35 | format: '{e.user!c} (`{e.user.id}`) joined {new} (created {created})' 36 | 37 | GUILD_MEMBER_REMOVE: 38 | emoji: outbox_tray 39 | format: '{e.user!c} (`{e.user.id}`) left the server' 40 | 41 | GUILD_MEMBER_ROLES_ADD: 42 | emoji: key 43 | format: "{e.user!c} (`{e.user.id}`) role added **{role.name}**" 44 | 45 | GUILD_MEMBER_ROLES_RMV: 46 | emoji: key 47 | format: "{e.user!c} (`{e.user.id}`) role removed **{role.name}**" 48 | 49 | GUILD_ROLE_CREATE: 50 | emoji: pen_ballpoint 51 | format: 'role {e.role} was created' 52 | 53 | GUILD_ROLE_DELETE: 54 | emoji: wastebasket 55 | format: 'role {pre_role} was deleted' 56 | 57 | ADD_NICK: 58 | emoji: name_badge 59 | format: '{e.user!c} (`{e.user.id}`) added nickname `{nickname!s}`' 60 | 61 | RMV_NICK: 62 | emoji: name_badge 63 | format: '{e.user!c} (`{e.user.id}`) removed nickname `{nickname!s}`' 64 | 65 | CHANGE_NICK: 66 | emoji: name_badge 67 | format: '{e.user!c} (`{e.user.id}`) changed nick from `{before!s}` to `{after!s}`' 68 | 69 | CHANGE_USERNAME: 70 | emoji: name_badge 71 | format: '{after!c} (`{e.user.id}`) changed username from `{before!s}` to `{after!s}`' 72 | 73 | MESSAGE_EDIT: 74 | emoji: pencil 75 | format: "{e.author!c} (`{e.author.id}`) message edited in **{e.channel}**:\n**B:** {before!s}\n**A:** {after!s}" 76 | 77 | MESSAGE_DELETE: 78 | emoji: wastebasket 79 | format: "{author!c} (`{author.id}`) message deleted in **{channel}**: (`{e.id}`)\n{msg!s} {attachments}" 80 | 81 | MESSAGE_DELETE_BULK: 82 | emoji: wastebasket 83 | format: "{count} messages deleted in **{channel}** (<{log}>)" 84 | 85 | VOICE_CHANNEL_JOIN: 86 | emoji: telephone 87 | format: "{e.user!c} (`{e.user.id}`) joined **{e.channel}**" 88 | 89 | VOICE_CHANNEL_LEAVE: 90 | emoji: telephone 91 | format: "{e.user!c} (`{e.user.id}`) left **{channel}**" 92 | 93 | VOICE_CHANNEL_MOVE: 94 | emoji: telephone 95 | format: "{e.user!c} (`{e.user.id}`) moved from **{before_channel}** to **{e.channel}**" 96 | 97 | COMMAND_USED: 98 | emoji: wrench 99 | format: "{e.author!c} (`{e.author.id}`) used command in **{e.channel}** `{e.content!s}`" 100 | 101 | SPAM_DEBUG: 102 | emoji: helmet_with_cross 103 | format: "{v.member!c} (`{v.member.id}`) violated {v.label} ({v.event.channel.mention}): {v.msg}" 104 | 105 | MEMBER_RESTORE: 106 | emoji: helmet_with_cross 107 | format: "{member!c} was restored ({elements})" 108 | 109 | MEMBER_MUTED: 110 | emoji: no_mouth 111 | format: '{member!c} (`{member.id}`) was muted by **{actor!c}**: `{reason!s}`' 112 | 113 | MEMBER_TEMP_MUTED: 114 | emoji: no_mouth 115 | format: '{member!c} (`{member.id}`) was temp-muted until {expires} by **{actor!c}**: `{reason!s}`' 116 | 117 | MEMBER_UNMUTED: 118 | emoji: open_mouth 119 | format: '{member!c} (`{member.id}`) was unmuted by **{actor!c}**' 120 | 121 | MEMBER_TEMPMUTE_EXPIRE: 122 | emoji: open_mouth 123 | format: '{member!c} (`{member.id}`) was automatically unmuted, their tempmute (#{inf.id}) expired' 124 | 125 | CENSORED: 126 | emoji: no_entry_sign 127 | format: "censored message by {e.author!c} (`{e.author.id}`) in {e.channel} (`{e.channel.id}`) {c.details}:\n```{c.content!s}```" 128 | 129 | MEMBER_WARNED: 130 | emoji: warning 131 | format: "{member!c} (`{member.id}`) was warned by **{actor!c}**: `{reason!s}`" 132 | 133 | MEMBER_ROLE_ADD: 134 | emoji: key 135 | format: "{member!c} (`{member.id}`) role added **{role.name}** by **{actor!c}**: `{reason!s}`" 136 | 137 | MEMBER_ROLE_REMOVE: 138 | emoji: key 139 | format: "{member!c} (`{member.id}`) role removed **{role.name}** by **{actor!c}**: `{reason!s}`" 140 | 141 | MEMBER_BAN: 142 | emoji: rotating_light 143 | format: '{user!c} (`{user_id}`) was banned by **{actor!c}**: `{reason!s}`' 144 | 145 | MEMBER_SOFTBAN: 146 | emoji: rotating_light 147 | format: '{member!c} (`{member.id}`) was soft-banned by **{actor!c}**: `{reason!s}`' 148 | 149 | MEMBER_TEMPBAN: 150 | emoji: rotating_light 151 | format: '{member!c} (`{member.id}`) was temp-banned until {expires} by **{actor!c}**: `{reason!s}`' 152 | 153 | MEMBER_TEMPBAN_EXPIRE: 154 | emoji: rotating_light 155 | format: '{user!c} (`{user_id}`) was automatically unbanned, their tempban (#{inf.id}) expired' 156 | 157 | MEMBER_KICK: 158 | emoji: boot 159 | format: '{member!c} (`{member.id}`) was kicked by **{actor!c}**: `{reason!s}`' 160 | -------------------------------------------------------------------------------- /rowboat/templates/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block body %} 3 |
    4 |
    5 |

    Dashboard

    6 |
    7 |
    8 | 9 | {% if g.user.admin %} 10 |
    11 |
    12 |
    13 |
    14 |
    15 |
    16 | 17 |
    18 |
    19 |
    {{ stats['messages'] }}
    20 |
    Messages
    21 |
    22 |
    23 |
    24 |
    25 |
    26 |
    27 |
    28 |
    29 |
    30 |
    31 | 32 |
    33 |
    34 |
    {{ stats['guilds'] }}
    35 |
    Guilds
    36 |
    37 |
    38 |
    39 |
    40 |
    41 |
    42 |
    43 |
    44 |
    45 |
    46 | 47 |
    48 |
    49 |
    {{ stats['users'] }}
    50 |
    Users
    51 |
    52 |
    53 |
    54 |
    55 |
    56 |
    57 |
    58 |
    59 |
    60 |
    61 | 62 |
    63 |
    64 |
    {{ stats['channels'] }}
    65 |
    Channels
    66 |
    67 |
    68 |
    69 |
    70 |
    71 |
    72 | {% endif %} 73 | 74 |
    75 |
    76 |
    77 |
    78 | Guilds 79 |
    80 |
    81 |
    82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | {% for guild in guilds %} 92 | 93 | 94 | 95 | 111 | 112 | {% endfor %} 113 | 114 |
    IDNameActions
    {{ guild.guild_id }}{{ guild.name }} 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | {% if g.user.admin %} 106 | 107 | 108 | 109 | {% endif %} 110 |
    115 |
    116 |
    117 |
    118 |
    119 | {% if g.user.admin %} 120 |
    121 |
    122 |
    123 | Control Panel 124 |
    125 | 126 |
    127 | Deploy 128 |
    129 | 130 |
    131 |
    132 | {% endif %} 133 |
    134 | {% endblock %} 135 | {% block scripts %} 136 | 159 | {% endblock %} 160 | -------------------------------------------------------------------------------- /frontend/src/components/dashboard.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import CountUp from 'react-countup'; 3 | 4 | import PageHeader from './page_header'; 5 | import GuildsTable from './guilds_table'; 6 | import {globalState} from '../state'; 7 | 8 | class DashboardGuildsList extends Component { 9 | constructor() { 10 | super(); 11 | this.state = {guilds: null}; 12 | } 13 | 14 | componentWillMount() { 15 | globalState.getCurrentUser().then((user) => { 16 | user.getGuilds().then((guilds) => { 17 | this.setState({guilds}); 18 | }); 19 | }); 20 | } 21 | 22 | render() { 23 | return ( 24 |
    25 |
    26 | Guilds 27 |
    28 |
    29 | 30 |
    31 |
    32 | ); 33 | } 34 | } 35 | 36 | class ControlPanel extends Component { 37 | constructor() { 38 | super(); 39 | 40 | this.messageTimer = null; 41 | 42 | this.state = { 43 | guilds: null, 44 | message: null, 45 | }; 46 | } 47 | 48 | componentWillMount() { 49 | globalState.getCurrentUser().then((user) => { 50 | user.getGuilds().then((guilds) => { 51 | this.setState({guilds}); 52 | }); 53 | }); 54 | } 55 | 56 | onDeploy() { 57 | globalState.deploy().then(() => { 58 | this.renderMessage('success', 'Deploy Started'); 59 | }).catch((err) => { 60 | this.renderMessage('danger', `Deploy Failed: ${err}`); 61 | }); 62 | } 63 | 64 | renderMessage(type, contents) { 65 | this.setState({ 66 | message: { 67 | type: type, 68 | contents: contents, 69 | } 70 | }) 71 | 72 | if (this.messageTimer) clearTimeout(this.messageTimer); 73 | 74 | this.messageTimer = setTimeout(() => { 75 | this.setState({ 76 | message: null, 77 | }); 78 | this.messageTimer = null; 79 | }, 5000); 80 | } 81 | 82 | render() { 83 | return ( 84 |
    85 | {this.state.message &&
    {this.state.message.contents}
    } 86 |
    87 | Control Panel 88 |
    89 | 92 |
    93 | ); 94 | } 95 | } 96 | 97 | class StatsPanel extends Component { 98 | render () { 99 | const panelClass = `panel panel-${this.props.color}`; 100 | const iconClass = `fa fa-${this.props.icon} fa-5x`; 101 | 102 | return ( 103 |
    104 |
    105 |
    106 |
    107 |
    108 | 109 |
    110 |
    111 |
    112 | 113 |
    114 |
    {this.props.text}
    115 |
    116 |
    117 |
    118 |
    119 |
    120 | ); 121 | } 122 | } 123 | 124 | class Stats extends Component { 125 | constructor() { 126 | super(); 127 | this.state = { 128 | stats: { 129 | messages: null, 130 | guilds: null, 131 | users: null, 132 | channels: null 133 | } 134 | }; 135 | } 136 | 137 | render() { 138 | // if (globalState.user.admin) { 139 | if (this.state.stats.guilds === null) { 140 | globalState.getStats().then((stats) => { 141 | this.setState({stats}); 142 | }); 143 | } 144 | 145 | let statsPanels = []; 146 | statsPanels.push( 147 | 148 | ); 149 | statsPanels.push( 150 | 151 | ); 152 | statsPanels.push( 153 | 154 | ); 155 | statsPanels.push( 156 | 157 | ); 158 | 159 | return ( 160 |
    161 | {statsPanels} 162 |
    163 | ); 164 | } 165 | } 166 | 167 | class Dashboard extends Component { 168 | render() { 169 | let parts = []; 170 | 171 | parts.push( 172 | 173 | ); 174 | 175 | parts.push( 176 |
    177 | 178 |
    179 | ); 180 | 181 | parts.push( 182 |
    183 |
    184 | 185 |
    186 | { 187 | globalState.user && globalState.user.admin && 188 |
    189 | 190 |
    191 | } 192 |
    193 | ); 194 | 195 | return ( 196 |
    197 | {parts} 198 |
    199 | ); 200 | } 201 | } 202 | 203 | export default Dashboard; 204 | -------------------------------------------------------------------------------- /frontend/src/components/guild_infractions.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import debounce from 'lodash/debounce'; 3 | import {globalState} from '../state'; 4 | import ReactTable from "react-table"; 5 | 6 | class InfractionTable extends Component { 7 | render() { 8 | const inf = this.props.infraction; 9 | 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 |
    ID{inf.id}
    Target User{inf.user.username}#{String(inf.user.discriminator).padStart(4, "0")} ({inf.user.id})
    Actor User{inf.actor.username}#{String(inf.actor.discriminator).padStart(4, "0")} ({inf.actor.id})
    Created At{inf.created_at}
    Expires At{inf.expires_at}
    Type{inf.type.name}
    Reason{inf.reason}
    49 | ); 50 | } 51 | } 52 | 53 | class GuildInfractionInfo extends Component { 54 | render() { 55 | return ( 56 |
    57 |
    58 | Infraction Info 59 |
    60 |
    61 | 62 |
    63 |
    64 | ); 65 | } 66 | } 67 | 68 | class GuildInfractionsTable extends Component { 69 | constructor() { 70 | super(); 71 | 72 | this.state = { 73 | data: [], 74 | loading: true, 75 | }; 76 | } 77 | 78 | render() { 79 | return ( 80 | d.user.username + '#' + String(d.user.discriminator).padStart(4, "0"), 90 | filterable: false, 91 | sortable: false, 92 | } 93 | ]}, 94 | {Header: "Actor", columns: [ 95 | {Header: "ID", accessor: "actor.id", id: "actor_id"}, 96 | { 97 | Header: "Tag", 98 | id: "actor_tag", 99 | accessor: d => d.actor.username + '#' + String(d.actor.discriminator).padStart(4, "0"), 100 | filterable: false, 101 | sortable: false, 102 | } 103 | ]}, 104 | {Header: "Created At", accessor: "created_at", filterable: false}, 105 | {Header: "Expires At", accessor: "expires_at", filterable: false}, 106 | {Header: "Type", accessor: "type.name", id: "type"}, 107 | {Header: "Reason", accessor: "reason", sortable: false}, 108 | {Header: "Active", id: "active", accessor: d => d.active ? 'Active' : 'Inactive', sortable: false, filterable: false}, 109 | ]} 110 | pages={10000} 111 | loading={this.state.loading} 112 | manual 113 | onFetchData={debounce(this.onFetchData.bind(this), 500)} 114 | filterable 115 | className="-striped -highlight" 116 | getTdProps={(state, rowInfo, column, instance) => { 117 | return { 118 | onClick: () => { 119 | this.props.onSelectInfraction(rowInfo.original); 120 | } 121 | }; 122 | }} 123 | /> 124 | ); 125 | } 126 | 127 | onFetchData(state, instance) { 128 | this.setState({loading: true}); 129 | 130 | this.props.guild.getInfractions(state.page + 1, state.pageSize, state.sorted, state.filtered).then((data) => { 131 | this.setState({ 132 | data: data, 133 | loading: false, 134 | }); 135 | }); 136 | } 137 | } 138 | 139 | export default class GuildInfractions extends Component { 140 | constructor() { 141 | super(); 142 | 143 | this.state = { 144 | guild: null, 145 | infraction: null, 146 | }; 147 | } 148 | 149 | componentWillMount() { 150 | globalState.getGuild(this.props.params.gid).then((guild) => { 151 | globalState.currentGuild = guild; 152 | this.setState({guild}); 153 | }).catch((err) => { 154 | console.error('Failed to load guild', this.props.params.gid); 155 | }); 156 | } 157 | 158 | componentWillUnmount() { 159 | globalState.currentGuild = null; 160 | } 161 | 162 | onSelectInfraction(infraction) { 163 | console.log('Set infraction', infraction); 164 | this.setState({infraction}); 165 | } 166 | 167 | render() { 168 | if (!this.state.guild) { 169 | return

    Loading...

    ; 170 | } 171 | 172 | return ( 173 |
    174 |
    175 | {this.state.infraction && } 176 |
    177 |
    178 | Infractions 179 |
    180 |
    181 | 182 |
    183 |
    184 |
    185 |
    186 | ); 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## V1.3.0 4 | 5 | ### Features 6 | 7 | - Added `archive extend` command which extends the duration of a current or expired archive 8 | - Added some information to the guild overview/information page on the dashboard (thanks @swvn9) 9 | - Added a spam bucket for max upper case letters (`max_upper_case`) 10 | - Added `group_confirm_reactions` option to admin configuration, when toggled to true it will respond to !join and !leave group commands with only a reaction 11 | - Added the ability to "snooze" reminders via reactions 12 | - Added statistics around message latency 13 | - Added a channel mention within the `SPAM_DEBUG` modlog event 14 | 15 | ### Bugfixes 16 | 17 | - Fixed the response text of the `seen` command (thanks @OGNova) 18 | - Fixed the infractions tab not showing up in the sidebar when viewing the config (thanks @OGNova) 19 | - Fixed carrige returns not being counted as new lines in spam (thanks @liampwll) 20 | - Fixed a bug with `mute` that would not allow a mute with no duration or reason to be applied 21 | - Fixed case where long message deletions would not be properly logged (they are now truncated properly by the modlog) 22 | 23 | ## V1.2.0 24 | 25 | ### Features 26 | 27 | - Twitch plugin added, can be used to track and notify a server of streams going online. (Currently early beta) 28 | 29 | ## V1.1.1 30 | 31 | - Removed some utilities commands that didn't fit rowboats goal 32 | - Etc SQL changes 33 | 34 | ## V1.1.0 35 | 36 | ### Features 37 | 38 | - **MAJOR** Added support for audit-log reasons withing various admin actions. This will log the reason you provide for kicks/bans/mutes/etc within the Discord audit-log. 39 | - **MAJOR** !mute behavior has changed. If a valid duration string is the first part of the reason, a !mute command is transformed into a tempmute. This should help resolve a common mistake people make. 40 | - !join and !leave will no longer respond if no group roles are specified within the admin config 41 | - Added a SQL command for global admins to graph word usage in a server. 42 | 43 | ### Bugfixes 44 | 45 | - Fixed reloading of SQLPlugin in development 46 | - Fixed some user images causing `get_dominant_colors` to return an incorrect value causing a command exception 47 | - Fixed error case in modlog when handling VoiceStateUpdate 48 | - Fixed a case where a user could not save the webconfig because the web access object had their ID stored as a string 49 | - Fixed censor throwing errors when a message which was censored was already deleted 50 | 51 | ## V1.0.5 52 | 53 | Similar changes to v1.0.4 54 | 55 | ## V1.0.4 56 | 57 | ### Bugfixes 58 | 59 | - Fixed invalid function call causing errors w/ CHANGE\_USERNAME event 60 | 61 | ## V1.0.3 62 | 63 | ### Features 64 | 65 | - Added two new modlog events, `MEMBER_TEMPMUTE_EXPIRE` and `MEMBER_TEMPBAN_EXPIRE` which are triggered when their respective infractions expire 66 | 67 | ### Bugfixes 68 | 69 | - Fixed cases where certain modlog channels could become stuck due to transient Discord issues 70 | - Fixed cases where content in certain censor filters would be ignored due to its casing, censor now ignores all casing in filters within its config 71 | 72 | ### Etc 73 | 74 | - Don't leave the ROWBOAT\_GUILD\_ID, its special (and not doing this makes it impossible to bootstrap the bot otherwise) 75 | - Improved the performance of !stats 76 | 77 | ## V1.0.2 78 | 79 | ### Bugfixes 80 | 81 | - Fixed the user in a ban/forceban's modlog message being ``. The modlog entry will now contain their ID if Rowboat cannot resolve further user information 82 | - Fixed the duration of unlocking a role being 6 minutes instead of 5 minutes like the response message said 83 | - Fixed some misc errors thrown when passing webhook messages to censor/spam plugins 84 | - Fixed case where Rowboat guild access was not being properly synced due to invalid data being passed in the web configuration for some guilds 85 | - Fixed the documentation URL being outdated 86 | - Fixed some commands being incorrectly exposed publically 87 | - Fixed the ability to revoke or change ones own roles within the configuration 88 | 89 | ### Etc 90 | 91 | - Removed ignored\_channels, this concept is no longer (and hasn't been for a long time) used. 92 | - Improved the performance (and formatting) around the !info command 93 | 94 | ## V1.0.1 95 | 96 | ### Bugfixes 97 | 98 | - Fixed admin add/rmv role being able to operate on role that matched the command executors highest role. 99 | - Fixed error triggered when removing debounces that where already partially-removed 100 | - Fixed add/remove role throwing a command error when attempting to execute the modlog portion of their code. 101 | - Fixed case where User.tempmute was called externally (e.g. by spam) for a guild without a mute role setup 102 | 103 | ## V1.0.0 104 | 105 | ### **BREAKING** Group Permissions Protection 106 | 107 | This update includes a change to the way admin-groups (aka joinable roles) work. When a user attempts to join a group now, rowboat will check and confirm the role does not give any unwanted permissions (e.g. _anything_ elevated). This check can not be skipped or disabled in the configuration. Groups are explicitly meant to give cosmetic or channel-based permissions to users, and should _never_ include elevated permissions. In the case that a group role somehow is created or gets permissions, this prevents any users from using Rowboat as an elevation attack. Combined with guild role locking, this should prevent almost all possible permission escalation attacks. 108 | 109 | ### Guild Role Locking 110 | 111 | This new feature allows Rowboat to lock-down a role, completely preventing/reverting updates to it. Roles can be unlocked by an administrator using the `!role unlock ` command, or by removing them from the config. The intention of this feature is to help locking down servers from permission escalation attacks. Role locking should be enabled for all roles that do not and should not change regularly, and for added protection you can disable the unlock command within your config. 112 | 113 | ```yaml 114 | plugins: 115 | admin: 116 | locked_roles: [ROLE_ID_HERE] 117 | ``` 118 | -------------------------------------------------------------------------------- /rowboat/plugins/internal.py: -------------------------------------------------------------------------------- 1 | import gevent 2 | from gevent.lock import Semaphore 3 | from datetime import datetime, timedelta 4 | 5 | from peewee import fn 6 | from disco.gateway.packets import OPCode, RECV 7 | from disco.types.message import MessageTable, MessageEmbed 8 | 9 | from rowboat.redis import rdb 10 | from rowboat.plugins import BasePlugin as Plugin 11 | from rowboat.util.redis import RedisSet 12 | from rowboat.models.event import Event 13 | from rowboat.models.user import User 14 | from rowboat.models.channel import Channel 15 | from rowboat.models.message import Command, Message 16 | 17 | 18 | class InternalPlugin(Plugin): 19 | global_plugin = True 20 | 21 | def load(self, ctx): 22 | super(InternalPlugin, self).load(ctx) 23 | 24 | self.events = RedisSet(rdb, 'internal:tracked-events') 25 | self.session_id = None 26 | self.lock = Semaphore() 27 | self.cache = [] 28 | 29 | @Plugin.command('errors', group='commands', level=-1) 30 | def on_commands_errors(self, event): 31 | q = Command.select().join( 32 | Message, on=(Command.message_id == Message.id) 33 | ).where( 34 | Command.success == 0 35 | ).order_by(Message.timestamp.desc()).limit(10) 36 | 37 | tbl = MessageTable() 38 | tbl.set_header('ID', 'Command', 'Error') 39 | 40 | for err in q: 41 | tbl.add(err.message_id, u'{}.{}'.format(err.plugin, err.command), err.traceback.split('\n')[-2]) 42 | 43 | event.msg.reply(tbl.compile()) 44 | 45 | @Plugin.command('info', '', group='commands', level=-1) 46 | def on_commands_info(self, event, mid): 47 | cmd = Command.select(Command, Message, Channel).join( 48 | Message, on=(Command.message_id == Message.id).alias('message') 49 | ).join( 50 | Channel, on=(Channel.channel_id == Message.channel_id).alias('channel') 51 | ).join( 52 | User, on=(User.user_id == Message.author_id).alias('author') 53 | ).where( 54 | Command.message_id == mid 55 | ).order_by( 56 | Message.timestamp.desc(), 57 | ).get() 58 | 59 | embed = MessageEmbed() 60 | embed.title = '{}.{} ({})'.format(cmd.plugin, cmd.command, cmd.message.id) 61 | embed.set_author(name=unicode(cmd.message.author), icon_url=cmd.message.author.get_avatar_url()) 62 | embed.color = 0x77dd77 if cmd.success else 0xff6961 63 | 64 | if not cmd.success: 65 | embed.description = u'```{}```'.format(cmd.traceback) 66 | 67 | embed.add_field(name='Message', value=cmd.message.content) 68 | embed.add_field(name='Channel', value=u'{} `{}`'.format(cmd.message.channel.name, cmd.message.channel.channel_id)) 69 | embed.add_field(name='Guild', value=unicode(cmd.message.guild_id)) 70 | event.msg.reply(embed=embed) 71 | 72 | @Plugin.command('usage', group='commands', level=-1) 73 | def on_commands_usage(self, event): 74 | q = Command.select( 75 | fn.COUNT('*'), 76 | Command.plugin, 77 | Command.command, 78 | ).group_by( 79 | Command.plugin, Command.command 80 | ).order_by(fn.COUNT('*').desc()).limit(25) 81 | 82 | tbl = MessageTable() 83 | tbl.set_header('Plugin', 'Command', 'Usage') 84 | 85 | for count, plugin, command in q.tuples(): 86 | tbl.add(plugin, command, count) 87 | 88 | event.msg.reply(tbl.compile()) 89 | 90 | @Plugin.command('stats', '', group='commands', level=-1) 91 | def on_commands_stats(self, event, name): 92 | if '.' in name: 93 | plugin, command = name.split('.', 1) 94 | q = ( 95 | (Command.plugin == plugin) & 96 | (Command.command == command) 97 | ) 98 | else: 99 | q = (Command.command == name) 100 | 101 | result = list(Command.select( 102 | fn.COUNT('*'), 103 | Command.success, 104 | ).where(q).group_by(Command.success).order_by(fn.COUNT('*').desc()).tuples()) 105 | 106 | success, error = 0, 0 107 | for count, check in result: 108 | if check: 109 | success = count 110 | else: 111 | error = count 112 | 113 | event.msg.reply('Command `{}` was used a total of {} times, {} of those had errors'.format( 114 | name, 115 | success + error, 116 | error 117 | )) 118 | 119 | @Plugin.command('throw', level=-1) 120 | def on_throw(self, event): 121 | raise Exception('Internal.throw') 122 | 123 | @Plugin.command('add', '', group='events', level=-1) 124 | def on_events_add(self, event, name): 125 | self.events.add(name) 126 | event.msg.reply(':ok_hand: added {} to the list of tracked events'.format(name)) 127 | 128 | @Plugin.command('remove', '', group='events', level=-1) 129 | def on_events_remove(self, event, name): 130 | self.events.remove(name) 131 | event.msg.reply(':ok_hand: removed {} from the list of tracked events'.format(name)) 132 | 133 | @Plugin.schedule(300, init=False) 134 | def prune_old_events(self): 135 | # Keep 24 hours of all events 136 | Event.delete().where( 137 | (Event.timestamp > datetime.utcnow() - timedelta(hours=24)) 138 | ).execute() 139 | 140 | @Plugin.listen('Ready') 141 | def on_ready(self, event): 142 | self.session_id = event.session_id 143 | gevent.spawn(self.flush_cache) 144 | 145 | @Plugin.listen_packet((RECV, OPCode.DISPATCH)) 146 | def on_gateway_event(self, event): 147 | if event['t'] not in self.events: 148 | return 149 | 150 | with self.lock: 151 | self.cache.append(event) 152 | 153 | def flush_cache(self): 154 | while True: 155 | gevent.sleep(1) 156 | 157 | if not len(self.cache): 158 | continue 159 | 160 | with self.lock: 161 | Event.insert_many(filter(bool, [ 162 | Event.prepare(self.session_id, event) for event in self.cache 163 | ])).execute() 164 | self.cache = [] 165 | -------------------------------------------------------------------------------- /rowboat/plugins/reddit.py: -------------------------------------------------------------------------------- 1 | import json 2 | import emoji 3 | import requests 4 | 5 | from collections import defaultdict 6 | 7 | from holster.enum import Enum 8 | from disco.types.message import MessageEmbed 9 | 10 | from rowboat.plugins import RowboatPlugin as Plugin 11 | from rowboat.redis import rdb 12 | from rowboat.models.guild import Guild 13 | from rowboat.types.plugin import PluginConfig 14 | from rowboat.types import SlottedModel, DictField, Field, ChannelField 15 | 16 | 17 | FormatMode = Enum( 18 | 'PLAIN', 19 | 'PRETTY' 20 | ) 21 | 22 | 23 | class SubRedditConfig(SlottedModel): 24 | channel = Field(ChannelField) 25 | mode = Field(FormatMode, default=FormatMode.PRETTY) 26 | nsfw = Field(bool, default=False) 27 | text_length = Field(int, default=256) 28 | include_stats = Field(bool, default=False) 29 | 30 | 31 | class RedditConfig(PluginConfig): 32 | # TODO: validate they have less than 3 reddits selected 33 | subs = DictField(str, SubRedditConfig) 34 | 35 | def validate(self): 36 | if len(self.subs) > 3: 37 | raise Exception('Cannot have more than 3 subreddits configured') 38 | 39 | # TODO: validate each subreddit 40 | 41 | 42 | @Plugin.with_config(RedditConfig) 43 | class RedditPlugin(Plugin): 44 | @Plugin.schedule(60, init=False) 45 | def check_subreddits(self): 46 | # TODO: sharding 47 | # TODO: filter in query 48 | subs_raw = list(Guild.select( 49 | Guild.guild_id, 50 | Guild.config['plugins']['reddit'] 51 | ).where( 52 | ~(Guild.config['plugins']['reddit'] >> None) 53 | ).tuples()) 54 | 55 | # Group all subreddits, iterate, update channels 56 | 57 | subs = defaultdict(list) 58 | 59 | for gid, config in subs_raw: 60 | config = json.loads(config) 61 | 62 | for k, v in config['subs'].items(): 63 | subs[k].append((gid, SubRedditConfig(v))) 64 | 65 | for sub, configs in subs.items(): 66 | try: 67 | self.update_subreddit(sub, configs) 68 | except requests.HTTPError: 69 | self.log.exception('Error loading sub %s:', sub) 70 | 71 | def get_channel(self, guild, ref): 72 | # CLEAN THIS UP TO A RESOLVER 73 | if isinstance(ref, (int, long)): 74 | return guild.channels.get(ref) 75 | else: 76 | return guild.channels.select_one(name=ref) 77 | 78 | def send_post(self, config, channel, data): 79 | if config.mode is FormatMode.PLAIN: 80 | channel.send_message('**{}**\n{}'.format( 81 | data['title'], 82 | 'https://reddit.com{}'.format(data['permalink']) 83 | )) 84 | else: 85 | embed = MessageEmbed() 86 | 87 | if 'nsfw' in data and data['nsfw']: 88 | if not config.nsfw: 89 | return 90 | embed.color = 0xff6961 91 | else: 92 | embed.color = 0xaecfc8 93 | 94 | # Limit title to 256 characters nicely 95 | if len(data['title']) > 256: 96 | embed.title = data['title'][:253] + '...' 97 | else: 98 | embed.title = data['title'] 99 | 100 | embed.url = u'https://reddit.com{}'.format(data['permalink']) 101 | embed.set_author( 102 | name=data['author'], 103 | url=u'https://reddit.com/u/{}'.format(data['author']) 104 | ) 105 | 106 | image = None 107 | 108 | if data.get('media'): 109 | if 'oembed' in data['media']: 110 | image = data['media']['oembed']['thumbnail_url'] 111 | elif data.get('preview'): 112 | if 'images' in data['preview']: 113 | image = data['preview']['images'][0]['source']['url'] 114 | 115 | if 'selftext' in data and data['selftext']: 116 | # TODO better place for validation 117 | sz = min(64, max(config.text_length, 1900)) 118 | embed.description = data['selftext'][:sz] 119 | if len(data['selftext']) > sz: 120 | embed.description += u'...' 121 | if image: 122 | embed.set_thumbnail(url=image) 123 | elif image: 124 | embed.set_image(url=image) 125 | 126 | if config.include_stats: 127 | embed.set_footer(text=emoji.emojize('{} upvotes | {} downvotes | {} comments'.format( 128 | data['ups'], data['downs'], data['num_comments'] 129 | ))) 130 | 131 | channel.send_message('', embed=embed) 132 | 133 | def update_subreddit(self, sub, configs): 134 | # TODO: use before on this request 135 | r = requests.get( 136 | 'https://www.reddit.com/r/{}/new.json'.format(sub), 137 | headers={ 138 | 'User-Agent': 'discord/Jetski v1.0 (by /u/ThaTiemsz)' 139 | } 140 | ) 141 | r.raise_for_status() 142 | 143 | data = list(reversed(map(lambda i: i['data'], r.json()['data']['children']))) 144 | 145 | # TODO: 146 | # 1. instead of tracking per guild, just track globally per subreddit 147 | # 2. fan-out posts to each subscribed channel 148 | 149 | for gid, config in configs: 150 | guild = self.state.guilds.get(gid) 151 | if not guild: 152 | self.log.warning('Skipping non existant guild %s', gid) 153 | continue 154 | 155 | channel = self.get_channel(guild, config.channel) 156 | if not channel: 157 | self.log.warning('Skipping non existant channel %s for guild %s (%s)', channel, guild.name, gid) 158 | continue 159 | last = float(rdb.get('rdt:lpid:{}:{}'.format(channel.id, sub)) or 0) 160 | 161 | item_count, high_time = 0, last 162 | for item in data: 163 | if item['created_utc'] > last: 164 | try: 165 | self.send_post(config, channel, item) 166 | except: 167 | self.log.exception('Failed to post reddit content from %s\n\n', item) 168 | item_count += 1 169 | 170 | if item['created_utc'] > high_time: 171 | rdb.set('rdt:lpid:{}:{}'.format(channel.id, sub), item['created_utc']) 172 | high_time = item['created_utc'] 173 | 174 | if item_count > 10: 175 | break 176 | -------------------------------------------------------------------------------- /frontend/src/components/guild_config_edit.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import AceEditor, { diff as DiffEditor } from 'react-ace'; 3 | import { globalState } from '../state'; 4 | import { NavLink } from 'react-router-dom'; 5 | import moment from 'moment-timezone'; 6 | 7 | import 'brace/mode/yaml' 8 | import 'brace/theme/monokai' 9 | 10 | class ConfigHistory extends Component { 11 | render() { 12 | let buttonsList = []; 13 | 14 | if (this.props.history) { 15 | const tz = moment.tz.guess(); 16 | for (let change of this.props.history) { 17 | buttonsList.push( 18 | 19 | {change.user.username}#{change.user.discriminator} 20 | {moment(new Date(change.created_timestamp*1000)).utc(tz).fromNow()} 21 | 22 | ); 23 | } 24 | } 25 | 26 | return ( 27 |
    28 |
    29 |
    30 | History 31 |
    32 |
    33 |
    34 | 35 | Current version 36 | 37 | {this.props.history && buttonsList} 38 |
    39 |
    40 |
    41 |
    42 | ); 43 | } 44 | } 45 | 46 | export default class GuildConfigEdit extends Component { 47 | constructor() { 48 | super(); 49 | 50 | this.messageTimer = null; 51 | this.initialConfig = null; 52 | 53 | this.state = { 54 | message: null, 55 | guild: null, 56 | contents: null, 57 | hasUnsavedChanges: false, 58 | history: null, 59 | } 60 | } 61 | 62 | componentWillMount() { 63 | globalState.getGuild(this.props.params.gid).then((guild) => { 64 | globalState.currentGuild = guild; 65 | 66 | guild.getConfig(true) 67 | .then((config) => { 68 | this.initialConfig = config.contents; 69 | this.setState({ 70 | guild: guild, 71 | contents: config.contents, 72 | }); 73 | return guild.id 74 | }) 75 | .then(guild.getConfigHistory) 76 | .then((history) => { 77 | this.setState({ 78 | history: history 79 | }); 80 | }); 81 | }).catch((err) => { 82 | console.error('Failed to find guild for config edit', this.props.params.gid); 83 | }); 84 | } 85 | 86 | componentWillUnmount() { 87 | globalState.currentGuild = null; 88 | } 89 | 90 | onEditorChange(newValue) { 91 | let newState = {contents: newValue, hasUnsavedChanges: false}; 92 | if (this.initialConfig != newValue) { 93 | newState.hasUnsavedChanges = true; 94 | } 95 | this.setState(newState); 96 | } 97 | 98 | onSave() { 99 | this.state.guild.putConfig(this.state.contents).then(() => { 100 | this.initialConfig = this.state.contents; 101 | this.setState({ 102 | hasUnsavedChanges: false, 103 | }); 104 | this.renderMessage('success', 'Saved Configuration!'); 105 | }).catch((err) => { 106 | this.renderMessage('danger', `Failed to save configuration: ${err}`); 107 | }); 108 | } 109 | 110 | renderMessage(type, contents) { 111 | this.setState({ 112 | message: { 113 | type: type, 114 | contents: contents, 115 | } 116 | }) 117 | 118 | if (this.messageTimer) clearTimeout(this.messageTimer); 119 | 120 | this.messageTimer = setTimeout(() => { 121 | this.setState({ 122 | message: null, 123 | }); 124 | this.messageTimer = null; 125 | }, 5000); 126 | } 127 | 128 | render() { 129 | let history; 130 | if (this.props.params.timestamp) { 131 | history = this.state.history ? this.state.history.find(c => c.created_timestamp == this.props.params.timestamp) : null 132 | } 133 | 134 | return (
    135 | {this.state.message &&
    {this.state.message.contents}
    } 136 |
    137 |
    138 |
    139 |
    140 | Configuration Editor 141 |
    142 |
    143 | {this.state.history && this.props.params.timestamp && this.state.history.find(c => c.created_timestamp == this.props.params.timestamp) ? ( 144 | 152 | ) : ( 153 | this.onEditorChange(newValue)} 160 | readOnly={this.state.guild && this.state.guild.role != 'viewer' ? false : true} 161 | wrapEnabled={true} 162 | tabSize={2} 163 | /> 164 | )} 165 |
    166 |
    167 | { 168 | this.state.guild && !this.props.params.timestamp && this.state.guild.role != 'viewer' && 169 | 172 | } 173 | { this.state.hasUnsavedChanges && Unsaved Changes!} 174 |
    175 |
    176 |
    177 | {this.state.guild && this.state.history && } 178 |
    179 |
    ); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /rowboat/views/guilds.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | import json 3 | import functools 4 | import operator 5 | 6 | from flask import Blueprint, request, g, jsonify 7 | 8 | from rowboat.util.decos import authed 9 | from rowboat.models.guild import Guild, GuildConfigChange 10 | from rowboat.models.user import User, Infraction 11 | from rowboat.models.message import Message 12 | 13 | guilds = Blueprint('guilds', __name__, url_prefix='/api/guilds') 14 | 15 | 16 | def serialize_user(u): 17 | return { 18 | 'user_id': str(u.user_id), 19 | 'username': u.username, 20 | 'discriminator': u.discriminator, 21 | } 22 | 23 | 24 | def with_guild(f=None): 25 | def deco(f): 26 | @authed 27 | @functools.wraps(f) 28 | def func(*args, **kwargs): 29 | try: 30 | if g.user.admin: 31 | guild = Guild.select().where(Guild.guild_id == kwargs.pop('gid')).get() 32 | guild.role = 'admin' 33 | else: 34 | guild = Guild.select( 35 | Guild, 36 | Guild.config['web'][str(g.user.user_id)].alias('role') 37 | ).where( 38 | (Guild.guild_id == kwargs.pop('gid')) & 39 | (~(Guild.config['web'][str(g.user.user_id)] >> None)) 40 | ).get() 41 | 42 | return f(guild, *args, **kwargs) 43 | except Guild.DoesNotExist: 44 | return 'Invalid Guild', 404 45 | return func 46 | 47 | if f and callable(f): 48 | return deco(f) 49 | 50 | return deco 51 | 52 | 53 | @guilds.route('/') 54 | @with_guild 55 | def guild_get(guild): 56 | return jsonify(guild.serialize()) 57 | 58 | 59 | @guilds.route('/', methods=['DELETE']) 60 | @with_guild 61 | def guild_delete(guild): 62 | if not g.user.admin: 63 | return '', 401 64 | 65 | guild.emit('GUILD_DELETE') 66 | return '', 204 67 | 68 | 69 | 70 | @guilds.route('//config') 71 | @with_guild 72 | def guild_config(guild): 73 | return jsonify({ 74 | 'contents': unicode(guild.config_raw) if guild.config_raw else yaml.safe_dump(guild.config), 75 | }) 76 | 77 | 78 | @guilds.route('//config', methods=['POST']) 79 | @with_guild 80 | def guild_z_config_update(guild): 81 | if guild.role not in ['admin', 'editor']: 82 | return 'Missing Permissions', 403 83 | 84 | # Calculate users diff 85 | try: 86 | data = yaml.safe_load(request.json['config']) 87 | except: 88 | return 'Invalid YAML', 400 89 | 90 | before = sorted(guild.config.get('web', {}).items(), key=lambda i: i[0]) 91 | after = sorted([(str(k), v) for k, v in data.get('web', {}).items()], key=lambda i: i[0]) 92 | 93 | if guild.role != 'admin' and before != after: 94 | return 'Invalid Access', 403 95 | 96 | role = data.get('web', {}).get(g.user.user_id) or data.get('web', {}).get(str(g.user.user_id)) 97 | if guild.role != role and not g.user.admin: 98 | print g.user.admin 99 | return 'Cannot change your own permissions', 400 100 | 101 | try: 102 | guild.update_config(g.user.user_id, request.json['config']) 103 | return '', 200 104 | except Guild.DoesNotExist: 105 | return 'Invalid Guild', 404 106 | except Exception as e: 107 | return 'Invalid Data: %s' % e, 400 108 | 109 | 110 | CAN_FILTER = ['id', 'user_id', 'actor_id', 'type', 'reason'] 111 | CAN_SORT = ['id', 'user_id', 'actor_id', 'created_at', 'expires_at', 'type'] 112 | 113 | 114 | @guilds.route('//infractions') 115 | @with_guild 116 | def guild_infractions(guild): 117 | user = User.alias() 118 | actor = User.alias() 119 | 120 | page = int(request.values.get('page', 1)) 121 | if page < 1: 122 | page = 1 123 | 124 | limit = int(request.values.get('limit', 1000)) 125 | if limit < 1 or limit > 1000: 126 | limit = 1000 127 | 128 | q = Infraction.select(Infraction, user, actor).join( 129 | user, 130 | on=((Infraction.user_id == user.user_id).alias('user')) 131 | ).switch(Infraction).join( 132 | actor, 133 | on=((Infraction.actor_id == actor.user_id).alias('actor')) 134 | ) 135 | 136 | queries = [] 137 | if 'filtered' in request.values: 138 | filters = json.loads(request.values['filtered']) 139 | 140 | for f in filters: 141 | if f['id'] not in CAN_FILTER: 142 | continue 143 | 144 | if f['id'] == 'type': 145 | queries.append(Infraction.type_ == Infraction.Types.get(f['value'])) 146 | elif f['id'] == 'reason': 147 | queries.append(Infraction.reason ** ('%' + f['value'].lower().replace('%', '') + '%')) 148 | else: 149 | queries.append(getattr(Infraction, f['id']) == f['value']) 150 | 151 | if queries: 152 | q = q.where( 153 | (Infraction.guild_id == guild.guild_id) & 154 | reduce(operator.and_, queries) 155 | ) 156 | else: 157 | q = q.where((Infraction.guild_id == guild.guild_id)) 158 | 159 | sorted_fields = [] 160 | if 'sorted' in request.values: 161 | sort = json.loads(request.values['sorted']) 162 | 163 | for s in sort: 164 | if s['id'] not in CAN_SORT: 165 | continue 166 | 167 | if s['desc']: 168 | sorted_fields.append( 169 | getattr(Infraction, s['id']).desc() 170 | ) 171 | else: 172 | sorted_fields.append( 173 | getattr(Infraction, s['id']) 174 | ) 175 | 176 | if sorted_fields: 177 | q = q.order_by(*sorted_fields) 178 | else: 179 | q = q.order_by(Infraction.id.desc()) 180 | 181 | q = q.paginate( 182 | page, 183 | limit, 184 | ) 185 | 186 | return jsonify([i.serialize(guild=guild, user=i.user, actor=i.actor) for i in q]) 187 | 188 | 189 | @guilds.route('//config/history') 190 | @with_guild 191 | def guild_config_history(guild): 192 | def serialize(gcc): 193 | return { 194 | 'user': serialize_user(gcc.user_id), 195 | 'before': str(gcc.before_raw).decode("latin-1"), 196 | 'after': str(gcc.after_raw).decode("latin-1"), 197 | 'created_at': gcc.created_at.isoformat(), 198 | } 199 | 200 | q = GuildConfigChange.select(GuildConfigChange, User).join( 201 | User, on=(User.user_id == GuildConfigChange.user_id), 202 | ).where(GuildConfigChange.guild_id == guild.guild_id).order_by( 203 | GuildConfigChange.created_at.desc() 204 | ).paginate(int(request.values.get('page', 1)), 25) 205 | 206 | return jsonify(map(serialize, q)) 207 | 208 | 209 | @guilds.route('//stats/messages', methods=['GET']) 210 | @with_guild() 211 | def guild_stats_messages(guild): 212 | unit = request.values.get('unit', 'days') 213 | amount = int(request.values.get('amount', 7)) 214 | 215 | sql = ''' 216 | SELECT date, coalesce(count, 0) AS count 217 | FROM 218 | generate_series( 219 | NOW() - interval %s, 220 | NOW(), 221 | %s 222 | ) AS date 223 | LEFT OUTER JOIN ( 224 | SELECT date_trunc(%s, timestamp) AS dt, count(*) AS count 225 | FROM messages 226 | WHERE 227 | timestamp >= (NOW() - interval %s) AND 228 | timestamp < (NOW()) AND 229 | guild_id=%s AND 230 | GROUP BY dt 231 | ) results 232 | ON (date_trunc(%s, date) = results.dt); 233 | ''' 234 | 235 | tuples = list(Message.raw( 236 | sql, 237 | '{} {}'.format(amount, unit), 238 | '1 {}'.format(unit), 239 | unit, 240 | '{} {}'.format(amount, unit), 241 | guild.guild_id, 242 | unit 243 | ).tuples()) 244 | 245 | return jsonify(tuples) 246 | -------------------------------------------------------------------------------- /frontend/src/static/css/dataTables.bootstrap.css: -------------------------------------------------------------------------------- 1 | div.dataTables_length label { 2 | font-weight: normal; 3 | text-align: left; 4 | white-space: nowrap; 5 | } 6 | 7 | div.dataTables_length select { 8 | width: 75px; 9 | display: inline-block; 10 | } 11 | 12 | div.dataTables_filter { 13 | text-align: right; 14 | } 15 | 16 | div.dataTables_filter label { 17 | font-weight: normal; 18 | white-space: nowrap; 19 | text-align: left; 20 | } 21 | 22 | div.dataTables_filter input { 23 | margin-left: 0.5em; 24 | display: inline-block; 25 | } 26 | 27 | div.dataTables_info { 28 | padding-top: 8px; 29 | white-space: nowrap; 30 | } 31 | 32 | div.dataTables_paginate { 33 | margin: 0; 34 | white-space: nowrap; 35 | text-align: right; 36 | } 37 | 38 | div.dataTables_paginate ul.pagination { 39 | margin: 2px 0; 40 | white-space: nowrap; 41 | } 42 | 43 | @media screen and (max-width: 767px) { 44 | div.dataTables_length, 45 | div.dataTables_filter, 46 | div.dataTables_info, 47 | div.dataTables_paginate { 48 | text-align: center; 49 | } 50 | } 51 | 52 | 53 | table.dataTable td, 54 | table.dataTable th { 55 | -webkit-box-sizing: content-box; 56 | -moz-box-sizing: content-box; 57 | box-sizing: content-box; 58 | } 59 | 60 | 61 | table.dataTable { 62 | clear: both; 63 | margin-top: 6px !important; 64 | margin-bottom: 6px !important; 65 | max-width: none !important; 66 | } 67 | 68 | table.dataTable thead .sorting, 69 | table.dataTable thead .sorting_asc, 70 | table.dataTable thead .sorting_desc, 71 | table.dataTable thead .sorting_asc_disabled, 72 | table.dataTable thead .sorting_desc_disabled { 73 | cursor: pointer; 74 | } 75 | 76 | table.dataTable thead .sorting { background: url('../images/sort_both.png') no-repeat center right; } 77 | table.dataTable thead .sorting_asc { background: url('../images/sort_asc.png') no-repeat center right; } 78 | table.dataTable thead .sorting_desc { background: url('../images/sort_desc.png') no-repeat center right; } 79 | 80 | table.dataTable thead .sorting_asc_disabled { background: url('../images/sort_asc_disabled.png') no-repeat center right; } 81 | table.dataTable thead .sorting_desc_disabled { background: url('../images/sort_desc_disabled.png') no-repeat center right; } 82 | 83 | table.dataTable thead > tr > th { 84 | padding-left: 18px; 85 | padding-right: 18px; 86 | } 87 | 88 | table.dataTable th:active { 89 | outline: none; 90 | } 91 | 92 | /* Scrolling */ 93 | div.dataTables_scrollHead table { 94 | margin-bottom: 0 !important; 95 | border-bottom-left-radius: 0; 96 | border-bottom-right-radius: 0; 97 | } 98 | 99 | div.dataTables_scrollHead table thead tr:last-child th:first-child, 100 | div.dataTables_scrollHead table thead tr:last-child td:first-child { 101 | border-bottom-left-radius: 0 !important; 102 | border-bottom-right-radius: 0 !important; 103 | } 104 | 105 | div.dataTables_scrollBody table { 106 | border-top: none; 107 | margin-top: 0 !important; 108 | margin-bottom: 0 !important; 109 | } 110 | 111 | div.dataTables_scrollBody tbody tr:first-child th, 112 | div.dataTables_scrollBody tbody tr:first-child td { 113 | border-top: none; 114 | } 115 | 116 | div.dataTables_scrollFoot table { 117 | margin-top: 0 !important; 118 | border-top: none; 119 | } 120 | 121 | /* Frustratingly the border-collapse:collapse used by Bootstrap makes the column 122 | width calculations when using scrolling impossible to align columns. We have 123 | to use separate 124 | */ 125 | table.table-bordered.dataTable { 126 | border-collapse: separate !important; 127 | } 128 | table.table-bordered thead th, 129 | table.table-bordered thead td { 130 | border-left-width: 0; 131 | border-top-width: 0; 132 | } 133 | table.table-bordered tbody th, 134 | table.table-bordered tbody td { 135 | border-left-width: 0; 136 | border-bottom-width: 0; 137 | } 138 | table.table-bordered th:last-child, 139 | table.table-bordered td:last-child { 140 | border-right-width: 0; 141 | } 142 | div.dataTables_scrollHead table.table-bordered { 143 | border-bottom-width: 0; 144 | } 145 | 146 | 147 | 148 | 149 | /* 150 | * TableTools styles 151 | */ 152 | .table.dataTable tbody tr.active td, 153 | .table.dataTable tbody tr.active th { 154 | background-color: #08C; 155 | color: white; 156 | } 157 | 158 | .table.dataTable tbody tr.active:hover td, 159 | .table.dataTable tbody tr.active:hover th { 160 | background-color: #0075b0 !important; 161 | } 162 | 163 | .table.dataTable tbody tr.active th > a, 164 | .table.dataTable tbody tr.active td > a { 165 | color: white; 166 | } 167 | 168 | .table-striped.dataTable tbody tr.active:nth-child(odd) td, 169 | .table-striped.dataTable tbody tr.active:nth-child(odd) th { 170 | background-color: #017ebc; 171 | } 172 | 173 | table.DTTT_selectable tbody tr { 174 | cursor: pointer; 175 | } 176 | 177 | div.DTTT .btn:hover { 178 | text-decoration: none !important; 179 | } 180 | 181 | ul.DTTT_dropdown.dropdown-menu { 182 | z-index: 2003; 183 | } 184 | 185 | ul.DTTT_dropdown.dropdown-menu a { 186 | color: #333 !important; /* needed only when demo_page.css is included */ 187 | } 188 | 189 | ul.DTTT_dropdown.dropdown-menu li { 190 | position: relative; 191 | } 192 | 193 | ul.DTTT_dropdown.dropdown-menu li:hover a { 194 | background-color: #0088cc; 195 | color: white !important; 196 | } 197 | 198 | div.DTTT_collection_background { 199 | z-index: 2002; 200 | } 201 | 202 | /* TableTools information display */ 203 | div.DTTT_print_info { 204 | position: fixed; 205 | top: 50%; 206 | left: 50%; 207 | width: 400px; 208 | height: 150px; 209 | margin-left: -200px; 210 | margin-top: -75px; 211 | text-align: center; 212 | color: #333; 213 | padding: 10px 30px; 214 | opacity: 0.95; 215 | 216 | background-color: white; 217 | border: 1px solid rgba(0, 0, 0, 0.2); 218 | border-radius: 6px; 219 | 220 | -webkit-box-shadow: 0 3px 7px rgba(0, 0, 0, 0.5); 221 | box-shadow: 0 3px 7px rgba(0, 0, 0, 0.5); 222 | } 223 | 224 | div.DTTT_print_info h6 { 225 | font-weight: normal; 226 | font-size: 28px; 227 | line-height: 28px; 228 | margin: 1em; 229 | } 230 | 231 | div.DTTT_print_info p { 232 | font-size: 14px; 233 | line-height: 20px; 234 | } 235 | 236 | div.dataTables_processing { 237 | position: absolute; 238 | top: 50%; 239 | left: 50%; 240 | width: 100%; 241 | height: 60px; 242 | margin-left: -50%; 243 | margin-top: -25px; 244 | padding-top: 20px; 245 | padding-bottom: 20px; 246 | text-align: center; 247 | font-size: 1.2em; 248 | background-color: white; 249 | background: -webkit-gradient(linear, left top, right top, color-stop(0%, rgba(255,255,255,0)), color-stop(25%, rgba(255,255,255,0.9)), color-stop(75%, rgba(255,255,255,0.9)), color-stop(100%, rgba(255,255,255,0))); 250 | background: -webkit-linear-gradient(left, rgba(255,255,255,0) 0%, rgba(255,255,255,0.9) 25%, rgba(255,255,255,0.9) 75%, rgba(255,255,255,0) 100%); 251 | background: -moz-linear-gradient(left, rgba(255,255,255,0) 0%, rgba(255,255,255,0.9) 25%, rgba(255,255,255,0.9) 75%, rgba(255,255,255,0) 100%); 252 | background: -ms-linear-gradient(left, rgba(255,255,255,0) 0%, rgba(255,255,255,0.9) 25%, rgba(255,255,255,0.9) 75%, rgba(255,255,255,0) 100%); 253 | background: -o-linear-gradient(left, rgba(255,255,255,0) 0%, rgba(255,255,255,0.9) 25%, rgba(255,255,255,0.9) 75%, rgba(255,255,255,0) 100%); 254 | background: linear-gradient(to right, rgba(255,255,255,0) 0%, rgba(255,255,255,0.9) 25%, rgba(255,255,255,0.9) 75%, rgba(255,255,255,0) 100%); 255 | } 256 | 257 | 258 | 259 | /* 260 | * FixedColumns styles 261 | */ 262 | div.DTFC_LeftHeadWrapper table, 263 | div.DTFC_LeftFootWrapper table, 264 | div.DTFC_RightHeadWrapper table, 265 | div.DTFC_RightFootWrapper table, 266 | table.DTFC_Cloned tr.even { 267 | background-color: white; 268 | margin-bottom: 0; 269 | } 270 | 271 | div.DTFC_RightHeadWrapper table , 272 | div.DTFC_LeftHeadWrapper table { 273 | border-bottom: none !important; 274 | margin-bottom: 0 !important; 275 | border-top-right-radius: 0 !important; 276 | border-bottom-left-radius: 0 !important; 277 | border-bottom-right-radius: 0 !important; 278 | } 279 | 280 | div.DTFC_RightHeadWrapper table thead tr:last-child th:first-child, 281 | div.DTFC_RightHeadWrapper table thead tr:last-child td:first-child, 282 | div.DTFC_LeftHeadWrapper table thead tr:last-child th:first-child, 283 | div.DTFC_LeftHeadWrapper table thead tr:last-child td:first-child { 284 | border-bottom-left-radius: 0 !important; 285 | border-bottom-right-radius: 0 !important; 286 | } 287 | 288 | div.DTFC_RightBodyWrapper table, 289 | div.DTFC_LeftBodyWrapper table { 290 | border-top: none; 291 | margin: 0 !important; 292 | } 293 | 294 | div.DTFC_RightBodyWrapper tbody tr:first-child th, 295 | div.DTFC_RightBodyWrapper tbody tr:first-child td, 296 | div.DTFC_LeftBodyWrapper tbody tr:first-child th, 297 | div.DTFC_LeftBodyWrapper tbody tr:first-child td { 298 | border-top: none; 299 | } 300 | 301 | div.DTFC_RightFootWrapper table, 302 | div.DTFC_LeftFootWrapper table { 303 | border-top: none; 304 | margin-top: 0 !important; 305 | } 306 | 307 | 308 | /* 309 | * FixedHeader styles 310 | */ 311 | div.FixedHeader_Cloned table { 312 | margin: 0 !important 313 | } 314 | 315 | -------------------------------------------------------------------------------- /rowboat/plugins/censor.py: -------------------------------------------------------------------------------- 1 | import re 2 | import json 3 | import urlparse 4 | 5 | from holster.enum import Enum 6 | from unidecode import unidecode 7 | from disco.types.base import cached_property 8 | from disco.types.channel import ChannelType 9 | from disco.util.sanitize import S 10 | from disco.api.http import APIException 11 | 12 | from rowboat.redis import rdb 13 | from rowboat.util.stats import timed 14 | from rowboat.util.zalgo import ZALGO_RE 15 | from rowboat.plugins import RowboatPlugin as Plugin 16 | from rowboat.types import SlottedModel, Field, ListField, DictField, ChannelField, snowflake, lower 17 | from rowboat.types.plugin import PluginConfig 18 | from rowboat.models.message import Message 19 | from rowboat.plugins.modlog import Actions 20 | from rowboat.constants import INVITE_LINK_RE, URL_RE 21 | 22 | CensorReason = Enum( 23 | 'INVITE', 24 | 'DOMAIN', 25 | 'WORD', 26 | 'ZALGO', 27 | ) 28 | 29 | 30 | class CensorSubConfig(SlottedModel): 31 | filter_zalgo = Field(bool, default=True) 32 | 33 | filter_invites = Field(bool, default=True) 34 | invites_guild_whitelist = ListField(snowflake, default=[]) 35 | invites_whitelist = ListField(lower, default=[]) 36 | invites_blacklist = ListField(lower, default=[]) 37 | 38 | filter_domains = Field(bool, default=True) 39 | domains_whitelist = ListField(lower, default=[]) 40 | domains_blacklist = ListField(lower, default=[]) 41 | 42 | blocked_words = ListField(lower, default=[]) 43 | blocked_tokens = ListField(lower, default=[]) 44 | unidecode_tokens = Field(bool, default=False) 45 | 46 | channel = Field(snowflake, default=None) 47 | bypass_channel = Field(snowflake, default=None) 48 | 49 | @cached_property 50 | def blocked_re(self): 51 | return re.compile(u'({})'.format(u'|'.join( 52 | map(re.escape, self.blocked_tokens) + 53 | map(lambda k: u'\\b{}\\b'.format(re.escape(k)), self.blocked_words) 54 | )), re.I + re.U) 55 | 56 | 57 | class CensorConfig(PluginConfig): 58 | levels = DictField(int, CensorSubConfig) 59 | channels = DictField(ChannelField, CensorSubConfig) 60 | 61 | 62 | # It's bad kids! 63 | class Censorship(Exception): 64 | def __init__(self, reason, event, ctx): 65 | self.reason = reason 66 | self.event = event 67 | self.ctx = ctx 68 | self.content = S(event.content, escape_codeblocks=True) 69 | 70 | @property 71 | def details(self): 72 | if self.reason is CensorReason.INVITE: 73 | if self.ctx['guild']: 74 | return u'invite `{}` to {}'.format( 75 | self.ctx['invite'], 76 | S(self.ctx['guild']['name'], escape_codeblocks=True) 77 | ) 78 | else: 79 | return u'invite `{}`'.format(self.ctx['invite']) 80 | elif self.reason is CensorReason.DOMAIN: 81 | if self.ctx['hit'] == 'whitelist': 82 | return u'domain `{}` is not in whitelist'.format(S(self.ctx['domain'], escape_codeblocks=True)) 83 | else: 84 | return u'domain `{}` is in blacklist'.format(S(self.ctx['domain'], escape_codeblocks=True)) 85 | elif self.reason is CensorReason.WORD: 86 | return u'found blacklisted words `{}`'.format( 87 | u', '.join([S(i, escape_codeblocks=True) for i in self.ctx['words']])) 88 | elif self.reason is CensorReason.ZALGO: 89 | return u'found zalgo at position `{}` in text'.format( 90 | self.ctx['position'] 91 | ) 92 | 93 | 94 | @Plugin.with_config(CensorConfig) 95 | class CensorPlugin(Plugin): 96 | def compute_relevant_configs(self, event, author): 97 | if event.channel_id in event.config.channels: 98 | yield event.config.channels[event.channel.id] 99 | 100 | if event.config.levels: 101 | user_level = int(self.bot.plugins.get('CorePlugin').get_level(event.guild, author)) 102 | 103 | for level, config in event.config.levels.items(): 104 | if user_level <= level: 105 | yield config 106 | 107 | def get_invite_info(self, code): 108 | if rdb.exists('inv:{}'.format(code)): 109 | return json.loads(rdb.get('inv:{}'.format(code))) 110 | 111 | try: 112 | obj = self.client.api.invites_get(code) 113 | except: 114 | return 115 | 116 | if obj.channel and obj.channel.type == ChannelType.GROUP_DM: 117 | obj = { 118 | 'id': obj.channel.id, 119 | 'name': obj.channel.name 120 | } 121 | else: 122 | obj = { 123 | 'id': obj.guild.id, 124 | 'name': obj.guild.name, 125 | 'icon': obj.guild.icon 126 | } 127 | 128 | # Cache for 12 hours 129 | rdb.setex('inv:{}'.format(code), json.dumps(obj), 43200) 130 | return obj 131 | 132 | @Plugin.listen('MessageUpdate') 133 | def on_message_update(self, event): 134 | try: 135 | msg = Message.get(id=event.id) 136 | except Message.DoesNotExist: 137 | self.log.warning('Not censoring MessageUpdate for id %s, %s, no stored message', event.channel_id, event.id) 138 | return 139 | 140 | if not event.content: 141 | return 142 | 143 | return self.on_message_create( 144 | event, 145 | author=event.guild.get_member(msg.author_id)) 146 | 147 | @Plugin.listen('MessageCreate') 148 | def on_message_create(self, event, author=None): 149 | author = author or event.author 150 | 151 | if author.id == self.state.me.id: 152 | return 153 | 154 | if event.webhook_id: 155 | return 156 | 157 | configs = list(self.compute_relevant_configs(event, author)) 158 | if not configs: 159 | return 160 | 161 | tags = {'guild_id': event.guild.id, 'channel_id': event.channel.id} 162 | with timed('rowboat.plugin.censor.duration', tags=tags): 163 | try: 164 | # TODO: perhaps imap here? how to raise exception then? 165 | for config in configs: 166 | if config.channel: 167 | if event.channel_id != config.channel: 168 | continue 169 | if config.bypass_channel: 170 | if event.channel_id == config.bypass_channel: 171 | continue 172 | 173 | if config.filter_zalgo: 174 | self.filter_zalgo(event, config) 175 | 176 | if config.filter_invites: 177 | self.filter_invites(event, config) 178 | 179 | if config.filter_domains: 180 | self.filter_domains(event, config) 181 | 182 | if config.blocked_words or config.blocked_tokens: 183 | self.filter_blocked_words(event, config) 184 | except Censorship as c: 185 | self.call( 186 | 'ModLogPlugin.create_debounce', 187 | event, 188 | ['MessageDelete'], 189 | message_id=event.message.id, 190 | ) 191 | 192 | try: 193 | event.delete() 194 | 195 | self.call( 196 | 'ModLogPlugin.log_action_ext', 197 | Actions.CENSORED, 198 | event.guild.id, 199 | e=event, 200 | c=c) 201 | except APIException: 202 | self.log.exception('Failed to delete censored message: ') 203 | 204 | def filter_zalgo(self, event, config): 205 | s = ZALGO_RE.search(event.content) 206 | if s: 207 | raise Censorship(CensorReason.ZALGO, event, ctx={ 208 | 'position': s.start() 209 | }) 210 | 211 | def filter_invites(self, event, config): 212 | invites = INVITE_LINK_RE.findall(event.content) 213 | 214 | for _, invite in invites: 215 | invite_info = self.get_invite_info(invite) 216 | 217 | need_whitelist = ( 218 | config.invites_guild_whitelist or 219 | (config.invites_whitelist or not config.invites_blacklist) 220 | ) 221 | whitelisted = False 222 | 223 | if invite_info and invite_info.get('id') in config.invites_guild_whitelist: 224 | whitelisted = True 225 | 226 | if invite.lower() in config.invites_whitelist: 227 | whitelisted = True 228 | 229 | if need_whitelist and not whitelisted: 230 | raise Censorship(CensorReason.INVITE, event, ctx={ 231 | 'hit': 'whitelist', 232 | 'invite': invite, 233 | 'guild': invite_info, 234 | }) 235 | elif config.invites_blacklist and invite.lower() in config.invites_blacklist: 236 | raise Censorship(CensorReason.INVITE, event, ctx={ 237 | 'hit': 'blacklist', 238 | 'invite': invite, 239 | 'guild': invite_info, 240 | }) 241 | 242 | def filter_domains(self, event, config): 243 | urls = URL_RE.findall(INVITE_LINK_RE.sub('', event.content)) 244 | 245 | for url in urls: 246 | try: 247 | parsed = urlparse.urlparse(url) 248 | except: 249 | continue 250 | 251 | if (config.domains_whitelist or not config.domains_blacklist)\ 252 | and parsed.netloc.lower() not in config.domains_whitelist: 253 | raise Censorship(CensorReason.DOMAIN, event, ctx={ 254 | 'hit': 'whitelist', 255 | 'url': url, 256 | 'domain': parsed.netloc, 257 | }) 258 | elif config.domains_blacklist and parsed.netloc.lower() in config.domains_blacklist: 259 | raise Censorship(CensorReason.DOMAIN, event, ctx={ 260 | 'hit': 'blacklist', 261 | 'url': url, 262 | 'domain': parsed.netloc 263 | }) 264 | 265 | def filter_blocked_words(self, event, config): 266 | content = event.content 267 | if config.unidecode_tokens: 268 | content = unidecode(content) 269 | blocked_words = config.blocked_re.findall(content) 270 | 271 | if blocked_words: 272 | raise Censorship(CensorReason.WORD, event, ctx={ 273 | 'words': blocked_words, 274 | }) 275 | --------------------------------------------------------------------------------