Selaa lähdekoodia

webapi: report icq user type and service for UIN accounts

Mike 1 päivä sitten
vanhempi
commit
e31ee2a868

+ 10 - 9
server/webapi/aim_handler.go

@@ -55,7 +55,7 @@ type MyInfo struct {
 	State     string `json:"state" xml:"state"`
 	State     string `json:"state" xml:"state"`
 	UserType  string `json:"userType" xml:"userType"` // "aim", "icq"
 	UserType  string `json:"userType" xml:"userType"` // "aim", "icq"
 	Bot       bool   `json:"bot" xml:"bot"`
 	Bot       bool   `json:"bot" xml:"bot"`
-	Service   string `json:"service" xml:"service"` // "AIM", "ICQ" (compared case-sensitively)
+	Service   string `json:"service,omitempty" xml:"service,omitempty"` // Non-native network; omitted for AIM
 	// Capabilities is always sent, empty included, because the client iterates it
 	// Capabilities is always sent, empty included, because the client iterates it
 	// unconditionally.
 	// unconditionally.
 	Capabilities []string `json:"capabilities" xml:"capabilities>capability"`
 	Capabilities []string `json:"capabilities" xml:"capabilities>capability"`
@@ -112,6 +112,7 @@ type StartSessionEvents struct {
 	BuddyList  *BuddyListData  `json:"buddylist,omitempty" xml:"buddylist,omitempty"`
 	BuddyList  *BuddyListData  `json:"buddylist,omitempty" xml:"buddylist,omitempty"`
 	Preference *PreferenceData `json:"preference,omitempty" xml:"preference,omitempty"`
 	Preference *PreferenceData `json:"preference,omitempty" xml:"preference,omitempty"`
 	PermitDeny any             `json:"permitDeny,omitempty" xml:"permitDeny,omitempty"`
 	PermitDeny any             `json:"permitDeny,omitempty" xml:"permitDeny,omitempty"`
+	Service    *ServiceData    `json:"service,omitempty" xml:"service,omitempty"`
 }
 }
 
 
 // BuddyListData is the buddylist event payload and the buddy list half of the
 // BuddyListData is the buddylist event payload and the buddy list half of the
@@ -438,6 +439,12 @@ func (h *AimHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 			ConversationEventData("list", nil))
 			ConversationEventData("list", nil))
 	}
 	}
 
 
