Переглянути джерело

implement STATS foodgroup

Implements a no-op stats foodgroup handler that fixes AIM 1.0
crash that happens when the server shuts down and client attempts
to send a stats SNAC.
Mike 1 рік тому
батько
коміт
20ba37eec2

+ 3 - 0
.mockery.yaml

@@ -102,6 +102,9 @@ packages:
       PermitDenyService:
       PermitDenyService:
         config:
         config:
           filename: "mock_permit_deny_service_test.go"
           filename: "mock_permit_deny_service_test.go"
+      StatsService:
+        config:
+          filename: "mock_stats_service_test.go"
       UserLookupService:
       UserLookupService:
         config:
         config:
           filename: "mock_user_lookup_service_test.go"
           filename: "mock_user_lookup_service_test.go"

+ 2 - 0
cmd/server/factory.go

@@ -305,6 +305,7 @@ func BOS(deps Container) oscar.BOSServer {
 		deps.snacRateLimits,
 		deps.snacRateLimits,
 	)
 	)
 	userLookupService := foodgroup.NewUserLookupService(deps.sqLiteUserStore)
 	userLookupService := foodgroup.NewUserLookupService(deps.sqLiteUserStore)
+	statsService := foodgroup.NewStatsService()
 
 
 	return oscar.BOSServer{
 	return oscar.BOSServer{
 		AuthService:        authService,
 		AuthService:        authService,
@@ -323,6 +324,7 @@ func BOS(deps Container) oscar.BOSServer {
 			LocateHandler:     handler.NewLocateHandler(locateService, logger),
 			LocateHandler:     handler.NewLocateHandler(locateService, logger),
 			OServiceHandler:   handler.NewOServiceHandler(logger, oServiceService),
 			OServiceHandler:   handler.NewOServiceHandler(logger, oServiceService),
 			PermitDenyHandler: handler.NewPermitDenyHandler(logger, permitDenyService),
 			PermitDenyHandler: handler.NewPermitDenyHandler(logger, permitDenyService),
+			StatsHandler:      handler.NewStatsHandler(logger, statsService),
 			UserLookupHandler: handler.NewUserLookupHandler(logger, userLookupService),
 			UserLookupHandler: handler.NewUserLookupHandler(logger, userLookupService),
 		}),
 		}),
 		Logger:           logger,
 		Logger:           logger,

+ 12 - 0
foodgroup/oservice.go

@@ -657,6 +657,18 @@ func (s OServiceServiceForBOS) ClientOnline(ctx context.Context, _ wire.SNAC_0x0
 		return fmt.Errorf("unable to send buddy arrival notification: %w", err)
 		return fmt.Errorf("unable to send buddy arrival notification: %w", err)
 	}
 	}
 
 
+	msg := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Stats,
+			SubGroup:  wire.StatsSetMinReportInterval,
+			RequestID: wire.ReqIDFromServer,
+		},
+		Body: wire.SNAC_0x0B_0x02_StatsSetMinReportInterval{
+			MinReportInterval: 1,
+		},
+	}
+	s.messageRelayer.RelayToScreenName(ctx, sess.IdentScreenName(), msg)
+
 	return nil
 	return nil
 }
 }
 
 

+ 25 - 3
foodgroup/oservice_test.go

@@ -1311,6 +1311,23 @@ func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
 						},
 						},
 					},
 					},
 				},
 				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("me"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Stats,
+									SubGroup:  wire.StatsSetMinReportInterval,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x0B_0x02_StatsSetMinReportInterval{
+									MinReportInterval: 1,
+								},
+							},
+						},
+					},
+				},
 			},
 			},
 			wantSess: newTestSession("me", sessOptCannedSignonTime, sessOptSignonComplete),
 			wantSess: newTestSession("me", sessOptCannedSignonTime, sessOptSignonComplete),
 		},
 		},
@@ -1320,13 +1337,18 @@ func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
 			buddyUpdateBroadcaster := newMockbuddyBroadcaster(t)
 			buddyUpdateBroadcaster := newMockbuddyBroadcaster(t)
 			for _, params := range tt.mockParams.broadcastVisibilityParams {
 			for _, params := range tt.mockParams.broadcastVisibilityParams {
 				buddyUpdateBroadcaster.EXPECT().
 				buddyUpdateBroadcaster.EXPECT().
-					BroadcastVisibility(mock.Anything, matchSession(params.from), params.filter, params.doSendDepartures).
+					BroadcastVisibility(matchContext(), matchSession(params.from), params.filter, params.doSendDepartures).
 					Return(params.err)
 					Return(params.err)
 			}
 			}
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tt.mockParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().
+					RelayToScreenName(matchContext(), params.screenName, params.message)
+			}
 
 
-			svc := NewOServiceServiceForBOS(config.Config{}, nil, slog.Default(), nil, nil, nil, nil, nil, wire.DefaultRateLimitClasses(), wire.DefaultSNACRateLimits())
+			svc := NewOServiceServiceForBOS(config.Config{}, messageRelayer, slog.Default(), nil, nil, nil, nil, nil, wire.DefaultRateLimitClasses(), wire.DefaultSNACRateLimits())
 			svc.buddyBroadcaster = buddyUpdateBroadcaster
 			svc.buddyBroadcaster = buddyUpdateBroadcaster
