Mike 1 неделя назад
Родитель
Сommit
f81a2f5cd8

+ 1 - 1
go.mod

@@ -3,7 +3,7 @@ module github.com/mk6i/open-oscar-server
 go 1.26.2
 
 require (
-	github.com/breign/goAMF3 v1.0.1-0.20250916173039-e43798221950
+	github.com/breign/goAMF3 v1.0.2
 	github.com/golang-migrate/migrate/v4 v4.19.1
 	github.com/google/uuid v1.6.0
 	github.com/joho/godotenv v1.5.1

+ 2 - 2
go.sum

@@ -1,5 +1,5 @@
-github.com/breign/goAMF3 v1.0.1-0.20250916173039-e43798221950 h1:4TpGDBqh7wsi7UvgoE85EknpgpYy2Yj5ZHahqwbF2LE=
-github.com/breign/goAMF3 v1.0.1-0.20250916173039-e43798221950/go.mod h1:ZN4htA6gGwnzqpHTfuRD+Ryt+Nj8sEIyecdQhFn4smY=
+github.com/breign/goAMF3 v1.0.2 h1:LiZ2iAvjiFJ/ISMHiwGaBD26aovB1LVI9p53ORFr8Gc=
+github.com/breign/goAMF3 v1.0.2/go.mod h1:ZN4htA6gGwnzqpHTfuRD+Ryt+Nj8sEIyecdQhFn4smY=
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=

+ 1 - 24
server/webapi/aim_handler.go

@@ -597,30 +597,7 @@ func (h *AimHandler) FetchEvents(w http.ResponseWriter, r *http.Request, session
 			baseURLFromRequest(r), aimsid, newLastSeqNum),
 	}
 
