From 2850192068d70645854170f4e1e373d97cab34f3 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Sun, 15 Feb 2026 10:59:34 +0000 Subject: [PATCH] Add get_account service to Mastodon (#161930) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Norbert Rittel --- homeassistant/components/mastodon/const.py | 1 + homeassistant/components/mastodon/icons.json | 3 + homeassistant/components/mastodon/services.py | 204 +++++++++++------- .../components/mastodon/services.yaml | 11 + .../components/mastodon/strings.json | 17 ++ tests/components/mastodon/conftest.py | 5 +- ...t_verify_credentials.json => account.json} | 0 .../mastodon/snapshots/test_services.ambr | 64 ++++++ tests/components/mastodon/test_services.py | 56 ++++- 9 files changed, 280 insertions(+), 81 deletions(-) rename tests/components/mastodon/fixtures/{account_verify_credentials.json => account.json} (100%) create mode 100644 tests/components/mastodon/snapshots/test_services.ambr diff --git a/homeassistant/components/mastodon/const.py b/homeassistant/components/mastodon/const.py index 145b4e969feb..b26aca307efa 100644 --- a/homeassistant/components/mastodon/const.py +++ b/homeassistant/components/mastodon/const.py @@ -12,6 +12,7 @@ DATA_HASS_CONFIG = "mastodon_hass_config" DEFAULT_URL: Final = "https://mastodon.social" DEFAULT_NAME: Final = "Mastodon" +ATTR_ACCOUNT_NAME = "account_name" ATTR_STATUS = "status" ATTR_VISIBILITY = "visibility" ATTR_IDEMPOTENCY_KEY = "idempotency_key" diff --git a/homeassistant/components/mastodon/icons.json b/homeassistant/components/mastodon/icons.json index ba1f748b3c1d..2883f2e857f1 100644 --- a/homeassistant/components/mastodon/icons.json +++ b/homeassistant/components/mastodon/icons.json @@ -32,6 +32,9 @@ } }, "services": { + "get_account": { + "service": "mdi:account-search" + }, "post": { "service": "mdi:message-text" } diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py index 2cca69fdfb1e..dbb5fc2afdc9 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -5,15 +5,22 @@ from functools import partial from typing import Any from mastodon import Mastodon -from mastodon.Mastodon import MastodonAPIError, MediaAttachment +from mastodon.Mastodon import Account, MastodonAPIError, MediaAttachment import voluptuous as vol from homeassistant.const import ATTR_CONFIG_ENTRY_ID -from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, callback +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import service from .const import ( + ATTR_ACCOUNT_NAME, ATTR_CONTENT_WARNING, ATTR_IDEMPOTENCY_KEY, ATTR_LANGUAGE, @@ -37,6 +44,13 @@ class StatusVisibility(StrEnum): DIRECT = "direct" +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_POST = "post" SERVICE_POST_SCHEMA = vol.Schema( { @@ -56,95 +70,127 @@ SERVICE_POST_SCHEMA = vol.Schema( @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_POST, _async_post, schema=SERVICE_POST_SCHEMA + ) - async def async_post(call: ServiceCall) -> ServiceResponse: - """Post a status.""" - entry: MastodonConfigEntry = service.async_get_config_entry( - hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] + +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: Account = await call.hass.async_add_executor_job( + partial(client.account_lookup, acct=account_name) ) - client = entry.runtime_data.client + except MastodonAPIError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_get_account", + translation_placeholders={"account_name": account_name}, + ) from err - status: str = call.data[ATTR_STATUS] + return {"account": account} - visibility: str | None = ( - StatusVisibility(call.data[ATTR_VISIBILITY]) - if ATTR_VISIBILITY in call.data - else 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 + ) + 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", ) - 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( + await call.hass.async_add_executor_job( + partial( + _post, + hass=call.hass, + client=client, + status=status, + visibility=visibility, + 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="idempotency_key_too_short", + translation_key="not_whitelisted_directory", + translation_placeholders={"media": media_path}, ) - await hass.async_add_executor_job( - partial( - _post, - client=client, - status=status, - visibility=visibility, - 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(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_type = get_media_type(media_path) + media_description = kwargs.get("media_description") try: - media_ids: str | None = None - if media_data: - media_ids = media_data.id - client.status_post(media_ids=media_ids, **kwargs) + 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_send_message", + translation_key="unable_to_upload_image", + translation_placeholders={"media_path": media_path}, ) from err - hass.services.async_register( - DOMAIN, SERVICE_POST, async_post, schema=SERVICE_POST_SCHEMA - ) + 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 diff --git a/homeassistant/components/mastodon/services.yaml b/homeassistant/components/mastodon/services.yaml index 1c0dfa7e836f..9027c6f9fcc1 100644 --- a/homeassistant/components/mastodon/services.yaml +++ b/homeassistant/components/mastodon/services.yaml @@ -1,3 +1,14 @@ +get_account: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: mastodon + account_name: + required: true + selector: + text: post: fields: config_entry_id: diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index 5c50c348b7ce..b069e09b7abd 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -75,6 +75,9 @@ "not_whitelisted_directory": { "message": "{media} is not a whitelisted directory." }, + "unable_to_get_account": { + "message": "Unable to get account \"{account_name}\"." + }, "unable_to_send_message": { "message": "Unable to send message." }, @@ -93,6 +96,20 @@ } }, "services": { + "get_account": { + "description": "Gets information about a Mastodon account.", + "fields": { + "account_name": { + "description": "The Mastodon account username (e.g. @user@instance).", + "name": "Account name" + }, + "config_entry_id": { + "description": "Select the Mastodon instance to use to search.", + "name": "Mastodon instance" + } + }, + "name": "Get account" + }, "post": { "description": "Posts a status on your Mastodon account.", "fields": { diff --git a/tests/components/mastodon/conftest.py b/tests/components/mastodon/conftest.py index 0a0e203bf281..a5e864477a67 100644 --- a/tests/components/mastodon/conftest.py +++ b/tests/components/mastodon/conftest.py @@ -39,7 +39,10 @@ def mock_mastodon_client() -> Generator[AsyncMock]: load_fixture("instance.json", DOMAIN) ) client.account_verify_credentials.return_value = Account.from_json( - load_fixture("account_verify_credentials.json", DOMAIN) + load_fixture("account.json", DOMAIN) + ) + client.account_lookup.return_value = Account.from_json( + load_fixture("account.json", DOMAIN) ) client.mastodon_api_version = 2 client.status_post.return_value = None diff --git a/tests/components/mastodon/fixtures/account_verify_credentials.json b/tests/components/mastodon/fixtures/account.json similarity index 100% rename from tests/components/mastodon/fixtures/account_verify_credentials.json rename to tests/components/mastodon/fixtures/account.json diff --git a/tests/components/mastodon/snapshots/test_services.ambr b/tests/components/mastodon/snapshots/test_services.ambr new file mode 100644 index 000000000000..21c539dd597e --- /dev/null +++ b/tests/components/mastodon/snapshots/test_services.ambr @@ -0,0 +1,64 @@ +# serializer version: 1 +# name: test_get_account_success + dict({ + 'account': Account({ + 'id': 14715, + 'username': 'trwnh', + 'acct': 'trwnh', + 'display_name': 'infinite love ⴳ', + 'discoverable': True, + 'group': False, + 'locked': False, + 'created_at': datetime.datetime(2016, 11, 24, 0, 0, tzinfo=tzlocal()), + 'following_count': 328, + 'followers_count': 3169, + 'statuses_count': 69523, + 'note': '

i have approximate knowledge of many things. perpetual student. (nb/ace/they)

xmpp/email: a@trwnh.com
trwnh.com
help me live:
- donate.stripe.com/4gwcPCaMpcQ1
- liberapay.com/trwnh

notes:
- my triggers are moths and glitter
- i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise
- dm me if i did something wrong, so i can improve
- purest person on fedi, do not lewd in my presence

', + 'url': 'https://mastodon.social/@trwnh', + 'uri': 'https://mastodon.social/users/trwnh', + 'avatar': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png', + 'header': 'https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg', + 'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png', + 'header_static': 'https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg', + 'moved': None, + 'suspended': None, + 'limited': None, + 'bot': True, + 'fields': NonPaginatableList([ + AccountField({ + 'name': 'Website', + 'value': 'trwnh.com', + 'verified_at': '2019-08-29T04:14:55.571+00:00', + }), + AccountField({ + 'name': 'Portfolio', + 'value': 'abdullahtarawneh.com', + 'verified_at': '2021-02-11T20:34:13.574+00:00', + }), + AccountField({ + 'name': 'Fan of:', + 'value': 'Punk-rock and post-hardcore (Circa Survive, letlive., La Dispute, THE FEVER 333)Manga (Yu-Gi-Oh!, One Piece, JoJo's Bizarre Adventure, Death Note, Shaman King)Platformers and RPGs (Banjo-Kazooie, Boktai, Final Fantasy Crystal Chronicles)', + 'verified_at': None, + }), + AccountField({ + 'name': 'What to expect:', + 'value': 'talking about various things i find interesting, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people! and to spill my thoughts.', + 'verified_at': None, + }), + ]), + 'emojis': NonPaginatableList([ + ]), + 'last_status_at': datetime.datetime(2025, 3, 4, 0, 0), + 'noindex': False, + 'roles': NonPaginatableList([ + ]), + 'role': None, + 'source': None, + 'mute_expires_at': None, + 'indexable': False, + 'hide_collections': True, + 'memorial': None, + 'moved_to_account': None, + }), + }) +# --- diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py index bdd798376f5e..8cc28b5ffdec 100644 --- a/tests/components/mastodon/test_services.py +++ b/tests/components/mastodon/test_services.py @@ -4,8 +4,10 @@ from unittest.mock import AsyncMock, Mock, patch from mastodon.Mastodon import MastodonAPIError, MediaAttachment import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.mastodon.const import ( + ATTR_ACCOUNT_NAME, ATTR_CONTENT_WARNING, ATTR_IDEMPOTENCY_KEY, ATTR_LANGUAGE, @@ -15,7 +17,7 @@ from homeassistant.components.mastodon.const import ( ATTR_VISIBILITY, DOMAIN, ) -from homeassistant.components.mastodon.services import SERVICE_POST +from homeassistant.components.mastodon.services import SERVICE_GET_ACCOUNT, SERVICE_POST from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -25,6 +27,58 @@ from . import setup_integration from tests.common import MockConfigEntry +async def test_get_account_success( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the get_account service successfully returns account data.""" + await setup_integration(hass, mock_config_entry) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_ACCOUNT, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_ACCOUNT_NAME: "@trwnh@mastodon.social", + }, + blocking=True, + return_response=True, + ) + + assert response == snapshot + mock_mastodon_client.account_lookup.assert_called_once_with( + acct="@trwnh@mastodon.social" + ) + + +async def test_get_account_failure( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the get_account service handles API errors.""" + await setup_integration(hass, mock_config_entry) + + # Test API error (this is the only error type currently caught by the service) + mock_mastodon_client.account_lookup.side_effect = MastodonAPIError("API error") + with pytest.raises( + HomeAssistantError, + match='Unable to get account "@test@mastodon.social"', + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_ACCOUNT, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_ACCOUNT_NAME: "@test@mastodon.social", + }, + blocking=True, + return_response=True, + ) + + @pytest.mark.parametrize( ("payload", "kwargs"), [