+	if slices.Contains(events, string(EventTypeService)) {
+		svcPayload := newServiceData()
+		data.Events.Service = svcPayload
+		session.EventQueue.Push(EventTypeService, svcPayload)
+	}
+
 	// The remaining seeds also populate the response payload, so they stay keyed off
 	// The remaining seeds also populate the response payload, so they stay keyed off
 	// the subscription list they are rendered into.
 	// the subscription list they are rendered into.
 	for _, event := range events {
 	for _, event := range events {
@@ -888,22 +895,16 @@ func seedRateLimitAlert(session *Session, classID wire.RateLimitClassID) {
 // URL (not "") is what clears an icon. moodIcon is a parameter rather than a
 // URL (not "") is what clears an icon. moodIcon is a parameter rather than a
 // field the callers set, because omitting it clears the user's mood.
 // field the callers set, because omitting it clears the user's mood.
 func buildMyInfo(screenName state.DisplayScreenName, webState, buddyIcon, moodIcon string) *MyInfo {
 func buildMyInfo(screenName state.DisplayScreenName, webState, buddyIcon, moodIcon 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"
-	}
 	return &MyInfo{
 	return &MyInfo{
 		AimID:     screenName.IdentScreenName().String(),
 		AimID:     screenName.IdentScreenName().String(),
 		DisplayID: screenName.String(),
 		DisplayID: screenName.String(),
 		Friendly:  screenName.String(),
 		Friendly:  screenName.String(),
 		State:     webState,
 		State:     webState,
-		UserType:  userType,
+		UserType:  userTypeFor(screenName.IdentScreenName()),
+		Service:   serviceFor(screenName.IdentScreenName()),
 		// Never nil: the client iterates capabilities unconditionally.
 		// Never nil: the client iterates capabilities unconditionally.
 		Capabilities: []string{},
 		Capabilities: []string{},
 		Bot:          false,
 		Bot:          false,
-		Service:      service,
 		BuddyIcon:    buddyIcon,
 		BuddyIcon:    buddyIcon,
 		MoodIcon:     moodIcon,
 		MoodIcon:     moodIcon,
 	}
 	}

+ 3 - 5
server/webapi/aim_handler_test.go

@@ -21,22 +21,20 @@ import (
 	"github.com/mk6i/open-oscar-server/wire"
 	"github.com/mk6i/open-oscar-server/wire"
 )
 )
 
 
-func TestBuildMyInfo_UserTypeAndService(t *testing.T) {
+func TestBuildMyInfo_UserType(t *testing.T) {
 	tests := []struct {
 	tests := []struct {
 		name       string
 		name       string
 		screenName string
 		screenName string
 		wantType   string
 		wantType   string
-		wantSvc    string
 	}{
 	}{
-		{"aim screen name", "mikekelly", "aim", "AIM"},
-		{"icq uin", "123456789", "icq", "ICQ"},
+		{"aim screen name", "mikekelly", "aim"},
+		{"icq uin", "123456789", "icq"},
 	}
 	}
 
 
 	for _, tt := range tests {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			mi := buildMyInfo(state.DisplayScreenName(tt.screenName), "online", "", "")
 			mi := buildMyInfo(state.DisplayScreenName(tt.screenName), "online", "", "")
 			assert.Equal(t, tt.wantType, mi.UserType)
 			assert.Equal(t, tt.wantType, mi.UserType)
-			assert.Equal(t, tt.wantSvc, mi.Service)
 		})
 		})
 	}
 	}
 }
 }

+ 4 - 4
server/webapi/buddy_list_manager.go

@@ -56,9 +56,9 @@ type BuddyInfo struct {
 	AwayMsg      string   `json:"awayMsg,omitempty" xml:"awayMsg,omitempty"`
 	AwayMsg      string   `json:"awayMsg,omitempty" xml:"awayMsg,omitempty"`
 	OnlineTime   int64    `json:"onlineTime,omitempty" xml:"onlineTime,omitempty"`
 	OnlineTime   int64    `json:"onlineTime,omitempty" xml:"onlineTime,omitempty"`
 	IdleTime     int      `json:"idleTime,omitempty" xml:"idleTime,omitempty"` // Minutes idle
 	IdleTime     int      `json:"idleTime,omitempty" xml:"idleTime,omitempty"` // Minutes idle
-	UserType     string   `json:"userType" xml:"userType"`                     // "aim", "icq", "admin"
+	UserType     string   `json:"userType" xml:"userType"`                     // "aim", "icq"
 	Bot          bool     `json:"bot" xml:"bot"`
 	Bot          bool     `json:"bot" xml:"bot"`
-	Service      string   `json:"service,omitempty" xml:"service,omitempty"` // "AIM", "ICQ" (Web AIM client compares case-sensitively)
+	Service      string   `json:"service,omitempty" xml:"service,omitempty"` // Non-native network; omitted for AIM
 	PresenceIcon string   `json:"presenceIcon,omitempty" xml:"presenceIcon,omitempty"`
 	PresenceIcon string   `json:"presenceIcon,omitempty" xml:"presenceIcon,omitempty"`
 	BuddyIcon    string   `json:"buddyIcon,omitempty" xml:"buddyIcon,omitempty"`
 	BuddyIcon    string   `json:"buddyIcon,omitempty" xml:"buddyIcon,omitempty"`
 	MoodIcon     string   `json:"moodIcon,omitempty" xml:"moodIcon,omitempty"`
 	MoodIcon     string   `json:"moodIcon,omitempty" xml:"moodIcon,omitempty"`
@@ -191,9 +191,9 @@ func (m *BuddyListManager) getBuddyInfo(ctx context.Context, instance *state.Ses
 		AimID:     ident.String(),
 		AimID:     ident.String(),
 		DisplayID: buddyName,
 		DisplayID: buddyName,
 		State:     "offline",
 		State:     "offline",
-		UserType:  "aim",
+		UserType:  userTypeFor(ident),
+		Service:   serviceFor(ident),
 		Bot:       false,
 		Bot:       false,
-		Service:   "AIM",
 	}
 	}
 
 
 	reply, err := m.locateService.UserInfoQuery(ctx, instance, wire.SNACFrame{},
 	reply, err := m.locateService.UserInfoQuery(ctx, instance, wire.SNACFrame{},

+ 25 - 1
server/webapi/buddy_list_manager_test.go

@@ -21,7 +21,6 @@ func offlineWebAPIBuddy(aimID, displayID string) BuddyInfo {
 		State:     "offline",
 		State:     "offline",
 		UserType:  "aim",
 		UserType:  "aim",
 		Bot:       false,
 		Bot:       false,
-		Service:   "AIM",
 	}
 	}
 }
 }
 
 
