mautrix-go/client.go

2302 lines
87 KiB
Go

// 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"
"strconv"
"sync/atomic"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/retryafter"
"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(context.Context) error
StartVerification(ctx context.Context, to id.UserID) (id.VerificationTransactionID, error)
StartInRoomVerification(ctx context.Context, roomID id.RoomID, to id.UserID) (id.VerificationTransactionID, error)
AcceptVerification(ctx context.Context, txnID id.VerificationTransactionID) error
CancelVerification(ctx context.Context, txnID id.VerificationTransactionID, code event.VerificationCancelCode, reason string) error
HandleScannedQRData(ctx context.Context, data []byte) error
ConfirmQRCodeScanned(ctx context.Context, txnID id.VerificationTransactionID) error
StartSAS(ctx context.Context, txnID id.VerificationTransactionID) error
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
Log zerolog.Logger
RequestHook func(req *http.Request)
ResponseHook func(req *http.Request, resp *http.Response, duration time.Duration)
SyncPresence event.Presence
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
// 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
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)
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
}
resSync, err := cli.FullSyncRequest(ctx, ReqSync{
Timeout: 30000,
Since: nextBatch,
FilterID: filterID,
FullState: false,
SetPresence: cli.SyncPresence,
StreamResponse: streamResp,
})
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
duration, err2 := cli.Syncer.OnFailedSync(resSync, err)
if err2 != nil {
return err2
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(duration):
continue
}
}
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 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 {
evt = zerolog.Ctx(req.Context()).Debug()
}
evt = evt.
Str("method", req.Method).
Str("url", req.URL.String()).
Dur("duration", duration)
if resp != nil {
if cli.ResponseHook != nil {
cli.ResponseHook(req, resp, duration)
}
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 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 interface{}, resBody interface{}) ([]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
SensitiveContent bool
Handler ClientResponseHandler
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)
} 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
}
// MakeFullRequest makes a JSON HTTP request to the given URL.
// If "resBody" is not nil, the response body will be json.Unmarshalled into it.
//
// Returns the HTTP body as bytes on 2xx with a nil error. Returns an error if the response is not 2xx along
// with the HTTP body bytes if it got that far. This error is an HTTPError which includes the returned
// HTTP status code and possibly a RespError as the WrappedError, if the HTTP body could be decoded as a RespError.
func (cli *Client) MakeFullRequest(ctx context.Context, params FullRequest) ([]byte, error) {
if params.MaxAttempts == 0 {
params.MaxAttempts = 1 + cli.DefaultHTTPRetries
}
if params.Logger == nil {
params.Logger = &cli.Log
}
req, err := params.compileRequest(ctx)
if err != nil {
return nil, err
}
if params.Handler == nil {
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, 4*time.Second, params.ResponseJSON, params.Handler, 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 interface{}, handler ClientResponseHandler, client *http.Client) ([]byte, error) {
log := zerolog.Ctx(req.Context())
if req.Body != nil {
if req.GetBody == nil {
log.Warn().Msg("Failed to get new body to retry request: GetBody is nil")
return nil, cause
}
var err error
req.Body, err = req.GetBody()
if err != nil {
log.Warn().Err(err).Msg("Failed to get new body to retry request")
return nil, cause
}
}
log.Warn().Err(cause).
Int("retry_in_seconds", int(backoff.Seconds())).
Msg("Request failed, retrying")
time.Sleep(backoff)
return cli.executeCompiledRequest(req, retries-1, backoff*2, responseJSON, handler, client)
}
func readRequestBody(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 handleNormalResponse(req *http.Request, res *http.Response, responseJSON interface{}) ([]byte, error) {
if contents, err := readRequestBody(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 := readRequestBody(req, res)
if err != nil {
return contents, err
}
respErr := &RespError{}
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 interface{}, handler ClientResponseHandler, client *http.Client) ([]byte, error) {
cli.RequestStart(req)
startTime := time.Now()
res, err := client.Do(req)
duration := time.Now().Sub(startTime)
if res != nil {
defer res.Body.Close()
}
if err != nil {
if retries > 0 {
return cli.doRetry(req, err, retries, backoff, responseJSON, handler, client)
}
err = HTTPError{
Request: req,
Response: res,
Message: "request error",
WrappedError: err,
}
cli.LogRequestDone(req, res, err, nil, 0, duration)
return nil, 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, 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, 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
}
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"
}
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,
// 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)
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 map[string]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 {
clearErr := cli.StateStore.ClearCachedMembers(ctx, roomID)
cli.cliOrContextLog(ctx).Warn().Err(clearErr).
Stringer("room_id", roomID).
Msg("Failed to clear cached member list after fetching state")
for _, evts := range stateMap {
for _, evt := range evts {
UpdateStateStore(ctx, cli.StateStore, evt)
}
}
}
return
}
// GetMediaConfig fetches the configuration of the content repository, such as upload limitations.
func (cli *Client) GetMediaConfig(ctx context.Context) (resp *RespMediaConfig, err error) {
u := cli.BuildURL(MediaURLPath{"v3", "config"})
_, err = cli.MakeRequest(ctx, http.MethodGet, u, 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) GetDownloadURL(mxcURL id.ContentURI) string {
return cli.BuildURLWithQuery(MediaURLPath{"v3", "download", mxcURL.Homeserver, mxcURL.FileID}, map[string]string{"allow_redirect": "true"})
}
func (cli *Client) Download(ctx context.Context, mxcURL id.ContentURI) (io.ReadCloser, error) {
resp, err := cli.download(ctx, mxcURL)
if err != nil {
return nil, err
}
return resp.Body, nil
}
func (cli *Client) doMediaRetry(req *http.Request, cause error, retries int, backoff time.Duration) (*http.Response, error) {
log := zerolog.Ctx(req.Context())
if req.Body != nil {
if req.GetBody == nil {
log.Warn().Msg("Failed to get new body to retry request: GetBody is nil")
return nil, cause
}
var err error
req.Body, err = req.GetBody()
if err != nil {
log.Warn().Err(err).Msg("Failed to get new body to retry request")
return nil, cause
}
}
log.Warn().Err(cause).
Int("retry_in_seconds", int(backoff.Seconds())).
Msg("Request failed, retrying")
time.Sleep(backoff)
return cli.doMediaRequest(req, retries-1, backoff*2)
}
func (cli *Client) doMediaRequest(req *http.Request, retries int, backoff time.Duration) (*http.Response, error) {
cli.RequestStart(req)
startTime := time.Now()
res, err := cli.Client.Do(req)
duration := time.Now().Sub(startTime)
if err != nil {
if retries > 0 {
return cli.doMediaRetry(req, err, retries, backoff)
}
err = HTTPError{
Request: req,
Response: res,
Message: "request error",
WrappedError: err,
}
cli.LogRequestDone(req, res, err, nil, 0, duration)
return nil, err
}
if retries > 0 && retryafter.Should(res.StatusCode, !cli.IgnoreRateLimit) {
backoff = retryafter.Parse(res.Header.Get("Retry-After"), backoff)
return cli.doMediaRetry(req, fmt.Errorf("HTTP %d", res.StatusCode), retries, backoff)
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
var body []byte
body, err = ParseErrorResponse(req, res)
cli.LogRequestDone(req, res, err, nil, len(body), duration)
} else {
cli.LogRequestDone(req, res, nil, nil, -1, duration)
}
return res, err
}
func (cli *Client) download(ctx context.Context, mxcURL id.ContentURI) (*http.Response, error) {
ctxLog := zerolog.Ctx(ctx)
if ctxLog.GetLevel() == zerolog.Disabled || ctxLog == zerolog.DefaultContextLogger {
ctx = cli.Log.WithContext(ctx)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cli.GetDownloadURL(mxcURL), nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", cli.UserAgent+" (media downloader)")
return cli.doMediaRequest(req, cli.DefaultHTTPRetries, 4*time.Second)
}
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)
}
// 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) (*RespCreateMXC, error) {
u, _ := url.Parse(cli.BuildURL(MediaURLPath{"v1", "create"}))
var m RespCreateMXC
_, err := cli.MakeFullRequest(ctx, FullRequest{
Method: http.MethodPost,
URL: u.String(),
ResponseJSON: &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 {
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
// 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) (*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.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
if data.ContentBytes == nil {
// Can't retry with a reader
retries = 0
}
for {
reader := data.Content
if reader == nil {
reader = bytes.NewReader(data.ContentBytes)
} else {
data.Content = nil
}
resp, err := cli.tryUploadMediaToURL(ctx, data.UnstableUploadURL, data.ContentType, reader)
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--
}
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.MakeFullRequest(ctx, FullRequest{
Method: http.MethodPost,
URL: notifyURL,
ResponseJSON: m,
})
if err != nil {
return nil, err
}
return m, 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.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(MediaURLPath{"v3", "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 {
clearErr := cli.StateStore.ClearCachedMembers(ctx, roomID, event.MembershipJoin)
if clearErr != nil {
cli.cliOrContextLog(ctx).Warn().Err(clearErr).
Stringer("room_id", roomID).
Msg("Failed to clear cached member list after fetching joined members")
}
for userID, member := range resp.Joined {
updateErr := cli.StateStore.SetMember(ctx, roomID, userID, &event.MemberEventContent{
Membership: event.MembershipJoin,
AvatarURL: id.ContentURIString(member.AvatarURL),
Displayname: member.DisplayName,
})
if updateErr != nil {
cli.cliOrContextLog(ctx).Warn().Err(updateErr).
Stringer("room_id", roomID).
Stringer("user_id", userID).
Msg("Failed to update membership 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 && cli.StateStore != nil {
var clearMemberships []event.Membership
if extra.Membership != "" {
clearMemberships = append(clearMemberships, extra.Membership)
}
if extra.NotMembership == "" {
clearErr := cli.StateStore.ClearCachedMembers(ctx, roomID, clearMemberships...)
if clearErr != nil {
cli.cliOrContextLog(ctx).Warn().Err(clearErr).
Stringer("room_id", roomID).
Msg("Failed to clear cached member list after fetching joined members")
}
}
for _, evt := range resp.Chunk {
UpdateStateStore(ctx, cli.StateStore, evt)
}
}
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.2/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{
"from": from,
"dir": string(dir),
}
if filter != nil {
filterJSON, err := json.Marshal(filter)
if err != nil {
return nil, err
}
query["filter"] = string(filterJSON)
}
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) AddTag(ctx context.Context, roomID id.RoomID, tag string, order float64) error {
var tagData event.Tag
if order == order {
tagData.Order = json.Number(strconv.FormatFloat(order, 'e', -1, 64))
}
return cli.AddTagWithCustomData(ctx, roomID, tag, tagData)
}
func (cli *Client) AddTagWithCustomData(ctx context.Context, roomID id.RoomID, tag string, data interface{}) (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 interface{}) (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 string) (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
}
// 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
}