From 302259f65cd0eeec7b7cf0336cd31aee87f0b1a5 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 14 Jun 2026 11:35:09 +0000 Subject: [PATCH] feat(irc): add `ssh irc@` built-in client for the members-only network Adds an in-process IRC client (internal/irc) and an `irc@` SSH route that drops a member straight into the BBS's own Ergo network with no client to install and no SASL to configure. - internal/irc/client.go: minimal IRC client (SASL PLAIN, IRCv3 CAP, PING, PRIVMSG/JOIN/PART/NICK, event stream). Dials Ergo on the loopback 127.0.0.1:6667; presents the member's account name (the SSH key already proved membership; Ergo's auth-script ignores the passphrase by design). - internal/irc/tui.go: Bubble Tea TUI over the SSH PTY (mirrors internal/chat) with /join /part /msg /me /names /nick /help and a current-channel input. - cmd/agentbbs: handleIRC resolves the member by key (members-only, free) and runs the client; routed via auth.IsIRCName. AGENTBBS_IRC_ADDR overrides the target on dev hosts. - auth: reserve `irc` as a route name. Unlike copying tor-irc@ (a third-party client in a pod), this runs our own Go code in-process, so there is no /exec shell-escape surface, and the host process can reach Ergo's loopback listener directly. Validated live against a members-only Ergo: non-members are rejected, a member authenticates via SASL, and channel messages are received. Co-Authored-By: Claude Opus 4.8 --- README.md | 6 +- cmd/agentbbs/main.go | 87 +++++++++++- docs/irc.md | 22 +++- internal/auth/auth.go | 10 +- internal/irc/client.go | 291 +++++++++++++++++++++++++++++++++++++++++ internal/irc/tui.go | 226 ++++++++++++++++++++++++++++++++ 6 files changed, 631 insertions(+), 11 deletions(-) create mode 100644 internal/irc/client.go create mode 100644 internal/irc/tui.go diff --git a/README.md b/README.md index e21bd39..28a60a0 100644 --- a/README.md +++ b/README.md @@ -110,13 +110,17 @@ name is an existing AgentBBS member (registration is off — your BBS account *i your IRC identity): ```bash +# zero-setup: built-in client over SSH (members only) +ssh -t irc@bbs.profullstack.com # native client — SASL account = your BBS member name /connect irc.bbs.profullstack.com 6697 # browser / agent over WebSocket wss://bbs.profullstack.com/irc ``` -Set `IRC=0` to skip it. Full details: [`docs/irc.md`](docs/irc.md). +`ssh irc@` is a built-in IRC client (`internal/irc`) that authenticates you to +the network automatically — no client to install. Set `IRC=0` to skip the +server. Full details: [`docs/irc.md`](docs/irc.md). ## Architecture diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index ecd776a..2045799 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -28,6 +28,7 @@ import ( "encoding/binary" "errors" "fmt" + "io" "net" "net/http" "os" @@ -53,6 +54,7 @@ import ( "github.com/profullstack/agentbbs/internal/forwardemail" "github.com/profullstack/agentbbs/internal/games" "github.com/profullstack/agentbbs/internal/hub" + "github.com/profullstack/agentbbs/internal/irc" "github.com/profullstack/agentbbs/internal/mail" "github.com/profullstack/agentbbs/internal/payments" "github.com/profullstack/agentbbs/internal/plugin" @@ -253,6 +255,8 @@ func (a *app) router() wish.Middleware { a.handleTorIRC(s) case auth.IsTorName(user): a.handleTorCmd(s) + case auth.IsIRCName(user): + a.handleIRC(s) case isVideo: a.handleVideo(s, code) case user == "agent": @@ -320,6 +324,41 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { return hub.New(u, ctx, a.enabledPlugins()), []tea.ProgramOption{tea.WithAltScreen()} } +// readLine reads one line of interactive input from an SSH session that is +// running under a client-allocated PTY. That detail is the whole reason this +// helper exists: when the client requests a PTY (which `ssh join@host` does by +// default) it puts its OWN terminal into raw mode, so it sends raw keystrokes — +// Enter arrives as '\r', not '\n' — and does NO local echo. bufio.ReadString +// ('\n') therefore blocks forever (the '\n' never comes) and the user sees a +// dead prompt. So we read byte-by-byte, accept either '\r' or '\n' as the line +// terminator, handle backspace, and echo printable bytes back ourselves. +func readLine(s ssh.Session, in *bufio.Reader) (string, error) { + var b []byte + for { + c, err := in.ReadByte() + if err != nil { + return "", err + } + switch c { + case '\r', '\n': + wish.Print(s, "\r\n") + return string(b), nil + case 0x03, 0x04: // Ctrl-C / Ctrl-D: treat as abort + return "", io.EOF + case 0x7f, '\b': // DEL / backspace: erase last char on screen too + if len(b) > 0 { + b = b[:len(b)-1] + wish.Print(s, "\b \b") + } + default: + if c >= 0x20 { // printable byte; ignore other control codes + b = append(b, c) + wish.Print(s, string(c)) + } + } + } +} + // handleJoin runs onboarding interactively in one SSH session: register the // visitor's key, confirm their email with a code we email them, then offer the // $10 lifetime Premium membership (CoinPay). It then disconnects. @@ -398,7 +437,7 @@ func (a *app) registerNewMember(s ssh.Session, in *bufio.Reader, fp string) (sto for tries := 0; tries < 5; tries++ { wish.Print(s, "\n Username ["+def+"]: ") - line, err := in.ReadString('\n') + line, err := readLine(s, in) if err != nil { return store.User{}, err } @@ -433,7 +472,7 @@ func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.U var email string for tries := 0; tries < 3; tries++ { wish.Print(s, "\n Email: ") - line, err := in.ReadString('\n') + line, err := readLine(s, in) if err != nil { return false } @@ -472,7 +511,7 @@ func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.U for tries := 0; tries < 3; tries++ { wish.Print(s, " Enter the code: ") - line, err := in.ReadString('\n') + line, err := readLine(s, in) if err != nil { return false } @@ -891,6 +930,48 @@ func (a *app) handleTorIRC(s ssh.Session) { } } +// handleIRC drops a member into the BBS's own (members-only) IRC network using +// an in-process client: it authenticates to Ergo over SASL as the member and +// runs a Bubble Tea TUI. Free for any registered member; needs a PTY. Distinct +// from tor-irc@ (a client for remote servers over Tor). +func (a *app) handleIRC(s ssh.Session) { + fp := auth.Fingerprint(s.PublicKey()) + if fp == "" { + wish.Println(s, "irc@ needs your registered SSH key. New here? ssh join@"+a.host) + _ = s.Exit(1) + return + } + u, found, err := a.st.UserByFingerprint(fp) + if err != nil || !found { + wish.Println(s, "the IRC network is members-only — register first: ssh join@"+a.host) + _ = s.Exit(1) + return + } + if u.Banned { + wish.Println(s, "this account is suspended.") + _ = s.Exit(1) + return + } + sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "irc") + defer func() { _ = a.st.EndSession(sessID) }() + + addr := strings.TrimSpace(os.Getenv("AGENTBBS_IRC_ADDR")) + if addr == "" { + addr = irc.DefaultAddr + } + log.Info("irc connect", "user", u.Name, "addr", addr) + c, err := irc.Dial(s.Context(), addr, u.Name) + if err != nil { + wish.Println(s, "irc: "+err.Error()) + _ = s.Exit(1) + return + } + _ = c.Join(irc.DefaultChannel) + if err := irc.Run(s, c); err != nil { + wish.Println(s, "irc: "+err.Error()) + } +} + // handleTorCmd runs an arbitrary command through Tor (torsocks) inside the // member's pod, never on the host. Premium; requires a PTY. func (a *app) handleTorCmd(s ssh.Session) { diff --git a/docs/irc.md b/docs/irc.md index fec1f49..74d6afe 100644 --- a/docs/irc.md +++ b/docs/irc.md @@ -13,13 +13,26 @@ operationally independent of the wish server. | Path | Address | For | |---|---|---| +| In-BBS | `ssh -t irc@bbs.profullstack.com` | members — zero-setup built-in client (see below) | | Native TLS | `irc.bbs.profullstack.com:6697` (TLS) | desktop/CLI clients (HexChat, irssi, WeeChat, Halloy…) | | WebSocket | `wss://bbs.profullstack.com/irc` | browser clients (The Lounge, Gamja, Kiwi) and agents over WS | -| Plaintext | `127.0.0.1:6667` | **loopback only** — on-box tooling/bridges; firewalled off | +| Plaintext | `127.0.0.1:6667` | **loopback only** — on-box tooling/the `irc@` client; firewalled off | The WebSocket path is fronted by Caddy (it terminates TLS and reverse-proxies to Ergo's loopback `127.0.0.1:8097`), so no extra public port is opened for the web. +### `ssh irc@` — the built-in client + +`ssh -t irc@bbs.profullstack.com` drops a member straight into the network with +no client to install or SASL to configure. It is an **in-process IRC client** +(`internal/irc`) running inside the agentbbs process: it reaches Ergo on the +loopback `127.0.0.1:6667` and authenticates as you (your SSH key already proved +you're a member, so it presents your account name over SASL). Because the client +is our own Go code — not a third-party client in a pod — there is no `/exec` +shell-escape surface. You land in `#lobby`; type to talk, or use +`/join #chan`, `/msg `, `/me`, `/names`, `/nick`, `/help`, and +`esc` to leave. Override the target with `AGENTBBS_IRC_ADDR` on a dev host. + ### Membership (who can connect) The network is **members-only**. There is **no self-service registration** — @@ -112,14 +125,11 @@ up immediately; the timer swaps in the real one once it exists. Unrelated, complementary. `ssh tor-irc@bbs.profullstack.com ` is a **client** that connects *out* to a remote (e.g. `.onion`) IRC server from inside -a member's pod. This is the BBS hosting **its own** IRC network for people and -agents to meet on. +a member's pod. `irc@` (above) and the 6697/WebSocket listeners are the BBS +hosting **its own** IRC network for people and agents to meet on. ## Ideas / next steps -- **In-BBS `irc@` route** — an SSH route that drops a member straight into the - local network (mirroring `tor-irc@` but pointed at `127.0.0.1:6667`), so - `ssh irc@bbs.profullstack.com` is an instant client with no setup. - **Bridge to `internal/chat`** — relay the BBS hub chat ↔ an IRC channel. - **Per-pod / per-game channels** — auto-create `#pod-`, `#game-`. - **Persistent history** — switch `datastore.mysql` on if replay must survive diff --git a/internal/auth/auth.go b/internal/auth/auth.go index b8276d5..7c5bcf8 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -56,6 +56,11 @@ var TorIRCNames = map[string]bool{"tor-irc": true} // member's pod (premium). Checked after the more specific tor-* routes. var TorNames = map[string]bool{"tor": true} +// IRCNames route a member straight into the BBS's own (members-only) IRC +// network via an in-process client. Distinct from tor-irc@, which is a client +// for connecting OUT to remote IRC servers over Tor. +var IRCNames = map[string]bool{"irc": true} + // GameNames are usernames that route to AgentGames: the line-delimited-JSON // agent-vs-agent match protocol (PRD §5.2). `play@` stays a guest hub alias. var GameNames = map[string]bool{"game": true, "games": true} @@ -84,6 +89,9 @@ func IsTorIRCName(u string) bool { return TorIRCNames[strings.ToLower(u)] } // IsTorName reports whether the SSH username requests the generic tor passthrough. func IsTorName(u string) bool { return TorNames[strings.ToLower(u)] } +// IsIRCName reports whether the SSH username requests the in-BBS IRC client. +func IsIRCName(u string) bool { return IRCNames[strings.ToLower(u)] } + // systemReserved are names that don't drive an SSH route but would still // collide with a per-user subdomain (.), the agent route, or common // infra hostnames — so members may not claim them as account names. @@ -100,7 +108,7 @@ var systemReserved = map[string]bool{ func IsReservedName(name string) bool { n := strings.ToLower(name) if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] || - TorURLNames[n] || TorIRCNames[n] || TorNames[n] || systemReserved[n] { + TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || systemReserved[n] { return true } return strings.HasPrefix(n, "video-") // video- call routes diff --git a/internal/irc/client.go b/internal/irc/client.go new file mode 100644 index 0000000..84a7868 --- /dev/null +++ b/internal/irc/client.go @@ -0,0 +1,291 @@ +// Package irc is a minimal, in-process IRC client for the `irc@` route: it +// connects a member to the BBS's own (members-only) Ergo network and drives a +// Bubble Tea TUI (see tui.go). It runs inside the agentbbs process on the host, +// so it reaches Ergo's loopback listener directly and — unlike running a +// third-party client like irssi in a pod — offers no /exec shell escape. +// +// The member is already authenticated to the BBS by their SSH key; we present +// their account name to Ergo over SASL PLAIN. Ergo's auth-script approves the +// login on membership alone (the passphrase is ignored, by design), so we send +// a placeholder. +package irc + +import ( + "bufio" + "context" + "encoding/base64" + "fmt" + "net" + "strings" + "time" +) + +// DefaultAddr is the host-local plaintext Ergo listener (loopback only; the +// public front door is 6697/TLS). Overridable for dev hosts. +const DefaultAddr = "127.0.0.1:6667" + +// Event is one thing that happened on the connection, already formatted for +// display. Kind groups them so the TUI can colorize. +type Event struct { + Kind EventKind + // Channel is the conversation the event belongs to ("" = server/status). + Channel string + Nick string + Text string +} + +type EventKind int + +const ( + EvMessage EventKind = iota // a PRIVMSG to a channel or to us + EvNotice // a NOTICE (often from services) + EvJoin + EvPart + EvQuit + EvNick + EvSystem // numerics, topics, names, errors, our own status lines + EvClosed // the connection ended; Text carries the reason +) + +// Client is a single member's connection to the network. +type Client struct { + conn net.Conn + w *bufio.Writer + r *bufio.Reader + nick string + events chan Event +} + +// Dial connects to addr, performs the SASL PLAIN handshake as nick, and blocks +// until the server welcomes us (001) or the attempt fails. On success the read +// loop is running and Events() is live. +func Dial(ctx context.Context, addr, nick string) (*Client, error) { + if addr == "" { + addr = DefaultAddr + } + d := net.Dialer{Timeout: 10 * time.Second} + conn, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + return nil, fmt.Errorf("could not reach the IRC server (%s): %w", addr, err) + } + c := &Client{ + conn: conn, + w: bufio.NewWriter(conn), + r: bufio.NewReader(conn), + nick: nick, + events: make(chan Event, 256), + } + if err := c.handshake(nick); err != nil { + _ = conn.Close() + return nil, err + } + go c.readLoop() + return c, nil +} + +// Events is the stream the TUI consumes. It is closed when the connection ends. +func (c *Client) Events() <-chan Event { return c.events } + +// Nick reports the connection's current nickname. +func (c *Client) Nick() string { return c.nick } + +func (c *Client) send(format string, a ...any) error { + if _, err := fmt.Fprintf(c.w, format+"\r\n", a...); err != nil { + return err + } + return c.w.Flush() +} + +// handshake authenticates with SASL PLAIN and waits for welcome (001) or an +// auth failure (904/906) / error. The membership gate lives server-side; the +// passphrase is a placeholder the auth-script ignores. +func (c *Client) handshake(nick string) error { + _ = c.conn.SetDeadline(time.Now().Add(20 * time.Second)) + if err := c.send("CAP LS 302"); err != nil { + return err + } + if err := c.send("NICK %s", nick); err != nil { + return err + } + if err := c.send("USER %s 0 * :%s", nick, nick); err != nil { + return err + } + if err := c.send("CAP REQ :sasl"); err != nil { + return err + } + for { + line, err := c.r.ReadString('\n') + if err != nil { + return fmt.Errorf("connection closed during login: %w", err) + } + msg := parse(line) + switch msg.command { + case "PING": + _ = c.send("PONG :%s", msg.trailing()) + case "CAP": + // params: <*> ACK :sasl → start PLAIN exchange + if len(msg.params) >= 2 && msg.params[1] == "ACK" { + _ = c.send("AUTHENTICATE PLAIN") + } + case "AUTHENTICATE": + if msg.first() == "+" { + tok := base64.StdEncoding.EncodeToString([]byte("\x00" + nick + "\x00x")) + _ = c.send("AUTHENTICATE %s", tok) + } + case "903": // SASL success → close capability negotiation so we get 001 + _ = c.send("CAP END") + case "900": // RPL_LOGGEDIN (informational; 903 drives CAP END) + case "904", "905", "906": // SASL failed/aborted + return fmt.Errorf("the network is members-only and rejected %q — register first: ssh join@", nick) + case "433": // nick in use + return fmt.Errorf("nickname %q is already in use on the network", nick) + case "ERROR": + return fmt.Errorf("server refused the connection: %s", msg.trailing()) + case "001": // welcome — we're in + _ = c.conn.SetDeadline(time.Time{}) // clear; read loop manages liveness + return nil + } + } +} + +// readLoop turns inbound IRC into Events until the connection drops. +func (c *Client) readLoop() { + defer close(c.events) + for { + line, err := c.r.ReadString('\n') + if err != nil { + c.emit(Event{Kind: EvClosed, Text: "disconnected"}) + return + } + msg := parse(line) + switch msg.command { + case "PING": + _ = c.send("PONG :%s", msg.trailing()) + case "PRIVMSG": + c.emit(Event{Kind: EvMessage, Channel: msg.first(), Nick: msg.nick(), Text: msg.trailing()}) + case "NOTICE": + c.emit(Event{Kind: EvNotice, Channel: msg.first(), Nick: msg.nick(), Text: msg.trailing()}) + case "JOIN": + c.emit(Event{Kind: EvJoin, Channel: msg.first(), Nick: msg.nick()}) + case "PART": + c.emit(Event{Kind: EvPart, Channel: msg.first(), Nick: msg.nick(), Text: msg.trailing()}) + case "QUIT": + c.emit(Event{Kind: EvQuit, Nick: msg.nick(), Text: msg.trailing()}) + case "NICK": + n := msg.nick() + if n == c.nick { + c.nick = msg.trailing() + } + c.emit(Event{Kind: EvNick, Nick: n, Text: msg.trailing()}) + case "332": // RPL_TOPIC + c.emit(Event{Kind: EvSystem, Channel: msg.nth(1), Text: "topic: " + msg.trailing()}) + case "353": // RPL_NAMREPLY + c.emit(Event{Kind: EvSystem, Channel: msg.nth(2), Text: "names: " + msg.trailing()}) + case "ERROR": + c.emit(Event{Kind: EvClosed, Text: msg.trailing()}) + return + default: + // Surface numeric replies (server info, MOTD, errors) as status. + if len(msg.command) == 3 && msg.command[0] >= '0' && msg.command[0] <= '9' { + c.emit(Event{Kind: EvSystem, Text: msg.trailing()}) + } + } + } +} + +func (c *Client) emit(e Event) { + select { + case c.events <- e: + default: // drop if the TUI is far behind rather than block the read loop + } +} + +// Privmsg sends a message to a channel or nick. +func (c *Client) Privmsg(target, text string) error { return c.send("PRIVMSG %s :%s", target, text) } + +// Join joins a channel. +func (c *Client) Join(ch string) error { return c.send("JOIN %s", ch) } + +// Part leaves a channel. +func (c *Client) Part(ch string) error { return c.send("PART %s", ch) } + +// Raw sends a pre-formed IRC command (for /-commands the TUI doesn't model). +func (c *Client) Raw(line string) error { return c.send("%s", line) } + +// Close quits cleanly and tears down the socket. +func (c *Client) Close() error { + _ = c.send("QUIT :leaving") + return c.conn.Close() +} + +// --- minimal message parsing ------------------------------------------------ + +type message struct { + prefix string + command string + params []string // includes the trailing param as the last element + hasTrail bool +} + +func parse(line string) message { + line = strings.TrimRight(line, "\r\n") + var m message + if strings.HasPrefix(line, "@") { // strip IRCv3 tags; we don't use them here + if i := strings.IndexByte(line, ' '); i >= 0 { + line = line[i+1:] + } + } + if strings.HasPrefix(line, ":") { + i := strings.IndexByte(line, ' ') + if i < 0 { + return m + } + m.prefix = line[1:i] + line = line[i+1:] + } + // trailing param + if i := strings.Index(line, " :"); i >= 0 { + trail := line[i+2:] + line = line[:i] + m.command, m.params = splitCmd(line) + m.params = append(m.params, trail) + m.hasTrail = true + return m + } + m.command, m.params = splitCmd(line) + return m +} + +func splitCmd(s string) (string, []string) { + f := strings.Fields(s) + if len(f) == 0 { + return "", nil + } + return strings.ToUpper(f[0]), f[1:] +} + +// nick returns the nick from the prefix (nick!user@host). +func (m message) nick() string { + if i := strings.IndexByte(m.prefix, '!'); i >= 0 { + return m.prefix[:i] + } + return m.prefix +} + +// first returns the first param (often the target/channel), else "". +func (m message) first() string { return m.nth(0) } + +func (m message) nth(i int) string { + if i >= 0 && i < len(m.params) { + return m.params[i] + } + return "" +} + +// trailing returns the trailing (message) param, else the last param. +func (m message) trailing() string { + if len(m.params) == 0 { + return "" + } + return m.params[len(m.params)-1] +} diff --git a/internal/irc/tui.go b/internal/irc/tui.go new file mode 100644 index 0000000..ffae9b5 --- /dev/null +++ b/internal/irc/tui.go @@ -0,0 +1,226 @@ +package irc + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/ssh" +) + +const historyLines = 500 + +// DefaultChannel is joined automatically when a member enters via irc@. +const DefaultChannel = "#lobby" + +var ( + cTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#c084fc")) + cNick = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80")) + cSelf = lipgloss.NewStyle().Foreground(lipgloss.Color("#38bdf8")) + cSys = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + cNote = lipgloss.NewStyle().Foreground(lipgloss.Color("#fbbf24")) + cErr = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) +) + +// Run drives the IRC TUI over the SSH session until the member leaves; leaving +// ends the session (irc@ is a dedicated route, like agent@). +func Run(s ssh.Session, c *Client) error { + ptyReq, winCh, hasPty := s.Pty() + if !hasPty { + _, _ = s.Write([]byte("irc needs a terminal (ssh -t irc@)\r\n")) + return nil + } + m := &model{ + c: c, + channel: DefaultChannel, + width: ptyReq.Window.Width, + height: ptyReq.Window.Height, + } + m.lines = append(m.lines, + cSys.Render(fmt.Sprintf("connected as %s — joined %s. /help for commands, esc to leave.", c.Nick(), DefaultChannel))) + p := tea.NewProgram(m, tea.WithInput(s), tea.WithOutput(s), tea.WithAltScreen()) + go func() { + for w := range winCh { + p.Send(tea.WindowSizeMsg{Width: w.Width, Height: w.Height}) + } + }() + _, err := p.Run() + _ = c.Close() + return err +} + +// waitEvent blocks on the next connection event and delivers it to the model. +func waitEvent(c *Client) tea.Cmd { + return func() tea.Msg { + e, ok := <-c.Events() + if !ok { + return Event{Kind: EvClosed, Text: "disconnected"} + } + return e + } +} + +type model struct { + c *Client + channel string // current conversation target for typed lines + lines []string + input string + + width, height int +} + +func (m *model) Init() tea.Cmd { return waitEvent(m.c) } + +func (m *model) push(line string) { + m.lines = append(m.lines, line) + if len(m.lines) > historyLines { + m.lines = m.lines[len(m.lines)-historyLines:] + } +} + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + case Event: + return m.handleEvent(msg) + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + case "enter": + return m.handleInput() + case "backspace": + if len(m.input) > 0 { + m.input = m.input[:len(m.input)-1] + } + default: + if msg.Type == tea.KeyRunes || msg.String() == " " { + m.input += string(msg.Runes) + } + } + } + return m, nil +} + +func (m *model) handleEvent(e Event) (tea.Model, tea.Cmd) { + switch e.Kind { + case EvMessage: + where := "" + if !strings.HasPrefix(e.Channel, "#") { // a direct message to us + where = cNote.Render("[dm] ") + } + m.push(where + cNick.Render(e.Nick) + " " + e.Text) + case EvNotice: + m.push(cNote.Render("-"+e.Nick+"- ") + e.Text) + case EvJoin: + m.push(cSys.Render(fmt.Sprintf("→ %s joined %s", e.Nick, e.Channel))) + case EvPart: + m.push(cSys.Render(fmt.Sprintf("← %s left %s %s", e.Nick, e.Channel, e.Text))) + case EvQuit: + m.push(cSys.Render(fmt.Sprintf("← %s quit (%s)", e.Nick, e.Text))) + case EvNick: + m.push(cSys.Render(fmt.Sprintf("* %s is now %s", e.Nick, e.Text))) + case EvSystem: + if e.Text != "" { + m.push(cSys.Render(e.Text)) + } + case EvClosed: + m.push(cErr.Render("* connection closed: " + e.Text)) + return m, tea.Quit + } + return m, waitEvent(m.c) +} + +func (m *model) handleInput() (tea.Model, tea.Cmd) { + text := strings.TrimSpace(m.input) + m.input = "" + if text == "" { + return m, nil + } + if strings.HasPrefix(text, "/") { + return m, m.command(text) + } + // plain line → message to the current channel + if err := m.c.Privmsg(m.channel, text); err != nil { + m.push(cErr.Render("send failed: " + err.Error())) + return m, nil + } + m.push(cSelf.Render(m.c.Nick()) + " " + text) + return m, nil +} + +// command handles the small set of /-commands the TUI models; anything else is +// passed through raw so power users can drive the server directly. +func (m *model) command(text string) tea.Cmd { + fields := strings.Fields(text) + cmd := strings.ToLower(fields[0]) + arg := strings.TrimSpace(strings.TrimPrefix(text, fields[0])) + switch cmd { + case "/help": + m.push(cSys.Render("commands: /join #chan /part [#chan] /msg /me /names /nick /quit")) + case "/join": + if arg == "" { + m.push(cErr.Render("usage: /join #channel")) + break + } + ch := arg + if !strings.HasPrefix(ch, "#") { + ch = "#" + ch + } + _ = m.c.Join(ch) + m.channel = ch + m.push(cSys.Render("joining " + ch)) + case "/part": + ch := m.channel + if arg != "" { + ch = arg + } + _ = m.c.Part(ch) + m.push(cSys.Render("leaving " + ch)) + case "/msg": + f := strings.SplitN(arg, " ", 2) + if len(f) < 2 { + m.push(cErr.Render("usage: /msg ")) + break + } + _ = m.c.Privmsg(f[0], f[1]) + m.push(cSelf.Render(m.c.Nick()) + " " + cNote.Render("→"+f[0]+" ") + f[1]) + case "/me": + if arg == "" { + break + } + _ = m.c.Privmsg(m.channel, "\x01ACTION "+arg+"\x01") + m.push(cSelf.Render("* "+m.c.Nick()) + " " + arg) + case "/names": + _ = m.c.Raw("NAMES " + m.channel) + case "/nick": + if arg == "" { + m.push(cErr.Render("usage: /nick ")) + break + } + _ = m.c.Raw("NICK " + arg) + case "/quit": + return tea.Quit + default: + _ = m.c.Raw(strings.TrimPrefix(text, "/")) + m.push(cSys.Render("» " + strings.TrimPrefix(text, "/"))) + } + return nil +} + +func (m *model) View() string { + rows := m.height - 4 + if rows < 3 { + rows = 3 + } + start := 0 + if len(m.lines) > rows { + start = len(m.lines) - rows + } + body := strings.Join(m.lines[start:], "\n") + header := cTitle.Render("irc@ — "+m.channel) + cSys.Render(" ("+m.c.Nick()+" · esc to leave)") + prompt := cSys.Render(m.channel+" ") + "› " + m.input + "█" + return lipgloss.NewStyle().Padding(0, 1).Render(header + "\n" + body + "\n\n" + prompt) +}