Ver Fonte

move session manager to user pkg

Mike há 2 anos atrás
pai
commit
2769ddc6be

+ 2 - 1
cmd/main.go

@@ -2,6 +2,7 @@ package main
 
 import (
 	"fmt"
+	"github.com/mkaminski/goaim/user"
 	"os"
 	"sync"
 
@@ -24,7 +25,7 @@ func main() {
 	}
 
 	logger := server.NewLogger(cfg)
-	sm := server.NewSessionManager(logger)
+	sm := user.NewSessionManager(logger)
 	cr := server.NewChatRegistry()
 
 	wg := sync.WaitGroup{}

+ 2 - 1
server/bucp_test.go

@@ -2,6 +2,7 @@ package server
 
 import (
 	"bytes"
+	"github.com/mkaminski/goaim/user"
 	"os"
 	"testing"
 
@@ -134,7 +135,7 @@ func TestReceiveAndSendBUCPLoginRequest(t *testing.T) {
 				assert.NoError(t, err)
 			}
 			assert.NoError(t, fs.InsertUser(tc.userInDB))
-			sm := NewSessionManager(NewLogger(Config{}))
+			sm := user.NewSessionManager(NewLogger(Config{}))
 			//
 			// send input SNAC
 			//

+ 10 - 14
server/buddy.go

@@ -111,13 +111,13 @@ func BroadcastDeparture(ctx context.Context, sess *user.Session, sm SessionManag
 	return nil
 }
 
-func UnicastArrival(ctx context.Context, srcScreenName, destScreenName string, sm SessionManager) error {
-	sess, err := sm.RetrieveByScreenName(srcScreenName)
+func UnicastArrival(ctx context.Context, srcScreenName, destScreenName string, sm SessionManager) {
+	sess := sm.RetrieveByScreenName(srcScreenName)
 	switch {
-	case err != nil:
-		return err
+	case sess == nil:
+		fallthrough
 	case sess.Invisible(): // don't tell user this buddy is online
-		return nil
+		return
 	}
 	sm.SendToScreenName(ctx, destScreenName, oscar.XMessage{
 		SnacFrame: oscar.SnacFrame{
@@ -128,17 +128,15 @@ func UnicastArrival(ctx context.Context, srcScreenName, destScreenName string, s
 			TLVUserInfo: sess.TLVUserInfo(),
 		},
 	})
-
-	return nil
 }
 
-func UnicastDeparture(ctx context.Context, srcScreenName, destScreenName string, sm SessionManager) error {
-	sess, err := sm.RetrieveByScreenName(srcScreenName)
+func UnicastDeparture(ctx context.Context, srcScreenName, destScreenName string, sm SessionManager) {
+	sess := sm.RetrieveByScreenName(srcScreenName)
 	switch {
-	case err != nil:
-		return err
+	case sess == nil:
+		fallthrough
 	case sess.Invisible(): // don't tell user this buddy is online
-		return nil
+		return
 	}
 
 	sm.SendToScreenName(ctx, destScreenName, oscar.XMessage{
@@ -155,6 +153,4 @@ func UnicastDeparture(ctx context.Context, srcScreenName, destScreenName string,
 			},
 		},
 	})
-
-	return nil
 }

+ 1 - 1
server/chat_nav.go

@@ -114,7 +114,7 @@ func NewChatRoom(logger *slog.Logger) ChatRoom {
 	return ChatRoom{
 		Cookie:         uuid.New().String(),
 		CreateTime:     time.Now(),
-		SessionManager: NewSessionManager(logger),
+		SessionManager: user.NewSessionManager(logger),
 	}
 }
 

+ 2 - 2
server/connection.go

@@ -159,7 +159,7 @@ func HandleChatConnection(ctx context.Context, cr *ChatRegistry, rw io.ReadWrite
 	dispatchIncomingMessages(ctx, chatSess, seq, rw, logger, fnClientReqHandler, fnAlertHandler)
 }
 
-func HandleAuthConnection(cfg Config, sm *InMemorySessionManager, fm *FeedbagStore, conn net.Conn) {
+func HandleAuthConnection(cfg Config, sm *user.InMemorySessionManager, fm *FeedbagStore, conn net.Conn) {
 	defer conn.Close()
 	seq := uint32(100)
 	_, err := SendAndReceiveSignonFrame(conn, &seq)
@@ -262,7 +262,7 @@ func ListenBOS(cfg Config, router BOSServiceRouter, logger *slog.Logger) {
 	}
 }
 
-func ListenBUCPLogin(cfg Config, err error, logger *slog.Logger, sm *InMemorySessionManager, fm *FeedbagStore) {
+func ListenBUCPLogin(cfg Config, err error, logger *slog.Logger, sm *user.InMemorySessionManager, fm *FeedbagStore) {
 	addr := Address("", cfg.OSCARPort)
 	listener, err := net.Listen("tcp", addr)
 	if err != nil {

+ 4 - 3
server/connection_test.go

@@ -5,6 +5,7 @@ import (
 	"bytes"
 	"context"
 	"github.com/mkaminski/goaim/oscar"
+	"github.com/mkaminski/goaim/user"
 	"github.com/stretchr/testify/assert"
 	"io"
 	"sync"
@@ -20,7 +21,7 @@ func TestHandleChatConnection_Notification(t *testing.T) {
 
 	room := ChatRoom{
 		Name:           "test chat room!",
-		SessionManager: NewSessionManager(logger),
+		SessionManager: user.NewSessionManager(logger),
 	}
 	bobSess := room.NewSessionWithSN("bob-sess-id", "bob")
 	cr.Register(room)
@@ -88,7 +89,7 @@ func TestHandleChatConnection_ClientRequestFLAP(t *testing.T) {
 
 	room := ChatRoom{
 		Name:           "test chat room!",
-		SessionManager: NewSessionManager(logger),
+		SessionManager: user.NewSessionManager(logger),
 	}
 	bobSess := room.NewSessionWithSN("bob-sess-id", "bob")
 	cr.Register(room)
@@ -148,7 +149,7 @@ func TestHandleChatConnection_SessionClosed(t *testing.T) {
 
 	room := ChatRoom{
 		Name:           "test chat room!",
-		SessionManager: NewSessionManager(logger),
+		SessionManager: user.NewSessionManager(logger),
 	}
 	sess := room.NewSessionWithSN("bob-sess-id", "bob")
 	cr.Register(room)

+ 6 - 39
server/feedbag.go

@@ -2,7 +2,6 @@ package server
 
 import (
 	"context"
-	"errors"
 	"github.com/mkaminski/goaim/user"
 	"io"
 	"log/slog"
@@ -263,26 +262,12 @@ func (s FeedbagService) InsertItemHandler(ctx context.Context, sess *user.Sessio
 	for _, item := range snacPayloadIn.Items {
 		switch item.ClassID {
 		case oscar.FeedbagClassIdBuddy, oscar.FeedbagClassIDPermit: // add new buddy
-			err := UnicastArrival(ctx, item.Name, sess.ScreenName(), s.sm)
-			switch {
-			case errors.Is(err, ErrSessNotFound):
-				continue
-			case err != nil:
-				return oscar.XMessage{}, err
-			}
+			UnicastArrival(ctx, item.Name, sess.ScreenName(), s.sm)
 		case oscar.FeedbagClassIDDeny: // block buddy
 			// notify this user that buddy is offline
-			err := UnicastDeparture(ctx, item.Name, sess.ScreenName(), s.sm)
-			switch {
-			case errors.Is(err, ErrSessNotFound):
-				continue
-			case err != nil:
-				return oscar.XMessage{}, err
-			}
+			UnicastDeparture(ctx, item.Name, sess.ScreenName(), s.sm)
 			// notify former buddy that this user is offline
-			if err := UnicastDeparture(ctx, sess.ScreenName(), item.Name, s.sm); err != nil {
-				return oscar.XMessage{}, err
-			}
+			UnicastDeparture(ctx, sess.ScreenName(), item.Name, s.sm)
 		}
 	}
 
@@ -308,13 +293,7 @@ func (s FeedbagService) UpdateItemHandler(ctx context.Context, sess *user.Sessio
 	for _, item := range snacPayloadIn.Items {
 		switch item.ClassID {
 		case oscar.FeedbagClassIdBuddy, oscar.FeedbagClassIDPermit:
-			err := UnicastArrival(ctx, item.Name, sess.ScreenName(), s.sm)
-			switch {
-			case errors.Is(err, ErrSessNotFound):
-				continue
-			case err != nil:
-				return oscar.XMessage{}, err
-			}
+			UnicastArrival(ctx, item.Name, sess.ScreenName(), s.sm)
 		}
 	}
 
@@ -339,20 +318,8 @@ func (s FeedbagService) DeleteItemHandler(ctx context.Context, sess *user.Sessio
 
 	for _, item := range snacPayloadIn.Items {
 		if item.ClassID == oscar.FeedbagClassIDDeny {
-			err := UnicastArrival(ctx, item.Name, sess.ScreenName(), s.sm)
-			switch {
-			case errors.Is(err, ErrSessNotFound):
-				continue
-			case err != nil:
-				return oscar.XMessage{}, err
-			}
-			err = UnicastArrival(ctx, sess.ScreenName(), item.Name, s.sm)
-			switch {
-			case errors.Is(err, ErrSessNotFound):
-				continue
-			case err != nil:
-				return oscar.XMessage{}, err
-			}
+			UnicastArrival(ctx, item.Name, sess.ScreenName(), s.sm)
+			UnicastArrival(ctx, sess.ScreenName(), item.Name, s.sm)
 		}
 	}
 

+ 1 - 1
server/feedbag_store.go

@@ -406,7 +406,7 @@ type SessionManager interface {
 	NewSessionWithSN(sessID string, screenName string) *user.Session
 	Remove(sess *user.Session)
 	Retrieve(ID string) (*user.Session, bool)
-	RetrieveByScreenName(screenName string) (*user.Session, error)
+	RetrieveByScreenName(screenName string) *user.Session
 	SendToScreenName(ctx context.Context, screenName string, msg oscar.XMessage)
 	Broadcast(ctx context.Context, msg oscar.XMessage)
 	BroadcastExcept(ctx context.Context, except *user.Session, msg oscar.XMessage)

+ 24 - 4
server/feedbag_test.go

@@ -344,7 +344,7 @@ func TestInsertItemHandler(t *testing.T) {
 				err  error
 			}{
 				"buddy_offline": {
-					err: ErrSessNotFound,
+					sess: nil,
 				},
 			},
 			clientResponse: oscar.XMessage{
@@ -455,7 +455,7 @@ func TestInsertItemHandler(t *testing.T) {
 			},
 		},
 		{
-			name:        "user blocks buddy currently offline, expect OK response and no buddy departed events",
+			name:        "user blocks buddy currently offline, expect OK response and a superfluous buddy departed events",
 			userSession: newTestSession("user_screen_name"),
 			inputSNAC: oscar.SNAC_0x13_0x08_FeedbagInsertItem{
 				Items: []oscar.FeedbagItem{
@@ -473,7 +473,7 @@ func TestInsertItemHandler(t *testing.T) {
 					sess: newTestSession("user_screen_name"),
 				},
 				"buddy_1": {
-					err: ErrSessNotFound,
+					sess: nil,
 				},
 			},
 			clientResponse: oscar.XMessage{
@@ -485,6 +485,26 @@ func TestInsertItemHandler(t *testing.T) {
 					Results: []uint16{0x0000},
 				},
 			},
+			buddyMessages: []struct {
+				user string
+				msg  oscar.XMessage
+			}{
+				{
+					user: "buddy_1",
+					msg: oscar.XMessage{
+						SnacFrame: oscar.SnacFrame{
+							FoodGroup: oscar.BUDDY,
+							SubGroup:  oscar.BuddyDeparted,
+						},
+						SnacOut: oscar.SNAC_0x03_0x0B_BuddyDeparted{
+							TLVUserInfo: oscar.TLVUserInfo{
+								ScreenName:   "user_screen_name",
+								WarningLevel: 0,
+							},
+						},
+					},
+				},
+			},
 		},
 		{
 			name:        "user tries to block themselves, expect feedback error",
@@ -527,7 +547,7 @@ func TestInsertItemHandler(t *testing.T) {
 			for screenName, val := range tc.screenNameLookups {
 				sm.EXPECT().
 					RetrieveByScreenName(screenName).
-					Return(val.sess, val.err).
+					Return(val.sess).
 					Maybe()
 			}
 			for _, n := range tc.buddyMessages {

+ 4 - 8
server/icbm.go

@@ -2,7 +2,6 @@ package server
 
 import (
 	"context"
-	"errors"
 	"github.com/mkaminski/goaim/user"
 	"io"
 	"log/slog"
@@ -132,9 +131,8 @@ func (s ICBMService) ChannelMsgToHostHandler(ctx context.Context, sess *user.Ses
 		}, nil
 	}
 
-	recipSess, err := s.sm.RetrieveByScreenName(snacPayloadIn.ScreenName)
-	switch {
-	case errors.Is(err, ErrSessNotFound):
+	recipSess := s.sm.RetrieveByScreenName(snacPayloadIn.ScreenName)
+	if recipSess == nil {
 		return &oscar.XMessage{
 			SnacFrame: oscar.SnacFrame{
 				FoodGroup: oscar.ICBM,
@@ -144,8 +142,6 @@ func (s ICBMService) ChannelMsgToHostHandler(ctx context.Context, sess *user.Ses
 				Code: oscar.ErrorCodeNotLoggedOn,
 			},
 		}, nil
-	case err != nil:
-		return nil, err
 	}
 
 	clientIM := oscar.SNAC_0x04_0x07_ICBMChannelMsgToClient{
@@ -252,8 +248,8 @@ func (s ICBMService) EvilRequestHandler(ctx context.Context, sess *user.Session,
 		}, nil
 	}
 
-	recipSess, err := s.sm.RetrieveByScreenName(snacPayloadIn.ScreenName)
-	if err != nil {
+	recipSess := s.sm.RetrieveByScreenName(snacPayloadIn.ScreenName)
+	if recipSess == nil {
 		return oscar.XMessage{}, nil
 	}
 

+ 10 - 9
server/icbm_test.go

@@ -168,9 +168,8 @@ func TestSendAndReceiveChannelMsgTohost(t *testing.T) {
 		{
 			name:             "don't transmit message from sender to recipient because recipient doesn't exist",
 			blockedState:     BlockedNo,
-			recipRetrieveErr: ErrSessNotFound,
 			senderSession:    newTestSession("sender-screen-name", sessOptWarning(10)),
-			recipientSession: newTestSession("recipient-screen-name", sessOptWarning(20)),
+			recipientSession: nil,
 			inputSNAC: oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost{
 				ScreenName: "recipient-screen-name",
 				TLVRestBlock: oscar.TLVRestBlock{
@@ -201,17 +200,19 @@ func TestSendAndReceiveChannelMsgTohost(t *testing.T) {
 			//
 			fm := NewMockFeedbagManager(t)
 			fm.EXPECT().
-				Blocked(tc.senderSession.ScreenName(), tc.recipientSession.ScreenName()).
+				Blocked(tc.senderSession.ScreenName(), tc.inputSNAC.ScreenName).
 				Return(tc.blockedState, nil).
 				Maybe()
 			sm := NewMockSessionManager(t)
 			sm.EXPECT().
-				RetrieveByScreenName(tc.recipientSession.ScreenName()).
-				Return(tc.recipientSession, tc.recipRetrieveErr).
-				Maybe()
-			sm.EXPECT().
-				SendToScreenName(mock.Anything, tc.recipientSession.ScreenName(), tc.expectSNACToClient).
+				RetrieveByScreenName(tc.inputSNAC.ScreenName).
+				Return(tc.recipientSession).
 				Maybe()
+			if tc.recipientSession != nil {
+				sm.EXPECT().
+					SendToScreenName(mock.Anything, tc.recipientSession.ScreenName(), tc.expectSNACToClient).
+					Maybe()
+			}
 			//
 			// send input SNAC
 			//
@@ -501,7 +502,7 @@ func TestSendAndReceiveEvilRequest(t *testing.T) {
 			sm := NewMockSessionManager(t)
 			sm.EXPECT().
 				RetrieveByScreenName(tc.recipientScreenName).
-				Return(recipSess, tc.recipRetrieveErr).
+				Return(recipSess).
 				Maybe()
 			sm.EXPECT().
 				SendToScreenName(mock.Anything, tc.recipientScreenName, tc.expectSNACToClient).

+ 2 - 6
server/locate.go

@@ -2,7 +2,6 @@ package server
 
 import (
 	"context"
-	"errors"
 	"github.com/mkaminski/goaim/user"
 	"io"
 	"log/slog"
@@ -146,9 +145,8 @@ func (s LocateService) UserInfoQuery2Handler(_ context.Context, sess *user.Sessi
 		}, nil
 	}
 
-	buddySess, err := s.sm.RetrieveByScreenName(snacPayloadIn.ScreenName)
-	switch {
-	case errors.Is(err, ErrSessNotFound):
+	buddySess := s.sm.RetrieveByScreenName(snacPayloadIn.ScreenName)
+	if buddySess == nil {
 		return oscar.XMessage{
 			SnacFrame: oscar.SnacFrame{
 				FoodGroup: oscar.LOCATE,
@@ -158,8 +156,6 @@ func (s LocateService) UserInfoQuery2Handler(_ context.Context, sess *user.Sessi
 				Code: oscar.ErrorCodeNotLoggedOn,
 			},
 		}, nil
-	case err != nil:
-		return oscar.XMessage{}, err
 	}
 
 	var list oscar.TLVList

+ 2 - 2
server/locate_test.go

@@ -219,7 +219,7 @@ func TestSendAndReceiveUserInfoQuery2(t *testing.T) {
 				err  error
 			}{
 				"non_existent_requested_user": {
-					err: ErrSessNotFound,
+					sess: nil,
 				},
 			},
 			userSession: newTestSession("user_screen_name"),
@@ -249,7 +249,7 @@ func TestSendAndReceiveUserInfoQuery2(t *testing.T) {
 			for screenName, val := range tc.screenNameLookups {
 				sm.EXPECT().
 					RetrieveByScreenName(screenName).
-					Return(val.sess, val.err).
+					Return(val.sess).
 					Maybe()
 			}
 			pm := NewMockProfileManager(t)

+ 1 - 7
server/oservice.go

@@ -392,13 +392,7 @@ func (s OServiceServiceForBOS) ClientOnlineHandler(ctx context.Context, _ oscar.
 		return err
 	}
 	for _, buddy := range buddies {
-		err := UnicastArrival(ctx, buddy, sess.ScreenName(), s.sm)
-		switch {
-		case errors.Is(err, ErrSessNotFound):
-			continue
-		case err != nil:
-			return err
-		}
+		UnicastArrival(ctx, buddy, sess.ScreenName(), s.sm)
 	}
 	return nil
 }

+ 1 - 143
server/session.go

@@ -1,156 +1,14 @@
 package server
 
 import (
-	"context"
 	"errors"
-	"fmt"
-	"github.com/mkaminski/goaim/user"
-	"log/slog"
 	"sync"
 	"time"
 
 	"github.com/mkaminski/goaim/oscar"
 )
 
-var (
-	ErrSessNotFound = errors.New("session was not found")
-	ErrSignedOff    = errors.New("user signed off")
-)
-
-type InMemorySessionManager struct {
-	store    map[string]*user.Session
-	mapMutex sync.RWMutex
-	logger   *slog.Logger
-}
-
-func NewSessionManager(logger *slog.Logger) *InMemorySessionManager {
-	return &InMemorySessionManager{
-		logger: logger,
-		store:  make(map[string]*user.Session),
-	}
-}
-
-func (s *InMemorySessionManager) Broadcast(ctx context.Context, msg oscar.XMessage) {
-	s.mapMutex.RLock()
-	defer s.mapMutex.RUnlock()
-	for _, sess := range s.store {
-		s.maybeSendMessage(ctx, msg, sess)
-	}
-}
-
-func (s *InMemorySessionManager) maybeSendMessage(ctx context.Context, msg oscar.XMessage, sess *user.Session) {
-	switch sess.SendMessage(msg) {
-	case user.SessSendClosed:
-		s.logger.WarnContext(ctx, "can't send notification because the user's session is closed", "recipient", sess.ScreenName(), "message", msg)
-	case user.SessQueueFull:
-		s.logger.WarnContext(ctx, "can't send notification because queue is full", "recipient", sess.ScreenName(), "message", msg)
-		sess.Close()
-	}
-}
-
-func (s *InMemorySessionManager) Empty() bool {
-	s.mapMutex.RLock()
-	defer s.mapMutex.RUnlock()
-	return len(s.store) == 0
-}
-
-func (s *InMemorySessionManager) Participants() []*user.Session {
-	s.mapMutex.RLock()
-	defer s.mapMutex.RUnlock()
-	var sessions []*user.Session
-	for _, sess := range s.store {
-		sessions = append(sessions, sess)
-	}
-	return sessions
-}
-
-func (s *InMemorySessionManager) BroadcastExcept(ctx context.Context, except *user.Session, msg oscar.XMessage) {
-	s.mapMutex.RLock()
-	defer s.mapMutex.RUnlock()
-	for _, sess := range s.store {
-		if sess == except {
-			continue
-		}
-		s.maybeSendMessage(ctx, msg, sess)
-	}
-}
-
-func (s *InMemorySessionManager) Retrieve(ID string) (*user.Session, bool) {
-	s.mapMutex.RLock()
-	defer s.mapMutex.RUnlock()
-	sess, found := s.store[ID]
-	return sess, found
-}
-
-func (s *InMemorySessionManager) RetrieveByScreenName(screenName string) (*user.Session, error) {
-	s.mapMutex.RLock()
-	defer s.mapMutex.RUnlock()
-	for _, sess := range s.store {
-		if screenName == sess.ScreenName() {
-			return sess, nil
-		}
-	}
-	return nil, fmt.Errorf("%w: %s", ErrSessNotFound, screenName)
-}
-
-func (s *InMemorySessionManager) retrieveByScreenNames(screenNames []string) []*user.Session {
-	s.mapMutex.RLock()
-	defer s.mapMutex.RUnlock()
-	var ret []*user.Session
-	for _, sn := range screenNames {
-		for _, sess := range s.store {
-			if sn == sess.ScreenName() {
-				ret = append(ret, sess)
-			}
-		}
-	}
-	return ret
-}
-
-func (s *InMemorySessionManager) SendToScreenName(ctx context.Context, screenName string, msg oscar.XMessage) {
-	sess, err := s.RetrieveByScreenName(screenName)
-	if err != nil {
-		s.logger.WarnContext(ctx, "can't send notification because user is not online", "recipient", screenName, "message", msg)
-		return
-	}
-	s.maybeSendMessage(ctx, msg, sess)
-}
-
-func (s *InMemorySessionManager) BroadcastToScreenNames(ctx context.Context, screenNames []string, msg oscar.XMessage) {
-	for _, sess := range s.retrieveByScreenNames(screenNames) {
-		s.maybeSendMessage(ctx, msg, sess)
-	}
-}
-
-func (s *InMemorySessionManager) NewSessionWithSN(sessID string, screenName string) *user.Session {
-	s.mapMutex.Lock()
-	defer s.mapMutex.Unlock()
-
-	// Only allow one session at a time per screen name. A session may already
-	// exist because:
-	// 1) the user is signing on using an already logged-on screen name.
-	// 2) the session might be orphaned due to an undetected client
-	// disconnection.
-	for _, sess := range s.store {
-		if screenName == sess.ScreenName() {
-			sess.Close()
-			delete(s.store, sess.ID())
-			break
-		}
-	}
-
-	sess := user.NewSession()
-	sess.SetID(sessID)
-	sess.SetScreenName(screenName)
-	s.store[sess.ID()] = sess
-	return sess
-}
-
-func (s *InMemorySessionManager) Remove(sess *user.Session) {
-	s.mapMutex.Lock()
-	defer s.mapMutex.Unlock()
-	delete(s.store, sess.ID())
-}
+var ErrSignedOff = errors.New("user signed off")
 
 type ChatRoom struct {
 	CreateTime     time.Time

+ 5 - 15
server/session_manager_mock.go

@@ -345,14 +345,10 @@ func (_c *MockSessionManager_Retrieve_Call) RunAndReturn(run func(string) (*user
 }
 
 // RetrieveByScreenName provides a mock function with given fields: screenName
-func (_m *MockSessionManager) RetrieveByScreenName(screenName string) (*user.Session, error) {
+func (_m *MockSessionManager) RetrieveByScreenName(screenName string) *user.Session {
 	ret := _m.Called(screenName)
 
 	var r0 *user.Session
-	var r1 error
-	if rf, ok := ret.Get(0).(func(string) (*user.Session, error)); ok {
-		return rf(screenName)
-	}
 	if rf, ok := ret.Get(0).(func(string) *user.Session); ok {
 		r0 = rf(screenName)
 	} else {
@@ -361,13 +357,7 @@ func (_m *MockSessionManager) RetrieveByScreenName(screenName string) (*user.Ses
 		}
 	}
 
-	if rf, ok := ret.Get(1).(func(string) error); ok {
-		r1 = rf(screenName)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
+	return r0
 }
 
 // MockSessionManager_RetrieveByScreenName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RetrieveByScreenName'
@@ -388,12 +378,12 @@ func (_c *MockSessionManager_RetrieveByScreenName_Call) Run(run func(screenName
 	return _c
 }
 
-func (_c *MockSessionManager_RetrieveByScreenName_Call) Return(_a0 *user.Session, _a1 error) *MockSessionManager_RetrieveByScreenName_Call {
-	_c.Call.Return(_a0, _a1)
+func (_c *MockSessionManager_RetrieveByScreenName_Call) Return(_a0 *user.Session) *MockSessionManager_RetrieveByScreenName_Call {
+	_c.Call.Return(_a0)
 	return _c
 }
 
-func (_c *MockSessionManager_RetrieveByScreenName_Call) RunAndReturn(run func(string) (*user.Session, error)) *MockSessionManager_RetrieveByScreenName_Call {
+func (_c *MockSessionManager_RetrieveByScreenName_Call) RunAndReturn(run func(string) *user.Session) *MockSessionManager_RetrieveByScreenName_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 144 - 0
user/session_manager.go

@@ -0,0 +1,144 @@
+package user
+
+import (
+	"context"
+	"log/slog"
+	"sync"
+
+	"github.com/mkaminski/goaim/oscar"
+)
+
+type InMemorySessionManager struct {
+	store    map[string]*Session
+	mapMutex sync.RWMutex
+	logger   *slog.Logger
+}
+
+func NewSessionManager(logger *slog.Logger) *InMemorySessionManager {
+	return &InMemorySessionManager{
+		logger: logger,
+		store:  make(map[string]*Session),
+	}
+}
+
+func (s *InMemorySessionManager) Broadcast(ctx context.Context, msg oscar.XMessage) {
+	s.mapMutex.RLock()
+	defer s.mapMutex.RUnlock()
+	for _, sess := range s.store {
+		s.maybeSendMessage(ctx, msg, sess)
+	}
+}
+
+func (s *InMemorySessionManager) maybeSendMessage(ctx context.Context, msg oscar.XMessage, sess *Session) {
+	switch sess.SendMessage(msg) {
+	case SessSendClosed:
+		s.logger.WarnContext(ctx, "can't send notification because the user's session is closed", "recipient", sess.ScreenName(), "message", msg)
+	case SessQueueFull:
+		s.logger.WarnContext(ctx, "can't send notification because queue is full", "recipient", sess.ScreenName(), "message", msg)
+		sess.Close()
+	}
+}
+
+func (s *InMemorySessionManager) Empty() bool {
+	s.mapMutex.RLock()
+	defer s.mapMutex.RUnlock()
+	return len(s.store) == 0
+}
+
+func (s *InMemorySessionManager) Participants() []*Session {
+	s.mapMutex.RLock()
+	defer s.mapMutex.RUnlock()
+	var sessions []*Session
+	for _, sess := range s.store {
+		sessions = append(sessions, sess)
+	}
+	return sessions
+}
+
+func (s *InMemorySessionManager) BroadcastExcept(ctx context.Context, except *Session, msg oscar.XMessage) {
+	s.mapMutex.RLock()
+	defer s.mapMutex.RUnlock()
+	for _, sess := range s.store {
+		if sess == except {
+			continue
+		}
+		s.maybeSendMessage(ctx, msg, sess)
+	}
+}
+
+func (s *InMemorySessionManager) Retrieve(ID string) (*Session, bool) {
+	s.mapMutex.RLock()
+	defer s.mapMutex.RUnlock()
+	sess, found := s.store[ID]
+	return sess, found
+}
+
+func (s *InMemorySessionManager) RetrieveByScreenName(screenName string) *Session {
+	s.mapMutex.RLock()
+	defer s.mapMutex.RUnlock()
+	for _, sess := range s.store {
+		if screenName == sess.ScreenName() {
+			return sess
+		}
+	}
+	return nil
+}
+
+func (s *InMemorySessionManager) retrieveByScreenNames(screenNames []string) []*Session {
+	s.mapMutex.RLock()
+	defer s.mapMutex.RUnlock()
+	var ret []*Session
+	for _, sn := range screenNames {
+		for _, sess := range s.store {
+			if sn == sess.ScreenName() {
+				ret = append(ret, sess)
+			}
+		}
+	}
+	return ret
+}
+
+func (s *InMemorySessionManager) SendToScreenName(ctx context.Context, screenName string, msg oscar.XMessage) {
+	sess := s.RetrieveByScreenName(screenName)
+	if sess == nil {
+		s.logger.WarnContext(ctx, "can't send notification because user is not online", "recipient", screenName, "message", msg)
+		return
+	}
+	s.maybeSendMessage(ctx, msg, sess)
+}
+
+func (s *InMemorySessionManager) BroadcastToScreenNames(ctx context.Context, screenNames []string, msg oscar.XMessage) {
+	for _, sess := range s.retrieveByScreenNames(screenNames) {
+		s.maybeSendMessage(ctx, msg, sess)
+	}
+}
+
+func (s *InMemorySessionManager) NewSessionWithSN(sessID string, screenName string) *Session {
+	s.mapMutex.Lock()
+	defer s.mapMutex.Unlock()
+
+	// Only allow one session at a time per screen name. A session may already
+	// exist because:
+	// 1) the user is signing on using an already logged-on screen name.
+	// 2) the session might be orphaned due to an undetected client
+	// disconnection.
+	for _, sess := range s.store {
+		if screenName == sess.ScreenName() {
+			sess.Close()
+			delete(s.store, sess.ID())
+			break
+		}
+	}
+
+	sess := NewSession()
+	sess.SetID(sessID)
+	sess.SetScreenName(screenName)
+	s.store[sess.ID()] = sess
+	return sess
+}
+
+func (s *InMemorySessionManager) Remove(sess *Session) {
+	s.mapMutex.Lock()
+	defer s.mapMutex.Unlock()
+	delete(s.store, sess.ID())
+}