diff --git a/.coveragerc b/.coveragerc index 33a66d202ca..54502229fe3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -217,6 +217,7 @@ omit = homeassistant/components/digital_ocean/* homeassistant/components/digitalloggers/switch.py homeassistant/components/discogs/sensor.py + homeassistant/components/discord/__init__.py homeassistant/components/discord/notify.py homeassistant/components/dlib_face_detect/image_processing.py homeassistant/components/dlib_face_identify/image_processing.py diff --git a/CODEOWNERS b/CODEOWNERS index 4c04e307949..731979781d4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -235,6 +235,8 @@ build.json @home-assistant/supervisor /tests/components/diagnostics/ @home-assistant/core /homeassistant/components/digital_ocean/ @fabaff /homeassistant/components/discogs/ @thibmaek +/homeassistant/components/discord/ @tkdrob +/tests/components/discord/ @tkdrob /homeassistant/components/discovery/ @home-assistant/core /tests/components/discovery/ @home-assistant/core /homeassistant/components/dlna_dmr/ @StevenLooman @chishm diff --git a/homeassistant/components/discord/__init__.py b/homeassistant/components/discord/__init__.py index 67b9f1b39ba..ec41ac9d073 100644 --- a/homeassistant/components/discord/__init__.py +++ b/homeassistant/components/discord/__init__.py @@ -1 +1,57 @@ """The discord integration.""" +from aiohttp.client_exceptions import ClientConnectorError +import nextcord + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_API_TOKEN, CONF_PLATFORM, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import discovery +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN + +PLATFORMS = [Platform.NOTIFY] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Discord component.""" + # Iterate all entries for notify to only get Discord + if Platform.NOTIFY in config: + for entry in config[Platform.NOTIFY]: + if entry[CONF_PLATFORM] == DOMAIN: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=entry + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Discord from a config entry.""" + nextcord.VoiceClient.warn_nacl = False + discord_bot = nextcord.Client() + try: + await discord_bot.login(entry.data[CONF_API_TOKEN]) + except nextcord.LoginFailure as ex: + raise ConfigEntryAuthFailed("Invalid token given") from ex + except (ClientConnectorError, nextcord.HTTPException, nextcord.NotFound) as ex: + raise ConfigEntryNotReady("Failed to connect") from ex + finally: + await discord_bot.close() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = entry.data + + hass.async_create_task( + discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + hass.data[DOMAIN][entry.entry_id], + hass.data[DOMAIN], + ) + ) + + return True diff --git a/homeassistant/components/discord/config_flow.py b/homeassistant/components/discord/config_flow.py new file mode 100644 index 00000000000..8abd2a6be37 --- /dev/null +++ b/homeassistant/components/discord/config_flow.py @@ -0,0 +1,112 @@ +"""Config flow for Discord integration.""" +from __future__ import annotations + +import logging + +from aiohttp.client_exceptions import ClientConnectorError +import nextcord +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN, URL_PLACEHOLDER + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema({vol.Required(CONF_API_TOKEN): str}) + + +class DiscordFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Discord.""" + + async def async_step_reauth(self, user_input: dict | None = None) -> FlowResult: + """Handle a reauthorization flow request.""" + if user_input is not None: + return await self.async_step_reauth_confirm() + + self._set_confirm_only() + return self.async_show_form(step_id="reauth") + + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + errors = {} + + if user_input: + error, info = await _async_try_connect(user_input[CONF_API_TOKEN]) + if info and (entry := await self.async_set_unique_id(str(info.id))): + self.hass.config_entries.async_update_entry( + entry, data=entry.data | user_input + ) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + if error: + errors["base"] = error + + user_input = user_input or {} + return self.async_show_form( + step_id="reauth_confirm", + data_schema=CONFIG_SCHEMA, + description_placeholders=URL_PLACEHOLDER, + errors=errors, + ) + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is not None: + error, info = await _async_try_connect(user_input[CONF_API_TOKEN]) + if error is not None: + errors["base"] = error + elif info is not None: + await self.async_set_unique_id(str(info.id)) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=info.name, + data=user_input | {CONF_NAME: user_input.get(CONF_NAME, info.name)}, + ) + + user_input = user_input or {} + return self.async_show_form( + step_id="user", + data_schema=CONFIG_SCHEMA, + description_placeholders=URL_PLACEHOLDER, + errors=errors, + ) + + async def async_step_import(self, import_config: dict[str, str]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + _LOGGER.warning( + "Configuration of the Discord integration in YAML is deprecated and " + "will be removed in Home Assistant 2022.6; Your existing configuration " + "has been imported into the UI automatically and can be safely removed " + "from your configuration.yaml file" + ) + for entry in self._async_current_entries(): + if entry.data[CONF_API_TOKEN] == import_config[CONF_TOKEN]: + return self.async_abort(reason="already_configured") + import_config[CONF_API_TOKEN] = import_config.pop(CONF_TOKEN) + return await self.async_step_user(import_config) + + +async def _async_try_connect(token: str) -> tuple[str | None, nextcord.AppInfo | None]: + """Try connecting to Discord.""" + discord_bot = nextcord.Client() + try: + await discord_bot.login(token) + info = await discord_bot.application_info() + except nextcord.LoginFailure: + return "invalid_auth", None + except (ClientConnectorError, nextcord.HTTPException, nextcord.NotFound): + return "cannot_connect", None + except Exception as ex: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception: %s", ex) + return "unknown", None + await discord_bot.close() + return None, info diff --git a/homeassistant/components/discord/const.py b/homeassistant/components/discord/const.py new file mode 100644 index 00000000000..9f11c3e2d7a --- /dev/null +++ b/homeassistant/components/discord/const.py @@ -0,0 +1,10 @@ +"""Constants for the Discord integration.""" + +from typing import Final + +from homeassistant.const import CONF_URL + +DEFAULT_NAME = "Discord" +DOMAIN: Final = "discord" + +URL_PLACEHOLDER = {CONF_URL: "https://www.home-assistant.io/integrations/discord"} diff --git a/homeassistant/components/discord/manifest.json b/homeassistant/components/discord/manifest.json index 02b31a3aa99..b631c5fa7e7 100644 --- a/homeassistant/components/discord/manifest.json +++ b/homeassistant/components/discord/manifest.json @@ -1,9 +1,10 @@ { "domain": "discord", "name": "Discord", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/discord", "requirements": ["nextcord==2.0.0a8"], - "codeowners": [], + "codeowners": ["@tkdrob"], "iot_class": "cloud_push", "loggers": ["discord"] } diff --git a/homeassistant/components/discord/notify.py b/homeassistant/components/discord/notify.py index 7c4b10f5662..098857876a1 100644 --- a/homeassistant/components/discord/notify.py +++ b/homeassistant/components/discord/notify.py @@ -3,8 +3,10 @@ from __future__ import annotations import logging import os.path +from typing import Any, cast import nextcord +from nextcord.abc import Messageable import voluptuous as vol from homeassistant.components.notify import ( @@ -13,8 +15,10 @@ from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.const import CONF_TOKEN +from homeassistant.const import CONF_API_TOKEN, CONF_TOKEN +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -29,24 +33,30 @@ ATTR_EMBED_THUMBNAIL = "thumbnail" ATTR_EMBED_URL = "url" ATTR_IMAGES = "images" +# Deprecated in Home Assistant 2022.4 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_TOKEN): cv.string}) -def get_service(hass, config, discovery_info=None): +async def async_get_service( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> DiscordNotificationService | None: """Get the Discord notification service.""" - token = config[CONF_TOKEN] - return DiscordNotificationService(hass, token) + if discovery_info is None: + return None + return DiscordNotificationService(hass, discovery_info[CONF_API_TOKEN]) class DiscordNotificationService(BaseNotificationService): """Implement the notification service for Discord.""" - def __init__(self, hass, token): + def __init__(self, hass: HomeAssistant, token: str) -> None: """Initialize the service.""" self.token = token self.hass = hass - def file_exists(self, filename): + def file_exists(self, filename: str) -> bool: """Check if a file exists on disk and is in authorized path.""" if not self.hass.config.is_allowed_path(filename): _LOGGER.warning("Path not allowed: %s", filename) @@ -56,7 +66,7 @@ class DiscordNotificationService(BaseNotificationService): return False return True - async def async_send_message(self, message, **kwargs): + async def async_send_message(self, message: str, **kwargs: Any) -> None: """Login to Discord, send message to channel(s) and log out.""" nextcord.VoiceClient.warn_nacl = False discord_bot = nextcord.Client() @@ -108,16 +118,18 @@ class DiscordNotificationService(BaseNotificationService): try: for channelid in kwargs[ATTR_TARGET]: channelid = int(channelid) + # Must create new instances of File for each channel. + files = [nextcord.File(image) for image in images] if images else [] try: - channel = await discord_bot.fetch_channel(channelid) + channel = cast( + Messageable, await discord_bot.fetch_channel(channelid) + ) except nextcord.NotFound: try: channel = await discord_bot.fetch_user(channelid) except nextcord.NotFound: _LOGGER.warning("Channel not found for ID: %s", channelid) continue - # Must create new instances of File for each channel. - files = [nextcord.File(image) for image in images] if images else [] await channel.send(message, files=files, embeds=embeds) except (nextcord.HTTPException, nextcord.NotFound) as error: _LOGGER.warning("Communication error: %s", error) diff --git a/homeassistant/components/discord/strings.json b/homeassistant/components/discord/strings.json new file mode 100644 index 00000000000..5b1bf409fa5 --- /dev/null +++ b/homeassistant/components/discord/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "user": { + "description": "Refer to the documentation on getting your Discord bot key.\n\n{url}", + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" + } + }, + "reauth_confirm": { + "description": "Refer to the documentation on getting your Discord bot key.\n\n{url}", + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } + } + \ No newline at end of file diff --git a/homeassistant/components/discord/translations/en.json b/homeassistant/components/discord/translations/en.json new file mode 100644 index 00000000000..77e16e9312d --- /dev/null +++ b/homeassistant/components/discord/translations/en.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Service is already configured", + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "api_token": "API Token" + }, + "description": "Refer to the documentation on getting your Discord bot key.\n\n{url}" + }, + "reauth_confirm": { + "data": { + "api_token": "API Token" + }, + "description": "Refer to the documentation on getting your Discord bot key.\n\n{url}" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index edfd623344b..7cfd326a249 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -73,6 +73,7 @@ FLOWS = { "dexcom", "dialogflow", "directv", + "discord", "dlna_dmr", "dlna_dms", "dnsip", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e0731b9fe6e..f91dc53d5a5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -714,6 +714,9 @@ nettigo-air-monitor==1.2.1 # homeassistant.components.nexia nexia==0.9.13 +# homeassistant.components.discord +nextcord==2.0.0a8 + # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 diff --git a/tests/components/discord/__init__.py b/tests/components/discord/__init__.py new file mode 100644 index 00000000000..ebc23360555 --- /dev/null +++ b/tests/components/discord/__init__.py @@ -0,0 +1,60 @@ +"""Tests for the Discord integration.""" + +from unittest.mock import AsyncMock, Mock, patch + +import nextcord + +from homeassistant.components.discord.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_TOKEN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TOKEN = "abc123" +NAME = "Discord Bot" + +CONF_INPUT = {CONF_API_TOKEN: TOKEN} + +CONF_DATA = { + CONF_API_TOKEN: TOKEN, + CONF_NAME: NAME, +} + +CONF_IMPORT_DATA_NO_NAME = {CONF_TOKEN: TOKEN} + +CONF_IMPORT_DATA = CONF_IMPORT_DATA_NO_NAME | {CONF_NAME: NAME} + + +def create_entry(hass: HomeAssistant) -> ConfigEntry: + """Add config entry in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONF_DATA, + unique_id="1234567890", + ) + entry.add_to_hass(hass) + return entry + + +def mocked_discord_info(): + """Create mocked discord.""" + mocked_discord = AsyncMock() + mocked_discord.id = "1234567890" + mocked_discord.name = NAME + return patch( + "homeassistant.components.discord.config_flow.nextcord.Client.application_info", + return_value=mocked_discord, + ) + + +def patch_discord_login(): + """Patch discord info.""" + return patch("homeassistant.components.discord.config_flow.nextcord.Client.login") + + +def mock_exception(): + """Mock response.""" + response = Mock() + response.status = 404 + return nextcord.HTTPException(response, "") diff --git a/tests/components/discord/test_config_flow.py b/tests/components/discord/test_config_flow.py new file mode 100644 index 00000000000..64588b052fe --- /dev/null +++ b/tests/components/discord/test_config_flow.py @@ -0,0 +1,206 @@ +"""Test Discord config flow.""" +import nextcord +from pytest import LogCaptureFixture + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.discord.const import DOMAIN +from homeassistant.const import CONF_API_TOKEN, CONF_SOURCE +from homeassistant.core import HomeAssistant + +from . import ( + CONF_DATA, + CONF_IMPORT_DATA, + CONF_IMPORT_DATA_NO_NAME, + CONF_INPUT, + NAME, + create_entry, + mock_exception, + mocked_discord_info, + patch_discord_login, +) + + +async def test_flow_user(hass: HomeAssistant) -> None: + """Test user initialized flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + with mocked_discord_info(), patch_discord_login(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == NAME + assert result["data"] == CONF_DATA + + +async def test_flow_user_already_configured(hass: HomeAssistant) -> None: + """Test user initialized flow with duplicate server.""" + create_entry(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + with mocked_discord_info(), patch_discord_login(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_user_invalid_auth(hass: HomeAssistant) -> None: + """Test user initialized flow with invalid token.""" + with patch_discord_login() as mock: + mock.side_effect = nextcord.LoginFailure + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=CONF_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + with mocked_discord_info(), patch_discord_login(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == NAME + assert result["data"] == CONF_DATA + + +async def test_flow_user_cannot_connect(hass: HomeAssistant) -> None: + """Test user initialized flow with unreachable server.""" + with patch_discord_login() as mock: + mock.side_effect = mock_exception() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=CONF_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + with mocked_discord_info(), patch_discord_login(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == NAME + assert result["data"] == CONF_DATA + + +async def test_flow_user_unknown_error(hass: HomeAssistant) -> None: + """Test user initialized flow with unreachable server.""" + with patch_discord_login() as mock: + mock.side_effect = Exception + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=CONF_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "unknown"} + + with mocked_discord_info(), patch_discord_login(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == NAME + assert result["data"] == CONF_DATA + + +async def test_flow_import(hass: HomeAssistant, caplog: LogCaptureFixture) -> None: + """Test an import flow.""" + with mocked_discord_info(), patch_discord_login(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=CONF_IMPORT_DATA.copy(), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == NAME + assert result["data"] == CONF_DATA + assert "Discord integration in YAML" in caplog.text + + +async def test_flow_import_no_name(hass: HomeAssistant) -> None: + """Test import flow with no name in config.""" + with mocked_discord_info(), patch_discord_login(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=CONF_IMPORT_DATA_NO_NAME, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == NAME + assert result["data"] == CONF_DATA + + +async def test_flow_import_already_configured(hass: HomeAssistant) -> None: + """Test an import flow already configured.""" + create_entry(hass) + with mocked_discord_info(), patch_discord_login(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=CONF_IMPORT_DATA, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_reauth(hass: HomeAssistant) -> None: + """Test a reauth flow.""" + entry = create_entry(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + CONF_SOURCE: config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + new_conf = {CONF_API_TOKEN: "1234567890123"} + with patch_discord_login() as mock: + mock.side_effect = nextcord.LoginFailure + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=new_conf, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + with mocked_discord_info(), patch_discord_login(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=new_conf, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + assert entry.data == CONF_DATA | new_conf