matrix-alertbot/matrix_alertbot/command.py

360 lines
12 KiB
Python
Raw Normal View History

2024-01-22 11:35:13 +01:00
from __future__ import annotations
import logging
import random
2024-01-22 11:35:13 +01:00
from typing import Optional, Tuple, cast
import pytimeparse2
2022-07-06 00:54:13 +02:00
from diskcache import Cache
2024-01-22 11:35:13 +01:00
from nio.client import AsyncClient
from nio.rooms import MatrixRoom
2019-09-25 14:26:29 +02:00
from matrix_alertbot.alertmanager import AlertmanagerClient
2022-07-10 14:06:36 +02:00
from matrix_alertbot.chat_functions import send_text_to_room
2022-06-13 20:55:01 +02:00
from matrix_alertbot.config import Config
from matrix_alertbot.errors import (
AlertmanagerError,
AlertNotFoundError,
SilenceExpiredError,
SilenceNotFoundError,
)
logger = logging.getLogger(__name__)
2021-01-10 04:30:07 +01:00
class BaseCommand:
2021-01-10 04:30:07 +01:00
def __init__(
self,
2022-08-08 00:28:36 +02:00
matrix_client: AsyncClient,
cache: Cache,
2022-08-08 00:28:36 +02:00
alertmanager_client: AlertmanagerClient,
2021-01-10 04:30:07 +01:00
config: Config,
room: MatrixRoom,
2022-07-10 14:06:36 +02:00
sender: str,
event_id: str,
2024-01-22 11:35:13 +01:00
args: Tuple[str, ...] = (),
) -> None:
"""A command made by a user.
2019-09-25 14:26:29 +02:00
Args:
client: The client to communicate with Matrix.
2019-09-25 14:26:29 +02:00
cache: Bot cache.
2019-09-25 14:26:29 +02:00
alertmanager: The client to communicate with Alertmanager.
config: Bot configuration parameters.
cmd: The command and arguments.
2019-09-25 14:26:29 +02:00
room: The room the command was sent in.
2019-09-25 14:26:29 +02:00
sender: The sender of the event
event_id: The ID of the event describing the command.
2019-09-25 14:26:29 +02:00
"""
2022-08-08 00:28:36 +02:00
self.matrix_client = matrix_client
self.cache = cache
2022-08-08 00:28:36 +02:00
self.alertmanager_client = alertmanager_client
2019-10-26 01:40:05 +02:00
self.config = config
2019-09-25 14:26:29 +02:00
self.room = room
2022-07-10 14:06:36 +02:00
self.sender = sender
self.event_id = event_id
2019-09-25 14:26:29 +02:00
if args is not None:
self.args = args
else:
self.args = ()
2022-06-14 23:37:54 +02:00
async def process(self) -> None:
raise NotImplementedError
2019-09-25 14:26:29 +02:00
class BaseAlertCommand(BaseCommand):
def __init__(
self,
client: AsyncClient,
cache: Cache,
alertmanager: AlertmanagerClient,
config: Config,
room: MatrixRoom,
sender: str,
event_id: str,
reacted_to_event_id: str,
2024-01-22 11:35:13 +01:00
args: Tuple[str, ...] = (),
) -> None:
super().__init__(
client, cache, alertmanager, config, room, sender, event_id, args
)
self.reacted_to_event_id = reacted_to_event_id
class AckAlertCommand(BaseAlertCommand):
async def process(self) -> None:
"""Acknowledge an alert and silence it for a certain duration in Alertmanager"""
durations = self.args
2022-07-10 02:40:04 +02:00
if len(durations) > 0:
duration = " ".join(durations)
logger.debug(f"Receiving a command to create a silence for {duration}.")
2024-01-22 11:35:13 +01:00
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(
2022-08-08 00:28:36 +02:00
self.matrix_client,
self.room.room_id,
f"I tried really hard, but I can't convert the duration '{duration}' to a number of seconds.",
)
return
elif duration_seconds < 0:
2022-07-28 17:39:47 +02:00
logger.error(
f"Unable to create silence: Duration must be positive, got '{duration}'"
)
await send_text_to_room(
2022-08-08 00:28:36 +02:00
self.matrix_client,
self.room.room_id,
"I can't create a silence with a negative duration!",
)
return
else:
duration_seconds = None
logger.debug(
"Receiving a command to create a silence for an indefinite period"
)
logger.debug(
f"Reading alert fingerprint for event {self.reacted_to_event_id} from cache"
)
try:
2024-01-22 11:35:13 +01:00
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
2022-07-10 15:11:25 +02:00
2024-01-22 11:35:13 +01:00
sender_user_name = self.room.user_name(self.sender)
if sender_user_name is None:
sender_user_name = self.sender
try:
2022-08-08 01:44:08 +02:00
silence_id = await self.alertmanager_client.create_or_update_silence(
alert_fingerprint,
2024-01-22 11:35:13 +01:00
sender_user_name,
2022-08-08 01:44:08 +02:00
duration_seconds,
2022-08-08 12:38:09 +02:00
force=True,
)
except AlertNotFoundError as e:
logger.warning(f"Unable to create silence: {e}")
await send_text_to_room(
2022-08-08 00:28:36 +02:00
self.matrix_client,
self.room.room_id,
2022-07-28 10:35:11 +02:00
f"Sorry, I couldn't create silence for alert with fingerprint {alert_fingerprint}: {e}",
)
return
except AlertmanagerError as e:
logger.exception(f"Unable to create silence: {e}", exc_info=e)
2022-07-11 23:43:27 +02:00
await send_text_to_room(
2022-08-08 00:28:36 +02:00
self.matrix_client,
2022-07-11 23:43:27 +02:00
self.room.room_id,
2022-07-28 10:35:11 +02:00
f"Sorry, I couldn't create silence for alert with fingerprint {alert_fingerprint} "
f"because something went wrong with Alertmanager: {e}",
2022-07-11 23:43:27 +02:00
)
return
2022-08-08 00:28:36 +02:00
self.cache.set(self.event_id, alert_fingerprint, expire=duration_seconds)
await send_text_to_room(
2022-08-08 00:28:36 +02:00
self.matrix_client,
self.room.room_id,
f"Created silence with ID {silence_id}.",
)
2022-07-06 00:54:13 +02:00
class UnackAlertCommand(BaseAlertCommand):
async def process(self) -> None:
2022-07-06 00:54:13 +02:00
"""Delete an alert's acknowledgement of an alert and remove corresponding silence in Alertmanager"""
2022-07-10 14:06:36 +02:00
logger.debug("Receiving a command to delete a silence")
logger.debug(
f"Reading alert fingerprint for event {self.reacted_to_event_id} from cache."
)
try:
2024-01-22 11:35:13 +01:00
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
logger.debug(f"Found alert fingerprint {alert_fingerprint} in cache.")
2022-07-06 00:54:13 +02:00
logger.debug(
f"Reading silence ID for alert fingerprint {alert_fingerprint} from cache."
)
try:
2024-01-22 11:35:13 +01:00
silence_id = cast(str, self.cache[alert_fingerprint])
except KeyError:
logger.error(
f"Cannot find silence for alert fingerprint {alert_fingerprint} in cache"
)
return
logger.debug(f"Found silence ID {silence_id} in cache.")
2022-07-10 15:11:25 +02:00
logger.debug(
f"Deleting silence with ID {silence_id} for alert with fingerprint {alert_fingerprint}"
)
2022-07-10 15:11:25 +02:00
try:
2022-08-08 00:28:36 +02:00
await self.alertmanager_client.delete_silence(silence_id)
except (SilenceNotFoundError, SilenceExpiredError) as e:
logger.error(f"Unable to delete silence: {e}")
await send_text_to_room(
2022-08-08 00:28:36 +02:00
self.matrix_client,
self.room.room_id,
2022-07-28 10:35:11 +02:00
f"Sorry, I couldn't remove silence for alert with fingerprint {alert_fingerprint}: {e}",
)
return
except AlertmanagerError as e:
logger.exception(f"Unable to delete silence: {e}", exc_info=e)
2022-07-11 23:43:27 +02:00
await send_text_to_room(
2022-08-08 00:28:36 +02:00
self.matrix_client,
2022-07-11 23:43:27 +02:00
self.room.room_id,
2022-07-28 10:35:11 +02:00
f"Sorry, I couldn't remove silence for alert with fingerprint {alert_fingerprint} "
f"because something went wrong with Alertmanager: {e}",
2022-07-11 23:43:27 +02:00
)
return
self.cache.delete(alert_fingerprint)
await send_text_to_room(
2022-08-08 00:28:36 +02:00
self.matrix_client,
self.room.room_id,
f"Removed silence with ID {silence_id}.",
)
2019-09-25 14:26:29 +02:00
class HelpCommand(BaseCommand):
async def process(self) -> None:
2019-09-25 14:26:29 +02:00
"""Show the help text"""
2022-07-10 14:06:36 +02:00
logger.debug(f"Displaying help to room {self.room.display_name}")
2024-04-18 11:58:51 +02:00
if len(self.args) == 0:
2020-08-10 00:02:07 +02:00
text = (
2024-04-18 11:58:51 +02:00
"Hello, I am a bot made with matrix-nio! Use 'help commands' to view "
2020-08-10 00:02:07 +02:00
"available commands."
)
2019-09-25 14:26:29 +02:00
else:
2024-04-18 11:58:51 +02:00
topic = self.args[0]
if topic == "commands":
reactions = " ".join(
sorted(self.config.allowed_reactions - self.config.insult_reactions)
)
text = (
"Here is the list of available commands:\n"
"- help: Display this help message.\n"
"- ack: Create a silence for the alert that is replied to.\n"
"- unack: Remove a silence for the alert that is replied to.\n\n"
"You can also react with an emoji to an alert to create a silence. "
"Removing a reaction will remove the silence.\n"
f"Here is the list of allowed emoji to trigger a silence: {reactions}\n"
)
else:
text = (
"I'm sorry, I don't know much about this topic. "
"You can type 'help commands' to view a list of available commands."
)
await send_text_to_room(
self.matrix_client, self.room.room_id, text, notice=False
)
2019-09-25 14:26:29 +02:00
class AngryUserCommand(BaseCommand):
async def process(self) -> None:
"""React to an insult from the user"""
sender_user_name = self.room.user_name(self.sender)
if sender_user_name is None:
sender_user_name = self.sender
replies = [
"You seem upset 😕 Take a deep breath 😌 and a cup of coffee ☕",
"Don't shoot the messenger! 😰",
"You're doing just fine, you're trying your best. If no one ever told you, it's all gonna be okay! 🎶",
]
random.shuffle(replies)
reply = replies.pop()
await send_text_to_room(
self.matrix_client,
self.room.room_id,
2024-04-18 10:33:55 +02:00
plaintext=f"{sender_user_name} {reply}",
html=f'<a href="https://matrix.to/#/{self.sender}">{sender_user_name}</a> {reply}',
notice=False,
)
class UnknownCommand(BaseCommand):
async def process(self) -> None:
logger.debug(
2022-07-10 14:06:36 +02:00
f"Sending unknown command response to room {self.room.display_name}"
)
2019-09-25 14:26:29 +02:00
await send_text_to_room(
2022-08-08 00:28:36 +02:00
self.matrix_client,
2019-09-25 14:26:29 +02:00
self.room.room_id,
"Unknown command. Try the 'help' command for more information.",
2019-09-25 14:26:29 +02:00
)
class CommandFactory:
@staticmethod
def create(
cmd: str,
client: AsyncClient,
cache: Cache,
alertmanager: AlertmanagerClient,
config: Config,
room: MatrixRoom,
sender: str,
event_id: str,
reacted_to_event_id: Optional[str] = None,
) -> BaseCommand:
args = tuple(cmd.split()[1:])
if cmd.startswith("ack"):
2022-07-16 23:20:25 +02:00
if reacted_to_event_id is None:
raise TypeError("Alert command must be in reply to an alert event.")
return AckAlertCommand(
client,
cache,
alertmanager,
config,
room,
sender,
event_id,
reacted_to_event_id,
args,
)
elif cmd.startswith("unack") or cmd.startswith("nack"):
2022-07-16 23:20:25 +02:00
if reacted_to_event_id is None:
raise TypeError("Alert command must be in reply to an alert event.")
return UnackAlertCommand(
client,
cache,
alertmanager,
config,
room,
sender,
event_id,
reacted_to_event_id,
args,
)
elif cmd.startswith("help"):
return HelpCommand(
client, cache, alertmanager, config, room, sender, event_id, args
)
else:
return UnknownCommand(
client, cache, alertmanager, config, room, sender, event_id, args
)