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

feat: ICQ legacy protocol support (V2-V5)

UDP-based ICQ legacy protocol handlers for pre-OSCAR clients. Implements
login, messaging, contacts, search, user info, status notifications,
registration, and offline messages. Based on iserverd and old versions of
licq source code.

Cross-protocol bridge between legacy ICQ and OSCAR:
- Bidirectional presence notifications (status, online/offline)
- Message routing (text, URL, contacts, auth messages)
- Authorization enforcement (feedbag 0x000E, request/grant/deny flow)
- Profile save persistence for all protocol versions
- Status mapping between legacy ICQ and OSCAR status values
- V2: Based on licq source, tested with ICQ for windows 3.11
- V3: Tested with ICQ Groupware
- V4: Based on licq, tested with ICQ 98a
- V5: Tested with ICQ99
Vasyl Samoilov 4 месяцев назад
Родитель
Сommit
627254236e

+ 15 - 0
cmd/server/factory.go

@@ -712,5 +712,20 @@ func ICQLegacy(deps Container) *icq_legacy.LegacyServer {
 	// into legacy protocol packets)
 	sessionManager.SetBridge(legacyBridge)
 
+	// Set the expired session callback so timed-out sessions notify contacts
+	// and OSCAR clients — same as a graceful logoff.
+	sessionManager.SetOnSessionExpired(func(session *icq_legacy.LegacySession) {
+		logger.Info("session expired, notifying contacts",
+			"uin", session.UIN,
+		)
+		// Notify legacy contacts
+		sessionManager.BroadcastToContacts(session, func(contact *icq_legacy.LegacySession) {
+			dispatcher.SendUserOffline(contact, session.UIN)
+		})
+		// Notify OSCAR clients
+		ctx := context.Background()
+		icqLegacyService.NotifyUserOffline(ctx, session.UIN)
+	})
+
 	return server
 }

+ 2 - 2
config/config.go

