Add actions for quest handling to Habitica (#129650)

pull/130238/head
Manu 2024-11-09 19:34:25 +01:00 committed by GitHub
parent 21d81d5a5c
commit 5d0277a0d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 282 additions and 5 deletions

View File

@ -26,7 +26,12 @@ ATTR_CONFIG_ENTRY = "config_entry"
ATTR_SKILL = "skill"
ATTR_TASK = "task"
SERVICE_CAST_SKILL = "cast_skill"
SERVICE_START_QUEST = "start_quest"
SERVICE_ACCEPT_QUEST = "accept_quest"
SERVICE_CANCEL_QUEST = "cancel_quest"
SERVICE_ABORT_QUEST = "abort_quest"
SERVICE_REJECT_QUEST = "reject_quest"
SERVICE_LEAVE_QUEST = "leave_quest"
WARRIOR = "warrior"
ROGUE = "rogue"
HEALER = "healer"

View File

@ -163,6 +163,24 @@
},
"cast_skill": {
"service": "mdi:creation-outline"
},
"accept_quest": {
"service": "mdi:script-text"
},
"reject_quest": {
"service": "mdi:script-text"
},
"leave_quest": {
"service": "mdi:script-text"
},
"abort_quest": {
"service": "mdi:script-text-key"
},
"cancel_quest": {
"service": "mdi:script-text-key"
},
"start_quest": {
"service": "mdi:script-text-key"
}
}
}

View File

