Просмотр исходного кода

consolidate icq authorization logic

- remove unused code
- fixed garbled auth messages
- fix authrequired flag
- replace redundant buddy/auth logic with resuable logic
Mike 2 месяцев назад
Родитель
Сommit
b300a34577

+ 6 - 0
.mockery.yaml

@@ -226,6 +226,9 @@ packages:
           filename: "mock_auth_test.go"
   github.com/mk6i/open-oscar-server/server/icq_legacy:
     interfaces:
+      AuthService:
+        config:
+          filename: "mock_auth_service_test.go"
       UserManager:
         config:
           filename: "mock_user_manager_test.go"
@@ -241,6 +244,9 @@ packages:
       BuddyBroadcaster:
         config:
           filename: "mock_buddy_broadcaster_test.go"
+      BuddyService:
+        config:
+          filename: "mock_buddy_service_test.go"
       OfflineMessageManager:
         config:
           filename: "mock_offline_message_manager_test.go"

+ 34 - 15
cmd/server/factory.go

@@ -91,6 +91,7 @@ func MakeCommonDeps() (Container, error) {
 		c.sqLiteUserStore,
 		c.inMemorySessionManager,
 		c.sqLiteUserStore,
+		c.sqLiteUserStore,
 	)
 
 	c.icbmSvc = foodgroup.NewICBMService(
@@ -638,27 +639,45 @@ func ICQLegacy(deps Container) *icq_legacy.LegacyServer {
 		logger,
 	)
 
+	authService := foodgroup.NewAuthService(
+		deps.cfg,
+		deps.inMemorySessionManager,
+		deps.inMemorySessionManager,
+		deps.chatSessionManager,
+		deps.sqLiteUserStore,
+		deps.hmacCookieBaker,
+		deps.chatSessionManager,
+		deps.sqLiteUserStore,
+		deps.sqLiteUserStore,
+		deps.rateLimitClasses,
+		state.NewAccountCreator(deps.sqLiteUserStore.InsertUser),
+		logger,
+	)
+
+	buddyService := foodgroup.NewBuddyService(
+		deps.inMemorySessionManager,
+		deps.sqLiteUserStore,
+		deps.sqLiteUserStore,
+		deps.inMemorySessionManager,
+		deps.sqLiteUserStore,
+		deps.sqLiteUserStore,
+	)
+
 	// Create the ICQ legacy service
 	icqLegacyService := icq_legacy.NewICQLegacyService(
+		authService,
 		deps.sqLiteUserStore,        // userManager
 		deps.sqLiteUserStore,        // accountManager
 		deps.inMemorySessionManager, // sessionRetriever
 		deps.inMemorySessionManager, // messageRelayer
-		foodgroup.NewBuddyService( // buddyBroadcaster
-			deps.inMemorySessionManager,
-			deps.sqLiteUserStore,
-			deps.sqLiteUserStore,
-			deps.inMemorySessionManager,
-			deps.sqLiteUserStore,
-			deps.sqLiteUserStore,
-		),
-		deps.sqLiteUserStore, // offlineMessageManager
-		deps.sqLiteUserStore, // userFinder
-		deps.sqLiteUserStore, // userUpdater
-		deps.sqLiteUserStore, // feedbagManager
-		deps.sqLiteUserStore, // relationshipFetcher
-		deps.sqLiteUserStore, // buddyListRegistry
-		deps.sqLiteUserStore, // clientSideBuddyListManager
+		buddyService,                // buddyBroadcaster
+		deps.sqLiteUserStore,        // offlineMessageManager
+		deps.sqLiteUserStore,        // userFinder
+		deps.sqLiteUserStore,        // userUpdater
+		deps.sqLiteUserStore,        // feedbagManager
+		deps.sqLiteUserStore,        // relationshipFetcher
+		deps.sqLiteUserStore,        // buddyListRegistry
+		buddyService,                // buddyService
 		deps.icbmSvc,
 		logger,
 	)

+ 10 - 3
foodgroup/auth.go

@@ -353,9 +353,8 @@ func (s AuthService) KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_
 	if info.Version >= 4 {
 		list = append(list, wire.NewTLVBE(wire.LoginTLVTagsRoastedKerberosPassword, info.Password))
 	} else {
-		list = append(list, wire.NewTLVBE(wire.LoginTLVTagsPlaintextPassword, info.Password))
+		list = append(list, wire.NewTLVBE(wire.LoginTLVTagsPlaintextKerberosPassword, info.Password))
 	}
-
 	result, err := s.login(ctx, list, advertisedHost)
 	if err != nil {
 		return wire.SNACMessage{}, fmt.Errorf("login: %w", err)
@@ -428,6 +427,7 @@ type loginProperties struct {
 	isFLAPJavaAuth          bool
 	isKerberosPlaintextAuth bool
 	isKerberosRoastedAuth   bool
+	isPlaintextAuth         bool
 	isTOCAuth               bool
 	multiConnFlag           uint8
 	passwordHash            []byte
@@ -471,9 +471,12 @@ func (l *loginProperties) fromTLV(list wire.TLVList) error {
 		// extract roasted password for TOC FLAP login
 		l.roastedPass, _ = list.Bytes(wire.LoginTLVTagsRoastedTOCPassword)
 		l.isTOCAuth = true
+	case list.HasTag(wire.LoginTLVTagsPlaintextKerberosPassword):
+		l.plaintextPassword, _ = list.Bytes(wire.LoginTLVTagsPlaintextKerberosPassword)
+		l.isKerberosPlaintextAuth = true
 	case list.HasTag(wire.LoginTLVTagsPlaintextPassword):
 		l.plaintextPassword, _ = list.Bytes(wire.LoginTLVTagsPlaintextPassword)
-		l.isKerberosPlaintextAuth = true
+		l.isPlaintextAuth = true
 	case list.HasTag(wire.LoginTLVTagsRoastedKerberosPassword):
 		l.roastedPass, _ = list.Bytes(wire.LoginTLVTagsRoastedKerberosPassword)
 		l.isKerberosRoastedAuth = true
@@ -508,6 +511,7 @@ func (s AuthService) login(ctx context.Context, tlv wire.TLVList, advertisedHost
 		"is_toc", props.isTOCAuth,
 		"is_kerberos_plaintext", props.isKerberosPlaintextAuth,
 		"is_kerberos_roasted", props.isKerberosRoastedAuth,
+		"is_plaintext", props.isPlaintextAuth,
 		"password_hash_len", len(props.passwordHash),
 		"roasted_pass_len", len(props.roastedPass))
 
@@ -573,6 +577,9 @@ func (s AuthService) login(ctx context.Context, tlv wire.TLVList, advertisedHost
 	case props.isKerberosRoastedAuth:
 		authMethod = "Kerberos_Roasted"
 		loginOK = user.ValidateRoastedKerberosPass(props.roastedPass)
+	case props.isPlaintextAuth:
+		authMethod = "Plaintext"
+		loginOK = user.ValidatePlaintextPass(props.plaintextPassword)
 	}
 
 	s.logger.Debug("login: password validation result",

+ 26 - 2
foodgroup/feedbag.go

@@ -8,6 +8,7 @@ import (
 	"log/slog"
 	"slices"
 	"strconv"
+	"strings"
 	"time"
 
 	"github.com/mk6i/open-oscar-server/state"
@@ -23,6 +24,7 @@ func NewFeedbagService(
 	relationshipFetcher RelationshipFetcher,
 	sessionRetriever SessionRetriever,
 	contactPreAuthorizer ContactPreAuthorizer,
+	userManager UserManager,
 ) *FeedbagService {
 	return &FeedbagService{
 		bartItemManager:      bartItemManager,
@@ -33,6 +35,7 @@ func NewFeedbagService(
 		relationshipFetcher:  relationshipFetcher,
 		sessionRetriever:     sessionRetriever,
 		contactPreAuthorizer: contactPreAuthorizer,
+		userManager:          userManager,
 		icbmSender: func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error) {
 			return nil, errors.New("icbmSender not implemented")
 		},
@@ -50,6 +53,7 @@ type FeedbagService struct {
 	relationshipFetcher  RelationshipFetcher
 	sessionRetriever     SessionRetriever
 	contactPreAuthorizer ContactPreAuthorizer
+	userManager          UserManager
 	icbmSender           func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)
 }
 
@@ -523,6 +527,15 @@ func (s *FeedbagService) RequestAuthorizeToHost(ctx context.Context, instance *s
 		authorized = 0
 	}
 
+	var userInfo state.User
+	if user, err := s.userManager.User(ctx, instance.IdentScreenName()); err != nil {
+		return fmt.Errorf("userManager.User: %w", err)
+	} else if user != nil {
+		userInfo = *user
+	} else {
+		s.logger.ErrorContext(ctx, "user not found", "screen_name", instance.IdentScreenName())
+	}
+
 	snac := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
 		ChannelID:  wire.ICBMChannelICQ,
 		ScreenName: inBody.ScreenName,
@@ -531,7 +544,13 @@ func (s *FeedbagService) RequestAuthorizeToHost(ctx context.Context, instance *s
 				wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
 					UIN:         instance.UIN(),
 					MessageType: wire.ICBMMsgTypeAuthReq,
-					Message:     fmt.Sprintf("%d\xFE\xFE\xFE\xFE%d\xFE%s", instance.UIN(), authorized, utf8ToLatin1(inBody.Reason)),
+					Message: fmt.Sprintf("%s\xFE%s\xFE%s\xFE%s\xFE%d\xFE%s",
+						userInfo.ICQBasicInfo.Nickname,
+						userInfo.ICQBasicInfo.FirstName,
+						userInfo.ICQBasicInfo.LastName,
+						userInfo.ICQBasicInfo.EmailAddress,
+						authorized,
+						utf8ToLatin1(inBody.Reason)),
 				}),
 				wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
 			},
@@ -1021,6 +1040,11 @@ func (s *FeedbagService) ForwardICQAuthEvents(ctx context.Context, sender state.
 			},
 		})
 	case wire.ICBMMsgTypeAuthReq:
+		reasonText := ""
+		parts := strings.Split(authMsg.Message, "\xFE")
+		if len(parts) >= 6 {
+			reasonText = parts[5]
+		}
 		s.messageRelayer.RelayToScreenName(ctx, recipient, wire.SNACMessage{
 			Frame: wire.SNACFrame{
 				FoodGroup: wire.Feedbag,
@@ -1030,7 +1054,7 @@ func (s *FeedbagService) ForwardICQAuthEvents(ctx context.Context, sender state.
 			Body: wire.SNAC_0x13_0x19_FeedbagRequestAuthorizeToClient{
 				TLV:        wire.NewTLVBE(6, uint32(0x00020004)),
 				ScreenName: sender.String(),
-				Reason:     authMsg.Message,
+				Reason:     reasonText,
 			},
 		})
 	default:

+ 78 - 14
foodgroup/feedbag_test.go

@@ -328,7 +328,7 @@ func TestFeedbagService_QueryIfModified(t *testing.T) {
 }
 
 func TestFeedbagService_RightsQuery(t *testing.T) {
-	svc := NewFeedbagService(nil, nil, nil, nil, nil, nil, nil)
+	svc := NewFeedbagService(nil, nil, nil, nil, nil, nil, nil, nil)
 
 	outputSNAC := svc.RightsQuery(context.Background(), wire.SNACFrame{RequestID: 1234})
 	expectSNAC := wire.SNACMessage{
@@ -2078,7 +2078,7 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 				assert.Equal(t, wantBody, haveBody)
 				return nil, nil
 			}
-			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, bartItemManager, nil, sessionRetriever, contactPreAuth)
+			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, bartItemManager, nil, sessionRetriever, contactPreAuth, nil)
 			svc.buddyBroadcaster = buddyUpdateBroadcaster
 			svc.icbmSender = icbmSender
 			output, err := svc.UpsertItem(context.Background(), tc.instance, tc.inputSNAC.Frame,
@@ -2374,7 +2374,7 @@ func TestFeedbagService_Use(t *testing.T) {
 					Return(params.results, nil)
 			}
 
-			svc := NewFeedbagService(slog.Default(), nil, feedbagManager, nil, nil, nil, nil)
+			svc := NewFeedbagService(slog.Default(), nil, feedbagManager, nil, nil, nil, nil, nil)
 
 			haveErr := svc.Use(context.Background(), tt.instance)
 			assert.ErrorIs(t, tt.wantErr, haveErr)
@@ -2384,6 +2384,15 @@ func TestFeedbagService_Use(t *testing.T) {
 }
 
 func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
+	authReqSender := state.User{
+		ICQBasicInfo: state.ICQBasicInfo{
+			Nickname:     "CoolNickname",
+			FirstName:    "Alice",
+			LastName:     "Smith",
+			EmailAddress: "alice@example.com",
+		},
+	}
+
 	tests := []struct {
 		// name is the unit test name
 		name string
@@ -2475,6 +2484,11 @@ func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 						{owner: state.NewIdentScreenName("100001"), requester: state.NewIdentScreenName("100002"), result: false},
 					},
 				},
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{screenName: state.NewIdentScreenName("100001"), result: &authReqSender, err: nil},
+					},
+				},
 			},
 			expectOutput: wire.SNACMessage{
 				Frame: wire.SNACFrame{
@@ -2490,7 +2504,14 @@ func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 							wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
 								UIN:         100001,
 								MessageType: wire.ICBMMsgTypeAuthReq,
-								Message:     fmt.Sprintf("%d\xFE%s\xFE%s\xFE%s\xFE1\xFE%s", 100001, "", "", "", "please add me"),
+								Message: fmt.Sprintf("%s\xFE%s\xFE%s\xFE%s\xFE%d\xFE%s",
+									authReqSender.ICQBasicInfo.Nickname,
+									authReqSender.ICQBasicInfo.FirstName,
+									authReqSender.ICQBasicInfo.LastName,
+									authReqSender.ICQBasicInfo.EmailAddress,
+									1,
+									utf8ToLatin1("please add me"),
+								),
 							}),
 							wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
 						},
@@ -2529,6 +2550,11 @@ func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 						{owner: state.NewIdentScreenName("100001"), requester: state.NewIdentScreenName("100002"), result: false},
 					},
 				},
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{screenName: state.NewIdentScreenName("100001"), result: &authReqSender, err: nil},
+					},
+				},
 			},
 			expectOutput: wire.SNACMessage{
 				Frame: wire.SNACFrame{
@@ -2544,7 +2570,14 @@ func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 							wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
 								UIN:         100001,
 								MessageType: wire.ICBMMsgTypeAuthReq,
-								Message:     fmt.Sprintf("%d\xFE%s\xFE%s\xFE%s\xFE1\xFE%s", 100001, "", "", "", "please add me"),
+								Message: fmt.Sprintf("%s\xFE%s\xFE%s\xFE%s\xFE%d\xFE%s",
+									authReqSender.ICQBasicInfo.Nickname,
+									authReqSender.ICQBasicInfo.FirstName,
+									authReqSender.ICQBasicInfo.LastName,
+									authReqSender.ICQBasicInfo.EmailAddress,
+									1,
+									utf8ToLatin1("please add me"),
+								),
 							}),
 							wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
 						},
@@ -2579,6 +2612,11 @@ func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 						{owner: state.NewIdentScreenName("100001"), requester: state.NewIdentScreenName("100002"), result: true},
 					},
 				},
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{screenName: state.NewIdentScreenName("100001"), result: &authReqSender, err: nil},
+					},
+				},
 			},
 			expectOutput: wire.SNACMessage{
 				Frame: wire.SNACFrame{
@@ -2594,7 +2632,14 @@ func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 							wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
 								UIN:         100001,
 								MessageType: wire.ICBMMsgTypeAuthReq,
-								Message:     fmt.Sprintf("%d\xFE%s\xFE%s\xFE%s\xFE0\xFE%s", 100001, "", "", "", "please add me"),
+								Message: fmt.Sprintf("%s\xFE%s\xFE%s\xFE%s\xFE%d\xFE%s",
+									authReqSender.ICQBasicInfo.Nickname,
+									authReqSender.ICQBasicInfo.FirstName,
+									authReqSender.ICQBasicInfo.LastName,
+									authReqSender.ICQBasicInfo.EmailAddress,
+									0,
+									utf8ToLatin1("please add me"),
+								),
 							}),
 							wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
 						},
@@ -2629,6 +2674,11 @@ func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 						{owner: state.NewIdentScreenName("100001"), requester: state.NewIdentScreenName("100002"), result: false},
 					},
 				},
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{screenName: state.NewIdentScreenName("100001"), result: &authReqSender, err: nil},
+					},
+				},
 			},
 			expectOutput: wire.SNACMessage{
 				Frame: wire.SNACFrame{
@@ -2644,7 +2694,14 @@ func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 							wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
 								UIN:         100001,
 								MessageType: wire.ICBMMsgTypeAuthReq,
-								Message:     fmt.Sprintf("%d\xFE%s\xFE%s\xFE%s\xFE1\xFE%s", 100001, "", "", "", "please add me"),
+								Message: fmt.Sprintf("%s\xFE%s\xFE%s\xFE%s\xFE%d\xFE%s",
+									authReqSender.ICQBasicInfo.Nickname,
+									authReqSender.ICQBasicInfo.FirstName,
+									authReqSender.ICQBasicInfo.LastName,
+									authReqSender.ICQBasicInfo.EmailAddress,
+									1,
+									utf8ToLatin1("please add me"),
+								),
 							}),
 							wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
 						},
@@ -2684,7 +2741,14 @@ func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 					Return(params.result, params.err)
 			}
 
-			svc := NewFeedbagService(slog.Default(), messageRelayer, nil, nil, nil, sessionRetriever, contactPreAuth)
+			userManager := newMockUserManager(t)
+			for _, params := range tt.mockParams.userManagerParams.getUserParams {
+				userManager.EXPECT().
+					User(matchContext(), params.screenName).
+					Return(params.result, params.err)
+			}
+
+			svc := NewFeedbagService(slog.Default(), messageRelayer, nil, nil, nil, sessionRetriever, contactPreAuth, userManager)
 			svc.icbmSender = icbmSender
 
 			haveErr := svc.RequestAuthorizeToHost(
@@ -3135,7 +3199,7 @@ func TestFeedbagService_RespondAuthorizeToHost(t *testing.T) {
 				return nil, tt.wantErr
 			}
 
-			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, relationshipFetcher, sessionRetriever, contactPreAuth)
+			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, relationshipFetcher, sessionRetriever, contactPreAuth, nil)
 			svc.buddyBroadcaster = buddyBroadcaster
 			svc.icbmSender = icbmSender
 
@@ -3465,7 +3529,7 @@ func TestFeedbagService_PreAuthorizeBuddy(t *testing.T) {
 				return nil, nil
 			}
 
-			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, relationshipFetcher, sessionRetriever, contactPreAuth)
+			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, relationshipFetcher, sessionRetriever, contactPreAuth, nil)
 			svc.icbmSender = icbmSender
 
 			out, err := svc.PreAuthorizeBuddy(context.Background(), alice, tt.inFrame, tt.inBody)
@@ -3694,7 +3758,7 @@ func TestFeedbagService_StartCluster(t *testing.T) {
 			Body:  inBody,
 		})
 
-	svc := NewFeedbagService(slog.Default(), messageRelayer, nil, nil, nil, nil, nil)
+	svc := NewFeedbagService(slog.Default(), messageRelayer, nil, nil, nil, nil, nil, nil)
 	svc.StartCluster(context.Background(), instance, inFrame, inBody)
 }
 
@@ -3713,7 +3777,7 @@ func TestFeedbagService_EndCluster(t *testing.T) {
 			Body:  wire.SNAC_0x13_0x12_FeedbagEndCluster{},
 		})
 
-	svc := NewFeedbagService(slog.Default(), messageRelayer, nil, nil, nil, nil, nil)
+	svc := NewFeedbagService(slog.Default(), messageRelayer, nil, nil, nil, nil, nil, nil)
 	svc.EndCluster(context.Background(), instance, inFrame)
 }
 
@@ -3920,7 +3984,7 @@ func TestFeedbagService_ForwardICQAuthEvents(t *testing.T) {
 			name: "auth req - relays FeedbagRequestAuthorizeToClient SNAC to recipient",
 			authMsg: wire.ICBMCh4Message{
 				MessageType: wire.ICBMMsgTypeAuthReq,
-				Message:     "please add me",
+				Message:     "11111111\xFE\xFE\xFE\xFE1\xFEplease add me",
 			},
 			mockParams: mockParams{
 				messageRelayerParams: messageRelayerParams{
@@ -3988,7 +4052,7 @@ func TestFeedbagService_ForwardICQAuthEvents(t *testing.T) {
 				return nil, tt.wantErr
 			}
 
-			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, relationshipFetcher, sessionRetriever, contactPreAuth)
+			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, relationshipFetcher, sessionRetriever, contactPreAuth, nil)
 			svc.icbmSender = icbmSender
 
 			err := svc.ForwardICQAuthEvents(context.Background(), sender.IdentScreenName(), recipient, tt.authMsg)

+ 10 - 34
server/icq_legacy/handler.go

@@ -8,6 +8,7 @@ import (
 
 	"github.com/mk6i/open-oscar-server/config"
 	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
 )
 
 // ProtocolDispatcher routes packets to the appropriate version handler
@@ -281,6 +282,13 @@ type BaseHandler struct {
 	logger   *slog.Logger
 }
 
+// AuthService provides OSCAR authentication and BOS session registration.
+type AuthService interface {
+	FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error)
+	CrackCookie(authCookie []byte) (state.ServerCookie, error)
+	RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie, cfg func(*state.Session)) (*state.SessionInstance, error)
+}
+
 // LegacyService is the interface for the ICQ legacy service layer.
 // It defines all business logic operations that protocol handlers delegate to,
 // keeping handlers thin and protocol-independent logic centralized.
