ctfy.sdk.resources.me

client.me — the calling user's profile, inbox, account + progress.

  1"""``client.me`` — the calling user's profile, inbox, account + progress."""
  2
  3from __future__ import annotations
  4
  5from typing import Any
  6
  7from ctfy.sdk._helpers import _raise_for_status
  8from ctfy.sdk.base import BaseHttpClient
  9from ctfy.server.models import (
 10    AccountDeletionAccepted,
 11    EmailVerificationSent,
 12    InboxResponse,
 13    MeResponse,
 14    MilestoneProgress,
 15    MyAchievementsResponse,
 16    MyFavourites,
 17    MyResults,
 18    MySolveSummary,
 19    NotificationPreferencesResponse,
 20    ReauthenticationOpened,
 21    StarGazerVerifyResponse,
 22)
 23
 24
 25class MeResource:
 26    """Self-service surface for the authenticated caller."""
 27
 28    def __init__(self, http: BaseHttpClient) -> None:
 29        self._http = http
 30
 31    def get(self) -> MeResponse:
 32        """Get the calling user's profile + auth context.
 33
 34        Per-competition team memberships ride along on
 35        ``competition_teams``; there is no global "current team" any
 36        more — pick the row whose ``competition_id`` matches the
 37        scope you care about.
 38        """
 39        resp = self._http.request("GET", "/me")
 40        _raise_for_status(resp)
 41        return MeResponse.model_validate(resp.json())
 42
 43    def inbox(self) -> InboxResponse:
 44        """Pending team invites, join requests, direct invites, announcements."""
 45        resp = self._http.request("GET", "/me/inbox")
 46        _raise_for_status(resp)
 47        return InboxResponse.model_validate(resp.json())
 48
 49    def notification_preferences(self) -> NotificationPreferencesResponse:
 50        """Which categories reach the caller by email.
 51
 52        Each cell reports ``effective`` (what happens now), ``default``
 53        (what reverting would restore) and ``explicit`` (whether the user
 54        actually chose it, or is merely inheriting). Storage is sparse, so
 55        an untouched cell has no row — ``explicit`` is how you tell.
 56        """
 57        resp = self._http.request("GET", "/me/notification-preferences")
 58        _raise_for_status(resp)
 59        return NotificationPreferencesResponse.model_validate(resp.json())
 60
 61    def set_notification_preference(
 62        self, category: str, enabled: bool, *, channel: str = "email"
 63    ) -> NotificationPreferencesResponse:
 64        """Turn one category on or off for one channel.
 65
 66        ``security`` is refused with a 400 — sign-in codes and account
 67        alerts exist to protect the account and are not switchable.
 68        """
 69        resp = self._http.request(
 70            "PATCH",
 71            "/me/notification-preferences",
 72            json={"category": category, "channel": channel, "enabled": enabled},
 73        )
 74        _raise_for_status(resp)
 75        return NotificationPreferencesResponse.model_validate(resp.json())
 76
 77    def clear_notification_preference(
 78        self, category: str, *, channel: str = "email"
 79    ) -> NotificationPreferencesResponse:
 80        """Drop the explicit cell so the default applies again.
 81
 82        This is *revert*, not *disable*: for most categories the default
 83        is on, so clearing an explicit ``False`` turns mail back on.
 84        """
 85        resp = self._http.request("DELETE", f"/me/notification-preferences/{category}/{channel}")
 86        _raise_for_status(resp)
 87        return NotificationPreferencesResponse.model_validate(resp.json())
 88
 89    def read_announcement(self, announcement_id: str) -> None:
 90        """Mark one announcement as read for the calling user."""
 91        resp = self._http.request("POST", f"/me/announcements/{announcement_id}/read")
 92        _raise_for_status(resp)
 93
 94    def read_all_announcements(self) -> None:
 95        """Mark every relevant announcement as read."""
 96        resp = self._http.request("POST", "/me/announcements/read-all")
 97        _raise_for_status(resp)
 98
 99    def update_profile(self, **fields: Any) -> MeResponse:
