agentbbs/internal/irc/tui.go
Anthony Ettinger 5eb1e96480 feat(irc): irc.profullstack.com host, OS-user (tilde.town) gate, premium channels
Hostname: serve the network as irc.profullstack.com (new IRC_DOMAIN var,
default irc.<root-of-DOMAIN>). Caddy serves an irc.profullstack.com site so it
gets a Let's Encrypt cert; ergo-refresh-certs copies that into Ergo for 6697.
Needs an A record irc.profullstack.com -> the box (self-signed until it resolves).

Members are OS users (tilde.town model): setup.sh reconciles a real OS account
per member dir (root-side, on each deploy + the 15-min timer; nologin shell, so
identity-only — no shell access). The IRC auth-script now gates on
`getent passwd` with uid>=1000 instead of the member dir, so "OS user" == member.

Premium channels: free members may /join; creating channels is a premium perk.
The ssh irc@ client gains /create #name (premium-gated via ensurePremium): it
joins the fresh channel and registers it with ChanServ as the member's founder.
v1 gate is route-level (operator-only-creation left off); external-client
creation hardening is a follow-up. See docs/irc.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:23:44 +00:00

249 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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@). canCreate gates the
// /create command on premium membership.
func Run(s ssh.Session, c *Client, canCreate bool) 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,
canCreate: canCreate,
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
canCreate bool // premium members may /create (register) new channels
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] /create #chan /msg <nick> <text> /me <action> /names /nick <name> /quit"))
case "/create":
if !m.canCreate {
m.push(cErr.Render("creating channels is a Founding Lifetime Member perk — upgrade with: ssh join@ (the BBS). You can still /join existing channels."))
break
}
ch := arg
if ch == "" {
m.push(cErr.Render("usage: /create #channel"))
break
}
if !strings.HasPrefix(ch, "#") {
ch = "#" + ch
}
// operator-only-creation is off, so joining a fresh channel creates it and
// ops the creator; registering it with ChanServ makes it persist with you
// as founder. (Both run in order on this connection.)
_ = m.c.Join(ch)
_ = m.c.Privmsg("ChanServ", "REGISTER "+ch)
m.channel = ch
m.push(cSys.Render("created " + ch + " — you're the founder. Share the name so members can /join it."))
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)
}