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

implement chat room creation in AIM 4.x

Chat room creation in AIM 4.x differs from AIM 5.x in that AIM 4.x
always makes a separate connection for the ChatNav food group, whereas
AIM 5.x uses the ChatNav food group provided by the BOS service.

To make chat room creation work, this commit adds a ChatNav server that
listens on a dedicated TCP socket exclusively for ChatNav food group
requests and adds logic to OService ServiceRequest that provides
service discovery info for the new service.
Mike 2 лет назад
Родитель
Сommit
e3aa022e02

+ 3 - 0
.mockery.yaml

@@ -51,6 +51,9 @@ packages:
       OServiceChatService:
         config:
           filename: "mock_oservice_chat_test.go"
+      OServiceChatNavService:
+        config:
+          filename: "mock_oservice_chat_nav_test.go"
       LocateService:
         config:
           filename: "mock_locate_test.go"

+ 20 - 0
cmd/server/main.go

@@ -92,6 +92,26 @@ func main() {
 		}.Start()
 		wg.Done()
 	}(logger)
+	go func(logger *slog.Logger) {
+		logger = logger.With("svc", "CHAT_NAV")
+		authService := foodgroup.NewAuthService(cfg, sessionManager, sessionManager, feedbagStore, feedbagStore, chatRegistry)
+		oServiceService := foodgroup.NewOServiceService(cfg, sessionManager, feedbagStore)
+		oServiceServiceForChatNav := foodgroup.NewOServiceServiceForChatNav(*oServiceService, chatRegistry)
+		newChatSessMgr := func() foodgroup.SessionManager { return state.NewInMemorySessionManager(logger) }
+		chatNavService := foodgroup.NewChatNavService(logger, chatRegistry, state.NewChatRoom, newChatSessMgr)
+
+		oscar.ChatNavServer{
+			AuthService: authService,
+			Config:      cfg,
+			Handler: handler.NewChatNavRouter(handler.Handlers{
+				ChatNavHandler:         handler.NewChatNavHandler(chatNavService, logger),
+				OServiceChatNavHandler: handler.NewOServiceHandlerForChatNav(logger, oServiceService, oServiceServiceForChatNav),
+			}),
+			Logger:         logger,
+			OnlineNotifier: oServiceServiceForChatNav,
+		}.Start()
+		wg.Done()
+	}(logger)
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "AUTH")
 		authHandler := foodgroup.NewAuthService(cfg, sessionManager, nil, feedbagStore, feedbagStore, chatRegistry)

+ 1 - 0
config/config.go

@@ -7,6 +7,7 @@ import "fmt"
 type Config struct {
 	BOSPort     int    `envconfig:"BOS_PORT" default:"5191" description:"The port that the BOS service binds to."`
 	BUCPPort    int    `envconfig:"BUCP_PORT" default:"5190" description:"The port that the auth service binds to."`
+	ChatNavPort int    `envconfig:"CHAT_NAV_PORT" default:"5193" description:"The port that the chat nav service binds to."`
 	ChatPort    int    `envconfig:"CHAT_PORT" default:"5192" description:"The port that the chat service binds to."`
 	DBPath      string `envconfig:"DB_PATH" default:"oscar.sqlite" description:"The path to the SQLite database file. The file and DB schema are auto-created if they doesn't exist."`
 	DisableAuth bool   `envconfig:"DISABLE_AUTH" default:"true" description:"Disable password check and auto-create new users at login time. Useful for quickly creating new accounts during development without having to register new users via the management API."`

+ 3 - 0
config/settings.bat

@@ -4,6 +4,9 @@ set BOS_PORT=5191
 rem The port that the auth service binds to.
 set BUCP_PORT=5190
 
+rem The port that the chat nav service binds to.
+set CHAT_NAV_PORT=5193
+
 rem The port that the chat service binds to.
 set CHAT_PORT=5192
 

+ 4 - 1
config/settings.env

@@ -4,6 +4,9 @@ export BOS_PORT=5191
 # The port that the auth service binds to.
 export BUCP_PORT=5190
 
+# The port that the chat nav service binds to.
+export CHAT_NAV_PORT=5193
+
 # The port that the chat service binds to.
 export CHAT_PORT=5192
 
@@ -22,7 +25,7 @@ export FAIL_FAST=false
 
 # Set logging granularity. Possible values: 'trace', 'debug', 'info', 'warn',
 # 'error'.
-export LOG_LEVEL=info
+export LOG_LEVEL=debug
 
 # The hostname that AIM clients connect to in order to reach OSCAR services
 # (BOS, BUCP, chat, etc). Make sure the hostname is reachable by all clients.

+ 12 - 11
foodgroup/chat_nav.go

@@ -46,14 +46,15 @@ func (s ChatNavService) RequestChatRights(_ context.Context, inFrame wire.SNACFr
 						Identifier: 4,
 						TLVBlock: wire.TLVBlock{
 							TLVList: wire.TLVList{
-								wire.NewTLV(wire.ChatNavTLVClassPerms, uint16(0x0010)),
-								wire.NewTLV(wire.ChatNavTLVFlags, uint16(15)),
-								wire.NewTLV(wire.ChatNavTLVRoomName, "default exchange"),
-								wire.NewTLV(wire.ChatNavTLVCreatePerms, uint8(2)),
-								wire.NewTLV(wire.ChatNavTLVCharSet1, "us-ascii"),
-								wire.NewTLV(wire.ChatNavTLVLang1, "en"),
-								wire.NewTLV(wire.ChatNavTLVCharSet2, "us-ascii"),
-								wire.NewTLV(wire.ChatNavTLVLang2, "en"),
+								wire.NewTLV(wire.ChatRoomTLVClassPerms, uint16(0x0010)),
+								wire.NewTLV(wire.ChatRoomTLVMaxNameLen, uint16(100)),
+								wire.NewTLV(wire.ChatRoomTLVFlags, uint16(15)),
+								wire.NewTLV(wire.ChatRoomTLVRoomName, "default exchange"),
+								wire.NewTLV(wire.ChatRoomTLVNavCreatePerms, uint8(2)),
+								wire.NewTLV(wire.ChatRoomTLVCharSet1, "us-ascii"),
+								wire.NewTLV(wire.ChatRoomTLVLang1, "en"),
+								wire.NewTLV(wire.ChatRoomTLVCharSet2, "us-ascii"),
+								wire.NewTLV(wire.ChatRoomTLVLang2, "en"),
 							},
 						},
 					}),
