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

webapi: send offline messages when user is offline

Mike 1 день тому
батько
коміт
9845103ffd

+ 35 - 9
server/webapi/handlers/messaging.go

@@ -63,6 +63,9 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request, sess *
 
 
 	// Parse optional parameters
 	// Parse optional parameters
 	autoResponse := queryOrFormParam(r, "autoResponse") == "1"
 	autoResponse := queryOrFormParam(r, "autoResponse") == "1"
+	// The client sets offlineIM once it believes the recipient is offline and
+	// storable; it sends the literal "true" rather than "1".
+	offlineIM := queryOrFormParam(r, "offlineIM") == "true" || queryOrFormParam(r, "offlineIM") == "1"
 
 
 	// Generate message cookie
 	// Generate message cookie
 	var cookie [8]byte
 	var cookie [8]byte
@@ -87,9 +90,7 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request, sess *
 	// The client sends t as the normalized aimId it keys the conversation by, so
 	// The client sends t as the normalized aimId it keys the conversation by, so
 	// it is never a source of display names.
 	// it is never a source of display names.
 	recipientIdent := state.NewIdentScreenName(recipient)
 	recipientIdent := state.NewIdentScreenName(recipient)
-	sess.AddStoredIM(recipientIdent.String(), sess.ScreenName.IdentScreenName().String(), message, messageID, nowSec)
 
 
-	// Recipient is online, deliver message
 	clientIM := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
 	clientIM := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
 		Cookie:       cookieUint64,
 		Cookie:       cookieUint64,
 		ChannelID:    wire.ICBMChannelIM,
 		ChannelID:    wire.ICBMChannelIM,
@@ -111,6 +112,12 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request, sess *
 		clientIM.Append(wire.NewTLVBE(wire.ICBMTLVAutoResponse, []byte{}))
 		clientIM.Append(wire.NewTLVBE(wire.ICBMTLVAutoResponse, []byte{}))
 	}
 	}
 
 
+	// Without this directive the ICBM service rejects messages to offline
+	// recipients instead of storing them.
+	if offlineIM {
+		clientIM.Append(wire.NewTLVBE(wire.ICBMTLVStore, []byte{}))
+	}
+
 	frame := wire.SNACFrame{
 	frame := wire.SNACFrame{
 		FoodGroup: wire.ICBM,
 		FoodGroup: wire.ICBM,
 		SubGroup:  wire.ICBMChannelMsgToHost,
 		SubGroup:  wire.ICBMChannelMsgToHost,
@@ -130,24 +137,30 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request, sess *
 				switch errSn.Code {
 				switch errSn.Code {
 				case wire.ErrorCodeNotLoggedOn:
 				case wire.ErrorCodeNotLoggedOn:
 					subCode, hasSubCode := errSn.Uint16BE(wire.ErrorTLVErrorSubcode)
 					subCode, hasSubCode := errSn.Uint16BE(wire.ErrorTLVErrorSubcode)
-					if hasSubCode {
-						if subCode == wire.ICBMSubErrOfflineIMExceedMax {
-							h.Logger.DebugContext(r.Context(), "user's offline messages full")
-						}
+					if hasSubCode && subCode == wire.ICBMSubErrOfflineIMExceedMax {
+						h.Logger.DebugContext(ctx, "user's offline messages full")
+						h.sendUndeliverable(w, r, "recipient's offline message store is full")
 					} else {
 					} else {
-						h.Logger.DebugContext(r.Context(), "recipient offline")
+						h.Logger.DebugContext(ctx, "recipient offline")
+						h.sendUndeliverable(w, r, "recipient is offline and cannot receive offline messages")
 					}
 					}
 					return
 					return
 				case wire.ErrorCodeInLocalPermitDeny:
 				case wire.ErrorCodeInLocalPermitDeny:
-					h.Logger.DebugContext(r.Context(), "you blocked this user")
+					h.Logger.DebugContext(ctx, "you blocked this user")
+					h.sendUndeliverable(w, r, "you have blocked this user")
 					return
 					return
 				}
 				}
 			}
 			}
+			h.Logger.DebugContext(ctx, "message rejected by ICBM service")
+			h.sendUndeliverable(w, r, "failed to send message")
+			return
 		case resp.Frame.FoodGroup == wire.ICBM && resp.Frame.SubGroup == wire.ICBMHostAck:
 		case resp.Frame.FoodGroup == wire.ICBM && resp.Frame.SubGroup == wire.ICBMHostAck:
-			h.Logger.DebugContext(r.Context(), "received host ack")
+			h.Logger.DebugContext(ctx, "received host ack")
 		}
 		}
 	}
 	}
 
 
+	sess.AddStoredIM(recipientIdent.String(), sess.ScreenName.IdentScreenName().String(), message, messageID, nowSec)
+
 	recipientDisplay := h.resolveDisplayName(ctx, sess.OSCARSession, recipientIdent)
 	recipientDisplay := h.resolveDisplayName(ctx, sess.OSCARSession, recipientIdent)
 	// The alias lives in the sender's feedbag, so unlike the display name it cannot
 	// The alias lives in the sender's feedbag, so unlike the display name it cannot
 	// be read off a locate reply.
 	// be read off a locate reply.
@@ -172,6 +185,19 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request, sess *
 	SendResponse(w, r, response, h.Logger)
 	SendResponse(w, r, response, h.Logger)
 }
 }
 
 
