mirror of
https://github.com/home-assistant/core.git
synced 2026-07-02 12:17:30 +01:00
62aa79a304
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
469 lines
15 KiB
Python
469 lines
15 KiB
Python
"""Define services for the Mastodon integration."""
|
|
|
|
from datetime import timedelta
|
|
from enum import StrEnum
|
|
from functools import partial
|
|
from math import isfinite
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from mastodon import Mastodon
|
|
from mastodon.Mastodon import (
|
|
Account,
|
|
MastodonAPIError,
|
|
MastodonNotFoundError,
|
|
MastodonUnauthorizedError,
|
|
MediaAttachment,
|
|
)
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.components import camera, image
|
|
from homeassistant.components.media_source import async_resolve_media
|
|
from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_LOCKED, ATTR_NAME
|
|
from homeassistant.core import (
|
|
HomeAssistant,
|
|
ServiceCall,
|
|
ServiceResponse,
|
|
SupportsResponse,
|
|
callback,
|
|
)
|
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
|
from homeassistant.helpers import config_validation as cv, service
|
|
from homeassistant.helpers.selector import MediaSelector
|
|
|
|
from .const import (
|
|
ATTR_ACCOUNT_NAME,
|
|
ATTR_ATTRIBUTION_DOMAINS,
|
|
ATTR_AVATAR,
|
|
ATTR_AVATAR_MIME_TYPE,
|
|
ATTR_BOT,
|
|
ATTR_CONTENT_WARNING,
|
|
ATTR_DELETE_AVATAR,
|
|
ATTR_DELETE_HEADER,
|
|
ATTR_DISCOVERABLE,
|
|
ATTR_DISPLAY_NAME,
|
|
ATTR_DURATION,
|
|
ATTR_FIELDS,
|
|
ATTR_HEADER,
|
|
ATTR_HEADER_MIME_TYPE,
|
|
ATTR_HIDE_NOTIFICATIONS,
|
|
ATTR_IDEMPOTENCY_KEY,
|
|
ATTR_LANGUAGE,
|
|
ATTR_MEDIA,
|
|
ATTR_MEDIA_DESCRIPTION,
|
|
ATTR_MEDIA_WARNING,
|
|
ATTR_NOTE,
|
|
ATTR_QUOTE_APPROVAL_POLICY,
|
|
ATTR_STATUS,
|
|
ATTR_VALUE,
|
|
ATTR_VISIBILITY,
|
|
DOMAIN,
|
|
LOGGER,
|
|
)
|
|
from .coordinator import MastodonConfigEntry
|
|
from .utils import get_media_type
|
|
|
|
MAX_DURATION_SECONDS = 315360000 # 10 years
|
|
|
|
|
|
class StatusVisibility(StrEnum):
|
|
"""StatusVisibility model."""
|
|
|
|
PUBLIC = "public"
|
|
UNLISTED = "unlisted"
|
|
PRIVATE = "private"
|
|
DIRECT = "direct"
|
|
|
|
|
|
class QuoteApprovalPolicy(StrEnum):
|
|
"""QuoteApprovalPolicy model."""
|
|
|
|
PUBLIC = "public"
|
|
FOLLOWERS = "followers"
|
|
NOBODY = "nobody"
|
|
|
|
|
|
SERVICE_GET_ACCOUNT = "get_account"
|
|
SERVICE_GET_ACCOUNT_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required(ATTR_CONFIG_ENTRY_ID): str,
|
|
vol.Required(ATTR_ACCOUNT_NAME): str,
|
|
}
|
|
)
|
|
SERVICE_MUTE_ACCOUNT = "mute_account"
|
|
SERVICE_MUTE_ACCOUNT_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required(ATTR_CONFIG_ENTRY_ID): str,
|
|
vol.Required(ATTR_ACCOUNT_NAME): str,
|
|
vol.Optional(ATTR_DURATION): vol.All(
|
|
cv.time_period,
|
|
vol.Range(
|
|
min=timedelta(seconds=1), max=timedelta(seconds=MAX_DURATION_SECONDS)
|
|
),
|
|
),
|
|
vol.Optional(ATTR_HIDE_NOTIFICATIONS, default=True): bool,
|
|
}
|
|
)
|
|
SERVICE_UNMUTE_ACCOUNT = "unmute_account"
|
|
SERVICE_UNMUTE_ACCOUNT_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required(ATTR_CONFIG_ENTRY_ID): str,
|
|
vol.Required(ATTR_ACCOUNT_NAME): str,
|
|
}
|
|
)
|
|
SERVICE_POST = "post"
|
|
SERVICE_POST_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required(ATTR_CONFIG_ENTRY_ID): str,
|
|
vol.Required(ATTR_STATUS): str,
|
|
vol.Optional(ATTR_VISIBILITY): vol.In([x.lower() for x in StatusVisibility]),
|
|
vol.Optional(ATTR_QUOTE_APPROVAL_POLICY): vol.In(
|
|
[x.lower() for x in QuoteApprovalPolicy]
|
|
),
|
|
vol.Optional(ATTR_IDEMPOTENCY_KEY): str,
|
|
vol.Optional(ATTR_CONTENT_WARNING): str,
|
|
vol.Optional(ATTR_LANGUAGE): str,
|
|
vol.Optional(ATTR_MEDIA): str,
|
|
vol.Optional(ATTR_MEDIA_DESCRIPTION): str,
|
|
vol.Optional(ATTR_MEDIA_WARNING): bool,
|
|
}
|
|
)
|
|
|
|
SERVICE_UPDATE_PROFILE = "update_profile"
|
|
SERVICE_UPDATE_PROFILE_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required(ATTR_CONFIG_ENTRY_ID): str,
|
|
vol.Optional(ATTR_DISPLAY_NAME): str,
|
|
vol.Optional(ATTR_NOTE): str,
|
|
vol.Exclusive(ATTR_AVATAR, ATTR_AVATAR): MediaSelector({"accept": ["image/*"]}),
|
|
vol.Exclusive(ATTR_DELETE_AVATAR, ATTR_AVATAR): cv.boolean,
|
|
vol.Exclusive(ATTR_HEADER, ATTR_HEADER): MediaSelector({"accept": ["image/*"]}),
|
|
vol.Exclusive(ATTR_DELETE_HEADER, ATTR_HEADER): cv.boolean,
|
|
vol.Optional(ATTR_LOCKED): bool,
|
|
vol.Optional(ATTR_BOT): bool,
|
|
vol.Optional(ATTR_DISCOVERABLE): bool,
|
|
vol.Optional(ATTR_FIELDS): vol.All(
|
|
cv.ensure_list, vol.Length(max=4), [dict[str, str]]
|
|
),
|
|
vol.Optional(ATTR_ATTRIBUTION_DOMAINS): vol.All(cv.ensure_list, [str]),
|
|
}
|
|
)
|
|
|
|
|
|
@callback
|
|
def async_setup_services(hass: HomeAssistant) -> None:
|
|
"""Set up the services for the Mastodon integration."""
|
|
hass.services.async_register(
|
|
DOMAIN,
|
|
SERVICE_GET_ACCOUNT,
|
|
_async_get_account,
|
|
schema=SERVICE_GET_ACCOUNT_SCHEMA,
|
|
supports_response=SupportsResponse.ONLY,
|
|
)
|
|
hass.services.async_register(
|
|
DOMAIN,
|
|
SERVICE_MUTE_ACCOUNT,
|
|
_async_mute_account,
|
|
schema=SERVICE_MUTE_ACCOUNT_SCHEMA,
|
|
)
|
|
hass.services.async_register(
|
|
DOMAIN,
|
|
SERVICE_UNMUTE_ACCOUNT,
|
|
_async_unmute_account,
|
|
schema=SERVICE_UNMUTE_ACCOUNT_SCHEMA,
|
|
)
|
|
hass.services.async_register(
|
|
DOMAIN, SERVICE_POST, _async_post, schema=SERVICE_POST_SCHEMA
|
|
)
|
|
hass.services.async_register(
|
|
DOMAIN,
|
|
SERVICE_UPDATE_PROFILE,
|
|
_async_update_profile,
|
|
schema=SERVICE_UPDATE_PROFILE_SCHEMA,
|
|
supports_response=SupportsResponse.OPTIONAL,
|
|
)
|
|
|
|
|
|
async def _async_account_lookup(
|
|
hass: HomeAssistant, client: Mastodon, account_name: str
|
|
) -> Account:
|
|
"""Lookup a Mastodon account by its username."""
|
|
try:
|
|
account: Account = await hass.async_add_executor_job(
|
|
partial(client.account_lookup, acct=account_name)
|
|
)
|
|
except MastodonNotFoundError:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="account_not_found",
|
|
translation_placeholders={"account_name": account_name},
|
|
) from None
|
|
return account
|
|
|
|
|
|
async def _async_get_account(call: ServiceCall) -> ServiceResponse:
|
|
"""Get account information."""
|
|
entry: MastodonConfigEntry = service.async_get_config_entry(
|
|
call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
|
|
)
|
|
client = entry.runtime_data.client
|
|
|
|
account_name: str = call.data[ATTR_ACCOUNT_NAME]
|
|
|
|
try:
|
|
account = await _async_account_lookup(call.hass, client, account_name)
|
|
except MastodonAPIError as err:
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="unable_to_get_account",
|
|
translation_placeholders={"account_name": account_name},
|
|
) from err
|
|
|
|
return {"account": account}
|
|
|
|
|
|
async def _async_mute_account(call: ServiceCall) -> ServiceResponse:
|
|
"""Mute account."""
|
|
entry: MastodonConfigEntry = service.async_get_config_entry(
|
|
call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
|
|
)
|
|
client = entry.runtime_data.client
|
|
|
|
account_name: str = call.data[ATTR_ACCOUNT_NAME]
|
|
hide_notifications: bool = call.data[ATTR_HIDE_NOTIFICATIONS]
|
|
duration: int | None = None
|
|
if call.data.get(ATTR_DURATION) is not None:
|
|
td: timedelta = call.data[ATTR_DURATION]
|
|
duration_seconds = td.total_seconds()
|
|
|
|
if not isfinite(duration_seconds) or duration_seconds > MAX_DURATION_SECONDS:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="mute_duration_too_long",
|
|
)
|
|
|
|
duration = int(duration_seconds)
|
|
|
|
try:
|
|
account = await _async_account_lookup(call.hass, client, account_name)
|
|
await call.hass.async_add_executor_job(
|
|
partial(
|
|
client.account_mute,
|
|
id=account.id,
|
|
notifications=hide_notifications,
|
|
duration=duration,
|
|
)
|
|
)
|
|
except MastodonAPIError as err:
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="unable_to_mute_account",
|
|
translation_placeholders={"account_name": account_name},
|
|
) from err
|
|
|
|
return None
|
|
|
|
|
|
async def _async_unmute_account(call: ServiceCall) -> ServiceResponse:
|
|
"""Unmute account."""
|
|
entry: MastodonConfigEntry = service.async_get_config_entry(
|
|
call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
|
|
)
|
|
client = entry.runtime_data.client
|
|
|
|
account_name: str = call.data[ATTR_ACCOUNT_NAME]
|
|
|
|
try:
|
|
account = await _async_account_lookup(call.hass, client, account_name)
|
|
await call.hass.async_add_executor_job(
|
|
partial(client.account_unmute, id=account.id)
|
|
)
|
|
except MastodonAPIError as err:
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="unable_to_unmute_account",
|
|
translation_placeholders={"account_name": account_name},
|
|
) from err
|
|
|
|
return None
|
|
|
|
|
|
async def _async_post(call: ServiceCall) -> ServiceResponse:
|
|
"""Post a status."""
|
|
entry: MastodonConfigEntry = service.async_get_config_entry(
|
|
call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
|
|
)
|
|
client = entry.runtime_data.client
|
|
|
|
status: str = call.data[ATTR_STATUS]
|
|
|
|
visibility: str | None = (
|
|
StatusVisibility(call.data[ATTR_VISIBILITY])
|
|
if ATTR_VISIBILITY in call.data
|
|
else None
|
|
)
|
|
quote_approval_policy: str | None = (
|
|
QuoteApprovalPolicy(call.data[ATTR_QUOTE_APPROVAL_POLICY])
|
|
if ATTR_QUOTE_APPROVAL_POLICY in call.data
|
|
else None
|
|
)
|
|
idempotency_key: str | None = call.data.get(ATTR_IDEMPOTENCY_KEY)
|
|
spoiler_text: str | None = call.data.get(ATTR_CONTENT_WARNING)
|
|
language: str | None = call.data.get(ATTR_LANGUAGE)
|
|
media_path: str | None = call.data.get(ATTR_MEDIA)
|
|
media_description: str | None = call.data.get(ATTR_MEDIA_DESCRIPTION)
|
|
media_warning: str | None = call.data.get(ATTR_MEDIA_WARNING)
|
|
|
|
if idempotency_key and len(idempotency_key) < 4:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="idempotency_key_too_short",
|
|
)
|
|
|
|
await call.hass.async_add_executor_job(
|
|
partial(
|
|
_post,
|
|
hass=call.hass,
|
|
client=client,
|
|
status=status,
|
|
visibility=visibility,
|
|
quote_approval_policy=quote_approval_policy,
|
|
idempotency_key=idempotency_key,
|
|
spoiler_text=spoiler_text,
|
|
language=language,
|
|
media_path=media_path,
|
|
media_description=media_description,
|
|
sensitive=media_warning,
|
|
)
|
|
)
|
|
|
|
return None
|
|
|
|
|
|
def _post(hass: HomeAssistant, client: Mastodon, **kwargs: Any) -> None:
|
|
"""Post to Mastodon."""
|
|
|
|
media_data: MediaAttachment | None = None
|
|
|
|
media_path = kwargs.get("media_path")
|
|
if media_path:
|
|
if not hass.config.is_allowed_path(media_path):
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="not_whitelisted_directory",
|
|
translation_placeholders={"media": media_path},
|
|
)
|
|
|
|
media_type = get_media_type(media_path)
|
|
media_description = kwargs.get("media_description")
|
|
try:
|
|
media_data = client.media_post(
|
|
media_file=media_path,
|
|
mime_type=media_type,
|
|
description=media_description,
|
|
)
|
|
|
|
except MastodonAPIError as err:
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="unable_to_upload_image",
|
|
translation_placeholders={"media_path": media_path},
|
|
) from err
|
|
|
|
kwargs.pop("media_path", None)
|
|
kwargs.pop("media_description", None)
|
|
|
|
media_ids: str | None = None
|
|
if media_data:
|
|
media_ids = media_data.id
|
|
try:
|
|
client.status_post(media_ids=media_ids, **kwargs)
|
|
except MastodonAPIError as err:
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="unable_to_send_message",
|
|
) from err
|
|
|
|
|
|
async def _async_update_profile(call: ServiceCall) -> ServiceResponse | None:
|
|
"""Update profile information."""
|
|
params = dict(call.data.copy())
|
|
|
|
entry: MastodonConfigEntry = service.async_get_config_entry(
|
|
call.hass, DOMAIN, params.pop(ATTR_CONFIG_ENTRY_ID)
|
|
)
|
|
client = entry.runtime_data.client
|
|
|
|
if avatar := params.pop(ATTR_AVATAR, None):
|
|
params[ATTR_AVATAR], params[ATTR_AVATAR_MIME_TYPE] = await _resolve_media(
|
|
call.hass, avatar
|
|
)
|
|
if header := params.pop(ATTR_HEADER, None):
|
|
params[ATTR_HEADER], params[ATTR_HEADER_MIME_TYPE] = await _resolve_media(
|
|
call.hass, header
|
|
)
|
|
if fields := params.get(ATTR_FIELDS):
|
|
params[ATTR_FIELDS] = [
|
|
(field[ATTR_NAME].strip(), field[ATTR_VALUE].strip())
|
|
for field in fields
|
|
if field[ATTR_NAME].strip()
|
|
]
|
|
delete_avatar = params.pop("delete_avatar", False)
|
|
delete_header = params.pop("delete_header", False)
|
|
try:
|
|
|
|
def _update_profile() -> Any:
|
|
if delete_avatar:
|
|
client.account_delete_avatar()
|
|
if delete_header:
|
|
client.account_delete_header()
|
|
if call.return_response or params:
|
|
return client.account_update_credentials(**params)
|
|
return None
|
|
|
|
response: Account | None = await call.hass.async_add_executor_job(
|
|
_update_profile
|
|
)
|
|
except MastodonUnauthorizedError as error:
|
|
entry.async_start_reauth(call.hass)
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="auth_failed",
|
|
) from error
|
|
except MastodonAPIError as err:
|
|
LOGGER.debug("Full exception:", exc_info=err)
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="unable_to_update_profile",
|
|
) from err
|
|
if call.return_response:
|
|
return response
|
|
return None
|
|
|
|
|
|
async def _resolve_media(
|
|
hass: HomeAssistant, media_source: dict[str, str]
|
|
) -> tuple[bytes | Path, str | None]:
|
|
"""Resolve media from a media source."""
|
|
media_content_id: str = media_source["media_content_id"]
|
|
if media_content_id.startswith("media-source://camera/"):
|
|
entity_id = media_content_id.removeprefix("media-source://camera/")
|
|
snapshot = await camera.async_get_image(hass, entity_id)
|
|
return snapshot.content, snapshot.content_type
|
|
|
|
if media_content_id.startswith("media-source://image/"):
|
|
entity_id = media_content_id.removeprefix("media-source://image/")
|
|
img = await image.async_get_image(hass, entity_id)
|
|
return img.content, img.content_type
|
|
|
|
media = await async_resolve_media(hass, media_source["media_content_id"], None)
|
|
|
|
if media.path is None:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="media_source_not_supported",
|
|
translation_placeholders={"media_content_id": media_content_id},
|
|
)
|
|
|
|
return media.path, media.mime_type
|