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

wire up auth flows b/t feedbag and non-feedbag clients

pre-authorization request still fails (make sure the box is unchecked).
to fix in next commit.
Mike 3 месяцев назад
Родитель
Сommit
267b09e540
13 измененных файлов с 1030 добавлено и 228 удалено
  1. 44 37
      AGENTS.md
  2. 19 24
      cmd/server/factory.go
  3. 149 69
      foodgroup/feedbag.go
  4. 153 66
      foodgroup/feedbag_test.go
  5. 5 0
      foodgroup/helpers_test.go
  6. 128 0
      foodgroup/icbm.go
  7. 343 0
      foodgroup/icbm_test.go
  8. 27 6
      foodgroup/icq.go
  9. 101 22
      foodgroup/icq_test.go
  10. 16 0
      state/session.go
  11. 10 4
      state/session_manager.go
  12. 20 0
      state/session_test.go
  13. 15 0
      wire/snacs.go

+ 44 - 37
AGENTS.md

@@ -9,28 +9,28 @@ AOL/Yahoo and non-commercial.
 
 ## Quick commands
 
-| Task | Command |
-|------|---------|
-| Build | `go build -o open_oscar_server ./cmd/server` |
-| Test | `go test -race ./...` |
-| Lint (matches CI) | `gofmt -s -l . && go vet ./...` |
-| Run (dev, plain) | `make run` |
-| Run (dev, SSL) | `make run-ssl` (+ `make run-stunnel` in a second terminal) |
-| Generate config | `make config` |
-| Regenerate mocks | `mockery` |
-| Build Docker images | `make docker-images` |
+| Task                | Command                                                    |
+|---------------------|------------------------------------------------------------|
+| Build               | `go build -o open_oscar_server ./cmd/server`               |
+| Test                | `go test -race ./...`                                      |
+| Lint (matches CI)   | `gofmt -s -l . && go vet ./...`                            |
+| Run (dev, plain)    | `make run`                                                 |
+| Run (dev, SSL)      | `make run-ssl` (+ `make run-stunnel` in a second terminal) |
+| Generate config     | `make config`                                              |
+| Regenerate mocks    | `mockery`                                                  |
+| Build Docker images | `make docker-images`                                       |
 
 ## Architecture
 
 The binary in `cmd/server` starts five servers concurrently via `errgroup`:
 
-| Server | Protocol | Default port |
-|--------|----------|-------------|
-| OSCAR | FLAP/BOS (binary) | 5190 (5193 via stunnel for SSL) |
-| TOC | TOC (text-based) | 9898 |
-| Kerberos | Kerberos auth | 1088 |
-| MgmtAPI | HTTP (management) | 8080 |
-| WebAPI | HTTP (web AIM-style, AMF3) | 9000 (opt-in via `ENABLE_WEBAPI=1`) |
+| Server   | Protocol                   | Default port                        |
+|----------|----------------------------|-------------------------------------|
+| OSCAR    | FLAP/BOS (binary)          | 5190 (5193 via stunnel for SSL)     |
+| TOC      | TOC (text-based)           | 9898                                |
+| Kerberos | Kerberos auth              | 1088                                |
+| MgmtAPI  | HTTP (management)          | 8080                                |
+| WebAPI   | HTTP (web AIM-style, AMF3) | 9000 (opt-in via `ENABLE_WEBAPI=1`) |
 
 All five servers share a common dependency container (`Container` in
 `cmd/server/factory.go`) that wires together config, persistence, and business
@@ -38,18 +38,18 @@ logic.
 
 ## Key packages
 
-| Package | Role |
-|---------|------|
-| `cmd/server` | Entry point; wires dependencies and starts all servers. |
-| `config` | Configuration via env vars (`envconfig`). Config files are generated from the `Config` struct—do **not** edit them by hand; run `make config` instead. |
-| `foodgroup` | Core business logic for OSCAR "food groups": Auth, Buddy, Feedbag, ICBM, Chat, BART, Locate, OService, ICQ, Admin, PermitDeny, ODir, Stats, UserLookup, ChatNav. Shared by the OSCAR, TOC, and WebAPI servers. |
-| `wire` | OSCAR wire protocol: SNAC/FLAP encoding, TLV, food group codes, rate limits, frames. |
-| `state` | Persistence and in-memory state: `SQLiteUserStore`, `InMemorySessionManager`, `InMemoryChatSessionManager`, DB migrations. |
-| `server/oscar` | OSCAR protocol server; SNAC routing and handler wiring. |
-| `server/toc` | TOC protocol server (text-based). |
-| `server/kerberos` | Kerberos auth server. |
-| `server/http` | Management HTTP API (users, sessions, chat rooms). Spec: `api.yml`. |
-| `server/webapi` | Web AIM-style API (AMF3). Spec: `docs/open_api/webapi.yml`. |
+| Package           | Role                                                                                                                                                                                                           |
+|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `cmd/server`      | Entry point; wires dependencies and starts all servers.                                                                                                                                                        |
+| `config`          | Configuration via env vars (`envconfig`). Config files are generated from the `Config` struct—do **not** edit them by hand; run `make config` instead.                                                         |
+| `foodgroup`       | Core business logic for OSCAR "food groups": Auth, Buddy, Feedbag, ICBM, Chat, BART, Locate, OService, ICQ, Admin, PermitDeny, ODir, Stats, UserLookup, ChatNav. Shared by the OSCAR, TOC, and WebAPI servers. |
+| `wire`            | OSCAR wire protocol: SNAC/FLAP encoding, TLV, food group codes, rate limits, frames.                                                                                                                           |
+| `state`           | Persistence and in-memory state: `SQLiteUserStore`, `InMemorySessionManager`, `InMemoryChatSessionManager`, DB migrations.                                                                                     |
+| `server/oscar`    | OSCAR protocol server; SNAC routing and handler wiring.                                                                                                                                                        |
+| `server/toc`      | TOC protocol server (text-based).                                                                                                                                                                              |
+| `server/kerberos` | Kerberos auth server.                                                                                                                                                                                          |
+| `server/http`     | Management HTTP API (users, sessions, chat rooms). Spec: `api.yml`.                                                                                                                                            |
+| `server/webapi`   | Web AIM-style API (AMF3). Spec: `docs/open_api/webapi.yml`.                                                                                                                                                    |
 
 ## Database
 
@@ -97,13 +97,20 @@ other sources.
 
 ## Useful docs
 
-| Document | Path |
-|----------|------|
-| Build & run | `docs/BUILD.md` |
-| Management API spec | `api.yml` |
-| Web API spec | `docs/open_api/webapi.yml` |
-| OSCAR protocol spec | https://devinsmith.net/backups/OSCAR/ |
+| Document            | Path                                                         |
+|---------------------|--------------------------------------------------------------|
+| Build & run         | `docs/BUILD.md`                                              |
+| Management API spec | `api.yml`                                                    |
+| Web API spec        | `docs/open_api/webapi.yml`                                   |
+| OSCAR protocol spec | https://devinsmith.net/backups/OSCAR/                        |
 | Client setup guides | `docs/CLIENT.md`, `docs/CLIENT_TIK.md`, `docs/CLIENT_ICQ.md` |
-| Docker guide | `docs/DOCKER.md` |
-| Platform guides | `docs/LINUX.md`, `docs/MACOS.md`, `docs/WINDOWS.md` |
+| Docker guide        | `docs/DOCKER.md`                                             |
+| Platform guides     | `docs/LINUX.md`, `docs/MACOS.md`, `docs/WINDOWS.md`          |
 
+## Go guidelines
+
+- **Injected dependencies are non-nil** — Types that the server factory wires up receive dependency fields through their
+  constructors; those fields are non-nil before the value is used. Call `dep.Method()` directly. Do not add
+  `if dep != nil` guards around those calls; that implies an optional dependency and duplicates an invariant already
+  enforced by construction. (Optional pointers, slices, maps, and values from external input still need normal nil/empty
+  checks.)

+ 19 - 24
cmd/server/factory.go

@@ -40,6 +40,7 @@ type Container struct {
 	sqLiteUserStore        *state.SQLiteUserStore
 	webAPISessionManager   *state.WebAPISessionManager
 	Listeners              []config.Listener
+	feedbagSvc             foodgroup.FeedbagService
 }
 
 // MakeCommonDeps creates common dependencies used by the food group services.