@@ -147,6 +146,31 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 				},
 				},
 			},
 			},
 		},
 		},
+		{
+			name: "uin buddy is tagged icq",
+			fb: []wire.FeedbagItem{
+				{
+					Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
+				},
+				{Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
+				{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "100003"},
+			},
+			want: []BuddyGroup{
+				{
+					Name: "Buddies",
+					ID:   100,
+					Buddies: []BuddyInfo{{
+						AimID:     "100003",
+						DisplayID: "100003",
+						State:     "offline",
+						UserType:  "icq",
+						Service:   "icq",
+					}},
+				},
+			},
+		},
 		{
 		{
 			name: "buddy with note still listed note not exposed in WebAPI",
 			name: "buddy with note still listed note not exposed in WebAPI",
 			fb: []wire.FeedbagItem{
 			fb: []wire.FeedbagItem{

+ 63 - 1
server/webapi/events.go

@@ -5,6 +5,8 @@ import (
 	"sync"
 	"sync"
 	"sync/atomic"
 	"sync/atomic"
 	"time"
 	"time"
+
+	"github.com/mk6i/open-oscar-server/state"
 )
 )
 
 
 // EventType defines the type of WebAPI event.
 // EventType defines the type of WebAPI event.
@@ -24,6 +26,7 @@ const (
 	EventTypeTyping       EventType = "typing"
 	EventTypeTyping       EventType = "typing"
 	EventTypePermitDeny   EventType = "permitDeny"
 	EventTypePermitDeny   EventType = "permitDeny"
 	EventTypeClientError  EventType = "clientError"
 	EventTypeClientError  EventType = "clientError"
+	EventTypeService      EventType = "service"
 )
 )
 
 
 // Event represents an event to be delivered to a web client.
 // Event represents an event to be delivered to a web client.
@@ -48,7 +51,7 @@ type PresenceEvent struct {
 	AwayMsg    string `json:"awayMsg,omitempty" xml:"awayMsg,omitempty"`
 	AwayMsg    string `json:"awayMsg,omitempty" xml:"awayMsg,omitempty"`
 	IdleTime   int    `json:"idleTime,omitempty" xml:"idleTime,omitempty"`     // Minutes idle
 	IdleTime   int    `json:"idleTime,omitempty" xml:"idleTime,omitempty"`     // Minutes idle
 	OnlineTime int64  `json:"onlineTime,omitempty" xml:"onlineTime,omitempty"` // Unix timestamp
 	OnlineTime int64  `json:"onlineTime,omitempty" xml:"onlineTime,omitempty"` // Unix timestamp
-	UserType   string `json:"userType" xml:"userType"`                         // "aim", "icq", "admin"
+	UserType   string `json:"userType" xml:"userType"`                         // "aim", "icq"
 	BuddyIcon  string `json:"buddyIcon,omitempty" xml:"buddyIcon,omitempty"`   // Absolute icon URL; empty preserves the client's current icon, the placeholder URL clears it
 	BuddyIcon  string `json:"buddyIcon,omitempty" xml:"buddyIcon,omitempty"`   // Absolute icon URL; empty preserves the client's current icon, the placeholder URL clears it
 }
 }
 
 
@@ -56,6 +59,65 @@ type PresenceEvent struct {
 // plain text.
 // plain text.
 const imfPlainText = "plain"
 const imfPlainText = "plain"
 
 
+// User-type tags, which clients compare case-sensitively.
+const (
+	userTypeAIM = "aim"
+	userTypeICQ = "icq"
+)
+
+// serviceICQ names ICQ in both a user's service tag and the config list the
+// client joins it to.
+const serviceICQ = "icq"
+
+// userTypeFor returns the user-type tag for a screen name. A numeric screen
+// name is an ICQ UIN.
+func userTypeFor(sn state.IdentScreenName) string {
+	if sn.UIN() != 0 {
+		return userTypeICQ
+	}
+	return userTypeAIM
+}
+
+// serviceFor returns the network tag for a screen name, empty for AIM. An absent
+// tag reads as the native network.
+func serviceFor(sn state.IdentScreenName) string {
+	if userTypeFor(sn) == userTypeICQ {
+		return serviceICQ
+	}
+	return ""
+}
+
+// ServiceData is the service event payload and the service half of the
+// startSession seed.
+type ServiceData struct {
+	ServiceConfigs []ServiceConfig `json:"serviceConfigs" xml:"serviceConfigs>serviceConfig"`
+}
+
+// ServiceConfig describes one network a user's service tag can name. The client
+// looks the tag up by Name and renders FriendlyName as the user's label.
+type ServiceConfig struct {
+	Name            string `json:"name" xml:"name"`
+	FriendlyName    string `json:"friendlyName" xml:"friendlyName"`
+	Associated      bool   `json:"associated" xml:"associated"`
+	ConnectionState string `json:"connectionState" xml:"connectionState"`
+}
+
+// newServiceData lists the networks a user's service tag can name. ICQ is the
+// only one, since an AIM user carries no tag. An associated network the client
+// reads as unconnected prompts it to open a connection.
+func newServiceData() *ServiceData {
+	return &ServiceData{
+		ServiceConfigs: []ServiceConfig{
+			{
+				Name:            serviceICQ,
+				FriendlyName:    "ICQ",
+				Associated:      true,
+				ConnectionState: "connected",
+			},
+		},
+	}
+}
+
 // IMEvent represents an instant message event.
 // IMEvent represents an instant message event.
 type IMEvent struct {
 type IMEvent struct {
 	Source    UserInfo `json:"source" xml:"source"`
 	Source    UserInfo `json:"source" xml:"source"`

+ 59 - 0
server/webapi/events_test.go

@@ -0,0 +1,59 @@
+package webapi
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/mk6i/open-oscar-server/state"
+)
+
+func TestUserTypeFor(t *testing.T) {
+	tests := []struct {
+		name       string
+		screenName string
+		want       string
+	}{
+		{"uin", "123456789", userTypeICQ},
+		{"single digit", "5", userTypeICQ},
+		{"aim handle", "cooluser", userTypeAIM},
+		{"aim handle with digits", "cool123", userTypeAIM},
+		{"digits around letters", "12abc34", userTypeAIM},
+		{"empty", "", userTypeAIM},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.want, userTypeFor(state.NewIdentScreenName(tt.screenName)))
+		})
+	}
+}
+
+func TestServiceFor(t *testing.T) {
+	tests := []struct {
+		name       string
+		screenName string
+		want       string
+	}{
+		{"uin names icq", "123400", serviceICQ},
+		{"aim handle is unnamed", "mike", ""},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.want, serviceFor(state.NewIdentScreenName(tt.screenName)))
+		})
+	}
+}
+
+func TestNewServiceData_MatchesUserServiceTag(t *testing.T) {
+	cfgs := newServiceData().ServiceConfigs
+	require.Len(t, cfgs, 1)
+
+	// The client joins a user's tag to this list by name, so the two must agree.
+	assert.Equal(t, serviceFor(state.NewIdentScreenName("123400")), cfgs[0].Name)
+	assert.Equal(t, "ICQ", cfgs[0].FriendlyName)
+	assert.True(t, cfgs[0].Associated)
+	assert.Equal(t, "connected", cfgs[0].ConnectionState)
+}

