From e12191686fbe9f692b3b766ac79177636f006157 Mon Sep 17 00:00:00 2001 From: Mike Lang Date: Thu, 13 Jul 2023 19:32:27 +1000 Subject: [PATCH] Implement twitch bot that notifies zulip on recieving a twitch DM --- zulip/tootbot.py | 115 +++++++++++++++++++++++++++++++++++++++++++++ zulip/twitchbot.py | 70 +++++++++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 zulip/tootbot.py create mode 100644 zulip/twitchbot.py diff --git a/zulip/tootbot.py b/zulip/tootbot.py new file mode 100644 index 0000000..687f46e --- /dev/null +++ b/zulip/tootbot.py @@ -0,0 +1,115 @@ + +import argh +import yaml +from mastodon import Mastodon + +import zulip + +cli = argh.EntryPoint() + + +def get_config(conf_file): + with open(conf_file) as f: + return yaml.safe_load(f) + + +def format_account(account): + return f"**[{account['display_name']}]({account['url']})**" + + +def format_status(status): + sender = format_account(status["account"]) + url = status["url"] + visibility = status["visibility"] + reply = status["in_reply_to_id"] + reblog = status["reblog"] + + # private messages should not show content. + if visibility not in ("public", "unlisted"): + kind = "message" + if reblog is not None: + kind = "boost" + if reply is not None: + kind = "reply" + return f"{sender} sent [a {visibility} {kind}]({url})" + + if reblog is not None: + boostee = format_account(reblog["account"]) + boost_url = reblog["url"] + content = format_content(reblog["content"]) + return f"{sender} reblogged {boostee}'s [post]({boost_url})\n{content}" + + return f"{" + + +class Listener(Mastodon.StreamListener): + def __init__(self, zulip_client, stream, post_topic, notification_topic): + self.zulip_client = zulip_client + self.stream = stream + self.post_topic = post_topic + self.notification_topic = notification_topic + + def send(self, topic, content): + logging.info(f"Sending message to {self.stream}/{topic}: {content!r}") + self.zulip_client.send_to_stream(self.stream, topic, content) + + def on_update(self, status): + logging.info(f"Got update: {status!r}") + self.send(self.post_topic, format_status(status)) + + def on_delete(self, status_id): + logging.info(f"Got delete: {status_id}") + self.send(self.post_topic, f"*Status with id {status_id} was deleted*") + + def on_status_update(self, status): + logging.info(f"Got status update: {status!r}") + self.send(self.post_topic, f"*The following status has been updated*\n{format_status(status)}") + + def on_notification(self, notification): + logging.info(f"Got {notification['type']} notification: {notification!r}") + if notification["type"] != "mention": + return + self.send(self.notification_topic, format_status(status)) + + +@cli +def main(conf_file, stream="bot-spam", post_topic="Toots from Desert Bus", notification_topic="Mastodon Notifications"): + """ + Run the actual bot. + + Config, in json or yaml format: + zulip: + url + email + api_key + mastodon: + url + client_id # only required for get-access-token + client_secret # only required for get-access-token + access_token # only required for main + """ + logging.basicConfig(level='INFO') + + conf = get_config(conf_file) + zc = conf["zulip"] + mc = conf["mastodon"] + + zulip_client = zulip.Client(zc["url"], zc["email"], zc["api_key"]) + mastodon = Mastodon(api_base_url=mc["url"], access_token=mc["access_token"]) + listener = Listener(zulip_client, stream, post_topic, notification_topic) + + logging.info("Starting") + mastodon.stream_user(listener) + + +@cli +def get_access_token(conf_file): + """Do OAuth login flow and obtain an access token.""" + mc = get_config(conf_file)["mastodon"] + mastodon = Mastodon(client_id=mc["client_id"], client_secret=mc["client_secret"], api_base_url=mc["url"]) + print("Go to the following URL to obtain an access token:") + print(mastodon.auth_request_url(scopes=["read:notifications", "read:statuses"])) + + +if __name__ == '__main__': + cli() diff --git a/zulip/twitchbot.py b/zulip/twitchbot.py new file mode 100644 index 0000000..b96eccf --- /dev/null +++ b/zulip/twitchbot.py @@ -0,0 +1,70 @@ + +import gevent.monkey +gevent.monkey.patch_all() + +import logging + +import argh +import girc +import yaml + +import zulip + + +def run(zulip_client, nick, oauth_token, stream, topic): + chat_client = girc.Client( + hostname="irc.chat.twitch.tv", + port=6697, + ssl=True, + nick=nick, + password=oauth_token, + twitch=True, + ) + + @chat_client.handler() # handle all messages + def log_message(chat_client, message): + logging.info(f"Got message: {message}") + + @chat_client.handler(command="WHISPER") + def handle_whisper(chat_client, message): + display_name = message.tags["display-name"] + user = message.sender + logging.info(f"Got whisper from {display_name!r} (username {user!r})") + zulip_client.send_to_stream(stream, topic, f"**{nick}** received a Twitch DM from [{display_name}](https://twitch.tv/{user})") + + chat_client.start() + logging.info("Chat client connected") + chat_client.wait_for_stop() + logging.warning("Chat client disconnected") + + +def main(conf_file, stream="bot-spam", topic="Twitch DMs", retry_interval=10): + """ + config, in json or yaml format: + twitch_username + twitch_token + zulip_url + zulip_email + zulip_api_key + """ + logging.basicConfig(level='INFO') + + with open(conf_file) as f: + config = yaml.safe_load(f) + + zulip_client = zulip.Client(config["zulip_url"], config["zulip_email"], config["zulip_api_key"]) + + while True: + try: + run(zulip_client, config["twitch_username"], config["twitch_oauth_token"], stream, topic) + except Exception: + logging.exception("Chat client failed") + + # We might get here either from an error, or because client disconnected. + # Either way, try to re-connect. + logging.info(f"Retrying in {retry_interval} seconds") + gevent.sleep(retry_interval) + + +if __name__ == '__main__': + argh.dispatch_command(main)