mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
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>
249 lines
6.9 KiB
Go
249 lines
6.9 KiB
Go
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)
|
||
}
|