-	// 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"))
-	if format == "amf" || format == "amf3" {
-		amfResp := map[string]interface{}{
-			"response": map[string]interface{}{
-				"data": map[string]interface{}{
-					"events":          ConvertEventsForAMF3(events),
-					"lastSeqNum":      newLastSeqNum,
-					"timeToNextFetch": session.TimeToNextFetch,
-					"fetchBaseURL": fmt.Sprintf("%s/aim/fetchEvents?aimsid=%s&seqNum=%d",
-						baseURLFromRequest(r), aimsid, newLastSeqNum),
-				},
-				"statusCode":       200,
-				"statusText":       "OK",
-				"statusDetailCode": 0,
-			},
-		}
-		SendResponse(w, r, amfResp, h.Logger)
-	} else {
-		// Send response in requested format (JSON, JSONP, or XML)
-		SendOK(w, r, data, h.Logger)
-	}
+	SendOK(w, r, data, h.Logger)
 
 	if len(events) > 0 {
 		h.Logger.DebugContext(ctx, "events fetched",

+ 0 - 529
server/webapi/amf.go

@@ -1,529 +0,0 @@
-package webapi
-
-import (
-	"fmt"
-	"log/slog"
-	"reflect"
-	"strings"
-	"time"
-
-	goAMF3 "github.com/breign/goAMF3"
-)
-
-// AMFEncoder handles AMF encoding operations for WebAPI responses
-type AMFEncoder struct {
-	logger *slog.Logger
-}
-
-// NewAMFEncoder creates a new AMF encoder instance
-func NewAMFEncoder(logger *slog.Logger) *AMFEncoder {
-	return &AMFEncoder{logger: logger}
-}
-
-// EncodeAMF encodes data to AMF3 format (only supported version)
-func (e *AMFEncoder) EncodeAMF(data interface{}) ([]byte, error) {
-	// For AMF3, use goAMF3 which properly supports it
-	// Convert to a regular map structure (no ECMAArray needed)
-	amfData := e.toAMF3Compatible(data)
-	// goAMF3 panics on nil values, ensure we sanitize
-	sanitized := e.sanitizeForAMF3(amfData)
-	encoded := goAMF3.EncodeAMF3(sanitized)
-	return encoded, nil
-}
-
-// toAMF3Compatible converts Go types to AMF3-compatible format for goAMF3
-func (e *AMFEncoder) toAMF3Compatible(data interface{}) interface{} {
-	if data == nil {
-		return map[string]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)
-	case ResponseBody:
-		return e.responseBodyToMap(d)
-	case ErrorResponse:
-		return e.errorResponseToMap(d)
-	default:
-		// For other types, convert structs to maps
-		return e.convertToMap(data)
-	}
-}
-
-// sanitizeForAMF3 recursively removes nil values from the data structure
-// 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{}{}
-	}
-
-	switch v := data.(type) {
-	case uint64:
-		// goAMF3 can't handle uint64, convert to int
-		return int(v)
-	case uint32:
-		// Convert all unsigned to signed for safety
-		return int(v)
-	case uint16:
-		return int(v)
-	case uint8:
-		return int(v)
-	case uint:
-		return int(v)
-	case map[string]interface{}:
-		result := make(map[string]interface{})
-		for key, val := range v {
-			if val == nil {
-				// For fields like 'data', replace with empty map
-				// For other fields, skip them
-				if key == "data" {
-					result[key] = map[string]interface{}{}
-				}
-				continue
-			}
-			result[key] = e.sanitizeForAMF3(val)
-		}
-		return result
-	case []interface{}:
-		result := make([]interface{}, len(v))
-		for i, item := range v {
-			result[i] = e.sanitizeForAMF3(item)
-		}
-		return result
-	default:
-		// goAMF3 writes nothing for a value it cannot encode, which truncates the
-		// object mid-key, so pointers and structs are reduced to maps first.
-		rv := reflect.ValueOf(data)
-		if rv.Kind() == reflect.Pointer {
-			if rv.IsNil() {
-				return map[string]interface{}{}
-			}
-			rv = rv.Elem()
-		}
-		if rv.Kind() == reflect.Struct {
-			return e.sanitizeForAMF3(e.structToMap(rv))
-		}
-		return rv.Interface()
-	}
-}
-
-// baseResponseToMap converts BaseResponse to AMF3-compatible map
-func (e *AMFEncoder) baseResponseToMap(resp BaseResponse) map[string]interface{} {
-	return map[string]interface{}{
-		"response": e.responseBodyToMap(resp.Response),
-	}
-}
-
-// responseBodyToMap converts ResponseBody to AMF3-compatible map
-func (e *AMFEncoder) responseBodyToMap(body ResponseBody) map[string]interface{} {
-	m := map[string]interface{}{
-		"statusCode": body.StatusCode,
-		"statusText": body.StatusText,
-	}
-	if body.RequestID != "" {
-		m["requestId"] = body.RequestID
-	}
-	if body.Data != nil {
-		m["data"] = e.toAMF3Compatible(body.Data)
-	} else {
-		// For AMF3, always include data field even if empty to prevent truncation
-		m["data"] = map[string]interface{}{}
-	}
-	return m
-}
-
-// errorResponseToMap converts ErrorResponse to AMF3-compatible map
-func (e *AMFEncoder) errorResponseToMap(err ErrorResponse) map[string]interface{} {
-	m := map[string]interface{}{
-		"statusCode": err.Response.StatusCode,
-		"statusText": err.Response.StatusText,
-	}
-	// Both are omitted when unset, matching the omitempty the JSON and XML
-	// encodings apply to the same two fields.
-	if err.Response.StatusDetailCode != 0 {
-		m["statusDetailCode"] = err.Response.StatusDetailCode
-	}
-	if err.Response.RequestID != "" {
-		m["requestId"] = err.Response.RequestID
-	}
-	// 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
-func (e *AMFEncoder) structToMap(v reflect.Value) map[string]interface{} {
-	result := make(map[string]interface{})
-	t := v.Type()
-
-	for i := 0; i < v.NumField(); i++ {
-		field := t.Field(i)
-		fieldValue := v.Field(i)
-
-		// Skip unexported fields
-		if !fieldValue.CanInterface() {
-			continue
-		}
-
-		// Get JSON tag
-		jsonTag := field.Tag.Get("json")
-		if jsonTag == "-" {
-			continue
-		}
-
-		// Parse JSON tag
-		tagParts := strings.Split(jsonTag, ",")
-		fieldName := tagParts[0]
-		if fieldName == "" {
-			fieldName = field.Name
-		}
-
-		// Check for omitempty
-		omitEmpty := false
-		for _, part := range tagParts[1:] {
-			if part == "omitempty" {
-				omitEmpty = true
-				break
-			}
-		}
-
-		// Skip if omitempty and value is zero
-		if omitEmpty && e.isZeroValue(fieldValue) {
-			continue
-		}
-
-		// Get field value and convert recursively
-		fieldData := fieldValue.Interface()
-		result[fieldName] = e.toAMF3Compatible(fieldData)
-	}
-
-	return result
-}
-
-// mapToAMFMap converts a Go map to an AMF3-compatible map
-func (e *AMFEncoder) mapToAMFMap(v reflect.Value) map[string]interface{} {
-	result := make(map[string]interface{})
-
-	for _, key := range v.MapKeys() {
-		// Convert key to string (AMF only supports string keys)
-		keyStr := fmt.Sprintf("%v", key.Interface())
-		value := v.MapIndex(key)
-
-		if value.CanInterface() {
-			result[keyStr] = e.toAMF3Compatible(value.Interface())
-		}
-	}
-
-	return result
-}
-
-// convertToMap converts any data to a map structure for AMF3
-func (e *AMFEncoder) convertToMap(data interface{}) interface{} {
-	if data == nil {
-		// For AMF3, return empty map instead of nil to avoid truncation
-		return map[string]interface{}{}
-	}
-
-	// If already a map, return as-is (even if empty)
-	if m, ok := data.(map[string]interface{}); ok {
-		if m == nil {
-			return map[string]interface{}{}
-		}
-		return m
-	}
-
-	v := reflect.ValueOf(data)
-
-	// Handle pointers
-	if v.Kind() == reflect.Pointer {
-		if v.IsNil() {
-			return nil
-		}
-		v = v.Elem()
-		data = v.Interface()
-	}
-
-	// Handle different types
-	switch v.Kind() {
-	case reflect.Struct:
-		return e.structToMap(v)
-	case reflect.Map:
-		return e.mapToAMFMap(v)
-	case reflect.Slice, reflect.Array:
-		result := make([]interface{}, v.Len())
-		for i := 0; i < v.Len(); i++ {
-			elem := v.Index(i)
-			if elem.CanInterface() {
-				result[i] = e.convertToMap(elem.Interface())
-			}
-		}
-		return result
-	default:
-		// For basic types, return as-is
-		return data
-	}
-}
-
-// isZeroValue checks if a reflect.Value is a zero value
-func (e *AMFEncoder) isZeroValue(v reflect.Value) bool {
-	switch v.Kind() {
-	case reflect.Array, reflect.Map, reflect.Slice, reflect.String:
-		return v.Len() == 0
-	case reflect.Bool:
-		return !v.Bool()
-	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
-		return v.Int() == 0
-	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
-		return v.Uint() == 0
-	case reflect.Float32, reflect.Float64:
-		return v.Float() == 0
-	case reflect.Interface, reflect.Pointer:
-		return v.IsNil()
-	case reflect.Struct:
-		// For time.Time, check if it's zero
-		if t, ok := v.Interface().(time.Time); ok {
-			return t.IsZero()
-		}
-		// For other structs, we can't easily determine zero value
-		return false
-	}
-	return false
-}
-
-// ConvertEventForAMF3 converts a WebAPIEvent to a map suitable for AMF3 encoding,
-// ensuring all timestamps are float64 to avoid uint29 overflow issues.
-func ConvertEventForAMF3(event Event) map[string]interface{} {
-	result := map[string]interface{}{
-		"type":      string(event.Type),
-		"seqNum":    event.SeqNum,
-		"timestamp": float64(event.Timestamp), // Convert to float64
-	}
-
-	// Convert event data based on type
-	switch event.Type {
-	case EventTypeIM:
-		if imEvent, ok := event.Data.(IMEvent); ok {
-			// Gromit expects 'source' as a user object and 'autoresponse' (lowercase)
-			eventData := map[string]interface{}{
-				"source": map[string]interface{}{
-					"aimId":     imEvent.Source.AimID,
-					"displayId": imEvent.Source.DisplayID,
-					"userType":  imEvent.Source.UserType,
-					"state":     imEvent.Source.State,
-				},
-				"message":      imEvent.Message,
-				"timestamp":    imEvent.Timestamp, // Already float64
-				"autoresponse": imEvent.AutoResp,
-			}
-			if imEvent.MsgID != "" {
-				eventData["msgId"] = imEvent.MsgID
-			}
-			result["eventData"] = eventData
-		} else if dataMap, ok := event.Data.(map[string]interface{}); ok {
-			// Already a map, ensure timestamps are float64
-			if ts, exists := dataMap["timestamp"]; exists {
-				if tsInt, ok := ts.(int64); ok {
-					dataMap["timestamp"] = float64(tsInt)
-				}
-			}
-			result["eventData"] = dataMap
-		} else {
-			result["eventData"] = event.Data
-		}
-
-	case EventTypeOfflineIM:
-		if imEvent, ok := event.Data.(OfflineIMEvent); ok {
-			eventData := map[string]interface{}{
-				"aimId":        imEvent.AimID,
-				"message":      imEvent.Message,
-				"timestamp":    imEvent.Timestamp, // Already float64
-				"autoresponse": imEvent.AutoResp,
-			}
-			// The client keys its conversation list and chat-log cache by msgId, so
-			// an event without one collides with every other offline message.
-			if imEvent.MsgID != "" {
-				eventData["msgId"] = imEvent.MsgID
-			}
-			result["eventData"] = eventData
-		} else if dataMap, ok := event.Data.(map[string]interface{}); ok {
-			// Already a map, ensure timestamps are float64
-			if ts, exists := dataMap["timestamp"]; exists {
-				if tsInt, ok := ts.(int64); ok {
-					dataMap["timestamp"] = float64(tsInt)
-				}
-			}
-			result["eventData"] = dataMap
-		} else {
-			result["eventData"] = event.Data
-		}
-
-	case EventTypePresence:
-		if presenceEvent, ok := event.Data.(PresenceEvent); ok {
-			eventData := map[string]interface{}{
-				"aimId":    presenceEvent.AimID,
-				"state":    presenceEvent.State,
-				"userType": presenceEvent.UserType,
-			}
-			// Convert timestamp fields to float64
-			if presenceEvent.OnlineTime > 0 {
-				eventData["onlineTime"] = float64(presenceEvent.OnlineTime)
-			}
-			// This branch flattens PresenceEvent through an explicit allowlist, so
-			// buddyIcon must be added here or it never reaches an AMF3 client.
-			if presenceEvent.BuddyIcon != "" {
-				eventData["buddyIcon"] = presenceEvent.BuddyIcon
-			}
-			result["eventData"] = eventData
-		} else if dataMap, ok := event.Data.(map[string]interface{}); ok {
-			// Already a map, ensure timestamps are float64
-			if ot, exists := dataMap["onlineTime"]; exists {
-				if otInt, ok := ot.(int64); ok {
-					dataMap["onlineTime"] = float64(otInt)
-				}
-			}
-			result["eventData"] = dataMap
-		} else {
-			result["eventData"] = event.Data
-		}
-
-	case EventType("myInfo"):
-		// MyInfo events often contain timestamps
-		if dataMap, ok := event.Data.(map[string]interface{}); ok {
-			// Convert any int64 timestamps to float64
-			for key, val := range dataMap {
-				if key == "onlineTime" || key == "memberSince" || key == "awayTime" || key == "statusTime" {
-					if intVal, ok := val.(int64); ok {
-						dataMap[key] = float64(intVal)
-					}
-				}
-			}
-			result["eventData"] = dataMap
-		} else {
-			result["eventData"] = event.Data
-		}
-
-	case EventTypeBuddyList:
-		// Just pass through
-		result["eventData"] = event.Data
-
-	case EventTypeTyping:
-		result["eventData"] = event.Data
-
-	case EventTypeSentIM:
-		if sentIMEvent, ok := event.Data.(SentIMEvent); ok {
-			// Gromit expects both 'source' (sender) and 'dest' (recipient) for sentIM
-			// The parseIM function needs source even for outgoing messages
-			eventData := map[string]interface{}{
-				"source": map[string]interface{}{
-					"aimId":     sentIMEvent.Sender.AimID,
-					"displayId": sentIMEvent.Sender.DisplayID,
-					"userType":  sentIMEvent.Sender.UserType,
-					"state":     "online",
-				},
-				"dest": map[string]interface{}{
-					"aimId":     sentIMEvent.Dest.AimID,
-					"displayId": sentIMEvent.Dest.DisplayID,
-					"userType":  sentIMEvent.Dest.UserType,
-					"state":     "online",
-				},
-				"message":      sentIMEvent.Message,
-				"timestamp":    sentIMEvent.Timestamp, // Already float64
-				"autoresponse": sentIMEvent.AutoResp,
-			}
-			if sentIMEvent.MsgID != "" {
-				eventData["msgId"] = sentIMEvent.MsgID
-			}
-			result["eventData"] = eventData
-		} else {
-			result["eventData"] = event.Data
-		}
-
-	default:
-		// For unknown types, check if data is a map and convert any int64 values
-		if dataMap, ok := event.Data.(map[string]interface{}); ok {
-			result["eventData"] = convertTimestampsInMap(dataMap)
-		} else {
-			result["eventData"] = event.Data
-		}
-	}
-
-	return result
-}
-
-// convertTimestampsInMap recursively converts int64 values that look like timestamps to float64
-func convertTimestampsInMap(data map[string]interface{}) map[string]interface{} {
-	result := make(map[string]interface{})
-	for key, val := range data {
-		// Check if key suggests it's a timestamp
-		if isTimestampField(key) {
-			if intVal, ok := val.(int64); ok {
-				result[key] = float64(intVal)
-				continue
-			}
-		}
-
-		// Recursively process nested maps
-		if nestedMap, ok := val.(map[string]interface{}); ok {
-			result[key] = convertTimestampsInMap(nestedMap)
-		} else if nestedSlice, ok := val.([]interface{}); ok {
-			convertedSlice := make([]interface{}, len(nestedSlice))
-			for i, item := range nestedSlice {
-				if itemMap, ok := item.(map[string]interface{}); ok {
-					convertedSlice[i] = convertTimestampsInMap(itemMap)
-				} else {
-					convertedSlice[i] = item
-				}
-			}
-			result[key] = convertedSlice
-		} else {
-			result[key] = val
-		}
-	}
-	return result
-}
-
-// isTimestampField checks if a field name suggests it contains a timestamp
-func isTimestampField(fieldName string) bool {
-	timestampFields := []string{
-		"timestamp", "Timestamp",
-		"onlineTime", "OnlineTime",
-		"memberSince", "MemberSince",
-		"awayTime", "AwayTime",
-		"statusTime", "StatusTime",
-		"idleTime", "IdleTime",
-		"loginTime", "LoginTime",
-		"createdAt", "CreatedAt",
-		"updatedAt", "UpdatedAt",
-	}
-
-	for _, tf := range timestampFields {
-		if fieldName == tf {
-			return true
-		}
-	}
-	return false
-}
-
-// ConvertEventsForAMF3 converts a slice of WebAPIEvents for AMF3 encoding
-func ConvertEventsForAMF3(events []Event) []interface{} {
-	result := make([]interface{}, len(events))
-	for i, event := range events {
-		result[i] = ConvertEventForAMF3(event)
-	}
-	return result
-}

+ 251 - 0
server/webapi/amf3/marshal.go

@@ -0,0 +1,251 @@
+// Package amf3 encodes Go values as AMF3, the format the Flash-based Web AIM
+// client reads its fetchEvents payloads in.
+package amf3
+
+import (
+	"fmt"
+	"reflect"
+	"strings"
+	"time"
+
+	goAMF3 "github.com/breign/goAMF3"
+)
+
+// AMF3 stores whole numbers in a signed 29-bit integer. A value outside this
+// range is written as a double instead, which is how a Unix timestamp survives
+// the trip.
+const (
+	minInt29 = -(1 << 28)
+	maxInt29 = 1<<28 - 1
+)
+
+var (
+	timeType = reflect.TypeFor[time.Time]()
+	byteType = reflect.TypeFor[byte]()
+)
+
+// Marshal returns the AMF3 encoding of v.
+//
+// Struct fields are named by their amf3 tag, falling back to the json tag, so a
+// field carries an amf3 tag only where the two formats disagree. Both spellings
+// honor "-" and ",omitempty".
+func Marshal(v any) ([]byte, error) {
+	norm, err := normalize(reflect.ValueOf(v))
+	if err != nil {
+		return nil, err
+	}
+	if norm == nil {
+		// A response body is an object even when it carries nothing, because the
+		// client dereferences it unconditionally.
+		norm = map[string]any{}
+	}
+	return goAMF3.EncodeAMF3(norm), nil
+}
+
+// normalize reduces v to the values the AMF3 writer encodes correctly: bool,
+// string, int32, float64, []byte, time.Time, []any and map[string]any. It is
+// given anything else only as an error, because the writer silently emits
+// nothing for a value it cannot handle, truncating the enclosing object.
+func normalize(v reflect.Value) (any, error) {
+	for v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface {
+		if v.IsNil() {
+			return nil, nil
+		}
+		v = v.Elem()
+	}
+	if !v.IsValid() {
+		return nil, nil
+	}
+
+	switch v.Kind() {
+	case reflect.Bool:
+		return v.Bool(), nil
+	case reflect.String:
+		return v.String(), nil
+	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+		return fromInt64(v.Int()), nil
+	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+		return fromUint64(v.Uint()), nil
+	case reflect.Float32, reflect.Float64:
+		return v.Float(), nil
+	case reflect.Slice, reflect.Array:
+		return normalizeSlice(v)
+	case reflect.Map:
+		return normalizeMap(v)
+	case reflect.Struct:
+		if v.Type() == timeType {
+			return v.Interface(), nil
+		}
+		return normalizeStruct(v)
+	default:
+		return nil, fmt.Errorf("amf3: cannot encode %s", v.Type())
+	}
+}
+
+// fromInt64 returns the narrowest AMF3 number holding n.
+func fromInt64(n int64) any {
+	if n >= minInt29 && n <= maxInt29 {
+		return int32(n)
+	}
+	return float64(n)
+}
+
+// fromUint64 returns the narrowest AMF3 number holding n.
+func fromUint64(n uint64) any {
+	if n <= maxInt29 {
+		return int32(n)
+	}
+	return float64(n)
+}
+
+// normalizeSlice returns v's elements as []any, passing a byte slice through so
+// it is written as an AMF3 byte array. A nil slice becomes an empty one because
+// the client iterates lists such as capabilities unconditionally.
+func normalizeSlice(v reflect.Value) (any, error) {
+	if v.Kind() == reflect.Slice && v.Type().Elem() == byteType {
+		return v.Bytes(), nil
+	}
+	out := make([]any, v.Len())
+	for i := range out {
+		elem, err := normalize(v.Index(i))
+		if err != nil {
+			return nil, fmt.Errorf("[%d]: %w", i, err)
+		}
+		out[i] = elem
+	}
+	return out, nil
+}
+
+// normalizeMap returns v's entries keyed by the string form of each key, which
+// is the only key type an AMF3 object has. A nil value is dropped rather than
+// written as null: the client merges each object it receives onto the one it
+// already holds, so an absent key leaves the current value alone.
+func normalizeMap(v reflect.Value) (any, error) {
+	out := make(map[string]any, v.Len())
+	for iter := v.MapRange(); iter.Next(); {
+		val, err := normalize(iter.Value())
+		if err != nil {
+			return nil, err
+		}
+		if val == nil {
+			continue
+		}
+		out[keyString(iter.Key())] = val
+	}
+	return out, nil
+}
+
+// keyString renders a map key as an AMF3 object key.
+func keyString(k reflect.Value) string {
+	if k.Kind() == reflect.String {
+		return k.String()
+	}
+	return fmt.Sprint(k.Interface())
+}
+
+// normalizeStruct returns v's exported fields keyed by their tag names.
+func normalizeStruct(v reflect.Value) (any, error) {
+	out := map[string]any{}
+	if err := addFields(v, out); err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// addFields writes v's fields into out. An untagged embedded struct contributes
+// its own fields to the enclosing object, as it does in JSON, including when its
+// type is unexported: reflect still reads the exported fields inside it.
+func addFields(v reflect.Value, out map[string]any) error {
+	t := v.Type()
+	for i := 0; i < t.NumField(); i++ {
+		f := t.Field(i)
+		name, omitEmpty, ok := fieldKey(f)
+		if !ok {
+			continue
+		}
+		fv := v.Field(i)
+
+		if f.Anonymous && name == "" {
+			embedded := reflect.Indirect(fv)
+			if embedded.Kind() == reflect.Struct && embedded.Type() != timeType {
+				if err := addFields(embedded, out); err != nil {
+					return err
+				}
+				continue
+			}
+		}
+		if !f.IsExported() {
+			continue
+		}
+		if name == "" {
+			name = f.Name
+		}
+		if omitEmpty && isEmpty(fv) {
+			continue
+		}
+
+		val, err := normalize(fv)
+		if err != nil {
+			return fmt.Errorf("%s.%s: %w", t.Name(), f.Name, err)
+		}
+		if val == nil {
+			if omitEmpty {
+				continue
+			}
+			// A field the client dereferences unconditionally, such as a
+			// response's data or an event's eventData, is an empty object
+			// rather than an absent key.
+			val = map[string]any{}
+		}
+		out[name] = val
+	}
+	return nil
+}
+
+// fieldKey returns the AMF3 name for f and whether it is written at all. An
+// amf3 tag replaces the json tag outright, so a field that must always be
+// present in AMF but is omitempty in JSON just names itself in amf3.
+func fieldKey(f reflect.StructField) (name string, omitEmpty, ok bool) {
+	tag, tagged := f.Tag.Lookup("amf3")
+	if !tagged {
+		tag = f.Tag.Get("json")
+	}
+	if tag == "-" {
+		return "", false, false
+	}
+	name, opts, _ := strings.Cut(tag, ",")
+	return name, hasOption(opts, "omitempty"), true
+}
+
+// hasOption reports whether the comma-separated tag options contain want.
+func hasOption(opts, want string) bool {
+	for opts != "" {
+		var opt string
+		opt, opts, _ = strings.Cut(opts, ",")
+		if opt == want {
+			return true
+		}
+	}
+	return false
+}
+
+// isEmpty reports whether v is the zero value that omitempty suppresses.
+func isEmpty(v reflect.Value) bool {
+	switch v.Kind() {
+	case reflect.Array, reflect.Map, reflect.Slice, reflect.String:
+		return v.Len() == 0
+	case reflect.Bool:
+		return !v.Bool()
+	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+		return v.Int() == 0
+	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+		return v.Uint() == 0
+	case reflect.Float32, reflect.Float64:
+		return v.Float() == 0
+	case reflect.Interface, reflect.Pointer:
+		return v.IsNil()
+	case reflect.Struct:
+		return v.Type() == timeType && v.Interface().(time.Time).IsZero()
+	}
+	return false
+}

+ 208 - 0
server/webapi/amf3/marshal_test.go

@@ -0,0 +1,208 @@
+package amf3
+
+import (
+	"testing"
+	"time"
+
+	goAMF3 "github.com/breign/goAMF3"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// marshalMap encodes v and decodes it back as an AMF3 object.
+func marshalMap(t *testing.T, v any) map[string]any {
+	t.Helper()
+	b, err := Marshal(v)
+	require.NoError(t, err)
+	decoded := goAMF3.DecodeAMF3(b)
+	m, ok := decoded.(map[string]any)
+	require.True(t, ok, "decoded as %T, want an object", decoded)
+	return m
+}
+
+type tagged struct {
+	Sender   string `json:"sender" amf3:"source"`
+	AutoResp bool   `json:"autoResponse,omitempty" amf3:"autoresponse"`
+	Message  string `json:"message"`
+	State    string `json:"state,omitempty" amf3:"state"`
+	Secret   string `json:"-"`
+	Hidden   string `amf3:"-" json:"hidden"`
+	MsgID    string `json:"msgId,omitempty"`
+	unseen   string
+}
+
+// An amf3 tag replaces the json tag, which is how the same struct spells a field
+// one way for the Web AIM client and another for the documented JSON API.
+func TestMarshalTagOverridesJSON(t *testing.T) {
+	m := marshalMap(t, tagged{Sender: "chattingchuck", Message: "hi", Secret: "s", Hidden: "h"})
+
+	assert.Equal(t, "chattingchuck", m["source"])
+	assert.NotContains(t, m, "sender")
+	assert.Equal(t, "hi", m["message"])
+
+	// An amf3 tag carrying no omitempty keeps a field the JSON encoding drops.
+	assert.Contains(t, m, "autoresponse")
+	assert.Equal(t, false, m["autoresponse"])
+	assert.Contains(t, m, "state")
+	assert.Equal(t, "", m["state"])
+
+	// "-" suppresses the field whichever tag spells it, and unexported fields
+	// never appear.
+	assert.NotContains(t, m, "secret")
+	assert.NotContains(t, m, "hidden")
+	assert.NotContains(t, m, "unseen")
+
+	// A json omitempty still applies when no amf3 tag overrides it.
+	assert.NotContains(t, m, "msgId")
+}
+
+// AMF3 stores whole numbers in 29 bits, so anything wider has to arrive as a
+// double or it is silently truncated on the wire.
+func TestMarshalIntegerRange(t *testing.T) {
+	tests := []struct {
+		name  string
+		value any
+		want  any
+	}{
+		{"zero", 0, int32(0)},
+		{"negative", -5, int32(-5)},
+		{"max int29", maxInt29, int32(maxInt29)},
+		{"min int29", minInt29, int32(minInt29)},
+		{"one past max int29", int64(maxInt29 + 1), float64(maxInt29 + 1)},
+		{"one past min int29", int64(minInt29 - 1), float64(minInt29 - 1)},
+		{"unix timestamp", int64(1700000000), float64(1700000000)},
+		{"uint64 in range", uint64(42), int32(42)},
+		{"uint64 out of range", uint64(1) << 40, float64(uint64(1) << 40)},
+		{"uint64 max", uint64(1<<64 - 1), float64(uint64(1<<64 - 1))},
+		{"uint32 out of range", uint32(4000000000), float64(4000000000)},
+		{"float stays a double", 1.5, 1.5},
+		{"seqNum", uint64(7), int32(7)},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			m := marshalMap(t, map[string]any{"n": tt.value})
+			assert.Equal(t, tt.want, m["n"])
+		})
+	}
+}
+
+type embeddedBase struct {
+	AimID string `json:"aimId"`
+}
+
+type withEmbedded struct {
+	embeddedBase
+	State string `json:"state"`
+}
+
+type nested struct {
+	Source   embeddedBase   `json:"source"`
+	Ptr      *embeddedBase  `json:"ptr,omitempty"`
+	Absent   *embeddedBase  `json:"absent,omitempty"`
+	Required *embeddedBase  `json:"required"`
+	Boxed    any            `json:"boxed"`
+	List     []embeddedBase `json:"list"`
+	NilList  []string       `json:"capabilities"`
+	Counts   map[string]int `json:"counts"`
+	Any      any            `json:"any"`
+	When     time.Time      `json:"when"`
+}
+
+func TestMarshalNestedValues(t *testing.T) {
+	when := time.Unix(1700000000, 0).UTC()
+	m := marshalMap(t, nested{
+		Source: embeddedBase{AimID: "chuck"},
+		Ptr:    &embeddedBase{AimID: "fred"},
+		List:   []embeddedBase{{AimID: "one"}},
+		Counts: map[string]int{"unread": 3},
+		Any:    embeddedBase{AimID: "boxed"},
+		When:   when,
+	})
+
+	assert.Equal(t, map[string]any{"aimId": "chuck"}, m["source"])
+	assert.Equal(t, map[string]any{"aimId": "fred"}, m["ptr"])
+	assert.Equal(t, []any{map[string]any{"aimId": "one"}}, m["list"])
+	// A map of a concrete value type reaches the wire; the writer takes only
+	// map[string]any on its own.
+	assert.Equal(t, map[string]any{"unread": int32(3)}, m["counts"])
+	assert.Equal(t, map[string]any{"aimId": "boxed"}, m["any"])
+
+	// An AMF3 date is a bare UTC epoch, so the instant survives but the zone
+	// does not.
+	decodedWhen, ok := m["when"].(time.Time)
+	require.True(t, ok, "when decoded as %T", m["when"])
+	assert.True(t, when.Equal(decodedWhen), "got %s, want %s", decodedWhen, when)
+
+	// A nil list is sent as an empty one because the client iterates lists such
+	// as capabilities unconditionally.
+	assert.Equal(t, []any{}, m["capabilities"])
+
+	// omitempty is what drops a nil, so the client's merge leaves whatever it
+	// already holds alone.
+	assert.NotContains(t, m, "absent")
+
+	// Without omitempty the field is one the client dereferences on sight, so
+	// it arrives as an empty object rather than an absent key.
+	assert.Equal(t, map[string]any{}, m["required"])
+	assert.Equal(t, map[string]any{}, m["boxed"])
+}
+
+// An untagged embedded struct contributes its fields to the enclosing object.
+func TestMarshalPromotesEmbeddedFields(t *testing.T) {
+	m := marshalMap(t, withEmbedded{embeddedBase: embeddedBase{AimID: "chuck"}, State: "online"})
+
+	assert.Equal(t, map[string]any{"aimId": "chuck", "state": "online"}, m)
+}
+
+type eventType string
+
+// A named string encodes as its underlying string, which is what carries an
+// event's type constant.
+func TestMarshalNamedString(t *testing.T) {
+	m := marshalMap(t, map[string]any{"type": eventType("presence")})
+
+	assert.Equal(t, "presence", m["type"])
+}
+
+// The AMF3 writer emits nothing for a value it cannot handle, truncating the
+// enclosing object mid-key, so an unsupported type has to be an error instead.
+func TestMarshalRejectsUnsupportedTypes(t *testing.T) {
+	tests := []struct {
+		name  string
+		value any
+	}{
+		{"channel", make(chan int)},
+		{"func", func() {}},
+		{"complex", complex(1, 2)},
+		{"struct carrying a channel", struct {
+			Ch chan int `json:"ch"`
+		}{Ch: make(chan int)}},
+		{"slice of channels", []chan int{make(chan int)}},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			_, err := Marshal(tt.value)
+			assert.Error(t, err)
+		})
+	}
+}
+
+// Every response is an object, so a nil payload is an empty one rather than a
+// null the client would dereference.
+func TestMarshalNilIsAnEmptyObject(t *testing.T) {
+	assert.Equal(t, map[string]any{}, marshalMap(t, nil))
+	assert.Equal(t, map[string]any{}, marshalMap(t, (*nested)(nil)))
+	assert.Equal(t, map[string]any{}, marshalMap(t, struct{}{}))
+}
+
+// Byte slices are the one slice written as an AMF3 byte array.
+func TestMarshalByteSlice(t *testing.T) {
+	b, err := Marshal(map[string]any{"raw": []byte{1, 2, 3}})
+	require.NoError(t, err)
+
+	m, ok := goAMF3.DecodeAMF3(b).(map[string]any)
+	require.True(t, ok)
+	assert.Equal(t, []byte{1, 2, 3}, m["raw"])
+}