100        """PATCH the caller's profile.
101
102        Pass any subset of ``bio`` / ``country`` / ``website_url`` /
103        ``timezone`` / ``social_links``. Omit a key to leave it unchanged;
104        pass ``None`` to clear it. Returns the updated :class:`MeResponse`.
105
106        Example::
107
108            client.me.update_profile(bio="hacking goblins", country="JP")
109            client.me.update_profile(website_url=None)  # clear it
110        """
111        resp = self._http.request("PATCH", "/me/profile", json=fields)
112        _raise_for_status(resp)
113        return MeResponse.model_validate(resp.json())
114
115    def update_visibility(self, overrides: dict[str, bool]) -> MeResponse:
116        """PATCH per-field privacy flags on the caller's profile.
117
118        Keys outside :data:`PROFILE_VISIBILITY_KEYS` are silently dropped
119        server-side. Returns the updated :class:`MeResponse`.
120        """
121        resp = self._http.request("PATCH", "/me/profile/visibility", json=overrides)
122        _raise_for_status(resp)
123        return MeResponse.model_validate(resp.json())
124
125    def export_account(self) -> bytes:
126        """Download a ZIP archive of every piece of data the platform
127        holds about the caller (user, identities, tokens, memberships,
128        submissions, solves, activity). Returns raw bytes — write to a
129        file with ``Path(...).write_bytes(client.me.export_account())``."""
130        resp = self._http.request("GET", "/me/export")
131        _raise_for_status(resp)
132        return resp.content
133
134    def reauthenticate(
135        self,
136        *,
137        current_password: str = "",
138        code: str = "",
139    ) -> ReauthenticationOpened:
140        """Prove a factor again, opening the sudo window on **this** token.
141
142        A handful of account changes — repointing the primary address,
143        setting or removing a password, unlinking an identity, minting a
144        token, widening one, adding an address, linking an identity,
145        approving a device login — refuse with ``409
146        reauthentication_required`` unless the credential making the
147        request proved a factor inside the last
148        ``reauth_window_s`` seconds. This is how it proves one.
149
150        Signing in already counts, so a freshly-minted session needs
151        nothing; this is for a bearer that has been held past the
152        window. Read ``deletion_proof`` off :meth:`get` to learn which
153        factor this account has — it is the same answer for both — and
154        pass that one. An account with neither passes with neither.
155
156        The window lands on the token in the ``Authorization`` header
157        and no other: proving on a laptop does not open it for a bearer
158        somebody else is holding.
159        """
160        body: dict[str, str] = {}
161        if current_password:
162            body["current_password"] = current_password
163        if code:
164            body["code"] = code
165        resp = self._http.request("POST", "/me/reauthenticate", json=body)
166        _raise_for_status(resp)
167        return ReauthenticationOpened.model_validate(resp.json())
168
169    def request_reauth_code(self) -> EmailVerificationSent:
170        """Mail this account's own address a code that opens the window.
171
172        Refused (409 ``reauth_proof_required``) unless a mailed code is
173        what this account re-proves with — one holding a password
174        re-enters that instead. ⚠️ A *different* code from the deletion
175        one: digits handed over under "confirm it is you" must not also
176        erase the account.
177        """
178        resp = self._http.request("POST", "/me/reauthenticate/code")
179        _raise_for_status(resp)
180        return EmailVerificationSent.model_validate(resp.json())
181
182    def request_deletion_code(self) -> EmailVerificationSent:
183        """Mail this account's own address a code that deletes it.
184
185        Refused (409 ``deletion_proof_required``) unless a mailed code is
186        what this account's proof actually is — read ``deletion_proof``
187        off :meth:`get` first. An account with a password re-enters that
188        instead, and one with neither needs no code at all.
189        """
190        resp = self._http.request("POST", "/me/deletion-code")
191        _raise_for_status(resp)
192        return EmailVerificationSent.model_validate(resp.json())
193
194    def delete_account(
195        self,
196        confirm_display_name: str,
197        *,
198        current_password: str = "",
199        code: str = "",
200    ) -> AccountDeletionAccepted:
201        """Self-delete the calling user.
202
203        ``confirm_display_name`` must match the caller's display name
204        (falls back to email when empty) exactly — the typo gate against
205        fat-fingered destruction, and **not** the authorization half: it
206        is a public string.
207
208        The second factor is whichever ``deletion_proof`` on
209        :meth:`get` names — ``current_password`` for an account holding
210        a password, ``code`` from :meth:`request_deletion_code` for one
211        proving a mailed address, neither when it answers ``none``. The
212        server composes that answer and enforces it, so read it rather
213        than guessing; sending the wrong one is a 401.
214
215        ⚠️ Both are keyword-only and default blank, so a caller written
216        before the gate existed still compiles — and still works against
217        an account whose answer is ``none``, which is every account that
218        predates it.
219
220        ⚠️ **Read the answer: this does not always delete anything yet.**
221        With ``account_deletion_grace_s`` above zero — the default — the
222        account is *scheduled*: its tokens are revoked and a job runs the
223        cascade later, and signing in before then cancels the whole
224        thing. ``deleted`` says which happened and ``effective_at`` says
225        when it becomes final. A caller that discards this cannot tell
226        *gone* from *recoverable*, which are opposite facts with opposite
227        next steps.
228        """
229        body: dict[str, str] = {"confirm_display_name": confirm_display_name}
230        if current_password:
231            body["current_password"] = current_password
232        if code:
233            body["code"] = code
234        resp = self._http.request("DELETE", "/me", json=body)
235        _raise_for_status(resp)
236        return AccountDeletionAccepted.model_validate(resp.json())
237
238    def achievements(self, competition_id: str = "") -> MyAchievementsResponse:
239        """Caller's unlocked + locked badges (with progress hints)."""
240        params: dict[str, str] = {}
241        if competition_id:
242            params["competition_id"] = competition_id
243        resp = self._http.request("GET", "/me/achievements", params=params)
244        _raise_for_status(resp)
245        return MyAchievementsResponse.model_validate(resp.json())
246
247    def results(self) -> MyResults:
248        """我的战绩: every started event the caller entered, newest first, each
249        at that event's own official placement (never a total). Includes the
250        sittings only the caller may see -- in progress, frozen (ranked as of
251        the freeze), anonymous, disqualified -- each with ``only_you`` set."""
252        resp = self._http.request("GET", "/me/results")
253        _raise_for_status(resp)
254        return MyResults.model_validate(resp.json())
255
256    def favourites(self) -> MyFavourites:
257        """Every competition and challenge the caller has starred (#2070) —
258        whole sets, and only ever the caller's own: a favourite is a private
259        preference, never shown to another person or shared with a team."""
260        resp = self._http.request("GET", "/me/favourites")
261        _raise_for_status(resp)
262        return MyFavourites.model_validate(resp.json())
263
264    def favourite(self, kind: str, target_id: str) -> MyFavourites:
265        """Star a ``competition`` or a ``challenge``; returns the caller's full
266        set. Idempotent. A competition the caller cannot see is a 404."""
267        resp = self._http.request("PUT", f"/me/favourites/{kind}/{target_id}")
268        _raise_for_status(resp)
269        return MyFavourites.model_validate(resp.json())
270
271    def unfavourite(self, kind: str, target_id: str) -> MyFavourites:
272        """Remove a star; removing one that is not there is the same answer."""
273        resp = self._http.request("DELETE", f"/me/favourites/{kind}/{target_id}")
274        _raise_for_status(resp)
275        return MyFavourites.model_validate(resp.json())
276
277    def solves(self, *, competition_id: str = "") -> list[MySolveSummary]:
278        """Per-challenge solve summary for the caller.
279
280        Unscoped this spans every team the caller has ever played for.
281        Pass ``competition_id`` to ask the narrower question a
282        competition-scoped surface means: have I solved this *here*.
283        """
284        params: dict[str, str] = {}
285        if competition_id:
286            params["competition_id"] = competition_id
287        resp = self._http.request("GET", "/me/solves", params=params)
288        _raise_for_status(resp)
289        return [MySolveSummary.model_validate(s) for s in resp.json()]
290
291    def milestone_progress(self, *, competition_id: str = "") -> list[MilestoneProgress]:
292        """Partial-solve progress per challenge for the caller.
293
294        One row per challenge with at least one captured question;
295        ``solved_question_ids`` enumerates which milestones the user
296        has cleared. The row deliberately carries no question total:
297        the number of objectives is player-facing secret (#2435).
298        With ``competition_id`` set the result narrows to solves
299        stamped against the caller's team in that comp.
300        """
301        params: dict[str, str] = {}
302        if competition_id:
303            params["competition_id"] = competition_id
304        resp = self._http.request("GET", "/me/milestone-progress", params=params)
305        _raise_for_status(resp)
306        return [MilestoneProgress.model_validate(s) for s in resp.json()]
307
308    def verify_star_gazer(self, competition_id: str = "") -> StarGazerVerifyResponse:
309        """Verify the caller starred the ctfy GitHub repo and grant the
310        ``star_gazer`` badge if so.
311
312        Friendly failures (no GitHub identity, repo not configured, not
313        yet starred, GitHub rate-limited) come back as 200 with
314        ``verified=False`` + an actionable ``reason``."""
315        params = {"competition_id": competition_id} if competition_id else None
316        resp = self._http.request("POST", "/me/star-gazer/verify", params=params)
317        _raise_for_status(resp)
318        return StarGazerVerifyResponse.model_validate(resp.json())
class MeResource:
 26class MeResource:
 27    """Self-service surface for the authenticated caller."""
 28
 29    def __init__(self, http: BaseHttpClient) -> None:
 30        self._http = http
 31
 32    def get(self) -> MeResponse:
 33        """Get the calling user's profile + auth context.
 34
 35        Per-competition team memberships ride along on
 36        ``competition_teams``; there is no global "current team" any
 37        more — pick the row whose ``competition_id`` matches the
 38        scope you care about.
 39        """
 40        resp = self._http.request("GET", "/me")
 41        _raise_for_status(resp)
 42        return MeResponse.model_validate(resp.json())
 43
 44    def inbox(self) -> InboxResponse:
 45        """Pending team invites, join requests, direct invites, announcements."""
 46        resp = self._http.request("GET", "/me/inbox")
 47        _raise_for_status(resp)
 48        return InboxResponse.model_validate(resp.json())
 49
 50    def notification_preferences(self) -> NotificationPreferencesResponse:
 51        """Which categories reach the caller by email.
 52
 53        Each cell reports ``effective`` (what happens now), ``default``
 54        (what reverting would restore) and ``explicit`` (whether the user
 55        actually chose it, or is merely inheriting). Storage is sparse, so
 56        an untouched cell has no row — ``explicit`` is how you tell.
 57        """
 58        resp = self._http.request("GET", "/me/notification-preferences")
 59        _raise_for_status(resp)
 60        return NotificationPreferencesResponse.model_validate(resp.json())
 61
 62    def set_notification_preference(
 63        self, category: str, enabled: bool, *, channel: str = "email"
 64    ) -> NotificationPreferencesResponse:
 65        """Turn one category on or off for one channel.
 66
 67        ``security`` is refused with a 400 — sign-in codes and account
 68        alerts exist to protect the account and are not switchable.
 69        """
 70        resp = self._http.request(
 71            "PATCH",
 72            "/me/notification-preferences",
 73            json={"category": category, "channel": channel, "enabled": enabled},
 74        )
 75        _raise_for_status(resp)
 76        return NotificationPreferencesResponse.model_validate(resp.json())
 77
 78    def clear_notification_preference(
 79        self, category: str, *, channel: str = "email"
 80    ) -> NotificationPreferencesResponse:
 81        """Drop the explicit cell so the default applies again.
 82
 83        This is *revert*, not *disable*: for most categories the default
 84        is on, so clearing an explicit ``False`` turns mail back on.
 85        """
 86        resp = self._http.request("DELETE", f"/me/notification-preferences/{category}/{channel}")
 87        _raise_for_status(resp)
 88        return NotificationPreferencesResponse.model_validate(resp.json())
 89
 90    def read_announcement(self, announcement_id: str) -> None:
 91        """Mark one announcement as read for the calling user."""
 92        resp = self._http.request("POST", f"/me/announcements/{announcement_id}/read")
 93        _raise_for_status(resp)
 94
 95    def read_all_announcements(self) -> None:
 96        """Mark every relevant announcement as read."""
 97        resp = self._http.request("POST", "/me/announcements/read-all")
 98        _raise_for_status(resp)
 99
