Admin interface for channels and plugins

This commit is contained in:
Felipe M 2020-12-08 20:17:19 +01:00
parent 78d943d530
commit b0e82fdefc
Signed by: fmartingr
GPG key ID: 716BC147715E716F
23 changed files with 859 additions and 169 deletions

View file

@ -3,18 +3,24 @@ FROM alpine:3.11
ENV PYTHON_VERSION=3.8.2-r1 ENV PYTHON_VERSION=3.8.2-r1
ENV APP_PORT 8080 ENV APP_PORT 8080
ENV BUILD_DIR /tmp/build ENV BUILD_DIR /tmp/build
ENV APP_PATH /etc/butterrobot
WORKDIR ${BUILD_DIR} WORKDIR ${BUILD_DIR}
COPY README.md ${BUILD_DIR}/README.md COPY README.md ${BUILD_DIR}/README.md
COPY poetry.lock ${BUILD_DIR}/poetry.lock COPY poetry.lock ${BUILD_DIR}/poetry.lock
COPY pyproject.toml ${BUILD_DIR}/pyproject.toml COPY pyproject.toml ${BUILD_DIR}/pyproject.toml
COPY ./butterrobot ${BUILD_DIR}/butterrobot
COPY ./butterrobot_plugins_contrib ${BUILD_DIR}/butterrobot_plugins_contrib COPY ./butterrobot_plugins_contrib ${BUILD_DIR}/butterrobot_plugins_contrib
COPY ./butterrobot ${BUILD_DIR}/butterrobot
RUN apk --update add curl python3-dev==${PYTHON_VERSION} gcc musl-dev libffi-dev openssl-dev && \ RUN apk --update add curl python3-dev==${PYTHON_VERSION} gcc musl-dev libffi-dev openssl-dev && \
pip3 install poetry && \ pip3 install poetry && \
poetry build && \ poetry build && \
pip3 install ${BUILD_DIR}/dist/butterrobot-*.tar.gz && \ pip3 install ${BUILD_DIR}/dist/butterrobot-*.tar.gz && \
rm -rf ${BUILD_DIR} rm -rf ${BUILD_DIR} && \
mkdir ${APP_PATH} && \
chown -R 1000:1000 ${APP_PATH}
USER 1000
WORKDIR ${APP_PATH}
COPY ./docker/bin/start-server.sh /usr/local/bin/start-server COPY ./docker/bin/start-server.sh /usr/local/bin/start-server
CMD ["/usr/local/bin/start-server"] CMD ["/usr/local/bin/start-server"]

View file

@ -1,43 +1,144 @@
import json
import os.path import os.path
from functools import wraps
from flask import Blueprint, render_template, request, session, redirect, url_for, flash import structlog
from flask import Blueprint, render_template, request, session, redirect, url_for, flash, g
from butterrobot.db import User from butterrobot.config import HOSTNAME
from butterrobot.db import UserQuery, ChannelQuery, ChannelPluginQuery
from butterrobot.plugins import get_available_plugins from butterrobot.plugins import get_available_plugins
admin = Blueprint('admin', __name__, url_prefix='/admin') admin = Blueprint("admin", __name__, url_prefix="/admin")
admin.template_folder = os.path.join(os.path.dirname(__name__), "templates") admin.template_folder = os.path.join(os.path.dirname(__name__), "templates")
logger = structlog.get_logger(__name__)
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if g.user is None:
return redirect(url_for('admin.login_view', next=request.path))
return f(*args, **kwargs)
return decorated_function
@admin.before_app_request
def load_logged_in_user():
user_id = session.get('user_id')
if user_id is None:
g.user = None
else:
try:
user = UserQuery.get(user_id)
g.user = user
except UserQuery.NotFound:
g.user = None
@admin.route("/") @admin.route("/")
@login_required
def index_view(): def index_view():
if not session.get("logged_in", False): if not session.get("logged_in", False):
logger.info(url_for("admin.login_view"))
return redirect(url_for("admin.login_view")) return redirect(url_for("admin.login_view"))
return render_template("index.j2") return redirect(url_for("admin.channel_list_view"))
@admin.route("/login", methods=["GET", "POST"]) @admin.route("/login", methods=["GET", "POST"])
def login_view(): def login_view():
error = None error = None
if request.method == 'POST': if request.method == "POST":
user = User.check_credentials(request.form["username"], request.form["password"]) user = UserQuery.check_credentials(
request.form["username"], request.form["password"]
)
if not user: if not user:
error = "Incorrect credentials" flash("Incorrect credentials", category="danger")
else: else:
session['logged_in'] = True session["logged_in"] = True
session["user"] = user session["user_id"] = user.id
flash('You were logged in') flash("You were logged in", category="success")
return redirect(url_for('admin.index_view')) _next = request.args.get("next", url_for("admin.index_view"))
return redirect(_next)
return render_template("login.j2", error=error) return render_template("login.j2", error=error)
@admin.route("/login")
@admin.route("/logout")
@login_required
def logout_view(): def logout_view():
session.pop('logged_in', None) session.clear()
flash('You were logged out') flash("You were logged out", category="success")
return redirect(url_for('admin.index_view')) return redirect(url_for("admin.index_view"))
@admin.route("/plugins") @admin.route("/plugins")
@login_required
def plugin_list_view(): def plugin_list_view():
print(get_available_plugins())
return render_template("plugin_list.j2", plugins=get_available_plugins().values()) return render_template("plugin_list.j2", plugins=get_available_plugins().values())
@admin.route("/channels")
@login_required
def channel_list_view():
channels = ChannelQuery.all()
return render_template("channel_list.j2", channels=ChannelQuery.all())
@admin.route("/channels/<channel_id>", methods=["GET", "POST"])
@login_required
def channel_detail_view(channel_id):
if request.method == "POST":
ChannelQuery.update(
channel_id,
enabled=request.form["enabled"] == "true",
)
flash("Channel updated", "success")
channel = ChannelQuery.get(channel_id)
return render_template(
"channel_detail.j2", channel=channel, plugins=get_available_plugins()
)
@admin.route("/channel/<channel_id>/delete", methods=["POST"])
@login_required
def channel_delete_view(channel_id):
ChannelQuery.delete(channel_id)
flash("Channel removed", category="success")
return redirect(url_for("admin.channel_list_view"))
@admin.route("/channelplugins", methods=["POST"])
@login_required
def channel_plugin_list_view():
data = request.form
try:
ChannelPluginQuery.create(
data["channel_id"], data["plugin_id"], enabled=data["enabled"] == "y"
)
flash(f"Plugin {data['plugin_id']} added to the channel", "success")
except ChannelPluginQuery.Duplicated:
flash(f"Plugin {data['plugin_id']} is already present on the channel", "error")
return redirect(request.headers.get("Referer"))
@admin.route("/channelplugins/<channel_plugin_id>", methods=["GET", "POST"])
@login_required
def channel_plugin_detail_view(channel_plugin_id):
if request.method == "POST":
ChannelPluginQuery.update(
channel_plugin_id,
enabled=request.form["enabled"] == "true",
)
flash("Plugin updated", category="success")
return redirect(request.headers.get("Referer"))
@admin.route("/channelplugins/<channel_plugin_id>/delete", methods=["POST"])
@login_required
def channel_plugin_delete_view(channel_plugin_id):
ChannelPluginQuery.delete(channel_plugin_id=channel_plugin_id)
flash("Plugin removed", category="success")
return redirect(request.headers.get("Referer"))

