commit 400004f3f3fa743ed9e5f0d1d505767ac944ba85 Author: Hein-Pieter van Braam-Stewart Date: Sun Jan 24 23:35:56 2021 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc75620 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.swp +*.pyc +credentials +env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b6310f6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM fedora:33 + +LABEL maintainer="Hein-Pieter van Braam-Stewart " + +RUN dnf -y install python3-websocket-client python3-requests && \ + dnf clean all + +COPY bot.py /root/bot.py +WORKDIR /root + +CMD /root/bot.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc42cf4 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# Godot Issuebot + +Simple bot to ferry between rocket.chat and Github. If you want to run one for yourself you can build the container. +The following environment variables are supported: + +`BOT_DEBUG` - Turn on exessive debug messages when set +`DEFAULT_AVATAR_URL` - *required* url to some image if gh can't provide an avatar +`ROCKET_WS_URL` - *required* url to the rocket.chat server (wss://chat.godotengine.org/websocket) +`ROCKET_USERNAME` - *required* username of the rocket.chat user to login as +`ROCKET_PASSWORD` - *required* password of the rocket.chat user +`GITHUB_PROJECT` - *required* github project to search in +`GITHUB_USERNAME` - *required* username to use for the github APIs +`GITHUB_TOKEN` - *required* github user token to authenticate to the APIs with + +## Running without a container + +Requirements are pretty small, only python-requests and python-websockets are required. After that make sure you set the above env variables and you're good to go! + +## Running the bot through podman + +Docker users can probable just replace `podman` with `docker` + + * clone the repostiory + * `podman build . -t issuebot:latest` + * `podman run -it --env-file=env issuebot:latest` + +example env file: +``` +BOT_DEBUG=true +DEFAULT_AVATAR_URL=https://chat.godotengine.org/avatar/github +GITHUB_PROJECT=godotengine +ROCKET_WS_URL=wss://chat.godotengine.org/websocket +ROCKET_USERNAME=github +ROCKET_PASSWORD=supersecret +GITHUB_USERNAME=hpvb +GITHUB_TOKEN=verysecret +``` diff --git a/bot.py b/bot.py new file mode 100755 index 0000000..0dbf6bf --- /dev/null +++ b/bot.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 + +from datetime import datetime +import requests +import hashlib +import websocket +import json +import os +import time + +DEBUG=os.environ.get('BOT_DEBUG') +ROCKET_WS_URL=os.environ.get('ROCKET_WS_URL') +ROCKET_USERNAME=os.environ.get('ROCKET_USERNAME') +ROCKET_PASSWORD=os.environ.get('ROCKET_PASSWORD') +GITHUB_PROJECT=os.environ.get('GITHUB_PROJECT') +GITHUB_USERNAME=os.environ.get('GITHUB_USERNAME') +GITHUB_TOKEN=os.environ.get('GITHUB_TOKEN') +DEFAULT_AVATAR_URL=os.environe.get('DEFAULT_AVATAR_URL') + +def debug_print(msg): + if DEBUG: + print(msg) + +class Bot: + def __init__(self): + self.sever_id = None + self.session_id = None + self.token = None + self.token_expires = None + self.id = None + self.github_user = 'hpvb' + self.github_token = 'adfb08e8dcd36889fce347cd880900f4e6cd61f3' + + self.ws = websocket.WebSocketApp(ROCKET_WS_URL, + on_message = lambda ws,msg: self.on_message(ws, msg), + on_error = lambda ws,msg: self.on_error(ws, msg), + on_close = lambda ws: self.on_close(ws), + on_open = lambda ws: self.on_open(ws)) + + def run(self): + self.ws.run_forever() + + def send(self, message_dict): + debug_print("Sending: " + message_dict.__repr__()) + self.ws.send(json.dumps(message_dict)) + + def login(self): + login_msg = { + "msg": "method", + "method": "login", + "id": "login", + "params" : [{ + "user": { + "username": ROCKET_USERNAME + }, + "password": { + "digest": hashlib.sha256(ROCKET_PASSWORD.encode('utf-8')).hexdigest(), + "algorithm": "sha-256", + }, + }], + } + self.send(login_msg) + + def get_subscriptions(self): + subscriptions_msg = { + "msg": "method", + "method": "subscriptions/get", + "id": "subscriptions", + } + self.send(subscriptions_msg) + + def subscribe(self, channel, channel_id): + subscribe_msg = { + "msg": "sub", + "id": channel, + "name": "stream-room-messages", + "params":[ + channel_id, + False + ] + } + self.send(subscribe_msg) + + def update_msg(self, msg): + links = [] + + for word in msg['msg'].split(' '): + if not word.count('#'): + continue + + parts = word.split('#') + if len(parts) != 2: + continue + + issue = 0 + repository = parts[0] + try: + issue = int(parts[1]) + except ValueError: + continue + + if issue < 100 and not repository: + continue + + if not repository: + repository = 'godot' + + headers = { 'User-Agent': 'Godot Issuebot by hpvb', } + r = requests.get(f"https://api.github.com/repos/{GITHUB_PROJECT}/{repository}/issues/{issue}", headers=headers, auth=(GITHUB_USERNAME, GITHUB_TOKEN)) + if r.status_code == 200: + issue = r.json() + + avatar_url = DEFAULT_AVATAR_URL + if 'avatar_url' in issue['user'] and issue['user']['avatar_url']: + avatar_url = issue['user']['avatar_url'] + if 'gravatar_id' in issue['user'] and issue['user']['gravatar_id']: + avatar_url = f"https://www.gravatar.com/avatar/{issue['user']['gravatar_id']}" + + is_pr = False + pr_mergeable = None + pr_merged = None + pr_merged_by = None + pr_draft = False + pr_reviewers = None + status = None + closed_by = None + + if 'pull_request' in issue and issue['pull_request']: + is_pr = True + prr = requests.get(issue['pull_request']['url'], headers=headers, auth=(GITHUB_USERNAME, GITHUB_TOKEN)) + if prr.status_code == 200: + pr = prr.json() + status = pr['state'] + + if 'merged_by' in pr and pr['merged_by']: + pr_merged_by = pr['merged_by']['login'] + if 'mergeable' in pr: + pr_mergeable = pr['mergeable'] + if 'merged' in pr: + pr_merged = pr['merged'] + if 'draft' in pr: + pr_draft = pr['draft'] + if 'requested_reviewers' in pr and pr['requested_reviewers']: + reviewers = [] + for reviewer in pr['requested_reviewers']: + reviewers.append(reviewer['login']) + pr_reviewers = ', '.join(reviewers) + else: + status = issue['state'] + + if status == 'closed': + if 'closed_by' in issue and issue['closed_by']: + closed_by = issue['closed_by']['login'] + + issue_type = None + + if is_pr: + issue_type = "Pull Request" + if pr_merged: + status = "PR merged" + if pr_merged_by: + status += f" by {pr_merged_by}" + elif status == 'closed': + status = "PR closed" + elif not pr_merged: + status = "PR open" + if pr_draft: + status += " [draft]" + if pr_mergeable != None: + if pr_mergeable: + status += " [mergeable]" + else: + status += " [needs rebase]" + if pr_reviewers: + status += f" reviews required from {pr_reviewers}" + else: + issue_type = "Issue" + status = f"Status: {status}" + + if not pr_merged and closed_by and status == 'closed': + status += f" by {closed_by}" + + links.append({ + "author_icon": avatar_url, + "author_link": issue['html_url'], + "author_name": f"{repository.title()} [{issue_type}]: {issue['title']} #{issue['number']}", + "text": status, + }) + + if not len(links): + return + + if not 'attachments' in msg: + msg['attachments'] = [] + + # We may be editing, remove all the github attachments + old_attachments = [] + for attachment in msg['attachments']: + if not attachment['author_link'].startswith(f'https://github.com/{GITHUB_PROJECT}'): + old_attachments.append(attachment) + + msg['attachments'] = old_attachments + + # Hack Hack, the clients won't update without a change to this field. Even if we add or remove attachments. + msg['msg'] = msg['msg'] + " " + msg['attachments'].extend(links) + + update_msg = { + "msg": "method", + "method": "updateMessage", + "id": "blah", + "params": [ msg ] + } + self.send(update_msg) + + def on_message(self, ws, message): + decoded_msg = json.loads(message) + debug_print("Incoming: " + decoded_msg.__repr__()) + + if 'server_id' in decoded_msg: + self.server_id = decoded_msg['server_id'] + + if 'msg' in decoded_msg: + msg = decoded_msg['msg'] + if msg == 'ping': + self.send({'msg': 'pong'}) + + if msg == 'connected': + self.session_id = decoded_msg['session'] + debug_print(f"Got session: {self.session_id}") + self.login() + + if msg == 'result': + if decoded_msg['id'] == 'login': + self.id = decoded_msg['result']['id'] + self.token = decoded_msg['result']['token'] + self.token_expires = datetime.fromtimestamp(int(decoded_msg['result']['tokenExpires']['$date']) / 1000) + debug_print(f"Loggedin: id: {self.id}, token: {self.token}, expires: {self.token_expires}") + self.get_subscriptions() + + if decoded_msg['id'] == 'subscriptions': + for subscription in decoded_msg['result']: + self.subscribe(subscription['name'], subscription['rid']) + + if msg == 'changed' and decoded_msg['collection'] == 'stream-room-messages': + for chat_msg in decoded_msg['fields']['args']: + if 'editedBy' in chat_msg and chat_msg['editedBy']['_id'] == self.id: + continue + if chat_msg['msg'].count('#'): + self.update_msg(chat_msg) + + def on_error(self, ws, error): + debug_print(error) + + def on_close(self, ws): + debug_print("Disconnected, reconnecting") + ws.close() + #ws.run_forever() + + def on_open(self, ws): + connect_msg = { "msg": "connect", "version": "1", "support": ["1"] } + self.send(connect_msg) + +if __name__ == "__main__": + if DEBUG: + websocket.enableTrace(True) + + while True: + try: + bot = Bot() + bot.run() + except Exception as e: + print(e) + time.sleep(10)