+ 2 - 2
server/webapi/im_handler.go

@@ -204,14 +204,14 @@ func (h *MessagingHandler) pushSenderWebAPIEvents(sess *Session, recipient state
 		Sender: UserInfo{
 		Sender: UserInfo{
 			AimID:     senderAimID,
 			AimID:     senderAimID,
 			DisplayID: sess.ScreenName.String(),
 			DisplayID: sess.ScreenName.String(),
-			UserType:  "aim",
+			UserType:  userTypeFor(sess.ScreenName.IdentScreenName()),
 			State:     "online",
 			State:     "online",
 		},
 		},
 		Dest: UserInfo{
 		Dest: UserInfo{
 			AimID:     recipientAimID,
 			AimID:     recipientAimID,
 			DisplayID: recipientDisplay,
 			DisplayID: recipientDisplay,
 			Friendly:  recipientAlias,
 			Friendly:  recipientAlias,
-			UserType:  "aim",
+			UserType:  userTypeFor(recipient),
 			State:     "online",
 			State:     "online",
 		},
 		},
 		Message:   message,
 		Message:   message,

+ 6 - 24
server/webapi/presence_handler.go

@@ -39,7 +39,7 @@ type SetStateData struct {
 	State      string `json:"state" xml:"state"`
 	State      string `json:"state" xml:"state"`
 	AwayMsg    string `json:"awayMsg" xml:"awayMsg"`
 	AwayMsg    string `json:"awayMsg" xml:"awayMsg"`
 	StatusMsg  string `json:"statusMsg" xml:"statusMsg"`
 	StatusMsg  string `json:"statusMsg" xml:"statusMsg"`
-	UserType   string `json:"userType" xml:"userType"`
+	UserType   string `json:"userType" xml:"userType"` // "aim", "icq"
 	OnlineTime int64  `json:"onlineTime" xml:"onlineTime"`
 	OnlineTime int64  `json:"onlineTime" xml:"onlineTime"`
 }
 }
 
 