100    def update_profile(self, **fields: Any) -> MeResponse:
101        """PATCH the caller's profile.
102
103        Pass any subset of ``bio`` / ``country`` / ``website_url`` /
104        ``timezone`` / ``social_links``. Omit a key to leave it unchanged;
105        pass ``None`` to clear it. Returns the updated :class:`MeResponse`.
106
107        Example::
108
109            client.me.update_profile(bio="hacking goblins", country="JP")
110            client.me.update_profile(website_url=None)  # clear it
111        """
112        resp = self._http.request("PATCH", "/me/profile", json=fields)
113        _raise_for_status(resp)
114        return MeResponse.model_validate(resp.json())
115
116    def update_visibility(self, overrides: dict[str, bool]) -> MeResponse:
117        """PATCH per-field privacy flags on the caller's profile.
118
119        Keys outside :data:`PROFILE_VISIBILITY_KEYS` are silently dropped
120        server-side. Returns the updated :class:`MeResponse`.
121        """
122        resp = self._http.request("PATCH", "/me/profile/visibility", json=overrides)
123        _raise_for_status(resp)
124        return MeResponse.model_validate(resp.json())
125
126    def export_account(self) -> bytes:
127        """Download a ZIP archive of every piece of data the platform
128        holds about the caller (user, identities, tokens, memberships,
129        submissions, solves, activity). Returns raw bytes — write to a
130        file with ``Path(...).write_bytes(client.me.export_account())``."""
131        resp = self._http.request("GET", "/me/export")
132        _raise_for_status(resp)
133        return resp.content
134
135    def reauthenticate(
136        self,
137        *,
138        current_password: str = "",
139        code: str = "",
140    ) -> ReauthenticationOpened:
141        """Prove a factor again, opening the sudo window on **this** token.
142
143        A handful of account changes — repointing the primary address,
144        setting or removing a password, unlinking an identity, minting a
145        token, widening one, adding an address, linking an identity,
146        approving a device login — refuse with ``409
147        reauthentication_required`` unless the credential making the
148        request proved a factor inside the last
149        ``reauth_window_s`` seconds. This is how it proves one.
150
151        Signing in already counts, so a freshly-minted session needs
152        nothing; this is for a bearer that has been held past the
153        window. Read ``deletion_proof`` off :meth:`get` to learn which
154        factor this account has — it is the same answer for both — and
155        pass that one. An account with neither passes with neither.
156
157        The window lands on the token in the ``Authorization`` header
158        and no other: proving on a laptop does not open it for a bearer
159        somebody else is holding.
160        """
161        body: dict[str, str] = {}
162        if current_password:
163            body["current_password"] = current_password
164        if code:
165            body["code"] = code
166        resp = self._http.request("POST", "/me/reauthenticate", json=body)
167        _raise_for_status(resp)
168        return ReauthenticationOpened.model_validate(resp.json())
169
170    def request_reauth_code(self) -> EmailVerificationSent:
171        """Mail this account's own address a code that opens the window.
172
173        Refused (409 ``reauth_proof_required``) unless a mailed code is
174        what this account re-proves with — one holding a password
175        re-enters that instead. ⚠️ A *different* code from the deletion
176        one: digits handed over under "confirm it is you" must not also
177        erase the account.
178        """
179        resp = self._http.request("POST", "/me/reauthenticate/code")
180        _raise_for_status(resp)
181        return EmailVerificationSent.model_validate(resp.json())
182
183    def request_deletion_code(self) -> EmailVerificationSent:
184        """Mail this account's own address a code that deletes it.
185
186        Refused (409 ``deletion_proof_required``) unless a mailed code is
187        what this account's proof actually is — read ``deletion_proof``
188        off :meth:`get` first. An account with a password re-enters that
189        instead, and one with neither needs no code at all.
190        """
191        resp = self._http.request("POST", "/me/deletion-code")
192        _raise_for_status(resp)
193        return EmailVerificationSent.model_validate(resp.json())
194
195    def delete_account(
196        self,
197        confirm_display_name: str,
198        *,
199        current_password: str = "",
200        code: str = "",
201    ) -> AccountDeletionAccepted:
202        """Self-delete the calling user.
203
204        ``confirm_display_name`` must match the caller's display name
205        (falls back to email when empty) exactly — the typo gate against
206        fat-fingered destruction, and **not** the authorization half: it
207        is a public string.
208
209        The second factor is whichever ``deletion_proof`` on
210        :meth:`get` names — ``current_password`` for an account holding
211        a password, ``code`` from :meth:`request_deletion_code` for one
212        proving a mailed address, neither when it answers ``none``. The
213        server composes that answer and enforces it, so read it rather
214        than guessing; sending the wrong one is a 401.
215
216        ⚠️ Both are keyword-only and default blank, so a caller written
217        before the gate existed still compiles — and still works against
218        an account whose answer is ``none``, which is every account that
219        predates it.
220
221        ⚠️ **Read the answer: this does not always delete anything yet.**
222        With ``account_deletion_grace_s`` above zero — the default — the
223        account is *scheduled*: its tokens are revoked and a job runs the
224        cascade later, and signing in before then cancels the whole
225        thing. ``deleted`` says which happened and ``effective_at`` says
226        when it becomes final. A caller that discards this cannot tell
227        *gone* from *recoverable*, which are opposite facts with opposite
228        next steps.
229        """
230        body: dict[str, str] = {"confirm_display_name": confirm_display_name}
231        if current_password:
232            body["current_password"] = current_password
233        if code:
234            body["code"] = code
235        resp = self._http.request("DELETE", "/me", json=body)
236        _raise_for_status(resp)
237        return AccountDeletionAccepted.model_validate(resp.json())
238
239    def achievements(self, competition_id: str = "") -> MyAchievementsResponse:
240        """Caller's unlocked + locked badges (with progress hints)."""
241        params: dict[str, str] = {}
242        if competition_id:
243            params["competition_id"] = competition_id
244        resp = self._http.request("GET", "/me/achievements", params=params)
245        _raise_for_status(resp)
246        return MyAchievementsResponse.model_validate(resp.json())
247
248    def results(self) -> MyResults:
249        """我的战绩: every started event the caller entered, newest first, each
250        at that event's own official placement (never a total). Includes the
251        sittings only the caller may see -- in progress, frozen (ranked as of
252        the freeze), anonymous, disqualified -- each with ``only_you`` set."""
253        resp = self._http.request("GET", "/me/results")
254        _raise_for_status(resp)
255        return MyResults.model_validate(resp.json())
256
257    def favourites(self) -> MyFavourites:
258        """Every competition and challenge the caller has starred (#2070) —
259        whole sets, and only ever the caller's own: a favourite is a private
260        preference, never shown to another person or shared with a team."""
261        resp = self._http.request("GET", "/me/favourites")
262        _raise_for_status(resp)
263        return MyFavourites.model_validate(resp.json())
264
265    def favourite(self, kind: str, target_id: str) -> MyFavourites:
266        """Star a ``competition`` or a ``challenge``; returns the caller's full
267        set. Idempotent. A competition the caller cannot see is a 404."""
268        resp = self._http.request("PUT", f"/me/favourites/{kind}/{target_id}")
269        _raise_for_status(resp)
270        return MyFavourites.model_validate(resp.json())
271
272    def unfavourite(self, kind: str, target_id: str) -> MyFavourites:
273        """Remove a star; removing one that is not there is the same answer."""
274        resp = self._http.request("DELETE", f"/me/favourites/{kind}/{target_id}")
275        _raise_for_status(resp)
276        return MyFavourites.model_validate(resp.json())
277
278    def solves(self, *, competition_id: str = "") -> list[MySolveSummary]:
279        """Per-challenge solve summary for the caller.
280
281        Unscoped this spans every team the caller has ever played for.
282        Pass ``competition_id`` to ask the narrower question a
283        competition-scoped surface means: have I solved this *here*.
284        """
285        params: dict[str, str] = {}
286        if competition_id:
287            params["competition_id"] = competition_id
288        resp = self._http.request("GET", "/me/solves", params=params)
289        _raise_for_status(resp)
290        return [MySolveSummary.model_validate(s) for s in resp.json()]
291
292    def milestone_progress(self, *, competition_id: str = "") -> list[MilestoneProgress]:
293        """Partial-solve progress per challenge for the caller.
294
295        One row per challenge with at least one captured question;
296        ``solved_question_ids`` enumerates which milestones the user
297        has cleared. The row deliberately carries no question total:
298        the number of objectives is player-facing secret (#2435).
299        With ``competition_id`` set the result narrows to solves
300        stamped against the caller's team in that comp.
301        """
302        params: dict[str, str] = {}
303        if competition_id:
304            params["competition_id"] = competition_id
305        resp = self._http.request("GET", "/me/milestone-progress", params=params)
306        _raise_for_status(resp)
307        return [MilestoneProgress.model_validate(s) for s in resp.json()]
308
309    def verify_star_gazer(self, competition_id: str = "") -> StarGazerVerifyResponse:
310        """Verify the caller starred the ctfy GitHub repo and grant the
311        ``star_gazer`` badge if so.
312
313        Friendly failures (no GitHub identity, repo not configured, not
314        yet starred, GitHub rate-limited) come back as 200 with
315        ``verified=False`` + an actionable ``reason``."""
316        params = {"competition_id": competition_id} if competition_id else None
317        resp = self._http.request("POST", "/me/star-gazer/verify", params=params)
318        _raise_for_status(resp)
319        return StarGazerVerifyResponse.model_validate(resp.json())

