Change to Nyx bot

This commit is contained in:
夜坂雅 2022-09-06 17:16:36 +08:00
parent e3c1de3a84
commit aec546e372
20 changed files with 245 additions and 250 deletions

View File

@ -1,4 +1,10 @@
# Nio Template [![Built with matrix-nio](https://img.shields.io/badge/built%20with-matrix--nio-brightgreen)](https://github.com/poljar/matrix-nio) <a href="https://matrix.to/#/#nio-template:matrix.org"><img src="https://img.shields.io/matrix/nio-template:matrix.org?color=blue&label=Join%20the%20Matrix%20Room&server_fqdn=matrix-client.matrix.org" /></a> # Nyx Bot [![Built with matrix-nio](https://img.shields.io/badge/built%20with-matrix--nio-brightgreen)](https://github.com/poljar/matrix-nio) <a href="https://matrix.to/#/#nio-template:matrix.org"><img src="https://img.shields.io/matrix/nio-template:matrix.org?color=blue&label=Join%20the%20Matrix%20Room&server_fqdn=matrix-client.matrix.org" /></a>
Matrix bot named after [Nyx](https://megamitensei.fandom.com/wiki/Nyx_Avatar).
The rest are from the original template.
---
A template for creating bots with A template for creating bots with
[matrix-nio](https://github.com/poljar/matrix-nio). The documentation for [matrix-nio](https://github.com/poljar/matrix-nio). The documentation for
@ -46,7 +52,7 @@ See [SETUP.md](SETUP.md) for how to setup and run the template project.
*A reference of each file included in the template repository, its purpose and *A reference of each file included in the template repository, its purpose and
what it does.* what it does.*
The majority of the code is kept inside of the `my_project_name` folder, which The majority of the code is kept inside of the `nyx_bot` folder, which
is in itself a [python package](https://docs.python.org/3/tutorial/modules.html), is in itself a [python package](https://docs.python.org/3/tutorial/modules.html),
the `__init__.py` file inside declaring it as such. the `__init__.py` file inside declaring it as such.
@ -65,7 +71,7 @@ their needs. Be sure never to check the edited `config.yaml` into source control
since it'll likely contain sensitive details such as passwords! since it'll likely contain sensitive details such as passwords!
Below is a detailed description of each of the source code files contained within Below is a detailed description of each of the source code files contained within
the `my_project_name` directory: the `nyx_bot` directory:
### `main.py` ### `main.py`

View File

@ -1,5 +1,7 @@
# Setup # Setup
_(This requires changing.)_
nio-template is a sample repository of a working Matrix bot that can be taken nio-template is a sample repository of a working Matrix bot that can be taken
and transformed into one's own bot, service or whatever else may be necessary. and transformed into one's own bot, service or whatever else may be necessary.
Below is a quick setup guide to running the existing bot. Below is a quick setup guide to running the existing bot.

View File

@ -58,8 +58,8 @@ RUN apk add --no-cache \
# Install python runtime modules. We do this before copying the source code # Install python runtime modules. We do this before copying the source code
# such that these dependencies can be cached # such that these dependencies can be cached
# This speeds up subsequent image builds when the source code is changed # This speeds up subsequent image builds when the source code is changed
RUN mkdir -p /src/my_project_name RUN mkdir -p /src/nyx_bot
COPY my_project_name/__init__.py /src/my_project_name/ COPY nyx_bot/__init__.py /src/nyx_bot/
COPY README.md my-project-name /src/ COPY README.md my-project-name /src/
# Build the dependencies # Build the dependencies
@ -68,7 +68,7 @@ RUN pip install --prefix="/python-libs" --no-warn-script-location "/src/.[postgr
# Now copy the source code # Now copy the source code
COPY *.py *.md /src/ COPY *.py *.md /src/
COPY my_project_name/*.py /src/my_project_name/ COPY nyx_bot/*.py /src/nyx_bot/
# And build the final module # And build the final module
RUN pip install --prefix="/python-libs" --no-warn-script-location "/src/.[postgres]" RUN pip install --prefix="/python-libs" --no-warn-script-location "/src/.[postgres]"

View File

@ -53,14 +53,14 @@ RUN apk add --no-cache \
# Install python runtime modules. We do this before copying the source code # Install python runtime modules. We do this before copying the source code
# such that these dependencies can be cached # such that these dependencies can be cached
RUN mkdir -p /src/my_project_name RUN mkdir -p /src/nyx_bot
COPY my_project_name/__init__.py /src/my_project_name/ COPY nyx_bot/__init__.py /src/nyx_bot/
COPY README.md my-project-name /src/ COPY README.md my-project-name /src/
COPY setup.py /src/setup.py COPY setup.py /src/setup.py
RUN pip install -e "/src/.[postgres]" RUN pip install -e "/src/.[postgres]"
# Now copy the source code # Now copy the source code
COPY my_project_name/*.py /src/my_project_name/ COPY nyx_bot/*.py /src/nyx_bot/
COPY *.py /src/ COPY *.py /src/
# Specify a volume that holds the config file, SQLite3 database, # Specify a volume that holds the config file, SQLite3 database,

View File

@ -1,211 +0,0 @@
import logging
from nio import (
AsyncClient,
InviteMemberEvent,
JoinError,
MatrixRoom,
MegolmEvent,
RoomGetEventError,
RoomMessageText,
UnknownEvent,
)
from my_project_name.bot_commands import Command
from my_project_name.chat_functions import make_pill, react_to_event, send_text_to_room
from my_project_name.config import Config
from my_project_name.message_responses import Message
from my_project_name.storage import Storage
logger = logging.getLogger(__name__)
class Callbacks:
def __init__(self, client: AsyncClient, store: Storage, config: Config):
"""
Args:
client: nio client used to interact with matrix.
store: Bot storage.
config: Bot configuration parameters.
"""
self.client = client
self.store = store
self.config = config
self.command_prefix = config.command_prefix
async def message(self, room: MatrixRoom, event: RoomMessageText) -> None:
"""Callback for when a message event is received
Args:
room: The room the event came from.
event: The event defining the message.
"""
# Extract the message text
msg = event.body
# Ignore messages from ourselves
if event.sender == self.client.user:
return
logger.debug(
f"Bot message received for room {room.display_name} | "
f"{room.user_name(event.sender)}: {msg}"
)
# Process as message if in a public room without command prefix
has_command_prefix = msg.startswith(self.command_prefix)
# room.is_group is often a DM, but not always.
# room.is_group does not allow room aliases
# room.member_count > 2 ... we assume a public room
# room.member_count <= 2 ... we assume a DM
if not has_command_prefix and room.member_count > 2:
# General message listener
message = Message(self.client, self.store, self.config, msg, room, event)
await message.process()
return
# Otherwise if this is in a 1-1 with the bot or features a command prefix,
# treat it as a command
if has_command_prefix:
# Remove the command prefix
msg = msg[len(self.command_prefix) :]
command = Command(self.client, self.store, self.config, msg, room, event)
await command.process()
async def invite(self, room: MatrixRoom, event: InviteMemberEvent) -> None:
"""Callback for when an invite is received. Join the room specified in the invite.
Args:
room: The room that we are invited to.
event: The invite event.
"""
logger.debug(f"Got invite to {room.room_id} from {event.sender}.")
# Attempt to join 3 times before giving up
for attempt in range(3):
result = await self.client.join(room.room_id)
if type(result) == JoinError:
logger.error(
f"Error joining room {room.room_id} (attempt %d): %s",
attempt,
result.message,
)
else:
break
else:
logger.error("Unable to join room: %s", room.room_id)
# Successfully joined room
logger.info(f"Joined {room.room_id}")
async def invite_event_filtered_callback(
self, room: MatrixRoom, event: InviteMemberEvent
) -> None:
"""
Since the InviteMemberEvent is fired for every m.room.member state received
in a sync response's `rooms.invite` section, we will receive some that are
not actually our own invite event (such as the inviter's membership).
This makes sure we only call `callbacks.invite` with our own invite events.
"""
if event.state_key == self.client.user_id:
# This is our own membership (invite) event
await self.invite(room, event)
async def _reaction(
self, room: MatrixRoom, event: UnknownEvent, reacted_to_id: str
) -> None:
"""A reaction was sent to one of our messages. Let's send a reply acknowledging it.
Args:
room: The room the reaction was sent in.
event: The reaction event.
reacted_to_id: The event ID that the reaction points to.
"""
logger.debug(f"Got reaction to {room.room_id} from {event.sender}.")
# Get the original event that was reacted to
event_response = await self.client.room_get_event(room.room_id, reacted_to_id)
if isinstance(event_response, RoomGetEventError):
logger.warning(
"Error getting event that was reacted to (%s)", reacted_to_id
)
return
reacted_to_event = event_response.event
# Only acknowledge reactions to events that we sent
if reacted_to_event.sender != self.config.user_id:
return
# Send a message acknowledging the reaction
reaction_sender_pill = make_pill(event.sender)
reaction_content = (
event.source.get("content", {}).get("m.relates_to", {}).get("key")
)
message = (
f"{reaction_sender_pill} reacted to this event with `{reaction_content}`!"
)
await send_text_to_room(
self.client,
room.room_id,
message,
reply_to_event_id=reacted_to_id,
)
async def decryption_failure(self, room: MatrixRoom, event: MegolmEvent) -> None:
"""Callback for when an event fails to decrypt. Inform the user.
Args:
room: The room that the event that we were unable to decrypt is in.
event: The encrypted event that we were unable to decrypt.
"""
logger.error(
f"Failed to decrypt event '{event.event_id}' in room '{room.room_id}'!"
f"\n\n"
f"Tip: try using a different device ID in your config file and restart."
f"\n\n"
f"If all else fails, delete your store directory and let the bot recreate "
f"it (your reminders will NOT be deleted, but the bot may respond to existing "
f"commands a second time)."
)
red_x_and_lock_emoji = "❌ 🔐"
# React to the undecryptable event with some emoji
await react_to_event(
self.client,
room.room_id,
event.event_id,
red_x_and_lock_emoji,
)
async def unknown(self, room: MatrixRoom, event: UnknownEvent) -> None:
"""Callback for when an event with a type that is unknown to matrix-nio is received.
Currently this is used for reaction events, which are not yet part of a released
matrix spec (and are thus unknown to nio).
Args:
room: The room the reaction was sent in.
event: The event itself.
"""
if event.type == "m.reaction":
# Get the ID of the event this was a reaction to
relation_dict = event.source.get("content", {}).get("m.relates_to", {})
reacted_to = relation_dict.get("event_id")
if reacted_to and relation_dict.get("rel_type") == "m.annotation":
await self._reaction(room, event, reacted_to)
return
logger.debug(
f"Got unknown event with type to {event.type} from {event.sender} in {room.room_id}."
)

View File

@ -2,9 +2,9 @@
import asyncio import asyncio
try: try:
from my_project_name import main from nyx_bot import main
# Run the main function of the bot # Run the main function of the bot
asyncio.get_event_loop().run_until_complete(main.main()) asyncio.get_event_loop().run_until_complete(main.main())
except ImportError as e: except ImportError as e:
print("Unable to import my_project_name.main:", e) print("Unable to import nyx_box.main:", e)

View File

@ -2,7 +2,7 @@ import sys
# Check that we're not running on an unsupported Python version. # Check that we're not running on an unsupported Python version.
if sys.version_info < (3, 5): if sys.version_info < (3, 5):
print("my_project_name requires Python 3.5 or above.") print("nyx_bot requires Python 3.5 or above.")
sys.exit(1) sys.exit(1)
__version__ = "0.0.1" __version__ = "0.0.1"

View File

@ -1,8 +1,8 @@
from nio import AsyncClient, MatrixRoom, RoomMessageText from nio import AsyncClient, MatrixRoom, RoomMessageText
from my_project_name.chat_functions import react_to_event, send_text_to_room from nyx_bot.chat_functions import react_to_event, send_text_to_room
from my_project_name.config import Config from nyx_bot.config import Config
from my_project_name.storage import Storage from nyx_bot.storage import Storage
class Command: class Command:
@ -72,7 +72,7 @@ class Command:
"""Show the help text""" """Show the help text"""
if not self.args: if not self.args:
text = ( text = (
"Hello, I am a bot made with matrix-nio! Use `help commands` to view " "matrix-nio\nUse `help commands` to view "
"available commands." "available commands."
) )
await send_text_to_room(self.client, self.room.room_id, text) await send_text_to_room(self.client, self.room.room_id, text)

144
nyx_bot/callbacks.py Normal file
View File

@ -0,0 +1,144 @@
import logging
from nio import (
AsyncClient,
InviteMemberEvent,
JoinError,
MatrixRoom,
RoomMessageText,
UnknownEvent,
)
# from nyx_bot.bot_commands import Command
from nyx_bot.chat_functions import send_jerryxiao
from nyx_bot.config import Config
# from nyx_bot.message_responses import Message
from nyx_bot.storage import Storage
logger = logging.getLogger(__name__)
class Callbacks:
def __init__(self, client: AsyncClient, store: Storage, config: Config):
"""
Args:
client: nio client used to interact with matrix.
store: Bot storage.
config: Bot configuration parameters.
"""
self.client = client
self.store = store
self.config = config
self.command_prefix = config.command_prefix
async def message(self, room: MatrixRoom, event: RoomMessageText) -> None:
"""Callback for when a message event is received
Args:
room: The room the event came from.
event: The event defining the message.
"""
# Extract the message text
msg = event.body
# Ignore messages from ourselves
if event.sender == self.client.user:
return
logger.debug(
f"Bot message received for room {room.display_name} | "
f"{room.user_name(event.sender)}: {msg}"
)
content = event.source.get("content")
reply_to = ((content.get("m.relates_to") or {}).get("m.in_reply_to") or {}).get(
"event_id"
)
logger.debug(f"In-Reply-To: {reply_to}")
has_jerryxiao_prefix = False
for i in msg.splitlines():
if i.startswith("/") or i.startswith("!!"):
has_jerryxiao_prefix = True
msg = i
break
if has_jerryxiao_prefix and reply_to:
if msg.startswith("/"):
await send_jerryxiao(self.client, room, event, "/", reply_to, msg)
elif msg.startswith("!!"):
await send_jerryxiao(self.client, room, event, "!!", reply_to, msg)
# # Treat it as a command only if it has a prefix
# if has_command_prefix:
# # Remove the command prefix
# msg = msg[len(self.command_prefix) :]
# command = Command(self.client, self.store, self.config, msg, room, event)
# await command.process()
async def unknown(self, room: MatrixRoom, event: UnknownEvent) -> None:
"""Callback for when an event with a type that is unknown to matrix-nio is received.
Currently this is used for reaction events, which are not yet part of a released
matrix spec (and are thus unknown to nio).
Args:
room: The room the reaction was sent in.
event: The event itself.
"""
if event.type == "m.reaction":
# Get the ID of the event this was a reaction to
relation_dict = event.source.get("content", {}).get("m.relates_to", {})
reacted_to = relation_dict.get("event_id")
if reacted_to and relation_dict.get("rel_type") == "m.annotation":
return
logger.debug(
f"Got unknown event with type to {event.type} from {event.sender} in {room.room_id}."
)
async def invite(self, room: MatrixRoom, event: InviteMemberEvent) -> None:
"""Callback for when an invite is received. Join the room specified in the invite.
Args:
room: The room that we are invited to.
event: The invite event.
"""
logger.debug(f"Got invite to {room.room_id} from {event.sender}.")
# Attempt to join 3 times before giving up
for attempt in range(3):
result = await self.client.join(room.room_id)
if type(result) == JoinError:
logger.error(
f"Error joining room {room.room_id} (attempt %d): %s",
attempt,
result.message,
)
else:
break
else:
logger.error("Unable to join room: %s", room.room_id)
# Successfully joined room
logger.info(f"Joined {room.room_id}")
async def invite_event_filtered_callback(
self, room: MatrixRoom, event: InviteMemberEvent
) -> None:
"""
Since the InviteMemberEvent is fired for every m.room.member state received
in a sync response's `rooms.invite` section, we will receive some that are
not actually our own invite event (such as the inviter's membership).
This makes sure we only call `callbacks.invite` with our own invite events.
"""
if event.state_key == self.client.user_id:
# This is our own membership (invite) event
await self.invite(room, event)

View File

@ -8,6 +8,7 @@ from nio import (
MatrixRoom, MatrixRoom,
MegolmEvent, MegolmEvent,
Response, Response,
RoomMessageText,
RoomSendResponse, RoomSendResponse,
SendRetryError, SendRetryError,
) )
@ -90,6 +91,61 @@ def make_pill(user_id: str, displayname: str = None) -> str:
return f'<a href="https://matrix.to/#/{user_id}">{displayname}</a>' return f'<a href="https://matrix.to/#/{user_id}">{displayname}</a>'
def make_jerryxiao_reply(
from_sender: str, to_sender: str, action: str, room: MatrixRoom
):
from_pill = make_pill(from_sender, room.user_name(from_sender))
to_pill = make_pill(to_sender, room.user_name(to_sender))
return f"{from_pill} {action}{to_pill}"
async def send_in_reply_to(
client: AsyncClient,
room_id: str,
event: RoomMessageText,
body: str,
formatted_body: str,
) -> Union[RoomSendResponse, ErrorResponse]:
content = {
"msgtype": "m.text",
"format": "org.matrix.custom.html",
"body": body,
"formatted_body": formatted_body,
}
content["m.relates_to"] = {"m.in_reply_to": {"event_id": event.event_id}}
try:
return await client.room_send(
room_id,
"m.room.message",
content,
ignore_unverified_devices=True,
)
except SendRetryError:
logger.exception(f"Unable to send message response to {room_id}")
async def send_jerryxiao(
client: AsyncClient,
room: MatrixRoom,
event: RoomMessageText,
prefix: str,
reply_to: str,
reference_text: str,
):
from_sender = event.sender
target_event = await client.room_get_event(room.room_id, reply_to)
to_sender = target_event.event.sender
action = reference_text[len(prefix) :]
if action != "":
send_text_formatted = make_jerryxiao_reply(from_sender, to_sender, action, room)
send_text = (
f"{room.user_name(from_sender)} {action}{room.user_name(to_sender)}"
)
await send_in_reply_to(
client, room.room_id, event, send_text, send_text_formatted
)
async def react_to_event( async def react_to_event(
client: AsyncClient, client: AsyncClient,
room_id: str, room_id: str,

View File

@ -6,7 +6,7 @@ from typing import Any, List, Optional
import yaml import yaml
from my_project_name.errors import ConfigError from nyx_bot.errors import ConfigError
logger = logging.getLogger() logger = logging.getLogger()
logging.getLogger("peewee").setLevel( logging.getLogger("peewee").setLevel(

View File

@ -11,14 +11,13 @@ from nio import (
InviteMemberEvent, InviteMemberEvent,
LocalProtocolError, LocalProtocolError,
LoginError, LoginError,
MegolmEvent,
RoomMessageText, RoomMessageText,
UnknownEvent, UnknownEvent,
) )
from my_project_name.callbacks import Callbacks from nyx_bot.callbacks import Callbacks
from my_project_name.config import Config from nyx_bot.config import Config
from my_project_name.storage import Storage from nyx_bot.storage import Storage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -44,7 +43,7 @@ async def main():
max_limit_exceeded=0, max_limit_exceeded=0,
max_timeouts=0, max_timeouts=0,
store_sync_tokens=True, store_sync_tokens=True,
encryption_enabled=True, encryption_enabled=False,
) )
# Initialize the matrix client # Initialize the matrix client
@ -63,11 +62,10 @@ async def main():
# Set up event callbacks # Set up event callbacks
callbacks = Callbacks(client, store, config) callbacks = Callbacks(client, store, config)
client.add_event_callback(callbacks.message, (RoomMessageText,)) client.add_event_callback(callbacks.message, (RoomMessageText,))
client.add_event_callback(callbacks.unknown, (UnknownEvent,))
client.add_event_callback( client.add_event_callback(
callbacks.invite_event_filtered_callback, (InviteMemberEvent,) callbacks.invite_event_filtered_callback, (InviteMemberEvent,)
) )
client.add_event_callback(callbacks.decryption_failure, (MegolmEvent,))
client.add_event_callback(callbacks.unknown, (UnknownEvent,))
# Keep trying to reconnect on failure (with some time in-between) # Keep trying to reconnect on failure (with some time in-between)
while True: while True:

View File

@ -2,9 +2,9 @@ import logging
from nio import AsyncClient, MatrixRoom, RoomMessageText from nio import AsyncClient, MatrixRoom, RoomMessageText
from my_project_name.chat_functions import send_text_to_room from nyx_bot.chat_functions import send_text_to_room
from my_project_name.config import Config from nyx_bot.config import Config
from my_project_name.storage import Storage from nyx_bot.storage import Storage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -11,7 +11,7 @@ if [ $# -ge 1 ]
then then
files=$* files=$*
else else
files="my_project_name my-project-name tests" files="nyx_bot nyx-bot tests"
fi fi
echo "Linting these locations: $files" echo "Linting these locations: $files"

View File

@ -12,7 +12,7 @@ ignore=W503,W504,E203,E731,E501
line_length = 88 line_length = 88
sections=FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,TESTS,LOCALFOLDER sections=FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,TESTS,LOCALFOLDER
default_section=THIRDPARTY default_section=THIRDPARTY
known_first_party=my_project_name known_first_party=nyx_bot
known_tests=tests known_tests=tests
multi_line_output=3 multi_line_output=3
include_trailing_comma=true include_trailing_comma=true

View File

@ -20,18 +20,18 @@ def read_file(path_segments):
return f.read() return f.read()
version = exec_file(("my_project_name", "__init__.py"))["__version__"] version = exec_file(("nyx_bot", "__init__.py"))["__version__"]
long_description = read_file(("README.md",)) long_description = read_file(("README.md",))
setup( setup(
name="my-project-name", name="nyx-bot",
version=version, version=version,
url="https://github.com/anoadragon453/nio-template", url="https://github.com/ShadowRZ/nyx-bot",
description="A matrix bot to do amazing things!", description="A matrix bot to do amazing things!",
packages=find_packages(exclude=["tests", "tests.*"]), packages=find_packages(exclude=["tests", "tests.*"]),
install_requires=[ install_requires=[
"matrix-nio[e2e]>=0.10.0", "matrix-nio>=0.10.0",
"Markdown>=3.1.1", "Markdown>=3.1.1",
"PyYAML>=5.1.2", "PyYAML>=5.1.2",
], ],
@ -54,6 +54,6 @@ setup(
], ],
long_description=long_description, long_description=long_description,
long_description_content_type="text/markdown", long_description_content_type="text/markdown",
# Allow the user to run the bot with `my-project-name ...` # Allow the user to run the bot with `nyx-bot ...`
scripts=["my-project-name"], scripts=["nyx-bot"],
) )

View File

@ -3,8 +3,8 @@ from unittest.mock import Mock
import nio import nio
from my_project_name.callbacks import Callbacks from nyx_bot.callbacks import Callbacks
from my_project_name.storage import Storage from nyx_bot.storage import Storage
from tests.utils import make_awaitable, run_coroutine from tests.utils import make_awaitable, run_coroutine

View File

@ -1,8 +1,8 @@
import unittest import unittest
from unittest.mock import Mock from unittest.mock import Mock
from my_project_name.config import Config from nyx_bot.config import Config
from my_project_name.errors import ConfigError from nyx_bot.errors import ConfigError
class ConfigTestCase(unittest.TestCase): class ConfigTestCase(unittest.TestCase):