@@ -67,7 +68,7 @@ func (s ChatNavService) RequestChatRights(_ context.Context, inFrame wire.SNACFr
 // participant. It returns SNAC wire.ChatNavNavInfo, which contains metadata
 // for the chat room.
 func (s ChatNavService) CreateRoom(_ context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (wire.SNACMessage, error) {
-	name, hasName := inBody.String(wire.ChatTLVRoomName)
+	name, hasName := inBody.String(wire.ChatRoomTLVRoomName)
 	if !hasName {
 		return wire.SNACMessage{}, errors.New("unable to find chat name")
 	}
@@ -95,7 +96,7 @@ func (s ChatNavService) CreateRoom(_ context.Context, sess *state.Session, inFra
 		Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{
 			TLVRestBlock: wire.TLVRestBlock{
 				TLVList: wire.TLVList{
-					wire.NewTLV(wire.ChatNavRequestRoomInfo, wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+					wire.NewTLV(wire.ChatNavTLVRoomInfo, wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
 						Exchange:       inBody.Exchange,
 						Cookie:         room.Cookie,
 						InstanceNumber: inBody.InstanceNumber,
@@ -127,7 +128,7 @@ func (s ChatNavService) RequestRoomInfo(_ context.Context, inFrame wire.SNACFram
 		Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{
 			TLVRestBlock: wire.TLVRestBlock{
 				TLVList: wire.TLVList{
-					wire.NewTLV(wire.ChatNavRequestRoomInfo, wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+					wire.NewTLV(wire.ChatNavTLVRoomInfo, wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
 						Exchange:       room.Exchange,
 						Cookie:         room.Cookie,
 						InstanceNumber: room.InstanceNumber,

+ 10 - 9
foodgroup/chat_nav_test.go

@@ -41,7 +41,7 @@ func TestChatNavService_CreateRoom(t *testing.T) {
 		DetailLevel:    3,
 		TLVBlock: wire.TLVBlock{
 			TLVList: wire.TLVList{
-				wire.NewTLV(wire.ChatTLVRoomName, "the-chat-room-name"),
+				wire.NewTLV(wire.ChatRoomTLVRoomName, "the-chat-room-name"),
 			},
 		},
 	}
@@ -178,14 +178,15 @@ func TestChatNavService_RequestChatRights(t *testing.T) {
 						Identifier: 4,
 						TLVBlock: wire.TLVBlock{
 							TLVList: wire.TLVList{
-								wire.NewTLV(wire.ChatNavTLVClassPerms, uint16(0x0010)),
-								wire.NewTLV(wire.ChatNavTLVFlags, uint16(15)),
-								wire.NewTLV(wire.ChatNavTLVRoomName, "default exchange"),
-								wire.NewTLV(wire.ChatNavTLVCreatePerms, uint8(2)),
-								wire.NewTLV(wire.ChatNavTLVCharSet1, "us-ascii"),
-								wire.NewTLV(wire.ChatNavTLVLang1, "en"),
-								wire.NewTLV(wire.ChatNavTLVCharSet2, "us-ascii"),
-								wire.NewTLV(wire.ChatNavTLVLang2, "en"),
+								wire.NewTLV(wire.ChatRoomTLVClassPerms, uint16(0x0010)),
+								wire.NewTLV(wire.ChatRoomTLVMaxNameLen, uint16(100)),
+								wire.NewTLV(wire.ChatRoomTLVFlags, uint16(15)),
+								wire.NewTLV(wire.ChatRoomTLVRoomName, "default exchange"),
+								wire.NewTLV(wire.ChatRoomTLVNavCreatePerms, uint8(2)),
+								wire.NewTLV(wire.ChatRoomTLVCharSet1, "us-ascii"),
+								wire.NewTLV(wire.ChatRoomTLVLang1, "en"),
+								wire.NewTLV(wire.ChatRoomTLVCharSet2, "us-ascii"),
+								wire.NewTLV(wire.ChatRoomTLVLang2, "en"),
 							},
 						},
 					}),

+ 106 - 45
foodgroup/oservice.go

@@ -345,62 +345,87 @@ type chatLoginCookie struct {
 	SessID string `len_prefix:"uint16"`
 }
 
-// ServiceRequest configures food group settings for the current user.
-// This method only provides services for the Chat food group; return
-// wire.ErrUnsupportedFoodGroup for any other food group. When the chat
-// food group is specified in inFrame, add user to the chat room specified by
-// TLV 0x01. It returns SNAC wire.OServiceServiceResponse containing
-// metadata the client needs to connect to the chat service and join the chat
-// room.
+// ServiceRequest handles service discovery, providing a host name and metadata
+// for connecting to the food group service specified in inFrame.
+// Depending on the food group specified, the method behaves as follows:
+// - ChatNav: Directs the user back to the current BOS server, which provides
+// ChatNav services. AIM 4.8 requests ChatNav service info even though
+// HostOnline reports it as available via BOS.
+// - Chat: Directs the client to the chat server along with metadata for
+// connecting to the chat room specified in inFrame.
+// - Other Food Groups: Returns wire.ErrUnsupportedFoodGroup.
 func (s OServiceServiceForBOS) ServiceRequest(_ context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest) (wire.SNACMessage, error) {
-	if inBody.FoodGroup != wire.Chat {
-		err := fmt.Errorf("%w. food group: %s", wire.ErrUnsupportedFoodGroup, wire.FoodGroupName(inBody.FoodGroup))
-		return wire.SNACMessage{}, err
-	}
-
-	roomMeta, ok := inBody.Slice(0x01)
-	if !ok {
-		return wire.SNACMessage{}, errors.New("missing room info")
-	}
+	switch inBody.FoodGroup {
+	case wire.ChatNav:
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.OService,
+				SubGroup:  wire.OServiceServiceResponse,
+				RequestID: inFrame.RequestID,
+			},
+			Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLV(wire.OServiceTLVTagsReconnectHere, config.Address(s.cfg.OSCARHost, s.cfg.ChatNavPort)),
+						wire.NewTLV(wire.OServiceTLVTagsLoginCookie, sess.ID()),
+						wire.NewTLV(wire.OServiceTLVTagsGroupID, wire.ChatNav),
+						wire.NewTLV(wire.OServiceTLVTagsSSLCertName, ""),
+						wire.NewTLV(wire.OServiceTLVTagsSSLState, uint8(0x00)),
+					},
+				},
+			},
+		}, nil
+	case wire.Chat:
+		roomMeta, ok := inBody.Slice(0x01)
+		if !ok {
+			return wire.SNACMessage{}, errors.New("missing room info")
+		}
 
-	roomSNAC := wire.SNAC_0x01_0x04_TLVRoomInfo{}
-	if err := wire.Unmarshal(&roomSNAC, bytes.NewBuffer(roomMeta)); err != nil {
-		return wire.SNACMessage{}, err
-	}
+		roomSNAC := wire.SNAC_0x01_0x04_TLVRoomInfo{}
+		if err := wire.Unmarshal(&roomSNAC, bytes.NewBuffer(roomMeta)); err != nil {
+			return wire.SNACMessage{}, err
+		}
 
-	room, chatSessMgr, err := s.chatRegistry.Retrieve(roomSNAC.Cookie)
-	if err != nil {
-		return wire.SNACMessage{}, err
-	}
-	chatSess := chatSessMgr.(SessionManager).AddSession(sess.ID(), sess.ScreenName())
-	chatSess.SetChatRoomCookie(room.Cookie)
+		room, chatSessMgr, err := s.chatRegistry.Retrieve(roomSNAC.Cookie)
+		if err != nil {
+			return wire.SNACMessage{}, err
+		}
+		chatSess := chatSessMgr.(SessionManager).AddSession(sess.ID(), sess.ScreenName())
+		chatSess.SetChatRoomCookie(room.Cookie)
 
-	return wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceServiceResponse,
-			RequestID: inFrame.RequestID,
-		},
-		Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
-			TLVRestBlock: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					wire.NewTLV(wire.OServiceTLVTagsReconnectHere, config.Address(s.cfg.OSCARHost, s.cfg.ChatPort)),
-					wire.NewTLV(wire.OServiceTLVTagsLoginCookie, chatLoginCookie{
-						Cookie: room.Cookie,
-						SessID: sess.ID(),
-					}),
-					wire.NewTLV(wire.OServiceTLVTagsGroupID, wire.Chat),
-					wire.NewTLV(wire.OServiceTLVTagsSSLCertName, ""),
-					wire.NewTLV(wire.OServiceTLVTagsSSLState, uint8(0x00)),
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.OService,
+				SubGroup:  wire.OServiceServiceResponse,
+				RequestID: inFrame.RequestID,
+			},
+			Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLV(wire.OServiceTLVTagsReconnectHere, config.Address(s.cfg.OSCARHost, s.cfg.ChatPort)),
+						wire.NewTLV(wire.OServiceTLVTagsLoginCookie, chatLoginCookie{
+							Cookie: room.Cookie,
+							SessID: sess.ID(),
+						}),
+						wire.NewTLV(wire.OServiceTLVTagsGroupID, wire.Chat),
+						wire.NewTLV(wire.OServiceTLVTagsSSLCertName, ""),
+						wire.NewTLV(wire.OServiceTLVTagsSSLState, uint8(0x00)),
+					},
 				},
 			},
-		},
-	}, nil
+		}, nil
+	default:
+		err := fmt.Errorf("%w. food group: %s", wire.ErrUnsupportedFoodGroup, wire.FoodGroupName(inBody.FoodGroup))
+		return wire.SNACMessage{}, err
+	}
 }
 
 // HostOnline initiates the BOS protocol sequence.
 // It returns SNAC wire.OServiceHostOnline containing the list food groups
 // supported by the BOS service.
+// ChatNav is provided by BOS in addition to the standalone ChatNav service.
+// AIM 4.x always creates a secondary TCP connection for ChatNav, whereas 5.x
+// can use the existing BOS connection for ChatNav services.
 func (s OServiceServiceForBOS) HostOnline() wire.SNACMessage {
 	return wire.SNACMessage{
 		Frame: wire.SNACFrame{
@@ -502,3 +527,39 @@ func (s OServiceServiceForChat) ClientOnline(ctx context.Context, sess *state.Se
 	setOnlineChatUsers(ctx, sess, chatSessMgr.(ChatMessageRelayer))
 	return nil
 }
+
+// NewOServiceServiceForChatNav creates a new instance of OServiceServiceForChat.
+func NewOServiceServiceForChatNav(oserviceService OServiceService, chatRegistry *state.ChatRegistry) *OServiceServiceForChatNav {
+	return &OServiceServiceForChatNav{
+		OServiceService: oserviceService,
+		chatRegistry:    chatRegistry,
+	}
+}
+
+// OServiceServiceForChatNav provides functionality for the OService food group
+// running on the Chat server.
+type OServiceServiceForChatNav struct {
+	OServiceService
+	chatRegistry *state.ChatRegistry
+}
+
+// HostOnline initiates the Chat protocol sequence.
+// It returns SNAC wire.OServiceHostOnline containing the list of food groups
+// supported by the Chat service.
+// ChatNav is provided by BOS in addition to the standalone ChatNav service.
+// AIM 4.x always creates a secondary TCP connection for ChatNav, whereas 5.x
+// can use the existing BOS connection for ChatNav services.
+func (s OServiceServiceForChatNav) HostOnline() wire.SNACMessage {
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceHostOnline,
+		},
+		Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
+			FoodGroups: []uint16{
+				wire.ChatNav,
+				wire.OService,
+			},
+		},
+	}
+}

+ 58 - 2
foodgroup/oservice_test.go

@@ -46,6 +46,40 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) {
 			},
 			expectErr: wire.ErrUnsupportedFoodGroup,
 		},