View file

@ -1,17 +1,106 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ButterRobot Admin</title> <title>ButterRobot Admin</title>
<link rel="stylesheet" href="https://unpkg.com/@tabler/core@latest/dist/css/tabler.min.css">
</head> </head>
<body>
<h1>ButterRobot Admin</h1>
{% if session.logged_in %}
<a href="{{ url_for("admin.index_view") }}">Index</a> |
<a href="{{ url_for("admin.plugin_list_view") }}">Plugins</a>
{% endif %}
{% block content %}{% endblock %} <body>
<div class="page">
<div class="sticky-top">
<header class="navbar navbar-expand-md navbar-light sticky-top d-print-none">
<div class="container-xl">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
data-bs-target="#navbar-menu">
<span class="navbar-toggler-icon"></span>
</button>
<h1 class="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pr-0 pr-md-3">
<a href="/admin/">
<h1>ButterRobot Admin</h1>
</a>
</h1>
<div class="navbar-nav flex-row order-md-last">
<div class="nav-item">
{% if not session.logged_in %}
<a href="{{ url_for('admin.login_view') }}">Log in</a>
{% else %}
<div class="d-none d-xl-block pl-2">
<div>{{ g.user.username }} - <a class="mt-1 small"
href="{{ url_for('admin.logout_view') }}">Log out</a></div>
</div>
</a>
{% endif %}
</div>
</div>
</div>
</header>
{% if session.logged_in %}
<div class="navbar-expand-md">
<div class="collapse navbar-collapse" id="navbar-menu">
<div class="navbar navbar-light">
<div class="container-xl">
<ul class="navbar-nav">
<li class="nav-item {% if 'channels' in request.url %}active{% endif %}">
<a class="nav-link" href="{{ url_for('admin.channel_list_view') }}">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24"
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<line x1="5" y1="9" x2="19" y2="9" />
<line x1="5" y1="15" x2="19" y2="15" />
<line x1="11" y1="4" x2="7" y2="20" />
<line x1="17" y1="4" x2="13" y2="20" /></svg>
</span>
<span class="nav-link-title">
Channels
</span>
</a>
</li>
<li class="nav-item {% if 'plugins' in request.url %}active{% endif %}">
<a class="nav-link" href="{{ url_for('admin.plugin_list_view') }}">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24"
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M4 7h3a1 1 0 0 0 1 -1v-1a2 2 0 0 1 4 0v1a1 1 0 0 0 1 1h3a1 1 0 0 1 1 1v3a1 1 0 0 0 1 1h1a2 2 0 0 1 0 4h-1a1 1 0 0 0 -1 1v3a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-1a2 2 0 0 0 -4 0v1a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1h1a2 2 0 0 0 0 -4h-1a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1" />
</svg>
</span>
<span class="nav-link-title">
Plugins
</span>
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
{% endif %}
</div>
{% for category, message in get_flashed_messages(with_categories=True) %}
<div class="card">
<div class="card-status-top bg-{{ category }}"></div>
<div class="card-body">
<p>{{ message }}</p>
</div>
</div>
{% endfor %}
<div class="content">
<div class="container-xl">
{% block content %}
{% endblock %}
</div>
</div>
</div>
</body> </body>
</html> </html>

View file

