diff --git a/pkg/connector/chatsync.go b/pkg/connector/chatsync.go index 174bfb5..ab27de6 100644 --- a/pkg/connector/chatsync.go +++ b/pkg/connector/chatsync.go @@ -60,10 +60,15 @@ func (r *RedditClient) runSync(ctx context.Context, done chan struct{}) { } if refreshNeedsLogin(refreshErr) { log.Warn().Err(refreshErr).Msg("Reddit session requires reauthentication") + message := "Reddit session expired. Please sign in again." + if errors.Is(refreshErr, errRefreshAccountMismatch) { + message = "Reddit session belongs to a different account. Sign in again with the original account." + } r.userLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateBadCredentials, Error: "reddit-token-refresh-failed", - Message: refreshErr.Error(), + Message: message, + Info: map[string]any{"go_error": refreshErr.Error()}, UserAction: status.UserActionRelogin, }) return diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 1bbccef..4ff7cca 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -82,7 +82,7 @@ func restoreSession(meta *UserLoginMetadata) (*redditchat.RedditSession, error) if err != nil { return nil, err } - httpClient := &http.Client{Jar: jar} + httpClient := &http.Client{Jar: jar, Timeout: 30 * time.Second} session, err := redditchat.NewRedditSession(httpClient, "", "") if err != nil { return nil, err @@ -142,6 +142,8 @@ func (r *RedditClient) IsThisUser(ctx context.Context, userID networkid.UserID) } func (r *RedditClient) LogoutRemote(ctx context.Context) { + // Stop syncing first so a 401 after logout can't refresh the token again. + r.Disconnect() if !r.IsLoggedIn() { return } diff --git a/pkg/connector/emoji.go b/pkg/connector/emoji.go index ea9f860..21d2944 100644 --- a/pkg/connector/emoji.go +++ b/pkg/connector/emoji.go @@ -126,7 +126,7 @@ func (r *RedditClient) resolveOutgoingReaction(ctx context.Context, value string } else if key, ok := nativeReactionKey(value); ok { return key, nil } - return "", errors.New("reddit supports its native reaction palette; choose an imported Reddit reaction or a matching emoji") + return "", permanentFailure(event.MessageStatusUnsupported, "reddit only supports its own reactions") } var _ bridgev2.StickerImportingNetworkAPI = (*RedditClient)(nil) diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index cb13f17..2490f74 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -3,7 +3,6 @@ package connector import ( "context" "errors" - "fmt" "time" "maunium.net/go/mautrix" @@ -24,7 +23,7 @@ var ( func (r *RedditClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) { if !r.IsLoggedIn() { - return nil, errors.New("not logged in to Reddit") + return nil, bridgev2.ErrNotLoggedIn } txn, err := r.outgoingTransaction(msg.Portal, msg.Event, msg.InputTransactionID) if err != nil { @@ -44,7 +43,7 @@ func (r *RedditClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Ma switch content.MsgType { case event.MsgImage: if err = r.sendMedia(ctx, content); err != nil { - return nil, err + return nil, mediaSendStatus(err) } case event.MsgText, event.MsgNotice, event.MsgEmote: default: @@ -52,7 +51,7 @@ func (r *RedditClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Ma } resp, err := r.remote().SendMessage(ctx, portalIDToRoomID(msg.Portal.ID), content, mautrix.ReqSendEvent{TransactionID: txn}) if err != nil { - return nil, fmt.Errorf("send to reddit: %w", err) + return nil, wrapRedditError(err) } if resp.EventID == "" { return nil, errors.New("reddit returned no message identity") @@ -89,7 +88,7 @@ func (r *RedditClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridg reason = msg.Content.Reason } _, err = r.matrix().RedactEvent(ctx, roomID, target, mautrix.ReqRedact{Reason: reason, TxnID: txn}) - return err + return wrapRedditError(err) } func (r *RedditClient) PreHandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) { @@ -118,13 +117,13 @@ func (r *RedditClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.M return nil, err } if msg.PreHandleResp == nil || msg.PreHandleResp.EmojiID == "" { - return nil, errors.New("reaction has no emoji") + return nil, permanentFailure(event.MessageStatusUnsupported, "reaction has no emoji") } roomID := portalIDToRoomID(msg.Portal.ID) emoji := string(msg.PreHandleResp.EmojiID) resp, err := r.matrix().SendMessageEvent(ctx, roomID, event.EventReaction, &event.ReactionEventContent{RelatesTo: event.RelatesTo{Type: event.RelAnnotation, EventID: target, Key: emoji}}, mautrix.ReqSendEvent{TransactionID: txn}) if err != nil { - return nil, err + return nil, wrapRedditError(err) } if resp.EventID == "" { return nil, errors.New("reddit returned no reaction identity") @@ -146,7 +145,7 @@ func (r *RedditClient) HandleMatrixReactionRemove(ctx context.Context, msg *brid } roomID := portalIDToRoomID(msg.Portal.ID) _, err = r.matrix().RedactEvent(ctx, roomID, meta.RemoteEventID, mautrix.ReqRedact{TxnID: txn}) - return err + return wrapRedditError(err) } func (r *RedditClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev2.MatrixReadReceipt) error { @@ -154,7 +153,7 @@ func (r *RedditClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev if msg.ExactMessage == nil { return nil } - return r.remote().MarkRead(ctx, roomID, messageIDToEventID(msg.ExactMessage.ID)) + return wrapRedditError(r.remote().MarkRead(ctx, roomID, messageIDToEventID(msg.ExactMessage.ID))) } func (r *RedditClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) error { @@ -164,7 +163,7 @@ func (r *RedditClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.Mat return nil } roomID := portalIDToRoomID(msg.Portal.ID) - return r.remote().SetTyping(ctx, roomID, msg.IsTyping, 30*time.Second) + return wrapRedditError(r.remote().SetTyping(ctx, roomID, msg.IsTyping, 30*time.Second)) } func (r *RedditClient) HandleMatrixRoomName(ctx context.Context, msg *bridgev2.MatrixRoomName) (bool, error) { @@ -177,7 +176,7 @@ func (r *RedditClient) HandleMatrixRoomName(ctx context.Context, msg *bridgev2.M } _, err := r.matrix().SendStateEvent(ctx, roomID, event.StateRoomName, "", msg.Content) if err != nil { - return false, err + return false, wrapRedditError(err) } msg.Portal.Name = msg.Content.Name msg.Portal.NameSet = true diff --git a/pkg/connector/login.go b/pkg/connector/login.go index 56beeac..6381825 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -3,12 +3,12 @@ package connector import ( "context" "encoding/json" - "errors" "fmt" "net/http" "net/url" "strings" + "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/status" @@ -134,7 +134,7 @@ func (p *PasswordLogin) SubmitUserInput(ctx context.Context, input map[string]st } return p.buildCaptchaStep(p.session.CaptchaRequest(redditchat.CaptchaStepOTP)), nil default: - return nil, errors.New("reddit login is not waiting for user input") + return nil, bridgev2.RespError(mautrix.MBadState.WithMessage("Reddit login is not waiting for input.").WithStatus(http.StatusBadRequest)) } } @@ -145,7 +145,7 @@ func (p *PasswordLogin) SubmitCookies(ctx context.Context, cookies map[string]st return nil, err } if p.step != StepIDCaptchaPassword && p.step != StepIDCaptchaOTP { - return nil, errors.New("reddit login is not waiting for verification") + return nil, bridgev2.RespError(mautrix.MBadState.WithMessage("Reddit login is not waiting for verification.").WithStatus(http.StatusBadRequest)) } captcha := redditchat.CaptchaResult{ Token: strings.TrimSpace(cookies[FieldRecaptchaToken]), diff --git a/pkg/connector/loginerrors.go b/pkg/connector/loginerrors.go index 3b9a6d9..ac7a3fc 100644 --- a/pkg/connector/loginerrors.go +++ b/pkg/connector/loginerrors.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" + "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2" "github.com/beeper/reddit/pkg/redditchat" @@ -50,11 +51,7 @@ var ( ErrCode: "FI.MAU.REDDIT.LOGIN_REJECTED", StatusCode: http.StatusBadRequest, } - ErrLoginUnknown = bridgev2.RespError{ - ErrCode: "M_UNKNOWN", - Err: "Internal error logging in to Reddit", - StatusCode: http.StatusInternalServerError, - } + ErrLoginUnknown = bridgev2.RespError(mautrix.MUnknown.WithMessage("Internal error logging in to Reddit")) ) // The original error stays in the chain for logs; only the mapped RespError is diff --git a/pkg/connector/media.go b/pkg/connector/media.go index 5407a8e..dc21d5c 100644 --- a/pkg/connector/media.go +++ b/pkg/connector/media.go @@ -53,6 +53,18 @@ func mediaError(ctx context.Context, operation string, err error) error { return safe } +// The message is never sent when its media fails, so the failure is certain. +func mediaSendStatus(err error) error { + if errors.As(err, new(bridgev2.MessageStatus)) { + return err + } + status := bridgev2.WrapErrorInStatus(err).WithIsCertain(true).WithErrorReason(event.MessageStatusNetworkError).WithMessage("failed to upload image") + if isUnbridgeable(err) { + return status.WithErrorReason(event.MessageStatusGenericError).WithStatus(event.MessageStatusFail) + } + return status +} + func copyMediaInfo(content *event.MessageEventContent) { if content.Info == nil { content.Info = &event.FileInfo{} diff --git a/pkg/connector/membership.go b/pkg/connector/membership.go index 324a6d8..763541b 100644 --- a/pkg/connector/membership.go +++ b/pkg/connector/membership.go @@ -43,7 +43,7 @@ func (r *RedditClient) HandleMatrixMembership(ctx context.Context, msg *bridgev2 roomID := portalIDToRoomID(msg.Portal.ID) state, key, err := r.resolveRoomState(ctx, roomID, &mautrix.SyncJoinedRoom{}, &roomSnapshots{}) if err != nil { - return nil, err + return nil, wrapRedditError(err) } if key != msg.Portal.PortalKey || state.Type != "private_group" { return nil, bridgev2.ErrMembershipNotSupported @@ -56,10 +56,10 @@ func (r *RedditClient) HandleMatrixMembership(ctx context.Context, msg *bridgev2 targetID = networkid.UserID(target.ID) } if !strings.HasPrefix(string(targetID), "t2_") || strings.Trim(string(targetID)[3:], "0123456789abcdefghijklmnopqrstuvwxyz") != "" || len(targetID) <= 3 { - return nil, errors.New("membership target is not a Reddit account ID") + return nil, permanentFailure(event.MessageStatusUnsupported, "only reddit accounts can be invited or removed") } if targetID == r.userID { - return nil, errors.New("use accept or leave for your own membership") + return nil, permanentFailure(event.MessageStatusUnsupported, "use accept or leave to change your own membership") } targetMXID := userIDToMatrix(targetID, "") if msg.Type == bridgev2.Invite { @@ -67,5 +67,5 @@ func (r *RedditClient) HandleMatrixMembership(ctx context.Context, msg *bridgev2 } else { _, err = r.matrix().KickUser(ctx, roomID, &mautrix.ReqKickUser{UserID: targetMXID}) } - return nil, err + return nil, wrapRedditError(err) } diff --git a/pkg/connector/redditerrors.go b/pkg/connector/redditerrors.go new file mode 100644 index 0000000..76d53ee --- /dev/null +++ b/pkg/connector/redditerrors.go @@ -0,0 +1,86 @@ +package connector + +import ( + "errors" + "fmt" + "net/http" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" + + "github.com/beeper/reddit/pkg/redditchat" +) + +type redditFailure int + +const ( + redditFailureTransient redditFailure = iota + redditFailureRateLimited + redditFailureUnauthorized + redditFailureForbidden + redditFailureRejected +) + +func classifyRedditError(err error) redditFailure { + redditErr, ok := redditchat.ExtractRedditError(err) + switch { + case ok && (redditErr.StatusCode == http.StatusTooManyRequests || redditErr.ErrCode == mautrix.MLimitExceeded.ErrCode): + return redditFailureRateLimited + case isUnauthorized(err): + return redditFailureUnauthorized + case ok && redditErr.StatusCode == http.StatusForbidden: + return redditFailureForbidden + case isPermanentHTTPError(err): + return redditFailureRejected + default: + return redditFailureTransient + } +} + +// wrapRedditError maps a failed Reddit request to a message status without +// exposing provider error text. +func wrapRedditError(err error) error { + if err == nil { + return nil + } + status := bridgev2.WrapErrorInStatus(err) + if isUnbridgeable(err) { + return status.WithIsCertain(true).WithErrorReason(event.MessageStatusUnsupported).WithStatus(event.MessageStatusFail).WithMessage("reddit chat details are unavailable") + } + switch classifyRedditError(err) { + case redditFailureRateLimited: + return status.WithIsCertain(true).WithErrorReason(event.MessageStatusNetworkError).WithMessage("reddit is rate limiting requests, try again later") + case redditFailureUnauthorized: + return status.WithIsCertain(true).WithErrorReason(event.MessageStatusNetworkError).WithMessage("reddit session expired") + case redditFailureForbidden: + return status.WithIsCertain(true).WithErrorReason(event.MessageStatusNoPermission).WithStatus(event.MessageStatusFail).WithMessage("reddit doesn't allow this") + case redditFailureRejected: + return status.WithIsCertain(true).WithErrorReason(event.MessageStatusGenericError).WithStatus(event.MessageStatusFail).WithMessage("reddit rejected this request") + default: + return status.WithIsCertain(false).WithErrorReason(event.MessageStatusNetworkError).WithMessage("couldn't reach reddit") + } +} + +func permanentFailure(reason event.MessageStatusReason, msg string) error { + return bridgev2.WrapErrorInStatus(errors.New(msg)).WithIsCertain(true).WithErrorReason(reason).WithStatus(event.MessageStatusFail).WithMessage(msg) +} + +// wrapRedditRespError maps a failed Reddit request to a provisioning error. The +// original error stays in the chain for logs; only the RespError is written. +func wrapRedditRespError(err error) error { + var mapped bridgev2.RespError + switch classifyRedditError(err) { + case redditFailureRateLimited: + mapped = bridgev2.RespError(mautrix.MLimitExceeded.WithMessage("Reddit is rate limiting requests. Please try again later.")) + case redditFailureUnauthorized: + mapped = bridgev2.RespError(mautrix.MUnknown.WithMessage("Reddit session expired. Please try again shortly.").WithStatus(http.StatusBadGateway)) + case redditFailureForbidden: + mapped = bridgev2.RespError(mautrix.MForbidden.WithMessage("Reddit doesn't allow this request.")) + case redditFailureRejected: + mapped = bridgev2.RespError(mautrix.MUnknown.WithMessage("Reddit rejected the request.").WithStatus(http.StatusBadRequest)) + default: + mapped = bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to reach Reddit. Please try again.").WithStatus(http.StatusBadGateway)) + } + return fmt.Errorf("%w: %w", mapped, err) +} diff --git a/pkg/connector/requests.go b/pkg/connector/requests.go index 983c552..f324e65 100644 --- a/pkg/connector/requests.go +++ b/pkg/connector/requests.go @@ -62,24 +62,24 @@ func (r *RedditClient) HandleMatrixDeleteChat(ctx context.Context, msg *bridgev2 return errors.New("chat belongs to another login") } if msg.Content.DeleteForEveryone { - return errors.New("reddit chats cannot be deleted for everyone") + return permanentFailure(event.MessageStatusUnsupported, "reddit chats can't be deleted for everyone") } roomID := portalIDToRoomID(msg.Portal.ID) state, key, err := r.resolveRoomState(ctx, roomID, &mautrix.SyncJoinedRoom{}, &roomSnapshots{}) if err != nil { - return err + return wrapRedditError(err) } if key != msg.Portal.PortalKey { return errors.New("chat ownership does not match Reddit room state") } switch state.Type { case "direct": - return r.matrix().SetRoomAccountData(ctx, roomID, "com.reddit.hidden_chat", map[string]bool{"hidden": true}) + return wrapRedditError(r.matrix().SetRoomAccountData(ctx, roomID, "com.reddit.hidden_chat", map[string]bool{"hidden": true})) case "private_group": _, err = r.matrix().LeaveRoom(ctx, roomID) - return err + return wrapRedditError(err) default: - return errors.New("unsupported Reddit chat type for deletion") + return bridgev2.ErrDeleteChatNotSupported } } @@ -131,7 +131,7 @@ func (r *RedditClient) HandleMatrixAcceptMessageRequest(ctx context.Context, msg roomID := portalIDToRoomID(msg.Portal.ID) joined, err := r.matrix().JoinRoomByID(ctx, roomID) if err != nil { - return err + return wrapRedditError(err) } if joined.RoomID != "" && joined.RoomID != roomID { return errors.New("reddit accepted a different conversation") diff --git a/pkg/connector/startchat.go b/pkg/connector/startchat.go index 114d84d..b85d182 100644 --- a/pkg/connector/startchat.go +++ b/pkg/connector/startchat.go @@ -2,11 +2,10 @@ package connector import ( "context" - "errors" - "fmt" "strings" "go.mau.fi/util/ptr" + "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" @@ -27,24 +26,24 @@ func (r *RedditClient) ResolveIdentifier(ctx context.Context, identifier string, identifier = strings.TrimPrefix(identifier, "u/") identifier = strings.TrimPrefix(identifier, "/u/") if identifier == "" { - return nil, errors.New("empty identifier") + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Enter a Reddit username.")) } results, err := r.remote().SearchUsers(ctx, identifier) if err != nil { - return nil, fmt.Errorf("search users: %w", err) + return nil, wrapRedditRespError(err) } var match *userDirectoryHit for _, hit := range results.Results { display := hit.DisplayName if strings.EqualFold(stripUserPrefix(display), identifier) || strings.EqualFold(string(hit.UserID), "@"+identifier+":reddit.com") { if match != nil && match.UserID != hit.UserID { - return nil, fmt.Errorf("multiple Reddit users matched %q", identifier) + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Multiple Reddit users match %q.", identifier)) } match = &userDirectoryHit{UserID: hit.UserID, Name: display, Avatar: string(hit.AvatarURL)} } } if match == nil { - return nil, fmt.Errorf("no Reddit user matched %q", identifier) + return nil, bridgev2.RespError(mautrix.MNotFound.WithMessage("No Reddit user found for %q.", identifier)) } uid := makeUserID(match.UserID) @@ -62,7 +61,7 @@ func (r *RedditClient) ResolveIdentifier(ctx context.Context, identifier string, } roomID, _, err := r.remote().CreateOrGetDM(ctx, match.UserID) if err != nil { - return nil, fmt.Errorf("create dm: %w", err) + return nil, wrapRedditRespError(err) } portalKey := networkid.PortalKey{ID: makePortalID(roomID), Receiver: r.userLogin.ID} resp.Chat = &bridgev2.CreateChatResponse{ @@ -79,7 +78,7 @@ func (r *RedditClient) SearchUsers(ctx context.Context, query string) ([]*bridge query = strings.TrimSpace(query) results, err := r.remote().SearchUsers(ctx, query) if err != nil { - return nil, err + return nil, wrapRedditRespError(err) } out := make([]*bridgev2.ResolveIdentifierResponse, 0, len(results.Results)) for _, hit := range results.Results { @@ -98,14 +97,13 @@ func (r *RedditClient) SearchUsers(ctx context.Context, query string) ([]*bridge } // CreateGroup spins up a private Reddit chat with the requested participants. -// Reddit has rate limits on group creation for new accounts; surface errors -// directly from the underlying library to the caller. +// Reddit has rate limits on group creation for new accounts. func (r *RedditClient) CreateGroup(ctx context.Context, params *bridgev2.GroupCreateParams) (*bridgev2.CreateChatResponse, error) { if params == nil || params.Name == nil || params.Name.Name == "" { - return nil, errors.New("group name is required") + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Group name is required.")) } if len(params.Participants) < 2 { - return nil, errors.New("at least 2 participants are required") + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("At least 2 participants are required.")) } mxids := make([]id.UserID, 0, len(params.Participants)) for _, uid := range params.Participants { @@ -113,7 +111,7 @@ func (r *RedditClient) CreateGroup(ctx context.Context, params *bridgev2.GroupCr } roomID, err := r.remote().CreateGroup(ctx, params.Name.Name, mxids) if err != nil { - return nil, fmt.Errorf("create group: %w", err) + return nil, wrapRedditRespError(err) } portalKey := networkid.PortalKey{ID: makePortalID(roomID)} if r.main.Bridge.Config.SplitPortals { diff --git a/pkg/redditchat/login.go b/pkg/redditchat/login.go index 34bea12..e3dc73c 100644 --- a/pkg/redditchat/login.go +++ b/pkg/redditchat/login.go @@ -14,6 +14,7 @@ import ( "net/url" "regexp" "strings" + "time" ) const ( @@ -103,7 +104,7 @@ func NewRedditSession(httpClient *http.Client, baseURL, userAgent string) (*Redd if err != nil { return nil, err } - httpClient = &http.Client{Jar: jar} + httpClient = &http.Client{Jar: jar, Timeout: 30 * time.Second} } if httpClient.Jar == nil { jar, err := cookiejar.New(nil)