Ver Fonte

issue #44 - add endpoint for sending IM

Mike há 2 anos atrás
pai
commit
46b3156737

+ 3 - 0
.mockery.yaml

@@ -34,6 +34,9 @@ packages:
       ChatSessionRetriever:
         config:
           filename: "mock_chat_session_retriever_test.go"
+      MessageRelayer:
+        config:
+          filename: "mock_message_relayer_test.go"
   github.com/mk6i/retro-aim-server/server/oscar/handler:
     interfaces:
       ResponseWriter:

+ 29 - 1
api.yml

@@ -202,4 +202,32 @@ paths:
                             description: Unique identifier of the participant.
                           screen_name:
                             type: string
-                            description: Screen name of the participant.
+                            description: Screen name of the participant.
+
+  /instant-message:
+    post:
+      summary: Send an instant message
+      description: Send an instant message from one user to another. No error is raised if the recipient does not exist or the user is offline. The sender screen name does not need to exist.
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                from:
+                  type: string
+                  description: The screen name of the sender.
+                to:
+                  type: string
+                  description: The screen name of the recipient.
+                text:
+                  type: string
+                  description: The text content of the message.
+      responses:
+        '200':
+          description: Message sent successfully.
+        '400':
+          description: Bad request. Invalid input data.
+        '404':
+          description: User not found.

+ 1 - 1
cmd/server/main.go

@@ -47,7 +47,7 @@ func main() {
 	wg.Add(7)
 
 	go func() {
-		http.StartManagementAPI(cfg, feedbagStore, sessionManager, feedbagStore, feedbagStore, chatSessionManager, logger)
+		http.StartManagementAPI(cfg, feedbagStore, sessionManager, feedbagStore, feedbagStore, chatSessionManager, sessionManager, logger)
 		wg.Done()
 	}()
 	go func(logger *slog.Logger) {

+ 52 - 0
server/http/mgmt_api.go

@@ -1,6 +1,7 @@
 package http
 
 import (
+	"context"
 	"encoding/base64"
 	"encoding/json"
 	"errors"
@@ -25,6 +26,7 @@ func StartManagementAPI(
 	chatRoomRetriever ChatRoomRetriever,
 	chatRoomCreator ChatRoomCreator,
 	chatSessionRetriever ChatSessionRetriever,
+	messageRelayer MessageRelayer,
 	logger *slog.Logger,
 ) {
 
@@ -47,6 +49,9 @@ func StartManagementAPI(
 	mux.HandleFunc("/chat/room/private", func(w http.ResponseWriter, r *http.Request) {
 		privateChatHandler(w, r, chatRoomRetriever, chatSessionRetriever, logger)
 	})
+	mux.HandleFunc("/instant-message", func(w http.ResponseWriter, r *http.Request) {
+		instantMessageHandler(w, r, messageRelayer, logger)
+	})
 
 	addr := net.JoinHostPort(cfg.ApiHost, cfg.ApiPort)
 	logger.Info("starting management API server", "addr", addr)
@@ -388,3 +393,50 @@ func getPrivateChatHandler(w http.ResponseWriter, _ *http.Request, chatRoomRetri
 		return
 	}
 }
+
+func instantMessageHandler(w http.ResponseWriter, r *http.Request, messageRelayer MessageRelayer, logger *slog.Logger) {
+	switch r.Method {
+	case http.MethodPost:
+		postInstantMessageHandler(w, r, messageRelayer, logger)
+	default:
+		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+	}
+}
+
+// postIMHandler handles the POST /instant-message endpoint.
+func postInstantMessageHandler(w http.ResponseWriter, r *http.Request, messageRelayer MessageRelayer, logger *slog.Logger) {
+	input := instantMessage{}
+	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+		http.Error(w, "malformed input", http.StatusBadRequest)
+		return
+	}
+
+	tlv, err := wire.ICBMFragmentList(input.Text)
+	if err != nil {
+		logger.Error("error sending message POST /instant-message", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	msg := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ICBM,
+			SubGroup:  wire.ICBMChannelMsgToClient,
+		},
+		Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+			ChannelID: 1,
+			TLVUserInfo: wire.TLVUserInfo{
+				ScreenName: input.From,
+			},
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLV(wire.ICBMTLVAOLIMData, tlv),
+				},
+			},
+		},
+	}
+	messageRelayer.RelayToScreenName(context.Background(), state.NewIdentScreenName(input.To), msg)
+
+	w.WriteHeader(http.StatusOK)
+	fmt.Fprintln(w, "Message sent successfully.")
+}