Self-service surface for the authenticated caller.

MeResource(http: ctfy.sdk.base.BaseHttpClient)
29    def __init__(self, http: BaseHttpClient) -> None:
30        self._http = http
def get(self) -> ctfy.server.models.MeResponse:
32    def get(self) -> MeResponse:
33        """Get the calling user's profile + auth context.
34
35        Per-competition team memberships ride along on
36        ``competition_teams``; there is no global "current team" any
37        more — pick the row whose ``competition_id`` matches the
38        scope you care about.
39        """
40        resp = self._http.request("GET", "/me")
41        _raise_for_status(resp)
42        return MeResponse.model_validate(resp.json())

Get the calling user's profile + auth context.

Per-competition team memberships ride along on competition_teams; there is no global "current team" any more — pick the row whose competition_id matches the scope you care about.

def inbox(self) -> ctfy.server.models.InboxResponse:
44    def inbox(self) -> InboxResponse:
45        """Pending team invites, join requests, direct invites, announcements."""
46        resp = self._http.request("GET", "/me/inbox")
47        _raise_for_status(resp)
48        return InboxResponse.model_validate(resp.json())

Pending team invites, join requests, direct invites, announcements.

def notification_preferences(self) -> ctfy.server.models.NotificationPreferencesResponse:
50    def notification_preferences(self) -> NotificationPreferencesResponse:
51        """Which categories reach the caller by email.
52
53        Each cell reports ``effective`` (what happens now), ``default``
54        (what reverting would restore) and ``explicit`` (whether the user
55        actually chose it, or is merely inheriting). Storage is sparse, so
56        an untouched cell has no row — ``explicit`` is how you tell.
57        """
58        resp = self._http.request("GET", "/me/notification-preferences")
59        _raise_for_status(resp)
60        return NotificationPreferencesResponse.model_validate(resp.json())