+ 280 - 0
server/webapi/amf_shape_test.go

@@ -0,0 +1,280 @@
+package webapi
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	goAMF3 "github.com/breign/goAMF3"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// decodeAMF decodes an AMF3 body as the object every Web API reply is.
+func decodeAMF(t *testing.T, body []byte) map[string]any {
+	t.Helper()
+	decoded := goAMF3.DecodeAMF3(body)
+	m, ok := decoded.(map[string]any)
+	require.True(t, ok, "decoded as %T, want an object", decoded)
+	return m
+}
+
+// renderAMF sends payload through the f=amf3 path and returns the response body.
+func renderAMF(t *testing.T, data any) map[string]any {
+	t.Helper()
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "Ok"
+	resp.Response.Data = data
+
+	rr := httptest.NewRecorder()
+	SendResponse(rr, httptest.NewRequest(http.MethodGet, "/x?f=amf3&r=123", nil), resp, nil)
+	require.Contains(t, rr.Header().Get("Content-Type"), "amf")
+
+	envelope := decodeAMF(t, rr.Body.Bytes())
+	body, ok := envelope["response"].(map[string]any)
+	require.True(t, ok, "response = %#v, want an object", envelope["response"])
+	return body
+}
+
+// object asserts that key holds a nested AMF3 object and returns it.
+func object(t *testing.T, m map[string]any, key string) map[string]any {
+	t.Helper()
+	v, ok := m[key].(map[string]any)
+	require.True(t, ok, "%s = %#v, want an object", key, m[key])
+	return v
+}
+
+// firstEvent returns the single event a fetchEvents payload carries.
+func firstEvent(t *testing.T, event Event) (map[string]any, map[string]any) {
+	t.Helper()
+	body := renderAMF(t, &FetchEventsData{Events: []Event{event}, LastSeqNum: event.SeqNum})
+
+	events, ok := object(t, body, "data")["events"].([]any)
+	require.True(t, ok, "events is not an array")
+	require.Len(t, events, 1)
+
+	wrapper, ok := events[0].(map[string]any)
+	require.True(t, ok, "events[0] = %#v, want an object", events[0])
+	return wrapper, object(t, wrapper, "eventData")
+}
+
+// The envelope nests the body under "response", where XML flattens it to the
+// document root.
+func TestAMFEnvelopeMatchesSpec(t *testing.T) {
+	body := renderAMF(t, struct {
+		AimSID string `json:"aimsid"`
+	}{AimSID: "opaquedata"})
+
+	assert.Equal(t, int32(200), body["statusCode"])
+	assert.Equal(t, "Ok", body["statusText"])
+	assert.Equal(t, "123", body["requestId"])
+	assert.Equal(t, map[string]any{"aimsid": "opaquedata"}, object(t, body, "data"))
+}
+
+// Every method sends a data object even when it carries no payload; the client
+// dereferences response.data regardless of outcome.
+func TestAMFAlwaysRendersData(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=amf3", nil), resp, nil)
+
+	body := object(t, decodeAMF(t, rr.Body.Bytes()), "response")
+	assert.Equal(t, map[string]any{}, object(t, body, "data"))
+}
+
+// A failure carries the same envelope, including the data object a client
+// callback reaches for on any outcome.
+func TestAMFErrorEnvelope(t *testing.T) {
+	rr := httptest.NewRecorder()
+	SendErrorDetail(rr, httptest.NewRequest(http.MethodGet, "/x?f=amf3&r=123", nil),
+		http.StatusUnauthorized, statusMoreAuthRequired, detailBadPassword, "invalid password")
+
+	body := object(t, decodeAMF(t, rr.Body.Bytes()), "response")
+	assert.Equal(t, int32(statusMoreAuthRequired), body["statusCode"])
+	assert.Equal(t, int32(detailBadPassword), body["statusDetailCode"])
+	assert.Equal(t, "invalid password", body["statusText"])
+	assert.Equal(t, "123", body["requestId"])
+	assert.Equal(t, map[string]any{}, object(t, body, "data"))
+}
+
+// AMF3 stores whole numbers in 29 bits, which a Unix timestamp overflows, so
+// every time value has to reach the client as a double.
+func TestAMFTimestampsAreDoubles(t *testing.T) {
+	wrapper, eventData := firstEvent(t, Event{
+		Type:      EventTypeIM,
+		SeqNum:    7,
+		Timestamp: 1700000000,
+		Data: IMEvent{
+			Source:    UserInfo{AimID: "chattingchuck", OnlineTime: 1699999000},
+			Message:   "hi",
+			Timestamp: 1700000000,
+		},
+	})
+
+	assert.Equal(t, float64(1700000000), wrapper["timestamp"])
+	assert.Equal(t, float64(1700000000), eventData["timestamp"])
+	assert.Equal(t, float64(1699999000), object(t, eventData, "source")["onlineTime"])
+	// A sequence number is small enough to stay a compact AMF3 integer.
+	assert.Equal(t, int32(7), wrapper["seqNum"])
+}
+
+// myInfo carries the timestamps most likely to overflow, and reaches the encoder
+// as a struct rather than a map.
+func TestAMFMyInfoTimestampsAreDoubles(t *testing.T) {
+	mi := buildMyInfo("ChattingChuck", "online", "")
+	mi.OnlineTime = 1700000000
+	mi.MemberSince = 1500000000
+	mi.Self = &MyInfoSelf{InstNum: 1, LoginTime: 1700000001, Events: []string{}, AssertCaps: []string{}}
+
+	body := renderAMF(t, mi)
+	data := object(t, body, "data")
+
+	assert.Equal(t, float64(1700000000), data["onlineTime"])
+	assert.Equal(t, float64(1500000000), data["memberSince"])
+	assert.Equal(t, float64(1700000001), object(t, data, "self")["loginTime"])
+	// A list the client iterates unconditionally is sent even when empty.
+	assert.Equal(t, []any{}, data["capabilities"])
+}
+
+// The client reads the sender of a sentIM from "source" and the flag from
+// "autoresponse", which is not how the JSON spec names either one.
+func TestAMFSentIMUsesClientFieldNames(t *testing.T) {
+	_, eventData := firstEvent(t, Event{
+		Type:   EventTypeSentIM,
+		SeqNum: 1,
+		Data: SentIMEvent{
+			Sender:  UserInfo{AimID: "chattingchuck", DisplayID: "ChattingChuck", UserType: "aim", State: "online"},
+			Dest:    UserInfo{AimID: "fred", DisplayID: "Fred", Friendly: "Freddy", UserType: "aim", State: "online"},
+			Message: "hi",
+			MsgID:   "beefcafe",
+		},
+	})
+
+	source := object(t, eventData, "source")
+	assert.Equal(t, "chattingchuck", source["aimId"])
+	assert.Equal(t, "online", source["state"])
+	assert.NotContains(t, eventData, "sender")
+
+	dest := object(t, eventData, "dest")
+	assert.Equal(t, "fred", dest["aimId"])
+	// The client's merge deletes any alias it holds before applying a user map,
+	// so an alias has to be repeated on every one.
+	assert.Equal(t, "Freddy", dest["friendly"])
+
+	assert.Equal(t, "beefcafe", eventData["msgId"])
+	// Always sent, false included, and never under the JSON spelling.
+	assert.Equal(t, false, eventData["autoresponse"])
+	assert.NotContains(t, eventData, "autoResponse")
+}
+
+// Presence is the whole user object the client's parseUser reads, not a subset.
+func TestAMFPresenceCarriesEveryField(t *testing.T) {
+	_, eventData := firstEvent(t, Event{
+		Type:   EventTypePresence,
+		SeqNum: 1,
+		Data: PresenceEvent{
+			AimID:      "mikekelly",
+			Friendly:   "Mike",
+			State:      "away",
+			StatusMsg:  "at lunch",
+			AwayMsg:    "back soon",
+			IdleTime:   5,
+			OnlineTime: 1700000000,
+			UserType:   "aim",
+			BuddyIcon:  "http://host/icon",
+		},
+	})
+
+	assert.Equal(t, "mikekelly", eventData["aimId"])
+	assert.Equal(t, "Mike", eventData["friendly"])
+	assert.Equal(t, "away", eventData["state"])
+	assert.Equal(t, "at lunch", eventData["statusMsg"])
+	assert.Equal(t, "back soon", eventData["awayMsg"])
+	assert.Equal(t, int32(5), eventData["idleTime"])
+	assert.Equal(t, float64(1700000000), eventData["onlineTime"])
+	assert.Equal(t, "aim", eventData["userType"])
+	assert.Equal(t, "http://host/icon", eventData["buddyIcon"])
+}
+
+// An empty buddyIcon is left out so the client's merge keeps the icon it holds;
+// the placeholder URL, not "", is what clears one.
+func TestAMFPresenceOmitsEmptyBuddyIcon(t *testing.T) {
+	_, eventData := firstEvent(t, Event{
+		Type:   EventTypePresence,
+		SeqNum: 1,
+		Data:   PresenceEvent{AimID: "mikekelly", State: "offline", UserType: "aim"},
+	})
+
+	assert.NotContains(t, eventData, "buddyIcon")
+}
+
+// An offline sender is identified by aimId and friendly alone, and the client
+// keys its conversation list by msgId.
+func TestAMFOfflineIM(t *testing.T) {
+	_, eventData := firstEvent(t, Event{
+		Type:   EventTypeOfflineIM,
+		SeqNum: 1,
+		Data: OfflineIMEvent{
+			AimID:     "mikekelly",
+			Friendly:  "Mike Kelly",
+			Message:   "sent while you were out",
+			MsgID:     "beefcafe",
+			Timestamp: 1700000000,
+		},
+	})
+
+	assert.Equal(t, "mikekelly", eventData["aimId"])
+	assert.Equal(t, "Mike Kelly", eventData["friendly"])
+	assert.Equal(t, "sent while you were out", eventData["message"])
+	assert.Equal(t, "beefcafe", eventData["msgId"])
+	assert.Equal(t, float64(1700000000), eventData["timestamp"])
+	assert.Equal(t, false, eventData["autoresponse"])
+}
+
+// A payload of nested structs and lists survives whole; the AMF3 writer emits
+// nothing for a value it cannot take, truncating the object mid-key.
+func TestAMFNestedPayloadSurvives(t *testing.T) {
+	_, eventData := firstEvent(t, Event{
+		Type:   EventTypeBuddyList,
+		SeqNum: 1,
+		Data: &BuddyListData{Groups: []BuddyGroup{{
+			Name: "Friends",
+			Buddies: []BuddyInfo{{
+				AimID:        "chattingchuck",
+				DisplayID:    "ChattingChuck",
+				Capabilities: []string{"200A0000A28911D3A52D001083341CFA"},
+			}},
+		}}},
+	})
+
+	groups, ok := eventData["groups"].([]any)
+	require.True(t, ok, "groups = %#v, want an array", eventData["groups"])
+	require.Len(t, groups, 1)
+
+	group, ok := groups[0].(map[string]any)
+	require.True(t, ok)
+	assert.Equal(t, "Friends", group["name"])
+
+	buddies, ok := group["buddies"].([]any)
+	require.True(t, ok)
+	require.Len(t, buddies, 1)
+
+	buddy, ok := buddies[0].(map[string]any)
+	require.True(t, ok)
+	assert.Equal(t, "chattingchuck", buddy["aimId"])
+	assert.Equal(t, []any{"200A0000A28911D3A52D001083341CFA"}, buddy["capabilities"])
+}
+
+// A typed nil payload is an empty object rather than a truncated stream.
+func TestAMFNilEventDataIsAnObject(t *testing.T) {
+	wrapper, _ := firstEvent(t, Event{
+		Type:   EventTypeBuddyList,
+		SeqNum: 3,
+		Data:   (*BuddyListData)(nil),
+	})
+
+	assert.Equal(t, int32(3), wrapper["seqNum"])
+}

