소스 검색

issue #41 - persist warning levels

persist warn level

persist warn level

persist warn level

fix tests

warn event stream

fix warning race condition

add separate screen name param to BroadcastBuddyArrived

remove cruft

more test coverage

return error from RestoreWarningLevel
Mike 10 달 전
부모
커밋
6d160f1c94

+ 5 - 2
cmd/server/factory.go

@@ -84,6 +84,7 @@ func MakeCommonDeps() (Container, error) {
 		c.sqLiteUserStore,
 		c.sqLiteUserStore,
 		c.inMemorySessionManager,
+		c.sqLiteUserStore,
 		c.snacRateLimits,
 		c.logger,
 	)
@@ -323,7 +324,8 @@ func OSCAR(deps Container) *oscar.Server {
 		deps.snacRateLimits,
 		oscar.NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
 		deps.Listeners,
-		deps.icbmSvc.DecayWarnLevel,
+		deps.icbmSvc.RestoreWarningLevel,
+		deps.icbmSvc.UpdateWarnLevel,
 	)
 }
 
@@ -420,7 +422,8 @@ func TOC(deps Container) *toc.Server {
 			HTTPIPRateLimiter: toc.NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
 		},
 		toc.NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
-		deps.icbmSvc.DecayWarnLevel,
+		deps.icbmSvc.RestoreWarningLevel,
+		deps.icbmSvc.UpdateWarnLevel,
 	)
 }
 

+ 2 - 2
foodgroup/admin.go

@@ -71,7 +71,7 @@ func (s AdminService) ConfirmRequest(ctx context.Context, sess *state.Session, f
 		return wire.SNACMessage{}, err
 	}
 	sess.ClearUserInfoFlag(wire.OServiceUserFlagUnconfirmed)
-	if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
+	if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess.IdentScreenName(), sess.TLVUserInfo()); err != nil {
 		return wire.SNACMessage{}, err
 	}
 	return getAdminConfirmReply(wire.AdminAcctConfirmStatusEmailSent), nil
@@ -210,7 +210,7 @@ func (s AdminService) InfoChangeRequest(ctx context.Context, sess *state.Session
 			return wire.SNACMessage{}, err
 		}
 		sess.SetDisplayScreenName(proposedName)
-		if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
+		if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess.IdentScreenName(), sess.TLVUserInfo()); err != nil {
 			return wire.SNACMessage{}, err
 		}
 		s.messageRelayer.RelayToScreenName(ctx, sess.IdentScreenName(), wire.SNACMessage{

+ 7 - 5
foodgroup/admin_test.go

@@ -84,7 +84,7 @@ func TestAdminService_ConfirmRequest(t *testing.T) {
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							screenName: state.NewIdentScreenName("Chatting Chuck"),
+							screenName: state.DisplayScreenName("Chatting Chuck"),
 						},
 					},
 				},
@@ -192,7 +192,7 @@ func TestAdminService_ConfirmRequest(t *testing.T) {
 			}
 			for _, params := range tc.mockParams.broadcastBuddyArrivedParams {
 				buddyBroadcaster.EXPECT().
-					BroadcastBuddyArrived(mock.Anything, tc.userSession).
+					BroadcastBuddyArrived(mock.Anything, tc.userSession.IdentScreenName(), tc.userSession.TLVUserInfo()).
 					Return(params.err)
 			}
 			svc := AdminService{
@@ -485,7 +485,7 @@ func TestAdminService_InfoChangeRequest_ScreenName(t *testing.T) {
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							screenName: state.NewIdentScreenName("Chatting Chuck"),
+							screenName: state.DisplayScreenName("Chatting Chuck"),
 						},
 					},
 				},
@@ -552,7 +552,7 @@ func TestAdminService_InfoChangeRequest_ScreenName(t *testing.T) {
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							screenName: state.NewIdentScreenName("Chatting Chuck"),
+							screenName: state.DisplayScreenName("Chatting Chuck"),
 						},
 					},
 				},
@@ -716,7 +716,9 @@ func TestAdminService_InfoChangeRequest_ScreenName(t *testing.T) {
 
 			for _, params := range tc.mockParams.broadcastBuddyArrivedParams {
 				mockBuddyBroadcaster.EXPECT().
-					BroadcastBuddyArrived(mock.Anything, matchSession(params.screenName)).
+					BroadcastBuddyArrived(mock.Anything, state.NewIdentScreenName(params.screenName.String()), mock.MatchedBy(func(userInfo wire.TLVUserInfo) bool {
+						return userInfo.ScreenName == params.screenName.String()
+					})).
 					Return(params.err)
 			}
 

+ 1 - 1
foodgroup/bart.go

@@ -51,7 +51,7 @@ func (s BARTService) UpsertItem(ctx context.Context, sess *state.Session, inFram
 
 	s.logger.DebugContext(ctx, "successfully uploaded buddy icon", "hash", fmt.Sprintf("%x", hash))
 
-	if err := s.buddyUpdateBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
+	if err := s.buddyUpdateBroadcaster.BroadcastBuddyArrived(ctx, sess.IdentScreenName(), sess.TLVUserInfo()); err != nil {
 		return wire.SNACMessage{}, err
 	}
 

+ 4 - 2
foodgroup/bart_test.go

@@ -50,7 +50,7 @@ func TestBARTService_UpsertItem(t *testing.T) {
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							screenName: state.NewIdentScreenName("user_screen_name"),
+							screenName: state.DisplayScreenName("user_screen_name"),
 						},
 					},
 				},
@@ -86,7 +86,9 @@ func TestBARTService_UpsertItem(t *testing.T) {
 			buddyUpdateBroadcaster := newMockbuddyBroadcaster(t)
 			for _, params := range tc.mockParams.broadcastBuddyArrivedParams {
 				buddyUpdateBroadcaster.EXPECT().
-					BroadcastBuddyArrived(mock.Anything, matchSession(params.screenName)).
+					BroadcastBuddyArrived(mock.Anything, state.NewIdentScreenName(params.screenName.String()), mock.MatchedBy(func(userInfo wire.TLVUserInfo) bool {
+						return userInfo.ScreenName == params.screenName.String()
+					})).
 					Return(params.err)
 			}
 			svc := NewBARTService(slog.Default(), buddyIconManager, nil, nil, nil)

+ 6 - 7
foodgroup/buddy.go

@@ -104,8 +104,8 @@ func (s BuddyService) BroadcastBuddyDeparted(ctx context.Context, sess *state.Se
 	return s.buddyBroadcaster.BroadcastBuddyDeparted(ctx, sess)
 }
 
-func (s BuddyService) BroadcastBuddyArrived(ctx context.Context, sess *state.Session) error {
-	return s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess)
+func (s BuddyService) BroadcastBuddyArrived(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) error {
+	return s.buddyBroadcaster.BroadcastBuddyArrived(ctx, screenName, userInfo)
 }
 
 func newBuddyNotifier(
@@ -135,8 +135,8 @@ type buddyNotifier struct {
 // While updates are sent via the wire.BuddyArrived SNAC, the message is not
 // only used to indicate the user coming online. It can also notify changes to
 // buddy icons, warning levels, invisibility status, etc.
-func (s buddyNotifier) BroadcastBuddyArrived(ctx context.Context, sess *state.Session) error {
-	users, err := s.relationshipFetcher.AllRelationships(ctx, sess.IdentScreenName(), nil)
+func (s buddyNotifier) BroadcastBuddyArrived(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) error {
+	users, err := s.relationshipFetcher.AllRelationships(ctx, screenName, nil)
 	if err != nil {
 		return err
 	}
@@ -149,9 +149,8 @@ func (s buddyNotifier) BroadcastBuddyArrived(ctx context.Context, sess *state.Se
 		recipients = append(recipients, user.User)
 	}
 
-	userInfo := sess.TLVUserInfo()
-	if err := s.setBuddyIcon(ctx, sess.IdentScreenName(), &userInfo); err != nil {
-		return fmt.Errorf("failed to set buddy icon for %s: %w", sess.IdentScreenName().String(), err)
+	if err := s.setBuddyIcon(ctx, screenName, &userInfo); err != nil {
+		return fmt.Errorf("failed to set buddy icon for %s: %w", screenName.String(), err)
 	}
 
 	s.messageRelayer.RelayToScreenNames(ctx, recipients, wire.SNACMessage{

+ 1 - 1
foodgroup/buddy_test.go

@@ -353,7 +353,7 @@ func TestBuddyNotifier_BroadcastBuddyArrived(t *testing.T) {
 				messageRelayer:      messageRelayer,
 			}
 
-			err := svc.BroadcastBuddyArrived(context.Background(), tc.userSession)
+			err := svc.BroadcastBuddyArrived(context.Background(), tc.userSession.IdentScreenName(), tc.userSession.TLVUserInfo())
 			assert.NoError(t, err)
 		})
 	}

+ 2 - 2
foodgroup/feedbag.go

@@ -251,7 +251,7 @@ func (s FeedbagService) broadcastIconUpdate(ctx context.Context, sess *state.Ses
 		s.logger.DebugContext(ctx, "user is clearing icon",
 			"hash", fmt.Sprintf("%x", btlv.Hash))
 		// tell buddies about the icon update
-		return s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess)
+		return s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess.IdentScreenName(), sess.TLVUserInfo())
 	}
 
 	bid := wire.BARTID{
@@ -272,7 +272,7 @@ func (s FeedbagService) broadcastIconUpdate(ctx context.Context, sess *state.Ses
 		s.logger.DebugContext(ctx, "icon already exists in BART store, don't upload the icon file",
 			"hash", fmt.Sprintf("%x", btlv.Hash))
 		// tell buddies about the icon update
-		if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
+		if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess.IdentScreenName(), sess.TLVUserInfo()); err != nil {
 			return err
 		}
 	}

+ 5 - 3
foodgroup/feedbag_test.go

@@ -906,7 +906,7 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							screenName: state.NewIdentScreenName("me"),
+							screenName: state.DisplayScreenName("me"),
 						},
 					},
 				},
@@ -967,7 +967,7 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							screenName: state.NewIdentScreenName("me"),
+							screenName: state.DisplayScreenName("me"),
 						},
 					},
 				},
