What is my purpose?
This commit is contained in:
commit
89db0bb24d
29 changed files with 1607 additions and 0 deletions
0
butterrobot/__init__.py
Normal file
0
butterrobot/__init__.py
Normal file
6
butterrobot/__main__.py
Normal file
6
butterrobot/__main__.py
Normal 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
60
butterrobot/app.py
Normal 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
27
butterrobot/config.py
Normal 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")
|
0
butterrobot/lib/__init__.py
Normal file
0
butterrobot/lib/__init__.py
Normal file
39
butterrobot/lib/slack.py
Normal file
39
butterrobot/lib/slack.py
Normal 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)
|
59
butterrobot/lib/telegram.py
Normal file
59
butterrobot/lib/telegram.py
Normal 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
23
butterrobot/logging.py
Normal 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
13
butterrobot/objects.py
Normal 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)
|
5
butterrobot/platforms/__init__.py
Normal file
5
butterrobot/platforms/__init__.py
Normal 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,)}
|
35
butterrobot/platforms/base.py
Normal file
35
butterrobot/platforms/base.py
Normal 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
|
70
butterrobot/platforms/slack.py
Normal file
70
butterrobot/platforms/slack.py
Normal 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,
|
||||
)
|
66
butterrobot/platforms/telegram.py
Normal file
66
butterrobot/platforms/telegram.py
Normal 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
37
butterrobot/plugins.py
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue