implement matrix client failover

This commit is contained in:
HgO 2024-01-22 11:35:13 +01:00
parent 6cfdf342e6
commit 86aa44b260
20 changed files with 722 additions and 521 deletions

2
.gitignore vendored
View file

@ -12,7 +12,7 @@ env3/
*.db *.db
store/ store/
cache/ cache/
token.json *token.json
# Config file # Config file
config.yaml config.yaml

View file

@ -7,19 +7,20 @@ command_prefix: "!alert"
# Options for connecting to the bot's Matrix account # Options for connecting to the bot's Matrix account
matrix: matrix:
# The Matrix User ID of the bot account accounts:
user_id: "@bot:matrix.example.com" - # The Matrix User IDs of the bot account
id: "@bot:matrix.example.com"
# Matrix account password (optional if access token used) # Matrix account password (optional if access token used)
user_password: "password" password: "password"
# Matrix account access token (optional if password used) # Matrix account access token (optional if password used)
# If not set, the server will provide an access token after log in, # If not set, the server will provide an access token after log in,
# which will be stored in the user token file (see below) # which will be stored in the user token file (see below)
#user_token: "" #token: ""
# Path to the file where to store the user access token # Path to the file where to store the user access token
user_token_file: "token.json" token_file: "token.json"
# The URL of the homeserver to connect to # The URL of the homeserver to connect to
url: https://matrix.example.com url: https://matrix.example.com
@ -79,6 +80,8 @@ logging:
file_logging: file_logging:
# Whether logging to a file is enabled # Whether logging to a file is enabled
enabled: false enabled: false
# Logging level specific to file logging (optional)
level: WARN
# The path to the file to log to. May be relative or absolute # The path to the file to log to. May be relative or absolute
filepath: matrix-alertbot.log filepath: matrix-alertbot.log
# Configure logging to the console output # Configure logging to the console output

View file

@ -2,7 +2,7 @@ from __future__ import annotations
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Dict, List, Optional from typing import Any, Dict, List, Optional, Tuple, TypedDict, cast
import aiohttp import aiohttp
from aiohttp import ClientError from aiohttp import ClientError
@ -23,6 +23,24 @@ MAX_DURATION = timedelta(days=3652)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
AlertDict = TypedDict(
"AlertDict",
{
"fingerprint": str,
"labels": Dict[str, str],
},
)
SilenceDict = TypedDict(
"SilenceDict",
{
"id": str,
"matchers": List[Dict[str, Any]],
"createdBy": str,
"status": Dict[str, str],
},
)
class AlertmanagerClient: class AlertmanagerClient:
def __init__(self, url: str, cache: Cache) -> None: def __init__(self, url: str, cache: Cache) -> None:
@ -33,7 +51,7 @@ class AlertmanagerClient:
async def close(self) -> None: async def close(self) -> None:
await self.session.close() await self.session.close()
async def get_alerts(self) -> List[Dict]: async def get_alerts(self) -> List[AlertDict]:
try: try:
async with self.session.get(f"{self.api_url}/alerts") as response: async with self.session.get(f"{self.api_url}/alerts") as response:
response.raise_for_status() response.raise_for_status()
@ -43,12 +61,12 @@ class AlertmanagerClient:
"Cannot fetch alerts from Alertmanager" "Cannot fetch alerts from Alertmanager"
) from e ) from e
async def get_alert(self, fingerprint: str) -> Dict: async def get_alert(self, fingerprint: str) -> AlertDict:
logger.debug(f"Fetching details for alert with fingerprint {fingerprint}") logger.debug(f"Fetching details for alert with fingerprint {fingerprint}")
alerts = await self.get_alerts() alerts = await self.get_alerts()
return self._find_alert(fingerprint, alerts) return self._find_alert(fingerprint, alerts)
async def get_silences(self) -> List[Dict]: async def get_silences(self) -> List[SilenceDict]:
try: try:
async with self.session.get(f"{self.api_url}/silences") as response: async with self.session.get(f"{self.api_url}/silences") as response:
response.raise_for_status() response.raise_for_status()
@ -58,7 +76,7 @@ class AlertmanagerClient:
"Cannot fetch silences from Alertmanager" "Cannot fetch silences from Alertmanager"
) from e ) from e
async def get_silence(self, silence_id: str) -> Dict: async def get_silence(self, silence_id: str) -> SilenceDict:
logger.debug(f"Fetching details for silence with ID {silence_id}") logger.debug(f"Fetching details for silence with ID {silence_id}")
silences = await self.get_silences() silences = await self.get_silences()
return self._find_silence(silence_id, silences) return self._find_silence(silence_id, silences)
@ -93,12 +111,15 @@ class AlertmanagerClient:
logger.debug( logger.debug(
f"Reading silence for alert with fingerprint {fingerprint} from cache" f"Reading silence for alert with fingerprint {fingerprint} from cache"
) )
try:
silence_id: Optional[str] cache_result = cast(
expire_time: Optional[int] Optional[Tuple[str, int]], self.cache.get(fingerprint, expire_time=True)
silence_id, expire_time = self.cache.get(fingerprint, expire_time=True) )
except TypeError: if cache_result is not None:
silence_id, expire_time = cache_result
else:
silence_id = None silence_id = None
expire_time = None
if silence_id is None: if silence_id is None:
raise SilenceNotFoundError( raise SilenceNotFoundError(
@ -202,14 +223,14 @@ class AlertmanagerClient:
) from e ) from e
@staticmethod @staticmethod
def _find_alert(fingerprint: str, alerts: List[Dict]) -> Dict: def _find_alert(fingerprint: str, alerts: List[AlertDict]) -> AlertDict:
for alert in alerts: for alert in alerts:
if alert["fingerprint"] == fingerprint: if alert["fingerprint"] == fingerprint:
return alert return alert
raise AlertNotFoundError(f"Cannot find alert with fingerprint {fingerprint}") raise AlertNotFoundError(f"Cannot find alert with fingerprint {fingerprint}")
@staticmethod @staticmethod
def _find_silence(silence_id: str, silences: List[Dict]) -> Dict: def _find_silence(silence_id: str, silences: List[SilenceDict]) -> SilenceDict:
for silence in silences: for silence in silences:
if silence["id"] == silence_id: if silence["id"] == silence_id:
return silence return silence

View file

