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

webapi: implement offline message receive

Mike 1 день назад
Родитель
Сommit
4dfae4e42c

+ 1 - 0
server/webapi/handlers/messaging.go

@@ -18,6 +18,7 @@ import (
 type ICBMService interface {
 	ChannelMsgToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)
 	ClientEvent(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent) error
+	OfflineRetrieve(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error)
 }
 
 // MessagingHandler handles Web AIM API messaging endpoints

+ 5 - 0
server/webapi/handlers/messaging_test.go

@@ -44,6 +44,11 @@ func (m *MockICBMService) ClientEvent(ctx context.Context, instance *state.Sessi
 	return args.Error(0)
 }
 
+func (m *MockICBMService) OfflineRetrieve(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error) {
+	args := m.Called(ctx, instance, inFrame)
+	return args.Get(0).(wire.SNACMessage), args.Error(1)
+}
+
 // createTestSessionManager creates a WebAPISessionManager with a pre-populated session.
 func createTestSessionManager(screenName string) (*state.WebAPISessionManager, string) {
 	return createTestSessionManagerWithOSCAR(screenName, nil)

+ 22 - 0
server/webapi/handlers/session.go

@@ -24,6 +24,7 @@ type SessionHandler struct {
 	SessionManager   *state.WebAPISessionManager
 	OSCARAuthService AuthService
 	FeedbagService   FeedbagService
+	ICBMService      ICBMService
 	BuddyListManager *BuddyListManager
 	IconSource       BuddyIconSource
 	Logger           *slog.Logger
@@ -383,6 +384,27 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
+	// Drain messages stored while the user was signed off. The service relays them
+	// as ordinary ICBMChannelMsgToClient SNACs stamped with a send time, which the
+	// listener started above turns into offlineIM events. Retrieval deletes them
+	// from the store, so skip it when the client subscribes to neither event that
+	// can carry them rather than dropping the messages on the floor.
+	//
+	// This trails the conversation list queued above because the listener pushes
+	// from its own goroutine. The client rebuilds its list from scratch on the first
+	// "list" it sees, dropping conversations it knows only from an earlier "update",
+	// so a drained message must not reach the queue ahead of that event.
+	if slices.Contains(events, "offlineIM") || slices.Contains(events, "im") {
+		frame := wire.SNACFrame{
+			FoodGroup: wire.ICBM,
+			SubGroup:  wire.ICBMOfflineRetrieve,
+			RequestID: wire.ReqIDFromServer,
+		}
+		if _, err := h.ICBMService.OfflineRetrieve(ctx, instance, frame); err != nil {
+			h.Logger.ErrorContext(ctx, "failed to retrieve offline messages", "err", err.Error())
+		}
+	}
+
 	now := time.Now().Unix()
 
 	// Prepare response

+ 12 - 5
server/webapi/handlers/webapi_event_converter.go

@@ -44,12 +44,19 @@ func ConvertEventForAMF3(event types.Event) map[string]interface{} {
 		}
 
 	case types.EventTypeOfflineIM:
-		if imEvent, ok := event.Data.(types.IMEvent); ok {
-			result["eventData"] = map[string]interface{}{
-				"aimId":     imEvent.Source.AimID,
-				"message":   imEvent.Message,
-				"timestamp": float64(imEvent.Timestamp), // Convert to float64
+		if imEvent, ok := event.Data.(types.OfflineIMEvent); ok {
+			eventData := map[string]interface{}{
+				"aimId":        imEvent.AimID,
+				"message":      imEvent.Message,
+				"timestamp":    imEvent.Timestamp, // Already float64
+				"autoresponse": imEvent.AutoResp,
 			}
+			// The client keys its conversation list and chat-log cache by msgId, so
+			// an event without one collides with every other offline message.
+			if imEvent.MsgID != "" {
+				eventData["msgId"] = imEvent.MsgID
+			}
+			result["eventData"] = eventData
 		} else if dataMap, ok := event.Data.(map[string]interface{}); ok {
 			// Already a map, ensure timestamps are float64
 			if ts, exists := dataMap["timestamp"]; exists {

+ 22 - 0
server/webapi/handlers/webapi_event_converter_test.go

@@ -40,3 +40,25 @@ func TestConvertEventForAMF3_PresenceCarriesBuddyIcon(t *testing.T) {
 		assert.False(t, ok)
 	})
 }
+
+// The AMF3 converter flattens OfflineIMEvent through an explicit allowlist. The
+// client keys its conversation list and chat-log cache by msgId, so an event that
+// loses it collides with every other offline message.
+func TestConvertEventForAMF3_OfflineIM(t *testing.T) {
+	out := ConvertEventForAMF3(types.Event{
+		Type: types.EventTypeOfflineIM,
+		Data: types.OfflineIMEvent{
+			AimID:     "mikekelly",
+			Message:   "sent while you were out",
+			MsgID:     "beefcafe",
+			Timestamp: 1700000000,
+		},
+	})
+
+	eventData := out["eventData"].(map[string]interface{})
+	assert.Equal(t, "mikekelly", eventData["aimId"])
+	assert.Equal(t, "sent while you were out", eventData["message"])
+	assert.Equal(t, "beefcafe", eventData["msgId"])
+	assert.Equal(t, float64(1700000000), eventData["timestamp"])
+	assert.Equal(t, false, eventData["autoresponse"])
+}

+ 1 - 0
server/webapi/server.go

@@ -32,6 +32,7 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		SessionManager:   sessionManager,
 		OSCARAuthService: handler.AuthService,
 		FeedbagService:   handler.FeedbagService,
+		ICBMService:      handler.ICBMService,
 		BuddyListManager: handler.BuddyListManager.(*handlers.BuddyListManager),
 		IconSource:       handler.IconSource,
 		Logger:           logger,

+ 1 - 0
server/webapi/types.go

@@ -16,6 +16,7 @@ type ICBMService interface {
 	EvilRequest(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x08_ICBMEvilRequest) (wire.SNACMessage, error)
 	ParameterQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
 	ClientErr(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x0B_ICBMClientErr) error
+	OfflineRetrieve(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error)
 }
 
 type OServiceService interface {

+ 15 - 0
server/webapi/types/events.go

@@ -58,6 +58,21 @@ type IMEvent struct {
 	AutoResp  bool     `json:"autoresponse,omitempty"`
 }
 
+// OfflineIMEvent represents a message that was stored while the user was signed
+// off and is replayed when they next start a session.
+//
+// The client models this separately from IMEvent: it reads the sender from a bare
+// aimId rather than a source user object, and resolves the display name from the
+// buddy list it already holds. Timestamp is when the sender sent the message, not
+// when it was delivered.
+type OfflineIMEvent struct {
+	AimID     string  `json:"aimId"`
+	Message   string  `json:"message"`
+	MsgID     string  `json:"msgId,omitempty"`
+	Timestamp float64 `json:"timestamp"` // float64 for AMF3 encoding
+	AutoResp  bool    `json:"autoresponse,omitempty"`
+}
+
 // SentIMEvent represents a sent instant message event.
 type SentIMEvent struct {
 	Sender    UserInfo `json:"sender"` // Sender user info

+ 49 - 24
state/webapi_session.go

@@ -324,12 +324,20 @@ func (s *WebAPISession) handleICBMMessage(msg wire.SNACMessage) {
 
 // handleIncomingIM handles incoming instant messages.
 func (s *WebAPISession) handleIncomingIM(msg wire.SNACMessage) {
-	if !s.IsSubscribedTo("im") {
+	body, ok := msg.Body.(wire.SNAC_0x04_0x07_ICBMChannelMsgToClient)
+	if !ok {
 		return
 	}
 
-	body, ok := msg.Body.(wire.SNAC_0x04_0x07_ICBMChannelMsgToClient)
-	if !ok {
+	// A send time is only stamped on a message replayed out of the offline store,
+	// so its presence marks this as a delivery of something sent while the user was
+	// signed off, and carries the moment the sender actually sent it.
+	sentTime, isOffline := body.Uint32BE(wire.ICBMTLVSendTime)
+
+	// An offlineIM subscriber gets the dedicated event; a client that asked for
+	// im only still gets the message, just with no signal that it was stored.
+	asOfflineIM := isOffline && s.IsSubscribedTo("offlineIM")
+	if !asOfflineIM && !s.IsSubscribedTo("im") {
 		return
 	}
 
@@ -358,32 +366,49 @@ func (s *WebAPISession) handleIncomingIM(msg wire.SNACMessage) {
 	// displayId, so the two forms must not be interchanged.
 	partnerDisplay := body.ScreenName
 	partnerAimID := NewIdentScreenName(partnerDisplay).String()
-	nowSec := time.Now().Unix()
-	s.AddStoredIM(partnerAimID, partnerAimID, messageText, msgID, nowSec)
 
-	// Create IM event
-	imEvent := types.IMEvent{
-		Source: types.UserInfo{
-			AimID:     partnerAimID,
-			DisplayID: partnerDisplay,
-			Friendly:  s.aliasFor(NewIdentScreenName(partnerAimID)),
-			UserType:  "aim",
-			State:     "online",
-		},
-		Message:   messageText,
-		MsgID:     msgID,
-		Timestamp: float64(time.Now().Unix()),
-		AutoResp:  autoResponse,
+	// An offline message is logged under the time it was sent, so the stored-IM
+	// history it lands in stays in the order the conversation happened.
+	timestamp := time.Now().Unix()
+	if isOffline {
+		timestamp = int64(sentTime)
 	}
+	s.AddStoredIM(partnerAimID, partnerAimID, messageText, msgID, timestamp)
 
-	s.EventQueue.Push(types.EventTypeIM, imEvent)
-	s.logger.Debug("delivered instant message",
-		"from", partnerDisplay,
-		"to", s.ScreenName)
+	if asOfflineIM {
+		s.EventQueue.Push(types.EventTypeOfflineIM, types.OfflineIMEvent{
+			AimID:     partnerAimID,
+			Message:   messageText,
+			MsgID:     msgID,
+			Timestamp: float64(timestamp),
+			AutoResp:  autoResponse,
+		})
+		s.logger.Debug("delivered offline instant message",
+			"from", partnerDisplay,
+			"to", s.ScreenName,
+			"sent", timestamp)
+	} else {
+		s.EventQueue.Push(types.EventTypeIM, types.IMEvent{
+			Source: types.UserInfo{
+				AimID:     partnerAimID,
+				DisplayID: partnerDisplay,
+				Friendly:  s.aliasFor(NewIdentScreenName(partnerAimID)),
+				UserType:  "aim",
+				State:     "online",
+			},
+			Message:   messageText,
+			MsgID:     msgID,
+			Timestamp: float64(timestamp),
+			AutoResp:  autoResponse,
+		})
+		s.logger.Debug("delivered instant message",
+			"from", partnerDisplay,
+			"to", s.ScreenName)
+	}
 
 	if s.IsSubscribedTo("conversation") {
-		// unread is 0 here, not 1, because the "im" event pushed above already
-		// causes the client to increment its own persisted per-buddy unread
+		// unread is 0 here, not 1, because the "im"/"offlineIM" event pushed above
+		// already causes the client to increment its own persisted per-buddy unread
 		// tally. The "Recent chats" badge is the sum of that persisted tally and
 		// this conversation's unreadCount, so sending 1 here would double-count
 		// the message (badge shows 2 for the first IM). Mirrors the sent-IM path,

+ 95 - 0
state/webapi_session_test.go

@@ -1068,3 +1068,98 @@ func TestWebAPISession_CloseCancelsSessionContext(t *testing.T) {
 
 	assert.ErrorIs(t, sess.ctx.Err(), context.Canceled)
 }
+
+// A message replayed out of the offline store arrives as an ordinary
+// ICBMChannelMsgToClient stamped with a send time. The client models that as its
+// own offlineIM event, keyed by a bare aimId and timestamped when the sender sent
+// it rather than when it was delivered.
+func TestWebAPISession_OfflineIM(t *testing.T) {
+	sentAt := time.Now().Add(-2 * time.Hour).Unix()
+
+	newSession := func(events ...string) *WebAPISession {
+		return &WebAPISession{
+			ScreenName: DisplayScreenName("me"),
+			Events:     events,
+			EventQueue: types.NewEventQueue(10),
+			logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
+		}
+	}
+
+	storedMsg := func(t *testing.T, withSendTime bool) wire.SNACMessage {
+		t.Helper()
+		frags, err := wire.ICBMFragmentList("sent while you were out")
+		require.NoError(t, err)
+		body := wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+			ChannelID:   wire.ICBMChannelIM,
+			TLVUserInfo: wire.TLVUserInfo{ScreenName: "Mike Kelly"},
+		}
+		body.Append(wire.NewTLVBE(wire.ICBMTLVAOLIMData, frags))
+		if withSendTime {
+			body.Append(wire.NewTLVBE(wire.ICBMTLVSendTime, uint32(sentAt)))
+		}
+		return wire.SNACMessage{Body: body}
+	}
+
+	t.Run("send time yields an offlineIM event", func(t *testing.T) {
+		sess := newSession("im", "offlineIM")
+		sess.handleIncomingIM(storedMsg(t, true))
+
+		events := sess.EventQueue.GetAllEvents()
+		require.Len(t, events, 1)
+		assert.Equal(t, types.EventTypeOfflineIM, events[0].Type)
+		offline := events[0].Data.(types.OfflineIMEvent)
+		assert.Equal(t, "mikekelly", offline.AimID)
+		assert.Equal(t, "sent while you were out", offline.Message)
+		assert.NotEmpty(t, offline.MsgID)
+		assert.Equal(t, float64(sentAt), offline.Timestamp)
+	})
+
+	t.Run("no send time yields an im event", func(t *testing.T) {
+		sess := newSession("im", "offlineIM")
+		sess.handleIncomingIM(storedMsg(t, false))
+
+		events := sess.EventQueue.GetAllEvents()
+		require.Len(t, events, 1)
+		assert.Equal(t, types.EventTypeIM, events[0].Type)
+	})
+
+	// Retrieval deletes the message from the store, so a client that subscribes to
+	// im alone still has to receive it.
+	t.Run("falls back to im when offlineIM is not subscribed", func(t *testing.T) {
+		sess := newSession("im")
+		sess.handleIncomingIM(storedMsg(t, true))
+
+		events := sess.EventQueue.GetAllEvents()
+		require.Len(t, events, 1)
+		assert.Equal(t, types.EventTypeIM, events[0].Type)
+		assert.Equal(t, float64(sentAt), events[0].Data.(types.IMEvent).Timestamp)
+	})
+
+	t.Run("offlineIM subscriber gets a conversation update", func(t *testing.T) {
+		sess := newSession("offlineIM", "conversation")
+		sess.handleIncomingIM(storedMsg(t, true))
+
+		events := sess.EventQueue.GetAllEvents()
+		require.Len(t, events, 2)
+		assert.Equal(t, types.EventTypeOfflineIM, events[0].Type)
+		assert.Equal(t, types.EventTypeConversation, events[1].Type)
+	})
+
+	// The history the client pulls with fetchStoredIMs has to order the message by
+	// when it was sent, not when the session that drained the store started.
+	t.Run("logs the message under its send time", func(t *testing.T) {
+		sess := newSession("offlineIM")
+		sess.handleIncomingIM(storedMsg(t, true))
+
+		stored := sess.GetStoredIMs(StoredIMQuery{PartnerAimID: "mikekelly", NToGet: 10})
+		require.Len(t, stored, 1)
+		assert.Equal(t, float64(sentAt), stored[0]["date"])
+	})
+
+	t.Run("no subscription drops the message", func(t *testing.T) {
+		sess := newSession("presence")
+		sess.handleIncomingIM(storedMsg(t, true))
+
+		assert.Empty(t, sess.EventQueue.GetAllEvents())
+	})
+}