@ -0,0 +1,140 @@
{% extends "_base.j2" %}
{% block content %}
<div class="page-header d-print-none">
<div class="row align-items-center">
<div class="col">
<h2 class="page-title">
Channel: {{ channel.channel_name }}
</h2>
</div>
</div>
</div>
<div class="row row-cards">
<div class="col-12">
<div class="card">
<div class="card-header">
<ul class="nav nav-pills card-header-pills">
<li class="nav-item">
<form
action="{{ url_for('admin.channel_detail_view', channel_id=channel.id) }}"
method="POST">
<input type="hidden" name="enabled" value="{{ 'false' if channel.enabled else 'true' }}" />
<input class="btn btn-{% if channel.enabled %}danger{% else %}success{% endif %}"
type="submit" value="{{ "Enable" if not channel.enabled else "Disable" }}">
</form>
</li>
<li class="nav-item">
<form action="{{ url_for('admin.channel_delete_view', channel_id=channel.id) }}" method="POST">
<input type="submit" value="Delete" class="btn btn-danger">
</form>
</li>
</ul>
</div>
<div class="card-body">
<table class="table table-vcenter card-table">
<tbody>
<tr>
<th width="20%">ID</th>
<td>{{ channel.id }}</td>
</tr>
<tr>
<th>Platform</th>
<td>{{ channel.platform }}</td>
</tr>
<tr>
<th>Platform Channel ID</th>
<td>{{ channel.platform_channel_id }}</td>
</tr>
<tr>
<th>RAW</th>
<td>
<pre>{{ channel.channel_raw }}</pre>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="col-12">
<div class="card">
<div class="card-body">
<h3 class="card-title">Plugins</h3>
</div>
<div class="card-body">
<form action="{{ url_for('admin.channel_plugin_list_view') }}" method="POST">
<input type="hidden" name="channel_id" value="{{ channel.id }}" />
<input type="hidden" name="enabled" value="y" />
<p>
<div class="row">
<div class="col-4">
Enable plugin
</div>
<div class="col-4">
<select class="form-select" name="plugin_id">
{% for plugin in plugins.values() %}
<option value="{{ plugin.id }}">{{ plugin.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-4">
<input type="submit" value="Enable" class="btn">
</div>
</div>
</p>
</form>
<div>
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>Name</th>
<th>Configuration</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for channel_plugin in channel.plugins.values() %}
<tr>
<td width="20%">{{ plugins[channel_plugin.plugin_id].name }}</td>
<td>
<pre>{{ channel_plugin.config }}</pre>
</td>
<td width="20%">
<div class="row">
<div class="col-6">
<form
action="{{ url_for('admin.channel_plugin_detail_view', channel_plugin_id=channel_plugin.id) }}"
method="POST">
<input type="hidden" name="enabled"
value="{{ 'false' if channel_plugin.enabled else 'true' }}" />
<input
class="btn btn-{% if channel_plugin.enabled %}danger{% else %}success{% endif %}"
type="submit"
value="{{ "Enable" if not channel_plugin.enabled else "Disable" }}">
</form>
</div>
<div class="col-6">
<form
action="{{ url_for('admin.channel_plugin_delete_view', channel_plugin_id=channel_plugin.id) }}"
method="POST">
<input type="submit" value="Delete" class="btn btn-danger">
</form>
</div>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="text-center">No plugin is enabled on this channel</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,45 @@
{% extends "_base.j2" %}
{% block content %}
<div class="page-header d-print-none">
<div class="row align-items-center">
<div class="col">
<h2 class="page-title">
Channel list
</h2>
</div>
</div>
</div>
<div class="row">
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>Platform</th>
<th>Channel name</th>
<th>Channel ID</th>
<th>Enabled</th>
<th class="w-1"></th>
</tr>
</thead>
<tbody>
{% for channel in channels %}
<tr>
<td>{{ channel.platform }}</td>
<td>{{ channel.channel_name }}</td>
<td class="text-muted">
{{ channel.platform_channel_id }}
</td>
<td class="text-muted">{{ channel.enabled }}</td>
<td>
<a href="{{ url_for("admin.channel_detail_view", channel_id=channel.id) }}">Edit</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View file

@ -1,15 +1,5 @@
{% extends "_base.j2" %} {% extends "_base.j2" %}
{% block content %} {% block content %}
<nav>
{% for message in get_flashed_messages() %}
<div class=flash>{{ message }}</div>
{% endfor %}
{% if not session.logged_in %}
<a href="{{ url_for('admin.login_view') }}">log in</a>
{% else %}
Hello {{ session.user.username }}! - <a href="{{ url_for('admin.logout_view') }}">log out</a>
{% endif %}
</nav>
{% endblock %} {% endblock %}

View file

@ -1,15 +1,32 @@
{% extends "_base.j2" %} {% extends "_base.j2" %}
{% block content %} {% block content %}
<h2>Login</h2> <div class="row">
{% if error %}<p class=error><strong>Error:</strong> {{ error }}{% endif %}
<form action="{{ url_for('admin.login_view') }}" method=post> {% if error %}<p class=error><strong>Error:</strong> {{ error }}{% endif %}
<dl> <div class="card">
<dt>Username: <div class="card-header">
<dd><input type=text name=username> <h3 class="card-title">Login</h3>
<dt>Password: </div>
<dd><input type=password name=password> <div class="card-body">
<dd><input type=submit value=Login> <form action="" method="post">
</dl> <div class="form-group mb-3 ">
</form> <label class="form-label">Username</label>
<div>
<input type="text" name="username" class="form-control" placeholder="Username">
</div>
</div>
<div class="form-group mb-3 ">
<label class="form-label">Password</label>
<div>
<input type="password" class="form-control" placeholder="Password" name="password">
</div>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %} {% endblock %}

View file

