Admin interface for channels and plugins

This commit is contained in:
Felipe Martin 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 APP_PORT 8080
ENV BUILD_DIR /tmp/build
ENV APP_PATH /etc/butterrobot
WORKDIR ${BUILD_DIR}
COPY README.md ${BUILD_DIR}/README.md
COPY poetry.lock ${BUILD_DIR}/poetry.lock
COPY pyproject.toml ${BUILD_DIR}/pyproject.toml
COPY ./butterrobot ${BUILD_DIR}/butterrobot
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 && \
pip3 install poetry && \
poetry build && \
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
CMD ["/usr/local/bin/start-server"]

View File

@ -1,43 +1,144 @@
import json
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
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")
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("/")
@login_required
def index_view():
if not session.get("logged_in", False):
logger.info(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"])
def login_view():
error = None
if request.method == 'POST':
user = User.check_credentials(request.form["username"], request.form["password"])
if request.method == "POST":
user = UserQuery.check_credentials(
request.form["username"], request.form["password"]
)
if not user:
error = "Incorrect credentials"
flash("Incorrect credentials", category="danger")
else:
session['logged_in'] = True
session["user"] = user
flash('You were logged in')
return redirect(url_for('admin.index_view'))
session["logged_in"] = True
session["user_id"] = user.id
flash("You were logged in", category="success")
_next = request.args.get("next", url_for("admin.index_view"))
return redirect(_next)
return render_template("login.j2", error=error)
@admin.route("/login")
@admin.route("/logout")
@login_required
def logout_view():
session.pop('logged_in', None)
flash('You were logged out')
return redirect(url_for('admin.index_view'))
session.clear()
flash("You were logged out", category="success")
return redirect(url_for("admin.index_view"))
@admin.route("/plugins")
@login_required
def plugin_list_view():
print(get_available_plugins())
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>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ButterRobot Admin</title>
<link rel="stylesheet" href="https://unpkg.com/@tabler/core@latest/dist/css/tabler.min.css">
</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>
</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" %}
{% 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 %}

View File

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

View File

@ -1,7 +1,33 @@
{% extends "_base.j2" %}
{% block content %}
{% for plugin in plugins %}
<li>{{ plugin.id }}
{% endfor %}
<div class="page-header d-print-none">
<div class="row align-items-center">
<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 %}

View File

@ -1,72 +1,56 @@
import asyncio
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 butterrobot.logging # noqa
from butterrobot.config import ENABLED_PLUGINS, SECRET_KEY
from butterrobot.objects import Message
from butterrobot.queue import q
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.platforms import PLATFORMS
from butterrobot.platforms import PLATFORMS, get_available_platforms
from butterrobot.platforms.base import Platform
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__)
app = Flask(__name__)
app.secret_key = SECRET_KEY
app.config.update(SECRET_KEY=SECRET_KEY)
app.register_blueprint(admin_bp)
available_platforms = {}
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.wsgi_app = ExternalProxyFix(app.wsgi_app)
@app.route("/<platform>/incoming", methods=["POST"])
@app.route("/<platform>/incoming/<path:path>", methods=["POST"])
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
try:
message = 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 {"error": str(error)}, 400
if not message or message.from_bot:
return {}
# TODO: make with rq/dramatiq
handle_message(platform, message)
q.put({"platform": platform, "request": {
"path": request.path,
"json": request.get_json()
}})
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")
ENABLED_PLUGINS = os.environ.get("ENABLED_PLUGINS", "contrib/dev/ping").split(",")
SECRET_KEY = os.environ.get("SECRET_KEY", "1234")
# --- DATABASE ---------------------------------------------------------------------

View File