+ 74 - 0
server/http/mgmt_api_test.go

@@ -11,8 +11,10 @@ import (
 
 	"github.com/google/uuid"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
 
 	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
 )
 
 func TestSessionHandler_GET(t *testing.T) {
@@ -679,3 +681,75 @@ func TestPrivateChatHandler_GET(t *testing.T) {
 		})
 	}
 }
+
+func TestInstantMessageHandler_POST(t *testing.T) {
+	type relayToScreenNameParams struct {
+		sender    state.IdentScreenName
+		recipient state.IdentScreenName
+		msg       string
+	}
+
+	tt := []struct {
+		name                    string
+		relayToScreenNameParams []relayToScreenNameParams
+		body                    string
+		want                    string
+		statusCode              int
+	}{
+		{
+			name: "send an instant message",
+			relayToScreenNameParams: []relayToScreenNameParams{
+				{
+					sender:    state.NewIdentScreenName("sender_sn"),
+					recipient: state.NewIdentScreenName("recip_sn"),
+					msg:       "hello world!",
+				},
+			},
+			body:       `{"from":"sender_sn","to":"recip_sn","text":"hello world!"}`,
+			want:       `Message sent successfully.`,
+			statusCode: http.StatusOK,
+		},
+		{
+			name:       "with malformed body",
+			body:       `{"screen_name":"userA", "password":"thepassword"`,
+			want:       `malformed input`,
+			statusCode: http.StatusBadRequest,
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			request := httptest.NewRequest(http.MethodPost, "/user", strings.NewReader(tc.body))
+			responseRecorder := httptest.NewRecorder()
+
+			messageRelayer := newMockMessageRelayer(t)
+
+			for _, params := range tc.relayToScreenNameParams {
+				validateSNAC := func(msg wire.SNACMessage) bool {
+					body := msg.Body.(wire.SNAC_0x04_0x07_ICBMChannelMsgToClient)
+					assert.Equal(t, params.sender.String(), body.TLVUserInfo.ScreenName)
+
+					b, ok := body.Slice(wire.ICBMTLVAOLIMData)
+					assert.True(t, ok)
+
+					txt, err := wire.UnmarshalICBMMessageText(b)
+					assert.NoError(t, err)
+					assert.Equal(t, params.msg, txt)
+					return true
+				}
+				messageRelayer.EXPECT().
+					RelayToScreenName(mock.Anything, params.recipient, mock.MatchedBy(validateSNAC))
+			}
+
+			postInstantMessageHandler(responseRecorder, request, messageRelayer, slog.Default())
+
+			if responseRecorder.Code != tc.statusCode {
+				t.Errorf("want status '%d', got '%d'", tc.statusCode, responseRecorder.Code)
+			}
+
+			if strings.TrimSpace(responseRecorder.Body.String()) != tc.want {
+				t.Errorf("want '%s', got '%s'", tc.want, responseRecorder.Body)
+			}
+		})
+	}
+}

+ 74 - 0
server/http/mock_message_relayer_test.go

