package main import ( "fmt" "os" "strings" "sync" "time" "git.eeqj.de/sneak/chat/cmd/chat-cli/api" ) // 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 lastQID int64 // queue cursor for polling 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") if err := app.ui.Run(); 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) handleCommand(text string) { parts := strings.SplitN(text, " ", 2) cmd := strings.ToLower(parts[0]) args := "" if len(parts) > 1 { args = parts[1] } switch cmd { case "/connect": a.cmdConnect(args) case "/nick": a.cmdNick(args) case "/join": a.cmdJoin(args) case "/part": a.cmdPart(args) case "/msg": a.cmdMsg(args) case "/query": a.cmdQuery(args) case "/topic": a.cmdTopic(args) case "/names": a.cmdNames() case "/list": a.cmdList() case "/window", "/w": a.cmdWindow(args) case "/quit": a.cmdQuit() case "/help": a.cmdHelp() default: a.ui.AddStatus(fmt.Sprintf("[red]Unknown command: %s", 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.lastQID = 0 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(fmt.Sprintf("Nick changed to %s", 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, fmt.Sprintf("[yellow]*** Joined %s", 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, fmt.Sprintf("[yellow]*** Left %s", 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, " ", 2) if len(parts) < 2 { 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() if n < a.ui.BufferCount() && n >= 0 { // Update target to the buffer name. // Needs to be done carefully. } 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 lastQID := a.lastQID a.mu.Unlock() if client == nil { return } result, err := client.PollMessages(lastQID, 15) if err != nil { // Transient error — retry after delay. time.Sleep(2 * time.Second) continue } if result.LastID > 0 { a.mu.Lock() a.lastQID = result.LastID a.mu.Unlock() } for _, msg := range result.Messages { a.handleServerMessage(&msg) } } } func (a *App) handleServerMessage(msg *api.Message) { ts := "" if msg.TS != "" { t := msg.ParseTS() ts = t.Local().Format("15:04") } else { ts = time.Now().Format("15:04") } a.mu.Lock() myNick := a.nick a.mu.Unlock() switch msg.Command { case "PRIVMSG": lines := msg.BodyLines() text := strings.Join(lines, " ") if msg.From == myNick { // Skip our own echoed messages (already displayed locally). return } target := msg.To if !strings.HasPrefix(target, "#") { // DM — use sender's nick as buffer name. target = msg.From } a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, msg.From, text)) case "JOIN": target := msg.To if target != "" { a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has joined %s", ts, msg.From, target)) } case "PART": target := msg.To lines := msg.BodyLines() reason := strings.Join(lines, " ") if target != "" { 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)) } } case "QUIT": lines := msg.BodyLines() reason := strings.Join(lines, " ") 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)) } case "NICK": 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)) case "NOTICE": lines := msg.BodyLines() text := strings.Join(lines, " ") a.ui.AddStatus(fmt.Sprintf("[gray]%s [magenta]--%s-- %s", ts, msg.From, text)) case "TOPIC": lines := msg.BodyLines() text := strings.Join(lines, " ") if msg.To != "" { a.ui.AddLine(msg.To, fmt.Sprintf("[gray]%s [cyan]*** %s set topic: %s", ts, msg.From, text)) } default: // Numeric replies and other messages → status window. lines := msg.BodyLines() text := strings.Join(lines, " ") if text != "" { a.ui.AddStatus(fmt.Sprintf("[gray]%s [white][%s] %s", ts, msg.Command, text)) } } }