sidequest_bot.api_client#

Typed HTTP client for the SideQuest API. The only way the bot talks to the API.

Attributes#

Exceptions#

APIError

The API returned an error response, or couldn't be reached. Every client failure is an APIError.

APIResponseShapeError

The API answered, but not in the shape this bot expects: usually the other half of a rolling deploy.

APIUnavailableError

The API couldn't be reached (connection/timeout) after retries. Reported as status 503.

NotFoundError

The API returned 404.

Classes#

SideQuestAPI

Async client for the SideQuest API.

Module Contents#

exception sidequest_bot.api_client.APIError(status_code: int, detail: str)#

Bases: 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.

detail#
status_code#
exception sidequest_bot.api_client.APIResponseShapeError(status_code: int, detail: str)#

Bases: 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.

exception sidequest_bot.api_client.APIUnavailableError(status_code: int, detail: str)#

Bases: APIError

The API couldn’t be reached (connection/timeout) after retries. Reported as status 503.

Initialize self. See help(type(self)) for accurate signature.

exception sidequest_bot.api_client.NotFoundError(status_code: int, detail: str)#

Bases: APIError

The API returned 404.

Initialize self. See help(type(self)) for accurate signature.

class sidequest_bot.api_client.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).

async ack_badge(guild_id: int, ack: sidequest_common.schemas.BadgeAck) → None#

Confirm one member’s badge announcement posted, so sync_badges stops reporting it as pending.

async ack_event_activity(guild_id: int, ids: list[uuid.UUID]) → None#

Confirm these sign-up activity rows were posted in their event’s forum thread.

async ack_event_push(guild_id: int, event_id: uuid.UUID, ack: sidequest_common.schemas.DiscordPushAck) → None#

Confirm ack.push_hash was applied (or skipped) for event_id.

async ack_role_reset(guild_id: int, reset_id: uuid.UUID) → None#

Confirm the bot stripped this member’s roles (or found them no longer in the guild).

async ack_signup_notice(guild_id: int, member_id: int, ack: sidequest_common.schemas.SignupNoticeAck) → None#

Confirm the bot’s message for member_id now reflects their current roster state.

async aclose() → None#

Close the underlying HTTP client if this instance created it.

async award_xp(guild_id: int, user_id: int) → sidequest_common.schemas.XPAwardResult#

Award one message’s worth of XP (API enforces cooldown and amount).

async confirm_roster_from_notice(guild_id: int, member_id: int, request: sidequest_common.schemas.ConfirmByDiscordRequest) → sidequest_common.schemas.MemberListing#

Confirm site member member_id on the roster, with the acting GM identified by Discord id.

async create_case(guild_id: int, case: sidequest_common.schemas.ModCaseCreate) → sidequest_common.schemas.ModCaseSchema#

Record a moderation action; the API assigns the case number.

async create_role_menu(guild_id: int, menu: sidequest_common.schemas.RoleMenuCreate) → sidequest_common.schemas.RoleMenuSchema#

Create a menu (no message yet; PATCH message_id after posting).

async delete_event_sync_tombstone(guild_id: int, event_id: int) → None#

Clear the tombstone for event_id after deleting it in Discord.

async delete_level_role(guild_id: int, level: int) → None#

Remove the reward at level. Raises NotFoundError if none.

async delete_role_menu(guild_id: int, menu_id: uuid.UUID) → None#

Delete a menu and its options.

async delete_role_menu_option(guild_id: int, menu_id: uuid.UUID, role_id: int) → sidequest_common.schemas.RoleMenuSchema#

Remove the button for role_id; returns the updated menu.

async get_away_now(guild_id: int) → list[int]#

Discord ids of members marked away today (America/Chicago).

async get_case(guild_id: int, case_number: int) → sidequest_common.schemas.ModCaseSchema#

One case. Raises NotFoundError if missing.

async get_event_signup_by_discord(guild_id: int, event_id: uuid.UUID, discord_id: int) → sidequest_common.schemas.SignupByDiscord#

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.

async get_guild_config(guild_id: int) → sidequest_common.schemas.GuildConfigSchema#

Config for guild_id (created with defaults on first access).

async get_leaderboard(guild_id: int, *, limit: int = 10, offset: int = 0) → sidequest_common.schemas.LeaderboardPage#

Highest XP first.

async get_pending_event_sync(guild_id: int) → sidequest_common.schemas.DiscordSyncPending#

Pushes and deletions the bot should apply to Discord now.

async get_rank(guild_id: int, user_id: int) → sidequest_common.schemas.RankSchema#

A member’s rank; members without XP get rank=None and zeros.

async get_recruiting() → sidequest_common.schemas.Recruiting#

What roles and classes the guild is recruiting for (the public /api/recruiting GET).

async get_role_menu(guild_id: int, menu_id: uuid.UUID) → sidequest_common.schemas.RoleMenuSchema#

One menu with its options.

async get_voice_presence(guild_id: int, event_id: uuid.UUID) → sidequest_common.schemas.VoicePresenceReport#

