Migrate Mastodon integration to config flow (#122376)

* Migrate to config flow

* Fixes & add code owner

* Add codeowners

* Import within notify module

* Fixes from review

* Fixes

* Remove config schema
This commit is contained in:
Andrew Jackson 2024-07-27 12:07:02 +01:00 committed by GitHub
parent 64f997718a
commit cb4a48ca02
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 953 additions and 28 deletions

View File

@ -839,7 +839,8 @@ build.json @home-assistant/supervisor
/tests/components/lyric/ @timmo001 /tests/components/lyric/ @timmo001
/homeassistant/components/madvr/ @iloveicedgreentea /homeassistant/components/madvr/ @iloveicedgreentea
/tests/components/madvr/ @iloveicedgreentea /tests/components/madvr/ @iloveicedgreentea
/homeassistant/components/mastodon/ @fabaff /homeassistant/components/mastodon/ @fabaff @andrew-codechimp
/tests/components/mastodon/ @fabaff @andrew-codechimp
/homeassistant/components/matrix/ @PaarthShah /homeassistant/components/matrix/ @PaarthShah
/tests/components/matrix/ @PaarthShah /tests/components/matrix/ @PaarthShah
/homeassistant/components/matter/ @home-assistant/matter /homeassistant/components/matter/ @home-assistant/matter

View File

@ -1 +1,60 @@
"""The Mastodon integration.""" """The Mastodon integration."""
from __future__ import annotations
from mastodon.Mastodon import Mastodon, MastodonError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_ACCESS_TOKEN,
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
CONF_NAME,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import discovery
from .const import CONF_BASE_URL, DOMAIN
from .utils import create_mastodon_client
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Mastodon from a config entry."""
try:
client, _, _ = await hass.async_add_executor_job(
setup_mastodon,
entry,
)
except MastodonError as ex:
raise ConfigEntryNotReady("Failed to connect") from ex
assert entry.unique_id
await discovery.async_load_platform(
hass,
Platform.NOTIFY,
DOMAIN,
{CONF_NAME: entry.title, "client": client},
{},
)
return True
def setup_mastodon(entry: ConfigEntry) -> tuple[Mastodon, dict, dict]:
"""Get mastodon details."""
client = create_mastodon_client(
entry.data[CONF_BASE_URL],
entry.data[CONF_CLIENT_ID],
entry.data[CONF_CLIENT_SECRET],
entry.data[CONF_ACCESS_TOKEN],
)
instance = client.instance()
account = client.account_verify_credentials()
return client, instance, account

View File

@ -0,0 +1,168 @@
"""Config flow for Mastodon."""
from __future__ import annotations
from typing import Any
from mastodon.Mastodon import MastodonNetworkError, MastodonUnauthorizedError
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_ACCESS_TOKEN,
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
CONF_NAME,
)
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from homeassistant.helpers.typing import ConfigType
from .const import CONF_BASE_URL, DEFAULT_URL, DOMAIN, LOGGER
from .utils import construct_mastodon_username, create_mastodon_client
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(
CONF_BASE_URL,
default=DEFAULT_URL,
): TextSelector(TextSelectorConfig(type=TextSelectorType.URL)),
vol.Required(
CONF_CLIENT_ID,
): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)),
vol.Required(
CONF_CLIENT_SECRET,
): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)),
vol.Required(
CONF_ACCESS_TOKEN,
): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)),
}
)
class MastodonConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
VERSION = 1
config_entry: ConfigEntry
def check_connection(
self,
base_url: str,
client_id: str,
client_secret: str,
access_token: str,
) -> tuple[
dict[str, str] | None,
dict[str, str] | None,
dict[str, str],
]:
"""Check connection to the Mastodon instance."""
try:
client = create_mastodon_client(
base_url,
client_id,
client_secret,
access_token,
)
instance = client.instance()
account = client.account_verify_credentials()
except MastodonNetworkError:
return None, None, {"base": "network_error"}
except MastodonUnauthorizedError:
return None, None, {"base": "unauthorized_error"}
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected error")
return None, None, {"base": "unknown"}
return instance, account, {}
def show_user_form(
self,
user_input: dict[str, Any] | None = None,
errors: dict[str, str] | None = None,
description_placeholders: dict[str, str] | None = None,
step_id: str = "user",
) -> ConfigFlowResult:
"""Show the user form."""
if user_input is None:
user_input = {}
return self.async_show_form(
step_id=step_id,
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input
),
description_placeholders=description_placeholders,
errors=errors,
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
errors: dict[str, str] | None = None
if user_input:
self._async_abort_entries_match(
{CONF_CLIENT_ID: user_input[CONF_CLIENT_ID]}
)
instance, account, errors = await self.hass.async_add_executor_job(
self.check_connection,
user_input[CONF_BASE_URL],
user_input[CONF_CLIENT_ID],
user_input[CONF_CLIENT_SECRET],
user_input[CONF_ACCESS_TOKEN],
)
if not errors:
name = construct_mastodon_username(instance, account)
await self.async_set_unique_id(user_input[CONF_CLIENT_ID])
return self.async_create_entry(
title=name,
data=user_input,
)
return self.show_user_form(user_input, errors)
async def async_step_import(self, import_config: ConfigType) -> ConfigFlowResult:
"""Import a config entry from configuration.yaml."""
errors: dict[str, str] | None = None
LOGGER.debug("Importing Mastodon from configuration.yaml")
base_url = str(import_config.get(CONF_BASE_URL, DEFAULT_URL))
client_id = str(import_config.get(CONF_CLIENT_ID))
client_secret = str(import_config.get(CONF_CLIENT_SECRET))
access_token = str(import_config.get(CONF_ACCESS_TOKEN))
name = import_config.get(CONF_NAME, None)
instance, account, errors = await self.hass.async_add_executor_job(
self.check_connection,
base_url,
client_id,
client_secret,
access_token,
)
if not errors:
await self.async_set_unique_id(client_id)
self._abort_if_unique_id_configured()
if not name:
name = construct_mastodon_username(instance, account)
return self.async_create_entry(
title=name,
data={
CONF_BASE_URL: base_url,
CONF_CLIENT_ID: client_id,
CONF_CLIENT_SECRET: client_secret,
CONF_ACCESS_TOKEN: access_token,
},
)
reason = next(iter(errors.items()))[1]
return self.async_abort(reason=reason)

View File

@ -5,5 +5,14 @@ from typing import Final
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
DOMAIN: Final = "mastodon"
CONF_BASE_URL: Final = "base_url" CONF_BASE_URL: Final = "base_url"
DATA_HASS_CONFIG = "mastodon_hass_config"
DEFAULT_URL: Final = "https://mastodon.social" DEFAULT_URL: Final = "https://mastodon.social"
DEFAULT_NAME: Final = "Mastodon"
INSTANCE_VERSION: Final = "version"
INSTANCE_URI: Final = "uri"
INSTANCE_DOMAIN: Final = "domain"
ACCOUNT_USERNAME: Final = "username"

View File

@ -1,8 +1,10 @@
{ {
"domain": "mastodon", "domain": "mastodon",
"name": "Mastodon", "name": "Mastodon",
"codeowners": ["@fabaff"], "codeowners": ["@fabaff", "@andrew-codechimp"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/mastodon", "documentation": "https://www.home-assistant.io/integrations/mastodon",
"integration_type": "service",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["mastodon"], "loggers": ["mastodon"],
"requirements": ["Mastodon.py==1.8.1"] "requirements": ["Mastodon.py==1.8.1"]

View File

@ -6,7 +6,7 @@ import mimetypes
from typing import Any, cast from typing import Any, cast
from mastodon import Mastodon from mastodon import Mastodon
from mastodon.Mastodon import MastodonAPIError, MastodonUnauthorizedError from mastodon.Mastodon import MastodonAPIError
import voluptuous as vol import voluptuous as vol
from homeassistant.components.notify import ( from homeassistant.components.notify import (
@ -14,12 +14,14 @@ from homeassistant.components.notify import (
PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA, PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA,
BaseNotificationService, BaseNotificationService,
) )
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.core import HomeAssistant from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.helpers import config_validation as cv from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_BASE_URL, DEFAULT_URL, LOGGER from .const import CONF_BASE_URL, DEFAULT_URL, DOMAIN, LOGGER
ATTR_MEDIA = "media" ATTR_MEDIA = "media"
ATTR_TARGET = "target" ATTR_TARGET = "target"
@ -35,39 +37,78 @@ PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend(
} }
) )
INTEGRATION_TITLE = "Mastodon"
def get_service(
async def async_get_service(
hass: HomeAssistant, hass: HomeAssistant,
config: ConfigType, config: ConfigType,
discovery_info: DiscoveryInfoType | None = None, discovery_info: DiscoveryInfoType | None = None,
) -> MastodonNotificationService | None: ) -> MastodonNotificationService | None:
"""Get the Mastodon notification service.""" """Get the Mastodon notification service."""
client_id = config.get(CONF_CLIENT_ID)
client_secret = config.get(CONF_CLIENT_SECRET)
access_token = config.get(CONF_ACCESS_TOKEN)
base_url = config.get(CONF_BASE_URL)
try: if not discovery_info:
mastodon = Mastodon( # Import config entry
client_id=client_id,
client_secret=client_secret, import_result = await hass.config_entries.flow.async_init(
access_token=access_token, DOMAIN,
api_base_url=base_url, context={"source": SOURCE_IMPORT},
data=config,
) )
mastodon.account_verify_credentials()
except MastodonUnauthorizedError: if (
LOGGER.warning("Authentication failed") import_result["type"] == FlowResultType.ABORT
and import_result["reason"] != "already_configured"
):
ir.async_create_issue(
hass,
DOMAIN,
f"deprecated_yaml_import_issue_{import_result["reason"]}",
breaks_in_ha_version="2025.2.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key=f"deprecated_yaml_import_issue_{import_result["reason"]}",
translation_placeholders={
"domain": DOMAIN,
"integration_title": INTEGRATION_TITLE,
},
)
return None
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2025.2.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": INTEGRATION_TITLE,
},
)
return None return None
return MastodonNotificationService(mastodon) client: Mastodon = discovery_info.get("client")
return MastodonNotificationService(hass, client)
class MastodonNotificationService(BaseNotificationService): class MastodonNotificationService(BaseNotificationService):
"""Implement the notification service for Mastodon.""" """Implement the notification service for Mastodon."""
def __init__(self, api: Mastodon) -> None: def __init__(
self,
hass: HomeAssistant,
client: Mastodon,
) -> None:
"""Initialize the service.""" """Initialize the service."""
self._api = api
self.client = client
def send_message(self, message: str = "", **kwargs: Any) -> None: def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Toot a message, with media perhaps.""" """Toot a message, with media perhaps."""
@ -96,7 +137,7 @@ class MastodonNotificationService(BaseNotificationService):
if mediadata: if mediadata:
try: try:
self._api.status_post( self.client.status_post(
message, message,
media_ids=mediadata["id"], media_ids=mediadata["id"],
sensitive=sensitive, sensitive=sensitive,
@ -107,7 +148,7 @@ class MastodonNotificationService(BaseNotificationService):
LOGGER.error("Unable to send message") LOGGER.error("Unable to send message")
else: else:
try: try:
self._api.status_post( self.client.status_post(
message, visibility=target, spoiler_text=content_warning message, visibility=target, spoiler_text=content_warning
) )
except MastodonAPIError: except MastodonAPIError:
@ -118,7 +159,7 @@ class MastodonNotificationService(BaseNotificationService):
with open(media_path, "rb"): with open(media_path, "rb"):
media_type = self._media_type(media_path) media_type = self._media_type(media_path)
try: try:
mediadata = self._api.media_post(media_path, mime_type=media_type) mediadata = self.client.media_post(media_path, mime_type=media_type)
except MastodonAPIError: except MastodonAPIError:
LOGGER.error(f"Unable to upload image {media_path}") LOGGER.error(f"Unable to upload image {media_path}")

View File

@ -0,0 +1,39 @@
{
"config": {
"step": {
"user": {
"data": {
"base_url": "[%key:common::config_flow::data::url%]",
"client_id": "Client Key",
"client_secret": "Client Secret",
"access_token": "[%key:common::config_flow::data::access_token%]"
},
"data_description": {
"base_url": "The URL of your Mastodon instance."
}
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"unauthorized_error": "The credentials are incorrect.",
"network_error": "The Mastodon instance was not found.",
"unknown": "Unknown error occured when connecting to the Mastodon instance."
}
},
"issues": {
"deprecated_yaml_import_issue_unauthorized_error": {
"title": "YAML import failed due to an authentication error",
"description": "Configuring {integration_title} using YAML is being removed but there was an authentication error while importing your existing configuration.\nPlease use the UI to configure Mastodon. Don't forget to delete the YAML configuration."
},
"deprecated_yaml_import_issue_network_error": {
"title": "YAML import failed because the instance was not found",
"description": "Configuring {integration_title} using YAML is being removed but no instance was found while importing your existing configuration.\nPlease use the UI to configure Mastodon. Don't forget to delete the YAML configuration."
},
"deprecated_yaml_import_issue_unknown": {
"title": "YAML import failed with unknown error",
"description": "Configuring {integration_title} using YAML is being removed but there was an unknown error while importing your existing configuration.\nPlease use the UI to configure Mastodon. Don't forget to delete the YAML configuration."
}
}
}

View File

@ -0,0 +1,32 @@
"""Mastodon util functions."""
from __future__ import annotations
from mastodon import Mastodon
from .const import ACCOUNT_USERNAME, DEFAULT_NAME, INSTANCE_DOMAIN, INSTANCE_URI
def create_mastodon_client(
base_url: str, client_id: str, client_secret: str, access_token: str
) -> Mastodon:
"""Create a Mastodon client with the api base url."""
return Mastodon(
api_base_url=base_url,
client_id=client_id,
client_secret=client_secret,
access_token=access_token,
)
def construct_mastodon_username(
instance: dict[str, str] | None, account: dict[str, str] | None
) -> str:
"""Construct a mastodon username from the account and instance."""
if instance and account:
return (
f"@{account[ACCOUNT_USERNAME]}@"
f"{instance.get(INSTANCE_URI, instance.get(INSTANCE_DOMAIN))}"
)
return DEFAULT_NAME

View File

@ -330,6 +330,7 @@ FLOWS = {
"lyric", "lyric",
"madvr", "madvr",
"mailgun", "mailgun",
"mastodon",
"matter", "matter",
"mealie", "mealie",
"meater", "meater",

View File

@ -3495,8 +3495,8 @@
}, },
"mastodon": { "mastodon": {
"name": "Mastodon", "name": "Mastodon",
"integration_type": "hub", "integration_type": "service",
"config_flow": false, "config_flow": true,
"iot_class": "cloud_push" "iot_class": "cloud_push"
}, },
"matrix": { "matrix": {

View File

@ -21,6 +21,9 @@ HAP-python==4.9.1
# homeassistant.components.tasmota # homeassistant.components.tasmota
HATasmota==0.9.2 HATasmota==0.9.2
# homeassistant.components.mastodon
Mastodon.py==1.8.1
# homeassistant.components.doods # homeassistant.components.doods
# homeassistant.components.generic # homeassistant.components.generic
# homeassistant.components.image_upload # homeassistant.components.image_upload

View File

@ -0,0 +1,13 @@
"""Tests for the Mastodon integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Fixture for setting up the component."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()

View File

@ -0,0 +1,57 @@
"""Mastodon tests configuration."""
from collections.abc import Generator
from unittest.mock import patch
import pytest
from homeassistant.components.mastodon.const import CONF_BASE_URL, DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET
from tests.common import MockConfigEntry, load_json_object_fixture
from tests.components.smhi.common import AsyncMock
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.mastodon.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_mastodon_client() -> Generator[AsyncMock]:
"""Mock a Mastodon client."""
with (
patch(
"homeassistant.components.mastodon.utils.Mastodon",
autospec=True,
) as mock_client,
):
client = mock_client.return_value
client.instance.return_value = load_json_object_fixture("instance.json", DOMAIN)
client.account_verify_credentials.return_value = load_json_object_fixture(
"account_verify_credentials.json", DOMAIN
)
client.status_post.return_value = None
yield client
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Mock a config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="@trwnh@mastodon.social",
data={
CONF_BASE_URL: "https://mastodon.social",
CONF_CLIENT_ID: "client_id",
CONF_CLIENT_SECRET: "client_secret",
CONF_ACCESS_TOKEN: "access_token",
},
entry_id="01J35M4AH9HYRC2V0G6RNVNWJH",
unique_id="client_id",
)

View File

@ -0,0 +1,78 @@
{
"id": "14715",
"username": "trwnh",
"acct": "trwnh",
"display_name": "infinite love ⴳ",
"locked": false,
"bot": false,
"created_at": "2016-11-24T10:02:12.085Z",
"note": "<p>i have approximate knowledge of many things. perpetual student. (nb/ace/they)</p><p>xmpp/email: a@trwnh.com<br /><a href=\"https://trwnh.com\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">trwnh.com</span><span class=\"invisible\"></span></a><br />help me live: <a href=\"https://liberapay.com/at\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">liberapay.com/at</span><span class=\"invisible\"></span></a> or <a href=\"https://paypal.me/trwnh\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">paypal.me/trwnh</span><span class=\"invisible\"></span></a></p><p>- my triggers are moths and glitter<br />- i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise<br />- dm me if i did something wrong, so i can improve<br />- purest person on fedi, do not lewd in my presence<br />- #1 ami cole fan account</p><p>:fatyoshi:</p>",
"url": "https://mastodon.social/@trwnh",
"avatar": "https://files.mastodon.social/accounts/avatars/000/014/715/original/34aa222f4ae2e0a9.png",
"avatar_static": "https://files.mastodon.social/accounts/avatars/000/014/715/original/34aa222f4ae2e0a9.png",
"header": "https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg",
"header_static": "https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg",
"followers_count": 821,
"following_count": 178,
"statuses_count": 33120,
"last_status_at": "2019-11-24T15:49:42.251Z",
"source": {
"privacy": "public",
"sensitive": false,
"language": "",
"note": "i have approximate knowledge of many things. perpetual student. (nb/ace/they)\r\n\r\nxmpp/email: a@trwnh.com\r\nhttps://trwnh.com\r\nhelp me live: https://liberapay.com/at or https://paypal.me/trwnh\r\n\r\n- my triggers are moths and glitter\r\n- i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise\r\n- dm me if i did something wrong, so i can improve\r\n- purest person on fedi, do not lewd in my presence\r\n- #1 ami cole fan account\r\n\r\n:fatyoshi:",
"fields": [
{
"name": "Website",
"value": "https://trwnh.com",
"verified_at": "2019-08-29T04:14:55.571+00:00"
},
{
"name": "Sponsor",
"value": "https://liberapay.com/at",
"verified_at": "2019-11-15T10:06:15.557+00:00"
},
{
"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": null
},
{
"name": "Main topics:",
"value": "systemic analysis, design patterns, anticapitalism, info/tech freedom, theory and philosophy, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people!",
"verified_at": null
}
],
"follow_requests_count": 0
},
"emojis": [
{
"shortcode": "fatyoshi",
"url": "https://files.mastodon.social/custom_emojis/images/000/023/920/original/e57ecb623faa0dc9.png",
"static_url": "https://files.mastodon.social/custom_emojis/images/000/023/920/static/e57ecb623faa0dc9.png",
"visible_in_picker": true
}
],
"fields": [
{
"name": "Website",
"value": "<a href=\"https://trwnh.com\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">trwnh.com</span><span class=\"invisible\"></span></a>",
"verified_at": "2019-08-29T04:14:55.571+00:00"
},
{
"name": "Sponsor",
"value": "<a href=\"https://liberapay.com/at\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">liberapay.com/at</span><span class=\"invisible\"></span></a>",
"verified_at": "2019-11-15T10:06:15.557+00:00"
},
{
"name": "Fan of:",
"value": "Punk-rock and post-hardcore (Circa Survive, letlive., La Dispute, THE FEVER 333)Manga (Yu-Gi-Oh!, One Piece, JoJo&apos;s Bizarre Adventure, Death Note, Shaman King)Platformers and RPGs (Banjo-Kazooie, Boktai, Final Fantasy Crystal Chronicles)",
"verified_at": null
},
{
"name": "Main topics:",
"value": "systemic analysis, design patterns, anticapitalism, info/tech freedom, theory and philosophy, and otherwise being a genuine and decent wholesome poster. i&apos;m just here to hang out and talk to cool people!",
"verified_at": null
}
]
}

View File

@ -0,0 +1,147 @@
{
"domain": "mastodon.social",
"title": "Mastodon",
"version": "4.0.0rc1",
"source_url": "https://github.com/mastodon/mastodon",
"description": "The original server operated by the Mastodon gGmbH non-profit",
"usage": {
"users": {
"active_month": 123122
}
},
"thumbnail": {
"url": "https://files.mastodon.social/site_uploads/files/000/000/001/@1x/57c12f441d083cde.png",
"blurhash": "UeKUpFxuo~R%0nW;WCnhF6RjaJt757oJodS$",
"versions": {
"@1x": "https://files.mastodon.social/site_uploads/files/000/000/001/@1x/57c12f441d083cde.png",
"@2x": "https://files.mastodon.social/site_uploads/files/000/000/001/@2x/57c12f441d083cde.png"
}
},
"languages": ["en"],
"configuration": {
"urls": {
"streaming": "wss://mastodon.social"
},
"vapid": {
"public_key": "BCkMmVdKDnKYwzVCDC99Iuc9GvId-x7-kKtuHnLgfF98ENiZp_aj-UNthbCdI70DqN1zUVis-x0Wrot2sBagkMc="
},
"accounts": {
"max_featured_tags": 10,
"max_pinned_statuses": 4
},
"statuses": {
"max_characters": 500,
"max_media_attachments": 4,
"characters_reserved_per_url": 23
},
"media_attachments": {
"supported_mime_types": [
"image/jpeg",
"image/png",
"image/gif",
"image/heic",
"image/heif",
"image/webp",
"video/webm",
"video/mp4",
"video/quicktime",
"video/ogg",
"audio/wave",
"audio/wav",
"audio/x-wav",
"audio/x-pn-wave",
"audio/vnd.wave",
"audio/ogg",
"audio/vorbis",
"audio/mpeg",
"audio/mp3",
"audio/webm",
"audio/flac",
"audio/aac",
"audio/m4a",
"audio/x-m4a",
"audio/mp4",
"audio/3gpp",
"video/x-ms-asf"
],
"image_size_limit": 10485760,
"image_matrix_limit": 16777216,
"video_size_limit": 41943040,
"video_frame_rate_limit": 60,
"video_matrix_limit": 2304000
},
"polls": {
"max_options": 4,
"max_characters_per_option": 50,
"min_expiration": 300,
"max_expiration": 2629746
},
"translation": {
"enabled": true
}
},
"registrations": {
"enabled": false,
"approval_required": false,
"message": null
},
"contact": {
"email": "staff@mastodon.social",
"account": {
"id": "1",
"username": "Gargron",
"acct": "Gargron",
"display_name": "Eugen 💀",
"locked": false,
"bot": false,
"discoverable": true,
"group": false,
"created_at": "2016-03-16T00:00:00.000Z",
"note": "<p>Founder, CEO and lead developer <span class=\"h-card\"><a href=\"https://mastodon.social/@Mastodon\" class=\"u-url mention\">@<span>Mastodon</span></a></span>, Germany.</p>",
"url": "https://mastodon.social/@Gargron",
"avatar": "https://files.mastodon.social/accounts/avatars/000/000/001/original/dc4286ceb8fab734.jpg",
"avatar_static": "https://files.mastodon.social/accounts/avatars/000/000/001/original/dc4286ceb8fab734.jpg",
"header": "https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg",
"header_static": "https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg",
"followers_count": 133026,
"following_count": 311,
"statuses_count": 72605,
"last_status_at": "2022-10-31",
"noindex": false,
"emojis": [],
"fields": [
{
"name": "Patreon",
"value": "<a href=\"https://www.patreon.com/mastodon\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\"><span class=\"invisible\">https://www.</span><span class=\"\">patreon.com/mastodon</span><span class=\"invisible\"></span></a>",
"verified_at": null
}
]
}
},
"rules": [
{
"id": "1",
"text": "Sexually explicit or violent media must be marked as sensitive when posting"
},
{
"id": "2",
"text": "No racism, sexism, homophobia, transphobia, xenophobia, or casteism"
},
{
"id": "3",
"text": "No incitement of violence or promotion of violent ideologies"
},
{
"id": "4",
"text": "No harassment, dogpiling or doxxing of other users"
},
{
"id": "5",
"text": "No content illegal in Germany"
},
{
"id": "7",
"text": "Do not share intentionally false or misleading information"
}
]
}

View File

@ -0,0 +1,33 @@
# serializer version: 1
# name: test_device_info
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': <DeviceEntryType.SERVICE: 'service'>,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'mastodon',
'client_id',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Mastodon gGmbH',
'model': '@trwnh@mastodon.social',
'model_id': None,
'name': 'Mastodon',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': '4.0.0rc1',
'via_device_id': None,
})
# ---

View File

@ -0,0 +1,179 @@
"""Tests for the Mastodon config flow."""
from unittest.mock import AsyncMock
from mastodon.Mastodon import MastodonNetworkError, MastodonUnauthorizedError
import pytest
from homeassistant.components.mastodon.const import CONF_BASE_URL, DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
async def test_full_flow(
hass: HomeAssistant,
mock_mastodon_client: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test full flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_BASE_URL: "https://mastodon.social",
CONF_CLIENT_ID: "client_id",
CONF_CLIENT_SECRET: "client_secret",
CONF_ACCESS_TOKEN: "access_token",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "@trwnh@mastodon.social"
assert result["data"] == {
CONF_BASE_URL: "https://mastodon.social",
CONF_CLIENT_ID: "client_id",
CONF_CLIENT_SECRET: "client_secret",
CONF_ACCESS_TOKEN: "access_token",
}
assert result["result"].unique_id == "client_id"
@pytest.mark.parametrize(
("exception", "error"),
[
(MastodonNetworkError, "network_error"),
(MastodonUnauthorizedError, "unauthorized_error"),
(Exception, "unknown"),
],
)
async def test_flow_errors(
hass: HomeAssistant,
mock_mastodon_client: AsyncMock,
mock_setup_entry: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test flow errors."""
mock_mastodon_client.account_verify_credentials.side_effect = exception
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_BASE_URL: "https://mastodon.social",
CONF_CLIENT_ID: "client_id",
CONF_CLIENT_SECRET: "client_secret",
CONF_ACCESS_TOKEN: "access_token",
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
mock_mastodon_client.account_verify_credentials.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_BASE_URL: "https://mastodon.social",
CONF_CLIENT_ID: "client_id",
CONF_CLIENT_SECRET: "client_secret",
CONF_ACCESS_TOKEN: "access_token",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_duplicate(
hass: HomeAssistant,
mock_mastodon_client: AsyncMock,
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test duplicate flow."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_BASE_URL: "https://mastodon.social",
CONF_CLIENT_ID: "client_id",
CONF_CLIENT_SECRET: "client_secret",
CONF_ACCESS_TOKEN: "access_token",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_import_flow(
hass: HomeAssistant,
mock_mastodon_client: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test importing yaml config."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={
CONF_BASE_URL: "https://mastodon.social",
CONF_CLIENT_ID: "import_client_id",
CONF_CLIENT_SECRET: "import_client_secret",
CONF_ACCESS_TOKEN: "import_access_token",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.parametrize(
("exception", "error"),
[
(MastodonNetworkError, "network_error"),
(MastodonUnauthorizedError, "unauthorized_error"),
(Exception, "unknown"),
],
)
async def test_import_flow_abort(
hass: HomeAssistant,
mock_mastodon_client: AsyncMock,
mock_setup_entry: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test importing yaml config abort."""
mock_mastodon_client.account_verify_credentials.side_effect = exception
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={
CONF_BASE_URL: "https://mastodon.social",
CONF_CLIENT_ID: "import_client_id",
CONF_CLIENT_SECRET: "import_client_secret",
CONF_ACCESS_TOKEN: "import_access_token",
},
)
assert result["type"] is FlowResultType.ABORT

View File

@ -0,0 +1,25 @@
"""Tests for the Mastodon integration."""
from unittest.mock import AsyncMock
from mastodon.Mastodon import MastodonError
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry
async def test_initialization_failure(
hass: HomeAssistant,
mock_mastodon_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test initialization failure."""
mock_mastodon_client.instance.side_effect = MastodonError
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY

View File

@ -0,0 +1,38 @@
"""Tests for the Mastodon notify platform."""
from unittest.mock import AsyncMock
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry
async def test_notify(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_mastodon_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test sending a message."""
await setup_integration(hass, mock_config_entry)
assert hass.services.has_service(NOTIFY_DOMAIN, "trwnh_mastodon_social")
await hass.services.async_call(
NOTIFY_DOMAIN,
"trwnh_mastodon_social",
{
"message": "test toot",
},
blocking=True,
return_response=False,
)
assert mock_mastodon_client.status_post.assert_called_once