Add config flow to discord (#61069)

This commit is contained in:
Robert Hillis 2022-03-30 07:18:48 -04:00 committed by GitHub
parent 93706ca21a
commit 9f0665bbb4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 530 additions and 11 deletions

View File

@ -217,6 +217,7 @@ omit =
homeassistant/components/digital_ocean/* homeassistant/components/digital_ocean/*
homeassistant/components/digitalloggers/switch.py homeassistant/components/digitalloggers/switch.py
homeassistant/components/discogs/sensor.py homeassistant/components/discogs/sensor.py
homeassistant/components/discord/__init__.py
homeassistant/components/discord/notify.py homeassistant/components/discord/notify.py
homeassistant/components/dlib_face_detect/image_processing.py homeassistant/components/dlib_face_detect/image_processing.py
homeassistant/components/dlib_face_identify/image_processing.py homeassistant/components/dlib_face_identify/image_processing.py

View File

@ -235,6 +235,8 @@ build.json @home-assistant/supervisor
/tests/components/diagnostics/ @home-assistant/core /tests/components/diagnostics/ @home-assistant/core
/homeassistant/components/digital_ocean/ @fabaff /homeassistant/components/digital_ocean/ @fabaff
/homeassistant/components/discogs/ @thibmaek /homeassistant/components/discogs/ @thibmaek
/homeassistant/components/discord/ @tkdrob
/tests/components/discord/ @tkdrob
/homeassistant/components/discovery/ @home-assistant/core /homeassistant/components/discovery/ @home-assistant/core
/tests/components/discovery/ @home-assistant/core /tests/components/discovery/ @home-assistant/core
/homeassistant/components/dlna_dmr/ @StevenLooman @chishm /homeassistant/components/dlna_dmr/ @StevenLooman @chishm

View File

@ -1 +1,57 @@
"""The discord integration.""" """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

View File

@ -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

View File

@ -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"}

View File

@ -1,9 +1,10 @@
{ {
"domain": "discord", "domain": "discord",
"name": "Discord", "name": "Discord",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/discord", "documentation": "https://www.home-assistant.io/integrations/discord",
"requirements": ["nextcord==2.0.0a8"], "requirements": ["nextcord==2.0.0a8"],
"codeowners": [], "codeowners": ["@tkdrob"],
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["discord"] "loggers": ["discord"]
} }

View File

@ -3,8 +3,10 @@ from __future__ import annotations
import logging import logging
import os.path import os.path
from typing import Any, cast
import nextcord import nextcord
from nextcord.abc import Messageable
import voluptuous as vol import voluptuous as vol
from homeassistant.components.notify import ( from homeassistant.components.notify import (
@ -13,8 +15,10 @@ from homeassistant.components.notify import (
PLATFORM_SCHEMA, PLATFORM_SCHEMA,
BaseNotificationService, 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 import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -29,24 +33,30 @@ ATTR_EMBED_THUMBNAIL = "thumbnail"
ATTR_EMBED_URL = "url" ATTR_EMBED_URL = "url"
ATTR_IMAGES = "images" ATTR_IMAGES = "images"
# Deprecated in Home Assistant 2022.4
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_TOKEN): cv.string}) 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.""" """Get the Discord notification service."""
token = config[CONF_TOKEN] if discovery_info is None:
return DiscordNotificationService(hass, token) return None
return DiscordNotificationService(hass, discovery_info[CONF_API_TOKEN])
class DiscordNotificationService(BaseNotificationService): class DiscordNotificationService(BaseNotificationService):
"""Implement the notification service for Discord.""" """Implement the notification service for Discord."""
def __init__(self, hass, token): def __init__(self, hass: HomeAssistant, token: str) -> None:
"""Initialize the service.""" """Initialize the service."""
self.token = token self.token = token
self.hass = hass 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.""" """Check if a file exists on disk and is in authorized path."""
if not self.hass.config.is_allowed_path(filename): if not self.hass.config.is_allowed_path(filename):
_LOGGER.warning("Path not allowed: %s", filename) _LOGGER.warning("Path not allowed: %s", filename)
@ -56,7 +66,7 @@ class DiscordNotificationService(BaseNotificationService):
return False return False
return True 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.""" """Login to Discord, send message to channel(s) and log out."""
nextcord.VoiceClient.warn_nacl = False nextcord.VoiceClient.warn_nacl = False
discord_bot = nextcord.Client() discord_bot = nextcord.Client()
@ -108,16 +118,18 @@ class DiscordNotificationService(BaseNotificationService):
try: try:
for channelid in kwargs[ATTR_TARGET]: for channelid in kwargs[ATTR_TARGET]:
channelid = int(channelid) channelid = int(channelid)
# Must create new instances of File for each channel.
files = [nextcord.File(image) for image in images] if images else []
try: try:
channel = await discord_bot.fetch_channel(channelid) channel = cast(
Messageable, await discord_bot.fetch_channel(channelid)
)
except nextcord.NotFound: except nextcord.NotFound:
try: try:
channel = await discord_bot.fetch_user(channelid) channel = await discord_bot.fetch_user(channelid)
except nextcord.NotFound: except nextcord.NotFound:
_LOGGER.warning("Channel not found for ID: %s", channelid) _LOGGER.warning("Channel not found for ID: %s", channelid)
continue 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) await channel.send(message, files=files, embeds=embeds)
except (nextcord.HTTPException, nextcord.NotFound) as error: except (nextcord.HTTPException, nextcord.NotFound) as error:
_LOGGER.warning("Communication error: %s", error) _LOGGER.warning("Communication error: %s", error)

View File

@ -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%]"
}
}
}

View File

@ -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}"
}
}
}
}

View File

@ -73,6 +73,7 @@ FLOWS = {
"dexcom", "dexcom",
"dialogflow", "dialogflow",
"directv", "directv",
"discord",
"dlna_dmr", "dlna_dmr",
"dlna_dms", "dlna_dms",
"dnsip", "dnsip",

View File

@ -714,6 +714,9 @@ nettigo-air-monitor==1.2.1
# homeassistant.components.nexia # homeassistant.components.nexia
nexia==0.9.13 nexia==0.9.13
# homeassistant.components.discord
nextcord==2.0.0a8
# homeassistant.components.nfandroidtv # homeassistant.components.nfandroidtv
notifications-android-tv==0.1.5 notifications-android-tv==0.1.5

View File

@ -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, "")

View File

@ -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