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

issue #41 - warning level decay + rate limit scaling

first pass at warning decay

tighten rate limits w/ warning

limit warnings between users

increase and decrease warning and rate limits in lock step

warning can't exceed 100%

wip

fix tests

unit test LowerWarnLevel

add warning burndown to toc

clamp min warning level at 0

consolidate ScaleRateLimit and IncrementWarning

refactoring
Mike 10 месяцев назад
Родитель
Сommit
497e00df21

+ 20 - 25
cmd/server/factory.go

@@ -29,6 +29,7 @@ type Container struct {
 	cfg                    config.Config
 	chatSessionManager     *state.InMemoryChatSessionManager
 	hmacCookieBaker        state.HMACCookieBaker
+	icbmSvc                *foodgroup.ICBMService
 	inMemorySessionManager *state.InMemorySessionManager
 	logger                 *slog.Logger
 	rateLimitClasses       wire.RateLimitClasses
@@ -74,6 +75,19 @@ func MakeCommonDeps() (Container, error) {
 	c.chatSessionManager = state.NewInMemoryChatSessionManager(c.logger)
 	c.rateLimitClasses = wire.DefaultRateLimitClasses()
 	c.snacRateLimits = wire.DefaultSNACRateLimits()
+
+	// ICBM svc is a common dep because OSCAR and TOC need to share convo
+	// history state.
+	c.icbmSvc = foodgroup.NewICBMService(
+		c.sqLiteUserStore,
+		c.inMemorySessionManager,
+		c.sqLiteUserStore,
+		c.sqLiteUserStore,
+		c.inMemorySessionManager,
+		c.snacRateLimits,
+		c.logger,
+	)
+
 	return c, nil
 }
 
@@ -249,14 +263,6 @@ func OSCAR(deps Container) *oscar.Server {
 		deps.inMemorySessionManager,
 		deps.inMemorySessionManager,
 	)
-	icbmService := foodgroup.NewICBMService(
-		deps.sqLiteUserStore,
-		deps.inMemorySessionManager,
-		deps.sqLiteUserStore,
-		deps.sqLiteUserStore,
-		deps.inMemorySessionManager,
-		deps.snacRateLimits,
-	)
 	icqService := foodgroup.NewICQService(deps.inMemorySessionManager, deps.sqLiteUserStore, deps.sqLiteUserStore,
 		logger, deps.inMemorySessionManager, deps.sqLiteUserStore)
 	locateService := foodgroup.NewLocateService(
@@ -301,7 +307,7 @@ func OSCAR(deps Container) *oscar.Server {
 			ChatNavService:    chatNavService,
 			ChatService:       chatService,
 			FeedbagService:    feedbagService,
-			ICBMService:       icbmService,
+			ICBMService:       deps.icbmSvc,
 			ICQService:        icqService,
 			LocateService:     locateService,
 			ODirService:       oDirService,
@@ -317,6 +323,7 @@ func OSCAR(deps Container) *oscar.Server {
 		deps.snacRateLimits,
 		oscar.NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
 		deps.Listeners,
+		deps.icbmSvc.DecayWarnLevel,
 	)
 }
 
@@ -343,6 +350,7 @@ func MgmtAPI(deps Container) *http.Server {
 // TOC creates a TOC server.
 func TOC(deps Container) *toc.Server {
 	logger := deps.logger.With("svc", "TOC")
+
 	return toc.NewServer(
 		deps.cfg.TOCListeners,
 		logger,
@@ -376,14 +384,7 @@ func TOC(deps Container) *toc.Server {
 			),
 			CookieBaker:      deps.hmacCookieBaker,
 			DirSearchService: foodgroup.NewODirService(logger, deps.sqLiteUserStore),
-			ICBMService: foodgroup.NewICBMService(
-				deps.sqLiteUserStore,
-				deps.inMemorySessionManager,
-				deps.sqLiteUserStore,
-				deps.sqLiteUserStore,
-				deps.inMemorySessionManager,
-				deps.snacRateLimits,
-			),
+			ICBMService:      deps.icbmSvc,
 			LocateService: foodgroup.NewLocateService(
 				deps.sqLiteUserStore,
 				deps.inMemorySessionManager,
@@ -419,6 +420,7 @@ 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,
 	)
 }
 
@@ -455,14 +457,7 @@ func WebAPI(deps Container) *webapi.Server {
 		),
 		CookieBaker:      deps.hmacCookieBaker,
 		DirSearchService: foodgroup.NewODirService(logger, deps.sqLiteUserStore),
-		ICBMService: foodgroup.NewICBMService(
-			deps.sqLiteUserStore,
-			deps.inMemorySessionManager,
-			deps.sqLiteUserStore,
-			deps.sqLiteUserStore,
-			deps.inMemorySessionManager,
-			deps.snacRateLimits,
-		),
+		ICBMService:      deps.icbmSvc,
 		LocateService: foodgroup.NewLocateService(
 			deps.sqLiteUserStore,
 			deps.inMemorySessionManager,

+ 4 - 0
foodgroup/buddy.go

@@ -104,6 +104,10 @@ 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 newBuddyNotifier(
 	buddyIconManager BuddyIconManager,
 	relationshipFetcher RelationshipFetcher,

+ 2 - 2
foodgroup/helpers_test.go

@@ -719,9 +719,9 @@ type createChatRoomParams []struct {
 }
 
 // sessOptWarning sets a warning level on the session object
-func sessOptWarning(level uint16) func(session *state.Session) {
+func sessOptWarning(level int16) func(session *state.Session) {
 	return func(session *state.Session) {
-		session.IncrementWarning(level)
+		session.IncrementWarning(level, 1)
 	}
 }
 

+ 211 - 3
foodgroup/icbm.go

@@ -4,15 +4,19 @@ import (
 	"bytes"
 	"context"
 	"fmt"
+	"log/slog"
 	"time"
 
+	"github.com/patrickmn/go-cache"
+
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
 )
 
 const (
-	evilDelta     = uint16(100)
-	evilDeltaAnon = uint16(30)
+	evilDelta       = uint16(100)
+	evilDeltaAnon   = uint16(30)
+	warningDecayPct = -50
 )
 
 // NewICBMService returns a new instance of ICBMService.
@@ -23,6 +27,7 @@ func NewICBMService(
 	relationshipFetcher RelationshipFetcher,
 	sessionRetriever SessionRetriever,
 	snacRateLimits wire.SNACRateLimits,
+	logger *slog.Logger,
 ) *ICBMService {
 	return &ICBMService{
 		relationshipFetcher: relationshipFetcher,
@@ -32,6 +37,9 @@ func NewICBMService(
 		timeNow:             time.Now,
 		sessionRetriever:    sessionRetriever,
 		snacRateLimits:      snacRateLimits,
+		convoTracker:        newConvoTracker(),
+		logger:              logger,
+		interval:            5 * time.Minute,
 	}
 }
 
@@ -46,6 +54,9 @@ type ICBMService struct {
 	timeNow             func() time.Time
 	sessionRetriever    SessionRetriever
 	snacRateLimits      wire.SNACRateLimits
+	convoTracker        *convoTracker
+	logger              *slog.Logger
+	interval            time.Duration
 }
 
 // ParameterQuery returns ICBM service parameters.
@@ -160,6 +171,8 @@ func (s ICBMService) ChannelMsgToHost(ctx context.Context, sess *state.Session,
 		Body: clientIM,
 	})
 
+	s.convoTracker.trackConvo(time.Now(), sess.IdentScreenName(), recipSess.IdentScreenName())
+
 	if _, requestedConfirmation := inBody.TLVRestBlock.Bytes(wire.ICBMTLVRequestHostAck); !requestedConfirmation {
 		// don't ack message
 		return nil, nil
@@ -310,11 +323,44 @@ func (s ICBMService) EvilRequest(ctx context.Context, sess *state.Session, inFra
 		}, nil
 	}
 
+	canWarn := s.convoTracker.trackWarn(time.Now(), sess.IdentScreenName(), recipSess.IdentScreenName())
+	if !canWarn {
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.ICBM,
+				SubGroup:  wire.ICBMErr,
+				RequestID: inFrame.RequestID,
+			},
+			Body: wire.SNACError{
+				Code: wire.ErrorCodeRequestDenied,
+			},
+		}, nil
+	}
+
 	increase := evilDelta
 	if inBody.SendAs == 1 {
 		increase = evilDeltaAnon
 	}
-	recipSess.IncrementWarning(increase)
+
+	// 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")
+	}
+
+	if ok := recipSess.IncrementWarning(int16(increase), classID); !ok {
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.ICBM,
+				SubGroup:  wire.ICBMErr,
+				RequestID: inFrame.RequestID,
+			},
+			Body: wire.SNACError{
+				Code: wire.ErrorCodeRequestDenied,
+			},
+		}, nil
+	}
+	recipSess.NotifyWarning(ctx)
 
 	notif := wire.SNAC_0x01_0x10_OServiceEvilNotification{
 		NewEvil: recipSess.Warning(),
@@ -357,3 +403,165 @@ func (s ICBMService) EvilRequest(ctx context.Context, sess *state.Session, inFra
 		},
 	}, 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) {
+	var inProgress bool
+	var ticker *time.Ticker
+	var tickC <-chan time.Time // nil when idle, enables/disables the select case
+
+	stopTicker := func() {
+		if ticker != nil {
+			ticker.Stop()
+			ticker = nil
+		}
+		tickC = nil
+		inProgress = false
+	}
+
+	startTicker := func() {
+		ticker = time.NewTicker(s.interval)
+		tickC = ticker.C
+		inProgress = true
+		s.logger.DebugContext(ctx, "warning decay started")
+	}
+
+	// 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")
+	}
+
+	for {
+		select {
+		case <-ctx.Done():
+			stopTicker()
+			return
+
+		case <-sess.WarningCh():
+			if inProgress {
+				s.logger.DebugContext(ctx, "warning decay already in progress")
+				continue
+			}
+			startTicker()
+
+		case <-tickC:
+			sess.IncrementWarning(warningDecayPct, classID)
+
+			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())
+			}
+
+			if sess.Warning() <= 0 {
+				s.logger.DebugContext(ctx, "warning decay complete")
+				stopTicker()
+			}
+		}
+	}
+}
+
+// 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
+// time per message received from warnee. The warner may only warn the warnee
+// up to 3 times per warn window.
+type convoTracker struct {
+	convos *cache.Cache
+	warns  *cache.Cache
+	window time.Duration
+}
+
+func newConvoTracker() *convoTracker {
+	window := 1 * time.Hour
+	return &convoTracker{
+		convos: cache.New(window, window),
+		warns:  cache.New(window, window),
+		window: window,
+	}
+}
+
+// trackConvo records a conversation from sender to recipient at the given time.
+func (w *convoTracker) trackConvo(now time.Time, sender, recip state.IdentScreenName) {
+	k := w.key(sender, recip)
+
+	buf, found := w.convos.Get(k)
+	if !found {
+		buf = &ringBuffer{}
+		w.convos.Set(k, buf, time.Hour)
+	}
+
+	buf.(*ringBuffer).set(now)
+}
+
+// trackWarn attempts to record a warning from warner to warnee.
+// It returns true if the warning is allowed (warnee has sent more messages
+// than warnings in the current window), or false if the warning limit has been
+// reached or no conversation exists in the current window.
+func (w *convoTracker) trackWarn(now time.Time, warner, warnee state.IdentScreenName) bool {
+	key := w.key(warnee, warner)
+
+	convos, found := w.convos.Get(key)
+	if !found {
+		// no convos tracked, can't warn
+		return false
+	}
+
+	windowStart := now.Add(-w.window)
+
+	// get convo count during window
+	var convoCt int
+	for _, v := range convos.(*ringBuffer).vals {
+		if v.After(windowStart) {
+			convoCt++
+		}
+	}
+
+	warns, found := w.warns.Get(key)
+	if !found {
+		warns = &ringBuffer{}
+		w.warns.Set(key, warns, time.Hour)
+	}
+
+	// get warn count during window
+	var warnCount int
+	for _, v := range warns.(*ringBuffer).vals {
+		if v.After(windowStart) {
+			warnCount++
+		}
+	}
+
+	if convoCt <= warnCount {
+		return false
+	}
+
+	warns.(*ringBuffer).set(now)
+
+	return true
+}
+
+func (w *convoTracker) key(sender state.IdentScreenName, recip state.IdentScreenName) string {
+	return sender.String() + recip.String()
+}
+
+// ringBuffer is a fixed-size circular buffer with 3 slots for storing time values.
+type ringBuffer struct {
+	cur  int          // Current cursor position (0, 1, or 2)
+	vals [3]time.Time // Fixed-size array to store time values
+}
+
+// val returns the time at the current cursor position.
+func (r *ringBuffer) val() time.Time {
+	return r.vals[r.cur]
+}
+
+// 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
+}

+ 363 - 2
foodgroup/icbm_test.go

@@ -2,8 +2,10 @@ package foodgroup
 
 import (
 	"context"
+	"log/slog"
 	"net"
 	"net/netip"
+	"sync"
 	"testing"
 	"time"
 
@@ -800,6 +802,7 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 				offlineMessageSaver: offlineMessageManager,
 				sessionRetriever:    sessionRetriever,
 				timeNow:             tc.timeNow,
+				convoTracker:        newConvoTracker(),
 			}
 
 			outputSNAC, err := svc.ChannelMsgToHost(context.Background(), tc.senderSession, tc.inputSNAC.Frame,
@@ -939,6 +942,8 @@ func TestICBMService_EvilRequest(t *testing.T) {
 		name string
 		// senderScreenName is the session of the user sending the EvilRequest
 		senderSession *state.Session
+		// msgsReceived is the # of messages received from the warned user
+		msgsReceived int
 		// inputSNAC is the SNAC sent by the sender client
 		inputSNAC wire.SNACMessage
 		// expectOutput is the SNAC sent from the server to client
@@ -946,10 +951,13 @@ func TestICBMService_EvilRequest(t *testing.T) {
 		// mockParams is the list of params sent to mocks that satisfy this
 		// method's dependencies
 		mockParams mockParams
+		// waitForWarnMsg indicates whether to wait for session warn signal
+		waitForWarnMsg bool
 	}{
 		{
 			name:          "transmit anonymous warning from sender to recipient",
 			senderSession: newTestSession("sender-screen-name"),
+			msgsReceived:  1,
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -1018,10 +1026,12 @@ func TestICBMService_EvilRequest(t *testing.T) {
 					},
 				},
 			},
+			waitForWarnMsg: true,
 		},
 		{
 			name:          "transmit non-anonymous warning from sender to recipient",
 			senderSession: newTestSession("sender-screen-name", sessOptWarning(110)),
+			msgsReceived:  1,
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -1098,10 +1108,12 @@ func TestICBMService_EvilRequest(t *testing.T) {
 					},
 				},
 			},
