Explorar o código

webapi: describe every response with one struct and one envelope

Mike hai 1 día
pai
achega
ab70f80a63
Modificáronse 35 ficheiros con 1161 adicións e 946 borrados
  1. 7 3
      server/webapi/handlers/aim_stub.go
  2. 20 84
      server/webapi/handlers/amf_encoder.go
  3. 28 10
      server/webapi/handlers/amf_encoder_test.go
  4. 57 21
      server/webapi/handlers/auth.go
  5. 22 88
      server/webapi/handlers/buddy_list_manager.go
  6. 28 39
      server/webapi/handlers/buddylist.go
  7. 25 27
      server/webapi/handlers/buddylist_test.go
  8. 52 125
      server/webapi/handlers/common.go
  9. 57 4
      server/webapi/handlers/common_test.go
  10. 6 3
      server/webapi/handlers/conversation_stub.go
  11. 20 66
      server/webapi/handlers/events.go
  12. 14 6
      server/webapi/handlers/expressions.go
  13. 15 5
      server/webapi/handlers/memberdir.go
  14. 8 5
      server/webapi/handlers/messaging.go
  15. 18 16
      server/webapi/handlers/messaging_test.go
  16. 6 23
      server/webapi/handlers/oscar_bridge.go
  17. 184 27
      server/webapi/handlers/preference.go
  18. 31 7
      server/webapi/handlers/preference_test.go
  19. 29 19
      server/webapi/handlers/presence.go
  20. 19 18
      server/webapi/handlers/presence_test.go
  21. 4 4
      server/webapi/handlers/ratelimit_test.go
  22. 28 0
      server/webapi/handlers/service_stub.go
  23. 146 247
      server/webapi/handlers/session.go
  24. 10 5
      server/webapi/handlers/session_test.go
  25. 39 7
      server/webapi/handlers/user_info_stub.go
  26. 0 1
      server/webapi/handlers/webapi_event_converter.go
  27. 152 0
      server/webapi/handlers/xml_shape_test.go
  28. 3 0
      server/webapi/middleware/auth.go
  29. 7 0
      server/webapi/server.go
  30. 50 22
      server/webapi/types/conversation.go
  31. 40 40
      server/webapi/types/events.go
  32. 19 8
      state/webapi_imlog.go
  33. 5 5
      state/webapi_imlog_test.go
  34. 3 3
      state/webapi_session.go
  35. 9 8
      state/webapi_session_test.go

+ 7 - 3
server/webapi/handlers/aim_stub.go

@@ -31,13 +31,17 @@ func (h *AimStubHandler) ReportAction(w http.ResponseWriter, r *http.Request) {
 	SendResponse(w, r, resp, h.Logger)
 }
 
+// StoredDataItems is the client-side data blob store, which this server does
+// not keep, so it always answers with an empty items list.
+type StoredDataItems struct {
+	Items []string `json:"items" xml:"items>item"`
+}
+
 // GetData returns empty client-side data blobs (buddy list favorites, etc.).
 func (h *AimStubHandler) GetData(w http.ResponseWriter, r *http.Request) {
 	resp := BaseResponse{}
 	resp.Response.StatusCode = 200
 	resp.Response.StatusText = "OK"
-	resp.Response.Data = map[string]interface{}{
-		"items": []interface{}{},
-	}
+	resp.Response.Data = &StoredDataItems{Items: []string{}}
 	SendResponse(w, r, resp, h.Logger)
 }

+ 20 - 84
server/webapi/handlers/amf_encoder.go

@@ -9,7 +9,6 @@ import (
 	"time"
 
 	goAMF3 "github.com/breign/goAMF3"
-	"github.com/mk6i/open-oscar-server/server/webapi/types"
 )
 
 // AMFVersion represents the AMF encoding version
