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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
module github.com/df-mc/go-xsapi

go 1.22.0
go 1.23.0

require (
github.com/coder/websocket v1.8.12
github.com/google/uuid v1.6.0
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
7 changes: 7 additions & 0 deletions internal/attr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package internal

import "log/slog"

const errorKey = "error"

func ErrAttr(err error) slog.Attr { return slog.Any(errorKey, err) }
22 changes: 22 additions & 0 deletions internal/transport.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package internal

import (
"github.com/df-mc/go-xsapi"
"net/http"
)

func SetTransport(client *http.Client, src xsapi.TokenSource) {
var (
hasTransport bool
base = client.Transport
)
if base != nil {
_, hasTransport = base.(*xsapi.Transport)
}
if !hasTransport {
client.Transport = &xsapi.Transport{
Source: src,
Base: base,
}
}
}
158 changes: 158 additions & 0 deletions mpsd/activity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package mpsd

import (
"bytes"
"context"
"encoding/json"
"fmt"
"github.com/df-mc/go-xsapi"
"github.com/df-mc/go-xsapi/internal"
"github.com/google/uuid"
"net/http"
"net/url"
"strconv"
"time"
)

// An ActivityFilter specifies a filter applied for searching activities on [ActivityFilter.Search]
type ActivityFilter struct {
// Client is a [http.Client] to be used to do HTTP requests. If nil, http.DefaultClient will be copied.
Client *http.Client

// SocialGroup specifies a group that contains handles of activities.
SocialGroup string
// SocialGroupXUID references a user that does searching on specific SocialGroup.
SocialGroupXUID string
}

func (f ActivityFilter) Search(src xsapi.TokenSource, serviceConfigID uuid.UUID) ([]ActivityHandle, error) {
if f.Client == nil {
f.Client = new(http.Client)
*f.Client = *http.DefaultClient
}
internal.SetTransport(f.Client, src)

owners := make(map[string]any)
if f.SocialGroup != "" {
if f.SocialGroupXUID == "" {
tok, err := src.Token()
if err != nil {
return nil, fmt.Errorf("request token: %w", err)
}
f.SocialGroupXUID = tok.DisplayClaims().XUID
}
owners["people"] = map[string]any{
"moniker": f.SocialGroup,
"monikerXuid": f.SocialGroupXUID,
}
}

buf := &bytes.Buffer{}
if err := json.NewEncoder(buf).Encode(map[string]any{
"type": "activity",
"scid": serviceConfigID,
"owners": owners,
}); err != nil {
return nil, fmt.Errorf("encode request body: %w", err)
}
req, err := http.NewRequest(http.MethodPost, searchURL.String(), buf)
if err != nil {
return nil, fmt.Errorf("make request: %w", err)
}
req.Header.Set("X-Xbl-Contract-Version", strconv.Itoa(contractVersion))

resp, err := f.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
var data struct {
Results []ActivityHandle `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, fmt.Errorf("decode response body: %w", err)
}
return data.Results, nil
default:
return nil, fmt.Errorf("%s %s: %s", req.Method, req.URL, resp.Status)
}
}

func (conf PublishConfig) commitActivity(ctx context.Context, ref SessionReference) error {
buf := &bytes.Buffer{}
if err := json.NewEncoder(buf).Encode(activityHandle{
Type: "activity",
SessionReference: ref,
Version: 1,
}); err != nil {
return fmt.Errorf("encode request body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, handlesURL.String(), buf)
if err != nil {
return fmt.Errorf("make request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Xbl-Contract-Version", strconv.Itoa(contractVersion))

resp, err := conf.Client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK, http.StatusCreated:
return nil
default:
return fmt.Errorf("%s %s: %s", req.Method, req.URL, resp.Status)
}
}

var (
handlesURL = &url.URL{
Scheme: "https",
Host: "sessiondirectory.xboxlive.com",
Path: "/handles",
}

searchURL = &url.URL{
Scheme: "https",
Host: "sessiondirectory.xboxlive.com",
Path: "/handles/query",
RawQuery: url.Values{
"include": []string{"relatedInfo,customProperties"},
}.Encode(),
}
)

type activityHandle struct {
Type string `json:"type"` // Always "activity".
SessionReference SessionReference `json:"sessionRef,omitempty"`
Version int `json:"version"` // Always 1.
OwnerXUID string `json:"ownerXuid,omitempty"`
}

type ActivityHandle struct {
activityHandle
CreateTime time.Time `json:"createTime,omitempty"`
CustomProperties json.RawMessage `json:"customProperties,omitempty"`
GameTypes json.RawMessage `json:"gameTypes,omitempty"`
ID uuid.UUID `json:"id,omitempty"`
InviteProtocol string `json:"inviteProtocol,omitempty"`
RelatedInfo *ActivityHandleRelatedInfo `json:"relatedInfo,omitempty"`
TitleID string `json:"titleId,omitempty"`
}

type ActivityHandleRelatedInfo struct {
Closed bool `json:"closed,omitempty"`
InviteProtocol string `json:"inviteProtocol,omitempty"`
JoinRestriction string `json:"joinRestriction,omitempty"`
MaxMembersCount uint32 `json:"maxMembersCount,omitempty"`
PostedTime time.Time `json:"postedTime,omitempty"`
Visibility string `json:"visibility,omitempty"`
}

const (
SocialGroupPeople = "people"
)
89 changes: 89 additions & 0 deletions mpsd/commit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package mpsd

import (
"bytes"
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"net/http"
"net/url"
"path"
"strconv"
"time"
)

// Commit pushes a [SessionDescription] into the session, updating properties and other fields
// on the service.
func (s *Session) Commit(ctx context.Context, d *SessionDescription) (*Commit, error) {
return s.conf.commit(ctx, s.ref.URL(), d)
}

// commit puts a [SessionDescription] on the URL. It is used for creating and updating the description
// of the Session.
func (conf PublishConfig) commit(ctx context.Context, u *url.URL, d *SessionDescription) (*Commit, error) {
buf := &bytes.Buffer{}
if err := json.NewEncoder(buf).Encode(d); err != nil {
return nil, fmt.Errorf("encode request body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, u.String(), buf)
if err != nil {
return nil, fmt.Errorf("make request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Xbl-Contract-Version", strconv.Itoa(contractVersion))

resp, err := conf.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK, http.StatusCreated:
var commitment *Commit
if err := json.NewDecoder(resp.Body).Decode(&commitment); err != nil {
return nil, fmt.Errorf("decode response body: %w", err)
}
return commitment, nil
case http.StatusNoContent:
return nil, nil
default:
return nil, fmt.Errorf("%s %s: %s", req.Method, req.URL, resp.Status)
}
}

// A SessionReference contains a reference to a Session.
type SessionReference struct {
ServiceConfigID uuid.UUID `json:"scid,omitempty"`
TemplateName string `json:"templateName,omitempty"`
Name string `json:"name,omitempty"`
}

// URL returns the [url.URL] of the session referenced in SessionReference.
func (ref SessionReference) URL() *url.URL {
return &url.URL{
Scheme: "https",
Host: "sessiondirectory.xboxlive.com",
Path: path.Join(
"/serviceconfigs/", ref.ServiceConfigID.String(),
"/sessionTemplates/", ref.TemplateName,
"/sessions/", ref.Name,
),
}
}

// Commit includes a [SessionDescription] returned as a response body from the service.
// It can be retrieved on [Session.Query], [Query], and [Session.Commit].
type Commit struct {
ContractVersion uint32 `json:"contractVersion,omitempty"`
CorrelationID uuid.UUID `json:"correlationId,omitempty"`
SearchHandle uuid.UUID `json:"searchHandle,omitempty"`
Branch uuid.UUID `json:"branch,omitempty"`
ChangeNumber uint64 `json:"changeNumber,omitempty"`
StartTime time.Time `json:"startTime,omitempty"`
NextTimer time.Time `json:"nextTimer,omitempty"`

*SessionDescription
}

const contractVersion = 107
31 changes: 31 additions & 0 deletions mpsd/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package mpsd

import "github.com/google/uuid"

// Handler notifies that a Session has been changed. It is called by the handler of
// *rta.Subscription contracted with *rta.Conn on [PublishConfig.PublishContext].
type Handler interface {
// HandleSessionChange handles a change of session. The latest state of Session can be
// retrieved via [Session.Query].
HandleSessionChange(ref SessionReference, branch uuid.UUID, changeNumber uint64)
}

// A NopHandler implements a no-op Handler, which does nothing.
type NopHandler struct{}

func (NopHandler) HandleSessionChange(SessionReference, uuid.UUID, uint64) {}

// Handle stores a Handler into the Session atomically, which notifies events that may occur
// in the *rta.Subscription of the Session. If Handler is a nil, a NopHandler will be stored
// instead.
func (s *Session) Handle(h Handler) {
if h == nil {
h = NopHandler{}
}
s.h.Store(&h)
}

// handler returns the Handler of the Session. It is usually called to handle events that may occur.
func (s *Session) handler() Handler {
return *s.h.Load()
}
68 changes: 68 additions & 0 deletions mpsd/invite.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package mpsd

import (
"bytes"
"encoding/json"
"fmt"
"github.com/google/uuid"
"net/http"
"strconv"
"time"
)

// Invite sends an invitation into the user referenced by XUID. The ID of the title which has sent
// an invitation is required to call this method. An InviteHandle may be returned.
func (s *Session) Invite(xuid string, titleID int32) (*InviteHandle, error) {
buf := &bytes.Buffer{}
if err := json.NewEncoder(buf).Encode(&inviteHandle{
Type: "invite",
SessionReference: s.ref,
Version: 1,
InvitedXUID: xuid,
InviteAttributes: map[string]any{
"titleId": strconv.FormatInt(int64(titleID), 10),
},
}); err != nil {
return nil, fmt.Errorf("encode request body: %w", err)
}
req, err := http.NewRequest(http.MethodPost, handlesURL.String(), buf)
if err != nil {
return nil, fmt.Errorf("make request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Xbl-Contract-Version", strconv.Itoa(contractVersion))

resp, err := s.conf.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusCreated:
// It seems the C++ implementation only decodes "id" field from the response.
var handle *InviteHandle
if err := json.NewDecoder(resp.Body).Decode(&handle); err != nil {
return nil, fmt.Errorf("decode response body: %w", err)
}
return handle, nil
default:
return nil, fmt.Errorf("%s %s: %s", req.Method, req.URL, resp.Status)
}
}

type inviteHandle struct {
Type string `json:"type,omitempty"` // Always "invite".
Version int `json:"version,omitempty"` // Always 1.
InviteAttributes map[string]any `json:"inviteAttributes,omitempty"`
InvitedXUID string `json:"invitedXuid,omitempty"`
SessionReference SessionReference `json:"sessionRef,omitempty"`
}

type InviteHandle struct {
inviteHandle
Expiration time.Time `json:"expiration,omitempty"`
ID uuid.UUID `json:"id,omitempty"`
InviteProtocol string `json:"inviteProtocol,omitempty"`
SenderXUID string `json:"senderXuid,omitempty"`
GameTypes json.RawMessage `json:"gameTypes,omitempty"`
}
Loading