diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c3ffd..72ac544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## v2.2.0 + +通話(カンファレンスコール)関連のモデルとエンドポイントを追加しました +(Go / TypeScript / Python / Rust 共通)。 + +- `ConferenceCall` / `RealmConferenceCall` モデルと `call_type`(`vdo` / + `voice`)・`joinable_by`(参加可能範囲)enum を追加 +- 通話・チャット・投稿の各サービスに関連エンドポイントを追加 + +また、メールログインが 401(unauthorized)で拒否された場合に、代替の +ログインエンドポイントへ自動でフォールバックするようになりました。 + +- 一部のアカウントは代替エンドポイントでのみ受け付けられます +- フォールバックは純粋な unauthorized のときだけ発火し、2FA が必要な + ケースや、呼び出し側が明示的に API キーを指定した場合、フォールバック用 + キーを空にした場合は発火しません +- 詳細な契約は `PORTING.md` の 6.2 節を参照 + ## v2.1.0 通話・グループ・投稿まわりのエンドポイントを追加しました(Go / TypeScript / Python 共通)。 diff --git a/PORTING.md b/PORTING.md index 12facbe..9e68e89 100644 --- a/PORTING.md +++ b/PORTING.md @@ -296,6 +296,42 @@ Other invariants: > not the raw one.) On re-issue, also refresh per-request headers that > go stale — at minimum `X-Timestamp`. +### 6.2 Alternate login fallback + +`loginWithEmail` makes a second login attempt when the primary email +login is rejected with a plain `Unauthorized` (error code 401): it +retries once against `POST /v2/users/login_with_email` with a separate +`webApiKey`. Some accounts are accepted only on this alternate endpoint. +Reference: Go `auth.go` (`loginWithEmailWeb`); mirrored in every port. + +The `webApiKey` is an embedded wire constant like `apiKey` (§3.1), with a +per-language default and an override (`WithWebAPIKey` / `webApiKey:` / +`web_api_key=` / `.web_api_key()`). Setting it to `""` disables the +fallback. + +Invariants every port MUST match: + +- Fire the fallback ONLY on error code `Unauthorized`, never on other + 401s — in particular `Required2FA` must fall straight through (the + caller supplies a code, not a different key). Gate on the parsed error + code (`codeOf`), NOT on the bare HTTP status. +- Skip the fallback when the caller pinned a specific `apiKey` (i.e. the + resolved key differs from the client's `apiKey`), and when `webApiKey` + is empty (the off switch). +- Issue the fallback through the UNWRAPPED HTTP layer (§6.1): no header + injection, no Bearer, no 401-refresh. Send the browser client's + minimal headers only (Content-Type, User-Agent, `Agent`, + `X-Device-Info`), and put `webApiKey` in the body's `api_key`. +- On a fallback that returns 2xx but fails to decode, surface that result + (raw body intact, as an `APIError`), NOT the primary 401. +- On any other fallback failure (non-2xx / transport error), surface the + original primary error unchanged. +- On success, activate tokens and persist the session exactly as the + primary path does. + +Parity scenarios live in each `auth` test suite (fires on 401 / no fire +on 2FA / no fire when key pinned / disabled / 2xx-decode-fail surfaced). + --- ## 7. Signing helpers diff --git a/aliases.go b/aliases.go index 04301b9..efcea1f 100644 --- a/aliases.go +++ b/aliases.go @@ -47,6 +47,7 @@ type ( CallGiftHistoryResponse = gen.CallGiftHistoryResponse CallMessage = gen.CallMessage CallStatusResponse = gen.CallStatusResponse + CallType = gen.CallType Callback = gen.Callback CampaignDTO = gen.CampaignDTO CampaignInvitedUsersResponse = gen.CampaignInvitedUsersResponse @@ -205,6 +206,7 @@ type ( Item = gen.Item J0A = gen.J0A JSEvent = gen.JSEvent + JoinableBy = gen.JoinableBy LastStatusScreenUserRank = gen.LastStatusScreenUserRank LevelUp = gen.LevelUp LevelUpDetails = gen.LevelUpDetails @@ -315,6 +317,7 @@ type ( NullableCallGiftHistoryResponse = gen.NullableCallGiftHistoryResponse NullableCallMessage = gen.NullableCallMessage NullableCallStatusResponse = gen.NullableCallStatusResponse + NullableCallType = gen.NullableCallType NullableCallback = gen.NullableCallback NullableCampaignDTO = gen.NullableCampaignDTO NullableCampaignInvitedUsersResponse = gen.NullableCampaignInvitedUsersResponse @@ -479,6 +482,7 @@ type ( NullableItem = gen.NullableItem NullableJ0A = gen.NullableJ0A NullableJSEvent = gen.NullableJSEvent + NullableJoinableBy = gen.NullableJoinableBy NullableLastStatusScreenUserRank = gen.NullableLastStatusScreenUserRank NullableLevelUp = gen.NullableLevelUp NullableLevelUpDetails = gen.NullableLevelUpDetails @@ -1064,6 +1068,7 @@ var ( NewCallMessageWithDefaults = gen.NewCallMessageWithDefaults NewCallStatusResponse = gen.NewCallStatusResponse NewCallStatusResponseWithDefaults = gen.NewCallStatusResponseWithDefaults + NewCallTypeFromValue = gen.NewCallTypeFromValue NewCallback = gen.NewCallback NewCallbackWithDefaults = gen.NewCallbackWithDefaults NewCampaignDTO = gen.NewCampaignDTO @@ -1379,6 +1384,7 @@ var ( NewJ0AFromValue = gen.NewJ0AFromValue NewJSEvent = gen.NewJSEvent NewJSEventWithDefaults = gen.NewJSEventWithDefaults + NewJoinableByFromValue = gen.NewJoinableByFromValue NewLastStatusScreenUserRank = gen.NewLastStatusScreenUserRank NewLastStatusScreenUserRankWithDefaults = gen.NewLastStatusScreenUserRankWithDefaults NewLevelUp = gen.NewLevelUp @@ -1554,6 +1560,7 @@ var ( NewNullableCallGiftHistoryResponse = gen.NewNullableCallGiftHistoryResponse NewNullableCallMessage = gen.NewNullableCallMessage NewNullableCallStatusResponse = gen.NewNullableCallStatusResponse + NewNullableCallType = gen.NewNullableCallType NewNullableCallback = gen.NewNullableCallback NewNullableCampaignDTO = gen.NewNullableCampaignDTO NewNullableCampaignInvitedUsersResponse = gen.NewNullableCampaignInvitedUsersResponse @@ -1718,6 +1725,7 @@ var ( NewNullableItem = gen.NewNullableItem NewNullableJ0A = gen.NewNullableJ0A NewNullableJSEvent = gen.NewNullableJSEvent + NewNullableJoinableBy = gen.NewNullableJoinableBy NewNullableLastStatusScreenUserRank = gen.NewNullableLastStatusScreenUserRank NewNullableLevelUp = gen.NewNullableLevelUp NewNullableLevelUpDetails = gen.NewNullableLevelUpDetails @@ -2445,45 +2453,53 @@ var ( // can write yaylib.MessageTypeVideo instead of the SHOUTY_SNAKE form // the generator emits. Each is the same value as its gen counterpart. const ( - J0AFollowingTimeline = gen.J0A_FOLLOWING_TIMELINE - J0AGroupTimeline = gen.J0A_GROUP_TIMELINE - J0APublicTimeline = gen.J0A_PUBLIC_TIMELINE - MessageTypeDeleted = gen.MESSAGETYPE_Deleted - MessageTypeEternalimage = gen.MESSAGETYPE_EternalImage - MessageTypeEternalvideo = gen.MESSAGETYPE_EternalVideo - MessageTypeGif = gen.MESSAGETYPE_Gif - MessageTypeImage = gen.MESSAGETYPE_Image - MessageTypeOwnerkick = gen.MESSAGETYPE_OwnerKick - MessageTypePrivatecall = gen.MESSAGETYPE_PrivateCall - MessageTypeScreenshot = gen.MESSAGETYPE_Screenshot - MessageTypeSticker = gen.MESSAGETYPE_Sticker - MessageTypeText = gen.MESSAGETYPE_Text - MessageTypeUserjoined = gen.MESSAGETYPE_UserJoined - MessageTypeUserleave = gen.MESSAGETYPE_UserLeave - MessageTypeVideo = gen.MESSAGETYPE_Video - MissionTypeDaily = gen.MISSIONTYPE_Daily - MissionTypeEndless = gen.MISSIONTYPE_Endless - MissionTypeNormal = gen.MISSIONTYPE_Normal - MissionTypeSpecial = gen.MISSIONTYPE_Special - NoreplyModeFalse = gen.NOREPLYMODE_False - NoreplyModeTrue = gen.NOREPLYMODE_True - OnlineStatusEnumHidden = gen.ONLINESTATUSENUM_Hidden - OnlineStatusEnumOffline = gen.ONLINESTATUSENUM_Offline - PalGradeEgg = gen.PALGRADE_Egg - PalGradeNormal = gen.PALGRADE_Normal - PalGradeSuper = gen.PALGRADE_Super - PalGradeUltimatepal = gen.PALGRADE_UltimatePal - PostTypeCall = gen.POSTTYPE_Call - PostTypeImage = gen.POSTTYPE_Image - PostTypeRepost = gen.POSTTYPE_Repost - PostTypeShareablegroup = gen.POSTTYPE_ShareableGroup - PostTypeShareablethread = gen.POSTTYPE_ShareableThread - PostTypeShareableurl = gen.POSTTYPE_ShareableUrl - PostTypeSharepal = gen.POSTTYPE_SharePal - PostTypeSurvey = gen.POSTTYPE_Survey - PostTypeText = gen.POSTTYPE_Text - PostTypeThread = gen.POSTTYPE_Thread - PostTypeUndefined = gen.POSTTYPE_Undefined - PostTypeVideo = gen.POSTTYPE_Video - PostTypeYoutube = gen.POSTTYPE_Youtube + CallTypeVideo = gen.CALLTYPE_Video + CallTypeVoice = gen.CALLTYPE_Voice + J0AFollowingTimeline = gen.J0A_FOLLOWING_TIMELINE + J0AGroupTimeline = gen.J0A_GROUP_TIMELINE + J0APublicTimeline = gen.J0A_PUBLIC_TIMELINE + JoinableByAnyone = gen.JOINABLEBY_Anyone + JoinableByFollowersonly = gen.JOINABLEBY_FollowersOnly + JoinableByInviteonly = gen.JOINABLEBY_InviteOnly + JoinableByMutualfollowsonly = gen.JOINABLEBY_MutualFollowsOnly + JoinableByNoone = gen.JOINABLEBY_NoOne + JoinableByUsers = gen.JOINABLEBY_Users + MessageTypeDeleted = gen.MESSAGETYPE_Deleted + MessageTypeEternalimage = gen.MESSAGETYPE_EternalImage + MessageTypeEternalvideo = gen.MESSAGETYPE_EternalVideo + MessageTypeGif = gen.MESSAGETYPE_Gif + MessageTypeImage = gen.MESSAGETYPE_Image + MessageTypeOwnerkick = gen.MESSAGETYPE_OwnerKick + MessageTypePrivatecall = gen.MESSAGETYPE_PrivateCall + MessageTypeScreenshot = gen.MESSAGETYPE_Screenshot + MessageTypeSticker = gen.MESSAGETYPE_Sticker + MessageTypeText = gen.MESSAGETYPE_Text + MessageTypeUserjoined = gen.MESSAGETYPE_UserJoined + MessageTypeUserleave = gen.MESSAGETYPE_UserLeave + MessageTypeVideo = gen.MESSAGETYPE_Video + MissionTypeDaily = gen.MISSIONTYPE_Daily + MissionTypeEndless = gen.MISSIONTYPE_Endless + MissionTypeNormal = gen.MISSIONTYPE_Normal + MissionTypeSpecial = gen.MISSIONTYPE_Special + NoreplyModeFalse = gen.NOREPLYMODE_False + NoreplyModeTrue = gen.NOREPLYMODE_True + OnlineStatusEnumHidden = gen.ONLINESTATUSENUM_Hidden + OnlineStatusEnumOffline = gen.ONLINESTATUSENUM_Offline + PalGradeEgg = gen.PALGRADE_Egg + PalGradeNormal = gen.PALGRADE_Normal + PalGradeSuper = gen.PALGRADE_Super + PalGradeUltimatepal = gen.PALGRADE_UltimatePal + PostTypeCall = gen.POSTTYPE_Call + PostTypeImage = gen.POSTTYPE_Image + PostTypeRepost = gen.POSTTYPE_Repost + PostTypeShareablegroup = gen.POSTTYPE_ShareableGroup + PostTypeShareablethread = gen.POSTTYPE_ShareableThread + PostTypeShareableurl = gen.POSTTYPE_ShareableUrl + PostTypeSharepal = gen.POSTTYPE_SharePal + PostTypeSurvey = gen.POSTTYPE_Survey + PostTypeText = gen.POSTTYPE_Text + PostTypeThread = gen.POSTTYPE_Thread + PostTypeUndefined = gen.POSTTYPE_Undefined + PostTypeVideo = gen.POSTTYPE_Video + PostTypeYoutube = gen.POSTTYPE_Youtube ) diff --git a/auth.go b/auth.go index 0ebe81f..dcb8d58 100644 --- a/auth.go +++ b/auth.go @@ -1,9 +1,12 @@ package yaylib import ( + "bytes" "context" + "encoding/json" "errors" "fmt" + "io" "net/http" "github.com/ekkx/yaylib/v2/gen" @@ -114,7 +117,40 @@ func (r LoginWithEmailRequest) Execute() (*gen.LoginUserResponse, *http.Response LoginEmailUserRequest(body). Execute() if err != nil { - return resp, httpResp, err + // Fallback: when the primary (app) login is rejected with a plain + // "unauthorized", retry against the browser login endpoint, which + // authenticates with a different key (WebAPIKey). On success the + // web response replaces the failed one; if the fallback also fails, + // the original error (a proper *APIError) is surfaced unchanged. + // + // The fallback is gated on ErrCodeUnauthorized specifically, not on + // a bare 401 status: other rejections are also 401 but must NOT be + // retried — notably ErrCodeRequired2FA (the caller has to supply a + // code, not switch keys). It is also skipped when the caller pinned + // a specific api_key via .ApiKey(), since the fallback would + // otherwise silently authenticate under a different key. + callerPinnedKey := body.GetApiKey() != r.client.APIKey + if r.client.WebAPIKey != "" && !callerPinnedKey && CodeOf(err) == ErrCodeUnauthorized { + if wResp, wHTTP, wErr := r.client.loginWithEmailWeb(r.ctx, body); wErr == nil { + r.client.Logger.Debug("web login fallback ok", + "event", "login_fallback", "outcome", "ok") + resp, httpResp, err = wResp, wHTTP, nil + } else { + r.client.Logger.Debug("web login fallback failed", + "event", "login_fallback", "outcome", "failed") + // A 2xx web response we couldn't decode is a genuine + // login result, not an auth rejection: surface it (with + // its raw body, readable on wHTTP) rather than masking it + // behind the primary 401. Non-2xx / transport errors fall + // through and the primary error is returned unchanged. + if wHTTP != nil && wHTTP.StatusCode >= 200 && wHTTP.StatusCode < 300 { + return nil, wHTTP, wErr + } + } + } + if err != nil { + return resp, httpResp, err + } } if perr := r.client.acceptLogin(email, resp); perr != nil { return resp, httpResp, perr @@ -124,6 +160,87 @@ func (r LoginWithEmailRequest) Execute() (*gen.LoginUserResponse, *http.Response // ---- internal helpers ---- +// bareClient returns an *http.Client that sends requests through the base +// RoundTripper WITHOUT the app transport's header injection, Authorization, +// 401 auto-refresh or retry. It inherits the configured client's timeout +// and, when the app transport is in place, its underlying (caller-supplied +// or default) RoundTripper — so a custom proxy / TLS config still applies. +func (c *Client) bareClient() *http.Client { + bare := &http.Client{Timeout: c.httpClient.Timeout} + if t, ok := c.httpClient.Transport.(*Transport); ok { + bare.Transport = t.Base + } + return bare +} + +// loginWithEmailWeb is the fallback login path invoked when the primary +// email login returns 401. It posts to /v2/users/login_with_email — the +// endpoint the browser client uses — authenticating with the client's +// WebAPIKey instead of APIKey. +// +// The request is sent through the UNWRAPPED HTTP layer (the base +// RoundTripper, bypassing the app transport) for two reasons: it must +// carry the browser client's minimal header set rather than the app's +// (X-App-Version / X-Connection-* / X-Device-UUID), and it must not +// pick up the transport's Authorization injection or 401 auto-refresh — +// a login must authenticate purely by its body credentials, never by a +// pre-existing session's Bearer. This mirrors the "re-issue with the +// unwrapped client" rule the retry/refresh paths follow (PORTING §6.1). +// A caller-supplied *http.Client / proxy is still honored: the base +// RoundTripper and timeout are inherited from it. Only the login body's +// api_key and the endpoint version differ from the app login. +func (c *Client) loginWithEmailWeb(ctx context.Context, body gen.LoginEmailUserRequest) (*gen.LoginUserResponse, *http.Response, error) { + payload := map[string]string{ + "email": body.GetEmail(), + "password": body.GetPassword(), + "api_key": c.WebAPIKey, + "uuid": body.GetUuid(), + } + if code := body.GetTwoFACode(); code != "" { + payload["two_fa_code"] = code + } + buf, err := json.Marshal(payload) + if err != nil { + return nil, nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.BaseURL+"/v2/users/login_with_email", bytes.NewReader(buf)) + if err != nil { + return nil, nil, err + } + req.Header.Set("Content-Type", "application/json;charset=UTF-8") + // Present as the browser client. The web version string is not + // otherwise known to the SDK, so AppVersion stands in for it. + const webUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + req.Header.Set("User-Agent", webUA) + req.Header.Set("Agent", "YayWeb "+c.AppVersion) + req.Header.Set("X-Device-Info", fmt.Sprintf("Yay %s Web (%s)", c.AppVersion, webUA)) + + httpResp, err := c.bareClient().Do(req) + if err != nil { + return nil, nil, err + } + respBody, err := io.ReadAll(httpResp.Body) + httpResp.Body.Close() + if err != nil { + return nil, httpResp, err + } + // Restore a readable body on the response so callers / middleware that + // inspect the returned *http.Response see the same bytes we decoded, + // matching the primary login path's semantics. + httpResp.Body = io.NopCloser(bytes.NewReader(respBody)) + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, httpResp, fmt.Errorf("yaylib: web login failed: status %d", httpResp.StatusCode) + } + var out gen.LoginUserResponse + if err := json.Unmarshal(respBody, &out); err != nil { + return nil, httpResp, fmt.Errorf("yaylib: web login decode: %w", err) + } + return &out, httpResp, nil +} + // fillEmailLogin populates APIKey and Uuid from the client if they weren't // set by the caller, and returns the completed body. func (c *Client) fillEmailLogin(body gen.LoginEmailUserRequest) gen.LoginEmailUserRequest { diff --git a/auth_test.go b/auth_test.go index 0edbc8e..44a58d8 100644 --- a/auth_test.go +++ b/auth_test.go @@ -232,3 +232,213 @@ func TestLoginWithEmail_TwoFARequiredSurfacesErrorCode(t *testing.T) { t.Errorf("tokens should not be set on 2FA-required, got %q", c.Tokens().Access) } } + +// fallbackLoginServer rejects the primary (v3) login with 401 and, when +// webOK is true, accepts the alternate (v2) login — recording the api_key +// the v2 request carried so tests can assert the fallback key was used. +func fallbackLoginServer(t *testing.T, webOK bool, seenWebKey *string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reject := func() { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]any{ + "result": "error", "message": "unauthorized", "error_code": 401, + }) + } + switch { + case strings.HasPrefix(r.URL.Path, "/v3/users/login_with_email"): + reject() + case strings.HasPrefix(r.URL.Path, "/v2/users/login_with_email"): + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + if seenWebKey != nil { + k, _ := body["api_key"].(string) + *seenWebKey = k + } + if !webOK { + reject() + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "WEBACC", "refresh_token": "WEBREF", "user_id": 7777, + }) + default: + http.NotFound(w, r) + } + })) +} + +// PORTING §6.2 — alternate login fallback (browser login endpoint). +func TestLoginWithEmail_FallsBackToWebOn401(t *testing.T) { + var seenKey string + srv := fallbackLoginServer(t, true, &seenKey) + defer srv.Close() + + c := NewClient(WithBaseURL(srv.URL), WithRetryPolicy(RetryPolicy{})) + resp, _, err := c.LoginWithEmail(context.Background()). + Email("u@example.com").Password("pw").Execute() + if err != nil { + t.Fatalf("LoginWithEmail with fallback: %v", err) + } + if resp.GetUserId() != 7777 { + t.Errorf("UserID = %d, want 7777 (web response)", resp.GetUserId()) + } + if c.Tokens().Access != "WEBACC" || c.Tokens().Refresh != "WEBREF" { + t.Errorf("tokens = %+v, want WEBACC/WEBREF from fallback", c.Tokens()) + } + if seenKey != DefaultWebAPIKey { + t.Errorf("fallback api_key = %q, want DefaultWebAPIKey", seenKey) + } +} + +// When the fallback is disabled (WebAPIKey==""), a primary 401 is surfaced +// unchanged and no v2 request is attempted. +func TestLoginWithEmail_FallbackDisabledSurfaces401(t *testing.T) { + var seenKey string + srv := fallbackLoginServer(t, true, &seenKey) + defer srv.Close() + + c := NewClient(WithBaseURL(srv.URL), WithRetryPolicy(RetryPolicy{}), WithWebAPIKey("")) + _, _, err := c.LoginWithEmail(context.Background()). + Email("u@example.com").Password("pw").Execute() + if err == nil { + t.Fatal("expected primary 401 to surface when fallback disabled") + } + if CodeOf(err) != ErrCodeUnauthorized { + t.Errorf("CodeOf(err) = %v, want ErrCodeUnauthorized", CodeOf(err)) + } + if seenKey != "" { + t.Errorf("v2 endpoint should not have been called, saw api_key %q", seenKey) + } + if c.Tokens().Access != "" { + t.Errorf("tokens should stay empty, got %q", c.Tokens().Access) + } +} + +// When both primary and fallback fail, the original primary 401 (a proper +// *APIError) is surfaced rather than the fallback's error. +func TestLoginWithEmail_FallbackAlsoFailsSurfacesPrimary401(t *testing.T) { + srv := fallbackLoginServer(t, false, nil) + defer srv.Close() + + c := NewClient(WithBaseURL(srv.URL), WithRetryPolicy(RetryPolicy{})) + _, _, err := c.LoginWithEmail(context.Background()). + Email("u@example.com").Password("pw").Execute() + if err == nil { + t.Fatal("expected error when both logins fail") + } + if CodeOf(err) != ErrCodeUnauthorized { + t.Errorf("CodeOf(err) = %v, want ErrCodeUnauthorized", CodeOf(err)) + } + if c.Tokens().Access != "" { + t.Errorf("tokens should stay empty, got %q", c.Tokens().Access) + } +} + +// A 2FA-required rejection is also HTTP 401 but carries ErrCodeRequired2FA; +// the fallback must NOT fire (the caller has to supply a code, not switch +// keys), so the v2 endpoint is never called and the 2FA code surfaces. +func TestLoginWithEmail_NoFallbackOn2FA(t *testing.T) { + var v2Hit int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case strings.HasPrefix(r.URL.Path, "/v3/users/login_with_email"): + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error_code": int(ErrCodeRequired2FA), "message": "2FA required", + }) + case strings.HasPrefix(r.URL.Path, "/v2/users/login_with_email"): + atomic.AddInt32(&v2Hit, 1) + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "WEBACC", "refresh_token": "WEBREF", "user_id": 7777, + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + c := NewClient(WithBaseURL(srv.URL), WithRetryPolicy(RetryPolicy{})) + _, _, err := c.LoginWithEmail(context.Background()). + Email("u@example.com").Password("pw").Execute() + if err == nil { + t.Fatal("expected 2FA-required error") + } + if CodeOf(err) != ErrCodeRequired2FA { + t.Errorf("CodeOf(err) = %v, want ErrCodeRequired2FA", CodeOf(err)) + } + if n := atomic.LoadInt32(&v2Hit); n != 0 { + t.Errorf("v2 endpoint hit %d times, want 0 (no fallback on 2FA)", n) + } + if c.Tokens().Access != "" { + t.Errorf("tokens should stay empty, got %q", c.Tokens().Access) + } +} + +// When the caller pins a specific api_key via .ApiKey(), the fallback is +// skipped (it would otherwise authenticate under a different key), so a +// primary 401 surfaces unchanged and the v2 endpoint is never called. +func TestLoginWithEmail_NoFallbackWhenApiKeyPinned(t *testing.T) { + seenKey := "SENTINEL" + srv := fallbackLoginServer(t, true, &seenKey) + defer srv.Close() + + c := NewClient(WithBaseURL(srv.URL), WithRetryPolicy(RetryPolicy{})) + _, _, err := c.LoginWithEmail(context.Background()). + Email("u@example.com").Password("pw").ApiKey("caller-pinned-key").Execute() + if err == nil { + t.Fatal("expected primary 401 when caller pinned a custom api_key") + } + if CodeOf(err) != ErrCodeUnauthorized { + t.Errorf("CodeOf(err) = %v, want ErrCodeUnauthorized", CodeOf(err)) + } + if seenKey != "SENTINEL" { + t.Errorf("v2 endpoint should not have been called, saw api_key %q", seenKey) + } +} + +// A web response that is 2xx but undecodable is a genuine login result, so +// its decode error (carrying the raw body on the response) is surfaced +// rather than masked behind the primary 401. +func TestLoginWithEmail_WebDecodeFailureSurfaced(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/v3/users/login_with_email"): + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]any{ + "result": "error", "message": "unauthorized", "error_code": 401, + }) + case strings.HasPrefix(r.URL.Path, "/v2/users/login_with_email"): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("{ this is not valid json")) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + c := NewClient(WithBaseURL(srv.URL), WithRetryPolicy(RetryPolicy{})) + _, httpResp, err := c.LoginWithEmail(context.Background()). + Email("u@example.com").Password("pw").Execute() + if err == nil { + t.Fatal("expected a decode error to surface") + } + // Not masked as the primary auth rejection. + if CodeOf(err) == ErrCodeUnauthorized { + t.Errorf("decode failure was masked as ErrCodeUnauthorized: %v", err) + } + if !strings.Contains(err.Error(), "decode") { + t.Errorf("err = %v, want a decode error", err) + } + // The raw web body is still readable on the returned response. + if httpResp == nil || httpResp.StatusCode != http.StatusOK { + t.Fatalf("want the 2xx web response surfaced, got %v", httpResp) + } + if c.Tokens().Access != "" { + t.Errorf("tokens should stay empty, got %q", c.Tokens().Access) + } +} diff --git a/client.go b/client.go index 8bec198..37a53aa 100644 --- a/client.go +++ b/client.go @@ -61,6 +61,12 @@ const ( // server). DefaultCassandraBaseURL = "https://cas.yay.space" DefaultAPIKey = "ccd59ee269c01511ba763467045c115779fcae3050238a252f1bd1a4b65cfec6" + // DefaultWebAPIKey is the API key the browser client authenticates + // with. It is used only for the login fallback: when a primary + // email login returns 401, the client retries against the + // alternate login endpoint (POST /v2/users/login_with_email), which + // validates against this key instead of DefaultAPIKey. + DefaultWebAPIKey = "e9f1ae4c4470f29a92c0168dc42b13637cc332692103f23e626bc2b016f66603" DefaultAPIVersionKey = "34c8c1cdf29b46a492e8ec58e6db37ec" // HS256 signing key for X-Jwt DefaultAPIVersionName = "4.26" DefaultAppVersion = "4.26.1" @@ -119,6 +125,9 @@ const ( type Client struct { // Wire configuration. APIKey string + // WebAPIKey is the key used by the email-login fallback (see + // DefaultWebAPIKey). Set it to "" to disable the fallback entirely. + WebAPIKey string APIVersionKey string APIVersionName string AppVersion string @@ -267,6 +276,11 @@ type Option func(*Client) // WithAPIKey overrides the default Yay! API key. func WithAPIKey(k string) Option { return func(c *Client) { c.APIKey = k } } +// WithWebAPIKey overrides the key used by the email-login fallback +// (see DefaultWebAPIKey). Pass "" to disable the fallback: LoginWithEmail +// then surfaces a primary 401 unchanged. +func WithWebAPIKey(k string) Option { return func(c *Client) { c.WebAPIKey = k } } + // WithAPIVersionKey overrides the HS256 key used to sign the X-Jwt header. func WithAPIVersionKey(k string) Option { return func(c *Client) { c.APIVersionKey = k } } @@ -379,6 +393,7 @@ func NewClient(opts ...Option) *Client { lifecycleCtx: lifecycleCtx, lifecycleCancel: lifecycleCancel, APIKey: DefaultAPIKey, + WebAPIKey: DefaultWebAPIKey, APIVersionKey: DefaultAPIVersionKey, APIVersionName: DefaultAPIVersionName, AppVersion: DefaultAppVersion, diff --git a/gen/api_calls.go b/gen/api_calls.go index c310feb..27a7aa4 100644 --- a/gen/api_calls.go +++ b/gen/api_calls.go @@ -1839,7 +1839,7 @@ type ApiUpdateCallRequest struct { callId int64 categoryId *string gameTitle *string - joinableBy *string + joinableBy *JoinableBy } func (r ApiUpdateCallRequest) CategoryId(categoryId string) ApiUpdateCallRequest { @@ -1852,7 +1852,7 @@ func (r ApiUpdateCallRequest) GameTitle(gameTitle string) ApiUpdateCallRequest { return r } -func (r ApiUpdateCallRequest) JoinableBy(joinableBy string) ApiUpdateCallRequest { +func (r ApiUpdateCallRequest) JoinableBy(joinableBy JoinableBy) ApiUpdateCallRequest { r.joinableBy = &joinableBy return r } diff --git a/gen/api_chat_rooms.go b/gen/api_chat_rooms.go index c354936..48ae413 100644 --- a/gen/api_chat_rooms.go +++ b/gen/api_chat_rooms.go @@ -2237,7 +2237,7 @@ type ApiSendChatMessageRequest struct { ApiService *ChatRoomsAPIService id int64 attachmentFileName *string - callType *string + callType *CallType fontSize *int32 gifImageId *int64 messageType *MessageType @@ -2253,7 +2253,7 @@ func (r ApiSendChatMessageRequest) AttachmentFileName(attachmentFileName string) return r } -func (r ApiSendChatMessageRequest) CallType(callType string) ApiSendChatMessageRequest { +func (r ApiSendChatMessageRequest) CallType(callType CallType) ApiSendChatMessageRequest { r.callType = &callType return r } diff --git a/gen/api_posts.go b/gen/api_posts.go index 56c99e1..a72b7f9 100644 --- a/gen/api_posts.go +++ b/gen/api_posts.go @@ -30,13 +30,13 @@ type ApiCreateConferenceCallPostRequest struct { attachment8Filename *string attachment9Filename *string attachmentFilename *string - callType *string + callType *CallType categoryId *int64 color *int32 fontSize *int32 gameTitle *string groupId *int64 - joinableBy *string + joinableBy *JoinableBy language *string messageTags *map[string]interface{} signedInfo *string @@ -95,7 +95,7 @@ func (r ApiCreateConferenceCallPostRequest) AttachmentFilename(attachmentFilenam return r } -func (r ApiCreateConferenceCallPostRequest) CallType(callType string) ApiCreateConferenceCallPostRequest { +func (r ApiCreateConferenceCallPostRequest) CallType(callType CallType) ApiCreateConferenceCallPostRequest { r.callType = &callType return r } @@ -125,7 +125,7 @@ func (r ApiCreateConferenceCallPostRequest) GroupId(groupId int64) ApiCreateConf return r } -func (r ApiCreateConferenceCallPostRequest) JoinableBy(joinableBy string) ApiCreateConferenceCallPostRequest { +func (r ApiCreateConferenceCallPostRequest) JoinableBy(joinableBy JoinableBy) ApiCreateConferenceCallPostRequest { r.joinableBy = &joinableBy return r } @@ -1144,7 +1144,7 @@ type ApiGetCallFollowersTimelineRequest struct { fromTimestamp *int64 number *int32 categoryId *int64 - callType *string + callType *CallType includeCircleCall *bool excludeRecentGomimushi *bool } @@ -1164,7 +1164,7 @@ func (r ApiGetCallFollowersTimelineRequest) CategoryId(categoryId int64) ApiGetC return r } -func (r ApiGetCallFollowersTimelineRequest) CallType(callType string) ApiGetCallFollowersTimelineRequest { +func (r ApiGetCallFollowersTimelineRequest) CallType(callType CallType) ApiGetCallFollowersTimelineRequest { r.callType = &callType return r } @@ -1301,7 +1301,7 @@ type ApiGetCallTimelineRequest struct { fromTimestamp *int64 number *int32 categoryId *int64 - callType *string + callType *CallType includeCircleCall *bool crossGeneration *bool excludeRecentGomimushi *bool @@ -1327,7 +1327,7 @@ func (r ApiGetCallTimelineRequest) CategoryId(categoryId int64) ApiGetCallTimeli return r } -func (r ApiGetCallTimelineRequest) CallType(callType string) ApiGetCallTimelineRequest { +func (r ApiGetCallTimelineRequest) CallType(callType CallType) ApiGetCallTimelineRequest { r.callType = &callType return r } diff --git a/gen/model_call_type.go b/gen/model_call_type.go new file mode 100644 index 0000000..b181355 --- /dev/null +++ b/gen/model_call_type.go @@ -0,0 +1,93 @@ + +// Code generated from the Yay! API schema; DO NOT EDIT. + +package gen + +import ( + "encoding/json" +) + +// CallType the model 'CallType' +type CallType string + +// List of CallType +const ( + CALLTYPE_Video CallType = "vdo" + CALLTYPE_Voice CallType = "voice" +) + +// All allowed values of CallType enum +var AllowedCallTypeEnumValues = []CallType{ + "vdo", + "voice", +} + +func (v *CallType) UnmarshalJSON(src []byte) error { + var value string + err := json.Unmarshal(src, &value) + if err != nil { + return err + } + enumTypeValue := CallType(value) + *v = enumTypeValue + return nil +} + +// NewCallTypeFromValue returns a pointer to a valid CallType +// for the value passed as argument, or an error if the value passed is not allowed by the enum +func NewCallTypeFromValue(v string) (*CallType, error) { + ev := CallType(v) + return &ev, nil +} + +// IsValid return true if the value is valid for the enum, false otherwise +func (v CallType) IsValid() bool { + for _, existing := range AllowedCallTypeEnumValues { + if existing == v { + return true + } + } + return false +} + +// Ptr returns reference to CallType value +func (v CallType) Ptr() *CallType { + return &v +} + +type NullableCallType struct { + value *CallType + isSet bool +} + +func (v NullableCallType) Get() *CallType { + return v.value +} + +func (v *NullableCallType) Set(val *CallType) { + v.value = val + v.isSet = true +} + +func (v NullableCallType) IsSet() bool { + return v.isSet +} + +func (v *NullableCallType) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableCallType(val *CallType) *NullableCallType { + return &NullableCallType{value: val, isSet: true} +} + +func (v NullableCallType) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableCallType) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + diff --git a/gen/model_conference_call.go b/gen/model_conference_call.go index 48b9300..169974d 100644 --- a/gen/model_conference_call.go +++ b/gen/model_conference_call.go @@ -16,7 +16,7 @@ type ConferenceCall struct { AgoraToken NullableString `json:"agora_token,omitempty"` AnonymousCallUsersCount NullableInt32 `json:"anonymous_call_users_count,omitempty"` BumpParams NullableBumpParams `json:"bump_params,omitempty"` - CallType NullableString `json:"call_type,omitempty"` + CallType NullableCallType `json:"call_type,omitempty"` ConferenceCallUserUuid NullableString `json:"conference_call_user_uuid,omitempty"` ConferenceCallUsers []User `json:"conference_call_users,omitempty"` ConferenceCallUsersRole []ModelConferenceCallUserRole `json:"conference_call_users_role,omitempty"` @@ -26,7 +26,7 @@ type ConferenceCall struct { GroupId NullableInt64 `json:"group_id,omitempty"` Id NullableInt64 `json:"id,omitempty"` IsActive NullableBool `json:"is_active,omitempty"` - JoinableBy NullableString `json:"joinable_by,omitempty"` + JoinableBy NullableJoinableBy `json:"joinable_by,omitempty"` MaxParticipants NullableInt32 `json:"max_participants,omitempty"` PostId NullableInt64 `json:"post_id,omitempty"` AdditionalProperties map[string]interface{} @@ -220,9 +220,9 @@ func (o *ConferenceCall) UnsetBumpParams() { } // GetCallType returns the CallType field value if set, zero value otherwise (both if not set or set to explicit null). -func (o *ConferenceCall) GetCallType() string { +func (o *ConferenceCall) GetCallType() CallType { if o == nil || IsNil(o.CallType.Get()) { - var ret string + var ret CallType return ret } return *o.CallType.Get() @@ -231,7 +231,7 @@ func (o *ConferenceCall) GetCallType() string { // GetCallTypeOk returns a tuple with the CallType field value if set, nil otherwise // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *ConferenceCall) GetCallTypeOk() (*string, bool) { +func (o *ConferenceCall) GetCallTypeOk() (*CallType, bool) { if o == nil { return nil, false } @@ -247,8 +247,8 @@ func (o *ConferenceCall) HasCallType() bool { return false } -// SetCallType gets a reference to the given NullableString and assigns it to the CallType field. -func (o *ConferenceCall) SetCallType(v string) { +// SetCallType gets a reference to the given NullableCallType and assigns it to the CallType field. +func (o *ConferenceCall) SetCallType(v CallType) { o.CallType.Set(&v) } // SetCallTypeNil sets the value for CallType to be an explicit nil @@ -622,9 +622,9 @@ func (o *ConferenceCall) UnsetIsActive() { } // GetJoinableBy returns the JoinableBy field value if set, zero value otherwise (both if not set or set to explicit null). -func (o *ConferenceCall) GetJoinableBy() string { +func (o *ConferenceCall) GetJoinableBy() JoinableBy { if o == nil || IsNil(o.JoinableBy.Get()) { - var ret string + var ret JoinableBy return ret } return *o.JoinableBy.Get() @@ -633,7 +633,7 @@ func (o *ConferenceCall) GetJoinableBy() string { // GetJoinableByOk returns a tuple with the JoinableBy field value if set, nil otherwise // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *ConferenceCall) GetJoinableByOk() (*string, bool) { +func (o *ConferenceCall) GetJoinableByOk() (*JoinableBy, bool) { if o == nil { return nil, false } @@ -649,8 +649,8 @@ func (o *ConferenceCall) HasJoinableBy() bool { return false } -// SetJoinableBy gets a reference to the given NullableString and assigns it to the JoinableBy field. -func (o *ConferenceCall) SetJoinableBy(v string) { +// SetJoinableBy gets a reference to the given NullableJoinableBy and assigns it to the JoinableBy field. +func (o *ConferenceCall) SetJoinableBy(v JoinableBy) { o.JoinableBy.Set(&v) } // SetJoinableByNil sets the value for JoinableBy to be an explicit nil diff --git a/gen/model_joinable_by.go b/gen/model_joinable_by.go new file mode 100644 index 0000000..2bb77a7 --- /dev/null +++ b/gen/model_joinable_by.go @@ -0,0 +1,101 @@ + +// Code generated from the Yay! API schema; DO NOT EDIT. + +package gen + +import ( + "encoding/json" +) + +// JoinableBy the model 'JoinableBy' +type JoinableBy string + +// List of JoinableBy +const ( + JOINABLEBY_Anyone JoinableBy = "anyone" + JOINABLEBY_Users JoinableBy = "users" + JOINABLEBY_FollowersOnly JoinableBy = "followers_only" + JOINABLEBY_MutualFollowsOnly JoinableBy = "mutual_follows_only" + JOINABLEBY_InviteOnly JoinableBy = "invite_only" + JOINABLEBY_NoOne JoinableBy = "noone" +) + +// All allowed values of JoinableBy enum +var AllowedJoinableByEnumValues = []JoinableBy{ + "anyone", + "users", + "followers_only", + "mutual_follows_only", + "invite_only", + "noone", +} + +func (v *JoinableBy) UnmarshalJSON(src []byte) error { + var value string + err := json.Unmarshal(src, &value) + if err != nil { + return err + } + enumTypeValue := JoinableBy(value) + *v = enumTypeValue + return nil +} + +// NewJoinableByFromValue returns a pointer to a valid JoinableBy +// for the value passed as argument, or an error if the value passed is not allowed by the enum +func NewJoinableByFromValue(v string) (*JoinableBy, error) { + ev := JoinableBy(v) + return &ev, nil +} + +// IsValid return true if the value is valid for the enum, false otherwise +func (v JoinableBy) IsValid() bool { + for _, existing := range AllowedJoinableByEnumValues { + if existing == v { + return true + } + } + return false +} + +// Ptr returns reference to JoinableBy value +func (v JoinableBy) Ptr() *JoinableBy { + return &v +} + +type NullableJoinableBy struct { + value *JoinableBy + isSet bool +} + +func (v NullableJoinableBy) Get() *JoinableBy { + return v.value +} + +func (v *NullableJoinableBy) Set(val *JoinableBy) { + v.value = val + v.isSet = true +} + +func (v NullableJoinableBy) IsSet() bool { + return v.isSet +} + +func (v *NullableJoinableBy) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableJoinableBy(val *JoinableBy) *NullableJoinableBy { + return &NullableJoinableBy{value: val, isSet: true} +} + +func (v NullableJoinableBy) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableJoinableBy) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + diff --git a/gen/model_realm_conference_call.go b/gen/model_realm_conference_call.go index 3d865c6..9b31b78 100644 --- a/gen/model_realm_conference_call.go +++ b/gen/model_realm_conference_call.go @@ -17,7 +17,7 @@ type RealmConferenceCall struct { AgoraToken NullableString `json:"agora_token,omitempty"` AnonymousCallUsersCount NullableInt32 `json:"anonymous_call_users_count,omitempty"` BumpParams NullableConferenceCallBumpParams `json:"bump_params,omitempty"` - CallType NullableString `json:"call_type,omitempty"` + CallType NullableCallType `json:"call_type,omitempty"` ConferenceCallUserRoles []ConferenceCallUserRole `json:"conference_call_user_roles,omitempty"` ConferenceCallUsers []RealmUser `json:"conference_call_users,omitempty"` ConferenceCallUsersCount NullableInt32 `json:"conference_call_users_count,omitempty"` @@ -26,7 +26,7 @@ type RealmConferenceCall struct { Genre NullableRealmGenre `json:"genre,omitempty"` GroupId NullableInt64 `json:"group_id,omitempty"` Id NullableInt64 `json:"id,omitempty"` - JoinableBy NullableString `json:"joinable_by,omitempty"` + JoinableBy NullableJoinableBy `json:"joinable_by,omitempty"` MaxParticipants NullableInt32 `json:"max_participants,omitempty"` PostId NullableInt64 `json:"post_id,omitempty"` Server NullableString `json:"server,omitempty"` @@ -263,9 +263,9 @@ func (o *RealmConferenceCall) UnsetBumpParams() { } // GetCallType returns the CallType field value if set, zero value otherwise (both if not set or set to explicit null). -func (o *RealmConferenceCall) GetCallType() string { +func (o *RealmConferenceCall) GetCallType() CallType { if o == nil || IsNil(o.CallType.Get()) { - var ret string + var ret CallType return ret } return *o.CallType.Get() @@ -274,7 +274,7 @@ func (o *RealmConferenceCall) GetCallType() string { // GetCallTypeOk returns a tuple with the CallType field value if set, nil otherwise // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *RealmConferenceCall) GetCallTypeOk() (*string, bool) { +func (o *RealmConferenceCall) GetCallTypeOk() (*CallType, bool) { if o == nil { return nil, false } @@ -290,8 +290,8 @@ func (o *RealmConferenceCall) HasCallType() bool { return false } -// SetCallType gets a reference to the given NullableString and assigns it to the CallType field. -func (o *RealmConferenceCall) SetCallType(v string) { +// SetCallType gets a reference to the given NullableCallType and assigns it to the CallType field. +func (o *RealmConferenceCall) SetCallType(v CallType) { o.CallType.Set(&v) } // SetCallTypeNil sets the value for CallType to be an explicit nil @@ -623,9 +623,9 @@ func (o *RealmConferenceCall) UnsetId() { } // GetJoinableBy returns the JoinableBy field value if set, zero value otherwise (both if not set or set to explicit null). -func (o *RealmConferenceCall) GetJoinableBy() string { +func (o *RealmConferenceCall) GetJoinableBy() JoinableBy { if o == nil || IsNil(o.JoinableBy.Get()) { - var ret string + var ret JoinableBy return ret } return *o.JoinableBy.Get() @@ -634,7 +634,7 @@ func (o *RealmConferenceCall) GetJoinableBy() string { // GetJoinableByOk returns a tuple with the JoinableBy field value if set, nil otherwise // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *RealmConferenceCall) GetJoinableByOk() (*string, bool) { +func (o *RealmConferenceCall) GetJoinableByOk() (*JoinableBy, bool) { if o == nil { return nil, false } @@ -650,8 +650,8 @@ func (o *RealmConferenceCall) HasJoinableBy() bool { return false } -// SetJoinableBy gets a reference to the given NullableString and assigns it to the JoinableBy field. -func (o *RealmConferenceCall) SetJoinableBy(v string) { +// SetJoinableBy gets a reference to the given NullableJoinableBy and assigns it to the JoinableBy field. +func (o *RealmConferenceCall) SetJoinableBy(v JoinableBy) { o.JoinableBy.Set(&v) } // SetJoinableByNil sets the value for JoinableBy to be an explicit nil diff --git a/packages/python/pyproject.toml b/packages/python/pyproject.toml index d9c869f..36d37d3 100644 --- a/packages/python/pyproject.toml +++ b/packages/python/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "yaylib" -version = "2.1.0" +version = "2.2.0" description = "Yay! API client SDK" authors = ["ekkx"] license = "MIT" diff --git a/packages/python/tests/test_auth.py b/packages/python/tests/test_auth.py index 2002370..6f4de32 100644 --- a/packages/python/tests/test_auth.py +++ b/packages/python/tests/test_auth.py @@ -214,3 +214,138 @@ def handler(path, method, body): assert client2.user_id == 11 finally: await client2.close() + + +# --- Browser-login fallback (PORTING.md §6.2) -------------------------------- + +from yaylib import ErrCodeRequired2FA, ErrCodeUnauthorized, code_of +from yaylib.exceptions import ApiException + +DEFAULT_WEB_KEY = "e9f1ae4c4470f29a92c0168dc42b13637cc332692103f23e626bc2b016f66603" + + +def _reject_401(code=401): + return 401, json.dumps({"result": "error", "message": "unauthorized", + "error_code": code}), {} + + +async def test_falls_back_to_web_on_401(): + seen = {} + + def handler(path, method, body): + if path == "/v3/users/login_with_email": + return _reject_401() + if path == "/v2/users/login_with_email": + seen["key"] = json.loads(body or "{}").get("api_key") + return (200, json.dumps( + {"access_token": "WEBACC", "refresh_token": "WEBREF", + "user_id": 7777}), {}) + return 404, "", {} + + async with serve(handler) as base_url: + client = Client(base_url=base_url) + try: + res = await client.login_with_email( + email="u@example.com", password="pw") + assert res.user_id == 7777 + assert client.tokens.access == "WEBACC" + assert client.tokens.refresh == "WEBREF" + assert seen["key"] == DEFAULT_WEB_KEY + finally: + await client.close() + + +async def test_no_fallback_on_2fa(): + v2_hits = 0 + + def handler(path, method, body): + nonlocal v2_hits + if path == "/v3/users/login_with_email": + return _reject_401(code=ErrCodeRequired2FA) + if path == "/v2/users/login_with_email": + v2_hits += 1 + return (200, json.dumps( + {"access_token": "X", "refresh_token": "Y", "user_id": 1}), {}) + return 404, "", {} + + async with serve(handler) as base_url: + client = Client(base_url=base_url) + try: + with pytest.raises(ApiException) as ei: + await client.login_with_email( + email="u@example.com", password="pw") + assert code_of(ei.value) == ErrCodeRequired2FA + assert v2_hits == 0 + assert client.tokens.access == "" + finally: + await client.close() + + +async def test_no_fallback_when_api_key_pinned(): + v2_hits = 0 + + def handler(path, method, body): + nonlocal v2_hits + if path == "/v3/users/login_with_email": + return _reject_401() + if path == "/v2/users/login_with_email": + v2_hits += 1 + return (200, "{}", {}) + return 404, "", {} + + async with serve(handler) as base_url: + client = Client(base_url=base_url) + try: + with pytest.raises(ApiException) as ei: + await client.login_with_email( + email="u@example.com", password="pw", + api_key="caller-pinned-key") + assert code_of(ei.value) == ErrCodeUnauthorized + assert v2_hits == 0 + finally: + await client.close() + + +async def test_fallback_disabled_surfaces_401(): + v2_hits = 0 + + def handler(path, method, body): + nonlocal v2_hits + if path == "/v3/users/login_with_email": + return _reject_401() + if path == "/v2/users/login_with_email": + v2_hits += 1 + return (200, "{}", {}) + return 404, "", {} + + async with serve(handler) as base_url: + client = Client(base_url=base_url, web_api_key="") + try: + with pytest.raises(ApiException) as ei: + await client.login_with_email( + email="u@example.com", password="pw") + assert code_of(ei.value) == ErrCodeUnauthorized + assert v2_hits == 0 + finally: + await client.close() + + +async def test_web_decode_failure_surfaced(): + def handler(path, method, body): + if path == "/v3/users/login_with_email": + return _reject_401() + if path == "/v2/users/login_with_email": + return (200, "{ this is not valid json", {}) + return 404, "", {} + + async with serve(handler) as base_url: + client = Client(base_url=base_url) + try: + with pytest.raises(ApiException) as ei: + await client.login_with_email( + email="u@example.com", password="pw") + # Not masked as the primary auth rejection. + assert code_of(ei.value) != ErrCodeUnauthorized + assert client.tokens.access == "" + finally: + await client.close() diff --git a/packages/python/yaylib/__init__.py b/packages/python/yaylib/__init__.py index f8566ef..4390298 100644 --- a/packages/python/yaylib/__init__.py +++ b/packages/python/yaylib/__init__.py @@ -14,7 +14,7 @@ """ # noqa: E501 -__version__ = "0.1.0" +__version__ = "2.2.0" # import apis into sdk package from yaylib.api.activities_api import ActivitiesApi diff --git a/packages/python/yaylib/_config.py b/packages/python/yaylib/_config.py index 23ca056..c4a261e 100644 --- a/packages/python/yaylib/_config.py +++ b/packages/python/yaylib/_config.py @@ -12,6 +12,14 @@ "ccd59ee269c01511ba763467045c115779fcae3050238a252f1bd1a4b65cfec6" ) +# API key used by the browser-login fallback. When the primary email +# login is rejected with a plain "unauthorized", the client retries +# against POST /v2/users/login_with_email, which validates against this +# key instead of DEFAULT_API_KEY. Set web_api_key="" to disable. +DEFAULT_WEB_API_KEY = ( + "e9f1ae4c4470f29a92c0168dc42b13637cc332692103f23e626bc2b016f66603" +) + # HS256 key used by signed_version and X-Jwt. DEFAULT_API_VERSION_KEY = "34c8c1cdf29b46a492e8ec58e6db37ec" diff --git a/packages/python/yaylib/api/calls_api.py b/packages/python/yaylib/api/calls_api.py index 8081bf2..a27fdf8 100644 --- a/packages/python/yaylib/api/calls_api.py +++ b/packages/python/yaylib/api/calls_api.py @@ -23,6 +23,7 @@ from yaylib.models.call_gift_history_response import CallGiftHistoryResponse from yaylib.models.call_status_response import CallStatusResponse from yaylib.models.conference_call_response import ConferenceCallResponse +from yaylib.models.joinable_by import JoinableBy from yaylib.models.rtm_token_response import RtmTokenResponse from yaylib.models.user_response import UserResponse from yaylib.models.users_by_timestamp_response import UsersByTimestampResponse @@ -4495,7 +4496,7 @@ async def update_call( call_id: StrictInt, category_id: Optional[StrictStr] = None, game_title: Optional[StrictStr] = None, - joinable_by: Optional[StrictStr] = None, + joinable_by: Optional[JoinableBy] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -4519,7 +4520,7 @@ async def update_call( :param game_title: :type game_title: str :param joinable_by: - :type joinable_by: str + :type joinable_by: JoinableBy :param _request_timeout: timeout setting for this request. If one number provided, it will be total request timeout. It can also be a pair (tuple) of @@ -4573,7 +4574,7 @@ async def update_call_with_http_info( call_id: StrictInt, category_id: Optional[StrictStr] = None, game_title: Optional[StrictStr] = None, - joinable_by: Optional[StrictStr] = None, + joinable_by: Optional[JoinableBy] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -4597,7 +4598,7 @@ async def update_call_with_http_info( :param game_title: :type game_title: str :param joinable_by: - :type joinable_by: str + :type joinable_by: JoinableBy :param _request_timeout: timeout setting for this request. If one number provided, it will be total request timeout. It can also be a pair (tuple) of @@ -4651,7 +4652,7 @@ async def update_call_without_preload_content( call_id: StrictInt, category_id: Optional[StrictStr] = None, game_title: Optional[StrictStr] = None, - joinable_by: Optional[StrictStr] = None, + joinable_by: Optional[JoinableBy] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -4675,7 +4676,7 @@ async def update_call_without_preload_content( :param game_title: :type game_title: str :param joinable_by: - :type joinable_by: str + :type joinable_by: JoinableBy :param _request_timeout: timeout setting for this request. If one number provided, it will be total request timeout. It can also be a pair (tuple) of diff --git a/packages/python/yaylib/api/chat_rooms_api.py b/packages/python/yaylib/api/chat_rooms_api.py index cd0fcaf..46b84b5 100644 --- a/packages/python/yaylib/api/chat_rooms_api.py +++ b/packages/python/yaylib/api/chat_rooms_api.py @@ -18,6 +18,7 @@ from pydantic import StrictBool, StrictInt, StrictStr from typing import List, Optional +from yaylib.models.call_type import CallType from yaylib.models.chat_room_response import ChatRoomResponse from yaylib.models.chat_rooms_response import ChatRoomsResponse from yaylib.models.create_chat_room_response import CreateChatRoomResponse @@ -5551,7 +5552,7 @@ async def send_chat_message( self, id: StrictInt, attachment_file_name: Optional[StrictStr] = None, - call_type: Optional[StrictStr] = None, + call_type: Optional[CallType] = None, font_size: Optional[StrictInt] = None, gif_image_id: Optional[StrictInt] = None, message_type: Optional[MessageType] = None, @@ -5581,7 +5582,7 @@ async def send_chat_message( :param attachment_file_name: :type attachment_file_name: str :param call_type: - :type call_type: str + :type call_type: CallType :param font_size: :type font_size: int :param gif_image_id: @@ -5657,7 +5658,7 @@ async def send_chat_message_with_http_info( self, id: StrictInt, attachment_file_name: Optional[StrictStr] = None, - call_type: Optional[StrictStr] = None, + call_type: Optional[CallType] = None, font_size: Optional[StrictInt] = None, gif_image_id: Optional[StrictInt] = None, message_type: Optional[MessageType] = None, @@ -5687,7 +5688,7 @@ async def send_chat_message_with_http_info( :param attachment_file_name: :type attachment_file_name: str :param call_type: - :type call_type: str + :type call_type: CallType :param font_size: :type font_size: int :param gif_image_id: @@ -5763,7 +5764,7 @@ async def send_chat_message_without_preload_content( self, id: StrictInt, attachment_file_name: Optional[StrictStr] = None, - call_type: Optional[StrictStr] = None, + call_type: Optional[CallType] = None, font_size: Optional[StrictInt] = None, gif_image_id: Optional[StrictInt] = None, message_type: Optional[MessageType] = None, @@ -5793,7 +5794,7 @@ async def send_chat_message_without_preload_content( :param attachment_file_name: :type attachment_file_name: str :param call_type: - :type call_type: str + :type call_type: CallType :param font_size: :type font_size: int :param gif_image_id: diff --git a/packages/python/yaylib/api/posts_api.py b/packages/python/yaylib/api/posts_api.py index f30d6b8..644101e 100644 --- a/packages/python/yaylib/api/posts_api.py +++ b/packages/python/yaylib/api/posts_api.py @@ -18,8 +18,10 @@ from pydantic import StrictBool, StrictInt, StrictStr from typing import Any, Dict, List, Optional +from yaylib.models.call_type import CallType from yaylib.models.create_post_response import CreatePostResponse from yaylib.models.gift_transactions_response import GiftTransactionsResponse +from yaylib.models.joinable_by import JoinableBy from yaylib.models.like_posts_response import LikePostsResponse from yaylib.models.noreply_mode import NoreplyMode from yaylib.models.post import Post @@ -63,13 +65,13 @@ async def create_conference_call_post( attachment_8_filename: Optional[StrictStr] = None, attachment_9_filename: Optional[StrictStr] = None, attachment_filename: Optional[StrictStr] = None, - call_type: Optional[StrictStr] = None, + call_type: Optional[CallType] = None, category_id: Optional[StrictInt] = None, color: Optional[StrictInt] = None, font_size: Optional[StrictInt] = None, game_title: Optional[StrictStr] = None, group_id: Optional[StrictInt] = None, - joinable_by: Optional[StrictStr] = None, + joinable_by: Optional[JoinableBy] = None, language: Optional[StrictStr] = None, message_tags: Optional[Dict[str, Any]] = None, signed_info: Optional[StrictStr] = None, @@ -113,7 +115,7 @@ async def create_conference_call_post( :param attachment_filename: :type attachment_filename: str :param call_type: - :type call_type: str + :type call_type: CallType :param category_id: :type category_id: int :param color: @@ -125,7 +127,7 @@ async def create_conference_call_post( :param group_id: :type group_id: int :param joinable_by: - :type joinable_by: str + :type joinable_by: JoinableBy :param language: :type language: str :param message_tags: @@ -217,13 +219,13 @@ async def create_conference_call_post_with_http_info( attachment_8_filename: Optional[StrictStr] = None, attachment_9_filename: Optional[StrictStr] = None, attachment_filename: Optional[StrictStr] = None, - call_type: Optional[StrictStr] = None, + call_type: Optional[CallType] = None, category_id: Optional[StrictInt] = None, color: Optional[StrictInt] = None, font_size: Optional[StrictInt] = None, game_title: Optional[StrictStr] = None, group_id: Optional[StrictInt] = None, - joinable_by: Optional[StrictStr] = None, + joinable_by: Optional[JoinableBy] = None, language: Optional[StrictStr] = None, message_tags: Optional[Dict[str, Any]] = None, signed_info: Optional[StrictStr] = None, @@ -267,7 +269,7 @@ async def create_conference_call_post_with_http_info( :param attachment_filename: :type attachment_filename: str :param call_type: - :type call_type: str + :type call_type: CallType :param category_id: :type category_id: int :param color: @@ -279,7 +281,7 @@ async def create_conference_call_post_with_http_info( :param group_id: :type group_id: int :param joinable_by: - :type joinable_by: str + :type joinable_by: JoinableBy :param language: :type language: str :param message_tags: @@ -371,13 +373,13 @@ async def create_conference_call_post_without_preload_content( attachment_8_filename: Optional[StrictStr] = None, attachment_9_filename: Optional[StrictStr] = None, attachment_filename: Optional[StrictStr] = None, - call_type: Optional[StrictStr] = None, + call_type: Optional[CallType] = None, category_id: Optional[StrictInt] = None, color: Optional[StrictInt] = None, font_size: Optional[StrictInt] = None, game_title: Optional[StrictStr] = None, group_id: Optional[StrictInt] = None, - joinable_by: Optional[StrictStr] = None, + joinable_by: Optional[JoinableBy] = None, language: Optional[StrictStr] = None, message_tags: Optional[Dict[str, Any]] = None, signed_info: Optional[StrictStr] = None, @@ -421,7 +423,7 @@ async def create_conference_call_post_without_preload_content( :param attachment_filename: :type attachment_filename: str :param call_type: - :type call_type: str + :type call_type: CallType :param category_id: :type category_id: int :param color: @@ -433,7 +435,7 @@ async def create_conference_call_post_without_preload_content( :param group_id: :type group_id: int :param joinable_by: - :type joinable_by: str + :type joinable_by: JoinableBy :param language: :type language: str :param message_tags: @@ -2436,7 +2438,7 @@ async def get_call_followers_timeline( from_timestamp: Optional[StrictInt] = None, number: Optional[StrictInt] = None, category_id: Optional[StrictInt] = None, - call_type: Optional[StrictStr] = None, + call_type: Optional[CallType] = None, include_circle_call: Optional[StrictBool] = None, exclude_recent_gomimushi: Optional[StrictBool] = None, _request_timeout: Union[ @@ -2462,7 +2464,7 @@ async def get_call_followers_timeline( :param category_id: :type category_id: int :param call_type: - :type call_type: str + :type call_type: CallType :param include_circle_call: :type include_circle_call: bool :param exclude_recent_gomimushi: @@ -2522,7 +2524,7 @@ async def get_call_followers_timeline_with_http_info( from_timestamp: Optional[StrictInt] = None, number: Optional[StrictInt] = None, category_id: Optional[StrictInt] = None, - call_type: Optional[StrictStr] = None, + call_type: Optional[CallType] = None, include_circle_call: Optional[StrictBool] = None, exclude_recent_gomimushi: Optional[StrictBool] = None, _request_timeout: Union[ @@ -2548,7 +2550,7 @@ async def get_call_followers_timeline_with_http_info( :param category_id: :type category_id: int :param call_type: - :type call_type: str + :type call_type: CallType :param include_circle_call: :type include_circle_call: bool :param exclude_recent_gomimushi: @@ -2608,7 +2610,7 @@ async def get_call_followers_timeline_without_preload_content( from_timestamp: Optional[StrictInt] = None, number: Optional[StrictInt] = None, category_id: Optional[StrictInt] = None, - call_type: Optional[StrictStr] = None, + call_type: Optional[CallType] = None, include_circle_call: Optional[StrictBool] = None, exclude_recent_gomimushi: Optional[StrictBool] = None, _request_timeout: Union[ @@ -2634,7 +2636,7 @@ async def get_call_followers_timeline_without_preload_content( :param category_id: :type category_id: int :param call_type: - :type call_type: str + :type call_type: CallType :param include_circle_call: :type include_circle_call: bool :param exclude_recent_gomimushi: @@ -2728,7 +2730,7 @@ def _get_call_followers_timeline_serialize( if call_type is not None: - _query_params.append(('call_type', call_type)) + _query_params.append(('call_type', call_type.value)) if include_circle_call is not None: @@ -2781,7 +2783,7 @@ async def get_call_timeline( from_timestamp: Optional[StrictInt] = None, number: Optional[StrictInt] = None, category_id: Optional[StrictInt] = None, - call_type: Optional[StrictStr] = None, + call_type: Optional[CallType] = None, include_circle_call: Optional[StrictBool] = None, cross_generation: Optional[StrictBool] = None, exclude_recent_gomimushi: Optional[StrictBool] = None, @@ -2810,7 +2812,7 @@ async def get_call_timeline( :param category_id: :type category_id: int :param call_type: - :type call_type: str + :type call_type: CallType :param include_circle_call: :type include_circle_call: bool :param cross_generation: @@ -2875,7 +2877,7 @@ async def get_call_timeline_with_http_info( from_timestamp: Optional[StrictInt] = None, number: Optional[StrictInt] = None, category_id: Optional[StrictInt] = None, - call_type: Optional[StrictStr] = None, + call_type: Optional[CallType] = None, include_circle_call: Optional[StrictBool] = None, cross_generation: Optional[StrictBool] = None, exclude_recent_gomimushi: Optional[StrictBool] = None, @@ -2904,7 +2906,7 @@ async def get_call_timeline_with_http_info( :param category_id: :type category_id: int :param call_type: - :type call_type: str + :type call_type: CallType :param include_circle_call: :type include_circle_call: bool :param cross_generation: @@ -2969,7 +2971,7 @@ async def get_call_timeline_without_preload_content( from_timestamp: Optional[StrictInt] = None, number: Optional[StrictInt] = None, category_id: Optional[StrictInt] = None, - call_type: Optional[StrictStr] = None, + call_type: Optional[CallType] = None, include_circle_call: Optional[StrictBool] = None, cross_generation: Optional[StrictBool] = None, exclude_recent_gomimushi: Optional[StrictBool] = None, @@ -2998,7 +3000,7 @@ async def get_call_timeline_without_preload_content( :param category_id: :type category_id: int :param call_type: - :type call_type: str + :type call_type: CallType :param include_circle_call: :type include_circle_call: bool :param cross_generation: @@ -3102,7 +3104,7 @@ def _get_call_timeline_serialize( if call_type is not None: - _query_params.append(('call_type', call_type)) + _query_params.append(('call_type', call_type.value)) if include_circle_call is not None: diff --git a/packages/python/yaylib/auth.py b/packages/python/yaylib/auth.py index 9222155..a6d591c 100644 --- a/packages/python/yaylib/auth.py +++ b/packages/python/yaylib/auth.py @@ -7,9 +7,12 @@ from __future__ import annotations +import json from typing import Optional -from yaylib.errors import as_api_error +from yaylib._error_codes import ErrCodeUnauthorized +from yaylib.errors import as_api_error, code_of +from yaylib.exceptions import ApiException from yaylib.models.login_email_user_request import LoginEmailUserRequest from yaylib.models.login_user_response import LoginUserResponse from yaylib.session import NoSessionError, Session, SessionSaveFailed @@ -60,6 +63,68 @@ def _activate_cached_session(client, session: Session) -> None: client.set_login_identity(session.email, session.user_id) +async def _login_with_email_web( + client, + *, + email: str, + password: str, + uuid: str, + two_fa_code: Optional[str], +) -> Optional[LoginUserResponse]: + """Fallback login against the browser endpoint (POST + /v2/users/login_with_email) using the client's ``web_api_key``. + + Sent through the UNWRAPPED transport (``send_unwrapped``): no header + injection, no Bearer, no 401 refresh — a login must authenticate + purely by its body credentials (PORTING.md §6.1/§6.2). Only the + body's api_key and the endpoint version differ from the app login. + + Returns the decoded response on success, or ``None`` when the + fallback is itself rejected (non-2xx) so the caller surfaces the + primary error. Raises an ``ApiException`` carrying the raw body when + a 2xx response fails to decode (§15), rather than masking it behind + the primary 401. + """ + payload = { + "email": email, + "password": password, + "api_key": client.web_api_key, + "uuid": uuid, + } + if two_fa_code: + payload["two_fa_code"] = two_fa_code + + # Present as the browser client. The web version string is not + # otherwise known to the SDK, so app_version stands in for it. + web_ua = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) + headers = { + "Content-Type": "application/json;charset=UTF-8", + "User-Agent": web_ua, + "Agent": f"YayWeb {client.app_version}", + "X-Device-Info": f"Yay {client.app_version} Web ({web_ua})", + } + url = f"{client.base_url}/v2/users/login_with_email" + try: + res = await client._transport.send_unwrapped( + "POST", url, headers, json.dumps(payload) + ) + except Exception: # noqa: BLE001 + return None + if not (200 <= res.status <= 299): + return None + raw = res.data.decode("utf-8", "replace") + try: + return LoginUserResponse.from_json(raw) + except Exception as err: # noqa: BLE001 + # A 2xx we couldn't decode is a genuine login result: surface it + # with the raw body, not the primary 401. + raise ApiException(status=res.status, reason=res.reason, body=raw) from err + + async def _execute_login( client, *, @@ -101,7 +166,35 @@ async def _execute_login( login_email_user_request=body ) except Exception as err: # noqa: BLE001 - raise as_api_error(err) + api_err = as_api_error(err) + # Fallback (PORTING.md §6.2): a plain "unauthorized" retries + # against the browser login endpoint with web_api_key. Skipped + # for other 401s — notably Required2FA (the caller must supply a + # code, not switch keys) — and when the caller pinned a specific + # api_key, or when web_api_key is empty (the off switch). + caller_pinned = api_key is not None and api_key != client.api_key + if ( + client.web_api_key + and not caller_pinned + and code_of(api_err) == ErrCodeUnauthorized + ): + fallback = await _login_with_email_web( + client, + email=email, + password=password, + uuid=uuid or client.device_uuid, + two_fa_code=two_fa_code, + ) + if fallback is None: + # Fallback itself rejected: surface the primary error. + raise api_err + client._logger.debug( + "web login fallback ok", + extra={"event": "login_fallback", "outcome": "ok"}, + ) + resp = fallback + else: + raise api_err access = resp.access_token or "" refresh = resp.refresh_token or "" diff --git a/packages/python/yaylib/client.py b/packages/python/yaylib/client.py index af32238..5c6ffe2 100644 --- a/packages/python/yaylib/client.py +++ b/packages/python/yaylib/client.py @@ -51,6 +51,7 @@ from yaylib._config import ( DEFAULT_ACCEPT_LANGUAGE, DEFAULT_API_KEY, + DEFAULT_WEB_API_KEY, DEFAULT_API_VERSION_KEY, DEFAULT_API_VERSION_NAME, DEFAULT_APP_VERSION, @@ -125,6 +126,7 @@ def __init__( cassandra_base_url: Optional[str] = None, event_stream_url: Optional[str] = None, api_key: Optional[str] = None, + web_api_key: Optional[str] = None, api_version_key: Optional[str] = None, api_version_name: Optional[str] = None, app_version: Optional[str] = None, @@ -142,6 +144,11 @@ def __init__( self.cassandra_base_url = cassandra_base_url or DEFAULT_CASSANDRA_BASE_URL self.event_stream_url = event_stream_url or DEFAULT_EVENT_STREAM_URL self.api_key = api_key or DEFAULT_API_KEY + # Empty string is a meaningful value here (disables the login + # fallback), so only fall back to the default when unset. + self.web_api_key = ( + web_api_key if web_api_key is not None else DEFAULT_WEB_API_KEY + ) self.api_version_key = api_version_key or DEFAULT_API_VERSION_KEY self.api_version_name = api_version_name or DEFAULT_API_VERSION_NAME self.app_version = app_version or DEFAULT_APP_VERSION diff --git a/packages/python/yaylib/models/__init__.py b/packages/python/yaylib/models/__init__.py index 5631e5a..1e34fd7 100644 --- a/packages/python/yaylib/models/__init__.py +++ b/packages/python/yaylib/models/__init__.py @@ -53,6 +53,7 @@ from yaylib.models.call_gift_history_response import CallGiftHistoryResponse from yaylib.models.call_message import CallMessage from yaylib.models.call_status_response import CallStatusResponse +from yaylib.models.call_type import CallType from yaylib.models.callback import Callback from yaylib.models.campaign_dto import CampaignDTO from yaylib.models.campaign_invited_users_response import CampaignInvitedUsersResponse @@ -211,6 +212,7 @@ from yaylib.models.item import Item from yaylib.models.j0_a import J0A from yaylib.models.js_event import JSEvent +from yaylib.models.joinable_by import JoinableBy from yaylib.models.last_status_screen_user_rank import LastStatusScreenUserRank from yaylib.models.level_up import LevelUp from yaylib.models.level_up_details import LevelUpDetails diff --git a/packages/python/yaylib/models/call_type.py b/packages/python/yaylib/models/call_type.py new file mode 100644 index 0000000..ecb3438 --- /dev/null +++ b/packages/python/yaylib/models/call_type.py @@ -0,0 +1,59 @@ +# coding: utf-8 + +""" + Yay! API + + No description provided + + Schema version: 4.26.1 + Code generated; DO NOT EDIT + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import json +from enum import Enum +from typing_extensions import Self + + +class CallType(str, Enum): + """ + CallType + """ + + """ + allowed enum values + """ + VDO = 'vdo' + VOICE = 'voice' + + @classmethod + def __get_pydantic_core_schema__(cls, source_type, handler): + # Accept any string the server sends, not only the values + # enumerated above, so a value added server-side still + # decodes. A typed constant passed in stays a member (so + # callers and request serializers keep working); an unknown + # string passes through unchanged. + from pydantic_core import core_schema + def _v(value): + if isinstance(value, cls): + return value + try: + return cls(value) + except ValueError: + return value + return core_schema.no_info_plain_validator_function( + _v, + serialization=core_schema.plain_serializer_function_ser_schema( + lambda value: value.value if isinstance(value, cls) else value + ), + ) + + @classmethod + def from_json(cls, json_str: str) -> Self: + """Create an instance of CallType from a JSON string""" + return cls(json.loads(json_str)) + + diff --git a/packages/python/yaylib/models/conference_call.py b/packages/python/yaylib/models/conference_call.py index f1beb8a..1e2cef9 100644 --- a/packages/python/yaylib/models/conference_call.py +++ b/packages/python/yaylib/models/conference_call.py @@ -20,8 +20,10 @@ from pydantic import BaseModel, ConfigDict, StrictBool, StrictInt, StrictStr from typing import Any, ClassVar, Dict, List, Optional from yaylib.models.bump_params import BumpParams +from yaylib.models.call_type import CallType from yaylib.models.game import Game from yaylib.models.genre import Genre +from yaylib.models.joinable_by import JoinableBy from yaylib.models.model_conference_call_user_role import ModelConferenceCallUserRole from yaylib.models.user import User from typing import Optional, Set @@ -35,7 +37,7 @@ class ConferenceCall(BaseModel): agora_token: Optional[StrictStr] = None anonymous_call_users_count: Optional[StrictInt] = None bump_params: Optional[BumpParams] = None - call_type: Optional[StrictStr] = None + call_type: Optional[CallType] = None conference_call_user_uuid: Optional[StrictStr] = None conference_call_users: Optional[List[User]] = None conference_call_users_role: Optional[List[ModelConferenceCallUserRole]] = None @@ -45,7 +47,7 @@ class ConferenceCall(BaseModel): group_id: Optional[StrictInt] = None id: Optional[StrictInt] = None is_active: Optional[StrictBool] = None - joinable_by: Optional[StrictStr] = None + joinable_by: Optional[JoinableBy] = None max_participants: Optional[StrictInt] = None post_id: Optional[StrictInt] = None __properties: ClassVar[List[str]] = ["agora_channel", "agora_token", "anonymous_call_users_count", "bump_params", "call_type", "conference_call_user_uuid", "conference_call_users", "conference_call_users_role", "duration_seconds", "game", "genre", "group_id", "id", "is_active", "joinable_by", "max_participants", "post_id"] diff --git a/packages/python/yaylib/models/joinable_by.py b/packages/python/yaylib/models/joinable_by.py new file mode 100644 index 0000000..e226cfa --- /dev/null +++ b/packages/python/yaylib/models/joinable_by.py @@ -0,0 +1,63 @@ +# coding: utf-8 + +""" + Yay! API + + No description provided + + Schema version: 4.26.1 + Code generated; DO NOT EDIT + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import json +from enum import Enum +from typing_extensions import Self + + +class JoinableBy(str, Enum): + """ + JoinableBy + """ + + """ + allowed enum values + """ + ANYONE = 'anyone' + USERS = 'users' + FOLLOWERS_ONLY = 'followers_only' + MUTUAL_FOLLOWS_ONLY = 'mutual_follows_only' + INVITE_ONLY = 'invite_only' + NOONE = 'noone' + + @classmethod + def __get_pydantic_core_schema__(cls, source_type, handler): + # Accept any string the server sends, not only the values + # enumerated above, so a value added server-side still + # decodes. A typed constant passed in stays a member (so + # callers and request serializers keep working); an unknown + # string passes through unchanged. + from pydantic_core import core_schema + def _v(value): + if isinstance(value, cls): + return value + try: + return cls(value) + except ValueError: + return value + return core_schema.no_info_plain_validator_function( + _v, + serialization=core_schema.plain_serializer_function_ser_schema( + lambda value: value.value if isinstance(value, cls) else value + ), + ) + + @classmethod + def from_json(cls, json_str: str) -> Self: + """Create an instance of JoinableBy from a JSON string""" + return cls(json.loads(json_str)) + + diff --git a/packages/python/yaylib/models/realm_conference_call.py b/packages/python/yaylib/models/realm_conference_call.py index dc954ec..b290407 100644 --- a/packages/python/yaylib/models/realm_conference_call.py +++ b/packages/python/yaylib/models/realm_conference_call.py @@ -19,8 +19,10 @@ from pydantic import BaseModel, ConfigDict, StrictBool, StrictInt, StrictStr from typing import Any, ClassVar, Dict, List, Optional +from yaylib.models.call_type import CallType from yaylib.models.conference_call_bump_params import ConferenceCallBumpParams from yaylib.models.conference_call_user_role import ConferenceCallUserRole +from yaylib.models.joinable_by import JoinableBy from yaylib.models.realm_game import RealmGame from yaylib.models.realm_genre import RealmGenre from yaylib.models.realm_user import RealmUser @@ -36,7 +38,7 @@ class RealmConferenceCall(BaseModel): agora_token: Optional[StrictStr] = None anonymous_call_users_count: Optional[StrictInt] = None bump_params: Optional[ConferenceCallBumpParams] = None - call_type: Optional[StrictStr] = None + call_type: Optional[CallType] = None conference_call_user_roles: Optional[List[ConferenceCallUserRole]] = None conference_call_users: Optional[List[RealmUser]] = None conference_call_users_count: Optional[StrictInt] = None @@ -45,7 +47,7 @@ class RealmConferenceCall(BaseModel): genre: Optional[RealmGenre] = None group_id: Optional[StrictInt] = None id: Optional[StrictInt] = None - joinable_by: Optional[StrictStr] = None + joinable_by: Optional[JoinableBy] = None max_participants: Optional[StrictInt] = None post_id: Optional[StrictInt] = None server: Optional[StrictStr] = None diff --git a/packages/rust/Cargo.toml b/packages/rust/Cargo.toml index 3dc7895..0c27cc6 100644 --- a/packages/rust/Cargo.toml +++ b/packages/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "yaylib" -version = "2.0.0" +version = "2.2.0" edition = "2021" rust-version = "1.88" description = "Rust client for the Yay! (SNS) API" diff --git a/packages/rust/src/auth.rs b/packages/rust/src/auth.rs index 49e99f3..c2e4dd1 100644 --- a/packages/rust/src/auth.rs +++ b/packages/rust/src/auth.rs @@ -118,6 +118,10 @@ impl<'a> LoginWithEmailRequest<'a> { } } + // Whether the caller pinned a specific api_key (vs. the client + // default). A pinned key opts out of the browser-login fallback. + let caller_pinned = api_key.as_str() != self.client.api_key(); + // Build and execute the HTTP login request. let body = LoginEmailUserRequestBody { email: email.clone(), @@ -127,7 +131,38 @@ impl<'a> LoginWithEmailRequest<'a> { two_fa_code: self.two_fa_code, }; - let resp = self.client.do_login_with_email(body).await?; + let resp = match self.client.do_login_with_email(body.clone()).await { + Ok(resp) => resp, + Err(e) => { + // Fallback (PORTING.md §6.2): a plain "unauthorized" + // retries against the browser login endpoint with + // web_api_key. Skipped for other 401s — notably + // Required2FA (the caller must supply a code, not switch + // keys) — when the caller pinned a specific api_key, or + // when web_api_key is empty (the off switch). + if !self.client.web_api_key().is_empty() + && !caller_pinned + && crate::errors::code_of(&e) + == crate::error_codes::ErrorCode::Unauthorized + { + match self.client.do_login_with_email_web(&body).await { + Ok(Some(resp)) => { + tracing::debug!( + event = "login_fallback", + outcome = "ok" + ); + resp + } + // Fallback rejected → surface the primary error. + Ok(None) => return Err(e), + // 2xx decode failure → surface the web error. + Err(we) => return Err(we), + } + } else { + return Err(e); + } + } + }; // Activate tokens immediately so follow-up requests work even if // session persistence fails below. @@ -181,3 +216,154 @@ pub struct LoginResponse { pub access_token: String, pub refresh_token: String, } + +// Browser-login fallback (PORTING.md §6.2). +#[cfg(test)] +mod fallback_tests { + use crate::client::Client; + use crate::error_codes::ErrorCode; + use crate::errors::code_of; + use wiremock::matchers::{body_string_contains, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + const WEB_KEY: &str = + "e9f1ae4c4470f29a92c0168dc42b13637cc332692103f23e626bc2b016f66603"; + + fn reject_401(code: i32) -> ResponseTemplate { + ResponseTemplate::new(401).set_body_string(format!( + r#"{{"result":"error","message":"unauthorized","error_code":{code}}}"# + )) + } + + async fn mock_v3_reject(server: &MockServer, code: i32) { + Mock::given(method("POST")) + .and(path("/v3/users/login_with_email")) + .respond_with(reject_401(code)) + .mount(server) + .await; + } + + #[tokio::test] + async fn falls_back_to_web_on_401() { + let server = MockServer::start().await; + mock_v3_reject(&server, 401).await; + // Matching on the web key asserts the fallback carried it. + Mock::given(method("POST")) + .and(path("/v2/users/login_with_email")) + .and(body_string_contains(WEB_KEY)) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"access_token":"WEBACC","refresh_token":"WEBREF","user_id":7777}"#, + )) + .mount(&server) + .await; + + let client = Client::builder().base_url(server.uri()).build(); + let resp = client + .login_with_email() + .email("u@example.com") + .password("pw") + .execute() + .await + .expect("fallback login should succeed"); + assert_eq!(resp.user_id, 7777); + assert_eq!(resp.access_token, "WEBACC"); + assert_eq!(client.tokens().access, "WEBACC"); + } + + #[tokio::test] + async fn no_fallback_on_2fa() { + let server = MockServer::start().await; + mock_v3_reject(&server, ErrorCode::Required2FA as i32).await; + // The v2 endpoint must never be hit. + Mock::given(method("POST")) + .and(path("/v2/users/login_with_email")) + .respond_with(ResponseTemplate::new(200).set_body_string("{}")) + .expect(0) + .mount(&server) + .await; + + let client = Client::builder().base_url(server.uri()).build(); + let err = client + .login_with_email() + .email("u@example.com") + .password("pw") + .execute() + .await + .expect_err("2FA-required must not fall back"); + assert_eq!(code_of(&err), ErrorCode::Required2FA); + assert_eq!(client.tokens().access, ""); + } + + #[tokio::test] + async fn no_fallback_when_api_key_pinned() { + let server = MockServer::start().await; + mock_v3_reject(&server, 401).await; + Mock::given(method("POST")) + .and(path("/v2/users/login_with_email")) + .respond_with(ResponseTemplate::new(200).set_body_string("{}")) + .expect(0) + .mount(&server) + .await; + + let client = Client::builder().base_url(server.uri()).build(); + let err = client + .login_with_email() + .email("u@example.com") + .password("pw") + .api_key("caller-pinned-key") + .execute() + .await + .expect_err("pinned api_key must not fall back"); + assert_eq!(code_of(&err), ErrorCode::Unauthorized); + } + + #[tokio::test] + async fn fallback_disabled_surfaces_401() { + let server = MockServer::start().await; + mock_v3_reject(&server, 401).await; + Mock::given(method("POST")) + .and(path("/v2/users/login_with_email")) + .respond_with(ResponseTemplate::new(200).set_body_string("{}")) + .expect(0) + .mount(&server) + .await; + + let client = Client::builder() + .base_url(server.uri()) + .web_api_key("") + .build(); + let err = client + .login_with_email() + .email("u@example.com") + .password("pw") + .execute() + .await + .expect_err("disabled fallback must surface the primary 401"); + assert_eq!(code_of(&err), ErrorCode::Unauthorized); + } + + #[tokio::test] + async fn web_decode_failure_surfaced() { + let server = MockServer::start().await; + mock_v3_reject(&server, 401).await; + Mock::given(method("POST")) + .and(path("/v2/users/login_with_email")) + .respond_with( + ResponseTemplate::new(200).set_body_string("{ this is not valid json"), + ) + .mount(&server) + .await; + + let client = Client::builder().base_url(server.uri()).build(); + let err = client + .login_with_email() + .email("u@example.com") + .password("pw") + .execute() + .await + .expect_err("a 2xx decode failure should surface as an error"); + // Not masked as the primary auth rejection. + assert_ne!(code_of(&err), ErrorCode::Unauthorized); + assert_eq!(client.tokens().access, ""); + } +} diff --git a/packages/rust/src/client.rs b/packages/rust/src/client.rs index eca5f3b..f67b44d 100644 --- a/packages/rust/src/client.rs +++ b/packages/rust/src/client.rs @@ -18,6 +18,7 @@ use crate::uuid::new_uuid_v4; #[derive(Debug)] pub(crate) struct ClientConfig { pub(crate) api_key: String, + pub(crate) web_api_key: String, pub(crate) api_version_key: String, pub(crate) api_version_name: String, pub(crate) app_version: String, @@ -53,6 +54,7 @@ pub(crate) struct SharedState { #[derive(Default)] pub struct ClientBuilder { api_key: Option, + web_api_key: Option, api_version_key: Option, api_version_name: Option, app_version: Option, @@ -78,6 +80,14 @@ impl ClientBuilder { self.api_key = Some(v.into()); self } + + /// Overrides the key used by the email-login fallback (see + /// [`DEFAULT_WEB_API_KEY`]). Pass `""` to disable the fallback: a + /// primary 401 then surfaces unchanged. + pub fn web_api_key(mut self, v: impl Into) -> Self { + self.web_api_key = Some(v.into()); + self + } pub fn api_version_key(mut self, v: impl Into) -> Self { self.api_version_key = Some(v.into()); self @@ -164,6 +174,9 @@ impl ClientBuilder { api_key: self .api_key .unwrap_or_else(|| DEFAULT_API_KEY.to_string()), + web_api_key: self + .web_api_key + .unwrap_or_else(|| DEFAULT_WEB_API_KEY.to_string()), api_version_key: self .api_version_key .unwrap_or_else(|| DEFAULT_API_VERSION_KEY.to_string()), @@ -294,6 +307,9 @@ impl Client { pub fn api_key(&self) -> &str { &self.config.api_key } + pub fn web_api_key(&self) -> &str { + &self.config.web_api_key + } pub fn api_version_key(&self) -> &str { &self.config.api_version_key } @@ -505,6 +521,74 @@ impl Client { refresh_token: resp.refresh_token.flatten().unwrap_or_default(), }) } + + /// Fallback login against the browser endpoint (POST + /// /v2/users/login_with_email) using [`web_api_key`](Client::web_api_key). + /// + /// Issued directly through the raw `reqwest::Client`, bypassing the + /// transport's header injection / Bearer / 401-refresh: a login must + /// authenticate purely by its body credentials and the browser + /// endpoint expects the browser client's minimal header set (PORTING + /// §6.1/§6.2). Only the body's api_key and the endpoint version + /// differ from the app login. + /// + /// Returns `Ok(Some(resp))` on success, `Ok(None)` when the fallback + /// is itself rejected (non-2xx) so the caller surfaces the primary + /// error, or `Err` carrying the raw body when a 2xx response fails to + /// decode (§15) — rather than masking it behind the primary 401. + pub(crate) async fn do_login_with_email_web( + &self, + body: &LoginEmailUserRequestBody, + ) -> Result, ApiError> { + let cfg = &self.config; + // Present as the browser client. The web version string is not + // otherwise known to the SDK, so app_version stands in for it. + const WEB_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \ +AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + + let mut payload = serde_json::json!({ + "email": body.email, + "password": body.password, + "api_key": cfg.web_api_key, + "uuid": body.uuid, + }); + if let Some(code) = &body.two_fa_code { + payload["two_fa_code"] = serde_json::Value::String(code.clone()); + } + + let resp = self + .transport + .http_client + .post(format!("{}/v2/users/login_with_email", cfg.base_url)) + .header("content-type", "application/json;charset=UTF-8") + .header("user-agent", WEB_UA) + .header("agent", format!("YayWeb {}", cfg.app_version)) + .header( + "x-device-info", + format!("Yay {} Web ({})", cfg.app_version, WEB_UA), + ) + .body(payload.to_string()) + .send() + .await + .map_err(ApiError::Http)?; + + let status = resp.status(); + let bytes = resp.bytes().await.map_err(ApiError::Http)?; + if !status.is_success() { + // Fallback itself rejected: caller surfaces the primary error. + return Ok(None); + } + match serde_json::from_slice::(&bytes) { + Ok(r) => Ok(Some(LoginResponse { + user_id: r.user_id.flatten().unwrap_or(0), + access_token: r.access_token.flatten().unwrap_or_default(), + refresh_token: r.refresh_token.flatten().unwrap_or_default(), + })), + // A 2xx we couldn't decode is a genuine login result: surface + // it with the raw body, not the primary 401. + Err(_) => Err(ApiError::from_response(status.as_u16(), bytes.to_vec())), + } + } } // --------------------------------------------------------------------------- diff --git a/packages/rust/src/config.rs b/packages/rust/src/config.rs index 1efed96..2dbc305 100644 --- a/packages/rust/src/config.rs +++ b/packages/rust/src/config.rs @@ -5,6 +5,12 @@ pub const DEFAULT_CASSANDRA_BASE_URL: &str = "https://cas.yay.space"; pub const DEFAULT_API_KEY: &str = "ccd59ee269c01511ba763467045c115779fcae3050238a252f1bd1a4b65cfec6"; +/// API key used by the browser-login fallback. When the primary email +/// login is rejected with a plain "unauthorized", the client retries +/// against POST /v2/users/login_with_email, which validates against this +/// key instead of [`DEFAULT_API_KEY`]. Set `web_api_key("")` to disable. +pub const DEFAULT_WEB_API_KEY: &str = + "e9f1ae4c4470f29a92c0168dc42b13637cc332692103f23e626bc2b016f66603"; pub const DEFAULT_API_VERSION_KEY: &str = "34c8c1cdf29b46a492e8ec58e6db37ec"; pub const DEFAULT_API_VERSION_NAME: &str = "4.26"; pub const DEFAULT_APP_VERSION: &str = "4.26.1"; diff --git a/packages/rust/src/facade.rs b/packages/rust/src/facade.rs index 6c86db1..f505c70 100644 --- a/packages/rust/src/facade.rs +++ b/packages/rust/src/facade.rs @@ -113,7 +113,7 @@ impl Client { crate::gen::apis::calls_api::start_conference_call(&config, call_sid, conference_id).await.map_err(Into::into) } - pub async fn update_call(&self, call_id: i64, category_id: Option<&str>, game_title: Option<&str>, joinable_by: Option<&str>) -> Result<(), crate::ApiError> { + pub async fn update_call(&self, call_id: i64, category_id: Option<&str>, game_title: Option<&str>, joinable_by: Option) -> Result<(), crate::ApiError> { let config = self.gen_configuration(); crate::gen::apis::calls_api::update_call(&config, call_id, category_id, game_title, joinable_by).await.map_err(Into::into) } @@ -228,7 +228,7 @@ impl Client { crate::gen::apis::chat_rooms_api::report_chat_room(&config, chat_room_id, category_id, opponent_id, reason, screenshot_2_filename, screenshot_3_filename, screenshot_4_filename, screenshot_filename).await.map_err(Into::into) } - pub async fn send_chat_message(&self, id: i64, attachment_file_name: Option<&str>, call_type: Option<&str>, font_size: Option, gif_image_id: Option, message_type: Option, parent_id: Option, sticker_id: Option, sticker_pack_id: Option, text: Option<&str>, video_file_name: Option<&str>) -> Result { + pub async fn send_chat_message(&self, id: i64, attachment_file_name: Option<&str>, call_type: Option, font_size: Option, gif_image_id: Option, message_type: Option, parent_id: Option, sticker_id: Option, sticker_pack_id: Option, text: Option<&str>, video_file_name: Option<&str>) -> Result { let config = self.gen_configuration(); crate::gen::apis::chat_rooms_api::send_chat_message(&config, id, attachment_file_name, call_type, font_size, gif_image_id, message_type, parent_id, sticker_id, sticker_pack_id, text, video_file_name).await.map_err(Into::into) } @@ -653,7 +653,7 @@ impl Client { crate::gen::apis::pinned_api::unpin_review(&config, id).await.map_err(Into::into) } - pub async fn create_conference_call_post(&self, api_key: Option<&str>, attachment_2_filename: Option<&str>, attachment_3_filename: Option<&str>, attachment_4_filename: Option<&str>, attachment_5_filename: Option<&str>, attachment_6_filename: Option<&str>, attachment_7_filename: Option<&str>, attachment_8_filename: Option<&str>, attachment_9_filename: Option<&str>, attachment_filename: Option<&str>, call_type: Option<&str>, category_id: Option, color: Option, font_size: Option, game_title: Option<&str>, group_id: Option, joinable_by: Option<&str>, language: Option<&str>, message_tags: Option, signed_info: Option<&str>, text: Option<&str>, timestamp: Option, uuid: Option<&str>) -> Result { + pub async fn create_conference_call_post(&self, api_key: Option<&str>, attachment_2_filename: Option<&str>, attachment_3_filename: Option<&str>, attachment_4_filename: Option<&str>, attachment_5_filename: Option<&str>, attachment_6_filename: Option<&str>, attachment_7_filename: Option<&str>, attachment_8_filename: Option<&str>, attachment_9_filename: Option<&str>, attachment_filename: Option<&str>, call_type: Option, category_id: Option, color: Option, font_size: Option, game_title: Option<&str>, group_id: Option, joinable_by: Option, language: Option<&str>, message_tags: Option, signed_info: Option<&str>, text: Option<&str>, timestamp: Option, uuid: Option<&str>) -> Result { let config = self.gen_configuration(); crate::gen::apis::posts_api::create_conference_call_post(&config, api_key, attachment_2_filename, attachment_3_filename, attachment_4_filename, attachment_5_filename, attachment_6_filename, attachment_7_filename, attachment_8_filename, attachment_9_filename, attachment_filename, call_type, category_id, color, font_size, game_title, group_id, joinable_by, language, message_tags, signed_info, text, timestamp, uuid).await.map_err(Into::into) } @@ -683,12 +683,12 @@ impl Client { crate::gen::apis::posts_api::get_active_call_post(&config, user_id).await.map_err(Into::into) } - pub async fn get_call_followers_timeline(&self, from_timestamp: Option, number: Option, category_id: Option, call_type: Option<&str>, include_circle_call: Option, exclude_recent_gomimushi: Option) -> Result { + pub async fn get_call_followers_timeline(&self, from_timestamp: Option, number: Option, category_id: Option, call_type: Option, include_circle_call: Option, exclude_recent_gomimushi: Option) -> Result { let config = self.gen_configuration(); crate::gen::apis::posts_api::get_call_followers_timeline(&config, from_timestamp, number, category_id, call_type, include_circle_call, exclude_recent_gomimushi).await.map_err(Into::into) } - pub async fn get_call_timeline(&self, group_id: Option, from_timestamp: Option, number: Option, category_id: Option, call_type: Option<&str>, include_circle_call: Option, cross_generation: Option, exclude_recent_gomimushi: Option) -> Result { + pub async fn get_call_timeline(&self, group_id: Option, from_timestamp: Option, number: Option, category_id: Option, call_type: Option, include_circle_call: Option, cross_generation: Option, exclude_recent_gomimushi: Option) -> Result { let config = self.gen_configuration(); crate::gen::apis::posts_api::get_call_timeline(&config, group_id, from_timestamp, number, category_id, call_type, include_circle_call, cross_generation, exclude_recent_gomimushi).await.map_err(Into::into) } diff --git a/packages/rust/src/gen/apis/calls_api.rs b/packages/rust/src/gen/apis/calls_api.rs index fa38572..3bf325a 100644 --- a/packages/rust/src/gen/apis/calls_api.rs +++ b/packages/rust/src/gen/apis/calls_api.rs @@ -656,7 +656,7 @@ pub async fn start_conference_call(configuration: &configuration::Configuration, } } -pub async fn update_call(configuration: &configuration::Configuration, call_id: i64, category_id: Option<&str>, game_title: Option<&str>, joinable_by: Option<&str>) -> Result<(), Error> { +pub async fn update_call(configuration: &configuration::Configuration, call_id: i64, category_id: Option<&str>, game_title: Option<&str>, joinable_by: Option) -> Result<(), Error> { let local_var_configuration = configuration; let local_var_client = &local_var_configuration.client; diff --git a/packages/rust/src/gen/apis/chat_rooms_api.rs b/packages/rust/src/gen/apis/chat_rooms_api.rs index b70b280..db6d44d 100644 --- a/packages/rust/src/gen/apis/chat_rooms_api.rs +++ b/packages/rust/src/gen/apis/chat_rooms_api.rs @@ -804,7 +804,7 @@ pub async fn report_chat_room(configuration: &configuration::Configuration, chat } } -pub async fn send_chat_message(configuration: &configuration::Configuration, id: i64, attachment_file_name: Option<&str>, call_type: Option<&str>, font_size: Option, gif_image_id: Option, message_type: Option, parent_id: Option, sticker_id: Option, sticker_pack_id: Option, text: Option<&str>, video_file_name: Option<&str>) -> Result> { +pub async fn send_chat_message(configuration: &configuration::Configuration, id: i64, attachment_file_name: Option<&str>, call_type: Option, font_size: Option, gif_image_id: Option, message_type: Option, parent_id: Option, sticker_id: Option, sticker_pack_id: Option, text: Option<&str>, video_file_name: Option<&str>) -> Result> { let local_var_configuration = configuration; let local_var_client = &local_var_configuration.client; diff --git a/packages/rust/src/gen/apis/posts_api.rs b/packages/rust/src/gen/apis/posts_api.rs index 9655d25..715b0a0 100644 --- a/packages/rust/src/gen/apis/posts_api.rs +++ b/packages/rust/src/gen/apis/posts_api.rs @@ -260,7 +260,7 @@ pub enum ViewPostVideoError { } -pub async fn create_conference_call_post(configuration: &configuration::Configuration, api_key: Option<&str>, attachment_2_filename: Option<&str>, attachment_3_filename: Option<&str>, attachment_4_filename: Option<&str>, attachment_5_filename: Option<&str>, attachment_6_filename: Option<&str>, attachment_7_filename: Option<&str>, attachment_8_filename: Option<&str>, attachment_9_filename: Option<&str>, attachment_filename: Option<&str>, call_type: Option<&str>, category_id: Option, color: Option, font_size: Option, game_title: Option<&str>, group_id: Option, joinable_by: Option<&str>, language: Option<&str>, message_tags: Option, signed_info: Option<&str>, text: Option<&str>, timestamp: Option, uuid: Option<&str>) -> Result> { +pub async fn create_conference_call_post(configuration: &configuration::Configuration, api_key: Option<&str>, attachment_2_filename: Option<&str>, attachment_3_filename: Option<&str>, attachment_4_filename: Option<&str>, attachment_5_filename: Option<&str>, attachment_6_filename: Option<&str>, attachment_7_filename: Option<&str>, attachment_8_filename: Option<&str>, attachment_9_filename: Option<&str>, attachment_filename: Option<&str>, call_type: Option, category_id: Option, color: Option, font_size: Option, game_title: Option<&str>, group_id: Option, joinable_by: Option, language: Option<&str>, message_tags: Option, signed_info: Option<&str>, text: Option<&str>, timestamp: Option, uuid: Option<&str>) -> Result> { let local_var_configuration = configuration; let local_var_client = &local_var_configuration.client; @@ -607,7 +607,7 @@ pub async fn get_active_call_post(configuration: &configuration::Configuration, } } -pub async fn get_call_followers_timeline(configuration: &configuration::Configuration, from_timestamp: Option, number: Option, category_id: Option, call_type: Option<&str>, include_circle_call: Option, exclude_recent_gomimushi: Option) -> Result> { +pub async fn get_call_followers_timeline(configuration: &configuration::Configuration, from_timestamp: Option, number: Option, category_id: Option, call_type: Option, include_circle_call: Option, exclude_recent_gomimushi: Option) -> Result> { let local_var_configuration = configuration; let local_var_client = &local_var_configuration.client; @@ -652,7 +652,7 @@ pub async fn get_call_followers_timeline(configuration: &configuration::Configur } } -pub async fn get_call_timeline(configuration: &configuration::Configuration, group_id: Option, from_timestamp: Option, number: Option, category_id: Option, call_type: Option<&str>, include_circle_call: Option, cross_generation: Option, exclude_recent_gomimushi: Option) -> Result> { +pub async fn get_call_timeline(configuration: &configuration::Configuration, group_id: Option, from_timestamp: Option, number: Option, category_id: Option, call_type: Option, include_circle_call: Option, cross_generation: Option, exclude_recent_gomimushi: Option) -> Result> { let local_var_configuration = configuration; let local_var_client = &local_var_configuration.client; diff --git a/packages/rust/src/gen/mod.rs b/packages/rust/src/gen/mod.rs index 3402ca5..7bac19b 100644 --- a/packages/rust/src/gen/mod.rs +++ b/packages/rust/src/gen/mod.rs @@ -1,17 +1,7 @@ // Code generated from the Yay! API schema; DO NOT EDIT. -#[allow( - clippy::into_iter_on_ref, - clippy::derivable_impls, - clippy::empty_docs, - clippy::to_string_in_format_args -)] +#[allow(clippy::into_iter_on_ref, clippy::derivable_impls, clippy::empty_docs, clippy::to_string_in_format_args)] pub mod apis; -#[allow( - clippy::into_iter_on_ref, - clippy::derivable_impls, - clippy::empty_docs, - clippy::to_string_in_format_args -)] +#[allow(clippy::into_iter_on_ref, clippy::derivable_impls, clippy::empty_docs, clippy::to_string_in_format_args)] pub mod models; pub mod cursor; diff --git a/packages/rust/src/gen/models/call_type.rs b/packages/rust/src/gen/models/call_type.rs new file mode 100644 index 0000000..5612dae --- /dev/null +++ b/packages/rust/src/gen/models/call_type.rs @@ -0,0 +1,33 @@ +// Code generated from the Yay! API schema; DO NOT EDIT. + +use crate::gen::models; +use serde::{Deserialize, Serialize}; + +/// +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum CallType { + #[serde(rename = "vdo")] + Video, + #[serde(rename = "voice")] + Voice, + + #[serde(other)] + Unknown, +} + +impl std::fmt::Display for CallType { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Video => write!(f, "vdo"), + Self::Voice => write!(f, "voice"), + Self::Unknown => write!(f, "unknown"), + } + } +} + +impl Default for CallType { + fn default() -> CallType { + Self::Unknown + } +} + diff --git a/packages/rust/src/gen/models/conference_call.rs b/packages/rust/src/gen/models/conference_call.rs index 3f833cc..0111f7a 100644 --- a/packages/rust/src/gen/models/conference_call.rs +++ b/packages/rust/src/gen/models/conference_call.rs @@ -14,7 +14,7 @@ pub struct ConferenceCall { #[serde(rename = "bump_params", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] pub bump_params: Option>, #[serde(rename = "call_type", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] - pub call_type: Option>, + pub call_type: Option>, #[serde(rename = "conference_call_user_uuid", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] pub conference_call_user_uuid: Option>, #[serde(rename = "conference_call_users", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] @@ -34,7 +34,7 @@ pub struct ConferenceCall { #[serde(rename = "is_active", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] pub is_active: Option>, #[serde(rename = "joinable_by", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] - pub joinable_by: Option>, + pub joinable_by: Option>, #[serde(rename = "max_participants", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] pub max_participants: Option>, #[serde(rename = "post_id", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] diff --git a/packages/rust/src/gen/models/joinable_by.rs b/packages/rust/src/gen/models/joinable_by.rs new file mode 100644 index 0000000..0850844 --- /dev/null +++ b/packages/rust/src/gen/models/joinable_by.rs @@ -0,0 +1,45 @@ +// Code generated from the Yay! API schema; DO NOT EDIT. + +use crate::gen::models; +use serde::{Deserialize, Serialize}; + +/// +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum JoinableBy { + #[serde(rename = "anyone")] + Anyone, + #[serde(rename = "users")] + Users, + #[serde(rename = "followers_only")] + FollowersOnly, + #[serde(rename = "mutual_follows_only")] + MutualFollowsOnly, + #[serde(rename = "invite_only")] + InviteOnly, + #[serde(rename = "noone")] + NoOne, + + #[serde(other)] + Unknown, +} + +impl std::fmt::Display for JoinableBy { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Anyone => write!(f, "anyone"), + Self::Users => write!(f, "users"), + Self::FollowersOnly => write!(f, "followers_only"), + Self::MutualFollowsOnly => write!(f, "mutual_follows_only"), + Self::InviteOnly => write!(f, "invite_only"), + Self::NoOne => write!(f, "noone"), + Self::Unknown => write!(f, "unknown"), + } + } +} + +impl Default for JoinableBy { + fn default() -> JoinableBy { + Self::Unknown + } +} + diff --git a/packages/rust/src/gen/models/mod.rs b/packages/rust/src/gen/models/mod.rs index e26a1fe..c2b4210 100644 --- a/packages/rust/src/gen/models/mod.rs +++ b/packages/rust/src/gen/models/mod.rs @@ -76,6 +76,8 @@ pub mod call_message; pub use self::call_message::CallMessage; pub mod call_status_response; pub use self::call_status_response::CallStatusResponse; +pub mod call_type; +pub use self::call_type::CallType; pub mod callback; pub use self::callback::Callback; pub mod campaign_dto; @@ -390,6 +392,8 @@ pub mod item; pub use self::item::Item; pub mod j0_a; pub use self::j0_a::J0A; +pub mod joinable_by; +pub use self::joinable_by::JoinableBy; pub mod js_event; pub use self::js_event::JsEvent; pub mod last_status_screen_user_rank; diff --git a/packages/rust/src/gen/models/realm_conference_call.rs b/packages/rust/src/gen/models/realm_conference_call.rs index 9d81d2f..6d8f330 100644 --- a/packages/rust/src/gen/models/realm_conference_call.rs +++ b/packages/rust/src/gen/models/realm_conference_call.rs @@ -16,7 +16,7 @@ pub struct RealmConferenceCall { #[serde(rename = "bump_params", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] pub bump_params: Option>, #[serde(rename = "call_type", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] - pub call_type: Option>, + pub call_type: Option>, #[serde(rename = "conference_call_user_roles", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] pub conference_call_user_roles: Option>>, #[serde(rename = "conference_call_users", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] @@ -34,7 +34,7 @@ pub struct RealmConferenceCall { #[serde(rename = "id", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] pub id: Option>, #[serde(rename = "joinable_by", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] - pub joinable_by: Option>, + pub joinable_by: Option>, #[serde(rename = "max_participants", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] pub max_participants: Option>, #[serde(rename = "post_id", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] diff --git a/packages/rust/src/transport.rs b/packages/rust/src/transport.rs index 12db9dd..b0fca3a 100644 --- a/packages/rust/src/transport.rs +++ b/packages/rust/src/transport.rs @@ -423,6 +423,7 @@ impl Default for Transport { fn default() -> Self { let config = Arc::new(crate::client::ClientConfig { api_key: crate::config::DEFAULT_API_KEY.into(), + web_api_key: crate::config::DEFAULT_WEB_API_KEY.into(), api_version_key: crate::config::DEFAULT_API_VERSION_KEY.into(), api_version_name: crate::config::DEFAULT_API_VERSION_NAME.into(), app_version: crate::config::DEFAULT_APP_VERSION.into(), diff --git a/packages/typescript/package.json b/packages/typescript/package.json index 197da0f..d067991 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -1,6 +1,6 @@ { "name": "yaylib", - "version": "2.1.0", + "version": "2.2.0", "description": "Yay! API client SDK", "author": "ekkx", "license": "MIT", diff --git a/packages/typescript/src/auth.ts b/packages/typescript/src/auth.ts index f66af3b..aa21501 100644 --- a/packages/typescript/src/auth.ts +++ b/packages/typescript/src/auth.ts @@ -7,7 +7,8 @@ import type { LoginUserResponse } from "./gen/models/LoginUserResponse.js"; import type { Client } from "./client.js"; -import { APIError, asAPIError } from "./errors.js"; +import { APIError, asAPIError, codeOf } from "./errors.js"; +import { ErrCodeUnauthorized } from "./error_codes.js"; import { type Session, SessionSaveFailedError } from "./session.js"; // LoginWithEmailParams is the options object — the same call shape as @@ -90,7 +91,34 @@ async function executeLogin( try { resp = await client.usersAPI.loginWithEmail({ loginEmailUserRequest: body }); } catch (err) { - throw await asAPIError(err); + const apiErr = await asAPIError(err); + // Fallback (PORTING.md §6.2): a plain "unauthorized" retries against + // the browser login endpoint with webApiKey. Skipped for other 401s — + // notably Required2FA (the caller must supply a code, not switch + // keys) — when the caller pinned a specific apiKey, or when webApiKey + // is empty (the off switch). + const callerPinned = + params.apiKey !== undefined && params.apiKey !== client.apiKey; + if ( + client.webApiKey && + !callerPinned && + codeOf(apiErr) === ErrCodeUnauthorized + ) { + const fallback = await client.loginWithEmailWeb({ + email: params.email, + password: params.password, + uuid: body.uuid, + twoFACode: params.twoFACode, + }); + if (!fallback) throw apiErr; // fallback itself rejected → surface primary + client.logger.debug("web login fallback ok", { + event: "login_fallback", + outcome: "ok", + }); + resp = fallback; + } else { + throw apiErr; + } } // Activate tokens / identity on the client. diff --git a/packages/typescript/src/client.ts b/packages/typescript/src/client.ts index e5ca6d7..e1e35a2 100644 --- a/packages/typescript/src/client.ts +++ b/packages/typescript/src/client.ts @@ -38,6 +38,10 @@ import { import { Configuration, ResponseError, type ApiResponse } from "./gen/runtime.js"; import { APIError, asAPIError } from "./errors.js"; +import { + type LoginUserResponse, + LoginUserResponseFromJSON, +} from "./gen/models/LoginUserResponse.js"; import { type Upload, type UploadCategory, @@ -72,6 +76,7 @@ import type { SignaturePayload } from "./gen/models/index.js"; import { DEFAULT_ACCEPT_LANGUAGE, DEFAULT_API_KEY, + DEFAULT_WEB_API_KEY, DEFAULT_API_VERSION_KEY, DEFAULT_API_VERSION_NAME, DEFAULT_APP_VERSION, @@ -110,6 +115,9 @@ export interface ClientOptions { cassandraBaseURL?: string; eventStreamURL?: string; apiKey?: string; + // Key for the browser-login fallback (see DEFAULT_WEB_API_KEY). Set to + // "" to disable the fallback: a primary 401 then surfaces unchanged. + webApiKey?: string; apiVersionKey?: string; apiVersionName?: string; appVersion?: string; @@ -153,6 +161,7 @@ export class Client { readonly cassandraBaseURL: string; readonly eventStreamURL: string; readonly apiKey: string; + readonly webApiKey: string; readonly apiVersionKey: string; readonly apiVersionName: string; readonly appVersion: string; @@ -213,6 +222,8 @@ export class Client { this.cassandraBaseURL = opts.cassandraBaseURL ?? DEFAULT_CASSANDRA_BASE_URL; this.eventStreamURL = opts.eventStreamURL ?? DEFAULT_EVENT_STREAM_URL; this.apiKey = opts.apiKey ?? DEFAULT_API_KEY; + // `??` preserves an explicit "" (the fallback off switch). + this.webApiKey = opts.webApiKey ?? DEFAULT_WEB_API_KEY; this.apiVersionKey = opts.apiVersionKey ?? DEFAULT_API_VERSION_KEY; this.apiVersionName = opts.apiVersionName ?? DEFAULT_API_VERSION_NAME; this.appVersion = opts.appVersion ?? DEFAULT_APP_VERSION; @@ -770,6 +781,80 @@ export class Client { return true; } + + /** + * Fallback login against the browser endpoint (POST + * /v2/users/login_with_email) using webApiKey. Some accounts are + * accepted only there. + * + * Issued through the bare fetch (no middleware / Yay! headers / Bearer + * / 401-refresh): a login must authenticate purely by its body + * credentials, and the browser endpoint expects the browser client's + * minimal header set (PORTING.md §6.1/§6.2). Only the body's api_key + * and the endpoint version differ from the app login. + * + * Resolves to the decoded response on success, or null when the + * fallback is itself rejected (non-2xx) so the caller surfaces the + * primary error. Throws an APIError carrying the raw body when a 2xx + * response fails to decode (§15), rather than masking it behind the + * primary 401. + * + * @internal — used by the loginWithEmail wrapper (auth.ts). + */ + async loginWithEmailWeb(params: { + email: string; + password: string; + uuid: string; + twoFACode?: string; + }): Promise { + const payload: Record = { + email: params.email, + password: params.password, + api_key: this.webApiKey, + uuid: params.uuid, + }; + if (params.twoFACode) payload.two_fa_code = params.twoFACode; + + // Present as the browser client. The web version string is not + // otherwise known to the SDK, so appVersion stands in for it. + const webUA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + const fetchImpl = this._fetchApi ?? globalThis.fetch; + + let res: Response; + try { + res = await fetchImpl(`${this.baseURL}/v2/users/login_with_email`, { + method: "POST", + headers: { + "Content-Type": "application/json;charset=UTF-8", + "User-Agent": webUA, + Agent: `YayWeb ${this.appVersion}`, + "X-Device-Info": `Yay ${this.appVersion} Web (${webUA})`, + }, + body: JSON.stringify(payload), + }); + } catch { + return null; + } + + const raw = await res.text(); + if (!res.ok) return null; + try { + return LoginUserResponseFromJSON(JSON.parse(raw)); + } catch (err) { + // A 2xx we couldn't decode is a genuine login result: surface it + // with the raw body, not the primary 401. + throw new APIError({ + message: `yaylib: web login decode failed: ${ + err instanceof Error ? err.message : String(err) + }`, + response: res, + body: new TextEncoder().encode(raw), + cause: err, + }); + } + } } // Declaration merging: the flat op signatures (exact per-service op diff --git a/packages/typescript/src/config.ts b/packages/typescript/src/config.ts index 589790b..21d39ce 100644 --- a/packages/typescript/src/config.ts +++ b/packages/typescript/src/config.ts @@ -6,6 +6,13 @@ export const DEFAULT_API_KEY = "ccd59ee269c01511ba763467045c115779fcae3050238a252f1bd1a4b65cfec6"; +// API key used by the browser-login fallback. When the primary email +// login is rejected with a plain "unauthorized", the client retries +// against POST /v2/users/login_with_email, which validates against this +// key instead of DEFAULT_API_KEY. Set webApiKey: "" to disable. +export const DEFAULT_WEB_API_KEY = + "e9f1ae4c4470f29a92c0168dc42b13637cc332692103f23e626bc2b016f66603"; + // HS256 key used by signed_version and X-Jwt. export const DEFAULT_API_VERSION_KEY = "34c8c1cdf29b46a492e8ec58e6db37ec"; diff --git a/packages/typescript/src/gen/apis/CallsApi.ts b/packages/typescript/src/gen/apis/CallsApi.ts index 151d2be..e19948a 100644 --- a/packages/typescript/src/gen/apis/CallsApi.ts +++ b/packages/typescript/src/gen/apis/CallsApi.ts @@ -21,6 +21,7 @@ import type { CallGiftHistoryResponse, CallStatusResponse, ConferenceCallResponse, + JoinableBy, RtmTokenResponse, UserResponse, UsersByTimestampResponse, @@ -36,6 +37,8 @@ import { CallStatusResponseToJSON, ConferenceCallResponseFromJSON, ConferenceCallResponseToJSON, + JoinableByFromJSON, + JoinableByToJSON, RtmTokenResponseFromJSON, RtmTokenResponseToJSON, UserResponseFromJSON, @@ -127,7 +130,7 @@ export interface UpdateCallRequest { callId: number; categoryId?: string | null; gameTitle?: string | null; - joinableBy?: string; + joinableBy?: JoinableBy; } export interface UpdateCallUserRequest { diff --git a/packages/typescript/src/gen/apis/ChatRoomsApi.ts b/packages/typescript/src/gen/apis/ChatRoomsApi.ts index e2b2a90..0d67d92 100644 --- a/packages/typescript/src/gen/apis/ChatRoomsApi.ts +++ b/packages/typescript/src/gen/apis/ChatRoomsApi.ts @@ -16,6 +16,7 @@ import * as runtime from '../runtime.js'; import type { + CallType, ChatRoomResponse, ChatRoomsResponse, CreateChatRoomResponse, @@ -27,6 +28,8 @@ import type { UnreadStatusResponse, } from '../models/index.js'; import { + CallTypeFromJSON, + CallTypeToJSON, ChatRoomResponseFromJSON, ChatRoomResponseToJSON, ChatRoomsResponseFromJSON, @@ -147,7 +150,7 @@ export interface ReportChatRoomRequest { export interface SendChatMessageRequest { id: number; attachmentFileName?: string | null; - callType?: string | null; + callType?: CallType | null; fontSize?: number | null; gifImageId?: number | null; messageType?: MessageType; diff --git a/packages/typescript/src/gen/apis/PostsApi.ts b/packages/typescript/src/gen/apis/PostsApi.ts index 4a9a8b7..d433299 100644 --- a/packages/typescript/src/gen/apis/PostsApi.ts +++ b/packages/typescript/src/gen/apis/PostsApi.ts @@ -16,8 +16,10 @@ import * as runtime from '../runtime.js'; import type { + CallType, CreatePostResponse, GiftTransactionsResponse, + JoinableBy, LikePostsResponse, NoreplyMode, Post, @@ -31,10 +33,14 @@ import type { ValidationPostResponse, } from '../models/index.js'; import { + CallTypeFromJSON, + CallTypeToJSON, CreatePostResponseFromJSON, CreatePostResponseToJSON, GiftTransactionsResponseFromJSON, GiftTransactionsResponseToJSON, + JoinableByFromJSON, + JoinableByToJSON, LikePostsResponseFromJSON, LikePostsResponseToJSON, NoreplyModeFromJSON, @@ -70,13 +76,13 @@ export interface CreateConferenceCallPostRequest { attachment8Filename?: string | null; attachment9Filename?: string | null; attachmentFilename?: string | null; - callType?: string | null; + callType?: CallType | null; categoryId?: number | null; color?: number | null; fontSize?: number | null; gameTitle?: string | null; groupId?: number | null; - joinableBy?: string | null; + joinableBy?: JoinableBy | null; language?: string | null; messageTags?: object | null; signedInfo?: string; @@ -137,7 +143,7 @@ export interface GetCallFollowersTimelineRequest { fromTimestamp?: number | null; number?: number | null; categoryId?: number | null; - callType?: string | null; + callType?: CallType | null; includeCircleCall?: boolean | null; excludeRecentGomimushi?: boolean | null; } @@ -147,7 +153,7 @@ export interface GetCallTimelineRequest { fromTimestamp?: number | null; number?: number | null; categoryId?: number | null; - callType?: string | null; + callType?: CallType | null; includeCircleCall?: boolean | null; crossGeneration?: boolean | null; excludeRecentGomimushi?: boolean | null; diff --git a/packages/typescript/src/gen/models/CallType.ts b/packages/typescript/src/gen/models/CallType.ts new file mode 100644 index 0000000..e3556a5 --- /dev/null +++ b/packages/typescript/src/gen/models/CallType.ts @@ -0,0 +1,53 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Yay! API + * No description provided + * + * Schema version: 4.26.1 + * + * + * NOTE: Code generated; DO NOT EDIT. + * + * Do not edit the class manually. + */ + + +/** + * + * @export + */ +export const CallType = { + Video: 'vdo', + Voice: 'voice' +} as const; +export type CallType = typeof CallType[keyof typeof CallType]; + + +export function instanceOfCallType(value: any): boolean { + for (const key in CallType) { + if (Object.prototype.hasOwnProperty.call(CallType, key)) { + if (CallType[key as keyof typeof CallType] === value) { + return true; + } + } + } + return false; +} + +export function CallTypeFromJSON(json: any): CallType { + return CallTypeFromJSONTyped(json, false); +} + +export function CallTypeFromJSONTyped(json: any, ignoreDiscriminator: boolean): CallType { + return json as CallType; +} + +export function CallTypeToJSON(value?: CallType | null): any { + return value as any; +} + +export function CallTypeToJSONTyped(value: any, ignoreDiscriminator: boolean): CallType { + return value as CallType; +} + diff --git a/packages/typescript/src/gen/models/ConferenceCall.ts b/packages/typescript/src/gen/models/ConferenceCall.ts index e7fdd63..da501ff 100644 --- a/packages/typescript/src/gen/models/ConferenceCall.ts +++ b/packages/typescript/src/gen/models/ConferenceCall.ts @@ -34,6 +34,20 @@ import { UserToJSON, UserToJSONTyped, } from './User.js'; +import type { JoinableBy } from './JoinableBy.js'; +import { + JoinableByFromJSON, + JoinableByFromJSONTyped, + JoinableByToJSON, + JoinableByToJSONTyped, +} from './JoinableBy.js'; +import type { CallType } from './CallType.js'; +import { + CallTypeFromJSON, + CallTypeFromJSONTyped, + CallTypeToJSON, + CallTypeToJSONTyped, +} from './CallType.js'; import type { Genre } from './Genre.js'; import { GenreFromJSON, @@ -81,10 +95,10 @@ export interface ConferenceCall { bumpParams?: BumpParams | null; /** * - * @type {string} + * @type {CallType} * @memberof ConferenceCall */ - callType?: string | null; + callType?: CallType | null; /** * * @type {string} @@ -141,10 +155,10 @@ export interface ConferenceCall { isActive?: boolean | null; /** * - * @type {string} + * @type {JoinableBy} * @memberof ConferenceCall */ - joinableBy?: string | null; + joinableBy?: JoinableBy | null; /** * * @type {number} @@ -159,6 +173,8 @@ export interface ConferenceCall { postId?: number | null; } + + /** * Check if a given object implements the ConferenceCall interface. */ @@ -180,7 +196,7 @@ export function ConferenceCallFromJSONTyped(json: any, ignoreDiscriminator: bool 'agoraToken': json['agora_token'] == null ? undefined : json['agora_token'], 'anonymousCallUsersCount': json['anonymous_call_users_count'] == null ? undefined : json['anonymous_call_users_count'], 'bumpParams': json['bump_params'] == null ? undefined : BumpParamsFromJSON(json['bump_params']), - 'callType': json['call_type'] == null ? undefined : json['call_type'], + 'callType': json['call_type'] == null ? undefined : CallTypeFromJSON(json['call_type']), 'conferenceCallUserUuid': json['conference_call_user_uuid'] == null ? undefined : json['conference_call_user_uuid'], 'conferenceCallUsers': json['conference_call_users'] == null ? undefined : ((json['conference_call_users'] as Array).map(UserFromJSON)), 'conferenceCallUsersRole': json['conference_call_users_role'] == null ? undefined : ((json['conference_call_users_role'] as Array).map(ModelConferenceCallUserRoleFromJSON)), @@ -190,7 +206,7 @@ export function ConferenceCallFromJSONTyped(json: any, ignoreDiscriminator: bool 'groupId': json['group_id'] == null ? undefined : json['group_id'], 'id': json['id'] == null ? undefined : json['id'], 'isActive': json['is_active'] == null ? undefined : json['is_active'], - 'joinableBy': json['joinable_by'] == null ? undefined : json['joinable_by'], + 'joinableBy': json['joinable_by'] == null ? undefined : JoinableByFromJSON(json['joinable_by']), 'maxParticipants': json['max_participants'] == null ? undefined : json['max_participants'], 'postId': json['post_id'] == null ? undefined : json['post_id'], }; @@ -211,7 +227,7 @@ export function ConferenceCallToJSONTyped(value?: ConferenceCall | null, ignoreD 'agora_token': value['agoraToken'], 'anonymous_call_users_count': value['anonymousCallUsersCount'], 'bump_params': BumpParamsToJSON(value['bumpParams']), - 'call_type': value['callType'], + 'call_type': CallTypeToJSON(value['callType']), 'conference_call_user_uuid': value['conferenceCallUserUuid'], 'conference_call_users': value['conferenceCallUsers'] == null ? undefined : ((value['conferenceCallUsers'] as Array).map(UserToJSON)), 'conference_call_users_role': value['conferenceCallUsersRole'] == null ? undefined : ((value['conferenceCallUsersRole'] as Array).map(ModelConferenceCallUserRoleToJSON)), @@ -221,7 +237,7 @@ export function ConferenceCallToJSONTyped(value?: ConferenceCall | null, ignoreD 'group_id': value['groupId'], 'id': value['id'], 'is_active': value['isActive'], - 'joinable_by': value['joinableBy'], + 'joinable_by': JoinableByToJSON(value['joinableBy']), 'max_participants': value['maxParticipants'], 'post_id': value['postId'], }; diff --git a/packages/typescript/src/gen/models/JoinableBy.ts b/packages/typescript/src/gen/models/JoinableBy.ts new file mode 100644 index 0000000..cf72211 --- /dev/null +++ b/packages/typescript/src/gen/models/JoinableBy.ts @@ -0,0 +1,57 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Yay! API + * No description provided + * + * Schema version: 4.26.1 + * + * + * NOTE: Code generated; DO NOT EDIT. + * + * Do not edit the class manually. + */ + + +/** + * + * @export + */ +export const JoinableBy = { + Anyone: 'anyone', + Users: 'users', + FollowersOnly: 'followers_only', + MutualFollowsOnly: 'mutual_follows_only', + InviteOnly: 'invite_only', + NoOne: 'noone' +} as const; +export type JoinableBy = typeof JoinableBy[keyof typeof JoinableBy]; + + +export function instanceOfJoinableBy(value: any): boolean { + for (const key in JoinableBy) { + if (Object.prototype.hasOwnProperty.call(JoinableBy, key)) { + if (JoinableBy[key as keyof typeof JoinableBy] === value) { + return true; + } + } + } + return false; +} + +export function JoinableByFromJSON(json: any): JoinableBy { + return JoinableByFromJSONTyped(json, false); +} + +export function JoinableByFromJSONTyped(json: any, ignoreDiscriminator: boolean): JoinableBy { + return json as JoinableBy; +} + +export function JoinableByToJSON(value?: JoinableBy | null): any { + return value as any; +} + +export function JoinableByToJSONTyped(value: any, ignoreDiscriminator: boolean): JoinableBy { + return value as JoinableBy; +} + diff --git a/packages/typescript/src/gen/models/RealmConferenceCall.ts b/packages/typescript/src/gen/models/RealmConferenceCall.ts index b0dc998..bfcfe08 100644 --- a/packages/typescript/src/gen/models/RealmConferenceCall.ts +++ b/packages/typescript/src/gen/models/RealmConferenceCall.ts @@ -20,6 +20,20 @@ import { RealmGenreToJSON, RealmGenreToJSONTyped, } from './RealmGenre.js'; +import type { JoinableBy } from './JoinableBy.js'; +import { + JoinableByFromJSON, + JoinableByFromJSONTyped, + JoinableByToJSON, + JoinableByToJSONTyped, +} from './JoinableBy.js'; +import type { CallType } from './CallType.js'; +import { + CallTypeFromJSON, + CallTypeFromJSONTyped, + CallTypeToJSON, + CallTypeToJSONTyped, +} from './CallType.js'; import type { ConferenceCallBumpParams } from './ConferenceCallBumpParams.js'; import { ConferenceCallBumpParamsFromJSON, @@ -87,10 +101,10 @@ export interface RealmConferenceCall { bumpParams?: ConferenceCallBumpParams | null; /** * - * @type {string} + * @type {CallType} * @memberof RealmConferenceCall */ - callType?: string | null; + callType?: CallType | null; /** * * @type {Array} @@ -141,10 +155,10 @@ export interface RealmConferenceCall { id?: number | null; /** * - * @type {string} + * @type {JoinableBy} * @memberof RealmConferenceCall */ - joinableBy?: string | null; + joinableBy?: JoinableBy | null; /** * * @type {number} @@ -165,6 +179,8 @@ export interface RealmConferenceCall { server?: string | null; } + + /** * Check if a given object implements the RealmConferenceCall interface. */ @@ -187,7 +203,7 @@ export function RealmConferenceCallFromJSONTyped(json: any, ignoreDiscriminator: 'agoraToken': json['agora_token'] == null ? undefined : json['agora_token'], 'anonymousCallUsersCount': json['anonymous_call_users_count'] == null ? undefined : json['anonymous_call_users_count'], 'bumpParams': json['bump_params'] == null ? undefined : ConferenceCallBumpParamsFromJSON(json['bump_params']), - 'callType': json['call_type'] == null ? undefined : json['call_type'], + 'callType': json['call_type'] == null ? undefined : CallTypeFromJSON(json['call_type']), 'conferenceCallUserRoles': json['conference_call_user_roles'] == null ? undefined : ((json['conference_call_user_roles'] as Array).map(ConferenceCallUserRoleFromJSON)), 'conferenceCallUsers': json['conference_call_users'] == null ? undefined : ((json['conference_call_users'] as Array).map(RealmUserFromJSON)), 'conferenceCallUsersCount': json['conference_call_users_count'] == null ? undefined : json['conference_call_users_count'], @@ -196,7 +212,7 @@ export function RealmConferenceCallFromJSONTyped(json: any, ignoreDiscriminator: 'genre': json['genre'] == null ? undefined : RealmGenreFromJSON(json['genre']), 'groupId': json['group_id'] == null ? undefined : json['group_id'], 'id': json['id'] == null ? undefined : json['id'], - 'joinableBy': json['joinable_by'] == null ? undefined : json['joinable_by'], + 'joinableBy': json['joinable_by'] == null ? undefined : JoinableByFromJSON(json['joinable_by']), 'maxParticipants': json['max_participants'] == null ? undefined : json['max_participants'], 'postId': json['post_id'] == null ? undefined : json['post_id'], 'server': json['server'] == null ? undefined : json['server'], @@ -219,7 +235,7 @@ export function RealmConferenceCallToJSONTyped(value?: RealmConferenceCall | nul 'agora_token': value['agoraToken'], 'anonymous_call_users_count': value['anonymousCallUsersCount'], 'bump_params': ConferenceCallBumpParamsToJSON(value['bumpParams']), - 'call_type': value['callType'], + 'call_type': CallTypeToJSON(value['callType']), 'conference_call_user_roles': value['conferenceCallUserRoles'] == null ? undefined : ((value['conferenceCallUserRoles'] as Array).map(ConferenceCallUserRoleToJSON)), 'conference_call_users': value['conferenceCallUsers'] == null ? undefined : ((value['conferenceCallUsers'] as Array).map(RealmUserToJSON)), 'conference_call_users_count': value['conferenceCallUsersCount'], @@ -228,7 +244,7 @@ export function RealmConferenceCallToJSONTyped(value?: RealmConferenceCall | nul 'genre': RealmGenreToJSON(value['genre']), 'group_id': value['groupId'], 'id': value['id'], - 'joinable_by': value['joinableBy'], + 'joinable_by': JoinableByToJSON(value['joinableBy']), 'max_participants': value['maxParticipants'], 'post_id': value['postId'], 'server': value['server'], diff --git a/packages/typescript/src/gen/models/index.ts b/packages/typescript/src/gen/models/index.ts index 12c4a8d..58b8e42 100644 --- a/packages/typescript/src/gen/models/index.ts +++ b/packages/typescript/src/gen/models/index.ts @@ -39,6 +39,7 @@ export * from './CallFinishedData.js'; export * from './CallGiftHistoryResponse.js'; export * from './CallMessage.js'; export * from './CallStatusResponse.js'; +export * from './CallType.js'; export * from './Callback.js'; export * from './CampaignDTO.js'; export * from './CampaignInvitedUsersResponse.js'; @@ -196,6 +197,7 @@ export * from './InvitedUserDetailsDTO.js'; export * from './Item.js'; export * from './J0A.js'; export * from './JSEvent.js'; +export * from './JoinableBy.js'; export * from './LastStatusScreenUserRank.js'; export * from './LevelUp.js'; export * from './LevelUpDetails.js'; diff --git a/packages/typescript/tests/auth.test.ts b/packages/typescript/tests/auth.test.ts index 07e097e..7e90aed 100644 --- a/packages/typescript/tests/auth.test.ts +++ b/packages/typescript/tests/auth.test.ts @@ -11,6 +11,15 @@ import { join } from "node:path"; import { Client } from "../src/client"; import { newSessionStore, FileSessionStore } from "../src/session_file"; import { MemorySessionStore, SessionSaveFailedError } from "../src/session"; +import { codeOf } from "../src/errors"; +import { ErrCodeUnauthorized, ErrCodeRequired2FA } from "../src/error_codes"; + +const DEFAULT_WEB_KEY = + "e9f1ae4c4470f29a92c0168dc42b13637cc332692103f23e626bc2b016f66603"; +const reject401 = (code = 401) => ({ + status: 401, + body: JSON.stringify({ result: "error", message: "unauthorized", error_code: code }), +}); type Handler = (path: string, method: string, body: string) => { status: number; @@ -271,12 +280,154 @@ async function scenarioFileStoreRoundTrip(): Promise { }); } +// --- Browser-login fallback (PORTING.md §6.2) -------------------------------- + +async function scenarioFallbackToWeb(): Promise { + let seenKey: unknown; + await withServer( + (path, _method, body) => { + if (path === "/v3/users/login_with_email") return reject401(); + if (path === "/v2/users/login_with_email") { + seenKey = (JSON.parse(body || "{}") as Record).api_key; + return { + status: 200, + body: JSON.stringify({ + access_token: "WEBACC", + refresh_token: "WEBREF", + user_id: 7777, + }), + }; + } + return { status: 404, body: "" }; + }, + async (baseURL) => { + const client = new Client({ baseURL }); + const resp = await client.loginWithEmail({ email: "u@example.com", password: "pw" }); + assertEq("fallback: userId from web", resp.userId, 7777); + assertEq("fallback: access token", client.tokens().access, "WEBACC"); + assertEq("fallback: web api_key used", seenKey, DEFAULT_WEB_KEY); + }, + ); +} + +async function scenarioNoFallbackOn2FA(): Promise { + let v2Hits = 0; + await withServer( + (path) => { + if (path === "/v3/users/login_with_email") return reject401(ErrCodeRequired2FA); + if (path === "/v2/users/login_with_email") { + v2Hits++; + return { status: 200, body: "{}" }; + } + return { status: 404, body: "" }; + }, + async (baseURL) => { + const client = new Client({ baseURL }); + let caught: unknown; + try { + await client.loginWithEmail({ email: "u@example.com", password: "pw" }); + } catch (e) { + caught = e; + } + assertEq("2FA: code is Required2FA", codeOf(caught), ErrCodeRequired2FA); + assertEq("2FA: no v2 fallback attempted", v2Hits, 0); + assertEq("2FA: tokens stay empty", client.tokens().access, ""); + }, + ); +} + +async function scenarioNoFallbackWhenApiKeyPinned(): Promise { + let v2Hits = 0; + await withServer( + (path) => { + if (path === "/v3/users/login_with_email") return reject401(); + if (path === "/v2/users/login_with_email") { + v2Hits++; + return { status: 200, body: "{}" }; + } + return { status: 404, body: "" }; + }, + async (baseURL) => { + const client = new Client({ baseURL }); + let caught: unknown; + try { + await client.loginWithEmail({ + email: "u@example.com", + password: "pw", + apiKey: "caller-pinned-key", + }); + } catch (e) { + caught = e; + } + assertEq("pinned: code is Unauthorized", codeOf(caught), ErrCodeUnauthorized); + assertEq("pinned: no v2 fallback attempted", v2Hits, 0); + }, + ); +} + +async function scenarioFallbackDisabled(): Promise { + let v2Hits = 0; + await withServer( + (path) => { + if (path === "/v3/users/login_with_email") return reject401(); + if (path === "/v2/users/login_with_email") { + v2Hits++; + return { status: 200, body: "{}" }; + } + return { status: 404, body: "" }; + }, + async (baseURL) => { + const client = new Client({ baseURL, webApiKey: "" }); + let caught: unknown; + try { + await client.loginWithEmail({ email: "u@example.com", password: "pw" }); + } catch (e) { + caught = e; + } + assertEq("disabled: code is Unauthorized", codeOf(caught), ErrCodeUnauthorized); + assertEq("disabled: no v2 fallback attempted", v2Hits, 0); + }, + ); +} + +async function scenarioWebDecodeFailureSurfaced(): Promise { + await withServer( + (path) => { + if (path === "/v3/users/login_with_email") return reject401(); + if (path === "/v2/users/login_with_email") { + return { status: 200, body: "{ this is not valid json" }; + } + return { status: 404, body: "" }; + }, + async (baseURL) => { + const client = new Client({ baseURL }); + let caught: unknown; + try { + await client.loginWithEmail({ email: "u@example.com", password: "pw" }); + } catch (e) { + caught = e; + } + assertEq( + "decode-fail: not masked as Unauthorized", + codeOf(caught) !== ErrCodeUnauthorized, + true, + ); + assertEq("decode-fail: tokens stay empty", client.tokens().access, ""); + }, + ); +} + (async () => { await scenarioFreshLogin(); await scenarioCacheHit(); await scenarioNoCacheBypass(); await scenarioSavePersistFailure(); await scenarioFileStoreRoundTrip(); + await scenarioFallbackToWeb(); + await scenarioNoFallbackOn2FA(); + await scenarioNoFallbackWhenApiKeyPinned(); + await scenarioFallbackDisabled(); + await scenarioWebDecodeFailureSurfaced(); process.exit(failed === 0 ? 0 : 1); })().catch((e) => { // eslint-disable-next-line no-console