Ver código fonte

issue #195 - fix TOC1 sign-on not announced to OSCAR buddies

Remove the deferred presence mechanism that held back the sign-on
broadcast until contacts were initialized.

ICQ 5 calls FeedbagUse after ClientOnline, so its feedbag privacy settings
are not in effect at ClientOnline. For client ID "ICQBasic", ClientOnline
skips the presence broadcast and FeedbagUse sends it instead.

SetUserInfoFields no longer broadcasts presence before sign-on completes.
Mike 2 semanas atrás
pai
commit
2e5dae71cc

+ 0 - 2
foodgroup/buddy.go

@@ -91,8 +91,6 @@ func (s BuddyService) AddBuddies(ctx context.Context, instance *state.SessionIns
 		added = append(added, them)
 		added = append(added, them)
 	}
 	}
 
 
-	instance.SetContactsInit()
-
 	if !instance.SignonComplete() {
 	if !instance.SignonComplete() {
 		// client has not completed sign-on sequence, so any arrival
 		// client has not completed sign-on sequence, so any arrival
 		// messages sent at this point would be ignored by the client.
 		// messages sent at this point would be ignored by the client.

+ 0 - 1
foodgroup/buddy_test.go

@@ -344,7 +344,6 @@ func TestBuddyService_AddBuddies(t *testing.T) {
 			rejectSNAC, haveErr := svc.AddBuddies(context.Background(), tt.instance, wire.SNACFrame{RequestID: 42}, tt.bodyIn)
 			rejectSNAC, haveErr := svc.AddBuddies(context.Background(), tt.instance, wire.SNACFrame{RequestID: 42}, tt.bodyIn)
 			assert.Equal(t, tt.wantSNAC, rejectSNAC)
 			assert.Equal(t, tt.wantSNAC, rejectSNAC)
 			assert.ErrorIs(t, haveErr, tt.wantErr)
 			assert.ErrorIs(t, haveErr, tt.wantErr)
-			assert.True(t, tt.instance.ContactsInit(), "AddBuddies marks client-side buddy list loaded")
 		})
 		})
 	}
 	}
 }
 }

+ 4 - 5
foodgroup/feedbag.go

@@ -564,8 +564,8 @@ func (s *FeedbagService) EndCluster(ctx context.Context, instance *state.Session
 }
 }
 
 
 // Use activates server-side buddy list state for feedbag clients at sign-on.
 // Use activates server-side buddy list state for feedbag clients at sign-on.
-// FeedbagUse and ClientOnline can arrive in either order; whichever handler runs
-// second performs the initial buddy presence broadcast when both are satisfied.
+// For ICQBasic clients that have already sent ClientOnline, it also performs
+// the initial buddy presence broadcast.
 func (s *FeedbagService) Use(ctx context.Context, instance *state.SessionInstance) error {
 func (s *FeedbagService) Use(ctx context.Context, instance *state.SessionInstance) error {
 	if err := s.feedbagManager.UseFeedbag(ctx, instance.IdentScreenName()); err != nil {
 	if err := s.feedbagManager.UseFeedbag(ctx, instance.IdentScreenName()); err != nil {
 		return fmt.Errorf("could not use feedbag: %w", err)
 		return fmt.Errorf("could not use feedbag: %w", err)
@@ -576,10 +576,9 @@ func (s *FeedbagService) Use(ctx context.Context, instance *state.SessionInstanc
 	}
 	}
 	setSessionBuddyPrefs(items, instance)
 	setSessionBuddyPrefs(items, instance)
 	instance.Session().SetUsesFeedbag()
 	instance.Session().SetUsesFeedbag()
-	instance.SetContactsInit()
+	instance.SetFeedbagActive()
 
 
-	// ICQ Lite order: ClientOnline before FeedbagUse — broadcast here.
-	if instance.SignonComplete() {
+	if isICQBasic(instance) && instance.SignonComplete() {
 		if err := s.buddyBroadcaster.BroadcastVisibility(ctx, instance, nil, false); err != nil {
 		if err := s.buddyBroadcaster.BroadcastVisibility(ctx, instance, nil, false); err != nil {
 			return fmt.Errorf("buddyBroadcaster.BroadcastVisibility: %w", err)
 			return fmt.Errorf("buddyBroadcaster.BroadcastVisibility: %w", err)
 		}
 		}

+ 52 - 8
foodgroup/feedbag_test.go

@@ -2991,8 +2991,8 @@ func TestFeedbagService_Use(t *testing.T) {
 				},
 				},
 			},
 			},
 			checkSession: func(t *testing.T, instance *state.SessionInstance) {
 			checkSession: func(t *testing.T, instance *state.SessionInstance) {
-				assert.True(t, instance.ContactsInit())
 				assert.True(t, instance.Session().UsesFeedbag())
 				assert.True(t, instance.Session().UsesFeedbag())
+				assert.True(t, instance.FeedbagActive())
 				assert.False(t, instance.Session().TypingEventsEnabled())
 				assert.False(t, instance.Session().TypingEventsEnabled())
 			},
 			},
 		},
 		},