Which categories reach the caller by email.

Each cell reports effective (what happens now), default (what reverting would restore) and explicit (whether the user actually chose it, or is merely inheriting). Storage is sparse, so an untouched cell has no row — explicit is how you tell.

def set_notification_preference( self, category: str, enabled: bool, *, channel: str = 'email') -> ctfy.server.models.NotificationPreferencesResponse:
62    def set_notification_preference(
63        self, category: str, enabled: bool, *, channel: str = "email"
64    ) -> NotificationPreferencesResponse:
65        """Turn one category on or off for one channel.
66
67        ``security`` is refused with a 400 — sign-in codes and account
68        alerts exist to protect the account and are not switchable.
69        """
70        resp = self._http.request(
71            "PATCH",
72            "/me/notification-preferences",
73            json={"category": category, "channel": channel, "enabled": enabled},
74        )
75        _raise_for_status(resp)
76        return NotificationPreferencesResponse.model_validate(resp.json())

Turn one category on or off for one channel.

security is refused with a 400 — sign-in codes and account alerts exist to protect the account and are not switchable.

def clear_notification_preference( self, category: str, *, channel: str = 'email') -> ctfy.server.models.NotificationPreferencesResponse:
78    def clear_notification_preference(
79        self, category: str, *, channel: str = "email"
80    ) -> NotificationPreferencesResponse:
81        """Drop the explicit cell so the default applies again.
82
83        This is *revert*, not *disable*: for most categories the default
84        is on, so clearing an explicit ``False`` turns mail back on.
85        """
86        resp = self._http.request("DELETE", f"/me/notification-preferences/{category}/{channel}")
87        _raise_for_status(resp)
88        return NotificationPreferencesResponse.model_validate(resp.json())

