Parcourir la source

implement ICQ offline messages

Mike il y a 1 an
Parent
commit
9b3d49ed7d

+ 4 - 1
.mockery.yaml

@@ -124,4 +124,7 @@ packages:
           filename: "mock_icq_user_updater_test.go"
       SessionRetriever:
         config:
-          filename: "mock_session_retriever_test.go"
+          filename: "mock_session_retriever_test.go"
+      OfflineMessageManager:
+        config:
+          filename: "mock_offline_message_manager_test.go"

+ 2 - 2
cmd/server/main.go

@@ -58,8 +58,8 @@ func main() {
 		chatNavService := foodgroup.NewChatNavService(logger, feedbagStore)
 		feedbagService := foodgroup.NewFeedbagService(logger, sessionManager, feedbagStore, feedbagStore, adjListBuddyListStore)
 		foodgroupService := foodgroup.NewPermitDenyService()
-		icbmService := foodgroup.NewICBMService(sessionManager, feedbagStore, adjListBuddyListStore)
-		icqService := foodgroup.NewICQService(sessionManager, feedbagStore, feedbagStore, logger, sessionManager)
+		icbmService := foodgroup.NewICBMService(sessionManager, feedbagStore, adjListBuddyListStore, feedbagStore)
+		icqService := foodgroup.NewICQService(sessionManager, feedbagStore, feedbagStore, logger, sessionManager, feedbagStore)
 		locateService := foodgroup.NewLocateService(sessionManager, feedbagStore, feedbagStore, adjListBuddyListStore)
 		oServiceService := foodgroup.NewOServiceServiceForBOS(cfg, sessionManager, adjListBuddyListStore, logger, cookieBaker, feedbagStore, feedbagStore)
 

+ 22 - 2
foodgroup/icbm.go

@@ -2,6 +2,8 @@ package foodgroup
 
 import (
 	"context"
+	"fmt"
+	"time"
 
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
@@ -17,11 +19,14 @@ func NewICBMService(
 	messageRelayer MessageRelayer,
 	feedbagManager FeedbagManager,
 	legacyBuddyListManager LegacyBuddyListManager,
+	offlineMessageSaver OfflineMessageManager,
 ) *ICBMService {
 	return &ICBMService{
 		buddyUpdateBroadcaster: NewBuddyService(messageRelayer, feedbagManager, legacyBuddyListManager),
 		feedbagManager:         feedbagManager,
 		messageRelayer:         messageRelayer,
+		offlineMessageSaver:    offlineMessageSaver,
+		timeNow:                time.Now,
 	}
 }
 
@@ -32,6 +37,8 @@ type ICBMService struct {
 	buddyUpdateBroadcaster buddyBroadcaster
 	feedbagManager         FeedbagManager
 	messageRelayer         MessageRelayer
+	offlineMessageSaver    OfflineMessageManager
+	timeNow                func() time.Time
 }
 
 // ParameterQuery returns ICBM service parameters.
@@ -58,7 +65,8 @@ func (s ICBMService) ParameterQuery(_ context.Context, inFrame wire.SNACFrame) w
 // the wire.ICBMChannelMsgToHost message contains a request acknowledgement
 // flag.
 func (s ICBMService) ChannelMsgToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error) {
-	blocked, err := s.feedbagManager.BlockedState(sess.IdentScreenName(), state.NewIdentScreenName(inBody.ScreenName))
+	recip := state.NewIdentScreenName(inBody.ScreenName)
+	blocked, err := s.feedbagManager.BlockedState(sess.IdentScreenName(), recip)
 	if err != nil {
 		return nil, err
 	}
@@ -80,8 +88,20 @@ func (s ICBMService) ChannelMsgToHost(ctx context.Context, sess *state.Session,
 		}, nil
 	}
 
-	recipSess := s.messageRelayer.RetrieveByScreenName(state.NewIdentScreenName(inBody.ScreenName))
+	recipSess := s.messageRelayer.RetrieveByScreenName(recip)
 	if recipSess == nil {
+		// todo: verify user exists, otherwise this could save a bunch of garbage records
+		if _, saveOffline := inBody.Slice(wire.ICBMTLVStore); saveOffline {
+			offlineMsg := state.OfflineMessage{
+				Message:   inBody,
+				Recipient: recip,
+				Sender:    sess.IdentScreenName(),
+				Sent:      s.timeNow().UTC(),
+			}
+			if err := s.offlineMessageSaver.SaveMessage(offlineMsg); err != nil {
+				return nil, fmt.Errorf("save ICBM offline message failed: %w", err)
+			}
+		}
 		return &wire.SNACMessage{
 			Frame: wire.SNACFrame{
 				FoodGroup: wire.ICBM,

+ 71 - 4
foodgroup/icbm_test.go

@@ -2,6 +2,7 @@ package foodgroup
 
 import (
 	"testing"
+	"time"
 
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
@@ -29,6 +30,8 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 		// inputSNAC is the SNAC frame sent from the server to the recipient
 		// client
 		expectOutput *wire.SNACMessage
+		mockParams   mockParams
+		timeNow      func() time.Time
 	}{
 		{
 			name:             "transmit message from sender to recipient, ack message back to sender",
@@ -214,6 +217,61 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 				},
 			},
 		},
+		{
+			name:             "send offline message to ICQ recipient",
+			blockedState:     state.BlockedNo,
+			senderSession:    newTestSession("11111111"),
+			recipientSession: nil,
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					ScreenName: "22222222",
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ICBMTLVRequestHostAck, []byte{}),
+							wire.NewTLV(wire.ICBMTLVStore, []byte{}),
+						},
+					},
+				},
+			},
+			expectOutput: &wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMErr,
+					RequestID: 1234,
+				},
+				Body: wire.SNACError{
+					Code: wire.ErrorCodeNotLoggedOn,
+				},
+			},
+			timeNow: func() time.Time {
+				return time.Date(2020, time.August, 1, 0, 0, 0, 0, time.UTC)
+			},
+			mockParams: mockParams{
+				offlineMessageManagerParams: offlineMessageManagerParams{
+					saveMessageParams: saveMessageParams{
+						{
+							offlineMessageIn: state.OfflineMessage{
+								Message: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+									ScreenName: "22222222",
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICBMTLVRequestHostAck, []byte{}),
+											wire.NewTLV(wire.ICBMTLVStore, []byte{}),
+										},
+									},
+								},
+								Recipient: state.NewIdentScreenName("22222222"),
+								Sender:    state.NewIdentScreenName("11111111"),
+								Sent:      time.Date(2020, time.August, 1, 0, 0, 0, 0, time.UTC),
+							},
+						},
+					},
+				},
+			},
+		},
 	}
 
 	for _, tc := range cases {
@@ -237,10 +295,19 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 					RelayToScreenName(mock.Anything, tc.recipientSession.IdentScreenName(), tc.expectSNACToClient).
 					Maybe()
 			}
+
+			offlineMessageManager := newMockOfflineMessageManager(t)
+			for _, params := range tc.mockParams.saveMessageParams {
+				offlineMessageManager.EXPECT().
+					SaveMessage(params.offlineMessageIn).
+					Return(params.err)
+			}
 			//
 			// send input SNAC
 			//
-			svc := NewICBMService(messageRelayer, feedbagManager, nil)
+			svc := NewICBMService(messageRelayer, feedbagManager, nil, offlineMessageManager)
+			svc.timeNow = tc.timeNow
+
 			outputSNAC, err := svc.ChannelMsgToHost(nil, tc.senderSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x04_0x06_ICBMChannelMsgToHost))
 			assert.NoError(t, err)