@@ -74,7 +74,8 @@ type BuddyPresenceInfo struct {
 	ProfileMsg string `json:"profileMsg,omitempty" xml:"profileMsg,omitempty"`
 	ProfileMsg string `json:"profileMsg,omitempty" xml:"profileMsg,omitempty"`
 	IdleTime   int    `json:"idleTime,omitempty" xml:"idleTime,omitempty"`
 	IdleTime   int    `json:"idleTime,omitempty" xml:"idleTime,omitempty"`
 	OnlineTime int64  `json:"onlineTime,omitempty" xml:"onlineTime,omitempty"`
 	OnlineTime int64  `json:"onlineTime,omitempty" xml:"onlineTime,omitempty"`
-	UserType   string `json:"userType" xml:"userType"` // "aim", "icq", "admin"
+	UserType   string `json:"userType" xml:"userType"`                   // "aim", "icq"
+	Service    string `json:"service,omitempty" xml:"service,omitempty"` // Non-native network; omitted for AIM
 	BuddyIcon  string `json:"buddyIcon,omitempty" xml:"buddyIcon,omitempty"`
 	BuddyIcon  string `json:"buddyIcon,omitempty" xml:"buddyIcon,omitempty"`
 	MoodIcon   string `json:"moodIcon,omitempty" xml:"moodIcon,omitempty"`
 	MoodIcon   string `json:"moodIcon,omitempty" xml:"moodIcon,omitempty"`
 	// Profile carries member-directory fields, present only under mdir=1. It must be
 	// Profile carries member-directory fields, present only under mdir=1. It must be
@@ -280,14 +281,8 @@ func (h *PresenceHandler) getUserPresence(ctx context.Context, instance *state.S
 		AimID:     ident.String(),
 		AimID:     ident.String(),
 		DisplayID: target.String(),
 		DisplayID: target.String(),
 		State:     "offline",
 		State:     "offline",
-		UserType:  "aim",
-	}
-
-	// Determine user type
-	if strings.HasPrefix(ident.String(), "admin") {
-		presence.UserType = "admin"
-	} else if isICQScreenName(ident.String()) {
-		presence.UserType = "icq"
+		UserType:  userTypeFor(ident),
+		Service:   serviceFor(ident),
 	}
 	}
 
 
 	// The unauthenticated icon endpoint resolves presence without a session, so
 	// The unauthenticated icon endpoint resolves presence without a session, so
@@ -358,19 +353,6 @@ func (h *PresenceHandler) getUserPresence(ctx context.Context, instance *state.S
 	return presence
 	return presence
 }
 }
 
 
-// isICQScreenName checks if a screen name is an ICQ number.
-func isICQScreenName(screenName string) bool {
-	if len(screenName) == 0 {
-		return false
-	}
-	for _, r := range screenName {
-		if r < '0' || r > '9' {
-			return false
-		}
-	}
-	return true
-}
-
 // SetState handles GET /presence/setState requests to update user's presence state.
 // SetState handles GET /presence/setState requests to update user's presence state.
 func (h *PresenceHandler) SetState(w http.ResponseWriter, r *http.Request, session *Session) {
 func (h *PresenceHandler) SetState(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 	ctx := r.Context()
@@ -448,7 +430,7 @@ func (h *PresenceHandler) SetState(w http.ResponseWriter, r *http.Request, sessi
 		State:      stateParam,
 		State:      stateParam,
 		AwayMsg:    awayMsg,
 		AwayMsg:    awayMsg,
 		StatusMsg:  "",
 		StatusMsg:  "",
-		UserType:   "aim",
+		UserType:   userTypeFor(session.ScreenName.IdentScreenName()),
 		OnlineTime: time.Now().Unix(),
 		OnlineTime: time.Now().Unix(),
 	}, h.Logger)
 	}, h.Logger)
 }
 }

+ 0 - 21
server/webapi/presence_handler_test.go

@@ -463,27 +463,6 @@ func TestPresenceHandler_SetState_MyInfoNormalizesAimID(t *testing.T) {
 	assert.Equal(t, "Mike Kelly", myInfo.Friendly)
 	assert.Equal(t, "Mike Kelly", myInfo.Friendly)
 }
 }
 
 
