Browse Source

implement user status message support

Mike 4 weeks ago
parent
commit
a13d216c0c

+ 26 - 0
foodgroup/helpers_test.go

@@ -4,9 +4,11 @@ import (
 	"context"
 	"net/mail"
 	"net/netip"
+	"testing"
 	"time"
 
 	"github.com/stretchr/testify/mock"
+	"github.com/stretchr/testify/require"
 
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
@@ -979,6 +981,30 @@ func sessOptBuddyIcon(icon wire.BARTID) func(instance *state.SessionInstance) {
 	}
 }
 
+// sessOptStatus sets the status message BART item on the session object.
+func sessOptStatus(status wire.BARTID) func(instance *state.SessionInstance) {
+	return func(instance *state.SessionInstance) {
+		instance.Session().SetStatus(status)
+	}
+}
+
+// newTestStatusBARTID builds the status message BART item a client sends, which
+// carries the text itself rather than a hash of it.
+func newTestStatusBARTID(t *testing.T, statusMsg string) wire.BARTID {
+	t.Helper()
+	var id wire.BARTID
+	require.NoError(t, id.SetStatusText(statusMsg))
+	return id
+}
+
+// statusMsgOf unpacks the text from a status message BART item.
+func statusMsgOf(t *testing.T, id wire.BARTID) string {
+	t.Helper()
+	msg, err := id.StatusText()
+	require.NoError(t, err)
+	return msg
+}
+
 // sessOptOfflineMsgCount sets the offline message count on the session object.
 func sessOptOfflineMsgCount(count int) func(instance *state.SessionInstance) {
 	return func(instance *state.SessionInstance) {

+ 19 - 7
foodgroup/locate.go

@@ -136,6 +136,11 @@ func (s LocateService) SetInfo(ctx context.Context, instance *state.SessionInsta
 		if len(b)%16 != 0 {
 			return errors.New("capability list must be array of 16-byte values")
 		}
+
+		// The mood is one of the capabilities, so it changed only if the list the
+		// client just sent names a different one than the list it replaces.
+		oldMood := instance.Mood()
+
 		var caps [][16]byte
 		for i := 0; i < len(b); i += 16 {
 			var c [16]byte
@@ -145,18 +150,25 @@ func (s LocateService) SetInfo(ctx context.Context, instance *state.SessionInsta
 			}
 			caps = append(caps, c)
 		}
+
 		instance.SetCaps(caps)
+
+		newMood := instance.Mood()
+		statusChanged := oldMood != newMood
+
 		if instance.SignonComplete() {
 			if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, instance.IdentScreenName(), instance.Session().TLVUserInfo()); err != nil {
 				return err
 			}
-			s.messageRelayer.RelayToSelf(ctx, instance, wire.SNACMessage{
-				Frame: wire.SNACFrame{
-					FoodGroup: wire.OService,
-					SubGroup:  wire.OServiceUserInfoUpdate,
-				},
-				Body: newOServiceUserInfoUpdate(instance),
-			})
+			if statusChanged {
+				s.messageRelayer.RelayToSelf(ctx, instance, wire.SNACMessage{
+					Frame: wire.SNACFrame{
+						FoodGroup: wire.OService,
+						SubGroup:  wire.OServiceUserInfoUpdate,
+					},
+					Body: newOServiceUserInfoUpdate(instance),
+				})
+			}
 		}
 	}
 

+ 105 - 0
foodgroup/locate_test.go

@@ -5,6 +5,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/google/uuid"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/require"
@@ -837,6 +838,110 @@ func TestLocateService_SetInfo_SetCaps(t *testing.T) {
 	assert.ElementsMatch(t, expect, instance.Session().Caps())
 }
 
+// A mood is one of the capabilities, and the client re-renders its own status from
+// the user info update the server relays back. Sending that update when the mood
+// did not change makes the client re-render on every capability write.
+func TestLocateService_SetInfo_MoodChange(t *testing.T) {
+	// chat: 748F2420-6287-11D1-8222-444553540000
+	chatCap := uuid.MustParse("748F2420-6287-11D1-8222-444553540000")
+
+	setCaps := func(caps ...uuid.UUID) wire.SNAC_0x02_0x04_LocateSetInfo {
+		var b []byte
+		for _, c := range caps {
+			b = append(b, c[:]...)
+		}
+		return wire.SNAC_0x02_0x04_LocateSetInfo{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{wire.NewTLVBE(wire.LocateTLVTagsInfoCapabilities, b)},
+			},
+		}
+	}
+
+	tests := []struct {
+		name string
+		// mood is the mood the instance already advertises, if any.
+		mood uuid.UUID
+		// signedOn reports whether sign-on completed, which gates both messages.
+		signedOn bool
+		inBody   wire.SNAC_0x02_0x04_LocateSetInfo
+		// wantRelay reports whether the user info update must reach the client.
+		wantRelay bool
+	}{
+		{
+			name:      "a new mood relays a user info update",
+			signedOn:  true,
+			inBody:    setCaps(chatCap, wire.CapXStatusPlate),
+			wantRelay: true,
+		},
+		{
+			name:      "a different mood relays a user info update",
+			mood:      wire.CapXStatusPlate,
+			signedOn:  true,
+			inBody:    setCaps(wire.CapXStatusBeer),
+			wantRelay: true,
+		},
+		{
+			name:      "clearing the mood relays a user info update",
+			mood:      wire.CapXStatusPlate,
+			signedOn:  true,
+			inBody:    setCaps(chatCap),
+			wantRelay: true,
+		},
+		{
+			name:     "the same mood relays nothing",
+			mood:     wire.CapXStatusPlate,
+			signedOn: true,
+			inBody:   setCaps(chatCap, wire.CapXStatusPlate),
+		},
+		{
+			name:     "a capability change with no mood relays nothing",
+			signedOn: true,
+			inBody:   setCaps(chatCap),
+		},
+		{
+			// Sign-on sends the user info itself, so nothing goes out before it.
+			name:   "a mood set before sign-on completes relays nothing",
+			inBody: setCaps(wire.CapXStatusPlate),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			opts := []func(*state.SessionInstance){}
+			if tt.signedOn {
+				opts = append(opts, sessOptSignonComplete)
+			}
+			instance := newTestInstance("screen-name", opts...)
+			if tt.mood != (uuid.UUID{}) {
+				instance.SetMood(tt.mood)
+			}
+
+			messageRelayer := newMockMessageRelayer(t)
+			if tt.wantRelay {
+				messageRelayer.EXPECT().
+					RelayToSelf(mock.Anything, instance, mock.MatchedBy(func(msg wire.SNACMessage) bool {
+						return msg.Frame.FoodGroup == wire.OService &&
+							msg.Frame.SubGroup == wire.OServiceUserInfoUpdate
+					})).
+					Once()
+			}
+
+			buddyBroadcaster := newMockbuddyBroadcaster(t)
+			if tt.signedOn {
+				buddyBroadcaster.EXPECT().
+					BroadcastBuddyArrived(mock.Anything, mock.Anything, mock.Anything).
+					Return(nil).
+					Once()
+			}
+
+			svc := NewLocateService(nil, messageRelayer, nil, nil, nil, nil)
+			svc.buddyBroadcaster = buddyBroadcaster
+
+			assert.NoError(t, svc.SetInfo(context.Background(), instance, tt.inBody))
+		})
+	}
+}
+
 func TestLocateService_RightsQuery(t *testing.T) {
 	messageRelayer := newMockMessageRelayer(t)
 	svc := NewLocateService(nil, messageRelayer, nil, nil, nil, nil)

+ 51 - 10
foodgroup/oservice.go

@@ -251,6 +251,8 @@ func (s OServiceService) UserInfoQuery(ctx context.Context, instance *state.Sess
 // presence changes to buddies. It relays an updated user info message back to the
 // instance that made the change.
 func (s OServiceService) SetUserInfoFields(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields) error {
+	var statusChanged bool
+
 	if status, hasStatus := inBody.Uint32BE(wire.OServiceUserInfoStatus); hasStatus {
 		instance.SetUserStatusBitmask(status)
 
@@ -264,6 +266,33 @@ func (s OServiceService) SetUserInfoFields(ctx context.Context, instance *state.
 			instance.ClearUserInfoFlag(wire.OServiceUserFlagUnavailable)
 		}
 
+		statusChanged = true
+	}
+
+	var statusMsgChanged bool
+	if b, hasBART := inBody.Bytes(wire.OServiceUserInfoBARTInfo); hasBART {
+		var ids []wire.BARTID
+		if err := wire.UnmarshalBE(&ids, bytes.NewReader(b)); err != nil {
+			return err
+		}
+		for _, id := range ids {
+			if id.Type == wire.BARTTypesStatusStr {
+				instance.Session().SetStatus(id)
+				statusMsgChanged = true
+				break
+			}
+		}
+	}
+
+	if dcBytes, hasDC := inBody.Bytes(wire.OServiceUserInfoICQDC); hasDC {
+		var dc wire.ICQDCInfo
+		if err := wire.UnmarshalBE(&dc, bytes.NewReader(dcBytes)); err != nil {
+			return err
+		}
+		instance.SetICQDCInfo(dc)
+	}
+
+	if statusChanged || statusMsgChanged {
 		if instance.Session().Invisible() {
 			if err := s.buddyBroadcaster.BroadcastBuddyDeparted(ctx, instance.IdentScreenName()); err != nil {
 				return err
@@ -275,12 +304,20 @@ func (s OServiceService) SetUserInfoFields(ctx context.Context, instance *state.
 		}
 	}
 
-	if dcBytes, hasDC := inBody.Bytes(wire.OServiceUserInfoICQDC); hasDC {
-		var dc wire.ICQDCInfo
-		if err := wire.UnmarshalBE(&dc, bytes.NewReader(dcBytes)); err != nil {
-			return err
-		}
-		instance.SetICQDCInfo(dc)
+	if statusMsgChanged {
+		// right now status messages are set session-wide and status flag is
+		// set per-instance. when the status message changes, inform concurrent
+		// logins. don't do this when only the status message changes.
+		s.messageRelayer.RelayToOtherInstances(ctx, instance, wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.OService,
+				SubGroup:  wire.OServiceUserInfoUpdate,
+				RequestID: wire.ReqIDFromServer,
+			},
+			Body: wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
+				UserInfo: []wire.TLVUserInfo{instance.Session().TLVUserInfo()},
+			},
+		})
 	}
 
 	// reflect the status of this instance back to the caller, even though
@@ -936,11 +973,15 @@ func newOServiceUserInfoUpdate(instance *state.SessionInstance) wire.SNAC_0x01_0
 			instanceInfo.Append(wire.NewTLVBE(wire.OServiceUserInfoStatus, statusBitmask))
 
 			if cur == instance {
+				var bartSet []wire.BARTID
 				if icon, hasIcon := cur.Session().BuddyIcon(); hasIcon {
-					// set buddy icon metadata, if user has buddy icon
-					if icon.Type != 0 {
-						instanceInfo.Append(wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, icon))
-					}
+					bartSet = append(bartSet, icon)
+				}
+				if status, hasStatus := cur.Session().Status(); hasStatus {
+					bartSet = append(bartSet, status)
+				}
+				if len(bartSet) > 0 {
+					instanceInfo.Append(wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, bartSet))
 				}
 			}
 

+ 353 - 1
foodgroup/oservice_test.go

@@ -1090,6 +1090,9 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 		mockParams mockParams
 		// checkSession validates the state of the session
 		checkSession func(*testing.T, *state.Session)
+		// notifiesOtherInstances reports whether the session's other instances get
+		// the update, which only a status message change triggers
+		notifiesOtherInstances bool
 	}{
 		{
 			name:     "set user status to visible aim < 6",
@@ -1402,6 +1405,279 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 				assert.NotZero(t, session.Instances()[0].UserInfoBitmask()&wire.OServiceUserFlagUnavailable)
 			},
 		},
