package main import ( "context" "crypto/rand" // For more robust random generation "errors" "fmt" "math/big" // For crypto/rand with charset "net" "os" "os/signal" "regexp" "sort" // For /users command "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" maxRoomNameLength = 24 roomNameCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" rateLimitMaxMessages = 15 // Max messages per user within the rateLimitWindow rateLimitWindow = 15 * time.Second // The time window for rate limiting maxChatMessageLength = 2000 // Max characters per chat message ) // generateRandomRoomName creates a random alphanumeric string of a given length. func generateRandomRoomName(length int) string { b := make([]byte, length) for i := range b { n, err := rand.Int(rand.Reader, big.NewInt(int64(len(roomNameCharset)))) if err != nil { log.Errorf("Error generating random char for room name: %v, falling back for char %d", err, i) b[i] = roomNameCharset[0] // Fallback to 'a' continue } b[i] = roomNameCharset[n.Int64()] } return string(b) } 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, // optionally excluding one program. func (a *app) BroadcastToRoom(roomName string, msg tea.Msg, excludeProg *tea.Program) { a.mu.Lock() defer a.mu.Unlock() if progsInRoom, ok := a.rooms[roomName]; ok { log.Debugf("Broadcasting to room %s (%d programs, excluding %p): %+v", roomName, len(progsInRoom), excludeProg, msg) for _, p := range progsInRoom { if excludeProg != nil && p == excludeProg { continue } go p.Send(msg) } } else { log.Warnf("Attempted to broadcast to non-existent or empty room: %s", roomName) } } func (a *app) GetUserIDsInRoom(roomName string) []string { a.mu.Lock() defer a.mu.Unlock() var userIDs []string if progsInRoom, ok := a.rooms[roomName]; ok { for _, p := range progsInRoom { if info, ok := a.activePrograms[p]; ok { userIDs = append(userIDs, info.userID) } } } sort.Strings(userIDs) // Sort for consistent display return userIDs } func newApp() *app { a := &app{ activePrograms: make(map[*tea.Program]programInfo), rooms: make(map[string][]*tea.Program), } s, err := wish.NewServer( wish.WithAddress(net.JoinHostPort(host, port)), wish.WithHostKeyPath(".ssh/id_ed25519"), 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 // Trigger shutdown if server fails to start } }() <-done // Wait for shutdown signal log.Info("Stopping SSH server...") ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer func() { cancel() // Clear program and room data after shutdown attempt a.mu.Lock() a.activePrograms = make(map[*tea.Program]programInfo) a.rooms = make(map[string][]*tea.Program) a.mu.Unlock() log.Info("Cleared app program stores.") }() 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.Info("SSH server stopped.") } func (a *app) ProgramHandler(s ssh.Session) *tea.Program { userID := s.User() if userID == "" { userID = "guest" // Default userID if empty } m := initialModel(a, userID) p := tea.NewProgram(m, bubbletea.MakeOptions(s)...) m.SetProgram(p) // Give the model a reference to its own program a.addProgram(p, 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} 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 { log.Warnf("Program %p not found in activePrograms, cannot assign to room %s", prog, roomName) return } userID := currentInfo.userID currentRoom := currentInfo.roomName // If program was in a different room, remove it from the old room's list if currentRoom != "" && currentRoom != roomName { if progsInOldRoom, ok := a.rooms[currentRoom]; ok { var updatedRoomProgs []*tea.Program for _, pInRoom := range progsInOldRoom { 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) } } // Ensure the new room exists in the map if _, ok := a.rooms[roomName]; !ok { a.rooms[roomName] = []*tea.Program{} } // Add program to the new room's list if not already there 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} log.Infof("Program %p assigned/confirmed in room %s (user: %s)", prog, roomName, userID) } func (a *app) HandleUserExitFromRoom(prog *tea.Program, oldRoomName string) { a.mu.Lock() info, exists := a.activePrograms[prog] if !exists { a.mu.Unlock() log.Warnf("Program %p tried to exit room but not found in activePrograms.", prog) return } if info.roomName == "" || info.roomName != oldRoomName { a.mu.Unlock() log.Warnf("Program %p (user: %s) tried to exit room %s but was not in it (actual: '%s').", prog, info.userID, oldRoomName, info.roomName) return } userID := info.userID leaveMsgText := fmt.Sprintf("%s has left the room.", userID) var recipients []*tea.Program if progs, ok := a.rooms[info.roomName]; ok { recipients = make([]*tea.Program, 0, len(progs)) for _, p := range progs { if p != prog { recipients = append(recipients, p) } } } var updatedRoomProgs []*tea.Program if progsInRoom, ok := a.rooms[info.roomName]; ok { for _, pInRoom := range progsInRoom { if pInRoom != prog { updatedRoomProgs = append(updatedRoomProgs, pInRoom) } } } if len(updatedRoomProgs) > 0 { a.rooms[info.roomName] = updatedRoomProgs } else { delete(a.rooms, info.roomName) } a.activePrograms[prog] = programInfo{roomName: "", userID: userID} a.mu.Unlock() // Unlock before broadcasting if len(recipients) > 0 { for _, p := range recipients { go p.Send(chatMsg{id: "system", text: leaveMsgText, room: oldRoomName, isSystemMessage: true}) } log.Infof("Broadcasted leave message for user %s from room %s (commanded exit)", userID, oldRoomName) } log.Infof("Program %p (user: %s) exited room %s via /exit command.", prog, userID, oldRoomName) } func (a *app) RemoveProgram(prog *tea.Program) { a.mu.Lock() info, exists := a.activePrograms[prog] if !exists { a.mu.Unlock() log.Warnf("Attempted to remove unknown or already removed program: %p", prog) return } userID := info.userID roomName := info.roomName var leaveRecipients []*tea.Program var leaveMsgText string if roomName != "" { leaveMsgText = fmt.Sprintf("%s has disconnected.", userID) if progs, ok := a.rooms[roomName]; ok { leaveRecipients = make([]*tea.Program, 0, len(progs)) for _, p := range progs { if p != prog { leaveRecipients = append(leaveRecipients, p) } } } } if roomName != "" { var updatedRoomProgs []*tea.Program if progsInRoom, ok := a.rooms[roomName]; ok { for _, pInRoom := range progsInRoom { if pInRoom != prog { updatedRoomProgs = append(updatedRoomProgs, pInRoom) } } } if len(updatedRoomProgs) > 0 { a.rooms[roomName] = updatedRoomProgs } else { delete(a.rooms, roomName) } log.Infof("Program %p (user: %s) removed from room %s tracking (full disconnect).", prog, userID, roomName) } delete(a.activePrograms, prog) a.mu.Unlock() // Unlock before broadcasting if roomName != "" && len(leaveRecipients) > 0 { for _, p := range leaveRecipients { go p.Send(chatMsg{id: "system", text: leaveMsgText, room: roomName, isSystemMessage: true}) } log.Infof("Broadcasted disconnect message for user %s from room %s.", userID, roomName) } log.Infof("Program %p (user: %s) removed from active list (full disconnect).", prog, userID) } func main() { // Optional: Setup file logging // logFile, err := os.OpenFile("chatter_server.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) // if err == nil { // defer logFile.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) // } app := newApp() app.Start() } type ( errMsg error // Used for tea.Cmd to send errors to Update chatMsg struct { // Used for tea.Cmd to send chat messages to Update id string text string room string isSystemMessage bool } ) type modelPhase int const ( phaseRoomInput modelPhase = iota phaseChat ) var roomNameRegex = regexp.MustCompile("^[a-zA-Z0-9]{1," + fmt.Sprintf("%d", maxRoomNameLength) + "}$") func isValidRoomName(name string) bool { return roomNameRegex.MatchString(name) } type model struct { app *app program *tea.Program // Reference to the Bubble Tea program managing this model phase modelPhase roomInput textinput.Model currentRoom string id string // User ID from SSH session viewport viewport.Model messages []string // Chat messages displayed in the viewport textarea textarea.Model senderStyle lipgloss.Style systemMessageStyle lipgloss.Style // For join/leave/command output (not for m.err) messageTimestamps []time.Time // For rate limiting user messages err error // For displaying transient input errors to the user (e.g., rate limit, too long) } func initialModel(appInstance *app, userID string) *model { roomTi := textinput.New() roomTi.Placeholder = "e.g., 'dev-chat' or 'random' (or press Enter for random)" roomTi.Focus() roomTi.CharLimit = maxRoomNameLength roomTi.Width = 50 ta := textarea.New() ta.Placeholder = "Type your message or a command (e.g. /help)..." ta.Prompt = "┃ " ta.CharLimit = maxChatMessageLength // User-facing limit in textarea ta.SetWidth(60) // Example width, adjust as needed ta.SetHeight(3) // Multi-line input area ta.FocusedStyle.CursorLine = lipgloss.NewStyle() // No special styling for cursor line ta.ShowLineNumbers = false ta.KeyMap.InsertNewline.SetEnabled(false) // Use Enter to send, not for newlines in message vp := viewport.New(60, 15) // Example dimensions, adjust as needed 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{}, messageTimestamps: []time.Time{}, // Initialize for rate limiting viewport: vp, 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 messages err: nil, // No error initially } } 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 keyEvent := msg.(type) { case tea.KeyMsg: switch keyEvent.Type { case tea.KeyCtrlC, tea.KeyEsc: if m.program != nil { m.app.RemoveProgram(m.program) // Deregister before quitting } return m, tea.Quit case tea.KeyEnter: roomName := strings.TrimSpace(m.roomInput.Value()) if roomName == "" { // Generate random room name if input is empty roomName = generateRandomRoomName(8) // Shorter random name for readability m.roomInput.SetValue(roomName) // Show generated name in input briefly log.Infof("User %s generated random room name: %s", m.id, roomName) } if isValidRoomName(roomName) { m.currentRoom = roomName m.phase = phaseChat m.err = nil // Clear any previous error from room input phase if m.program == nil { log.Error("CRITICAL: m.program is nil when trying to assign to room.") m.err = errors.New("internal setup error: program reference missing") return m, nil // Stay in current phase with error displayed } m.app.AssignProgramToRoom(m.program, m.currentRoom) joinMsgText := fmt.Sprintf("%s has joined the room.", m.id) m.app.BroadcastToRoom(m.currentRoom, chatMsg{ id: "system", text: joinMsgText, room: m.currentRoom, isSystemMessage: true, }, nil) // Broadcast to all, including self m.textarea.Focus() 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.messages = []string{} // Clear message history from potential previous room/session cmds = append(cmds, textarea.Blink) } else { m.err = fmt.Errorf("invalid room name: use 1-%d alphanumeric characters", maxRoomNameLength) m.roomInput.Focus() // Keep focus on room input if error cmds = append(cmds, textinput.Blink) } return m, tea.Batch(cmds...) } case errMsg: // This would be an error from a command, not typically used in room input phase m.err = keyEvent return m, nil } m.roomInput, cmd = m.roomInput.Update(msg) cmds = append(cmds, cmd) case phaseChat: // Order of updates: components first, then specific message handling. 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) switch event := msg.(type) { case tea.KeyMsg: // 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: if m.program != nil { m.app.RemoveProgram(m.program) } return m, tea.Quit case tea.KeyEnter: val := strings.TrimSpace(m.textarea.Value()) m.textarea.Reset() // Reset textarea immediately after getting value if strings.HasPrefix(val, "/") { m.err = nil // Clear previous input errors when a command is attempted parts := strings.Fields(val) command := parts[0] // args := parts[1:] // For future use switch command { case "/help": helpText := m.systemMessageStyle.Render(`Available commands: /help Show this help message. /exit Leave the current room. /users List users in this room.`) m.messages = append(m.messages, helpText) case "/exit", "/leave": if m.program != nil && m.currentRoom != "" { m.app.HandleUserExitFromRoom(m.program, m.currentRoom) } m.phase = phaseRoomInput m.currentRoom = "" m.messages = []string{} m.err = nil // Ensure error is clear when changing phase m.roomInput.Reset() m.roomInput.Placeholder = "Enter another room or press Enter for random..." m.roomInput.Focus() m.textarea.Blur() m.viewport.SetContent("You have left the room. Enter a new room name.") cmds = append(cmds, textinput.Blink) return m, tea.Batch(cmds...) // Return early as state has significantly changed case "/users": if m.currentRoom != "" { userIDs := m.app.GetUserIDsInRoom(m.currentRoom) var userListText string if len(userIDs) > 0 { userListText = "Users in '" + m.currentRoom + "':\n " + strings.Join(userIDs, "\n ") } else { // 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)) } 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.")) } default: 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.GotoBottom() } else if val != "" { // Regular chat message attempt // 1. Message Length Check if len(val) > maxChatMessageLength { m.err = fmt.Errorf("message too long (max %d chars). Not sent.", maxChatMessageLength) } else { // 2. Rate Limiting Check now := time.Now() // Remove timestamps older than the window var recentTimestamps []time.Time for _, ts := range m.messageTimestamps { if now.Sub(ts) < rateLimitWindow { recentTimestamps = append(recentTimestamps, ts) } } m.messageTimestamps = recentTimestamps if len(m.messageTimestamps) >= rateLimitMaxMessages { m.err = fmt.Errorf("too many messages (max %d per %s). Wait. Not sent.", rateLimitMaxMessages, rateLimitWindow.String()) } else { // SUCCESSFUL SEND m.err = nil // Clear any prior input error 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) } } } else { // val == "" (empty input submitted via Enter) m.err = nil // Clear any previous error, do nothing for empty message } // After sending, command, or empty Enter, ensure textarea is focused for next input 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) // Process only if message is for the current room if event.room == m.currentRoom { var styledMessage string if event.isSystemMessage { styledMessage = m.systemMessageStyle.Render(event.text) } else { styledId := m.senderStyle.Render(event.id) // 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.viewport.SetContent(strings.Join(m.messages, "\n")) m.viewport.GotoBottom() } else if event.room != "" && !event.isSystemMessage { // 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: // This is for system/internal errors passed as errMsg type to Update m.err = event // Display this error using the m.err mechanism log.Error("Internal error in model", "user", m.id, "room", m.currentRoom, "error", event) } } return m, tea.Batch(cmds...) } func (m *model) View() string { var viewBuilder strings.Builder titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")) // Purple for titles errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true) // Red for m.err 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(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(m.roomInput.View()) viewBuilder.WriteString("\n\n(Ctrl+C or Esc to quit)") case phaseChat: header := fmt.Sprintf("Room: %s / User: %s (type /help for commands)", m.currentRoom, m.id) viewBuilder.WriteString(titleStyle.Render(header) + "\n") // Simple border for viewport content viewBuilder.WriteString(lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).Padding(0, 1).Render(m.viewport.View()) + "\n") viewBuilder.WriteString(m.textarea.View()) viewBuilder.WriteString("\n(Ctrl+C or Esc to quit)") default: 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 { viewBuilder.WriteString("\n\n" + errorStyle.Render("Error: "+m.err.Error())) } return viewBuilder.String() + "\n" }