Drop the explicit cell so the default applies again.

This is revert, not disable: for most categories the default is on, so clearing an explicit False turns mail back on.

def read_announcement(self, announcement_id: str) -> None:
90    def read_announcement(self, announcement_id: str) -> None:
91        """Mark one announcement as read for the calling user."""
92        resp = self._http.request("POST", f"/me/announcements/{announcement_id}/read")
93        _raise_for_status(resp)

Mark one announcement as read for the calling user.

def read_all_announcements(self) -> None:
95    def read_all_announcements(self) -> None:
96        """Mark every relevant announcement as read."""
97        resp = self._http.request("POST", "/me/announcements/read-all")
98        _raise_for_status(resp)

Mark every relevant announcement as read.

def update_profile(self, **fields: Any) -> ctfy.server.models.MeResponse:
100    def update_profile(self, **fields: Any) -> MeResponse:
101        """PATCH the caller's profile.
102
103        Pass any subset of ``bio`` / ``country`` / ``website_url`` /
104        ``timezone`` / ``social_links``. Omit a key to leave it unchanged;
105        pass ``None`` to clear it. Returns the updated :class:`MeResponse`.
106
107        Example::
108
109            client.me.update_profile(bio="hacking goblins", country="JP")
110            client.me.update_profile(website_url=None)  # clear it
111        """
112        resp = self._http.request("PATCH", "/me/profile", json=fields)
113        _raise_for_status(resp)
114        return MeResponse.model_validate(resp.json())

PATCH the caller's profile.

Pass any subset of bio / country / website_url / timezone / social_links. Omit a key to leave it unchanged; pass None to clear it. Returns the updated MeResponse.

Example::

client.me.update_profile(bio="hacking goblins", country="JP")
client.me.update_profile(website_url=None)  # clear it
def update_visibility(self, overrides: dict[str, bool]) -> ctfy.server.models.MeResponse:
116    def update_visibility(self, overrides: dict[str, bool]) -> MeResponse:
117        """PATCH per-field privacy flags on the caller's profile.
118
119        Keys outside :data:`PROFILE_VISIBILITY_KEYS` are silently dropped
120        server-side. Returns the updated :class:`MeResponse`.
121        """
122        resp = self._http.request("PATCH", "/me/profile/visibility", json=overrides)
123        _raise_for_status(resp)
124        return MeResponse.model_validate(resp.json())

PATCH per-field privacy flags on the caller's profile.

Keys outside PROFILE_VISIBILITY_KEYS are silently dropped server-side. Returns the updated MeResponse.

def export_account(self) -> bytes:
126    def export_account(self) -> bytes:
127        """Download a ZIP archive of every piece of data the platform
128        holds about the caller (user, identities, tokens, memberships,
129        submissions, solves, activity). Returns raw bytes — write to a
130        file with ``Path(...).write_bytes(client.me.export_account())``."""
131        resp = self._http.request("GET", "/me/export")
132        _raise_for_status(resp)
133        return resp.content

Download a ZIP archive of every piece of data the platform holds about the caller (user, identities, tokens, memberships, submissions, solves, activity). Returns raw bytes — write to a file with Path(...).write_bytes(client.me.export_account()).

def reauthenticate( self, *, current_password: str = '', code: str = '') -> ctfy.server.models.ReauthenticationOpened:
135    def reauthenticate(
136        self,
137        *,
138        current_password: str = "",
139        code: str = "",
140    ) -> ReauthenticationOpened:
141        """Prove a factor again, opening the sudo window on **this** token.
142
143        A handful of account changes — repointing the primary address,
144        setting or removing a password, unlinking an identity, minting a
145        token, widening one, adding an address, linking an identity,
146        approving a device login — refuse with ``409
147        reauthentication_required`` unless the credential making the
148        request proved a factor inside the last
149        ``reauth_window_s`` seconds. This is how it proves one.
150
151        Signing in already counts, so a freshly-minted session needs
152        nothing; this is for a bearer that has been held past the
153        window. Read ``deletion_proof`` off :meth:`get` to learn which
154        factor this account has — it is the same answer for both — and
155        pass that one. An account with neither passes with neither.
156
157        The window lands on the token in the ``Authorization`` header
158        and no other: proving on a laptop does not open it for a bearer
159        somebody else is holding.
160        """
161        body: dict[str, str] = {}
162        if current_password:
163            body["current_password"] = current_password
164        if code:
165            body["code"] = code
166        resp = self._http.request("POST", "/me/reauthenticate", json=body)
167        _raise_for_status(resp)
168        return ReauthenticationOpened.model_validate(resp.json())

