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
store/
cache/
token.json
*token.json
# Config file
config.yaml

View file

@ -7,28 +7,29 @@ command_prefix: "!alert"
# Options for connecting to the bot's Matrix account
matrix:
# The Matrix User ID of the bot account
user_id: "@bot:matrix.example.com"
accounts:
- # The Matrix User IDs of the bot account
id: "@bot:matrix.example.com"
# Matrix account password (optional if access token used)
user_password: "password"
# 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)
#user_token: ""
# 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: ""
# Path to the file where to store the user access token
user_token_file: "token.json"
# Path to the file where to store the user access token
token_file: "token.json"
# The URL of the homeserver to connect to
url: https://matrix.example.com
# The URL of the homeserver to connect to
url: https://matrix.example.com
# 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: ABCDEFGHIJ
# 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: ABCDEFGHIJ
# What to name the logged in device
device_name: matrix-alertbot
@ -79,6 +80,8 @@ logging:
file_logging:
# Whether logging to a file is enabled
enabled: false
# Logging level specific to file logging (optional)
level: WARN
# The path to the file to log to. May be relative or absolute
filepath: matrix-alertbot.log
# Configure logging to the console output

View file

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

View file

@ -1,25 +1,27 @@
from __future__ import annotations
import logging
from diskcache import Cache
from nio import (
AsyncClient,
from nio.client import AsyncClient
from nio.events import (
Event,
InviteMemberEvent,
JoinError,
KeyVerificationCancel,
KeyVerificationKey,
KeyVerificationMac,
KeyVerificationStart,
LocalProtocolError,
MatrixRoom,
MegolmEvent,
ReactionEvent,
RedactionEvent,
RoomGetEventError,
RoomMessageText,
SendRetryError,
ToDeviceError,
UnknownEvent,
RoomMessageUnknown,
)
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.chat_functions import strip_fallback
from matrix_alertbot.command import AckAlertCommand, CommandFactory, UnackAlertCommand
@ -35,6 +37,7 @@ class Callbacks:
alertmanager_client: AlertmanagerClient,
cache: Cache,
config: Config,
matrix_client_pool: matrix_alertbot.matrix.MatrixClientPool,
):
"""
Args:
@ -47,6 +50,7 @@ class Callbacks:
config: Bot configuration parameters.
"""
self.matrix_client = matrix_client
self.matrix_client_pool = matrix_client_pool
self.cache = cache
self.alertmanager_client = alertmanager_client
self.config = config
@ -60,8 +64,12 @@ class Callbacks:
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
if event.sender == self.matrix_client.user:
if event.sender in self.config.user_ids:
return
# Ignore messages from unauthorized room
@ -72,13 +80,16 @@ class Callbacks:
msg = strip_fallback(event.body)
logger.debug(
f"Bot message received for room {room.display_name} | "
f"{room.user_name(event.sender)}: {msg}"
f"Bot {self.matrix_client.user_id} | Room ID {room.room_id} | "
f"Event ID {event.event_id} | Sender {event.sender} | "
f"Message received: {msg}"
)
# Process as message if in a public room without command prefix
has_command_prefix = msg.startswith(self.command_prefix)
if not has_command_prefix:
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."
)
return
@ -91,7 +102,11 @@ class Callbacks:
)
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
cmd = msg[len(self.command_prefix) :]
@ -108,13 +123,22 @@ class Callbacks:
reacted_to_event_id,
)
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
try:
await command.process()
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:
"""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:
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
for attempt in range(3):
@ -145,7 +173,11 @@ class Callbacks:
logger.error("Unable to join room: %s", room.room_id)
# 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(
self, room: MatrixRoom, event: InviteMemberEvent
@ -160,9 +192,7 @@ class Callbacks:
# This is our own membership (invite) event
await self.invite(room, event)
async def _reaction(
self, room: MatrixRoom, event: UnknownEvent, alert_event_id: str
) -> None:
async def reaction(self, room: MatrixRoom, event: ReactionEvent) -> None:
"""A reaction was sent to one of our messages. Let's send a reply acknowledging it.
Args:
@ -172,34 +202,48 @@ class Callbacks:
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
if room.room_id not in self.config.allowed_rooms:
return
# Ignore reactions from ourselves
if event.sender == self.matrix_client.user:
if event.sender in self.config.user_ids:
return
reaction = event.source.get("content", {}).get("m.relates_to", {}).get("key")
logger.debug(f"Got reaction {reaction} to {room.room_id} from {event.sender}.")
logger.debug(
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:
logger.warning(f"Uknown duration reaction {reaction}")
if event.key not in self.config.allowed_reactions:
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
alert_event_id = event.reacts_to
# Get the original event that was reacted to
event_response = await self.matrix_client.room_get_event(
room.room_id, alert_event_id
)
if isinstance(event_response, RoomGetEventError):
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
reacted_to_event = event_response.event
# 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
# Send a message acknowledging the reaction
@ -217,18 +261,31 @@ class Callbacks:
try:
await command.process()
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:
# 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
if room.room_id not in self.config.allowed_rooms:
return
# Ignore redactions from ourselves
if event.sender == self.matrix_client.user:
if event.sender in self.config.user_ids:
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(
self.matrix_client,
@ -243,7 +300,12 @@ class Callbacks:
try:
await command.process()
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:
"""Callback for when an event fails to decrypt. Inform the user.
@ -258,7 +320,9 @@ class Callbacks:
return
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"Tip: try using a different device ID in your config file and restart."
f"\n\n"
@ -271,7 +335,8 @@ class Callbacks:
"""Callback for when somebody wants to verify our devices."""
if "emoji" not in event.short_authentication_string:
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
@ -280,7 +345,8 @@ class Callbacks:
)
if isinstance(event_response, ToDeviceError):
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
@ -290,7 +356,8 @@ class Callbacks:
event_response = await self.matrix_client.to_device(todevice_msg)
if isinstance(event_response, ToDeviceError):
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
@ -300,7 +367,8 @@ class Callbacks:
# here. The SAS flow is already cancelled.
# We only need to inform the user.
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):
@ -310,7 +378,8 @@ class Callbacks:
alt_text_str = " ".join(alt_text_list)
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(
@ -318,7 +387,8 @@ class Callbacks:
)
if isinstance(event_response, ToDeviceError):
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
@ -338,12 +408,13 @@ class Callbacks:
# 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:
sas = self.matrix_client.key_verifications[event.transaction_id]
except KeyError:
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
@ -351,44 +422,54 @@ class Callbacks:
todevice_msg = sas.get_mac()
except LocalProtocolError as e:
# 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
event_response = await self.matrix_client.to_device(todevice_msg)
if isinstance(event_response, ToDeviceError):
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
verified_devices = " ".join(sas.verified_devices)
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:
"""Callback for when an event with a type that is unknown to matrix-nio is received.
Currently this is used for reaction events, which are not yet part of a released
matrix spec (and are thus unknown to nio).
Args:
room: The room the reaction was sent in.
event: The event itself.
"""
# Ignore events from unauthorized room
if room.room_id not in self.config.allowed_rooms:
async def unknown_message(
self, room: MatrixRoom, event: RoomMessageUnknown
) -> None:
event_content = event.source["content"]
if event_content["msgtype"] != "m.key.verification.request":
return
if event.type == "m.reaction":
# Get the ID of the event this was a reaction to
relation_dict = event.source.get("content", {}).get("m.relates_to", {})
if "m.sas.v1" not in event_content["methods"]:
return
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
logger.debug(
f"Got unknown event with type to {event.type} from {event.sender} in {room.room_id}."
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(
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
from typing import Dict, Optional, TypedDict, Union
from nio import (
AsyncClient,
ErrorResponse,
Response,
RoomSendError,
RoomSendResponse,
SendRetryError,
)
from nio.client import AsyncClient
from nio.exceptions import SendRetryError
from nio.responses import ErrorResponse, Response, RoomSendError, RoomSendResponse
from typing_extensions import NotRequired
logger = logging.getLogger(__name__)
@ -30,7 +27,7 @@ async def send_text_to_room(
matrix_client: AsyncClient,
room_id: str,
plaintext: str,
html: str = None,
html: Optional[str] = None,
notice: bool = True,
reply_to_event_id: Optional[str] = None,
) -> RoomSendResponse:

View file

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

View file

@ -1,8 +1,10 @@
from __future__ import annotations
import logging
import os
import re
import sys
from typing import Any, List, Optional
from typing import Any, Dict, List, Optional
import pytimeparse2
import yaml
@ -22,6 +24,27 @@ logging.getLogger("peewee").setLevel(
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:
"""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(
["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:
file_handler = logging.FileHandler(file_logging_filepath)
file_handler.setFormatter(formatter)
if file_logging_log_level:
file_handler.setLevel(file_logging_log_level)
logger.addHandler(file_handler)
console_logging_enabled = self._get_cfg(
["logging", "console_logging", "enabled"], default=True
)
console_logging_log_level = self._get_cfg(
["logging", "console_logging", "level"], required=False
)
if console_logging_enabled:
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
if console_logging_log_level:
console_handler.setLevel(console_logging_log_level)
logger.addHandler(console_handler)
# Storage setup
@ -91,26 +124,22 @@ class Config:
["alertmanager", "url"], required=True
)
# Matrix bot account setup
self.user_id: str = self._get_cfg(["matrix", "user_id"], required=True)
if not re.match("@.+:.+", self.user_id):
raise InvalidConfigError("matrix.user_id must be in the form @name:domain")
self.user_password: str = self._get_cfg(
["matrix", "user_password"], required=False
)
self.user_token: str = self._get_cfg(["matrix", "user_token"], required=False)
if not self.user_token and not self.user_password:
raise RequiredConfigKeyError("Must supply either user token or password")
self.device_id: str = self._get_cfg(["matrix", "device_id"], required=False)
# Matrix bot accounts setup
self.accounts: List[AccountConfig] = []
accounts_dict: list = self._get_cfg(["matrix", "accounts"], required=True)
for i, account_dict in enumerate(accounts_dict):
try:
account = AccountConfig(account_dict)
except KeyError as e:
key_name = e.args[0]
raise RequiredConfigKeyError(
f"Config option matrix.accounts.{i}.{key_name} is required"
)
self.accounts.append(account)
self.user_ids = {account.id for account in self.accounts}
self.device_name: str = self._get_cfg(
["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(
["matrix", "allowed_rooms"], required=True
)

View file

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

View file

@ -1,142 +1,20 @@
#!/usr/bin/env python3
import asyncio
import json
import logging
import os
import sys
from asyncio import TimeoutError
from __future__ import annotations
import asyncio
import logging
import sys
from aiohttp import ClientConnectionError, ServerDisconnectedError
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.callback import Callbacks
from matrix_alertbot.config import Config
from matrix_alertbot.matrix import MatrixClientPool
from matrix_alertbot.webhook import Webhook
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:
"""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
config = Config(config_path)
matrix_client = create_matrix_client(config)
# Configure the cache
cache = Cache(config.cache_dir)
# Configure Alertmanager client
alertmanager_client = AlertmanagerClient(config.alertmanager_url, cache)
# Set up event callbacks
callbacks = Callbacks(matrix_client, 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,)
)
# Create matrix clients
matrix_client_pool = MatrixClientPool(alertmanager_client, cache, config)
# 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.create_task(matrix_client_pool.switch_active_client())
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:
loop.run_forever()
@ -193,5 +52,5 @@ def main() -> None:
finally:
loop.run_until_complete(webhook_server.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()

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
import logging
from typing import List
import prometheus_client
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.middleware import prometheus_middleware_factory
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.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.errors import (
AlertmanagerError,
MatrixClientError,
SilenceExtendError,
SilenceNotFoundError,
)
from matrix_alertbot.matrix import MatrixClientPool
logger = logging.getLogger(__name__)
@ -93,13 +96,13 @@ async def create_alerts(request: web_request.Request) -> web.Response:
if len(data["alerts"]) == 0:
return web.Response(status=400, body="Alerts cannot be empty.")
alerts = []
for alert in alert_dicts:
alerts: List[Alert] = []
for alert_dict in alert_dicts:
try:
alert = Alert.from_dict(alert)
alert = Alert.from_dict(alert_dict)
except KeyError as 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)
for alert in alerts:
@ -121,6 +124,14 @@ async def create_alerts(request: web_request.Request) -> web.Response:
status=500,
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:
logger.error(
f"Unable to send alert {alert.fingerprint} to Matrix room {room_id}: {e}"
@ -138,7 +149,7 @@ async def create_alert(
) -> None:
alertmanager_client: AlertmanagerClient = request.app["alertmanager_client"]
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"]
config: Config = request.app["config"]
@ -162,9 +173,12 @@ async def create_alert(
plaintext = alert_renderer.render(alert, html=False)
html = alert_renderer.render(alert, html=True)
event = await send_text_to_room(
matrix_client, room_id, plaintext, html, notice=False
)
if matrix_client_pool.matrix_client is not None:
event = await send_text_to_room(
matrix_client_pool.matrix_client, room_id, plaintext, html, notice=False
)
else:
raise MatrixClientError("No matrix client available")
if alert.firing:
cache.set(event.event_id, alert.fingerprint, expire=config.cache_expire_time)
@ -175,13 +189,13 @@ async def create_alert(
class Webhook:
def __init__(
self,
matrix_client: AsyncClient,
matrix_client_pool: MatrixClientPool,
alertmanager_client: AlertmanagerClient,
cache: Cache,
config: Config,
) -> None:
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["config"] = config
self.app["cache"] = cache

View file

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

View file

@ -7,28 +7,52 @@ command_prefix: "!alert"
# Options for connecting to the bot's Matrix account
matrix:
# The Matrix User ID of the bot account
user_id: "@fakes_user:matrix.example.com"
accounts:
- # The Matrix User ID of the bot account
id: "@fakes_user:matrix.example.com"
# Matrix account password (optional if access token used)
user_password: "password"
# 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)
#user_token: ""
# 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: ""
# Path to the file where to store the user access token
user_token_file: "token.json"
# Path to the file where to store the user access token
token_file: "fake_token.json"
# The URL of the homeserver to connect to
url: https://matrix.example.com
# The URL of the homeserver to connect to
url: https://matrix.example.com
# 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: ABCDEFGHIJ
# 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: 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
device_name: fake_device_name
@ -75,6 +99,7 @@ logging:
enabled: true
# The path to the file to log to. May be relative or absolute
filepath: fake.log
level: INFO
# Configure logging to the console output
console_logging:
# Whether logging to the console is enabled

View file

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

View file

@ -1,3 +1,5 @@
from __future__ import annotations
import unittest
from typing import Dict
from unittest.mock import MagicMock, Mock, patch
@ -6,13 +8,10 @@ import nio
import nio.crypto
from diskcache import Cache
import matrix_alertbot.alertmanager
import matrix_alertbot.callback
import matrix_alertbot.command
from matrix_alertbot.alertmanager import AlertmanagerClient
from matrix_alertbot.callback import Callbacks
from matrix_alertbot.command import BaseCommand
from tests.utils import make_awaitable
import matrix_alertbot.matrix
def key_verification_get_mac_raise_protocol_error():
@ -23,10 +22,13 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
# 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.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_alertmanager_client = Mock(spec=AlertmanagerClient)
self.fake_alertmanager_client = Mock(
spec=matrix_alertbot.alertmanager.AlertmanagerClient
)
# Create a fake room to play with
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_reactions = ["🤫"]
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_alertmanager_client,
self.fake_cache,
self.fake_config,
self.fake_matrix_client_pool,
)
async def test_invite(self) -> None:
@ -52,9 +61,6 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
fake_invite_event = Mock(spec=nio.InviteMemberEvent)
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
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.sender = "@some_other_fake_user:example.com"
fake_message_event.body = "Hello world!"
fake_message_event.event_id = "some event id"
# Pretend that we received a text 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
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
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
fake_alert_event = Mock(spec=nio.RoomMessageText)
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.type = "m.reaction"
fake_reaction_event = Mock(spec=nio.ReactionEvent)
fake_reaction_event.event_id = "some event id"
fake_reaction_event.sender = "@some_other_fake_user:example.com"
fake_reaction_event.source = {
"content": {
"m.relates_to": {
"event_id": fake_alert_event.event_id,
"key": "🤫",
"rel_type": "m.annotation",
}
}
}
fake_reaction_event.reacts_to = fake_alert_event.event_id
fake_reaction_event.key = "🤫"
fake_event_response = Mock(spec=nio.RoomGetEventResponse)
fake_event_response.event = fake_alert_event
self.fake_matrix_client.room_get_event.return_value = make_awaitable(
fake_event_response
)
self.fake_matrix_client.room_get_event.return_value = fake_event_response
# 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
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
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.event_id = "some event id"
fake_reaction_event.sender = "@some_other_fake_user:example.com"
fake_reaction_event.source = {
"content": {
"m.relates_to": {
"event_id": fake_alert_event_id,
"key": "🤫",
"rel_type": "m.annotation",
}
}
}
fake_reaction_event.reacts_to = fake_alert_event_id
fake_reaction_event.key = "🤫"
fake_event_response = Mock(spec=nio.RoomGetEventError)
self.fake_matrix_client.room_get_event.return_value = make_awaitable(
fake_event_response
)
self.fake_matrix_client.room_get_event.return_value = fake_event_response
# 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
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.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.event_id = "some event id"
fake_reaction_event.sender = "@some_other_fake_user:example.com"
fake_reaction_event.source = {
"content": {
"m.relates_to": {
"event_id": fake_alert_event.event_id,
"key": "🤫",
"rel_type": "m.annotation",
}
}
}
fake_reaction_event.reacts_to = fake_alert_event.event_id
fake_reaction_event.key = "🤫"
fake_event_response = Mock(spec=nio.RoomGetEventResponse)
fake_event_response.event = fake_alert_event
self.fake_matrix_client.room_get_event.return_value = make_awaitable(
fake_event_response
)
self.fake_matrix_client.room_get_event.return_value = fake_event_response
# 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
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
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.event_id = "some event id"
fake_reaction_event.sender = "@some_other_fake_user:example.com"
fake_reaction_event.source = {
"content": {
"m.relates_to": {
"event_id": fake_alert_event_id,
"key": "unknown",
"rel_type": "m.annotation",
}
}
}
fake_reaction_event.reacts_to = fake_alert_event_id
fake_reaction_event.key = "unknown"
# 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
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
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.event_id = "some event id"
fake_reaction_event.sender = self.fake_matrix_client.user
fake_reaction_event.source = {
"content": {
"m.relates_to": {
"event_id": fake_alert_event_id,
"key": "unknown",
"rel_type": "m.annotation",
}
}
}
fake_reaction_event.sender = self.fake_matrix_client.user_id
fake_reaction_event.reacts_to = fake_alert_event_id
fake_reaction_event.key = "unknown"
# 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, fake_alert_event_id
)
await self.callbacks.reaction(self.fake_room, fake_reaction_event)
# Check that we attempted to execute the command
fake_command.assert_not_called()
@ -453,25 +415,15 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
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.event_id = "some event id"
fake_reaction_event.sender = "@some_other_fake_user:example.com"
fake_reaction_event.source = {
"content": {
"m.relates_to": {
"event_id": fake_alert_event_id,
"key": "unknown",
"rel_type": "m.annotation",
}
}
}
fake_reaction_event.reacts_to = fake_alert_event_id
fake_reaction_event.key = "unknown"
# 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, fake_alert_event_id
)
await self.callbacks.reaction(self.fake_room, fake_reaction_event)
# Check that we attempted to execute the command
fake_command.assert_not_called()
@ -512,7 +464,7 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
"""Tests the callback for RoomMessageText with the command prefix"""
# Tests that the bot process messages in the room that contain a command
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 = {}
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.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_transactions_dict = {fake_transaction_id: fake_sas}
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.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_transactions_dict = {fake_transaction_id: fake_sas}
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.transaction_id = fake_transaction_id
self.fake_matrix_client.accept_key_verification.return_value = make_awaitable(
Mock(spec=nio.ToDeviceError)
self.fake_matrix_client.accept_key_verification.return_value = Mock(
spec=nio.ToDeviceError
)
self.fake_matrix_client.to_device.return_value = make_awaitable()
fake_sas = Mock()
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.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(
Mock(spec=nio.ToDeviceError)
)
self.fake_matrix_client.to_device.return_value = Mock(spec=nio.ToDeviceError)
fake_sas = Mock()
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.transaction_id = fake_transaction_id
self.fake_matrix_client.confirm_short_auth_string.return_value = (
make_awaitable()
)
fake_sas = Mock()
fake_sas.get_emoji.return_value = [
("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.transaction_id = fake_transaction_id
self.fake_matrix_client.confirm_short_auth_string.return_value = make_awaitable(
Mock(spec=nio.ToDeviceError)
self.fake_matrix_client.confirm_short_auth_string.return_value = Mock(
spec=nio.ToDeviceError
)
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.transaction_id = fake_transaction_id
self.fake_matrix_client.to_device.return_value = make_awaitable()
fake_sas = Mock()
fake_sas.verified_devices = ["HGFEDCBA"]
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.transaction_id = fake_transaction_id
self.fake_matrix_client.to_device.return_value = make_awaitable()
fake_sas = Mock()
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.transaction_id = fake_transaction_id
self.fake_matrix_client.to_device.return_value = make_awaitable()
fake_sas = Mock()
fake_sas.get_mac.side_effect = key_verification_get_mac_raise_protocol_error
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.transaction_id = fake_transaction_id
self.fake_matrix_client.to_device.return_value = make_awaitable(
Mock(spec=nio.ToDeviceError)
)
self.fake_matrix_client.to_device.return_value = Mock(spec=nio.ToDeviceError)
fake_sas = Mock()
fake_transactions_dict = {fake_transaction_id: fake_sas}
@ -821,25 +751,6 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
fake_sas.get_mac.assert_called_once_with()
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__":
unittest.main()

View file

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

View file

@ -21,8 +21,6 @@ from matrix_alertbot.errors import (
SilenceNotFoundError,
)
from tests.utils import make_awaitable
def cache_get_item(key: str) -> str:
return {
@ -84,8 +82,6 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase):
# 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.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.__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_mkdir.assert_called_once_with("data/store")
self.assertEqual("@fakes_user:matrix.example.com", config.user_id)
self.assertEqual("password", config.user_password)
self.assertIsNone(config.user_token)
self.assertIsNone(config.device_id)
self.assertEqual({"@fakes_user:matrix.example.com"}, config.user_ids)
self.assertEqual(1, len(config.accounts))
self.assertEqual("password", config.accounts[0].password)
self.assertIsNone(config.accounts[0].token)
self.assertIsNone(config.accounts[0].device_id)
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(DEFAULT_REACTIONS, config.allowed_reactions)
@ -92,13 +95,24 @@ class ConfigTestCase(unittest.TestCase):
fake_path_exists.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("password", config.user_password)
self.assertIsNone(config.user_token)
self.assertEqual("token.json", config.user_token_file)
self.assertEqual("ABCDEFGHIJ", config.device_id)
self.assertEqual(
{"@fakes_user:matrix.example.com", "@other_user:matrix.domain.tld"},
config.user_ids,
)
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("https://matrix.example.com", config.homeserver_url)
self.assertEqual(["!abcdefgh:matrix.example.com"], config.allowed_rooms)
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 = DummyConfig(config_path)
del config.config_dict["matrix"]["user_id"]
del config.config_dict["matrix"]["accounts"]
with self.assertRaises(RequiredConfigKeyError):
config._parse_config_values()
@ -166,7 +180,7 @@ class ConfigTestCase(unittest.TestCase):
config_path = os.path.join(CONFIG_RESOURCES_DIR, "config.minimal.yml")
config = DummyConfig(config_path)
del config.config_dict["matrix"]["user_password"]
del config.config_dict["matrix"]["accounts"][0]["password"]
with self.assertRaises(RequiredConfigKeyError):
config._parse_config_values()
@ -182,7 +196,7 @@ class ConfigTestCase(unittest.TestCase):
config_path = os.path.join(CONFIG_RESOURCES_DIR, "config.minimal.yml")
config = DummyConfig(config_path)
del config.config_dict["matrix"]["url"]
del config.config_dict["matrix"]["accounts"][0]["url"]
with self.assertRaises(RequiredConfigKeyError):
config._parse_config_values()
@ -279,27 +293,27 @@ class ConfigTestCase(unittest.TestCase):
config_path = os.path.join(CONFIG_RESOURCES_DIR, "config.minimal.yml")
config = DummyConfig(config_path)
config.config_dict["matrix"]["user_id"] = ""
config.config_dict["matrix"]["accounts"][0]["id"] = ""
with self.assertRaises(InvalidConfigError):
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):
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):
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):
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):
config._parse_config_values()
config.config_dict["matrix"]["user_id"] = "@:"
config.config_dict["matrix"]["accounts"][0]["id"] = "@:"
with self.assertRaises(InvalidConfigError):
config._parse_config_values()

View file

@ -6,7 +6,8 @@ import aiohttp.test_utils
import nio
from aiohttp import web
from diskcache import Cache
from nio import LocalProtocolError, RoomSendResponse
from nio.exceptions import LocalProtocolError
from nio.responses import RoomSendResponse
import matrix_alertbot.webhook
from matrix_alertbot.alertmanager import AlertmanagerClient
@ -16,6 +17,7 @@ from matrix_alertbot.errors import (
SilenceExtendError,
SilenceNotFoundError,
)
from matrix_alertbot.matrix import MatrixClientPool
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):
async def get_application(self) -> web.Application:
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_cache = Mock(spec=Cache)
@ -81,7 +85,7 @@ class WebhookApplicationTestCase(aiohttp.test_utils.AioHTTPTestCase):
}
webhook = Webhook(
self.fake_matrix_client,
self.fake_matrix_client_pool,
self.fake_alertmanager_client,
self.fake_cache,
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