@@ -81,7 +82,6 @@ func MakeCommonDeps() (Container, error) {
 	c.rateLimitClasses = wire.DefaultRateLimitClasses()
 	c.snacRateLimits = wire.DefaultSNACRateLimits()
 
-	// ICBM svc is a common dep because OSCAR and TOC need to share convo history state.
 	c.icbmSvc = foodgroup.NewICBMService(
 		c.sqLiteUserStore,
 		c.inMemorySessionManager,
@@ -94,6 +94,17 @@ func MakeCommonDeps() (Container, error) {
 		c.logger,
 	)
 
+	c.feedbagSvc = foodgroup.NewFeedbagService(
+		c.logger,
+		c.inMemorySessionManager,
+		c.sqLiteUserStore,
+		c.sqLiteUserStore,
+		c.sqLiteUserStore,
+		c.inMemorySessionManager,
+		c.sqLiteUserStore,
+		c.icbmSvc.ChannelMsgToHost,
+	)
+
 	return c, nil
 }
 
@@ -257,15 +268,6 @@ func OSCAR(deps Container) *oscar.Server {
 	)
 	chatService := foodgroup.NewChatService(deps.chatSessionManager)
 	chatNavService := foodgroup.NewChatNavService(logger, deps.sqLiteUserStore)
-	feedbagService := foodgroup.NewFeedbagService(
-		logger,
-		deps.inMemorySessionManager,
-		deps.sqLiteUserStore,
-		deps.sqLiteUserStore,
-		deps.sqLiteUserStore,
-		deps.inMemorySessionManager,
-		deps.sqLiteUserStore,
-	)
 	permitDenyService := foodgroup.NewPermitDenyService(
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
@@ -280,6 +282,7 @@ func OSCAR(deps Container) *oscar.Server {
 		logger,
 		deps.inMemorySessionManager,
 		deps.sqLiteUserStore,
+		deps.icbmSvc.ChannelMsgToHost,
 	)
 	locateService := foodgroup.NewLocateService(
 		deps.sqLiteUserStore,
@@ -324,7 +327,7 @@ func OSCAR(deps Container) *oscar.Server {
 			BuddyService:      buddyService,
 			ChatNavService:    chatNavService,
 			ChatService:       chatService,
-			FeedbagService:    feedbagService,
+			FeedbagService:    deps.feedbagSvc,
 			ICBMService:       deps.icbmSvc,
 			ICQService:        icqService,
 			LocateService:     locateService,
@@ -477,19 +480,11 @@ func TOC(deps Container) *toc.Server {
 				deps.inMemorySessionManager,
 				deps.inMemorySessionManager,
 			),
-			TOCConfigStore: deps.sqLiteUserStore,
-			ChatService:    foodgroup.NewChatService(deps.chatSessionManager),
-			ChatNavService: foodgroup.NewChatNavService(logger, deps.sqLiteUserStore),
-			FeedbagManager: deps.sqLiteUserStore,
-			FeedbagService: foodgroup.NewFeedbagService(
-				logger,
-				deps.inMemorySessionManager,
-				deps.sqLiteUserStore,
-				deps.sqLiteUserStore,
-				deps.sqLiteUserStore,
-				deps.inMemorySessionManager,
-				deps.sqLiteUserStore,
-			),
+			TOCConfigStore:    deps.sqLiteUserStore,
+			ChatService:       foodgroup.NewChatService(deps.chatSessionManager),
+			ChatNavService:    foodgroup.NewChatNavService(logger, deps.sqLiteUserStore),
+			FeedbagManager:    deps.sqLiteUserStore,
+			FeedbagService:    deps.feedbagSvc,
 			SNACRateLimits:    deps.snacRateLimits,
 			HTTPIPRateLimiter: toc.NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
 			SessionRetriever:  deps.inMemorySessionManager,

+ 149 - 69
foodgroup/feedbag.go

@@ -21,7 +21,8 @@ func NewFeedbagService(
 	bartItemManager BARTItemManager,
 	relationshipFetcher RelationshipFetcher,
 	sessionRetriever SessionRetriever,
-	userFinder ICQUserFinder,
+	userManager UserManager,
+	icbmSender func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error),
 ) FeedbagService {
 	return FeedbagService{
 		bartItemManager:  bartItemManager,
@@ -29,7 +30,9 @@ func NewFeedbagService(
 		feedbagManager:   feedbagManager,
 		logger:           logger,
 		messageRelayer:   messageRelayer,
-		userFinder:       userFinder,
+		sessionRetriever: sessionRetriever,
+		userManager:      userManager,
+		icbmSender:       icbmSender,
 	}
 }
 
@@ -41,7 +44,9 @@ type FeedbagService struct {
 	feedbagManager   FeedbagManager
 	logger           *slog.Logger
 	messageRelayer   MessageRelayer
-	userFinder       ICQUserFinder
+	sessionRetriever SessionRetriever
+	userManager      UserManager
+	icbmSender       func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)
 }
 
 // RightsQuery returns SNAC wire.FeedbagRightsReply, which contains Feedbag
@@ -199,56 +204,44 @@ func (s FeedbagService) UpsertItem(ctx context.Context, instance *state.SessionI
 	// Check which buddy items require authorization. Items that need auth
 	// are NOT inserted into the feedbag — the client must retry with the
 	// FeedbagAttributesPending flag after receiving error 0x000E.
-	authRequired := make(map[int]bool)
-	if s.userFinder != nil {
-		for i, item := range items {
-			if item.ClassID == wire.FeedbagClassIdBuddy && !item.HasTag(wire.FeedbagAttributesPending) {
-				uin, err := strconv.ParseUint(item.Name, 10, 32)
-				if err == nil {
-					if user, findErr := s.userFinder.FindByUIN(ctx, uint32(uin)); findErr == nil && user.ICQPermissions.AuthRequired {
-						authRequired[i] = true
-					}
-				}
+	var toUpsert []wire.FeedbagItem
+	authRequired := make(map[string]bool)
+
+	for _, item := range items {
+		if item.ClassID == wire.FeedbagClassIdBuddy && !item.HasTag(wire.FeedbagAttributesPending) {
+			sn := state.NewIdentScreenName(item.Name)
+			if sn.UIN() == 0 {
+				continue
+			}
+			user, err := s.userManager.User(ctx, sn)
+			if err != nil {
+				return nil, fmt.Errorf("userManager.User: %w", err)
+			}
+			if user == nil {
+				s.logger.DebugContext(ctx, "user not found", "name", item.Name)
+				continue
+			}
+			if user.ICQPermissions.AuthRequired {
+				authRequired[item.Name] = true
+			} else {
+				toUpsert = append(toUpsert, item)
 			}
+		} else {
+			toUpsert = append(toUpsert, item)
 		}
 	}
 
-	// Only upsert items that don't require auth
-	var itemsToUpsert []wire.FeedbagItem
-	for i, item := range items {
-		if !authRequired[i] {
-			itemsToUpsert = append(itemsToUpsert, item)
-		}
-	}
-	if len(itemsToUpsert) > 0 {
-		if err := s.feedbagManager.FeedbagUpsert(ctx, instance.IdentScreenName(), itemsToUpsert); err != nil {
+	if len(toUpsert) > 0 {
+		if err := s.feedbagManager.FeedbagUpsert(ctx, instance.IdentScreenName(), toUpsert); err != nil {
 			return nil, err
 		}
 	}
 
 	setSessionBuddyPrefs(items, instance)
 
-	var filter []state.IdentScreenName
-	var alertAll bool
-	for i, item := range items {
-		if authRequired[i] {
-			continue
-		}
-		switch item.ClassID {
-		case wire.FeedbagClassIdBuddy, wire.FeedbagClassIDPermit, wire.FeedbagClassIDDeny:
-			filter = append(filter, state.NewIdentScreenName(item.Name))
-		case wire.FeedbagClassIdBart:
-			if err := s.setBARTItem(ctx, instance, item); err != nil {
-				return nil, err
-			}
-		case wire.FeedbagClassIdPdinfo:
-			alertAll = true
-		}
-	}
-
 	snacPayloadOut := wire.SNAC_0x13_0x0E_FeedbagStatus{}
-	for i := range items {
-		if authRequired[i] {
+	for _, item := range items {
+		if authRequired[item.Name] {
 			snacPayloadOut.Results = append(snacPayloadOut.Results, 0x000E)
 		} else {
 			snacPayloadOut.Results = append(snacPayloadOut.Results, 0x0000)
@@ -271,10 +264,25 @@ func (s FeedbagService) UpsertItem(ctx context.Context, instance *state.SessionI
 			RequestID: wire.ReqIDFromServer,
 		},
 		Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
-			Items: items,
+			Items: toUpsert,
 		},
 	})
 
+	var filter []state.IdentScreenName
+	var alertAll bool
+	for _, item := range toUpsert {
+		switch item.ClassID {
+		case wire.FeedbagClassIdBuddy, wire.FeedbagClassIDPermit, wire.FeedbagClassIDDeny:
+			filter = append(filter, state.NewIdentScreenName(item.Name))
+		case wire.FeedbagClassIdBart:
+			if err := s.setBARTItem(ctx, instance, item); err != nil {
+				return nil, err
+			}
+		case wire.FeedbagClassIdPdinfo:
+			alertAll = true
+		}
+	}
+
 	if alertAll || len(filter) > 0 {
 		if err := s.buddyBroadcaster.BroadcastVisibility(ctx, instance, filter, true); err != nil {
 			return nil, err
@@ -441,26 +449,96 @@ func (s FeedbagService) Use(ctx context.Context, instance *state.SessionInstance
 		return fmt.Errorf("feedbagManager.Feedbag: %w", err)
 	}
 	setSessionBuddyPrefs(items, instance)
+	instance.Session().SetUsesFeedbag()
 	return nil
 }
 
 // RequestAuthorizeToHost forwards an authorization request from the user who
-// wants to add a contact to the contact being added. The target user receives
-// SNAC(0x13,0x19) with the requester's screen name and reason text.
+// wants to add a contact to the contact being added. If the recipient session
+// uses feedbag this sign-on, they receive SNAC(0x13,0x19); otherwise a
+// synthetic SNAC(0x04,0x06) ICBM channel message to host (ICQ ch4 auth req)
+// is relayed to the recipient via messageRelayer.
 func (s FeedbagService) RequestAuthorizeToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x18_FeedbagRequestAuthorizationToHost) error {
-	s.messageRelayer.RelayToScreenName(ctx, state.NewIdentScreenName(inBody.ScreenName), wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Feedbag,
-			SubGroup:  wire.FeedbagRequestAuthorizeToClient,
-		},
-		Body: wire.SNAC_0x13_0x18_FeedbagRequestAuthorizationToHost{
-			ScreenName: instance.IdentScreenName().String(),
-			Reason:     inBody.Reason,
-		},
-	})
+	recipient := state.NewIdentScreenName(inBody.ScreenName)
+	recipSess := s.sessionRetriever.RetrieveSession(recipient)
+	useFeedbag := recipSess != nil && recipSess.UsesFeedbag()
+
+	if useFeedbag {
+		s.messageRelayer.RelayToScreenName(ctx, recipient, wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Feedbag,
+				SubGroup:  wire.FeedbagRequestAuthorizeToClient,
+			},
+			Body: wire.SNAC_0x13_0x18_FeedbagRequestAuthorizationToHost{
+				ScreenName: instance.IdentScreenName().String(),
+				Reason:     inBody.Reason,
+			},
+		})
+		return nil
+	}
+
+	// send offline or to old client
+	frame := wire.SNACFrame{
+		FoodGroup: wire.ICBM,
+		SubGroup:  wire.ICBMChannelMsgToHost,
+	}
+	hostBody := buildAuthRequestICBMChannelMsgToHost(instance, inBody.ScreenName, inBody.Reason)
+	if _, err := s.icbmSender(ctx, instance, frame, hostBody); err != nil {
+		return err
+	}
+
 	return nil
 }
 
+// buildAuthRequestICBMChannelMsgToHost builds SNAC(0x04,0x06) for an ICQ authorization
+// request.
+func buildAuthRequestICBMChannelMsgToHost(instance *state.SessionInstance, recipientScreenName, reason string) wire.SNAC_0x04_0x06_ICBMChannelMsgToHost {
+	fromUIN := instance.UIN()
+	reasonLatin := utf8ToLatin1(reason)
+	text := fmt.Sprintf("%d\xFE%s\xFE%s\xFE%s\xFE1\xFE%s", fromUIN, "", "", "", reasonLatin)
+
+	ch4 := wire.ICBMCh4Message{
+		UIN:         fromUIN,
+		MessageType: wire.ICBMMsgTypeAuthReq,
+		Message:     text,
+	}
+	return wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+		Cookie:     uint64(time.Now().UnixNano()),
+		ChannelID:  wire.ICBMChannelICQ,
+		ScreenName: recipientScreenName,
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVLE(wire.ICBMTLVData, ch4),
+				wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
+			},
+		},
+	}
+}
+
+func utf8ToLatin1(s string) string {
+	if isASCII(s) {
+		return s
+	}
+	var result []byte
+	for _, r := range s {
+		if r <= 0xFF {
+			result = append(result, byte(r))
+		} else {
+			result = append(result, '?')
+		}
+	}
+	return string(result)
+}
+
+func isASCII(s string) bool {
+	for i := 0; i < len(s); i++ {
+		if s[i] > 0x7F {
+			return false
+		}
+	}
+	return true
+}
+
 // RespondAuthorizeToHost forwards an authorization response from the user
 // whose authorization was requested to the user who made the authorization
 // request.
@@ -482,22 +560,24 @@ func (s FeedbagService) RespondAuthorizeToHost(ctx context.Context, instance *st
 		return fmt.Errorf("invalid accepted flag %d", inBody.Accepted)
 	}
 
-	s.messageRelayer.RelayToScreenName(ctx, state.NewIdentScreenName(inBody.ScreenName), wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ICBM,
-			SubGroup:  wire.ICBMChannelMsgToClient,
-		},
-		Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
-			ChannelID:   wire.ICBMChannelICQ,
-			TLVUserInfo: instance.Session().TLVUserInfo(),
-			TLVRestBlock: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					wire.NewTLVLE(wire.ICBMTLVData, response),
-					wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
-				},
+	frame := wire.SNACFrame{
+		FoodGroup: wire.ICBM,
+		SubGroup:  wire.ICBMChannelMsgToHost,
+	}
+	snac := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+		ChannelID:  wire.ICBMChannelICQ,
+		ScreenName: inBody.ScreenName,
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVLE(wire.ICBMTLVData, response),
+				wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
 			},
 		},