+		{
+			name:                   "set status message",
+			notifiesOtherInstances: true,
+			instance:               newTestInstance("me"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, newTestStatusBARTID(t, "out to lunch")),
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceUserInfoUpdate,
+					RequestID: 1234,
+				},
+				Body: func(val any) bool {
+					snac, ok := val.(wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate)
+					if !ok || len(snac.UserInfo) == 0 {
+						return false
+					}
+					b, hasBART := snac.UserInfo[0].Bytes(wire.OServiceUserInfoBARTInfo)
+					if !hasBART {
+						return false
+					}
+					var got wire.BARTID
+					if err := wire.UnmarshalBE(&got, bytes.NewReader(b)); err != nil {
+						return false
+					}
+					return got.Type == wire.BARTTypesStatusStr
+				},
+			},
+			mockParams: mockParams{
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
+						{
+							screenName: state.DisplayScreenName("me"),
+						},
+					},
+				},
+			},
+			checkSession: func(t *testing.T, session *state.Session) {
+				status, hasStatus := session.Status()
+				assert.True(t, hasStatus)
+				assert.Equal(t, "out to lunch", statusMsgOf(t, status))
+			},
+		},
+		{
+			// An empty status message is how a status is cleared. It has to keep
+			// riding in the BART TLV: a client drops the status it shows for a buddy
+			// only when it is sent an empty one.
+			name:                   "clear status message",
+			notifiesOtherInstances: true,
+			instance:               newTestInstance("me", sessOptStatus(newTestStatusBARTID(t, "old"))),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, newTestStatusBARTID(t, "")),
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceUserInfoUpdate,
+					RequestID: 1234,
+				},
+				Body: func(val any) bool {
+					snac, ok := val.(wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate)
+					if !ok || len(snac.UserInfo) == 0 {
+						return false
+					}
+					b, hasBART := snac.UserInfo[0].Bytes(wire.OServiceUserInfoBARTInfo)
+					if !hasBART {
+						return false
+					}
+					var got wire.BARTID
+					if err := wire.UnmarshalBE(&got, bytes.NewReader(b)); err != nil {
+						return false
+					}
+					text, err := got.StatusText()
+					return err == nil && got.Type == wire.BARTTypesStatusStr && text == ""
+				},
+			},
+			mockParams: mockParams{
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
+						{
+							screenName: state.DisplayScreenName("me"),
+						},
+					},
+				},
+			},
+			checkSession: func(t *testing.T, session *state.Session) {
+				status, hasStatus := session.Status()
+				assert.True(t, hasStatus)
+				assert.Empty(t, statusMsgOf(t, status))
+			},
+		},
+		{
+			// Two changed fields, one arrival: the client would otherwise see the
+			// buddy blink twice.
+			name:                   "status bitmask and status message in one request",
+			notifiesOtherInstances: true,
+			instance:               newTestInstance("me"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.OServiceUserInfoStatus, wire.OServiceUserStatusBusy),
+							wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, newTestStatusBARTID(t, "on a call")),
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceUserInfoUpdate,
+					RequestID: 1234,
+				},
+				Body: func(val any) bool {
+					snac, ok := val.(wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate)
+					if !ok || len(snac.UserInfo) == 0 {
+						return false
+					}
+					status, hasStatus := snac.UserInfo[0].Uint32BE(wire.OServiceUserInfoStatus)
+					return hasStatus && status == wire.OServiceUserStatusBusy &&
+						snac.UserInfo[0].HasTag(wire.OServiceUserInfoBARTInfo)
+				},
+			},
+			mockParams: mockParams{
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
+						{
+							screenName: state.DisplayScreenName("me"),
+						},
+					},
+				},
+			},
+			checkSession: func(t *testing.T, session *state.Session) {
+				status, hasStatus := session.Status()
+				assert.True(t, hasStatus)
+				assert.Equal(t, "on a call", statusMsgOf(t, status))
+			},
+		},
+		{
+			// A client can set both in one request, and the status must survive the
+			// icon that precedes it in the list.
+			name:                   "set status message alongside a buddy icon",
+			notifiesOtherInstances: true,
+			instance:               newTestInstance("me"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, []wire.BARTID{
+								{
+									Type: wire.BARTTypesBuddyIcon,
+									BARTInfo: wire.BARTInfo{
+										Flags: wire.BARTFlagsData,
+										Hash:  []byte{0x01, 0x02, 0x03},
+									},
+								},
+								newTestStatusBARTID(t, "on a call"),
+							}),
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceUserInfoUpdate,
+					RequestID: 1234,
+				},
+				Body: func(val any) bool {
+					snac, ok := val.(wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate)
+					return ok && len(snac.UserInfo) > 0
+				},
+			},
+			mockParams: mockParams{
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
+						{
+							screenName: state.DisplayScreenName("me"),
+						},
+					},
+				},
+			},
+			checkSession: func(t *testing.T, session *state.Session) {
+				status, hasStatus := session.Status()
+				assert.True(t, hasStatus)
+				assert.Equal(t, "on a call", statusMsgOf(t, status))
+			},
+		},
+		{
+			// A buddy icon reference travels in the same TLV, and only the BART
+			// service stores those. Nothing changed here, so buddies hear nothing.
+			name:     "ignore a BART item that is not a status message",
+			instance: newTestInstance("me"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, wire.BARTID{
+								Type: wire.BARTTypesBuddyIcon,
+								BARTInfo: wire.BARTInfo{
+									Flags: wire.BARTFlagsData,
+									Hash:  []byte{0x01, 0x02, 0x03},
+								},
+							}),
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceUserInfoUpdate,
+					RequestID: 1234,
+				},
+				Body: func(val any) bool {
+					snac, ok := val.(wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate)
+					return ok && len(snac.UserInfo) > 0
+				},
+			},
+			checkSession: func(t *testing.T, session *state.Session) {
+				_, hasStatus := session.Status()
+				assert.False(t, hasStatus)
+			},
+		},
+		{
+			name:     "malformed BART item",
+			instance: newTestInstance("me"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, []byte{0x00}),
+						},
+					},
+				},
+			},
+			expectErr: io.ErrUnexpectedEOF,
+			checkSession: func(t *testing.T, session *state.Session) {
+				_, hasStatus := session.Status()
+				assert.False(t, hasStatus)
+			},
+		},
 		{
 			name:     "set ICQ direct connect info",
 			instance: newTestInstance("1000003", sessOptUserInfoFlag(wire.OServiceUserFlagICQ)),
@@ -1454,11 +1730,13 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			buddyUpdateBroadcaster := newMockbuddyBroadcaster(t)
 			for _, params := range tc.mockParams.broadcastBuddyArrivedParams {
+				// One entry is one broadcast, so a duplicate fails the test.
 				buddyUpdateBroadcaster.EXPECT().
 					BroadcastBuddyArrived(mock.Anything, state.NewIdentScreenName(params.screenName.String()), mock.MatchedBy(func(userInfo wire.TLVUserInfo) bool {
 						return userInfo.ScreenName == params.screenName.String()
 					})).
-					Return(params.err)
+					Return(params.err).
+					Once()
 			}
 			for _, params := range tc.mockParams.broadcastBuddyDepartedParams {
 				buddyUpdateBroadcaster.EXPECT().
@@ -1467,12 +1745,23 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 			}
 			messageRelayer := newMockMessageRelayer(t)
 			var relayedSNAC wire.SNACMessage
+			var otherInstanceSNAC *wire.SNACMessage
 			if tc.expectErr == nil {
 				messageRelayer.EXPECT().
 					RelayToSelf(mock.Anything, tc.instance, mock.Anything).
 					Run(func(ctx context.Context, instance *state.SessionInstance, msg wire.SNACMessage) {
 						relayedSNAC = msg
 					})
+				// No expectation otherwise: a relay the case does not call for is an
+				// unexpected call, which fails the test.
+				if tc.notifiesOtherInstances {
+					messageRelayer.EXPECT().
+						RelayToOtherInstances(mock.Anything, tc.instance, mock.Anything).
+						Run(func(ctx context.Context, instance *state.SessionInstance, msg wire.SNACMessage) {
+							otherInstanceSNAC = &msg
+						}).
+						Once()
+				}
 			}
 			svc := OServiceService{
 				cfg:              config.Config{},
@@ -1492,6 +1781,22 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 			} else {
 				assert.Equal(t, tc.expectOutput.Body, relayedSNAC.Body)
 			}
+
+			// A status message is session-wide, so the session's other instances get
+			// the update too, unsolicited, carrying the server request ID.
+			if tc.notifiesOtherInstances {
+				require.NotNil(t, otherInstanceSNAC, "the update must reach the session's other instances")
+				assert.Equal(t, wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceUserInfoUpdate,
+					RequestID: wire.ReqIDFromServer,
+				}, otherInstanceSNAC.Frame)
+				update, ok := otherInstanceSNAC.Body.(wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate)
+				require.True(t, ok)
+				require.Len(t, update.UserInfo, 1)
+				assert.Equal(t, tc.instance.DisplayScreenName().String(), update.UserInfo[0].ScreenName)
+			}
+
 			tc.checkSession(t, tc.instance.Session())
 		})
 	}
