refactor
This commit is contained in:
292
main.go
292
main.go
@ -133,15 +133,16 @@ func (a *app) Start() {
|
|||||||
go func() {
|
go func() {
|
||||||
if err = a.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
|
if err = a.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
|
||||||
log.Error("Could not start server", "error", err)
|
log.Error("Could not start server", "error", err)
|
||||||
done <- syscall.SIGTERM
|
done <- syscall.SIGTERM // Trigger shutdown if server fails to start
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
<-done
|
<-done // Wait for shutdown signal
|
||||||
log.Info("Stopping SSH server...")
|
log.Info("Stopping SSH server...")
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer func() {
|
defer func() {
|
||||||
cancel()
|
cancel()
|
||||||
|
// Clear program and room data after shutdown attempt
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.activePrograms = make(map[*tea.Program]programInfo)
|
a.activePrograms = make(map[*tea.Program]programInfo)
|
||||||
a.rooms = make(map[string][]*tea.Program)
|
a.rooms = make(map[string][]*tea.Program)
|
||||||
@ -149,7 +150,7 @@ func (a *app) Start() {
|
|||||||
log.Info("Cleared app program stores.")
|
log.Info("Cleared app program stores.")
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err := a.Shutdown(ctx); err != nil {
|
if err := a.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) { // Don't log ErrServerClosed as an error during graceful shutdown
|
||||||
log.Error("Could not gracefully stop server", "error", err)
|
log.Error("Could not gracefully stop server", "error", err)
|
||||||
}
|
}
|
||||||
log.Info("SSH server stopped.")
|
log.Info("SSH server stopped.")
|
||||||
@ -157,6 +158,9 @@ func (a *app) Start() {
|
|||||||
|
|
||||||
func (a *app) ProgramHandler(s ssh.Session) *tea.Program {
|
func (a *app) ProgramHandler(s ssh.Session) *tea.Program {
|
||||||
userID := s.User()
|
userID := s.User()
|
||||||
|
if userID == "" {
|
||||||
|
userID = "guest" // Default userID if empty
|
||||||
|
}
|
||||||
m := initialModel(a, userID)
|
m := initialModel(a, userID)
|
||||||
p := tea.NewProgram(m, bubbletea.MakeOptions(s)...)
|
p := tea.NewProgram(m, bubbletea.MakeOptions(s)...)
|
||||||
m.SetProgram(p) // Give the model a reference to its own program
|
m.SetProgram(p) // Give the model a reference to its own program
|
||||||
@ -185,18 +189,20 @@ func (a *app) AssignProgramToRoom(prog *tea.Program, roomName string) {
|
|||||||
|
|
||||||
// If program was in a different room, remove it from the old room's list
|
// If program was in a different room, remove it from the old room's list
|
||||||
if currentRoom != "" && currentRoom != roomName {
|
if currentRoom != "" && currentRoom != roomName {
|
||||||
var updatedRoomProgs []*tea.Program
|
if progsInOldRoom, ok := a.rooms[currentRoom]; ok {
|
||||||
for _, pInRoom := range a.rooms[currentRoom] {
|
var updatedRoomProgs []*tea.Program
|
||||||
if pInRoom != prog {
|
for _, pInRoom := range progsInOldRoom {
|
||||||
updatedRoomProgs = append(updatedRoomProgs, pInRoom)
|
if pInRoom != prog {
|
||||||
|
updatedRoomProgs = append(updatedRoomProgs, pInRoom)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if len(updatedRoomProgs) > 0 {
|
||||||
|
a.rooms[currentRoom] = updatedRoomProgs
|
||||||
|
} else {
|
||||||
|
delete(a.rooms, currentRoom) // Clean up empty room
|
||||||
|
}
|
||||||
|
log.Debugf("Program %p (user: %s) moved from room %s", prog, userID, currentRoom)
|
||||||
}
|
}
|
||||||
if len(updatedRoomProgs) > 0 {
|
|
||||||
a.rooms[currentRoom] = updatedRoomProgs
|
|
||||||
} else {
|
|
||||||
delete(a.rooms, currentRoom)
|
|
||||||
}
|
|
||||||
log.Debugf("Program %p (user: %s) moved from room %s", prog, userID, currentRoom)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure the new room exists in the map
|
// Ensure the new room exists in the map
|
||||||
@ -236,34 +242,34 @@ func (a *app) HandleUserExitFromRoom(prog *tea.Program, oldRoomName string) {
|
|||||||
}
|
}
|
||||||
userID := info.userID
|
userID := info.userID
|
||||||
|
|
||||||
// Prepare list of recipients for the leave message BEFORE modifying room structure
|
|
||||||
leaveMsgText := fmt.Sprintf("%s has left the room.", userID)
|
leaveMsgText := fmt.Sprintf("%s has left the room.", userID)
|
||||||
var recipients []*tea.Program
|
var recipients []*tea.Program
|
||||||
if progs, ok := a.rooms[info.roomName]; ok {
|
if progs, ok := a.rooms[info.roomName]; ok {
|
||||||
recipients = make([]*tea.Program, 0, len(progs)) // Allocate with capacity
|
recipients = make([]*tea.Program, 0, len(progs))
|
||||||
for _, p := range progs {
|
for _, p := range progs {
|
||||||
if p != prog { // Don't send to the exiting program
|
if p != prog {
|
||||||
recipients = append(recipients, p)
|
recipients = append(recipients, p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now, update the room structure
|
|
||||||
var updatedRoomProgs []*tea.Program
|
var updatedRoomProgs []*tea.Program
|
||||||
for _, pInRoom := range a.rooms[info.roomName] {
|
if progsInRoom, ok := a.rooms[info.roomName]; ok {
|
||||||
if pInRoom != prog {
|
for _, pInRoom := range progsInRoom {
|
||||||
updatedRoomProgs = append(updatedRoomProgs, pInRoom)
|
if pInRoom != prog {
|
||||||
|
updatedRoomProgs = append(updatedRoomProgs, pInRoom)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(updatedRoomProgs) > 0 {
|
if len(updatedRoomProgs) > 0 {
|
||||||
a.rooms[info.roomName] = updatedRoomProgs
|
a.rooms[info.roomName] = updatedRoomProgs
|
||||||
} else {
|
} else {
|
||||||
delete(a.rooms, info.roomName) // Clean up empty room
|
delete(a.rooms, info.roomName)
|
||||||
}
|
}
|
||||||
a.activePrograms[prog] = programInfo{roomName: "", userID: userID} // Update program's state
|
a.activePrograms[prog] = programInfo{roomName: "", userID: userID}
|
||||||
a.mu.Unlock() // Unlock before broadcasting
|
a.mu.Unlock() // Unlock before broadcasting
|
||||||
|
|
||||||
// Broadcast leave message
|
|
||||||
if len(recipients) > 0 {
|
if len(recipients) > 0 {
|
||||||
for _, p := range recipients {
|
for _, p := range recipients {
|
||||||
go p.Send(chatMsg{id: "system", text: leaveMsgText, room: oldRoomName, isSystemMessage: true})
|
go p.Send(chatMsg{id: "system", text: leaveMsgText, room: oldRoomName, isSystemMessage: true})
|
||||||
@ -288,40 +294,38 @@ func (a *app) RemoveProgram(prog *tea.Program) {
|
|||||||
var leaveRecipients []*tea.Program
|
var leaveRecipients []*tea.Program
|
||||||
var leaveMsgText string
|
var leaveMsgText string
|
||||||
|
|
||||||
// If the user was in a room, prepare to notify others
|
|
||||||
if roomName != "" {
|
if roomName != "" {
|
||||||
leaveMsgText = fmt.Sprintf("%s has disconnected.", userID)
|
leaveMsgText = fmt.Sprintf("%s has disconnected.", userID)
|
||||||
if progs, ok := a.rooms[roomName]; ok {
|
if progs, ok := a.rooms[roomName]; ok {
|
||||||
leaveRecipients = make([]*tea.Program, 0, len(progs))
|
leaveRecipients = make([]*tea.Program, 0, len(progs))
|
||||||
for _, p := range progs {
|
for _, p := range progs {
|
||||||
if p != prog { // Don't collect the disconnecting program
|
if p != prog {
|
||||||
leaveRecipients = append(leaveRecipients, p)
|
leaveRecipients = append(leaveRecipients, p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from room tracking
|
|
||||||
if roomName != "" {
|
if roomName != "" {
|
||||||
var updatedRoomProgs []*tea.Program
|
var updatedRoomProgs []*tea.Program
|
||||||
for _, pInRoom := range a.rooms[roomName] {
|
if progsInRoom, ok := a.rooms[roomName]; ok {
|
||||||
if pInRoom != prog {
|
for _, pInRoom := range progsInRoom {
|
||||||
updatedRoomProgs = append(updatedRoomProgs, pInRoom)
|
if pInRoom != prog {
|
||||||
|
updatedRoomProgs = append(updatedRoomProgs, pInRoom)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(updatedRoomProgs) > 0 {
|
if len(updatedRoomProgs) > 0 {
|
||||||
a.rooms[roomName] = updatedRoomProgs
|
a.rooms[roomName] = updatedRoomProgs
|
||||||
} else {
|
} else {
|
||||||
delete(a.rooms, roomName) // Clean up room if it becomes empty
|
delete(a.rooms, roomName)
|
||||||
}
|
}
|
||||||
log.Infof("Program %p (user: %s) removed from room %s tracking (full disconnect).", prog, userID, roomName)
|
log.Infof("Program %p (user: %s) removed from room %s tracking (full disconnect).", prog, userID, roomName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from active programs list
|
|
||||||
delete(a.activePrograms, prog)
|
delete(a.activePrograms, prog)
|
||||||
a.mu.Unlock() // Unlock before broadcasting
|
a.mu.Unlock() // Unlock before broadcasting
|
||||||
|
|
||||||
// Broadcast disconnect message to former room members
|
|
||||||
if roomName != "" && len(leaveRecipients) > 0 {
|
if roomName != "" && len(leaveRecipients) > 0 {
|
||||||
for _, p := range leaveRecipients {
|
for _, p := range leaveRecipients {
|
||||||
go p.Send(chatMsg{id: "system", text: leaveMsgText, room: roomName, isSystemMessage: true})
|
go p.Send(chatMsg{id: "system", text: leaveMsgText, room: roomName, isSystemMessage: true})
|
||||||
@ -331,24 +335,27 @@ func (a *app) RemoveProgram(prog *tea.Program) {
|
|||||||
log.Infof("Program %p (user: %s) removed from active list (full disconnect).", prog, userID)
|
log.Infof("Program %p (user: %s) removed from active list (full disconnect).", prog, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- main, model types, and functions continue below ---
|
|
||||||
func main() {
|
func main() {
|
||||||
// Optional: Setup file logging
|
// Optional: Setup file logging
|
||||||
// file, err := os.OpenFile("chatter_server.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
// logFile, err := os.OpenFile("chatter_server.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
||||||
// if err == nil {
|
// if err == nil {
|
||||||
// log.Default().SetOutput(io.MultiWriter(os.Stderr, file)) // Log to both stderr and file
|
// defer logFile.Close()
|
||||||
// defer file.Close()
|
// // You can use a multiwriter to log to both stderr and file if desired
|
||||||
|
// // log.Default().SetOutput(io.MultiWriter(os.Stderr, logFile))
|
||||||
|
// log.Default().SetOutput(logFile) // Log only to file
|
||||||
|
// log.SetTimeFormat(time.RFC3339)
|
||||||
|
// log.SetLevel(log.DebugLevel) // Set desired log level
|
||||||
|
// } else {
|
||||||
|
// log.Error("Failed to open log file", "error", err)
|
||||||
// }
|
// }
|
||||||
// log.SetLevel(log.DebugLevel)
|
|
||||||
// log.SetTimeFormat(time.Kitchen)
|
|
||||||
|
|
||||||
app := newApp()
|
app := newApp()
|
||||||
app.Start()
|
app.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
type (
|
type (
|
||||||
errMsg error
|
errMsg error // Used for tea.Cmd to send errors to Update
|
||||||
chatMsg struct {
|
chatMsg struct { // Used for tea.Cmd to send chat messages to Update
|
||||||
id string
|
id string
|
||||||
text string
|
text string
|
||||||
room string
|
room string
|
||||||
@ -363,7 +370,7 @@ const (
|
|||||||
phaseChat
|
phaseChat
|
||||||
)
|
)
|
||||||
|
|
||||||
var roomNameRegex = regexp.MustCompile("^[a-zA-Z0-9]{1,24}$")
|
var roomNameRegex = regexp.MustCompile("^[a-zA-Z0-9]{1," + fmt.Sprintf("%d", maxRoomNameLength) + "}$")
|
||||||
|
|
||||||
func isValidRoomName(name string) bool {
|
func isValidRoomName(name string) bool {
|
||||||
return roomNameRegex.MatchString(name)
|
return roomNameRegex.MatchString(name)
|
||||||
@ -371,18 +378,18 @@ func isValidRoomName(name string) bool {
|
|||||||
|
|
||||||
type model struct {
|
type model struct {
|
||||||
app *app
|
app *app
|
||||||
program *tea.Program
|
program *tea.Program // Reference to the Bubble Tea program managing this model
|
||||||
phase modelPhase
|
phase modelPhase
|
||||||
roomInput textinput.Model
|
roomInput textinput.Model
|
||||||
currentRoom string
|
currentRoom string
|
||||||
id string // User ID from SSH session
|
id string // User ID from SSH session
|
||||||
viewport viewport.Model
|
viewport viewport.Model
|
||||||
messages []string
|
messages []string // Chat messages displayed in the viewport
|
||||||
textarea textarea.Model
|
textarea textarea.Model
|
||||||
senderStyle lipgloss.Style
|
senderStyle lipgloss.Style
|
||||||
systemMessageStyle lipgloss.Style // For join/leave/command output/errors
|
systemMessageStyle lipgloss.Style // For join/leave/command output (not for m.err)
|
||||||
messageTimestamps []time.Time // For rate limiting
|
messageTimestamps []time.Time // For rate limiting user messages
|
||||||
err error
|
err error // For displaying transient input errors to the user (e.g., rate limit, too long)
|
||||||
}
|
}
|
||||||
|
|
||||||
func initialModel(appInstance *app, userID string) *model {
|
func initialModel(appInstance *app, userID string) *model {
|
||||||
@ -395,14 +402,14 @@ func initialModel(appInstance *app, userID string) *model {
|
|||||||
ta := textarea.New()
|
ta := textarea.New()
|
||||||
ta.Placeholder = "Type your message or a command (e.g. /help)..."
|
ta.Placeholder = "Type your message or a command (e.g. /help)..."
|
||||||
ta.Prompt = "┃ "
|
ta.Prompt = "┃ "
|
||||||
ta.CharLimit = maxChatMessageLength // Updated to new max length
|
ta.CharLimit = maxChatMessageLength // User-facing limit in textarea
|
||||||
ta.SetWidth(60)
|
ta.SetWidth(60) // Example width, adjust as needed
|
||||||
ta.SetHeight(3)
|
ta.SetHeight(3) // Multi-line input area
|
||||||
ta.FocusedStyle.CursorLine = lipgloss.NewStyle()
|
ta.FocusedStyle.CursorLine = lipgloss.NewStyle() // No special styling for cursor line
|
||||||
ta.ShowLineNumbers = false
|
ta.ShowLineNumbers = false
|
||||||
ta.KeyMap.InsertNewline.SetEnabled(false)
|
ta.KeyMap.InsertNewline.SetEnabled(false) // Use Enter to send, not for newlines in message
|
||||||
|
|
||||||
vp := viewport.New(60, 15)
|
vp := viewport.New(60, 15) // Example dimensions, adjust as needed
|
||||||
vp.SetContent("Welcome! Please enter a room name to join or create.")
|
vp.SetContent("Welcome! Please enter a room name to join or create.")
|
||||||
|
|
||||||
return &model{
|
return &model{
|
||||||
@ -414,9 +421,9 @@ func initialModel(appInstance *app, userID string) *model {
|
|||||||
messages: []string{},
|
messages: []string{},
|
||||||
messageTimestamps: []time.Time{}, // Initialize for rate limiting
|
messageTimestamps: []time.Time{}, // Initialize for rate limiting
|
||||||
viewport: vp,
|
viewport: vp,
|
||||||
senderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("12")), // Blue for sender
|
senderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("12")), // Light Blue for sender ID
|
||||||
systemMessageStyle: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#909090", Dark: "#6C6C6C"}), // Grey for system
|
systemMessageStyle: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#909090", Dark: "#6C6C6C"}), // Grey for system messages
|
||||||
err: nil,
|
err: nil, // No error initially
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -441,35 +448,34 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
switch m.phase {
|
switch m.phase {
|
||||||
case phaseRoomInput:
|
case phaseRoomInput:
|
||||||
switch msg := msg.(type) {
|
switch keyEvent := msg.(type) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch msg.Type {
|
switch keyEvent.Type {
|
||||||
case tea.KeyCtrlC, tea.KeyEsc:
|
case tea.KeyCtrlC, tea.KeyEsc:
|
||||||
if m.program != nil {
|
if m.program != nil {
|
||||||
m.app.RemoveProgram(m.program)
|
m.app.RemoveProgram(m.program) // Deregister before quitting
|
||||||
}
|
}
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
case tea.KeyEnter:
|
case tea.KeyEnter:
|
||||||
roomName := strings.TrimSpace(m.roomInput.Value())
|
roomName := strings.TrimSpace(m.roomInput.Value())
|
||||||
if roomName == "" {
|
if roomName == "" { // Generate random room name if input is empty
|
||||||
roomName = generateRandomRoomName(maxRoomNameLength)
|
roomName = generateRandomRoomName(8) // Shorter random name for readability
|
||||||
m.roomInput.SetValue(roomName) // Show generated name in input briefly
|
m.roomInput.SetValue(roomName) // Show generated name in input briefly
|
||||||
log.Infof("User %s generated random room name: %s", m.id, roomName)
|
log.Infof("User %s generated random room name: %s", m.id, roomName)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isValidRoomName(roomName) {
|
if isValidRoomName(roomName) {
|
||||||
m.currentRoom = roomName
|
m.currentRoom = roomName
|
||||||
m.phase = phaseChat
|
m.phase = phaseChat
|
||||||
m.err = nil
|
m.err = nil // Clear any previous error from room input phase
|
||||||
|
|
||||||
if m.program == nil {
|
if m.program == nil {
|
||||||
log.Error("CRITICAL: m.program is nil when trying to assign to room.")
|
log.Error("CRITICAL: m.program is nil when trying to assign to room.")
|
||||||
m.err = errors.New("internal setup error: program reference missing")
|
m.err = errors.New("internal setup error: program reference missing")
|
||||||
return m, nil
|
return m, nil // Stay in current phase with error displayed
|
||||||
}
|
}
|
||||||
m.app.AssignProgramToRoom(m.program, m.currentRoom)
|
m.app.AssignProgramToRoom(m.program, m.currentRoom)
|
||||||
|
|
||||||
// Send join message
|
|
||||||
joinMsgText := fmt.Sprintf("%s has joined the room.", m.id)
|
joinMsgText := fmt.Sprintf("%s has joined the room.", m.id)
|
||||||
m.app.BroadcastToRoom(m.currentRoom, chatMsg{
|
m.app.BroadcastToRoom(m.currentRoom, chatMsg{
|
||||||
id: "system",
|
id: "system",
|
||||||
@ -480,48 +486,62 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
m.textarea.Focus()
|
m.textarea.Focus()
|
||||||
m.roomInput.Blur()
|
m.roomInput.Blur()
|
||||||
|
// Initial welcome message for the room, not appended to m.messages directly to avoid duplication
|
||||||
m.viewport.SetContent(fmt.Sprintf("Welcome to room '%s'! (User: %s)\nType /help for commands.", m.currentRoom, m.id))
|
m.viewport.SetContent(fmt.Sprintf("Welcome to room '%s'! (User: %s)\nType /help for commands.", m.currentRoom, m.id))
|
||||||
m.messages = []string{} // Clear previous messages if any
|
m.messages = []string{} // Clear message history from potential previous room/session
|
||||||
cmds = append(cmds, textarea.Blink)
|
cmds = append(cmds, textarea.Blink)
|
||||||
} else {
|
} else {
|
||||||
m.err = errors.New("invalid room name: 1-24 alphanumeric characters required")
|
m.err = fmt.Errorf("invalid room name: use 1-%d alphanumeric characters", maxRoomNameLength)
|
||||||
m.roomInput.Focus() // Keep focus on room input if error
|
m.roomInput.Focus() // Keep focus on room input if error
|
||||||
cmds = append(cmds, textinput.Blink)
|
cmds = append(cmds, textinput.Blink)
|
||||||
}
|
}
|
||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
case errMsg:
|
case errMsg: // This would be an error from a command, not typically used in room input phase
|
||||||
m.err = msg
|
m.err = keyEvent
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.roomInput, cmd = m.roomInput.Update(msg)
|
m.roomInput, cmd = m.roomInput.Update(msg)
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
case phaseChat:
|
case phaseChat:
|
||||||
var tiCmd tea.Cmd
|
// Order of updates: components first, then specific message handling.
|
||||||
var vpCmd tea.Cmd
|
m.textarea, cmd = m.textarea.Update(msg)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
m.viewport, cmd = m.viewport.Update(msg) // Handles scrolling based on content changes
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
// Order matters: update inputs first, then handle specific key presses or messages
|
switch event := msg.(type) {
|
||||||
m.textarea, tiCmd = m.textarea.Update(msg)
|
|
||||||
m.viewport, vpCmd = m.viewport.Update(msg) // Handles scrolling, etc.
|
|
||||||
cmds = append(cmds, tiCmd, vpCmd)
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch msg.Type {
|
// If a key is pressed (and it's not Enter potentially causing a new error),
|
||||||
|
// and there was a previous user input error (m.err), clear it.
|
||||||
|
if m.err != nil && event.Type != tea.KeyEnter {
|
||||||
|
isTextModifyingKey := false
|
||||||
|
switch event.Type {
|
||||||
|
case tea.KeyRunes, tea.KeySpace, tea.KeyBackspace, tea.KeyDelete:
|
||||||
|
isTextModifyingKey = true
|
||||||
|
}
|
||||||
|
if isTextModifyingKey {
|
||||||
|
m.err = nil // Clear error as user types new input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch event.Type {
|
||||||
case tea.KeyCtrlC, tea.KeyEsc:
|
case tea.KeyCtrlC, tea.KeyEsc:
|
||||||
if m.program != nil {
|
if m.program != nil {
|
||||||
m.app.RemoveProgram(m.program)
|
m.app.RemoveProgram(m.program)
|
||||||
}
|
}
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
|
||||||
case tea.KeyEnter:
|
case tea.KeyEnter:
|
||||||
val := strings.TrimSpace(m.textarea.Value())
|
val := strings.TrimSpace(m.textarea.Value())
|
||||||
m.textarea.Reset() // Reset textarea immediately
|
m.textarea.Reset() // Reset textarea immediately after getting value
|
||||||
|
|
||||||
if strings.HasPrefix(val, "/") {
|
if strings.HasPrefix(val, "/") {
|
||||||
|
m.err = nil // Clear previous input errors when a command is attempted
|
||||||
parts := strings.Fields(val)
|
parts := strings.Fields(val)
|
||||||
command := parts[0]
|
command := parts[0]
|
||||||
// args := parts[1:] // For future use if commands take arguments
|
// args := parts[1:] // For future use
|
||||||
|
|
||||||
switch command {
|
switch command {
|
||||||
case "/help":
|
case "/help":
|
||||||
@ -537,7 +557,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.phase = phaseRoomInput
|
m.phase = phaseRoomInput
|
||||||
m.currentRoom = ""
|
m.currentRoom = ""
|
||||||
m.messages = []string{}
|
m.messages = []string{}
|
||||||
m.err = nil
|
m.err = nil // Ensure error is clear when changing phase
|
||||||
m.roomInput.Reset()
|
m.roomInput.Reset()
|
||||||
m.roomInput.Placeholder = "Enter another room or press Enter for random..."
|
m.roomInput.Placeholder = "Enter another room or press Enter for random..."
|
||||||
m.roomInput.Focus()
|
m.roomInput.Focus()
|
||||||
@ -552,88 +572,83 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if len(userIDs) > 0 {
|
if len(userIDs) > 0 {
|
||||||
userListText = "Users in '" + m.currentRoom + "':\n " + strings.Join(userIDs, "\n ")
|
userListText = "Users in '" + m.currentRoom + "':\n " + strings.Join(userIDs, "\n ")
|
||||||
} else {
|
} else {
|
||||||
userListText = "You are the only one here."
|
// This case (only one here) should be handled by GetUserIDsInRoom including self.
|
||||||
|
userListText = "It seems you are the only one here."
|
||||||
}
|
}
|
||||||
m.messages = append(m.messages, m.systemMessageStyle.Render(userListText))
|
m.messages = append(m.messages, m.systemMessageStyle.Render(userListText))
|
||||||
} else {
|
} else {
|
||||||
|
// This should not happen if in phaseChat, currentRoom should be set.
|
||||||
m.messages = append(m.messages, m.systemMessageStyle.Render("Error: Not in a room."))
|
m.messages = append(m.messages, m.systemMessageStyle.Render("Error: Not in a room."))
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
m.messages = append(m.messages, m.systemMessageStyle.Render("Unknown command: "+command))
|
m.messages = append(m.messages, m.systemMessageStyle.Render("Unknown command: "+command))
|
||||||
}
|
}
|
||||||
|
// Update viewport with command output
|
||||||
m.viewport.SetContent(strings.Join(m.messages, "\n"))
|
m.viewport.SetContent(strings.Join(m.messages, "\n"))
|
||||||
m.viewport.GotoBottom()
|
m.viewport.GotoBottom()
|
||||||
|
|
||||||
} else if val != "" { // Regular chat message
|
} else if val != "" { // Regular chat message attempt
|
||||||
// 1. Message Length Check
|
// 1. Message Length Check
|
||||||
if len(val) > maxChatMessageLength {
|
if len(val) > maxChatMessageLength {
|
||||||
errMsgText := fmt.Sprintf("Your message is too long (max %d chars). It was not sent.", maxChatMessageLength)
|
m.err = fmt.Errorf("message too long (max %d chars). Not sent.", maxChatMessageLength)
|
||||||
m.messages = append(m.messages, m.systemMessageStyle.Render(errMsgText))
|
} else {
|
||||||
m.viewport.SetContent(strings.Join(m.messages, "\n"))
|
// 2. Rate Limiting Check
|
||||||
m.viewport.GotoBottom()
|
now := time.Now()
|
||||||
// Do not broadcast, keep textarea focused for correction or new message
|
// Remove timestamps older than the window
|
||||||
cmds = append(cmds, m.textarea.Focus(), textarea.Blink)
|
var recentTimestamps []time.Time
|
||||||
return m, tea.Batch(cmds...) // Return early, message not sent
|
for _, ts := range m.messageTimestamps {
|
||||||
}
|
if now.Sub(ts) < rateLimitWindow {
|
||||||
|
recentTimestamps = append(recentTimestamps, ts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.messageTimestamps = recentTimestamps
|
||||||
|
|
||||||
// 2. Rate Limiting Check
|
if len(m.messageTimestamps) >= rateLimitMaxMessages {
|
||||||
now := time.Now()
|
m.err = fmt.Errorf("too many messages (max %d per %s). Wait. Not sent.", rateLimitMaxMessages, rateLimitWindow.String())
|
||||||
var recentTimestamps []time.Time
|
} else {
|
||||||
for _, ts := range m.messageTimestamps {
|
// SUCCESSFUL SEND
|
||||||
if now.Sub(ts) < rateLimitWindow {
|
m.err = nil // Clear any prior input error
|
||||||
recentTimestamps = append(recentTimestamps, ts)
|
m.messageTimestamps = append(m.messageTimestamps, now) // Add current message's timestamp
|
||||||
|
|
||||||
|
m.app.BroadcastToRoom(m.currentRoom, chatMsg{
|
||||||
|
id: m.id,
|
||||||
|
text: val,
|
||||||
|
room: m.currentRoom,
|
||||||
|
isSystemMessage: false,
|
||||||
|
}, nil) // Broadcast to all, including self (sender)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.messageTimestamps = recentTimestamps
|
} else { // val == "" (empty input submitted via Enter)
|
||||||
|
m.err = nil // Clear any previous error, do nothing for empty message
|
||||||
if len(m.messageTimestamps) >= rateLimitMaxMessages {
|
|
||||||
errMsgText := fmt.Sprintf("You're sending messages too quickly (max %d per %s). Please wait. Your message was not sent.", rateLimitMaxMessages, rateLimitWindow.String())
|
|
||||||
m.messages = append(m.messages, m.systemMessageStyle.Render(errMsgText))
|
|
||||||
m.viewport.SetContent(strings.Join(m.messages, "\n"))
|
|
||||||
m.viewport.GotoBottom()
|
|
||||||
// Do not broadcast, keep textarea focused
|
|
||||||
cmds = append(cmds, m.textarea.Focus(), textarea.Blink)
|
|
||||||
return m, tea.Batch(cmds...) // Return early, message not sent
|
|
||||||
}
|
|
||||||
|
|
||||||
// If all checks pass, add current timestamp and broadcast
|
|
||||||
m.messageTimestamps = append(m.messageTimestamps, now)
|
|
||||||
|
|
||||||
m.app.BroadcastToRoom(m.currentRoom, chatMsg{
|
|
||||||
id: m.id,
|
|
||||||
text: val,
|
|
||||||
room: m.currentRoom,
|
|
||||||
isSystemMessage: false,
|
|
||||||
}, nil) // Broadcast to all, including self (sender)
|
|
||||||
}
|
}
|
||||||
// After sending or command, ensure textarea is focused for next input
|
// After sending, command, or empty Enter, ensure textarea is focused for next input
|
||||||
cmds = append(cmds, m.textarea.Focus(), textarea.Blink)
|
cmds = append(cmds, m.textarea.Focus(), textarea.Blink)
|
||||||
}
|
|
||||||
|
} // end switch event.Type (inner for KeyMsg)
|
||||||
|
|
||||||
case chatMsg: // Received a message (could be own, from others, or system)
|
case chatMsg: // Received a message (could be own, from others, or system)
|
||||||
if msg.room == m.currentRoom { // Process only if message is for the current room
|
// Process only if message is for the current room
|
||||||
|
if event.room == m.currentRoom {
|
||||||
var styledMessage string
|
var styledMessage string
|
||||||
if msg.isSystemMessage {
|
if event.isSystemMessage {
|
||||||
styledMessage = m.systemMessageStyle.Render(msg.text)
|
styledMessage = m.systemMessageStyle.Render(event.text)
|
||||||
} else {
|
} else {
|
||||||
styledId := m.senderStyle.Render(msg.id)
|
styledId := m.senderStyle.Render(event.id)
|
||||||
styledMessage = styledId + ": " + msg.text
|
// Handle potential multi-line messages by ensuring they are displayed correctly
|
||||||
|
messageText := strings.ReplaceAll(event.text, "\n", "\n ") // Indent subsequent lines
|
||||||
|
styledMessage = styledId + ": " + messageText
|
||||||
}
|
}
|
||||||
m.messages = append(m.messages, styledMessage)
|
m.messages = append(m.messages, styledMessage)
|
||||||
m.viewport.SetContent(strings.Join(m.messages, "\n"))
|
m.viewport.SetContent(strings.Join(m.messages, "\n"))
|
||||||
m.viewport.GotoBottom()
|
m.viewport.GotoBottom()
|
||||||
} else if msg.room != "" && !msg.isSystemMessage { // Log if a non-system message for a different room is somehow received by this model
|
} else if event.room != "" && !event.isSystemMessage {
|
||||||
log.Warnf("Client '%s' in room '%s' received non-system message for different room '%s': %s", m.id, m.currentRoom, msg.room, msg.text)
|
// Log if a non-system message for a different room is somehow received by this model's Update
|
||||||
|
log.Warnf("Client '%s' in room '%s' received non-system message for different room '%s': %s", m.id, m.currentRoom, event.room, event.text)
|
||||||
}
|
}
|
||||||
|
|
||||||
case errMsg:
|
case errMsg: // This is for system/internal errors passed as errMsg type to Update
|
||||||
m.err = msg
|
m.err = event // Display this error using the m.err mechanism
|
||||||
log.Error("Error in chat model", "user", m.id, "room", m.currentRoom, "error", msg)
|
log.Error("Internal error in model", "user", m.id, "room", m.currentRoom, "error", event)
|
||||||
// Optionally display this error in the viewport too
|
|
||||||
// m.messages = append(m.messages, m.systemMessageStyle.Render("An error occurred: "+msg.Error()))
|
|
||||||
// m.viewport.SetContent(strings.Join(m.messages, "\n"))
|
|
||||||
// m.viewport.GotoBottom()
|
|
||||||
return m, nil // Or tea.Batch(cmds...) if other cmds are important
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
@ -642,14 +657,14 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
func (m *model) View() string {
|
func (m *model) View() string {
|
||||||
var viewBuilder strings.Builder
|
var viewBuilder strings.Builder
|
||||||
|
|
||||||
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")) // Purple
|
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")) // Purple for titles
|
||||||
errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true) // Red
|
errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true) // Red for m.err
|
||||||
|
|
||||||
switch m.phase {
|
switch m.phase {
|
||||||
case phaseRoomInput:
|
case phaseRoomInput:
|
||||||
viewBuilder.WriteString(titleStyle.Render("chatter.sh") + "\n\n")
|
viewBuilder.WriteString(titleStyle.Render("chatter.sh") + "\n\n")
|
||||||
viewBuilder.WriteString("Please enter a room name to join or create.\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")
|
viewBuilder.WriteString(fmt.Sprintf("Rules: 1-%d characters, letters (a-z, A-Z) and numbers (0-9) only.\n", maxRoomNameLength))
|
||||||
viewBuilder.WriteString("(Leave empty and press Enter for a random room name.)\n\n")
|
viewBuilder.WriteString("(Leave empty and press Enter for a random room name.)\n\n")
|
||||||
viewBuilder.WriteString(m.roomInput.View())
|
viewBuilder.WriteString(m.roomInput.View())
|
||||||
viewBuilder.WriteString("\n\n(Ctrl+C or Esc to quit)")
|
viewBuilder.WriteString("\n\n(Ctrl+C or Esc to quit)")
|
||||||
@ -664,6 +679,7 @@ func (m *model) View() string {
|
|||||||
viewBuilder.WriteString("Unknown application state. This is a bug.")
|
viewBuilder.WriteString("Unknown application state. This is a bug.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Display m.err if it's set (for transient input errors like rate limit, too long message)
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
viewBuilder.WriteString("\n\n" + errorStyle.Render("Error: "+m.err.Error()))
|
viewBuilder.WriteString("\n\n" + errorStyle.Render("Error: "+m.err.Error()))
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user