ScuttleBot

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

Keyboard Shortcuts

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