chat over ssh, powered by atproto
at main 181 lines 4.0 kB view raw
1package main 2 3import ( 4 "net" 5 "os" 6 "sync" 7 "time" 8 9 tea "github.com/charmbracelet/bubbletea" 10 "github.com/charmbracelet/log" 11 "github.com/charmbracelet/ssh" 12 "github.com/charmbracelet/wish" 13 "github.com/charmbracelet/wish/activeterm" 14 bm "github.com/charmbracelet/wish/bubbletea" 15 "github.com/charmbracelet/wish/logging" 16 "github.com/muesli/termenv" 17 18 "github.com/bluesky-social/indigo/atproto/auth/oauth" 19 "github.com/bluesky-social/indigo/atproto/syntax" 20) 21 22// --- user session: holds the atproto api client for posting --- 23 24type userSession struct { 25 handle string 26 did syntax.DID 27 oauthApp *oauth.ClientApp 28 sessData *oauth.ClientSessionData 29} 30 31// --- chat message --- 32 33type chatMsg struct { 34 sender string 35 text string 36 system bool 37 ts time.Time 38} 39 40// --- app --- 41 42type app struct { 43 *ssh.Server 44 mu sync.Mutex 45 progs map[string]*tea.Program 46 sessions map[string]*tea.Program 47 userSessions map[string]*userSession // handle -> userSession 48 users map[string]string // handle -> color 49 colorIdx int 50 history []chatMsg 51 keys *keyStore 52} 53 54func newApp() *app { 55 os.MkdirAll(keysDir, 0700) 56 57 a := &app{ 58 progs: make(map[string]*tea.Program), 59 sessions: make(map[string]*tea.Program), 60 userSessions: make(map[string]*userSession), 61 users: make(map[string]string), 62 keys: newKeyStore(keysDir + "/keys.json"), 63 } 64 65 s, err := wish.NewServer( 66 wish.WithAddress(net.JoinHostPort(host, port)), 67 wish.WithHostKeyPath(".ssh/id_ed25519"), 68 wish.WithPublicKeyAuth(func(_ ssh.Context, _ ssh.PublicKey) bool { 69 return true // accept all keys; we check them for identity 70 }), 71 wish.WithMiddleware( 72 goodbyeMiddleware(), 73 bm.MiddlewareWithProgramHandler(a.programHandler, termenv.ANSI256), 74 pdsGateMiddleware(), 75 activeterm.Middleware(), 76 logging.Middleware(), 77 ), 78 ) 79 if err != nil { 80 log.Fatal("could not create server", "err", err) 81 } 82 a.Server = s 83 return a 84} 85 86func (a *app) assignColor(handle string) string { 87 a.mu.Lock() 88 defer a.mu.Unlock() 89 if c, ok := a.users[handle]; ok { 90 return c 91 } 92 c := userColors[a.colorIdx%len(userColors)] 93 a.colorIdx++ 94 a.users[handle] = c 95 return c 96} 97 98func (a *app) promoteSession(sessionID, handle string) { 99 a.mu.Lock() 100 defer a.mu.Unlock() 101 if p, ok := a.sessions[sessionID]; ok { 102 a.progs[handle] = p 103 delete(a.sessions, sessionID) 104 } 105} 106 107func (a *app) removeProg(handle string) { 108 a.mu.Lock() 109 defer a.mu.Unlock() 110 delete(a.progs, handle) 111} 112 113func (a *app) removeSession(sessionID string) { 114 a.mu.Lock() 115 defer a.mu.Unlock() 116 delete(a.sessions, sessionID) 117} 118 119func (a *app) getHistory() []chatMsg { 120 a.mu.Lock() 121 defer a.mu.Unlock() 122 h := make([]chatMsg, len(a.history)) 123 copy(h, a.history) 124 return h 125} 126 127func (a *app) broadcast(msg chatMsg) { 128 a.mu.Lock() 129 a.history = append(a.history, msg) 130 if len(a.history) > 500 { 131 a.history = a.history[len(a.history)-500:] 132 } 133 progs := make([]*tea.Program, 0, len(a.progs)) 134 for _, p := range a.progs { 135 progs = append(progs, p) 136 } 137 a.mu.Unlock() 138 139 for _, p := range progs { 140 go p.Send(msg) 141 } 142} 143 144func (a *app) onlineUsers() []string { 145 a.mu.Lock() 146 defer a.mu.Unlock() 147 users := make([]string, 0, len(a.progs)) 148 for h := range a.progs { 149 users = append(users, h) 150 } 151 return users 152} 153 154func (a *app) setUserSession(handle string, us *userSession) { 155 a.mu.Lock() 156 defer a.mu.Unlock() 157 a.userSessions[handle] = us 158} 159 160func (a *app) getUserSession(handle string) *userSession { 161 a.mu.Lock() 162 defer a.mu.Unlock() 163 return a.userSessions[handle] 164} 165 166func (a *app) programHandler(s ssh.Session) *tea.Program { 167 renderer := bm.MakeRenderer(s) 168 sessionID := s.RemoteAddr().String() 169 sshUser := s.User() 170 pubkey := s.PublicKey() 171 172 // check if we already know this key 173 knownHandle := a.keys.lookup(pubkey) 174 175 m := newModel(a, renderer, sessionID, sshUser, pubkey, knownHandle) 176 p := tea.NewProgram(m, append(bm.MakeOptions(s), tea.WithAltScreen())...) 177 a.mu.Lock() 178 a.sessions[sessionID] = p 179 a.mu.Unlock() 180 return p 181}