mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 19:27:45 +00:00
Add post action to mastodon (#134788)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
parent
a7903d344f
commit
50f3d79fb2
@ -2,11 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from mastodon.Mastodon import Mastodon, MastodonError
|
from mastodon.Mastodon import Mastodon, MastodonError
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONF_ACCESS_TOKEN,
|
CONF_ACCESS_TOKEN,
|
||||||
CONF_CLIENT_ID,
|
CONF_CLIENT_ID,
|
||||||
@ -16,27 +13,24 @@ from homeassistant.const import (
|
|||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
from homeassistant.helpers import discovery
|
from homeassistant.helpers import config_validation as cv, discovery
|
||||||
|
from homeassistant.helpers.typing import ConfigType
|
||||||
from homeassistant.util import slugify
|
from homeassistant.util import slugify
|
||||||
|
|
||||||
from .const import CONF_BASE_URL, DOMAIN, LOGGER
|
from .const import CONF_BASE_URL, DOMAIN, LOGGER
|
||||||
from .coordinator import MastodonCoordinator
|
from .coordinator import MastodonConfigEntry, MastodonCoordinator, MastodonData
|
||||||
|
from .services import setup_services
|
||||||
from .utils import construct_mastodon_username, create_mastodon_client
|
from .utils import construct_mastodon_username, create_mastodon_client
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [Platform.NOTIFY, Platform.SENSOR]
|
PLATFORMS: list[Platform] = [Platform.NOTIFY, Platform.SENSOR]
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||||
@dataclass
|
|
||||||
class MastodonData:
|
|
||||||
"""Mastodon data type."""
|
|
||||||
|
|
||||||
client: Mastodon
|
|
||||||
instance: dict
|
|
||||||
account: dict
|
|
||||||
coordinator: MastodonCoordinator
|
|
||||||
|
|
||||||
|
|
||||||
type MastodonConfigEntry = ConfigEntry[MastodonData]
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
|
"""Set up the Mastodon component."""
|
||||||
|
setup_services(hass)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> bool:
|
||||||
|
@ -19,3 +19,10 @@ ACCOUNT_USERNAME: Final = "username"
|
|||||||
ACCOUNT_FOLLOWERS_COUNT: Final = "followers_count"
|
ACCOUNT_FOLLOWERS_COUNT: Final = "followers_count"
|
||||||
ACCOUNT_FOLLOWING_COUNT: Final = "following_count"
|
ACCOUNT_FOLLOWING_COUNT: Final = "following_count"
|
||||||
ACCOUNT_STATUSES_COUNT: Final = "statuses_count"
|
ACCOUNT_STATUSES_COUNT: Final = "statuses_count"
|
||||||
|
|
||||||
|
ATTR_CONFIG_ENTRY_ID = "config_entry_id"
|
||||||
|
ATTR_STATUS = "status"
|
||||||
|
ATTR_VISIBILITY = "visibility"
|
||||||
|
ATTR_CONTENT_WARNING = "content_warning"
|
||||||
|
ATTR_MEDIA_WARNING = "media_warning"
|
||||||
|
ATTR_MEDIA = "media"
|
||||||
|
@ -2,18 +2,33 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from mastodon import Mastodon
|
from mastodon import Mastodon
|
||||||
from mastodon.Mastodon import MastodonError
|
from mastodon.Mastodon import MastodonError
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import LOGGER
|
from .const import LOGGER
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MastodonData:
|
||||||
|
"""Mastodon data type."""
|
||||||
|
|
||||||
|
client: Mastodon
|
||||||
|
instance: dict
|
||||||
|
account: dict
|
||||||
|
coordinator: MastodonCoordinator
|
||||||
|
|
||||||
|
|
||||||
|
type MastodonConfigEntry = ConfigEntry[MastodonData]
|
||||||
|
|
||||||
|
|
||||||
class MastodonCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
class MastodonCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||||
"""Class to manage fetching Mastodon data."""
|
"""Class to manage fetching Mastodon data."""
|
||||||
|
|
||||||
|
@ -11,5 +11,10 @@
|
|||||||
"default": "mdi:message-text"
|
"default": "mdi:message-text"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"post": {
|
||||||
|
"service": "mdi:message-text"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import mimetypes
|
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from mastodon import Mastodon
|
from mastodon import Mastodon
|
||||||
@ -16,15 +15,21 @@ from homeassistant.components.notify 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 HomeAssistant
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
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 (
|
||||||
|
ATTR_CONTENT_WARNING,
|
||||||
|
ATTR_MEDIA_WARNING,
|
||||||
|
CONF_BASE_URL,
|
||||||
|
DEFAULT_URL,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
from .utils import get_media_type
|
||||||
|
|
||||||
ATTR_MEDIA = "media"
|
ATTR_MEDIA = "media"
|
||||||
ATTR_TARGET = "target"
|
ATTR_TARGET = "target"
|
||||||
ATTR_MEDIA_WARNING = "media_warning"
|
|
||||||
ATTR_CONTENT_WARNING = "content_warning"
|
|
||||||
|
|
||||||
PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend(
|
PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend(
|
||||||
{
|
{
|
||||||
@ -67,6 +72,17 @@ class MastodonNotificationService(BaseNotificationService):
|
|||||||
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."""
|
||||||
|
|
||||||
|
ir.create_issue(
|
||||||
|
self.hass,
|
||||||
|
DOMAIN,
|
||||||
|
"deprecated_notify_action_mastodon",
|
||||||
|
breaks_in_ha_version="2025.9.0",
|
||||||
|
is_fixable=False,
|
||||||
|
issue_domain=DOMAIN,
|
||||||
|
severity=ir.IssueSeverity.WARNING,
|
||||||
|
translation_key="deprecated_notify_action",
|
||||||
|
)
|
||||||
|
|
||||||
target = None
|
target = None
|
||||||
if (target_list := kwargs.get(ATTR_TARGET)) is not None:
|
if (target_list := kwargs.get(ATTR_TARGET)) is not None:
|
||||||
target = cast(list[str], target_list)[0]
|
target = cast(list[str], target_list)[0]
|
||||||
@ -82,8 +98,11 @@ class MastodonNotificationService(BaseNotificationService):
|
|||||||
media = data.get(ATTR_MEDIA)
|
media = data.get(ATTR_MEDIA)
|
||||||
if media:
|
if media:
|
||||||
if not self.hass.config.is_allowed_path(media):
|
if not self.hass.config.is_allowed_path(media):
|
||||||
LOGGER.warning("'%s' is not a whitelisted directory", media)
|
raise HomeAssistantError(
|
||||||
return
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="not_whitelisted_directory",
|
||||||
|
translation_placeholders={"media": media},
|
||||||
|
)
|
||||||
mediadata = self._upload_media(media)
|
mediadata = self._upload_media(media)
|
||||||
|
|
||||||
sensitive = data.get(ATTR_MEDIA_WARNING)
|
sensitive = data.get(ATTR_MEDIA_WARNING)
|
||||||
@ -93,34 +112,39 @@ class MastodonNotificationService(BaseNotificationService):
|
|||||||
try:
|
try:
|
||||||
self.client.status_post(
|
self.client.status_post(
|
||||||
message,
|
message,
|
||||||
media_ids=mediadata["id"],
|
|
||||||
sensitive=sensitive,
|
|
||||||
visibility=target,
|
visibility=target,
|
||||||
spoiler_text=content_warning,
|
spoiler_text=content_warning,
|
||||||
|
media_ids=mediadata["id"],
|
||||||
|
sensitive=sensitive,
|
||||||
)
|
)
|
||||||
except MastodonAPIError:
|
except MastodonAPIError as err:
|
||||||
LOGGER.error("Unable to send message")
|
raise HomeAssistantError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="unable_to_send_message",
|
||||||
|
) from err
|
||||||
|
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
self.client.status_post(
|
self.client.status_post(
|
||||||
message, visibility=target, spoiler_text=content_warning
|
message, visibility=target, spoiler_text=content_warning
|
||||||
)
|
)
|
||||||
except MastodonAPIError:
|
except MastodonAPIError as err:
|
||||||
LOGGER.error("Unable to send message")
|
raise HomeAssistantError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="unable_to_send_message",
|
||||||
|
) from err
|
||||||
|
|
||||||
def _upload_media(self, media_path: Any = None) -> Any:
|
def _upload_media(self, media_path: Any = None) -> Any:
|
||||||
"""Upload media."""
|
"""Upload media."""
|
||||||
with open(media_path, "rb"):
|
with open(media_path, "rb"):
|
||||||
media_type = self._media_type(media_path)
|
media_type = get_media_type(media_path)
|
||||||
try:
|
try:
|
||||||
mediadata = self.client.media_post(media_path, mime_type=media_type)
|
mediadata = self.client.media_post(media_path, mime_type=media_type)
|
||||||
except MastodonAPIError:
|
except MastodonAPIError as err:
|
||||||
LOGGER.error("Unable to upload image %s", media_path)
|
raise HomeAssistantError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="unable_to_upload_image",
|
||||||
|
translation_placeholders={"media_path": media_path},
|
||||||
|
) from err
|
||||||
|
|
||||||
return mediadata
|
return mediadata
|
||||||
|
|
||||||
def _media_type(self, media_path: Any = None) -> Any:
|
|
||||||
"""Get media Type."""
|
|
||||||
(media_type, _) = mimetypes.guess_type(media_path)
|
|
||||||
|
|
||||||
return media_type
|
|
||||||
|
@ -29,7 +29,7 @@ rules:
|
|||||||
action-exceptions:
|
action-exceptions:
|
||||||
status: todo
|
status: todo
|
||||||
comment: |
|
comment: |
|
||||||
Legacy Notify needs rewriting once Notify architecture stabilizes.
|
Awaiting legacy Notify deprecation.
|
||||||
config-entry-unloading: done
|
config-entry-unloading: done
|
||||||
docs-configuration-parameters:
|
docs-configuration-parameters:
|
||||||
status: exempt
|
status: exempt
|
||||||
@ -42,7 +42,7 @@ rules:
|
|||||||
parallel-updates:
|
parallel-updates:
|
||||||
status: todo
|
status: todo
|
||||||
comment: |
|
comment: |
|
||||||
Does not set parallel-updates on notify platform.
|
Awaiting legacy Notify deprecation.
|
||||||
reauthentication-flow:
|
reauthentication-flow:
|
||||||
status: todo
|
status: todo
|
||||||
comment: |
|
comment: |
|
||||||
@ -50,7 +50,7 @@ rules:
|
|||||||
test-coverage:
|
test-coverage:
|
||||||
status: todo
|
status: todo
|
||||||
comment: |
|
comment: |
|
||||||
Legacy Notify needs rewriting once Notify architecture stabilizes.
|
Awaiting legacy Notify deprecation.
|
||||||
|
|
||||||
# Gold
|
# Gold
|
||||||
devices: done
|
devices: done
|
||||||
@ -78,7 +78,7 @@ rules:
|
|||||||
entity-device-class: done
|
entity-device-class: done
|
||||||
entity-disabled-by-default: done
|
entity-disabled-by-default: done
|
||||||
entity-translations: done
|
entity-translations: done
|
||||||
exception-translations: todo
|
exception-translations: done
|
||||||
icon-translations: done
|
icon-translations: done
|
||||||
reconfiguration-flow:
|
reconfiguration-flow:
|
||||||
status: todo
|
status: todo
|
||||||
|
142
homeassistant/components/mastodon/services.py
Normal file
142
homeassistant/components/mastodon/services.py
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
"""Define services for the Mastodon integration."""
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
from functools import partial
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from mastodon import Mastodon
|
||||||
|
from mastodon.Mastodon import MastodonAPIError
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
|
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse
|
||||||
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||||
|
|
||||||
|
from . import MastodonConfigEntry
|
||||||
|
from .const import (
|
||||||
|
ATTR_CONFIG_ENTRY_ID,
|
||||||
|
ATTR_CONTENT_WARNING,
|
||||||
|
ATTR_MEDIA,
|
||||||
|
ATTR_MEDIA_WARNING,
|
||||||
|
ATTR_STATUS,
|
||||||
|
ATTR_VISIBILITY,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
from .utils import get_media_type
|
||||||
|
|
||||||
|
|
||||||
|
class StatusVisibility(StrEnum):
|
||||||
|
"""StatusVisibility model."""
|
||||||
|
|
||||||
|
PUBLIC = "public"
|
||||||
|
UNLISTED = "unlisted"
|
||||||
|
PRIVATE = "private"
|
||||||
|
DIRECT = "direct"
|
||||||
|
|
||||||
|
|
||||||
|
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_CONTENT_WARNING): str,
|
||||||
|
vol.Optional(ATTR_MEDIA): str,
|
||||||
|
vol.Optional(ATTR_MEDIA_WARNING): bool,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> MastodonConfigEntry:
|
||||||
|
"""Get the Mastodon config entry."""
|
||||||
|
if not (entry := hass.config_entries.async_get_entry(config_entry_id)):
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="integration_not_found",
|
||||||
|
translation_placeholders={"target": DOMAIN},
|
||||||
|
)
|
||||||
|
if entry.state is not ConfigEntryState.LOADED:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="not_loaded",
|
||||||
|
translation_placeholders={"target": entry.title},
|
||||||
|
)
|
||||||
|
return cast(MastodonConfigEntry, entry)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_services(hass: HomeAssistant) -> None:
|
||||||
|
"""Set up the services for the Mastodon integration."""
|
||||||
|
|
||||||
|
async def async_post(call: ServiceCall) -> ServiceResponse:
|
||||||
|
"""Post a status."""
|
||||||
|
entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID])
|
||||||
|
client = entry.runtime_data.client
|
||||||
|
|
||||||
|
status = call.data[ATTR_STATUS]
|
||||||
|
|
||||||
|
visibility: str | None = (
|
||||||
|
StatusVisibility(call.data[ATTR_VISIBILITY])
|
||||||
|
if ATTR_VISIBILITY in call.data
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
spoiler_text: str | None = call.data.get(ATTR_CONTENT_WARNING)
|
||||||
|
media_path: str | None = call.data.get(ATTR_MEDIA)
|
||||||
|
media_warning: str | None = call.data.get(ATTR_MEDIA_WARNING)
|
||||||
|
|
||||||
|
await hass.async_add_executor_job(
|
||||||
|
partial(
|
||||||
|
_post,
|
||||||
|
client=client,
|
||||||
|
status=status,
|
||||||
|
visibility=visibility,
|
||||||
|
spoiler_text=spoiler_text,
|
||||||
|
media_path=media_path,
|
||||||
|
sensitive=media_warning,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _post(client: Mastodon, **kwargs: Any) -> None:
|
||||||
|
"""Post to Mastodon."""
|
||||||
|
|
||||||
|
media_data: dict[str, Any] | 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)
|
||||||
|
try:
|
||||||
|
media_data = client.media_post(
|
||||||
|
media_file=media_path, mime_type=media_type
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
try:
|
||||||
|
media_ids: str | None = None
|
||||||
|
if media_data:
|
||||||
|
media_ids = media_data["id"]
|
||||||
|
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
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN, SERVICE_POST, async_post, schema=SERVICE_POST_SCHEMA
|
||||||
|
)
|
30
homeassistant/components/mastodon/services.yaml
Normal file
30
homeassistant/components/mastodon/services.yaml
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
post:
|
||||||
|
fields:
|
||||||
|
config_entry_id:
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
config_entry:
|
||||||
|
integration: mastodon
|
||||||
|
status:
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
visibility:
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- public
|
||||||
|
- unlisted
|
||||||
|
- private
|
||||||
|
- direct
|
||||||
|
translation_key: post_visibility
|
||||||
|
content_warning:
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
media:
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
media_warning:
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
boolean:
|
@ -25,6 +25,29 @@
|
|||||||
"unknown": "Unknown error occured when connecting to the Mastodon instance."
|
"unknown": "Unknown error occured when connecting to the Mastodon instance."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"not_loaded": {
|
||||||
|
"message": "{target} is not loaded."
|
||||||
|
},
|
||||||
|
"integration_not_found": {
|
||||||
|
"message": "Integration \"{target}\" not found in registry."
|
||||||
|
},
|
||||||
|
"unable_to_send_message": {
|
||||||
|
"message": "Unable to send message."
|
||||||
|
},
|
||||||
|
"unable_to_upload_image": {
|
||||||
|
"message": "Unable to upload image {media_path}."
|
||||||
|
},
|
||||||
|
"not_whitelisted_directory": {
|
||||||
|
"message": "{media} is not a whitelisted directory."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"issues": {
|
||||||
|
"deprecated_notify_action": {
|
||||||
|
"title": "Deprecated Notify action used for Mastodon",
|
||||||
|
"description": "The Notify action for Mastodon is deprecated.\n\nUse the `mastodon.post` action instead."
|
||||||
|
}
|
||||||
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"sensor": {
|
"sensor": {
|
||||||
"followers": {
|
"followers": {
|
||||||
@ -40,5 +63,47 @@
|
|||||||
"unit_of_measurement": "posts"
|
"unit_of_measurement": "posts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"post": {
|
||||||
|
"name": "Post",
|
||||||
|
"description": "Posts a status on your Mastodon account.",
|
||||||
|
"fields": {
|
||||||
|
"config_entry_id": {
|
||||||
|
"name": "Mastodon account",
|
||||||
|
"description": "Select the Mastodon account to post to."
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "Status",
|
||||||
|
"description": "The status to post."
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"name": "Visibility",
|
||||||
|
"description": "The visibility of the post (default: account setting)."
|
||||||
|
},
|
||||||
|
"content_warning": {
|
||||||
|
"name": "Content warning",
|
||||||
|
"description": "A content warning will be shown before the status text is shown (default: no content warning)."
|
||||||
|
},
|
||||||
|
"media": {
|
||||||
|
"name": "Media",
|
||||||
|
"description": "Attach an image or video to the post."
|
||||||
|
},
|
||||||
|
"media_warning": {
|
||||||
|
"name": "Media warning",
|
||||||
|
"description": "If an image or video is attached, will mark the media as sensitive (default: no media warning)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selector": {
|
||||||
|
"post_visibility": {
|
||||||
|
"options": {
|
||||||
|
"public": "Public - Visible to everyone",
|
||||||
|
"unlisted": "Unlisted - Public but not shown in public timelines",
|
||||||
|
"private": "Private - Followers only",
|
||||||
|
"direct": "Direct - Mentioned accounts only"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import mimetypes
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from mastodon import Mastodon
|
from mastodon import Mastodon
|
||||||
|
|
||||||
from .const import ACCOUNT_USERNAME, DEFAULT_NAME, INSTANCE_DOMAIN, INSTANCE_URI
|
from .const import ACCOUNT_USERNAME, DEFAULT_NAME, INSTANCE_DOMAIN, INSTANCE_URI
|
||||||
@ -30,3 +33,11 @@ def construct_mastodon_username(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return DEFAULT_NAME
|
return DEFAULT_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def get_media_type(media_path: Any = None) -> Any:
|
||||||
|
"""Get media type."""
|
||||||
|
|
||||||
|
(media_type, _) = mimetypes.guess_type(media_path)
|
||||||
|
|
||||||
|
return media_type
|
||||||
|
@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from mastodon.Mastodon import MastodonAPIError
|
||||||
|
import pytest
|
||||||
from syrupy.assertion import SnapshotAssertion
|
from syrupy.assertion import SnapshotAssertion
|
||||||
|
|
||||||
from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
|
from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
from . import setup_integration
|
from . import setup_integration
|
||||||
@ -36,3 +39,27 @@ async def test_notify(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert mock_mastodon_client.status_post.assert_called_once
|
assert mock_mastodon_client.status_post.assert_called_once
|
||||||
|
|
||||||
|
|
||||||
|
async def test_notify_failed(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_mastodon_client: AsyncMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test the notify raising an error."""
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
mock_mastodon_client.status_post.side_effect = MastodonAPIError
|
||||||
|
|
||||||
|
with pytest.raises(HomeAssistantError, match="Unable to send message"):
|
||||||
|
await hass.services.async_call(
|
||||||
|
NOTIFY_DOMAIN,
|
||||||
|
"trwnh_mastodon_social",
|
||||||
|
{
|
||||||
|
"message": "test toot",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
return_response=False,
|
||||||
|
)
|
||||||
|
246
tests/components/mastodon/test_services.py
Normal file
246
tests/components/mastodon/test_services.py
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
"""Tests for the Mastodon services."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
from mastodon.Mastodon import MastodonAPIError
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.mastodon.const import (
|
||||||
|
ATTR_CONFIG_ENTRY_ID,
|
||||||
|
ATTR_CONTENT_WARNING,
|
||||||
|
ATTR_MEDIA,
|
||||||
|
ATTR_STATUS,
|
||||||
|
ATTR_VISIBILITY,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
from homeassistant.components.mastodon.services import SERVICE_POST
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||||
|
|
||||||
|
from . import setup_integration
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("payload", "kwargs"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
{
|
||||||
|
ATTR_STATUS: "test toot",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"status": "test toot",
|
||||||
|
"spoiler_text": None,
|
||||||
|
"visibility": None,
|
||||||
|
"media_ids": None,
|
||||||
|
"sensitive": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{ATTR_STATUS: "test toot", ATTR_VISIBILITY: "private"},
|
||||||
|
{
|
||||||
|
"status": "test toot",
|
||||||
|
"spoiler_text": None,
|
||||||
|
"visibility": "private",
|
||||||
|
"media_ids": None,
|
||||||
|
"sensitive": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{
|
||||||
|
ATTR_STATUS: "test toot",
|
||||||
|
ATTR_CONTENT_WARNING: "Spoiler",
|
||||||
|
ATTR_VISIBILITY: "private",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"status": "test toot",
|
||||||
|
"spoiler_text": "Spoiler",
|
||||||
|
"visibility": "private",
|
||||||
|
"media_ids": None,
|
||||||
|
"sensitive": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{
|
||||||
|
ATTR_STATUS: "test toot",
|
||||||
|
ATTR_CONTENT_WARNING: "Spoiler",
|
||||||
|
ATTR_MEDIA: "/image.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"status": "test toot",
|
||||||
|
"spoiler_text": "Spoiler",
|
||||||
|
"visibility": None,
|
||||||
|
"media_ids": "1",
|
||||||
|
"sensitive": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_service_post(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_mastodon_client: AsyncMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
payload: dict[str, str],
|
||||||
|
kwargs: dict[str, str | None],
|
||||||
|
) -> None:
|
||||||
|
"""Test the post service."""
|
||||||
|
|
||||||
|
await setup_integration(hass, mock_config_entry)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(hass.config, "is_allowed_path", return_value=True),
|
||||||
|
patch.object(mock_mastodon_client, "media_post", return_value={"id": "1"}),
|
||||||
|
):
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_POST,
|
||||||
|
{
|
||||||
|
ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
|
||||||
|
}
|
||||||
|
| payload,
|
||||||
|
blocking=True,
|
||||||
|
return_response=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_mastodon_client.status_post.assert_called_with(**kwargs)
|
||||||
|
|
||||||
|
mock_mastodon_client.status_post.reset_mock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("payload", "kwargs"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
{
|
||||||
|
ATTR_STATUS: "test toot",
|
||||||
|
},
|
||||||
|
{"status": "test toot", "spoiler_text": None, "visibility": None},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{
|
||||||
|
ATTR_STATUS: "test toot",
|
||||||
|
ATTR_CONTENT_WARNING: "Spoiler",
|
||||||
|
ATTR_MEDIA: "/image.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"status": "test toot",
|
||||||
|
"spoiler_text": "Spoiler",
|
||||||
|
"visibility": None,
|
||||||
|
"media_ids": "1",
|
||||||
|
"sensitive": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_post_service_failed(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_mastodon_client: AsyncMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
payload: dict[str, str],
|
||||||
|
kwargs: dict[str, str | None],
|
||||||
|
) -> None:
|
||||||
|
"""Test the post service raising an error."""
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
hass.config.is_allowed_path = Mock(return_value=True)
|
||||||
|
mock_mastodon_client.media_post.return_value = {"id": "1"}
|
||||||
|
|
||||||
|
mock_mastodon_client.status_post.side_effect = MastodonAPIError
|
||||||
|
|
||||||
|
with pytest.raises(HomeAssistantError, match="Unable to send message"):
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_POST,
|
||||||
|
{ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id} | payload,
|
||||||
|
blocking=True,
|
||||||
|
return_response=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_post_media_upload_failed(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_mastodon_client: AsyncMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test the post service raising an error because media upload fails."""
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
payload = {"status": "test toot", "media": "/fail.jpg"}
|
||||||
|
|
||||||
|
mock_mastodon_client.media_post.side_effect = MastodonAPIError
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(hass.config, "is_allowed_path", return_value=True),
|
||||||
|
pytest.raises(HomeAssistantError, match="Unable to upload image /fail.jpg"),
|
||||||
|
):
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_POST,
|
||||||
|
{ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id} | payload,
|
||||||
|
blocking=True,
|
||||||
|
return_response=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_post_path_not_whitelisted(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_mastodon_client: AsyncMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test the post service raising an error because the file path is not whitelisted."""
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
payload = {"status": "test toot", "media": "/fail.jpg"}
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
HomeAssistantError, match="/fail.jpg is not a whitelisted directory"
|
||||||
|
):
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_POST,
|
||||||
|
{ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id} | payload,
|
||||||
|
blocking=True,
|
||||||
|
return_response=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_service_entry_availability(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_mastodon_client: AsyncMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test the services without valid entry."""
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
mock_config_entry2 = MockConfigEntry(domain=DOMAIN)
|
||||||
|
mock_config_entry2.add_to_hass(hass)
|
||||||
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
payload = {"status": "test toot"}
|
||||||
|
|
||||||
|
with pytest.raises(ServiceValidationError, match="Mock Title is not loaded"):
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_POST,
|
||||||
|
{ATTR_CONFIG_ENTRY_ID: mock_config_entry2.entry_id} | payload,
|
||||||
|
blocking=True,
|
||||||
|
return_response=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ServiceValidationError, match='Integration "mastodon" not found in registry'
|
||||||
|
):
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_POST,
|
||||||
|
{ATTR_CONFIG_ENTRY_ID: "bad-config_id"} | payload,
|
||||||
|
blocking=True,
|
||||||
|
return_response=False,
|
||||||
|
)
|
Loading…
x
Reference in New Issue
Block a user