summary refs log tree commit diff
path: root/vendor/maunium.net/go/mautrix/client.go
diff options
context:
space:
mode:
Diffstat (limited to 'vendor/maunium.net/go/mautrix/client.go')
-rw-r--r--vendor/maunium.net/go/mautrix/client.go2391
1 files changed, 2391 insertions, 0 deletions
diff --git a/vendor/maunium.net/go/mautrix/client.go b/vendor/maunium.net/go/mautrix/client.go
new file mode 100644
index 0000000..b85d86f
--- /dev/null
+++ b/vendor/maunium.net/go/mautrix/client.go
@@ -0,0 +1,2391 @@
+// Package mautrix implements the Matrix Client-Server API.
+//
+// Specification can be found at https://spec.matrix.org/v1.2/client-server-api/
+package mautrix
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"os"
+	"slices"
+	"strconv"
+	"strings"
+	"sync/atomic"
+	"time"
+
+	"github.com/rs/zerolog"
+	"go.mau.fi/util/ptr"
+	"go.mau.fi/util/retryafter"
+	"golang.org/x/exp/maps"
+
+	"maunium.net/go/mautrix/crypto/backup"
+	"maunium.net/go/mautrix/event"
+	"maunium.net/go/mautrix/id"
+	"maunium.net/go/mautrix/pushrules"
+)
+
+type CryptoHelper interface {
+	Encrypt(context.Context, id.RoomID, event.Type, any) (*event.EncryptedEventContent, error)
+	Decrypt(context.Context, *event.Event) (*event.Event, error)
+	WaitForSession(context.Context, id.RoomID, id.SenderKey, id.SessionID, time.Duration) bool
+	RequestSession(context.Context, id.RoomID, id.SenderKey, id.SessionID, id.UserID, id.DeviceID)
+	Init(context.Context) error
+}
+
+type VerificationHelper interface {
+	// Init initializes the helper. This should be called before any other
+	// methods.
+	Init(context.Context) error
+
+	// StartVerification starts an interactive verification flow with the given
+	// user via a to-device event.
+	StartVerification(ctx context.Context, to id.UserID) (id.VerificationTransactionID, error)
+	// StartInRoomVerification starts an interactive verification flow with the
+	// given user in the given room.
+	StartInRoomVerification(ctx context.Context, roomID id.RoomID, to id.UserID) (id.VerificationTransactionID, error)
+
+	// AcceptVerification accepts a verification request.
+	AcceptVerification(ctx context.Context, txnID id.VerificationTransactionID) error
+	// DismissVerification dismisses a verification request. This will not send
+	// a cancellation to the other device. This method should only be called
+	// *before* the request has been accepted and will error otherwise.
+	DismissVerification(ctx context.Context, txnID id.VerificationTransactionID) error
+	// CancelVerification cancels a verification request. This method should
+	// only be called *after* the request has been accepted, although it will
+	// not error if called beforehand.
+	CancelVerification(ctx context.Context, txnID id.VerificationTransactionID, code event.VerificationCancelCode, reason string) error
+
+	// HandleScannedQRData handles the data from a QR code scan.
+	HandleScannedQRData(ctx context.Context, data []byte) error
+	// ConfirmQRCodeScanned confirms that our QR code has been scanned.
+	ConfirmQRCodeScanned(ctx context.Context, txnID id.VerificationTransactionID) error
+
+	// StartSAS starts a SAS verification flow.
+	StartSAS(ctx context.Context, txnID id.VerificationTransactionID) error
+	// ConfirmSAS indicates that the user has confirmed that the SAS matches
+	// SAS shown on the other user's device.
+	ConfirmSAS(ctx context.Context, txnID id.VerificationTransactionID) error
+}
+
+// Client represents a Matrix client.
+type Client struct {
+	HomeserverURL *url.URL     // The base homeserver URL
+	UserID        id.UserID    // The user ID of the client. Used for forming HTTP paths which use the client's user ID.
+	DeviceID      id.DeviceID  // The device ID of the client.
+	AccessToken   string       // The access_token for the client.
+	UserAgent     string       // The value for the User-Agent header
+	Client        *http.Client // The underlying HTTP client which will be used to make HTTP requests.
+	Syncer        Syncer       // The thing which can process /sync responses
+	Store         SyncStore    // The thing which can store tokens/ids
+	StateStore    StateStore
+	Crypto        CryptoHelper
+	Verification  VerificationHelper
+	SpecVersions  *RespVersions
+
+	Log zerolog.Logger
+
+	RequestHook  func(req *http.Request)
+	ResponseHook func(req *http.Request, resp *http.Response, err error, duration time.Duration)
+
+	UpdateRequestOnRetry func(req *http.Request, cause error) *http.Request
+
+	SyncPresence event.Presence
+	SyncTraceLog bool
+
+	StreamSyncMinAge time.Duration
+
+	// Number of times that mautrix will retry any HTTP request
+	// if the request fails entirely or returns a HTTP gateway error (502-504)
+	DefaultHTTPRetries int
+	// Amount of time to wait between HTTP retries, defaults to 4 seconds
+	DefaultHTTPBackoff time.Duration
+	// Set to true to disable automatically sleeping on 429 errors.
+	IgnoreRateLimit bool
+
+	txnID int32
+
+	// Should the ?user_id= query parameter be set in requests?
+	// See https://spec.matrix.org/v1.6/application-service-api/#identity-assertion
+	SetAppServiceUserID bool
+	// Should the org.matrix.msc3202.device_id query parameter be set in requests?
+	// See https://github.com/matrix-org/matrix-spec-proposals/pull/3202
+	SetAppServiceDeviceID bool
+
+	syncingID uint32 // Identifies the current Sync. Only one Sync can be active at any given time.
+}
+
+type ClientWellKnown struct {
+	Homeserver     HomeserverInfo     `json:"m.homeserver"`
+	IdentityServer IdentityServerInfo `json:"m.identity_server"`
+}
+
+type HomeserverInfo struct {
+	BaseURL string `json:"base_url"`
+}
+
+type IdentityServerInfo struct {
+	BaseURL string `json:"base_url"`
+}
+
+// DiscoverClientAPI resolves the client API URL from a Matrix server name.
+// Use ParseUserID to extract the server name from a user ID.
+// https://spec.matrix.org/v1.2/client-server-api/#server-discovery
+func DiscoverClientAPI(ctx context.Context, serverName string) (*ClientWellKnown, error) {
+	wellKnownURL := url.URL{
+		Scheme: "https",
+		Host:   serverName,
+		Path:   "/.well-known/matrix/client",
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnownURL.String(), nil)
+	if err != nil {
+		return nil, err
+	}
+
+	req.Header.Set("Accept", "application/json")
+	req.Header.Set("User-Agent", DefaultUserAgent+" (.well-known fetcher)")
+
+	client := &http.Client{Timeout: 30 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode == http.StatusNotFound {
+		return nil, nil
+	}
+
+	data, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	var wellKnown ClientWellKnown
+	err = json.Unmarshal(data, &wellKnown)
+	if err != nil {
+		return nil, errors.New(".well-known response not JSON")
+	}
+
+	return &wellKnown, nil
+}
+
+// SetCredentials sets the user ID and access token on this client instance.
+//
+// Deprecated: use the StoreCredentials field in ReqLogin instead.
+func (cli *Client) SetCredentials(userID id.UserID, accessToken string) {
+	cli.AccessToken = accessToken
+	cli.UserID = userID
+}
+
+// ClearCredentials removes the user ID and access token on this client instance.
+func (cli *Client) ClearCredentials() {
+	cli.AccessToken = ""
+	cli.UserID = ""
+	cli.DeviceID = ""
+}
+
+// Sync starts syncing with the provided Homeserver. If Sync() is called twice then the first sync will be stopped and the
+// error will be nil.
+//
+// This function will block until a fatal /sync error occurs, so it should almost always be started as a new goroutine.
+// Fatal sync errors can be caused by:
+//   - The failure to create a filter.
+//   - Client.Syncer.OnFailedSync returning an error in response to a failed sync.
+//   - Client.Syncer.ProcessResponse returning an error.
+//
+// If you wish to continue retrying in spite of these fatal errors, call Sync() again.
+func (cli *Client) Sync() error {
+	return cli.SyncWithContext(context.Background())
+}
+
+func (cli *Client) SyncWithContext(ctx context.Context) error {
+	// Mark the client as syncing.
+	// We will keep syncing until the syncing state changes. Either because
+	// Sync is called or StopSync is called.
+	syncingID := cli.incrementSyncingID()
+	nextBatch, err := cli.Store.LoadNextBatch(ctx, cli.UserID)
+	if err != nil {
+		return err
+	}
+	filterID, err := cli.Store.LoadFilterID(ctx, cli.UserID)
+	if err != nil {
+		return err
+	}
+
+	if filterID == "" {
+		filterJSON := cli.Syncer.GetFilterJSON(cli.UserID)
+		resFilter, err := cli.CreateFilter(ctx, filterJSON)
+		if err != nil {
+			return err
+		}
+		filterID = resFilter.FilterID
+		if err := cli.Store.SaveFilterID(ctx, cli.UserID, filterID); err != nil {
+			return err
+		}
+	}
+	lastSuccessfulSync := time.Now().Add(-cli.StreamSyncMinAge - 1*time.Hour)
+	// Always do first sync with 0 timeout
+	isFailing := true
+	for {
+		streamResp := false
+		if cli.StreamSyncMinAge > 0 && time.Since(lastSuccessfulSync) > cli.StreamSyncMinAge {
+			cli.Log.Debug().Msg("Last sync is old, will stream next response")
+			streamResp = true
+		}
+		timeout := 30000
+		if isFailing {
+			timeout = 0
+		}
+		resSync, err := cli.FullSyncRequest(ctx, ReqSync{
+			Timeout:        timeout,
+			Since:          nextBatch,
+			FilterID:       filterID,
+			FullState:      false,
+			SetPresence:    cli.SyncPresence,
+			StreamResponse: streamResp,
+		})
+		if err != nil {
+			isFailing = true
+			if ctx.Err() != nil {
+				return ctx.Err()
+			}
+			duration, err2 := cli.Syncer.OnFailedSync(resSync, err)
+			if err2 != nil {
+				return err2
+			}
+			if duration <= 0 {
+				continue
+			}
+			select {
+			case <-ctx.Done():
+				return ctx.Err()
+			case <-time.After(duration):
+				continue
+			}
+		}
+		isFailing = false
+		lastSuccessfulSync = time.Now()
+
+		// Check that the syncing state hasn't changed
+		// Either because we've stopped syncing or another sync has been started.
+		// We discard the response from our sync.
+		if cli.getSyncingID() != syncingID {
+			return nil
+		}
+
+		// Save the token now *before* processing it. This means it's possible
+		// to not process some events, but it means that we won't get constantly stuck processing
+		// a malformed/buggy event which keeps making us panic.
+		err = cli.Store.SaveNextBatch(ctx, cli.UserID, resSync.NextBatch)
+		if err != nil {
+			return err
+		}
+		if err = cli.Syncer.ProcessResponse(ctx, resSync, nextBatch); err != nil {
+			return err
+		}
+
+		nextBatch = resSync.NextBatch
+	}
+}
+
+func (cli *Client) incrementSyncingID() uint32 {
+	return atomic.AddUint32(&cli.syncingID, 1)
+}
+
+func (cli *Client) getSyncingID() uint32 {
+	return atomic.LoadUint32(&cli.syncingID)
+}
+
+// StopSync stops the ongoing sync started by Sync.
+func (cli *Client) StopSync() {
+	// Advance the syncing state so that any running Syncs will terminate.
+	cli.incrementSyncingID()
+}
+
+type contextKey int
+
+const (
+	LogBodyContextKey contextKey = iota
+	LogRequestIDContextKey
+)
+
+func (cli *Client) RequestStart(req *http.Request) {
+	if cli.RequestHook != nil {
+		cli.RequestHook(req)
+	}
+}
+
+func (cli *Client) LogRequestDone(req *http.Request, resp *http.Response, err error, handlerErr error, contentLength int, duration time.Duration) {
+	var evt *zerolog.Event
+	if errors.Is(err, context.Canceled) {
+		evt = zerolog.Ctx(req.Context()).Warn()
+	} else if err != nil {
+		evt = zerolog.Ctx(req.Context()).Err(err)
+	} else if handlerErr != nil {
+		evt = zerolog.Ctx(req.Context()).Warn().
+			AnErr("body_parse_err", handlerErr)
+	} else if cli.SyncTraceLog && strings.HasSuffix(req.URL.Path, "/_matrix/client/v3/sync") {
+		evt = zerolog.Ctx(req.Context()).Trace()
+	} else {
+		evt = zerolog.Ctx(req.Context()).Debug()
+	}
+	evt = evt.
+		Str("method", req.Method).
+		Str("url", req.URL.String()).
+		Dur("duration", duration)
+	if cli.ResponseHook != nil {
+		cli.ResponseHook(req, resp, err, duration)
+	}
+	if resp != nil {
+		mime := resp.Header.Get("Content-Type")
+		length := resp.ContentLength
+		if length == -1 && contentLength > 0 {
+			length = int64(contentLength)
+		}
+		evt = evt.Int("status_code", resp.StatusCode).
+			Int64("response_length", length).
+			Str("response_mime", mime)
+		if serverRequestID := resp.Header.Get("X-Beeper-Request-ID"); serverRequestID != "" {
+			evt.Str("beeper_request_id", serverRequestID)
+		}
+	}
+	if body := req.Context().Value(LogBodyContextKey); body != nil {
+		evt.Interface("req_body", body)
+	}
+	if errors.Is(err, context.Canceled) {
+		evt.Msg("Request canceled")
+	} else if err != nil {
+		evt.Msg("Request failed")
+	} else if handlerErr != nil {
+		evt.Msg("Request parsing failed")
+	} else {
+		evt.Msg("Request completed")
+	}
+}
+
+func (cli *Client) MakeRequest(ctx context.Context, method string, httpURL string, reqBody any, resBody any) ([]byte, error) {
+	return cli.MakeFullRequest(ctx, FullRequest{Method: method, URL: httpURL, RequestJSON: reqBody, ResponseJSON: resBody})
+}
+
+type ClientResponseHandler = func(req *http.Request, res *http.Response, responseJSON interface{}) ([]byte, error)
+
+type FullRequest struct {
+	Method           string
+	URL              string
+	Headers          http.Header
+	RequestJSON      interface{}
+	RequestBytes     []byte
+	RequestBody      io.Reader
+	RequestLength    int64
+	ResponseJSON     interface{}
+	MaxAttempts      int
+	BackoffDuration  time.Duration
+	SensitiveContent bool
+	Handler          ClientResponseHandler
+	DontReadResponse bool
+	Logger           *zerolog.Logger
+	Client           *http.Client
+}
+
+var requestID int32
+var logSensitiveContent = os.Getenv("MAUTRIX_LOG_SENSITIVE_CONTENT") == "yes"
+
+func (params *FullRequest) compileRequest(ctx context.Context) (*http.Request, error) {
+	var logBody any
+	reqBody := params.RequestBody
+	if params.RequestJSON != nil {
+		jsonStr, err := json.Marshal(params.RequestJSON)
+		if err != nil {
+			return nil, HTTPError{
+				Message:      "failed to marshal JSON",
+				WrappedError: err,
+			}
+		}
+		if params.SensitiveContent && !logSensitiveContent {
+			logBody = "<sensitive content omitted>"
+		} else {
+			logBody = params.RequestJSON
+		}
+		reqBody = bytes.NewReader(jsonStr)
+	} else if params.RequestBytes != nil {
+		logBody = fmt.Sprintf("<%d bytes>", len(params.RequestBytes))
+		reqBody = bytes.NewReader(params.RequestBytes)
+		params.RequestLength = int64(len(params.RequestBytes))
+	} else if params.RequestLength > 0 && params.RequestBody != nil {
+		logBody = fmt.Sprintf("<%d bytes>", params.RequestLength)
+		if rsc, ok := params.RequestBody.(io.ReadSeekCloser); ok {
+			// Prevent HTTP from closing the request body, it might be needed for retries
+			reqBody = nopCloseSeeker{rsc}
+		}
+	} else if params.Method != http.MethodGet && params.Method != http.MethodHead {
+		params.RequestJSON = struct{}{}
+		logBody = params.RequestJSON
+		reqBody = bytes.NewReader([]byte("{}"))
+	}
+	reqID := atomic.AddInt32(&requestID, 1)
+	logger := zerolog.Ctx(ctx)
+	if logger.GetLevel() == zerolog.Disabled || logger == zerolog.DefaultContextLogger {
+		logger = params.Logger
+	}
+	ctx = logger.With().
+		Int32("req_id", reqID).
+		Logger().WithContext(ctx)
+	ctx = context.WithValue(ctx, LogBodyContextKey, logBody)
+	ctx = context.WithValue(ctx, LogRequestIDContextKey, int(reqID))
+	req, err := http.NewRequestWithContext(ctx, params.Method, params.URL, reqBody)
+	if err != nil {
+		return nil, HTTPError{
+			Message:      "failed to create request",
+			WrappedError: err,
+		}
+	}
+	if params.Headers != nil {
+		req.Header = params.Headers
+	}
+	if params.RequestJSON != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+	if params.RequestLength > 0 && params.RequestBody != nil {
+		req.ContentLength = params.RequestLength
+	}
+	return req, nil
+}
+
+func (cli *Client) MakeFullRequest(ctx context.Context, params FullRequest) ([]byte, error) {
+	data, _, err := cli.MakeFullRequestWithResp(ctx, params)
+	return data, err
+}
+
+func (cli *Client) MakeFullRequestWithResp(ctx context.Context, params FullRequest) ([]byte, *http.Response, error) {
+	if params.MaxAttempts == 0 {
+		params.MaxAttempts = 1 + cli.DefaultHTTPRetries
+	}
+	if params.BackoffDuration == 0 {
+		if cli.DefaultHTTPBackoff == 0 {
+			params.BackoffDuration = 4 * time.Second
+		} else {
+			params.BackoffDuration = cli.DefaultHTTPBackoff
+		}
+	}
+	if params.Logger == nil {
+		params.Logger = &cli.Log
+	}
+	req, err := params.compileRequest(ctx)
+	if err != nil {
+		return nil, nil, err
+	}
+	if params.Handler == nil {
+		if params.DontReadResponse {
+			params.Handler = noopHandleResponse
+		} else {
+			params.Handler = handleNormalResponse
+		}
+	}
+	req.Header.Set("User-Agent", cli.UserAgent)
+	if len(cli.AccessToken) > 0 {
+		req.Header.Set("Authorization", "Bearer "+cli.AccessToken)
+	}
+	if params.Client == nil {
+		params.Client = cli.Client
+	}
+	return cli.executeCompiledRequest(req, params.MaxAttempts-1, params.BackoffDuration, params.ResponseJSON, params.Handler, params.DontReadResponse, params.Client)
+}
+
+func (cli *Client) cliOrContextLog(ctx context.Context) *zerolog.Logger {
+	log := zerolog.Ctx(ctx)
+	if log.GetLevel() == zerolog.Disabled || log == zerolog.DefaultContextLogger {
+		return &cli.Log
+	}
+	return log
+}
+
+func (cli *Client) doRetry(req *http.Request, cause error, retries int, backoff time.Duration, responseJSON any, handler ClientResponseHandler, dontReadResponse bool, client *http.Client) ([]byte, *http.Response, error) {
+	log := zerolog.Ctx(req.Context())
+	if req.Body != nil {
+		var err error
+		if req.GetBody != nil {
+			req.Body, err = req.GetBody()
+			if err != nil {
+				log.Warn().Err(err).Msg("Failed to get new body to retry request")
+				return nil, nil, cause
+			}
+		} else if bodySeeker, ok := req.Body.(io.ReadSeeker); ok {
+			_, err = bodySeeker.Seek(0, io.SeekStart)
+			if err != nil {
+				log.Warn().Err(err).Msg("Failed to seek to beginning of request body")
+				return nil, nil, cause
+			}
+		} else {
+			log.Warn().Msg("Failed to get new body to retry request: GetBody is nil and Body is not an io.ReadSeeker")
+			return nil, nil, cause
+		}
+	}
+	log.Warn().Err(cause).
+		Int("retry_in_seconds", int(backoff.Seconds())).
+		Msg("Request failed, retrying")
+	time.Sleep(backoff)
+	if cli.UpdateRequestOnRetry != nil {
+		req = cli.UpdateRequestOnRetry(req, cause)
+	}
+	return cli.executeCompiledRequest(req, retries-1, backoff*2, responseJSON, handler, dontReadResponse, client)
+}
+
+func readResponseBody(req *http.Request, res *http.Response) ([]byte, error) {
+	contents, err := io.ReadAll(res.Body)
+	if err != nil {
+		return nil, HTTPError{
+			Request:  req,
+			Response: res,
+
+			Message:      "failed to read response body",
+			WrappedError: err,
+		}
+	}
+	return contents, nil
+}
+
+func closeTemp(log *zerolog.Logger, file *os.File) {
+	_ = file.Close()
+	err := os.Remove(file.Name())
+	if err != nil {
+		log.Warn().Err(err).Str("file_name", file.Name()).Msg("Failed to remove response temp file")
+	}
+}
+
+func streamResponse(req *http.Request, res *http.Response, responseJSON interface{}) ([]byte, error) {
+	log := zerolog.Ctx(req.Context())
+	file, err := os.CreateTemp("", "mautrix-response-")
+	if err != nil {
+		log.Warn().Err(err).Msg("Failed to create temporary file for streaming response")
+		_, err = handleNormalResponse(req, res, responseJSON)
+		return nil, err
+	}
+	defer closeTemp(log, file)
+	if _, err = io.Copy(file, res.Body); err != nil {
+		return nil, fmt.Errorf("failed to copy response to file: %w", err)
+	} else if _, err = file.Seek(0, 0); err != nil {
+		return nil, fmt.Errorf("failed to seek to beginning of response file: %w", err)
+	} else if err = json.NewDecoder(file).Decode(responseJSON); err != nil {
+		return nil, fmt.Errorf("failed to unmarshal response body: %w", err)
+	} else {
+		return nil, nil
+	}
+}
+
+func noopHandleResponse(req *http.Request, res *http.Response, responseJSON interface{}) ([]byte, error) {
+	return nil, nil
+}
+
+func handleNormalResponse(req *http.Request, res *http.Response, responseJSON interface{}) ([]byte, error) {
+	if contents, err := readResponseBody(req, res); err != nil {
+		return nil, err
+	} else if responseJSON == nil {
+		return contents, nil
+	} else if err = json.Unmarshal(contents, &responseJSON); err != nil {
+		return nil, HTTPError{
+			Request:  req,
+			Response: res,
+
+			Message:      "failed to unmarshal response body",
+			ResponseBody: string(contents),
+			WrappedError: err,
+		}
+	} else {
+		return contents, nil
+	}
+}
+
+func ParseErrorResponse(req *http.Request, res *http.Response) ([]byte, error) {
+	contents, err := readResponseBody(req, res)
+	if err != nil {
+		return contents, err
+	}
+
+	respErr := &RespError{
+		StatusCode: res.StatusCode,
+	}
+	if _ = json.Unmarshal(contents, respErr); respErr.ErrCode == "" {
+		respErr = nil
+	}
+
+	return contents, HTTPError{
+		Request:   req,
+		Response:  res,
+		RespError: respErr,
+	}
+}
+
+func (cli *Client) executeCompiledRequest(req *http.Request, retries int, backoff time.Duration, responseJSON any, handler ClientResponseHandler, dontReadResponse bool, client *http.Client) ([]byte, *http.Response, error) {
+	cli.RequestStart(req)
+	startTime := time.Now()
+	res, err := client.Do(req)
+	duration := time.Now().Sub(startTime)
+	if res != nil && !dontReadResponse {
+		defer res.Body.Close()
+	}
+	if err != nil {
+		if retries > 0 && !errors.Is(err, context.Canceled) {
+			return cli.doRetry(req, err, retries, backoff, responseJSON, handler, dontReadResponse, client)
+		}
+		err = HTTPError{
+			Request:  req,
+			Response: res,
+
+			Message:      "request error",
+			WrappedError: err,
+		}
+		cli.LogRequestDone(req, res, err, nil, 0, duration)
+		return nil, res, err
+	}
+
+	if retries > 0 && retryafter.Should(res.StatusCode, !cli.IgnoreRateLimit) {
+		backoff = retryafter.Parse(res.Header.Get("Retry-After"), backoff)
+		return cli.doRetry(req, fmt.Errorf("HTTP %d", res.StatusCode), retries, backoff, responseJSON, handler, dontReadResponse, client)
+	}
+
+	var body []byte
+	if res.StatusCode < 200 || res.StatusCode >= 300 {
+		body, err = ParseErrorResponse(req, res)
+		cli.LogRequestDone(req, res, nil, nil, len(body), duration)
+	} else {
+		body, err = handler(req, res, responseJSON)
+		cli.LogRequestDone(req, res, nil, err, len(body), duration)
+	}
+	return body, res, err
+}
+
+// Whoami gets the user ID of the current user. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3accountwhoami
+func (cli *Client) Whoami(ctx context.Context) (resp *RespWhoami, err error) {
+
+	urlPath := cli.BuildClientURL("v3", "account", "whoami")
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
+	return
+}
+
+// CreateFilter makes an HTTP request according to https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3useruseridfilter
+func (cli *Client) CreateFilter(ctx context.Context, filter *Filter) (resp *RespCreateFilter, err error) {
+	urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "filter")
+	_, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, filter, &resp)
+	return
+}
+
+// SyncRequest makes an HTTP request according to https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3sync
+func (cli *Client) SyncRequest(ctx context.Context, timeout int, since, filterID string, fullState bool, setPresence event.Presence) (resp *RespSync, err error) {
+	return cli.FullSyncRequest(ctx, ReqSync{
+		Timeout:     timeout,
+		Since:       since,
+		FilterID:    filterID,
+		FullState:   fullState,
+		SetPresence: setPresence,
+	})
+}
+
+type ReqSync struct {
+	Timeout         int
+	Since           string
+	FilterID        string
+	FullState       bool
+	SetPresence     event.Presence
+	StreamResponse  bool
+	BeeperStreaming bool
+	Client          *http.Client
+}
+
+func (req *ReqSync) BuildQuery() map[string]string {
+	query := map[string]string{
+		"timeout": strconv.Itoa(req.Timeout),
+	}
+	if req.Since != "" {
+		query["since"] = req.Since
+	}
+	if req.FilterID != "" {
+		query["filter"] = req.FilterID
+	}
+	if req.SetPresence != "" {
+		query["set_presence"] = string(req.SetPresence)
+	}
+	if req.FullState {
+		query["full_state"] = "true"
+	}
+	if req.BeeperStreaming {
+		// TODO remove this
+		query["streaming"] = ""
+		query["com.beeper.streaming"] = "true"
+	}
+	return query
+}
+
+// FullSyncRequest makes an HTTP request according to https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3sync
+func (cli *Client) FullSyncRequest(ctx context.Context, req ReqSync) (resp *RespSync, err error) {
+	urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "sync"}, req.BuildQuery())
+	fullReq := FullRequest{
+		Method:       http.MethodGet,
+		URL:          urlPath,
+		ResponseJSON: &resp,
+		Client:       req.Client,
+		// We don't want automatic retries for SyncRequest, the Sync() wrapper handles those.
+		MaxAttempts: 1,
+	}
+	if req.StreamResponse {
+		fullReq.Handler = streamResponse
+	}
+	start := time.Now()
+	_, err = cli.MakeFullRequest(ctx, fullReq)
+	duration := time.Now().Sub(start)
+	timeout := time.Duration(req.Timeout) * time.Millisecond
+	buffer := 10 * time.Second
+	if req.Since == "" {
+		buffer = 1 * time.Minute
+	}
+	if err == nil && duration > timeout+buffer {
+		cli.cliOrContextLog(ctx).Warn().
+			Str("since", req.Since).
+			Dur("duration", duration).
+			Dur("timeout", timeout).
+			Msg("Sync request took unusually long")
+	}
+	return
+}
+
+// RegisterAvailable checks if a username is valid and available for registration on the server.
+//
+// See https://spec.matrix.org/v1.4/client-server-api/#get_matrixclientv3registeravailable for more details
+//
+// This will always return an error if the username isn't available, so checking the actual response struct is generally
+// not necessary. It is still returned for future-proofing. For a simple availability check, just check that the returned
+// error is nil. `errors.Is` can be used to find the exact reason why a username isn't available:
+//
+//	_, err := cli.RegisterAvailable("cat")
+//	if errors.Is(err, mautrix.MUserInUse) {
+//		// Username is taken
+//	} else if errors.Is(err, mautrix.MInvalidUsername) {
+//		// Username is not valid
+//	} else if errors.Is(err, mautrix.MExclusive) {
+//		// Username is reserved for an appservice
+//	} else if errors.Is(err, mautrix.MLimitExceeded) {
+//		// Too many requests
+//	} else if err != nil {
+//		// Unknown error
+//	} else {
+//		// Username is available
+//	}
+func (cli *Client) RegisterAvailable(ctx context.Context, username string) (resp *RespRegisterAvailable, err error) {
+	u := cli.BuildURLWithQuery(ClientURLPath{"v3", "register", "available"}, map[string]string{"username": username})
+	_, err = cli.MakeRequest(ctx, http.MethodGet, u, nil, &resp)
+	if err == nil && !resp.Available {
+		err = fmt.Errorf(`request returned OK status without "available": true`)
+	}
+	return
+}
+
+func (cli *Client) register(ctx context.Context, url string, req *ReqRegister) (resp *RespRegister, uiaResp *RespUserInteractive, err error) {
+	var bodyBytes []byte
+	bodyBytes, err = cli.MakeFullRequest(ctx, FullRequest{
+		Method:           http.MethodPost,
+		URL:              url,
+		RequestJSON:      req,
+		SensitiveContent: len(req.Password) > 0,
+	})
+	if err != nil {
+		httpErr, ok := err.(HTTPError)
+		// if response has a 401 status, but doesn't have the errcode field, it's probably a UIA response.
+		if ok && httpErr.IsStatus(http.StatusUnauthorized) && httpErr.RespError == nil {
+			err = json.Unmarshal(bodyBytes, &uiaResp)
+		}
+	} else {
+		// body should be RespRegister
+		err = json.Unmarshal(bodyBytes, &resp)
+	}
+	return
+}
+
+// Register makes an HTTP request according to https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3register
+//
+// Registers with kind=user. For kind=guest, see RegisterGuest.
+func (cli *Client) Register(ctx context.Context, req *ReqRegister) (*RespRegister, *RespUserInteractive, error) {
+	u := cli.BuildClientURL("v3", "register")
+	return cli.register(ctx, u, req)
+}
+
+// RegisterGuest makes an HTTP request according to https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3register
+// with kind=guest.
+//
+// For kind=user, see Register.
+func (cli *Client) RegisterGuest(ctx context.Context, req *ReqRegister) (*RespRegister, *RespUserInteractive, error) {
+	query := map[string]string{
+		"kind": "guest",
+	}
+	u := cli.BuildURLWithQuery(ClientURLPath{"v3", "register"}, query)
+	return cli.register(ctx, u, req)
+}
+
+// RegisterDummy performs m.login.dummy registration according to https://spec.matrix.org/v1.2/client-server-api/#dummy-auth
+//
+// Only a username and password need to be provided on the ReqRegister struct. Most local/developer homeservers will allow registration
+// this way. If the homeserver does not, an error is returned.
+//
+// This does not set credentials on the client instance. See SetCredentials() instead.
+//
+//	res, err := cli.RegisterDummy(&mautrix.ReqRegister{
+//		Username: "alice",
+//		Password: "wonderland",
+//	})
+//	if err != nil {
+//		panic(err)
+//	}
+//	token := res.AccessToken
+func (cli *Client) RegisterDummy(ctx context.Context, req *ReqRegister) (*RespRegister, error) {
+	res, uia, err := cli.Register(ctx, req)
+	if err != nil && uia == nil {
+		return nil, err
+	} else if uia == nil {
+		return nil, errors.New("server did not return user-interactive auth flows")
+	} else if !uia.HasSingleStageFlow(AuthTypeDummy) {
+		return nil, errors.New("server does not support m.login.dummy")
+	}
+	req.Auth = BaseAuthData{Type: AuthTypeDummy, Session: uia.Session}
+	res, _, err = cli.Register(ctx, req)
+	if err != nil {
+		return nil, err
+	}
+	return res, nil
+}
+
+// GetLoginFlows fetches the login flows that the homeserver supports using https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3login
+func (cli *Client) GetLoginFlows(ctx context.Context) (resp *RespLoginFlows, err error) {
+	urlPath := cli.BuildClientURL("v3", "login")
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
+	return
+}
+
+// Login a user to the homeserver according to https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3login
+func (cli *Client) Login(ctx context.Context, req *ReqLogin) (resp *RespLogin, err error) {
+	_, err = cli.MakeFullRequest(ctx, FullRequest{
+		Method:           http.MethodPost,
+		URL:              cli.BuildClientURL("v3", "login"),
+		RequestJSON:      req,
+		ResponseJSON:     &resp,
+		SensitiveContent: len(req.Password) > 0 || len(req.Token) > 0,
+	})
+	if req.StoreCredentials && err == nil {
+		cli.DeviceID = resp.DeviceID
+		cli.AccessToken = resp.AccessToken
+		cli.UserID = resp.UserID
+
+		cli.Log.Debug().
+			Str("user_id", cli.UserID.String()).
+			Str("device_id", cli.DeviceID.String()).
+			Msg("Stored credentials after login")
+	}
+	if req.StoreHomeserverURL && err == nil && resp.WellKnown != nil && len(resp.WellKnown.Homeserver.BaseURL) > 0 {
+		var urlErr error
+		cli.HomeserverURL, urlErr = url.Parse(resp.WellKnown.Homeserver.BaseURL)
+		if urlErr != nil {
+			cli.Log.Warn().
+				Err(urlErr).
+				Str("homeserver_url", resp.WellKnown.Homeserver.BaseURL).
+				Msg("Failed to parse homeserver URL in login response")
+		} else {
+			cli.Log.Debug().
+				Str("homeserver_url", cli.HomeserverURL.String()).
+				Msg("Updated homeserver URL after login")
+		}
+	}
+	return
+}
+
+// Logout the current user. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3logout
+// This does not clear the credentials from the client instance. See ClearCredentials() instead.
+func (cli *Client) Logout(ctx context.Context) (resp *RespLogout, err error) {
+	urlPath := cli.BuildClientURL("v3", "logout")
+	_, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, nil, &resp)
+	return
+}
+
+// LogoutAll logs out all the devices of the current user. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3logoutall
+// This does not clear the credentials from the client instance. See ClearCredentials() instead.
+func (cli *Client) LogoutAll(ctx context.Context) (resp *RespLogout, err error) {
+	urlPath := cli.BuildClientURL("v3", "logout", "all")
+	_, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, nil, &resp)
+	return
+}
+
+// Versions returns the list of supported Matrix versions on this homeserver. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientversions
+func (cli *Client) Versions(ctx context.Context) (resp *RespVersions, err error) {
+	urlPath := cli.BuildClientURL("versions")
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
+	if resp != nil {
+		cli.SpecVersions = resp
+	}
+	return
+}
+
+// Capabilities returns capabilities on this homeserver. See https://spec.matrix.org/v1.3/client-server-api/#capabilities-negotiation
+func (cli *Client) Capabilities(ctx context.Context) (resp *RespCapabilities, err error) {
+	urlPath := cli.BuildClientURL("v3", "capabilities")
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
+	return
+}
+
+// JoinRoom joins the client to a room ID or alias. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3joinroomidoralias
+//
+// If serverName is specified, this will be added as a query param to instruct the homeserver to join via that server. If content is specified, it will
+// be JSON encoded and used as the request body.
+func (cli *Client) JoinRoom(ctx context.Context, roomIDorAlias, serverName string, content interface{}) (resp *RespJoinRoom, err error) {
+	var urlPath string
+	if serverName != "" {
+		urlPath = cli.BuildURLWithQuery(ClientURLPath{"v3", "join", roomIDorAlias}, map[string]string{
+			"server_name": serverName,
+		})
+	} else {
+		urlPath = cli.BuildClientURL("v3", "join", roomIDorAlias)
+	}
+	_, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, content, &resp)
+	if err == nil && cli.StateStore != nil {
+		err = cli.StateStore.SetMembership(ctx, resp.RoomID, cli.UserID, event.MembershipJoin)
+		if err != nil {
+			err = fmt.Errorf("failed to update state store: %w", err)
+		}
+	}
+	return
+}
+
+// JoinRoomByID joins the client to a room ID. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidjoin
+//
+// Unlike JoinRoom, this method can only be used to join rooms that the server already knows about.
+// It's mostly intended for bridges and other things where it's already certain that the server is in the room.
+func (cli *Client) JoinRoomByID(ctx context.Context, roomID id.RoomID) (resp *RespJoinRoom, err error) {
+	_, err = cli.MakeRequest(ctx, http.MethodPost, cli.BuildClientURL("v3", "rooms", roomID, "join"), nil, &resp)
+	if err == nil && cli.StateStore != nil {
+		err = cli.StateStore.SetMembership(ctx, resp.RoomID, cli.UserID, event.MembershipJoin)
+		if err != nil {
+			err = fmt.Errorf("failed to update state store: %w", err)
+		}
+	}
+	return
+}
+
+func (cli *Client) GetProfile(ctx context.Context, mxid id.UserID) (resp *RespUserProfile, err error) {
+	urlPath := cli.BuildClientURL("v3", "profile", mxid)
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
+	return
+}
+
+// GetDisplayName returns the display name of the user with the specified MXID. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3profileuseriddisplayname
+func (cli *Client) GetDisplayName(ctx context.Context, mxid id.UserID) (resp *RespUserDisplayName, err error) {
+	urlPath := cli.BuildClientURL("v3", "profile", mxid, "displayname")
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
+	return
+}
+
+// GetOwnDisplayName returns the user's display name. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3profileuseriddisplayname
+func (cli *Client) GetOwnDisplayName(ctx context.Context) (resp *RespUserDisplayName, err error) {
+	return cli.GetDisplayName(ctx, cli.UserID)
+}
+
+// SetDisplayName sets the user's profile display name. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3profileuseriddisplayname
+func (cli *Client) SetDisplayName(ctx context.Context, displayName string) (err error) {
+	urlPath := cli.BuildClientURL("v3", "profile", cli.UserID, "displayname")
+	s := struct {
+		DisplayName string `json:"displayname"`
+	}{displayName}
+	_, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, &s, nil)
+	return
+}
+
+// GetAvatarURL gets the avatar URL of the user with the specified MXID. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3profileuseridavatar_url
+func (cli *Client) GetAvatarURL(ctx context.Context, mxid id.UserID) (url id.ContentURI, err error) {
+	urlPath := cli.BuildClientURL("v3", "profile", mxid, "avatar_url")
+	s := struct {
+		AvatarURL id.ContentURI `json:"avatar_url"`
+	}{}
+
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &s)
+	if err != nil {
+		return
+	}
+	url = s.AvatarURL
+	return
+}
+
+// GetOwnAvatarURL gets the user's avatar URL. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3profileuseridavatar_url
+func (cli *Client) GetOwnAvatarURL(ctx context.Context) (url id.ContentURI, err error) {
+	return cli.GetAvatarURL(ctx, cli.UserID)
+}
+
+// SetAvatarURL sets the user's avatar URL. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3profileuseridavatar_url
+func (cli *Client) SetAvatarURL(ctx context.Context, url id.ContentURI) (err error) {
+	urlPath := cli.BuildClientURL("v3", "profile", cli.UserID, "avatar_url")
+	s := struct {
+		AvatarURL string `json:"avatar_url"`
+	}{url.String()}
+	_, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, &s, nil)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// BeeperUpdateProfile sets custom fields in the user's profile.
+func (cli *Client) BeeperUpdateProfile(ctx context.Context, data any) (err error) {
+	urlPath := cli.BuildClientURL("v3", "profile", cli.UserID)
+	_, err = cli.MakeRequest(ctx, http.MethodPatch, urlPath, data, nil)
+	return
+}
+
+// GetAccountData gets the user's account data of this type. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3useruseridaccount_datatype
+func (cli *Client) GetAccountData(ctx context.Context, name string, output interface{}) (err error) {
+	urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "account_data", name)
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, output)
+	return
+}
+
+// SetAccountData sets the user's account data of this type. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3useruseridaccount_datatype
+func (cli *Client) SetAccountData(ctx context.Context, name string, data interface{}) (err error) {
+	urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "account_data", name)
+	_, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, data, nil)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// GetRoomAccountData gets the user's account data of this type in a specific room. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3useruseridaccount_datatype
+func (cli *Client) GetRoomAccountData(ctx context.Context, roomID id.RoomID, name string, output interface{}) (err error) {
+	urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "rooms", roomID, "account_data", name)
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, output)
+	return
+}
+
+// SetRoomAccountData sets the user's account data of this type in a specific room. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3useruseridroomsroomidaccount_datatype
+func (cli *Client) SetRoomAccountData(ctx context.Context, roomID id.RoomID, name string, data interface{}) (err error) {
+	urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "rooms", roomID, "account_data", name)
+	_, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, data, nil)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+type ReqSendEvent struct {
+	Timestamp     int64
+	TransactionID string
+
+	DontEncrypt bool
+
+	MeowEventID id.EventID
+}
+
+// SendMessageEvent sends a message event into a room. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidsendeventtypetxnid
+// contentJSON should be a pointer to something that can be encoded as JSON using json.Marshal.
+func (cli *Client) SendMessageEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, contentJSON interface{}, extra ...ReqSendEvent) (resp *RespSendEvent, err error) {
+	var req ReqSendEvent
+	if len(extra) > 0 {
+		req = extra[0]
+	}
+
+	var txnID string
+	if len(req.TransactionID) > 0 {
+		txnID = req.TransactionID
+	} else {
+		txnID = cli.TxnID()
+	}
+
+	queryParams := map[string]string{}
+	if req.Timestamp > 0 {
+		queryParams["ts"] = strconv.FormatInt(req.Timestamp, 10)
+	}
+	if req.MeowEventID != "" {
+		queryParams["fi.mau.event_id"] = req.MeowEventID.String()
+	}
+
+	if !req.DontEncrypt && cli.Crypto != nil && eventType != event.EventReaction && eventType != event.EventEncrypted {
+		var isEncrypted bool
+		isEncrypted, err = cli.StateStore.IsEncrypted(ctx, roomID)
+		if err != nil {
+			err = fmt.Errorf("failed to check if room is encrypted: %w", err)
+			return
+		}
+		if isEncrypted {
+			if contentJSON, err = cli.Crypto.Encrypt(ctx, roomID, eventType, contentJSON); err != nil {
+				err = fmt.Errorf("failed to encrypt event: %w", err)
+				return
+			}
+			eventType = event.EventEncrypted
+		}
+	}
+
+	urlData := ClientURLPath{"v3", "rooms", roomID, "send", eventType.String(), txnID}
+	urlPath := cli.BuildURLWithQuery(urlData, queryParams)
+	_, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, contentJSON, &resp)
+	return
+}
+
+// SendStateEvent sends a state event into a room. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidstateeventtypestatekey
+// contentJSON should be a pointer to something that can be encoded as JSON using json.Marshal.
+func (cli *Client) SendStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON interface{}) (resp *RespSendEvent, err error) {
+	urlPath := cli.BuildClientURL("v3", "rooms", roomID, "state", eventType.String(), stateKey)
+	_, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, contentJSON, &resp)
+	if err == nil && cli.StateStore != nil {
+		cli.updateStoreWithOutgoingEvent(ctx, roomID, eventType, stateKey, contentJSON)
+	}
+	return
+}
+
+// SendMassagedStateEvent sends a state event into a room with a custom timestamp. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidstateeventtypestatekey
+// contentJSON should be a pointer to something that can be encoded as JSON using json.Marshal.
+func (cli *Client) SendMassagedStateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON interface{}, ts int64) (resp *RespSendEvent, err error) {
+	urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "rooms", roomID, "state", eventType.String(), stateKey}, map[string]string{
+		"ts": strconv.FormatInt(ts, 10),
+	})
+	_, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, contentJSON, &resp)
+	if err == nil && cli.StateStore != nil {
+		cli.updateStoreWithOutgoingEvent(ctx, roomID, eventType, stateKey, contentJSON)
+	}
+	return
+}
+
+// SendText sends an m.room.message event into the given room with a msgtype of m.text
+// See https://spec.matrix.org/v1.2/client-server-api/#mtext
+func (cli *Client) SendText(ctx context.Context, roomID id.RoomID, text string) (*RespSendEvent, error) {
+	return cli.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{
+		MsgType: event.MsgText,
+		Body:    text,
+	})
+}
+
+// SendNotice sends an m.room.message event into the given room with a msgtype of m.notice
+// See https://spec.matrix.org/v1.2/client-server-api/#mnotice
+func (cli *Client) SendNotice(ctx context.Context, roomID id.RoomID, text string) (*RespSendEvent, error) {
+	return cli.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{
+		MsgType: event.MsgNotice,
+		Body:    text,
+	})
+}
+
+func (cli *Client) SendReaction(ctx context.Context, roomID id.RoomID, eventID id.EventID, reaction string) (*RespSendEvent, error) {
+	return cli.SendMessageEvent(ctx, roomID, event.EventReaction, &event.ReactionEventContent{
+		RelatesTo: event.RelatesTo{
+			EventID: eventID,
+			Type:    event.RelAnnotation,
+			Key:     reaction,
+		},
+	})
+}
+
+// RedactEvent redacts the given event. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidredacteventidtxnid
+func (cli *Client) RedactEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID, extra ...ReqRedact) (resp *RespSendEvent, err error) {
+	req := ReqRedact{}
+	if len(extra) > 0 {
+		req = extra[0]
+	}
+	if req.Extra == nil {
+		req.Extra = make(map[string]interface{})
+	}
+	if len(req.Reason) > 0 {
+		req.Extra["reason"] = req.Reason
+	}
+	var txnID string
+	if len(req.TxnID) > 0 {
+		txnID = req.TxnID
+	} else {
+		txnID = cli.TxnID()
+	}
+	urlPath := cli.BuildClientURL("v3", "rooms", roomID, "redact", eventID, txnID)
+	_, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, req.Extra, &resp)
+	return
+}
+
+// CreateRoom creates a new Matrix room. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3createroom
+//
+//	resp, err := cli.CreateRoom(&mautrix.ReqCreateRoom{
+//		Preset: "public_chat",
+//	})
+//	fmt.Println("Room:", resp.RoomID)
+func (cli *Client) CreateRoom(ctx context.Context, req *ReqCreateRoom) (resp *RespCreateRoom, err error) {
+	urlPath := cli.BuildClientURL("v3", "createRoom")
+	_, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp)
+	if err == nil && cli.StateStore != nil {
+		storeErr := cli.StateStore.SetMembership(ctx, resp.RoomID, cli.UserID, event.MembershipJoin)
+		if storeErr != nil {
+			cli.cliOrContextLog(ctx).Warn().Err(storeErr).
+				Stringer("creator_user_id", cli.UserID).
+				Msg("Failed to update creator membership in state store after creating room")
+		}
+		for _, evt := range req.InitialState {
+			UpdateStateStore(ctx, cli.StateStore, evt)
+		}
+		inviteMembership := event.MembershipInvite
+		if req.BeeperAutoJoinInvites {
+			inviteMembership = event.MembershipJoin
+		}
+		for _, invitee := range req.Invite {
+			storeErr = cli.StateStore.SetMembership(ctx, resp.RoomID, invitee, inviteMembership)
+			if storeErr != nil {
+				cli.cliOrContextLog(ctx).Warn().Err(storeErr).
+					Stringer("invitee_user_id", invitee).
+					Msg("Failed to update membership in state store after creating room")
+			}
+		}
+		for _, evt := range req.InitialState {
+			cli.updateStoreWithOutgoingEvent(ctx, resp.RoomID, evt.Type, evt.GetStateKey(), &evt.Content)
+		}
+	}
+	return
+}
+
+// LeaveRoom leaves the given room. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidleave
+func (cli *Client) LeaveRoom(ctx context.Context, roomID id.RoomID, optionalReq ...*ReqLeave) (resp *RespLeaveRoom, err error) {
+	req := &ReqLeave{}
+	if len(optionalReq) == 1 {
+		req = optionalReq[0]
+	} else if len(optionalReq) > 1 {
+		panic("invalid number of arguments to LeaveRoom")
+	}
+	u := cli.BuildClientURL("v3", "rooms", roomID, "leave")
+	_, err = cli.MakeRequest(ctx, http.MethodPost, u, req, &resp)
+	if err == nil && cli.StateStore != nil {
+		err = cli.StateStore.SetMembership(ctx, roomID, cli.UserID, event.MembershipLeave)
+		if err != nil {
+			err = fmt.Errorf("failed to update membership in state store: %w", err)
+		}
+	}
+	return
+}
+
+// ForgetRoom forgets a room entirely. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidforget
+func (cli *Client) ForgetRoom(ctx context.Context, roomID id.RoomID) (resp *RespForgetRoom, err error) {
+	u := cli.BuildClientURL("v3", "rooms", roomID, "forget")
+	_, err = cli.MakeRequest(ctx, http.MethodPost, u, struct{}{}, &resp)
+	return
+}
+
+// InviteUser invites a user to a room. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidinvite
+func (cli *Client) InviteUser(ctx context.Context, roomID id.RoomID, req *ReqInviteUser) (resp *RespInviteUser, err error) {
+	u := cli.BuildClientURL("v3", "rooms", roomID, "invite")
+	_, err = cli.MakeRequest(ctx, http.MethodPost, u, req, &resp)
+	if err == nil && cli.StateStore != nil {
+		err = cli.StateStore.SetMembership(ctx, roomID, req.UserID, event.MembershipInvite)
+		if err != nil {
+			err = fmt.Errorf("failed to update membership in state store: %w", err)
+		}
+	}
+	return
+}
+
+// InviteUserByThirdParty invites a third-party identifier to a room. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidinvite-1
+func (cli *Client) InviteUserByThirdParty(ctx context.Context, roomID id.RoomID, req *ReqInvite3PID) (resp *RespInviteUser, err error) {
+	u := cli.BuildClientURL("v3", "rooms", roomID, "invite")
+	_, err = cli.MakeRequest(ctx, http.MethodPost, u, req, &resp)
+	return
+}
+
+// KickUser kicks a user from a room. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidkick
+func (cli *Client) KickUser(ctx context.Context, roomID id.RoomID, req *ReqKickUser) (resp *RespKickUser, err error) {
+	u := cli.BuildClientURL("v3", "rooms", roomID, "kick")
+	_, err = cli.MakeRequest(ctx, http.MethodPost, u, req, &resp)
+	if err == nil && cli.StateStore != nil {
+		err = cli.StateStore.SetMembership(ctx, roomID, req.UserID, event.MembershipLeave)
+		if err != nil {
+			err = fmt.Errorf("failed to update membership in state store: %w", err)
+		}
+	}
+	return
+}
+
+// BanUser bans a user from a room. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidban
+func (cli *Client) BanUser(ctx context.Context, roomID id.RoomID, req *ReqBanUser) (resp *RespBanUser, err error) {
+	u := cli.BuildClientURL("v3", "rooms", roomID, "ban")
+	_, err = cli.MakeRequest(ctx, http.MethodPost, u, req, &resp)
+	if err == nil && cli.StateStore != nil {
+		err = cli.StateStore.SetMembership(ctx, roomID, req.UserID, event.MembershipBan)
+		if err != nil {
+			err = fmt.Errorf("failed to update membership in state store: %w", err)
+		}
+	}
+	return
+}
+
+// UnbanUser unbans a user from a room. See https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidunban
+func (cli *Client) UnbanUser(ctx context.Context, roomID id.RoomID, req *ReqUnbanUser) (resp *RespUnbanUser, err error) {
+	u := cli.BuildClientURL("v3", "rooms", roomID, "unban")
+	_, err = cli.MakeRequest(ctx, http.MethodPost, u, req, &resp)
+	if err == nil && cli.StateStore != nil {
+		err = cli.StateStore.SetMembership(ctx, roomID, req.UserID, event.MembershipLeave)
+		if err != nil {
+			err = fmt.Errorf("failed to update membership in state store: %w", err)
+		}
+	}
+	return
+}
+
+// UserTyping sets the typing status of the user. See https://spec.matrix.org/v1.2/client-server-api/#put_matrixclientv3roomsroomidtypinguserid
+func (cli *Client) UserTyping(ctx context.Context, roomID id.RoomID, typing bool, timeout time.Duration) (resp *RespTyping, err error) {
+	req := ReqTyping{Typing: typing, Timeout: timeout.Milliseconds()}
+	u := cli.BuildClientURL("v3", "rooms", roomID, "typing", cli.UserID)
+	_, err = cli.MakeRequest(ctx, http.MethodPut, u, req, &resp)
+	return
+}
+
+// GetPresence gets the presence of the user with the specified MXID. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3presenceuseridstatus
+func (cli *Client) GetPresence(ctx context.Context, userID id.UserID) (resp *RespPresence, err error) {
+	resp = new(RespPresence)
+	u := cli.BuildClientURL("v3", "presence", userID, "status")
+	_, err = cli.MakeRequest(ctx, http.MethodGet, u, nil, resp)
+	return
+}
+
+// GetOwnPresence gets the user's presence. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3presenceuseridstatus
+func (cli *Client) GetOwnPresence(ctx context.Context) (resp *RespPresence, err error) {
+	return cli.GetPresence(ctx, cli.UserID)
+}
+
+func (cli *Client) SetPresence(ctx context.Context, status event.Presence) (err error) {
+	req := ReqPresence{Presence: status}
+	u := cli.BuildClientURL("v3", "presence", cli.UserID, "status")
+	_, err = cli.MakeRequest(ctx, http.MethodPut, u, req, nil)
+	return
+}
+
+func (cli *Client) updateStoreWithOutgoingEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, contentJSON interface{}) {
+	if cli.StateStore == nil {
+		return
+	}
+	fakeEvt := &event.Event{
+		StateKey: &stateKey,
+		Type:     eventType,
+		RoomID:   roomID,
+	}
+	var err error
+	fakeEvt.Content.VeryRaw, err = json.Marshal(contentJSON)
+	if err != nil {
+		cli.Log.Warn().Err(err).Msg("Failed to marshal state event content to update state store")
+		return
+	}
+	err = json.Unmarshal(fakeEvt.Content.VeryRaw, &fakeEvt.Content.Raw)
+	if err != nil {
+		cli.Log.Warn().Err(err).Msg("Failed to unmarshal state event content to update state store")
+		return
+	}
+	err = fakeEvt.Content.ParseRaw(fakeEvt.Type)
+	if err != nil {
+		switch fakeEvt.Type {
+		case event.StateMember, event.StatePowerLevels, event.StateEncryption:
+			cli.Log.Warn().Err(err).Msg("Failed to parse state event content to update state store")
+		default:
+			cli.Log.Debug().Err(err).Msg("Failed to parse state event content to update state store")
+		}
+		return
+	}
+	UpdateStateStore(ctx, cli.StateStore, fakeEvt)
+}
+
+// StateEvent gets a single state event in a room. It will attempt to JSON unmarshal into the given "outContent" struct with
+// the HTTP response body, or return an error.
+// See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3roomsroomidstateeventtypestatekey
+func (cli *Client) StateEvent(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, outContent interface{}) (err error) {
+	u := cli.BuildClientURL("v3", "rooms", roomID, "state", eventType.String(), stateKey)
+	_, err = cli.MakeRequest(ctx, http.MethodGet, u, nil, outContent)
+	if err == nil && cli.StateStore != nil {
+		cli.updateStoreWithOutgoingEvent(ctx, roomID, eventType, stateKey, outContent)
+	}
+	return
+}
+
+// parseRoomStateArray parses a JSON array as a stream and stores the events inside it in a room state map.
+func parseRoomStateArray(_ *http.Request, res *http.Response, responseJSON interface{}) ([]byte, error) {
+	response := make(RoomStateMap)
+	responsePtr := responseJSON.(*map[event.Type]map[string]*event.Event)
+	*responsePtr = response
+	dec := json.NewDecoder(res.Body)
+
+	arrayStart, err := dec.Token()
+	if err != nil {
+		return nil, err
+	} else if arrayStart != json.Delim('[') {
+		return nil, fmt.Errorf("expected array start, got %+v", arrayStart)
+	}
+
+	for i := 1; dec.More(); i++ {
+		var evt *event.Event
+		err = dec.Decode(&evt)
+		if err != nil {
+			return nil, fmt.Errorf("failed to parse state array item #%d: %v", i, err)
+		}
+		evt.Type.Class = event.StateEventType
+		_ = evt.Content.ParseRaw(evt.Type)
+		subMap, ok := response[evt.Type]
+		if !ok {
+			subMap = make(map[string]*event.Event)
+			response[evt.Type] = subMap
+		}
+		subMap[*evt.StateKey] = evt
+	}
+
+	arrayEnd, err := dec.Token()
+	if err != nil {
+		return nil, err
+	} else if arrayEnd != json.Delim(']') {
+		return nil, fmt.Errorf("expected array end, got %+v", arrayStart)
+	}
+	return nil, nil
+}
+
+// State gets all state in a room.
+// See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3roomsroomidstate
+func (cli *Client) State(ctx context.Context, roomID id.RoomID) (stateMap RoomStateMap, err error) {
+	_, err = cli.MakeFullRequest(ctx, FullRequest{
+		Method:       http.MethodGet,
+		URL:          cli.BuildClientURL("v3", "rooms", roomID, "state"),
+		ResponseJSON: &stateMap,
+		Handler:      parseRoomStateArray,
+	})
+	if err == nil && cli.StateStore != nil {
+		for evtType, evts := range stateMap {
+			if evtType == event.StateMember {
+				continue
+			}
+			for _, evt := range evts {
+				UpdateStateStore(ctx, cli.StateStore, evt)
+			}
+		}
+		updateErr := cli.StateStore.ReplaceCachedMembers(ctx, roomID, maps.Values(stateMap[event.StateMember]))
+		if updateErr != nil {
+			cli.cliOrContextLog(ctx).Warn().Err(updateErr).
+				Stringer("room_id", roomID).
+				Msg("Failed to update members in state store after fetching members")
+		}
+	}
+	return
+}
+
+// StateAsArray gets all the state in a room as an array. It does not update the state store.
+// Use State to get the events as a map and also update the state store.
+func (cli *Client) StateAsArray(ctx context.Context, roomID id.RoomID) (state []*event.Event, err error) {
+	_, err = cli.MakeRequest(ctx, http.MethodGet, cli.BuildClientURL("v3", "rooms", roomID, "state"), nil, &state)
+	if err == nil {
+		for _, evt := range state {
+			evt.Type.Class = event.StateEventType
+		}
+	}
+	return
+}
+
+// GetMediaConfig fetches the configuration of the content repository, such as upload limitations.
+func (cli *Client) GetMediaConfig(ctx context.Context) (resp *RespMediaConfig, err error) {
+	_, err = cli.MakeRequest(ctx, http.MethodGet, cli.BuildClientURL("v1", "media", "config"), nil, &resp)
+	return
+}
+
+// UploadLink uploads an HTTP URL and then returns an MXC URI.
+func (cli *Client) UploadLink(ctx context.Context, link string) (*RespMediaUpload, error) {
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, link, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	res, err := cli.Client.Do(req)
+	if res != nil {
+		defer res.Body.Close()
+	}
+	if err != nil {
+		return nil, err
+	}
+	return cli.Upload(ctx, res.Body, res.Header.Get("Content-Type"), res.ContentLength)
+}
+
+func (cli *Client) Download(ctx context.Context, mxcURL id.ContentURI) (*http.Response, error) {
+	_, resp, err := cli.MakeFullRequestWithResp(ctx, FullRequest{
+		Method:           http.MethodGet,
+		URL:              cli.BuildClientURL("v1", "media", "download", mxcURL.Homeserver, mxcURL.FileID),
+		DontReadResponse: true,
+	})
+	return resp, err
+}
+
+func (cli *Client) DownloadBytes(ctx context.Context, mxcURL id.ContentURI) ([]byte, error) {
+	resp, err := cli.Download(ctx, mxcURL)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+	return io.ReadAll(resp.Body)
+}
+
+type ReqCreateMXC struct {
+	BeeperUniqueID string
+	BeeperRoomID   id.RoomID
+}
+
+// CreateMXC creates a blank Matrix content URI to allow uploading the content asynchronously later.
+//
+// See https://spec.matrix.org/v1.7/client-server-api/#post_matrixmediav1create
+func (cli *Client) CreateMXC(ctx context.Context, extra ...ReqCreateMXC) (*RespCreateMXC, error) {
+	var m RespCreateMXC
+	query := map[string]string{}
+	if len(extra) > 0 {
+		if extra[0].BeeperUniqueID != "" {
+			query["com.beeper.unique_id"] = extra[0].BeeperUniqueID
+		}
+		if extra[0].BeeperRoomID != "" {
+			query["com.beeper.room_id"] = string(extra[0].BeeperRoomID)
+		}
+	}
+	createURL := cli.BuildURLWithQuery(MediaURLPath{"v1", "create"}, query)
+	_, err := cli.MakeRequest(ctx, http.MethodPost, createURL, nil, &m)
+	return &m, err
+}
+
+// UploadAsync creates a blank content URI with CreateMXC, starts uploading the data in the background
+// and returns the created MXC immediately.
+//
+// See https://spec.matrix.org/v1.7/client-server-api/#post_matrixmediav1create
+// and https://spec.matrix.org/v1.7/client-server-api/#put_matrixmediav3uploadservernamemediaid
+func (cli *Client) UploadAsync(ctx context.Context, req ReqUploadMedia) (*RespCreateMXC, error) {
+	resp, err := cli.CreateMXC(ctx)
+	if err != nil {
+		req.DoneCallback()
+		return nil, err
+	}
+	req.MXC = resp.ContentURI
+	req.UnstableUploadURL = resp.UnstableUploadURL
+	go func() {
+		_, err = cli.UploadMedia(ctx, req)
+		if err != nil {
+			cli.Log.Error().Str("mxc", req.MXC.String()).Err(err).Msg("Async upload of media failed")
+		}
+	}()
+	return resp, nil
+}
+
+func (cli *Client) UploadBytes(ctx context.Context, data []byte, contentType string) (*RespMediaUpload, error) {
+	return cli.UploadBytesWithName(ctx, data, contentType, "")
+}
+
+func (cli *Client) UploadBytesWithName(ctx context.Context, data []byte, contentType, fileName string) (*RespMediaUpload, error) {
+	return cli.UploadMedia(ctx, ReqUploadMedia{
+		ContentBytes: data,
+		ContentType:  contentType,
+		FileName:     fileName,
+	})
+}
+
+// Upload uploads the given data to the content repository and returns an MXC URI.
+//
+// Deprecated: UploadMedia should be used instead.
+func (cli *Client) Upload(ctx context.Context, content io.Reader, contentType string, contentLength int64) (*RespMediaUpload, error) {
+	return cli.UploadMedia(ctx, ReqUploadMedia{
+		Content:       content,
+		ContentLength: contentLength,
+		ContentType:   contentType,
+	})
+}
+
+type ReqUploadMedia struct {
+	ContentBytes  []byte
+	Content       io.Reader
+	ContentLength int64
+	ContentType   string
+	FileName      string
+
+	DoneCallback func()
+
+	// MXC specifies an existing MXC URI which doesn't have content yet to upload into.
+	// See https://spec.matrix.org/unstable/client-server-api/#put_matrixmediav3uploadservernamemediaid
+	MXC id.ContentURI
+
+	// UnstableUploadURL specifies the URL to upload the content to. MXC must also be set.
+	// see https://github.com/matrix-org/matrix-spec-proposals/pull/3870 for more info
+	UnstableUploadURL string
+}
+
+func (cli *Client) tryUploadMediaToURL(ctx context.Context, url, contentType string, content io.Reader, contentLength int64) (*http.Response, error) {
+	cli.Log.Debug().Str("url", url).Msg("Uploading media to external URL")
+	req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, content)
+	if err != nil {
+		return nil, err
+	}
+	req.ContentLength = contentLength
+	req.Header.Set("Content-Type", contentType)
+	req.Header.Set("User-Agent", cli.UserAgent+" (external media uploader)")
+
+	return http.DefaultClient.Do(req)
+}
+
+func (cli *Client) uploadMediaToURL(ctx context.Context, data ReqUploadMedia) (*RespMediaUpload, error) {
+	retries := cli.DefaultHTTPRetries
+	reader := data.Content
+	if data.ContentBytes != nil {
+		data.ContentLength = int64(len(data.ContentBytes))
+		reader = bytes.NewReader(data.ContentBytes)
+	} else if rsc, ok := reader.(io.ReadSeekCloser); ok {
+		// Prevent HTTP from closing the request body, it might be needed for retries
+		reader = nopCloseSeeker{rsc}
+	}
+	readerSeeker, canSeek := reader.(io.ReadSeeker)
+	if !canSeek {
+		retries = 0
+	}
+	for {
+		resp, err := cli.tryUploadMediaToURL(ctx, data.UnstableUploadURL, data.ContentType, reader, data.ContentLength)
+		if err == nil {
+			if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+				// Everything is fine
+				break
+			}
+			err = fmt.Errorf("HTTP %d", resp.StatusCode)
+		}
+		if retries <= 0 {
+			cli.Log.Warn().Str("url", data.UnstableUploadURL).Err(err).
+				Msg("Error uploading media to external URL, not retrying")
+			return nil, err
+		}
+		cli.Log.Warn().Str("url", data.UnstableUploadURL).Err(err).
+			Msg("Error uploading media to external URL, retrying")
+		retries--
+		_, err = readerSeeker.Seek(0, io.SeekStart)
+		if err != nil {
+			return nil, fmt.Errorf("failed to seek back to start of reader: %w", err)
+		}
+	}
+
+	query := map[string]string{}
+	if len(data.FileName) > 0 {
+		query["filename"] = data.FileName
+	}
+
+	notifyURL := cli.BuildURLWithQuery(MediaURLPath{"unstable", "com.beeper.msc3870", "upload", data.MXC.Homeserver, data.MXC.FileID, "complete"}, query)
+
+	var m *RespMediaUpload
+	_, err := cli.MakeRequest(ctx, http.MethodPost, notifyURL, nil, &m)
+	if err != nil {
+		return nil, err
+	}
+
+	return m, nil
+}
+
+type nopCloseSeeker struct {
+	io.ReadSeeker
+}
+
+func (nopCloseSeeker) Close() error {
+	return nil
+}
+
+// UploadMedia uploads the given data to the content repository and returns an MXC URI.
+// See https://spec.matrix.org/v1.7/client-server-api/#post_matrixmediav3upload
+func (cli *Client) UploadMedia(ctx context.Context, data ReqUploadMedia) (*RespMediaUpload, error) {
+	if data.DoneCallback != nil {
+		defer data.DoneCallback()
+	}
+	if data.UnstableUploadURL != "" {
+		if data.MXC.IsEmpty() {
+			return nil, errors.New("MXC must also be set when uploading to external URL")
+		}
+		return cli.uploadMediaToURL(ctx, data)
+	}
+	u, _ := url.Parse(cli.BuildURL(MediaURLPath{"v3", "upload"}))
+	method := http.MethodPost
+	if !data.MXC.IsEmpty() {
+		u, _ = url.Parse(cli.BuildURL(MediaURLPath{"v3", "upload", data.MXC.Homeserver, data.MXC.FileID}))
+		method = http.MethodPut
+	}
+	if len(data.FileName) > 0 {
+		q := u.Query()
+		q.Set("filename", data.FileName)
+		u.RawQuery = q.Encode()
+	}
+
+	var headers http.Header
+	if len(data.ContentType) > 0 {
+		headers = http.Header{"Content-Type": []string{data.ContentType}}
+	}
+
+	var m RespMediaUpload
+	_, err := cli.MakeFullRequest(ctx, FullRequest{
+		Method:        method,
+		URL:           u.String(),
+		Headers:       headers,
+		RequestBytes:  data.ContentBytes,
+		RequestBody:   data.Content,
+		RequestLength: data.ContentLength,
+		ResponseJSON:  &m,
+	})
+	return &m, err
+}
+
+// GetURLPreview asks the homeserver to fetch a preview for a given URL.
+//
+// See https://spec.matrix.org/v1.2/client-server-api/#get_matrixmediav3preview_url
+func (cli *Client) GetURLPreview(ctx context.Context, url string) (*RespPreviewURL, error) {
+	reqURL := cli.BuildURLWithQuery(ClientURLPath{"v1", "media", "preview_url"}, map[string]string{
+		"url": url,
+	})
+	var output RespPreviewURL
+	_, err := cli.MakeRequest(ctx, http.MethodGet, reqURL, nil, &output)
+	return &output, err
+}
+
+// JoinedMembers returns a map of joined room members. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3roomsroomidjoined_members
+//
+// In general, usage of this API is discouraged in favour of /sync, as calling this API can race with incoming membership changes.
+// This API is primarily designed for application services which may want to efficiently look up joined members in a room.
+func (cli *Client) JoinedMembers(ctx context.Context, roomID id.RoomID) (resp *RespJoinedMembers, err error) {
+	u := cli.BuildClientURL("v3", "rooms", roomID, "joined_members")
+	_, err = cli.MakeRequest(ctx, http.MethodGet, u, nil, &resp)
+	if err == nil && cli.StateStore != nil {
+		fakeEvents := make([]*event.Event, len(resp.Joined))
+		i := 0
+		for userID, member := range resp.Joined {
+			fakeEvents[i] = &event.Event{
+				StateKey: ptr.Ptr(userID.String()),
+				Type:     event.StateMember,
+				RoomID:   roomID,
+				Content: event.Content{Parsed: &event.MemberEventContent{
+					Membership:  event.MembershipJoin,
+					AvatarURL:   id.ContentURIString(member.AvatarURL),
+					Displayname: member.DisplayName,
+				}},
+			}
+			i++
+		}
+		updateErr := cli.StateStore.ReplaceCachedMembers(ctx, roomID, fakeEvents, event.MembershipJoin)
+		if updateErr != nil {
+			cli.cliOrContextLog(ctx).Warn().Err(updateErr).
+				Stringer("room_id", roomID).
+				Msg("Failed to update members in state store after fetching joined members")
+		}
+	}
+	return
+}
+
+func (cli *Client) Members(ctx context.Context, roomID id.RoomID, req ...ReqMembers) (resp *RespMembers, err error) {
+	var extra ReqMembers
+	if len(req) > 0 {
+		extra = req[0]
+	}
+	query := map[string]string{}
+	if len(extra.At) > 0 {
+		query["at"] = extra.At
+	}
+	if len(extra.Membership) > 0 {
+		query["membership"] = string(extra.Membership)
+	}
+	if len(extra.NotMembership) > 0 {
+		query["not_membership"] = string(extra.NotMembership)
+	}
+	u := cli.BuildURLWithQuery(ClientURLPath{"v3", "rooms", roomID, "members"}, query)
+	_, err = cli.MakeRequest(ctx, http.MethodGet, u, nil, &resp)
+	if err == nil {
+		for _, evt := range resp.Chunk {
+			_ = evt.Content.ParseRaw(evt.Type)
+		}
+	}
+	if err == nil && cli.StateStore != nil {
+		var onlyMemberships []event.Membership
+		if extra.Membership != "" {
+			onlyMemberships = []event.Membership{extra.Membership}
+		} else if extra.NotMembership != "" {
+			onlyMemberships = []event.Membership{event.MembershipJoin, event.MembershipLeave, event.MembershipInvite, event.MembershipBan, event.MembershipKnock}
+			onlyMemberships = slices.DeleteFunc(onlyMemberships, func(m event.Membership) bool {
+				return m == extra.NotMembership
+			})
+		}
+		updateErr := cli.StateStore.ReplaceCachedMembers(ctx, roomID, resp.Chunk, onlyMemberships...)
+		if updateErr != nil {
+			cli.cliOrContextLog(ctx).Warn().Err(updateErr).
+				Stringer("room_id", roomID).
+				Msg("Failed to update members in state store after fetching members")
+		}
+	}
+	return
+}
+
+// JoinedRooms returns a list of rooms which the client is joined to. See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3joined_rooms
+//
+// In general, usage of this API is discouraged in favour of /sync, as calling this API can race with incoming membership changes.
+// This API is primarily designed for application services which may want to efficiently look up joined rooms.
+func (cli *Client) JoinedRooms(ctx context.Context) (resp *RespJoinedRooms, err error) {
+	u := cli.BuildClientURL("v3", "joined_rooms")
+	_, err = cli.MakeRequest(ctx, http.MethodGet, u, nil, &resp)
+	return
+}
+
+// Hierarchy returns a list of rooms that are in the room's hierarchy. See https://spec.matrix.org/v1.4/client-server-api/#get_matrixclientv1roomsroomidhierarchy
+//
+// The hierarchy API is provided to walk the space tree and discover the rooms with their aesthetic details. works in a depth-first manner:
+// when it encounters another space as a child it recurses into that space before returning non-space children.
+//
+// The second function parameter specifies query parameters to limit the response. No query parameters will be added if it's nil.
+func (cli *Client) Hierarchy(ctx context.Context, roomID id.RoomID, req *ReqHierarchy) (resp *RespHierarchy, err error) {
+	urlPath := cli.BuildURLWithQuery(ClientURLPath{"v1", "rooms", roomID, "hierarchy"}, req.Query())
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
+	return
+}
+
+// Messages returns a list of message and state events for a room. It uses
+// pagination query parameters to paginate history in the room.
+// See https://spec.matrix.org/v1.12/client-server-api/#get_matrixclientv3roomsroomidmessages
+func (cli *Client) Messages(ctx context.Context, roomID id.RoomID, from, to string, dir Direction, filter *FilterPart, limit int) (resp *RespMessages, err error) {
+	query := map[string]string{
+		"dir": string(dir),
+	}
+	if filter != nil {
+		filterJSON, err := json.Marshal(filter)
+		if err != nil {
+			return nil, err
+		}
+		query["filter"] = string(filterJSON)
+	}
+	if from != "" {
+		query["from"] = from
+	}
+	if to != "" {
+		query["to"] = to
+	}
+	if limit != 0 {
+		query["limit"] = strconv.Itoa(limit)
+	}
+
+	urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "rooms", roomID, "messages"}, query)
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
+	return
+}
+
+// TimestampToEvent finds the ID of the event closest to the given timestamp.
+//
+// See https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv1roomsroomidtimestamp_to_event
+func (cli *Client) TimestampToEvent(ctx context.Context, roomID id.RoomID, timestamp time.Time, dir Direction) (resp *RespTimestampToEvent, err error) {
+	query := map[string]string{
+		"ts":  strconv.FormatInt(timestamp.UnixMilli(), 10),
+		"dir": string(dir),
+	}
+	urlPath := cli.BuildURLWithQuery(ClientURLPath{"v1", "rooms", roomID, "timestamp_to_event"}, query)
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
+	return
+}
+
+// Context returns a number of events that happened just before and after the
+// specified event. It use pagination query parameters to paginate history in
+// the room.
+// See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3roomsroomidcontexteventid
+func (cli *Client) Context(ctx context.Context, roomID id.RoomID, eventID id.EventID, filter *FilterPart, limit int) (resp *RespContext, err error) {
+	query := map[string]string{}
+	if filter != nil {
+		filterJSON, err := json.Marshal(filter)
+		if err != nil {
+			return nil, err
+		}
+		query["filter"] = string(filterJSON)
+	}
+	if limit != 0 {
+		query["limit"] = strconv.Itoa(limit)
+	}
+
+	urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "rooms", roomID, "context", eventID}, query)
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
+	return
+}
+
+func (cli *Client) GetEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (resp *event.Event, err error) {
+	urlPath := cli.BuildClientURL("v3", "rooms", roomID, "event", eventID)
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
+	return
+}
+
+func (cli *Client) MarkRead(ctx context.Context, roomID id.RoomID, eventID id.EventID) (err error) {
+	return cli.SendReceipt(ctx, roomID, eventID, event.ReceiptTypeRead, nil)
+}
+
+// MarkReadWithContent sends a read receipt including custom data.
+//
+// Deprecated: Use SendReceipt instead.
+func (cli *Client) MarkReadWithContent(ctx context.Context, roomID id.RoomID, eventID id.EventID, content interface{}) (err error) {
+	return cli.SendReceipt(ctx, roomID, eventID, event.ReceiptTypeRead, content)
+}
+
+// SendReceipt sends a receipt, usually specifically a read receipt.
+//
+// Passing nil as the content is safe, the library will automatically replace it with an empty JSON object.
+// To mark a message in a specific thread as read, use pass a ReqSendReceipt as the content.
+func (cli *Client) SendReceipt(ctx context.Context, roomID id.RoomID, eventID id.EventID, receiptType event.ReceiptType, content interface{}) (err error) {
+	urlPath := cli.BuildClientURL("v3", "rooms", roomID, "receipt", receiptType, eventID)
+	_, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, content, nil)
+	return
+}
+
+func (cli *Client) SetReadMarkers(ctx context.Context, roomID id.RoomID, content interface{}) (err error) {
+	urlPath := cli.BuildClientURL("v3", "rooms", roomID, "read_markers")
+	_, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, content, nil)
+	return
+}
+
+func (cli *Client) SetBeeperInboxState(ctx context.Context, roomID id.RoomID, content *ReqSetBeeperInboxState) (err error) {
+	urlPath := cli.BuildClientURL("unstable", "com.beeper.inbox", "user", cli.UserID, "rooms", roomID, "inbox_state")
+	_, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, content, nil)
+	return
+}
+
+func (cli *Client) AddTag(ctx context.Context, roomID id.RoomID, tag event.RoomTag, order float64) error {
+	return cli.AddTagWithCustomData(ctx, roomID, tag, &event.TagMetadata{
+		Order: json.Number(strconv.FormatFloat(order, 'e', -1, 64)),
+	})
+}
+
+func (cli *Client) AddTagWithCustomData(ctx context.Context, roomID id.RoomID, tag event.RoomTag, data any) (err error) {
+	urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "rooms", roomID, "tags", tag)
+	_, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, data, nil)
+	return
+}
+
+func (cli *Client) GetTags(ctx context.Context, roomID id.RoomID) (tags event.TagEventContent, err error) {
+	err = cli.GetTagsWithCustomData(ctx, roomID, &tags)
+	return
+}
+
+func (cli *Client) GetTagsWithCustomData(ctx context.Context, roomID id.RoomID, resp any) (err error) {
+	urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "rooms", roomID, "tags")
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
+	return
+}
+
+func (cli *Client) RemoveTag(ctx context.Context, roomID id.RoomID, tag event.RoomTag) (err error) {
+	urlPath := cli.BuildClientURL("v3", "user", cli.UserID, "rooms", roomID, "tags", tag)
+	_, err = cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, nil)
+	return
+}
+
+// Deprecated: Synapse may not handle setting m.tag directly properly, so you should use the Add/RemoveTag methods instead.
+func (cli *Client) SetTags(ctx context.Context, roomID id.RoomID, tags event.Tags) (err error) {
+	return cli.SetRoomAccountData(ctx, roomID, "m.tag", map[string]event.Tags{
+		"tags": tags,
+	})
+}
+
+// TurnServer returns turn server details and credentials for the client to use when initiating calls.
+// See https://spec.matrix.org/v1.2/client-server-api/#get_matrixclientv3voipturnserver
+func (cli *Client) TurnServer(ctx context.Context) (resp *RespTurnServer, err error) {
+	urlPath := cli.BuildClientURL("v3", "voip", "turnServer")
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
+	return
+}
+
+func (cli *Client) CreateAlias(ctx context.Context, alias id.RoomAlias, roomID id.RoomID) (resp *RespAliasCreate, err error) {
+	urlPath := cli.BuildClientURL("v3", "directory", "room", alias)
+	_, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, &ReqAliasCreate{RoomID: roomID}, &resp)
+	return
+}
+
+func (cli *Client) ResolveAlias(ctx context.Context, alias id.RoomAlias) (resp *RespAliasResolve, err error) {
+	urlPath := cli.BuildClientURL("v3", "directory", "room", alias)
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
+	return
+}
+
+func (cli *Client) DeleteAlias(ctx context.Context, alias id.RoomAlias) (resp *RespAliasDelete, err error) {
+	urlPath := cli.BuildClientURL("v3", "directory", "room", alias)
+	_, err = cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, &resp)
+	return
+}
+
+func (cli *Client) GetAliases(ctx context.Context, roomID id.RoomID) (resp *RespAliasList, err error) {
+	urlPath := cli.BuildClientURL("v3", "rooms", roomID, "aliases")
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
+	return
+}
+
+func (cli *Client) UploadKeys(ctx context.Context, req *ReqUploadKeys) (resp *RespUploadKeys, err error) {
+	urlPath := cli.BuildClientURL("v3", "keys", "upload")
+	_, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp)
+	return
+}
+
+func (cli *Client) QueryKeys(ctx context.Context, req *ReqQueryKeys) (resp *RespQueryKeys, err error) {
+	urlPath := cli.BuildClientURL("v3", "keys", "query")
+	_, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp)
+	return
+}
+
+func (cli *Client) ClaimKeys(ctx context.Context, req *ReqClaimKeys) (resp *RespClaimKeys, err error) {
+	urlPath := cli.BuildClientURL("v3", "keys", "claim")
+	_, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp)
+	return
+}
+
+func (cli *Client) GetKeyChanges(ctx context.Context, from, to string) (resp *RespKeyChanges, err error) {
+	urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "keys", "changes"}, map[string]string{
+		"from": from,
+		"to":   to,
+	})
+	_, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, nil, &resp)
+	return
+}
+
+// GetKeyBackup retrieves the keys from the backup.
+//
+// See: https://spec.matrix.org/v1.9/client-server-api/#get_matrixclientv3room_keyskeys
+func (cli *Client) GetKeyBackup(ctx context.Context, version id.KeyBackupVersion) (resp *RespRoomKeys[backup.EncryptedSessionData[backup.MegolmSessionData]], err error) {
+	urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys"}, map[string]string{
+		"version": string(version),
+	})
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
+	return
+}
+
+// PutKeysInBackup stores several keys in the backup.
+//
+// See: https://spec.matrix.org/v1.9/client-server-api/#put_matrixclientv3room_keyskeys
+func (cli *Client) PutKeysInBackup(ctx context.Context, version id.KeyBackupVersion, req *ReqKeyBackup) (resp *RespRoomKeysUpdate, err error) {
+	urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys"}, map[string]string{
+		"version": string(version),
+	})
+	_, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, req, &resp)
+	return
+}
+
+// DeleteKeyBackup deletes all keys from the backup.
+//
+// See: https://spec.matrix.org/v1.9/client-server-api/#delete_matrixclientv3room_keyskeys
+func (cli *Client) DeleteKeyBackup(ctx context.Context, version id.KeyBackupVersion) (resp *RespRoomKeysUpdate, err error) {
+	urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys"}, map[string]string{
+		"version": string(version),
+	})
+	_, err = cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, &resp)
+	return
+}
+
+// GetKeyBackupForRoom retrieves the keys from the backup for the given room.
+//
+// See: https://spec.matrix.org/v1.9/client-server-api/#get_matrixclientv3room_keyskeysroomid
+func (cli *Client) GetKeyBackupForRoom(
+	ctx context.Context, version id.KeyBackupVersion, roomID id.RoomID,
+) (resp *RespRoomKeyBackup[backup.EncryptedSessionData[backup.MegolmSessionData]], err error) {
+	urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys", roomID.String()}, map[string]string{
+		"version": string(version),
+	})
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
+	return
+}
+
+// PutKeysInBackupForRoom stores several keys in the backup for the given room.
+//
+// See: https://spec.matrix.org/v1.9/client-server-api/#put_matrixclientv3room_keyskeysroomid
+func (cli *Client) PutKeysInBackupForRoom(ctx context.Context, version id.KeyBackupVersion, roomID id.RoomID, req *ReqRoomKeyBackup) (resp *RespRoomKeysUpdate, err error) {
+	urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys", roomID.String()}, map[string]string{
+		"version": string(version),
+	})
+	_, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, req, &resp)
+	return
+}
+
+// DeleteKeysFromBackupForRoom deletes all the keys in the backup for the given
+// room.
+//
+// See: https://spec.matrix.org/v1.9/client-server-api/#delete_matrixclientv3room_keyskeysroomid
+func (cli *Client) DeleteKeysFromBackupForRoom(ctx context.Context, version id.KeyBackupVersion, roomID id.RoomID) (resp *RespRoomKeysUpdate, err error) {
+	urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys", roomID.String()}, map[string]string{
+		"version": string(version),
+	})
+	_, err = cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, &resp)
+	return
+}
+
+// GetKeyBackupForRoomAndSession retrieves a key from the backup.
+//
+// See: https://spec.matrix.org/v1.9/client-server-api/#get_matrixclientv3room_keyskeysroomidsessionid
+func (cli *Client) GetKeyBackupForRoomAndSession(
+	ctx context.Context, version id.KeyBackupVersion, roomID id.RoomID, sessionID id.SessionID,
+) (resp *RespKeyBackupData[backup.EncryptedSessionData[backup.MegolmSessionData]], err error) {
+	urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys", roomID.String(), sessionID.String()}, map[string]string{
+		"version": string(version),
+	})
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
+	return
+}
+
+// PutKeysInBackupForRoomAndSession stores a key in the backup.
+//
+// See: https://spec.matrix.org/v1.9/client-server-api/#put_matrixclientv3room_keyskeysroomidsessionid
+func (cli *Client) PutKeysInBackupForRoomAndSession(ctx context.Context, version id.KeyBackupVersion, roomID id.RoomID, sessionID id.SessionID, req *ReqKeyBackupData) (resp *RespRoomKeysUpdate, err error) {
+	urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys", roomID.String(), sessionID.String()}, map[string]string{
+		"version": string(version),
+	})
+	_, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, req, &resp)
+	return
+}
+
+// DeleteKeysInBackupForRoomAndSession deletes a key from the backup.
+//
+// See: https://spec.matrix.org/v1.9/client-server-api/#delete_matrixclientv3room_keyskeysroomidsessionid
+func (cli *Client) DeleteKeysInBackupForRoomAndSession(ctx context.Context, version id.KeyBackupVersion, roomID id.RoomID, sessionID id.SessionID) (resp *RespRoomKeysUpdate, err error) {
+	urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "room_keys", "keys", roomID.String(), sessionID.String()}, map[string]string{
+		"version": string(version),
+	})
+	_, err = cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, &resp)
+	return
+}
+
+// GetKeyBackupLatestVersion returns information about the latest backup version.
+//
+// See: https://spec.matrix.org/v1.9/client-server-api/#get_matrixclientv3room_keysversion
+func (cli *Client) GetKeyBackupLatestVersion(ctx context.Context) (resp *RespRoomKeysVersion[backup.MegolmAuthData], err error) {
+	urlPath := cli.BuildClientURL("v3", "room_keys", "version")
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
+	return
+}
+
+// CreateKeyBackupVersion creates a new key backup.
+//
+// See: https://spec.matrix.org/v1.9/client-server-api/#post_matrixclientv3room_keysversion
+func (cli *Client) CreateKeyBackupVersion(ctx context.Context, req *ReqRoomKeysVersionCreate[backup.MegolmAuthData]) (resp *RespRoomKeysVersionCreate, err error) {
+	urlPath := cli.BuildClientURL("v3", "room_keys", "version")
+	_, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp)
+	return
+}
+
+// GetKeyBackupVersion returns information about an existing key backup.
+//
+// See: https://spec.matrix.org/v1.9/client-server-api/#get_matrixclientv3room_keysversionversion
+func (cli *Client) GetKeyBackupVersion(ctx context.Context, version id.KeyBackupVersion) (resp *RespRoomKeysVersion[backup.MegolmAuthData], err error) {
+	urlPath := cli.BuildClientURL("v3", "room_keys", "version", version)
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
+	return
+}
+
+// UpdateKeyBackupVersion updates information about an existing key backup. Only
+// the auth_data can be modified.
+//
+// See: https://spec.matrix.org/v1.9/client-server-api/#put_matrixclientv3room_keysversionversion
+func (cli *Client) UpdateKeyBackupVersion(ctx context.Context, version id.KeyBackupVersion, req *ReqRoomKeysVersionUpdate[backup.MegolmAuthData]) error {
+	urlPath := cli.BuildClientURL("v3", "room_keys", "version", version)
+	_, err := cli.MakeRequest(ctx, http.MethodPut, urlPath, nil, nil)
+	return err
+}
+
+// DeleteKeyBackupVersion deletes an existing key backup. Both the information
+// about the backup, as well as all key data related to the backup will be
+// deleted.
+//
+// See: https://spec.matrix.org/v1.1/client-server-api/#delete_matrixclientv3room_keysversionversion
+func (cli *Client) DeleteKeyBackupVersion(ctx context.Context, version id.KeyBackupVersion) error {
+	urlPath := cli.BuildClientURL("v3", "room_keys", "version", version)
+	_, err := cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, nil)
+	return err
+}
+
+func (cli *Client) SendToDevice(ctx context.Context, eventType event.Type, req *ReqSendToDevice) (resp *RespSendToDevice, err error) {
+	urlPath := cli.BuildClientURL("v3", "sendToDevice", eventType.String(), cli.TxnID())
+	_, err = cli.MakeRequest(ctx, http.MethodPut, urlPath, req, &resp)
+	return
+}
+
+func (cli *Client) GetDevicesInfo(ctx context.Context) (resp *RespDevicesInfo, err error) {
+	urlPath := cli.BuildClientURL("v3", "devices")
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
+	return
+}
+
+func (cli *Client) GetDeviceInfo(ctx context.Context, deviceID id.DeviceID) (resp *RespDeviceInfo, err error) {
+	urlPath := cli.BuildClientURL("v3", "devices", deviceID)
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
+	return
+}
+
+func (cli *Client) SetDeviceInfo(ctx context.Context, deviceID id.DeviceID, req *ReqDeviceInfo) error {
+	urlPath := cli.BuildClientURL("v3", "devices", deviceID)
+	_, err := cli.MakeRequest(ctx, http.MethodPut, urlPath, req, nil)
+	return err
+}
+
+func (cli *Client) DeleteDevice(ctx context.Context, deviceID id.DeviceID, req *ReqDeleteDevice) error {
+	urlPath := cli.BuildClientURL("v3", "devices", deviceID)
+	_, err := cli.MakeRequest(ctx, http.MethodDelete, urlPath, req, nil)
+	return err
+}
+
+func (cli *Client) DeleteDevices(ctx context.Context, req *ReqDeleteDevices) error {
+	urlPath := cli.BuildClientURL("v3", "delete_devices")
+	_, err := cli.MakeRequest(ctx, http.MethodDelete, urlPath, req, nil)
+	return err
+}
+
+type UIACallback = func(*RespUserInteractive) interface{}
+
+// UploadCrossSigningKeys uploads the given cross-signing keys to the server.
+// Because the endpoint requires user-interactive authentication a callback must be provided that,
+// given the UI auth parameters, produces the required result (or nil to end the flow).
+func (cli *Client) UploadCrossSigningKeys(ctx context.Context, keys *UploadCrossSigningKeysReq, uiaCallback UIACallback) error {
+	content, err := cli.MakeFullRequest(ctx, FullRequest{
+		Method:           http.MethodPost,
+		URL:              cli.BuildClientURL("v3", "keys", "device_signing", "upload"),
+		RequestJSON:      keys,
+		SensitiveContent: keys.Auth != nil,
+	})
+	if respErr, ok := err.(HTTPError); ok && respErr.IsStatus(http.StatusUnauthorized) && uiaCallback != nil {
+		// try again with UI auth
+		var uiAuthResp RespUserInteractive
+		if err := json.Unmarshal(content, &uiAuthResp); err != nil {
+			return fmt.Errorf("failed to decode UIA response: %w", err)
+		}
+		auth := uiaCallback(&uiAuthResp)
+		if auth != nil {
+			keys.Auth = auth
+			return cli.UploadCrossSigningKeys(ctx, keys, nil)
+		}
+	}
+	return err
+}
+
+func (cli *Client) UploadSignatures(ctx context.Context, req *ReqUploadSignatures) (resp *RespUploadSignatures, err error) {
+	urlPath := cli.BuildClientURL("v3", "keys", "signatures", "upload")
+	_, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp)
+	return
+}
+
+// GetPushRules returns the push notification rules for the global scope.
+func (cli *Client) GetPushRules(ctx context.Context) (*pushrules.PushRuleset, error) {
+	return cli.GetScopedPushRules(ctx, "global")
+}
+
+// GetScopedPushRules returns the push notification rules for the given scope.
+func (cli *Client) GetScopedPushRules(ctx context.Context, scope string) (resp *pushrules.PushRuleset, err error) {
+	u, _ := url.Parse(cli.BuildClientURL("v3", "pushrules", scope))
+	// client.BuildURL returns the URL without a trailing slash, but the pushrules endpoint requires the slash.
+	u.Path += "/"
+	_, err = cli.MakeRequest(ctx, http.MethodGet, u.String(), nil, &resp)
+	return
+}
+
+func (cli *Client) GetPushRule(ctx context.Context, scope string, kind pushrules.PushRuleType, ruleID string) (resp *pushrules.PushRule, err error) {
+	urlPath := cli.BuildClientURL("v3", "pushrules", scope, kind, ruleID)
+	_, err = cli.MakeRequest(ctx, http.MethodGet, urlPath, nil, &resp)
+	if resp != nil {
+		resp.Type = kind
+	}
+	return
+}
+
+func (cli *Client) DeletePushRule(ctx context.Context, scope string, kind pushrules.PushRuleType, ruleID string) error {
+	urlPath := cli.BuildClientURL("v3", "pushrules", scope, kind, ruleID)
+	_, err := cli.MakeRequest(ctx, http.MethodDelete, urlPath, nil, nil)
+	return err
+}
+
+func (cli *Client) PutPushRule(ctx context.Context, scope string, kind pushrules.PushRuleType, ruleID string, req *ReqPutPushRule) error {
+	query := make(map[string]string)
+	if len(req.After) > 0 {
+		query["after"] = req.After
+	}
+	if len(req.Before) > 0 {
+		query["before"] = req.Before
+	}
+	urlPath := cli.BuildURLWithQuery(ClientURLPath{"v3", "pushrules", scope, kind, ruleID}, query)
+	_, err := cli.MakeRequest(ctx, http.MethodPut, urlPath, req, nil)
+	return err
+}
+
+func (cli *Client) ReportEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID, reason string) error {
+	urlPath := cli.BuildClientURL("v3", "rooms", roomID, "report", eventID)
+	_, err := cli.MakeRequest(ctx, http.MethodPost, urlPath, &ReqReport{Reason: reason, Score: -100}, nil)
+	return err
+}
+
+func (cli *Client) ReportRoom(ctx context.Context, roomID id.RoomID, reason string) error {
+	urlPath := cli.BuildClientURL("v3", "rooms", roomID, "report")
+	_, err := cli.MakeRequest(ctx, http.MethodPost, urlPath, &ReqReport{Reason: reason, Score: -100}, nil)
+	return err
+}
+
+// BatchSend sends a batch of historical events into a room. This is only available for appservices.
+//
+// Deprecated: MSC2716 has been abandoned, so this is now Beeper-specific. BeeperBatchSend should be used instead.
+func (cli *Client) BatchSend(ctx context.Context, roomID id.RoomID, req *ReqBatchSend) (resp *RespBatchSend, err error) {
+	path := ClientURLPath{"unstable", "org.matrix.msc2716", "rooms", roomID, "batch_send"}
+	query := map[string]string{
+		"prev_event_id": req.PrevEventID.String(),
+	}
+	if req.BeeperNewMessages {
+		query["com.beeper.new_messages"] = "true"
+	}
+	if req.BeeperMarkReadBy != "" {
+		query["com.beeper.mark_read_by"] = req.BeeperMarkReadBy.String()
+	}
+	if len(req.BatchID) > 0 {
+		query["batch_id"] = req.BatchID.String()
+	}
+	_, err = cli.MakeRequest(ctx, http.MethodPost, cli.BuildURLWithQuery(path, query), req, &resp)
+	return
+}
+
+func (cli *Client) AppservicePing(ctx context.Context, id, txnID string) (resp *RespAppservicePing, err error) {
+	_, err = cli.MakeFullRequest(ctx, FullRequest{
+		Method:       http.MethodPost,
+		URL:          cli.BuildClientURL("v1", "appservice", id, "ping"),
+		RequestJSON:  &ReqAppservicePing{TxnID: txnID},
+		ResponseJSON: &resp,
+		// This endpoint intentionally returns 50x, so don't retry
+		MaxAttempts: 1,
+	})
+	return
+}
+
+func (cli *Client) BeeperBatchSend(ctx context.Context, roomID id.RoomID, req *ReqBeeperBatchSend) (resp *RespBeeperBatchSend, err error) {
+	u := cli.BuildClientURL("unstable", "com.beeper.backfill", "rooms", roomID, "batch_send")
+	_, err = cli.MakeRequest(ctx, http.MethodPost, u, req, &resp)
+	return
+}
+
+func (cli *Client) BeeperMergeRooms(ctx context.Context, req *ReqBeeperMergeRoom) (resp *RespBeeperMergeRoom, err error) {
+	urlPath := cli.BuildClientURL("unstable", "com.beeper.chatmerging", "merge")
+	_, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp)
+	return
+}
+
+func (cli *Client) BeeperSplitRoom(ctx context.Context, req *ReqBeeperSplitRoom) (resp *RespBeeperSplitRoom, err error) {
+	urlPath := cli.BuildClientURL("unstable", "com.beeper.chatmerging", "rooms", req.RoomID, "split")
+	_, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, req, &resp)
+	return
+}
+func (cli *Client) BeeperDeleteRoom(ctx context.Context, roomID id.RoomID) (err error) {
+	urlPath := cli.BuildClientURL("unstable", "com.beeper.yeet", "rooms", roomID, "delete")
+	_, err = cli.MakeRequest(ctx, http.MethodPost, urlPath, nil, nil)
+	return
+}
+
+// TxnID returns the next transaction ID.
+func (cli *Client) TxnID() string {
+	txnID := atomic.AddInt32(&cli.txnID, 1)
+	return fmt.Sprintf("mautrix-go_%d_%d", time.Now().UnixNano(), txnID)
+}
+
+// NewClient creates a new Matrix Client ready for syncing
+func NewClient(homeserverURL string, userID id.UserID, accessToken string) (*Client, error) {
+	hsURL, err := ParseAndNormalizeBaseURL(homeserverURL)
+	if err != nil {
+		return nil, err
+	}
+	return &Client{
+		AccessToken:   accessToken,
+		UserAgent:     DefaultUserAgent,
+		HomeserverURL: hsURL,
+		UserID:        userID,
+		Client:        &http.Client{Timeout: 180 * time.Second},
+		Syncer:        NewDefaultSyncer(),
+		Log:           zerolog.Nop(),
+		// By default, use an in-memory store which will never save filter ids / next batch tokens to disk.
+		// The client will work with this storer: it just won't remember across restarts.
+		// In practice, a database backend should be used.
+		Store: NewMemorySyncStore(),
+	}, nil
+}