+ 0 - 529
server/webapi/amf_test.go

@@ -1,529 +0,0 @@
-package webapi
-
-import (
-	"fmt"
-	"net/http"
-	"net/http/httptest"
-	"testing"
-	"time"
-
-	goAMF3 "github.com/breign/goAMF3"
-	"github.com/stretchr/testify/assert"
-)
-
-func TestAMFEncoderBasicTypes(t *testing.T) {
-	encoder := NewAMFEncoder(nil)
-
-	tests := []struct {
-		name    string
-		input   interface{}
-		wantErr bool
-	}{
-		{"String AMF3", "hello world", false},
-		{"Number AMF3", 42, false},
-		{"Float AMF3", 3.14159, false},
-		{"Boolean AMF3", false, false},
-		{"Null AMF3", nil, false},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			data, err := encoder.EncodeAMF(tt.input)
-			if (err != nil) != tt.wantErr {
-				t.Fatalf("EncodeAMF() error = %v, wantErr %v", err, tt.wantErr)
-			}
-
-			if !tt.wantErr && len(data) == 0 {
-				t.Fatal("EncodeAMF() returned empty data")
-			}
-
-			// Try to decode the data to verify it's valid AMF3
-			if !tt.wantErr {
-				decoded := goAMF3.DecodeAMF3(data)
-				if decoded == nil {
-					t.Fatalf("Failed to decode AMF3 data: got nil result")
-				}
-			}
-		})
-	}
-}
-
-func TestAMFEncoderComplexTypes(t *testing.T) {
-	encoder := NewAMFEncoder(nil)
-
-	tests := []struct {
-		name  string
-		input interface{}
-	}{
-		{
-			name: "Map",
-			input: map[string]interface{}{
-				"name":   "John Doe",
-				"age":    30,
-				"active": true,
-			},
-		},
-		{
-			name: "Array",
-			input: []interface{}{
-				"item1",
-				42,
-				true,
-				nil,
-			},
-		},
-		{
-			name: "BaseResponse",
-			input: BaseResponse{
-				Response: ResponseBody{
-					StatusCode: 200,
-					StatusText: "OK",
-					Data: map[string]interface{}{
-						"user":   "testuser",
-						"online": true,
-						"buddies": []interface{}{
-							"friend1",
-							"friend2",
-						},
-					},
-				},
-			},
-		},
-		{
-			name:  "ErrorResponse",
-			input: newErrorResponse(404, "Not Found"),
-		},
-		{
-			name: "Time",
-			input: map[string]interface{}{
-				"timestamp": time.Now(),
-				"name":      "Event",
-			},
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			data, err := encoder.EncodeAMF(tt.input)
-			if err != nil {
-				t.Fatalf("EncodeAMF() error = %v", err)
-			}
-
-			if len(data) == 0 {
-				t.Fatal("EncodeAMF() returned empty data")
-			}
-
-			// Verify the data is valid AMF
-			decoded := goAMF3.DecodeAMF3(data)
-
-			if decoded == nil {
-				t.Fatalf("Failed to decode AMF data: got nil result")
-			}
-
-			// Log the size for performance comparison
-			t.Logf("%s: %d bytes", tt.name, len(data))
-		})
-	}
-}
-
-func TestSendAMF(t *testing.T) {
-	tests := []struct {
-		name         string
-		request      *http.Request
-		data         interface{}
-		expectStatus int
-	}{
-		{
-			name:    "Simple response",
-			request: httptest.NewRequest("GET", "/?f=amf", nil),
-			data: BaseResponse{
-				Response: ResponseBody{
-					StatusCode: 200,
-					StatusText: "OK",
-					Data:       map[string]interface{}{"test": "value"},
-				},
-			},
-			expectStatus: http.StatusOK,
-		},
-		{
-			name:    "AMF3 response with array",
-			request: httptest.NewRequest("GET", "/?f=amf3", nil),
-			data: BaseResponse{
-				Response: ResponseBody{
-					StatusCode: 200,
-					StatusText: "OK",
-					Data:       []interface{}{"item1", "item2"},
-				},
-			},
-			expectStatus: http.StatusOK,
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			// First test if the encoder can handle the data
-			encoder := NewAMFEncoder(nil)
-			_, encodeErr := encoder.EncodeAMF(tt.data)
-			if encodeErr != nil {
-				t.Fatalf("Encoding failed: %v", encodeErr)
-			}
-
-			w := httptest.NewRecorder()
-			sendAMF(w, tt.request, tt.data, nil)
-
-			resp := w.Result()
-			if resp.StatusCode != tt.expectStatus {
-				t.Errorf("Expected status %d, got %d", tt.expectStatus, resp.StatusCode)
-				// Print response body for debugging
-				body := w.Body.String()
-				t.Logf("Response body: %s", body)
-			}
-
-			contentType := resp.Header.Get("Content-Type")
-			if contentType != "application/x-amf" {
-				t.Errorf("Expected Content-Type application/x-amf, got %s", contentType)
-			}
-
-			body := w.Body.Bytes()
-			if len(body) == 0 {
-				t.Error("Response body is empty")
-			}
-		})
-	}
-}
-
-func TestStructToMap(t *testing.T) {
-	encoder := NewAMFEncoder(nil)
-
-	type TestStruct struct {
-		Name     string `json:"name"`
-		Age      int    `json:"age"`
-		Active   bool   `json:"active"`
-		Hidden   string `json:"-"`
-		Optional string `json:"optional,omitempty"`
-		NoTag    string
-	}
-
-	testStruct := TestStruct{
-		Name:     "John",
-		Age:      30,
-		Active:   true,
-		Hidden:   "should not appear",
-		Optional: "", // should be omitted
-		NoTag:    "should appear with field name",
-	}
-
-	result := encoder.toAMF3Compatible(testStruct)
-	resultMap, ok := result.(map[string]interface{})
-	if !ok {
-		t.Fatal("Expected map[string]interface{}")
-	}
-
-	// Check expected fields
-	if resultMap["name"] != "John" {
-		t.Errorf("Expected name=John, got %v", resultMap["name"])
-	}
-	if resultMap["age"] != 30 {
-		t.Errorf("Expected age=30, got %v", resultMap["age"])
-	}
-	if resultMap["active"] != true {
-		t.Errorf("Expected active=true, got %v", resultMap["active"])
-	}
-	if resultMap["NoTag"] != "should appear with field name" {
-		t.Errorf("Expected NoTag field, got %v", resultMap["NoTag"])
-	}
-
-	// Check omitted fields
-	if _, exists := resultMap["Hidden"]; exists {
-		t.Error("Hidden field should not appear")
-	}
-	if _, exists := resultMap["optional"]; exists {
-		t.Error("Optional empty field should be omitted")
-	}
-}
-
-func TestSliceToArray(t *testing.T) {
-	encoder := NewAMFEncoder(nil)
-
-	input := []interface{}{
-		"string",
-		42,
-		true,
-		nil,
-		map[string]interface{}{"nested": "value"},
-	}
-
-	result := encoder.toAMF3Compatible(input)
-	resultArray, ok := result.([]interface{})
-	if !ok {
-		t.Fatal("Expected []interface{}")
-	}
-
-	if len(resultArray) != 5 {
-		t.Errorf("Expected 5 elements, got %d", len(resultArray))
-	}
-
-	if resultArray[0] != "string" {
-		t.Errorf("Expected first element to be 'string', got %v", resultArray[0])
-	}
-	if resultArray[1] != 42 {
-		t.Errorf("Expected second element to be 42, got %v", resultArray[1])
-	}
-	if resultArray[2] != true {
-		t.Errorf("Expected third element to be true, got %v", resultArray[2])
-	}
-	// For AMF3, nil values are converted to empty maps for compatibility
-	if resultArray[3] != nil {
-		emptyMap, ok := resultArray[3].(map[string]interface{})
-		if !ok || len(emptyMap) != 0 {
-			t.Errorf("Expected fourth element to be empty map, got %v", resultArray[3])
-		}
-	}
-
-	nested, ok := resultArray[4].(map[string]interface{})
-	if !ok {
-		t.Error("Expected fifth element to be map")
-	} else if nested["nested"] != "value" {
-		t.Errorf("Expected nested value, got %v", nested["nested"])
-	}
-}
-
-// Benchmark tests
-func BenchmarkAMFEncoding(b *testing.B) {
-	encoder := NewAMFEncoder(nil)
-	data := BaseResponse{
-		Response: ResponseBody{
-			StatusCode: 200,
-			StatusText: "OK",
-			Data: map[string]interface{}{
-				"users": []interface{}{
-					map[string]interface{}{"name": "user1", "online": true},
-					map[string]interface{}{"name": "user2", "online": false},
-					map[string]interface{}{"name": "user3", "online": true},
-				},
-				"timestamp": time.Now().Unix(),
-				"server":    "open-oscar-server",
-			},
-		},
-	}
-
-	b.Run("AMF3", func(b *testing.B) {
-		for i := 0; i < b.N; i++ {
-			_, _ = encoder.EncodeAMF(data)
-		}
-	})
-}
-
-func TestZeroValueDetection(t *testing.T) {
-	encoder := NewAMFEncoder(nil)
-
-	type TestStruct struct {
-		EmptyString string    `json:"emptyString,omitempty"`
-		ZeroInt     int       `json:"zeroInt,omitempty"`
-		FalseValue  bool      `json:"falseValue,omitempty"`
-		ZeroTime    time.Time `json:"zeroTime,omitempty"`
-		ValidString string    `json:"validString,omitempty"`
-		ValidInt    int       `json:"validInt,omitempty"`
-		TrueValue   bool      `json:"trueValue,omitempty"`
-	}
-
-	testStruct := TestStruct{
-		EmptyString: "",
-		ZeroInt:     0,
-		FalseValue:  false,
-		ZeroTime:    time.Time{},
-		ValidString: "test",
-		ValidInt:    42,
-		TrueValue:   true,
-	}
-
-	result := encoder.toAMF3Compatible(testStruct)
-	resultMap, ok := result.(map[string]interface{})
-	if !ok {
-		t.Fatal("Expected map[string]interface{}")
-	}
-
-	// Should be omitted (zero values)
-	omittedFields := []string{"emptyString", "zeroInt", "falseValue", "zeroTime"}
-	for _, field := range omittedFields {
-		if _, exists := resultMap[field]; exists {
-			t.Errorf("Field %s should be omitted (zero value)", field)
-		}
-	}
-
-	// Should be present (non-zero values)
-	presentFields := map[string]interface{}{
-		"validString": "test",
-		"validInt":    42,
-		"trueValue":   true,
-	}
-	for field, expected := range presentFields {
-		if actual, exists := resultMap[field]; !exists {
-			t.Errorf("Field %s should be present", field)
-		} else if actual != expected {
-			t.Errorf("Field %s: expected %v, got %v", field, expected, actual)
-		}
-	}
-}
-
-// 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)
-	}
-}
-
-// The buddylist and preference events carry a pointer payload. goAMF3 emits
-// nothing for a value it cannot encode, so a pointer that reaches it writes the
-// key and truncates the stream there, taking every later field with it.
-func TestAMFEncoderPointerEventData(t *testing.T) {
-	encoder := NewAMFEncoder(nil)
-
-	event := ConvertEventForAMF3(Event{
-		Type:      EventTypeBuddyList,
-		SeqNum:    1,
-		Timestamp: 1787277769,
-		Data: &BuddyListData{
-			Groups: []BuddyGroup{{
-				Name:    "Friends",
-				Buddies: []BuddyInfo{{AimID: "mk6i"}},
-			}},
-		},
-	})
-
-	encoded, err := encoder.EncodeAMF(map[string]interface{}{
-		"events":     []interface{}{event},
-		"lastSeqNum": 1,
-	})
-	if err != nil {
-		t.Fatalf("EncodeAMF() error = %v", err)
-	}
-
-	decoded, ok := goAMF3.DecodeAMF3(encoded).(map[string]interface{})
-	if !ok {
-		t.Fatalf("DecodeAMF3() = %#v, want map", goAMF3.DecodeAMF3(encoded))
-	}
-
-	// Present only if the stream survived past the event: it is written after it.
-	if got := fmt.Sprintf("%v", decoded["lastSeqNum"]); got != "1" {
-		t.Errorf("lastSeqNum = %v, want 1", got)
-	}
-
-	events, ok := decoded["events"].([]interface{})
-	if !ok || len(events) != 1 {
-		t.Fatalf("events = %#v, want 1 element", decoded["events"])
-	}
-	eventMap, ok := events[0].(map[string]interface{})
-	if !ok {
-		t.Fatalf("events[0] = %#v, want map", events[0])
-	}
-	eventData, ok := eventMap["eventData"].(map[string]interface{})
-	if !ok {
-		t.Fatalf("eventData = %#v, want map", eventMap["eventData"])
-	}
-	groups, ok := eventData["groups"].([]interface{})
-	if !ok || len(groups) != 1 {
-		t.Fatalf("groups = %#v, want 1 element", eventData["groups"])
-	}
-	group, ok := groups[0].(map[string]interface{})
-	if !ok {
-		t.Fatalf("groups[0] = %#v, want map", groups[0])
-	}
-	if got := group["name"]; got != "Friends" {
-		t.Errorf("groups[0].name = %#v, want Friends", got)
-	}
-}
-
-func TestAMFEncoderNilPointerEventData(t *testing.T) {
-	encoder := NewAMFEncoder(nil)
-
-	encoded, err := encoder.EncodeAMF(map[string]interface{}{
-		"eventData":  (*BuddyListData)(nil),
-		"lastSeqNum": 2,
-	})
-	if err != nil {
-		t.Fatalf("EncodeAMF() error = %v", err)
-	}
-
-	decoded, ok := goAMF3.DecodeAMF3(encoded).(map[string]interface{})
-	if !ok {
-		t.Fatalf("DecodeAMF3() = %#v, want map", goAMF3.DecodeAMF3(encoded))
-	}
-	if got := fmt.Sprintf("%v", decoded["lastSeqNum"]); got != "2" {
-		t.Errorf("lastSeqNum = %v, want 2", got)
-	}
-}
-
-// The AMF3 converter re-flattens PresenceEvent through an explicit allowlist, so a
-// field absent from that allowlist never reaches an AMF3 client. buddyIcon must be
-// on it.
-func TestConvertEventForAMF3_PresenceCarriesBuddyIcon(t *testing.T) {
-	t.Run("buddyIcon is included when set", func(t *testing.T) {
-		out := ConvertEventForAMF3(Event{
-			Type: EventTypePresence,
-			Data: PresenceEvent{
-				AimID:     "mikekelly",
-				State:     "online",
-				UserType:  "aim",
-				BuddyIcon: "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=dead",
-			},
-		})
-
-		eventData := out["eventData"].(map[string]interface{})
-		assert.Equal(t,
-			"http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=dead",
-			eventData["buddyIcon"])
-	})
-
-	t.Run("buddyIcon is omitted when empty", func(t *testing.T) {
-		out := ConvertEventForAMF3(Event{
-			Type: EventTypePresence,
-			Data: PresenceEvent{AimID: "mikekelly", State: "offline", UserType: "aim"},
-		})
-
-		eventData := out["eventData"].(map[string]interface{})
-		_, ok := eventData["buddyIcon"]
-		assert.False(t, ok)
-	})
-}
-
-// The AMF3 converter flattens OfflineIMEvent through an explicit allowlist. The
-// client keys its conversation list and chat-log cache by msgId, so an event that
-// loses it collides with every other offline message.
-func TestConvertEventForAMF3_OfflineIM(t *testing.T) {
-	out := ConvertEventForAMF3(Event{
-		Type: EventTypeOfflineIM,
-		Data: OfflineIMEvent{
-			AimID:     "mikekelly",
-			Message:   "sent while you were out",
-			MsgID:     "beefcafe",
-			Timestamp: 1700000000,
-		},
-	})
-
-	eventData := out["eventData"].(map[string]interface{})
-	assert.Equal(t, "mikekelly", eventData["aimId"])
-	assert.Equal(t, "sent while you were out", eventData["message"])
-	assert.Equal(t, "beefcafe", eventData["msgId"])
-	assert.Equal(t, float64(1700000000), eventData["timestamp"])
-	assert.Equal(t, false, eventData["autoresponse"])
-}