@ -1,7 +1,33 @@
{% extends "_base.j2" %} {% extends "_base.j2" %}
{% block content %} {% block content %}
{% for plugin in plugins %} <div class="page-header d-print-none">
<li>{{ plugin.id }} <div class="row align-items-center">
{% endfor %} <div class="col">
<h2 class="page-title">
Plugin list
</h2>
</div>
</div>
</div>
<div class="row">
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>Name</th>
</tr>
</thead>
<tbody>
{% for plugin in plugins %}
<tr>
<td>{{ plugin.name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %} {% endblock %}

View file

@ -1,72 +1,56 @@
import asyncio
import traceback import traceback
from dataclasses import asdict
from functools import lru_cache
from flask import Flask, request from flask import Flask, request, jsonify
import structlog import structlog
import butterrobot.logging # noqa import butterrobot.logging # noqa
from butterrobot.config import ENABLED_PLUGINS, SECRET_KEY from butterrobot.queue import q
from butterrobot.objects import Message from butterrobot.db import ChannelQuery
from butterrobot.config import SECRET_KEY, HOSTNAME
from butterrobot.objects import Message, Channel
from butterrobot.plugins import get_available_plugins from butterrobot.plugins import get_available_plugins
from butterrobot.platforms import PLATFORMS from butterrobot.platforms import PLATFORMS, get_available_platforms
from butterrobot.platforms.base import Platform from butterrobot.platforms.base import Platform
from butterrobot.admin.blueprint import admin as admin_bp from butterrobot.admin.blueprint import admin as admin_bp
class ExternalProxyFix(object):
"""
Custom proxy helper to get the external hostname from the `X-External-Host` header
used by one of the reverse proxies in front of this in production.
It does nothing if the header is not present.
"""
def __init__(self, app):
self.app = app
def __call__(self, environ, start_response):
host = environ.get('HTTP_X_EXTERNAL_HOST', '')
if host:
environ['HTTP_HOST'] = host
return self.app(environ, start_response)
loop = asyncio.get_event_loop()
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
app = Flask(__name__) app = Flask(__name__)
app.secret_key = SECRET_KEY app.config.update(SECRET_KEY=SECRET_KEY)
app.register_blueprint(admin_bp) app.register_blueprint(admin_bp)
available_platforms = {} app.wsgi_app = ExternalProxyFix(app.wsgi_app)
plugins = get_available_plugins()
enabled_plugins = [
plugin for plugin_name, plugin in plugins.items() if plugin_name in ENABLED_PLUGINS
]
def handle_message(platform: str, message: Message):
for plugin in enabled_plugins:
for response_message in plugin.on_message(message):
available_platforms[platform].methods.send_message(response_message)
@app.before_first_request
def init_platforms():
for platform in PLATFORMS.values():
logger.debug("Setting up", platform=platform.ID)
try:
platform.init(app=app)
available_platforms[platform.ID] = platform
logger.info("platform setup completed", platform=platform.ID)
except platform.PlatformInitError as error:
logger.error("Platform init error", error=error, platform=platform.ID)
@app.route("/<platform>/incoming", methods=["POST"]) @app.route("/<platform>/incoming", methods=["POST"])
@app.route("/<platform>/incoming/<path:path>", methods=["POST"]) @app.route("/<platform>/incoming/<path:path>", methods=["POST"])
def incoming_platform_message_view(platform, path=None): def incoming_platform_message_view(platform, path=None):
if platform not in available_platforms: if platform not in get_available_platforms():
return {"error": "Unknown platform"}, 400 return {"error": "Unknown platform"}, 400
try: q.put({"platform": platform, "request": {
message = available_platforms[platform].parse_incoming_message( "path": request.path,
request=request "json": request.get_json()
) }})
except Platform.PlatformAuthResponse as response:
return response.data, response.status_code
except Exception as error:
logger.error(
"Error parsing message",
platform=platform,
error=error,
traceback=traceback.format_exc(),
)
return {"error": str(error)}, 400
if not message or message.from_bot:
return {}
# TODO: make with rq/dramatiq
handle_message(platform, message)
return {} return {}

View file

@ -7,8 +7,6 @@ HOSTNAME = os.environ.get("BUTTERROBOT_HOSTNAME", "butterrobot-dev.int.fmartingr
LOG_LEVEL = os.environ.get("LOG_LEVEL", "ERROR") LOG_LEVEL = os.environ.get("LOG_LEVEL", "ERROR")
ENABLED_PLUGINS = os.environ.get("ENABLED_PLUGINS", "contrib/dev/ping").split(",")
SECRET_KEY = os.environ.get("SECRET_KEY", "1234") SECRET_KEY = os.environ.get("SECRET_KEY", "1234")
# --- DATABASE --------------------------------------------------------------------- # --- DATABASE ---------------------------------------------------------------------

View file

@ -4,30 +4,71 @@ import os
import dataset import dataset
from butterrobot.config import DATABASE_PATH, SECRET_KEY from butterrobot.config import DATABASE_PATH, SECRET_KEY
from butterrobot.objects import Channel, ChannelPlugin, User
db = dataset.connect(DATABASE_PATH) db = dataset.connect(DATABASE_PATH)
class Model:
class Query:
class NotFound(Exception): class NotFound(Exception):
pass pass
class User(Model): class Duplicated(Exception):
pass
@classmethod
def all(cls):
for row in cls._table.all():
yield cls._obj(**row)
@classmethod
def exists(cls, *args, **kwargs):
try:
# Using only *args since those are supposed to be mandatory
cls.get(*args)
except cls.NotFound:
return False
return True
@classmethod
def update(cls, row_id, **fields):
fields.update({"id": row_id})
return cls._table.update(fields, ("id", ))
@classmethod
def get(cls, _id):
row = cls._table.find_one(id=_id)
if not row:
raise cls.NotFound
return cls._obj(**row)
@classmethod
def update(cls, _id, **fields):
fields.update({"id": _id})
return cls._table.update(fields, ("id"))
@classmethod
def delete(cls, _id):
cls._table.delete(id=_id)
class UserQuery(Query):
_table = db["users"] _table = db["users"]
_obj = User
@classmethod @classmethod
def _hash_password(cls, password): def _hash_password(cls, password):
return hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), str.encode(SECRET_KEY), 100000).hex() return hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), str.encode(SECRET_KEY), 100000
).hex()
@classmethod @classmethod
def check_credentials(cls, username, password): def check_credentials(cls, username, password):
try: user = cls._table.find_one(username=username)
user = cls.get(username=username) if user:
hash_password = cls._hash_password(password) hash_password = cls._hash_password(password)
if user["password"] == hash_password: if user["password"] == hash_password:
return user return cls._obj(**user)
except cls.NotFound:
pass
return False return False
@classmethod @classmethod
@ -36,41 +77,89 @@ class User(Model):
cls._table.insert({"username": username, "password": hash_password}) cls._table.insert({"username": username, "password": hash_password})
@classmethod @classmethod
def get(cls, username): def delete(cls, username):
result = cls._table.find_one(username=username) return cls._table.delete(username=username)
@classmethod
def update(cls, username, **fields):
fields.update({"username": username})
return cls._table.update(fields, ("username",))
class ChannelQuery(Query):
_table = db["channels"]
_obj = Channel
@classmethod
def create(cls, platform, platform_channel_id, enabled=False, channel_raw={}):
params = {
"platform": platform,
"platform_channel_id": platform_channel_id,
"enabled": enabled,
"channel_raw": channel_raw,
}
cls._table.insert(params)
return cls._obj(**params)
@classmethod
def get(cls, _id):
channel = super().get(_id)
plugins = ChannelPluginQuery.get_from_channel_id(_id)
channel.plugins = {plugin.plugin_id: plugin for plugin in plugins}
return channel
@classmethod
def get_by_platform(cls, platform, platform_channel_id):
result = cls._table.find_one(
platform=platform, platform_channel_id=platform_channel_id
)
if not result: if not result:
raise cls.NotFound raise cls.NotFound
return result
plugins = ChannelPluginQuery.get_from_channel_id(result["id"])
return cls._obj(plugins={plugin.plugin_id: plugin for plugin in plugins}, **result)
@classmethod @classmethod
def delete(cls, username): def delete(cls, _id):
return cls._table.delete(username=username) ChannelPluginQuery.delete_by_channel(channel_id=_id)
super().delete(_id)
class ChannelPluginQuery(Query):
_table = db["channel_plugin"]
_obj = ChannelPlugin
@classmethod @classmethod
def update(cls, username, **fields): def create(cls, channel_id, plugin_id, enabled=False, config={}):
fields.update({"username": username}) if cls.exists(channel_id, plugin_id):
return cls._table.update(fields, ["username"]) raise cls.Duplicated
params = {
class Channel(Model): "channel_id": channel_id,
_table = db["channels"] "plugin_id": plugin_id,
"enabled": enabled,
"config": config,
}
obj_id = cls._table.insert(params)
return cls._obj(id=obj_id, **params)
@classmethod @classmethod
def create(cls, provider, channel_id, enabled=False, channel_raw={}): def get(cls, channel_id, plugin_id):
cls._table.insert({"provider": provider, "channel_id": channel_id, "enabled": enabled, "channel_raw": channel_raw}) result = cls._table.find_one(channel_id=channel_id, plugin_id=plugin_id)
@classmethod
def get(cls, username):
result = cls._table.find_one(username=username)
if not result: if not result:
raise cls.UserNotFound raise cls.NotFound
return result return cls._obj(**result)
@classmethod @classmethod
def delete(cls, username): def get_from_channel_id(cls, channel_id):
return cls._table.delete(username=username) yield from [cls._obj(**row) for row in cls._table.find(channel_id=channel_id)]
@classmethod @classmethod
def update(cls, username, **fields): def delete(cls, channel_plugin_id):
fields.update({"username": username}) return cls._table.delete(id=channel_plugin_id)
return cls._table.update(fields, ["username"])
@classmethod
def delete_by_channel(cls, channel_id):
cls._table.delete(channel_id=channel_id)