@@ -62,7 +62,7 @@ type Config struct {
 type ICQLegacyConfig struct {
 	Enabled            bool          `envconfig:"ICQ_LEGACY_ENABLED" required:"false" basic:"true" ssl:"true" description:"Enable legacy ICQ protocol support (v2-v5). Allows vintage ICQ clients to connect."`
 	UDPListener        string        `envconfig:"ICQ_LEGACY_UDP_LISTENER" required:"false" basic:"0.0.0.0:4000" ssl:"0.0.0.0:4000" description:"UDP listener address for legacy ICQ protocols.\n\nFormat: HOST:PORT\n\nExamples:\n\t// All interfaces\n\t0.0.0.0:4000\n\t// Specific interface\n\t192.168.1.10:4000"`
-	SupportedVersions  []int         `envconfig:"ICQ_LEGACY_VERSIONS" required:"false" basic:"3,4,5" ssl:"3,4,5" description:"Comma-separated list of supported ICQ protocol versions. Valid values: 1, 2, 3, 4, 5. V1 is logging only (not implemented). V2 is partial (login sequence incomplete)."`
+	SupportedVersions  []int         `envconfig:"ICQ_LEGACY_VERSIONS" required:"false" basic:"2,3,4,5" ssl:"2,3,4,5" description:"Comma-separated list of supported ICQ protocol versions. Valid values: 1, 2, 3, 4, 5. V1 is logging only (not implemented)."`
 	SessionTimeout     time.Duration `envconfig:"ICQ_LEGACY_SESSION_TIMEOUT" required:"false" basic:"120s" ssl:"120s" description:"Session timeout for legacy ICQ connections. Sessions are cleaned up after this duration of inactivity."`
 	KeepAliveInterval  time.Duration `envconfig:"ICQ_LEGACY_KEEPALIVE_INTERVAL" required:"false" basic:"120s" ssl:"120s" description:"Expected keep-alive interval from clients. Used for timeout calculations."`
 	AutoRegistration   bool          `envconfig:"ICQ_LEGACY_AUTO_REGISTRATION" required:"false" basic:"false" ssl:"false" description:"Allow automatic user registration from legacy clients. When enabled, new UINs can be created via the legacy protocol."`
@@ -77,7 +77,7 @@ func DefaultICQLegacyConfig() ICQLegacyConfig {
 	return ICQLegacyConfig{
 		Enabled:            true,
 		UDPListener:        "0.0.0.0:4000",
-		SupportedVersions:  []int{3, 4, 5},
+		SupportedVersions:  []int{2, 3, 4, 5},
 		SessionTimeout:     120 * time.Second,
 		KeepAliveInterval:  120 * time.Second,
 		AutoRegistration:   false,

+ 9 - 9
docs/CLIENT_ICQ_LEGACY.md

@@ -16,15 +16,15 @@ UDP).
 | V5 | ICQ 99a, ICQ 99b | Supported (default) |
 | V4 | ICQ 98a (some builds; later builds use V5) | Supported (default) |
 | V3 | ICQ Groupware | Supported (default) |
-| V2 | ICQ 1.111 Beta (1997, Win95/NT4 only), ICQ 1.111 Beta for Windows 3.11, open-source center client (early centericq/centerim) | Disabled by default (first-login flow incomplete) |
+| V2 | ICQ 1.111 Beta (1997, Win95/NT4 only), ICQ 1.111 Beta for Windows 3.11, open-source center client (early centericq/centerim) | Supported (default) |
 | V1 | ICQ 1.02 Beta (1996) | Unsupported (logging only) |
 
-V1 and V2 clients (ICQ 1.02 Beta, ICQ 1.111 Beta) require Windows 95 or NT 4.0
-and will not install on later versions. The 1997 ICQ for Windows 3.11 (1.111
-Beta) also appears to use V2. The V2 protocol is mostly implemented based on
-open-source clients of the era (center, later centericq/centerim), but the
-first-login flow after fresh installation is not yet complete. V2 can be enabled
-for development by adding `2` to `ICQ_LEGACY_VERSIONS`.
+V1 clients (ICQ 1.02 Beta) require Windows 95 or NT 4.0 and will not install on
+later versions. V2 clients (ICQ 1.111 Beta, 1997) also require Windows 95/NT4
+or Windows 3.11. The V2 protocol supports login, messaging, authorization,
+presence, status changes, contact list, search, and profile updates. V2 clients
+see advanced statuses (N/A, Occupied, DND) from later clients mapped to the
+closest V2 equivalent (Away or DND).
 
 ## Download
 
@@ -101,8 +101,8 @@ ICQ_LEGACY_ENABLED=true
 # UDP listener address
 ICQ_LEGACY_UDP_LISTENER=0.0.0.0:4000
 
-# Supported protocol versions (V3, V4, V5 are production-ready)
-ICQ_LEGACY_VERSIONS=3,4,5
+# Supported protocol versions (V2, V3, V4, V5 are production-ready)
+ICQ_LEGACY_VERSIONS=2,3,4,5
 
 # Enable direct connections for following protocol versions for peer-to-peer
 # communication (file transfer, direct chat). Will leak client IP address

+ 65 - 9
server/icq_legacy/handler.go

@@ -93,9 +93,9 @@ func (d *ProtocolDispatcher) SendUserOnline(toSession *LegacySession, onlineUIN
 
 	switch toSession.Version {
 	case ICQLegacyVersionV1:
-		return d.v1Handler.sendUserOnline(toSession, onlineUIN, status, nil, 0)
+		return d.v1Handler.sendUserOnline(toSession, onlineUIN, downgradeStatusForV2(status), nil, 0)
 	case ICQLegacyVersionV2:
-		return d.v2Handler.sendUserOnline(toSession, onlineUIN, status, nil, 0)
+		return d.v2Handler.sendUserOnline(toSession, onlineUIN, downgradeStatusForV2(status), nil, 0)
 	case ICQLegacyVersionV3:
 		return d.v3Handler.sendUserOnline(toSession, onlineUIN, status)
 	case ICQLegacyVersionV4:
@@ -187,9 +187,9 @@ func (d *ProtocolDispatcher) SendStatusChange(toSession *LegacySession, changedU
 
 	switch toSession.Version {
 	case ICQLegacyVersionV1:
-		return d.v1Handler.sendStatusUpdate(toSession, changedUIN, newStatus)
+		return d.v1Handler.sendStatusUpdate(toSession, changedUIN, downgradeStatusForV2(newStatus))
 	case ICQLegacyVersionV2:
-		return d.v2Handler.sendStatusUpdate(toSession, changedUIN, newStatus)
+		return d.v2Handler.sendStatusUpdate(toSession, changedUIN, downgradeStatusForV2(newStatus))
 	case ICQLegacyVersionV3:
 		return d.v3Handler.sendUserStatus(toSession, changedUIN, newStatus)
 	case ICQLegacyVersionV4:
@@ -201,6 +201,45 @@ func (d *ProtocolDispatcher) SendStatusChange(toSession *LegacySession, changedU
 	}
 }
 
+// downgradeStatusForV2 maps advanced legacy statuses (N/A, Occupied, FFC)
+// to the subset that V2 clients display for remote contacts.
+//
+// The real ICQ V2 client uses combined status bits just like V5:
+//
+//	DND = 0x11 (Away|Occupied), not 0x02
+//
+// So we map to the combined values the V2 client actually understands.
+//
+//	DND (0x02)      -> 0x11           — V2 uses 0x11 for DND, not 0x02
+//	N/A (0x04)      -> Away (0x01)    — extended away maps to away
+//	N/A (0x05)      -> Away (0x01)    — Away|N/A maps to away
+//	Occupied (0x10) -> 0x11           — V2 uses 0x11 for DND (closest busy state)
+//	Occupied (0x11) -> 0x11           — already correct
+//	DND (0x13)      -> 0x11           — V2 uses 0x11 for DND
+//	FFC (0x20)      -> Online (0x00)  — free-for-chat maps to online
+//
+// Flags in the upper word (invisible, web-aware, etc.) are preserved.
+func downgradeStatusForV2(status uint32) uint32 {
+	base := status & 0xFF
+	flags := status & 0xFFFFFF00
+
+	switch base {
+	case 0x02: // DND (pure)
+		base = 0x11
+	case 0x04, 0x05: // N/A, Away|N/A
+		base = 0x01 // Away
+	case 0x10: // Occupied (pure)
+		base = 0x11
+	case 0x13: // Away|DND|Occupied
+		base = 0x11
+	case 0x20: // FFC
+		base = 0x00 // Online
+		// 0x01 (Away) and 0x11 (Occupied/DND) pass through unchanged
+	}
+
+	return flags | base
+}
+
 // PacketSender is the interface for sending packets
 type PacketSender interface {
 	SendPacket(addr *net.UDPAddr, packet []byte) error
@@ -494,7 +533,7 @@ func (h *BaseHandler) sendStatusUpdate(session *LegacySession, uin uint32, statu
 
 // sendContactListDone sends a contact list processed response
 func (h *BaseHandler) sendContactListDone(session *LegacySession, seqNum uint16) error {
-	pkt := BuildV2ContactListDone(seqNum)
+	pkt := BuildV2ContactListDone(seqNum, session.UIN)
 	pkt.Version = session.Version
 	return h.sender.SendToSession(session, MarshalV2ServerPacket(pkt))
 }
@@ -507,7 +546,7 @@ func (h *BaseHandler) sendMessage(session *LegacySession, fromUIN uint32, msgTyp
 }
 
 // sendSearchResult sends a search result
-func (h *BaseHandler) sendSearchResult(session *LegacySession, user *LegacyUserSearchResult, isLast bool) error {
+func (h *BaseHandler) sendSearchResult(session *LegacySession, user *LegacyUserSearchResult, isLast bool, clientSubSeq uint16) error {
 	info := &LegacyUserInfo{
 		UIN:       user.UIN,
 		Nickname:  truncateField(user.Nickname, 20, h.logger, "nickname", user.UIN),
@@ -516,7 +555,24 @@ func (h *BaseHandler) sendSearchResult(session *LegacySession, user *LegacyUserS
 		Email:     truncateField(user.Email, 64, h.logger, "email", user.UIN),
 		Auth:      user.AuthRequired,
 	}
-	pkt := BuildV2SearchResult(session.NextServerSeqNum(), info, isLast)
-	pkt.Version = session.Version
-	return h.sender.SendToSession(session, MarshalV2ServerPacket(pkt))
+
+	if user.UIN != 0 {
+		// Send search found with user data — subseq must echo client's sub-sequence
+		pkt := BuildV2SearchResult(clientSubSeq, info, false)
+		pkt.SeqNum = session.NextServerSeqNum()
+		pkt.Version = session.Version
+		if err := h.sender.SendToSession(session, MarshalV2ServerPacket(pkt)); err != nil {
+			return err
+		}
+	}
+
+	if isLast {
+		// Send search done with its own server seq — subseq must echo client's sub-sequence
+		pkt := BuildV2SearchResult(clientSubSeq, &LegacyUserInfo{}, true)
+		pkt.SeqNum = session.NextServerSeqNum()
+		pkt.Version = session.Version
+		return h.sender.SendToSession(session, MarshalV2ServerPacket(pkt))
+	}
+
+	return nil
 }

+ 70 - 9
server/icq_legacy/service.go

@@ -276,12 +276,21 @@ func (s *ICQLegacyService) ProcessContactList(ctx context.Context, req ContactLi
 		if s.legacySessionManager != nil {
 			legacySession := s.legacySessionManager.GetSession(contactUIN)
 			if legacySession != nil {
-				status.Online = true
-				status.Status = legacySession.GetStatus()
-				s.logger.Debug("ProcessContactList: contact online (legacy)",
-					"contact_uin", contactUIN,
-					"status", fmt.Sprintf("0x%08X", status.Status),
-				)
+				// Check visibility — invisible users should not appear online
+				// unless the viewer is on their visible list
+				contactStatus := legacySession.GetStatus()
+				visible := true
+				if contactStatus&ICQLegacyStatusInvisible != 0 {
+					visible = legacySession.IsOnVisibleList(req.UIN)
+				}
+				if visible {
+					status.Online = true
+					status.Status = contactStatus
+					s.logger.Debug("ProcessContactList: contact online (legacy)",
+						"contact_uin", contactUIN,
+						"status", fmt.Sprintf("0x%08X", status.Status),
+					)
+				}
 			}
 		}
 
@@ -363,6 +372,18 @@ func (s *ICQLegacyService) ProcessUserAdd(ctx context.Context, req UserAddReques
 		"target_uin", req.TargetUIN,
 	)
 
+	// 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",
+			"from_uin", req.FromUIN,
+			"target_uin", req.TargetUIN,
+			"err", err,
+		)
+	}
+
 	// Check if target user is online via legacy session manager
 	if s.legacySessionManager != nil {
 		legacySession := s.legacySessionManager.GetSession(req.TargetUIN)
@@ -1463,8 +1484,44 @@ func (s *ICQLegacyService) ProcessStatusChange(ctx context.Context, req StatusCh
 func (s *ICQLegacyService) NotifyStatusChange(ctx context.Context, uin uint32, status uint32) error {
 	screenName := state.NewIdentScreenName(strconv.FormatUint(uint64(uin), 10))
 
-	// Build user info for OSCAR clients with all required TLVs for ICQ clients
 	oscarStatus := mapLegacyStatusToOSCAR(status)
+
+	// Update the OSCAR instance so session.TLVUserInfo() is correct
+	if s.legacySessionManager != nil {
+		session := s.legacySessionManager.GetSession(uin)
+		if session != nil && session.Instance != nil {
+			session.Instance.SetUserStatusBitmask(oscarStatus)
+			if oscarStatus != wire.OServiceUserStatusAvailable {
+				session.Instance.SetUserInfoFlag(wire.OServiceUserFlagUnavailable)
+			} else {
+				session.Instance.ClearUserInfoFlag(wire.OServiceUserFlagUnavailable)
+			}
+
+			// Invisible: send departure so OSCAR clients see user as offline
+			if status&ICQLegacyStatusInvisible != 0 {
+				if err := s.buddyBroadcaster.BroadcastBuddyDeparted(ctx, screenName); err != nil {
+					s.logger.Debug("NotifyStatusChange: failed to broadcast departure", "err", err)
+				}
+				return nil
+			}
+
+			userInfo := session.Instance.Session().TLVUserInfo()
+			if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, screenName, userInfo); err != nil {
+				s.logger.Debug("NotifyStatusChange: failed to broadcast arrival", "err", err)
+			}
+			return nil
+		}
+	}
+
+	// Fallback if no session/instance — invisible should not broadcast arrival
+	if status&ICQLegacyStatusInvisible != 0 {
+		if err := s.buddyBroadcaster.BroadcastBuddyDeparted(ctx, screenName); err != nil {
+			s.logger.Debug("NotifyStatusChange: failed to broadcast departure", "err", err)
+		}
+		return nil
+	}
+
+	// Build minimal user info for OSCAR clients
 	userFlags := uint16(wire.OServiceUserFlagICQ | wire.OServiceUserFlagOSCARFree)
 	if oscarStatus != wire.OServiceUserStatusAvailable {
 		userFlags |= wire.OServiceUserFlagUnavailable
@@ -1481,9 +1538,8 @@ func (s *ICQLegacyService) NotifyStatusChange(ctx context.Context, uin uint32, s
 		},
 	}
 
-	// Broadcast to OSCAR clients
 	if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, screenName, userInfo); err != nil {
-		s.logger.Debug("failed to broadcast to OSCAR clients", "err", err)
+		s.logger.Debug("NotifyStatusChange: failed to broadcast arrival", "err", err)
 	}
 
 	return nil
@@ -1513,6 +1569,11 @@ func (s *ICQLegacyService) NotifyUserOnline(ctx context.Context, uin uint32, sta
 				session.Instance.ClearUserInfoFlag(wire.OServiceUserFlagUnavailable)
 			}
 
+			// Invisible: don't broadcast arrival — user should appear offline
+			if status&ICQLegacyStatusInvisible != 0 {
+				return nil
+			}
+
 			// Use the session's TLVUserInfo — same as OSCAR's SetUserInfoFields
 			userInfo := session.Instance.Session().TLVUserInfo()
 

+ 40 - 14
server/icq_legacy/session.go

@@ -16,13 +16,14 @@ import (
 
 // LegacySessionManager manages sessions for legacy ICQ clients
 type LegacySessionManager struct {
-	sessions   map[uint32]*LegacySession // Indexed by UIN
-	addrIndex  map[string]*LegacySession // Indexed by UDP address string
-	sessionMgr SessionRegistry           // Unified session manager
-	bridge     *LegacyMessageBridge      // OSCAR->legacy bridge for message pump
-	config     config.ICQLegacyConfig
-	logger     *slog.Logger
-	mu         sync.RWMutex
+	sessions         map[uint32]*LegacySession    // Indexed by UIN
+	addrIndex        map[string]*LegacySession    // Indexed by UDP address string
+	sessionMgr       SessionRegistry              // Unified session manager
+	bridge           *LegacyMessageBridge         // OSCAR->legacy bridge for message pump
+	onSessionExpired func(session *LegacySession) // Called before removing expired sessions
+	config           config.ICQLegacyConfig
+	logger           *slog.Logger
+	mu               sync.RWMutex
 }
 
 // SessionRegistry is the interface for the unified session manager
@@ -49,6 +50,13 @@ func (m *LegacySessionManager) SetBridge(bridge *LegacyMessageBridge) {
 	m.bridge = bridge
 }
 
+// SetOnSessionExpired sets a callback invoked before an expired session is
+// removed. The callback should notify contacts and OSCAR clients that the
+// user went offline — mirroring what handleLogoff does for graceful logouts.
+func (m *LegacySessionManager) SetOnSessionExpired(fn func(session *LegacySession)) {
+	m.onSessionExpired = fn
+}
+
 // CreateSession creates a new legacy session
 func (m *LegacySessionManager) CreateSession(uin uint32, addr *net.UDPAddr, version uint16) (*LegacySession, error) {
 	m.mu.Lock()
@@ -186,18 +194,36 @@ func (m *LegacySessionManager) UpdateSessionAddr(uin uint32, newAddr *net.UDPAdd
 // CleanupExpired removes sessions that have timed out
 func (m *LegacySessionManager) CleanupExpired() int {
 	m.mu.Lock()
-	defer m.mu.Unlock()
 
 	timeout := m.config.SessionTimeout
 	now := time.Now()
-	removed := 0
 
-	for uin, session := range m.sessions {
+	// Collect expired sessions while holding the lock
+	var expired []*LegacySession
+	for _, session := range m.sessions {
 		if now.Sub(session.GetLastActivity()) > timeout {
-			m.logger.Info("cleaning up expired session",
-				"uin", uin,
-				"last_activity", session.GetLastActivity(),
-			)
+			expired = append(expired, session)
+		}
+	}
+	m.mu.Unlock()
+
+	// Notify contacts outside the lock to avoid deadlock
+	for _, session := range expired {
+		m.logger.Info("cleaning up expired session",
+			"uin", session.UIN,
+			"last_activity", session.GetLastActivity(),
+		)
+		if m.onSessionExpired != nil {
+			m.onSessionExpired(session)
+		}
+	}
+
+	// Re-acquire lock and remove the sessions
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	removed := 0
+	for _, session := range expired {
+		if _, ok := m.sessions[session.UIN]; ok {
 			m.removeSessionLocked(session)
 			removed++
 		}

+ 2 - 2
server/icq_legacy/v1_packet_builder.go

@@ -45,8 +45,8 @@ func (b *V1PacketBuilder) BuildUserOffline(seqNum uint16, uin uint32) []byte {
 	return patchVersion(b.v2.BuildUserOffline(seqNum, uin))
 }
 
-func (b *V1PacketBuilder) BuildContactListDone(seqNum uint16) []byte {
-	return patchVersion(b.v2.BuildContactListDone(seqNum))
+func (b *V1PacketBuilder) BuildContactListDone(seqNum uint16, uin uint32) []byte {
+	return patchVersion(b.v2.BuildContactListDone(seqNum, uin))
 }
 
 func (b *V1PacketBuilder) BuildMessage(seqNum uint16, fromUIN uint32, msgType uint16, message string) []byte {

+ 152 - 67
server/icq_legacy/v2_handler.go

@@ -128,7 +128,7 @@ func (h *V2Handler) Handle(session *LegacySession, addr *net.UDPAddr, packet []b
 		return h.handleInvisibleList(session, pkt)
 	case ICQLegacyCmdUserAdd:
 		return h.handleUserAdd(session, pkt)
-	case ICQLegacyCmdUpdateBasic:
+	case ICQLegacyCmdUpdateBasic, ICQLegacyCmdSetBasicInfo:
 		return h.handleUpdateBasic(session, pkt)
 	case ICQLegacyCmdUpdateDetail:
 		return h.handleUpdateDetail(session, pkt)
@@ -342,17 +342,19 @@ func (h *V2Handler) handleContactList(session *LegacySession, pkt *V2ClientPacke
 	if err != nil {
 		h.logger.Debug("failed to process contact list", "err", err)
 		// Still send contact list done even on error
-		return h.sender.SendToSession(session, h.packetBuilder.BuildContactListDone(session.NextServerSeqNum()))
+		return h.sender.SendToSession(session, h.packetBuilder.BuildContactListDone(session.NextServerSeqNum(), session.UIN))
 	}
 
 	// 3. Build and send responses using packet builder
-	// Send online status for each contact that is online
+	// Send online status for each contact that is online.
+	// Apply downgradeStatusForV2 so V2 clients see correct icons
+	// for statuses they don't natively support (N/A→Away, Occupied→DND, FFC→Online).
 	for _, contact := range contactResult.OnlineContacts {
 		if contact.Online {
 			onlinePkt := h.packetBuilder.BuildUserOnline(
 				session.NextServerSeqNum(),
 				contact.UIN,
-				contact.Status,
+				downgradeStatusForV2(contact.Status),
 				nil, // IP not available from service layer
 				0,   // Port not available from service layer
 			)
@@ -366,7 +368,7 @@ func (h *V2Handler) handleContactList(session *LegacySession, pkt *V2ClientPacke
 	}
 
 	// 4. Send contact list done using packet builder
-	return h.sender.SendToSession(session, h.packetBuilder.BuildContactListDone(session.NextServerSeqNum()))
+	return h.sender.SendToSession(session, h.packetBuilder.BuildContactListDone(session.NextServerSeqNum(), session.UIN))
 }
 
 // handleSendMessage processes a message send request
@@ -490,17 +492,23 @@ func (h *V2Handler) handleSetStatus(session *LegacySession, pkt *V2ClientPacket)
 			"notify_count", len(statusResult.NotifyTargets),
 		)
 
-		// 3. Send status notifications to contacts using packet builder
-		// The service layer returns the list of users to notify
+		// 3. Send status notifications to contacts using dispatcher
+		// The service layer returns the list of users to notify.
+		// We must go through the dispatcher so each target gets the
+		// correct packet format for its protocol version.
 		for _, target := range statusResult.NotifyTargets {
 			targetSession := h.sessions.GetSession(target.UIN)
 			if targetSession != nil {
-				statusPkt := h.packetBuilder.BuildStatusUpdate(
-					targetSession.NextServerSeqNum(),
-					session.UIN,
-					newStatus,
-				)
-				h.sender.SendToSession(targetSession, statusPkt)
+				if h.dispatcher != nil {
+					h.dispatcher.SendStatusChange(targetSession, session.UIN, newStatus)
+				} else {
+					statusPkt := h.packetBuilder.BuildStatusUpdate(
+						targetSession.NextServerSeqNum(),
+						session.UIN,
+						newStatus,
+					)
+					h.sender.SendToSession(targetSession, statusPkt)
+				}
 			}
 		}
 	}
@@ -567,6 +575,67 @@ 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)
@@ -653,10 +722,11 @@ func (h *V2Handler) handleSearchUIN(session *LegacySession, pkt *V2ClientPacket)
 
 	// Client sends SEQ(2) + UIN(4) = 6 bytes minimum
 	if len(pkt.Data) < 6 {
-		return h.sendSearchResult(session, &LegacyUserSearchResult{}, true)
+		return h.sendSearchResult(session, &LegacyUserSearchResult{}, true, 0)
 	}
 
-	// Skip SEQ prefix (2 bytes), read UIN at offset 2
+	// Read client sub-sequence (2 bytes) and UIN (4 bytes)
+	clientSubSeq := binary.LittleEndian.Uint16(pkt.Data[0:2])
 	targetUIN := binary.LittleEndian.Uint32(pkt.Data[2:6])
 
 	h.logger.Debug("search by UIN",
@@ -669,10 +739,10 @@ func (h *V2Handler) handleSearchUIN(session *LegacySession, pkt *V2ClientPacket)
 	if err != nil {
 		h.logger.Debug("search failed", "err", err)
 		// Send empty search result
-		return h.sendSearchResult(session, &LegacyUserSearchResult{}, true)
+		return h.sendSearchResult(session, &LegacyUserSearchResult{}, true, clientSubSeq)
 	}
 
-	return h.sendSearchResult(session, result, true)
+	return h.sendSearchResult(session, result, true, clientSubSeq)
 }
 
 // handleSearchUser processes a search by name/email request
@@ -697,9 +767,12 @@ func (h *V2Handler) handleSearchUser(session *LegacySession, pkt *V2ClientPacket
 
 	// Need at least SEQ(2) + one length-prefixed string
 	if len(pkt.Data) < 4 {
-		return h.sendSearchResult(session, &LegacyUserSearchResult{}, true)
+		return h.sendSearchResult(session, &LegacyUserSearchResult{}, true, 0)
 	}
 
+	// Read client sub-sequence
+	clientSubSeq := binary.LittleEndian.Uint16(pkt.Data[0:2])
+
 	// Skip SEQ prefix (2 bytes)
 	r := bytes.NewReader(pkt.Data[2:])
 
@@ -718,21 +791,21 @@ func (h *V2Handler) handleSearchUser(session *LegacySession, pkt *V2ClientPacket
 	results, err := h.service.SearchByName(ctx, nick, first, last, email)
 	if err != nil {
 		h.logger.Debug("search failed", "err", err)
-		return h.sendSearchResult(session, &LegacyUserSearchResult{}, true)
+		return h.sendSearchResult(session, &LegacyUserSearchResult{}, true, clientSubSeq)
 	}
 
 	// Send each result
 	for i, result := range results {
 		isLast := i == len(results)-1
 		r := result // copy for pointer
-		if err := h.sendSearchResult(session, &r, isLast); err != nil {
+		if err := h.sendSearchResult(session, &r, isLast, clientSubSeq); err != nil {
 			return err
 		}
 	}
 
 	// If no results, send empty done
 	if len(results) == 0 {
-		return h.sendSearchResult(session, &LegacyUserSearchResult{}, true)
+		return h.sendSearchResult(session, &LegacyUserSearchResult{}, true, clientSubSeq)
 	}
 
 	return nil
@@ -779,10 +852,14 @@ func (h *V2Handler) handleOfflineMsgReq(session *LegacySession, pkt *V2ClientPac
 	}
 
 	// Send end of offline messages (SRV_SYS_MSG_DONE 0x00E6)
+	// V2 format includes UIN(4) — from licq: 02 00 E6 00 04 00 50 A5 82 00
+	uinData := make([]byte, 4)
+	binary.LittleEndian.PutUint32(uinData[0:4], session.UIN)
 	endPkt := &V2ServerPacket{
 		Version: session.Version,
 		Command: ICQLegacySrvSysMsgDone,
 		SeqNum:  session.NextServerSeqNum(),
+		Data:    uinData,
 	}
 	return h.sender.SendToSession(session, MarshalV2ServerPacket(endPkt))
 }
@@ -887,10 +964,25 @@ func (h *V2Handler) handleUserAdd(session *LegacySession, pkt *V2ClientPacket) e
 	contacts = append(contacts, targetUIN)
 	session.SetContactList(contacts)
 
-	// Check if target is online and send notification
+	ctx := context.Background()
+
+	// 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{
+		FromUIN:   session.UIN,
+		TargetUIN: targetUIN,
+	}); err != nil {
+		h.logger.Debug("user add service call failed", "err", err)
+	}
+
+	// Check if target is online via legacy session and send notification
 	targetSession := h.sessions.GetSession(targetUIN)
 	if targetSession != nil {
-		h.sendUserOnline(session, targetUIN, targetSession.GetStatus(), nil, 0)
+		if h.dispatcher != nil {
+			h.dispatcher.SendUserOnline(session, targetUIN, targetSession.GetStatus())
+		} else {
+			h.sendUserOnline(session, targetUIN, targetSession.GetStatus(), nil, 0)
+		}
 
 		// Also send the adder's online status to the target.
 		// The target won't see the adder as online unless we tell them.
@@ -899,6 +991,13 @@ func (h *V2Handler) handleUserAdd(session *LegacySession, pkt *V2ClientPacket) e
 		} else {
 			h.sendUserOnline(targetSession, session.UIN, session.GetStatus(), nil, 0)
 		}
+	} else {
+		// Check if target is online via OSCAR session
+		info, err := h.service.GetUserInfoForProtocol(ctx, targetUIN)
+		if err == nil && info != nil && info.Online {
+			status := downgradeStatusForV2(info.Status)
+			h.sendUserOnline(session, targetUIN, status, nil, 0)
+		}
 	}
 
 	return nil
@@ -1062,39 +1161,12 @@ func (h *V2Handler) handleGetDeps(addr *net.UDPAddr, packet []byte) error {
 	return h.sender.SendPacket(addr, MarshalV2ServerPacket(depsPkt))
 }
 
-// handleAuthorize processes an authorization grant (CMD_AUTHORIZE 0x0456)
-// Client sends: UIN(4) + X1(5) as data (from licq CPU_Authorize)
-// The server ACKs and the authorized user is notified.
-//
-// IMPORTANT: In V2, 0x0456 is AUTHORIZE, not "send message through server".
-// The V3/V4/V5 handlers route this to handleMessage which is also acceptable
-// since the authorize packet structure is similar to a message, but for V2
-// we handle it explicitly.
+// handleAuthorize processes CMD_AUTHORIZE (0x0456) which is a thru-server
+// message in V2. The packet format is identical to CMD_THRUxSERVER (0x010E):
+// TO_UIN(4) + MSG_TYPE(2) + MSG_LEN(2) + MESSAGE
+// iserverd routes both 0x010E and 0x0456 through the same v3_process_sysmsg.
 func (h *V2Handler) handleAuthorize(session *LegacySession, pkt *V2ClientPacket) error {
-	if session == nil {
-		return nil
-	}
-
-	// Send ACK first to stop client retransmission
-	if err := h.sendAck(session, pkt.SeqNum); err != nil {
-		return err
-	}
-
-	if len(pkt.Data) < 4 {
-		return nil
-	}
-
-	targetUIN := binary.LittleEndian.Uint32(pkt.Data[0:4])
-
-	h.logger.Debug("authorize user",
-		"from", session.UIN,
-		"target", targetUIN,
-	)
-
-	// TODO: Implement authorization grant via service layer
-	// For now, just ACK - the authorization system is not yet implemented
-
-	return nil
+	return h.handleSendMessage(session, pkt)
 }
 
 // handleUpdateBasic processes a basic profile update (CMD_UPDATExBASIC 0x04A6)
@@ -1132,7 +1204,7 @@ func (h *V2Handler) handleUpdateBasic(session *LegacySession, pkt *V2ClientPacke
 	email, _ := ParseLegacyString(r, true)
 
 	var auth uint8
-	binary.Read(r, binary.LittleEndian, &auth)
+	hasAuth := binary.Read(r, binary.LittleEndian, &auth) == nil && r.Len() >= 0
 
 	h.logger.Debug("update basic info",
 		"uin", session.UIN,
@@ -1141,31 +1213,44 @@ func (h *V2Handler) handleUpdateBasic(session *LegacySession, pkt *V2ClientPacke
 		"last", lastName,
 		"email", email,
 		"auth", auth,
+		"has_auth", hasAuth,
 	)
 
-	// Persist basic info via service layer
+	// Persist basic info via service layer using read-merge-write
+	// to avoid overwriting fields not present in the V2 packet
+	// (city, state, country, phone, etc.)
 	ctx := context.Background()
-	info := state.ICQBasicInfo{
-		Nickname:     alias,
-		FirstName:    firstName,
-		LastName:     lastName,
-		EmailAddress: email,
+	existing, err := h.service.GetFullUserInfo(ctx, session.UIN)
+	var info state.ICQBasicInfo
+	if err == nil && existing != nil {
+		info = existing.ICQBasicInfo
 	}
+	info.Nickname = alias
+	info.FirstName = firstName
+	info.LastName = lastName
+	info.EmailAddress = email
 	if err := h.service.UpdateBasicInfo(ctx, session.UIN, info); err != nil {
 		h.logger.Error("V2 update basic info failed", "uin", session.UIN, "err", err)
 	}
 
-	// Update auth mode
-	if err := h.service.SetAuthMode(ctx, session.UIN, auth == 1); err != nil {
-		h.logger.Error("V2 set auth mode failed", "uin", session.UIN, "err", err)
+	// Update auth mode only if the auth byte was present in the packet.
+	// V2 format (0x04A6) includes AUTH(1), V4 format (0x050A) does not.
+	if hasAuth && pkt.Command == ICQLegacyCmdUpdateBasic {
+		if err := h.service.SetAuthMode(ctx, session.UIN, auth == 1); err != nil {
+			h.logger.Error("V2 set auth mode failed", "uin", session.UIN, "err", err)
+		}
 	}
 
-	// Send SRV_UPDATEDxBASIC (0x00B4) with the update sequence
+	// Send SRV_UPDATEDxBASIC — V2 uses 0x00B4, V4 uses 0x01E0
+	replyCmd := ICQLegacySrvUpdatedBasic // 0x00B4
+	if pkt.Command == ICQLegacyCmdSetBasicInfo {
+		replyCmd = ICQLegacySrvUpdatedBasicV4 // 0x01E0
+	}
 	data := make([]byte, 2)
 	binary.LittleEndian.PutUint16(data[0:2], updateSeq)
 	replyPkt := &V2ServerPacket{
 		Version: session.Version,
-		Command: ICQLegacySrvUpdatedBasic,
+		Command: replyCmd,
 		SeqNum:  session.NextServerSeqNum(),
 		Data:    data,
 	}

+ 3 - 3
server/icq_legacy/v2_packet_builder.go

@@ -32,7 +32,7 @@ type V2PacketBuilder interface {
 
 	// BuildContactListDone constructs a contact list processing complete response.
 	// Sent after processing a user's contact list to indicate completion.
-	BuildContactListDone(seqNum uint16) []byte
+	BuildContactListDone(seqNum uint16, uin uint32) []byte
 
 	// BuildMessage constructs a message delivery packet.
 	// Used for both online messages and offline message delivery.
@@ -111,8 +111,8 @@ func (b *V2PacketBuilderImpl) BuildUserOffline(seqNum uint16, uin uint32) []byte
 }
 
 // BuildContactListDone constructs a contact list processing complete response.
-func (b *V2PacketBuilderImpl) BuildContactListDone(seqNum uint16) []byte {
-	pkt := BuildV2ContactListDone(seqNum)
+func (b *V2PacketBuilderImpl) BuildContactListDone(seqNum uint16, uin uint32) []byte {
+	pkt := BuildV2ContactListDone(seqNum, uin)
 	return MarshalV2ServerPacket(pkt)
 }
 

+ 35 - 27
server/icq_legacy/wire_legacy.go

@@ -467,9 +467,11 @@ func ParseV2LoginPacket(data []byte) (*LegacyLoginPacket, error) {
 
 // BuildV2LoginReply creates a login success response
 // V2 LOGIN_REPLY format (from protocol spec):
-// USER_UIN(4) + USER_IP(4) + LOGIN_SEQ_NUM(2) + X1(4) + X2(4) + X3(4) + X4(4) + X5(6) = 32 bytes
+// USER_UIN(4) + USER_IP(4) + LOGIN_SEQ_NUM(2) + X1(4) + X2(4) + X3(4) + X4(4) + X5(7) = 33 bytes
+// Format from real ICQ server capture (licq source):
+// 8F 76 20 00 CD CD 76 10 02 00 01 00 05 00 00 00 00 00 8C 00 00 00 F0 00 0A 00 0A 00 05 00 0A 00 01
 func BuildV2LoginReply(serverSeqNum uint16, clientConnectionID uint16, uin uint32, clientIP net.IP) *V2ServerPacket {
-	data := make([]byte, 32)
+	data := make([]byte, 33)
 	offset := 0
 
 	// USER_UIN (4 bytes)
@@ -485,45 +487,39 @@ func BuildV2LoginReply(serverSeqNum uint16, clientConnectionID uint16, uin uint3
 	}
 	offset += 4
 
-	// LOGIN_SEQ_NUM (2 bytes) - connection ID for routing to wizard page
+	// LOGIN_SEQ_NUM (2 bytes)
 	binary.LittleEndian.PutUint16(data[offset:], clientConnectionID)
 	offset += 2
 
-	// X1 (4 bytes): 01 00 01 00
+	// X1 (4 bytes): 01 00 05 00
 	data[offset] = 0x01
 	data[offset+1] = 0x00
-	data[offset+2] = 0x01
+	data[offset+2] = 0x05
 	data[offset+3] = 0x00
 	offset += 4
 
-	// X2 (4 bytes): 19 00 16 00 (or 18 00 16 00)
-	data[offset] = 0x19
-	data[offset+1] = 0x00
-	data[offset+2] = 0x16
-	data[offset+3] = 0x00
+	// X2 (4 bytes): 00 00 00 00
 	offset += 4
 
 	// X3 (4 bytes): 8C 00 00 00
 	data[offset] = 0x8C
-	data[offset+1] = 0x00
-	data[offset+2] = 0x00
-	data[offset+3] = 0x00
 	offset += 4
 
-	// X4 (4 bytes): 78 00 05 00
-	data[offset] = 0x78
+	// X4 (4 bytes): F0 00 0A 00
+	data[offset] = 0xF0
 	data[offset+1] = 0x00
-	data[offset+2] = 0x05
+	data[offset+2] = 0x0A
 	data[offset+3] = 0x00
 	offset += 4
 
-	// X5 (6 bytes): 0A 00 05 00 01 00
+	// X5 (7 bytes): 0A 00 05 00 0A 00 01
 	data[offset] = 0x0A
 	data[offset+1] = 0x00
 	data[offset+2] = 0x05
 	data[offset+3] = 0x00
-	data[offset+4] = 0x01
+	data[offset+4] = 0x0A
 	data[offset+5] = 0x00
+	data[offset+6] = 0x01
 
 	return &V2ServerPacket{
 		Version: ICQLegacyVersionV2,
@@ -703,37 +699,49 @@ func BuildV2StatusUpdate(seqNum uint16, uin uint32, status uint32) *V2ServerPack
 }
 
 // BuildV2ContactListDone creates a contact list processed response
-func BuildV2ContactListDone(seqNum uint16) *V2ServerPacket {
+// V2 format: UIN(4) — from licq example: 02 00 1C 02 05 00 8F 76 20 00
+func BuildV2ContactListDone(seqNum uint16, uin uint32) *V2ServerPacket {
+	data := make([]byte, 4)
+	binary.LittleEndian.PutUint32(data[0:4], uin)
 	return &V2ServerPacket{
 		Version: ICQLegacyVersionV2,
 		Command: ICQLegacySrvUserListDone,
 		SeqNum:  seqNum,
+		Data:    data,
 	}
 }
 
 // BuildV2SearchResult creates a search result packet
 // V2 format (from center-1.10.7 icq_HandleSearchReply / icq_HandleInfoReply):
+// V2 search found format (from licq):
 // SEQ(2) + UIN(4) + NICK_LEN(2) + NICK + FIRST_LEN(2) + FIRST + LAST_LEN(2) + LAST + EMAIL_LEN(2) + EMAIL + AUTH(1)
+// V2 search done format (from licq): SEQ(2) + MORE(1)
 func BuildV2SearchResult(seqNum uint16, user *LegacyUserInfo, isLast bool) *V2ServerPacket {
 	buf := new(bytes.Buffer)
-	// SEQ prefix - the client reads this first before UIN
 	binary.Write(buf, binary.LittleEndian, seqNum)
+
+	if user.UIN == 0 && isLast {
+		// No results — just send search done with more=0
+		buf.WriteByte(0x00)
+		return &V2ServerPacket{
+			Version: ICQLegacyVersionV2,
+			Command: ICQLegacySrvSearchDone,
+			SeqNum:  seqNum,
+			Data:    buf.Bytes(),
+		}
+	}
+
+	// Send search found with user data
 	binary.Write(buf, binary.LittleEndian, user.UIN)
 	WriteLegacyString(buf, user.Nickname)
 	WriteLegacyString(buf, user.FirstName)
 	WriteLegacyString(buf, user.LastName)
 	WriteLegacyString(buf, user.Email)
-	// AUTH byte (0 = auth not required, 1 = auth required)
 	buf.WriteByte(user.Auth)
 
-	cmd := ICQLegacySrvSearchFound
-	if isLast {
-		cmd = ICQLegacySrvSearchDone
-	}
-
 	return &V2ServerPacket{
 		Version: ICQLegacyVersionV2,
-		Command: cmd,
+		Command: ICQLegacySrvSearchFound,
 		SeqNum:  seqNum,
 		Data:    buf.Bytes(),
 	}