+		{
+			name: "request info for connecting to chat nav, return chat nav connection metadata",
+			cfg: config.Config{
+				OSCARHost:   "127.0.0.1",
+				ChatNavPort: 1234,
+			},
+			userSession: newTestSession("user_screen_name", sessOptCannedID),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x01_0x04_OServiceServiceRequest{
+					FoodGroup: wire.ChatNav,
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceServiceResponse,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.OServiceTLVTagsReconnectHere, "127.0.0.1:1234"),
+							wire.NewTLV(wire.OServiceTLVTagsLoginCookie, newTestSession("user_screen_name", sessOptCannedID).ID()),
+							wire.NewTLV(wire.OServiceTLVTagsGroupID, wire.ChatNav),
+							wire.NewTLV(wire.OServiceTLVTagsSSLCertName, ""),
+							wire.NewTLV(wire.OServiceTLVTagsSSLState, uint8(0x00)),
+						},
+					},
+				},
+			},
+		},
 		{
 			name: "request info for connecting to chat room, return chat service and chat room metadata",
 			cfg: config.Config{
@@ -157,8 +191,10 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) {
 			if tc.expectErr != nil {
 				return
 			}
-			// assert the user session is linked to the chat room
-			assert.Equal(t, chatSess.ChatRoomCookie(), tc.chatRoom.Cookie)
+			if tc.chatRoom != nil {
+				// assert the user session is linked to the chat room
+				assert.Equal(t, chatSess.ChatRoomCookie(), tc.chatRoom.Cookie)
+			}
 			//
 			// verify output
 			//
@@ -982,3 +1018,23 @@ func TestOServiceServiceForChat_ClientOnline(t *testing.T) {
 		})
 	}
 }
