Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
35 changes: 31 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,7 @@ The config exposes the same operator-facing areas as MCXboxBroadcast:
world type, and displayed MOTD data (joinability is always
`joinable_by_friends`, matching MCXboxBroadcast)
- gallery showcase image upload through `gallery.imagePath`
- friend sync automation and expiry settings, including last-seen history path
(stored as JSON at `friendSync.expiry.historyPath`, not Java's SQLite
database — operators migrating from MCXboxBroadcast start with a fresh
expiry history)
- friend sync automation and friend list cleanup (see below)
- Slack/Discord-compatible webhook notifications
- primary and sub-account token cache paths
- optional HTTP proxy URL through `http.proxy`
Expand All @@ -68,6 +65,36 @@ The config exposes the same operator-facing areas as MCXboxBroadcast:
- relay mode through `relay.enabled`, which keeps players inside the NetherNet
session instead of transferring them (see below).

### Friend list cleanup

Xbox allows an account at most 1000 friends. Once the list is full, new players
can't add the bot. `friendSync.cleanup` keeps room for them:

```yaml
friendSync:
cleanup:
inactiveDays: 15 # remove friends not seen for this many days; 0 = off
maxFriends: 950 # keep friends plus pending requests at or below this; 0 = off
interval: 1800 # seconds between inactive-friend checks
historyPath: cache/player_history.json
```

`maxFriends` is checked on every friend sync. When friends plus waiting friend
requests would go over it, the bot removes the friends it has seen least
recently to make exactly that much room, then accepts the waiting requests. It
never removes friends below `maxFriends`. A lower value leaves space for
requests that arrive between syncs. Removal ends the friendship
in both directions, so a removed player is not followed back. The bot's own
primary and sub-accounts are never removed.

"Seen" means the player's last join, or when the bot first tracked them as a
friend. Each account keeps its own history in the JSON file at `historyPath`.
This is not Java's SQLite database, so operators migrating from MCXboxBroadcast
start with a fresh history.

Configs from before `configVersion: 5` are migrated from `friendSync.expiry`.
They keep their inactivity setting, and `maxFriends` stays `0` until you set it.

### Session recovery

