Files
core/homeassistant/components/monzo/webhook.py
T

403 lines
15 KiB
Python

"""Webhook support for the Monzo integration."""
import asyncio
from collections.abc import Iterable, Mapping
from datetime import datetime
import logging
from typing import Any
from aiohttp import ClientError
from aiohttp.hdrs import METH_POST
from aiohttp.web import Request
from monzopy import (
AbstractMonzoApi,
AuthorisationExpiredError,
InvalidMonzoAPIResponseError,
)
from homeassistant.components import cloud, webhook
from homeassistant.const import CONF_WEBHOOK_ID, EVENT_CORE_CONFIG_UPDATE
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.exceptions import OAuth2TokenRequestReauthError
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.network import NoURLAvailableError
from .const import (
ATTR_DATA,
CONF_CLOUDHOOK_URL,
CONF_WEBHOOK_URL,
DOMAIN,
EVENT_TRANSACTION_CREATED,
MONZO_WEBHOOK_TRANSACTION_CREATED,
)
from .coordinator import MonzoConfigEntry, MonzoCoordinator
_LOGGER = logging.getLogger(__name__)
WEBHOOK_RETRY_DELAY = 60
def webhook_signal(account_id: str) -> str:
"""Return the dispatcher signal for a Monzo account."""
return f"{DOMAIN}_webhook_{account_id}"
async def async_delete_remote_webhooks(
api: AbstractMonzoApi, account_ids: Iterable[str], webhook_url: str
) -> None:
"""Delete remote Monzo webhooks registered to a Home Assistant URL."""
for account_id in account_ids:
account_webhooks = await api.user_account.list_account_webhooks(account_id)
for account_webhook in account_webhooks:
if account_webhook.url == webhook_url:
await api.user_account.delete_webhook(account_webhook.id)
class MonzoWebhookManager:
"""Manage Home Assistant and Monzo webhooks for a config entry."""
def __init__(
self,
hass: HomeAssistant,
entry: MonzoConfigEntry,
coordinator: MonzoCoordinator,
) -> None:
"""Initialize the webhook manager."""
self.hass = hass
self.entry = entry
self.coordinator = coordinator
self._active = True
self._register_lock = asyncio.Lock()
self._retry_cancel: CALLBACK_TYPE | None = None
self._retrying = False
self._webhook_url: str | None = None
self._known_account_ids: set[str] = set()
@property
def diagnostics_data(self) -> dict[str, bool | int]:
"""Return privacy-safe webhook diagnostics."""
return {
"active": self._active,
"has_registered_webhook_url": self._webhook_url is not None,
"known_account_count": len(self._known_account_ids),
"retry_scheduled": self._retry_cancel is not None,
"retrying": self._retrying,
"uses_cloudhook": self._webhook_url is not None
and self._webhook_url == self.entry.data.get(CONF_CLOUDHOOK_URL),
}
async def async_setup(self) -> None:
"""Set up the local webhook and remote subscriptions."""
webhook.async_register(
self.hass,
DOMAIN,
self.entry.title,
self.entry.data[CONF_WEBHOOK_ID],
self.async_handle_webhook,
allowed_methods=[METH_POST],
)
self.entry.async_on_unload(self._async_unregister_local_webhook)
self.entry.async_on_unload(self._cancel_retry)
self.entry.async_on_unload(
cloud.async_listen_connection_change(
self.hass, self._async_cloud_connection_changed
)
)
self.entry.async_on_unload(
cloud.async_listen_cloudhook_change(
self.hass,
self.entry.data[CONF_WEBHOOK_ID],
self._async_cloudhook_changed,
)
)
self.entry.async_on_unload(
self.hass.bus.async_listen(
EVENT_CORE_CONFIG_UPDATE, self._async_core_config_updated
)
)
self._known_account_ids.update(self.coordinator.data.accounts)
self.entry.async_on_unload(
self.coordinator.async_add_listener(self._async_accounts_updated)
)
await self.async_register_remote_webhooks()
async def async_register_remote_webhooks(
self, _now: datetime | None = None
) -> None:
"""Reconcile remote Monzo webhooks for every account."""
async with self._register_lock:
if not self._active:
return
webhook_url: str | None = None
if cloud.async_active_subscription(self.hass):
webhook_url = self.entry.data.get(CONF_CLOUDHOOK_URL)
if webhook_url is None and cloud.async_is_connected(self.hass):
try:
webhook_url = await cloud.async_get_or_create_cloudhook(
self.hass, self.entry.data[CONF_WEBHOOK_ID]
)
except cloud.CloudNotAvailable:
self._schedule_retry("Unable to create Monzo cloud webhook")
return
self.hass.config_entries.async_update_entry(
self.entry,
data={**self.entry.data, CONF_CLOUDHOOK_URL: webhook_url},
)
if not webhook_url:
try:
webhook_url = webhook.async_generate_url(
self.hass,
self.entry.data[CONF_WEBHOOK_ID],
allow_internal=False,
)
except NoURLAvailableError:
_LOGGER.warning(
"Monzo webhooks require an external Home Assistant URL"
)
await self._async_remove_previous_remote_webhooks()
return
registered_webhook_ids: list[str] = []
try:
await self._async_reconcile_remote_webhooks(
webhook_url, registered_webhook_ids
)
except AuthorisationExpiredError, OAuth2TokenRequestReauthError:
await self._async_rollback_remote_webhooks(registered_webhook_ids)
self.entry.async_start_reauth(self.hass)
return
except (ClientError, InvalidMonzoAPIResponseError, TimeoutError) as err:
await self._async_rollback_remote_webhooks(registered_webhook_ids)
self._schedule_retry("Unable to register Monzo webhooks", err)
return
self._cancel_retry()
self._log_retry_success()
self._webhook_url = webhook_url
if self.entry.data.get(CONF_WEBHOOK_URL) != webhook_url:
self.hass.config_entries.async_update_entry(
self.entry,
data={**self.entry.data, CONF_WEBHOOK_URL: webhook_url},
)
async def _async_reconcile_remote_webhooks(
self, webhook_url: str, registered_webhook_ids: list[str]
) -> None:
"""Ensure each account has exactly one webhook using our URL."""
previous_url = self.entry.data.get(CONF_WEBHOOK_URL)
for account_id in self.coordinator.data.accounts:
account_webhooks = (
await self.coordinator.api.user_account.list_account_webhooks(
account_id
)
)
matching_webhooks = []
for account_webhook in account_webhooks:
if previous_url and account_webhook.url == previous_url:
if previous_url != webhook_url:
await self.coordinator.api.user_account.delete_webhook(
account_webhook.id
)
else:
matching_webhooks.append(account_webhook)
elif account_webhook.url == webhook_url:
matching_webhooks.append(account_webhook)
if matching_webhooks:
for duplicate in matching_webhooks[1:]:
await self.coordinator.api.user_account.delete_webhook(duplicate.id)
else:
registered = await self.coordinator.api.user_account.register_webhook(
account_id, webhook_url
)
registered_webhook_ids.append(registered.id)
async def _async_rollback_remote_webhooks(self, webhook_ids: list[str]) -> None:
"""Roll back webhooks created during an incomplete reconciliation."""
for webhook_id in webhook_ids:
try:
await self.coordinator.api.user_account.delete_webhook(webhook_id)
except (
AuthorisationExpiredError,
ClientError,
InvalidMonzoAPIResponseError,
TimeoutError,
) as err:
_LOGGER.warning(
"Unable to roll back Monzo webhook %s: %s", webhook_id, err
)
async def _async_remove_previous_remote_webhooks(self) -> None:
"""Remove remote webhooks when no callback URL is available."""
if (previous_url := self.entry.data.get(CONF_WEBHOOK_URL)) is None:
self._cancel_retry()
self._retrying = False
return
try:
await async_delete_remote_webhooks(
self.coordinator.api,
self.coordinator.data.accounts,
previous_url,
)
except AuthorisationExpiredError, OAuth2TokenRequestReauthError:
self.entry.async_start_reauth(self.hass)
return
except (ClientError, InvalidMonzoAPIResponseError, TimeoutError) as err:
self._schedule_retry("Unable to remove obsolete Monzo webhooks", err)
return
self._cancel_retry()
self._log_retry_success()
self._webhook_url = None
data = dict(self.entry.data)
data.pop(CONF_WEBHOOK_URL, None)
self.hass.config_entries.async_update_entry(self.entry, data=data)
async def async_unload(self) -> None:
"""Unload the webhook manager while preserving remote subscriptions."""
self._active = False
self._cancel_retry()
async with self._register_lock:
self._async_unregister_local_webhook()
async def async_handle_webhook(
self, hass: HomeAssistant, webhook_id: str, request: Request
) -> None:
"""Handle an incoming Monzo webhook."""
try:
payload = await request.json()
except ValueError:
_LOGGER.warning("Received invalid JSON from a Monzo webhook")
return
if not isinstance(payload, Mapping):
_LOGGER.warning("Received an invalid Monzo webhook payload")
return
event_type = payload.get("type")
if event_type != MONZO_WEBHOOK_TRANSACTION_CREATED:
_LOGGER.debug("Ignoring unsupported Monzo webhook type %s", event_type)
return
transaction = payload.get(ATTR_DATA)
if not isinstance(transaction, dict) or not isinstance(
account_id := transaction.get("account_id"), str
):
_LOGGER.warning("Received an invalid Monzo transaction webhook")
return
if account_id not in self.coordinator.data.accounts:
_LOGGER.warning("Received a Monzo webhook for an unknown account")
return
async_dispatcher_send(
hass,
webhook_signal(account_id),
EVENT_TRANSACTION_CREATED,
transaction,
)
self.entry.async_create_background_task(
self.hass,
self.coordinator.async_request_refresh(),
"refresh Monzo data",
)
@callback
def _async_cloud_connection_changed(
self, state: cloud.CloudConnectionState
) -> None:
"""Reconcile webhooks when Home Assistant Cloud availability changes."""
if (
state is cloud.CloudConnectionState.CLOUD_DISCONNECTED
and cloud.async_active_subscription(self.hass)
):
return
self._async_schedule_registration("update Monzo webhooks")
@callback
def _async_cloudhook_changed(self, cloudhook: dict[str, Any] | None) -> None:
"""Reconcile remote webhooks when our cloudhook changes."""
cloudhook_url = cloudhook.get("cloudhook_url") if cloudhook else None
data = dict(self.entry.data)
if cloudhook_url is None:
data.pop(CONF_CLOUDHOOK_URL, None)
else:
data[CONF_CLOUDHOOK_URL] = cloudhook_url
self.hass.config_entries.async_update_entry(self.entry, data=data)
if self._webhook_url == cloudhook_url:
return
self._async_schedule_registration("update Monzo webhooks")
@callback
def _async_core_config_updated(self, event: Event[dict[str, Any]]) -> None:
"""Reconcile remote webhooks when the external URL changes."""
if "external_url" not in event.data:
return
self._async_schedule_registration("update Monzo webhooks")
@callback
def _async_accounts_updated(self) -> None:
"""Reconcile webhooks when the set of Monzo accounts changes."""
current_account_ids = set(self.coordinator.data.accounts)
new_account_ids = current_account_ids - self._known_account_ids
self._known_account_ids = current_account_ids
if new_account_ids:
self._async_schedule_registration("add webhooks for new Monzo accounts")
@callback
def _async_schedule_registration(self, name: str) -> None:
"""Schedule remote webhook reconciliation."""
self.entry.async_create_task(
self.hass,
self.async_register_remote_webhooks(),
name,
)
def _schedule_retry(self, message: str, err: Exception | None = None) -> None:
"""Schedule another remote webhook registration attempt."""
if self._retry_cancel is not None:
return
if not self._retrying:
if err is None:
_LOGGER.info("%s; retrying in %s seconds", message, WEBHOOK_RETRY_DELAY)
else:
_LOGGER.info(
"%s: %s; retrying in %s seconds",
message,
err,
WEBHOOK_RETRY_DELAY,
)
self._retrying = True
self._retry_cancel = async_call_later(
self.hass, WEBHOOK_RETRY_DELAY, self._async_retry
)
def _log_retry_success(self) -> None:
"""Log when remote webhook management recovers."""
if self._retrying:
_LOGGER.info("Successfully updated Monzo webhooks after retrying")
self._retrying = False
async def _async_retry(self, now: datetime) -> None:
"""Retry remote webhook registration."""
self._retry_cancel = None
await self.async_register_remote_webhooks(now)
def _cancel_retry(self) -> None:
"""Cancel a pending registration retry."""
if self._retry_cancel is not None:
self._retry_cancel()
self._retry_cancel = None
@callback
def _async_unregister_local_webhook(self) -> None:
"""Unregister the local Home Assistant webhook."""
webhook.async_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID])