ScuttleBot

feat: web IRC chat client — bridge bot, SSE stream, join/send UI

lmata 2026-03-31 13:18 trunk
Commit d74d207839197778e10a9b9c4f8af7af742765395f6f3cf1862bb9da6bc27f33
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -13,10 +13,11 @@
1313
"path/filepath"
1414
"syscall"
1515
"time"
1616
1717
"github.com/conflicthq/scuttlebot/internal/api"
18
+ "github.com/conflicthq/scuttlebot/internal/bots/bridge"
1819
"github.com/conflicthq/scuttlebot/internal/config"
1920
"github.com/conflicthq/scuttlebot/internal/ergo"
2021
"github.com/conflicthq/scuttlebot/internal/mcp"
2122
"github.com/conflicthq/scuttlebot/internal/registry"
2223
)
@@ -96,13 +97,42 @@
9697
9798
// Shared API token — used by both REST and MCP servers.
9899
apiToken := mustGenToken()
99100
log.Info("api token", "token", apiToken) // printed once on startup — user copies this
100101
tokens := []string{apiToken}
102
+
103
+ // Start bridge bot (powers the web chat UI).
104
+ var bridgeBot *bridge.Bot
105
+ if cfg.Bridge.Enabled {
106
+ if cfg.Bridge.Password == "" {
107
+ cfg.Bridge.Password = mustGenToken()
108
+ }
109
+ // Ensure the bridge's NickServ account exists with the current password.
110
+ if err := manager.API().RegisterAccount(cfg.Bridge.Nick, cfg.Bridge.Password); err != nil {
111
+ // Account exists from a previous run — update the password so it matches.
112
+ if err2 := manager.API().ChangePassword(cfg.Bridge.Nick, cfg.Bridge.Password); err2 != nil {
113
+ log.Error("bridge account setup failed", "err", err2)
114
+ os.Exit(1)
115
+ }
116
+ }
117
+ bridgeBot = bridge.New(
118
+ cfg.Ergo.IRCAddr,
119
+ cfg.Bridge.Nick,
120
+ cfg.Bridge.Password,
121
+ cfg.Bridge.Channels,
122
+ cfg.Bridge.BufferSize,
123
+ log,
124
+ )
125
+ go func() {
126
+ if err := bridgeBot.Start(ctx); err != nil {
127
+ log.Error("bridge bot error", "err", err)
128
+ }
129
+ }()
130
+ }
101131
102132
// Start HTTP REST API server.
103
- apiSrv := api.New(reg, tokens, log)
133
+ apiSrv := api.New(reg, tokens, bridgeBot, log)
104134
httpServer := &http.Server{
105135
Addr: cfg.APIAddr,
106136
Handler: apiSrv.Handler(),
107137
}
108138
go func() {
109139
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -13,10 +13,11 @@
13 "path/filepath"
14 "syscall"
15 "time"
16
17 "github.com/conflicthq/scuttlebot/internal/api"
 
18 "github.com/conflicthq/scuttlebot/internal/config"
19 "github.com/conflicthq/scuttlebot/internal/ergo"
20 "github.com/conflicthq/scuttlebot/internal/mcp"
21 "github.com/conflicthq/scuttlebot/internal/registry"
22 )
@@ -96,13 +97,42 @@
96
97 // Shared API token — used by both REST and MCP servers.
98 apiToken := mustGenToken()
99 log.Info("api token", "token", apiToken) // printed once on startup — user copies this
100 tokens := []string{apiToken}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
102 // Start HTTP REST API server.
103 apiSrv := api.New(reg, tokens, log)
104 httpServer := &http.Server{
105 Addr: cfg.APIAddr,
106 Handler: apiSrv.Handler(),
107 }
108 go func() {
109
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -13,10 +13,11 @@
13 "path/filepath"
14 "syscall"
15 "time"
16
17 "github.com/conflicthq/scuttlebot/internal/api"
18 "github.com/conflicthq/scuttlebot/internal/bots/bridge"
19 "github.com/conflicthq/scuttlebot/internal/config"
20 "github.com/conflicthq/scuttlebot/internal/ergo"
21 "github.com/conflicthq/scuttlebot/internal/mcp"
22 "github.com/conflicthq/scuttlebot/internal/registry"
23 )
@@ -96,13 +97,42 @@
97
98 // Shared API token — used by both REST and MCP servers.
99 apiToken := mustGenToken()
100 log.Info("api token", "token", apiToken) // printed once on startup — user copies this
101 tokens := []string{apiToken}
102
103 // Start bridge bot (powers the web chat UI).
104 var bridgeBot *bridge.Bot
105 if cfg.Bridge.Enabled {
106 if cfg.Bridge.Password == "" {
107 cfg.Bridge.Password = mustGenToken()
108 }
109 // Ensure the bridge's NickServ account exists with the current password.
110 if err := manager.API().RegisterAccount(cfg.Bridge.Nick, cfg.Bridge.Password); err != nil {
111 // Account exists from a previous run — update the password so it matches.
112 if err2 := manager.API().ChangePassword(cfg.Bridge.Nick, cfg.Bridge.Password); err2 != nil {
113 log.Error("bridge account setup failed", "err", err2)
114 os.Exit(1)
115 }
116 }
117 bridgeBot = bridge.New(
118 cfg.Ergo.IRCAddr,
119 cfg.Bridge.Nick,
120 cfg.Bridge.Password,
121 cfg.Bridge.Channels,
122 cfg.Bridge.BufferSize,
123 log,
124 )
125 go func() {
126 if err := bridgeBot.Start(ctx); err != nil {
127 log.Error("bridge bot error", "err", err)
128 }
129 }()
130 }
131
132 // Start HTTP REST API server.
133 apiSrv := api.New(reg, tokens, bridgeBot, log)
134 httpServer := &http.Server{
135 Addr: cfg.APIAddr,
136 Handler: apiSrv.Handler(),
137 }
138 go func() {
139
--- internal/api/api_test.go
+++ internal/api/api_test.go
@@ -50,11 +50,11 @@
5050
const testToken = "test-api-token-abc123"
5151
5252
func newTestServer(t *testing.T) *httptest.Server {
5353
t.Helper()
5454
reg := registry.New(newMock(), []byte("test-signing-key"))
55
- srv := api.New(reg, []string{testToken}, testLog)
55
+ srv := api.New(reg, []string{testToken}, nil, testLog)
5656
return httptest.NewServer(srv.Handler())
5757
}
5858
5959
func authHeader() http.Header {
6060
h := http.Header{}
6161
6262
ADDED internal/api/chat.go
--- internal/api/api_test.go
+++ internal/api/api_test.go
@@ -50,11 +50,11 @@
50 const testToken = "test-api-token-abc123"
51
52 func newTestServer(t *testing.T) *httptest.Server {
53 t.Helper()
54 reg := registry.New(newMock(), []byte("test-signing-key"))
55 srv := api.New(reg, []string{testToken}, testLog)
56 return httptest.NewServer(srv.Handler())
57 }
58
59 func authHeader() http.Header {
60 h := http.Header{}
61
62 DDED internal/api/chat.go
--- internal/api/api_test.go
+++ internal/api/api_test.go
@@ -50,11 +50,11 @@
50 const testToken = "test-api-token-abc123"
51
52 func newTestServer(t *testing.T) *httptest.Server {
53 t.Helper()
54 reg := registry.New(newMock(), []byte("test-signing-key"))
55 srv := api.New(reg, []string{testToken}, nil, testLog)
56 return httptest.NewServer(srv.Handler())
57 }
58
59 func authHeader() http.Header {
60 h := http.Header{}
61
62 DDED internal/api/chat.go
--- a/internal/api/chat.go
+++ b/internal/api/chat.go
@@ -0,0 +1,47 @@
1
+Messagesannel string)
2
+ L []bridge.Message
3
+ Subscribe(channel string) (<-chan bridge.Message, func())
4
+ Send(ctx context.Context, channel, text, tats() bridge.Stats
5
+ TouchUser(channel, nick string)
6
+ Users(channel string) []string
7
+ontext"
8
+ "encoding/json"
9
+ package apileListChannelsl)
10
+ msgs := s.bridge.Messages(cage{}
11
+ }
12
+ // Filter by ?since=<RFC3port (
13
+ "context"
14
+ "encoding/json"
15
+ "fmt"
16
+ "net/http"
17
+ "time"
18
+
19
+ "github.com/conflicthq/scuttlebot/ string)
20
+ LeaveChstring)
21
+ Users(channel strring) (<-chan bridge.Message, func())
22
+ Send(ctx context.Context, channel, text, senderNick string) error
23
+ SendWithMeta(ctx context.Context, channel, texbridge.Stats
24
+ TouchUser(channel, nick string)
25
+ Users(channel string) []string
26
+ UsersWithModes(channel string) []bridge.UserInfo
27
+ ChannelMo}
28
+ }
29
+ writeJSON(w, hpackage api
30
+
31
+istring{}
32
+ } }
33
+ }
34
+onflicthq/scuttlebot/internal/auth"
35
+ "github.com/conflicthq/scuttlebot/internal/bots/bridge"
36
+)
37
+
38
+// chatBridge is the interface the API layer requires from the bridge bot.
39
+type chatBridge interface {
40
+ Channels() []string
41
+ JoinChannel(channel string)
42
+ LeaveChannel(channel string)
43
+ Messages(channel string) []bridge.Message
44
+ Subscribe(channel string) (<-chan bridge.Message, func())
45
+ Send(ctx context.Context, channel, text, senderNick string) error
46
+ SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *bridge.Meta) error
47
+ Stat
--- a/internal/api/chat.go
+++ b/internal/api/chat.go
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/api/chat.go
+++ b/internal/api/chat.go
@@ -0,0 +1,47 @@
1 Messagesannel string)
2 L []bridge.Message
3 Subscribe(channel string) (<-chan bridge.Message, func())
4 Send(ctx context.Context, channel, text, tats() bridge.Stats
5 TouchUser(channel, nick string)
6 Users(channel string) []string
7 ontext"
8 "encoding/json"
9 package apileListChannelsl)
10 msgs := s.bridge.Messages(cage{}
11 }
12 // Filter by ?since=<RFC3port (
13 "context"
14 "encoding/json"
15 "fmt"
16 "net/http"
17 "time"
18
19 "github.com/conflicthq/scuttlebot/ string)
20 LeaveChstring)
21 Users(channel strring) (<-chan bridge.Message, func())
22 Send(ctx context.Context, channel, text, senderNick string) error
23 SendWithMeta(ctx context.Context, channel, texbridge.Stats
24 TouchUser(channel, nick string)
25 Users(channel string) []string
26 UsersWithModes(channel string) []bridge.UserInfo
27 ChannelMo}
28 }
29 writeJSON(w, hpackage api
30
31 istring{}
32 } }
33 }
34 onflicthq/scuttlebot/internal/auth"
35 "github.com/conflicthq/scuttlebot/internal/bots/bridge"
36 )
37
38 // chatBridge is the interface the API layer requires from the bridge bot.
39 type chatBridge interface {
40 Channels() []string
41 JoinChannel(channel string)
42 LeaveChannel(channel string)
43 Messages(channel string) []bridge.Message
44 Subscribe(channel string) (<-chan bridge.Message, func())
45 Send(ctx context.Context, channel, text, senderNick string) error
46 SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *bridge.Meta) error
47 Stat
--- internal/api/server.go
+++ internal/api/server.go
@@ -1,10 +1,10 @@
11
// Package api implements the scuttlebot HTTP management API.
22
//
3
-// All endpoints require a valid Bearer token. No anonymous access.
4
-// Agents and external systems use this API to register, manage credentials,
5
-// and query fleet status.
3
+// /v1/ endpoints require a valid Bearer token.
4
+// /ui/ is served unauthenticated (static web UI).
5
+// /v1/channels/{channel}/stream uses ?token= query param (EventSource limitation).
66
package api
77
88
import (
99
"log/slog"
1010
"net/http"
@@ -15,22 +15,24 @@
1515
// Server is the scuttlebot HTTP API server.
1616
type Server struct {
1717
registry *registry.Registry
1818
tokens map[string]struct{}
1919
log *slog.Logger
20
+ bridge chatBridge // nil if bridge is disabled
2021
}
2122
22
-// New creates a new API Server.
23
-func New(reg *registry.Registry, tokens []string, log *slog.Logger) *Server {
23
+// New creates a new API Server. Pass nil for b to disable the chat bridge.
24
+func New(reg *registry.Registry, tokens []string, b chatBridge, log *slog.Logger) *Server {
2425
tokenSet := make(map[string]struct{}, len(tokens))
2526
for _, t := range tokens {
2627
tokenSet[t] = struct{}{}
2728
}
2829
return &Server{
2930
registry: reg,
3031
tokens: tokenSet,
3132
log: log,
33
+ bridge: b,
3234
}
3335
}
3436
3537
// Handler returns the HTTP handler with all routes registered.
3638
// /v1/ routes require a valid Bearer token. /ui/ is served unauthenticated.
@@ -40,15 +42,26 @@
4042
apiMux.HandleFunc("GET /v1/agents", s.handleListAgents)
4143
apiMux.HandleFunc("GET /v1/agents/{nick}", s.handleGetAgent)
4244
apiMux.HandleFunc("POST /v1/agents/register", s.handleRegister)
4345
apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.handleRotate)
4446
apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.handleRevoke)
47
+ if s.bridge != nil {
48
+ apiMux.HandleFunc("GET /v1/channels", s.handleListChannels)
49
+ apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.handleJoinChannel)
50
+ apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.handleChannelMessages)
51
+ apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.handleSendMessage)
52
+ }
4553
4654
outer := http.NewServeMux()
4755
outer.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
4856
http.Redirect(w, r, "/ui/", http.StatusFound)
4957
})
5058
outer.Handle("/ui/", s.uiFileServer())
59
+ // SSE stream uses ?token= auth (EventSource can't send headers), registered
60
+ // on outer so it bypasses the Bearer-token authMiddleware on /v1/.
61
+ if s.bridge != nil {
62
+ outer.HandleFunc("GET /v1/channels/{channel}/stream", s.handleChannelStream)
63
+ }
5164
outer.Handle("/v1/", s.authMiddleware(apiMux))
5265
5366
return outer
5467
}
5568
--- internal/api/server.go
+++ internal/api/server.go
@@ -1,10 +1,10 @@
1 // Package api implements the scuttlebot HTTP management API.
2 //
3 // All endpoints require a valid Bearer token. No anonymous access.
4 // Agents and external systems use this API to register, manage credentials,
5 // and query fleet status.
6 package api
7
8 import (
9 "log/slog"
10 "net/http"
@@ -15,22 +15,24 @@
15 // Server is the scuttlebot HTTP API server.
16 type Server struct {
17 registry *registry.Registry
18 tokens map[string]struct{}
19 log *slog.Logger
 
20 }
21
22 // New creates a new API Server.
23 func New(reg *registry.Registry, tokens []string, log *slog.Logger) *Server {
24 tokenSet := make(map[string]struct{}, len(tokens))
25 for _, t := range tokens {
26 tokenSet[t] = struct{}{}
27 }
28 return &Server{
29 registry: reg,
30 tokens: tokenSet,
31 log: log,
 
32 }
33 }
34
35 // Handler returns the HTTP handler with all routes registered.
36 // /v1/ routes require a valid Bearer token. /ui/ is served unauthenticated.
@@ -40,15 +42,26 @@
40 apiMux.HandleFunc("GET /v1/agents", s.handleListAgents)
41 apiMux.HandleFunc("GET /v1/agents/{nick}", s.handleGetAgent)
42 apiMux.HandleFunc("POST /v1/agents/register", s.handleRegister)
43 apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.handleRotate)
44 apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.handleRevoke)
 
 
 
 
 
 
45
46 outer := http.NewServeMux()
47 outer.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
48 http.Redirect(w, r, "/ui/", http.StatusFound)
49 })
50 outer.Handle("/ui/", s.uiFileServer())
 
 
 
 
 