+
+func TestOServiceServiceForChatNav_HostOnline(t *testing.T) {
+	svc := NewOServiceServiceForChatNav(*NewOServiceService(config.Config{}, nil, nil), nil)
+
+	want := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceHostOnline,
+		},
+		Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
+			FoodGroups: []uint16{
+				wire.ChatNav,
+				wire.OService,
+			},
+		},
+	}
+
+	have := svc.HostOnline()
+	assert.Equal(t, want, have)
+}

+ 107 - 0
server/oscar/chat_nav.go

@@ -0,0 +1,107 @@
+package oscar
+
+import (
+	"context"
+	"errors"
+	"io"
+	"log/slog"
+	"net"
+	"os"
+
+	"github.com/mk6i/retro-aim-server/config"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// ChatNavServer provides client connection lifecycle management for the
+// ChatNav service. This service is only used by AIM 4.x clients that make a
+// separate ChatNav TCP connection. AIM 5.x clients call the ChatNav food group
+// provided by BOS without creating an additional TCP connection.
+type ChatNavServer struct {
+	AuthService
+	Handler
+	Logger *slog.Logger
+	OnlineNotifier
+	config.Config
+}
+
+// Start starts a TCP server and listens for ChatNav connections.
+func (rt ChatNavServer) Start() {
+	addr := config.Address("", rt.Config.ChatNavPort)
+	listener, err := net.Listen("tcp", addr)
+	if err != nil {
+		rt.Logger.Error("unable to bind chat nav server address", "err", err.Error())
+		os.Exit(1)
+	}
+	defer listener.Close()
+
+	rt.Logger.Info("starting chat nav service", "addr", addr)
+
+	for {
+		conn, err := listener.Accept()
+		if err != nil {
+			rt.Logger.Error(err.Error())
+			continue
+		}
+		ctx := context.Background()
+		ctx = context.WithValue(ctx, "ip", conn.RemoteAddr().String())
+		rt.Logger.DebugContext(ctx, "accepted connection")
+		go func() {
+			if err := rt.handleNewConnection(ctx, conn); err != nil {
+				rt.Logger.Info("user session failed", "err", err.Error())
+			}
+		}()
+	}
+}
+
+func (rt ChatNavServer) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser) error {
+	flapc := &flapClient{
+		r:        rwc,
+		sequence: 100,
+		w:        rwc,
+	}
+
+	flap, err := flapc.SignonHandshake()
+	if err != nil {
+		return err
+	}
+
+	var ok bool
+	sessionID, ok := flap.Slice(wire.OServiceTLVTagsLoginCookie)
+	if !ok {
+		return errors.New("unable to get session id from payload")
+	}
+
+	bosSess, err := rt.RetrieveBOSSession(string(sessionID))
+	if err != nil {
+		return err
+	}
+	if bosSess == nil {
+		return errors.New("session not found")
+	}
+
+	defer func() {
+		rwc.Close()
+	}()
+
+	ctx = context.WithValue(ctx, "screenName", bosSess.ScreenName())
+
+	msg := rt.OnlineNotifier.HostOnline()
+	if err := flapc.SendSNAC(msg.Frame, msg.Body); err != nil {
+		return err
+	}
+
+	// We copy the session object here to make sure that
+	// dispatchIncomingMessages does not consume relayed messages produced by
+	// the BOS server. Without this hack, message consumption would be split
+	// between the BOS server and ChatNav server, which would result in
+	// incorrect sequence number generation, because each server has its own
+	// sequence counter. This hack can be removed by decoupling FLAP routing
+	// and message relaying, which are both performed in
+	// dispatchIncomingMessages.
+	sessCopy := state.NewSession()
+	sessCopy.SetScreenName(bosSess.ScreenName())
+	sessCopy.SetID(bosSess.ID())
+
+	return dispatchIncomingMessages(ctx, sessCopy, flapc, rwc, rt.Logger, rt.Handler, rt.Config)
+}

+ 110 - 0
server/oscar/chat_nav_test.go

@@ -0,0 +1,110 @@
+package oscar
+
+import (
+	"bytes"
+	"context"
+	"io"
+	"log/slog"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+func TestChatNavServer_handleNewConnection(t *testing.T) {
+	sess := state.NewSession()
+	sess.SetID("login-cookie-1234")
+
+	clientReader, serverWriter := io.Pipe()
+	serverReader, clientWriter := io.Pipe()
+
+	go func() {
+		// < receive FLAPSignonFrame
+		flap := wire.FLAPFrame{}
+		assert.NoError(t, wire.Unmarshal(&flap, serverReader))
+		buf, err := flap.ReadBody(serverReader)
+		assert.NoError(t, err)
+		flapSignonFrame := wire.FLAPSignonFrame{}
+		assert.NoError(t, wire.Unmarshal(&flapSignonFrame, buf))
+
+		// > send FLAPSignonFrame
+		flapSignonFrame = wire.FLAPSignonFrame{
+			FLAPVersion: 1,
+		}
+		flapSignonFrame.Append(wire.NewTLV(wire.OServiceTLVTagsLoginCookie, []byte(sess.ID())))
+		buf = &bytes.Buffer{}
+		assert.NoError(t, wire.Marshal(flapSignonFrame, buf))
+		flap = wire.FLAPFrame{
+			StartMarker:   42,
+			FrameType:     wire.FLAPFrameSignon,
+			PayloadLength: uint16(buf.Len()),
+		}
+		assert.NoError(t, wire.Marshal(flap, serverWriter))
+		_, err = serverWriter.Write(buf.Bytes())
+		assert.NoError(t, err)
+
+		// < receive SNAC_0x01_0x03_OServiceHostOnline
+		flap = wire.FLAPFrame{}
+		assert.NoError(t, wire.Unmarshal(&flap, serverReader))
+		buf, err = flap.ReadBody(serverReader)
+		assert.NoError(t, err)
+		frame := wire.SNACFrame{}
+		assert.NoError(t, wire.Unmarshal(&frame, buf))
+		body := wire.SNAC_0x01_0x03_OServiceHostOnline{}
+		assert.NoError(t, wire.Unmarshal(&body, buf))
+
+		// send the first request that should get relayed to Handler
+		flapc := flapClient{
+			w: serverWriter,
+		}
+		frame = wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceClientOnline,
+		}
+		assert.NoError(t, flapc.SendSNAC(frame, struct{}{}))
+		assert.NoError(t, serverWriter.Close())
+	}()
+
+	authService := newMockAuthService(t)
+	authService.EXPECT().
+		RetrieveBOSSession(sess.ID()).
+		Return(sess, nil)
+
+	onlineNotifier := newMockOnlineNotifier(t)
+	onlineNotifier.EXPECT().
+		HostOnline().
+		Return(wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.OService,
+				SubGroup:  wire.OServiceHostOnline,
+			},
+			Body: wire.SNAC_0x01_0x03_OServiceHostOnline{},
+		})
+
+	// we can't assert that the same sess instance created above is passed as a
+	// parameter to Handle because the session instance is copied
+	router := newMockHandler(t)
+	router.EXPECT().
+		Handle(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+		Run(func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) {
+			assert.Equal(t, wire.SNACFrame{
+				FoodGroup: wire.OService,
+				SubGroup:  wire.OServiceClientOnline,
+			}, inFrame)
+		}).Return(nil)
+
+	rt := ChatNavServer{
+		AuthService:    authService,
+		Handler:        router,
+		Logger:         slog.Default(),
+		OnlineNotifier: onlineNotifier,
+	}
+	rwc := pipeRWC{
+		PipeReader: clientReader,
+		PipeWriter: clientWriter,
+	}
+	assert.NoError(t, rt.handleNewConnection(context.Background(), rwc))
+}