-	})
+	}
+
+	if _, err := s.icbmSender(ctx, instance, frame, snac); err != nil {
+		return fmt.Errorf("could not send ICBM message: %w", err)
+	}
 
 	return nil
 }

+ 153 - 66
foodgroup/feedbag_test.go

@@ -328,7 +328,7 @@ func TestFeedbagService_QueryIfModified(t *testing.T) {
 }
 
 func TestFeedbagService_RightsQuery(t *testing.T) {
-	svc := NewFeedbagService(nil, nil, nil, nil, nil, nil, nil)
+	svc := NewFeedbagService(nil, nil, nil, nil, nil, nil, nil, nil)
 
 	outputSNAC := svc.RightsQuery(context.Background(), wire.SNACFrame{RequestID: 1234})
 	expectSNAC := wire.SNACMessage{
@@ -492,6 +492,112 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 			},
 			expectOutput: nil,
 		},
+		{
+			name:     "add 2 ICQ buddies one that requires authorization",
+			instance: newTestInstance("me"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Feedbag,
+					SubGroup:  wire.FeedbagInsertItem,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x13_0x08_FeedbagInsertItem{
+					Items: []wire.FeedbagItem{
+						{
+							ClassID: wire.FeedbagClassIdBuddy,
+							Name:    "123400", // requires authorization
+						},
+						{
+							ClassID: wire.FeedbagClassIdBuddy,
+							Name:    "123401", // does not require authorization
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagUpsertParams: feedbagUpsertParams{
+						{
+							screenName: state.NewIdentScreenName("me"),
+							items: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "123401",
+								},
+							},
+						},
+					},
+				},
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastVisibilityParams: broadcastVisibilityParams{
+						{
+							from: state.NewIdentScreenName("me"),
+							filter: []state.IdentScreenName{
+								state.NewIdentScreenName("123401"),
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToOtherInstancesParams: relayToOtherInstancesParams{
+						{
+							screenName: state.NewIdentScreenName("me"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagInsertItem,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+									Items: []wire.FeedbagItem{
+										{
+											ClassID: wire.FeedbagClassIdBuddy,
+											Name:    "123401",
+										},
+									},
+								},
+							},
+						},
+					},
+					relayToSelfParams: relayToSelfParams{
+						{
+							screenName: state.NewIdentScreenName("me"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagStatus,
+									RequestID: 1234,
+								},
+								Body: wire.SNAC_0x13_0x0E_FeedbagStatus{
+									Results: []uint16{0x000E, 0x0000},
+								},
+							},
+						},
+					},
+				},
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("123400"),
+							result: &state.User{
+								ICQPermissions: state.ICQPermissions{
+									AuthRequired: true,
+								},
+							},
+						},
+						{
+							screenName: state.NewIdentScreenName("123401"),
+							result: &state.User{
+								ICQPermissions: state.ICQPermissions{
+									AuthRequired: false,
+								},
+							},
+						},
+					},
+				},
+			},
+			expectOutput: nil,
+		},
 		{
 			name:     "disable typing events",
 			instance: newTestInstance("me"),
@@ -1601,7 +1707,13 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 					BroadcastVisibility(mock.Anything, matchSession(params.from), params.filter, true).
 					Return(params.err)
 			}