@ -30,8 +30,14 @@ from .const import (
ATTR_TASK,
DOMAIN,
EVENT_API_CALL_SUCCESS,
SERVICE_ABORT_QUEST,
SERVICE_ACCEPT_QUEST,
SERVICE_API_CALL,
SERVICE_CANCEL_QUEST,
SERVICE_CAST_SKILL,
SERVICE_LEAVE_QUEST,
SERVICE_REJECT_QUEST,
SERVICE_START_QUEST,
)
from .types import HabiticaConfigEntry
@ -54,6 +60,12 @@ SERVICE_CAST_SKILL_SCHEMA = vol.Schema(
}
)
SERVICE_MANAGE_QUEST_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
}
)
def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry:
"""Return config entry or raise if not found or not loaded."""
@ -160,6 +172,57 @@ def async_setup_services(hass: HomeAssistant) -> None:
await coordinator.async_request_refresh()
return response
async def manage_quests(call: ServiceCall) -> ServiceResponse:
"""Accept, reject, start, leave or cancel quests."""
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
coordinator = entry.runtime_data
COMMAND_MAP = {
SERVICE_ABORT_QUEST: "abort",
SERVICE_ACCEPT_QUEST: "accept",
SERVICE_CANCEL_QUEST: "cancel",
SERVICE_LEAVE_QUEST: "leave",
SERVICE_REJECT_QUEST: "reject",
SERVICE_START_QUEST: "force-start",
}
try:
return await coordinator.api.groups.party.quests[
COMMAND_MAP[call.service]
].post()
except ClientResponseError as e:
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
if e.status == HTTPStatus.UNAUTHORIZED:
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="quest_action_unallowed"
) from e
if e.status == HTTPStatus.NOT_FOUND:
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="quest_not_found"
) from e
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="service_call_exception"
) from e
for service in (
SERVICE_ABORT_QUEST,
SERVICE_ACCEPT_QUEST,
SERVICE_CANCEL_QUEST,
SERVICE_LEAVE_QUEST,
SERVICE_REJECT_QUEST,
SERVICE_START_QUEST,
):
hass.services.async_register(
DOMAIN,
service,
manage_quests,
schema=SERVICE_MANAGE_QUEST_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_API_CALL,

View File

@ -17,7 +17,7 @@ api_call:
object:
cast_skill:
fields:
config_entry:
config_entry: &config_entry
required: true
selector:
config_entry:
@ -37,3 +37,21 @@ cast_skill:
required: true
selector:
text:
accept_quest:
fields:
config_entry: *config_entry
reject_quest:
fields:
config_entry: *config_entry
start_quest:
fields:
config_entry: *config_entry
cancel_quest:
fields:
config_entry: *config_entry
abort_quest:
fields:
config_entry: *config_entry
leave_quest:
fields:
config_entry: *config_entry

View File

@ -1,7 +1,8 @@
{
"common": {
"todos": "To-Do's",
"dailies": "Dailies"
"dailies": "Dailies",
"config_entry_name": "Select character"
},
"config": {
"abort": {
@ -311,6 +312,12 @@
},
"task_not_found": {
"message": "Unable to cast skill, could not find the task {task}"
},
"quest_action_unallowed": {
"message": "Action not allowed, only quest leader or group leader can perform this action"
},
"quest_not_found": {
"message": "Unable to complete action, quest or group not found"
}
},
"issues": {
@ -355,6 +362,66 @@
"description": "The name (or task ID) of the task you want to target with the skill or spell."
}
}
},
"accept_quest": {
"name": "Accept a quest invitation",
"description": "Accept a pending invitation to a quest.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
"description": "Choose the Habitica character for which to perform the action."
}
}
},
"reject_quest": {
"name": "Reject a quest invitation",
"description": "Reject a pending invitation to a quest.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
"description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]"
}
}
},
"leave_quest": {
"name": "Leave a quest",
"description": "Leave the current quest you are participating in.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
"description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]"
}
}
},
"abort_quest": {
"name": "Abort an active quest",
"description": "Terminate your party's ongoing quest. All progress will be lost and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
"description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]"
}
}
},
"cancel_quest": {
"name": "Cancel a pending quest",
"description": "Cancel a quest that has not yet startet. All accepted and pending invitations will be canceled and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
"description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]"
}
}
},
"start_quest": {
"name": "Force-start a pending quest",
"description": "Begin the quest immediately, bypassing any pending invitations that haven't been accepted or rejected. Only quest leader or group leader can perform this action.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
"description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]"
}
}
}
},
"selector": {

View File

@ -13,7 +13,13 @@ from homeassistant.components.habitica.const import (
ATTR_TASK,
DEFAULT_URL,
DOMAIN,
SERVICE_ABORT_QUEST,
SERVICE_ACCEPT_QUEST,
SERVICE_CANCEL_QUEST,
SERVICE_CAST_SKILL,
SERVICE_LEAVE_QUEST,
SERVICE_REJECT_QUEST,
SERVICE_START_QUEST,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
@ -24,6 +30,9 @@ from .conftest import mock_called_with
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
REQUEST_EXCEPTION_MSG = "Unable to connect to Habitica, try again later"
RATE_LIMIT_EXCEPTION_MSG = "Rate limit exceeded, try again later"
@pytest.fixture(autouse=True)
def services_only() -> Generator[None]:
@ -168,7 +177,7 @@ async def test_cast_skill(
},
HTTPStatus.TOO_MANY_REQUESTS,
ServiceValidationError,
"Rate limit exceeded, try again later",
RATE_LIMIT_EXCEPTION_MSG,
),
(
{
@ -195,7 +204,7 @@ async def test_cast_skill(
},
HTTPStatus.BAD_REQUEST,
HomeAssistantError,
"Unable to connect to Habitica, try again later",
REQUEST_EXCEPTION_MSG,
),
],
)
@ -271,3 +280,100 @@ async def test_get_config_entry(
return_response=True,
blocking=True,
)
@pytest.mark.parametrize(
("service", "command"),
[
(SERVICE_ABORT_QUEST, "abort"),
(SERVICE_ACCEPT_QUEST, "accept"),
(SERVICE_CANCEL_QUEST, "cancel"),
(SERVICE_LEAVE_QUEST, "leave"),
(SERVICE_REJECT_QUEST, "reject"),
(SERVICE_START_QUEST, "force-start"),
],
ids=[],
)
async def test_handle_quests(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
service: str,
command: str,
) -> None:
"""Test Habitica actions for quest handling."""
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/groups/party/quests/{command}",
json={"success": True, "data": {}},
)
await hass.services.async_call(
DOMAIN,
service,
service_data={ATTR_CONFIG_ENTRY: config_entry.entry_id},
return_response=True,
blocking=True,
)
assert mock_called_with(
mock_habitica,
"post",
f"{DEFAULT_URL}/api/v3/groups/party/quests/{command}",
)
@pytest.mark.parametrize(
(
"http_status",
"expected_exception",
"expected_exception_msg",
),
[
(
HTTPStatus.TOO_MANY_REQUESTS,
ServiceValidationError,
RATE_LIMIT_EXCEPTION_MSG,
),
(
HTTPStatus.NOT_FOUND,
ServiceValidationError,
"Unable to complete action, quest or group not found",
),
(
HTTPStatus.UNAUTHORIZED,
ServiceValidationError,
"Action not allowed, only quest leader or group leader can perform this action",
),
(
HTTPStatus.BAD_REQUEST,
HomeAssistantError,
REQUEST_EXCEPTION_MSG,
),
],
)
@pytest.mark.usefixtures("mock_habitica")
async def test_handle_quests_exceptions(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
http_status: HTTPStatus,
expected_exception: Exception,
expected_exception_msg: str,
) -> None:
"""Test Habitica handle quests action exceptions."""
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/groups/party/quests/accept",
json={"success": True, "data": {}},
status=http_status,
)
with pytest.raises(expected_exception, match=expected_exception_msg):
await hass.services.async_call(
DOMAIN,
SERVICE_ACCEPT_QUEST,
service_data={ATTR_CONFIG_ENTRY: config_entry.entry_id},
return_response=True,
blocking=True,
)