chat over ssh, powered by atproto
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}