hubbub/main.go
2025-10-31 05:10:40 +01:00

284 lines
6.0 KiB
Go

package main
import (
"context"
"fmt"
"log"
"net"
"os"
"slices"
"strings"
"time"
"git.tilde.town/nbsp/hubbub/config"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/ergochat/irc-go/ircevent"
"github.com/ergochat/irc-go/ircmsg"
"github.com/urfave/cli/v2"
"golang.org/x/crypto/ssh"
)
var app = &cli.App{
HelpName: "hubbub",
Usage: "voice chat over IRC over SSH",
Action: start,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "config",
Aliases: []string{"c"},
Usage: "load config from file",
},
},
}
var styleNotification = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(3))
var styleMic = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(1))
var styleInactive = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(0))
var deafened = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(1)).Render("D")
var muted = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(3)).Render("M")
var speaking = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(2)).Render("-")
type user struct {
lastSpoke time.Time
isMuted bool
isDeafened bool
}
type notification struct {
timestamp time.Time
text string
}
type model struct {
users map[string]user
notifications []notification
nick string
channel string
mute bool
deafen bool
conn *Conn
}
type Conn struct {
ircevent.Connection
}
func tick() tea.Cmd {
return tea.Tick(time.Millisecond*250, func(t time.Time) tea.Msg {
return t
})
}
func (m *model) setMute(is bool) {
m.mute = is
u := m.users[m.nick]
u.isMuted = is
m.users[m.nick] = u
un := ""
if !is {
un = "UN"
}
m.conn.Privmsg(m.channel, un+"MUTE")
}
func (m *model) setDeafen(is bool) {
m.deafen = is
u := m.users[m.nick]
u.isDeafened = is
m.users[m.nick] = u
un := ""
if !is {
un = "UN"
}
m.conn.Privmsg(m.channel, un+"DEAFEN")
}
func (m model) Init() tea.Cmd {
m.conn.AddCallback("PRIVMSG", func(e ircmsg.Message) {
target, message := e.Params[0], e.Params[1]
if target != m.channel {
return
}
u, ok := m.users[e.Nick()]
if !ok {
m.users[e.Nick()] = user{}
}
switch message {
case "DEAFEN":
u.isDeafened = true
case "UNDEAFEN":
u.isDeafened = false
case "MUTE":
u.isMuted = true
case "UNMUTE":
u.isDeafened = false
u.isMuted = false
default:
u.lastSpoke = time.Now()
}
m.users[e.Nick()] = u
})
m.conn.AddCallback("JOIN", func(e ircmsg.Message) {
m.users[e.Nick()] = user{}
// advertise your muted and deafened statuses on someone joining
if m.deafen {
m.conn.Privmsg(m.channel, "DEAFEN")
} else {
m.conn.Privmsg(m.channel, "UNDEAFEN")
}
if m.mute {
m.conn.Privmsg(m.channel, "MUTE")
} else {
m.conn.Privmsg(m.channel, "UNMUTE")
}
})
m.conn.AddCallback("PART", func(e ircmsg.Message) {
delete(m.users, e.Nick())
})
m.conn.Join(m.channel)
return tea.Batch(tick())
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case time.Time:
return m, tick()
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q", "ctrl+d":
return m, tea.Quit
case " ", "m", ",":
// if deafened, unmute and undeafen
if m.deafen {
m.setMute(false)
m.setDeafen(false)
} else {
m.setMute(!m.mute)
}
case "d", ".":
m.setDeafen(!m.deafen)
}
}
return m, nil
}
func (m model) View() (s string) {
s = fmt.Sprintf("connected to voice on %s\n", m.channel)
if m.deafen {
s += styleMic.Render("deafened: press period to undeafen or comma to unmute.") + "\n"
} else if m.mute {
s += styleMic.Render("muted: press comma to unmute.") + "\n"
}
for i, notification := range m.notifications {
if notification.timestamp.Add(time.Second * 5).Before(time.Now()) {
m.notifications = append(m.notifications[:i], m.notifications[i+1:]...)
} else {
s += styleNotification.Render(notification.text) + "\n"
}
}
plural := "s"
numUsers := len(m.users)
if numUsers == 1 {
plural = ""
}
s += fmt.Sprintf("%d user%s connected:\n", numUsers, plural)
keys := make([]string, 0, numUsers)
for k := range m.users {
keys = append(keys, k)
}
slices.Sort(keys)
for _, nick := range keys {
user := m.users[nick]
status := " "
nickStyled := styleInactive.Render(nick)
if user.isDeafened {
status = deafened
} else if user.isMuted {
status = muted
} else if user.lastSpoke.Add(time.Second * 2).After(time.Now()) {
status = speaking
nickStyled = nick
}
s += fmt.Sprintf("[%s] %s\n", status, nickStyled)
}
s += ", mute | . deafen | q quit"
return
}
func start(c *cli.Context) error {
config, err := config.LoadConfigFile(c.String("config"))
if err != nil {
log.Fatalf("unable to read config file: %v", err)
return err
}
key, err := os.ReadFile(os.ExpandEnv(strings.Replace(config.Privkey, "~", "$HOME", 1)))
if err != nil {
log.Fatalf("unable to read private key: %v", err)
return err
}
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
log.Fatalf("unable to parse private key: %v", err)
return err
}
client, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", config.Server, config.Port), &ssh.ClientConfig{
User: config.Username,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // XXX
})
if err != nil {
log.Fatalf("unable to connect to ssh: %v", err)
return err
}
defer client.Close()
conn := &Conn{
Connection: ircevent.Connection{
Server: "localhost:6667",
Nick: config.Nick,
UseTLS: false,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return client.Dial(network, addr)
},
},
}
if err := conn.Connect(); err != nil {
log.Fatalf("unable to connect to irc: %v", err)
return err
}
p := tea.NewProgram(model{
channel: config.Channel,
nick: config.Nick,
conn: conn,
users: map[string]user{},
notifications: []notification{},
})
_, err = p.Run()
return err
}
func main() {
if err := app.Run(os.Args); err != nil {
os.Exit(1)
}
}