@@ -1007,7 +1007,9 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 			buddyUpdateBroadcaster := newMockbuddyBroadcaster(t)
 			for _, params := range tc.mockParams.broadcastBuddyArrivedParams {
 				buddyUpdateBroadcaster.EXPECT().
-					BroadcastBuddyArrived(mock.Anything, matchSession(params.screenName)).
+					BroadcastBuddyArrived(mock.Anything, state.NewIdentScreenName(params.screenName.String()), mock.MatchedBy(func(userInfo wire.TLVUserInfo) bool {
+						return userInfo.ScreenName == params.screenName.String()
+					})).
 					Return(params.err)
 			}
 			for _, params := range tc.mockParams.broadcastVisibilityParams {

+ 2 - 2
foodgroup/helpers_test.go

@@ -675,7 +675,7 @@ type broadcastVisibilityParams []struct {
 // broadcastBuddyArrivedParams is the list of parameters passed at the mock
 // buddyBroadcaster.BroadcastBuddyArrived call site
 type broadcastBuddyArrivedParams []struct {
-	screenName state.IdentScreenName
+	screenName state.DisplayScreenName
 	err        error
 }
 
@@ -721,7 +721,7 @@ type createChatRoomParams []struct {
 // sessOptWarning sets a warning level on the session object
 func sessOptWarning(level int16) func(session *state.Session) {
 	return func(session *state.Session) {
-		session.IncrementWarning(level, 1)
+		session.SetWarning(uint16(level))
 	}
 }
 

+ 153 - 26
foodgroup/icbm.go

@@ -5,6 +5,7 @@ import (
 	"context"
 	"fmt"
 	"log/slog"
+	"sync"
 	"time"
 
 	"github.com/patrickmn/go-cache"
@@ -26,6 +27,7 @@ func NewICBMService(
 	offlineMessageSaver OfflineMessageManager,
 	relationshipFetcher RelationshipFetcher,
 	sessionRetriever SessionRetriever,
+	userManager UserManager,
 	snacRateLimits wire.SNACRateLimits,
 	logger *slog.Logger,
 ) *ICBMService {
@@ -34,6 +36,7 @@ func NewICBMService(
 		buddyBroadcaster:    newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
 		messageRelayer:      messageRelayer,
 		offlineMessageSaver: offlineMessageSaver,
+		userManager:         userManager,
 		timeNow:             time.Now,
 		sessionRetriever:    sessionRetriever,
 		snacRateLimits:      snacRateLimits,
@@ -51,6 +54,7 @@ type ICBMService struct {
 	buddyBroadcaster    buddyBroadcaster
 	messageRelayer      MessageRelayer
 	offlineMessageSaver OfflineMessageManager
+	userManager         UserManager
 	timeNow             func() time.Time
 	sessionRetriever    SessionRetriever
 	snacRateLimits      wire.SNACRateLimits
@@ -348,7 +352,8 @@ func (s ICBMService) EvilRequest(ctx context.Context, sess *state.Session, inFra
 		panic("failed to retrieve rate class for ICBMChannelMsgToHost")
 	}
 
-	if ok := recipSess.IncrementWarning(int16(increase), classID); !ok {
+	ok, newLevel := recipSess.IncrementWarning(int16(increase), classID)
+	if !ok {
 		return wire.SNACMessage{
 			Frame: wire.SNACFrame{
 				FoodGroup: wire.ICBM,
@@ -360,10 +365,9 @@ func (s ICBMService) EvilRequest(ctx context.Context, sess *state.Session, inFra
 			},
 		}, nil
 	}
-	recipSess.NotifyWarning(ctx)
 
 	notif := wire.SNAC_0x01_0x10_OServiceEvilNotification{
-		NewEvil: recipSess.Warning(),
+		NewEvil: newLevel,
 	}
 
 	// append info about user who sent the warning
@@ -386,11 +390,6 @@ func (s ICBMService) EvilRequest(ctx context.Context, sess *state.Session, inFra
 		Body: notif,
 	})
 
-	// inform the warned user's buddies that their warning level has increased
-	if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, recipSess); err != nil {
-		return wire.SNACMessage{}, err
-	}
-
 	return wire.SNACMessage{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.ICBM,
@@ -399,20 +398,66 @@ func (s ICBMService) EvilRequest(ctx context.Context, sess *state.Session, inFra
 		},
 		Body: wire.SNAC_0x04_0x09_ICBMEvilReply{
 			EvilDeltaApplied: increase,
-			UpdatedEvilValue: recipSess.Warning(),
+			UpdatedEvilValue: newLevel,
 		},
 	}, nil
 }
 
-// DecayWarnLevel gradually reduces a user's warning level over time.
-// It listens for warning notifications and starts a periodic decay process
-// that reduces the warning level by a fixed percentage at regular intervals
-// until the warning level reaches zero. Warning updates are broadcast to
-// users who have this user on their buddy list.
-func (s ICBMService) DecayWarnLevel(ctx context.Context, sess *state.Session) {
+// RestoreWarningLevel restores the warning level from the last stored value at login time,
+// accounting for time passed between logins.
+func (s ICBMService) RestoreWarningLevel(ctx context.Context, sess *state.Session) error {
+	u, err := s.userManager.User(ctx, sess.IdentScreenName())
+	if err != nil {
+		return fmt.Errorf("failed to get user: %w", err)
+	}
+	if u == nil {
+		return state.ErrNoUser
+	}
+
+	if u.LastWarnLevel == 0 {
+		// user had no warning at the end of last session
+		return nil
+	}
+
+	sess.SetWarning(u.LastWarnLevel)
+
+	warnDelta := calcElapsedWarningLevel(u.LastWarnUpdate, s.timeNow(), s.interval)
+
+	// get the rate class for sending IMs, which gets limited when the user gets warned
+	classID, ok := s.snacRateLimits.RateClassLookup(wire.ICBM, wire.ICBMChannelMsgToHost)
+	if !ok {
+		panic("failed to retrieve rate class for ICBMChannelMsgToHost")
+	}
+
+	// increment warning level by the amount of time that has passed since last
+	// login, proportionally increasing the warning level
+	sess.IncrementWarning(warnDelta, classID)
+
+	if sess.Warning() > 0 {
+		s.logger.DebugContext(ctx, "restored warning level with time decay applied since last login",
+			"stored_level", u.LastWarnLevel,
+			"time_since_update", s.timeNow().Sub(u.LastWarnUpdate),
+			"decay_delta", warnDelta,
+			"final_level", sess.Warning(),
+		)
+	} else {
+		s.logger.DebugContext(ctx, "warning level decayed to zero since last login",
+			"stored_level", u.LastWarnLevel,
+			"time_since_update", s.timeNow().Sub(u.LastWarnUpdate),
+			"decay_delta", warnDelta,
+		)
+	}
+
+	return nil
+}
+
+// UpdateWarnLevel periodically updates the warning level relative to time
+// elapsed between warnings.
+func (s ICBMService) UpdateWarnLevel(ctx context.Context, sess *state.Session) {
 	var inProgress bool
 	var ticker *time.Ticker
 	var tickC <-chan time.Time // nil when idle, enables/disables the select case
+	var doReset bool
 
 	stopTicker := func() {
 		if ticker != nil {
@@ -421,44 +466,110 @@ func (s ICBMService) DecayWarnLevel(ctx context.Context, sess *state.Session) {
 		}
 		tickC = nil
 		inProgress = false
+		s.logger.DebugContext(ctx, "warning decay stopped")
 	}
 
-	startTicker := func() {
-		ticker = time.NewTicker(s.interval)
+	startTicker := func(interval time.Duration) {
+		ticker = time.NewTicker(interval)
 		tickC = ticker.C
 		inProgress = true
 		s.logger.DebugContext(ctx, "warning decay started")
 	}
 
+	if sess.Warning() > 0 {
+		u, err := s.userManager.User(ctx, sess.IdentScreenName())
+		if err != nil {
+			s.logger.ErrorContext(ctx, "failed to get user", "err", err)
+			return
+		}
+		newInterval := timeTillNextInterval(u.LastWarnUpdate, s.timeNow(), s.interval)
+		interval := s.interval
+		if newInterval > 0 {
+			interval = newInterval
+		}
+		s.logger.DebugContext(ctx, "starting warning level update with interval adjusted to next boundary",
+			"user", sess.IdentScreenName(),
+			"adjusted_interval", interval,
+			"default_interval", s.interval,
+			"time_since_last_update", s.timeNow().Sub(u.LastWarnUpdate),
+		)
+		startTicker(interval)
+		doReset = true
+	}
+
 	// get the rate class for sending IMs, which gets limited when the user gets warned
 	classID, ok := s.snacRateLimits.RateClassLookup(wire.ICBM, wire.ICBMChannelMsgToHost)
 	if !ok {
 		panic("failed to retrieve rate class for ICBMChannelMsgToHost")
 	}
 
+	warnCh := make(chan struct{}, 1)
+
+	var wg sync.WaitGroup
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		defer close(warnCh)
+		for {
+			select {
+			case <-sess.Closed():
+				return
+			case <-ctx.Done():
+				return
+			case warning := <-sess.WarningCh():
+				if warning > 0 {
+					warnCh <- struct{}{}
+				}
+				if err := s.userManager.SetWarnLevel(ctx, sess.IdentScreenName(), s.timeNow(), warning); err != nil {
+					s.logger.ErrorContext(ctx, "failed to set warn level", "err", err)
+				}
+
+				info := sess.TLVUserInfo()
+				// lock in the current warning level to avoid race conditions
+				// where the warning level might change during this broadcast
+				// operation
+				info.WarningLevel = warning
+				if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess.IdentScreenName(), info); err != nil {
+					s.logger.ErrorContext(ctx, "BroadcastBuddyArrived failed", "err", err)
+				} else {
+					s.logger.DebugContext(ctx, "warning lowered", "remaining", warning)
+				}
+			}
+		}
+	}()
+
+	defer wg.Wait()
+
 	for {
 		select {
+		case <-sess.Closed():
+			stopTicker()
+			return
 		case <-ctx.Done():
 			stopTicker()
 			return
 
-		case <-sess.WarningCh():
+		case <-warnCh:
 			if inProgress {
 				s.logger.DebugContext(ctx, "warning decay already in progress")
 				continue
 			}
-			startTicker()
+			startTicker(s.interval)
 
 		case <-tickC:
-			sess.IncrementWarning(warningDecayPct, classID)
+			if doReset {
+				ticker.Reset(s.interval)
+				doReset = false
+			}
 
-			if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
-				s.logger.ErrorContext(ctx, "BroadcastBuddyArrived failed", "err", err)
-			} else {
-				s.logger.DebugContext(ctx, "warning lowered", "remaining", sess.Warning())
+			ok, warning := sess.IncrementWarning(warningDecayPct, classID)
+			if !ok {
+				s.logger.ErrorContext(ctx, "warning increment out of rage", "level", warning)
+				stopTicker()
+				return
 			}
 
-			if sess.Warning() <= 0 {
+			if warning == 0 {
 				s.logger.DebugContext(ctx, "warning decay complete")
 				stopTicker()
 			}
@@ -466,6 +577,22 @@ func (s ICBMService) DecayWarnLevel(ctx context.Context, sess *state.Session) {
 	}
 }
 
+func calcElapsedWarningLevel(lastWarnUpdate time.Time, now time.Time, interval time.Duration) int16 {
+	// time passed since last signoff
+	since := now.Sub(lastWarnUpdate)
+
+	// how many times warning decayed since last signoff
+	decayPeriods := int(since / interval)
+	// total amount warning decreased since last signoff
+	warnDelta := decayPeriods * warningDecayPct
+
+	return int16(warnDelta)
+}
+
+func timeTillNextInterval(lastWarned time.Time, now time.Time, interval time.Duration) time.Duration {
+	return interval - (now.Sub(lastWarned) % interval)
+}
+
 // convoTracker keeps track of messages initiated from a sender to a recipient.
 // A user (the warner) can only warn another user (the warnee) only if the
 // warner has received a message from the warnee. The warner may only warn 1
@@ -563,5 +690,5 @@ func (r *ringBuffer) val() time.Time {
 // set stores the given time at the current cursor position and advances the cursor.
 func (r *ringBuffer) set(v time.Time) {
 	r.vals[r.cur] = v
-	r.cur = (r.cur + 1) % 3
+	r.cur = (r.cur + 1) % len(r.vals)
 }

+ 258 - 62
foodgroup/icbm_test.go

@@ -2,6 +2,7 @@ package foodgroup
 
 import (
 	"context"
+	"errors"
 	"log/slog"
 	"net"
 	"net/netip"
@@ -979,13 +980,6 @@ func TestICBMService_EvilRequest(t *testing.T) {
 				},
 			},
 			mockParams: mockParams{
-				buddyBroadcasterParams: buddyBroadcasterParams{
-					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
-						{
-							screenName: state.NewIdentScreenName("recipient-screen-name"),
-						},
-					},
-				},
 				relationshipFetcherParams: relationshipFetcherParams{
 					relationshipParams: relationshipParams{
 						{
@@ -1053,13 +1047,6 @@ func TestICBMService_EvilRequest(t *testing.T) {
 				},
 			},
 			mockParams: mockParams{
-				buddyBroadcasterParams: buddyBroadcasterParams{
-					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
-						{
-							screenName: state.NewIdentScreenName("recipient-screen-name"),
-						},
-					},
-				},
 				relationshipFetcherParams: relationshipFetcherParams{
 					relationshipParams: relationshipParams{
 						{
@@ -1318,12 +1305,6 @@ func TestICBMService_EvilRequest(t *testing.T) {
 
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			mockBuddyBroadcaster := newMockbuddyBroadcaster(t)
-			for _, item := range tc.mockParams.broadcastBuddyArrivedParams {
-				mockBuddyBroadcaster.EXPECT().
-					BroadcastBuddyArrived(mock.Anything, matchSession(item.screenName)).
-					Return(item.err)
-			}
 			relationshipFetcher := newMockRelationshipFetcher(t)
 			for _, item := range tc.mockParams.relationshipFetcherParams.relationshipParams {
 				relationshipFetcher.EXPECT().
@@ -1349,7 +1330,6 @@ func TestICBMService_EvilRequest(t *testing.T) {
 			}
 
 			svc := ICBMService{
-				buddyBroadcaster:    mockBuddyBroadcaster,
 				relationshipFetcher: relationshipFetcher,
 				messageRelayer:      messageRelayer,
 				offlineMessageSaver: offlineMessageManager,
@@ -1385,7 +1365,7 @@ func TestICBMService_EvilRequest(t *testing.T) {
 }
 
 func TestICBMService_ParameterQuery(t *testing.T) {
-	svc := NewICBMService(nil, nil, nil, nil, nil, wire.DefaultSNACRateLimits(), slog.Default())
+	svc := NewICBMService(nil, nil, nil, nil, nil, nil, wire.DefaultSNACRateLimits(), slog.Default())
 
 	have := svc.ParameterQuery(nil, wire.SNACFrame{RequestID: 1234})
 	want := wire.SNACMessage{
@@ -1437,7 +1417,7 @@ func TestICBMService_ClientErr(t *testing.T) {
 	messageRelayer.EXPECT().
 		RelayToScreenName(mock.Anything, state.NewIdentScreenName("recipientScreenName"), expect)
 
-	svc := NewICBMService(nil, messageRelayer, nil, nil, nil, wire.DefaultSNACRateLimits(), slog.Default())
+	svc := NewICBMService(nil, messageRelayer, nil, nil, nil, nil, wire.DefaultSNACRateLimits(), slog.Default())
 
 	err := svc.ClientErr(context.Background(), sess, wire.SNACFrame{RequestID: 1234}, inBody)
 	assert.NoError(t, err)
@@ -1680,25 +1660,48 @@ func TestConvoTracker(t *testing.T) {
 	assert.True(t, ct.trackWarn(now, recip, sender))
 }
 
-func TestICBMService_DecayWarnLevel(t *testing.T) {
+func TestICBMService_UpdateWarnLevel(t *testing.T) {
 
 	t.Run("happy path", func(t *testing.T) {
+		now := time.Now()
 
 		sess := newTestSession("screen-name")
 		warnCh := make(chan uint16)
 
 		mockBuddyBroadcaster := newMockbuddyBroadcaster(t)
 		mockBuddyBroadcaster.EXPECT().
-			BroadcastBuddyArrived(mock.Anything, matchSession(sess.IdentScreenName())).
-			Run(func(ctx context.Context, sess *state.Session) {
-				warnCh <- sess.Warning()
+			BroadcastBuddyArrived(mock.Anything, sess.IdentScreenName(), mock.MatchedBy(func(userInfo wire.TLVUserInfo) bool {
+				return userInfo.ScreenName == sess.IdentScreenName().String()
+			})).
+			Run(func(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) {
+				warnCh <- userInfo.WarningLevel
 			}).Return(nil)
 
+		u := &state.User{}
+		userManager := newMockUserManager(t)
+		userManager.EXPECT().
+			User(matchContext(), sess.IdentScreenName()).
+			Return(u, nil)
+		userManager.EXPECT().
+			SetWarnLevel(matchContext(), sess.IdentScreenName(), now, uint16(100)).
+			Return(nil)
+		userManager.EXPECT().
+			SetWarnLevel(matchContext(), sess.IdentScreenName(), now, uint16(50)).
+			Return(nil)
+		userManager.EXPECT().
+			SetWarnLevel(matchContext(), sess.IdentScreenName(), now, uint16(30)).
+			Return(nil)
+		userManager.EXPECT().
+			SetWarnLevel(matchContext(), sess.IdentScreenName(), now, uint16(0)).
+			Return(nil)
+
 		svc := ICBMService{
 			buddyBroadcaster: mockBuddyBroadcaster,
-			logger:           slog.Default(),
 			interval:         1 * time.Millisecond,
+			logger:           slog.Default(),
 			snacRateLimits:   wire.DefaultSNACRateLimits(),
+			timeNow:          func() time.Time { return now },
+			userManager:      userManager,
 		}
 
 		ctx, cancel := context.WithCancel(context.Background())
@@ -1708,62 +1711,255 @@ func TestICBMService_DecayWarnLevel(t *testing.T) {
 		wg.Add(1)
 		go func() {
 			defer wg.Done()
-			svc.DecayWarnLevel(ctx, sess)
+			svc.UpdateWarnLevel(ctx, sess) // do a sync test here?
 		}()
 
-		sess.IncrementWarning(100, 3)
-		sess.NotifyWarning(ctx)
-
+		ok, _ := sess.IncrementWarning(100, 3)
+		assert.True(t, ok)
+		assert.Equal(t, uint16(100), <-warnCh)
 		assert.Equal(t, uint16(50), <-warnCh)
 		assert.Equal(t, uint16(0), <-warnCh)
 
-		sess.IncrementWarning(50, 3)
-		sess.NotifyWarning(ctx)
-		sess.IncrementWarning(50, 3)
-		sess.NotifyWarning(ctx)
-
+		ok, _ = sess.IncrementWarning(100, 3)
+		assert.True(t, ok)
+		assert.Equal(t, uint16(100), <-warnCh)
 		assert.Equal(t, uint16(50), <-warnCh)
 		assert.Equal(t, uint16(0), <-warnCh)
 
+		sess.IncrementWarning(30, 3)
+		assert.Equal(t, uint16(30), <-warnCh)
+		assert.Equal(t, uint16(0), <-warnCh)
+
 		cancel()
 		wg.Wait()
 	})
+}
 
-	t.Run("3% burn down clamps to 0", func(t *testing.T) {
+func TestICBMService_RestoreWarningLevel(t *testing.T) {
+	tests := []struct {
+		name           string
+		lastWarnUpdate time.Duration
+		lastWarnLevel  uint16
+		expectedWarn   uint16
+	}{
+		{
+			name:           "decays warning when last update is before interval boundary",
+			lastWarnUpdate: -15*time.Millisecond - 1*time.Millisecond,
+			lastWarnLevel:  250,
+			expectedWarn:   100,
+		},
+		{
+			name:           "decays warning when last update is after interval boundary",
+			lastWarnUpdate: -15*time.Millisecond + 1*time.Millisecond,
+			lastWarnLevel:  250,
+			expectedWarn:   150,
+		},
+		{
+			name:           "decays warning when last update is exactly on interval boundary",
+			lastWarnUpdate: -15 * time.Millisecond,
+			lastWarnLevel:  250,
+			expectedWarn:   100,
+		},
+		{
+			name:           "resets warning to zero when time is exactly at decay period",
+			lastWarnUpdate: -25 * time.Millisecond,
+			lastWarnLevel:  250,
+			expectedWarn:   0,
+		},
+		{
+			name:           "resets warning to zero when time far decay period",
+			lastWarnUpdate: -1 * time.Second,
+			lastWarnLevel:  250,
+			expectedWarn:   0,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			now := time.Now()
+
+			sess := newTestSession("screen-name")
+
+			u := &state.User{
+				LastWarnUpdate: now.Add(tt.lastWarnUpdate),
+				LastWarnLevel:  tt.lastWarnLevel,
+			}
+			userManager := newMockUserManager(t)
+			userManager.EXPECT().
+				User(matchContext(), sess.IdentScreenName()).
+				Return(u, nil)
+
+			svc := ICBMService{
+				logger:         slog.Default(),
+				interval:       5 * time.Millisecond,
+				snacRateLimits: wire.DefaultSNACRateLimits(),
+				timeNow:        func() time.Time { return now },
+				userManager:    userManager,
+			}
+
+			ctx, cancel := context.WithCancel(context.Background())
+			defer cancel()
+
+			err := svc.RestoreWarningLevel(ctx, sess)
+			assert.NoError(t, err)
 
+			assert.Equal(t, tt.expectedWarn, sess.Warning())
+		})
+	}
+}
+
+func TestICBMService_RestoreWarningLevel_ErrorCases(t *testing.T) {
+	t.Run("user does not exist", func(t *testing.T) {
 		sess := newTestSession("screen-name")
-		warnCh := make(chan uint16)
 
-		mockBuddyBroadcaster := newMockbuddyBroadcaster(t)
-		mockBuddyBroadcaster.EXPECT().
-			BroadcastBuddyArrived(mock.Anything, matchSession(sess.IdentScreenName())).
-			Run(func(ctx context.Context, sess *state.Session) {
-				warnCh <- sess.Warning()
-			}).Return(nil)
+		userManager := newMockUserManager(t)
+		userManager.EXPECT().
+			User(matchContext(), sess.IdentScreenName()).
+			Return(nil, nil)
 
 		svc := ICBMService{
-			buddyBroadcaster: mockBuddyBroadcaster,
-			logger:           slog.Default(),
-			interval:         1 * time.Millisecond,
-			snacRateLimits:   wire.DefaultSNACRateLimits(),
+			logger:         slog.Default(),
+			interval:       5 * time.Millisecond,
+			snacRateLimits: wire.DefaultSNACRateLimits(),
+			timeNow:        time.Now,
+			userManager:    userManager,
 		}
 
-		ctx, cancel := context.WithCancel(context.Background())
-		defer cancel()
+		err := svc.RestoreWarningLevel(context.Background(), sess)
+		assert.ErrorIs(t, err, state.ErrNoUser)
+	})
 
-		var wg sync.WaitGroup
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
-			svc.DecayWarnLevel(ctx, sess)
-		}()
+	t.Run("user manager returns error", func(t *testing.T) {
+		sess := newTestSession("screen-name")
+		expectedErr := errors.New("database connection failed")
 
-		sess.IncrementWarning(30, 3)
-		sess.NotifyWarning(ctx)
+		userManager := newMockUserManager(t)
+		userManager.EXPECT().
+			User(matchContext(), sess.IdentScreenName()).
+			Return(nil, expectedErr)
 
-		assert.Equal(t, uint16(0), <-warnCh)
+		svc := ICBMService{
+			logger:         slog.Default(),
+			interval:       5 * time.Millisecond,
+			snacRateLimits: wire.DefaultSNACRateLimits(),
+			timeNow:        time.Now,
+			userManager:    userManager,
+		}
 
-		cancel()
-		wg.Wait()
+		err := svc.RestoreWarningLevel(context.Background(), sess)
+		assert.Error(t, err)
+		// When user is nil, it returns ErrNoUser regardless of the error
+		assert.ErrorIs(t, err, expectedErr)
+	})
+
+	t.Run("user exists with zero warning level", func(t *testing.T) {
+		sess := newTestSession("screen-name")
+
+		u := &state.User{
+			LastWarnUpdate: time.Now().Add(-10 * time.Millisecond),
+			LastWarnLevel:  0, // No warning level
+		}
+		userManager := newMockUserManager(t)
+		userManager.EXPECT().
+			User(matchContext(), sess.IdentScreenName()).
+			Return(u, nil)
+
+		svc := ICBMService{
+			logger:         slog.Default(),
+			interval:       5 * time.Millisecond,
+			snacRateLimits: wire.DefaultSNACRateLimits(),
+			timeNow:        time.Now,
+			userManager:    userManager,
+		}
+
+		err := svc.RestoreWarningLevel(context.Background(), sess)
+		assert.NoError(t, err)
+		assert.Equal(t, uint16(0), sess.Warning())
+	})
+}
+
+func Test_calcWarningLevelChange(t *testing.T) {
+
+	t.Run("active warn level, last modified between intervals", func(t *testing.T) {
+		now := time.Now()
+		interval := 5 * time.Millisecond
+		lastWarn := now.Add(-15 * time.Millisecond).Add(-1 * time.Millisecond)
+		warnDelta := calcElapsedWarningLevel(lastWarn, now, interval)
+		assert.Equal(t, int16(-150), warnDelta)
+	})
+
+	t.Run("active warn level, last modified between intervals", func(t *testing.T) {
+		now := time.Now()
+		interval := 5 * time.Millisecond
+		lastWarn := now.Add(-15 * time.Millisecond).Add(1 * time.Millisecond)
+		warnDelta := calcElapsedWarningLevel(lastWarn, now, interval)
+		assert.Equal(t, int16(-100), warnDelta)
+	})
+
+	t.Run("active warn level, last modified exactly on interval", func(t *testing.T) {
+		now := time.Now()
+		interval := 5 * time.Millisecond
+		lastWarn := now.Add(-15 * time.Millisecond)
+		warnDelta := calcElapsedWarningLevel(lastWarn, now, interval)
+		assert.Equal(t, int16(-150), warnDelta)
+	})
+
+	t.Run("resolved warn level", func(t *testing.T) {
+		now := time.Now()
+		interval := 5 * time.Millisecond
+		lastWarn := now.Add(-25 * time.Millisecond)
+		warnDelta := calcElapsedWarningLevel(lastWarn, now, interval)
+		assert.Equal(t, int16(-250), warnDelta)
+	})
+
+	t.Run("resolved warn level - time past exceeds maximum window", func(t *testing.T) {
+		now := time.Now()
+		interval := 5 * time.Millisecond
+		lastWarn := now.Add(-200 * time.Millisecond)
+		warnDelta := calcElapsedWarningLevel(lastWarn, now, interval)
+		assert.Equal(t, int16(-2000), warnDelta)
+	})
+}
+
+func Test_calcRefreshInterval(t *testing.T) {
+
+	t.Run("active warn level, last modified between intervals", func(t *testing.T) {
+		now := time.Now()
+		interval := 5 * time.Millisecond
+		lastWarn := now.Add(-15 * time.Millisecond).Add(-1 * time.Millisecond)
+		newInterval := timeTillNextInterval(lastWarn, now, interval)
+		assert.Equal(t, 4*time.Millisecond, newInterval)
+	})
+
+	t.Run("active warn level, last modified between intervals", func(t *testing.T) {
+		now := time.Now()
+		interval := 5 * time.Millisecond
+		lastWarn := now.Add(-15 * time.Millisecond).Add(1 * time.Millisecond)
+		newInterval := timeTillNextInterval(lastWarn, now, interval)
+		assert.Equal(t, 1*time.Millisecond, newInterval)
+	})
+
+	t.Run("active warn level, last modified exactly on interval", func(t *testing.T) {
+		now := time.Now()
+		interval := 5 * time.Millisecond
+		lastWarn := now.Add(-15 * time.Millisecond)
+		newInterval := timeTillNextInterval(lastWarn, now, interval)
+		assert.Equal(t, 5*time.Millisecond, newInterval)
+	})
+
+	t.Run("resolved warn level", func(t *testing.T) {
+		now := time.Now()
+		interval := 5 * time.Millisecond
+		lastWarn := now.Add(-25 * time.Millisecond)
+		newInterval := timeTillNextInterval(lastWarn, now, interval)
+		assert.Equal(t, interval, newInterval)
+	})
+
+	t.Run("resolved warn level - time past exceeds maximum window", func(t *testing.T) {
+		now := time.Now()
+		interval := 5 * time.Millisecond
+		lastWarn := now.Add(-200 * time.Millisecond)
+		newInterval := timeTillNextInterval(lastWarn, now, interval)
+		assert.Equal(t, interval, newInterval)
 	})
 }

+ 2 - 2
foodgroup/locate.go

@@ -84,7 +84,7 @@ func (s LocateService) SetInfo(ctx context.Context, sess *state.Session, inBody
 	if awayMsg, hasAwayMsg := inBody.String(wire.LocateTLVTagsInfoUnavailableData); hasAwayMsg {
 		sess.SetAwayMessage(awayMsg)
 		if sess.SignonComplete() {
-			if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
+			if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess.IdentScreenName(), sess.TLVUserInfo()); err != nil {
 				return err
 			}
 		}
@@ -106,7 +106,7 @@ func (s LocateService) SetInfo(ctx context.Context, sess *state.Session, inBody
 		}
 		sess.SetCaps(caps)
 		if sess.SignonComplete() {
-			if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
+			if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess.IdentScreenName(), sess.TLVUserInfo()); err != nil {
 				return err
 			}
 		}

+ 4 - 2
foodgroup/locate_test.go

@@ -725,7 +725,7 @@ func TestLocateService_SetInfo(t *testing.T) {
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							screenName: state.NewIdentScreenName("user_screen_name"),
+							screenName: state.DisplayScreenName("user_screen_name"),
 						},
 					},
 				},
@@ -743,7 +743,9 @@ func TestLocateService_SetInfo(t *testing.T) {
 			buddyUpdateBroadcaster := newMockbuddyBroadcaster(t)
 			for _, params := range tt.mockParams.broadcastBuddyArrivedParams {
 				buddyUpdateBroadcaster.EXPECT().
-					BroadcastBuddyArrived(mock.Anything, matchSession(params.screenName)).
+					BroadcastBuddyArrived(mock.Anything, state.NewIdentScreenName(params.screenName.String()), mock.MatchedBy(func(userInfo wire.TLVUserInfo) bool {
+						return userInfo.ScreenName == params.screenName.String()
+					})).
 					Return(params.err)
 			}
 			svc := NewLocateService(nil, nil, profileManager, nil, nil)

+ 14 - 11
foodgroup/mock_buddy_broadcaster_test.go

@@ -7,6 +7,8 @@ import (
 
 	state "github.com/mk6i/retro-aim-server/state"
 	mock "github.com/stretchr/testify/mock"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
 )
 
 // mockbuddyBroadcaster is an autogenerated mock type for the buddyBroadcaster type
@@ -22,17 +24,17 @@ func (_m *mockbuddyBroadcaster) EXPECT() *mockbuddyBroadcaster_Expecter {
 	return &mockbuddyBroadcaster_Expecter{mock: &_m.Mock}
 }
 
-// BroadcastBuddyArrived provides a mock function with given fields: ctx, sess
-func (_m *mockbuddyBroadcaster) BroadcastBuddyArrived(ctx context.Context, sess *state.Session) error {
-	ret := _m.Called(ctx, sess)
+// BroadcastBuddyArrived provides a mock function with given fields: ctx, screenName, userInfo
+func (_m *mockbuddyBroadcaster) BroadcastBuddyArrived(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) error {
+	ret := _m.Called(ctx, screenName, userInfo)
 
 	if len(ret) == 0 {
 		panic("no return value specified for BroadcastBuddyArrived")
 	}
 
 	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session) error); ok {
-		r0 = rf(ctx, sess)
+	if rf, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, wire.TLVUserInfo) error); ok {
+		r0 = rf(ctx, screenName, userInfo)
 	} else {
 		r0 = ret.Error(0)
 	}
@@ -47,14 +49,15 @@ type mockbuddyBroadcaster_BroadcastBuddyArrived_Call struct {
 
 // BroadcastBuddyArrived is a helper method to define mock.On call
 //   - ctx context.Context
-//   - sess *state.Session
-func (_e *mockbuddyBroadcaster_Expecter) BroadcastBuddyArrived(ctx interface{}, sess interface{}) *mockbuddyBroadcaster_BroadcastBuddyArrived_Call {
-	return &mockbuddyBroadcaster_BroadcastBuddyArrived_Call{Call: _e.mock.On("BroadcastBuddyArrived", ctx, sess)}
+//   - screenName state.IdentScreenName
+//   - userInfo wire.TLVUserInfo
+func (_e *mockbuddyBroadcaster_Expecter) BroadcastBuddyArrived(ctx interface{}, screenName interface{}, userInfo interface{}) *mockbuddyBroadcaster_BroadcastBuddyArrived_Call {
+	return &mockbuddyBroadcaster_BroadcastBuddyArrived_Call{Call: _e.mock.On("BroadcastBuddyArrived", ctx, screenName, userInfo)}
 }
 
-func (_c *mockbuddyBroadcaster_BroadcastBuddyArrived_Call) Run(run func(ctx context.Context, sess *state.Session)) *mockbuddyBroadcaster_BroadcastBuddyArrived_Call {
+func (_c *mockbuddyBroadcaster_BroadcastBuddyArrived_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo)) *mockbuddyBroadcaster_BroadcastBuddyArrived_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session))
+		run(args[0].(context.Context), args[1].(state.IdentScreenName), args[2].(wire.TLVUserInfo))
 	})
 	return _c
 }
@@ -64,7 +67,7 @@ func (_c *mockbuddyBroadcaster_BroadcastBuddyArrived_Call) Return(_a0 error) *mo
 	return _c
 }
 
-func (_c *mockbuddyBroadcaster_BroadcastBuddyArrived_Call) RunAndReturn(run func(context.Context, *state.Session) error) *mockbuddyBroadcaster_BroadcastBuddyArrived_Call {
+func (_c *mockbuddyBroadcaster_BroadcastBuddyArrived_Call) RunAndReturn(run func(context.Context, state.IdentScreenName, wire.TLVUserInfo) error) *mockbuddyBroadcaster_BroadcastBuddyArrived_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 51 - 0
foodgroup/mock_user_manager_manager_test.go

@@ -7,6 +7,8 @@ import (
 
 	state "github.com/mk6i/retro-aim-server/state"
 	mock "github.com/stretchr/testify/mock"
+
+	time "time"
 )
 
 // mockUserManager is an autogenerated mock type for the UserManager type
@@ -69,6 +71,55 @@ func (_c *mockUserManager_InsertUser_Call) RunAndReturn(run func(context.Context
 	return _c
 }
 
+// SetWarnLevel provides a mock function with given fields: ctx, user, lastWarnUpdate, lastWarnLevel
+func (_m *mockUserManager) SetWarnLevel(ctx context.Context, user state.IdentScreenName, lastWarnUpdate time.Time, lastWarnLevel uint16) error {
+	ret := _m.Called(ctx, user, lastWarnUpdate, lastWarnLevel)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetWarnLevel")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, time.Time, uint16) error); ok {
+		r0 = rf(ctx, user, lastWarnUpdate, lastWarnLevel)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockUserManager_SetWarnLevel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetWarnLevel'
+type mockUserManager_SetWarnLevel_Call struct {
+	*mock.Call
+}
+
+// SetWarnLevel is a helper method to define mock.On call
+//   - ctx context.Context
+//   - user state.IdentScreenName
+//   - lastWarnUpdate time.Time
+//   - lastWarnLevel uint16
+func (_e *mockUserManager_Expecter) SetWarnLevel(ctx interface{}, user interface{}, lastWarnUpdate interface{}, lastWarnLevel interface{}) *mockUserManager_SetWarnLevel_Call {
+	return &mockUserManager_SetWarnLevel_Call{Call: _e.mock.On("SetWarnLevel", ctx, user, lastWarnUpdate, lastWarnLevel)}
+}
+
+func (_c *mockUserManager_SetWarnLevel_Call) Run(run func(ctx context.Context, user state.IdentScreenName, lastWarnUpdate time.Time, lastWarnLevel uint16)) *mockUserManager_SetWarnLevel_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(state.IdentScreenName), args[2].(time.Time), args[3].(uint16))
+	})
+	return _c
+}
+
+func (_c *mockUserManager_SetWarnLevel_Call) Return(_a0 error) *mockUserManager_SetWarnLevel_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockUserManager_SetWarnLevel_Call) RunAndReturn(run func(context.Context, state.IdentScreenName, time.Time, uint16) error) *mockUserManager_SetWarnLevel_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // User provides a mock function with given fields: ctx, screenName
 func (_m *mockUserManager) User(ctx context.Context, screenName state.IdentScreenName) (*state.User, error) {
 	ret := _m.Called(ctx, screenName)

+ 2 - 2
foodgroup/oservice.go

@@ -237,7 +237,7 @@ func (s OServiceService) SetUserInfoFields(ctx context.Context, sess *state.Sess
 				return wire.SNACMessage{}, err
 			}
 		} else {
-			if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
+			if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess.IdentScreenName(), sess.TLVUserInfo()); err != nil {
 				return wire.SNACMessage{}, err
 			}
 
@@ -262,7 +262,7 @@ func (s OServiceService) IdleNotification(ctx context.Context, sess *state.Sessi
 	} else {
 		sess.SetIdle(time.Duration(bodyIn.IdleTime) * time.Second)
 	}
-	return s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess)
+	return s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess.IdentScreenName(), sess.TLVUserInfo())
 }
 
 // SetPrivacyFlags sets client privacy settings. Currently, there's no action

+ 10 - 6
foodgroup/oservice_test.go

@@ -873,7 +873,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							screenName: state.NewIdentScreenName("me"),
+							screenName: state.DisplayScreenName("me"),
 						},
 					},
 				},
@@ -943,7 +943,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							screenName: state.NewIdentScreenName("me"),
+							screenName: state.DisplayScreenName("me"),
 						},
 					},
 				},
@@ -989,7 +989,9 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 			buddyUpdateBroadcaster := newMockbuddyBroadcaster(t)
 			for _, params := range tc.mockParams.broadcastBuddyArrivedParams {
 				buddyUpdateBroadcaster.EXPECT().
-					BroadcastBuddyArrived(mock.Anything, matchSession(params.screenName)).
+					BroadcastBuddyArrived(mock.Anything, state.NewIdentScreenName(params.screenName.String()), mock.MatchedBy(func(userInfo wire.TLVUserInfo) bool {
+						return userInfo.ScreenName == params.screenName.String()
+					})).
 					Return(params.err)
 			}
 			for _, params := range tc.mockParams.broadcastBuddyDepartedParams {
@@ -1805,7 +1807,7 @@ func TestOServiceService_IdleNotification(t *testing.T) {
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							screenName: state.NewIdentScreenName("me"),
+							screenName: state.DisplayScreenName("me"),
 						},
 					},
 				},
@@ -1821,7 +1823,7 @@ func TestOServiceService_IdleNotification(t *testing.T) {
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							screenName: state.NewIdentScreenName("me"),
+							screenName: state.DisplayScreenName("me"),
 						},
 					},
 				},
@@ -1833,7 +1835,9 @@ func TestOServiceService_IdleNotification(t *testing.T) {
 			buddyUpdateBroadcaster := newMockbuddyBroadcaster(t)
 			for _, params := range tt.mockParams.broadcastBuddyArrivedParams {
 				buddyUpdateBroadcaster.EXPECT().
-					BroadcastBuddyArrived(mock.Anything, matchSession(params.screenName)).
+					BroadcastBuddyArrived(mock.Anything, state.NewIdentScreenName(params.screenName.String()), mock.MatchedBy(func(userInfo wire.TLVUserInfo) bool {
+						return userInfo.ScreenName == params.screenName.String()
+					})).
 					Return(params.err)
 			}
 			svc := OServiceService{

+ 4 - 1
foodgroup/types.go

@@ -87,7 +87,7 @@ type AccountManager interface {
 // or changes visibility status.
 type buddyBroadcaster interface {
 	// BroadcastBuddyArrived notifies all relevant users that the given user has come online.
-	BroadcastBuddyArrived(ctx context.Context, sess *state.Session) error
+	BroadcastBuddyArrived(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) error
 
 	// BroadcastBuddyDeparted notifies all relevant users that the given user has gone offline.
 	BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error
@@ -365,4 +365,7 @@ type UserManager interface {
 
 	// User returns the user record associated with the given screen name.
 	User(ctx context.Context, screenName state.IdentScreenName) (*state.User, error)
+
+	// SetWarnLevel updates the last warn update time and warning level for a user.
+	SetWarnLevel(ctx context.Context, user state.IdentScreenName, lastWarnUpdate time.Time, lastWarnLevel uint16) error
 }

+ 14 - 11
server/oscar/mock_departure_notifier_test.go

@@ -7,6 +7,8 @@ import (
 
 	state "github.com/mk6i/retro-aim-server/state"
 	mock "github.com/stretchr/testify/mock"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
 )
 
 // mockDepartureNotifier is an autogenerated mock type for the DepartureNotifier type
@@ -22,17 +24,17 @@ func (_m *mockDepartureNotifier) EXPECT() *mockDepartureNotifier_Expecter {
 	return &mockDepartureNotifier_Expecter{mock: &_m.Mock}
 }
 
-// BroadcastBuddyArrived provides a mock function with given fields: ctx, sess
-func (_m *mockDepartureNotifier) BroadcastBuddyArrived(ctx context.Context, sess *state.Session) error {
-	ret := _m.Called(ctx, sess)
+// BroadcastBuddyArrived provides a mock function with given fields: ctx, screenName, userInfo
+func (_m *mockDepartureNotifier) BroadcastBuddyArrived(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) error {
+	ret := _m.Called(ctx, screenName, userInfo)
 
 	if len(ret) == 0 {
 		panic("no return value specified for BroadcastBuddyArrived")
 	}
 
 	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session) error); ok {
-		r0 = rf(ctx, sess)
+	if rf, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, wire.TLVUserInfo) error); ok {
+		r0 = rf(ctx, screenName, userInfo)
 	} else {
 		r0 = ret.Error(0)
 	}
@@ -47,14 +49,15 @@ type mockDepartureNotifier_BroadcastBuddyArrived_Call struct {
 
 // BroadcastBuddyArrived is a helper method to define mock.On call
 //   - ctx context.Context
-//   - sess *state.Session
-func (_e *mockDepartureNotifier_Expecter) BroadcastBuddyArrived(ctx interface{}, sess interface{}) *mockDepartureNotifier_BroadcastBuddyArrived_Call {
-	return &mockDepartureNotifier_BroadcastBuddyArrived_Call{Call: _e.mock.On("BroadcastBuddyArrived", ctx, sess)}
+//   - screenName state.IdentScreenName
+//   - userInfo wire.TLVUserInfo
+func (_e *mockDepartureNotifier_Expecter) BroadcastBuddyArrived(ctx interface{}, screenName interface{}, userInfo interface{}) *mockDepartureNotifier_BroadcastBuddyArrived_Call {
+	return &mockDepartureNotifier_BroadcastBuddyArrived_Call{Call: _e.mock.On("BroadcastBuddyArrived", ctx, screenName, userInfo)}
 }
 
-func (_c *mockDepartureNotifier_BroadcastBuddyArrived_Call) Run(run func(ctx context.Context, sess *state.Session)) *mockDepartureNotifier_BroadcastBuddyArrived_Call {
+func (_c *mockDepartureNotifier_BroadcastBuddyArrived_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo)) *mockDepartureNotifier_BroadcastBuddyArrived_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session))
+		run(args[0].(context.Context), args[1].(state.IdentScreenName), args[2].(wire.TLVUserInfo))
 	})
 	return _c
 }
@@ -64,7 +67,7 @@ func (_c *mockDepartureNotifier_BroadcastBuddyArrived_Call) Return(_a0 error) *m
 	return _c
 }
 
-func (_c *mockDepartureNotifier_BroadcastBuddyArrived_Call) RunAndReturn(run func(context.Context, *state.Session) error) *mockDepartureNotifier_BroadcastBuddyArrived_Call {
+func (_c *mockDepartureNotifier_BroadcastBuddyArrived_Call) RunAndReturn(run func(context.Context, state.IdentScreenName, wire.TLVUserInfo) error) *mockDepartureNotifier_BroadcastBuddyArrived_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 81 - 34
server/oscar/mock_icbm_service_test.go

@@ -183,40 +183,6 @@ func (_c *mockICBMService_ClientEvent_Call) RunAndReturn(run func(context.Contex
 	return _c
 }
 
-// DecayWarnLevel provides a mock function with given fields: ctx, sess
-func (_m *mockICBMService) DecayWarnLevel(ctx context.Context, sess *state.Session) {
-	_m.Called(ctx, sess)
-}
-
-// mockICBMService_DecayWarnLevel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DecayWarnLevel'
-type mockICBMService_DecayWarnLevel_Call struct {
-	*mock.Call
-}
-
-// DecayWarnLevel is a helper method to define mock.On call
-//   - ctx context.Context
-//   - sess *state.Session
-func (_e *mockICBMService_Expecter) DecayWarnLevel(ctx interface{}, sess interface{}) *mockICBMService_DecayWarnLevel_Call {
-	return &mockICBMService_DecayWarnLevel_Call{Call: _e.mock.On("DecayWarnLevel", ctx, sess)}
-}
-
-func (_c *mockICBMService_DecayWarnLevel_Call) Run(run func(ctx context.Context, sess *state.Session)) *mockICBMService_DecayWarnLevel_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session))
-	})
-	return _c
-}
-
-func (_c *mockICBMService_DecayWarnLevel_Call) Return() *mockICBMService_DecayWarnLevel_Call {
-	_c.Call.Return()
-	return _c
-}
-
-func (_c *mockICBMService_DecayWarnLevel_Call) RunAndReturn(run func(context.Context, *state.Session)) *mockICBMService_DecayWarnLevel_Call {
-	_c.Run(run)
-	return _c
-}
-
 // EvilRequest provides a mock function with given fields: ctx, sess, inFrame, inBody
 func (_m *mockICBMService) EvilRequest(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x08_ICBMEvilRequest) (wire.SNACMessage, error) {
 	ret := _m.Called(ctx, sess, inFrame, inBody)
@@ -323,6 +289,87 @@ func (_c *mockICBMService_ParameterQuery_Call) RunAndReturn(run func(context.Con
 	return _c
 }
 
+// RestoreWarningLevel provides a mock function with given fields: ctx, sess
+func (_m *mockICBMService) RestoreWarningLevel(ctx context.Context, sess *state.Session) error {
+	ret := _m.Called(ctx, sess)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RestoreWarningLevel")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session) error); ok {
+		r0 = rf(ctx, sess)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICBMService_RestoreWarningLevel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RestoreWarningLevel'
+type mockICBMService_RestoreWarningLevel_Call struct {
+	*mock.Call
+}
+
+// RestoreWarningLevel is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+func (_e *mockICBMService_Expecter) RestoreWarningLevel(ctx interface{}, sess interface{}) *mockICBMService_RestoreWarningLevel_Call {
+	return &mockICBMService_RestoreWarningLevel_Call{Call: _e.mock.On("RestoreWarningLevel", ctx, sess)}
+}
+
+func (_c *mockICBMService_RestoreWarningLevel_Call) Run(run func(ctx context.Context, sess *state.Session)) *mockICBMService_RestoreWarningLevel_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session))
+	})
+	return _c
+}
+
+func (_c *mockICBMService_RestoreWarningLevel_Call) Return(_a0 error) *mockICBMService_RestoreWarningLevel_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICBMService_RestoreWarningLevel_Call) RunAndReturn(run func(context.Context, *state.Session) error) *mockICBMService_RestoreWarningLevel_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// UpdateWarnLevel provides a mock function with given fields: ctx, sess
+func (_m *mockICBMService) UpdateWarnLevel(ctx context.Context, sess *state.Session) {
+	_m.Called(ctx, sess)
+}
+
+// mockICBMService_UpdateWarnLevel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateWarnLevel'
+type mockICBMService_UpdateWarnLevel_Call struct {
+	*mock.Call
+}
+
+// UpdateWarnLevel is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+func (_e *mockICBMService_Expecter) UpdateWarnLevel(ctx interface{}, sess interface{}) *mockICBMService_UpdateWarnLevel_Call {
+	return &mockICBMService_UpdateWarnLevel_Call{Call: _e.mock.On("UpdateWarnLevel", ctx, sess)}
+}
+
+func (_c *mockICBMService_UpdateWarnLevel_Call) Run(run func(ctx context.Context, sess *state.Session)) *mockICBMService_UpdateWarnLevel_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session))
+	})
+	return _c
+}
+
+func (_c *mockICBMService_UpdateWarnLevel_Call) Return() *mockICBMService_UpdateWarnLevel_Call {
+	_c.Call.Return()
+	return _c
+}
+
+func (_c *mockICBMService_UpdateWarnLevel_Call) RunAndReturn(run func(context.Context, *state.Session)) *mockICBMService_UpdateWarnLevel_Call {
+	_c.Run(run)
+	return _c
+}
+
 // newMockICBMService creates a new instance of mockICBMService. 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 newMockICBMService(t interface {