@@ -0,0 +1,74 @@
+// Code generated by mockery v2.43.2. DO NOT EDIT.
+
+package http
+
+import (
+	context "context"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+)
+
+// mockMessageRelayer is an autogenerated mock type for the MessageRelayer type
+type mockMessageRelayer struct {
+	mock.Mock
+}
+
+type mockMessageRelayer_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockMessageRelayer) EXPECT() *mockMessageRelayer_Expecter {
+	return &mockMessageRelayer_Expecter{mock: &_m.Mock}
+}
+
+// RelayToScreenName provides a mock function with given fields: ctx, screenName, msg
+func (_m *mockMessageRelayer) RelayToScreenName(ctx context.Context, screenName state.IdentScreenName, msg wire.SNACMessage) {
+	_m.Called(ctx, screenName, msg)
+}
+
+// mockMessageRelayer_RelayToScreenName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RelayToScreenName'
+type mockMessageRelayer_RelayToScreenName_Call struct {
+	*mock.Call
+}
+
+// RelayToScreenName is a helper method to define mock.On call
+//   - ctx context.Context
+//   - screenName state.IdentScreenName
+//   - msg wire.SNACMessage
+func (_e *mockMessageRelayer_Expecter) RelayToScreenName(ctx interface{}, screenName interface{}, msg interface{}) *mockMessageRelayer_RelayToScreenName_Call {
+	return &mockMessageRelayer_RelayToScreenName_Call{Call: _e.mock.On("RelayToScreenName", ctx, screenName, msg)}
+}
+
+func (_c *mockMessageRelayer_RelayToScreenName_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName, msg wire.SNACMessage)) *mockMessageRelayer_RelayToScreenName_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(state.IdentScreenName), args[2].(wire.SNACMessage))
+	})
+	return _c
+}
+
+func (_c *mockMessageRelayer_RelayToScreenName_Call) Return() *mockMessageRelayer_RelayToScreenName_Call {
+	_c.Call.Return()
+	return _c
+}
+
+func (_c *mockMessageRelayer_RelayToScreenName_Call) RunAndReturn(run func(context.Context, state.IdentScreenName, wire.SNACMessage)) *mockMessageRelayer_RelayToScreenName_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockMessageRelayer creates a new instance of mockMessageRelayer. 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 newMockMessageRelayer(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockMessageRelayer {
+	mock := &mockMessageRelayer{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 12 - 0
server/http/types.go

@@ -1,9 +1,11 @@
 package http
 
 import (
+	"context"
 	"time"
 
 	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
 )
 
 type ChatRoomRetriever interface {
@@ -30,6 +32,10 @@ type UserManager interface {
 	User(screenName state.IdentScreenName) (*state.User, error)
 }
 
+type MessageRelayer interface {
+	RelayToScreenName(ctx context.Context, screenName state.IdentScreenName, msg wire.SNACMessage)
+}
+
 type userWithPassword struct {
 	state.User
 	Password string `json:"password,omitempty"`
@@ -60,3 +66,9 @@ type chatRoom struct {
 	URL          string       `json:"url"`
 	Participants []userHandle `json:"participants"`
 }
+
+type instantMessage struct {
+	From string `json:"from"`
+	To   string `json:"to"`
+	Text string `json:"text"`
+}

+ 17 - 23
wire/snacs.go

@@ -546,9 +546,16 @@ type SNAC_0x04_0x06_ICBMChannelMsgToHost struct {
 	TLVRestBlock
 }
 
-// ComposeMessage inserts message text into SNAC(0x04,0x06). It populates TLV
-// 0x02 with fragments that contain the message text and requisite metadata.
-func (m *SNAC_0x04_0x06_ICBMChannelMsgToHost) ComposeMessage(text string) error {
+type SNAC_0x04_0x07_ICBMChannelMsgToClient struct {
+	Cookie    uint64
+	ChannelID uint16
+	TLVUserInfo
+	TLVRestBlock
+}
+
+// ICBMFragmentList creates an ICBM fragment list for an instant message
+// payload.
+func ICBMFragmentList(text string) ([]ICBMFragment, error) {
 	msg := ICBMMessage{
 		Charset:  ICBMMessageEncodingASCII,
 		Language: 0, // not clear what this means, but it works
@@ -556,10 +563,10 @@ func (m *SNAC_0x04_0x06_ICBMChannelMsgToHost) ComposeMessage(text string) error
 	}
 	msgBuf := bytes.Buffer{}
 	if err := Marshal(msg, &msgBuf); err != nil {
-		return fmt.Errorf("unable to marshal ICBM message: %w", err)
+		return nil, fmt.Errorf("unable to marshal ICBM message: %w", err)
 	}
 
-	m.Append(NewTLV(ICBMTLVAOLIMData, []ICBMFragment{
+	return []ICBMFragment{
 		{
 			ID:      5, // 5 = capabilities
 			Version: 1,
@@ -570,27 +577,14 @@ func (m *SNAC_0x04_0x06_ICBMChannelMsgToHost) ComposeMessage(text string) error
 			Version: 1,
 			Payload: msgBuf.Bytes(),
 		},
-	}))
-
-	return nil
+	}, nil
 }
 
-type SNAC_0x04_0x07_ICBMChannelMsgToClient struct {
-	Cookie    uint64
-	ChannelID uint16
-	TLVUserInfo
-	TLVRestBlock
-}
-
-// ExtractMessageText extracts the message text from SNAC(0x04,0x07).
-func (s SNAC_0x04_0x07_ICBMChannelMsgToClient) ExtractMessageText() (string, error) {
-	fragment, ok := s.Slice(ICBMTLVAOLIMData)
-	if !ok {
-		return "", errors.New("ICBM message does not contain a fragment")
-	}
-
+// 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
-	if err := Unmarshal(&frags, bytes.NewBuffer(fragment)); err != nil {
+	if err := Unmarshal(&frags, bytes.NewBuffer(b)); err != nil {
 		return "", fmt.Errorf("unable to unmarshal ICBM fragment: %w", err)
 	}