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

icqlegacy: hook into ICBM service for message passing

This makes request authorization work between legacy ICQ and feedbag
enabled ICQ.
Mike 2 месяцев назад
Родитель
Сommit
02f9f6da08

+ 3 - 0
.mockery.yaml

@@ -268,3 +268,6 @@ packages:
       LegacyService:
         config:
           filename: "mock_legacy_service_test.go"
+      ICBMService:
+        config:
+          filename: "mock_icbm_service_test.go"

+ 1 - 0
cmd/server/factory.go

@@ -659,6 +659,7 @@ func ICQLegacy(deps Container) *icq_legacy.LegacyServer {
 		deps.sqLiteUserStore, // relationshipFetcher
 		deps.sqLiteUserStore, // buddyListRegistry
 		deps.sqLiteUserStore, // clientSideBuddyListManager
+		deps.icbmSvc,
 		logger,
 	)
 

+ 10 - 5
foodgroup/icbm.go

@@ -141,11 +141,16 @@ func (s *ICBMService) ChannelMsgToHost(ctx context.Context, instance *state.Sess
 			if err = wire.UnmarshalLE(&authMsg, bytes.NewReader(b)); err != nil {
 				return nil, fmt.Errorf("failed to unmarshal ICBM authMsg: %w", err)
 			}
-			if recipSess.UsesFeedbag() {
-				return nil, s.forwardICQAuthEvents(ctx, instance.IdentScreenName(), recipSess.IdentScreenName(), authMsg)
-			} else if authMsg.MessageType == wire.ICBMMsgTypeAuthOK {
-				if err := s.contactPreAuthorizer.RecordPreAuth(ctx, instance.IdentScreenName(), recipSess.IdentScreenName()); err != nil {
-					return nil, fmt.Errorf("RecordPreAuth: %w", err)
+			if authMsg.MessageType == wire.ICBMMsgTypeAuthReq ||
+				authMsg.MessageType == wire.ICBMMsgTypeAuthDeny ||
+				authMsg.MessageType == wire.ICBMMsgTypeAuthOK ||
+				authMsg.MessageType == wire.ICBMMsgTypeAdded {
+				if recipSess.UsesFeedbag() {
+					return nil, s.forwardICQAuthEvents(ctx, instance.IdentScreenName(), recipSess.IdentScreenName(), authMsg)
+				} else if authMsg.MessageType == wire.ICBMMsgTypeAuthOK {
+					if err := s.contactPreAuthorizer.RecordPreAuth(ctx, instance.IdentScreenName(), recipSess.IdentScreenName()); err != nil {
+						return nil, fmt.Errorf("RecordPreAuth: %w", err)
+					}
 				}
 			}
 		}

+ 1 - 5
server/icq_legacy/handler.go

@@ -302,10 +302,6 @@ type LegacyService interface {
 	// Returns the newly assigned UIN on success.
 	RegisterNewUser(ctx context.Context, nickname, firstName, lastName, email, password string) (uint32, error)
 
-	// SendMessage sends a message from one user to another, routing to online
-	// users or storing for offline delivery.
-	SendMessage(ctx context.Context, fromUIN, toUIN uint32, msgType uint16, message string) error
-
 	// GetOfflineMessages retrieves stored offline messages for the given UIN.
 	GetOfflineMessages(ctx context.Context, uin uint32) ([]LegacyOfflineMessage, error)
 
@@ -324,7 +320,7 @@ type LegacyService interface {
 	// The method does NOT contain any protocol-specific packet building logic.
 	// Handlers are responsible for building protocol-specific responses based on
 	// the returned MessageResult.
-	ProcessMessage(ctx context.Context, req MessageRequest) (*MessageResult, error)
+	ProcessMessage(ctx context.Context, session *LegacySession, req MessageRequest) (*MessageResult, error)
 
 	// ProcessContactList processes a contact list and returns online status for each contact.
 	// This is the service layer method for contact list processing that handlers call after

+ 36 - 3
server/icq_legacy/helpers_test.go

@@ -3,6 +3,7 @@ package icq_legacy
 import (
 	"context"
 	"net"
+	"strconv"
 
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
@@ -22,6 +23,22 @@ type mockParams struct {
 	icqUserUpdaterParams
 	feedbagManagerParams
 	relationshipFetcherParams
+	icbmFoodgroupParams
+}
+
+// icbmFoodgroupParams is a helper struct that contains mock parameters for
+// ICBMService methods
+type icbmFoodgroupParams struct {
+	channelMsgToHostParams
+}
+
+// channelMsgToHostParams is the list of parameters passed at the mock
+// ICBMService.ChannelMsgToHost call site
+type channelMsgToHostParams []struct {
+	screenName state.IdentScreenName
+	inFrame    wire.SNACFrame
+	inBody     wire.SNAC_0x04_0x06_ICBMChannelMsgToHost
+	err        error
 }
 
 // userManagerParams is a helper struct that contains mock parameters for
@@ -319,14 +336,23 @@ type relationshipParams []struct {
 // newTestLegacySession creates a *LegacySession with configurable fields for
 // use in table-driven tests. Fields not set remain at their zero values.
 func newTestLegacySession(uin uint32, opts ...func(*LegacySession)) *LegacySession {
-	sess := &LegacySession{
+	legacySess := &LegacySession{
 		UIN:  uin,
 		Addr: &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 4000},
 	}
 	for _, opt := range opts {
-		opt(sess)
+		opt(legacySess)
 	}
-	return sess
+	return legacySess
+}
+
+// legacySessionOptAddr sets the UDP address on the legacy session.
+func legacySessionOptOSCARSess(s *LegacySession) {
+	oscarSess := state.NewSession()
+	oscarSess.SetUIN(s.UIN)
+	oscarSess.SetDisplayScreenName(state.DisplayScreenName(strconv.FormatUint(uint64(s.UIN), 10)))
+	oscarSess.SetIdentScreenName(oscarSess.DisplayScreenName().IdentScreenName())
+	s.Instance = oscarSess.AddInstance()
 }
 
 // legacySessionOptVersion sets the protocol version on the legacy session.
@@ -381,3 +407,10 @@ func matchContext() interface{} {
 		return ok
 	})
 }
+
+// matchSession matches a mock call based session ident screen name.
+func matchSession(mustMatch state.IdentScreenName) interface{} {
+	return mock.MatchedBy(func(s *state.SessionInstance) bool {
+		return mustMatch == s.IdentScreenName()
+	})
+}

+ 120 - 0
server/icq_legacy/mock_icbm_service_test.go

@@ -0,0 +1,120 @@
+// Code generated by mockery; DO NOT EDIT.
+// github.com/vektra/mockery
+// template: testify
+
+package icq_legacy
+
+import (
+	"context"
+
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// 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 {
+	mock.TestingT
+	Cleanup(func())
+}) *mockICBMService {
+	mock := &mockICBMService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}
+
+// mockICBMService is an autogenerated mock type for the ICBMService type
+type mockICBMService struct {
+	mock.Mock
+}
+
+type mockICBMService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockICBMService) EXPECT() *mockICBMService_Expecter {
+	return &mockICBMService_Expecter{mock: &_m.Mock}
+}
+
+// ChannelMsgToHost provides a mock function for the type mockICBMService
+func (_mock *mockICBMService) ChannelMsgToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, instance, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ChannelMsgToHost")
+	}
+
+	var r0 *wire.SNACMessage
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, instance, inFrame, inBody)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) *wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*wire.SNACMessage)
+		}
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) error); ok {
+		r1 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockICBMService_ChannelMsgToHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ChannelMsgToHost'
+type mockICBMService_ChannelMsgToHost_Call struct {
+	*mock.Call
+}
+
+// ChannelMsgToHost is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost
+func (_e *mockICBMService_Expecter) ChannelMsgToHost(ctx interface{}, instance interface{}, inFrame interface{}, inBody interface{}) *mockICBMService_ChannelMsgToHost_Call {
+	return &mockICBMService_ChannelMsgToHost_Call{Call: _e.mock.On("ChannelMsgToHost", ctx, instance, inFrame, inBody)}
+}
+
+func (_c *mockICBMService_ChannelMsgToHost_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost)) *mockICBMService_ChannelMsgToHost_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 wire.SNACFrame
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		var arg3 wire.SNAC_0x04_0x06_ICBMChannelMsgToHost
+		if args[3] != nil {
+			arg3 = args[3].(wire.SNAC_0x04_0x06_ICBMChannelMsgToHost)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+			arg3,
+		)
+	})
+	return _c
+}
+
+func (_c *mockICBMService_ChannelMsgToHost_Call) Return(sNACMessage *wire.SNACMessage, err error) *mockICBMService_ChannelMsgToHost_Call {
+	_c.Call.Return(sNACMessage, err)
+	return _c
+}
+
+func (_c *mockICBMService_ChannelMsgToHost_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)) *mockICBMService_ChannelMsgToHost_Call {
+	_c.Call.Return(run)
+	return _c
+}