+ 6 - 0
server/oscar/server.go

@@ -34,6 +34,7 @@ func NewServer(
 	limits wire.SNACRateLimits,
 	limiter *IPRateLimiter,
 	listenerCfg []config.Listener,
+	recalcWarning func(ctx context.Context, sess *state.Session) error,
 	lowerWarnLevel func(ctx context.Context, sess *state.Session),
 ) *Server {
 	oscarSvc := oscarServer{
@@ -47,6 +48,7 @@ func NewServer(
 		RateLimitUpdater:   rateLimitUpdater,
 		SNACRateLimits:     limits,
 		IPRateLimiter:      limiter,
+		recalcWarning:      recalcWarning,
 		lowerWarnLevel:     lowerWarnLevel,
 	}
 
@@ -190,6 +192,7 @@ type oscarServer struct {
 	RateLimitUpdater
 	wire.SNACRateLimits
 	*IPRateLimiter
+	recalcWarning  func(ctx context.Context, sess *state.Session) error
 	lowerWarnLevel func(ctx context.Context, sess *state.Session)
 }
 
@@ -276,6 +279,9 @@ func (s oscarServer) connectToOSCARService(
 			sess.SetRemoteAddr(&ip)
 		}
 
+		if err := s.recalcWarning(ctx, sess); err != nil {
+			return fmt.Errorf("failed to recalculate warning level: %w", err)
+		}
 		go s.lowerWarnLevel(ctx, sess)
 		go s.receiveSessMessages(ctx, sess, flapc)
 

+ 5 - 0
server/oscar/server_test.go

@@ -55,6 +55,7 @@ func TestServer_ListenAndServeAndShutdown(t *testing.T) {
 		wire.DefaultSNACRateLimits(),
 		nil,
 		cfg,
+		func(ctx context.Context, sess *state.Session) error { return nil },
 		func(ctx context.Context, sess *state.Session) {},
 	)
 
@@ -419,6 +420,9 @@ func TestOscarServer_RouteConnection_BOS(t *testing.T) {
 		BuddyListRegistry:  buddyListRegistry,
 		ChatSessionManager: chatSessionManager,
 		DepartureNotifier:  departureNotifier,
+		recalcWarning: func(ctx context.Context, sess *state.Session) error {
+			return nil
+		},
 		lowerWarnLevel: func(ctx context.Context, sess *state.Session) {
 			defer wg.Done()
 		},
@@ -760,6 +764,7 @@ func Test_oscarServer_receiveSessMessages_BOS_integration(t *testing.T) {
 		DepartureNotifier:  departureNotifier,
 		OnlineNotifier:     onlineNotifier,
 		Logger:             slog.New(slog.NewTextHandler(io.Discard, nil)),
+		recalcWarning:      func(ctx context.Context, sess *state.Session) error { return nil },
 		lowerWarnLevel:     func(ctx context.Context, sess *state.Session) {},
 	}
 

+ 3 - 2
server/oscar/types.go

@@ -30,7 +30,7 @@ type BuddyListRegistry interface {
 // DepartureNotifier is the interface for sending buddy departure notifications
 // when a client disconnects.
 type DepartureNotifier interface {
-	BroadcastBuddyArrived(ctx context.Context, sess *state.Session) error
+	BroadcastBuddyArrived(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) error
 	BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error
 }
 
@@ -103,7 +103,8 @@ type ICBMService interface {
 	EvilRequest(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x08_ICBMEvilRequest) (wire.SNACMessage, error)
 	ParameterQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
 	ClientErr(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x04_0x0B_ICBMClientErr) error
-	DecayWarnLevel(ctx context.Context, sess *state.Session)
+	RestoreWarningLevel(ctx context.Context, sess *state.Session) error
+	UpdateWarnLevel(ctx context.Context, sess *state.Session)
 }
 
 type ICQService interface {

+ 7 - 1
server/toc/server.go

@@ -127,6 +127,7 @@ func NewServer(
 	logger *slog.Logger,
 	BOSProxy OSCARProxy,
 	ipRateLimiter *IPRateLimiter,
+	recalcWarning func(ctx context.Context, sess *state.Session) error,
 	lowerWarnLevel func(ctx context.Context, sess *state.Session),
 ) *Server {
 
@@ -138,6 +139,7 @@ func NewServer(
 		listenerCfg:        listenerCfg,
 		logger:             logger,
 		loginIPRateLimiter: ipRateLimiter,
+		recalcWarning:      recalcWarning,
 		lowerWarnLevel:     lowerWarnLevel,
 		servers:            make([]*http.Server, 0, len(listenerCfg)),
 		shutdownCancel:     cancel,
@@ -163,6 +165,7 @@ type Server struct {
 	bosProxy           OSCARProxy
 	logger             *slog.Logger
 	loginIPRateLimiter *IPRateLimiter
+	recalcWarning      func(ctx context.Context, sess *state.Session) error
 	lowerWarnLevel     func(ctx context.Context, sess *state.Session)
 
 	listenerCfg []string
@@ -203,7 +206,6 @@ func (s *Server) ListenAndServe() error {
 				ctx: s.shutdownCtx,
 			}
 			if err := s.servers[i].Serve(cl); !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, io.EOF) {
-				fmt.Println("HAHA")
 				s.shutdownCancel()
 				return err
 			}
@@ -396,6 +398,10 @@ func (s *Server) handleTOCRequest(
 	chatRegistry *ChatRegistry,
 	clientFlap *wire.FlapClient,
 ) error {
+	if err := s.recalcWarning(ctx, sessBOS); err != nil {
+		return fmt.Errorf("failed to recalculate warning level: %w", err)
+	}
+
 	// TOC response queue
 	msgCh := make(chan []byte, 1)
 

+ 5 - 0
server/toc/server_test.go

@@ -26,6 +26,7 @@ func TestServer_handleTOCRequest_serverShutdown(t *testing.T) {
 		sv := Server{
 			bosProxy:       testOSCARProxy(t),
 			logger:         slog.Default(),
+			recalcWarning:  func(ctx context.Context, sess *state.Session) error { return nil },
 			lowerWarnLevel: func(ctx context.Context, sess *state.Session) {},
 		}
 
@@ -65,6 +66,7 @@ func TestServer_handleTOCRequest_clientReadDisconnect(t *testing.T) {
 		sv := Server{
 			bosProxy:       testOSCARProxy(t),
 			logger:         slog.Default(),
+			recalcWarning:  func(ctx context.Context, sess *state.Session) error { return nil },
 			lowerWarnLevel: func(ctx context.Context, sess *state.Session) {},
 		}
 		err := sv.handleTOCRequest(context.Background(), closeConn, sess, NewChatRegistry(), fc)
@@ -99,6 +101,7 @@ func TestServer_handleTOCRequest_sessClose(t *testing.T) {
 		sv := Server{
 			bosProxy:       testOSCARProxy(t),
 			logger:         slog.Default(),
+			recalcWarning:  func(ctx context.Context, sess *state.Session) error { return nil },
 			lowerWarnLevel: func(ctx context.Context, sess *state.Session) {},
 		}
 		err := sv.handleTOCRequest(context.Background(), closeConn, sess, NewChatRegistry(), fc)
@@ -132,6 +135,7 @@ func TestServer_handleTOCRequest_replyFailure(t *testing.T) {
 		sv := Server{
 			bosProxy:       testOSCARProxy(t),
 			logger:         slog.Default(),
+			recalcWarning:  func(ctx context.Context, sess *state.Session) error { return nil },
 			lowerWarnLevel: func(ctx context.Context, sess *state.Session) {},
 		}
 		err := sv.handleTOCRequest(context.Background(), closeConn, sess, NewChatRegistry(), fc)
@@ -170,6 +174,7 @@ func TestServer_handleTOCRequest_happyPath(t *testing.T) {
 		sv := Server{
 			bosProxy:       testOSCARProxy(t),
 			logger:         slog.Default(),
+			recalcWarning:  func(ctx context.Context, sess *state.Session) error { return nil },
 			lowerWarnLevel: func(ctx context.Context, sess *state.Session) {},
 		}
 		err := sv.handleTOCRequest(context.Background(), closeConn, newTestSession("me"), NewChatRegistry(), fc)

+ 5 - 0
state/migrations/0014_warn_state.down.sql

@@ -0,0 +1,5 @@
+ALTER TABLE users
+    DROP COLUMN lastWarnLevel;
+
+ALTER TABLE users
+    DROP COLUMN lastWarnUpdate;

+ 5 - 0
state/migrations/0014_warn_state.up.sql

@@ -0,0 +1,5 @@
+ALTER TABLE users
+    ADD COLUMN lastWarnUpdate DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00';
+
+ALTER TABLE users
+    ADD COLUMN lastWarnLevel INTEGER NOT NULL DEFAULT 0;

+ 16 - 15
state/session.go

@@ -1,7 +1,6 @@
 package state
 
 import (
-	"context"
 	"net/netip"
 	"sync"
 	"time"
@@ -76,7 +75,8 @@ type Session struct {
 	userInfoBitmask       uint16
 	userStatusBitmask     uint32
 	warning               uint16
-	warningCh             chan struct{}
+	warningCh             chan uint16
+	lastWarnUpdate        time.Time
 }
 
 // NewSession returns a new instance of Session. By default, the user may have
@@ -119,7 +119,7 @@ func NewSession() *Session {
 			vals[wire.MDir] = 1
 			return vals
 		}(),
-		warningCh: make(chan struct{}),
+		warningCh: make(chan uint16, 1),
 	}
 }
 
@@ -196,14 +196,14 @@ func (s *Session) SetUserStatusBitmask(bitmask uint32) {
 // The incr parameter is the warning increment (negative to decrease), and classID specifies
 // which rate limit class to scale. The incr param is a percentage represented as an integer
 // where 30 = 3.0%, 100 = 10.0%, etc.
-func (s *Session) IncrementWarning(incr int16, classID wire.RateLimitClassID) bool {
+func (s *Session) IncrementWarning(incr int16, classID wire.RateLimitClassID) (bool, uint16) {
 	s.mutex.Lock()
 	defer s.mutex.Unlock()
 
 	// Handle warning level increment
 	newWarning := int32(s.warning) + int32(incr)
 	if newWarning > 1000 {
-		return false
+		return false, 0
 	}
 	if newWarning < 0 {
 		s.warning = 0 // clamp min at 0
@@ -238,7 +238,16 @@ func (s *Session) IncrementWarning(incr int16, classID wire.RateLimitClassID) bo
 	newLimitLevel = rateClass.AlertLevel + int32(float32(originalRateClass.MaxLevel-originalRateClass.AlertLevel)*pct)
 	rateClass.AlertLevel = clamp(newLimitLevel, originalRateClass.AlertLevel, originalRateClass.MaxLevel)
 
-	return true
+	s.warningCh <- s.warning
+
+	return true, s.warning
+}
+
+// SetWarning sets the user's last warning level.
+func (s *Session) SetWarning(warning uint16) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	s.warning = warning
 }
 
 // Warning returns the user's current warning level as a percentage.
@@ -251,17 +260,9 @@ func (s *Session) Warning() uint16 {
 	return s.warning
 }
 
-// NotifyWarning sends a warning notification signal to any listeners.
-func (s *Session) NotifyWarning(ctx context.Context) {
-	select {
-	case s.warningCh <- struct{}{}:
-	case <-ctx.Done():
-	}
-}
-
 // WarningCh returns the warning notification channel.
 // Listeners can receive from this channel to be notified when warnings occur.
-func (s *Session) WarningCh() chan struct{} {
+func (s *Session) WarningCh() chan uint16 {
 	return s.warningCh
 }
 

+ 61 - 4
state/session_test.go

@@ -1,6 +1,7 @@
 package state
 
 import (
+	"context"
 	"math"
 	"net/netip"
 	"sync"
@@ -23,10 +24,21 @@ func TestSession_SetAndGetAwayMessage(t *testing.T) {
 
 func TestSession_IncrementAndGetWarning(t *testing.T) {
 	s := NewSession()
-	assert.Zero(t, s.Warning())
-	s.IncrementWarning(1, 1)
-	s.IncrementWarning(2, 1)
-	assert.Equal(t, uint16(3), s.Warning())
+
+	var wg sync.WaitGroup
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		s.IncrementWarning(1, 1)
+		s.IncrementWarning(2, 1)
+		s.IncrementWarning(3, 1)
+	}()
+
+	assert.Equal(t, uint16(1), <-s.WarningCh())
+	assert.Equal(t, uint16(3), <-s.WarningCh())
+	assert.Equal(t, uint16(6), <-s.WarningCh())
+
+	wg.Wait()
 }
 
 func TestSession_SetAndGetInvisible(t *testing.T) {
@@ -623,6 +635,15 @@ func TestSession_SetAndGetMultiConnFlag(t *testing.T) {
 	assert.Equal(t, wire.MultiConnFlagsSingleClient, s.MultiConnFlag())
 }
 
+func TestSession_SetAndGetLastWarnLevel(t *testing.T) {
+	s := NewSession()
+	assert.Zero(t, s.Warning())
+
+	level := uint16(500)
+	s.SetWarning(level)
+	assert.Equal(t, level, s.Warning())
+}
+
 func TestSession_IncrementWarningWithRateLimitScaling(t *testing.T) {
 	t.Run("scale up", func(t *testing.T) {
 		classParams := [5]wire.RateClass{
@@ -647,6 +668,21 @@ func TestSession_IncrementWarningWithRateLimitScaling(t *testing.T) {
 		sess := NewSession()
 		sess.SetRateClasses(now, rateClasses)
 
+		var wg sync.WaitGroup
+		wg.Add(1)
+
+		ctx, cancel := context.WithCancel(t.Context())
+		go func() {
+			defer wg.Done()
+			for {
+				select {
+				case <-ctx.Done():
+					return
+				case <-sess.WarningCh():
+				}
+			}
+		}()
+
 		assert.Equal(t, int32(5000), sess.rateByClassID[2].AlertLevel)
 		assert.Equal(t, int32(5100), sess.rateByClassID[2].ClearLevel)
 		assert.Equal(t, int32(4000), sess.rateByClassID[2].LimitLevel)
@@ -705,6 +741,9 @@ func TestSession_IncrementWarningWithRateLimitScaling(t *testing.T) {
 		assert.Equal(t, int32(6000), sess.rateByClassID[2].AlertLevel)
 		assert.Equal(t, int32(6000), sess.rateByClassID[2].ClearLevel)
 		assert.Equal(t, int32(6000), sess.rateByClassID[2].LimitLevel)
+
+		cancel()
+		wg.Wait()
 	})
 
 	t.Run("scale down", func(t *testing.T) {
@@ -730,6 +769,21 @@ func TestSession_IncrementWarningWithRateLimitScaling(t *testing.T) {
 		sess := NewSession()
 		sess.SetRateClasses(now, rateClasses)
 
+		var wg sync.WaitGroup
+		wg.Add(1)
+
+		ctx, cancel := context.WithCancel(t.Context())
+		go func() {
+			defer wg.Done()
+			for {
+				select {
+				case <-ctx.Done():
+					return
+				case <-sess.WarningCh():
+				}
+			}
+		}()
+
 		for i := 0; i < 10; i++ {
 			sess.IncrementWarning(100, 3)
 		}
@@ -792,5 +846,8 @@ func TestSession_IncrementWarningWithRateLimitScaling(t *testing.T) {
 		assert.Equal(t, int32(5000), sess.rateByClassID[2].AlertLevel)
 		assert.Equal(t, int32(5100), sess.rateByClassID[2].ClearLevel)
 		assert.Equal(t, int32(4000), sess.rateByClassID[2].LimitLevel)
+
+		cancel()
+		wg.Wait()
 	})
 }

+ 4 - 0
state/user.go

@@ -202,6 +202,10 @@ type User struct {
 	TOCConfig string
 	// IsBot indicates whether the user is a bot.
 	IsBot bool
+	// LastWarnUpdate is the timestamp when the user's warning level was last updated.
+	LastWarnUpdate time.Time
+	// LastWarnLevel is the warning level when the user last signed off.
+	LastWarnLevel uint16
 }
 
 // AIMNameAndAddr holds name and address AIM directory information.

+ 31 - 1
state/user_store.go

@@ -410,7 +410,9 @@ func (f SQLiteUserStore) queryUsers(ctx context.Context, whereClause string, que
 			aim_nickName,
 			aim_zipCode,
 			aim_address,
-			tocConfig
+			tocConfig,
+			lastWarnUpdate,
+			lastWarnLevel
 		FROM users
 		WHERE %s
 	`
@@ -504,6 +506,8 @@ func (f SQLiteUserStore) queryUsers(ctx context.Context, whereClause string, que
 			&u.AIMDirectoryInfo.ZIPCode,
 			&u.AIMDirectoryInfo.Address,
 			&u.TOCConfig,
+			&u.LastWarnUpdate,
+			&u.LastWarnLevel,
 		)
 		if err != nil {
 			return nil, err
@@ -1959,3 +1963,29 @@ func (f SQLiteUserStore) SetTOCConfig(ctx context.Context, user IdentScreenName,
 	}
 	return nil
 }
+
+// SetWarnLevel updates the last warn update time and warning level for a user.
+func (f SQLiteUserStore) SetWarnLevel(ctx context.Context, user IdentScreenName, lastWarnUpdate time.Time, lastWarnLevel uint16) error {
+	q := `
+		UPDATE users
+		SET lastWarnUpdate = ?, lastWarnLevel = ?
+		WHERE identScreenName = ?
+	`
+	res, err := f.db.ExecContext(ctx,
+		q,
+		lastWarnUpdate,
+		lastWarnLevel,
+		user.String(),
+	)
+	if err != nil {
+		return fmt.Errorf("exec: %w", err)
+	}
+	c, err := res.RowsAffected()
+	if err != nil {
+		return fmt.Errorf("rows affected: %w", err)
+	}
+	if c == 0 {
+		return ErrNoUser
+	}
+	return nil
+}

+ 48 - 0
state/user_store_test.go

@@ -319,6 +319,7 @@ func TestGetUser(t *testing.T) {
 		AuthKey:           "theauthkey",
 		StrongMD5Pass:     []byte("thepasshash"),
 		RegStatus:         3,
+		LastWarnUpdate:    time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC), // Database default value
 	}
 	err = f.InsertUser(context.Background(), *insertedUser)
 	assert.NoError(t, err)
@@ -3279,3 +3280,50 @@ func TestSQLiteUserStore_SetBotStatus(t *testing.T) {
 	assert.NoError(t, err)
 	assert.False(t, user.IsBot)
 }
+
+func TestSQLiteUserStore_SetWarnLevel(t *testing.T) {
+	t.Run("Happy Path - Update Warning Level for Existing User", func(t *testing.T) {
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+
+		screenName := NewIdentScreenName("testuser")
+		user := User{
+			IdentScreenName: screenName,
+		}
+		err = f.InsertUser(context.Background(), user)
+		assert.NoError(t, err)
+
+		// Set initial warning level
+		lastWarnUpdate := time.Date(2023, 12, 1, 10, 30, 0, 0, time.UTC)
+		lastWarnLevel := uint16(5)
+
+		err = f.SetWarnLevel(context.Background(), screenName, lastWarnUpdate, lastWarnLevel)
+		assert.NoError(t, err)
+
+		// Verify the warning level was updated
+		updatedUser, err := f.User(context.Background(), screenName)
+		assert.NoError(t, err)
+		assert.Equal(t, lastWarnUpdate, updatedUser.LastWarnUpdate)
+		assert.Equal(t, lastWarnLevel, updatedUser.LastWarnLevel)
+	})
+
+	t.Run("User Does Not Exist", func(t *testing.T) {
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+
+		f, err := NewSQLiteUserStore(testFile)
+		assert.NoError(t, err)
+
+		nonExistentScreenName := NewIdentScreenName("nonexistentuser")
+		lastWarnUpdate := time.Date(2023, 12, 1, 10, 30, 0, 0, time.UTC)
+		lastWarnLevel := uint16(5)
+
+		err = f.SetWarnLevel(context.Background(), nonExistentScreenName, lastWarnUpdate, lastWarnLevel)
+		assert.ErrorIs(t, err, ErrNoUser)
+	})
+}