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

icq: SetFullInfo, wire quirks, originally-from columns

- Handle META 0x0C3A (CLI_SET_FULLINFO): OSCAR → ICQService.SetICQInfo → batch SQLite update
- Nest ICQ profile on User under ICQInfo; thread through foodgroup, legacy, OSCAR, mgmt API
- UnmarshalLE: tagged quirks for ICQ 2003b email TLV and QIP SearchByUIN2 UIN TLV
- Migration: persist ICQ "originally from" city/state/country on users
Mike 2 месяцев назад
Родитель
Сommit
b5fdb7e46c

+ 1 - 1
foodgroup/feedbag.go

@@ -554,7 +554,7 @@ func (s *FeedbagService) RequestAuthorizeToHost(ctx context.Context, instance *s
 		s.logger.ErrorContext(ctx, "user not found", "screen_name", instance.IdentScreenName())
 	}
 
-	return s.sendLegacyAuthReq(ctx, instance, recipient, inBody.Reason, userInfo.ICQBasicInfo, authorized)
+	return s.sendLegacyAuthReq(ctx, instance, recipient, inBody.Reason, userInfo.ICQInfo.Basic, authorized)
 }
 
 // sendLegacyAuthReq sends an offline authorization request to ICQ client

+ 23 - 21
foodgroup/feedbag_test.go

@@ -2594,11 +2594,13 @@ func TestFeedbagService_Use(t *testing.T) {
 
 func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 	authReqSender := state.User{
-		ICQBasicInfo: state.ICQBasicInfo{
-			Nickname:     "CoolNickname",
-			FirstName:    "Alice",
-			LastName:     "Smith",
-			EmailAddress: "alice@example.com",
+		ICQInfo: state.ICQInfo{
+			Basic: state.ICQBasicInfo{
+				Nickname:     "CoolNickname",
+				FirstName:    "Alice",
+				LastName:     "Smith",
+				EmailAddress: "alice@example.com",
+			},
 		},
 	}
 
@@ -2713,10 +2715,10 @@ func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 								UIN:         100001,
 								MessageType: wire.ICBMMsgTypeAuthReq,
 								Message: fmt.Sprintf("%s\xFE%s\xFE%s\xFE%s\xFE%d\xFE%s",
-									authReqSender.ICQBasicInfo.Nickname,
-									authReqSender.ICQBasicInfo.FirstName,
-									authReqSender.ICQBasicInfo.LastName,
-									authReqSender.ICQBasicInfo.EmailAddress,
+									authReqSender.ICQInfo.Basic.Nickname,
+									authReqSender.ICQInfo.Basic.FirstName,
+									authReqSender.ICQInfo.Basic.LastName,
+									authReqSender.ICQInfo.Basic.EmailAddress,
 									1,
 									utf8ToLatin1("please add me"),
 								),
@@ -2778,10 +2780,10 @@ func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 								UIN:         100001,
 								MessageType: wire.ICBMMsgTypeAuthReq,
 								Message: fmt.Sprintf("%s\xFE%s\xFE%s\xFE%s\xFE%d\xFE%s",
-									authReqSender.ICQBasicInfo.Nickname,
-									authReqSender.ICQBasicInfo.FirstName,
-									authReqSender.ICQBasicInfo.LastName,
-									authReqSender.ICQBasicInfo.EmailAddress,
+									authReqSender.ICQInfo.Basic.Nickname,
+									authReqSender.ICQInfo.Basic.FirstName,
+									authReqSender.ICQInfo.Basic.LastName,
+									authReqSender.ICQInfo.Basic.EmailAddress,
 									1,
 									utf8ToLatin1("please add me"),
 								),
@@ -2839,10 +2841,10 @@ func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 								UIN:         100001,
 								MessageType: wire.ICBMMsgTypeAuthReq,
 								Message: fmt.Sprintf("%s\xFE%s\xFE%s\xFE%s\xFE%d\xFE%s",
-									authReqSender.ICQBasicInfo.Nickname,
-									authReqSender.ICQBasicInfo.FirstName,
-									authReqSender.ICQBasicInfo.LastName,
-									authReqSender.ICQBasicInfo.EmailAddress,
+									authReqSender.ICQInfo.Basic.Nickname,
+									authReqSender.ICQInfo.Basic.FirstName,
+									authReqSender.ICQInfo.Basic.LastName,
+									authReqSender.ICQInfo.Basic.EmailAddress,
 									0,
 									utf8ToLatin1("please add me"),
 								),
@@ -2900,10 +2902,10 @@ func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 								UIN:         100001,
 								MessageType: wire.ICBMMsgTypeAuthReq,
 								Message: fmt.Sprintf("%s\xFE%s\xFE%s\xFE%s\xFE%d\xFE%s",
-									authReqSender.ICQBasicInfo.Nickname,
-									authReqSender.ICQBasicInfo.FirstName,
-									authReqSender.ICQBasicInfo.LastName,
-									authReqSender.ICQBasicInfo.EmailAddress,
+									authReqSender.ICQInfo.Basic.Nickname,
+									authReqSender.ICQInfo.Basic.FirstName,
+									authReqSender.ICQInfo.Basic.LastName,
+									authReqSender.ICQInfo.Basic.EmailAddress,
 									1,
 									utf8ToLatin1("please add me"),
 								),

+ 18 - 0
foodgroup/helpers_test.go

@@ -195,6 +195,8 @@ type findByInterestsParams []struct {
 type icqUserUpdaterParams struct {
 	setAffiliationsParams
 	setBasicInfoParams
+	setFullInfoParams
+	setHomepageCategoryParams
 	setInterestsParams
 	setMoreInfoParams
 	setPermissionsParams
@@ -234,6 +236,14 @@ type setBasicInfoParams []struct {
 	err  error
 }
 
+// setFullInfoParams is the list of parameters passed at the mock
+// ICQUserUpdater.SetICQInfo call site
+type setFullInfoParams []struct {
+	name state.IdentScreenName
+	info state.ICQInfo
+	err  error
+}
+
 // setWorkInfoParams is the list of parameters passed at the mock
 // ICQUserUpdater.SetWorkInfo call site
 type setWorkInfoParams []struct {
@@ -258,6 +268,14 @@ type setPermissionsParams []struct {
 	err  error
 }
 
+// setHomepageCategoryParams is the list of parameters passed at the mock
+// ICQUserUpdater.SetHomepageCategory call site
+type setHomepageCategoryParams []struct {
+	name state.IdentScreenName
+	data state.ICQHomepageCategory
+	err  error
+}
+
 // bartItemManagerParams is a helper struct that contains mock parameters for
 // BARTItemManager methods
 type bartItemManagerParams struct {

+ 437 - 74
foodgroup/icq.go

@@ -3,6 +3,7 @@ package foodgroup
 import (
 	"bytes"
 	"context"
+	"encoding/binary"
 	"errors"
 	"fmt"
 	"log/slog"
@@ -544,6 +545,294 @@ func (s *ICQService) SetEmails(ctx context.Context, instance *state.SessionInsta
 	return s.reqAck(ctx, instance, seq, wire.ICQDBQueryMetaReplySetEmails)
 }
 
+// SetICQInfo handles the TLV-based CLI_SET_FULLINFO (0x0C3A) request used to
+// update all profile information in a single packet. The TLV chain may
+// contain any subset of fields; omitted fields are preserved by loading the
+// current user record and overlaying only the values that were sent. Within a
+// multi-valued group (languages, interests, affiliations) any unspecified
+// slots are reset when at least one TLV in that group is present, matching
+// the way ICQLite tabs replace their entire group on save.
+func (s *ICQService) SetICQInfo(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0C3A_DBQueryMetaReqSetFullInfo, seq uint16) error {
+	user, err := s.userFinder.FindByUIN(ctx, instance.UIN())
+	if err != nil {
+		return fmt.Errorf("FindByUIN: %w", err)
+	}
+
+	// Multi-valued tags are collected in encounter order and applied to
+	// their target struct after the loop.
+	var langTLVs []uint8
+	var interestTLVs, currentAffTLVs, pastAffTLVs [][]byte
+
+	for _, tlv := range inBody.TLVList {
+		switch tlv.Tag {
+
+		// ----- ICQBasicInfo -----
+		case wire.ICQTLVTagsFirstName:
+			if v, ok := decodeICQSString(tlv.Value); ok {
+				user.ICQInfo.Basic.FirstName = v
+			}
+		case wire.ICQTLVTagsLastName:
+			if v, ok := decodeICQSString(tlv.Value); ok {
+				user.ICQInfo.Basic.LastName = v
+			}
+		case wire.ICQTLVTagsNickname:
+			if v, ok := decodeICQSString(tlv.Value); ok {
+				user.ICQInfo.Basic.Nickname = v
+			}
+		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
+			}
+		case wire.ICQTLVTagsHomeCityName:
+			if v, ok := decodeICQSString(tlv.Value); ok {
+				user.ICQInfo.Basic.City = v
+			}
+		case wire.ICQTLVTagsHomeStateAbbr:
+			if v, ok := decodeICQSString(tlv.Value); ok {
+				user.ICQInfo.Basic.State = v
+			}
+		case wire.ICQTLVTagsHomeCountryCode:
+			if v, ok := readUint16LE(tlv.Value); ok {
+				user.ICQInfo.Basic.CountryCode = v
+			}
+		case wire.ICQTLVTagsHomeStreetAddress:
+			if v, ok := decodeICQSString(tlv.Value); ok {
+				user.ICQInfo.Basic.Address = v
+			}
+		case wire.ICQTLVTagsHomeZipCode:
+			if v, ok := readUint32LE(tlv.Value); ok {
+				user.ICQInfo.Basic.ZIPCode = strconv.FormatUint(uint64(v), 10)
+			}
+		case wire.ICQTLVTagsHomePhoneNumber:
+			if v, ok := decodeICQSString(tlv.Value); ok {
+				user.ICQInfo.Basic.Phone = v
+			}
+		case wire.ICQTLVTagsHomeFaxNumber:
+			if v, ok := decodeICQSString(tlv.Value); ok {
+				user.ICQInfo.Basic.Fax = v
+			}
+		case wire.ICQTLVTagsHomeCellularPhoneNumber:
+			if v, ok := decodeICQSString(tlv.Value); ok {
+				user.ICQInfo.Basic.CellPhone = v
+			}
+		case wire.ICQTLVTagsGMTOffset:
+			if v, ok := readUint8(tlv.Value); ok {
+				user.ICQInfo.Basic.GMTOffset = v
+			}
+		case wire.ICQTLVTagsOriginallyFromCity:
+			if v, ok := decodeICQSString(tlv.Value); ok {
+				user.ICQInfo.Basic.OriginallyFromCity = v
+			}
+		case wire.ICQTLVTagsOriginallyFromState:
+			if v, ok := decodeICQSString(tlv.Value); ok {
+				user.ICQInfo.Basic.OriginallyFromState = v
+			}
+		case wire.ICQTLVTagsOriginallyFromCountryCode:
+			if v, ok := readUint16LE(tlv.Value); ok {
+				user.ICQInfo.Basic.OriginallyFromCountryCode = v
+			}
+
+		// ----- ICQMoreInfo -----
+		case wire.ICQTLVTagsGender:
+			if v, ok := readUint8(tlv.Value); ok {
+				user.ICQInfo.More.Gender = uint16(v)
+			}
+		case wire.ICQTLVTagsSpokenLanguage:
+			if v, ok := readUint8(tlv.Value); ok {
+				langTLVs = append(langTLVs, v)
+			}
+		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)
+			}
+		case wire.ICQTLVTagsHomepageURL:
+			if _, url, ok := decodeICQICombo(tlv.Value); ok {
+				user.ICQInfo.More.HomePageAddr = url
+			}
+
+		// ----- ICQWorkInfo -----
+		case wire.ICQTLVTagsWorkCompanyName:
+			if v, ok := decodeICQSString(tlv.Value); ok {
+				user.ICQInfo.Work.Company = v
+			}
+		case wire.ICQTLVTagsWorkDepartmentName:
+			if v, ok := decodeICQSString(tlv.Value); ok {
+				user.ICQInfo.Work.Department = v
+			}
+		case wire.ICQTLVTagsWorkPositionTitle:
+			if v, ok := decodeICQSString(tlv.Value); ok {
+				user.ICQInfo.Work.Position = v
+			}
+		case wire.ICQTLVTagsWorkOccupationCode:
+			if v, ok := readUint16LE(tlv.Value); ok {
+				user.ICQInfo.Work.OccupationCode = v
+			}
+		case wire.ICQTLVTagsWorkStreetAddress:
+			if v, ok := decodeICQSString(tlv.Value); ok {
+				user.ICQInfo.Work.Address = v
+			}
+		case wire.ICQTLVTagsWorkCityName:
+			if v, ok := decodeICQSString(tlv.Value); ok {
+				user.ICQInfo.Work.City = v
+			}
+		case wire.ICQTLVTagsWorkStateName:
+			if v, ok := decodeICQSString(tlv.Value); ok {
+				user.ICQInfo.Work.State = v
+			}
+		case wire.ICQTLVTagsWorkCountryCode:
+			if v, ok := readUint16LE(tlv.Value); ok {
+				user.ICQInfo.Work.CountryCode = v
+			}
+		case wire.ICQTLVTagsWorkZipCode:
+			if v, ok := readUint32LE(tlv.Value); ok {
+				user.ICQInfo.Work.ZIPCode = strconv.FormatUint(uint64(v), 10)
+			}
+		case wire.ICQTLVTagsWorkPhoneNumber:
+			if v, ok := decodeICQSString(tlv.Value); ok {
+				user.ICQInfo.Work.Phone = v
+			}
+		case wire.ICQTLVTagsWorkFaxNumber:
+			if v, ok := decodeICQSString(tlv.Value); ok {
+				user.ICQInfo.Work.Fax = v
+			}
+		case wire.ICQTLVTagsWorkWebpageURL:
+			if v, ok := decodeICQSString(tlv.Value); ok {
+				user.ICQInfo.Work.WebPage = v
+			}
+
+		// ----- 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
+			}
+		case wire.ICQTLVTagsShowWebStatusPermissions:
+			if v, ok := readUint8(tlv.Value); ok {
+				user.ICQInfo.Permissions.WebAware = v == 1
+			}
+
+		// ----- ICQUserNotes -----
+		case wire.ICQTLVTagsNotesText:
+			if v, ok := decodeICQSString(tlv.Value); ok {
+				user.ICQInfo.Notes.Notes = v
+			}
+
+		// ----- ICQInterests -----
+		case wire.ICQTLVTagsInterestsNode:
+			interestTLVs = append(interestTLVs, tlv.Value)
+
+		// ----- ICQAffiliations -----
+		case wire.ICQTLVTagsAffiliationsNode:
+			currentAffTLVs = append(currentAffTLVs, tlv.Value)
+		case wire.ICQTLVTagsPastInfoNode:
+			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
+			}
+
+		default:
+			// Search-only or otherwise unmapped tags
+			// (UIN, Age, AgeRangeSearch, WhitepagesSearchKeywords,
+			// SearchOnlineUsersFlag).
+			s.logger.Debug("ICQ SetICQInfo: ignoring unsupported TLV",
+				"tag", fmt.Sprintf("0x%04X", tlv.Tag),
+				"len", len(tlv.Value))
+		}
+	}
+
+	if len(langTLVs) > 0 {
+		user.ICQInfo.More.Lang1, user.ICQInfo.More.Lang2, user.ICQInfo.More.Lang3 = 0, 0, 0
+		for i, v := range langTLVs {
+			switch i {
+			case 0:
+				user.ICQInfo.More.Lang1 = v
+			case 1:
+				user.ICQInfo.More.Lang2 = v
+			case 2:
+				user.ICQInfo.More.Lang3 = v
+			}
+		}
+	}
+
+	if len(interestTLVs) > 0 {
+		user.ICQInfo.Interests = state.ICQInterests{}
+		for i, raw := range interestTLVs {
+			code, kw, ok := decodeICQICombo(raw)
+			if !ok {
+				continue
+			}
+			switch i {
+			case 0:
+				user.ICQInfo.Interests.Code1, user.ICQInfo.Interests.Keyword1 = code, kw
+			case 1:
+				user.ICQInfo.Interests.Code2, user.ICQInfo.Interests.Keyword2 = code, kw
+			case 2:
+				user.ICQInfo.Interests.Code3, user.ICQInfo.Interests.Keyword3 = code, kw
+			case 3:
+				user.ICQInfo.Interests.Code4, user.ICQInfo.Interests.Keyword4 = code, kw
+			}
+		}
+	}
+
+	if len(currentAffTLVs) > 0 {
+		user.ICQInfo.Affiliations.CurrentCode1, user.ICQInfo.Affiliations.CurrentKeyword1 = 0, ""
+		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
+			}
+			switch i {
+			case 0:
+				user.ICQInfo.Affiliations.CurrentCode1, user.ICQInfo.Affiliations.CurrentKeyword1 = code, kw
+			case 1:
+				user.ICQInfo.Affiliations.CurrentCode2, user.ICQInfo.Affiliations.CurrentKeyword2 = code, kw
+			case 2:
+				user.ICQInfo.Affiliations.CurrentCode3, user.ICQInfo.Affiliations.CurrentKeyword3 = code, kw
+			}
+		}
+	}
+
+	if len(pastAffTLVs) > 0 {
+		user.ICQInfo.Affiliations.PastCode1, user.ICQInfo.Affiliations.PastKeyword1 = 0, ""
+		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
+			}
+			switch i {
+			case 0:
+				user.ICQInfo.Affiliations.PastCode1, user.ICQInfo.Affiliations.PastKeyword1 = code, kw
+			case 1:
+				user.ICQInfo.Affiliations.PastCode2, user.ICQInfo.Affiliations.PastKeyword2 = code, kw
+			case 2:
+				user.ICQInfo.Affiliations.PastCode3, user.ICQInfo.Affiliations.PastKeyword3 = code, kw
+			}
+		}
+	}
+
+	name := instance.IdentScreenName()
+	if err := s.userUpdater.SetICQInfo(ctx, name, user.ICQInfo); err != nil {
+		return err
+	}
+
+	return s.reqAck(ctx, instance, seq, wire.ICQDBQueryMetaReplySetFullInfo)
+}
+
 func (s *ICQService) SetICQPhone(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0654_DBQueryMetaReqSetICQPhone, seq uint16) error {
 	s.logger.Debug("received SetICQPhone request")
 	return s.reqAck(ctx, instance, seq, wire.ICQDBQueryMetaReplySetICQPhone)
@@ -669,13 +958,13 @@ func (s *ICQService) ShortUserInfo(ctx context.Context, instance *state.SessionI
 		},
 		ReqSubType: wire.ICQDBQueryMetaReplyShortInfo,
 		Success:    wire.ICQStatusCodeOK,
-		Nickname:   user.ICQBasicInfo.Nickname,
-		FirstName:  user.ICQBasicInfo.FirstName,
-		LastName:   user.ICQBasicInfo.LastName,
-		Email:      user.ICQBasicInfo.EmailAddress,
-		Gender:     uint8(user.ICQMoreInfo.Gender),
+		Nickname:   user.ICQInfo.Basic.Nickname,
+		FirstName:  user.ICQInfo.Basic.FirstName,
+		LastName:   user.ICQInfo.Basic.LastName,
+		Email:      user.ICQInfo.Basic.EmailAddress,
+		Gender:     uint8(user.ICQInfo.More.Gender),
 	}
-	if !user.ICQPermissions.AuthRequired {
+	if !user.ICQInfo.Permissions.AuthRequired {
 		info.Authorization = 1
 	}
 
@@ -695,7 +984,7 @@ func (s *ICQService) XMLReqData(ctx context.Context, instance *state.SessionInst
 				Seq:     seq,
 			},
 			ReqSubType: wire.ICQDBQueryMetaReplyXMLData,
-			Success:    wire.ICQStatusCodeFail,
+			Success:    wire.ICQStatusCodeErr,
 		},
 	}
 	return s.reply(ctx, instance, msg)
@@ -717,16 +1006,16 @@ func (s *ICQService) affiliations(ctx context.Context, instance *state.SessionIn
 					Keyword string `oscar:"len_prefix=uint16,nullterm"`
 				}{
 					{
-						Code:    user.ICQAffiliations.PastCode1,
-						Keyword: user.ICQAffiliations.PastKeyword1,
+						Code:    user.ICQInfo.Affiliations.PastCode1,
+						Keyword: user.ICQInfo.Affiliations.PastKeyword1,
 					},
 					{
-						Code:    user.ICQAffiliations.PastCode2,
-						Keyword: user.ICQAffiliations.PastKeyword2,
+						Code:    user.ICQInfo.Affiliations.PastCode2,
+						Keyword: user.ICQInfo.Affiliations.PastKeyword2,
 					},
 					{
-						Code:    user.ICQAffiliations.PastCode3,
-						Keyword: user.ICQAffiliations.PastKeyword3,
+						Code:    user.ICQInfo.Affiliations.PastCode3,
+						Keyword: user.ICQInfo.Affiliations.PastKeyword3,
 					},
 				},
 				Affiliations: []struct {
@@ -734,16 +1023,16 @@ func (s *ICQService) affiliations(ctx context.Context, instance *state.SessionIn
 					Keyword string `oscar:"len_prefix=uint16,nullterm"`
 				}{
 					{
-						Code:    user.ICQAffiliations.CurrentCode1,
-						Keyword: user.ICQAffiliations.CurrentKeyword1,
+						Code:    user.ICQInfo.Affiliations.CurrentCode1,
+						Keyword: user.ICQInfo.Affiliations.CurrentKeyword1,
 					},
 					{
-						Code:    user.ICQAffiliations.CurrentCode2,
-						Keyword: user.ICQAffiliations.CurrentKeyword2,
+						Code:    user.ICQInfo.Affiliations.CurrentCode2,
+						Keyword: user.ICQInfo.Affiliations.CurrentKeyword2,
 					},
 					{
-						Code:    user.ICQAffiliations.CurrentCode3,
-						Keyword: user.ICQAffiliations.CurrentKeyword3,
+						Code:    user.ICQInfo.Affiliations.CurrentCode3,
+						Keyword: user.ICQInfo.Affiliations.CurrentKeyword3,
 					},
 				},
 			},