@@ -2440,6 +2745,53 @@ func TestNewOServiceUserInfoUpdate(t *testing.T) {
 		require.False(t, got.UserInfo[2].HasTag(wire.OServiceUserInfoSigTime))
 	})
 
+	t.Run("sends the buddy icon and the status message in one BART list", func(t *testing.T) {
+		icon := wire.BARTID{
+			Type: wire.BARTTypesBuddyIcon,
+			BARTInfo: wire.BARTInfo{
+				Flags: 1,
+				Hash:  []byte{0xAA, 0xBB, 0xCC},
+			},
+		}
+		status := newTestStatusBARTID(t, "at lunch")
+		session := newTestInstance("me",
+			sessOptSetFoodGroupVersion(wire.OService, 4),
+			sessOptBuddyIcon(icon),
+			sessOptStatus(status))
+
+		got := newOServiceUserInfoUpdate(session)
+
+		require.Len(t, got.UserInfo, 2)
+		b, hasBART := got.UserInfo[1].Bytes(wire.OServiceUserInfoBARTInfo)
+		require.True(t, hasBART)
+
+		var gotIDs []wire.BARTID
+		r := bytes.NewReader(b)
+		for r.Len() > 0 {
+			var id wire.BARTID
+			require.NoError(t, wire.UnmarshalBE(&id, r))
+			gotIDs = append(gotIDs, id)
+		}
+		require.Equal(t, []wire.BARTID{icon, status}, gotIDs)
+	})
+
+	t.Run("sends the status message without a buddy icon", func(t *testing.T) {
+		status := newTestStatusBARTID(t, "at lunch")
+		session := newTestInstance("me",
+			sessOptSetFoodGroupVersion(wire.OService, 4),
+			sessOptStatus(status))
+
+		got := newOServiceUserInfoUpdate(session)
+
+		require.Len(t, got.UserInfo, 2)
+		b, hasBART := got.UserInfo[1].Bytes(wire.OServiceUserInfoBARTInfo)
+		require.True(t, hasBART)
+
+		var gotID wire.BARTID
+		require.NoError(t, wire.UnmarshalBE(&gotID, bytes.NewReader(b)))
+		require.Equal(t, status, gotID)
+	})
+
 	t.Run("does not add buddy icon when icon type is zero", func(t *testing.T) {
 		session := newTestInstance("me",
 			sessOptSetFoodGroupVersion(wire.OService, 4),

+ 2 - 0
server/webapi/aim_handler.go

@@ -388,6 +388,7 @@ func (h *AimHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 
 	myMoodURL := moodIconURL(baseURL, "online", session.OSCARSession.Session().Caps())
 	myInfoPayload := buildMyInfo(screenName, "online", myIconURL, myMoodURL)
+	myInfoPayload.StatusMsg = sessionStatusMsg(session.OSCARSession)
 	myInfoPayload.OnlineTime = time.Now().Unix()
 	myInfoPayload.MemberSince = time.Now().Unix() - 86400*30 // 30 days ago
 	myInfoPayload.Self = &MyInfoSelf{
@@ -418,6 +419,7 @@ func (h *AimHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 	// client subscribes to both, which a per-subscription loop would queue twice.
 	if slices.Contains(events, "myInfo") || slices.Contains(events, "presence") {
 		myInfoData := buildMyInfo(screenName, "online", myIconURL, myMoodURL)
+		myInfoData.StatusMsg = sessionStatusMsg(session.OSCARSession)
 		myInfoData.OnlineTime = time.Now().Unix()
 		myInfoData.MemberSince = time.Now().Unix() - 86400*30 // 30 days ago
 		session.EventQueue.Push(EventTypeMyInfo, myInfoData)

+ 2 - 0
server/webapi/buddy_list_manager.go

@@ -229,6 +229,8 @@ func (m *BuddyListManager) getBuddyInfo(ctx context.Context, instance *state.Ses
 		info.OnlineTime = int64(tod)
 	}
 
+	info.StatusMsg = userStatusMsg(userInfo.TLVUserInfo)
+
 	if st := statusBitState(userInfo.TLVUserInfo, instance.IdentScreenName().UIN() == 0); st != "" {
 		info.State = st
 	} else if userInfo.IsAway() {

+ 54 - 0
server/webapi/buddy_list_manager_test.go

@@ -445,6 +445,60 @@ func TestBuddyListManager_GetBuddyListForUser_PublishesBuddyIcons(t *testing.T)
 		got[0].Buddies[2].BuddyIcon)
 }
 
+// The roster is where a client first reads a buddy's status message.
+func TestBuddyListManager_GetBuddyListForUser_PublishesStatusMessages(t *testing.T) {
+	ctx := context.Background()
+
+	fb := []wire.FeedbagItem{
+		{Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+			TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}}},
+		{Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+			TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1, 2})}}},
+		{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "hasstatus"},
+		{ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "nostatus"},
+	}
+
+	fs := newMockFeedbagService(t)
+	fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
+		wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil,
+	).Once()
+
+	withStatus := wire.TLVUserInfo{ScreenName: "hasstatus"}
+	withStatus.Append(wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, []wire.BARTID{testStatusBART}))
+
+	locateFor := func(name string) any {
+		return mock.MatchedBy(func(q wire.SNAC_0x02_0x05_LocateUserInfoQuery) bool { return q.ScreenName == name })
+	}
+	ls := newMockLocateService(t)
+	ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, locateFor("hasstatus")).
+		Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{TLVUserInfo: withStatus}}, nil).Once()
+	ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, locateFor("nostatus")).
+		Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
+			TLVUserInfo: wire.TLVUserInfo{ScreenName: "nostatus"},
+		}}, nil).Once()
+
+	iconRetriever := newMockBuddyIconRetriever(t)
+	iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).Return(nil, nil).Twice()
+
+	m := NewBuddyListManager(fs, ls, BuddyIconSource{
+		IconRetriever: iconRetriever,
+		Logger:        slog.Default(),
+	}, slog.Default())
+
+	sess := &Session{
+		ScreenName:   state.DisplayScreenName("listowner"),
+		OSCARSession: state.NewSession().AddInstance(),
+		BaseURL:      "http://api.example.com",
+	}
+	got, err := m.GetBuddyListForUser(ctx, sess)
+	require.NoError(t, err)
+	require.Len(t, got, 1)
+	require.Len(t, got[0].Buddies, 2)
+
+	assert.Equal(t, "brb", got[0].Buddies[0].StatusMsg)
+	assert.Empty(t, got[0].Buddies[1].StatusMsg)
+}
+
 // The feedbag service relays a session's own writes only to the owner's other
 // instances, so renaming a buddy from the web client produces no SNAC for that
 // session. Without an explicit invalidation, its cached aliases would keep serving

