Create Zulip Mostr Bot

This commit is contained in:
xeruf 2024-12-06 23:35:31 +01:00
commit 7808b57b0b
4 changed files with 163 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.idea
fixtures/
test_mostr.py
/mostr.conf
/zuliprc

31
README.md Normal file
View File

@ -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.

126
mostr.py Normal file
View File

@ -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

1
requirements.txt Normal file
View File

@ -0,0 +1 @@
pynostr