commit 64c416bb7323244afabb8f2b640cbda98fb48248 Author: Weetile Date: Sun May 25 14:20:06 2025 +0100 Initial commit; minimum viable product diff --git a/.ssh/id_ed25519 b/.ssh/id_ed25519 new file mode 100644 index 0000000..9383b80 --- /dev/null +++ b/.ssh/id_ed25519 @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz +c2gtZWQyNTUxOQAAACDU0dFU2QBvXE0nSna4v3QGeOEXrDlwyz5tcQ4UAsHNxwAA +AIhUr4t6VK+LegAAAAtzc2gtZWQyNTUxOQAAACDU0dFU2QBvXE0nSna4v3QGeOEX +rDlwyz5tcQ4UAsHNxwAAAEDxcPDoQOToQSZuhkEDCb6gnJwwrHpmFYFhWFX8i0BP +ttTR0VTZAG9cTSdKdri/dAZ44ResOXDLPm1xDhQCwc3HAAAAAAECAwQF +-----END OPENSSH PRIVATE KEY----- diff --git a/.ssh/id_ed25519.pub b/.ssh/id_ed25519.pub new file mode 100644 index 0000000..63c6fda --- /dev/null +++ b/.ssh/id_ed25519.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINTR0VTZAG9cTSdKdri/dAZ44ResOXDLPm1xDhQCwc3H weetile@weetileBox diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0b51127 --- /dev/null +++ b/go.mod @@ -0,0 +1,45 @@ +module chatter + +go 1.24.3 + +require ( + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.5 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/log v0.4.2 + github.com/charmbracelet/ssh v0.0.0-20250429213052-383d50896132 + github.com/charmbracelet/wish v1.4.7 + github.com/muesli/termenv v0.16.0 +) + +require ( + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/keygen v0.5.3 // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/conpty v0.1.0 // indirect + github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect + github.com/charmbracelet/x/input v0.3.4 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/x/termios v0.1.0 // indirect + github.com/charmbracelet/x/windows v0.2.0 // indirect + github.com/creack/pty v1.1.21 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-logfmt/logfmt v0.6.0 // 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.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..510b4d3 --- /dev/null +++ b/go.sum @@ -0,0 +1,89 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +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/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= +github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4= +github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= +github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= +github.com/charmbracelet/ssh v0.0.0-20250429213052-383d50896132 h1:ILyX/vWS/SeHfyuMFAsaV95jEKrwivNUkA8tE7JEXs4= +github.com/charmbracelet/ssh v0.0.0-20250429213052-383d50896132/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE= +github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc= +github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0= +github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.0 h1:y4rjAHeFksBAfGbkRDmVinMg7x7DELIGAFbdNvxg97k= +github.com/charmbracelet/x/termios v0.1.0/go.mod h1:H/EVv/KRnrYjz+fCYa9bsKdqF3S8ouDK0AZEbG7r+/U= +github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw= +github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s= +github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= +github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/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/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..161d348 --- /dev/null +++ b/main.go @@ -0,0 +1,450 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "os/signal" + "regexp" + "strings" + "sync" + "syscall" + "time" + + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/log" + "github.com/charmbracelet/ssh" + "github.com/charmbracelet/wish" + "github.com/charmbracelet/wish/activeterm" + "github.com/charmbracelet/wish/bubbletea" + "github.com/charmbracelet/wish/logging" + "github.com/muesli/termenv" +) + +const ( + host = "0.0.0.0" + port = "23234" +) + +type programInfo struct { + roomName string + userID string +} + +// app contains a wish server and manages chat rooms and programs. +type app struct { + *ssh.Server + activePrograms map[*tea.Program]programInfo // program -> programInfo + rooms map[string][]*tea.Program // roomName -> list of programs in that room + mu sync.Mutex +} + +// BroadcastToRoom sends a message to all running programs in a specific room. +func (a *app) BroadcastToRoom(roomName string, msg tea.Msg) { + a.mu.Lock() + defer a.mu.Unlock() + + if progsInRoom, ok := a.rooms[roomName]; ok { + log.Debugf("Broadcasting to room %s (%d programs): %+v", roomName, len(progsInRoom), msg) + for _, p := range progsInRoom { + go p.Send(msg) + } + } else { + log.Warnf("Attempted to broadcast to non-existent or empty room: %s", roomName) + } +} + +func newApp() *app { + a := &app{ + activePrograms: make(map[*tea.Program]programInfo), // Initialize correctly + rooms: make(map[string][]*tea.Program), + } + s, err := wish.NewServer( + wish.WithAddress(net.JoinHostPort(host, port)), + wish.WithHostKeyPath(".ssh/id_ed25519"), // Ensure this key exists (e.g., run ssh-keygen -t ed25519 -f .ssh/id_ed25519 -N "") + wish.WithMiddleware( + bubbletea.MiddlewareWithProgramHandler(a.ProgramHandler, termenv.ANSI256), + activeterm.Middleware(), + logging.Middleware(), + ), + ) + if err != nil { + log.Fatal("Could not initialize server", "error", err) + } + + a.Server = s + return a +} + +func (a *app) Start() { + var err error + done := make(chan os.Signal, 1) + signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + log.Info("Starting SSH server", "host", host, "port", port) + go func() { + if err = a.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { + log.Error("Could not start server", "error", err) + done <- syscall.SIGTERM // Signal main to exit if server fails + } + }() + + <-done + log.Info("Stopping SSH server...") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer func() { + cancel() + // Clean up internal state after server shutdown + a.mu.Lock() + a.activePrograms = make(map[*tea.Program]programInfo) // Correct type + a.rooms = make(map[string][]*tea.Program) + a.mu.Unlock() + log.Info("Cleared app program stores.") + }() + + if err := a.Shutdown(ctx); err != nil { + log.Error("Could not gracefully stop server", "error", err) + } + log.Info("SSH server stopped.") +} + +func (a *app) ProgramHandler(s ssh.Session) *tea.Program { + userID := s.User() + m := initialModel(a, userID) // initialModel returns *model + + p := tea.NewProgram(m, bubbletea.MakeOptions(s)...) + + m.SetProgram(p) // Give model a reference to its own program + a.addProgram(p, userID) // Register program with the app, passing userID + + return p +} + +func (a *app) addProgram(p *tea.Program, userID string) { + a.mu.Lock() + defer a.mu.Unlock() + a.activePrograms[p] = programInfo{roomName: "", userID: userID} // Store userID + log.Debugf("Program %p added for user %s (unassigned)", p, userID) +} + +func (a *app) AssignProgramToRoom(prog *tea.Program, roomName string) { + a.mu.Lock() + defer a.mu.Unlock() + + currentInfo, exists := a.activePrograms[prog] + if !exists { + // userID is NOT available here if the program isn't in activePrograms. + // NO prog.Model() call should be here. + log.Warnf("Program %p not found in activePrograms, cannot assign to room %s", prog, roomName) + return + } + // Correct: UserID is obtained from the stored currentInfo. + userID := currentInfo.userID + currentRoom := currentInfo.roomName + + if currentRoom != "" && currentRoom != roomName { + var updatedRoomProgs []*tea.Program + for _, pInRoom := range a.rooms[currentRoom] { + if pInRoom != prog { + updatedRoomProgs = append(updatedRoomProgs, pInRoom) + } + } + if len(updatedRoomProgs) > 0 { + a.rooms[currentRoom] = updatedRoomProgs + } else { + delete(a.rooms, currentRoom) + } + // Correct: userID is used from the variable. NO prog.Model() here. + log.Debugf("Program %p (user: %s) moved from room %s", prog, userID, currentRoom) + } + + if _, ok := a.rooms[roomName]; !ok { + a.rooms[roomName] = []*tea.Program{} + } + + foundInNewRoomList := false + for _, pInList := range a.rooms[roomName] { + if pInList == prog { + foundInNewRoomList = true + break + } + } + if !foundInNewRoomList { + a.rooms[roomName] = append(a.rooms[roomName], prog) + } + + a.activePrograms[prog] = programInfo{roomName: roomName, userID: userID} + // Correct: userID is used from the variable. NO prog.Model() here. + // If your error is on this line (or similar), ensure 'userID' is not derived from prog.Model(). + log.Infof("Program %p assigned/confirmed in room %s (user: %s)", prog, roomName, userID) +} + +func (a *app) RemoveProgram(prog *tea.Program) { + a.mu.Lock() + defer a.mu.Unlock() + + info, exists := a.activePrograms[prog] + if !exists { + // Correct: NO prog.Model() call here. + log.Warnf("Attempted to remove unknown or already removed program: %p", prog) + return + } + // Correct: UserID and roomName are obtained from the stored info. + userID := info.userID + roomName := info.roomName + + if roomName != "" { + var updatedRoomProgs []*tea.Program + for _, pInRoom := range a.rooms[roomName] { + if pInRoom != prog { + updatedRoomProgs = append(updatedRoomProgs, pInRoom) + } + } + if len(updatedRoomProgs) > 0 { + a.rooms[roomName] = updatedRoomProgs + } else { + delete(a.rooms, roomName) + } + // Correct: userID is used from the variable. NO prog.Model() here. + // If your error is on this line, ensure 'userID' is not derived from prog.Model(). + log.Infof("Program %p removed from room %s (user: %s)", prog, roomName, userID) + } else { + // Correct: userID is used from the variable. NO prog.Model() here. + log.Debugf("Program %p was not in any room, removing from active list (user: %s).", prog, userID) + } + + delete(a.activePrograms, prog) + // Correct: userID is used from the variable. NO prog.Model() here. + log.Infof("Program %p removed from active list (user: %s).", prog, userID) +} + +func main() { + // For more detailed logs: + // file, err := os.OpenFile("chat_server.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + // if err == nil { + // log.Default().SetOutput(file) + // } + // log.SetLevel(log.DebugLevel) // Show debug messages + + app := newApp() + app.Start() +} + +type ( + errMsg error + chatMsg struct { + id string + text string + room string // So message carries its intended room + } +) + +type modelPhase int + +const ( + phaseRoomInput modelPhase = iota + phaseChat +) + +var roomNameRegex = regexp.MustCompile("^[a-zA-Z0-9]{1,24}$") + +func isValidRoomName(name string) bool { + return roomNameRegex.MatchString(name) +} + +type model struct { + app *app // Pointer to the main application state + program *tea.Program // Pointer to this model's own tea.Program + phase modelPhase + roomInput textinput.Model + currentRoom string + id string // User ID from SSH session + + // Chat fields + viewport viewport.Model + messages []string + textarea textarea.Model + senderStyle lipgloss.Style + err error +} + +func initialModel(appInstance *app, userID string) *model { + roomTi := textinput.New() + roomTi.Placeholder = "e.g., 'dev-chat' or 'gaming'" + roomTi.Focus() + roomTi.CharLimit = 24 + roomTi.Width = 40 + + ta := textarea.New() + ta.Placeholder = "Type your message here and press Enter..." + ta.Prompt = "┃ " + ta.CharLimit = 280 + ta.SetWidth(60) + ta.SetHeight(3) + ta.FocusedStyle.CursorLine = lipgloss.NewStyle() + ta.ShowLineNumbers = false + ta.KeyMap.InsertNewline.SetEnabled(false) + + vp := viewport.New(60, 15) + vp.SetContent("Welcome! Please enter a room name to join or create.") + + return &model{ + app: appInstance, + id: userID, + phase: phaseRoomInput, + roomInput: roomTi, + textarea: ta, + messages: []string{}, + viewport: vp, + senderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("12")), + err: nil, + } +} + +func (m *model) SetProgram(p *tea.Program) { + m.program = p +} + +func (m *model) Init() tea.Cmd { + switch m.phase { + case phaseRoomInput: + return textinput.Blink + case phaseChat: + return textarea.Blink + default: + return nil + } +} + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + + switch m.phase { + case phaseRoomInput: + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + if m.program != nil { + m.app.RemoveProgram(m.program) + } + return m, tea.Quit + case tea.KeyEnter: + roomName := strings.TrimSpace(m.roomInput.Value()) + if isValidRoomName(roomName) { + m.currentRoom = roomName + m.phase = phaseChat + m.err = nil + + if m.program == nil { + log.Error("CRITICAL: m.program is nil when trying to assign to room. This should not happen.") + m.err = errors.New("internal setup error: program reference missing") + return m, nil + } + m.app.AssignProgramToRoom(m.program, m.currentRoom) + + m.textarea.Focus() + m.roomInput.Blur() + + m.viewport.SetContent(fmt.Sprintf("Welcome to room '%s'! (User: %s)\nMessages will appear below.", m.currentRoom, m.id)) + m.messages = []string{} + cmds = append(cmds, textarea.Blink) + } else { + m.err = errors.New("invalid room name: 1-24 alphanumeric characters required") + m.roomInput.Focus() + cmds = append(cmds, textinput.Blink) + } + return m, tea.Batch(cmds...) + } + case errMsg: + m.err = msg + return m, nil + } + + m.roomInput, cmd = m.roomInput.Update(msg) + cmds = append(cmds, cmd) + + case phaseChat: + var tiCmd tea.Cmd + var vpCmd tea.Cmd + + m.textarea, tiCmd = m.textarea.Update(msg) + m.viewport, vpCmd = m.viewport.Update(msg) + cmds = append(cmds, tiCmd, vpCmd) + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + if m.program != nil { + m.app.RemoveProgram(m.program) + } + return m, tea.Quit + case tea.KeyEnter: + val := strings.TrimSpace(m.textarea.Value()) + if val != "" { + m.app.BroadcastToRoom(m.currentRoom, chatMsg{ + id: m.id, + text: val, + room: m.currentRoom, + }) + m.textarea.Reset() + m.viewport.GotoBottom() + } + } + case chatMsg: + if msg.room == m.currentRoom { + styledId := m.senderStyle.Render(msg.id) + if msg.id == m.id { + styledId = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render(msg.id + " (you)") + } + m.messages = append(m.messages, styledId+": "+msg.text) + m.viewport.SetContent(strings.Join(m.messages, "\n")) + m.viewport.GotoBottom() + } else { + log.Warnf("Client in room '%s' (user '%s') incorrectly received msg for room '%s'. Ignored.", m.currentRoom, m.id, msg.room) + } + case errMsg: + m.err = msg + log.Error("Error in chat model", "user", m.id, "room", m.currentRoom, "error", msg) + return m, nil + } + } + return m, tea.Batch(cmds...) +} + +func (m *model) View() string { + var viewBuilder strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")) + errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true) + + switch m.phase { + case phaseRoomInput: + viewBuilder.WriteString(titleStyle.Render("chatter.sh") + "\n\n") + viewBuilder.WriteString("Please enter a room name to join or create.\n") + viewBuilder.WriteString("Rules: 1-24 characters, letters (a-z, A-Z) and numbers (0-9) only.\n\n") + viewBuilder.WriteString(m.roomInput.View()) + viewBuilder.WriteString("\n\n(Ctrl+C or Esc to quit)") + case phaseChat: + header := fmt.Sprintf("Room: %s / User: %s", m.currentRoom, m.id) + viewBuilder.WriteString(titleStyle.Render(header) + "\n") + viewBuilder.WriteString(lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).Width(m.viewport.Width).Render(m.viewport.View()) + "\n\n") + viewBuilder.WriteString(m.textarea.View()) + viewBuilder.WriteString("\n\n(Ctrl+C or Esc to quit)") + default: + viewBuilder.WriteString("Unknown application state. This is a bug.") + } + + if m.err != nil { + viewBuilder.WriteString("\n\n" + errorStyle.Render("Error: "+m.err.Error())) + } + + return viewBuilder.String() + "\n" +}