-			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, bartItemManager, nil, nil, nil)
+			userManager := newMockUserManager(t)
+			for _, params := range tc.mockParams.userManagerParams.getUserParams {
+				userManager.EXPECT().
+					User(matchContext(), params.screenName).
+					Return(params.result, params.err)
+			}
+			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, bartItemManager, nil, nil, userManager, nil)
 			svc.buddyBroadcaster = buddyUpdateBroadcaster
 			output, err := svc.UpsertItem(context.Background(), tc.instance, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x13_0x08_FeedbagInsertItem).Items)
@@ -1888,7 +2000,7 @@ func TestFeedbagService_Use(t *testing.T) {
 					Return(params.results, nil)
 			}
 
-			svc := NewFeedbagService(slog.Default(), nil, feedbagManager, nil, nil, nil, nil)
+			svc := NewFeedbagService(slog.Default(), nil, feedbagManager, nil, nil, nil, nil, nil)
 
 			haveErr := svc.Use(context.Background(), tt.instance)
 			assert.ErrorIs(t, tt.wantErr, haveErr)
@@ -1899,12 +2011,16 @@ func TestFeedbagService_Use(t *testing.T) {
 }
 
 func TestFeedbagService_RespondAuthorizeToHost(t *testing.T) {
+	wantICBMFrame := wire.SNACFrame{
+		FoodGroup: wire.ICBM,
+		SubGroup:  wire.ICBMChannelMsgToHost,
+	}
 	tests := []struct {
-		name       string
-		instance   *state.SessionInstance
-		bodyIn     wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost
-		mockParams mockParams
-		wantErr    error
+		name         string
+		instance     *state.SessionInstance
+		bodyIn       wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost
+		wantHostSNAC wire.SNAC_0x04_0x06_ICBMChannelMsgToHost
+		wantErr      error
 	}{
 		{
 			name:     "authorization accepted",
@@ -1913,31 +2029,16 @@ func TestFeedbagService_RespondAuthorizeToHost(t *testing.T) {
 				ScreenName: "100003",
 				Accepted:   1,
 			},
-			mockParams: mockParams{
-				messageRelayerParams: messageRelayerParams{
-					relayToScreenNameParams: relayToScreenNameParams{
-						{
-							screenName: state.NewIdentScreenName("100003"),
-							message: wire.SNACMessage{
-								Frame: wire.SNACFrame{
-									FoodGroup: wire.ICBM,
-									SubGroup:  wire.ICBMChannelMsgToClient,
-								},
-								Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
-									ChannelID:   wire.ICBMChannelICQ,
-									TLVUserInfo: newTestInstance("100001").Session().TLVUserInfo(),
-									TLVRestBlock: wire.TLVRestBlock{
-										TLVList: wire.TLVList{
-											wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
-												UIN:         100001,
-												MessageType: wire.ICBMMsgTypeAuthOK,
-											}),
-											wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
-										},
-									},
-								},
-							},
-						},
+			wantHostSNAC: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+				ChannelID:  wire.ICBMChannelICQ,
+				ScreenName: "100003",
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
+							UIN:         100001,
+							MessageType: wire.ICBMMsgTypeAuthOK,
+						}),
+						wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
 					},
 				},
 			},
@@ -1950,32 +2051,17 @@ func TestFeedbagService_RespondAuthorizeToHost(t *testing.T) {
 				Accepted:   0,
 				Reason:     "I don't know you!",
 			},
-			mockParams: mockParams{
-				messageRelayerParams: messageRelayerParams{
-					relayToScreenNameParams: relayToScreenNameParams{
-						{
-							screenName: state.NewIdentScreenName("100003"),
-							message: wire.SNACMessage{
-								Frame: wire.SNACFrame{
-									FoodGroup: wire.ICBM,
-									SubGroup:  wire.ICBMChannelMsgToClient,
-								},
-								Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
-									ChannelID:   wire.ICBMChannelICQ,
-									TLVUserInfo: newTestInstance("100001").Session().TLVUserInfo(),
-									TLVRestBlock: wire.TLVRestBlock{
-										TLVList: wire.TLVList{
-											wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
-												UIN:         100001,
-												MessageType: wire.ICBMMsgTypeAuthDeny,
-												Message:     "I don't know you!",
-											}),
-											wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
-										},
-									},
-								},
-							},
-						},
+			wantHostSNAC: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+				ChannelID:  wire.ICBMChannelICQ,
+				ScreenName: "100003",
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
+							UIN:         100001,
+							MessageType: wire.ICBMMsgTypeAuthDeny,
+							Message:     "I don't know you!",
+						}),
+						wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
 					},
 				},
 			},
@@ -1983,13 +2069,14 @@ func TestFeedbagService_RespondAuthorizeToHost(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			messageRelayer := newMockMessageRelayer(t)
-			for _, params := range tt.mockParams.relayToScreenNameParams {
-				messageRelayer.EXPECT().
-					RelayToScreenName(matchContext(), params.screenName, params.message)
+			icbmSender := func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error) {
+				assert.Equal(t, wantICBMFrame, inFrame)
+				assert.Equal(t, tt.instance, instance)
+				assert.Equal(t, tt.wantHostSNAC, inBody)
+				return nil, nil
 			}
 
-			svc := NewFeedbagService(slog.Default(), messageRelayer, nil, nil, nil, nil, nil)
+			svc := NewFeedbagService(slog.Default(), nil, nil, nil, nil, nil, nil, icbmSender)
 			haveErr := svc.RespondAuthorizeToHost(context.Background(), tt.instance, wire.SNACFrame{}, tt.bodyIn)
 			assert.ErrorIs(t, tt.wantErr, haveErr)
 		})
@@ -2181,7 +2268,7 @@ func TestFeedbagService_StartCluster(t *testing.T) {
 			Body:  inBody,
 		})
 
-	svc := NewFeedbagService(slog.Default(), messageRelayer, nil, nil, nil, nil, nil)
+	svc := NewFeedbagService(slog.Default(), messageRelayer, nil, nil, nil, nil, nil, nil)
 	svc.StartCluster(context.Background(), instance, inFrame, inBody)
 }
 
@@ -2200,6 +2287,6 @@ func TestFeedbagService_EndCluster(t *testing.T) {
 			Body:  wire.SNAC_0x13_0x12_FeedbagEndCluster{},
 		})
 
-	svc := NewFeedbagService(slog.Default(), messageRelayer, nil, nil, nil, nil, nil)
+	svc := NewFeedbagService(slog.Default(), messageRelayer, nil, nil, nil, nil, nil, nil)
 	svc.EndCluster(context.Background(), instance, inFrame)
 }

+ 5 - 0
foodgroup/helpers_test.go

@@ -912,6 +912,11 @@ func sessOptMixedStates(instance *state.SessionInstance) {
 	// with some active and some inactive
 }
 