+ 0 - 4
server/webapi/im_handler.go

@@ -26,7 +26,6 @@ type MessagingHandler struct {
 func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request, sess *Session) {
 	ctx := r.Context()
 
-	// Parse parameters
 	recipient := param(r, "t")
 	if recipient == "" {
 		SendError(w, r, http.StatusBadRequest, "missing required parameter: t (recipient)")
@@ -39,7 +38,6 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request, sess *
 		return
 	}
 
-	// Parse optional parameters
 	autoResponse := param(r, "autoResponse") == "1"
 	// The client sets offlineIM once it believes the recipient is offline and
 	// storable; it sends the literal "true" rather than "1".
@@ -155,7 +153,6 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request, sess *
 		"eventType", EventTypeSentIM,
 	)
 
-	// Send success response
 	responseData := &SendIMData{MsgID: messageID, State: "delivered"}
 	SendOK(w, r, responseData, h.Logger)
 }
@@ -231,7 +228,6 @@ func (h *MessagingHandler) pushSenderWebAPIEvents(sess *Session, recipient state
 func (h *MessagingHandler) SetTyping(w http.ResponseWriter, r *http.Request, sess *Session) {
 	ctx := r.Context()
 
-	// Parse parameters
 	recipient := r.URL.Query().Get("t")
 	if recipient == "" {
 		SendError(w, r, http.StatusBadRequest, "missing required parameter: t (recipient)")

+ 0 - 3
server/webapi/preference_handler.go

@@ -261,7 +261,6 @@ func (h *PreferenceHandler) SetPreferences(w http.ResponseWriter, r *http.Reques
 		"prefCount", applied.Len(),
 	)
 
-	// Send success response
 	SendOK(w, r, applied, h.Logger)
 }
 
@@ -322,7 +321,6 @@ func (h *PreferenceHandler) GetPreferences(w http.ResponseWriter, r *http.Reques
 		)
 	}
 
-	// Send response in requested format
 	SendOK(w, r, payload, h.Logger)
 }
 
