mirror of
https://github.com/supleed2/go-chat.git
synced 2024-12-22 06:05:49 +00:00
Initial Commit
This commit is contained in:
commit
6a94af7daf
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
|
@ -0,0 +1,24 @@
|
|||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# Project specific files
|
||||
nicks.json
|
334
client/main.go
Normal file
334
client/main.go
Normal file
|
@ -0,0 +1,334 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
c "go-chat/common"
|
||||
|
||||
"github.com/alexflint/go-arg"
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
ws "nhooyr.io/websocket"
|
||||
"nhooyr.io/websocket/wsjson"
|
||||
)
|
||||
|
||||
const manText string = `The current available commands are:
|
||||
man
|
||||
prints this message
|
||||
mv <string>
|
||||
set your nick
|
||||
ls
|
||||
get the available rooms
|
||||
cd <string>
|
||||
connect to a room
|
||||
who
|
||||
list users in the current room
|
||||
moo
|
||||
:)`
|
||||
|
||||
const mooText string = `
|
||||
(__)
|
||||
(oo)
|
||||
/------\/
|
||||
/ | ||
|
||||
* /\---/\
|
||||
~~ ~~
|
||||
..."Have you mooed today?"...`
|
||||
|
||||
type showTim int
|
||||
|
||||
const (
|
||||
off showTim = iota
|
||||
short
|
||||
full
|
||||
)
|
||||
|
||||
type model struct {
|
||||
history viewport.Model
|
||||
msgs []c.SMsg
|
||||
showTim showTim
|
||||
tz time.Location
|
||||
input textinput.Model
|
||||
idStyle lipgloss.Style
|
||||
pStyle lipgloss.Style
|
||||
help help.Model
|
||||
recvCh chan c.SMsg
|
||||
sendCh chan c.CMsg
|
||||
conn *ws.Conn
|
||||
exitCh chan exit
|
||||
}
|
||||
|
||||
type args struct {
|
||||
Address string `arg:"positional" default:"localhost:8000" help:"address to connect to, without ws://" placeholder:"HOST[:PORT]"`
|
||||
Timestamps showTim `arg:"-t" default:"off" help:"display timestamps of messages, ctrl+t to cycle after startup [off, short, full]" placeholder:"CHOICE"`
|
||||
Nick *string `arg:"-n" help:"attempt to automatically set nick after connecting"`
|
||||
Password *string `arg:"-p" help:"password, if required"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
var a args
|
||||
arg.MustParse(&a)
|
||||
|
||||
conn, _, err := ws.Dial(ctx, "ws://"+a.Address, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer conn.Close(ws.StatusNormalClosure, "")
|
||||
|
||||
local, err := time.LoadLocation("Local")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
p := tea.NewProgram(initModel(ctx, conn, a, *local), tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||
if _, err := p.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func initModel(ctx context.Context, conn *ws.Conn, a args, tz time.Location) model {
|
||||
discCtx, disc := context.WithCancel(ctx)
|
||||
exitCh := make(chan exit)
|
||||
|
||||
recvCh := make(chan c.SMsg)
|
||||
go func() {
|
||||
smsg := c.SMsg{}
|
||||
for {
|
||||
err := wsjson.Read(ctx, conn, &smsg)
|
||||
if err != nil {
|
||||
if ws.CloseStatus(err) != ws.StatusNormalClosure {
|
||||
log.Println(err)
|
||||
}
|
||||
disc()
|
||||
return
|
||||
}
|
||||
recvCh <- smsg
|
||||
}
|
||||
}()
|
||||
|
||||
sendCh := make(chan c.CMsg)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case cmsg := <-sendCh:
|
||||
err := wsjson.Write(ctx, conn, cmsg)
|
||||
if err != nil {
|
||||
recvCh <- c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("wsjson error when sending message: %v", err)}
|
||||
}
|
||||
case <-discCtx.Done():
|
||||
exitCh <- exit{}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
ta := textinput.New()
|
||||
ta.Placeholder = "Send a message (or a command with /)"
|
||||
ta.Focus()
|
||||
ta.CharLimit = 128
|
||||
ta.Width = 60
|
||||
|
||||
vp := viewport.New(60, 5)
|
||||
vp.KeyMap = viewport.KeyMap{
|
||||
PageDown: key.NewBinding(
|
||||
key.WithKeys("pgdown"),
|
||||
key.WithHelp("pgdn", "page down"),
|
||||
),
|
||||
PageUp: key.NewBinding(
|
||||
key.WithKeys("pgup"),
|
||||
key.WithHelp("pgup", "page up"),
|
||||
),
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up"),
|
||||
key.WithHelp("↑", "up"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down"),
|
||||
key.WithHelp("↓", "down"),
|
||||
),
|
||||
}
|
||||
|
||||
if a.Nick != nil {
|
||||
login := *a.Nick
|
||||
if a.Password != nil {
|
||||
login += ":" + *a.Password
|
||||
}
|
||||
sendCh <- c.CMsg{Typ: c.Mv, Msg: login}
|
||||
}
|
||||
|
||||
messages := []c.SMsg{{Tim: time.Now(), Id: "system", Msg: "Welcome to the chat room! Press Enter to send, /man for more info :)"}}
|
||||
|
||||
return model{
|
||||
input: ta,
|
||||
msgs: messages,
|
||||
showTim: a.Timestamps,
|
||||
tz: tz,
|
||||
history: vp,
|
||||
idStyle: lipgloss.NewStyle().Width(30),
|
||||
pStyle: lipgloss.NewStyle().Bold(true),
|
||||
help: help.New(),
|
||||
recvCh: recvCh,
|
||||
sendCh: sendCh,
|
||||
conn: conn,
|
||||
exitCh: exitCh,
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
tea.SetWindowTitle("go-chat by 8bit"),
|
||||
textinput.Blink,
|
||||
getNextSMsg(m.recvCh),
|
||||
getExitMsg(m.exitCh),
|
||||
)
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var tiCmd, vpCmd, clCmd, smCmd tea.Cmd
|
||||
m.input, tiCmd = m.input.Update(msg)
|
||||
m.history, vpCmd = m.history.Update(msg)
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case exit:
|
||||
return m, tea.Quit
|
||||
case c.SMsg:
|
||||
m.msgs = append(m.msgs, msg)
|
||||
m.history.SetContent(m.viewMessages())
|
||||
m.history.GotoBottom()
|
||||
smCmd = getNextSMsg(m.recvCh)
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyCtrlC, tea.KeyEsc:
|
||||
return m, tea.Quit
|
||||
case tea.KeyCtrlT:
|
||||
m.showTim = (m.showTim + 1) % 3
|
||||
m.history.SetContent(m.viewMessages())
|
||||
case tea.KeyEnter:
|
||||
text := strings.TrimSpace(m.input.Value())
|
||||
if text, ok := strings.CutPrefix(text, "/"); ok {
|
||||
if text == "man" {
|
||||
m.recvCh <- c.SMsg{Tim: time.Now(), Id: "system", Msg: manText}
|
||||
} else if text, ok := strings.CutPrefix(text, "mv "); ok {
|
||||
m.sendCh <- c.CMsg{Typ: c.Mv, Msg: text}
|
||||
} else if text == "ls" {
|
||||
m.sendCh <- c.CMsg{Typ: c.Ls, Msg: ""}
|
||||
} else if text, ok := strings.CutPrefix(text, "cd "); ok {
|
||||
m.sendCh <- c.CMsg{Typ: c.Cd, Msg: text}
|
||||
} else if text == "who" {
|
||||
m.sendCh <- c.CMsg{Typ: c.Who, Msg: ""}
|
||||
} else if text, ok := strings.CutPrefix(text, "sudo "); ok {
|
||||
m.sendCh <- c.CMsg{Typ: c.Sudo, Msg: text}
|
||||
} else if text == "moo" {
|
||||
m.recvCh <- c.SMsg{Tim: time.Now(), Id: "cow", Msg: mooText}
|
||||
} else {
|
||||
m.recvCh <- c.SMsg{Tim: time.Now(), Id: "system", Msg: "Unrecognised command, use /man for more info"}
|
||||
}
|
||||
} else if text != "" {
|
||||
m.sendCh <- c.CMsg{Typ: c.Echo, Msg: text}
|
||||
}
|
||||
m.input.Reset()
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
clCmd = tea.Sequence(tea.ExitAltScreen, tea.EnterAltScreen)
|
||||
m.history.Height = msg.Height - 2
|
||||
m.history.Width = msg.Width
|
||||
m.history.GotoBottom()
|
||||
m.input.Width = msg.Width - 3
|
||||
m.idStyle.Width(msg.Width)
|
||||
m.help.Width = msg.Width - 1
|
||||
m.history.SetContent(m.viewMessages())
|
||||
}
|
||||
|
||||
return m, tea.Batch(tiCmd, vpCmd, clCmd, smCmd)
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
return fmt.Sprintf(
|
||||
"%s\n%s\n%s",
|
||||
m.history.View(),
|
||||
m.input.View(),
|
||||
m.help.View(m),
|
||||
)
|
||||
}
|
||||
|
||||
func (m model) ShortHelp() []key.Binding {
|
||||
return []key.Binding{
|
||||
m.history.KeyMap.PageDown,
|
||||
m.history.KeyMap.PageUp,
|
||||
m.history.KeyMap.Down,
|
||||
m.history.KeyMap.Up,
|
||||
key.NewBinding(
|
||||
key.WithKeys("ctrl+t"),
|
||||
key.WithHelp("ctrl+t", "toggle timestamps"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) FullHelp() [][]key.Binding {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m model) viewMessages() string {
|
||||
s := ""
|
||||
for i := range m.msgs {
|
||||
prefix := ""
|
||||
if m.showTim == short {
|
||||
prefix += m.msgs[i].Tim.In(&m.tz).Format(time.TimeOnly) + " "
|
||||
} else if m.showTim == full {
|
||||
prefix += m.msgs[i].Tim.In(&m.tz).Format(time.DateTime) + " "
|
||||
}
|
||||
if m.msgs[i].Id == "system" {
|
||||
prefix += m.pStyle.Foreground(lipgloss.Color("201")).Render("system:")
|
||||
} else {
|
||||
prefix += m.pStyle.Foreground(lipgloss.Color(prefixColor(m.msgs[i].Id))).Render(m.msgs[i].Id + ":")
|
||||
}
|
||||
s += m.idStyle.SetString(prefix).Render(m.msgs[i].Msg) + "\n"
|
||||
}
|
||||
return s[:len(s)-1]
|
||||
}
|
||||
|
||||
func prefixColor(s string) string {
|
||||
if len(s) == 0 {
|
||||
s = "missing"
|
||||
}
|
||||
return fmt.Sprint(uint(s[0]+s[len(s)-1]) % 8)
|
||||
}
|
||||
|
||||
func getNextSMsg(c <-chan c.SMsg) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return <-c
|
||||
}
|
||||
}
|
||||
|
||||
type exit struct{}
|
||||
|
||||
func getExitMsg(c <-chan exit) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return <-c
|
||||
}
|
||||
}
|
||||
|
||||
func (st *showTim) UnmarshalText(b []byte) error {
|
||||
s := string(b)
|
||||
switch s {
|
||||
case "off":
|
||||
*st = off
|
||||
case "short":
|
||||
*st = short
|
||||
case "full":
|
||||
*st = full
|
||||
default:
|
||||
return fmt.Errorf("invalid choice: %s [off, short, full]", s)
|
||||
}
|
||||
return nil
|
||||
}
|
25
common/types.go
Normal file
25
common/types.go
Normal file
|
@ -0,0 +1,25 @@
|
|||
package common
|
||||
|
||||
import "time"
|
||||
|
||||
type SMsg struct {
|
||||
Tim time.Time
|
||||
Id string
|
||||
Msg string
|
||||
}
|
||||
|
||||
type CMsgT int
|
||||
|
||||
const (
|
||||
Sudo CMsgT = iota
|
||||
Echo
|
||||
Mv
|
||||
Ls
|
||||
Cd
|
||||
Who
|
||||
)
|
||||
|
||||
type CMsg struct {
|
||||
Typ CMsgT
|
||||
Msg string
|
||||
}
|
32
go.mod
Normal file
32
go.mod
Normal file
|
@ -0,0 +1,32 @@
|
|||
module go-chat
|
||||
|
||||
go 1.21.5
|
||||
|
||||
require (
|
||||
github.com/alexflint/go-arg v1.4.3
|
||||
github.com/charmbracelet/bubbles v0.17.1
|
||||
github.com/charmbracelet/bubbletea v0.25.0
|
||||
github.com/charmbracelet/lipgloss v0.9.1
|
||||
nhooyr.io/websocket v1.8.10
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/alexflint/go-scalar v1.2.0 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.15.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/stretchr/testify v1.8.4 // indirect
|
||||
golang.org/x/sync v0.6.0 // indirect
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
golang.org/x/term v0.16.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
)
|
64
go.sum
Normal file
64
go.sum
Normal file
|
@ -0,0 +1,64 @@
|
|||
github.com/alexflint/go-arg v1.4.3 h1:9rwwEBpMXfKQKceuZfYcwuc/7YY7tWJbFsgG5cAU/uo=
|
||||
github.com/alexflint/go-arg v1.4.3/go.mod h1:3PZ/wp/8HuqRZMUUgu7I+e1qcpUbvmS258mRXkFH4IA=
|
||||
github.com/alexflint/go-scalar v1.1.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
|
||||
github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw=
|
||||
github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbles v0.17.1 h1:0SIyjOnkrsfDo88YvPgAWvZMwXe26TP6drRvmkjyUu4=
|
||||
github.com/charmbracelet/bubbles v0.17.1/go.mod h1:9HxZWlkCqz2PRwsCbYl7a3KXvGzFaDHpYbSYMJ+nE3o=
|
||||
github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
|
||||
github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
|
||||
github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
|
||||
github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
|
||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
|
||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
|
||||
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
|
||||
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/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q=
|
||||
nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
|
359
server/main.go
Normal file
359
server/main.go
Normal file
|
@ -0,0 +1,359 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
c "go-chat/common"
|
||||
|
||||
"github.com/alexflint/go-arg"
|
||||
ws "nhooyr.io/websocket"
|
||||
"nhooyr.io/websocket/wsjson"
|
||||
)
|
||||
|
||||
type user struct {
|
||||
room string
|
||||
nick string
|
||||
}
|
||||
|
||||
type conns struct {
|
||||
sm sync.Mutex
|
||||
cm map[*ws.Conn]user
|
||||
}
|
||||
|
||||
type server struct {
|
||||
admin string
|
||||
logFn func(string, ...interface{})
|
||||
conns *conns
|
||||
rooms map[string]struct{}
|
||||
rhist map[string][]c.SMsg
|
||||
rhlen int
|
||||
nickm map[string]string
|
||||
}
|
||||
|
||||
func main() {
|
||||
log := log.New(os.Stderr, "ws server 🚀 ", log.LstdFlags|log.Lshortfile|log.Lmsgprefix)
|
||||
|
||||
var args struct {
|
||||
Admin string `arg:"-a" default:"8bit" help:"admin user nick, allows access to /sudo" placeholder:"NICK"`
|
||||
HistLen uint `arg:"-h" default:"10" help:"set message history size" placeholder:"N"`
|
||||
Port uint `arg:"positional" default:"0" help:"port to listen on, random available port if not set"`
|
||||
NickMap *string `arg:"-n" help:"path to nick:pass JSON file" placeholder:"FILE"`
|
||||
}
|
||||
arg.MustParse(&args)
|
||||
|
||||
nickMap, err := loadNickMap(args.NickMap)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = run("localhost:"+fmt.Sprint(args.Port), nickMap, args.Admin, int(args.HistLen), log)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func run(addr string, nickMap map[string]string, admin string, rhlen int, log *log.Logger) error {
|
||||
listener, err := net.Listen("tcp4", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("listening on ws://%v", listener.Addr())
|
||||
|
||||
server := &http.Server{
|
||||
Handler: server{
|
||||
admin: admin,
|
||||
logFn: log.Printf,
|
||||
conns: &conns{cm: make(map[*ws.Conn]user)},
|
||||
rooms: map[string]struct{}{"general": {}, "test1": {}, "test2": {}},
|
||||
rhist: make(map[string][]c.SMsg),
|
||||
rhlen: rhlen,
|
||||
nickm: nickMap,
|
||||
},
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
errch := make(chan error, 1)
|
||||
go func() {
|
||||
errch <- server.Serve(listener)
|
||||
}()
|
||||
|
||||
signals := make(chan os.Signal, 1)
|
||||
signal.Notify(signals, os.Interrupt)
|
||||
select {
|
||||
case err := <-errch:
|
||||
log.Printf("failed to serve: %v", err)
|
||||
case signal := <-signals:
|
||||
log.Printf("quitting: %v", signal)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return server.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func (s server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
conn, err := ws.Accept(w, r, nil)
|
||||
if err != nil {
|
||||
s.logFn("%v", err)
|
||||
return
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
|
||||
if conn.Subprotocol() != "" {
|
||||
return
|
||||
}
|
||||
|
||||
port := strings.Split(r.RemoteAddr, ":")[1]
|
||||
s.conns.sm.Lock()
|
||||
s.conns.cm[conn] = user{room: "general", nick: port}
|
||||
s.conns.sm.Unlock()
|
||||
defer func() {
|
||||
s.conns.sm.Lock()
|
||||
delete(s.conns.cm, conn)
|
||||
s.logFn("Remaining connections: %v", len(s.conns.cm))
|
||||
s.conns.sm.Unlock()
|
||||
}()
|
||||
|
||||
s.logFn("connected: %v", r.RemoteAddr)
|
||||
for i := range s.rhist["general"] {
|
||||
wsjson.Write(ctx, conn, s.rhist["general"][i])
|
||||
}
|
||||
cmsg := c.CMsg{}
|
||||
smsg := c.SMsg{Id: port}
|
||||
for {
|
||||
err := func(ctx context.Context, conn *ws.Conn) error {
|
||||
err := wsjson.Read(ctx, conn, &cmsg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch cmsg.Typ {
|
||||
case c.Sudo:
|
||||
s.logFn("(%v) sudo: %v", smsg.Id, cmsg.Msg)
|
||||
if smsg.Id == s.admin {
|
||||
cmd := strings.Split(cmsg.Msg, " ")
|
||||
if len(cmd) == 2 {
|
||||
if cmd[0] == "mk" {
|
||||
if _, ok := s.rooms[cmd[1]]; ok {
|
||||
wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("Room exists: %v", cmd)})
|
||||
} else {
|
||||
s.rooms[cmd[1]] = struct{}{}
|
||||
wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("Created room: %v", cmd)})
|
||||
}
|
||||
} else if cmd[0] == "rm" {
|
||||
if _, ok := s.rooms[cmd[1]]; ok && cmd[1] != "general" {
|
||||
delete(s.rooms, cmd[1])
|
||||
s.rhist[cmd[1]] = []c.SMsg{}
|
||||
tim := time.Now()
|
||||
s.conns.sm.Lock()
|
||||
for cn, r := range s.conns.cm {
|
||||
if r.room == cmd[1] {
|
||||
r.room = "general"
|
||||
s.conns.cm[cn] = r
|
||||
wsjson.Write(ctx, cn, c.SMsg{Tim: tim, Id: "system", Msg: "room deleted, reconnected to general"})
|
||||
}
|
||||
}
|
||||
s.conns.sm.Unlock()
|
||||
wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("Deleted room: %v", cmd)})
|
||||
} else {
|
||||
wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("Room does not exist: %v", cmd)})
|
||||
}
|
||||
} else if cmd[0] == "yeet" {
|
||||
found := false
|
||||
s.conns.sm.Lock()
|
||||
for c, r := range s.conns.cm {
|
||||
if r.nick == cmd[1] {
|
||||
c.Close(ws.StatusNormalClosure, "Kicked")
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
s.conns.sm.Unlock()
|
||||
if found {
|
||||
wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("Yeet: %v", cmd[1])})
|
||||
} else {
|
||||
wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("Not found: %v", cmd[1])})
|
||||
}
|
||||
} else {
|
||||
wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("Invalid command: %v", cmd)})
|
||||
}
|
||||
} else if cmd[0] == "wc" {
|
||||
s.conns.sm.Lock()
|
||||
wc := len(s.conns.cm)
|
||||
s.conns.sm.Unlock()
|
||||
wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("Online: %v", wc)})
|
||||
} else if cmd[0] == "man" {
|
||||
wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: "Available commands: man, mk, rm, wc, yeet"})
|
||||
} else {
|
||||
wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("Invalid command: %v", cmd)})
|
||||
}
|
||||
} else {
|
||||
wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: "Unrecognised command, use /man for more info"})
|
||||
}
|
||||
case c.Echo:
|
||||
s.logFn("(%v) echo: %v", smsg.Id, cmsg.Msg)
|
||||
s.conns.sm.Lock()
|
||||
room := s.conns.cm[conn].room
|
||||
s.conns.sm.Unlock()
|
||||
smsg.Tim = time.Now()
|
||||
smsg.Msg = cmsg.Msg
|
||||
if len(s.rhist[room]) < s.rhlen {
|
||||
s.rhist[room] = append(s.rhist[room], smsg)
|
||||
} else {
|
||||
s.rhist[room] = append(s.rhist[room][1:], smsg)
|
||||
}
|
||||
s.conns.sm.Lock()
|
||||
for c, r := range s.conns.cm {
|
||||
if r.room == room {
|
||||
wsjson.Write(ctx, c, &smsg)
|
||||
}
|
||||
}
|
||||
s.conns.sm.Unlock()
|
||||
case c.Mv:
|
||||
switch nick, valid := verifyNick(&s, cmsg.Msg); valid {
|
||||
case nickOk:
|
||||
s.logFn("(%v) mv: %v", smsg.Id, cmsg.Msg)
|
||||
smsg.Id = nick
|
||||
s.conns.sm.Lock()
|
||||
u := s.conns.cm[conn]
|
||||
u.nick = smsg.Id
|
||||
s.conns.cm[conn] = u
|
||||
s.conns.sm.Unlock()
|
||||
wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("nick set: %v", nick)})
|
||||
case nickUsed:
|
||||
s.logFn("(%v) mv used: %v", smsg.Id, cmsg.Msg)
|
||||
wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("nick in use: %v", cmsg.Msg)})
|
||||
case nickInvalid:
|
||||
s.logFn("(%v) mv invalid: %v", smsg.Id, cmsg.Msg)
|
||||
wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("invalid nick: %v", cmsg.Msg)})
|
||||
}
|
||||
case c.Ls:
|
||||
s.logFn("(%v) ls", smsg.Id)
|
||||
s.conns.sm.Lock()
|
||||
room := s.conns.cm[conn].room
|
||||
s.conns.sm.Unlock()
|
||||
avRooms := ""
|
||||
for r := range s.rooms {
|
||||
avRooms += r + ", "
|
||||
}
|
||||
wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("connected to: %v, available: %v", room, avRooms[:len(avRooms)-2])})
|
||||
case c.Cd:
|
||||
if _, ok := s.rooms[cmsg.Msg]; ok {
|
||||
s.logFn("(%v) cd: %v", smsg.Id, cmsg.Msg)
|
||||
s.conns.sm.Lock()
|
||||
u := s.conns.cm[conn]
|
||||
u.room = cmsg.Msg
|
||||
s.conns.cm[conn] = u
|
||||
s.conns.sm.Unlock()
|
||||
wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("connected to: %v", u.room)})
|
||||
rhistSize := max(len(s.rhist[u.room])-10, 0)
|
||||
recentHistory := s.rhist[u.room][rhistSize:]
|
||||
for i := range recentHistory {
|
||||
wsjson.Write(ctx, conn, recentHistory[i])
|
||||
}
|
||||
} else {
|
||||
s.logFn("(%v) cd invalid: %v", smsg.Id, cmsg.Msg)
|
||||
wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("unchanged, invalid room: %v", cmsg.Msg)})
|
||||
}
|
||||
case c.Who:
|
||||
s.conns.sm.Lock()
|
||||
room := s.conns.cm[conn].room
|
||||
s.logFn("(%v) who: %v", smsg.Id, room)
|
||||
users := fmt.Sprintf("users in %v: ", room)
|
||||
for _, r := range s.conns.cm {
|
||||
if r.room == room {
|
||||
users += fmt.Sprintf("%v, ", r.nick)
|
||||
}
|
||||
}
|
||||
s.conns.sm.Unlock()
|
||||
wsjson.Write(ctx, conn, &c.SMsg{Tim: time.Now(), Id: "system", Msg: users[:len(users)-2]})
|
||||
}
|
||||
return nil
|
||||
}(ctx, conn)
|
||||
|
||||
if ws.CloseStatus(err) == ws.StatusNormalClosure {
|
||||
s.logFn("disconnected: %v", r.RemoteAddr)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.logFn("failed, addr %v: %v", r.RemoteAddr, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type nickErr int
|
||||
|
||||
const (
|
||||
nickOk nickErr = iota
|
||||
nickUsed
|
||||
nickInvalid
|
||||
)
|
||||
|
||||
func verifyNick(s *server, n string) (string, nickErr) {
|
||||
nick, pass, _ := strings.Cut(n, ":")
|
||||
|
||||
s.conns.sm.Lock()
|
||||
defer s.conns.sm.Unlock()
|
||||
for _, u := range s.conns.cm {
|
||||
if u.nick == nick {
|
||||
return "", nickUsed
|
||||
}
|
||||
}
|
||||
|
||||
expPass, needAuth := s.nickm[nick]
|
||||
|
||||
if (!needAuth || pass == expPass) && alphanumeric(nick) {
|
||||
return nick, nickOk
|
||||
} else {
|
||||
return "", nickInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func loadNickMap(m *string) (map[string]string, error) {
|
||||
nm := make(map[string]string)
|
||||
if m == nil {
|
||||
return nm, nil
|
||||
}
|
||||
|
||||
path, err := filepath.Abs(*m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(file, &nm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nm, nil
|
||||
}
|
||||
|
||||
func alphanumeric(s string) bool {
|
||||
for _, r := range s {
|
||||
if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
Loading…
Reference in a new issue