What is my purpose?

This commit is contained in:
Felipe M 2020-04-22 23:58:06 +02:00
commit 89db0bb24d
Signed by: fmartingr
GPG key ID: 716BC147715E716F
29 changed files with 1607 additions and 0 deletions

0
butterrobot/__init__.py Normal file
View file

6
butterrobot/__main__.py Normal file
View file

@ -0,0 +1,6 @@
from butterrobot.app import app
from butterrobot.config import DEBUG
# Only used for local development!
# python -m butterrobot
app.run(debug=DEBUG, host="0.0.0.0")

60
butterrobot/app.py Normal file
View file

@ -0,0 +1,60 @@
import asyncio
import traceback
import urllib.parse
from quart import Quart, request
import structlog
import butterrobot.logging
from butterrobot.config import SLACK_TOKEN, LOG_LEVEL, ENABLED_PLUGINS
from butterrobot.plugins import get_available_plugins
from butterrobot.platforms import PLATFORMS
from butterrobot.platforms.base import Platform
logger = structlog.get_logger(__name__)
app = Quart(__name__)
available_platforms = {}
plugins = get_available_plugins()
enabled_plugins = [plugin for plugin_name, plugin in plugins.items() if plugin in ENABLED_PLUGINS]
@app.before_serving
async def init_platforms():
for platform in PLATFORMS.values():
logger.debug("Setting up", platform=platform.ID)
try:
await platform.init(app=app)
available_platforms[platform.ID] = platform
logger.info("platform setup completed", platform=platform.ID)
except platform.platformInitError as error:
logger.error(f"platform init error", error=error, platform=platform.ID)
@app.route("/<platform>/incoming", methods=["POST"])
@app.route("/<platform>/incoming/<path:path>", methods=["POST"])
async def incoming_platform_message_view(platform, path=None):
if platform not in available_platforms:
return {"error": "Unknown platform"}, 400
try:
message = await 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(f"Error parsing message", platform=platform, error=error, traceback=traceback.format_exc())
return {"error": str(error)}, 400
if not message:
return {}
for plugin in enabled_plugins:
if result := await plugin.on_message(message):
await available_platforms[platform].methods.send_message(result)
return {}
@app.route("/healthz")
def healthz():
return {}

27
butterrobot/config.py Normal file
View file

@ -0,0 +1,27 @@
import os
# --- Butter Robot -----------------------------------------------------------------
DEBUG = os.environ.get("DEBUG", "n") == "y"
HOSTNAME = os.environ.get("BUTTERROBOT_HOSTNAME", "butterrobot-dev.int.fmartingr.network")
LOG_LEVEL = os.environ.get("LOG_LEVEL", "ERROR")
ENABLED_PLUGINS = os.environ.get("ENABLED_PLUGINS", "contrib/dev/ping").split(",")
# --- PLATFORMS ---------------------------------------------------------------------
# ---
# Slack
# ---
# Slack app access token
SLACK_TOKEN = os.environ.get("SLACK_TOKEN")
# Slack app oauth access token to send messages on the bot behalf
SLACK_BOT_OAUTH_ACCESS_TOKEN = os.environ.get("SLACK_BOT_OAUTH_ACCESS_TOKEN")
# ---
# Telegram
# ---
# Telegram auth token
TELEGRAM_TOKEN = os.environ.get("TELEGRAM_TOKEN")

View file

39
butterrobot/lib/slack.py Normal file
View file

@ -0,0 +1,39 @@
from typing import Optional, Text
import aiohttp
import structlog
from butterrobot.config import SLACK_BOT_OAUTH_ACCESS_TOKEN
logger = structlog.get_logger()
class SlackAPI:
BASE_URL = "https://slack.com/api"
class SlackError(Exception):
pass
class SlackClientError(Exception):
pass
@classmethod
async def send_message(cls, platform, message, thread: Optional[Text] = None):
payload = {
"text": message,
"platform": platform,
}
if thread:
payload["thread_ts"] = thread
async with aiohttp.ClientSession() as session:
async with session.post(
f"{cls.BASE_URL}/chat.postMessage",
data=payload,
headers={"Authorization": f"Bearer {SLACK_BOT_OAUTH_ACCESS_TOKEN}"},
) as response:
response = await response.json()
if not response["ok"]:
raise cls.SlackClientError(response)