@@ -48,6 +47,10 @@ func (e *AMFEncoder) toAMF3Compatible(data interface{}) interface{} {
 
 	// goAMF3 handles regular Go types well, just need to ensure maps are used
 	// Don't use ECMAArray for AMF3 - just regular maps
+	// Every response is a struct whose json tags name its fields, and
+	// structToMap reflects over exactly those tags, so no response needs a case
+	// of its own here. sanitizeForAMF3 handles the types goAMF3 cannot take —
+	// notably the uint64 sequence numbers — on the way out.
 	switch d := data.(type) {
 	case BaseResponse:
 		return e.baseResponseToMap(d)
@@ -55,46 +58,6 @@ func (e *AMFEncoder) toAMF3Compatible(data interface{}) interface{} {
 		return e.responseBodyToMap(d)
 	case ErrorResponse:
 		return e.errorResponseToMap(d)
-	case StartSessionResponse:
-		// Special handling for StartSessionResponse
-		return map[string]interface{}{
-			"response": map[string]interface{}{
-				"statusCode": d.Response.StatusCode,
-				"statusText": d.Response.StatusText,
-				"data": map[string]interface{}{
-					"aimsid":          d.Response.Data.AimSID,
-					"fetchTimeout":    d.Response.Data.FetchTimeout,
-					"timeToNextFetch": d.Response.Data.TimeToNextFetch,
-					"fetchBaseURL":    d.Response.Data.FetchBaseURL, // Required for Gromit
-					"events":          d.Response.Data.Events,
-					"wellKnownUrls":   d.Response.Data.WellKnownUrls,
-				},
-			},
-		}
-	case FetchEventsResponse:
-		// Special handling for FetchEventsResponse
-		// goAMF3 can't handle uint64, must convert to int
-		return map[string]interface{}{
-			"response": map[string]interface{}{
-				"statusCode": d.Response.StatusCode,
-				"statusText": d.Response.StatusText,
-				"data": map[string]interface{}{
-					"events":          d.Response.Data.Events,
-					"lastSeqNum":      int(d.Response.Data.LastSeqNum), // Convert uint64 to int
-					"timeToNextFetch": d.Response.Data.TimeToNextFetch,
-					"fetchBaseURL":    d.Response.Data.FetchBaseURL,
-				},
-			},
-		}
-	case EndSessionResponse:
-		// Special handling for EndSessionResponse - Gromit expects flat structure
-		// Based on Gromit's MockServer, it expects:
-		// { "data": {}, "statusCode": 200, "statusText": "OK" }
-		return map[string]interface{}{
-			"data":       map[string]interface{}{}, // Empty data object
-			"statusCode": d.Response.StatusCode,
-			"statusText": d.Response.StatusText,
-		}
 	default:
 		// For other types, convert structs to maps
 		return e.convertToMap(data)
@@ -102,7 +65,11 @@ func (e *AMFEncoder) toAMF3Compatible(data interface{}) interface{} {
 }
 
 // sanitizeForAMF3 recursively removes nil values from the data structure
-// because goAMF3 panics when encountering nil values in maps
+// because goAMF3 panics when encountering nil values in maps.
+//
+// It runs on the output of toAMF3Compatible, so every struct and slice has
+// already been reduced to maps and []interface{}; only the leaf types goAMF3
+// cannot take are left to convert.
 func (e *AMFEncoder) sanitizeForAMF3(data interface{}) interface{} {
 	if data == nil {
 		return map[string]interface{}{}
@@ -141,43 +108,6 @@ func (e *AMFEncoder) sanitizeForAMF3(data interface{}) interface{} {
 			result[i] = e.sanitizeForAMF3(item)
 		}
 		return result
-	case []types.Event:
-		// Handle WebAPIEvent arrays specially
-		result := make([]interface{}, len(v))
-		for i, event := range v {
-			// AMF3 has a 29-bit limit for integers
-			// Keep seqNum small by using modulo
-			seqNum := int(event.SeqNum % (1 << 29))
-			// Convert timestamp to seconds ago to keep it small
-			timestampSec := int(time.Now().Unix() - event.Timestamp)
-			if timestampSec < 0 {
-				timestampSec = 0
-			}
-
-			result[i] = map[string]interface{}{
-				"type":      event.Type,
-				"seqNum":    seqNum,
-				"timestamp": timestampSec,
-				"data":      e.sanitizeForAMF3(event.Data),
-			}
-		}
-		return result
-	case types.Event:
-		// Handle single WebAPIEvent
-		// AMF3 has a 29-bit limit for integers
-		seqNum := int(v.SeqNum % (1 << 29))
-		// Convert timestamp to seconds ago to keep it small
-		timestampSec := int(time.Now().Unix() - v.Timestamp)
-		if timestampSec < 0 {
-			timestampSec = 0
-		}
-
-		return map[string]interface{}{
-			"type":      v.Type,
-			"seqNum":    seqNum,
-			"timestamp": timestampSec,
-			"data":      e.sanitizeForAMF3(v.Data),
-		}
 	default:
 		// For other types, use reflection to check if it's a struct
 		// and convert to map
@@ -216,12 +146,18 @@ func (e *AMFEncoder) responseBodyToMap(body ResponseBody) map[string]interface{}
 
 // errorResponseToMap converts ErrorResponse to AMF3-compatible map
 func (e *AMFEncoder) errorResponseToMap(err ErrorResponse) map[string]interface{} {
-	return map[string]interface{}{
-		"response": map[string]interface{}{
-			"statusCode": err.Response.StatusCode,
-			"statusText": err.Response.StatusText,
-		},
+	m := map[string]interface{}{
+		"statusCode": err.Response.StatusCode,
+		"statusText": err.Response.StatusText,
+	}
+	// The client dereferences response.data on a failure too, so the error
+	// envelope carries one in AMF as it does in every other format.
+	if err.Response.Data != nil {
+		m["data"] = e.toAMF3Compatible(err.Response.Data)
+	} else {
+		m["data"] = map[string]interface{}{}
 	}
+	return map[string]interface{}{"response": m}
 }
 
 // structToMap converts a struct to a map using JSON tags for AMF3

+ 28 - 10
server/webapi/handlers/amf_encoder_test.go

@@ -93,16 +93,8 @@ func TestAMFEncoderComplexTypes(t *testing.T) {
 			version: AMF3,
 		},
 		{
-			name: "ErrorResponse",
-			input: ErrorResponse{
-				Response: struct {
-					StatusCode int    `json:"statusCode" xml:"statusCode"`
-					StatusText string `json:"statusText" xml:"statusText"`
-				}{
-					StatusCode: 404,
-					StatusText: "Not Found",
-				},
-			},
+			name:    "ErrorResponse",
+			input:   newErrorResponse(404, "Not Found"),
 			version: AMF3,
 		},
 		{
@@ -435,3 +427,29 @@ func TestZeroValueDetection(t *testing.T) {
 		}
 	}
 }
+
+// The client dereferences response.data on a failure too, so the AMF error
+// envelope carries one just as the JSON, JSONP and XML ones do.
+func TestAMFErrorEnvelopeCarriesData(t *testing.T) {
+	encoder := NewAMFEncoder(nil)
+
+	out, ok := encoder.toAMF3Compatible(newErrorResponse(404, "Not Found")).(map[string]interface{})
+	if !ok {
+		t.Fatalf("expected an envelope map, got %T", out)
+	}
+	resp, ok := out["response"].(map[string]interface{})
+	if !ok {
+		t.Fatalf("expected a response map, got %T", out["response"])
+	}
+
+	if resp["statusCode"] != 404 {
+		t.Errorf("statusCode: expected 404, got %v", resp["statusCode"])
+	}
+	data, ok := resp["data"].(map[string]interface{})
+	if !ok {
+		t.Fatalf("expected an empty data map, got %T", resp["data"])
+	}
+	if len(data) != 0 {
+		t.Errorf("data: expected empty, got %v", data)
+	}
+}

+ 57 - 21
server/webapi/handlers/auth.go

@@ -17,6 +17,46 @@ import (
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
+// AuthToken is the opaque credential the client presents on later requests.
+//
+// ExpiresIn is a string because that is the shape the client is given, even
+// though ClientLoginData.TokenExpiresIn carries the same quantity as a number.
+type AuthToken struct {
+	A         string `json:"a" xml:"a"`
+	ExpiresIn string `json:"expiresIn" xml:"expiresIn"`
+}
+
+// GetTokenData is the getToken payload.
+type GetTokenData struct {
+	Token    AuthToken `json:"token" xml:"token"`
+	UserData UserData  `json:"userData" xml:"userData"`
+}
+
+// UserData wraps the attributes getToken reports about the account.
+type UserData struct {
+	Attributes UserAttributes `json:"attributes" xml:"attributes"`
+}
+
+// UserAttributes names the account the token belongs to.
+type UserAttributes struct {
+	LoginID string `json:"loginId" xml:"loginId"`
+}
+
+// ClientLoginData is the clientLogin payload.
+type ClientLoginData struct {
+	Token          AuthToken `json:"token" xml:"token"`
+	LoginID        string    `json:"loginId" xml:"loginId"`
+	ScreenName     string    `json:"screenName" xml:"screenName"`
+	SessionSecret  string    `json:"sessionSecret" xml:"sessionSecret"`
+	HostTime       int64     `json:"hostTime" xml:"hostTime"`
+	TokenExpiresIn int       `json:"tokenExpiresIn" xml:"tokenExpiresIn"`
+}
+
+// RedirectData sends an unauthenticated client to the login page.
+type RedirectData struct {
+	RedirectURL string `json:"redirectURL" xml:"redirectURL"`
+}
+
 // AuthHandler handles Web AIM API authentication endpoints.
 type AuthHandler struct {
 	AuthService AuthService
@@ -50,9 +90,7 @@ func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
 		resp := BaseResponse{}
 		resp.Response.StatusCode = 401
 		resp.Response.StatusText = "Unauthorized"
-		resp.Response.Data = map[string]interface{}{
-			"redirectURL": h.loginRedirectURL(r),
-		}
+		resp.Response.Data = &RedirectData{RedirectURL: h.loginRedirectURL(r)}
 		SendResponse(w, r, resp, h.Logger)
 		return
 	}
@@ -73,16 +111,13 @@ func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
 	resp := BaseResponse{}
 	resp.Response.StatusCode = 200
 	resp.Response.StatusText = "OK"
-	resp.Response.Data = map[string]interface{}{
-		"token": map[string]interface{}{
-			"a":         base64.URLEncoding.EncodeToString(tokenBytes),
-			"expiresIn": "86400", // todo check this assumption
-		},
-		"userData": map[string]interface{}{
-			"attributes": map[string]interface{}{
-				"loginId": string(loginID),
-			},
+	resp.Response.Data = &GetTokenData{
+		Token: AuthToken{
+			A: base64.URLEncoding.EncodeToString(tokenBytes),
+			// A string, not a number: that is how the client is given it.
+			ExpiresIn: "86400", // todo check this assumption
 		},
+		UserData: UserData{Attributes: UserAttributes{LoginID: string(loginID)}},
 	}
 	SendResponse(w, r, resp, h.Logger)
 
@@ -294,16 +329,17 @@ func (h *AuthHandler) ClientLogin(w http.ResponseWriter, r *http.Request) {
 	resp := BaseResponse{}
 	resp.Response.StatusCode = 200
 	resp.Response.StatusText = "OK"
-	resp.Response.Data = map[string]interface{}{
-		"token": map[string]interface{}{
-			"a":         base64.URLEncoding.EncodeToString(authCookie),
-			"expiresIn": "86400", // 24 hours in seconds
+	resp.Response.Data = &ClientLoginData{
+		Token: AuthToken{
+			A:         base64.URLEncoding.EncodeToString(authCookie),
+			ExpiresIn: "86400", // 24 hours in seconds
 		},
-		"loginId":        username,
-		"screenName":     username,
-		"sessionSecret":  sessionSecret,
-		"hostTime":       time.Now().Unix(),
-		"tokenExpiresIn": 86400, // 24 hours in seconds
+		LoginID:       username,
+		ScreenName:    username,
+		SessionSecret: sessionSecret,
+		HostTime:      time.Now().Unix(),
+		// A number here where token.expiresIn is a string, as the client expects.
+		TokenExpiresIn: 86400, // 24 hours in seconds
 	}
 
 	// Send response in requested format (JSON, JSONP, XML, or AMF)

+ 22 - 88
server/webapi/handlers/buddy_list_manager.go

@@ -34,29 +34,32 @@ func NewBuddyListManager(feedbagService FeedbagService, locateService LocateServ
 
 // WebAPIBuddyGroup represents a group in the WebAPI buddy list format.
 type WebAPIBuddyGroup struct {
-	Name    string            `json:"name"`
-	Buddies []WebAPIBuddyInfo `json:"buddies"`
-	Recent  bool              `json:"recent,omitempty"`
-	Smart   interface{}       `json:"smart,omitempty"` // Can be null or number
+	Name    string            `json:"name" xml:"name"`
+	Buddies []WebAPIBuddyInfo `json:"buddies" xml:"buddies>buddy"`
+	Recent  bool              `json:"recent,omitempty" xml:"recent,omitempty"`
+	// Smart is null or a number. It is a pointer rather than an interface so
+	// encoding/xml can render it: a non-nil interface holding a map cannot be
+	// marshalled, while a nil pointer is simply omitted.
+	Smart *int `json:"smart,omitempty" xml:"smart,omitempty"`
 }
 
 // WebAPIBuddyInfo represents a buddy in the WebAPI format.
 type WebAPIBuddyInfo struct {
-	AimID        string   `json:"aimId"`
-	DisplayID    string   `json:"displayId"`
-	Friendly     string   `json:"friendly,omitempty"` // Viewer's private alias, rendered in preference to DisplayID
-	State        string   `json:"state"`              // "online", "offline", "away", "idle"
-	StatusMsg    string   `json:"statusMsg,omitempty"`
-	AwayMsg      string   `json:"awayMsg,omitempty"`
-	OnlineTime   int64    `json:"onlineTime,omitempty"`
-	IdleTime     int      `json:"idleTime,omitempty"` // Minutes idle
-	UserType     string   `json:"userType"`           // "aim", "icq", "admin"
-	Bot          bool     `json:"bot"`
-	Service      string   `json:"service,omitempty"` // "AIM", "ICQ" (Web AIM client compares case-sensitively)
-	PresenceIcon string   `json:"presenceIcon,omitempty"`
-	BuddyIcon    string   `json:"buddyIcon,omitempty"`
-	Capabilities []string `json:"capabilities,omitempty"`
-	MemberSince  int64    `json:"memberSince,omitempty"`
+	AimID        string   `json:"aimId" xml:"aimId"`
+	DisplayID    string   `json:"displayId" xml:"displayId"`
+	Friendly     string   `json:"friendly,omitempty" xml:"friendly,omitempty"` // Viewer's private alias, rendered in preference to DisplayID
+	State        string   `json:"state" xml:"state"`                           // "online", "offline", "away", "idle"
+	StatusMsg    string   `json:"statusMsg,omitempty" xml:"statusMsg,omitempty"`
+	AwayMsg      string   `json:"awayMsg,omitempty" xml:"awayMsg,omitempty"`
+	OnlineTime   int64    `json:"onlineTime,omitempty" xml:"onlineTime,omitempty"`
+	IdleTime     int      `json:"idleTime,omitempty" xml:"idleTime,omitempty"` // Minutes idle
+	UserType     string   `json:"userType" xml:"userType"`                     // "aim", "icq", "admin"
+	Bot          bool     `json:"bot" xml:"bot"`
+	Service      string   `json:"service,omitempty" xml:"service,omitempty"` // "AIM", "ICQ" (Web AIM client compares case-sensitively)
+	PresenceIcon string   `json:"presenceIcon,omitempty" xml:"presenceIcon,omitempty"`
+	BuddyIcon    string   `json:"buddyIcon,omitempty" xml:"buddyIcon,omitempty"`
+	Capabilities []string `json:"capabilities,omitempty" xml:"capabilities>capability,omitempty"`
+	MemberSince  int64    `json:"memberSince,omitempty" xml:"memberSince,omitempty"`
 }
 
 // GetBuddyListForUser retrieves and converts the buddy list for a user.
@@ -565,72 +568,3 @@ func (m *BuddyListManager) SetGroupAttributeInFeedbag(ctx context.Context, sess
 
 	return "success", nil
 }
-
-// FormatBuddyListEvent formats a buddy list for an event.
-func (m *BuddyListManager) FormatBuddyListEvent(groups []WebAPIBuddyGroup) map[string]interface{} {
-	// Convert groups to a format that AMF3 can properly encode
-	// AMF3 has trouble with complex struct slices, so convert to maps
-	groupMaps := make([]interface{}, len(groups))
-	for i, group := range groups {
-		buddyMaps := make([]interface{}, len(group.Buddies))
-		for j, buddy := range group.Buddies {
-			// Convert each buddy to a map
-			buddyMap := map[string]interface{}{
-				"aimId":     buddy.AimID,
-				"displayId": buddy.DisplayID,
-				"state":     buddy.State,
-				"userType":  buddy.UserType,
-				"bot":       buddy.Bot,
-				"service":   buddy.Service,
-			}
-
-			// Add optional fields if present
-			if buddy.StatusMsg != "" {
-				buddyMap["statusMsg"] = buddy.StatusMsg
-			}
-			if buddy.AwayMsg != "" {
-				buddyMap["awayMsg"] = buddy.AwayMsg
-			}
-			if buddy.OnlineTime > 0 {
-				buddyMap["onlineTime"] = float64(buddy.OnlineTime)
-			}
-			if buddy.IdleTime > 0 {
-				buddyMap["idleTime"] = buddy.IdleTime
-			}
-			if buddy.PresenceIcon != "" {
-				buddyMap["presenceIcon"] = buddy.PresenceIcon
-			}
-			if buddy.BuddyIcon != "" {
-				buddyMap["buddyIcon"] = buddy.BuddyIcon
-			}
-			if len(buddy.Capabilities) > 0 {
-				buddyMap["capabilities"] = buddy.Capabilities
-			}
-			if buddy.MemberSince > 0 {
-				buddyMap["memberSince"] = float64(buddy.MemberSince)
-			}
-
-			buddyMaps[j] = buddyMap
-		}
-
-		// Convert group to a map
-		groupMap := map[string]interface{}{
-			"name":    group.Name,
-			"buddies": buddyMaps,
-		}
-
-		// Add optional group fields
-		if group.Recent {
-			groupMap["recent"] = group.Recent
-		}
-		if group.Smart != nil {
-			groupMap["smart"] = group.Smart
-		}
-
-		groupMaps[i] = groupMap
-	}
-
-	return map[string]interface{}{
-		"groups": groupMaps,
-	}
-}

+ 28 - 39
server/webapi/handlers/buddylist.go

@@ -52,11 +52,9 @@ func (h *BuddyListHandler) AddBuddy(w http.ResponseWriter, r *http.Request, sess
 	resultCode, buddyInfo := h.addBuddyToFeedbag(ctx, session, buddyName, groupName)
 
 	// Prepare response
-	responseData := map[string]any{
-		"resultCode": resultCode,
-	}
+	responseData := &ResultCodeData{ResultCode: resultCode}
 	if resultCode == "success" {
-		responseData["buddyInfo"] = buddyInfo
+		responseData.BuddyInfo = buddyInfo
 	}
 
 	resp := BaseResponse{}
@@ -70,7 +68,7 @@ func (h *BuddyListHandler) AddBuddy(w http.ResponseWriter, r *http.Request, sess
 		if err != nil {
 			h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
 		} else {
-			blPayload := map[string]any{"groups": groups}
+			blPayload := &BuddyListData{Groups: groups}
 			session.EventQueue.Push(types.EventTypeBuddyList, blPayload)
 		}
 	}
@@ -99,9 +97,7 @@ func (h *BuddyListHandler) AddGroup(w http.ResponseWriter, r *http.Request, sess
 	resp := BaseResponse{}
 	resp.Response.StatusCode = 200
 	resp.Response.StatusText = "OK"
-	resp.Response.Data = map[string]any{
-		"resultCode": resultCode,
-	}
+	resp.Response.Data = &ResultCodeData{ResultCode: resultCode}
 	SendResponse(w, r, resp, h.Logger)
 
 	if resultCode == "success" {
@@ -109,7 +105,7 @@ func (h *BuddyListHandler) AddGroup(w http.ResponseWriter, r *http.Request, sess
 		if err != nil {
 			h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
 		} else {
-			blPayload := map[string]any{"groups": groups}
+			blPayload := &BuddyListData{Groups: groups}
 			session.EventQueue.Push(types.EventTypeBuddyList, blPayload)
 		}
 	}
@@ -210,9 +206,7 @@ func (h *BuddyListHandler) RemoveBuddy(w http.ResponseWriter, r *http.Request, s
 	resp := BaseResponse{}
 	resp.Response.StatusCode = 200
 	resp.Response.StatusText = "OK"
-	resp.Response.Data = map[string]any{
-		"resultCode": resultCode,
-	}
+	resp.Response.Data = &ResultCodeData{ResultCode: resultCode}
 	SendResponse(w, r, resp, h.Logger)
 
 	if resultCode == "success" {
@@ -220,7 +214,7 @@ func (h *BuddyListHandler) RemoveBuddy(w http.ResponseWriter, r *http.Request, s
 		if err != nil {
 			h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
 		} else {
-			blPayload := map[string]any{"groups": groups}
+			blPayload := &BuddyListData{Groups: groups}
 			session.EventQueue.Push(types.EventTypeBuddyList, blPayload)
 		}
 	}
@@ -253,9 +247,7 @@ func (h *BuddyListHandler) RemoveGroup(w http.ResponseWriter, r *http.Request, s
 	resp := BaseResponse{}
 	resp.Response.StatusCode = 200
 	resp.Response.StatusText = "OK"
-	resp.Response.Data = map[string]any{
-		"resultCode": resultCode,
-	}
+	resp.Response.Data = &ResultCodeData{ResultCode: resultCode}
 	SendResponse(w, r, resp, h.Logger)
 
 	if resultCode == "success" {
@@ -263,7 +255,7 @@ func (h *BuddyListHandler) RemoveGroup(w http.ResponseWriter, r *http.Request, s
 		if err != nil {
 			h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
 		} else {
-			blPayload := map[string]any{"groups": groups}
+			blPayload := &BuddyListData{Groups: groups}
 			session.EventQueue.Push(types.EventTypeBuddyList, blPayload)
 		}
 	}
@@ -384,10 +376,7 @@ func (h *BuddyListHandler) AddTempBuddy(w http.ResponseWriter, r *http.Request,
 	}
 
 	// Prepare response
-	responseData := map[string]any{
-		"resultCode": "success",
-		"buddyNames": buddyNames,
-	}
+	responseData := &ResultCodeData{ResultCode: "success", BuddyNames: buddyNames}
 
 	resp := BaseResponse{}
 	resp.Response.StatusCode = 200
@@ -433,10 +422,7 @@ func (h *BuddyListHandler) RemoveTempBuddy(w http.ResponseWriter, r *http.Reques
 	resp := BaseResponse{}
 	resp.Response.StatusCode = 200
 	resp.Response.StatusText = "OK"
-	resp.Response.Data = map[string]any{
-		"resultCode": "success",
-		"buddyNames": removed,
-	}
+	resp.Response.Data = &ResultCodeData{ResultCode: "success", BuddyNames: removed}
 	SendResponse(w, r, resp, h.Logger)
 
 	h.Logger.InfoContext(ctx, "temporary buddies removed",
@@ -470,9 +456,7 @@ func (h *BuddyListHandler) RenameGroup(w http.ResponseWriter, r *http.Request, s
 	resp := BaseResponse{}
 	resp.Response.StatusCode = 200
 	resp.Response.StatusText = "OK"
-	resp.Response.Data = map[string]any{
-		"resultCode": resultCode,
-	}
+	resp.Response.Data = &ResultCodeData{ResultCode: resultCode}
 	SendResponse(w, r, resp, h.Logger)
 
 	if resultCode == "success" {
@@ -518,9 +502,7 @@ func (h *BuddyListHandler) MoveBuddy(w http.ResponseWriter, r *http.Request, ses
 	resp := BaseResponse{}
 	resp.Response.StatusCode = 200
 	resp.Response.StatusText = "OK"
-	resp.Response.Data = map[string]any{
-		"resultCode": resultCode,
-	}
+	resp.Response.Data = &ResultCodeData{ResultCode: resultCode}
 	SendResponse(w, r, resp, h.Logger)
 
 	if resultCode == "success" {
@@ -561,9 +543,7 @@ func (h *BuddyListHandler) SetBuddyAttribute(w http.ResponseWriter, r *http.Requ
 	resp := BaseResponse{}
 	resp.Response.StatusCode = 200
 	resp.Response.StatusText = "OK"
-	resp.Response.Data = map[string]any{
-		"resultCode": resultCode,
-	}
+	resp.Response.Data = &ResultCodeData{ResultCode: resultCode}
 	SendResponse(w, r, resp, h.Logger)
 
 	if resultCode == "success" {
@@ -604,9 +584,7 @@ func (h *BuddyListHandler) SetGroupAttribute(w http.ResponseWriter, r *http.Requ
 	resp := BaseResponse{}
 	resp.Response.StatusCode = 200
 	resp.Response.StatusText = "OK"
-	resp.Response.Data = map[string]any{
-		"resultCode": resultCode,
-	}
+	resp.Response.Data = &ResultCodeData{ResultCode: resultCode}
 	SendResponse(w, r, resp, h.Logger)
 
 	if resultCode == "success" {
@@ -629,8 +607,19 @@ func (h *BuddyListHandler) pushBuddyListEvent(ctx context.Context, session *stat
 		h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
 		return
 	}
-	blPayload := map[string]any{"groups": groups}
-	session.EventQueue.Push(types.EventTypeBuddyList, blPayload)
+	session.EventQueue.Push(types.EventTypeBuddyList, &BuddyListData{Groups: groups})
+}
+
+// ResultCodeData is the payload the buddy list editing methods answer with.
+//
+// The spec shows these methods returning an empty data; the Web AIM client
+// reads resultCode from it, so the server sends one.
+type ResultCodeData struct {
+	ResultCode string `json:"resultCode" xml:"resultCode"`
+	// BuddyInfo accompanies a successful addBuddy only.
+	BuddyInfo *BuddyPresenceInfo `json:"buddyInfo,omitempty" xml:"buddyInfo,omitempty"`
+	// BuddyNames accompanies the temp-buddy methods only.
+	BuddyNames []string `json:"buddyNames,omitempty" xml:"buddyNames>buddyName,omitempty"`
 }
 
 // sendError is a convenience method that wraps the common SendError function.

+ 25 - 27
server/webapi/handlers/buddylist_test.go

@@ -7,7 +7,6 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"net/url"
-	"strings"
 	"testing"
 	"time"
 
@@ -127,7 +126,7 @@ func TestBuddyListHandler_AddTempBuddy(t *testing.T) {
 				LastAccessed: time.Now(),
 			},
 			expectedStatusCode: http.StatusBadRequest,
-			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing buddy names (t parameter)"}}`,
+			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing buddy names (t parameter)","data":{}}}`,
 		},
 		{
 			name: "Success_WithWhitespace",
@@ -178,7 +177,7 @@ func TestBuddyListHandler_AddTempBuddy(t *testing.T) {
 			handler.AddTempBuddy(rr, req, tt.session)
 
 			assert.Equal(t, tt.expectedStatusCode, rr.Code)
-			assert.Equal(t, tt.expectedResponse, strings.TrimSpace(rr.Body.String()))
+			assert.JSONEq(t, tt.expectedResponse, rr.Body.String())
 
 			if tt.checkSession != nil && tt.session != nil {
 				tt.checkSession(t, tt.session)
@@ -368,7 +367,7 @@ func TestBuddyListHandler_AddBuddy(t *testing.T) {
 				}
 			},
 			expectedStatusCode: http.StatusBadRequest,
-			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing buddy parameter"}}`,
+			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing buddy parameter","data":{}}}`,
 		},
 	}
 
@@ -410,8 +409,7 @@ func TestBuddyListHandler_AddBuddy(t *testing.T) {
 			handler.AddBuddy(rr, req, session)
 
 			assert.Equal(t, tt.expectedStatusCode, rr.Code)
-			responseBody := strings.TrimSpace(rr.Body.String())
-			assert.Equal(t, tt.expectedResponse, responseBody)
+			assert.JSONEq(t, tt.expectedResponse, rr.Body.String())
 
 			feedbagService.AssertExpectations(t)
 			blmFeedbagService.AssertExpectations(t)
@@ -445,7 +443,7 @@ func TestBuddyListHandler_AddGroup(t *testing.T) {
 				return newSession(aimsid)
 			},
 			expectedStatusCode: http.StatusBadRequest,
-			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing group parameter"}}`,
+			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing group parameter","data":{}}}`,
 		},
 		{
 			name:        "Success_GroupAdded",
@@ -539,7 +537,7 @@ func TestBuddyListHandler_AddGroup(t *testing.T) {
 			handler.AddGroup(rr, req, session)
 
 			assert.Equal(t, tt.expectedStatusCode, rr.Code)
-			assert.Equal(t, tt.expectedResponse, strings.TrimSpace(rr.Body.String()))
+			assert.JSONEq(t, tt.expectedResponse, rr.Body.String())
 			fs.AssertExpectations(t)
 			blmFs.AssertExpectations(t)
 		})
@@ -567,7 +565,7 @@ func TestBuddyListHandler_RemoveBuddy(t *testing.T) {
 				return &state.WebAPISession{AimSID: aimsid, ScreenName: state.DisplayScreenName("testuser"), LastAccessed: time.Now()}
 			},
 			expectedStatusCode: http.StatusBadRequest,
-			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing buddy parameter"}}`,
+			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing buddy parameter","data":{}}}`,
 		},
 		{
 			name:        "Success_BuddyRemoved",
@@ -695,7 +693,7 @@ func TestBuddyListHandler_RemoveBuddy(t *testing.T) {
 			handler.RemoveBuddy(rr, req, session)
 
 			assert.Equal(t, tt.expectedStatusCode, rr.Code)
-			assert.Equal(t, tt.expectedResponse, strings.TrimSpace(rr.Body.String()))
+			assert.JSONEq(t, tt.expectedResponse, rr.Body.String())
 			fs.AssertExpectations(t)
 		})
 	}
@@ -722,7 +720,7 @@ func TestBuddyListHandler_RemoveGroup(t *testing.T) {
 				return &state.WebAPISession{AimSID: aimsid, ScreenName: state.DisplayScreenName("testuser"), LastAccessed: time.Now()}
 			},
 			expectedStatusCode: http.StatusBadRequest,
-			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing group parameter"}}`,
+			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing group parameter","data":{}}}`,
 		},
 		{
 			name:        "Success_GroupRemoved",
@@ -840,7 +838,7 @@ func TestBuddyListHandler_RemoveGroup(t *testing.T) {
 			handler.RemoveGroup(rr, req, session)
 
 			assert.Equal(t, tt.expectedStatusCode, rr.Code)
-			assert.Equal(t, tt.expectedResponse, strings.TrimSpace(rr.Body.String()))
+			assert.JSONEq(t, tt.expectedResponse, rr.Body.String())
 			fs.AssertExpectations(t)
 		})
 	}
@@ -860,7 +858,7 @@ func TestRequireSession(t *testing.T) {
 			aimsid:             "",
 			setupMocks:         func(sm *MockWebAPISessionManager, aimsid string) {},
 			expectedStatusCode: http.StatusBadRequest,
-			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing aimsid parameter"}}`,
+			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing aimsid parameter","data":{}}}`,
 			expectNextCalled:   false,
 		},
 		{
@@ -870,7 +868,7 @@ func TestRequireSession(t *testing.T) {
 				sm.On("GetSession", mock.Anything, aimsid).Return(nil, state.ErrNoWebAPISession)
 			},
 			expectedStatusCode: http.StatusUnauthorized,
-			expectedResponse:   `{"response":{"statusCode":401,"statusText":"invalid or expired session"}}`,
+			expectedResponse:   `{"response":{"statusCode":401,"statusText":"invalid or expired session","data":{}}}`,
 			expectNextCalled:   false,
 		},
 		{
@@ -880,7 +878,7 @@ func TestRequireSession(t *testing.T) {
 				sm.On("GetSession", mock.Anything, aimsid).Return(nil, state.ErrWebAPISessionExpired)
 			},
 			expectedStatusCode: http.StatusUnauthorized,
-			expectedResponse:   `{"response":{"statusCode":401,"statusText":"invalid or expired session"}}`,
+			expectedResponse:   `{"response":{"statusCode":401,"statusText":"invalid or expired session","data":{}}}`,
 			expectNextCalled:   false,
 		},
 		{
@@ -890,7 +888,7 @@ func TestRequireSession(t *testing.T) {
 				sm.On("GetSession", mock.Anything, aimsid).Return(nil, errors.New("db error"))
 			},
 			expectedStatusCode: http.StatusUnauthorized,
-			expectedResponse:   `{"response":{"statusCode":401,"statusText":"invalid or expired session"}}`,
+			expectedResponse:   `{"response":{"statusCode":401,"statusText":"invalid or expired session","data":{}}}`,
 			expectNextCalled:   false,
 		},
 		{
@@ -907,7 +905,7 @@ func TestRequireSession(t *testing.T) {
 				sm.On("GetSession", mock.Anything, aimsid).Return(sess, nil)
 			},
 			expectedStatusCode: http.StatusInternalServerError,
-			expectedResponse:   `{"response":{"statusCode":500,"statusText":"internal server error"}}`,
+			expectedResponse:   `{"response":{"statusCode":500,"statusText":"internal server error","data":{}}}`,
 			expectNextCalled:   false,
 		},
 		{
@@ -924,7 +922,7 @@ func TestRequireSession(t *testing.T) {
 				sm.On("TouchSession", mock.Anything, aimsid).Return(nil)
 			},
 			expectedStatusCode: http.StatusOK,
-			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK"}}`,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{}}}`,
 			expectNextCalled:   true,
 		},
 	}
@@ -957,7 +955,7 @@ func TestRequireSession(t *testing.T) {
 			wrapped.ServeHTTP(rr, req)
 
 			assert.Equal(t, tt.expectedStatusCode, rr.Code)
-			assert.Equal(t, tt.expectedResponse, strings.TrimSpace(rr.Body.String()))
+			assert.JSONEq(t, tt.expectedResponse, rr.Body.String())
 			assert.Equal(t, tt.expectNextCalled, nextCalled)
 
 			sm.AssertExpectations(t)
@@ -993,7 +991,7 @@ func TestBuddyListHandler_RenameGroup(t *testing.T) {
 				return &state.WebAPISession{AimSID: aimsid, LastAccessed: time.Now()}
 			},
 			expectStatusCode: http.StatusBadRequest,
-			expectResponse:   `{"response":{"statusCode":400,"statusText":"missing oldGroup or newGroup parameter"}}`,
+			expectResponse:   `{"response":{"statusCode":400,"statusText":"missing oldGroup or newGroup parameter","data":{}}}`,
 		},
 		{
 			name:        "Success",
@@ -1044,7 +1042,7 @@ func TestBuddyListHandler_RenameGroup(t *testing.T) {
 			handler.RenameGroup(rr, req, session)
 
 			assert.Equal(t, tt.expectStatusCode, rr.Code)
-			assert.Equal(t, tt.expectResponse, strings.TrimSpace(rr.Body.String()))
+			assert.JSONEq(t, tt.expectResponse, rr.Body.String())
 			fs.AssertExpectations(t)
 		})
 	}
@@ -1078,7 +1076,7 @@ func TestBuddyListHandler_MoveBuddy(t *testing.T) {
 				return &state.WebAPISession{AimSID: aimsid, LastAccessed: time.Now()}
 			},
 			expectStatusCode: http.StatusBadRequest,
-			expectResponse:   `{"response":{"statusCode":400,"statusText":"missing buddy parameter"}}`,
+			expectResponse:   `{"response":{"statusCode":400,"statusText":"missing buddy parameter","data":{}}}`,
 		},
 		{
 			name:        "Success_Reorder",
@@ -1136,7 +1134,7 @@ func TestBuddyListHandler_MoveBuddy(t *testing.T) {
 			handler.MoveBuddy(rr, req, session)
 
 			assert.Equal(t, tt.expectStatusCode, rr.Code)
-			assert.Equal(t, tt.expectResponse, strings.TrimSpace(rr.Body.String()))
+			assert.JSONEq(t, tt.expectResponse, rr.Body.String())
 			fs.AssertExpectations(t)
 		})
 	}
@@ -1170,7 +1168,7 @@ func TestBuddyListHandler_SetBuddyAttribute(t *testing.T) {
 				return &state.WebAPISession{AimSID: aimsid, LastAccessed: time.Now()}
 			},
 			expectStatusCode: http.StatusBadRequest,
-			expectResponse:   `{"response":{"statusCode":400,"statusText":"missing t parameter"}}`,
+			expectResponse:   `{"response":{"statusCode":400,"statusText":"missing t parameter","data":{}}}`,
 		},
 		{
 			name:        "Success",
@@ -1221,7 +1219,7 @@ func TestBuddyListHandler_SetBuddyAttribute(t *testing.T) {
 			handler.SetBuddyAttribute(rr, req, session)
 
 			assert.Equal(t, tt.expectStatusCode, rr.Code)
-			assert.Equal(t, tt.expectResponse, strings.TrimSpace(rr.Body.String()))
+			assert.JSONEq(t, tt.expectResponse, rr.Body.String())
 			fs.AssertExpectations(t)
 		})
 	}
@@ -1255,7 +1253,7 @@ func TestBuddyListHandler_SetGroupAttribute(t *testing.T) {
 				return &state.WebAPISession{AimSID: aimsid, LastAccessed: time.Now()}
 			},
 			expectStatusCode: http.StatusBadRequest,
-			expectResponse:   `{"response":{"statusCode":400,"statusText":"missing collapsed parameter"}}`,
+			expectResponse:   `{"response":{"statusCode":400,"statusText":"missing collapsed parameter","data":{}}}`,
 		},
 		{
 			name:        "Success",
@@ -1306,7 +1304,7 @@ func TestBuddyListHandler_SetGroupAttribute(t *testing.T) {
 			handler.SetGroupAttribute(rr, req, session)
 
 			assert.Equal(t, tt.expectStatusCode, rr.Code)
-			assert.Equal(t, tt.expectResponse, strings.TrimSpace(rr.Body.String()))
+			assert.JSONEq(t, tt.expectResponse, rr.Body.String())
 			fs.AssertExpectations(t)
 		})
 	}

+ 52 - 125
server/webapi/handlers/common.go

@@ -15,62 +15,51 @@ import (
 // BaseResponse is the standard response envelope for all Web API responses.
 // It supports both JSON and XML marshaling.
 type BaseResponse struct {
-	XMLName  xml.Name     `xml:"response" json:"-"`
 	Response ResponseBody `json:"response"`
 }
 
+// MarshalXML renders the envelope as the Web API's flat <response> root, where
+// JSON nests the same body under a "response" key. Reconciling the two shapes
+// here is what lets one struct describe a response in both formats.
+func (b BaseResponse) MarshalXML(e *xml.Encoder, _ xml.StartElement) error {
+	return e.EncodeElement(b.Response, xml.StartElement{Name: xml.Name{Local: "response"}})
+}
+
 // ResponseBody contains the status and data for API responses.
 type ResponseBody struct {
-	StatusCode int         `json:"statusCode" xml:"statusCode"`
-	StatusText string      `json:"statusText" xml:"statusText"`
-	RequestID  string      `json:"requestId,omitempty" xml:"requestId,omitempty"`
-	Data       interface{} `json:"data,omitempty" xml:"data,omitempty"`
+	StatusCode int    `json:"statusCode" xml:"statusCode"`
+	StatusText string `json:"statusText" xml:"statusText"`
+	RequestID  string `json:"requestId,omitempty" xml:"requestId,omitempty"`
+	// Data is never omitted. Every Web API method sends a data element even when
+	// it carries no payload, and the client dereferences response.data on any
+	// success; SendResponse substitutes an empty object when a handler sets none.
+	Data interface{} `json:"data" xml:"data"`
 }
 
 // ErrorResponse represents an error response with proper XML/JSON support.
 type ErrorResponse struct {
-	XMLName  xml.Name `xml:"response" json:"-"`
 	Response struct {
 		StatusCode int    `json:"statusCode" xml:"statusCode"`
 		StatusText string `json:"statusText" xml:"statusText"`
-	} `json:"response" xml:"-"`
-	// For XML responses, flatten the structure
-	StatusCode int    `json:"-" xml:"statusCode"`
-	StatusText string `json:"-" xml:"statusText"`
+		// Data carries an empty object for the same reason the JSONP error path
+		// sends one: a client callback that reaches response.data on a failure
+		// throws a TypeError when it is absent.
+		Data interface{} `json:"data" xml:"data"`
+	} `json:"response"`
 }
 
-// XMLMapResponse is a helper struct for converting map-based responses to XML
-type XMLMapResponse struct {
-	XMLName    xml.Name `xml:"response"`
-	StatusCode int      `xml:"statusCode"`
-	StatusText string   `xml:"statusText"`
-	Data       XMLData  `xml:"data,omitempty"`
+// MarshalXML renders the error envelope with the same flat root as BaseResponse.
+func (e ErrorResponse) MarshalXML(enc *xml.Encoder, _ xml.StartElement) error {
+	return enc.EncodeElement(e.Response, xml.StartElement{Name: xml.Name{Local: "response"}})
 }
 
-// XMLData wraps the data for XML responses
-type XMLData struct {
-	// Auth response fields
-	Token          *XMLToken `xml:"token,omitempty"`
-	LoginID        string    `xml:"loginId,omitempty"`
-	ScreenName     string    `xml:"screenName,omitempty"`
-	SessionSecret  string    `xml:"sessionSecret,omitempty"`
-	HostTime       int64     `xml:"hostTime,omitempty"`
-	TokenExpiresIn int       `xml:"tokenExpiresIn,omitempty"`
-
-	// Generic fields for other responses
-	AimSID   string `xml:"aimsid,omitempty"`
-	FetchURL string `xml:"fetchUrl,omitempty"`
-	MsgID    string `xml:"msgId,omitempty"`
-	State    string `xml:"state,omitempty"`
-
-	// For any other data, we'll encode as string
-	Raw string `xml:",chardata"`
-}
-
-// XMLToken represents the token structure in XML
-type XMLToken struct {
-	A         string `xml:"a"`
-	ExpiresIn int    `xml:"expiresIn"`
+// newErrorResponse builds the error envelope every format shares.
+func newErrorResponse(statusCode int, message string) ErrorResponse {
+	resp := ErrorResponse{}
+	resp.Response.StatusCode = statusCode
+	resp.Response.StatusText = message
+	resp.Response.Data = struct{}{}
+	return resp
 }
 
 // requestIDFromRequest returns the Web AIM client request correlation id from the
@@ -82,25 +71,28 @@ func requestIDFromRequest(r *http.Request) string {
 	return r.URL.Query().Get("r")
 }
 
-// attachRequestID copies the request's "r" parameter into BaseResponse.requestId
-// when the handler did not set one explicitly.
-func attachRequestID(r *http.Request, data interface{}) interface{} {
-	id := requestIDFromRequest(r)
-	if id == "" {
-		return data
-	}
+// normalizeEnvelope fills in the envelope fields a handler does not set itself:
+// the request correlation id, and an empty data object for a response that
+// carries no payload. Both are things every encoder needs and none can infer —
+// and encoding/xml has no way to render a nil data at all.
+func normalizeEnvelope(r *http.Request, data interface{}) interface{} {
 	br, ok := data.(BaseResponse)
-	if !ok || br.Response.RequestID != "" {
+	if !ok {
 		return data
 	}
-	br.Response.RequestID = id
+	if br.Response.RequestID == "" {
+		br.Response.RequestID = requestIDFromRequest(r)
+	}
+	if br.Response.Data == nil {
+		br.Response.Data = struct{}{}
+	}
 	return br
 }
 
 // SendResponse sends a response in the requested format (JSON, JSONP, XML, or AMF).
 // This is the centralized function that all handlers should use for responses.
 func SendResponse(w http.ResponseWriter, r *http.Request, data interface{}, logger *slog.Logger) {
-	data = attachRequestID(r, data)
+	data = normalizeEnvelope(r, data)
 
 	// Check for format parameter (f for format or callback for JSONP)
 	// First check URL query parameters
@@ -180,6 +172,11 @@ func sendJSONPError(w http.ResponseWriter, r *http.Request, callback string, sta
 	envelope := map[string]any{
 		"statusCode": statusCode,
 		"statusText": message,
+		// Callbacks that reach response.data on a failure throw a TypeError when
+		// it is absent, which aborts whatever the client was doing mid-startup.
+		// Its XHR path fabricates an empty data for transport failures; JSONP
+		// delivers the envelope verbatim, so the empty data has to come from here.
+		"data": map[string]any{},
 	}
 	// The client indexes JSONP replies by response.requestId and discards any
 	// reply that lacks one ("Request id is missing from the server response"),
@@ -203,9 +200,7 @@ func sendJSONPError(w http.ResponseWriter, r *http.Request, callback string, sta
 
 // sendJSONError sends a JSON error response.
 func sendJSONError(w http.ResponseWriter, statusCode int, message string) {
-	resp := ErrorResponse{}
-	resp.Response.StatusCode = statusCode
-	resp.Response.StatusText = message
+	resp := newErrorResponse(statusCode, message)
 
 	w.Header().Set("Content-Type", "application/json")
 	w.WriteHeader(statusCode)
@@ -214,9 +209,7 @@ func sendJSONError(w http.ResponseWriter, statusCode int, message string) {
 
 // sendXMLError sends an XML error response.
 func sendXMLError(w http.ResponseWriter, statusCode int, message string) {
-	resp := ErrorResponse{}
-	resp.StatusCode = statusCode
-	resp.StatusText = message
+	resp := newErrorResponse(statusCode, message)
 
 	w.Header().Set("Content-Type", "text/xml; charset=utf-8")
 	w.WriteHeader(statusCode)
@@ -255,12 +248,8 @@ func sendJSON(w http.ResponseWriter, data interface{}, logger *slog.Logger) {
 func sendXML(w http.ResponseWriter, data interface{}, logger *slog.Logger) {
 	w.Header().Set("Content-Type", "text/xml; charset=utf-8")
 
-	// Convert BaseResponse with map data to a format XML can handle
-	if baseResp, ok := data.(BaseResponse); ok {
-		data = convertBaseResponseForXML(baseResp)
-	}
-
-	// Marshal the data
+	// Every payload is a struct whose xml tags name its elements, and the
+	// envelope's MarshalXML renders the flat <response> root the Web API uses.
 	xmlData, err := xml.Marshal(data)
 	if err != nil {
 		if logger != nil {
@@ -383,71 +372,9 @@ func sendAMF(w http.ResponseWriter, r *http.Request, data interface{}, logger *s
 	}
 }
 
-// convertBaseResponseForXML converts a BaseResponse with map data to XMLMapResponse
-func convertBaseResponseForXML(resp BaseResponse) XMLMapResponse {
-	xmlResp := XMLMapResponse{
-		StatusCode: resp.Response.StatusCode,
-		StatusText: resp.Response.StatusText,
-	}
-
-	// Convert map data to XMLData struct
-	if dataMap, ok := resp.Response.Data.(map[string]interface{}); ok {
-		xmlData := XMLData{}
-
-		// Handle auth response fields
-		if tokenData, ok := dataMap["token"].(map[string]interface{}); ok {
-			xmlData.Token = &XMLToken{}
-			if a, ok := tokenData["a"].(string); ok {
-				xmlData.Token.A = a
-			}
-			if expiresIn, ok := tokenData["expiresIn"].(int); ok {
-				xmlData.Token.ExpiresIn = expiresIn
-			}
-		}
-
-		if loginId, ok := dataMap["loginId"].(string); ok {
-			xmlData.LoginID = loginId
-		}
-		if screenName, ok := dataMap["screenName"].(string); ok {
-			xmlData.ScreenName = screenName
-		}
-		if sessionSecret, ok := dataMap["sessionSecret"].(string); ok {
-			xmlData.SessionSecret = sessionSecret
-		}
-		if hostTime, ok := dataMap["hostTime"].(int64); ok {
-			xmlData.HostTime = hostTime
-		}
-		if tokenExpiresIn, ok := dataMap["tokenExpiresIn"].(int); ok {
-			xmlData.TokenExpiresIn = tokenExpiresIn
-		}
-
-		// Handle session response fields
-		if aimsid, ok := dataMap["aimsid"].(string); ok {
-			xmlData.AimSID = aimsid
-		}
-		if fetchUrl, ok := dataMap["fetchUrl"].(string); ok {
-			xmlData.FetchURL = fetchUrl
-		}
-
-		// Handle message response fields
-		if msgId, ok := dataMap["msgId"].(string); ok {
-			xmlData.MsgID = msgId
-		}
-		if state, ok := dataMap["state"].(string); ok {
-			xmlData.State = state
-		}
-
-		xmlResp.Data = xmlData
-	}
-
-	return xmlResp
-}
-
 // sendAMFError sends an AMF error response
 func sendAMFError(w http.ResponseWriter, r *http.Request, statusCode int, message string, logger *slog.Logger) {
-	errorResp := ErrorResponse{}
-	errorResp.Response.StatusCode = statusCode
-	errorResp.Response.StatusText = message
+	errorResp := newErrorResponse(statusCode, message)
 
 	encoder := NewAMFEncoder(logger)
 	version := DetectAMFVersion(r)

+ 57 - 4
server/webapi/handlers/common_test.go

@@ -1,6 +1,7 @@
 package handlers
 
 import (
+	"encoding/xml"
 	"log/slog"
 	"net/http"
 	"net/http/httptest"
@@ -10,10 +11,10 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
-func TestAttachRequestID(t *testing.T) {
+func TestNormalizeEnvelope(t *testing.T) {
 	t.Run("sets requestId from r query param", func(t *testing.T) {
 		req := httptest.NewRequest("GET", "/buddylist/addBuddy?r=abc123", nil)
-		data := attachRequestID(req, BaseResponse{
+		data := normalizeEnvelope(req, BaseResponse{
 			Response: ResponseBody{
 				StatusCode: 200,
 				StatusText: "OK",
@@ -27,7 +28,7 @@ func TestAttachRequestID(t *testing.T) {
 
 	t.Run("preserves explicit requestId", func(t *testing.T) {
 		req := httptest.NewRequest("GET", "/buddylist/addBuddy?r=abc123", nil)
-		data := attachRequestID(req, BaseResponse{
+		data := normalizeEnvelope(req, BaseResponse{
 			Response: ResponseBody{
 				StatusCode: 200,
 				StatusText: "OK",
@@ -42,7 +43,7 @@ func TestAttachRequestID(t *testing.T) {
 
 	t.Run("no-op without r param", func(t *testing.T) {
 		req := httptest.NewRequest("GET", "/buddylist/addBuddy", nil)
-		data := attachRequestID(req, BaseResponse{
+		data := normalizeEnvelope(req, BaseResponse{
 			Response: ResponseBody{
 				StatusCode: 200,
 				StatusText: "OK",
@@ -139,3 +140,55 @@ func TestSendErrorJSONFallback(t *testing.T) {
 	assert.Contains(t, w.Header().Get("Content-Type"), "json")
 	assert.Contains(t, w.Body.String(), `"statusCode":404`)
 }
+
+// The Web API nests the envelope under a "response" key in JSON but renders it
+// as a flat <response> root in XML. MarshalXML reconciles the two so one struct
+// can describe a response in both formats.
+func TestEnvelopeMarshalXML(t *testing.T) {
+	t.Run("renders the flat response root", func(t *testing.T) {
+		resp := BaseResponse{}
+		resp.Response.StatusCode = 200
+		resp.Response.StatusText = "Ok"
+		resp.Response.RequestID = "123"
+		resp.Response.Data = struct {
+			AimSID string `json:"aimsid" xml:"aimsid"`
+		}{AimSID: "opaquedata"}
+
+		out, err := xml.Marshal(resp)
+		assert.NoError(t, err)
+		assert.Equal(t,
+			"<response><statusCode>200</statusCode><statusText>Ok</statusText>"+
+				"<requestId>123</requestId><data><aimsid>opaquedata</aimsid></data></response>",
+			string(out))
+	})
+
+	t.Run("renders an empty data element for a response with no payload", func(t *testing.T) {
+		req := httptest.NewRequest("GET", "/aim/endSession", nil)
+		resp := BaseResponse{}
+		resp.Response.StatusCode = 200
+		resp.Response.StatusText = "Ok"
+
+		out, err := xml.Marshal(normalizeEnvelope(req, resp))
+		assert.NoError(t, err)
+		assert.Contains(t, string(out), "<data></data>")
+	})
+
+	t.Run("error envelopes share the shape", func(t *testing.T) {
+		out, err := xml.Marshal(newErrorResponse(400, "bad request"))
+		assert.NoError(t, err)
+		assert.Equal(t,
+			"<response><statusCode>400</statusCode><statusText>bad request</statusText>"+
+				"<data></data></response>",
+			string(out))
+	})
+}
+
+// A handler that sets no data still sends one, because the client dereferences
+// response.data on any success.
+func TestNormalizeEnvelopeSuppliesEmptyData(t *testing.T) {
+	req := httptest.NewRequest("GET", "/aim/endSession", nil)
+
+	got := normalizeEnvelope(req, BaseResponse{}).(BaseResponse)
+
+	assert.Equal(t, struct{}{}, got.Response.Data)
+}

+ 6 - 3
server/webapi/handlers/conversation_stub.go

@@ -8,6 +8,11 @@ import (
 	"github.com/mk6i/open-oscar-server/state"
 )
 
+// StoredIMsData is the fetchStoredIMs payload.
+type StoredIMsData struct {
+	Msgs []state.StoredIM `json:"msgs" xml:"msgs>msg"`
+}
+
 // ConversationStubHandler serves Web AIM conversation/imlog endpoints the
 // client calls when syncing chat focus and read state.
 type ConversationStubHandler struct {
@@ -72,8 +77,6 @@ func (h *ConversationStubHandler) FetchStoredIMs(w http.ResponseWriter, r *http.
 	resp := BaseResponse{}
 	resp.Response.StatusCode = 200
 	resp.Response.StatusText = "OK"
-	resp.Response.Data = map[string]interface{}{
-		"msgs": msgs,
-	}
+	resp.Response.Data = &StoredIMsData{Msgs: msgs}
 	SendResponse(w, r, resp, h.Logger)
 }

+ 20 - 66
server/webapi/handlers/events.go

@@ -2,7 +2,6 @@ package handlers
 
 import (
 	"context"
-	"encoding/xml"
 	"fmt"
 	"log/slog"
 	"net/http"
@@ -20,34 +19,12 @@ type EventsHandler struct {
 	Logger         *slog.Logger
 }
 
-// FetchEventsResponse represents the response for fetchEvents endpoint.
-type FetchEventsResponse struct {
-	Response struct {
-		StatusCode int             `json:"statusCode"`
-		StatusText string          `json:"statusText"`
-		Data       FetchEventsData `json:"data"`
-	} `json:"response"`
-}
-
 // FetchEventsData contains the events and metadata.
 type FetchEventsData struct {
-	Events          []types.Event `json:"events"`
-	LastSeqNum      uint64        `json:"lastSeqNum"`
-	TimeToNextFetch int           `json:"timeToNextFetch"`
-	FetchBaseURL    string        `json:"fetchBaseURL"`
-}
-
-// FetchEventsXMLResponse represents the XML response for fetchEvents endpoint.
-type FetchEventsXMLResponse struct {
-	XMLName    xml.Name `xml:"response"`
-	StatusCode int      `xml:"statusCode"`
-	StatusText string   `xml:"statusText"`
-	Data       struct {
-		Events          []types.Event `xml:"events>event"`
-		LastSeqNum      uint64        `xml:"lastSeqNum"`
-		TimeToNextFetch int           `xml:"timeToNextFetch"`
-		FetchBaseURL    string        `xml:"fetchBaseURL"`
-	} `xml:"data"`
+	Events          []types.Event `json:"events" xml:"events>event"`
+	LastSeqNum      uint64        `json:"lastSeqNum" xml:"lastSeqNum"`
+	TimeToNextFetch int           `json:"timeToNextFetch" xml:"timeToNextFetch"`
+	FetchBaseURL    string        `json:"fetchBaseURL" xml:"fetchBaseURL"`
 }
 
 // FetchEvents handles GET /aim/fetchEvents requests with long-polling support.
@@ -100,63 +77,40 @@ func (h *EventsHandler) FetchEvents(w http.ResponseWriter, r *http.Request, sess
 	}
 
 	// Prepare response
-	resp := FetchEventsResponse{}
+	resp := BaseResponse{}
 	resp.Response.StatusCode = 200
 	resp.Response.StatusText = "OK"
-	resp.Response.Data.Events = events
-	resp.Response.Data.LastSeqNum = newLastSeqNum
-	resp.Response.Data.TimeToNextFetch = session.TimeToNextFetch
-	// Include fetchBaseURL with updated sequence number for next request
-	resp.Response.Data.FetchBaseURL = fmt.Sprintf("http://%s/aim/fetchEvents?aimsid=%s&seqNum=%d",
-		r.Host, aimsid, newLastSeqNum)
+	resp.Response.Data = &FetchEventsData{
+		Events:          events,
+		LastSeqNum:      newLastSeqNum,
+		TimeToNextFetch: session.TimeToNextFetch,
+		// Include fetchBaseURL with updated sequence number for next request
+		FetchBaseURL: fmt.Sprintf("http://%s/aim/fetchEvents?aimsid=%s&seqNum=%d",
+			r.Host, aimsid, newLastSeqNum),
+	}
 
-	// Check response format
+	// AMF3 clients (e.g. Gromit) take the events reshaped: timestamps as floats
+	// and the source/dest user objects flattened. That is a payload difference,
+	// not just an encoding one, so it stays here rather than in the encoder.
 	format := strings.ToLower(r.URL.Query().Get("f"))
-
-	switch format {
-	case "xml":
-		// Send XML response
-		xmlResp := FetchEventsXMLResponse{}
-		xmlResp.StatusCode = 200
-		xmlResp.StatusText = "OK"
-		xmlResp.Data.Events = events
-		xmlResp.Data.LastSeqNum = newLastSeqNum
-		xmlResp.Data.TimeToNextFetch = session.TimeToNextFetch
-		xmlResp.Data.FetchBaseURL = fmt.Sprintf("http://%s/aim/fetchEvents?aimsid=%s&seqNum=%d",
-			r.Host, aimsid, newLastSeqNum)
-
-		w.Header().Set("Content-Type", "text/xml")
-		_, _ = fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?>`)
-		if err := xml.NewEncoder(w).Encode(xmlResp); err != nil {
-			h.Logger.Error("failed to encode XML response", "error", err)
-		}
-	case "amf", "amf3":
-		// For AMF3, build the response with fields in the correct order
-		// The working implementation has: response { data {...}, statusCode, statusText, statusDetailCode }
-		// Convert events to ensure timestamps are float64 for AMF3
-		convertedEvents := ConvertEventsForAMF3(events)
-
+	if format == "amf" || format == "amf3" {
 		amfResp := map[string]interface{}{
 			"response": map[string]interface{}{
-				// Data comes FIRST (Gromit processes this large object)
 				"data": map[string]interface{}{
-					"events":          convertedEvents,
+					"events":          ConvertEventsForAMF3(events),
 					"lastSeqNum":      newLastSeqNum,
 					"timeToNextFetch": session.TimeToNextFetch,
 					"fetchBaseURL": fmt.Sprintf("http://%s/aim/fetchEvents?aimsid=%s&seqNum=%d",
 						r.Host, aimsid, newLastSeqNum),
 				},
-				// Status fields come AFTER data
 				"statusCode":       200,
 				"statusText":       "OK",
 				"statusDetailCode": 0,
 			},
 		}
-
-		// Use SendResponse which will detect AMF format and encode properly
 		SendResponse(w, r, amfResp, h.Logger)
-	default:
-		// Send JSON/JSONP response with standard structure
+	} else {
+		// Send response in requested format (JSON, JSONP, or XML)
 		SendResponse(w, r, resp, h.Logger)
 	}
 

+ 14 - 6
server/webapi/handlers/expressions.go

@@ -10,6 +10,17 @@ import (
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
+// ExpressionsData lists the expressions (buddy icons, etc.) a user publishes.
+type ExpressionsData struct {
+	Expressions []Expression `json:"expressions" xml:"expressions>expression"`
+}
+
+// Expression is one published asset.
+type Expression struct {
+	Type string `json:"type" xml:"type"`
+	URL  string `json:"url" xml:"url"`
+}
+
 // ExpressionsHandler handles Web AIM API expressions/buddy icon endpoints.
 type ExpressionsHandler struct {
 	IconSource BuddyIconSource
@@ -63,18 +74,15 @@ func (h *ExpressionsHandler) Get(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	expressions := []any{}
+	expressions := []Expression{}
 	if iconURL != "" {
-		expressions = append(expressions, map[string]any{
-			"type": "bigBuddyIcon",
-			"url":  iconURL,
-		})
+		expressions = append(expressions, Expression{Type: "bigBuddyIcon", URL: iconURL})
 	}
 
 	resp := BaseResponse{}
 	resp.Response.StatusCode = 200
 	resp.Response.StatusText = "OK"
-	resp.Response.Data = map[string]any{"expressions": expressions}
+	resp.Response.Data = &ExpressionsData{Expressions: expressions}
 	SendResponse(w, r, resp, h.Logger)
 }
 

+ 15 - 5
server/webapi/handlers/memberdir.go

@@ -81,6 +81,16 @@ type MemberDirInfo struct {
 	Profile MemberDirProfile `json:"profile" xml:"profile"`
 }
 
+// MemberDirResults wraps a directory search result set.
+type MemberDirResults struct {
+	Results MemberDirInfoArray `json:"results" xml:"results"`
+}
+
+// MemberDirInfoArray is the list of matched profiles.
+type MemberDirInfoArray struct {
+	InfoArray []MemberDirInfo `json:"infoArray" xml:"infoArray>info"`
+}
+
 // Search handles GET /memberDir/search. The web client sends the raw add-contact
 // input as a "match" parameter shaped like "keyword=<x>" or
 // "firstName=<x>,lastName=<y>". We translate that into an OSCAR ODir InfoQuery
@@ -94,7 +104,7 @@ func (h *MemberDirHandler) Search(w http.ResponseWriter, r *http.Request, sessio
 	reply, err := h.DirSearchService.InfoQuery(ctx, wire.SNACFrame{}, inBody)
 	if err != nil {
 		h.Logger.ErrorContext(ctx, "memberDir search failed", "err", err.Error())
-		h.sendData(w, r, map[string]any{"results": map[string]any{"infoArray": []MemberDirInfo{}}})
+		h.sendData(w, r, &MemberDirResults{Results: MemberDirInfoArray{InfoArray: []MemberDirInfo{}}})
 		return
 	}
 
@@ -102,7 +112,7 @@ func (h *MemberDirHandler) Search(w http.ResponseWriter, r *http.Request, sessio
 	if !ok || body.Status != wire.ODirSearchResponseOK {
 		// Missing/insufficient params or an empty directory: return no results
 		// rather than an error so the client simply shows an empty result set.
-		h.sendData(w, r, map[string]any{"results": map[string]any{"infoArray": []MemberDirInfo{}}})
+		h.sendData(w, r, &MemberDirResults{Results: MemberDirInfoArray{InfoArray: []MemberDirInfo{}}})
 		return
 	}
 
@@ -142,7 +152,7 @@ func (h *MemberDirHandler) Search(w http.ResponseWriter, r *http.Request, sessio
 		"results", len(infoArray),
 	)
 
-	h.sendData(w, r, map[string]any{"results": map[string]any{"infoArray": infoArray}})
+	h.sendData(w, r, &MemberDirResults{Results: MemberDirInfoArray{InfoArray: infoArray}})
 }
 
 // Get handles GET /memberDir/get. The "t" param names the screen names to look
@@ -191,7 +201,7 @@ func (h *MemberDirHandler) Get(w http.ResponseWriter, r *http.Request, session *
 		"targets", len(targets),
 	)
 
-	h.sendData(w, r, map[string]any{"infoArray": infoArray})
+	h.sendData(w, r, &MemberDirInfoArray{InfoArray: infoArray})
 }
 
 // Update handles GET /memberDir/update. The "Edit Your Name" form sends repeated
@@ -255,7 +265,7 @@ func (h *MemberDirHandler) Update(w http.ResponseWriter, r *http.Request, sessio
 		"lastName", values[wire.ODirTLVLastName],
 	)
 
-	h.sendData(w, r, map[string]any{})
+	h.sendData(w, r, struct{}{})
 }
 
 // sendData wraps data in the standard response envelope and sends it via

+ 8 - 5
server/webapi/handlers/messaging.go

@@ -175,10 +175,7 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request, sess *
 	)
 
 	// Send success response
-	responseData := map[string]interface{}{
-		"msgId": messageID,
-		"state": "delivered",
-	}
+	responseData := &SendIMData{MsgID: messageID, State: "delivered"}
 	response := BaseResponse{}
 	response.Response.StatusCode = 200
 	response.Response.StatusText = "OK"
@@ -253,7 +250,7 @@ func (h *MessagingHandler) pushSenderWebAPIEvents(sess *state.WebAPISession, rec
 	}
 	sess.EventQueue.Push(types.EventTypeSentIM, senderEventData)
 	if sess.IsSubscribedTo("conversation") {
-		sess.EventQueue.Push(types.EventTypeConversation, types.ConversationEventData("update", []map[string]interface{}{
+		sess.EventQueue.Push(types.EventTypeConversation, types.ConversationEventData("update", []types.ConversationEntryData{
 			types.ConversationEntry(recipientAimID, recipientDisplay, message, messageID, senderAimID, true, 0),
 		}))
 	}
@@ -304,6 +301,12 @@ func (h *MessagingHandler) SetTyping(w http.ResponseWriter, r *http.Request, ses
 	h.sendSuccessResponse(w, r, nil)
 }
 
+// SendIMData reports the fate of an accepted IM.
+type SendIMData struct {
+	MsgID string `json:"msgId" xml:"msgId"`
+	State string `json:"state" xml:"state"`
+}
+
 // sendSuccessResponse sends a success response in Web AIM API format
 func (h *MessagingHandler) sendSuccessResponse(w http.ResponseWriter, r *http.Request, data interface{}) {
 	response := BaseResponse{}

+ 18 - 16
server/webapi/handlers/messaging_test.go

@@ -132,16 +132,16 @@ func sendIMForDest(t *testing.T, dest, locateName, alias string) []types.Event {
 // properly formatted name the client already holds for that aimId.
 func TestMessagingHandler_SendIM_DestDisplayIDFromLocateReply(t *testing.T) {
 	var sentIM types.SentIMEvent
-	var conv map[string]interface{}
+	var conv types.ConversationEntryData
 	for _, event := range sendIMForDest(t, "mikelee", "Mike Lee", "") {
 		switch event.Type {
 		case types.EventTypeSentIM:
 			sentIM, _ = event.Data.(types.SentIMEvent)
 		case types.EventTypeConversation:
-			data, _ := event.Data.(map[string]interface{})
-			convs, _ := data["conversations"].([]map[string]interface{})
-			require.Len(t, convs, 1)
-			conv = convs[0]
+			data, _ := event.Data.(*types.ConversationData)
+			require.NotNil(t, data)
+			require.Len(t, data.Conversations, 1)
+			conv = data.Conversations[0]
 		}
 	}
 
@@ -150,9 +150,8 @@ func TestMessagingHandler_SendIM_DestDisplayIDFromLocateReply(t *testing.T) {
 	assert.Equal(t, "mikelee", sentIM.Dest.AimID)
 	assert.Equal(t, "Mike Lee", sentIM.Dest.DisplayID)
 
-	require.NotNil(t, conv)
-	assert.Equal(t, "mikelee", conv["aimId"])
-	assert.Equal(t, "Mike Lee", conv["displayId"])
+	assert.Equal(t, "mikelee", conv.AimID)
+	assert.Equal(t, "Mike Lee", conv.DisplayID)
 }
 
 // An alias is private to the sender and lives only in their feedbag, and the client
@@ -175,16 +174,16 @@ func TestMessagingHandler_SendIM_DestCarriesAlias(t *testing.T) {
 // rather than filled in with the aimId, leaving the client's existing name intact.
 func TestMessagingHandler_SendIM_OmitsDestDisplayIDWhenUnresolved(t *testing.T) {
 	var sentIM types.SentIMEvent
-	var conv map[string]interface{}
+	var conv types.ConversationEntryData
 	for _, event := range sendIMForDest(t, "mikelee", "", "") {
 		switch event.Type {
 		case types.EventTypeSentIM:
 			sentIM, _ = event.Data.(types.SentIMEvent)
 		case types.EventTypeConversation:
-			data, _ := event.Data.(map[string]interface{})
-			convs, _ := data["conversations"].([]map[string]interface{})
-			require.Len(t, convs, 1)
-			conv = convs[0]
+			data, _ := event.Data.(*types.ConversationData)
+			require.NotNil(t, data)
+			require.Len(t, data.Conversations, 1)
+			conv = data.Conversations[0]
 		}
 	}
 
@@ -194,9 +193,12 @@ func TestMessagingHandler_SendIM_OmitsDestDisplayIDWhenUnresolved(t *testing.T)
 	require.NoError(t, err)
 	assert.NotContains(t, string(encoded), "displayId\":\"mikelee\"")
 
-	require.NotNil(t, conv)
-	assert.Equal(t, "mikelee", conv["aimId"])
-	assert.NotContains(t, conv, "displayId")
+	assert.Equal(t, "mikelee", conv.AimID)
+	assert.Empty(t, conv.DisplayID)
+	// omitempty is what keeps it out of the payload.
+	encodedConv, err := json.Marshal(conv)
+	require.NoError(t, err)
+	assert.NotContains(t, string(encodedConv), "displayId")
 }
 
 func TestMessagingHandler_SendIM(t *testing.T) {

+ 6 - 23
server/webapi/handlers/oscar_bridge.go

@@ -65,7 +65,6 @@ type StartOSCARSessionRequest struct {
 
 // StartOSCARSessionResponse represents the response for startOSCARSession endpoint.
 type StartOSCARSessionResponse struct {
-	XMLName  xml.Name `xml:"response" json:"-"`
 	Response struct {
 		StatusCode int    `json:"statusCode" xml:"statusCode"`
 		StatusText string `json:"statusText" xml:"statusText"`
@@ -77,18 +76,12 @@ type StartOSCARSessionResponse struct {
 			Encryption  string `json:"encryption,omitempty" xml:"encryption,omitempty"`
 			Compression string `json:"compression,omitempty" xml:"compression,omitempty"`
 		} `json:"data" xml:"data"`
-	} `json:"response" xml:"-"`
-	// For XML responses, flatten the structure
-	StatusCode int    `json:"-" xml:"statusCode"`
-	StatusText string `json:"-" xml:"statusText"`
-	Data       struct {
-		Host        string `json:"-" xml:"host"`
-		Port        int    `json:"-" xml:"port"`
-		Cookie      string `json:"-" xml:"cookie"`
-		UseSSL      bool   `json:"-" xml:"useSSL"`
-		Encryption  string `json:"-" xml:"encryption,omitempty"`
-		Compression string `json:"-" xml:"compression,omitempty"`
-	} `json:"-" xml:"data"`
+	} `json:"response"`
+}
+
+// MarshalXML renders the envelope with the same flat root as BaseResponse.
+func (s StartOSCARSessionResponse) MarshalXML(e *xml.Encoder, _ xml.StartElement) error {
+	return e.EncodeElement(s.Response, xml.StartElement{Name: xml.Name{Local: "response"}})
 }
 
 // StartOSCARSession handles GET /aim/startOSCARSession requests.
@@ -309,16 +302,6 @@ func (h *OSCARBridgeHandler) buildResponse(host string, port int, cookie []byte,
 		resp.Response.Data.Compression = "none" // Compression not implemented
 	}
 
-	// Duplicate data for XML format
-	resp.StatusCode = resp.Response.StatusCode
-	resp.StatusText = resp.Response.StatusText
-	resp.Data.Host = resp.Response.Data.Host
-	resp.Data.Port = resp.Response.Data.Port
-	resp.Data.Cookie = resp.Response.Data.Cookie
-	resp.Data.UseSSL = resp.Response.Data.UseSSL
-	resp.Data.Encryption = resp.Response.Data.Encryption
-	resp.Data.Compression = resp.Response.Data.Compression
-
 	return resp
 }
 

+ 184 - 27
server/webapi/handlers/preference.go

@@ -6,6 +6,7 @@ import (
 	"log/slog"
 	"math/rand"
 	"net/http"
+	"reflect"
 	"strings"
 
 	"github.com/mk6i/open-oscar-server/server/webapi/types"
@@ -110,11 +111,108 @@ var webBuddyPrefs = map[string]uint16{
 	"imblastInviteFromBuddyOnly": wire.FeedbagBuddyPrefsImblastInviteFromBuddyOnly,
 }
 
+// PreferenceData carries Web API buddy preferences.
+//
+// Values are numbers, not "1"/"0" strings, because the client evaluates them
+// with JavaScript truthiness and numeric comparison, where the string "0" is
+// truthy. They are pointers because a preference set to 0 and a preference not
+// carried at all mean different things and both occur: getPreference and
+// setPreference answer with just the preferences the request named, while the
+// startSession seed carries every one. A plain int could not tell the two
+// apart, and the client reads its buddy-list display preferences only from
+// here — an omitted showGroups falls back to a hidden client default that hides
+// group headers.
+//
+// The fields mirror webBuddyPrefs one for one; TestPreferenceDataMatchesPrefTable
+// fails if the two drift apart.
+type PreferenceData struct {
+	DisplayLogin                   *int `json:"displayLogin,omitempty" xml:"displayLogin,omitempty"`
+	DisplayEBuddy                  *int `json:"displayEBuddy,omitempty" xml:"displayEBuddy,omitempty"`
+	PlayEnter                      *int `json:"playEnter,omitempty" xml:"playEnter,omitempty"`
+	PlayExit                       *int `json:"playExit,omitempty" xml:"playExit,omitempty"`
+	ViewIMTimestamps               *int `json:"viewIMTimestamps,omitempty" xml:"viewIMTimestamps,omitempty"`
+	ViewSmilies                    *int `json:"viewSmilies,omitempty" xml:"viewSmilies,omitempty"`
+	AcceptIcons                    *int `json:"acceptIcons,omitempty" xml:"acceptIcons,omitempty"`
+	KnockNonAOLIMs                 *int `json:"knockNonAOLIMs,omitempty" xml:"knockNonAOLIMs,omitempty"`
+	KnockNonListIMs                *int `json:"knockNonListIMs,omitempty" xml:"knockNonListIMs,omitempty"`
+	DiscloseIdle                   *int `json:"discloseIdle,omitempty" xml:"discloseIdle,omitempty"`
+	AcceptCustomBart               *int `json:"acceptCustomBart,omitempty" xml:"acceptCustomBart,omitempty"`
+	AcceptNonListBart              *int `json:"acceptNonListBart,omitempty" xml:"acceptNonListBart,omitempty"`
+	AcceptBgs                      *int `json:"acceptBgs,omitempty" xml:"acceptBgs,omitempty"`
+	AcceptChromes                  *int `json:"acceptChromes,omitempty" xml:"acceptChromes,omitempty"`
+	AcceptBLSounds                 *int `json:"acceptBLSounds,omitempty" xml:"acceptBLSounds,omitempty"`
+	AcceptIMsounds                 *int `json:"acceptIMsounds,omitempty" xml:"acceptIMsounds,omitempty"`
+	NoSeeRecentBuddies             *int `json:"noSeeRecentBuddies,omitempty" xml:"noSeeRecentBuddies,omitempty"`
+	AcceptSMSLegal                 *int `json:"acceptSMSLegal,omitempty" xml:"acceptSMSLegal,omitempty"`
+	EnterDoesCRLF                  *int `json:"enterDoesCRLF,omitempty" xml:"enterDoesCRLF,omitempty"`
+	PlayIMSound                    *int `json:"playIMSound,omitempty" xml:"playIMSound,omitempty"`
+	DiscloseTyping                 *int `json:"discloseTyping,omitempty" xml:"discloseTyping,omitempty"`
+	AcceptSuperIcons               *int `json:"acceptSuperIcons,omitempty" xml:"acceptSuperIcons,omitempty"`
+	AcceptBLRichText               *int `json:"acceptBLRichText,omitempty" xml:"acceptBLRichText,omitempty"`
+	ReduceIMSound                  *int `json:"reduceIMSound,omitempty" xml:"reduceIMSound,omitempty"`
+	ConfirmDirectIM                *int `json:"confirmDirectIM,omitempty" xml:"confirmDirectIM,omitempty"`
+	OneTabbedIMWindow              *int `json:"oneTabbedIMWindow,omitempty" xml:"oneTabbedIMWindow,omitempty"`
+	BuddyInfoOnMouseover           *int `json:"buddyInfoOnMouseover,omitempty" xml:"buddyInfoOnMouseover,omitempty"`
+	DiscloseBuddyMatches           *int `json:"discloseBuddyMatches,omitempty" xml:"discloseBuddyMatches,omitempty"`
+	CatchIMs                       *int `json:"catchIMs,omitempty" xml:"catchIMs,omitempty"`
+	ShowFriendlyName               *int `json:"showFriendlyName,omitempty" xml:"showFriendlyName,omitempty"`
+	DiscloseRadio                  *int `json:"discloseRadio,omitempty" xml:"discloseRadio,omitempty"`
+	ShowCapabilities               *int `json:"showCapabilities,omitempty" xml:"showCapabilities,omitempty"`
+	ShowBuddyListFilter            *int `json:"showBuddyListFilter,omitempty" xml:"showBuddyListFilter,omitempty"`
+	ShowAwayIdle                   *int `json:"showAwayIdle,omitempty" xml:"showAwayIdle,omitempty"`
+	ShowMobile                     *int `json:"showMobile,omitempty" xml:"showMobile,omitempty"`
+	SortBuddyList                  *int `json:"sortBuddyList,omitempty" xml:"sortBuddyList,omitempty"`
+	CatchIMsForClient              *int `json:"catchIMsForClient,omitempty" xml:"catchIMsForClient,omitempty"`
+	NewMessageSmallNotification    *int `json:"newMessageSmallNotification,omitempty" xml:"newMessageSmallNotification,omitempty"`
+	NoFrequentBuddies              *int `json:"noFrequentBuddies,omitempty" xml:"noFrequentBuddies,omitempty"`
+	BlogAwayMessages               *int `json:"blogAwayMessages,omitempty" xml:"blogAwayMessages,omitempty"`
+	BlogAIMSigMessages             *int `json:"blogAIMSigMessages,omitempty" xml:"blogAIMSigMessages,omitempty"`
+	BlogNoComments                 *int `json:"blogNoComments,omitempty" xml:"blogNoComments,omitempty"`
+	FriendOfFriend                 *int `json:"friendOfFriend,omitempty" xml:"friendOfFriend,omitempty"`
+	FriendGetContactList           *int `json:"friendGetContactList,omitempty" xml:"friendGetContactList,omitempty"`
+	CompadInit                     *int `json:"compadInit,omitempty" xml:"compadInit,omitempty"`
+	SendBuddyFeed                  *int `json:"sendBuddyFeed,omitempty" xml:"sendBuddyFeed,omitempty"`
+	BlkSendIMWhileAway             *int `json:"blkSendIMWhileAway,omitempty" xml:"blkSendIMWhileAway,omitempty"`
+	ShowBuddyFeed                  *int `json:"showBuddyFeed,omitempty" xml:"showBuddyFeed,omitempty"`
+	NoSaveVanityInfo               *int `json:"noSaveVanityInfo,omitempty" xml:"noSaveVanityInfo,omitempty"`
+	AcceptOffLineIM                *int `json:"acceptOffLineIM,omitempty" xml:"acceptOffLineIM,omitempty"`
+	ShowGroups                     *int `json:"showGroups,omitempty" xml:"showGroups,omitempty"`
+	SortGroup                      *int `json:"sortGroup,omitempty" xml:"sortGroup,omitempty"`
+	ShowOffLineBuddies             *int `json:"showOffLineBuddies,omitempty" xml:"showOffLineBuddies,omitempty"`
+	ExpandBuddies                  *int `json:"expandBuddies,omitempty" xml:"expandBuddies,omitempty"`
+	ThirdPartyFeeds                *int `json:"thirdPartyFeeds,omitempty" xml:"thirdPartyFeeds,omitempty"`
+	NotifyReceivedInvite           *int `json:"notifyReceivedInvite,omitempty" xml:"notifyReceivedInvite,omitempty"`
+	ApfAutoAccept                  *int `json:"apfAutoAccept,omitempty" xml:"apfAutoAccept,omitempty"`
+	ApfAutoAcceptBuddy             *int `json:"apfAutoAcceptBuddy,omitempty" xml:"apfAutoAcceptBuddy,omitempty"`
+	BlockAwayMsgFeed               *int `json:"blockAwayMsgFeed,omitempty" xml:"blockAwayMsgFeed,omitempty"`
+	BlockAIMProfileFeed            *int `json:"blockAIMProfileFeed,omitempty" xml:"blockAIMProfileFeed,omitempty"`
+	BlockAIMPagesFeed              *int `json:"blockAIMPagesFeed,omitempty" xml:"blockAIMPagesFeed,omitempty"`
+	BlockJournalsFeed              *int `json:"blockJournalsFeed,omitempty" xml:"blockJournalsFeed,omitempty"`
+	BlockLocationFeed              *int `json:"blockLocationFeed,omitempty" xml:"blockLocationFeed,omitempty"`
+	BlockStickiesFeed              *int `json:"blockStickiesFeed,omitempty" xml:"blockStickiesFeed,omitempty"`
+	BlockUncutFeed                 *int `json:"blockUncutFeed,omitempty" xml:"blockUncutFeed,omitempty"`
+	BlockLinksFeed                 *int `json:"blockLinksFeed,omitempty" xml:"blockLinksFeed,omitempty"`
+	BlockAIMBulletinFeed           *int `json:"blockAIMBulletinFeed,omitempty" xml:"blockAIMBulletinFeed,omitempty"`
+	SaveStatusMsg                  *int `json:"saveStatusMsg,omitempty" xml:"saveStatusMsg,omitempty"`
+	ApfNotifyReceivedInviteByEmail *int `json:"apfNotifyReceivedInviteByEmail,omitempty" xml:"apfNotifyReceivedInviteByEmail,omitempty"`
+	ShowOfflineGrp                 *int `json:"showOfflineGrp,omitempty" xml:"showOfflineGrp,omitempty"`
+	OfflineGrpCollapsed            *int `json:"offlineGrpCollapsed,omitempty" xml:"offlineGrpCollapsed,omitempty"`
+	FirstImSoundOnly               *int `json:"firstImSoundOnly,omitempty" xml:"firstImSoundOnly,omitempty"`
+	ImblastInviteNotify            *int `json:"imblastInviteNotify,omitempty" xml:"imblastInviteNotify,omitempty"`
+	ViewIMsInBubbles               *int `json:"viewIMsInBubbles,omitempty" xml:"viewIMsInBubbles,omitempty"`
+	ViewIMTimestampsRelative       *int `json:"viewIMTimestampsRelative,omitempty" xml:"viewIMTimestampsRelative,omitempty"`
+	GlobalOTR                      *int `json:"globalOTR,omitempty" xml:"globalOTR,omitempty"`
+	ImblastInviteFromBuddyOnly     *int `json:"imblastInviteFromBuddyOnly,omitempty" xml:"imblastInviteFromBuddyOnly,omitempty"`
+}
+
 // PermitDenyData contains permit/deny list information.
+//
+// The XML item names follow the spec's getPermitDeny, which nests <allow> under
+// <allows> and <block> under <blocks>.
 type PermitDenyData struct {
 	PDMode     string   `json:"pdMode" xml:"pdMode"`
-	PermitList []string `json:"allows,omitempty" xml:"allows>user,omitempty"`
-	DenyList   []string `json:"blocks,omitempty" xml:"blocks>user,omitempty"`
+	PermitList []string `json:"allows,omitempty" xml:"allows>allow,omitempty"`
+	DenyList   []string `json:"blocks,omitempty" xml:"blocks>block,omitempty"`
 }
 
 // SetPreferences handles GET /preference/set requests to update user preferences.
@@ -134,7 +232,7 @@ func (h *PreferenceHandler) SetPreferences(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	applied := make(map[string]interface{})
+	applied := &PreferenceData{}
 	for name, pref := range webBuddyPrefs {
 		val := r.URL.Query().Get(name)
 		if val == "" {
@@ -142,10 +240,10 @@ func (h *PreferenceHandler) SetPreferences(w http.ResponseWriter, r *http.Reques
 		}
 		on := parseBoolPref(val)
 		item.TLVList = wire.SetBuddyPref(item.TLVList, pref, on)
-		applied[name] = boolToPrefInt(on)
+		applied.Set(name, boolToPrefInt(on))
 	}
 
-	if len(applied) > 0 {
+	if applied.Len() > 0 {
 		frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
 		if _, err := h.FeedbagService.UpsertItem(ctx, instance, frame, []wire.FeedbagItem{item}); err != nil {
 			h.Logger.ErrorContext(ctx, "failed to set preferences", "err", err.Error())
@@ -161,7 +259,7 @@ func (h *PreferenceHandler) SetPreferences(w http.ResponseWriter, r *http.Reques
 
 	h.Logger.DebugContext(ctx, "preferences updated",
 		"screenName", session.ScreenName.String(),
-		"prefCount", len(applied),
+		"prefCount", applied.Len(),
 	)
 
 	// Send success response
@@ -187,45 +285,44 @@ func (h *PreferenceHandler) GetPreferences(w http.ResponseWriter, r *http.Reques
 
 	// When specific preferences are named in the query (e.g. playIMSound=1), the
 	// client is selecting those; otherwise return all preferences.
-	requestedPrefs := make(map[string]interface{})
+	requestedPrefs := &PreferenceData{}
 	for name, pref := range webBuddyPrefs {
 		if r.URL.Query().Has(name) {
-			requestedPrefs[name] = effectivePrefValue(prefsList, pref)
+			requestedPrefs.Set(name, effectivePrefValue(prefsList, pref))
 		}
 	}
 
-	var prefs map[string]interface{}
-	if len(requestedPrefs) > 0 {
-		prefs = requestedPrefs
-	} else {
-		prefs = make(map[string]interface{}, len(webBuddyPrefs))
-		for name, pref := range webBuddyPrefs {
-			prefs[name] = effectivePrefValue(prefsList, pref)
-		}
+	prefs := requestedPrefs
+	if prefs.Len() == 0 {
+		prefs = effectiveBuddyPrefs(prefsList)
 	}
 
 	h.Logger.DebugContext(ctx, "preferences retrieved",
 		"screenName", session.ScreenName.String(),
-		"prefCount", len(prefs),
-		"requested", len(requestedPrefs) > 0,
+		"prefCount", prefs.Len(),
+		"requested", requestedPrefs.Len() > 0,
 	)
 
+	var payload any = prefs
+
 	// AMF clients (e.g. Gromit) expect the payload shaped a specific way. Pref
 	// values are already numeric 0/1, which is what these clients expect.
 	format := strings.ToLower(r.URL.Query().Get("f"))
 	if format == "amf" || format == "amf3" {
+		amfPrefs := prefs.Map()
 		// Ensure prefs is never empty for Gromit.
-		if len(prefs) == 0 {
-			prefs = map[string]interface{}{"playIMSound": 1}
+		if len(amfPrefs) == 0 {
+			amfPrefs = map[string]any{"playIMSound": 1}
 		}
 		// A single preference is returned directly; multiple are wrapped in
 		// jsonData for Gromit compatibility.
-		if len(prefs) != 1 {
-			prefs = map[string]interface{}{"jsonData": prefs}
+		if len(amfPrefs) != 1 {
+			amfPrefs = map[string]any{"jsonData": amfPrefs}
 		}
+		payload = amfPrefs
 
 		h.Logger.DebugContext(ctx, "AMF preference response",
-			"prefCount", len(prefs),
+			"prefCount", prefs.Len(),
 			"format", format,
 		)
 	}
@@ -234,7 +331,7 @@ func (h *PreferenceHandler) GetPreferences(w http.ResponseWriter, r *http.Reques
 	response := BaseResponse{}
 	response.Response.StatusCode = 200
 	response.Response.StatusText = "OK"
-	response.Response.Data = prefs
+	response.Response.Data = payload
 	SendResponse(w, r, response, h.Logger)
 }
 
@@ -271,14 +368,74 @@ func buddyPrefsItem(ctx context.Context, fs FeedbagService, instance *state.Sess
 // reads these from the startup preference event and has no other default for
 // them, so an omitted pref would silently fall back to the client's own hidden
 // default (which, for showGroups, hides group headers).
-func effectiveBuddyPrefs(list wire.TLVList) map[string]interface{} {
-	prefs := make(map[string]interface{}, len(webBuddyPrefs))
+func effectiveBuddyPrefs(list wire.TLVList) *PreferenceData {
+	prefs := &PreferenceData{}
 	for name, pref := range webBuddyPrefs {
-		prefs[name] = effectivePrefValue(list, pref)
+		prefs.Set(name, effectivePrefValue(list, pref))
 	}
 	return prefs
 }
 
+// prefFieldIndex maps a preference name to its PreferenceData field.
+var prefFieldIndex = func() map[string]int {
+	t := reflect.TypeOf(PreferenceData{})
+	index := make(map[string]int, t.NumField())
+	for i := 0; i < t.NumField(); i++ {
+		name, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",")
+		index[name] = i
+	}
+	return index
+}()
+
+// Set records one preference by its Web API name, leaving every preference not
+// set this way absent from the payload. It reports whether the name is one this
+// server carries.
+func (p *PreferenceData) Set(name string, value int) bool {
+	i, ok := prefFieldIndex[name]
+	if !ok {
+		return false
+	}
+	reflect.ValueOf(p).Elem().Field(i).Set(reflect.ValueOf(&value))
+	return true
+}
+
+// Get returns a preference by its Web API name and whether it is carried.
+func (p *PreferenceData) Get(name string) (int, bool) {
+	i, ok := prefFieldIndex[name]
+	if !ok {
+		return 0, false
+	}
+	field := reflect.ValueOf(p).Elem().Field(i)
+	if field.IsNil() {
+		return 0, false
+	}
+	return int(field.Elem().Int()), true
+}
+
+// Map returns the carried preferences keyed by Web API name, for the AMF path
+// that reshapes the payload rather than sending it as-is.
+func (p *PreferenceData) Map() map[string]any {
+	out := make(map[string]any, len(prefFieldIndex))
+	for name := range prefFieldIndex {
+		if v, ok := p.Get(name); ok {
+			out[name] = v
+		}
+	}
+	return out
+}
+
+// Len reports how many preferences the payload carries.
+func (p *PreferenceData) Len() int {
+	fields := reflect.ValueOf(p).Elem()
+	n := 0
+	for i := 0; i < fields.NumField(); i++ {
+		if !fields.Field(i).IsNil() {
+			n++
+		}
+	}
+	return n
+}
+
 // effectivePrefValue returns the 0/1 value for the buddy pref prefNum, deferring
 // to wire.BuddyPref for both the stored value and its default. Values are emitted
 // as numbers (not "1"/"0" strings) because the web client evaluates them with

+ 31 - 7
server/webapi/handlers/preference_test.go

@@ -162,19 +162,19 @@ func TestEffectiveBuddyPrefs_StoredOverridesDefault(t *testing.T) {
 	got := effectiveBuddyPrefs(list)
 
 	// Every pref is present (defaults applied for unset ones).
-	assert.Len(t, got, len(webBuddyPrefs))
-	assert.Equal(t, 0, got["playIMSound"])
-	assert.Equal(t, 1, got["viewIMsInBubbles"])
+	assert.Equal(t, len(webBuddyPrefs), got.Len())
+	assertPref(t, got, "playIMSound", 0)
+	assertPref(t, got, "viewIMsInBubbles", 1)
 }
 
 func TestEffectiveBuddyPrefs_AppliesDefaultsWhenNothingSet(t *testing.T) {
 	got := effectiveBuddyPrefs(wire.TLVList{})
 
 	// Unset prefs resolve to their spec defaults rather than being omitted.
-	assert.Len(t, got, len(webBuddyPrefs))
-	assert.Equal(t, 1, got["showGroups"], "showGroups should default to shown")
-	assert.Equal(t, 1, got["playIMSound"], "playIMSound defaults true")
-	assert.Equal(t, 0, got["sortBuddyList"], "sortBuddyList defaults false")
+	assert.Equal(t, len(webBuddyPrefs), got.Len())
+	assertPref(t, got, "showGroups", 1)    // showGroups should default to shown
+	assertPref(t, got, "playIMSound", 1)   // playIMSound defaults true
+	assertPref(t, got, "sortBuddyList", 0) // sortBuddyList defaults false
 }
 
 func TestPreferenceHandler_SetPermitDeny_QueuesPermitDenyEvent(t *testing.T) {
@@ -258,3 +258,27 @@ func TestPreferenceHandler_SetPreferences_NoOSCARSession(t *testing.T) {
 	assert.Contains(t, rr.Body.String(), "internal server error")
 	fs.AssertNotCalled(t, "Query", mock.Anything, mock.Anything, mock.Anything)
 }
+
+// assertPref asserts a preference is carried and holds want.
+func assertPref(t *testing.T, prefs *PreferenceData, name string, want int) {
+	t.Helper()
+	got, ok := prefs.Get(name)
+	if assert.True(t, ok, "%s should be carried", name) {
+		assert.Equal(t, want, got, "%s", name)
+	}
+}
+
+// The struct and the table must describe the same set of preferences: a
+// preference in the table but not the struct is silently never sent, and one in
+// the struct but not the table is always absent.
+func TestPreferenceDataMatchesPrefTable(t *testing.T) {
+	structNames := make([]string, 0, len(prefFieldIndex))
+	for name := range prefFieldIndex {
+		structNames = append(structNames, name)
+	}
+	tableNames := make([]string, 0, len(webBuddyPrefs))
+	for name := range webBuddyPrefs {
+		tableNames = append(tableNames, name)
+	}
+	assert.ElementsMatch(t, tableNames, structNames)
+}

+ 29 - 19
server/webapi/handlers/presence.go

@@ -41,6 +41,24 @@ type BuddyBroadcaster interface {
 // may query in target-list ("t=") mode.
 const maxPresenceTargets = 10
 
+// ProfileData is the getProfile payload.
+type ProfileData struct {
+	ScreenName  string `json:"screenName" xml:"screenName"`
+	Profile     string `json:"profile" xml:"profile"`
+	LastUpdated int64  `json:"lastUpdated" xml:"lastUpdated"`
+}
+
+// SetStateData echoes the identity fields a setState changed.
+type SetStateData struct {
+	AimID      string `json:"aimId" xml:"aimId"`
+	DisplayID  string `json:"displayId" xml:"displayId"`
+	State      string `json:"state" xml:"state"`
+	AwayMsg    string `json:"awayMsg" xml:"awayMsg"`
+	StatusMsg  string `json:"statusMsg" xml:"statusMsg"`
+	UserType   string `json:"userType" xml:"userType"`
+	OnlineTime int64  `json:"onlineTime" xml:"onlineTime"`
+}
+
 // PresenceData contains presence information.
 type PresenceData struct {
 	Groups []BuddyGroupInfo    `json:"groups,omitempty" xml:"groups>group,omitempty"`
@@ -391,14 +409,14 @@ func (h *PresenceHandler) SetState(w http.ResponseWriter, r *http.Request, sessi
 	response := BaseResponse{}
 	response.Response.StatusCode = 200
 	response.Response.StatusText = "OK"
-	response.Response.Data = map[string]interface{}{
-		"aimId":      session.ScreenName.IdentScreenName().String(),
-		"displayId":  session.ScreenName.String(),
-		"state":      stateParam,
-		"awayMsg":    awayMsg,
-		"statusMsg":  "",
-		"userType":   "aim",
-		"onlineTime": time.Now().Unix(),
+	response.Response.Data = &SetStateData{
+		AimID:      session.ScreenName.IdentScreenName().String(),
+		DisplayID:  session.ScreenName.String(),
+		State:      stateParam,
+		AwayMsg:    awayMsg,
+		StatusMsg:  "",
+		UserType:   "aim",
+		OnlineTime: time.Now().Unix(),
 	}
 	SendResponse(w, r, response, h.Logger)
 }
@@ -505,11 +523,7 @@ func (h *PresenceHandler) GetProfile(w http.ResponseWriter, r *http.Request, ses
 	}
 
 	// Send response
-	responseData := map[string]interface{}{
-		"screenName":  targetSN,
-		"profile":     profileText,
-		"lastUpdated": int64(0),
-	}
+	responseData := &ProfileData{ScreenName: targetSN, Profile: profileText}
 
 	response := BaseResponse{}
 	response.Response.StatusCode = 200
@@ -609,12 +623,8 @@ func (h *PresenceHandler) pushMyInfo(session *state.WebAPISession, webState, awa
 	// already holds; a setState/setStatus does not change the icon. Icon changes
 	// arrive on their own myInfo via the pump's MyInfoRefresher.
 	myInfo := buildMyInfo(session.ScreenName, webState, "")
-	if awayMsg != "" {
-		myInfo["awayMsg"] = awayMsg
-	}
-	if statusMsg != "" {
-		myInfo["statusMsg"] = statusMsg
-	}
+	myInfo.AwayMsg = awayMsg
+	myInfo.StatusMsg = statusMsg
 
 	session.EventQueue.Push(types.EventType("myInfo"), myInfo)
 }

+ 19 - 18
server/webapi/handlers/presence_test.go

@@ -478,16 +478,11 @@ func TestPresenceHandler_SetState_EmitsMyInfoEvent(t *testing.T) {
 	session, err := sessionMgr.GetSession(context.Background(), aimsid)
 	assert.NoError(t, err)
 
-	var myInfo map[string]interface{}
-	for _, event := range session.EventQueue.GetAllEvents() {
-		if event.Type == "myInfo" {
-			myInfo, _ = event.Data.(map[string]interface{})
-		}
-	}
+	myInfo := queuedMyInfo(session)
 	assert.NotNil(t, myInfo, "expected a myInfo event to be queued")
-	assert.Equal(t, "away", myInfo["state"])
-	assert.Equal(t, "brb", myInfo["awayMsg"])
-	assert.Equal(t, "testuser", myInfo["aimId"])
+	assert.Equal(t, "away", myInfo.State)
+	assert.Equal(t, "brb", myInfo.AwayMsg)
+	assert.Equal(t, "testuser", myInfo.AimID)
 }
 
 func TestPresenceHandler_SetState_MyInfoNormalizesAimID(t *testing.T) {
@@ -526,16 +521,11 @@ func TestPresenceHandler_SetState_MyInfoNormalizesAimID(t *testing.T) {
 	session, err := sessionMgr.GetSession(context.Background(), aimsid)
 	assert.NoError(t, err)
 
-	var myInfo map[string]interface{}
-	for _, event := range session.EventQueue.GetAllEvents() {
-		if event.Type == "myInfo" {
-			myInfo, _ = event.Data.(map[string]interface{})
-		}
-	}
+	myInfo := queuedMyInfo(session)
 	require.NotNil(t, myInfo, "expected a myInfo event to be queued")
-	assert.Equal(t, "mikekelly", myInfo["aimId"])
-	assert.Equal(t, "Mike Kelly", myInfo["displayId"])
-	assert.Equal(t, "Mike Kelly", myInfo["friendly"])
+	assert.Equal(t, "mikekelly", myInfo.AimID)
+	assert.Equal(t, "Mike Kelly", myInfo.DisplayID)
+	assert.Equal(t, "Mike Kelly", myInfo.Friendly)
 }
 
 func TestPresenceHandler_SetState_NoOSCARSession_Rejected(t *testing.T) {
@@ -735,3 +725,14 @@ func TestPresenceHandler_GetProfile(t *testing.T) {
 
 	locateService.AssertExpectations(t)
 }
+
+// queuedMyInfo returns the myInfo event the session has queued, if any.
+func queuedMyInfo(session *state.WebAPISession) *MyInfo {
+	var myInfo *MyInfo
+	for _, event := range session.EventQueue.GetAllEvents() {
+		if event.Type == "myInfo" {
+			myInfo, _ = event.Data.(*MyInfo)
+		}
+	}
+	return myInfo
+}

+ 4 - 4
server/webapi/handlers/ratelimit_test.go

@@ -342,14 +342,14 @@ func TestRateLimitMiddleware_sendRateLimited(t *testing.T) {
 			name:            "plain JSON",
 			query:           "",
 			wantCode:        http.StatusOK,
-			wantBody:        `{"response":{"statusCode":430,"statusText":"rate limit exceeded"}}`,
+			wantBody:        `{"response":{"statusCode":430,"statusText":"rate limit exceeded","data":{}}}`,
 			wantContentType: "application/json",
 		},
 		{
 			name:            "JSONP callback",
 			query:           "?c=myCallback",
 			wantCode:        http.StatusOK,
-			wantBody:        `myCallback({"response":{"statusCode":430,"statusText":"rate limit exceeded"}});`,
+			wantBody:        `myCallback({"response":{"statusCode":430,"statusText":"rate limit exceeded","data":{}}});`,
 			wantContentType: "application/javascript",
 		},
 		{
@@ -358,7 +358,7 @@ func TestRateLimitMiddleware_sendRateLimited(t *testing.T) {
 			name:            "JSONP callback echoes requestId",
 			query:           "?c=myCallback&r=42",
 			wantCode:        http.StatusOK,
-			wantBody:        `myCallback({"response":{"statusCode":430,"statusText":"rate limit exceeded","requestId":"42"}});`,
+			wantBody:        `myCallback({"response":{"statusCode":430,"statusText":"rate limit exceeded","requestId":"42","data":{}}});`,
 			wantContentType: "application/javascript",
 		},
 		{
@@ -368,7 +368,7 @@ func TestRateLimitMiddleware_sendRateLimited(t *testing.T) {
 			query:    "?c=alert(1)",
 			wantCode: http.StatusBadRequest,
 			// sendJSONError encodes with json.Encoder, which appends a newline.
-			wantBody:        "{\"response\":{\"statusCode\":400,\"statusText\":\"invalid callback parameter\"}}\n",
+			wantBody:        "{\"response\":{\"statusCode\":400,\"statusText\":\"invalid callback parameter\",\"data\":{}}}\n",
 			wantContentType: "application/json",
 		},
 	}

+ 28 - 0
server/webapi/handlers/service_stub.go

@@ -0,0 +1,28 @@
+package handlers
+
+import (
+	"log/slog"
+	"net/http"
+)
+
+// ServiceStubHandler serves the /service/* endpoints that manage third-party
+// service linking (Google Talk, Facebook), none of which this server federates.
+type ServiceStubHandler struct {
+	Logger *slog.Logger
+}
+
+// statusNoSuchService is the envelope status the Web AIM client accepts as
+// "this account has no such linked service". Its getAttributes callback treats
+// 601 as an expected outcome and returns early; any other status sends it into
+// the success branch, where it dereferences response.data.serviceName and marks
+// the service associated. A 404 therefore both crashes the callback and, if it
+// did not, would advertise a Google Talk link that does not exist.
+const statusNoSuchService = 601
+
+// GetAttributes reports that the requested third-party service is not linked.
+func (h *ServiceStubHandler) GetAttributes(w http.ResponseWriter, r *http.Request) {
+	resp := BaseResponse{}
+	resp.Response.StatusCode = statusNoSuchService
+	resp.Response.StatusText = "Service not available"
+	SendResponse(w, r, resp, h.Logger)
+}

+ 146 - 247
server/webapi/handlers/session.go

@@ -3,7 +3,6 @@ package handlers
 import (
 	"context"
 	"encoding/base64"
-	"encoding/xml"
 	"fmt"
 	"log/slog"
 	"net/http"
@@ -65,75 +64,91 @@ type ChatSessionManager interface {
 	RemoveUserFromAllChats(user state.IdentScreenName)
 }
 
-// BuddyGroup represents a group of buddies.
-type BuddyGroup struct {
-	Name    string  `json:"name"`
-	Buddies []Buddy `json:"buddies"`
+// MyInfo is the user's own identity blob, which the Web AIM client renders in
+// its identity badge. It is both the startSession payload's myInfo and the
+// myInfo event's data.
+type MyInfo struct {
+	AimID     string `json:"aimId" xml:"aimId"`
+	DisplayID string `json:"displayId" xml:"displayId"`
+	Friendly  string `json:"friendly" xml:"friendly"`
+	State     string `json:"state" xml:"state"`
+	UserType  string `json:"userType" xml:"userType"` // "aim", "icq"
+	Bot       bool   `json:"bot" xml:"bot"`
+	Service   string `json:"service" xml:"service"` // "AIM", "ICQ" (compared case-sensitively)
+	// Capabilities is always sent, empty included, because the client iterates it
+	// unconditionally.
+	Capabilities []string `json:"capabilities" xml:"capabilities>capability"`
+	// BuddyIcon is omitted when empty so the client's merge preserves the icon it
+	// already holds.
+	BuddyIcon   string      `json:"buddyIcon,omitempty" xml:"buddyIcon,omitempty"`
+	AwayMsg     string      `json:"awayMsg,omitempty" xml:"awayMsg,omitempty"`
+	StatusMsg   string      `json:"statusMsg,omitempty" xml:"statusMsg,omitempty"`
+	OnlineTime  int64       `json:"onlineTime,omitempty" xml:"onlineTime,omitempty"`
+	MemberSince int64       `json:"memberSince,omitempty" xml:"memberSince,omitempty"`
+	Self        *MyInfoSelf `json:"self,omitempty" xml:"self,omitempty"`
 }
 
-// Buddy represents a buddy in the buddy list.
-type Buddy struct {
-	AimID     string `json:"aimId"`
-	State     string `json:"state"`
-	StatusMsg string `json:"statusMsg,omitempty"`
-	AwayMsg   string `json:"awayMsg,omitempty"`
-	UserType  string `json:"userType"`
+// MyInfoSelf carries the session-scoped half of myInfo: the instance the client
+// is talking to and the limits it must respect.
+type MyInfoSelf struct {
+	InstNum        int        `json:"instNum" xml:"instNum"`
+	LoginTime      int64      `json:"loginTime" xml:"loginTime"`
+	SessionTimeout int        `json:"sessionTimeout" xml:"sessionTimeout"`
+	Events         []string   `json:"events" xml:"events>event"`
+	AssertCaps     []string   `json:"assertCaps" xml:"assertCaps>capability"`
+	RightsInfo     RightsInfo `json:"rightsInfo" xml:"rightsInfo"`
 }
 
-// StartSessionResponse represents the response for startSession endpoint.
-type StartSessionResponse struct {
-	Response struct {
-		StatusCode int    `json:"statusCode"`
-		StatusText string `json:"statusText"`
-		Data       struct {
-			AimSID          string                 `json:"aimsid"`
-			Ts              int64                  `json:"ts"`
-			FetchTimeout    int                    `json:"fetchTimeout"`
-			TimeToNextFetch int                    `json:"timeToNextFetch"`
-			FetchBaseURL    string                 `json:"fetchBaseURL"` // Gromit expects this directly in data!
-			MyInfo          map[string]interface{} `json:"myInfo,omitempty"`
-			Events          map[string]interface{} `json:"events,omitempty"`
-			WellKnownUrls   map[string]string      `json:"wellKnownUrls,omitempty"`
-		} `json:"data"`
-	} `json:"response"`
+// RightsInfo reports the account limits the client enforces client-side.
+type RightsInfo struct {
+	MaxDenies            int `json:"maxDenies" xml:"maxDenies"`
+	MaxPermits           int `json:"maxPermits" xml:"maxPermits"`
+	MaxWatchers          int `json:"maxWatchers" xml:"maxWatchers"`
+	MaxBuddies           int `json:"maxBuddies" xml:"maxBuddies"`
+	MaxTempBuddies       int `json:"maxTempBuddies" xml:"maxTempBuddies"`
+	MaxIMSize            int `json:"maxIMSize" xml:"maxIMSize"`
+	MinInterIcbmInterval int `json:"minInterIcbmInterval" xml:"minInterIcbmInterval"`
+	MaxSourceEvil        int `json:"maxSourceEvil" xml:"maxSourceEvil"`
+	MaxDstEvil           int `json:"maxDstEvil" xml:"maxDstEvil"`
+	MaxSigLen            int `json:"maxSigLen" xml:"maxSigLen"`
 }
 
-// StartSessionXMLResponse represents the XML response for startSession endpoint.
-type StartSessionXMLResponse struct {
-	XMLName    xml.Name `xml:"response"`
-	StatusCode int      `xml:"statusCode"`
-	StatusText string   `xml:"statusText"`
-	Data       struct {
-		AimSID          string `xml:"aimsid"`
-		FetchTimeout    int    `xml:"fetchTimeout"`
-		TimeToNextFetch int    `xml:"timeToNextFetch"`
-		FetchBaseURL    string `xml:"fetchBaseURL"` // Gromit expects this directly!
-		WellKnownUrls   *struct {
-			WebApiBase        string `xml:"webApiBase"`
-			FetchBaseURL      string `xml:"fetchBaseURL"`
-			LifestreamApiBase string `xml:"lifestreamApiBase"`
-		} `xml:"wellKnownUrls,omitempty"`
-		MyInfo *struct {
-			AimID     string `xml:"aimId"`
-			DisplayID string `xml:"displayId"`
-			Buddylist struct {
-				Groups *[]BuddyGroup `xml:"group,omitempty"`
-			} `xml:"buddylist,omitempty"`
-		} `xml:"myInfo,omitempty"`
-		Events *struct {
-			BuddyList struct {
-				Groups *[]BuddyGroup `xml:"group,omitempty"`
-			} `xml:"buddylist"`
-		} `xml:"events,omitempty"`
-	} `xml:"data"`
+// WellKnownUrls advertises the API roots to clients that discover them rather
+// than deriving them.
+type WellKnownUrls struct {
+	WebApiBase        string `json:"webApiBase" xml:"webApiBase"`
+	FetchBaseURL      string `json:"fetchBaseURL" xml:"fetchBaseURL"`
+	LifestreamApiBase string `json:"lifestreamApiBase" xml:"lifestreamApiBase"`
 }
 
-// EndSessionResponse represents the response for endSession endpoint.
-type EndSessionResponse struct {
-	Response struct {
-		StatusCode int    `json:"statusCode"`
-		StatusText string `json:"statusText"`
-	} `json:"response"`
+// StartSessionEvents seeds the client with the first value of each event it
+// subscribed to, so it renders a populated UI before its first fetchEvents.
+// Each field is absent unless the client asked for that event.
+type StartSessionEvents struct {
+	MyInfo     *MyInfo         `json:"myInfo,omitempty" xml:"myInfo,omitempty"`
+	BuddyList  *BuddyListData  `json:"buddylist,omitempty" xml:"buddylist,omitempty"`
+	Preference *PreferenceData `json:"preference,omitempty" xml:"preference,omitempty"`
+	PermitDeny interface{}     `json:"permitDeny,omitempty" xml:"permitDeny,omitempty"`
+}
+
+// BuddyListData is the buddylist event payload and the buddy list half of the
+// startSession seed.
+type BuddyListData struct {
+	Groups []WebAPIBuddyGroup `json:"groups" xml:"groups>group"`
+}
+
+// StartSessionData is the startSession payload.
+type StartSessionData struct {
+	AimSID          string `json:"aimsid" xml:"aimsid"`
+	Ts              int64  `json:"ts" xml:"ts"`
+	FetchTimeout    int    `json:"fetchTimeout" xml:"fetchTimeout"`
+	TimeToNextFetch int    `json:"timeToNextFetch" xml:"timeToNextFetch"`
+	// FetchBaseURL sits directly in data, not in wellKnownUrls: it is where the
+	// client reads its poll URL from.
+	FetchBaseURL  string              `json:"fetchBaseURL" xml:"fetchBaseURL"`
+	MyInfo        *MyInfo             `json:"myInfo,omitempty" xml:"myInfo,omitempty"`
+	Events        *StartSessionEvents `json:"events,omitempty" xml:"events,omitempty"`
+	WellKnownUrls *WellKnownUrls      `json:"wellKnownUrls,omitempty" xml:"wellKnownUrls,omitempty"`
 }
 
 // StartSession handles GET /aim/startSession requests.
@@ -302,8 +317,14 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 	)
 
 	// Wire buddy list refresher so feedbag SNACs from the OSCAR bridge trigger a buddylist event.
+	// The refresher yields the whole buddylist event payload, not just the groups,
+	// so the session that pushes it does not have to know the payload's shape.
 	session.BuddyListRefresher = func(ctx context.Context) (interface{}, error) {
-		return h.BuddyListManager.GetBuddyListForUser(ctx, session)
+		groups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
+		if err != nil {
+			return nil, err
+		}
+		return &BuddyListData{Groups: groups}, nil
 	}
 
 	// Wire the alias loader so OSCAR-driven im/presence events can repeat the
@@ -367,50 +388,46 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 	now := time.Now().Unix()
 
 	// Prepare response
-	resp := StartSessionResponse{}
-	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
-	resp.Response.Data.AimSID = session.AimSID
-	resp.Response.Data.Ts = now
-	resp.Response.Data.FetchTimeout = session.FetchTimeout
-	resp.Response.Data.TimeToNextFetch = session.TimeToNextFetch
-	// Gromit expects fetchBaseURL directly in data, not in wellKnownUrls
-	resp.Response.Data.FetchBaseURL = fmt.Sprintf("%s/aim/fetchEvents?aimsid=%s&seqNum=0", baseURL, session.AimSID)
-
-	// Add wellKnownUrls for other clients that might use it.
-	resp.Response.Data.WellKnownUrls = map[string]string{
-		"webApiBase":        baseURL + "/",
-		"fetchBaseURL":      baseURL + "/aim/fetchEvents",
-		"lifestreamApiBase": baseURL + "/",
+	data := &StartSessionData{
+		AimSID:          session.AimSID,
+		Ts:              now,
+		FetchTimeout:    session.FetchTimeout,
+		TimeToNextFetch: session.TimeToNextFetch,
+		// Gromit expects fetchBaseURL directly in data, not in wellKnownUrls
+		FetchBaseURL: fmt.Sprintf("%s/aim/fetchEvents?aimsid=%s&seqNum=0", baseURL, session.AimSID),
+		// Add wellKnownUrls for other clients that might use it.
+		WellKnownUrls: &WellKnownUrls{
+			WebApiBase:        baseURL + "/",
+			FetchBaseURL:      baseURL + "/aim/fetchEvents",
+			LifestreamApiBase: baseURL + "/",
+		},
+		Events: &StartSessionEvents{},
 	}
 
 	myInfoPayload := buildMyInfo(screenName, "online", myIconURL)
-	myInfoPayload["onlineTime"] = time.Now().Unix()
-	myInfoPayload["memberSince"] = time.Now().Unix() - 86400*30 // 30 days ago
-	myInfoPayload["self"] = map[string]interface{}{
-		"instNum":        1,
-		"loginTime":      time.Now().Unix(),
-		"sessionTimeout": 30,
-		"events":         events,
-		"assertCaps":     []string{},
-		"rightsInfo": map[string]interface{}{
-			"maxDenies":            500,
-			"maxPermits":           500,
-			"maxWatchers":          3000,
-			"maxBuddies":           500,
-			"maxTempBuddies":       160,
-			"maxIMSize":            3987,
-			"minInterIcbmInterval": 1000,
-			"maxSourceEvil":        900,
-			"maxDstEvil":           999,
-			"maxSigLen":            4096,
+	myInfoPayload.OnlineTime = time.Now().Unix()
+	myInfoPayload.MemberSince = time.Now().Unix() - 86400*30 // 30 days ago
+	myInfoPayload.Self = &MyInfoSelf{
+		InstNum:        1,
+		LoginTime:      time.Now().Unix(),
+		SessionTimeout: 30,
+		Events:         events,
+		AssertCaps:     []string{},
+		RightsInfo: RightsInfo{
+			MaxDenies:            500,
+			MaxPermits:           500,
+			MaxWatchers:          3000,
+			MaxBuddies:           500,
+			MaxTempBuddies:       160,
+			MaxIMSize:            3987,
+			MinInterIcbmInterval: 1000,
+			MaxSourceEvil:        900,
+			MaxDstEvil:           999,
+			MaxSigLen:            4096,
 		},
 	}
-	resp.Response.Data.MyInfo = myInfoPayload
-	if resp.Response.Data.Events == nil {
-		resp.Response.Data.Events = make(map[string]interface{})
-	}
-	resp.Response.Data.Events["myInfo"] = myInfoPayload
+	data.MyInfo = myInfoPayload
+	data.Events.MyInfo = myInfoPayload
 
 	// Seeds that only queue an event are keyed off the subscription rather than
 	// iterated with it, so the server fixes their order and each is queued once.
@@ -418,8 +435,8 @@ func (h *SessionHandler) 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)
-		myInfoData["onlineTime"] = time.Now().Unix()
-		myInfoData["memberSince"] = time.Now().Unix() - 86400*30 // 30 days ago
+		myInfoData.OnlineTime = time.Now().Unix()
+		myInfoData.MemberSince = time.Now().Unix() - 86400*30 // 30 days ago
 		session.EventQueue.Push(types.EventTypeMyInfo, myInfoData)
 	}
 
@@ -445,11 +462,8 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 			if buddyGroups == nil {
 				buddyGroups = []WebAPIBuddyGroup{}
 			}
-			blPayload := map[string]interface{}{"groups": buddyGroups}
-			if resp.Response.Data.Events == nil {
-				resp.Response.Data.Events = make(map[string]interface{})
-			}
-			resp.Response.Data.Events["buddylist"] = blPayload
+			blPayload := &BuddyListData{Groups: buddyGroups}
+			data.Events.BuddyList = blPayload
 			session.EventQueue.Push(types.EventTypeBuddyList, blPayload)
 		case types.EventTypePreference:
 			// Seed the client with effective preference values: the user's stored
@@ -458,7 +472,7 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 			// this event and has no default of its own for them, so an omitted pref
 			// would silently fall back to the client's hidden default and, for
 			// showGroups, hide group headers.
-			prefPayload := map[string]interface{}{}
+			prefPayload := &PreferenceData{}
 			if session.OSCARSession != nil {
 				if item, err := buddyPrefsItem(ctx, h.FeedbagService, session.OSCARSession); err != nil {
 					h.Logger.ErrorContext(ctx, "failed to get preferences", "err", err.Error())
@@ -466,10 +480,7 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 					prefPayload = effectiveBuddyPrefs(item.TLVList)
 				}
 			}
-			if resp.Response.Data.Events == nil {
-				resp.Response.Data.Events = make(map[string]interface{})
-			}
-			resp.Response.Data.Events["preference"] = prefPayload
+			data.Events.Preference = prefPayload
 			session.EventQueue.Push(types.EventTypePreference, prefPayload)
 		case types.EventTypePermitDeny:
 			// The client keeps its privacy state solely in the model this event
@@ -485,10 +496,7 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 					pdPayload = pdd
 				}
 			}
-			if resp.Response.Data.Events == nil {
-				resp.Response.Data.Events = make(map[string]interface{})
-			}
-			resp.Response.Data.Events["permitDeny"] = pdPayload
+			data.Events.PermitDeny = pdPayload
 			session.EventQueue.Push(types.EventTypePermitDeny, pdPayload)
 		}
 	}
@@ -517,127 +525,20 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
-	// Check response format
-	format := r.URL.Query().Get("f")
-	if format == "" {
-		format = "json" // default to JSON
-	}
-
-	// Send response in requested format
-	if format == "xml" {
-		// Build XML response
-		xmlResp := StartSessionXMLResponse{}
-		xmlResp.StatusCode = 200
-		xmlResp.StatusText = "OK"
-		xmlResp.Data.AimSID = session.AimSID
-		xmlResp.Data.FetchTimeout = timeout
-		xmlResp.Data.TimeToNextFetch = 500
-		// Gromit expects fetchBaseURL directly in data
-		xmlResp.Data.FetchBaseURL = fmt.Sprintf("%s/aim/fetchEvents?aimsid=%s&seqNum=0", baseURL, session.AimSID)
-
-		// Add wellKnownUrls for other clients
-		xmlBase := baseURL + "/"
-		xmlResp.Data.WellKnownUrls = &struct {
-			WebApiBase        string `xml:"webApiBase"`
-			FetchBaseURL      string `xml:"fetchBaseURL"`
-			LifestreamApiBase string `xml:"lifestreamApiBase"`
-		}{
-			WebApiBase:        xmlBase,
-			FetchBaseURL:      baseURL + "/aim/fetchEvents",
-			LifestreamApiBase: xmlBase,
-		}
-
-		// Add myInfo with user data
-		xmlResp.Data.MyInfo = &struct {
-			AimID     string `xml:"aimId"`
-			DisplayID string `xml:"displayId"`
-			Buddylist struct {
-				Groups *[]BuddyGroup `xml:"group,omitempty"`
-			} `xml:"buddylist,omitempty"`
-		}{
-			AimID:     session.ScreenName.IdentScreenName().String(),
-			DisplayID: session.ScreenName.String(),
-		}
-
-		// Add buddy list if requested in myInfo or events
-		for _, event := range events {
-			if event == "buddylist" || event == "myInfo" {
-				var buddyGroups []BuddyGroup
-
-				if h.BuddyListManager != nil {
-					// Fetch actual buddy list from service
-					webAPIGroups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
-					if err != nil {
-						h.Logger.ErrorContext(ctx, "failed to get buddy list for XML response", "err", err.Error())
-						buddyGroups = []BuddyGroup{}
-					} else {
-						// Convert WebAPIBuddyGroup to handler.BuddyGroup
-						for _, webGroup := range webAPIGroups {
-							group := BuddyGroup{
-								Name:    webGroup.Name,
-								Buddies: []Buddy{},
-							}
-							for _, webBuddy := range webGroup.Buddies {
-								buddy := Buddy{
-									AimID:     webBuddy.AimID,
-									State:     webBuddy.State,
-									StatusMsg: webBuddy.StatusMsg,
-									AwayMsg:   webBuddy.AwayMsg,
-									UserType:  webBuddy.UserType,
-								}
-								group.Buddies = append(group.Buddies, buddy)
-							}
-							buddyGroups = append(buddyGroups, group)
-						}
-					}
-				} else {
-					buddyGroups = []BuddyGroup{}
-				}
-
-				// Add to myInfo buddylist
-				xmlResp.Data.MyInfo.Buddylist.Groups = &buddyGroups
-
-				// Also add to events if specifically requested
-				if event == "buddylist" {
-					if xmlResp.Data.Events == nil {
-						xmlResp.Data.Events = &struct {
-							BuddyList struct {
-								Groups *[]BuddyGroup `xml:"group,omitempty"`
-							} `xml:"buddylist"`
-						}{}
-					}
-					xmlResp.Data.Events.BuddyList.Groups = &buddyGroups
-				}
-				break
-			}
-		}
-
-		// Send XML response
-		w.Header().Set("Content-Type", "text/xml; charset=utf-8")
-
-		// Build complete XML string first
-		xmlData, err := xml.Marshal(xmlResp)
-		if err != nil {
-			h.Logger.Error("failed to marshal XML response", "error", err)
-			h.sendError(w, r, http.StatusInternalServerError, "internal server error")
-			return
-		}
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = data
 
-		// Write XML declaration and data as one response
-		xmlOutput := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>%s`, xmlData)
-		w.Header().Set("Content-Length", strconv.Itoa(len(xmlOutput)))
-		_, _ = fmt.Fprint(w, xmlOutput)
-	} else {
-		// Send response in requested format (JSON, JSONP, or AMF)
-		SendResponse(w, r, resp, h.Logger)
-	}
+	// Send response in requested format (JSON, JSONP, XML, or AMF)
+	SendResponse(w, r, resp, h.Logger)
 
 	h.Logger.DebugContext(ctx, "session started",
 		"aimsid", session.AimSID,
 		"screen_name", screenName,
 		"dev_id", apiKey.DevID,
 		"events", events,
-		"format", format,
+		"format", r.URL.Query().Get("f"),
 	)
 }
 
@@ -654,7 +555,7 @@ func (h *SessionHandler) EndSession(w http.ResponseWriter, r *http.Request, sess
 	}
 
 	// Send response
-	resp := EndSessionResponse{}
+	resp := BaseResponse{}
 	resp.Response.StatusCode = 200
 	resp.Response.StatusText = "OK"
 
@@ -686,27 +587,25 @@ func (h *SessionHandler) sendError(w http.ResponseWriter, r *http.Request, statu
 // builders add them explicitly. buddyIcon is included only when non-empty; an
 // empty value would be dropped by the client merge anyway, and the placeholder
 // URL (not "") is what clears an icon.
-func buildMyInfo(screenName state.DisplayScreenName, webState, buddyIcon string) map[string]interface{} {
+func buildMyInfo(screenName state.DisplayScreenName, webState, buddyIcon string) *MyInfo {
 	// The web client compares userType/service case-sensitively; a UIN account must
 	// report ICQ so it renders as an ICQ contact rather than AIM.
 	userType, service := "aim", "AIM"
 	if screenName.IsUIN() {
 		userType, service = "icq", "ICQ"
 	}
-	myInfo := map[string]interface{}{
-		"aimId":        screenName.IdentScreenName().String(),
-		"displayId":    screenName.String(),
-		"friendly":     screenName.String(),
-		"state":        webState,
-		"userType":     userType,
-		"capabilities": []string{},
-		"bot":          false,
-		"service":      service,
-	}
-	if buddyIcon != "" {
-		myInfo["buddyIcon"] = buddyIcon
+	return &MyInfo{
+		AimID:     screenName.IdentScreenName().String(),
+		DisplayID: screenName.String(),
+		Friendly:  screenName.String(),
+		State:     webState,
+		UserType:  userType,
+		// Never nil: the client iterates capabilities unconditionally.
+		Capabilities: []string{},
+		Bot:          false,
+		Service:      service,
+		BuddyIcon:    buddyIcon,
 	}
-	return myInfo
 }
 
 func requestScheme(r *http.Request) string {

+ 10 - 5
server/webapi/handlers/session_test.go

@@ -1,6 +1,7 @@
 package handlers
 
 import (
+	"encoding/json"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -22,8 +23,8 @@ func TestBuildMyInfo_UserTypeAndService(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			mi := buildMyInfo(state.DisplayScreenName(tt.screenName), "online", "")
-			assert.Equal(t, tt.wantType, mi["userType"])
-			assert.Equal(t, tt.wantSvc, mi["service"])
+			assert.Equal(t, tt.wantType, mi.UserType)
+			assert.Equal(t, tt.wantSvc, mi.Service)
 		})
 	}
 }
@@ -31,11 +32,15 @@ func TestBuildMyInfo_UserTypeAndService(t *testing.T) {
 func TestBuildMyInfo_BuddyIcon(t *testing.T) {
 	t.Run("included when set", func(t *testing.T) {
 		mi := buildMyInfo(state.DisplayScreenName("mikekelly"), "away", "http://x/icon")
-		assert.Equal(t, "http://x/icon", mi["buddyIcon"])
+		assert.Equal(t, "http://x/icon", mi.BuddyIcon)
 	})
 	t.Run("omitted when empty so the client merge preserves the current icon", func(t *testing.T) {
 		mi := buildMyInfo(state.DisplayScreenName("mikekelly"), "away", "")
-		_, ok := mi["buddyIcon"]
-		assert.False(t, ok)
+		assert.Empty(t, mi.BuddyIcon)
+
+		// omitempty is what actually keeps it out of the payload.
+		body, err := json.Marshal(mi)
+		assert.NoError(t, err)
+		assert.NotContains(t, string(body), "buddyIcon")
 	})
 }

+ 39 - 7
server/webapi/handlers/user_info_stub.go

@@ -9,20 +9,53 @@ type UserInfoStubHandler struct {
 	Logger *slog.Logger
 }
 
+// UserDetailsData reports which third-party services the account is linked to.
+type UserDetailsData struct {
+	UserDetails UserDetails `json:"userDetails" xml:"userDetails"`
+}
+
+// UserDetails lists the linked services.
+type UserDetails struct {
+	Services []UserService `json:"services" xml:"services>service"`
+}
+
+// UserService names one linked service.
+type UserService struct {
+	Service string `json:"service" xml:"service"`
+}
+
+// NotificationsData is the social-notification feed, which this server does not
+// keep.
+type NotificationsData struct {
+	// Activities is always sent, empty included: the client maps over it
+	// unconditionally on a 200.
+	Activities []string `json:"activities" xml:"activities>activity"`
+}
+
 func (h *UserInfoStubHandler) GetUserDetails(w http.ResponseWriter, r *http.Request) {
 	resp := BaseResponse{}
 	resp.Response.StatusCode = 200
 	resp.Response.StatusText = "OK"
-	resp.Response.Data = map[string]interface{}{
-		"userDetails": map[string]interface{}{
-			"services": []map[string]interface{}{
-				{"service": "aim"},
-			},
-		},
+	resp.Response.Data = &UserDetailsData{
+		UserDetails: UserDetails{Services: []UserService{{Service: "aim"}}},
 	}
 	SendResponse(w, r, resp, h.Logger)
 }
 
+// HeyGetNotifications returns an empty social-notification feed.
+//
+// The activities array must be present even when empty: the client maps over
+// response.data.activities unconditionally on a 200, so omitting it raises
+// "Array.prototype.map called on null or undefined" and aborts the rest of the
+// notification setup.
+func (h *UserInfoStubHandler) HeyGetNotifications(w http.ResponseWriter, r *http.Request) {
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = &NotificationsData{Activities: []string{}}
+	SendResponse(w, r, resp, h.Logger)
+}
+
 func (h *UserInfoStubHandler) EmptyOK(w http.ResponseWriter, r *http.Request) {
 	h.emptyOK(w, r)
 }
@@ -31,6 +64,5 @@ func (h *UserInfoStubHandler) emptyOK(w http.ResponseWriter, r *http.Request) {
 	resp := BaseResponse{}
 	resp.Response.StatusCode = 200
 	resp.Response.StatusText = "OK"
-	resp.Response.Data = map[string]interface{}{}
 	SendResponse(w, r, resp, h.Logger)
 }

+ 0 - 1
server/webapi/handlers/webapi_event_converter.go

@@ -115,7 +115,6 @@ func ConvertEventForAMF3(event types.Event) map[string]interface{} {
 		}
 
 	case types.EventTypeBuddyList:
-		// Buddy list events are already converted to maps in FormatBuddyListEvent
 		// Just pass through
 		result["eventData"] = event.Data
 

+ 152 - 0
server/webapi/handlers/xml_shape_test.go

@@ -0,0 +1,152 @@
+package handlers
+
+import (
+	"encoding/xml"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/mk6i/open-oscar-server/server/webapi/types"
+	"github.com/mk6i/open-oscar-server/state"
+)
+
+// renderXML sends payload through the f=xml path and returns the body.
+func renderXML(t *testing.T, data any) string {
+	t.Helper()
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "Ok"
+	resp.Response.RequestID = "123"
+	resp.Response.Data = data
+
+	rr := httptest.NewRecorder()
+	SendResponse(rr, httptest.NewRequest(http.MethodGet, "/x?f=xml&r=123", nil), resp, nil)
+
+	body := rr.Body.String()
+	require.Contains(t, rr.Header().Get("Content-Type"), "xml")
+	// A document the client could not parse is the failure this guards against.
+	var probe any
+	require.NoError(t, xml.Unmarshal([]byte(body), &probe), "not well-formed: %s", body)
+	return body
+}
+
+// The spec renders the envelope as a flat <response> root carrying statusCode,
+// statusText, requestId and data — not the "response"-keyed nesting JSON uses.
+func TestXMLEnvelopeMatchesSpec(t *testing.T) {
+	body := renderXML(t, struct {
+		AimSID string `xml:"aimsid"`
+	}{AimSID: "opaquedata"})
+
+	assert.Contains(t, body, `<?xml version="1.0" encoding="UTF-8"?>`)
+	assert.Contains(t, body, "<response><statusCode>200</statusCode><statusText>Ok</statusText>"+
+		"<requestId>123</requestId><data><aimsid>opaquedata</aimsid></data></response>")
+}
+
+// Every method in the spec renders a data element even when it carries no
+// payload; the client dereferences response.data regardless of outcome.
+func TestXMLAlwaysRendersData(t *testing.T) {
+	rr := httptest.NewRecorder()
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "Ok"
+	SendResponse(rr, httptest.NewRequest(http.MethodGet, "/aim/endSession?f=xml", nil), resp, nil)
+
+	assert.Contains(t, rr.Body.String(), "<data></data>")
+}
+
+// The item names inside a list element are fixed by the spec and cannot be
+// derived from the container's name (allows holds allow, not user).
+func TestXMLItemNamesMatchSpec(t *testing.T) {
+	t.Run("buddy list", func(t *testing.T) {
+		body := renderXML(t, &BuddyListData{Groups: []WebAPIBuddyGroup{{
+			Name: "Friends",
+			Buddies: []WebAPIBuddyInfo{{
+				AimID:        "chattingchuck",
+				DisplayID:    "ChattingChuck",
+				State:        "away",
+				UserType:     "aim",
+				Capabilities: []string{"200A0000A28911D3A52D001083341CFA"},
+			}},
+		}}})
+
+		assert.Contains(t, body, "<groups><group><name>Friends</name>")
+		assert.Contains(t, body, "<buddies><buddy><aimId>chattingchuck</aimId>")
+		assert.Contains(t, body, "<capabilities><capability>200A0000A28911D3A52D001083341CFA</capability></capabilities>")
+		// The Go field names must not leak through as element names.
+		assert.NotContains(t, body, "<AimID>")
+		assert.NotContains(t, body, "<Buddies>")
+	})
+
+	t.Run("permit deny", func(t *testing.T) {
+		body := renderXML(t, PermitDenyData{
+			PDMode:     "permitOnList",
+			PermitList: []string{"ChattingChuck"},
+			DenyList:   []string{"fred"},
+		})
+
+		assert.Contains(t, body, "<pdMode>permitOnList</pdMode>")
+		assert.Contains(t, body, "<allows><allow>ChattingChuck</allow></allows>")
+		assert.Contains(t, body, "<blocks><block>fred</block></blocks>")
+	})
+
+	t.Run("presence", func(t *testing.T) {
+		body := renderXML(t, PresenceData{Users: []BuddyPresenceInfo{{
+			AimID: "chattingchuck", State: "away", UserType: "aim",
+		}}})
+
+		assert.Contains(t, body, "<users><user><aimId>chattingchuck</aimId>")
+	})
+
+	t.Run("fetch events", func(t *testing.T) {
+		body := renderXML(t, &FetchEventsData{
+			Events: []types.Event{{
+				Type:      types.EventTypeTyping,
+				SeqNum:    7,
+				Timestamp: 100,
+				Data:      types.TypingEvent{AimID: "chattingchuck", TypingStatus: "typing"},
+			}},
+			LastSeqNum: 7,
+		})
+
+		assert.Contains(t, body, "<events><event><type>typing</type><seqNum>7</seqNum>")
+		assert.Contains(t, body, "<eventData><aimId>chattingchuck</aimId><typingStatus>typing</typingStatus></eventData>")
+	})
+}
+
+// myInfo is the payload the spec documents in the most detail, and the one the
+// old hand-built XML truncated to two fields.
+func TestXMLMyInfoCarriesEveryField(t *testing.T) {
+	mi := buildMyInfo(state.DisplayScreenName("ChattingChuck"), "away", "http://host/icon")
+	mi.OnlineTime = 100
+	mi.AwayMsg = "I'm busy right now chatting."
+
+	body := renderXML(t, mi)
+
+	for _, want := range []string{
+		"<aimId>chattingchuck</aimId>",
+		"<displayId>ChattingChuck</displayId>",
+		"<friendly>ChattingChuck</friendly>",
+		"<state>away</state>",
+		"<userType>aim</userType>",
+		"<awayMsg>I&#39;m busy right now chatting.</awayMsg>",
+		"<buddyIcon>http://host/icon</buddyIcon>",
+		"<onlineTime>100</onlineTime>",
+	} {
+		assert.Contains(t, body, want)
+	}
+}
+
+// Preferences are the one payload that is legitimately partial, so an absent
+// preference and one set to zero have to stay distinguishable in XML too.
+func TestXMLPreferencesDistinguishAbsentFromZero(t *testing.T) {
+	prefs := &PreferenceData{}
+	prefs.Set("showGroups", 0)
+
+	body := renderXML(t, prefs)
+
+	assert.Contains(t, body, "<showGroups>0</showGroups>")
+	assert.NotContains(t, body, "<playIMSound>")
+}

+ 3 - 0
server/webapi/middleware/auth.go

@@ -367,6 +367,9 @@ func (m *AuthMiddleware) writeErrorEnvelope(w http.ResponseWriter, r *http.Reque
 	envelope := map[string]any{
 		"statusCode": statusCode,
 		"statusText": message,
+		// Callbacks that reach response.data on a failure throw a TypeError when
+		// it is absent, so the envelope carries an empty one even here.
+		"data": map[string]any{},
 	}
 	// The client indexes JSONP replies by response.requestId and discards any
 	// reply that lacks one, leaving the request pending until it times out.

+ 7 - 0
server/webapi/server.go

@@ -267,8 +267,15 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		// method is an unimplemented social-feed feature; the subtree catch-all
 		// acknowledges them with an empty 200 so the client doesn't error.
 		mux.Handle("GET /lifestream/getUserDetails", stubRoute(lifestreamStub.GetUserDetails))
+		mux.Handle("GET /lifestream/heyGetNotifications", stubRoute(lifestreamStub.HeyGetNotifications))
 		mux.Handle("GET /lifestream/", stubRoute(lifestreamStub.EmptyOK))
 
+		// The client probes for a linked Google Talk account as soon as the
+		// session comes up, and its callback dereferences response.data unless
+		// the status says the service is absent.
+		serviceStub := &handlers.ServiceStubHandler{Logger: logger}
+		mux.Handle("GET /service/getAttributes", stubRoute(serviceStub.GetAttributes))
+
 		// Go 1.22 patterns are method-exact, so an OPTIONS preflight matches none of
 		// the "GET /x" routes above and would otherwise fall through to the 404
 		// handler, failing the preflight. CORSMiddleware answers OPTIONS with a 204

+ 50 - 22
server/webapi/types/conversation.go

@@ -2,37 +2,65 @@ package types
 
 import "time"
 
+// ConversationData is a conversation event payload: an operation and the
+// conversations it applies to.
+type ConversationData struct {
+	Operation     string                  `json:"operation" xml:"operation"`
+	Conversations []ConversationEntryData `json:"conversations" xml:"conversations>conversation"`
+}
+
+// ConversationEntryData is one conversation in the client's list.
+type ConversationEntryData struct {
+	AimID string `json:"aimId" xml:"aimId"`
+	// Active is always sent, zero included, because the client reads it
+	// unconditionally.
+	Active      int `json:"active" xml:"active"`
+	UnreadCount int `json:"unreadCount" xml:"unreadCount"`
+	// DisplayID is omitted when empty rather than sent blank: the client falls
+	// back to the name it already has for aimID, whereas any value present here
+	// replaces it.
+	DisplayID string  `json:"displayId,omitempty" xml:"displayId,omitempty"`
+	LastIM    *LastIM `json:"lastIM,omitempty" xml:"lastIM,omitempty"`
+}
+
+// LastIM is the most recent message in a conversation.
+//
+// Timestamp is a float because AMF3 encodes whole numbers in 29 bits, which a
+// Unix timestamp overflows.
+type LastIM struct {
+	Message   string  `json:"message" xml:"message"`
+	MsgID     string  `json:"msgId" xml:"msgId"`
+	Sender    string  `json:"sender" xml:"sender"`
+	Sent      bool    `json:"sent" xml:"sent"`
+	Timestamp float64 `json:"timestamp" xml:"timestamp"`
+}
+
 // ConversationEventData builds a conversation fetchEvents payload.
-func ConversationEventData(operation string, conversations []map[string]interface{}) map[string]interface{} {
+func ConversationEventData(operation string, conversations []ConversationEntryData) *ConversationData {
 	if conversations == nil {
-		conversations = []map[string]interface{}{}
+		conversations = []ConversationEntryData{}
 	}
-	return map[string]interface{}{
-		"operation":     operation,
-		"conversations": conversations,
+	return &ConversationData{
+		Operation:     operation,
+		Conversations: conversations,
 	}
 }
 
 // ConversationEntry builds one conversation object for the Web AIM client.
-//
-// An empty displayID is omitted rather than sent blank: the client falls back to
-// the name it already has for aimID, whereas any value present here replaces it.
-func ConversationEntry(aimID, displayID, message, msgID, sender string, sent bool, unread int) map[string]interface{} {
-	entry := map[string]interface{}{
-		"aimId":       aimID,
-		"active":      0,
-		"unreadCount": unread,
-	}
-	if displayID != "" {
-		entry["displayId"] = displayID
+func ConversationEntry(aimID, displayID, message, msgID, sender string, sent bool, unread int) ConversationEntryData {
+	entry := ConversationEntryData{
+		AimID:       aimID,
+		Active:      0,
+		UnreadCount: unread,
+		DisplayID:   displayID,
 	}
 	if message != "" {
-		entry["lastIM"] = map[string]interface{}{
-			"message":   message,
-			"msgId":     msgID,
-			"sender":    sender,
-			"sent":      sent,
-			"timestamp": float64(time.Now().Unix()),
+		entry.LastIM = &LastIM{
+			Message:   message,
+			MsgID:     msgID,
+			Sender:    sender,
+			Sent:      sent,
+			Timestamp: float64(time.Now().Unix()),
 		}
 	}
 	return entry

+ 40 - 40
server/webapi/types/events.go

@@ -28,10 +28,10 @@ const (
 
 // Event represents an event to be delivered to a web client.
 type Event struct {
-	Type      EventType   `json:"type"`
-	SeqNum    uint64      `json:"seqNum"`
-	Timestamp int64       `json:"timestamp"`
-	Data      interface{} `json:"eventData"`
+	Type      EventType   `json:"type" xml:"type"`
+	SeqNum    uint64      `json:"seqNum" xml:"seqNum"`
+	Timestamp int64       `json:"timestamp" xml:"timestamp"`
+	Data      interface{} `json:"eventData" xml:"eventData"`
 }
 
 // PresenceEvent represents a presence change event.
@@ -39,24 +39,24 @@ type Event struct {
 // alias it already holds, so a presence update that omits it silently renames the
 // buddy back to their screen name. See UserInfo.
 type PresenceEvent struct {
-	AimID      string `json:"aimId"`
-	Friendly   string `json:"friendly,omitempty"`
-	State      string `json:"state"` // "online", "offline", "away", "idle"
-	StatusMsg  string `json:"statusMsg,omitempty"`
-	AwayMsg    string `json:"awayMsg,omitempty"`
-	IdleTime   int    `json:"idleTime,omitempty"`   // Minutes idle
-	OnlineTime int64  `json:"onlineTime,omitempty"` // Unix timestamp
-	UserType   string `json:"userType"`             // "aim", "icq", "admin"
-	BuddyIcon  string `json:"buddyIcon,omitempty"`  // Absolute icon URL; empty preserves the client's current icon, the placeholder URL clears it
+	AimID      string `json:"aimId" xml:"aimId"`
+	Friendly   string `json:"friendly,omitempty" xml:"friendly,omitempty"`
+	State      string `json:"state" xml:"state"` // "online", "offline", "away", "idle"
+	StatusMsg  string `json:"statusMsg,omitempty" xml:"statusMsg,omitempty"`
+	AwayMsg    string `json:"awayMsg,omitempty" xml:"awayMsg,omitempty"`
+	IdleTime   int    `json:"idleTime,omitempty" xml:"idleTime,omitempty"`     // Minutes idle
+	OnlineTime int64  `json:"onlineTime,omitempty" xml:"onlineTime,omitempty"` // Unix timestamp
+	UserType   string `json:"userType" xml:"userType"`                         // "aim", "icq", "admin"
+	BuddyIcon  string `json:"buddyIcon,omitempty" xml:"buddyIcon,omitempty"`   // Absolute icon URL; empty preserves the client's current icon, the placeholder URL clears it
 }
 
 // IMEvent represents an instant message event.
 type IMEvent struct {
-	Source    UserInfo `json:"source"`
-	Message   string   `json:"message"`
-	MsgID     string   `json:"msgId,omitempty"`
-	Timestamp float64  `json:"timestamp"` // float64 for AMF3 encoding
-	AutoResp  bool     `json:"autoresponse,omitempty"`
+	Source    UserInfo `json:"source" xml:"source"`
+	Message   string   `json:"message" xml:"message"`
+	MsgID     string   `json:"msgId,omitempty" xml:"msgId,omitempty"`
+	Timestamp float64  `json:"timestamp" xml:"timestamp"` // float64 for AMF3 encoding
+	AutoResp  bool     `json:"autoresponse,omitempty" xml:"autoresponse,omitempty"`
 }
 
 // OfflineIMEvent represents a message that was stored while the user was signed
@@ -67,21 +67,21 @@ type IMEvent struct {
 // buddy list it already holds. Timestamp is when the sender sent the message, not
 // when it was delivered.
 type OfflineIMEvent struct {
-	AimID     string  `json:"aimId"`
-	Message   string  `json:"message"`
-	MsgID     string  `json:"msgId,omitempty"`
-	Timestamp float64 `json:"timestamp"` // float64 for AMF3 encoding
-	AutoResp  bool    `json:"autoresponse,omitempty"`
+	AimID     string  `json:"aimId" xml:"aimId"`
+	Message   string  `json:"message" xml:"message"`
+	MsgID     string  `json:"msgId,omitempty" xml:"msgId,omitempty"`
+	Timestamp float64 `json:"timestamp" xml:"timestamp"` // float64 for AMF3 encoding
+	AutoResp  bool    `json:"autoresponse,omitempty" xml:"autoresponse,omitempty"`
 }
 
 // SentIMEvent represents a sent instant message event.
 type SentIMEvent struct {
-	Sender    UserInfo `json:"sender"` // Sender user info
-	Dest      UserInfo `json:"dest"`   // Destination user info
-	Message   string   `json:"message"`
-	MsgID     string   `json:"msgId,omitempty"`
-	Timestamp float64  `json:"timestamp"` // float64 for AMF3 encoding
-	AutoResp  bool     `json:"autoResponse,omitempty"`
+	Sender    UserInfo `json:"sender" xml:"sender"` // Sender user info
+	Dest      UserInfo `json:"dest" xml:"dest"`     // Destination user info
+	Message   string   `json:"message" xml:"message"`
+	MsgID     string   `json:"msgId,omitempty" xml:"msgId,omitempty"`
+	Timestamp float64  `json:"timestamp" xml:"timestamp"` // float64 for AMF3 encoding
+	AutoResp  bool     `json:"autoResponse,omitempty" xml:"autoResponse,omitempty"`
 }
 
 // UserInfo represents basic user information in events.
@@ -93,18 +93,18 @@ type SentIMEvent struct {
 // per aimId, and that merge deletes friendly before applying the map. An alias
 // therefore has to be repeated on every user map, or it is lost.
 type UserInfo struct {
-	AimID      string  `json:"aimId"`
-	DisplayID  string  `json:"displayId,omitempty"`
-	Friendly   string  `json:"friendly,omitempty"`
-	UserType   string  `json:"userType,omitempty"`
-	State      string  `json:"state,omitempty"`
-	OnlineTime float64 `json:"onlineTime,omitempty"` // float64 for AMF3 encoding
+	AimID      string  `json:"aimId" xml:"aimId"`
+	DisplayID  string  `json:"displayId,omitempty" xml:"displayId,omitempty"`
+	Friendly   string  `json:"friendly,omitempty" xml:"friendly,omitempty"`
+	UserType   string  `json:"userType,omitempty" xml:"userType,omitempty"`
+	State      string  `json:"state,omitempty" xml:"state,omitempty"`
+	OnlineTime float64 `json:"onlineTime,omitempty" xml:"onlineTime,omitempty"` // float64 for AMF3 encoding
 }
 
 // TypingEvent represents a typing notification event.
 type TypingEvent struct {
-	AimID        string `json:"aimId"`
-	TypingStatus string `json:"typingStatus"`
+	AimID        string `json:"aimId" xml:"aimId"`
+	TypingStatus string `json:"typingStatus" xml:"typingStatus"`
 }
 
 // RateLimitEvent tells the client that its rate limit status changed.
@@ -116,13 +116,13 @@ type TypingEvent struct {
 // client's last recorded status was "limit", so this event must be pushed on
 // status transitions rather than on every rate-limited request.
 type RateLimitEvent struct {
-	Classes []RateLimitClass `json:"classes"`
+	Classes []RateLimitClass `json:"classes" xml:"classes>class"`
 }
 
 // RateLimitClass is the per-rate-class state carried by a RateLimitEvent.
 type RateLimitClass struct {
-	ID     int    `json:"id"`
-	Status string `json:"status"` // "clear", "warn", "limit", or "disconnect"
+	ID     int    `json:"id" xml:"id"`
+	Status string `json:"status" xml:"status"` // "clear", "warn", "limit", or "disconnect"
 }
 
 // EventQueue manages a queue of events for a WebAPI session.

+ 19 - 8
state/webapi_imlog.go

@@ -32,6 +32,17 @@ func (s *WebAPISession) AddStoredIM(partnerAimID, sender, message, msgID string,
 	})
 }
 
+// StoredIM is one entry in a fetchStoredIMs reply.
+//
+// Date is a float because AMF3 encodes whole numbers in 29 bits, which a Unix
+// timestamp overflows.
+type StoredIM struct {
+	Sender  string  `json:"sender" xml:"sender"`
+	Message string  `json:"message" xml:"message"`
+	MsgID   string  `json:"msgId" xml:"msgId"`
+	Date    float64 `json:"date" xml:"date"`
+}
+
 // StoredIMQuery describes filters for fetchStoredIMs.
 type StoredIMQuery struct {
 	PartnerAimID string
@@ -45,7 +56,7 @@ type StoredIMQuery struct {
 
 // GetStoredIMs returns stored messages for a conversation partner, filtered and sorted
 // per the Web AIM client's fetchStoredIMs parameters.
-func (s *WebAPISession) GetStoredIMs(q StoredIMQuery) []map[string]interface{} {
+func (s *WebAPISession) GetStoredIMs(q StoredIMQuery) []StoredIM {
 	if s == nil || q.PartnerAimID == "" {
 		return nil
 	}
@@ -55,7 +66,7 @@ func (s *WebAPISession) GetStoredIMs(q StoredIMQuery) []map[string]interface{} {
 	s.imLogMu.Unlock()
 
 	if len(msgs) == 0 {
-		return []map[string]interface{}{}
+		return []StoredIM{}
 	}
 
 	filtered := make([]WebAPIStoredIM, 0, len(msgs))
@@ -102,13 +113,13 @@ func (s *WebAPISession) GetStoredIMs(q StoredIMQuery) []map[string]interface{} {
 		filtered = filtered[:n]
 	}
 
-	out := make([]map[string]interface{}, len(filtered))
+	out := make([]StoredIM, len(filtered))
 	for i, msg := range filtered {
-		out[i] = map[string]interface{}{
-			"sender":  msg.Sender,
-			"message": msg.Message,
-			"msgId":   msg.MsgID,
-			"date":    float64(msg.Date),
+		out[i] = StoredIM{
+			Sender:  msg.Sender,
+			Message: msg.Message,
+			MsgID:   msg.MsgID,
+			Date:    float64(msg.Date),
 		}
 	}
 	return out

+ 5 - 5
state/webapi_imlog_test.go

@@ -19,9 +19,9 @@ func TestWebAPISession_GetStoredIMs(t *testing.T) {
 		NToGet:       10,
 	})
 	assert.Len(t, msgs, 2)
-	assert.Equal(t, "msg-2", msgs[0]["msgId"])
-	assert.Equal(t, float64(200), msgs[0]["date"])
-	assert.Equal(t, "hello", msgs[1]["message"])
+	assert.Equal(t, "msg-2", msgs[0].MsgID)
+	assert.Equal(t, float64(200), msgs[0].Date)
+	assert.Equal(t, "hello", msgs[1].Message)
 
 	msgs = sess.GetStoredIMs(StoredIMQuery{
 		PartnerAimID: "buddy1",
@@ -30,7 +30,7 @@ func TestWebAPISession_GetStoredIMs(t *testing.T) {
 		EndTime:      250,
 	})
 	assert.Len(t, msgs, 1)
-	assert.Equal(t, "msg-2", msgs[0]["msgId"])
+	assert.Equal(t, "msg-2", msgs[0].MsgID)
 }
 
 func TestWebAPISession_GetStoredIMs_NormalizesPartner(t *testing.T) {
@@ -44,5 +44,5 @@ func TestWebAPISession_GetStoredIMs_NormalizesPartner(t *testing.T) {
 		NToGet:       10,
 	})
 	require.Len(t, msgs, 1)
-	assert.Equal(t, "msg-1", msgs[0]["msgId"])
+	assert.Equal(t, "msg-1", msgs[0].MsgID)
 }

+ 3 - 3
state/webapi_session.go

@@ -413,7 +413,7 @@ func (s *WebAPISession) handleIncomingIM(msg wire.SNACMessage) {
 		// this conversation's unreadCount, so sending 1 here would double-count
 		// the message (badge shows 2 for the first IM). Mirrors the sent-IM path,
 		// which also passes 0.
-		s.EventQueue.Push(types.EventTypeConversation, types.ConversationEventData("update", []map[string]interface{}{
+		s.EventQueue.Push(types.EventTypeConversation, types.ConversationEventData("update", []types.ConversationEntryData{
 			types.ConversationEntry(
 				partnerAimID,
 				partnerDisplay,
@@ -554,11 +554,11 @@ func (s *WebAPISession) handleFeedbagMessage(msg wire.SNACMessage) {
 		s.InvalidateAliases()
 
 		if s.BuddyListRefresher != nil {
-			groups, err := s.BuddyListRefresher(s.ctx)
+			payload, err := s.BuddyListRefresher(s.ctx)
 			if err != nil {
 				s.logger.Error("failed to refresh buddy list after feedbag change", "err", err)
 			} else {
-				s.EventQueue.Push(types.EventTypeBuddyList, map[string]interface{}{"groups": groups})
+				s.EventQueue.Push(types.EventTypeBuddyList, payload)
 			}
 		}
 		if s.PermitDenyRefresher != nil {

+ 9 - 8
state/webapi_session_test.go

@@ -829,18 +829,19 @@ func TestWebAPISession_HandleIncomingIM_NormalizesAimID(t *testing.T) {
 	assert.Equal(t, "mikekelly", imEvent.Source.AimID)
 	assert.Equal(t, "Mike Kelly", imEvent.Source.DisplayID)
 
-	convData := events[1].Data.(map[string]interface{})
-	entries := convData["conversations"].([]map[string]interface{})
-	require.Len(t, entries, 1)
-	assert.Equal(t, "mikekelly", entries[0]["aimId"])
-	assert.Equal(t, "Mike Kelly", entries[0]["displayId"])
-	assert.Equal(t, "mikekelly", entries[0]["lastIM"].(map[string]interface{})["sender"])
+	convData := events[1].Data.(*types.ConversationData)
+	require.Len(t, convData.Conversations, 1)
+	entry := convData.Conversations[0]
+	assert.Equal(t, "mikekelly", entry.AimID)
+	assert.Equal(t, "Mike Kelly", entry.DisplayID)
+	require.NotNil(t, entry.LastIM)
+	assert.Equal(t, "mikekelly", entry.LastIM.Sender)
 
 	// The IM log is keyed by aimId, so the conversation the client opens from
 	// this event finds its own history.
 	msgs := sess.GetStoredIMs(StoredIMQuery{PartnerAimID: "mikekelly", NToGet: 10})
 	require.Len(t, msgs, 1)
-	assert.Equal(t, "hello", msgs[0]["message"])
+	assert.Equal(t, "hello", msgs[0].Message)
 }
 
 func TestWebAPISession_HandleTypingNotification_NormalizesAimID(t *testing.T) {
@@ -1141,7 +1142,7 @@ func TestWebAPISession_OfflineIM(t *testing.T) {
 
 		stored := sess.GetStoredIMs(StoredIMQuery{PartnerAimID: "mikekelly", NToGet: 10})
 		require.Len(t, stored, 1)
-		assert.Equal(t, float64(sentAt), stored[0]["date"])
+		assert.Equal(t, float64(sentAt), stored[0].Date)
 	})
 
 	// Only a live IM is filtered on subscription here. Retrieval answers the