+// sessOptFeedbagEnabled marks the instance as using the feedbag buddy list this sign-on.
+func sessOptFeedbagEnabled(instance *state.SessionInstance) {
+	instance.Session().SetUsesFeedbag()
+}
+
 // sessBuddyIcon sets session buddy icon
 func sessOptBuddyIcon(icon wire.BARTID) func(instance *state.SessionInstance) {
 	return func(instance *state.SessionInstance) {

+ 128 - 0
foodgroup/icbm.go

@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"io"
 	"log/slog"
+	"slices"
 	"strings"
 	"sync"
 	"time"
@@ -133,6 +134,16 @@ func (s ICBMService) ChannelMsgToHost(ctx context.Context, instance *state.Sessi
 		return msg, err
 	}
 
+	if inBody.ChannelID == wire.ICBMChannelICQ && recipSess.UsesFeedbag() {
+		if b, ok := inBody.Bytes(wire.ICBMTLVData); ok {
+			authMsg := wire.ICBMCh4Message{}
+			if err = wire.UnmarshalLE(&authMsg, bytes.NewReader(b)); err != nil {
+				return nil, fmt.Errorf("failed to unmarshal ICBM authMsg: %w", err)
+			}
+			return nil, s.forwardICQAuthEvents(ctx, instance, recipSess.IdentScreenName(), authMsg)
+		}
+	}
+
 	clientIM := wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
 		Cookie:       inBody.Cookie,
 		ChannelID:    inBody.ChannelID,
@@ -212,6 +223,123 @@ func (s ICBMService) ChannelMsgToHost(ctx context.Context, instance *state.Sessi
 	}, nil
 }
 
+// forwardICQAuthEvents converts ICQ channel-4 payloads to feedbag SNACs and
+// sends them to feedbag-enabled recipient.
+func (s ICBMService) forwardICQAuthEvents(ctx context.Context, sender *state.SessionInstance, recipient state.IdentScreenName, authMsg wire.ICBMCh4Message) error {
+	switch authMsg.MessageType {
+	case wire.ICBMMsgTypeAuthOK:
+		items, err := s.feedbagManager.Feedbag(ctx, recipient)
+		if err != nil {
+			return fmt.Errorf("failed to fetch feedbag items: %w", err)
+		}
+
+		// look for the pending buddy authorization
+		var buddyItem *wire.FeedbagItem
+		for _, item := range items {
+			if item.ClassID == wire.FeedbagClassIdBuddy && item.Name == sender.IdentScreenName().String() {
+				buddyItem = &item
+				break
+			}
+		}
+
+		// pending buddy authorization is not found, nothing to do
+		if buddyItem == nil || !buddyItem.HasTag(wire.FeedbagAttributesPending) {
+			return fmt.Errorf("no pending buddy authorization found for %s", sender.IdentScreenName().String())
+		}
+
+		// remove the pending buddy authorization tag
+		buddyItem.TLVList = slices.DeleteFunc(buddyItem.TLVList, func(tlv wire.TLV) bool {
+			return tlv.Tag == wire.FeedbagAttributesPending
+		})
+
+		updates := []wire.FeedbagItem{*buddyItem}
+		if err = s.feedbagManager.FeedbagUpsert(ctx, recipient, updates); err != nil {
+			return fmt.Errorf("failed to update feedbag: %w", err)
+		}
+
+		// clear the pending flag on the recipient's buddy entry
+		s.messageRelayer.RelayToScreenName(ctx, recipient, wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Feedbag,
+				SubGroup:  wire.FeedbagUpdateItem,
+				Flags:     0x8000,
+			},
+			Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+				Items: updates,
+			},
+		})
+
+		// tell the recipient that we're friends
+		snac := wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Feedbag,
+				SubGroup:  wire.FeedbagRespondAuthorizeToClient,
+				Flags:     0x8000,
+			},
+			Body: wire.SNAC_0x13_0x1B_FeedbagRespondAuthorizeToClient{
+				TLV:        wire.NewTLVBE(6, uint32(0x00020004)),
+				ScreenName: sender.IdentScreenName().String(),
+				Accepted:   1,
+			},
+		}
+		s.messageRelayer.RelayToScreenName(ctx, recipient, snac)
+
+		// tell the recipient that we're online
+		if err := s.buddyBroadcaster.BroadcastVisibility(ctx, sender, []state.IdentScreenName{recipient}, false); err != nil {
+			s.logger.ErrorContext(ctx, "broadcastBuddyArrived failed", "err", err)
+		}
+		return nil
+	case wire.ICBMMsgTypeAuthDeny:
+		snac := wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Feedbag,
+				SubGroup:  wire.FeedbagRespondAuthorizeToClient,
+				Flags:     0x8000,
+			},
+			Body: wire.SNAC_0x13_0x1B_FeedbagRespondAuthorizeToClient{
+				TLV:        wire.NewTLVBE(6, uint32(0x00020004)),
+				ScreenName: sender.IdentScreenName().String(),
+				Accepted:   0,
+				Reason:     authMsg.Message,
+			},
+		}
+		s.messageRelayer.RelayToScreenName(ctx, recipient, snac)
+		return nil
+	case wire.ICBMMsgTypeAdded:
+		snac := wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Feedbag,
+				SubGroup:  wire.FeedbagBuddyAdded,
+				Flags:     0x8000,
+			},
+			Body: wire.SNAC_0x13_0x1C_FeedbagBuddyAdded{
+				TLV:        wire.NewTLVBE(6, uint32(0x00020004)),
+				ScreenName: sender.IdentScreenName().String(),
+			},
+		}
+		s.messageRelayer.RelayToScreenName(ctx, recipient, snac)
+		return nil
+	case wire.ICBMMsgTypeAuthReq:
+		snac := wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Feedbag,
+				SubGroup:  wire.FeedbagRequestAuthorizeToClient,
+				Flags:     0x8000,
+			},
+			Body: wire.SNAC_0x13_0x19_FeedbagRequestAuthorizeToClient{
+				TLV:        wire.NewTLVBE(6, uint32(0x00020004)),
+				ScreenName: sender.IdentScreenName().String(),
+				Reason:     authMsg.Message,
+			},
+		}
+		s.messageRelayer.RelayToScreenName(ctx, recipient, snac)
+		return nil
+	default:
+		s.logger.WarnContext(ctx, "unknown authMsg ICBM message type", "type", authMsg.MessageType)
+		return nil
+	}
+}
+
 // canSendOfflineMessage returns true if the user can send an offline message.
 //
 //	For ICQ users, always return true. Todo: Check ICQ recipient's preferences.

+ 343 - 0
foodgroup/icbm_test.go

@@ -3,6 +3,7 @@ package foodgroup
 import (
 	"context"
 	"errors"
+	"io"
 	"log/slog"
 	"net"
 	"net/netip"
@@ -1756,10 +1757,334 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 				},
 			},
 		},
