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) } }