@ -4,30 +4,71 @@ import os
import dataset
from butterrobot.config import DATABASE_PATH, SECRET_KEY
from butterrobot.objects import Channel, ChannelPlugin, User
db = dataset.connect(DATABASE_PATH)
class Model:
class Query:
class NotFound(Exception):
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"]
_obj = User
@classmethod
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
def check_credentials(cls, username, password):
try:
user = cls.get(username=username)
user = cls._table.find_one(username=username)
if user:
hash_password = cls._hash_password(password)
if user["password"] == hash_password:
return user
except cls.NotFound:
pass
return cls._obj(**user)
return False
@classmethod
@ -36,41 +77,89 @@ class User(Model):
cls._table.insert({"username": username, "password": hash_password})
@classmethod
def get(cls, username):
result = cls._table.find_one(username=username)
def delete(cls, 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:
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
def delete(cls, username):
return cls._table.delete(username=username)
def delete(cls, _id):
ChannelPluginQuery.delete_by_channel(channel_id=_id)
super().delete(_id)
class ChannelPluginQuery(Query):
_table = db["channel_plugin"]
_obj = ChannelPlugin
@classmethod
def update(cls, username, **fields):
fields.update({"username": username})
return cls._table.update(fields, ["username"])
def create(cls, channel_id, plugin_id, enabled=False, config={}):
if cls.exists(channel_id, plugin_id):
raise cls.Duplicated
class Channel(Model):
_table = db["channels"]
params = {
"channel_id": channel_id,
"plugin_id": plugin_id,
"enabled": enabled,
"config": config,
}
obj_id = cls._table.insert(params)
return cls._obj(id=obj_id, **params)
@classmethod
def create(cls, provider, channel_id, enabled=False, channel_raw={}):
cls._table.insert({"provider": provider, "channel_id": channel_id, "enabled": enabled, "channel_raw": channel_raw})
@classmethod
def get(cls, username):
result = cls._table.find_one(username=username)
def get(cls, channel_id, plugin_id):
result = cls._table.find_one(channel_id=channel_id, plugin_id=plugin_id)
if not result:
raise cls.UserNotFound
return result
raise cls.NotFound
return cls._obj(**result)
@classmethod
def delete(cls, username):
return cls._table.delete(username=username)
def get_from_channel_id(cls, channel_id):
yield from [cls._obj(**row) for row in cls._table.find(channel_id=channel_id)]
@classmethod
def update(cls, username, **fields):
fields.update({"username": username})
return cls._table.update(fields, ["username"])
def delete(cls, channel_plugin_id):
return cls._table.delete(id=channel_plugin_id)
@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:
BASE_URL = "https://slack.com/api"
HEADERS = {"Authorization": f"Bearer {SLACK_BOT_OAUTH_ACCESS_TOKEN}"}
class SlackError(Exception):
pass
@ -18,6 +19,30 @@ class SlackAPI:
class SlackClientError(Exception):
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
def send_message(cls, channel, message, thread: Optional[Text] = None):
payload = {
@ -28,11 +53,9 @@ class SlackAPI:
if thread:
payload["thread_ts"] = thread
response = requestts.post(
f"{cls.BASE_URL}/chat.postMessage",
data=payload,
headers={"Authorization": f"Bearer {SLACK_BOT_OAUTH_ACCESS_TOKEN}"},
)
response = requests.post(
f"{cls.BASE_URL}/chat.postMessage", data=payload, headers=cls.HEADERS,
)
response_json = response.json()
if not response_json["ok"]:
raise cls.SlackClientError(response_json)

View File

@ -1,15 +1,60 @@
from datetime import datetime
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
class Message:
text: Text
chat: Text
# TODO: Move chat references to `.channel.platform_channel_id`
channel: Optional[Channel] = None
author: Text = None
from_bot: bool = False
date: Optional[datetime] = None
id: Optional[Text] = None
reply_to: Optional[Text] = None
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.telegram import TelegramPlatform
from butterrobot.platforms.debug import DebugPlatform
logger = structlog.get_logger(__name__)
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
from butterrobot.platforms.base import Platform, PlatformMethods
from butterrobot.objects import Message
from butterrobot.objects import Message, Channel
logger = structlog.get_logger(__name__)
@ -25,7 +25,7 @@ class DebugPlatform(Platform):
@classmethod
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)
return Message(
@ -35,5 +35,6 @@ class DebugPlatform(Platform):
from_bot=bool(request_data.get("from_bot", False)),
author=request_data.get("author", "Debug author"),
chat=request_data.get("chat", "Debug chat ID"),
channel=Channel(platform=cls.ID, platform_channel_id=request_data.get("chat"), channel_raw={}),
raw={},
)

View File

@ -4,7 +4,7 @@ import structlog
from butterrobot.platforms.base import Platform, PlatformMethods
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
@ -41,9 +41,27 @@ class SlackPlatform(Platform):
logger.error("Missing token. platform not enabled.", platform=cls.ID)
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
def parse_incoming_message(cls, request):
data = request.get_json()
data = request["json"]
# Auth
if data.get("token") != SLACK_TOKEN:
@ -69,5 +87,6 @@ class SlackPlatform(Platform):
date=datetime.fromtimestamp(int(float(data["event"]["event_ts"]))),
text=data["event"]["text"],
chat=data["event"]["channel"],
channel=cls.parse_channel_from_message(data),
raw=data,
)