Signaling loss and repeated primary-session update failures share one recovery
Expand Down
104 changes: 69 additions & 35 deletions broadcaster.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,18 @@ func New(conf Config) (*Broadcaster, error) {
if err := conf.Relay.validate(); err != nil {
return nil, err
}
if conf.FriendSync != nil {
if err := conf.FriendSync.Cleanup.validate(); err != nil {
return nil, err
}
}
for _, account := range conf.SubAccounts {
if account.FriendSync != nil {
if err := account.FriendSync.Cleanup.validate(); err != nil {
return nil, fmt.Errorf("sub-account %q: %w", account.ID, err)
}
}
}
mode, err := normalizeSignalingMode(conf.SignalingMode)
if err != nil {
return nil, err
Expand Down Expand Up @@ -278,12 +290,14 @@ func (b *Broadcaster) Start(ctx context.Context) error {
go client.Run(b.ctx, b.log)
}
go b.uploadGalleryWithTimeout()
b.primeFriendHistory()
if b.conf.FriendSync != nil && hasSocialClient(b.conf.XBLClient) {
b.debug("starting friend sync",
"auto_follow", b.conf.FriendSync.AutoFollow,
"auto_unfollow", b.conf.FriendSync.AutoUnfollow,
"initial_invite", b.conf.FriendSync.InitialInvite,
"expiry_enabled", b.conf.FriendSync.ExpiryEnabled,
"cleanup_inactive_days", b.conf.FriendSync.Cleanup.InactiveDays,
"cleanup_max_friends", b.conf.FriendSync.Cleanup.MaxFriends,
)
syncer := b.friendSyncer()
syncer.Trigger = b.startSocialSubscription(b.conf.XBLClient, b.conf.FriendSync, b.log)
Expand Down Expand Up @@ -314,19 +328,30 @@ func (b *Broadcaster) enabledSubAccounts() (accounts []*SubAccountConfig, duplic
return accounts, duplicates
}

// subAccountFriendSyncActive reports whether any enabled sub-account runs a
// friend syncer sharing the primary's history store.
func (b *Broadcaster) subAccountFriendSyncActive() bool {
accounts, _ := b.enabledSubAccounts()
for _, account := range accounts {
if !subAccountHasXBLCredentials(*account) {
// primeFriendHistory reads every own account's history once before any syncer
// writes, so a history file from before per-account entries migrates to all
// of them rather than only to the first account that writes.
func (b *Broadcaster) primeFriendHistory() {
if b.conf.FriendHistory == nil {
return
}
for _, xuid := range b.ownXUIDs() {
if xuid == "" {
continue
}
if account.FriendSync != nil || b.conf.FriendSync != nil {
return true
if _, err := b.conf.FriendHistory.LastSeen(b.ctx, xuid); err != nil {
b.log.Error("read player history", "xuid", xuid, "err", err)
}
}
return false
}

// ownXUIDs returns the XUIDs of the primary and every configured sub-account.
func (b *Broadcaster) ownXUIDs() []string {
xuids := []string{b.primaryXUID()}
for _, account := range b.conf.SubAccounts {
xuids = append(xuids, accountXUID(account))
}
return xuids
}

// startSubAccountFriendSync runs a friend syncer per enabled sub-account so
Expand All @@ -346,13 +371,15 @@ func (b *Broadcaster) startSubAccountFriendSync() {
continue
}
syncLog := b.log.With("sub_account", account.ID)
syncer := FriendSyncer{
Client: b.friendClientFor(account.XBLClient),
Config: *conf,
History: b.conf.FriendHistory,
Notifier: b.conf.Notifier,
Trigger: b.startSocialSubscription(account.XBLClient, conf, syncLog),
Log: syncLog,
syncer := &FriendSyncer{
Client: b.friendClientFor(account.XBLClient),
Config: *conf,
History: b.conf.FriendHistory,
Account: accountXUID(*account),
OwnAccounts: b.ownXUIDs(),
Notifier: b.conf.Notifier,
Trigger: b.startSocialSubscription(account.XBLClient, conf, syncLog),
Log: syncLog,
}
if conf.InitialInvite {
syncer.Inviter = &subAccountInviter{b: b, id: account.ID}
Expand All @@ -363,24 +390,34 @@ func (b *Broadcaster) startSubAccountFriendSync() {
}

// logSocialSummary logs the authenticated account and its friend usage at
// startup, mirroring MCXboxBroadcast's "N/2000 friends" line. The count comes
// from the friend list like MCXboxBroadcast; the social summary's
// startup. The count comes from the friend list; the social summary's
// targetFollowingCount is unreliable for the caller's own profile.
func (b *Broadcaster) logSocialSummary() {
if !hasSocialClient(b.conf.XBLClient) {
return
}
ctx, cancel := context.WithTimeout(b.ctx, 15*time.Second)
defer cancel()
friends, err := b.friendClientFor(b.conf.XBLClient).Friends(ctx)
people, err := b.friendClientFor(b.conf.XBLClient).Friends(ctx)
if err != nil {
b.debug("fetch friend list for summary", "err", err)
return
}
// Xbox caps the people an account follows; followers are unlimited.
friends, followers := 0, 0
for _, p := range people {
if p.IsFollowedByCaller {
friends++
}
if p.IsFollowingCaller {
followers++
}
}
b.info("authenticated to xbox live",
"gamertag", b.hostNameFallback(),
"xuid", b.primaryXUID(),
"friends", fmt.Sprintf("%d/2000", len(friends)),
"friends", fmt.Sprintf("%d/%d", friends, XboxFriendLimit),
"followers", followers,
)
}

Expand Down Expand Up @@ -415,18 +452,15 @@ func (b *Broadcaster) presenceClients() []PresenceClient {
}

// friendSyncer creates a FriendSyncer from the broadcaster's current config.
func (b *Broadcaster) friendSyncer() FriendSyncer {
syncer := FriendSyncer{
Client: b.friendClientFor(b.conf.XBLClient),
Config: *b.conf.FriendSync,
History: b.conf.FriendHistory,
Notifier: b.conf.Notifier,
// Pruning compares the store against the primary's friend list only,
// so it must stay off while sub-account syncers share the store:
// people who only friended a sub-account would be pruned and re-seeded
// with a fresh expiry clock every pass.
PruneHistory: !b.subAccountFriendSyncActive(),
Log: b.log,
func (b *Broadcaster) friendSyncer() *FriendSyncer {
syncer := &FriendSyncer{
Client: b.friendClientFor(b.conf.XBLClient),
Config: *b.conf.FriendSync,
History: b.conf.FriendHistory,
Account: b.primaryXUID(),
OwnAccounts: b.ownXUIDs(),
Notifier: b.conf.Notifier,
Log: b.log,
}
if b.conf.FriendSync.InitialInvite {
syncer.Inviter = &broadcasterInviter{b: b}
Expand Down Expand Up @@ -1434,8 +1468,8 @@ func (b *Broadcaster) transfer(conn transferConn) {
b.log.Error("flush transfer", "xuid", id.XUID, "name", id.DisplayName, "err", err)
return
}
if recorder, ok := b.conf.FriendHistory.(HistoryRecorder); ok && id.XUID != "" {
if err := recorder.Seen(b.ctx, id.XUID, time.Now()); err != nil {
if b.conf.FriendHistory != nil && id.XUID != "" {
if err := b.conf.FriendHistory.Seen(b.ctx, id.XUID, time.Now()); err != nil {
b.log.Error("record player history", "xuid", id.XUID, "err", err)
}
}
Expand Down
33 changes: 21 additions & 12 deletions broadcaster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,22 +226,31 @@ func TestBroadcasterStartSubAccountsStopsQuietlyOnContextCancel(t *testing.T) {
}
}

func TestBroadcasterPrimarySyncerDisablesPruningWithSubAccountSyncers(t *testing.T) {
// Each syncer keys history by its own account and never removes any bot account.
func TestBroadcasterFriendSyncerKeysHistoryByAccount(t *testing.T) {
conf := Config{
XBLClient: &xsapi.Client{},
XUID: "100",
FriendSync: &FriendSyncConfig{AutoFollow: true, ExpiryEnabled: true},
XBLClient: &xsapi.Client{},
XUID: "100",
FriendSync: &FriendSyncConfig{AutoFollow: true, Cleanup: FriendCleanupConfig{InactiveDays: 15}},
SubAccounts: []SubAccountConfig{{ID: "sub", Enabled: true, XBLClient: &xsapi.Client{}, XUID: "200"}},
}

solo := &Broadcaster{log: testBroadcasterLogger(), conf: conf}
if !solo.friendSyncer().PruneHistory {
t.Fatal("primary syncer should prune when it owns the history store alone")
syncer := (&Broadcaster{log: testBroadcasterLogger(), conf: conf}).friendSyncer()
if syncer.Account != "100" {
t.Fatalf("primary syncer account = %q, want 100", syncer.Account)
}
if got := strings.Join(syncer.OwnAccounts, ","); got != "100,200" {
t.Fatalf("own accounts = %q, want 100,200", got)
}
}

conf.SubAccounts = []SubAccountConfig{{ID: "sub", Enabled: true, XBLClient: &xsapi.Client{}, XUID: "200"}}
shared := &Broadcaster{log: testBroadcasterLogger(), conf: conf}
if shared.friendSyncer().PruneHistory {
t.Fatal("primary syncer must not prune a history store shared with sub-account syncers")
func TestNewRejectsFriendLimitAboveXboxCap(t *testing.T) {
_, err := New(Config{
XBLTokenSource: staticTokenSource{},
Server: ServerInfo{Host: "127.0.0.1", Port: 19132},
FriendSync: &FriendSyncConfig{Cleanup: FriendCleanupConfig{MaxFriends: XboxFriendLimit + 1}},
})
if err == nil {
t.Fatal("expected maxFriends above the Xbox friend limit to be rejected")
}
}

Expand Down
15 changes: 10 additions & 5 deletions config.example.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
configVersion: 4
configVersion: 5
debugMode: false
suppressSessionUpdateMessage: false

Expand Down Expand Up @@ -32,10 +32,15 @@ friendSync:
autoFollow: true
autoUnfollow: true
initialInvite: true
expiry:
enabled: true
days: 8
check: 1800
# Keeps room on the friend list (Xbox allows 1000). 0 turns a rule off.
cleanup:
# Remove friends not seen for this many days.
inactiveDays: 15
# Keep friends plus pending requests at or below this, removing the least
# recently seen friends first.
maxFriends: 950
# Seconds between inactive-friend checks.
interval: 1800
historyPath: cache/player_history.json

notifications:
Expand Down
33 changes: 29 additions & 4 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ type Config struct {
SuppressSessionUpdateMessage bool
// FriendSync controls optional follower/friend synchronization.
FriendSync *FriendSyncConfig
// FriendHistory records player activity for friend expiry.
// FriendHistory records when each account's friends were last seen, for
// FriendSync cleanup. Without it, cleanup is skipped.
FriendHistory HistoryStore
// SubAccounts contains additional accounts that publish independently owned
// MPSD sessions for the same NetherNet listener.
Expand Down Expand Up @@ -171,9 +172,33 @@ type FriendSyncConfig struct {
AutoFollow bool
AutoUnfollow bool
InitialInvite bool
ExpiryEnabled bool
ExpiryDays int
ExpiryCheck time.Duration
Cleanup FriendCleanupConfig
}

// FriendCleanupConfig removes friends so the list keeps room for new players.
// Each rule is off at zero, and the two can be combined.
type FriendCleanupConfig struct {
// InactiveDays removes friends not seen for this many days.
InactiveDays int
// MaxFriends keeps friends plus pending friend requests at or below this
// count by removing the least recently seen friends. At most XboxFriendLimit.
MaxFriends int
// Interval spaces inactive-friend checks. MaxFriends is enforced on every sync.
Interval time.Duration
}

func (c FriendCleanupConfig) enabled() bool {
return c.InactiveDays > 0 || c.MaxFriends > 0
}

func (c FriendCleanupConfig) validate() error {
if c.InactiveDays < 0 {
return fmt.Errorf("friend cleanup inactive days must not be negative (got %d)", c.InactiveDays)
}
if c.MaxFriends < 0 || c.MaxFriends > XboxFriendLimit {
return fmt.Errorf("friend cleanup max friends must be between 0 and %d (got %d)", XboxFriendLimit, c.MaxFriends)
}
return nil
}

type SubAccountConfig struct {
Expand Down
Loading
Loading