+ 32 - 26
server/webapi/events.go

@@ -54,8 +54,10 @@ type IMEvent struct {
 	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"`
+	Timestamp int64    `json:"timestamp" xml:"timestamp"`
+	// AutoResp is always sent to AMF clients, false included, because the client
+	// reads it unconditionally when it builds the message.
+	AutoResp bool `json:"autoresponse,omitempty" xml:"autoresponse,omitempty" amf3:"autoresponse"`
 }
 
 // OfflineIMEvent represents a message that was stored while the user was signed
@@ -66,21 +68,28 @@ 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" 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"`
+	AimID string `json:"aimId" xml:"aimId"`
+	// Friendly is the sender's display name. The client resolves an offline
+	// sender from this pair alone, so without it the message renders under the
+	// normalized aimId.
+	Friendly  string `json:"friendly,omitempty" xml:"friendly,omitempty"`
+	Message   string `json:"message" xml:"message"`
+	MsgID     string `json:"msgId,omitempty" xml:"msgId,omitempty"`
+	Timestamp int64  `json:"timestamp" xml:"timestamp"`
+	AutoResp  bool   `json:"autoresponse,omitempty" xml:"autoresponse,omitempty" amf3:"autoresponse"`
 }
 
 // SentIMEvent represents a sent instant message event.
+// The AMF3 spellings differ from the documented JSON ones: the client reads the
+// sender from "source" and the flag from "autoresponse", while the spec names
+// them "sender" and "autoResponse".
 type SentIMEvent struct {
-	Sender    UserInfo `json:"sender" xml:"sender"` // Sender user info
-	Dest      UserInfo `json:"dest" xml:"dest"`     // Destination user info
+	Sender    UserInfo `json:"sender" xml:"sender" amf3:"source"`
+	Dest      UserInfo `json:"dest" xml:"dest"`
 	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"`
