Skip to content

Commit

Permalink
issue #44 - add endpoint for sending IM
Browse files Browse the repository at this point in the history
  • Loading branch information
mk6i committed Jun 29, 2024
1 parent aa3b21f commit 46b3156
Show file tree
Hide file tree
Showing 8 changed files with 262 additions and 25 deletions.
3 changes: 3 additions & 0 deletions .mockery.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
30 changes: 29 additions & 1 deletion api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
52 changes: 52 additions & 0 deletions server/http/mgmt_api.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package http

import (
"context"
"encoding/base64"
"encoding/json"
"errors"
Expand All @@ -25,6 +26,7 @@ func StartManagementAPI(
chatRoomRetriever ChatRoomRetriever,
chatRoomCreator ChatRoomCreator,
chatSessionRetriever ChatSessionRetriever,
messageRelayer MessageRelayer,
logger *slog.Logger,
) {

Expand All @@ -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)
Expand Down Expand Up @@ -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 changes: 74 additions & 0 deletions server/http/mgmt_api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 changes: 74 additions & 0 deletions server/http/mock_message_relayer_test.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions server/http/types.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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"`
Expand Down Expand Up @@ -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"`
}
40 changes: 17 additions & 23 deletions wire/snacs.go
Original file line number Diff line number Diff line change
Expand Up @@ -546,20 +546,27 @@ 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
Text: []byte(text),
}
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,
Expand All @@ -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)
}

Expand Down

0 comments on commit 46b3156

Please sign in to comment.