@@ -3022,8 +3022,8 @@ func TestFeedbagService_Use(t *testing.T) {
 				},
 				},
 			},
 			},
 			checkSession: func(t *testing.T, instance *state.SessionInstance) {
 			checkSession: func(t *testing.T, instance *state.SessionInstance) {
-				assert.True(t, instance.ContactsInit())
 				assert.True(t, instance.Session().UsesFeedbag())
 				assert.True(t, instance.Session().UsesFeedbag())
+				assert.True(t, instance.FeedbagActive())
 				assert.False(t, instance.Session().TypingEventsEnabled())
 				assert.False(t, instance.Session().TypingEventsEnabled())
 			},
 			},
 		},
 		},
@@ -3053,14 +3053,58 @@ func TestFeedbagService_Use(t *testing.T) {
 				},
 				},
 			},
 			},
 			checkSession: func(t *testing.T, instance *state.SessionInstance) {
 			checkSession: func(t *testing.T, instance *state.SessionInstance) {
-				assert.True(t, instance.ContactsInit())
 				assert.True(t, instance.Session().UsesFeedbag())
 				assert.True(t, instance.Session().UsesFeedbag())
+				assert.True(t, instance.FeedbagActive())
 				assert.True(t, instance.Session().TypingEventsEnabled())
 				assert.True(t, instance.Session().TypingEventsEnabled())
 			},
 			},
 		},
 		},
 		{
 		{
-			name:     "ICQ Lite order: feedbag use after ClientOnline broadcasts presence",
+			name:     "ICQBasic client, feedbag use before ClientOnline does not broadcast presence",
+			instance: newTestInstance("me", sessClientID("ICQBasic")),
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					useParams: useParams{
+						{
+							screenName: state.NewIdentScreenName("me"),
+						},
+					},
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("me"),
+						},
+					},
+				},
+			},
+			checkSession: func(t *testing.T, instance *state.SessionInstance) {
+				assert.True(t, instance.Session().UsesFeedbag())
+				assert.True(t, instance.FeedbagActive())
+			},
+		},
+		{
+			name:     "non-ICQBasic client, feedbag use after ClientOnline does not broadcast presence",
 			instance: newTestInstance("me", sessOptSignonComplete),
 			instance: newTestInstance("me", sessOptSignonComplete),
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					useParams: useParams{
+						{
+							screenName: state.NewIdentScreenName("me"),
+						},
+					},
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("me"),
+						},
+					},
+				},
+			},
+			checkSession: func(t *testing.T, instance *state.SessionInstance) {
+				assert.True(t, instance.Session().UsesFeedbag())
+				assert.True(t, instance.FeedbagActive())
+			},
+		},
+		{
+			name:     "ICQBasic client, feedbag use after ClientOnline broadcasts presence",
+			instance: newTestInstance("me", sessOptSignonComplete, sessClientID("ICQBasic")),
 			mockParams: mockParams{
 			mockParams: mockParams{
 				feedbagManagerParams: feedbagManagerParams{
 				feedbagManagerParams: feedbagManagerParams{
 					useParams: useParams{
 					useParams: useParams{
@@ -3085,13 +3129,13 @@ func TestFeedbagService_Use(t *testing.T) {
 				},
 				},
 			},
 			},
 			checkSession: func(t *testing.T, instance *state.SessionInstance) {
 			checkSession: func(t *testing.T, instance *state.SessionInstance) {
-				assert.True(t, instance.ContactsInit())
 				assert.True(t, instance.Session().UsesFeedbag())
 				assert.True(t, instance.Session().UsesFeedbag())
+				assert.True(t, instance.FeedbagActive())
 			},
 			},
 		},
 		},
 		{
 		{
-			name:     "ICQ Lite order: feedbag use after ClientOnline, BroadcastVisibility fails",
-			instance: newTestInstance("me", sessOptSignonComplete),
+			name:     "ICQBasic client, feedbag use after ClientOnline, BroadcastVisibility fails",
+			instance: newTestInstance("me", sessOptSignonComplete, sessClientID("ICQBasic")),
 			mockParams: mockParams{
 			mockParams: mockParams{
 				feedbagManagerParams: feedbagManagerParams{
 				feedbagManagerParams: feedbagManagerParams{
 					useParams: useParams{
 					useParams: useParams{
@@ -3118,8 +3162,8 @@ func TestFeedbagService_Use(t *testing.T) {
 			},
 			},
 			wantErr: assert.AnError,
 			wantErr: assert.AnError,
 			checkSession: func(t *testing.T, instance *state.SessionInstance) {
 			checkSession: func(t *testing.T, instance *state.SessionInstance) {
-				assert.True(t, instance.ContactsInit())
 				assert.True(t, instance.Session().UsesFeedbag())
 				assert.True(t, instance.Session().UsesFeedbag())
+				assert.True(t, instance.FeedbagActive())
 			},
 			},
 		},
 		},
 	}
 	}

+ 5 - 5
foodgroup/helpers_test.go

@@ -863,16 +863,16 @@ func sessOptIdle(dur time.Duration) func(instance *state.SessionInstance) {
 	}
 	}
 }
 }
 
 
+// sessOptFeedbagActive sets the feedbag active flag to true
+func sessOptFeedbagActive(instance *state.SessionInstance) {
+	instance.SetFeedbagActive()
+}
+
 // sessOptSignonComplete sets the sign on complete flag to true
 // sessOptSignonComplete sets the sign on complete flag to true
 func sessOptSignonComplete(instance *state.SessionInstance) {
 func sessOptSignonComplete(instance *state.SessionInstance) {
 	instance.SetSignonComplete()
 	instance.SetSignonComplete()
 }
 }
 
 
-// sessOptContactsInit sets the contacts-init flag to true
-func sessOptContactsInit(instance *state.SessionInstance) {
-	instance.SetContactsInit()
-}
-
 // sessOptCaps sets caps
 // sessOptCaps sets caps
 func sessOptUIN(UIN uint32) func(instance *state.SessionInstance) {
 func sessOptUIN(UIN uint32) func(instance *state.SessionInstance) {
 	return func(instance *state.SessionInstance) {
 	return func(instance *state.SessionInstance) {

+ 20 - 7
foodgroup/oservice.go

@@ -310,7 +310,8 @@ func (s OServiceService) SetUserInfoFields(ctx context.Context, instance *state.
 		instance.SetICQDCInfo(dc)
 		instance.SetICQDCInfo(dc)
 	}
 	}
 
 
-	if statusChanged || statusMsgChanged {
+	// presence is announced at sign-on, so don't broadcast changes before then
+	if (statusChanged || statusMsgChanged) && instance.SignonComplete() {
 		if instance.Session().Invisible() {
 		if instance.Session().Invisible() {
 			if err := s.buddyBroadcaster.BroadcastBuddyDeparted(ctx, instance.IdentScreenName()); err != nil {
 			if err := s.buddyBroadcaster.BroadcastBuddyDeparted(ctx, instance.IdentScreenName()); err != nil {
 				return err
 				return err
@@ -764,10 +765,9 @@ func (s OServiceService) ServiceRequest(ctx context.Context, service uint16, ins
 
 
 // ClientOnline runs when the current user is ready to join.
 // ClientOnline runs when the current user is ready to join.
 // If BOS:
 // If BOS:
-//   - Announce current user's arrival to users who have the current user on their buddy list,
-//     but only when the contact list is ready (feedbag initialized or client-side buddy
-//     list loaded). Clients that send ClientOnline before feedbag activation get the
-//     initial broadcast from FeedbagService.Use instead.
+//   - Announce current user's arrival to users who have the current user on their buddy list.
+//     ICQBasic (i.e. ICQ 5) clients that have not yet activated their feedbag get the initial
+//     broadcast from FeedbagService.Use instead.
 //
 //
 // If Chat:
 // If Chat:
 //   - Send current user the chat room metadata
 //   - Send current user the chat room metadata
@@ -778,8 +778,7 @@ func (s OServiceService) ClientOnline(ctx context.Context, service uint16, inBod
 
 
 	switch service {
 	switch service {
 	case wire.BOS:
 	case wire.BOS:
-		// AIM order: feedbag or client-side buddy list before ClientOnline.
-		if instance.ContactsInit() {
+		if !isICQBasic(instance) || instance.FeedbagActive() {
 			if err := s.buddyBroadcaster.BroadcastVisibility(ctx, instance, nil, false); err != nil {
 			if err := s.buddyBroadcaster.BroadcastVisibility(ctx, instance, nil, false); err != nil {
 				return fmt.Errorf("unable to send buddy arrival notification: %w", err)
 				return fmt.Errorf("unable to send buddy arrival notification: %w", err)
 			}
 			}
@@ -847,6 +846,20 @@ func (s OServiceService) ClientOnline(ctx context.Context, service uint16, inBod
 	}
 	}
 }
 }
 
 
+// isICQBasic reports whether the client is ICQBasic (ICQ 5), which is the only
+// official client that calls FeedbagUse -after- ClientOnline. ClientOnline is
+// the method that reports presence to users. If FeedbagUse has not yet been
+// called, ClientOnline operates on the default privacy settings. That means
+// blocked users could get a presence notification without any mitigations in
+// place.
+//
+// To mitigate what is arguably a bug in ICQ 5, ClientOnline refrains from
+// sending presence if the client ID is "ICQBasic". Presence is finally sent
+// on a subsequent call to FeedbagUse.
+func isICQBasic(instance *state.SessionInstance) bool {
+	return instance.ClientID() == "ICQBasic"
+}
+
 // sendOfflineMessageNotification sends an IM notifying the user of their
 // sendOfflineMessageNotification sends an IM notifying the user of their
 // offline message count and resets the count to zero.
 // offline message count and resets the count to zero.
 func (s OServiceService) sendOfflineMessageNotification(ctx context.Context, instance *state.SessionInstance) error {
 func (s OServiceService) sendOfflineMessageNotification(ctx context.Context, instance *state.SessionInstance) error {

+ 144 - 34
foodgroup/oservice_test.go

@@ -1094,7 +1094,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 	}{
 	}{
 		{
 		{
 			name:     "set user status to visible aim < 6",
 			name:     "set user status to visible aim < 6",
-			instance: newTestInstance("me", sessOptInvisible),
+			instance: newTestInstance("me", sessOptSignonComplete, sessOptInvisible),
 			inputSNAC: wire.SNACMessage{
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
 					RequestID: 1234,
@@ -1139,9 +1139,47 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 				assert.False(t, session.Invisible())
 				assert.False(t, session.Invisible())
 			},
 			},
 		},
 		},
+		{
+			name:     "set user status to visible before sign-on, no arrival broadcast",
+			instance: newTestInstance("me", sessOptInvisible),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.OServiceUserInfoStatus, uint32(0x0000)),
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceUserInfoUpdate,
+					// Pushes are unsolicited, so they carry the server request ID.
+					RequestID: wire.ReqIDFromServer,
+				},
+				Body: func(val any) bool {
+					snac, ok := val.(wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate)
+					if !ok {
+						return false
+					}
+					if len(snac.UserInfo) == 0 {
+						return false
+					}
+					status, hasStatus := snac.UserInfo[0].Uint32BE(wire.OServiceUserInfoStatus)
+					return hasStatus && status == uint32(0x0000)
+				},
+			},
+			checkSession: func(t *testing.T, session *state.Session) {
+				assert.False(t, session.Invisible())
+			},
+		},
 		{
 		{
 			name:     "set user status to invisible aim < 6",
 			name:     "set user status to invisible aim < 6",
-			instance: newTestInstance("me"),
+			instance: newTestInstance("me", sessOptSignonComplete),
 			inputSNAC: wire.SNACMessage{
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
 					RequestID: 1234,
@@ -1186,9 +1224,47 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 				assert.True(t, session.Invisible())
 				assert.True(t, session.Invisible())
 			},
 			},
 		},
 		},
+		{
+			name:     "set user status to invisible before sign-on, no departure broadcast",
+			instance: newTestInstance("me"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.OServiceUserInfoStatus, uint32(0x0100)),
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceUserInfoUpdate,
+					// Pushes are unsolicited, so they carry the server request ID.
+					RequestID: wire.ReqIDFromServer,
+				},
+				Body: func(val any) bool {
+					snac, ok := val.(wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate)
+					if !ok {
+						return false
+					}
+					if len(snac.UserInfo) == 0 {
+						return false
+					}
+					status, hasStatus := snac.UserInfo[0].Uint32BE(wire.OServiceUserInfoStatus)
+					return hasStatus && status == uint32(0x0100)
+				},
+			},
+			checkSession: func(t *testing.T, session *state.Session) {
+				assert.True(t, session.Invisible())
+			},
+		},
 		{
 		{
 			name:     "set user status to visible aim >= 6",
 			name:     "set user status to visible aim >= 6",
-			instance: newTestInstance("me", sessOptInvisible, sessOptSetFoodGroupVersion(wire.OService, 4)),
+			instance: newTestInstance("me", sessOptSignonComplete, sessOptInvisible, sessOptSetFoodGroupVersion(wire.OService, 4)),
 			inputSNAC: wire.SNACMessage{
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
 					RequestID: 1234,
@@ -1235,7 +1311,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "set user status to invisible aim >= 6",
 			name:     "set user status to invisible aim >= 6",
-			instance: newTestInstance("me", sessOptSetFoodGroupVersion(wire.OService, 4)),
+			instance: newTestInstance("me", sessOptSignonComplete, sessOptSetFoodGroupVersion(wire.OService, 4)),
 			inputSNAC: wire.SNACMessage{
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
 					RequestID: 1234,
@@ -1282,7 +1358,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "set busy status raises the unavailable flag",
 			name:     "set busy status raises the unavailable flag",
-			instance: newTestInstance("me"),
+			instance: newTestInstance("me", sessOptSignonComplete),
 			inputSNAC: wire.SNACMessage{
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
 					RequestID: 1234,
@@ -1326,7 +1402,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "set away status raises the unavailable flag",
 			name:     "set away status raises the unavailable flag",
-			instance: newTestInstance("me"),
+			instance: newTestInstance("me", sessOptSignonComplete),
 			inputSNAC: wire.SNACMessage{
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
 					RequestID: 1234,
@@ -1374,7 +1450,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "set DND status raises the unavailable flag",
 			name:     "set DND status raises the unavailable flag",
-			instance: newTestInstance("me"),
+			instance: newTestInstance("me", sessOptSignonComplete),
 			inputSNAC: wire.SNACMessage{
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
 					RequestID: 1234,
@@ -1420,7 +1496,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "set out status raises the unavailable flag",
 			name:     "set out status raises the unavailable flag",
-			instance: newTestInstance("me"),
+			instance: newTestInstance("me", sessOptSignonComplete),
 			inputSNAC: wire.SNACMessage{
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
 					RequestID: 1234,
@@ -1468,7 +1544,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 			// Clients send combined masks, so an unavailable bit alongside invisible
 			// Clients send combined masks, so an unavailable bit alongside invisible
 			// raises the flag and still hides the user.
 			// raises the flag and still hides the user.
 			name:     "set away and invisible status in one bitmask",
 			name:     "set away and invisible status in one bitmask",
-			instance: newTestInstance("me"),
+			instance: newTestInstance("me", sessOptSignonComplete),
 			inputSNAC: wire.SNACMessage{
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
 					RequestID: 1234,
@@ -1515,7 +1591,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 			// The away message rides along with the status bitmask that set it, so
 			// The away message rides along with the status bitmask that set it, so
 			// clearing the status clears the message too.
 			// clearing the status clears the message too.
 			name: "clear status lowers the unavailable flag and drops the away message",
 			name: "clear status lowers the unavailable flag and drops the away message",
-			instance: newTestInstance("me",
+			instance: newTestInstance("me", sessOptSignonComplete,
 				sessOptUserStatusBitmask(wire.OServiceUserStatusAway),
 				sessOptUserStatusBitmask(wire.OServiceUserStatusAway),
 				sessOptCannedAwayMessage,
 				sessOptCannedAwayMessage,
 				sessOptUserInfoFlag(wire.OServiceUserFlagUnavailable)),
 				sessOptUserInfoFlag(wire.OServiceUserFlagUnavailable)),
@@ -1567,7 +1643,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 			// An away message set through LocateService.SetInfo owns the flag,
 			// An away message set through LocateService.SetInfo owns the flag,
 			// because no unavailable status bit put it there.
 			// because no unavailable status bit put it there.
 			name: "clear status keeps the unavailable flag of a user with an away message",
 			name: "clear status keeps the unavailable flag of a user with an away message",
-			instance: newTestInstance("me",
+			instance: newTestInstance("me", sessOptSignonComplete,
 				sessOptCannedAwayMessage,
 				sessOptCannedAwayMessage,
 				sessOptUserInfoFlag(wire.OServiceUserFlagUnavailable)),
 				sessOptUserInfoFlag(wire.OServiceUserFlagUnavailable)),
 			inputSNAC: wire.SNACMessage{
 			inputSNAC: wire.SNACMessage{
@@ -1617,7 +1693,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 			// The user stays unavailable across the switch, so the away message set
 			// The user stays unavailable across the switch, so the away message set
 			// with the old status survives.
 			// with the old status survives.
 			name: "switch from one unavailable status to another keeps the away message",
 			name: "switch from one unavailable status to another keeps the away message",
-			instance: newTestInstance("me",
+			instance: newTestInstance("me", sessOptSignonComplete,
 				sessOptUserStatusBitmask(wire.OServiceUserStatusBusy),
 				sessOptUserStatusBitmask(wire.OServiceUserStatusBusy),
 				sessOptCannedAwayMessage,
 				sessOptCannedAwayMessage,
 				sessOptUserInfoFlag(wire.OServiceUserFlagUnavailable)),
 				sessOptUserInfoFlag(wire.OServiceUserFlagUnavailable)),
@@ -1670,7 +1746,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 			// Invisible is not an unavailable status, so going invisible from away
 			// Invisible is not an unavailable status, so going invisible from away
 			// lowers the flag and drops the message.
 			// lowers the flag and drops the message.
 			name: "swap an unavailable status for invisible drops the away message",
 			name: "swap an unavailable status for invisible drops the away message",
-			instance: newTestInstance("me",
+			instance: newTestInstance("me", sessOptSignonComplete,
 				sessOptUserStatusBitmask(wire.OServiceUserStatusAway),
 				sessOptUserStatusBitmask(wire.OServiceUserStatusAway),
 				sessOptCannedAwayMessage,
 				sessOptCannedAwayMessage,
 				sessOptUserInfoFlag(wire.OServiceUserFlagUnavailable)),
 				sessOptUserInfoFlag(wire.OServiceUserFlagUnavailable)),
@@ -1722,7 +1798,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 			// Away state belongs to the status TLV, so a request without one leaves
 			// Away state belongs to the status TLV, so a request without one leaves
 			// the flag and the away message alone.
 			// the flag and the away message alone.
 			name: "set status message",
 			name: "set status message",
-			instance: newTestInstance("me",
+			instance: newTestInstance("me", sessOptSignonComplete,
 				sessOptCannedAwayMessage,
 				sessOptCannedAwayMessage,
 				sessOptUserInfoFlag(wire.OServiceUserFlagUnavailable)),
 				sessOptUserInfoFlag(wire.OServiceUserFlagUnavailable)),
 			inputSNAC: wire.SNACMessage{
 			inputSNAC: wire.SNACMessage{
@@ -1784,7 +1860,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 			// riding in the BART TLV: a client drops the status it shows for a buddy
 			// riding in the BART TLV: a client drops the status it shows for a buddy
 			// only when it is sent an empty one.
 			// only when it is sent an empty one.
 			name:     "clear status message",
 			name:     "clear status message",
-			instance: newTestInstance("me", sessOptStatus(newTestStatusBARTID(t, "old"))),
+			instance: newTestInstance("me", sessOptSignonComplete, sessOptStatus(newTestStatusBARTID(t, "old"))),
 			inputSNAC: wire.SNACMessage{
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
 					RequestID: 1234,
@@ -1840,7 +1916,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 			// Two changed fields, one arrival: the client would otherwise see the
 			// Two changed fields, one arrival: the client would otherwise see the
 			// buddy blink twice.
 			// buddy blink twice.
 			name:     "status bitmask and status message in one request",
 			name:     "status bitmask and status message in one request",
-			instance: newTestInstance("me"),
+			instance: newTestInstance("me", sessOptSignonComplete),
 			inputSNAC: wire.SNACMessage{
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
 					RequestID: 1234,
@@ -1890,7 +1966,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 			// A client can set both in one request, and the status must survive the
 			// A client can set both in one request, and the status must survive the
 			// icon that precedes it in the list.
 			// icon that precedes it in the list.
 			name:     "set status message alongside a buddy icon",
 			name:     "set status message alongside a buddy icon",
-			instance: newTestInstance("me"),
+			instance: newTestInstance("me", sessOptSignonComplete),
 			inputSNAC: wire.SNACMessage{
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
 					RequestID: 1234,
@@ -1943,7 +2019,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 			// A buddy icon reference travels in the same TLV, and only the BART
 			// A buddy icon reference travels in the same TLV, and only the BART
 			// service stores those. Nothing changed here, so buddies hear nothing.
 			// service stores those. Nothing changed here, so buddies hear nothing.
 			name:     "ignore a BART item that is not a status message",
 			name:     "ignore a BART item that is not a status message",
-			instance: newTestInstance("me"),
+			instance: newTestInstance("me", sessOptSignonComplete),
 			inputSNAC: wire.SNACMessage{
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
 					RequestID: 1234,
@@ -1981,7 +2057,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "malformed BART item",
 			name:     "malformed BART item",
-			instance: newTestInstance("me"),
+			instance: newTestInstance("me", sessOptSignonComplete),
 			inputSNAC: wire.SNACMessage{
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
 					RequestID: 1234,
@@ -2002,7 +2078,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "set ICQ direct connect info",
 			name:     "set ICQ direct connect info",
-			instance: newTestInstance("1000003", sessOptUserInfoFlag(wire.OServiceUserFlagICQ)),
+			instance: newTestInstance("1000003", sessOptSignonComplete, sessOptUserInfoFlag(wire.OServiceUserFlagICQ)),
 			inputSNAC: wire.SNACMessage{
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
 					RequestID: 1234,
@@ -2119,7 +2195,7 @@ func TestOServiceService_SetUserInfoFields_ClearUnavailableStatus(t *testing.T)
 
 
 	for _, tt := range tests {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
-			instance := newTestInstance("me",
+			instance := newTestInstance("me", sessOptSignonComplete,
 				sessOptUserStatusBitmask(tt.status),
 				sessOptUserStatusBitmask(tt.status),
 				sessOptCannedAwayMessage,
 				sessOptCannedAwayMessage,
 				sessOptUserInfoFlag(wire.OServiceUserFlagUnavailable))
 				sessOptUserInfoFlag(wire.OServiceUserFlagUnavailable))
@@ -3318,7 +3394,7 @@ func TestOServiceService_ClientOnline(t *testing.T) {
 	}{
 	}{
 		{
 		{
 			name:     "notify that BOS user is online",
 			name:     "notify that BOS user is online",
-			instance: newTestInstance("me", sessOptCannedSignonTime, sessOptContactsInit),
+			instance: newTestInstance("me", sessOptCannedSignonTime),
 			bodyIn:   wire.SNAC_0x01_0x02_OServiceClientOnline{},
 			bodyIn:   wire.SNAC_0x01_0x02_OServiceClientOnline{},
 			service:  wire.BOS,
 			service:  wire.BOS,
 			mockParams: mockParams{
 			mockParams: mockParams{
@@ -3354,8 +3430,45 @@ func TestOServiceService_ClientOnline(t *testing.T) {
 			},
 			},
 		},
 		},
 		{
 		{
-			name:     "ICQ Lite order: ClientOnline before feedbag use",
-			instance: newTestInstance("me", sessOptCannedSignonTime),
+			name:     "ICQBasic client, feedbag already active, notify that BOS user is online",
+			instance: newTestInstance("me", sessOptCannedSignonTime, sessClientID("ICQBasic"), sessOptFeedbagActive),
+			bodyIn:   wire.SNAC_0x01_0x02_OServiceClientOnline{},
+			service:  wire.BOS,
+			mockParams: mockParams{
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastVisibilityParams: broadcastVisibilityParams{
+						{
+							from:             state.NewIdentScreenName("me"),
+							filter:           nil,
+							doSendDepartures: false,
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("me"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Stats,
+									SubGroup:  wire.StatsSetMinReportInterval,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x0B_0x02_StatsSetMinReportInterval{
+									MinReportInterval: 1,
+								},
+							},
+						},
+					},
+				},
+			},
+			validateSess: func(t *testing.T, instance *state.SessionInstance) {
+				assert.True(t, instance.SignonComplete())
+			},
+		},
+		{
+			name:     "ICQBasic client, feedbag not active, defer buddy broadcast to feedbag use",
+			instance: newTestInstance("me", sessOptCannedSignonTime, sessClientID("ICQBasic")),
 			bodyIn:   wire.SNAC_0x01_0x02_OServiceClientOnline{},
 			bodyIn:   wire.SNAC_0x01_0x02_OServiceClientOnline{},
 			service:  wire.BOS,
 			service:  wire.BOS,
 			mockParams: mockParams{
 			mockParams: mockParams{
@@ -3379,12 +3492,11 @@ func TestOServiceService_ClientOnline(t *testing.T) {
 			},
 			},
 			validateSess: func(t *testing.T, instance *state.SessionInstance) {
 			validateSess: func(t *testing.T, instance *state.SessionInstance) {
 				assert.True(t, instance.SignonComplete())
 				assert.True(t, instance.SignonComplete())
-				assert.False(t, instance.ContactsInit())
 			},
 			},
 		},
 		},
 		{
 		{
 			name:     "notify that BOS user is online via Kerberos auth, does not have stored profile",
 			name:     "notify that BOS user is online via Kerberos auth, does not have stored profile",
-			instance: newTestInstance("me", sessOptCannedSignonTime, sessOptKerberosAuth, sessOptContactsInit),
+			instance: newTestInstance("me", sessOptCannedSignonTime, sessOptKerberosAuth),
 			bodyIn:   wire.SNAC_0x01_0x02_OServiceClientOnline{},
 			bodyIn:   wire.SNAC_0x01_0x02_OServiceClientOnline{},
 			service:  wire.BOS,
 			service:  wire.BOS,
 			mockParams: mockParams{
 			mockParams: mockParams{
@@ -3430,7 +3542,7 @@ func TestOServiceService_ClientOnline(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "notify that BOS user is online via Kerberos auth, has stored profile",
 			name:     "notify that BOS user is online via Kerberos auth, has stored profile",
-			instance: newTestInstance("me", sessOptCannedSignonTime, sessOptKerberosAuth, sessOptContactsInit),
+			instance: newTestInstance("me", sessOptCannedSignonTime, sessOptKerberosAuth),
 			bodyIn:   wire.SNAC_0x01_0x02_OServiceClientOnline{},
 			bodyIn:   wire.SNAC_0x01_0x02_OServiceClientOnline{},
 			service:  wire.BOS,
 			service:  wire.BOS,
 			mockParams: mockParams{
 			mockParams: mockParams{
@@ -3495,7 +3607,7 @@ func TestOServiceService_ClientOnline(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "notify that BOS user is online with 0 offline messages, no notification sent",
 			name:     "notify that BOS user is online with 0 offline messages, no notification sent",
-			instance: newTestInstance("me", sessOptCannedSignonTime, sessOptOfflineMsgCount(0), sessOptContactsInit),
+			instance: newTestInstance("me", sessOptCannedSignonTime, sessOptOfflineMsgCount(0)),
 			bodyIn:   wire.SNAC_0x01_0x02_OServiceClientOnline{},
 			bodyIn:   wire.SNAC_0x01_0x02_OServiceClientOnline{},
 			service:  wire.BOS,
 			service:  wire.BOS,
 			mockParams: mockParams{
 			mockParams: mockParams{
@@ -3533,7 +3645,7 @@ func TestOServiceService_ClientOnline(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "notify that BOS user is online with offline messages, send notification and reset count",
 			name:     "notify that BOS user is online with offline messages, send notification and reset count",
-			instance: newTestInstance("me", sessOptCannedSignonTime, sessOptOfflineMsgCount(3), sessOptContactsInit),
+			instance: newTestInstance("me", sessOptCannedSignonTime, sessOptOfflineMsgCount(3)),
 			bodyIn:   wire.SNAC_0x01_0x02_OServiceClientOnline{},
 			bodyIn:   wire.SNAC_0x01_0x02_OServiceClientOnline{},
 			service:  wire.BOS,
 			service:  wire.BOS,
 			mockParams: mockParams{
 			mockParams: mockParams{
@@ -3590,7 +3702,7 @@ func TestOServiceService_ClientOnline(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "ICQ user with offline messages, no offline message notification sent",
 			name:     "ICQ user with offline messages, no offline message notification sent",
-			instance: newTestInstance("100001", sessOptCannedSignonTime, sessOptOfflineMsgCount(3), sessOptContactsInit, sessOptUIN(100001)),
+			instance: newTestInstance("100001", sessOptCannedSignonTime, sessOptOfflineMsgCount(3), sessOptUIN(100001)),
 			bodyIn:   wire.SNAC_0x01_0x02_OServiceClientOnline{},
 			bodyIn:   wire.SNAC_0x01_0x02_OServiceClientOnline{},
 			service:  wire.BOS,
 			service:  wire.BOS,
 			mockParams: mockParams{
 			mockParams: mockParams{
@@ -3635,7 +3747,6 @@ func TestOServiceService_ClientOnline(t *testing.T) {
 				instance2 := instance1.Session().AddInstance()
 				instance2 := instance1.Session().AddInstance()
 				instance2.SetSignonComplete()
 				instance2.SetSignonComplete()
 				instance3 := instance1.Session().AddInstance()
 				instance3 := instance1.Session().AddInstance()
-				instance3.SetContactsInit()
 				// instance 3 is not yet signed on
 				// instance 3 is not yet signed on
 				return instance3
 				return instance3
 			}(),
 			}(),
@@ -3693,7 +3804,6 @@ func TestOServiceService_ClientOnline(t *testing.T) {
 				instance2 := instance1.Session().AddInstance()
 				instance2 := instance1.Session().AddInstance()
 				instance2.SetSignonComplete()
 				instance2.SetSignonComplete()
 				instance3 := instance1.Session().AddInstance()
 				instance3 := instance1.Session().AddInstance()
-				instance3.SetContactsInit()
 				// instance 3 is not yet signed on
 				// instance 3 is not yet signed on
 				return instance3
 				return instance3
 			}(),
 			}(),
@@ -3737,7 +3847,7 @@ func TestOServiceService_ClientOnline(t *testing.T) {
 		},
 		},
 		{
 		{
 			name:     "notify that BOS user is online with offline messages, SetOfflineMsgCount fails",
 			name:     "notify that BOS user is online with offline messages, SetOfflineMsgCount fails",
-			instance: newTestInstance("me", sessOptCannedSignonTime, sessOptOfflineMsgCount(2), sessOptContactsInit),
+			instance: newTestInstance("me", sessOptCannedSignonTime, sessOptOfflineMsgCount(2)),
 			bodyIn:   wire.SNAC_0x01_0x02_OServiceClientOnline{},
 			bodyIn:   wire.SNAC_0x01_0x02_OServiceClientOnline{},
 			service:  wire.BOS,
 			service:  wire.BOS,
 			wantErr:  assert.AnError,
 			wantErr:  assert.AnError,
@@ -4616,7 +4726,7 @@ func TestOServiceService_SetUserInfoFields_MultiInstance(t *testing.T) {
 
 
 	for _, tt := range tests {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
-			sender := newTestInstance("me")
+			sender := newTestInstance("me", sessOptSignonComplete)
 			other := sender.Session().AddInstance()
 			other := sender.Session().AddInstance()
 
 
 			messageRelayer := newMockMessageRelayer(t)
 			messageRelayer := newMockMessageRelayer(t)

+ 15 - 18
state/session.go

@@ -1000,6 +1000,7 @@ type SessionInstance struct {
 	// Per-session connection state
 	// Per-session connection state
 	remoteAddr     *netip.AddrPort
 	remoteAddr     *netip.AddrPort
 	signonComplete bool
 	signonComplete bool
+	feedbagActive  bool
 	closed         bool
 	closed         bool
 	stopCh         chan struct{}
 	stopCh         chan struct{}
 	msgCh          chan wire.SNACMessage
 	msgCh          chan wire.SNACMessage
@@ -1020,8 +1021,6 @@ type SessionInstance struct {
 	awayMsg           string
 	awayMsg           string
 	userInfoBitmask   uint16
 	userInfoBitmask   uint16
 	userStatusBitmask uint32
 	userStatusBitmask uint32
-	// contactsInit indicates whether the client-side buddy list or feedbag has been initialized
-	contactsInit bool
 
 
 	// notify transaction: accumulate visibility notification targets during feedbag batches
 	// notify transaction: accumulate visibility notification targets during feedbag batches
 	notifyTxnActive     bool
 	notifyTxnActive     bool
@@ -1166,6 +1165,20 @@ func (s *SessionInstance) SignonComplete() bool {
 	return s.signonComplete
 	return s.signonComplete
 }
 }
 
 
+// SetFeedbagActive indicates that the instance has activated its feedbag.
+func (s *SessionInstance) SetFeedbagActive() {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	s.feedbagActive = true
+}
+
+// FeedbagActive indicates whether the instance has activated its feedbag.
+func (s *SessionInstance) FeedbagActive() bool {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return s.feedbagActive
+}
+
 // UnsetIdle removes the instance's idle state.
 // UnsetIdle removes the instance's idle state.
 func (s *SessionInstance) UnsetIdle() {
 func (s *SessionInstance) UnsetIdle() {
 	s.mutex.Lock()
 	s.mutex.Lock()
@@ -1271,22 +1284,6 @@ func (s *SessionInstance) OnClose(fn func()) {
 	s.onInstanceCloseFn = fn
 	s.onInstanceCloseFn = fn
 }
 }
 
 
-// ContactsInit returns whether the client-side buddy list has been loaded or
-// the feedbag has been initialized.
-func (s *SessionInstance) ContactsInit() bool {
-	s.mutex.RLock()
-	defer s.mutex.RUnlock()
-	return s.contactsInit
-}
-
-// SetContactsInit indicates that the client-side buddy list has been loaded or
-// the feedbag has been initialized.
-func (s *SessionInstance) SetContactsInit() {
-	s.mutex.Lock()
-	defer s.mutex.Unlock()
-	s.contactsInit = true
-}
-
 // BeginNotifyTxn starts accumulating notification targets during feedbag transactions.
 // BeginNotifyTxn starts accumulating notification targets during feedbag transactions.
 func (s *SessionInstance) BeginNotifyTxn() {
 func (s *SessionInstance) BeginNotifyTxn() {
 	s.mutex.Lock()
 	s.mutex.Lock()

+ 8 - 11
state/session_test.go

@@ -940,6 +940,14 @@ func TestSession_SetAndGetMultiConnFlag(t *testing.T) {
 	assert.Equal(t, wire.MultiConnFlagsSingleClient, s.MultiConnFlag())
 	assert.Equal(t, wire.MultiConnFlagsSingleClient, s.MultiConnFlag())
 }
 }
 
 
+func TestSessionInstance_FeedbagActive(t *testing.T) {
+	instance := NewSession().AddInstance()
+	assert.False(t, instance.FeedbagActive())
+
+	instance.SetFeedbagActive()
+	assert.True(t, instance.FeedbagActive())
+}
+
 func TestSession_SetAndGetLastWarnLevel(t *testing.T) {
 func TestSession_SetAndGetLastWarnLevel(t *testing.T) {
 	s := NewSession().AddInstance()
 	s := NewSession().AddInstance()
 	assert.Zero(t, s.Warning())
 	assert.Zero(t, s.Warning())
@@ -949,17 +957,6 @@ func TestSession_SetAndGetLastWarnLevel(t *testing.T) {
 	assert.Equal(t, level, s.Warning())
 	assert.Equal(t, level, s.Warning())
 }
 }
 
 
-func TestSessionInstance_ContactsInit(t *testing.T) {
-	instance := NewSession().AddInstance()
-	assert.False(t, instance.ContactsInit())
-
-	instance.SetContactsInit()
-	assert.True(t, instance.ContactsInit())
-
-	instance.SetContactsInit()
-	assert.True(t, instance.ContactsInit())
-}
-
 func TestInstance_Active(t *testing.T) {
 func TestInstance_Active(t *testing.T) {
 	tests := []struct {
 	tests := []struct {
 		name           string
 		name           string