├── nostr_bot ├── examples │ ├── __init__.py │ ├── registration.py │ ├── ping.py │ ├── welcome.py │ ├── gotmail.py │ ├── mirror.py │ └── reporting.py ├── __init__.py ├── cli.py └── bot.py ├── docs ├── authors.rst ├── history.rst ├── readme.rst ├── contributing.rst ├── usage.rst ├── index.rst ├── Makefile ├── make.bat ├── installation.rst └── conf.py ├── tests ├── __init__.py └── test_nostr_bot.py ├── AUTHORS.rst ├── HISTORY.rst ├── requirements_dev.txt ├── MANIFEST.in ├── tox.ini ├── .travis.yml ├── .editorconfig ├── .github └── ISSUE_TEMPLATE.md ├── setup.cfg ├── README.rst ├── setup.py ├── LICENSE ├── .gitignore ├── Makefile └── CONTRIBUTING.rst /nostr_bot/examples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit test package for nostr_bot.""" 2 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | To use nostr bot in a project:: 6 | 7 | import nostr_bot 8 | -------------------------------------------------------------------------------- /nostr_bot/__init__.py: -------------------------------------------------------------------------------- 1 | """Top-level package for nostr bot.""" 2 | 3 | __author__ = """Dave St.Germain""" 4 | __email__ = 'dave@st.germa.in' 5 | __version__ = '0.4.0' 6 | 7 | from .bot import NostrBot, CommunicatorBot, RPCBot 8 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Dave St.Germain 9 | 10 | Contributors 11 | ------------ 12 | 13 | None yet. Why not be the first? 14 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | History 3 | ======= 4 | 5 | 0.3.0 (2023-02-02) 6 | ------------------ 7 | 8 | * Added a bot for mirroring. 9 | 10 | 11 | 0.1.0 (2023-01-26) 12 | ------------------ 13 | 14 | * First release on PyPI. 15 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | pip==19.2.3 2 | bump2version==0.5.11 3 | wheel==0.33.6 4 | watchdog==0.9.0 5 | flake8==3.7.8 6 | tox==3.14.0 7 | coverage==4.5.4 8 | Sphinx==1.8.5 9 | twine==1.14.0 10 | Click==7.1.2 11 | 12 | black==21.7b0 13 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | 7 | recursive-include tests * 8 | recursive-exclude * __pycache__ 9 | recursive-exclude * *.py[co] 10 | 11 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 12 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36, py37, py38, flake8 3 | 4 | [travis] 5 | python = 6 | 3.8: py38 7 | 3.7: py37 8 | 3.6: py36 9 | 10 | [testenv:flake8] 11 | basepython = python 12 | deps = flake8 13 | commands = flake8 nostr_bot tests 14 | 15 | [testenv] 16 | setenv = 17 | PYTHONPATH = {toxinidir} 18 | 19 | commands = python setup.py test 20 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Config file for automatic testing at travis-ci.com 2 | 3 | language: python 4 | python: 5 | - 3.8 6 | - 3.7 7 | - 3.6 8 | 9 | # Command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors 10 | install: pip install -U tox-travis 11 | 12 | # Command to run tests, e.g. python setup.py test 13 | script: tox 14 | 15 | 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * nostr bot version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to nostr bot's documentation! 2 | ====================================== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | readme 9 | installation 10 | usage 11 | modules 12 | contributing 13 | authors 14 | history 15 | 16 | Indices and tables 17 | ================== 18 | * :ref:`genindex` 19 | * :ref:`modindex` 20 | * :ref:`search` 21 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.4.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version='{current_version}' 8 | replace = version='{new_version}' 9 | 10 | [bumpversion:file:nostr_bot/__init__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | 14 | [bdist_wheel] 15 | universal = 1 16 | 17 | [flake8] 18 | exclude = docs 19 | -------------------------------------------------------------------------------- /nostr_bot/examples/registration.py: -------------------------------------------------------------------------------- 1 | from nostr_bot.bot import RegistrationBot 2 | import os 3 | 4 | class ShibbolethRegistrationBot(RegistrationBot): 5 | SHIBBOLETH = os.getenv("SHIBBOLETH") 6 | 7 | async def is_valid(self, event): 8 | return event.content == self.SHIBBOLETH 9 | 10 | 11 | class PaymentRegistrationBot(RegistrationBot): 12 | AMOUNT = os.getenv("AMOUNT") 13 | 14 | async def is_valid(self, event): 15 | return event.content == 'here is {self.AMOUNT}' 16 | 17 | -------------------------------------------------------------------------------- /nostr_bot/examples/ping.py: -------------------------------------------------------------------------------- 1 | """ 2 | Ping bot 3 | """ 4 | 5 | import os 6 | import json 7 | from nostr_bot import RPCBot 8 | 9 | 10 | class PingBot(RPCBot): 11 | ENCRYPTED = False 12 | 13 | def __init__(self): 14 | super().__init__() 15 | self.LISTEN_PUBKEY = os.getenv("PUBLIC_KEY") 16 | 17 | async def on_ping(self, event, *args): 18 | self.log.info("Got ping %s", event) 19 | self.log.info("Sending pong") 20 | return self.make_response(event, kind=event.kind, content=json.dumps({"method": "pong", "args": []})) 21 | 22 | async def on_pong(self, event, *args): 23 | self.log.info("Got pong %s", event) -------------------------------------------------------------------------------- /nostr_bot/examples/welcome.py: -------------------------------------------------------------------------------- 1 | """ 2 | New User bot 3 | 4 | This will listen for kind=0 events and send a dm to itself, welcoming the user 5 | 6 | """ 7 | from nostr_bot import CommunicatorBot 8 | import json 9 | 10 | 11 | class WelcomeBot(CommunicatorBot): 12 | LISTEN_KIND = 0 13 | LISTEN_PUBKEY = None 14 | 15 | async def handle_event(self, event): 16 | meta = json.loads(event.content) 17 | name = meta.get('display_name', '') or meta.get('name', '') or event.pubkey 18 | dm = self.make_dm(self.PUBLIC_KEY, content=f"Welcome, {name}!") 19 | self.log.info("Welcoming %s with %s", name, dm.id) 20 | await self.reply(dm) 21 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = nostr_bot 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=nostr_bot 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /tests/test_nostr_bot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """Tests for `nostr_bot` package.""" 4 | 5 | 6 | import unittest 7 | from click.testing import CliRunner 8 | 9 | from nostr_bot import nostr_bot 10 | from nostr_bot import cli 11 | 12 | 13 | class TestNostr_bot(unittest.TestCase): 14 | """Tests for `nostr_bot` package.""" 15 | 16 | def setUp(self): 17 | """Set up test fixtures, if any.""" 18 | 19 | def tearDown(self): 20 | """Tear down test fixtures, if any.""" 21 | 22 | def test_000_something(self): 23 | """Test something.""" 24 | 25 | def test_command_line_interface(self): 26 | """Test the CLI.""" 27 | runner = CliRunner() 28 | result = runner.invoke(cli.main) 29 | assert result.exit_code == 0 30 | assert 'nostr_bot.cli.main' in result.output 31 | help_result = runner.invoke(cli.main, ['--help']) 32 | assert help_result.exit_code == 0 33 | assert '--help Show this message and exit.' in help_result.output 34 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | 8 | Stable release 9 | -------------- 10 | 11 | To install nostr bot, run this command in your terminal: 12 | 13 | .. code-block:: console 14 | 15 | $ pip install nostr_bot 16 | 17 | This is the preferred method to install nostr bot, as it will always install the most recent stable release. 18 | 19 | If you don't have `pip`_ installed, this `Python installation guide`_ can guide 20 | you through the process. 21 | 22 | .. _pip: https://pip.pypa.io 23 | .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ 24 | 25 | 26 | From sources 27 | ------------ 28 | 29 | The sources for nostr bot can be downloaded from the `Github repo`_. 30 | 31 | You can either clone the public repository: 32 | 33 | .. code-block:: console 34 | 35 | $ git clone git://github.com/davestgermain/nostr_bot 36 | 37 | Or download the `tarball`_: 38 | 39 | .. code-block:: console 40 | 41 | $ curl -OJL https://github.com/davestgermain/nostr_bot/tarball/master 42 | 43 | Once you have a copy of the source, you can install it with: 44 | 45 | .. code-block:: console 46 | 47 | $ python setup.py install 48 | 49 | 50 | .. _Github repo: https://github.com/davestgermain/nostr_bot 51 | .. _tarball: https://github.com/davestgermain/nostr_bot/tarball/master 52 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | nostr bot 3 | ========= 4 | 5 | 6 | .. image:: https://img.shields.io/pypi/v/nostr-bot.svg 7 | :target: https://pypi.python.org/pypi/nostr-bot 8 | 9 | .. image:: https://img.shields.io/travis/davestgermain/nostr_bot.svg 10 | :target: https://travis-ci.com/davestgermain/nostr_bot 11 | 12 | .. image:: https://readthedocs.org/projects/nostr-bot/badge/?version=latest 13 | :target: https://nostr-bot.readthedocs.io/en/latest/?version=latest 14 | :alt: Documentation Status 15 | 16 | 17 | 18 | 19 | A Python nostr bot framework 20 | 21 | To install: 22 | 23 | ``pip install nostr-bot`` 24 | 25 | To run: 26 | 27 | ``nostr-bot run`` 28 | 29 | For instance: 30 | ``PUBLIC_KEY=84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240 KINDS=1,4,5,7 nostr-bot run -c nostr_bot.examples.gotmail.GotMailBot`` 31 | 32 | See the examples_ for more ideas of what you can do! 33 | 34 | 35 | 36 | * Free software: BSD license 37 | * Documentation: https://nostr-bot.readthedocs.io. 38 | 39 | 40 | Features 41 | -------- 42 | 43 | * RPC using ephemeral events 44 | * automatic reconnect 45 | * simple API 46 | 47 | Credits 48 | ------- 49 | 50 | This package was created with Cookiecutter_ and the `audreyr/cookiecutter-pypackage`_ project template. 51 | 52 | .. _Cookiecutter: https://github.com/audreyr/cookiecutter 53 | .. _`audreyr/cookiecutter-pypackage`: https://github.com/audreyr/cookiecutter-pypackage 54 | .. _examples: https://github.com/davestgermain/nostr_bot/tree/master/nostr_bot/examples 55 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """The setup script.""" 4 | 5 | from setuptools import setup, find_packages 6 | 7 | with open('README.rst') as readme_file: 8 | readme = readme_file.read() 9 | 10 | with open('HISTORY.rst') as history_file: 11 | history = history_file.read() 12 | 13 | requirements = ['Click>=7.0', 'aionostr>=0.11'] 14 | 15 | test_requirements = [ ] 16 | 17 | setup( 18 | author="Dave St.Germain", 19 | author_email='dave@st.germa.in', 20 | python_requires='>=3.9', 21 | classifiers=[ 22 | 'Development Status :: 2 - Pre-Alpha', 23 | 'Intended Audience :: Developers', 24 | 'License :: OSI Approved :: BSD License', 25 | 'Natural Language :: English', 26 | 'Programming Language :: Python :: 3', 27 | 'Programming Language :: Python :: 3.6', 28 | 'Programming Language :: Python :: 3.7', 29 | 'Programming Language :: Python :: 3.8', 30 | ], 31 | description="A Python nostr bot framework", 32 | entry_points={ 33 | 'console_scripts': [ 34 | 'nostr-bot=nostr_bot.cli:main', 35 | ], 36 | }, 37 | install_requires=requirements, 38 | license="BSD license", 39 | long_description=readme + '\n\n' + history, 40 | include_package_data=True, 41 | keywords='nostr_bot', 42 | name='nostr_bot', 43 | packages=find_packages(include=['nostr_bot', 'nostr_bot.*']), 44 | test_suite='tests', 45 | tests_require=test_requirements, 46 | url='https://github.com/davestgermain/nostr_bot', 47 | version='0.4.0', 48 | zip_safe=False, 49 | ) 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | 3 | BSD License 4 | 5 | Copyright (c) 2023, Dave St.Germain 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without modification, 9 | are permitted provided that the following conditions are met: 10 | 11 | * Redistributions of source code must retain the above copyright notice, this 12 | list of conditions and the following disclaimer. 13 | 14 | * Redistributions in binary form must reproduce the above copyright notice, this 15 | list of conditions and the following disclaimer in the documentation and/or 16 | other materials provided with the distribution. 17 | 18 | * Neither the name of the copyright holder nor the names of its 19 | contributors may be used to endorse or promote products derived from this 20 | software without specific prior written permission. 21 | 22 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 23 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 24 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 25 | IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 26 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 27 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 28 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY 29 | OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 30 | OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED 31 | OF THE POSSIBILITY OF SUCH DAMAGE. 32 | 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | 104 | # IDE settings 105 | .vscode/ 106 | .idea/ 107 | -------------------------------------------------------------------------------- /nostr_bot/examples/gotmail.py: -------------------------------------------------------------------------------- 1 | """ 2 | Announce when you receive a DM 3 | 4 | Set environment variable PUBLIC_KEY to your public key 5 | 6 | Set env var KINDS to a comma-separated list of kinds to listen for 7 | 8 | This works on MacOS by calling the 'say' command 9 | 10 | You can set env var VOICE to use a different voice 11 | """ 12 | from nostr_bot import NostrBot 13 | import os 14 | import subprocess 15 | import json 16 | 17 | 18 | class GotMailBot(NostrBot): 19 | VOICE = os.getenv("VOICE", "Fred") 20 | KINDS = [int(k) for k in os.getenv("KINDS", "4").split(',')] 21 | seen_pubkeys = {} 22 | 23 | def get_query(self): 24 | my_pubkey = os.getenv('PUBLIC_KEY') 25 | assert my_pubkey, "Set PUBLIC_KEY environment variable" 26 | return { 27 | 'limit': 1, 28 | '#p': [my_pubkey], 29 | 'kinds': self.KINDS, 30 | } 31 | 32 | async def handle_event(self, event): 33 | if event.pubkey not in self.seen_pubkeys: 34 | self.seen_pubkeys[event.pubkey] = event.pubkey[-4:] 35 | async for profile in self.manager.get_events({"authors": [event.pubkey], "kinds": [0]}, single_event=True): 36 | meta = json.loads(profile.content) 37 | self.seen_pubkeys[event.pubkey] = meta.get('display_name', '') or meta.get('name', '') or event.pubkey[-4:] 38 | 39 | name = self.seen_pubkeys[event.pubkey] 40 | kind = "mail" 41 | if event.kind == 7: 42 | kind = "a reaction" 43 | elif event.kind == 1: 44 | kind = "a reply" 45 | announcement = f"You've got {kind} from {name}" 46 | self.log.info(announcement) 47 | command = ["say", announcement, f"-v", self.VOICE] 48 | try: 49 | subprocess.run(command) 50 | except FileNotFoundError: 51 | self.log.error("Cannot speak the announcement") 52 | -------------------------------------------------------------------------------- /nostr_bot/cli.py: -------------------------------------------------------------------------------- 1 | """Console script for nostr_bot.""" 2 | import sys 3 | import click 4 | import asyncio 5 | import os 6 | from functools import wraps 7 | try: 8 | import uvloop 9 | uvloop.install() 10 | except ImportError: 11 | pass 12 | 13 | 14 | DEFAULT_RELAYS = os.getenv('NOSTR_RELAYS', 'wss://nostr.mom,wss://relay.snort.social').split(',') 15 | 16 | def stop(): 17 | loop = asyncio.get_running_loop() 18 | try: 19 | loop.stop() 20 | except RuntimeError: 21 | return 22 | 23 | 24 | def async_cmd(func): 25 | @wraps(func) 26 | def wrapper(*args, **kwargs): 27 | async def _run(coro): 28 | from signal import SIGINT, SIGTERM 29 | 30 | loop = asyncio.get_running_loop() 31 | for signal_enum in [SIGINT, SIGTERM]: 32 | loop.add_signal_handler(signal_enum, stop) 33 | 34 | await coro 35 | coro = func(*args, **kwargs) 36 | try: 37 | asyncio.run(_run(coro)) 38 | except RuntimeError: 39 | return 40 | return wrapper 41 | 42 | 43 | @click.group() 44 | def main(args=None): 45 | """Console script for nostr_bot.""" 46 | return 0 47 | 48 | 49 | @main.command() 50 | @click.option('-c', '--cls', multiple=True, help='bot class(es) to run', default=['nostr_bot.NostrBot']) 51 | @click.option('-r', 'relays', multiple=True, help='Relay address (can be added multiple times)', default=DEFAULT_RELAYS) 52 | @click.option('-v', '--verbose', help='verbose results', is_flag=True, default=False) 53 | @async_cmd 54 | async def run(relays, cls, verbose): 55 | """ 56 | Run a bot 57 | """ 58 | import logging 59 | import importlib 60 | from .bot import start_multiple 61 | logging.basicConfig(format='%(asctime)s %(name)s %(levelname)s – %(message)s', level=logging.DEBUG if verbose else logging.INFO) 62 | 63 | bots = [] 64 | for classname in cls: 65 | try: 66 | bot_module, bot_class = classname.rsplit('.', 1) 67 | bot_class = getattr(importlib.import_module(bot_module), bot_class) 68 | except (ImportError, AttributeError): 69 | click.echo(f"Class {classname} not found") 70 | return -1 71 | bots.append(bot_class()) 72 | await start_multiple(bots, relays=relays) 73 | 74 | 75 | 76 | 77 | if __name__ == "__main__": 78 | sys.exit(main()) # pragma: no cover 79 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-build clean-pyc clean-test coverage dist docs help install lint lint/flake8 lint/black 2 | .DEFAULT_GOAL := help 3 | 4 | define BROWSER_PYSCRIPT 5 | import os, webbrowser, sys 6 | 7 | from urllib.request import pathname2url 8 | 9 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 10 | endef 11 | export BROWSER_PYSCRIPT 12 | 13 | define PRINT_HELP_PYSCRIPT 14 | import re, sys 15 | 16 | for line in sys.stdin: 17 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 18 | if match: 19 | target, help = match.groups() 20 | print("%-20s %s" % (target, help)) 21 | endef 22 | export PRINT_HELP_PYSCRIPT 23 | 24 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 25 | 26 | help: 27 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 28 | 29 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 30 | 31 | clean-build: ## remove build artifacts 32 | rm -fr build/ 33 | rm -fr dist/ 34 | rm -fr .eggs/ 35 | find . -name '*.egg-info' -exec rm -fr {} + 36 | find . -name '*.egg' -exec rm -f {} + 37 | 38 | clean-pyc: ## remove Python file artifacts 39 | find . -name '*.pyc' -exec rm -f {} + 40 | find . -name '*.pyo' -exec rm -f {} + 41 | find . -name '*~' -exec rm -f {} + 42 | find . -name '__pycache__' -exec rm -fr {} + 43 | 44 | clean-test: ## remove test and coverage artifacts 45 | rm -fr .tox/ 46 | rm -f .coverage 47 | rm -fr htmlcov/ 48 | rm -fr .pytest_cache 49 | 50 | lint/flake8: ## check style with flake8 51 | flake8 nostr_bot tests 52 | lint/black: ## check style with black 53 | black --check nostr_bot tests 54 | 55 | lint: lint/flake8 lint/black ## check style 56 | 57 | test: ## run tests quickly with the default Python 58 | python setup.py test 59 | 60 | test-all: ## run tests on every Python version with tox 61 | tox 62 | 63 | coverage: ## check code coverage quickly with the default Python 64 | coverage run --source nostr_bot setup.py test 65 | coverage report -m 66 | coverage html 67 | $(BROWSER) htmlcov/index.html 68 | 69 | docs: ## generate Sphinx HTML documentation, including API docs 70 | rm -f docs/nostr_bot.rst 71 | rm -f docs/modules.rst 72 | sphinx-apidoc -o docs/ nostr_bot 73 | $(MAKE) -C docs clean 74 | $(MAKE) -C docs html 75 | $(BROWSER) docs/_build/html/index.html 76 | 77 | servedocs: docs ## compile the docs watching for changes 78 | watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . 79 | 80 | release: dist ## package and upload a release 81 | twine upload dist/* 82 | 83 | dist: clean ## builds source and wheel package 84 | python setup.py sdist 85 | python setup.py bdist_wheel 86 | ls -l dist 87 | 88 | install: clean ## install the package to the active Python's site-packages 89 | python setup.py install 90 | -------------------------------------------------------------------------------- /nostr_bot/examples/mirror.py: -------------------------------------------------------------------------------- 1 | """ 2 | MirrorBot will mirror events from authors you follow. 3 | The source relays are set with environment variable NOSTR_RELAYS or with -r on the command line 4 | 5 | To run: 6 | 7 | TARGET=ws://target-relay.biz PUBLIC_KEY= nostr-bot run -c nostr_bot.examples.mirror.MirrorFollowersBot 8 | """ 9 | import os 10 | import time 11 | import shelve 12 | from nostr_bot.bot import NostrBot 13 | from aionostr import Manager 14 | 15 | 16 | class MirrorFollowersBot(NostrBot): 17 | MY_PUBKEY = os.getenv('PUBLIC_KEY') 18 | TARGET_RELAY = os.getenv('TARGET') 19 | shelf = shelve.open(f'mirrorbot-{TARGET_RELAY.replace("://", "-")}') 20 | 21 | def get_query(self): 22 | return self.query 23 | 24 | def get_last_seen(self): 25 | return self.shelf.get('last_seen', 1) 26 | 27 | def set_last_seen(self, timestamp): 28 | if timestamp > self.get_last_seen(): 29 | self.shelf['last_seen'] = timestamp 30 | 31 | async def get_following(self): 32 | find_query = { 33 | 'kinds': [3], 34 | 'authors': [self.MY_PUBKEY] 35 | } 36 | following = [self.MY_PUBKEY] 37 | self.log.info("Getting following for %s %s", self.MY_PUBKEY, find_query) 38 | async for event in self.manager.get_events(find_query): 39 | for tag in event.tags: 40 | if tag[0] == 'p': 41 | following.append(tag[1]) 42 | return following 43 | 44 | async def start(self): 45 | self.target_manager = Manager([self.TARGET_RELAY]) 46 | await self.target_manager.connect() 47 | await self.manager.connect() 48 | self.query = { 49 | 'authors': await self.get_following(), 50 | 'since': self.get_last_seen() 51 | } 52 | return await super().start() 53 | 54 | async def handle_event(self, event): 55 | if event.id in self.shelf: 56 | return 57 | await self.target_manager.add_event(event) 58 | self.log.info("Mirrored %s from %s to %s", event.id[:8], event.pubkey, self.TARGET_RELAY) 59 | self.set_last_seen(event.created_at) 60 | self.shelf[event.id] = time.time() 61 | 62 | 63 | class MirrorFOAFBot(MirrorFollowersBot): 64 | """ 65 | Mirrors authors you follow, and their follows 66 | """ 67 | async def get_following(self): 68 | find_query = { 69 | 'kinds': [3], 70 | 'authors': [self.MY_PUBKEY] 71 | } 72 | following = set([self.MY_PUBKEY]) 73 | self.log.info("Getting following for %s %s", self.MY_PUBKEY, find_query) 74 | async for event in self.manager.get_events(find_query): 75 | for tag in event.tags: 76 | if tag[0] == 'p': 77 | following.add(tag[1]) 78 | self.log.info("Getting extended network") 79 | find_query['authors'] = list(following) 80 | async for event in self.manager.get_events(find_query): 81 | for tag in event.tags: 82 | if tag[0] == 'p': 83 | following.add(tag[1]) 84 | return list(following) 85 | -------------------------------------------------------------------------------- /nostr_bot/examples/reporting.py: -------------------------------------------------------------------------------- 1 | """ 2 | TattleBot will listen for kind=1984 reports and notify the subject of the report that they've been tattled on. 3 | 4 | You can subclass TattleBot and override handle_message(event, tattle_subject, message) to do something more interesting with the generated message 5 | Override create_message(event, report_type, tattled_event_id, impersonation) to generate a different response 6 | 7 | To run: 8 | TATTLE_WATCH= NOSTR_KEY= nostr-bot run -c nostr_bot.examples.reporting.TattleBot -r wss://my.relay.biz 9 | 10 | """ 11 | 12 | from nostr_bot.bot import CommunicatorBot 13 | from aionostr.util import to_nip19 14 | import shelve 15 | import os 16 | import time 17 | 18 | 19 | class TattleBot(CommunicatorBot): 20 | SEND_MESSAGE = False 21 | 22 | shelf = shelve.open('tattlebot') 23 | 24 | def get_query(self): 25 | query = { 26 | 'kinds': [1984] 27 | } 28 | last_seen = self.get_last_seen() 29 | if last_seen: 30 | query['since'] = last_seen 31 | pubkeys = self.get_watch_for_pubkeys() 32 | if pubkeys: 33 | query['#p'] = pubkeys 34 | return query 35 | 36 | def get_last_seen(self): 37 | return self.shelf.get('last_seen', 0) 38 | 39 | def set_last_seen(self, event): 40 | self.shelf['last_seen'] = max(event.created_at, self.get_last_seen()) 41 | 42 | def get_watch_for_pubkeys(self): 43 | pubkeys = os.getenv('TATTLE_WATCH', '').split(',') 44 | if all(pubkeys): 45 | return pubkeys 46 | 47 | async def handle_event(self, event): 48 | if event.id in self.shelf: 49 | self.log.info("Skipping %s", event.id) 50 | return 51 | report_type = '' 52 | tattled_event_id = '' 53 | tattle_subject = '' 54 | impersonation = '' 55 | for tag in event.tags: 56 | if tag[0] == 'report': 57 | report_type = tag[1] 58 | elif tag[0] == 'e': 59 | tattled_event_id = tag[1] 60 | elif tag[0] == 'p': 61 | if len(tag) == 3 and tag[2] == 'impersonation': 62 | impersonation = tag[1] 63 | else: 64 | tattle_subject = tag[1] 65 | 66 | message = self.create_message(event, report_type, tattled_event_id, impersonation) 67 | 68 | response = await self.handle_message(event, tattle_subject, message) 69 | self.set_last_seen(event) 70 | self.shelf[event.id] = {'seen': time.time(), 'response': response} 71 | 72 | def create_message(self, event, report_type, tattled_event_id, impersonation): 73 | reporter = to_nip19('npub', event.pubkey) 74 | if tattled_event_id: 75 | tattle_note = to_nip19('note', tattled_event_id) 76 | else: 77 | tattle_note = '' 78 | if report_type == 'spam': 79 | reason = 'spamming' 80 | elif report_type == 'illegal': 81 | reason = 'doing something illegal' 82 | elif report_type == 'impersonation': 83 | reason = f'impersonating {to_nip19("npub", impersonation)}' 84 | else: 85 | reason = report_type 86 | response = f'''YOU'RE IN TROUBLE 87 | {reporter} tattled on you for {reason} in {tattle_note}. 88 | They said you were "{event.content}" 89 | 90 | Just letting you know. 91 | 92 | Sent by TattleBot. 93 | ''' 94 | return response 95 | 96 | async def handle_message(self, event, tattle_subject, message): 97 | if tattle_subject and message: 98 | if self.SEND_MESSAGE: 99 | dm = self.make_dm(tattle_subject, content=message) 100 | self.log.debug(str(dm)) 101 | await self.reply(dm) 102 | self.log.info("Alerted %s about tattling on %s with dm %s", tattle_subject, event.id, dm.id) 103 | return dm.id 104 | else: 105 | self.log.info("%s tattled on %s. Sending:\n%s", event.pubkey, tattle_subject, message) 106 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every little bit 8 | helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/davestgermain/nostr_bot/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 30 | wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | nostr bot could always use more documentation, whether as part of the 42 | official nostr bot docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at https://github.com/davestgermain/nostr_bot/issues. 49 | 50 | If you are proposing a feature: 51 | 52 | * Explain in detail how it would work. 53 | * Keep the scope as narrow as possible, to make it easier to implement. 54 | * Remember that this is a volunteer-driven project, and that contributions 55 | are welcome :) 56 | 57 | Get Started! 58 | ------------ 59 | 60 | Ready to contribute? Here's how to set up `nostr_bot` for local development. 61 | 62 | 1. Fork the `nostr_bot` repo on GitHub. 63 | 2. Clone your fork locally:: 64 | 65 | $ git clone git@github.com:your_name_here/nostr_bot.git 66 | 67 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 68 | 69 | $ mkvirtualenv nostr_bot 70 | $ cd nostr_bot/ 71 | $ python setup.py develop 72 | 73 | 4. Create a branch for local development:: 74 | 75 | $ git checkout -b name-of-your-bugfix-or-feature 76 | 77 | Now you can make your changes locally. 78 | 79 | 5. When you're done making changes, check that your changes pass flake8 and the 80 | tests, including testing other Python versions with tox:: 81 | 82 | $ flake8 nostr_bot tests 83 | $ python setup.py test or pytest 84 | $ tox 85 | 86 | To get flake8 and tox, just pip install them into your virtualenv. 87 | 88 | 6. Commit your changes and push your branch to GitHub:: 89 | 90 | $ git add . 91 | $ git commit -m "Your detailed description of your changes." 92 | $ git push origin name-of-your-bugfix-or-feature 93 | 94 | 7. Submit a pull request through the GitHub website. 95 | 96 | Pull Request Guidelines 97 | ----------------------- 98 | 99 | Before you submit a pull request, check that it meets these guidelines: 100 | 101 | 1. The pull request should include tests. 102 | 2. If the pull request adds functionality, the docs should be updated. Put 103 | your new functionality into a function with a docstring, and add the 104 | feature to the list in README.rst. 105 | 3. The pull request should work for Python 3.5, 3.6, 3.7 and 3.8, and for PyPy. Check 106 | https://travis-ci.com/davestgermain/nostr_bot/pull_requests 107 | and make sure that the tests pass for all supported Python versions. 108 | 109 | Tips 110 | ---- 111 | 112 | To run a subset of tests:: 113 | 114 | 115 | $ python -m unittest tests.test_nostr_bot 116 | 117 | Deploying 118 | --------- 119 | 120 | A reminder for the maintainers on how to deploy. 121 | Make sure all your changes are committed (including an entry in HISTORY.rst). 122 | Then run:: 123 | 124 | $ bump2version patch # possible: major / minor / patch 125 | $ git push 126 | $ git push --tags 127 | 128 | Travis will then deploy to PyPI if tests pass. 129 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # nostr_bot documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Jun 9 13:47:02 2017. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another 16 | # directory, add these directories to sys.path here. If the directory is 17 | # relative to the documentation root, use os.path.abspath to make it 18 | # absolute, like shown here. 19 | # 20 | import os 21 | import sys 22 | sys.path.insert(0, os.path.abspath('..')) 23 | 24 | import nostr_bot 25 | 26 | # -- General configuration --------------------------------------------- 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | # 30 | # needs_sphinx = '1.0' 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 34 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ['_templates'] 38 | 39 | # The suffix(es) of source filenames. 40 | # You can specify multiple suffix as a list of string: 41 | # 42 | # source_suffix = ['.rst', '.md'] 43 | source_suffix = '.rst' 44 | 45 | # The master toctree document. 46 | master_doc = 'index' 47 | 48 | # General information about the project. 49 | project = 'nostr bot' 50 | copyright = "2023, Dave St.Germain" 51 | author = "Dave St.Germain" 52 | 53 | # The version info for the project you're documenting, acts as replacement 54 | # for |version| and |release|, also used in various other places throughout 55 | # the built documents. 56 | # 57 | # The short X.Y version. 58 | version = nostr_bot.__version__ 59 | # The full version, including alpha/beta/rc tags. 60 | release = nostr_bot.__version__ 61 | 62 | # The language for content autogenerated by Sphinx. Refer to documentation 63 | # for a list of supported languages. 64 | # 65 | # This is also used if you do content translation via gettext catalogs. 66 | # Usually you set "language" from the command line for these cases. 67 | language = None 68 | 69 | # List of patterns, relative to source directory, that match files and 70 | # directories to ignore when looking for source files. 71 | # This patterns also effect to html_static_path and html_extra_path 72 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 73 | 74 | # The name of the Pygments (syntax highlighting) style to use. 75 | pygments_style = 'sphinx' 76 | 77 | # If true, `todo` and `todoList` produce output, else they produce nothing. 78 | todo_include_todos = False 79 | 80 | 81 | # -- Options for HTML output ------------------------------------------- 82 | 83 | # The theme to use for HTML and HTML Help pages. See the documentation for 84 | # a list of builtin themes. 85 | # 86 | html_theme = 'alabaster' 87 | 88 | # Theme options are theme-specific and customize the look and feel of a 89 | # theme further. For a list of options available for each theme, see the 90 | # documentation. 91 | # 92 | # html_theme_options = {} 93 | 94 | # Add any paths that contain custom static files (such as style sheets) here, 95 | # relative to this directory. They are copied after the builtin static files, 96 | # so a file named "default.css" will overwrite the builtin "default.css". 97 | html_static_path = ['_static'] 98 | 99 | 100 | # -- Options for HTMLHelp output --------------------------------------- 101 | 102 | # Output file base name for HTML help builder. 103 | htmlhelp_basename = 'nostr_botdoc' 104 | 105 | 106 | # -- Options for LaTeX output ------------------------------------------ 107 | 108 | latex_elements = { 109 | # The paper size ('letterpaper' or 'a4paper'). 110 | # 111 | # 'papersize': 'letterpaper', 112 | 113 | # The font size ('10pt', '11pt' or '12pt'). 114 | # 115 | # 'pointsize': '10pt', 116 | 117 | # Additional stuff for the LaTeX preamble. 118 | # 119 | # 'preamble': '', 120 | 121 | # Latex figure (float) alignment 122 | # 123 | # 'figure_align': 'htbp', 124 | } 125 | 126 | # Grouping the document tree into LaTeX files. List of tuples 127 | # (source start file, target name, title, author, documentclass 128 | # [howto, manual, or own class]). 129 | latex_documents = [ 130 | (master_doc, 'nostr_bot.tex', 131 | 'nostr bot Documentation', 132 | 'Dave St.Germain', 'manual'), 133 | ] 134 | 135 | 136 | # -- Options for manual page output ------------------------------------ 137 | 138 | # One entry per manual page. List of tuples 139 | # (source start file, name, description, authors, manual section). 140 | man_pages = [ 141 | (master_doc, 'nostr_bot', 142 | 'nostr bot Documentation', 143 | [author], 1) 144 | ] 145 | 146 | 147 | # -- Options for Texinfo output ---------------------------------------- 148 | 149 | # Grouping the document tree into Texinfo files. List of tuples 150 | # (source start file, target name, title, author, 151 | # dir menu entry, description, category) 152 | texinfo_documents = [ 153 | (master_doc, 'nostr_bot', 154 | 'nostr bot Documentation', 155 | author, 156 | 'nostr_bot', 157 | 'One line description of project.', 158 | 'Miscellaneous'), 159 | ] 160 | 161 | 162 | 163 | -------------------------------------------------------------------------------- /nostr_bot/bot.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from aionostr.relay import Manager 5 | from aionostr.event import Event, loads, dumps 6 | 7 | __all__ = ('NostrBot', 'RPCBot', 'CommunicatorBot') 8 | 9 | 10 | class NostrBot: 11 | """ 12 | Base bot class that listens for a query and logs the results 13 | 14 | Override `handle_event(event)` to do something interesting 15 | """ 16 | LISTEN_KIND = 1 17 | LISTEN_PUBKEY = '' 18 | LIMIT = 1 19 | RELAYS = ['ws://localhost:6969'] 20 | PRIVATE_KEY = os.getenv('NOSTR_KEY', 'd3b7207018ac76dfab82100a6c07a42c68b8efc4898b96d2882b8a7636dd0498') 21 | 22 | def __init__(self): 23 | self.log = logging.getLogger(self.get_origin()) 24 | self._manager = None 25 | 26 | def get_origin(self): 27 | return self.__class__.__name__ 28 | 29 | def get_manager(self): 30 | pk = self.private_key 31 | if pk: 32 | pk = pk.hex() 33 | return Manager(self.get_relays(), origin=self.get_origin(), private_key=pk) 34 | 35 | def get_relays(self): 36 | return self.RELAYS 37 | 38 | def get_query(self): 39 | filter_obj = { 40 | 'limit': self.LIMIT 41 | } 42 | if self.LISTEN_PUBKEY: 43 | filter_obj['authors'] = [self.LISTEN_PUBKEY] 44 | if self.LISTEN_KIND is not None: 45 | filter_obj['kinds'] = [self.LISTEN_KIND] 46 | return filter_obj 47 | 48 | @property 49 | def private_key(self): 50 | if not self.PRIVATE_KEY: 51 | return None 52 | from aionostr.key import PrivateKey 53 | from aionostr.util import from_nip19 54 | if self.PRIVATE_KEY.startswith('nsec'): 55 | pk = from_nip19(self.PRIVATE_KEY)['object'] 56 | else: 57 | pk = PrivateKey(bytes.fromhex(self.PRIVATE_KEY)) 58 | return pk 59 | 60 | @property 61 | def manager(self): 62 | if not self._manager: 63 | self._manager = self.get_manager() 64 | return self._manager 65 | 66 | @manager.setter 67 | def manager(self, manager): 68 | self._manager = manager 69 | 70 | async def start(self): 71 | if self.private_key: 72 | self.manager.private_key = self.private_key.hex() 73 | await self.manager.connect() 74 | 75 | query = self.get_query() 76 | self.log.info("Running query %s on %s", query, self.get_relays()) 77 | 78 | 79 | async for event in self.manager.get_events(query, only_stored=False): 80 | try: 81 | if not event.verify(): 82 | self.log.warning('Invalid event: %s', event.id) 83 | continue 84 | except Exception as e: 85 | self.log.error(str(e)) 86 | continue 87 | try: 88 | await self.handle_event(event) 89 | except Exception: 90 | self.log.exception('handle_event') 91 | 92 | async def handle_event(self, event: Event): 93 | """ 94 | Override this to do useful things 95 | """ 96 | self.log.info(str(event)) 97 | 98 | 99 | class CommunicatorBot(NostrBot): 100 | """ 101 | A bot that can make DM's and reply 102 | """ 103 | PUBLIC_KEY = '' 104 | 105 | def __init__(self): 106 | super().__init__() 107 | if not self.PUBLIC_KEY: 108 | self.PUBLIC_KEY = self.private_key.public_key.hex() 109 | elif self.PUBLIC_KEY.startswith('npub'): 110 | from aionostr.util import from_nip19 111 | self.PUBLIC_KEY = from_nip19(self.PUBLIC_KEY)['object'].hex() 112 | 113 | def make_event(self, encrypt_to=None, **event_args): 114 | if encrypt_to: 115 | event_args['content'] = self.private_key.encrypt_message(event_args['content'], encrypt_to) 116 | if not event_args.get('pubkey'): 117 | event_args['pubkey'] = self.PUBLIC_KEY 118 | event = Event(**event_args) 119 | 120 | event.sign(self.private_key.hex()) 121 | return event 122 | 123 | def make_dm(self, encrypt_to, **event_args): 124 | tags = event_args.get('tags', []) 125 | tags.append(["p", encrypt_to]) 126 | event_args['kind'] = 4 127 | event_args['tags'] = tags 128 | event = self.make_event(encrypt_to=encrypt_to, **event_args) 129 | return event 130 | 131 | async def reply(self, event): 132 | self.log.debug("Replying with %s", event) 133 | await self.manager.add_event(event, check_response=True) 134 | 135 | 136 | class RPCBot(CommunicatorBot): 137 | """ 138 | A bot that listens for (optionally encrypted) messages in the form: 139 | { 140 | "method": "name", 141 | "args": ["arg1", 1, "arg3"] 142 | } 143 | 144 | By default, these are kind 22222 -- ephemeral events 145 | """ 146 | LISTEN_KIND = 22222 147 | ENCRYPTED = True 148 | 149 | def __init__(self): 150 | super().__init__() 151 | self.LISTEN_PUBKEY = self.PUBLIC_KEY 152 | 153 | async def handle_event(self, event: Event): 154 | content = event.content 155 | if self.ENCRYPTED: 156 | try: 157 | content = self.private_key.decrypt_message(content, event.pubkey) 158 | except Exception: 159 | self.log.exception("decrypt") 160 | return 161 | try: 162 | command = loads(content) 163 | except Exception: 164 | self.log.exception("json") 165 | return 166 | try: 167 | func = getattr(self, f"on_{command['method']}") 168 | except (KeyError, AttributeError, TypeError): 169 | self.log.error("%s", command) 170 | return 171 | try: 172 | args = command['args'] 173 | response = await func(event, *args) 174 | self.log.debug("Command %s from %s", command, event) 175 | except Exception as e: 176 | self.log.exception(str(e)) 177 | else: 178 | if response and 'event' in response: 179 | await self.reply(response['event']) 180 | 181 | def make_response(self, event, **kwargs): 182 | if self.ENCRYPTED: 183 | kwargs['encrypt_to'] = event.pubkey 184 | response = { 185 | 'event': self.make_event(**kwargs) 186 | } 187 | return response 188 | 189 | 190 | class RegistrationBot(CommunicatorBot): 191 | LISTEN_KIND = 11141 192 | LIMIT = 100 193 | 194 | async def handle_event(self, event: Event): 195 | if not event.verify(): 196 | self.log("Bad event: %s", event.id) 197 | return 198 | self.log.info("Got registration request %s", event) 199 | 200 | if await self.is_valid(event): 201 | self.log.info("Valid registration from %s", event.pubkey) 202 | if await self.register(event): 203 | self.log.info("Registered pubkey %s", event.pubkey) 204 | 205 | async def is_valid(self, event: Event): 206 | valid = False 207 | for tag in event.tags: 208 | if tag[0] == 'relay': 209 | if tag[1] in self.RELAYS: 210 | valid = True 211 | return valid 212 | 213 | async def register(self, event: Event): 214 | return False 215 | 216 | 217 | async def start_multiple(bots, relays=None): 218 | """ 219 | Start multiple bots in their own task 220 | """ 221 | import asyncio 222 | 223 | if relays: 224 | first_manager = None 225 | for bot in bots: 226 | bot.RELAYS = relays 227 | if not first_manager: 228 | first_manager = bot.manager 229 | bot.manager = first_manager 230 | 231 | tasks = [asyncio.create_task(bot.start()) for bot in bots] 232 | try: 233 | await asyncio.wait(tasks) 234 | except asyncio.exceptions.CancelledError: 235 | return 236 | --------------------------------------------------------------------------------