diff --git a/Dockerfile.dev b/Dockerfile.dev index 3e1a8fb..cd831c8 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -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"] diff --git a/butterrobot/admin/blueprint.py b/butterrobot/admin/blueprint.py index 3907b77..c575215 100644 --- a/butterrobot/admin/blueprint.py +++ b/butterrobot/admin/blueprint.py @@ -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/", 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//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/", 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//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")) diff --git a/butterrobot/admin/templates/_base.j2 b/butterrobot/admin/templates/_base.j2 index 0d206da..95ab53b 100644 --- a/butterrobot/admin/templates/_base.j2 +++ b/butterrobot/admin/templates/_base.j2 @@ -1,17 +1,106 @@ + ButterRobot Admin + - -

ButterRobot Admin

- {% if session.logged_in %} - Index | - Plugins - {% endif %} - {% block content %}{% endblock %} + +
+
+ + {% if session.logged_in %} + + {% endif %} +
+ + {% for category, message in get_flashed_messages(with_categories=True) %} +
+
+
+

{{ message }}

+
+
+ {% endfor %} + +
+
+ {% block content %} + {% endblock %} +
+
+ +
+ diff --git a/butterrobot/admin/templates/channel_detail.j2 b/butterrobot/admin/templates/channel_detail.j2 new file mode 100644 index 0000000..409a230 --- /dev/null +++ b/butterrobot/admin/templates/channel_detail.j2 @@ -0,0 +1,140 @@ +{% extends "_base.j2" %} + +{% block content %} + +
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + +
ID{{ channel.id }}
Platform{{ channel.platform }}
Platform Channel ID{{ channel.platform_channel_id }}
RAW +
{{ channel.channel_raw }}
+
+
+
+
+
+
+
+

Plugins

+
+
+
+ + +

+

+
+ Enable plugin +
+
+ +
+
+ +
+
+

+
+
+ + + + + + + + + + + {% for channel_plugin in channel.plugins.values() %} + + + + + + {% else %} + + + + {% endfor %} + +
NameConfigurationActions
{{ plugins[channel_plugin.plugin_id].name }} +
{{ channel_plugin.config }}
+
+
+
+
+ + +
+
+
+
+ +
+
+
+
No plugin is enabled on this channel
+
+
+
+
+
+{% endblock %} diff --git a/butterrobot/admin/templates/channel_list.j2 b/butterrobot/admin/templates/channel_list.j2 new file mode 100644 index 0000000..6bb0887 --- /dev/null +++ b/butterrobot/admin/templates/channel_list.j2 @@ -0,0 +1,45 @@ +{% extends "_base.j2" %} + +{% block content %} + + +
+
+ + + + + + + + + + + + + {% for channel in channels %} + + + + + + + + {% endfor %} + +
PlatformChannel nameChannel IDEnabled
{{ channel.platform }}{{ channel.channel_name }} + {{ channel.platform_channel_id }} + {{ channel.enabled }} + Edit +
+
+
+{% endblock %} diff --git a/butterrobot/admin/templates/index.j2 b/butterrobot/admin/templates/index.j2 index d6e07e2..70b55ea 100644 --- a/butterrobot/admin/templates/index.j2 +++ b/butterrobot/admin/templates/index.j2 @@ -1,15 +1,5 @@ {% extends "_base.j2" %} {% block content %} - {% endblock %} diff --git a/butterrobot/admin/templates/login.j2 b/butterrobot/admin/templates/login.j2 index 0e3daf8..eb78117 100644 --- a/butterrobot/admin/templates/login.j2 +++ b/butterrobot/admin/templates/login.j2 @@ -1,15 +1,32 @@ {% extends "_base.j2" %} {% block content %} -

Login

-{% if error %}

Error: {{ error }}{% endif %} -

-
-
Username: -
-
Password: -
-
-
-
+
+ + {% if error %}

Error: {{ error }}{% endif %} +

+
+

Login

