From 6703144fabc03e75fc35403c20eef612bd4cebec Mon Sep 17 00:00:00 2001 From: owen Date: Fri, 24 Apr 2026 00:25:23 -0400 Subject: [PATCH 1/5] feat(discord): subscriptions command, status embed tweaks - Rename slash command subscription -> subscriptions (manifest + router + copy). - Drop aggregate Rule Filters field; keep per-rule filter lines only. - Subscription status embed: green when destination active, red when inactive. - Align manifest tests with search/subscribe manifest; extend subscription helper tests. Made-with: Cursor --- src/app_factory.py | 2 +- src/commands/subscription.py | 6 ++--- src/commands/subscription_helpers.py | 35 +++------------------------ src/commands/subscription_messages.py | 2 +- src/manifest/builders.py | 2 +- tests/test_manifest_commands.py | 31 ++++++++++++------------ tests/test_subscription_helpers.py | 24 +++++++++++++----- 7 files changed, 44 insertions(+), 58 deletions(-) diff --git a/src/app_factory.py b/src/app_factory.py index 1dc5487..b7c1801 100644 --- a/src/app_factory.py +++ b/src/app_factory.py @@ -44,7 +44,7 @@ def create_app() -> FastAPI: "instance": run_instance_deferred, "player-search": run_player_search_deferred, "subscribe": run_subscribe_deferred, - "subscription": run_subscription_deferred, + "subscriptions": run_subscription_deferred, "unsubscribe": run_unsubscribe_deferred, } diff --git a/src/commands/subscription.py b/src/commands/subscription.py index 7819ccb..509a0e4 100644 --- a/src/commands/subscription.py +++ b/src/commands/subscription.py @@ -42,7 +42,7 @@ async def run_subscription_deferred( await patch_discord_followup_best_effort( app_id, token, - warn_embed(SUBSCRIPTION_COMMAND_TITLE, "Use `/subscription` to view status."), + warn_embed(SUBSCRIPTION_COMMAND_TITLE, "Use `/subscriptions` to view status."), ) return @@ -81,7 +81,7 @@ async def run_subscription_deferred( except Exception as err: outcome = "error" await report_deferred_exception( - command="subscription", + command="subscriptions", log_key="SUBSCRIPTION_DEFERRED_FAILED", err=err, discord_application_id=app_id, @@ -89,7 +89,7 @@ async def run_subscription_deferred( user_message_payload=error_embed("Subscription Failed", USER_FACING_GENERIC), ) finally: - observe_deferred_completion(command="subscription", outcome=outcome) + observe_deferred_completion(command="subscriptions", outcome=outcome) __all__ = ["run_subscription_deferred"] diff --git a/src/commands/subscription_helpers.py b/src/commands/subscription_helpers.py index 6c6adb4..ee0a034 100644 --- a/src/commands/subscription_helpers.py +++ b/src/commands/subscription_helpers.py @@ -247,28 +247,6 @@ def _indexed_clan_rules(cl_raw: list[Any]) -> dict[str, dict[str, Any]]: return out -def _rule_filter_state(items: list[Any], key: str) -> str: - values = [item.get(key) for item in items if isinstance(item, dict) and key in item] - bool_values = [bool(v) for v in values if isinstance(v, bool)] - if not bool_values: - return "unset" - uniq = set(bool_values) - if len(uniq) > 1: - return "mixed" - return "yes" if True in uniq else "no" - - -def _rule_filters_summary(pl_raw: list[Any], cl_raw: list[Any]) -> str: - all_rules = [*pl_raw, *cl_raw] - if not all_rules: - return "—" - fresh = _rule_filter_state(all_rules, "requireFresh") - completed = _rule_filter_state(all_rules, "requireCompleted") - return (f"Require Fresh: **{fresh}**\n" f"Require Completed: **{completed}**")[ - :1024 - ] - - async def format_subscription_status_embed( raidhub: RaidHubClient | None, data: dict[str, Any], @@ -278,7 +256,8 @@ async def format_subscription_status_embed( "Subscription Status", "RaidHub alerts are currently turned off for this channel.", ) - active = "**yes**" if data.get("destinationActive") else "**no**" + destination_active = bool(data.get("destinationActive")) + active = "**yes**" if destination_active else "**no**" fails = int(data.get("consecutiveDeliveryFailures") or 0) fields: list[dict[str, Any]] = [ {"name": "Destination Active", "value": active, "inline": True}, @@ -312,13 +291,6 @@ async def format_subscription_status_embed( clan_rules = _indexed_clan_rules(cl_raw) pc = len(pl_ids) cc = len(cl_ids) - fields.append( - { - "name": "Rule Filters", - "value": _rule_filters_summary(pl_raw, cl_raw), - "inline": False, - } - ) player_cards: dict[str, dict[str, Any]] = {} clan_cards: dict[str, dict[str, Any]] = {} @@ -383,10 +355,11 @@ async def format_subscription_status_embed( "(name and id view)." ) + embed_color = 0x57_F287 if destination_active else 0xED42_45 return base_embed( title="Subscription Status", description=desc, - color=0x5865_F2, + color=embed_color, fields=fields, ) diff --git a/src/commands/subscription_messages.py b/src/commands/subscription_messages.py index 5479992..e827ef0 100644 --- a/src/commands/subscription_messages.py +++ b/src/commands/subscription_messages.py @@ -15,7 +15,7 @@ PLAYER_UNSUBSCRIBED_TITLE = "Player Unsubscribed" CLAN_UNSUBSCRIBED_TITLE = "Clan Unsubscribed" SUBSCRIPTION_STATUS_RULES_HINT = ( - "Use `/subscription status` to see all rules for this channel." + "Use `/subscriptions` to see all rules for this channel." ) diff --git a/src/manifest/builders.py b/src/manifest/builders.py index 6551d28..d7d6882 100644 --- a/src/manifest/builders.py +++ b/src/manifest/builders.py @@ -91,7 +91,7 @@ def build_commands(raid_filter_choices: list[tuple[str, int]] | None = None) -> ], ), CommandDto( - name="subscription", + name="subscriptions", description="View this channel's RaidHub subscription status and rule health.", dm_permission=False, options=[], diff --git a/tests/test_manifest_commands.py b/tests/test_manifest_commands.py index 2b976f4..9d43289 100644 --- a/tests/test_manifest_commands.py +++ b/tests/test_manifest_commands.py @@ -12,20 +12,19 @@ def test_build_commands_has_stable_slash_names(self) -> None: self.assertEqual( names, { - "instance", - "player-search", + "search", "subscribe", - "subscription", + "subscriptions", "unsubscribe", }, ) - def test_instance_command_has_raid_instance_id_option(self) -> None: + def test_search_command_has_search_query_option(self) -> None: cmds = {c.name: c for c in build_commands()} - inst = cmds["instance"] - self.assertIsNotNone(inst.options) - opt_names = {o.name for o in (inst.options or [])} - self.assertIn("raid_instance_id", opt_names) + search = cmds["search"] + self.assertIsNotNone(search.options) + opt_names = {o.name for o in (search.options or [])} + self.assertIn("search_query", opt_names) def test_subscribe_dm_permission_false(self) -> None: cmds = {c.name: c for c in build_commands()} @@ -38,18 +37,20 @@ def test_subscribe_subcommands_expose_rule_filter_options(self) -> None: player_opt_names = {o.name for o in (options["player"].options or [])} clan_opt_names = {o.name for o in (options["clan"].options or [])} self.assertEqual( - player_opt_names, {"player", "require_fresh", "require_completed", "raid"} + player_opt_names, {"player", "require_fresh", "require_completed"} ) self.assertEqual( clan_opt_names, - {"clan", "require_fresh", "require_completed", "raid"}, + {"clan", "require_fresh", "require_completed"}, ) - player_raid = next( - o for o in (options["player"].options or []) if o.name == "raid" + player_rq = next( + o for o in (options["player"].options or []) if o.name == "require_fresh" ) - clan_raid = next(o for o in (options["clan"].options or []) if o.name == "raid") - self.assertEqual(player_raid.type, CommandOptionType.INTEGER) - self.assertEqual(clan_raid.type, CommandOptionType.INTEGER) + clan_rq = next( + o for o in (options["clan"].options or []) if o.name == "require_fresh" + ) + self.assertEqual(player_rq.type, CommandOptionType.BOOLEAN) + self.assertEqual(clan_rq.type, CommandOptionType.BOOLEAN) def test_unsubscribe_has_all_player_clan_subcommands(self) -> None: cmds = {c.name: c for c in build_commands()} diff --git a/tests/test_subscription_helpers.py b/tests/test_subscription_helpers.py index 570357b..86f3b65 100644 --- a/tests/test_subscription_helpers.py +++ b/tests/test_subscription_helpers.py @@ -86,7 +86,21 @@ def test_registered_minimal(self) -> None: fields = {f["name"]: f["value"] for f in out["embeds"][0]["fields"]} self.assertIn("Destination Active", fields) self.assertIn("Delivery Failures", fields) - self.assertEqual(fields["Rule Filters"], "—") + self.assertNotIn("Rule Filters", fields) + self.assertEqual(out["embeds"][0]["color"], 0x57_F287) + + def test_registered_destination_inactive_embed_is_red(self) -> None: + out = asyncio.run( + format_subscription_status_embed( + None, + { + "registered": True, + "destinationActive": False, + "consecutiveDeliveryFailures": 0, + }, + ) + ) + self.assertEqual(out["embeds"][0]["color"], 0xED42_45) def test_registered_uses_clan_group_id_key(self) -> None: out = asyncio.run( @@ -102,10 +116,9 @@ def test_registered_uses_clan_group_id_key(self) -> None: ) fields = {f["name"]: f["value"] for f in out["embeds"][0]["fields"]} self.assertIn("• `4927161`", fields["Clan Rules (1)"]) - self.assertIn("`raid:all`", fields["Clan Rules (1)"]) - self.assertIn("Require Fresh", fields["Rule Filters"]) + self.assertIn("`raids:all`", fields["Clan Rules (1)"]) - def test_registered_rule_filters_summary(self) -> None: + def test_registered_player_rules_show_per_rule_filters(self) -> None: out = asyncio.run( format_subscription_status_embed( None, @@ -125,8 +138,7 @@ def test_registered_rule_filters_summary(self) -> None: ) ) fields = {f["name"]: f["value"] for f in out["embeds"][0]["fields"]} - self.assertIn("**yes**", fields["Rule Filters"]) - self.assertIn("**no**", fields["Rule Filters"]) + self.assertNotIn("Rule Filters", fields) self.assertIn("`fresh:yes`", fields["Player Rules (1)"]) self.assertIn("`completed:no`", fields["Player Rules (1)"]) From c8c00ecde8f3c2aa8b44a9ff9f12762270f71d9f Mon Sep 17 00:00:00 2001 From: owen Date: Fri, 24 Apr 2026 00:27:17 -0400 Subject: [PATCH 2/5] fix(discord): gray subscription status embed when not registered Use neutral greyple for unregistered channels; keep green/red for active/inactive destinations. Fix unregistered status test copy and assert embed color. Made-with: Cursor --- src/commands/subscription_helpers.py | 8 ++++---- tests/test_subscription_helpers.py | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/commands/subscription_helpers.py b/src/commands/subscription_helpers.py index ee0a034..000682a 100644 --- a/src/commands/subscription_helpers.py +++ b/src/commands/subscription_helpers.py @@ -10,7 +10,6 @@ from .shared import ( base_embed, discord_message_for_failed_envelope, - info_embed, iso_to_discord_relative, ) @@ -252,9 +251,10 @@ async def format_subscription_status_embed( data: dict[str, Any], ) -> dict[str, Any]: if not data.get("registered"): - return info_embed( - "Subscription Status", - "RaidHub alerts are currently turned off for this channel.", + return base_embed( + title="Subscription Status", + description="RaidHub alerts are currently turned off for this channel.", + color=0x747F8D, ) destination_active = bool(data.get("destinationActive")) active = "**yes**" if destination_active else "**no**" diff --git a/tests/test_subscription_helpers.py b/tests/test_subscription_helpers.py index 86f3b65..bb52d1e 100644 --- a/tests/test_subscription_helpers.py +++ b/tests/test_subscription_helpers.py @@ -69,7 +69,8 @@ def test_unregistered_channel(self) -> None: out = asyncio.run(format_subscription_status_embed(None, {"registered": False})) self.assertIn("embeds", out) self.assertEqual(out["embeds"][0]["title"], "Subscription Status") - self.assertIn("No RaidHub subscription webhook", out["embeds"][0]["description"]) + self.assertIn("RaidHub alerts are currently turned off", out["embeds"][0]["description"]) + self.assertEqual(out["embeds"][0]["color"], 0x747F8D) def test_registered_minimal(self) -> None: out = asyncio.run( From f4ad75b23973ff2582fc6d50ee57c61f5109110b Mon Sep 17 00:00:00 2001 From: owen Date: Fri, 24 Apr 2026 00:50:03 -0400 Subject: [PATCH 3/5] feat(subscriptions): per-target filters on webhook PUT body Align subscribe/unsubscribe with API targets.players / targets.clans (requireFresh, requireCompleted, raids per entry). Remove propagateFilters. Also drop unused /search slash options (membership type, global name) so manifest matches handler and API. Made-with: Cursor --- src/commands/player_search.py | 8 -- src/commands/player_search_helpers.py | 4 - src/commands/subscribe.py | 37 ++--- src/commands/subscription_helpers.py | 187 ++++++++++++++++++++++++-- src/commands/unsubscribe.py | 12 +- src/manifest/builders.py | 18 +-- tests/test_subscription_helpers.py | 24 +++- 7 files changed, 221 insertions(+), 69 deletions(-) diff --git a/src/commands/player_search.py b/src/commands/player_search.py index 63738c1..11490fc 100644 --- a/src/commands/player_search.py +++ b/src/commands/player_search.py @@ -38,14 +38,6 @@ async def run_player_search_deferred( return query_params: dict[str, Any] = {"query": query} - if "destiny_membership_type" in opts: - query_params["membershipType"] = opts["destiny_membership_type"] - elif "membership_type" in opts: - query_params["membershipType"] = opts["membership_type"] - if "use_global_name_search" in opts: - query_params["global"] = opts["use_global_name_search"] - elif "global" in opts: - query_params["global"] = opts["global"] page_size = PLAYER_SEARCH_PAGE_SIZE session_id = store_paged_session( diff --git a/src/commands/player_search_helpers.py b/src/commands/player_search_helpers.py index 11d384b..f81853f 100644 --- a/src/commands/player_search_helpers.py +++ b/src/commands/player_search_helpers.py @@ -73,10 +73,6 @@ async def player_search_render_from_state( offset = page * page_size params: dict[str, Any] = {"query": query, "count": page_size, "offset": offset} - if "membershipType" in qp: - params["membershipType"] = qp["membershipType"] - if "global" in qp: - params["global"] = qp["global"] env = await raidhub.request_envelope("GET", "/player/search", params=params) if not env.get("success"): diff --git a/src/commands/subscribe.py b/src/commands/subscribe.py index 892019c..b7eedc5 100644 --- a/src/commands/subscribe.py +++ b/src/commands/subscribe.py @@ -24,10 +24,12 @@ subscribe_success_description, ) from .subscription_helpers import ( + clan_target_from_subscribe_leaf, fetch_subscription_status_envelope, format_clan_display_name, - subscription_active_clan_ids, - subscription_active_player_ids, + merge_clan_subscribe_put_body, + merge_player_subscribe_put_body, + player_target_from_subscribe_leaf, subscription_envelope_error_message, ) from .subscription_routes import SUB_ROUTE_PUT @@ -76,11 +78,6 @@ async def run_subscribe_deferred( leaf = flatten_options(top_opts[0].get("options")) target_raw = str(leaf.get("player") or leaf.get("clan") or "").strip() - filters: dict[str, Any] = {} - if "require_fresh" in leaf: - filters["requireFresh"] = bool(leaf["require_fresh"]) - if "require_completed" in leaf: - filters["requireCompleted"] = bool(leaf["require_completed"]) # TODO: Add raid filter input once we have a solid multi-select UX. if not target_raw: await patch_discord_followup_best_effort( @@ -147,15 +144,13 @@ async def run_subscribe_deferred( ) return if registered: - merged_players = subscription_active_player_ids(status_inner) - if resolved_id not in merged_players: - merged_players.append(resolved_id) - merged_players.sort() - body: dict[str, Any] = {"targets": {"playerMembershipIds": merged_players}} + body = merge_player_subscribe_put_body(status_inner, resolved_id, leaf) else: - body = {"targets": {"playerMembershipIds": [resolved_id]}} - if filters: - body["filters"] = filters + body = { + "targets": { + "players": [player_target_from_subscribe_leaf(resolved_id, leaf)] + } + } display_name = format_player_display_name(prow) icon_raw = prow.get("iconPath") thumb_url = ( @@ -178,15 +173,11 @@ async def run_subscribe_deferred( return resolved_id = str(int(gid)) if registered: - merged_clans = subscription_active_clan_ids(status_inner) - if resolved_id not in merged_clans: - merged_clans.append(resolved_id) - merged_clans.sort() - body = {"targets": {"clanGroupIds": merged_clans}} + body = merge_clan_subscribe_put_body(status_inner, resolved_id, leaf) else: - body = {"targets": {"clanGroupIds": [resolved_id]}} - if filters: - body["filters"] = filters + body = { + "targets": {"clans": [clan_target_from_subscribe_leaf(resolved_id, leaf)]} + } ctx = discord_invocation_context(interaction, route_id=SUB_ROUTE_PUT) env = await raidhub.request_envelope( diff --git a/src/commands/subscription_helpers.py b/src/commands/subscription_helpers.py index 000682a..e122654 100644 --- a/src/commands/subscription_helpers.py +++ b/src/commands/subscription_helpers.py @@ -44,6 +44,172 @@ def subscription_active_clan_ids(inner: dict[str, Any]) -> list[str]: return sorted(set(out)) +def _raid_ids_list_from_rule(item: dict[str, Any]) -> list[int]: + raw = item.get("raidIds") + if not isinstance(raw, list): + return [] + out: list[int] = [] + for v in raw: + s = str(v).strip() + if s.isdigit(): + out.append(int(s)) + return out + + +def player_put_targets_from_status(inner: dict[str, Any]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for item in inner.get("players") or []: + if not isinstance(item, dict): + continue + raw = item.get("membershipId") + if raw is None: + continue + s = str(raw).strip() + if not s.isdigit(): + continue + mid = str(int(s)) + entry: dict[str, Any] = { + "membershipId": mid, + "requireFresh": bool(item.get("requireFresh")), + "requireCompleted": bool(item.get("requireCompleted")), + } + rids = _raid_ids_list_from_rule(item) + if rids: + entry["raids"] = rids + out.append(entry) + out.sort(key=lambda r: r["membershipId"]) + return out + + +def clan_put_targets_from_status(inner: dict[str, Any]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for item in inner.get("clans") or []: + if not isinstance(item, dict): + continue + raw = item.get("groupId") or item.get("clanGroupId") + if raw is None: + continue + s = str(raw).strip() + if not s.isdigit(): + continue + gid = str(int(s)) + entry: dict[str, Any] = { + "groupId": gid, + "requireFresh": bool(item.get("requireFresh")), + "requireCompleted": bool(item.get("requireCompleted")), + } + rids = _raid_ids_list_from_rule(item) + if rids: + entry["raids"] = rids + out.append(entry) + out.sort(key=lambda r: r["groupId"]) + return out + + +def player_target_from_subscribe_leaf(membership_id: str, leaf: dict[str, Any]) -> dict[str, Any]: + base: dict[str, Any] = { + "membershipId": membership_id, + "requireFresh": False, + "requireCompleted": False, + } + if "require_fresh" in leaf: + base["requireFresh"] = bool(leaf["require_fresh"]) + if "require_completed" in leaf: + base["requireCompleted"] = bool(leaf["require_completed"]) + return base + + +def merge_player_subscribe_put_body( + status_inner: dict[str, Any], resolved_id: str, leaf: dict[str, Any] +) -> dict[str, Any]: + rows = [r for r in player_put_targets_from_status(status_inner) if r["membershipId"] != resolved_id] + prior: dict[str, Any] | None = None + for item in status_inner.get("players") or []: + if not isinstance(item, dict): + continue + raw = item.get("membershipId") + if raw is None: + continue + s = str(raw).strip() + if not s.isdigit(): + continue + if str(int(s)) != resolved_id: + continue + prior = { + "membershipId": resolved_id, + "requireFresh": bool(item.get("requireFresh")), + "requireCompleted": bool(item.get("requireCompleted")), + } + rids = _raid_ids_list_from_rule(item) + if rids: + prior["raids"] = rids + break + base = prior or { + "membershipId": resolved_id, + "requireFresh": False, + "requireCompleted": False, + } + if "require_fresh" in leaf: + base["requireFresh"] = bool(leaf["require_fresh"]) + if "require_completed" in leaf: + base["requireCompleted"] = bool(leaf["require_completed"]) + rows.append(base) + rows.sort(key=lambda r: r["membershipId"]) + return {"targets": {"players": rows}} + + +def clan_target_from_subscribe_leaf(group_id: str, leaf: dict[str, Any]) -> dict[str, Any]: + base: dict[str, Any] = { + "groupId": group_id, + "requireFresh": False, + "requireCompleted": False, + } + if "require_fresh" in leaf: + base["requireFresh"] = bool(leaf["require_fresh"]) + if "require_completed" in leaf: + base["requireCompleted"] = bool(leaf["require_completed"]) + return base + + +def merge_clan_subscribe_put_body( + status_inner: dict[str, Any], resolved_id: str, leaf: dict[str, Any] +) -> dict[str, Any]: + rows = [r for r in clan_put_targets_from_status(status_inner) if r["groupId"] != resolved_id] + prior: dict[str, Any] | None = None + for item in status_inner.get("clans") or []: + if not isinstance(item, dict): + continue + raw = item.get("groupId") or item.get("clanGroupId") + if raw is None: + continue + s = str(raw).strip() + if not s.isdigit(): + continue + if str(int(s)) != resolved_id: + continue + prior = { + "groupId": resolved_id, + "requireFresh": bool(item.get("requireFresh")), + "requireCompleted": bool(item.get("requireCompleted")), + } + rids = _raid_ids_list_from_rule(item) + if rids: + prior["raids"] = rids + break + base = prior or { + "groupId": resolved_id, + "requireFresh": False, + "requireCompleted": False, + } + if "require_fresh" in leaf: + base["requireFresh"] = bool(leaf["require_fresh"]) + if "require_completed" in leaf: + base["requireCompleted"] = bool(leaf["require_completed"]) + rows.append(base) + rows.sort(key=lambda r: r["groupId"]) + return {"targets": {"clans": rows}} + + async def fetch_subscription_status_envelope( raidhub: RaidHubClient, interaction: dict[str, Any], @@ -63,20 +229,19 @@ def build_subscription_json_body(leaf_opts: dict[str, Any]) -> dict[str, Any]: ).strip() if wn: body["name"] = wn[:80] - filters: dict[str, Any] = {} - if "require_fresh" in leaf_opts: - filters["requireFresh"] = bool(leaf_opts["require_fresh"]) - if "require_completed" in leaf_opts: - filters["requireCompleted"] = bool(leaf_opts["require_completed"]) - if filters: - body["filters"] = filters + rf = bool(leaf_opts["require_fresh"]) if "require_fresh" in leaf_opts else False + rc = bool(leaf_opts["require_completed"]) if "require_completed" in leaf_opts else False targets: dict[str, Any] = {} players = _comma_separated_digit_ids(str(leaf_opts.get("players") or "")) if players: - targets["playerMembershipIds"] = players + targets["players"] = [ + {"membershipId": p, "requireFresh": rf, "requireCompleted": rc} for p in players + ] clans = _comma_separated_digit_ids(str(leaf_opts.get("clans") or "")) if clans: - targets["clanGroupIds"] = clans + targets["clans"] = [ + {"groupId": c, "requireFresh": rf, "requireCompleted": rc} for c in clans + ] if targets: body["targets"] = targets return body @@ -373,8 +538,8 @@ def subscription_envelope_error_message(env: dict[str, Any]) -> str: ) if code == "BodyValidationError": return ( - "RaidHub could not validate the payload. Use digits only for **players** / **clans** " - "lists (comma-separated)." + "RaidHub could not validate the payload. Check **targets.players** / **targets.clans** " + "(numeric membership id / group id and optional rule fields)." ) return discord_message_for_failed_envelope(code, "") diff --git a/src/commands/unsubscribe.py b/src/commands/unsubscribe.py index c1bdc2e..1d722c3 100644 --- a/src/commands/unsubscribe.py +++ b/src/commands/unsubscribe.py @@ -25,8 +25,10 @@ unsubscribe_success_description, ) from .subscription_helpers import ( + clan_put_targets_from_status, fetch_subscription_status_envelope, format_clan_display_name, + player_put_targets_from_status, subscription_active_clan_ids, subscription_active_player_ids, subscription_envelope_error_message, @@ -215,8 +217,9 @@ async def run_unsubscribe_player_deferred( ) return - next_players = sorted([p for p in players if p != resolved_id]) - body: dict[str, Any] = {"targets": {"playerMembershipIds": next_players}} + row_list = player_put_targets_from_status(inner) + next_rows = [r for r in row_list if r["membershipId"] != resolved_id] + body: dict[str, Any] = {"targets": {"players": next_rows}} ctx = discord_invocation_context(interaction, route_id=SUB_ROUTE_PUT) env = await raidhub.request_envelope( @@ -343,8 +346,9 @@ async def run_unsubscribe_clan_deferred( ) return - next_clans = sorted([c for c in clans if c != resolved_id]) - body = {"targets": {"clanGroupIds": next_clans}} + row_list = clan_put_targets_from_status(inner) + next_rows = [r for r in row_list if r["groupId"] != resolved_id] + body = {"targets": {"clans": next_rows}} ctx = discord_invocation_context(interaction, route_id=SUB_ROUTE_PUT) env = await raidhub.request_envelope( diff --git a/src/manifest/builders.py b/src/manifest/builders.py index d7d6882..438f6bb 100644 --- a/src/manifest/builders.py +++ b/src/manifest/builders.py @@ -3,7 +3,9 @@ from .schema import CommandDto, CommandOptionDto, CommandOptionType -def build_commands(raid_filter_choices: list[tuple[str, int]] | None = None) -> list[CommandDto]: +def build_commands( + raid_filter_choices: list[tuple[str, int]] | None = None, +) -> list[CommandDto]: return [ CommandDto( name="search", @@ -15,18 +17,6 @@ def build_commands(raid_filter_choices: list[tuple[str, int]] | None = None) -> description="Search text", required=True, ), - CommandOptionDto( - type=CommandOptionType.INTEGER, - name="destiny_membership_type", - description="Destiny membership type", - required=False, - ), - CommandOptionDto( - type=CommandOptionType.BOOLEAN, - name="use_global_name_search", - description="Search by Bungie name", - required=False, - ), ], ), CommandDto( @@ -132,7 +122,7 @@ def build_commands(raid_filter_choices: list[tuple[str, int]] | None = None) -> required=True, ) ], - ) + ), ], ), ] diff --git a/tests/test_subscription_helpers.py b/tests/test_subscription_helpers.py index bb52d1e..8cf334b 100644 --- a/tests/test_subscription_helpers.py +++ b/tests/test_subscription_helpers.py @@ -40,14 +40,28 @@ def test_filters_and_targets(self) -> None: "clans": "9", } ) - self.assertEqual(body["filters"]["requireFresh"], True) - self.assertEqual(body["filters"]["requireCompleted"], False) - self.assertEqual(body["targets"]["playerMembershipIds"], ["1", "2", "3"]) - self.assertEqual(body["targets"]["clanGroupIds"], ["9"]) + self.assertEqual( + body["targets"]["players"], + [ + {"membershipId": "1", "requireFresh": True, "requireCompleted": False}, + {"membershipId": "2", "requireFresh": True, "requireCompleted": False}, + {"membershipId": "3", "requireFresh": True, "requireCompleted": False}, + ], + ) + self.assertEqual( + body["targets"]["clans"], + [{"groupId": "9", "requireFresh": True, "requireCompleted": False}], + ) def test_ignores_non_digit_tokens_in_lists(self) -> None: body = build_subscription_json_body({"players": "1, abc, 2"}) - self.assertEqual(body["targets"]["playerMembershipIds"], ["1", "2"]) + self.assertEqual( + body["targets"]["players"], + [ + {"membershipId": "1", "requireFresh": False, "requireCompleted": False}, + {"membershipId": "2", "requireFresh": False, "requireCompleted": False}, + ], + ) class SubscriptionEnvelopeErrorMessageTests(unittest.TestCase): From 05695919f01aed6d46e895c4912ec88d0abeae27 Mon Sep 17 00:00:00 2001 From: owen Date: Fri, 24 Apr 2026 00:52:19 -0400 Subject: [PATCH 4/5] fix: route /search and legacy /subscription command names Discord sends the manifest command name (e.g. search). Map search and player-search to the same handler. Keep subscription as an alias for subscriptions during rollout. Made-with: Cursor --- src/app_factory.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app_factory.py b/src/app_factory.py index b7c1801..268c66f 100644 --- a/src/app_factory.py +++ b/src/app_factory.py @@ -42,8 +42,10 @@ def create_app() -> FastAPI: command_handlers: dict[str, CommandHandler] = { "instance": run_instance_deferred, + "search": run_player_search_deferred, "player-search": run_player_search_deferred, "subscribe": run_subscribe_deferred, + "subscription": run_subscription_deferred, "subscriptions": run_subscription_deferred, "unsubscribe": run_unsubscribe_deferred, } From 7baff2e2f394786b3cb140d37794c4d69a5b3907 Mon Sep 17 00:00:00 2001 From: owen Date: Fri, 24 Apr 2026 00:53:44 -0400 Subject: [PATCH 5/5] chore: align interaction routes with manifest slash commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop instance handler and module (command removed from manifest). Route only search, subscribe, subscriptions, unsubscribe — no legacy player-search or subscription aliases. Made-with: Cursor --- README.md | 2 +- src/app_factory.py | 4 -- src/commands/__init__.py | 2 - src/commands/instance.py | 117 ---------------------------------- src/commands/player_search.py | 4 +- 5 files changed, 3 insertions(+), 126 deletions(-) delete mode 100644 src/commands/instance.py diff --git a/README.md b/README.md index ae64d5a..d9762bf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # raidhub-discord -Backend for the RaidHub Discord application: it receives [Discord interactions](https://discord.com/developers/docs/interactions/receiving-and-responding) over HTTP, checks request signatures, registers slash commands, and proxies the relevant work to the RaidHub API (search, instances, channel subscriptions, and related flows). It is meant to run as a small always-on service (for example behind your ingress), not as something you embed in other apps. +Backend for the RaidHub Discord application: it receives [Discord interactions](https://discord.com/developers/docs/interactions/receiving-and-responding) over HTTP, checks request signatures, registers slash commands, and proxies the relevant work to the RaidHub API (search, channel subscriptions, and related flows). It is meant to run as a small always-on service (for example behind your ingress), not as something you embed in other apps. ## Quick start diff --git a/src/app_factory.py b/src/app_factory.py index 268c66f..e85470c 100644 --- a/src/app_factory.py +++ b/src/app_factory.py @@ -12,7 +12,6 @@ from .commands import ( register_player_search_pager, - run_instance_deferred, run_player_search_deferred, run_subscribe_deferred, run_subscription_deferred, @@ -41,11 +40,8 @@ def create_app() -> FastAPI: register_player_search_pager(raidhub) command_handlers: dict[str, CommandHandler] = { - "instance": run_instance_deferred, "search": run_player_search_deferred, - "player-search": run_player_search_deferred, "subscribe": run_subscribe_deferred, - "subscription": run_subscription_deferred, "subscriptions": run_subscription_deferred, "unsubscribe": run_unsubscribe_deferred, } diff --git a/src/commands/__init__.py b/src/commands/__init__.py index b89d706..df04bd1 100644 --- a/src/commands/__init__.py +++ b/src/commands/__init__.py @@ -1,4 +1,3 @@ -from .instance import run_instance_deferred from .player_search import register_player_search_pager, run_player_search_deferred from .subscribe import run_subscribe_deferred from .subscription import run_subscription_deferred @@ -10,7 +9,6 @@ __all__ = [ "register_player_search_pager", - "run_instance_deferred", "run_player_search_deferred", "run_subscribe_deferred", "run_subscription_deferred", diff --git a/src/commands/instance.py b/src/commands/instance.py deleted file mode 100644 index a65f9c8..0000000 --- a/src/commands/instance.py +++ /dev/null @@ -1,117 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from ..config import Settings -from ..prom_metrics import observe_deferred_completion -from ..raidhub_client import RaidHubClient -from .shared import ( - USER_FACING_GENERIC, - application_id, - discord_message_for_failed_envelope, - flatten_options, - patch_discord_followup_best_effort, - report_deferred_exception, -) - - -def _format_duration(seconds: int) -> str: - h = seconds // 3600 - m = (seconds % 3600) // 60 - s = seconds % 60 - return f"{h}h {m}m {s}s" - - -async def run_instance_deferred( - interaction: dict[str, Any], - raidhub: RaidHubClient, - settings: Settings, -) -> None: - app_id = application_id(interaction, settings) - token = str(interaction.get("token") or "") - outcome = "completed" - try: - opts = flatten_options(interaction.get("data", {}).get("options")) - raw_id = opts.get("raid_instance_id") or opts.get("instance_id") - if raw_id is None or str(raw_id).strip() == "": - await patch_discord_followup_best_effort( - app_id, token, {"content": "Provide a **raid_instance_id** to look up."} - ) - return - instance_id = str(raw_id).strip() - - env = await raidhub.request_envelope("GET", f"/instance/{instance_id}") - if not env.get("success"): - code = str(env.get("code", "")) - if code == "InstanceNotFoundError": - await patch_discord_followup_best_effort( - app_id, token, {"content": "Instance not found."} - ) - return - await patch_discord_followup_best_effort( - app_id, - token, - {"content": discord_message_for_failed_envelope(code, "")}, - ) - return - - inst = env.get("response") or {} - meta = inst.get("metadata") or {} - title = str(meta.get("activityName") or "Raid instance") - desc = f"Instance `{inst.get('instanceId', instance_id)}`" - date_done = inst.get("dateCompleted") - date_s = str(date_done) if date_done else "—" - embed = { - "title": title, - "description": desc, - "color": 0x5865_F2, - "fields": [ - { - "name": "Version", - "value": str(meta.get("versionName") or "—"), - "inline": True, - }, - { - "name": "Players", - "value": str(inst.get("playerCount", "—")), - "inline": True, - }, - { - "name": "Duration", - "value": _format_duration(int(inst.get("duration") or 0)), - "inline": True, - }, - { - "name": "Completed", - "value": "Yes" if inst.get("completed") else "No", - "inline": True, - }, - { - "name": "Fresh", - "value": "Yes" if inst.get("fresh") else "No", - "inline": True, - }, - { - "name": "Flawless", - "value": "Yes" if inst.get("flawless") else "No", - "inline": True, - }, - {"name": "Completed At", "value": date_s, "inline": False}, - ], - } - await patch_discord_followup_best_effort(app_id, token, {"embeds": [embed]}) - except Exception as err: - outcome = "error" - await report_deferred_exception( - command="instance", - log_key="INSTANCE_DEFERRED_FAILED", - err=err, - discord_application_id=app_id, - interaction_token=token, - user_message_payload={"content": USER_FACING_GENERIC}, - ) - finally: - observe_deferred_completion(command="instance", outcome=outcome) - - -__all__ = ["run_instance_deferred"] diff --git a/src/commands/player_search.py b/src/commands/player_search.py index 11490fc..c6affef 100644 --- a/src/commands/player_search.py +++ b/src/commands/player_search.py @@ -53,7 +53,7 @@ async def run_player_search_deferred( except Exception as err: outcome = "error" await report_deferred_exception( - command="player-search", + command="search", log_key="PLAYER_SEARCH_DEFERRED_FAILED", err=err, discord_application_id=app_id, @@ -61,7 +61,7 @@ async def run_player_search_deferred( user_message_payload={"content": USER_FACING_GENERIC}, ) finally: - observe_deferred_completion(command="player-search", outcome=outcome) + observe_deferred_completion(command="search", outcome=outcome) __all__ = ["register_player_search_pager", "run_player_search_deferred"]