From 59d2e4e4212efeb96d52f726079a599bbbe16f1a Mon Sep 17 00:00:00 2001 From: Joy <56365512+joyguptaa@users.noreply.github.com> Date: Sun, 2 Feb 2025 00:48:43 +0530 Subject: [PATCH] Dev to Main Sync (#42) * Implemented Queue (#36) * Chore : Updated .env.example * Feat : Added rabbitmq * Feat : Added exponential retry method * Feat: Added DTO for message * Bug : Fixed ExponentialBackoffRetry function * Feat : Added ExponentialBackoffRetry helper * Feat : Added Queue code * Test : Updated Test * Feat : Updated config to load queue name * Feat : Added QUEUE_URL in env * Doc : Updated readme * Chore : Updated order * Chore : Fixed broken test and added error handling * Feat : Added queue handler (#37) * Chore : Updated .env.example * Feat : Added rabbitmq * Feat : Added exponential retry method * Feat: Added DTO for message * Bug : Fixed ExponentialBackoffRetry function * Feat : Added ExponentialBackoffRetry helper * Feat : Added Queue code * Test : Updated Test * Feat : Updated config to load queue name * Feat : Added QUEUE_URL in env * Doc : Updated readme * Chore : Updated order * Chore : Fixed broken test and added error handling * Feat : Added queue handler * Add QUEUE_URL and QUEUE_NAME env (#39) * Chore : Updated .env.example * Feat : Added rabbitmq * Feat : Added exponential retry method * Feat: Added DTO for message * Bug : Fixed ExponentialBackoffRetry function * Feat : Added ExponentialBackoffRetry helper * Feat : Added Queue code * Test : Updated Test * Feat : Updated config to load queue name * Feat : Added QUEUE_URL in env * Doc : Updated readme * Chore : Updated order * Chore : Fixed broken test and added error handling * Feat : Added queue handler * chore: add queuename and url env --------- Co-authored-by: Joy * replace env to vars (#41) * Feat/listening command (#43) * Feat : Added listening command * Feat : Added helper to DataPacket * Feat : Updated SendMessage Code * Test : Fixed test for DataPacket * Feat : Added listening service * Chore : Updated function declaration * Chore : Updated InteractionResponseData * Feat : Addded end-to-end implementation of listening command * Feat : Build common package for discord * WIP : Creating methods for discord * Test : Added test for commandHandler.MainHandler in QueueHandler * Test : Added test for QueueHandler * Test : Added test for MainHandler * Test : Added test * Test : Added test * Chore : Removed consoles * Chore : Removed fmt * Chore : Minor change * Chore : Minor change * feat : Moved register command flow within main.go (#45) * Feat : Moved register command flow within main.go * Refactor : Updated vars & functions name * Update commands/register/register.go Co-authored-by: Yash Raj <56453897+yesyash@users.noreply.github.com> * Chore : Minor change * Chore : Minor change --------- Co-authored-by: Amit Prakash <34869115+iamitprakash@users.noreply.github.com> Co-authored-by: Yash Raj <56453897+yesyash@users.noreply.github.com> --------- Co-authored-by: Prakash Choudhary <34452139+prakashchoudhary07@users.noreply.github.com> Co-authored-by: Amit Prakash <34869115+iamitprakash@users.noreply.github.com> Co-authored-by: Yash Raj <56453897+yesyash@users.noreply.github.com> --- .env.example | 8 +- .github/workflows/deploy.yml | 2 + Makefile | 5 - README.md | 26 ++++- SETUP.md | 18 +-- commands/handlers/listeningHandler.go | 19 +++ commands/handlers/listeningHandler_test.go | 63 ++++++++++ commands/handlers/main.go | 71 ++++++++++++ commands/handlers/main_test.go | 79 +++++++++++++ commands/main.go | 12 ++ commands/main/register.go | 47 ++------ commands/main/resgister_test.go | 51 ++------- commands/register/register.go | 44 +++++++ commands/register/register_test.go | 100 ++++++++++++++++ config/config.go | 6 + controllers/queueHandler.go | 29 +++++ controllers/queueHandler_test.go | 64 +++++++++++ dtos/queue.go | 31 +++++ go.mod | 1 + go.sum | 2 + main.go | 4 + models/discord.go | 30 +++++ models/discord_test.go | 43 +++++++ queue/main.go | 114 ++++++++++++++++++ queue/main_test.go | 119 +++++++++++++++++++ routes/baseRoute.go | 1 + routes/main.go | 2 +- service/listeningService.go | 57 +++++++++ service/listeningService_test.go | 127 +++++++++++++++++++++ service/main.go | 3 +- tests/helpers/config.go | 2 + utils/constants.go | 4 + utils/helper.go | 23 ++++ utils/helper_test.go | 58 ++++++++++ 34 files changed, 1160 insertions(+), 105 deletions(-) create mode 100644 commands/handlers/listeningHandler.go create mode 100644 commands/handlers/listeningHandler_test.go create mode 100644 commands/handlers/main.go create mode 100644 commands/handlers/main_test.go create mode 100644 commands/register/register.go create mode 100644 commands/register/register_test.go create mode 100644 controllers/queueHandler.go create mode 100644 controllers/queueHandler_test.go create mode 100644 dtos/queue.go create mode 100644 models/discord.go create mode 100644 models/discord_test.go create mode 100644 queue/main.go create mode 100644 queue/main_test.go create mode 100644 service/listeningService.go create mode 100644 service/listeningService_test.go create mode 100644 utils/constants.go create mode 100644 utils/helper.go create mode 100644 utils/helper_test.go diff --git a/.env.example b/.env.example index 0a185e0..7a6f196 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,6 @@ -PORT = 8999 -DISCORD_PUBLIC_KEY = "" \ No newline at end of file +PORT = "" # Default :8999 +DISCORD_PUBLIC_KEY = "" +GUILD_ID = "" +BOT_TOKEN = "" +DISCORD_QUEUE = "" # Default :DISCORD_QUEUE +QUEUE_URL = "" # Default :amqp://localhost diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9418d7f..e01d485 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -55,4 +55,6 @@ jobs: -e DISCORD_PUBLIC_KEY=${{secrets.DISCORD_PUBLIC_KEY}} \ -e BOT_TOKEN=${{secrets.BOT_TOKEN}} \ -e GUILD_ID=${{secrets.GUILD_ID}} \ + -e QUEUE_NAME=${{vars.QUEUE_NAME}} \ + -e QUEUE_URL=${{vars.QUEUE_URL}} \ ${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }} diff --git a/Makefile b/Makefile index 0a8cee4..db4fcdb 100644 --- a/Makefile +++ b/Makefile @@ -37,11 +37,6 @@ clean: @rm -rf coverage.out @rm -rf coverage.html -register: - @echo "Registering commands..." - @go run commands/main/register.go - @echo "Registration complete." - test-cover: ifeq ($(FORCE),1) @echo "Force flag detected. Cleaning before tests..." diff --git a/README.md b/README.md index 4d21c96..7f98d8f 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,31 @@ Before running the project, ensure that you have the following installed: To install Air, follow the installation steps here: [Air Installation Guide](https://github.com/air-verse/air) +## Running RabbitMQ with Docker + +1. Ensure Docker is installed and running on your machine. +2. Navigate to the project directory. +3. Create a `docker-compose.yml` file with the following content: + + ```yaml + version: '3.8' + + services: + rabbitmq: + image: rabbitmq:3.13-management + container_name: rabbitmq + ports: + - '5672:5672' + - '15672:15672' + ``` + +4. Start the RabbitMQ container: + + ```sh + docker-compose up -d + ``` + +5. Verify that RabbitMQ is running by accessing the management interface at [http://localhost:15672](http://localhost:15672). The default username and password are both `guest`. ## Running the Project Using Go @@ -62,7 +87,6 @@ Before running the project, ensure that you have the following installed: air ``` - ## Running the Project Using Make You can run the project using the `Makefile`, which provides several commands for various tasks. Below are the steps to run the project: diff --git a/SETUP.md b/SETUP.md index e23d7ca..fd3f7e0 100644 --- a/SETUP.md +++ b/SETUP.md @@ -3,49 +3,34 @@ To setup a discord you first need to create an application (basically a bot), here are the steps to follow: 1. Visit [Discord Application](https://discord.com/developers/applications 'Discord Application') - 2. Cick on "New Application" Button ![Screenshot 2024-11-08 at 1 52 53 AM](https://github.com/user-attachments/assets/380657ca-89b4-4053-96c9-6b73632d382c) - 3. Fill in your Application Name ![Screenshot 2024-11-08 at 1 52 53 AM](https://github.com/user-attachments/assets/688bd69d-fcca-4a80-8780-9ab18bfc5037) 4. Now [here](https://discord.com/developers/applications) you will see your newly created application, click on it, this should open _"General Information"_ - 5. If you scroll down, you will se _PUBLIC KEY_, copy it and place it `.env` as _DISCORD_PUBLIC_KEY_ - 6. Now to create BOT_TOKEN, click on BOT > Reset Token ![Screenshot 2024-11-08 at 2 07 40 AM](https://github.com/user-attachments/assets/201f9e51-a44a-43af-9c96-4eaf453d02b0) - 7. Once you have the token, place it against _BOT_TOKEN_ in `.env` - 8. Now will be creating an invite URL and for that you need to click on OAuth2 > bot ![Screenshot 2024-11-13 at 11 40 21 PM](https://github.com/user-attachments/assets/aebad7fe-aa82-45de-bb17-25dc0fff0e5f) - 9. Now as soon as you click on bot, a section to choose bot permission from, will shown up ![Screenshot 2024-11-14 at 10 53 20 AM](https://github.com/user-attachments/assets/b6fc4afb-4de4-449c-bf39-f8a0b4d3de06) - 10. Check the following options 1. [ ] Send Messages 11. Once you select all the bot permissions, scroll a bit down and you will see "Generated URL" ![Screenshot 2024-11-14 at 10 58 30 AM](https://github.com/user-attachments/assets/bbff4c6d-4ef5-46fd-89c7-9acf31c11cdd) - 12. Copy and paste that URL in browser, a prompt will come up where it will ask you to select you own "Discord Server" ![Screenshot 2024-11-14 at 11 00 45 AM](https://github.com/user-attachments/assets/322caf6d-af84-4752-88db-0ce64e080d6d) - 13. Once you add the Bot into your server, copy the "Server Id", by right clicking on the server avatar. Now place this id in `.env` against _GUILD_ID_ # Connecting Discord Service with Discord Now as you have created the discord bot, now its time to connect it with discord service using the following steps: -1. Even before setting up the connection, you would need to register the commands first. That can be done using the following - - ```bash - make register #or go run commands/main/register.go - ``` - +1. You would need to register the commands first. That will be auto handled once you start the server 2. Now start the server using ```bash @@ -62,5 +47,4 @@ Since we are considering 8999 as default port for this service. If you wish to c 4. Copy the Ngrok URL and open the General Information on [Discord Developer Portal](https://discord.com/developers/applications) of your bot, paste the copied URL in Interactions Endpoint URL ![Screenshot 2024-11-14 at 10 58 30 AM](https://github.com/user-attachments/assets/53f372e4-44e7-4cdc-acfc-0e3b707f8607) - 5. All Set 🚀🚀🚀. Now you can start with running hello command diff --git a/commands/handlers/listeningHandler.go b/commands/handlers/listeningHandler.go new file mode 100644 index 0000000..1846af1 --- /dev/null +++ b/commands/handlers/listeningHandler.go @@ -0,0 +1,19 @@ +package handlers + +import ( + "fmt" + "strings" + + "github.com/Real-Dev-Squad/discord-service/utils" +) + +func (s *CommandHandler) listeningHandler() error { + metaData := s.discordMessage.MetaData + nickName := metaData["nickname"] + if metaData["value"] == "true" { + nickName = fmt.Sprintf("%s%s%s", utils.NICKNAME_PREFIX, nickName, utils.NICKNAME_SUFFIX) + } else { + nickName = strings.TrimPrefix(strings.TrimSuffix(nickName, utils.NICKNAME_SUFFIX), utils.NICKNAME_PREFIX) + } + return UpdateNickName(s.discordMessage.UserID, nickName) +} diff --git a/commands/handlers/listeningHandler_test.go b/commands/handlers/listeningHandler_test.go new file mode 100644 index 0000000..f056909 --- /dev/null +++ b/commands/handlers/listeningHandler_test.go @@ -0,0 +1,63 @@ +package handlers + +import ( + "testing" + + "github.com/Real-Dev-Squad/discord-service/dtos" + _ "github.com/Real-Dev-Squad/discord-service/tests/helpers" + "github.com/Real-Dev-Squad/discord-service/utils" + "github.com/stretchr/testify/assert" +) + +type MockCommandHandler struct { + discordMessage *dtos.DataPacket +} + +func TestListeningHandler(t *testing.T) { + + t.Run("should update nickname with prefix and suffix if value is true", func(t *testing.T) { + + dataPacket := &dtos.DataPacket{ + UserID: "userID", + MetaData: map[string]string{ + "nickname": "testNick", + "value": "true", + }, + } + + handler := &CommandHandler{discordMessage: dataPacket} + err := handler.listeningHandler() + assert.Error(t, err) + }) + + t.Run("should update nickname without prefix and suffix if value is false", func(t *testing.T) { + + dataPacket := &dtos.DataPacket{ + UserID: "userID", + MetaData: map[string]string{ + "nickname": utils.NICKNAME_PREFIX + "testNick" + utils.NICKNAME_SUFFIX, + "value": "false", + }, + } + + handler := &CommandHandler{discordMessage: dataPacket} + err := handler.listeningHandler() + assert.Error(t, err) + }) + + t.Run("should return error if UpdateNickName fails", func(t *testing.T) { + + dataPacket := &dtos.DataPacket{ + UserID: "userID", + MetaData: map[string]string{ + "nickname": "testNick", + "value": "true", + }, + } + + handler := &CommandHandler{discordMessage: dataPacket} + err := handler.listeningHandler() + assert.Error(t, err) + assert.Equal(t, "websocket: close 4004: Authentication failed.", err.Error()) + }) +} diff --git a/commands/handlers/main.go b/commands/handlers/main.go new file mode 100644 index 0000000..83c1693 --- /dev/null +++ b/commands/handlers/main.go @@ -0,0 +1,71 @@ +package handlers + +import ( + "errors" + + "github.com/Real-Dev-Squad/discord-service/config" + "github.com/Real-Dev-Squad/discord-service/dtos" + "github.com/Real-Dev-Squad/discord-service/models" + "github.com/bwmarrin/discordgo" + "github.com/sirupsen/logrus" +) + +type CommandHandler struct { + discordMessage *dtos.DataPacket +} + +var CS = CommandHandler{} + +func MainHandler(dataPacket []byte) func() error { + packetData := &dtos.DataPacket{} + err := packetData.FromByte(dataPacket) + if err != nil { + logrus.Errorf("Failed to unmarshal data send by queue: %v", err) + return nil + } + CS.discordMessage = packetData + switch packetData.CommandName { + case "listening": + return CS.listeningHandler + default: + logrus.Warn("Invalid Command Received: ", packetData.CommandName) + return nil + } +} + +type DiscordSession struct { + session *discordgo.Session +} + +var NewDiscord = discordgo.New +var CreateSession = func() (*discordgo.Session, error) { + session, err := NewDiscord("Bot " + config.AppConfig.BOT_TOKEN) + if err != nil { + logrus.Errorf("Cannot create a new Discord session: %v", err) + return nil, err + } + openSession := &models.SessionWrapper{Session: session} + err = openSession.Open() + if err != nil { + logrus.Errorf("Cannot open the session: %v", err) + return nil, err + } + return session, nil +} + +func UpdateNickName(userId string, newNickName string) error { + if len(newNickName) > 32 { + logrus.Error("Must be 32 or fewer in length.") + return errors.New("Must be 32 or fewer in length.") + } + session, err := CreateSession() + if err != nil { + return err + } + err = session.GuildMemberNickname(config.AppConfig.GUILD_ID, userId, newNickName) + if err != nil { + logrus.Errorf("Cannot update nickname: %v", err) + return nil + } + return nil +} diff --git a/commands/handlers/main_test.go b/commands/handlers/main_test.go new file mode 100644 index 0000000..722725e --- /dev/null +++ b/commands/handlers/main_test.go @@ -0,0 +1,79 @@ +package handlers + +import ( + "errors" + "testing" + + "github.com/Real-Dev-Squad/discord-service/dtos" + _ "github.com/Real-Dev-Squad/discord-service/tests/helpers" + "github.com/bwmarrin/discordgo" + "github.com/stretchr/testify/assert" +) + +func TestMainHandler(t *testing.T) { + t.Run("should return listeningHandler for 'listening' command", func(t *testing.T) { + dataPacket := &dtos.DataPacket{ + CommandName: "listening", + } + data, err := dataPacket.ToByte() + assert.NoError(t, err) + + handler := MainHandler(data) + assert.NotNil(t, handler) + }) + t.Run("should return nil for invalid data", func(t *testing.T) { + invalidData := []byte(`{"invalid": "data"}`) + handler := MainHandler(invalidData) + assert.Nil(t, handler) + }) +} + +func TestCreateSession(t *testing.T) { + t.Run("should fail if NewDiscord returns an error", func(t *testing.T) { + originalNewDiscord := NewDiscord + defer func() { NewDiscord = originalNewDiscord }() + NewDiscord = func(token string) (s *discordgo.Session, err error) { + return nil, errors.New("testing error") + } + _, err := CreateSession() + assert.Error(t, err) + }) + t.Run("should initiate open session if NewDiscord returns no error", func(t *testing.T) { + originalNewDiscord := NewDiscord + defer func() { NewDiscord = originalNewDiscord }() + NewDiscord = func(token string) (s *discordgo.Session, err error) { + return &discordgo.Session{}, nil + } + assert.Panics(t, func() { CreateSession() }) + }) +} + +func mockCreateSession() (*discordgo.Session, error) { + return &discordgo.Session{}, nil +} +func TestUpdateNickName(t *testing.T) { + + var originalCreateSession = CreateSession + defer func() { CreateSession = originalCreateSession }() + + t.Run("should return error if newNickName is longer than 32 characters", func(t *testing.T) { + err := UpdateNickName("userID", "ThisIsAVeryLongNicknameThatExceedsTheLimit") + assert.Error(t, err) + assert.Equal(t, "Must be 32 or fewer in length.", err.Error()) + }) + + t.Run("should return error if CreateSession fails", func(t *testing.T) { + CreateSession = func() (*discordgo.Session, error) { + return nil, errors.New("failed to create session") + } + err := UpdateNickName("userID", "validNickname") + assert.Error(t, err) + assert.Equal(t, "failed to create session", err.Error()) + }) + t.Run("should hit GuildMemberNickname if CreateSession succeeds", func(t *testing.T) { + CreateSession = mockCreateSession + assert.Panics(t, func() { UpdateNickName("userID", "validNickname") }) + + }) + +} diff --git a/commands/main.go b/commands/main.go index 89a6780..a281564 100644 --- a/commands/main.go +++ b/commands/main.go @@ -7,4 +7,16 @@ var Commands = []*discordgo.ApplicationCommand{ Name: "hello", Description: "Greets back with hello!", }, + { + Name: "listening", + Description: "mark user as listening", + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "value", + Description: "to enable or disable the listening mode", + Type: 5, + Required: true, + }, + }, + }, } diff --git a/commands/main/register.go b/commands/main/register.go index c99e2b4..24681ac 100644 --- a/commands/main/register.go +++ b/commands/main/register.go @@ -3,68 +3,41 @@ package main import ( constants "github.com/Real-Dev-Squad/discord-service/commands" "github.com/Real-Dev-Squad/discord-service/config" + "github.com/Real-Dev-Squad/discord-service/models" "github.com/bwmarrin/discordgo" "github.com/sirupsen/logrus" ) -type SessionWrapper struct { - session *discordgo.Session -} - -func (s *SessionWrapper) open() error { - return s.session.Open() -} - -func (s *SessionWrapper) close() error { - return s.session.Close() -} - -func (s *SessionWrapper) applicationCommandCreate(applicationID, guildID string, command *discordgo.ApplicationCommand) (*discordgo.ApplicationCommand, error) { - return s.session.ApplicationCommandCreate(applicationID, guildID, command) -} - -func (sw *SessionWrapper) getUerId() string { - return sw.session.State.User.ID -} - -type sessionInterface interface { - open() error - close() error - applicationCommandCreate(applicationID, guildID string, command *discordgo.ApplicationCommand) (*discordgo.ApplicationCommand, error) - getUerId() string -} - -var NewDiscord = discordgo.New +var NewDiscordSession = discordgo.New func main() { - session, err := NewDiscord("Bot " + config.AppConfig.BOT_TOKEN) + session, err := NewDiscordSession("Bot " + config.AppConfig.BOT_TOKEN) if err != nil { - logrus.Error("Cannot create a new Discord session: ") + logrus.Error("Cannot create a new Discord session") panic(err) } session.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) { logrus.Info("Logged in as: ", session.State.User.Username, session.State.User.Discriminator) }) - - sessionWrapper := &SessionWrapper{session: session} + sessionWrapper := &models.SessionWrapper{Session: session} RegisterCommands(sessionWrapper) } -var RegisterCommands = func(openSession sessionInterface) { - err := openSession.open() +var RegisterCommands = func(openSession models.SessionInterface) { + err := openSession.Open() if err != nil { - logrus.Error("Cannot open the session: ") + logrus.Error("Cannot open the session") panic(err) } for _, v := range constants.Commands { - _, err := openSession.applicationCommandCreate(openSession.getUerId(), config.AppConfig.GUILD_ID, v) + _, err := openSession.ApplicationCommandCreate(openSession.GetUerId(), config.AppConfig.GUILD_ID, v) if err != nil { logrus.Error("Cannot create ", v.Name, "command: ", err) panic(err) } } - defer openSession.close() + defer openSession.Close() } diff --git a/commands/main/resgister_test.go b/commands/main/resgister_test.go index 9698048..0279dbb 100644 --- a/commands/main/resgister_test.go +++ b/commands/main/resgister_test.go @@ -3,7 +3,6 @@ package main import ( "testing" - constants "github.com/Real-Dev-Squad/discord-service/commands" _ "github.com/Real-Dev-Squad/discord-service/tests/helpers" "github.com/bwmarrin/discordgo" "github.com/stretchr/testify/assert" @@ -12,17 +11,17 @@ import ( func TestInit(t *testing.T) { t.Run("should panic when SetupConnection returns an error", func(t *testing.T) { - originalNewDiscord := NewDiscord - defer func() { NewDiscord = originalNewDiscord }() - NewDiscord = func(token string) (s *discordgo.Session, err error) { + originalNewDiscord := NewDiscordSession + defer func() { NewDiscordSession = originalNewDiscord }() + NewDiscordSession = func(token string) (s *discordgo.Session, err error) { return nil, assert.AnError } assert.Panics(t, main) }) t.Run("should call AddHandler method of session if SetupConnection succeeds", func(t *testing.T) { - originalNewDiscord := NewDiscord - defer func() { NewDiscord = originalNewDiscord }() - NewDiscord = func(token string) (s *discordgo.Session, err error) { + originalNewDiscord := NewDiscordSession + defer func() { NewDiscordSession = originalNewDiscord }() + NewDiscordSession = func(token string) (s *discordgo.Session, err error) { mockSession := &discordgo.Session{ State: &discordgo.State{}, } @@ -40,23 +39,23 @@ type mockSession struct { getUserIdCalled bool } -func (m *mockSession) open() error { +func (m *mockSession) Open() error { return m.openError } -func (m *mockSession) close() error { +func (m *mockSession) Close() error { m.closeCalled = true return nil } -func (m *mockSession) applicationCommandCreate(applicationID, guildID string, command *discordgo.ApplicationCommand) (*discordgo.ApplicationCommand, error) { +func (m *mockSession) ApplicationCommandCreate(applicationID, guildID string, command *discordgo.ApplicationCommand) (*discordgo.ApplicationCommand, error) { if m.commandError == nil { m.applicationCommandCalled = true } return nil, m.commandError } -func (m *mockSession) getUerId() string { +func (m *mockSession) GetUerId() string { m.getUserIdCalled = true return "" } @@ -99,33 +98,3 @@ func TestRegisterCommands(t *testing.T) { assert.True(t, mockSess.closeCalled) }) } - -func TestSessionWrapper(t *testing.T) { - mockSession := &discordgo.Session{} - sessionWrapper := &SessionWrapper{session: mockSession} - - t.Run("SessionWrapper should always implement open() method", func(t *testing.T) { - assert.Panics(t, func() { - sessionWrapper.open() - }, "should panic when open() is called") - }) - - t.Run("SessionWrapper should always implement close() method", func(t *testing.T) { - assert.NotPanics(t, func() { - sessionWrapper.close() - }, "should not panic when close() is called") - }) - - t.Run("SessionWrapper should always implement applicationCommandCreate() method", func(t *testing.T) { - assert.Panics(t, func() { - sessionWrapper.applicationCommandCreate("1", "2", constants.Commands[0]) - }, "should panic when applicationCommandCreate() is called") - }) - - t.Run("SessionWrapper should always implement getUerId() method", func(t *testing.T) { - assert.Panics(t, func() { - sessionWrapper.getUerId() - }, "should panic when getUerId() is called") - }) - -} diff --git a/commands/register/register.go b/commands/register/register.go new file mode 100644 index 0000000..1ccab14 --- /dev/null +++ b/commands/register/register.go @@ -0,0 +1,44 @@ +package register + +import ( + constants "github.com/Real-Dev-Squad/discord-service/commands" + "github.com/Real-Dev-Squad/discord-service/config" + "github.com/Real-Dev-Squad/discord-service/models" + "github.com/bwmarrin/discordgo" + "github.com/sirupsen/logrus" +) + +var NewDiscord = discordgo.New + +func SetupRegister() { + session, err := NewDiscord("Bot " + config.AppConfig.BOT_TOKEN) + if err != nil { + logrus.Error("Cannot create a new Discord session") + panic(err) + } + + session.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) { + logrus.Info("Logged in as: ", session.State.User.Username, session.State.User.Discriminator) + }) + sessionWrapper := &models.SessionWrapper{Session: session} + RegisterCommands(sessionWrapper) +} + +var RegisterCommands = func(openSession models.SessionInterface) { + err := openSession.Open() + defer openSession.Close() + + if err != nil { + logrus.Error("Cannot open the session") + panic(err) + } + + for _, v := range constants.Commands { + _, err := openSession.ApplicationCommandCreate(openSession.GetUerId(), config.AppConfig.GUILD_ID, v) + if err != nil { + logrus.Error("Cannot create ", v.Name, "command: ", err) + panic(err) + } + } + logrus.Info("Successfully registered commands") +} diff --git a/commands/register/register_test.go b/commands/register/register_test.go new file mode 100644 index 0000000..8780e0e --- /dev/null +++ b/commands/register/register_test.go @@ -0,0 +1,100 @@ +package register + +import ( + "testing" + + _ "github.com/Real-Dev-Squad/discord-service/tests/helpers" + "github.com/bwmarrin/discordgo" + "github.com/stretchr/testify/assert" +) + +func TestInit(t *testing.T) { + + t.Run("should panic when SetupConnection returns an error", func(t *testing.T) { + originalNewDiscord := NewDiscord + defer func() { NewDiscord = originalNewDiscord }() + NewDiscord = func(token string) (s *discordgo.Session, err error) { + return nil, assert.AnError + } + assert.Panics(t, SetupRegister) + }) + t.Run("should call AddHandler method of session if SetupConnection succeeds", func(t *testing.T) { + originalNewDiscord := NewDiscord + defer func() { NewDiscord = originalNewDiscord }() + NewDiscord = func(token string) (s *discordgo.Session, err error) { + mockSession := &discordgo.Session{ + State: &discordgo.State{}, + } + return mockSession, nil + } + assert.Panics(t, SetupRegister) + }) +} + +type mockSession struct { + openError error + commandError error + applicationCommandCalled bool + closeCalled bool + getUserIdCalled bool +} + +func (m *mockSession) Open() error { + return m.openError +} + +func (m *mockSession) Close() error { + m.closeCalled = true + return nil +} + +func (m *mockSession) ApplicationCommandCreate(applicationID, guildID string, command *discordgo.ApplicationCommand) (*discordgo.ApplicationCommand, error) { + if m.commandError == nil { + m.applicationCommandCalled = true + } + return nil, m.commandError +} + +func (m *mockSession) GetUerId() string { + m.getUserIdCalled = true + return "" +} + +func TestRegisterCommands(t *testing.T) { + t.Run("should not panic when Open() returns no error", func(t *testing.T) { + mockSess := &mockSession{openError: nil, commandError: nil} + assert.NotPanics(t, func() { + RegisterCommands(mockSess) + }, "RegisterCommands should not panic when Open is successful") + + }) + t.Run("should panic when Open() returns an error", func(t *testing.T) { + mockSess := &mockSession{openError: assert.AnError, commandError: nil} + assert.Panics(t, func() { + RegisterCommands(mockSess) + }, "RegisterCommands should panic when Open returns an error") + }) + t.Run("should panic when openSession.ApplicationCommandCreate() returns an error", func(t *testing.T) { + mockSess := &mockSession{openError: nil, commandError: assert.AnError} + + assert.Panics(t, func() { + RegisterCommands(mockSess) + }, "RegisterCommands should panic when ApplicationCommandCreate returns an error") + }) + t.Run("should panic when openSession.ApplicationCommandCreate() returns an error", func(t *testing.T) { + mockSess := &mockSession{openError: nil, commandError: assert.AnError} + + assert.Panics(t, func() { + RegisterCommands(mockSess) + }, "RegisterCommands should panic when ApplicationCommandCreate returns an error") + }) + t.Run("should call all methods when none of the methods returns no error", func(t *testing.T) { + mockSess := &mockSession{openError: nil, commandError: nil} + assert.NotPanics(t, func() { + RegisterCommands(mockSess) + }) + assert.True(t, mockSess.applicationCommandCalled) + assert.True(t, mockSess.getUserIdCalled) + assert.True(t, mockSess.closeCalled) + }) +} diff --git a/config/config.go b/config/config.go index da0a0ab..625a8c9 100644 --- a/config/config.go +++ b/config/config.go @@ -13,6 +13,9 @@ type Config struct { DISCORD_PUBLIC_KEY string GUILD_ID string BOT_TOKEN string + QUEUE_URL string + QUEUE_NAME string + MAX_RETRIES int } var AppConfig Config @@ -26,9 +29,12 @@ func init() { AppConfig = Config{ Port: loadEnv("PORT"), + QUEUE_URL: loadEnv("QUEUE_URL"), DISCORD_PUBLIC_KEY: loadEnv("DISCORD_PUBLIC_KEY"), GUILD_ID: loadEnv("GUILD_ID"), BOT_TOKEN: loadEnv("BOT_TOKEN"), + QUEUE_NAME: loadEnv("QUEUE_NAME"), + MAX_RETRIES: 5, } } diff --git a/controllers/queueHandler.go b/controllers/queueHandler.go new file mode 100644 index 0000000..af8e3cd --- /dev/null +++ b/controllers/queueHandler.go @@ -0,0 +1,29 @@ +package controllers + +import ( + "io" + "net/http" + + "github.com/Real-Dev-Squad/discord-service/commands/handlers" + "github.com/Real-Dev-Squad/discord-service/config" + "github.com/Real-Dev-Squad/discord-service/utils" + "github.com/julienschmidt/httprouter" + "github.com/sirupsen/logrus" +) + +func QueueHandler(response http.ResponseWriter, request *http.Request, params httprouter.Params) { + body, err := io.ReadAll(request.Body) + if err != nil { + http.Error(response, "Failed to read request body", http.StatusInternalServerError) + return + } + handler := handlers.MainHandler(body) + if handler != nil { + if err := utils.ExponentialBackoffRetry(config.AppConfig.MAX_RETRIES, handler); err != nil { + logrus.Errorf("Failed to process command after %d attempts: %s", config.AppConfig.MAX_RETRIES, err) + } + } + response.Header().Set("Content-Type", "application/json") + response.WriteHeader(http.StatusOK) + +} diff --git a/controllers/queueHandler_test.go b/controllers/queueHandler_test.go new file mode 100644 index 0000000..0b4ff9c --- /dev/null +++ b/controllers/queueHandler_test.go @@ -0,0 +1,64 @@ +package controllers_test + +import ( + "bytes" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Real-Dev-Squad/discord-service/config" + "github.com/Real-Dev-Squad/discord-service/controllers" + "github.com/Real-Dev-Squad/discord-service/utils" + "github.com/julienschmidt/httprouter" + "github.com/stretchr/testify/assert" +) + +type errorReader struct{} + +func (e *errorReader) Read(p []byte) (n int, err error) { + return 0, errors.New("simulated read error") +} +func TestQueueHandler(t *testing.T) { + + router := httprouter.New() + router.POST("/queue", controllers.QueueHandler) + t.Run("should return 200 OK and log the request body", func(t *testing.T) { + body := []byte(`{"message": "test message"}`) + req, err := http.NewRequest("POST", "/queue", bytes.NewBuffer(body)) + assert.NoError(t, err) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) + }) + t.Run("should be able to execute listening command", func(t *testing.T) { + body := []byte(`{"CommandName": "listening"}`) + req, err := http.NewRequest("POST", "/queue", bytes.NewBuffer(body)) + assert.NoError(t, err) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) + }) + + t.Run("should fail if ExponentialBackoffRetry fails for listening command", func(t *testing.T) { + config.AppConfig.MAX_RETRIES = 1 + originalFunc := utils.ExponentialBackoffRetry + utils.ExponentialBackoffRetry = func(maxRetries int, operation func() error) error { + return errors.New("error") + } + defer func() { utils.ExponentialBackoffRetry = originalFunc }() + body := []byte(`{"CommandName": "listening", "MetaData": {"value": "true", "nickname" : "joy-gupta-1"}}`) + _, err := http.NewRequest("POST", "/queue", bytes.NewBuffer(body)) + assert.NoError(t, err) + }) + + t.Run("should return 500 Internal Server Error if payload is unable to be decoded", func(t *testing.T) { + req, err := http.NewRequest("POST", "/queue", &errorReader{}) + assert.NoError(t, err) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + assert.Equal(t, http.StatusInternalServerError, rr.Code) + }) +} diff --git a/dtos/queue.go b/dtos/queue.go new file mode 100644 index 0000000..1c815fc --- /dev/null +++ b/dtos/queue.go @@ -0,0 +1,31 @@ +package dtos + +import ( + "encoding/json" + + "github.com/sirupsen/logrus" +) + +type DataPacket struct { + UserID string + CommandName string + MetaData map[string]string +} + +func (d *DataPacket) ToByte() ([]byte, error) { + bytes, err := json.Marshal(d) + if err != nil { + logrus.Errorf("Failed to marshal message: %v", err) + return nil, err + } + return bytes, nil +} + +func (d *DataPacket) FromByte(bytes []byte) error { + err := json.Unmarshal(bytes, d) + if err != nil { + logrus.Errorf("Failed to unmarshal message: %v", err) + return err + } + return nil +} diff --git a/go.mod b/go.mod index 2ee5740..e98308c 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rabbitmq/amqp091-go v1.10.0 // indirect golang.org/x/crypto v0.28.0 // indirect golang.org/x/sys v0.26.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 937bf1a..0971de0 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4d github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= +github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= diff --git a/main.go b/main.go index 815fe0e..1f54fd0 100644 --- a/main.go +++ b/main.go @@ -1,12 +1,16 @@ package main import ( + "github.com/Real-Dev-Squad/discord-service/commands/register" config "github.com/Real-Dev-Squad/discord-service/config" + queue "github.com/Real-Dev-Squad/discord-service/queue" "github.com/Real-Dev-Squad/discord-service/routes" "github.com/sirupsen/logrus" ) func main() { + register.SetupRegister() logrus.Info("Starting server on port " + config.AppConfig.Port) + queue.GetQueueInstance() routes.Listen(":" + config.AppConfig.Port) } diff --git a/models/discord.go b/models/discord.go new file mode 100644 index 0000000..08678ea --- /dev/null +++ b/models/discord.go @@ -0,0 +1,30 @@ +package models + +import "github.com/bwmarrin/discordgo" + +type SessionWrapper struct { + Session *discordgo.Session +} + +func (s *SessionWrapper) Open() error { + return s.Session.Open() +} + +func (s *SessionWrapper) Close() error { + return s.Session.Close() +} + +func (s *SessionWrapper) ApplicationCommandCreate(applicationID, guildID string, command *discordgo.ApplicationCommand) (*discordgo.ApplicationCommand, error) { + return s.Session.ApplicationCommandCreate(applicationID, guildID, command) +} + +func (sw *SessionWrapper) GetUerId() string { + return sw.Session.State.User.ID +} + +type SessionInterface interface { + Open() error + Close() error + ApplicationCommandCreate(applicationID, guildID string, command *discordgo.ApplicationCommand) (*discordgo.ApplicationCommand, error) + GetUerId() string +} diff --git a/models/discord_test.go b/models/discord_test.go new file mode 100644 index 0000000..f9ee128 --- /dev/null +++ b/models/discord_test.go @@ -0,0 +1,43 @@ +package models + +import ( + "testing" + + "github.com/bwmarrin/discordgo" + "github.com/stretchr/testify/assert" +) + +var command = &discordgo.ApplicationCommand{ + Name: "hello", + Description: "Greets back with hello!", +} + +func TestSessionWrapper(t *testing.T) { + mockSession := &discordgo.Session{} + sessionWrapper := &SessionWrapper{Session: mockSession} + + t.Run("SessionWrapper should always implement open() method", func(t *testing.T) { + assert.Panics(t, func() { + sessionWrapper.Open() + }, "should panic when open() is called") + }) + + t.Run("SessionWrapper should always implement close() method", func(t *testing.T) { + assert.NotPanics(t, func() { + sessionWrapper.Close() + }, "should not panic when close() is called") + }) + + t.Run("SessionWrapper should always implement applicationCommandCreate() method", func(t *testing.T) { + assert.Panics(t, func() { + sessionWrapper.ApplicationCommandCreate("1", "2", command) + }, "should panic when applicationCommandCreate() is called") + }) + + t.Run("SessionWrapper should always implement getUerId() method", func(t *testing.T) { + assert.Panics(t, func() { + sessionWrapper.GetUerId() + }, "should panic when getUerId() is called") + }) + +} diff --git a/queue/main.go b/queue/main.go new file mode 100644 index 0000000..6fbc00b --- /dev/null +++ b/queue/main.go @@ -0,0 +1,114 @@ +package queue + +import ( + "errors" + "sync" + + "github.com/Real-Dev-Squad/discord-service/config" + "github.com/Real-Dev-Squad/discord-service/utils" + amqp "github.com/rabbitmq/amqp091-go" + "github.com/sirupsen/logrus" +) + +var ( + queueInstance *Queue + once sync.Once +) + +type sessionInterface interface { + dial() error + createChannel() error + declareQueue() error +} + +type Queue struct { + Connection *amqp.Connection + Queue amqp.Queue + Name string + Channel *amqp.Channel +} + +func (q *Queue) dial() error { + var err error + q.Connection, err = amqp.Dial(config.AppConfig.QUEUE_URL) + return err +} + +func (q *Queue) createChannel() error { + var err error + q.Channel, err = q.Connection.Channel() + return err +} + +func (q *Queue) declareQueue() error { + var err error + q.Queue, err = q.Channel.QueueDeclare( + config.AppConfig.QUEUE_NAME, // name + true, // durable + false, // delete when unused + false, // exclusive + false, // no-wait + amqp.Table{"x-max-priority": 2}, // arguments + ) + return err +} + +func InitQueueConnection(openSession sessionInterface) { + var err error + f := func() error { + err = openSession.dial() + if err != nil { + return err + } + err = openSession.createChannel() + if err != nil { + return err + } + err = openSession.declareQueue() + return err + } + + err = utils.ExponentialBackoffRetry(config.AppConfig.MAX_RETRIES, f) + if err != nil { + logrus.Errorf("Failed to initialize queue after %d attempts: %s", config.AppConfig.MAX_RETRIES, err) + return + } + logrus.Infof("Established a connection to RabbitMQ named %s", config.AppConfig.QUEUE_NAME) + +} + +func queueHandler() { + queueInstance = &Queue{} + InitQueueConnection(queueInstance) +} + +var GetQueueInstance = func() *Queue { + once.Do(queueHandler) + return queueInstance +} + +var SendMessage = func(message []byte) error { + queue := GetQueueInstance() + + if queue.Channel == nil { + logrus.Errorf("Queue channel is not initialized") + return errors.New("Queue channel is not initialized") + } + + err := queue.Channel.Publish( + "", // default exchange + queue.Queue.Name, // use the actual queue name + false, // mandatory + false, // immediate + amqp.Publishing{ + ContentType: "text/plain", + Body: message, + }) + + if err != nil { + logrus.Errorf("Failed to publish message: %v", err) + return err + } + logrus.Info("Message sent successfully") + return nil +} diff --git a/queue/main_test.go b/queue/main_test.go new file mode 100644 index 0000000..1cfab6a --- /dev/null +++ b/queue/main_test.go @@ -0,0 +1,119 @@ +package queue + +import ( + "errors" + "testing" + + "github.com/Real-Dev-Squad/discord-service/config" + "github.com/Real-Dev-Squad/discord-service/dtos" + _ "github.com/Real-Dev-Squad/discord-service/tests/helpers" + "github.com/Real-Dev-Squad/discord-service/utils" + amqp "github.com/rabbitmq/amqp091-go" + + "github.com/stretchr/testify/assert" +) + +type mockQueue struct { + dialError error + channelError error + queueError error +} + +func (m *mockQueue) dial() error { + return m.dialError +} + +func (m *mockQueue) createChannel() error { + return m.channelError +} +func (m *mockQueue) declareQueue() error { + return m.queueError +} + +func TestInitQueueConnection(t *testing.T) { + config.AppConfig.MAX_RETRIES = 1 + t.Run("should not panic when Dial() returns error", func(t *testing.T) { + mockQueue := &mockQueue{dialError: errors.New("connection failed")} + assert.NotPanics(t, func() { + InitQueueConnection(mockQueue) + }, "InitQueueConnection should not panic when Dial is unsuccessful") + + }) + + t.Run("should not panic when CreateChannel() returns error", func(t *testing.T) { + mockQueue := &mockQueue{channelError: errors.New("channel failed")} + assert.NotPanics(t, func() { + InitQueueConnection(mockQueue) + }, "InitQueueConnection should not panic when CreateChannel is unsuccessful") + + }) + + t.Run("should not panic when DeclareQueue() returns error", func(t *testing.T) { + mockQueue := &mockQueue{queueError: errors.New("queue failed")} + assert.NotPanics(t, func() { + InitQueueConnection(mockQueue) + }, "InitQueueConnection should not when DeclareQueue is unsuccessful") + + }) + + t.Run("should pass when no error is returned", func(t *testing.T) { + mockQueue := &mockQueue{} + assert.NotPanics(t, func() { + InitQueueConnection(mockQueue) + }) + }) +} + +func TestGetQueueInstance(t *testing.T) { + t.Run("Should use ExponentialBackoffRetry via GetQueueInstance", func(t *testing.T) { + attempt := 0 + originalFunc := utils.ExponentialBackoffRetry + utils.ExponentialBackoffRetry = func(maxRetries int, operation func() error) error { + attempt++ + return errors.New("error") + } + defer func() { utils.ExponentialBackoffRetry = originalFunc }() + assert.NotNil(t, GetQueueInstance()) + assert.Equal(t, 1, attempt) + }) +} + +func TestSessionWrapper(t *testing.T) { + sessionWrapper := &Queue{} + + t.Run("SessionWrapper should always implement dial() method", func(t *testing.T) { + err := sessionWrapper.dial() + assert.Error(t, err) + }) + + t.Run("SessionWrapper should always implement createChannel() method", func(t *testing.T) { + sessionWrapper.Connection = &amqp.Connection{} + assert.Panics(t, func() { + sessionWrapper.createChannel() + }) + + }) + + t.Run("SessionWrapper should always implement declareQueue() method", func(t *testing.T) { + sessionWrapper.Channel = &amqp.Channel{} + assert.Panics(t, func() { + sessionWrapper.declareQueue() + }) + }) + +} + +func TestSendMessage(t *testing.T) { + t.Run("Should not panic when SendMessage returns error", func(t *testing.T) { + config.AppConfig.MAX_RETRIES = 1 + message := dtos.DataPacket{ + UserID: "1", + CommandName: "listening", + } + bytes, err := message.ToByte() + assert.NoError(t, err) + assert.NotPanics(t, func() { + SendMessage(bytes) + }, "SendMessage should panic when SendMessage returns error") + }) +} diff --git a/routes/baseRoute.go b/routes/baseRoute.go index 10206d1..01e5cb6 100644 --- a/routes/baseRoute.go +++ b/routes/baseRoute.go @@ -9,4 +9,5 @@ import ( func SetupBaseRoutes(router *httprouter.Router) { router.POST("/", middleware.VerifyCommand(controllers.HomeHandler)) router.GET("/health", controllers.HealthCheckHandler) + router.POST("/queue", controllers.QueueHandler) } diff --git a/routes/main.go b/routes/main.go index 8e4526c..70930cc 100644 --- a/routes/main.go +++ b/routes/main.go @@ -24,6 +24,6 @@ func Listen(listenAddress string) { router := SetupV1Routes() err := http.ListenAndServe(listenAddress, router) if err != nil { - logrus.Error(err) + logrus.Fatal(err) } } diff --git a/service/listeningService.go b/service/listeningService.go new file mode 100644 index 0000000..818eb8e --- /dev/null +++ b/service/listeningService.go @@ -0,0 +1,57 @@ +package service + +import ( + "fmt" + "net/http" + "strings" + + "github.com/Real-Dev-Squad/discord-service/dtos" + "github.com/Real-Dev-Squad/discord-service/queue" + "github.com/Real-Dev-Squad/discord-service/utils" + "github.com/bwmarrin/discordgo" +) + +func (s *CommandService) ListeningService(response http.ResponseWriter, request *http.Request) { + options := s.discordMessage.Data.Options[0] + msg := "" + requiresUpdate := false + + if options.Value.(bool) && strings.Contains(s.discordMessage.Member.Nick, utils.NICKNAME_SUFFIX) { + msg = "You are already set to listen." + } else if !options.Value.(bool) && !strings.Contains(s.discordMessage.Member.Nick, utils.NICKNAME_SUFFIX) { + msg = "Your nickname remains unchanged." + } else { + requiresUpdate = true + msg = "Your nickname will be updated shortly." + } + + if requiresUpdate { + dataPacket := dtos.DataPacket{ + UserID: s.discordMessage.Member.User.ID, + CommandName: "listening", + MetaData: map[string]string{ + "value": fmt.Sprint(options.Value), + "nickname": s.discordMessage.Member.Nick, + }, + } + bytePacket, err := dataPacket.ToByte() + if err != nil { + msg = "Failed to update your nickname." + response.WriteHeader(http.StatusInternalServerError) + return + } + if err := queue.SendMessage(bytePacket); err != nil { + msg = "Failed to update your nickname." + response.WriteHeader(http.StatusInternalServerError) + return + } + } + messageResponse := &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: fmt.Sprintf(msg), + Flags: 64, // Ephemeral message flag + }, + } + utils.Success.NewDiscordResponse(response, "Success", messageResponse) +} diff --git a/service/listeningService_test.go b/service/listeningService_test.go new file mode 100644 index 0000000..2df6308 --- /dev/null +++ b/service/listeningService_test.go @@ -0,0 +1,127 @@ +package service + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Real-Dev-Squad/discord-service/config" + "github.com/Real-Dev-Squad/discord-service/dtos" + "github.com/Real-Dev-Squad/discord-service/queue" + "github.com/Real-Dev-Squad/discord-service/utils" + "github.com/bwmarrin/discordgo" + "github.com/stretchr/testify/assert" +) + +func TestListeningService(t *testing.T) { + originalSendMessage := queue.SendMessage + defer func() { + queue.SendMessage = originalSendMessage + }() + config.AppConfig.MAX_RETRIES = 1 + options := &discordgo.ApplicationCommandInteractionDataOption{ + Value: true, + } + + mockData := &dtos.Data{ + GuildId: "876543210987654321", + ApplicationCommandInteractionData: discordgo.ApplicationCommandInteractionData{ + Name: "listening", + Options: []*discordgo.ApplicationCommandInteractionDataOption{ + options, + }, + }, + } + t.Run("should return 'You are already set to listen.' if nickname contains suffix and value is true", func(t *testing.T) { + data := dtos.DataPacket{ + UserID: "userID", + MetaData: map[string]string{ + "nickname": "testNick" + utils.NICKNAME_SUFFIX, + "value": "true", + }, + } + body, _ := json.Marshal(data) + req, _ := http.NewRequest("POST", "/listening", bytes.NewBuffer(body)) + rr := httptest.NewRecorder() + + discordMessage := &dtos.DiscordMessage{ + Data: mockData, + Member: &discordgo.Member{ + Nick: fmt.Sprintf("joy-gupta-1%s", utils.NICKNAME_SUFFIX), + User: &discordgo.User{ + ID: "1", + }, + }, + } + + commandService := &CommandService{discordMessage: discordMessage} + commandService.ListeningService(rr, req) + + assert.Contains(t, rr.Body.String(), "You are already set to listen.") + }) + + t.Run("should return 'Your nickname remains unchanged.' if nickname contains suffix and value is true", func(t *testing.T) { + data := dtos.DataPacket{ + UserID: "userID", + MetaData: map[string]string{ + "nickname": "testNick", + "value": "false", + }, + } + body, _ := json.Marshal(data) + req, _ := http.NewRequest("POST", "/listening", bytes.NewBuffer(body)) + rr := httptest.NewRecorder() + options.Value = false + discordMessage := &dtos.DiscordMessage{ + Data: mockData, + Member: &discordgo.Member{ + Nick: fmt.Sprintf("joy-gupta-1"), + User: &discordgo.User{ + ID: "1", + }, + }, + } + + commandService := &CommandService{discordMessage: discordMessage} + commandService.ListeningService(rr, req) + + assert.Contains(t, rr.Body.String(), "Your nickname remains unchanged.") + }) + + t.Run("should pass if nickname does not contain suffix and value is true", func(t *testing.T) { + originalFunc := queue.SendMessage + defer func() { queue.SendMessage = originalFunc }() + queue.SendMessage = func(message []byte) error { + return nil + } + data := dtos.DataPacket{ + UserID: "userID", + MetaData: map[string]string{ + "nickname": "testNick", + "value": "true", + }, + } + body, _ := json.Marshal(data) + req, _ := http.NewRequest("POST", "/listening", bytes.NewBuffer(body)) + rr := httptest.NewRecorder() + options.Value = true + discordMessage := &dtos.DiscordMessage{ + Data: mockData, + Member: &discordgo.Member{ + Nick: fmt.Sprintf("joy-gupta-1"), + User: &discordgo.User{ + ID: "1", + }, + }, + } + + commandService := &CommandService{discordMessage: discordMessage} + commandService.ListeningService(rr, req) + + assert.Contains(t, rr.Body.String(), "Your nickname will be updated shortly.") + }) + +} diff --git a/service/main.go b/service/main.go index 93f6bc8..e90a577 100644 --- a/service/main.go +++ b/service/main.go @@ -17,7 +17,8 @@ func MainService(discordMessage *dtos.DiscordMessage) func(response http.Respons switch discordMessage.Data.Name { case "hello": return CS.HelloService - + case "listening": + return CS.ListeningService default: return func(response http.ResponseWriter, request *http.Request) { response.WriteHeader(http.StatusOK) diff --git a/tests/helpers/config.go b/tests/helpers/config.go index 8d2db63..10715bd 100644 --- a/tests/helpers/config.go +++ b/tests/helpers/config.go @@ -9,4 +9,6 @@ func init() { os.Setenv("DISCORD_PUBLIC_KEY", "8933e3749b4feb4d76169b26ed372af3c378f4353c2024fee0601f2a2e7918e1") os.Setenv("GUILD_ID", "8933e3749b4feb4d76169b26ed372af3c378f4353c2024fee0601f2a2e7918e1") os.Setenv("BOT_TOKEN", "8933e3749b4feb4d76169b26ed372af3c378f4353c2024fee0601f2a2e7918e1") + os.Setenv("QUEUE_NAME", "DISCORD_QUEUE") + os.Setenv("QUEUE_URL", "local:5672") } diff --git a/utils/constants.go b/utils/constants.go new file mode 100644 index 0000000..4057036 --- /dev/null +++ b/utils/constants.go @@ -0,0 +1,4 @@ +package utils + +const NICKNAME_SUFFIX = "-Can't Talk" +const NICKNAME_PREFIX = "🎧 " diff --git a/utils/helper.go b/utils/helper.go new file mode 100644 index 0000000..895ac7d --- /dev/null +++ b/utils/helper.go @@ -0,0 +1,23 @@ +package utils + +import ( + "math" + "time" + + "github.com/sirupsen/logrus" +) + +var ExponentialBackoffRetry = func(maxRetries int, operation func() error) error { + var err error + for i := 0; i < maxRetries; i++ { + err = operation() + if err == nil { + return nil + } + logrus.Errorf("Attempt %d: Operation failed: %s", i+1, err) + if i < maxRetries-1 { + time.Sleep(time.Duration(math.Pow(2, float64(i))) * time.Second) + } + } + return err +} diff --git a/utils/helper_test.go b/utils/helper_test.go new file mode 100644 index 0000000..717945b --- /dev/null +++ b/utils/helper_test.go @@ -0,0 +1,58 @@ +package utils + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExponentialBackoffRetry_Success(t *testing.T) { + attempts := 0 + operation := func() error { + attempts++ + if attempts < 3 { + return errors.New("temporary error") + } + return nil + } + + err := ExponentialBackoffRetry(5, operation) + assert.NoError(t, err) + assert.Equal(t, 3, attempts) +} + +func TestExponentialBackoffRetry_Failure(t *testing.T) { + attempts := 0 + operation := func() error { + attempts++ + return errors.New("permanent error") + } + + err := ExponentialBackoffRetry(3, operation) + assert.Error(t, err) + assert.Equal(t, 3, attempts) +} + +func TestExponentialBackoffRetry_NoRetries(t *testing.T) { + attempts := 0 + operation := func() error { + attempts++ + return errors.New("error") + } + + err := ExponentialBackoffRetry(0, operation) + assert.Nil(t, err) + assert.Equal(t, 0, attempts) +} + +func TestExponentialBackoffRetry_ImmediateSuccess(t *testing.T) { + attempts := 0 + operation := func() error { + attempts++ + return nil + } + err := ExponentialBackoffRetry(5, operation) + assert.NoError(t, err) + assert.Equal(t, 1, attempts) +}