Parcourir la source

search by all TLV combos in FindByWhitePages2

Mike il y a 2 mois
Parent
commit
2904e4d003

+ 9 - 0
foodgroup/helpers_test.go

@@ -145,6 +145,7 @@ type icqUserFinderParams struct {
 	findByInterestsParams
 	findByKeywordParams
 	findByUINParams
+	searchICQUsersParams
 }
 
 // findByKeywordParams is the list of parameters passed at the mock
@@ -190,6 +191,14 @@ type findByInterestsParams []struct {
 	err      error
 }
 
+// searchICQUsersParams is the list of parameters passed at the mock
+// ICQUserFinder.SearchICQUsers call site.
+type searchICQUsersParams []struct {
+	criteria state.ICQUserSearchCriteria
+	result   []state.User
+	err      error
+}
+
 // icqUserUpdaterParams is a helper struct that contains mock parameters for
 // ICQUserUpdater methods
 type icqUserUpdaterParams struct {

+ 246 - 248
foodgroup/icq.go

@@ -3,7 +3,6 @@ package foodgroup
 import (
 	"bytes"
 	"context"
-	"encoding/binary"
 	"errors"
 	"fmt"
 	"log/slog"
@@ -225,30 +224,132 @@ func (s *ICQService) FindByICQInterests(ctx context.Context, instance *state.Ses
 }
 
 func (s *ICQService) FindByWhitePages2(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x055F_DBQueryMetaReqSearchWhitePages2, seq uint16) error {
+	var criteria state.ICQUserSearchCriteria
 
-	users, err := func() ([]state.User, error) {
-		if keyword, hasKeyword := inBody.ICQString(wire.ICQTLVTagsWhitepagesSearchKeywords); hasKeyword {
-			res, err := s.userFinder.FindByICQKeyword(ctx, keyword)
-			if err != nil {
-				return nil, fmt.Errorf("FindByICQKeyword failed: %w", err)
+	if uin, ok := inBody.Uint32LE(wire.ICQTLVTagsUIN); ok {
+		criteria.UIN = &uin
+	}
+	if sNick, ok := inBody.ICQString(wire.ICQTLVTagsNickname); ok {
+		criteria.NickName = &sNick
+	}
+	if sFirst, ok := inBody.ICQString(wire.ICQTLVTagsFirstName); ok {
+		criteria.FirstName = &sFirst
+	}
+	if sLast, ok := inBody.ICQString(wire.ICQTLVTagsLastName); ok {
+		criteria.LastName = &sLast
+	}
+	if sEmail, ok := inBody.ICQString(wire.ICQTLVTagsEmail); ok {
+		criteria.Email = &sEmail
+	}
+	if b, ok := inBody.Bytes(wire.ICQTLVTagsAgeRangeSearch); ok {
+		var ar struct {
+			MinAge uint16
+			MaxAge uint16
+		}
+		if err := wire.UnmarshalLE(&ar, bytes.NewReader(b)); err != nil {
+			return fmt.Errorf("unmarshaling age range: %w", err)
+		}
+		criteria.MinAge = &ar.MinAge
+		criteria.MaxAge = &ar.MaxAge
+	}
+	if gender, ok := inBody.Uint8(wire.ICQTLVTagsGender); ok {
+		criteria.Gender = &gender
+	}
+	if lang, ok := inBody.Uint8(wire.ICQTLVTagsSpokenLanguage); ok {
+		criteria.SpokenLanguage = &lang
+	}
+	if city, ok := inBody.ICQString(wire.ICQTLVTagsHomeCityName); ok {
+		criteria.City = &city
+	}
+	if st, ok := inBody.ICQString(wire.ICQTLVTagsHomeStateAbbr); ok {
+		criteria.State = &st
+	}
+	if cc, ok := inBody.Uint16LE(wire.ICQTLVTagsHomeCountryCode); ok {
+		criteria.CountryCode = &cc
+	}
+	if company, ok := inBody.ICQString(wire.ICQTLVTagsWorkCompanyName); ok {
+		criteria.Company = &company
+	}
+	if departmentName, ok := inBody.ICQString(wire.ICQTLVTagsWorkDepartmentName); ok {
+		criteria.DepartmentName = &departmentName
+	}
+	if position, ok := inBody.ICQString(wire.ICQTLVTagsWorkPositionTitle); ok {
+		criteria.Position = &position
+	}
+	if occ, ok := inBody.Uint16LE(wire.ICQTLVTagsWorkOccupationCode); ok {
+		criteria.OccupationCode = &occ
+	}
+	if b, ok := inBody.Bytes(wire.ICQTLVTagsInterestsNode); ok {
+		var n struct {
+			Code    uint16
+			Keyword string `oscar:"len_prefix=uint16,nullterm"`
+		}
+		if err := wire.UnmarshalLE(&n, bytes.NewReader(b)); err != nil {
+			return fmt.Errorf("unmarshaling interests node: %w", err)
+		}
+		if n.Keyword != "" {
+			criteria.InterestsKeywords = strings.Split(n.Keyword, ",")
+			criteria.InterestsCode = &n.Code
+		}
+	}
+	if b, ok := inBody.Bytes(wire.ICQTLVTagsAffiliationsNode); ok {
+		var n struct {
+			Code    uint16
+			Keyword string `oscar:"len_prefix=uint16,nullterm"`
+		}
+		if err := wire.UnmarshalLE(&n, bytes.NewReader(b)); err != nil {
+			return fmt.Errorf("unmarshaling affiliations node: %w", err)
+		}
+		if n.Keyword != "" {
+			criteria.AffiliationsKeywords = strings.Split(n.Keyword, ",")
+			for i := range criteria.AffiliationsKeywords {
+				criteria.AffiliationsKeywords[i] = strings.TrimSpace(criteria.AffiliationsKeywords[i])
 			}
-			return res, nil
+			criteria.AffiliationsCode = &n.Code
 		}
-
-		bNick, hasNick := inBody.ICQString(wire.ICQTLVTagsNickname)
-		bFirst, hasFirst := inBody.ICQString(wire.ICQTLVTagsFirstName)
-		bLast, hastLast := inBody.ICQString(wire.ICQTLVTagsLastName)
-
-		if hasNick || hasFirst || hastLast {
-			res, err := s.userFinder.FindByICQName(ctx, bFirst, bLast, bNick)
-			if err != nil {
-				return nil, fmt.Errorf("FindByICQName failed: %w", err)
+	}
+	if b, ok := inBody.Bytes(wire.ICQTLVTagsPastInfoNode); ok {
+		var n struct {
+			Code    uint16
+			Keyword string `oscar:"len_prefix=uint16,nullterm"`
+		}
+		if err := wire.UnmarshalLE(&n, bytes.NewReader(b)); err != nil {
+			return fmt.Errorf("unmarshaling past info node: %w", err)
+		}
+		if n.Keyword != "" {
+			criteria.PastAffiliationsKeywords = strings.Split(n.Keyword, ",")
+			for i := range criteria.PastAffiliationsKeywords {
+				criteria.PastAffiliationsKeywords[i] = strings.TrimSpace(criteria.PastAffiliationsKeywords[i])
 			}
-			return res, nil
+			criteria.PastAffiliationsCode = &n.Code
 		}
+	}
+	if b, ok := inBody.Bytes(wire.ICQTLVTagsHomepageCategoryKeywords); ok {
+		var p struct {
+			Index    uint16
+			Keywords string `oscar:"len_prefix=uint16,nullterm"`
+		}
+		if err := wire.UnmarshalLE(&p, bytes.NewReader(b)); err != nil {
+			return fmt.Errorf("unmarshaling homepage category keywords: %w", err)
+		}
+		criteria.HomePageCategoryIndex = new(p.Index)
+		if p.Keywords != "" {
+			criteria.HomePageKeywords = strings.Split(p.Keywords, ",")
+			for i := range criteria.HomePageKeywords {
+				criteria.HomePageKeywords[i] = strings.TrimSpace(criteria.HomePageKeywords[i])
+			}
+		}
+	}
+	if kw, ok := inBody.ICQString(wire.ICQTLVTagsWhitepagesSearchKeywords); ok {
+		criteria.AnyKeyword = &kw
+	}
+
+	onlineOnly := false
+	if f, ok := inBody.Uint8(wire.ICQTLVTagsSearchOnlineUsersFlag); ok && f != 0 {
+		onlineOnly = true
+	}
 
-		return nil, nil
-	}()
+	users, err := s.userFinder.SearchICQUsers(ctx, criteria)
 
 	resp := wire.ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound{
 		ICQMetadata: wire.ICQMetadata{
@@ -261,13 +362,27 @@ func (s *ICQService) FindByWhitePages2(ctx context.Context, instance *state.Sess
 	}
 
 	if err != nil {
-		s.logger.Error("FindByWhitePages2 failed", "err", err.Error())
-		resp.Success = wire.ICQStatusCodeErr
+		if errors.Is(err, state.ErrICQSearchEmptyCriteria) {
+			resp.Success = wire.ICQStatusCodeFail
+		} else {
+			s.logger.Error("FindByWhitePages2 failed", "err", err.Error())
+			resp.Success = wire.ICQStatusCodeErr
+		}
 		return s.reply(ctx, instance, wire.ICQMessageReplyEnvelope{
 			Message: resp,
 		})
 	}
 
+	if onlineOnly {
+		var filtered []state.User
+		for _, u := range users {
+			if s.sessionRetriever.RetrieveSession(u.IdentScreenName) != nil {
+				filtered = append(filtered, u)
+			}
+		}
+		users = filtered
+	}
+
 	if len(users) == 0 {
 		resp.Success = wire.ICQStatusCodeFail
 		return s.reply(ctx, instance, wire.ICQMessageReplyEnvelope{
@@ -568,178 +683,128 @@ func (s *ICQService) SetICQInfo(ctx context.Context, instance *state.SessionInst
 
 		// ----- ICQBasicInfo -----
 		case wire.ICQTLVTagsFirstName:
-			if v, ok := decodeICQSString(tlv.Value); ok {
-				user.ICQInfo.Basic.FirstName = v
-			}
+			user.ICQInfo.Basic.FirstName = tlv.ICQString()
 		case wire.ICQTLVTagsLastName:
-			if v, ok := decodeICQSString(tlv.Value); ok {
-				user.ICQInfo.Basic.LastName = v
-			}
+			user.ICQInfo.Basic.LastName = tlv.ICQString()
 		case wire.ICQTLVTagsNickname:
-			if v, ok := decodeICQSString(tlv.Value); ok {
-				user.ICQInfo.Basic.Nickname = v
-			}
+			user.ICQInfo.Basic.Nickname = tlv.ICQString()
 		case wire.ICQTLVTagsEmail:
-			if email, publish, ok := decodeICQECombo(tlv.Value); ok {
-				user.ICQInfo.Basic.EmailAddress = email
-				// publish=0 means "publish my email", matching the
-				// existing ICQUserFlagPublishEmailYes (0) convention.
-				user.ICQInfo.Basic.PublishEmail = publish == wire.ICQUserFlagPublishEmailYes
+			var n struct {
+				Email   string `oscar:"len_prefix=uint16,nullterm"`
+				Publish uint8
 			}
-		case wire.ICQTLVTagsHomeCityName:
-			if v, ok := decodeICQSString(tlv.Value); ok {
-				user.ICQInfo.Basic.City = v
+			if err := wire.UnmarshalLE(&n, bytes.NewReader(tlv.Value)); err != nil {
+				return fmt.Errorf("wire.UnmarshalLE: %w", err)
 			}
+			user.ICQInfo.Basic.EmailAddress = n.Email
+			// publish=0 means "publish my email", matching the
+			// existing ICQUserFlagPublishEmailYes (0) convention.
+			user.ICQInfo.Basic.PublishEmail = n.Publish == wire.ICQUserFlagPublishEmailYes
+		case wire.ICQTLVTagsHomeCityName:
+			user.ICQInfo.Basic.City = tlv.ICQString()
 		case wire.ICQTLVTagsHomeStateAbbr:
-			if v, ok := decodeICQSString(tlv.Value); ok {
-				user.ICQInfo.Basic.State = v
-			}
+			user.ICQInfo.Basic.State = tlv.ICQString()
 		case wire.ICQTLVTagsHomeCountryCode:
-			if v, ok := readUint16LE(tlv.Value); ok {
-				user.ICQInfo.Basic.CountryCode = v
-			}
+			user.ICQInfo.Basic.CountryCode = tlv.Uint16LE()
 		case wire.ICQTLVTagsHomeStreetAddress:
-			if v, ok := decodeICQSString(tlv.Value); ok {
-				user.ICQInfo.Basic.Address = v
-			}
+			user.ICQInfo.Basic.Address = tlv.ICQString()
 		case wire.ICQTLVTagsHomeZipCode:
-			if v, ok := readUint32LE(tlv.Value); ok {
-				user.ICQInfo.Basic.ZIPCode = strconv.FormatUint(uint64(v), 10)
-			}
+			user.ICQInfo.Basic.ZIPCode = strconv.FormatUint(uint64(tlv.Uint32LE()), 10)
 		case wire.ICQTLVTagsHomePhoneNumber:
-			if v, ok := decodeICQSString(tlv.Value); ok {
-				user.ICQInfo.Basic.Phone = v
-			}
+			user.ICQInfo.Basic.Phone = tlv.ICQString()
 		case wire.ICQTLVTagsHomeFaxNumber:
-			if v, ok := decodeICQSString(tlv.Value); ok {
-				user.ICQInfo.Basic.Fax = v
-			}
+			user.ICQInfo.Basic.Fax = tlv.ICQString()
 		case wire.ICQTLVTagsHomeCellularPhoneNumber:
-			if v, ok := decodeICQSString(tlv.Value); ok {
-				user.ICQInfo.Basic.CellPhone = v
-			}
+			user.ICQInfo.Basic.CellPhone = tlv.ICQString()
 		case wire.ICQTLVTagsGMTOffset:
-			if v, ok := readUint8(tlv.Value); ok {
-				user.ICQInfo.Basic.GMTOffset = v
-			}
+			user.ICQInfo.Basic.GMTOffset = tlv.Uint8()
 		case wire.ICQTLVTagsOriginallyFromCity:
-			if v, ok := decodeICQSString(tlv.Value); ok {
-				user.ICQInfo.Basic.OriginallyFromCity = v
-			}
+			user.ICQInfo.Basic.OriginallyFromCity = tlv.ICQString()
 		case wire.ICQTLVTagsOriginallyFromState:
-			if v, ok := decodeICQSString(tlv.Value); ok {
-				user.ICQInfo.Basic.OriginallyFromState = v
-			}
+			user.ICQInfo.Basic.OriginallyFromState = tlv.ICQString()
 		case wire.ICQTLVTagsOriginallyFromCountryCode:
-			if v, ok := readUint16LE(tlv.Value); ok {
-				user.ICQInfo.Basic.OriginallyFromCountryCode = v
-			}
+			user.ICQInfo.Basic.OriginallyFromCountryCode = tlv.Uint16LE()
 
 		// ----- ICQMoreInfo -----
 		case wire.ICQTLVTagsGender:
-			if v, ok := readUint8(tlv.Value); ok {
-				user.ICQInfo.More.Gender = uint16(v)
-			}
+			user.ICQInfo.More.Gender = uint16(tlv.Uint8())
 		case wire.ICQTLVTagsSpokenLanguage:
-			if v, ok := readUint8(tlv.Value); ok {
-				langTLVs = append(langTLVs, v)
-			}
+			langTLVs = append(langTLVs, tlv.Uint8())
 		case wire.ICQTLVTagsBirthdayInfo:
-			if year, month, day, ok := decodeICQBCombo(tlv.Value); ok {
-				user.ICQInfo.More.BirthYear = year
-				user.ICQInfo.More.BirthMonth = uint8(month)
-				user.ICQInfo.More.BirthDay = uint8(day)
+			var n struct {
+				Year  uint16
+				Month uint16
+				Day   uint16
 			}
-		case wire.ICQTLVTagsHomepageURL:
-			if _, url, ok := decodeICQICombo(tlv.Value); ok {
-				user.ICQInfo.More.HomePageAddr = url
+			if err := wire.UnmarshalLE(&n, bytes.NewReader(tlv.Value)); err != nil {
+				return fmt.Errorf("wire.UnmarshalLE: %w", err)
 			}
+			user.ICQInfo.More.BirthYear = n.Year
+			user.ICQInfo.More.BirthMonth = uint8(n.Month)
+			user.ICQInfo.More.BirthDay = uint8(n.Day)
+		case wire.ICQTLVTagsHomepageURL:
+			user.ICQInfo.More.HomePageAddr = tlv.ICQString()
 
 		// ----- ICQWorkInfo -----
 		case wire.ICQTLVTagsWorkCompanyName:
-			if v, ok := decodeICQSString(tlv.Value); ok {
-				user.ICQInfo.Work.Company = v
-			}
+			user.ICQInfo.Work.Company = tlv.ICQString()
 		case wire.ICQTLVTagsWorkDepartmentName:
-			if v, ok := decodeICQSString(tlv.Value); ok {
-				user.ICQInfo.Work.Department = v
-			}
+			user.ICQInfo.Work.Department = tlv.ICQString()
 		case wire.ICQTLVTagsWorkPositionTitle:
-			if v, ok := decodeICQSString(tlv.Value); ok {
-				user.ICQInfo.Work.Position = v
-			}
+			user.ICQInfo.Work.Position = tlv.ICQString()
 		case wire.ICQTLVTagsWorkOccupationCode:
-			if v, ok := readUint16LE(tlv.Value); ok {
-				user.ICQInfo.Work.OccupationCode = v
-			}
+			user.ICQInfo.Work.OccupationCode = tlv.Uint16LE()
 		case wire.ICQTLVTagsWorkStreetAddress:
-			if v, ok := decodeICQSString(tlv.Value); ok {
-				user.ICQInfo.Work.Address = v
-			}
+			user.ICQInfo.Work.Address = tlv.ICQString()
 		case wire.ICQTLVTagsWorkCityName:
-			if v, ok := decodeICQSString(tlv.Value); ok {
-				user.ICQInfo.Work.City = v
-			}
+			user.ICQInfo.Work.City = tlv.ICQString()
 		case wire.ICQTLVTagsWorkStateName:
-			if v, ok := decodeICQSString(tlv.Value); ok {
-				user.ICQInfo.Work.State = v
-			}
+			user.ICQInfo.Work.State = tlv.ICQString()
 		case wire.ICQTLVTagsWorkCountryCode:
-			if v, ok := readUint16LE(tlv.Value); ok {
-				user.ICQInfo.Work.CountryCode = v
-			}
+			user.ICQInfo.Work.CountryCode = tlv.Uint16LE()
 		case wire.ICQTLVTagsWorkZipCode:
-			if v, ok := readUint32LE(tlv.Value); ok {
-				user.ICQInfo.Work.ZIPCode = strconv.FormatUint(uint64(v), 10)
-			}
+			user.ICQInfo.Work.ZIPCode = strconv.FormatUint(uint64(tlv.Uint32LE()), 10)
 		case wire.ICQTLVTagsWorkPhoneNumber:
-			if v, ok := decodeICQSString(tlv.Value); ok {
-				user.ICQInfo.Work.Phone = v
-			}
+			user.ICQInfo.Work.Phone = tlv.ICQString()
 		case wire.ICQTLVTagsWorkFaxNumber:
-			if v, ok := decodeICQSString(tlv.Value); ok {
-				user.ICQInfo.Work.Fax = v
-			}
+			user.ICQInfo.Work.Fax = tlv.ICQString()
 		case wire.ICQTLVTagsWorkWebpageURL:
-			if v, ok := decodeICQSString(tlv.Value); ok {
-				user.ICQInfo.Work.WebPage = v
-			}
+			user.ICQInfo.Work.WebPage = tlv.ICQString()
 
 		// ----- ICQPermissions -----
 		case wire.ICQTLVTagsAuthorizationPermissions:
-			if v, ok := readUint8(tlv.Value); ok {
-				// Matches the wire-struct convention used by SetPermissions:
-				// authorization == 0 means "authorization required".
-				user.ICQInfo.Permissions.AuthRequired = v == 0
-			}
+			// Matches the wire-struct convention used by SetPermissions:
+			// authorization == 0 means "authorization required".
+			user.ICQInfo.Permissions.AuthRequired = tlv.Uint8() == 0
 		case wire.ICQTLVTagsShowWebStatusPermissions:
-			if v, ok := readUint8(tlv.Value); ok {
-				user.ICQInfo.Permissions.WebAware = v == 1
-			}
+			user.ICQInfo.Permissions.WebAware = tlv.Uint8() == 1
 
 		// ----- ICQUserNotes -----
 		case wire.ICQTLVTagsNotesText:
-			if v, ok := decodeICQSString(tlv.Value); ok {
-				user.ICQInfo.Notes.Notes = v
-			}
+			user.ICQInfo.Notes.Notes = tlv.ICQString()
 
 		// ----- ICQInterests -----
-		case wire.ICQTLVTagsInterestsNode:
+		case wire.ICQTLVTagsInterestsNode: // may appear multiple times
 			interestTLVs = append(interestTLVs, tlv.Value)
 
 		// ----- ICQAffiliations -----
-		case wire.ICQTLVTagsAffiliationsNode:
+		case wire.ICQTLVTagsAffiliationsNode: // may appear multiple times
 			currentAffTLVs = append(currentAffTLVs, tlv.Value)
-		case wire.ICQTLVTagsPastInfoNode:
+		case wire.ICQTLVTagsPastInfoNode: // may appear multiple times
 			pastAffTLVs = append(pastAffTLVs, tlv.Value)
 
 		// ----- ICQHomepageCategory -----
 		case wire.ICQTLVTagsHomepageCategoryKeywords:
-			if code, kw, ok := decodeICQICombo(tlv.Value); ok {
-				user.ICQInfo.HomepageCategory.Index = code
-				user.ICQInfo.HomepageCategory.Description = kw
-				user.ICQInfo.HomepageCategory.Enabled = true
+			var n struct {
+				Index       uint16
+				Description string `oscar:"len_prefix=uint16,nullterm"`
 			}
+			if err := wire.UnmarshalLE(&n, bytes.NewReader(tlv.Value)); err != nil {
+				return fmt.Errorf("wire.UnmarshalLE: %w", err)
+			}
+			user.ICQInfo.HomepageCategory.Index = n.Index
+			user.ICQInfo.HomepageCategory.Description = n.Description
+			user.ICQInfo.HomepageCategory.Enabled = true
 
 		default:
 			// Search-only or otherwise unmapped tags
@@ -768,19 +833,22 @@ func (s *ICQService) SetICQInfo(ctx context.Context, instance *state.SessionInst
 	if len(interestTLVs) > 0 {
 		user.ICQInfo.Interests = state.ICQInterests{}
 		for i, raw := range interestTLVs {
-			code, kw, ok := decodeICQICombo(raw)
-			if !ok {
-				continue
+			var n struct {
+				Code    uint16
+				Keyword string `oscar:"len_prefix=uint16,nullterm"`
+			}
+			if err := wire.UnmarshalLE(&n, bytes.NewReader(raw)); err != nil {
+				return fmt.Errorf("wire.UnmarshalLE: %w", err)
 			}
 			switch i {
 			case 0:
-				user.ICQInfo.Interests.Code1, user.ICQInfo.Interests.Keyword1 = code, kw
+				user.ICQInfo.Interests.Code1, user.ICQInfo.Interests.Keyword1 = n.Code, n.Keyword
 			case 1:
-				user.ICQInfo.Interests.Code2, user.ICQInfo.Interests.Keyword2 = code, kw
+				user.ICQInfo.Interests.Code2, user.ICQInfo.Interests.Keyword2 = n.Code, n.Keyword
 			case 2:
-				user.ICQInfo.Interests.Code3, user.ICQInfo.Interests.Keyword3 = code, kw
+				user.ICQInfo.Interests.Code3, user.ICQInfo.Interests.Keyword3 = n.Code, n.Keyword
 			case 3:
-				user.ICQInfo.Interests.Code4, user.ICQInfo.Interests.Keyword4 = code, kw
+				user.ICQInfo.Interests.Code4, user.ICQInfo.Interests.Keyword4 = n.Code, n.Keyword
 			}
 		}
 	}
@@ -790,17 +858,20 @@ func (s *ICQService) SetICQInfo(ctx context.Context, instance *state.SessionInst
 		user.ICQInfo.Affiliations.CurrentCode2, user.ICQInfo.Affiliations.CurrentKeyword2 = 0, ""
 		user.ICQInfo.Affiliations.CurrentCode3, user.ICQInfo.Affiliations.CurrentKeyword3 = 0, ""
 		for i, raw := range currentAffTLVs {
-			code, kw, ok := decodeICQICombo(raw)
-			if !ok {
-				continue
+			var n struct {
+				Code    uint16
+				Keyword string `oscar:"len_prefix=uint16,nullterm"`
+			}
+			if err := wire.UnmarshalLE(&n, bytes.NewReader(raw)); err != nil {
+				return fmt.Errorf("wire.UnmarshalLE: %w", err)
 			}
 			switch i {
 			case 0:
-				user.ICQInfo.Affiliations.CurrentCode1, user.ICQInfo.Affiliations.CurrentKeyword1 = code, kw
+				user.ICQInfo.Affiliations.CurrentCode1, user.ICQInfo.Affiliations.CurrentKeyword1 = n.Code, n.Keyword
 			case 1:
-				user.ICQInfo.Affiliations.CurrentCode2, user.ICQInfo.Affiliations.CurrentKeyword2 = code, kw
+				user.ICQInfo.Affiliations.CurrentCode2, user.ICQInfo.Affiliations.CurrentKeyword2 = n.Code, n.Keyword
 			case 2:
-				user.ICQInfo.Affiliations.CurrentCode3, user.ICQInfo.Affiliations.CurrentKeyword3 = code, kw
+				user.ICQInfo.Affiliations.CurrentCode3, user.ICQInfo.Affiliations.CurrentKeyword3 = n.Code, n.Keyword
 			}
 		}
 	}
@@ -810,17 +881,20 @@ func (s *ICQService) SetICQInfo(ctx context.Context, instance *state.SessionInst
 		user.ICQInfo.Affiliations.PastCode2, user.ICQInfo.Affiliations.PastKeyword2 = 0, ""
 		user.ICQInfo.Affiliations.PastCode3, user.ICQInfo.Affiliations.PastKeyword3 = 0, ""
 		for i, raw := range pastAffTLVs {
-			code, kw, ok := decodeICQICombo(raw)
-			if !ok {
-				continue
+			var n struct {
+				Code    uint16
+				Keyword string `oscar:"len_prefix=uint16,nullterm"`
+			}
+			if err := wire.UnmarshalLE(&n, bytes.NewReader(raw)); err != nil {
+				return fmt.Errorf("wire.UnmarshalLE: %w", err)
 			}
 			switch i {
 			case 0:
-				user.ICQInfo.Affiliations.PastCode1, user.ICQInfo.Affiliations.PastKeyword1 = code, kw
+				user.ICQInfo.Affiliations.PastCode1, user.ICQInfo.Affiliations.PastKeyword1 = n.Code, n.Keyword
 			case 1:
-				user.ICQInfo.Affiliations.PastCode2, user.ICQInfo.Affiliations.PastKeyword2 = code, kw
+				user.ICQInfo.Affiliations.PastCode2, user.ICQInfo.Affiliations.PastKeyword2 = n.Code, n.Keyword
 			case 2:
-				user.ICQInfo.Affiliations.PastCode3, user.ICQInfo.Affiliations.PastKeyword3 = code, kw
+				user.ICQInfo.Affiliations.PastCode3, user.ICQInfo.Affiliations.PastKeyword3 = n.Code, n.Keyword
 			}
 		}
 	}
@@ -1142,23 +1216,21 @@ func (s *ICQService) moreUserInfo(ctx context.Context, instance *state.SessionIn
 				ReqType: wire.ICQDBQueryMetaReply,
 				Seq:     seq,
 			},
-			ReqSubType: wire.ICQDBQueryMetaReplyMoreInfo,
-			Success:    wire.ICQStatusCodeOK,
-			ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo: wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo{
-				Age:          uint8(user.Age(s.timeNow)),
-				Gender:       user.ICQInfo.More.Gender,
-				HomePageAddr: user.ICQInfo.More.HomePageAddr,
-				BirthYear:    user.ICQInfo.More.BirthYear,
-				BirthMonth:   user.ICQInfo.More.BirthMonth,
-				BirthDay:     user.ICQInfo.More.BirthDay,
-				Lang1:        user.ICQInfo.More.Lang1,
-				Lang2:        user.ICQInfo.More.Lang2,
-				Lang3:        user.ICQInfo.More.Lang3,
-			},
-			City:        user.ICQInfo.Basic.City,
-			State:       user.ICQInfo.Basic.State,
-			CountryCode: user.ICQInfo.Basic.CountryCode,
-			TimeZone:    user.ICQInfo.Basic.GMTOffset,
+			ReqSubType:   wire.ICQDBQueryMetaReplyMoreInfo,
+			Success:      wire.ICQStatusCodeOK,
+			Age:          uint16(user.Age(s.timeNow)),
+			Gender:       uint8(user.ICQInfo.More.Gender),
+			HomePageAddr: user.ICQInfo.More.HomePageAddr,
+			BirthYear:    user.ICQInfo.More.BirthYear,
+			BirthMonth:   user.ICQInfo.More.BirthMonth,
+			BirthDay:     user.ICQInfo.More.BirthDay,
+			Lang1:        user.ICQInfo.More.Lang1,
+			Lang2:        user.ICQInfo.More.Lang2,
+			Lang3:        user.ICQInfo.More.Lang3,
+			City:         user.ICQInfo.Basic.City,
+			State:        user.ICQInfo.Basic.State,
+			CountryCode:  user.ICQInfo.Basic.CountryCode,
+			TimeZone:     user.ICQInfo.Basic.GMTOffset,
 		},
 	}
 
@@ -1296,77 +1368,3 @@ func (s *ICQService) workInfo(ctx context.Context, instance *state.SessionInstan
 	}
 	return s.reply(ctx, instance, msg)
 }
-
-// decodeICQSString decodes an ICQ "sstring" value: a uint16 (LE) length
-// prefix followed by an ASCIIZ string. The length includes the trailing
-// null terminator. Returns the string (without the terminator) and true
-// when the value is well-formed; otherwise returns "" and false.
-func decodeICQSString(b []byte) (string, bool) {
-	if len(b) < 3 {
-		return "", false
-	}
-	expected := binary.LittleEndian.Uint16(b[0:2])
-	value := b[2:]
-	if int(expected) != len(value) {
-		return "", false
-	}
-	return string(value[:len(value)-1]), true
-}
-
-// decodeICQECombo decodes an ICQ "ecombo" value: an sstring (email) followed
-// by a uint8 publish flag.
-func decodeICQECombo(b []byte) (email string, publish uint8, ok bool) {
-	if len(b) < 4 {
-		return "", 0, false
-	}
-	expected := binary.LittleEndian.Uint16(b[0:2])
-	if int(expected)+2+1 != len(b) {
-		return "", 0, false
-	}
-	value := b[2 : 2+int(expected)]
-	return string(value[:len(value)-1]), b[2+int(expected)], true
-}
-
-// decodeICQICombo decodes an ICQ "icombo"/"hcombo" value: a uint16 (LE)
-// category code followed by an sstring keyword.
-func decodeICQICombo(b []byte) (code uint16, keyword string, ok bool) {
-	if len(b) < 2 {
-		return 0, "", false
-	}
-	code = binary.LittleEndian.Uint16(b[0:2])
-	keyword, ok = decodeICQSString(b[2:])
-	return code, keyword, ok
-}
-
-// decodeICQBCombo decodes an ICQ "bcombo" birthday value: three uint16 (LE)
-// values for year, month, and day.
-func decodeICQBCombo(b []byte) (year, month, day uint16, ok bool) {
-	if len(b) < 6 {
-		return 0, 0, 0, false
-	}
-	return binary.LittleEndian.Uint16(b[0:2]),
-		binary.LittleEndian.Uint16(b[2:4]),
-		binary.LittleEndian.Uint16(b[4:6]),
-		true
-}
-
-func readUint8(b []byte) (uint8, bool) {
-	if len(b) < 1 {
-		return 0, false
-	}
-	return b[0], true
-}
-
-func readUint16LE(b []byte) (uint16, bool) {
-	if len(b) < 2 {
-		return 0, false
-	}
-	return binary.LittleEndian.Uint16(b), true
-}
-
-func readUint32LE(b []byte) (uint32, bool) {
-	if len(b) < 4 {
-		return 0, false
-	}
-	return binary.LittleEndian.Uint32(b), true
-}

+ 554 - 44
foodgroup/icq_test.go

@@ -3,6 +3,7 @@ package foodgroup
 import (
 	"bytes"
 	"context"
+	"io"
 	"log/slog"
 	"testing"
 	"time"
@@ -247,6 +248,7 @@ func TestICQService_FindByICQName(t *testing.T) {
 			}
 
 			s := ICQService{
+				logger:           slog.New(slog.NewTextHandler(io.Discard, nil)),
 				messageRelayer:   messageRelayer,
 				sessionRetriever: sessionRetriever,
 				timeNow:          tt.timeNow,
@@ -385,6 +387,7 @@ func TestICQService_FindByICQEmail(t *testing.T) {
 			}
 
 			s := ICQService{
+				logger:           slog.New(slog.NewTextHandler(io.Discard, nil)),
 				messageRelayer:   messageRelayer,
 				sessionRetriever: sessionRetriever,
 				timeNow:          tt.timeNow,
@@ -529,6 +532,7 @@ func TestICQService_FindByEmail3(t *testing.T) {
 			}
 
 			s := ICQService{
+				logger:           slog.New(slog.NewTextHandler(io.Discard, nil)),
 				messageRelayer:   messageRelayer,
 				sessionRetriever: sessionRetriever,
 				timeNow:          tt.timeNow,
@@ -667,6 +671,7 @@ func TestICQService_FindByUIN(t *testing.T) {
 			}
 
 			s := ICQService{
+				logger:           slog.New(slog.NewTextHandler(io.Discard, nil)),
 				messageRelayer:   messageRelayer,
 				sessionRetriever: sessionRetriever,
 				timeNow:          tt.timeNow,
@@ -809,6 +814,7 @@ func TestICQService_FindByUIN2(t *testing.T) {
 			}
 
 			s := ICQService{
+				logger:           slog.New(slog.NewTextHandler(io.Discard, nil)),
 				messageRelayer:   messageRelayer,
 				sessionRetriever: sessionRetriever,
 				timeNow:          tt.timeNow,
@@ -1012,6 +1018,7 @@ func TestICQService_FindByWhitePages(t *testing.T) {
 			}
 
 			s := ICQService{
+				logger:           slog.New(slog.NewTextHandler(io.Discard, nil)),
 				messageRelayer:   messageRelayer,
 				sessionRetriever: sessionRetriever,
 				timeNow:          tt.timeNow,
@@ -1053,9 +1060,11 @@ func TestICQService_FindByWhitePages2(t *testing.T) {
 			},
 			mockParams: mockParams{
 				icqUserFinderParams: icqUserFinderParams{
-					findByKeywordParams: findByKeywordParams{
+					searchICQUsersParams: searchICQUsersParams{
 						{
-							keyword: "knitting",
+							criteria: state.ICQUserSearchCriteria{
+								AnyKeyword: new("knitting"),
+							},
 							result: []state.User{
 								{
 									IdentScreenName: state.NewIdentScreenName("987654321"),
@@ -1228,11 +1237,13 @@ func TestICQService_FindByWhitePages2(t *testing.T) {
 			},
 			mockParams: mockParams{
 				icqUserFinderParams: icqUserFinderParams{
-					findByDetailsParams: findByDetailsParams{
+					searchICQUsersParams: searchICQUsersParams{
 						{
-							nickName:  "Janey",
-							firstName: "Jane",
-							lastName:  "Janey",
+							criteria: state.ICQUserSearchCriteria{
+								NickName:  new("Janey"),
+								FirstName: new("Jane"),
+								LastName:  new("Janey"),
+							},
 							result: []state.User{
 								{
 									IdentScreenName: state.NewIdentScreenName("987654321"),
@@ -1314,18 +1325,522 @@ func TestICQService_FindByWhitePages2(t *testing.T) {
 				},
 			},
 		},
+		{
+			name: "search by structured TLVs",
+			timeNow: func() time.Time {
+				return time.Date(2020, time.August, 1, 0, 0, 0, 0, time.UTC)
+			},
+			seq:      1,
+			instance: newTestInstance("11111111", sessOptUIN(11111111)),
+			req: wire.ICQ_0x07D0_0x055F_DBQueryMetaReqSearchWhitePages2{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVLE(wire.ICQTLVTagsUIN, uint32(987654321)),
+						wire.NewTLVLE(wire.ICQTLVTagsEmail, struct {
+							Val string `oscar:"len_prefix=uint16,nullterm"`
+						}{
+							Val: "janey@example.com",
+						}),
+						wire.NewTLVLE(wire.ICQTLVTagsAgeRangeSearch, struct {
+							MinAge uint16
+							MaxAge uint16
+						}{
+							MinAge: 18,
+							MaxAge: 30,
+						}),
+						wire.NewTLVLE(wire.ICQTLVTagsGender, uint8(2)),
+						wire.NewTLVLE(wire.ICQTLVTagsSpokenLanguage, uint8(10)),
+
+						wire.NewTLVLE(wire.ICQTLVTagsHomeCityName, struct {
+							Val string `oscar:"len_prefix=uint16,nullterm"`
+						}{
+							Val: "New York",
+						}),
+						wire.NewTLVLE(wire.ICQTLVTagsHomeStateAbbr, struct {
+							Val string `oscar:"len_prefix=uint16,nullterm"`
+						}{
+							Val: "NY",
+						}),
+						wire.NewTLVLE(wire.ICQTLVTagsHomeCountryCode, uint16(840)),
+
+						wire.NewTLVLE(wire.ICQTLVTagsWorkCompanyName, struct {
+							Val string `oscar:"len_prefix=uint16,nullterm"`
+						}{
+							Val: "Acme Corp",
+						}),
+						wire.NewTLVLE(wire.ICQTLVTagsWorkDepartmentName, struct {
+							Val string `oscar:"len_prefix=uint16,nullterm"`
+						}{
+							Val: "Engineering",
+						}),
+						wire.NewTLVLE(wire.ICQTLVTagsWorkPositionTitle, struct {
+							Val string `oscar:"len_prefix=uint16,nullterm"`
+						}{
+							Val: "Engineer",
+						}),
+						wire.NewTLVLE(wire.ICQTLVTagsWorkOccupationCode, uint16(42)),
+
+						wire.NewTLVLE(wire.ICQTLVTagsInterestsNode, wire.ICQInterests{Code: 1, Keyword: "Music"}),
+						wire.NewTLVLE(wire.ICQTLVTagsAffiliationsNode, wire.ICQInterests{Code: 99, Keyword: "NewClub1,NewClub2, NewClub3"}),
+						wire.NewTLVLE(wire.ICQTLVTagsPastInfoNode, wire.ICQInterests{Code: 100, Keyword: "OldClub1, OldClub2 ,OldClub3"}),
+						wire.NewTLVLE(wire.ICQTLVTagsHomepageCategoryKeywords, struct {
+							Index    uint16
+							Keywords string `oscar:"len_prefix=uint16,nullterm"`
+						}{
+							Index:    7,
+							Keywords: "cats,dogs ,parrots",
+						}),
+					},
+				},
+			},
+			mockParams: mockParams{
+				icqUserFinderParams: icqUserFinderParams{
+					searchICQUsersParams: searchICQUsersParams{
+						{
+							criteria: state.ICQUserSearchCriteria{
+								UIN:            new(uint32(987654321)),
+								Email:          new("janey@example.com"),
+								MinAge:         new(uint16(18)),
+								MaxAge:         new(uint16(30)),
+								Gender:         new(uint8(2)),
+								SpokenLanguage: new(uint8(10)),
+								City:           new("New York"),
+								State:          new("NY"),
+								CountryCode:    new(uint16(840)),
+								Company:        new("Acme Corp"),
+								DepartmentName: new("Engineering"),
+								Position:       new("Engineer"),
+								OccupationCode: new(uint16(42)),
+								InterestsCode:  new(uint16(1)),
+								InterestsKeywords: []string{
+									"Music",
+								},
+								AffiliationsCode: new(uint16(99)),
+								AffiliationsKeywords: []string{
+									"NewClub1",
+									"NewClub2",
+									"NewClub3",
+								},
+								PastAffiliationsCode: new(uint16(100)),
+								PastAffiliationsKeywords: []string{
+									"OldClub1",
+									"OldClub2",
+									"OldClub3",
+								},
+								HomePageCategoryIndex: new(uint16(7)),
+								HomePageKeywords: []string{
+									"cats",
+									"dogs",
+									"parrots",
+								},
+							},
+							result: []state.User{
+								{
+									IdentScreenName: state.NewIdentScreenName("987654321"),
+									ICQInfo: state.ICQInfo{
+										Basic: state.ICQBasicInfo{
+											EmailAddress: "janey@example.com",
+											FirstName:    "Jane",
+											LastName:     "Doe",
+											Nickname:     "Janey",
+										},
+										Permissions: state.ICQPermissions{
+											AuthRequired: false,
+										},
+										More: state.ICQMoreInfo{
+											BirthDay:   31,
+											BirthMonth: 7,
+											BirthYear:  1995,
+											Gender:     2,
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x15_0x02_DBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													Success:    wire.ICQStatusCodeOK,
+													ReqSubType: wire.ICQDBQueryMetaReplyLastUserFound,
+													Details: wire.ICQUserSearchRecord{
+														UIN:           987654321,
+														Nickname:      "Janey",
+														FirstName:     "Jane",
+														LastName:      "Doe",
+														Email:         "janey@example.com",
+														Authorization: 1,
+														OnlineStatus:  0,
+														Gender:        2,
+														Age:           25,
+													},
+													LastMessageFooter: &struct {
+														FoundUsersLeft uint32
+													}{
+														FoundUsersLeft: 0,
+													},
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{
+						{
+							screenName: state.NewIdentScreenName("987654321"),
+							result:     nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "search returns runtime error",
+			timeNow: func() time.Time {
+				return time.Date(2020, time.August, 1, 0, 0, 0, 0, time.UTC)
+			},
+			seq:      1,
+			instance: newTestInstance("11111111", sessOptUIN(11111111)),
+			req: wire.ICQ_0x07D0_0x055F_DBQueryMetaReqSearchWhitePages2{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVLE(wire.ICQTLVTagsWhitepagesSearchKeywords, struct {
+							Val string `oscar:"len_prefix=uint16,nullterm"`
+						}{
+							Val: "knitting",
+						}),
+					},
+				},
+			},
+			mockParams: mockParams{
+				icqUserFinderParams: icqUserFinderParams{
+					searchICQUsersParams: searchICQUsersParams{
+						{
+							criteria: state.ICQUserSearchCriteria{
+								AnyKeyword: new("knitting"),
+							},
+							err: assert.AnError,
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x15_0x02_DBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													Success:    wire.ICQStatusCodeErr,
+													ReqSubType: wire.ICQDBQueryMetaReplyLastUserFound,
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{},
+				},
+			},
+		},
+		{
+			name: "search returns empty criteria error",
+			timeNow: func() time.Time {
+				return time.Date(2020, time.August, 1, 0, 0, 0, 0, time.UTC)
+			},
+			seq:      1,
+			instance: newTestInstance("11111111", sessOptUIN(11111111)),
+			req: wire.ICQ_0x07D0_0x055F_DBQueryMetaReqSearchWhitePages2{
+				TLVRestBlock: wire.TLVRestBlock{TLVList: wire.TLVList{}},
+			},
+			mockParams: mockParams{
+				icqUserFinderParams: icqUserFinderParams{
+					searchICQUsersParams: searchICQUsersParams{
+						{
+							criteria: state.ICQUserSearchCriteria{},
+							err:      state.ErrICQSearchEmptyCriteria,
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x15_0x02_DBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													Success:    wire.ICQStatusCodeFail,
+													ReqSubType: wire.ICQDBQueryMetaReplyLastUserFound,
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{},
+				},
+			},
+		},
+		{
+			name: "search returns no users",
+			timeNow: func() time.Time {
+				return time.Date(2020, time.August, 1, 0, 0, 0, 0, time.UTC)
+			},
+			seq:      1,
+			instance: newTestInstance("11111111", sessOptUIN(11111111)),
+			req: wire.ICQ_0x07D0_0x055F_DBQueryMetaReqSearchWhitePages2{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVLE(wire.ICQTLVTagsWhitepagesSearchKeywords, struct {
+							Val string `oscar:"len_prefix=uint16,nullterm"`
+						}{
+							Val: "knitting",
+						}),
+					},
+				},
+			},
+			mockParams: mockParams{
+				icqUserFinderParams: icqUserFinderParams{
+					searchICQUsersParams: searchICQUsersParams{
+						{
+							criteria: state.ICQUserSearchCriteria{
+								AnyKeyword: new("knitting"),
+							},
+							result: []state.User{},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x15_0x02_DBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													Success:    wire.ICQStatusCodeFail,
+													ReqSubType: wire.ICQDBQueryMetaReplyLastUserFound,
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{},
+				},
+			},
+		},
+		{
+			name: "search by keyword (online only)",
+			timeNow: func() time.Time {
+				return time.Date(2020, time.August, 1, 0, 0, 0, 0, time.UTC)
+			},
+			seq:      1,
+			instance: newTestInstance("11111111", sessOptUIN(11111111)),
+			req: wire.ICQ_0x07D0_0x055F_DBQueryMetaReqSearchWhitePages2{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVLE(wire.ICQTLVTagsWhitepagesSearchKeywords, struct {
+							Val string `oscar:"len_prefix=uint16,nullterm"`
+						}{
+							Val: "knitting",
+						}),
+						wire.NewTLVLE(wire.ICQTLVTagsSearchOnlineUsersFlag, uint8(1)),
+					},
+				},
+			},
+			mockParams: mockParams{
+				icqUserFinderParams: icqUserFinderParams{
+					searchICQUsersParams: searchICQUsersParams{
+						{
+							criteria: state.ICQUserSearchCriteria{
+								AnyKeyword: new("knitting"),
+							},
+							result: []state.User{
+								{
+									IdentScreenName: state.NewIdentScreenName("987654321"),
+									ICQInfo: state.ICQInfo{
+										Basic: state.ICQBasicInfo{
+											EmailAddress: "janey@example.com",
+											FirstName:    "Jane",
+											LastName:     "Doe",
+											Nickname:     "Janey",
+										},
+										Permissions: state.ICQPermissions{
+											AuthRequired: false,
+										},
+										More: state.ICQMoreInfo{
+											BirthDay:   31,
+											BirthMonth: 7,
+											BirthYear:  1995,
+											Gender:     2,
+										},
+									},
+								},
+								{
+									IdentScreenName: state.NewIdentScreenName("123456789"),
+									ICQInfo: state.ICQInfo{
+										Basic: state.ICQBasicInfo{
+											EmailAddress: "alice@example.com",
+											FirstName:    "Alice",
+											LastName:     "Smith",
+											Nickname:     "Ally123",
+										},
+										Permissions: state.ICQPermissions{
+											AuthRequired: true,
+										},
+										More: state.ICQMoreInfo{
+											BirthDay:   31,
+											BirthMonth: 7,
+											BirthYear:  1999,
+											Gender:     1,
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x15_0x02_DBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryMetaReply,
+														Seq:     1,
+													},
+													Success:    wire.ICQStatusCodeOK,
+													ReqSubType: wire.ICQDBQueryMetaReplyLastUserFound,
+													Details: wire.ICQUserSearchRecord{
+														UIN:           123456789,
+														Nickname:      "Ally123",
+														FirstName:     "Alice",
+														LastName:      "Smith",
+														Email:         "alice@example.com",
+														Authorization: 0,
+														OnlineStatus:  1,
+														Gender:        1,
+														Age:           21,
+													},
+													LastMessageFooter: &struct {
+														FoundUsersLeft uint32
+													}{
+														FoundUsersLeft: 0,
+													},
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{
+						// filter step
+						{
+							screenName: state.NewIdentScreenName("987654321"),
+							result:     nil,
+						},
+						{
+							screenName: state.NewIdentScreenName("123456789"),
+							result:     state.NewSession(),
+						},
+						// createResult step (only for the included user)
+						{
+							screenName: state.NewIdentScreenName("123456789"),
+							result:     state.NewSession(),
+						},
+					},
+				},
+			},
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			userFinder := newMockICQUserFinder(t)
-			for _, params := range tt.mockParams.findByKeywordParams {
+			for _, params := range tt.mockParams.searchICQUsersParams {
 				userFinder.EXPECT().
-					FindByICQKeyword(matchContext(), params.keyword).
-					Return(params.result, params.err)
-			}
-			for _, params := range tt.mockParams.findByDetailsParams {
-				userFinder.EXPECT().
-					FindByICQName(matchContext(), params.firstName, params.lastName, params.nickName).
+					SearchICQUsers(matchContext(), params.criteria).
 					Return(params.result, params.err)
 			}
 
@@ -1342,6 +1857,7 @@ func TestICQService_FindByWhitePages2(t *testing.T) {
 			}
 
 			s := ICQService{
+				logger:           slog.New(slog.NewTextHandler(io.Discard, nil)),
 				messageRelayer:   messageRelayer,
 				sessionRetriever: sessionRetriever,
 				timeNow:          tt.timeNow,
@@ -1519,23 +2035,21 @@ func TestICQService_FullUserInfo(t *testing.T) {
 														ReqType: wire.ICQDBQueryMetaReply,
 														Seq:     1,
 													},
-													Success:    wire.ICQStatusCodeOK,
-													ReqSubType: wire.ICQDBQueryMetaReplyMoreInfo,
-													ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo: wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo{
-														Age:          30,
-														Gender:       1,
-														HomePageAddr: "https://johnsdomain.com",
-														BirthYear:    1990,
-														BirthMonth:   6,
-														BirthDay:     15,
-														Lang1:        1,
-														Lang2:        2,
-														Lang3:        3,
-													},
-													City:        "New York",
-													State:       "NY",
-													CountryCode: 1,
-													TimeZone:    5,
+													Success:      wire.ICQStatusCodeOK,
+													ReqSubType:   wire.ICQDBQueryMetaReplyMoreInfo,
+													Age:          30,
+													Gender:       1,
+													HomePageAddr: "https://johnsdomain.com",
+													BirthYear:    1990,
+													BirthMonth:   6,
+													BirthDay:     15,
+													Lang1:        1,
+													Lang2:        2,
+													Lang3:        3,
+													City:         "New York",
+													State:        "NY",
+													CountryCode:  1,
+													TimeZone:     5,
 												},
 											}),
 										},
@@ -2481,16 +2995,6 @@ func TestICQService_SetICQInfo(t *testing.T) {
 		}{V: v})
 	}
 
-	type ecombo struct {
-		Email   string `oscar:"len_prefix=uint16,nullterm"`
-		Publish uint8
-	}
-	type bcombo struct {
-		Year  uint16
-		Month uint16
-		Day   uint16
-	}
-
 	expectedReply := wire.SNACMessage{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.ICQ,
@@ -2533,7 +3037,10 @@ func TestICQService_SetICQInfo(t *testing.T) {
 						sstring(wire.ICQTLVTagsFirstName, "John"),
 						sstring(wire.ICQTLVTagsLastName, "Doe"),
 						sstring(wire.ICQTLVTagsNickname, "Johnny"),
-						wire.NewTLVLE(wire.ICQTLVTagsEmail, ecombo{Email: "j@d.com", Publish: 0}),
+						wire.NewTLVLE(wire.ICQTLVTagsEmail, struct {
+							Email   string `oscar:"len_prefix=uint16,nullterm"`
+							Publish uint8
+						}{Email: "j@d.com", Publish: 0}),
 						sstring(wire.ICQTLVTagsHomeCityName, "Anytown"),
 						sstring(wire.ICQTLVTagsHomeStateAbbr, "CA"),
 						wire.NewTLVLE(wire.ICQTLVTagsHomeCountryCode, uint16(840)),
@@ -2545,9 +3052,12 @@ func TestICQService_SetICQInfo(t *testing.T) {
 						wire.NewTLVLE(wire.ICQTLVTagsGMTOffset, uint8(5)),
 
 						wire.NewTLVLE(wire.ICQTLVTagsGender, uint8(2)),
-						wire.NewTLVLE(wire.ICQTLVTagsBirthdayInfo, bcombo{Year: 1990, Month: 5, Day: 15}),
-						wire.NewTLVLE(wire.ICQTLVTagsHomepageURL, wire.ICQInterests{Code: 0, Keyword: "http://johnd.example.com"}),
-
+						wire.NewTLVLE(wire.ICQTLVTagsBirthdayInfo, struct {
+							Year  uint16
+							Month uint16
+							Day   uint16
+						}{Year: 1990, Month: 5, Day: 15}),
+						sstring(wire.ICQTLVTagsHomepageURL, "http://johnd.example.com"),
 						sstring(wire.ICQTLVTagsWorkCompanyName, "Acme"),
 						sstring(wire.ICQTLVTagsWorkDepartmentName, "Eng"),
 						sstring(wire.ICQTLVTagsWorkPositionTitle, "SWE"),

+ 68 - 0
foodgroup/mock_icq_user_finder_test.go

@@ -391,3 +391,71 @@ func (_c *mockICQUserFinder_FindByUIN_Call) RunAndReturn(run func(ctx context.Co
 	_c.Call.Return(run)
 	return _c
 }
+
+// SearchICQUsers provides a mock function for the type mockICQUserFinder
+func (_mock *mockICQUserFinder) SearchICQUsers(ctx context.Context, c state.ICQUserSearchCriteria) ([]state.User, error) {
+	ret := _mock.Called(ctx, c)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SearchICQUsers")
+	}
+
+	var r0 []state.User
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.ICQUserSearchCriteria) ([]state.User, error)); ok {
+		return returnFunc(ctx, c)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.ICQUserSearchCriteria) []state.User); ok {
+		r0 = returnFunc(ctx, c)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]state.User)
+		}
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, state.ICQUserSearchCriteria) error); ok {
+		r1 = returnFunc(ctx, c)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockICQUserFinder_SearchICQUsers_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SearchICQUsers'
+type mockICQUserFinder_SearchICQUsers_Call struct {
+	*mock.Call
+}
+
+// SearchICQUsers is a helper method to define mock.On call
+//   - ctx context.Context
+//   - c state.ICQUserSearchCriteria
+func (_e *mockICQUserFinder_Expecter) SearchICQUsers(ctx interface{}, c interface{}) *mockICQUserFinder_SearchICQUsers_Call {
+	return &mockICQUserFinder_SearchICQUsers_Call{Call: _e.mock.On("SearchICQUsers", ctx, c)}
+}
+
+func (_c *mockICQUserFinder_SearchICQUsers_Call) Run(run func(ctx context.Context, c state.ICQUserSearchCriteria)) *mockICQUserFinder_SearchICQUsers_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 state.ICQUserSearchCriteria
+		if args[1] != nil {
+			arg1 = args[1].(state.ICQUserSearchCriteria)
+		}
+		run(
+			arg0,
+			arg1,
+		)
+	})
+	return _c
+}
+
+func (_c *mockICQUserFinder_SearchICQUsers_Call) Return(users []state.User, err error) *mockICQUserFinder_SearchICQUsers_Call {
+	_c.Call.Return(users, err)
+	return _c
+}
+
+func (_c *mockICQUserFinder_SearchICQUsers_Call) RunAndReturn(run func(ctx context.Context, c state.ICQUserSearchCriteria) ([]state.User, error)) *mockICQUserFinder_SearchICQUsers_Call {
+	_c.Call.Return(run)
+	return _c
+}

+ 4 - 0
foodgroup/types.go

@@ -272,6 +272,10 @@ type ICQUserFinder interface {
 
 	// FindByICQKeyword returns users who have the specified keyword in any interest category.
 	FindByICQKeyword(ctx context.Context, keyword string) ([]state.User, error)
+
+	// SearchICQUsers performs a flexible, AND-combined search over ICQ directory
+	// fields (white pages).
+	SearchICQUsers(ctx context.Context, c state.ICQUserSearchCriteria) ([]state.User, error)
 }
 
 // ICQUserUpdater defines methods for updating various fields of an ICQ user's profile.

+ 1 - 1
go.mod

@@ -1,6 +1,6 @@
 module github.com/mk6i/open-oscar-server
 
-go 1.24.2
+go 1.26.0
 
 require (
 	github.com/breign/goAMF3 v1.0.1-0.20250916173039-e43798221950

+ 46 - 46
scripts/populate_icq_users.sh

@@ -24,13 +24,13 @@ API="${1:-http://127.0.0.1:8080}"
 #   6=Community, 7=Education, 8=Engineering, 9=Financial Services, 10=Government, 11=High School Student,
 #   12=Home, 13=ICQ-Providing Help, 14=Law, 15=Managerial, 16=Manufacturing, 17=Medical/Health,
 #   18=Military, 19=Not Employed, 20=Other Services, 99=Retired
-# ICQ affiliation codes (past): 200=Elementary School, 201=High School, 202=College, 203=University,
-#   204=Military, 205=Past Work Place, 206=Past Organization, 207=Other
-# ICQ affiliation codes (current): 300=Alumni Org, 301=Charity Org, 302=Club/Social Org, 303=Community Org,
-#   304=Cultural Org, 305=Fan Clubs, 306=Fraternity/Sorority, 307=Hobbyists Org, 308=International Org,
-#   309=Nature and Environment Org, 310=Professional Org, 311=Scientific/Technical Org, 312=Self Improvement,
-#   313=Spiritual/Religious Org, 314=Sports Org, 315=Support Org, 316=Trade and Business Org,
-#   317=Union, 318=Volunteer Org, 399=Other
+# ICQ affiliation codes (past): 300=Elementary School, 301=High School, 302=College, 303=University,
+#   304=Military, 305=Past Work Place, 306=Past Organization, 307=Other
+# ICQ affiliation codes (current): 200=Alumni Org, 201=Charity Org, 202=Club/Social Org, 203=Community Org,
+#   204=Cultural Org, 205=Fan Clubs, 206=Fraternity/Sorority, 207=Hobbyists Org, 208=International Org,
+#   209=Nature and Environment Org, 210=Professional Org, 211=Scientific/Technical Org, 212=Self Improvement,
+#   213=Spiritual/Religious Org, 214=Sports Org, 215=Support Org, 216=Trade and Business Org,
+#   217=Union, 218=Volunteer Org, 299=Other
 # Gender: 0=not specified, 1=female, 2=male
 
 set -e
@@ -115,11 +115,11 @@ set_profile 123400 '{
     "code4": 120, "keyword4": "Basketball"
   },
   "affiliations": {
-    "past_code1": 203, "past_keyword1": "Eastfield University",
-    "past_code2": 205, "past_keyword2": "Orion Software",
+    "past_code1": 303, "past_keyword1": "Eastfield University",
+    "past_code2": 305, "past_keyword2": "Orion Software",
     "past_code3": 0, "past_keyword3": "",
-    "current_code1": 310, "current_keyword1": "Dev Guild",
-    "current_code2": 314, "current_keyword2": "NYC Basketball League",
+    "current_code1": 210, "current_keyword1": "Dev Guild",
+    "current_code2": 214, "current_keyword2": "NYC Basketball League",
     "current_code3": 0, "current_keyword3": ""
   },
   "permissions": {
@@ -181,12 +181,12 @@ set_profile 123401 '{
     "code4": 124, "keyword4": "International Travel Photography and Adventure Tourism Planning"
   },
   "affiliations": {
-    "past_code1": 203, "past_keyword1": "Westbridge University College of Computer Science and Engineerin",
-    "past_code2": 202, "past_keyword2": "Kingsfield College Department of Computing and Applied Engineeri",
-    "past_code3": 201, "past_keyword3": "Thornhill Academy for Advanced Mathematics and Natural Sciences",
-    "current_code1": 308, "current_keyword1": "International Association for Computing Professionals UK Chapter",
-    "current_code2": 311, "current_keyword2": "Royal Society for the Encouragement of Arts Manufactures Commer",
-    "current_code3": 304, "current_keyword3": "Heritage Museum Friends Society and Cultural Heritage Preservati"
+    "past_code1": 303, "past_keyword1": "Westbridge University College of Computer Science and Engineerin",
+    "past_code2": 302, "past_keyword2": "Kingsfield College Department of Computing and Applied Engineeri",
+    "past_code3": 301, "past_keyword3": "Thornhill Academy for Advanced Mathematics and Natural Sciences",
+    "current_code1": 208, "current_keyword1": "International Association for Computing Professionals UK Chapter",
+    "current_code2": 211, "current_keyword2": "Royal Society for the Encouragement of Arts Manufactures Commer",
+    "current_code3": 204, "current_keyword3": "Heritage Museum Friends Society and Cultural Heritage Preservati"
   },
   "permissions": {
     "auth_required": true,
@@ -247,10 +247,10 @@ set_profile 123402 '{
     "code4": 106, "keyword4": "Cycling"
   },
   "affiliations": {
-    "past_code1": 203, "past_keyword1": "Berlin Technical University",
+    "past_code1": 303, "past_keyword1": "Berlin Technical University",
     "past_code2": 0, "past_keyword2": "",
     "past_code3": 0, "past_keyword3": "",
-    "current_code1": 310, "current_keyword1": "VDI Engineers Association",
+    "current_code1": 210, "current_keyword1": "VDI Engineers Association",
     "current_code2": 0, "current_keyword2": "",
     "current_code3": 0, "current_keyword3": ""
   },
@@ -313,10 +313,10 @@ set_profile 123403 '{
     "code4": 136, "keyword4": "Cooking"
   },
   "affiliations": {
-    "past_code1": 203, "past_keyword1": "Kyiv National University",
-    "past_code2": 205, "past_keyword2": "Sunrise Media Group",
+    "past_code1": 303, "past_keyword1": "Kyiv National University",
+    "past_code2": 303, "Westbridge University College of Computer Science and Engineerin",
     "past_code3": 0, "past_keyword3": "",
-    "current_code1": 304, "current_keyword1": "Kyiv Art Society",
+    "current_code1": 204, "current_keyword1": "Kyiv Art Society",
     "current_code2": 0, "current_keyword2": "",
     "current_code3": 0, "current_keyword3": ""
   },
@@ -343,7 +343,7 @@ set_profile 123404 '{
     "fax": "",
     "address": "Herzl Blvd 42",
     "cell_phone": "+972-555-054-0100",
-    "zip": "6688312",
+    "zip": "6688212",
     "country_code": 972,
     "gmt_offset": 2,
     "publish_email": false
@@ -380,10 +380,10 @@ set_profile 123404 '{
     "code4": 120, "keyword4": "Football"
   },
   "affiliations": {
-    "past_code1": 203, "past_keyword1": "Haifa Institute of Technology",
-    "past_code2": 204, "past_keyword2": "Military Service",
-    "past_code3": 205, "past_keyword3": "Firewall Systems Ltd",
-    "current_code1": 310, "current_keyword1": "National Cyber Directorate",
+    "past_code1": 303, "past_keyword1": "Haifa Institute of Technology",
+    "past_code2": 304, "past_keyword2": "Military Service",
+    "past_code3": 305, "past_keyword3": "Firewall Systems Ltd",
+    "current_code1": 210, "current_keyword1": "National Cyber Directorate",
     "current_code2": 316, "current_keyword2": "Tel Aviv Tech Hub",
     "current_code3": 0, "current_keyword3": ""
   },
@@ -405,8 +405,8 @@ set_profile 123405 '{
     "email": "yuki.tanaka@example.jp",
     "city": "Tokyo",
     "state": "Tokyo",
-    "phone": "+81-555-300-0100",
-    "fax": "+81-555-300-0101",
+    "phone": "+81-555-200-0100",
+    "fax": "+81-555-200-0101",
     "address": "Shibuya 2-21-1",
     "cell_phone": "+81-555-900-0100",
     "zip": "150-0002",
@@ -446,11 +446,11 @@ set_profile 123405 '{
     "code4": 103, "keyword4": "Figurines"
   },
   "affiliations": {
-    "past_code1": 203, "past_keyword1": "Tokyo University of the Arts",
+    "past_code1": 303, "past_keyword1": "Tokyo University of the Arts",
     "past_code2": 0, "past_keyword2": "",
     "past_code3": 0, "past_keyword3": "",
-    "current_code1": 305, "current_keyword1": "Retro Gaming Club Japan",
-    "current_code2": 307, "current_keyword2": "Tokyo Pixel Artists",
+    "current_code1": 205, "current_keyword1": "Retro Gaming Club Japan",
+    "current_code2": 207, "current_keyword2": "Tokyo Pixel Artists",
     "current_code3": 0, "current_keyword3": ""
   },
   "permissions": {
@@ -475,7 +475,7 @@ set_profile 123406 '{
     "fax": "",
     "address": "Av Ipiranga 1578",
     "cell_phone": "+55-555-119-0100",
-    "zip": "01310-200",
+    "zip": "01210-200",
     "country_code": 55,
     "gmt_offset": 253,
     "publish_email": true
@@ -512,10 +512,10 @@ set_profile 123406 '{
     "code4": 120, "keyword4": "Surfing"
   },
   "affiliations": {
-    "past_code1": 202, "past_keyword1": "SP Conservatory of Music",
+    "past_code1": 302, "past_keyword1": "SP Conservatory of Music",
     "past_code2": 0, "past_keyword2": "",
     "past_code3": 0, "past_keyword3": "",
-    "current_code1": 302, "current_keyword1": "SP Electronic Music Collective",
+    "current_code1": 202, "current_keyword1": "SP Electronic Music Collective",
     "current_code2": 0, "current_keyword2": "",
     "current_code3": 0, "current_keyword3": ""
   },
@@ -578,12 +578,12 @@ set_profile 123407 '{
     "code4": 125, "keyword4": "Astronomy"
   },
   "affiliations": {
-    "past_code1": 203, "past_keyword1": "Southport University",
-    "past_code2": 203, "past_keyword2": "Reef Coast University",
+    "past_code1": 303, "past_keyword1": "Southport University",
+    "past_code2": 303, "past_keyword2": "Reef Coast University",
     "past_code3": 0, "past_keyword3": "",
-    "current_code1": 311, "current_keyword1": "Australian Marine Sciences Assoc",
-    "current_code2": 309, "current_keyword2": "Reef Conservation Foundation",
-    "current_code3": 310, "current_keyword3": "Royal Society of NSW"
+    "current_code1": 211, "current_keyword1": "Australian Marine Sciences Assoc",
+    "current_code2": 209, "current_keyword2": "Reef Conservation Foundation",
+    "current_code3": 210, "current_keyword3": "Royal Society of NSW"
   },
   "permissions": {
     "auth_required": true,
@@ -645,10 +645,10 @@ set_profile 123408 '{
     "code4": 124, "keyword4": "European Travel"
   },
   "affiliations": {
-    "past_code1": 203, "past_keyword1": "Sorbonne Business School",
-    "past_code2": 205, "past_keyword2": "Alpine Investments",
+    "past_code1": 303, "past_keyword1": "Sorbonne Business School",
+    "past_code2": 305, "past_keyword2": "Alpine Investments",
     "past_code3": 0, "past_keyword3": "",
-    "current_code1": 310, "current_keyword1": "European Finance Association",
+    "current_code1": 210, "current_keyword1": "European Finance Association",
     "current_code2": 316, "current_keyword2": "Paris Fintech Forum",
     "current_code3": 0, "current_keyword3": ""
   },
@@ -711,11 +711,11 @@ set_profile 123409 '{
     "code4": 113, "keyword4": "Electronic Music"
   },
   "affiliations": {
-    "past_code1": 201, "past_keyword1": "Lviv Lyceum No 1",
+    "past_code1": 301, "past_keyword1": "Lviv Lyceum No 1",
     "past_code2": 0, "past_keyword2": "",
     "past_code3": 0, "past_keyword3": "",
-    "current_code1": 302, "current_keyword1": "Lviv Open Source Community",
-    "current_code2": 307, "current_keyword2": "Retro Computing Club",
+    "current_code1": 202, "current_keyword1": "Lviv Open Source Community",
+    "current_code2": 207, "current_keyword2": "Retro Computing Club",
     "current_code3": 0, "current_keyword3": ""
   },
   "permissions": {

+ 68 - 0
server/icq_legacy/mock_icq_user_finder_test.go

@@ -391,3 +391,71 @@ func (_c *mockICQUserFinder_FindByUIN_Call) RunAndReturn(run func(ctx context.Co
 	_c.Call.Return(run)
 	return _c
 }
+
+// SearchICQUsers provides a mock function for the type mockICQUserFinder
+func (_mock *mockICQUserFinder) SearchICQUsers(ctx context.Context, c state.ICQUserSearchCriteria) ([]state.User, error) {
+	ret := _mock.Called(ctx, c)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SearchICQUsers")
+	}
+
+	var r0 []state.User
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.ICQUserSearchCriteria) ([]state.User, error)); ok {
+		return returnFunc(ctx, c)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.ICQUserSearchCriteria) []state.User); ok {
+		r0 = returnFunc(ctx, c)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]state.User)
+		}
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, state.ICQUserSearchCriteria) error); ok {
+		r1 = returnFunc(ctx, c)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockICQUserFinder_SearchICQUsers_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SearchICQUsers'
+type mockICQUserFinder_SearchICQUsers_Call struct {
+	*mock.Call
+}
+
+// SearchICQUsers is a helper method to define mock.On call
+//   - ctx context.Context
+//   - c state.ICQUserSearchCriteria
+func (_e *mockICQUserFinder_Expecter) SearchICQUsers(ctx interface{}, c interface{}) *mockICQUserFinder_SearchICQUsers_Call {
+	return &mockICQUserFinder_SearchICQUsers_Call{Call: _e.mock.On("SearchICQUsers", ctx, c)}
+}
+
+func (_c *mockICQUserFinder_SearchICQUsers_Call) Run(run func(ctx context.Context, c state.ICQUserSearchCriteria)) *mockICQUserFinder_SearchICQUsers_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 state.ICQUserSearchCriteria
+		if args[1] != nil {
+			arg1 = args[1].(state.ICQUserSearchCriteria)
+		}
+		run(
+			arg0,
+			arg1,
+		)
+	})
+	return _c
+}
+
+func (_c *mockICQUserFinder_SearchICQUsers_Call) Return(users []state.User, err error) *mockICQUserFinder_SearchICQUsers_Call {
+	_c.Call.Return(users, err)
+	return _c
+}
+
+func (_c *mockICQUserFinder_SearchICQUsers_Call) RunAndReturn(run func(ctx context.Context, c state.ICQUserSearchCriteria) ([]state.User, error)) *mockICQUserFinder_SearchICQUsers_Call {
+	_c.Call.Return(run)
+	return _c
+}

+ 1 - 0
server/icq_legacy/types.go

@@ -315,6 +315,7 @@ type ICQUserFinder interface {
 	FindByICQName(ctx context.Context, firstName, lastName, nickName string) ([]state.User, error)
 	FindByICQInterests(ctx context.Context, code uint16, keywords []string) ([]state.User, error)
 	FindByICQKeyword(ctx context.Context, keyword string) ([]state.User, error)
+	SearchICQUsers(ctx context.Context, c state.ICQUserSearchCriteria) ([]state.User, error)
 }
 
 // ICQUserUpdater provides profile update capabilities for legacy ICQ operations.

+ 216 - 6
state/user_store.go

@@ -38,6 +38,10 @@ var (
 	ErrOfflineInboxFull        = errors.New("offline inbox full")
 	errTooManyCategories       = errors.New("there are too many keyword categories")
 	errTooManyKeywords         = errors.New("there are too many keywords")
+
+	// ErrICQSearchEmptyCriteria indicates the caller did not provide any search
+	// constraints. This prevents accidental full-table scans.
+	ErrICQSearchEmptyCriteria = errors.New("ICQ search criteria is empty")
 )
 
 //go:embed migrations/*
@@ -277,10 +281,11 @@ func (f SQLiteUserStore) FindByAIMNameAndAddr(ctx context.Context, info AIMNameA
 	return users, nil
 }
 
-func (f SQLiteUserStore) FindByICQInterests(ctx context.Context, code uint16, keywords []string) ([]User, error) {
-	var args []any
+// icqInterestsWhereClause builds SQL and args for matching a category code and
+// keyword(s) against the four stored interest slots (same semantics as
+// FindByICQInterests).
+func icqInterestsWhereClause(code uint16, keywords []string) (cond string, args []any) {
 	var clauses []string
-
 	for i := 1; i <= 4; i++ {
 		var subClauses []string
 		args = append(args, code)
@@ -290,14 +295,32 @@ func (f SQLiteUserStore) FindByICQInterests(ctx context.Context, code uint16, ke
 		}
 		clauses = append(clauses, fmt.Sprintf("(icq_interests_code%d = ? AND (%s))", i, strings.Join(subClauses, " OR ")))
 	}
+	return strings.Join(clauses, " OR "), args
+}
 
-	cond := strings.Join(clauses, " OR ")
+// icqAffiliationsWhereClause builds SQL and args for matching a category code and
+// keyword(s) across the six stored affiliation slots (three current + three past),
+// using the same OR-of-slots pattern as icqInterestsWhereClause.
+func icqAffiliationsWhereClause(code uint16, keywords []string, slots []struct{ codeCol, keyCol string }) (cond string, args []any) {
+	var clauses []string
+	for _, slot := range slots {
+		var subClauses []string
+		args = append(args, code)
+		for _, key := range keywords {
+			subClauses = append(subClauses, fmt.Sprintf("%s LIKE ?", slot.keyCol))
+			args = append(args, "%"+key+"%")
+		}
+		clauses = append(clauses, fmt.Sprintf("(%s = ? AND (%s))", slot.codeCol, strings.Join(subClauses, " OR ")))
+	}
+	return strings.Join(clauses, " OR "), args
+}
 
+func (f SQLiteUserStore) FindByICQInterests(ctx context.Context, code uint16, keywords []string) ([]User, error) {
+	cond, args := icqInterestsWhereClause(code, keywords)
 	users, err := f.queryUsers(ctx, cond, args)
 	if err != nil {
-		err = fmt.Errorf("FindByICQInterests: %w", err)
+		return nil, fmt.Errorf("FindByICQInterests: %w", err)
 	}
-
 	return users, nil
 }
 
@@ -320,6 +343,193 @@ func (f SQLiteUserStore) FindByICQKeyword(ctx context.Context, keyword string) (
 	return users, nil
 }
 
+// ICQUserSearchCriteria represents an AND-combined set of filters for ICQ
+// white-pages style searches (SNAC(15,02) / 0x07D0 / 0x055F).
+//
+// Fields are pointers so callers can distinguish "unset" from "set to the
+// zero value". For example, MinAge == nil means no minimum age constraint,
+// while MinAge != nil && *MinAge == 0 means the client explicitly set 0.
+type ICQUserSearchCriteria struct {
+	// Identity
+	UIN *uint32
+
+	// Basic name/email (case-insensitive substring matches). When non-nil, the
+	// string is non-empty.
+	FirstName *string
+	LastName  *string
+	NickName  *string
+	Email     *string
+
+	// Demographics
+	MinAge *uint16
+	MaxAge *uint16
+
+	Gender         *uint8
+	SpokenLanguage *uint8
+
+	// Location / work (non-nil *string fields are non-empty)
+	City        *string
+	State       *string
+	CountryCode *uint16
+
+	Company        *string // non-nil => non-empty
+	Position       *string // non-nil => non-empty
+	DepartmentName *string // non-nil => non-empty
+	OccupationCode *uint16
+
+	// Directory nodes (code + keywords). InterestsCode and InterestsKeywords are
+	// either both unset or both set; when code is non-nil, keywords must be non-empty.
+	InterestsCode     *uint16
+	InterestsKeywords []string
+
+	// AffiliationsCode and AffiliationsKeywords follow the same pairing rules as
+	// InterestsCode / InterestsKeywords.
+	AffiliationsCode     *uint16
+	AffiliationsKeywords []string
+
+	// PastAffiliationsCode and PastAffiliationsKeywords follow the same pairing rules as
+	// InterestsCode / InterestsKeywords.
+	PastAffiliationsCode     *uint16
+	PastAffiliationsKeywords []string
+
+	HomePageCategoryIndex *uint16
+	HomePageKeywords      []string
+
+	// Whitepages search keywords string (TLV 0x0226). This is modeled as a
+	// global keyword across searchable profile fields.
+	AnyKeyword *string
+}
+
+// SearchICQUsers returns ICQ users whose stored profile fields satisfy every
+// non-empty constraint in c. Predicates are AND-combined. Only accounts with
+// isICQ = 1 are considered.
+//
+// If c contains no constraints, SearchICQUsers returns [ErrICQSearchEmptyCriteria].
+func (f SQLiteUserStore) SearchICQUsers(ctx context.Context, c ICQUserSearchCriteria) ([]User, error) {
+	var args []any
+	var clauses []string
+
+	appendLike := func(column string, val string) {
+		args = append(args, "%"+val+"%")
+		clauses = append(clauses, fmt.Sprintf(`LOWER(%s) LIKE LOWER(?)`, column))
+	}
+
+	if c.UIN != nil {
+		args = append(args, strconv.Itoa(int(*c.UIN)))
+		clauses = append(clauses, `identScreenName = ?`)
+	}
+	if c.FirstName != nil {
+		appendLike("icq_basicInfo_firstName", *c.FirstName)
+	}
+	if c.LastName != nil {
+		appendLike("icq_basicInfo_lastName", *c.LastName)
+	}
+	if c.NickName != nil {
+		appendLike("icq_basicInfo_nickName", *c.NickName)
+	}
+	if c.Email != nil {
+		appendLike("icq_basicInfo_emailAddress", *c.Email)
+	}
+	if c.Gender != nil {
+		args = append(args, *c.Gender)
+		clauses = append(clauses, `icq_moreInfo_gender = ?`)
+	}
+	if c.SpokenLanguage != nil {
+		args = append(args, *c.SpokenLanguage, *c.SpokenLanguage, *c.SpokenLanguage)
+		clauses = append(clauses, `(icq_moreInfo_lang1 = ? OR icq_moreInfo_lang2 = ? OR icq_moreInfo_lang3 = ?)`)
+	}
+	if c.MinAge != nil {
+		args = append(args, *c.MinAge)
+		clauses = append(clauses, `(icq_moreInfo_birthYear > 0 AND (CAST(strftime('%Y','now') AS INTEGER) - icq_moreInfo_birthYear) >= ?)`)
+	}
+	if c.MaxAge != nil {
+		args = append(args, *c.MaxAge)
+		clauses = append(clauses, `(icq_moreInfo_birthYear > 0 AND (CAST(strftime('%Y','now') AS INTEGER) - icq_moreInfo_birthYear) <= ?)`)
+	}
+	if c.City != nil {
+		appendLike("icq_basicInfo_city", *c.City)
+	}
+	if c.State != nil {
+		args = append(args, "%"+*c.State+"%", "%"+*c.State+"%")
+		clauses = append(clauses, `(LOWER(icq_basicInfo_state) LIKE LOWER(?) OR LOWER(icq_workInfo_state) LIKE LOWER(?))`)
+	}
+	if c.CountryCode != nil {
+		args = append(args, *c.CountryCode, *c.CountryCode)
+		clauses = append(clauses, `(icq_basicInfo_countryCode = ? OR icq_workInfo_countryCode = ?)`)
+	}
+	if c.Company != nil {
+		appendLike("icq_workInfo_company", *c.Company)
+	}
+	if c.DepartmentName != nil {
+		appendLike("icq_workInfo_department", *c.DepartmentName)
+	}
+	if c.Position != nil {
+		appendLike("icq_workInfo_position", *c.Position)
+	}
+	if c.OccupationCode != nil {
+		args = append(args, *c.OccupationCode)
+		clauses = append(clauses, `icq_workInfo_occupationCode = ?`)
+	}
+	if c.InterestsCode != nil {
+		intCond, intArgs := icqInterestsWhereClause(*c.InterestsCode, c.InterestsKeywords)
+		clauses = append(clauses, "("+intCond+")")
+		args = append(args, intArgs...)
+	}
+	if c.AffiliationsCode != nil {
+		slots := []struct{ codeCol, keyCol string }{
+			{"icq_affiliations_currentCode1", "icq_affiliations_currentKeyword1"},
+			{"icq_affiliations_currentCode2", "icq_affiliations_currentKeyword2"},
+			{"icq_affiliations_currentCode3", "icq_affiliations_currentKeyword3"},
+		}
+		affCond, affArgs := icqAffiliationsWhereClause(*c.AffiliationsCode, c.AffiliationsKeywords, slots)
+		clauses = append(clauses, "("+affCond+")")
+		args = append(args, affArgs...)
+	}
+	if c.PastAffiliationsCode != nil {
+		slots := []struct{ codeCol, keyCol string }{
+			{"icq_affiliations_pastCode1", "icq_affiliations_pastKeyword1"},
+			{"icq_affiliations_pastCode2", "icq_affiliations_pastKeyword2"},
+			{"icq_affiliations_pastCode3", "icq_affiliations_pastKeyword3"},
+		}
+		affCond, affArgs := icqAffiliationsWhereClause(*c.PastAffiliationsCode, c.PastAffiliationsKeywords, slots)
+		clauses = append(clauses, "("+affCond+")")
+		args = append(args, affArgs...)
+	}
+	if c.HomePageCategoryIndex != nil {
+		args = append(args, *c.HomePageCategoryIndex)
+		clauses = append(clauses, `icq_homepageCategory_index = ?`)
+	}
+	if len(c.HomePageKeywords) > 0 {
+		var kwClauses []string
+		for _, kw := range c.HomePageKeywords {
+			kwClauses = append(kwClauses, `LOWER(icq_homepageCategory_description) LIKE LOWER(?)`)
+			args = append(args, "%"+kw+"%")
+		}
+		clauses = append(clauses, fmt.Sprintf("(%s)", strings.Join(kwClauses, " OR ")))
+	}
+	if c.AnyKeyword != nil {
+		var kwClauses []string
+		for i := 1; i <= 4; i++ {
+			kwClauses = append(kwClauses, fmt.Sprintf("icq_interests_keyword%d LIKE ?", i))
+			args = append(args, "%"+*c.AnyKeyword+"%")
+		}
+		clauses = append(clauses, fmt.Sprintf("(%s)", strings.Join(kwClauses, " OR ")))
+	}
+
+	if len(clauses) == 0 {
+		return nil, ErrICQSearchEmptyCriteria
+	}
+
+	clauses = append(clauses, `isICQ = 1`)
+
+	whereClause := strings.Join(clauses, " AND ")
+	users, err := f.queryUsers(ctx, whereClause, args)
+	if err != nil {
+		return nil, fmt.Errorf("SearchICQUsers: %w", err)
+	}
+	return users, nil
+}
+
 func (f SQLiteUserStore) User(ctx context.Context, screenName IdentScreenName) (*User, error) {
 	users, err := f.queryUsers(ctx, `identScreenName = ?`, []any{screenName.String()})
 	if err != nil {

+ 380 - 0
state/user_store_test.go

@@ -1946,6 +1946,386 @@ func TestSQLiteUserStore_FindByDirectoryInfo(t *testing.T) {
 	})
 }
 
+func TestSQLiteUserStore_SearchICQUsers(t *testing.T) {
+	newStore := func(t *testing.T) (*SQLiteUserStore, string) {
+		t.Helper()
+
+		dbFile := fmt.Sprintf("icq_search_test_%s.db", uuid.NewString())
+
+		f, err := NewSQLiteUserStore(dbFile)
+		require.NoError(t, err)
+		return f, dbFile
+	}
+
+	seedUsers := func(t *testing.T, f *SQLiteUserStore) (User, User) {
+		t.Helper()
+
+		user1 := User{
+			IdentScreenName: NewIdentScreenName("123456"),
+			IsICQ:           true,
+		}
+		require.NoError(t, f.InsertUser(context.Background(), user1))
+		require.NoError(t, f.SetBasicInfo(context.Background(), user1.IdentScreenName, ICQBasicInfo{
+			FirstName:    "Jane",
+			LastName:     "Doe",
+			Nickname:     "Janey",
+			City:         "New York",
+			State:        "NY",
+			EmailAddress: "jane@example.com",
+			CountryCode:  100,
+		}))
+		require.NoError(t, f.SetMoreInfo(context.Background(), user1.IdentScreenName, ICQMoreInfo{
+			BirthYear: 1985,
+			Gender:    2,
+			Lang1:     10,
+		}))
+		require.NoError(t, f.SetWorkInfo(context.Background(), user1.IdentScreenName, ICQWorkInfo{
+			Company:        "Acme Corp",
+			Department:     "Engineering",
+			Position:       "Engineer",
+			OccupationCode: 500,
+			CountryCode:    300,
+		}))
+		require.NoError(t, f.SetInterests(context.Background(), user1.IdentScreenName, ICQInterests{
+			Code1:    1,
+			Keyword1: "Knitting",
+		}))
+		require.NoError(t, f.SetAffiliations(context.Background(), user1.IdentScreenName, ICQAffiliations{
+			CurrentCode1:    99,
+			CurrentKeyword1: "RoboticsTeam",
+			PastCode1:       100,
+			PastKeyword1:    "GhostTeam",
+		}))
+
+		user2 := User{
+			IdentScreenName: NewIdentScreenName("234567"),
+			IsICQ:           true,
+		}
+		require.NoError(t, f.InsertUser(context.Background(), user2))
+		require.NoError(t, f.SetBasicInfo(context.Background(), user2.IdentScreenName, ICQBasicInfo{
+			FirstName:    "Alice",
+			LastName:     "Smith",
+			Nickname:     "Ally",
+			City:         "Boston",
+			State:        "MA",
+			EmailAddress: "alice@example.com",
+			CountryCode:  200,
+		}))
+		require.NoError(t, f.SetMoreInfo(context.Background(), user2.IdentScreenName, ICQMoreInfo{
+			BirthYear: 2005,
+			Gender:    1,
+			Lang1:     20,
+		}))
+		require.NoError(t, f.SetWorkInfo(context.Background(), user2.IdentScreenName, ICQWorkInfo{
+			Company:        "Globex",
+			Position:       "Manager",
+			OccupationCode: 600,
+			CountryCode:    400,
+		}))
+		require.NoError(t, f.SetInterests(context.Background(), user2.IdentScreenName, ICQInterests{
+			Code1:    1,
+			Keyword1: "Music",
+		}))
+		require.NoError(t, f.SetHomepageCategory(context.Background(), user2.IdentScreenName, ICQHomepageCategory{
+			Enabled:     true,
+			Index:       42,
+			Description: "My photo blog about cats",
+		}))
+
+		return user1, user2
+	}
+
+	t.Run("Empty criteria", func(t *testing.T) {
+		defer func() { assert.NoError(t, os.Remove(testFile)) }()
+
+		f, err := NewSQLiteUserStore(testFile)
+		require.NoError(t, err)
+		_, searchErr := f.SearchICQUsers(context.Background(), ICQUserSearchCriteria{})
+		assert.ErrorIs(t, searchErr, ErrICQSearchEmptyCriteria)
+	})
+
+	t.Run("Search by nickname substring", func(t *testing.T) {
+		f, dbFile := newStore(t)
+		defer func() { assert.NoError(t, os.Remove(dbFile)) }()
+
+		user1, _ := seedUsers(t, f)
+		users, err := f.SearchICQUsers(context.Background(), ICQUserSearchCriteria{
+			NickName: new("ane"),
+		})
+		assert.NoError(t, err)
+		require.Len(t, users, 1)
+		assert.Equal(t, user1.IdentScreenName, users[0].IdentScreenName)
+	})
+
+	t.Run("Search by city+state", func(t *testing.T) {
+		f, dbFile := newStore(t)
+		defer func() { assert.NoError(t, os.Remove(dbFile)) }()
+
+		_, user2 := seedUsers(t, f)
+		users, err := f.SearchICQUsers(context.Background(), ICQUserSearchCriteria{
+			City:  new("bost"),
+			State: new("MA"),
+		})
+		assert.NoError(t, err)
+		require.Len(t, users, 1)
+		assert.Equal(t, user2.IdentScreenName, users[0].IdentScreenName)
+	})
+
+	t.Run("Search by keyword", func(t *testing.T) {
+		f, dbFile := newStore(t)
+		defer func() { assert.NoError(t, os.Remove(dbFile)) }()
+
+		user1, _ := seedUsers(t, f)
+		kw := "knit"
+		users, err := f.SearchICQUsers(context.Background(), ICQUserSearchCriteria{
+			AnyKeyword: &kw,
+		})
+		assert.NoError(t, err)
+		require.Len(t, users, 1)
+		assert.Equal(t, user1.IdentScreenName, users[0].IdentScreenName)
+	})
+
+	t.Run("Search by interests node", func(t *testing.T) {
+		f, dbFile := newStore(t)
+		defer func() { assert.NoError(t, os.Remove(dbFile)) }()
+
+		_, user2 := seedUsers(t, f)
+		users, err := f.SearchICQUsers(context.Background(), ICQUserSearchCriteria{
+			InterestsCode:     new(uint16(1)),
+			InterestsKeywords: []string{"Music"},
+		})
+		assert.NoError(t, err)
+		require.Len(t, users, 1)
+		assert.Equal(t, user2.IdentScreenName, users[0].IdentScreenName)
+	})
+
+	t.Run("Search by UIN", func(t *testing.T) {
+		f, dbFile := newStore(t)
+		defer func() { assert.NoError(t, os.Remove(dbFile)) }()
+
+		user1, _ := seedUsers(t, f)
+		users, err := f.SearchICQUsers(context.Background(), ICQUserSearchCriteria{
+			UIN: new(uint32(123456)),
+		})
+		assert.NoError(t, err)
+		require.Len(t, users, 1)
+		assert.Equal(t, user1.IdentScreenName, users[0].IdentScreenName)
+	})
+
+	t.Run("Search by first and last name", func(t *testing.T) {
+		f, dbFile := newStore(t)
+		defer func() { assert.NoError(t, os.Remove(dbFile)) }()
+
+		user1, _ := seedUsers(t, f)
+		users, err := f.SearchICQUsers(context.Background(), ICQUserSearchCriteria{
+			FirstName: new("jan"),
+			LastName:  new("doe"),
+		})
+		assert.NoError(t, err)
+		require.Len(t, users, 1)
+		assert.Equal(t, user1.IdentScreenName, users[0].IdentScreenName)
+	})
+
+	t.Run("Search by email substring", func(t *testing.T) {
+		f, dbFile := newStore(t)
+		defer func() { assert.NoError(t, os.Remove(dbFile)) }()
+
+		_, user2 := seedUsers(t, f)
+		users, err := f.SearchICQUsers(context.Background(), ICQUserSearchCriteria{
+			Email: new("alice@"),
+		})
+		assert.NoError(t, err)
+		require.Len(t, users, 1)
+		assert.Equal(t, user2.IdentScreenName, users[0].IdentScreenName)
+	})
+
+	t.Run("Search by gender", func(t *testing.T) {
+		f, dbFile := newStore(t)
+		defer func() { assert.NoError(t, os.Remove(dbFile)) }()
+
+		_, user2 := seedUsers(t, f)
+		users, err := f.SearchICQUsers(context.Background(), ICQUserSearchCriteria{
+			Gender: new(uint8(1)),
+		})
+		assert.NoError(t, err)
+		require.Len(t, users, 1)
+		assert.Equal(t, user2.IdentScreenName, users[0].IdentScreenName)
+	})
+
+	t.Run("Search by spoken language", func(t *testing.T) {
+		f, dbFile := newStore(t)
+		defer func() { assert.NoError(t, os.Remove(dbFile)) }()
+
+		user1, _ := seedUsers(t, f)
+		users, err := f.SearchICQUsers(context.Background(), ICQUserSearchCriteria{
+			SpokenLanguage: new(uint8(10)),
+		})
+		assert.NoError(t, err)
+		require.Len(t, users, 1)
+		assert.Equal(t, user1.IdentScreenName, users[0].IdentScreenName)
+	})
+
+	t.Run("Search by minimum age", func(t *testing.T) {
+		f, dbFile := newStore(t)
+		defer func() { assert.NoError(t, os.Remove(dbFile)) }()
+
+		user1, _ := seedUsers(t, f)
+		min := uint16(35)
+		users, err := f.SearchICQUsers(context.Background(), ICQUserSearchCriteria{
+			MinAge: &min,
+		})
+		assert.NoError(t, err)
+		require.Len(t, users, 1)
+		assert.Equal(t, user1.IdentScreenName, users[0].IdentScreenName)
+	})
+
+	t.Run("Search by maximum age", func(t *testing.T) {
+		f, dbFile := newStore(t)
+		defer func() { assert.NoError(t, os.Remove(dbFile)) }()
+
+		_, user2 := seedUsers(t, f)
+		max := uint16(25)
+		users, err := f.SearchICQUsers(context.Background(), ICQUserSearchCriteria{
+			MaxAge: &max,
+		})
+		assert.NoError(t, err)
+		require.Len(t, users, 1)
+		assert.Equal(t, user2.IdentScreenName, users[0].IdentScreenName)
+	})
+
+	t.Run("Search by home country code", func(t *testing.T) {
+		f, dbFile := newStore(t)
+		defer func() { assert.NoError(t, os.Remove(dbFile)) }()
+
+		user1, _ := seedUsers(t, f)
+		users, err := f.SearchICQUsers(context.Background(), ICQUserSearchCriteria{
+			CountryCode: new(uint16(100)),
+		})
+		assert.NoError(t, err)
+		require.Len(t, users, 1)
+		assert.Equal(t, user1.IdentScreenName, users[0].IdentScreenName)
+	})
+
+	t.Run("Search by work country code", func(t *testing.T) {
+		f, dbFile := newStore(t)
+		defer func() { assert.NoError(t, os.Remove(dbFile)) }()
+
+		_, user2 := seedUsers(t, f)
+		users, err := f.SearchICQUsers(context.Background(), ICQUserSearchCriteria{
+			CountryCode: new(uint16(400)),
+		})
+		assert.NoError(t, err)
+		require.Len(t, users, 1)
+		assert.Equal(t, user2.IdentScreenName, users[0].IdentScreenName)
+	})
+
+	t.Run("Search by company", func(t *testing.T) {
+		f, dbFile := newStore(t)
+		defer func() { assert.NoError(t, os.Remove(dbFile)) }()
+
+		_, user2 := seedUsers(t, f)
+		users, err := f.SearchICQUsers(context.Background(), ICQUserSearchCriteria{
+			Company: new("Globex"),
+		})
+		assert.NoError(t, err)
+		require.Len(t, users, 1)
+		assert.Equal(t, user2.IdentScreenName, users[0].IdentScreenName)
+	})
+
+	t.Run("Search by department", func(t *testing.T) {
+		f, dbFile := newStore(t)
+		defer func() { assert.NoError(t, os.Remove(dbFile)) }()
+
+		user1, _ := seedUsers(t, f)
+		users, err := f.SearchICQUsers(context.Background(), ICQUserSearchCriteria{
+			DepartmentName: new("Engineering"),
+		})
+		assert.NoError(t, err)
+		require.Len(t, users, 1)
+		assert.Equal(t, user1.IdentScreenName, users[0].IdentScreenName)
+	})
+
+	t.Run("Search by position", func(t *testing.T) {
+		f, dbFile := newStore(t)
+		defer func() { assert.NoError(t, os.Remove(dbFile)) }()
+
+		user1, _ := seedUsers(t, f)
+		users, err := f.SearchICQUsers(context.Background(), ICQUserSearchCriteria{
+			Position: new("Engineer"),
+		})
+		assert.NoError(t, err)
+		require.Len(t, users, 1)
+		assert.Equal(t, user1.IdentScreenName, users[0].IdentScreenName)
+	})
+
+	t.Run("Search by occupation code", func(t *testing.T) {
+		f, dbFile := newStore(t)
+		defer func() { assert.NoError(t, os.Remove(dbFile)) }()
+
+		_, user2 := seedUsers(t, f)
+		users, err := f.SearchICQUsers(context.Background(), ICQUserSearchCriteria{
+			OccupationCode: new(uint16(600)),
+		})
+		assert.NoError(t, err)
+		require.Len(t, users, 1)
+		assert.Equal(t, user2.IdentScreenName, users[0].IdentScreenName)
+	})
+
+	t.Run("Search by affiliations node", func(t *testing.T) {
+		f, dbFile := newStore(t)
+		defer func() { assert.NoError(t, os.Remove(dbFile)) }()
+
+		user1, _ := seedUsers(t, f)
+		users, err := f.SearchICQUsers(context.Background(), ICQUserSearchCriteria{
+			AffiliationsCode:     new(uint16(99)),
+			AffiliationsKeywords: []string{"Robot"},
+		})
+		assert.NoError(t, err)
+		require.Len(t, users, 1)
+		assert.Equal(t, user1.IdentScreenName, users[0].IdentScreenName)
+	})
+
+	t.Run("Search by past affiliations node", func(t *testing.T) {
+		f, dbFile := newStore(t)
+		defer func() { assert.NoError(t, os.Remove(dbFile)) }()
+
+		user1, _ := seedUsers(t, f)
+		users, err := f.SearchICQUsers(context.Background(), ICQUserSearchCriteria{
+			PastAffiliationsCode:     new(uint16(100)),
+			PastAffiliationsKeywords: []string{"Ghost"},
+		})
+		assert.NoError(t, err)
+		require.Len(t, users, 1)
+		assert.Equal(t, user1.IdentScreenName, users[0].IdentScreenName)
+	})
+
+	t.Run("Search by homepage category index", func(t *testing.T) {
+		f, dbFile := newStore(t)
+		defer func() { assert.NoError(t, os.Remove(dbFile)) }()
+
+		_, user2 := seedUsers(t, f)
+		users, err := f.SearchICQUsers(context.Background(), ICQUserSearchCriteria{
+			HomePageCategoryIndex: new(uint16(42)),
+		})
+		assert.NoError(t, err)
+		require.Len(t, users, 1)
+		assert.Equal(t, user2.IdentScreenName, users[0].IdentScreenName)
+	})
+
+	t.Run("Search by homepage keywords", func(t *testing.T) {
+		f, dbFile := newStore(t)
+		defer func() { assert.NoError(t, os.Remove(dbFile)) }()
+
+		_, user2 := seedUsers(t, f)
+		users, err := f.SearchICQUsers(context.Background(), ICQUserSearchCriteria{
+			HomePageKeywords: []string{"cats"},
+		})
+		assert.NoError(t, err)
+		require.Len(t, users, 1)
+		assert.Equal(t, user2.IdentScreenName, users[0].IdentScreenName)
+	})
+}
+
 func TestSQLiteUserStore_FindByICQEmail(t *testing.T) {
 	// Cleanup after test
 	defer func() {

+ 18 - 10
wire/snacs.go

@@ -1927,9 +1927,9 @@ const (
 	ICQTLVTagsWorkDepartmentName        uint16 = 0x01B8 // User work department name
 	ICQTLVTagsWorkPositionTitle         uint16 = 0x01C2 // User work position (title)
 	ICQTLVTagsWorkOccupationCode        uint16 = 0x01CC // User work occupation code
-	ICQTLVTagsAffiliationsNode          uint16 = 0x01D6 // User affiliations node
+	ICQTLVTagsAffiliationsNode          uint16 = 0x01FE // User affiliations node
 	ICQTLVTagsInterestsNode             uint16 = 0x01EA // User interests node
-	ICQTLVTagsPastInfoNode              uint16 = 0x01FE // User past info node
+	ICQTLVTagsPastInfoNode              uint16 = 0x01D6 // User past info node
 	ICQTLVTagsHomepageCategoryKeywords  uint16 = 0x0212 // User homepage category/keywords
 	ICQTLVTagsHomepageURL               uint16 = 0x0213 // User homepage URL
 	ICQTLVTagsWhitepagesSearchKeywords  uint16 = 0x0226 // Whitepages search keywords string (search)
@@ -2257,14 +2257,22 @@ type ICQ_0x07DA_0x08A2_DBQueryMetaReplyXMLData struct {
 
 type ICQ_0x07DA_0x00DC_DBQueryMetaReplyMoreInfo struct {
 	ICQMetadata
-	ReqSubType uint16
-	Success    uint8
-	ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo
-	Unknown     uint16
-	City        string `oscar:"len_prefix=uint16,nullterm"`
-	State       string `oscar:"len_prefix=uint16,nullterm"`
-	CountryCode uint16
-	TimeZone    uint8
+	ReqSubType   uint16
+	Success      uint8
+	Age          uint16
+	Gender       uint8
+	HomePageAddr string `oscar:"len_prefix=uint16,nullterm"`
+	BirthYear    uint16
+	BirthMonth   uint8
+	BirthDay     uint8
+	Lang1        uint8
+	Lang2        uint8
+	Lang3        uint8
+	Unknown      uint16
+	City         string `oscar:"len_prefix=uint16,nullterm"`
+	State        string `oscar:"len_prefix=uint16,nullterm"`
+	CountryCode  uint16
+	TimeZone     uint8
 }
 
 type ICQ_0x07DA_0x00EB_DBQueryMetaReplyExtEmailInfo struct {

+ 8 - 23
wire/snacs_string.go

@@ -1,5 +1,7 @@
 package wire
 
+import "fmt"
+
 var foodGroupName = map[uint16]string{
 	BOS:         "BOS",
 	OService:    "OService",
@@ -339,7 +341,7 @@ var icqDBQuery = map[uint16]string{
 func ICQDBQueryMetaName(query uint16) string {
 	name := icqDBQueryMeta[query]
 	if name == "" {
-		name = "unknown"
+		name = fmt.Sprintf("unknown (%d)", query)
 	}
 	return name
 }
@@ -355,12 +357,17 @@ var icqDBQueryMeta = map[uint16]string{
 	ICQDBQueryMetaReqSetPermissions:    "ICQDBQueryMetaReqSetPermissions",
 	ICQDBQueryMetaReqSetICQPhone:       "ICQDBQueryMetaReqSetICQPhone",
 	ICQDBQueryMetaReqSetFullInfo:       "ICQDBQueryMetaReqSetFullInfo",
+	ICQDBQueryMetaReqShortInfo:         "ICQDBQueryMetaReqShortInfo",
 	ICQDBQueryMetaReqFullInfo:          "ICQDBQueryMetaReqFullInfo",
 	ICQDBQueryMetaReqFullInfo2:         "ICQDBQueryMetaReqFullInfo2",
 	ICQDBQueryMetaReqSearchByDetails:   "ICQDBQueryMetaReqSearchByDetails",
 	ICQDBQueryMetaReqSearchByUIN:       "ICQDBQueryMetaReqSearchByUIN",
 	ICQDBQueryMetaReqSearchByEmail:     "ICQDBQueryMetaReqSearchByEmail",
 	ICQDBQueryMetaReqSearchWhitePages:  "ICQDBQueryMetaReqSearchWhitePages",
+	ICQDBQueryMetaReqSearchWhitePages2: "ICQDBQueryMetaReqSearchWhitePages2",
+	ICQDBQueryMetaReqSearchByUIN2:      "ICQDBQueryMetaReqSearchByUIN2",
+	ICQDBQueryMetaReqSearchByEmail3:    "ICQDBQueryMetaReqSearchByEmail3",
+	ICQDBQueryMetaReqStat0758:          "ICQDBQueryMetaReqStat0758",
 	ICQDBQueryMetaReqXMLReq:            "ICQDBQueryMetaReqXMLReq",
 	ICQDBQueryMetaReqStat0a8c:          "ICQDBQueryMetaReqStat0a8c",
 	ICQDBQueryMetaReqStat0a96:          "ICQDBQueryMetaReqStat0a96",
@@ -372,28 +379,6 @@ var icqDBQueryMeta = map[uint16]string{
 	ICQDBQueryMetaReqStat0acd:          "ICQDBQueryMetaReqStat0acd",
 	ICQDBQueryMetaReqStat0ad2:          "ICQDBQueryMetaReqStat0ad2",
 	ICQDBQueryMetaReqStat0ad7:          "ICQDBQueryMetaReqStat0ad7",
-	ICQDBQueryMetaReqStat0758:          "ICQDBQueryMetaReqStat0758",
 	ICQDBQueryMetaReqDirectoryQuery:    "ICQDBQueryMetaReqDirectoryQuery",
 	ICQDBQueryMetaReqDirectoryUpdate:   "ICQDBQueryMetaReqDirectoryUpdate",
-	ICQDBQueryMetaReplySetBasicInfo:    "ICQDBQueryMetaReplySetBasicInfo",
-	ICQDBQueryMetaReplySetWorkInfo:     "ICQDBQueryMetaReplySetWorkInfo",
-	ICQDBQueryMetaReplySetMoreInfo:     "ICQDBQueryMetaReplySetMoreInfo",
-	ICQDBQueryMetaReplySetNotes:        "ICQDBQueryMetaReplySetNotes",
-	ICQDBQueryMetaReplySetEmails:       "ICQDBQueryMetaReplySetEmails",
-	ICQDBQueryMetaReplySetInterests:    "ICQDBQueryMetaReplySetInterests",
-	ICQDBQueryMetaReplySetAffiliations: "ICQDBQueryMetaReplySetAffiliations",
-	ICQDBQueryMetaReplySetPermissions:  "ICQDBQueryMetaReplySetPermissions",
-	ICQDBQueryMetaReplySetICQPhone:     "ICQDBQueryMetaReplySetICQPhone",
-	ICQDBQueryMetaReplySetFullInfo:     "ICQDBQueryMetaReplySetFullInfo",
-	ICQDBQueryMetaReplyBasicInfo:       "ICQDBQueryMetaReplyBasicInfo",
-	ICQDBQueryMetaReplyWorkInfo:        "ICQDBQueryMetaReplyWorkInfo",
-	ICQDBQueryMetaReplyMoreInfo:        "ICQDBQueryMetaReplyMoreInfo",
-	ICQDBQueryMetaReplyNotes:           "ICQDBQueryMetaReplyNotes",
-	ICQDBQueryMetaReplyExtEmailInfo:    "ICQDBQueryMetaReplyExtEmailInfo",
-	ICQDBQueryMetaReplyInterests:       "ICQDBQueryMetaReplyInterests",
-	ICQDBQueryMetaReplyAffiliations:    "ICQDBQueryMetaReplyAffiliations",
-	ICQDBQueryMetaReplyHomePageCat:     "ICQDBQueryMetaReplyHomePageCat",
-	ICQDBQueryMetaReplyUserFound:       "ICQDBQueryMetaReplyUserFound",
-	ICQDBQueryMetaReplyLastUserFound:   "ICQDBQueryMetaReplyLastUserFound",
-	ICQDBQueryMetaReplyXMLData:         "ICQDBQueryMetaReplyXMLData",
 }

+ 65 - 34
wire/tlv.go

@@ -14,6 +14,55 @@ type TLV struct {
 	Value []byte `oscar:"len_prefix=uint16"`
 }
 
+func (t TLV) ICQString() string {
+	// Ensure the value is long enough to contain a valid length prefix and value
+	if len(t.Value) < 3 {
+		return ""
+	}
+
+	// Extract the length prefix (first 2 bytes) as a uint16
+	expectedLength := binary.LittleEndian.Uint16(t.Value[0:2])
+
+	// Extract the actual string value, excluding the length prefix
+	value := t.Value[2:]
+
+	// Check if the length matches the value length (including the null terminator)
+	if int(expectedLength) != len(value) {
+		return ""
+	}
+
+	// Remove the null terminator
+	return string(value[:len(value)-1])
+}
+
+// Uint8 returns a uint8 representation of the TLV.
+func (t TLV) Uint8() uint8 {
+	if len(t.Value) > 0 {
+		return t.Value[0]
+	}
+	return 0
+}
+
+// Uint16LE returns a uint16 little-endian representation of the TLV.
+func (t TLV) Uint16LE() uint16 {
+	return binary.LittleEndian.Uint16(t.Value)
+}
+
+// Uint16BE returns a uint16 big-endian representation of the TLV.
+func (t TLV) Uint16BE() uint16 {
+	return binary.BigEndian.Uint16(t.Value)
+}
+
+// Uint32LE returns a uint32 little-endian representation of the TLV.
+func (t TLV) Uint32LE() uint32 {
+	return binary.LittleEndian.Uint32(t.Value)
+}
+
+// Uint32BE returns a uint32 big-endian representation of the TLV.
+func (t TLV) Uint32BE() uint32 {
+	return binary.BigEndian.Uint32(t.Value)
+}
+
 // NewTLVBE creates a new TLV. Values are marshalled in big-endian order.
 func NewTLVBE(tag uint16, val any) TLV {
 	return newTLV(tag, val, binary.BigEndian)
@@ -133,25 +182,7 @@ func (s *TLVList) ICQString(tag uint16) (string, bool) {
 		if tag != tlv.Tag {
 			continue
 		}
-
-		// Ensure the value is long enough to contain a valid length prefix and value
-		if len(tlv.Value) < 3 {
-			break
-		}
-
-		// Extract the length prefix (first 2 bytes) as a uint16
-		expectedLength := binary.LittleEndian.Uint16(tlv.Value[0:2])
-
-		// Extract the actual string value, excluding the length prefix
-		value := tlv.Value[2:]
-
-		// Check if the length matches the value length (including the null terminator)
-		if int(expectedLength) != len(value) {
-			break
-		}
-
-		// Remove the null terminator
-		return string(value[:len(value)-1]), true
+		return tlv.ICQString(), true
 	}
 
 	// Tag not found
@@ -181,9 +212,7 @@ func (s *TLVList) Bytes(tag uint16) ([]byte, bool) {
 func (s *TLVList) Uint8(tag uint16) (uint8, bool) {
 	for _, tlv := range *s {
 		if tag == tlv.Tag {
-			if len(tlv.Value) > 0 {
-				return tlv.Value[0], true
-			}
+			return tlv.Uint8(), true
 		}
 	}
 	return 0, false
@@ -197,7 +226,12 @@ func (s *TLVList) Uint8(tag uint16) (uint8, bool) {
 // as a uint16 and true. If the tag is not found, the function returns 0 and
 // false.
 func (s *TLVList) Uint16BE(tag uint16) (uint16, bool) {
-	return s.uint16(tag, binary.BigEndian)
+	for _, tlv := range *s {
+		if tag == tlv.Tag {
+			return tlv.Uint16BE(), true
+		}
+	}
+	return 0, false
 }
 
 // Uint16LE retrieves a 16-bit unsigned integer value from the TLVList
@@ -208,13 +242,9 @@ func (s *TLVList) Uint16BE(tag uint16) (uint16, bool) {
 // as a uint16 and true. If the tag is not found, the function returns 0 and
 // false.
 func (s *TLVList) Uint16LE(tag uint16) (uint16, bool) {
-	return s.uint16(tag, binary.LittleEndian)
-}
-
-func (s *TLVList) uint16(tag uint16, order binary.ByteOrder) (uint16, bool) {
 	for _, tlv := range *s {
 		if tag == tlv.Tag {
-			return order.Uint16(tlv.Value), true
+			return tlv.Uint16LE(), true
 		}
 	}
 	return 0, false
@@ -226,7 +256,12 @@ func (s *TLVList) uint16(tag uint16, order binary.ByteOrder) (uint16, bool) {
 // If the specified tag is found, the function returns the associated value
 // as a uint32 and true. If the tag is not found, the function returns 0 and false.
 func (s *TLVList) Uint32BE(tag uint16) (uint32, bool) {
-	return s.uint32(tag, binary.BigEndian)
+	for _, tlv := range *s {
+		if tag == tlv.Tag {
+			return tlv.Uint32BE(), true
+		}
+	}
+	return 0, false
 }
 
 // Uint32LE retrieves a 32-bit unsigned integer value from the TLVList
@@ -235,13 +270,9 @@ func (s *TLVList) Uint32BE(tag uint16) (uint32, bool) {
 // If the specified tag is found, the function returns the associated value
 // as a uint32 and true. If the tag is not found, the function returns 0 and false.
 func (s *TLVList) Uint32LE(tag uint16) (uint32, bool) {
-	return s.uint32(tag, binary.LittleEndian)
-}
-
-func (s *TLVList) uint32(tag uint16, order binary.ByteOrder) (uint32, bool) {
 	for _, tlv := range *s {
 		if tag == tlv.Tag {
-			return order.Uint32(tlv.Value), true
+			return tlv.Uint32LE(), true
 		}
 	}
 	return 0, false

+ 3 - 3
wire/tlv_test.go

@@ -371,7 +371,7 @@ func TestTLVList_ICQString(t *testing.T) {
 		tlvMalformed.Append(NewTLVLE(0x03, []byte{0x02, 0x00, 'a'})) // Length 2 but only 1 character and no null terminator
 
 		str, ok := tlvMalformed.ICQString(0x03)
-		assert.False(t, ok)
+		assert.True(t, ok)
 		assert.Empty(t, str)
 	})
 
@@ -381,7 +381,7 @@ func TestTLVList_ICQString(t *testing.T) {
 		tlvIncorrectLength.Append(NewTLVLE(0x04, []byte{0x0A, 0x00, 'k', 'n', 'i', 't', 't', 'i', 'n', 'g', '\x00'})) // Length prefix is 9 but actual length is 7 + 1 (null terminator)
 
 		str, ok := tlvIncorrectLength.ICQString(0x04)
-		assert.False(t, ok)
+		assert.True(t, ok)
 		assert.Empty(t, str)
 	})
 
@@ -391,7 +391,7 @@ func TestTLVList_ICQString(t *testing.T) {
 		tlvShortLength.Append(NewTLVLE(0x05, []byte{0x05, 0x00})) // Length prefix is 5 but no data
 
 		str, ok := tlvShortLength.ICQString(0x05)
-		assert.False(t, ok)
+		assert.True(t, ok)
 		assert.Empty(t, str)
 	})