View file

@ -11,6 +11,7 @@ logger = structlog.get_logger()
class SlackAPI: class SlackAPI:
BASE_URL = "https://slack.com/api" BASE_URL = "https://slack.com/api"
HEADERS = {"Authorization": f"Bearer {SLACK_BOT_OAUTH_ACCESS_TOKEN}"}
class SlackError(Exception): class SlackError(Exception):
pass pass
@ -18,6 +19,30 @@ class SlackAPI:
class SlackClientError(Exception): class SlackClientError(Exception):
pass pass
@classmethod
def get_conversations_info(cls, chat_id) -> dict:
params = {"channel": chat_id}
response = requests.get(
f"{cls.BASE_URL}/conversations.info", params=params, headers=cls.HEADERS,
)
response_json = response.json()
if not response_json["ok"]:
raise cls.SlackClientError(response_json)
return response_json["channel"]
@classmethod
def get_user_info(cls, chat_id) -> dict:
params = {"user": chat_id}
response = requests.get(
f"{cls.BASE_URL}/users.info", params=params, headers=cls.HEADERS,
)
response_json = response.json()
if not response_json["ok"]:
raise cls.SlackClientError(response_json)
return response_json["user"]
@classmethod @classmethod
def send_message(cls, channel, message, thread: Optional[Text] = None): def send_message(cls, channel, message, thread: Optional[Text] = None):
payload = { payload = {
@ -28,10 +53,8 @@ class SlackAPI:
if thread: if thread:
payload["thread_ts"] = thread payload["thread_ts"] = thread
response = requestts.post( response = requests.post(
f"{cls.BASE_URL}/chat.postMessage", f"{cls.BASE_URL}/chat.postMessage", data=payload, headers=cls.HEADERS,
data=payload,
headers={"Authorization": f"Bearer {SLACK_BOT_OAUTH_ACCESS_TOKEN}"},
) )
response_json = response.json() response_json = response.json()
if not response_json["ok"]: if not response_json["ok"]:

View file

@ -1,15 +1,60 @@
from datetime import datetime from datetime import datetime
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Text, Optional from typing import Text, Optional, Dict
import structlog
logger = structlog.get_logger(__name__)
@dataclass
class ChannelPlugin:
id: int
channel_id: int
plugin_id: str
enabled: bool = False
config: dict = field(default_factory=dict)
@dataclass
class Channel:
platform: str
platform_channel_id: str
channel_raw: dict
enabled: bool = False
id: Optional[int] = None
plugins: Dict[str, ChannelPlugin] = field(default_factory=dict)
def has_enabled_plugin(self, plugin_id):
if plugin_id not in self.plugins:
logger.info("No enabled!", plugin_id=plugin_id, plugins=self.plugins)
return False
return self.plugins[plugin_id].enabled
@property
def channel_name(self):
from butterrobot.platforms import PLATFORMS
return PLATFORMS[self.platform].parse_channel_name_from_raw(self.channel_raw)
@dataclass @dataclass
class Message: class Message:
text: Text text: Text
chat: Text chat: Text
# TODO: Move chat references to `.channel.platform_channel_id`
channel: Optional[Channel] = None
author: Text = None author: Text = None
from_bot: bool = False from_bot: bool = False
date: Optional[datetime] = None date: Optional[datetime] = None
id: Optional[Text] = None id: Optional[Text] = None
reply_to: Optional[Text] = None reply_to: Optional[Text] = None
raw: dict = field(default_factory=dict) raw: dict = field(default_factory=dict)
@dataclass
class User:
id: int
username: Text
password: Text

View file

@ -1,6 +1,26 @@
from functools import lru_cache
import structlog
from butterrobot.platforms.slack import SlackPlatform from butterrobot.platforms.slack import SlackPlatform
from butterrobot.platforms.telegram import TelegramPlatform from butterrobot.platforms.telegram import TelegramPlatform
from butterrobot.platforms.debug import DebugPlatform from butterrobot.platforms.debug import DebugPlatform
logger = structlog.get_logger(__name__)
PLATFORMS = {platform.ID: platform for platform in (SlackPlatform, TelegramPlatform, DebugPlatform)} PLATFORMS = {platform.ID: platform for platform in (SlackPlatform, TelegramPlatform, DebugPlatform)}
@lru_cache
def get_available_platforms():
from butterrobot.platforms import PLATFORMS
available_platforms = {}
for platform in PLATFORMS.values():
logger.debug("Setting up", platform=platform.ID)
try:
platform.init(app=None)
available_platforms[platform.ID] = platform
logger.info("platform setup completed", platform=platform.ID)
except platform.PlatformInitError as error:
logger.error("Platform init error", error=error, platform=platform.ID)
return available_platforms

View file

@ -4,7 +4,7 @@ from datetime import datetime
import structlog import structlog
from butterrobot.platforms.base import Platform, PlatformMethods from butterrobot.platforms.base import Platform, PlatformMethods
from butterrobot.objects import Message from butterrobot.objects import Message, Channel
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@ -25,7 +25,7 @@ class DebugPlatform(Platform):
@classmethod @classmethod
def parse_incoming_message(cls, request): def parse_incoming_message(cls, request):
request_data = request.get_json() request_data = request["json"]
logger.debug("Parsing message", data=request_data, platform=cls.ID) logger.debug("Parsing message", data=request_data, platform=cls.ID)
return Message( return Message(
@ -35,5 +35,6 @@ class DebugPlatform(Platform):
from_bot=bool(request_data.get("from_bot", False)), from_bot=bool(request_data.get("from_bot", False)),
author=request_data.get("author", "Debug author"), author=request_data.get("author", "Debug author"),
chat=request_data.get("chat", "Debug chat ID"), chat=request_data.get("chat", "Debug chat ID"),
channel=Channel(platform=cls.ID, platform_channel_id=request_data.get("chat"), channel_raw={}),
raw={}, raw={},
) )

View file

@ -4,7 +4,7 @@ import structlog
from butterrobot.platforms.base import Platform, PlatformMethods from butterrobot.platforms.base import Platform, PlatformMethods
from butterrobot.config import SLACK_TOKEN, SLACK_BOT_OAUTH_ACCESS_TOKEN from butterrobot.config import SLACK_TOKEN, SLACK_BOT_OAUTH_ACCESS_TOKEN
from butterrobot.objects import Message from butterrobot.objects import Message, Channel
from butterrobot.lib.slack import SlackAPI from butterrobot.lib.slack import SlackAPI
@ -41,9 +41,27 @@ class SlackPlatform(Platform):
logger.error("Missing token. platform not enabled.", platform=cls.ID) logger.error("Missing token. platform not enabled.", platform=cls.ID)
return return
@classmethod
def parse_channel_name_from_raw(cls, channel_raw):
return channel_raw["name"]
@classmethod
def parse_channel_from_message(cls, message):
# Call different APIs for a channel or DM
if message["event"]["channel_type"] == "im":
chat_raw = SlackAPI.get_user_info(message["event"]["user"])
else:
chat_raw = SlackAPI.get_conversations_info(message["event"]["channel"])
return Channel(
platform=cls.ID,
platform_channel_id=message["event"]["channel"],
channel_raw=chat_raw,
)
@classmethod @classmethod
def parse_incoming_message(cls, request): def parse_incoming_message(cls, request):
data = request.get_json() data = request["json"]
# Auth # Auth
if data.get("token") != SLACK_TOKEN: if data.get("token") != SLACK_TOKEN:
@ -69,5 +87,6 @@ class SlackPlatform(Platform):
date=datetime.fromtimestamp(int(float(data["event"]["event_ts"]))), date=datetime.fromtimestamp(int(float(data["event"]["event_ts"]))),
text=data["event"]["text"], text=data["event"]["text"],
chat=data["event"]["channel"], chat=data["event"]["channel"],
channel=cls.parse_channel_from_message(data),
raw=data, raw=data,
) )

