matrix-alertbot/matrix_alertbot/matrix.py

399 lines
15 KiB
Python
Raw Normal View History

2024-01-22 11:35:13 +01:00
from __future__ import annotations
import asyncio
import json
import logging
import os
import random
2024-04-20 17:16:59 +02:00
from asyncio.exceptions import TimeoutError
2024-01-22 11:35:13 +01:00
from typing import Dict, List, Optional, Tuple
from aiohttp import ClientConnectionError, ServerDisconnectedError
from diskcache import Cache
from nio import RoomPreset, RoomVisibility
2024-01-22 11:35:13 +01:00
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, LocalTransportError
from nio.responses import (
JoinedMembersError,
LoginError,
ProfileGetDisplayNameError,
RoomCreateError,
WhoamiError,
)
2024-01-22 11:35:13 +01:00
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]
self.dm_rooms = {}
def unactive_user_ids(self):
active_user_id = self.account.id
user_ids = []
for account in self._accounts:
user_id = account.id
if active_user_id is not user_id:
user_ids.append(user_id)
return user_ids
2024-01-22 11:35:13 +01:00
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
logger.info(
f"Bot {account.id} | Checking if matrix client is connected"
)
2024-01-22 11:35:13 +01:00
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 find_existing_dm_rooms(
self, account: AccountConfig, matrix_client: AsyncClient, config: Config
) -> Dict[str, str]:
unactive_user_ids = self.unactive_user_ids()
dm_rooms = {}
for room_id in matrix_client.rooms:
if room_id in config.allowed_rooms:
continue
room_members_response = await matrix_client.joined_members(room_id)
if isinstance(room_members_response, JoinedMembersError):
logger.warning(
f"Bot {account.id} | Cannot get joined members for room {room_id}"
)
continue
room_members = []
for room_member in room_members_response.members:
room_members.append(room_member.user_id)
logger.info(
f"Bot {account.id} | Found {len(room_members)} room members in {room_id}"
)
if len(room_members) > len(self._matrix_clients) + 1:
continue
all_accounts_in_room = True
for user_id in unactive_user_ids:
if user_id not in room_members:
all_accounts_in_room = False
if not all_accounts_in_room:
continue
logger.info(f"Bot {account.id} | All matrix clients are in {room_id}")
for room_member in room_members:
if room_member not in config.dm_users.inverse:
continue
if room_member in dm_rooms:
logger.warning(
f"Bot {account.id} | Found more than one direct room with user {room_member}: {room_id}"
)
continue
dm_rooms[room_member] = room_id
logger.info(
f"Bot {account.id} | Found direct room {room_id} with user {room_member}"
)
return dm_rooms
async def create_dm_rooms(
self, account: AccountConfig, matrix_client: AsyncClient, config: Config
) -> None:
async with self._lock:
if matrix_client is self.matrix_client:
unactive_user_ids = self.unactive_user_ids()
self.dm_rooms = await self.find_existing_dm_rooms(
account=account, matrix_client=matrix_client, config=config
)
for user_id in config.dm_users.inverse:
if user_id in self.dm_rooms:
continue
display_name_response = await matrix_client.get_displayname(user_id)
if isinstance(display_name_response, ProfileGetDisplayNameError):
error = display_name_response.message
logger.warning(
f"Bot {account.id} | Cannot fetch user name for {user_id}: {error}"
)
continue
user_name = display_name_response.displayname
if config.dm_room_title:
room_title = config.dm_room_title.format(user=user_name)
else:
room_title = None
logger.info(
f"Bot {account.id} | Creating direct room with user {user_id}"
)
invitations = unactive_user_ids + [user_id]
room_user_ids = invitations + [account.id]
power_levels = {"users": dict.fromkeys(room_user_ids, 100)}
create_room_response = await matrix_client.room_create(
visibility=RoomVisibility.private,
name=room_title,
invite=invitations,
is_direct=True,
preset=RoomPreset.private_chat,
power_level_override=power_levels,
)
if isinstance(create_room_response, RoomCreateError):
error = create_room_response.message
logger.warning(
f"Bot {account.id} | Cannot create direct room with user {user_id}: {error}"
)
continue
dm_room_id = create_room_response.room_id
if dm_room_id is None:
logger.warning(
f"Bot {account.id} | Cannot find direct room id with user {user_id}"
)
continue
logger.info(
f"Bot {account.id} | Created direct room {dm_room_id} with user {user_id}"
)
self.dm_rooms[user_id] = dm_room_id
2024-01-22 11:35:13 +01:00
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
2024-01-22 11:50:09 +01:00
if isinstance(login_response, LoginError):
2024-01-22 11:35:13 +01:00
logger.error(
f"Bot {account.id} | Failed to login: {login_response.message}"
)
return False
except LocalProtocolError as error:
2024-01-22 11:35:13 +01:00
# 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",
error,
2024-01-22 11:35:13 +01:00
)
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(timeout=30000, full_state=True)
await self.create_dm_rooms(
account=account, matrix_client=matrix_client, config=config
)
2024-01-22 11:35:13 +01:00
await matrix_client.sync_forever(timeout=30000, full_state=True)
except (
ClientConnectionError,
LocalTransportError,
ServerDisconnectedError,
TimeoutError,
):
2024-01-22 11:35:13 +01:00
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()