diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6b83bed --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.6-alpine3.7 + +ADD [".", "/app"] +WORKDIR /app +RUN apk add --update g++ gfortran openblas-dev libpng-dev musl-dev freetype-dev +RUN pip install -r requirements.txt --no-cache-dir + +CMD ["python", "bot.py"] \ No newline at end of file diff --git a/bot.py b/bot.py index 2918bf9..685556d 100644 --- a/bot.py +++ b/bot.py @@ -1,116 +1,76 @@ import argparse -import datetime import json -import time -from tempfile import NamedTemporaryFile import logging +import time + +from tempfile import NamedTemporaryFile +from collections import namedtuple -import requests import schedule -STATUS_URL = "https://isfswiaiopen.wiai.de?json" -MESSAGE_URL = "https://api.telegram.org/bot{token}/sendMessage" -IMAGE_URL = "https://api.telegram.org/bot{token}/sendPhoto" +from plot import get_plot +from sources import IsFsWIAIopen +from targets import Client, MatrixClient, TelegramClient logging.basicConfig(format='%(asctime)s %(levelname)s %(name)s:%(message)s', level=logging.DEBUG, datefmt="%Y.%m.%d %H:%M:%S") log = logging.getLogger(__name__) -def parse_time(string): - return datetime.datetime.strptime(string, "%Y-%m-%d %H:%M:%S") +Config = namedtuple("Config", ['sleep', 'plot_interval', 'clients', 'source', 'loop']) -def get_status(): - status = requests.get(STATUS_URL).json() - status["timestamp"] = parse_time(status['timestamp']) - return status +def publish(clients, fn, **kwargs): + for client in clients: + fn(client, **kwargs) -def get_status_text(config, src=get_status): - return config["texts"][str(get_status()["doorstate"])] - -def post(chats, text, token): - url = MESSAGE_URL.format(token=token) - for chat in chats: - response = requests.post(url, data={'chat_id': chats[chat], "text": text}) - log.info("post message: %s", response.status_code) +def post_plot(config): + with NamedTemporaryFile() as target: + plot_file, last = get_plot(target) + plot_file.seek(0) + publish(config.clients, Client.post_image, file=plot_file, name="") def has_argument(args, key): return key in args and args[key] def get_config(args): - config = json.load(open(args['config'])) - if has_argument(args, "interval"): - log.info("Overwrite sleep value by argument…") - config["sleep"] = args["interval"] + settings = json.load(open(args['config'])) + clients = [] + for client in (MatrixClient, TelegramClient): + if client.key in settings['groups']: + clients.append(client(**settings['groups'][client.key])) + config = Config( + sleep=args['interval'] if has_argument(args, 'interval') else settings['sleep'], + plot_interval=settings['plot_interval'], + clients=clients, + source=IsFsWIAIopen(texts=settings['texts']), + loop=args['loop']) return config -def main(args={"config": "settings.json"}): - log.info("run once") - config = get_config(args) - text = get_status_text(config) - post(config['groups'], text, config['token']) - #post_plot(config) +def setup(config): + schedule.every(config.plot_interval).seconds.do(lambda: post_plot(config)) -def loop(args={"config": "settings.json"}): - log.info("prepare loop") +def main(args={'config': "settings.json"}): config = get_config(args) + + status = config.source.get_status() + publish(config.clients, Client.post_text, text=status.text) setup(config) - while True: + + while config.loop: try: - do_loop(config) + schedule.run_pending() + new_status = config.source.get_status() + if not new_status.doorstate == status.doorstate: + status = new_status + publish(config.clients, Client.post_text, text=status.text) + time.sleep(config.sleep) except Exception as e: log.exception(e) - time.sleep(config['sleep']) - -def do_loop(config): - last_state = None - while True: - log.info("enter loop") - changed = has_changed(last_state) - if changed: - last_state = update(new_state) - log.info("run pending tasks") - schedule.run_pending() - log.info("sleep") - time.sleep(config['sleep']) - -def has_changed(last_state): - changed = False - new_state = get_status() - if last_state is None: - changed = True - elif not last_state["doorstate"] == new_state["doorstate"]: - changed = True - return changed - -def update(state): - text = get_status_text(config, lambda: state) - post(config["groups"], text, config["token"]) - return state - -def post_plot(config): - from plot import get_plot - with NamedTemporaryFile() as target: - image_url = IMAGE_URL.format(token=config['token']) - photo, last = get_plot(target) - files = {'photo': photo} - if last + datetime.timedelta(days=1) < datetime.datetime.today(): - log.info("skipping image, no new updates...") - return - for chat in config['groups']: - files['photo'].seek(0) - values = {"chat_id": config['groups'][chat]} - r = requests.post(image_url, files=files, data=values) - log.info("post photo: %s", r.status_code) - -def setup(config): - schedule.every(config['photo_interval']).seconds.do(lambda: post_plot(config)) + if not config.loop: + post_plot(config) if __name__ == "__main__": parser = argparse.ArgumentParser(description="DoorStateBot") parser.add_argument("--config", "-c", default="settings.json", help="Configuration file") - parser.add_argument("--loop", "-l", action="store_true", help="Loop") + parser.add_argument("--loop", "-l", action="store_false", help="Loop") parser.add_argument("--interval", "-i", help="Interval") args = parser.parse_args() - if args.loop: - loop(vars(args)) - else: - main(vars(args)) + main(vars(args)) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..700c071 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +version: "2" + +services: + doorbot: + build: . + image: fswiai/doorbot:0.2 + volumes: + - ./settings.json:/app/settings.json \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index d150fee..c90d0c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ requests==2.18.4 schedule==0.4.3 matplotlib==2.0.2 numpy==1.13.1 +matrix_client==0.1.0 diff --git a/settings.sample.json b/settings.sample.json index beed094..08c2619 100644 --- a/settings.sample.json +++ b/settings.sample.json @@ -1,9 +1,17 @@ { - "token": "BOTID:TOKEN", "sleep": 30, - "photo_interval": 30, + "plot_interval": 30, "groups":{ - "wiaidoor": -12357567 + "matrix": { + "host": "…", + "username": "…", + "password": "…", + "doorstate": "..." + }, + "telegram": { + "token": "BOTID:TOKEN", + "wiaidoor": -12357567 + } }, "disabled":{ "WIAIdoorTest": -234502, diff --git a/sources.py b/sources.py new file mode 100644 index 0000000..d2bd8a4 --- /dev/null +++ b/sources.py @@ -0,0 +1,35 @@ +import datetime +import requests + +from collections import namedtuple + +Status = namedtuple("Status", ['doorstate', 'timestamp', 'text']) + +class Source: + def get_status(self): + raise NotImplementedError() + + def is_recent(self, status, **kwargs): + raise NotImplementedError() + +class IsFsWIAIopen(Source): + url = "https://isfswiaiopen.wiai.de?json" + + def __init__(self, texts=None): + self.texts = texts + + def _parse_time(self, string): + return datetime.datetime.strptime(string, "%Y-%m-%d %H:%M:%S") + + def _get_text(self, state): + return self.texts[state] if self.texts else "" + + def get_status(self): + status = requests.get(self.url).json() + return Status( + doorstate=str(status['doorstate']), + timestamp=self._parse_time(status['timestamp']), + text=self._get_text(str(status['doorstate']))) + + def is_recent(self, status, **kwargs): + return status.timestamp + datetime.timedelta(days=1) < datetime.datetime.today() \ No newline at end of file diff --git a/targets.py b/targets.py new file mode 100644 index 0000000..3df4708 --- /dev/null +++ b/targets.py @@ -0,0 +1,121 @@ +import logging + +import requests + +from matrix_client.client import MatrixClient as MatrixApiClient +from matrix_client.errors import MatrixError + + +class Client: + def post_image(self, file, name, content_type="image/png", targets=None): + """Push to all targets""" + self.post_image(file, name, content_type, targets) + #raise NotImplementedError() + + def send_image(self, target, file, name, content_type="image/png"): + """Send an image to a room + + Args: + target (str): The internal room id to post into + path (file): The image file object + name (str): The name for the file in the room + content_type (str): Content-type of the image + """ + self.post_image(path, name, content_type, targets=[target]) + + def post_text(self, text, targets=None): + """Push to all targets""" + self.post_text(text, targets) + #raise NotImplementedError() + + def send_text(self, target, text): + """Send a text to a room + + Args: + target (str): The internal room id to post into + text (str): The text to post + """ + self.post_text(text, targets=[target]) + + def _get_targets(self, targets): + if not targets: + targets = self.targets + if not targets: + self.log.info("no targets…") + targets = [] + return targets + + +class MatrixClient(Client): + key = "matrix" + def __init__(self, host, username, password, **kwargs): + self.client = MatrixApiClient(host) + self.client.login_with_password_no_sync(username=username, password=password) + self.log = logging.getLogger(__name__) + self.targets = kwargs.values() + + def _get_room(self, target): + if target not in self.client.rooms: + try: + self.client.join_room(target) + except MatrixError as e: + self.log.error("could not join room '" + target + "'") + self.log.exception(e) + return None + return self.client.rooms[target] + + def _upload_image(self, file, content_type): + try: + data = file.read() + file.seek(0) + return self.client.api.media_upload(data, content_type) + except MatrixError as e: + self.log.exception(e) + return None + + def post_image(self, file, name, content_type="image/png", targets=None): + targets = self._get_targets(targets) + mxc = None + for target in targets: + room = self._get_room(target) + if not room: + self.log.error("could not get room '" + target + "'") + continue + if not mxc: + mxc = self._upload_image(file, content_type) + if not mxc: + return + room.send_image(mxc['content_uri'], name) + + def post_text(self, text, targets=None): + targets = self._get_targets(targets) + for target in targets: + room = self._get_room(target) + if room: + room.send_text(text) + +class TelegramClient(Client): + key = "telegram" + MESSAGE_URL = "https://api.telegram.org/bot{token}/sendMessage" + IMAGE_URL = "https://api.telegram.org/bot{token}/sendPhoto" + + def __init__(self, token, **kwargs): + self.text_url = self.MESSAGE_URL.format(token=token) + self.image_url = self.IMAGE_URL.format(token=token) + self.log = logging.getLogger(__name__) + self.targets = kwargs.values() + + def post_image(self, file, name, content_type="image/png", targets=None): + targets = self._get_targets(targets) + for target in targets: + files = {'photo': file} + values = {'chat_id': target} + response = requests.post(self.image_url, files=files, data=values) + self.log.info("post message: %s -- %s", response.status_code, response.text) + file.seek(0) + + def post_text(self, text, targets=None): + targets = self._get_targets(targets) + for target in targets: + response = requests.post(self.text_url, data={'chat_id': target, 'text': text}) + self.log.info("post message: %s -- %s", response.status_code, response.text) \ No newline at end of file