mirror of
https://github.com/home-assistant/core.git
synced 2026-09-06 05:22:44 +01:00
403 lines
15 KiB
Python
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])
|