-			haveErr := svc.ClientOnline(nil, tt.bodyIn, tt.sess)
+			haveErr := svc.ClientOnline(context.Background(), tt.bodyIn, tt.sess)
 			assert.ErrorIs(t, tt.wantErr, haveErr)
 			assert.ErrorIs(t, tt.wantErr, haveErr)
 			assert.Equal(t, tt.wantSess.SignonComplete(), tt.sess.SignonComplete())
 			assert.Equal(t, tt.wantSess.SignonComplete(), tt.sess.SignonComplete())
 		})
 		})

+ 28 - 0
foodgroup/stats.go

@@ -0,0 +1,28 @@
+package foodgroup
+
+import (
+	"context"
+
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+func NewStatsService() StatsService {
+	return StatsService{}
+}
+
+type StatsService struct {
+}
+
+// ReportEvents handles incoming stats events by acknowledging them without
+// processing. This is a no-op implementation to satisfy the client's
+// expectation of a response.
+func (s StatsService) ReportEvents(ctx context.Context, inFrame wire.SNACFrame, _ wire.SNAC_0x0B_0x03_StatsReportEvents) wire.SNACMessage {
+	return wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Stats,
+			SubGroup:  wire.StatsReportAck,
+			RequestID: inFrame.RequestID,
+		},
+		Body: wire.SNAC_0x0B_0x04_StatsReportAck{},
+	}
+}

+ 32 - 0
foodgroup/stats_test.go

@@ -0,0 +1,32 @@
+package foodgroup
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+func TestStatsService_ReportEvents(t *testing.T) {
+	svc := NewStatsService()
+
+	frame := wire.SNACFrame{
+		RequestID: 1234,
+	}
+	body := wire.SNAC_0x0B_0x03_StatsReportEvents{}
+
+	have := svc.ReportEvents(context.Background(), frame, body)
+
+	want := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Stats,
+			SubGroup:  wire.StatsReportAck,
+			RequestID: 1234,
+		},
+		Body: wire.SNAC_0x0B_0x04_StatsReportAck{},
+	}
+
+	assert.Equal(t, want, have)
+}

+ 0 - 1
server/oscar/handler/chat.go

@@ -6,7 +6,6 @@ import (
 	"log/slog"
 	"log/slog"
 
 
 	"github.com/mk6i/retro-aim-server/server/oscar"
 	"github.com/mk6i/retro-aim-server/server/oscar"
-
 	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
 	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
 	"github.com/mk6i/retro-aim-server/wire"

+ 85 - 0
server/oscar/handler/mock_stats_service_test.go

@@ -0,0 +1,85 @@
+// Code generated by mockery v2.53.3. DO NOT EDIT.
+
+package handler
+
+import (
+	context "context"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockStatsService is an autogenerated mock type for the StatsService type
+type mockStatsService struct {
+	mock.Mock
+}
+
+type mockStatsService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockStatsService) EXPECT() *mockStatsService_Expecter {
+	return &mockStatsService_Expecter{mock: &_m.Mock}
+}
+
+// ReportEvents provides a mock function with given fields: ctx, inFrame, inBody
+func (_m *mockStatsService) ReportEvents(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0B_0x03_StatsReportEvents) wire.SNACMessage {
+	ret := _m.Called(ctx, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ReportEvents")
+	}
+
+	var r0 wire.SNACMessage
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0B_0x03_StatsReportEvents) wire.SNACMessage); ok {
+		r0 = rf(ctx, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	return r0
+}
+
+// mockStatsService_ReportEvents_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReportEvents'
+type mockStatsService_ReportEvents_Call struct {
+	*mock.Call
+}
+
+// ReportEvents is a helper method to define mock.On call
+//   - ctx context.Context
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x0B_0x03_StatsReportEvents
+func (_e *mockStatsService_Expecter) ReportEvents(ctx interface{}, inFrame interface{}, inBody interface{}) *mockStatsService_ReportEvents_Call {
+	return &mockStatsService_ReportEvents_Call{Call: _e.mock.On("ReportEvents", ctx, inFrame, inBody)}
+}
+
+func (_c *mockStatsService_ReportEvents_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0B_0x03_StatsReportEvents)) *mockStatsService_ReportEvents_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNACFrame), args[2].(wire.SNAC_0x0B_0x03_StatsReportEvents))
+	})
+	return _c
+}
+
+func (_c *mockStatsService_ReportEvents_Call) Return(_a0 wire.SNACMessage) *mockStatsService_ReportEvents_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockStatsService_ReportEvents_Call) RunAndReturn(run func(context.Context, wire.SNACFrame, wire.SNAC_0x0B_0x03_StatsReportEvents) wire.SNACMessage) *mockStatsService_ReportEvents_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockStatsService creates a new instance of mockStatsService. 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 newMockStatsService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockStatsService {
+	mock := &mockStatsService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

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

@@ -23,6 +23,7 @@ type Handlers struct {
 	ODirHandler
 	ODirHandler
 	OServiceHandler
 	OServiceHandler
 	PermitDenyHandler
 	PermitDenyHandler
+	StatsHandler
 	UserLookupHandler
 	UserLookupHandler
 }
 }
 
 