@@ -324,7 +332,7 @@ type LegacyService interface {
 	// The method does NOT contain any protocol-specific packet building logic.
 	// Handlers are responsible for building protocol-specific responses based on
 	// the returned ContactListResult.
-	ProcessContactList(ctx context.Context, req ContactListRequest) (*ContactListResult, error)
+	ProcessContactList(ctx context.Context, instance *state.SessionInstance, req ContactListRequest) (*ContactListResult, error)
 
 	// ProcessUserAdd processes a user add request and returns information about the target user.
 	// This is the service layer method for user add operations that handlers call after
@@ -333,7 +341,7 @@ type LegacyService interface {
 	// The method does NOT contain any protocol-specific packet building logic.
 	// Handlers are responsible for building protocol-specific responses based on
 	// the returned UserAddResult.
-	ProcessUserAdd(ctx context.Context, req UserAddRequest) (*UserAddResult, error)
+	ProcessUserAdd(ctx context.Context, instance *state.SessionInstance, req UserAddRequest) (*UserAddResult, error)
 
 	// ProcessStatusChange processes a status change and returns notification targets.
 	// This is the service layer method for status changes that handlers call after
@@ -469,31 +477,6 @@ func (h *BaseHandler) sendAck(session *LegacySession, seqNum uint16) error {
 	return h.sender.SendToSession(session, MarshalV2ServerPacket(pkt))
 }
 
-// sendLoginReply sends a login success response
-func (h *BaseHandler) sendLoginReply(session *LegacySession, clientSeqNum uint16) error {
-	var clientIP net.IP
-	if session.Addr != nil {
-		clientIP = session.Addr.IP
-	}
-	// Use server's own sequence number for packet header
-	// Echo client's login sequence in the data payload
-	serverSeq := session.NextServerSeqNum()
-	pkt := BuildV2LoginReply(serverSeq, clientSeqNum, session.UIN, clientIP)
-	pkt.Version = session.Version
-
-	rawPkt := MarshalV2ServerPacket(pkt)
-	h.logger.Debug("sending V2 login reply",
-		"uin", session.UIN,
-		"server_seq", serverSeq,
-		"client_seq", clientSeqNum,
-		"command", fmt.Sprintf("0x%04X", pkt.Command),
-		"data_len", len(pkt.Data),
-		"raw_hex", fmt.Sprintf("%X", rawPkt),
-	)
-
-	return h.sender.SendToSession(session, rawPkt)
-}
-
 // sendBadPassword sends a bad password response
 func (h *BaseHandler) sendBadPassword(addr *net.UDPAddr, seqNum uint16, version uint16) error {
 	pkt := BuildV2BadPassword(seqNum)
@@ -522,13 +505,6 @@ func (h *BaseHandler) sendStatusUpdate(session *LegacySession, uin uint32, statu
 	return h.sender.SendToSession(session, MarshalV2ServerPacket(pkt))
 }
 
-// sendContactListDone sends a contact list processed response
-func (h *BaseHandler) sendContactListDone(session *LegacySession, seqNum uint16) error {
-	pkt := BuildV2ContactListDone(seqNum, session.UIN)
-	pkt.Version = session.Version
-	return h.sender.SendToSession(session, MarshalV2ServerPacket(pkt))
-}
-
 // sendMessage sends a message to a session
 func (h *BaseHandler) sendMessage(session *LegacySession, fromUIN uint32, msgType uint16, message string) error {
 	pkt := BuildV2Message(session.NextServerSeqNum(), fromUIN, msgType, message)

+ 13 - 251
server/icq_legacy/helpers_test.go

@@ -14,15 +14,9 @@ import (
 // in one place for a table test
 type mockParams struct {
 	userManagerParams
-	accountManagerParams
 	sessionRetrieverParams
-	messageRelayerParams
-	buddyBroadcasterParams
 	offlineMessageManagerParams
 	icqUserFinderParams
-	icqUserUpdaterParams
-	feedbagManagerParams
-	relationshipFetcherParams
 	icbmFoodgroupParams
 }
 
@@ -46,7 +40,6 @@ type channelMsgToHostParams []struct {
 // UserManager methods
 type userManagerParams struct {
 	userParams
-	insertUserParams
 	deleteUserParams
 }
 
@@ -58,13 +51,6 @@ type userParams []struct {
 	err        error
 }
 
-// insertUserParams is the list of parameters passed at the mock
-// UserManager.InsertUser call site
-type insertUserParams []struct {
-	user state.User
-	err  error
-}
-
 // deleteUserParams is the list of parameters passed at the mock
 // UserManager.DeleteUser call site
 type deleteUserParams []struct {
@@ -72,20 +58,6 @@ type deleteUserParams []struct {
 	err        error
 }
 
-// accountManagerParams is a helper struct that contains mock parameters for
-// AccountManager methods
-type accountManagerParams struct {
-	setUserPasswordParams
-}
-
-// setUserPasswordParams is the list of parameters passed at the mock
-// AccountManager.SetUserPassword call site
-type setUserPasswordParams []struct {
-	screenName  state.IdentScreenName
-	newPassword string
-	err         error
-}
-
 // sessionRetrieverParams is a helper struct that contains mock parameters for
 // SessionRetriever methods
 type sessionRetrieverParams struct {
@@ -99,54 +71,10 @@ type retrieveSessionParams []struct {
 	result     *state.Session
 }
 
-// messageRelayerParams is a helper struct that contains mock parameters for
-// MessageRelayer methods
-type messageRelayerParams struct {
-	relayToScreenNameParams
-}
-
-// relayToScreenNameParams is the list of parameters passed at the mock
-// MessageRelayer.RelayToScreenName call site
-type relayToScreenNameParams []struct {
-	screenName state.IdentScreenName
-	message    wire.SNACMessage
-}
-
-// buddyBroadcasterParams is a helper struct that contains mock parameters for
-// BuddyBroadcaster methods
-type buddyBroadcasterParams struct {
-	broadcastBuddyArrivedParams
-	broadcastBuddyDepartedParams
-}
-
-// broadcastBuddyArrivedParams is the list of parameters passed at the mock
-// BuddyBroadcaster.BroadcastBuddyArrived call site
-type broadcastBuddyArrivedParams []struct {
-	screenName state.IdentScreenName
-	userInfo   wire.TLVUserInfo
-	err        error
-}
-
-// broadcastBuddyDepartedParams is the list of parameters passed at the mock
-// BuddyBroadcaster.BroadcastBuddyDeparted call site
-type broadcastBuddyDepartedParams []struct {
-	instance *state.SessionInstance
-	err      error
-}
-
 // offlineMessageManagerParams is a helper struct that contains mock parameters for
 // OfflineMessageManager methods
 type offlineMessageManagerParams struct {
-	deleteMessagesParams
 	retrieveMessagesParams
-	saveMessageParams
-}
-
-// deleteMessagesParams is the list of parameters passed at the mock
-// OfflineMessageManager.DeleteMessages call site
-type deleteMessagesParams []struct {
-	recip state.IdentScreenName
-	err   error
 }
 
 // retrieveMessagesParams is the list of parameters passed at the mock
@@ -157,22 +85,12 @@ type retrieveMessagesParams []struct {
 	err      error
 }
 
-// saveMessageParams is the list of parameters passed at the mock
-// OfflineMessageManager.SaveMessage call site
-type saveMessageParams []struct {
-	offlineMessage state.OfflineMessage
-	count          int
-	err            error
-}
-
 // icqUserFinderParams is a helper struct that contains mock parameters for
 // ICQUserFinder methods
 type icqUserFinderParams struct {
 	findByUINParams
 	findByICQEmailParams
 	findByICQNameParams
-	findByICQInterestsParams
-	findByICQKeywordParams
 }
 
 // findByUINParams is the list of parameters passed at the mock
@@ -201,139 +119,6 @@ type findByICQNameParams []struct {
 	err       error
 }
 
-// findByICQInterestsParams is the list of parameters passed at the mock
-// ICQUserFinder.FindByICQInterests call site
-type findByICQInterestsParams []struct {
-	code     uint16
-	keywords []string
-	result   []state.User
-	err      error
-}
-
-// findByICQKeywordParams is the list of parameters passed at the mock
-// ICQUserFinder.FindByICQKeyword call site
-type findByICQKeywordParams []struct {
-	keyword string
-	result  []state.User
-	err     error
-}
-
-// icqUserUpdaterParams is a helper struct that contains mock parameters for
-// ICQUserUpdater methods
-type icqUserUpdaterParams struct {
-	setBasicInfoParams
-	setWorkInfoParams
-	setMoreInfoParams
-	setInterestsParams
-	setAffiliationsParams
-	setUserNotesParams
-	setPermissionsParams
-	setHomepageCategoryParams
-}
-
-// setBasicInfoParams is the list of parameters passed at the mock
-// ICQUserUpdater.SetBasicInfo call site
-type setBasicInfoParams []struct {
-	name state.IdentScreenName
-	data state.ICQBasicInfo
-	err  error
-}
-
-// setWorkInfoParams is the list of parameters passed at the mock
-// ICQUserUpdater.SetWorkInfo call site
-type setWorkInfoParams []struct {
-	name state.IdentScreenName
-	data state.ICQWorkInfo
-	err  error
-}
-
-// setMoreInfoParams is the list of parameters passed at the mock
-// ICQUserUpdater.SetMoreInfo call site
-type setMoreInfoParams []struct {
-	name state.IdentScreenName
-	data state.ICQMoreInfo
-	err  error
-}
-
-// setInterestsParams is the list of parameters passed at the mock
-// ICQUserUpdater.SetInterests call site
-type setInterestsParams []struct {
-	name state.IdentScreenName
-	data state.ICQInterests
-	err  error
-}
-
-// setAffiliationsParams is the list of parameters passed at the mock
-// ICQUserUpdater.SetAffiliations call site
-type setAffiliationsParams []struct {
-	name state.IdentScreenName
-	data state.ICQAffiliations
-	err  error
-}
-
-// setUserNotesParams is the list of parameters passed at the mock
-// ICQUserUpdater.SetUserNotes call site
-type setUserNotesParams []struct {
-	name state.IdentScreenName
-	data state.ICQUserNotes
-	err  error
-}
-
-// setPermissionsParams is the list of parameters passed at the mock
-// ICQUserUpdater.SetPermissions call site
-type setPermissionsParams []struct {
-	name state.IdentScreenName
-	data state.ICQPermissions
-	err  error
-}
-
-// setHomepageCategoryParams is the list of parameters passed at the mock
-// ICQUserUpdater.SetHomepageCategory call site
-type setHomepageCategoryParams []struct {
-	name state.IdentScreenName
-	data state.ICQHomepageCategory
-	err  error
-}
-
-// feedbagManagerParams is a helper struct that contains mock parameters for
-// FeedbagManager methods
-type feedbagManagerParams struct {
-	feedbagParams
-}
-
-// feedbagParams is the list of parameters passed at the mock
-// FeedbagManager.Feedbag call site
-type feedbagParams []struct {
-	screenName state.IdentScreenName
-	results    []wire.FeedbagItem
-	err        error
-}
-
-// relationshipFetcherParams is a helper struct that contains mock parameters
-// for RelationshipFetcher methods
-type relationshipFetcherParams struct {
-	allRelationshipsParams
-	relationshipParams
-}
-
-// allRelationshipsParams is the list of parameters passed at the mock
-// RelationshipFetcher.AllRelationships call site
-type allRelationshipsParams []struct {
-	me     state.IdentScreenName
-	filter []state.IdentScreenName
-	result []state.Relationship
-	err    error
-}
-
-// relationshipParams is the list of parameters passed at the mock
-// RelationshipFetcher.Relationship call site
-type relationshipParams []struct {
-	me     state.IdentScreenName
-	them   state.IdentScreenName
-	result state.Relationship
-	err    error
-}
-
 // newTestLegacySession creates a *LegacySession with configurable fields for
 // use in table-driven tests. Fields not set remain at their zero values.
 func newTestLegacySession(uin uint32, opts ...func(*LegacySession)) *LegacySession {
@@ -347,7 +132,14 @@ func newTestLegacySession(uin uint32, opts ...func(*LegacySession)) *LegacySessi
 	return legacySess
 }
 
-// legacySessionOptAddr sets the UDP address on the legacy session.
+// legacySessionOptVersion sets the protocol version on the legacy session.
+func legacySessionOptVersion(version uint16) func(*LegacySession) {
+	return func(s *LegacySession) {
+		s.Version = version
+	}
+}
+
+// legacySessionOptOSCARSess attaches an OSCAR session instance for tests that need Instance set.
 func legacySessionOptOSCARSess(s *LegacySession) {
 	oscarSess := state.NewSession()
 	oscarSess.SetUIN(s.UIN)
@@ -356,18 +148,11 @@ func legacySessionOptOSCARSess(s *LegacySession) {
 	s.Instance = oscarSess.AddInstance()
 }
 
-// legacySessionOptVersion sets the protocol version on the legacy session.
-func legacySessionOptVersion(version uint16) func(*LegacySession) {
-	return func(s *LegacySession) {
-		s.Version = version
-	}
-}
-
-// legacySessionOptStatus sets the status on the legacy session.
-func legacySessionOptStatus(status uint32) func(*LegacySession) {
-	return func(s *LegacySession) {
-		s.Status = status
-	}
+func newTestOSCARInstance(screenName state.DisplayScreenName) *state.SessionInstance {
+	oscarSess := state.NewSession()
+	oscarSess.SetDisplayScreenName(screenName)
+	oscarSess.SetIdentScreenName(screenName.IdentScreenName())
+	return oscarSess.AddInstance()
 }
 
 // legacySessionOptContactList sets the contact list on the legacy session.
@@ -378,29 +163,6 @@ func legacySessionOptContactList(contacts []uint32) func(*LegacySession) {
 	}
 }
 
-// legacySessionOptVisibleList sets the visible list on the legacy session.
-func legacySessionOptVisibleList(visible []uint32) func(*LegacySession) {
-	return func(s *LegacySession) {
-		s.VisibleList = make([]uint32, len(visible))
-		copy(s.VisibleList, visible)
-	}
-}
-
-// legacySessionOptInvisibleList sets the invisible list on the legacy session.
-func legacySessionOptInvisibleList(invisible []uint32) func(*LegacySession) {
-	return func(s *LegacySession) {
-		s.InvisibleList = make([]uint32, len(invisible))
-		copy(s.InvisibleList, invisible)
-	}
-}
-
-// legacySessionOptAddr sets the UDP address on the legacy session.
-func legacySessionOptAddr(addr *net.UDPAddr) func(*LegacySession) {
-	return func(s *LegacySession) {
-		s.Addr = addr
-	}
-}
-
 // matchContext matches any instance of context.Context interface.
 func matchContext() interface{} {
 	return mock.MatchedBy(func(ctx any) bool {

+ 2 - 23
server/icq_legacy/legacy_message_bridge.go

@@ -390,31 +390,10 @@ func (b *LegacyMessageBridge) handleICBMMessage(session *LegacySession, msg wire
 			if err := wire.UnmarshalLE(&ch4Msg, bytes.NewBuffer(payload)); err == nil {
 				msgType = uint16(ch4Msg.MessageType)
 				text = ch4Msg.Message
-
-				// For auth messages from OSCAR, wrap reason text in FE-delimited
-				// format that legacy clients expect:
-				// nick\xFEfirst\xFElast\xFEemail\xFEauth\xFEreason
-				switch msgType {
-				case ICQLegacyMsgAuthReq, ICQLegacyMsgAuthDeny, ICQLegacyMsgAuthGrant, ICQLegacyMsgAdded:
-					nick, first, last, email := b.buildLegacyAuthFields(fromUIN)
-					reason := ch4Msg.Message
-					switch msgType {
-					case ICQLegacyMsgAuthReq:
-						text = fmt.Sprintf("%s\xFE%s\xFE%s\xFE%s\xFE1\xFE%s", nick, first, last, email, reason)
-					case ICQLegacyMsgAuthDeny:
-						text = fmt.Sprintf("%s\xFE%s\xFE%s\xFE%s\xFE%s", nick, first, last, email, reason)
-					case ICQLegacyMsgAuthGrant:
-						text = fmt.Sprintf("%s\xFE%s\xFE%s\xFE%s\xFE", nick, first, last, email)
-					case ICQLegacyMsgAdded:
-						text = fmt.Sprintf("%s\xFE%s\xFE%s\xFE%s\xFE0", nick, first, last, email)
-					}
-				}
 			}
 		}
-	}
-
-	// Fallback to channel 1 text extraction
-	if text == "" {
+	} else {
+		// Fallback to channel 1 text extraction
 		text = extractAndConvertICBMText(clientMsg)
 		msgType = ICQLegacyMsgText
 	}

+ 246 - 0
server/icq_legacy/mock_auth_service_test.go

@@ -0,0 +1,246 @@
+// Code generated by mockery; DO NOT EDIT.
+// github.com/vektra/mockery
+// template: testify
+
+package icq_legacy
+
+import (
+	"context"
+
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// newMockAuthService creates a new instance of mockAuthService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockAuthService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockAuthService {
+	mock := &mockAuthService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}
+
+// mockAuthService is an autogenerated mock type for the AuthService type
+type mockAuthService struct {
+	mock.Mock
+}
+
+type mockAuthService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockAuthService) EXPECT() *mockAuthService_Expecter {
+	return &mockAuthService_Expecter{mock: &_m.Mock}
+}
+
+// CrackCookie provides a mock function for the type mockAuthService
+func (_mock *mockAuthService) CrackCookie(authCookie []byte) (state.ServerCookie, error) {
+	ret := _mock.Called(authCookie)
+
+	if len(ret) == 0 {
+		panic("no return value specified for CrackCookie")
+	}
+
+	var r0 state.ServerCookie
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func([]byte) (state.ServerCookie, error)); ok {
+		return returnFunc(authCookie)
+	}
+	if returnFunc, ok := ret.Get(0).(func([]byte) state.ServerCookie); ok {
+		r0 = returnFunc(authCookie)
+	} else {
+		r0 = ret.Get(0).(state.ServerCookie)
+	}
+	if returnFunc, ok := ret.Get(1).(func([]byte) error); ok {
+		r1 = returnFunc(authCookie)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockAuthService_CrackCookie_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CrackCookie'
+type mockAuthService_CrackCookie_Call struct {
+	*mock.Call
+}
+
+// CrackCookie is a helper method to define mock.On call
+//   - authCookie []byte
+func (_e *mockAuthService_Expecter) CrackCookie(authCookie interface{}) *mockAuthService_CrackCookie_Call {
+	return &mockAuthService_CrackCookie_Call{Call: _e.mock.On("CrackCookie", authCookie)}
+}
+
+func (_c *mockAuthService_CrackCookie_Call) Run(run func(authCookie []byte)) *mockAuthService_CrackCookie_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 []byte
+		if args[0] != nil {
+			arg0 = args[0].([]byte)
+		}
+		run(
+			arg0,
+		)
+	})
+	return _c
+}
+
+func (_c *mockAuthService_CrackCookie_Call) Return(serverCookie state.ServerCookie, err error) *mockAuthService_CrackCookie_Call {
+	_c.Call.Return(serverCookie, err)
+	return _c
+}
+
+func (_c *mockAuthService_CrackCookie_Call) RunAndReturn(run func(authCookie []byte) (state.ServerCookie, error)) *mockAuthService_CrackCookie_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// FLAPLogin provides a mock function for the type mockAuthService
+func (_mock *mockAuthService) FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
+	ret := _mock.Called(ctx, inFrame, advertisedHost)
+
+	if len(ret) == 0 {
+		panic("no return value specified for FLAPLogin")
+	}
+
+	var r0 wire.TLVRestBlock
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, string) (wire.TLVRestBlock, error)); ok {
+		return returnFunc(ctx, inFrame, advertisedHost)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, string) wire.TLVRestBlock); ok {
+		r0 = returnFunc(ctx, inFrame, advertisedHost)
+	} else {
+		r0 = ret.Get(0).(wire.TLVRestBlock)
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.FLAPSignonFrame, string) error); ok {
+		r1 = returnFunc(ctx, inFrame, advertisedHost)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockAuthService_FLAPLogin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FLAPLogin'
+type mockAuthService_FLAPLogin_Call struct {
+	*mock.Call
+}
+
+// FLAPLogin is a helper method to define mock.On call
+//   - ctx context.Context
+//   - inFrame wire.FLAPSignonFrame
+//   - advertisedHost string
+func (_e *mockAuthService_Expecter) FLAPLogin(ctx interface{}, inFrame interface{}, advertisedHost interface{}) *mockAuthService_FLAPLogin_Call {
+	return &mockAuthService_FLAPLogin_Call{Call: _e.mock.On("FLAPLogin", ctx, inFrame, advertisedHost)}
+}
+
+func (_c *mockAuthService_FLAPLogin_Call) Run(run func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string)) *mockAuthService_FLAPLogin_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 wire.FLAPSignonFrame
+		if args[1] != nil {
+			arg1 = args[1].(wire.FLAPSignonFrame)
+		}
+		var arg2 string
+		if args[2] != nil {
+			arg2 = args[2].(string)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+		)
+	})
+	return _c
+}
+
+func (_c *mockAuthService_FLAPLogin_Call) Return(tLVRestBlock wire.TLVRestBlock, err error) *mockAuthService_FLAPLogin_Call {
+	_c.Call.Return(tLVRestBlock, err)
+	return _c
+}
+
+func (_c *mockAuthService_FLAPLogin_Call) RunAndReturn(run func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error)) *mockAuthService_FLAPLogin_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RegisterBOSSession provides a mock function for the type mockAuthService
+func (_mock *mockAuthService) RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie, cfg func(*state.Session)) (*state.SessionInstance, error) {
+	ret := _mock.Called(ctx, authCookie, cfg)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RegisterBOSSession")
+	}
+
+	var r0 *state.SessionInstance
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.ServerCookie, func(*state.Session)) (*state.SessionInstance, error)); ok {
+		return returnFunc(ctx, authCookie, cfg)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.ServerCookie, func(*state.Session)) *state.SessionInstance); ok {
+		r0 = returnFunc(ctx, authCookie, cfg)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*state.SessionInstance)
+		}
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, state.ServerCookie, func(*state.Session)) error); ok {
+		r1 = returnFunc(ctx, authCookie, cfg)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockAuthService_RegisterBOSSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterBOSSession'
+type mockAuthService_RegisterBOSSession_Call struct {
+	*mock.Call
+}
+
+// RegisterBOSSession is a helper method to define mock.On call
+//   - ctx context.Context
+//   - authCookie state.ServerCookie
+//   - cfg func(*state.Session)
+func (_e *mockAuthService_Expecter) RegisterBOSSession(ctx interface{}, authCookie interface{}, cfg interface{}) *mockAuthService_RegisterBOSSession_Call {
+	return &mockAuthService_RegisterBOSSession_Call{Call: _e.mock.On("RegisterBOSSession", ctx, authCookie, cfg)}
+}
+
+func (_c *mockAuthService_RegisterBOSSession_Call) Run(run func(ctx context.Context, authCookie state.ServerCookie, cfg func(*state.Session))) *mockAuthService_RegisterBOSSession_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 state.ServerCookie
+		if args[1] != nil {
+			arg1 = args[1].(state.ServerCookie)
+		}
+		var arg2 func(*state.Session)
+		if args[2] != nil {
+			arg2 = args[2].(func(*state.Session))
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+		)
+	})
+	return _c
+}
+
+func (_c *mockAuthService_RegisterBOSSession_Call) Return(sessionInstance *state.SessionInstance, err error) *mockAuthService_RegisterBOSSession_Call {
+	_c.Call.Return(sessionInstance, err)
+	return _c
+}
+
+func (_c *mockAuthService_RegisterBOSSession_Call) RunAndReturn(run func(ctx context.Context, authCookie state.ServerCookie, cfg func(*state.Session)) (*state.SessionInstance, error)) *mockAuthService_RegisterBOSSession_Call {
+	_c.Call.Return(run)
+	return _c
+}