View file

@ -5,7 +5,7 @@ import structlog
from butterrobot.platforms.base import Platform, PlatformMethods from butterrobot.platforms.base import Platform, PlatformMethods
from butterrobot.config import TELEGRAM_TOKEN, HOSTNAME from butterrobot.config import TELEGRAM_TOKEN, HOSTNAME
from butterrobot.lib.telegram import TelegramAPI from butterrobot.lib.telegram import TelegramAPI
from butterrobot.objects import Message from butterrobot.objects import Message, Channel
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@ -46,23 +46,40 @@ class TelegramPlatform(Platform):
logger.error(f"Error setting Telegram webhook: {error}", platform=cls.ID) logger.error(f"Error setting Telegram webhook: {error}", platform=cls.ID)
raise Platform.PlatformInitError() raise Platform.PlatformInitError()
@classmethod
def parse_channel_name_from_raw(cls, channel_raw):
if channel_raw["id"] < 0:
return channel_raw["title"]
else:
if channel_raw["username"]:
return f"@{channel_raw['username']}"
return f"{channel_raw['first_name']} {channel_raw['last_name']}"
@classmethod
def parse_channel_from_message(cls, channel_raw):
return Channel(
platform=cls.ID,
platform_channel_id=channel_raw["id"],
channel_raw=channel_raw,
)
@classmethod @classmethod
def parse_incoming_message(cls, request): def parse_incoming_message(cls, request):
token = request.path.split("/")[-1] token = request["path"].split("/")[-1]
if token != TELEGRAM_TOKEN: if token != TELEGRAM_TOKEN:
raise cls.PlatformAuthError("Authentication error") raise cls.PlatformAuthError("Authentication error")
request_data = request.get_json() logger.debug("Parsing message", data=request["json"], platform=cls.ID)
logger.debug("Parsing message", data=request_data, platform=cls.ID)
if "text" in request_data["message"]: if "text" in request["json"]["message"]:
# Ignore all messages but text messages # Ignore all messages but text messages
return Message( return Message(
id=request_data["message"]["message_id"], id=request["json"]["message"]["message_id"],
date=datetime.fromtimestamp(request_data["message"]["date"]), date=datetime.fromtimestamp(request["json"]["message"]["date"]),
text=str(request_data["message"]["text"]), text=str(request["json"]["message"]["text"]),
from_bot=request_data["message"]["from"]["is_bot"], from_bot=request["json"]["message"]["from"]["is_bot"],
author=request_data["message"]["from"]["id"], author=request["json"]["message"]["from"]["id"],
chat=str(request_data["message"]["chat"]["id"]), chat=str(request["json"]["message"]["chat"]["id"]),
raw=request_data, channel=cls.parse_channel_from_message(request["json"]["message"]["chat"]),
raw=request["json"],
) )