@@ -471,7 +469,6 @@ func (h *PreferenceHandler) SetPermitDeny(w http.ResponseWriter, r *http.Request
 
 	fl := state.NewFeedbagList(reply.Items, rand.Intn)
 
-	// Get pdMode parameter
 	pdModeStr := r.URL.Query().Get("pdMode")
 	if pdModeStr != "" {
 		switch pdModeStr { // todo: are the string ints possible inputs?

+ 30 - 13
server/webapi/presence_handler.go

@@ -2,6 +2,7 @@ package webapi
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"log/slog"
 	"net/http"
@@ -104,7 +105,6 @@ func (h *PresenceHandler) GetPresence(w http.ResponseWriter, r *http.Request, se
 	ctx := r.Context()
 	aimsid := session.AimSID
 
-	// Check if buddy list is requested
 	getBuddyList := r.URL.Query().Get("bl") == "1"
 	wantProfileMsg := r.URL.Query().Get("profileMsg") == "1"
 	// mdir asks for member-directory fields alongside presence.
@@ -156,7 +156,6 @@ func (h *PresenceHandler) GetPresence(w http.ResponseWriter, r *http.Request, se
 		presenceData.Users = []BuddyPresenceInfo{}
 	}
 
-	// Send response in requested format
 	SendOK(w, r, presenceData, h.Logger)
 
 	h.Logger.DebugContext(ctx, "presence retrieved",
@@ -199,7 +198,6 @@ func (h *PresenceHandler) directoryProfile(ctx context.Context, screenName strin
 
 // getBuddyListGroups retrieves the buddy list organized by groups.
 func (h *PresenceHandler) getBuddyListGroups(ctx context.Context, session *Session, wantProfileMsg bool) ([]BuddyGroupInfo, error) {
-	// Get feedbag items via the feedbag service
 	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
 	reply, err := h.FeedbagService.Query(ctx, session.OSCARSession, frame)
 	if err != nil {
@@ -349,6 +347,7 @@ func (h *PresenceHandler) getUserPresence(ctx context.Context, instance *state.S
 	}
 
 	presence.MoodIcon = moodIconURL(baseURL, presence.State, userInfoCaps(info.TLVUserInfo))
+	presence.StatusMsg = userStatusMsg(info.TLVUserInfo)
 
 	return presence
 }
@@ -434,13 +433,12 @@ func (h *PresenceHandler) SetState(w http.ResponseWriter, r *http.Request, sessi
 		"hasAwayMsg", awayMsg != "",
 	)
 
-	// Send success response
 	SendOK(w, r, &SetStateData{
 		AimID:      session.ScreenName.IdentScreenName().String(),
 		DisplayID:  session.ScreenName.String(),
 		State:      reportedState,
 		AwayMsg:    awayMsg,
-		StatusMsg:  "",
+		StatusMsg:  sessionStatusMsg(oscarSession),
 		UserType:   userTypeFor(session.ScreenName.IdentScreenName()),
 		OnlineTime: time.Now().Unix(),
 	}, h.Logger)
@@ -450,10 +448,36 @@ func (h *PresenceHandler) SetState(w http.ResponseWriter, r *http.Request, sessi
 func (h *PresenceHandler) SetStatus(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 
-	// Get the status message
 	statusMsg := r.URL.Query().Get("statusMsg")
 	statusCode := r.URL.Query().Get("statusCode")
 
+	if r.URL.Query().Has("statusMsg") {
+
+		var bid wire.BARTID
+		if err := bid.SetStatusText(statusMsg); err != nil {
+			if errors.Is(err, wire.ErrStatusTextSizeExceeded) {
+				SendError(w, r, http.StatusBadRequest, err.Error())
+			} else {
+				h.Logger.ErrorContext(ctx, "failed to marshal status message", "err", err.Error())
+				SendError(w, r, http.StatusInternalServerError, "failed to set status")
+			}
+			return
+		}
+
+		setFields := wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, bid),
+				},
+			},
+		}
+		if err := h.OServiceService.SetUserInfoFields(ctx, session.OSCARSession, wire.SNACFrame{}, setFields); err != nil {
+			h.Logger.ErrorContext(ctx, "failed to set user info fields", "err", err.Error())
+			SendError(w, r, http.StatusInternalServerError, "failed to set status")
+			return
+		}
+	}
+
 	if r.URL.Query().Has("mood") {
 		moodID := r.URL.Query().Get("mood")
 		if moodID == "" {
@@ -495,10 +519,8 @@ func (h *PresenceHandler) SetStatus(w http.ResponseWriter, r *http.Request, sess
 func (h *PresenceHandler) SetProfile(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 
-	// Get the profile content
 	profileText := r.URL.Query().Get("profile")
 
-	// Limit profile size (4KB max)
 	if len(profileText) > 4096 {
 		SendError(w, r, http.StatusBadRequest, "profile too large (max 4KB)")
 		return
@@ -525,7 +547,6 @@ func (h *PresenceHandler) SetProfile(w http.ResponseWriter, r *http.Request, ses
 		"profileSize", len(profileText),
 	)
 
-	// Send success response
 	SendOK(w, r, nil, h.Logger)
 }
 
@@ -533,13 +554,11 @@ func (h *PresenceHandler) SetProfile(w http.ResponseWriter, r *http.Request, ses
 func (h *PresenceHandler) GetProfile(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 
-	// Get target screen name (optional - defaults to self)
 	targetSN := r.URL.Query().Get("sn")
 	if targetSN == "" {
 		targetSN = session.ScreenName.String()
 	}
 
-	// Retrieve profile via OSCAR LocateService.
 	var profileText string
 	instance := session.OSCARSession
 	reply, err := h.LocateService.UserInfoQuery(ctx, instance, wire.SNACFrame{},
@@ -552,7 +571,6 @@ func (h *PresenceHandler) GetProfile(w http.ResponseWriter, r *http.Request, ses
 		}
 	}
 
-	// Send response
 	responseData := &ProfileData{ScreenName: targetSN, Profile: profileText}
 
 	SendOK(w, r, responseData, h.Logger)
@@ -560,7 +578,6 @@ func (h *PresenceHandler) GetProfile(w http.ResponseWriter, r *http.Request, ses
 
 // Icon handles GET /presence/icon requests for presence icons.
 func (h *PresenceHandler) Icon(w http.ResponseWriter, r *http.Request) {
-	// Get parameters
 	name := r.URL.Query().Get("name")
 	size := r.URL.Query().Get("size")
 	iconType := r.URL.Query().Get("type")

+ 226 - 3
server/webapi/presence_handler_test.go

@@ -1,6 +1,7 @@
 package webapi
 
 import (
+	"bytes"
 	"context"
 	"encoding/json"
 	"fmt"
@@ -255,6 +256,62 @@ func TestPresenceHandler_GetPresence_PublishesIconForOnlineBuddiesOnly(t *testin
 // each buddy under its own group using realistic feedbag data, where group rows
 // carry ItemID 0 and a distinct nonzero GroupID, and buddy rows reference those
 // GroupIDs. This is the shape the OSCAR feedbag actually stores.
+// A buddy's status message rides in the BART TLV, next to the icon reference.
+func TestPresenceHandler_GetPresence_PublishesStatusMessage(t *testing.T) {
+	ctx := context.Background()
+
+	withStatus := wire.TLVUserInfo{ScreenName: "hasstatus"}
+	withStatus.Append(wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, []wire.BARTID{testStatusBART}))
+
+	locateService := newMockLocateService(t)
+	locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("hasstatus")).
+		Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{TLVUserInfo: withStatus}}, nil)
+	locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("nostatus")).
+		Return(onlineUserInfoReply("nostatus", 0), nil)
+
+	iconRetriever := newMockBuddyIconRetriever(t)
+	iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).Return(nil, nil).Maybe()
+
+	oscarInstance := state.NewSession().AddInstance()
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+	sess, err := sessionMgr.GetSession(ctx, aimsid)
+	require.NoError(t, err)
+	sess.BaseURL = "http://api.example.com"
+
+	handler := &PresenceHandler{
+		SessionManager: sessionMgr,
+		LocateService:  locateService,
+		IconSource:     BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()},
+		Logger:         slog.Default(),
+	}
+
+	req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t=hasstatus,nostatus", nil)
+	require.NoError(t, err)
+	rr := httptest.NewRecorder()
+	requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
+	require.Equal(t, http.StatusOK, rr.Code)
+
+	var got struct {
+		Response struct {
+			Data struct {
+				Users []struct {
+					AimID     string `json:"aimId"`
+					StatusMsg string `json:"statusMsg"`
+				} `json:"users"`
+			} `json:"data"`
+		} `json:"response"`
+	}
+	require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
+	require.Len(t, got.Response.Data.Users, 2)
+
+	byName := map[string]string{}
+	for _, u := range got.Response.Data.Users {
+		byName[u.AimID] = u.StatusMsg
+	}
+	assert.Equal(t, "brb", byName["hasstatus"])
+	assert.Empty(t, byName["nostatus"])
+}
+
 func TestPresenceHandler_GetPresence_BuddyListGrouping(t *testing.T) {
 	feedbagService := newMockFeedbagService(t)
 	locateService := newMockLocateService(t)
@@ -997,10 +1054,17 @@ func setStatusCaps(t *testing.T, instance *state.SessionInstance, query string)
 			instance.SetCaps(gotCaps)
 		}).Return(nil).Maybe()
 
+	oservice := newMockOServiceService(t)
+	oservice.EXPECT().
+		SetUserInfoFields(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+		Return(nil).
+		Maybe()
+
 	handler := &PresenceHandler{
-		SessionManager: sessionMgr,
-		LocateService:  locate,
-		Logger:         slog.Default(),
+		SessionManager:  sessionMgr,
+		LocateService:   locate,
+		OServiceService: oservice,
+		Logger:          slog.Default(),
 	}
 
 	req, err := http.NewRequest("GET", "/presence/setStatus?aimsid="+aimsid+query, nil)
@@ -1015,6 +1079,165 @@ func setStatusCaps(t *testing.T, instance *state.SessionInstance, query string)
 	return gotCaps, rr.Code
 }
 
+// A state change must not blank the status message: the client renders both from
+// the same payload.
+func TestPresenceHandler_SetState_PreservesStatusMessage(t *testing.T) {
+	oscarInstance := state.NewSession().AddInstance()
+	oscarInstance.Session().SetStatus(testStatusBART)
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+
+	oservice := newMockOServiceService(t)
+	oservice.EXPECT().
+		SetUserInfoFields(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+		Return(nil)
+
+	locate := newMockLocateService(t)
+	locate.EXPECT().SetInfo(mock.Anything, mock.Anything, mock.Anything).Return(nil)
+
+	handler := &PresenceHandler{
+		SessionManager:  sessionMgr,
+		LocateService:   locate,
+		OServiceService: oservice,
+		Logger:          slog.Default(),
+	}
+
+	req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=online", nil)
+	require.NoError(t, err)
+
+	rr := httptest.NewRecorder()
+	requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
+	require.Equal(t, http.StatusOK, rr.Code)
+	assert.Contains(t, rr.Body.String(), `"statusMsg":"brb"`)
+}
+
+func TestPresenceHandler_SetStatus_StatusMessage(t *testing.T) {
+	tests := []struct {
+		name string
+		// query is appended to the request, after aimsid.
+		query string
+		// wantStatusMsg is the text that must reach OSCAR, when wantSet is true.
+		wantStatusMsg string
+		wantSet       bool
+	}{
+		{
+			name:          "a status message is sent as a BART status item",
+			query:         "&statusMsg=out+to+lunch",
+			wantStatusMsg: "out to lunch",
+			wantSet:       true,
+		},
+		{
+			// The client clears the status message by sending an empty one.
+			name:    "an empty status message clears it",
+			query:   "&statusMsg=",
+			wantSet: true,
+		},
+		{
+			name:  "an absent status message parameter leaves it alone",
+			query: "&mood=",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			oscarInstance := state.NewSession().AddInstance()
+			sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+
+			var gotBody wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields
+			oservice := newMockOServiceService(t)
+			if tt.wantSet {
+				oservice.EXPECT().
+					SetUserInfoFields(mock.Anything, oscarInstance, wire.SNACFrame{}, mock.Anything).
+					Run(func(_ context.Context, _ *state.SessionInstance, _ wire.SNACFrame, inBody wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields) {
+						gotBody = inBody
+					}).
+					Return(nil)
+			}
+
+			locate := newMockLocateService(t)
+			locate.EXPECT().SetInfo(mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
+
+			handler := &PresenceHandler{
+				SessionManager:  sessionMgr,
+				LocateService:   locate,
+				OServiceService: oservice,
+				Logger:          slog.Default(),
+			}
+
+			req, err := http.NewRequest("GET", "/presence/setStatus?aimsid="+aimsid+tt.query, nil)
+			require.NoError(t, err)
+
+			rr := httptest.NewRecorder()
+			requireSession(handler.SessionManager, handler.SetStatus).ServeHTTP(rr, req)
+			require.Equal(t, http.StatusOK, rr.Code)
+
+			if !tt.wantSet {
+				return
+			}
+
+			b, hasBART := gotBody.Bytes(wire.OServiceUserInfoBARTInfo)
+			require.True(t, hasBART, "setStatus must send a BART TLV")
+
+			var gotID wire.BARTID
+			require.NoError(t, wire.UnmarshalBE(&gotID, bytes.NewReader(b)))
+			assert.Equal(t, wire.BARTTypesStatusStr, gotID.Type)
+			assert.Equal(t, wire.BARTFlagsData, gotID.Flags)
+
+			gotStatusMsg, err := gotID.StatusText()
+			require.NoError(t, err)
+			assert.Equal(t, tt.wantStatusMsg, gotStatusMsg)
+		})
+	}
+}
+
+func TestPresenceHandler_SetStatus_StatusMessageError(t *testing.T) {
+	oscarInstance := state.NewSession().AddInstance()
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+
+	oservice := newMockOServiceService(t)
+	oservice.EXPECT().
+		SetUserInfoFields(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+		Return(io.EOF)
+
+	handler := &PresenceHandler{
+		SessionManager:  sessionMgr,
+		OServiceService: oservice,
+		Logger:          slog.Default(),
+	}
+
+	req, err := http.NewRequest("GET", "/presence/setStatus?aimsid="+aimsid+"&statusMsg=nope", nil)
+	require.NoError(t, err)
+
+	rr := httptest.NewRecorder()
+	requireSession(handler.SessionManager, handler.SetStatus).ServeHTTP(rr, req)
+
+	assert.Equal(t, http.StatusInternalServerError, rr.Code)
+	assert.Contains(t, rr.Body.String(), "failed to set status")
+}
+
+func TestPresenceHandler_SetStatus_StatusMessageTooLong(t *testing.T) {
+	// An overlong message would wrap the one-byte length prefix and corrupt the
+	// user info block.
+	oscarInstance := state.NewSession().AddInstance()
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+
+	// No EXPECT: nothing may reach OSCAR.
+	handler := &PresenceHandler{
+		SessionManager:  sessionMgr,
+		OServiceService: newMockOServiceService(t),
+		Logger:          slog.Default(),
+	}
+
+	tooLong := strings.Repeat("a", 252)
+	req, err := http.NewRequest("GET", "/presence/setStatus?aimsid="+aimsid+"&statusMsg="+tooLong, nil)
+	require.NoError(t, err)
+
+	rr := httptest.NewRecorder()
+	requireSession(handler.SessionManager, handler.SetStatus).ServeHTTP(rr, req)
+
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), wire.ErrStatusTextSizeExceeded.Error())
+}
+
 func TestPresenceHandler_SetStatus_Mood(t *testing.T) {
 	tests := []struct {
 		name string

+ 54 - 15
server/webapi/session.go

@@ -273,13 +273,7 @@ func (s *Session) handleUserInfoUpdate(msg wire.SNACMessage) {
 
 	// A missing icon TLV yields a nil hash, which publishes the placeholder URL
 	// and so clears an icon the client still holds.
-	var hash []byte
-	if b, ok := info.Bytes(wire.OServiceUserInfoBARTInfo); ok {
-		var id wire.BARTID
-		if err := wire.UnmarshalBE(&id, bytes.NewBuffer(b)); err == nil {
-			hash = id.Hash
-		}
-	}
+	hash := buddyIconHash(info)
 
 	myInfo := buildMyInfo(
 		screenName,
@@ -288,6 +282,7 @@ func (s *Session) handleUserInfoUpdate(msg wire.SNACMessage) {
 		moodIconURL(s.BaseURL, webState, userInfoCaps(info)),
 	)
 	myInfo.AwayMsg = s.OSCARSession.Session().AwayMessage()
+	myInfo.StatusMsg = userStatusMsg(info)
 
 	s.EventQueue.Push(EventTypeMyInfo, myInfo)
 }
@@ -564,6 +559,7 @@ func (s *Session) handleBuddyArrived(msg wire.SNACMessage) {
 	}
 
 	presenceEvent.MoodIcon = moodIconURL(s.BaseURL, stateStr, userInfoCaps(body.TLVUserInfo))
+	presenceEvent.StatusMsg = userStatusMsg(body.TLVUserInfo)
 
 	// A BuddyArrived carries the buddy's current icon in TLV 0x1D whenever they
 	// have one, so an icon change (or clear, which arrives as the sentinel hash)
@@ -573,19 +569,62 @@ func (s *Session) handleBuddyArrived(msg wire.SNACMessage) {
 	// client's shallow merge. An empty result (no origin known) is omitted, which
 	// preserves whatever icon the client already holds.
 	if s.BuddyIconURL != nil {
-		var hash []byte
-		if b, ok := body.Bytes(wire.OServiceUserInfoBARTInfo); ok {
-			var id wire.BARTID
-			if err := wire.UnmarshalBE(&id, bytes.NewBuffer(b)); err == nil {
-				hash = id.Hash
-			}
-		}
-		presenceEvent.BuddyIcon = s.BuddyIconURL(buddy, hash)
+		presenceEvent.BuddyIcon = s.BuddyIconURL(buddy, buddyIconHash(body.TLVUserInfo))
 	}
 
 	s.EventQueue.Push(EventTypePresence, presenceEvent)
 }
 
+// bartIDs returns the BART items a user info block carries. They travel as a list
+// in one TLV: a user's buddy icon and status message ride in it together.
+func bartIDs(info wire.TLVUserInfo) []wire.BARTID {
+	b, ok := info.Bytes(wire.OServiceUserInfoBARTInfo)
+	if !ok {
+		return nil
+	}
+	var ids []wire.BARTID
+	if err := wire.UnmarshalBE(&ids, bytes.NewReader(b)); err != nil {
+		return nil
+	}
+	return ids
+}
+
+// buddyIconHash returns the buddy icon hash carried in a user info block, or nil
+// when it carries none.
+func buddyIconHash(info wire.TLVUserInfo) []byte {
+	for _, id := range bartIDs(info) {
+		if id.Type == wire.BARTTypesBuddyIcon || id.Type == wire.BARTTypesBuddyIconSmall {
+			return id.Hash
+		}
+	}
+	return nil
+}
+
+// userStatusMsg returns the status message carried in a user info block, or ""
+// when it carries none or one that cannot be decoded.
+func userStatusMsg(info wire.TLVUserInfo) string {
+	for _, id := range bartIDs(info) {
+		msg, err := id.StatusText()
+		if err != nil {
+			continue
+		}
+		if msg != "" {
+			return msg
+		}
+	}
+	return ""
+}
+
+// sessionStatusMsg returns the status message a session currently advertises.
+func sessionStatusMsg(instance *state.SessionInstance) string {
+	status, _ := instance.Session().Status()
+	msg, err := status.StatusText()
+	if err != nil {
+		return ""
+	}
+	return msg
+}
+
 // handleBuddyDeparted handles when a buddy goes offline.
 func (s *Session) handleBuddyDeparted(msg wire.SNACMessage) {
 	if !s.IsSubscribedTo("presence") {

+ 76 - 0
server/webapi/session_test.go

@@ -702,6 +702,23 @@ func TestSession_HandleBuddyArrivedDeparted_NormalizesAimID(t *testing.T) {
 // rides along on the presence broadcast and must reach the presence event. The
 // stub BuddyIconURL stands in for the handlers-side URL formatter, which state
 // cannot import.
+var testIconBART = wire.BARTID{
+	Type:     wire.BARTTypesBuddyIcon,
+	BARTInfo: wire.BARTInfo{Hash: []byte{0xde, 0xad, 0xbe, 0xef}},
+}
+
+var testStatusBART = statusBARTID("brb")
+
+// statusBARTID builds the BART item that advertises statusMsg as a user's status
+// message. It panics only on a message too long to fit the item.
+func statusBARTID(statusMsg string) wire.BARTID {
+	var id wire.BARTID
+	if err := id.SetStatusText(statusMsg); err != nil {
+		panic(err)
+	}
+	return id
+}
+
 func TestSession_PublishesBuddyIconOnPresence(t *testing.T) {
 	newSession := func() *Session {
 		return &Session{
@@ -747,6 +764,37 @@ func TestSession_PublishesBuddyIconOnPresence(t *testing.T) {
 		assert.Equal(t, "placeholder:mikekelly", lastPresence(sess).BuddyIcon)
 	})
 
+	// The BART TLV is a list, so a status message rides in it next to the icon.
+	arrivedBART := func(sess *Session, screenName string, ids []wire.BARTID) {
+		info := wire.TLVUserInfo{ScreenName: screenName}
+		info.Append(wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, ids))
+		sess.handleBuddyArrived(wire.SNACMessage{Body: wire.SNAC_0x03_0x0B_BuddyArrived{TLVUserInfo: info}})
+	}
+
+	t.Run("a buddy's status message reaches the presence event", func(t *testing.T) {
+		sess := newSession()
+		arrivedBART(sess, "Mike Kelly", []wire.BARTID{testIconBART, testStatusBART})
+		assert.Equal(t, "brb", lastPresence(sess).StatusMsg)
+	})
+
+	t.Run("no status message leaves the field empty", func(t *testing.T) {
+		sess := newSession()
+		arrivedBART(sess, "Mike Kelly", []wire.BARTID{testIconBART})
+		assert.Empty(t, lastPresence(sess).StatusMsg)
+	})
+
+	t.Run("a status message next to the icon does not displace it", func(t *testing.T) {
+		sess := newSession()
+		arrivedBART(sess, "Mike Kelly", []wire.BARTID{testIconBART, testStatusBART})
+		assert.Equal(t, "icon:deadbeef", lastPresence(sess).BuddyIcon)
+	})
+
+	t.Run("a status message without an icon yields the placeholder URL", func(t *testing.T) {
+		sess := newSession()
+		arrivedBART(sess, "Mike Kelly", []wire.BARTID{testStatusBART})
+		assert.Equal(t, "placeholder:mikekelly", lastPresence(sess).BuddyIcon)
+	})
+
 	t.Run("cleared icon yields a URL naming the sentinel hash", func(t *testing.T) {
 		sess := newSession()
 		arrived(sess, "Mike Kelly", wire.GetClearIconHash())
@@ -892,6 +940,34 @@ func TestSession_PushesMyInfoOnUserInfoUpdate(t *testing.T) {
 		assert.Equal(t, "icon:deadbeef", lastMyInfo(t, sess).BuddyIcon)
 	})
 
+	t.Run("a status message without an icon yields the placeholder URL", func(t *testing.T) {
+		sess := newSession("myInfo")
+
+		info := wire.TLVUserInfo{ScreenName: "me"}
+		info.Append(wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, []wire.BARTID{testStatusBART}))
+		sess.handleSNACMessage(update(info))
+
+		assert.Equal(t, "placeholder:me", lastMyInfo(t, sess).BuddyIcon)
+	})
+
+	// The user info update is the only myInfo raised after a status message is set,
+	// so dropping the text here erases it from the user's own badge.
+	t.Run("the user's own status message survives the update", func(t *testing.T) {
+		sess := newSession("myInfo")
+
+		info := wire.TLVUserInfo{ScreenName: "me"}
+		info.Append(wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, []wire.BARTID{testStatusBART}))
+		sess.handleSNACMessage(update(info))
+
+		assert.Equal(t, "brb", lastMyInfo(t, sess).StatusMsg)
+	})
+
+	t.Run("no status message leaves the field empty", func(t *testing.T) {
+		sess := newSession("myInfo")
+		sess.handleSNACMessage(update(wire.TLVUserInfo{ScreenName: "me"}))
+		assert.Empty(t, lastMyInfo(t, sess).StatusMsg)
+	})
+
 	t.Run("no icon TLV yields the placeholder URL, which clears a removed icon", func(t *testing.T) {
 		sess := newSession("myInfo")
 		sess.handleSNACMessage(update(wire.TLVUserInfo{ScreenName: "me"}))

+ 41 - 2
state/session.go

@@ -107,6 +107,7 @@ type Session struct {
 	closeCh chan struct{}
 	closed  bool
 	nowFn   func() time.Time
+	status  wire.BARTID
 }
 
 // NewSession creates a new Session for a user.
@@ -368,6 +369,14 @@ func (s *Session) AwayMessage() string {
 	return awayMsg
 }
 
+// Status returns the session's status message and reports whether one is set.
+func (s *Session) Status() (wire.BARTID, bool) {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	status := s.status
+	return status, status.Type == wire.BARTTypesStatusStr
+}
+
 // Idle returns true if all instances are idle.
 func (s *Session) Idle() bool {
 	instances := s.Instances()
@@ -816,6 +825,13 @@ func (s *Session) SetBuddyIcon(icon wire.BARTID) {
 	s.buddyIcon = icon
 }
 
+// SetStatus stores the session's status message.
+func (s *Session) SetStatus(status wire.BARTID) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	s.status = status
+}
+
 // SetChatRoomCookie sets the chat room cookie.
 func (s *Session) SetChatRoomCookie(cookie string) {
 	s.mutex.Lock()
@@ -931,9 +947,17 @@ func (s *Session) userInfo() wire.TLVList {
 		tlvs.Append(wire.NewTLVBE(wire.OServiceUserInfoIdleTime, uint16(s.nowFn().Sub(mostRecentIdleTime).Minutes())))
 	}
 
-	// set buddy icon metadata, if user has buddy icon
+	var bartIDs []wire.BARTID
+
 	if icon, hasIcon := s.BuddyIcon(); hasIcon {
-		tlvs.Append(wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, icon))
+		bartIDs = append(bartIDs, icon)
+	}
+	if status, hasStatus := s.Status(); hasStatus {
+		bartIDs = append(bartIDs, status)
+	}
+
+	if len(bartIDs) > 0 {
+		tlvs.Append(wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, bartIDs))
 	}
 
 	// ICQ direct-connect info. The TLV is required for buddy arrival events to
@@ -1393,6 +1417,21 @@ func (s *SessionInstance) KerberosAuth() bool {
 	return s.kerberosAuth
 }
 
+// Mood returns the mood capability this instance advertises, or the zero UUID
+// when it advertises none.
+func (s *SessionInstance) Mood() uuid.UUID {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+
+	for _, c := range s.capabilities {
+		if wire.IsMoodCap(c) {
+			return c
+		}
+	}
+
+	return uuid.UUID{}
+}
+
 // MultiConnFlag retrieves the multi-connection flag for this instance.
 func (s *SessionInstance) MultiConnFlag() wire.MultiConnFlag {
 	s.mutex.RLock()

+ 110 - 0
state/session_test.go

@@ -194,6 +194,47 @@ func TestSession_SetAndGetRemoteAddr(t *testing.T) {
 	assert.Equal(t, &remoteAddr, s.RemoteAddr())
 }
 
+var testBuddyIcon = wire.BARTID{
+	Type: wire.BARTTypesBuddyIcon,
+	BARTInfo: wire.BARTInfo{
+		Flags: wire.BARTFlagsData,
+		Hash:  []byte{0xAA, 0xBB, 0xCC},
+	},
+}
+
+var testStatusBART = statusBARTID("brb")
+
+// testClearedStatusBART carries an empty status message, which is how a cleared
+// status reaches buddy lists.
+var testClearedStatusBART = statusBARTID("")
+
+// statusBARTID builds the BART item that advertises statusMsg as a user's status
+// message. It panics only on a message too long to fit the item.
+func statusBARTID(statusMsg string) wire.BARTID {
+	var id wire.BARTID
+	if err := id.SetStatusText(statusMsg); err != nil {
+		panic(err)
+	}
+	return id
+}
+
+func TestSession_Status(t *testing.T) {
+	s := NewSession()
+
+	_, hasStatus := s.Status()
+	assert.False(t, hasStatus)
+
+	s.SetStatus(testStatusBART)
+	status, hasStatus := s.Status()
+	assert.True(t, hasStatus)
+	assert.Equal(t, testStatusBART, status)
+
+	// Only the status message BART type reports as a status message.
+	s.SetStatus(testBuddyIcon)
+	_, hasStatus = s.Status()
+	assert.False(t, hasStatus)
+}
+
 func TestSession_TLVUserInfo(t *testing.T) {
 	tests := []struct {
 		name           string
@@ -463,6 +504,74 @@ func TestSession_TLVUserInfo(t *testing.T) {
 			givenSessionFn: func() *SessionInstance {
 				s := NewSession().AddInstance()
 				s.Session().SetSignonTime(time.Unix(1, 0))
+				s.Session().SetBuddyIcon(testBuddyIcon)
+				return s
+			},
+			want: wire.TLVUserInfo{
+				WarningLevel: 0,
+				TLVBlock: wire.TLVBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(1)),
+						wire.NewTLVBE(wire.OServiceUserInfoUserFlags, uint16(0x0010)),
+						wire.NewTLVBE(wire.OServiceUserInfoStatus, uint32(0x0000)),
+						wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, []wire.BARTID{testBuddyIcon}),
+						wire.NewTLVBE(wire.OServiceUserInfoMySubscriptions, uint32(0)),
+					},
+				},
+			},
+		},
+		{
+			name: "user has status message",
+			givenSessionFn: func() *SessionInstance {
+				s := NewSession().AddInstance()
+				s.Session().SetSignonTime(time.Unix(1, 0))
+				s.Session().SetStatus(testStatusBART)
+				return s
+			},
+			want: wire.TLVUserInfo{
+				WarningLevel: 0,
+				TLVBlock: wire.TLVBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(1)),
+						wire.NewTLVBE(wire.OServiceUserInfoUserFlags, uint16(0x0010)),
+						wire.NewTLVBE(wire.OServiceUserInfoStatus, uint32(0x0000)),
+						wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, []wire.BARTID{testStatusBART}),
+						wire.NewTLVBE(wire.OServiceUserInfoMySubscriptions, uint32(0)),
+					},
+				},
+			},
+		},
+		{
+			// A cleared status is an empty status message, not a missing one: a
+			// client drops the status it shows only when it is sent an empty one.
+			name: "user cleared their status message",
+			givenSessionFn: func() *SessionInstance {
+				s := NewSession().AddInstance()
+				s.Session().SetSignonTime(time.Unix(1, 0))
+				s.Session().SetStatus(testClearedStatusBART)
+				return s
+			},
+			want: wire.TLVUserInfo{
+				WarningLevel: 0,
+				TLVBlock: wire.TLVBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(1)),
+						wire.NewTLVBE(wire.OServiceUserInfoUserFlags, uint16(0x0010)),
+						wire.NewTLVBE(wire.OServiceUserInfoStatus, uint32(0x0000)),
+						wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, []wire.BARTID{testClearedStatusBART}),
+						wire.NewTLVBE(wire.OServiceUserInfoMySubscriptions, uint32(0)),
+					},
+				},
+			},
+		},
+		{
+			// Both ride in one TLV, so a client reading the icon reads the status.
+			name: "user has buddy icon and status message",
+			givenSessionFn: func() *SessionInstance {
+				s := NewSession().AddInstance()
+				s.Session().SetSignonTime(time.Unix(1, 0))
+				s.Session().SetBuddyIcon(testBuddyIcon)
+				s.Session().SetStatus(testStatusBART)
 				return s
 			},
 			want: wire.TLVUserInfo{
@@ -472,6 +581,7 @@ func TestSession_TLVUserInfo(t *testing.T) {
 						wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(1)),
 						wire.NewTLVBE(wire.OServiceUserInfoUserFlags, uint16(0x0010)),
 						wire.NewTLVBE(wire.OServiceUserInfoStatus, uint32(0x0000)),
+						wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, []wire.BARTID{testBuddyIcon, testStatusBART}),
 						wire.NewTLVBE(wire.OServiceUserInfoMySubscriptions, uint32(0)),
 					},
 				},

+ 41 - 3
wire/snacs.go

@@ -1589,15 +1589,53 @@ func (h BARTInfo) HasClearIconHash() bool {
 	return bytes.Equal(h.Hash, GetClearIconHash())
 }
 
+// maxStatusMsgLen is the longest status message that fits in a BART item, whose
+// hash field is length-prefixed with one byte and adds 4 bytes of framing. A
+// longer message wraps the prefix and corrupts the whole user info block.
+const maxStatusMsgLen = 251
+
+var ErrStatusTextSizeExceeded = fmt.Errorf("status message too large (max %d bytes)", maxStatusMsgLen)
+
 type BARTID struct {
 	Type uint16
 	BARTInfo
 }
 
-type BartIDsWName struct {
-	ScreenName string   `oscar:"len_prefix=uint8"`
-	IDs        []BARTID `oscar:"len_prefix=uint8"`
+type bartStatus struct {
+	Status string   `oscar:"len_prefix=uint16"`
+	IDs    []BARTID `oscar:"len_prefix=uint16"`
+}
+
+// SetStatusText sets a BART status string on the BART ID.
+func (b *BARTID) SetStatusText(statusMsg string) error {
+	if len(statusMsg) > maxStatusMsgLen {
+		return ErrStatusTextSizeExceeded
+	}
+
+	bs := bartStatus{Status: statusMsg}
+	buf := &bytes.Buffer{}
+	if err := MarshalBE(bs, buf); err != nil {
+		return fmt.Errorf("BARTID.SetStatusText: failed to marshal BART status: %w", err)
+	}
+	b.Type = BARTTypesStatusStr
+	b.Flags = BARTFlagsData
+	b.Hash = buf.Bytes()
+
+	return nil
 }
+
+// StatusText returns the status message text a status string BART item carries.
+func (b *BARTID) StatusText() (string, error) {
+	if b.Type != BARTTypesStatusStr || b.Flags&BARTFlagsData == 0 {
+		return "", nil
+	}
+	var status bartStatus
+	if err := UnmarshalBE(&status, bytes.NewReader(b.Hash)); err != nil {
+		return "", fmt.Errorf("BARTID.StatusText: failed to unmarshal BART status: %w", err)
+	}
+	return status.Status, nil
+}
+
 type BartQueryReplyID struct {
 	QueryID BARTID
 	Code    uint8

+ 66 - 0
wire/snacs_test.go

@@ -34,6 +34,72 @@ func TestBARTInfo_HasClearIconHash(t *testing.T) {
 	}
 }
 
+func TestBARTID_StatusText(t *testing.T) {
+	marshaled := func(t *testing.T, status string) []byte {
+		t.Helper()
+		b := &bytes.Buffer{}
+		assert.NoError(t, MarshalBE(bartStatus{Status: status}, b))
+		return b.Bytes()
+	}
+
+	tests := []struct {
+		name string
+		id   BARTID
+		want string
+		// wantErr reports whether the item's payload fails to decode.
+		wantErr bool
+	}{
+		{
+			name: "a data-bearing status item carries its text",
+			id: BARTID{
+				Type:     BARTTypesStatusStr,
+				BARTInfo: BARTInfo{Flags: BARTFlagsData, Hash: marshaled(t, "out to lunch")},
+			},
+			want: "out to lunch",
+		},
+		{
+			name: "empty text reads as no status message",
+			id: BARTID{
+				Type:     BARTTypesStatusStr,
+				BARTInfo: BARTInfo{Flags: BARTFlagsData, Hash: marshaled(t, "")},
+			},
+		},
+		{
+			name: "a status item without the data flag reads as no status message",
+			id: BARTID{
+				Type:     BARTTypesStatusStr,
+				BARTInfo: BARTInfo{Flags: BARTFlagsKnown, Hash: marshaled(t, "out to lunch")},
+			},
+		},
+		{
+			name: "another BART type is not a status message",
+			id: BARTID{
+				Type:     BARTTypesBuddyIcon,
+				BARTInfo: BARTInfo{Flags: BARTFlagsData, Hash: marshaled(t, "out to lunch")},
+			},
+		},
+		{
+			name: "a malformed item is an error",
+			id: BARTID{
+				Type:     BARTTypesStatusStr,
+				BARTInfo: BARTInfo{Flags: BARTFlagsData, Hash: []byte{0x00}},
+			},
+			wantErr: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := tt.id.StatusText()
+			if tt.wantErr {
+				assert.Error(t, err)
+			} else {
+				assert.NoError(t, err)
+			}
+			assert.Equal(t, tt.want, got)
+		})
+	}
+}
+
 func TestSNAC_0x01_0x14_OServiceSetPrivacyFlags_IdleFlag(t *testing.T) {
 	type fields struct {
 		PrivacyFlags uint32