Files
chatter/main.go
2025-05-25 15:40:18 +01:00

689 lines
23 KiB
Go

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"
}