From d2df8a7e2c9fbd792db557e1d2d1212588977836 Mon Sep 17 00:00:00 2001 From: xeruf <27jf@pm.me> Date: Fri, 6 Dec 2024 23:35:31 +0100 Subject: [PATCH] Create Zulip Mostr Bot --- .gitignore | 5 ++ README.md | 31 ++++++++++++ mostr.py | 126 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + 4 files changed, 163 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 mostr.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..f1b62cc4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea +fixtures/ +test_mostr.py +/mostr.conf +/zuliprc diff --git a/README.md b/README.md new file mode 100644 index 00000000..a81389a1 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +This is a zulip bot to create mostr tasks on a nostr relay. + +# Quickstart + +1. Clone https://github.com/zulip/python-zulip-api +2. Run `python3 ./tools/provision` and activate the created venv +3. Clone this repo into `python-zulip-api/zulip_bots/zulip_bots/bots/mostr` +4. Create a `mostr.conf` to specify the private key and relay for the bot to use: +```ini +[mostr] +key = nsec... +relay = wss://... +``` + +Now you can test it with: + + zulip-bot-shell mostr --bot-config-file mostr.conf + +5. Follow the setup steps in https://zulip.com/api/running-bots and put the zuliprc file into the mostr directory + +Now you can properly run the bot with: + + zulip-run-bot mostr --config-file zuliprc --bot-config-file mostr.conf + +It will track context per conversation, +so you can DM it, ping it in topics or group chats. + +Note that every message not starting with `#` or `-` +(to add or remove tags from current context, respectively) +or containing a valid public key for assignment +will create a new task. diff --git a/mostr.py b/mostr.py new file mode 100644 index 00000000..34bd7177 --- /dev/null +++ b/mostr.py @@ -0,0 +1,126 @@ +from typing import Dict + +from pynostr.event import Event +from pynostr.relay_manager import RelayManager +from pynostr.key import PrivateKey +from pynostr.key import PublicKey + +from zulip_bots.lib import AbstractBotHandler + +def parse_pubkey(key_str: str) -> PublicKey: + try: + return PublicKey.from_hex(key_str) + except (ValueError, TypeError): + pass # try to load as npub if hex fails + + try: + return PublicKey.from_npub(key_str) + except (ValueError, TypeError): + return None + + +class MostrHandler: + """A Zulip bot for Mostr""" + + def usage(self) -> str: + return "Send me a message to create a task!" + + def initialize(self, bot_handler: AbstractBotHandler) -> None: + self.config_info = bot_handler.get_config_info('mostr') + self.relay = self.config_info['relay'] + self.relays = RelayManager() + self.relays.add_relay(self.relay) + self.key = PrivateKey.from_nsec(self.config_info['key']).hex() + print('Connecting to relay', self.relay) + + # TODO React to all group messages + def handle_message(self, message: Dict[str, str], bot_handler: AbstractBotHandler) -> None: + username = message.get('sender_full_name') + print('Received', message, 'from', username) + + # Tags + if message.get('type') == 'private' and len(message.get('display_recipient')) < 3: + context = f"s{message.get('sender_id')}" + else: + context = f"r{message.get('recipient_id')}" + try: + hashtags = list(filter(None, bot_handler.storage.get(context).split("#"))) + except KeyError: + hashtags = [] + + print('Context', context, 'contains', hashtags) + + if message['content'].startswith("#"): + hashtags.extend(filter(None, message['content'].split('#'))) + bot_handler.send_reply(message, f"Active Hashtags: {hashtags}") + if hashtags: + result = bot_handler.storage.put(context, '#'.join(set(hashtags))) + return + + if message['content'].startswith("-"): + to_remove = message['content'].replace('-', '#').split('#') + hashtags = [t for t in hashtags if t not in to_remove] + bot_handler.send_reply(message, f"Active Hashtags: {hashtags}") + bot_handler.storage.put(context, '#'.join(hashtags)) + return + + # Initial Feedback + if len(message['content']) < 4: + if message.get('type') == 'private': + bot_handler.send_reply(message, "That task name seems a bit short!") + else: + bot_handler.react(message, 'question') + return + bot_handler.react(message, 'working_on_it') + + # Assignee Public Key + pubkey = None + user = message.get('sender_id') or message.get('sender_email') + if user: + # TODO Need proper verification, which is not done by pynostr + # A public key is generally also a valid private key, apparently + # try: + # privkey = PrivateKey.from_hex(message['content']) + # bot_handler.send_reply(message, f"Whoa that looks like a private key! Please delete it swiftly. This is the corresponding private key: {privkey.public_key}") + # return + # except: + # pass + + # Parse pubkey from storage or message + key = f"pubkey_{user}" + if message['content']: + pubkey = parse_pubkey(message['content']) + if pubkey: + bot_handler.storage.put(key, pubkey.npub) + bot_handler.send_reply(message, "New pubkey set!") + return + try: + pubkey = PublicKey.from_npub(bot_handler.storage.get(key)) + except KeyError,IndexError: + if message.get('type') == 'private': + bot_handler.send_reply(message, "Please send me your public key for proper attribution") + + # auto-assign via username matching, extract hashtags + + tags = list(map(lambda tag: ['t', tag], hashtags or ['zulip'])) + if pubkey: + tags.append(['p', pubkey.hex()]) + event = Event(content=message['content'], kind=1621, tags=tags) + event.sign(self.key) + + print('Publishing', event) + self.relays.publish_event(event) + self.relays.run_sync() + + if message.get('type') == 'private': + bot_handler.send_reply(message, f"Sent `{event}` to {self.relay}") + else: + if hasattr(bot_handler, '_client'): + bot_handler._client.remove_reaction({'message_id': message['id'], 'emoji_name': 'working_on_it'}) + bot_handler.react(message, 'check') + + def __del__(self): + self.relays.close_connections() + print('Terminated Mostr Bot') + +handler_class = MostrHandler diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..934815d4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pynostr