@@ -758,14 +1047,14 @@ func (s *ICQService) createResult(res state.User) wire.ICQUserSearchRecord {
 
 	searchRecord := wire.ICQUserSearchRecord{
 		UIN:       uint32(uin),
-		Nickname:  res.ICQBasicInfo.Nickname,
-		FirstName: res.ICQBasicInfo.FirstName,
-		LastName:  res.ICQBasicInfo.LastName,
-		Email:     res.ICQBasicInfo.EmailAddress,
-		Gender:    uint8(res.ICQMoreInfo.Gender),
+		Nickname:  res.ICQInfo.Basic.Nickname,
+		FirstName: res.ICQInfo.Basic.FirstName,
+		LastName:  res.ICQInfo.Basic.LastName,
+		Email:     res.ICQInfo.Basic.EmailAddress,
+		Gender:    uint8(res.ICQInfo.More.Gender),
 		Age:       res.Age(s.timeNow),
 	}
-	if !res.ICQPermissions.AuthRequired {
+	if !res.ICQInfo.Permissions.AuthRequired {
 		searchRecord.Authorization = 1
 	}
 
@@ -823,20 +1112,20 @@ func (s *ICQService) interests(ctx context.Context, instance *state.SessionInsta
 				Keyword string `oscar:"len_prefix=uint16,nullterm"`
 			}{
 				{
-					Code:    user.ICQInterests.Code1,
-					Keyword: user.ICQInterests.Keyword1,
+					Code:    user.ICQInfo.Interests.Code1,
+					Keyword: user.ICQInfo.Interests.Keyword1,
 				},
 				{
-					Code:    user.ICQInterests.Code2,
-					Keyword: user.ICQInterests.Keyword2,
+					Code:    user.ICQInfo.Interests.Code2,
+					Keyword: user.ICQInfo.Interests.Keyword2,
 				},
 				{
-					Code:    user.ICQInterests.Code3,
-					Keyword: user.ICQInterests.Keyword3,
+					Code:    user.ICQInfo.Interests.Code3,
+					Keyword: user.ICQInfo.Interests.Keyword3,
 				},
 				{
-					Code:    user.ICQInterests.Code4,
-					Keyword: user.ICQInterests.Keyword4,
+					Code:    user.ICQInfo.Interests.Code4,
+					Keyword: user.ICQInfo.Interests.Keyword4,
 				},
 			},
 		},
@@ -857,19 +1146,19 @@ func (s *ICQService) moreUserInfo(ctx context.Context, instance *state.SessionIn
 			Success:    wire.ICQStatusCodeOK,
 			ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo: wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo{
 				Age:          uint8(user.Age(s.timeNow)),
-				Gender:       user.ICQMoreInfo.Gender,
-				HomePageAddr: user.ICQMoreInfo.HomePageAddr,
-				BirthYear:    user.ICQMoreInfo.BirthYear,
-				BirthMonth:   user.ICQMoreInfo.BirthMonth,
-				BirthDay:     user.ICQMoreInfo.BirthDay,
-				Lang1:        user.ICQMoreInfo.Lang1,
-				Lang2:        user.ICQMoreInfo.Lang2,
-				Lang3:        user.ICQMoreInfo.Lang3,
+				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.ICQBasicInfo.City,
-			State:       user.ICQBasicInfo.State,
-			CountryCode: user.ICQBasicInfo.CountryCode,
-			TimeZone:    user.ICQBasicInfo.GMTOffset,
+			City:        user.ICQInfo.Basic.City,
+			State:       user.ICQInfo.Basic.State,
+			CountryCode: user.ICQInfo.Basic.CountryCode,
+			TimeZone:    user.ICQInfo.Basic.GMTOffset,
 		},
 	}
 
@@ -887,7 +1176,7 @@ func (s *ICQService) notes(ctx context.Context, instance *state.SessionInstance,
 			ReqSubType: wire.ICQDBQueryMetaReplyNotes,
 			Success:    wire.ICQStatusCodeOK,
 			ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes: wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes{
-				Notes: user.ICQNotes.Notes,
+				Notes: user.ICQInfo.Notes.Notes,
 			},
 		},
 	}
@@ -939,34 +1228,34 @@ func (s *ICQService) userInfo(ctx context.Context, instance *state.SessionInstan
 		},
 		ReqSubType:  wire.ICQDBQueryMetaReplyBasicInfo,
 		Success:     wire.ICQStatusCodeOK,
-		Nickname:    user.ICQBasicInfo.Nickname,
-		FirstName:   user.ICQBasicInfo.FirstName,
-		LastName:    user.ICQBasicInfo.LastName,
-		Email:       user.ICQBasicInfo.EmailAddress,
-		City:        user.ICQBasicInfo.City,
-		State:       user.ICQBasicInfo.State,
-		Phone:       user.ICQBasicInfo.Phone,
-		Fax:         user.ICQBasicInfo.Fax,
-		Address:     user.ICQBasicInfo.Address,
-		CellPhone:   user.ICQBasicInfo.CellPhone,
-		ZIP:         user.ICQBasicInfo.ZIPCode,
-		CountryCode: user.ICQBasicInfo.CountryCode,
-		GMTOffset:   user.ICQBasicInfo.GMTOffset,
+		Nickname:    user.ICQInfo.Basic.Nickname,
+		FirstName:   user.ICQInfo.Basic.FirstName,
+		LastName:    user.ICQInfo.Basic.LastName,
+		Email:       user.ICQInfo.Basic.EmailAddress,
+		City:        user.ICQInfo.Basic.City,
+		State:       user.ICQInfo.Basic.State,
+		Phone:       user.ICQInfo.Basic.Phone,
+		Fax:         user.ICQInfo.Basic.Fax,
+		Address:     user.ICQInfo.Basic.Address,
+		CellPhone:   user.ICQInfo.Basic.CellPhone,
+		ZIP:         user.ICQInfo.Basic.ZIPCode,
+		CountryCode: user.ICQInfo.Basic.CountryCode,
+		GMTOffset:   user.ICQInfo.Basic.GMTOffset,
 		AuthFlag:    0, // required by default
 		WebAware:    1,
 		DCPerms:     0,
 	}
 