View file

@ -1,6 +1,8 @@
import traceback import traceback
import pkg_resources import pkg_resources
from abc import abstractclassmethod from abc import abstractclassmethod
from functools import lru_cache
from typing import Optional, Dict
import structlog import structlog
@ -10,15 +12,20 @@ logger = structlog.get_logger(__name__)
class Plugin: class Plugin:
id: str
name: str
help: str
requires_config: bool = False
@abstractclassmethod @abstractclassmethod
def on_message(cls, message: Message): def on_message(cls, message: Message, channel_config: Optional[Dict] = None):
pass pass
@lru_cache
def get_available_plugins(): def get_available_plugins():
"""Retrieves every available plugin""" """Retrieves every available plugin"""
plugins = {} plugins = {}
logger.debug("Loading plugins")
for ep in pkg_resources.iter_entry_points("butterrobot.plugins"): for ep in pkg_resources.iter_entry_points("butterrobot.plugins"):
try: try:
plugin_cls = ep.load() plugin_cls = ep.load()
@ -34,5 +41,4 @@ def get_available_plugins():
module=ep.module_name, module=ep.module_name,
) )
logger.info(f"Plugins loaded", plugins=list(plugins.keys()))
return plugins return plugins

59
butterrobot/queue.py Normal file
View file

@ -0,0 +1,59 @@
import threading
import traceback
import queue
import structlog
from butterrobot.db import ChannelQuery
from butterrobot.platforms import get_available_platforms
from butterrobot.platforms.base import Platform
from butterrobot.plugins import get_available_plugins
logger = structlog.get_logger(__name__)
q = queue.Queue()
def handle_message(platform: str, request: dict):
try:
message = get_available_platforms()[platform].parse_incoming_message(request=request)
except Platform.PlatformAuthResponse as response:
return response.data, response.status_code
except Exception as error:
logger.error(
"Error parsing message",
platform=platform,
error=error,
traceback=traceback.format_exc(),
)
return
logger.info("Received request", platform=platform, message=message)
if not message or message.from_bot:
return
try:
channel = ChannelQuery.get_by_platform(platform, message.chat)
except ChannelQuery.NotFound:
# If channel is still not present on the database, create it (defaults to disabled)
channel = ChannelQuery.create(platform, message.chat, channel_raw=message.channel.channel_raw)
if not channel.enabled:
return
for plugin_id, channel_plugin in channel.plugins.items():
if not channel.has_enabled_plugin(plugin_id):
continue
for response_message in get_available_plugins()[plugin_id].on_message(message, plugin_config=channel_plugin.config):
get_available_platforms()[platform].methods.send_message(response_message)
def worker_thread():
while True:
item = q.get()
handle_message(item["platform"], item["request"])
q.task_done()
# turn-on the worker thread
worker = threading.Thread(target=worker_thread, daemon=True).start()