@@ -92,6 +93,8 @@ func NewBOSRouter(h Handlers) oscar.Router {
 	router.Register(wire.PermitDeny, wire.PermitDenyRightsQuery, h.PermitDenyHandler.RightsQuery)
 	router.Register(wire.PermitDeny, wire.PermitDenyRightsQuery, h.PermitDenyHandler.RightsQuery)
 	router.Register(wire.PermitDeny, wire.PermitDenySetGroupPermitMask, h.PermitDenyHandler.SetGroupPermitMask)
 	router.Register(wire.PermitDeny, wire.PermitDenySetGroupPermitMask, h.PermitDenyHandler.SetGroupPermitMask)
 
 
+	router.Register(wire.Stats, wire.StatsReportEvents, h.StatsHandler.ReportEvents)
+
 	router.Register(wire.UserLookup, wire.UserLookupFindByEmail, h.UserLookupHandler.FindByEmail)
 	router.Register(wire.UserLookup, wire.UserLookupFindByEmail, h.UserLookupHandler.FindByEmail)
 
 
 	return router
 	return router

+ 42 - 0
server/oscar/handler/stats.go

@@ -0,0 +1,42 @@
+package handler
+
+import (
+	"context"
+	"io"
+	"log/slog"
+
+	"github.com/mk6i/retro-aim-server/server/oscar"
+	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+type StatsService interface {
+	ReportEvents(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0B_0x03_StatsReportEvents) wire.SNACMessage
+}
+
+func NewStatsHandler(logger *slog.Logger, statsService StatsService) StatsHandler {
+	return StatsHandler{
+		StatsService: statsService,
+		RouteLogger: middleware.RouteLogger{
+			Logger: logger,
+		},
+	}
+}
+
+type StatsHandler struct {
+	StatsService
+	middleware.RouteLogger
+}
+
+func (h StatsHandler) ReportEvents(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
+	inBody := wire.SNAC_0x0B_0x03_StatsReportEvents{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+
+	outSNAC := h.StatsService.ReportEvents(ctx, inFrame, inBody)
+	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}

+ 46 - 0
server/oscar/handler/stats_test.go

@@ -0,0 +1,46 @@
+package handler
+
+import (
+	"bytes"
+	"log/slog"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+func TestStatsHandler_ReportEvents(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Stats,
+			SubGroup:  wire.StatsReportEvents,
+		},
+		Body: wire.SNAC_0x0B_0x03_StatsReportEvents{},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Stats,
+			SubGroup:  wire.StatsReportAck,
+		},
+		Body: wire.SNAC_0x0B_0x04_StatsReportAck{},
+	}
+
+	svc := newMockStatsService(t)
+	svc.EXPECT().
+		ReportEvents(mock.Anything, input.Frame, input.Body).
+		Return(output)
+
+	h := NewStatsHandler(slog.Default(), svc)
+
+	ss := newMockResponseWriter(t)
+	ss.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+	assert.NoError(t, h.ReportEvents(nil, nil, input.Frame, buf, ss))
+}

+ 11 - 0
wire/snacs.go

@@ -1029,6 +1029,17 @@ const (
 	StatsReportAck            uint16 = 0x0004
 	StatsReportAck            uint16 = 0x0004
 )
 )
 
 
+type SNAC_0x0B_0x02_StatsSetMinReportInterval struct {
+	MinReportInterval uint16
+}
+
+type SNAC_0x0B_0x03_StatsReportEvents struct {
+	TLVRestBlock
+}
+
+type SNAC_0x0B_0x04_StatsReportAck struct {
+}
+
 //
 //
 // 0x0C: Translate
 // 0x0C: Translate
 //
 //

+ 6 - 0
wire/snacs_string.go

@@ -299,6 +299,12 @@ var subGroupName = map[uint16]map[uint16]string{
 		ODirKeywordListQuery: "ODirKeywordListQuery",
 		ODirKeywordListQuery: "ODirKeywordListQuery",
 		ODirKeywordListReply: "ODirKeywordListReply",
 		ODirKeywordListReply: "ODirKeywordListReply",
 	},
 	},
+	Stats: {
+		StatsErr:                  "StatsErr",
+		StatsSetMinReportInterval: "StatsSetMinReportInterval",
+		StatsReportEvents:         "StatsReportEvents",
+		StatsReportAck:            "StatsReportAck",
+	},
 }
 }
 
 
 // SubGroupName gets the string name of a subgroup within a food group. It
 // SubGroupName gets the string name of a subgroup within a food group. It