+// sendUndeliverable reports an IM the server accepted but could not deliver.
+//
+// The status travels in the envelope with HTTP 200, because the web client reads
+// response.statusCode and only recognizes 602/603 as "recipient offline or
+// blocked". Any other code, and an empty body most of all, falls through to its
+// generic "Bummer. Your message failed." alert.
+func (h *MessagingHandler) sendUndeliverable(w http.ResponseWriter, r *http.Request, statusText string) {
+	response := BaseResponse{}
+	response.Response.StatusCode = 602
+	response.Response.StatusText = statusText
+	SendResponse(w, r, response, h.Logger)
+}
+
 // resolveDisplayName returns the recipient's screen name as they formatted it,
 // resolveDisplayName returns the recipient's screen name as they formatted it,
 // or "" when it cannot be determined because they are offline or blocked.
 // or "" when it cannot be determined because they are offline or blocked.
 func (h *MessagingHandler) resolveDisplayName(ctx context.Context, instance *state.SessionInstance, recipient state.IdentScreenName) string {
 func (h *MessagingHandler) resolveDisplayName(ctx context.Context, instance *state.SessionInstance, recipient state.IdentScreenName) string {

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

@@ -274,6 +274,97 @@ func TestMessagingHandler_SendIM(t *testing.T) {
 	}
 	}
 }
 }
 
 
+// The ICBM service refuses to store a message for an offline recipient unless the
+// store directive is present, so the client's offlineIM flag has to become one.
+func TestMessagingHandler_SendIM_OfflineIMSetsStoreTLV(t *testing.T) {
+	oscarInstance := state.NewSession().AddInstance()
+	icbmService := &MockICBMService{}
+
+	var sent wire.SNAC_0x04_0x06_ICBMChannelMsgToHost
+	icbmService.On("ChannelMsgToHost", mock.Anything, oscarInstance, mock.Anything, mock.Anything).
+		Run(func(args mock.Arguments) {
+			sent = args.Get(3).(wire.SNAC_0x04_0x06_ICBMChannelMsgToHost)
+		}).
+		Return(nil, nil)
+
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+	handler := &MessagingHandler{
+		SessionManager: sessionMgr,
+		ICBMService:    icbmService,
+		LocateService:  stubLocateService(""),
+		FeedbagService: stubFeedbagService("recipient", ""),
+		Logger:         slog.Default(),
+	}
+
+	req, err := http.NewRequest("GET", "/im/sendIM?aimsid="+aimsid+"&t=recipient&message=hi&offlineIM=true", nil)
+	require.NoError(t, err)
+	rr := httptest.NewRecorder()
+	requireSession(handler.SessionManager, handler.SendIM).ServeHTTP(rr, req)
+
+	require.Equal(t, http.StatusOK, rr.Code)
+	_, hasStore := sent.Bytes(wire.ICBMTLVStore)
+	assert.True(t, hasStore)
+	icbmService.AssertExpectations(t)
+}
+
+// An undeliverable IM must still produce an envelope: the web client reads
+// response.statusCode, and an empty body leaves it with no status at all.
+func TestMessagingHandler_SendIM_UndeliverableReportsStatus(t *testing.T) {
+	tests := []struct {
+		name string
+		errs wire.SNACError
+	}{
+		{
+			name: "RecipientOffline",
+			errs: wire.SNACError{Code: wire.ErrorCodeNotLoggedOn},
+		},
+		{
+			name: "OfflineInboxFull",
+			errs: wire.SNACError{
+				Code: wire.ErrorCodeNotLoggedOn,
+				TLVRestBlock: wire.TLVRestBlock{TLVList: wire.TLVList{
+					wire.NewTLVBE(wire.ErrorTLVErrorSubcode, wire.ICBMSubErrOfflineIMExceedMax),
+				}},
+			},
+		},
+		{
+			name: "SenderBlockedRecipient",
+			errs: wire.SNACError{Code: wire.ErrorCodeInLocalPermitDeny},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			oscarInstance := state.NewSession().AddInstance()
+			icbmService := &MockICBMService{}
+			icbmService.On("ChannelMsgToHost", mock.Anything, oscarInstance, mock.Anything, mock.Anything).
+				Return(&wire.SNACMessage{
+					Frame: wire.SNACFrame{FoodGroup: wire.ICBM, SubGroup: wire.ICBMErr},
+					Body:  tt.errs,
+				}, nil)
+
+			sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+			handler := &MessagingHandler{
+				SessionManager: sessionMgr,
+				ICBMService:    icbmService,
+				LocateService:  stubLocateService(""),
+				FeedbagService: stubFeedbagService("recipient", ""),
+				Logger:         slog.Default(),
+			}
+
+			req, err := http.NewRequest("GET", "/im/sendIM?aimsid="+aimsid+"&f=json&t=recipient&message=hi", nil)
+			require.NoError(t, err)
+			rr := httptest.NewRecorder()
+			requireSession(handler.SessionManager, handler.SendIM).ServeHTTP(rr, req)
+
+			require.Equal(t, http.StatusOK, rr.Code)
+			assert.Contains(t, rr.Body.String(), `"statusCode":602`)
+			assert.NotContains(t, rr.Body.String(), `"msgId"`)
+			icbmService.AssertExpectations(t)
+		})
+	}
+}
+
 func TestMessagingHandler_SendIM_POST(t *testing.T) {
 func TestMessagingHandler_SendIM_POST(t *testing.T) {
 	oscarInstance := state.NewSession().AddInstance()
 	oscarInstance := state.NewSession().AddInstance()
 	icbmService := &MockICBMService{}
 	icbmService := &MockICBMService{}