Selaa lähdekoodia

icq: support directory queries (icq 6, miranda)

Thanks to the following resources:
- iserverd
- miranda IM
- github.com/fejdraus/icq-revival
Mike 1 päivä sitten
vanhempi
sitoutus
98f6483f0d

+ 324 - 5
foodgroup/icq.go

@@ -3,9 +3,12 @@ package foodgroup
 import (
 	"bytes"
 	"context"
+	"encoding/binary"
 	"errors"
 	"fmt"
+	"io"
 	"log/slog"
+	"math"
 	"strconv"
 	"strings"
 	"time"
@@ -68,6 +71,28 @@ func (s *ICQService) DeleteMsgReq(ctx context.Context, instance *state.SessionIn
 	return nil
 }
 
+func (s *ICQService) DirectoryQuery(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.ICQ_0x07D0_0x0FA0_DBQueryMetaReqDirectoryQuery, seq uint16) error {
+	req := wire.ICQ_0x07D0_0x0FA0_DBQueryMetaReqDirectoryBody{}
+	if err := wire.UnmarshalBE(&req, bytes.NewReader(inBody.Body)); err != nil {
+		return err
+	}
+
+	// QIP 2012 repeats the meta request subtype where the nested SNAC subtype
+	// belongs, so map it to the query info command.
+	if req.SubType == wire.ICQDBQueryMetaReqDirectoryQuery {
+		req.SubType = wire.ICQDirCmdQuery
+	}
+
+	switch req.SubType {
+	case wire.ICQDirCmdQuery:
+		return s.directoryQueryInfo(ctx, instance, inFrame, req, seq)
+	case wire.ICQDirCmdQueryMulti:
+		return s.directoryMultiInfo(ctx, instance, inFrame, req, seq)
+	default:
+		return s.directoryError(ctx, instance, inFrame.RequestID, seq, req.SubType, req.Versioned())
+	}
+}
+
 func (s *ICQService) FindByICQName(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, seq uint16) error {
 	res, err := s.userFinder.FindByICQName(ctx, inBody.FirstName, inBody.LastName, inBody.NickName)
 
@@ -287,7 +312,7 @@ func (s *ICQService) FindByWhitePages2(ctx context.Context, instance *state.Sess
 	if onlineOnly {
 		var filtered []state.User
 		for _, u := range users {
-			if s.sessionRetriever.RetrieveSession(u.IdentScreenName) != nil {
+			if s.searchStatus(u) == wire.ICQDirStatusOnline {
 				filtered = append(filtered, u)
 			}
 		}
@@ -1367,9 +1392,303 @@ func (s *ICQService) createResult(res state.User) wire.ICQUserSearchRecord {
 		searchRecord.Authorization = 1
 	}
 
-	userSess := s.sessionRetriever.RetrieveSession(res.IdentScreenName)
-	if userSess != nil {
-		searchRecord.OnlineStatus = 1
-	}
+	searchRecord.OnlineStatus = s.searchStatus(res)
 	return searchRecord
 }
+
+// searchStatus is the presence a search result reports for u. A user who does
+// not publish their web status shows as hidden, connected or not.
+func (s *ICQService) searchStatus(u state.User) uint16 {
+	switch {
+	case !u.ICQInfo.Permissions.WebAware:
+		return wire.ICQDirStatusHidden
+	case s.sessionRetriever.RetrieveSession(u.IdentScreenName) == nil:
+		return wire.ICQDirStatusOffline
+	default:
+		return wire.ICQDirStatusOnline
+	}
+}
+
+// directoryQueryInfo handles a directory search or a single profile lookup. Both
+// share a layout up to the block tag, which tells them apart; each block then
+// carries its own body.
+func (s *ICQService) directoryQueryInfo(ctx context.Context, instance *state.SessionInstance, frame wire.SNACFrame, req wire.ICQ_0x07D0_0x0FA0_DBQueryMetaReqDirectoryBody, seq uint16) error {
+	header := wire.ICQDirectoryQueryHeader{}
+	if req.Versioned() {
+		header.SetVersioned()
+	}
+	r := bytes.NewReader(req.Body)
+	if err := wire.UnmarshalBE(&header, r); err != nil {
+		return err
+	}
+
+	switch header.Block {
+	case wire.ICQDirBlockSearch:
+		return s.directorySearch(ctx, instance, frame, req, r, seq)
+	case wire.ICQDirBlockLookup:
+		return s.directoryInfo(ctx, instance, frame, req, r, seq)
+	default:
+		return s.directoryError(ctx, instance, frame.RequestID, seq, req.SubType, req.Versioned())
+	}
+}
+
+// directoryInfo handles a single profile lookup. Profiles are not served yet, so
+// the request is refused.
+func (s *ICQService) directoryInfo(ctx context.Context, instance *state.SessionInstance, frame wire.SNACFrame, req wire.ICQ_0x07D0_0x0FA0_DBQueryMetaReqDirectoryBody, r io.Reader, seq uint16) error {
+	query := wire.ICQDirectoryInfoQuery{}
+	if err := wire.UnmarshalBE(&query, r); err != nil {
+		return err
+	}
+
+	s.logger.DebugContext(ctx, "directory profile lookup refused", "requester", instance.UIN())
+	return s.directoryError(ctx, instance, frame.RequestID, seq, req.SubType, req.Versioned())
+}
+
+// directorySearch applies the conditions of a directory search and replies with
+// the matching users.
+func (s *ICQService) directorySearch(ctx context.Context, instance *state.SessionInstance, frame wire.SNACFrame, req wire.ICQ_0x07D0_0x0FA0_DBQueryMetaReqDirectoryBody, r io.Reader, seq uint16) error {
+	versioned := req.Versioned()
+
+	query := wire.ICQDirectorySearchQuery{}
+	if err := wire.UnmarshalBE(&query, r); err != nil {
+		return err
+	}
+
+	var c state.ICQUserSearchCriteria
+	var onlineOnly bool
+	for _, tlv := range query.TLVList {
+		if len(tlv.Value) == 0 {
+			continue
+		}
+		val := string(tlv.Value)
+		switch tlv.Tag {
+		case wire.ICQDirTLVTagsUID:
+			uin, err := strconv.ParseUint(strings.TrimRight(val, "\x00"), 10, 32)
+			if err != nil {
+				s.logger.DebugContext(ctx, "directory search refused, malformed UID", "requester", instance.UIN())
+				return s.directoryError(ctx, instance, frame.RequestID, seq, req.SubType, versioned)
+			}
+			c.UIN = new(uint32(uin))
+		case wire.ICQDirTLVTagsFirstName:
+			c.FirstName = new(val)
+		case wire.ICQDirTLVTagsLastName:
+			c.LastName = new(val)
+		case wire.ICQDirTLVTagsNickname:
+			c.NickName = new(val)
+		case wire.ICQDirTLVTagsVerifiedEmail:
+			c.Email = new(val)
+		case wire.ICQDirTLVTagsOnlineOnly:
+			onlineOnly = tlv.Value[len(tlv.Value)-1] != 0
+		case wire.ICQDirTLVTagsGender:
+			if v := tlv.Uint8(); v != 0 {
+				c.Gender = &v
+			}
+		case wire.ICQDirTLVTagsSearchLanguage:
+			if v := tlv.Uint16BE(); v != 0 {
+				c.SpokenLanguage = new(uint8(v))
+			}
+		case wire.ICQDirTLVTagsSearchCountry:
+			if v := tlv.Uint32BE(); v != 0 {
+				c.CountryCode = new(uint16(v))
+			}
+		case wire.ICQDirTLVTagsSearchAge:
+			var age struct {
+				Max uint16
+				Min uint16
+			}
+			if err := wire.UnmarshalBE(&age, bytes.NewReader(tlv.Value)); err != nil {
+				return s.directoryError(ctx, instance, frame.RequestID, seq, req.SubType, versioned)
+			}
+			if age.Min != 0 && age.Max != 0 {
+				c.MinAge, c.MaxAge = &age.Min, &age.Max
+			}
+		}
+	}
+
+	users, err := s.userFinder.SearchICQUsers(ctx, c)
+	if errors.Is(err, state.ErrICQSearchEmptyCriteria) {
+		// Nothing searchable was sent, so nothing matches.
+		users, err = nil, nil
+	}
+	if err != nil {
+		s.logger.ErrorContext(ctx, "directory search failed", "requester", instance.UIN(), "err", err.Error())
+		return s.directoryError(ctx, instance, frame.RequestID, seq, req.SubType, versioned)
+	}
+
+	type hit struct {
+		user   state.User
+		status uint16
+	}
+	hits := make([]hit, 0, len(users))
+	for _, u := range users {
+		status := s.searchStatus(u)
+		if onlineOnly && status != wire.ICQDirStatusOnline {
+			continue
+		}
+		hits = append(hits, hit{user: u, status: status})
+	}
+
+	if len(hits) == 0 {
+		// The client finishes its search when it receives a reply with no blocks.
+		return s.directoryReply(ctx, instance, frame.RequestID, seq, wire.ICQDirectoryReplyBody{
+			SubType:   wire.ICQDirCmdQueryAck,
+			Result:    wire.ICQDirResultOK,
+			Pages:     1,
+			Versioned: versioned,
+		})
+	}
+
+	total := uint32(len(hits))
+	for i, h := range hits {
+		u := h.user
+		last := i == len(hits)-1
+
+		tlvs := wire.TLVList{}
+		tlvs.Append(wire.NewTLVBE(wire.ICQDirTLVTagsUID, u.IdentScreenName.String()))
+
+		if u.ICQInfo.Basic.FirstName != "" {
+			tlvs.Append(wire.NewTLVBE(wire.ICQDirTLVTagsFirstName, u.ICQInfo.Basic.FirstName))
+		}
+		if u.ICQInfo.Basic.LastName != "" {
+			tlvs.Append(wire.NewTLVBE(wire.ICQDirTLVTagsLastName, u.ICQInfo.Basic.LastName))
+		}
+		if u.ICQInfo.Basic.Nickname != "" {
+			tlvs.Append(wire.NewTLVBE(wire.ICQDirTLVTagsNickname, u.ICQInfo.Basic.Nickname))
+		}
+		if u.ICQInfo.Basic.EmailAddress != "" {
+			tlvs.Append(wire.NewTLVBE(wire.ICQDirTLVTagsVerifiedEmail, u.ICQInfo.Basic.EmailAddress))
+		}
+		if u.ICQInfo.More.Gender != 0 {
+			tlvs.Append(wire.NewTLVBE(wire.ICQDirTLVTagsGender, uint8(u.ICQInfo.More.Gender)))
+		}
+		if days, ok := wire.ICQDirDate(u.ICQInfo.More.BirthYear, u.ICQInfo.More.BirthMonth, u.ICQInfo.More.BirthDay); ok {
+			// a birth date goes out as the day count in an 8-byte big-endian float
+			var date [8]byte
+			binary.BigEndian.PutUint64(date[:], math.Float64bits(days))
+			tlvs.Append(wire.NewTLVBE(wire.ICQDirTLVTagsBirthDate, date[:]))
+		}
+
+		authOptional := uint8(0)
+		if !u.ICQInfo.Permissions.AuthRequired {
+			authOptional = 1
+		}
+		tlvs.Append(wire.NewTLVBE(wire.ICQDirTLVTagsAuthRequired, authOptional))
+
+		if u.ICQInfo.Basic.CountryCode != 0 {
+			addr := wire.TLVList{}
+			addr.Append(wire.NewTLVBE(wire.ICQDirTLVTagsAddressCountry, uint32(u.ICQInfo.Basic.CountryCode)))
+			blob := &bytes.Buffer{}
+			if err := wire.MarshalBE(wire.TLVRestBlock{TLVList: addr}, blob); err != nil {
+				return err
+			}
+			tlvs.Append(wire.NewTLVBE(wire.ICQDirTLVTagsHomeAddress, blob.Bytes()))
+		}
+
+		tlvs.Append(wire.NewTLVBE(wire.ICQDirTLVTagsOnlineStatus, h.status))
+
+		err := s.directoryReply(ctx, instance, frame.RequestID, seq, wire.ICQDirectoryReplyBody{
+			SubType:   wire.ICQDirCmdQueryAck,
+			Result:    wire.ICQDirResultOK,
+			Item:      tlvs,
+			Total:     total,
+			Pages:     1,
+			More:      !last,
+			Versioned: versioned,
+		})
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// directoryMultiInfo handles a request for the profiles of several users at
+// once. Each item is a length-prefixed TLV list naming one user: the UID, the
+// time the client last saw the profile and optionally a privacy token. The
+// profiles are not served yet, so the request is refused.
+func (s *ICQService) directoryMultiInfo(ctx context.Context, instance *state.SessionInstance, frame wire.SNACFrame, req wire.ICQ_0x07D0_0x0FA0_DBQueryMetaReqDirectoryBody, seq uint16) error {
+	query := struct {
+		Extras *struct {
+			Body []byte `oscar:"len_prefix=uint16"`
+		} `oscar:"variant"`
+		Filler [8]byte
+		Items  []wire.TLVLBlock `oscar:"count_prefix=uint16"`
+	}{}
+	versioned := req.Versioned()
+	if versioned {
+		query.Extras = &struct {
+			Body []byte `oscar:"len_prefix=uint16"`
+		}{}
+	}
+	if err := wire.UnmarshalBE(&query, bytes.NewReader(req.Body)); err != nil {
+		return err
+	}
+
+	s.logger.DebugContext(ctx, "directory multi lookup refused", "requester", instance.UIN(), "items", len(query.Items))
+	return s.directoryError(ctx, instance, frame.RequestID, seq, wire.ICQDirCmdQueryMulti, versioned)
+}
+
+// directoryReply builds one directory packet from r and sends it. The client
+// reads a single result from each packet, so a result set goes out as several
+// packets. All but the last are flagged as more to come and use the "data" meta
+// subtype; the last uses "response", which ends the search.
+func (s *ICQService) directoryReply(ctx context.Context, instance *state.SessionInstance, requestID uint32, seq uint16, r wire.ICQDirectoryReplyBody) error {
+	// A hit is a single block; no hit means no block at all, not an empty one.
+	var items []wire.TLVLBlock
+	if len(r.Item) > 0 {
+		items = []wire.TLVLBlock{{TLVList: r.Item}}
+	}
+
+	header := wire.ICQDirectoryReply{
+		SNACFamily: wire.ICQDirNestedFamily,
+		SubType:    r.SubType,
+		Result:     r.Result,
+		Total:      r.Total,
+		BlockCount: uint16(len(items)),
+		Items:      items,
+	}
+	if r.More {
+		header.Flags |= 0x0001
+	}
+	if r.Versioned {
+		header.SetVersion1(r.Pages)
+	}
+
+	body := &bytes.Buffer{}
+	if err := wire.MarshalBE(header, body); err != nil {
+		return err
+	}
+
+	if body.Len() > math.MaxUint16 {
+		return errors.New("directory payload too long")
+	}
+
+	snacFlags := uint16(0)
+	replySubType := wire.ICQDBQueryMetaReplyDirectoryResponse
+	if r.More {
+		snacFlags = wire.SNACFlagsMoreToCome
+		replySubType = wire.ICQDBQueryMetaReplyDirectoryData
+	}
+
+	msg := wire.ICQMessageReplyEnvelope{
+		Message: wire.ICQ_0x07DA_0x0FAA_DBQueryMetaReplyDirectory{
+			ICQMetadata: wire.ICQMetadata{
+				UIN:     instance.UIN(),
+				ReqType: wire.ICQDBQueryMetaReply,
+				Seq:     seq,
+			},
+			ReqSubType: replySubType,
+			Success:    wire.ICQStatusCodeOK,
+			Payload:    body.Bytes(),
+		},
+	}
+	return s.reply(ctx, instance, msg, requestID, snacFlags)
+}
+
+// directoryError replies to a request with its subtype, result 0 and no data.
+func (s *ICQService) directoryError(ctx context.Context, instance *state.SessionInstance, requestID uint32, seq uint16, subType uint16, versioned bool) error {
+	return s.directoryReply(ctx, instance, requestID, seq, wire.ICQDirectoryReplyBody{
+		SubType:   subType,
+		Versioned: versioned,
+	})
+}

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 1628 - 0
foodgroup/icq_test.go


+ 8 - 0
server/oscar/handler.go

@@ -549,6 +549,14 @@ func (rt Handler) ICQDBQuery(ctx context.Context, instance *state.SessionInstanc
 				return nil
 			}
 			return rt.FullUserInfo(ctx, instance, inFrame, userInfo, icqMD.Seq)
+		case wire.ICQDBQueryMetaReqDirectoryQuery:
+			envelope := wire.ICQ_0x07D0_0x0FA0_DBQueryMetaReqDirectoryQuery{}
+			if err := wire.UnmarshalLE(&envelope, buf); err != nil {
+				return err
+			}
+			if err := rt.DirectoryQuery(ctx, instance, inFrame, envelope, icqMD.Seq); err != nil {
+				return err
+			}
 		case wire.ICQDBQueryMetaReqXMLReq:
 			req := wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq{}
 			if err := wire.UnmarshalLE(&req, buf); err != nil {

+ 75 - 0
server/oscar/mock_icq_service_test.go

@@ -111,6 +111,81 @@ func (_c *mockICQService_DeleteMsgReq_Call) RunAndReturn(run func(ctx context.Co
 	return _c
 }
 
+// DirectoryQuery provides a mock function for the type mockICQService
+func (_mock *mockICQService) DirectoryQuery(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.ICQ_0x07D0_0x0FA0_DBQueryMetaReqDirectoryQuery, seq uint16) error {
+	ret := _mock.Called(ctx, instance, inFrame, inBody, seq)
+
+	if len(ret) == 0 {
+		panic("no return value specified for DirectoryQuery")
+	}
+
+	var r0 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.ICQ_0x07D0_0x0FA0_DBQueryMetaReqDirectoryQuery, uint16) error); ok {
+		r0 = returnFunc(ctx, instance, inFrame, inBody, seq)
+	} else {
+		r0 = ret.Error(0)
+	}
+	return r0
+}
+
+// mockICQService_DirectoryQuery_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DirectoryQuery'
+type mockICQService_DirectoryQuery_Call struct {
+	*mock.Call
+}
+
+// DirectoryQuery is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
+//   - inBody wire.ICQ_0x07D0_0x0FA0_DBQueryMetaReqDirectoryQuery
+//   - seq uint16
+func (_e *mockICQService_Expecter) DirectoryQuery(ctx any, instance any, inFrame any, inBody any, seq any) *mockICQService_DirectoryQuery_Call {
+	return &mockICQService_DirectoryQuery_Call{Call: _e.mock.On("DirectoryQuery", ctx, instance, inFrame, inBody, seq)}
+}
+
+func (_c *mockICQService_DirectoryQuery_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.ICQ_0x07D0_0x0FA0_DBQueryMetaReqDirectoryQuery, seq uint16)) *mockICQService_DirectoryQuery_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 wire.SNACFrame
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		var arg3 wire.ICQ_0x07D0_0x0FA0_DBQueryMetaReqDirectoryQuery
+		if args[3] != nil {
+			arg3 = args[3].(wire.ICQ_0x07D0_0x0FA0_DBQueryMetaReqDirectoryQuery)
+		}
+		var arg4 uint16
+		if args[4] != nil {
+			arg4 = args[4].(uint16)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+			arg3,
+			arg4,
+		)
+	})
+	return _c
+}
+
+func (_c *mockICQService_DirectoryQuery_Call) Return(err error) *mockICQService_DirectoryQuery_Call {
+	_c.Call.Return(err)
+	return _c
+}
+
+func (_c *mockICQService_DirectoryQuery_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.ICQ_0x07D0_0x0FA0_DBQueryMetaReqDirectoryQuery, seq uint16) error) *mockICQService_DirectoryQuery_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // FindByEmail3 provides a mock function for the type mockICQService
 func (_mock *mockICQService) FindByEmail3(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3, seq uint16) error {
 	ret := _mock.Called(ctx, instance, inFrame, inBody, seq)

+ 1 - 0
server/oscar/types.go

@@ -138,6 +138,7 @@ type ICQService interface {
 	SetWorkInfo(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo, seq uint16) error
 	ShortUserInfo(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.ICQ_0x07D0_0x04BA_DBQueryMetaReqShortInfo, seq uint16) error
 	XMLReqData(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq, seq uint16) error
+	DirectoryQuery(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.ICQ_0x07D0_0x0FA0_DBQueryMetaReqDirectoryQuery, seq uint16) error
 }
 
 type LocateService interface {

+ 4 - 0
state/migrations/0045_icq_permissions_web_aware_default.down.sql

@@ -0,0 +1,4 @@
+ALTER TABLE users
+    DROP COLUMN icq_permissions_webAware;
+ALTER TABLE users
+    ADD COLUMN icq_permissions_webAware BOOLEAN NOT NULL DEFAULT false;

+ 4 - 0
state/migrations/0045_icq_permissions_web_aware_default.up.sql

@@ -0,0 +1,4 @@
+ALTER TABLE users
+    DROP COLUMN icq_permissions_webAware;
+ALTER TABLE users
+    ADD COLUMN icq_permissions_webAware BOOLEAN NOT NULL DEFAULT true;

+ 28 - 1
state/user_store_test.go

@@ -535,7 +535,7 @@ func TestGetUser(t *testing.T) {
 		RegStatus:         3,
 		LastWarnUpdate:    time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC), // Database default value
 		ICQInfo: ICQInfo{
-			Permissions: ICQPermissions{AuthRequired: true},
+			Permissions: ICQPermissions{AuthRequired: true, WebAware: true},
 		},
 	}
 	err = f.InsertUser(context.Background(), *insertedUser)
@@ -551,6 +551,33 @@ func TestGetUser(t *testing.T) {
 	}
 }
 
+// A new user publishes their web status unless they opt out later, whatever
+// the inserted struct says.
+func TestSQLiteUserStore_InsertUser_WebAwareDefault(t *testing.T) {
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	f, err := NewSQLiteUserStore(testFile)
+	require.NoError(t, err)
+
+	screenName := NewIdentScreenName("100001")
+	require.NoError(t, f.InsertUser(context.Background(), User{
+		IdentScreenName:   screenName,
+		DisplayScreenName: DisplayScreenName("100001"),
+		IsICQ:             true,
+	}))
+
+	got, err := f.User(context.Background(), screenName)
+	require.NoError(t, err)
+	assert.True(t, got.ICQInfo.Permissions.WebAware)
+
+	require.NoError(t, f.SetPermissions(context.Background(), screenName, ICQPermissions{WebAware: false}))
+	got, err = f.User(context.Background(), screenName)
+	require.NoError(t, err)
+	assert.False(t, got.ICQInfo.Permissions.WebAware, "opting out still works")
+}
+
 func TestSQLiteUserStore_User_OfflineMsgCount(t *testing.T) {
 	defer func() {
 		assert.NoError(t, os.Remove(testFile))

+ 21 - 5
wire/decode.go

@@ -38,6 +38,16 @@ func unmarshal(t reflect.Type, v reflect.Value, tag reflect.StructTag, r io.Read
 		effectiveQuirk = activeQuirk
 	}
 
+	if oscTag.variant {
+		if err := checkVariantType(t); err != nil {
+			return err
+		}
+		if v.IsNil() {
+			return nil // variant is not present
+		}
+		return unmarshalStruct(t.Elem(), v.Elem(), oscTag, r, order, effectiveQuirk)
+	}
+
 	if oscTag.optional {
 		v.Set(reflect.New(t.Elem()))
 		err := unmarshalStruct(t.Elem(), v.Elem(), oscTag, r, order, effectiveQuirk)
@@ -296,12 +306,18 @@ func unmarshalStruct(t reflect.Type, v reflect.Value, oscTag oscarTag, r io.Read
 		field := t.Field(i)
 		value := v.Field(i)
 		if field.Type.Kind() == reflect.Pointer {
-			if i != v.NumField()-1 {
-				return fmt.Errorf("pointer type found at non-final field %s", field.Name)
+			fieldTag, err := parseOSCARTag(field.Tag)
+			if err != nil {
+				return fmt.Errorf("error parsing tag: %w", err)
 			}
-			if field.Type.Elem().Kind() != reflect.Struct {
-				return fmt.Errorf("%w: field %s must point to a struct, got %v instead",
-					errNonOptionalPointer, field.Name, field.Type.Elem().Kind())
+			if !fieldTag.variant {
+				if i != v.NumField()-1 {
+					return fmt.Errorf("pointer type found at non-final field %s", field.Name)
+				}
+				if field.Type.Elem().Kind() != reflect.Struct {
+					return fmt.Errorf("%w: field %s must point to a struct, got %v instead",
+						errNonOptionalPointer, field.Name, field.Type.Elem().Kind())
+				}
 			}
 		}
 		if err := unmarshal(field.Type, value, field.Tag, r, order, activeQuirk); err != nil {

+ 121 - 0
wire/decode_test.go

@@ -694,6 +694,127 @@ func TestUnmarshal(t *testing.T) {
 			},
 			given: []byte{0x0, 0xa, 0x0, 0x2, 0x4, 0xd2, 0x0, 0x14, 0x0, 0x2, 0x4, 0xd2},
 		},
+		{
+			name: "variant struct pre-set is filled mid-struct",
+			prototype: &struct {
+				Val0    uint16
+				Variant *struct {
+					Val1 uint16
+				} `oscar:"variant"`
+				Val2 uint8
+			}{
+				Variant: &struct {
+					Val1 uint16
+				}{},
+			},
+			want: &struct {
+				Val0    uint16
+				Variant *struct {
+					Val1 uint16
+				} `oscar:"variant"`
+				Val2 uint8
+			}{
+				Val0: 34,
+				Variant: &struct {
+					Val1 uint16
+				}{
+					Val1: 100,
+				},
+				Val2: 7,
+			},
+			given: []byte{
+				0x00, 0x22, // Val0
+				0x00, 0x64, // Val1
+				0x07, // Val2
+			},
+		},
+		{
+			name: "variant struct nil is skipped and consumes nothing",
+			prototype: &struct {
+				Val0    uint16
+				Variant *struct {
+					Val1 uint16
+				} `oscar:"variant"`
+				Val2 uint8
+			}{},
+			want: &struct {
+				Val0    uint16
+				Variant *struct {
+					Val1 uint16
+				} `oscar:"variant"`
+				Val2 uint8
+			}{
+				Val0: 34,
+				Val2: 7,
+			},
+			given: []byte{
+				0x00, 0x22, // Val0
+				0x07, // Val2
+			},
+		},
+		{
+			name: "variant struct pre-set with uint16 len_prefix",
+			prototype: &struct {
+				Variant *struct {
+					Val1 uint16
+				} `oscar:"len_prefix=uint16,variant"`
+				Val2 uint8
+			}{
+				Variant: &struct {
+					Val1 uint16
+				}{},
+			},
+			want: &struct {
+				Variant *struct {
+					Val1 uint16
+				} `oscar:"len_prefix=uint16,variant"`
+				Val2 uint8
+			}{
+				Variant: &struct {
+					Val1 uint16
+				}{
+					Val1: 100,
+				},
+				Val2: 7,
+			},
+			given: []byte{
+				0x00, 0x02, // Variant len
+				0x00, 0x64, // Val1
+				0x07, // Val2
+			},
+		},
+		{
+			name: "variant struct pre-set with read error",
+			prototype: &struct {
+				Variant *struct {
+					Val1 uint16
+				} `oscar:"variant"`
+			}{
+				Variant: &struct {
+					Val1 uint16
+				}{},
+			},
+			wantErr: io.ErrUnexpectedEOF,
+			given:   []byte{0x00},
+		},
+		{
+			name: "variant field that isn't a pointer to a struct is unsupported",
+			prototype: &struct {
+				Variant *uint16 `oscar:"variant"`
+			}{},
+			wantErr: ErrUnmarshalFailure,
+			given:   []byte{0x00, 0x22},
+		},
+		{
+			name: "variant and optional on the same field is unsupported",
+			prototype: &struct {
+				Variant *struct {
+					Val1 uint16
+				} `oscar:"variant,optional"`
+			}{},
+			wantErr: ErrUnmarshalFailure,
+			given:   []byte{0x00, 0x22},
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {

+ 38 - 5
wire/encode.go

@@ -15,6 +15,7 @@ var (
 	errMarshalFailureNilSNAC = errors.New("attempting to marshal a nil SNAC")
 	errNonOptionalPointer    = errors.New("pointer fields must reference structs and have an `optional` struct tag")
 	errOptionalNonPointer    = errors.New("optional fields must be pointers")
+	errVariantNonPointer     = errors.New("variant fields must be pointers to structs")
 	errInvalidStructTag      = errors.New("invalid struct tag")
 )
 
@@ -44,6 +45,16 @@ func marshal(t reflect.Type, v reflect.Value, tag reflect.StructTag, w io.Writer
 		return err
 	}
 
+	if oscTag.variant {
+		if err := checkVariantType(t); err != nil {
+			return err
+		}
+		if v.IsNil() {
+			return nil // variant is not present
+		}
+		return marshalStruct(t.Elem(), v.Elem(), oscTag, w, order)
+	}
+
 	if oscTag.optional {
 		if t.Kind() != reflect.Pointer {
 			return fmt.Errorf("%w: got %v", errOptionalNonPointer, t.Kind())
@@ -75,6 +86,14 @@ func marshal(t reflect.Type, v reflect.Value, tag reflect.StructTag, w io.Writer
 	}
 }
 
+// checkVariantType verifies that a variant field is a pointer to a struct.
+func checkVariantType(t reflect.Type) error {
+	if t.Kind() != reflect.Pointer || t.Elem().Kind() != reflect.Struct {
+		return fmt.Errorf("%w: got %v", errVariantNonPointer, t)
+	}
+	return nil
+}
+
 func marshalInterface(v reflect.Value, w io.Writer, tag oscarTag, order binary.ByteOrder) error {
 	elem := v.Elem()
 	if elem.Kind() != reflect.Struct {
@@ -157,12 +176,18 @@ func marshalStruct(t reflect.Type, v reflect.Value, oscTag oscarTag, w io.Writer
 			field := t.Field(i)
 			value := v.Field(i)
 			if field.Type.Kind() == reflect.Pointer {
-				if i != t.NumField()-1 {
-					return fmt.Errorf("pointer type found at non-final field %s", field.Name)
+				fieldTag, err := parseOSCARTag(field.Tag)
+				if err != nil {
+					return err
 				}
-				if field.Type.Elem().Kind() != reflect.Struct {
-					return fmt.Errorf("field %s must point to a struct, got %v instead", field.Name,
-						field.Type.Elem().Kind())
+				if !fieldTag.variant {
+					if i != t.NumField()-1 {
+						return fmt.Errorf("pointer type found at non-final field %s", field.Name)
+					}
+					if field.Type.Elem().Kind() != reflect.Struct {
+						return fmt.Errorf("field %s must point to a struct, got %v instead", field.Name,
+							field.Type.Elem().Kind())
+					}
 				}
 			}
 			if err := marshal(field.Type, value, field.Tag, w, order); err != nil {
@@ -212,6 +237,9 @@ type oscarTag struct {
 	hasLenPrefix   bool
 	lenPrefix      reflect.Kind
 	optional       bool
+	// variant marks a pointer-to-struct field that is processed only if the
+	// pointer is non-nil, at any position in the struct.
+	variant        bool
 	nullTerminated bool
 	// quirk names an unmarshal-only decode exception
 	quirk string
@@ -273,6 +301,8 @@ func parseOSCARTag(tag reflect.StructTag) (oscarTag, error) {
 			switch key {
 			case "optional":
 				oscTag.optional = true
+			case "variant":
+				oscTag.variant = true
 			case "nullterm":
 				oscTag.nullTerminated = true
 			default:
@@ -286,5 +316,8 @@ func parseOSCARTag(tag reflect.StructTag) (oscarTag, error) {
 	if oscTag.hasCountPrefix && oscTag.hasLenPrefix {
 		err = fmt.Errorf("%w: struct elem has both len_prefix and count_prefix", errInvalidStructTag)
 	}
+	if oscTag.variant && oscTag.optional {
+		err = fmt.Errorf("%w: struct elem has both variant and optional", errInvalidStructTag)
+	}
 	return oscTag, err
 }

+ 121 - 0
wire/encode_test.go

@@ -749,6 +749,127 @@ func TestMarshal(t *testing.T) {
 			}{V: 1},
 			wantErr: errInvalidStructTag,
 		},
+		{
+			name: "variant struct has value mid-struct",
+			w:    &bytes.Buffer{},
+			given: struct {
+				Val0    uint16
+				Variant *struct {
+					Val1 uint16
+				} `oscar:"variant"`
+				Val2 uint8
+			}{
+				Val0: 34,
+				Variant: &struct {
+					Val1 uint16
+				}{
+					Val1: 100,
+				},
+				Val2: 7,
+			},
+			want: []byte{
+				0x00, 0x22, // Val0
+				0x00, 0x64, // Val1
+				0x07, // Val2
+			},
+		},
+		{
+			name: "variant struct is nil mid-struct",
+			w:    &bytes.Buffer{},
+			given: struct {
+				Val0    uint16
+				Variant *struct {
+					Val1 uint16
+				} `oscar:"variant"`
+				Val2 uint8
+			}{
+				Val0: 34,
+				Val2: 7,
+			},
+			want: []byte{
+				0x00, 0x22, // Val0
+				0x07, // Val2
+			},
+		},
+		{
+			name: "multiple variant structs",
+			w:    &bytes.Buffer{},
+			given: struct {
+				Val0 uint8
+				A    *struct{ Val uint8 } `oscar:"variant"`
+				Val1 uint8
+				B    *struct{ Val uint8 } `oscar:"variant"`
+			}{
+				Val0: 1,
+				A:    &struct{ Val uint8 }{Val: 2},
+				Val1: 3,
+				B:    &struct{ Val uint8 }{Val: 4},
+			},
+			want: []byte{0x01, 0x02, 0x03, 0x04},
+		},
+		{
+			name: "variant struct with uint16 len_prefix and value",
+			w:    &bytes.Buffer{},
+			given: struct {
+				Variant *struct {
+					Val1 uint16
+				} `oscar:"len_prefix=uint16,variant"`
+				Val2 uint8
+			}{
+				Variant: &struct {
+					Val1 uint16
+				}{
+					Val1: 100,
+				},
+				Val2: 7,
+			},
+			want: []byte{
+				0x00, 0x02, // Variant len
+				0x00, 0x64, // Val1
+				0x07, // Val2
+			},
+		},
+		{
+			name: "variant struct with uint16 len_prefix and no value",
+			w:    &bytes.Buffer{},
+			given: struct {
+				Variant *struct {
+					Val1 uint16
+				} `oscar:"len_prefix=uint16,variant"`
+				Val2 uint8
+			}{
+				Val2: 7,
+			},
+			want: []byte{0x07},
+		},
+		{
+			name: "variant field that isn't a pointer is unsupported",
+			w:    &bytes.Buffer{},
+			given: struct {
+				Variant struct {
+					Val1 uint16
+				} `oscar:"variant"`
+			}{},
+			wantErr: errVariantNonPointer,
+		},
+		{
+			name: "variant field that isn't a pointer to a struct is unsupported",
+			w:    &bytes.Buffer{},
+			given: struct {
+				Variant *uint16 `oscar:"variant"`
+			}{},
+			wantErr: errVariantNonPointer,
+		},
+		{
+			name: "variant and optional on the same field is unsupported",
+			w:    &bytes.Buffer{},
+			given: struct {
+				Variant *struct {
+					Val1 uint16
+				} `oscar:"variant,optional"`
+			}{},
+			wantErr: errInvalidStructTag,
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {

+ 177 - 0
wire/snacs.go

@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"strconv"
 	"strings"
+	"time"
 	"unicode"
 
 	"github.com/google/uuid"
@@ -2069,6 +2070,7 @@ const (
 	ICQTLVTagsOriginallyFromState       uint16 = 0x032A // User originally from state
 	ICQTLVTagsOriginallyFromCountryCode uint16 = 0x0334 // User originally from country (code)
 
+	ICQDirTLVTagsUID            uint16 = 0x0032 // User ID, the UIN as a decimal string
 	ICQDirTLVTagsPrivacyToken   uint16 = 0x003C // Privacy token (16 bytes)
 	ICQDirTLVTagsVerifiedEmail  uint16 = 0x0050 // Verified email address
 	ICQDirTLVTagsPendingEmail   uint16 = 0x0055 // Pending email address
@@ -2077,6 +2079,7 @@ const (
 	ICQDirTLVTagsNickname       uint16 = 0x0078 // Nickname
 	ICQDirTLVTagsGender         uint16 = 0x0082 // Gender (1=Female, 2=Male)
 	ICQDirTLVTagsEmailAddresses uint16 = 0x008C // Email addresses (record list)
+	ICQDirTLVTagsAddressCountry uint16 = 0x008C // Country inside an address record
 	ICQDirTLVTagsHomeAddress    uint16 = 0x0096 // Home address
 	ICQDirTLVTagsOriginAddress  uint16 = 0x00A0 // Origin/birth address
 	ICQDirTLVTagsLanguage1      uint16 = 0x00AA // Primary language
@@ -2098,6 +2101,18 @@ const (
 	ICQDirTLVTagsPrivacyLevel   uint16 = 0x01F9 // Privacy level
 	ICQDirTLVTagsWebAware       uint16 = 0x0212 // Web aware flag (online visibility via web)
 	ICQDirTLVTagsStatusNote     uint16 = 0x0226 // Status note/message
+	ICQDirTLVTagsOnlineStatus   uint16 = 0x0190 // Online status in a search hit (0=offline, 1=online, 2=hidden)
+	ICQDirTLVTagsOnlineOnly     uint16 = 0x0136 // Online users only (search request filter)
+
+	// Tags that appear only in a search request. They do not match the profile
+	// tags of the same number.
+	ICQDirTLVTagsSearchCountry  uint16 = 0x00BE // Country code (4 bytes)
+	ICQDirTLVTagsSearchLanguage uint16 = 0x00FA // Spoken language code (2 bytes)
+	ICQDirTLVTagsSearchAge      uint16 = 0x0154 // Age range (2 words)
+
+	ICQDirStatusOffline uint16 = 0
+	ICQDirStatusOnline  uint16 = 1
+	ICQDirStatusHidden  uint16 = 2
 
 	ICQStatusCodeOK   uint8 = 0x0A
 	ICQStatusCodeFail uint8 = 0x32
@@ -2111,6 +2126,23 @@ const (
 	ICQDBQueryMetaReply           uint16 = 0x07DA
 )
 
+// directoryEpoch is the day that directory dates are counted from.
+var directoryEpoch = time.Date(1900, time.January, 1, 0, 0, 0, 0, time.UTC)
+
+// ICQDirDate returns how many days lie between 1900-01-01 and the given date,
+// which is how a directory hit carries a birth date. ok is false if the year,
+// month or day is unset, or if the date falls before 1900.
+func ICQDirDate(year uint16, month uint8, day uint8) (days float64, ok bool) {
+	if year == 0 || month == 0 || day == 0 {
+		return 0, false
+	}
+	date := time.Date(int(year), time.Month(month), int(day), 0, 0, 0, 0, time.UTC)
+	if date.Before(directoryEpoch) {
+		return 0, false
+	}
+	return date.Sub(directoryEpoch).Hours() / 24, true
+}
+
 const (
 	ICQUserFlagPublishEmailYes uint8 = 0
 	ICQUserFlagPublishEmailNo  uint8 = 1
@@ -2351,6 +2383,151 @@ type SNAC_0x15_0x02_DBReply struct {
 	TLVRestBlock
 }
 
+// The directory protocol travels as a SNAC of its own inside the payload of the
+// meta request and reply.
+const (
+	ICQDirNestedFamily  uint16 = 0x05B9
+	ICQDirNestedVersion uint16 = 0x0002 // the value of the version TLV
+)
+
+// Commands of the nested SNAC, carried in its subtype field.
+const (
+	ICQDirCmdQuery      uint16 = 0x0002 // a search or one profile lookup
+	ICQDirCmdQueryMulti uint16 = 0x0006 // profile lookups for several users
+	ICQDirCmdQueryAck   uint16 = 0x0009 // the answer to a query
+)
+
+// Block tags. A query body begins with one, and it is the only thing that
+// separates a search from a profile lookup, since both use ICQDirCmdQuery.
+const (
+	ICQDirBlockSearch uint16 = 0x0002
+	ICQDirBlockLookup uint16 = 0x0003
+)
+
+// Subtypes of the meta reply that wraps a directory packet, and the result byte
+// inside the packet.
+const (
+	ICQDBQueryMetaReplyDirectoryData     uint16 = 0x0FAA
+	ICQDBQueryMetaReplyDirectoryResponse uint16 = 0x0FB4
+
+	ICQDirResultOK uint8 = 0x01
+)
+
+type ICQ_0x07D0_0x0FA0_DBQueryMetaReqDirectoryQuery struct {
+	Body []byte `oscar:"len_prefix=uint16"`
+}
+
+type ICQ_0x07D0_0x0FA0_DBQueryMetaReqDirectoryBody struct {
+	Family  uint16
+	SubType uint16
+	Flags   uint16
+	Seq     uint16
+	Command uint16
+	Body    []byte
+}
+
+// Versioned reports whether the client sent the version block, which the reply
+// has to mirror.
+func (b ICQ_0x07D0_0x0FA0_DBQueryMetaReqDirectoryBody) Versioned() bool {
+	return b.Flags&0x8000 != 0
+}
+
+// ICQDirectorySearchQuery is what a directory search carries after its block
+// tag: a page number, how many conditions follow, and the conditions as TLVs.
+type ICQDirectorySearchQuery struct {
+	Page  uint16
+	Count uint16
+	TLVLBlock
+}
+
+// ICQDirectoryInfoQuery is what a profile lookup carries after its block tag:
+// a count and the TLVs that name the user.
+type ICQDirectoryInfoQuery struct {
+	Count uint32
+	TLVLBlock
+}
+
+// ICQDirectoryReplyBody holds the values that go into one directory reply
+// packet. Leave Item empty to send a packet with no result block at all.
+// Versioned adds the extra fields that only version-block clients expect.
+type ICQDirectoryReplyBody struct {
+	SubType   uint16
+	Result    uint8
+	Item      TLVList
+	Total     uint32
+	Pages     uint16
+	More      bool
+	Versioned bool
+}
+
+type ICQ_0x07DA_0x0FAA_DBQueryMetaReplyDirectory struct {
+	ICQMetadata
+	ReqSubType uint16
+	Success    uint8
+	Payload    []byte `oscar:"len_prefix=uint16"`
+}
+
+// ICQDirectoryQueryHeader is the first part of a directory query body after the
+// nested SNAC header: the optional version extras, a filler, and the block tag.
+// The extras exist only for clients that send the version block; SetVersioned
+// turns them on.
+type ICQDirectoryQueryHeader struct {
+	Extras *struct {
+		Body []byte `oscar:"len_prefix=uint16"`
+	} `oscar:"variant"`
+	Filler [8]byte
+	Block  uint16
+}
+
+// SetVersioned makes the header read the extras that clients sending the
+// version block put before the filler.
+func (h *ICQDirectoryQueryHeader) SetVersioned() {
+	h.Extras = &struct {
+		Body []byte `oscar:"len_prefix=uint16"`
+	}{}
+}
+
+// ICQDirectoryReply is the nested directory SNAC carried in the payload of a
+// meta reply. The variant fields are filled in by SetVersion1 for clients that
+// sent the version block, and Items holds either one result or nothing.
+type ICQDirectoryReply struct {
+	SNACFamily uint16
+	SubType    uint16
+	Flags      uint16
+	Seq        uint16
+	Command    uint16
+	Extras     *TLVLBlock `oscar:"variant"`
+	Result     uint8
+	ErrMsg     string `oscar:"len_prefix=uint16"`
+	Void       [10]byte
+	Void2      *struct {
+		Void [6]byte
+	} `oscar:"variant"`
+	Total uint32
+	Pages *struct {
+		Pages uint16
+	} `oscar:"variant"`
+	BlockCount uint16
+	Items      []TLVLBlock
+}
+
+// SetVersion1 fills in the variant fields that clients sending the version
+// block expect.
+func (r *ICQDirectoryReply) SetVersion1(pages uint16) {
+	r.Flags |= 0x8000
+	r.Extras = &TLVLBlock{
+		TLVList: TLVList{
+			NewTLVBE(1, ICQDirNestedVersion),
+		},
+	}
+	r.Void2 = &struct {
+		Void [6]byte
+	}{}
+	r.Pages = &struct {
+		Pages uint16
+	}{Pages: pages}
+}
+
 type ICQ_0x07DA_0x010E_DBQueryMetaReplyHomePageCat struct {
 	ICQMetadata
 	ReqSubType uint16

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä