├── 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 |
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 |
--------------------------------------------------------------------------------
/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 |
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 |
--------------------------------------------------------------------------------
/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 | | ID |
85 | Name |
86 | Actions |
87 |
88 |
89 |
90 | {rows}
91 |
92 |
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 (
);
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
;
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
;
28 | } else {
29 | return No Splash;
30 | }
31 | }
32 | }
33 |
34 | class GuildOverviewInfoTable extends Component {
35 | render() {
36 | return (
37 |
38 |
39 |
40 |
41 | | ID |
42 | {this.props.guild.id} |
43 |
44 |
45 | | Owner |
46 | {this.props.guild.ownerID} |
47 |
48 |
49 | | Region |
50 | {this.props.guild.region} |
51 |
52 |
53 | | Icon |
54 | |
55 |
56 |
57 | | Splash |
58 | |
59 |
60 |
61 |
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 |
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 |
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 ();
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 |
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 | | ID |
86 | Name |
87 | Actions |
88 |
89 |
90 |
91 | {% for guild in guilds %}
92 |
93 | | {{ guild.guild_id }} |
94 | {{ guild.name }} |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 | {% if g.user.admin %}
106 |
107 |
108 |
109 | {% endif %}
110 | |
111 |
112 | {% endfor %}
113 |
114 |
115 |
116 |
117 |
118 |
119 | {% if g.user.admin %}
120 |
121 |
122 |
123 | Control Panel
124 |
125 |
126 |
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 | | ID |
21 | {inf.id} |
22 |
23 |
24 | | Target User |
25 | {inf.user.username}#{String(inf.user.discriminator).padStart(4, "0")} ({inf.user.id}) |
26 |
27 |
28 | | Actor User |
29 | {inf.actor.username}#{String(inf.actor.discriminator).padStart(4, "0")} ({inf.actor.id}) |
30 |
31 |
32 | | Created At |
33 | {inf.created_at} |
34 |
35 |
36 | | Expires At |
37 | {inf.expires_at} |
38 |
39 |
40 | | Type |
41 | {inf.type.name} |
42 |
43 |
44 | | Reason |
45 | {inf.reason} |
46 |
47 |
48 |
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 |
--------------------------------------------------------------------------------