create matrix bot to send and acknowledge alerts
This commit is contained in:
parent
6ab094acdc
commit
96ee7f068a
17 changed files with 467 additions and 323 deletions
15
.dockerignore
Normal file
15
.dockerignore
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
# PyCharm
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Python virtualenv environment folders
|
||||||
|
env/
|
||||||
|
env3/
|
||||||
|
.env/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
dist/
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,5 +1,6 @@
|
||||||
# PyCharm
|
# PyCharm
|
||||||
.idea/
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
# Python virtualenv environment folders
|
# Python virtualenv environment folders
|
||||||
env/
|
env/
|
||||||
|
|
|
@ -69,3 +69,5 @@ VOLUME ["/data"]
|
||||||
|
|
||||||
# Start the app
|
# Start the app
|
||||||
ENTRYPOINT ["matrix-alertbot", "/data/config.yaml"]
|
ENTRYPOINT ["matrix-alertbot", "/data/config.yaml"]
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import asyncio
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from matrix_alertbot import main
|
from matrix_alertbot import main
|
||||||
|
|
||||||
# Run the main function of the bot
|
# Run the main function of the bot
|
||||||
asyncio.get_event_loop().run_until_complete(main.main())
|
main.main()
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
print("Unable to import matrix_alertbot.main:", e)
|
print("Unable to import matrix_alertbot.main:", e)
|
||||||
|
|
58
matrix_alertbot/alertmanager.py
Normal file
58
matrix_alertbot/alertmanager.py
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import datetime
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
import pytimeparse
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from matrix_alertbot.cache import Cache
|
||||||
|
from matrix_alertbot.errors import AlertNotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
class AlertmanagerClient:
|
||||||
|
def __init__(self, url: str, cache: Cache) -> None:
|
||||||
|
self.api_url = f"{url}/api/v2"
|
||||||
|
self.cache = cache
|
||||||
|
|
||||||
|
def get_alerts(self) -> List[Dict]:
|
||||||
|
response = requests.get(f"{self.api_url}/alert")
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def get_alert_labels(self, fingerprint: str) -> Dict[str, str]:
|
||||||
|
if fingerprint not in self.cache:
|
||||||
|
alerts = self.get_alerts()
|
||||||
|
alert = self._find_alert(alerts, fingerprint)
|
||||||
|
self.cache[fingerprint] = alert["labels"]
|
||||||
|
return self.cache[fingerprint]
|
||||||
|
|
||||||
|
def create_silence(self, fingerprint: str, duration: str, user: str) -> str:
|
||||||
|
labels = self.get_alert_labels(fingerprint)
|
||||||
|
matchers = []
|
||||||
|
for label_name, label_value in labels.items():
|
||||||
|
matchers.append(
|
||||||
|
{"name": label_name, "value": label_value, "isRegex": False}
|
||||||
|
)
|
||||||
|
|
||||||
|
start_time = datetime.datetime.now()
|
||||||
|
duration_seconds = pytimeparse.parse(duration)
|
||||||
|
duration_delta = datetime.timedelta(seconds=duration_seconds)
|
||||||
|
end_time = start_time + duration_delta
|
||||||
|
|
||||||
|
silence = {
|
||||||
|
"matchers": matchers,
|
||||||
|
"startsAt": start_time,
|
||||||
|
"endsAt": end_time,
|
||||||
|
"createdBy": user,
|
||||||
|
"comment": "Acknowledge alert from Matrix",
|
||||||
|
}
|
||||||
|
response = requests.post(f"{self.api_url}/silences", json=silence)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return data["silenceID"]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _find_alert(alerts: List[Dict], fingerprint: str) -> Dict:
|
||||||
|
for alert in alerts:
|
||||||
|
if alert["fingerprint"] == fingerprint:
|
||||||
|
return alert
|
||||||
|
raise AlertNotFoundError(f"Cannot find alert with fingerprint {fingerprint}")
|
|
@ -1,15 +1,21 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
from nio import AsyncClient, MatrixRoom, RoomMessageText
|
from nio import AsyncClient, MatrixRoom, RoomMessageText
|
||||||
|
|
||||||
|
from matrix_alertbot.alertmanager import AlertmanagerClient
|
||||||
|
from matrix_alertbot.cache import Cache
|
||||||
from matrix_alertbot.chat_functions import react_to_event, send_text_to_room
|
from matrix_alertbot.chat_functions import react_to_event, send_text_to_room
|
||||||
from matrix_alertbot.config import Config
|
from matrix_alertbot.config import Config
|
||||||
from matrix_alertbot.storage import Storage
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Command:
|
class Command:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
client: AsyncClient,
|
client: AsyncClient,
|
||||||
store: Storage,
|
cache: Cache,
|
||||||
|
alertmanager: AlertmanagerClient,
|
||||||
config: Config,
|
config: Config,
|
||||||
command: str,
|
command: str,
|
||||||
room: MatrixRoom,
|
room: MatrixRoom,
|
||||||
|
@ -20,7 +26,7 @@ class Command:
|
||||||
Args:
|
Args:
|
||||||
client: The client to communicate to matrix with.
|
client: The client to communicate to matrix with.
|
||||||
|
|
||||||
store: Bot storage.
|
cache: Bot cache.
|
||||||
|
|
||||||
config: Bot configuration parameters.
|
config: Bot configuration parameters.
|
||||||
|
|
||||||
|
@ -31,7 +37,8 @@ class Command:
|
||||||
event: The event describing the command.
|
event: The event describing the command.
|
||||||
"""
|
"""
|
||||||
self.client = client
|
self.client = client
|
||||||
self.store = store
|
self.cache = cache
|
||||||
|
self.alertmanager = alertmanager
|
||||||
self.config = config
|
self.config = config
|
||||||
self.command = command
|
self.command = command
|
||||||
self.room = room
|
self.room = room
|
||||||
|
@ -40,8 +47,8 @@ class Command:
|
||||||
|
|
||||||
async def process(self) -> None:
|
async def process(self) -> None:
|
||||||
"""Process the command"""
|
"""Process the command"""
|
||||||
if self.command.startswith("echo"):
|
if self.command.startswith("ack"):
|
||||||
await self._echo()
|
await self._ack()
|
||||||
elif self.command.startswith("react"):
|
elif self.command.startswith("react"):
|
||||||
await self._react()
|
await self._react()
|
||||||
elif self.command.startswith("help"):
|
elif self.command.startswith("help"):
|
||||||
|
@ -49,15 +56,46 @@ class Command:
|
||||||
else:
|
else:
|
||||||
await self._unknown_command()
|
await self._unknown_command()
|
||||||
|
|
||||||
async def _echo(self) -> None:
|
async def _ack(self) -> None:
|
||||||
"""Echo back the command's arguments"""
|
"""Acknowledge an alert and silence it for a certain duration in Alertmanager"""
|
||||||
response = " ".join(self.args)
|
if len(self.args) > 0:
|
||||||
await send_text_to_room(self.client, self.room.room_id, response)
|
duration = " ".join(self.args)
|
||||||
|
else:
|
||||||
|
duration = "1d"
|
||||||
|
logger.debug(
|
||||||
|
f"Acknowledging alert with fingerprint {self.room.display_name} for a duration of {duration} | "
|
||||||
|
f"{self.room.user_name(self.event.sender)}: {self.event.body}"
|
||||||
|
)
|
||||||
|
|
||||||
|
source_content = self.event.source["content"]
|
||||||
|
try:
|
||||||
|
alert_event_id = source_content["m.relates_to"]["m.in_reply_to"]["event_id"]
|
||||||
|
except KeyError:
|
||||||
|
logger.debug("Unable to find the event ID of the alert")
|
||||||
|
return
|
||||||
|
logger.debug(f"Read alert fingerprint for event {alert_event_id} from cache")
|
||||||
|
alert_fingerprint = self.cache[alert_event_id]
|
||||||
|
logger.debug(
|
||||||
|
f"Create silence for alert with fingerprint {alert_fingerprint} for a duration of {duration}"
|
||||||
|
)
|
||||||
|
silence_id = self.alertmanager.create_silence(
|
||||||
|
alert_fingerprint, duration, self.room.user_name(self.event.sender)
|
||||||
|
)
|
||||||
|
await send_text_to_room(
|
||||||
|
self.client,
|
||||||
|
self.room.room_id,
|
||||||
|
f"Created silence {silence_id} for {duration}",
|
||||||
|
reply_to_event_id=alert_event_id,
|
||||||
|
)
|
||||||
|
|
||||||
async def _react(self) -> None:
|
async def _react(self) -> None:
|
||||||
"""Make the bot react to the command message"""
|
"""Make the bot react to the command message"""
|
||||||
# React with a start emoji
|
# React with a start emoji
|
||||||
reaction = "⭐"
|
reaction = "⭐"
|
||||||
|
logger.debug(
|
||||||
|
f"Reacting with {reaction} to room {self.room.display_name} | "
|
||||||
|
f"{self.room.user_name(self.event.sender)}: {self.event.body}"
|
||||||
|
)
|
||||||
await react_to_event(
|
await react_to_event(
|
||||||
self.client, self.room.room_id, self.event.event_id, reaction
|
self.client, self.room.room_id, self.event.event_id, reaction
|
||||||
)
|
)
|
||||||
|
@ -70,6 +108,10 @@ class Command:
|
||||||
|
|
||||||
async def _show_help(self) -> None:
|
async def _show_help(self) -> None:
|
||||||
"""Show the help text"""
|
"""Show the help text"""
|
||||||
|
logger.debug(
|
||||||
|
f"Displaying help to room {self.room.display_name} | "
|
||||||
|
f"{self.room.user_name(self.event.sender)}: {self.event.body}"
|
||||||
|
)
|
||||||
if not self.args:
|
if not self.args:
|
||||||
text = (
|
text = (
|
||||||
"Hello, I am a bot made with matrix-nio! Use `help commands` to view "
|
"Hello, I am a bot made with matrix-nio! Use `help commands` to view "
|
||||||
|
@ -88,6 +130,10 @@ class Command:
|
||||||
await send_text_to_room(self.client, self.room.room_id, text)
|
await send_text_to_room(self.client, self.room.room_id, text)
|
||||||
|
|
||||||
async def _unknown_command(self) -> None:
|
async def _unknown_command(self) -> None:
|
||||||
|
logger.debug(
|
||||||
|
f"Sending unknown command response to room {self.room.display_name} | "
|
||||||
|
f"{self.room.user_name(self.event.sender)}: {self.event.body}"
|
||||||
|
)
|
||||||
await send_text_to_room(
|
await send_text_to_room(
|
||||||
self.client,
|
self.client,
|
||||||
self.room.room_id,
|
self.room.room_id,
|
||||||
|
|
18
matrix_alertbot/cache.py
Normal file
18
matrix_alertbot/cache.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import diskcache
|
||||||
|
|
||||||
|
|
||||||
|
class Cache:
|
||||||
|
def __init__(self, directory: str, expire: int):
|
||||||
|
self.cache = diskcache.Cache(directory)
|
||||||
|
self.expire = expire
|
||||||
|
|
||||||
|
def __getitem__(self, key: str) -> Any:
|
||||||
|
return self.cache[key]
|
||||||
|
|
||||||
|
def __setitem__(self, key: str, value: Any) -> None:
|
||||||
|
self.cache.set(key, value, expire=self.expire)
|
||||||
|
|
||||||
|
def __contains__(self, key: str) -> bool:
|
||||||
|
return key in self.cache
|
|
@ -11,27 +11,41 @@ from nio import (
|
||||||
UnknownEvent,
|
UnknownEvent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from matrix_alertbot.alertmanager import AlertmanagerClient
|
||||||
from matrix_alertbot.bot_commands import Command
|
from matrix_alertbot.bot_commands import Command
|
||||||
from matrix_alertbot.chat_functions import make_pill, react_to_event, send_text_to_room
|
from matrix_alertbot.cache import Cache
|
||||||
|
from matrix_alertbot.chat_functions import (
|
||||||
|
make_pill,
|
||||||
|
react_to_event,
|
||||||
|
send_text_to_room,
|
||||||
|
strip_fallback,
|
||||||
|
)
|
||||||
from matrix_alertbot.config import Config
|
from matrix_alertbot.config import Config
|
||||||
from matrix_alertbot.message_responses import Message
|
|
||||||
from matrix_alertbot.storage import Storage
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Callbacks:
|
class Callbacks:
|
||||||
def __init__(self, client: AsyncClient, store: Storage, config: Config):
|
def __init__(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
cache: Cache,
|
||||||
|
alertmanager: AlertmanagerClient,
|
||||||
|
config: Config,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
client: nio client used to interact with matrix.
|
client: nio client used to interact with matrix.
|
||||||
|
|
||||||
store: Bot storage.
|
cache: Bot cache.
|
||||||
|
|
||||||
|
alertmanager: Client used to interact with alertmanager.
|
||||||
|
|
||||||
config: Bot configuration parameters.
|
config: Bot configuration parameters.
|
||||||
"""
|
"""
|
||||||
self.client = client
|
self.client = client
|
||||||
self.store = store
|
self.cache = cache
|
||||||
|
self.alertmanager = alertmanager
|
||||||
self.config = config
|
self.config = config
|
||||||
self.command_prefix = config.command_prefix
|
self.command_prefix = config.command_prefix
|
||||||
|
|
||||||
|
@ -44,12 +58,16 @@ class Callbacks:
|
||||||
event: The event defining the message.
|
event: The event defining the message.
|
||||||
"""
|
"""
|
||||||
# Extract the message text
|
# Extract the message text
|
||||||
msg = event.body
|
msg = strip_fallback(event.body)
|
||||||
|
|
||||||
# Ignore messages from ourselves
|
# Ignore messages from ourselves
|
||||||
if event.sender == self.client.user:
|
if event.sender == self.client.user:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Ignore messages from unauthorized room
|
||||||
|
if room.room_id != self.config.room:
|
||||||
|
return
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Bot message received for room {room.display_name} | "
|
f"Bot message received for room {room.display_name} | "
|
||||||
f"{room.user_name(event.sender)}: {msg}"
|
f"{room.user_name(event.sender)}: {msg}"
|
||||||
|
@ -58,23 +76,18 @@ class Callbacks:
|
||||||
# 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)
|
||||||
|
|
||||||
# room.is_group is often a DM, but not always.
|
if not has_command_prefix:
|
||||||
# room.is_group does not allow room aliases
|
logger.debug(
|
||||||
# room.member_count > 2 ... we assume a public room
|
f"Message received without command prefix {self.command_prefix}: Aborting."
|
||||||
# room.member_count <= 2 ... we assume a DM
|
)
|
||||||
if not has_command_prefix and room.member_count > 2:
|
|
||||||
# General message listener
|
|
||||||
message = Message(self.client, self.store, self.config, msg, room, event)
|
|
||||||
await message.process()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Otherwise if this is in a 1-1 with the bot or features a command prefix,
|
|
||||||
# treat it as a command
|
|
||||||
if has_command_prefix:
|
|
||||||
# Remove the command prefix
|
# Remove the command prefix
|
||||||
msg = msg[len(self.command_prefix) :]
|
msg = msg[len(self.command_prefix) :]
|
||||||
|
|
||||||
command = Command(self.client, self.store, self.config, msg, room, event)
|
command = Command(
|
||||||
|
self.client, self.cache, self.alertmanager, self.config, msg, room, event
|
||||||
|
)
|
||||||
await command.process()
|
await command.process()
|
||||||
|
|
||||||
async def invite(self, room: MatrixRoom, event: InviteMemberEvent) -> None:
|
async def invite(self, room: MatrixRoom, event: InviteMemberEvent) -> None:
|
||||||
|
@ -85,6 +98,10 @@ class Callbacks:
|
||||||
|
|
||||||
event: The invite event.
|
event: The invite event.
|
||||||
"""
|
"""
|
||||||
|
# Ignore invites from unauthorized room
|
||||||
|
if room.room_id != self.config.room:
|
||||||
|
return
|
||||||
|
|
||||||
logger.debug(f"Got invite to {room.room_id} from {event.sender}.")
|
logger.debug(f"Got invite to {room.room_id} from {event.sender}.")
|
||||||
|
|
||||||
# Attempt to join 3 times before giving up
|
# Attempt to join 3 times before giving up
|
||||||
|
@ -129,6 +146,14 @@ 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 reactions from unauthorized room
|
||||||
|
if room.room_id != self.config.room:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ignore reactions from ourselves
|
||||||
|
if event.sender == self.client.user:
|
||||||
|
return
|
||||||
|
|
||||||
logger.debug(f"Got reaction to {room.room_id} from {event.sender}.")
|
logger.debug(f"Got reaction to {room.room_id} from {event.sender}.")
|
||||||
|
|
||||||
# Get the original event that was reacted to
|
# Get the original event that was reacted to
|
||||||
|
@ -167,6 +192,10 @@ class Callbacks:
|
||||||
|
|
||||||
event: The encrypted event that we were unable to decrypt.
|
event: The encrypted event that we were unable to decrypt.
|
||||||
"""
|
"""
|
||||||
|
# Ignore events from unauthorized room
|
||||||
|
if room.room_id != self.config.room:
|
||||||
|
return
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to decrypt event '{event.event_id}' in room '{room.room_id}'!"
|
f"Failed to decrypt event '{event.event_id}' in room '{room.room_id}'!"
|
||||||
f"\n\n"
|
f"\n\n"
|
||||||
|
@ -177,16 +206,6 @@ class Callbacks:
|
||||||
f"commands a second time)."
|
f"commands a second time)."
|
||||||
)
|
)
|
||||||
|
|
||||||
red_x_and_lock_emoji = "❌ 🔐"
|
|
||||||
|
|
||||||
# React to the undecryptable event with some emoji
|
|
||||||
await react_to_event(
|
|
||||||
self.client,
|
|
||||||
room.room_id,
|
|
||||||
event.event_id,
|
|
||||||
red_x_and_lock_emoji,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def unknown(self, room: MatrixRoom, event: UnknownEvent) -> None:
|
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.
|
"""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
|
Currently this is used for reaction events, which are not yet part of a released
|
||||||
|
@ -197,6 +216,10 @@ class Callbacks:
|
||||||
|
|
||||||
event: The event itself.
|
event: The event itself.
|
||||||
"""
|
"""
|
||||||
|
# Ignore events from unauthorized room
|
||||||
|
if room.room_id != self.config.room:
|
||||||
|
return
|
||||||
|
|
||||||
if event.type == "m.reaction":
|
if event.type == "m.reaction":
|
||||||
# Get the ID of the event this was a reaction to
|
# Get the ID of the event this was a reaction to
|
||||||
relation_dict = event.source.get("content", {}).get("m.relates_to", {})
|
relation_dict = event.source.get("content", {}).get("m.relates_to", {})
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, Union, Dict
|
from typing import Optional, Union
|
||||||
|
|
||||||
from markdown import markdown
|
|
||||||
from nio import (
|
from nio import (
|
||||||
AsyncClient,
|
AsyncClient,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
|
@ -18,11 +17,11 @@ logger = logging.getLogger(__name__)
|
||||||
async def send_text_to_room(
|
async def send_text_to_room(
|
||||||
client: AsyncClient,
|
client: AsyncClient,
|
||||||
room_id: str,
|
room_id: str,
|
||||||
message: str,
|
plaintext: str,
|
||||||
|
html: str = None,
|
||||||
notice: bool = True,
|
notice: bool = True,
|
||||||
markdown_convert: bool = True,
|
|
||||||
reply_to_event_id: Optional[str] = None,
|
reply_to_event_id: Optional[str] = None,
|
||||||
) -> None:
|
) -> RoomSendResponse:
|
||||||
"""Send text to a matrix room.
|
"""Send text to a matrix room.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
@ -30,14 +29,13 @@ async def send_text_to_room(
|
||||||
|
|
||||||
room_id: The ID of the room to send the message to.
|
room_id: The ID of the room to send the message to.
|
||||||
|
|
||||||
message: The message content.
|
plaintext: The message content.
|
||||||
|
|
||||||
|
html: The message content in HTML format.
|
||||||
|
|
||||||
notice: Whether the message should be sent with an "m.notice" message type
|
notice: Whether the message should be sent with an "m.notice" message type
|
||||||
(will not ping users).
|
(will not ping users).
|
||||||
|
|
||||||
markdown_convert: Whether to convert the message content to markdown.
|
|
||||||
Defaults to true.
|
|
||||||
|
|
||||||
reply_to_event_id: Whether this message is a reply to another event. The event
|
reply_to_event_id: Whether this message is a reply to another event. The event
|
||||||
ID this is message is a reply to.
|
ID this is message is a reply to.
|
||||||
|
|
||||||
|
@ -50,17 +48,17 @@ async def send_text_to_room(
|
||||||
content = {
|
content = {
|
||||||
"msgtype": msgtype,
|
"msgtype": msgtype,
|
||||||
"format": "org.matrix.custom.html",
|
"format": "org.matrix.custom.html",
|
||||||
"body": message,
|
"body": plaintext,
|
||||||
}
|
}
|
||||||
|
|
||||||
if markdown_convert:
|
if html is not None:
|
||||||
content["formatted_body"] = markdown(message)
|
content["formatted_body"] = html
|
||||||
|
|
||||||
if reply_to_event_id:
|
if reply_to_event_id:
|
||||||
content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_event_id}}
|
content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_event_id}}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await client.room_send(
|
return await client.room_send(
|
||||||
room_id,
|
room_id,
|
||||||
"m.room.message",
|
"m.room.message",
|
||||||
content,
|
content,
|
||||||
|
@ -129,28 +127,12 @@ async def react_to_event(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def decryption_failure(
|
def strip_fallback(content: str) -> str:
|
||||||
client: AsyncClient, room: MatrixRoom, event: MegolmEvent
|
index = 0
|
||||||
) -> None:
|
for line in content.splitlines(keepends=True):
|
||||||
"""Callback for when an event fails to decrypt. Inform the user"""
|
if not line.startswith("> "):
|
||||||
logger.error(
|
break
|
||||||
f"Failed to decrypt event '{event.event_id}' in room '{room.room_id}'!"
|
if index == 0:
|
||||||
f"\n\n"
|
index += 1
|
||||||
f"Tip: try using a different device ID in your config file and restart."
|
index += len(line)
|
||||||
f"\n\n"
|
return content[index:]
|
||||||
f"If all else fails, delete your store directory and let the bot recreate "
|
|
||||||
f"it (your reminders will NOT be deleted, but the bot may respond to existing "
|
|
||||||
f"commands a second time)."
|
|
||||||
)
|
|
||||||
|
|
||||||
user_msg = (
|
|
||||||
"Unable to decrypt this message. "
|
|
||||||
"Check whether you've chosen to only encrypt to trusted devices."
|
|
||||||
)
|
|
||||||
|
|
||||||
await send_text_to_room(
|
|
||||||
client,
|
|
||||||
room.room_id,
|
|
||||||
user_msg,
|
|
||||||
reply_to_event_id=event.event_id,
|
|
||||||
)
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ import re
|
||||||
import sys
|
import sys
|
||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
|
import pytimeparse
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from matrix_alertbot.errors import ConfigError
|
from matrix_alertbot.errors import ConfigError
|
||||||
|
@ -70,22 +71,13 @@ class Config:
|
||||||
f"storage.store_path '{self.store_path}' is not a directory"
|
f"storage.store_path '{self.store_path}' is not a directory"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Database setup
|
# Cache setup
|
||||||
database_path = self._get_cfg(["storage", "database"], required=True)
|
self.cache_dir = self._get_cfg(["cache", "directory"], required=True)
|
||||||
|
expire_time = self._get_cfg(["cache", "expire_time"], default="1w")
|
||||||
|
self.cache_expire_time = pytimeparse.parse(expire_time)
|
||||||
|
|
||||||
# Support both SQLite and Postgres backends
|
# Alertmanager client setup
|
||||||
# Determine which one the user intends
|
self.alertmanager_url = self._get_cfg(["alertmanager", "url"], required=True)
|
||||||
sqlite_scheme = "sqlite://"
|
|
||||||
postgres_scheme = "postgres://"
|
|
||||||
if database_path.startswith(sqlite_scheme):
|
|
||||||
self.database = {
|
|
||||||
"type": "sqlite",
|
|
||||||
"connection_string": database_path[len(sqlite_scheme) :],
|
|
||||||
}
|
|
||||||
elif database_path.startswith(postgres_scheme):
|
|
||||||
self.database = {"type": "postgres", "connection_string": database_path}
|
|
||||||
else:
|
|
||||||
raise ConfigError("Invalid connection string for storage.database")
|
|
||||||
|
|
||||||
# Matrix bot account setup
|
# Matrix bot account setup
|
||||||
self.user_id = self._get_cfg(["matrix", "user_id"], required=True)
|
self.user_id = self._get_cfg(["matrix", "user_id"], required=True)
|
||||||
|
@ -101,8 +93,21 @@ class Config:
|
||||||
self.device_name = self._get_cfg(
|
self.device_name = self._get_cfg(
|
||||||
["matrix", "device_name"], default="nio-template"
|
["matrix", "device_name"], default="nio-template"
|
||||||
)
|
)
|
||||||
self.homeserver_url = self._get_cfg(["matrix", "homeserver_url"], required=True)
|
self.homeserver_url = self._get_cfg(["matrix", "url"], required=True)
|
||||||
|
self.room = self._get_cfg(["matrix", "room"], required=True)
|
||||||
|
|
||||||
|
self.address = self._get_cfg(["webhook", "address"], required=False)
|
||||||
|
self.port = self._get_cfg(["webhook", "port"], required=False)
|
||||||
|
self.socket = self._get_cfg(["webhook", "socket"], required=False)
|
||||||
|
if (
|
||||||
|
not (self.address or self.port or self.socket)
|
||||||
|
or (self.socket and self.address and self.port)
|
||||||
|
or (self.address and not self.port)
|
||||||
|
or (not self.address and self.port)
|
||||||
|
):
|
||||||
|
raise ConfigError(
|
||||||
|
"Must supply either webhook.socket or both webhook.address and webhook.port"
|
||||||
|
)
|
||||||
self.command_prefix = self._get_cfg(["command_prefix"], default="!c") + " "
|
self.command_prefix = self._get_cfg(["command_prefix"], default="!c") + " "
|
||||||
|
|
||||||
def _get_cfg(
|
def _get_cfg(
|
||||||
|
|
|
@ -8,5 +8,14 @@ class ConfigError(RuntimeError):
|
||||||
msg: The message displayed to the user on error.
|
msg: The message displayed to the user on error.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, msg: str):
|
pass
|
||||||
super(ConfigError, self).__init__("%s" % (msg,))
|
|
||||||
|
|
||||||
|
class AlertNotFoundError(RuntimeError):
|
||||||
|
"""An error encountered when an alert cannot be found in database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg: The message displayed to the user on error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
|
@ -2,9 +2,10 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
from asyncio import TimeoutError
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
from aiohttp import ClientConnectionError, ServerDisconnectedError
|
from aiohttp import ClientConnectionError, ServerDisconnectedError, web
|
||||||
from nio import (
|
from nio import (
|
||||||
AsyncClient,
|
AsyncClient,
|
||||||
AsyncClientConfig,
|
AsyncClientConfig,
|
||||||
|
@ -16,59 +17,16 @@ from nio import (
|
||||||
UnknownEvent,
|
UnknownEvent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from matrix_alertbot.alertmanager import AlertmanagerClient
|
||||||
|
from matrix_alertbot.cache import Cache
|
||||||
from matrix_alertbot.callbacks import Callbacks
|
from matrix_alertbot.callbacks import Callbacks
|
||||||
from matrix_alertbot.config import Config
|
from matrix_alertbot.config import Config
|
||||||
from matrix_alertbot.storage import Storage
|
from matrix_alertbot.webhook import Webhook
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def main() -> bool:
|
async def start_matrix_client(client: AsyncClient, config: Config) -> bool:
|
||||||
"""The first function that is run when starting the bot"""
|
|
||||||
|
|
||||||
# Read user-configured options from a config file.
|
|
||||||
# A different config file path can be specified as the first command line argument
|
|
||||||
if len(sys.argv) > 1:
|
|
||||||
config_path = sys.argv[1]
|
|
||||||
else:
|
|
||||||
config_path = "config.yaml"
|
|
||||||
|
|
||||||
# Read the parsed config file and create a Config object
|
|
||||||
config = Config(config_path)
|
|
||||||
|
|
||||||
# Configure the database
|
|
||||||
store = Storage(config.database)
|
|
||||||
|
|
||||||
# Configuration options for the AsyncClient
|
|
||||||
client_config = AsyncClientConfig(
|
|
||||||
max_limit_exceeded=0,
|
|
||||||
max_timeouts=0,
|
|
||||||
store_sync_tokens=True,
|
|
||||||
encryption_enabled=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Initialize the matrix client
|
|
||||||
client = AsyncClient(
|
|
||||||
config.homeserver_url,
|
|
||||||
config.user_id,
|
|
||||||
device_id=config.device_id,
|
|
||||||
store_path=config.store_path,
|
|
||||||
config=client_config,
|
|
||||||
)
|
|
||||||
|
|
||||||
if config.user_token:
|
|
||||||
client.access_token = config.user_token
|
|
||||||
client.user_id = config.user_id
|
|
||||||
|
|
||||||
# Set up event callbacks
|
|
||||||
callbacks = Callbacks(client, store, config)
|
|
||||||
client.add_event_callback(callbacks.message, (RoomMessageText,))
|
|
||||||
client.add_event_callback(
|
|
||||||
callbacks.invite_event_filtered_callback, (InviteMemberEvent,)
|
|
||||||
)
|
|
||||||
client.add_event_callback(callbacks.decryption_failure, (MegolmEvent,))
|
|
||||||
client.add_event_callback(callbacks.unknown, (UnknownEvent,))
|
|
||||||
|
|
||||||
# Keep trying to reconnect on failure (with some time in-between)
|
# Keep trying to reconnect on failure (with some time in-between)
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
@ -107,7 +65,7 @@ async def main() -> bool:
|
||||||
logger.info(f"Logged in as {config.user_id}")
|
logger.info(f"Logged in as {config.user_id}")
|
||||||
await client.sync_forever(timeout=30000, full_state=True)
|
await client.sync_forever(timeout=30000, full_state=True)
|
||||||
|
|
||||||
except (ClientConnectionError, ServerDisconnectedError):
|
except (ClientConnectionError, ServerDisconnectedError, TimeoutError):
|
||||||
logger.warning("Unable to connect to homeserver, retrying in 15s...")
|
logger.warning("Unable to connect to homeserver, retrying in 15s...")
|
||||||
|
|
||||||
# Sleep so we don't bombard the server with login requests
|
# Sleep so we don't bombard the server with login requests
|
||||||
|
@ -117,5 +75,64 @@ async def main() -> bool:
|
||||||
await client.close()
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
# Run the main function in an asyncio event loop
|
def main() -> None:
|
||||||
asyncio.get_event_loop().run_until_complete(main())
|
"""The first function that is run when starting the bot"""
|
||||||
|
|
||||||
|
# Read user-configured options from a config file.
|
||||||
|
# A different config file path can be specified as the first command line argument
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
config_path = sys.argv[1]
|
||||||
|
else:
|
||||||
|
config_path = "config.yaml"
|
||||||
|
|
||||||
|
# Read the parsed config file and create a Config object
|
||||||
|
config = Config(config_path)
|
||||||
|
|
||||||
|
# Configure the cache
|
||||||
|
cache = Cache(config.cache_dir, config.cache_expire_time)
|
||||||
|
|
||||||
|
# Configure Alertmanager client
|
||||||
|
alertmanager = AlertmanagerClient(config.alertmanager_url, cache)
|
||||||
|
|
||||||
|
# Configuration options for the AsyncClient
|
||||||
|
client_config = AsyncClientConfig(
|
||||||
|
max_limit_exceeded=0,
|
||||||
|
max_timeouts=0,
|
||||||
|
store_sync_tokens=True,
|
||||||
|
encryption_enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize the matrix client
|
||||||
|
client = AsyncClient(
|
||||||
|
config.homeserver_url,
|
||||||
|
config.user_id,
|
||||||
|
device_id=config.device_id,
|
||||||
|
store_path=config.store_path,
|
||||||
|
config=client_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
if config.user_token:
|
||||||
|
client.access_token = config.user_token
|
||||||
|
client.user_id = config.user_id
|
||||||
|
|
||||||
|
# Set up event callbacks
|
||||||
|
callbacks = Callbacks(client, cache, alertmanager, config)
|
||||||
|
client.add_event_callback(callbacks.message, (RoomMessageText,))
|
||||||
|
client.add_event_callback(
|
||||||
|
callbacks.invite_event_filtered_callback, (InviteMemberEvent,)
|
||||||
|
)
|
||||||
|
client.add_event_callback(callbacks.decryption_failure, (MegolmEvent,))
|
||||||
|
client.add_event_callback(callbacks.unknown, (UnknownEvent,))
|
||||||
|
|
||||||
|
webhook_server = Webhook(client, cache, config)
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
loop.create_task(webhook_server.start())
|
||||||
|
loop.create_task(start_matrix_client(client, config))
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop.run_forever()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(e)
|
||||||
|
finally:
|
||||||
|
loop.run_until_complete(webhook_server.close())
|
||||||
|
|
|
@ -1,52 +0,0 @@
|
||||||
import logging
|
|
||||||
|
|
||||||
from nio import AsyncClient, MatrixRoom, RoomMessageText
|
|
||||||
|
|
||||||
from matrix_alertbot.chat_functions import send_text_to_room
|
|
||||||
from matrix_alertbot.config import Config
|
|
||||||
from matrix_alertbot.storage import Storage
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class Message:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
client: AsyncClient,
|
|
||||||
store: Storage,
|
|
||||||
config: Config,
|
|
||||||
message_content: str,
|
|
||||||
room: MatrixRoom,
|
|
||||||
event: RoomMessageText,
|
|
||||||
):
|
|
||||||
"""Initialize a new Message
|
|
||||||
|
|
||||||
Args:
|
|
||||||
client: nio client used to interact with matrix.
|
|
||||||
|
|
||||||
store: Bot storage.
|
|
||||||
|
|
||||||
config: Bot configuration parameters.
|
|
||||||
|
|
||||||
message_content: The body of the message.
|
|
||||||
|
|
||||||
room: The room the event came from.
|
|
||||||
|
|
||||||
event: The event defining the message.
|
|
||||||
"""
|
|
||||||
self.client = client
|
|
||||||
self.store = store
|
|
||||||
self.config = config
|
|
||||||
self.message_content = message_content
|
|
||||||
self.room = room
|
|
||||||
self.event = event
|
|
||||||
|
|
||||||
async def process(self) -> None:
|
|
||||||
"""Process and possibly respond to the message"""
|
|
||||||
if self.message_content.lower() == "hello world":
|
|
||||||
await self._hello_world()
|
|
||||||
|
|
||||||
async def _hello_world(self) -> None:
|
|
||||||
"""Say hello"""
|
|
||||||
text = "Hello, world!"
|
|
||||||
await send_text_to_room(self.client, self.room.room_id, text)
|
|
|
@ -1,126 +1,70 @@
|
||||||
import logging
|
from __future__ import annotations
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
# The latest migration version of the database.
|
import logging
|
||||||
#
|
from typing import Dict
|
||||||
# Database migrations are applied starting from the number specified in the database's
|
|
||||||
# `migration_version` table + 1 (or from 0 if this table does not yet exist) up until
|
|
||||||
# the version specified here.
|
|
||||||
#
|
|
||||||
# When a migration is performed, the `migration_version` table should be incremented.
|
|
||||||
latest_migration_version = 0
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Storage:
|
class Alert:
|
||||||
def __init__(self, database_config: Dict[str, str]):
|
EMOJIS = {"critical": "🔥", "warning": "⚠️", "resolved": "🥦"}
|
||||||
"""Setup the database.
|
COLORS = {"critical": "dc3545", "warning": "ffc107", "resolved": "33cc33"}
|
||||||
|
|
||||||
Runs an initial setup or migrations depending on whether a database file has already
|
def __init__(
|
||||||
been created.
|
self,
|
||||||
|
id: str,
|
||||||
|
url: str,
|
||||||
|
firing: bool = True,
|
||||||
|
labels: Dict[str, str] = None,
|
||||||
|
annotations: Dict[str, str] = None,
|
||||||
|
):
|
||||||
|
self.id = id
|
||||||
|
self.url = url
|
||||||
|
self.firing = firing
|
||||||
|
|
||||||
Args:
|
if labels is None:
|
||||||
database_config: a dictionary containing the following keys:
|
self.labels = {}
|
||||||
* type: A string, one of "sqlite" or "postgres".
|
|
||||||
* connection_string: A string, featuring a connection string that
|
|
||||||
be fed to each respective db library's `connect` method.
|
|
||||||
"""
|
|
||||||
self.conn = self._get_database_connection(
|
|
||||||
database_config["type"], database_config["connection_string"]
|
|
||||||
)
|
|
||||||
self.cursor = self.conn.cursor()
|
|
||||||
self.db_type = database_config["type"]
|
|
||||||
|
|
||||||
# Try to check the current migration version
|
|
||||||
migration_level = 0
|
|
||||||
try:
|
|
||||||
self._execute("SELECT version FROM migration_version")
|
|
||||||
row = self.cursor.fetchone()
|
|
||||||
migration_level = row[0]
|
|
||||||
except Exception:
|
|
||||||
self._initial_setup()
|
|
||||||
finally:
|
|
||||||
if migration_level < latest_migration_version:
|
|
||||||
self._run_migrations(migration_level)
|
|
||||||
|
|
||||||
logger.info(f"Database initialization of type '{self.db_type}' complete")
|
|
||||||
|
|
||||||
def _get_database_connection(
|
|
||||||
self, database_type: str, connection_string: str
|
|
||||||
) -> Any:
|
|
||||||
"""Creates and returns a connection to the database"""
|
|
||||||
if database_type == "sqlite":
|
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
# Initialize a connection to the database, with autocommit on
|
|
||||||
return sqlite3.connect(connection_string, isolation_level=None)
|
|
||||||
elif database_type == "postgres":
|
|
||||||
import psycopg2
|
|
||||||
|
|
||||||
conn = psycopg2.connect(connection_string)
|
|
||||||
|
|
||||||
# Autocommit on
|
|
||||||
conn.set_isolation_level(0)
|
|
||||||
|
|
||||||
return conn
|
|
||||||
|
|
||||||
def _initial_setup(self) -> None:
|
|
||||||
"""Initial setup of the database"""
|
|
||||||
logger.info("Performing initial database setup...")
|
|
||||||
|
|
||||||
# Set up the migration_version table
|
|
||||||
self._execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE migration_version (
|
|
||||||
version INTEGER PRIMARY KEY
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Initially set the migration version to 0
|
|
||||||
self._execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO migration_version (
|
|
||||||
version
|
|
||||||
) VALUES (?)
|
|
||||||
""",
|
|
||||||
(0,),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set up any other necessary database tables here
|
|
||||||
|
|
||||||
logger.info("Database setup complete")
|
|
||||||
|
|
||||||
def _run_migrations(self, current_migration_version: int) -> None:
|
|
||||||
"""Execute database migrations. Migrates the database to the
|
|
||||||
`latest_migration_version`.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
current_migration_version: The migration version that the database is
|
|
||||||
currently at.
|
|
||||||
"""
|
|
||||||
logger.debug("Checking for necessary database migrations...")
|
|
||||||
|
|
||||||
# if current_migration_version < 1:
|
|
||||||
# logger.info("Migrating the database from v0 to v1...")
|
|
||||||
#
|
|
||||||
# # Add new table, delete old ones, etc.
|
|
||||||
#
|
|
||||||
# # Update the stored migration version
|
|
||||||
# self._execute("UPDATE migration_version SET version = 1")
|
|
||||||
#
|
|
||||||
# logger.info("Database migrated to v1")
|
|
||||||
|
|
||||||
def _execute(self, *args: Any) -> None:
|
|
||||||
"""A wrapper around cursor.execute that transforms placeholder ?'s to %s for postgres.
|
|
||||||
|
|
||||||
This allows for the support of queries that are compatible with both postgres and sqlite.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
args: Arguments passed to cursor.execute.
|
|
||||||
"""
|
|
||||||
if self.db_type == "postgres":
|
|
||||||
self.cursor.execute(args[0].replace("?", "%s"), *args[1:])
|
|
||||||
else:
|
else:
|
||||||
self.cursor.execute(*args)
|
self.labels = labels
|
||||||
|
|
||||||
|
if annotations is None:
|
||||||
|
self.annotations = {}
|
||||||
|
else:
|
||||||
|
self.annotations = annotations
|
||||||
|
|
||||||
|
if self.firing:
|
||||||
|
self.status = self.labels["severity"]
|
||||||
|
else:
|
||||||
|
self.status = "resolved"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(data: Dict) -> Alert:
|
||||||
|
return Alert(
|
||||||
|
id=data["fingerprint"],
|
||||||
|
url=data["generatorURL"],
|
||||||
|
firing=data["status"] == "firing",
|
||||||
|
labels=data["labels"],
|
||||||
|
annotations=data["annotations"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def emoji(self) -> str:
|
||||||
|
return self.EMOJIS[self.status]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def color(self) -> str:
|
||||||
|
return self.COLORS[self.status]
|
||||||
|
|
||||||
|
def plaintext(self) -> str:
|
||||||
|
alertname = self.labels["alertname"]
|
||||||
|
description = self.annotations["description"]
|
||||||
|
return f"[{self.emoji} {self.status.upper()}] {alertname}: {description}"
|
||||||
|
|
||||||
|
def html(self) -> str:
|
||||||
|
alertname = self.labels["alertname"]
|
||||||
|
job = self.labels["job"]
|
||||||
|
description = self.annotations["description"]
|
||||||
|
return (
|
||||||
|
f"<font color='#{self.color}'><b>[{self.emoji} {self.status.upper()}]</b></font> "
|
||||||
|
f"<a href='{self.url}'>{alertname}</a> ({job})<br/>{description}"
|
||||||
|
)
|
||||||
|
|
74
matrix_alertbot/webhook.py
Normal file
74
matrix_alertbot/webhook.py
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import logging
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from aiohttp import web, web_request
|
||||||
|
from nio import AsyncClient, SendRetryError
|
||||||
|
from matrix_alertbot.cache import Cache
|
||||||
|
|
||||||
|
from matrix_alertbot.chat_functions import send_text_to_room
|
||||||
|
from matrix_alertbot.config import Config
|
||||||
|
from matrix_alertbot.storage import Alert
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
routes = web.RouteTableDef()
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post("/alert")
|
||||||
|
async def create_alert(request: web_request.Request) -> web.Response:
|
||||||
|
data = await request.json()
|
||||||
|
logger.info(f"Received alert: {data}")
|
||||||
|
client = request.app["client"]
|
||||||
|
cache = request.app['cache']
|
||||||
|
|
||||||
|
plaintext = ""
|
||||||
|
html = ""
|
||||||
|
for i, alert in enumerate(data["alerts"]):
|
||||||
|
alert = Alert.from_dict(alert)
|
||||||
|
|
||||||
|
if i != 0:
|
||||||
|
plaintext += "\n"
|
||||||
|
html += "<br/>\n"
|
||||||
|
plaintext += alert.plaintext()
|
||||||
|
html += alert.html()
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = await send_text_to_room(client, request.app["room_id"], plaintext, html)
|
||||||
|
except SendRetryError as e:
|
||||||
|
logger.error(e)
|
||||||
|
return web.Response(status=500)
|
||||||
|
|
||||||
|
cache[event.event_id] = tuple(alert["fingerprint"] for alert in data["alerts"])
|
||||||
|
|
||||||
|
return web.Response(status=200)
|
||||||
|
|
||||||
|
|
||||||
|
class Webhook:
|
||||||
|
def __init__(self, client: AsyncClient, cache: Cache, config: Config) -> None:
|
||||||
|
self.app = web.Application(logger=logger)
|
||||||
|
self.app["client"] = client
|
||||||
|
self.app["room_id"] = config.room
|
||||||
|
self.app["cache"] = cache
|
||||||
|
self.app.add_routes(routes)
|
||||||
|
self.runner = web.AppRunner(self.app)
|
||||||
|
|
||||||
|
self.config = config
|
||||||
|
self.address = config.address
|
||||||
|
self.port = config.port
|
||||||
|
self.socket = config.socket
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
await self.runner.setup()
|
||||||
|
|
||||||
|
site: web.BaseSite
|
||||||
|
if self.address and self.port:
|
||||||
|
site = web.TCPSite(self.runner, self.address, self.port)
|
||||||
|
logger.info(f"Listenning on {self.address}:{self.port}")
|
||||||
|
elif self.socket:
|
||||||
|
site = web.UnixSite(self.runner, self.socket)
|
||||||
|
logger.info(f"Listenning on unix://{self.socket}")
|
||||||
|
|
||||||
|
await site.start()
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
await self.runner.cleanup()
|
1
mypy.ini
1
mypy.ini
|
@ -2,3 +2,4 @@
|
||||||
ignore_missing_imports = True
|
ignore_missing_imports = True
|
||||||
disallow_untyped_defs = True
|
disallow_untyped_defs = True
|
||||||
disallow_untyped_calls = True
|
disallow_untyped_calls = True
|
||||||
|
plugins = sqlalchemy.ext.mypy.plugin
|
||||||
|
|
|
@ -3,8 +3,9 @@ from unittest.mock import Mock
|
||||||
|
|
||||||
import nio
|
import nio
|
||||||
|
|
||||||
|
from matrix_alertbot.alertmanager import AlertmanagerClient
|
||||||
|
from matrix_alertbot.cache import Cache
|
||||||
from matrix_alertbot.callbacks import Callbacks
|
from matrix_alertbot.callbacks import Callbacks
|
||||||
from matrix_alertbot.storage import Storage
|
|
||||||
|
|
||||||
from tests.utils import make_awaitable, run_coroutine
|
from tests.utils import make_awaitable, run_coroutine
|
||||||
|
|
||||||
|
@ -15,13 +16,14 @@ class CallbacksTestCase(unittest.TestCase):
|
||||||
self.fake_client = Mock(spec=nio.AsyncClient)
|
self.fake_client = Mock(spec=nio.AsyncClient)
|
||||||
self.fake_client.user = "@fake_user:example.com"
|
self.fake_client.user = "@fake_user:example.com"
|
||||||
|
|
||||||
self.fake_storage = Mock(spec=Storage)
|
self.fake_cache = Mock(spec=Cache)
|
||||||
|
self.fake_alertmanager = Mock(spec=AlertmanagerClient)
|
||||||
|
|
||||||
# We don't spec config, as it doesn't currently have well defined attributes
|
# We don't spec config, as it doesn't currently have well defined attributes
|
||||||
self.fake_config = Mock()
|
self.fake_config = Mock()
|
||||||
|
|
||||||
self.callbacks = Callbacks(
|
self.callbacks = Callbacks(
|
||||||
self.fake_client, self.fake_storage, self.fake_config
|
self.fake_client, self.fake_cache, self.fake_alertmanager, self.fake_config
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_invite(self) -> None:
|
def test_invite(self) -> None:
|
||||||
|
|
Loading…
Reference in a new issue