+	Timestamp int64    `json:"timestamp" xml:"timestamp"`
+	AutoResp  bool     `json:"autoResponse,omitempty" xml:"autoResponse,omitempty" amf3:"autoresponse"`
 }
 
 // UserInfo represents basic user information in events.
@@ -92,12 +101,12 @@ 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" 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
+	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 int64  `json:"onlineTime,omitempty" xml:"onlineTime,omitempty"`
 }
 
 // TypingEvent represents a typing notification event.
@@ -290,15 +299,12 @@ type ConversationEntryData struct {
 }
 
 // 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"`
+	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 int64  `json:"timestamp" xml:"timestamp"`
 }
 
 // ConversationEventData builds a conversation fetchEvents payload.
@@ -326,7 +332,7 @@ func ConversationEntry(aimID, displayID, message, msgID, sender string, sent boo
 			MsgID:     msgID,
 			Sender:    sender,
 			Sent:      sent,
-			Timestamp: float64(time.Now().Unix()),
+			Timestamp: time.Now().Unix(),
 		}
 	}
 	return entry

+ 5 - 4
server/webapi/im_handler.go

@@ -78,8 +78,7 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request, sess *
 		binary.BigEndian.Uint16([]byte{0x80, 0x00}), // Version bits
 		time.Now().UnixNano()&0xffffffffffff)
 