View file

@ -0,0 +1,59 @@
import aiohttp
import structlog
from butterrobot.config import TELEGRAM_TOKEN
logger = structlog.get_logger(__name__)
class TelegramAPI:
BASE_URL = f"https://api.telegram.org/bot{TELEGRAM_TOKEN}"
DEFAULT_ALLOWED_UPDATES = ["message"]
class TelegramError(Exception):
pass
class TelegramClientError(Exception):
pass
@classmethod
async def set_webhook(cls, webhook_url, max_connections=40, allowed_updates=None):
allowed_updates = allowed_updates or cls.DEFAULT_ALLOWED_UPDATES
url = f"{cls.BASE_URL}/setWebhook"
payload = {
"url": webhook_url,
"max_connections": max_connections,
"allowed_updates": allowed_updates,
}
async with aiohttp.ClientSession() as session:
async with session.post(url, json=payload) as response:
response = await response.json()
if not response["ok"]:
raise cls.TelegramClientError
@classmethod
async def send_message(
cls,
chat_id,
text,
parse_mode="markdown",
disable_web_page_preview=False,
disable_notification=False,
reply_to_message_id=None,
):
url = f"{cls.BASE_URL}/sendMessage"
payload = {
"chat_id": chat_id,
"text": text,
"parse_mode": parse_mode,
"disable_web_page_preview": disable_web_page_preview,
"disable_notification": disable_notification,
"reply_to_message_id": reply_to_message_id,
}
async with aiohttp.ClientSession() as session:
async with session.post(url, json=payload) as response:
response = await response.json()
if not response["ok"]:
raise cls.TelegramClientError(response)

23
butterrobot/logging.py Normal file
View file

@ -0,0 +1,23 @@
import logging
import structlog
from butterrobot.config import LOG_LEVEL, DEBUG
logging.basicConfig(format="%(message)s", level=LOG_LEVEL)
structlog.configure(
processors=[
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.dev.set_exc_info,
structlog.processors.StackInfoRenderer(),
structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M.%S"),
structlog.processors.format_exc_info,
structlog.dev.ConsoleRenderer() if DEBUG else structlog.processors.JSONRenderer(),
],
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.BoundLogger,
cache_logger_on_first_use=True,
)

13
butterrobot/objects.py Normal file
View file

@ -0,0 +1,13 @@
from datetime import datetime
from dataclasses import dataclass, field
from typing import Text, Optional
@dataclass
class Message:
text: Text
chat: Text
date: Optional[datetime] = None
id: Optional[Text] = None
reply_to: Optional[Text] = None
raw: dict = field(default_factory=dict)

View file

@ -0,0 +1,5 @@
from butterrobot.platforms.slack import SlackPlatform
from butterrobot.platforms.telegram import TelegramPlatform
PLATFORMS = {platform.ID: platform for platform in (SlackPlatform, TelegramPlatform,)}

View file

@ -0,0 +1,35 @@
from abc import abstractclassmethod
from dataclasses import dataclass
class Platform:
class PlatformError(Exception):
pass
class PlatformInitError(PlatformError):
pass
class PlatformAuthError(PlatformError):
pass
@dataclass
class PlatformAuthResponse(PlatformError):
"""
Used when the platform needs to make a response right away instead of async.
"""
data: dict
status_code: int = 200
@classmethod
async def init(cls, app):
pass
class PlatformMethods:
@abstractclassmethod
def send_message(cls, message):
pass
@abstractclassmethod
def reply_message(cls, message, reply_to):
pass