@ -1,25 +1,27 @@
from __future__ import annotations
import logging import logging
from diskcache import Cache from diskcache import Cache
from nio import ( from nio.client import AsyncClient
AsyncClient, from nio.events import (
Event,
InviteMemberEvent, InviteMemberEvent,
JoinError,
KeyVerificationCancel, KeyVerificationCancel,
KeyVerificationKey, KeyVerificationKey,
KeyVerificationMac, KeyVerificationMac,
KeyVerificationStart, KeyVerificationStart,
LocalProtocolError,
MatrixRoom,
MegolmEvent, MegolmEvent,
ReactionEvent,
RedactionEvent, RedactionEvent,
RoomGetEventError,
RoomMessageText, RoomMessageText,
SendRetryError, RoomMessageUnknown,
ToDeviceError,
UnknownEvent,
) )
from nio.exceptions import LocalProtocolError, SendRetryError
from nio.responses import JoinError, RoomGetEventError, RoomSendError, ToDeviceError
from nio.rooms import MatrixRoom
import matrix_alertbot.matrix
from matrix_alertbot.alertmanager import AlertmanagerClient from matrix_alertbot.alertmanager import AlertmanagerClient
from matrix_alertbot.chat_functions import strip_fallback from matrix_alertbot.chat_functions import strip_fallback
from matrix_alertbot.command import AckAlertCommand, CommandFactory, UnackAlertCommand from matrix_alertbot.command import AckAlertCommand, CommandFactory, UnackAlertCommand
@ -35,6 +37,7 @@ class Callbacks:
alertmanager_client: AlertmanagerClient, alertmanager_client: AlertmanagerClient,
cache: Cache, cache: Cache,
config: Config, config: Config,
matrix_client_pool: matrix_alertbot.matrix.MatrixClientPool,
): ):
""" """
Args: Args:
@ -47,6 +50,7 @@ class Callbacks:
config: Bot configuration parameters. config: Bot configuration parameters.
""" """
self.matrix_client = matrix_client self.matrix_client = matrix_client
self.matrix_client_pool = matrix_client_pool
self.cache = cache self.cache = cache
self.alertmanager_client = alertmanager_client self.alertmanager_client = alertmanager_client
self.config = config self.config = config
@ -60,8 +64,12 @@ class Callbacks:
event: The event defining the message. event: The event defining the message.
""" """
# Ignore message when we aren't the leader in the client pool
if self.matrix_client is not self.matrix_client_pool.matrix_client:
return
# Ignore messages from ourselves # Ignore messages from ourselves
if event.sender == self.matrix_client.user: if event.sender in self.config.user_ids:
return return
# Ignore messages from unauthorized room # Ignore messages from unauthorized room
@ -72,13 +80,16 @@ class Callbacks:
msg = strip_fallback(event.body) msg = strip_fallback(event.body)
logger.debug( logger.debug(
f"Bot message received for room {room.display_name} | " f"Bot {self.matrix_client.user_id} | Room ID {room.room_id} | "
f"{room.user_name(event.sender)}: {msg}" f"Event ID {event.event_id} | Sender {event.sender} | "
f"Message received: {msg}"
) )
# Process as message if in a public room without command prefix # Process as message if in a public room without command prefix
has_command_prefix = msg.startswith(self.command_prefix) has_command_prefix = msg.startswith(self.command_prefix)
if not has_command_prefix: if not has_command_prefix:
logger.debug( logger.debug(
f"Bot {self.matrix_client.user_id} | Room ID {room.room_id} | "
f"Event ID {event.event_id} | Sender {event.sender} | "
f"Cannot process message: Command prefix {self.command_prefix} not provided." f"Cannot process message: Command prefix {self.command_prefix} not provided."
) )
return return
@ -91,7 +102,11 @@ class Callbacks:
) )
if reacted_to_event_id is not None: if reacted_to_event_id is not None:
logger.debug(f"Command in reply to event ID {reacted_to_event_id}") logger.debug(
f"Bot {self.matrix_client.user_id} | Room ID {room.room_id} | "
f"Event ID {event.event_id} | Sender {event.sender} | "
f"Command received is in reply to event ID {reacted_to_event_id}"
)
# Remove the command prefix # Remove the command prefix
cmd = msg[len(self.command_prefix) :] cmd = msg[len(self.command_prefix) :]
@ -108,13 +123,22 @@ class Callbacks:
reacted_to_event_id, reacted_to_event_id,
) )
except TypeError as e: except TypeError as e:
logging.error(f"Cannot process command '{cmd}': {e}") logger.error(
f"Bot {self.matrix_client.user_id} | Room ID {room.room_id} | "
f"Event ID {event.event_id} | Sender {event.sender} | "
f"Cannot process command '{cmd}': {e}"
)
return return
try: try:
await command.process() await command.process()
except (SendRetryError, LocalProtocolError) as e: except (SendRetryError, LocalProtocolError) as e:
logger.exception(f"Unable to send message to {room.room_id}", exc_info=e) logger.exception(
f"Bot {self.matrix_client.user_id} | Room ID {room.room_id} | "
f"Event ID {event.event_id} | Sender {event.sender} | "
f"Cannot send message to room.",
exc_info=e,
)
async def invite(self, room: MatrixRoom, event: InviteMemberEvent) -> None: async def invite(self, room: MatrixRoom, event: InviteMemberEvent) -> None:
"""Callback for when an invite is received. Join the room specified in the invite. """Callback for when an invite is received. Join the room specified in the invite.
@ -128,7 +152,11 @@ class Callbacks:
if room.room_id not in self.config.allowed_rooms: if room.room_id not in self.config.allowed_rooms:
return return
logger.debug(f"Got invite to {room.room_id} from {event.sender}.") logger.debug(
f"Bot {self.matrix_client.user_id} | Room ID {room.room_id} | "
f"Sender {event.sender} | "
f"Invitation received."
)
# Attempt to join 3 times before giving up # Attempt to join 3 times before giving up
for attempt in range(3): for attempt in range(3):
@ -145,7 +173,11 @@ class Callbacks:
logger.error("Unable to join room: %s", room.room_id) logger.error("Unable to join room: %s", room.room_id)
# Successfully joined room # Successfully joined room
logger.info(f"Joined {room.room_id}") logger.info(
f"Bot {self.matrix_client.user_id} | Room ID {room.room_id} | "
f"Sender {event.sender} | "
f"Room joined."
)
async def invite_event_filtered_callback( async def invite_event_filtered_callback(
self, room: MatrixRoom, event: InviteMemberEvent self, room: MatrixRoom, event: InviteMemberEvent
@ -160,9 +192,7 @@ class Callbacks:
# This is our own membership (invite) event # This is our own membership (invite) event
await self.invite(room, event) await self.invite(room, event)
async def _reaction( async def reaction(self, room: MatrixRoom, event: ReactionEvent) -> None:
self, room: MatrixRoom, event: UnknownEvent, alert_event_id: str
) -> None:
"""A reaction was sent to one of our messages. Let's send a reply acknowledging it. """A reaction was sent to one of our messages. Let's send a reply acknowledging it.
Args: Args:
@ -172,34 +202,48 @@ class Callbacks:
reacted_to_id: The event ID that the reaction points to. reacted_to_id: The event ID that the reaction points to.
""" """
# Ignore message when we aren't the leader in the client pool
if self.matrix_client is not self.matrix_client_pool.matrix_client:
return
# Ignore reactions from unauthorized room # Ignore reactions from unauthorized room
if room.room_id not in self.config.allowed_rooms: if room.room_id not in self.config.allowed_rooms:
return return
# Ignore reactions from ourselves # Ignore reactions from ourselves
if event.sender == self.matrix_client.user: if event.sender in self.config.user_ids:
return return
reaction = event.source.get("content", {}).get("m.relates_to", {}).get("key") logger.debug(
logger.debug(f"Got reaction {reaction} to {room.room_id} from {event.sender}.") f"Bot {self.matrix_client.user_id} | Room ID {room.room_id} | "
f"Event ID {event.event_id} | Sender {event.sender} | "
f"Reaction received: {event.key}"
)
if reaction not in self.config.allowed_reactions: if event.key not in self.config.allowed_reactions:
logger.warning(f"Uknown duration reaction {reaction}") logger.warning(
f"Bot {self.matrix_client.user_id} | Room ID {room.room_id} | "
f"Event ID {event.event_id} | Sender {event.sender} | "
f"Reaction not handled: {event.key}"
)
return return
alert_event_id = event.reacts_to
# Get the original event that was reacted to # Get the original event that was reacted to
event_response = await self.matrix_client.room_get_event( event_response = await self.matrix_client.room_get_event(
room.room_id, alert_event_id room.room_id, alert_event_id
) )
if isinstance(event_response, RoomGetEventError): if isinstance(event_response, RoomGetEventError):
logger.warning( logger.warning(
f"Error getting event that was reacted to ({alert_event_id})" f"Bot {self.matrix_client.user_id} | Room ID {room.room_id} | "
f"Event ID {event.event_id} | Sender {event.sender} | "
f"Cannot get event related to the reaction and with event ID {alert_event_id}"
) )
return return
reacted_to_event = event_response.event reacted_to_event = event_response.event
# Only acknowledge reactions to events that we sent # Only acknowledge reactions to events that we sent
if reacted_to_event.sender != self.config.user_id: if reacted_to_event.sender not in self.config.user_ids:
return return
# Send a message acknowledging the reaction # Send a message acknowledging the reaction
@ -217,18 +261,31 @@ class Callbacks:
try: try:
await command.process() await command.process()
except (SendRetryError, LocalProtocolError) as e: except (SendRetryError, LocalProtocolError) as e:
logger.exception(f"Unable to send message to {room.room_id}", exc_info=e) logger.exception(
f"Bot {self.matrix_client.user_id} | Room ID {room.room_id} | "
f"Event ID {event.event_id} | Sender {event.sender} | "
f"Cannot send message to room.",
exc_info=e,
)
async def redaction(self, room: MatrixRoom, event: RedactionEvent) -> None: async def redaction(self, room: MatrixRoom, event: RedactionEvent) -> None:
# Ignore message when we aren't the leader in the client pool
if self.matrix_client is not self.matrix_client_pool.matrix_client:
return
# Ignore events from unauthorized room # Ignore events from unauthorized room
if room.room_id not in self.config.allowed_rooms: if room.room_id not in self.config.allowed_rooms:
return return
# Ignore redactions from ourselves # Ignore redactions from ourselves
if event.sender == self.matrix_client.user: if event.sender in self.config.user_ids:
return return
logger.debug(f"Received event to remove event ID {event.redacts}") logger.debug(
f"Bot {self.matrix_client.user_id} | Room ID {room.room_id} | "
f"Event ID {event.event_id} | Sender {event.sender} | "
f"Received event to remove event ID {event.redacts}"
)
command = UnackAlertCommand( command = UnackAlertCommand(
self.matrix_client, self.matrix_client,
@ -243,7 +300,12 @@ class Callbacks:
try: try:
await command.process() await command.process()
except (SendRetryError, LocalProtocolError) as e: except (SendRetryError, LocalProtocolError) as e:
logger.exception(f"Unable to send message to {room.room_id}", exc_info=e) logger.exception(
f"Bot {self.matrix_client.user_id} | Room ID {room.room_id} | "
f"Event ID {event.event_id} | Sender {event.sender} | "
f"Cannot send message to room.",
exc_info=e,
)
async def decryption_failure(self, room: MatrixRoom, event: MegolmEvent) -> None: async def decryption_failure(self, room: MatrixRoom, event: MegolmEvent) -> None:
"""Callback for when an event fails to decrypt. Inform the user. """Callback for when an event fails to decrypt. Inform the user.
@ -258,7 +320,9 @@ class Callbacks:
return return
logger.error( logger.error(
f"Failed to decrypt event '{event.event_id}' in room '{room.room_id}'!" f"Bot {self.matrix_client.user_id} | Room ID {room.room_id} | "
f"Event ID {event.event_id} | Sender {event.sender} | "
f"Failed to decrypt event!"
f"\n\n" f"\n\n"
f"Tip: try using a different device ID in your config file and restart." f"Tip: try using a different device ID in your config file and restart."
f"\n\n" f"\n\n"
@ -271,7 +335,8 @@ class Callbacks:
"""Callback for when somebody wants to verify our devices.""" """Callback for when somebody wants to verify our devices."""
if "emoji" not in event.short_authentication_string: if "emoji" not in event.short_authentication_string:
logger.error( logger.error(
f"Unable to use emoji verification with {event.sender} on device {event.from_device}." f"Bot {self.matrix_client.user_id} | Sender {event.sender} | "
f"Cannot use emoji verification with device {event.from_device}."
) )
return return
@ -280,7 +345,8 @@ class Callbacks:
) )
if isinstance(event_response, ToDeviceError): if isinstance(event_response, ToDeviceError):
logger.error( logger.error(
f"Unable to start key verification with {event.sender} on device {event.from_device}, got error: {event_response}." f"Bot {self.matrix_client.user_id} | Sender {event.sender} | "
f"Cannot start key verification with device {event.from_device}, got error: {event_response}."
) )
return return
@ -290,7 +356,8 @@ class Callbacks:
event_response = await self.matrix_client.to_device(todevice_msg) event_response = await self.matrix_client.to_device(todevice_msg)
if isinstance(event_response, ToDeviceError): if isinstance(event_response, ToDeviceError):
logger.error( logger.error(
f"Unable to share key with {event.sender} on device {event.from_device}, got error: {event_response}." f"Bot {self.matrix_client.user_id} | Sender {event.sender} | "
f"Cannot share key with device {event.from_device}, got error: {event_response}."
) )
return return
@ -300,7 +367,8 @@ class Callbacks:
# here. The SAS flow is already cancelled. # here. The SAS flow is already cancelled.
# We only need to inform the user. # We only need to inform the user.
logger.info( logger.info(
f"Verification has been cancelled by {event.sender} for reason: {event.reason}." f"Bot {self.matrix_client.user_id} | Sender {event.sender} | "
f"Key verification has been cancelled for reason: {event.reason}."
) )
async def key_verification_confirm(self, event: KeyVerificationKey): async def key_verification_confirm(self, event: KeyVerificationKey):
@ -310,7 +378,8 @@ class Callbacks:
alt_text_str = " ".join(alt_text_list) alt_text_str = " ".join(alt_text_list)
logger.info( logger.info(
f"Received request to verify emojis from {event.sender}: {emoji_str} ({alt_text_str})" f"Bot {self.matrix_client.user_id} | Sender {event.sender} | "
f"Received request to verify emojis: {emoji_str} ({alt_text_str})"
) )
event_response = await self.matrix_client.confirm_short_auth_string( event_response = await self.matrix_client.confirm_short_auth_string(
@ -318,7 +387,8 @@ class Callbacks:
) )
if isinstance(event_response, ToDeviceError): if isinstance(event_response, ToDeviceError):
logger.error( logger.error(
f"Unable to confirm emoji verification with {event.sender}, got error: {event_response}." f"Bot {self.matrix_client.user_id} | Sender {event.sender} | "
f"Cannot confirm emoji verification, got error: {event_response}."
) )
# FIXME: We should allow manual cancel or reject # FIXME: We should allow manual cancel or reject
@ -338,12 +408,13 @@ class Callbacks:
# f"Unable to cancel emoji verification with {event.sender}, got error: {event_response}." # f"Unable to cancel emoji verification with {event.sender}, got error: {event_response}."
# ) # )
async def key_verification_end(self, event: KeyVerificationMac): async def key_verification_end(self, event: KeyVerificationMac) -> None:
try: try:
sas = self.matrix_client.key_verifications[event.transaction_id] sas = self.matrix_client.key_verifications[event.transaction_id]
except KeyError: except KeyError:
logger.error( logger.error(
f"Unable to find transaction ID {event.transaction_id} sent by {event.sender}" f"Bot {self.matrix_client.user_id} | Sender {event.sender} | "
f"Cannot find transaction ID {event.transaction_id}"
) )
return return
@ -351,44 +422,54 @@ class Callbacks:
todevice_msg = sas.get_mac() todevice_msg = sas.get_mac()
except LocalProtocolError as e: except LocalProtocolError as e:
# e.g. it might have been cancelled by ourselves # e.g. it might have been cancelled by ourselves
logger.warning(f"Unable to conclude verification with {event.sender}: {e}.") logger.warning(
f"Bot {self.matrix_client.user_id} | Sender {event.sender} | "
f"Cannot conclude key verification: {e}."
)
return return
event_response = await self.matrix_client.to_device(todevice_msg) event_response = await self.matrix_client.to_device(todevice_msg)
if isinstance(event_response, ToDeviceError): if isinstance(event_response, ToDeviceError):
logger.error( logger.error(
f"Unable to conclude verification with {event.sender}, got error: {event_response}." f"Bot {self.matrix_client.user_id} | Sender {event.sender} | "
f"Cannot conclude key verification, got error: {event_response}."
) )
return return
verified_devices = " ".join(sas.verified_devices) verified_devices = " ".join(sas.verified_devices)
logger.info( logger.info(
f"Successfully verified devices from {event.sender}: {verified_devices}" f"Bot {self.matrix_client.user_id} | Sender {event.sender} | "
f"Successfully verified devices: {verified_devices}"
) )
async def unknown(self, room: MatrixRoom, event: UnknownEvent) -> None: async def unknown_message(
"""Callback for when an event with a type that is unknown to matrix-nio is received. self, room: MatrixRoom, event: RoomMessageUnknown
Currently this is used for reaction events, which are not yet part of a released ) -> None:
matrix spec (and are thus unknown to nio). event_content = event.source["content"]
if event_content["msgtype"] != "m.key.verification.request":
Args:
room: The room the reaction was sent in.
event: The event itself.
"""
# Ignore events from unauthorized room
if room.room_id not in self.config.allowed_rooms:
return return
if event.type == "m.reaction": if "m.sas.v1" not in event_content["methods"]:
# Get the ID of the event this was a reaction to
relation_dict = event.source.get("content", {}).get("m.relates_to", {})
reacted_to_id = relation_dict.get("event_id")
if reacted_to_id and relation_dict.get("rel_type") == "m.annotation":
await self._reaction(room, event, reacted_to_id)
return return
response_event = await self.matrix_client.room_send(
room.room_id,
"m.room.message",
{
"msgtype": "m.key.verification.ready",
"methods": ["m.sas.v1"],
"m.relates_to": {"rel_type": "m.reference", "event_id": event.event_id},
},
)
if isinstance(response_event, RoomSendError):
raise SendRetryError(
f"{response_event.status_code} - {response_event.message}"
)
async def debug(self, room: MatrixRoom, event: Event) -> None:
logger.debug( logger.debug(
f"Got unknown event with type to {event.type} from {event.sender} in {room.room_id}." f"Bot {self.matrix_client.user_id} | Room ID {room.room_id} | "
f"Event ID {event.event_id} | Sender {event.sender} | "
f"Received some event: {event.source}"
) )

View file

@ -1,14 +1,11 @@
from __future__ import annotations
import logging import logging
from typing import Dict, Optional, TypedDict, Union from typing import Dict, Optional, TypedDict, Union
from nio import ( from nio.client import AsyncClient
AsyncClient, from nio.exceptions import SendRetryError
ErrorResponse, from nio.responses import ErrorResponse, Response, RoomSendError, RoomSendResponse
Response,
RoomSendError,
RoomSendResponse,
SendRetryError,
)
from typing_extensions import NotRequired from typing_extensions import NotRequired
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -30,7 +27,7 @@ async def send_text_to_room(
matrix_client: AsyncClient, matrix_client: AsyncClient,
room_id: str, room_id: str,
plaintext: str, plaintext: str,
html: str = None, html: Optional[str] = None,
notice: bool = True, notice: bool = True,
reply_to_event_id: Optional[str] = None, reply_to_event_id: Optional[str] = None,
) -> RoomSendResponse: ) -> RoomSendResponse:

View file

@ -1,9 +1,12 @@
from __future__ import annotations
import logging import logging
from typing import Optional, Tuple from typing import Optional, Tuple, cast
import pytimeparse2 import pytimeparse2
from diskcache import Cache from diskcache import Cache
from nio import AsyncClient, MatrixRoom from nio.client import AsyncClient
from nio.rooms import MatrixRoom
from matrix_alertbot.alertmanager import AlertmanagerClient from matrix_alertbot.alertmanager import AlertmanagerClient
from matrix_alertbot.chat_functions import send_text_to_room from matrix_alertbot.chat_functions import send_text_to_room
@ -28,7 +31,7 @@ class BaseCommand:
room: MatrixRoom, room: MatrixRoom,
sender: str, sender: str,
event_id: str, event_id: str,
args: Tuple[str, ...] = None, args: Tuple[str, ...] = (),
) -> None: ) -> None:
"""A command made by a user. """A command made by a user.
@ -77,7 +80,7 @@ class BaseAlertCommand(BaseCommand):
sender: str, sender: str,
event_id: str, event_id: str,
reacted_to_event_id: str, reacted_to_event_id: str,
args: Tuple[str, ...] = None, args: Tuple[str, ...] = (),
) -> None: ) -> None:
super().__init__( super().__init__(
client, cache, alertmanager, config, room, sender, event_id, args client, cache, alertmanager, config, room, sender, event_id, args
@ -94,7 +97,7 @@ class AckAlertCommand(BaseAlertCommand):
duration = " ".join(durations) duration = " ".join(durations)
logger.debug(f"Receiving a command to create a silence for {duration}.") logger.debug(f"Receiving a command to create a silence for {duration}.")
duration_seconds = pytimeparse2.parse(duration) duration_seconds = cast(Optional[int], pytimeparse2.parse(duration))
if duration_seconds is None: if duration_seconds is None:
logger.error(f"Unable to create silence: Invalid duration '{duration}'") logger.error(f"Unable to create silence: Invalid duration '{duration}'")
await send_text_to_room( await send_text_to_room(
@ -123,17 +126,21 @@ class AckAlertCommand(BaseAlertCommand):
f"Reading alert fingerprint for event {self.reacted_to_event_id} from cache" f"Reading alert fingerprint for event {self.reacted_to_event_id} from cache"
) )
try: try:
alert_fingerprint: str = self.cache[self.reacted_to_event_id] alert_fingerprint = cast(str, self.cache[self.reacted_to_event_id])
except KeyError: except KeyError:
logger.error( logger.error(
f"Cannot find fingerprint for alert event {self.reacted_to_event_id} in cache" f"Cannot find fingerprint for alert event {self.reacted_to_event_id} in cache"
) )
return return
sender_user_name = self.room.user_name(self.sender)
if sender_user_name is None:
sender_user_name = self.sender
try: try:
silence_id = await self.alertmanager_client.create_or_update_silence( silence_id = await self.alertmanager_client.create_or_update_silence(
alert_fingerprint, alert_fingerprint,
self.room.user_name(self.sender), sender_user_name,
duration_seconds, duration_seconds,
force=True, force=True,
) )
@ -173,7 +180,7 @@ class UnackAlertCommand(BaseAlertCommand):
f"Reading alert fingerprint for event {self.reacted_to_event_id} from cache." f"Reading alert fingerprint for event {self.reacted_to_event_id} from cache."
) )
try: try:
alert_fingerprint: str = self.cache[self.reacted_to_event_id] alert_fingerprint = cast(str, self.cache[self.reacted_to_event_id])
except KeyError: except KeyError:
logger.error( logger.error(
f"Cannot find fingerprint for alert event {self.reacted_to_event_id} in cache." f"Cannot find fingerprint for alert event {self.reacted_to_event_id} in cache."
@ -185,7 +192,7 @@ class UnackAlertCommand(BaseAlertCommand):
f"Reading silence ID for alert fingerprint {alert_fingerprint} from cache." f"Reading silence ID for alert fingerprint {alert_fingerprint} from cache."
) )
try: try:
silence_id: str = self.cache[alert_fingerprint] silence_id = cast(str, self.cache[alert_fingerprint])
except KeyError: except KeyError:
logger.error( logger.error(
f"Cannot find silence for alert fingerprint {alert_fingerprint} in cache" f"Cannot find silence for alert fingerprint {alert_fingerprint} in cache"

View file

@ -1,8 +1,10 @@
from __future__ import annotations
import logging import logging
import os import os
import re import re
import sys import sys
from typing import Any, List, Optional from typing import Any, Dict, List, Optional
import pytimeparse2 import pytimeparse2
import yaml import yaml
@ -22,6 +24,27 @@ logging.getLogger("peewee").setLevel(
DEFAULT_REACTIONS = {"🤫", "😶", "🤐", "🙊", "🔇", "🔕"} DEFAULT_REACTIONS = {"🤫", "😶", "🤐", "🙊", "🔇", "🔕"}
class AccountConfig:
def __init__(self, account: Dict[str, str]) -> None:
self.id: str = account["id"]
if not re.match("@.+:.+", self.id):
raise InvalidConfigError("matrix.user_id must be in the form @name:domain")
self.password: Optional[str] = account.get("password")
self.token: Optional[str] = account.get("token")
if self.password is None and self.token is None:
raise RequiredConfigKeyError("Must supply either user token or password")
self.device_id: Optional[str] = account.get("device_id")
self.token_file: str = account.get("token_file", "token.json")
self.homeserver_url: str = account["url"]
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.id})"
class Config: class Config:
"""Creates a Config object from a YAML-encoded config file from a given filepath""" """Creates a Config object from a YAML-encoded config file from a given filepath"""
@ -53,17 +76,27 @@ class Config:
file_logging_filepath = self._get_cfg( file_logging_filepath = self._get_cfg(
["logging", "file_logging", "filepath"], default="matrix-alertbot.log" ["logging", "file_logging", "filepath"], default="matrix-alertbot.log"
) )
file_logging_log_level = self._get_cfg(
["logging", "file_logging", "level"], required=False
)
if file_logging_enabled: if file_logging_enabled:
file_handler = logging.FileHandler(file_logging_filepath) file_handler = logging.FileHandler(file_logging_filepath)
file_handler.setFormatter(formatter) file_handler.setFormatter(formatter)
if file_logging_log_level:
file_handler.setLevel(file_logging_log_level)
logger.addHandler(file_handler) logger.addHandler(file_handler)
console_logging_enabled = self._get_cfg( console_logging_enabled = self._get_cfg(
["logging", "console_logging", "enabled"], default=True ["logging", "console_logging", "enabled"], default=True
) )
console_logging_log_level = self._get_cfg(
["logging", "console_logging", "level"], required=False
)
if console_logging_enabled: if console_logging_enabled:
console_handler = logging.StreamHandler(sys.stdout) console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter) console_handler.setFormatter(formatter)
if console_logging_log_level:
console_handler.setLevel(console_logging_log_level)
logger.addHandler(console_handler) logger.addHandler(console_handler)
# Storage setup # Storage setup
@ -91,26 +124,22 @@ class Config:
["alertmanager", "url"], required=True ["alertmanager", "url"], required=True
) )
# Matrix bot account setup # Matrix bot accounts setup
self.user_id: str = self._get_cfg(["matrix", "user_id"], required=True) self.accounts: List[AccountConfig] = []
if not re.match("@.+:.+", self.user_id): accounts_dict: list = self._get_cfg(["matrix", "accounts"], required=True)
raise InvalidConfigError("matrix.user_id must be in the form @name:domain") for i, account_dict in enumerate(accounts_dict):
try:
self.user_password: str = self._get_cfg( account = AccountConfig(account_dict)
["matrix", "user_password"], required=False except KeyError as e:
key_name = e.args[0]
raise RequiredConfigKeyError(
f"Config option matrix.accounts.{i}.{key_name} is required"
) )
self.user_token: str = self._get_cfg(["matrix", "user_token"], required=False) self.accounts.append(account)
if not self.user_token and not self.user_password: self.user_ids = {account.id for account in self.accounts}
raise RequiredConfigKeyError("Must supply either user token or password")
self.device_id: str = self._get_cfg(["matrix", "device_id"], required=False)
self.device_name: str = self._get_cfg( self.device_name: str = self._get_cfg(
["matrix", "device_name"], default="matrix-alertbot" ["matrix", "device_name"], default="matrix-alertbot"
) )
self.user_token_file: str = self._get_cfg(
["matrix", "user_token_file"], default="token.json"
)
self.homeserver_url: str = self._get_cfg(["matrix", "url"], required=True)
self.allowed_rooms: list = self._get_cfg( self.allowed_rooms: list = self._get_cfg(
["matrix", "allowed_rooms"], required=True ["matrix", "allowed_rooms"], required=True
) )

