Files
neoirc/internal/cli/app.go
T
sneak 75d761906b
check / check (push) Waiting to run
Show neoirc-cli's own messages, joins and parts from the server's events (closes #186)
neoirc-cli wrote its own line when it sent a message, joined or parted,
and ignored the server's event for its own nick, except a JOIN, so a
join showed twice and what the session did from another client never
showed. The HTTP API queues these events to every client of the
session, so the CLI now writes no line of its own when it sends and
shows the event as it shows anyone else's. A direct message the user
sent goes in the recipient's buffer. The user's own part does not mark
the channel unread, as the user has just left it.

Model: opus-5-5
2026-10-21 16:04:42 +00:00

901 lines
15 KiB
Go

// Package cli implements the neoirc-cli terminal client.
package cli
import (
"fmt"
"os"
"strings"
"sync"
"time"
api "sneak.berlin/go/neoirc/internal/cli/api"
"sneak.berlin/go/neoirc/pkg/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 <server-url>" +
"[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 <url>",
)
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(),
)
}
}
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 <server-url>",
)
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 <name>",
)
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.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.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 <nick> <text>",
)
return
}
target, text := parts[0], parts[1]
a.mu.Lock()
connected := a.connected
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,
))
}
}
func (a *App) cmdQuery(nick string) {
if nick == "" {
a.ui.AddStatus(
"[red]Usage: /query <nick>",
)
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
}
// The HTTP API has no topic query: a TOPIC needs text.
if args == "" {
a.ui.AddStatus("[red]Usage: /topic <text>")
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
}
nicks := make([]string, 0, len(members))
for _, member := range members {
nicks = append(nicks, memberPrefix(member)+member.Nick)
}
a.ui.AddLine(target, fmt.Sprintf(
"[cyan]*** Members of %s: %s",
target, strings.Join(nicks, " "),
))
}
// memberPrefix returns "@" for an operator, "+" for a voiced
// member, and "" for anyone else.
func memberPrefix(member api.Member) string {
if member.IsOperator {
return "@"
}
if member.IsVoiced {
return "+"
}
return ""
}
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 %s", ch.Name, 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 <nick>",
)
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 <number>",
)
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 <url> — Connect to server",
" /nick <name> — Change nickname",
" /join #channel — Join channel",
" /part [#chan] — Leave channel",
" /msg <nick> <text> — Send DM",
" /query <nick> — Open DM window",
" /topic <text> — Set topic",
" /names — List channel members",
" /list — List channels",
" /who [#channel] — List users in channel",
" /whois <nick> — Show user info",
" /motd — Show message of the day",
" /window <n> — 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, myNick)
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, " ")
// A direct message shows in the buffer of the other user:
// the recipient of one the user sent, else the sender.
target := msg.To
if !strings.HasPrefix(target, "#") && msg.From != myNick {
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,
))
}
// handlePartEvent shows a part in the channel's buffer. The
// user's own part does not mark the channel unread: the user
// has just left it.
func (a *App) handlePartEvent(
msg *api.Message, timestamp, myNick string,
) {
if msg.To == "" {
return
}
lines := msg.BodyLines()
reason := strings.Join(lines, " ")
line := fmt.Sprintf(
"[gray]%s [yellow]*** %s has left %s",
timestamp, msg.From, msg.To,
)
if reason != "" {
line += " (" + reason + ")"
}
if msg.From == myNick {
a.ui.AddLineAsRead(msg.To, line)
return
}
a.ui.AddLine(msg.To, line)
}
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,
))
}
}