From 8854b17ebcfbf261285f2a1527eccedb78a4e26b Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Mar 2026 03:27:13 -0700 Subject: [PATCH] refactor: move CLI code from cmd/ to internal/cli Move all non-bootstrapping CLI code to internal/cli package. cmd/neoirc-cli/main.go now contains only minimal bootstrapping that calls cli.Run(). The App struct, UI, command handlers, poll loop, and api client are now in internal/cli/ and internal/cli/api/. --- README.md | 15 +- cmd/neoirc-cli/main.go | 907 +---------------- .../neoirc-cli => internal/cli}/api/client.go | 0 .../cli}/api/hashcash.go | 0 {cmd/neoirc-cli => internal/cli}/api/types.go | 0 internal/cli/app.go | 912 ++++++++++++++++++ {cmd/neoirc-cli => internal/cli}/ui.go | 2 +- 7 files changed, 924 insertions(+), 912 deletions(-) rename {cmd/neoirc-cli => internal/cli}/api/client.go (100%) rename {cmd/neoirc-cli => internal/cli}/api/hashcash.go (100%) rename {cmd/neoirc-cli => internal/cli}/api/types.go (100%) create mode 100644 internal/cli/app.go rename {cmd/neoirc-cli => internal/cli}/ui.go (99%) diff --git a/README.md b/README.md index a86ccaa..bdf76e4 100644 --- a/README.md +++ b/README.md @@ -2311,15 +2311,18 @@ neoirc/ ├── cmd/ │ ├── neoircd/ # Server binary entry point │ │ └── main.go -│ └── neoirc-cli/ # TUI client -│ ├── main.go # Command handling, poll loop -│ ├── ui.go # tview-based terminal UI -│ └── api/ -│ ├── client.go # HTTP API client library -│ └── types.go # Request/response types +│ └── neoirc-cli/ # TUI client entry point +│ └── main.go # Minimal bootstrapping (calls internal/cli) ├── internal/ │ ├── broker/ # In-memory pub/sub for long-poll notifications │ │ └── broker.go +│ ├── cli/ # TUI client implementation +│ │ ├── app.go # App struct, command handling, poll loop +│ │ ├── ui.go # tview-based terminal UI +│ │ └── api/ +│ │ ├── client.go # HTTP API client library +│ │ ├── types.go # Request/response types +│ │ └── hashcash.go # Hashcash proof-of-work minting │ ├── config/ # Viper-based configuration │ │ └── config.go │ ├── db/ # Database access and migrations diff --git a/cmd/neoirc-cli/main.go b/cmd/neoirc-cli/main.go index 4000e01..c10f0ba 100644 --- a/cmd/neoirc-cli/main.go +++ b/cmd/neoirc-cli/main.go @@ -1,911 +1,8 @@ // Package main is the entry point for the neoirc-cli client. package main -import ( - "fmt" - "os" - "strings" - "sync" - "time" - - api "git.eeqj.de/sneak/neoirc/cmd/neoirc-cli/api" - "git.eeqj.de/sneak/neoirc/internal/irc" -) - -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{} -} +import "git.eeqj.de/sneak/neoirc/internal/cli" func main() { - app := &App{ //nolint:exhaustruct - ui: NewUI(), - nick: "guest", - } - - app.ui.OnInput(app.handleInput) - app.ui.SetStatus(app.nick, "", "disconnected") - - app.ui.AddStatus( - "Welcome to neoirc-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{ //nolint:exhaustruct - Command: irc.CmdPrivmsg, - To: target, - Body: []string{text}, - }) - if err != nil { - a.ui.AddStatus( - "[red]Send error: " + err.Error(), - ) - - return - } - - timestamp := 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", - timestamp, 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 "/window", "/w": - a.cmdWindow(args) - case "/quit": - a.cmdQuit() - case "/help": - a.cmdHelp() - default: - a.dispatchInfoCommand(cmd, args) - } -} - -func (a *App) dispatchInfoCommand(cmd, args string) { - switch cmd { - case "/names": - a.cmdNames() - case "/list": - a.cmdList() - case "/motd": - a.cmdMotd() - case "/who": - a.cmdWho(args) - case "/whois": - a.cmdWhois(args) - 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{ //nolint:exhaustruct - Command: irc.CmdNick, - 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{ //nolint:exhaustruct - Command: irc.CmdPrivmsg, - To: target, - Body: []string{text}, - }) - if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]Send failed: %v", err, - )) - - return - } - - timestamp := time.Now().Format(timeFormat) - - a.ui.AddLine(target, fmt.Sprintf( - "[gray]%s [green]<%s>[white] %s", - timestamp, 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{ //nolint:exhaustruct - Command: irc.CmdTopic, - To: target, - }) - if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]Topic query failed: %v", err, - )) - } - - return - } - - err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct - Command: irc.CmdTopic, - 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) cmdMotd() { - a.mu.Lock() - connected := a.connected - a.mu.Unlock() - - if !connected { - a.ui.AddStatus("[red]Not connected") - - return - } - - err := a.client.SendMessage( - &api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct - ) - if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]MOTD failed: %v", err, - )) - } -} - -func (a *App) cmdWho(args string) { - a.mu.Lock() - connected := a.connected - target := a.target - a.mu.Unlock() - - if !connected { - a.ui.AddStatus("[red]Not connected") - - return - } - - channel := args - if channel == "" { - channel = target - } - - if channel == "" || - !strings.HasPrefix(channel, "#") { - a.ui.AddStatus( - "[red]Usage: /who #channel", - ) - - return - } - - err := a.client.SendMessage( - &api.Message{ //nolint:exhaustruct - Command: irc.CmdWho, To: channel, - }, - ) - if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]WHO failed: %v", err, - )) - } -} - -func (a *App) cmdWhois(args string) { - a.mu.Lock() - connected := a.connected - a.mu.Unlock() - - if !connected { - a.ui.AddStatus("[red]Not connected") - - return - } - - if args == "" { - a.ui.AddStatus( - "[red]Usage: /whois ", - ) - - return - } - - err := a.client.SendMessage( - &api.Message{ //nolint:exhaustruct - Command: irc.CmdWhois, To: args, - }, - ) - if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]WHOIS failed: %v", err, - )) - } -} - -func (a *App) cmdWindow(args string) { - if args == "" { - a.ui.AddStatus( - "[red]Usage: /window ", - ) - - return - } - - var bufIndex int - - _, _ = fmt.Sscanf(args, "%d", &bufIndex) - - a.ui.SwitchBuffer(bufIndex) - - a.mu.Lock() - nick := a.nick - a.mu.Unlock() - - if bufIndex >= 0 && bufIndex < a.ui.BufferCount() { - buf := a.ui.buffers[bufIndex] - 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: irc.CmdQuit}, //nolint:exhaustruct - ) - } - - if a.stopPoll != nil { - close(a.stopPoll) - } - - a.mu.Unlock() - a.ui.Stop() -} - -func (a *App) cmdHelp() { - help := []string{ - "[cyan]*** neoirc-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", - " /who [#channel] — List users in channel", - " /whois — Show user info", - " /motd — Show message of the day", - " /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) { - timestamp := a.formatTS(msg) - - a.mu.Lock() - myNick := a.nick - a.mu.Unlock() - - switch msg.Command { - case irc.CmdPrivmsg: - a.handlePrivmsgEvent(msg, timestamp, myNick) - case irc.CmdJoin: - a.handleJoinEvent(msg, timestamp) - case irc.CmdPart: - a.handlePartEvent(msg, timestamp) - case irc.CmdQuit: - a.handleQuitEvent(msg, timestamp) - case irc.CmdNick: - a.handleNickEvent(msg, timestamp, myNick) - case irc.CmdNotice: - a.handleNoticeEvent(msg, timestamp) - case irc.CmdTopic: - a.handleTopicEvent(msg, timestamp) - default: - a.handleDefaultEvent(msg, timestamp) - } -} - -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, timestamp, 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", - timestamp, msg.From, text, - )) -} - -func (a *App) handleJoinEvent( - msg *api.Message, timestamp string, -) { - if msg.To == "" { - return - } - - a.ui.AddLine(msg.To, fmt.Sprintf( - "[gray]%s [yellow]*** %s has joined %s", - timestamp, msg.From, msg.To, - )) -} - -func (a *App) handlePartEvent( - msg *api.Message, timestamp 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)", - timestamp, msg.From, msg.To, reason, - )) - } else { - a.ui.AddLine(msg.To, fmt.Sprintf( - "[gray]%s [yellow]*** %s has left %s", - timestamp, msg.From, msg.To, - )) - } -} - -func (a *App) handleQuitEvent( - msg *api.Message, timestamp string, -) { - lines := msg.BodyLines() - reason := strings.Join(lines, " ") - - if reason != "" { - a.ui.AddStatus(fmt.Sprintf( - "[gray]%s [yellow]*** %s has quit (%s)", - timestamp, msg.From, reason, - )) - } else { - a.ui.AddStatus(fmt.Sprintf( - "[gray]%s [yellow]*** %s has quit", - timestamp, msg.From, - )) - } -} - -func (a *App) handleNickEvent( - msg *api.Message, timestamp, 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", - timestamp, msg.From, newNick, - )) -} - -func (a *App) handleNoticeEvent( - msg *api.Message, timestamp string, -) { - lines := msg.BodyLines() - text := strings.Join(lines, " ") - - a.ui.AddStatus(fmt.Sprintf( - "[gray]%s [magenta]--%s-- %s", - timestamp, msg.From, text, - )) -} - -func (a *App) handleTopicEvent( - msg *api.Message, timestamp 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", - timestamp, msg.From, text, - )) -} - -func (a *App) handleDefaultEvent( - msg *api.Message, timestamp string, -) { - lines := msg.BodyLines() - text := strings.Join(lines, " ") - - if text != "" { - a.ui.AddStatus(fmt.Sprintf( - "[gray]%s [white][%s] %s", - timestamp, msg.Command, text, - )) - } + cli.Run() } diff --git a/cmd/neoirc-cli/api/client.go b/internal/cli/api/client.go similarity index 100% rename from cmd/neoirc-cli/api/client.go rename to internal/cli/api/client.go diff --git a/cmd/neoirc-cli/api/hashcash.go b/internal/cli/api/hashcash.go similarity index 100% rename from cmd/neoirc-cli/api/hashcash.go rename to internal/cli/api/hashcash.go diff --git a/cmd/neoirc-cli/api/types.go b/internal/cli/api/types.go similarity index 100% rename from cmd/neoirc-cli/api/types.go rename to internal/cli/api/types.go diff --git a/internal/cli/app.go b/internal/cli/app.go new file mode 100644 index 0000000..0066438 --- /dev/null +++ b/internal/cli/app.go @@ -0,0 +1,912 @@ +// Package cli implements the neoirc-cli terminal client. +package cli + +import ( + "fmt" + "os" + "strings" + "sync" + "time" + + api "git.eeqj.de/sneak/neoirc/internal/cli/api" + "git.eeqj.de/sneak/neoirc/internal/irc" +) + +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{} +} + +// Run creates and runs the CLI application. +func Run() { + app := &App{ //nolint:exhaustruct + ui: NewUI(), + nick: "guest", + } + + app.ui.OnInput(app.handleInput) + app.ui.SetStatus(app.nick, "", "disconnected") + + app.ui.AddStatus( + "Welcome to neoirc-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{ //nolint:exhaustruct + Command: irc.CmdPrivmsg, + To: target, + Body: []string{text}, + }) + if err != nil { + a.ui.AddStatus( + "[red]Send error: " + err.Error(), + ) + + return + } + + timestamp := 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", + timestamp, 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 "/window", "/w": + a.cmdWindow(args) + case "/quit": + a.cmdQuit() + case "/help": + a.cmdHelp() + default: + a.dispatchInfoCommand(cmd, args) + } +} + +func (a *App) dispatchInfoCommand(cmd, args string) { + switch cmd { + case "/names": + a.cmdNames() + case "/list": + a.cmdList() + case "/motd": + a.cmdMotd() + case "/who": + a.cmdWho(args) + case "/whois": + a.cmdWhois(args) + 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{ //nolint:exhaustruct + Command: irc.CmdNick, + 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{ //nolint:exhaustruct + Command: irc.CmdPrivmsg, + To: target, + Body: []string{text}, + }) + if err != nil { + a.ui.AddStatus(fmt.Sprintf( + "[red]Send failed: %v", err, + )) + + return + } + + timestamp := time.Now().Format(timeFormat) + + a.ui.AddLine(target, fmt.Sprintf( + "[gray]%s [green]<%s>[white] %s", + timestamp, 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{ //nolint:exhaustruct + Command: irc.CmdTopic, + To: target, + }) + if err != nil { + a.ui.AddStatus(fmt.Sprintf( + "[red]Topic query failed: %v", err, + )) + } + + return + } + + err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct + Command: irc.CmdTopic, + 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) cmdMotd() { + a.mu.Lock() + connected := a.connected + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected") + + return + } + + err := a.client.SendMessage( + &api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct + ) + if err != nil { + a.ui.AddStatus(fmt.Sprintf( + "[red]MOTD failed: %v", err, + )) + } +} + +func (a *App) cmdWho(args string) { + a.mu.Lock() + connected := a.connected + target := a.target + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected") + + return + } + + channel := args + if channel == "" { + channel = target + } + + if channel == "" || + !strings.HasPrefix(channel, "#") { + a.ui.AddStatus( + "[red]Usage: /who #channel", + ) + + return + } + + err := a.client.SendMessage( + &api.Message{ //nolint:exhaustruct + Command: irc.CmdWho, To: channel, + }, + ) + if err != nil { + a.ui.AddStatus(fmt.Sprintf( + "[red]WHO failed: %v", err, + )) + } +} + +func (a *App) cmdWhois(args string) { + a.mu.Lock() + connected := a.connected + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected") + + return + } + + if args == "" { + a.ui.AddStatus( + "[red]Usage: /whois ", + ) + + return + } + + err := a.client.SendMessage( + &api.Message{ //nolint:exhaustruct + Command: irc.CmdWhois, To: args, + }, + ) + if err != nil { + a.ui.AddStatus(fmt.Sprintf( + "[red]WHOIS failed: %v", err, + )) + } +} + +func (a *App) cmdWindow(args string) { + if args == "" { + a.ui.AddStatus( + "[red]Usage: /window ", + ) + + return + } + + var bufIndex int + + _, _ = fmt.Sscanf(args, "%d", &bufIndex) + + a.ui.SwitchBuffer(bufIndex) + + a.mu.Lock() + nick := a.nick + a.mu.Unlock() + + if bufIndex >= 0 && bufIndex < a.ui.BufferCount() { + buf := a.ui.buffers[bufIndex] + 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: irc.CmdQuit}, //nolint:exhaustruct + ) + } + + if a.stopPoll != nil { + close(a.stopPoll) + } + + a.mu.Unlock() + a.ui.Stop() +} + +func (a *App) cmdHelp() { + help := []string{ + "[cyan]*** neoirc-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", + " /who [#channel] — List users in channel", + " /whois — Show user info", + " /motd — Show message of the day", + " /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) { + timestamp := a.formatTS(msg) + + a.mu.Lock() + myNick := a.nick + a.mu.Unlock() + + switch msg.Command { + case irc.CmdPrivmsg: + a.handlePrivmsgEvent(msg, timestamp, myNick) + case irc.CmdJoin: + a.handleJoinEvent(msg, timestamp) + case irc.CmdPart: + a.handlePartEvent(msg, timestamp) + case irc.CmdQuit: + a.handleQuitEvent(msg, timestamp) + case irc.CmdNick: + a.handleNickEvent(msg, timestamp, myNick) + case irc.CmdNotice: + a.handleNoticeEvent(msg, timestamp) + case irc.CmdTopic: + a.handleTopicEvent(msg, timestamp) + default: + a.handleDefaultEvent(msg, timestamp) + } +} + +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, timestamp, 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", + timestamp, msg.From, text, + )) +} + +func (a *App) handleJoinEvent( + msg *api.Message, timestamp string, +) { + if msg.To == "" { + return + } + + a.ui.AddLine(msg.To, fmt.Sprintf( + "[gray]%s [yellow]*** %s has joined %s", + timestamp, msg.From, msg.To, + )) +} + +func (a *App) handlePartEvent( + msg *api.Message, timestamp 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)", + timestamp, msg.From, msg.To, reason, + )) + } else { + a.ui.AddLine(msg.To, fmt.Sprintf( + "[gray]%s [yellow]*** %s has left %s", + timestamp, msg.From, msg.To, + )) + } +} + +func (a *App) handleQuitEvent( + msg *api.Message, timestamp string, +) { + lines := msg.BodyLines() + reason := strings.Join(lines, " ") + + if reason != "" { + a.ui.AddStatus(fmt.Sprintf( + "[gray]%s [yellow]*** %s has quit (%s)", + timestamp, msg.From, reason, + )) + } else { + a.ui.AddStatus(fmt.Sprintf( + "[gray]%s [yellow]*** %s has quit", + timestamp, msg.From, + )) + } +} + +func (a *App) handleNickEvent( + msg *api.Message, timestamp, 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", + timestamp, msg.From, newNick, + )) +} + +func (a *App) handleNoticeEvent( + msg *api.Message, timestamp string, +) { + lines := msg.BodyLines() + text := strings.Join(lines, " ") + + a.ui.AddStatus(fmt.Sprintf( + "[gray]%s [magenta]--%s-- %s", + timestamp, msg.From, text, + )) +} + +func (a *App) handleTopicEvent( + msg *api.Message, timestamp 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", + timestamp, msg.From, text, + )) +} + +func (a *App) handleDefaultEvent( + msg *api.Message, timestamp string, +) { + lines := msg.BodyLines() + text := strings.Join(lines, " ") + + if text != "" { + a.ui.AddStatus(fmt.Sprintf( + "[gray]%s [white][%s] %s", + timestamp, msg.Command, text, + )) + } +} diff --git a/cmd/neoirc-cli/ui.go b/internal/cli/ui.go similarity index 99% rename from cmd/neoirc-cli/ui.go rename to internal/cli/ui.go index a0f1bbb..80f5fc9 100644 --- a/cmd/neoirc-cli/ui.go +++ b/internal/cli/ui.go @@ -1,4 +1,4 @@ -package main +package cli import ( "fmt"