View file

@ -63,3 +63,9 @@ class AlertmanagerServerError(AlertmanagerError):
"""An error encountered with Alertmanager server.""" """An error encountered with Alertmanager server."""
pass pass
class MatrixClientError(MatrixAlertbotError):
"""An error encountered with the Matrix client"""
pass

View file

@ -1,142 +1,20 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import asyncio from __future__ import annotations
import json
import logging import asyncio
import os import logging
import sys import sys
from asyncio import TimeoutError
from aiohttp import ClientConnectionError, ServerDisconnectedError
from diskcache import Cache from diskcache import Cache
from nio import (
AsyncClient,
AsyncClientConfig,
InviteMemberEvent,
KeyVerificationCancel,
KeyVerificationKey,
KeyVerificationMac,
KeyVerificationStart,
LocalProtocolError,
LoginError,
MegolmEvent,
RedactionEvent,
RoomMessageText,
UnknownEvent,
)
from matrix_alertbot.alertmanager import AlertmanagerClient from matrix_alertbot.alertmanager import AlertmanagerClient
from matrix_alertbot.callback import Callbacks
from matrix_alertbot.config import Config from matrix_alertbot.config import Config
from matrix_alertbot.matrix import MatrixClientPool
from matrix_alertbot.webhook import Webhook from matrix_alertbot.webhook import Webhook
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def create_matrix_client(config: Config) -> AsyncClient:
# Configuration options for the AsyncClient
try:
matrix_client_config = AsyncClientConfig(
max_limit_exceeded=5,
max_timeouts=3,
store_sync_tokens=True,
encryption_enabled=True,
)
except ImportWarning as e:
logger.warning(e)
matrix_client_config = AsyncClientConfig(
max_limit_exceeded=5,
max_timeouts=3,
store_sync_tokens=True,
encryption_enabled=False,
)
# Load credentials from a previous session
if os.path.exists(config.user_token_file):
with open(config.user_token_file, "r") as ifd:
credentials = json.load(ifd)
config.user_token = credentials["access_token"]
config.device_id = credentials["device_id"]
# Initialize the matrix client based on stored credentials
matrix_client = AsyncClient(
config.homeserver_url,
config.user_id,
device_id=config.device_id,
store_path=config.store_dir,
config=matrix_client_config,
)
return matrix_client
async def start_matrix_client(
matrix_client: AsyncClient, cache: Cache, config: Config
) -> bool:
# Keep trying to reconnect on failure (with some time in-between)
while True:
try:
if config.device_id and config.user_token:
matrix_client.restore_login(
user_id=config.user_id,
device_id=config.device_id,
access_token=config.user_token,
)
# Sync encryption keys with the server
if matrix_client.should_upload_keys:
await matrix_client.keys_upload()
else:
# Try to login with the configured username/password
try:
login_response = await matrix_client.login(
password=config.user_password,
device_name=config.device_name,
)
# Check if login failed
if type(login_response) == LoginError:
logger.error("Failed to login: %s", login_response.message)
return False
except LocalProtocolError as e:
# There's an edge case here where the user hasn't installed the correct C
# dependencies. In that case, a LocalProtocolError is raised on login.
logger.fatal(
"Failed to login. Have you installed the correct dependencies? "
"https://github.com/poljar/matrix-nio#installation "
"Error: %s",
e,
)
return False
# Save user's access token and device ID
# See https://stackoverflow.com/a/45368120
user_token_fd = os.open(
config.user_token_file,
flags=os.O_CREAT | os.O_WRONLY | os.O_TRUNC,
mode=0o640,
)
with os.fdopen(user_token_fd, "w") as ofd:
json.dump(
{
"device_id": login_response.device_id,
"access_token": login_response.access_token,
},
ofd,
)
# Login succeeded!
logger.info(f"Logged in as {config.user_id}")
await matrix_client.sync_forever(timeout=30000, full_state=True)
except (ClientConnectionError, ServerDisconnectedError, TimeoutError):
logger.warning("Unable to connect to homeserver, retrying in 15s...")
# Sleep so we don't bombard the server with login requests
await asyncio.sleep(15)
finally:
await matrix_client.close()
def main() -> None: def main() -> None:
"""The first function that is run when starting the bot""" """The first function that is run when starting the bot"""
@ -150,41 +28,22 @@ def main() -> None:
# Read the parsed config file and create a Config object # Read the parsed config file and create a Config object
config = Config(config_path) config = Config(config_path)
matrix_client = create_matrix_client(config)
# Configure the cache # Configure the cache
cache = Cache(config.cache_dir) cache = Cache(config.cache_dir)
# Configure Alertmanager client # Configure Alertmanager client
alertmanager_client = AlertmanagerClient(config.alertmanager_url, cache) alertmanager_client = AlertmanagerClient(config.alertmanager_url, cache)
# Set up event callbacks # Create matrix clients
callbacks = Callbacks(matrix_client, alertmanager_client, cache, config) matrix_client_pool = MatrixClientPool(alertmanager_client, cache, config)
matrix_client.add_event_callback(callbacks.message, (RoomMessageText,))
matrix_client.add_event_callback(
callbacks.invite_event_filtered_callback, (InviteMemberEvent,)
)
matrix_client.add_event_callback(callbacks.decryption_failure, (MegolmEvent,))
matrix_client.add_event_callback(callbacks.unknown, (UnknownEvent,))
matrix_client.add_event_callback(callbacks.redaction, (RedactionEvent,))
matrix_client.add_to_device_callback(
callbacks.key_verification_start, (KeyVerificationStart,)
)
matrix_client.add_to_device_callback(
callbacks.key_verification_cancel, (KeyVerificationCancel,)
)
matrix_client.add_to_device_callback(
callbacks.key_verification_confirm, (KeyVerificationKey,)
)
matrix_client.add_to_device_callback(
callbacks.key_verification_end, (KeyVerificationMac,)
)
# Configure webhook server # Configure webhook server
webhook_server = Webhook(matrix_client, alertmanager_client, cache, config) webhook_server = Webhook(matrix_client_pool, alertmanager_client, cache, config)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
loop.create_task(matrix_client_pool.switch_active_client())
loop.create_task(webhook_server.start()) loop.create_task(webhook_server.start())
loop.create_task(start_matrix_client(matrix_client, cache, config)) for account in config.accounts:
loop.create_task(matrix_client_pool.start(account, config))
try: try:
loop.run_forever() loop.run_forever()
@ -193,5 +52,5 @@ def main() -> None:
finally: finally:
loop.run_until_complete(webhook_server.close()) loop.run_until_complete(webhook_server.close())
loop.run_until_complete(alertmanager_client.close()) loop.run_until_complete(alertmanager_client.close())
loop.run_until_complete(matrix_client.close()) loop.run_until_complete(matrix_client_pool.close())
cache.close() cache.close()