-	if !user.ICQPermissions.AuthRequired {
+	if !user.ICQInfo.Permissions.AuthRequired {
 		userInfo.AuthFlag = 1
 	}
-	if user.ICQPermissions.WebAware {
+	if user.ICQInfo.Permissions.WebAware {
 		userInfo.WebAware = 1
 	} else {
 		userInfo.WebAware = 0
 	}
 
-	if user.ICQBasicInfo.PublishEmail {
+	if user.ICQInfo.Basic.PublishEmail {
 		userInfo.PublishEmail = wire.ICQUserFlagPublishEmailYes
 	} else {
 		userInfo.PublishEmail = wire.ICQUserFlagPublishEmailNo
@@ -990,20 +1279,94 @@ func (s *ICQService) workInfo(ctx context.Context, instance *state.SessionInstan
 			ReqSubType: wire.ICQDBQueryMetaReplyWorkInfo,
 			Success:    wire.ICQStatusCodeOK,
 			ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo: wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo{
-				City:           user.ICQWorkInfo.City,
-				State:          user.ICQWorkInfo.State,
-				Phone:          user.ICQWorkInfo.Phone,
-				Fax:            user.ICQWorkInfo.Fax,
-				Address:        user.ICQWorkInfo.Address,
-				ZIP:            user.ICQWorkInfo.ZIPCode,
-				CountryCode:    user.ICQWorkInfo.CountryCode,
-				Company:        user.ICQWorkInfo.Company,
-				Department:     user.ICQWorkInfo.Department,
-				Position:       user.ICQWorkInfo.Position,
-				OccupationCode: user.ICQWorkInfo.OccupationCode,
-				WebPage:        user.ICQWorkInfo.WebPage,
+				City:           user.ICQInfo.Work.City,
+				State:          user.ICQInfo.Work.State,
+				Phone:          user.ICQInfo.Work.Phone,
+				Fax:            user.ICQInfo.Work.Fax,
+				Address:        user.ICQInfo.Work.Address,
+				ZIP:            user.ICQInfo.Work.ZIPCode,
+				CountryCode:    user.ICQInfo.Work.CountryCode,
+				Company:        user.ICQInfo.Work.Company,
+				Department:     user.ICQInfo.Work.Department,
+				Position:       user.ICQInfo.Work.Position,
+				OccupationCode: user.ICQInfo.Work.OccupationCode,
+				WebPage:        user.ICQInfo.Work.WebPage,
 			},
 		},
 	}
 	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
+}

Разница между файлами не показана из-за своего большого размера
+ 735 - 223
foodgroup/icq_test.go


+ 126 - 0
foodgroup/mock_icq_user_updater_test.go

@@ -164,6 +164,132 @@ func (_c *mockICQUserUpdater_SetBasicInfo_Call) RunAndReturn(run func(ctx contex
 	return _c
 }
 
+// SetHomepageCategory provides a mock function for the type mockICQUserUpdater
+func (_mock *mockICQUserUpdater) SetHomepageCategory(ctx context.Context, name state.IdentScreenName, data state.ICQHomepageCategory) error {
+	ret := _mock.Called(ctx, name, data)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetHomepageCategory")
+	}
+
+	var r0 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, state.ICQHomepageCategory) error); ok {
+		r0 = returnFunc(ctx, name, data)
+	} else {
+		r0 = ret.Error(0)
+	}
+	return r0
+}
+
+// mockICQUserUpdater_SetHomepageCategory_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetHomepageCategory'
+type mockICQUserUpdater_SetHomepageCategory_Call struct {
+	*mock.Call
+}
+
+// SetHomepageCategory is a helper method to define mock.On call
+//   - ctx context.Context
+//   - name state.IdentScreenName
+//   - data state.ICQHomepageCategory
+func (_e *mockICQUserUpdater_Expecter) SetHomepageCategory(ctx interface{}, name interface{}, data interface{}) *mockICQUserUpdater_SetHomepageCategory_Call {
+	return &mockICQUserUpdater_SetHomepageCategory_Call{Call: _e.mock.On("SetHomepageCategory", ctx, name, data)}
+}
+
+func (_c *mockICQUserUpdater_SetHomepageCategory_Call) Run(run func(ctx context.Context, name state.IdentScreenName, data state.ICQHomepageCategory)) *mockICQUserUpdater_SetHomepageCategory_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 state.IdentScreenName
+		if args[1] != nil {
+			arg1 = args[1].(state.IdentScreenName)
+		}
+		var arg2 state.ICQHomepageCategory
+		if args[2] != nil {
+			arg2 = args[2].(state.ICQHomepageCategory)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+		)
+	})
+	return _c
+}
+
+func (_c *mockICQUserUpdater_SetHomepageCategory_Call) Return(err error) *mockICQUserUpdater_SetHomepageCategory_Call {
+	_c.Call.Return(err)
+	return _c
+}
+
+func (_c *mockICQUserUpdater_SetHomepageCategory_Call) RunAndReturn(run func(ctx context.Context, name state.IdentScreenName, data state.ICQHomepageCategory) error) *mockICQUserUpdater_SetHomepageCategory_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetICQInfo provides a mock function for the type mockICQUserUpdater
+func (_mock *mockICQUserUpdater) SetICQInfo(ctx context.Context, name state.IdentScreenName, info state.ICQInfo) error {
+	ret := _mock.Called(ctx, name, info)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetICQInfo")
+	}
+
+	var r0 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, state.ICQInfo) error); ok {
+		r0 = returnFunc(ctx, name, info)
+	} else {
+		r0 = ret.Error(0)
+	}
+	return r0
+}
+
+// mockICQUserUpdater_SetICQInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetICQInfo'
+type mockICQUserUpdater_SetICQInfo_Call struct {
+	*mock.Call
+}
+
+// SetICQInfo is a helper method to define mock.On call
+//   - ctx context.Context
+//   - name state.IdentScreenName
+//   - info state.ICQInfo
+func (_e *mockICQUserUpdater_Expecter) SetICQInfo(ctx interface{}, name interface{}, info interface{}) *mockICQUserUpdater_SetICQInfo_Call {
+	return &mockICQUserUpdater_SetICQInfo_Call{Call: _e.mock.On("SetICQInfo", ctx, name, info)}
+}
+
+func (_c *mockICQUserUpdater_SetICQInfo_Call) Run(run func(ctx context.Context, name state.IdentScreenName, info state.ICQInfo)) *mockICQUserUpdater_SetICQInfo_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 state.IdentScreenName
+		if args[1] != nil {
+			arg1 = args[1].(state.IdentScreenName)
+		}
+		var arg2 state.ICQInfo
+		if args[2] != nil {
+			arg2 = args[2].(state.ICQInfo)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+		)
+	})
+	return _c
+}
+
+func (_c *mockICQUserUpdater_SetICQInfo_Call) Return(err error) *mockICQUserUpdater_SetICQInfo_Call {
+	_c.Call.Return(err)
+	return _c
+}
+
+func (_c *mockICQUserUpdater_SetICQInfo_Call) RunAndReturn(run func(ctx context.Context, name state.IdentScreenName, info state.ICQInfo) error) *mockICQUserUpdater_SetICQInfo_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // SetInterests provides a mock function for the type mockICQUserUpdater
 func (_mock *mockICQUserUpdater) SetInterests(ctx context.Context, name state.IdentScreenName, data state.ICQInterests) error {
 	ret := _mock.Called(ctx, name, data)

+ 6 - 0
foodgroup/types.go

@@ -296,6 +296,12 @@ type ICQUserUpdater interface {
 
 	// SetPermissions updates the user's privacy and permission settings.
 	SetPermissions(ctx context.Context, name state.IdentScreenName, data state.ICQPermissions) error
+
+	// SetHomepageCategory updates the user's homepage category and keyword.
+	SetHomepageCategory(ctx context.Context, name state.IdentScreenName, data state.ICQHomepageCategory) error
+
+	// SetICQInfo updates all ICQ profile columns for the user in one operation.
+	SetICQInfo(ctx context.Context, name state.IdentScreenName, info state.ICQInfo) error
 }
 
 // MessageRelayer defines methods for delivering SNAC messages to one or more

+ 85 - 58
server/http/mgmt_api.go

@@ -1070,74 +1070,77 @@ func getICQProfileHandler(w http.ResponseWriter, r *http.Request, mgr ICQProfile
 	out := icqProfileHandle{
 		UIN: user.IdentScreenName.UIN(),
 		BasicInfo: icqBasicInfoHandle{
-			Nickname:     user.ICQBasicInfo.Nickname,
-			FirstName:    user.ICQBasicInfo.FirstName,
-			LastName:     user.ICQBasicInfo.LastName,
-			EmailAddress: user.ICQBasicInfo.EmailAddress,
-			City:         user.ICQBasicInfo.City,
-			State:        user.ICQBasicInfo.State,
-			Phone:        user.ICQBasicInfo.Phone,
-			Fax:          user.ICQBasicInfo.Fax,
-			Address:      user.ICQBasicInfo.Address,
-			CellPhone:    user.ICQBasicInfo.CellPhone,
-			ZIPCode:      user.ICQBasicInfo.ZIPCode,
-			CountryCode:  user.ICQBasicInfo.CountryCode,
-			GMTOffset:    user.ICQBasicInfo.GMTOffset,
-			PublishEmail: user.ICQBasicInfo.PublishEmail,
+			Nickname:          user.ICQInfo.Basic.Nickname,
+			FirstName:         user.ICQInfo.Basic.FirstName,
+			LastName:          user.ICQInfo.Basic.LastName,
+			EmailAddress:      user.ICQInfo.Basic.EmailAddress,
+			City:              user.ICQInfo.Basic.City,
+			State:             user.ICQInfo.Basic.State,
+			Phone:             user.ICQInfo.Basic.Phone,
+			Fax:               user.ICQInfo.Basic.Fax,
+			Address:           user.ICQInfo.Basic.Address,
+			CellPhone:         user.ICQInfo.Basic.CellPhone,
+			ZIPCode:           user.ICQInfo.Basic.ZIPCode,
+			CountryCode:       user.ICQInfo.Basic.CountryCode,
+			GMTOffset:         user.ICQInfo.Basic.GMTOffset,
+			PublishEmail:      user.ICQInfo.Basic.PublishEmail,
+			OriginCity:        user.ICQInfo.Basic.OriginallyFromCity,
+			OriginState:       user.ICQInfo.Basic.OriginallyFromState,
+			OriginCountryCode: user.ICQInfo.Basic.OriginallyFromCountryCode,
 		},
 		MoreInfo: icqMoreInfoHandle{
-			Gender:       user.ICQMoreInfo.Gender,
-			HomePageAddr: user.ICQMoreInfo.HomePageAddr,
-			BirthYear:    user.ICQMoreInfo.BirthYear,
-			BirthMonth:   user.ICQMoreInfo.BirthMonth,
-			BirthDay:     user.ICQMoreInfo.BirthDay,
-			Lang1:        user.ICQMoreInfo.Lang1,
-			Lang2:        user.ICQMoreInfo.Lang2,
-			Lang3:        user.ICQMoreInfo.Lang3,
+			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,
 		},
 		WorkInfo: icqWorkInfoHandle{
-			Company:        user.ICQWorkInfo.Company,
-			Department:     user.ICQWorkInfo.Department,
-			Position:       user.ICQWorkInfo.Position,
-			OccupationCode: user.ICQWorkInfo.OccupationCode,
-			Address:        user.ICQWorkInfo.Address,
-			City:           user.ICQWorkInfo.City,
-			State:          user.ICQWorkInfo.State,
-			ZIPCode:        user.ICQWorkInfo.ZIPCode,
-			CountryCode:    user.ICQWorkInfo.CountryCode,
-			Phone:          user.ICQWorkInfo.Phone,
-			Fax:            user.ICQWorkInfo.Fax,
-			WebPage:        user.ICQWorkInfo.WebPage,
+			Company:        user.ICQInfo.Work.Company,
+			Department:     user.ICQInfo.Work.Department,
+			Position:       user.ICQInfo.Work.Position,
+			OccupationCode: user.ICQInfo.Work.OccupationCode,
+			Address:        user.ICQInfo.Work.Address,
+			City:           user.ICQInfo.Work.City,
+			State:          user.ICQInfo.Work.State,
+			ZIPCode:        user.ICQInfo.Work.ZIPCode,
+			CountryCode:    user.ICQInfo.Work.CountryCode,
+			Phone:          user.ICQInfo.Work.Phone,
+			Fax:            user.ICQInfo.Work.Fax,
+			WebPage:        user.ICQInfo.Work.WebPage,
 		},
-		Notes: user.ICQNotes.Notes,
+		Notes: user.ICQInfo.Notes.Notes,
 		Interests: icqInterestsHandle{
-			Code1:    user.ICQInterests.Code1,
-			Keyword1: user.ICQInterests.Keyword1,
-			Code2:    user.ICQInterests.Code2,
-			Keyword2: user.ICQInterests.Keyword2,
-			Code3:    user.ICQInterests.Code3,
-			Keyword3: user.ICQInterests.Keyword3,
-			Code4:    user.ICQInterests.Code4,
-			Keyword4: user.ICQInterests.Keyword4,
+			Code1:    user.ICQInfo.Interests.Code1,
+			Keyword1: user.ICQInfo.Interests.Keyword1,
+			Code2:    user.ICQInfo.Interests.Code2,
+			Keyword2: user.ICQInfo.Interests.Keyword2,
+			Code3:    user.ICQInfo.Interests.Code3,
+			Keyword3: user.ICQInfo.Interests.Keyword3,
+			Code4:    user.ICQInfo.Interests.Code4,
+			Keyword4: user.ICQInfo.Interests.Keyword4,
 		},
 		Affiliations: icqAffiliationsHandle{
-			PastCode1:       user.ICQAffiliations.PastCode1,
-			PastKeyword1:    user.ICQAffiliations.PastKeyword1,
-			PastCode2:       user.ICQAffiliations.PastCode2,
-			PastKeyword2:    user.ICQAffiliations.PastKeyword2,
-			PastCode3:       user.ICQAffiliations.PastCode3,
-			PastKeyword3:    user.ICQAffiliations.PastKeyword3,
-			CurrentCode1:    user.ICQAffiliations.CurrentCode1,
-			CurrentKeyword1: user.ICQAffiliations.CurrentKeyword1,
-			CurrentCode2:    user.ICQAffiliations.CurrentCode2,
-			CurrentKeyword2: user.ICQAffiliations.CurrentKeyword2,
-			CurrentCode3:    user.ICQAffiliations.CurrentCode3,
-			CurrentKeyword3: user.ICQAffiliations.CurrentKeyword3,
+			PastCode1:       user.ICQInfo.Affiliations.PastCode1,
+			PastKeyword1:    user.ICQInfo.Affiliations.PastKeyword1,
+			PastCode2:       user.ICQInfo.Affiliations.PastCode2,
+			PastKeyword2:    user.ICQInfo.Affiliations.PastKeyword2,
+			PastCode3:       user.ICQInfo.Affiliations.PastCode3,
+			PastKeyword3:    user.ICQInfo.Affiliations.PastKeyword3,
+			CurrentCode1:    user.ICQInfo.Affiliations.CurrentCode1,
+			CurrentKeyword1: user.ICQInfo.Affiliations.CurrentKeyword1,
+			CurrentCode2:    user.ICQInfo.Affiliations.CurrentCode2,
+			CurrentKeyword2: user.ICQInfo.Affiliations.CurrentKeyword2,
+			CurrentCode3:    user.ICQInfo.Affiliations.CurrentCode3,
+			CurrentKeyword3: user.ICQInfo.Affiliations.CurrentKeyword3,
 		},
 		Permissions: icqPermissionsHandle{
-			AuthRequired: user.ICQPermissions.AuthRequired,
-			WebAware:     user.ICQPermissions.WebAware,
-			AllowSpam:    user.ICQPermissions.AllowSpam,
+			AuthRequired: user.ICQInfo.Permissions.AuthRequired,
+			WebAware:     user.ICQInfo.Permissions.WebAware,
+			AllowSpam:    user.ICQInfo.Permissions.AllowSpam,
 		},
 	}
 
@@ -1179,6 +1182,8 @@ func putICQProfileHandler(w http.ResponseWriter, r *http.Request, mgr ICQProfile
 	// - Phone/fax: max 30 chars
 	// - Email: max 64 chars
 	// - Address/city/state: max 64 chars
+	// - Country codes (basic_info.country_code, basic_info.origin_country_code,
+	//   work_info.country_code): uint16 in JSON, not string length checks
 	// - ZIP: max 12 chars
 	// - Homepage: max 127 chars
 	// - Notes: max 450 chars (v5 limit)
@@ -1202,6 +1207,9 @@ func putICQProfileHandler(w http.ResponseWriter, r *http.Request, mgr ICQProfile
 		{"basic_info.address", input.BasicInfo.Address, 64},
 		{"basic_info.cell_phone", input.BasicInfo.CellPhone, 30},
 		{"basic_info.zip", input.BasicInfo.ZIPCode, 12},
+		{"basic_info.origin_city", input.BasicInfo.OriginCity, 64},
+		{"basic_info.origin_state", input.BasicInfo.OriginState, 64},
+		// basic_info.origin_country_code is uint16 (like basic_info.country_code); validated by JSON binding only.
 		{"more_info.homepage", input.MoreInfo.HomePageAddr, 127},
 		{"work_info.company", input.WorkInfo.Company, 64},
 		{"work_info.department", input.WorkInfo.Department, 64},
@@ -1369,6 +1377,25 @@ func putICQProfileHandler(w http.ResponseWriter, r *http.Request, mgr ICQProfile
 		return
 	}
 
+	merged, err := mgr.User(r.Context(), sn)
+	if err != nil {
+		logger.Error("error reloading user after ICQ profile update", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+	if merged == nil {
+		http.Error(w, "user not found", http.StatusNotFound)
+		return
+	}
+	merged.ICQInfo.Basic.OriginallyFromCity = input.BasicInfo.OriginCity
+	merged.ICQInfo.Basic.OriginallyFromState = input.BasicInfo.OriginState
+	merged.ICQInfo.Basic.OriginallyFromCountryCode = input.BasicInfo.OriginCountryCode
+	if err := mgr.SetICQInfo(r.Context(), sn, merged.ICQInfo); err != nil {
+		logger.Error("error setting ICQ info (origin fields)", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
 	w.WriteHeader(http.StatusNoContent)
 }
 

+ 7 - 0
server/http/types.go

@@ -190,6 +190,9 @@ type ICQProfileManager interface {
 
 	// SetPermissions updates the user's privacy permissions.
 	SetPermissions(ctx context.Context, name state.IdentScreenName, data state.ICQPermissions) error
+
+	// SetICQInfo updates all ICQ profile columns for the user in one operation.
+	SetICQInfo(ctx context.Context, name state.IdentScreenName, info state.ICQInfo) error
 }
 
 type userWithPassword struct {
@@ -348,6 +351,10 @@ type icqBasicInfoHandle struct {
 	CountryCode  uint16 `json:"country_code"`
 	GMTOffset    uint8  `json:"gmt_offset"`
 	PublishEmail bool   `json:"publish_email"`
+	OriginCity   string `json:"origin_city"`
+	OriginState  string `json:"origin_state"`
+	// OriginCountryCode is the ICQ country code for the user's original home.
+	OriginCountryCode uint16 `json:"origin_country_code"`
 }
 
 type icqMoreInfoHandle struct {

+ 5 - 5
server/icq_legacy/legacy_message_bridge.go

@@ -244,12 +244,12 @@ func (b *LegacyMessageBridge) handleBuddyDeparted(session *LegacySession, msg wi
 func (b *LegacyMessageBridge) buildLegacyAuthFields(uin uint32) (nick, firstName, lastName, email string) {
 	nick = fmt.Sprintf("%d", uin)
 	if user, err := b.userFinder.FindByUIN(context.Background(), uin); err == nil {
-		if user.ICQBasicInfo.Nickname != "" {
-			nick = user.ICQBasicInfo.Nickname
+		if user.ICQInfo.Basic.Nickname != "" {
+			nick = user.ICQInfo.Basic.Nickname
 		}
-		firstName = user.ICQBasicInfo.FirstName
-		lastName = user.ICQBasicInfo.LastName
-		email = user.ICQBasicInfo.EmailAddress
+		firstName = user.ICQInfo.Basic.FirstName
+		lastName = user.ICQInfo.Basic.LastName
+		email = user.ICQInfo.Basic.EmailAddress
 	}
 	if len(nick) > 20 {
 		nick = nick[:20]

+ 63 - 0
server/icq_legacy/mock_icq_user_updater_test.go

@@ -227,6 +227,69 @@ func (_c *mockICQUserUpdater_SetHomepageCategory_Call) RunAndReturn(run func(ctx
 	return _c
 }
 
+// SetICQInfo provides a mock function for the type mockICQUserUpdater
+func (_mock *mockICQUserUpdater) SetICQInfo(ctx context.Context, name state.IdentScreenName, info state.ICQInfo) error {
+	ret := _mock.Called(ctx, name, info)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetICQInfo")
+	}
+
+	var r0 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, state.ICQInfo) error); ok {
+		r0 = returnFunc(ctx, name, info)
+	} else {
+		r0 = ret.Error(0)
+	}
+	return r0
+}
+
+// mockICQUserUpdater_SetICQInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetICQInfo'
+type mockICQUserUpdater_SetICQInfo_Call struct {
+	*mock.Call
+}
+
+// SetICQInfo is a helper method to define mock.On call
+//   - ctx context.Context
+//   - name state.IdentScreenName
+//   - info state.ICQInfo
+func (_e *mockICQUserUpdater_Expecter) SetICQInfo(ctx interface{}, name interface{}, info interface{}) *mockICQUserUpdater_SetICQInfo_Call {
+	return &mockICQUserUpdater_SetICQInfo_Call{Call: _e.mock.On("SetICQInfo", ctx, name, info)}
+}
+
+func (_c *mockICQUserUpdater_SetICQInfo_Call) Run(run func(ctx context.Context, name state.IdentScreenName, info state.ICQInfo)) *mockICQUserUpdater_SetICQInfo_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 state.IdentScreenName
+		if args[1] != nil {
+			arg1 = args[1].(state.IdentScreenName)
+		}
+		var arg2 state.ICQInfo
+		if args[2] != nil {
+			arg2 = args[2].(state.ICQInfo)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+		)
+	})
+	return _c
+}
+
+func (_c *mockICQUserUpdater_SetICQInfo_Call) Return(err error) *mockICQUserUpdater_SetICQInfo_Call {
+	_c.Call.Return(err)
+	return _c
+}
+
+func (_c *mockICQUserUpdater_SetICQInfo_Call) RunAndReturn(run func(ctx context.Context, name state.IdentScreenName, info state.ICQInfo) error) *mockICQUserUpdater_SetICQInfo_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // SetInterests provides a mock function for the type mockICQUserUpdater
 func (_mock *mockICQUserUpdater) SetInterests(ctx context.Context, name state.IdentScreenName, data state.ICQInterests) error {
 	ret := _mock.Called(ctx, name, data)

+ 53 - 51
server/icq_legacy/service.go

@@ -252,7 +252,7 @@ func (s *ICQLegacyService) ProcessContactList(ctx context.Context, instance *sta
 		// Only add if the contact does NOT require authorization — otherwise the
 		// legacy user must go through the auth request flow first.
 		contactUser, userErr := s.userFinder.FindByUIN(ctx, contactUIN)
-		if userErr == nil && contactUser.ICQPermissions.AuthRequired {
+		if userErr == nil && contactUser.ICQInfo.Permissions.AuthRequired {
 			continue
 		}
 		contactSession := state.NewSession()
@@ -567,11 +567,13 @@ func (s *ICQLegacyService) RegisterNewUser(ctx context.Context, nickname, firstN
 		IdentScreenName:   screenName,
 		DisplayScreenName: state.DisplayScreenName(strconv.FormatUint(uint64(newUIN), 10)),
 		IsICQ:             true,
-		ICQBasicInfo: state.ICQBasicInfo{
-			Nickname:     nickname,
-			FirstName:    firstName,
-			LastName:     lastName,
-			EmailAddress: email,
+		ICQInfo: state.ICQInfo{
+			Basic: state.ICQBasicInfo{
+				Nickname:     nickname,
+				FirstName:    firstName,
+				LastName:     lastName,
+				EmailAddress: email,
+			},
 		},
 	}
 
@@ -688,10 +690,10 @@ func (s *ICQLegacyService) GetUserInfo(ctx context.Context, uin uint32) (*Legacy
 		if basicUser != nil {
 			return &LegacyUserSearchResult{
 				UIN:       uin,
-				Nickname:  basicUser.ICQBasicInfo.Nickname,
-				FirstName: basicUser.ICQBasicInfo.FirstName,
-				LastName:  basicUser.ICQBasicInfo.LastName,
-				Email:     basicUser.ICQBasicInfo.EmailAddress,
+				Nickname:  basicUser.ICQInfo.Basic.Nickname,
+				FirstName: basicUser.ICQInfo.Basic.FirstName,
+				LastName:  basicUser.ICQInfo.Basic.LastName,
+				Email:     basicUser.ICQInfo.Basic.EmailAddress,
 			}, nil
 		}
 		// Return minimal info
@@ -760,27 +762,27 @@ func (s *ICQLegacyService) GetUserInfoForProtocol(ctx context.Context, targetUIN
 	result := &UserInfoResult{
 		// Basic fields
 		UIN:       targetUIN,
-		Nickname:  user.ICQBasicInfo.Nickname,
-		FirstName: user.ICQBasicInfo.FirstName,
-		LastName:  user.ICQBasicInfo.LastName,
-		Email:     user.ICQBasicInfo.EmailAddress,
-		Gender:    uint8(user.ICQMoreInfo.Gender),
+		Nickname:  user.ICQInfo.Basic.Nickname,
+		FirstName: user.ICQInfo.Basic.FirstName,
+		LastName:  user.ICQInfo.Basic.LastName,
+		Email:     user.ICQInfo.Basic.EmailAddress,
+		Gender:    uint8(user.ICQInfo.More.Gender),
 		Age:       uint8(user.Age(s.timeNow)),
 
 		// Extended fields - location
-		City:    user.ICQBasicInfo.City,
-		State:   user.ICQBasicInfo.State,
-		Country: user.ICQBasicInfo.CountryCode,
-		Phone:   user.ICQBasicInfo.Phone,
+		City:    user.ICQInfo.Basic.City,
+		State:   user.ICQInfo.Basic.State,
+		Country: user.ICQInfo.Basic.CountryCode,
+		Phone:   user.ICQInfo.Basic.Phone,
 
 		// Extended fields - more info
-		Homepage: user.ICQMoreInfo.HomePageAddr,
-		About:    user.ICQNotes.Notes,
+		Homepage: user.ICQInfo.More.HomePageAddr,
+		About:    user.ICQInfo.Notes.Notes,
 
 		// Work info
-		Company:    user.ICQWorkInfo.Company,
-		Department: user.ICQWorkInfo.Department,
-		Position:   user.ICQWorkInfo.Position,
+		Company:    user.ICQInfo.Work.Company,
+		Department: user.ICQInfo.Work.Department,
+		Position:   user.ICQInfo.Work.Position,
 
 		// Auth required
 		AuthRequired: 1,
@@ -792,7 +794,7 @@ func (s *ICQLegacyService) GetUserInfoForProtocol(ctx context.Context, targetUIN
 	}
 
 	// Set auth required flag
-	if user.ICQPermissions.AuthRequired {
+	if user.ICQInfo.Permissions.AuthRequired {
 		result.AuthRequired = 0
 	}
 
@@ -998,7 +1000,7 @@ func (s *ICQLegacyService) filterWhitePagesResults(users []state.User, criteria
 
 		// Filter by gender (1=female, 2=male)
 		if criteria.Gender > 0 && criteria.Gender < 16 {
-			if uint8(user.ICQMoreInfo.Gender) != criteria.Gender {
+			if uint8(user.ICQInfo.More.Gender) != criteria.Gender {
 				continue
 			}
 		}
@@ -1006,9 +1008,9 @@ func (s *ICQLegacyService) filterWhitePagesResults(users []state.User, criteria
 		// Filter by language
 		if criteria.Language > 0 && criteria.Language < 127 {
 			langMatch := false
-			if user.ICQMoreInfo.Lang1 == criteria.Language ||
-				user.ICQMoreInfo.Lang2 == criteria.Language ||
-				user.ICQMoreInfo.Lang3 == criteria.Language {
+			if user.ICQInfo.More.Lang1 == criteria.Language ||
+				user.ICQInfo.More.Lang2 == criteria.Language ||
+				user.ICQInfo.More.Lang3 == criteria.Language {
 				langMatch = true
 			}
 			if !langMatch {
@@ -1018,42 +1020,42 @@ func (s *ICQLegacyService) filterWhitePagesResults(users []state.User, criteria
 
 		// Filter by country (home country is in ICQBasicInfo)
 		if criteria.Country > 0 && criteria.Country < 20000 {
-			if user.ICQBasicInfo.CountryCode != criteria.Country {
+			if user.ICQInfo.Basic.CountryCode != criteria.Country {
 				continue
 			}
 		}
 
 		// Filter by city (case-insensitive partial match, home city is in ICQBasicInfo)
 		if criteria.City != "" {
-			if !containsIgnoreCase(user.ICQBasicInfo.City, criteria.City) {
+			if !containsIgnoreCase(user.ICQInfo.Basic.City, criteria.City) {
 				continue
 			}
 		}
 
 		// Filter by state (case-insensitive partial match, home state is in ICQBasicInfo)
 		if criteria.State != "" {
-			if !containsIgnoreCase(user.ICQBasicInfo.State, criteria.State) {
+			if !containsIgnoreCase(user.ICQInfo.Basic.State, criteria.State) {
 				continue
 			}
 		}
 
 		// Filter by company (case-insensitive partial match)
 		if criteria.Company != "" {
-			if !containsIgnoreCase(user.ICQWorkInfo.Company, criteria.Company) {
+			if !containsIgnoreCase(user.ICQInfo.Work.Company, criteria.Company) {
 				continue
 			}
 		}
 
 		// Filter by position (case-insensitive partial match)
 		if criteria.Position != "" {
-			if !containsIgnoreCase(user.ICQWorkInfo.Position, criteria.Position) {
+			if !containsIgnoreCase(user.ICQInfo.Work.Position, criteria.Position) {
 				continue
 			}
 		}
 
 		// Filter by work code/occupation
 		if criteria.WorkCode > 0 && criteria.WorkCode < 127 {
-			if uint8(user.ICQWorkInfo.OccupationCode) != criteria.WorkCode {
+			if uint8(user.ICQInfo.Work.OccupationCode) != criteria.WorkCode {
 				continue
 			}
 		}
@@ -1343,10 +1345,10 @@ func (s *ICQLegacyService) GetInterests(ctx context.Context, uin uint32) (*state
 
 	s.logger.Debug("GetInterests: retrieved interests",
 		"uin", uin,
-		"count", user.ICQInterests.Count,
+		"count", user.ICQInfo.Interests.Count,
 	)
 
-	return &user.ICQInterests, nil
+	return &user.ICQInfo.Interests, nil
 }
 
 // SetInterests saves the user's interests to the database.
@@ -1400,11 +1402,11 @@ func (s *ICQLegacyService) GetAffiliations(ctx context.Context, uin uint32) (*st
 
 	s.logger.Debug("GetAffiliations: retrieved affiliations",
 		"uin", uin,
-		"past_count", user.ICQAffiliations.PastCount,
-		"current_count", user.ICQAffiliations.CurrentCount,
+		"past_count", user.ICQInfo.Affiliations.PastCount,
+		"current_count", user.ICQInfo.Affiliations.CurrentCount,
 	)
 
-	return &user.ICQAffiliations, nil
+	return &user.ICQInfo.Affiliations, nil
 }
 
 // SetAffiliations saves the user's affiliations to the database.
@@ -1500,10 +1502,10 @@ func (s *ICQLegacyService) GetNotes(ctx context.Context, uin uint32) (string, er
 
 	s.logger.Debug("GetNotes: retrieved notes",
 		"uin", uin,
-		"notes_len", len(user.ICQNotes.Notes),
+		"notes_len", len(user.ICQInfo.Notes.Notes),
 	)
 
-	return user.ICQNotes.Notes, nil
+	return user.ICQInfo.Notes.Notes, nil
 }
 
 // SetNotes saves the user's notes to the database.
@@ -1624,7 +1626,7 @@ func (s *ICQLegacyService) SetAuthMode(ctx context.Context, uin uint32, authRequ
 	}
 
 	// Update the auth mode while preserving other permission settings
-	permissions := user.ICQPermissions
+	permissions := user.ICQInfo.Permissions
 	permissions.AuthRequired = authRequired
 
 	if err := s.userUpdater.SetPermissions(ctx, screenName, permissions); err != nil {
@@ -1666,11 +1668,11 @@ func (s *ICQLegacyService) GetHomepageCategory(ctx context.Context, uin uint32)
 
 	s.logger.Debug("GetHomepageCategory: retrieved homepage category",
 		"uin", uin,
-		"enabled", user.ICQHomepageCategory.Enabled,
-		"index", user.ICQHomepageCategory.Index,
+		"enabled", user.ICQInfo.HomepageCategory.Enabled,
+		"index", user.ICQInfo.HomepageCategory.Index,
 	)
 
-	return &user.ICQHomepageCategory, nil
+	return &user.ICQInfo.HomepageCategory, nil
 }
 
 // SetHomepageCategory saves the user's homepage category to the database.
@@ -1708,7 +1710,7 @@ func (s *ICQLegacyService) SetHomepageCategory(ctx context.Context, uin uint32,
 func (s *ICQLegacyService) userToSearchResult(user state.User) *LegacyUserSearchResult {
 	uin, _ := strconv.Atoi(user.IdentScreenName.String())
 
-	nickname := user.ICQBasicInfo.Nickname
+	nickname := user.ICQInfo.Basic.Nickname
 	// Use UIN as fallback nickname if not set
 	if nickname == "" {
 		nickname = user.IdentScreenName.String()
@@ -1717,10 +1719,10 @@ func (s *ICQLegacyService) userToSearchResult(user state.User) *LegacyUserSearch
 	result := &LegacyUserSearchResult{
 		UIN:       uint32(uin),
 		Nickname:  nickname,
-		FirstName: user.ICQBasicInfo.FirstName,
-		LastName:  user.ICQBasicInfo.LastName,
-		Email:     user.ICQBasicInfo.EmailAddress,
-		Gender:    uint8(user.ICQMoreInfo.Gender),
+		FirstName: user.ICQInfo.Basic.FirstName,
+		LastName:  user.ICQInfo.Basic.LastName,
+		Email:     user.ICQInfo.Basic.EmailAddress,
+		Gender:    uint8(user.ICQInfo.More.Gender),
 		Age:       uint8(user.Age(s.timeNow)),
 	}
 

+ 18 - 12
server/icq_legacy/service_test.go

@@ -564,11 +564,13 @@ func TestICQLegacyService_SearchByUIN(t *testing.T) {
 							UIN: 12345,
 							result: state.User{
 								IdentScreenName: state.NewIdentScreenName("12345"),
-								ICQBasicInfo: state.ICQBasicInfo{
-									Nickname:     "CoolUser",
-									FirstName:    "John",
-									LastName:     "Doe",
-									EmailAddress: "john@example.com",
+								ICQInfo: state.ICQInfo{
+									Basic: state.ICQBasicInfo{
+										Nickname:     "CoolUser",
+										FirstName:    "John",
+										LastName:     "Doe",
+										EmailAddress: "john@example.com",
+									},
 								},
 							},
 						},
@@ -683,10 +685,12 @@ func TestICQLegacyService_SearchByName(t *testing.T) {
 							result: []state.User{
 								{
 									IdentScreenName: state.NewIdentScreenName("12345"),
-									ICQBasicInfo: state.ICQBasicInfo{
-										Nickname:  "JD",
-										FirstName: "John",
-										LastName:  "Doe",
+									ICQInfo: state.ICQInfo{
+										Basic: state.ICQBasicInfo{
+											Nickname:  "JD",
+											FirstName: "John",
+											LastName:  "Doe",
+										},
 									},
 								},
 							},
@@ -714,9 +718,11 @@ func TestICQLegacyService_SearchByName(t *testing.T) {
 							email: "john@example.com",
 							result: state.User{
 								IdentScreenName: state.NewIdentScreenName("12345"),
-								ICQBasicInfo: state.ICQBasicInfo{
-									Nickname:     "JD",
-									EmailAddress: "john@example.com",
+								ICQInfo: state.ICQInfo{
+									Basic: state.ICQBasicInfo{
+										Nickname:     "JD",
+										EmailAddress: "john@example.com",
+									},
 								},
 							},
 						},

+ 1 - 0
server/icq_legacy/types.go

@@ -327,6 +327,7 @@ type ICQUserUpdater interface {
 	SetUserNotes(ctx context.Context, name state.IdentScreenName, data state.ICQUserNotes) error
 	SetPermissions(ctx context.Context, name state.IdentScreenName, data state.ICQPermissions) error
 	SetHomepageCategory(ctx context.Context, name state.IdentScreenName, data state.ICQHomepageCategory) error
+	SetICQInfo(ctx context.Context, name state.IdentScreenName, info state.ICQInfo) error
 }
 
 // FeedbagManager provides server-side buddy list access and modification.

+ 16 - 16
server/icq_legacy/v2_handler.go

@@ -622,14 +622,14 @@ func (h *V2Handler) handleExtInfoReq(session *LegacySession, addr *net.UDPAddr,
 	// Build and send SRV_EXT_INFO_REPLY (0x0122)
 	wireInfo := &LegacyUserInfo{
 		UIN:      targetUIN,
-		City:     truncateField(user.ICQBasicInfo.City, 64, h.logger, "city", targetUIN),
-		State:    truncateField(user.ICQBasicInfo.State, 64, h.logger, "state", targetUIN),
-		Country:  user.ICQBasicInfo.CountryCode,
-		Phone:    truncateField(user.ICQBasicInfo.CellPhone, 30, h.logger, "phone", targetUIN),
-		Homepage: truncateField(user.ICQMoreInfo.HomePageAddr, 127, h.logger, "homepage", targetUIN),
-		About:    truncateField(user.ICQNotes.Notes, 450, h.logger, "about", targetUIN),
+		City:     truncateField(user.ICQInfo.Basic.City, 64, h.logger, "city", targetUIN),
+		State:    truncateField(user.ICQInfo.Basic.State, 64, h.logger, "state", targetUIN),
+		Country:  user.ICQInfo.Basic.CountryCode,
+		Phone:    truncateField(user.ICQInfo.Basic.CellPhone, 30, h.logger, "phone", targetUIN),
+		Homepage: truncateField(user.ICQInfo.More.HomePageAddr, 127, h.logger, "homepage", targetUIN),
+		About:    truncateField(user.ICQInfo.Notes.Notes, 450, h.logger, "about", targetUIN),
 		Age:      user.Age(time.Now),
-		Gender:   uint8(user.ICQMoreInfo.Gender),
+		Gender:   uint8(user.ICQInfo.More.Gender),
 	}
 
 	if session != nil {
@@ -1128,7 +1128,7 @@ func (h *V2Handler) handleUpdateBasic(session *LegacySession, pkt *V2ClientPacke
 	existing, err := h.service.GetFullUserInfo(ctx, session.UIN)
 	var info state.ICQBasicInfo
 	if err == nil && existing != nil {
-		info = existing.ICQBasicInfo
+		info = existing.ICQInfo.Basic
 	}
 	info.Nickname = alias
 	info.FirstName = firstName
@@ -1236,20 +1236,20 @@ func (h *V2Handler) handleUpdateDetail(session *LegacySession, pkt *V2ClientPack
 	ctx := context.Background()
 	existing, err := h.service.GetFullUserInfo(ctx, session.UIN)
 	if err == nil && existing != nil {
-		existing.ICQBasicInfo.City = city
-		existing.ICQBasicInfo.CountryCode = country
-		existing.ICQBasicInfo.State = st
-		existing.ICQBasicInfo.Phone = phone
-		if err := h.service.UpdateBasicInfo(ctx, session.UIN, existing.ICQBasicInfo); err != nil {
+		existing.ICQInfo.Basic.City = city
+		existing.ICQInfo.Basic.CountryCode = country
+		existing.ICQInfo.Basic.State = st
+		existing.ICQInfo.Basic.Phone = phone
+		if err := h.service.UpdateBasicInfo(ctx, session.UIN, existing.ICQInfo.Basic); err != nil {
 			h.logger.Error("V2 update detail basic failed", "uin", session.UIN, "err", err)
 		}
 	}
 
 	// Read existing more info to avoid overwriting birthday/languages
 	if existing != nil {
-		existing.ICQMoreInfo.Gender = uint16(sex)
-		existing.ICQMoreInfo.HomePageAddr = homepage
-		if err := h.service.UpdateMoreInfo(ctx, session.UIN, existing.ICQMoreInfo); err != nil {
+		existing.ICQInfo.More.Gender = uint16(sex)
+		existing.ICQInfo.More.HomePageAddr = homepage
+		if err := h.service.UpdateMoreInfo(ctx, session.UIN, existing.ICQInfo.More); err != nil {
 			h.logger.Error("V2 update detail more failed", "uin", session.UIN, "err", err)
 		}
 	}

+ 22 - 22
server/icq_legacy/v3_handler.go

@@ -1082,16 +1082,16 @@ func (h *V3Handler) handleSetHomeInfo(session *LegacySession, seq1, seq2 uint16,
 	ctx := context.Background()
 	existing, err := h.service.GetFullUserInfo(ctx, uin)
 	if err == nil && existing != nil {
-		existing.ICQBasicInfo.Address = address
-		existing.ICQBasicInfo.City = city
-		existing.ICQBasicInfo.State = st
-		existing.ICQBasicInfo.CountryCode = country
-		existing.ICQBasicInfo.Phone = phone
-		existing.ICQBasicInfo.Fax = fax
-		existing.ICQBasicInfo.CellPhone = cell
-		existing.ICQBasicInfo.ZIPCode = fmt.Sprintf("%d", zipCode)
-		existing.ICQBasicInfo.GMTOffset = uint8(gmt)
-		if err := h.service.UpdateBasicInfo(ctx, uin, existing.ICQBasicInfo); err != nil {
+		existing.ICQInfo.Basic.Address = address
+		existing.ICQInfo.Basic.City = city
+		existing.ICQInfo.Basic.State = st
+		existing.ICQInfo.Basic.CountryCode = country
+		existing.ICQInfo.Basic.Phone = phone
+		existing.ICQInfo.Basic.Fax = fax
+		existing.ICQInfo.Basic.CellPhone = cell
+		existing.ICQInfo.Basic.ZIPCode = fmt.Sprintf("%d", zipCode)
+		existing.ICQInfo.Basic.GMTOffset = uint8(gmt)
+		if err := h.service.UpdateBasicInfo(ctx, uin, existing.ICQInfo.Basic); err != nil {
 			h.logger.Error("V3 set home info failed", "uin", uin, "err", err)
 		}
 	}
@@ -1182,16 +1182,16 @@ func (h *V3Handler) handleSetWorkInfo(session *LegacySession, seq1, seq2 uint16,
 	ctx := context.Background()
 	existing, existErr := h.service.GetFullUserInfo(ctx, uin)
 	if existErr == nil && existing != nil {
-		existing.ICQWorkInfo.Address = address
-		existing.ICQWorkInfo.City = city
-		existing.ICQWorkInfo.State = st
-		existing.ICQWorkInfo.CountryCode = country
-		existing.ICQWorkInfo.Company = company
-		existing.ICQWorkInfo.Position = position
-		existing.ICQWorkInfo.Phone = phone
-		existing.ICQWorkInfo.Fax = fax
-		existing.ICQWorkInfo.ZIPCode = fmt.Sprintf("%d", zipCode)
-		if err := h.service.UpdateWorkInfo(ctx, uin, existing.ICQWorkInfo); err != nil {
+		existing.ICQInfo.Work.Address = address
+		existing.ICQInfo.Work.City = city
+		existing.ICQInfo.Work.State = st
+		existing.ICQInfo.Work.CountryCode = country
+		existing.ICQInfo.Work.Company = company
+		existing.ICQInfo.Work.Position = position
+		existing.ICQInfo.Work.Phone = phone
+		existing.ICQInfo.Work.Fax = fax
+		existing.ICQInfo.Work.ZIPCode = fmt.Sprintf("%d", zipCode)
+		if err := h.service.UpdateWorkInfo(ctx, uin, existing.ICQInfo.Work); err != nil {
 			h.logger.Error("V3 set work info failed", "uin", uin, "err", err)
 		}
 	}
@@ -1219,8 +1219,8 @@ func (h *V3Handler) handleSetWorkWeb(session *LegacySession, seq1, seq2 uint16,
 	ctx := context.Background()
 	existing, err := h.service.GetFullUserInfo(ctx, uin)
 	if err == nil && existing != nil {
-		existing.ICQWorkInfo.WebPage = web
-		if err := h.service.UpdateWorkInfo(ctx, uin, existing.ICQWorkInfo); err != nil {
+		existing.ICQInfo.Work.WebPage = web
+		if err := h.service.UpdateWorkInfo(ctx, uin, existing.ICQInfo.Work); err != nil {
 			h.logger.Error("V3 set work web failed", "uin", uin, "err", err)
 		}
 	}

+ 20 - 20
server/icq_legacy/v4_handler.go

@@ -1430,19 +1430,19 @@ func (h *V4Handler) handleUpdateDetail(session *LegacySession, seq1, seq2 uint16
 		// Read existing basic info to avoid overwriting nick/first/last/email
 		existing, err := h.service.GetFullUserInfo(ctx, uin)
 		if err == nil && existing != nil {
-			existing.ICQBasicInfo.City = city
-			existing.ICQBasicInfo.CountryCode = country
-			existing.ICQBasicInfo.State = st
-			existing.ICQBasicInfo.Phone = phone
-			if err := h.service.UpdateBasicInfo(ctx, uin, existing.ICQBasicInfo); err != nil {
+			existing.ICQInfo.Basic.City = city
+			existing.ICQInfo.Basic.CountryCode = country
+			existing.ICQInfo.Basic.State = st
+			existing.ICQInfo.Basic.Phone = phone
+			if err := h.service.UpdateBasicInfo(ctx, uin, existing.ICQInfo.Basic); err != nil {
 				h.logger.Error("V4 update detail basic failed", "uin", uin, "err", err)
 			}
 		}
 		// Merge more info to avoid overwriting birthday/languages
 		if existing != nil {
-			existing.ICQMoreInfo.Gender = uint16(sex)
-			existing.ICQMoreInfo.HomePageAddr = homepage
-			if err := h.service.UpdateMoreInfo(ctx, uin, existing.ICQMoreInfo); err != nil {
+			existing.ICQInfo.More.Gender = uint16(sex)
+			existing.ICQInfo.More.HomePageAddr = homepage
+			if err := h.service.UpdateMoreInfo(ctx, uin, existing.ICQInfo.More); err != nil {
 				h.logger.Error("V4 update detail more failed", "uin", uin, "err", err)
 			}
 		}
@@ -2109,26 +2109,26 @@ func (h *V4Handler) sendExtInfoResponse(session *LegacySession, seq2 uint16, tar
 	// Try to get full user info from the service
 	user, err := h.service.GetFullUserInfo(ctx, targetUIN)
 	if err == nil && user != nil {
-		city = user.ICQBasicInfo.City
-		country = user.ICQBasicInfo.CountryCode
-		timezone = user.ICQBasicInfo.GMTOffset
-		state = user.ICQBasicInfo.State
-		phone = user.ICQBasicInfo.Phone
-		homepage = user.ICQMoreInfo.HomePageAddr
-		about = user.ICQNotes.Notes
+		city = user.ICQInfo.Basic.City
+		country = user.ICQInfo.Basic.CountryCode
+		timezone = user.ICQInfo.Basic.GMTOffset
+		state = user.ICQInfo.Basic.State
+		phone = user.ICQInfo.Basic.Phone
+		homepage = user.ICQInfo.More.HomePageAddr
+		about = user.ICQInfo.Notes.Notes
 
 		// Calculate age from birth year
-		if user.ICQMoreInfo.BirthYear > 0 {
+		if user.ICQInfo.More.BirthYear > 0 {
 			currentYear := uint16(2026) // Current year
-			age = currentYear - user.ICQMoreInfo.BirthYear
+			age = currentYear - user.ICQInfo.More.BirthYear
 		}
 
 		// Gender (ICQ uses 1=female, 2=male, 0=unspecified)
-		gender = byte(user.ICQMoreInfo.Gender)
+		gender = byte(user.ICQInfo.More.Gender)
 
 		// Zip code - stored as string, sent as uint32 in V3/V4 protocol
-		if user.ICQBasicInfo.ZIPCode != "" {
-			if z, err := strconv.ParseUint(user.ICQBasicInfo.ZIPCode, 10, 32); err == nil {
+		if user.ICQInfo.Basic.ZIPCode != "" {
+			if z, err := strconv.ParseUint(user.ICQInfo.Basic.ZIPCode, 10, 32); err == nil {
 				zipcode = uint32(z)
 			}
 		}

+ 51 - 51
server/icq_legacy/v5_handler.go

@@ -1495,7 +1495,7 @@ func (h *V5Handler) handleMetaLoginInfo(session *LegacySession, pkt *V5ClientPac
 
 	h.logger.Info("META login info - FOUND, sending 7 info packets",
 		"target_uin", targetUIN,
-		"nickname", user.ICQBasicInfo.Nickname,
+		"nickname", user.ICQInfo.Basic.Nickname,
 	)
 
 	// Send all 7 info packets as per iserverd v5_reply_metafullinfo_request2()
@@ -1924,7 +1924,7 @@ func (h *V5Handler) handleMetaUserFullInfo(session *LegacySession, pkt *V5Client
 
 	h.logger.Info("META user full info - FOUND, sending 7 info packets (older format)",
 		"target_uin", targetUIN,
-		"nickname", user.ICQBasicInfo.Nickname,
+		"nickname", user.ICQInfo.Basic.Nickname,
 	)
 
 	// Send all 7 info packets as per iserverd v5_reply_metafullinfo_request()
@@ -1973,7 +1973,7 @@ func (h *V5Handler) handleMetaUserFullInfo2(session *LegacySession, pkt *V5Clien
 
 	h.logger.Info("META user full info2 - FOUND, sending 7 info packets (newer format)",
 		"target_uin", targetUIN,
-		"nickname", user.ICQBasicInfo.Nickname,
+		"nickname", user.ICQInfo.Basic.Nickname,
 	)
 
 	// Send all 7 info packets as per iserverd v5_reply_metafullinfo_request2()
@@ -3078,28 +3078,28 @@ func (h *V5Handler) sendMetaInfo3(session *LegacySession, seq2 uint16, user *sta
 	buf := new(bytes.Buffer)
 	binary.Write(buf, binary.LittleEndian, uint16(0x00C8)) // SRV_META_USER_INFO2
 	buf.WriteByte(0x0A)                                    // success
-	writeLegacyString(buf, user.ICQBasicInfo.Nickname)
-	writeLegacyString(buf, user.ICQBasicInfo.FirstName)
-	writeLegacyString(buf, user.ICQBasicInfo.LastName)
-	writeLegacyString(buf, user.ICQBasicInfo.EmailAddress) // email1
-	writeLegacyString(buf, "")                             // email2 (secondary)
-	writeLegacyString(buf, "")                             // email3 (old)
-	writeLegacyString(buf, user.ICQBasicInfo.City)
-	writeLegacyString(buf, user.ICQBasicInfo.State)
-	writeLegacyString(buf, user.ICQBasicInfo.Phone)
-	writeLegacyString(buf, user.ICQBasicInfo.Fax)
-	writeLegacyString(buf, user.ICQBasicInfo.Address)
-	writeLegacyString(buf, user.ICQBasicInfo.CellPhone)
-	writeLegacyString(buf, user.ICQBasicInfo.ZIPCode)
-	binary.Write(buf, binary.LittleEndian, user.ICQBasicInfo.CountryCode)
-	binary.Write(buf, binary.LittleEndian, uint16(user.ICQBasicInfo.GMTOffset))
+	writeLegacyString(buf, user.ICQInfo.Basic.Nickname)
+	writeLegacyString(buf, user.ICQInfo.Basic.FirstName)
+	writeLegacyString(buf, user.ICQInfo.Basic.LastName)
+	writeLegacyString(buf, user.ICQInfo.Basic.EmailAddress) // email1
+	writeLegacyString(buf, "")                              // email2 (secondary)
+	writeLegacyString(buf, "")                              // email3 (old)
+	writeLegacyString(buf, user.ICQInfo.Basic.City)
+	writeLegacyString(buf, user.ICQInfo.Basic.State)
+	writeLegacyString(buf, user.ICQInfo.Basic.Phone)
+	writeLegacyString(buf, user.ICQInfo.Basic.Fax)
+	writeLegacyString(buf, user.ICQInfo.Basic.Address)
+	writeLegacyString(buf, user.ICQInfo.Basic.CellPhone)
+	writeLegacyString(buf, user.ICQInfo.Basic.ZIPCode)
+	binary.Write(buf, binary.LittleEndian, user.ICQInfo.Basic.CountryCode)
+	binary.Write(buf, binary.LittleEndian, uint16(user.ICQInfo.Basic.GMTOffset))
 	authFlag := uint8(0)
-	if user.ICQPermissions.AuthRequired {
+	if user.ICQInfo.Permissions.AuthRequired {
 		authFlag = 1
 	}
 	buf.WriteByte(authFlag)
 	publishFlag := uint8(0)
-	if !user.ICQBasicInfo.PublishEmail {
+	if !user.ICQInfo.Basic.PublishEmail {
 		publishFlag = 1
 	}
 	buf.WriteByte(publishFlag)
@@ -3148,24 +3148,24 @@ func (h *V5Handler) sendMetaMore(session *LegacySession, seq2 uint16, user *stat
 	// Calculate birth year as single byte (year - 1900)
 	// From iserverd: if (tuser.byear < 1900) {temp_year = tuser.byear;} else {temp_year = tuser.byear - 1900;};
 	var tempYear uint8
-	if user.ICQMoreInfo.BirthYear < 1900 {
-		tempYear = uint8(user.ICQMoreInfo.BirthYear)
+	if user.ICQInfo.More.BirthYear < 1900 {
+		tempYear = uint8(user.ICQInfo.More.BirthYear)
 	} else {
-		tempYear = uint8(user.ICQMoreInfo.BirthYear - 1900)
+		tempYear = uint8(user.ICQInfo.More.BirthYear - 1900)
 	}
 
 	buf := new(bytes.Buffer)
 	binary.Write(buf, binary.LittleEndian, ICQLegacySrvMetaInfoMore) // SRV_META_INFO_MORE = 0x00DC
 	buf.WriteByte(0x0A)                                              // success
 	binary.Write(buf, binary.LittleEndian, user.Age(time.Now))       // age(2)
-	buf.WriteByte(uint8(user.ICQMoreInfo.Gender))                    // gender(1)
-	writeLegacyString(buf, user.ICQMoreInfo.HomePageAddr)            // homepage_len(2) + homepage
+	buf.WriteByte(uint8(user.ICQInfo.More.Gender))                   // gender(1)
+	writeLegacyString(buf, user.ICQInfo.More.HomePageAddr)           // homepage_len(2) + homepage
 	buf.WriteByte(tempYear)                                          // byear(1) - year minus 1900
-	buf.WriteByte(user.ICQMoreInfo.BirthMonth)                       // bmonth(1)
-	buf.WriteByte(user.ICQMoreInfo.BirthDay)                         // bday(1)
-	buf.WriteByte(user.ICQMoreInfo.Lang1)                            // lang1(1)
-	buf.WriteByte(user.ICQMoreInfo.Lang2)                            // lang2(1)
-	buf.WriteByte(user.ICQMoreInfo.Lang3)                            // lang3(1)
+	buf.WriteByte(user.ICQInfo.More.BirthMonth)                      // bmonth(1)
+	buf.WriteByte(user.ICQInfo.More.BirthDay)                        // bday(1)
+	buf.WriteByte(user.ICQInfo.More.Lang1)                           // lang1(1)
+	buf.WriteByte(user.ICQInfo.More.Lang2)                           // lang2(1)
+	buf.WriteByte(user.ICQInfo.More.Lang3)                           // lang3(1)
 
 	pkt := &V5ServerPacket{
 		Version:   ICQLegacyVersionV5,
@@ -3206,17 +3206,17 @@ func (h *V5Handler) sendMetaMore2(session *LegacySession, seq2 uint16, user *sta
 	}
 
 	buf := new(bytes.Buffer)
-	binary.Write(buf, binary.LittleEndian, ICQLegacySrvMetaInfoMore)   // SRV_META_INFO_MORE = 0x00DC
-	buf.WriteByte(0x0A)                                                // success
-	binary.Write(buf, binary.LittleEndian, user.Age(time.Now))         // age(2)
-	buf.WriteByte(uint8(user.ICQMoreInfo.Gender))                      // gender(1)
-	writeLegacyString(buf, user.ICQMoreInfo.HomePageAddr)              // homepage_len(2) + homepage
-	binary.Write(buf, binary.LittleEndian, user.ICQMoreInfo.BirthYear) // byear(2) - full year
-	buf.WriteByte(user.ICQMoreInfo.BirthMonth)                         // bmonth(1)
-	buf.WriteByte(user.ICQMoreInfo.BirthDay)                           // bday(1)
-	buf.WriteByte(user.ICQMoreInfo.Lang1)                              // lang1(1)
-	buf.WriteByte(user.ICQMoreInfo.Lang2)                              // lang2(1)
-	buf.WriteByte(user.ICQMoreInfo.Lang3)                              // lang3(1)
+	binary.Write(buf, binary.LittleEndian, ICQLegacySrvMetaInfoMore)    // SRV_META_INFO_MORE = 0x00DC
+	buf.WriteByte(0x0A)                                                 // success
+	binary.Write(buf, binary.LittleEndian, user.Age(time.Now))          // age(2)
+	buf.WriteByte(uint8(user.ICQInfo.More.Gender))                      // gender(1)
+	writeLegacyString(buf, user.ICQInfo.More.HomePageAddr)              // homepage_len(2) + homepage
+	binary.Write(buf, binary.LittleEndian, user.ICQInfo.More.BirthYear) // byear(2) - full year
+	buf.WriteByte(user.ICQInfo.More.BirthMonth)                         // bmonth(1)
+	buf.WriteByte(user.ICQInfo.More.BirthDay)                           // bday(1)
+	buf.WriteByte(user.ICQInfo.More.Lang1)                              // lang1(1)
+	buf.WriteByte(user.ICQInfo.More.Lang2)                              // lang2(1)
+	buf.WriteByte(user.ICQInfo.More.Lang3)                              // lang3(1)
 
 	pkt := &V5ServerPacket{
 		Version:   ICQLegacyVersionV5,
@@ -3692,21 +3692,21 @@ func (h *V5Handler) sendV5OldStyleInfoExt(session *LegacySession, targetUIN uint
 	// Extract fields from user struct (matching iserverd field names)
 	// From iserverd: tuser.hcity, tuser.hcountry, tuser.hstate, tuser.age, tuser.gender,
 	//                tuser.hphone, tuser.hpage, notes.notes
-	hcity := user.ICQBasicInfo.City
-	hcountry := user.ICQBasicInfo.CountryCode
-	gmtOffset := user.ICQBasicInfo.GMTOffset
-	hstate := user.ICQBasicInfo.State
+	hcity := user.ICQInfo.Basic.City
+	hcountry := user.ICQInfo.Basic.CountryCode
+	gmtOffset := user.ICQInfo.Basic.GMTOffset
+	hstate := user.ICQInfo.Basic.State
 	age := uint16(user.Age(func() time.Time { return time.Now() }))
-	gender := uint8(user.ICQMoreInfo.Gender)
-	hphone := user.ICQBasicInfo.Phone
-	hpage := user.ICQMoreInfo.HomePageAddr
-	notes := user.ICQNotes.Notes
+	gender := uint8(user.ICQInfo.More.Gender)
+	hphone := user.ICQInfo.Basic.Phone
+	hpage := user.ICQInfo.More.HomePageAddr
+	notes := user.ICQInfo.Notes.Notes
 
 	// Parse ZIP code string to uint32 for the old-style ext info format
 	// The licq.5 client reads this as UnpackUnsignedLong (4 bytes)
 	var zipCode uint32
-	if user.ICQBasicInfo.ZIPCode != "" {
-		fmt.Sscanf(user.ICQBasicInfo.ZIPCode, "%d", &zipCode)
+	if user.ICQInfo.Basic.ZIPCode != "" {
+		fmt.Sscanf(user.ICQInfo.Basic.ZIPCode, "%d", &zipCode)
 	}
 
 	// Build data matching licq.5 client's USERxDETAILS parsing:

+ 9 - 7
server/oscar/handler.go

@@ -581,14 +581,8 @@ func (rt Handler) ICQDBQuery(ctx context.Context, instance *state.SessionInstanc
 				return err
 			}
 		case wire.ICQDBQueryMetaReqSearchByUIN2:
-			rest := buf.Bytes()
-			if bytes.HasPrefix(rest, []byte{0x36, 0x01, 0x06, 0x00}) && len(rest) == 8 {
-				// fix incorrect TLV len set by QIP 2005. it specifies len=6
-				// for a 4-byte value, causing the unmarshaler to return EOF.
-				rest[2] = 4
-			}
 			req := wire.ICQ_0x07D0_0x0569_DBQueryMetaReqSearchByUIN2{}
-			if err := wire.UnmarshalLE(&req, bytes.NewReader(rest)); err != nil {
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
 				return err
 			}
 			if err := rt.ICQService.FindByUIN2(ctx, instance, req, icqMD.Seq); err != nil {
@@ -690,6 +684,14 @@ func (rt Handler) ICQDBQuery(ctx context.Context, instance *state.SessionInstanc
 			if err := rt.ICQService.SetAffiliations(ctx, instance, req, icqMD.Seq); err != nil {
 				return err
 			}
+		case wire.ICQDBQueryMetaReqSetFullInfo:
+			req := wire.ICQ_0x07D0_0x0C3A_DBQueryMetaReqSetFullInfo{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.SetICQInfo(ctx, instance, req, icqMD.Seq); err != nil {
+				return err
+			}
 		case wire.ICQDBQueryMetaReqStat0a8c,
 			wire.ICQDBQueryMetaReqStat0a96,
 			wire.ICQDBQueryMetaReqStat0aaa,

+ 49 - 0
server/oscar/handler_test.go

@@ -2848,6 +2848,7 @@ func TestHandler_ICQDBQuery(t *testing.T) {
 		setAffiliations   *mockParam
 		setBasicInfo      *mockParam
 		setEmails         *mockParam
+		setFullInfo       *mockParam
 		setInterests      *mockParam
 		setMoreInfo       *mockParam
 		setPermissions    *mockParam
@@ -3580,6 +3581,50 @@ func TestHandler_ICQDBQuery(t *testing.T) {
 				},
 			},
 		},
+		{
+			name: "MetaReqSetFullInfo - happy path",
+			reqParams: reqParams{
+				instance: state.NewSession().AddInstance(),
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSetFullInfo,
+									MetaRequest: wire.ICQ_0x07D0_0x0C3A_DBQueryMetaReqSetFullInfo{
+										TLVRestBlock: wire.TLVRestBlock{
+											TLVList: wire.TLVList{
+												wire.NewTLVLE(wire.ICQTLVTagsFirstName, struct {
+													V string `oscar:"len_prefix=uint16,nullterm"`
+												}{V: "john"}),
+											},
+										},
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				setFullInfo: &mockParam{
+					req: wire.ICQ_0x07D0_0x0C3A_DBQueryMetaReqSetFullInfo{
+						TLVRestBlock: wire.TLVRestBlock{
+							TLVList: wire.TLVList{
+								wire.NewTLVLE(wire.ICQTLVTagsFirstName, struct {
+									V string `oscar:"len_prefix=uint16,nullterm"`
+								}{V: "john"}),
+							},
+						},
+					},
+				},
+			},
+		},
 		{
 			name: "MetaReqStat - happy path",
 			reqParams: reqParams{
@@ -3781,6 +3826,10 @@ func TestHandler_ICQDBQuery(t *testing.T) {
 				icqService.EXPECT().
 					SetAffiliations(mock.Anything, tt.reqParams.instance, tt.allMockParams.setAffiliations.req, tt.reqParams.seq).
 					Return(tt.allMockParams.setAffiliations.wantErr)
+			case tt.allMockParams.setFullInfo != nil:
+				icqService.EXPECT().
+					SetICQInfo(mock.Anything, tt.reqParams.instance, tt.allMockParams.setFullInfo.req, tt.reqParams.seq).
+					Return(tt.allMockParams.setFullInfo.wantErr)
 			case tt.allMockParams.offlineMsgReq != nil:
 				icqService.EXPECT().
 					OfflineMsgReq(mock.Anything, tt.reqParams.instance, tt.reqParams.seq).

+ 69 - 0
server/oscar/mock_icq_service_test.go

@@ -924,6 +924,75 @@ func (_c *mockICQService_SetEmails_Call) RunAndReturn(run func(ctx context.Conte
 	return _c
 }
 
+// SetICQInfo provides a mock function for the type mockICQService
+func (_mock *mockICQService) SetICQInfo(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0C3A_DBQueryMetaReqSetFullInfo, seq uint16) error {
+	ret := _mock.Called(ctx, instance, inBody, seq)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetICQInfo")
+	}
+
+	var r0 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.ICQ_0x07D0_0x0C3A_DBQueryMetaReqSetFullInfo, uint16) error); ok {
+		r0 = returnFunc(ctx, instance, inBody, seq)
+	} else {
+		r0 = ret.Error(0)
+	}
+	return r0
+}
+
+// mockICQService_SetICQInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetICQInfo'
+type mockICQService_SetICQInfo_Call struct {
+	*mock.Call
+}
+
+// SetICQInfo is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+//   - inBody wire.ICQ_0x07D0_0x0C3A_DBQueryMetaReqSetFullInfo
+//   - seq uint16
+func (_e *mockICQService_Expecter) SetICQInfo(ctx interface{}, instance interface{}, inBody interface{}, seq interface{}) *mockICQService_SetICQInfo_Call {
+	return &mockICQService_SetICQInfo_Call{Call: _e.mock.On("SetICQInfo", ctx, instance, inBody, seq)}
+}
+
+func (_c *mockICQService_SetICQInfo_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0C3A_DBQueryMetaReqSetFullInfo, seq uint16)) *mockICQService_SetICQInfo_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 wire.ICQ_0x07D0_0x0C3A_DBQueryMetaReqSetFullInfo
+		if args[2] != nil {
+			arg2 = args[2].(wire.ICQ_0x07D0_0x0C3A_DBQueryMetaReqSetFullInfo)
+		}
+		var arg3 uint16
+		if args[3] != nil {
+			arg3 = args[3].(uint16)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+			arg3,
+		)
+	})
+	return _c
+}
+
+func (_c *mockICQService_SetICQInfo_Call) Return(err error) *mockICQService_SetICQInfo_Call {
+	_c.Call.Return(err)
+	return _c
+}
+
+func (_c *mockICQService_SetICQInfo_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0C3A_DBQueryMetaReqSetFullInfo, seq uint16) error) *mockICQService_SetICQInfo_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // SetICQPhone provides a mock function for the type mockICQService
 func (_mock *mockICQService) SetICQPhone(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0654_DBQueryMetaReqSetICQPhone, seq uint16) error {
 	ret := _mock.Called(ctx, instance, inBody, seq)

+ 1 - 0
server/oscar/types.go

@@ -128,6 +128,7 @@ type ICQService interface {
 	SetAffiliations(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations, seq uint16) error
 	SetBasicInfo(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo, seq uint16) error
 	SetEmails(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails, seq uint16) error
+	SetICQInfo(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0C3A_DBQueryMetaReqSetFullInfo, seq uint16) error
 	SetInterests(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests, seq uint16) error
 	SetMoreInfo(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo, seq uint16) error
 	SetPermissions(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions, seq uint16) error

+ 3 - 0
state/migrations/0032_icq_basic_originally_from.down.sql

@@ -0,0 +1,3 @@
+ALTER TABLE users DROP COLUMN icq_basicInfo_originCity;
+ALTER TABLE users DROP COLUMN icq_basicInfo_originState;
+ALTER TABLE users DROP COLUMN icq_basicInfo_originCountryCode;

+ 4 - 0
state/migrations/0032_icq_basic_originally_from.up.sql

@@ -0,0 +1,4 @@
+-- ICQ "originally from" home town (SetICQInfo TLVs 0x0320 / 0x032A / 0x0334).
+ALTER TABLE users ADD COLUMN icq_basicInfo_originCity TEXT NOT NULL DEFAULT '';
+ALTER TABLE users ADD COLUMN icq_basicInfo_originState TEXT NOT NULL DEFAULT '';
+ALTER TABLE users ADD COLUMN icq_basicInfo_originCountryCode INTEGER NOT NULL DEFAULT 0;

+ 42 - 56
state/user.go

@@ -131,6 +131,35 @@ func (s DisplayScreenName) String() string {
 	return string(s)
 }
 
+// ICQInfo groups ICQ profile segments stored for a user. Sub-structs are
+// named fields (not anonymously embedded) because ICQBasicInfo and ICQWorkInfo
+// share exported field names (e.g. Address, City), which would make field
+// promotion into User invalid or ambiguous.
+type ICQInfo struct {
+	// Affiliations holds information about the user's affiliations,
+	// including past and current affiliations.
+	Affiliations ICQAffiliations
+	// Interests holds information about the user's interests, categorized
+	// by code and associated keywords.
+	Interests ICQInterests
+	// More contains additional information about the user.
+	More ICQMoreInfo
+	// Permissions specifies the user's privacy settings.
+	Permissions ICQPermissions
+	// Basic contains the user's basic profile information, including
+	// contact details and personal identifiers.
+	Basic ICQBasicInfo
+	// Notes allows the user to store personal notes or additional
+	// information within their profile.
+	Notes ICQUserNotes
+	// Work contains the user's professional information, including
+	// their workplace address and job-related details.
+	Work ICQWorkInfo
+	// HomepageCategory contains homepage category information for the user.
+	// Used by V5 META_SET_HPCAT (0x0442) command.
+	HomepageCategory ICQHomepageCategory
+}
+
 // User represents a user account.
 type User struct {
 	// IdentScreenName is the AIM screen name.
@@ -158,29 +187,9 @@ type User struct {
 	SuspendedStatus uint16
 	// EmailAddress is the email address set by the AIM client.
 	EmailAddress string
-	// ICQAffiliations holds information about the user's affiliations,
-	// including past and current affiliations.
-	ICQAffiliations ICQAffiliations
-	// ICQInterests holds information about the user's interests, categorized
-	// by code and associated keywords.
-	ICQInterests ICQInterests
-	// ICQMoreInfo contains additional information about the user.
-	ICQMoreInfo ICQMoreInfo
-	// ICQPermissions specifies the user's privacy settings.
-	ICQPermissions ICQPermissions
-	// ICQBasicInfo contains the user's basic profile information, including
-	// contact details and personal identifiers.
-	ICQBasicInfo ICQBasicInfo
-	// ICQNotes allows the user to store personal notes or additional
-	// information within their profile.
-	ICQNotes ICQUserNotes
-	// ICQWorkInfo contains the user's professional information, including
-	// their workplace address and job-related details.
-	ICQWorkInfo ICQWorkInfo
-	// ICQHomepageCategory contains homepage category information for the user.
-	// Used by V5 META_SET_HPCAT (0x0442) command.
-	ICQHomepageCategory ICQHomepageCategory
-	AIMDirectoryInfo    AIMNameAndAddr
+	// ICQInfo holds ICQ-specific profile segments for ICQ accounts.
+	ICQInfo          ICQInfo
+	AIMDirectoryInfo AIMNameAndAddr
 	// TOCConfig is the user's saved server-side info (buddy list, etc) for
 	// on the TOC service.
 	TOCConfig string
@@ -268,85 +277,66 @@ type ICQBasicInfo struct {
 	State string
 	// ZIPCode is the user's postal code.
 	ZIPCode string
+	// OriginallyFromCity is the city the user is originally from (ICQ profile).
+	OriginallyFromCity string
+	// OriginallyFromState is the state or region the user is originally from (ICQ profile).
+	OriginallyFromState string
+	// OriginallyFromCountryCode is the country code for the user's original home (ICQ profile).
+	OriginallyFromCountryCode uint16
 }
 
 // ICQAffiliations contains information about the user's affiliations, both past and present.
-// From iserverd: past_num, past_ind1-3, past_key1-3, aff_num, aff_ind1-3, aff_key1-3
 type ICQAffiliations struct {
 	// PastCount is the number of past affiliations set (0-3).
-	// From iserverd: past_num
 	PastCount uint8
 	// PastCode1 is the code representing the user's first past affiliation.
-	// From iserverd: past_ind1
 	PastCode1 uint16
 	// PastKeyword1 is the keyword associated with the user's first past affiliation.
-	// From iserverd: past_key1
 	PastKeyword1 string
 	// PastCode2 is the code representing the user's second past affiliation.
-	// From iserverd: past_ind2
 	PastCode2 uint16
 	// PastKeyword2 is the keyword associated with the user's second past affiliation.
-	// From iserverd: past_key2
 	PastKeyword2 string
 	// PastCode3 is the code representing the user's third past affiliation.
-	// From iserverd: past_ind3
 	PastCode3 uint16
 	// PastKeyword3 is the keyword associated with the user's third past affiliation.
-	// From iserverd: past_key3
 	PastKeyword3 string
 	// CurrentCount is the number of current affiliations set (0-3).
-	// From iserverd: aff_num
 	CurrentCount uint8
 	// CurrentCode1 is the code representing the user's current first affiliation.
-	// From iserverd: aff_ind1
 	CurrentCode1 uint16
 	// CurrentKeyword1 is the keyword associated with the user's current first affiliation.
-	// From iserverd: aff_key1
 	CurrentKeyword1 string
 	// CurrentCode2 is the code representing the user's current second affiliation.
-	// From iserverd: aff_ind2
 	CurrentCode2 uint16
 	// CurrentKeyword2 is the keyword associated with the user's current second affiliation.
-	// From iserverd: aff_key2
 	CurrentKeyword2 string
 	// CurrentCode3 is the code representing the user's current third affiliation.
-	// From iserverd: aff_ind3
 	CurrentCode3 uint16
 	// CurrentKeyword3 is the keyword associated with the user's current third affiliation.
-	// From iserverd: aff_key3
 	CurrentKeyword3 string
 }
 
 // ICQInterests holds information about the user's interests, categorized by
 // interest code and associated keyword.
-// From iserverd: int_num, int_ind1-4, int_key1-4
 type ICQInterests struct {
 	// Count is the number of interests set (0-4).
-	// From iserverd: int_num
 	Count uint8
 	// Code1 is the code representing the user's first interest.
-	// From iserverd: int_ind1
 	Code1 uint16
 	// Keyword1 is the keyword associated with the user's first interest.
-	// From iserverd: int_key1
 	Keyword1 string
 	// Code2 is the code representing the user's second interest.
-	// From iserverd: int_ind2
 	Code2 uint16
 	// Keyword2 is the keyword associated with the user's second interest.
-	// From iserverd: int_key2
 	Keyword2 string
 	// Code3 is the code representing the user's third interest.
-	// From iserverd: int_ind3
 	Code3 uint16
 	// Keyword3 is the keyword associated with the user's third interest.
-	// From iserverd: int_key3
 	Keyword3 string
 	// Code4 is the code representing the user's fourth interest.
-	// From iserverd: int_ind4
 	Code4 uint16
 	// Keyword4 is the keyword associated with the user's fourth interest.
-	// From iserverd: int_key4
 	Keyword4 string
 }
 
@@ -421,16 +411,12 @@ type ICQPermissions struct {
 
 // ICQHomepageCategory contains homepage category information for an ICQ user.
 // This is used by the V5 META_SET_HPCAT (0x0442) command.
-// From iserverd: hpage_cf, hpage_cat, hpage_txt
 type ICQHomepageCategory struct {
 	// Enabled indicates whether the homepage category is enabled.
-	// From iserverd: hpage_cf (0 = disabled, 1 = enabled)
 	Enabled bool
 	// Index is the homepage category index/code.
-	// From iserverd: hpage_cat
 	Index uint16
 	// Description is the homepage category description/keyword.
-	// From iserverd: hpage_txt (max 127 chars)
 	Description string
 }
 
@@ -438,11 +424,11 @@ type ICQHomepageCategory struct {
 func (u *User) Age(timeNow func() time.Time) uint16 {
 	now := timeNow().UTC()
 	switch {
-	case u.ICQMoreInfo.BirthYear > 0 && u.ICQMoreInfo.BirthDay == 0 && u.ICQMoreInfo.BirthMonth == 0:
-		bday := time.Date(int(u.ICQMoreInfo.BirthYear), time.January, 1, 0, 0, 0, 0, time.UTC)
+	case u.ICQInfo.More.BirthYear > 0 && u.ICQInfo.More.BirthDay == 0 && u.ICQInfo.More.BirthMonth == 0:
+		bday := time.Date(int(u.ICQInfo.More.BirthYear), time.January, 1, 0, 0, 0, 0, time.UTC)
 		return uint16(now.Year() - bday.Year())
-	case u.ICQMoreInfo.BirthYear > 0 && u.ICQMoreInfo.BirthDay > 0 && u.ICQMoreInfo.BirthMonth > 0:
-		bday := time.Date(int(u.ICQMoreInfo.BirthYear), time.Month(u.ICQMoreInfo.BirthMonth), int(u.ICQMoreInfo.BirthDay), 0, 0, 0, 0, time.UTC)
+	case u.ICQInfo.More.BirthYear > 0 && u.ICQInfo.More.BirthDay > 0 && u.ICQInfo.More.BirthMonth > 0:
+		bday := time.Date(int(u.ICQInfo.More.BirthYear), time.Month(u.ICQInfo.More.BirthMonth), int(u.ICQInfo.More.BirthDay), 0, 0, 0, 0, time.UTC)
 		years := now.Year() - bday.Year()
 		if now.YearDay() < bday.YearDay() {
 			years--

+ 221 - 62
state/user_store.go

@@ -341,7 +341,7 @@ func (f SQLiteUserStore) RequiresAuthorization(ctx context.Context, owner, reque
 	if err != nil {
 		return false, fmt.Errorf("RequiresAuthorization: %w", err)
 	}
-	if u == nil || !u.ICQPermissions.AuthRequired {
+	if u == nil || !u.ICQInfo.Permissions.AuthRequired {
 		return false, nil
 	}
 	var one int
@@ -426,6 +426,9 @@ func (f SQLiteUserStore) queryUsers(ctx context.Context, whereClause string, que
 			icq_basicInfo_publishEmail,
 			icq_basicInfo_state,
 			icq_basicInfo_zipCode,
+			icq_basicInfo_originCity,
+			icq_basicInfo_originState,
+			icq_basicInfo_originCountryCode,
 			icq_interests_code1,
 			icq_interests_code2,
 			icq_interests_code3,
@@ -502,67 +505,70 @@ func (f SQLiteUserStore) queryUsers(ctx context.Context, whereClause string, que
 			&u.SuspendedStatus,
 			&u.IsBot,
 			&u.IsICQ,
-			&u.ICQAffiliations.CurrentCode1,
-			&u.ICQAffiliations.CurrentCode2,
-			&u.ICQAffiliations.CurrentCode3,
-			&u.ICQAffiliations.CurrentKeyword1,
-			&u.ICQAffiliations.CurrentKeyword2,
-			&u.ICQAffiliations.CurrentKeyword3,
-			&u.ICQAffiliations.PastCode1,
-			&u.ICQAffiliations.PastCode2,
-			&u.ICQAffiliations.PastCode3,
-			&u.ICQAffiliations.PastKeyword1,
-			&u.ICQAffiliations.PastKeyword2,
-			&u.ICQAffiliations.PastKeyword3,
-			&u.ICQBasicInfo.Address,
-			&u.ICQBasicInfo.CellPhone,
-			&u.ICQBasicInfo.City,
-			&u.ICQBasicInfo.CountryCode,
-			&u.ICQBasicInfo.EmailAddress,
-			&u.ICQBasicInfo.Fax,
-			&u.ICQBasicInfo.FirstName,
-			&u.ICQBasicInfo.GMTOffset,
-			&u.ICQBasicInfo.LastName,
-			&u.ICQBasicInfo.Nickname,
-			&u.ICQBasicInfo.Phone,
-			&u.ICQBasicInfo.PublishEmail,
-			&u.ICQBasicInfo.State,
-			&u.ICQBasicInfo.ZIPCode,
-			&u.ICQInterests.Code1,
-			&u.ICQInterests.Code2,
-			&u.ICQInterests.Code3,
-			&u.ICQInterests.Code4,
-			&u.ICQInterests.Keyword1,
-			&u.ICQInterests.Keyword2,
-			&u.ICQInterests.Keyword3,
-			&u.ICQInterests.Keyword4,
-			&u.ICQMoreInfo.BirthDay,
-			&u.ICQMoreInfo.BirthMonth,
-			&u.ICQMoreInfo.BirthYear,
-			&u.ICQMoreInfo.Gender,
-			&u.ICQMoreInfo.HomePageAddr,
-			&u.ICQMoreInfo.Lang1,
-			&u.ICQMoreInfo.Lang2,
-			&u.ICQMoreInfo.Lang3,
-			&u.ICQNotes.Notes,
-			&u.ICQPermissions.AuthRequired,
-			&u.ICQPermissions.WebAware,
-			&u.ICQPermissions.AllowSpam,
-			&u.ICQWorkInfo.Address,
-			&u.ICQWorkInfo.City,
-			&u.ICQWorkInfo.Company,
-			&u.ICQWorkInfo.CountryCode,
-			&u.ICQWorkInfo.Department,
-			&u.ICQWorkInfo.Fax,
-			&u.ICQWorkInfo.OccupationCode,
-			&u.ICQWorkInfo.Phone,
-			&u.ICQWorkInfo.Position,
-			&u.ICQWorkInfo.State,
-			&u.ICQWorkInfo.WebPage,
-			&u.ICQWorkInfo.ZIPCode,
-			&u.ICQHomepageCategory.Enabled,
-			&u.ICQHomepageCategory.Index,
-			&u.ICQHomepageCategory.Description,
+			&u.ICQInfo.Affiliations.CurrentCode1,
+			&u.ICQInfo.Affiliations.CurrentCode2,
+			&u.ICQInfo.Affiliations.CurrentCode3,
+			&u.ICQInfo.Affiliations.CurrentKeyword1,
+			&u.ICQInfo.Affiliations.CurrentKeyword2,
+			&u.ICQInfo.Affiliations.CurrentKeyword3,
+			&u.ICQInfo.Affiliations.PastCode1,
+			&u.ICQInfo.Affiliations.PastCode2,
+			&u.ICQInfo.Affiliations.PastCode3,
+			&u.ICQInfo.Affiliations.PastKeyword1,
+			&u.ICQInfo.Affiliations.PastKeyword2,
+			&u.ICQInfo.Affiliations.PastKeyword3,
+			&u.ICQInfo.Basic.Address,
+			&u.ICQInfo.Basic.CellPhone,
+			&u.ICQInfo.Basic.City,
+			&u.ICQInfo.Basic.CountryCode,
+			&u.ICQInfo.Basic.EmailAddress,
+			&u.ICQInfo.Basic.Fax,
+			&u.ICQInfo.Basic.FirstName,
+			&u.ICQInfo.Basic.GMTOffset,
+			&u.ICQInfo.Basic.LastName,
+			&u.ICQInfo.Basic.Nickname,
+			&u.ICQInfo.Basic.Phone,
+			&u.ICQInfo.Basic.PublishEmail,
+			&u.ICQInfo.Basic.State,
+			&u.ICQInfo.Basic.ZIPCode,
+			&u.ICQInfo.Basic.OriginallyFromCity,
+			&u.ICQInfo.Basic.OriginallyFromState,
+			&u.ICQInfo.Basic.OriginallyFromCountryCode,
+			&u.ICQInfo.Interests.Code1,
+			&u.ICQInfo.Interests.Code2,
+			&u.ICQInfo.Interests.Code3,
+			&u.ICQInfo.Interests.Code4,
+			&u.ICQInfo.Interests.Keyword1,
+			&u.ICQInfo.Interests.Keyword2,
+			&u.ICQInfo.Interests.Keyword3,
+			&u.ICQInfo.Interests.Keyword4,
+			&u.ICQInfo.More.BirthDay,
+			&u.ICQInfo.More.BirthMonth,
+			&u.ICQInfo.More.BirthYear,
+			&u.ICQInfo.More.Gender,
+			&u.ICQInfo.More.HomePageAddr,
+			&u.ICQInfo.More.Lang1,
+			&u.ICQInfo.More.Lang2,
+			&u.ICQInfo.More.Lang3,
+			&u.ICQInfo.Notes.Notes,
+			&u.ICQInfo.Permissions.AuthRequired,
+			&u.ICQInfo.Permissions.WebAware,
+			&u.ICQInfo.Permissions.AllowSpam,
+			&u.ICQInfo.Work.Address,
+			&u.ICQInfo.Work.City,
+			&u.ICQInfo.Work.Company,
+			&u.ICQInfo.Work.CountryCode,
+			&u.ICQInfo.Work.Department,
+			&u.ICQInfo.Work.Fax,
+			&u.ICQInfo.Work.OccupationCode,
+			&u.ICQInfo.Work.Phone,
+			&u.ICQInfo.Work.Position,
+			&u.ICQInfo.Work.State,
+			&u.ICQInfo.Work.WebPage,
+			&u.ICQInfo.Work.ZIPCode,
+			&u.ICQInfo.HomepageCategory.Enabled,
+			&u.ICQInfo.HomepageCategory.Index,
+			&u.ICQInfo.HomepageCategory.Description,
 			&u.AIMDirectoryInfo.FirstName,
 			&u.AIMDirectoryInfo.LastName,
 			&u.AIMDirectoryInfo.MiddleName,
@@ -1732,6 +1738,159 @@ func (f SQLiteUserStore) SetBasicInfo(ctx context.Context, name IdentScreenName,
 	return nil
 }
 
+// SetICQInfo updates all ICQ profile columns on the user row in one UPDATE.
+// Keep this column list aligned with SetMoreInfo, SetWorkInfo, SetPermissions,
+// SetUserNotes, SetInterests, SetAffiliations, and SetHomepageCategory. Basic-info
+// columns that are not on SetBasicInfo (e.g. originally-from) live only here.
+func (f SQLiteUserStore) SetICQInfo(ctx context.Context, name IdentScreenName, info ICQInfo) error {
+	q := `
+		UPDATE users SET
+			icq_basicInfo_cellPhone = ?,
+			icq_basicInfo_countryCode = ?,
+			icq_basicInfo_emailAddress = ?,
+			icq_basicInfo_firstName = ?,
+			icq_basicInfo_gmtOffset = ?,
+			icq_basicInfo_address = ?,
+			icq_basicInfo_city = ?,
+			icq_basicInfo_fax = ?,
+			icq_basicInfo_phone = ?,
+			icq_basicInfo_state = ?,
+			icq_basicInfo_lastName = ?,
+			icq_basicInfo_nickName = ?,
+			icq_basicInfo_publishEmail = ?,
+			icq_basicInfo_zipCode = ?,
+			icq_basicInfo_originCity = ?,
+			icq_basicInfo_originState = ?,
+			icq_basicInfo_originCountryCode = ?,
+			icq_moreInfo_birthDay = ?,
+			icq_moreInfo_birthMonth = ?,
+			icq_moreInfo_birthYear = ?,
+			icq_moreInfo_gender = ?,
+			icq_moreInfo_homePageAddr = ?,
+			icq_moreInfo_lang1 = ?,
+			icq_moreInfo_lang2 = ?,
+			icq_moreInfo_lang3 = ?,
+			icq_workInfo_company = ?,
+			icq_workInfo_department = ?,
+			icq_workInfo_occupationCode = ?,
+			icq_workInfo_position = ?,
+			icq_workInfo_address = ?,
+			icq_workInfo_city = ?,
+			icq_workInfo_countryCode = ?,
+			icq_workInfo_fax = ?,
+			icq_workInfo_phone = ?,
+			icq_workInfo_state = ?,
+			icq_workInfo_webPage = ?,
+			icq_workInfo_zipCode = ?,
+			icq_permissions_authRequired = ?,
+			icq_permissions_webAware = ?,
+			icq_permissions_allowSpam = ?,
+			icq_notes = ?,
+			icq_interests_code1 = ?,
+			icq_interests_keyword1 = ?,
+			icq_interests_code2 = ?,
+			icq_interests_keyword2 = ?,
+			icq_interests_code3 = ?,
+			icq_interests_keyword3 = ?,
+			icq_interests_code4 = ?,
+			icq_interests_keyword4 = ?,
+			icq_affiliations_currentCode1 = ?,
+			icq_affiliations_currentKeyword1 = ?,
+			icq_affiliations_currentCode2 = ?,
+			icq_affiliations_currentKeyword2 = ?,
+			icq_affiliations_currentCode3 = ?,
+			icq_affiliations_currentKeyword3 = ?,
+			icq_affiliations_pastCode1 = ?,
+			icq_affiliations_pastKeyword1 = ?,
+			icq_affiliations_pastCode2 = ?,
+			icq_affiliations_pastKeyword2 = ?,
+			icq_affiliations_pastCode3 = ?,
+			icq_affiliations_pastKeyword3 = ?,
+			icq_homepageCategory_enabled = ?,
+			icq_homepageCategory_index = ?,
+			icq_homepageCategory_description = ?
+		WHERE identScreenName = ?
+	`
+	res, err := f.db.ExecContext(ctx, q,
+		info.Basic.CellPhone,
+		info.Basic.CountryCode,
+		info.Basic.EmailAddress,
+		info.Basic.FirstName,
+		info.Basic.GMTOffset,
+		info.Basic.Address,
+		info.Basic.City,
+		info.Basic.Fax,
+		info.Basic.Phone,
+		info.Basic.State,
+		info.Basic.LastName,
+		info.Basic.Nickname,
+		info.Basic.PublishEmail,
+		info.Basic.ZIPCode,
+		info.Basic.OriginallyFromCity,
+		info.Basic.OriginallyFromState,
+		info.Basic.OriginallyFromCountryCode,
+		info.More.BirthDay,
+		info.More.BirthMonth,
+		info.More.BirthYear,
+		info.More.Gender,
+		info.More.HomePageAddr,
+		info.More.Lang1,
+		info.More.Lang2,
+		info.More.Lang3,
+		info.Work.Company,
+		info.Work.Department,
+		info.Work.OccupationCode,
+		info.Work.Position,
+		info.Work.Address,
+		info.Work.City,
+		info.Work.CountryCode,
+		info.Work.Fax,
+		info.Work.Phone,
+		info.Work.State,
+		info.Work.WebPage,
+		info.Work.ZIPCode,
+		info.Permissions.AuthRequired,
+		info.Permissions.WebAware,
+		info.Permissions.AllowSpam,
+		info.Notes.Notes,
+		info.Interests.Code1,
+		info.Interests.Keyword1,
+		info.Interests.Code2,
+		info.Interests.Keyword2,
+		info.Interests.Code3,
+		info.Interests.Keyword3,
+		info.Interests.Code4,
+		info.Interests.Keyword4,
+		info.Affiliations.CurrentCode1,
+		info.Affiliations.CurrentKeyword1,
+		info.Affiliations.CurrentCode2,
+		info.Affiliations.CurrentKeyword2,
+		info.Affiliations.CurrentCode3,
+		info.Affiliations.CurrentKeyword3,
+		info.Affiliations.PastCode1,
+		info.Affiliations.PastKeyword1,
+		info.Affiliations.PastCode2,
+		info.Affiliations.PastKeyword2,
+		info.Affiliations.PastCode3,
+		info.Affiliations.PastKeyword3,
+		info.HomepageCategory.Enabled,
+		info.HomepageCategory.Index,
+		info.HomepageCategory.Description,
+		name.String(),
+	)
+	if err != nil {
+		return fmt.Errorf("exec: %w", err)
+	}
+	c, err := res.RowsAffected()
+	if err != nil {
+		return fmt.Errorf("rows affected: %w", err)
+	}
+	if c == 0 {
+		return ErrNoUser
+	}
+	return nil
+}
+
 func (f SQLiteUserStore) SaveMessage(ctx context.Context, offlineMessage OfflineMessage) (newCount int, err error) {
 	buf := &bytes.Buffer{}
 	if err := wire.MarshalBE(offlineMessage.Message, buf); err != nil {

+ 116 - 112
state/user_store_test.go

@@ -526,7 +526,9 @@ func TestGetUser(t *testing.T) {
 		StrongMD5Pass:     []byte("thepasshash"),
 		RegStatus:         3,
 		LastWarnUpdate:    time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC), // Database default value
-		ICQPermissions:    ICQPermissions{AuthRequired: true},
+		ICQInfo: ICQInfo{
+			Permissions: ICQPermissions{AuthRequired: true},
+		},
 	}
 	err = f.InsertUser(context.Background(), *insertedUser)
 	assert.NoError(t, err)
@@ -1086,18 +1088,18 @@ func TestSQLiteUserStore_SetWorkInfo(t *testing.T) {
 		// Retrieve the user and verify the work info was set correctly
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
-		assert.Equal(t, workInfo.Company, updatedUser.ICQWorkInfo.Company)
-		assert.Equal(t, workInfo.Department, updatedUser.ICQWorkInfo.Department)
-		assert.Equal(t, workInfo.OccupationCode, updatedUser.ICQWorkInfo.OccupationCode)
-		assert.Equal(t, workInfo.Position, updatedUser.ICQWorkInfo.Position)
-		assert.Equal(t, workInfo.Address, updatedUser.ICQWorkInfo.Address)
-		assert.Equal(t, workInfo.City, updatedUser.ICQWorkInfo.City)
-		assert.Equal(t, workInfo.CountryCode, updatedUser.ICQWorkInfo.CountryCode)
-		assert.Equal(t, workInfo.Fax, updatedUser.ICQWorkInfo.Fax)
-		assert.Equal(t, workInfo.Phone, updatedUser.ICQWorkInfo.Phone)
-		assert.Equal(t, workInfo.State, updatedUser.ICQWorkInfo.State)
-		assert.Equal(t, workInfo.WebPage, updatedUser.ICQWorkInfo.WebPage)
-		assert.Equal(t, workInfo.ZIPCode, updatedUser.ICQWorkInfo.ZIPCode)
+		assert.Equal(t, workInfo.Company, updatedUser.ICQInfo.Work.Company)
+		assert.Equal(t, workInfo.Department, updatedUser.ICQInfo.Work.Department)
+		assert.Equal(t, workInfo.OccupationCode, updatedUser.ICQInfo.Work.OccupationCode)
+		assert.Equal(t, workInfo.Position, updatedUser.ICQInfo.Work.Position)
+		assert.Equal(t, workInfo.Address, updatedUser.ICQInfo.Work.Address)
+		assert.Equal(t, workInfo.City, updatedUser.ICQInfo.Work.City)
+		assert.Equal(t, workInfo.CountryCode, updatedUser.ICQInfo.Work.CountryCode)
+		assert.Equal(t, workInfo.Fax, updatedUser.ICQInfo.Work.Fax)
+		assert.Equal(t, workInfo.Phone, updatedUser.ICQInfo.Work.Phone)
+		assert.Equal(t, workInfo.State, updatedUser.ICQInfo.Work.State)
+		assert.Equal(t, workInfo.WebPage, updatedUser.ICQInfo.Work.WebPage)
+		assert.Equal(t, workInfo.ZIPCode, updatedUser.ICQInfo.Work.ZIPCode)
 	})
 
 	t.Run("Update Non-Existing User", func(t *testing.T) {
@@ -1118,18 +1120,18 @@ func TestSQLiteUserStore_SetWorkInfo(t *testing.T) {
 		// Retrieve the user and verify that fields are empty or have default values
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
-		assert.Empty(t, updatedUser.ICQWorkInfo.Company)
-		assert.Empty(t, updatedUser.ICQWorkInfo.Department)
-		assert.Empty(t, updatedUser.ICQWorkInfo.OccupationCode)
-		assert.Empty(t, updatedUser.ICQWorkInfo.Position)
-		assert.Empty(t, updatedUser.ICQWorkInfo.Address)
-		assert.Empty(t, updatedUser.ICQWorkInfo.City)
-		assert.Empty(t, updatedUser.ICQWorkInfo.CountryCode)
-		assert.Empty(t, updatedUser.ICQWorkInfo.Fax)
-		assert.Empty(t, updatedUser.ICQWorkInfo.Phone)
-		assert.Empty(t, updatedUser.ICQWorkInfo.State)
-		assert.Empty(t, updatedUser.ICQWorkInfo.WebPage)
-		assert.Empty(t, updatedUser.ICQWorkInfo.ZIPCode)
+		assert.Empty(t, updatedUser.ICQInfo.Work.Company)
+		assert.Empty(t, updatedUser.ICQInfo.Work.Department)
+		assert.Empty(t, updatedUser.ICQInfo.Work.OccupationCode)
+		assert.Empty(t, updatedUser.ICQInfo.Work.Position)
+		assert.Empty(t, updatedUser.ICQInfo.Work.Address)
+		assert.Empty(t, updatedUser.ICQInfo.Work.City)
+		assert.Empty(t, updatedUser.ICQInfo.Work.CountryCode)
+		assert.Empty(t, updatedUser.ICQInfo.Work.Fax)
+		assert.Empty(t, updatedUser.ICQInfo.Work.Phone)
+		assert.Empty(t, updatedUser.ICQInfo.Work.State)
+		assert.Empty(t, updatedUser.ICQInfo.Work.WebPage)
+		assert.Empty(t, updatedUser.ICQInfo.Work.ZIPCode)
 	})
 }
 
@@ -1171,14 +1173,14 @@ func TestSQLiteUserStore_SetMoreInfo(t *testing.T) {
 		// Retrieve the user and verify the more info was set correctly
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
-		assert.Equal(t, moreInfo.BirthDay, updatedUser.ICQMoreInfo.BirthDay)
-		assert.Equal(t, moreInfo.BirthMonth, updatedUser.ICQMoreInfo.BirthMonth)
-		assert.Equal(t, moreInfo.BirthYear, updatedUser.ICQMoreInfo.BirthYear)
-		assert.Equal(t, moreInfo.Gender, updatedUser.ICQMoreInfo.Gender)
-		assert.Equal(t, moreInfo.HomePageAddr, updatedUser.ICQMoreInfo.HomePageAddr)
-		assert.Equal(t, moreInfo.Lang1, updatedUser.ICQMoreInfo.Lang1)
-		assert.Equal(t, moreInfo.Lang2, updatedUser.ICQMoreInfo.Lang2)
-		assert.Equal(t, moreInfo.Lang3, updatedUser.ICQMoreInfo.Lang3)
+		assert.Equal(t, moreInfo.BirthDay, updatedUser.ICQInfo.More.BirthDay)
+		assert.Equal(t, moreInfo.BirthMonth, updatedUser.ICQInfo.More.BirthMonth)
+		assert.Equal(t, moreInfo.BirthYear, updatedUser.ICQInfo.More.BirthYear)
+		assert.Equal(t, moreInfo.Gender, updatedUser.ICQInfo.More.Gender)
+		assert.Equal(t, moreInfo.HomePageAddr, updatedUser.ICQInfo.More.HomePageAddr)
+		assert.Equal(t, moreInfo.Lang1, updatedUser.ICQInfo.More.Lang1)
+		assert.Equal(t, moreInfo.Lang2, updatedUser.ICQInfo.More.Lang2)
+		assert.Equal(t, moreInfo.Lang3, updatedUser.ICQInfo.More.Lang3)
 	})
 
 	t.Run("Update Non-Existing User", func(t *testing.T) {
@@ -1199,14 +1201,14 @@ func TestSQLiteUserStore_SetMoreInfo(t *testing.T) {
 		// Retrieve the user and verify that fields are empty or have default values
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
-		assert.Empty(t, updatedUser.ICQMoreInfo.BirthDay)
-		assert.Empty(t, updatedUser.ICQMoreInfo.BirthMonth)
-		assert.Empty(t, updatedUser.ICQMoreInfo.BirthYear)
-		assert.Empty(t, updatedUser.ICQMoreInfo.Gender)
-		assert.Empty(t, updatedUser.ICQMoreInfo.HomePageAddr)
-		assert.Empty(t, updatedUser.ICQMoreInfo.Lang1)
-		assert.Empty(t, updatedUser.ICQMoreInfo.Lang2)
-		assert.Empty(t, updatedUser.ICQMoreInfo.Lang3)
+		assert.Empty(t, updatedUser.ICQInfo.More.BirthDay)
+		assert.Empty(t, updatedUser.ICQInfo.More.BirthMonth)
+		assert.Empty(t, updatedUser.ICQInfo.More.BirthYear)
+		assert.Empty(t, updatedUser.ICQInfo.More.Gender)
+		assert.Empty(t, updatedUser.ICQInfo.More.HomePageAddr)
+		assert.Empty(t, updatedUser.ICQInfo.More.Lang1)
+		assert.Empty(t, updatedUser.ICQInfo.More.Lang2)
+		assert.Empty(t, updatedUser.ICQInfo.More.Lang3)
 	})
 }
 
@@ -1241,7 +1243,7 @@ func TestSQLiteUserStore_SetUserNotes(t *testing.T) {
 		// Retrieve the user and verify the notes were set correctly
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
-		assert.Equal(t, userNotes.Notes, updatedUser.ICQNotes.Notes)
+		assert.Equal(t, userNotes.Notes, updatedUser.ICQInfo.Notes.Notes)
 	})
 
 	t.Run("Update Non-Existing User", func(t *testing.T) {
@@ -1262,7 +1264,7 @@ func TestSQLiteUserStore_SetUserNotes(t *testing.T) {
 		// Retrieve the user and verify that notes are empty
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
-		assert.Empty(t, updatedUser.ICQNotes.Notes)
+		assert.Empty(t, updatedUser.ICQInfo.Notes.Notes)
 	})
 }
 
@@ -1304,14 +1306,14 @@ func TestSQLiteUserStore_SetInterests(t *testing.T) {
 		// Retrieve the user and verify the interests were set correctly
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
-		assert.Equal(t, interests.Code1, updatedUser.ICQInterests.Code1)
-		assert.Equal(t, interests.Keyword1, updatedUser.ICQInterests.Keyword1)
-		assert.Equal(t, interests.Code2, updatedUser.ICQInterests.Code2)
-		assert.Equal(t, interests.Keyword2, updatedUser.ICQInterests.Keyword2)
-		assert.Equal(t, interests.Code3, updatedUser.ICQInterests.Code3)
-		assert.Equal(t, interests.Keyword3, updatedUser.ICQInterests.Keyword3)
-		assert.Equal(t, interests.Code4, updatedUser.ICQInterests.Code4)
-		assert.Equal(t, interests.Keyword4, updatedUser.ICQInterests.Keyword4)
+		assert.Equal(t, interests.Code1, updatedUser.ICQInfo.Interests.Code1)
+		assert.Equal(t, interests.Keyword1, updatedUser.ICQInfo.Interests.Keyword1)
+		assert.Equal(t, interests.Code2, updatedUser.ICQInfo.Interests.Code2)
+		assert.Equal(t, interests.Keyword2, updatedUser.ICQInfo.Interests.Keyword2)
+		assert.Equal(t, interests.Code3, updatedUser.ICQInfo.Interests.Code3)
+		assert.Equal(t, interests.Keyword3, updatedUser.ICQInfo.Interests.Keyword3)
+		assert.Equal(t, interests.Code4, updatedUser.ICQInfo.Interests.Code4)
+		assert.Equal(t, interests.Keyword4, updatedUser.ICQInfo.Interests.Keyword4)
 	})
 
 	t.Run("Update Non-Existing User", func(t *testing.T) {
@@ -1332,14 +1334,14 @@ func TestSQLiteUserStore_SetInterests(t *testing.T) {
 		// Retrieve the user and verify that interests fields are empty or have default values
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
-		assert.Empty(t, updatedUser.ICQInterests.Code1)
-		assert.Empty(t, updatedUser.ICQInterests.Keyword1)
-		assert.Empty(t, updatedUser.ICQInterests.Code2)
-		assert.Empty(t, updatedUser.ICQInterests.Keyword2)
-		assert.Empty(t, updatedUser.ICQInterests.Code3)
-		assert.Empty(t, updatedUser.ICQInterests.Keyword3)
-		assert.Empty(t, updatedUser.ICQInterests.Code4)
-		assert.Empty(t, updatedUser.ICQInterests.Keyword4)
+		assert.Empty(t, updatedUser.ICQInfo.Interests.Code1)
+		assert.Empty(t, updatedUser.ICQInfo.Interests.Keyword1)
+		assert.Empty(t, updatedUser.ICQInfo.Interests.Code2)
+		assert.Empty(t, updatedUser.ICQInfo.Interests.Keyword2)
+		assert.Empty(t, updatedUser.ICQInfo.Interests.Code3)
+		assert.Empty(t, updatedUser.ICQInfo.Interests.Keyword3)
+		assert.Empty(t, updatedUser.ICQInfo.Interests.Code4)
+		assert.Empty(t, updatedUser.ICQInfo.Interests.Keyword4)
 	})
 }
 
@@ -1385,18 +1387,18 @@ func TestSQLiteUserStore_SetAffiliations(t *testing.T) {
 		// Retrieve the user and verify the affiliations were set correctly
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
-		assert.Equal(t, affiliations.CurrentCode1, updatedUser.ICQAffiliations.CurrentCode1)
-		assert.Equal(t, affiliations.CurrentKeyword1, updatedUser.ICQAffiliations.CurrentKeyword1)
-		assert.Equal(t, affiliations.CurrentCode2, updatedUser.ICQAffiliations.CurrentCode2)
-		assert.Equal(t, affiliations.CurrentKeyword2, updatedUser.ICQAffiliations.CurrentKeyword2)
-		assert.Equal(t, affiliations.CurrentCode3, updatedUser.ICQAffiliations.CurrentCode3)
-		assert.Equal(t, affiliations.CurrentKeyword3, updatedUser.ICQAffiliations.CurrentKeyword3)
-		assert.Equal(t, affiliations.PastCode1, updatedUser.ICQAffiliations.PastCode1)
-		assert.Equal(t, affiliations.PastKeyword1, updatedUser.ICQAffiliations.PastKeyword1)
-		assert.Equal(t, affiliations.PastCode2, updatedUser.ICQAffiliations.PastCode2)
-		assert.Equal(t, affiliations.PastKeyword2, updatedUser.ICQAffiliations.PastKeyword2)
-		assert.Equal(t, affiliations.PastCode3, updatedUser.ICQAffiliations.PastCode3)
-		assert.Equal(t, affiliations.PastKeyword3, updatedUser.ICQAffiliations.PastKeyword3)
+		assert.Equal(t, affiliations.CurrentCode1, updatedUser.ICQInfo.Affiliations.CurrentCode1)
+		assert.Equal(t, affiliations.CurrentKeyword1, updatedUser.ICQInfo.Affiliations.CurrentKeyword1)
+		assert.Equal(t, affiliations.CurrentCode2, updatedUser.ICQInfo.Affiliations.CurrentCode2)
+		assert.Equal(t, affiliations.CurrentKeyword2, updatedUser.ICQInfo.Affiliations.CurrentKeyword2)
+		assert.Equal(t, affiliations.CurrentCode3, updatedUser.ICQInfo.Affiliations.CurrentCode3)
+		assert.Equal(t, affiliations.CurrentKeyword3, updatedUser.ICQInfo.Affiliations.CurrentKeyword3)
+		assert.Equal(t, affiliations.PastCode1, updatedUser.ICQInfo.Affiliations.PastCode1)
+		assert.Equal(t, affiliations.PastKeyword1, updatedUser.ICQInfo.Affiliations.PastKeyword1)
+		assert.Equal(t, affiliations.PastCode2, updatedUser.ICQInfo.Affiliations.PastCode2)
+		assert.Equal(t, affiliations.PastKeyword2, updatedUser.ICQInfo.Affiliations.PastKeyword2)
+		assert.Equal(t, affiliations.PastCode3, updatedUser.ICQInfo.Affiliations.PastCode3)
+		assert.Equal(t, affiliations.PastKeyword3, updatedUser.ICQInfo.Affiliations.PastKeyword3)
 	})
 
 	t.Run("Update Non-Existing User", func(t *testing.T) {
@@ -1417,18 +1419,18 @@ func TestSQLiteUserStore_SetAffiliations(t *testing.T) {
 		// Retrieve the user and verify that affiliations fields are empty or have default values
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
-		assert.Empty(t, updatedUser.ICQAffiliations.CurrentCode1)
-		assert.Empty(t, updatedUser.ICQAffiliations.CurrentKeyword1)
-		assert.Empty(t, updatedUser.ICQAffiliations.CurrentCode2)
-		assert.Empty(t, updatedUser.ICQAffiliations.CurrentKeyword2)
-		assert.Empty(t, updatedUser.ICQAffiliations.CurrentCode3)
-		assert.Empty(t, updatedUser.ICQAffiliations.CurrentKeyword3)
-		assert.Empty(t, updatedUser.ICQAffiliations.PastCode1)
-		assert.Empty(t, updatedUser.ICQAffiliations.PastKeyword1)
-		assert.Empty(t, updatedUser.ICQAffiliations.PastCode2)
-		assert.Empty(t, updatedUser.ICQAffiliations.PastKeyword2)
-		assert.Empty(t, updatedUser.ICQAffiliations.PastCode3)
-		assert.Empty(t, updatedUser.ICQAffiliations.PastKeyword3)
+		assert.Empty(t, updatedUser.ICQInfo.Affiliations.CurrentCode1)
+		assert.Empty(t, updatedUser.ICQInfo.Affiliations.CurrentKeyword1)
+		assert.Empty(t, updatedUser.ICQInfo.Affiliations.CurrentCode2)
+		assert.Empty(t, updatedUser.ICQInfo.Affiliations.CurrentKeyword2)
+		assert.Empty(t, updatedUser.ICQInfo.Affiliations.CurrentCode3)
+		assert.Empty(t, updatedUser.ICQInfo.Affiliations.CurrentKeyword3)
+		assert.Empty(t, updatedUser.ICQInfo.Affiliations.PastCode1)
+		assert.Empty(t, updatedUser.ICQInfo.Affiliations.PastKeyword1)
+		assert.Empty(t, updatedUser.ICQInfo.Affiliations.PastCode2)
+		assert.Empty(t, updatedUser.ICQInfo.Affiliations.PastKeyword2)
+		assert.Empty(t, updatedUser.ICQInfo.Affiliations.PastCode3)
+		assert.Empty(t, updatedUser.ICQInfo.Affiliations.PastKeyword3)
 	})
 }
 
@@ -1476,20 +1478,20 @@ func TestSQLiteUserStore_SetBasicInfo(t *testing.T) {
 		// Retrieve the user and verify the basic info was set correctly
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
-		assert.Equal(t, basicInfo.CellPhone, updatedUser.ICQBasicInfo.CellPhone)
-		assert.Equal(t, basicInfo.CountryCode, updatedUser.ICQBasicInfo.CountryCode)
-		assert.Equal(t, basicInfo.EmailAddress, updatedUser.ICQBasicInfo.EmailAddress)
-		assert.Equal(t, basicInfo.FirstName, updatedUser.ICQBasicInfo.FirstName)
-		assert.Equal(t, basicInfo.GMTOffset, updatedUser.ICQBasicInfo.GMTOffset)
-		assert.Equal(t, basicInfo.Address, updatedUser.ICQBasicInfo.Address)
-		assert.Equal(t, basicInfo.City, updatedUser.ICQBasicInfo.City)
-		assert.Equal(t, basicInfo.Fax, updatedUser.ICQBasicInfo.Fax)
-		assert.Equal(t, basicInfo.Phone, updatedUser.ICQBasicInfo.Phone)
-		assert.Equal(t, basicInfo.State, updatedUser.ICQBasicInfo.State)
-		assert.Equal(t, basicInfo.LastName, updatedUser.ICQBasicInfo.LastName)
-		assert.Equal(t, basicInfo.Nickname, updatedUser.ICQBasicInfo.Nickname)
-		assert.Equal(t, basicInfo.PublishEmail, updatedUser.ICQBasicInfo.PublishEmail)
-		assert.Equal(t, basicInfo.ZIPCode, updatedUser.ICQBasicInfo.ZIPCode)
+		assert.Equal(t, basicInfo.CellPhone, updatedUser.ICQInfo.Basic.CellPhone)
+		assert.Equal(t, basicInfo.CountryCode, updatedUser.ICQInfo.Basic.CountryCode)
+		assert.Equal(t, basicInfo.EmailAddress, updatedUser.ICQInfo.Basic.EmailAddress)
+		assert.Equal(t, basicInfo.FirstName, updatedUser.ICQInfo.Basic.FirstName)
+		assert.Equal(t, basicInfo.GMTOffset, updatedUser.ICQInfo.Basic.GMTOffset)
+		assert.Equal(t, basicInfo.Address, updatedUser.ICQInfo.Basic.Address)
+		assert.Equal(t, basicInfo.City, updatedUser.ICQInfo.Basic.City)
+		assert.Equal(t, basicInfo.Fax, updatedUser.ICQInfo.Basic.Fax)
+		assert.Equal(t, basicInfo.Phone, updatedUser.ICQInfo.Basic.Phone)
+		assert.Equal(t, basicInfo.State, updatedUser.ICQInfo.Basic.State)
+		assert.Equal(t, basicInfo.LastName, updatedUser.ICQInfo.Basic.LastName)
+		assert.Equal(t, basicInfo.Nickname, updatedUser.ICQInfo.Basic.Nickname)
+		assert.Equal(t, basicInfo.PublishEmail, updatedUser.ICQInfo.Basic.PublishEmail)
+		assert.Equal(t, basicInfo.ZIPCode, updatedUser.ICQInfo.Basic.ZIPCode)
 	})
 
 	t.Run("Update Non-Existing User", func(t *testing.T) {
@@ -1510,20 +1512,20 @@ func TestSQLiteUserStore_SetBasicInfo(t *testing.T) {
 		// Retrieve the user and verify that basic info fields are empty or have default values
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
-		assert.Empty(t, updatedUser.ICQBasicInfo.CellPhone)
-		assert.Empty(t, updatedUser.ICQBasicInfo.CountryCode)
-		assert.Empty(t, updatedUser.ICQBasicInfo.EmailAddress)
-		assert.Empty(t, updatedUser.ICQBasicInfo.FirstName)
-		assert.Empty(t, updatedUser.ICQBasicInfo.GMTOffset)
-		assert.Empty(t, updatedUser.ICQBasicInfo.Address)
-		assert.Empty(t, updatedUser.ICQBasicInfo.City)
-		assert.Empty(t, updatedUser.ICQBasicInfo.Fax)
-		assert.Empty(t, updatedUser.ICQBasicInfo.Phone)
-		assert.Empty(t, updatedUser.ICQBasicInfo.State)
-		assert.Empty(t, updatedUser.ICQBasicInfo.LastName)
-		assert.Empty(t, updatedUser.ICQBasicInfo.Nickname)
-		assert.Empty(t, updatedUser.ICQBasicInfo.PublishEmail)
-		assert.Empty(t, updatedUser.ICQBasicInfo.ZIPCode)
+		assert.Empty(t, updatedUser.ICQInfo.Basic.CellPhone)
+		assert.Empty(t, updatedUser.ICQInfo.Basic.CountryCode)
+		assert.Empty(t, updatedUser.ICQInfo.Basic.EmailAddress)
+		assert.Empty(t, updatedUser.ICQInfo.Basic.FirstName)
+		assert.Empty(t, updatedUser.ICQInfo.Basic.GMTOffset)
+		assert.Empty(t, updatedUser.ICQInfo.Basic.Address)
+		assert.Empty(t, updatedUser.ICQInfo.Basic.City)
+		assert.Empty(t, updatedUser.ICQInfo.Basic.Fax)
+		assert.Empty(t, updatedUser.ICQInfo.Basic.Phone)
+		assert.Empty(t, updatedUser.ICQInfo.Basic.State)
+		assert.Empty(t, updatedUser.ICQInfo.Basic.LastName)
+		assert.Empty(t, updatedUser.ICQInfo.Basic.Nickname)
+		assert.Empty(t, updatedUser.ICQInfo.Basic.PublishEmail)
+		assert.Empty(t, updatedUser.ICQInfo.Basic.ZIPCode)
 	})
 }
 
@@ -3807,7 +3809,9 @@ func TestSQLiteUserStore_ContactPreAuth(t *testing.T) {
 		IdentScreenName:   owner,
 		DisplayScreenName: DisplayScreenName("100001"),
 		IsICQ:             true,
-		ICQPermissions:    ICQPermissions{AuthRequired: true},
+		ICQInfo: ICQInfo{
+			Permissions: ICQPermissions{AuthRequired: true},
+		},
 	}))
 	require.NoError(t, f.InsertUser(ctx, User{
 		IdentScreenName:   requester,

+ 40 - 26
state/user_test.go

@@ -102,8 +102,10 @@ func TestAge(t *testing.T) {
 		{
 			name: "Valid birthday, only year is set",
 			user: User{
-				ICQMoreInfo: ICQMoreInfo{
-					BirthYear: 1990,
+				ICQInfo: ICQInfo{
+					More: ICQMoreInfo{
+						BirthYear: 1990,
+					},
 				},
 			},
 			timeNow: func() time.Time {
@@ -114,10 +116,12 @@ func TestAge(t *testing.T) {
 		{
 			name: "Valid birthday, birthday passed this year",
 			user: User{
-				ICQMoreInfo: ICQMoreInfo{
-					BirthYear:  1990,
-					BirthMonth: 5,
-					BirthDay:   10,
+				ICQInfo: ICQInfo{
+					More: ICQMoreInfo{
+						BirthYear:  1990,
+						BirthMonth: 5,
+						BirthDay:   10,
+					},
 				},
 			},
 			timeNow: func() time.Time {
@@ -128,10 +132,12 @@ func TestAge(t *testing.T) {
 		{
 			name: "Valid birthday, birthday not yet passed this year",
 			user: User{
-				ICQMoreInfo: ICQMoreInfo{
-					BirthYear:  1990,
-					BirthMonth: 12,
-					BirthDay:   10,
+				ICQInfo: ICQInfo{
+					More: ICQMoreInfo{
+						BirthYear:  1990,
+						BirthMonth: 12,
+						BirthDay:   10,
+					},
 				},
 			},
 			timeNow: func() time.Time {
@@ -142,10 +148,12 @@ func TestAge(t *testing.T) {
 		{
 			name: "Birthday is today",
 			user: User{
-				ICQMoreInfo: ICQMoreInfo{
-					BirthYear:  1990,
-					BirthMonth: 8,
-					BirthDay:   1,
+				ICQInfo: ICQInfo{
+					More: ICQMoreInfo{
+						BirthYear:  1990,
+						BirthMonth: 8,
+						BirthDay:   1,
+					},
 				},
 			},
 			timeNow: func() time.Time {
@@ -156,10 +164,12 @@ func TestAge(t *testing.T) {
 		{
 			name: "Invalid birthday, year is zero",
 			user: User{
-				ICQMoreInfo: ICQMoreInfo{
-					BirthYear:  0,
-					BirthMonth: 8,
-					BirthDay:   1,
+				ICQInfo: ICQInfo{
+					More: ICQMoreInfo{
+						BirthYear:  0,
+						BirthMonth: 8,
+						BirthDay:   1,
+					},
 				},
 			},
 			timeNow: func() time.Time {
@@ -170,10 +180,12 @@ func TestAge(t *testing.T) {
 		{
 			name: "Invalid birthday, day is zero",
 			user: User{
-				ICQMoreInfo: ICQMoreInfo{
-					BirthYear:  1990,
-					BirthMonth: 8,
-					BirthDay:   0,
+				ICQInfo: ICQInfo{
+					More: ICQMoreInfo{
+						BirthYear:  1990,
+						BirthMonth: 8,
+						BirthDay:   0,
+					},
 				},
 			},
 			timeNow: func() time.Time {
@@ -184,10 +196,12 @@ func TestAge(t *testing.T) {
 		{
 			name: "Invalid birthday, month is zero",
 			user: User{
-				ICQMoreInfo: ICQMoreInfo{
-					BirthYear:  1990,
-					BirthMonth: 0,
-					BirthDay:   1,
+				ICQInfo: ICQInfo{
+					More: ICQMoreInfo{
+						BirthYear:  1990,
+						BirthMonth: 0,
+						BirthDay:   1,
+					},
 				},
 			},
 			timeNow: func() time.Time {

+ 83 - 16
wire/decode.go

@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"io"
 	"reflect"
+	"strings"
 )
 
 var (
@@ -16,7 +17,7 @@ var (
 
 // UnmarshalBE unmarshalls OSCAR protocol messages in big-endian format.
 func UnmarshalBE(v any, r io.Reader) error {
-	if err := unmarshal(reflect.TypeOf(v).Elem(), reflect.ValueOf(v).Elem(), "", r, binary.BigEndian); err != nil {
+	if err := unmarshal(reflect.TypeOf(v).Elem(), reflect.ValueOf(v).Elem(), "", r, binary.BigEndian, ""); err != nil {
 		return fmt.Errorf("%w: %w", ErrUnmarshalFailure, err)
 	}
 	return nil
@@ -24,22 +25,25 @@ func UnmarshalBE(v any, r io.Reader) error {
 
 // UnmarshalLE unmarshalls OSCAR protocol messages in little-endian format.
 func UnmarshalLE(v any, r io.Reader) error {
-	if err := unmarshal(reflect.TypeOf(v).Elem(), reflect.ValueOf(v).Elem(), "", r, binary.LittleEndian); err != nil {
+	if err := unmarshal(reflect.TypeOf(v).Elem(), reflect.ValueOf(v).Elem(), "", r, binary.LittleEndian, ""); err != nil {
 		return fmt.Errorf("%w: %w", ErrUnmarshalFailure, err)
 	}
 	return nil
 }
 
-// MarshalLE marshals ICQ protocol messages in little-endian format.
-func unmarshal(t reflect.Type, v reflect.Value, tag reflect.StructTag, r io.Reader, order binary.ByteOrder) error {
+func unmarshal(t reflect.Type, v reflect.Value, tag reflect.StructTag, r io.Reader, order binary.ByteOrder, activeQuirk string) error {
 	oscTag, err := parseOSCARTag(tag)
 	if err != nil {
 		return fmt.Errorf("error parsing tag: %w", err)
 	}
+	effectiveQuirk := strings.TrimSpace(oscTag.quirk)
+	if effectiveQuirk == "" {
+		effectiveQuirk = activeQuirk
+	}
 
 	if oscTag.optional {
 		v.Set(reflect.New(t.Elem()))
-		err := unmarshalStruct(t.Elem(), v.Elem(), oscTag, r, order)
+		err := unmarshalStruct(t.Elem(), v.Elem(), oscTag, r, order, effectiveQuirk)
 		if errors.Is(err, io.EOF) {
 			// no values to read, but that's ok since this struct is optional
 			v.Set(reflect.Zero(t))
@@ -52,13 +56,13 @@ func unmarshal(t reflect.Type, v reflect.Value, tag reflect.StructTag, r io.Read
 
 	switch v.Kind() {
 	case reflect.Array:
-		return unmarshalArray(v, r, order)
+		return unmarshalArray(v, r, order, effectiveQuirk)
 	case reflect.Slice:
-		return unmarshalSlice(v, oscTag, r, order)
+		return unmarshalSlice(v, oscTag, r, order, effectiveQuirk)
 	case reflect.String:
 		return unmarshalString(v, oscTag, r, order)
 	case reflect.Struct:
-		return unmarshalStruct(t, v, oscTag, r, order)
+		return unmarshalStruct(t, v, oscTag, r, order, effectiveQuirk)
 	case reflect.Uint8:
 		var l uint8
 		if err := binary.Read(r, order, &l); err != nil {
@@ -92,13 +96,13 @@ func unmarshal(t reflect.Type, v reflect.Value, tag reflect.StructTag, r io.Read
 	}
 }
 
-func unmarshalArray(v reflect.Value, r io.Reader, order binary.ByteOrder) error {
+func unmarshalArray(v reflect.Value, r io.Reader, order binary.ByteOrder, activeQuirk string) error {
 	arrLen := v.Len()
 	arrType := v.Type().Elem()
 
 	for i := 0; i < arrLen; i++ {
 		elem := reflect.New(arrType).Elem()
-		if err := unmarshal(arrType, elem, "", r, order); err != nil {
+		if err := unmarshal(arrType, elem, "", r, order, activeQuirk); err != nil {
 			return err
 		}
 		v.Index(i).Set(elem)
@@ -107,7 +111,7 @@ func unmarshalArray(v reflect.Value, r io.Reader, order binary.ByteOrder) error
 	return nil
 }
 
-func unmarshalSlice(v reflect.Value, oscTag oscarTag, r io.Reader, order binary.ByteOrder) error {
+func unmarshalSlice(v reflect.Value, oscTag oscarTag, r io.Reader, order binary.ByteOrder, activeQuirk string) error {
 	slice := reflect.New(v.Type()).Elem()
 	elemType := v.Type().Elem()
 
@@ -125,7 +129,7 @@ func unmarshalSlice(v reflect.Value, oscTag oscarTag, r io.Reader, order binary.
 		buf := bytes.NewBuffer(b)
 		for buf.Len() > 0 {
 			elem := reflect.New(elemType).Elem()
-			if err := unmarshal(elemType, elem, "", buf, order); err != nil {
+			if err := unmarshalSliceElement(elemType, elem, buf, order, activeQuirk); err != nil {
 				return err
 			}
 			slice = reflect.Append(slice, elem)
@@ -138,7 +142,7 @@ func unmarshalSlice(v reflect.Value, oscTag oscarTag, r io.Reader, order binary.
 
 		for i := 0; i < count; i++ {
 			elem := reflect.New(elemType).Elem()
-			if err := unmarshal(elemType, elem, "", r, order); err != nil {
+			if err := unmarshalSliceElement(elemType, elem, r, order, activeQuirk); err != nil {
 				return err
 			}
 			slice = reflect.Append(slice, elem)
@@ -146,7 +150,7 @@ func unmarshalSlice(v reflect.Value, oscTag oscarTag, r io.Reader, order binary.
 	} else {
 		for {
 			elem := reflect.New(elemType).Elem()
-			if err := unmarshal(elemType, elem, "", r, order); err != nil {
+			if err := unmarshalSliceElement(elemType, elem, r, order, activeQuirk); err != nil {
 				if errors.Is(err, io.EOF) {
 					break
 				}
@@ -159,6 +163,69 @@ func unmarshalSlice(v reflect.Value, oscTag oscarTag, r io.Reader, order binary.
 	return nil
 }
 
+// unmarshalSliceElement reads one element of a slice; for TLV under LE with decode quirks,
+// applies client-specific length workarounds (see unmarshalTLV* helpers).
+func unmarshalSliceElement(elemType reflect.Type, elem reflect.Value, r io.Reader, order binary.ByteOrder, activeQuirk string) error {
+	if activeQuirk != "" {
+		switch {
+		case activeQuirk == "icq2003b_set_fullinfo" && order == binary.LittleEndian && elemType == reflect.TypeOf(TLV{}):
+			return unmarshalTLVICQ2003bSetFullInfo(elem, r, order)
+		case activeQuirk == "qip_2005_search_by_uin2" && order == binary.LittleEndian && elemType == reflect.TypeOf(TLV{}):
+			return unmarshalTLVQIP2005SearchByUIN2(elem, r, order)
+		}
+	}
+	return unmarshal(elemType, elem, "", r, order, activeQuirk)
+}
+
+// unmarshalTLVICQ2003bSetFullInfo decodes one TLV with ICQ 2003b save-info workaround for ICQTLVTagsEmail.
+func unmarshalTLVICQ2003bSetFullInfo(elem reflect.Value, r io.Reader, order binary.ByteOrder) error {
+	var tag uint16
+	if err := binary.Read(r, order, &tag); err != nil {
+		return err
+	}
+	var n uint16
+	if err := binary.Read(r, order, &n); err != nil {
+		return err
+	}
+	if tag == ICQTLVTagsEmail && n == 3 {
+		n = 4
+	}
+	buf := make([]byte, n)
+	if n > 0 {
+		if _, err := io.ReadFull(r, buf); err != nil {
+			return err
+		}
+	}
+	elem.Field(0).Set(reflect.ValueOf(tag))
+	elem.Field(1).SetBytes(buf)
+	return nil
+}
+
+// unmarshalTLVQIP2005SearchByUIN2 decodes one TLV for META SearchByUIN2 (0x0569): QIP 2005 sends
+// ICQTLVTagsUIN (0x0136) with an incorrect length (e.g. 6) for a 4-byte UIN, causing EOF on a strict read.
+func unmarshalTLVQIP2005SearchByUIN2(elem reflect.Value, r io.Reader, order binary.ByteOrder) error {
+	var tag uint16
+	if err := binary.Read(r, order, &tag); err != nil {
+		return err
+	}
+	var n uint16
+	if err := binary.Read(r, order, &n); err != nil {
+		return err
+	}
+	if tag == ICQTLVTagsUIN && n != 4 {
+		n = 4
+	}
+	buf := make([]byte, n)
+	if n > 0 {
+		if _, err := io.ReadFull(r, buf); err != nil {
+			return err
+		}
+	}
+	elem.Field(0).Set(reflect.ValueOf(tag))
+	elem.Field(1).SetBytes(buf)
+	return nil
+}
+
 func unmarshalString(v reflect.Value, oscTag oscarTag, r io.Reader, order binary.ByteOrder) error {
 	if !oscTag.hasLenPrefix {
 		return fmt.Errorf("missing len_prefix tag")
@@ -186,7 +253,7 @@ func unmarshalString(v reflect.Value, oscTag oscarTag, r io.Reader, order binary
 	return nil
 }
 
-func unmarshalStruct(t reflect.Type, v reflect.Value, oscTag oscarTag, r io.Reader, order binary.ByteOrder) error {
+func unmarshalStruct(t reflect.Type, v reflect.Value, oscTag oscarTag, r io.Reader, order binary.ByteOrder, activeQuirk string) error {
 	if oscTag.hasLenPrefix {
 		bufLen, err := unmarshalUnsignedInt(oscTag.lenPrefix, r, order)
 		if err != nil {
@@ -212,7 +279,7 @@ func unmarshalStruct(t reflect.Type, v reflect.Value, oscTag oscarTag, r io.Read
 					errNonOptionalPointer, field.Name, field.Type.Elem().Kind())
 			}
 		}
-		if err := unmarshal(field.Type, value, field.Tag, r, order); err != nil {
+		if err := unmarshal(field.Type, value, field.Tag, r, order, activeQuirk); err != nil {
 			return err
 		}
 	}

+ 33 - 0
wire/decode_test.go

@@ -708,3 +708,36 @@ func TestUnmarshal(t *testing.T) {
 		})
 	}
 }
+
+// TestUnmarshalLE_ICQ2003bSaveInfoEmailTLVLengthQuirk checks ICQ 2003b "save info" / META
+// CLI_SET_FULLINFO: INF_TLV_EMAIL (ICQTLVTagsEmail, 0x015E) declares length 3 but writes 4 bytes
+// on the wire; unmarshal with oscar:"quirk=icq2003b_set_fullinfo" matches iserverd tlv_chain_c::readXXX.
+func TestUnmarshalLE_ICQ2003bSaveInfoEmailTLVLengthQuirk(t *testing.T) {
+	b := []byte{
+		0x5E, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, // ICQTLVTagsEmail: LE len 3, 4 bytes on wire
+		0x58, 0x02, 0x02, 0x00, 0x00, 0x00, // ICQTLVTagsNotesText (0x0258), LE len 2
+	}
+	var got ICQ_0x07D0_0x0C3A_DBQueryMetaReqSetFullInfo
+	err := UnmarshalLE(&got, bytes.NewReader(b))
+	assert.NoError(t, err)
+	assert.Len(t, got.TLVList, 2)
+	assert.Equal(t, ICQTLVTagsEmail, got.TLVList[0].Tag)
+	assert.Equal(t, []byte{0x01, 0x00, 0x00, 0x00}, got.TLVList[0].Value)
+	assert.Equal(t, ICQTLVTagsNotesText, got.TLVList[1].Tag)
+	assert.Equal(t, []byte{0x00, 0x00}, got.TLVList[1].Value)
+}
+
+// TestUnmarshalLE_QIP2005SearchByUIN2TLVLengthQuirk checks QIP 2005 META SearchByUIN2: TLV ICQTLVTagsUIN
+// declares length 6 but only 4 bytes (UIN) follow; unmarshal with oscar:"quirk=qip_2005_search_by_uin2"
+// matches iserverd tlv.cpp rewrite for type 0x0136.
+func TestUnmarshalLE_QIP2005SearchByUIN2TLVLengthQuirk(t *testing.T) {
+	b := []byte{
+		0x36, 0x01, 0x06, 0x00, 0x01, 0x00, 0x00, 0x00, // ICQTLVTagsUIN: LE len 6 (wrong), 4-byte UIN on wire
+	}
+	var got ICQ_0x07D0_0x0569_DBQueryMetaReqSearchByUIN2
+	err := UnmarshalLE(&got, bytes.NewReader(b))
+	assert.NoError(t, err)
+	assert.Len(t, got.TLVList, 1)
+	assert.Equal(t, ICQTLVTagsUIN, got.TLVList[0].Tag)
+	assert.Equal(t, []byte{0x01, 0x00, 0x00, 0x00}, got.TLVList[0].Value)
+}

+ 26 - 7
wire/encode.go

@@ -213,6 +213,8 @@ type oscarTag struct {
 	lenPrefix      reflect.Kind
 	optional       bool
 	nullTerminated bool
+	// quirk names an unmarshal-only decode exception
+	quirk string
 }
 
 func parseOSCARTag(tag reflect.StructTag) (oscarTag, error) {
@@ -224,41 +226,58 @@ func parseOSCARTag(tag reflect.StructTag) (oscarTag, error) {
 	}
 
 	for _, kv := range strings.Split(val, ",") {
+		kv = strings.TrimSpace(kv)
+		if kv == "" {
+			continue
+		}
 		kvSplit := strings.SplitN(kv, "=", 2)
+		key := strings.TrimSpace(kvSplit[0])
 		if len(kvSplit) == 2 {
-			switch kvSplit[0] {
+			valPart := strings.TrimSpace(kvSplit[1])
+			switch key {
 			case "len_prefix":
 				oscTag.hasLenPrefix = true
-				switch kvSplit[1] {
+				switch valPart {
 				case "uint8":
 					oscTag.lenPrefix = reflect.Uint8
 				case "uint16":
 					oscTag.lenPrefix = reflect.Uint16
 				default:
 					return oscTag, fmt.Errorf("%w: unsupported type %s. allowed types: uint8, uint16",
-						errInvalidStructTag, kvSplit[1])
+						errInvalidStructTag, valPart)
 				}
 			case "count_prefix":
 				oscTag.hasCountPrefix = true
-				switch kvSplit[1] {
+				switch valPart {
 				case "uint8":
 					oscTag.countPrefix = reflect.Uint8
 				case "uint16":
 					oscTag.countPrefix = reflect.Uint16
 				default:
 					return oscTag, fmt.Errorf("%w: unsupported type %s. allowed types: uint8, uint16",
-						errInvalidStructTag, kvSplit[1])
+						errInvalidStructTag, valPart)
+				}
+			case "quirk":
+				if valPart == "" {
+					return oscTag, fmt.Errorf("%w: empty quirk value", errInvalidStructTag)
+				}
+				if oscTag.quirk != "" {
+					return oscTag, fmt.Errorf("%w: duplicate quirk key", errInvalidStructTag)
 				}
+				oscTag.quirk = valPart
+			default:
+				return oscTag, fmt.Errorf("%w: unsupported struct tag %s",
+					errInvalidStructTag, key)
 			}
 		} else {
-			switch kvSplit[0] {
+			switch key {
 			case "optional":
 				oscTag.optional = true
 			case "nullterm":
 				oscTag.nullTerminated = true
 			default:
 				return oscTag, fmt.Errorf("%w: unsupported struct tag %s",
-					errInvalidStructTag, kvSplit[0])
+					errInvalidStructTag, key)
 			}
 		}
 	}

+ 16 - 0
wire/encode_test.go

@@ -733,6 +733,22 @@ func TestMarshal(t *testing.T) {
 			},
 			wantErr: io.EOF,
 		},
+		{
+			name: "duplicate quirk keys in oscar tag",
+			w:    &bytes.Buffer{},
+			given: struct {
+				V uint8 `oscar:"quirk=a,quirk=b"`
+			}{V: 1},
+			wantErr: errInvalidStructTag,
+		},
+		{
+			name: "empty quirk value",
+			w:    &bytes.Buffer{},
+			given: struct {
+				V uint8 `oscar:"quirk="`
+			}{V: 1},
+			wantErr: errInvalidStructTag,
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {

+ 16 - 3
wire/snacs.go

@@ -1949,8 +1949,8 @@ const (
 	ICQTLVTagsWorkPhoneNumber           uint16 = 0x02C6 // User work phone number
 	ICQTLVTagsWorkFaxNumber             uint16 = 0x02D0 // User work fax number
 	ICQTLVTagsWorkWebpageURL            uint16 = 0x02DA // User work webpage URL
-	ICQTLVTagsShowWebStatusPermissions  uint16 = 0x02F8 // User 'show web status' permissions
-	ICQTLVTagsAuthorizationPermissions  uint16 = 0x030C // User authorization permissions
+	ICQTLVTagsAuthorizationPermissions  uint16 = 0x02F8 // User authorization permissions
+	ICQTLVTagsShowWebStatusPermissions  uint16 = 0x030C // User 'show web status' permissions
 	ICQTLVTagsGMTOffset                 uint16 = 0x0316 // User GMT offset
 	ICQTLVTagsOriginallyFromCity        uint16 = 0x0320 // User originally from city
 	ICQTLVTagsOriginallyFromState       uint16 = 0x032A // User originally from state
@@ -2013,6 +2013,7 @@ const (
 	ICQDBQueryMetaReqSetAffiliations   uint16 = 0x041A
 	ICQDBQueryMetaReqSetPermissions    uint16 = 0x0424
 	ICQDBQueryMetaReqSetICQPhone       uint16 = 0x0654
+	ICQDBQueryMetaReqSetFullInfo       uint16 = 0x0C3A
 	ICQDBQueryMetaReqShortInfo         uint16 = 0x04BA
 	ICQDBQueryMetaReqFullInfo          uint16 = 0x04B2
 	ICQDBQueryMetaReqFullInfo2         uint16 = 0x04D0
@@ -2047,6 +2048,7 @@ const (
 	ICQDBQueryMetaReplySetAffiliations uint16 = 0x0096
 	ICQDBQueryMetaReplySetPermissions  uint16 = 0x00A0
 	ICQDBQueryMetaReplySetICQPhone     uint16 = 0x031E
+	ICQDBQueryMetaReplySetFullInfo     uint16 = 0x0C3F
 	ICQDBQueryMetaReplyBasicInfo       uint16 = 0x00C8
 	ICQDBQueryMetaReplyWorkInfo        uint16 = 0x00D2
 	ICQDBQueryMetaReplyMoreInfo        uint16 = 0x00DC
@@ -2091,7 +2093,7 @@ type ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN struct {
 }
 
 type ICQ_0x07D0_0x0569_DBQueryMetaReqSearchByUIN2 struct {
-	TLVRestBlock
+	TLVRestBlock `oscar:"quirk=qip_2005_search_by_uin2"`
 }
 
 type ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail struct {
@@ -2148,6 +2150,17 @@ type ICQ_0x07D0_0x055F_DBQueryMetaReqSearchWhitePages2 struct {
 	TLVRestBlock
 }
 
+// ICQ_0x07D0_0x0C3A_DBQueryMetaReqSetFullInfo represents the TLV-based
+// CLI_SET_FULLINFO request used by ICQLite to update profile information in
+// a single packet. The TLV chain may contain any subset of the tags in the
+// ICQTLVTags* group; tags omitted by the client should leave the
+// corresponding profile fields unchanged. Some tags (e.g.
+// ICQTLVTagsSpokenLanguage, ICQTLVTagsAffiliationsNode) may appear multiple
+// times in the chain.
+type ICQ_0x07D0_0x0C3A_DBQueryMetaReqSetFullInfo struct {
+	TLVRestBlock `oscar:"quirk=icq2003b_set_fullinfo"`
+}
+
 type ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo struct {
 	Age          uint8 // not used because age is calculated from birthdate
 	Gender       uint16

+ 2 - 0
wire/snacs_string.go

@@ -354,6 +354,7 @@ var icqDBQueryMeta = map[uint16]string{
 	ICQDBQueryMetaReqSetAffiliations:   "ICQDBQueryMetaReqSetAffiliations",
 	ICQDBQueryMetaReqSetPermissions:    "ICQDBQueryMetaReqSetPermissions",
 	ICQDBQueryMetaReqSetICQPhone:       "ICQDBQueryMetaReqSetICQPhone",
+	ICQDBQueryMetaReqSetFullInfo:       "ICQDBQueryMetaReqSetFullInfo",
 	ICQDBQueryMetaReqFullInfo:          "ICQDBQueryMetaReqFullInfo",
 	ICQDBQueryMetaReqFullInfo2:         "ICQDBQueryMetaReqFullInfo2",
 	ICQDBQueryMetaReqSearchByDetails:   "ICQDBQueryMetaReqSearchByDetails",
@@ -383,6 +384,7 @@ var icqDBQueryMeta = map[uint16]string{
 	ICQDBQueryMetaReplySetAffiliations: "ICQDBQueryMetaReplySetAffiliations",
 	ICQDBQueryMetaReplySetPermissions:  "ICQDBQueryMetaReplySetPermissions",
 	ICQDBQueryMetaReplySetICQPhone:     "ICQDBQueryMetaReplySetICQPhone",
+	ICQDBQueryMetaReplySetFullInfo:     "ICQDBQueryMetaReplySetFullInfo",
 	ICQDBQueryMetaReplyBasicInfo:       "ICQDBQueryMetaReplyBasicInfo",
 	ICQDBQueryMetaReplyWorkInfo:        "ICQDBQueryMetaReplyWorkInfo",
 	ICQDBQueryMetaReplyMoreInfo:        "ICQDBQueryMetaReplyMoreInfo",

Некоторые файлы не были показаны из-за большого количества измененных файлов