+		{
+			name:     "ICQ channel feedbag ch4: auth_ok",
+			instance: newTestInstance("100001", sessOptUIN(100001)),
+			mockParams: mockParams{
+				relationshipFetcherParams: relationshipFetcherParams{
+					relationshipParams: relationshipParams{
+						{
+							me:   state.NewIdentScreenName("100001"),
+							them: state.NewIdentScreenName("200002"),
+							result: state.Relationship{
+								User:          state.NewIdentScreenName("200002"),
+								BlocksYou:     false,
+								YouBlock:      false,
+								IsOnTheirList: false,
+								IsOnYourList:  false,
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{
+						{
+							screenName: state.NewIdentScreenName("200002"),
+							result:     newTestInstance("200002", sessOptFeedbagEnabled, sessOptSignonComplete).Session(),
+						},
+					},
+				},
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("200002"),
+							results: []wire.FeedbagItem{
+								{
+									Name:    "100001",
+									ClassID: wire.FeedbagClassIdBuddy,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{{Tag: wire.FeedbagAttributesPending, Value: []byte{1}}},
+									},
+								},
+							},
+						},
+					},
+					feedbagUpsertParams: feedbagUpsertParams{
+						{
+							screenName: state.NewIdentScreenName("200002"),
+							items: []wire.FeedbagItem{
+								{
+									Name:    "100001",
+									ClassID: wire.FeedbagClassIdBuddy,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{},
+									},
+								},
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("200002"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagUpdateItem,
+									Flags:     0x8000,
+								},
+								Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+									Items: []wire.FeedbagItem{
+										{
+											Name:    "100001",
+											ClassID: wire.FeedbagClassIdBuddy,
+											TLVLBlock: wire.TLVLBlock{
+												TLVList: wire.TLVList{},
+											},
+										},
+									},
+								},
+							},
+						},
+						{
+							screenName: state.NewIdentScreenName("200002"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagRespondAuthorizeToClient,
+									Flags:     0x8000,
+								},
+								Body: wire.SNAC_0x13_0x1B_FeedbagRespondAuthorizeToClient{
+									TLV:        wire.NewTLVBE(6, uint32(0x00020004)),
+									ScreenName: "100001",
+									Accepted:   1,
+								},
+							},
+						},
+					},
+				},
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastVisibilityParams: broadcastVisibilityParams{
+						{
+							from:             state.NewIdentScreenName("100001"),
+							filter:           []state.IdentScreenName{state.NewIdentScreenName("200002")},
+							doSendDepartures: false,
+							err:              nil,
+						},
+					},
+				},
+			},
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{},
+				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					ChannelID:  wire.ICBMChannelICQ,
+					ScreenName: "200002",
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
+								UIN:         100001,
+								MessageType: wire.ICBMMsgTypeAuthOK,
+							}),
+						},
+					},
+				},
+			},
+			expectOutput: nil,
+			wantErr:      nil,
+		},
+		{
+			name:     "ICQ channel feedbag ch4: auth_deny",
+			instance: newTestInstance("100001", sessOptUIN(100001)),
+			mockParams: mockParams{
+				relationshipFetcherParams: relationshipFetcherParams{
+					relationshipParams: relationshipParams{
+						{
+							me:   state.NewIdentScreenName("100001"),
+							them: state.NewIdentScreenName("200002"),
+							result: state.Relationship{
+								User:          state.NewIdentScreenName("200002"),
+								BlocksYou:     false,
+								YouBlock:      false,
+								IsOnTheirList: false,
+								IsOnYourList:  false,
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{
+						{
+							screenName: state.NewIdentScreenName("200002"),
+							result:     newTestInstance("200002", sessOptFeedbagEnabled, sessOptSignonComplete).Session(),
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("200002"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagRespondAuthorizeToClient,
+									Flags:     0x8000,
+								},
+								Body: wire.SNAC_0x13_0x1B_FeedbagRespondAuthorizeToClient{
+									TLV:        wire.NewTLVBE(6, uint32(0x00020004)),
+									ScreenName: "100001",
+									Accepted:   0,
+									Reason:     "no thanks",
+								},
+							},
+						},
+					},
+				},
+			},
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{},
+				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					ChannelID:  wire.ICBMChannelICQ,
+					ScreenName: "200002",
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
+								UIN:         100001,
+								MessageType: wire.ICBMMsgTypeAuthDeny,
+								Message:     "no thanks",
+							}),
+						},
+					},
+				},
+			},
+			expectOutput: nil,
+			wantErr:      nil,
+		},
+		{
+			name:     "ICQ channel feedbag ch4: added",
+			instance: newTestInstance("100001", sessOptUIN(100001)),
+			mockParams: mockParams{
+				relationshipFetcherParams: relationshipFetcherParams{
+					relationshipParams: relationshipParams{
+						{
+							me:   state.NewIdentScreenName("100001"),
+							them: state.NewIdentScreenName("200002"),
+							result: state.Relationship{
+								User:          state.NewIdentScreenName("200002"),
+								BlocksYou:     false,
+								YouBlock:      false,
+								IsOnTheirList: false,
+								IsOnYourList:  false,
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{
+						{
+							screenName: state.NewIdentScreenName("200002"),
+							result:     newTestInstance("200002", sessOptFeedbagEnabled, sessOptSignonComplete).Session(),
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("200002"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagBuddyAdded,
+									Flags:     0x8000,
+								},
+								Body: wire.SNAC_0x13_0x1C_FeedbagBuddyAdded{
+									TLV:        wire.NewTLVBE(6, uint32(0x00020004)),
+									ScreenName: "100001",
+								},
+							},
+						},
+					},
+				},
+			},
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{},
+				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					ChannelID:  wire.ICBMChannelICQ,
+					ScreenName: "200002",
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
+								UIN:         100001,
+								MessageType: wire.ICBMMsgTypeAdded,
+							}),
+						},
+					},
+				},
+			},
+			expectOutput: nil,
+			wantErr:      nil,
+		},
+		{
+			name:     "ICQ channel feedbag ch4: auth_req",
+			instance: newTestInstance("100001", sessOptUIN(100001)),
+			mockParams: mockParams{
+				relationshipFetcherParams: relationshipFetcherParams{
+					relationshipParams: relationshipParams{
+						{
+							me:   state.NewIdentScreenName("100001"),
+							them: state.NewIdentScreenName("200002"),
+							result: state.Relationship{
+								User:          state.NewIdentScreenName("200002"),
+								BlocksYou:     false,
+								YouBlock:      false,
+								IsOnTheirList: false,
+								IsOnYourList:  false,
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{
+						{
+							screenName: state.NewIdentScreenName("200002"),
+							result:     newTestInstance("200002", sessOptFeedbagEnabled, sessOptSignonComplete).Session(),
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("200002"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagRequestAuthorizeToClient,
+									Flags:     0x8000,
+								},
+								Body: wire.SNAC_0x13_0x19_FeedbagRequestAuthorizeToClient{
+									TLV:        wire.NewTLVBE(6, uint32(0x00020004)),
+									ScreenName: "100001",
+									Reason:     "hi",
+								},
+							},
+						},
+					},
+				},
+			},
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{},
+				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					ChannelID:  wire.ICBMChannelICQ,
+					ScreenName: "200002",
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
+								UIN:         100001,
+								MessageType: wire.ICBMMsgTypeAuthReq,
+								Message:     "hi",
+							}),
+						},
+					},
+				},
+			},
+			expectOutput: nil,
+			wantErr:      nil,
+		},
 	}
 
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
+			discardLogger := slog.New(slog.NewTextHandler(io.Discard, nil))
 			relationshipFetcher := newMockRelationshipFetcher(t)
 			for _, item := range tc.mockParams.relationshipFetcherParams.relationshipParams {
 				relationshipFetcher.EXPECT().
@@ -1793,6 +2118,22 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 					Feedbag(matchContext(), params.screenName).
 					Return(params.results, params.err)
 			}