252
matrix_alertbot/matrix.py Normal file
View file

@ -0,0 +1,252 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
import random
from typing import Dict, List, Optional, Tuple
from aiohttp import ClientConnectionError, ServerDisconnectedError
from diskcache import Cache
from nio.client import AsyncClient, AsyncClientConfig
from nio.events import (
InviteMemberEvent,
KeyVerificationCancel,
KeyVerificationKey,
KeyVerificationMac,
KeyVerificationStart,
MegolmEvent,
ReactionEvent,
RedactionEvent,
RoomMessageText,
RoomMessageUnknown,
)
from nio.exceptions import LocalProtocolError
from nio.responses import LoginError, WhoamiError
import matrix_alertbot.callback
from matrix_alertbot.alertmanager import AlertmanagerClient
from matrix_alertbot.config import AccountConfig, Config
logger = logging.getLogger(__name__)
class MatrixClientPool:
def __init__(
self, alertmanager_client: AlertmanagerClient, cache: Cache, config: Config
) -> None:
self._lock = asyncio.Lock()
self._matrix_clients: Dict[AccountConfig, AsyncClient] = {}
self._accounts: List[AccountConfig] = []
self._accounts = config.accounts
for account in self._accounts:
matrix_client = self._create_matrix_client(
account, alertmanager_client, cache, config
)
self._matrix_clients[account] = matrix_client
self.account = next(iter(self._accounts))
self.matrix_client = self._matrix_clients[self.account]
async def switch_active_client(
self,
) -> Optional[Tuple[AsyncClient, AccountConfig]]:
async with self._lock:
for account in random.sample(self._accounts, len(self._accounts)):
if account is self.account:
continue
matrix_client = self._matrix_clients[account]
try:
whoami = await matrix_client.whoami()
logged_in = not isinstance(whoami, WhoamiError)
except Exception:
logged_in = False
if logged_in:
self.account = account
self.matrix_client = matrix_client
logger.warning(
f"Bot {self.account.id} | Matrix client for homeserver {self.account.homeserver_url} selected as new leader."
)
return matrix_client, account
if self.matrix_client.logged_in:
logger.warning(
f"Bot {self.account.id} | No active Matrix client available, keeping Matrix client for {self.account.homeserver_url} as the leader."
)
else:
logger.error(
f"Bot {self.account.id} | No active Matrix client connected."
)
return None
async def close(self) -> None:
for matrix_client in self._matrix_clients.values():
await matrix_client.close()
def _create_matrix_client(
self,
account: AccountConfig,
alertmanager_client: AlertmanagerClient,
cache: Cache,
config: Config,
) -> AsyncClient:
# Configuration options for the AsyncClient
try:
matrix_client_config = AsyncClientConfig(
max_limit_exceeded=5,
max_timeouts=3,
store_sync_tokens=True,
encryption_enabled=True,
)
except ImportWarning as e:
logger.warning(e)
matrix_client_config = AsyncClientConfig(
max_limit_exceeded=5,
max_timeouts=3,
store_sync_tokens=True,
encryption_enabled=False,
)
# Load credentials from a previous session
if os.path.exists(account.token_file):
with open(account.token_file, "r") as ifd:
credentials = json.load(ifd)
account.token = credentials["access_token"]
account.device_id = credentials["device_id"]
# Initialize the matrix client based on stored credentials
matrix_client = AsyncClient(
account.homeserver_url,
account.id,
device_id=account.device_id,
store_path=config.store_dir,
config=matrix_client_config,
)
# Set up event callbacks
callbacks = matrix_alertbot.callback.Callbacks(
matrix_client, alertmanager_client, cache, config, self
)
matrix_client.add_event_callback(callbacks.message, (RoomMessageText,))
matrix_client.add_event_callback(
callbacks.invite_event_filtered_callback, (InviteMemberEvent,)
)
# matrix_client.add_event_callback(callbacks.debug, (Event,))
matrix_client.add_event_callback(callbacks.decryption_failure, (MegolmEvent,))
matrix_client.add_event_callback(callbacks.reaction, (ReactionEvent,))
matrix_client.add_event_callback(callbacks.redaction, (RedactionEvent,))
matrix_client.add_event_callback(
callbacks.unknown_message, (RoomMessageUnknown,)
)
matrix_client.add_to_device_callback(
callbacks.key_verification_start, (KeyVerificationStart,)
)
matrix_client.add_to_device_callback(
callbacks.key_verification_cancel, (KeyVerificationCancel,)
)
matrix_client.add_to_device_callback(
callbacks.key_verification_confirm, (KeyVerificationKey,)
)
matrix_client.add_to_device_callback(
callbacks.key_verification_end, (KeyVerificationMac,)
)
return matrix_client
async def start(
self,
account: AccountConfig,
config: Config,
):
matrix_client = self._matrix_clients[account]
# Keep trying to reconnect on failure (with some time in-between)
# We switch homeserver after some retries
while True:
try:
if account.device_id and account.token:
matrix_client.restore_login(
user_id=account.id,
device_id=account.device_id,
access_token=account.token,
)
# Sync encryption keys with the server
if matrix_client.should_upload_keys:
await matrix_client.keys_upload()
else:
# Try to login with the configured username/password
try:
login_response = await matrix_client.login(
password=account.password,
device_name=config.device_name,
)
# Check if login failed
if type(login_response) == LoginError:
logger.error(
f"Bot {account.id} | Failed to login: {login_response.message}"
)
return False
except LocalProtocolError as e:
# There's an edge case here where the user hasn't installed the correct C
# dependencies. In that case, a LocalProtocolError is raised on login.
logger.fatal(
f"Bot {account.id} | Failed to login. Have you installed the correct dependencies? "
"https://github.com/poljar/matrix-nio#installation "
"Error: %s",
e,
)
return False
if isinstance(login_response, LoginError):
logger.fatal(
f"Bot {account.id} | Failed to login: {login_response.message}"
)
return False
# Save user's access token and device ID
# See https://stackoverflow.com/a/45368120
account_token_fd = os.open(
account.token_file,
flags=os.O_CREAT | os.O_WRONLY | os.O_TRUNC,
mode=0o640,
)
with os.fdopen(account_token_fd, "w") as ofd:
json.dump(
{
"device_id": login_response.device_id,
"access_token": login_response.access_token,
},
ofd,
)
# Login succeeded!
logger.info(f"Bot {account.id} | Logged in.")
await matrix_client.sync_forever(timeout=30000, full_state=True)
except (ClientConnectionError, ServerDisconnectedError, TimeoutError):
await matrix_client.close()
logger.warning(
f"Bot {account.id} | Matrix client disconnected, retrying in 15s..."
)
if len(self._accounts) > 1 and self.matrix_client is matrix_client:
logger.warning(
f"Bot {account.id} | Selecting another Matrix client as leader..."
)
await self.switch_active_client()
# Sleep so we don't bombard the server with login requests
await asyncio.sleep(15)
finally:
await matrix_client.close()

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import List
import prometheus_client import prometheus_client
from aiohttp import ClientError, web, web_request from aiohttp import ClientError, web, web_request
@ -8,7 +9,7 @@ from aiohttp.abc import AbstractAccessLogger
from aiohttp_prometheus_exporter.handler import metrics from aiohttp_prometheus_exporter.handler import metrics
from aiohttp_prometheus_exporter.middleware import prometheus_middleware_factory from aiohttp_prometheus_exporter.middleware import prometheus_middleware_factory
from diskcache import Cache from diskcache import Cache
from nio import AsyncClient, LocalProtocolError, SendRetryError from nio.exceptions import LocalProtocolError, SendRetryError
from matrix_alertbot.alert import Alert, AlertRenderer from matrix_alertbot.alert import Alert, AlertRenderer
from matrix_alertbot.alertmanager import AlertmanagerClient from matrix_alertbot.alertmanager import AlertmanagerClient
@ -16,9 +17,11 @@ from matrix_alertbot.chat_functions import send_text_to_room
from matrix_alertbot.config import Config from matrix_alertbot.config import Config
from matrix_alertbot.errors import ( from matrix_alertbot.errors import (
AlertmanagerError, AlertmanagerError,
MatrixClientError,
SilenceExtendError, SilenceExtendError,
SilenceNotFoundError, SilenceNotFoundError,
) )
from matrix_alertbot.matrix import MatrixClientPool
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -93,13 +96,13 @@ async def create_alerts(request: web_request.Request) -> web.Response:
if len(data["alerts"]) == 0: if len(data["alerts"]) == 0:
return web.Response(status=400, body="Alerts cannot be empty.") return web.Response(status=400, body="Alerts cannot be empty.")
alerts = [] alerts: List[Alert] = []
for alert in alert_dicts: for alert_dict in alert_dicts:
try: try:
alert = Alert.from_dict(alert) alert = Alert.from_dict(alert_dict)
except KeyError as e: except KeyError as e:
logger.error(f"Cannot parse alert dict: {e}") logger.error(f"Cannot parse alert dict: {e}")
return web.Response(status=400, body=f"Invalid alert: {alert}.") return web.Response(status=400, body=f"Invalid alert: {alert_dict}.")
alerts.append(alert) alerts.append(alert)
for alert in alerts: for alert in alerts:
@ -121,6 +124,14 @@ async def create_alerts(request: web_request.Request) -> web.Response:
status=500, status=500,
body=f"An error occured when sending alert with fingerprint '{alert.fingerprint}' to Matrix room.", body=f"An error occured when sending alert with fingerprint '{alert.fingerprint}' to Matrix room.",
) )
except MatrixClientError as e:
logger.error(
f"Unable to send alert {alert.fingerprint} to Matrix room {room_id}: {e}"
)
return web.Response(
status=500,
body=f"An error occured when sending alert with fingerprint '{alert.fingerprint}' to Matrix room.",
)
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Unable to send alert {alert.fingerprint} to Matrix room {room_id}: {e}" f"Unable to send alert {alert.fingerprint} to Matrix room {room_id}: {e}"
@ -138,7 +149,7 @@ async def create_alert(
) -> None: ) -> None:
alertmanager_client: AlertmanagerClient = request.app["alertmanager_client"] alertmanager_client: AlertmanagerClient = request.app["alertmanager_client"]
alert_renderer: AlertRenderer = request.app["alert_renderer"] alert_renderer: AlertRenderer = request.app["alert_renderer"]
matrix_client: AsyncClient = request.app["matrix_client"] matrix_client_pool: MatrixClientPool = request.app["matrix_client_pool"]
cache: Cache = request.app["cache"] cache: Cache = request.app["cache"]
config: Config = request.app["config"] config: Config = request.app["config"]
@ -162,9 +173,12 @@ async def create_alert(
plaintext = alert_renderer.render(alert, html=False) plaintext = alert_renderer.render(alert, html=False)
html = alert_renderer.render(alert, html=True) html = alert_renderer.render(alert, html=True)
if matrix_client_pool.matrix_client is not None:
event = await send_text_to_room( event = await send_text_to_room(
matrix_client, room_id, plaintext, html, notice=False matrix_client_pool.matrix_client, room_id, plaintext, html, notice=False
) )
else:
raise MatrixClientError("No matrix client available")
if alert.firing: if alert.firing:
cache.set(event.event_id, alert.fingerprint, expire=config.cache_expire_time) cache.set(event.event_id, alert.fingerprint, expire=config.cache_expire_time)
@ -175,13 +189,13 @@ async def create_alert(
class Webhook: class Webhook:
def __init__( def __init__(
self, self,
matrix_client: AsyncClient, matrix_client_pool: MatrixClientPool,
alertmanager_client: AlertmanagerClient, alertmanager_client: AlertmanagerClient,
cache: Cache, cache: Cache,
config: Config, config: Config,
) -> None: ) -> None:
self.app = web.Application(logger=logger) self.app = web.Application(logger=logger)
self.app["matrix_client"] = matrix_client self.app["matrix_client_pool"] = matrix_client_pool
self.app["alertmanager_client"] = alertmanager_client self.app["alertmanager_client"] = alertmanager_client
self.app["config"] = config self.app["config"] = config
self.app["cache"] = cache self.app["cache"] = cache

View file

@ -26,7 +26,7 @@ install_requires =
aiotools>=1.5.9 aiotools>=1.5.9
diskcache>=5.4.0 diskcache>=5.4.0
jinja2>=3.1.2 jinja2>=3.1.2
matrix-nio>=0.19.0 matrix-nio>=0.24.0
Markdown>=3.3.7 Markdown>=3.3.7
pytimeparse2>=1.4.0 pytimeparse2>=1.4.0
PyYAML>=5.4.1 PyYAML>=5.4.1
@ -56,7 +56,7 @@ test =
types-PyYAML>=6.0.9 types-PyYAML>=6.0.9
types-setuptools>=62.6.0 types-setuptools>=62.6.0
e2e = e2e =
matrix-nio[e2e]>=0.19.0 matrix-nio[e2e]>=0.24.0
all = all =
%(test)s %(test)s
%(e2e)s %(e2e)s

View file

@ -7,19 +7,20 @@ command_prefix: "!alert"
# Options for connecting to the bot's Matrix account # Options for connecting to the bot's Matrix account
matrix: matrix:
# The Matrix User ID of the bot account accounts:
user_id: "@fakes_user:matrix.example.com" - # The Matrix User ID of the bot account
id: "@fakes_user:matrix.example.com"
# Matrix account password (optional if access token used) # Matrix account password (optional if access token used)
user_password: "password" password: "password"
# Matrix account access token (optional if password used) # Matrix account access token (optional if password used)
# If not set, the server will provide an access token after log in, # If not set, the server will provide an access token after log in,
# which will be stored in the user token file (see below) # which will be stored in the user token file (see below)
#user_token: "" #token: ""
# Path to the file where to store the user access token # Path to the file where to store the user access token
user_token_file: "token.json" token_file: "fake_token.json"
# The URL of the homeserver to connect to # The URL of the homeserver to connect to
url: https://matrix.example.com url: https://matrix.example.com
@ -30,6 +31,29 @@ matrix:
# will change each time the bot reconnects. # will change each time the bot reconnects.
device_id: ABCDEFGHIJ device_id: ABCDEFGHIJ
- # The Matrix User ID of the bot account
id: "@other_user:matrix.domain.tld"
# Matrix account password (optional if access token used)
#password: "password"
# Matrix account access token (optional if password used)
# If not set, the server will provide an access token after log in,
# which will be stored in the user token file (see below)
token: "token"
# Path to the file where to store the user access token
token_file: "other_token.json"
# The URL of the homeserver to connect to
url: https://matrix.domain.tld
# The device ID that is **non pre-existing** device
# If this device ID already exists, messages will be dropped silently in encrypted rooms
# If not set the server will provide a device ID after log in. Note that this ID
# will change each time the bot reconnects.
device_id: KLMNOPQRST
# What to name the logged in device # What to name the logged in device
device_name: fake_device_name device_name: fake_device_name
@ -75,6 +99,7 @@ logging:
enabled: true enabled: true
# The path to the file to log to. May be relative or absolute # The path to the file to log to. May be relative or absolute
filepath: fake.log filepath: fake.log
level: INFO
# Configure logging to the console output # Configure logging to the console output
console_logging: console_logging:
# Whether logging to the console is enabled # Whether logging to the console is enabled

View file

@ -4,11 +4,12 @@
# Options for connecting to the bot's Matrix account # Options for connecting to the bot's Matrix account
matrix: matrix:
# The Matrix User ID of the bot account accounts:
user_id: "@fakes_user:matrix.example.com" - # The Matrix User ID of the bot account
id: "@fakes_user:matrix.example.com"
# Matrix account password (optional if access token used) # Matrix account password (optional if access token used)
user_password: "password" password: "password"
# The URL of the homeserver to connect to # The URL of the homeserver to connect to
url: https://matrix.example.com url: https://matrix.example.com

View file

@ -1,3 +1,5 @@
from __future__ import annotations
import unittest import unittest
from typing import Dict from typing import Dict
from unittest.mock import MagicMock, Mock, patch from unittest.mock import MagicMock, Mock, patch
@ -6,13 +8,10 @@ import nio
import nio.crypto import nio.crypto
from diskcache import Cache from diskcache import Cache
import matrix_alertbot.alertmanager
import matrix_alertbot.callback import matrix_alertbot.callback
import matrix_alertbot.command import matrix_alertbot.command
from matrix_alertbot.alertmanager import AlertmanagerClient import matrix_alertbot.matrix
from matrix_alertbot.callback import Callbacks
from matrix_alertbot.command import BaseCommand
from tests.utils import make_awaitable
def key_verification_get_mac_raise_protocol_error(): def key_verification_get_mac_raise_protocol_error():
@ -23,10 +22,13 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None: def setUp(self) -> None:
# Create a Callbacks object and give it some Mock'd objects to use # Create a Callbacks object and give it some Mock'd objects to use
self.fake_matrix_client = Mock(spec=nio.AsyncClient) self.fake_matrix_client = Mock(spec=nio.AsyncClient)
self.fake_matrix_client.user = "@fake_user:example.com" self.fake_matrix_client.user_id = "@fake_user:example.com"
# self.fake_matrix_client.user = "@fake_user"
self.fake_cache = MagicMock(spec=Cache) self.fake_cache = MagicMock(spec=Cache)
self.fake_alertmanager_client = Mock(spec=AlertmanagerClient) self.fake_alertmanager_client = Mock(
spec=matrix_alertbot.alertmanager.AlertmanagerClient
)
# Create a fake room to play with # Create a fake room to play with
self.fake_room = Mock(spec=nio.MatrixRoom) self.fake_room = Mock(spec=nio.MatrixRoom)
@ -38,12 +40,19 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
self.fake_config.allowed_rooms = [self.fake_room.room_id] self.fake_config.allowed_rooms = [self.fake_room.room_id]
self.fake_config.allowed_reactions = ["🤫"] self.fake_config.allowed_reactions = ["🤫"]
self.fake_config.command_prefix = "!alert " self.fake_config.command_prefix = "!alert "
self.fake_config.user_ids = [self.fake_matrix_client.user_id]
self.callbacks = Callbacks( self.fake_matrix_client_pool = Mock(
spec=matrix_alertbot.matrix.MatrixClientPool
)
self.fake_matrix_client_pool.matrix_client = self.fake_matrix_client
self.callbacks = matrix_alertbot.callback.Callbacks(
self.fake_matrix_client, self.fake_matrix_client,
self.fake_alertmanager_client, self.fake_alertmanager_client,
self.fake_cache, self.fake_cache,
self.fake_config, self.fake_config,
self.fake_matrix_client_pool,
) )
async def test_invite(self) -> None: async def test_invite(self) -> None:
@ -52,9 +61,6 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
fake_invite_event = Mock(spec=nio.InviteMemberEvent) fake_invite_event = Mock(spec=nio.InviteMemberEvent)
fake_invite_event.sender = "@some_other_fake_user:example.com" fake_invite_event.sender = "@some_other_fake_user:example.com"
# Pretend that attempting to join a room is always successful
self.fake_matrix_client.join.return_value = make_awaitable()
# Pretend that we received an invite event # Pretend that we received an invite event
await self.callbacks.invite(self.fake_room, fake_invite_event) await self.callbacks.invite(self.fake_room, fake_invite_event)
@ -68,6 +74,7 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
fake_message_event = Mock(spec=nio.RoomMessageText) fake_message_event = Mock(spec=nio.RoomMessageText)
fake_message_event.sender = "@some_other_fake_user:example.com" fake_message_event.sender = "@some_other_fake_user:example.com"
fake_message_event.body = "Hello world!" fake_message_event.body = "Hello world!"
fake_message_event.event_id = "some event id"
# Pretend that we received a text message event # Pretend that we received a text message event
await self.callbacks.message(self.fake_room, fake_message_event) await self.callbacks.message(self.fake_room, fake_message_event)
@ -140,7 +147,7 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
# Tests that the bot process messages in the room that contain a command # Tests that the bot process messages in the room that contain a command
fake_message_event = Mock(spec=nio.RoomMessageText) fake_message_event = Mock(spec=nio.RoomMessageText)
fake_message_event.sender = self.fake_matrix_client.user fake_message_event.sender = self.fake_matrix_client.user_id
# Pretend that we received a text message event # Pretend that we received a text message event
await self.callbacks.message(self.fake_room, fake_message_event) await self.callbacks.message(self.fake_room, fake_message_event)
@ -270,30 +277,20 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
# Tests that the bot process messages in the room that contain a command # Tests that the bot process messages in the room that contain a command
fake_alert_event = Mock(spec=nio.RoomMessageText) fake_alert_event = Mock(spec=nio.RoomMessageText)
fake_alert_event.event_id = "some alert event id" fake_alert_event.event_id = "some alert event id"
fake_alert_event.sender = self.fake_config.user_id fake_alert_event.sender = self.fake_matrix_client.user_id
fake_reaction_event = Mock(spec=nio.UnknownEvent) fake_reaction_event = Mock(spec=nio.ReactionEvent)
fake_reaction_event.type = "m.reaction"
fake_reaction_event.event_id = "some event id" fake_reaction_event.event_id = "some event id"
fake_reaction_event.sender = "@some_other_fake_user:example.com" fake_reaction_event.sender = "@some_other_fake_user:example.com"
fake_reaction_event.source = { fake_reaction_event.reacts_to = fake_alert_event.event_id
"content": { fake_reaction_event.key = "🤫"
"m.relates_to": {
"event_id": fake_alert_event.event_id,
"key": "🤫",
"rel_type": "m.annotation",
}
}
}
fake_event_response = Mock(spec=nio.RoomGetEventResponse) fake_event_response = Mock(spec=nio.RoomGetEventResponse)
fake_event_response.event = fake_alert_event fake_event_response.event = fake_alert_event
self.fake_matrix_client.room_get_event.return_value = make_awaitable( self.fake_matrix_client.room_get_event.return_value = fake_event_response
fake_event_response
)
# Pretend that we received a text message event # Pretend that we received a text message event
await self.callbacks.unknown(self.fake_room, fake_reaction_event) await self.callbacks.reaction(self.fake_room, fake_reaction_event)
# Check that we attempted to execute the command # Check that we attempted to execute the command
fake_command.assert_called_once_with( fake_command.assert_called_once_with(
@ -317,27 +314,18 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
# Tests that the bot process messages in the room that contain a command # Tests that the bot process messages in the room that contain a command
fake_alert_event_id = "some alert event id" fake_alert_event_id = "some alert event id"
fake_reaction_event = Mock(spec=nio.UnknownEvent) fake_reaction_event = Mock(spec=nio.ReactionEvent)
fake_reaction_event.type = "m.reaction" fake_reaction_event.type = "m.reaction"
fake_reaction_event.event_id = "some event id" fake_reaction_event.event_id = "some event id"
fake_reaction_event.sender = "@some_other_fake_user:example.com" fake_reaction_event.sender = "@some_other_fake_user:example.com"
fake_reaction_event.source = { fake_reaction_event.reacts_to = fake_alert_event_id
"content": { fake_reaction_event.key = "🤫"
"m.relates_to": {
"event_id": fake_alert_event_id,
"key": "🤫",
"rel_type": "m.annotation",
}
}
}
fake_event_response = Mock(spec=nio.RoomGetEventError) fake_event_response = Mock(spec=nio.RoomGetEventError)
self.fake_matrix_client.room_get_event.return_value = make_awaitable( self.fake_matrix_client.room_get_event.return_value = fake_event_response
fake_event_response
)
# Pretend that we received a text message event # Pretend that we received a text message event
await self.callbacks.unknown(self.fake_room, fake_reaction_event) await self.callbacks.reaction(self.fake_room, fake_reaction_event)
# Check that we attempted to execute the command # Check that we attempted to execute the command
fake_command.assert_not_called() fake_command.assert_not_called()
@ -356,28 +344,19 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
fake_alert_event.event_id = "some alert event id" fake_alert_event.event_id = "some alert event id"
fake_alert_event.sender = "@some_other_fake_user.example.com" fake_alert_event.sender = "@some_other_fake_user.example.com"
fake_reaction_event = Mock(spec=nio.UnknownEvent) fake_reaction_event = Mock(spec=nio.ReactionEvent)
fake_reaction_event.type = "m.reaction" fake_reaction_event.type = "m.reaction"
fake_reaction_event.event_id = "some event id" fake_reaction_event.event_id = "some event id"
fake_reaction_event.sender = "@some_other_fake_user:example.com" fake_reaction_event.sender = "@some_other_fake_user:example.com"
fake_reaction_event.source = { fake_reaction_event.reacts_to = fake_alert_event.event_id
"content": { fake_reaction_event.key = "🤫"
"m.relates_to": {
"event_id": fake_alert_event.event_id,
"key": "🤫",
"rel_type": "m.annotation",
}
}
}
fake_event_response = Mock(spec=nio.RoomGetEventResponse) fake_event_response = Mock(spec=nio.RoomGetEventResponse)
fake_event_response.event = fake_alert_event fake_event_response.event = fake_alert_event
self.fake_matrix_client.room_get_event.return_value = make_awaitable( self.fake_matrix_client.room_get_event.return_value = fake_event_response
fake_event_response
)
# Pretend that we received a text message event # Pretend that we received a text message event
await self.callbacks.unknown(self.fake_room, fake_reaction_event) await self.callbacks.reaction(self.fake_room, fake_reaction_event)
# Check that we attempted to execute the command # Check that we attempted to execute the command
fake_command.assert_not_called() fake_command.assert_not_called()
@ -392,22 +371,15 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
# Tests that the bot process messages in the room that contain a command # Tests that the bot process messages in the room that contain a command
fake_alert_event_id = "some alert event id" fake_alert_event_id = "some alert event id"
fake_reaction_event = Mock(spec=nio.UnknownEvent) fake_reaction_event = Mock(spec=nio.ReactionEvent)
fake_reaction_event.type = "m.reaction" fake_reaction_event.type = "m.reaction"
fake_reaction_event.event_id = "some event id" fake_reaction_event.event_id = "some event id"
fake_reaction_event.sender = "@some_other_fake_user:example.com" fake_reaction_event.sender = "@some_other_fake_user:example.com"
fake_reaction_event.source = { fake_reaction_event.reacts_to = fake_alert_event_id
"content": { fake_reaction_event.key = "unknown"
"m.relates_to": {
"event_id": fake_alert_event_id,
"key": "unknown",
"rel_type": "m.annotation",
}
}
}
# Pretend that we received a text message event # Pretend that we received a text message event
await self.callbacks.unknown(self.fake_room, fake_reaction_event) await self.callbacks.reaction(self.fake_room, fake_reaction_event)
# Check that we attempted to execute the command # Check that we attempted to execute the command
fake_command.assert_not_called() fake_command.assert_not_called()
@ -419,25 +391,15 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
# Tests that the bot process messages in the room that contain a command # Tests that the bot process messages in the room that contain a command
fake_alert_event_id = "some alert event id" fake_alert_event_id = "some alert event id"
fake_reaction_event = Mock(spec=nio.UnknownEvent) fake_reaction_event = Mock(spec=nio.ReactionEvent)
fake_reaction_event.type = "m.reaction" fake_reaction_event.type = "m.reaction"
fake_reaction_event.event_id = "some event id" fake_reaction_event.event_id = "some event id"
fake_reaction_event.sender = self.fake_matrix_client.user fake_reaction_event.sender = self.fake_matrix_client.user_id
fake_reaction_event.source = { fake_reaction_event.reacts_to = fake_alert_event_id
"content": { fake_reaction_event.key = "unknown"
"m.relates_to": {
"event_id": fake_alert_event_id,
"key": "unknown",
"rel_type": "m.annotation",
}
}
}
# Pretend that we received a text message event # Pretend that we received a text message event
await self.callbacks.unknown(self.fake_room, fake_reaction_event) await self.callbacks.reaction(self.fake_room, fake_reaction_event)
await self.callbacks._reaction(
self.fake_room, fake_reaction_event, fake_alert_event_id
)
# Check that we attempted to execute the command # Check that we attempted to execute the command
fake_command.assert_not_called() fake_command.assert_not_called()
@ -453,25 +415,15 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
fake_alert_event_id = "some alert event id" fake_alert_event_id = "some alert event id"
fake_reaction_event = Mock(spec=nio.UnknownEvent) fake_reaction_event = Mock(spec=nio.ReactionEvent)
fake_reaction_event.type = "m.reaction" fake_reaction_event.type = "m.reaction"
fake_reaction_event.event_id = "some event id" fake_reaction_event.event_id = "some event id"
fake_reaction_event.sender = "@some_other_fake_user:example.com" fake_reaction_event.sender = "@some_other_fake_user:example.com"
fake_reaction_event.source = { fake_reaction_event.reacts_to = fake_alert_event_id
"content": { fake_reaction_event.key = "unknown"
"m.relates_to": {
"event_id": fake_alert_event_id,
"key": "unknown",
"rel_type": "m.annotation",
}
}
}
# Pretend that we received a text message event # Pretend that we received a text message event
await self.callbacks.unknown(self.fake_room, fake_reaction_event) await self.callbacks.reaction(self.fake_room, fake_reaction_event)
await self.callbacks._reaction(
self.fake_room, fake_reaction_event, fake_alert_event_id
)
# Check that we attempted to execute the command # Check that we attempted to execute the command
fake_command.assert_not_called() fake_command.assert_not_called()
@ -512,7 +464,7 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
"""Tests the callback for RoomMessageText with the command prefix""" """Tests the callback for RoomMessageText with the command prefix"""
# Tests that the bot process messages in the room that contain a command # Tests that the bot process messages in the room that contain a command
fake_redaction_event = Mock(spec=nio.RedactionEvent) fake_redaction_event = Mock(spec=nio.RedactionEvent)
fake_redaction_event.sender = self.fake_matrix_client.user fake_redaction_event.sender = self.fake_matrix_client.user_id
fake_cache_dict: Dict = {} fake_cache_dict: Dict = {}
self.fake_cache.__getitem__.side_effect = fake_cache_dict.__getitem__ self.fake_cache.__getitem__.side_effect = fake_cache_dict.__getitem__
@ -556,9 +508,6 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
fake_key_verification_event.short_authentication_string = ["emoji"] fake_key_verification_event.short_authentication_string = ["emoji"]
fake_key_verification_event.transaction_id = fake_transaction_id fake_key_verification_event.transaction_id = fake_transaction_id
self.fake_matrix_client.accept_key_verification.return_value = make_awaitable()
self.fake_matrix_client.to_device.return_value = make_awaitable()
fake_sas = Mock() fake_sas = Mock()
fake_transactions_dict = {fake_transaction_id: fake_sas} fake_transactions_dict = {fake_transaction_id: fake_sas}
self.fake_matrix_client.key_verifications = fake_transactions_dict self.fake_matrix_client.key_verifications = fake_transactions_dict
@ -583,9 +532,6 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
fake_key_verification_event.short_authentication_string = [] fake_key_verification_event.short_authentication_string = []
fake_key_verification_event.transaction_id = fake_transaction_id fake_key_verification_event.transaction_id = fake_transaction_id
self.fake_matrix_client.accept_key_verification.return_value = make_awaitable()
self.fake_matrix_client.to_device.return_value = make_awaitable()
fake_sas = Mock() fake_sas = Mock()
fake_transactions_dict = {fake_transaction_id: fake_sas} fake_transactions_dict = {fake_transaction_id: fake_sas}
self.fake_matrix_client.key_verifications = fake_transactions_dict self.fake_matrix_client.key_verifications = fake_transactions_dict
@ -610,10 +556,9 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
fake_key_verification_event.short_authentication_string = ["emoji"] fake_key_verification_event.short_authentication_string = ["emoji"]
fake_key_verification_event.transaction_id = fake_transaction_id fake_key_verification_event.transaction_id = fake_transaction_id
self.fake_matrix_client.accept_key_verification.return_value = make_awaitable( self.fake_matrix_client.accept_key_verification.return_value = Mock(
Mock(spec=nio.ToDeviceError) spec=nio.ToDeviceError
) )
self.fake_matrix_client.to_device.return_value = make_awaitable()
fake_sas = Mock() fake_sas = Mock()
fake_transactions_dict = {fake_transaction_id: fake_sas} fake_transactions_dict = {fake_transaction_id: fake_sas}
@ -641,10 +586,7 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
fake_key_verification_event.short_authentication_string = ["emoji"] fake_key_verification_event.short_authentication_string = ["emoji"]
fake_key_verification_event.transaction_id = fake_transaction_id fake_key_verification_event.transaction_id = fake_transaction_id
self.fake_matrix_client.accept_key_verification.return_value = make_awaitable() self.fake_matrix_client.to_device.return_value = Mock(spec=nio.ToDeviceError)
self.fake_matrix_client.to_device.return_value = make_awaitable(
Mock(spec=nio.ToDeviceError)
)
fake_sas = Mock() fake_sas = Mock()
fake_transactions_dict = {fake_transaction_id: fake_sas} fake_transactions_dict = {fake_transaction_id: fake_sas}
@ -680,10 +622,6 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
fake_key_verification_event.sender = "@some_other_fake_user:example.com" fake_key_verification_event.sender = "@some_other_fake_user:example.com"
fake_key_verification_event.transaction_id = fake_transaction_id fake_key_verification_event.transaction_id = fake_transaction_id
self.fake_matrix_client.confirm_short_auth_string.return_value = (
make_awaitable()
)
fake_sas = Mock() fake_sas = Mock()
fake_sas.get_emoji.return_value = [ fake_sas.get_emoji.return_value = [
("emoji1", "alt text1"), ("emoji1", "alt text1"),
@ -709,8 +647,8 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
fake_key_verification_event.sender = "@some_other_fake_user:example.com" fake_key_verification_event.sender = "@some_other_fake_user:example.com"
fake_key_verification_event.transaction_id = fake_transaction_id fake_key_verification_event.transaction_id = fake_transaction_id
self.fake_matrix_client.confirm_short_auth_string.return_value = make_awaitable( self.fake_matrix_client.confirm_short_auth_string.return_value = Mock(
Mock(spec=nio.ToDeviceError) spec=nio.ToDeviceError
) )
fake_sas = Mock() fake_sas = Mock()
@ -738,8 +676,6 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
fake_key_verification_event.sender = "@some_other_fake_user:example.com" fake_key_verification_event.sender = "@some_other_fake_user:example.com"
fake_key_verification_event.transaction_id = fake_transaction_id fake_key_verification_event.transaction_id = fake_transaction_id
self.fake_matrix_client.to_device.return_value = make_awaitable()
fake_sas = Mock() fake_sas = Mock()
fake_sas.verified_devices = ["HGFEDCBA"] fake_sas.verified_devices = ["HGFEDCBA"]
fake_transactions_dict = {fake_transaction_id: fake_sas} fake_transactions_dict = {fake_transaction_id: fake_sas}
@ -761,8 +697,6 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
fake_key_verification_event.sender = "@some_other_fake_user:example.com" fake_key_verification_event.sender = "@some_other_fake_user:example.com"
fake_key_verification_event.transaction_id = fake_transaction_id fake_key_verification_event.transaction_id = fake_transaction_id
self.fake_matrix_client.to_device.return_value = make_awaitable()
fake_sas = Mock() fake_sas = Mock()
fake_transactions_dict = {} fake_transactions_dict = {}
self.fake_matrix_client.key_verifications = fake_transactions_dict self.fake_matrix_client.key_verifications = fake_transactions_dict
@ -783,8 +717,6 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
fake_key_verification_event.sender = "@some_other_fake_user:example.com" fake_key_verification_event.sender = "@some_other_fake_user:example.com"
fake_key_verification_event.transaction_id = fake_transaction_id fake_key_verification_event.transaction_id = fake_transaction_id
self.fake_matrix_client.to_device.return_value = make_awaitable()
fake_sas = Mock() fake_sas = Mock()
fake_sas.get_mac.side_effect = key_verification_get_mac_raise_protocol_error fake_sas.get_mac.side_effect = key_verification_get_mac_raise_protocol_error
fake_transactions_dict = {fake_transaction_id: fake_sas} fake_transactions_dict = {fake_transaction_id: fake_sas}
@ -806,9 +738,7 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
fake_key_verification_event.sender = "@some_other_fake_user:example.com" fake_key_verification_event.sender = "@some_other_fake_user:example.com"
fake_key_verification_event.transaction_id = fake_transaction_id fake_key_verification_event.transaction_id = fake_transaction_id
self.fake_matrix_client.to_device.return_value = make_awaitable( self.fake_matrix_client.to_device.return_value = Mock(spec=nio.ToDeviceError)
Mock(spec=nio.ToDeviceError)
)
fake_sas = Mock() fake_sas = Mock()
fake_transactions_dict = {fake_transaction_id: fake_sas} fake_transactions_dict = {fake_transaction_id: fake_sas}
@ -821,25 +751,6 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
fake_sas.get_mac.assert_called_once_with() fake_sas.get_mac.assert_called_once_with()
self.fake_matrix_client.to_device.assert_called_once_with(fake_sas.get_mac()) self.fake_matrix_client.to_device.assert_called_once_with(fake_sas.get_mac())
@patch.object(matrix_alertbot.callback.CommandFactory, "create", autospec=True)
async def test_unknown(self, fake_command_create: Mock) -> None:
"""Tests the callback for RoomMessageText with the command prefix"""
# Tests that the bot process messages in the room that contain a command
fake_command = Mock(spec=BaseCommand)
fake_command_create.return_value = fake_command
fake_reaction_event = Mock(spec=nio.UnknownEvent)
fake_reaction_event.type = "m.reaction"
fake_reaction_event.event_id = "some event id"
fake_reaction_event.sender = "@some_other_fake_user:example.com"
fake_reaction_event.source = {}
# Pretend that we received a text message event
await self.callbacks.unknown(self.fake_room, fake_reaction_event)
# Check that we attempted to execute the command
fake_command_create.assert_not_called()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -10,8 +10,6 @@ from matrix_alertbot.chat_functions import (
strip_fallback, strip_fallback,
) )
from tests.utils import make_awaitable
async def send_room_raise_send_retry_error( async def send_room_raise_send_retry_error(
room_id: str, room_id: str,
@ -39,11 +37,12 @@ class ChatFunctionsTestCase(unittest.IsolatedAsyncioTestCase):
async def test_react_to_event(self) -> None: async def test_react_to_event(self) -> None:
fake_response = Mock(spec=nio.RoomSendResponse) fake_response = Mock(spec=nio.RoomSendResponse)
fake_matrix_client = Mock(spec=nio.AsyncClient) fake_matrix_client = Mock(spec=nio.AsyncClient)
fake_matrix_client.room_send = Mock(return_value=make_awaitable(fake_response))
fake_room_id = "!abcdefgh:example.com" fake_room_id = "!abcdefgh:example.com"
fake_event_id = "some event id" fake_event_id = "some event id"
fake_reaction_text = "some reaction" fake_reaction_text = "some reaction"
fake_matrix_client.room_send.return_value = fake_response
response = await react_to_event( response = await react_to_event(
fake_matrix_client, fake_room_id, fake_event_id, fake_reaction_text fake_matrix_client, fake_room_id, fake_event_id, fake_reaction_text
) )
@ -67,7 +66,7 @@ class ChatFunctionsTestCase(unittest.IsolatedAsyncioTestCase):
fake_response.message = "some error" fake_response.message = "some error"
fake_response.status_code = "some status code" fake_response.status_code = "some status code"
fake_matrix_client = Mock(spec=nio.AsyncClient) fake_matrix_client = Mock(spec=nio.AsyncClient)
fake_matrix_client.room_send.return_value = make_awaitable(fake_response) fake_matrix_client.room_send.return_value = fake_response
fake_room_id = "!abcdefgh:example.com" fake_room_id = "!abcdefgh:example.com"
fake_event_id = "some event id" fake_event_id = "some event id"
fake_reaction_text = "some reaction" fake_reaction_text = "some reaction"
@ -93,11 +92,12 @@ class ChatFunctionsTestCase(unittest.IsolatedAsyncioTestCase):
async def test_send_text_to_room_as_notice(self) -> None: async def test_send_text_to_room_as_notice(self) -> None:
fake_response = Mock(spec=nio.RoomSendResponse) fake_response = Mock(spec=nio.RoomSendResponse)
fake_matrix_client = Mock(spec=nio.AsyncClient) fake_matrix_client = Mock(spec=nio.AsyncClient)
fake_matrix_client.room_send = Mock(return_value=make_awaitable(fake_response))
fake_room_id = "!abcdefgh:example.com" fake_room_id = "!abcdefgh:example.com"
fake_plaintext_body = "some plaintext message" fake_plaintext_body = "some plaintext message"
fake_html_body = "some html message" fake_html_body = "some html message"
fake_matrix_client.room_send.return_value = fake_response
response = await send_text_to_room( response = await send_text_to_room(
fake_matrix_client, fake_room_id, fake_plaintext_body, fake_html_body fake_matrix_client, fake_room_id, fake_plaintext_body, fake_html_body
) )
@ -118,11 +118,12 @@ class ChatFunctionsTestCase(unittest.IsolatedAsyncioTestCase):
async def test_send_text_to_room_as_message(self) -> None: async def test_send_text_to_room_as_message(self) -> None:
fake_response = Mock(spec=nio.RoomSendResponse) fake_response = Mock(spec=nio.RoomSendResponse)
fake_matrix_client = Mock(spec=nio.AsyncClient) fake_matrix_client = Mock(spec=nio.AsyncClient)
fake_matrix_client.room_send.return_value = make_awaitable(fake_response)
fake_room_id = "!abcdefgh:example.com" fake_room_id = "!abcdefgh:example.com"
fake_plaintext_body = "some plaintext message" fake_plaintext_body = "some plaintext message"
fake_html_body = "some html message" fake_html_body = "some html message"
fake_matrix_client.room_send.return_value = fake_response
response = await send_text_to_room( response = await send_text_to_room(
fake_matrix_client, fake_matrix_client,
fake_room_id, fake_room_id,
@ -147,12 +148,13 @@ class ChatFunctionsTestCase(unittest.IsolatedAsyncioTestCase):
async def test_send_text_to_room_in_reply_to_event(self) -> None: async def test_send_text_to_room_in_reply_to_event(self) -> None:
fake_response = Mock(spec=nio.RoomSendResponse) fake_response = Mock(spec=nio.RoomSendResponse)
fake_matrix_client = Mock(spec=nio.AsyncClient) fake_matrix_client = Mock(spec=nio.AsyncClient)
fake_matrix_client.room_send.return_value = make_awaitable(fake_response)
fake_room_id = "!abcdefgh:example.com" fake_room_id = "!abcdefgh:example.com"
fake_plaintext_body = "some plaintext message" fake_plaintext_body = "some plaintext message"
fake_html_body = "some html message" fake_html_body = "some html message"
fake_event_id = "some event id" fake_event_id = "some event id"
fake_matrix_client.room_send.return_value = fake_response
response = await send_text_to_room( response = await send_text_to_room(
fake_matrix_client, fake_matrix_client,
fake_room_id, fake_room_id,
@ -208,11 +210,12 @@ class ChatFunctionsTestCase(unittest.IsolatedAsyncioTestCase):
fake_response.status_code = "some status_code" fake_response.status_code = "some status_code"
fake_response.message = "some error" fake_response.message = "some error"
fake_matrix_client = Mock(spec=nio.AsyncClient) fake_matrix_client = Mock(spec=nio.AsyncClient)
fake_matrix_client.room_send.return_value = make_awaitable(fake_response)
fake_room_id = "!abcdefgh:example.com" fake_room_id = "!abcdefgh:example.com"
fake_plaintext_body = "some plaintext message" fake_plaintext_body = "some plaintext message"
fake_html_body = "some html message" fake_html_body = "some html message"
fake_matrix_client.room_send.return_value = fake_response
with self.assertRaises(nio.SendRetryError): with self.assertRaises(nio.SendRetryError):
await send_text_to_room( await send_text_to_room(
fake_matrix_client, fake_matrix_client,

View file

@ -21,8 +21,6 @@ from matrix_alertbot.errors import (
SilenceNotFoundError, SilenceNotFoundError,
) )
from tests.utils import make_awaitable
def cache_get_item(key: str) -> str: def cache_get_item(key: str) -> str:
return { return {
@ -84,8 +82,6 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase):
# Create a Command object and give it some Mock'd objects to use # Create a Command object and give it some Mock'd objects to use
self.fake_matrix_client = Mock(spec=nio.AsyncClient) self.fake_matrix_client = Mock(spec=nio.AsyncClient)
self.fake_matrix_client.user = "@fake_user:example.com" self.fake_matrix_client.user = "@fake_user:example.com"
# Pretend that attempting to send a message is always successful
self.fake_matrix_client.room_send.return_value = make_awaitable()
self.fake_cache = MagicMock(spec=Cache) self.fake_cache = MagicMock(spec=Cache)
self.fake_cache.__getitem__.side_effect = cache_get_item self.fake_cache.__getitem__.side_effect = cache_get_item

View file

@ -51,12 +51,15 @@ class ConfigTestCase(unittest.TestCase):
fake_path_exists.assert_called_once_with("data/store") fake_path_exists.assert_called_once_with("data/store")
fake_mkdir.assert_called_once_with("data/store") fake_mkdir.assert_called_once_with("data/store")
self.assertEqual("@fakes_user:matrix.example.com", config.user_id) self.assertEqual({"@fakes_user:matrix.example.com"}, config.user_ids)
self.assertEqual("password", config.user_password) self.assertEqual(1, len(config.accounts))
self.assertIsNone(config.user_token) self.assertEqual("password", config.accounts[0].password)
self.assertIsNone(config.device_id) self.assertIsNone(config.accounts[0].token)
self.assertIsNone(config.accounts[0].device_id)
self.assertEqual("matrix-alertbot", config.device_name) self.assertEqual("matrix-alertbot", config.device_name)
self.assertEqual("https://matrix.example.com", config.homeserver_url) self.assertEqual(
"https://matrix.example.com", config.accounts[0].homeserver_url
)
self.assertEqual(["!abcdefgh:matrix.example.com"], config.allowed_rooms) self.assertEqual(["!abcdefgh:matrix.example.com"], config.allowed_rooms)
self.assertEqual(DEFAULT_REACTIONS, config.allowed_reactions) self.assertEqual(DEFAULT_REACTIONS, config.allowed_reactions)
@ -92,13 +95,24 @@ class ConfigTestCase(unittest.TestCase):
fake_path_exists.assert_called_once_with("data/store") fake_path_exists.assert_called_once_with("data/store")
fake_mkdir.assert_called_once_with("data/store") fake_mkdir.assert_called_once_with("data/store")
self.assertEqual("@fakes_user:matrix.example.com", config.user_id) self.assertEqual(
self.assertEqual("password", config.user_password) {"@fakes_user:matrix.example.com", "@other_user:matrix.domain.tld"},
self.assertIsNone(config.user_token) config.user_ids,
self.assertEqual("token.json", config.user_token_file) )
self.assertEqual("ABCDEFGHIJ", config.device_id) self.assertEqual(2, len(config.accounts))
self.assertEqual("password", config.accounts[0].password)
self.assertIsNone(config.accounts[0].token)
self.assertEqual("fake_token.json", config.accounts[0].token_file)
self.assertEqual("ABCDEFGHIJ", config.accounts[0].device_id)
self.assertEqual(
"https://matrix.example.com", config.accounts[0].homeserver_url
)
self.assertIsNone(config.accounts[1].password)
self.assertEqual("token", config.accounts[1].token)
self.assertEqual("other_token.json", config.accounts[1].token_file)
self.assertEqual("KLMNOPQRST", config.accounts[1].device_id)
self.assertEqual("https://matrix.domain.tld", config.accounts[1].homeserver_url)
self.assertEqual("fake_device_name", config.device_name) self.assertEqual("fake_device_name", config.device_name)
self.assertEqual("https://matrix.example.com", config.homeserver_url)
self.assertEqual(["!abcdefgh:matrix.example.com"], config.allowed_rooms) self.assertEqual(["!abcdefgh:matrix.example.com"], config.allowed_rooms)
self.assertEqual({"🤫", "😶", "🤐"}, config.allowed_reactions) self.assertEqual({"🤫", "😶", "🤐"}, config.allowed_reactions)
@ -150,7 +164,7 @@ class ConfigTestCase(unittest.TestCase):
config_path = os.path.join(CONFIG_RESOURCES_DIR, "config.minimal.yml") config_path = os.path.join(CONFIG_RESOURCES_DIR, "config.minimal.yml")
config = DummyConfig(config_path) config = DummyConfig(config_path)
del config.config_dict["matrix"]["user_id"] del config.config_dict["matrix"]["accounts"]
with self.assertRaises(RequiredConfigKeyError): with self.assertRaises(RequiredConfigKeyError):
config._parse_config_values() config._parse_config_values()
@ -166,7 +180,7 @@ class ConfigTestCase(unittest.TestCase):
config_path = os.path.join(CONFIG_RESOURCES_DIR, "config.minimal.yml") config_path = os.path.join(CONFIG_RESOURCES_DIR, "config.minimal.yml")
config = DummyConfig(config_path) config = DummyConfig(config_path)
del config.config_dict["matrix"]["user_password"] del config.config_dict["matrix"]["accounts"][0]["password"]
with self.assertRaises(RequiredConfigKeyError): with self.assertRaises(RequiredConfigKeyError):
config._parse_config_values() config._parse_config_values()
@ -182,7 +196,7 @@ class ConfigTestCase(unittest.TestCase):
config_path = os.path.join(CONFIG_RESOURCES_DIR, "config.minimal.yml") config_path = os.path.join(CONFIG_RESOURCES_DIR, "config.minimal.yml")
config = DummyConfig(config_path) config = DummyConfig(config_path)
del config.config_dict["matrix"]["url"] del config.config_dict["matrix"]["accounts"][0]["url"]
with self.assertRaises(RequiredConfigKeyError): with self.assertRaises(RequiredConfigKeyError):
config._parse_config_values() config._parse_config_values()
@ -279,27 +293,27 @@ class ConfigTestCase(unittest.TestCase):
config_path = os.path.join(CONFIG_RESOURCES_DIR, "config.minimal.yml") config_path = os.path.join(CONFIG_RESOURCES_DIR, "config.minimal.yml")
config = DummyConfig(config_path) config = DummyConfig(config_path)
config.config_dict["matrix"]["user_id"] = "" config.config_dict["matrix"]["accounts"][0]["id"] = ""
with self.assertRaises(InvalidConfigError): with self.assertRaises(InvalidConfigError):
config._parse_config_values() config._parse_config_values()
config.config_dict["matrix"]["user_id"] = "@fake_user" config.config_dict["matrix"]["accounts"][0]["id"] = "@fake_user"
with self.assertRaises(InvalidConfigError): with self.assertRaises(InvalidConfigError):
config._parse_config_values() config._parse_config_values()
config.config_dict["matrix"]["user_id"] = "@fake_user:" config.config_dict["matrix"]["accounts"][0]["id"] = "@fake_user:"
with self.assertRaises(InvalidConfigError): with self.assertRaises(InvalidConfigError):
config._parse_config_values() config._parse_config_values()
config.config_dict["matrix"]["user_id"] = ":matrix.example.com" config.config_dict["matrix"]["accounts"][0]["id"] = ":matrix.example.com"
with self.assertRaises(InvalidConfigError): with self.assertRaises(InvalidConfigError):
config._parse_config_values() config._parse_config_values()
config.config_dict["matrix"]["user_id"] = "@:matrix.example.com" config.config_dict["matrix"]["accounts"][0]["id"] = "@:matrix.example.com"
with self.assertRaises(InvalidConfigError): with self.assertRaises(InvalidConfigError):
config._parse_config_values() config._parse_config_values()
config.config_dict["matrix"]["user_id"] = "@:" config.config_dict["matrix"]["accounts"][0]["id"] = "@:"
with self.assertRaises(InvalidConfigError): with self.assertRaises(InvalidConfigError):
config._parse_config_values() config._parse_config_values()

View file

@ -6,7 +6,8 @@ import aiohttp.test_utils
import nio import nio
from aiohttp import web from aiohttp import web
from diskcache import Cache from diskcache import Cache
from nio import LocalProtocolError, RoomSendResponse from nio.exceptions import LocalProtocolError
from nio.responses import RoomSendResponse
import matrix_alertbot.webhook import matrix_alertbot.webhook
from matrix_alertbot.alertmanager import AlertmanagerClient from matrix_alertbot.alertmanager import AlertmanagerClient
@ -16,6 +17,7 @@ from matrix_alertbot.errors import (
SilenceExtendError, SilenceExtendError,
SilenceNotFoundError, SilenceNotFoundError,
) )
from matrix_alertbot.matrix import MatrixClientPool
from matrix_alertbot.webhook import Webhook from matrix_alertbot.webhook import Webhook
@ -40,6 +42,8 @@ def update_silence_raise_alertmanager_error(fingerprint: str) -> str:
class WebhookApplicationTestCase(aiohttp.test_utils.AioHTTPTestCase): class WebhookApplicationTestCase(aiohttp.test_utils.AioHTTPTestCase):
async def get_application(self) -> web.Application: async def get_application(self) -> web.Application:
self.fake_matrix_client = Mock(spec=nio.AsyncClient) self.fake_matrix_client = Mock(spec=nio.AsyncClient)
self.fake_matrix_client_pool = Mock(spec=MatrixClientPool)
self.fake_matrix_client_pool.matrix_client = self.fake_matrix_client
self.fake_alertmanager_client = Mock(spec=AlertmanagerClient) self.fake_alertmanager_client = Mock(spec=AlertmanagerClient)
self.fake_cache = Mock(spec=Cache) self.fake_cache = Mock(spec=Cache)
@ -81,7 +85,7 @@ class WebhookApplicationTestCase(aiohttp.test_utils.AioHTTPTestCase):
} }
webhook = Webhook( webhook = Webhook(
self.fake_matrix_client, self.fake_matrix_client_pool,
self.fake_alertmanager_client, self.fake_alertmanager_client,
self.fake_cache, self.fake_cache,
self.fake_config, self.fake_config,

View file

@ -1,22 +0,0 @@
# Utility functions to make testing easier
import asyncio
from typing import Any, Awaitable
def run_coroutine(result: Awaitable[Any]) -> Any:
"""Wrapper for asyncio functions to allow them to be run from synchronous functions"""
loop = asyncio.get_event_loop()
result = loop.run_until_complete(result)
loop.close()
return result
def make_awaitable(result: Any = None) -> Awaitable[Any]:
"""
Makes an awaitable, suitable for mocking an `async` function.
This uses Futures as they can be awaited multiple times so can be returned
to multiple callers.
"""
future = asyncio.Future() # type: ignore
future.set_result(result)
return future