-	now := float64(time.Now().Unix())
-	nowSec := time.Now().Unix()
+	now := time.Now().Unix()
 	// The client sends t as the normalized aimId it keys the conversation by, so
 	// it is never a source of display names.
 	recipientIdent := state.NewIdentScreenName(recipient)
@@ -152,7 +151,7 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request, sess *
 		}
 	}
 
-	sess.AddStoredIM(recipientIdent.String(), sess.ScreenName.IdentScreenName().String(), message, messageID, nowSec)
+	sess.AddStoredIM(recipientIdent.String(), sess.ScreenName.IdentScreenName().String(), message, messageID, now)
 
 	recipientDisplay := h.resolveDisplayName(ctx, sess.OSCARSession, recipientIdent)
 	// The alias lives in the sender's feedbag, so unlike the display name it cannot
@@ -207,7 +206,7 @@ func (h *MessagingHandler) resolveDisplayName(ctx context.Context, instance *sta
 // "Mike Lee" to "mikelee" the moment you message him. Omitting displayId leaves the
 // client's existing name untouched. The merge also deletes any alias it holds, so
 // friendly has to be repeated here even though the buddy list already sent it.
-func (h *MessagingHandler) pushSenderWebAPIEvents(sess *Session, recipient state.IdentScreenName, recipientDisplay, recipientAlias, message, messageID string, now float64, autoResponse bool) {
+func (h *MessagingHandler) pushSenderWebAPIEvents(sess *Session, recipient state.IdentScreenName, recipientDisplay, recipientAlias, message, messageID string, now int64, autoResponse bool) {
 	senderAimID := sess.ScreenName.IdentScreenName().String()
 	recipientAimID := recipient.String()
 
@@ -216,12 +215,14 @@ func (h *MessagingHandler) pushSenderWebAPIEvents(sess *Session, recipient state
 			AimID:     senderAimID,
 			DisplayID: sess.ScreenName.String(),
 			UserType:  "aim",
+			State:     "online",
 		},
 		Dest: UserInfo{
 			AimID:     recipientAimID,
 			DisplayID: recipientDisplay,
 			Friendly:  recipientAlias,
 			UserType:  "aim",
+			State:     "online",
 		},
 		Message:   message,
 		MsgID:     messageID,

+ 1 - 1
server/webapi/preference_handler.go

@@ -302,7 +302,7 @@ func (h *PreferenceHandler) GetPreferences(w http.ResponseWriter, r *http.Reques
 
 	// 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"))
+	format := requestFormat(r)
 	if format == "amf" || format == "amf3" {
 		amfPrefs := prefs.Map()
 		// Ensure prefs is never empty for Gromit.

+ 8 - 10
server/webapi/response.go

@@ -10,6 +10,8 @@ import (
 	"net/http"
 	"strconv"
 	"strings"
+
+	"github.com/mk6i/open-oscar-server/server/webapi/amf3"
 )
 
 // Web API status codes. These are the client's own vocabulary, not HTTP codes,
@@ -253,7 +255,7 @@ func sendErrorEnvelope(w http.ResponseWriter, r *http.Request, httpStatus int, r
 	case format == "xml" || strings.Contains(contentType, "xml"):
 		sendXMLError(w, httpStatus, resp)
 	case format == "amf" || format == "amf3" || strings.Contains(contentType, "amf"):
-		sendAMFError(w, r, httpStatus, resp, nil)
+		sendAMFError(w, httpStatus, resp)
 	default:
 		sendJSONError(w, httpStatus, resp)
 	}
@@ -423,9 +425,7 @@ func isValidCallback(callback string) bool {
 
 // sendAMF sends an AMF response
 func sendAMF(w http.ResponseWriter, r *http.Request, data interface{}, logger *slog.Logger) {
-	encoder := NewAMFEncoder(logger)
-
-	amfData, err := encoder.EncodeAMF(data)
+	amfData, err := amf3.Marshal(data)
 	if err != nil {
 		if logger != nil {
 			logger.Error("failed to encode AMF response",
@@ -465,13 +465,11 @@ func sendAMF(w http.ResponseWriter, r *http.Request, data interface{}, logger *s
 	}
 }
 
-// sendAMFError sends an AMF error response
-func sendAMFError(w http.ResponseWriter, r *http.Request, httpStatus int, resp ErrorResponse, logger *slog.Logger) {
-	encoder := NewAMFEncoder(logger)
-
-	amfData, err := encoder.EncodeAMF(resp)
+// sendAMFError sends an AMF error response, falling back to JSON for a payload
+// AMF3 cannot represent.
+func sendAMFError(w http.ResponseWriter, httpStatus int, resp ErrorResponse) {
+	amfData, err := amf3.Marshal(resp)
 	if err != nil {
-		// If AMF encoding fails, fall back to JSON error
 		sendJSONError(w, httpStatus, resp)
 		return
 	}

+ 15 - 10
server/webapi/session.go

@@ -375,11 +375,19 @@ func (s *Session) handleIncomingIM(msg wire.SNACMessage) {
 	s.AddStoredIM(partnerAimID, partnerAimID, messageText, msgID, timestamp)
 
 	if isOffline {
+		// The client resolves an offline sender from aimId and friendly alone,
+		// so friendly falls back to the sender's own formatting when the viewer
+		// has no alias for them.
+		friendly := s.aliasFor(state.NewIdentScreenName(partnerAimID))
+		if friendly == "" {
+			friendly = partnerDisplay
+		}
 		s.EventQueue.Push(EventTypeOfflineIM, OfflineIMEvent{
 			AimID:     partnerAimID,
+			Friendly:  friendly,
 			Message:   messageText,
 			MsgID:     msgID,
-			Timestamp: float64(timestamp),
+			Timestamp: timestamp,
 			AutoResp:  autoResponse,
 		})
 		s.logger.Debug("delivered offline instant message",
@@ -397,7 +405,7 @@ func (s *Session) handleIncomingIM(msg wire.SNACMessage) {
 			},
 			Message:   messageText,
 			MsgID:     msgID,
-			Timestamp: float64(timestamp),
+			Timestamp: timestamp,
 			AutoResp:  autoResponse,
 		})
 		s.logger.Debug("delivered instant message",
@@ -854,14 +862,11 @@ func (s *Session) AddStoredIM(partnerAimID, sender, message, msgID string, date
 }
 
 // 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"`
+	Sender  string `json:"sender" xml:"sender"`
+	Message string `json:"message" xml:"message"`
+	MsgID   string `json:"msgId" xml:"msgId"`
+	Date    int64  `json:"date" xml:"date"`
 }
 
 // StoredIMQuery describes filters for fetchStoredIMs.
@@ -940,7 +945,7 @@ func (s *Session) GetStoredIMs(q StoredIMQuery) []StoredIM {
 			Sender:  msg.Sender,
 			Message: msg.Message,
 			MsgID:   msg.MsgID,
-			Date:    float64(msg.Date),
+			Date:    msg.Date,
 		}
 	}
 	return out

+ 3 - 3
server/webapi/session_test.go

@@ -1112,7 +1112,7 @@ func TestSession_OfflineIM(t *testing.T) {
 		assert.Equal(t, "mikekelly", offline.AimID)
 		assert.Equal(t, "sent while you were out", offline.Message)
 		assert.NotEmpty(t, offline.MsgID)
-		assert.Equal(t, float64(sentAt), offline.Timestamp)
+		assert.Equal(t, int64(sentAt), offline.Timestamp)
 	})
 
 	t.Run("no send time yields an im event", func(t *testing.T) {
@@ -1142,7 +1142,7 @@ func TestSession_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, int64(sentAt), stored[0].Date)
 	})
 
 	// Only a live IM is filtered on subscription here. Retrieval answers the
@@ -1226,7 +1226,7 @@ func TestSession_GetStoredIMs(t *testing.T) {
 	})
 	assert.Len(t, msgs, 2)
 	assert.Equal(t, "msg-2", msgs[0].MsgID)
-	assert.Equal(t, float64(200), msgs[0].Date)
+	assert.Equal(t, int64(200), msgs[0].Date)
 	assert.Equal(t, "hello", msgs[1].Message)
 
 	msgs = sess.GetStoredIMs(StoredIMQuery{