sidequest_bot.api_client ======================== .. py:module:: sidequest_bot.api_client .. autoapi-nested-parse:: Typed HTTP client for the SideQuest API. The only way the bot talks to the API. Attributes ---------- .. autoapisummary:: sidequest_bot.api_client.API_KEY_HEADER sidequest_bot.api_client.logger Exceptions ---------- .. autoapisummary:: sidequest_bot.api_client.APIError sidequest_bot.api_client.APIResponseShapeError sidequest_bot.api_client.APIUnavailableError sidequest_bot.api_client.NotFoundError Classes ------- .. autoapisummary:: sidequest_bot.api_client.SideQuestAPI Module Contents --------------- .. py:exception:: APIError(status_code: int, detail: str) Bases: :py:obj:`Exception` The API returned an error response, or couldn't be reached. Every client failure is an APIError. Initialize self. See help(type(self)) for accurate signature. .. py:attribute:: detail .. py:attribute:: status_code .. py:exception:: APIResponseShapeError(status_code: int, detail: str) Bases: :py:obj:`APIError` The API answered, but not in the shape this bot expects: usually the other half of a rolling deploy. Raised as an APIError (status 502) so every caller's existing APIError handling covers it; a bare pydantic ValidationError escaping a discord.py ``tasks.loop`` would stop that loop until the bot restarts. Initialize self. See help(type(self)) for accurate signature. .. py:exception:: APIUnavailableError(status_code: int, detail: str) Bases: :py:obj:`APIError` The API couldn't be reached (connection/timeout) after retries. Reported as status 503. Initialize self. See help(type(self)) for accurate signature. .. py:exception:: NotFoundError(status_code: int, detail: str) Bases: :py:obj:`APIError` The API returned 404. Initialize self. See help(type(self)) for accurate signature. .. py:class:: SideQuestAPI(base_url: str, api_key: str, *, client: httpx.AsyncClient | None = None) Async client for the SideQuest API. Retries 3x with backoff: idempotent methods on transport errors/5xx, POST only when the connection never opened (so a case or XP award is never recorded twice). 4xx is never retried. Pass ``client`` to reuse an existing ``httpx.AsyncClient`` (tests pass Litestar's ``AsyncTestClient``). .. py:method:: ack_badge(guild_id: int, ack: sidequest_common.schemas.BadgeAck) -> None :async: Confirm one member's badge announcement posted, so ``sync_badges`` stops reporting it as pending. .. py:method:: ack_event_activity(guild_id: int, ids: list[uuid.UUID]) -> None :async: Confirm these sign-up activity rows were posted in their event's forum thread. .. py:method:: ack_event_push(guild_id: int, event_id: uuid.UUID, ack: sidequest_common.schemas.DiscordPushAck) -> None :async: Confirm ``ack.push_hash`` was applied (or skipped) for ``event_id``. .. py:method:: ack_role_reset(guild_id: int, reset_id: uuid.UUID) -> None :async: Confirm the bot stripped this member's roles (or found them no longer in the guild). .. py:method:: ack_signup_notice(guild_id: int, member_id: int, ack: sidequest_common.schemas.SignupNoticeAck) -> None :async: Confirm the bot's message for ``member_id`` now reflects their current roster state. .. py:method:: aclose() -> None :async: Close the underlying HTTP client if this instance created it. .. py:method:: award_xp(guild_id: int, user_id: int) -> sidequest_common.schemas.XPAwardResult :async: Award one message's worth of XP (API enforces cooldown and amount). .. py:method:: confirm_roster_from_notice(guild_id: int, member_id: int, request: sidequest_common.schemas.ConfirmByDiscordRequest) -> sidequest_common.schemas.MemberListing :async: Confirm site member ``member_id`` on the roster, with the acting GM identified by Discord id. .. py:method:: create_case(guild_id: int, case: sidequest_common.schemas.ModCaseCreate) -> sidequest_common.schemas.ModCaseSchema :async: Record a moderation action; the API assigns the case number. .. py:method:: create_role_menu(guild_id: int, menu: sidequest_common.schemas.RoleMenuCreate) -> sidequest_common.schemas.RoleMenuSchema :async: Create a menu (no message yet; PATCH ``message_id`` after posting). .. py:method:: delete_event_sync_tombstone(guild_id: int, event_id: int) -> None :async: Clear the tombstone for ``event_id`` after deleting it in Discord. .. py:method:: delete_level_role(guild_id: int, level: int) -> None :async: Remove the reward at ``level``. Raises NotFoundError if none. .. py:method:: delete_role_menu(guild_id: int, menu_id: uuid.UUID) -> None :async: Delete a menu and its options. .. py:method:: delete_role_menu_option(guild_id: int, menu_id: uuid.UUID, role_id: int) -> sidequest_common.schemas.RoleMenuSchema :async: Remove the button for ``role_id``; returns the updated menu. .. py:method:: get_away_now(guild_id: int) -> list[int] :async: Discord ids of members marked away today (America/Chicago). .. py:method:: get_case(guild_id: int, case_number: int) -> sidequest_common.schemas.ModCaseSchema :async: One case. Raises NotFoundError if missing. .. py:method:: get_event_signup_by_discord(guild_id: int, event_id: uuid.UUID, discord_id: int) -> sidequest_common.schemas.SignupByDiscord :async: A member's current signup for ``event_id`` plus their most recent character/class/role, by Discord id. Raises ``NotFoundError`` if ``discord_id`` has never signed in on the site, or ``event_id`` doesn't exist. .. py:method:: get_guild_config(guild_id: int) -> sidequest_common.schemas.GuildConfigSchema :async: Config for ``guild_id`` (created with defaults on first access). .. py:method:: get_leaderboard(guild_id: int, *, limit: int = 10, offset: int = 0) -> sidequest_common.schemas.LeaderboardPage :async: Highest XP first. .. py:method:: get_pending_event_sync(guild_id: int) -> sidequest_common.schemas.DiscordSyncPending :async: Pushes and deletions the bot should apply to Discord now. .. py:method:: get_rank(guild_id: int, user_id: int) -> sidequest_common.schemas.RankSchema :async: A member's rank; members without XP get ``rank=None`` and zeros. .. py:method:: get_recruiting() -> sidequest_common.schemas.Recruiting :async: What roles and classes the guild is recruiting for (the public ``/api/recruiting`` GET). .. py:method:: get_role_menu(guild_id: int, menu_id: uuid.UUID) -> sidequest_common.schemas.RoleMenuSchema :async: One menu with its options. .. py:method:: get_voice_presence(guild_id: int, event_id: uuid.UUID) -> sidequest_common.schemas.VoicePresenceReport :async: Currently stored cumulative voice minutes per member for ``event_id``, to resume tracking after a restart. .. py:method:: list_cases(guild_id: int, *, user_id: int | None = None, limit: int = 25, offset: int = 0) -> list[sidequest_common.schemas.ModCaseSchema] :async: Newest first, optionally only for ``user_id``. .. py:method:: list_level_roles(guild_id: int) -> list[sidequest_common.schemas.LevelRoleSchema] :async: Level rewards, lowest level first. .. py:method:: list_member_links(guild_id: int) -> list[sidequest_common.schemas.MemberLink] :async: Every signed-in member of ``guild_id``, and whether they've also linked Battle.net. .. py:method:: list_pending_announcements(guild_id: int) -> list[sidequest_common.schemas.PendingAnnouncement] :async: Discord-queued announcements not yet posted, oldest first. .. py:method:: list_role_menus(guild_id: int) -> list[sidequest_common.schemas.RoleMenuSchema] :async: All menus in the guild. .. py:method:: list_role_resets(guild_id: int) -> list[sidequest_common.schemas.RoleResetCandidate] :async: Pending Discord role resets owed after a site account deletion, oldest first, capped at 50. .. py:method:: list_signup_notices(guild_id: int) -> sidequest_common.schemas.SignupNoticesPage :async: Members needing a new notice posted, and posted notices whose message is now stale. .. py:method:: list_upcoming_events() -> list[sidequest_common.schemas.EventSummary] :async: Upcoming guild events, soonest first (the public ``/api/events`` list; also materializes repeats). .. py:method:: list_weekly_board_events(guild_id: int, start: datetime.datetime, end: datetime.datetime) -> list[sidequest_common.schemas.WeeklyBoardEvent] :async: Scheduled events starting in ``[start, end)``, soonest first, with their forum thread id if any. .. py:method:: mark_announcement_posted(guild_id: int, announcement_id: uuid.UUID, message_id: int) -> None :async: Record that ``announcement_id`` posted to Discord as ``message_id``. .. py:method:: put_guild_roles(guild_id: int, roles: list[sidequest_common.schemas.GuildRoleIn]) -> None :async: Replace ``guild_id``'s full role set (excluding ``@everyone``, dropped by the caller). .. py:method:: put_member_profiles(guild_id: int, profiles: list[sidequest_common.schemas.MemberProfileIn], departed_ids: list[int]) -> None :async: Push linked members' current Discord profiles and report linked members who've left. .. py:method:: reconcile_event_sync(guild_id: int, reconcile: sidequest_common.schemas.DiscordEventReconcile) -> None :async: Report every scheduled event currently in the guild. .. py:method:: report_discord_event(guild_id: int, state: sidequest_common.schemas.DiscordEventState) -> None :async: Report that Discord now holds ``state`` for one event (created, edited, or read off the gateway). .. py:method:: report_discord_event_deleted(guild_id: int, event_id: int) -> None :async: Report that ``event_id`` was deleted in Discord. .. py:method:: report_discord_live(guild_id: int, report: sidequest_common.schemas.DiscordLiveReport) -> None :async: Push the current voice/presence snapshot for ``guild_id``. .. py:method:: report_event_interest(guild_id: int, discord_event_id: int, report: sidequest_common.schemas.DiscordInterestReport) -> None :async: Replace the interested-user set for the guild event linked to ``discord_event_id``; no-op if unlinked. .. py:method:: report_voice_presence(guild_id: int, report: sidequest_common.schemas.VoicePresenceReport) -> None :async: Push cumulative voice minutes per member for one event so far. Idempotent: the API keeps ``max(stored, reported)`` per member, so a repeated or restart-reset push never lowers the stored total. .. py:method:: set_level_role(guild_id: int, level: int, role_id: int) -> sidequest_common.schemas.LevelRoleSchema :async: Grant ``role_id`` at ``level`` (replaces any existing reward for that level). .. py:method:: set_xp(guild_id: int, user_id: int, xp: int) -> sidequest_common.schemas.MemberLevelSchema :async: Overwrite a member's XP total; level is recomputed. .. py:method:: sync_badges(guild_id: int) -> sidequest_common.schemas.BadgeSyncReport :async: Recompute every member's badges, award newly earned ones, and report role state + new announcements. .. py:method:: unconfirm_roster_from_notice(guild_id: int, member_id: int, request: sidequest_common.schemas.ConfirmByDiscordRequest) -> sidequest_common.schemas.MemberListing :async: Clear site member ``member_id``'s roster confirmation, with the acting GM identified by Discord id. .. py:method:: update_case(guild_id: int, case_number: int, changes: sidequest_common.schemas.ModCaseUpdate) -> sidequest_common.schemas.ModCaseSchema :async: Edit a case (currently just its reason). .. py:method:: update_guild_config(guild_id: int, changes: sidequest_common.schemas.GuildConfigUpdate) -> sidequest_common.schemas.GuildConfigSchema :async: Apply only the fields set on ``changes``. .. py:method:: update_role_menu(guild_id: int, menu_id: uuid.UUID, changes: sidequest_common.schemas.RoleMenuUpdate) -> sidequest_common.schemas.RoleMenuSchema :async: Apply only the fields set on ``changes``. .. py:method:: upsert_event_signup_by_discord(guild_id: int, event_id: uuid.UUID, discord_id: int, data: sidequest_common.schemas.SignupUpsert) -> sidequest_common.schemas.Signup :async: Create or replace a member's signup for ``event_id``, by Discord id (Discord's sign-up buttons). Raises ``NotFoundError`` if ``discord_id`` has never signed in. The caller is responsible for handling a 409 (event not scheduled) or 400 (role kind missing character/class/role) ``APIError``. .. py:method:: upsert_role_menu_option(guild_id: int, menu_id: uuid.UUID, role_id: int, option: sidequest_common.schemas.RoleMenuOptionUpsert) -> sidequest_common.schemas.RoleMenuSchema :async: Add or edit the button for ``role_id``; returns the updated menu. .. py:data:: API_KEY_HEADER :value: 'X-API-Key' .. py:data:: logger