Added admin interface to manage channels and enabled plugins (#9)
* Added base admin login/logout flows * Ignore local database * Channel model * Admin interface for channels and plugins * Added database tests along with workflows * Added some docstrings * Ignore .coverage file * Creating plugins docs WIP * Documentation * Black everything * Some documentation * Coverage for the plugins package as well * DB Fixes * Absolute FROM in Dockerfile * Database and logging fixes * Slack: Support private channels * Added pre-commit * black'd * Fixed UserQuery.create * Fixed ChannelPluginQuery.create exists call * Added ChannelPlugin menu for debugging * Ignore sqlite databases * Updated contributing docs
This commit is contained in:
parent
456d144a7d
commit
57b413dd1b
45 changed files with 2210 additions and 421 deletions
|
@ -1,4 +1,4 @@
|
||||||
# For information about this variables check config.py
|
# For information about this variables check butterrobot/config.py
|
||||||
|
|
||||||
SLACK_TOKEN=xxx
|
SLACK_TOKEN=xxx
|
||||||
TELEGRAM_TOKEN=xxx
|
TELEGRAM_TOKEN=xxx
|
||||||
|
|
27
.github/workflows/black.yaml
vendored
Normal file
27
.github/workflows/black.yaml
vendored
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
name: Black
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ master, stable ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ master, stable ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
black:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: 3.8
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install black
|
||||||
|
|
||||||
|
- name: Black check
|
||||||
|
run: |
|
||||||
|
black --check butterrobot
|
32
.github/workflows/pytest.yaml
vendored
Normal file
32
.github/workflows/pytest.yaml
vendored
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
name: Pytest
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ master, stable ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ master, stable ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pytest:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: [3.8]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
pip install --upgrade pip poetry
|
||||||
|
poetry install
|
||||||
|
|
||||||
|
- name: Test with pytest
|
||||||
|
run: |
|
||||||
|
ls
|
||||||
|
poetry run pytest --cov=butterrobot --cov=butterrobot_plugins_contrib
|
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -5,6 +5,7 @@ __pycache__
|
||||||
*.cert
|
*.cert
|
||||||
.env-local
|
.env-local
|
||||||
test.py
|
test.py
|
||||||
|
.coverage
|
||||||
|
|
||||||
# Distribution
|
# Distribution
|
||||||
dist
|
dist
|
||||||
|
@ -12,4 +13,7 @@ dist
|
||||||
pip-wheel-metadata
|
pip-wheel-metadata
|
||||||
|
|
||||||
# Github Codespaces
|
# Github Codespaces
|
||||||
pythonenv3.8
|
pythonenv3.8
|
||||||
|
|
||||||
|
# Butterrobot
|
||||||
|
*.sqlite*
|
||||||
|
|
22
.pre-commit-config.yaml
Normal file
22
.pre-commit-config.yaml
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v2.2.3
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: flake8
|
||||||
|
|
||||||
|
- repo: https://github.com/asottile/seed-isort-config
|
||||||
|
rev: v1.9.2
|
||||||
|
hooks:
|
||||||
|
- id: seed-isort-config
|
||||||
|
- repo: https://github.com/pre-commit/mirrors-isort
|
||||||
|
rev: v4.3.20
|
||||||
|
hooks:
|
||||||
|
- id: isort
|
||||||
|
|
||||||
|
- repo: https://github.com/ambv/black
|
||||||
|
rev: stable
|
||||||
|
hooks:
|
||||||
|
- id: black
|
||||||
|
language_version: python3
|
|
@ -1,20 +1,26 @@
|
||||||
FROM alpine:3.11
|
FROM docker.io/library/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"]
|
||||||
|
|
3
Makefile
3
Makefile
|
@ -19,6 +19,9 @@ podman@dev:
|
||||||
make podman@tag-dev
|
make podman@tag-dev
|
||||||
make podman@push-dev
|
make podman@push-dev
|
||||||
|
|
||||||
|
test:
|
||||||
|
poetry run pytest --cov=butterrobot --cov=butterrobot_plugins_contrib
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf dist
|
rm -rf dist
|
||||||
rm -rf butterrobot.egg-info
|
rm -rf butterrobot.egg-info
|
||||||
|
|
34
README.md
34
README.md
|
@ -1,7 +1,9 @@
|
||||||
# Butter Robot
|
# Butter Robot
|
||||||
|
|
||||||

|
| Stable | Master |
|
||||||

|
| --- | --- |
|
||||||
|
|  |  |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
Python framework to create bots for several platforms.
|
Python framework to create bots for several platforms.
|
||||||
|
|
||||||
|
@ -9,25 +11,9 @@ Python framework to create bots for several platforms.
|
||||||
|
|
||||||
> What is my purpose?
|
> What is my purpose?
|
||||||
|
|
||||||
## Supported platforms
|
## Documentation
|
||||||
|
|
||||||
| Name | Receive messages | Send messages |
|
[Go to documentation](./docs)
|
||||||
| --------------- | ---------------- | ------------- |
|
|
||||||
| Slack (app) | Yes | Yes |
|
|
||||||
| Telegram | Yes | Yes |
|
|
||||||
|
|
||||||
## Provided plugins
|
|
||||||
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
- `!ping`: Say `!ping` to get response with time elapsed.
|
|
||||||
|
|
||||||
### Fun and entertainment
|
|
||||||
|
|
||||||
|
|
||||||
- Lo quito: What happens when you say _"lo quito"_...? (Spanish pun)
|
|
||||||
- Dice: Put `!dice` and wathever roll you want to perform.
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
@ -44,12 +30,11 @@ $ python -m butterrobot
|
||||||
|
|
||||||
### Containers
|
### Containers
|
||||||
|
|
||||||
The `fmartingr/butterrobot/butterrobot` container image is published on Github packages to
|
The `fmartingr/butterrobot/butterrobot` container image is published on Github packages to use with your favourite tool:
|
||||||
use with your favourite tool:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
docker pull docker.pkg.github.com/fmartingr/butterrobot/butterrobot:latest
|
docker pull docker.pkg.github.com/fmartingr/butterrobot/butterrobot:latest
|
||||||
podman run -d --name fmartingr/butterrobot/butterrobot -p 8080:8080
|
podman run -d --name fmartingr/butterrobot/butterrobot -p 8080:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
@ -62,8 +47,7 @@ cd butterrobot
|
||||||
poetry install
|
poetry install
|
||||||
```
|
```
|
||||||
|
|
||||||
Create a `.env-local` file with the required environment variables,
|
Create a `.env-local` file with the required environment variables, you have [an example file](.env-example).
|
||||||
you have [an example file](.env-example).
|
|
||||||
|
|
||||||
```
|
```
|
||||||
SLACK_TOKEN=xxx
|
SLACK_TOKEN=xxx
|
||||||
|
|
0
butterrobot/admin/__init__.py
Normal file
0
butterrobot/admin/__init__.py
Normal file
156
butterrobot/admin/blueprint.py
Normal file
156
butterrobot/admin/blueprint.py
Normal file
|
@ -0,0 +1,156 @@
|
||||||
|
import os.path
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from flask import (
|
||||||
|
Blueprint,
|
||||||
|
g,
|
||||||
|
flash,
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
url_for,
|
||||||
|
redirect,
|
||||||
|
render_template,
|
||||||
|
)
|
||||||
|
|
||||||
|
from butterrobot.db import UserQuery, ChannelQuery, ChannelPluginQuery
|
||||||
|
from butterrobot.plugins import get_available_plugins
|
||||||
|
|
||||||
|
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(id=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):
|
||||||
|
return redirect(url_for("admin.login_view"))
|
||||||
|
return redirect(url_for("admin.channel_list_view"))
|
||||||
|
|
||||||
|
|
||||||
|
@admin.route("/login", methods=["GET", "POST"])
|
||||||
|
def login_view():
|
||||||
|
error = None
|
||||||
|
if request.method == "POST":
|
||||||
|
user = UserQuery.check_credentials(
|
||||||
|
request.form["username"], request.form["password"]
|
||||||
|
)
|
||||||
|
if not user:
|
||||||
|
flash("Incorrect credentials", category="danger")
|
||||||
|
else:
|
||||||
|
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("/logout")
|
||||||
|
@login_required
|
||||||
|
def logout_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():
|
||||||
|
return render_template("plugin_list.j2", plugins=get_available_plugins().values())
|
||||||
|
|
||||||
|
|
||||||
|
@admin.route("/channels")
|
||||||
|
@login_required
|
||||||
|
def channel_list_view():
|
||||||
|
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=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def channel_plugin_list_view():
|
||||||
|
if request.method == "POST":
|
||||||
|
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"))
|
||||||
|
|
||||||
|
channel_plugins = ChannelPluginQuery.all()
|
||||||
|
return render_template("channel_plugins_list.j2", channel_plugins=channel_plugins)
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
flash("Plugin removed", category="success")
|
||||||
|
return redirect(request.headers.get("Referer"))
|
122
butterrobot/admin/templates/_base.j2
Normal file
122
butterrobot/admin/templates/_base.j2
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
<!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>
|
||||||
|
<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>
|
||||||
|
<li class="nav-item {% if '/channelplugins' in request.url %}active{% endif %}">
|
||||||
|
<a class="nav-link" href="{{ url_for('admin.channel_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">
|
||||||
|
Channel 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>
|
140
butterrobot/admin/templates/channel_detail.j2
Normal file
140
butterrobot/admin/templates/channel_detail.j2
Normal 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 %}
|
45
butterrobot/admin/templates/channel_list.j2
Normal file
45
butterrobot/admin/templates/channel_list.j2
Normal 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 %}
|
41
butterrobot/admin/templates/channel_plugins_list.j2
Normal file
41
butterrobot/admin/templates/channel_plugins_list.j2
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
{% 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>ID</th>
|
||||||
|
<th>Channel ID</th>
|
||||||
|
<th>Plugin ID</th>
|
||||||
|
<th>Enabled</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{% for channel_plugin in channel_plugins %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ channel_plugin.id }}</td>
|
||||||
|
<td>{{ channel_plugin.channel_id }}</td>
|
||||||
|
<td class="text-muted">
|
||||||
|
{{ channel_plugin.plugin_id }}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted">{{ channel_plugin.enabled }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
5
butterrobot/admin/templates/index.j2
Normal file
5
butterrobot/admin/templates/index.j2
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{% extends "_base.j2" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{% endblock %}
|
32
butterrobot/admin/templates/login.j2
Normal file
32
butterrobot/admin/templates/login.j2
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
{% extends "_base.j2" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<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 %}
|
33
butterrobot/admin/templates/plugin_list.j2
Normal file
33
butterrobot/admin/templates/plugin_list.j2
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
{% 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">
|
||||||
|
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 %}
|
|
@ -1,69 +1,35 @@
|
||||||
import traceback
|
import asyncio
|
||||||
|
|
||||||
from flask import Flask, request
|
|
||||||
import structlog
|
import structlog
|
||||||
|
from flask import Flask, request
|
||||||
|
|
||||||
import butterrobot.logging # noqa
|
import butterrobot.logging # noqa
|
||||||
from butterrobot.config import ENABLED_PLUGINS
|
from butterrobot.http import ExternalProxyFix
|
||||||
from butterrobot.objects import Message
|
from butterrobot.queue import q
|
||||||
from butterrobot.plugins import get_available_plugins
|
from butterrobot.config import SECRET_KEY
|
||||||
from butterrobot.platforms import PLATFORMS
|
from butterrobot.platforms import get_available_platforms
|
||||||
from butterrobot.platforms.base import Platform
|
from butterrobot.admin.blueprint import admin as admin_bp
|
||||||
|
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
available_platforms = {}
|
app.config.update(SECRET_KEY=SECRET_KEY)
|
||||||
plugins = get_available_plugins()
|
app.register_blueprint(admin_bp)
|
||||||
enabled_plugins = [
|
app.wsgi_app = ExternalProxyFix(app.wsgi_app)
|
||||||
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(
|
||||||
message = available_platforms[platform].parse_incoming_message(
|
{
|
||||||
request=request
|
"platform": platform,
|
||||||
)
|
"request": {"path": request.path, "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 {}
|
||||||
|
|
||||||
|
|
|
@ -3,12 +3,16 @@ import os
|
||||||
# --- Butter Robot -----------------------------------------------------------------
|
# --- Butter Robot -----------------------------------------------------------------
|
||||||
DEBUG = os.environ.get("DEBUG", "n") == "y"
|
DEBUG = os.environ.get("DEBUG", "n") == "y"
|
||||||
|
|
||||||
HOSTNAME = os.environ.get("BUTTERROBOT_HOSTNAME", "butterrobot-dev.int.fmartingr.network")
|
HOSTNAME = os.environ.get(
|
||||||
|
"BUTTERROBOT_HOSTNAME", "butterrobot-dev.int.fmartingr.network"
|
||||||
|
)
|
||||||
|
|
||||||
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")
|
||||||
|
|
||||||
|
# --- DATABASE ---------------------------------------------------------------------
|
||||||
|
DATABASE_PATH = os.environ.get("DATABASE_PATH", "sqlite:///butterrobot.sqlite")
|
||||||
|
|
||||||
# --- PLATFORMS ---------------------------------------------------------------------
|
# --- PLATFORMS ---------------------------------------------------------------------
|
||||||
# ---
|
# ---
|
||||||
|
|
163
butterrobot/db.py
Normal file
163
butterrobot/db.py
Normal file
|
@ -0,0 +1,163 @@
|
||||||
|
import hashlib
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
import dataset
|
||||||
|
|
||||||
|
from butterrobot.config import SECRET_KEY, DATABASE_PATH
|
||||||
|
from butterrobot.objects import User, Channel, ChannelPlugin
|
||||||
|
|
||||||
|
db = dataset.connect(DATABASE_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
class Query:
|
||||||
|
class NotFound(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Duplicated(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def all(cls):
|
||||||
|
"""
|
||||||
|
Iterate over all rows on a table.
|
||||||
|
"""
|
||||||
|
for row in db[cls.tablename].all():
|
||||||
|
yield cls.obj(**row)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls, **kwargs):
|
||||||
|
"""
|
||||||
|
Returns the object representation of an specific row in a table.
|
||||||
|
Allows retrieving object by multiple columns.
|
||||||
|
Raises `NotFound` error if query return no results.
|
||||||
|
"""
|
||||||
|
row = db[cls.tablename].find_one(**kwargs)
|
||||||
|
if not row:
|
||||||
|
raise cls.NotFound
|
||||||
|
return cls.obj(**row)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, **kwargs):
|
||||||
|
"""
|
||||||
|
Creates a new row in the table with the provided arguments.
|
||||||
|
Returns the row_id
|
||||||
|
TODO: Return obj?
|
||||||
|
"""
|
||||||
|
return db[cls.tablename].insert(kwargs)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def exists(cls, **kwargs) -> bool:
|
||||||
|
"""
|
||||||
|
Check for the existence of a row with the provided columns.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cls.get(**kwargs)
|
||||||
|
except cls.NotFound:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def update(cls, row_id, **fields):
|
||||||
|
fields.update({"id": row_id})
|
||||||
|
return db[cls.tablename].update(fields, ("id",))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def delete(cls, id):
|
||||||
|
return db[cls.tablename].delete(id=id)
|
||||||
|
|
||||||
|
|
||||||
|
class UserQuery(Query):
|
||||||
|
tablename = "users"
|
||||||
|
obj = User
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _hash_password(cls, password):
|
||||||
|
return hashlib.pbkdf2_hmac(
|
||||||
|
"sha256", password.encode("utf-8"), str.encode(SECRET_KEY), 100000
|
||||||
|
).hex()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def check_credentials(cls, username, password) -> Union[User, "False"]:
|
||||||
|
user = db[cls.tablename].find_one(username=username)
|
||||||
|
if user:
|
||||||
|
hash_password = cls._hash_password(password)
|
||||||
|
if user["password"] == hash_password:
|
||||||
|
return cls.obj(**user)
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, **kwargs):
|
||||||
|
kwargs["password"] = cls._hash_password(kwargs["password"])
|
||||||
|
return super().create(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelQuery(Query):
|
||||||
|
tablename = "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,
|
||||||
|
}
|
||||||
|
super().create(**params)
|
||||||
|
return cls.obj(**params)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls, _id):
|
||||||
|
channel = super().get(id=_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 = db[cls.tablename].find_one(
|
||||||
|
platform=platform, platform_channel_id=platform_channel_id
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
raise cls.NotFound
|
||||||
|
|
||||||
|
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, _id):
|
||||||
|
ChannelPluginQuery.delete_by_channel(channel_id=_id)
|
||||||
|
super().delete(_id)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelPluginQuery(Query):
|
||||||
|
tablename = "channel_plugin"
|
||||||
|
obj = ChannelPlugin
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, channel_id, plugin_id, enabled=False, config={}):
|
||||||
|
if cls.exists(channel_id=channel_id, plugin_id=plugin_id):
|
||||||
|
raise cls.Duplicated
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"plugin_id": plugin_id,
|
||||||
|
"enabled": enabled,
|
||||||
|
"config": config,
|
||||||
|
}
|
||||||
|
obj_id = super().create(**params)
|
||||||
|
return cls.obj(id=obj_id, **params)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_from_channel_id(cls, channel_id):
|
||||||
|
yield from [
|
||||||
|
cls.obj(**row) for row in db[cls.tablename].find(channel_id=channel_id)
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def delete_by_channel(cls, channel_id):
|
||||||
|
channel_plugins = cls.get_from_channel_id(channel_id)
|
||||||
|
[cls.delete(item.id) for item in channel_plugins]
|
15
butterrobot/http.py
Normal file
15
butterrobot/http.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
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)
|
|
@ -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,11 +53,9 @@ 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"]:
|
||||||
raise cls.SlackClientError(response_json)
|
raise cls.SlackClientError(response_json)
|
||||||
|
|
|
@ -51,8 +51,8 @@ class TelegramAPI:
|
||||||
"disable_notification": disable_notification,
|
"disable_notification": disable_notification,
|
||||||
"reply_to_message_id": reply_to_message_id,
|
"reply_to_message_id": reply_to_message_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(url, json=payload)
|
response = requests.post(url, json=payload)
|
||||||
response_json = response.json()
|
response_json = response.json()
|
||||||
if not response_json["ok"]:
|
if not response_json["ok"]:
|
||||||
raise cls.TelegramClientError(response_json)
|
raise cls.TelegramClientError(response_json)
|
||||||
|
|
|
@ -14,7 +14,9 @@ structlog.configure(
|
||||||
structlog.processors.StackInfoRenderer(),
|
structlog.processors.StackInfoRenderer(),
|
||||||
structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M.%S"),
|
structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M.%S"),
|
||||||
structlog.processors.format_exc_info,
|
structlog.processors.format_exc_info,
|
||||||
structlog.dev.ConsoleRenderer() if DEBUG else structlog.processors.JSONRenderer(),
|
structlog.dev.ConsoleRenderer()
|
||||||
|
if DEBUG
|
||||||
|
else structlog.processors.JSONRenderer(),
|
||||||
],
|
],
|
||||||
context_class=dict,
|
context_class=dict,
|
||||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||||
|
|
|
@ -1,15 +1,61 @@
|
||||||
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.debug("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
|
||||||
|
|
|
@ -1,6 +1,30 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
PLATFORMS = {platform.ID: platform for platform in (SlackPlatform, TelegramPlatform, 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
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from abc import abstractclassmethod
|
from abc import abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@ -17,19 +17,51 @@ class Platform:
|
||||||
"""
|
"""
|
||||||
Used when the platform needs to make a response right away instead of async.
|
Used when the platform needs to make a response right away instead of async.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
data: dict
|
data: dict
|
||||||
status_code: int = 200
|
status_code: int = 200
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def init(cls, app):
|
def init(cls, app):
|
||||||
|
"""
|
||||||
|
Initialises the platform.
|
||||||
|
|
||||||
|
Used at the application launch to prepare anything required for
|
||||||
|
the platform to work..
|
||||||
|
|
||||||
|
It receives the flask application via parameter in case the platform
|
||||||
|
requires for custom webservice endpoints or configuration.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
|
def parse_incoming_message(cls, request):
|
||||||
|
"""
|
||||||
|
Parses the incoming request and returns a :class:`butterrobot.objects.Message` instance.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
|
def parse_channel_name_from_raw(cls, channel_raw) -> str:
|
||||||
|
"""
|
||||||
|
Extracts the Channel name from :class:`butterrobot.objects.Channel.channel_raw`.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
|
def parse_channel_from_message(cls, channel_raw):
|
||||||
|
"""
|
||||||
|
Extracts the Channel raw data from the message received in the incoming webhook.
|
||||||
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PlatformMethods:
|
class PlatformMethods:
|
||||||
@abstractclassmethod
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
def send_message(cls, message):
|
def send_message(cls, message):
|
||||||
pass
|
"""Method used to send a message via the platform"""
|
||||||
|
|
||||||
@abstractclassmethod
|
|
||||||
def reply_message(cls, message, reply_to):
|
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -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,10 @@ 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={},
|
||||||
)
|
)
|
||||||
|
|
|
@ -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:
|
||||||
|
@ -58,16 +76,30 @@ class SlackPlatform(Platform):
|
||||||
logger.debug("Discarding message", data=data)
|
logger.debug("Discarding message", data=data)
|
||||||
return
|
return
|
||||||
|
|
||||||
if data["event"]["type"] != "message":
|
logger.debug("Parsing message", platform=cls.ID, data=data)
|
||||||
|
|
||||||
|
if data["event"]["type"] not in ("message", "message.groups"):
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.debug("Parsing message", platform=cls.ID, data=data)
|
# Surprisingly, this *can* happen.
|
||||||
return Message(
|
if "text" not in data["event"]:
|
||||||
|
return
|
||||||
|
|
||||||
|
message = Message(
|
||||||
id=data["event"].get("thread_ts", data["event"]["ts"]),
|
id=data["event"].get("thread_ts", data["event"]["ts"]),
|
||||||
author=data["event"]["user"],
|
author=data["event"].get("user"),
|
||||||
from_bot="bot_id" in data["event"],
|
from_bot="bot_id" in data["event"],
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"New message",
|
||||||
|
platform=message.channel.platform,
|
||||||
|
channel=cls.parse_channel_name_from_raw(message.channel.channel_raw),
|
||||||
|
)
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
|
@ -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,42 @@ 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"],
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,24 +1,54 @@
|
||||||
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
|
||||||
|
|
||||||
from butterrobot.objects import Message
|
from butterrobot.objects import Message
|
||||||
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Plugin:
|
class Plugin:
|
||||||
|
"""
|
||||||
|
Base Plugin class.
|
||||||
|
|
||||||
|
All attributes are required except for `requires_config`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""
|
||||||
|
Function called for each message received on the chat.
|
||||||
|
|
||||||
|
It should exit as soon as possible (usually checking for a keyword or something)
|
||||||
|
similar just at the start.
|
||||||
|
|
||||||
|
If the plugin needs to be executed (keyword matches), keep it as fast as possible
|
||||||
|
as this currently blocks the execution of the rest of the plugins on the channel
|
||||||
|
until this does not finish.
|
||||||
|
TODO: Update this once we go proper async plugin/message integration
|
||||||
|
|
||||||
|
In case something needs to be answered to the channel, you can `yield` a `Message`
|
||||||
|
instance and it will be relayed using the appropriate provider.
|
||||||
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
def get_available_plugins():
|
def get_available_plugins():
|
||||||
"""Retrieves every available plugin"""
|
"""
|
||||||
|
Retrieves every available auto discovered 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 +64,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
|
||||||
|
|
64
butterrobot/queue.py
Normal file
64
butterrobot/queue.py
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
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()
|
|
@ -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
|
||||||
|
|
|
@ -1,34 +1,51 @@
|
||||||
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")),
|
||||||
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
8
docs/README.md
Normal file
8
docs/README.md
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
# Butterrobot Documentation
|
||||||
|
|
||||||
|
## Index
|
||||||
|
- [Contributing](./contributing.md)
|
||||||
|
- [Platforms](./platforms.md)
|
||||||
|
- Plugins
|
||||||
|
- [Creating a Plugin](./creating-a-plugin.md)
|
||||||
|
- [Provided plugins](./plugins.md)
|
23
docs/contributing.md
Normal file
23
docs/contributing.md
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
To run the project locally you will need [poetry](https://python-poetry.org/).
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone git@github.com:fmartingr/butterrobot.git
|
||||||
|
cd butterrobot
|
||||||
|
make setup
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a `.env-local` file with the required environment variables, you have [an example file](.env-example).
|
||||||
|
|
||||||
|
```
|
||||||
|
SLACK_TOKEN=xxx
|
||||||
|
TELEGRAM_TOKEN=xxx
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
And then you can run it directly with poetry:
|
||||||
|
|
||||||
|
```
|
||||||
|
poetry run python -m butterrobot
|
||||||
|
```
|
37
docs/creating-a-plugin.md
Normal file
37
docs/creating-a-plugin.md
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
# Creating a Plugin
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
This simple "Marco Polo" plugin will answer _Polo_ to the user that say _Marco_:
|
||||||
|
|
||||||
|
``` python
|
||||||
|
# mypackage/plugins.py
|
||||||
|
from butterrobot.plugins import Plugin
|
||||||
|
from butterrobot.objects import Message
|
||||||
|
|
||||||
|
|
||||||
|
class PingPlugin(Plugin):
|
||||||
|
name = "Marco/Polo"
|
||||||
|
id = "test.marco"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def on_message(cls, message, **kwargs):
|
||||||
|
if message.text == "Marco":
|
||||||
|
yield Message(
|
||||||
|
chat=message.chat, reply_to=message.id, text=f"polo",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
``` python
|
||||||
|
# setup.py
|
||||||
|
# ...
|
||||||
|
entrypoints = {
|
||||||
|
"test.marco" = "mypackage.plugins:MarcoPlugin"
|
||||||
|
}
|
||||||
|
|
||||||
|
setup(
|
||||||
|
# ...
|
||||||
|
entry_points=entrypoints,
|
||||||
|
# ...
|
||||||
|
)
|
||||||
|
```
|
8
docs/platforms.md
Normal file
8
docs/platforms.md
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
## Supported platforms
|
||||||
|
|
||||||
|
TODO: Create better actions matrix
|
||||||
|
|
||||||
|
| Name | Receive messages | Send messages |
|
||||||
|
| --------------- | ---------------- | ------------- |
|
||||||
|
| Slack (app) | Yes | Yes |
|
||||||
|
| Telegram | Yes | Yes |
|
11
docs/plugins.md
Normal file
11
docs/plugins.md
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
## Provided plugins
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
- `!ping`: Say `!ping` to get response with time elapsed.
|
||||||
|
|
||||||
|
### Fun and entertainment
|
||||||
|
|
||||||
|
|
||||||
|
- Lo quito: What happens when you say _"lo quito"_...? (Spanish pun)
|
||||||
|
- Dice: Put `!dice` and wathever roll you want to perform.
|
1038
poetry.lock
generated
1038
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -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"
|
||||||
|
@ -19,6 +19,7 @@ dice = "^3.1.0"
|
||||||
flask = "^1.1.2"
|
flask = "^1.1.2"
|
||||||
requests = "^2.24.0"
|
requests = "^2.24.0"
|
||||||
waitress = "^1.4.4"
|
waitress = "^1.4.4"
|
||||||
|
dataset = "^1.3.2"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
black = "^19.10b0"
|
black = "^19.10b0"
|
||||||
|
@ -26,6 +27,9 @@ flake8 = "^3.7.9"
|
||||||
rope = "^0.16.0"
|
rope = "^0.16.0"
|
||||||
isort = "^4.3.21"
|
isort = "^4.3.21"
|
||||||
ipdb = "^0.13.2"
|
ipdb = "^0.13.2"
|
||||||
|
pytest = "^6.1.2"
|
||||||
|
pytest-cov = "^2.10.1"
|
||||||
|
pre-commit = "^2.10.0"
|
||||||
|
|
||||||
[tool.poetry.plugins]
|
[tool.poetry.plugins]
|
||||||
[tool.poetry.plugins."butterrobot.plugins"]
|
[tool.poetry.plugins."butterrobot.plugins"]
|
||||||
|
|
|
@ -11,6 +11,6 @@ include_trailing_comma = True
|
||||||
length_sort = 1
|
length_sort = 1
|
||||||
lines_between_types = 0
|
lines_between_types = 0
|
||||||
line_length = 88
|
line_length = 88
|
||||||
known_third_party = click,django,docker,factory,pydantic,pytest,requests,toml
|
known_third_party = dataset,dice,flask,pkg_resources,pytest,requests,structlog
|
||||||
sections = FUTURE, STDLIB, DJANGO, THIRDPARTY, FIRSTPARTY, LOCALFOLDER
|
sections = FUTURE, STDLIB, DJANGO, THIRDPARTY, FIRSTPARTY, LOCALFOLDER
|
||||||
no_lines_before = LOCALFOLDER
|
no_lines_before = LOCALFOLDER
|
||||||
|
|
109
tests/test_db.py
Normal file
109
tests/test_db.py
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import os.path
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import dataset
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from butterrobot import db
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DummyItem:
|
||||||
|
id: int
|
||||||
|
foo: str
|
||||||
|
|
||||||
|
|
||||||
|
class DummyQuery(db.Query):
|
||||||
|
tablename = "dummy"
|
||||||
|
obj = DummyItem
|
||||||
|
|
||||||
|
|
||||||
|
class MockDatabase:
|
||||||
|
def __init__(self):
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
db_path = os.path.join(self.temp_dir.name, "db.sqlite")
|
||||||
|
db.db = dataset.connect(f"sqlite:///{db_path}")
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_create_ok():
|
||||||
|
with MockDatabase():
|
||||||
|
assert DummyQuery.create(foo="bar")
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_delete_ok():
|
||||||
|
with MockDatabase():
|
||||||
|
item_id = DummyQuery.create(foo="bar")
|
||||||
|
assert DummyQuery.delete(item_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_exists_by_id_ok():
|
||||||
|
with MockDatabase():
|
||||||
|
assert not DummyQuery.exists(id=1)
|
||||||
|
item_id = DummyQuery.create(foo="bar")
|
||||||
|
assert DummyQuery.exists(id=item_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_exists_by_attribute_ok():
|
||||||
|
with MockDatabase():
|
||||||
|
assert not DummyQuery.exists(id=1)
|
||||||
|
item_id = DummyQuery.create(foo="bar")
|
||||||
|
assert DummyQuery.exists(foo="bar")
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_get_ok():
|
||||||
|
with MockDatabase():
|
||||||
|
item_id = DummyQuery.create(foo="bar")
|
||||||
|
item = DummyQuery.get(id=item_id)
|
||||||
|
assert item.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_all_ok():
|
||||||
|
with MockDatabase():
|
||||||
|
assert len(list(DummyQuery.all())) == 0
|
||||||
|
[DummyQuery.create(foo="bar") for i in range(0, 3)]
|
||||||
|
assert len(list(DummyQuery.all())) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_ok():
|
||||||
|
with MockDatabase():
|
||||||
|
expected = "bar2"
|
||||||
|
item_id = DummyQuery.create(foo="bar")
|
||||||
|
assert DummyQuery.update(item_id, foo=expected)
|
||||||
|
item = DummyQuery.get(id=item_id)
|
||||||
|
assert item.foo == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_sets_password_ok():
|
||||||
|
password = "password"
|
||||||
|
with MockDatabase():
|
||||||
|
user_id = db.UserQuery.create(username="foo", password=password)
|
||||||
|
user = db.UserQuery.get(id=user_id)
|
||||||
|
assert user.password == db.UserQuery._hash_password(password)
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_check_credentials_ok():
|
||||||
|
with MockDatabase():
|
||||||
|
username = "foo"
|
||||||
|
password = "bar"
|
||||||
|
user_id = db.UserQuery.create(username=username, password=password)
|
||||||
|
user = db.UserQuery.get(id=user_id)
|
||||||
|
user = db.UserQuery.check_credentials(username, password)
|
||||||
|
assert isinstance(user, db.UserQuery.obj)
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_check_credentials_ko():
|
||||||
|
with MockDatabase():
|
||||||
|
username = "foo"
|
||||||
|
password = "bar"
|
||||||
|
user_id = db.UserQuery.create(username=username, password=password)
|
||||||
|
user = db.UserQuery.get(id=user_id)
|
||||||
|
assert not db.UserQuery.check_credentials(username, "error")
|
||||||
|
assert not db.UserQuery.check_credentials("error", password)
|
||||||
|
assert not db.UserQuery.check_credentials("error", "error")
|
18
tests/test_objects.py
Normal file
18
tests/test_objects.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
from butterrobot.objects import Channel, ChannelPlugin
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_has_enabled_plugin_ok():
|
||||||
|
channel = Channel(
|
||||||
|
platform="debug",
|
||||||
|
platform_channel_id="debug",
|
||||||
|
channel_raw={},
|
||||||
|
plugins={
|
||||||
|
"enabled": ChannelPlugin(
|
||||||
|
id=1, channel_id="test", plugin_id="enabled", enabled=True
|
||||||
|
),
|
||||||
|
"existant": ChannelPlugin(id=2, channel_id="test", plugin_id="existant"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert not channel.has_enabled_plugin("non.existant")
|
||||||
|
assert not channel.has_enabled_plugin("existant")
|
||||||
|
assert channel.has_enabled_plugin("enabled")
|
Loading…
Add table
Add a link
Reference in a new issue