// Package main is the entry point for the chat-cli client. package main import ( "fmt" "os" "strings" "sync" "time" api "git.eeqj.de/sneak/chat/cmd/chat-cli/api" ) const ( splitParts = 2 pollTimeout = 15 pollRetry = 2 * time.Second timeFormat = "15:04" ) // App holds the application state. type App struct { ui *UI client *api.Client mu sync.Mutex nick string target string connected bool lastQID int64 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 } 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( "[red]Send error: " + err.Error(), ) return } ts := time.Now().Format(timeFormat) 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, " ", splitParts) cmd := strings.ToLower(parts[0]) args := "" if len(parts) > 1 { args = parts[1] } a.dispatchCommand(cmd, args) } func (a *App) dispatchCommand(cmd, args string) { 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( "[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("Connecting to " + 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: %d", resp.Nick, resp.ID, )) a.ui.SetStatus(resp.Nick, "", "connected") 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( "Nick set to " + nick + " (will be used on connect)", ) 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(timeFormat) 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 == "" { 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 } var n int _, _ = fmt.Sscanf(args, "%d", &n) a.ui.SwitchBuffer(n) a.mu.Lock() nick := a.nick a.mu.Unlock() if n >= 0 && 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", " /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, pollTimeout, ) if err != nil { time.Sleep(pollRetry) continue } if result.LastID > 0 { a.mu.Lock() a.lastQID = result.LastID a.mu.Unlock() } for i := range result.Messages { a.handleServerMessage(&result.Messages[i]) } } } func (a *App) handleServerMessage(msg *api.Message) { ts := a.formatTS(msg) a.mu.Lock() myNick := a.nick a.mu.Unlock() switch msg.Command { case "PRIVMSG": a.handlePrivmsgEvent(msg, ts, myNick) case "JOIN": a.handleJoinEvent(msg, ts) case "PART": a.handlePartEvent(msg, ts) case "QUIT": a.handleQuitEvent(msg, ts) case "NICK": a.handleNickEvent(msg, ts, myNick) case "NOTICE": a.handleNoticeEvent(msg, ts) case "TOPIC": a.handleTopicEvent(msg, ts) default: a.handleDefaultEvent(msg, ts) } } func (a *App) formatTS(msg *api.Message) string { if msg.TS != "" { return msg.ParseTS().UTC().Format(timeFormat) } return time.Now().Format(timeFormat) } func (a *App) handlePrivmsgEvent( 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) handleJoinEvent( msg *api.Message, ts string, ) { if msg.To == "" { return } a.ui.AddLine(msg.To, fmt.Sprintf( "[gray]%s [yellow]*** %s has joined %s", ts, msg.From, msg.To, )) } func (a *App) handlePartEvent( msg *api.Message, ts string, ) { if msg.To == "" { return } lines := msg.BodyLines() reason := strings.Join(lines, " ") if reason != "" { a.ui.AddLine(msg.To, fmt.Sprintf( "[gray]%s [yellow]*** %s has left %s (%s)", ts, msg.From, msg.To, reason, )) } else { a.ui.AddLine(msg.To, fmt.Sprintf( "[gray]%s [yellow]*** %s has left %s", ts, msg.From, msg.To, )) } } func (a *App) handleQuitEvent( msg *api.Message, ts string, ) { 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, )) } } func (a *App) handleNickEvent( 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) handleNoticeEvent( msg *api.Message, ts string, ) { lines := msg.BodyLines() text := strings.Join(lines, " ") a.ui.AddStatus(fmt.Sprintf( "[gray]%s [magenta]--%s-- %s", ts, msg.From, text, )) } func (a *App) handleTopicEvent( msg *api.Message, ts string, ) { if msg.To == "" { return } lines := msg.BodyLines() text := strings.Join(lines, " ") a.ui.AddLine(msg.To, fmt.Sprintf( "[gray]%s [cyan]*** %s set topic: %s", ts, msg.From, text, )) } func (a *App) handleDefaultEvent( msg *api.Message, ts string, ) { lines := msg.BodyLines() text := strings.Join(lines, " ") if text != "" { a.ui.AddStatus(fmt.Sprintf( "[gray]%s [white][%s] %s", ts, msg.Command, text, )) } }