Create individual devices for telegram_bot chats (#176606)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Erik Montnemery
2026-07-16 16:55:02 +02:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent a55511f3fb
commit ac6aedab34
7 changed files with 291 additions and 37 deletions
@@ -3,6 +3,7 @@
import logging
from typing import Protocol, cast
import telegram
from telegram import Bot
from telegram.constants import InputMediaType
from telegram.error import InvalidToken, TelegramError
@@ -33,6 +34,7 @@ from homeassistant.exceptions import (
)
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
@@ -104,6 +106,7 @@ from .const import (
CHAT_ACTION_UPLOAD_VIDEO_NOTE,
CHAT_ACTION_UPLOAD_VOICE,
CONF_API_ENDPOINT,
CONF_CHAT_ID,
CONF_CONFIG_ENTRY_ID,
DEFAULT_API_ENDPOINT,
DOMAIN,
@@ -705,6 +708,50 @@ async def async_migrate_entry(
updated,
)
# version 1.2 -> 1.3: move each chat's notify entity onto its own per-chat device
# (linked to the bot device) and strip the chat subentries from the bot device, leaving
# it associated with only (entry, None).
if version == 1 and config_entry.minor_version < 3:
device_registry = dr.async_get(hass)
entity_registry = er.async_get(hass)
# Up to 1.2 the entry has a single device, the bot device, shared by every chat
devices = dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id
)
if devices:
bot_device = devices[0]
bot_id = next(
identifier
for domain, identifier in bot_device.identifiers
if domain == DOMAIN
)
notify_entities = {
entity.config_subentry_id: entity
for entity in er.async_entries_for_config_entry(
entity_registry, config_entry.entry_id
)
# The event entity (no subentry) stays on the shared bot device
if entity.config_subentry_id is not None
}
for subentry_id, subentry in config_entry.subentries.items():
per_chat_device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
config_subentry_id=subentry_id,
identifiers={(DOMAIN, f"{bot_id}_{subentry.data[CONF_CHAT_ID]}")},
via_device=(DOMAIN, bot_id),
)
if entity := notify_entities.get(subentry_id):
entity_registry.async_update_entity(
entity.entity_id, device_id=per_chat_device.id
)
# Strip this chat's subentry from the bot device, leaving (entry, None)
device_registry.async_update_device(
bot_device.id,
remove_config_entry_id=config_entry.entry_id,
remove_config_subentry_id=subentry_id,
)
hass.config_entries.async_update_entry(config_entry, minor_version=3)
return True
@@ -906,6 +953,18 @@ def _warn_chat_id_migration(service: ServiceCall) -> set[int]:
return chat_ids
def bot_device_info(config_entry: TelegramBotConfigEntry, bot_id: int) -> dr.DeviceInfo:
"""Return device info for the shared bot device."""
return dr.DeviceInfo(
name=config_entry.title,
entry_type=dr.DeviceEntryType.SERVICE,
manufacturer="Telegram",
model=config_entry.data[CONF_PLATFORM].capitalize(),
sw_version=telegram.__version__,
identifiers={(DOMAIN, f"{bot_id}")},
)
async def async_setup_entry(hass: HomeAssistant, entry: TelegramBotConfigEntry) -> bool:
"""Create the Telegram bot from config entry."""
bot: Bot = await hass.async_add_executor_job(initialize_bot, hass, entry.data)
@@ -933,6 +992,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: TelegramBotConfigEntry)
)
entry.runtime_data = notify_service
# Create the bot device before the platforms are set up, so the per-chat devices can
# resolve it as their via_device no matter which platform is set up first
dr.async_get(hass).async_get_or_create(
config_entry_id=entry.entry_id, **bot_device_info(entry, bot.id)
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
@@ -192,7 +192,7 @@ class TelegramBotConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Telegram."""
VERSION = 1
MINOR_VERSION = 2
MINOR_VERSION = 3
@staticmethod
@callback
@@ -1,13 +1,8 @@
"""Base entity for Telegram bot integration."""
import telegram
from homeassistant.const import CONF_PLATFORM
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import Entity, EntityDescription
from . import TelegramBotConfigEntry
from .const import DOMAIN
from . import TelegramBotConfigEntry, bot_device_info
class TelegramBotEntity(Entity):
@@ -28,11 +23,4 @@ class TelegramBotEntity(Entity):
self.service = config_entry.runtime_data
self._attr_unique_id = f"{self.bot_id}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
name=config_entry.title,
entry_type=DeviceEntryType.SERVICE,
manufacturer="Telegram",
model=config_entry.data[CONF_PLATFORM].capitalize(),
sw_version=telegram.__version__,
identifiers={(DOMAIN, f"{self.bot_id}")},
)
self._attr_device_info = bot_device_info(config_entry, self.bot_id)
@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TelegramBotConfigEntry
from .const import ATTR_TITLE, CONF_CHAT_ID
from .const import ATTR_TITLE, CONF_CHAT_ID, DOMAIN
from .entity import TelegramBotEntity
@@ -33,6 +33,7 @@ async def async_setup_entry(
class TelegramBotNotifyEntity(TelegramBotEntity, NotifyEntity):
"""Representation of a telegram bot notification entity."""
_attr_name = None
_attr_supported_features = NotifyEntityFeature.TITLE
def __init__(
@@ -45,7 +46,12 @@ class TelegramBotNotifyEntity(TelegramBotEntity, NotifyEntity):
config_entry, NotifyEntityDescription(key=subentry.data[CONF_CHAT_ID])
)
self.chat_id = subentry.data[CONF_CHAT_ID]
self._attr_name = subentry.title
# Each chat gets its own device (keyed per chat) linked to the shared bot device.
device_info = self._attr_device_info
assert device_info is not None
device_info["identifiers"] = {(DOMAIN, f"{self.bot_id}_{self.chat_id}")}
device_info["name"] = subentry.title
device_info["via_device"] = (DOMAIN, f"{self.bot_id}")
@override
async def async_send_message(self, message: str, title: str | None = None) -> None:
+199 -4
View File
@@ -1,16 +1,21 @@
"""Init tests for the Telegram Bot integration."""
import pytest
from homeassistant.components.telegram_bot.const import (
ATTR_PARSER,
CONF_ALLOWED_CHAT_IDS,
CONF_API_ENDPOINT,
CONF_CHAT_ID,
DEFAULT_API_ENDPOINT,
DOMAIN,
PARSER_MD,
PLATFORM_BROADCAST,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.config_entries import ConfigEntryState, ConfigSubentryData
from homeassistant.const import CONF_API_KEY, CONF_PLATFORM
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from tests.common import MockConfigEntry
@@ -19,7 +24,7 @@ async def test_migration_error(
hass: HomeAssistant,
mock_external_calls: None,
) -> None:
"""Test migrate config entry from 1.1 to 1.2."""
"""Test migrate config entry from unsupported version."""
mock_config_entry = MockConfigEntry(
unique_id="mock api key",
@@ -43,7 +48,7 @@ async def test_migrate_entry_from_1_1(
hass: HomeAssistant,
mock_external_calls: None,
) -> None:
"""Test migrate config entry from 1.1 to 1.2."""
"""Test migrate config entry from 1.1, chaining through to the latest version."""
mock_config_entry = MockConfigEntry(
unique_id="mock api key",
@@ -61,9 +66,199 @@ async def test_migrate_entry_from_1_1(
assert mock_config_entry.state is ConfigEntryState.LOADED
assert mock_config_entry.version == 1
assert mock_config_entry.minor_version == 2
assert mock_config_entry.minor_version == 3
assert mock_config_entry.data == {
CONF_PLATFORM: PLATFORM_BROADCAST,
CONF_API_KEY: "mock api key",
CONF_API_ENDPOINT: DEFAULT_API_ENDPOINT,
}
@pytest.mark.parametrize(
"chats_without_notify_entity",
[
pytest.param((), id="notify entities intact"),
pytest.param((654321,), id="notify entity deleted"),
],
)
async def test_migrate_entry_to_per_chat_devices(
hass: HomeAssistant,
mock_external_calls: None,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
chats_without_notify_entity: tuple[int, ...],
) -> None:
"""Test migrating a shared bot device to per-chat devices."""
bot_id = 123456 # test_user id from mock_external_calls
chat_ids = (123456, 654321)
config_entry = MockConfigEntry(
unique_id="mock api key",
domain=DOMAIN,
minor_version=2,
data={
CONF_PLATFORM: PLATFORM_BROADCAST,
CONF_API_KEY: "mock api key",
CONF_API_ENDPOINT: DEFAULT_API_ENDPOINT,
},
options={ATTR_PARSER: PARSER_MD},
subentries_data=[
ConfigSubentryData(
unique_id="123456",
data={CONF_CHAT_ID: 123456},
subentry_type=CONF_ALLOWED_CHAT_IDS,
title="chat 1",
),
ConfigSubentryData(
unique_id="654321",
data={CONF_CHAT_ID: 654321},
subentry_type=CONF_ALLOWED_CHAT_IDS,
title="chat 2",
),
],
)
config_entry.add_to_hass(hass)
subentry_ids = list(config_entry.subentries)
# Pre-migration state: one shared bot device associated with the config entry (None)
# and every chat subentry, holding the event entity and every chat's notify entity.
bot_device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={(DOMAIN, str(bot_id))},
)
for subentry_id in subentry_ids:
bot_device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
config_subentry_id=subentry_id,
identifiers={(DOMAIN, str(bot_id))},
)
assert bot_device.config_entries_subentries == {
config_entry.entry_id: {None, *subentry_ids}
}
event_entity = entity_registry.async_get_or_create(
"event",
DOMAIN,
f"{bot_id}_update_event",
config_entry=config_entry,
device_id=bot_device.id,
)
notify_entities = {
chat_id: entity_registry.async_get_or_create(
"notify",
DOMAIN,
f"{bot_id}_{chat_id}",
config_entry=config_entry,
config_subentry_id=subentry_id,
device_id=bot_device.id,
)
for subentry_id, chat_id in zip(subentry_ids, chat_ids, strict=True)
if chat_id not in chats_without_notify_entity
}
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
assert config_entry.minor_version == 3
# Each chat has its own device, owned by that chat's subentry and linked to the bot
# device.
chat_devices = {
chat_id: device_registry.async_get_device(
identifiers={(DOMAIN, f"{bot_id}_{chat_id}")}
)
for chat_id in chat_ids
}
for subentry_id, chat_id in zip(subentry_ids, chat_ids, strict=True):
chat_device = chat_devices[chat_id]
assert chat_device is not None
assert chat_device.config_entries_subentries == {
config_entry.entry_id: {subentry_id}
}
assert chat_device.via_device_id == bot_device.id
# Every notify entity that survived is moved onto its chat's device
for chat_id, notify_entity in notify_entities.items():
assert (
entity_registry.async_get(notify_entity.entity_id).device_id
== chat_devices[chat_id].id
)
# The bot device ends up associated with only (entry, None), keeping the event entity
bot_device = device_registry.async_get(bot_device.id)
assert bot_device is not None
assert bot_device.config_entries_subentries == {config_entry.entry_id: {None}}
assert entity_registry.async_get(event_entity.entity_id).device_id == bot_device.id
async def test_per_chat_devices(
hass: HomeAssistant,
mock_broadcast_config_entry: MockConfigEntry,
mock_external_calls: None,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Each chat gets its own device linked to the config-entry-level bot device."""
mock_broadcast_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id)
await hass.async_block_till_done()
entry_id = mock_broadcast_config_entry.entry_id
# The bot device belongs to the config entry (no subentry) and holds the event entity
bot_device = device_registry.async_get_device(identifiers={(DOMAIN, "123456")})
assert bot_device is not None
assert bot_device.config_entries_subentries == {entry_id: {None}}
assert bot_device.name == "Mock Title"
for chat_id, chat_name in ((123456, "mock chat 1"), (654321, "mock chat 2")):
subentry_id = next(
sid
for sid, subentry in mock_broadcast_config_entry.subentries.items()
if subentry.data[CONF_CHAT_ID] == chat_id
)
chat_device = device_registry.async_get_device(
identifiers={(DOMAIN, f"123456_{chat_id}")}
)
assert chat_device is not None
assert chat_device.config_entries_subentries == {entry_id: {subentry_id}}
assert chat_device.via_device_id == bot_device.id
# The device is named after the chat, and its notify entity takes the device name
assert chat_device.name == chat_name
notify_entity_id = entity_registry.async_get_entity_id(
"notify", DOMAIN, f"123456_{chat_id}"
)
assert notify_entity_id is not None
assert entity_registry.async_get(notify_entity_id).device_id == chat_device.id
assert hass.states.get(notify_entity_id).name == chat_name
async def test_remove_chat_subentry_removes_per_chat_device(
hass: HomeAssistant,
mock_broadcast_config_entry: MockConfigEntry,
mock_external_calls: None,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Removing a chat subentry removes just its per-chat device and notify entity."""
mock_broadcast_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id)
await hass.async_block_till_done()
subentry_id = next(
sid
for sid, subentry in mock_broadcast_config_entry.subentries.items()
if subentry.data[CONF_CHAT_ID] == 123456
)
assert device_registry.async_get_device(identifiers={(DOMAIN, "123456_123456")})
assert entity_registry.async_get_entity_id("notify", DOMAIN, "123456_123456")
hass.config_entries.async_remove_subentry(mock_broadcast_config_entry, subentry_id)
await hass.async_block_till_done()
# The removed chat's device and notify entity are gone; the other chat and the bot
# device remain
assert not device_registry.async_get_device(identifiers={(DOMAIN, "123456_123456")})
assert not entity_registry.async_get_entity_id("notify", DOMAIN, "123456_123456")
assert device_registry.async_get_device(identifiers={(DOMAIN, "123456_654321")})
assert device_registry.async_get_device(identifiers={(DOMAIN, "123456")})
+2 -2
View File
@@ -43,7 +43,7 @@ async def test_send_message(
NOTIFY_DOMAIN,
SERVICE_SEND_MESSAGE,
{
ATTR_ENTITY_ID: "notify.mock_title_mock_chat",
ATTR_ENTITY_ID: "notify.mock_chat",
ATTR_MESSAGE: "mock message",
ATTR_TITLE: "mock title",
},
@@ -64,7 +64,7 @@ async def test_send_message(
message_thread_id=None,
)
state = hass.states.get("notify.mock_title_mock_chat")
state = hass.states.get("notify.mock_chat")
assert state
assert state.state == "2025-01-09T12:00:00+00:00"
@@ -224,7 +224,7 @@ async def test_send_message(
{
ATTR_CHAT_ID: 12345678,
ATTR_MESSAGE_ID: 12345,
ATTR_ENTITY_ID: "notify.mock_title_mock_chat",
ATTR_ENTITY_ID: "notify.mock_chat",
}
]
}
@@ -322,7 +322,7 @@ async def test_send_message_with_inline_keyboard(
{
ATTR_CHAT_ID: 12345678,
ATTR_MESSAGE_ID: 12345,
ATTR_ENTITY_ID: "notify.mock_title_mock_chat",
ATTR_ENTITY_ID: "notify.mock_chat",
}
]
}
@@ -368,9 +368,9 @@ async def test_send_sticker_partial_error(
assert err.value.translation_key == "multiple_errors"
assert err.value.translation_placeholders == {
"errors": (
"`entity_id` notify.mock_title_mock_chat_1:"
"`entity_id` notify.mock_chat_1:"
" mock network error\n"
"`entity_id` notify.mock_title_mock_chat_2:"
"`entity_id` notify.mock_chat_2:"
" mock network error"
)
}
@@ -588,7 +588,7 @@ async def test_send_file(hass: HomeAssistant, webhook_bot, service: str) -> None
{
ATTR_CHAT_ID: 12345678,
ATTR_MESSAGE_ID: 12345,
ATTR_ENTITY_ID: "notify.mock_title_mock_chat",
ATTR_ENTITY_ID: "notify.mock_chat",
}
]
}
@@ -1076,7 +1076,7 @@ async def test_send_message_with_config_entry(
{
ATTR_CHAT_ID: 123456,
ATTR_MESSAGE_ID: 12345,
ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1",
ATTR_ENTITY_ID: "notify.mock_chat_1",
}
]
}
@@ -1187,7 +1187,7 @@ async def test_delete_message(
{
ATTR_CHAT_ID: 123456,
ATTR_MESSAGE_ID: 12345,
ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1",
ATTR_ENTITY_ID: "notify.mock_chat_1",
}
]
}
@@ -1616,7 +1616,7 @@ async def test_send_video(
{
ATTR_CHAT_ID: 123456,
ATTR_MESSAGE_ID: 12345,
ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1",
ATTR_ENTITY_ID: "notify.mock_chat_1",
}
]
}
@@ -1648,7 +1648,7 @@ async def test_send_video(
{
ATTR_CHAT_ID: 123456,
ATTR_MESSAGE_ID: 12345,
ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1",
ATTR_ENTITY_ID: "notify.mock_chat_1",
}
]
}
@@ -1837,7 +1837,7 @@ async def test_send_message_multi_target(
{
ATTR_CHAT_ID: 654321,
ATTR_MESSAGE_ID: 12345,
ATTR_ENTITY_ID: "notify.mock_title_mock_chat_2",
ATTR_ENTITY_ID: "notify.mock_chat_2",
}
]
}
@@ -1857,7 +1857,7 @@ async def test_notify_entity_send_message(
response = await hass.services.async_call(
DOMAIN,
SERVICE_SEND_MESSAGE,
{ATTR_ENTITY_ID: "notify.mock_title_mock_chat_2", ATTR_MESSAGE: "test_message"},
{ATTR_ENTITY_ID: "notify.mock_chat_2", ATTR_MESSAGE: "test_message"},
blocking=True,
return_response=True,
)
@@ -1867,7 +1867,7 @@ async def test_notify_entity_send_message(
{
ATTR_CHAT_ID: 654321,
ATTR_MESSAGE_ID: 12345,
ATTR_ENTITY_ID: "notify.mock_title_mock_chat_2",
ATTR_ENTITY_ID: "notify.mock_chat_2",
}
]
}
@@ -1921,7 +1921,7 @@ async def test_migrate_chat_id(
{
ATTR_CHAT_ID: 654321,
ATTR_MESSAGE_ID: 12345,
ATTR_ENTITY_ID: "notify.mock_title_mock_chat_2",
ATTR_ENTITY_ID: "notify.mock_chat_2",
}
]
}
@@ -2616,7 +2616,7 @@ async def test_send_media_group(
"chats": [
{
ATTR_CHAT_ID: 123456,
ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1",
ATTR_ENTITY_ID: "notify.mock_chat_1",
ATTR_MESSAGE_ID: [12345, 12346, 12347, 12348],
}
]