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

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())
 		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
 // 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) {
 func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 	authReqSender := state.User{
 	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,
 								UIN:         100001,
 								MessageType: wire.ICBMMsgTypeAuthReq,
 								MessageType: wire.ICBMMsgTypeAuthReq,
 								Message: fmt.Sprintf("%s\xFE%s\xFE%s\xFE%s\xFE%d\xFE%s",
 								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,
 									1,
 									utf8ToLatin1("please add me"),
 									utf8ToLatin1("please add me"),
 								),
 								),
@@ -2778,10 +2780,10 @@ func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 								UIN:         100001,
 								UIN:         100001,
 								MessageType: wire.ICBMMsgTypeAuthReq,
 								MessageType: wire.ICBMMsgTypeAuthReq,
 								Message: fmt.Sprintf("%s\xFE%s\xFE%s\xFE%s\xFE%d\xFE%s",
 								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,
 									1,
 									utf8ToLatin1("please add me"),
 									utf8ToLatin1("please add me"),
 								),
 								),
@@ -2839,10 +2841,10 @@ func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 								UIN:         100001,
 								UIN:         100001,
 								MessageType: wire.ICBMMsgTypeAuthReq,
 								MessageType: wire.ICBMMsgTypeAuthReq,
 								Message: fmt.Sprintf("%s\xFE%s\xFE%s\xFE%s\xFE%d\xFE%s",
 								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,
 									0,
 									utf8ToLatin1("please add me"),
 									utf8ToLatin1("please add me"),
 								),
 								),
@@ -2900,10 +2902,10 @@ func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 								UIN:         100001,
 								UIN:         100001,
 								MessageType: wire.ICBMMsgTypeAuthReq,
 								MessageType: wire.ICBMMsgTypeAuthReq,
 								Message: fmt.Sprintf("%s\xFE%s\xFE%s\xFE%s\xFE%d\xFE%s",
 								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,
 									1,
 									utf8ToLatin1("please add me"),
 									utf8ToLatin1("please add me"),
 								),
 								),

+ 18 - 0
foodgroup/helpers_test.go