+			waitForWarnMsg: true,
 		},
 		{
 			name:          "don't transmit non-anonymous warning from sender to recipient because sender has blocked recipient",
 			senderSession: newTestSession("sender-screen-name"),
+			msgsReceived:  1,
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -1142,6 +1154,7 @@ func TestICBMService_EvilRequest(t *testing.T) {
 		{
 			name:          "don't transmit non-anonymous warning from sender to recipient because recipient has blocked sender",
 			senderSession: newTestSession("sender-screen-name"),
+			msgsReceived:  1,
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -1182,6 +1195,7 @@ func TestICBMService_EvilRequest(t *testing.T) {
 		{
 			name:          "don't let users warn themselves",
 			senderSession: newTestSession("sender-screen-name"),
+			msgsReceived:  1,
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -1205,6 +1219,7 @@ func TestICBMService_EvilRequest(t *testing.T) {
 		{
 			name:          "don't transmit non-anonymous warning from sender to recipient because recipient is offline",
 			senderSession: newTestSession("sender-screen-name"),
+			msgsReceived:  1,
 			mockParams: mockParams{
 				relationshipFetcherParams: relationshipFetcherParams{
 					relationshipParams: relationshipParams{
@@ -1253,6 +1268,7 @@ func TestICBMService_EvilRequest(t *testing.T) {
 		{
 			name:          "don't transmit anonymous warning from sender to recipient because recipient is offline",
 			senderSession: newTestSession("sender-screen-name"),
+			msgsReceived:  1,
 			mockParams: mockParams{
 				relationshipFetcherParams: relationshipFetcherParams{
 					relationshipParams: relationshipParams{
@@ -1338,18 +1354,38 @@ func TestICBMService_EvilRequest(t *testing.T) {
 				messageRelayer:      messageRelayer,
 				offlineMessageSaver: offlineMessageManager,
 				sessionRetriever:    sessionRetriever,
+				convoTracker:        newConvoTracker(),
+				snacRateLimits:      wire.DefaultSNACRateLimits(),
 			}
 
+			for i := 0; i < tc.msgsReceived; i++ {
+				svc.convoTracker.trackConvo(time.Now(),
+					state.NewIdentScreenName(tc.inputSNAC.Body.(wire.SNAC_0x04_0x08_ICBMEvilRequest).ScreenName),
+					tc.senderSession.IdentScreenName())
+			}
+
+			var wg sync.WaitGroup
+			if tc.waitForWarnMsg {
+				wg.Add(1)
+				go func() {
+					defer wg.Done()
+					for _, sess := range tc.mockParams.sessionRetrieverParams.retrieveSessionParams {
+						<-sess.result.WarningCh()
+					}
+				}()
+			}
 			outputSNAC, err := svc.EvilRequest(context.Background(), tc.senderSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x04_0x08_ICBMEvilRequest))
 			assert.NoError(t, err)
 			assert.Equal(t, tc.expectOutput, outputSNAC)
+
+			wg.Wait()
 		})
 	}
 }
 
 func TestICBMService_ParameterQuery(t *testing.T) {
-	svc := NewICBMService(nil, nil, nil, nil, nil, wire.DefaultSNACRateLimits())
+	svc := NewICBMService(nil, nil, nil, nil, nil, wire.DefaultSNACRateLimits(), slog.Default())
 
 	have := svc.ParameterQuery(nil, wire.SNACFrame{RequestID: 1234})
 	want := wire.SNACMessage{
@@ -1401,8 +1437,333 @@ func TestICBMService_ClientErr(t *testing.T) {
 	messageRelayer.EXPECT().
 		RelayToScreenName(mock.Anything, state.NewIdentScreenName("recipientScreenName"), expect)
 
-	svc := NewICBMService(nil, messageRelayer, nil, nil, nil, wire.DefaultSNACRateLimits())
+	svc := NewICBMService(nil, messageRelayer, nil, nil, nil, wire.DefaultSNACRateLimits(), slog.Default())
 
 	err := svc.ClientErr(context.Background(), sess, wire.SNACFrame{RequestID: 1234}, inBody)
 	assert.NoError(t, err)
 }
+
+func TestRingBuffer(t *testing.T) {
+	t.Run("new ringBuffer should have zero values", func(t *testing.T) {
+		rb := &ringBuffer{}
+
+		// Test val() on empty ringBuffer - should return zero time
+		result := rb.val()
+		zeroTime := time.Time{}
+		assert.Equal(t, zeroTime, result)
+	})
+
+	t.Run("val() should return current time", func(t *testing.T) {
+		now := time.Now()
+		rb := &ringBuffer{
+			cur: 1,
+			vals: [3]time.Time{
+				now.Add(-2 * time.Hour),
+				now.Add(-1 * time.Hour),
+				now,
+			},
+		}
+
+		result := rb.val()
+		assert.Equal(t, rb.vals[1], result)
+	})
+
+	t.Run("set() should store time and advance cursor", func(t *testing.T) {
+		rb := &ringBuffer{cur: 0}
+		newTime := time.Now()
+
+		// Set the time
+		rb.set(newTime)
+
+		// After set, cursor should advance to position 1
+		// We can verify this by setting another time and checking that it's stored at position 1
+		secondTime := time.Now().Add(time.Hour)
+		rb.set(secondTime)
+
+		// Now cursor should be at position 2, so val() should return the time at position 2
+		// But since we only set 2 times, position 2 should still be the zero time
+		zeroTime := time.Time{}
+		assert.Equal(t, zeroTime, rb.val())
+	})
+
+	t.Run("set() should wrap around after reaching end of array", func(t *testing.T) {
+		rb := &ringBuffer{cur: 2}
+		newTime := time.Now()
+
+		// Set the time at position 2
+		rb.set(newTime)
+
+		// Cursor should wrap around to position 0
+		// We can verify this by setting another time and checking behavior
+		rb.set(time.Now().Add(time.Hour))
+
+		// Now cursor should be at position 1, so val() should return the time at position 1
+		// But since we only set 2 times, position 1 should still be the zero time
+		zeroTime := time.Time{}
+		assert.Equal(t, zeroTime, rb.val())
+	})
+
+	t.Run("set() should handle multiple insertions correctly", func(t *testing.T) {
+		rb := &ringBuffer{}
+
+		// Insert 3 times
+		time1 := time.Now()
+		time2 := time.Now().Add(time.Hour)
+		time3 := time.Now().Add(2 * time.Hour)
+
+		rb.set(time1)
+		rb.set(time2)
+		rb.set(time3)
+
+		// After 3 insertions, cursor should be at position 0
+		// So val() should return the time at position 0
+		// This should be time1 since it was the first time set
+		assert.Equal(t, time1, rb.val())
+	})
+
+	t.Run("set() should overwrite existing values in order", func(t *testing.T) {
+		rb := &ringBuffer{}
+
+		// Set 3 times to fill the buffer
+		rb.set(time.Now())
+		rb.set(time.Now().Add(time.Hour))
+		rb.set(time.Now().Add(2 * time.Hour))
+
+		// After 3 sets, cursor is at position 0, val() returns first time
+		firstTime := rb.val()
+		assert.False(t, firstTime.IsZero())
+
+		// Set a 4th time - should overwrite position 0
+		fourthTime := time.Now().Add(3 * time.Hour)
+		rb.set(fourthTime)
+
+		// Now cursor is at position 1, val() returns second time
+		secondTime := rb.val()
+		assert.False(t, secondTime.IsZero())
+
+		// Set a 5th time - should overwrite position 1
+		fifthTime := time.Now().Add(4 * time.Hour)
+		rb.set(fifthTime)
+
+		// Now cursor is at position 2, val() returns third time
+		thirdTime := rb.val()
+		assert.False(t, thirdTime.IsZero())
+
+		// Set a 6th time - should overwrite position 2
+		sixthTime := time.Now().Add(5 * time.Hour)
+		rb.set(sixthTime)
+
+		// Now cursor wraps around to position 0, val() returns fourth time
+		assert.Equal(t, fourthTime, rb.val())
+	})
+
+	t.Run("val() should return correct time after multiple operations", func(t *testing.T) {
+		rb := &ringBuffer{}
+
+		// Insert times and verify val() returns correct current time
+		time1 := time.Now()
+		time2 := time.Now().Add(time.Hour)
+
+		rb.set(time1)
+		rb.set(time2)
+
+		// After 2 sets, cursor is at position 2
+		// So val() should return the time at position 2
+		// But since we only set 2 times, position 2 should still be the zero time
+		zeroTime := time.Time{}
+		assert.Equal(t, zeroTime, rb.val())
+
+		// Set one more to wrap around
+		time3 := time.Now().Add(2 * time.Hour)
+		rb.set(time3)
+
+		// Now cursor is at position 0, so val() should return the time at position 0
+		// This should be time1
+		assert.Equal(t, time1, rb.val())
+	})
+
+	t.Run("ringBuffer should maintain circular behavior over many operations", func(t *testing.T) {
+		rb := &ringBuffer{}
+
+		// Perform many operations to test circular behavior
+		for i := 0; i < 10; i++ {
+			rb.set(time.Now().Add(time.Duration(i) * time.Hour))
+		}
+
+		// After 10 operations, cursor should be at position 1 (10 % 3 = 1)
+		// So val() should return the time at position 1
+		// This should be the 8th time set (at position 1)
+		// We can't compare exact times since they're set in a loop, so just verify it's not zero
+		assert.False(t, rb.val().IsZero())
+
+		// Set one more to advance cursor to position 2
+		rb.set(time.Now().Add(10 * time.Hour))
+
+		// Now cursor is at position 2, so val() should return the time at position 2
+		// This should be the 9th time set (at position 2)
+		assert.False(t, rb.val().IsZero())
+
+		// Set one more to wrap around to position 0
+		rb.set(time.Now().Add(11 * time.Hour))
+
+		// Now cursor is at position 0, so val() should return the time at position 0
+		// This should be the 10th time set (at position 0)
+		assert.False(t, rb.val().IsZero())
+	})
+
+	t.Run("ringBuffer should cycle through all positions correctly", func(t *testing.T) {
+		rb := &ringBuffer{}
+
+		// Test cycling through all 3 positions
+		times := []time.Time{
+			time.Now(),
+			time.Now().Add(time.Hour),
+			time.Now().Add(2 * time.Hour),
+		}
+
+		// Set all 3 times
+		for _, t := range times {
+			rb.set(t)
+		}
+
+		// After 3 sets, cursor should be at position 0
+		// So val() should return the time at position 0
+		assert.Equal(t, times[0], rb.val())
+
+		// Set one more to advance cursor to position 1
+		nextTime := time.Now().Add(3 * time.Hour)
+		rb.set(nextTime)
+
+		// Now cursor is at position 1, so val() should return the time at position 1
+		// This should be the second time since it was stored at position 1
+		assert.Equal(t, times[1], rb.val())
+	})
+}
+
+func TestConvoTracker(t *testing.T) {
+	ct := newConvoTracker()
+	sender := state.NewIdentScreenName("sender")
+	recip := state.NewIdentScreenName("recipient")
+	now := time.Now()
+
+	// can't warn until a message is sent
+	assert.False(t, ct.trackWarn(now, recip, sender))
+
+	// can warn 1st time
+	ct.trackConvo(now, sender, recip)
+	assert.True(t, ct.trackWarn(now, recip, sender))
+
+	// can't warn 2nd time until 2nd message is sent
+	assert.False(t, ct.trackWarn(now, recip, sender))
+
+	// can warn 2nd time
+	now = now.Add(1 * time.Second)
+	ct.trackConvo(now, sender, recip)
+	assert.True(t, ct.trackWarn(now, recip, sender))
+
+	// can't warn 3rd time until 3rd message is sent
+	assert.False(t, ct.trackWarn(now, recip, sender))
+
+	// can warn 3rd time
+	now = now.Add(1 * time.Second)
+	ct.trackConvo(now, sender, recip)
+	assert.True(t, ct.trackWarn(now, recip, sender))
+
+	// can't warn 4th time
+	now = now.Add(1 * time.Second)
+	ct.trackConvo(now, sender, recip)
+	assert.False(t, ct.trackWarn(now, recip, sender))
+
+	// let an hour pass, we should be able to warn again
+	now = now.Add(1 * time.Hour)
+	ct.trackConvo(now, sender, recip)
+	assert.True(t, ct.trackWarn(now, recip, sender))
+}
+
+func TestICBMService_DecayWarnLevel(t *testing.T) {
+
+	t.Run("happy path", 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)
+
+		svc := ICBMService{
+			buddyBroadcaster: mockBuddyBroadcaster,
+			logger:           slog.Default(),
+			interval:         1 * time.Millisecond,
+			snacRateLimits:   wire.DefaultSNACRateLimits(),
+		}
+
+		ctx, cancel := context.WithCancel(context.Background())
+		defer cancel()
+
+		var wg sync.WaitGroup
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			svc.DecayWarnLevel(ctx, sess)
+		}()
+
+		sess.IncrementWarning(100, 3)
+		sess.NotifyWarning(ctx)
+
+		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)
+
+		assert.Equal(t, uint16(50), <-warnCh)
+		assert.Equal(t, uint16(0), <-warnCh)
+
+		cancel()
+		wg.Wait()
+	})
+
+	t.Run("3% burn down clamps to 0", 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)
+
+		svc := ICBMService{
+			buddyBroadcaster: mockBuddyBroadcaster,
+			logger:           slog.Default(),
+			interval:         1 * time.Millisecond,
+			snacRateLimits:   wire.DefaultSNACRateLimits(),
+		}
+
+		ctx, cancel := context.WithCancel(context.Background())
+		defer cancel()
+
+		var wg sync.WaitGroup
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			svc.DecayWarnLevel(ctx, sess)
+		}()
+
+		sess.IncrementWarning(30, 3)
+		sess.NotifyWarning(ctx)
+
+		assert.Equal(t, uint16(0), <-warnCh)
+
+		cancel()
+		wg.Wait()
+	})
+}

+ 47 - 0
server/oscar/mock_departure_notifier_test.go

@@ -22,6 +22,53 @@ 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)
+
+	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)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockDepartureNotifier_BroadcastBuddyArrived_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BroadcastBuddyArrived'
+type mockDepartureNotifier_BroadcastBuddyArrived_Call struct {
+	*mock.Call
+}
+
+// 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)}
+}
+
+func (_c *mockDepartureNotifier_BroadcastBuddyArrived_Call) Run(run func(ctx context.Context, sess *state.Session)) *mockDepartureNotifier_BroadcastBuddyArrived_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session))
+	})
+	return _c
+}
+
+func (_c *mockDepartureNotifier_BroadcastBuddyArrived_Call) Return(_a0 error) *mockDepartureNotifier_BroadcastBuddyArrived_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockDepartureNotifier_BroadcastBuddyArrived_Call) RunAndReturn(run func(context.Context, *state.Session) error) *mockDepartureNotifier_BroadcastBuddyArrived_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // BroadcastBuddyDeparted provides a mock function with given fields: ctx, sess
 func (_m *mockDepartureNotifier) BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error {
 	ret := _m.Called(ctx, sess)

+ 34 - 0
server/oscar/mock_icbm_service_test.go

@@ -183,6 +183,40 @@ 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)