Currently stored cumulative voice minutes per member for event_id, to resume tracking after a restart.

async list_cases(guild_id: int, *, user_id: int | None = None, limit: int = 25, offset: int = 0) → list[sidequest_common.schemas.ModCaseSchema]#

Newest first, optionally only for user_id.

async list_level_roles(guild_id: int) → list[sidequest_common.schemas.LevelRoleSchema]#

Level rewards, lowest level first.

Every signed-in member of guild_id, and whether they’ve also linked Battle.net.

async list_pending_announcements(guild_id: int) → list[sidequest_common.schemas.PendingAnnouncement]#

Discord-queued announcements not yet posted, oldest first.

async list_role_menus(guild_id: int) → list[sidequest_common.schemas.RoleMenuSchema]#

All menus in the guild.

async list_role_resets(guild_id: int) → list[sidequest_common.schemas.RoleResetCandidate]#

Pending Discord role resets owed after a site account deletion, oldest first, capped at 50.

async list_signup_notices(guild_id: int) → sidequest_common.schemas.SignupNoticesPage#

Members needing a new notice posted, and posted notices whose message is now stale.

async list_upcoming_events() → list[sidequest_common.schemas.EventSummary]#

Upcoming guild events, soonest first (the public /api/events list; also materializes repeats).

async list_weekly_board_events(guild_id: int, start: datetime.datetime, end: datetime.datetime) → list[sidequest_common.schemas.WeeklyBoardEvent]#

Scheduled events starting in [start, end), soonest first, with their forum thread id if any.

async mark_announcement_posted(guild_id: int, announcement_id: uuid.UUID, message_id: int) → None#

Record that announcement_id posted to Discord as message_id.

async put_guild_roles(guild_id: int, roles: list[sidequest_common.schemas.GuildRoleIn]) → None#

Replace guild_id’s full role set (excluding @everyone, dropped by the caller).

async put_member_profiles(guild_id: int, profiles: list[sidequest_common.schemas.MemberProfileIn], departed_ids: list[int]) → None#

Push linked members’ current Discord profiles and report linked members who’ve left.

async reconcile_event_sync(guild_id: int, reconcile: sidequest_common.schemas.DiscordEventReconcile) → None#

Report every scheduled event currently in the guild.

async report_discord_event(guild_id: int, state: sidequest_common.schemas.DiscordEventState) → None#

Report that Discord now holds state for one event (created, edited, or read off the gateway).

async report_discord_event_deleted(guild_id: int, event_id: int) → None#

Report that event_id was deleted in Discord.

async report_discord_live(guild_id: int, report: sidequest_common.schemas.DiscordLiveReport) → None#

Push the current voice/presence snapshot for guild_id.

async report_event_interest(guild_id: int, discord_event_id: int, report: sidequest_common.schemas.DiscordInterestReport) → None#

Replace the interested-user set for the guild event linked to discord_event_id; no-op if unlinked.

async report_voice_presence(guild_id: int, report: sidequest_common.schemas.VoicePresenceReport) → None#

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.

async set_level_role(guild_id: int, level: int, role_id: int) → sidequest_common.schemas.LevelRoleSchema#

Grant role_id at level (replaces any existing reward for that level).

async set_xp(guild_id: int, user_id: int, xp: int) → sidequest_common.schemas.MemberLevelSchema#

Overwrite a member’s XP total; level is recomputed.

async sync_badges(guild_id: int) → sidequest_common.schemas.BadgeSyncReport#

Recompute every member’s badges, award newly earned ones, and report role state + new announcements.

async unconfirm_roster_from_notice(guild_id: int, member_id: int, request: sidequest_common.schemas.ConfirmByDiscordRequest) → sidequest_common.schemas.MemberListing#

Clear site member member_id’s roster confirmation, with the acting GM identified by Discord id.

async update_case(guild_id: int, case_number: int, changes: sidequest_common.schemas.ModCaseUpdate) → sidequest_common.schemas.ModCaseSchema#

Edit a case (currently just its reason).

async update_guild_config(guild_id: int, changes: sidequest_common.schemas.GuildConfigUpdate) → sidequest_common.schemas.GuildConfigSchema#

Apply only the fields set on changes.

async update_role_menu(guild_id: int, menu_id: uuid.UUID, changes: sidequest_common.schemas.RoleMenuUpdate) → sidequest_common.schemas.RoleMenuSchema#

Apply only the fields set on changes.

async upsert_event_signup_by_discord(guild_id: int, event_id: uuid.UUID, discord_id: int, data: sidequest_common.schemas.SignupUpsert) → sidequest_common.schemas.Signup#

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.

async upsert_role_menu_option(guild_id: int, menu_id: uuid.UUID, role_id: int, option: sidequest_common.schemas.RoleMenuOptionUpsert) → sidequest_common.schemas.RoleMenuSchema#

Add or edit the button for role_id; returns the updated menu.

sidequest_bot.api_client.API_KEY_HEADER = 'X-API-Key'#
sidequest_bot.api_client.logger#