// Package main implements the chat-cli terminal client. package main import ( "fmt" "os" "strings" "sync" "time" "git.eeqj.de/sneak/chat/cmd/chat-cli/api" ) const ( // splitParts is the number of parts to split a command into (command + args). splitParts = 2 // pollTimeout is the long-poll timeout in seconds. pollTimeout = 15 // pollRetryDelay is the delay before retrying a failed poll. pollRetryDelay = 2 * time.Second ) // App holds the application state. type App struct { ui *UI client *api.Client mu sync.Mutex nick string target string // current target (#channel or nick for DM) connected bool lastMsgID string stopPoll chan struct{} } func main() { app := &App{ ui: NewUI(), nick: "guest", } app.ui.OnInput(app.handleInput) app.ui.SetStatus(app.nick, "", "disconnected") app.ui.AddStatus("Welcome to chat-cli — an IRC-style client") app.ui.AddStatus("Type [yellow]/connect [white] to begin, or [yellow]/help[white] for commands") err := app.ui.Run() if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } func (a *App) handleInput(text string) { if strings.HasPrefix(text, "/") { a.handleCommand(text) return } // Plain text → PRIVMSG to current target. a.mu.Lock() target := a.target connected := a.connected a.mu.Unlock() if !connected { a.ui.AddStatus("[red]Not connected. Use /connect ") return } if target == "" { a.ui.AddStatus("[red]No target. Use /join #channel or /query nick") return } err := a.client.SendMessage(&api.Message{ Command: "PRIVMSG", To: target, Body: []string{text}, }) if err != nil { a.ui.AddStatus(fmt.Sprintf("[red]Send error: %v", err)) return } // Echo locally. ts := time.Now().Format("15:04") a.mu.Lock() nick := a.nick a.mu.Unlock() a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, nick, text)) } func (a *App) commandHandlers() map[string]func(string) { return map[string]func(string){ "/connect": a.cmdConnect, "/nick": a.cmdNick, "/join": a.cmdJoin, "/part": a.cmdPart, "/msg": a.cmdMsg, "/query": a.cmdQuery, "/topic": a.cmdTopic, "/names": func(_ string) { a.cmdNames() }, "/list": func(_ string) { a.cmdList() }, "/window": a.cmdWindow, "/w": a.cmdWindow, "/quit": func(_ string) { a.cmdQuit() }, "/help": func(_ string) { a.cmdHelp() }, } } func (a *App) handleCommand(text string) { parts := strings.SplitN(text, " ", splitParts) cmd := strings.ToLower(parts[0]) args := "" if len(parts) > 1 { args = parts[1] } if handler, ok := a.commandHandlers()[cmd]; ok { handler(args) } else { a.ui.AddStatus("[red]Unknown command: " + cmd) } } func (a *App) cmdConnect(serverURL string) { if serverURL == "" { a.ui.AddStatus("[red]Usage: /connect ") return } serverURL = strings.TrimRight(serverURL, "/") a.ui.AddStatus(fmt.Sprintf("Connecting to %s...", serverURL)) a.mu.Lock() nick := a.nick a.mu.Unlock() client := api.NewClient(serverURL) resp, err := client.CreateSession(nick) if err != nil { a.ui.AddStatus(fmt.Sprintf("[red]Connection failed: %v", err)) return } a.mu.Lock() a.client = client a.nick = resp.Nick a.connected = true a.lastMsgID = "" a.mu.Unlock() a.ui.AddStatus(fmt.Sprintf("[green]Connected! Nick: %s, Session: %s", resp.Nick, resp.SessionID)) a.ui.SetStatus(resp.Nick, "", "connected") // Start polling. a.stopPoll = make(chan struct{}) go a.pollLoop() } func (a *App) cmdNick(nick string) { if nick == "" { a.ui.AddStatus("[red]Usage: /nick ") return } a.mu.Lock() connected := a.connected a.mu.Unlock() if !connected { a.mu.Lock() a.nick = nick a.mu.Unlock() a.ui.AddStatus(fmt.Sprintf("Nick set to %s (will be used on connect)", nick)) return } err := a.client.SendMessage(&api.Message{ Command: "NICK", Body: []string{nick}, }) if err != nil { a.ui.AddStatus(fmt.Sprintf("[red]Nick change failed: %v", err)) return } a.mu.Lock() a.nick = nick target := a.target a.mu.Unlock() a.ui.SetStatus(nick, target, "connected") a.ui.AddStatus("Nick changed to " + nick) } func (a *App) cmdJoin(channel string) { if channel == "" { a.ui.AddStatus("[red]Usage: /join #channel") return } if !strings.HasPrefix(channel, "#") { channel = "#" + channel } a.mu.Lock() connected := a.connected a.mu.Unlock() if !connected { a.ui.AddStatus("[red]Not connected") return } err := a.client.JoinChannel(channel) if err != nil { a.ui.AddStatus(fmt.Sprintf("[red]Join failed: %v", err)) return } a.mu.Lock() a.target = channel nick := a.nick a.mu.Unlock() a.ui.SwitchToBuffer(channel) a.ui.AddLine(channel, "[yellow]*** Joined "+channel) a.ui.SetStatus(nick, channel, "connected") } func (a *App) cmdPart(channel string) { a.mu.Lock() if channel == "" { channel = a.target } connected := a.connected a.mu.Unlock() if channel == "" || !strings.HasPrefix(channel, "#") { a.ui.AddStatus("[red]No channel to part") return } if !connected { a.ui.AddStatus("[red]Not connected") return } err := a.client.PartChannel(channel) if err != nil { a.ui.AddStatus(fmt.Sprintf("[red]Part failed: %v", err)) return } a.ui.AddLine(channel, "[yellow]*** Left "+channel) a.mu.Lock() if a.target == channel { a.target = "" } nick := a.nick a.mu.Unlock() a.ui.SwitchBuffer(0) a.ui.SetStatus(nick, "", "connected") } func (a *App) cmdMsg(args string) { parts := strings.SplitN(args, " ", splitParts) if len(parts) < splitParts { a.ui.AddStatus("[red]Usage: /msg ") return } target, text := parts[0], parts[1] a.mu.Lock() connected := a.connected nick := a.nick a.mu.Unlock() if !connected { a.ui.AddStatus("[red]Not connected") return } err := a.client.SendMessage(&api.Message{ Command: "PRIVMSG", To: target, Body: []string{text}, }) if err != nil { a.ui.AddStatus(fmt.Sprintf("[red]Send failed: %v", err)) return } ts := time.Now().Format("15:04") a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, nick, text)) } func (a *App) cmdQuery(nick string) { if nick == "" { a.ui.AddStatus("[red]Usage: /query ") return } a.mu.Lock() a.target = nick myNick := a.nick a.mu.Unlock() a.ui.SwitchToBuffer(nick) a.ui.SetStatus(myNick, nick, "connected") } func (a *App) cmdTopic(args string) { a.mu.Lock() target := a.target connected := a.connected a.mu.Unlock() if !connected { a.ui.AddStatus("[red]Not connected") return } if !strings.HasPrefix(target, "#") { a.ui.AddStatus("[red]Not in a channel") return } if args == "" { // Query topic. err := a.client.SendMessage(&api.Message{ Command: "TOPIC", To: target, }) if err != nil { a.ui.AddStatus(fmt.Sprintf("[red]Topic query failed: %v", err)) } return } err := a.client.SendMessage(&api.Message{ Command: "TOPIC", To: target, Body: []string{args}, }) if err != nil { a.ui.AddStatus(fmt.Sprintf("[red]Topic set failed: %v", err)) } } func (a *App) cmdNames() { a.mu.Lock() target := a.target connected := a.connected a.mu.Unlock() if !connected { a.ui.AddStatus("[red]Not connected") return } if !strings.HasPrefix(target, "#") { a.ui.AddStatus("[red]Not in a channel") return } members, err := a.client.GetMembers(target) if err != nil { a.ui.AddStatus(fmt.Sprintf("[red]Names failed: %v", err)) return } a.ui.AddLine(target, fmt.Sprintf("[cyan]*** Members of %s: %s", target, strings.Join(members, " "))) } func (a *App) cmdList() { a.mu.Lock() connected := a.connected a.mu.Unlock() if !connected { a.ui.AddStatus("[red]Not connected") return } channels, err := a.client.ListChannels() if err != nil { a.ui.AddStatus(fmt.Sprintf("[red]List failed: %v", err)) return } a.ui.AddStatus("[cyan]*** Channel list:") for _, ch := range channels { a.ui.AddStatus(fmt.Sprintf(" %s (%d members) %s", ch.Name, ch.Members, ch.Topic)) } a.ui.AddStatus("[cyan]*** End of channel list") } func (a *App) cmdWindow(args string) { if args == "" { a.ui.AddStatus("[red]Usage: /window ") return } n := 0 _, _ = fmt.Sscanf(args, "%d", &n) a.ui.SwitchBuffer(n) a.mu.Lock() nick := a.nick a.mu.Unlock() // Update target based on buffer. if n < a.ui.BufferCount() { buf := a.ui.buffers[n] if buf.Name != "(status)" { a.mu.Lock() a.target = buf.Name a.mu.Unlock() a.ui.SetStatus(nick, buf.Name, "connected") } else { a.ui.SetStatus(nick, "", "connected") } } } func (a *App) cmdQuit() { a.mu.Lock() if a.connected && a.client != nil { _ = a.client.SendMessage(&api.Message{Command: "QUIT"}) } if a.stopPoll != nil { close(a.stopPoll) } a.mu.Unlock() a.ui.Stop() } func (a *App) cmdHelp() { help := []string{ "[cyan]*** chat-cli commands:", " /connect — Connect to server", " /nick — Change nickname", " /join #channel — Join channel", " /part [#chan] — Leave channel", " /msg — Send DM", " /query — Open DM window", " /topic [text] — View/set topic", " /names — List channel members", " /list — List channels", " /window — Switch buffer (Alt+0-9)", " /quit — Disconnect and exit", " /help — This help", " Plain text sends to current target.", } for _, line := range help { a.ui.AddStatus(line) } } // pollLoop long-polls for messages in the background. func (a *App) pollLoop() { for { select { case <-a.stopPoll: return default: } a.mu.Lock() client := a.client lastID := a.lastMsgID a.mu.Unlock() if client == nil { return } msgs, err := client.PollMessages(lastID, pollTimeout) if err != nil { // Transient error — retry after delay. time.Sleep(pollRetryDelay) continue } for _, msg := range msgs { a.handleServerMessage(&msg) if msg.ID != "" { a.mu.Lock() a.lastMsgID = msg.ID a.mu.Unlock() } } } } func (a *App) messageTimestamp(msg *api.Message) string { if msg.TS != "" { t := msg.ParseTS() return t.Local().Format("15:04") //nolint:gosmopolitan // CLI displays local time intentionally } return time.Now().Format("15:04") } func (a *App) handleServerMessage(msg *api.Message) { ts := a.messageTimestamp(msg) a.mu.Lock() myNick := a.nick a.mu.Unlock() switch msg.Command { case "PRIVMSG": a.handleMsgPrivmsg(msg, ts, myNick) case "JOIN": a.handleMsgJoin(msg, ts) case "PART": a.handleMsgPart(msg, ts) case "QUIT": a.handleMsgQuit(msg, ts) case "NICK": a.handleMsgNick(msg, ts, myNick) case "NOTICE": a.handleMsgNotice(msg, ts) case "TOPIC": a.handleMsgTopic(msg, ts) default: a.handleMsgDefault(msg, ts) } } func (a *App) handleMsgPrivmsg(msg *api.Message, ts, myNick string) { lines := msg.BodyLines() text := strings.Join(lines, " ") if msg.From == myNick { return } target := msg.To if !strings.HasPrefix(target, "#") { target = msg.From } a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, msg.From, text)) } func (a *App) handleMsgJoin(msg *api.Message, ts string) { if msg.To != "" { a.ui.AddLine(msg.To, fmt.Sprintf("[gray]%s [yellow]*** %s has joined %s", ts, msg.From, msg.To)) } } func (a *App) handleMsgPart(msg *api.Message, ts string) { target := msg.To reason := strings.Join(msg.BodyLines(), " ") if target == "" { return } if reason != "" { a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has left %s (%s)", ts, msg.From, target, reason)) } else { a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has left %s", ts, msg.From, target)) } } func (a *App) handleMsgQuit(msg *api.Message, ts string) { reason := strings.Join(msg.BodyLines(), " ") if reason != "" { a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s has quit (%s)", ts, msg.From, reason)) } else { a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s has quit", ts, msg.From)) } } func (a *App) handleMsgNick(msg *api.Message, ts, myNick string) { lines := msg.BodyLines() newNick := "" if len(lines) > 0 { newNick = lines[0] } if msg.From == myNick && newNick != "" { a.mu.Lock() a.nick = newNick target := a.target a.mu.Unlock() a.ui.SetStatus(newNick, target, "connected") } a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s is now known as %s", ts, msg.From, newNick)) } func (a *App) handleMsgNotice(msg *api.Message, ts string) { text := strings.Join(msg.BodyLines(), " ") a.ui.AddStatus(fmt.Sprintf("[gray]%s [magenta]--%s-- %s", ts, msg.From, text)) } func (a *App) handleMsgTopic(msg *api.Message, ts string) { text := strings.Join(msg.BodyLines(), " ") if msg.To != "" { a.ui.AddLine(msg.To, fmt.Sprintf("[gray]%s [cyan]*** %s set topic: %s", ts, msg.From, text)) } } func (a *App) handleMsgDefault(msg *api.Message, ts string) { text := strings.Join(msg.BodyLines(), " ") if text != "" { a.ui.AddStatus(fmt.Sprintf("[gray]%s [white][%s] %s", ts, msg.Command, text)) } }