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