View file

@ -5,10 +5,11 @@ from butterrobot.objects import Message
class PingPlugin(Plugin): class PingPlugin(Plugin):
id = "contrib/dev/ping" name = "Ping command"
id = "contrib.dev.ping"
@classmethod @classmethod
def on_message(cls, message): def on_message(cls, message, **kwargs):
if message.text == "!ping": if message.text == "!ping":
delta = datetime.now() - message.date delta = datetime.now() - message.date
delta_ms = delta.seconds * 1000 + delta.microseconds / 1000 delta_ms = delta.seconds * 1000 + delta.microseconds / 1000

View file

@ -1,34 +1,45 @@
import random import random
import dice import dice
import structlog
from butterrobot.plugins import Plugin from butterrobot.plugins import Plugin
from butterrobot.objects import Message from butterrobot.objects import Message
logger = structlog.get_logger(__name__)
class LoquitoPlugin(Plugin): class LoquitoPlugin(Plugin):
id = "contrib/fun/loquito" name = "Loquito reply"
id = "contrib.fun.loquito"
@classmethod @classmethod
def on_message(cls, message): def on_message(cls, message, **kwargs):
if "lo quito" in message.text.lower(): if "lo quito" in message.text.lower():
yield Message(chat=message.chat, reply_to=message.id, text="Loquito tu.",) yield Message(chat=message.chat, reply_to=message.id, text="Loquito tu.",)
class DicePlugin(Plugin): class DicePlugin(Plugin):
id = "contrib/fun/dice" name = "Dice command"
id = "contrib.fun.dice"
DEFAULT_FORMULA = "1d20"
@classmethod @classmethod
def on_message(cls, message: Message): def on_message(cls, message: Message, **kwargs):
if message.text.startswith("!dice"): if message.text.startswith("!dice"):
roll = int(dice.roll(message.text.replace("!dice ", ""))) dice_formula = message.text.replace("!dice", "").strip()
if not dice_formula:
dice_formula = cls.DEFAULT_FORMULA
roll = int(dice.roll(dice_formula))
yield Message(chat=message.chat, reply_to=message.id, text=roll) yield Message(chat=message.chat, reply_to=message.id, text=roll)
class CoinPlugin(Plugin): class CoinPlugin(Plugin):
id = "contrib/fun/coin" name = "Coin command"
id = "contrib.fun.coin"
@classmethod @classmethod
def on_message(cls, message: Message): def on_message(cls, message: Message, **kwargs):
if message.text.startswith("!coin"): if message.text.startswith("!coin"):
yield Message(chat=message.chat, reply_to=message.id, text=random.choice(("heads", "tails"))) yield Message(chat=message.chat, reply_to=message.id, text=random.choice(("heads", "tails")))

View file

@ -2,12 +2,15 @@ FROM alpine:3.11
ENV PYTHON_VERSION=3.8.2-r1 ENV PYTHON_VERSION=3.8.2-r1
ENV APP_PORT 8080 ENV APP_PORT 8080
ENV BUTTERROBOT_VERSION 0.0.2a4 ENV BUTTERROBOT_VERSION 0.0.3
ENV EXTRA_DEPENDENCIES "" ENV EXTRA_DEPENDENCIES ""
ENV APP_PATH /etc/butterrobot
COPY bin/start-server.sh /usr/local/bin/start-server COPY bin/start-server.sh /usr/local/bin/start-server
RUN apk --update add curl python3-dev==${PYTHON_VERSION} gcc musl-dev libffi-dev openssl-dev && \ RUN apk --update add curl python3-dev==${PYTHON_VERSION} gcc musl-dev libffi-dev openssl-dev && \
pip3 install butterrobot==${BUTTERROBOT_VERSION} ${EXTRA_DEPENDENCIES} pip3 install butterrobot==${BUTTERROBOT_VERSION} ${EXTRA_DEPENDENCIES} && \
mkdir ${APP_PATH} && \
chown -R 1000:1000 ${APP_PATH}
USER 1000 USER 1000

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "butterrobot" name = "butterrobot"
version = "0.0.2a4" version = "0.0.3"
description = "What is my purpose?" description = "What is my purpose?"
authors = ["Felipe Martin <me@fmartingr.com>"] authors = ["Felipe Martin <me@fmartingr.com>"]
license = "GPL-2.0" license = "GPL-2.0"