Prove a factor again, opening the sudo window on this token.

A handful of account changes — repointing the primary address, setting or removing a password, unlinking an identity, minting a token, widening one, adding an address, linking an identity, approving a device login — refuse with 409 reauthentication_required unless the credential making the request proved a factor inside the last reauth_window_s seconds. This is how it proves one.

Signing in already counts, so a freshly-minted session needs nothing; this is for a bearer that has been held past the window. Read deletion_proof off get() to learn which factor this account has — it is the same answer for both — and pass that one. An account with neither passes with neither.

The window lands on the token in the Authorization header and no other: proving on a laptop does not open it for a bearer somebody else is holding.

def request_reauth_code(self) -> ctfy.server.models.EmailVerificationSent:
170    def request_reauth_code(self) -> EmailVerificationSent:
171        """Mail this account's own address a code that opens the window.
172
173        Refused (409 ``reauth_proof_required``) unless a mailed code is
174        what this account re-proves with — one holding a password
175        re-enters that instead. ⚠️ A *different* code from the deletion
176        one: digits handed over under "confirm it is you" must not also
177        erase the account.
178        """
179        resp = self._http.request("POST", "/me/reauthenticate/code")
180        _raise_for_status(resp)
181        return EmailVerificationSent.model_validate(resp.json())

Mail this account's own address a code that opens the window.

Refused (409 reauth_proof_required) unless a mailed code is what this account re-proves with — one holding a password re-enters that instead. ⚠️ A different code from the deletion one: digits handed over under "confirm it is you" must not also erase the account.

def request_deletion_code(self) -> ctfy.server.models.EmailVerificationSent:
183    def request_deletion_code(self) -> EmailVerificationSent:
184        """Mail this account's own address a code that deletes it.
185
186        Refused (409 ``deletion_proof_required``) unless a mailed code is
187        what this account's proof actually is — read ``deletion_proof``
188        off :meth:`get` first. An account with a password re-enters that
189        instead, and one with neither needs no code at all.
190        """
191        resp = self._http.request("POST", "/me/deletion-code")
192        _raise_for_status(resp)
193        return EmailVerificationSent.model_validate(resp.json())

Mail this account's own address a code that deletes it.

Refused (409 deletion_proof_required) unless a mailed code is what this account's proof actually is — read deletion_proof off get() first. An account with a password re-enters that instead, and one with neither needs no code at all.

def delete_account( self, confirm_display_name: str, *, current_password: str = '', code: str = '') -> ctfy.server.models.AccountDeletionAccepted:
195    def delete_account(
196        self,
197        confirm_display_name: str,
198        *,
199        current_password: str = "",
200        code: str = "",
201    ) -> AccountDeletionAccepted:
202        """Self-delete the calling user.
203
204        ``confirm_display_name`` must match the caller's display name
205        (falls back to email when empty) exactly — the typo gate against
206        fat-fingered destruction, and **not** the authorization half: it
207        is a public string.
208
209        The second factor is whichever ``deletion_proof`` on
210        :meth:`get` names — ``current_password`` for an account holding
211        a password, ``code`` from :meth:`request_deletion_code` for one
212        proving a mailed address, neither when it answers ``none``. The
213        server composes that answer and enforces it, so read it rather
214        than guessing; sending the wrong one is a 401.
215
216        ⚠️ Both are keyword-only and default blank, so a caller written
217        before the gate existed still compiles — and still works against
218        an account whose answer is ``none``, which is every account that
219        predates it.
220
221        ⚠️ **Read the answer: this does not always delete anything yet.**
222        With ``account_deletion_grace_s`` above zero — the default — the
223        account is *scheduled*: its tokens are revoked and a job runs the
224        cascade later, and signing in before then cancels the whole
225        thing. ``deleted`` says which happened and ``effective_at`` says
226        when it becomes final. A caller that discards this cannot tell
227        *gone* from *recoverable*, which are opposite facts with opposite
228        next steps.
229        """
230        body: dict[str, str] = {"confirm_display_name": confirm_display_name}
231        if current_password:
232            body["current_password"] = current_password
233        if code:
234            body["code"] = code
235        resp = self._http.request("DELETE", "/me", json=body)
236        _raise_for_status(resp)
237        return AccountDeletionAccepted.model_validate(resp.json())

Self-delete the calling user.

confirm_display_name must match the caller's display name (falls back to email when empty) exactly — the typo gate against fat-fingered destruction, and not the authorization half: it is a public string.

The second factor is whichever deletion_proof on get() names — current_password for an account holding a password, code from request_deletion_code() for one proving a mailed address, neither when it answers none. The server composes that answer and enforces it, so read it rather than guessing; sending the wrong one is a 401.

⚠️ Both are keyword-only and default blank, so a caller written before the gate existed still compiles — and still works against an account whose answer is none, which is every account that predates it.

⚠️ Read the answer: this does not always delete anything yet. With account_deletion_grace_s above zero — the default — the account is scheduled: its tokens are revoked and a job runs the cascade later, and signing in before then cancels the whole thing. deleted says which happened and effective_at says when it becomes final. A caller that discards this cannot tell gone from recoverable, which are opposite facts with opposite next steps.