View File

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

View File

@ -1,6 +1,8 @@
import traceback
import pkg_resources
from abc import abstractclassmethod
from functools import lru_cache
from typing import Optional, Dict
import structlog
@ -10,15 +12,20 @@ logger = structlog.get_logger(__name__)
class Plugin:
id: str
name: str
help: str
requires_config: bool = False
@abstractclassmethod
def on_message(cls, message: Message):
def on_message(cls, message: Message, channel_config: Optional[Dict] = None):
pass
@lru_cache
def get_available_plugins():
"""Retrieves every available plugin"""
plugins = {}
logger.debug("Loading plugins")
for ep in pkg_resources.iter_entry_points("butterrobot.plugins"):
try:
plugin_cls = ep.load()
@ -34,5 +41,4 @@ def get_available_plugins():
module=ep.module_name,
)
logger.info(f"Plugins loaded", plugins=list(plugins.keys()))
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):
id = "contrib/dev/ping"
name = "Ping command"
id = "contrib.dev.ping"
@classmethod
def on_message(cls, message):
def on_message(cls, message, **kwargs):
if message.text == "!ping":
delta = datetime.now() - message.date
delta_ms = delta.seconds * 1000 + delta.microseconds / 1000

View File

@ -1,34 +1,45 @@
import random
import dice
import structlog
from butterrobot.plugins import Plugin
from butterrobot.objects import Message
logger = structlog.get_logger(__name__)
class LoquitoPlugin(Plugin):
id = "contrib/fun/loquito"
name = "Loquito reply"
id = "contrib.fun.loquito"
@classmethod
def on_message(cls, message):
def on_message(cls, message, **kwargs):
if "lo quito" in message.text.lower():
yield Message(chat=message.chat, reply_to=message.id, text="Loquito tu.",)
class DicePlugin(Plugin):
id = "contrib/fun/dice"
name = "Dice command"
id = "contrib.fun.dice"
DEFAULT_FORMULA = "1d20"
@classmethod
def on_message(cls, message: Message):
def on_message(cls, message: Message, **kwargs):
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)
class CoinPlugin(Plugin):
id = "contrib/fun/coin"
name = "Coin command"
id = "contrib.fun.coin"
@classmethod
def on_message(cls, message: Message):
def on_message(cls, message: Message, **kwargs):
if message.text.startswith("!coin"):
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 APP_PORT 8080
ENV BUTTERROBOT_VERSION 0.0.2a4
ENV BUTTERROBOT_VERSION 0.0.3
ENV EXTRA_DEPENDENCIES ""
ENV APP_PATH /etc/butterrobot
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 && \
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

View File

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