Initial 'rewrite' of hatbot
This commit is contained in:
parent
efef9cd7d9
commit
ac113944cd
|
@ -1,3 +1,8 @@
|
|||
# Project specific
|
||||
*.kate-swp
|
||||
*.log
|
||||
config/owncast.json
|
||||
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
# Code Attribution
|
||||
|
||||
## ameliabot.logger
|
||||
`ameliabot.logger` is based on [colargulog]() by David Ohana under the [Apache-2.0 License](). Changed for `flake8` compliance.
|
||||
|
||||
|
||||
## ameliabot.owncast
|
||||
`ameliabot.owncast` originally started as [hatbot]() by hatniX, licensed under the [Unlicense](), which this project also uses.
|
||||
|
||||
|
||||
[colargulog]:https://github.com/davidohana/colargulog
|
||||
[Apache-2.0 License]:https://www.apache.org/licenses/LICENSE-2.0.txt
|
||||
[Unlicense]:https://unlicense.org/
|
30
README.md
30
README.md
|
@ -1,3 +1,31 @@
|
|||
# ameliabot
|
||||
|
||||
Ameliabot, a chat bot for owncast.
|
||||
A chatbot for owncast, inspiration from [hatbot]() by [hatniX]().
|
||||
|
||||
Code attributions can be found in `ATTRIBUTIONS.md`
|
||||
|
||||
## Requirements
|
||||
* [Owncast]() server
|
||||
* Python 3
|
||||
* Flask
|
||||
* psycopg2
|
||||
|
||||
## Setup
|
||||
|
||||
* Install Flask and psycopg2
|
||||
* `pip --user install Flask psycopg2`
|
||||
* If you don't want to fiddle with psycopg2, you can install `python-psycopg2` from your distribution's package manager
|
||||
* Create an Owncast webhook url pointing to your bot's location
|
||||
* http://localhost:5000/webhook/owncast if bot and owncast are on the same machine
|
||||
* Copy `config-example.json` to `config.json` and fill out the information required
|
||||
|
||||
Ameliabot can be run by executing `start.sh` or:
|
||||
```
|
||||
FLASK_APP=bot.py python3 -m flask run
|
||||
```
|
||||
|
||||
Please use a proper uWSGI proxy if the bot is not on the same machine as owncast.
|
||||
|
||||
[Owncast]:https://owncast.online
|
||||
[hatbot]:https://github.com/hatniX/hatbot
|
||||
[hatniX]:https://hatnix.net
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
{}
|
|
@ -0,0 +1,30 @@
|
|||
"""
|
||||
colargulog
|
||||
Python3 Logging with Colored Arguments and new string formatting style
|
||||
|
||||
Written by david.ohana@ibm.com
|
||||
License: Apache-2.0
|
||||
"""
|
||||
import sys
|
||||
from ameliabot.logger import ColorizedArgsFormatter, BraceFormatStyleFormatter
|
||||
from ameliabot.logger import logging
|
||||
|
||||
|
||||
def init_logging():
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(logging.DEBUG)
|
||||
|
||||
console_level = "DEBUG"
|
||||
console_handler = logging.StreamHandler(stream=sys.stdout)
|
||||
console_handler.setLevel(console_level)
|
||||
console_format = "%(asctime)s - %(levelname)-8s - %(name)-25s - %(message)s" # NOQA
|
||||
colored_formatter = ColorizedArgsFormatter(console_format)
|
||||
console_handler.setFormatter(colored_formatter)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
file_handler = logging.FileHandler("app.log")
|
||||
file_level = "DEBUG"
|
||||
file_handler.setLevel(file_level)
|
||||
file_format = "%(asctime)s - %(name)s (%(lineno)s) - %(levelname)-8s - %(threadName)-12s - %(message)s" # NOQA
|
||||
file_handler.setFormatter(BraceFormatStyleFormatter(file_format))
|
||||
root_logger.addHandler(file_handler)
|
|
@ -0,0 +1,134 @@
|
|||
"""
|
||||
colargulog
|
||||
Python3 Logging with Colored Arguments and new string formatting style
|
||||
|
||||
Written by david.ohana@ibm.com
|
||||
License: Apache-2.0
|
||||
"""
|
||||
import logging
|
||||
import logging.handlers
|
||||
import re
|
||||
|
||||
|
||||
class ColorCodes:
|
||||
grey = "\x1b[38;21m"
|
||||
green = "\x1b[1;32m"
|
||||
yellow = "\x1b[33;21m"
|
||||
red = "\x1b[31;21m"
|
||||
bold_red = "\x1b[31;1m"
|
||||
blue = "\x1b[1;34m"
|
||||
light_blue = "\x1b[1;36m"
|
||||
purple = "\x1b[1;35m"
|
||||
reset = "\x1b[0m"
|
||||
|
||||
|
||||
class ColorizedArgsFormatter(logging.Formatter):
|
||||
arg_colors = [ColorCodes.purple, ColorCodes.light_blue]
|
||||
level_fields = ["levelname", "levelno"]
|
||||
level_to_color = {
|
||||
logging.DEBUG: ColorCodes.grey,
|
||||
logging.INFO: ColorCodes.green,
|
||||
logging.WARNING: ColorCodes.yellow,
|
||||
logging.ERROR: ColorCodes.red,
|
||||
logging.CRITICAL: ColorCodes.bold_red,
|
||||
}
|
||||
|
||||
def __init__(self, fmt: str):
|
||||
super().__init__()
|
||||
self.level_to_formatter = {}
|
||||
|
||||
def add_color_format(level: int):
|
||||
color = ColorizedArgsFormatter.level_to_color[level]
|
||||
_format = fmt
|
||||
for fld in ColorizedArgsFormatter.level_fields:
|
||||
search = r"(%\(" + fld + r"\).*?s)"
|
||||
_format = re.sub(search, f"{color}\\1{ColorCodes.reset}", _format) # NOQA
|
||||
formatter = logging.Formatter(_format)
|
||||
self.level_to_formatter[level] = formatter
|
||||
|
||||
add_color_format(logging.DEBUG)
|
||||
add_color_format(logging.INFO)
|
||||
add_color_format(logging.WARNING)
|
||||
add_color_format(logging.ERROR)
|
||||
add_color_format(logging.CRITICAL)
|
||||
|
||||
@staticmethod
|
||||
def rewrite_record(record: logging.LogRecord):
|
||||
if not BraceFormatStyleFormatter.is_brace_format_style(record):
|
||||
return
|
||||
|
||||
msg = record.msg
|
||||
msg = msg.replace("{", "_{{")
|
||||
msg = msg.replace("}", "_}}")
|
||||
placeholder_count = 0
|
||||
# add ANSI escape code for next alternating
|
||||
# color before each formatting parameter
|
||||
# and reset color after it.
|
||||
while True:
|
||||
if "_{{" not in msg:
|
||||
break
|
||||
color_index = placeholder_count % len(ColorizedArgsFormatter.arg_colors) # NOQA
|
||||
color = ColorizedArgsFormatter.arg_colors[color_index]
|
||||
msg = msg.replace("_{{", color + "{", 1)
|
||||
msg = msg.replace("_}}", "}" + ColorCodes.reset, 1)
|
||||
placeholder_count += 1
|
||||
|
||||
record.msg = msg.format(*record.args)
|
||||
record.args = []
|
||||
|
||||
def format(self, record):
|
||||
orig_msg = record.msg
|
||||
orig_args = record.args
|
||||
formatter = self.level_to_formatter.get(record.levelno)
|
||||
self.rewrite_record(record)
|
||||
formatted = formatter.format(record)
|
||||
|
||||
# restore log record to original state for other handlers
|
||||
record.msg = orig_msg
|
||||
record.args = orig_args
|
||||
return formatted
|
||||
|
||||
|
||||
class BraceFormatStyleFormatter(logging.Formatter):
|
||||
def __init__(self, fmt: str):
|
||||
super().__init__()
|
||||
self.formatter = logging.Formatter(fmt)
|
||||
|
||||
@staticmethod
|
||||
def is_brace_format_style(record: logging.LogRecord):
|
||||
if len(record.args) == 0:
|
||||
return False
|
||||
|
||||
msg = record.msg
|
||||
if '%' in msg:
|
||||
return False
|
||||
|
||||
count_of_start_param = msg.count("{")
|
||||
count_of_end_param = msg.count("}")
|
||||
|
||||
if count_of_start_param != count_of_end_param:
|
||||
return False
|
||||
|
||||
if count_of_start_param != len(record.args):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def rewrite_record(record: logging.LogRecord):
|
||||
if not BraceFormatStyleFormatter.is_brace_format_style(record):
|
||||
return
|
||||
|
||||
record.msg = record.msg.format(*record.args)
|
||||
record.args = []
|
||||
|
||||
def format(self, record):
|
||||
orig_msg = record.msg
|
||||
orig_args = record.args
|
||||
self.rewrite_record(record)
|
||||
formatted = self.formatter.format(record)
|
||||
|
||||
# restore log record to original state for other handlers
|
||||
record.msg = orig_msg
|
||||
record.args = orig_args
|
||||
return formatted
|
|
@ -0,0 +1,117 @@
|
|||
import html
|
||||
import json
|
||||
import random
|
||||
import requests
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
from ameliabot.logger import logging
|
||||
from ameliabot.quote import Quote
|
||||
from flask import request
|
||||
|
||||
|
||||
bot_version = "0.0.1"
|
||||
|
||||
# Get basic owncast bot config
|
||||
with open("config/owncast.json", "r") as f:
|
||||
config = json.load(f)
|
||||
logging.info("Configuration loaded")
|
||||
|
||||
with open("commands.json", "r") as f:
|
||||
commands = json.load(f)
|
||||
logging.info("Commands loaded")
|
||||
|
||||
with open("alias.json", "r") as f:
|
||||
aliases = json.load(f)
|
||||
logging.info("Aliases loaded")
|
||||
|
||||
quote = Quote(
|
||||
database=config["quote_db_name"],
|
||||
user=config["quote_db_user"],
|
||||
password=config["quote_db_pass"],
|
||||
)
|
||||
|
||||
# prepare the header for the bot posts
|
||||
headers = CaseInsensitiveDict()
|
||||
headers["Content-Type"] = "application/json"
|
||||
headers["Authorization"] = "Bearer {}".format(config["access_token"])
|
||||
|
||||
|
||||
def parse_webhook():
|
||||
hook_type = request.json["type"]
|
||||
data = request.json["eventData"]
|
||||
|
||||
if "CHAT" in hook_type:
|
||||
data = process_chat(data)
|
||||
|
||||
if data:
|
||||
logging.debug("Processed: {}".format(data))
|
||||
data = '{"body": "%s"}' % data
|
||||
resp = requests.post(
|
||||
config["owncast_server"],
|
||||
headers=headers,
|
||||
data=data.encode('utf-8'))
|
||||
|
||||
if resp.status_code == 200:
|
||||
logging.debug("RESP: {}".format(data))
|
||||
else:
|
||||
logging.error("Status code {} returned".format(
|
||||
str(resp.status_code)))
|
||||
|
||||
|
||||
def get_command(text):
|
||||
first_word = text.split()[0].lower()
|
||||
|
||||
if first_word in commands:
|
||||
return commands[first_word]
|
||||
elif first_word in aliases:
|
||||
return commands[first_word]
|
||||
|
||||
|
||||
def get_quote(num):
|
||||
try:
|
||||
num = int(num)
|
||||
except TypeError:
|
||||
pass
|
||||
except ValueError:
|
||||
pass
|
||||
q = quote.get(num)
|
||||
return html.escape(q)
|
||||
|
||||
|
||||
def process_chat(data):
|
||||
timestamp = "{} {}".format(
|
||||
data["timestamp"][0:10], data["timestamp"][11:19])
|
||||
sender = data["user"]["displayName"]
|
||||
text = data["body"]
|
||||
command_reply = get_command(text)
|
||||
|
||||
logging.info("{} <{}> {}".format(timestamp, sender, text))
|
||||
|
||||
if command_reply:
|
||||
try:
|
||||
first_parameter = text.split(" ")[1]
|
||||
except IndexError:
|
||||
first_parameter = None
|
||||
|
||||
# Move this dictionary to a configuration file or something
|
||||
# This is so bad, it runs everything every command :pikajoy:
|
||||
placeholders = {
|
||||
"sender": sender,
|
||||
"streamer": config["streamer_name"],
|
||||
"target": first_parameter,
|
||||
"random": str(random.randrange(1, 100, 1)),
|
||||
"commands": ", ".join(list(commands.keys())),
|
||||
"aliases": ", ".join(list(aliases.keys())),
|
||||
"bot_version": bot_version,
|
||||
"quote": get_quote(first_parameter),
|
||||
"quote_parameters": "Not implemented yet",
|
||||
}
|
||||
|
||||
for placeholder in placeholders:
|
||||
findme = "{%s}" % placeholder
|
||||
if findme in command_reply:
|
||||
if placeholders[placeholder] == "":
|
||||
command_reply = "%s required" % placeholder.upper()
|
||||
command_reply = command_reply.replace(
|
||||
findme, placeholders[placeholder])
|
||||
|
||||
return command_reply
|
|
@ -0,0 +1,56 @@
|
|||
from datetime import datetime
|
||||
import random
|
||||
import psycopg2
|
||||
|
||||
|
||||
class Quote:
|
||||
def __init__(self, database, user, password):
|
||||
self.conn = psycopg2.connect(database, user, password)
|
||||
self.num_quotes = self._get_num_quotes()
|
||||
|
||||
def insert(self, owner, submitter, text):
|
||||
cur = self.conn.cursor()
|
||||
text = text.replace("'", "''")
|
||||
cur.execute('''
|
||||
INSERT INTO quotes (owner, submitter, text, timestamp)
|
||||
VALUES ('{}', '{}', E'{}', current_timestamp)'''.format(
|
||||
owner, submitter, text))
|
||||
self.conn.commit()
|
||||
cur.close()
|
||||
self.num_quotes += 1
|
||||
|
||||
def get(self, arg=None):
|
||||
if arg:
|
||||
cur = self.conn.cursor()
|
||||
ret = "SELECT number, text, timestamp FROM quotes WHERE "
|
||||
try:
|
||||
arg = int(arg)
|
||||
if arg > self.num_quotes:
|
||||
return "No quote matching that number."
|
||||
ret += "number = {}".format(arg)
|
||||
except ValueError:
|
||||
ret += "text like '%{}%'".format(arg.lower())
|
||||
|
||||
cur.execute(ret)
|
||||
quotes = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
return self._format(quotes)
|
||||
else:
|
||||
return self.get(random.randint(0, self.num_quotes))
|
||||
|
||||
def _get_num_quotes(self):
|
||||
cur = self.conn.cursor()
|
||||
cur.execute("SELECT COUNT(id) FROM quotes")
|
||||
return cur.fetchone()[0]
|
||||
|
||||
def _format(self, quotes):
|
||||
if len(quotes) >= 1:
|
||||
data = quotes[random.randint(0, len(quotes)-1)]
|
||||
else:
|
||||
try:
|
||||
data = quotes[0]
|
||||
except IndexError:
|
||||
return "No quote."
|
||||
return "{}. {}, {}".format(
|
||||
data[0], data[1], datetime.strftime(data[2], '%Y'))
|
|
@ -0,0 +1,21 @@
|
|||
#!/usr/bin/env python
|
||||
from flask import Flask, Response
|
||||
from ameliabot import owncast
|
||||
from ameliabot.init_logging import init_logging
|
||||
from ameliabot.logger import logging
|
||||
|
||||
|
||||
init_logging()
|
||||
logging.info("Loaded ameliabot v%s" % owncast.bot_version)
|
||||
|
||||
# the url of the Owncast API for bot posts
|
||||
owncast_url = "{}/api/integrations/chat/send".format(
|
||||
owncast.config["owncast_server"])
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
@app.route('/webhook/owncast', methods=['POST'])
|
||||
def respond():
|
||||
owncast.parse_webhook()
|
||||
return Response(status=200)
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"!help": "Commands: {commands}",
|
||||
"!backseat": "**No backseating** unless I specifically ask for help.",
|
||||
"!hydrate": "[HYDRATE] {sender} wants {streamer} to take a drink!",
|
||||
"!stretch": "[STRETCH] {sender} reminds {streamer} to stretch dat body.",
|
||||
"!save": "SAVE! **NOW!!!**",
|
||||
"!love": "There is **{random}% love** between {sender} and {target} <3",
|
||||
"!slap": "*slaps {target} with a trout >:)*",
|
||||
"!bot": "I am currently running **ameliabot {bot_version}**",
|
||||
"!quote": "{quote}",
|
||||
"!addquote": "{quote_parameters}"
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"owncast_server": "localhost",
|
||||
"access_token": "",
|
||||
"streamer_name": "owncast streamer",
|
||||
"quote_db_name": "",
|
||||
"quote_db_user": "",
|
||||
"quote_db_pass": ""
|
||||
}
|
Loading…
Reference in New Issue