-func TestIsICQScreenName(t *testing.T) {
-	tests := []struct {
-		name       string
-		screenName string
-		expected   bool
-	}{
-		{"ICQ_Number", "123456789", true},
-		{"AIM_Name", "cooluser", false},
-		{"AIM_WithNumbers", "cool123", false},
-		{"Empty", "", false},
-		{"Single_Digit", "5", true},
-		{"Mixed_Chars", "12abc34", false},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			assert.Equal(t, tt.expected, isICQScreenName(tt.screenName))
-		})
-	}
-}
-
 func TestPresenceHandler_Icon(t *testing.T) {
 func TestPresenceHandler_Icon(t *testing.T) {
 	tests := []struct {
 	tests := []struct {
 		name               string
 		name               string

+ 8 - 11
server/webapi/session.go

@@ -361,7 +361,8 @@ func (s *Session) handleIncomingIM(msg wire.SNACMessage) {
 	// keys conversations and users by the normalized aimId and only renders
 	// keys conversations and users by the normalized aimId and only renders
 	// displayId, so the two forms must not be interchanged.
 	// displayId, so the two forms must not be interchanged.
 	partnerDisplay := body.ScreenName
 	partnerDisplay := body.ScreenName
-	partnerAimID := state.NewIdentScreenName(partnerDisplay).String()
+	partner := state.NewIdentScreenName(partnerDisplay)
+	partnerAimID := partner.String()
 
 
 	// An offline message is logged under the time it was sent, so the stored-IM
 	// An offline message is logged under the time it was sent, so the stored-IM
 	// history it lands in stays in the order the conversation happened.
 	// history it lands in stays in the order the conversation happened.
@@ -375,7 +376,7 @@ func (s *Session) handleIncomingIM(msg wire.SNACMessage) {
 		// The client resolves an offline sender from aimId and friendly alone,
 		// The client resolves an offline sender from aimId and friendly alone,
 		// so friendly falls back to the sender's own formatting when the viewer
 		// so friendly falls back to the sender's own formatting when the viewer
 		// has no alias for them.
 		// has no alias for them.
-		friendly := s.aliasFor(state.NewIdentScreenName(partnerAimID))
+		friendly := s.aliasFor(partner)
 		if friendly == "" {
 		if friendly == "" {
 			friendly = partnerDisplay
 			friendly = partnerDisplay
 		}
 		}
@@ -397,8 +398,8 @@ func (s *Session) handleIncomingIM(msg wire.SNACMessage) {
 			Source: UserInfo{
 			Source: UserInfo{
 				AimID:     partnerAimID,
 				AimID:     partnerAimID,
 				DisplayID: partnerDisplay,
 				DisplayID: partnerDisplay,
-				Friendly:  s.aliasFor(state.NewIdentScreenName(partnerAimID)),
-				UserType:  "aim",
+				Friendly:  s.aliasFor(partner),
+				UserType:  userTypeFor(partner),
 				State:     "online",
 				State:     "online",
 			},
 			},
 			Message:   messageText,
 			Message:   messageText,
@@ -460,7 +461,7 @@ func (s *Session) handleClientError(msg wire.SNACMessage) {
 			AimID:     sender.String(),
 			AimID:     sender.String(),
 			DisplayID: body.ScreenName,
 			DisplayID: body.ScreenName,
 			Friendly:  s.aliasFor(sender),
 			Friendly:  s.aliasFor(sender),
-			UserType:  "aim",
+			UserType:  userTypeFor(sender),
 		},
 		},
 		Cookie:  s.msgIDForCookie(body.Cookie),
 		Cookie:  s.msgIDForCookie(body.Cookie),
 		Channel: channel,
 		Channel: channel,
@@ -541,11 +542,7 @@ func (s *Session) handleBuddyArrived(msg wire.SNACMessage) {
 		AimID:    buddy.String(),
 		AimID:    buddy.String(),
 		Friendly: s.aliasFor(buddy),
 		Friendly: s.aliasFor(buddy),
 		State:    stateStr,
 		State:    stateStr,
-		UserType: "aim",
-	}
-
-	if buddy.UIN() > 0 {
-		presenceEvent.UserType = "icq"
+		UserType: userTypeFor(buddy),
 	}
 	}
 
 
 	presenceEvent.MoodIcon = moodIconURL(s.BaseURL, stateStr, userInfoCaps(body.TLVUserInfo))
 	presenceEvent.MoodIcon = moodIconURL(s.BaseURL, stateStr, userInfoCaps(body.TLVUserInfo))
@@ -589,7 +586,7 @@ func (s *Session) handleBuddyDeparted(msg wire.SNACMessage) {
 		AimID:    buddy.String(),
 		AimID:    buddy.String(),
 		Friendly: s.aliasFor(buddy),
 		Friendly: s.aliasFor(buddy),
 		State:    "offline",
 		State:    "offline",
-		UserType: "aim",
+		UserType: userTypeFor(buddy),
 	}
 	}
 
 
 	s.EventQueue.Push(EventTypePresence, presenceEvent)
 	s.EventQueue.Push(EventTypePresence, presenceEvent)

+ 21 - 0
server/webapi/session_test.go

@@ -343,6 +343,27 @@ func TestSessionManager_RunAfterShutdown(t *testing.T) {
 // The client deletes the alias it holds each time it merges a user map, so every
 // The client deletes the alias it holds each time it merges a user map, so every
 // event naming a buddy has to repeat it. An incoming IM and a presence change both
 // event naming a buddy has to repeat it. An incoming IM and a presence change both
 // carry a user map, and both would otherwise rename an aliased buddy.
 // carry a user map, and both would otherwise rename an aliased buddy.
+func TestSession_UINBuddyReportsICQOnArrivalAndDeparture(t *testing.T) {
+	sess := &Session{
+		ScreenName: state.DisplayScreenName("me"),
+		Events:     []string{"presence"},
+		EventQueue: NewEventQueue(10),
+		logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
+	}
+
+	sess.handleBuddyArrived(wire.SNACMessage{Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+		TLVUserInfo: wire.TLVUserInfo{ScreenName: "100003"},
+	}})
+	sess.handleBuddyDeparted(wire.SNACMessage{Body: wire.SNAC_0x03_0x0C_BuddyDeparted{
+		TLVUserInfo: wire.TLVUserInfo{ScreenName: "100003"},
+	}})
+
+	events := sess.EventQueue.GetAllEvents()
+	require.Len(t, events, 2)
+	assert.Equal(t, "icq", events[0].Data.(PresenceEvent).UserType)
+	assert.Equal(t, "icq", events[1].Data.(PresenceEvent).UserType)
+}
+
 func TestSession_RepeatsBuddyAliasOnOSCAREvents(t *testing.T) {
 func TestSession_RepeatsBuddyAliasOnOSCAREvents(t *testing.T) {
 	newSession := func() *Session {
 	newSession := func() *Session {
 		return &Session{
 		return &Session{

+ 10 - 0
server/webapi/strict_keys_test.go

@@ -56,6 +56,16 @@ func TestStrictKeys_BuddyGroupID(t *testing.T) {
 	assert.Contains(t, body, `"id":0`)
 	assert.Contains(t, body, `"id":0`)
 }
 }
 
 
+func TestStrictKeys_BuddyUserType(t *testing.T) {
+	// A buddy's userType is read strictly, so an absent key costs the whole roster
+	// rather than that one entry.
+	body := renderJSON(t, BuddyListData{Groups: []BuddyGroup{{
+		Name: "Buddies", Buddies: []BuddyInfo{{AimID: "chattingchuck", DisplayID: "ChattingChuck", State: "offline", UserType: "aim"}},
+	}}})
+
+	assert.Contains(t, body, `"userType":"aim"`)
+}
+
 func TestStrictKeys_PresenceUsers(t *testing.T) {
 func TestStrictKeys_PresenceUsers(t *testing.T) {
 	// Each query fills in one field, and a match of none must still render that
 	// Each query fills in one field, and a match of none must still render that
 	// field as an empty array rather than drop it: a client reading data.users or
 	// field as an empty array rather than drop it: a client reading data.users or

+ 1 - 0
state/session.go

@@ -9,6 +9,7 @@ import (
 	"time"
 	"time"
 
 
 	"github.com/google/uuid"
 	"github.com/google/uuid"
+
 	"github.com/mk6i/open-oscar-server/wire"
 	"github.com/mk6i/open-oscar-server/wire"
 )
 )