+
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+
+
+
{% endblock %} diff --git a/butterrobot/admin/templates/plugin_list.j2 b/butterrobot/admin/templates/plugin_list.j2 index d727d4e..68532fb 100644 --- a/butterrobot/admin/templates/plugin_list.j2 +++ b/butterrobot/admin/templates/plugin_list.j2 @@ -1,7 +1,33 @@ {% extends "_base.j2" %} {% block content %} -{% for plugin in plugins %} -
  • {{ plugin.id }} -{% endfor %} + + +
    +
    + + + + + + + + + {% for plugin in plugins %} + + + + {% endfor %} + +
    Name
    {{ plugin.name }}
    +
    +
    {% endblock %} diff --git a/butterrobot/app.py b/butterrobot/app.py index b279d15..fee903a 100644 --- a/butterrobot/app.py +++ b/butterrobot/app.py @@ -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("//incoming", methods=["POST"]) @app.route("//incoming/", 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 {} diff --git a/butterrobot/config.py b/butterrobot/config.py index b601df1..c350ba5 100644 --- a/butterrobot/config.py +++ b/butterrobot/config.py @@ -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 --------------------------------------------------------------------- diff --git a/butterrobot/db.py b/butterrobot/db.py index 3dc6033..28fd334 100644 --- a/butterrobot/db.py +++ b/butterrobot/db.py @@ -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) + diff --git a/butterrobot/lib/slack.py b/butterrobot/lib/slack.py index e0dbbd1..b8cc7d3 100644 --- a/butterrobot/lib/slack.py +++ b/butterrobot/lib/slack.py @@ -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) diff --git a/butterrobot/objects.py b/butterrobot/objects.py index 55051c7..dc6e846 100644 --- a/butterrobot/objects.py +++ b/butterrobot/objects.py @@ -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 diff --git a/butterrobot/platforms/__init__.py b/butterrobot/platforms/__init__.py index c63b65e..b8c8fe0 100644 --- a/butterrobot/platforms/__init__.py +++ b/butterrobot/platforms/__init__.py @@ -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 diff --git a/butterrobot/platforms/debug.py b/butterrobot/platforms/debug.py index 6faacfe..d98c5ca 100644 --- a/butterrobot/platforms/debug.py +++ b/butterrobot/platforms/debug.py @@ -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={}, ) diff --git a/butterrobot/platforms/slack.py b/butterrobot/platforms/slack.py index 8a77a01..2cb829e 100644 --- a/butterrobot/platforms/slack.py +++ b/butterrobot/platforms/slack.py @@ -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, ) diff --git a/butterrobot/platforms/telegram.py b/butterrobot/platforms/telegram.py index 13583fe..faa29d2 100644 --- a/butterrobot/platforms/telegram.py +++ b/butterrobot/platforms/telegram.py @@ -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"], ) diff --git a/butterrobot/plugins.py b/butterrobot/plugins.py index 514c69e..e3be1e6 100644 --- a/butterrobot/plugins.py +++ b/butterrobot/plugins.py @@ -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 diff --git a/butterrobot/queue.py b/butterrobot/queue.py new file mode 100644 index 0000000..5069540 --- /dev/null +++ b/butterrobot/queue.py @@ -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() diff --git a/butterrobot_plugins_contrib/dev.py b/butterrobot_plugins_contrib/dev.py index c4641cb..c03f6c7 100644 --- a/butterrobot_plugins_contrib/dev.py +++ b/butterrobot_plugins_contrib/dev.py @@ -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 diff --git a/butterrobot_plugins_contrib/fun.py b/butterrobot_plugins_contrib/fun.py index 33b69f6..8b40228 100644 --- a/butterrobot_plugins_contrib/fun.py +++ b/butterrobot_plugins_contrib/fun.py @@ -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"))) diff --git a/docker/Dockerfile b/docker/Dockerfile index 54c2ede..7c95591 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 3232582..33ff7d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "butterrobot" -version = "0.0.2a4" +version = "0.0.3" description = "What is my purpose?" authors = ["Felipe Martin "] license = "GPL-2.0"