+ 20 - 89
server/icq_legacy/mock_legacy_service_test.go

@@ -1083,8 +1083,8 @@ func (_c *mockLegacyService_ProcessContactList_Call) RunAndReturn(run func(ctx c
 }
 
 // ProcessMessage provides a mock function for the type mockLegacyService
-func (_mock *mockLegacyService) ProcessMessage(ctx context.Context, req MessageRequest) (*MessageResult, error) {
-	ret := _mock.Called(ctx, req)
+func (_mock *mockLegacyService) ProcessMessage(ctx context.Context, session *LegacySession, req MessageRequest) (*MessageResult, error) {
+	ret := _mock.Called(ctx, session, req)
 
 	if len(ret) == 0 {
 		panic("no return value specified for ProcessMessage")
@@ -1092,18 +1092,18 @@ func (_mock *mockLegacyService) ProcessMessage(ctx context.Context, req MessageR
 
 	var r0 *MessageResult
 	var r1 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, MessageRequest) (*MessageResult, error)); ok {
-		return returnFunc(ctx, req)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *LegacySession, MessageRequest) (*MessageResult, error)); ok {
+		return returnFunc(ctx, session, req)
 	}
-	if returnFunc, ok := ret.Get(0).(func(context.Context, MessageRequest) *MessageResult); ok {
-		r0 = returnFunc(ctx, req)
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *LegacySession, MessageRequest) *MessageResult); ok {
+		r0 = returnFunc(ctx, session, req)
 	} else {
 		if ret.Get(0) != nil {
 			r0 = ret.Get(0).(*MessageResult)
 		}
 	}
-	if returnFunc, ok := ret.Get(1).(func(context.Context, MessageRequest) error); ok {
-		r1 = returnFunc(ctx, req)
+	if returnFunc, ok := ret.Get(1).(func(context.Context, *LegacySession, MessageRequest) error); ok {
+		r1 = returnFunc(ctx, session, req)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -1117,24 +1117,30 @@ type mockLegacyService_ProcessMessage_Call struct {
 
 // ProcessMessage is a helper method to define mock.On call
 //   - ctx context.Context
+//   - session *LegacySession
 //   - req MessageRequest
-func (_e *mockLegacyService_Expecter) ProcessMessage(ctx interface{}, req interface{}) *mockLegacyService_ProcessMessage_Call {
-	return &mockLegacyService_ProcessMessage_Call{Call: _e.mock.On("ProcessMessage", ctx, req)}
+func (_e *mockLegacyService_Expecter) ProcessMessage(ctx interface{}, session interface{}, req interface{}) *mockLegacyService_ProcessMessage_Call {
+	return &mockLegacyService_ProcessMessage_Call{Call: _e.mock.On("ProcessMessage", ctx, session, req)}
 }
 
-func (_c *mockLegacyService_ProcessMessage_Call) Run(run func(ctx context.Context, req MessageRequest)) *mockLegacyService_ProcessMessage_Call {
+func (_c *mockLegacyService_ProcessMessage_Call) Run(run func(ctx context.Context, session *LegacySession, req MessageRequest)) *mockLegacyService_ProcessMessage_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		var arg0 context.Context
 		if args[0] != nil {
 			arg0 = args[0].(context.Context)
 		}
-		var arg1 MessageRequest
+		var arg1 *LegacySession
 		if args[1] != nil {
-			arg1 = args[1].(MessageRequest)
+			arg1 = args[1].(*LegacySession)
+		}
+		var arg2 MessageRequest
+		if args[2] != nil {
+			arg2 = args[2].(MessageRequest)
 		}
 		run(
 			arg0,
 			arg1,
+			arg2,
 		)
 	})
 	return _c
@@ -1145,7 +1151,7 @@ func (_c *mockLegacyService_ProcessMessage_Call) Return(messageResult *MessageRe
 	return _c
 }
 
-func (_c *mockLegacyService_ProcessMessage_Call) RunAndReturn(run func(ctx context.Context, req MessageRequest) (*MessageResult, error)) *mockLegacyService_ProcessMessage_Call {
+func (_c *mockLegacyService_ProcessMessage_Call) RunAndReturn(run func(ctx context.Context, session *LegacySession, req MessageRequest) (*MessageResult, error)) *mockLegacyService_ProcessMessage_Call {
 	_c.Call.Return(run)
 	return _c
 }
@@ -1605,81 +1611,6 @@ func (_c *mockLegacyService_SearchByUIN_Call) RunAndReturn(run func(ctx context.
 	return _c
 }
 
-// SendMessage provides a mock function for the type mockLegacyService
-func (_mock *mockLegacyService) SendMessage(ctx context.Context, fromUIN uint32, toUIN uint32, msgType uint16, message string) error {
-	ret := _mock.Called(ctx, fromUIN, toUIN, msgType, message)
-
-	if len(ret) == 0 {
-		panic("no return value specified for SendMessage")
-	}
-
-	var r0 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, uint32, uint32, uint16, string) error); ok {
-		r0 = returnFunc(ctx, fromUIN, toUIN, msgType, message)
-	} else {
-		r0 = ret.Error(0)
-	}
-	return r0
-}
-
-// mockLegacyService_SendMessage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendMessage'
-type mockLegacyService_SendMessage_Call struct {
-	*mock.Call
-}
-
-// SendMessage is a helper method to define mock.On call
-//   - ctx context.Context
-//   - fromUIN uint32
-//   - toUIN uint32
-//   - msgType uint16
-//   - message string
-func (_e *mockLegacyService_Expecter) SendMessage(ctx interface{}, fromUIN interface{}, toUIN interface{}, msgType interface{}, message interface{}) *mockLegacyService_SendMessage_Call {
-	return &mockLegacyService_SendMessage_Call{Call: _e.mock.On("SendMessage", ctx, fromUIN, toUIN, msgType, message)}
-}
-
-func (_c *mockLegacyService_SendMessage_Call) Run(run func(ctx context.Context, fromUIN uint32, toUIN uint32, msgType uint16, message string)) *mockLegacyService_SendMessage_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		var arg0 context.Context
-		if args[0] != nil {
-			arg0 = args[0].(context.Context)
-		}
-		var arg1 uint32
-		if args[1] != nil {
-			arg1 = args[1].(uint32)
-		}
-		var arg2 uint32
-		if args[2] != nil {
-			arg2 = args[2].(uint32)
-		}
-		var arg3 uint16
-		if args[3] != nil {
-			arg3 = args[3].(uint16)
-		}
-		var arg4 string
-		if args[4] != nil {
-			arg4 = args[4].(string)
-		}
-		run(
-			arg0,
-			arg1,
-			arg2,
-			arg3,
-			arg4,
-		)
-	})
-	return _c
-}
-
-func (_c *mockLegacyService_SendMessage_Call) Return(err error) *mockLegacyService_SendMessage_Call {
-	_c.Call.Return(err)
-	return _c
-}
-
-func (_c *mockLegacyService_SendMessage_Call) RunAndReturn(run func(ctx context.Context, fromUIN uint32, toUIN uint32, msgType uint16, message string) error) *mockLegacyService_SendMessage_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
 // SetAffiliations provides a mock function for the type mockLegacyService
 func (_mock *mockLegacyService) SetAffiliations(ctx context.Context, uin uint32, affiliations state.ICQAffiliations) error {
 	ret := _mock.Called(ctx, uin, affiliations)

+ 1 - 0
server/icq_legacy/property_test.go

@@ -72,6 +72,7 @@ func TestProperty_ServiceBehavioralEquivalence(t *testing.T) {
 			newMockRelationshipFetcher(t),
 			newMockBuddyListRegistry(t),
 			newMockClientSideBuddyListManager(t),
+			newMockICBMService(t),
 			slog.Default(),
 		)
 

+ 24 - 36
server/icq_legacy/service.go

@@ -33,6 +33,7 @@ type ICQLegacyService struct {
 	messageRelayer             MessageRelayer
 	buddyBroadcaster           BuddyBroadcaster
 	offlineMessageManager      OfflineMessageManager
+	icbmService                ICBMService
 	userFinder                 ICQUserFinder
 	userUpdater                ICQUserUpdater
 	feedbagManager             FeedbagManager
@@ -62,6 +63,7 @@ func NewICQLegacyService(
 	relationshipFetcher RelationshipFetcher,
 	buddyListRegistry BuddyListRegistry,
 	clientSideBuddyListManager ClientSideBuddyListManager,
+	icbmSvc ICBMService,
 	logger *slog.Logger,
 ) *ICQLegacyService {
 	return &ICQLegacyService{
@@ -77,6 +79,7 @@ func NewICQLegacyService(
 		relationshipFetcher:        relationshipFetcher,
 		buddyListRegistry:          buddyListRegistry,
 		clientSideBuddyListManager: clientSideBuddyListManager,
+		icbmService:                icbmSvc,
 		logger:                     logger,
 		timeNow:                    time.Now,
 	}
@@ -436,7 +439,7 @@ func (s *ICQLegacyService) ProcessUserAdd(ctx context.Context, req UserAddReques
 // - Parsing protocol-specific message packets into MessageRequest
 // - Using the returned MessageResult to route messages or confirm storage
 // - Building protocol-specific responses
-func (s *ICQLegacyService) ProcessMessage(ctx context.Context, req MessageRequest) (*MessageResult, error) {
+func (s *ICQLegacyService) ProcessMessage(ctx context.Context, session *LegacySession, req MessageRequest) (*MessageResult, error) {
 	result := &MessageResult{
 		Delivered:     false,
 		StoredOffline: false,
@@ -495,7 +498,26 @@ func (s *ICQLegacyService) ProcessMessage(ctx context.Context, req MessageReques
 
 		// Send message to OSCAR client
 		fromScreenName := state.NewIdentScreenName(strconv.FormatUint(uint64(req.FromUIN), 10))
-		if err := s.sendToOSCARClient(ctx, fromScreenName, toScreenName, req.MsgType, req.Message); err != nil {
+
+		frame := wire.SNACFrame{
+			FoodGroup: wire.ICBM,
+			SubGroup:  wire.ICBMChannelMsgToHost,
+		}
+		snac := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+			ChannelID:  wire.ICBMChannelICQ,
+			ScreenName: toScreenName.String(),
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
+						UIN:         fromScreenName.UIN(),
+						MessageType: uint8(req.MsgType),
+						Message:     req.Message,
+					}),
+					wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
+				},
+			},
+		}
+		if _, err := s.icbmService.ChannelMsgToHost(ctx, session.Instance, frame, snac); err != nil {
 			s.logger.Error("ProcessMessage: failed to send to OSCAR client",
 				"to", req.ToUIN,
 				"err", err,
@@ -597,40 +619,6 @@ func (s *ICQLegacyService) generateNewUIN(ctx context.Context) (uint32, error) {
 	return 0, errors.New("no available UINs")
 }
 
-// SendMessage sends a message from one user to another, routing to online
-// users via OSCAR or legacy protocols, or storing for offline delivery.
-// The handler is responsible for sending to legacy sessions; this method
-// handles OSCAR routing and offline storage.
-func (s *ICQLegacyService) SendMessage(ctx context.Context, fromUIN, toUIN uint32, msgType uint16, message string) error {
-	fromScreenName := state.NewIdentScreenName(strconv.FormatUint(uint64(fromUIN), 10))
-	toScreenName := state.NewIdentScreenName(strconv.FormatUint(uint64(toUIN), 10))
-
-	s.logger.Debug("sending message",
-		"from", fromUIN,
-		"to", toUIN,
-		"type", msgType,
-	)
-
-	// Check if recipient is online (OSCAR session)
-	oscarSession := s.sessionRetriever.RetrieveSession(toScreenName)
-	if oscarSession != nil {
-		// Send via OSCAR protocol
-		return s.sendToOSCARClient(ctx, fromScreenName, toScreenName, msgType, message)
-	}
-
-	// Check if recipient is online (legacy session)
-	if s.legacySessionManager != nil {
-		legacySession := s.legacySessionManager.GetSession(toUIN)
-		if legacySession != nil {
-			// Message will be sent by the caller (handler) since it has access to the sender
-			return nil
-		}
-	}
-
-	// Recipient is offline - store as offline message
-	return s.storeOfflineMessage(ctx, fromScreenName, toScreenName, msgType, message)
-}
-
 // sendToOSCARClient sends a message to an OSCAR client
 func (s *ICQLegacyService) sendToOSCARClient(ctx context.Context, from, to state.IdentScreenName, msgType uint16, message string) error {
 	// Strip trailing null bytes from legacy messages

+ 40 - 5
server/icq_legacy/service_test.go

@@ -158,6 +158,7 @@ func TestICQLegacyService_AuthenticateUser(t *testing.T) {
 				newMockRelationshipFetcher(t),
 				newMockBuddyListRegistry(t),
 				newMockClientSideBuddyListManager(t),
+				newMockICBMService(t),
 				slog.Default(),
 			)
 
@@ -180,6 +181,7 @@ func TestICQLegacyService_AuthenticateUser(t *testing.T) {
 func TestICQLegacyService_ProcessMessage(t *testing.T) {
 	tests := []struct {
 		name       string
+		sess       *LegacySession
 		mockParams mockParams
 		req        MessageRequest
 		wantResult *MessageResult
@@ -212,6 +214,7 @@ func TestICQLegacyService_ProcessMessage(t *testing.T) {
 		},
 		{
 			name: "target online - OSCAR session",
+			sess: newTestLegacySession(11111, legacySessionOptOSCARSess),
 			req: MessageRequest{
 				FromUIN: 11111,
 				ToUIN:   22222,
@@ -227,11 +230,28 @@ func TestICQLegacyService_ProcessMessage(t *testing.T) {
 						},
 					},
 				},
-				messageRelayerParams: messageRelayerParams{
-					relayToScreenNameParams: relayToScreenNameParams{
+				icbmFoodgroupParams: icbmFoodgroupParams{
+					channelMsgToHostParams: channelMsgToHostParams{
 						{
-							screenName: state.NewIdentScreenName("22222"),
-							message:    wire.SNACMessage{}, // use mock.Anything for message
+							screenName: state.NewIdentScreenName("11111"),
+							inFrame: wire.SNACFrame{
+								FoodGroup: wire.ICBM,
+								SubGroup:  wire.ICBMChannelMsgToHost,
+							},
+							inBody: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+								ChannelID:  wire.ICBMChannelICQ,
+								ScreenName: "22222",
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
+											UIN:         11111,
+											MessageType: wire.ICBMMsgTypePlain,
+											Message:     "hello from legacy",
+										}),
+										wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
+									},
+								},
+							},
 						},
 					},
 				},
@@ -328,6 +348,13 @@ func TestICQLegacyService_ProcessMessage(t *testing.T) {
 					Return(p.count, p.err)
 			}
 
+			icbmSvc := newMockICBMService(t)
+			for _, msg := range tc.mockParams.channelMsgToHostParams {
+				icbmSvc.EXPECT().
+					ChannelMsgToHost(matchContext(), matchSession(msg.screenName), msg.inFrame, msg.inBody).
+					Return(nil, msg.err)
+			}
+
 			svc := NewICQLegacyService(
 				newMockUserManager(t),
 				newMockAccountManager(t),
@@ -341,6 +368,7 @@ func TestICQLegacyService_ProcessMessage(t *testing.T) {
 				newMockRelationshipFetcher(t),
 				newMockBuddyListRegistry(t),
 				newMockClientSideBuddyListManager(t),
+				icbmSvc,
 				slog.Default(),
 			)
 
@@ -348,7 +376,7 @@ func TestICQLegacyService_ProcessMessage(t *testing.T) {
 				tc.setupLegacyMgr(t, svc)
 			}
 
-			got, err := svc.ProcessMessage(context.Background(), tc.req)
+			got, err := svc.ProcessMessage(context.Background(), tc.sess, tc.req)
 
 			if tc.wantErr != nil {
 				assert.ErrorIs(t, err, tc.wantErr)
@@ -450,6 +478,7 @@ func TestICQLegacyService_ProcessContactList(t *testing.T) {
 				newMockRelationshipFetcher(t),
 				newMockBuddyListRegistry(t),
 				clientSideBuddyListMgr,
+				newMockICBMService(t),
 				slog.Default(),
 			)
 
@@ -526,6 +555,7 @@ func TestICQLegacyService_ProcessStatusChange(t *testing.T) {
 				newMockRelationshipFetcher(t),
 				newMockBuddyListRegistry(t),
 				newMockClientSideBuddyListManager(t),
+				newMockICBMService(t),
 				slog.Default(),
 			)
 
@@ -632,6 +662,7 @@ func TestICQLegacyService_SearchByUIN(t *testing.T) {
 				newMockRelationshipFetcher(t),
 				newMockBuddyListRegistry(t),
 				newMockClientSideBuddyListManager(t),
+				newMockICBMService(t),
 				slog.Default(),
 			)
 
@@ -761,6 +792,7 @@ func TestICQLegacyService_SearchByName(t *testing.T) {
 				newMockRelationshipFetcher(t),
 				newMockBuddyListRegistry(t),
 				newMockClientSideBuddyListManager(t),
+				newMockICBMService(t),
 				slog.Default(),
 			)
 
@@ -852,6 +884,7 @@ func TestICQLegacyService_GetOfflineMessages(t *testing.T) {
 				newMockRelationshipFetcher(t),
 				newMockBuddyListRegistry(t),
 				newMockClientSideBuddyListManager(t),
+				newMockICBMService(t),
 				slog.Default(),
 			)
 
@@ -903,6 +936,7 @@ func TestICQLegacyService_RegisterNewUser(t *testing.T) {
 				newMockRelationshipFetcher(t),
 				newMockBuddyListRegistry(t),
 				newMockClientSideBuddyListManager(t),
+				newMockICBMService(t),
 				slog.Default(),
 			)
 
@@ -1001,6 +1035,7 @@ func TestICQLegacyService_DeleteUser(t *testing.T) {
 				newMockRelationshipFetcher(t),
 				newMockBuddyListRegistry(t),
 				newMockClientSideBuddyListManager(t),
+				newMockICBMService(t),
 				slog.Default(),
 			)
 

+ 5 - 0
server/icq_legacy/types.go

@@ -393,3 +393,8 @@ type LegacyMessageSender interface {
 	// SendUserOffline sends a user offline notification to a legacy client.
 	SendUserOffline(uin uint32, targetUIN uint32) error
 }
+
+// ICBMService is the interface for sending SNAC messages to the OSCAR ICBM service.
+type ICBMService interface {
+	ChannelMsgToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)
+}

+ 1 - 1
server/icq_legacy/v2_handler.go

@@ -413,7 +413,7 @@ func (h *V2Handler) handleSendMessage(session *LegacySession, pkt *V2ClientPacke
 		Message: msg.Message,
 	}
 
-	msgResult, err := h.service.ProcessMessage(ctx, msgReq)
+	msgResult, err := h.service.ProcessMessage(ctx, session, msgReq)
 	if err != nil {
 		h.logger.Debug("failed to process message", "err", err)
 	} else {

+ 1 - 1
server/icq_legacy/v3_handler.go

@@ -653,7 +653,7 @@ func (h *V3Handler) handleMessage(session *LegacySession, seq1, seq2 uint16, uin
 
 	// 3. Call service layer with typed request
 	ctx := context.Background()
-	result, err := h.service.ProcessMessage(ctx, req)
+	result, err := h.service.ProcessMessage(ctx, session, req)
 	if err != nil {
 		h.logger.Error("message processing failed",
 			"from", req.FromUIN,

+ 1 - 1
server/icq_legacy/v4_handler.go

@@ -909,7 +909,7 @@ func (h *V4Handler) handleMessage(session *LegacySession, seq1, seq2 uint16, uin
 	)
 
 	// 3. Call service layer with typed request
-	result, err := h.service.ProcessMessage(ctx, req)
+	result, err := h.service.ProcessMessage(ctx, session, req)
 	if err != nil {
 		h.logger.Error("V4 message processing failed",
 			"from", req.FromUIN,

+ 1 - 1
server/icq_legacy/v5_handler.go

@@ -734,7 +734,7 @@ func (h *V5Handler) handleMessage(session *LegacySession, pkt *V5ClientPacket) e
 
 	// 3. Call service layer with typed request
 	ctx := context.Background()
-	result, err := h.service.ProcessMessage(ctx, req)
+	result, err := h.service.ProcessMessage(ctx, session, req)
 	if err != nil {
 		h.logger.Error("message processing failed",
 			"from", req.FromUIN,