+ 120 - 0
server/icq_legacy/mock_buddy_service_test.go

@@ -0,0 +1,120 @@
+// Code generated by mockery; DO NOT EDIT.
+// github.com/vektra/mockery
+// template: testify
+
+package icq_legacy
+
+import (
+	"context"
+
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// newMockBuddyService creates a new instance of mockBuddyService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockBuddyService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockBuddyService {
+	mock := &mockBuddyService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}
+
+// mockBuddyService is an autogenerated mock type for the BuddyService type
+type mockBuddyService struct {
+	mock.Mock
+}
+
+type mockBuddyService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockBuddyService) EXPECT() *mockBuddyService_Expecter {
+	return &mockBuddyService_Expecter{mock: &_m.Mock}
+}
+
+// AddBuddies provides a mock function for the type mockBuddyService
+func (_mock *mockBuddyService) AddBuddies(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) (*wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, instance, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for AddBuddies")
+	}
+
+	var r0 *wire.SNACMessage
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x03_0x04_BuddyAddBuddies) (*wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, instance, inFrame, inBody)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x03_0x04_BuddyAddBuddies) *wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*wire.SNACMessage)
+		}
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x03_0x04_BuddyAddBuddies) error); ok {
+		r1 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockBuddyService_AddBuddies_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddBuddies'
+type mockBuddyService_AddBuddies_Call struct {
+	*mock.Call
+}
+
+// AddBuddies is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x03_0x04_BuddyAddBuddies
+func (_e *mockBuddyService_Expecter) AddBuddies(ctx interface{}, instance interface{}, inFrame interface{}, inBody interface{}) *mockBuddyService_AddBuddies_Call {
+	return &mockBuddyService_AddBuddies_Call{Call: _e.mock.On("AddBuddies", ctx, instance, inFrame, inBody)}
+}
+
+func (_c *mockBuddyService_AddBuddies_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies)) *mockBuddyService_AddBuddies_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 wire.SNACFrame
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		var arg3 wire.SNAC_0x03_0x04_BuddyAddBuddies
+		if args[3] != nil {
+			arg3 = args[3].(wire.SNAC_0x03_0x04_BuddyAddBuddies)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+			arg3,
+		)
+	})
+	return _c
+}
+
+func (_c *mockBuddyService_AddBuddies_Call) Return(sNACMessage *wire.SNACMessage, err error) *mockBuddyService_AddBuddies_Call {
+	_c.Call.Return(sNACMessage, err)
+	return _c
+}
+
+func (_c *mockBuddyService_AddBuddies_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) (*wire.SNACMessage, error)) *mockBuddyService_AddBuddies_Call {
+	_c.Call.Return(run)
+	return _c
+}

+ 0 - 102
server/icq_legacy/mock_client_side_buddy_list_manager_test.go

@@ -1,102 +0,0 @@
-// Code generated by mockery; DO NOT EDIT.
-// github.com/vektra/mockery
-// template: testify
-
-package icq_legacy
-
-import (
-	"context"
-
-	"github.com/mk6i/open-oscar-server/state"
-	mock "github.com/stretchr/testify/mock"
-)
-
-// newMockClientSideBuddyListManager creates a new instance of mockClientSideBuddyListManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
-// The first argument is typically a *testing.T value.
-func newMockClientSideBuddyListManager(t interface {
-	mock.TestingT
-	Cleanup(func())
-}) *mockClientSideBuddyListManager {
-	mock := &mockClientSideBuddyListManager{}
-	mock.Mock.Test(t)
-
-	t.Cleanup(func() { mock.AssertExpectations(t) })
-
-	return mock
-}
-
-// mockClientSideBuddyListManager is an autogenerated mock type for the ClientSideBuddyListManager type
-type mockClientSideBuddyListManager struct {
-	mock.Mock
-}
-
-type mockClientSideBuddyListManager_Expecter struct {
-	mock *mock.Mock
-}
-
-func (_m *mockClientSideBuddyListManager) EXPECT() *mockClientSideBuddyListManager_Expecter {
-	return &mockClientSideBuddyListManager_Expecter{mock: &_m.Mock}
-}
-
-// AddBuddy provides a mock function for the type mockClientSideBuddyListManager
-func (_mock *mockClientSideBuddyListManager) AddBuddy(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) error {
-	ret := _mock.Called(ctx, me, them)
-
-	if len(ret) == 0 {
-		panic("no return value specified for AddBuddy")
-	}
-
-	var r0 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, state.IdentScreenName) error); ok {
-		r0 = returnFunc(ctx, me, them)
-	} else {
-		r0 = ret.Error(0)
-	}
-	return r0
-}
-
-// mockClientSideBuddyListManager_AddBuddy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddBuddy'
-type mockClientSideBuddyListManager_AddBuddy_Call struct {
-	*mock.Call
-}
-
-// AddBuddy is a helper method to define mock.On call
-//   - ctx context.Context
-//   - me state.IdentScreenName
-//   - them state.IdentScreenName
-func (_e *mockClientSideBuddyListManager_Expecter) AddBuddy(ctx interface{}, me interface{}, them interface{}) *mockClientSideBuddyListManager_AddBuddy_Call {
-	return &mockClientSideBuddyListManager_AddBuddy_Call{Call: _e.mock.On("AddBuddy", ctx, me, them)}
-}
-
-func (_c *mockClientSideBuddyListManager_AddBuddy_Call) Run(run func(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName)) *mockClientSideBuddyListManager_AddBuddy_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		var arg0 context.Context
-		if args[0] != nil {
-			arg0 = args[0].(context.Context)
-		}
-		var arg1 state.IdentScreenName
-		if args[1] != nil {
-			arg1 = args[1].(state.IdentScreenName)
-		}
-		var arg2 state.IdentScreenName
-		if args[2] != nil {
-			arg2 = args[2].(state.IdentScreenName)
-		}
-		run(
-			arg0,
-			arg1,
-			arg2,
-		)
-	})
-	return _c
-}
-
-func (_c *mockClientSideBuddyListManager_AddBuddy_Call) Return(err error) *mockClientSideBuddyListManager_AddBuddy_Call {
-	_c.Call.Return(err)
-	return _c
-}
-
-func (_c *mockClientSideBuddyListManager_AddBuddy_Call) RunAndReturn(run func(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) error) *mockClientSideBuddyListManager_AddBuddy_Call {
-	_c.Call.Return(run)
-	return _c
-}

+ 40 - 103
server/icq_legacy/mock_legacy_service_test.go

