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 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-14 11:35:09 +00:00
parent 8adafaf515
commit 302259f65c
6 changed files with 631 additions and 11 deletions

226
internal/irc/tui.go Normal file
View file

@ -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@<host>)\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 <nick> <text> /me <action> /names /nick <name> /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 <nick> <text>"))
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 <name>"))
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)
}