diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..f7e28c6 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,15 @@ +name: Lint +on: [push, pull_request] +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/setup-go@v3 + with: + go-version: 1.18 + - uses: actions/checkout@v3 + - name: golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + version: v1.48 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..e204772 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,14 @@ +on: [push, pull_request] +name: Tests +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Install Go. + uses: actions/setup-go@v2 + with: + go-version: 1.18 + - name: Checkout code. + uses: actions/checkout@v2 + - name: Run tests. + run: go test -v ./... diff --git a/client/main.go b/client/main.go new file mode 100644 index 0000000..3f8803a --- /dev/null +++ b/client/main.go @@ -0,0 +1,124 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "log" + "net/url" + "os" + + "github.com/fatih/color" + "github.com/gorilla/websocket" +) + +type ConnReader interface { + ReadJSON(v interface{}) error +} + +type ConnWriter interface { + WriteJSON(v interface{}) error + Close() error +} + +type Scanner interface { + Scan() bool + Text() string +} + +type message struct { + Username string `json:"username"` + Text string `json:"text"` + Type string `json:"type"` +} + +func main() { + var name string + var s *bufio.Scanner + + // Parse flags. + server := flag.String("server", "localhost:9000", "Server network address") + path := flag.String("path", "/", "Server path") + flag.Parse() + + // Construct WebSocket URL. + u := url.URL{Scheme: "ws", Host: *server, Path: *path} + + // Read username. + fmt.Printf("%s", color.YellowString("Enter your Name: ")) + s = bufio.NewScanner(os.Stdin) + s.Scan() + name = s.Text() + + // Display welcome message. + color.Green("\nWelcome %s!\n", name) + color.Green("Connecting to server @ %s\n", *server) + color.Yellow("Send a message, or type !q to exit.\n") + + // Get WebSocket connection. + conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil) + if err != nil { + color.Red("Connection error, exiting: %s", err) + os.Exit(0) + } + defer conn.Close() + + // Send joining message. + msg := message{Username: name, Text: "has joined the chat.", Type: "info"} + _ = conn.WriteJSON(msg) + + go readMessages(conn) // Handle incoming messages concurrently. + writeMessages(conn, s, name) // Handle outgoing messages concurrently. +} + +// readMessages handles incoming messages on the WebSocket connection. +func readMessages(conn ConnReader) { + for { + var msg message + + // Read message. + err := conn.ReadJSON(&msg) + if err != nil { + color.Red("Server closed. Exiting...") + os.Exit(0) + } + + // Display message. + color.Magenta("%s: %s\n", msg.Username, msg.Text) + } +} + +// writeMessages scans stdin and sends each scanned line to the server as JSON. +func writeMessages(conn ConnWriter, s Scanner, name string) { + var msg message + msg.Username = name + + for { + fmt.Print("> ") + if s.Scan() { + fmt.Printf("\033[A") + msg.Text = s.Text() + + // Handle quit event. + if msg.Text == "!q" { + fmt.Println("Goodbye!") + _ = conn.WriteJSON(message{Username: name, Text: "has disconnected.", Type: "info"}) + conn.Close() + os.Exit(0) + } + + // Display message. + if msg.Type != "" { + color.Cyan("%s %s\n", msg.Username, msg.Text) + } else { + color.Cyan("%s: %s\n", msg.Username, msg.Text) + } + + // Write message to connection. + err := conn.WriteJSON(msg) + if err != nil { + log.Fatal("Error sending message, exiting") + } + } + } +} diff --git a/go.mod b/go.mod index e8b0379..94d45ae 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ go 1.18 require ( github.com/charmbracelet/bubbles v0.14.0 github.com/charmbracelet/bubbletea v0.22.1 + github.com/fatih/color v1.13.0 + github.com/google/uuid v1.3.0 + github.com/gorilla/websocket v1.5.0 ) require ( @@ -12,6 +15,7 @@ require ( github.com/charmbracelet/lipgloss v0.5.0 // indirect github.com/containerd/console v1.0.3 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-colorable v0.1.9 // indirect github.com/mattn/go-isatty v0.0.16 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect diff --git a/go.sum b/go.sum index ddbf585..1c5ce4c 100644 --- a/go.sum +++ b/go.sum @@ -10,9 +10,18 @@ github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DA github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -37,6 +46,8 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..aabc332 --- /dev/null +++ b/server/main.go @@ -0,0 +1,108 @@ +package main + +import ( + "flag" + "log" + "net/http" + "time" + + "github.com/fatih/color" + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +type message struct { + Username string `json:"username"` + Text string `json:"text"` + Type string `json:"type"` + ID uuid.UUID +} + +// Upgrader instance to upgrade all HTTP connections to a WebSocket. +var upgrader = websocket.Upgrader{} + +// Map to store currently active client connections. +var activeClients = make(map[*websocket.Conn]uuid.UUID) + +// Channel for client messages. +var messageChan = make(chan message) + +func main() { + // Parse flags. + addr := flag.String("addr", ":9000", "Server's network address") + flag.Parse() + + mux := http.NewServeMux() + mux.HandleFunc("/", handleConn) + + // Handle incoming messages. + go handleMsg() + + // Start the server. + log.Printf("Starting server on %s", *addr) + err := http.ListenAndServe(*addr, mux) + if err != nil { + log.Fatal("Error starting server, exiting.", err) + } +} + +// handleConn handles incoming HTTP connections by adding the connection to activeClients and reads messages from the connection. +func handleConn(w http.ResponseWriter, r *http.Request) { + // Upgrade incoming HTTP connections to WebSocket connections + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("Error upgrading connection to websocket: %v", err) + } + defer conn.Close() + + // Generate a UUID for the client. + activeClients[conn] = uuid.New() + + for { + var msg message + + // Read message from the connection. + err := conn.ReadJSON(&msg) + if err != nil { + log.Printf("Closing connection with ID: %v", activeClients[conn]) + delete(activeClients, conn) + break + } + + // Set message ID + msg.ID = activeClients[conn] + + // Send message to messageChan. + messageChan <- msg + } +} + +// handleMsg listens to the messageChan channel and broadcasts messages to other clients. +func handleMsg() { + for { + // Get message from messageChan. + msg := <-messageChan + + // Log each message to stdout. + t := time.Now().Format(time.ANSIC) + if msg.Type != "" { + color.Green("%s >> %s %s\n", t, msg.Username, msg.Text) + } else { + color.Green("%s >> %s: %s\n", t, msg.Username, msg.Text) + } + + // Broadcast to all active clients. + for client, UUID := range activeClients { + // Check the UUID to prevent sending messages to their origin. + if msg.ID != UUID { + // Write JSON message. + err := client.WriteJSON(msg) + if err != nil { + log.Printf("Error sending message to client: %v", err) + client.Close() + delete(activeClients, client) + } + } + } + } +} diff --git a/tui/main.go b/tui/tui.go similarity index 98% rename from tui/main.go rename to tui/tui.go index 1f0d0ab..78aca5a 100644 --- a/tui/main.go +++ b/tui/tui.go @@ -9,7 +9,7 @@ import ( tea "github.com/charmbracelet/bubbletea" ) -func main() { +func UI() { //nolint:deadcode p := tea.NewProgram(initialModel()) if err := p.Start(); err != nil { log.Fatal(err)