+ 4 - 0
server/oscar/server.go

@@ -34,6 +34,7 @@ func NewServer(
 	limits wire.SNACRateLimits,
 	limiter *IPRateLimiter,
 	listenerCfg []config.Listener,
+	lowerWarnLevel func(ctx context.Context, sess *state.Session),
 ) *Server {
 	oscarSvc := oscarServer{
 		AuthService:        authService,
@@ -46,6 +47,7 @@ func NewServer(
 		RateLimitUpdater:   rateLimitUpdater,
 		SNACRateLimits:     limits,
 		IPRateLimiter:      limiter,
+		lowerWarnLevel:     lowerWarnLevel,
 	}
 
 	ctx, cancel := context.WithCancel(context.Background())
@@ -188,6 +190,7 @@ type oscarServer struct {
 	RateLimitUpdater
 	wire.SNACRateLimits
 	*IPRateLimiter
+	lowerWarnLevel func(ctx context.Context, sess *state.Session)
 }
 
 func (s oscarServer) routeConnection(ctx context.Context, conn net.Conn, listener config.Listener) error {
@@ -273,6 +276,7 @@ func (s oscarServer) connectToOSCARService(
 			sess.SetRemoteAddr(&ip)
 		}
 
+		go s.lowerWarnLevel(ctx, sess)
 		go s.receiveSessMessages(ctx, sess, flapc)
 
 	case wire.Chat:

+ 10 - 4
server/oscar/server_test.go

@@ -12,12 +12,13 @@ import (
 	"testing"
 	"time"
 
-	"github.com/mk6i/retro-aim-server/config"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
 	"golang.org/x/time/rate"
+
+	"github.com/mk6i/retro-aim-server/config"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
 )
 
 func TestServer_ListenAndServeAndShutdown(t *testing.T) {
@@ -54,6 +55,7 @@ func TestServer_ListenAndServeAndShutdown(t *testing.T) {
 		wire.DefaultSNACRateLimits(),
 		nil,
 		cfg,
+		func(ctx context.Context, sess *state.Session) {},
 	)
 
 	server.handler = func(ctx context.Context, conn net.Conn, listener config.Listener) error {
@@ -403,7 +405,7 @@ func TestOscarServer_RouteConnection_BOS(t *testing.T) {
 	chatSessionManager.EXPECT().
 		RemoveUserFromAllChats(mock.Anything)
 
-	wg.Add(1)
+	wg.Add(2)
 	handler := func(ctx context.Context, serverType uint16, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, listener config.Listener) error {
 		defer wg.Done()
 		return nil
@@ -417,6 +419,9 @@ func TestOscarServer_RouteConnection_BOS(t *testing.T) {
 		BuddyListRegistry:  buddyListRegistry,
 		ChatSessionManager: chatSessionManager,
 		DepartureNotifier:  departureNotifier,
+		lowerWarnLevel: func(ctx context.Context, sess *state.Session) {
+			defer wg.Done()
+		},
 	}
 	assert.NoError(t, rt.routeConnection(context.Background(), clientFake, config.Listener{}))
 
@@ -755,6 +760,7 @@ func Test_oscarServer_receiveSessMessages_BOS_integration(t *testing.T) {
 		DepartureNotifier:  departureNotifier,
 		OnlineNotifier:     onlineNotifier,
 		Logger:             slog.New(slog.NewTextHandler(io.Discard, nil)),
+		lowerWarnLevel:     func(ctx context.Context, sess *state.Session) {},
 	}
 
 	// Fake client connection with address

+ 2 - 0
server/oscar/types.go

@@ -30,6 +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
 	BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error
 }
 
@@ -102,6 +103,7 @@ 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)
 }
 
 type ICQService interface {

+ 16 - 1
server/toc/server.go

@@ -122,7 +122,14 @@ func (l *IPRateLimiter) Allow(ip string) (allowed bool) {
 	return limiter.(*rate.Limiter).Allow()
 }
 
-func NewServer(listenerCfg []string, logger *slog.Logger, BOSProxy OSCARProxy, ipRateLimiter *IPRateLimiter) *Server {
+func NewServer(
+	listenerCfg []string,
+	logger *slog.Logger,
+	BOSProxy OSCARProxy,
+	ipRateLimiter *IPRateLimiter,
+	lowerWarnLevel func(ctx context.Context, sess *state.Session),
+) *Server {
+
 	ctx, cancel := context.WithCancel(context.Background())
 
 	s := &Server{
@@ -131,6 +138,7 @@ func NewServer(listenerCfg []string, logger *slog.Logger, BOSProxy OSCARProxy, i
 		listenerCfg:        listenerCfg,
 		logger:             logger,
 		loginIPRateLimiter: ipRateLimiter,
+		lowerWarnLevel:     lowerWarnLevel,
 		servers:            make([]*http.Server, 0, len(listenerCfg)),
 		shutdownCancel:     cancel,
 		shutdownCtx:        ctx,
@@ -155,6 +163,7 @@ type Server struct {
 	bosProxy           OSCARProxy
 	logger             *slog.Logger
 	loginIPRateLimiter *IPRateLimiter
+	lowerWarnLevel     func(ctx context.Context, sess *state.Session)
 
 	listenerCfg []string
 	listeners   []net.Listener
@@ -412,6 +421,12 @@ func (s *Server) handleTOCRequest(
 		return errors.Join(err, errServerWrite)
 	})
 
+	// process warning limits
+	g.Go(func() error {
+		s.lowerWarnLevel(ctx, sessBOS)
+		return nil
+	})
+
 	return g.Wait()
 }
 

+ 16 - 10
server/toc/server_test.go

@@ -8,6 +8,7 @@ import (
 	"sync"
 	"testing"
 
+	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
 
 	"github.com/stretchr/testify/assert"
@@ -23,8 +24,9 @@ func TestServer_handleTOCRequest_serverShutdown(t *testing.T) {
 	go func() {
 		defer wg.Done()
 		sv := Server{
-			bosProxy: testOSCARProxy(t),
-			logger:   slog.Default(),
+			bosProxy:       testOSCARProxy(t),
+			logger:         slog.Default(),
+			lowerWarnLevel: func(ctx context.Context, sess *state.Session) {},
 		}
 
 		serverReader, _ := io.Pipe()
@@ -61,8 +63,9 @@ func TestServer_handleTOCRequest_clientReadDisconnect(t *testing.T) {
 		fc := wire.NewFlapClient(0, serverReader, nil)
 
 		sv := Server{
-			bosProxy: testOSCARProxy(t),
-			logger:   slog.Default(),
+			bosProxy:       testOSCARProxy(t),
+			logger:         slog.Default(),
+			lowerWarnLevel: func(ctx context.Context, sess *state.Session) {},
 		}
 		err := sv.handleTOCRequest(context.Background(), closeConn, sess, NewChatRegistry(), fc)
 		assert.ErrorIs(t, err, errClientReq)
@@ -94,8 +97,9 @@ func TestServer_handleTOCRequest_sessClose(t *testing.T) {
 		}
 
 		sv := Server{
-			bosProxy: testOSCARProxy(t),
-			logger:   slog.Default(),
+			bosProxy:       testOSCARProxy(t),
+			logger:         slog.Default(),
+			lowerWarnLevel: func(ctx context.Context, sess *state.Session) {},
 		}
 		err := sv.handleTOCRequest(context.Background(), closeConn, sess, NewChatRegistry(), fc)
 		assert.ErrorIs(t, err, errTOCProcessing)
@@ -126,8 +130,9 @@ func TestServer_handleTOCRequest_replyFailure(t *testing.T) {
 		fc := wire.NewFlapClient(0, serverReader, serverWriter)
 
 		sv := Server{
-			bosProxy: testOSCARProxy(t),
-			logger:   slog.Default(),
+			bosProxy:       testOSCARProxy(t),
+			logger:         slog.Default(),
+			lowerWarnLevel: func(ctx context.Context, sess *state.Session) {},
 		}
 		err := sv.handleTOCRequest(context.Background(), closeConn, sess, NewChatRegistry(), fc)
 		assert.ErrorIs(t, err, errServerWrite)
@@ -163,8 +168,9 @@ func TestServer_handleTOCRequest_happyPath(t *testing.T) {
 		}
 		fc := wire.NewFlapClient(0, serverReader, serverWriter)
 		sv := Server{
-			bosProxy: testOSCARProxy(t),
-			logger:   slog.Default(),
+			bosProxy:       testOSCARProxy(t),
+			logger:         slog.Default(),
+			lowerWarnLevel: func(ctx context.Context, sess *state.Session) {},
 		}
 		err := sv.handleTOCRequest(context.Background(), closeConn, newTestSession("me"), NewChatRegistry(), fc)
 		assert.ErrorIs(t, err, errClientReq)

+ 100 - 38
state/session.go

@@ -1,6 +1,7 @@
 package state
 
 import (
+	"context"
 	"net/netip"
 	"sync"
 	"time"
@@ -49,31 +50,33 @@ const (
 // Session represents a user's current session. Unless stated otherwise, all
 // methods may be safely accessed by multiple goroutines.
 type Session struct {
-	awayMessage         string
-	caps                [][16]byte
-	chatRoomCookie      string
-	closed              bool
-	displayScreenName   DisplayScreenName
-	identScreenName     IdentScreenName
-	idle                bool
-	idleTime            time.Time
-	msgCh               chan wire.SNACMessage
-	mutex               sync.RWMutex
-	nowFn               func() time.Time
-	signonComplete      bool
-	signonTime          time.Time
-	stopCh              chan struct{}
-	uin                 uint32
-	warning             uint16
-	userInfoBitmask     uint16
-	userStatusBitmask   uint32
-	clientID            string
-	remoteAddr          *netip.AddrPort
-	lastObservedStates  [5]RateClassState
-	rateByClassID       [5]RateClassState
-	foodGroupVersions   [wire.MDir + 1]uint16
-	typingEventsEnabled bool
-	multiConnFlag       wire.MultiConnFlag
+	awayMessage           string
+	caps                  [][16]byte
+	chatRoomCookie        string
+	clientID              string
+	closed                bool
+	displayScreenName     DisplayScreenName
+	foodGroupVersions     [wire.MDir + 1]uint16
+	identScreenName       IdentScreenName
+	idle                  bool
+	idleTime              time.Time
+	lastObservedStates    [5]RateClassState
+	msgCh                 chan wire.SNACMessage
+	multiConnFlag         wire.MultiConnFlag
+	mutex                 sync.RWMutex
+	nowFn                 func() time.Time
+	rateByClassID         [5]RateClassState
+	rateByClassIDOriginal [5]RateClassState
+	remoteAddr            *netip.AddrPort
+	signonComplete        bool
+	signonTime            time.Time
+	stopCh                chan struct{}
+	typingEventsEnabled   bool
+	uin                   uint32
+	userInfoBitmask       uint16
+	userStatusBitmask     uint32
+	warning               uint16
+	warningCh             chan struct{}
 }
 
 // NewSession returns a new instance of Session. By default, the user may have
@@ -116,6 +119,7 @@ func NewSession() *Session {
 			vals[wire.MDir] = 1
 			return vals
 		}(),
+		warningCh: make(chan struct{}),
 	}
 }
 
@@ -141,6 +145,7 @@ func (s *Session) SetRateClasses(now time.Time, classes wire.RateLimitClasses) {
 	}
 
 	s.rateByClassID = newStates
+	s.rateByClassIDOriginal = newStates
 }
 
 // SetRemoteAddr sets the user's remote IP address
@@ -187,12 +192,77 @@ func (s *Session) SetUserStatusBitmask(bitmask uint32) {
 	s.userStatusBitmask = bitmask
 }
 
-// IncrementWarning increments the user's warning level. To decrease, pass a
-// negative increment value.
-func (s *Session) IncrementWarning(incr uint16) {
+// IncrementWarning increments the user's warning level and scales rate limits accordingly.
+// 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 {
 	s.mutex.Lock()
 	defer s.mutex.Unlock()
-	s.warning += incr
+
+	// Handle warning level increment
+	newWarning := int32(s.warning) + int32(incr)
+	if newWarning > 1000 {
+		return false
+	}
+	if newWarning < 0 {
+		s.warning = 0 // clamp min at 0
+	} else {
+		s.warning = uint16(newWarning)
+	}
+
+	pct := float32(incr) / 1000.0
+
+	// create reference variables for better readability
+	rateClass := &s.rateByClassID[classID-1]
+	originalRateClass := &s.rateByClassIDOriginal[classID-1]
+
+	// clamp function to constrain values between min and max
+	clamp := func(value, min, max int32) int32 {
+		if value < min {
+			return min
+		}
+		if value > max {
+			return max
+		}
+		return value
+	}
+
+	// scale the rate limit parameters
+	newLimitLevel := rateClass.LimitLevel + int32(float32(originalRateClass.MaxLevel-originalRateClass.LimitLevel)*pct)
+	rateClass.LimitLevel = clamp(newLimitLevel, originalRateClass.LimitLevel, originalRateClass.MaxLevel)
+
+	newLimitLevel = rateClass.ClearLevel + int32(float32(originalRateClass.MaxLevel-originalRateClass.ClearLevel)*pct)
+	rateClass.ClearLevel = clamp(newLimitLevel, originalRateClass.ClearLevel, originalRateClass.MaxLevel)
+
+	newLimitLevel = rateClass.AlertLevel + int32(float32(originalRateClass.MaxLevel-originalRateClass.AlertLevel)*pct)
+	rateClass.AlertLevel = clamp(newLimitLevel, originalRateClass.AlertLevel, originalRateClass.MaxLevel)
+
+	return true
+}
+
+// Warning returns the user's current warning level as a percentage.
+// The warning level is stored as an integer representation of a percentage
+// where 30 = 3.0%, 100 = 10.0%, 1000 = 100.0%, etc.
+// This is how the OSCAR protocol represents warning percentages.
+func (s *Session) Warning() uint16 {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	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{} {
+	return s.warningCh
 }
 
 // Invisible returns true if the user is idle.
@@ -337,7 +407,7 @@ func (s *Session) TLVUserInfo() wire.TLVUserInfo {
 	defer s.mutex.RUnlock()
 	return wire.TLVUserInfo{
 		ScreenName:   string(s.displayScreenName),
-		WarningLevel: s.warning,
+		WarningLevel: uint16(s.warning),
 		TLVBlock: wire.TLVBlock{
 			TLVList: s.userInfo(),
 		},
@@ -396,14 +466,6 @@ func (s *Session) Caps() [][16]byte {
 	return s.caps
 }
 
-func (s *Session) Warning() uint16 {
-	s.mutex.RLock()
-	defer s.mutex.RUnlock()
-	var w uint16
-	w = s.warning
-	return w
-}
-
 // ReceiveMessage returns a channel of messages relayed via this session. It
 // may only be read by one consumer. The channel never closes; call this method
 // in a select block along with Closed in order to detect session closure.

+ 175 - 3
state/session_test.go

@@ -24,8 +24,8 @@ func TestSession_SetAndGetAwayMessage(t *testing.T) {
 func TestSession_IncrementAndGetWarning(t *testing.T) {
 	s := NewSession()
 	assert.Zero(t, s.Warning())
-	s.IncrementWarning(1)
-	s.IncrementWarning(2)
+	s.IncrementWarning(1, 1)
+	s.IncrementWarning(2, 1)
 	assert.Equal(t, uint16(3), s.Warning())
 }
 
@@ -89,7 +89,7 @@ func TestSession_TLVUserInfo(t *testing.T) {
 				s.SetSignonTime(time.Unix(1, 0))
 				s.SetIdentScreenName(NewIdentScreenName("xXAIMUSERXx"))
 				s.SetDisplayScreenName("xXAIMUSERXx")
-				s.IncrementWarning(10)
+				s.IncrementWarning(10, 1)
 				s.SetUserInfoFlag(wire.OServiceUserFlagOSCARFree)
 				return s
 			},
@@ -622,3 +622,175 @@ func TestSession_SetAndGetMultiConnFlag(t *testing.T) {
 	s.SetMultiConnFlag(wire.MultiConnFlagsSingleClient)
 	assert.Equal(t, wire.MultiConnFlagsSingleClient, s.MultiConnFlag())
 }
+
+func TestSession_IncrementWarningWithRateLimitScaling(t *testing.T) {
+	t.Run("scale up", func(t *testing.T) {
+		classParams := [5]wire.RateClass{
+			{},
+			{},
+			{
+				ID:              3,
+				WindowSize:      20,
+				ClearLevel:      5100,
+				AlertLevel:      5000,
+				LimitLevel:      4000,
+				DisconnectLevel: 3000,
+				MaxLevel:        6000,
+			},
+			{},
+			{},
+		}
+		rateClasses := wire.NewRateLimitClasses(classParams)
+
+		now := time.Now()
+
+		sess := NewSession()
+		sess.SetRateClasses(now, rateClasses)
+
+		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)
+
+		sess.IncrementWarning(100, 3)
+		assert.Equal(t, int32(5100), sess.rateByClassID[2].AlertLevel)
+		assert.Equal(t, int32(5190), sess.rateByClassID[2].ClearLevel)
+		assert.Equal(t, int32(4200), sess.rateByClassID[2].LimitLevel)
+
+		sess.IncrementWarning(100, 3)
+		assert.Equal(t, int32(5200), sess.rateByClassID[2].AlertLevel)
+		assert.Equal(t, int32(5280), sess.rateByClassID[2].ClearLevel)
+		assert.Equal(t, int32(4400), sess.rateByClassID[2].LimitLevel)
+
+		sess.IncrementWarning(100, 3)
+		assert.Equal(t, int32(5300), sess.rateByClassID[2].AlertLevel)
+		assert.Equal(t, int32(5370), sess.rateByClassID[2].ClearLevel)
+		assert.Equal(t, int32(4600), sess.rateByClassID[2].LimitLevel)
+
+		sess.IncrementWarning(100, 3)
+		assert.Equal(t, int32(5400), sess.rateByClassID[2].AlertLevel)
+		assert.Equal(t, int32(5460), sess.rateByClassID[2].ClearLevel)
+		assert.Equal(t, int32(4800), sess.rateByClassID[2].LimitLevel)
+
+		sess.IncrementWarning(100, 3)
+		assert.Equal(t, int32(5500), sess.rateByClassID[2].AlertLevel)
+		assert.Equal(t, int32(5550), sess.rateByClassID[2].ClearLevel)
+		assert.Equal(t, int32(5000), sess.rateByClassID[2].LimitLevel)
+
+		sess.IncrementWarning(100, 3)
+		assert.Equal(t, int32(5600), sess.rateByClassID[2].AlertLevel)
+		assert.Equal(t, int32(5640), sess.rateByClassID[2].ClearLevel)
+		assert.Equal(t, int32(5200), sess.rateByClassID[2].LimitLevel)
+
+		sess.IncrementWarning(100, 3)
+		assert.Equal(t, int32(5700), sess.rateByClassID[2].AlertLevel)
+		assert.Equal(t, int32(5730), sess.rateByClassID[2].ClearLevel)
+		assert.Equal(t, int32(5400), sess.rateByClassID[2].LimitLevel)
+
+		sess.IncrementWarning(100, 3)
+		assert.Equal(t, int32(5800), sess.rateByClassID[2].AlertLevel)
+		assert.Equal(t, int32(5820), sess.rateByClassID[2].ClearLevel)
+		assert.Equal(t, int32(5600), sess.rateByClassID[2].LimitLevel)
+
+		sess.IncrementWarning(100, 3)
+		assert.Equal(t, int32(5900), sess.rateByClassID[2].AlertLevel)
+		assert.Equal(t, int32(5910), sess.rateByClassID[2].ClearLevel)
+		assert.Equal(t, int32(5800), sess.rateByClassID[2].LimitLevel)
+
+		sess.IncrementWarning(100, 3)
+		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)
+
+		sess.IncrementWarning(100, 3)
+		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)
+	})
+
+	t.Run("scale down", func(t *testing.T) {
+		currentClassParams := [5]wire.RateClass{
+			{},
+			{},
+			{
+				ID:              3,
+				WindowSize:      20,
+				ClearLevel:      5100,
+				AlertLevel:      5000,
+				LimitLevel:      4000,
+				DisconnectLevel: 3000,
+				MaxLevel:        6000,
+			},
+			{},
+			{},
+		}
+		rateClasses := wire.NewRateLimitClasses(currentClassParams)
+
+		now := time.Now()
+
+		sess := NewSession()
+		sess.SetRateClasses(now, rateClasses)
+
+		for i := 0; i < 10; i++ {
+			sess.IncrementWarning(100, 3)
+		}
+
+		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)
+
+		sess.IncrementWarning(-100, 3)
+		assert.Equal(t, int32(5900), sess.rateByClassID[2].AlertLevel)
+		assert.Equal(t, int32(5910), sess.rateByClassID[2].ClearLevel)
+		assert.Equal(t, int32(5800), sess.rateByClassID[2].LimitLevel)
+
+		sess.IncrementWarning(-100, 3)
+		assert.Equal(t, int32(5800), sess.rateByClassID[2].AlertLevel)
+		assert.Equal(t, int32(5820), sess.rateByClassID[2].ClearLevel)
+		assert.Equal(t, int32(5600), sess.rateByClassID[2].LimitLevel)
+
+		sess.IncrementWarning(-100, 3)
+		assert.Equal(t, int32(5700), sess.rateByClassID[2].AlertLevel)
+		assert.Equal(t, int32(5730), sess.rateByClassID[2].ClearLevel)
+		assert.Equal(t, int32(5400), sess.rateByClassID[2].LimitLevel)
+
+		sess.IncrementWarning(-100, 3)
+		assert.Equal(t, int32(5600), sess.rateByClassID[2].AlertLevel)
+		assert.Equal(t, int32(5640), sess.rateByClassID[2].ClearLevel)
+		assert.Equal(t, int32(5200), sess.rateByClassID[2].LimitLevel)
+
+		sess.IncrementWarning(-100, 3)
+		assert.Equal(t, int32(5500), sess.rateByClassID[2].AlertLevel)
+		assert.Equal(t, int32(5550), sess.rateByClassID[2].ClearLevel)
+		assert.Equal(t, int32(5000), sess.rateByClassID[2].LimitLevel)
+
+		sess.IncrementWarning(-100, 3)
+		assert.Equal(t, int32(5400), sess.rateByClassID[2].AlertLevel)
+		assert.Equal(t, int32(5460), sess.rateByClassID[2].ClearLevel)
+		assert.Equal(t, int32(4800), sess.rateByClassID[2].LimitLevel)
+
+		sess.IncrementWarning(-100, 3)
+		assert.Equal(t, int32(5300), sess.rateByClassID[2].AlertLevel)
+		assert.Equal(t, int32(5370), sess.rateByClassID[2].ClearLevel)
+		assert.Equal(t, int32(4600), sess.rateByClassID[2].LimitLevel)
+
+		sess.IncrementWarning(-100, 3)
+		assert.Equal(t, int32(5200), sess.rateByClassID[2].AlertLevel)
+		assert.Equal(t, int32(5280), sess.rateByClassID[2].ClearLevel)
+		assert.Equal(t, int32(4400), sess.rateByClassID[2].LimitLevel)
+
+		sess.IncrementWarning(-100, 3)
+		assert.Equal(t, int32(5100), sess.rateByClassID[2].AlertLevel)
+		assert.Equal(t, int32(5190), sess.rateByClassID[2].ClearLevel)
+		assert.Equal(t, int32(4200), sess.rateByClassID[2].LimitLevel)
+
+		sess.IncrementWarning(-100, 3)
+		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)
+
+		sess.IncrementWarning(-100, 3)
+		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)
+	})
+}