284 lines
		
	
	
		
			6.0 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			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)
 | |
| 	}
 | |
| }
 |