View file

@ -0,0 +1,70 @@
from datetime import datetime
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.lib.slack import SlackAPI
logger = structlog.get_logger(__name__)
class SlackMethods(PlatformMethods):
@classmethod
async def send_message(self, message: Message):
logger.debug(
"Outgoing message", message=message.__dict__, platform=SlackPlatform.ID
)
try:
await SlackAPI.send_message(
platform=message.chat, message=message.text, thread=message.reply_to
)
except SlackAPI.SlackClientError as error:
logger.error(
"Send message error",
platform=SlackPlatform.ID,
error=error,
message=message.__dict__,
)
class SlackPlatform(Platform):
ID = "slack"
methods = SlackMethods
@classmethod
async def init(cls, app):
if not (SLACK_TOKEN and SLACK_BOT_OAUTH_ACCESS_TOKEN):
logger.error("Missing token. platform not enabled.", platform=cls.ID)
return
@classmethod
async def parse_incoming_message(cls, request):
data = await request.get_json()
logger.debug("Parsing message", platform=cls.ID, data=data)
# Auth
if data.get("token") != SLACK_TOKEN:
raise cls.PlatformAuthError("Authentication error")
# Confirms challenge request to configure webhook
if "challenge" in data:
raise cls.PlatformAuthResponse(data={"challenge": data["challenge"]})
# Discard messages by bots
if "bot_id" in data["event"]:
return
if data["event"]["type"] != "message":
return
return Message(
id=data["event"].get("thread_ts", data["event"]["ts"]),
date=datetime.fromtimestamp(int(float(data["event"]["event_ts"]))),
text=data["event"]["text"],
chat=data["event"]["platform"],
raw=data,
)

View file

@ -0,0 +1,66 @@
from datetime import datetime
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
logger = structlog.get_logger(__name__)
class TelegramMethods(PlatformMethods):
@classmethod
async def send_message(self, message: Message):
logger.debug(
"Outgoing message", message=message.__dict__, platform=TelegramPlatform.ID
)
await TelegramAPI.send_message(
chat_id=message.chat,
text=message.text,
reply_to_message_id=message.reply_to,
)
class TelegramPlatform(Platform):
ID = "telegram"
methods = TelegramMethods
@classmethod
async def init(cls, app):
"""
Initializes the Telegram webhook endpoint to receive updates
"""
if not TELEGRAM_TOKEN:
logger.error("Missing token. platform not enabled.", platform=cls.ID)
return
webhook_url = f"https://{HOSTNAME}/telegram/incoming/{TELEGRAM_TOKEN}"
try:
await TelegramAPI.set_webhook(webhook_url)
except TelegramAPI.TelegramError as error:
logger.error(f"Error setting Telegram webhook: {error}", platform=cls.ID)
raise Platform.PlatformInitError()
@classmethod
async def parse_incoming_message(cls, request):
token = request.path.split("/")[-1]
if token != TELEGRAM_TOKEN:
raise cls.PlatformAuthError("Authentication error")
request_data = await request.get_json()
logger.debug("Parsing message", data=request_data, platform=cls.ID)
if "text" in request_data["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"]),
chat=str(request_data["message"]["chat"]["id"]),
raw=request_data,
)

37
butterrobot/plugins.py Normal file
View file

@ -0,0 +1,37 @@
import traceback
import pkg_resources
from abc import abstractclassmethod
import structlog
logger = structlog.get_logger(__name__)
class Plugin:
@abstractclassmethod
def on_message(cls, message):
pass
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()
plugins[plugin_cls.id] = plugin_cls
except Exception as error:
logger.error(
"Error loading plugin",
exception=str(error),
traceback=traceback.format_exc(),
plugin=ep.name,
project_name=ep.dist.project_name,
entry_point=ep,
module=ep.module_name,
)
logger.info(f"Plugins loaded", plugins=list(plugins.keys()))
return plugins