+ 3 - 2
server/oscar/handler/chat_nav.go

@@ -2,10 +2,11 @@ package handler
 
 import (
 	"context"
-	"github.com/mk6i/retro-aim-server/server/oscar"
 	"io"
 	"log/slog"
 
+	"github.com/mk6i/retro-aim-server/server/oscar"
+
 	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
@@ -59,7 +60,7 @@ func (rt ChatNavHandler) CreateRoom(ctx context.Context, sess *state.Session, in
 	if err != nil {
 		return err
 	}
-	roomName, _ := inBody.String(wire.ChatTLVRoomName)
+	roomName, _ := inBody.String(wire.ChatRoomTLVRoomName)
 	rt.Logger.InfoContext(ctx, "user started a chat room", slog.String("roomName", roomName))
 	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
 	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)

+ 368 - 0
server/oscar/handler/mock_oservice_chat_nav_test.go

@@ -0,0 +1,368 @@
+// Code generated by mockery v2.40.1. DO NOT EDIT.
+
+package handler
+
+import (
+	context "context"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+)
+
+// mockOServiceChatNavService is an autogenerated mock type for the OServiceChatNavService type
+type mockOServiceChatNavService struct {
+	mock.Mock
+}
+
+type mockOServiceChatNavService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockOServiceChatNavService) EXPECT() *mockOServiceChatNavService_Expecter {
+	return &mockOServiceChatNavService_Expecter{mock: &_m.Mock}
+}
+
+// ClientVersions provides a mock function with given fields: ctx, frame, bodyIn
+func (_m *mockOServiceChatNavService) ClientVersions(ctx context.Context, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x17_OServiceClientVersions) wire.SNACMessage {
+	ret := _m.Called(ctx, frame, bodyIn)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ClientVersions")
+	}
+
+	var r0 wire.SNACMessage
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x01_0x17_OServiceClientVersions) wire.SNACMessage); ok {
+		r0 = rf(ctx, frame, bodyIn)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	return r0
+}
+
+// mockOServiceChatNavService_ClientVersions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClientVersions'
+type mockOServiceChatNavService_ClientVersions_Call struct {
+	*mock.Call
+}
+
+// ClientVersions is a helper method to define mock.On call
+//   - ctx context.Context
+//   - frame wire.SNACFrame
+//   - bodyIn wire.SNAC_0x01_0x17_OServiceClientVersions
+func (_e *mockOServiceChatNavService_Expecter) ClientVersions(ctx interface{}, frame interface{}, bodyIn interface{}) *mockOServiceChatNavService_ClientVersions_Call {
+	return &mockOServiceChatNavService_ClientVersions_Call{Call: _e.mock.On("ClientVersions", ctx, frame, bodyIn)}
+}
+
+func (_c *mockOServiceChatNavService_ClientVersions_Call) Run(run func(ctx context.Context, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x17_OServiceClientVersions)) *mockOServiceChatNavService_ClientVersions_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNACFrame), args[2].(wire.SNAC_0x01_0x17_OServiceClientVersions))
+	})
+	return _c
+}
+
+func (_c *mockOServiceChatNavService_ClientVersions_Call) Return(_a0 wire.SNACMessage) *mockOServiceChatNavService_ClientVersions_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockOServiceChatNavService_ClientVersions_Call) RunAndReturn(run func(context.Context, wire.SNACFrame, wire.SNAC_0x01_0x17_OServiceClientVersions) wire.SNACMessage) *mockOServiceChatNavService_ClientVersions_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// HostOnline provides a mock function with given fields:
+func (_m *mockOServiceChatNavService) HostOnline() wire.SNACMessage {
+	ret := _m.Called()
+
+	if len(ret) == 0 {
+		panic("no return value specified for HostOnline")
+	}
+
+	var r0 wire.SNACMessage
+	if rf, ok := ret.Get(0).(func() wire.SNACMessage); ok {
+		r0 = rf()
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	return r0
+}
+
+// mockOServiceChatNavService_HostOnline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HostOnline'
+type mockOServiceChatNavService_HostOnline_Call struct {
+	*mock.Call
+}
+
+// HostOnline is a helper method to define mock.On call
+func (_e *mockOServiceChatNavService_Expecter) HostOnline() *mockOServiceChatNavService_HostOnline_Call {
+	return &mockOServiceChatNavService_HostOnline_Call{Call: _e.mock.On("HostOnline")}
+}
+
+func (_c *mockOServiceChatNavService_HostOnline_Call) Run(run func()) *mockOServiceChatNavService_HostOnline_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run()
+	})
+	return _c
+}
+
+func (_c *mockOServiceChatNavService_HostOnline_Call) Return(_a0 wire.SNACMessage) *mockOServiceChatNavService_HostOnline_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockOServiceChatNavService_HostOnline_Call) RunAndReturn(run func() wire.SNACMessage) *mockOServiceChatNavService_HostOnline_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// IdleNotification provides a mock function with given fields: ctx, sess, bodyIn
+func (_m *mockOServiceChatNavService) IdleNotification(ctx context.Context, sess *state.Session, bodyIn wire.SNAC_0x01_0x11_OServiceIdleNotification) error {
+	ret := _m.Called(ctx, sess, bodyIn)
+
+	if len(ret) == 0 {
+		panic("no return value specified for IdleNotification")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNAC_0x01_0x11_OServiceIdleNotification) error); ok {
+		r0 = rf(ctx, sess, bodyIn)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockOServiceChatNavService_IdleNotification_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IdleNotification'
+type mockOServiceChatNavService_IdleNotification_Call struct {
+	*mock.Call
+}
+
+// IdleNotification is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - bodyIn wire.SNAC_0x01_0x11_OServiceIdleNotification
+func (_e *mockOServiceChatNavService_Expecter) IdleNotification(ctx interface{}, sess interface{}, bodyIn interface{}) *mockOServiceChatNavService_IdleNotification_Call {
+	return &mockOServiceChatNavService_IdleNotification_Call{Call: _e.mock.On("IdleNotification", ctx, sess, bodyIn)}
+}
+
+func (_c *mockOServiceChatNavService_IdleNotification_Call) Run(run func(ctx context.Context, sess *state.Session, bodyIn wire.SNAC_0x01_0x11_OServiceIdleNotification)) *mockOServiceChatNavService_IdleNotification_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNAC_0x01_0x11_OServiceIdleNotification))
+	})
+	return _c
+}
+
+func (_c *mockOServiceChatNavService_IdleNotification_Call) Return(_a0 error) *mockOServiceChatNavService_IdleNotification_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockOServiceChatNavService_IdleNotification_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNAC_0x01_0x11_OServiceIdleNotification) error) *mockOServiceChatNavService_IdleNotification_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RateParamsQuery provides a mock function with given fields: ctx, frame
+func (_m *mockOServiceChatNavService) RateParamsQuery(ctx context.Context, frame wire.SNACFrame) wire.SNACMessage {
+	ret := _m.Called(ctx, frame)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RateParamsQuery")
+	}
+
+	var r0 wire.SNACMessage
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame) wire.SNACMessage); ok {
+		r0 = rf(ctx, frame)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	return r0
+}
+
+// mockOServiceChatNavService_RateParamsQuery_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RateParamsQuery'
+type mockOServiceChatNavService_RateParamsQuery_Call struct {
+	*mock.Call
+}
+
+// RateParamsQuery is a helper method to define mock.On call
+//   - ctx context.Context
+//   - frame wire.SNACFrame
+func (_e *mockOServiceChatNavService_Expecter) RateParamsQuery(ctx interface{}, frame interface{}) *mockOServiceChatNavService_RateParamsQuery_Call {
+	return &mockOServiceChatNavService_RateParamsQuery_Call{Call: _e.mock.On("RateParamsQuery", ctx, frame)}
+}
+
+func (_c *mockOServiceChatNavService_RateParamsQuery_Call) Run(run func(ctx context.Context, frame wire.SNACFrame)) *mockOServiceChatNavService_RateParamsQuery_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNACFrame))
+	})
+	return _c
+}
+
+func (_c *mockOServiceChatNavService_RateParamsQuery_Call) Return(_a0 wire.SNACMessage) *mockOServiceChatNavService_RateParamsQuery_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockOServiceChatNavService_RateParamsQuery_Call) RunAndReturn(run func(context.Context, wire.SNACFrame) wire.SNACMessage) *mockOServiceChatNavService_RateParamsQuery_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RateParamsSubAdd provides a mock function with given fields: _a0, _a1
+func (_m *mockOServiceChatNavService) RateParamsSubAdd(_a0 context.Context, _a1 wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd) {
+	_m.Called(_a0, _a1)
+}
+
+// mockOServiceChatNavService_RateParamsSubAdd_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RateParamsSubAdd'
+type mockOServiceChatNavService_RateParamsSubAdd_Call struct {
+	*mock.Call
+}
+
+// RateParamsSubAdd is a helper method to define mock.On call
+//   - _a0 context.Context
+//   - _a1 wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd
+func (_e *mockOServiceChatNavService_Expecter) RateParamsSubAdd(_a0 interface{}, _a1 interface{}) *mockOServiceChatNavService_RateParamsSubAdd_Call {
+	return &mockOServiceChatNavService_RateParamsSubAdd_Call{Call: _e.mock.On("RateParamsSubAdd", _a0, _a1)}
+}
+
+func (_c *mockOServiceChatNavService_RateParamsSubAdd_Call) Run(run func(_a0 context.Context, _a1 wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd)) *mockOServiceChatNavService_RateParamsSubAdd_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd))
+	})
+	return _c
+}
+
+func (_c *mockOServiceChatNavService_RateParamsSubAdd_Call) Return() *mockOServiceChatNavService_RateParamsSubAdd_Call {
+	_c.Call.Return()
+	return _c
+}
+
+func (_c *mockOServiceChatNavService_RateParamsSubAdd_Call) RunAndReturn(run func(context.Context, wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd)) *mockOServiceChatNavService_RateParamsSubAdd_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetUserInfoFields provides a mock function with given fields: ctx, sess, frame, bodyIn
+func (_m *mockOServiceChatNavService) SetUserInfoFields(ctx context.Context, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, frame, bodyIn)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetUserInfoFields")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields) (wire.SNACMessage, error)); ok {
+		return rf(ctx, sess, frame, bodyIn)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields) wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, frame, bodyIn)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields) error); ok {
+		r1 = rf(ctx, sess, frame, bodyIn)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockOServiceChatNavService_SetUserInfoFields_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetUserInfoFields'
+type mockOServiceChatNavService_SetUserInfoFields_Call struct {
+	*mock.Call
+}
+
+// SetUserInfoFields is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - frame wire.SNACFrame
+//   - bodyIn wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields
+func (_e *mockOServiceChatNavService_Expecter) SetUserInfoFields(ctx interface{}, sess interface{}, frame interface{}, bodyIn interface{}) *mockOServiceChatNavService_SetUserInfoFields_Call {
+	return &mockOServiceChatNavService_SetUserInfoFields_Call{Call: _e.mock.On("SetUserInfoFields", ctx, sess, frame, bodyIn)}
+}
+
+func (_c *mockOServiceChatNavService_SetUserInfoFields_Call) Run(run func(ctx context.Context, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields)) *mockOServiceChatNavService_SetUserInfoFields_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields))
+	})
+	return _c
+}
+
+func (_c *mockOServiceChatNavService_SetUserInfoFields_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockOServiceChatNavService_SetUserInfoFields_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockOServiceChatNavService_SetUserInfoFields_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields) (wire.SNACMessage, error)) *mockOServiceChatNavService_SetUserInfoFields_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// UserInfoQuery provides a mock function with given fields: ctx, sess, frame
+func (_m *mockOServiceChatNavService) UserInfoQuery(ctx context.Context, sess *state.Session, frame wire.SNACFrame) wire.SNACMessage {
+	ret := _m.Called(ctx, sess, frame)
+
+	if len(ret) == 0 {
+		panic("no return value specified for UserInfoQuery")
+	}
+
+	var r0 wire.SNACMessage
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame) wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, frame)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	return r0
+}
+
+// mockOServiceChatNavService_UserInfoQuery_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UserInfoQuery'
+type mockOServiceChatNavService_UserInfoQuery_Call struct {
+	*mock.Call
+}
+
+// UserInfoQuery is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - frame wire.SNACFrame
+func (_e *mockOServiceChatNavService_Expecter) UserInfoQuery(ctx interface{}, sess interface{}, frame interface{}) *mockOServiceChatNavService_UserInfoQuery_Call {
+	return &mockOServiceChatNavService_UserInfoQuery_Call{Call: _e.mock.On("UserInfoQuery", ctx, sess, frame)}
+}
+
+func (_c *mockOServiceChatNavService_UserInfoQuery_Call) Run(run func(ctx context.Context, sess *state.Session, frame wire.SNACFrame)) *mockOServiceChatNavService_UserInfoQuery_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame))
+	})
+	return _c
+}
+
+func (_c *mockOServiceChatNavService_UserInfoQuery_Call) Return(_a0 wire.SNACMessage) *mockOServiceChatNavService_UserInfoQuery_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockOServiceChatNavService_UserInfoQuery_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame) wire.SNACMessage) *mockOServiceChatNavService_UserInfoQuery_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockOServiceChatNavService creates a new instance of mockOServiceChatNavService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockOServiceChatNavService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockOServiceChatNavService {
+	mock := &mockOServiceChatNavService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 34 - 1
server/oscar/handler/oservice.go

@@ -2,10 +2,11 @@ package handler
 
 import (
 	"context"
-	"github.com/mk6i/retro-aim-server/server/oscar"
 	"io"
 	"log/slog"
 
+	"github.com/mk6i/retro-aim-server/server/oscar"
+
 	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
@@ -33,6 +34,11 @@ type OServiceChatService interface {
 	ClientOnline(ctx context.Context, sess *state.Session) error
 }
 
+type OServiceChatNavService interface {
+	OServiceService
+	HostOnline() wire.SNACMessage
+}
+
 type OServiceHandler struct {
 	OServiceService
 	middleware.RouteLogger
@@ -158,3 +164,30 @@ func (s OServiceChatHandler) ClientOnline(ctx context.Context, sess *state.Sessi
 	s.LogRequest(ctx, inFrame, inBody)
 	return s.OServiceChatService.ClientOnline(ctx, sess)
 }
+
+func NewOServiceHandlerForChatNav(logger *slog.Logger, oServiceService OServiceService, oServiceChatNavService OServiceChatNavService) OServiceChatNavHandler {
+	return OServiceChatNavHandler{
+		OServiceHandler: OServiceHandler{
+			OServiceService: oServiceService,
+			RouteLogger: middleware.RouteLogger{
+				Logger: logger,
+			},
+		},
+		OServiceChatNavService: oServiceChatNavService,
+	}
+}
+
+type OServiceChatNavHandler struct {
+	OServiceHandler
+	OServiceChatNavService
+}
+
+func (s OServiceChatNavHandler) ClientOnline(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, _ oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x01_0x02_OServiceClientOnline{}
+	if err := wire.Unmarshal(&inBody, r); err != nil {
+		return err
+	}
+	s.Logger.InfoContext(ctx, "user signed on")
+	s.LogRequest(ctx, inFrame, inBody)
+	return nil
+}

+ 23 - 0
server/oscar/handler/routes.go

@@ -20,6 +20,7 @@ type Handlers struct {
 	LocateHandler
 	OServiceBOSHandler
 	OServiceChatHandler
+	OServiceChatNavHandler
 }
 
 // NewBOSRouter initializes and configures a new Router instance for handling
@@ -93,3 +94,25 @@ func NewChatRouter(h Handlers) oscar.Router {
 
 	return router
 }
+
+// NewChatNavRouter initializes and configures a new Router instance for
+// handling OSCAR protocol requests in the context of a Basic Oscar Service
+// (BOS).
+func NewChatNavRouter(h Handlers) oscar.Router {
+	router := oscar.NewRouter()
+
+	router.Register(wire.ChatNav, wire.ChatNavCreateRoom, h.ChatNavHandler.CreateRoom)
+	router.Register(wire.ChatNav, wire.ChatNavRequestChatRights, h.ChatNavHandler.RequestChatRights)
+	router.Register(wire.ChatNav, wire.ChatNavRequestRoomInfo, h.ChatNavHandler.RequestRoomInfo)
+
+	router.Register(wire.OService, wire.OServiceClientOnline, h.OServiceChatNavHandler.ClientOnline)
+	router.Register(wire.OService, wire.OServiceClientVersions, h.OServiceChatNavHandler.OServiceHandler.ClientVersions)
+	router.Register(wire.OService, wire.OServiceIdleNotification, h.OServiceChatNavHandler.OServiceHandler.IdleNotification)
+	router.Register(wire.OService, wire.OServiceRateParamsQuery, h.OServiceChatNavHandler.OServiceHandler.RateParamsQuery)
+	router.Register(wire.OService, wire.OServiceRateParamsSubAdd, h.OServiceChatNavHandler.OServiceHandler.RateParamsSubAdd)
+	//router.Register(wire.OService, wire.OServiceServiceRequest, h.OServiceChatNavHandler.ServiceRequest)
+	router.Register(wire.OService, wire.OServiceSetUserInfoFields, h.OServiceChatNavHandler.OServiceHandler.SetUserInfoFields)
+	router.Register(wire.OService, wire.OServiceUserInfoQuery, h.OServiceChatNavHandler.OServiceHandler.UserInfoQuery)
+
+	return router
+}

+ 7 - 7
state/chat_registry.go

@@ -89,19 +89,19 @@ func (c ChatRoom) TLVList() []wire.TLV {
 		// - 4 Instancing Allowed
 		// - 8 Occupant Peek Allowed
 		// It's unclear what effect they actually have.
-		wire.NewTLV(wire.ChatNavTLVFlags, uint16(15)),
-		wire.NewTLV(wire.ChatNavCreateTime, uint32(c.CreateTime.Unix())),
-		wire.NewTLV(wire.ChatNavTLVMaxMsgLen, uint16(1024)),
-		wire.NewTLV(wire.ChatNavTLVMaxOccupancy, uint16(100)),
+		wire.NewTLV(wire.ChatRoomTLVFlags, uint16(15)),
+		wire.NewTLV(wire.ChatRoomTLVCreateTime, uint32(c.CreateTime.Unix())),
+		wire.NewTLV(wire.ChatRoomTLVMaxMsgLen, uint16(1024)),
+		wire.NewTLV(wire.ChatRoomTLVMaxOccupancy, uint16(100)),
 		// From protocols/oscar/family_chatnav.c in lib purple, these are the
 		// room creation permission values:
 		// - 0  creation not allowed
 		// - 1  room creation allowed
 		// - 2  exchange creation allowed
 		// It's unclear what effect they actually have.
-		wire.NewTLV(wire.ChatNavTLVCreatePerms, uint8(2)),
-		wire.NewTLV(wire.ChatNavTLVFullyQualifiedName, c.Name),
-		wire.NewTLV(wire.ChatNavTLVRoomName, c.Name),
+		wire.NewTLV(wire.ChatRoomTLVNavCreatePerms, uint8(2)),
+		wire.NewTLV(wire.ChatRoomTLVFullyQualifiedName, c.Name),
+		wire.NewTLV(wire.ChatRoomTLVRoomName, c.Name),
 	}
 }
 

+ 7 - 7
state/chat_registry_test.go

@@ -152,13 +152,13 @@ func TestChatRoom_TLVList(t *testing.T) {
 
 	have := room.TLVList()
 	want := []wire.TLV{
-		wire.NewTLV(wire.ChatNavTLVFlags, uint16(15)),
-		wire.NewTLV(wire.ChatNavCreateTime, uint32(room.CreateTime.Unix())),
-		wire.NewTLV(wire.ChatNavTLVMaxMsgLen, uint16(1024)),
-		wire.NewTLV(wire.ChatNavTLVMaxOccupancy, uint16(100)),
-		wire.NewTLV(wire.ChatNavTLVCreatePerms, uint8(2)),
-		wire.NewTLV(wire.ChatNavTLVFullyQualifiedName, room.Name),
-		wire.NewTLV(wire.ChatNavTLVRoomName, room.Name),
+		wire.NewTLV(wire.ChatRoomTLVFlags, uint16(15)),
+		wire.NewTLV(wire.ChatRoomTLVCreateTime, uint32(room.CreateTime.Unix())),
+		wire.NewTLV(wire.ChatRoomTLVMaxMsgLen, uint16(1024)),
+		wire.NewTLV(wire.ChatRoomTLVMaxOccupancy, uint16(100)),
+		wire.NewTLV(wire.ChatRoomTLVNavCreatePerms, uint8(2)),
+		wire.NewTLV(wire.ChatRoomTLVFullyQualifiedName, room.Name),
+		wire.NewTLV(wire.ChatRoomTLVRoomName, room.Name),
 	}
 
 	assert.Equal(t, want, have)

+ 18 - 17
wire/snacs.go

@@ -515,22 +515,9 @@ const (
 	ChatNavCreateRoom          uint16 = 0x0008
 	ChatNavNavInfo             uint16 = 0x0009
 
-	// referenced from protocols/oscar/family_chatnav.c in lib purple
-
-	ChatNavTLVMaxConcurrentRooms uint16 = 0x02
-	ChatNavTLVClassPerms         uint16 = 0x02
-	ChatNavTLVExchangeInfo       uint16 = 0x03
-	ChatNavTLVFullyQualifiedName uint16 = 0x6A
-	ChatNavCreateTime            uint16 = 0x00CA
-	ChatNavTLVFlags              uint16 = 0xC9
-	ChatNavTLVMaxMsgLen          uint16 = 0xD1
-	ChatNavTLVMaxOccupancy       uint16 = 0xD2
-	ChatNavTLVRoomName           uint16 = 0xD3
-	ChatNavTLVCreatePerms        uint16 = 0xD5
-	ChatNavTLVCharSet1           uint16 = 0xD6
-	ChatNavTLVLang1              uint16 = 0xD7
-	ChatNavTLVCharSet2           uint16 = 0xD8
-	ChatNavTLVLang2              uint16 = 0xD9
+	ChatNavTLVMaxConcurrentRooms uint16 = 0x0002
+	ChatNavTLVExchangeInfo       uint16 = 0x0003
+	ChatNavTLVRoomInfo           uint16 = 0x0004
 )
 
 type SNAC_0x0D_0x04_ChatNavRequestRoomInfo struct {
@@ -600,7 +587,21 @@ const (
 	ChatTLVPublicWhisperFlag    uint16 = 0x01
 	ChatTLVSenderInformation    uint16 = 0x03
 	ChatTLVEnableReflectionFlag uint16 = 0x06
-	ChatTLVRoomName             uint16 = 0xD3
+
+	// referenced from protocols/oscar/family_chatnav.c in lib purple
+	ChatRoomTLVClassPerms         uint16 = 0x02
+	ChatRoomTLVMaxNameLen         uint16 = 0x04
+	ChatRoomTLVFullyQualifiedName uint16 = 0x6A
+	ChatRoomTLVCreateTime         uint16 = 0xCA
+	ChatRoomTLVFlags              uint16 = 0xC9
+	ChatRoomTLVMaxMsgLen          uint16 = 0xD1
+	ChatRoomTLVMaxOccupancy       uint16 = 0xD2
+	ChatRoomTLVRoomName           uint16 = 0xD3
+	ChatRoomTLVNavCreatePerms     uint16 = 0xD5
+	ChatRoomTLVCharSet1           uint16 = 0xD6
+	ChatRoomTLVLang1              uint16 = 0xD7
+	ChatRoomTLVCharSet2           uint16 = 0xD8
+	ChatRoomTLVLang2              uint16 = 0xD9
 )
 
 type SNAC_0x0E_0x02_ChatRoomInfoUpdate struct {