def achievements( self, competition_id: str = '') -> ctfy.server.models.MyAchievementsResponse:
239    def achievements(self, competition_id: str = "") -> MyAchievementsResponse:
240        """Caller's unlocked + locked badges (with progress hints)."""
241        params: dict[str, str] = {}
242        if competition_id:
243            params["competition_id"] = competition_id
244        resp = self._http.request("GET", "/me/achievements", params=params)
245        _raise_for_status(resp)
246        return MyAchievementsResponse.model_validate(resp.json())

Caller's unlocked + locked badges (with progress hints).

def results(self) -> ctfy.server.models.MyResults:
248    def results(self) -> MyResults:
249        """我的战绩: every started event the caller entered, newest first, each
250        at that event's own official placement (never a total). Includes the
251        sittings only the caller may see -- in progress, frozen (ranked as of
252        the freeze), anonymous, disqualified -- each with ``only_you`` set."""
253        resp = self._http.request("GET", "/me/results")
254        _raise_for_status(resp)
255        return MyResults.model_validate(resp.json())

我的战绩: every started event the caller entered, newest first, each at that event's own official placement (never a total). Includes the sittings only the caller may see -- in progress, frozen (ranked as of the freeze), anonymous, disqualified -- each with only_you set.

def favourites(self) -> ctfy.server.models.MyFavourites:
257    def favourites(self) -> MyFavourites:
258        """Every competition and challenge the caller has starred (#2070) —
259        whole sets, and only ever the caller's own: a favourite is a private
260        preference, never shown to another person or shared with a team."""
261        resp = self._http.request("GET", "/me/favourites")
262        _raise_for_status(resp)
263        return MyFavourites.model_validate(resp.json())

Every competition and challenge the caller has starred (#2070) — whole sets, and only ever the caller's own: a favourite is a private preference, never shown to another person or shared with a team.

def favourite( self, kind: str, target_id: str) -> ctfy.server.models.MyFavourites:
265    def favourite(self, kind: str, target_id: str) -> MyFavourites:
266        """Star a ``competition`` or a ``challenge``; returns the caller's full
267        set. Idempotent. A competition the caller cannot see is a 404."""
268        resp = self._http.request("PUT", f"/me/favourites/{kind}/{target_id}")
269        _raise_for_status(resp)
270        return MyFavourites.model_validate(resp.json())

Star a competition or a challenge; returns the caller's full set. Idempotent. A competition the caller cannot see is a 404.

def unfavourite( self, kind: str, target_id: str) -> ctfy.server.models.MyFavourites:
272    def unfavourite(self, kind: str, target_id: str) -> MyFavourites:
273        """Remove a star; removing one that is not there is the same answer."""
274        resp = self._http.request("DELETE", f"/me/favourites/{kind}/{target_id}")
275        _raise_for_status(resp)
276        return MyFavourites.model_validate(resp.json())

Remove a star; removing one that is not there is the same answer.

def solves( self, *, competition_id: str = '') -> list[ctfy.server.models.MySolveSummary]:
278    def solves(self, *, competition_id: str = "") -> list[MySolveSummary]:
279        """Per-challenge solve summary for the caller.
280
281        Unscoped this spans every team the caller has ever played for.
282        Pass ``competition_id`` to ask the narrower question a
283        competition-scoped surface means: have I solved this *here*.
284        """
285        params: dict[str, str] = {}
286        if competition_id:
287            params["competition_id"] = competition_id
288        resp = self._http.request("GET", "/me/solves", params=params)
289        _raise_for_status(resp)
290        return [MySolveSummary.model_validate(s) for s in resp.json()]

Per-challenge solve summary for the caller.

Unscoped this spans every team the caller has ever played for. Pass competition_id to ask the narrower question a competition-scoped surface means: have I solved this here.

def milestone_progress( self, *, competition_id: str = '') -> list[ctfy.server.models.MilestoneProgress]:
292    def milestone_progress(self, *, competition_id: str = "") -> list[MilestoneProgress]:
293        """Partial-solve progress per challenge for the caller.
294
295        One row per challenge with at least one captured question;
296        ``solved_question_ids`` enumerates which milestones the user
297        has cleared. The row deliberately carries no question total:
298        the number of objectives is player-facing secret (#2435).
299        With ``competition_id`` set the result narrows to solves
300        stamped against the caller's team in that comp.
301        """
302        params: dict[str, str] = {}
303        if competition_id:
304            params["competition_id"] = competition_id
305        resp = self._http.request("GET", "/me/milestone-progress", params=params)
306        _raise_for_status(resp)
307        return [MilestoneProgress.model_validate(s) for s in resp.json()]

Partial-solve progress per challenge for the caller.

One row per challenge with at least one captured question; solved_question_ids enumerates which milestones the user has cleared. The row deliberately carries no question total: the number of objectives is player-facing secret (#2435). With competition_id set the result narrows to solves stamped against the caller's team in that comp.

def verify_star_gazer( self, competition_id: str = '') -> ctfy.server.models.StarGazerVerifyResponse:
309    def verify_star_gazer(self, competition_id: str = "") -> StarGazerVerifyResponse:
310        """Verify the caller starred the ctfy GitHub repo and grant the
311        ``star_gazer`` badge if so.
312
313        Friendly failures (no GitHub identity, repo not configured, not
314        yet starred, GitHub rate-limited) come back as 200 with
315        ``verified=False`` + an actionable ``reason``."""
316        params = {"competition_id": competition_id} if competition_id else None
317        resp = self._http.request("POST", "/me/star-gazer/verify", params=params)
318        _raise_for_status(resp)
319        return StarGazerVerifyResponse.model_validate(resp.json())

Verify the caller starred the ctfy GitHub repo and grant the star_gazer badge if so.

Friendly failures (no GitHub identity, repo not configured, not yet starred, GitHub rate-limited) come back as 200 with verified=False + an actionable reason.