@@ -195,6 +195,8 @@ type findByInterestsParams []struct {
 type icqUserUpdaterParams struct {
 type icqUserUpdaterParams struct {
 	setAffiliationsParams
 	setAffiliationsParams
 	setBasicInfoParams
 	setBasicInfoParams
+	setFullInfoParams
+	setHomepageCategoryParams
 	setInterestsParams
 	setInterestsParams
 	setMoreInfoParams
 	setMoreInfoParams
 	setPermissionsParams
 	setPermissionsParams
@@ -234,6 +236,14 @@ type setBasicInfoParams []struct {
 	err  error
 	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
 // setWorkInfoParams is the list of parameters passed at the mock
 // ICQUserUpdater.SetWorkInfo call site
 // ICQUserUpdater.SetWorkInfo call site
 type setWorkInfoParams []struct {
 type setWorkInfoParams []struct {
@@ -258,6 +268,14 @@ type setPermissionsParams []struct {
 	err  error
 	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
 // bartItemManagerParams is a helper struct that contains mock parameters for
 // BARTItemManager methods
 // BARTItemManager methods
 type bartItemManagerParams struct {
 type bartItemManagerParams struct {

+ 437 - 74
foodgroup/icq.go

@@ -3,6 +3,7 @@ package foodgroup
 import (
 import (
 	"bytes"
 	"bytes"
 	"context"
 	"context"
+	"encoding/binary"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
 	"log/slog"
 	"log/slog"
@@ -544,6 +545,294 @@ func (s *ICQService) SetEmails(ctx context.Context, instance *state.SessionInsta
 	return s.reqAck(ctx, instance, seq, wire.ICQDBQueryMetaReplySetEmails)
 	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 {
 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")
 	s.logger.Debug("received SetICQPhone request")
 	return s.reqAck(ctx, instance, seq, wire.ICQDBQueryMetaReplySetICQPhone)
 	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,
 		ReqSubType: wire.ICQDBQueryMetaReplyShortInfo,
 		Success:    wire.ICQStatusCodeOK,
 		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
 		info.Authorization = 1
 	}
 	}
 
 
@@ -695,7 +984,7 @@ func (s *ICQService) XMLReqData(ctx context.Context, instance *state.SessionInst
 				Seq:     seq,
 				Seq:     seq,
 			},
 			},
 			ReqSubType: wire.ICQDBQueryMetaReplyXMLData,
 			ReqSubType: wire.ICQDBQueryMetaReplyXMLData,
-			Success:    wire.ICQStatusCodeFail,
+			Success:    wire.ICQStatusCodeErr,
 		},
 		},
 	}
 	}
 	return s.reply(ctx, instance, msg)
 	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"`
 					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 {
 				Affiliations: []struct {
@@ -734,16 +1023,16 @@ func (s *ICQService) affiliations(ctx context.Context, instance *state.SessionIn
 					Keyword string `oscar:"len_prefix=uint16,nullterm"`
 					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{
 	searchRecord := wire.ICQUserSearchRecord{
 		UIN:       uint32(uin),
 		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),
 		Age:       res.Age(s.timeNow),
 	}
 	}
-	if !res.ICQPermissions.AuthRequired {
+	if !res.ICQInfo.Permissions.AuthRequired {
 		searchRecord.Authorization = 1
 		searchRecord.Authorization = 1
 	}
 	}
 
 
@@ -823,20 +1112,20 @@ func (s *ICQService) interests(ctx context.Context, instance *state.SessionInsta
 				Keyword string `oscar:"len_prefix=uint16,nullterm"`
 				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,
 			Success:    wire.ICQStatusCodeOK,
 			ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo: wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo{
 			ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo: wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo{
 				Age:          uint8(user.Age(s.timeNow)),
 				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,
 			ReqSubType: wire.ICQDBQueryMetaReplyNotes,
 			Success:    wire.ICQStatusCodeOK,
 			Success:    wire.ICQStatusCodeOK,
 			ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes: wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes{
 			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,
 		ReqSubType:  wire.ICQDBQueryMetaReplyBasicInfo,
 		Success:     wire.ICQStatusCodeOK,
 		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
 		AuthFlag:    0, // required by default
 		WebAware:    1,
 		WebAware:    1,
 		DCPerms:     0,
 		DCPerms:     0,
 	}
 	}
 
 
-	if !user.ICQPermissions.AuthRequired {
+	if !user.ICQInfo.Permissions.AuthRequired {
 		userInfo.AuthFlag = 1
 		userInfo.AuthFlag = 1
 	}
 	}
-	if user.ICQPermissions.WebAware {
+	if user.ICQInfo.Permissions.WebAware {
 		userInfo.WebAware = 1
 		userInfo.WebAware = 1
 	} else {
 	} else {
 		userInfo.WebAware = 0
 		userInfo.WebAware = 0
 	}
 	}
 
 
-	if user.ICQBasicInfo.PublishEmail {
+	if user.ICQInfo.Basic.PublishEmail {
 		userInfo.PublishEmail = wire.ICQUserFlagPublishEmailYes
 		userInfo.PublishEmail = wire.ICQUserFlagPublishEmailYes
 	} else {
 	} else {
 		userInfo.PublishEmail = wire.ICQUserFlagPublishEmailNo
 		userInfo.PublishEmail = wire.ICQUserFlagPublishEmailNo
@@ -990,20 +1279,94 @@ func (s *ICQService) workInfo(ctx context.Context, instance *state.SessionInstan
 			ReqSubType: wire.ICQDBQueryMetaReplyWorkInfo,
 			ReqSubType: wire.ICQDBQueryMetaReplyWorkInfo,
 			Success:    wire.ICQStatusCodeOK,
 			Success:    wire.ICQStatusCodeOK,
 			ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo: wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo{
 			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)
 	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
 	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
 // SetInterests provides a mock function for the type mockICQUserUpdater
 func (_mock *mockICQUserUpdater) SetInterests(ctx context.Context, name state.IdentScreenName, data state.ICQInterests) error {
 func (_mock *mockICQUserUpdater) SetInterests(ctx context.Context, name state.IdentScreenName, data state.ICQInterests) error {
 	ret := _mock.Called(ctx, name, data)
 	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 updates the user's privacy and permission settings.
 	SetPermissions(ctx context.Context, name state.IdentScreenName, data state.ICQPermissions) error
 	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
 // 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{
 	out := icqProfileHandle{
 		UIN: user.IdentScreenName.UIN(),
 		UIN: user.IdentScreenName.UIN(),
 		BasicInfo: icqBasicInfoHandle{
 		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{
 		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{
 		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{
 		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{
 		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{
 		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
 	// - Phone/fax: max 30 chars
 	// - Email: max 64 chars
 	// - Email: max 64 chars
 	// - Address/city/state: 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
 	// - ZIP: max 12 chars
 	// - Homepage: max 127 chars
 	// - Homepage: max 127 chars
 	// - Notes: max 450 chars (v5 limit)
 	// - 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.address", input.BasicInfo.Address, 64},
 		{"basic_info.cell_phone", input.BasicInfo.CellPhone, 30},
 		{"basic_info.cell_phone", input.BasicInfo.CellPhone, 30},
 		{"basic_info.zip", input.BasicInfo.ZIPCode, 12},
 		{"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},
 		{"more_info.homepage", input.MoreInfo.HomePageAddr, 127},
 		{"work_info.company", input.WorkInfo.Company, 64},
 		{"work_info.company", input.WorkInfo.Company, 64},
 		{"work_info.department", input.WorkInfo.Department, 64},
 		{"work_info.department", input.WorkInfo.Department, 64},
@@ -1369,6 +1377,25 @@ func putICQProfileHandler(w http.ResponseWriter, r *http.Request, mgr ICQProfile
 		return
 		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)
 	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 updates the user's privacy permissions.
 	SetPermissions(ctx context.Context, name state.IdentScreenName, data state.ICQPermissions) error
 	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 {
 type userWithPassword struct {
@@ -348,6 +351,10 @@ type icqBasicInfoHandle struct {
 	CountryCode  uint16 `json:"country_code"`
 	CountryCode  uint16 `json:"country_code"`
 	GMTOffset    uint8  `json:"gmt_offset"`
 	GMTOffset    uint8  `json:"gmt_offset"`
 	PublishEmail bool   `json:"publish_email"`
 	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 {
 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) {
 func (b *LegacyMessageBridge) buildLegacyAuthFields(uin uint32) (nick, firstName, lastName, email string) {
 	nick = fmt.Sprintf("%d", uin)
 	nick = fmt.Sprintf("%d", uin)
 	if user, err := b.userFinder.FindByUIN(context.Background(), uin); err == nil {
 	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 {
 	if len(nick) > 20 {
 		nick = 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
 	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
 // SetInterests provides a mock function for the type mockICQUserUpdater
 func (_mock *mockICQUserUpdater) SetInterests(ctx context.Context, name state.IdentScreenName, data state.ICQInterests) error {
 func (_mock *mockICQUserUpdater) SetInterests(ctx context.Context, name state.IdentScreenName, data state.ICQInterests) error {
 	ret := _mock.Called(ctx, name, data)
 	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
 		// Only add if the contact does NOT require authorization — otherwise the
 		// legacy user must go through the auth request flow first.
 		// legacy user must go through the auth request flow first.
 		contactUser, userErr := s.userFinder.FindByUIN(ctx, contactUIN)
 		contactUser, userErr := s.userFinder.FindByUIN(ctx, contactUIN)
-		if userErr == nil && contactUser.ICQPermissions.AuthRequired {
+		if userErr == nil && contactUser.ICQInfo.Permissions.AuthRequired {
 			continue
 			continue
 		}
 		}
 		contactSession := state.NewSession()
 		contactSession := state.NewSession()
@@ -567,11 +567,13 @@ func (s *ICQLegacyService) RegisterNewUser(ctx context.Context, nickname, firstN
 		IdentScreenName:   screenName,
 		IdentScreenName:   screenName,
 		DisplayScreenName: state.DisplayScreenName(strconv.FormatUint(uint64(newUIN), 10)),
 		DisplayScreenName: state.DisplayScreenName(strconv.FormatUint(uint64(newUIN), 10)),
 		IsICQ:             true,
 		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 {
 		if basicUser != nil {
 			return &LegacyUserSearchResult{
 			return &LegacyUserSearchResult{
 				UIN:       uin,
 				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
 			}, nil
 		}
 		}
 		// Return minimal info
 		// Return minimal info
@@ -760,27 +762,27 @@ func (s *ICQLegacyService) GetUserInfoForProtocol(ctx context.Context, targetUIN
 	result := &UserInfoResult{
 	result := &UserInfoResult{
 		// Basic fields
 		// Basic fields
 		UIN:       targetUIN,
 		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)),
 		Age:       uint8(user.Age(s.timeNow)),
 
 
 		// Extended fields - location
 		// 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
 		// Extended fields - more info
-		Homepage: user.ICQMoreInfo.HomePageAddr,
-		About:    user.ICQNotes.Notes,
+		Homepage: user.ICQInfo.More.HomePageAddr,
+		About:    user.ICQInfo.Notes.Notes,
 
 
 		// Work info
 		// 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
 		// Auth required
 		AuthRequired: 1,
 		AuthRequired: 1,
@@ -792,7 +794,7 @@ func (s *ICQLegacyService) GetUserInfoForProtocol(ctx context.Context, targetUIN
 	}
 	}
 
 
 	// Set auth required flag
 	// Set auth required flag
-	if user.ICQPermissions.AuthRequired {
+	if user.ICQInfo.Permissions.AuthRequired {
 		result.AuthRequired = 0
 		result.AuthRequired = 0
 	}
 	}
 
 
@@ -998,7 +1000,7 @@ func (s *ICQLegacyService) filterWhitePagesResults(users []state.User, criteria
 
 
 		// Filter by gender (1=female, 2=male)
 		// Filter by gender (1=female, 2=male)
 		if criteria.Gender > 0 && criteria.Gender < 16 {
 		if criteria.Gender > 0 && criteria.Gender < 16 {
-			if uint8(user.ICQMoreInfo.Gender) != criteria.Gender {
+			if uint8(user.ICQInfo.More.Gender) != criteria.Gender {
 				continue
 				continue
 			}
 			}
 		}
 		}
@@ -1006,9 +1008,9 @@ func (s *ICQLegacyService) filterWhitePagesResults(users []state.User, criteria
 		// Filter by language
 		// Filter by language
 		if criteria.Language > 0 && criteria.Language < 127 {
 		if criteria.Language > 0 && criteria.Language < 127 {
 			langMatch := false
 			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
 				langMatch = true
 			}
 			}
 			if !langMatch {
 			if !langMatch {
@@ -1018,42 +1020,42 @@ func (s *ICQLegacyService) filterWhitePagesResults(users []state.User, criteria
 
 
 		// Filter by country (home country is in ICQBasicInfo)
 		// Filter by country (home country is in ICQBasicInfo)
 		if criteria.Country > 0 && criteria.Country < 20000 {
 		if criteria.Country > 0 && criteria.Country < 20000 {
-			if user.ICQBasicInfo.CountryCode != criteria.Country {
+			if user.ICQInfo.Basic.CountryCode != criteria.Country {
 				continue
 				continue
 			}
 			}
 		}
 		}
 
 
 		// Filter by city (case-insensitive partial match, home city is in ICQBasicInfo)
 		// Filter by city (case-insensitive partial match, home city is in ICQBasicInfo)
 		if criteria.City != "" {
 		if criteria.City != "" {
-			if !containsIgnoreCase(user.ICQBasicInfo.City, criteria.City) {
+			if !containsIgnoreCase(user.ICQInfo.Basic.City, criteria.City) {
 				continue
 				continue
 			}
 			}
 		}
 		}
 
 
 		// Filter by state (case-insensitive partial match, home state is in ICQBasicInfo)
 		// Filter by state (case-insensitive partial match, home state is in ICQBasicInfo)
 		if criteria.State != "" {
 		if criteria.State != "" {
-			if !containsIgnoreCase(user.ICQBasicInfo.State, criteria.State) {
+			if !containsIgnoreCase(user.ICQInfo.Basic.State, criteria.State) {
 				continue
 				continue
 			}
 			}
 		}
 		}
 
 
 		// Filter by company (case-insensitive partial match)
 		// Filter by company (case-insensitive partial match)
 		if criteria.Company != "" {
 		if criteria.Company != "" {
-			if !containsIgnoreCase(user.ICQWorkInfo.Company, criteria.Company) {
+			if !containsIgnoreCase(user.ICQInfo.Work.Company, criteria.Company) {
 				continue
 				continue
 			}
 			}
 		}
 		}
 
 
 		// Filter by position (case-insensitive partial match)
 		// Filter by position (case-insensitive partial match)
 		if criteria.Position != "" {
 		if criteria.Position != "" {
-			if !containsIgnoreCase(user.ICQWorkInfo.Position, criteria.Position) {
+			if !containsIgnoreCase(user.ICQInfo.Work.Position, criteria.Position) {
 				continue
 				continue
 			}
 			}
 		}
 		}
 
 
 		// Filter by work code/occupation
 		// Filter by work code/occupation
 		if criteria.WorkCode > 0 && criteria.WorkCode < 127 {
 		if criteria.WorkCode > 0 && criteria.WorkCode < 127 {
-			if uint8(user.ICQWorkInfo.OccupationCode) != criteria.WorkCode {
+			if uint8(user.ICQInfo.Work.OccupationCode) != criteria.WorkCode {
 				continue
 				continue
 			}
 			}
 		}
 		}
@@ -1343,10 +1345,10 @@ func (s *ICQLegacyService) GetInterests(ctx context.Context, uin uint32) (*state
 
 
 	s.logger.Debug("GetInterests: retrieved interests",
 	s.logger.Debug("GetInterests: retrieved interests",
 		"uin", uin,
 		"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.
 // 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",
 	s.logger.Debug("GetAffiliations: retrieved affiliations",
 		"uin", uin,
 		"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.
 // 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",
 	s.logger.Debug("GetNotes: retrieved notes",
 		"uin", uin,
 		"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.
 // 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
 	// Update the auth mode while preserving other permission settings
-	permissions := user.ICQPermissions
+	permissions := user.ICQInfo.Permissions
 	permissions.AuthRequired = authRequired
 	permissions.AuthRequired = authRequired
 
 
 	if err := s.userUpdater.SetPermissions(ctx, screenName, permissions); err != nil {
 	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",
 	s.logger.Debug("GetHomepageCategory: retrieved homepage category",
 		"uin", uin,
 		"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.
 // 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 {
 func (s *ICQLegacyService) userToSearchResult(user state.User) *LegacyUserSearchResult {
 	uin, _ := strconv.Atoi(user.IdentScreenName.String())
 	uin, _ := strconv.Atoi(user.IdentScreenName.String())
 
 
-	nickname := user.ICQBasicInfo.Nickname
+	nickname := user.ICQInfo.Basic.Nickname
 	// Use UIN as fallback nickname if not set
 	// Use UIN as fallback nickname if not set
 	if nickname == "" {
 	if nickname == "" {
 		nickname = user.IdentScreenName.String()
 		nickname = user.IdentScreenName.String()
@@ -1717,10 +1719,10 @@ func (s *ICQLegacyService) userToSearchResult(user state.User) *LegacyUserSearch
 	result := &LegacyUserSearchResult{
 	result := &LegacyUserSearchResult{
 		UIN:       uint32(uin),
 		UIN:       uint32(uin),
 		Nickname:  nickname,
 		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)),
 		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,
 							UIN: 12345,
 							result: state.User{
 							result: state.User{
 								IdentScreenName: state.NewIdentScreenName("12345"),
 								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{
 							result: []state.User{
 								{
 								{
 									IdentScreenName: state.NewIdentScreenName("12345"),
 									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",
 							email: "john@example.com",
 							result: state.User{
 							result: state.User{
 								IdentScreenName: state.NewIdentScreenName("12345"),
 								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
 	SetUserNotes(ctx context.Context, name state.IdentScreenName, data state.ICQUserNotes) error
 	SetPermissions(ctx context.Context, name state.IdentScreenName, data state.ICQPermissions) error
 	SetPermissions(ctx context.Context, name state.IdentScreenName, data state.ICQPermissions) error
 	SetHomepageCategory(ctx context.Context, name state.IdentScreenName, data state.ICQHomepageCategory) 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.
 // 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)
 	// Build and send SRV_EXT_INFO_REPLY (0x0122)
 	wireInfo := &LegacyUserInfo{
 	wireInfo := &LegacyUserInfo{
 		UIN:      targetUIN,
 		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),
 		Age:      user.Age(time.Now),
-		Gender:   uint8(user.ICQMoreInfo.Gender),
+		Gender:   uint8(user.ICQInfo.More.Gender),
 	}
 	}
 
 
 	if session != nil {
 	if session != nil {
@@ -1128,7 +1128,7 @@ func (h *V2Handler) handleUpdateBasic(session *LegacySession, pkt *V2ClientPacke
 	existing, err := h.service.GetFullUserInfo(ctx, session.UIN)
 	existing, err := h.service.GetFullUserInfo(ctx, session.UIN)
 	var info state.ICQBasicInfo
 	var info state.ICQBasicInfo
 	if err == nil && existing != nil {
 	if err == nil && existing != nil {
-		info = existing.ICQBasicInfo
+		info = existing.ICQInfo.Basic
 	}
 	}
 	info.Nickname = alias
 	info.Nickname = alias
 	info.FirstName = firstName
 	info.FirstName = firstName
@@ -1236,20 +1236,20 @@ func (h *V2Handler) handleUpdateDetail(session *LegacySession, pkt *V2ClientPack
 	ctx := context.Background()
 	ctx := context.Background()
 	existing, err := h.service.GetFullUserInfo(ctx, session.UIN)
 	existing, err := h.service.GetFullUserInfo(ctx, session.UIN)
 	if err == nil && existing != nil {
 	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)
 			h.logger.Error("V2 update detail basic failed", "uin", session.UIN, "err", err)
 		}
 		}
 	}
 	}
 
 
 	// Read existing more info to avoid overwriting birthday/languages
 	// Read existing more info to avoid overwriting birthday/languages
 	if existing != nil {
 	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)
 			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()
 	ctx := context.Background()
 	existing, err := h.service.GetFullUserInfo(ctx, uin)
 	existing, err := h.service.GetFullUserInfo(ctx, uin)
 	if err == nil && existing != nil {
 	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)
 			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()
 	ctx := context.Background()
 	existing, existErr := h.service.GetFullUserInfo(ctx, uin)
 	existing, existErr := h.service.GetFullUserInfo(ctx, uin)
 	if existErr == nil && existing != nil {
 	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)
 			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()
 	ctx := context.Background()
 	existing, err := h.service.GetFullUserInfo(ctx, uin)
 	existing, err := h.service.GetFullUserInfo(ctx, uin)
 	if err == nil && existing != nil {
 	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)
 			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
 		// Read existing basic info to avoid overwriting nick/first/last/email
 		existing, err := h.service.GetFullUserInfo(ctx, uin)
 		existing, err := h.service.GetFullUserInfo(ctx, uin)
 		if err == nil && existing != nil {
 		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)
 				h.logger.Error("V4 update detail basic failed", "uin", uin, "err", err)
 			}
 			}
 		}
 		}
 		// Merge more info to avoid overwriting birthday/languages
 		// Merge more info to avoid overwriting birthday/languages
 		if existing != nil {
 		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)
 				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
 	// Try to get full user info from the service
 	user, err := h.service.GetFullUserInfo(ctx, targetUIN)
 	user, err := h.service.GetFullUserInfo(ctx, targetUIN)
 	if err == nil && user != nil {
 	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
 		// Calculate age from birth year
-		if user.ICQMoreInfo.BirthYear > 0 {
+		if user.ICQInfo.More.BirthYear > 0 {
 			currentYear := uint16(2026) // Current year
 			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 (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
 		// 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)
 				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",
 	h.logger.Info("META login info - FOUND, sending 7 info packets",
 		"target_uin", targetUIN,
 		"target_uin", targetUIN,
-		"nickname", user.ICQBasicInfo.Nickname,
+		"nickname", user.ICQInfo.Basic.Nickname,
 	)
 	)
 
 
 	// Send all 7 info packets as per iserverd v5_reply_metafullinfo_request2()
 	// 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)",
 	h.logger.Info("META user full info - FOUND, sending 7 info packets (older format)",
 		"target_uin", targetUIN,
 		"target_uin", targetUIN,
-		"nickname", user.ICQBasicInfo.Nickname,
+		"nickname", user.ICQInfo.Basic.Nickname,
 	)
 	)
 
 
 	// Send all 7 info packets as per iserverd v5_reply_metafullinfo_request()
 	// 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)",
 	h.logger.Info("META user full info2 - FOUND, sending 7 info packets (newer format)",
 		"target_uin", targetUIN,
 		"target_uin", targetUIN,
-		"nickname", user.ICQBasicInfo.Nickname,
+		"nickname", user.ICQInfo.Basic.Nickname,
 	)
 	)
 
 
 	// Send all 7 info packets as per iserverd v5_reply_metafullinfo_request2()
 	// 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)
 	buf := new(bytes.Buffer)
 	binary.Write(buf, binary.LittleEndian, uint16(0x00C8)) // SRV_META_USER_INFO2
 	binary.Write(buf, binary.LittleEndian, uint16(0x00C8)) // SRV_META_USER_INFO2
 	buf.WriteByte(0x0A)                                    // success
 	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)
 	authFlag := uint8(0)
-	if user.ICQPermissions.AuthRequired {
+	if user.ICQInfo.Permissions.AuthRequired {
 		authFlag = 1
 		authFlag = 1
 	}
 	}
 	buf.WriteByte(authFlag)
 	buf.WriteByte(authFlag)
 	publishFlag := uint8(0)
 	publishFlag := uint8(0)
-	if !user.ICQBasicInfo.PublishEmail {
+	if !user.ICQInfo.Basic.PublishEmail {
 		publishFlag = 1
 		publishFlag = 1
 	}
 	}
 	buf.WriteByte(publishFlag)
 	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)
 	// Calculate birth year as single byte (year - 1900)
 	// From iserverd: if (tuser.byear < 1900) {temp_year = tuser.byear;} else {temp_year = tuser.byear - 1900;};
 	// From iserverd: if (tuser.byear < 1900) {temp_year = tuser.byear;} else {temp_year = tuser.byear - 1900;};
 	var tempYear uint8
 	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 {
 	} else {
-		tempYear = uint8(user.ICQMoreInfo.BirthYear - 1900)
+		tempYear = uint8(user.ICQInfo.More.BirthYear - 1900)
 	}
 	}
 
 
 	buf := new(bytes.Buffer)
 	buf := new(bytes.Buffer)
 	binary.Write(buf, binary.LittleEndian, ICQLegacySrvMetaInfoMore) // SRV_META_INFO_MORE = 0x00DC
 	binary.Write(buf, binary.LittleEndian, ICQLegacySrvMetaInfoMore) // SRV_META_INFO_MORE = 0x00DC
 	buf.WriteByte(0x0A)                                              // success
 	buf.WriteByte(0x0A)                                              // success
 	binary.Write(buf, binary.LittleEndian, user.Age(time.Now))       // age(2)
 	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(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{
 	pkt := &V5ServerPacket{
 		Version:   ICQLegacyVersionV5,
 		Version:   ICQLegacyVersionV5,
@@ -3206,17 +3206,17 @@ func (h *V5Handler) sendMetaMore2(session *LegacySession, seq2 uint16, user *sta
 	}
 	}
 
 
 	buf := new(bytes.Buffer)
 	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{
 	pkt := &V5ServerPacket{
 		Version:   ICQLegacyVersionV5,
 		Version:   ICQLegacyVersionV5,
@@ -3692,21 +3692,21 @@ func (h *V5Handler) sendV5OldStyleInfoExt(session *LegacySession, targetUIN uint
 	// Extract fields from user struct (matching iserverd field names)
 	// Extract fields from user struct (matching iserverd field names)
 	// From iserverd: tuser.hcity, tuser.hcountry, tuser.hstate, tuser.age, tuser.gender,
 	// From iserverd: tuser.hcity, tuser.hcountry, tuser.hstate, tuser.age, tuser.gender,
 	//                tuser.hphone, tuser.hpage, notes.notes
 	//                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() }))
 	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
 	// Parse ZIP code string to uint32 for the old-style ext info format
 	// The licq.5 client reads this as UnpackUnsignedLong (4 bytes)
 	// The licq.5 client reads this as UnpackUnsignedLong (4 bytes)
 	var zipCode uint32
 	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:
 	// 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
 				return err
 			}
 			}
 		case wire.ICQDBQueryMetaReqSearchByUIN2:
 		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{}
 			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
 				return err
 			}
 			}
 			if err := rt.ICQService.FindByUIN2(ctx, instance, req, icqMD.Seq); err != nil {
 			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 {
 			if err := rt.ICQService.SetAffiliations(ctx, instance, req, icqMD.Seq); err != nil {
 				return err
 				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,
 		case wire.ICQDBQueryMetaReqStat0a8c,
 			wire.ICQDBQueryMetaReqStat0a96,
 			wire.ICQDBQueryMetaReqStat0a96,
 			wire.ICQDBQueryMetaReqStat0aaa,
 			wire.ICQDBQueryMetaReqStat0aaa,

+ 49 - 0
server/oscar/handler_test.go

@@ -2848,6 +2848,7 @@ func TestHandler_ICQDBQuery(t *testing.T) {
 		setAffiliations   *mockParam
 		setAffiliations   *mockParam
 		setBasicInfo      *mockParam
 		setBasicInfo      *mockParam
 		setEmails         *mockParam
 		setEmails         *mockParam
+		setFullInfo       *mockParam
 		setInterests      *mockParam
 		setInterests      *mockParam
 		setMoreInfo       *mockParam
 		setMoreInfo       *mockParam
 		setPermissions    *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",
 			name: "MetaReqStat - happy path",
 			reqParams: reqParams{
 			reqParams: reqParams{
@@ -3781,6 +3826,10 @@ func TestHandler_ICQDBQuery(t *testing.T) {
 				icqService.EXPECT().
 				icqService.EXPECT().
 					SetAffiliations(mock.Anything, tt.reqParams.instance, tt.allMockParams.setAffiliations.req, tt.reqParams.seq).
 					SetAffiliations(mock.Anything, tt.reqParams.instance, tt.allMockParams.setAffiliations.req, tt.reqParams.seq).
 					Return(tt.allMockParams.setAffiliations.wantErr)
 					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:
 			case tt.allMockParams.offlineMsgReq != nil:
 				icqService.EXPECT().
 				icqService.EXPECT().
 					OfflineMsgReq(mock.Anything, tt.reqParams.instance, tt.reqParams.seq).
 					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
 	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
 // 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 {
 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)
 	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
 	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
 	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
 	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
 	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
 	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
 	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)
 	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.
 // User represents a user account.
 type User struct {
 type User struct {
 	// IdentScreenName is the AIM screen name.
 	// IdentScreenName is the AIM screen name.
@@ -158,29 +187,9 @@ type User struct {
 	SuspendedStatus uint16
 	SuspendedStatus uint16
 	// EmailAddress is the email address set by the AIM client.
 	// EmailAddress is the email address set by the AIM client.
 	EmailAddress string
 	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
 	// TOCConfig is the user's saved server-side info (buddy list, etc) for
 	// on the TOC service.
 	// on the TOC service.
 	TOCConfig string
 	TOCConfig string
@@ -268,85 +277,66 @@ type ICQBasicInfo struct {
 	State string
 	State string
 	// ZIPCode is the user's postal code.
 	// ZIPCode is the user's postal code.
 	ZIPCode string
 	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.
 // 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 {
 type ICQAffiliations struct {
 	// PastCount is the number of past affiliations set (0-3).
 	// PastCount is the number of past affiliations set (0-3).
-	// From iserverd: past_num
 	PastCount uint8
 	PastCount uint8
 	// PastCode1 is the code representing the user's first past affiliation.
 	// PastCode1 is the code representing the user's first past affiliation.
-	// From iserverd: past_ind1
 	PastCode1 uint16
 	PastCode1 uint16
 	// PastKeyword1 is the keyword associated with the user's first past affiliation.
 	// PastKeyword1 is the keyword associated with the user's first past affiliation.
-	// From iserverd: past_key1
 	PastKeyword1 string
 	PastKeyword1 string
 	// PastCode2 is the code representing the user's second past affiliation.
 	// PastCode2 is the code representing the user's second past affiliation.
-	// From iserverd: past_ind2
 	PastCode2 uint16
 	PastCode2 uint16
 	// PastKeyword2 is the keyword associated with the user's second past affiliation.
 	// PastKeyword2 is the keyword associated with the user's second past affiliation.
-	// From iserverd: past_key2
 	PastKeyword2 string
 	PastKeyword2 string
 	// PastCode3 is the code representing the user's third past affiliation.
 	// PastCode3 is the code representing the user's third past affiliation.
-	// From iserverd: past_ind3
 	PastCode3 uint16
 	PastCode3 uint16
 	// PastKeyword3 is the keyword associated with the user's third past affiliation.
 	// PastKeyword3 is the keyword associated with the user's third past affiliation.
-	// From iserverd: past_key3
 	PastKeyword3 string
 	PastKeyword3 string
 	// CurrentCount is the number of current affiliations set (0-3).
 	// CurrentCount is the number of current affiliations set (0-3).
-	// From iserverd: aff_num
 	CurrentCount uint8
 	CurrentCount uint8
 	// CurrentCode1 is the code representing the user's current first affiliation.
 	// CurrentCode1 is the code representing the user's current first affiliation.
-	// From iserverd: aff_ind1
 	CurrentCode1 uint16
 	CurrentCode1 uint16
 	// CurrentKeyword1 is the keyword associated with the user's current first affiliation.
 	// CurrentKeyword1 is the keyword associated with the user's current first affiliation.
-	// From iserverd: aff_key1
 	CurrentKeyword1 string
 	CurrentKeyword1 string
 	// CurrentCode2 is the code representing the user's current second affiliation.
 	// CurrentCode2 is the code representing the user's current second affiliation.
-	// From iserverd: aff_ind2
 	CurrentCode2 uint16
 	CurrentCode2 uint16
 	// CurrentKeyword2 is the keyword associated with the user's current second affiliation.
 	// CurrentKeyword2 is the keyword associated with the user's current second affiliation.
-	// From iserverd: aff_key2
 	CurrentKeyword2 string
 	CurrentKeyword2 string
 	// CurrentCode3 is the code representing the user's current third affiliation.
 	// CurrentCode3 is the code representing the user's current third affiliation.
-	// From iserverd: aff_ind3
 	CurrentCode3 uint16
 	CurrentCode3 uint16
 	// CurrentKeyword3 is the keyword associated with the user's current third affiliation.
 	// CurrentKeyword3 is the keyword associated with the user's current third affiliation.
-	// From iserverd: aff_key3
 	CurrentKeyword3 string
 	CurrentKeyword3 string
 }
 }
 
 
 // ICQInterests holds information about the user's interests, categorized by
 // ICQInterests holds information about the user's interests, categorized by
 // interest code and associated keyword.
 // interest code and associated keyword.
-// From iserverd: int_num, int_ind1-4, int_key1-4
 type ICQInterests struct {
 type ICQInterests struct {
 	// Count is the number of interests set (0-4).
 	// Count is the number of interests set (0-4).
-	// From iserverd: int_num
 	Count uint8
 	Count uint8
 	// Code1 is the code representing the user's first interest.
 	// Code1 is the code representing the user's first interest.
-	// From iserverd: int_ind1
 	Code1 uint16
 	Code1 uint16
 	// Keyword1 is the keyword associated with the user's first interest.
 	// Keyword1 is the keyword associated with the user's first interest.
-	// From iserverd: int_key1
 	Keyword1 string
 	Keyword1 string
 	// Code2 is the code representing the user's second interest.
 	// Code2 is the code representing the user's second interest.
-	// From iserverd: int_ind2
 	Code2 uint16
 	Code2 uint16
 	// Keyword2 is the keyword associated with the user's second interest.
 	// Keyword2 is the keyword associated with the user's second interest.
-	// From iserverd: int_key2
 	Keyword2 string
 	Keyword2 string
 	// Code3 is the code representing the user's third interest.
 	// Code3 is the code representing the user's third interest.
-	// From iserverd: int_ind3
 	Code3 uint16
 	Code3 uint16
 	// Keyword3 is the keyword associated with the user's third interest.
 	// Keyword3 is the keyword associated with the user's third interest.
-	// From iserverd: int_key3
 	Keyword3 string
 	Keyword3 string
 	// Code4 is the code representing the user's fourth interest.
 	// Code4 is the code representing the user's fourth interest.
-	// From iserverd: int_ind4
 	Code4 uint16
 	Code4 uint16
 	// Keyword4 is the keyword associated with the user's fourth interest.
 	// Keyword4 is the keyword associated with the user's fourth interest.
-	// From iserverd: int_key4
 	Keyword4 string
 	Keyword4 string
 }
 }
 
 
@@ -421,16 +411,12 @@ type ICQPermissions struct {
 
 
 // ICQHomepageCategory contains homepage category information for an ICQ user.
 // ICQHomepageCategory contains homepage category information for an ICQ user.
 // This is used by the V5 META_SET_HPCAT (0x0442) command.
 // This is used by the V5 META_SET_HPCAT (0x0442) command.
-// From iserverd: hpage_cf, hpage_cat, hpage_txt
 type ICQHomepageCategory struct {
 type ICQHomepageCategory struct {
 	// Enabled indicates whether the homepage category is enabled.
 	// Enabled indicates whether the homepage category is enabled.
-	// From iserverd: hpage_cf (0 = disabled, 1 = enabled)
 	Enabled bool
 	Enabled bool
 	// Index is the homepage category index/code.
 	// Index is the homepage category index/code.
-	// From iserverd: hpage_cat
 	Index uint16
 	Index uint16
 	// Description is the homepage category description/keyword.
 	// Description is the homepage category description/keyword.
-	// From iserverd: hpage_txt (max 127 chars)
 	Description string
 	Description string
 }
 }
 
 
@@ -438,11 +424,11 @@ type ICQHomepageCategory struct {
 func (u *User) Age(timeNow func() time.Time) uint16 {
 func (u *User) Age(timeNow func() time.Time) uint16 {
 	now := timeNow().UTC()
 	now := timeNow().UTC()
 	switch {
 	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())
 		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()
 		years := now.Year() - bday.Year()
 		if now.YearDay() < bday.YearDay() {
 		if now.YearDay() < bday.YearDay() {
 			years--
 			years--

+ 221 - 62
state/user_store.go

@@ -341,7 +341,7 @@ func (f SQLiteUserStore) RequiresAuthorization(ctx context.Context, owner, reque
 	if err != nil {
 	if err != nil {
 		return false, fmt.Errorf("RequiresAuthorization: %w", err)
 		return false, fmt.Errorf("RequiresAuthorization: %w", err)
 	}
 	}
-	if u == nil || !u.ICQPermissions.AuthRequired {
+	if u == nil || !u.ICQInfo.Permissions.AuthRequired {
 		return false, nil
 		return false, nil
 	}
 	}
 	var one int
 	var one int
@@ -426,6 +426,9 @@ func (f SQLiteUserStore) queryUsers(ctx context.Context, whereClause string, que
 			icq_basicInfo_publishEmail,
 			icq_basicInfo_publishEmail,
 			icq_basicInfo_state,
 			icq_basicInfo_state,
 			icq_basicInfo_zipCode,
 			icq_basicInfo_zipCode,
+			icq_basicInfo_originCity,
+			icq_basicInfo_originState,
+			icq_basicInfo_originCountryCode,
 			icq_interests_code1,
 			icq_interests_code1,
 			icq_interests_code2,
 			icq_interests_code2,
 			icq_interests_code3,
 			icq_interests_code3,
@@ -502,67 +505,70 @@ func (f SQLiteUserStore) queryUsers(ctx context.Context, whereClause string, que
 			&u.SuspendedStatus,
 			&u.SuspendedStatus,
 			&u.IsBot,
 			&u.IsBot,
 			&u.IsICQ,
 			&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.FirstName,
 			&u.AIMDirectoryInfo.LastName,
 			&u.AIMDirectoryInfo.LastName,
 			&u.AIMDirectoryInfo.MiddleName,
 			&u.AIMDirectoryInfo.MiddleName,
@@ -1732,6 +1738,159 @@ func (f SQLiteUserStore) SetBasicInfo(ctx context.Context, name IdentScreenName,
 	return nil
 	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) {
 func (f SQLiteUserStore) SaveMessage(ctx context.Context, offlineMessage OfflineMessage) (newCount int, err error) {
 	buf := &bytes.Buffer{}
 	buf := &bytes.Buffer{}
 	if err := wire.MarshalBE(offlineMessage.Message, buf); err != nil {
 	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"),
 		StrongMD5Pass:     []byte("thepasshash"),
 		RegStatus:         3,
 		RegStatus:         3,
 		LastWarnUpdate:    time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC), // Database default value
 		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)
 	err = f.InsertUser(context.Background(), *insertedUser)
 	assert.NoError(t, err)
 	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
 		// Retrieve the user and verify the work info was set correctly
 		updatedUser, err := f.User(context.Background(), screenName)
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
 		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) {
 	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
 		// Retrieve the user and verify that fields are empty or have default values
 		updatedUser, err := f.User(context.Background(), screenName)
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
 		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
 		// Retrieve the user and verify the more info was set correctly
 		updatedUser, err := f.User(context.Background(), screenName)
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
 		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) {
 	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
 		// Retrieve the user and verify that fields are empty or have default values
 		updatedUser, err := f.User(context.Background(), screenName)
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
 		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
 		// Retrieve the user and verify the notes were set correctly
 		updatedUser, err := f.User(context.Background(), screenName)
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
 		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) {
 	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
 		// Retrieve the user and verify that notes are empty
 		updatedUser, err := f.User(context.Background(), screenName)
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
 		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
 		// Retrieve the user and verify the interests were set correctly
 		updatedUser, err := f.User(context.Background(), screenName)
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
 		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) {
 	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
 		// Retrieve the user and verify that interests fields are empty or have default values
 		updatedUser, err := f.User(context.Background(), screenName)
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
 		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
 		// Retrieve the user and verify the affiliations were set correctly
 		updatedUser, err := f.User(context.Background(), screenName)
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
 		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) {
 	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
 		// Retrieve the user and verify that affiliations fields are empty or have default values
 		updatedUser, err := f.User(context.Background(), screenName)
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
 		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
 		// Retrieve the user and verify the basic info was set correctly
 		updatedUser, err := f.User(context.Background(), screenName)
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
 		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) {
 	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
 		// Retrieve the user and verify that basic info fields are empty or have default values
 		updatedUser, err := f.User(context.Background(), screenName)
 		updatedUser, err := f.User(context.Background(), screenName)
 		assert.NoError(t, err)
 		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,
 		IdentScreenName:   owner,
 		DisplayScreenName: DisplayScreenName("100001"),
 		DisplayScreenName: DisplayScreenName("100001"),
 		IsICQ:             true,
 		IsICQ:             true,
-		ICQPermissions:    ICQPermissions{AuthRequired: true},
+		ICQInfo: ICQInfo{
+			Permissions: ICQPermissions{AuthRequired: true},
+		},
 	}))
 	}))
 	require.NoError(t, f.InsertUser(ctx, User{
 	require.NoError(t, f.InsertUser(ctx, User{
 		IdentScreenName:   requester,
 		IdentScreenName:   requester,

+ 40 - 26
state/user_test.go

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

+ 83 - 16
wire/decode.go

@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"fmt"
 	"io"
 	"io"
 	"reflect"
 	"reflect"
+	"strings"
 )
 )
 
 
 var (
 var (
@@ -16,7 +17,7 @@ var (
 
 
 // UnmarshalBE unmarshalls OSCAR protocol messages in big-endian format.
 // UnmarshalBE unmarshalls OSCAR protocol messages in big-endian format.
 func UnmarshalBE(v any, r io.Reader) error {
 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 fmt.Errorf("%w: %w", ErrUnmarshalFailure, err)
 	}
 	}
 	return nil
 	return nil
@@ -24,22 +25,25 @@ func UnmarshalBE(v any, r io.Reader) error {
 
 
 // UnmarshalLE unmarshalls OSCAR protocol messages in little-endian format.
 // UnmarshalLE unmarshalls OSCAR protocol messages in little-endian format.
 func UnmarshalLE(v any, r io.Reader) error {
 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 fmt.Errorf("%w: %w", ErrUnmarshalFailure, err)
 	}
 	}
 	return nil
 	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)
 	oscTag, err := parseOSCARTag(tag)
 	if err != nil {
 	if err != nil {
 		return fmt.Errorf("error parsing tag: %w", err)
 		return fmt.Errorf("error parsing tag: %w", err)
 	}
 	}
+	effectiveQuirk := strings.TrimSpace(oscTag.quirk)
+	if effectiveQuirk == "" {
+		effectiveQuirk = activeQuirk
+	}
 
 
 	if oscTag.optional {
 	if oscTag.optional {
 		v.Set(reflect.New(t.Elem()))
 		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) {
 		if errors.Is(err, io.EOF) {
 			// no values to read, but that's ok since this struct is optional
 			// no values to read, but that's ok since this struct is optional
 			v.Set(reflect.Zero(t))
 			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() {
 	switch v.Kind() {
 	case reflect.Array:
 	case reflect.Array:
-		return unmarshalArray(v, r, order)
+		return unmarshalArray(v, r, order, effectiveQuirk)
 	case reflect.Slice:
 	case reflect.Slice:
-		return unmarshalSlice(v, oscTag, r, order)
+		return unmarshalSlice(v, oscTag, r, order, effectiveQuirk)
 	case reflect.String:
 	case reflect.String:
 		return unmarshalString(v, oscTag, r, order)
 		return unmarshalString(v, oscTag, r, order)
 	case reflect.Struct:
 	case reflect.Struct:
-		return unmarshalStruct(t, v, oscTag, r, order)
+		return unmarshalStruct(t, v, oscTag, r, order, effectiveQuirk)
 	case reflect.Uint8:
 	case reflect.Uint8:
 		var l uint8
 		var l uint8
 		if err := binary.Read(r, order, &l); err != nil {
 		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()
 	arrLen := v.Len()
 	arrType := v.Type().Elem()
 	arrType := v.Type().Elem()
 
 
 	for i := 0; i < arrLen; i++ {
 	for i := 0; i < arrLen; i++ {
 		elem := reflect.New(arrType).Elem()
 		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
 			return err
 		}
 		}
 		v.Index(i).Set(elem)
 		v.Index(i).Set(elem)
@@ -107,7 +111,7 @@ func unmarshalArray(v reflect.Value, r io.Reader, order binary.ByteOrder) error
 	return nil
 	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()
 	slice := reflect.New(v.Type()).Elem()
 	elemType := 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)
 		buf := bytes.NewBuffer(b)
 		for buf.Len() > 0 {
 		for buf.Len() > 0 {
 			elem := reflect.New(elemType).Elem()
 			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
 				return err
 			}
 			}
 			slice = reflect.Append(slice, elem)
 			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++ {
 		for i := 0; i < count; i++ {
 			elem := reflect.New(elemType).Elem()
 			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
 				return err
 			}
 			}
 			slice = reflect.Append(slice, elem)
 			slice = reflect.Append(slice, elem)
@@ -146,7 +150,7 @@ func unmarshalSlice(v reflect.Value, oscTag oscarTag, r io.Reader, order binary.
 	} else {
 	} else {
 		for {
 		for {
 			elem := reflect.New(elemType).Elem()
 			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) {
 				if errors.Is(err, io.EOF) {
 					break
 					break
 				}
 				}
@@ -159,6 +163,69 @@ func unmarshalSlice(v reflect.Value, oscTag oscarTag, r io.Reader, order binary.
 	return nil
 	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 {
 func unmarshalString(v reflect.Value, oscTag oscarTag, r io.Reader, order binary.ByteOrder) error {
 	if !oscTag.hasLenPrefix {
 	if !oscTag.hasLenPrefix {
 		return fmt.Errorf("missing len_prefix tag")
 		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
 	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 {
 	if oscTag.hasLenPrefix {
 		bufLen, err := unmarshalUnsignedInt(oscTag.lenPrefix, r, order)
 		bufLen, err := unmarshalUnsignedInt(oscTag.lenPrefix, r, order)
 		if err != nil {
 		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())
 					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
 			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
 	lenPrefix      reflect.Kind
 	optional       bool
 	optional       bool
 	nullTerminated bool
 	nullTerminated bool
+	// quirk names an unmarshal-only decode exception
+	quirk string
 }
 }
 
 
 func parseOSCARTag(tag reflect.StructTag) (oscarTag, error) {
 func parseOSCARTag(tag reflect.StructTag) (oscarTag, error) {
@@ -224,41 +226,58 @@ func parseOSCARTag(tag reflect.StructTag) (oscarTag, error) {
 	}
 	}
 
 
 	for _, kv := range strings.Split(val, ",") {
 	for _, kv := range strings.Split(val, ",") {
+		kv = strings.TrimSpace(kv)
+		if kv == "" {
+			continue
+		}
 		kvSplit := strings.SplitN(kv, "=", 2)
 		kvSplit := strings.SplitN(kv, "=", 2)
+		key := strings.TrimSpace(kvSplit[0])
 		if len(kvSplit) == 2 {
 		if len(kvSplit) == 2 {
-			switch kvSplit[0] {
+			valPart := strings.TrimSpace(kvSplit[1])
+			switch key {
 			case "len_prefix":
 			case "len_prefix":
 				oscTag.hasLenPrefix = true
 				oscTag.hasLenPrefix = true
-				switch kvSplit[1] {
+				switch valPart {
 				case "uint8":
 				case "uint8":
 					oscTag.lenPrefix = reflect.Uint8
 					oscTag.lenPrefix = reflect.Uint8
 				case "uint16":
 				case "uint16":
 					oscTag.lenPrefix = reflect.Uint16
 					oscTag.lenPrefix = reflect.Uint16
 				default:
 				default:
 					return oscTag, fmt.Errorf("%w: unsupported type %s. allowed types: uint8, uint16",
 					return oscTag, fmt.Errorf("%w: unsupported type %s. allowed types: uint8, uint16",
-						errInvalidStructTag, kvSplit[1])
+						errInvalidStructTag, valPart)
 				}
 				}
 			case "count_prefix":
 			case "count_prefix":
 				oscTag.hasCountPrefix = true
 				oscTag.hasCountPrefix = true
-				switch kvSplit[1] {
+				switch valPart {
 				case "uint8":
 				case "uint8":
 					oscTag.countPrefix = reflect.Uint8
 					oscTag.countPrefix = reflect.Uint8
 				case "uint16":
 				case "uint16":
 					oscTag.countPrefix = reflect.Uint16
 					oscTag.countPrefix = reflect.Uint16
 				default:
 				default:
 					return oscTag, fmt.Errorf("%w: unsupported type %s. allowed types: uint8, uint16",
 					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 {
 		} else {
-			switch kvSplit[0] {
+			switch key {
 			case "optional":
 			case "optional":
 				oscTag.optional = true
 				oscTag.optional = true
 			case "nullterm":
 			case "nullterm":
 				oscTag.nullTerminated = true
 				oscTag.nullTerminated = true
 			default:
 			default:
 				return oscTag, fmt.Errorf("%w: unsupported struct tag %s",
 				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,
 			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 {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 		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
 	ICQTLVTagsWorkPhoneNumber           uint16 = 0x02C6 // User work phone number
 	ICQTLVTagsWorkFaxNumber             uint16 = 0x02D0 // User work fax number
 	ICQTLVTagsWorkFaxNumber             uint16 = 0x02D0 // User work fax number
 	ICQTLVTagsWorkWebpageURL            uint16 = 0x02DA // User work webpage URL
 	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
 	ICQTLVTagsGMTOffset                 uint16 = 0x0316 // User GMT offset
 	ICQTLVTagsOriginallyFromCity        uint16 = 0x0320 // User originally from city
 	ICQTLVTagsOriginallyFromCity        uint16 = 0x0320 // User originally from city
 	ICQTLVTagsOriginallyFromState       uint16 = 0x032A // User originally from state
 	ICQTLVTagsOriginallyFromState       uint16 = 0x032A // User originally from state
@@ -2013,6 +2013,7 @@ const (
 	ICQDBQueryMetaReqSetAffiliations   uint16 = 0x041A
 	ICQDBQueryMetaReqSetAffiliations   uint16 = 0x041A
 	ICQDBQueryMetaReqSetPermissions    uint16 = 0x0424
 	ICQDBQueryMetaReqSetPermissions    uint16 = 0x0424
 	ICQDBQueryMetaReqSetICQPhone       uint16 = 0x0654
 	ICQDBQueryMetaReqSetICQPhone       uint16 = 0x0654
+	ICQDBQueryMetaReqSetFullInfo       uint16 = 0x0C3A
 	ICQDBQueryMetaReqShortInfo         uint16 = 0x04BA
 	ICQDBQueryMetaReqShortInfo         uint16 = 0x04BA
 	ICQDBQueryMetaReqFullInfo          uint16 = 0x04B2
 	ICQDBQueryMetaReqFullInfo          uint16 = 0x04B2
 	ICQDBQueryMetaReqFullInfo2         uint16 = 0x04D0
 	ICQDBQueryMetaReqFullInfo2         uint16 = 0x04D0
@@ -2047,6 +2048,7 @@ const (
 	ICQDBQueryMetaReplySetAffiliations uint16 = 0x0096
 	ICQDBQueryMetaReplySetAffiliations uint16 = 0x0096
 	ICQDBQueryMetaReplySetPermissions  uint16 = 0x00A0
 	ICQDBQueryMetaReplySetPermissions  uint16 = 0x00A0
 	ICQDBQueryMetaReplySetICQPhone     uint16 = 0x031E
 	ICQDBQueryMetaReplySetICQPhone     uint16 = 0x031E
+	ICQDBQueryMetaReplySetFullInfo     uint16 = 0x0C3F
 	ICQDBQueryMetaReplyBasicInfo       uint16 = 0x00C8
 	ICQDBQueryMetaReplyBasicInfo       uint16 = 0x00C8
 	ICQDBQueryMetaReplyWorkInfo        uint16 = 0x00D2
 	ICQDBQueryMetaReplyWorkInfo        uint16 = 0x00D2
 	ICQDBQueryMetaReplyMoreInfo        uint16 = 0x00DC
 	ICQDBQueryMetaReplyMoreInfo        uint16 = 0x00DC
@@ -2091,7 +2093,7 @@ type ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN struct {
 }
 }
 
 
 type ICQ_0x07D0_0x0569_DBQueryMetaReqSearchByUIN2 struct {
 type ICQ_0x07D0_0x0569_DBQueryMetaReqSearchByUIN2 struct {
-	TLVRestBlock
+	TLVRestBlock `oscar:"quirk=qip_2005_search_by_uin2"`
 }
 }
 
 
 type ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail struct {
 type ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail struct {
@@ -2148,6 +2150,17 @@ type ICQ_0x07D0_0x055F_DBQueryMetaReqSearchWhitePages2 struct {
 	TLVRestBlock
 	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 {
 type ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo struct {
 	Age          uint8 // not used because age is calculated from birthdate
 	Age          uint8 // not used because age is calculated from birthdate
 	Gender       uint16
 	Gender       uint16

+ 2 - 0
wire/snacs_string.go

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

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