Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion pkg/connector/chatsync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion pkg/connector/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/connector/emoji.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 10 additions & 11 deletions pkg/connector/handlematrix.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package connector
import (
"context"
"errors"
"fmt"
"time"

"maunium.net/go/mautrix"
Expand All @@ -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 {
Expand All @@ -44,15 +43,15 @@ 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:
return nil, bridgev2.ErrUnsupportedMessageType
}
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")
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
Expand All @@ -146,15 +145,15 @@ 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 {
roomID := portalIDToRoomID(msg.Portal.ID)
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 {
Expand All @@ -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) {
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions pkg/connector/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
}
}

Expand All @@ -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]),
Expand Down
7 changes: 2 additions & 5 deletions pkg/connector/loginerrors.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"net/http"

"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"

"github.com/beeper/reddit/pkg/redditchat"
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions pkg/connector/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down
8 changes: 4 additions & 4 deletions pkg/connector/membership.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -56,16 +56,16 @@ 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 {
err = r.remote().Invite(ctx, roomID, targetMXID)
} else {
_, err = r.matrix().KickUser(ctx, roomID, &mautrix.ReqKickUser{UserID: targetMXID})
}
return nil, err
return nil, wrapRedditError(err)
}
86 changes: 86 additions & 0 deletions pkg/connector/redditerrors.go
Original file line number Diff line number Diff line change
@@ -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)
}
12 changes: 6 additions & 6 deletions pkg/connector/requests.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading