ScuttleBot

scuttlebot / internal / api / chat.go
Source Blame History 207 lines
d74d207… lmata 1 package api
d74d207… lmata 2
d74d207… lmata 3 import (
d74d207… lmata 4 "context"
d74d207… lmata 5 "encoding/json"
d74d207… lmata 6 "fmt"
d74d207… lmata 7 "net/http"
d74d207… lmata 8 "time"
d74d207… lmata 9
68677f9… noreply 10 "github.com/conflicthq/scuttlebot/internal/auth"
d74d207… lmata 11 "github.com/conflicthq/scuttlebot/internal/bots/bridge"
d74d207… lmata 12 )
d74d207… lmata 13
d74d207… lmata 14 // chatBridge is the interface the API layer requires from the bridge bot.
d74d207… lmata 15 type chatBridge interface {
d74d207… lmata 16 Channels() []string
d74d207… lmata 17 JoinChannel(channel string)
5ac549c… lmata 18 LeaveChannel(channel string)
d74d207… lmata 19 Messages(channel string) []bridge.Message
d74d207… lmata 20 Subscribe(channel string) (<-chan bridge.Message, func())
d74d207… lmata 21 Send(ctx context.Context, channel, text, senderNick string) error
f3c383e… noreply 22 SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *bridge.Meta) error
24a217e… lmata 23 Stats() bridge.Stats
24a217e… lmata 24 TouchUser(channel, nick string)
24a217e… lmata 25 Users(channel string) []string
6d94dfd… noreply 26 UsersWithModes(channel string) []bridge.UserInfo
6d94dfd… noreply 27 ChannelModes(channel string) string
cca45cb… lmata 28 RefreshNames(channel string)
d74d207… lmata 29 }
d74d207… lmata 30
d74d207… lmata 31 func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) {
d74d207… lmata 32 channel := "#" + r.PathValue("channel")
d74d207… lmata 33 s.bridge.JoinChannel(channel)
5ac549c… lmata 34 w.WriteHeader(http.StatusNoContent)
5ac549c… lmata 35 }
5ac549c… lmata 36
5ac549c… lmata 37 func (s *Server) handleDeleteChannel(w http.ResponseWriter, r *http.Request) {
5ac549c… lmata 38 channel := "#" + r.PathValue("channel")
5ac549c… lmata 39 s.bridge.LeaveChannel(channel)
d74d207… lmata 40 w.WriteHeader(http.StatusNoContent)
d74d207… lmata 41 }
d74d207… lmata 42
d74d207… lmata 43 func (s *Server) handleListChannels(w http.ResponseWriter, r *http.Request) {
d74d207… lmata 44 writeJSON(w, http.StatusOK, map[string]any{"channels": s.bridge.Channels()})
d74d207… lmata 45 }
d74d207… lmata 46
d74d207… lmata 47 func (s *Server) handleChannelMessages(w http.ResponseWriter, r *http.Request) {
d74d207… lmata 48 channel := "#" + r.PathValue("channel")
d74d207… lmata 49 // Auto-join on first access so the bridge starts tracking this channel.
d74d207… lmata 50 s.bridge.JoinChannel(channel)
d74d207… lmata 51 msgs := s.bridge.Messages(channel)
d74d207… lmata 52 if msgs == nil {
d74d207… lmata 53 msgs = []bridge.Message{}
d74d207… lmata 54 }
b71f8ab… lmata 55 // Filter by ?since=<RFC3339> when provided (avoids sending full history on each poll).
b71f8ab… lmata 56 if sinceStr := r.URL.Query().Get("since"); sinceStr != "" {
b71f8ab… lmata 57 since, err := time.Parse(time.RFC3339Nano, sinceStr)
b71f8ab… lmata 58 if err == nil {
b71f8ab… lmata 59 filtered := msgs[:0]
b71f8ab… lmata 60 for _, m := range msgs {
b71f8ab… lmata 61 if m.At.After(since) {
b71f8ab… lmata 62 filtered = append(filtered, m)
b71f8ab… lmata 63 }
b71f8ab… lmata 64 }
b71f8ab… lmata 65 msgs = filtered
b71f8ab… lmata 66 }
b71f8ab… lmata 67 }
d74d207… lmata 68 writeJSON(w, http.StatusOK, map[string]any{"messages": msgs})
d74d207… lmata 69 }
d74d207… lmata 70
d74d207… lmata 71 func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) {
d74d207… lmata 72 channel := "#" + r.PathValue("channel")
d74d207… lmata 73 var req struct {
f3c383e… noreply 74 Text string `json:"text"`
f3c383e… noreply 75 Nick string `json:"nick"`
f3c383e… noreply 76 Meta *bridge.Meta `json:"meta,omitempty"`
d74d207… lmata 77 }
d74d207… lmata 78 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
d74d207… lmata 79 writeError(w, http.StatusBadRequest, "invalid request body")
d74d207… lmata 80 return
d74d207… lmata 81 }
d74d207… lmata 82 if req.Text == "" {
d74d207… lmata 83 writeError(w, http.StatusBadRequest, "text is required")
d74d207… lmata 84 return
d74d207… lmata 85 }
f3c383e… noreply 86 if err := s.bridge.SendWithMeta(r.Context(), channel, req.Text, req.Nick, req.Meta); err != nil {
d74d207… lmata 87 s.log.Error("bridge send", "channel", channel, "err", err)
d74d207… lmata 88 writeError(w, http.StatusInternalServerError, "send failed")
24a217e… lmata 89 return
24a217e… lmata 90 }
24a217e… lmata 91 w.WriteHeader(http.StatusNoContent)
24a217e… lmata 92 }
24a217e… lmata 93
24a217e… lmata 94 func (s *Server) handleChannelPresence(w http.ResponseWriter, r *http.Request) {
24a217e… lmata 95 channel := "#" + r.PathValue("channel")
24a217e… lmata 96 var req struct {
24a217e… lmata 97 Nick string `json:"nick"`
24a217e… lmata 98 }
24a217e… lmata 99 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
24a217e… lmata 100 writeError(w, http.StatusBadRequest, "invalid request body")
24a217e… lmata 101 return
24a217e… lmata 102 }
24a217e… lmata 103 if req.Nick == "" {
24a217e… lmata 104 writeError(w, http.StatusBadRequest, "nick is required")
24a217e… lmata 105 return
24a217e… lmata 106 }
24a217e… lmata 107 s.bridge.TouchUser(channel, req.Nick)
1cbc747… lmata 108 if s.registry != nil {
1cbc747… lmata 109 s.registry.Touch(req.Nick)
1cbc747… lmata 110 }
24a217e… lmata 111 w.WriteHeader(http.StatusNoContent)
24a217e… lmata 112 }
24a217e… lmata 113
24a217e… lmata 114 func (s *Server) handleChannelUsers(w http.ResponseWriter, r *http.Request) {
24a217e… lmata 115 channel := "#" + r.PathValue("channel")
6d94dfd… noreply 116 users := s.bridge.UsersWithModes(channel)
24a217e… lmata 117 if users == nil {
6d94dfd… noreply 118 users = []bridge.UserInfo{}
6d94dfd… noreply 119 }
6d94dfd… noreply 120 modes := s.bridge.ChannelModes(channel)
6d94dfd… noreply 121 writeJSON(w, http.StatusOK, map[string]any{"users": users, "channel_modes": modes})
a027855… noreply 122 }
a027855… noreply 123
a027855… noreply 124 func (s *Server) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) {
a027855… noreply 125 channel := "#" + r.PathValue("channel")
a027855… noreply 126 if s.policies == nil {
a027855… noreply 127 writeJSON(w, http.StatusOK, ChannelDisplayConfig{})
a027855… noreply 128 return
a027855… noreply 129 }
a027855… noreply 130 p := s.policies.Get()
a027855… noreply 131 cfg := p.Bridge.ChannelDisplay[channel]
a027855… noreply 132 writeJSON(w, http.StatusOK, cfg)
a027855… noreply 133 }
a027855… noreply 134
a027855… noreply 135 func (s *Server) handlePutChannelConfig(w http.ResponseWriter, r *http.Request) {
a027855… noreply 136 channel := "#" + r.PathValue("channel")
a027855… noreply 137 if s.policies == nil {
a027855… noreply 138 writeError(w, http.StatusServiceUnavailable, "policies not configured")
a027855… noreply 139 return
a027855… noreply 140 }
a027855… noreply 141 var cfg ChannelDisplayConfig
a027855… noreply 142 if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
a027855… noreply 143 writeError(w, http.StatusBadRequest, "invalid request body")
a027855… noreply 144 return
a027855… noreply 145 }
a027855… noreply 146 p := s.policies.Get()
a027855… noreply 147 if p.Bridge.ChannelDisplay == nil {
a027855… noreply 148 p.Bridge.ChannelDisplay = make(map[string]ChannelDisplayConfig)
a027855… noreply 149 }
a027855… noreply 150 p.Bridge.ChannelDisplay[channel] = cfg
a027855… noreply 151 if err := s.policies.Set(p); err != nil {
a027855… noreply 152 writeError(w, http.StatusInternalServerError, "save failed")
a027855… noreply 153 return
a027855… noreply 154 }
a027855… noreply 155 w.WriteHeader(http.StatusNoContent)
d74d207… lmata 156 }
d74d207… lmata 157
d74d207… lmata 158 // handleChannelStream serves an SSE stream of IRC messages for a channel.
d74d207… lmata 159 // Auth is via ?token= query param because EventSource doesn't support custom headers.
d74d207… lmata 160 func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) {
d74d207… lmata 161 token := r.URL.Query().Get("token")
68677f9… noreply 162 key := s.apiKeys.Lookup(token)
68677f9… noreply 163 if key == nil || (!key.HasScope(auth.ScopeChannels) && !key.HasScope(auth.ScopeChat)) {
d74d207… lmata 164 writeError(w, http.StatusUnauthorized, "invalid or missing token")
d74d207… lmata 165 return
d74d207… lmata 166 }
d74d207… lmata 167
d74d207… lmata 168 channel := "#" + r.PathValue("channel")
d74d207… lmata 169 s.bridge.JoinChannel(channel)
d74d207… lmata 170
d74d207… lmata 171 flusher, ok := w.(http.Flusher)
d74d207… lmata 172 if !ok {
d74d207… lmata 173 writeError(w, http.StatusInternalServerError, "streaming not supported")
d74d207… lmata 174 return
d74d207… lmata 175 }
d74d207… lmata 176
d74d207… lmata 177 w.Header().Set("Content-Type", "text/event-stream")
d74d207… lmata 178 w.Header().Set("Cache-Control", "no-cache")
d74d207… lmata 179 w.Header().Set("Connection", "keep-alive")
d74d207… lmata 180 w.Header().Set("X-Accel-Buffering", "no")
d74d207… lmata 181
d74d207… lmata 182 msgs, unsub := s.bridge.Subscribe(channel)
d74d207… lmata 183 defer unsub()
d74d207… lmata 184
d74d207… lmata 185 ticker := time.NewTicker(25 * time.Second)
d74d207… lmata 186 defer ticker.Stop()
d74d207… lmata 187
d74d207… lmata 188 for {
d74d207… lmata 189 select {
d74d207… lmata 190 case <-r.Context().Done():
d74d207… lmata 191 return
d74d207… lmata 192 case msg, ok := <-msgs:
d74d207… lmata 193 if !ok {
d74d207… lmata 194 return
d74d207… lmata 195 }
d74d207… lmata 196 data, err := json.Marshal(msg)
d74d207… lmata 197 if err != nil {
d74d207… lmata 198 continue
d74d207… lmata 199 }
d74d207… lmata 200 fmt.Fprintf(w, "data: %s\n\n", data)
d74d207… lmata 201 flusher.Flush()
d74d207… lmata 202 case <-ticker.C:
d74d207… lmata 203 fmt.Fprintf(w, ": heartbeat\n\n")
d74d207… lmata 204 flusher.Flush()
d74d207… lmata 205 }
d74d207… lmata 206 }
d74d207… lmata 207 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button