51 outer.Handle("/v1/", s.authMiddleware(apiMux))
52
53 return outer
54 }
55
--- internal/api/server.go
+++ internal/api/server.go
@@ -1,10 +1,10 @@
1 // Package api implements the scuttlebot HTTP management API.
2 //
3 // /v1/ endpoints require a valid Bearer token.
4 // /ui/ is served unauthenticated (static web UI).
5 // /v1/channels/{channel}/stream uses ?token= query param (EventSource limitation).
6 package api
7
8 import (
9 "log/slog"
10 "net/http"
@@ -15,22 +15,24 @@
15 // Server is the scuttlebot HTTP API server.
16 type Server struct {
17 registry *registry.Registry
18 tokens map[string]struct{}
19 log *slog.Logger
20 bridge chatBridge // nil if bridge is disabled
21 }
22
23 // New creates a new API Server. Pass nil for b to disable the chat bridge.
24 func New(reg *registry.Registry, tokens []string, b chatBridge, log *slog.Logger) *Server {
25 tokenSet := make(map[string]struct{}, len(tokens))
26 for _, t := range tokens {
27 tokenSet[t] = struct{}{}
28 }
29 return &Server{
30 registry: reg,
31 tokens: tokenSet,
32 log: log,
33 bridge: b,
34 }
35 }
36
37 // Handler returns the HTTP handler with all routes registered.
38 // /v1/ routes require a valid Bearer token. /ui/ is served unauthenticated.
@@ -40,15 +42,26 @@
42 apiMux.HandleFunc("GET /v1/agents", s.handleListAgents)
43 apiMux.HandleFunc("GET /v1/agents/{nick}", s.handleGetAgent)
44 apiMux.HandleFunc("POST /v1/agents/register", s.handleRegister)
45 apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.handleRotate)
46 apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.handleRevoke)
47 if s.bridge != nil {
48 apiMux.HandleFunc("GET /v1/channels", s.handleListChannels)
49 apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.handleJoinChannel)
50 apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.handleChannelMessages)
51 apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.handleSendMessage)
52 }
53
54 outer := http.NewServeMux()
55 outer.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
56 http.Redirect(w, r, "/ui/", http.StatusFound)
57 })
58 outer.Handle("/ui/", s.uiFileServer())
59 // SSE stream uses ?token= auth (EventSource can't send headers), registered
60 // on outer so it bypasses the Bearer-token authMiddleware on /v1/.
61 if s.bridge != nil {
62 outer.HandleFunc("GET /v1/channels/{channel}/stream", s.handleChannelStream)
63 }
64 outer.Handle("/v1/", s.authMiddleware(apiMux))
65
66 return outer
67 }
68
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -45,10 +45,18 @@
4545
.tag.type-worker { background: #1f6feb22; border-color: #1f6feb44; color: #79c0ff; }
4646
.tag.type-observer { background: #21262d; border-color: #30363d; color: #8b949e; }
4747
.tag.revoked { background: #f8514922; border-color: #f8514944; color: #ff7b72; }
4848
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
4949
.empty { color: #8b949e; font-size: 13px; text-align: center; padding: 24px; }
50
+ .chan-item { padding: 8px 12px; font-size: 13px; cursor: pointer; color: #8b949e; border-bottom: 1px solid #21262d; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
51
+ .chan-item:hover { background: #1c2128; color: #e6edf3; }
52
+ .chan-item.active { background: #1f6feb22; color: #58a6ff; border-left: 2px solid #58a6ff; }
53
+ .msg-row { display: flex; gap: 8px; font-size: 13px; line-height: 1.6; padding: 1px 0; }
54
+ .msg-time { color: #8b949e; font-size: 11px; flex-shrink: 0; padding-top: 3px; min-width: 44px; }
55
+ .msg-nick { color: #58a6ff; font-weight: 600; flex-shrink: 0; min-width: 80px; text-align: right; }
56
+ .msg-nick.bridge-nick { color: #3fb950; }
57
+ .msg-text { color: #e6edf3; word-break: break-word; }
5058
form { display: flex; flex-direction: column; gap: 14px; }
5159
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
5260
label { display: block; font-size: 12px; color: #8b949e; margin-bottom: 4px; }
5361
input, select, textarea { width: 100%; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 8px 10px; font-size: 13px; font-family: inherit; color: #e6edf3; outline: none; transition: border-color 0.1s; }
5462
input:focus, select:focus, textarea:focus { border-color: #58a6ff; }
@@ -157,10 +165,39 @@
157165
<button type="submit" class="primary">register</button>
158166
</div>
159167
</form>
160168
</div>
161169
</div>
170
+
171
+ <!-- Chat -->
172
+ <div class="card" id="chat-card" style="display:none">
173
+ <div class="card-header">
174
+ <h2>chat</h2>
175
+ <span class="badge" id="chat-channel-badge" style="display:none"></span>
176
+ <div class="spacer"></div>
177
+ <span id="chat-stream-status" style="font-size:11px;color:#8b949e"></span>
178
+ </div>
179
+ <div style="display:flex;height:440px">
180
+ <div id="chat-channel-list" style="width:140px;border-right:1px solid #30363d;overflow-y:auto;flex-shrink:0;padding:8px 0;display:flex;flex-direction:column">
181
+ <div style="padding:6px 12px;font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#8b949e">channels</div>
182
+ <div style="padding:6px 8px;border-bottom:1px solid #21262d;display:flex;gap:4px">
183
+ <input type="text" id="join-channel-input" placeholder="#channel" style="flex:1;font-size:11px;padding:3px 6px" autocomplete="off">
184
+ <button class="small" onclick="joinChannel()" style="padding:3px 6px;font-size:11px">+</button>
185
+ </div>
186
+ </div>
187
+ <div style="display:flex;flex-direction:column;flex:1;min-width:0">
188
+ <div id="chat-messages" style="flex:1;overflow-y:auto;padding:12px 16px;display:flex;flex-direction:column;gap:2px">
189
+ <div class="empty" id="chat-placeholder">select a channel to view messages</div>
190
+ </div>
191
+ <div style="padding:10px 14px;border-top:1px solid #30363d;display:flex;gap:8px;align-items:center">
192
+ <input type="text" id="chat-nick-input" placeholder="your nick" style="width:110px;flex-shrink:0" autocomplete="off">
193
+ <input type="text" id="chat-text-input" placeholder="type a message…" style="flex:1" autocomplete="off">
194
+ <button class="primary small" id="chat-send-btn" onclick="sendChatMessage()">send</button>
195
+ </div>
196
+ </div>
197
+ </div>
198
+ </div>
162199
</main>
163200
164201
<!-- Token modal -->
165202
<div class="modal-overlay" id="token-modal">
166203
<div class="modal">
@@ -375,14 +412,136 @@
375412
function fmtTime(iso) {
376413
if (!iso) return '—';
377414
const d = new Date(iso);
378415
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
379416
}
417
+
418
+// --- chat ---
419
+let chatChannel = null;
420
+let chatSSE = null;
421
+
422
+async function loadChannels() {
423
+ if (!getToken()) return;
424
+ try {
425
+ const data = await api('GET', '/v1/channels');
426
+ renderChannelList(data.channels || []);
427
+ document.getElementById('chat-card').style.display = '';
428
+ } catch(e) {
429
+ // bridge disabled or error — keep chat card hidden
430
+ }
431
+}
432
+
433
+function renderChannelList(channels) {
434
+ const list = document.getElementById('chat-channel-list');
435
+ // Remove old channel items (keep header div and join input div)
436
+ Array.from(list.querySelectorAll('.chan-item')).forEach(el => el.remove());
437
+ channels.sort().forEach(ch => {
438
+ const el = document.createElement('div');
439
+ el.className = 'chan-item' + (ch === chatChannel ? ' active' : '');
440
+ el.textContent = ch;
441
+ el.onclick = () => selectChannel(ch);
442
+ list.appendChild(el);
443
+ });
444
+}
445
+
446
+async function joinChannel() {
447
+ let ch = document.getElementById('join-channel-input').value.trim();
448
+ if (!ch) return;
449
+ if (!ch.startsWith('#')) ch = '#' + ch;
450
+ const slug = ch.replace(/^#/, '');
451
+ try {
452
+ await api('POST', `/v1/channels/${slug}/join`);
453
+ document.getElementById('join-channel-input').value = '';
454
+ await loadChannels();
455
+ selectChannel(ch);
456
+ } catch(e) {
457
+ alert('Join failed: ' + e.message);
458
+ }
459
+}
460
+
461
+document.getElementById('join-channel-input').addEventListener('keydown', e => {
462
+ if (e.key === 'Enter') joinChannel();
463
+});
464
+
465
+async function selectChannel(ch) {
466
+ chatChannel = ch;
467
+ document.getElementById('chat-channel-badge').textContent = ch;
468
+ document.getElementById('chat-channel-badge').style.display = '';
469
+ document.getElementById('chat-placeholder').style.display = 'none';
470
+ document.querySelectorAll('.chan-item').forEach(el => {
471
+ el.classList.toggle('active', el.textContent === ch);
472
+ });
473
+
474
+ const area = document.getElementById('chat-messages');
475
+ // clear previous messages (keep placeholder)
476
+ Array.from(area.children).forEach(el => { if (!el.id) el.remove(); });
477
+
478
+ try {
479
+ const slug = ch.replace(/^#/, '');
480
+ const data = await api('GET', `/v1/channels/${slug}/messages`);
481
+ (data.messages || []).forEach(appendMessage);
482
+ area.scrollTop = area.scrollHeight;
483
+ } catch(e) {}
484
+
485
+ if (chatSSE) { chatSSE.close(); chatSSE = null; }
486
+ const slug = ch.replace(/^#/, '');
487
+ const url = `/v1/channels/${slug}/stream?token=${encodeURIComponent(getToken())}`;
488
+ const es = new EventSource(url);
489
+ chatSSE = es;
490
+ const status = document.getElementById('chat-stream-status');
491
+ es.onopen = () => { status.textContent = '● live'; status.style.color = '#3fb950'; };
492
+ es.onmessage = (e) => {
493
+ try {
494
+ const msg = JSON.parse(e.data);
495
+ appendMessage(msg);
496
+ area.scrollTop = area.scrollHeight;
497
+ } catch(_) {}
498
+ };
499
+ es.onerror = () => { status.textContent = '○ reconnecting…'; status.style.color = '#8b949e'; };
500
+}
501
+
502
+function appendMessage(msg) {
503
+ const area = document.getElementById('chat-messages');
504
+ const t = new Date(msg.at);
505
+ const timeStr = t.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
506
+ const isBridge = msg.nick === 'bridge';
507
+ const row = document.createElement('div');
508
+ row.className = 'msg-row';
509
+ row.innerHTML = `<span class="msg-time">${esc(timeStr)}</span>`
510
+ + `<span class="msg-nick${isBridge ? ' bridge-nick' : ''}">${esc(msg.nick)}</span>`
511
+ + `<span class="msg-text">${esc(msg.text)}</span>`;
512
+ area.appendChild(row);
513
+}
514
+
515
+async function sendChatMessage() {
516
+ if (!chatChannel) return;
517
+ const input = document.getElementById('chat-text-input');
518
+ const nick = document.getElementById('chat-nick-input').value.trim() || 'web';
519
+ const text = input.value.trim();
520
+ if (!text) return;
521
+ input.disabled = true;
522
+ document.getElementById('chat-send-btn').disabled = true;
523
+ try {
524
+ const slug = chatChannel.replace(/^#/, '');
525
+ await api('POST', `/v1/channels/${slug}/messages`, { text, nick });
526
+ input.value = '';
527
+ } catch(e) {
528
+ alert('Send failed: ' + e.message);
529
+ } finally {
530
+ input.disabled = false;
531
+ document.getElementById('chat-send-btn').disabled = false;
532
+ input.focus();
533
+ }
534
+}
535
+
536
+document.getElementById('chat-text-input').addEventListener('keydown', e => {
537
+ if (e.key === 'Enter') sendChatMessage();
538
+});
380539
381540
// --- init ---
382
-function loadAll() { loadStatus(); loadAgents(); }
541
+function loadAll() { loadStatus(); loadAgents(); loadChannels(); }
383542
updateTokenDisplay();
384543
loadAll();
385544
setInterval(loadStatus, 15000);
386545
</script>
387546
</body>
388547
</html>
389548
390549
ADDED internal/bots/bridge/bridge.go
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -45,10 +45,18 @@
45 .tag.type-worker { background: #1f6feb22; border-color: #1f6feb44; color: #79c0ff; }
46 .tag.type-observer { background: #21262d; border-color: #30363d; color: #8b949e; }
47 .tag.revoked { background: #f8514922; border-color: #f8514944; color: #ff7b72; }
48 .actions { display: flex; gap: 6px; flex-wrap: wrap; }
49 .empty { color: #8b949e; font-size: 13px; text-align: center; padding: 24px; }
 
 
 
 
 
 
 
 
50 form { display: flex; flex-direction: column; gap: 14px; }
51 .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
52 label { display: block; font-size: 12px; color: #8b949e; margin-bottom: 4px; }
53 input, select, textarea { width: 100%; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 8px 10px; font-size: 13px; font-family: inherit; color: #e6edf3; outline: none; transition: border-color 0.1s; }
54 input:focus, select:focus, textarea:focus { border-color: #58a6ff; }
@@ -157,10 +165,39 @@
157 <button type="submit" class="primary">register</button>
158 </div>
159 </form>
160 </div>
161 </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162 </main>
163
164 <!-- Token modal -->
165 <div class="modal-overlay" id="token-modal">
166 <div class="modal">
@@ -375,14 +412,136 @@
375 function fmtTime(iso) {
376 if (!iso) return '—';
377 const d = new Date(iso);
378 return d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
379 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
381 // --- init ---
382 function loadAll() { loadStatus(); loadAgents(); }
383 updateTokenDisplay();
384 loadAll();
385 setInterval(loadStatus, 15000);
386 </script>
387 </body>
388 </html>
389
390 DDED internal/bots/bridge/bridge.go
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -45,10 +45,18 @@
45 .tag.type-worker { background: #1f6feb22; border-color: #1f6feb44; color: #79c0ff; }
46 .tag.type-observer { background: #21262d; border-color: #30363d; color: #8b949e; }
47 .tag.revoked { background: #f8514922; border-color: #f8514944; color: #ff7b72; }
48 .actions { display: flex; gap: 6px; flex-wrap: wrap; }
49 .empty { color: #8b949e; font-size: 13px; text-align: center; padding: 24px; }
50 .chan-item { padding: 8px 12px; font-size: 13px; cursor: pointer; color: #8b949e; border-bottom: 1px solid #21262d; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
51 .chan-item:hover { background: #1c2128; color: #e6edf3; }
52 .chan-item.active { background: #1f6feb22; color: #58a6ff; border-left: 2px solid #58a6ff; }
53 .msg-row { display: flex; gap: 8px; font-size: 13px; line-height: 1.6; padding: 1px 0; }
54 .msg-time { color: #8b949e; font-size: 11px; flex-shrink: 0; padding-top: 3px; min-width: 44px; }
55 .msg-nick { color: #58a6ff; font-weight: 600; flex-shrink: 0; min-width: 80px; text-align: right; }
56 .msg-nick.bridge-nick { color: #3fb950; }
57 .msg-text { color: #e6edf3; word-break: break-word; }
58 form { display: flex; flex-direction: column; gap: 14px; }
59 .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
60 label { display: block; font-size: 12px; color: #8b949e; margin-bottom: 4px; }
61 input, select, textarea { width: 100%; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 8px 10px; font-size: 13px; font-family: inherit; color: #e6edf3; outline: none; transition: border-color 0.1s; }
62 input:focus, select:focus, textarea:focus { border-color: #58a6ff; }
@@ -157,10 +165,39 @@
165 <button type="submit" class="primary">register</button>
166 </div>
167 </form>
168 </div>
169 </div>
170
171 <!-- Chat -->
172 <div class="card" id="chat-card" style="display:none">
173 <div class="card-header">
174 <h2>chat</h2>
175 <span class="badge" id="chat-channel-badge" style="display:none"></span>
176 <div class="spacer"></div>
177 <span id="chat-stream-status" style="font-size:11px;color:#8b949e"></span>
178 </div>
179 <div style="display:flex;height:440px">
180 <div id="chat-channel-list" style="width:140px;border-right:1px solid #30363d;overflow-y:auto;flex-shrink:0;padding:8px 0;display:flex;flex-direction:column">
181 <div style="padding:6px 12px;font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#8b949e">channels</div>
182 <div style="padding:6px 8px;border-bottom:1px solid #21262d;display:flex;gap:4px">
183 <input type="text" id="join-channel-input" placeholder="#channel" style="flex:1;font-size:11px;padding:3px 6px" autocomplete="off">
184 <button class="small" onclick="joinChannel()" style="padding:3px 6px;font-size:11px">+</button>
185 </div>
186 </div>
187 <div style="display:flex;flex-direction:column;flex:1;min-width:0">
188 <div id="chat-messages" style="flex:1;overflow-y:auto;padding:12px 16px;display:flex;flex-direction:column;gap:2px">
189 <div class="empty" id="chat-placeholder">select a channel to view messages</div>
190 </div>
191 <div style="padding:10px 14px;border-top:1px solid #30363d;display:flex;gap:8px;align-items:center">
192 <input type="text" id="chat-nick-input" placeholder="your nick" style="width:110px;flex-shrink:0" autocomplete="off">
193 <input type="text" id="chat-text-input" placeholder="type a message…" style="flex:1" autocomplete="off">
194 <button class="primary small" id="chat-send-btn" onclick="sendChatMessage()">send</button>
195 </div>
196 </div>
197 </div>
198 </div>
199 </main>
200
201 <!-- Token modal -->
202 <div class="modal-overlay" id="token-modal">
203 <div class="modal">
@@ -375,14 +412,136 @@
412 function fmtTime(iso) {
413 if (!iso) return '—';
414 const d = new Date(iso);
415 return d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
416 }
417
418 // --- chat ---
419 let chatChannel = null;
420 let chatSSE = null;
421
422 async function loadChannels() {
423 if (!getToken()) return;
424 try {
425 const data = await api('GET', '/v1/channels');
426 renderChannelList(data.channels || []);
427 document.getElementById('chat-card').style.display = '';
428 } catch(e) {
429 // bridge disabled or error — keep chat card hidden
430 }
431 }
432
433 function renderChannelList(channels) {
434 const list = document.getElementById('chat-channel-list');
435 // Remove old channel items (keep header div and join input div)
436 Array.from(list.querySelectorAll('.chan-item')).forEach(el => el.remove());
437 channels.sort().forEach(ch => {
438 const el = document.createElement('div');
439 el.className = 'chan-item' + (ch === chatChannel ? ' active' : '');
440 el.textContent = ch;
441 el.onclick = () => selectChannel(ch);
442 list.appendChild(el);
443 });
444 }
445
446 async function joinChannel() {
447 let ch = document.getElementById('join-channel-input').value.trim();
448 if (!ch) return;
449 if (!ch.startsWith('#')) ch = '#' + ch;
450 const slug = ch.replace(/^#/, '');
451 try {
452 await api('POST', `/v1/channels/${slug}/join`);
453 document.getElementById('join-channel-input').value = '';
454 await loadChannels();
455 selectChannel(ch);
456 } catch(e) {
457 alert('Join failed: ' + e.message);
458 }
459 }
460
461 document.getElementById('join-channel-input').addEventListener('keydown', e => {
462 if (e.key === 'Enter') joinChannel();
463 });
464
465 async function selectChannel(ch) {
466 chatChannel = ch;
467 document.getElementById('chat-channel-badge').textContent = ch;
468 document.getElementById('chat-channel-badge').style.display = '';
469 document.getElementById('chat-placeholder').style.display = 'none';
470 document.querySelectorAll('.chan-item').forEach(el => {
471 el.classList.toggle('active', el.textContent === ch);
472 });
473
474 const area = document.getElementById('chat-messages');
475 // clear previous messages (keep placeholder)
476 Array.from(area.children).forEach(el => { if (!el.id) el.remove(); });
477
478 try {
479 const slug = ch.replace(/^#/, '');
480 const data = await api('GET', `/v1/channels/${slug}/messages`);
481 (data.messages || []).forEach(appendMessage);
482 area.scrollTop = area.scrollHeight;
483 } catch(e) {}
484
485 if (chatSSE) { chatSSE.close(); chatSSE = null; }
486 const slug = ch.replace(/^#/, '');
487 const url = `/v1/channels/${slug}/stream?token=${encodeURIComponent(getToken())}`;
488 const es = new EventSource(url);
489 chatSSE = es;
490 const status = document.getElementById('chat-stream-status');
491 es.onopen = () => { status.textContent = '● live'; status.style.color = '#3fb950'; };
492 es.onmessage = (e) => {
493 try {
494 const msg = JSON.parse(e.data);
495 appendMessage(msg);
496 area.scrollTop = area.scrollHeight;
497 } catch(_) {}
498 };
499 es.onerror = () => { status.textContent = '○ reconnecting…'; status.style.color = '#8b949e'; };
500 }
501
502 function appendMessage(msg) {
503 const area = document.getElementById('chat-messages');
504 const t = new Date(msg.at);
505 const timeStr = t.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
506 const isBridge = msg.nick === 'bridge';
507 const row = document.createElement('div');
508 row.className = 'msg-row';
509 row.innerHTML = `<span class="msg-time">${esc(timeStr)}</span>`
510 + `<span class="msg-nick${isBridge ? ' bridge-nick' : ''}">${esc(msg.nick)}</span>`
511 + `<span class="msg-text">${esc(msg.text)}</span>`;
512 area.appendChild(row);
513 }
514
515 async function sendChatMessage() {
516 if (!chatChannel) return;
517 const input = document.getElementById('chat-text-input');
518 const nick = document.getElementById('chat-nick-input').value.trim() || 'web';
519 const text = input.value.trim();
520 if (!text) return;
521 input.disabled = true;
522 document.getElementById('chat-send-btn').disabled = true;
523 try {
524 const slug = chatChannel.replace(/^#/, '');
525 await api('POST', `/v1/channels/${slug}/messages`, { text, nick });
526 input.value = '';
527 } catch(e) {
528 alert('Send failed: ' + e.message);
529 } finally {
530 input.disabled = false;
531 document.getElementById('chat-send-btn').disabled = false;
532 input.focus();
533 }
534 }
535
536 document.getElementById('chat-text-input').addEventListener('keydown', e => {
537 if (e.key === 'Enter') sendChatMessage();
538 });
539
540 // --- init ---
541 function loadAll() { loadStatus(); loadAgents(); loadChannels(); }
542 updateTokenDisplay();
543 loadAll();
544 setInterval(loadStatus, 15000);
545 </script>
546 </body>
547 </html>
548
549 DDED internal/bots/bridge/bridge.go
--- a/internal/bots/bridge/bridge.go
+++ b/internal/bots/bridge/bridge.go
@@ -0,0 +1,4 @@
1
+//// Track web sender// Buffer the outgoing message immediately (server won't echo it back).
2
+ // Use senderNick so the web UI shows who actua//// Track web sender// Buffer the outgoing message immediately (server wooutgoing message immediately (server won't echo it back).
3
+ // Use senderNick so the web UI shows who actuab.nick,
4
+ Text:
--- a/internal/bots/bridge/bridge.go
+++ b/internal/bots/bridge/bridge.go
@@ -0,0 +1,4 @@
 
 
 
 
--- a/internal/bots/bridge/bridge.go
+++ b/internal/bots/bridge/bridge.go
@@ -0,0 +1,4 @@
1 //// Track web sender// Buffer the outgoing message immediately (server won't echo it back).
2 // Use senderNick so the web UI shows who actua//// Track web sender// Buffer the outgoing message immediately (server wooutgoing message immediately (server won't echo it back).
3 // Use senderNick so the web UI shows who actuab.nick,
4 Text:
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -13,10 +13,11 @@
1313
1414
import (
1515
"context"
1616
"fmt"
1717
"log/slog"
18
+ "net"
1819
"strconv"
1920
"strings"
2021
"sync"
2122
"time"
2223
@@ -278,12 +279,15 @@
278279
channel, count, summary)
279280
}
280281
}
281282
282283
func splitHostPort(addr string) (string, int, error) {
283
- var host string
284
- var port int
285
- if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port); err != nil {
284
+ host, portStr, err := net.SplitHostPort(addr)
285
+ if err != nil {
286286
return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
287287
}
288
+ port, err := strconv.Atoi(portStr)
289
+ if err != nil {
290
+ return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
291
+ }
288292
return host, port, nil
289293
}
290294
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -13,10 +13,11 @@
13
14 import (
15 "context"
16 "fmt"
17 "log/slog"
 
18 "strconv"
19 "strings"
20 "sync"
21 "time"
22
@@ -278,12 +279,15 @@
278 channel, count, summary)
279 }
280 }
281
282 func splitHostPort(addr string) (string, int, error) {
283 var host string
284 var port int
285 if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port); err != nil {
286 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
287 }
 
 
 
 
288 return host, port, nil
289 }
290
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -13,10 +13,11 @@
13
14 import (
15 "context"
16 "fmt"
17 "log/slog"
18 "net"
19 "strconv"
20 "strings"
21 "sync"
22 "time"
23
@@ -278,12 +279,15 @@
279 channel, count, summary)
280 }
281 }
282
283 func splitHostPort(addr string) (string, int, error) {
284 host, portStr, err := net.SplitHostPort(addr)
285 if err != nil {
 
286 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
287 }
288 port, err := strconv.Atoi(portStr)
289 if err != nil {
290 return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
291 }
292 return host, port, nil
293 }
294
--- internal/config/config.go
+++ internal/config/config.go
@@ -10,10 +10,11 @@
1010
1111
// Config is the top-level scuttlebot configuration.
1212
type Config struct {
1313
Ergo ErgoConfig `yaml:"ergo"`
1414
Datastore DatastoreConfig `yaml:"datastore"`
15
+ Bridge BridgeConfig `yaml:"bridge"`
1516
1617
// APIAddr is the address for scuttlebot's own HTTP management API.
1718
// Default: ":8080"
1819
APIAddr string `yaml:"api_addr"`
1920
@@ -78,10 +79,29 @@
7879
Port int `yaml:"port"`
7980
User string `yaml:"user"`
8081
Password string `yaml:"password"`
8182
Database string `yaml:"database"`
8283
}
84
+
85
+// BridgeConfig configures the IRC bridge bot that powers the web chat UI.
86
+type BridgeConfig struct {
87
+ // Enabled controls whether the bridge bot starts. Default: true.
88
+ Enabled bool `yaml:"enabled"`
89
+
90
+ // Nick is the IRC nick for the bridge bot. Default: "bridge".
91
+ Nick string `yaml:"nick"`
92
+
93
+ // Password is the SASL PLAIN passphrase for the bridge's NickServ account.
94
+ // Auto-generated on first start if empty.
95
+ Password string `yaml:"password"`
96
+
97
+ // Channels is the list of IRC channels the bridge joins on startup.
98
+ Channels []string `yaml:"channels"`
99
+
100
+ // BufferSize is the number of messages to keep per channel. Default: 200.
101
+ BufferSize int `yaml:"buffer_size"`
102
+}
83103
84104
// DatastoreConfig configures scuttlebot's own state store (separate from Ergo).
85105
type DatastoreConfig struct {
86106
// Driver is "sqlite" or "postgres". Default: "sqlite".
87107
Driver string `yaml:"driver"`
@@ -121,10 +141,19 @@
121141
if c.APIAddr == "" {
122142
c.APIAddr = ":8080"
123143
}
124144
if c.MCPAddr == "" {
125145
c.MCPAddr = ":8081"
146
+ }
147
+ if !c.Bridge.Enabled && c.Bridge.Nick == "" {
148
+ c.Bridge.Enabled = true // enabled by default
149
+ }
150
+ if c.Bridge.Nick == "" {
151
+ c.Bridge.Nick = "bridge"
152
+ }
153
+ if c.Bridge.BufferSize == 0 {
154
+ c.Bridge.BufferSize = 200
126155
}
127156
}
128157
129158
func envStr(key string) string { return os.Getenv(key) }
130159
131160
--- internal/config/config.go
+++ internal/config/config.go
@@ -10,10 +10,11 @@
10
11 // Config is the top-level scuttlebot configuration.
12 type Config struct {
13 Ergo ErgoConfig `yaml:"ergo"`
14 Datastore DatastoreConfig `yaml:"datastore"`
 
15
16 // APIAddr is the address for scuttlebot's own HTTP management API.
17 // Default: ":8080"
18 APIAddr string `yaml:"api_addr"`
19
@@ -78,10 +79,29 @@
78 Port int `yaml:"port"`
79 User string `yaml:"user"`
80 Password string `yaml:"password"`
81 Database string `yaml:"database"`
82 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
84 // DatastoreConfig configures scuttlebot's own state store (separate from Ergo).
85 type DatastoreConfig struct {
86 // Driver is "sqlite" or "postgres". Default: "sqlite".
87 Driver string `yaml:"driver"`
@@ -121,10 +141,19 @@
121 if c.APIAddr == "" {
122 c.APIAddr = ":8080"
123 }
124 if c.MCPAddr == "" {
125 c.MCPAddr = ":8081"
 
 
 
 
 
 
 
 
 
126 }
127 }
128
129 func envStr(key string) string { return os.Getenv(key) }
130
131
--- internal/config/config.go
+++ internal/config/config.go
@@ -10,10 +10,11 @@
10
11 // Config is the top-level scuttlebot configuration.
12 type Config struct {
13 Ergo ErgoConfig `yaml:"ergo"`
14 Datastore DatastoreConfig `yaml:"datastore"`
15 Bridge BridgeConfig `yaml:"bridge"`
16
17 // APIAddr is the address for scuttlebot's own HTTP management API.
18 // Default: ":8080"
19 APIAddr string `yaml:"api_addr"`
20
@@ -78,10 +79,29 @@
79 Port int `yaml:"port"`
80 User string `yaml:"user"`
81 Password string `yaml:"password"`
82 Database string `yaml:"database"`
83 }
84
85 // BridgeConfig configures the IRC bridge bot that powers the web chat UI.
86 type BridgeConfig struct {
87 // Enabled controls whether the bridge bot starts. Default: true.
88 Enabled bool `yaml:"enabled"`
89
90 // Nick is the IRC nick for the bridge bot. Default: "bridge".
91 Nick string `yaml:"nick"`
92
93 // Password is the SASL PLAIN passphrase for the bridge's NickServ account.
94 // Auto-generated on first start if empty.
95 Password string `yaml:"password"`
96
97 // Channels is the list of IRC channels the bridge joins on startup.
98 Channels []string `yaml:"channels"`
99
100 // BufferSize is the number of messages to keep per channel. Default: 200.
101 BufferSize int `yaml:"buffer_size"`
102 }
103
104 // DatastoreConfig configures scuttlebot's own state store (separate from Ergo).
105 type DatastoreConfig struct {
106 // Driver is "sqlite" or "postgres". Default: "sqlite".
107 Driver string `yaml:"driver"`
@@ -121,10 +141,19 @@
141 if c.APIAddr == "" {
142 c.APIAddr = ":8080"
143 }
144 if c.MCPAddr == "" {
145 c.MCPAddr = ":8081"
146 }
147 if !c.Bridge.Enabled && c.Bridge.Nick == "" {
148 c.Bridge.Enabled = true // enabled by default
149 }
150 if c.Bridge.Nick == "" {
151 c.Bridge.Nick = "bridge"
152 }
153 if c.Bridge.BufferSize == 0 {
154 c.Bridge.BufferSize = 200
155 }
156 }
157
158 func envStr(key string) string { return os.Getenv(key) }
159
160
--- internal/ergo/ircdconfig_test.go
+++ internal/ergo/ircdconfig_test.go
@@ -30,11 +30,11 @@
3030
want string
3131
}{
3232
{"network name", "name: testnet"},
3333
{"server name", "name: irc.test.local"},
3434
{"irc addr", `"127.0.0.1:6667"`},
35
- {"data dir", "/tmp/ergo/ircd.db"},
35
+ {"data dir", "path: ./ircd.db"},
3636
{"api addr", `"127.0.0.1:8089"`},
3737
{"api token", "test-token-abc123"},
3838
{"api enabled", "enabled: true"},
3939
}
4040
4141
--- internal/ergo/ircdconfig_test.go
+++ internal/ergo/ircdconfig_test.go
@@ -30,11 +30,11 @@
30 want string
31 }{
32 {"network name", "name: testnet"},
33 {"server name", "name: irc.test.local"},
34 {"irc addr", `"127.0.0.1:6667"`},
35 {"data dir", "/tmp/ergo/ircd.db"},
36 {"api addr", `"127.0.0.1:8089"`},
37 {"api token", "test-token-abc123"},
38 {"api enabled", "enabled: true"},
39 }
40
41
--- internal/ergo/ircdconfig_test.go
+++ internal/ergo/ircdconfig_test.go
@@ -30,11 +30,11 @@
30 want string
31 }{
32 {"network name", "name: testnet"},
33 {"server name", "name: irc.test.local"},
34 {"irc addr", `"127.0.0.1:6667"`},
35 {"data dir", "path: ./ircd.db"},
36 {"api addr", `"127.0.0.1:8089"`},
37 {"api token", "test-token-abc123"},
38 {"api enabled", "enabled: true"},
39 }
40
41

Keyboard Shortcuts

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