@@ -332,7 +399,7 @@ func TestICBMService_ClientEvent(t *testing.T) {
 			// send input SNAC
 			//
 			senderSession := newTestSession(tc.senderScreenName)
-			svc := NewICBMService(messageRelayer, feedbagManager, nil)
+			svc := NewICBMService(messageRelayer, feedbagManager, nil, nil)
 			assert.NoError(t, svc.ClientEvent(nil, senderSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x04_0x14_ICBMClientEvent)))
 		})
@@ -624,7 +691,7 @@ func TestICBMService_EvilRequest(t *testing.T) {
 			//
 			// send input SNAC
 			//
-			svc := NewICBMService(messageRelayer, feedbagManager, nil)
+			svc := NewICBMService(messageRelayer, feedbagManager, nil, nil)
 			svc.buddyUpdateBroadcaster = buddyUpdateBroadcaster
 			outputSNAC, err := svc.EvilRequest(nil, tc.senderSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x04_0x08_ICBMEvilRequest))
@@ -635,7 +702,7 @@ func TestICBMService_EvilRequest(t *testing.T) {
 }
 
 func TestICBMService_ParameterQuery(t *testing.T) {
-	svc := NewICBMService(nil, nil, nil)
+	svc := NewICBMService(nil, nil, nil, nil)
 
 	have := svc.ParameterQuery(nil, wire.SNACFrame{RequestID: 1234})
 	want := wire.SNACMessage{

+ 85 - 17
foodgroup/icq.go

@@ -1,6 +1,7 @@
 package foodgroup
 
 import (
+	"bytes"
 	"context"
 	"errors"
 	"fmt"
@@ -22,25 +23,35 @@ func NewICQService(
 	userUpdater ICQUserUpdater,
 	logger *slog.Logger,
 	sessionRetriever SessionRetriever,
+	offlineMessageManager OfflineMessageManager,
 ) ICQService {
 	return ICQService{
-		messageRelayer:   messageRelayer,
-		userFinder:       finder,
-		userUpdater:      userUpdater,
-		logger:           logger,
-		sessionRetriever: sessionRetriever,
-		timeNow:          time.Now,
+		messageRelayer:        messageRelayer,
+		userFinder:            finder,
+		userUpdater:           userUpdater,
+		logger:                logger,
+		sessionRetriever:      sessionRetriever,
+		offlineMessageManager: offlineMessageManager,
+		timeNow:               time.Now,
 	}
 }
 
 // ICQService provides functionality for the ICQ food group.
 type ICQService struct {
-	userFinder       ICQUserFinder
-	logger           *slog.Logger
-	messageRelayer   MessageRelayer
-	sessionRetriever SessionRetriever
-	userUpdater      ICQUserUpdater
-	timeNow          func() time.Time
+	userFinder            ICQUserFinder
+	logger                *slog.Logger
+	messageRelayer        MessageRelayer
+	sessionRetriever      SessionRetriever
+	userUpdater           ICQUserUpdater
+	timeNow               func() time.Time
+	offlineMessageManager OfflineMessageManager
+}
+
+func (s ICQService) DeleteMsgReq(ctx context.Context, sess *state.Session, seq uint16) error {
+	if err := s.offlineMessageManager.DeleteMessages(sess.IdentScreenName()); err != nil {
+		return fmt.Errorf("deleting messages: %w", err)
+	}
+	return nil
 }
 
 func (s ICQService) FindByDetails(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, seq uint16) error {
@@ -233,9 +244,66 @@ func (s ICQService) FullUserInfo(ctx context.Context, sess *state.Session, req w
 	return nil
 }
 
-func (s ICQService) MessagesEOF(ctx context.Context, sess *state.Session, seq uint16) error {
-	s.logger.Debug("returning offline messages is not yet supported")
-	msg := wire.ICQMessageReplyEnvelope{
+func (s ICQService) OfflineMsgReq(ctx context.Context, sess *state.Session, seq uint16) error {
+	messages, err := s.offlineMessageManager.RetrieveMessages(sess.IdentScreenName())
+	if err != nil {
+		return fmt.Errorf("retrieving messages: %w", err)
+	}
+
+	for _, msgIn := range messages {
+		reply := wire.ICQ_0x0041_DBQueryOfflineMsgReply{
+			ICQMetadata: wire.ICQMetadata{
+				UIN:     sess.UIN(),
+				ReqType: wire.ICQDBQueryOfflineMsgReply,
+				Seq:     seq,
+			},
+			SenderUIN: msgIn.Sender.UIN(),
+			Year:      uint16(msgIn.Sent.Year()),
+			Month:     uint8(msgIn.Sent.Month()),
+			Day:       uint8(msgIn.Sent.Day()),
+			Hour:      uint8(msgIn.Sent.Hour()),
+			Minute:    uint8(msgIn.Sent.Minute()),
+		}
+
+		switch msgIn.Message.ChannelID {
+		case wire.ICBMChannelIM:
+			if payload, hasIM := msgIn.Message.Slice(wire.ICBMTLVAOLIMData); hasIM {
+				// send regular IM
+				msgText, err := wire.UnmarshalICBMMessageText(payload)
+				if err != nil {
+					return fmt.Errorf("unmarshalling offline message: %w", err)
+				}
+				reply.MsgType = wire.ICBMExtendedMsgTypePlain
+				reply.Message = msgText
+			}
+		case wire.ICBMChannelICQ:
+			if b, hasAuthReq := msgIn.Message.Slice(wire.ICBMTLVData); hasAuthReq {
+				// send authorization request
+				rdv := wire.ICBMCh4Message{}
+				buf := bytes.NewBuffer(b)
+				if err := wire.UnmarshalLE(&rdv, buf); err != nil {
+					return err
+				}
+				reply.MsgType = rdv.MessageType
+				reply.Flags = rdv.Flags
+				reply.Message = rdv.Message
+			}
+		}
+
+		if reply.MsgType == 0 {
+			return fmt.Errorf("did not find an appropriate saved message payload. channel: %d",
+				msgIn.Message.ChannelID)
+		}
+
+		msgOut := wire.ICQMessageReplyEnvelope{
+			Message: reply,
+		}
+		if err := s.reply(ctx, sess, msgOut); err != nil {
+			return fmt.Errorf("sending offline message: %w", err)
+		}
+	}
+
+	eofMsg := wire.ICQMessageReplyEnvelope{
 		Message: wire.ICQ_0x0042_DBQueryOfflineMsgReplyLast{
 			ICQMetadata: wire.ICQMetadata{
 				UIN:     sess.UIN(),
@@ -246,7 +314,7 @@ func (s ICQService) MessagesEOF(ctx context.Context, sess *state.Session, seq ui
 		},
 	}
 
-	return s.reply(ctx, sess, msg)
+	return s.reply(ctx, sess, eofMsg)
 }
 
 func (s ICQService) SetAffiliations(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations, seq uint16) error {
@@ -603,7 +671,7 @@ func (s ICQService) reply(ctx context.Context, sess *state.Session, message wire
 			FoodGroup: wire.ICQ,
 			SubGroup:  wire.ICQDBReply,
 		},
-		Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+		Body: wire.SNAC_0x0F_0x02_DBReply{
 			TLVRestBlock: wire.TLVRestBlock{
 				TLVList: wire.TLVList{
 					wire.NewTLV(wire.ICQTLVTagsMetadata, message),

+ 187 - 28
foodgroup/icq_test.go

@@ -1,6 +1,7 @@
 package foodgroup
 
 import (
+	"bytes"
 	"log/slog"
 	"testing"
 	"time"
@@ -12,6 +13,45 @@ import (
 	"github.com/mk6i/retro-aim-server/wire"
 )
 
+func TestICQService_DeleteMsgReq(t *testing.T) {
+	tests := []struct {
+		name       string
+		seq        uint16
+		sess       *state.Session
+		mockParams mockParams
+		wantErr    error
+	}{
+		{
+			name: "send offline IM, offline friend request",
+			seq:  1,
+			sess: newTestSession("11111111", sessOptUIN(11111111)),
+			mockParams: mockParams{
+				offlineMessageManagerParams: offlineMessageManagerParams{
+					deleteMessagesParams: deleteMessagesParams{
+						{
+							recipIn: state.NewIdentScreenName("11111111"),
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			offlineMessageManager := newMockOfflineMessageManager(t)
+			for _, params := range tt.mockParams.deleteMessagesParams {
+				offlineMessageManager.EXPECT().
+					DeleteMessages(params.recipIn).
+					Return(params.err)
+			}
+
+			s := NewICQService(nil, nil, nil, slog.Default(), nil, offlineMessageManager)
+			err := s.DeleteMsgReq(nil, tt.sess, tt.seq)
+			assert.NoError(t, err)
+		})
+	}
+}
+
 func TestICQService_FindByDetails(t *testing.T) {
 	tests := []struct {
 		name       string
@@ -91,7 +131,7 @@ func TestICQService_FindByDetails(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -128,7 +168,7 @@ func TestICQService_FindByDetails(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -268,7 +308,7 @@ func TestICQService_FindByEmail(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -404,7 +444,7 @@ func TestICQService_FindByUIN(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -562,7 +602,7 @@ func TestICQService_FindByWhitePages(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -599,7 +639,7 @@ func TestICQService_FindByWhitePages(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -794,7 +834,7 @@ func TestICQService_FullUserInfo(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -838,7 +878,7 @@ func TestICQService_FullUserInfo(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -879,7 +919,7 @@ func TestICQService_FullUserInfo(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -905,7 +945,7 @@ func TestICQService_FullUserInfo(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -931,7 +971,7 @@ func TestICQService_FullUserInfo(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -971,7 +1011,7 @@ func TestICQService_FullUserInfo(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -1000,7 +1040,7 @@ func TestICQService_FullUserInfo(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -1047,7 +1087,7 @@ func TestICQService_FullUserInfo(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -1140,7 +1180,7 @@ func TestICQService_FullUserInfo(t *testing.T) {
 	}
 }
 
-func TestICQService_MessagesEOF(t *testing.T) {
+func TestICQService_OfflineMsgReq(t *testing.T) {
 	tests := []struct {
 		name       string
 		seq        uint16
@@ -1149,10 +1189,59 @@ func TestICQService_MessagesEOF(t *testing.T) {
 		wantErr    error
 	}{
 		{
-			name: "happy path",
+			name: "send offline IM, offline friend request",
 			seq:  1,
 			sess: newTestSession("11111111", sessOptUIN(11111111)),
 			mockParams: mockParams{
+				offlineMessageManagerParams: offlineMessageManagerParams{
+					retrieveMessagesParams: retrieveMessagesParams{
+						{
+							recipIn: state.NewIdentScreenName("11111111"),
+							messagesOut: []state.OfflineMessage{
+								{
+									Sender:    state.NewIdentScreenName("22222222"),
+									Recipient: state.NewIdentScreenName("11111111"),
+									Message: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+										ChannelID: wire.ICBMChannelIM,
+										TLVRestBlock: wire.TLVRestBlock{
+											TLVList: wire.TLVList{
+												wire.NewTLV(wire.ICBMTLVAOLIMData, func() []wire.ICBMCh1Fragment {
+													frags, err := wire.ICBMFragmentList("hello!")
+													assert.NoError(t, err)
+													return frags
+												}()),
+											},
+										},
+									},
+									Sent: time.Date(2024, time.August, 2, 12, 5, 0, 0, time.UTC),
+								},
+								{
+									Sender:    state.NewIdentScreenName("33333333"),
+									Recipient: state.NewIdentScreenName("11111111"),
+									Message: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+										ChannelID: wire.ICBMChannelICQ,
+										TLVRestBlock: wire.TLVRestBlock{
+											TLVList: wire.TLVList{
+												wire.NewTLV(wire.ICBMTLVData, func() []byte {
+													msg := wire.ICBMCh4Message{
+														UIN:         33333333,
+														MessageType: wire.ICBMExtendedMsgTypeAuthReq,
+														Flags:       0,
+														Message:     "please add me to your contacts list",
+													}
+													buf := &bytes.Buffer{}
+													assert.NoError(t, wire.MarshalLE(msg, buf))
+													return buf.Bytes()
+												}()),
+											},
+										},
+									},
+									Sent: time.Date(2024, time.August, 1, 8, 2, 0, 0, time.UTC),
+								},
+							},
+						},
+					},
+				},
 				messageRelayerParams: messageRelayerParams{
 					relayToScreenNameParams: relayToScreenNameParams{
 						{
@@ -1162,7 +1251,71 @@ func TestICQService_MessagesEOF(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x0041_DBQueryOfflineMsgReply{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryOfflineMsgReply,
+														Seq:     1,
+													},
+													SenderUIN: 22222222,
+													Year:      uint16(2024),
+													Month:     uint8(8),
+													Day:       uint8(2),
+													Hour:      uint8(12),
+													Minute:    uint8(5),
+													MsgType:   wire.ICBMExtendedMsgTypePlain,
+													Message:   "hello!",
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_DBReply{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+												Message: wire.ICQ_0x0041_DBQueryOfflineMsgReply{
+													ICQMetadata: wire.ICQMetadata{
+														UIN:     11111111,
+														ReqType: wire.ICQDBQueryOfflineMsgReply,
+														Seq:     1,
+													},
+													SenderUIN: 33333333,
+													Year:      uint16(2024),
+													Month:     uint8(8),
+													Day:       uint8(1),
+													Hour:      uint8(8),
+													Minute:    uint8(2),
+													MsgType:   wire.ICBMExtendedMsgTypeAuthReq,
+													Message:   "please add me to your contacts list",
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+						{
+							screenName: state.NewIdentScreenName("11111111"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.ICQ,
+									SubGroup:  wire.ICQDBReply,
+								},
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -1187,13 +1340,19 @@ func TestICQService_MessagesEOF(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
+			offlineMessageManager := newMockOfflineMessageManager(t)
+			for _, params := range tt.mockParams.retrieveMessagesParams {
+				offlineMessageManager.EXPECT().
+					RetrieveMessages(params.recipIn).
+					Return(params.messagesOut, params.err)
+			}
 			messageRelayer := newMockMessageRelayer(t)
 			for _, params := range tt.mockParams.relayToScreenNameParams {
 				messageRelayer.EXPECT().RelayToScreenName(mock.Anything, params.screenName, params.message)
 			}
 
-			s := NewICQService(messageRelayer, nil, nil, slog.Default(), nil)
-			err := s.MessagesEOF(nil, tt.sess, tt.seq)
+			s := NewICQService(messageRelayer, nil, nil, slog.Default(), nil, offlineMessageManager)
+			err := s.OfflineMsgReq(nil, tt.sess, tt.seq)
 			assert.NoError(t, err)
 		})
 	}
@@ -1279,7 +1438,7 @@ func TestICQService_SetAffiliations(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -1387,7 +1546,7 @@ func TestICQService_SetEmails(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -1490,7 +1649,7 @@ func TestICQService_SetBasicInfo(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -1601,7 +1760,7 @@ func TestICQService_SetInterests(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -1721,7 +1880,7 @@ func TestICQService_SetMoreInfo(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -1793,7 +1952,7 @@ func TestICQService_SetPermissions(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -1870,7 +2029,7 @@ func TestICQService_SetUserNotes(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -1976,7 +2135,7 @@ func TestICQService_SetWorkInfo(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -2054,7 +2213,7 @@ func TestICQService_XMLReqData(t *testing.T) {
 									FoodGroup: wire.ICQ,
 									SubGroup:  wire.ICQDBReply,
 								},
-								Body: wire.SNAC_0x0F_0x02_ICQDBReply{
+								Body: wire.SNAC_0x0F_0x02_DBReply{
 									TLVRestBlock: wire.TLVRestBlock{
 										TLVList: wire.TLVList{
 											wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{

+ 185 - 0
foodgroup/mock_offline_message_manager_test.go

@@ -0,0 +1,185 @@
+// Code generated by mockery v2.43.2. DO NOT EDIT.
+
+package foodgroup
+
+import (
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockOfflineMessageManager is an autogenerated mock type for the OfflineMessageManager type
+type mockOfflineMessageManager struct {
+	mock.Mock
+}
+
+type mockOfflineMessageManager_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockOfflineMessageManager) EXPECT() *mockOfflineMessageManager_Expecter {
+	return &mockOfflineMessageManager_Expecter{mock: &_m.Mock}
+}
+
+// DeleteMessages provides a mock function with given fields: recip
+func (_m *mockOfflineMessageManager) DeleteMessages(recip state.IdentScreenName) error {
+	ret := _m.Called(recip)
+
+	if len(ret) == 0 {
+		panic("no return value specified for DeleteMessages")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) error); ok {
+		r0 = rf(recip)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockOfflineMessageManager_DeleteMessages_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteMessages'
+type mockOfflineMessageManager_DeleteMessages_Call struct {
+	*mock.Call
+}
+
+// DeleteMessages is a helper method to define mock.On call
+//   - recip state.IdentScreenName
+func (_e *mockOfflineMessageManager_Expecter) DeleteMessages(recip interface{}) *mockOfflineMessageManager_DeleteMessages_Call {
+	return &mockOfflineMessageManager_DeleteMessages_Call{Call: _e.mock.On("DeleteMessages", recip)}
+}
+
+func (_c *mockOfflineMessageManager_DeleteMessages_Call) Run(run func(recip state.IdentScreenName)) *mockOfflineMessageManager_DeleteMessages_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockOfflineMessageManager_DeleteMessages_Call) Return(_a0 error) *mockOfflineMessageManager_DeleteMessages_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockOfflineMessageManager_DeleteMessages_Call) RunAndReturn(run func(state.IdentScreenName) error) *mockOfflineMessageManager_DeleteMessages_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RetrieveMessages provides a mock function with given fields: recip
+func (_m *mockOfflineMessageManager) RetrieveMessages(recip state.IdentScreenName) ([]state.OfflineMessage, error) {
+	ret := _m.Called(recip)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RetrieveMessages")
+	}
+
+	var r0 []state.OfflineMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) ([]state.OfflineMessage, error)); ok {
+		return rf(recip)
+	}
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) []state.OfflineMessage); ok {
+		r0 = rf(recip)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]state.OfflineMessage)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(state.IdentScreenName) error); ok {
+		r1 = rf(recip)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockOfflineMessageManager_RetrieveMessages_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RetrieveMessages'
+type mockOfflineMessageManager_RetrieveMessages_Call struct {
+	*mock.Call
+}
+
+// RetrieveMessages is a helper method to define mock.On call
+//   - recip state.IdentScreenName
+func (_e *mockOfflineMessageManager_Expecter) RetrieveMessages(recip interface{}) *mockOfflineMessageManager_RetrieveMessages_Call {
+	return &mockOfflineMessageManager_RetrieveMessages_Call{Call: _e.mock.On("RetrieveMessages", recip)}
+}
+
+func (_c *mockOfflineMessageManager_RetrieveMessages_Call) Run(run func(recip state.IdentScreenName)) *mockOfflineMessageManager_RetrieveMessages_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockOfflineMessageManager_RetrieveMessages_Call) Return(_a0 []state.OfflineMessage, _a1 error) *mockOfflineMessageManager_RetrieveMessages_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockOfflineMessageManager_RetrieveMessages_Call) RunAndReturn(run func(state.IdentScreenName) ([]state.OfflineMessage, error)) *mockOfflineMessageManager_RetrieveMessages_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SaveMessage provides a mock function with given fields: offlineMessage
+func (_m *mockOfflineMessageManager) SaveMessage(offlineMessage state.OfflineMessage) error {
+	ret := _m.Called(offlineMessage)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SaveMessage")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(state.OfflineMessage) error); ok {
+		r0 = rf(offlineMessage)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockOfflineMessageManager_SaveMessage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveMessage'
+type mockOfflineMessageManager_SaveMessage_Call struct {
+	*mock.Call
+}
+
+// SaveMessage is a helper method to define mock.On call
+//   - offlineMessage state.OfflineMessage
+func (_e *mockOfflineMessageManager_Expecter) SaveMessage(offlineMessage interface{}) *mockOfflineMessageManager_SaveMessage_Call {
+	return &mockOfflineMessageManager_SaveMessage_Call{Call: _e.mock.On("SaveMessage", offlineMessage)}
+}
+
+func (_c *mockOfflineMessageManager_SaveMessage_Call) Run(run func(offlineMessage state.OfflineMessage)) *mockOfflineMessageManager_SaveMessage_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.OfflineMessage))
+	})
+	return _c
+}
+
+func (_c *mockOfflineMessageManager_SaveMessage_Call) Return(_a0 error) *mockOfflineMessageManager_SaveMessage_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockOfflineMessageManager_SaveMessage_Call) RunAndReturn(run func(state.OfflineMessage) error) *mockOfflineMessageManager_SaveMessage_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockOfflineMessageManager creates a new instance of mockOfflineMessageManager. 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 newMockOfflineMessageManager(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockOfflineMessageManager {
+	mock := &mockOfflineMessageManager{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 31 - 0
foodgroup/test_helpers.go

@@ -24,12 +24,43 @@ type mockParams struct {
 	icqUserUpdaterParams
 	legacyBuddyListManagerParams
 	messageRelayerParams
+	offlineMessageManagerParams
 	profileManagerParams
 	sessionManagerParams
 	sessionRetrieverParams
 	userManagerParams
 }
 
+// offlineMessageManagerParams is a helper struct that contains mock parameters for
+// OfflineMessageManager methods
+type offlineMessageManagerParams struct {
+	deleteMessagesParams
+	retrieveMessagesParams
+	saveMessageParams
+}
+
+// deleteMessagesParams is the list of parameters passed at the mock
+// OfflineMessageManager.DeleteMessages call site
+type deleteMessagesParams []struct {
+	recipIn state.IdentScreenName
+	err     error
+}
+
+// deleteMessagesParams is the list of parameters passed at the mock
+// OfflineMessageManager.RetrieveMessages call site
+type retrieveMessagesParams []struct {
+	recipIn     state.IdentScreenName
+	messagesOut []state.OfflineMessage
+	err         error
+}
+
+// deleteMessagesParams is the list of parameters passed at the mock
+// OfflineMessageManager.SaveMessage call site
+type saveMessageParams []struct {
+	offlineMessageIn state.OfflineMessage
+	err              error
+}
+
 // sessionRetrieverParams is a helper struct that contains mock parameters for
 // SessionRetriever methods
 type sessionRetrieverParams struct {

+ 6 - 0
foodgroup/types.go

@@ -196,3 +196,9 @@ type ICQUserUpdater interface {
 type SessionRetriever interface {
 	RetrieveSession(screenName state.IdentScreenName) *state.Session
 }
+
+type OfflineMessageManager interface {
+	DeleteMessages(recip state.IdentScreenName) error
+	RetrieveMessages(recip state.IdentScreenName) ([]state.OfflineMessage, error)
+	SaveMessage(offlineMessage state.OfflineMessage) error
+}

+ 5 - 4
server/oscar/handler/icq.go

@@ -16,12 +16,13 @@ import (
 )
 
 type ICQService interface {
+	DeleteMsgReq(ctx context.Context, sess *state.Session, seq uint16) error
 	FindByDetails(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, seq uint16) error
 	FindByEmail(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail, seq uint16) error
 	FindByInterests(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages, seq uint16) error
 	FindByUIN(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN, seq uint16) error
 	FullUserInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN, seq uint16) error
-	MessagesEOF(ctx context.Context, sess *state.Session, seq uint16) error
+	OfflineMsgReq(ctx context.Context, sess *state.Session, seq uint16) error
 	SetAffiliations(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations, seq uint16) error
 	SetBasicInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo, seq uint16) error
 	SetEmails(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails, seq uint16) error
@@ -53,7 +54,7 @@ type ICQHandler struct {
 }
 
 func (rt ICQHandler) DBQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x0F_0x02_ICQDBQuery{}
+	inBody := wire.SNAC_0x0F_0x02_BQuery{}
 	if err := wire.UnmarshalBE(&inBody, r); err != nil {
 		return err
 	}
@@ -75,9 +76,9 @@ func (rt ICQHandler) DBQuery(ctx context.Context, sess *state.Session, inFrame w
 
 	switch icqMD.ReqType {
 	case wire.ICQDBQueryOfflineMsgReq:
-		return rt.ICQService.MessagesEOF(ctx, sess, icqMD.Seq)
+		return rt.ICQService.OfflineMsgReq(ctx, sess, icqMD.Seq)
 	case wire.ICQDBQueryDeleteMsgReq:
-		rt.Logger.Debug("returning offline messages is not yet supported")
+		return rt.ICQService.DeleteMsgReq(ctx, sess, icqMD.Seq)
 	case wire.ICQDBQueryMetaReq:
 		if icqMD.Optional == nil {
 			return errors.New("got req without subtype")

+ 86 - 28
server/oscar/handler/icq_test.go

@@ -24,7 +24,7 @@ func TestICQHandler_DBQuery(t *testing.T) {
 		ctx     context.Context
 		sess    *state.Session
 		inFrame wire.SNACFrame
-		inBody  wire.SNAC_0x0F_0x02_ICQDBQuery
+		inBody  wire.SNAC_0x0F_0x02_BQuery
 		rw      oscar.ResponseWriter
 		seq     uint16
 		wantErr error
@@ -34,20 +34,22 @@ func TestICQHandler_DBQuery(t *testing.T) {
 		wantErr error
 	}
 	type allMockParams struct {
-		fullUserInfo    *mockParam
-		xmlReqData      *mockParam
-		setPermissions  *mockParam
-		findByUIN       *mockParam
-		findByEmail     *mockParam
+		deleteMsgReq    *mockParam
 		findByDetails   *mockParam
+		findByEmail     *mockParam
 		findByInterests *mockParam
+		findByUIN       *mockParam
+		fullUserInfo    *mockParam
+		offlineMsgReq   *mockParam
+		setAffiliations *mockParam
 		setBasicInfo    *mockParam
-		setWorkInfo     *mockParam
-		setMoreInfo     *mockParam
-		setUserNotes    *mockParam
 		setEmails       *mockParam
 		setInterests    *mockParam
-		setAffiliations *mockParam
+		setMoreInfo     *mockParam
+		setPermissions  *mockParam
+		setUserNotes    *mockParam
+		setWorkInfo     *mockParam
+		xmlReqData      *mockParam
 	}
 	tests := []struct {
 		name          string
@@ -58,7 +60,7 @@ func TestICQHandler_DBQuery(t *testing.T) {
 			name: "MetaReqFullInfo - happy path",
 			reqParams: reqParams{
 				sess: &state.Session{},
-				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+				inBody: wire.SNAC_0x0F_0x02_BQuery{
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -90,7 +92,7 @@ func TestICQHandler_DBQuery(t *testing.T) {
 			name: "MetaReqFullInfo2 - happy path",
 			reqParams: reqParams{
 				sess: &state.Session{},
-				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+				inBody: wire.SNAC_0x0F_0x02_BQuery{
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -122,7 +124,7 @@ func TestICQHandler_DBQuery(t *testing.T) {
 			name: "MetaReqXMLReq - happy path",
 			reqParams: reqParams{
 				sess: &state.Session{},
-				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+				inBody: wire.SNAC_0x0F_0x02_BQuery{
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -154,7 +156,7 @@ func TestICQHandler_DBQuery(t *testing.T) {
 			name: "MetaReqSetPermissions - happy path",
 			reqParams: reqParams{
 				sess: &state.Session{},
-				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+				inBody: wire.SNAC_0x0F_0x02_BQuery{
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -186,7 +188,7 @@ func TestICQHandler_DBQuery(t *testing.T) {
 			name: "MetaReqSearchByUIN - happy path",
 			reqParams: reqParams{
 				sess: &state.Session{},
-				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+				inBody: wire.SNAC_0x0F_0x02_BQuery{
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -218,7 +220,7 @@ func TestICQHandler_DBQuery(t *testing.T) {
 			name: "MetaReqSearchByEmail - happy path",
 			reqParams: reqParams{
 				sess: &state.Session{},
-				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+				inBody: wire.SNAC_0x0F_0x02_BQuery{
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -250,7 +252,7 @@ func TestICQHandler_DBQuery(t *testing.T) {
 			name: "MetaReqSearchByDetails - happy path",
 			reqParams: reqParams{
 				sess: &state.Session{},
-				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+				inBody: wire.SNAC_0x0F_0x02_BQuery{
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -282,7 +284,7 @@ func TestICQHandler_DBQuery(t *testing.T) {
 			name: "MetaReqSearchWhitePages - happy path",
 			reqParams: reqParams{
 				sess: &state.Session{},
-				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+				inBody: wire.SNAC_0x0F_0x02_BQuery{
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -314,7 +316,7 @@ func TestICQHandler_DBQuery(t *testing.T) {
 			name: "MetaReqSetBasicInfo - happy path",
 			reqParams: reqParams{
 				sess: &state.Session{},
-				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+				inBody: wire.SNAC_0x0F_0x02_BQuery{
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -346,7 +348,7 @@ func TestICQHandler_DBQuery(t *testing.T) {
 			name: "MetaReqSetWorkInfo - happy path",
 			reqParams: reqParams{
 				sess: &state.Session{},
-				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+				inBody: wire.SNAC_0x0F_0x02_BQuery{
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -378,7 +380,7 @@ func TestICQHandler_DBQuery(t *testing.T) {
 			name: "MetaReqSetMoreInfo - happy path",
 			reqParams: reqParams{
 				sess: &state.Session{},
-				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+				inBody: wire.SNAC_0x0F_0x02_BQuery{
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -410,7 +412,7 @@ func TestICQHandler_DBQuery(t *testing.T) {
 			name: "MetaReqSetNotes - happy path",
 			reqParams: reqParams{
 				sess: &state.Session{},
-				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+				inBody: wire.SNAC_0x0F_0x02_BQuery{
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -442,7 +444,7 @@ func TestICQHandler_DBQuery(t *testing.T) {
 			name: "MetaReqSetEmails - happy path",
 			reqParams: reqParams{
 				sess: &state.Session{},
-				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+				inBody: wire.SNAC_0x0F_0x02_BQuery{
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -488,7 +490,7 @@ func TestICQHandler_DBQuery(t *testing.T) {
 			name: "MetaReqSetInterests - happy path",
 			reqParams: reqParams{
 				sess: &state.Session{},
-				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+				inBody: wire.SNAC_0x0F_0x02_BQuery{
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -534,7 +536,7 @@ func TestICQHandler_DBQuery(t *testing.T) {
 			name: "MetaReqSetAffiliations - happy path",
 			reqParams: reqParams{
 				sess: &state.Session{},
-				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+				inBody: wire.SNAC_0x0F_0x02_BQuery{
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -596,7 +598,7 @@ func TestICQHandler_DBQuery(t *testing.T) {
 			name: "MetaReqStat - happy path",
 			reqParams: reqParams{
 				sess: &state.Session{},
-				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+				inBody: wire.SNAC_0x0F_0x02_BQuery{
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -619,7 +621,7 @@ func TestICQHandler_DBQuery(t *testing.T) {
 			name: "unknown metadata request subtype",
 			reqParams: reqParams{
 				sess: &state.Session{},
-				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+				inBody: wire.SNAC_0x0F_0x02_BQuery{
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -639,11 +641,59 @@ func TestICQHandler_DBQuery(t *testing.T) {
 				wantErr: errUnknownICQMetaReqSubType,
 			},
 		},
+		{
+			name: "OfflineMsgReq - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x0F_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: wire.ICQMetadataWithSubType{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryOfflineMsgReq,
+										Seq:     1,
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				offlineMsgReq: &mockParam{},
+			},
+		},
+		{
+			name: "DeleteMsgReq - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x0F_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: wire.ICQMetadataWithSubType{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryDeleteMsgReq,
+										Seq:     1,
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				deleteMsgReq: &mockParam{},
+			},
+		},
 		{
 			name: "unknown request type",
 			reqParams: reqParams{
 				sess: &state.Session{},
-				inBody: wire.SNAC_0x0F_0x02_ICQDBQuery{
+				inBody: wire.SNAC_0x0F_0x02_BQuery{
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLV(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
@@ -725,6 +775,14 @@ func TestICQHandler_DBQuery(t *testing.T) {
 				icqService.EXPECT().
 					SetAffiliations(mock.Anything, tt.reqParams.sess, tt.allMockParams.setAffiliations.req, tt.reqParams.seq).
 					Return(tt.allMockParams.setAffiliations.wantErr)
+			case tt.allMockParams.offlineMsgReq != nil:
+				icqService.EXPECT().
+					OfflineMsgReq(mock.Anything, tt.reqParams.sess, tt.reqParams.seq).
+					Return(tt.allMockParams.offlineMsgReq.wantErr)
+			case tt.allMockParams.deleteMsgReq != nil:
+				icqService.EXPECT().
+					DeleteMsgReq(mock.Anything, tt.reqParams.sess, tt.reqParams.seq).
+					Return(tt.allMockParams.deleteMsgReq.wantErr)
 			}
 
 			rt := NewICQHandler(slog.Default(), icqService)

+ 59 - 11
server/oscar/handler/mock_icq_service_test.go

@@ -24,6 +24,54 @@ func (_m *mockICQService) EXPECT() *mockICQService_Expecter {
 	return &mockICQService_Expecter{mock: &_m.Mock}
 }
 
+// DeleteMsgReq provides a mock function with given fields: ctx, sess, seq
+func (_m *mockICQService) DeleteMsgReq(ctx context.Context, sess *state.Session, seq uint16) error {
+	ret := _m.Called(ctx, sess, seq)
+
+	if len(ret) == 0 {
+		panic("no return value specified for DeleteMsgReq")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, uint16) error); ok {
+		r0 = rf(ctx, sess, seq)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICQService_DeleteMsgReq_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteMsgReq'
+type mockICQService_DeleteMsgReq_Call struct {
+	*mock.Call
+}
+
+// DeleteMsgReq is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - seq uint16
+func (_e *mockICQService_Expecter) DeleteMsgReq(ctx interface{}, sess interface{}, seq interface{}) *mockICQService_DeleteMsgReq_Call {
+	return &mockICQService_DeleteMsgReq_Call{Call: _e.mock.On("DeleteMsgReq", ctx, sess, seq)}
+}
+
+func (_c *mockICQService_DeleteMsgReq_Call) Run(run func(ctx context.Context, sess *state.Session, seq uint16)) *mockICQService_DeleteMsgReq_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(uint16))
+	})
+	return _c
+}
+
+func (_c *mockICQService_DeleteMsgReq_Call) Return(_a0 error) *mockICQService_DeleteMsgReq_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICQService_DeleteMsgReq_Call) RunAndReturn(run func(context.Context, *state.Session, uint16) error) *mockICQService_DeleteMsgReq_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // FindByDetails provides a mock function with given fields: ctx, sess, req, seq
 func (_m *mockICQService) FindByDetails(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, seq uint16) error {
 	ret := _m.Called(ctx, sess, req, seq)
@@ -269,12 +317,12 @@ func (_c *mockICQService_FullUserInfo_Call) RunAndReturn(run func(context.Contex
 	return _c
 }
 
-// MessagesEOF provides a mock function with given fields: ctx, sess, seq
-func (_m *mockICQService) MessagesEOF(ctx context.Context, sess *state.Session, seq uint16) error {
+// OfflineMsgReq provides a mock function with given fields: ctx, sess, seq
+func (_m *mockICQService) OfflineMsgReq(ctx context.Context, sess *state.Session, seq uint16) error {
 	ret := _m.Called(ctx, sess, seq)
 
 	if len(ret) == 0 {
-		panic("no return value specified for MessagesEOF")
+		panic("no return value specified for OfflineMsgReq")
 	}
 
 	var r0 error
@@ -287,32 +335,32 @@ func (_m *mockICQService) MessagesEOF(ctx context.Context, sess *state.Session,
 	return r0
 }
 
-// mockICQService_MessagesEOF_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MessagesEOF'
-type mockICQService_MessagesEOF_Call struct {
+// mockICQService_OfflineMsgReq_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OfflineMsgReq'
+type mockICQService_OfflineMsgReq_Call struct {
 	*mock.Call
 }
 
-// MessagesEOF is a helper method to define mock.On call
+// OfflineMsgReq is a helper method to define mock.On call
 //   - ctx context.Context
 //   - sess *state.Session
 //   - seq uint16
-func (_e *mockICQService_Expecter) MessagesEOF(ctx interface{}, sess interface{}, seq interface{}) *mockICQService_MessagesEOF_Call {
-	return &mockICQService_MessagesEOF_Call{Call: _e.mock.On("MessagesEOF", ctx, sess, seq)}
+func (_e *mockICQService_Expecter) OfflineMsgReq(ctx interface{}, sess interface{}, seq interface{}) *mockICQService_OfflineMsgReq_Call {
+	return &mockICQService_OfflineMsgReq_Call{Call: _e.mock.On("OfflineMsgReq", ctx, sess, seq)}
 }
 
-func (_c *mockICQService_MessagesEOF_Call) Run(run func(ctx context.Context, sess *state.Session, seq uint16)) *mockICQService_MessagesEOF_Call {
+func (_c *mockICQService_OfflineMsgReq_Call) Run(run func(ctx context.Context, sess *state.Session, seq uint16)) *mockICQService_OfflineMsgReq_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		run(args[0].(context.Context), args[1].(*state.Session), args[2].(uint16))
 	})
 	return _c
 }
 
-func (_c *mockICQService_MessagesEOF_Call) Return(_a0 error) *mockICQService_MessagesEOF_Call {
+func (_c *mockICQService_OfflineMsgReq_Call) Return(_a0 error) *mockICQService_OfflineMsgReq_Call {
 	_c.Call.Return(_a0)
 	return _c
 }
 
-func (_c *mockICQService_MessagesEOF_Call) RunAndReturn(run func(context.Context, *state.Session, uint16) error) *mockICQService_MessagesEOF_Call {
+func (_c *mockICQService_OfflineMsgReq_Call) RunAndReturn(run func(context.Context, *state.Session, uint16) error) *mockICQService_OfflineMsgReq_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 1 - 0
state/migrations/0008_offline_msgs.down.sql

@@ -0,0 +1 @@
+DROP TABLE offlineMessage;

+ 7 - 0
state/migrations/0008_offline_msgs.up.sql

@@ -0,0 +1,7 @@
+CREATE TABLE offlineMessage
+(
+    sender      VARCHAR(16) NOT NULL,
+    recipient   VARCHAR(16) NOT NULL,
+    message     BLOB NOT NULL,
+    sent        TIMESTAMP NOT NULL
+);

+ 13 - 0
state/user.go

@@ -51,6 +51,12 @@ func (i IdentScreenName) String() string {
 	return i.screenName
 }
 
+// UIN returns a numeric UIN representation of the IdentScreenName.
+func (i IdentScreenName) UIN() uint32 {
+	v, _ := strconv.Atoi(i.screenName)
+	return uint32(v)
+}
+
 // NewIdentScreenName creates a new IdentScreenName.
 func NewIdentScreenName(screenName string) IdentScreenName {
 	str := strings.ReplaceAll(screenName, " ", "")
@@ -421,3 +427,10 @@ func validateICQPassword(pass string) error {
 	}
 	return nil
 }
+
+type OfflineMessage struct {
+	Sender    IdentScreenName
+	Recipient IdentScreenName
+	Message   wire.SNAC_0x04_0x06_ICBMChannelMsgToHost
+	Sent      time.Time
+}

+ 76 - 0
state/user_store.go

@@ -1205,3 +1205,79 @@ func (f SQLiteUserStore) SetBasicInfo(name IdentScreenName, data ICQBasicInfo) e
 	}
 	return nil
 }
+
+// SaveMessage saves an offline message for later retrieval.
+func (f SQLiteUserStore) SaveMessage(offlineMessage OfflineMessage) error {
+	buf := &bytes.Buffer{}
+	if err := wire.MarshalBE(offlineMessage.Message, buf); err != nil {
+		return fmt.Errorf("marshal: %w", err)
+	}
+
+	q := `
+		INSERT INTO offlineMessage (sender, recipient, message, sent)
+		VALUES (?, ?, ?, ?)
+	`
+	_, err := f.db.Exec(
+		q,
+		offlineMessage.Sender.String(),
+		offlineMessage.Recipient.String(),
+		buf.Bytes(),
+		offlineMessage.Sent,
+	)
+	return err
+}
+
+// RetrieveMessages retrieves all offline messages sent to recipient.
+func (f SQLiteUserStore) RetrieveMessages(recip IdentScreenName) ([]OfflineMessage, error) {
+	q := `
+		SELECT 
+		    sender, 
+		    message,
+		    sent
+		FROM offlineMessage
+		WHERE recipient = ?
+	`
+	rows, err := f.db.Query(q, recip.String())
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var messages []OfflineMessage
+
+	for rows.Next() {
+		var sender string
+		var buf []byte
+		var sent time.Time
+		if err := rows.Scan(&sender, &buf, &sent); err != nil {
+			return nil, err
+		}
+
+		var msg wire.SNAC_0x04_0x06_ICBMChannelMsgToHost
+		if err := wire.UnmarshalBE(&msg, bytes.NewBuffer(buf)); err != nil {
+			return nil, fmt.Errorf("unmarshal: %w", err)
+		}
+
+		messages = append(messages, OfflineMessage{
+			Sender:    NewIdentScreenName(sender),
+			Recipient: recip,
+			Message:   msg,
+			Sent:      sent,
+		})
+	}
+
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+
+	return messages, nil
+}
+
+// DeleteMessages deletes all offline messages sent to recipient.
+func (f SQLiteUserStore) DeleteMessages(recip IdentScreenName) error {
+	q := `
+		DELETE FROM offlineMessage WHERE recipient = ?
+	`
+	_, err := f.db.Exec(q, recip.String())
+	return err
+}

+ 124 - 0
state/user_store_test.go

@@ -4,6 +4,7 @@ import (
 	"os"
 	"reflect"
 	"testing"
+	"time"
 
 	"github.com/mk6i/retro-aim-server/wire"
 
@@ -1672,3 +1673,126 @@ func TestSQLiteUserStore_FindByUIN(t *testing.T) {
 		assert.ErrorIs(t, err, ErrNoUser)
 	})
 }
+
+func TestSQLiteUserStore_RetrieveMessages(t *testing.T) {
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	f, err := NewSQLiteUserStore(testFile)
+	assert.NoError(t, err)
+
+	sendTime := time.Now().UTC()
+
+	offlineMessages := []OfflineMessage{
+		{
+			Sender:    NewIdentScreenName("John"),
+			Recipient: NewIdentScreenName("Jack"),
+			Message: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+				Cookie: 1,
+			},
+			Sent: sendTime,
+		},
+		{
+			Sender:    NewIdentScreenName("John"),
+			Recipient: NewIdentScreenName("Anne"),
+			Message: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+				Cookie: 2,
+			},
+			Sent: sendTime,
+		},
+		{
+			Sender:    NewIdentScreenName("John"),
+			Recipient: NewIdentScreenName("Jack"),
+			Message: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+				Cookie: 3,
+			},
+			Sent: sendTime,
+		},
+	}
+
+	for _, msg := range offlineMessages {
+		err = f.SaveMessage(msg)
+		assert.NoError(t, err)
+	}
+
+	t.Run("Retrieve Messages", func(t *testing.T) {
+		messages, err := f.RetrieveMessages(NewIdentScreenName("Jack"))
+		assert.NoError(t, err)
+		if assert.Len(t, messages, 2) {
+			assert.Equal(t, offlineMessages[0], messages[0])
+			assert.Equal(t, offlineMessages[2], messages[1])
+		}
+	})
+
+	t.Run("Retrieve No Messages", func(t *testing.T) {
+		messages, err := f.RetrieveMessages(NewIdentScreenName("Franke"))
+		assert.NoError(t, err)
+		assert.Empty(t, messages)
+	})
+}
+
+func TestSQLiteUserStore_DeleteMessages(t *testing.T) {
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	f, err := NewSQLiteUserStore(testFile)
+	assert.NoError(t, err)
+
+	sendTime := time.Now().UTC()
+
+	offlineMessages := []OfflineMessage{
+		{
+			Sender:    NewIdentScreenName("John"),
+			Recipient: NewIdentScreenName("Jack"),
+			Message: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+				Cookie: 1,
+			},
+			Sent: sendTime,
+		},
+		{
+			Sender:    NewIdentScreenName("John"),
+			Recipient: NewIdentScreenName("Anne"),
+			Message: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+				Cookie: 2,
+			},
+			Sent: sendTime,
+		},
+		{
+			Sender:    NewIdentScreenName("John"),
+			Recipient: NewIdentScreenName("Jack"),
+			Message: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+				Cookie: 3,
+			},
+			Sent: sendTime,
+		},
+	}
+
+	for _, msg := range offlineMessages {
+		err = f.SaveMessage(msg)
+		assert.NoError(t, err)
+	}
+
+	t.Run("Delete Messages", func(t *testing.T) {
+		err := f.DeleteMessages(NewIdentScreenName("Jack"))
+		assert.NoError(t, err)
+
+		messages, err := f.RetrieveMessages(NewIdentScreenName("Jack"))
+		assert.NoError(t, err)
+		assert.Empty(t, messages)
+
+		messages, err = f.RetrieveMessages(NewIdentScreenName("Anne"))
+		assert.NoError(t, err)
+		assert.Len(t, messages, 1)
+	})
+
+	t.Run("Delete No Messages", func(t *testing.T) {
+		err := f.DeleteMessages(NewIdentScreenName("Franke"))
+		assert.NoError(t, err)
+
+		messages, err := f.RetrieveMessages(NewIdentScreenName("Anne"))
+		assert.NoError(t, err)
+		assert.Len(t, messages, 1)
+	})
+}

+ 60 - 12
wire/snacs.go

@@ -510,7 +510,7 @@ const (
 	ICBMTLVAutoResponse   uint16 = 0x04
 	ICBMTLVData           uint16 = 0x05
 	ICBMTLVStore          uint16 = 0x06
-	ICBMTLVICQblob        uint16 = 0x07
+	ICBMTLVICQBlob        uint16 = 0x07
 	ICBMTLVAvatarInfo     uint16 = 0x08
 	ICBMTLVWantAvatar     uint16 = 0x09
 	ICBMTLVMultiUser      uint16 = 0x0A
@@ -527,22 +527,57 @@ const (
 	ICBMMessageEncodingASCII   uint16 = 0x00 // ANSI ASCII -- ISO 646
 	ICBMMessageEncodingUnicode uint16 = 0x02 // ISO 10646.USC-2 Unicode
 	ICBMMessageEncodingLatin1  uint16 = 0x03 // ISO 8859-1
+
+	ICBMExtendedMsgTypePlain    uint8 = 0x01 // Plain text (simple) message
+	ICBMExtendedMsgTypeChat     uint8 = 0x02 // Chat request message
+	ICBMExtendedMsgTypeFileReq  uint8 = 0x03 // File request / file ok message
+	ICBMExtendedMsgTypeURL      uint8 = 0x04 // URL message (0xFE formatted)
+	ICBMExtendedMsgTypeAuthReq  uint8 = 0x06 // Authorization request message (0xFE formatted)
+	ICBMExtendedMsgTypeAuthDeny uint8 = 0x07 // Authorization denied message (0xFE formatted)
+	ICBMExtendedMsgTypeAuthOK   uint8 = 0x08 // Authorization given message (empty)
+	ICBMExtendedMsgTypeServer   uint8 = 0x09 // Message from OSCAR server (0xFE formatted)
+	ICBMExtendedMsgTypeAdded    uint8 = 0x0C // "You-were-added" message (0xFE formatted)
+	ICBMExtendedMsgTypeWWP      uint8 = 0x0D // Web pager message (0xFE formatted)
+	ICBMExtendedMsgTypeExpress  uint8 = 0x0E // Email express message (0xFE formatted)
+	ICBMExtendedMsgTypeContacts uint8 = 0x13 // Contact list message
+	ICBMExtendedMsgTypePlugin   uint8 = 0x1A // Plugin message described by text string
+	ICBMExtendedMsgTypeAutoAway uint8 = 0xE8 // Auto away message
+	ICBMExtendedMsgTypeAutoBusy uint8 = 0xE9 // Auto occupied message
+	ICBMExtendedMsgTypeAutoNA   uint8 = 0xEA // Auto not available message
+	ICBMExtendedMsgTypeAutoDND  uint8 = 0xEB // Auto do not disturb message
+	ICBMExtendedMsgTypeAutoFFC  uint8 = 0xEC // Auto free for chat message
+
+	ICBMChannelIM         uint16 = 0x01
+	ICBMChannelRendezvous uint16 = 0x02
+	ICBMChannelMIME       uint16 = 0x03
+	ICBMChannelICQ        uint16 = 0x04
+	ICBMChannelCoBrowser  uint16 = 0x05
 )
 
-// ICBMFragment represents an ICBM message component.
-type ICBMFragment struct {
+// ICBMCh1Fragment represents an ICBM channel 1 (instant message) message
+// component.
+type ICBMCh1Fragment struct {
 	ID      uint8
 	Version uint8
 	Payload []byte `oscar:"len_prefix=uint16"`
 }
 
-// ICBMMessage represents the text component of an ICBM message.
-type ICBMMessage struct {
+// ICBMCh1Message represents the text component of an ICBM channel 1 (instant
+// message) message.
+type ICBMCh1Message struct {
 	Charset  uint16
 	Language uint16
 	Text     []byte
 }
 
+// ICBMCh4Message represents an ICBM channel 4 (ICQ) message component.
+type ICBMCh4Message struct {
+	UIN         uint32
+	MessageType uint8
+	Flags       uint8
+	Message     string `oscar:"len_prefix=uint16,nullterm"`
+}
+
 type SNAC_0x04_0x02_ICBMAddParameters struct {
 	Channel              uint16
 	ICBMFlags            uint32
@@ -577,8 +612,8 @@ type SNAC_0x04_0x07_ICBMChannelMsgToClient struct {
 
 // ICBMFragmentList creates an ICBM fragment list for an instant message
 // payload.
-func ICBMFragmentList(text string) ([]ICBMFragment, error) {
-	msg := ICBMMessage{
+func ICBMFragmentList(text string) ([]ICBMCh1Fragment, error) {
+	msg := ICBMCh1Message{
 		Charset:  ICBMMessageEncodingASCII,
 		Language: 0, // not clear what this means, but it works
 		Text:     []byte(text),
@@ -588,7 +623,7 @@ func ICBMFragmentList(text string) ([]ICBMFragment, error) {
 		return nil, fmt.Errorf("unable to marshal ICBM message: %w", err)
 	}
 
-	return []ICBMFragment{
+	return []ICBMCh1Fragment{
 		{
 			ID:      5, // 5 = capabilities
 			Version: 1,
@@ -605,14 +640,14 @@ func ICBMFragmentList(text string) ([]ICBMFragment, error) {
 // UnmarshalICBMMessageText extracts message text from an ICBM fragment list.
 // Param b is a slice from TLV wire.ICBMTLVAOLIMData.
 func UnmarshalICBMMessageText(b []byte) (string, error) {
-	var frags []ICBMFragment
+	var frags []ICBMCh1Fragment
 	if err := UnmarshalBE(&frags, bytes.NewBuffer(b)); err != nil {
 		return "", fmt.Errorf("unable to unmarshal ICBM fragment: %w", err)
 	}
 
 	for _, frag := range frags {
 		if frag.ID == 1 { // 1 = message text
-			msg := ICBMMessage{}
+			msg := ICBMCh1Message{}
 			err := UnmarshalBE(&msg, bytes.NewBuffer(frag.Payload))
 			if err != nil {
 				err = fmt.Errorf("unable to unmarshal ICBM message: %w", err)
@@ -1002,7 +1037,7 @@ const (
 	ICQDBQueryMetaReplyXMLData         uint16 = 0x08A2
 )
 
-type SNAC_0x0F_0x02_ICQDBQuery struct {
+type SNAC_0x0F_0x02_BQuery struct {
 	TLVRestBlock
 }
 
@@ -1131,7 +1166,7 @@ type ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo struct {
 	PublishEmail uint8
 }
 
-type SNAC_0x0F_0x02_ICQDBReply struct {
+type SNAC_0x0F_0x02_DBReply struct {
 	TLVRestBlock
 }
 
@@ -1249,6 +1284,19 @@ func (s *ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound) LastResult() {
 	}
 }
 
+type ICQ_0x0041_DBQueryOfflineMsgReply struct {
+	ICQMetadata
+	SenderUIN uint32
+	Year      uint16
+	Month     uint8
+	Day       uint8
+	Hour      uint8
+	Minute    uint8
+	MsgType   uint8
+	Flags     uint8
+	Message   string `oscar:"len_prefix=uint16,nullterm"`
+}
+
 type ICQ_0x0042_DBQueryOfflineMsgReplyLast struct {
 	ICQMetadata
 	DroppedMessages uint8