+			for _, params := range tc.mockParams.feedbagUpsertParams {
+				feedbagManager.EXPECT().
+					FeedbagUpsert(matchContext(), params.screenName, params.items).
+					Return(nil)
+			}
+			buddyBroadcaster := newMockbuddyBroadcaster(t)
+			for _, params := range tc.mockParams.broadcastBuddyArrivedParams {
+				buddyBroadcaster.EXPECT().
+					BroadcastBuddyArrived(matchContext(), state.NewIdentScreenName(params.screenName.String()), mock.Anything).
+					Return(params.err)
+			}
+			for _, params := range tc.mockParams.broadcastVisibilityParams {
+				buddyBroadcaster.EXPECT().
+					BroadcastVisibility(mock.Anything, matchSession(params.from), params.filter, params.doSendDepartures).
+					Return(params.err)
+			}
 
 			svc := ICBMService{
 				relationshipFetcher: relationshipFetcher,
@@ -1802,6 +2143,8 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 				timeNow:             tc.timeNow,
 				convoTracker:        newConvoTracker(),
 				feedbagManager:      feedbagManager,
+				buddyBroadcaster:    buddyBroadcaster,
+				logger:              discardLogger,
 			}
 
 			outputSNAC, err := svc.ChannelMsgToHost(context.Background(), tc.instance, tc.inputSNAC.Frame,

+ 27 - 6
foodgroup/icq.go

@@ -24,6 +24,7 @@ func NewICQService(
 	logger *slog.Logger,
 	sessionRetriever SessionRetriever,
 	offlineMessageManager OfflineMessageManager,
+	icbmSender func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error),
 ) ICQService {
 	return ICQService{
 		messageRelayer:        messageRelayer,
@@ -32,6 +33,7 @@ func NewICQService(
 		logger:                logger,
 		sessionRetriever:      sessionRetriever,
 		offlineMessageManager: offlineMessageManager,
+		icbmSender:            icbmSender,
 		timeNow:               time.Now,
 	}
 }
@@ -45,6 +47,7 @@ type ICQService struct {
 	userUpdater           ICQUserUpdater
 	timeNow               func() time.Time
 	offlineMessageManager OfflineMessageManager
+	icbmSender            func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)
 }
 
 func (s ICQService) DeleteMsgReq(ctx context.Context, instance *state.SessionInstance, seq uint16) error {
@@ -421,12 +424,26 @@ func (s ICQService) OfflineMsgReq(ctx context.Context, instance *state.SessionIn
 			}
 		case wire.ICBMChannelICQ:
 			if b, hasAuthReq := msgIn.Message.Bytes(wire.ICBMTLVData); hasAuthReq {
-				// send authorization request
 				msg := wire.ICBMCh4Message{}
 				buf := bytes.NewBuffer(b)
 				if err := wire.UnmarshalLE(&msg, buf); err != nil {
 					return err
 				}
+				if instance.Session().UsesFeedbag() {
+					// send auth grant/deny/request SNACs instead of the legacy MSG_TYPE_*
+					// ICQ messages.
+					frame := wire.SNACFrame{
+						FoodGroup: wire.ICBM,
+						SubGroup:  wire.ICBMChannelMsgToHost,
+					}
+					// fake a session since the sender may be offline
+					sender := state.NewSession()
+					sender.SetIdentScreenName(msgIn.Sender)
+					if _, err := s.icbmSender(ctx, sender.AddInstance(), frame, msgIn.Message); err != nil {
+						return fmt.Errorf("s.icbmSender: %w", err)
+					}
+					continue // do not send these messages in response
+				}
 				reply.MsgType = msg.MessageType
 				reply.Flags = msg.Flags
 				reply.Message = msg.Message
@@ -457,7 +474,11 @@ func (s ICQService) OfflineMsgReq(ctx context.Context, instance *state.SessionIn
 		},
 	}
 
-	return s.reply(ctx, instance, eofMsg)
+	if err := s.reply(ctx, instance, eofMsg); err != nil {
+		return fmt.Errorf("sending end of offline messages: %w", err)
+	}
+
+	return nil
 }
 
 func (s ICQService) SetAffiliations(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations, seq uint16) error {
@@ -632,7 +653,7 @@ func (s ICQService) ShortUserInfo(ctx context.Context, instance *state.SessionIn
 		Email:      user.ICQBasicInfo.EmailAddress,
 		Gender:     uint8(user.ICQMoreInfo.Gender),
 	}
-	if user.ICQPermissions.AuthRequired {
+	if !user.ICQPermissions.AuthRequired {
 		info.Authorization = 1
 	}
 
@@ -722,7 +743,7 @@ func (s ICQService) createResult(res state.User) wire.ICQUserSearchRecord {
 		Gender:    uint8(res.ICQMoreInfo.Gender),
 		Age:       res.Age(s.timeNow),
 	}
-	if res.ICQPermissions.AuthRequired {
+	if !res.ICQPermissions.AuthRequired {
 		searchRecord.Authorization = 1
 	}
 
@@ -909,12 +930,12 @@ func (s ICQService) userInfo(ctx context.Context, instance *state.SessionInstanc
 		ZIP:         user.ICQBasicInfo.ZIPCode,
 		CountryCode: user.ICQBasicInfo.CountryCode,
 		GMTOffset:   user.ICQBasicInfo.GMTOffset,
-		AuthFlag:    0,
+		AuthFlag:    0, // required by default
 		WebAware:    1,
 		DCPerms:     0,
 	}
 
-	if user.ICQPermissions.AuthRequired {
+	if !user.ICQPermissions.AuthRequired {
 		userInfo.AuthFlag = 1
 	}
 	if user.ICQPermissions.WebAware {

+ 101 - 22
foodgroup/icq_test.go

@@ -46,7 +46,7 @@ func TestICQService_DeleteMsgReq(t *testing.T) {
 					Return(params.err)
 			}
 
-			s := NewICQService(nil, nil, nil, slog.Default(), nil, offlineMessageManager)
+			s := NewICQService(nil, nil, nil, slog.Default(), nil, offlineMessageManager, nil)
 			err := s.DeleteMsgReq(context.Background(), tt.instance, tt.seq)
 			assert.NoError(t, err)
 		})
@@ -150,7 +150,7 @@ func TestICQService_FindByICQName(t *testing.T) {
 														FirstName:     "Jane",
 														LastName:      "Doe",
 														Email:         "janey@example.com",
-														Authorization: 0,
+														Authorization: 1,
 														OnlineStatus:  0,
 														Gender:        2,
 														Age:           25,
@@ -187,7 +187,7 @@ func TestICQService_FindByICQName(t *testing.T) {
 														FirstName:     "John",
 														LastName:      "Doe",
 														Email:         "john@example.com",
-														Authorization: 1,
+														Authorization: 0,
 														OnlineStatus:  1,
 														Gender:        1,
 														Age:           21,
@@ -327,7 +327,7 @@ func TestICQService_FindByICQEmail(t *testing.T) {
 														FirstName:     "John",
 														LastName:      "Doe",
 														Email:         "john@example.com",
-														Authorization: 1,
+														Authorization: 0,
 														OnlineStatus:  1,
 														Gender:        1,
 														Age:           21,
@@ -469,7 +469,7 @@ func TestICQService_FindByEmail3(t *testing.T) {
 														FirstName:     "John",
 														LastName:      "Doe",
 														Email:         "john@example.com",
-														Authorization: 1,
+														Authorization: 0,
 														OnlineStatus:  1,
 														Gender:        1,
 														Age:           21,
@@ -605,7 +605,7 @@ func TestICQService_FindByUIN(t *testing.T) {
 														FirstName:     "John",
 														LastName:      "Doe",
 														Email:         "john@example.com",
-														Authorization: 1,
+														Authorization: 0,
 														OnlineStatus:  1,
 														Gender:        1,
 														Age:           21,
@@ -745,7 +745,7 @@ func TestICQService_FindByUIN2(t *testing.T) {
 														FirstName:     "John",
 														LastName:      "Doe",
 														Email:         "john@example.com",
-														Authorization: 1,
+														Authorization: 0,
 														OnlineStatus:  1,
 														Gender:        1,
 														Age:           21,
@@ -903,7 +903,7 @@ func TestICQService_FindByWhitePages(t *testing.T) {
 														FirstName:     "Jane",
 														LastName:      "Doe",
 														Email:         "janey@example.com",
-														Authorization: 0,
+														Authorization: 1,
 														OnlineStatus:  0,
 														Gender:        2,
 														Age:           25,
@@ -940,7 +940,7 @@ func TestICQService_FindByWhitePages(t *testing.T) {
 														FirstName:     "Alice",
 														LastName:      "Smith",
 														Email:         "alice@example.com",
-														Authorization: 1,
+														Authorization: 0,
 														OnlineStatus:  1,
 														Gender:        1,
 														Age:           21,
@@ -1108,7 +1108,7 @@ func TestICQService_FindByWhitePages2(t *testing.T) {
 														FirstName:     "Jane",
 														LastName:      "Doe",
 														Email:         "janey@example.com",
-														Authorization: 0,
+														Authorization: 1,
 														OnlineStatus:  0,
 														Gender:        2,
 														Age:           25,
@@ -1145,7 +1145,7 @@ func TestICQService_FindByWhitePages2(t *testing.T) {
 														FirstName:     "Alice",
 														LastName:      "Smith",
 														Email:         "alice@example.com",
-														Authorization: 1,
+														Authorization: 0,
 														OnlineStatus:  1,
 														Gender:        1,
 														Age:           21,
@@ -1263,7 +1263,7 @@ func TestICQService_FindByWhitePages2(t *testing.T) {
 														FirstName:     "Jane",
 														LastName:      "Doe",
 														Email:         "janey@example.com",
-														Authorization: 0,
+														Authorization: 1,
 														OnlineStatus:  0,
 														Gender:        2,
 														Age:           25,
@@ -1467,7 +1467,7 @@ func TestICQService_FullUserInfo(t *testing.T) {
 													ZIP:          "10001",
 													CountryCode:  1,
 													GMTOffset:    5,
-													AuthFlag:     1,
+													AuthFlag:     0,
 													WebAware:     0,
 													DCPerms:      0,
 													PublishEmail: wire.ICQUserFlagPublishEmailYes,
@@ -1789,14 +1789,15 @@ func TestICQService_FullUserInfo(t *testing.T) {
 
 func TestICQService_OfflineMsgReq(t *testing.T) {
 	tests := []struct {
-		name       string
-		seq        uint16
-		instance   *state.SessionInstance
-		mockParams mockParams
-		wantErr    error
+		name                string
+		seq                 uint16
+		instance            *state.SessionInstance
+		mockParams          mockParams
+		wantErr             error
+		wantICBMSenderCalls int
 	}{
 		{
-			name:     "send offline IM, offline friend request",
+			name:     "send offline IM, offline friend request to non-feedbag client",
 			seq:      1,
 			instance: newTestInstance("11111111", sessOptUIN(11111111)),
 			mockParams: mockParams{
@@ -1943,6 +1944,76 @@ func TestICQService_OfflineMsgReq(t *testing.T) {
 					},
 				},
 			},
+			wantICBMSenderCalls: 0,
+		},
+		{
+			name:     "send offline IM; ICQ auth request uses feedbag path (no legacy offline reply for auth)",
+			seq:      1,
+			instance: newTestInstance("11111111", sessOptUIN(11111111), sessOptFeedbagEnabled),
+			mockParams: mockParams{
+				offlineMessageManagerParams: offlineMessageManagerParams{
+					retrieveMessagesParams: retrieveMessagesParams{
+						{
+							recipIn: state.NewIdentScreenName("11111111"),
+							messagesOut: []state.OfflineMessage{
+								{
+									Sender:    state.NewIdentScreenName("33333333"),
+									Recipient: state.NewIdentScreenName("11111111"),
+									Message: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+										ChannelID: wire.ICBMChannelICQ,
+										TLVRestBlock: wire.TLVRestBlock{
+											TLVList: wire.TLVList{
+												wire.NewTLVBE(wire.ICBMTLVData, func() []byte {
+													msg := wire.ICBMCh4Message{
+														UIN:         33333333,
+														MessageType: wire.ICBMExtendedMsgTypeAuthReq,
+														Flags:       0,
+														Message:     "please add me to your contacts list",
+													}
+													buf := &bytes.Buffer{}
+													assert.NoError(t, wire.MarshalLE(msg, buf))
+													return buf.Bytes()
+												}()),
+											},
+										},
+									},
+									Sent: time.Date(2024, time.August, 1, 8, 2, 0, 0, time.UTC),
+								},
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x15_0x02_DBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x0042_DBQueryOfflineMsgReplyLast{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryOfflineMsgReplyLast,
+														Seq:     1,
+													},
+													DroppedMessages: 0,
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			wantICBMSenderCalls: 1,
 		},
 	}
 	for _, tt := range tests {
@@ -1955,12 +2026,20 @@ func TestICQService_OfflineMsgReq(t *testing.T) {
 			}
 			messageRelayer := newMockMessageRelayer(t)
 			for _, params := range tt.mockParams.relayToScreenNameParams {
-				messageRelayer.EXPECT().RelayToScreenName(mock.Anything, params.screenName, params.message)
+				messageRelayer.EXPECT().
+					RelayToScreenName(mock.Anything, params.screenName, params.message)
+			}
+
+			var icbmSenderCalls int
+			icbmSender := func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error) {
+				icbmSenderCalls++
+				return nil, nil
 			}
 
-			s := NewICQService(messageRelayer, nil, nil, slog.Default(), nil, offlineMessageManager)
+			s := NewICQService(messageRelayer, nil, nil, slog.Default(), nil, offlineMessageManager, icbmSender)
 			err := s.OfflineMsgReq(context.Background(), tt.instance, tt.seq)
 			assert.NoError(t, err)
+			assert.Equal(t, tt.wantICBMSenderCalls, icbmSenderCalls)
 		})
 	}
 }
@@ -2996,7 +3075,7 @@ func TestICQService_ShortUserInfo(t *testing.T) {
 													FirstName:     "John",
 													LastName:      "Doe",
 													Email:         "john.doe@example.com",
-													Authorization: 1,
+													Authorization: 0,
 													Gender:        2,
 												},
 											}),

+ 16 - 0
state/session.go

@@ -73,6 +73,8 @@ type Session struct {
 	chatRoomCookie      string
 	buddyIcon           wire.BARTID
 	typingEventsEnabled bool
+	// usesFeedbag is true after FeedbagService.Use completes this sign-on (server-side buddy list / SSI).
+	usesFeedbag bool
 
 	// Rate limiting (shared across all sessions per user)
 	rateLimitStates         [5]RateClassState
@@ -779,6 +781,20 @@ func (s *Session) TypingEventsEnabled() bool {
 	return s.typingEventsEnabled
 }
 
+// SetUsesFeedbag records that this session uses the feedbag (server-side) buddy list for this sign-on.
+func (s *Session) SetUsesFeedbag() {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	s.usesFeedbag = true
+}
+
+// UsesFeedbag reports whether the client completed the feedbag Use handshake this sign-on.
+func (s *Session) UsesFeedbag() bool {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return s.usesFeedbag
+}
+
 func (s *Session) userInfo() wire.TLVList {
 	tlvs := wire.TLVList{}
 

+ 10 - 4
state/session_manager.go

@@ -100,10 +100,16 @@ func (s *InMemorySessionManager) RelayToAll(ctx context.Context, msg wire.SNACMe
 func (s *InMemorySessionManager) RelayToScreenName(ctx context.Context, screenName IdentScreenName, msg wire.SNACMessage) {
 	sess := s.RetrieveSession(screenName)
 	if sess == nil {
-		s.logger.WarnContext(ctx, "RelayToScreenName: session not found", "recipient", screenName, "food_group", msg.Frame.FoodGroup, "sub_group", msg.Frame.SubGroup)
+		s.logger.WarnContext(ctx, "RelayToScreenName: session not found",
+			"recipient", screenName,
+			"food_group", wire.FoodGroupName(msg.Frame.FoodGroup),
+			"sub_group", wire.SubGroupName(msg.Frame.FoodGroup, msg.Frame.SubGroup))
 		return
 	}
-	s.logger.DebugContext(ctx, "RelayToScreenName: found session, relaying", "recipient", screenName, "food_group", msg.Frame.FoodGroup, "sub_group", msg.Frame.SubGroup, "instances", len(sess.Instances()))
+	s.logger.DebugContext(ctx, "RelayToScreenName: found session, relaying", "recipient", screenName,
+		"food_group", wire.FoodGroupName(msg.Frame.FoodGroup),
+		"sub_group", wire.SubGroupName(msg.Frame.FoodGroup, msg.Frame.SubGroup),
+		"instances", len(sess.Instances()))
 	s.maybeRelayMessage(ctx, msg, sess)
 }
 
@@ -168,8 +174,8 @@ func (s *InMemorySessionManager) maybeRelayMessage(ctx context.Context, msg wire
 		default:
 			s.logger.DebugContext(ctx, "maybeRelayMessage: relayed to instance",
 				"recipient", sess.IdentScreenName(),
-				"food_group", msg.Frame.FoodGroup,
-				"sub_group", msg.Frame.SubGroup,
+				"food_group", wire.FoodGroupName(msg.Frame.FoodGroup),
+				"sub_group", wire.SubGroupName(msg.Frame.FoodGroup, msg.Frame.SubGroup),
 			)
 		}
 	}

+ 20 - 0
state/session_test.go

@@ -13,6 +13,26 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
+func TestSession_UsesFeedbag(t *testing.T) {
+	s := NewSession()
+
+	if s.UsesFeedbag() {
+		t.Fatalf("UsesFeedbag() = true; want false")
+	}
+
+	s.SetUsesFeedbag()
+
+	if !s.UsesFeedbag() {
+		t.Fatalf("UsesFeedbag() = false; want true")
+	}
+
+	// idempotent
+	s.SetUsesFeedbag()
+	if !s.UsesFeedbag() {
+		t.Fatalf("UsesFeedbag() = false after second SetUsesFeedbag; want true")
+	}
+}
+
 func TestSession_IncrementAndGetWarning(t *testing.T) {
 	s := NewSession().AddInstance()
 

+ 15 - 0
wire/snacs.go

@@ -1849,6 +1849,13 @@ type SNAC_0x13_0x18_FeedbagRequestAuthorizationToHost struct {
 	Unknown    uint16
 }
 
+type SNAC_0x13_0x19_FeedbagRequestAuthorizeToClient struct {
+	TLV
+	ScreenName string `oscar:"len_prefix=uint8"`
+	Reason     string `oscar:"len_prefix=uint16"`
+	Unknown    uint16
+}
+
 type SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost struct {
 	ScreenName string `oscar:"len_prefix=uint8"`
 	Accepted   uint8
@@ -1856,9 +1863,17 @@ type SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost struct {
 }
 
 type SNAC_0x13_0x1B_FeedbagRespondAuthorizeToClient struct {
+	TLV
 	ScreenName string `oscar:"len_prefix=uint8"`
 	Accepted   uint8
 	Reason     string `oscar:"len_prefix=uint16"`
+	Nullterm   uint16
+}
+
+type SNAC_0x13_0x1C_FeedbagBuddyAdded struct {
+	TLV
+	ScreenName string `oscar:"len_prefix=uint8"`
+	Nullterm   uint16
 }
 
 //