@@ -1015,8 +1015,8 @@ func (_c *mockLegacyService_NotifyUserOnline_Call) RunAndReturn(run func(ctx con
 }
 
 // ProcessContactList provides a mock function for the type mockLegacyService
-func (_mock *mockLegacyService) ProcessContactList(ctx context.Context, req ContactListRequest) (*ContactListResult, error) {
-	ret := _mock.Called(ctx, req)
+func (_mock *mockLegacyService) ProcessContactList(ctx context.Context, instance *state.SessionInstance, req ContactListRequest) (*ContactListResult, error) {
+	ret := _mock.Called(ctx, instance, req)
 
 	if len(ret) == 0 {
 		panic("no return value specified for ProcessContactList")
@@ -1024,18 +1024,18 @@ func (_mock *mockLegacyService) ProcessContactList(ctx context.Context, req Cont
 
 	var r0 *ContactListResult
 	var r1 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, ContactListRequest) (*ContactListResult, error)); ok {
-		return returnFunc(ctx, req)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, ContactListRequest) (*ContactListResult, error)); ok {
+		return returnFunc(ctx, instance, req)
 	}
-	if returnFunc, ok := ret.Get(0).(func(context.Context, ContactListRequest) *ContactListResult); ok {
-		r0 = returnFunc(ctx, req)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, ContactListRequest) *ContactListResult); ok {
+		r0 = returnFunc(ctx, instance, req)
 	} else {
 		if ret.Get(0) != nil {
 			r0 = ret.Get(0).(*ContactListResult)
 		}
 	}
-	if returnFunc, ok := ret.Get(1).(func(context.Context, ContactListRequest) error); ok {
-		r1 = returnFunc(ctx, req)
+	if returnFunc, ok := ret.Get(1).(func(context.Context, *state.SessionInstance, ContactListRequest) error); ok {
+		r1 = returnFunc(ctx, instance, req)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -1049,24 +1049,30 @@ type mockLegacyService_ProcessContactList_Call struct {
 
 // ProcessContactList is a helper method to define mock.On call
 //   - ctx context.Context
+//   - instance *state.SessionInstance
 //   - req ContactListRequest
-func (_e *mockLegacyService_Expecter) ProcessContactList(ctx interface{}, req interface{}) *mockLegacyService_ProcessContactList_Call {
-	return &mockLegacyService_ProcessContactList_Call{Call: _e.mock.On("ProcessContactList", ctx, req)}
+func (_e *mockLegacyService_Expecter) ProcessContactList(ctx interface{}, instance interface{}, req interface{}) *mockLegacyService_ProcessContactList_Call {
+	return &mockLegacyService_ProcessContactList_Call{Call: _e.mock.On("ProcessContactList", ctx, instance, req)}
 }
 
-func (_c *mockLegacyService_ProcessContactList_Call) Run(run func(ctx context.Context, req ContactListRequest)) *mockLegacyService_ProcessContactList_Call {
+func (_c *mockLegacyService_ProcessContactList_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, req ContactListRequest)) *mockLegacyService_ProcessContactList_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		var arg0 context.Context
 		if args[0] != nil {
 			arg0 = args[0].(context.Context)
 		}
-		var arg1 ContactListRequest
+		var arg1 *state.SessionInstance
 		if args[1] != nil {
-			arg1 = args[1].(ContactListRequest)
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 ContactListRequest
+		if args[2] != nil {
+			arg2 = args[2].(ContactListRequest)
 		}
 		run(
 			arg0,
 			arg1,
+			arg2,
 		)
 	})
 	return _c
@@ -1077,7 +1083,7 @@ func (_c *mockLegacyService_ProcessContactList_Call) Return(contactListResult *C
 	return _c
 }
 
-func (_c *mockLegacyService_ProcessContactList_Call) RunAndReturn(run func(ctx context.Context, req ContactListRequest) (*ContactListResult, error)) *mockLegacyService_ProcessContactList_Call {
+func (_c *mockLegacyService_ProcessContactList_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, req ContactListRequest) (*ContactListResult, error)) *mockLegacyService_ProcessContactList_Call {
 	_c.Call.Return(run)
 	return _c
 }
@@ -1225,8 +1231,8 @@ func (_c *mockLegacyService_ProcessStatusChange_Call) RunAndReturn(run func(ctx
 }
 
 // ProcessUserAdd provides a mock function for the type mockLegacyService
-func (_mock *mockLegacyService) ProcessUserAdd(ctx context.Context, req UserAddRequest) (*UserAddResult, error) {
-	ret := _mock.Called(ctx, req)
+func (_mock *mockLegacyService) ProcessUserAdd(ctx context.Context, instance *state.SessionInstance, req UserAddRequest) (*UserAddResult, error) {
+	ret := _mock.Called(ctx, instance, req)
 
 	if len(ret) == 0 {
 		panic("no return value specified for ProcessUserAdd")
@@ -1234,18 +1240,18 @@ func (_mock *mockLegacyService) ProcessUserAdd(ctx context.Context, req UserAddR
 
 	var r0 *UserAddResult
 	var r1 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, UserAddRequest) (*UserAddResult, error)); ok {
-		return returnFunc(ctx, req)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, UserAddRequest) (*UserAddResult, error)); ok {
+		return returnFunc(ctx, instance, req)
 	}
-	if returnFunc, ok := ret.Get(0).(func(context.Context, UserAddRequest) *UserAddResult); ok {
-		r0 = returnFunc(ctx, req)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, UserAddRequest) *UserAddResult); ok {
+		r0 = returnFunc(ctx, instance, req)
 	} else {
 		if ret.Get(0) != nil {
 			r0 = ret.Get(0).(*UserAddResult)
 		}
 	}
-	if returnFunc, ok := ret.Get(1).(func(context.Context, UserAddRequest) error); ok {
-		r1 = returnFunc(ctx, req)
+	if returnFunc, ok := ret.Get(1).(func(context.Context, *state.SessionInstance, UserAddRequest) error); ok {
+		r1 = returnFunc(ctx, instance, req)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -1259,24 +1265,30 @@ type mockLegacyService_ProcessUserAdd_Call struct {
 
 // ProcessUserAdd is a helper method to define mock.On call
 //   - ctx context.Context
+//   - instance *state.SessionInstance
 //   - req UserAddRequest
-func (_e *mockLegacyService_Expecter) ProcessUserAdd(ctx interface{}, req interface{}) *mockLegacyService_ProcessUserAdd_Call {
-	return &mockLegacyService_ProcessUserAdd_Call{Call: _e.mock.On("ProcessUserAdd", ctx, req)}
+func (_e *mockLegacyService_Expecter) ProcessUserAdd(ctx interface{}, instance interface{}, req interface{}) *mockLegacyService_ProcessUserAdd_Call {
+	return &mockLegacyService_ProcessUserAdd_Call{Call: _e.mock.On("ProcessUserAdd", ctx, instance, req)}
 }
 
-func (_c *mockLegacyService_ProcessUserAdd_Call) Run(run func(ctx context.Context, req UserAddRequest)) *mockLegacyService_ProcessUserAdd_Call {
+func (_c *mockLegacyService_ProcessUserAdd_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, req UserAddRequest)) *mockLegacyService_ProcessUserAdd_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		var arg0 context.Context
 		if args[0] != nil {
 			arg0 = args[0].(context.Context)
 		}
-		var arg1 UserAddRequest
+		var arg1 *state.SessionInstance
 		if args[1] != nil {
-			arg1 = args[1].(UserAddRequest)
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 UserAddRequest
+		if args[2] != nil {
+			arg2 = args[2].(UserAddRequest)
 		}
 		run(
 			arg0,
 			arg1,
+			arg2,
 		)
 	})
 	return _c
@@ -1287,7 +1299,7 @@ func (_c *mockLegacyService_ProcessUserAdd_Call) Return(userAddResult *UserAddRe
 	return _c
 }
 
-func (_c *mockLegacyService_ProcessUserAdd_Call) RunAndReturn(run func(ctx context.Context, req UserAddRequest) (*UserAddResult, error)) *mockLegacyService_ProcessUserAdd_Call {
+func (_c *mockLegacyService_ProcessUserAdd_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, req UserAddRequest) (*UserAddResult, error)) *mockLegacyService_ProcessUserAdd_Call {
 	_c.Call.Return(run)
 	return _c
 }
@@ -1382,81 +1394,6 @@ func (_c *mockLegacyService_RegisterNewUser_Call) RunAndReturn(run func(ctx cont
 	return _c
 }
 
-// SaveOfflineMessage provides a mock function for the type mockLegacyService
-func (_mock *mockLegacyService) SaveOfflineMessage(ctx context.Context, fromUIN uint32, toUIN uint32, msgType uint16, message string) error {
-	ret := _mock.Called(ctx, fromUIN, toUIN, msgType, message)
-
-	if len(ret) == 0 {
-		panic("no return value specified for SaveOfflineMessage")
-	}
-
-	var r0 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, uint32, uint32, uint16, string) error); ok {
-		r0 = returnFunc(ctx, fromUIN, toUIN, msgType, message)
-	} else {
-		r0 = ret.Error(0)
-	}
-	return r0
-}
-
-// mockLegacyService_SaveOfflineMessage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveOfflineMessage'
-type mockLegacyService_SaveOfflineMessage_Call struct {
-	*mock.Call
-}
-
-// SaveOfflineMessage is a helper method to define mock.On call
-//   - ctx context.Context
-//   - fromUIN uint32
-//   - toUIN uint32
-//   - msgType uint16
-//   - message string
-func (_e *mockLegacyService_Expecter) SaveOfflineMessage(ctx interface{}, fromUIN interface{}, toUIN interface{}, msgType interface{}, message interface{}) *mockLegacyService_SaveOfflineMessage_Call {
-	return &mockLegacyService_SaveOfflineMessage_Call{Call: _e.mock.On("SaveOfflineMessage", ctx, fromUIN, toUIN, msgType, message)}
-}
-
-func (_c *mockLegacyService_SaveOfflineMessage_Call) Run(run func(ctx context.Context, fromUIN uint32, toUIN uint32, msgType uint16, message string)) *mockLegacyService_SaveOfflineMessage_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		var arg0 context.Context
-		if args[0] != nil {
-			arg0 = args[0].(context.Context)
-		}
-		var arg1 uint32
-		if args[1] != nil {
-			arg1 = args[1].(uint32)
-		}
-		var arg2 uint32
-		if args[2] != nil {
-			arg2 = args[2].(uint32)
-		}
-		var arg3 uint16
-		if args[3] != nil {
-			arg3 = args[3].(uint16)
-		}
-		var arg4 string
-		if args[4] != nil {
-			arg4 = args[4].(string)
-		}
-		run(
-			arg0,
-			arg1,
-			arg2,
-			arg3,
-			arg4,
-		)
-	})
-	return _c
-}
-
-func (_c *mockLegacyService_SaveOfflineMessage_Call) Return(err error) *mockLegacyService_SaveOfflineMessage_Call {
-	_c.Call.Return(err)
-	return _c
-}
-
-func (_c *mockLegacyService_SaveOfflineMessage_Call) RunAndReturn(run func(ctx context.Context, fromUIN uint32, toUIN uint32, msgType uint16, message string) error) *mockLegacyService_SaveOfflineMessage_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
 // SearchByName provides a mock function for the type mockLegacyService
 func (_mock *mockLegacyService) SearchByName(ctx context.Context, nick string, first string, last string, email string) ([]LegacyUserSearchResult, error) {
 	ret := _mock.Called(ctx, nick, first, last, email)

+ 12 - 8
server/icq_legacy/property_test.go

@@ -8,6 +8,8 @@ import (
 	"testing/quick"
 
 	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+	"github.com/stretchr/testify/mock"
 )
 
 // TestProperty_UINScreenNameRoundTrip verifies Property 8: UIN↔ScreenName round trip.
@@ -52,15 +54,17 @@ func TestProperty_ServiceBehavioralEquivalence(t *testing.T) {
 		// Generate a non-empty password from the random byte
 		password := string([]byte{'a' + passByte%26})
 
-		// Create a service with a mock UserManager that always returns ErrNoUser
-		userManager := newMockUserManager(t)
-		screenName := state.NewIdentScreenName(strconv.FormatUint(uint64(uin), 10))
-		userManager.EXPECT().
-			User(matchContext(), screenName).
-			Return(nil, state.ErrNoUser)
+		authSvc := newMockAuthService(t)
+		authSvc.EXPECT().FLAPLogin(mock.Anything, mock.Anything, "").
+			Return(wire.TLVRestBlock{
+				TLVList: []wire.TLV{
+					wire.NewTLVBE(wire.LoginTLVTagsErrorSubcode, wire.LoginErrICQUserErr),
+				},
+			}, nil)
 
 		svc := NewICQLegacyService(
-			userManager,
+			authSvc,
+			newMockUserManager(t),
 			newMockAccountManager(t),
 			newMockSessionRetriever(t),
 			newMockMessageRelayer(t),
@@ -71,7 +75,7 @@ func TestProperty_ServiceBehavioralEquivalence(t *testing.T) {
 			newMockFeedbagManager(t),
 			newMockRelationshipFetcher(t),
 			newMockBuddyListRegistry(t),
-			newMockClientSideBuddyListManager(t),
+			newMockBuddyService(t),
 			newMockICBMService(t),
 			slog.Default(),
 		)

+ 118 - 365
server/icq_legacy/service.go

@@ -27,21 +27,22 @@ import (
 // parsing protocol-specific packets into request structs and building
 // protocol-specific response packets from the returned result structs.
 type ICQLegacyService struct {
-	userManager                UserManager
-	accountManager             AccountManager
-	sessionRetriever           SessionRetriever
-	messageRelayer             MessageRelayer
-	buddyBroadcaster           BuddyBroadcaster
-	offlineMessageManager      OfflineMessageManager
-	icbmService                ICBMService
-	userFinder                 ICQUserFinder
-	userUpdater                ICQUserUpdater
-	feedbagManager             FeedbagManager
-	relationshipFetcher        RelationshipFetcher
-	buddyListRegistry          BuddyListRegistry
-	clientSideBuddyListManager ClientSideBuddyListManager
-	logger                     *slog.Logger
-	timeNow                    func() time.Time
+	authService           AuthService
+	userManager           UserManager
+	accountManager        AccountManager
+	sessionRetriever      SessionRetriever
+	messageRelayer        MessageRelayer
+	buddyBroadcaster      BuddyBroadcaster
+	offlineMessageManager OfflineMessageManager
+	icbmService           ICBMService
+	userFinder            ICQUserFinder
+	userUpdater           ICQUserUpdater
+	feedbagManager        FeedbagManager
+	relationshipFetcher   RelationshipFetcher
+	buddyListRegistry     BuddyListRegistry
+	buddyService          BuddyService
+	logger                *slog.Logger
+	timeNow               func() time.Time
 
 	// legacySessionManager is set by the server package
 	legacySessionManager *LegacySessionManager
@@ -51,6 +52,7 @@ type ICQLegacyService struct {
 // The legacy session manager must be set separately via SetLegacySessionManager
 // after the server package initializes it, to avoid circular dependencies.
 func NewICQLegacyService(
+	authService AuthService,
 	userManager UserManager,
 	accountManager AccountManager,
 	sessionRetriever SessionRetriever,
@@ -62,26 +64,27 @@ func NewICQLegacyService(
 	feedbagManager FeedbagManager,
 	relationshipFetcher RelationshipFetcher,
 	buddyListRegistry BuddyListRegistry,
-	clientSideBuddyListManager ClientSideBuddyListManager,
+	buddyService BuddyService,
 	icbmSvc ICBMService,
 	logger *slog.Logger,
 ) *ICQLegacyService {
 	return &ICQLegacyService{
-		userManager:                userManager,
-		accountManager:             accountManager,
-		sessionRetriever:           sessionRetriever,
-		messageRelayer:             messageRelayer,
-		buddyBroadcaster:           buddyBroadcaster,
-		offlineMessageManager:      offlineMessageManager,
-		userFinder:                 userFinder,
-		userUpdater:                userUpdater,
-		feedbagManager:             feedbagManager,
-		relationshipFetcher:        relationshipFetcher,
-		buddyListRegistry:          buddyListRegistry,
-		clientSideBuddyListManager: clientSideBuddyListManager,
-		icbmService:                icbmSvc,
-		logger:                     logger,
-		timeNow:                    time.Now,
+		authService:           authService,
+		userManager:           userManager,
+		accountManager:        accountManager,
+		sessionRetriever:      sessionRetriever,
+		messageRelayer:        messageRelayer,
+		buddyBroadcaster:      buddyBroadcaster,
+		offlineMessageManager: offlineMessageManager,
+		userFinder:            userFinder,
+		userUpdater:           userUpdater,
+		feedbagManager:        feedbagManager,
+		relationshipFetcher:   relationshipFetcher,
+		buddyListRegistry:     buddyListRegistry,
+		buddyService:          buddyService,
+		icbmService:           icbmSvc,
+		logger:                logger,
+		timeNow:               time.Now,
 	}
 }
 
@@ -95,34 +98,21 @@ func (s *ICQLegacyService) SetLegacySessionManager(mgr *LegacySessionManager) {
 
 // ValidateCredentials checks if the given UIN and password are valid.
 // Returns true if credentials are valid, false otherwise.
-// The password is validated using the same StrongMD5 hash method as OSCAR.
+// The password is validated by AuthService without registering a BOS session.
 func (s *ICQLegacyService) ValidateCredentials(ctx context.Context, uin uint32, password string) (bool, error) {
-	screenName := state.NewIdentScreenName(strconv.FormatUint(uint64(uin), 10))
-
-	user, err := s.userManager.User(ctx, screenName)
+	if uin == 0 {
+		return false, nil
+	}
+	block, err := s.legacyFLAPLogin(ctx, uin, password)
 	if err != nil {
 		if errors.Is(err, state.ErrNoUser) {
 			return false, nil
 		}
-		return false, fmt.Errorf("looking up user: %w", err)
-	}
-
-	// User not found
-	if user == nil {
-		return false, nil
-	}
-
-	// For legacy ICQ, we do a simple password comparison
-	// The password is stored as a hash, but legacy clients send plaintext
-	if user.StrongMD5Pass == nil {
-		s.logger.Debug("user has no password hash", "uin", uin)
-		return false, nil
+		return false, fmt.Errorf("AuthService.FLAPLogin: %w", err)
 	}
 
-	// Validate password using the same hash method as OSCAR
-	expectedHash := wire.StrongMD5PasswordHash(password, user.AuthKey)
-	if !user.ValidateHash(expectedHash) {
-		s.logger.Debug("password validation failed", "uin", uin)
+	if block.HasTag(wire.LoginTLVTagsErrorSubcode) {
+		s.logger.Debug("credentials validation failed", "uin", uin)
 		return false, nil
 	}
 
@@ -151,37 +141,49 @@ func (s *ICQLegacyService) AuthenticateUser(ctx context.Context, req AuthRequest
 		return result, nil
 	}
 
-	// Look up the user
-	screenName := state.NewIdentScreenName(strconv.FormatUint(uint64(req.UIN), 10))
-	user, err := s.userManager.User(ctx, screenName)
+	block, err := s.legacyFLAPLogin(ctx, req.UIN, req.Password)
 	if err != nil {
 		if errors.Is(err, state.ErrNoUser) {
 			s.logger.Debug("authentication failed - user not found", "uin", req.UIN)
 			result.ErrorCode = 0x0002 // User not found
 			return result, nil
 		}
-		return nil, fmt.Errorf("looking up user: %w", err)
+		return nil, fmt.Errorf("AuthService.FLAPLogin: %w", err)
 	}
 
-	// User not found
-	if user == nil {
-		s.logger.Debug("authentication failed - user not found", "uin", req.UIN)
-		result.ErrorCode = 0x0002 // User not found
+	if errCode, ok := block.Uint16BE(wire.LoginTLVTagsErrorSubcode); ok {
+		s.logger.Debug("authentication failed",
+			"uin", req.UIN,
+			"error_code", fmt.Sprintf("0x%04X", errCode),
+		)
+		if errCode == wire.LoginErrICQUserErr {
+			result.ErrorCode = 0x0002 // User not found
+		}
 		return result, nil
 	}
 
-	// Check if user has a password hash
-	if user.StrongMD5Pass == nil {
-		s.logger.Debug("authentication failed - user has no password hash", "uin", req.UIN)
-		result.ErrorCode = 0x0001 // Bad password
-		return result, nil
+	authCookie, ok := block.Bytes(wire.LoginTLVTagsAuthorizationCookie)
+	if !ok {
+		return nil, fmt.Errorf("authorization cookie missing from auth response")
 	}
 
-	// Validate password using the same hash method as OSCAR
-	expectedHash := wire.StrongMD5PasswordHash(req.Password, user.AuthKey)
-	if !user.ValidateHash(expectedHash) {
-		s.logger.Debug("authentication failed - invalid password", "uin", req.UIN)
-		result.ErrorCode = 0x0001 // Bad password
+	serverCookie, err := s.authService.CrackCookie(authCookie)
+	if err != nil {
+		return nil, fmt.Errorf("AuthService.CrackCookie: %w", err)
+	}
+
+	instance, err := s.authService.RegisterBOSSession(ctx, serverCookie, func(sess *state.Session) {
+		sess.OnSessionClose(func() {
+			if s.legacySessionManager != nil {
+				s.legacySessionManager.RemoveSession(req.UIN)
+			}
+		})
+	})
+	if err != nil {
+		return nil, fmt.Errorf("AuthService.RegisterBOSSession: %w", err)
+	}
+	if instance == nil {
+		s.logger.Debug("authentication failed - missing OSCAR session", "uin", req.UIN)
 		return result, nil
 	}
 
@@ -189,6 +191,7 @@ func (s *ICQLegacyService) AuthenticateUser(ctx context.Context, req AuthRequest
 	result.Success = true
 	result.ErrorCode = 0 // Success
 	result.SessionID = uuid.New().String()
+	result.oscarSession = instance
 
 	s.logger.Info("user authenticated successfully",
 		"uin", req.UIN,
@@ -199,6 +202,14 @@ func (s *ICQLegacyService) AuthenticateUser(ctx context.Context, req AuthRequest
 	return result, nil
 }
 
+func (s *ICQLegacyService) legacyFLAPLogin(ctx context.Context, uin uint32, password string) (wire.TLVRestBlock, error) {
+	screenName := strconv.FormatUint(uint64(uin), 10)
+	signonFrame := wire.FLAPSignonFrame{}
+	signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsScreenName, screenName))
+	signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsPlaintextPassword, password))
+	return s.authService.FLAPLogin(ctx, signonFrame, "")
+}
+
 // ProcessContactList processes a contact list and returns online status for each contact.
 // This is the service layer method for contact list processing that handlers call after
 // parsing contact list packets. It checks the online status of each contact and returns
@@ -209,7 +220,7 @@ func (s *ICQLegacyService) AuthenticateUser(ctx context.Context, req AuthRequest
 // - Parsing protocol-specific contact list packets into ContactListRequest
 // - Using the returned ContactListResult to send online/offline notifications
 // - Building protocol-specific response packets
-func (s *ICQLegacyService) ProcessContactList(ctx context.Context, req ContactListRequest) (*ContactListResult, error) {
+func (s *ICQLegacyService) ProcessContactList(ctx context.Context, instance *state.SessionInstance, req ContactListRequest) (*ContactListResult, error) {
 	result := &ContactListResult{
 		OnlineContacts: make([]ContactStatus, 0, len(req.Contacts)),
 	}
@@ -234,35 +245,42 @@ func (s *ICQLegacyService) ProcessContactList(ctx context.Context, req ContactLi
 	ownerScreenName := state.NewIdentScreenName(strconv.FormatUint(uint64(req.UIN), 10))
 	for _, contactUIN := range req.Contacts {
 		contactName := state.NewIdentScreenName(strconv.FormatUint(uint64(contactUIN), 10))
-		// Forward: owner has contact on their list
-		if err := s.clientSideBuddyListManager.AddBuddy(ctx, ownerScreenName, contactName); err != nil {
-			s.logger.Error("ProcessContactList: failed to add buddy to client-side list",
+		// Forward: owner has contact on their list.
+		snac := wire.SNAC_0x03_0x04_BuddyAddBuddies{}
+		snac.Buddies = append(snac.Buddies, struct {
+			ScreenName string `oscar:"len_prefix=uint8"`
+		}{ScreenName: contactName.String()})
+		if _, err := s.buddyService.AddBuddies(ctx, instance, wire.SNACFrame{}, snac); err != nil {
+			s.logger.Error("ProcessContactList: failed to add buddy via BuddyService",
 				"owner_uin", req.UIN,
 				"contact_uin", contactUIN,
 				"err", err,
 			)
 		}
+
 		// Reverse: contact has owner on their list (so OSCAR clients see the legacy user).
 		// Only add if the contact does NOT require authorization — otherwise the
 		// legacy user must go through the auth request flow first.
 		contactUser, userErr := s.userFinder.FindByUIN(ctx, contactUIN)
-		if userErr == nil && !contactUser.ICQPermissions.AuthRequired {
-			if err := s.clientSideBuddyListManager.AddBuddy(ctx, contactName, ownerScreenName); err != nil {
-				s.logger.Error("ProcessContactList: failed to add reverse buddy entry",
-					"owner_uin", req.UIN,
-					"contact_uin", contactUIN,
-					"err", err,
-				)
-			}
-		} else if userErr != nil {
-			// User not found or error — add reverse entry anyway (permissive default)
-			if err := s.clientSideBuddyListManager.AddBuddy(ctx, contactName, ownerScreenName); err != nil {
-				s.logger.Error("ProcessContactList: failed to add reverse buddy entry",
-					"owner_uin", req.UIN,
-					"contact_uin", contactUIN,
-					"err", err,
-				)
-			}
+		if userErr == nil && contactUser.ICQPermissions.AuthRequired {
+			continue
+		}
+		contactSession := state.NewSession()
+		contactSession.SetUIN(contactUIN)
+		contactSession.SetDisplayScreenName(state.DisplayScreenName(contactName.String()))
+		contactSession.SetIdentScreenName(contactName)
+		contactInstance := contactSession.AddInstance()
+
+		snac = wire.SNAC_0x03_0x04_BuddyAddBuddies{}
+		snac.Buddies = append(snac.Buddies, struct {
+			ScreenName string `oscar:"len_prefix=uint8"`
+		}{ScreenName: ownerScreenName.String()})
+		if _, err := s.buddyService.AddBuddies(ctx, contactInstance, wire.SNACFrame{}, snac); err != nil {
+			s.logger.Error("ProcessContactList: failed to add reverse buddy entry via BuddyService",
+				"owner_uin", req.UIN,
+				"contact_uin", contactUIN,
+				"err", err,
+			)
 		}
 	}
 
@@ -352,7 +370,7 @@ func countOnlineContacts(contacts []ContactStatus) int {
 // From iserverd v3_process_useradd() and v5_process_useradd() - when a user adds
 // someone to their contact list, the server checks if the target is online and
 // optionally sends a "you were added" notification to the target.
-func (s *ICQLegacyService) ProcessUserAdd(ctx context.Context, req UserAddRequest) (*UserAddResult, error) {
+func (s *ICQLegacyService) ProcessUserAdd(ctx context.Context, instance *state.SessionInstance, req UserAddRequest) (*UserAddResult, error) {
 	result := &UserAddResult{
 		TargetOnline:     false,
 		TargetStatus:     0,
@@ -377,10 +395,13 @@ func (s *ICQLegacyService) ProcessUserAdd(ctx context.Context, req UserAddReques
 
 	// Sync to clientSideBuddyList so OSCAR's BuddyArrived/Departed
 	// notifications reach this legacy user for the newly added contact.
-	ownerScreenName := state.NewIdentScreenName(strconv.FormatUint(uint64(req.FromUIN), 10))
 	contactName := state.NewIdentScreenName(strconv.FormatUint(uint64(req.TargetUIN), 10))
-	if err := s.clientSideBuddyListManager.AddBuddy(ctx, ownerScreenName, contactName); err != nil {
-		s.logger.Error("ProcessUserAdd: failed to add buddy to client-side list",
+	snac := wire.SNAC_0x03_0x04_BuddyAddBuddies{}
+	snac.Buddies = append(snac.Buddies, struct {
+		ScreenName string `oscar:"len_prefix=uint8"`
+	}{ScreenName: contactName.String()})
+	if _, err := s.buddyService.AddBuddies(ctx, instance, wire.SNACFrame{}, snac); err != nil {
+		s.logger.Error("ProcessUserAdd: failed to add buddy via BuddyService",
 			"from_uin", req.FromUIN,
 			"target_uin", req.TargetUIN,
 			"err", err,
@@ -613,263 +634,6 @@ func (s *ICQLegacyService) generateNewUIN(ctx context.Context) (uint32, error) {
 	return 0, errors.New("no available UINs")
 }
 
-// sendToOSCARClient sends a message to an OSCAR client
-func (s *ICQLegacyService) sendToOSCARClient(ctx context.Context, from, to state.IdentScreenName, msgType uint16, message string) error {
-	// Strip trailing null bytes from legacy messages
-	message = strings.TrimRight(message, "\x00")
-
-	// Messages that use FE-delimited format (URLs, contacts) are sent on
-	// ICBM Channel 4 with their original message type preserved.
-	// OSCAR ICQ clients handle these natively on Channel 4.
-	if msgType == ICQLegacyMsgURL || msgType == ICQLegacyMsgContacts {
-		fromUINVal, _ := strconv.ParseUint(from.String(), 10, 32)
-		var urlSenderInfo wire.TLVUserInfo
-		if s.legacySessionManager != nil {
-			session := s.legacySessionManager.GetSession(uint32(fromUINVal))
-			if session != nil && session.Instance != nil {
-				urlSenderInfo = session.Instance.Session().TLVUserInfo()
-			}
-		}
-		if urlSenderInfo.ScreenName == "" {
-			urlSenderInfo = wire.TLVUserInfo{ScreenName: from.String()}
-		}
-		ch4Msg := wire.ICBMCh4Message{
-			UIN:         uint32(fromUINVal),
-			MessageType: uint8(ICQLegacyMsgURL),
-			Message:     message,
-		}
-		s.messageRelayer.RelayToScreenName(ctx, to, wire.SNACMessage{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.ICBM,
-				SubGroup:  wire.ICBMChannelMsgToClient,
-				RequestID: wire.ReqIDFromServer,
-			},
-			Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
-				Cookie:      generateMessageCookie(),
-				ChannelID:   wire.ICBMChannelICQ,
-				TLVUserInfo: urlSenderInfo,
-				TLVRestBlock: wire.TLVRestBlock{
-					TLVList: wire.TLVList{
-						wire.NewTLVLE(wire.ICBMTLVData, ch4Msg),
-						wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
-					},
-				},
-			},
-		})
-		return nil
-	}
-
-	// Auth grant/deny: send as ICBM Channel 4 (same as RespondAuthorizeToHost).
-	// ICQ 2003b handles auth responses on Channel 4 correctly — it shows the
-	// message and updates the pending flag. Sending as FeedbagRespondAuthorizeToClient
-	// (0x13,0x1B) causes the client to remove the pending flag on deny, preventing
-	// re-request authorization.
-	switch msgType {
-	case ICQLegacyMsgAuthGrant, ICQLegacyMsgAuthDeny:
-		fromUIN, _ := strconv.ParseUint(from.String(), 10, 32)
-		reasonText := strings.TrimRight(message, "\x00")
-
-		var senderInfo wire.TLVUserInfo
-		if s.legacySessionManager != nil {
-			session := s.legacySessionManager.GetSession(uint32(fromUIN))
-			if session != nil && session.Instance != nil {
-				senderInfo = session.Instance.Session().TLVUserInfo()
-			}
-		}
-		if senderInfo.ScreenName == "" {
-			senderInfo = wire.TLVUserInfo{ScreenName: from.String()}
-		}
-
-		ch4Msg := wire.ICBMCh4Message{
-			UIN:         uint32(fromUIN),
-			MessageType: uint8(msgType),
-			Message:     reasonText,
-		}
-		s.messageRelayer.RelayToScreenName(ctx, to, wire.SNACMessage{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.ICBM,
-				SubGroup:  wire.ICBMChannelMsgToClient,
-				RequestID: wire.ReqIDFromServer,
-			},
-			Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
-				Cookie:      generateMessageCookie(),
-				ChannelID:   wire.ICBMChannelICQ,
-				TLVUserInfo: senderInfo,
-				TLVRestBlock: wire.TLVRestBlock{
-					TLVList: wire.TLVList{
-						wire.NewTLVLE(wire.ICBMTLVData, ch4Msg),
-						wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
-					},
-				},
-			},
-		})
-		// For auth grant, also send FeedbagRespondAuthorizeToClient so the
-		// OSCAR client removes the pending flag from the feedbag item.
-		// For deny, only ICBM is sent — this preserves the pending flag so
-		// the user can re-request authorization.
-		if msgType == ICQLegacyMsgAuthGrant {
-			s.messageRelayer.RelayToScreenName(ctx, to, wire.SNACMessage{
-				Frame: wire.SNACFrame{
-					FoodGroup: wire.Feedbag,
-					SubGroup:  wire.FeedbagRespondAuthorizeToClient,
-				},
-				Body: wire.SNAC_0x13_0x1B_FeedbagRespondAuthorizeToClient{
-					ScreenName: from.String(),
-					Accepted:   1,
-				},
-			})
-		}
-		return nil
-
-	case ICQLegacyMsgAuthReq:
-		// Send as FeedbagRequestAuthorizeToClient so OSCAR client shows auth dialog
-		reasonText := ""
-		parts := strings.Split(message, "\xFE")
-		if len(parts) >= 6 {
-			reasonText = parts[5]
-		}
-		s.messageRelayer.RelayToScreenName(ctx, to, wire.SNACMessage{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.Feedbag,
-				SubGroup:  wire.FeedbagRequestAuthorizeToClient,
-			},
-			Body: wire.SNAC_0x13_0x18_FeedbagRequestAuthorizationToHost{
-				ScreenName: from.String(),
-				Reason:     reasonText,
-			},
-		})
-		return nil
-
-	case ICQLegacyMsgAdded:
-		fromUIN, _ := strconv.ParseUint(from.String(), 10, 32)
-
-		// Populate TLVUserInfo from the sender's OSCAR session if available,
-		// otherwise build a minimal one with ICQ flags
-		var senderInfo wire.TLVUserInfo
-		if s.legacySessionManager != nil {
-			session := s.legacySessionManager.GetSession(uint32(fromUIN))
-			if session != nil && session.Instance != nil {
-				senderInfo = session.Instance.Session().TLVUserInfo()
-			}
-		}
-		if senderInfo.ScreenName == "" {
-			senderInfo = wire.TLVUserInfo{ScreenName: from.String()}
-		}
-
-		ch4Msg := wire.ICBMCh4Message{
-			UIN:         uint32(fromUIN),
-			MessageType: uint8(msgType),
-			Message:     "",
-		}
-		s.messageRelayer.RelayToScreenName(ctx, to, wire.SNACMessage{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.ICBM,
-				SubGroup:  wire.ICBMChannelMsgToClient,
-				RequestID: wire.ReqIDFromServer,
-			},
-			Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
-				Cookie:      generateMessageCookie(),
-				ChannelID:   wire.ICBMChannelICQ,
-				TLVUserInfo: senderInfo,
-				TLVRestBlock: wire.TLVRestBlock{
-					TLVList: wire.TLVList{
-						wire.NewTLVLE(wire.ICBMTLVData, ch4Msg),
-						wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
-					},
-				},
-			},
-		})
-		return nil
-	}
-
-	// Regular text messages use ICBM Channel 1
-	frags, err := wire.ICBMFragmentList(message)
-	if err != nil {
-		return fmt.Errorf("creating ICBM fragments: %w", err)
-	}
-
-	// Use sender's session TLVUserInfo if available
-	var senderInfo wire.TLVUserInfo
-	fromUIN, _ := strconv.ParseUint(from.String(), 10, 32)
-	if s.legacySessionManager != nil {
-		session := s.legacySessionManager.GetSession(uint32(fromUIN))
-		if session != nil && session.Instance != nil {
-			senderInfo = session.Instance.Session().TLVUserInfo()
-		}
-	}
-	if senderInfo.ScreenName == "" {
-		senderInfo = wire.TLVUserInfo{ScreenName: from.String()}
-	}
-
-	icbmMsg := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ICBM,
-			SubGroup:  wire.ICBMChannelMsgToClient,
-			RequestID: wire.ReqIDFromServer,
-		},
-		Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
-			Cookie:      generateMessageCookie(),
-			ChannelID:   wire.ICBMChannelIM,
-			TLVUserInfo: senderInfo,
-			TLVRestBlock: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					wire.NewTLVBE(wire.ICBMTLVAOLIMData, frags),
-				},
-			},
-		},
-	}
-
-	s.messageRelayer.RelayToScreenName(ctx, to, icbmMsg)
-	return nil
-}
-
-// storeOfflineMessage stores a message for offline delivery
-func (s *ICQLegacyService) storeOfflineMessage(ctx context.Context, from, to state.IdentScreenName, msgType uint16, message string) error {
-	// Create ICBM fragment list for the message
-	frags, err := wire.ICBMFragmentList(message)
-	if err != nil {
-		return fmt.Errorf("creating ICBM fragments: %w", err)
-	}
-
-	// Store the legacy message type in ICBMTLVICQBlob so it can be
-	// recovered when delivering the message to a legacy client.
-	// Without this, all offline messages are delivered as type 0x0001
-	// (normal text), which causes auth requests and "you were added"
-	// notifications to display as garbage on the receiving client.
-	msgTypeBytes := make([]byte, 2)
-	msgTypeBytes[0] = byte(msgType)
-	msgTypeBytes[1] = byte(msgType >> 8)
-
-	offlineMsg := state.OfflineMessage{
-		Sender:    from,
-		Recipient: to,
-		Sent:      s.timeNow(),
-		Message: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
-			Cookie:    generateMessageCookie(),
-			ChannelID: wire.ICBMChannelIM,
-			TLVRestBlock: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					wire.NewTLVBE(wire.ICBMTLVAOLIMData, frags),
-					wire.NewTLVBE(wire.ICBMTLVICQBlob, msgTypeBytes),
-				},
-			},
-		},
-	}
-
-	_, err = s.offlineMessageManager.SaveMessage(ctx, offlineMsg)
-	if err != nil {
-		return fmt.Errorf("saving offline message: %w", err)
-	}
-
-	s.logger.Debug("stored offline message",
-		"from", from.String(),
-		"to", to.String(),
-		"msg_type", fmt.Sprintf("0x%04X", msgType),
-	)
-
-	return nil
-}
-
 // GetOfflineMessages retrieves stored offline messages for the given UIN.
 // Messages are converted from the internal OSCAR ICBM format to the legacy
 // LegacyOfflineMessage format suitable for delivery by protocol handlers.
@@ -1035,7 +799,7 @@ func (s *ICQLegacyService) GetUserInfoForProtocol(ctx context.Context, targetUIN
 		Position:   user.ICQWorkInfo.Position,
 
 		// Auth required
-		AuthRequired: 0,
+		AuthRequired: 1,
 	}
 
 	// Use UIN as fallback nickname if not set
@@ -1045,7 +809,7 @@ func (s *ICQLegacyService) GetUserInfoForProtocol(ctx context.Context, targetUIN
 
 	// Set auth required flag
 	if user.ICQPermissions.AuthRequired {
-		result.AuthRequired = 1
+		result.AuthRequired = 0
 	}
 
 	// Check if user is online
@@ -1135,7 +899,6 @@ func (s *ICQLegacyService) SearchByName(ctx context.Context, nick, first, last,
 // Results are limited to 40 users maximum, matching iserverd behavior.
 func (s *ICQLegacyService) WhitePagesSearch(ctx context.Context, criteria WhitePagesSearchCriteria) ([]LegacyUserSearchResult, error) {
 	var allResults []state.User
-	var err error
 
 	// Start with basic name/email search if those criteria are provided
 	// This is the primary search method available in the current infrastructure
@@ -1180,10 +943,6 @@ func (s *ICQLegacyService) WhitePagesSearch(ctx context.Context, criteria WhiteP
 		}
 	}
 
-	if err != nil {
-		return nil, err
-	}
-
 	// Filter results based on additional criteria
 	filteredResults := s.filterWhitePagesResults(allResults, criteria)
 
@@ -2112,9 +1871,3 @@ func mapOSCARStatusToLegacy(oscarStatus uint32) uint32 {
 
 	return legacyStatus
 }
-
-// generateMessageCookie generates a unique message cookie for ICBM messages
-// using the current nanosecond timestamp.
-func generateMessageCookie() uint64 {
-	return uint64(time.Now().UnixNano())
-}

+ 80 - 71
server/icq_legacy/service_test.go

@@ -14,13 +14,20 @@ import (
 )
 
 func TestICQLegacyService_AuthenticateUser(t *testing.T) {
-	authKey := "test-auth-key"
 	password := "secret123"
-	passHash := wire.StrongMD5PasswordHash(password, authKey)
+	authCookie := []byte("auth-cookie")
+	screenName := state.DisplayScreenName("12345")
+	serverCookie := state.ServerCookie{ScreenName: screenName}
+	oscarSession := newTestOSCARInstance(screenName)
+	successBlock := wire.TLVRestBlock{
+		TLVList: []wire.TLV{
+			wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, authCookie),
+		},
+	}
 
 	tests := []struct {
 		name       string
-		mockParams mockParams
+		setupAuth  func(*mockAuthService)
 		req        AuthRequest
 		wantResult *AuthResult
 		wantErr    error
@@ -32,18 +39,13 @@ func TestICQLegacyService_AuthenticateUser(t *testing.T) {
 				Password: password,
 				Version:  ICQLegacyVersionV5,
 			},
-			mockParams: mockParams{
-				userManagerParams: userManagerParams{
-					userParams: userParams{
-						{
-							screenName: state.NewIdentScreenName("12345"),
-							result: &state.User{
-								AuthKey:       authKey,
-								StrongMD5Pass: passHash,
-							},
-						},
-					},
-				},
+			setupAuth: func(authSvc *mockAuthService) {
+				authSvc.EXPECT().FLAPLogin(mock.Anything, mock.Anything, "").
+					Return(successBlock, nil)
+				authSvc.EXPECT().CrackCookie(authCookie).
+					Return(serverCookie, nil)
+				authSvc.EXPECT().RegisterBOSSession(mock.Anything, serverCookie, mock.Anything).
+					Return(oscarSession, nil)
 			},
 			wantResult: &AuthResult{
 				Success:   true,
@@ -57,18 +59,13 @@ func TestICQLegacyService_AuthenticateUser(t *testing.T) {
 				Password: "wrongpass",
 				Version:  ICQLegacyVersionV5,
 			},
-			mockParams: mockParams{
-				userManagerParams: userManagerParams{
-					userParams: userParams{
-						{
-							screenName: state.NewIdentScreenName("12345"),
-							result: &state.User{
-								AuthKey:       authKey,
-								StrongMD5Pass: passHash,
-							},
+			setupAuth: func(authSvc *mockAuthService) {
+				authSvc.EXPECT().FLAPLogin(mock.Anything, mock.Anything, "").
+					Return(wire.TLVRestBlock{
+						TLVList: []wire.TLV{
+							wire.NewTLVBE(wire.LoginTLVTagsErrorSubcode, wire.LoginErrInvalidPassword),
 						},
-					},
-				},
+					}, nil)
 			},
 			wantResult: &AuthResult{
 				Success:   false,
@@ -82,16 +79,13 @@ func TestICQLegacyService_AuthenticateUser(t *testing.T) {
 				Password: password,
 				Version:  ICQLegacyVersionV5,
 			},
-			mockParams: mockParams{
-				userManagerParams: userManagerParams{
-					userParams: userParams{
-						{
-							screenName: state.NewIdentScreenName("99999"),
-							result:     nil,
-							err:        state.ErrNoUser,
+			setupAuth: func(authSvc *mockAuthService) {
+				authSvc.EXPECT().FLAPLogin(mock.Anything, mock.Anything, "").
+					Return(wire.TLVRestBlock{
+						TLVList: []wire.TLV{
+							wire.NewTLVBE(wire.LoginTLVTagsErrorSubcode, wire.LoginErrICQUserErr),
 						},
-					},
-				},
+					}, nil)
 			},
 			wantResult: &AuthResult{
 				Success:   false,
@@ -116,18 +110,13 @@ func TestICQLegacyService_AuthenticateUser(t *testing.T) {
 				Password: password,
 				Version:  ICQLegacyVersionV5,
 			},
-			mockParams: mockParams{
-				userManagerParams: userManagerParams{
-					userParams: userParams{
-						{
-							screenName: state.NewIdentScreenName("12345"),
-							result: &state.User{
-								AuthKey:       authKey,
-								StrongMD5Pass: nil,
-							},
+			setupAuth: func(authSvc *mockAuthService) {
+				authSvc.EXPECT().FLAPLogin(mock.Anything, mock.Anything, "").
+					Return(wire.TLVRestBlock{
+						TLVList: []wire.TLV{
+							wire.NewTLVBE(wire.LoginTLVTagsErrorSubcode, wire.LoginErrInvalidPassword),
 						},
-					},
-				},
+					}, nil)
 			},
 			wantResult: &AuthResult{
 				Success:   false,
@@ -138,15 +127,14 @@ func TestICQLegacyService_AuthenticateUser(t *testing.T) {
 
 	for _, tc := range tests {
 		t.Run(tc.name, func(t *testing.T) {
-			userManager := newMockUserManager(t)
-			for _, p := range tc.mockParams.userParams {
-				userManager.EXPECT().
-					User(matchContext(), p.screenName).
-					Return(p.result, p.err)
+			authSvc := newMockAuthService(t)
+			if tc.setupAuth != nil {
+				tc.setupAuth(authSvc)
 			}
 
 			svc := NewICQLegacyService(
-				userManager,
+				authSvc,
+				newMockUserManager(t),
 				newMockAccountManager(t),
 				newMockSessionRetriever(t),
 				newMockMessageRelayer(t),
@@ -157,7 +145,7 @@ func TestICQLegacyService_AuthenticateUser(t *testing.T) {
 				newMockFeedbagManager(t),
 				newMockRelationshipFetcher(t),
 				newMockBuddyListRegistry(t),
-				newMockClientSideBuddyListManager(t),
+				newMockBuddyService(t),
 				newMockICBMService(t),
 				slog.Default(),
 			)
@@ -173,6 +161,7 @@ func TestICQLegacyService_AuthenticateUser(t *testing.T) {
 			assert.Equal(t, tc.wantResult.ErrorCode, got.ErrorCode)
 			if tc.wantResult.Success {
 				assert.NotEmpty(t, got.SessionID)
+				assert.Same(t, oscarSession, got.oscarSession)
 			}
 		})
 	}
@@ -348,6 +337,7 @@ func TestICQLegacyService_ProcessMessage(t *testing.T) {
 			}
 
 			svc := NewICQLegacyService(
+				newMockAuthService(t),
 				newMockUserManager(t),
 				newMockAccountManager(t),
 				newMockSessionRetriever(t),
@@ -359,7 +349,7 @@ func TestICQLegacyService_ProcessMessage(t *testing.T) {
 				newMockFeedbagManager(t),
 				newMockRelationshipFetcher(t),
 				newMockBuddyListRegistry(t),
-				newMockClientSideBuddyListManager(t),
+				newMockBuddyService(t),
 				icbmSvc,
 				slog.Default(),
 			)
@@ -445,12 +435,12 @@ func TestICQLegacyService_ProcessContactList(t *testing.T) {
 					Return(p.result)
 			}
 
-			clientSideBuddyListMgr := newMockClientSideBuddyListManager(t)
+			buddySvc := newMockBuddyService(t)
 			userFinder := newMockICQUserFinder(t)
 			// ProcessContactList adds both forward and reverse buddy entries
-			clientSideBuddyListMgr.EXPECT().
-				AddBuddy(mock.Anything, mock.Anything, mock.Anything).
-				Return(nil).
+			buddySvc.EXPECT().
+				AddBuddies(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+				Return(nil, nil).
 				Maybe()
 			userFinder.EXPECT().
 				FindByUIN(mock.Anything, mock.Anything).
@@ -458,6 +448,7 @@ func TestICQLegacyService_ProcessContactList(t *testing.T) {
 				Maybe()
 
 			svc := NewICQLegacyService(
+				newMockAuthService(t),
 				newMockUserManager(t),
 				newMockAccountManager(t),
 				sessionRetriever,
@@ -469,7 +460,7 @@ func TestICQLegacyService_ProcessContactList(t *testing.T) {
 				newMockFeedbagManager(t),
 				newMockRelationshipFetcher(t),
 				newMockBuddyListRegistry(t),
-				clientSideBuddyListMgr,
+				buddySvc,
 				newMockICBMService(t),
 				slog.Default(),
 			)
@@ -477,8 +468,12 @@ func TestICQLegacyService_ProcessContactList(t *testing.T) {
 			if tc.setupLegacyMgr != nil {
 				tc.setupLegacyMgr(t, svc)
 			}
+			var instance *state.SessionInstance
+			if tc.req.UIN != 0 {
+				instance = newTestOSCARInstance(state.DisplayScreenName(strconv.FormatUint(uint64(tc.req.UIN), 10)))
+			}
 
-			got, err := svc.ProcessContactList(context.Background(), tc.req)
+			got, err := svc.ProcessContactList(context.Background(), instance, tc.req)
 
 			assert.NoError(t, err)
 			assert.Equal(t, len(tc.wantResult.OnlineContacts), len(got.OnlineContacts))
@@ -535,6 +530,7 @@ func TestICQLegacyService_ProcessStatusChange(t *testing.T) {
 	for _, tc := range tests {
 		t.Run(tc.name, func(t *testing.T) {
 			svc := NewICQLegacyService(
+				newMockAuthService(t),
 				newMockUserManager(t),
 				newMockAccountManager(t),
 				newMockSessionRetriever(t),
@@ -546,7 +542,7 @@ func TestICQLegacyService_ProcessStatusChange(t *testing.T) {
 				newMockFeedbagManager(t),
 				newMockRelationshipFetcher(t),
 				newMockBuddyListRegistry(t),
-				newMockClientSideBuddyListManager(t),
+				newMockBuddyService(t),
 				newMockICBMService(t),
 				slog.Default(),
 			)
@@ -642,6 +638,7 @@ func TestICQLegacyService_SearchByUIN(t *testing.T) {
 			}
 
 			svc := NewICQLegacyService(
+				newMockAuthService(t),
 				newMockUserManager(t),
 				newMockAccountManager(t),
 				sessionRetriever,
@@ -653,7 +650,7 @@ func TestICQLegacyService_SearchByUIN(t *testing.T) {
 				newMockFeedbagManager(t),
 				newMockRelationshipFetcher(t),
 				newMockBuddyListRegistry(t),
-				newMockClientSideBuddyListManager(t),
+				newMockBuddyService(t),
 				newMockICBMService(t),
 				slog.Default(),
 			)
@@ -772,6 +769,7 @@ func TestICQLegacyService_SearchByName(t *testing.T) {
 			}
 
 			svc := NewICQLegacyService(
+				newMockAuthService(t),
 				newMockUserManager(t),
 				newMockAccountManager(t),
 				sessionRetriever,
@@ -783,7 +781,7 @@ func TestICQLegacyService_SearchByName(t *testing.T) {
 				newMockFeedbagManager(t),
 				newMockRelationshipFetcher(t),
 				newMockBuddyListRegistry(t),
-				newMockClientSideBuddyListManager(t),
+				newMockBuddyService(t),
 				newMockICBMService(t),
 				slog.Default(),
 			)
@@ -864,6 +862,7 @@ func TestICQLegacyService_GetOfflineMessages(t *testing.T) {
 			}
 
 			svc := NewICQLegacyService(
+				newMockAuthService(t),
 				newMockUserManager(t),
 				newMockAccountManager(t),
 				newMockSessionRetriever(t),
@@ -875,7 +874,7 @@ func TestICQLegacyService_GetOfflineMessages(t *testing.T) {
 				newMockFeedbagManager(t),
 				newMockRelationshipFetcher(t),
 				newMockBuddyListRegistry(t),
-				newMockClientSideBuddyListManager(t),
+				newMockBuddyService(t),
 				newMockICBMService(t),
 				slog.Default(),
 			)
@@ -916,6 +915,7 @@ func TestICQLegacyService_RegisterNewUser(t *testing.T) {
 				Return(nil)
 
 			svc := NewICQLegacyService(
+				newMockAuthService(t),
 				userManager,
 				newMockAccountManager(t),
 				newMockSessionRetriever(t),
@@ -927,7 +927,7 @@ func TestICQLegacyService_RegisterNewUser(t *testing.T) {
 				newMockFeedbagManager(t),
 				newMockRelationshipFetcher(t),
 				newMockBuddyListRegistry(t),
-				newMockClientSideBuddyListManager(t),
+				newMockBuddyService(t),
 				newMockICBMService(t),
 				slog.Default(),
 			)
@@ -1002,12 +1002,20 @@ func TestICQLegacyService_DeleteUser(t *testing.T) {
 
 	for _, tc := range tests {
 		t.Run(tc.name, func(t *testing.T) {
-			userManager := newMockUserManager(t)
-			for _, p := range tc.mockParams.userParams {
-				userManager.EXPECT().
-					User(matchContext(), p.screenName).
-					Return(p.result, p.err)
+			authSvc := newMockAuthService(t)
+			if tc.wantErr {
+				authSvc.EXPECT().FLAPLogin(mock.Anything, mock.Anything, "").
+					Return(wire.TLVRestBlock{
+						TLVList: []wire.TLV{
+							wire.NewTLVBE(wire.LoginTLVTagsErrorSubcode, wire.LoginErrInvalidPassword),
+						},
+					}, nil)
+			} else {
+				authSvc.EXPECT().FLAPLogin(mock.Anything, mock.Anything, "").
+					Return(wire.TLVRestBlock{}, nil)
 			}
+
+			userManager := newMockUserManager(t)
 			for _, p := range tc.mockParams.deleteUserParams {
 				userManager.EXPECT().
 					DeleteUser(matchContext(), p.screenName).
@@ -1015,6 +1023,7 @@ func TestICQLegacyService_DeleteUser(t *testing.T) {
 			}
 
 			svc := NewICQLegacyService(
+				authSvc,
 				userManager,
 				newMockAccountManager(t),
 				newMockSessionRetriever(t),
@@ -1026,7 +1035,7 @@ func TestICQLegacyService_DeleteUser(t *testing.T) {
 				newMockFeedbagManager(t),
 				newMockRelationshipFetcher(t),
 				newMockBuddyListRegistry(t),
-				newMockClientSideBuddyListManager(t),
+				newMockBuddyService(t),
 				newMockICBMService(t),
 				slog.Default(),
 			)

+ 6 - 1
server/icq_legacy/service_types.go

@@ -3,6 +3,8 @@ package icq_legacy
 import (
 	"net"
 	"time"
+
+	"github.com/mk6i/open-oscar-server/state"
 )
 
 // This file contains typed request and response structs for the ICQ legacy service layer.
@@ -105,6 +107,9 @@ type AuthResult struct {
 	// - 0x0002: User not found
 	// - 0x0003: Already logged in
 	ErrorCode uint16
+
+	// oscarSession is the BOS session registered by AuthService for full logins.
+	oscarSession *state.SessionInstance
 }
 
 // MessageResult contains message routing info from the service layer.
@@ -197,7 +202,7 @@ type UserInfoResult struct {
 	// Online indicates whether the user is currently online
 	Online bool
 
-	// AuthRequired indicates whether authorization is required to add this user (0=no, 1=yes)
+	// AuthRequired indicates whether authorization is required to add this user (0=yes, 1=no)
 	AuthRequired uint8
 
 	// Extended fields (V3/V5 protocols)

+ 80 - 42
server/icq_legacy/session.go

@@ -1,17 +1,14 @@
 package icq_legacy
 
 import (
-	"context"
 	"fmt"
 	"log/slog"
 	"net"
-	"strconv"
 	"sync"
 	"time"
 
 	"github.com/mk6i/open-oscar-server/config"
 	"github.com/mk6i/open-oscar-server/state"
-	"github.com/mk6i/open-oscar-server/wire"
 )
 
 // LegacySessionManager manages sessions for legacy ICQ clients
@@ -28,9 +25,7 @@ type LegacySessionManager struct {
 
 // SessionRegistry is the interface for the unified session manager
 type SessionRegistry interface {
-	AddSession(ctx context.Context, screenName state.DisplayScreenName, doMultiSess bool, cfg ...func(sess *state.Session)) (*state.SessionInstance, error)
 	RemoveSession(session *state.Session)
-	RetrieveSession(screenName state.IdentScreenName) *state.Session
 }
 
 // NewLegacySessionManager creates a new session manager
@@ -57,8 +52,12 @@ func (m *LegacySessionManager) SetOnSessionExpired(fn func(session *LegacySessio
 	m.onSessionExpired = fn
 }
 
-// CreateSession creates a new legacy session
-func (m *LegacySessionManager) CreateSession(uin uint32, addr *net.UDPAddr, version uint16) (*LegacySession, error) {
+// CreateSession creates a new legacy session backed by an OSCAR session instance.
+func (m *LegacySessionManager) CreateSession(uin uint32, addr *net.UDPAddr, version uint16, instance *state.SessionInstance) (*LegacySession, error) {
+	if instance == nil {
+		return nil, fmt.Errorf("missing OSCAR session instance for UIN %d", uin)
+	}
+
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
@@ -68,54 +67,95 @@ func (m *LegacySessionManager) CreateSession(uin uint32, addr *net.UDPAddr, vers
 		m.removeSessionLocked(existing)
 	}
 
-	// Create screen name from UIN
-	screenName := state.NewIdentScreenName(strconv.FormatUint(uint64(uin), 10))
-	displayName := state.DisplayScreenName(screenName.String())
+	session := newLegacySession(uin, addr, version, instance)
+	m.activateSessionLocked(session)
+
+	m.sessions[uin] = session
+	m.addrIndex[addr.String()] = session
+
+	m.logger.Info("created legacy session",
+		"uin", uin,
+		"addr", addr.String(),
+		"version", version,
+		"session_id", session.SessionID,
+	)
+
+	return session, nil
+}
+
+// CreatePendingSession creates a legacy session before the OSCAR session exists.
+func (m *LegacySessionManager) CreatePendingSession(uin uint32, addr *net.UDPAddr, version uint16) (*LegacySession, error) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if existing, ok := m.sessions[uin]; ok {
+		m.removeSessionLocked(existing)
+	}
+
+	session := newLegacySession(uin, addr, version, nil)
+	m.sessions[uin] = session
+	m.addrIndex[addr.String()] = session
+
+	m.logger.Info("created pending legacy session",
+		"uin", uin,
+		"addr", addr.String(),
+		"version", version,
+		"session_id", session.SessionID,
+	)
+
+	return session, nil
+}
+
+// AttachOSCARSession attaches an AuthService-created OSCAR instance to a pending legacy session.
+func (m *LegacySessionManager) AttachOSCARSession(uin uint32, addr *net.UDPAddr, instance *state.SessionInstance) (*LegacySession, error) {
+	if instance == nil {
+		return nil, fmt.Errorf("missing OSCAR session instance for UIN %d", uin)
+	}
+
+	m.mu.Lock()
+	defer m.mu.Unlock()
 
-	// Register with unified session manager
-	ctx := context.Background()
-	instance, err := m.sessionMgr.AddSession(ctx, displayName, false)
-	if err != nil {
-		return nil, fmt.Errorf("failed to create unified session for UIN %d: %w", uin, err)
+	session, ok := m.sessions[uin]
+	if !ok {
+		return nil, fmt.Errorf("pending legacy session not found for UIN %d", uin)
+	}
+	if session.Instance != nil && session.Instance != instance {
+		return nil, fmt.Errorf("legacy session for UIN %d already has an OSCAR session", uin)
 	}
 
-	// Generate session ID for V5
-	sessionID := GenerateSessionID()
+	if session.Addr != nil {
+		delete(m.addrIndex, session.Addr.String())
+	}
+	session.Addr = addr
+	m.addrIndex[addr.String()] = session
+	session.Instance = instance
+	m.activateSessionLocked(session)
 
-	// Mark the unified session instance as signon-complete so that
-	// RetrieveSession() considers this session "live". Without this, the
-	// OSCAR relay system (RelayToScreenName, BroadcastBuddyArrived, etc.)
-	// skips legacy sessions, and messages/status notifications never reach
-	// the message pump. The legacy login handshake is simpler than OSCAR's
-	// multi-step signon, so we can mark it complete immediately.
-	instance.SetSignonComplete()
+	m.logger.Info("attached OSCAR session to legacy session",
+		"uin", uin,
+		"addr", addr.String(),
+		"version", session.Version,
+		"session_id", session.SessionID,
+	)
 
-	// Set ICQ user flags so that BuddyArrived SNACs generated from this
-	// session's TLVUserInfo() include the ICQ flag and DC info TLV that
-	// ICQ 2003b requires to display the user as online.
-	instance.SetUserInfoFlag(wire.OServiceUserFlagICQ | wire.OServiceUserFlagOSCARFree)
-	instance.Session().SetUIN(uin)
+	return session, nil
+}
 
-	session := &LegacySession{
+func newLegacySession(uin uint32, addr *net.UDPAddr, version uint16, instance *state.SessionInstance) *LegacySession {
+	return &LegacySession{
 		UIN:          uin,
 		Addr:         addr,
 		Version:      version,
-		SessionID:    sessionID,
+		SessionID:    GenerateSessionID(),
 		SeqNumServer: 0, // V2 spec: server starts counting at 0
 		Status:       ICQLegacyStatusOnline,
 		LastActivity: time.Now(),
 		Instance:     instance,
 	}
+}
 
-	m.sessions[uin] = session
-	m.addrIndex[addr.String()] = session
-
-	m.logger.Info("created legacy session",
-		"uin", uin,
-		"addr", addr.String(),
-		"version", version,
-		"session_id", sessionID,
-	)
+func (m *LegacySessionManager) activateSessionLocked(session *LegacySession) {
+	session.Instance.SetSignonComplete()
 
 	// Start the OSCAR message pump for this session. This goroutine drains
 	// SNAC messages (BuddyArrived/BuddyDeparted) that the OSCAR buddy system
@@ -125,8 +165,6 @@ func (m *LegacySessionManager) CreateSession(uin uint32, addr *net.UDPAddr, vers
 	if m.bridge != nil {
 		m.bridge.StartOSCARMessagePump(session)
 	}
-
-	return session, nil
 }
 
 // GetSession retrieves a session by UIN

+ 4 - 6
server/icq_legacy/types.go

@@ -349,12 +349,10 @@ type BuddyListRegistry interface {
 	UnregisterBuddyList(ctx context.Context, user state.IdentScreenName) error
 }
 
-// ClientSideBuddyListManager provides client-side buddy list management.
-// Legacy ICQ clients use client-side buddy lists (not feedbag), so their
-// contacts must be written to the clientSideBuddyList table for the
-// relationship query to discover them.
-type ClientSideBuddyListManager interface {
-	AddBuddy(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) error
+// BuddyService provides client-side buddy list management through the OSCAR
+// Buddy food group path used by OSCAR and TOC clients.
+type BuddyService interface {
+	AddBuddies(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) (*wire.SNACMessage, error)
 }
 
 // LegacySessionInstance represents a legacy session as seen by the service layer.

+ 1 - 1
server/icq_legacy/v1_handler.go

@@ -129,7 +129,7 @@ func (h *V1Handler) handleV1Login(session *LegacySession, addr *net.UDPAddr, pac
 	}
 
 	// Create session
-	newSession, err := h.sessions.CreateSession(uin, addr, ICQLegacyVersionV1)
+	newSession, err := h.sessions.CreateSession(uin, addr, ICQLegacyVersionV1, authResult.oscarSession)
 	if err != nil {
 		h.logger.Error("failed to create V1 session", "err", err, "uin", uin)
 		return h.sender.SendPacket(addr, h.packetBuilder.BuildBadPassword(seqNum, ICQLegacyVersionV1))

+ 5 - 100
server/icq_legacy/v2_handler.go

@@ -217,7 +217,7 @@ func (h *V2Handler) handleLogin(session *LegacySession, addr *net.UDPAddr, pkt *
 	}
 
 	// 3. Create session
-	newSession, err := h.sessions.CreateSession(pkt.UIN, addr, pkt.Version)
+	newSession, err := h.sessions.CreateSession(pkt.UIN, addr, pkt.Version, authResult.oscarSession)
 	if err != nil {
 		h.logger.Error("failed to create session", "err", err, "uin", pkt.UIN)
 		return h.sender.SendPacket(addr, h.packetBuilder.BuildBadPassword(pkt.SeqNum, pkt.Version))
@@ -338,7 +338,7 @@ func (h *V2Handler) handleContactList(session *LegacySession, pkt *V2ClientPacke
 		Contacts: contactList.UINs,
 	}
 
-	contactResult, err := h.service.ProcessContactList(ctx, contactReq)
+	contactResult, err := h.service.ProcessContactList(ctx, session.Instance, contactReq)
 	if err != nil {
 		h.logger.Debug("failed to process contact list", "err", err)
 		// Still send contact list done even on error
@@ -575,67 +575,6 @@ func (h *V2Handler) handleInfoReq(session *LegacySession, pkt *V2ClientPacket) e
 	return h.sender.SendToSession(session, MarshalV2ServerPacket(replyPkt))
 }
 
-// handleLoginInfoReq processes a login info request (0x04CE)
-// The V2 client sends this after login to get its own profile data.
-// We respond with both SRV_INFO_REPLY (0x0118) and SRV_EXT_INFO_REPLY (0x0122).
-func (h *V2Handler) handleLoginInfoReq(session *LegacySession, pkt *V2ClientPacket) error {
-	if session == nil {
-		return nil
-	}
-
-	ctx := context.Background()
-
-	if err := h.sendAck(session, pkt.SeqNum); err != nil {
-		return err
-	}
-
-	targetUIN := session.UIN
-	if len(pkt.Data) >= 4 {
-		targetUIN = binary.LittleEndian.Uint32(pkt.Data[0:4])
-	}
-	if targetUIN == 0 {
-		targetUIN = session.UIN
-	}
-
-	info, err := h.service.GetUserInfo(ctx, targetUIN)
-	if err != nil || info == nil {
-		return nil
-	}
-
-	// Send basic info (0x0118)
-	wireInfo := &LegacyUserInfo{
-		UIN:       info.UIN,
-		Nickname:  truncateField(info.Nickname, 20, h.logger, "nickname", info.UIN),
-		FirstName: truncateField(info.FirstName, 64, h.logger, "first_name", info.UIN),
-		LastName:  truncateField(info.LastName, 64, h.logger, "last_name", info.UIN),
-		Email:     truncateField(info.Email, 64, h.logger, "email", info.UIN),
-		Auth:      info.AuthRequired,
-	}
-	replyPkt := BuildV2InfoReply(session.NextServerSeqNum(), pkt.SeqNum, wireInfo)
-	replyPkt.Version = session.Version
-	h.sender.SendToSession(session, MarshalV2ServerPacket(replyPkt))
-
-	// Send extended info (0x0122)
-	user, err := h.service.GetFullUserInfo(ctx, targetUIN)
-	if err != nil || user == nil {
-		return nil
-	}
-	extInfo := &LegacyUserInfo{
-		UIN:      targetUIN,
-		City:     truncateField(user.ICQBasicInfo.City, 64, h.logger, "city", targetUIN),
-		State:    truncateField(user.ICQBasicInfo.State, 64, h.logger, "state", targetUIN),
-		Country:  user.ICQBasicInfo.CountryCode,
-		Phone:    truncateField(user.ICQBasicInfo.CellPhone, 30, h.logger, "phone", targetUIN),
-		Homepage: truncateField(user.ICQMoreInfo.HomePageAddr, 127, h.logger, "homepage", targetUIN),
-		About:    truncateField(user.ICQNotes.Notes, 450, h.logger, "about", targetUIN),
-		Age:      user.Age(time.Now),
-		Gender:   uint8(user.ICQMoreInfo.Gender),
-	}
-	extReplyPkt := BuildV2ExtInfoReply(session.NextServerSeqNum(), pkt.SeqNum, extInfo)
-	extReplyPkt.Version = session.Version
-	return h.sender.SendToSession(session, MarshalV2ServerPacket(extReplyPkt))
-}
-
 // handleExtInfoReq processes an extended user info request
 // Client sends: SEQ(2) + UIN(4) as data (from center-1.10.7 icq_SendExtInfoReq)
 // Server responds with SRV_EXT_INFO_REPLY (0x0122)
@@ -968,7 +907,7 @@ func (h *V2Handler) handleUserAdd(session *LegacySession, pkt *V2ClientPacket) e
 
 	// Sync to clientSideBuddyList so OSCAR's BuddyArrived reaches this user
 	// for the newly added contact (mirrors ProcessContactList sync logic)
-	if _, err := h.service.ProcessUserAdd(ctx, UserAddRequest{
+	if _, err := h.service.ProcessUserAdd(ctx, session.Instance, UserAddRequest{
 		FromUIN:   session.UIN,
 		TargetUIN: targetUIN,
 	}); err != nil {
@@ -1025,40 +964,6 @@ func (h *V2Handler) handleFirstLogin(session *LegacySession, addr *net.UDPAddr,
 	return h.sender.SendPacket(addr, MarshalV2ServerPacket(ackPkt))
 }
 
-// sendRegisterInfo sends registration info (admin notes) to the client.
-// V2 server packet format: VERSION(2) + COMMAND(2) + SEQ(2) + DATA
-// Following V4's sendRegisterInfo but using V2 packet format.
-func (h *V2Handler) sendRegisterInfo(addr *net.UDPAddr, seqNum uint16, uin uint32) error {
-	adminNotes := "Welcome to Open OSCAR Server!\x00"
-
-	buf := make([]byte, 2+len(adminNotes)+1+4)
-	offset := 0
-
-	// NOTES_LEN(2) + NOTES
-	binary.LittleEndian.PutUint16(buf[offset:], uint16(len(adminNotes)))
-	offset += 2
-	copy(buf[offset:], adminNotes)
-	offset += len(adminNotes)
-
-	// Registration enabled flag
-	buf[offset] = 0x01
-	offset++
-
-	// Trailer (from iserverd)
-	binary.LittleEndian.PutUint16(buf[offset:], 0x0002)
-	offset += 2
-	binary.LittleEndian.PutUint16(buf[offset:], 0x002A)
-	offset += 2
-
-	pkt := &V2ServerPacket{
-		Version: ICQLegacyVersionV2,
-		Command: ICQLegacySrvRegisterInfo,
-		SeqNum:  seqNum,
-		Data:    buf[:offset],
-	}
-	return h.sender.SendPacket(addr, MarshalV2ServerPacket(pkt))
-}
-
 // handleGetDeps processes the pre-auth pseudo-login packet (0x03F2).
 // Historically called "get departments list" in iserverd (from its Users_Deps database table).
 // The server validates credentials, sends V3-format ACK, then sends the pre-auth response (0x0032).
@@ -1130,8 +1035,8 @@ func (h *V2Handler) handleGetDeps(addr *net.UDPAddr, packet []byte) error {
 		Version:  ICQLegacyVersionV2,
 	}
 
-	authResult, err := h.service.AuthenticateUser(ctx, authReq)
-	if err != nil || !authResult.Success {
+	loginOK, err := h.service.ValidateCredentials(ctx, authReq.UIN, authReq.Password)
+	if err != nil || !loginOK {
 		h.logger.Info("getdeps failed - invalid credentials", "uin", uin)
 		return h.sendBadPassword(addr, seq1, 2)
 	}

+ 6 - 20
server/icq_legacy/v3_handler.go

@@ -253,14 +253,14 @@ func (h *V3Handler) handleGetDeps(addr *net.UDPAddr, seq1, seq2 uint16, data []b
 		Version:  ICQLegacyVersionV3,
 	}
 
-	authResult, err := h.service.AuthenticateUser(ctx, authReq)
+	loginOK, err := h.service.ValidateCredentials(ctx, authReq.UIN, authReq.Password)
 	if err != nil {
 		h.logger.Error("getdeps authentication error", "err", err, "uin", uin)
 		return h.sender.SendPacket(addr, h.packetBuilder.BuildBadPassword(seq1, seq2, uin))
 	}
 
-	if !authResult.Success {
-		h.logger.Info("getdeps failed - invalid credentials", "uin", uin, "error_code", authResult.ErrorCode)
+	if !loginOK {
+		h.logger.Info("getdeps failed - invalid credentials", "uin", uin)
 		return h.sender.SendPacket(addr, h.packetBuilder.BuildBadPassword(seq1, seq2, uin))
 	}
 
@@ -357,7 +357,7 @@ func (h *V3Handler) handleLogin(session *LegacySession, addr *net.UDPAddr, seq1,
 	// TODO: parse and store direct connection info (IP, port, DC version, DC type)
 	// from the V3 login packet so that presence notifications can include real
 	// connection details when ICQ_LEGACY_DIRECT_CONNECTIONS includes V3.
-	newSession, err := h.sessions.CreateSession(uin, addr, ICQLegacyVersionV3)
+	newSession, err := h.sessions.CreateSession(uin, addr, ICQLegacyVersionV3, authResult.oscarSession)
 	if err != nil {
 		h.logger.Error("failed to create session", "err", err, "uin", uin)
 		return h.sender.SendPacket(addr, h.packetBuilder.BuildBadPassword(seq1, seq2, uin))
@@ -461,7 +461,7 @@ func (h *V3Handler) handleContactList(session *LegacySession, seq1, seq2 uint16,
 
 	// 3. Call service layer with typed request
 	ctx := context.Background()
-	result, err := h.service.ProcessContactList(ctx, req)
+	result, err := h.service.ProcessContactList(ctx, session.Instance, req)
 	if err != nil {
 		h.logger.Error("contact list processing failed", "uin", uin, "err", err)
 		// Still send contact list done on error
@@ -765,7 +765,7 @@ func (h *V3Handler) handleUserAdd(session *LegacySession, seq1, seq2 uint16, uin
 
 	// 3. Call service layer with typed request
 	ctx := context.Background()
-	result, err := h.service.ProcessUserAdd(ctx, req)
+	result, err := h.service.ProcessUserAdd(ctx, session.Instance, req)
 	if err != nil {
 		h.logger.Error("user add processing failed", "from", req.FromUIN, "target", req.TargetUIN, "err", err)
 		return nil
@@ -1267,20 +1267,6 @@ func (h *V3Handler) handleUnknownDep(session *LegacySession, seq1, seq2 uint16,
 	return nil
 }
 
-// handleSearch processes search request (0x0528)
-func (h *V3Handler) handleSearch(session *LegacySession, seq1, seq2 uint16, uin uint32, data []byte) error {
-	if session == nil {
-		return nil
-	}
-
-	h.sendAck(session.Addr, seq1, seq2, uin)
-
-	h.logger.Debug("V3 search request", "uin", uin)
-
-	// Send search done (no results for now)
-	return h.sendSearchDone(session, seq2)
-}
-
 // handleSearchStart processes search user request (0x05C8)
 // From V3 protocol: TIMESTAMP(4) + TYPE(2) + COMP(2) + LEN(2) + STRING
 // Type: 0x00FF=UIN, 0x01FF=Nick, 0x02FF=First, 0x03FF=Last, etc.

+ 19 - 277
server/icq_legacy/v4_handler.go

@@ -472,14 +472,14 @@ func (h *V4Handler) handleGetDeps(addr *net.UDPAddr, seq1, seq2 uint16, uin uint
 		Version:  ICQLegacyVersionV4,
 	}
 
-	authResult, err := h.service.AuthenticateUser(ctx, authReq)
+	loginOK, err := h.service.ValidateCredentials(ctx, authReq.UIN, authReq.Password)
 	if err != nil {
 		h.logger.Error("V4 getdeps authentication error", "err", err, "uin", dataUIN)
 		return h.sender.SendPacket(addr, h.packetBuilder.BuildBadPassword(seq1, seq2, uin))
 	}
 
-	if !authResult.Success {
-		h.logger.Info("V4 login failed - invalid credentials", "uin", dataUIN, "error_code", authResult.ErrorCode)
+	if !loginOK {
+		h.logger.Info("V4 login failed - invalid credentials", "uin", dataUIN)
 		return h.sender.SendPacket(addr, h.packetBuilder.BuildBadPassword(seq1, seq2, uin))
 	}
 
@@ -491,7 +491,7 @@ func (h *V4Handler) handleGetDeps(addr *net.UDPAddr, seq1, seq2 uint16, uin uint
 	// TODO: parse and store direct connection info (IP, port, DC version, DC type)
 	// from the V4 login packet so that presence notifications can include real
 	// connection details when ICQ_LEGACY_DIRECT_CONNECTIONS includes V4.
-	newSession, err := h.sessions.CreateSession(dataUIN, addr, ICQLegacyVersionV4)
+	newSession, err := h.sessions.CreatePendingSession(dataUIN, addr, ICQLegacyVersionV4)
 	if err != nil {
 		h.logger.Error("failed to create V4 session", "err", err, "uin", dataUIN)
 		return h.sender.SendPacket(addr, h.packetBuilder.BuildBadPassword(seq1, seq2, uin))
@@ -616,7 +616,11 @@ func (h *V4Handler) handleLogin(session *LegacySession, addr *net.UDPAddr, seq1,
 		// FirstLogin flow: session already exists from handleGetDeps.
 		// Depts list was already sent there, just update metadata and
 		// send the login reply.
-		h.sessions.UpdateSessionAddr(uin, addr)
+		existingSession, err = h.sessions.AttachOSCARSession(uin, addr, authResult.oscarSession)
+		if err != nil {
+			h.logger.Error("failed to attach V4 OSCAR session", "err", err, "uin", uin)
+			return h.sender.SendPacket(addr, h.packetBuilder.BuildBadPassword(seq1, seq2, uin))
+		}
 		existingSession.Password = password
 		existingSession.SetStatus(requestedStatus)
 
@@ -636,7 +640,7 @@ func (h *V4Handler) handleLogin(session *LegacySession, addr *net.UDPAddr, seq1,
 		// Direct login flow: client sent Login (0x03E8) without prior
 		// GetDeps. Just create session and send login reply directly.
 		// TODO: parse and store direct connection info from V4 login packet.
-		newSession, err := h.sessions.CreateSession(uin, addr, ICQLegacyVersionV4)
+		newSession, err := h.sessions.CreateSession(uin, addr, ICQLegacyVersionV4, authResult.oscarSession)
 		if err != nil {
 			h.logger.Error("failed to create V4 session", "err", err, "uin", uin)
 			return h.sender.SendPacket(addr, h.packetBuilder.BuildBadPassword(seq1, seq2, uin))
@@ -746,7 +750,7 @@ func (h *V4Handler) handleContactList(session *LegacySession, seq1, seq2 uint16,
 	session.SetContactList(req.Contacts)
 
 	// 3. Call service layer with typed request
-	result, err := h.service.ProcessContactList(ctx, req)
+	result, err := h.service.ProcessContactList(ctx, session.Instance, req)
 	if err != nil {
 		h.logger.Error("V4 contact list processing failed", "uin", uin, "err", err)
 		// Still send contact list done on error
@@ -1024,6 +1028,14 @@ func (h *V4Handler) handleUserAdd(session *LegacySession, seq1, seq2 uint16, uin
 	contacts = append(contacts, targetUIN)
 	session.SetContactList(contacts)
 
+	ctx := context.Background()
+	if _, err := h.service.ProcessUserAdd(ctx, session.Instance, UserAddRequest{
+		FromUIN:   uin,
+		TargetUIN: targetUIN,
+	}); err != nil {
+		h.logger.Debug("V4 user add service call failed", "err", err)
+	}
+
 	targetSession := h.sessions.GetSession(targetUIN)
 	if targetSession != nil {
 		// Send target's online status to the user who added them
@@ -1643,20 +1655,6 @@ func (h *V4Handler) sendAck(addr *net.UDPAddr, seq1, seq2 uint16, uin uint32) er
 	return h.sender.SendPacket(addr, pkt)
 }
 
-// sendBadPassword sends wrong password response
-// V3 server packet format: VERSION(2) + COMMAND(2) + SEQ1(2) + SEQ2(2) + UIN(4) + ZERO(4)
-func (h *V4Handler) sendBadPassword(addr *net.UDPAddr, seq1, seq2 uint16, uin uint32) error {
-	pkt := make([]byte, 16)
-	binary.LittleEndian.PutUint16(pkt[0:2], ICQLegacyVersionV3)
-	binary.LittleEndian.PutUint16(pkt[2:4], ICQLegacySrvWrongPasswd)
-	binary.LittleEndian.PutUint16(pkt[4:6], seq1)
-	binary.LittleEndian.PutUint16(pkt[6:8], seq2)
-	binary.LittleEndian.PutUint32(pkt[8:12], uin)
-	binary.LittleEndian.PutUint32(pkt[12:16], 0) // Zero field
-
-	return h.sender.SendPacket(addr, pkt)
-}
-
 // sendNotConnected sends not connected error
 // V3 server packet format: VERSION(2) + COMMAND(2) + SEQ1(2) + SEQ2(2) + UIN(4) + ZERO(4)
 func (h *V4Handler) sendNotConnected(addr *net.UDPAddr, seq2 uint16, uin uint32) error {
@@ -1671,122 +1669,6 @@ func (h *V4Handler) sendNotConnected(addr *net.UDPAddr, seq2 uint16, uin uint32)
 	return h.sender.SendPacket(addr, pkt)
 }
 
-// sendLoginReply sends login success (HELLO packet)
-// V3 server packet format (from server.html):
-// VERSION(2) + COMMAND(2) + SEQ1(2) + SEQ2(2) + UIN(4) + CHECKCODE(4) + DATA...
-// V3 packets need a checkcode at offset 12 for the client to accept them
-// Server uses its own seq1 counter, seq2 references the client's login packet
-func (h *V4Handler) sendLoginReply(session *LegacySession, seq2 uint16) error {
-	// Get client IP as 4 bytes in network byte order (big-endian)
-	// Example from docs: c0 a8 0a 01 = 192.168.10.1
-	var ipBytes [4]byte
-	if session.Addr != nil && session.Addr.IP != nil {
-		ip := session.Addr.IP.To4()
-		if ip != nil {
-			copy(ipBytes[:], ip)
-		}
-	}
-
-	// LOGIN_REPLY packet format based on ICQ98a client reverse engineering:
-	// Header: 16 bytes (VERSION(2) + COMMAND(2) + SEQ1(2) + SEQ2(2) + UIN(4) + CHECKCODE(4))
-	// Data: IP(4) + 10 WORDs(20) = 24 bytes
-	// Total: 40 bytes
-
-	pkt := make([]byte, 40)
-	offset := 0
-
-	// V3 Header (16 bytes)
-	// Server uses its own seq1, but keeps client's seq2 as reference
-	serverSeq := session.NextServerSeqNum()
-
-	binary.LittleEndian.PutUint16(pkt[offset:], ICQLegacyVersionV3) // 03 00
-	offset += 2
-	binary.LittleEndian.PutUint16(pkt[offset:], ICQLegacySrvHello) // 5a 00
-	offset += 2
-	binary.LittleEndian.PutUint16(pkt[offset:], serverSeq) // server's seq1
-	offset += 2
-	binary.LittleEndian.PutUint16(pkt[offset:], seq2) // seq2 of login packet
-	offset += 2
-	binary.LittleEndian.PutUint32(pkt[offset:], session.UIN) // UIN
-	offset += 4
-
-	// Calculate and add checkcode (V3 packets need this for client to accept)
-	// Note: The server.html example shows 00 00 00 00 at this position.
-	// Some implementations may not verify the checkcode, so we calculate it properly.
-	checkcode := h.calculateV4Checkcode(pkt)
-	binary.LittleEndian.PutUint32(pkt[offset:], checkcode)
-	offset += 4
-
-	// Data section for LOGIN_REPLY (0x005A):
-	// Based on reverse engineering ICQ98a client handle_login_reply_005A:
-	// The client reads: IP(4) + 6 WORDs (stored) + 4 WORDs (discarded) + conditional data
-	// Total: 4 + 20 = 24 bytes of data after header
-	//
-	// IP is in network byte order (big-endian): c0 a8 0a 01 = 192.168.10.1
-	pkt[offset] = ipBytes[0]
-	pkt[offset+1] = ipBytes[1]
-	pkt[offset+2] = ipBytes[2]
-	pkt[offset+3] = ipBytes[3]
-	offset += 4
-
-	// 6 WORDs that get stored in globals:
-	binary.LittleEndian.PutUint16(pkt[offset:], 0x0000) // -> DAT_004df54c
-	offset += 2
-	binary.LittleEndian.PutUint16(pkt[offset:], 0x0000) // -> DAT_004df438
-	offset += 2
-	binary.LittleEndian.PutUint16(pkt[offset:], 0x0019) // -> DAT_004df7a8 (keep-alive interval suggestion = 25)
-	offset += 2
-	binary.LittleEndian.PutUint16(pkt[offset:], 0x0002) // -> DAT_004df550
-	offset += 2
-	binary.LittleEndian.PutUint16(pkt[offset:], 0x0001) // -> DAT_004d4178
-	offset += 2
-	binary.LittleEndian.PutUint16(pkt[offset:], 0x00FA) // -> DAT_004df820 (250 = timeout?)
-	offset += 2
-
-	// 4 WORDs that are discarded:
-	binary.LittleEndian.PutUint16(pkt[offset:], 0x002D) // discarded
-	offset += 2
-	binary.LittleEndian.PutUint16(pkt[offset:], 0x0005) // discarded
-	offset += 2
-	binary.LittleEndian.PutUint16(pkt[offset:], 0x000A) // discarded
-	offset += 2
-	binary.LittleEndian.PutUint16(pkt[offset:], 0x0005) // discarded
-	offset += 2
-
-	h.logger.Debug("sending V4 login reply",
-		"uin", session.UIN,
-		"client_ip", fmt.Sprintf("%d.%d.%d.%d", ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3]),
-		"packet_len", offset,
-		"hex", fmt.Sprintf("%X", pkt[:offset]),
-	)
-
-	return h.sender.SendToSession(session, pkt[:offset])
-}
-
-// sendContactListDone sends contact list processed response
-// V3 server packet format: VERSION(2) + COMMAND(2) + SEQ1(2) + SEQ2(2) + UIN(4) + CHECKCODE(4)
-// seq2 should be the seq2 of the client's contact list packet, or 0 if unsolicited
-func (h *V4Handler) sendContactListDone(session *LegacySession, clientSeq2 uint16) error {
-	pkt := make([]byte, 16)
-	binary.LittleEndian.PutUint16(pkt[0:2], ICQLegacyVersionV3)
-	binary.LittleEndian.PutUint16(pkt[2:4], ICQLegacySrvUserListDone)
-	binary.LittleEndian.PutUint16(pkt[4:6], session.NextServerSeqNum())
-	binary.LittleEndian.PutUint16(pkt[6:8], clientSeq2) // seq2 of contact list packet, or 0 if unsolicited
-	binary.LittleEndian.PutUint32(pkt[8:12], session.UIN)
-
-	// Calculate and add checkcode
-	checkcode := h.calculateV4Checkcode(pkt)
-	binary.LittleEndian.PutUint32(pkt[12:16], checkcode)
-
-	h.logger.Debug("sending V4 contact list done",
-		"uin", session.UIN,
-		"seq2", clientSeq2,
-		"hex", fmt.Sprintf("%X", pkt),
-	)
-
-	return h.sender.SendToSession(session, pkt)
-}
-
 // notifyContactsUserOnline notifies all contacts who have this user in their list that we're online
 func (h *V4Handler) notifyContactsUserOnline(session *LegacySession) {
 	if h.dispatcher == nil {
@@ -1831,7 +1713,6 @@ func (h *V4Handler) notifyContactsUserOffline(session *LegacySession) {
 
 // sendUserOnline sends user online notification
 // V3 server packet format: VERSION(2) + COMMAND(2) + SEQ1(2) + SEQ2(2) + UIN(4) + CHECKCODE(4) + DATA
-// sendUserOnline sends user online notification
 // From iserverd v3_send_user_online() - V3 and V4 share the same server packet format.
 // Data: UIN(4) + IP(4) + PORT(4) + REAL_IP(4) + DC_TYPE(1) + STATUS(2) + ESTATUS(2) + DCVER(2) + UNKNOWN(2)
 // Total data: 25 bytes, total packet: 41 bytes
@@ -1887,91 +1768,6 @@ func (h *V4Handler) sendUserOnline(session *LegacySession, uin uint32, status ui
 	return h.sender.SendToSession(session, pkt[:offset])
 }
 
-// sendUserOnlineNotification sends a full user online notification for the session user
-// From iserverd v3_send_user_online() - V3 and V4 share the same server packet format.
-// Data: UIN(4) + IP(4) + PORT(4) + REAL_IP(4) + DC_TYPE(1) + STATUS(2) + ESTATUS(2) + DCVER(2) + UNKNOWN(2)
-// Total data: 25 bytes, total packet: 41 bytes
-func (h *V4Handler) sendUserOnlineNotification(session *LegacySession) error {
-	// Get client IP
-	var clientIP uint32
-	if session.Addr != nil && session.Addr.IP != nil {
-		ip := session.Addr.IP.To4()
-		if ip != nil {
-			clientIP = uint32(ip[0]) | uint32(ip[1])<<8 | uint32(ip[2])<<16 | uint32(ip[3])<<24
-		}
-	}
-
-	pkt := make([]byte, 41)
-	offset := 0
-
-	// Header
-	binary.LittleEndian.PutUint16(pkt[offset:], ICQLegacyVersionV3)
-	offset += 2
-	binary.LittleEndian.PutUint16(pkt[offset:], ICQLegacySrvUserOnline)
-	offset += 2
-	binary.LittleEndian.PutUint16(pkt[offset:], session.NextServerSeqNum())
-	offset += 2
-	binary.LittleEndian.PutUint16(pkt[offset:], 0) // seq2 = 0 for notifications
-	offset += 2
-	binary.LittleEndian.PutUint32(pkt[offset:], session.UIN)
-	offset += 4
-
-	// Calculate and add checkcode
-	checkcode := h.calculateV4Checkcode(pkt)
-	binary.LittleEndian.PutUint32(pkt[offset:], checkcode)
-	offset += 4
-
-	// UIN of user who came online
-	binary.LittleEndian.PutUint32(pkt[offset:], session.UIN)
-	offset += 4
-	// IP address (4 bytes LE)
-	binary.LittleEndian.PutUint32(pkt[offset:], clientIP)
-	offset += 4
-	// TCP port (4 bytes)
-	binary.LittleEndian.PutUint32(pkt[offset:], 0)
-	offset += 4
-	// Internal/real IP (4 bytes)
-	binary.LittleEndian.PutUint32(pkt[offset:], 0)
-	offset += 4
-	// DC type (1 byte)
-	pkt[offset] = 0x04
-	offset++
-	// Status (low 16 bits) - online
-	binary.LittleEndian.PutUint16(pkt[offset:], uint16(ICQLegacyStatusOnline))
-	offset += 2
-	// Extended status (high 16 bits)
-	binary.LittleEndian.PutUint16(pkt[offset:], 0)
-	offset += 2
-	// DC version (2 bytes)
-	binary.LittleEndian.PutUint16(pkt[offset:], 0)
-	offset += 2
-	// Unknown (2 bytes)
-	binary.LittleEndian.PutUint16(pkt[offset:], 0)
-	offset += 2
-
-	return h.sender.SendToSession(session, pkt[:offset])
-}
-
-// sendDeptsList sends the pre-auth response (historically "departments list" in iserverd).
-// V3 server packet format: VERSION(2) + COMMAND(2) + SEQ1(2) + SEQ2(2) + UIN(4) + ZERO(4) + DATA
-func (h *V4Handler) sendDeptsList(addr *net.UDPAddr, seq2 uint16, uin uint32) error {
-	pkt := make([]byte, 28)
-	binary.LittleEndian.PutUint16(pkt[0:2], ICQLegacyVersionV3)
-	binary.LittleEndian.PutUint16(pkt[2:4], ICQLegacySrvUserDepsList)
-	binary.LittleEndian.PutUint16(pkt[4:6], 0)
-	binary.LittleEndian.PutUint16(pkt[6:8], seq2)
-	binary.LittleEndian.PutUint32(pkt[8:12], uin)
-	binary.LittleEndian.PutUint32(pkt[12:16], 0) // Zero field
-
-	// Pre-auth response data
-	binary.LittleEndian.PutUint32(pkt[16:20], 1)
-	binary.LittleEndian.PutUint32(pkt[20:24], 0)
-	binary.LittleEndian.PutUint16(pkt[24:26], 0x0002)
-	binary.LittleEndian.PutUint16(pkt[26:28], 0x002a)
-
-	return h.sender.SendPacket(addr, pkt)
-}
-
 // sendDeptsListWithCheckcode sends the pre-auth response with proper checkcode for V4 clients.
 // Historically called "departments list" in iserverd (from its Users_Deps database table).
 // V3 server packet format: VERSION(2) + COMMAND(2) + SEQ1(2) + SEQ2(2) + UIN(4) + CHECKCODE(4) + DATA
@@ -2077,60 +1873,6 @@ func (h *V4Handler) sendRegisterInfo(addr *net.UDPAddr, seq2 uint16, uin uint32)
 	return h.sender.SendPacket(addr, pkt[:offset])
 }
 
-// sendRegisterInfoForLogin sends registration info (0x037A) as a response to Login (0x03E8)
-// EXPERIMENT: Testing if this triggers the client to send GetDeps, enabling the normal login flow
-func (h *V4Handler) sendRegisterInfoForLogin(session *LegacySession, seq2 uint16) error {
-	// Admin notes message
-	adminNotes := "Login accepted.\x00"
-
-	// Build packet
-	// Header (16 bytes) + NOTES_LEN(2) + NOTES + REG_ENABLED(1) + UNKNOWN(4)
-	pktSize := 16 + 2 + len(adminNotes) + 1 + 4
-	pkt := make([]byte, pktSize)
-	offset := 0
-
-	// V3 Header
-	binary.LittleEndian.PutUint16(pkt[offset:], ICQLegacyVersionV3)
-	offset += 2
-	binary.LittleEndian.PutUint16(pkt[offset:], ICQLegacySrvRegisterInfo) // 0x037A
-	offset += 2
-	binary.LittleEndian.PutUint16(pkt[offset:], session.NextServerSeqNum()) // server seq1
-	offset += 2
-	binary.LittleEndian.PutUint16(pkt[offset:], seq2) // client's seq2
-	offset += 2
-	binary.LittleEndian.PutUint32(pkt[offset:], session.UIN)
-	offset += 4
-
-	// Calculate and add checkcode
-	checkcode := h.calculateV4Checkcode(pkt)
-	binary.LittleEndian.PutUint32(pkt[offset:], checkcode)
-	offset += 4
-
-	// Admin notes length and string
-	binary.LittleEndian.PutUint16(pkt[offset:], uint16(len(adminNotes)))
-	offset += 2
-	copy(pkt[offset:], adminNotes)
-	offset += len(adminNotes)
-
-	// Registration enabled flag (0 = disabled, since this is login not registration)
-	pkt[offset] = 0x00
-	offset++
-
-	// Unknown fields (from iserverd: 0x0002, 0x002A)
-	binary.LittleEndian.PutUint16(pkt[offset:], 0x0002)
-	offset += 2
-	binary.LittleEndian.PutUint16(pkt[offset:], 0x002A)
-	offset += 2
-
-	h.logger.Info("EXPERIMENT: sending RegInfo (0x037A) instead of LoginReply for login",
-		"uin", session.UIN,
-		"seq2", seq2,
-		"hex", fmt.Sprintf("%X", pkt[:offset]),
-	)
-
-	return h.sender.SendToSession(session, pkt[:offset])
-}
-
 // sendRegistrationOK sends registration success with the new UIN
 // V4 client (licq) expects ICQ_CMDxRCV_NEWxUIN (0x0046) - NOT 0x0384.
 // The client reads the new UIN from the header's UIN field (offset 8-11),

+ 5 - 468
server/icq_legacy/v5_handler.go

@@ -414,16 +414,15 @@ func (h *V5Handler) handleGetDeps(session *LegacySession, addr *net.UDPAddr, pkt
 		Version:  ICQLegacyVersionV5,
 	}
 
-	authResult, err := h.service.AuthenticateUser(ctx, authReq)
+	loginOK, err := h.service.ValidateCredentials(ctx, authReq.UIN, authReq.Password)
 	if err != nil {
 		h.logger.Error("V5 getdeps authentication error", "err", err, "uin", uin)
 		return h.sender.SendPacket(addr, h.packetBuilder.BuildBadPassword(pkt.SessionID, uin, pkt.SeqNum2))
 	}
 
-	if !authResult.Success {
+	if !loginOK {
 		h.logger.Info("V5 getdeps FAILED - invalid credentials",
 			"uin", uin,
-			"error_code", authResult.ErrorCode,
 		)
 		return h.sender.SendPacket(addr, h.packetBuilder.BuildBadPassword(pkt.SessionID, uin, pkt.SeqNum2))
 	}
@@ -470,7 +469,7 @@ func (h *V5Handler) handleContactList(session *LegacySession, pkt *V5ClientPacke
 
 	// 3. Call service layer with typed request
 	ctx := context.Background()
-	result, err := h.service.ProcessContactList(ctx, req)
+	result, err := h.service.ProcessContactList(ctx, session.Instance, req)
 	if err != nil {
 		h.logger.Error("contact list processing failed", "uin", session.UIN, "err", err)
 		// Still send contact list done on error
@@ -839,7 +838,7 @@ func (h *V5Handler) handleUserAdd(session *LegacySession, pkt *V5ClientPacket) e
 
 	// 3. Call service layer with typed request
 	ctx := context.Background()
-	result, err := h.service.ProcessUserAdd(ctx, req)
+	result, err := h.service.ProcessUserAdd(ctx, session.Instance, req)
 	if err != nil {
 		h.logger.Error("user add processing failed", "from", req.FromUIN, "target", req.TargetUIN, "err", err)
 		return nil
@@ -1232,7 +1231,7 @@ func (h *V5Handler) handleLogin(session *LegacySession, addr *net.UDPAddr, pkt *
 	}
 
 	// 4. Create session (handler responsibility - session management)
-	newSession, err := h.sessions.CreateSession(pkt.UIN, addr, ICQLegacyVersionV5)
+	newSession, err := h.sessions.CreateSession(pkt.UIN, addr, ICQLegacyVersionV5, authResult.oscarSession)
 	if err != nil {
 		h.logger.Error("failed to create V5 session", "err", err, "uin", pkt.UIN)
 		return h.sender.SendPacket(addr, h.packetBuilder.BuildBadPassword(pkt.SessionID, pkt.UIN, pkt.SeqNum2))
@@ -1260,96 +1259,6 @@ func (h *V5Handler) handleLogin(session *LegacySession, addr *net.UDPAddr, pkt *
 	return nil
 }
 
-// sendV5BadPassword sends a bad password response
-func (h *V5Handler) sendV5BadPassword(addr *net.UDPAddr, sessionID uint32, uin uint32, seq2 uint16) error {
-	pkt := &V5ServerPacket{
-		Version:   ICQLegacyVersionV5,
-		SessionID: sessionID,
-		Command:   ICQLegacySrvWrongPasswd,
-		SeqNum1:   0,
-		SeqNum2:   seq2,
-		UIN:       uin,
-	}
-
-	data := MarshalV5ServerPacket(pkt)
-	return h.sender.SendPacket(addr, data)
-}
-
-// sendV5LoginReply sends the login success (HELLO) packet
-// From iserverd v5_send_login_reply()
-//
-// Data format (20 bytes total):
-// - iserverd sends: 0x008C(2) + 0x0000(2) + PING_TIME(2) + TIMEOUT(2) + 0x000A(2) + RETRIES(2) + CLIENT_IP(4) + SERVER_ID(4)
-// - Documentation shows: X1(4)=0x0000008C + X2(2)=0x00F0 + X3(2)=0x000A + X4(2)=0x000A + X5(2)=0x0005 + IP(4) + X6(4)
-//
-// Note: The JUNK(4) in iserverd code is the checkcode placeholder in the HEADER at offset 0x11, NOT data!
-func (h *V5Handler) sendV5LoginReply(session *LegacySession, seq2 uint16) error {
-	// Get client IP as uint32 (little-endian)
-	var clientIP uint32
-	if session.Addr != nil && session.Addr.IP != nil {
-		ip := session.Addr.IP.To4()
-		if ip != nil {
-			clientIP = uint32(ip[0]) | uint32(ip[1])<<8 | uint32(ip[2])<<16 | uint32(ip[3])<<24
-		}
-	}
-
-	// Build data - 20 bytes (using iserverd format)
-	data := make([]byte, 20)
-	offset := 0
-
-	binary.LittleEndian.PutUint16(data[offset:], 0x008C) // keep alive interval low
-	offset += 2
-	binary.LittleEndian.PutUint16(data[offset:], 0x0000) // keep alive interval high
-	offset += 2
-	binary.LittleEndian.PutUint16(data[offset:], 50) // ping time (60-10)
-	offset += 2
-	binary.LittleEndian.PutUint16(data[offset:], 60) // timeout
-	offset += 2
-	binary.LittleEndian.PutUint16(data[offset:], 0x000A) // unknown
-	offset += 2
-	binary.LittleEndian.PutUint16(data[offset:], 5) // retries
-	offset += 2
-	binary.LittleEndian.PutUint32(data[offset:], clientIP) // client IP
-	offset += 4
-	binary.LittleEndian.PutUint32(data[offset:], 0x80CDC19B) // server ID
-
-	pkt := &V5ServerPacket{
-		Version:   ICQLegacyVersionV5,
-		SessionID: session.SessionID,
-		Command:   ICQLegacySrvHello,
-		SeqNum1:   0,
-		SeqNum2:   seq2,
-		UIN:       session.UIN,
-		Data:      data,
-	}
-
-	packetData := MarshalV5ServerPacket(pkt)
-
-	h.logger.Debug("sending V5 login reply",
-		"uin", session.UIN,
-		"session_id", session.SessionID,
-		"client_ip", clientIP,
-		"packet_len", len(packetData),
-		"packet_hex", fmt.Sprintf("%X", packetData),
-	)
-
-	return h.sender.SendToSession(session, packetData)
-}
-
-// sendV5ContactListDone sends contact list processed response
-func (h *V5Handler) sendV5ContactListDone(session *LegacySession, seq2 uint16) error {
-	pkt := &V5ServerPacket{
-		Version:   ICQLegacyVersionV5,
-		SessionID: session.SessionID,
-		Command:   ICQLegacySrvUserListDone,
-		SeqNum1:   session.NextServerSeqNum(),
-		SeqNum2:   seq2,
-		UIN:       session.UIN,
-	}
-
-	return h.sender.SendToSession(session, MarshalV5ServerPacket(pkt))
-}
-
 // sendV5UserOnline sends user online notification
 // From iserverd v5_send_user_online()
 //
@@ -3004,29 +2913,6 @@ func (h *V5Handler) sendV5FirstLoginReply(addr *net.UDPAddr, sessionID uint32, u
 	return h.sender.SendPacket(addr, packetData)
 }
 
-// sendV5DepsListReply sends the pre-auth response (V3 format!)
-// Historically called "departments list" in iserverd. In V5, this is a deprecated
-// empty V3-format response sent during the pre-auth pseudo-login (0x03F2).
-// From iserverd v5_send_depslist()
-func (h *V5Handler) sendV5DepsListReply(addr *net.UDPAddr, uin uint32, seq2 uint16) error {
-	// V3 format packet: VERSION(2) + COMMAND(2) + SEQ1(2) + SEQ2(2) + UIN(4) + CHECKSUM(4)
-	// Command is 0x0032 (pre-auth response)
-	buf := make([]byte, 16)
-	binary.LittleEndian.PutUint16(buf[0:2], ICQLegacyVersionV3)
-	binary.LittleEndian.PutUint16(buf[2:4], ICQLegacySrvUserDepsList)
-	binary.LittleEndian.PutUint16(buf[4:6], 0x0000) // seq1
-	binary.LittleEndian.PutUint16(buf[6:8], seq2)
-	binary.LittleEndian.PutUint32(buf[8:12], uin)
-	binary.LittleEndian.PutUint32(buf[12:16], 0x8FFCACBF) // magic checksum
-
-	h.logger.Debug("sending V5 depslist reply (V3 format)",
-		"uin", uin,
-		"packet_len", len(buf),
-	)
-
-	return h.sender.SendPacket(addr, buf)
-}
-
 func (h *V5Handler) sendV5Ack(session *LegacySession, seqNum uint16) error {
 	if session == nil {
 		return nil
@@ -3661,355 +3547,6 @@ func (h *V5Handler) sendMetaUserInfo(session *LegacySession, seqNum uint16, info
 	return h.sender.SendToSession(session, packetData)
 }
 
-// sendMetaUserInfo2 sends an extended META user info response (home info)
-// From iserverd v5_send_meta_info2() in make_meta.cpp
-//
-// This is an extended version of sendMetaUserInfo() that includes home address info.
-// Packet format (success case):
-// - SubCommand (2 bytes): SRV_META_USER_INFO2 (0x00C8)
-// - Success (1 byte): 0x0A for success
-// - Nickname length (2 bytes) + Nickname (null-terminated string)
-// - First name length (2 bytes) + First name (null-terminated string)
-// - Last name length (2 bytes) + Last name (null-terminated string)
-// - Email1 length (2 bytes) + Email1 (null-terminated string) - conditionally hidden based on e1publ
-// - Email2 length (2 bytes) + Email2 (null-terminated string)
-// - Email3 length (2 bytes) + Email3 (null-terminated string)
-// - Home city length (2 bytes) + Home city (null-terminated string)
-// - Home state length (2 bytes) + Home state (null-terminated string)
-// - Home phone length (2 bytes) + Home phone (null-terminated string)
-// - Home fax length (2 bytes) + Home fax (null-terminated string)
-// - Home address length (2 bytes) + Home address (null-terminated string)
-// - Home cell length (2 bytes) + Home cell (null-terminated string)
-// - Home ZIP (4 bytes): uint32 little-endian
-// - Home country (2 bytes): uint16 little-endian
-// - GMT offset (2 bytes): uint16 little-endian
-// - Auth required (1 byte): 0=no auth, 1=auth required
-// - Web aware (1 byte): 0=not web aware, 1=web aware
-// - IP hide (1 byte): 0=show IP, 1=hide IP
-// - Zero (1 byte): 0x00
-// - Zero (1 byte): 0x00
-func (h *V5Handler) sendMetaUserInfo2(session *LegacySession, seqNum uint16, info *LegacyUserSearchResult) error {
-	if session == nil {
-		return nil
-	}
-
-	buf := new(bytes.Buffer)
-
-	// SubCommand: SRV_META_USER_INFO2 (0x00C8)
-	binary.Write(buf, binary.LittleEndian, ICQLegacySrvMetaUserInfo2)
-
-	if info != nil {
-		// Success byte: 0x0A
-		buf.WriteByte(0x0A)
-
-		// Nickname (length-prefixed, null-terminated)
-		writeLegacyString(buf, info.Nickname)
-
-		// First name (length-prefixed, null-terminated)
-		writeLegacyString(buf, info.FirstName)
-
-		// Last name (length-prefixed, null-terminated)
-		writeLegacyString(buf, info.LastName)
-
-		// Email1 (length-prefixed, null-terminated)
-		// Note: In iserverd, this is conditionally hidden based on e1publ flag
-		// For now, we always send the email (same as sendMetaUserInfo)
-		writeLegacyString(buf, info.Email)
-
-		// Email2 (length-prefixed, null-terminated) - not in LegacyUserSearchResult
-		writeLegacyString(buf, "")
-
-		// Email3 (length-prefixed, null-terminated) - not in LegacyUserSearchResult
-		writeLegacyString(buf, "")
-
-		// Home city (length-prefixed, null-terminated) - not in LegacyUserSearchResult
-		writeLegacyString(buf, "")
-
-		// Home state (length-prefixed, null-terminated) - not in LegacyUserSearchResult
-		writeLegacyString(buf, "")
-
-		// Home phone (length-prefixed, null-terminated) - not in LegacyUserSearchResult
-		writeLegacyString(buf, "")
-
-		// Home fax (length-prefixed, null-terminated) - not in LegacyUserSearchResult
-		writeLegacyString(buf, "")
-
-		// Home address (length-prefixed, null-terminated) - not in LegacyUserSearchResult
-		writeLegacyString(buf, "")
-
-		// Home cell (length-prefixed, null-terminated) - not in LegacyUserSearchResult
-		writeLegacyString(buf, "")
-
-		// Home ZIP (4 bytes): uint32 little-endian - not in LegacyUserSearchResult
-		binary.Write(buf, binary.LittleEndian, uint32(0))
-
-		// Home country (2 bytes): uint16 little-endian - not in LegacyUserSearchResult
-		binary.Write(buf, binary.LittleEndian, uint16(0))
-
-		// GMT offset (2 bytes): uint16 little-endian - not in LegacyUserSearchResult
-		binary.Write(buf, binary.LittleEndian, uint16(0))
-
-		// Auth required (1 byte)
-		buf.WriteByte(info.AuthRequired)
-
-		// Web aware (1 byte)
-		buf.WriteByte(info.WebAware)
-
-		// IP hide (1 byte) - not in LegacyUserSearchResult, default to 0 (show IP)
-		buf.WriteByte(0x00)
-
-		// Two trailing zeros (as per iserverd)
-		buf.WriteByte(0x00)
-		buf.WriteByte(0x00)
-	} else {
-		// Failure case: send meta fail
-		// Success byte: 0x32 (fail)
-		buf.WriteByte(0x32)
-	}
-
-	pkt := &V5ServerPacket{
-		Version:   ICQLegacyVersionV5,
-		SessionID: session.SessionID,
-		Command:   ICQLegacySrvMetaUser,
-		SeqNum1:   session.NextServerSeqNum(),
-		SeqNum2:   seqNum,
-		UIN:       session.UIN,
-		Data:      buf.Bytes(),
-	}
-
-	packetData := MarshalV5ServerPacket(pkt)
-
-	h.logger.Info("sending META user info2 (home info)",
-		"uin", session.UIN,
-		"sub_command", fmt.Sprintf("0x%04X", ICQLegacySrvMetaUserInfo2),
-		"target_uin", info.UIN,
-		"nickname", info.Nickname,
-		"firstname", info.FirstName,
-		"lastname", info.LastName,
-		"email", info.Email,
-		"auth", info.AuthRequired,
-		"webaware", info.WebAware,
-		"packet_len", len(packetData),
-	)
-
-	return h.sender.SendToSession(session, packetData)
-}
-
-// sendMetaUserInfo3 sends an extended META user info response (home info) for ICQ99b clients
-// From iserverd v5_send_meta_info3() in make_meta.cpp
-//
-// This is a variant of sendMetaUserInfo2() used with ICQ99b clients.
-// The key difference is that the home ZIP code is sent as a string instead of uint32.
-//
-// Packet format (success case):
-// - SubCommand (2 bytes): SRV_META_USER_INFO2 (0x00C8) - same as info2
-// - Success (1 byte): 0x0A for success
-// - Nickname length (2 bytes) + Nickname (null-terminated string)
-// - First name length (2 bytes) + First name (null-terminated string)
-// - Last name length (2 bytes) + Last name (null-terminated string)
-// - Email1 length (2 bytes) + Email1 (null-terminated string) - conditionally hidden based on e1publ
-// - Email2 length (2 bytes) + Email2 (null-terminated string)
-// - Email3 length (2 bytes) + Email3 (null-terminated string)
-// - Home city length (2 bytes) + Home city (null-terminated string)
-// - Home state length (2 bytes) + Home state (null-terminated string)
-// - Home phone length (2 bytes) + Home phone (null-terminated string)
-// - Home fax length (2 bytes) + Home fax (null-terminated string)
-// - Home address length (2 bytes) + Home address (null-terminated string)
-// - Home cell length (2 bytes) + Home cell (null-terminated string)
-// - Home ZIP length (2 bytes) + Home ZIP (null-terminated STRING) - KEY DIFFERENCE from info2
-// - Home country (2 bytes): uint16 little-endian
-// - GMT offset (2 bytes): uint16 little-endian
-// - 0x01 (1 byte): constant
-// - e1publ (1 byte): email1 public flag
-// - Zero (1 byte): 0x00
-// - Zero (1 byte): 0x00
-// - Zero (1 byte): 0x00
-func (h *V5Handler) sendMetaUserInfo3(session *LegacySession, seqNum uint16, info *LegacyUserSearchResult) error {
-	if session == nil {
-		return nil
-	}
-
-	buf := new(bytes.Buffer)
-
-	// SubCommand: SRV_META_USER_INFO2 (0x00C8) - same as info2
-	binary.Write(buf, binary.LittleEndian, ICQLegacySrvMetaUserInfo2)
-
-	if info != nil {
-		// Success byte: 0x0A
-		buf.WriteByte(0x0A)
-
-		// Nickname (length-prefixed, null-terminated)
-		writeLegacyString(buf, info.Nickname)
-
-		// First name (length-prefixed, null-terminated)
-		writeLegacyString(buf, info.FirstName)
-
-		// Last name (length-prefixed, null-terminated)
-		writeLegacyString(buf, info.LastName)
-
-		// Email1 (length-prefixed, null-terminated)
-		// Note: In iserverd, this is conditionally hidden based on e1publ flag
-		// If e1publ != 1 OR requesting own info, show email; otherwise hide it
-		// For simplicity, we always send the email (same as sendMetaUserInfo2)
-		writeLegacyString(buf, info.Email)
-
-		// Email2 (length-prefixed, null-terminated) - not in LegacyUserSearchResult
-		writeLegacyString(buf, "")
-
-		// Email3 (length-prefixed, null-terminated) - not in LegacyUserSearchResult
-		writeLegacyString(buf, "")
-
-		// Home city (length-prefixed, null-terminated) - not in LegacyUserSearchResult
-		writeLegacyString(buf, "")
-
-		// Home state (length-prefixed, null-terminated) - not in LegacyUserSearchResult
-		writeLegacyString(buf, "")
-
-		// Home phone (length-prefixed, null-terminated) - not in LegacyUserSearchResult
-		writeLegacyString(buf, "")
-
-		// Home fax (length-prefixed, null-terminated) - not in LegacyUserSearchResult
-		writeLegacyString(buf, "")
-
-		// Home address (length-prefixed, null-terminated) - not in LegacyUserSearchResult
-		writeLegacyString(buf, "")
-
-		// Home cell (length-prefixed, null-terminated) - not in LegacyUserSearchResult
-		writeLegacyString(buf, "")
-
-		// Home ZIP as STRING (KEY DIFFERENCE from info2)
-		// In iserverd: snprintf(hzip, 31, "%lu", tuser.hzip)
-		// For now, we send empty string since we don't have ZIP in LegacyUserSearchResult
-		writeLegacyString(buf, "")
-
-		// Home country (2 bytes): uint16 little-endian - not in LegacyUserSearchResult
-		binary.Write(buf, binary.LittleEndian, uint16(0))
-
-		// GMT offset (2 bytes): uint16 little-endian - not in LegacyUserSearchResult
-		binary.Write(buf, binary.LittleEndian, uint16(0))
-
-		// 0x01 (1 byte): constant - as per iserverd
-		buf.WriteByte(0x01)
-
-		// e1publ (1 byte): email1 public flag - default to 1 (public)
-		buf.WriteByte(0x01)
-
-		// Three trailing zeros (as per iserverd)
-		buf.WriteByte(0x00)
-		buf.WriteByte(0x00)
-		buf.WriteByte(0x00)
-	} else {
-		// Failure case: send meta fail
-		// Success byte: 0x32 (fail)
-		buf.WriteByte(0x32)
-	}
-
-	pkt := &V5ServerPacket{
-		Version:   ICQLegacyVersionV5,
-		SessionID: session.SessionID,
-		Command:   ICQLegacySrvMetaUser,
-		SeqNum1:   session.NextServerSeqNum(),
-		SeqNum2:   seqNum,
-		UIN:       session.UIN,
-		Data:      buf.Bytes(),
-	}
-
-	packetData := MarshalV5ServerPacket(pkt)
-
-	h.logger.Info("sending META user info3 (home info for ICQ99b)",
-		"uin", session.UIN,
-		"sub_command", fmt.Sprintf("0x%04X", ICQLegacySrvMetaUserInfo2),
-		"target_uin", info.UIN,
-		"nickname", info.Nickname,
-		"firstname", info.FirstName,
-		"lastname", info.LastName,
-		"email", info.Email,
-		"auth", info.AuthRequired,
-		"webaware", info.WebAware,
-		"packet_len", len(packetData),
-	)
-
-	return h.sender.SendToSession(session, packetData)
-}
-
-// sendMetaSearchResult sends a META search result
-// From iserverd v5_send_user_found2()
-func (h *V5Handler) sendMetaSearchResult(session *LegacySession, seqNum uint16, result *LegacyUserSearchResult, isLast bool) error {
-	if session == nil {
-		return nil
-	}
-
-	subCommand := uint16(0x0190) // SRV_META_USER_FOUND
-	if isLast {
-		subCommand = 0x019A // SRV_META_USER_LAST_FOUND
-	}
-
-	buf := new(bytes.Buffer)
-	binary.Write(buf, binary.LittleEndian, subCommand)
-
-	if result != nil {
-		buf.WriteByte(0x0A) // success
-
-		// Calculate pack_len: 15 + strings + 4 for users_left
-		packLen := uint16(15 + len(result.Nickname) + len(result.FirstName) + len(result.LastName) + len(result.Email) + 4)
-		binary.Write(buf, binary.LittleEndian, packLen)
-
-		binary.Write(buf, binary.LittleEndian, result.UIN)
-		writeLegacyString(buf, result.Nickname)
-		writeLegacyString(buf, result.FirstName)
-		writeLegacyString(buf, result.LastName)
-		writeLegacyString(buf, result.Email)
-		buf.WriteByte(0) // auth
-		buf.WriteByte(0) // webaware
-		buf.WriteByte(0) // unknown
-
-		if isLast {
-			binary.Write(buf, binary.LittleEndian, uint32(0)) // users_left
-		}
-	} else {
-		buf.WriteByte(0x32) // fail - no results
-		if isLast {
-			binary.Write(buf, binary.LittleEndian, uint32(0)) // users_left
-		}
-	}
-
-	pkt := &V5ServerPacket{
-		Version:   ICQLegacyVersionV5,
-		SessionID: session.SessionID,
-		Command:   ICQLegacySrvMetaUser,
-		SeqNum1:   session.NextServerSeqNum(),
-		SeqNum2:   seqNum,
-		UIN:       session.UIN,
-		Data:      buf.Bytes(),
-	}
-
-	packetData := MarshalV5ServerPacket(pkt)
-
-	if result != nil {
-		h.logger.Info("sending META search result",
-			"uin", session.UIN,
-			"sub_command", fmt.Sprintf("0x%04X", subCommand),
-			"result_uin", result.UIN,
-			"result_nickname", result.Nickname,
-			"is_last", isLast,
-			"packet_len", len(packetData),
-		)
-	} else {
-		h.logger.Info("sending META search result - no results",
-			"uin", session.UIN,
-			"sub_command", fmt.Sprintf("0x%04X", subCommand),
-			"is_last", isLast,
-		)
-	}
-
-	return h.sender.SendToSession(session, packetData)
-}
-
-func (h *V5Handler) sendMetaSearchEnd(session *LegacySession, seqNum uint16) error {
-	// Send empty last result to indicate end of search
-	return h.sendMetaSearchResult(session, seqNum, nil, true)
-}
-
 // sendV5OldSearchFound sends an old-style search result
 // From iserverd v5_send_old_search_found()
 //

+ 16 - 15
wire/snacs.go

@@ -94,21 +94,22 @@ const (
 //
 
 const (
-	LoginTLVTagsScreenName              uint16 = 0x01
-	LoginTLVTagsRoastedPassword         uint16 = 0x02
-	LoginTLVTagsClientIdentity          uint16 = 0x03
-	LoginTLVTagsReconnectHere           uint16 = 0x05
-	LoginTLVTagsAuthorizationCookie     uint16 = 0x06
-	LoginTLVTagsErrorSubcode            uint16 = 0x08
-	LoginTLVTagsPasswordHash            uint16 = 0x25
-	LoginTLVTagsMultiConnFlags          uint16 = 0x4A
-	LoginTLVTagsMaxSendSize             uint16 = 0x8001
-	LoginTLVTagsMaxRecvSize             uint16 = 0x8003
-	LoginTLVTagsHostSuffix              uint16 = 0x8004
-	LoginTLVTagsRoastedKerberosPassword uint16 = 0x1335
-	LoginTLVTagsRoastedTOCPassword      uint16 = 0x1337
-	LoginTLVTagsPlaintextPassword       uint16 = 0x1338
-	LoginTLVTagsUseBigTime              uint16 = 0x2038
+	LoginTLVTagsScreenName                uint16 = 0x01
+	LoginTLVTagsRoastedPassword           uint16 = 0x02
+	LoginTLVTagsClientIdentity            uint16 = 0x03
+	LoginTLVTagsReconnectHere             uint16 = 0x05
+	LoginTLVTagsAuthorizationCookie       uint16 = 0x06
+	LoginTLVTagsErrorSubcode              uint16 = 0x08
+	LoginTLVTagsPasswordHash              uint16 = 0x25
+	LoginTLVTagsMultiConnFlags            uint16 = 0x4A
+	LoginTLVTagsMaxSendSize               uint16 = 0x8001
+	LoginTLVTagsMaxRecvSize               uint16 = 0x8003
+	LoginTLVTagsHostSuffix                uint16 = 0x8004
+	LoginTLVTagsRoastedKerberosPassword   uint16 = 0x1335
+	LoginTLVTagsRoastedTOCPassword        uint16 = 0x1337
+	LoginTLVTagsPlaintextKerberosPassword uint16 = 0x1338
+	LoginTLVTagsPlaintextPassword         uint16 = 0x1339
+	LoginTLVTagsUseBigTime                uint16 = 0x2038
 )
 
 const (