ScuttleBot

merge main into feature/123-sdk-ircv3-tags

lmata 2026-04-05 16:38 trunk merge
Commit d722e362dfb91a98ab2a8b99ea695d14dab8b26ad8aa524e469b27b204aebf51
44 files changed +30 -7 +20 +99 +3 -1 +2 -1 +125 +11 -3 +5 -2 +43 -4 +7 -4 +2 -1 +7 -5 +2 -2 +38 -2 +71 -5 +78 -60 +4 -2 +377 -21 +288 +1 +117 -7 +117 -7 +1 +52 -13 +1 +78 -10 +1 +46 -1 +1 +1 +10 -1 +13 +3 -1 +7 +9 -8 +8 -1 +10 +65 -11 +183 +7 +56 -8 +4 +121 +65
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -138,18 +138,31 @@
138138
} else if err := reg.SetDataPath(filepath.Join(cfg.Ergo.DataDir, "registry.json")); err != nil {
139139
log.Error("registry load", "err", err)
140140
os.Exit(1)
141141
}
142142
143
- // Shared API token — persisted so the UI token survives restarts.
144
- apiToken, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "api_token"))
143
+ // API key store — per-consumer tokens with scoped permissions.
144
+ apiKeyStore, err := auth.NewAPIKeyStore(filepath.Join(cfg.Ergo.DataDir, "api_keys.json"))
145145
if err != nil {
146
- log.Error("api token", "err", err)
146
+ log.Error("api key store", "err", err)
147147
os.Exit(1)
148148
}
149
- log.Info("api token", "token", apiToken) // printed on every startup
150
- tokens := []string{apiToken}
149
+ // Migrate legacy api_token into key store on first run.
150
+ if apiKeyStore.IsEmpty() {
151
+ apiToken, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "api_token"))
152
+ if err != nil {
153
+ log.Error("api token", "err", err)
154
+ os.Exit(1)
155
+ }
156
+ if _, err := apiKeyStore.Insert("server", apiToken, []auth.Scope{auth.ScopeAdmin}); err != nil {
157
+ log.Error("migrate api token to key store", "err", err)
158
+ os.Exit(1)
159
+ }
160
+ log.Info("migrated api_token to api_keys.json", "token", apiToken)
161
+ } else {
162
+ log.Info("api key store loaded", "keys", len(apiKeyStore.List()))
163
+ }
151164
152165
// Start bridge bot (powers the web chat UI).
153166
var bridgeBot *bridge.Bot
154167
if cfg.Bridge.Enabled {
155168
if cfg.Bridge.Password == "" {
@@ -204,10 +217,11 @@
204217
Name: sc.Name,
205218
Topic: sc.Topic,
206219
Ops: sc.Ops,
207220
Voice: sc.Voice,
208221
Autojoin: sc.Autojoin,
222
+ Modes: sc.Modes,
209223
})
210224
}
211225
if err := topoMgr.Provision(staticChannels); err != nil {
212226
log.Error("topology provision failed", "err", err)
213227
}
@@ -328,19 +342,28 @@
328342
staticChannels := make([]topology.ChannelConfig, 0, len(updated.Topology.Channels))
329343
for _, sc := range updated.Topology.Channels {
330344
staticChannels = append(staticChannels, topology.ChannelConfig{
331345
Name: sc.Name, Topic: sc.Topic,
332346
Ops: sc.Ops, Voice: sc.Voice, Autojoin: sc.Autojoin,
347
+ Modes: sc.Modes,
333348
})
334349
}
335350
if err := topoMgr.Provision(staticChannels); err != nil {
336351
log.Error("topology hot-reload failed", "err", err)
337352
}
338353
}
339354
// Hot-reload bridge web TTL.
340355
if bridgeBot != nil {
341356
bridgeBot.SetWebUserTTL(time.Duration(updated.Bridge.WebUserTTLMinutes) * time.Minute)
357
+ }
358
+ // Regenerate ircd.yaml and rehash Ergo on config changes.
359
+ if ergoMgr != nil {
360
+ if err := ergoMgr.UpdateConfig(updated.Ergo); err != nil {
361
+ log.Error("ergo config hot-reload failed", "err", err)
362
+ } else {
363
+ log.Info("ergo config reloaded")
364
+ }
342365
}
343366
})
344367
345368
// Start HTTP REST API server.
346369
var llmCfg *config.LLMConfig
@@ -352,11 +375,11 @@
352375
// non-nil (Go nil interface trap) and causes panics in setAgentModes.
353376
var topoIface api.TopologyManager
354377
if topoMgr != nil {
355378
topoIface = topoMgr
356379
}
357
- apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log)
380
+ apiSrv := api.New(reg, apiKeyStore, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log)
358381
handler := apiSrv.Handler()
359382
360383
var httpServer, tlsServer *http.Server
361384
362385
if cfg.TLS.Domain != "" {
@@ -418,11 +441,11 @@
418441
}
419442
}()
420443
}
421444
422445
// Start MCP server.
423
- mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, tokens, log)
446
+ mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, apiKeyStore, log)
424447
mcpServer := &http.Server{
425448
Addr: cfg.MCPAddr,
426449
Handler: mcpSrv.Handler(),
427450
}
428451
go func() {
429452
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -138,18 +138,31 @@
138 } else if err := reg.SetDataPath(filepath.Join(cfg.Ergo.DataDir, "registry.json")); err != nil {
139 log.Error("registry load", "err", err)
140 os.Exit(1)
141 }
142
143 // Shared API token — persisted so the UI token survives restarts.
144 apiToken, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "api_token"))
145 if err != nil {
146 log.Error("api token", "err", err)
147 os.Exit(1)
148 }
149 log.Info("api token", "token", apiToken) // printed on every startup
150 tokens := []string{apiToken}
 
 
 
 
 
 
 
 
 
 
 
 
 
151
152 // Start bridge bot (powers the web chat UI).
153 var bridgeBot *bridge.Bot
154 if cfg.Bridge.Enabled {
155 if cfg.Bridge.Password == "" {
@@ -204,10 +217,11 @@
204 Name: sc.Name,
205 Topic: sc.Topic,
206 Ops: sc.Ops,
207 Voice: sc.Voice,
208 Autojoin: sc.Autojoin,
 
209 })
210 }
211 if err := topoMgr.Provision(staticChannels); err != nil {
212 log.Error("topology provision failed", "err", err)
213 }
@@ -328,19 +342,28 @@
328 staticChannels := make([]topology.ChannelConfig, 0, len(updated.Topology.Channels))
329 for _, sc := range updated.Topology.Channels {
330 staticChannels = append(staticChannels, topology.ChannelConfig{
331 Name: sc.Name, Topic: sc.Topic,
332 Ops: sc.Ops, Voice: sc.Voice, Autojoin: sc.Autojoin,
 
333 })
334 }
335 if err := topoMgr.Provision(staticChannels); err != nil {
336 log.Error("topology hot-reload failed", "err", err)
337 }
338 }
339 // Hot-reload bridge web TTL.
340 if bridgeBot != nil {
341 bridgeBot.SetWebUserTTL(time.Duration(updated.Bridge.WebUserTTLMinutes) * time.Minute)
 
 
 
 
 
 
 
 
342 }
343 })
344
345 // Start HTTP REST API server.
346 var llmCfg *config.LLMConfig
@@ -352,11 +375,11 @@
352 // non-nil (Go nil interface trap) and causes panics in setAgentModes.
353 var topoIface api.TopologyManager
354 if topoMgr != nil {
355 topoIface = topoMgr
356 }
357 apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log)
358 handler := apiSrv.Handler()
359
360 var httpServer, tlsServer *http.Server
361
362 if cfg.TLS.Domain != "" {
@@ -418,11 +441,11 @@
418 }
419 }()
420 }
421
422 // Start MCP server.
423 mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, tokens, log)
424 mcpServer := &http.Server{
425 Addr: cfg.MCPAddr,
426 Handler: mcpSrv.Handler(),
427 }
428 go func() {
429
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -138,18 +138,31 @@
138 } else if err := reg.SetDataPath(filepath.Join(cfg.Ergo.DataDir, "registry.json")); err != nil {
139 log.Error("registry load", "err", err)
140 os.Exit(1)
141 }
142
143 // API key store — per-consumer tokens with scoped permissions.
144 apiKeyStore, err := auth.NewAPIKeyStore(filepath.Join(cfg.Ergo.DataDir, "api_keys.json"))
145 if err != nil {
146 log.Error("api key store", "err", err)
147 os.Exit(1)
148 }
149 // Migrate legacy api_token into key store on first run.
150 if apiKeyStore.IsEmpty() {
151 apiToken, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "api_token"))
152 if err != nil {
153 log.Error("api token", "err", err)
154 os.Exit(1)
155 }
156 if _, err := apiKeyStore.Insert("server", apiToken, []auth.Scope{auth.ScopeAdmin}); err != nil {
157 log.Error("migrate api token to key store", "err", err)
158 os.Exit(1)
159 }
160 log.Info("migrated api_token to api_keys.json", "token", apiToken)
161 } else {
162 log.Info("api key store loaded", "keys", len(apiKeyStore.List()))
163 }
164
165 // Start bridge bot (powers the web chat UI).
166 var bridgeBot *bridge.Bot
167 if cfg.Bridge.Enabled {
168 if cfg.Bridge.Password == "" {
@@ -204,10 +217,11 @@
217 Name: sc.Name,
218 Topic: sc.Topic,
219 Ops: sc.Ops,
220 Voice: sc.Voice,
221 Autojoin: sc.Autojoin,
222 Modes: sc.Modes,
223 })
224 }
225 if err := topoMgr.Provision(staticChannels); err != nil {
226 log.Error("topology provision failed", "err", err)
227 }
@@ -328,19 +342,28 @@
342 staticChannels := make([]topology.ChannelConfig, 0, len(updated.Topology.Channels))
343 for _, sc := range updated.Topology.Channels {
344 staticChannels = append(staticChannels, topology.ChannelConfig{
345 Name: sc.Name, Topic: sc.Topic,
346 Ops: sc.Ops, Voice: sc.Voice, Autojoin: sc.Autojoin,
347 Modes: sc.Modes,
348 })
349 }
350 if err := topoMgr.Provision(staticChannels); err != nil {
351 log.Error("topology hot-reload failed", "err", err)
352 }
353 }
354 // Hot-reload bridge web TTL.
355 if bridgeBot != nil {
356 bridgeBot.SetWebUserTTL(time.Duration(updated.Bridge.WebUserTTLMinutes) * time.Minute)
357 }
358 // Regenerate ircd.yaml and rehash Ergo on config changes.
359 if ergoMgr != nil {
360 if err := ergoMgr.UpdateConfig(updated.Ergo); err != nil {
361 log.Error("ergo config hot-reload failed", "err", err)
362 } else {
363 log.Info("ergo config reloaded")
364 }
365 }
366 })
367
368 // Start HTTP REST API server.
369 var llmCfg *config.LLMConfig
@@ -352,11 +375,11 @@
375 // non-nil (Go nil interface trap) and causes panics in setAgentModes.
376 var topoIface api.TopologyManager
377 if topoMgr != nil {
378 topoIface = topoMgr
379 }
380 apiSrv := api.New(reg, apiKeyStore, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log)
381 handler := apiSrv.Handler()
382
383 var httpServer, tlsServer *http.Server
384
385 if cfg.TLS.Domain != "" {
@@ -418,11 +441,11 @@
441 }
442 }()
443 }
444
445 // Start MCP server.
446 mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, apiKeyStore, log)
447 mcpServer := &http.Server{
448 Addr: cfg.MCPAddr,
449 Handler: mcpSrv.Handler(),
450 }
451 go func() {
452
--- cmd/scuttlectl/internal/apiclient/apiclient.go
+++ cmd/scuttlectl/internal/apiclient/apiclient.go
@@ -137,10 +137,30 @@
137137
// RemoveAdmin sends DELETE /v1/admins/{username}.
138138
func (c *Client) RemoveAdmin(username string) error {
139139
_, err := c.doNoBody("DELETE", "/v1/admins/"+username)
140140
return err
141141
}
142
+
143
+// ListAPIKeys returns GET /v1/api-keys.
144
+func (c *Client) ListAPIKeys() (json.RawMessage, error) {
145
+ return c.get("/v1/api-keys")
146
+}
147
+
148
+// CreateAPIKey sends POST /v1/api-keys.
149
+func (c *Client) CreateAPIKey(name string, scopes []string, expiresIn string) (json.RawMessage, error) {
150
+ body := map[string]any{"name": name, "scopes": scopes}
151
+ if expiresIn != "" {
152
+ body["expires_in"] = expiresIn
153
+ }
154
+ return c.post("/v1/api-keys", body)
155
+}
156
+
157
+// RevokeAPIKey sends DELETE /v1/api-keys/{id}.
158
+func (c *Client) RevokeAPIKey(id string) error {
159
+ _, err := c.doNoBody("DELETE", "/v1/api-keys/"+id)
160
+ return err
161
+}
142162
143163
// SetAdminPassword sends PUT /v1/admins/{username}/password.
144164
func (c *Client) SetAdminPassword(username, password string) error {
145165
_, err := c.put("/v1/admins/"+username+"/password", map[string]string{"password": password})
146166
return err
147167
--- cmd/scuttlectl/internal/apiclient/apiclient.go
+++ cmd/scuttlectl/internal/apiclient/apiclient.go
@@ -137,10 +137,30 @@
137 // RemoveAdmin sends DELETE /v1/admins/{username}.
138 func (c *Client) RemoveAdmin(username string) error {
139 _, err := c.doNoBody("DELETE", "/v1/admins/"+username)
140 return err
141 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
143 // SetAdminPassword sends PUT /v1/admins/{username}/password.
144 func (c *Client) SetAdminPassword(username, password string) error {
145 _, err := c.put("/v1/admins/"+username+"/password", map[string]string{"password": password})
146 return err
147
--- cmd/scuttlectl/internal/apiclient/apiclient.go
+++ cmd/scuttlectl/internal/apiclient/apiclient.go
@@ -137,10 +137,30 @@
137 // RemoveAdmin sends DELETE /v1/admins/{username}.
138 func (c *Client) RemoveAdmin(username string) error {
139 _, err := c.doNoBody("DELETE", "/v1/admins/"+username)
140 return err
141 }
142
143 // ListAPIKeys returns GET /v1/api-keys.
144 func (c *Client) ListAPIKeys() (json.RawMessage, error) {
145 return c.get("/v1/api-keys")
146 }
147
148 // CreateAPIKey sends POST /v1/api-keys.
149 func (c *Client) CreateAPIKey(name string, scopes []string, expiresIn string) (json.RawMessage, error) {
150 body := map[string]any{"name": name, "scopes": scopes}
151 if expiresIn != "" {
152 body["expires_in"] = expiresIn
153 }
154 return c.post("/v1/api-keys", body)
155 }
156
157 // RevokeAPIKey sends DELETE /v1/api-keys/{id}.
158 func (c *Client) RevokeAPIKey(id string) error {
159 _, err := c.doNoBody("DELETE", "/v1/api-keys/"+id)
160 return err
161 }
162
163 // SetAdminPassword sends PUT /v1/admins/{username}/password.
164 func (c *Client) SetAdminPassword(username, password string) error {
165 _, err := c.put("/v1/admins/"+username+"/password", map[string]string{"password": password})
166 return err
167
--- cmd/scuttlectl/main.go
+++ cmd/scuttlectl/main.go
@@ -108,10 +108,28 @@
108108
requireArgs(args, 3, "scuttlectl admin passwd <username>")
109109
cmdAdminPasswd(api, args[2])
110110
default:
111111
fmt.Fprintf(os.Stderr, "unknown subcommand: admin %s\n", args[1])
112112
os.Exit(1)
113
+ }
114
+ case "api-key", "api-keys":
115
+ if len(args) < 2 {
116
+ fmt.Fprintf(os.Stderr, "usage: scuttlectl api-key <list|create|revoke>\n")
117
+ os.Exit(1)
118
+ }
119
+ switch args[1] {
120
+ case "list":
121
+ cmdAPIKeyList(api, *jsonFlag)
122
+ case "create":
123
+ requireArgs(args, 3, "scuttlectl api-key create --name <name> --scopes <scope1,scope2>")
124
+ cmdAPIKeyCreate(api, args[2:], *jsonFlag)
125
+ case "revoke":
126
+ requireArgs(args, 3, "scuttlectl api-key revoke <id>")
127
+ cmdAPIKeyRevoke(api, args[2])
128
+ default:
129
+ fmt.Fprintf(os.Stderr, "unknown subcommand: api-key %s\n", args[1])
130
+ os.Exit(1)
113131
}
114132
case "channels", "channel":
115133
if len(args) < 2 {
116134
fmt.Fprintf(os.Stderr, "usage: scuttlectl channels <list|users <channel>>\n")
117135
os.Exit(1)
@@ -491,10 +509,88 @@
491509
fmt.Fprintf(tw, "password\t%s\n", creds.Password)
492510
fmt.Fprintf(tw, "server\t%s\n", creds.Server)
493511
tw.Flush()
494512
fmt.Println("\nStore this password — it will not be shown again.")
495513
}
514
+
515
+func cmdAPIKeyList(api *apiclient.Client, asJSON bool) {
516
+ raw, err := api.ListAPIKeys()
517
+ die(err)
518
+ if asJSON {
519
+ printJSON(raw)
520
+ return
521
+ }
522
+
523
+ var keys []struct {
524
+ ID string `json:"id"`
525
+ Name string `json:"name"`
526
+ Scopes []string `json:"scopes"`
527
+ CreatedAt string `json:"created_at"`
528
+ LastUsed *string `json:"last_used"`
529
+ ExpiresAt *string `json:"expires_at"`
530
+ Active bool `json:"active"`
531
+ }
532
+ must(json.Unmarshal(raw, &keys))
533
+
534
+ if len(keys) == 0 {
535
+ fmt.Println("no API keys")
536
+ return
537
+ }
538
+
539
+ tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
540
+ fmt.Fprintln(tw, "ID\tNAME\tSCOPES\tACTIVE\tLAST USED")
541
+ for _, k := range keys {
542
+ lastUsed := "-"
543
+ if k.LastUsed != nil {
544
+ lastUsed = *k.LastUsed
545
+ }
546
+ status := "yes"
547
+ if !k.Active {
548
+ status = "revoked"
549
+ }
550
+ fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", k.ID, k.Name, strings.Join(k.Scopes, ","), status, lastUsed)
551
+ }
552
+ tw.Flush()
553
+}
554
+
555
+func cmdAPIKeyCreate(api *apiclient.Client, args []string, asJSON bool) {
556
+ fs := flag.NewFlagSet("api-key create", flag.ExitOnError)
557
+ nameFlag := fs.String("name", "", "key name (required)")
558
+ scopesFlag := fs.String("scopes", "", "comma-separated scopes (required)")
559
+ expiresFlag := fs.String("expires", "", "expiry duration (e.g. 720h for 30 days)")
560
+ _ = fs.Parse(args)
561
+
562
+ if *nameFlag == "" || *scopesFlag == "" {
563
+ fmt.Fprintln(os.Stderr, "usage: scuttlectl api-key create --name <name> --scopes <scope1,scope2> [--expires 720h]")
564
+ os.Exit(1)
565
+ }
566
+
567
+ scopes := strings.Split(*scopesFlag, ",")
568
+ raw, err := api.CreateAPIKey(*nameFlag, scopes, *expiresFlag)
569
+ die(err)
570
+
571
+ if asJSON {
572
+ printJSON(raw)
573
+ return
574
+ }
575
+
576
+ var key struct {
577
+ ID string `json:"id"`
578
+ Name string `json:"name"`
579
+ Token string `json:"token"`
580
+ }
581
+ must(json.Unmarshal(raw, &key))
582
+
583
+ fmt.Printf("API key created: %s\n\n", key.Name)
584
+ fmt.Printf(" Token: %s\n\n", key.Token)
585
+ fmt.Println("Store this token — it will not be shown again.")
586
+}
587
+
588
+func cmdAPIKeyRevoke(api *apiclient.Client, id string) {
589
+ die(api.RevokeAPIKey(id))
590
+ fmt.Printf("API key revoked: %s\n", id)
591
+}
496592
497593
func usage() {
498594
fmt.Fprintf(os.Stderr, `scuttlectl %s — scuttlebot management CLI
499595
500596
Usage:
@@ -526,10 +622,13 @@
526622
backend rename <old> <new> rename a backend
527623
admin list list admin accounts
528624
admin add <username> add admin (prompts for password)
529625
admin remove <username> remove admin
530626
admin passwd <username> change admin password (prompts)
627
+ api-key list list API keys
628
+ api-key create --name <name> --scopes <s1,s2> [--expires 720h]
629
+ api-key revoke <id> revoke an API key
531630
`, version)
532631
}
533632
534633
func printJSON(raw json.RawMessage) {
535634
var buf []byte
536635
--- cmd/scuttlectl/main.go
+++ cmd/scuttlectl/main.go
@@ -108,10 +108,28 @@
108 requireArgs(args, 3, "scuttlectl admin passwd <username>")
109 cmdAdminPasswd(api, args[2])
110 default:
111 fmt.Fprintf(os.Stderr, "unknown subcommand: admin %s\n", args[1])
112 os.Exit(1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113 }
114 case "channels", "channel":
115 if len(args) < 2 {
116 fmt.Fprintf(os.Stderr, "usage: scuttlectl channels <list|users <channel>>\n")
117 os.Exit(1)
@@ -491,10 +509,88 @@
491 fmt.Fprintf(tw, "password\t%s\n", creds.Password)
492 fmt.Fprintf(tw, "server\t%s\n", creds.Server)
493 tw.Flush()
494 fmt.Println("\nStore this password — it will not be shown again.")
495 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
496
497 func usage() {
498 fmt.Fprintf(os.Stderr, `scuttlectl %s — scuttlebot management CLI
499
500 Usage:
@@ -526,10 +622,13 @@
526 backend rename <old> <new> rename a backend
527 admin list list admin accounts
528 admin add <username> add admin (prompts for password)
529 admin remove <username> remove admin
530 admin passwd <username> change admin password (prompts)
 
 
 
531 `, version)
532 }
533
534 func printJSON(raw json.RawMessage) {
535 var buf []byte
536
--- cmd/scuttlectl/main.go
+++ cmd/scuttlectl/main.go
@@ -108,10 +108,28 @@
108 requireArgs(args, 3, "scuttlectl admin passwd <username>")
109 cmdAdminPasswd(api, args[2])
110 default:
111 fmt.Fprintf(os.Stderr, "unknown subcommand: admin %s\n", args[1])
112 os.Exit(1)
113 }
114 case "api-key", "api-keys":
115 if len(args) < 2 {
116 fmt.Fprintf(os.Stderr, "usage: scuttlectl api-key <list|create|revoke>\n")
117 os.Exit(1)
118 }
119 switch args[1] {
120 case "list":
121 cmdAPIKeyList(api, *jsonFlag)
122 case "create":
123 requireArgs(args, 3, "scuttlectl api-key create --name <name> --scopes <scope1,scope2>")
124 cmdAPIKeyCreate(api, args[2:], *jsonFlag)
125 case "revoke":
126 requireArgs(args, 3, "scuttlectl api-key revoke <id>")
127 cmdAPIKeyRevoke(api, args[2])
128 default:
129 fmt.Fprintf(os.Stderr, "unknown subcommand: api-key %s\n", args[1])
130 os.Exit(1)
131 }
132 case "channels", "channel":
133 if len(args) < 2 {
134 fmt.Fprintf(os.Stderr, "usage: scuttlectl channels <list|users <channel>>\n")
135 os.Exit(1)
@@ -491,10 +509,88 @@
509 fmt.Fprintf(tw, "password\t%s\n", creds.Password)
510 fmt.Fprintf(tw, "server\t%s\n", creds.Server)
511 tw.Flush()
512 fmt.Println("\nStore this password — it will not be shown again.")
513 }
514
515 func cmdAPIKeyList(api *apiclient.Client, asJSON bool) {
516 raw, err := api.ListAPIKeys()
517 die(err)
518 if asJSON {
519 printJSON(raw)
520 return
521 }
522
523 var keys []struct {
524 ID string `json:"id"`
525 Name string `json:"name"`
526 Scopes []string `json:"scopes"`
527 CreatedAt string `json:"created_at"`
528 LastUsed *string `json:"last_used"`
529 ExpiresAt *string `json:"expires_at"`
530 Active bool `json:"active"`
531 }
532 must(json.Unmarshal(raw, &keys))
533
534 if len(keys) == 0 {
535 fmt.Println("no API keys")
536 return
537 }
538
539 tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
540 fmt.Fprintln(tw, "ID\tNAME\tSCOPES\tACTIVE\tLAST USED")
541 for _, k := range keys {
542 lastUsed := "-"
543 if k.LastUsed != nil {
544 lastUsed = *k.LastUsed
545 }
546 status := "yes"
547 if !k.Active {
548 status = "revoked"
549 }
550 fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", k.ID, k.Name, strings.Join(k.Scopes, ","), status, lastUsed)
551 }
552 tw.Flush()
553 }
554
555 func cmdAPIKeyCreate(api *apiclient.Client, args []string, asJSON bool) {
556 fs := flag.NewFlagSet("api-key create", flag.ExitOnError)
557 nameFlag := fs.String("name", "", "key name (required)")
558 scopesFlag := fs.String("scopes", "", "comma-separated scopes (required)")
559 expiresFlag := fs.String("expires", "", "expiry duration (e.g. 720h for 30 days)")
560 _ = fs.Parse(args)
561
562 if *nameFlag == "" || *scopesFlag == "" {
563 fmt.Fprintln(os.Stderr, "usage: scuttlectl api-key create --name <name> --scopes <scope1,scope2> [--expires 720h]")
564 os.Exit(1)
565 }
566
567 scopes := strings.Split(*scopesFlag, ",")
568 raw, err := api.CreateAPIKey(*nameFlag, scopes, *expiresFlag)
569 die(err)
570
571 if asJSON {
572 printJSON(raw)
573 return
574 }
575
576 var key struct {
577 ID string `json:"id"`
578 Name string `json:"name"`
579 Token string `json:"token"`
580 }
581 must(json.Unmarshal(raw, &key))
582
583 fmt.Printf("API key created: %s\n\n", key.Name)
584 fmt.Printf(" Token: %s\n\n", key.Token)
585 fmt.Println("Store this token — it will not be shown again.")
586 }
587
588 func cmdAPIKeyRevoke(api *apiclient.Client, id string) {
589 die(api.RevokeAPIKey(id))
590 fmt.Printf("API key revoked: %s\n", id)
591 }
592
593 func usage() {
594 fmt.Fprintf(os.Stderr, `scuttlectl %s — scuttlebot management CLI
595
596 Usage:
@@ -526,10 +622,13 @@
622 backend rename <old> <new> rename a backend
623 admin list list admin accounts
624 admin add <username> add admin (prompts for password)
625 admin remove <username> remove admin
626 admin passwd <username> change admin password (prompts)
627 api-key list list API keys
628 api-key create --name <name> --scopes <s1,s2> [--expires 720h]
629 api-key revoke <id> revoke an API key
630 `, version)
631 }
632
633 func printJSON(raw json.RawMessage) {
634 var buf []byte
635
--- deploy/compose/ergo/ircd.yaml.tmpl
+++ deploy/compose/ergo/ircd.yaml.tmpl
@@ -13,11 +13,13 @@
1313
enforce-utf8: true
1414
lookup-hostnames: false
1515
forward-confirm-hostnames: false
1616
check-ident: false
1717
relaymsg:
18
- enabled: false
18
+ enabled: true
19
+ separators: /
20
+ available-to-chanops: false
1921
ip-cloaking:
2022
enabled: false
2123
max-sendq: "1M"
2224
ip-limits:
2325
count-exempted: true
2426
--- deploy/compose/ergo/ircd.yaml.tmpl
+++ deploy/compose/ergo/ircd.yaml.tmpl
@@ -13,11 +13,13 @@
13 enforce-utf8: true
14 lookup-hostnames: false
15 forward-confirm-hostnames: false
16 check-ident: false
17 relaymsg:
18 enabled: false
 
 
19 ip-cloaking:
20 enabled: false
21 max-sendq: "1M"
22 ip-limits:
23 count-exempted: true
24
--- deploy/compose/ergo/ircd.yaml.tmpl
+++ deploy/compose/ergo/ircd.yaml.tmpl
@@ -13,11 +13,13 @@
13 enforce-utf8: true
14 lookup-hostnames: false
15 forward-confirm-hostnames: false
16 check-ident: false
17 relaymsg:
18 enabled: true
19 separators: /
20 available-to-chanops: false
21 ip-cloaking:
22 enabled: false
23 max-sendq: "1M"
24 ip-limits:
25 count-exempted: true
26
--- internal/api/api_test.go
+++ internal/api/api_test.go
@@ -8,10 +8,11 @@
88
"net/http/httptest"
99
"sync"
1010
"testing"
1111
1212
"github.com/conflicthq/scuttlebot/internal/api"
13
+ "github.com/conflicthq/scuttlebot/internal/auth"
1314
"github.com/conflicthq/scuttlebot/internal/registry"
1415
"log/slog"
1516
"os"
1617
)
1718
@@ -50,11 +51,11 @@
5051
const testToken = "test-api-token-abc123"
5152
5253
func newTestServer(t *testing.T) *httptest.Server {
5354
t.Helper()
5455
reg := registry.New(newMock(), []byte("test-signing-key"))
55
- srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog)
56
+ srv := api.New(reg, auth.TestStore(testToken), nil, nil, nil, nil, nil, nil, "", testLog)
5657
return httptest.NewServer(srv.Handler())
5758
}
5859
5960
func authHeader() http.Header {
6061
h := http.Header{}
6162
6263
ADDED internal/api/apikeys.go
--- internal/api/api_test.go
+++ internal/api/api_test.go
@@ -8,10 +8,11 @@
8 "net/http/httptest"
9 "sync"
10 "testing"
11
12 "github.com/conflicthq/scuttlebot/internal/api"
 
13 "github.com/conflicthq/scuttlebot/internal/registry"
14 "log/slog"
15 "os"
16 )
17
@@ -50,11 +51,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, nil, nil, nil, nil, nil, "", testLog)
56 return httptest.NewServer(srv.Handler())
57 }
58
59 func authHeader() http.Header {
60 h := http.Header{}
61
62 DDED internal/api/apikeys.go
--- internal/api/api_test.go
+++ internal/api/api_test.go
@@ -8,10 +8,11 @@
8 "net/http/httptest"
9 "sync"
10 "testing"
11
12 "github.com/conflicthq/scuttlebot/internal/api"
13 "github.com/conflicthq/scuttlebot/internal/auth"
14 "github.com/conflicthq/scuttlebot/internal/registry"
15 "log/slog"
16 "os"
17 )
18
@@ -50,11 +51,11 @@
51 const testToken = "test-api-token-abc123"
52
53 func newTestServer(t *testing.T) *httptest.Server {
54 t.Helper()
55 reg := registry.New(newMock(), []byte("test-signing-key"))
56 srv := api.New(reg, auth.TestStore(testToken), nil, nil, nil, nil, nil, nil, "", testLog)
57 return httptest.NewServer(srv.Handler())
58 }
59
60 func authHeader() http.Header {
61 h := http.Header{}
62
63 DDED internal/api/apikeys.go
--- a/internal/api/apikeys.go
+++ b/internal/api/apikeys.go
@@ -0,0 +1,125 @@
1
+package api
2
+
3
+import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "time"
7
+
8
+ "github.com/conflicthq/scuttlebot/internal/auth"
9
+)
10
+
11
+type createAPIKeyRequest struct {
12
+ Name string `json:"name"`
13
+ Scopes []string `json:"scopes"`
14
+ ExpiresIn string `json:"expires_in,omitempty"` // e.g. "720h" for 30 days, empty = never
15
+}
16
+
17
+type createAPIKeyResponse struct {
18
+ ID string `json:"id"`
19
+ Name string `json:"name"`
20
+ Token string `json:"token"` // plaintext, shown only once
21
+ Scopes []auth.Scope `json:"scopes"`
22
+ CreatedAt time.Time `json:"created_at"`
23
+ ExpiresAt *time.Time `json:"expires_at,omitempty"`
24
+}
25
+
26
+type apiKeyListEntry struct {
27
+ ID string `json:"id"`
28
+ Name string `json:"name"`
29
+ Scopes []auth.Scope `json:"scopes"`
30
+ CreatedAt time.Time `json:"created_at"`
31
+ LastUsed *time.Time `json:"last_used,omitempty"`
32
+ ExpiresAt *time.Time `json:"expires_at,omitempty"`
33
+ Active bool `json:"active"`
34
+}
35
+
36
+// handleListAPIKeys handles GET /v1/api-keys.
37
+func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
38
+ keys := s.apiKeys.List()
39
+ out := make([]apiKeyListEntry, len(keys))
40
+ for i, k := range keys {
41
+ out[i] = apiKeyListEntry{
42
+ ID: k.ID,
43
+ Name: k.Name,
44
+ Scopes: k.Scopes,
45
+ CreatedAt: k.CreatedAt,
46
+ Active: k.Active,
47
+ }
48
+ if !k.LastUsed.IsZero() {
49
+ t := k.LastUsed
50
+ out[i].LastUsed = &t
51
+ }
52
+ if !k.ExpiresAt.IsZero() {
53
+ t := k.ExpiresAt
54
+ out[i].ExpiresAt = &t
55
+ }
56
+ }
57
+ writeJSON(w, http.StatusOK, out)
58
+}
59
+
60
+// handleCreateAPIKey handles POST /v1/api-keys.
61
+func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
62
+ var req createAPIKeyRequest
63
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
64
+ writeError(w, http.StatusBadRequest, "invalid request body")
65
+ return
66
+ }
67
+ if req.Name == "" {
68
+ writeError(w, http.StatusBadRequest, "name is required")
69
+ return
70
+ }
71
+
72
+ scopes := make([]auth.Scope, len(req.Scopes))
73
+ for i, s := range req.Scopes {
74
+ scope := auth.Scope(s)
75
+ if !auth.ValidScopes[scope] {
76
+ writeError(w, http.StatusBadRequest, "unknown scope: "+s)
77
+ return
78
+ }
79
+ scopes[i] = scope
80
+ }
81
+ if len(scopes) == 0 {
82
+ writeError(w, http.StatusBadRequest, "at least one scope is required")
83
+ return
84
+ }
85
+
86
+ var expiresAt time.Time
87
+ if req.ExpiresIn != "" {
88
+ dur, err := time.ParseDuration(req.ExpiresIn)
89
+ if err != nil {
90
+ writeError(w, http.StatusBadRequest, "invalid expires_in duration: "+err.Error())
91
+ return
92
+ }
93
+ expiresAt = time.Now().Add(dur)
94
+ }
95
+
96
+ token, key, err := s.apiKeys.Create(req.Name, scopes, expiresAt)
97
+ if err != nil {
98
+ s.log.Error("create api key", "err", err)
99
+ writeError(w, http.StatusInternalServerError, "failed to create API key")
100
+ return
101
+ }
102
+
103
+ resp := createAPIKeyResponse{
104
+ ID: key.ID,
105
+ Name: key.Name,
106
+ Token: token,
107
+ Scopes: key.Scopes,
108
+ CreatedAt: key.CreatedAt,
109
+ }
110
+ if !key.ExpiresAt.IsZero() {
111
+ t := key.ExpiresAt
112
+ resp.ExpiresAt = &t
113
+ }
114
+ writeJSON(w, http.StatusCreated, resp)
115
+}
116
+
117
+// handleRevokeAPIKey handles DELETE /v1/api-keys/{id}.
118
+func (s *Server) handleRevokeAPIKey(w http.ResponseWriter, r *http.Request) {
119
+ id := r.PathValue("id")
120
+ if err := s.apiKeys.Revoke(id); err != nil {
121
+ writeError(w, http.StatusNotFound, err.Error())
122
+ return
123
+ }
124
+ w.WriteHeader(http.StatusNoContent)
125
+}
--- a/internal/api/apikeys.go
+++ b/internal/api/apikeys.go
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/api/apikeys.go
+++ b/internal/api/apikeys.go
@@ -0,0 +1,125 @@
1 package api
2
3 import (
4 "encoding/json"
5 "net/http"
6 "time"
7
8 "github.com/conflicthq/scuttlebot/internal/auth"
9 )
10
11 type createAPIKeyRequest struct {
12 Name string `json:"name"`
13 Scopes []string `json:"scopes"`
14 ExpiresIn string `json:"expires_in,omitempty"` // e.g. "720h" for 30 days, empty = never
15 }
16
17 type createAPIKeyResponse struct {
18 ID string `json:"id"`
19 Name string `json:"name"`
20 Token string `json:"token"` // plaintext, shown only once
21 Scopes []auth.Scope `json:"scopes"`
22 CreatedAt time.Time `json:"created_at"`
23 ExpiresAt *time.Time `json:"expires_at,omitempty"`
24 }
25
26 type apiKeyListEntry struct {
27 ID string `json:"id"`
28 Name string `json:"name"`
29 Scopes []auth.Scope `json:"scopes"`
30 CreatedAt time.Time `json:"created_at"`
31 LastUsed *time.Time `json:"last_used,omitempty"`
32 ExpiresAt *time.Time `json:"expires_at,omitempty"`
33 Active bool `json:"active"`
34 }
35
36 // handleListAPIKeys handles GET /v1/api-keys.
37 func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
38 keys := s.apiKeys.List()
39 out := make([]apiKeyListEntry, len(keys))
40 for i, k := range keys {
41 out[i] = apiKeyListEntry{
42 ID: k.ID,
43 Name: k.Name,
44 Scopes: k.Scopes,
45 CreatedAt: k.CreatedAt,
46 Active: k.Active,
47 }
48 if !k.LastUsed.IsZero() {
49 t := k.LastUsed
50 out[i].LastUsed = &t
51 }
52 if !k.ExpiresAt.IsZero() {
53 t := k.ExpiresAt
54 out[i].ExpiresAt = &t
55 }
56 }
57 writeJSON(w, http.StatusOK, out)
58 }
59
60 // handleCreateAPIKey handles POST /v1/api-keys.
61 func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
62 var req createAPIKeyRequest
63 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
64 writeError(w, http.StatusBadRequest, "invalid request body")
65 return
66 }
67 if req.Name == "" {
68 writeError(w, http.StatusBadRequest, "name is required")
69 return
70 }
71
72 scopes := make([]auth.Scope, len(req.Scopes))
73 for i, s := range req.Scopes {
74 scope := auth.Scope(s)
75 if !auth.ValidScopes[scope] {
76 writeError(w, http.StatusBadRequest, "unknown scope: "+s)
77 return
78 }
79 scopes[i] = scope
80 }
81 if len(scopes) == 0 {
82 writeError(w, http.StatusBadRequest, "at least one scope is required")
83 return
84 }
85
86 var expiresAt time.Time
87 if req.ExpiresIn != "" {
88 dur, err := time.ParseDuration(req.ExpiresIn)
89 if err != nil {
90 writeError(w, http.StatusBadRequest, "invalid expires_in duration: "+err.Error())
91 return
92 }
93 expiresAt = time.Now().Add(dur)
94 }
95
96 token, key, err := s.apiKeys.Create(req.Name, scopes, expiresAt)
97 if err != nil {
98 s.log.Error("create api key", "err", err)
99 writeError(w, http.StatusInternalServerError, "failed to create API key")
100 return
101 }
102
103 resp := createAPIKeyResponse{
104 ID: key.ID,
105 Name: key.Name,
106 Token: token,
107 Scopes: key.Scopes,
108 CreatedAt: key.CreatedAt,
109 }
110 if !key.ExpiresAt.IsZero() {
111 t := key.ExpiresAt
112 resp.ExpiresAt = &t
113 }
114 writeJSON(w, http.StatusCreated, resp)
115 }
116
117 // handleRevokeAPIKey handles DELETE /v1/api-keys/{id}.
118 func (s *Server) handleRevokeAPIKey(w http.ResponseWriter, r *http.Request) {
119 id := r.PathValue("id")
120 if err := s.apiKeys.Revoke(id); err != nil {
121 writeError(w, http.StatusNotFound, err.Error())
122 return
123 }
124 w.WriteHeader(http.StatusNoContent)
125 }
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -14,10 +14,11 @@
1414
ProvisionChannel(ch topology.ChannelConfig) error
1515
DropChannel(channel string)
1616
Policy() *topology.Policy
1717
GrantAccess(nick, channel, level string)
1818
RevokeAccess(nick, channel string)
19
+ ListChannels() []topology.ChannelInfo
1920
}
2021
2122
type provisionChannelRequest struct {
2223
Name string `json:"name"`
2324
Topic string `json:"topic,omitempty"`
@@ -51,22 +52,27 @@
5152
return
5253
}
5354
5455
policy := s.topoMgr.Policy()
5556
56
- // Merge autojoin from policy if the caller didn't specify any.
57
+ // Merge autojoin and modes from policy if the caller didn't specify any.
5758
autojoin := req.Autojoin
5859
if len(autojoin) == 0 && policy != nil {
5960
autojoin = policy.AutojoinFor(req.Name)
6061
}
62
+ var modes []string
63
+ if policy != nil {
64
+ modes = policy.ModesFor(req.Name)
65
+ }
6166
6267
ch := topology.ChannelConfig{
6368
Name: req.Name,
6469
Topic: req.Topic,
6570
Ops: req.Ops,
6671
Voice: req.Voice,
6772
Autojoin: autojoin,
73
+ Modes: modes,
6874
}
6975
if err := s.topoMgr.ProvisionChannel(ch); err != nil {
7076
s.log.Error("provision channel", "channel", req.Name, "err", err)
7177
writeError(w, http.StatusInternalServerError, "provision failed")
7278
return
@@ -91,12 +97,13 @@
9197
Ephemeral bool `json:"ephemeral,omitempty"`
9298
TTLSeconds int64 `json:"ttl_seconds,omitempty"`
9399
}
94100
95101
type topologyResponse struct {
96
- StaticChannels []string `json:"static_channels"`
97
- Types []channelTypeInfo `json:"types"`
102
+ StaticChannels []string `json:"static_channels"`
103
+ Types []channelTypeInfo `json:"types"`
104
+ ActiveChannels []topology.ChannelInfo `json:"active_channels,omitempty"`
98105
}
99106
100107
// handleDropChannel handles DELETE /v1/topology/channels/{channel}.
101108
// Drops the ChanServ registration of an ephemeral channel.
102109
func (s *Server) handleDropChannel(w http.ResponseWriter, r *http.Request) {
@@ -146,7 +153,8 @@
146153
}
147154
148155
writeJSON(w, http.StatusOK, topologyResponse{
149156
StaticChannels: staticNames,
150157
Types: typeInfos,
158
+ ActiveChannels: s.topoMgr.ListChannels(),
151159
})
152160
}
153161
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -14,10 +14,11 @@
14 ProvisionChannel(ch topology.ChannelConfig) error
15 DropChannel(channel string)
16 Policy() *topology.Policy
17 GrantAccess(nick, channel, level string)
18 RevokeAccess(nick, channel string)
 
19 }
20
21 type provisionChannelRequest struct {
22 Name string `json:"name"`
23 Topic string `json:"topic,omitempty"`
@@ -51,22 +52,27 @@
51 return
52 }
53
54 policy := s.topoMgr.Policy()
55
56 // Merge autojoin from policy if the caller didn't specify any.
57 autojoin := req.Autojoin
58 if len(autojoin) == 0 && policy != nil {
59 autojoin = policy.AutojoinFor(req.Name)
60 }
 
 
 
 
61
62 ch := topology.ChannelConfig{
63 Name: req.Name,
64 Topic: req.Topic,
65 Ops: req.Ops,
66 Voice: req.Voice,
67 Autojoin: autojoin,
 
68 }
69 if err := s.topoMgr.ProvisionChannel(ch); err != nil {
70 s.log.Error("provision channel", "channel", req.Name, "err", err)
71 writeError(w, http.StatusInternalServerError, "provision failed")
72 return
@@ -91,12 +97,13 @@
91 Ephemeral bool `json:"ephemeral,omitempty"`
92 TTLSeconds int64 `json:"ttl_seconds,omitempty"`
93 }
94
95 type topologyResponse struct {
96 StaticChannels []string `json:"static_channels"`
97 Types []channelTypeInfo `json:"types"`
 
98 }
99
100 // handleDropChannel handles DELETE /v1/topology/channels/{channel}.
101 // Drops the ChanServ registration of an ephemeral channel.
102 func (s *Server) handleDropChannel(w http.ResponseWriter, r *http.Request) {
@@ -146,7 +153,8 @@
146 }
147
148 writeJSON(w, http.StatusOK, topologyResponse{
149 StaticChannels: staticNames,
150 Types: typeInfos,
 
151 })
152 }
153
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -14,10 +14,11 @@
14 ProvisionChannel(ch topology.ChannelConfig) error
15 DropChannel(channel string)
16 Policy() *topology.Policy
17 GrantAccess(nick, channel, level string)
18 RevokeAccess(nick, channel string)
19 ListChannels() []topology.ChannelInfo
20 }
21
22 type provisionChannelRequest struct {
23 Name string `json:"name"`
24 Topic string `json:"topic,omitempty"`
@@ -51,22 +52,27 @@
52 return
53 }
54
55 policy := s.topoMgr.Policy()
56
57 // Merge autojoin and modes from policy if the caller didn't specify any.
58 autojoin := req.Autojoin
59 if len(autojoin) == 0 && policy != nil {
60 autojoin = policy.AutojoinFor(req.Name)
61 }
62 var modes []string
63 if policy != nil {
64 modes = policy.ModesFor(req.Name)
65 }
66
67 ch := topology.ChannelConfig{
68 Name: req.Name,
69 Topic: req.Topic,
70 Ops: req.Ops,
71 Voice: req.Voice,
72 Autojoin: autojoin,
73 Modes: modes,
74 }
75 if err := s.topoMgr.ProvisionChannel(ch); err != nil {
76 s.log.Error("provision channel", "channel", req.Name, "err", err)
77 writeError(w, http.StatusInternalServerError, "provision failed")
78 return
@@ -91,12 +97,13 @@
97 Ephemeral bool `json:"ephemeral,omitempty"`
98 TTLSeconds int64 `json:"ttl_seconds,omitempty"`
99 }
100
101 type topologyResponse struct {
102 StaticChannels []string `json:"static_channels"`
103 Types []channelTypeInfo `json:"types"`
104 ActiveChannels []topology.ChannelInfo `json:"active_channels,omitempty"`
105 }
106
107 // handleDropChannel handles DELETE /v1/topology/channels/{channel}.
108 // Drops the ChanServ registration of an ephemeral channel.
109 func (s *Server) handleDropChannel(w http.ResponseWriter, r *http.Request) {
@@ -146,7 +153,8 @@
153 }
154
155 writeJSON(w, http.StatusOK, topologyResponse{
156 StaticChannels: staticNames,
157 Types: typeInfos,
158 ActiveChannels: s.topoMgr.ListChannels(),
159 })
160 }
161
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -9,10 +9,11 @@
99
"net/http"
1010
"net/http/httptest"
1111
"testing"
1212
"time"
1313
14
+ "github.com/conflicthq/scuttlebot/internal/auth"
1415
"github.com/conflicthq/scuttlebot/internal/config"
1516
"github.com/conflicthq/scuttlebot/internal/registry"
1617
"github.com/conflicthq/scuttlebot/internal/topology"
1718
)
1819
@@ -48,10 +49,12 @@
4849
4950
func (s *stubTopologyManager) RevokeAccess(nick, channel string) {
5051
s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel})
5152
}
5253
54
+func (s *stubTopologyManager) ListChannels() []topology.ChannelInfo { return nil }
55
+
5356
// stubProvisioner is a minimal AccountProvisioner for agent registration tests.
5457
type stubProvisioner struct {
5558
accounts map[string]string
5659
}
5760
@@ -74,11 +77,11 @@
7477
7578
func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
7679
t.Helper()
7780
reg := registry.New(nil, []byte("key"))
7881
log := slog.New(slog.NewTextHandler(io.Discard, nil))
79
- srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler())
82
+ srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler())
8083
t.Cleanup(srv.Close)
8184
return srv, "tok"
8285
}
8386
8487
// newTopoTestServerWithRegistry creates a test server with both topology and a
@@ -85,11 +88,11 @@
8588
// real registry backed by stubProvisioner, so agent registration works.
8689
func newTopoTestServerWithRegistry(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
8790
t.Helper()
8891
reg := registry.New(newStubProvisioner(), []byte("key"))
8992
log := slog.New(slog.NewTextHandler(io.Discard, nil))
90
- srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler())
93
+ srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler())
9194
t.Cleanup(srv.Close)
9295
return srv, "tok"
9396
}
9497
9598
func TestHandleProvisionChannel(t *testing.T) {
9699
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -9,10 +9,11 @@
9 "net/http"
10 "net/http/httptest"
11 "testing"
12 "time"
13
 
14 "github.com/conflicthq/scuttlebot/internal/config"
15 "github.com/conflicthq/scuttlebot/internal/registry"
16 "github.com/conflicthq/scuttlebot/internal/topology"
17 )
18
@@ -48,10 +49,12 @@
48
49 func (s *stubTopologyManager) RevokeAccess(nick, channel string) {
50 s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel})
51 }
52
 
 
53 // stubProvisioner is a minimal AccountProvisioner for agent registration tests.
54 type stubProvisioner struct {
55 accounts map[string]string
56 }
57
@@ -74,11 +77,11 @@
74
75 func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
76 t.Helper()
77 reg := registry.New(nil, []byte("key"))
78 log := slog.New(slog.NewTextHandler(io.Discard, nil))
79 srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler())
80 t.Cleanup(srv.Close)
81 return srv, "tok"
82 }
83
84 // newTopoTestServerWithRegistry creates a test server with both topology and a
@@ -85,11 +88,11 @@
85 // real registry backed by stubProvisioner, so agent registration works.
86 func newTopoTestServerWithRegistry(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
87 t.Helper()
88 reg := registry.New(newStubProvisioner(), []byte("key"))
89 log := slog.New(slog.NewTextHandler(io.Discard, nil))
90 srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler())
91 t.Cleanup(srv.Close)
92 return srv, "tok"
93 }
94
95 func TestHandleProvisionChannel(t *testing.T) {
96
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -9,10 +9,11 @@
9 "net/http"
10 "net/http/httptest"
11 "testing"
12 "time"
13
14 "github.com/conflicthq/scuttlebot/internal/auth"
15 "github.com/conflicthq/scuttlebot/internal/config"
16 "github.com/conflicthq/scuttlebot/internal/registry"
17 "github.com/conflicthq/scuttlebot/internal/topology"
18 )
19
@@ -48,10 +49,12 @@
49
50 func (s *stubTopologyManager) RevokeAccess(nick, channel string) {
51 s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel})
52 }
53
54 func (s *stubTopologyManager) ListChannels() []topology.ChannelInfo { return nil }
55
56 // stubProvisioner is a minimal AccountProvisioner for agent registration tests.
57 type stubProvisioner struct {
58 accounts map[string]string
59 }
60
@@ -74,11 +77,11 @@
77
78 func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
79 t.Helper()
80 reg := registry.New(nil, []byte("key"))
81 log := slog.New(slog.NewTextHandler(io.Discard, nil))
82 srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler())
83 t.Cleanup(srv.Close)
84 return srv, "tok"
85 }
86
87 // newTopoTestServerWithRegistry creates a test server with both topology and a
@@ -85,11 +88,11 @@
88 // real registry backed by stubProvisioner, so agent registration works.
89 func newTopoTestServerWithRegistry(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
90 t.Helper()
91 reg := registry.New(newStubProvisioner(), []byte("key"))
92 log := slog.New(slog.NewTextHandler(io.Discard, nil))
93 srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler())
94 t.Cleanup(srv.Close)
95 return srv, "tok"
96 }
97
98 func TestHandleProvisionChannel(t *testing.T) {
99
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -5,10 +5,11 @@
55
"encoding/json"
66
"fmt"
77
"net/http"
88
"time"
99
10
+ "github.com/conflicthq/scuttlebot/internal/auth"
1011
"github.com/conflicthq/scuttlebot/internal/bots/bridge"
1112
)
1213
1314
// chatBridge is the interface the API layer requires from the bridge bot.
1415
type chatBridge interface {
@@ -20,10 +21,12 @@
2021
Send(ctx context.Context, channel, text, senderNick string) error
2122
SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *bridge.Meta) error
2223
Stats() bridge.Stats
2324
TouchUser(channel, nick string)
2425
Users(channel string) []string
26
+ UsersWithModes(channel string) []bridge.UserInfo
27
+ ChannelModes(channel string) string
2528
}
2629
2730
func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) {
2831
channel := "#" + r.PathValue("channel")
2932
s.bridge.JoinChannel(channel)
@@ -107,22 +110,58 @@
107110
w.WriteHeader(http.StatusNoContent)
108111
}
109112
110113
func (s *Server) handleChannelUsers(w http.ResponseWriter, r *http.Request) {
111114
channel := "#" + r.PathValue("channel")
112
- users := s.bridge.Users(channel)
115
+ users := s.bridge.UsersWithModes(channel)
113116
if users == nil {
114
- users = []string{}
117
+ users = []bridge.UserInfo{}
118
+ }
119
+ modes := s.bridge.ChannelModes(channel)
120
+ writeJSON(w, http.StatusOK, map[string]any{"users": users, "channel_modes": modes})
121
+}
122
+
123
+func (s *Server) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) {
124
+ channel := "#" + r.PathValue("channel")
125
+ if s.policies == nil {
126
+ writeJSON(w, http.StatusOK, ChannelDisplayConfig{})
127
+ return
128
+ }
129
+ p := s.policies.Get()
130
+ cfg := p.Bridge.ChannelDisplay[channel]
131
+ writeJSON(w, http.StatusOK, cfg)
132
+}
133
+
134
+func (s *Server) handlePutChannelConfig(w http.ResponseWriter, r *http.Request) {
135
+ channel := "#" + r.PathValue("channel")
136
+ if s.policies == nil {
137
+ writeError(w, http.StatusServiceUnavailable, "policies not configured")
138
+ return
139
+ }
140
+ var cfg ChannelDisplayConfig
141
+ if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
142
+ writeError(w, http.StatusBadRequest, "invalid request body")
143
+ return
144
+ }
145
+ p := s.policies.Get()
146
+ if p.Bridge.ChannelDisplay == nil {
147
+ p.Bridge.ChannelDisplay = make(map[string]ChannelDisplayConfig)
148
+ }
149
+ p.Bridge.ChannelDisplay[channel] = cfg
150
+ if err := s.policies.Set(p); err != nil {
151
+ writeError(w, http.StatusInternalServerError, "save failed")
152
+ return
115153
}
116
- writeJSON(w, http.StatusOK, map[string]any{"users": users})
154
+ w.WriteHeader(http.StatusNoContent)
117155
}
118156
119157
// handleChannelStream serves an SSE stream of IRC messages for a channel.
120158
// Auth is via ?token= query param because EventSource doesn't support custom headers.
121159
func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) {
122160
token := r.URL.Query().Get("token")
123
- if _, ok := s.tokens[token]; !ok {
161
+ key := s.apiKeys.Lookup(token)
162
+ if key == nil || (!key.HasScope(auth.ScopeChannels) && !key.HasScope(auth.ScopeChat)) {
124163
writeError(w, http.StatusUnauthorized, "invalid or missing token")
125164
return
126165
}
127166
128167
channel := "#" + r.PathValue("channel")
129168
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -5,10 +5,11 @@
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "time"
9
 
10 "github.com/conflicthq/scuttlebot/internal/bots/bridge"
11 )
12
13 // chatBridge is the interface the API layer requires from the bridge bot.
14 type chatBridge interface {
@@ -20,10 +21,12 @@
20 Send(ctx context.Context, channel, text, senderNick string) error
21 SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *bridge.Meta) error
22 Stats() bridge.Stats
23 TouchUser(channel, nick string)
24 Users(channel string) []string
 
 
25 }
26
27 func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) {
28 channel := "#" + r.PathValue("channel")
29 s.bridge.JoinChannel(channel)
@@ -107,22 +110,58 @@
107 w.WriteHeader(http.StatusNoContent)
108 }
109
110 func (s *Server) handleChannelUsers(w http.ResponseWriter, r *http.Request) {
111 channel := "#" + r.PathValue("channel")
112 users := s.bridge.Users(channel)
113 if users == nil {
114 users = []string{}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115 }
116 writeJSON(w, http.StatusOK, map[string]any{"users": users})
117 }
118
119 // handleChannelStream serves an SSE stream of IRC messages for a channel.
120 // Auth is via ?token= query param because EventSource doesn't support custom headers.
121 func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) {
122 token := r.URL.Query().Get("token")
123 if _, ok := s.tokens[token]; !ok {
 
124 writeError(w, http.StatusUnauthorized, "invalid or missing token")
125 return
126 }
127
128 channel := "#" + r.PathValue("channel")
129
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -5,10 +5,11 @@
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 {
@@ -20,10 +21,12 @@
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 }
29
30 func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) {
31 channel := "#" + r.PathValue("channel")
32 s.bridge.JoinChannel(channel)
@@ -107,22 +110,58 @@
110 w.WriteHeader(http.StatusNoContent)
111 }
112
113 func (s *Server) handleChannelUsers(w http.ResponseWriter, r *http.Request) {
114 channel := "#" + r.PathValue("channel")
115 users := s.bridge.UsersWithModes(channel)
116 if users == nil {
117 users = []bridge.UserInfo{}
118 }
119 modes := s.bridge.ChannelModes(channel)
120 writeJSON(w, http.StatusOK, map[string]any{"users": users, "channel_modes": modes})
121 }
122
123 func (s *Server) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) {
124 channel := "#" + r.PathValue("channel")
125 if s.policies == nil {
126 writeJSON(w, http.StatusOK, ChannelDisplayConfig{})
127 return
128 }
129 p := s.policies.Get()
130 cfg := p.Bridge.ChannelDisplay[channel]
131 writeJSON(w, http.StatusOK, cfg)
132 }
133
134 func (s *Server) handlePutChannelConfig(w http.ResponseWriter, r *http.Request) {
135 channel := "#" + r.PathValue("channel")
136 if s.policies == nil {
137 writeError(w, http.StatusServiceUnavailable, "policies not configured")
138 return
139 }
140 var cfg ChannelDisplayConfig
141 if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
142 writeError(w, http.StatusBadRequest, "invalid request body")
143 return
144 }
145 p := s.policies.Get()
146 if p.Bridge.ChannelDisplay == nil {
147 p.Bridge.ChannelDisplay = make(map[string]ChannelDisplayConfig)
148 }
149 p.Bridge.ChannelDisplay[channel] = cfg
150 if err := s.policies.Set(p); err != nil {
151 writeError(w, http.StatusInternalServerError, "save failed")
152 return
153 }
154 w.WriteHeader(http.StatusNoContent)
155 }
156
157 // handleChannelStream serves an SSE stream of IRC messages for a channel.
158 // Auth is via ?token= query param because EventSource doesn't support custom headers.
159 func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) {
160 token := r.URL.Query().Get("token")
161 key := s.apiKeys.Lookup(token)
162 if key == nil || (!key.HasScope(auth.ScopeChannels) && !key.HasScope(auth.ScopeChat)) {
163 writeError(w, http.StatusUnauthorized, "invalid or missing token")
164 return
165 }
166
167 channel := "#" + r.PathValue("channel")
168
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -8,10 +8,11 @@
88
"log/slog"
99
"net/http"
1010
"net/http/httptest"
1111
"testing"
1212
13
+ "github.com/conflicthq/scuttlebot/internal/auth"
1314
"github.com/conflicthq/scuttlebot/internal/bots/bridge"
1415
"github.com/conflicthq/scuttlebot/internal/registry"
1516
)
1617
1718
type stubChatBridge struct {
@@ -30,12 +31,14 @@
3031
}
3132
func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil }
3233
func (b *stubChatBridge) SendWithMeta(_ context.Context, _, _, _ string, _ *bridge.Meta) error {
3334
return nil
3435
}
35
-func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} }
36
-func (b *stubChatBridge) Users(string) []string { return nil }
36
+func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} }
37
+func (b *stubChatBridge) Users(string) []string { return nil }
38
+func (b *stubChatBridge) UsersWithModes(string) []bridge.UserInfo { return nil }
39
+func (b *stubChatBridge) ChannelModes(string) string { return "" }
3740
func (b *stubChatBridge) TouchUser(channel, nick string) {
3841
b.touched = append(b.touched, struct{ channel, nick string }{channel: channel, nick: nick})
3942
}
4043
4144
func TestHandleChannelPresence(t *testing.T) {
@@ -42,11 +45,11 @@
4245
t.Helper()
4346
4447
bridgeStub := &stubChatBridge{}
4548
reg := registry.New(nil, []byte("test-signing-key"))
4649
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
47
- srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
50
+ srv := httptest.NewServer(New(reg, auth.TestStore("token"), bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
4851
defer srv.Close()
4952
5053
body, _ := json.Marshal(map[string]string{"nick": "codex-test"})
5154
req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
5255
if err != nil {
@@ -75,11 +78,11 @@
7578
t.Helper()
7679
7780
bridgeStub := &stubChatBridge{}
7881
reg := registry.New(nil, []byte("test-signing-key"))
7982
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
80
- srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
83
+ srv := httptest.NewServer(New(reg, auth.TestStore("token"), bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
8184
defer srv.Close()
8285
8386
body, _ := json.Marshal(map[string]string{})
8487
req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
8588
if err != nil {
8689
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -8,10 +8,11 @@
8 "log/slog"
9 "net/http"
10 "net/http/httptest"
11 "testing"
12
 
13 "github.com/conflicthq/scuttlebot/internal/bots/bridge"
14 "github.com/conflicthq/scuttlebot/internal/registry"
15 )
16
17 type stubChatBridge struct {
@@ -30,12 +31,14 @@
30 }
31 func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil }
32 func (b *stubChatBridge) SendWithMeta(_ context.Context, _, _, _ string, _ *bridge.Meta) error {
33 return nil
34 }
35 func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} }
36 func (b *stubChatBridge) Users(string) []string { return nil }
 
 
37 func (b *stubChatBridge) TouchUser(channel, nick string) {
38 b.touched = append(b.touched, struct{ channel, nick string }{channel: channel, nick: nick})
39 }
40
41 func TestHandleChannelPresence(t *testing.T) {
@@ -42,11 +45,11 @@
42 t.Helper()
43
44 bridgeStub := &stubChatBridge{}
45 reg := registry.New(nil, []byte("test-signing-key"))
46 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
47 srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
48 defer srv.Close()
49
50 body, _ := json.Marshal(map[string]string{"nick": "codex-test"})
51 req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
52 if err != nil {
@@ -75,11 +78,11 @@
75 t.Helper()
76
77 bridgeStub := &stubChatBridge{}
78 reg := registry.New(nil, []byte("test-signing-key"))
79 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
80 srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
81 defer srv.Close()
82
83 body, _ := json.Marshal(map[string]string{})
84 req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
85 if err != nil {
86
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -8,10 +8,11 @@
8 "log/slog"
9 "net/http"
10 "net/http/httptest"
11 "testing"
12
13 "github.com/conflicthq/scuttlebot/internal/auth"
14 "github.com/conflicthq/scuttlebot/internal/bots/bridge"
15 "github.com/conflicthq/scuttlebot/internal/registry"
16 )
17
18 type stubChatBridge struct {
@@ -30,12 +31,14 @@
31 }
32 func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil }
33 func (b *stubChatBridge) SendWithMeta(_ context.Context, _, _, _ string, _ *bridge.Meta) error {
34 return nil
35 }
36 func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} }
37 func (b *stubChatBridge) Users(string) []string { return nil }
38 func (b *stubChatBridge) UsersWithModes(string) []bridge.UserInfo { return nil }
39 func (b *stubChatBridge) ChannelModes(string) string { return "" }
40 func (b *stubChatBridge) TouchUser(channel, nick string) {
41 b.touched = append(b.touched, struct{ channel, nick string }{channel: channel, nick: nick})
42 }
43
44 func TestHandleChannelPresence(t *testing.T) {
@@ -42,11 +45,11 @@
45 t.Helper()
46
47 bridgeStub := &stubChatBridge{}
48 reg := registry.New(nil, []byte("test-signing-key"))
49 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
50 srv := httptest.NewServer(New(reg, auth.TestStore("token"), bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
51 defer srv.Close()
52
53 body, _ := json.Marshal(map[string]string{"nick": "codex-test"})
54 req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
55 if err != nil {
@@ -75,11 +78,11 @@
78 t.Helper()
79
80 bridgeStub := &stubChatBridge{}
81 reg := registry.New(nil, []byte("test-signing-key"))
82 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
83 srv := httptest.NewServer(New(reg, auth.TestStore("token"), bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
84 defer srv.Close()
85
86 body, _ := json.Marshal(map[string]string{})
87 req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
88 if err != nil {
89
--- internal/api/config_handlers_test.go
+++ internal/api/config_handlers_test.go
@@ -9,10 +9,11 @@
99
"net/http/httptest"
1010
"path/filepath"
1111
"testing"
1212
"time"
1313
14
+ "github.com/conflicthq/scuttlebot/internal/auth"
1415
"github.com/conflicthq/scuttlebot/internal/config"
1516
"github.com/conflicthq/scuttlebot/internal/registry"
1617
)
1718
1819
func newCfgTestServer(t *testing.T) (*httptest.Server, *ConfigStore) {
@@ -25,11 +26,11 @@
2526
cfg.Ergo.DataDir = dir
2627
2728
store := NewConfigStore(path, cfg)
2829
reg := registry.New(nil, []byte("key"))
2930
log := slog.New(slog.NewTextHandler(io.Discard, nil))
30
- srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, nil, store, "", log).Handler())
31
+ srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, nil, store, "", log).Handler())
3132
t.Cleanup(srv.Close)
3233
return srv, store
3334
}
3435
3536
func TestHandleGetConfig(t *testing.T) {
3637
--- internal/api/config_handlers_test.go
+++ internal/api/config_handlers_test.go
@@ -9,10 +9,11 @@
9 "net/http/httptest"
10 "path/filepath"
11 "testing"
12 "time"
13
 
14 "github.com/conflicthq/scuttlebot/internal/config"
15 "github.com/conflicthq/scuttlebot/internal/registry"
16 )
17
18 func newCfgTestServer(t *testing.T) (*httptest.Server, *ConfigStore) {
@@ -25,11 +26,11 @@
25 cfg.Ergo.DataDir = dir
26
27 store := NewConfigStore(path, cfg)
28 reg := registry.New(nil, []byte("key"))
29 log := slog.New(slog.NewTextHandler(io.Discard, nil))
30 srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, nil, store, "", log).Handler())
31 t.Cleanup(srv.Close)
32 return srv, store
33 }
34
35 func TestHandleGetConfig(t *testing.T) {
36
--- internal/api/config_handlers_test.go
+++ internal/api/config_handlers_test.go
@@ -9,10 +9,11 @@
9 "net/http/httptest"
10 "path/filepath"
11 "testing"
12 "time"
13
14 "github.com/conflicthq/scuttlebot/internal/auth"
15 "github.com/conflicthq/scuttlebot/internal/config"
16 "github.com/conflicthq/scuttlebot/internal/registry"
17 )
18
19 func newCfgTestServer(t *testing.T) (*httptest.Server, *ConfigStore) {
@@ -25,11 +26,11 @@
26 cfg.Ergo.DataDir = dir
27
28 store := NewConfigStore(path, cfg)
29 reg := registry.New(nil, []byte("key"))
30 log := slog.New(slog.NewTextHandler(io.Discard, nil))
31 srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, nil, store, "", log).Handler())
32 t.Cleanup(srv.Close)
33 return srv, store
34 }
35
36 func TestHandleGetConfig(t *testing.T) {
37
--- internal/api/login.go
+++ internal/api/login.go
@@ -93,15 +93,17 @@
9393
if !s.admins.Authenticate(req.Username, req.Password) {
9494
writeError(w, http.StatusUnauthorized, "invalid credentials")
9595
return
9696
}
9797
98
- // Return the first API token — the shared server token.
99
- var token string
100
- for t := range s.tokens {
101
- token = t
102
- break
98
+ // Create a session API key for this admin login.
99
+ sessionName := "session:" + req.Username
100
+ token, _, err := s.apiKeys.Create(sessionName, []auth.Scope{auth.ScopeAdmin}, time.Now().Add(24*time.Hour))
101
+ if err != nil {
102
+ s.log.Error("login: create session key", "err", err)
103
+ writeError(w, http.StatusInternalServerError, "failed to create session")
104
+ return
103105
}
104106
105107
writeJSON(w, http.StatusOK, map[string]string{
106108
"token": token,
107109
"username": req.Username,
108110
--- internal/api/login.go
+++ internal/api/login.go
@@ -93,15 +93,17 @@
93 if !s.admins.Authenticate(req.Username, req.Password) {
94 writeError(w, http.StatusUnauthorized, "invalid credentials")
95 return
96 }
97
98 // Return the first API token — the shared server token.
99 var token string
100 for t := range s.tokens {
101 token = t
102 break
 
 
103 }
104
105 writeJSON(w, http.StatusOK, map[string]string{
106 "token": token,
107 "username": req.Username,
108
--- internal/api/login.go
+++ internal/api/login.go
@@ -93,15 +93,17 @@
93 if !s.admins.Authenticate(req.Username, req.Password) {
94 writeError(w, http.StatusUnauthorized, "invalid credentials")
95 return
96 }
97
98 // Create a session API key for this admin login.
99 sessionName := "session:" + req.Username
100 token, _, err := s.apiKeys.Create(sessionName, []auth.Scope{auth.ScopeAdmin}, time.Now().Add(24*time.Hour))
101 if err != nil {
102 s.log.Error("login: create session key", "err", err)
103 writeError(w, http.StatusInternalServerError, "failed to create session")
104 return
105 }
106
107 writeJSON(w, http.StatusOK, map[string]string{
108 "token": token,
109 "username": req.Username,
110
--- internal/api/login_test.go
+++ internal/api/login_test.go
@@ -28,18 +28,18 @@
2828
admins := newAdminStore(t)
2929
if err := admins.Add("admin", "hunter2"); err != nil {
3030
t.Fatalf("Add admin: %v", err)
3131
}
3232
reg := registry.New(newMock(), []byte("test-signing-key"))
33
- srv := api.New(reg, []string{testToken}, nil, nil, admins, nil, nil, nil, "", testLog)
33
+ srv := api.New(reg, auth.TestStore(testToken), nil, nil, admins, nil, nil, nil, "", testLog)
3434
return httptest.NewServer(srv.Handler()), admins
3535
}
3636
3737
func TestLoginNoAdmins(t *testing.T) {
3838
// When admins is nil, login returns 404.
3939
reg := registry.New(newMock(), []byte("test-signing-key"))
40
- srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog)
40
+ srv := api.New(reg, auth.TestStore(testToken), nil, nil, nil, nil, nil, nil, "", testLog)
4141
ts := httptest.NewServer(srv.Handler())
4242
defer ts.Close()
4343
4444
resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil)
4545
defer resp.Body.Close()
4646
--- internal/api/login_test.go
+++ internal/api/login_test.go
@@ -28,18 +28,18 @@
28 admins := newAdminStore(t)
29 if err := admins.Add("admin", "hunter2"); err != nil {
30 t.Fatalf("Add admin: %v", err)
31 }
32 reg := registry.New(newMock(), []byte("test-signing-key"))
33 srv := api.New(reg, []string{testToken}, nil, nil, admins, nil, nil, nil, "", testLog)
34 return httptest.NewServer(srv.Handler()), admins
35 }
36
37 func TestLoginNoAdmins(t *testing.T) {
38 // When admins is nil, login returns 404.
39 reg := registry.New(newMock(), []byte("test-signing-key"))
40 srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog)
41 ts := httptest.NewServer(srv.Handler())
42 defer ts.Close()
43
44 resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil)
45 defer resp.Body.Close()
46
--- internal/api/login_test.go
+++ internal/api/login_test.go
@@ -28,18 +28,18 @@
28 admins := newAdminStore(t)
29 if err := admins.Add("admin", "hunter2"); err != nil {
30 t.Fatalf("Add admin: %v", err)
31 }
32 reg := registry.New(newMock(), []byte("test-signing-key"))
33 srv := api.New(reg, auth.TestStore(testToken), nil, nil, admins, nil, nil, nil, "", testLog)
34 return httptest.NewServer(srv.Handler()), admins
35 }
36
37 func TestLoginNoAdmins(t *testing.T) {
38 // When admins is nil, login returns 404.
39 reg := registry.New(newMock(), []byte("test-signing-key"))
40 srv := api.New(reg, auth.TestStore(testToken), nil, nil, nil, nil, nil, nil, "", testLog)
41 ts := httptest.NewServer(srv.Handler())
42 defer ts.Close()
43
44 resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil)
45 defer resp.Body.Close()
46
--- internal/api/middleware.go
+++ internal/api/middleware.go
@@ -1,26 +1,62 @@
11
package api
22
33
import (
4
+ "context"
45
"net/http"
56
"strings"
7
+
8
+ "github.com/conflicthq/scuttlebot/internal/auth"
69
)
710
11
+type ctxKey string
12
+
13
+const ctxAPIKey ctxKey = "apikey"
14
+
15
+// apiKeyFromContext returns the authenticated APIKey from the request context,
16
+// or nil if not authenticated.
17
+func apiKeyFromContext(ctx context.Context) *auth.APIKey {
18
+ k, _ := ctx.Value(ctxAPIKey).(*auth.APIKey)
19
+ return k
20
+}
21
+
22
+// authMiddleware validates the Bearer token and injects the APIKey into context.
823
func (s *Server) authMiddleware(next http.Handler) http.Handler {
924
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1025
token := bearerToken(r)
1126
if token == "" {
1227
writeError(w, http.StatusUnauthorized, "missing authorization header")
1328
return
1429
}
15
- if _, ok := s.tokens[token]; !ok {
30
+ key := s.apiKeys.Lookup(token)
31
+ if key == nil {
1632
writeError(w, http.StatusUnauthorized, "invalid token")
1733
return
1834
}
19
- next.ServeHTTP(w, r)
35
+ // Update last-used timestamp in the background.
36
+ go s.apiKeys.TouchLastUsed(key.ID)
37
+
38
+ ctx := context.WithValue(r.Context(), ctxAPIKey, key)
39
+ next.ServeHTTP(w, r.WithContext(ctx))
2040
})
2141
}
42
+
43
+// requireScope returns middleware that rejects requests without the given scope.
44
+func (s *Server) requireScope(scope auth.Scope, next http.HandlerFunc) http.HandlerFunc {
45
+ return func(w http.ResponseWriter, r *http.Request) {
46
+ key := apiKeyFromContext(r.Context())
47
+ if key == nil {
48
+ writeError(w, http.StatusUnauthorized, "missing authentication")
49
+ return
50
+ }
51
+ if !key.HasScope(scope) {
52
+ writeError(w, http.StatusForbidden, "insufficient scope: requires "+string(scope))
53
+ return
54
+ }
55
+ next(w, r)
56
+ }
57
+}
2258
2359
func bearerToken(r *http.Request) string {
2460
auth := r.Header.Get("Authorization")
2561
token, found := strings.CutPrefix(auth, "Bearer ")
2662
if !found {
2763
--- internal/api/middleware.go
+++ internal/api/middleware.go
@@ -1,26 +1,62 @@
1 package api
2
3 import (
 
4 "net/http"
5 "strings"
 
 
6 )
7
 
 
 
 
 
 
 
 
 
 
 
 
8 func (s *Server) authMiddleware(next http.Handler) http.Handler {
9 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
10 token := bearerToken(r)
11 if token == "" {
12 writeError(w, http.StatusUnauthorized, "missing authorization header")
13 return
14 }
15 if _, ok := s.tokens[token]; !ok {
 
16 writeError(w, http.StatusUnauthorized, "invalid token")
17 return
18 }
19 next.ServeHTTP(w, r)
 
 
 
 
20 })
21 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
23 func bearerToken(r *http.Request) string {
24 auth := r.Header.Get("Authorization")
25 token, found := strings.CutPrefix(auth, "Bearer ")
26 if !found {
27
--- internal/api/middleware.go
+++ internal/api/middleware.go
@@ -1,26 +1,62 @@
1 package api
2
3 import (
4 "context"
5 "net/http"
6 "strings"
7
8 "github.com/conflicthq/scuttlebot/internal/auth"
9 )
10
11 type ctxKey string
12
13 const ctxAPIKey ctxKey = "apikey"
14
15 // apiKeyFromContext returns the authenticated APIKey from the request context,
16 // or nil if not authenticated.
17 func apiKeyFromContext(ctx context.Context) *auth.APIKey {
18 k, _ := ctx.Value(ctxAPIKey).(*auth.APIKey)
19 return k
20 }
21
22 // authMiddleware validates the Bearer token and injects the APIKey into context.
23 func (s *Server) authMiddleware(next http.Handler) http.Handler {
24 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25 token := bearerToken(r)
26 if token == "" {
27 writeError(w, http.StatusUnauthorized, "missing authorization header")
28 return
29 }
30 key := s.apiKeys.Lookup(token)
31 if key == nil {
32 writeError(w, http.StatusUnauthorized, "invalid token")
33 return
34 }
35 // Update last-used timestamp in the background.
36 go s.apiKeys.TouchLastUsed(key.ID)
37
38 ctx := context.WithValue(r.Context(), ctxAPIKey, key)
39 next.ServeHTTP(w, r.WithContext(ctx))
40 })
41 }
42
43 // requireScope returns middleware that rejects requests without the given scope.
44 func (s *Server) requireScope(scope auth.Scope, next http.HandlerFunc) http.HandlerFunc {
45 return func(w http.ResponseWriter, r *http.Request) {
46 key := apiKeyFromContext(r.Context())
47 if key == nil {
48 writeError(w, http.StatusUnauthorized, "missing authentication")
49 return
50 }
51 if !key.HasScope(scope) {
52 writeError(w, http.StatusForbidden, "insufficient scope: requires "+string(scope))
53 return
54 }
55 next(w, r)
56 }
57 }
58
59 func bearerToken(r *http.Request) string {
60 auth := r.Header.Get("Authorization")
61 token, found := strings.CutPrefix(auth, "Bearer ")
62 if !found {
63
--- internal/api/policies.go
+++ internal/api/policies.go
@@ -42,16 +42,24 @@
4242
Rotation string `json:"rotation"` // "none" | "daily" | "weekly" | "size"
4343
MaxSizeMB int `json:"max_size_mb"` // size rotation threshold (MiB); 0 = unlimited
4444
PerChannel bool `json:"per_channel"` // separate file per channel
4545
MaxAgeDays int `json:"max_age_days"` // prune rotated files older than N days; 0 = keep all
4646
}
47
+
48
+// ChannelDisplayConfig holds per-channel rendering preferences.
49
+type ChannelDisplayConfig struct {
50
+ MirrorDetail string `json:"mirror_detail,omitempty"` // "full", "compact", "minimal"
51
+ RenderMode string `json:"render_mode,omitempty"` // "rich", "text"
52
+}
4753
4854
// BridgePolicy configures bridge-specific UI/relay behavior.
4955
type BridgePolicy struct {
5056
// WebUserTTLMinutes controls how long HTTP bridge sender nicks remain
5157
// visible in the channel user list after their last post.
5258
WebUserTTLMinutes int `json:"web_user_ttl_minutes"`
59
+ // ChannelDisplay holds per-channel rendering config.
60
+ ChannelDisplay map[string]ChannelDisplayConfig `json:"channel_display,omitempty"`
5361
}
5462
5563
// PolicyLLMBackend stores an LLM backend configuration in the policy store.
5664
// This allows backends to be added and edited from the web UI rather than
5765
// requiring a change to scuttlebot.yaml.
@@ -68,18 +76,32 @@
6876
AWSSecretKey string `json:"aws_secret_key,omitempty"`
6977
Allow []string `json:"allow,omitempty"`
7078
Block []string `json:"block,omitempty"`
7179
Default bool `json:"default,omitempty"`
7280
}
81
+
82
+// ROETemplate is a rules-of-engagement template.
83
+type ROETemplate struct {
84
+ Name string `json:"name"`
85
+ Description string `json:"description,omitempty"`
86
+ Channels []string `json:"channels,omitempty"`
87
+ Permissions []string `json:"permissions,omitempty"`
88
+ RateLimit struct {
89
+ MessagesPerSecond float64 `json:"messages_per_second,omitempty"`
90
+ Burst int `json:"burst,omitempty"`
91
+ } `json:"rate_limit,omitempty"`
92
+}
7393
7494
// Policies is the full mutable settings blob, persisted to policies.json.
7595
type Policies struct {
76
- Behaviors []BehaviorConfig `json:"behaviors"`
77
- AgentPolicy AgentPolicy `json:"agent_policy"`
78
- Bridge BridgePolicy `json:"bridge"`
79
- Logging LoggingPolicy `json:"logging"`
80
- LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"`
96
+ Behaviors []BehaviorConfig `json:"behaviors"`
97
+ AgentPolicy AgentPolicy `json:"agent_policy"`
98
+ Bridge BridgePolicy `json:"bridge"`
99
+ Logging LoggingPolicy `json:"logging"`
100
+ LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"`
101
+ ROETemplates []ROETemplate `json:"roe_templates,omitempty"`
102
+ OnJoinMessages map[string]string `json:"on_join_messages,omitempty"` // channel → message template
81103
}
82104
83105
// defaultBehaviors lists every built-in bot with conservative defaults (disabled).
84106
var defaultBehaviors = []BehaviorConfig{
85107
{
@@ -151,10 +173,42 @@
151173
Description: "Acts on sentinel incident reports — issues warnings, mutes, or kicks based on severity. Operators can also issue direct commands via DM.",
152174
Nick: "steward",
153175
JoinAllChannels: true,
154176
},
155177
}
178
+
179
+// BotCommand describes a single command a bot responds to.
180
+type BotCommand struct {
181
+ Command string `json:"command"`
182
+ Usage string `json:"usage"`
183
+ Description string `json:"description"`
184
+}
185
+
186
+// botCommands maps bot ID to its available commands.
187
+var botCommands = map[string][]BotCommand{
188
+ "oracle": {
189
+ {Command: "summarize", Usage: "summarize #channel [last=N] [format=toon|json]", Description: "Summarize recent channel activity using an LLM."},
190
+ },
191
+ "scroll": {
192
+ {Command: "replay", Usage: "replay #channel [last=N] [since=<unix_ms>]", Description: "Replay recent channel history via DM."},
193
+ },
194
+ "steward": {
195
+ {Command: "mute", Usage: "mute <nick> [duration]", Description: "Mute a nick in the current channel."},
196
+ {Command: "unmute", Usage: "unmute <nick>", Description: "Remove mute from a nick."},
197
+ {Command: "kick", Usage: "kick <nick> [reason]", Description: "Kick a nick from the current channel."},
198
+ {Command: "warn", Usage: "warn <nick> <message>", Description: "Send a warning notice to a nick."},
199
+ },
200
+ "warden": {
201
+ {Command: "status", Usage: "status", Description: "Show warden rate-limit status for all tracked nicks."},
202
+ },
203
+ "snitch": {
204
+ {Command: "status", Usage: "status", Description: "Show snitch monitoring status and alert history."},
205
+ },
206
+ "herald": {
207
+ {Command: "announce", Usage: "announce #channel <message>", Description: "Post an announcement to a channel."},
208
+ },
209
+}
156210
157211
// PolicyStore persists Policies to a JSON file or database.
158212
type PolicyStore struct {
159213
mu sync.RWMutex
160214
path string
@@ -227,10 +281,12 @@
227281
}
228282
ps.data.AgentPolicy = p.AgentPolicy
229283
ps.data.Bridge = p.Bridge
230284
ps.data.Logging = p.Logging
231285
ps.data.LLMBackends = p.LLMBackends
286
+ ps.data.ROETemplates = p.ROETemplates
287
+ ps.data.OnJoinMessages = p.OnJoinMessages
232288
return nil
233289
}
234290
235291
func (ps *PolicyStore) save() error {
236292
raw, err := json.MarshalIndent(ps.data, "", " ")
@@ -337,10 +393,20 @@
337393
338394
// Merge LLM backends if provided.
339395
if patch.LLMBackends != nil {
340396
ps.data.LLMBackends = patch.LLMBackends
341397
}
398
+
399
+ // Merge ROE templates if provided.
400
+ if patch.ROETemplates != nil {
401
+ ps.data.ROETemplates = patch.ROETemplates
402
+ }
403
+
404
+ // Merge on-join messages if provided.
405
+ if patch.OnJoinMessages != nil {
406
+ ps.data.OnJoinMessages = patch.OnJoinMessages
407
+ }
342408
343409
ps.normalize(&ps.data)
344410
if err := ps.save(); err != nil {
345411
return err
346412
}
347413
--- internal/api/policies.go
+++ internal/api/policies.go
@@ -42,16 +42,24 @@
42 Rotation string `json:"rotation"` // "none" | "daily" | "weekly" | "size"
43 MaxSizeMB int `json:"max_size_mb"` // size rotation threshold (MiB); 0 = unlimited
44 PerChannel bool `json:"per_channel"` // separate file per channel
45 MaxAgeDays int `json:"max_age_days"` // prune rotated files older than N days; 0 = keep all
46 }
 
 
 
 
 
 
47
48 // BridgePolicy configures bridge-specific UI/relay behavior.
49 type BridgePolicy struct {
50 // WebUserTTLMinutes controls how long HTTP bridge sender nicks remain
51 // visible in the channel user list after their last post.
52 WebUserTTLMinutes int `json:"web_user_ttl_minutes"`
 
 
53 }
54
55 // PolicyLLMBackend stores an LLM backend configuration in the policy store.
56 // This allows backends to be added and edited from the web UI rather than
57 // requiring a change to scuttlebot.yaml.
@@ -68,18 +76,32 @@
68 AWSSecretKey string `json:"aws_secret_key,omitempty"`
69 Allow []string `json:"allow,omitempty"`
70 Block []string `json:"block,omitempty"`
71 Default bool `json:"default,omitempty"`
72 }
 
 
 
 
 
 
 
 
 
 
 
 
73
74 // Policies is the full mutable settings blob, persisted to policies.json.
75 type Policies struct {
76 Behaviors []BehaviorConfig `json:"behaviors"`
77 AgentPolicy AgentPolicy `json:"agent_policy"`
78 Bridge BridgePolicy `json:"bridge"`
79 Logging LoggingPolicy `json:"logging"`
80 LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"`
 
 
81 }
82
83 // defaultBehaviors lists every built-in bot with conservative defaults (disabled).
84 var defaultBehaviors = []BehaviorConfig{
85 {
@@ -151,10 +173,42 @@
151 Description: "Acts on sentinel incident reports — issues warnings, mutes, or kicks based on severity. Operators can also issue direct commands via DM.",
152 Nick: "steward",
153 JoinAllChannels: true,
154 },
155 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
157 // PolicyStore persists Policies to a JSON file or database.
158 type PolicyStore struct {
159 mu sync.RWMutex
160 path string
@@ -227,10 +281,12 @@
227 }
228 ps.data.AgentPolicy = p.AgentPolicy
229 ps.data.Bridge = p.Bridge
230 ps.data.Logging = p.Logging
231 ps.data.LLMBackends = p.LLMBackends
 
 
232 return nil
233 }
234
235 func (ps *PolicyStore) save() error {
236 raw, err := json.MarshalIndent(ps.data, "", " ")
@@ -337,10 +393,20 @@
337
338 // Merge LLM backends if provided.
339 if patch.LLMBackends != nil {
340 ps.data.LLMBackends = patch.LLMBackends
341 }
 
 
 
 
 
 
 
 
 
 
342
343 ps.normalize(&ps.data)
344 if err := ps.save(); err != nil {
345 return err
346 }
347
--- internal/api/policies.go
+++ internal/api/policies.go
@@ -42,16 +42,24 @@
42 Rotation string `json:"rotation"` // "none" | "daily" | "weekly" | "size"
43 MaxSizeMB int `json:"max_size_mb"` // size rotation threshold (MiB); 0 = unlimited
44 PerChannel bool `json:"per_channel"` // separate file per channel
45 MaxAgeDays int `json:"max_age_days"` // prune rotated files older than N days; 0 = keep all
46 }
47
48 // ChannelDisplayConfig holds per-channel rendering preferences.
49 type ChannelDisplayConfig struct {
50 MirrorDetail string `json:"mirror_detail,omitempty"` // "full", "compact", "minimal"
51 RenderMode string `json:"render_mode,omitempty"` // "rich", "text"
52 }
53
54 // BridgePolicy configures bridge-specific UI/relay behavior.
55 type BridgePolicy struct {
56 // WebUserTTLMinutes controls how long HTTP bridge sender nicks remain
57 // visible in the channel user list after their last post.
58 WebUserTTLMinutes int `json:"web_user_ttl_minutes"`
59 // ChannelDisplay holds per-channel rendering config.
60 ChannelDisplay map[string]ChannelDisplayConfig `json:"channel_display,omitempty"`
61 }
62
63 // PolicyLLMBackend stores an LLM backend configuration in the policy store.
64 // This allows backends to be added and edited from the web UI rather than
65 // requiring a change to scuttlebot.yaml.
@@ -68,18 +76,32 @@
76 AWSSecretKey string `json:"aws_secret_key,omitempty"`
77 Allow []string `json:"allow,omitempty"`
78 Block []string `json:"block,omitempty"`
79 Default bool `json:"default,omitempty"`
80 }
81
82 // ROETemplate is a rules-of-engagement template.
83 type ROETemplate struct {
84 Name string `json:"name"`
85 Description string `json:"description,omitempty"`
86 Channels []string `json:"channels,omitempty"`
87 Permissions []string `json:"permissions,omitempty"`
88 RateLimit struct {
89 MessagesPerSecond float64 `json:"messages_per_second,omitempty"`
90 Burst int `json:"burst,omitempty"`
91 } `json:"rate_limit,omitempty"`
92 }
93
94 // Policies is the full mutable settings blob, persisted to policies.json.
95 type Policies struct {
96 Behaviors []BehaviorConfig `json:"behaviors"`
97 AgentPolicy AgentPolicy `json:"agent_policy"`
98 Bridge BridgePolicy `json:"bridge"`
99 Logging LoggingPolicy `json:"logging"`
100 LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"`
101 ROETemplates []ROETemplate `json:"roe_templates,omitempty"`
102 OnJoinMessages map[string]string `json:"on_join_messages,omitempty"` // channel → message template
103 }
104
105 // defaultBehaviors lists every built-in bot with conservative defaults (disabled).
106 var defaultBehaviors = []BehaviorConfig{
107 {
@@ -151,10 +173,42 @@
173 Description: "Acts on sentinel incident reports — issues warnings, mutes, or kicks based on severity. Operators can also issue direct commands via DM.",
174 Nick: "steward",
175 JoinAllChannels: true,
176 },
177 }
178
179 // BotCommand describes a single command a bot responds to.
180 type BotCommand struct {
181 Command string `json:"command"`
182 Usage string `json:"usage"`
183 Description string `json:"description"`
184 }
185
186 // botCommands maps bot ID to its available commands.
187 var botCommands = map[string][]BotCommand{
188 "oracle": {
189 {Command: "summarize", Usage: "summarize #channel [last=N] [format=toon|json]", Description: "Summarize recent channel activity using an LLM."},
190 },
191 "scroll": {
192 {Command: "replay", Usage: "replay #channel [last=N] [since=<unix_ms>]", Description: "Replay recent channel history via DM."},
193 },
194 "steward": {
195 {Command: "mute", Usage: "mute <nick> [duration]", Description: "Mute a nick in the current channel."},
196 {Command: "unmute", Usage: "unmute <nick>", Description: "Remove mute from a nick."},
197 {Command: "kick", Usage: "kick <nick> [reason]", Description: "Kick a nick from the current channel."},
198 {Command: "warn", Usage: "warn <nick> <message>", Description: "Send a warning notice to a nick."},
199 },
200 "warden": {
201 {Command: "status", Usage: "status", Description: "Show warden rate-limit status for all tracked nicks."},
202 },
203 "snitch": {
204 {Command: "status", Usage: "status", Description: "Show snitch monitoring status and alert history."},
205 },
206 "herald": {
207 {Command: "announce", Usage: "announce #channel <message>", Description: "Post an announcement to a channel."},
208 },
209 }
210
211 // PolicyStore persists Policies to a JSON file or database.
212 type PolicyStore struct {
213 mu sync.RWMutex
214 path string
@@ -227,10 +281,12 @@
281 }
282 ps.data.AgentPolicy = p.AgentPolicy
283 ps.data.Bridge = p.Bridge
284 ps.data.Logging = p.Logging
285 ps.data.LLMBackends = p.LLMBackends
286 ps.data.ROETemplates = p.ROETemplates
287 ps.data.OnJoinMessages = p.OnJoinMessages
288 return nil
289 }
290
291 func (ps *PolicyStore) save() error {
292 raw, err := json.MarshalIndent(ps.data, "", " ")
@@ -337,10 +393,20 @@
393
394 // Merge LLM backends if provided.
395 if patch.LLMBackends != nil {
396 ps.data.LLMBackends = patch.LLMBackends
397 }
398
399 // Merge ROE templates if provided.
400 if patch.ROETemplates != nil {
401 ps.data.ROETemplates = patch.ROETemplates
402 }
403
404 // Merge on-join messages if provided.
405 if patch.OnJoinMessages != nil {
406 ps.data.OnJoinMessages = patch.OnJoinMessages
407 }
408
409 ps.normalize(&ps.data)
410 if err := ps.save(); err != nil {
411 return err
412 }
413
--- internal/api/server.go
+++ internal/api/server.go
@@ -7,18 +7,19 @@
77
88
import (
99
"log/slog"
1010
"net/http"
1111
12
+ "github.com/conflicthq/scuttlebot/internal/auth"
1213
"github.com/conflicthq/scuttlebot/internal/config"
1314
"github.com/conflicthq/scuttlebot/internal/registry"
1415
)
1516
1617
// Server is the scuttlebot HTTP API server.
1718
type Server struct {
1819
registry *registry.Registry
19
- tokens map[string]struct{}
20
+ apiKeys *auth.APIKeyStore
2021
log *slog.Logger
2122
bridge chatBridge // nil if bridge is disabled
2223
policies *PolicyStore // nil if not configured
2324
admins adminStore // nil if not configured
2425
llmCfg *config.LLMConfig // nil if no LLM backends configured
@@ -31,18 +32,14 @@
3132
// New creates a new API Server. Pass nil for b to disable the chat bridge.
3233
// Pass nil for admins to disable admin authentication endpoints.
3334
// Pass nil for llmCfg to disable AI/LLM management endpoints.
3435
// Pass nil for topo to disable topology provisioning endpoints.
3536
// Pass nil for cfgStore to disable config read/write endpoints.
36
-func New(reg *registry.Registry, tokens []string, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, cfgStore *ConfigStore, tlsDomain string, log *slog.Logger) *Server {
37
- tokenSet := make(map[string]struct{}, len(tokens))
38
- for _, t := range tokens {
39
- tokenSet[t] = struct{}{}
40
- }
37
+func New(reg *registry.Registry, apiKeys *auth.APIKeyStore, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, cfgStore *ConfigStore, tlsDomain string, log *slog.Logger) *Server {
4138
return &Server{
4239
registry: reg,
43
- tokens: tokenSet,
40
+ apiKeys: apiKeys,
4441
log: log,
4542
bridge: b,
4643
policies: ps,
4744
admins: admins,
4845
llmCfg: llmCfg,
@@ -53,65 +50,86 @@
5350
}
5451
}
5552
5653
// Handler returns the HTTP handler with all routes registered.
5754
// /v1/ routes require a valid Bearer token. /ui/ is served unauthenticated.
55
+// Scoped routes additionally check the API key's scopes.
5856
func (s *Server) Handler() http.Handler {
5957
apiMux := http.NewServeMux()
60
- apiMux.HandleFunc("GET /v1/status", s.handleStatus)
61
- apiMux.HandleFunc("GET /v1/metrics", s.handleMetrics)
62
- if s.policies != nil {
63
- apiMux.HandleFunc("GET /v1/settings", s.handleGetSettings)
64
- apiMux.HandleFunc("GET /v1/settings/policies", s.handleGetPolicies)
65
- apiMux.HandleFunc("PUT /v1/settings/policies", s.handlePutPolicies)
66
- apiMux.HandleFunc("PATCH /v1/settings/policies", s.handlePatchPolicies)
67
- }
68
- apiMux.HandleFunc("GET /v1/agents", s.handleListAgents)
69
- apiMux.HandleFunc("GET /v1/agents/{nick}", s.handleGetAgent)
70
- apiMux.HandleFunc("PATCH /v1/agents/{nick}", s.handleUpdateAgent)
71
- apiMux.HandleFunc("POST /v1/agents/register", s.handleRegister)
72
- apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.handleRotate)
73
- apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.handleAdopt)
74
- apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.handleRevoke)
75
- apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.handleDelete)
76
- if s.bridge != nil {
77
- apiMux.HandleFunc("GET /v1/channels", s.handleListChannels)
78
- apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.handleJoinChannel)
79
- apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.handleDeleteChannel)
80
- apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.handleChannelMessages)
81
- apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.handleSendMessage)
82
- apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.handleChannelPresence)
83
- apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.handleChannelUsers)
84
- }
85
- if s.topoMgr != nil {
86
- apiMux.HandleFunc("POST /v1/channels", s.handleProvisionChannel)
87
- apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.handleDropChannel)
88
- apiMux.HandleFunc("GET /v1/topology", s.handleGetTopology)
89
- }
90
- if s.cfgStore != nil {
91
- apiMux.HandleFunc("GET /v1/config", s.handleGetConfig)
92
- apiMux.HandleFunc("PUT /v1/config", s.handlePutConfig)
93
- apiMux.HandleFunc("GET /v1/config/history", s.handleGetConfigHistory)
94
- apiMux.HandleFunc("GET /v1/config/history/{filename}", s.handleGetConfigHistoryEntry)
95
- }
96
-
97
- if s.admins != nil {
98
- apiMux.HandleFunc("GET /v1/admins", s.handleAdminList)
99
- apiMux.HandleFunc("POST /v1/admins", s.handleAdminAdd)
100
- apiMux.HandleFunc("DELETE /v1/admins/{username}", s.handleAdminRemove)
101
- apiMux.HandleFunc("PUT /v1/admins/{username}/password", s.handleAdminSetPassword)
102
- }
103
-
104
- // LLM / AI gateway endpoints.
105
- apiMux.HandleFunc("GET /v1/llm/backends", s.handleLLMBackends)
106
- apiMux.HandleFunc("POST /v1/llm/backends", s.handleLLMBackendCreate)
107
- apiMux.HandleFunc("PUT /v1/llm/backends/{name}", s.handleLLMBackendUpdate)
108
- apiMux.HandleFunc("DELETE /v1/llm/backends/{name}", s.handleLLMBackendDelete)
109
- apiMux.HandleFunc("GET /v1/llm/backends/{name}/models", s.handleLLMModels)
110
- apiMux.HandleFunc("POST /v1/llm/discover", s.handleLLMDiscover)
111
- apiMux.HandleFunc("GET /v1/llm/known", s.handleLLMKnown)
112
- apiMux.HandleFunc("POST /v1/llm/complete", s.handleLLMComplete)
58
+
59
+ // Read-scope: status, metrics (also accessible with any scope via admin).
60
+ apiMux.HandleFunc("GET /v1/status", s.requireScope(auth.ScopeRead, s.handleStatus))
61
+ apiMux.HandleFunc("GET /v1/metrics", s.requireScope(auth.ScopeRead, s.handleMetrics))
62
+
63
+ // Policies — admin scope.
64
+ if s.policies != nil {
65
+ apiMux.HandleFunc("GET /v1/settings", s.requireScope(auth.ScopeRead, s.handleGetSettings))
66
+ apiMux.HandleFunc("GET /v1/settings/policies", s.requireScope(auth.ScopeRead, s.handleGetPolicies))
67
+ apiMux.HandleFunc("PUT /v1/settings/policies", s.requireScope(auth.ScopeAdmin, s.handlePutPolicies))
68
+ apiMux.HandleFunc("PATCH /v1/settings/policies", s.requireScope(auth.ScopeAdmin, s.handlePatchPolicies))
69
+ }
70
+
71
+ // Agents — agents scope.
72
+ apiMux.HandleFunc("GET /v1/agents", s.requireScope(auth.ScopeAgents, s.handleListAgents))
73
+ apiMux.HandleFunc("GET /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleGetAgent))
74
+ apiMux.HandleFunc("PATCH /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleUpdateAgent))
75
+ apiMux.HandleFunc("POST /v1/agents/register", s.requireScope(auth.ScopeAgents, s.handleRegister))
76
+ apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.requireScope(auth.ScopeAgents, s.handleRotate))
77
+ apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.requireScope(auth.ScopeAgents, s.handleAdopt))
78
+ apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.requireScope(auth.ScopeAgents, s.handleRevoke))
79
+ apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleDelete))
80
+
81
+ // Channels — channels scope (read), chat scope (send).
82
+ if s.bridge != nil {
83
+ apiMux.HandleFunc("GET /v1/channels", s.requireScope(auth.ScopeChannels, s.handleListChannels))
84
+ apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.requireScope(auth.ScopeChannels, s.handleJoinChannel))
85
+ apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.requireScope(auth.ScopeChannels, s.handleDeleteChannel))
86
+ apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChannels, s.handleChannelMessages))
87
+ apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChat, s.handleSendMessage))
88
+ apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.requireScope(auth.ScopeChat, s.handleChannelPresence))
89
+ apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.requireScope(auth.ScopeChannels, s.handleChannelUsers))
90
+ apiMux.HandleFunc("GET /v1/channels/{channel}/config", s.requireScope(auth.ScopeChannels, s.handleGetChannelConfig))
91
+ apiMux.HandleFunc("PUT /v1/channels/{channel}/config", s.requireScope(auth.ScopeAdmin, s.handlePutChannelConfig))
92
+ }
93
+
94
+ // Topology — topology scope.
95
+ if s.topoMgr != nil {
96
+ apiMux.HandleFunc("POST /v1/channels", s.requireScope(auth.ScopeTopology, s.handleProvisionChannel))
97
+ apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.requireScope(auth.ScopeTopology, s.handleDropChannel))
98
+ apiMux.HandleFunc("GET /v1/topology", s.requireScope(auth.ScopeTopology, s.handleGetTopology))
99
+ }
100
+
101
+ // Config — config scope.
102
+ if s.cfgStore != nil {
103
+ apiMux.HandleFunc("GET /v1/config", s.requireScope(auth.ScopeConfig, s.handleGetConfig))
104
+ apiMux.HandleFunc("PUT /v1/config", s.requireScope(auth.ScopeConfig, s.handlePutConfig))
105
+ apiMux.HandleFunc("GET /v1/config/history", s.requireScope(auth.ScopeConfig, s.handleGetConfigHistory))
106
+ apiMux.HandleFunc("GET /v1/config/history/{filename}", s.requireScope(auth.ScopeConfig, s.handleGetConfigHistoryEntry))
107
+ }
108
+
109
+ // Admin — admin scope.
110
+ if s.admins != nil {
111
+ apiMux.HandleFunc("GET /v1/admins", s.requireScope(auth.ScopeAdmin, s.handleAdminList))
112
+ apiMux.HandleFunc("POST /v1/admins", s.requireScope(auth.ScopeAdmin, s.handleAdminAdd))
113
+ apiMux.HandleFunc("DELETE /v1/admins/{username}", s.requireScope(auth.ScopeAdmin, s.handleAdminRemove))
114
+ apiMux.HandleFunc("PUT /v1/admins/{username}/password", s.requireScope(auth.ScopeAdmin, s.handleAdminSetPassword))
115
+ }
116
+
117
+ // API key management — admin scope.
118
+ apiMux.HandleFunc("GET /v1/api-keys", s.requireScope(auth.ScopeAdmin, s.handleListAPIKeys))
119
+ apiMux.HandleFunc("POST /v1/api-keys", s.requireScope(auth.ScopeAdmin, s.handleCreateAPIKey))
120
+ apiMux.HandleFunc("DELETE /v1/api-keys/{id}", s.requireScope(auth.ScopeAdmin, s.handleRevokeAPIKey))
121
+
122
+ // LLM / AI gateway — bots scope.
123
+ apiMux.HandleFunc("GET /v1/llm/backends", s.requireScope(auth.ScopeBots, s.handleLLMBackends))
124
+ apiMux.HandleFunc("POST /v1/llm/backends", s.requireScope(auth.ScopeBots, s.handleLLMBackendCreate))
125
+ apiMux.HandleFunc("PUT /v1/llm/backends/{name}", s.requireScope(auth.ScopeBots, s.handleLLMBackendUpdate))
126
+ apiMux.HandleFunc("DELETE /v1/llm/backends/{name}", s.requireScope(auth.ScopeBots, s.handleLLMBackendDelete))
127
+ apiMux.HandleFunc("GET /v1/llm/backends/{name}/models", s.requireScope(auth.ScopeBots, s.handleLLMModels))
128
+ apiMux.HandleFunc("POST /v1/llm/discover", s.requireScope(auth.ScopeBots, s.handleLLMDiscover))
129
+ apiMux.HandleFunc("GET /v1/llm/known", s.requireScope(auth.ScopeBots, s.handleLLMKnown))
130
+ apiMux.HandleFunc("POST /v1/llm/complete", s.requireScope(auth.ScopeBots, s.handleLLMComplete))
113131
114132
outer := http.NewServeMux()
115133
outer.HandleFunc("POST /login", s.handleLogin)
116134
outer.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
117135
http.Redirect(w, r, "/ui/", http.StatusFound)
118136
--- internal/api/server.go
+++ internal/api/server.go
@@ -7,18 +7,19 @@
7
8 import (
9 "log/slog"
10 "net/http"
11
 
12 "github.com/conflicthq/scuttlebot/internal/config"
13 "github.com/conflicthq/scuttlebot/internal/registry"
14 )
15
16 // Server is the scuttlebot HTTP API server.
17 type Server struct {
18 registry *registry.Registry
19 tokens map[string]struct{}
20 log *slog.Logger
21 bridge chatBridge // nil if bridge is disabled
22 policies *PolicyStore // nil if not configured
23 admins adminStore // nil if not configured
24 llmCfg *config.LLMConfig // nil if no LLM backends configured
@@ -31,18 +32,14 @@
31 // New creates a new API Server. Pass nil for b to disable the chat bridge.
32 // Pass nil for admins to disable admin authentication endpoints.
33 // Pass nil for llmCfg to disable AI/LLM management endpoints.
34 // Pass nil for topo to disable topology provisioning endpoints.
35 // Pass nil for cfgStore to disable config read/write endpoints.
36 func New(reg *registry.Registry, tokens []string, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, cfgStore *ConfigStore, tlsDomain string, log *slog.Logger) *Server {
37 tokenSet := make(map[string]struct{}, len(tokens))
38 for _, t := range tokens {
39 tokenSet[t] = struct{}{}
40 }
41 return &Server{
42 registry: reg,
43 tokens: tokenSet,
44 log: log,
45 bridge: b,
46 policies: ps,
47 admins: admins,
48 llmCfg: llmCfg,
@@ -53,65 +50,86 @@
53 }
54 }
55
56 // Handler returns the HTTP handler with all routes registered.
57 // /v1/ routes require a valid Bearer token. /ui/ is served unauthenticated.
 
58 func (s *Server) Handler() http.Handler {
59 apiMux := http.NewServeMux()
60 apiMux.HandleFunc("GET /v1/status", s.handleStatus)
61 apiMux.HandleFunc("GET /v1/metrics", s.handleMetrics)
62 if s.policies != nil {
63 apiMux.HandleFunc("GET /v1/settings", s.handleGetSettings)
64 apiMux.HandleFunc("GET /v1/settings/policies", s.handleGetPolicies)
65 apiMux.HandleFunc("PUT /v1/settings/policies", s.handlePutPolicies)
66 apiMux.HandleFunc("PATCH /v1/settings/policies", s.handlePatchPolicies)
67 }
68 apiMux.HandleFunc("GET /v1/agents", s.handleListAgents)
69 apiMux.HandleFunc("GET /v1/agents/{nick}", s.handleGetAgent)
70 apiMux.HandleFunc("PATCH /v1/agents/{nick}", s.handleUpdateAgent)
71 apiMux.HandleFunc("POST /v1/agents/register", s.handleRegister)
72 apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.handleRotate)
73 apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.handleAdopt)
74 apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.handleRevoke)
75 apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.handleDelete)
76 if s.bridge != nil {
77 apiMux.HandleFunc("GET /v1/channels", s.handleListChannels)
78 apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.handleJoinChannel)
79 apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.handleDeleteChannel)
80 apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.handleChannelMessages)
81 apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.handleSendMessage)
82 apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.handleChannelPresence)
83 apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.handleChannelUsers)
84 }
85 if s.topoMgr != nil {
86 apiMux.HandleFunc("POST /v1/channels", s.handleProvisionChannel)
87 apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.handleDropChannel)
88 apiMux.HandleFunc("GET /v1/topology", s.handleGetTopology)
89 }
90 if s.cfgStore != nil {
91 apiMux.HandleFunc("GET /v1/config", s.handleGetConfig)
92 apiMux.HandleFunc("PUT /v1/config", s.handlePutConfig)
93 apiMux.HandleFunc("GET /v1/config/history", s.handleGetConfigHistory)
94 apiMux.HandleFunc("GET /v1/config/history/{filename}", s.handleGetConfigHistoryEntry)
95 }
96
97 if s.admins != nil {
98 apiMux.HandleFunc("GET /v1/admins", s.handleAdminList)
99 apiMux.HandleFunc("POST /v1/admins", s.handleAdminAdd)
100 apiMux.HandleFunc("DELETE /v1/admins/{username}", s.handleAdminRemove)
101 apiMux.HandleFunc("PUT /v1/admins/{username}/password", s.handleAdminSetPassword)
102 }
103
104 // LLM / AI gateway endpoints.
105 apiMux.HandleFunc("GET /v1/llm/backends", s.handleLLMBackends)
106 apiMux.HandleFunc("POST /v1/llm/backends", s.handleLLMBackendCreate)
107 apiMux.HandleFunc("PUT /v1/llm/backends/{name}", s.handleLLMBackendUpdate)
108 apiMux.HandleFunc("DELETE /v1/llm/backends/{name}", s.handleLLMBackendDelete)
109 apiMux.HandleFunc("GET /v1/llm/backends/{name}/models", s.handleLLMModels)
110 apiMux.HandleFunc("POST /v1/llm/discover", s.handleLLMDiscover)
111 apiMux.HandleFunc("GET /v1/llm/known", s.handleLLMKnown)
112 apiMux.HandleFunc("POST /v1/llm/complete", s.handleLLMComplete)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
114 outer := http.NewServeMux()
115 outer.HandleFunc("POST /login", s.handleLogin)
116 outer.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
117 http.Redirect(w, r, "/ui/", http.StatusFound)
118
--- internal/api/server.go
+++ internal/api/server.go
@@ -7,18 +7,19 @@
7
8 import (
9 "log/slog"
10 "net/http"
11
12 "github.com/conflicthq/scuttlebot/internal/auth"
13 "github.com/conflicthq/scuttlebot/internal/config"
14 "github.com/conflicthq/scuttlebot/internal/registry"
15 )
16
17 // Server is the scuttlebot HTTP API server.
18 type Server struct {
19 registry *registry.Registry
20 apiKeys *auth.APIKeyStore
21 log *slog.Logger
22 bridge chatBridge // nil if bridge is disabled
23 policies *PolicyStore // nil if not configured
24 admins adminStore // nil if not configured
25 llmCfg *config.LLMConfig // nil if no LLM backends configured
@@ -31,18 +32,14 @@
32 // New creates a new API Server. Pass nil for b to disable the chat bridge.
33 // Pass nil for admins to disable admin authentication endpoints.
34 // Pass nil for llmCfg to disable AI/LLM management endpoints.
35 // Pass nil for topo to disable topology provisioning endpoints.
36 // Pass nil for cfgStore to disable config read/write endpoints.
37 func New(reg *registry.Registry, apiKeys *auth.APIKeyStore, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, cfgStore *ConfigStore, tlsDomain string, log *slog.Logger) *Server {
 
 
 
 
38 return &Server{
39 registry: reg,
40 apiKeys: apiKeys,
41 log: log,
42 bridge: b,
43 policies: ps,
44 admins: admins,
45 llmCfg: llmCfg,
@@ -53,65 +50,86 @@
50 }
51 }
52
53 // Handler returns the HTTP handler with all routes registered.
54 // /v1/ routes require a valid Bearer token. /ui/ is served unauthenticated.
55 // Scoped routes additionally check the API key's scopes.
56 func (s *Server) Handler() http.Handler {
57 apiMux := http.NewServeMux()
58
59 // Read-scope: status, metrics (also accessible with any scope via admin).
60 apiMux.HandleFunc("GET /v1/status", s.requireScope(auth.ScopeRead, s.handleStatus))
61 apiMux.HandleFunc("GET /v1/metrics", s.requireScope(auth.ScopeRead, s.handleMetrics))
62
63 // Policies — admin scope.
64 if s.policies != nil {
65 apiMux.HandleFunc("GET /v1/settings", s.requireScope(auth.ScopeRead, s.handleGetSettings))
66 apiMux.HandleFunc("GET /v1/settings/policies", s.requireScope(auth.ScopeRead, s.handleGetPolicies))
67 apiMux.HandleFunc("PUT /v1/settings/policies", s.requireScope(auth.ScopeAdmin, s.handlePutPolicies))
68 apiMux.HandleFunc("PATCH /v1/settings/policies", s.requireScope(auth.ScopeAdmin, s.handlePatchPolicies))
69 }
70
71 // Agents — agents scope.
72 apiMux.HandleFunc("GET /v1/agents", s.requireScope(auth.ScopeAgents, s.handleListAgents))
73 apiMux.HandleFunc("GET /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleGetAgent))
74 apiMux.HandleFunc("PATCH /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleUpdateAgent))
75 apiMux.HandleFunc("POST /v1/agents/register", s.requireScope(auth.ScopeAgents, s.handleRegister))
76 apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.requireScope(auth.ScopeAgents, s.handleRotate))
77 apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.requireScope(auth.ScopeAgents, s.handleAdopt))
78 apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.requireScope(auth.ScopeAgents, s.handleRevoke))
79 apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleDelete))
80
81 // Channels — channels scope (read), chat scope (send).
82 if s.bridge != nil {
83 apiMux.HandleFunc("GET /v1/channels", s.requireScope(auth.ScopeChannels, s.handleListChannels))
84 apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.requireScope(auth.ScopeChannels, s.handleJoinChannel))
85 apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.requireScope(auth.ScopeChannels, s.handleDeleteChannel))
86 apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChannels, s.handleChannelMessages))
87 apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChat, s.handleSendMessage))
88 apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.requireScope(auth.ScopeChat, s.handleChannelPresence))
89 apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.requireScope(auth.ScopeChannels, s.handleChannelUsers))
90 apiMux.HandleFunc("GET /v1/channels/{channel}/config", s.requireScope(auth.ScopeChannels, s.handleGetChannelConfig))
91 apiMux.HandleFunc("PUT /v1/channels/{channel}/config", s.requireScope(auth.ScopeAdmin, s.handlePutChannelConfig))
92 }
93
94 // Topology — topology scope.
95 if s.topoMgr != nil {
96 apiMux.HandleFunc("POST /v1/channels", s.requireScope(auth.ScopeTopology, s.handleProvisionChannel))
97 apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.requireScope(auth.ScopeTopology, s.handleDropChannel))
98 apiMux.HandleFunc("GET /v1/topology", s.requireScope(auth.ScopeTopology, s.handleGetTopology))
99 }
100
101 // Config — config scope.
102 if s.cfgStore != nil {
103 apiMux.HandleFunc("GET /v1/config", s.requireScope(auth.ScopeConfig, s.handleGetConfig))
104 apiMux.HandleFunc("PUT /v1/config", s.requireScope(auth.ScopeConfig, s.handlePutConfig))
105 apiMux.HandleFunc("GET /v1/config/history", s.requireScope(auth.ScopeConfig, s.handleGetConfigHistory))
106 apiMux.HandleFunc("GET /v1/config/history/{filename}", s.requireScope(auth.ScopeConfig, s.handleGetConfigHistoryEntry))
107 }
108
109 // Admin — admin scope.
110 if s.admins != nil {
111 apiMux.HandleFunc("GET /v1/admins", s.requireScope(auth.ScopeAdmin, s.handleAdminList))
112 apiMux.HandleFunc("POST /v1/admins", s.requireScope(auth.ScopeAdmin, s.handleAdminAdd))
113 apiMux.HandleFunc("DELETE /v1/admins/{username}", s.requireScope(auth.ScopeAdmin, s.handleAdminRemove))
114 apiMux.HandleFunc("PUT /v1/admins/{username}/password", s.requireScope(auth.ScopeAdmin, s.handleAdminSetPassword))
115 }
116
117 // API key management — admin scope.
118 apiMux.HandleFunc("GET /v1/api-keys", s.requireScope(auth.ScopeAdmin, s.handleListAPIKeys))
119 apiMux.HandleFunc("POST /v1/api-keys", s.requireScope(auth.ScopeAdmin, s.handleCreateAPIKey))
120 apiMux.HandleFunc("DELETE /v1/api-keys/{id}", s.requireScope(auth.ScopeAdmin, s.handleRevokeAPIKey))
121
122 // LLM / AI gateway — bots scope.
123 apiMux.HandleFunc("GET /v1/llm/backends", s.requireScope(auth.ScopeBots, s.handleLLMBackends))
124 apiMux.HandleFunc("POST /v1/llm/backends", s.requireScope(auth.ScopeBots, s.handleLLMBackendCreate))
125 apiMux.HandleFunc("PUT /v1/llm/backends/{name}", s.requireScope(auth.ScopeBots, s.handleLLMBackendUpdate))
126 apiMux.HandleFunc("DELETE /v1/llm/backends/{name}", s.requireScope(auth.ScopeBots, s.handleLLMBackendDelete))
127 apiMux.HandleFunc("GET /v1/llm/backends/{name}/models", s.requireScope(auth.ScopeBots, s.handleLLMModels))
128 apiMux.HandleFunc("POST /v1/llm/discover", s.requireScope(auth.ScopeBots, s.handleLLMDiscover))
129 apiMux.HandleFunc("GET /v1/llm/known", s.requireScope(auth.ScopeBots, s.handleLLMKnown))
130 apiMux.HandleFunc("POST /v1/llm/complete", s.requireScope(auth.ScopeBots, s.handleLLMComplete))
131
132 outer := http.NewServeMux()
133 outer.HandleFunc("POST /login", s.handleLogin)
134 outer.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
135 http.Redirect(w, r, "/ui/", http.StatusFound)
136
--- internal/api/settings.go
+++ internal/api/settings.go
@@ -5,12 +5,13 @@
55
66
"github.com/conflicthq/scuttlebot/internal/config"
77
)
88
99
type settingsResponse struct {
10
- TLS tlsInfo `json:"tls"`
11
- Policies Policies `json:"policies"`
10
+ TLS tlsInfo `json:"tls"`
11
+ Policies Policies `json:"policies"`
12
+ BotCommands map[string][]BotCommand `json:"bot_commands,omitempty"`
1213
}
1314
1415
type tlsInfo struct {
1516
Enabled bool `json:"enabled"`
1617
Domain string `json:"domain,omitempty"`
@@ -33,10 +34,11 @@
3334
cfg := s.cfgStore.Get()
3435
resp.Policies.AgentPolicy = toAPIAgentPolicy(cfg.AgentPolicy)
3536
resp.Policies.Logging = toAPILogging(cfg.Logging)
3637
resp.Policies.Bridge.WebUserTTLMinutes = cfg.Bridge.WebUserTTLMinutes
3738
}
39
+ resp.BotCommands = botCommands
3840
writeJSON(w, http.StatusOK, resp)
3941
}
4042
4143
func toAPIAgentPolicy(c config.AgentPolicyConfig) AgentPolicy {
4244
return AgentPolicy{
4345
--- internal/api/settings.go
+++ internal/api/settings.go
@@ -5,12 +5,13 @@
5
6 "github.com/conflicthq/scuttlebot/internal/config"
7 )
8
9 type settingsResponse struct {
10 TLS tlsInfo `json:"tls"`
11 Policies Policies `json:"policies"`
 
12 }
13
14 type tlsInfo struct {
15 Enabled bool `json:"enabled"`
16 Domain string `json:"domain,omitempty"`
@@ -33,10 +34,11 @@
33 cfg := s.cfgStore.Get()
34 resp.Policies.AgentPolicy = toAPIAgentPolicy(cfg.AgentPolicy)
35 resp.Policies.Logging = toAPILogging(cfg.Logging)
36 resp.Policies.Bridge.WebUserTTLMinutes = cfg.Bridge.WebUserTTLMinutes
37 }
 
38 writeJSON(w, http.StatusOK, resp)
39 }
40
41 func toAPIAgentPolicy(c config.AgentPolicyConfig) AgentPolicy {
42 return AgentPolicy{
43
--- internal/api/settings.go
+++ internal/api/settings.go
@@ -5,12 +5,13 @@
5
6 "github.com/conflicthq/scuttlebot/internal/config"
7 )
8
9 type settingsResponse struct {
10 TLS tlsInfo `json:"tls"`
11 Policies Policies `json:"policies"`
12 BotCommands map[string][]BotCommand `json:"bot_commands,omitempty"`
13 }
14
15 type tlsInfo struct {
16 Enabled bool `json:"enabled"`
17 Domain string `json:"domain,omitempty"`
@@ -33,10 +34,11 @@
34 cfg := s.cfgStore.Get()
35 resp.Policies.AgentPolicy = toAPIAgentPolicy(cfg.AgentPolicy)
36 resp.Policies.Logging = toAPILogging(cfg.Logging)
37 resp.Policies.Bridge.WebUserTTLMinutes = cfg.Bridge.WebUserTTLMinutes
38 }
39 resp.BotCommands = botCommands
40 writeJSON(w, http.StatusOK, resp)
41 }
42
43 func toAPIAgentPolicy(c config.AgentPolicyConfig) AgentPolicy {
44 return AgentPolicy{
45
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -485,10 +485,40 @@
485485
<button class="sm primary" onclick="quickJoin()">join</button>
486486
</div>
487487
</div>
488488
<div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div>
489489
</div>
490
+
491
+ <!-- topology panel -->
492
+ <div class="card" id="card-topology">
493
+ <div class="card-header" onclick="toggleCard('card-topology',event)">
494
+ <h2>topology</h2><span class="card-desc">channel types, provisioning rules, active task channels</span><span class="collapse-icon">▾</span>
495
+ <div class="spacer"></div>
496
+ <div style="display:flex;gap:6px;align-items:center">
497
+ <input type="text" id="provision-channel-input" placeholder="#project.name" style="width:160px;padding:5px 8px;font-size:12px" autocomplete="off">
498
+ <button class="sm primary" onclick="provisionChannel()">provision</button>
499
+ </div>
500
+ </div>
501
+ <div class="card-body" style="padding:0">
502
+ <div id="topology-types"></div>
503
+ <div id="topology-active" style="padding:12px 16px"><div class="empty">loading topology…</div></div>
504
+ </div>
505
+ </div>
506
+
507
+ <!-- ROE templates -->
508
+ <div class="card" id="card-roe">
509
+ <div class="card-header" onclick="toggleCard('card-roe',event)">
510
+ <h2>ROE templates</h2><span class="card-desc">rules-of-engagement presets for agent registration</span><span class="collapse-icon">▾</span>
511
+ <div class="spacer"></div>
512
+ <button class="sm primary" onclick="event.stopPropagation();savePolicies()">save</button>
513
+ </div>
514
+ <div class="card-body">
515
+ <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Define ROE templates applied to agents at registration. Includes channels, permissions, and rate limits.</p>
516
+ <div id="roe-list"></div>
517
+ <button class="sm" onclick="addROETemplate()" style="margin-top:10px">+ add template</button>
518
+ </div>
519
+ </div>
490520
</div>
491521
</div>
492522
493523
<!-- CHAT -->
494524
<div class="tab-pane" id="pane-chat">
@@ -504,11 +534,11 @@
504534
<div class="chan-list" id="chan-list"></div>
505535
</div>
506536
<div class="sidebar-resize" id="resize-left" title="drag to resize"></div>
507537
<div class="chat-main">
508538
<div class="chat-topbar">
509
- <span class="chat-ch-name" id="chat-ch-name">select a channel</span>
539
+ <span class="chat-ch-name" id="chat-ch-name">select a channel</span><span id="chat-channel-modes" style="color:#8b949e;font-size:11px;margin-left:6px"></span>
510540
<div class="spacer"></div>
511541
<span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span>
512542
<select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()">
513543
<option value="">— pick a user —</option>
514544
</select>
@@ -580,10 +610,40 @@
580610
<button type="submit" class="primary sm" style="margin-bottom:1px">add admin</button>
581611
</form>
582612
<div id="add-admin-result" style="margin-top:10px"></div>
583613
</div>
584614
</div>
615
+
616
+ <!-- api keys -->
617
+ <div class="card" id="card-apikeys">
618
+ <div class="card-header" onclick="toggleCard('card-apikeys',event)"><h2>API keys</h2><span class="card-desc">per-consumer tokens with scoped permissions</span><span class="collapse-icon">▾</span></div>
619
+ <div id="apikeys-list-container"></div>
620
+ <div class="card-body" style="border-top:1px solid #21262d">
621
+ <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Create an API key with a name and scopes. The token is shown only once.</p>
622
+ <form id="add-apikey-form" onsubmit="createAPIKey(event)" style="display:flex;flex-direction:column;gap:10px">
623
+ <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
624
+ <div style="flex:1;min-width:160px"><label>name</label><input type="text" id="new-apikey-name" placeholder="e.g. kohakku-controller" autocomplete="off"></div>
625
+ <div style="flex:1;min-width:160px"><label>expires in</label><input type="text" id="new-apikey-expires" placeholder="e.g. 720h (empty=never)" autocomplete="off"></div>
626
+ </div>
627
+ <div>
628
+ <label style="margin-bottom:6px;display:block">scopes</label>
629
+ <div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px">
630
+ <label><input type="checkbox" value="admin" class="apikey-scope"> admin</label>
631
+ <label><input type="checkbox" value="agents" class="apikey-scope"> agents</label>
632
+ <label><input type="checkbox" value="channels" class="apikey-scope"> channels</label>
633
+ <label><input type="checkbox" value="chat" class="apikey-scope"> chat</label>
634
+ <label><input type="checkbox" value="topology" class="apikey-scope"> topology</label>
635
+ <label><input type="checkbox" value="bots" class="apikey-scope"> bots</label>
636
+ <label><input type="checkbox" value="config" class="apikey-scope"> config</label>
637
+ <label><input type="checkbox" value="read" class="apikey-scope"> read</label>
638
+ </div>
639
+ </div>
640
+ <button type="submit" class="primary sm" style="align-self:flex-start">create key</button>
641
+ </form>
642
+ <div id="add-apikey-result" style="margin-top:10px"></div>
643
+ </div>
644
+ </div>
585645
586646
<!-- tls -->
587647
<div class="card" id="card-tls">
588648
<div class="card-header" onclick="toggleCard('card-tls',event)"><h2>TLS / SSL</h2><span class="card-desc">certificate status</span><span class="collapse-icon">▾</span><div class="spacer"></div><span id="tls-badge" class="badge">loading…</span></div>
589649
<div class="card-body">
@@ -605,10 +665,28 @@
605665
</div>
606666
<div class="card-body" style="padding:0">
607667
<div id="behaviors-list"></div>
608668
</div>
609669
</div>
670
+
671
+ <!-- on-join instructions -->
672
+ <div class="card" id="card-onjoin">
673
+ <div class="card-header" onclick="toggleCard('card-onjoin',event)">
674
+ <h2>on-join instructions</h2><span class="card-desc">messages sent to agents when they join a channel</span><span class="collapse-icon">▾</span>
675
+ <div class="spacer"></div>
676
+ <button class="sm primary" onclick="event.stopPropagation();savePolicies()">save</button>
677
+ </div>
678
+ <div class="card-body">
679
+ <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Per-channel instructions delivered to agents on join. Supports <code>{nick}</code> and <code>{channel}</code> template variables.</p>
680
+ <div id="onjoin-list"></div>
681
+ <div style="display:flex;gap:8px;margin-top:12px;align-items:flex-end">
682
+ <div style="flex:0 0 160px"><label>channel</label><input type="text" id="onjoin-new-channel" placeholder="#channel" style="width:100%"></div>
683
+ <div style="flex:1"><label>message</label><input type="text" id="onjoin-new-message" placeholder="Welcome to {channel}, {nick}!" style="width:100%"></div>
684
+ <button class="sm primary" onclick="addOnJoinMessage()">add</button>
685
+ </div>
686
+ </div>
687
+ </div>
610688
611689
<!-- agent policy -->
612690
<div class="card" id="card-agentpolicy">
613691
<div class="card-header" onclick="toggleCard('card-agentpolicy',event)"><h2>agent policy</h2><span class="card-desc">autojoin and check-in rules for all agents</span><span class="collapse-icon">▾</span><div class="spacer"></div><button class="sm primary" onclick="event.stopPropagation();saveAgentPolicy()">save</button></div>
614692
<div class="card-body">
@@ -798,10 +876,31 @@
798876
</div>
799877
<div class="setting-row">
800878
<div class="setting-label">IRC address</div>
801879
<div class="setting-desc">Address Ergo listens on for IRC connections. Requires restart.</div>
802880
<input type="text" id="ergo-irc-addr" placeholder="127.0.0.1:6667" style="width:180px;padding:4px 8px;font-size:12px">
881
+ </div>
882
+ <div class="setting-row">
883
+ <div class="setting-label">require SASL</div>
884
+ <div class="setting-desc">Enforce SASL authentication for all IRC connections. Only registered accounts can connect. Hot-reloads.</div>
885
+ <label style="display:flex;align-items:center;gap:6px;cursor:pointer">
886
+ <input type="checkbox" id="ergo-require-sasl">
887
+ <span style="font-size:12px">enforce SASL</span>
888
+ </label>
889
+ </div>
890
+ <div class="setting-row">
891
+ <div class="setting-label">default channel modes</div>
892
+ <div class="setting-desc">Modes applied to new channels (e.g. "+n", "+Rn"). Hot-reloads.</div>
893
+ <input type="text" id="ergo-default-modes" placeholder="+n" style="width:120px;padding:4px 8px;font-size:12px">
894
+ </div>
895
+ <div class="setting-row">
896
+ <div class="setting-label">message history</div>
897
+ <div class="setting-desc">Enable persistent message history (CHATHISTORY). Hot-reloads.</div>
898
+ <label style="display:flex;align-items:center;gap:6px;cursor:pointer">
899
+ <input type="checkbox" id="ergo-history-enabled">
900
+ <span style="font-size:12px">enabled</span>
901
+ </label>
803902
</div>
804903
<div class="setting-row">
805904
<div class="setting-label">external mode</div>
806905
<div class="setting-desc">Disable subprocess management — scuttlebot expects Ergo to already be running. Requires restart.</div>
807906
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
@@ -1726,10 +1825,19 @@
17261825
allChannels = (data.channels || []).sort();
17271826
renderChanList();
17281827
} catch(e) {
17291828
document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>';
17301829
}
1830
+ loadTopology();
1831
+ // Load ROE templates from policies for the ROE card.
1832
+ try {
1833
+ const s = await api('GET', '/v1/settings');
1834
+ if (s && s.policies) {
1835
+ currentPolicies = s.policies;
1836
+ renderROETemplates(s.policies.roe_templates || []);
1837
+ }
1838
+ } catch(e) {}
17311839
}
17321840
17331841
function renderChanList() {
17341842
const q = (document.getElementById('chan-search').value||'').toLowerCase();
17351843
const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q));
@@ -1764,10 +1872,138 @@
17641872
await loadChanTab();
17651873
renderChanSidebar((await api('GET','/v1/channels')).channels||[]);
17661874
} catch(e) { alert('Join failed: '+e.message); }
17671875
}
17681876
document.getElementById('quick-join-input').addEventListener('keydown', e => { if(e.key==='Enter')quickJoin(); });
1877
+
1878
+// --- topology panel (#115) + task channels (#114) ---
1879
+async function loadTopology() {
1880
+ try {
1881
+ const data = await api('GET', '/v1/topology');
1882
+ renderTopologyTypes(data.types || []);
1883
+ renderTopologyActive(data.active_channels || [], data.types || []);
1884
+ } catch(e) {
1885
+ document.getElementById('topology-types').innerHTML = '';
1886
+ document.getElementById('topology-active').innerHTML = '<div style="color:#8b949e;font-size:12px">topology not configured</div>';
1887
+ }
1888
+}
1889
+
1890
+function renderTopologyTypes(types) {
1891
+ if (!types.length) { document.getElementById('topology-types').innerHTML = ''; return; }
1892
+ const rows = types.map(t => {
1893
+ const ttl = t.ttl_seconds > 0 ? `${Math.round(t.ttl_seconds/3600)}h` : '—';
1894
+ const tags = [];
1895
+ if (t.ephemeral) tags.push('<span style="background:#f8514922;color:#f85149;padding:1px 5px;border-radius:3px;font-size:10px">ephemeral</span>');
1896
+ if (t.supervision) tags.push(`<span style="font-size:11px;color:#8b949e">→ ${esc(t.supervision)}</span>`);
1897
+ return `<tr>
1898
+ <td><strong>${esc(t.name)}</strong></td>
1899
+ <td><code style="font-size:11px">#${esc(t.prefix)}*</code></td>
1900
+ <td style="font-size:12px">${(t.autojoin||[]).map(n => `<code style="font-size:11px;background:#21262d;padding:1px 4px;border-radius:3px">${esc(n)}</code>`).join(' ')}</td>
1901
+ <td style="font-size:12px">${ttl}</td>
1902
+ <td>${tags.join(' ')}</td>
1903
+ </tr>`;
1904
+ }).join('');
1905
+ document.getElementById('topology-types').innerHTML = `<table><thead><tr><th>type</th><th>prefix</th><th>autojoin</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
1906
+}
1907
+
1908
+function renderTopologyActive(channels, types) {
1909
+ const el = document.getElementById('topology-active');
1910
+ const tasks = channels.filter(c => c.ephemeral || c.type === 'task');
1911
+ if (!tasks.length) {
1912
+ el.innerHTML = '<div style="color:#8b949e;font-size:12px;padding:4px 0">no active task channels</div>';
1913
+ return;
1914
+ }
1915
+ const rows = tasks.map(c => {
1916
+ const age = c.provisioned_at ? timeSince(new Date(c.provisioned_at)) : '—';
1917
+ const ttl = c.ttl_seconds > 0 ? `${Math.round(c.ttl_seconds/3600)}h` : '—';
1918
+ return `<tr>
1919
+ <td><strong>${esc(c.name)}</strong></td>
1920
+ <td style="font-size:12px;color:#8b949e">${esc(c.type || '—')}</td>
1921
+ <td style="font-size:12px">${age}</td>
1922
+ <td style="font-size:12px">${ttl}</td>
1923
+ <td><button class="sm danger" onclick="dropChannel('${esc(c.name)}')">drop</button></td>
1924
+ </tr>`;
1925
+ }).join('');
1926
+ el.innerHTML = `<table><thead><tr><th>channel</th><th>type</th><th>age</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
1927
+}
1928
+
1929
+function timeSince(date) {
1930
+ const s = Math.floor((new Date() - date) / 1000);
1931
+ if (s < 60) return s + 's';
1932
+ if (s < 3600) return Math.floor(s/60) + 'm';
1933
+ if (s < 86400) return Math.floor(s/3600) + 'h';
1934
+ return Math.floor(s/86400) + 'd';
1935
+}
1936
+
1937
+async function provisionChannel() {
1938
+ let ch = document.getElementById('provision-channel-input').value.trim();
1939
+ if (!ch) return;
1940
+ if (!ch.startsWith('#')) ch = '#' + ch;
1941
+ try {
1942
+ await api('POST', '/v1/channels', {name: ch});
1943
+ document.getElementById('provision-channel-input').value = '';
1944
+ loadTopology();
1945
+ loadChanTab();
1946
+ } catch(e) { alert('Provision failed: ' + e.message); }
1947
+}
1948
+
1949
+async function dropChannel(ch) {
1950
+ if (!confirm('Drop channel ' + ch + '? This unregisters it from ChanServ.')) return;
1951
+ const slug = ch.replace(/^#/,'');
1952
+ try {
1953
+ await api('DELETE', `/v1/topology/channels/${slug}`);
1954
+ loadTopology();
1955
+ loadChanTab();
1956
+ } catch(e) { alert('Drop failed: ' + e.message); }
1957
+}
1958
+
1959
+// --- ROE template editor (#118) ---
1960
+function renderROETemplates(templates) {
1961
+ const el = document.getElementById('roe-list');
1962
+ if (!templates || !templates.length) {
1963
+ el.innerHTML = '<div style="color:#8b949e;font-size:12px">No ROE templates defined. Click + add template to create one.</div>';
1964
+ return;
1965
+ }
1966
+ el.innerHTML = templates.map((t, i) => `
1967
+ <div style="border:1px solid #21262d;border-radius:6px;padding:12px;margin-bottom:10px;background:#0d1117">
1968
+ <div style="display:flex;gap:10px;align-items:center;margin-bottom:8px">
1969
+ <input type="text" value="${esc(t.name)}" placeholder="template name" style="flex:1;font-weight:600" onchange="updateROE(${i},'name',this.value)">
1970
+ <button class="sm danger" onclick="removeROE(${i})">remove</button>
1971
+ </div>
1972
+ <div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:6px">
1973
+ <div style="flex:1;min-width:200px"><label style="font-size:11px">channels (comma-separated)</label><input type="text" value="${esc((t.channels||[]).join(', '))}" onchange="updateROE(${i},'channels',this.value)" style="width:100%"></div>
1974
+ <div style="flex:1;min-width:200px"><label style="font-size:11px">permissions</label><input type="text" value="${esc((t.permissions||[]).join(', '))}" onchange="updateROE(${i},'permissions',this.value)" style="width:100%"></div>
1975
+ </div>
1976
+ <div style="display:flex;gap:10px">
1977
+ <div><label style="font-size:11px">msg/sec</label><input type="number" value="${t.rate_limit?.messages_per_second||''}" placeholder="10" style="width:70px" onchange="updateROERateLimit(${i},'messages_per_second',this.value)"></div>
1978
+ <div><label style="font-size:11px">burst</label><input type="number" value="${t.rate_limit?.burst||''}" placeholder="50" style="width:70px" onchange="updateROERateLimit(${i},'burst',this.value)"></div>
1979
+ <div style="flex:1"><label style="font-size:11px">description</label><input type="text" value="${esc(t.description||'')}" onchange="updateROE(${i},'description',this.value)" style="width:100%"></div>
1980
+ </div>
1981
+ </div>
1982
+ `).join('');
1983
+}
1984
+
1985
+function addROETemplate() {
1986
+ if (!currentPolicies.roe_templates) currentPolicies.roe_templates = [];
1987
+ currentPolicies.roe_templates.push({name: 'new-template', channels: [], permissions: []});
1988
+ renderROETemplates(currentPolicies.roe_templates);
1989
+}
1990
+function removeROE(i) {
1991
+ currentPolicies.roe_templates.splice(i, 1);
1992
+ renderROETemplates(currentPolicies.roe_templates);
1993
+}
1994
+function updateROE(i, field, val) {
1995
+ if (field === 'channels' || field === 'permissions') {
1996
+ currentPolicies.roe_templates[i][field] = val.split(',').map(s => s.trim()).filter(Boolean);
1997
+ } else {
1998
+ currentPolicies.roe_templates[i][field] = val;
1999
+ }
2000
+}
2001
+function updateROERateLimit(i, field, val) {
2002
+ if (!currentPolicies.roe_templates[i].rate_limit) currentPolicies.roe_templates[i].rate_limit = {};
2003
+ currentPolicies.roe_templates[i].rate_limit[field] = Number(val) || 0;
2004
+}
17692005
17702006
// --- chat ---
17712007
let chatChannel = null, chatSSE = null;
17722008
17732009
async function loadChannels() {
@@ -1838,11 +2074,11 @@
18382074
async function loadNicklist(ch) {
18392075
if (!ch) return;
18402076
try {
18412077
const slug = ch.replace(/^#/,'');
18422078
const data = await api('GET', `/v1/channels/${slug}/users`);
1843
- renderNicklist(data.users || []);
2079
+ renderNicklist(data.users || [], data.channel_modes || '');
18442080
} catch(e) {}
18452081
}
18462082
const SYSTEM_BOTS = new Set(['bridge','oracle','sentinel','steward','scribe','warden','snitch','herald','scroll','systembot','auditbot']);
18472083
const AGENT_PREFIXES = ['claude-','codex-','gemini-','openclaw-'];
18482084
@@ -1861,24 +2097,35 @@
18612097
if (tier === 0) return '@';
18622098
if (tier === 1) return '+';
18632099
return '';
18642100
}
18652101
1866
-function renderNicklist(users) {
2102
+function renderNicklist(users, channelModes) {
18672103
const el = document.getElementById('nicklist-users');
2104
+ // users may be [{nick, modes}] or ["nick"] for backwards compat.
2105
+ const normalized = users.map(u => typeof u === 'string' ? {nick: u, modes: []} : u);
18682106
// Sort: ops > system bots > agents > users, alpha within each tier.
1869
- const sorted = users.slice().sort((a, b) => {
1870
- const ta = nickTier(a), tb = nickTier(b);
2107
+ const sorted = normalized.slice().sort((a, b) => {
2108
+ const ta = nickTier(a.nick), tb = nickTier(b.nick);
18712109
if (ta !== tb) return ta - tb;
1872
- return a.localeCompare(b);
2110
+ return a.nick.localeCompare(b.nick);
18732111
});
1874
- el.innerHTML = sorted.map(nick => {
1875
- const tier = nickTier(nick);
1876
- const prefix = nickPrefix(nick);
1877
- const cls = tier === 1 ? ' is-bot' : tier === 0 ? ' is-op' : '';
1878
- return `<div class="nicklist-nick${cls}" title="${esc(nick)}">${prefix}${esc(nick)}</div>`;
2112
+ el.innerHTML = sorted.map(u => {
2113
+ const modes = u.modes || [];
2114
+ // IRC mode prefix: @ for op, + for voice
2115
+ let prefix = '';
2116
+ if (modes.includes('o') || modes.includes('a') || modes.includes('q')) prefix = '@';
2117
+ else if (modes.includes('v')) prefix = '+';
2118
+ else prefix = nickPrefix(u.nick);
2119
+ const tier = nickTier(u.nick);
2120
+ const cls = (modes.includes('o') || tier === 0) ? ' is-op' : tier === 1 ? ' is-bot' : '';
2121
+ const modeStr = modes.length ? ` [+${modes.join('')}]` : '';
2122
+ return `<div class="nicklist-nick${cls}" title="${esc(u.nick)}${modeStr}">${prefix}${esc(u.nick)}</div>`;
18792123
}).join('');
2124
+ // Show channel modes in header if available.
2125
+ const modesEl = document.getElementById('chat-channel-modes');
2126
+ if (modesEl) modesEl.textContent = channelModes ? ` ${channelModes}` : '';
18802127
}
18812128
// Nick colors — deterministic hash over a palette
18822129
const NICK_PALETTE = ['#58a6ff','#3fb950','#ffa657','#d2a8ff','#56d364','#79c0ff','#ff7b72','#a5d6ff','#f0883e','#39d353'];
18832130
function nickColor(nick) {
18842131
let h = 0;
@@ -1892,14 +2139,16 @@
18922139
let _chatUnread = 0;
18932140
18942141
function appendMsg(msg, isHistory) {
18952142
const area = document.getElementById('chat-msgs');
18962143
1897
- // Parse "[nick] text" sent by the bridge bot on behalf of a web user
2144
+ // Attribution: RELAYMSG delivers nicks as "user/bridge"; legacy uses "[nick] text".
18982145
let displayNick = msg.nick;
18992146
let displayText = msg.text;
1900
- if (msg.nick === 'bridge') {
2147
+ if (msg.nick && msg.nick.endsWith('/bridge')) {
2148
+ displayNick = msg.nick.slice(0, -'/bridge'.length);
2149
+ } else if (msg.nick === 'bridge') {
19012150
const m = msg.text.match(/^\[([^\]]+)\] ([\s\S]*)$/);
19022151
if (m) { displayNick = m[1]; displayText = m[2]; }
19032152
}
19042153
19052154
const atMs = new Date(msg.at).getTime();
@@ -2542,10 +2791,73 @@
25422791
try {
25432792
await api('PUT', `/v1/admins/${encodeURIComponent(username)}/password`, { password: pw });
25442793
alert('Password updated.');
25452794
} catch(e) { alert('Failed: ' + e.message); }
25462795
}
2796
+
2797
+// --- API keys ---
2798
+async function loadAPIKeys() {
2799
+ try {
2800
+ const keys = await api('GET', '/v1/api-keys');
2801
+ renderAPIKeys(keys || []);
2802
+ } catch(e) {
2803
+ document.getElementById('apikeys-list-container').innerHTML = '';
2804
+ }
2805
+}
2806
+
2807
+function renderAPIKeys(keys) {
2808
+ const el = document.getElementById('apikeys-list-container');
2809
+ if (!keys.length) { el.innerHTML = ''; return; }
2810
+ const rows = keys.map(k => {
2811
+ const status = k.active ? '<span style="color:#3fb950">active</span>' : '<span style="color:#f85149">revoked</span>';
2812
+ const scopes = (k.scopes || []).map(s => `<code style="font-size:11px;background:#21262d;padding:1px 5px;border-radius:3px">${esc(s)}</code>`).join(' ');
2813
+ const lastUsed = k.last_used ? fmtTime(k.last_used) : '—';
2814
+ const revokeBtn = k.active ? `<button class="sm danger" onclick="revokeAPIKey('${esc(k.id)}')">revoke</button>` : '';
2815
+ return `<tr>
2816
+ <td><strong>${esc(k.name)}</strong><br><span style="color:#8b949e;font-size:11px">${esc(k.id)}</span></td>
2817
+ <td>${scopes}</td>
2818
+ <td style="font-size:12px">${status}</td>
2819
+ <td style="color:#8b949e;font-size:12px">${lastUsed}</td>
2820
+ <td><div class="actions">${revokeBtn}</div></td>
2821
+ </tr>`;
2822
+ }).join('');
2823
+ el.innerHTML = `<table><thead><tr><th>name</th><th>scopes</th><th>status</th><th>last used</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
2824
+}
2825
+
2826
+async function createAPIKey(e) {
2827
+ e.preventDefault();
2828
+ const name = document.getElementById('new-apikey-name').value.trim();
2829
+ const expires = document.getElementById('new-apikey-expires').value.trim();
2830
+ const scopes = [...document.querySelectorAll('.apikey-scope:checked')].map(cb => cb.value);
2831
+ const resultEl = document.getElementById('add-apikey-result');
2832
+ if (!name) { resultEl.innerHTML = '<span style="color:#f85149">name is required</span>'; return; }
2833
+ if (!scopes.length) { resultEl.innerHTML = '<span style="color:#f85149">select at least one scope</span>'; return; }
2834
+ try {
2835
+ const body = { name, scopes };
2836
+ if (expires) body.expires_in = expires;
2837
+ const result = await api('POST', '/v1/api-keys', body);
2838
+ resultEl.innerHTML = `<div style="background:#0d1117;border:1px solid #3fb95044;border-radius:6px;padding:12px;margin-top:8px">
2839
+ <div style="color:#3fb950;font-weight:600;margin-bottom:6px">Key created: ${esc(result.name)}</div>
2840
+ <div style="margin-bottom:4px;font-size:12px;color:#8b949e">Copy this token now — it will not be shown again:</div>
2841
+ <code style="display:block;padding:8px;background:#161b22;border-radius:4px;word-break:break-all;user-select:all">${esc(result.token)}</code>
2842
+ </div>`;
2843
+ document.getElementById('new-apikey-name').value = '';
2844
+ document.getElementById('new-apikey-expires').value = '';
2845
+ document.querySelectorAll('.apikey-scope:checked').forEach(cb => cb.checked = false);
2846
+ loadAPIKeys();
2847
+ } catch(e) {
2848
+ resultEl.innerHTML = `<span style="color:#f85149">${esc(e.message)}</span>`;
2849
+ }
2850
+}
2851
+
2852
+async function revokeAPIKey(id) {
2853
+ if (!confirm('Revoke this API key? This cannot be undone.')) return;
2854
+ try {
2855
+ await api('DELETE', `/v1/api-keys/${encodeURIComponent(id)}`);
2856
+ loadAPIKeys();
2857
+ } catch(e) { alert('Failed: ' + e.message); }
2858
+}
25472859
25482860
// --- AI / LLM tab ---
25492861
async function loadAI() {
25502862
await Promise.all([loadAIBackends(), loadAIKnown()]);
25512863
}
@@ -2899,10 +3211,41 @@
28993211
if (body) body.style.display = '';
29003212
}
29013213
29023214
// --- settings / policies ---
29033215
let currentPolicies = null;
3216
+let _botCommands = {};
3217
+
3218
+function renderOnJoinMessages(msgs) {
3219
+ const el = document.getElementById('onjoin-list');
3220
+ if (!msgs || !Object.keys(msgs).length) { el.innerHTML = '<div style="color:#8b949e;font-size:12px">No on-join instructions configured.</div>'; return; }
3221
+ el.innerHTML = Object.entries(msgs).sort().map(([ch, msg]) => `
3222
+ <div style="display:flex;gap:8px;align-items:center;padding:6px 0;border-bottom:1px solid #21262d">
3223
+ <code style="font-size:12px;min-width:120px">${esc(ch)}</code>
3224
+ <input type="text" value="${esc(msg)}" style="flex:1;font-size:12px" onchange="updateOnJoinMessage('${esc(ch)}',this.value)">
3225
+ <button class="sm danger" onclick="removeOnJoinMessage('${esc(ch)}')">remove</button>
3226
+ </div>
3227
+ `).join('');
3228
+}
3229
+function addOnJoinMessage() {
3230
+ const ch = document.getElementById('onjoin-new-channel').value.trim();
3231
+ const msg = document.getElementById('onjoin-new-message').value.trim();
3232
+ if (!ch || !msg) return;
3233
+ if (!currentPolicies.on_join_messages) currentPolicies.on_join_messages = {};
3234
+ currentPolicies.on_join_messages[ch] = msg;
3235
+ document.getElementById('onjoin-new-channel').value = '';
3236
+ document.getElementById('onjoin-new-message').value = '';
3237
+ renderOnJoinMessages(currentPolicies.on_join_messages);
3238
+}
3239
+function updateOnJoinMessage(ch, msg) {
3240
+ if (!currentPolicies.on_join_messages) currentPolicies.on_join_messages = {};
3241
+ currentPolicies.on_join_messages[ch] = msg;
3242
+}
3243
+function removeOnJoinMessage(ch) {
3244
+ if (currentPolicies.on_join_messages) delete currentPolicies.on_join_messages[ch];
3245
+ renderOnJoinMessages(currentPolicies.on_join_messages);
3246
+}
29043247
let _llmBackendNames = []; // cached backend names for oracle dropdown
29053248
29063249
async function loadSettings() {
29073250
try {
29083251
const [s, backends] = await Promise.all([
@@ -2910,15 +3253,18 @@
29103253
api('GET', '/v1/llm/backends').catch(() => []),
29113254
]);
29123255
_llmBackendNames = (backends || []).map(b => b.name);
29133256
renderTLSStatus(s.tls);
29143257
currentPolicies = s.policies;
3258
+ _botCommands = s.bot_commands || {};
29153259
renderBehaviors(s.policies.behaviors || []);
3260
+ renderOnJoinMessages(s.policies.on_join_messages || {});
29163261
renderAgentPolicy(s.policies.agent_policy || {});
29173262
renderBridgePolicy(s.policies.bridge || {});
29183263
renderLoggingPolicy(s.policies.logging || {});
29193264
loadAdmins();
3265
+ loadAPIKeys();
29203266
loadConfigCards();
29213267
} catch(e) {
29223268
document.getElementById('tls-badge').textContent = 'error';
29233269
}
29243270
}
@@ -2973,10 +3319,14 @@
29733319
` : ''}
29743320
<span class="tag type-observer" style="font-size:11px;min-width:64px;text-align:center">${esc(b.nick)}</span>
29753321
</div>
29763322
</div>
29773323
${b.enabled && hasSchema(b.id) ? renderBehConfig(b) : ''}
3324
+ ${_botCommands[b.id] ? `<div style="padding:6px 16px 8px 42px;border-bottom:1px solid #21262d;background:#0d1117">
3325
+ <span style="font-size:11px;color:#8b949e;font-weight:600">commands:</span>
3326
+ ${_botCommands[b.id].map(c => `<code style="font-size:11px;margin-left:8px;background:#161b22;padding:1px 5px;border-radius:3px" title="${esc(c.description)}&#10;${esc(c.usage)}">${esc(c.command)}</code>`).join('')}
3327
+ </div>` : ''}
29783328
</div>
29793329
`).join('');
29803330
}
29813331
29823332
function onBehaviorToggle(id, enabled) {
@@ -3222,14 +3572,17 @@
32223572
// general
32233573
document.getElementById('general-api-addr').value = cfg.api_addr || '';
32243574
document.getElementById('general-mcp-addr').value = cfg.mcp_addr || '';
32253575
// ergo
32263576
const e = cfg.ergo || {};
3227
- document.getElementById('ergo-network-name').value = e.network_name || '';
3228
- document.getElementById('ergo-server-name').value = e.server_name || '';
3229
- document.getElementById('ergo-irc-addr').value = e.irc_addr || '';
3230
- document.getElementById('ergo-external').checked = !!e.external;
3577
+ document.getElementById('ergo-network-name').value = e.network_name || '';
3578
+ document.getElementById('ergo-server-name').value = e.server_name || '';
3579
+ document.getElementById('ergo-irc-addr').value = e.irc_addr || '';
3580
+ document.getElementById('ergo-require-sasl').checked = !!e.require_sasl;
3581
+ document.getElementById('ergo-default-modes').value = e.default_channel_modes || '';
3582
+ document.getElementById('ergo-history-enabled').checked = !!(e.history && e.history.enabled);
3583
+ document.getElementById('ergo-external').checked = !!e.external;
32313584
// tls
32323585
const t = cfg.tls || {};
32333586
document.getElementById('tls-domain').value = t.domain || '';
32343587
document.getElementById('tls-email').value = t.email || '';
32353588
document.getElementById('tls-allow-insecure').checked = !!t.allow_insecure;
@@ -3302,14 +3655,17 @@
33023655
}
33033656
33043657
function saveErgoConfig() {
33053658
saveConfigPatch({
33063659
ergo: {
3307
- network_name: document.getElementById('ergo-network-name').value.trim() || undefined,
3308
- server_name: document.getElementById('ergo-server-name').value.trim() || undefined,
3309
- irc_addr: document.getElementById('ergo-irc-addr').value.trim() || undefined,
3310
- external: document.getElementById('ergo-external').checked,
3660
+ network_name: document.getElementById('ergo-network-name').value.trim() || undefined,
3661
+ server_name: document.getElementById('ergo-server-name').value.trim() || undefined,
3662
+ irc_addr: document.getElementById('ergo-irc-addr').value.trim() || undefined,
3663
+ require_sasl: document.getElementById('ergo-require-sasl').checked,
3664
+ default_channel_modes: document.getElementById('ergo-default-modes').value.trim() || undefined,
3665
+ history: { enabled: document.getElementById('ergo-history-enabled').checked },
3666
+ external: document.getElementById('ergo-external').checked,
33113667
}
33123668
}, 'ergo-save-result');
33133669
}
33143670
33153671
function saveTLSConfig() {
33163672
33173673
ADDED internal/auth/apikeys.go
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -485,10 +485,40 @@
485 <button class="sm primary" onclick="quickJoin()">join</button>
486 </div>
487 </div>
488 <div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div>
489 </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
490 </div>
491 </div>
492
493 <!-- CHAT -->
494 <div class="tab-pane" id="pane-chat">
@@ -504,11 +534,11 @@
504 <div class="chan-list" id="chan-list"></div>
505 </div>
506 <div class="sidebar-resize" id="resize-left" title="drag to resize"></div>
507 <div class="chat-main">
508 <div class="chat-topbar">
509 <span class="chat-ch-name" id="chat-ch-name">select a channel</span>
510 <div class="spacer"></div>
511 <span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span>
512 <select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()">
513 <option value="">— pick a user —</option>
514 </select>
@@ -580,10 +610,40 @@
580 <button type="submit" class="primary sm" style="margin-bottom:1px">add admin</button>
581 </form>
582 <div id="add-admin-result" style="margin-top:10px"></div>
583 </div>
584 </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
585
586 <!-- tls -->
587 <div class="card" id="card-tls">
588 <div class="card-header" onclick="toggleCard('card-tls',event)"><h2>TLS / SSL</h2><span class="card-desc">certificate status</span><span class="collapse-icon">▾</span><div class="spacer"></div><span id="tls-badge" class="badge">loading…</span></div>
589 <div class="card-body">
@@ -605,10 +665,28 @@
605 </div>
606 <div class="card-body" style="padding:0">
607 <div id="behaviors-list"></div>
608 </div>
609 </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
610
611 <!-- agent policy -->
612 <div class="card" id="card-agentpolicy">
613 <div class="card-header" onclick="toggleCard('card-agentpolicy',event)"><h2>agent policy</h2><span class="card-desc">autojoin and check-in rules for all agents</span><span class="collapse-icon">▾</span><div class="spacer"></div><button class="sm primary" onclick="event.stopPropagation();saveAgentPolicy()">save</button></div>
614 <div class="card-body">
@@ -798,10 +876,31 @@
798 </div>
799 <div class="setting-row">
800 <div class="setting-label">IRC address</div>
801 <div class="setting-desc">Address Ergo listens on for IRC connections. Requires restart.</div>
802 <input type="text" id="ergo-irc-addr" placeholder="127.0.0.1:6667" style="width:180px;padding:4px 8px;font-size:12px">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
803 </div>
804 <div class="setting-row">
805 <div class="setting-label">external mode</div>
806 <div class="setting-desc">Disable subprocess management — scuttlebot expects Ergo to already be running. Requires restart.</div>
807 <label style="display:flex;align-items:center;gap:6px;cursor:pointer">
@@ -1726,10 +1825,19 @@
1726 allChannels = (data.channels || []).sort();
1727 renderChanList();
1728 } catch(e) {
1729 document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>';
1730 }
 
 
 
 
 
 
 
 
 
1731 }
1732
1733 function renderChanList() {
1734 const q = (document.getElementById('chan-search').value||'').toLowerCase();
1735 const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q));
@@ -1764,10 +1872,138 @@
1764 await loadChanTab();
1765 renderChanSidebar((await api('GET','/v1/channels')).channels||[]);
1766 } catch(e) { alert('Join failed: '+e.message); }
1767 }
1768 document.getElementById('quick-join-input').addEventListener('keydown', e => { if(e.key==='Enter')quickJoin(); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1769
1770 // --- chat ---
1771 let chatChannel = null, chatSSE = null;
1772
1773 async function loadChannels() {
@@ -1838,11 +2074,11 @@
1838 async function loadNicklist(ch) {
1839 if (!ch) return;
1840 try {
1841 const slug = ch.replace(/^#/,'');
1842 const data = await api('GET', `/v1/channels/${slug}/users`);
1843 renderNicklist(data.users || []);
1844 } catch(e) {}
1845 }
1846 const SYSTEM_BOTS = new Set(['bridge','oracle','sentinel','steward','scribe','warden','snitch','herald','scroll','systembot','auditbot']);
1847 const AGENT_PREFIXES = ['claude-','codex-','gemini-','openclaw-'];
1848
@@ -1861,24 +2097,35 @@
1861 if (tier === 0) return '@';
1862 if (tier === 1) return '+';
1863 return '';
1864 }
1865
1866 function renderNicklist(users) {
1867 const el = document.getElementById('nicklist-users');
 
 
1868 // Sort: ops > system bots > agents > users, alpha within each tier.
1869 const sorted = users.slice().sort((a, b) => {
1870 const ta = nickTier(a), tb = nickTier(b);
1871 if (ta !== tb) return ta - tb;
1872 return a.localeCompare(b);
1873 });
1874 el.innerHTML = sorted.map(nick => {
1875 const tier = nickTier(nick);
1876 const prefix = nickPrefix(nick);
1877 const cls = tier === 1 ? ' is-bot' : tier === 0 ? ' is-op' : '';
1878 return `<div class="nicklist-nick${cls}" title="${esc(nick)}">${prefix}${esc(nick)}</div>`;
 
 
 
 
 
 
1879 }).join('');
 
 
 
1880 }
1881 // Nick colors — deterministic hash over a palette
1882 const NICK_PALETTE = ['#58a6ff','#3fb950','#ffa657','#d2a8ff','#56d364','#79c0ff','#ff7b72','#a5d6ff','#f0883e','#39d353'];
1883 function nickColor(nick) {
1884 let h = 0;
@@ -1892,14 +2139,16 @@
1892 let _chatUnread = 0;
1893
1894 function appendMsg(msg, isHistory) {
1895 const area = document.getElementById('chat-msgs');
1896
1897 // Parse "[nick] text" sent by the bridge bot on behalf of a web user
1898 let displayNick = msg.nick;
1899 let displayText = msg.text;
1900 if (msg.nick === 'bridge') {
 
 
1901 const m = msg.text.match(/^\[([^\]]+)\] ([\s\S]*)$/);
1902 if (m) { displayNick = m[1]; displayText = m[2]; }
1903 }
1904
1905 const atMs = new Date(msg.at).getTime();
@@ -2542,10 +2791,73 @@
2542 try {
2543 await api('PUT', `/v1/admins/${encodeURIComponent(username)}/password`, { password: pw });
2544 alert('Password updated.');
2545 } catch(e) { alert('Failed: ' + e.message); }
2546 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2547
2548 // --- AI / LLM tab ---
2549 async function loadAI() {
2550 await Promise.all([loadAIBackends(), loadAIKnown()]);
2551 }
@@ -2899,10 +3211,41 @@
2899 if (body) body.style.display = '';
2900 }
2901
2902 // --- settings / policies ---
2903 let currentPolicies = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2904 let _llmBackendNames = []; // cached backend names for oracle dropdown
2905
2906 async function loadSettings() {
2907 try {
2908 const [s, backends] = await Promise.all([
@@ -2910,15 +3253,18 @@
2910 api('GET', '/v1/llm/backends').catch(() => []),
2911 ]);
2912 _llmBackendNames = (backends || []).map(b => b.name);
2913 renderTLSStatus(s.tls);
2914 currentPolicies = s.policies;
 
2915 renderBehaviors(s.policies.behaviors || []);
 
2916 renderAgentPolicy(s.policies.agent_policy || {});
2917 renderBridgePolicy(s.policies.bridge || {});
2918 renderLoggingPolicy(s.policies.logging || {});
2919 loadAdmins();
 
2920 loadConfigCards();
2921 } catch(e) {
2922 document.getElementById('tls-badge').textContent = 'error';
2923 }
2924 }
@@ -2973,10 +3319,14 @@
2973 ` : ''}
2974 <span class="tag type-observer" style="font-size:11px;min-width:64px;text-align:center">${esc(b.nick)}</span>
2975 </div>
2976 </div>
2977 ${b.enabled && hasSchema(b.id) ? renderBehConfig(b) : ''}
 
 
 
 
2978 </div>
2979 `).join('');
2980 }
2981
2982 function onBehaviorToggle(id, enabled) {
@@ -3222,14 +3572,17 @@
3222 // general
3223 document.getElementById('general-api-addr').value = cfg.api_addr || '';
3224 document.getElementById('general-mcp-addr').value = cfg.mcp_addr || '';
3225 // ergo
3226 const e = cfg.ergo || {};
3227 document.getElementById('ergo-network-name').value = e.network_name || '';
3228 document.getElementById('ergo-server-name').value = e.server_name || '';
3229 document.getElementById('ergo-irc-addr').value = e.irc_addr || '';
3230 document.getElementById('ergo-external').checked = !!e.external;
 
 
 
3231 // tls
3232 const t = cfg.tls || {};
3233 document.getElementById('tls-domain').value = t.domain || '';
3234 document.getElementById('tls-email').value = t.email || '';
3235 document.getElementById('tls-allow-insecure').checked = !!t.allow_insecure;
@@ -3302,14 +3655,17 @@
3302 }
3303
3304 function saveErgoConfig() {
3305 saveConfigPatch({
3306 ergo: {
3307 network_name: document.getElementById('ergo-network-name').value.trim() || undefined,
3308 server_name: document.getElementById('ergo-server-name').value.trim() || undefined,
3309 irc_addr: document.getElementById('ergo-irc-addr').value.trim() || undefined,
3310 external: document.getElementById('ergo-external').checked,
 
 
 
3311 }
3312 }, 'ergo-save-result');
3313 }
3314
3315 function saveTLSConfig() {
3316
3317 DDED internal/auth/apikeys.go
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -485,10 +485,40 @@
485 <button class="sm primary" onclick="quickJoin()">join</button>
486 </div>
487 </div>
488 <div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div>
489 </div>
490
491 <!-- topology panel -->
492 <div class="card" id="card-topology">
493 <div class="card-header" onclick="toggleCard('card-topology',event)">
494 <h2>topology</h2><span class="card-desc">channel types, provisioning rules, active task channels</span><span class="collapse-icon">▾</span>
495 <div class="spacer"></div>
496 <div style="display:flex;gap:6px;align-items:center">
497 <input type="text" id="provision-channel-input" placeholder="#project.name" style="width:160px;padding:5px 8px;font-size:12px" autocomplete="off">
498 <button class="sm primary" onclick="provisionChannel()">provision</button>
499 </div>
500 </div>
501 <div class="card-body" style="padding:0">
502 <div id="topology-types"></div>
503 <div id="topology-active" style="padding:12px 16px"><div class="empty">loading topology…</div></div>
504 </div>
505 </div>
506
507 <!-- ROE templates -->
508 <div class="card" id="card-roe">
509 <div class="card-header" onclick="toggleCard('card-roe',event)">
510 <h2>ROE templates</h2><span class="card-desc">rules-of-engagement presets for agent registration</span><span class="collapse-icon">▾</span>
511 <div class="spacer"></div>
512 <button class="sm primary" onclick="event.stopPropagation();savePolicies()">save</button>
513 </div>
514 <div class="card-body">
515 <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Define ROE templates applied to agents at registration. Includes channels, permissions, and rate limits.</p>
516 <div id="roe-list"></div>
517 <button class="sm" onclick="addROETemplate()" style="margin-top:10px">+ add template</button>
518 </div>
519 </div>
520 </div>
521 </div>
522
523 <!-- CHAT -->
524 <div class="tab-pane" id="pane-chat">
@@ -504,11 +534,11 @@
534 <div class="chan-list" id="chan-list"></div>
535 </div>
536 <div class="sidebar-resize" id="resize-left" title="drag to resize"></div>
537 <div class="chat-main">
538 <div class="chat-topbar">
539 <span class="chat-ch-name" id="chat-ch-name">select a channel</span><span id="chat-channel-modes" style="color:#8b949e;font-size:11px;margin-left:6px"></span>
540 <div class="spacer"></div>
541 <span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span>
542 <select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()">
543 <option value="">— pick a user —</option>
544 </select>
@@ -580,10 +610,40 @@
610 <button type="submit" class="primary sm" style="margin-bottom:1px">add admin</button>
611 </form>
612 <div id="add-admin-result" style="margin-top:10px"></div>
613 </div>
614 </div>
615
616 <!-- api keys -->
617 <div class="card" id="card-apikeys">
618 <div class="card-header" onclick="toggleCard('card-apikeys',event)"><h2>API keys</h2><span class="card-desc">per-consumer tokens with scoped permissions</span><span class="collapse-icon">▾</span></div>
619 <div id="apikeys-list-container"></div>
620 <div class="card-body" style="border-top:1px solid #21262d">
621 <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Create an API key with a name and scopes. The token is shown only once.</p>
622 <form id="add-apikey-form" onsubmit="createAPIKey(event)" style="display:flex;flex-direction:column;gap:10px">
623 <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
624 <div style="flex:1;min-width:160px"><label>name</label><input type="text" id="new-apikey-name" placeholder="e.g. kohakku-controller" autocomplete="off"></div>
625 <div style="flex:1;min-width:160px"><label>expires in</label><input type="text" id="new-apikey-expires" placeholder="e.g. 720h (empty=never)" autocomplete="off"></div>
626 </div>
627 <div>
628 <label style="margin-bottom:6px;display:block">scopes</label>
629 <div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px">
630 <label><input type="checkbox" value="admin" class="apikey-scope"> admin</label>
631 <label><input type="checkbox" value="agents" class="apikey-scope"> agents</label>
632 <label><input type="checkbox" value="channels" class="apikey-scope"> channels</label>
633 <label><input type="checkbox" value="chat" class="apikey-scope"> chat</label>
634 <label><input type="checkbox" value="topology" class="apikey-scope"> topology</label>
635 <label><input type="checkbox" value="bots" class="apikey-scope"> bots</label>
636 <label><input type="checkbox" value="config" class="apikey-scope"> config</label>
637 <label><input type="checkbox" value="read" class="apikey-scope"> read</label>
638 </div>
639 </div>
640 <button type="submit" class="primary sm" style="align-self:flex-start">create key</button>
641 </form>
642 <div id="add-apikey-result" style="margin-top:10px"></div>
643 </div>
644 </div>
645
646 <!-- tls -->
647 <div class="card" id="card-tls">
648 <div class="card-header" onclick="toggleCard('card-tls',event)"><h2>TLS / SSL</h2><span class="card-desc">certificate status</span><span class="collapse-icon">▾</span><div class="spacer"></div><span id="tls-badge" class="badge">loading…</span></div>
649 <div class="card-body">
@@ -605,10 +665,28 @@
665 </div>
666 <div class="card-body" style="padding:0">
667 <div id="behaviors-list"></div>
668 </div>
669 </div>
670
671 <!-- on-join instructions -->
672 <div class="card" id="card-onjoin">
673 <div class="card-header" onclick="toggleCard('card-onjoin',event)">
674 <h2>on-join instructions</h2><span class="card-desc">messages sent to agents when they join a channel</span><span class="collapse-icon">▾</span>
675 <div class="spacer"></div>
676 <button class="sm primary" onclick="event.stopPropagation();savePolicies()">save</button>
677 </div>
678 <div class="card-body">
679 <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Per-channel instructions delivered to agents on join. Supports <code>{nick}</code> and <code>{channel}</code> template variables.</p>
680 <div id="onjoin-list"></div>
681 <div style="display:flex;gap:8px;margin-top:12px;align-items:flex-end">
682 <div style="flex:0 0 160px"><label>channel</label><input type="text" id="onjoin-new-channel" placeholder="#channel" style="width:100%"></div>
683 <div style="flex:1"><label>message</label><input type="text" id="onjoin-new-message" placeholder="Welcome to {channel}, {nick}!" style="width:100%"></div>
684 <button class="sm primary" onclick="addOnJoinMessage()">add</button>
685 </div>
686 </div>
687 </div>
688
689 <!-- agent policy -->
690 <div class="card" id="card-agentpolicy">
691 <div class="card-header" onclick="toggleCard('card-agentpolicy',event)"><h2>agent policy</h2><span class="card-desc">autojoin and check-in rules for all agents</span><span class="collapse-icon">▾</span><div class="spacer"></div><button class="sm primary" onclick="event.stopPropagation();saveAgentPolicy()">save</button></div>
692 <div class="card-body">
@@ -798,10 +876,31 @@
876 </div>
877 <div class="setting-row">
878 <div class="setting-label">IRC address</div>
879 <div class="setting-desc">Address Ergo listens on for IRC connections. Requires restart.</div>
880 <input type="text" id="ergo-irc-addr" placeholder="127.0.0.1:6667" style="width:180px;padding:4px 8px;font-size:12px">
881 </div>
882 <div class="setting-row">
883 <div class="setting-label">require SASL</div>
884 <div class="setting-desc">Enforce SASL authentication for all IRC connections. Only registered accounts can connect. Hot-reloads.</div>
885 <label style="display:flex;align-items:center;gap:6px;cursor:pointer">
886 <input type="checkbox" id="ergo-require-sasl">
887 <span style="font-size:12px">enforce SASL</span>
888 </label>
889 </div>
890 <div class="setting-row">
891 <div class="setting-label">default channel modes</div>
892 <div class="setting-desc">Modes applied to new channels (e.g. "+n", "+Rn"). Hot-reloads.</div>
893 <input type="text" id="ergo-default-modes" placeholder="+n" style="width:120px;padding:4px 8px;font-size:12px">
894 </div>
895 <div class="setting-row">
896 <div class="setting-label">message history</div>
897 <div class="setting-desc">Enable persistent message history (CHATHISTORY). Hot-reloads.</div>
898 <label style="display:flex;align-items:center;gap:6px;cursor:pointer">
899 <input type="checkbox" id="ergo-history-enabled">
900 <span style="font-size:12px">enabled</span>
901 </label>
902 </div>
903 <div class="setting-row">
904 <div class="setting-label">external mode</div>
905 <div class="setting-desc">Disable subprocess management — scuttlebot expects Ergo to already be running. Requires restart.</div>
906 <label style="display:flex;align-items:center;gap:6px;cursor:pointer">
@@ -1726,10 +1825,19 @@
1825 allChannels = (data.channels || []).sort();
1826 renderChanList();
1827 } catch(e) {
1828 document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>';
1829 }
1830 loadTopology();
1831 // Load ROE templates from policies for the ROE card.
1832 try {
1833 const s = await api('GET', '/v1/settings');
1834 if (s && s.policies) {
1835 currentPolicies = s.policies;
1836 renderROETemplates(s.policies.roe_templates || []);
1837 }
1838 } catch(e) {}
1839 }
1840
1841 function renderChanList() {
1842 const q = (document.getElementById('chan-search').value||'').toLowerCase();
1843 const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q));
@@ -1764,10 +1872,138 @@
1872 await loadChanTab();
1873 renderChanSidebar((await api('GET','/v1/channels')).channels||[]);
1874 } catch(e) { alert('Join failed: '+e.message); }
1875 }
1876 document.getElementById('quick-join-input').addEventListener('keydown', e => { if(e.key==='Enter')quickJoin(); });
1877
1878 // --- topology panel (#115) + task channels (#114) ---
1879 async function loadTopology() {
1880 try {
1881 const data = await api('GET', '/v1/topology');
1882 renderTopologyTypes(data.types || []);
1883 renderTopologyActive(data.active_channels || [], data.types || []);
1884 } catch(e) {
1885 document.getElementById('topology-types').innerHTML = '';
1886 document.getElementById('topology-active').innerHTML = '<div style="color:#8b949e;font-size:12px">topology not configured</div>';
1887 }
1888 }
1889
1890 function renderTopologyTypes(types) {
1891 if (!types.length) { document.getElementById('topology-types').innerHTML = ''; return; }
1892 const rows = types.map(t => {
1893 const ttl = t.ttl_seconds > 0 ? `${Math.round(t.ttl_seconds/3600)}h` : '—';
1894 const tags = [];
1895 if (t.ephemeral) tags.push('<span style="background:#f8514922;color:#f85149;padding:1px 5px;border-radius:3px;font-size:10px">ephemeral</span>');
1896 if (t.supervision) tags.push(`<span style="font-size:11px;color:#8b949e">→ ${esc(t.supervision)}</span>`);
1897 return `<tr>
1898 <td><strong>${esc(t.name)}</strong></td>
1899 <td><code style="font-size:11px">#${esc(t.prefix)}*</code></td>
1900 <td style="font-size:12px">${(t.autojoin||[]).map(n => `<code style="font-size:11px;background:#21262d;padding:1px 4px;border-radius:3px">${esc(n)}</code>`).join(' ')}</td>
1901 <td style="font-size:12px">${ttl}</td>
1902 <td>${tags.join(' ')}</td>
1903 </tr>`;
1904 }).join('');
1905 document.getElementById('topology-types').innerHTML = `<table><thead><tr><th>type</th><th>prefix</th><th>autojoin</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
1906 }
1907
1908 function renderTopologyActive(channels, types) {
1909 const el = document.getElementById('topology-active');
1910 const tasks = channels.filter(c => c.ephemeral || c.type === 'task');
1911 if (!tasks.length) {
1912 el.innerHTML = '<div style="color:#8b949e;font-size:12px;padding:4px 0">no active task channels</div>';
1913 return;
1914 }
1915 const rows = tasks.map(c => {
1916 const age = c.provisioned_at ? timeSince(new Date(c.provisioned_at)) : '—';
1917 const ttl = c.ttl_seconds > 0 ? `${Math.round(c.ttl_seconds/3600)}h` : '—';
1918 return `<tr>
1919 <td><strong>${esc(c.name)}</strong></td>
1920 <td style="font-size:12px;color:#8b949e">${esc(c.type || '—')}</td>
1921 <td style="font-size:12px">${age}</td>
1922 <td style="font-size:12px">${ttl}</td>
1923 <td><button class="sm danger" onclick="dropChannel('${esc(c.name)}')">drop</button></td>
1924 </tr>`;
1925 }).join('');
1926 el.innerHTML = `<table><thead><tr><th>channel</th><th>type</th><th>age</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
1927 }
1928
1929 function timeSince(date) {
1930 const s = Math.floor((new Date() - date) / 1000);
1931 if (s < 60) return s + 's';
1932 if (s < 3600) return Math.floor(s/60) + 'm';
1933 if (s < 86400) return Math.floor(s/3600) + 'h';
1934 return Math.floor(s/86400) + 'd';
1935 }
1936
1937 async function provisionChannel() {
1938 let ch = document.getElementById('provision-channel-input').value.trim();
1939 if (!ch) return;
1940 if (!ch.startsWith('#')) ch = '#' + ch;
1941 try {
1942 await api('POST', '/v1/channels', {name: ch});
1943 document.getElementById('provision-channel-input').value = '';
1944 loadTopology();
1945 loadChanTab();
1946 } catch(e) { alert('Provision failed: ' + e.message); }
1947 }
1948
1949 async function dropChannel(ch) {
1950 if (!confirm('Drop channel ' + ch + '? This unregisters it from ChanServ.')) return;
1951 const slug = ch.replace(/^#/,'');
1952 try {
1953 await api('DELETE', `/v1/topology/channels/${slug}`);
1954 loadTopology();
1955 loadChanTab();
1956 } catch(e) { alert('Drop failed: ' + e.message); }
1957 }
1958
1959 // --- ROE template editor (#118) ---
1960 function renderROETemplates(templates) {
1961 const el = document.getElementById('roe-list');
1962 if (!templates || !templates.length) {
1963 el.innerHTML = '<div style="color:#8b949e;font-size:12px">No ROE templates defined. Click + add template to create one.</div>';
1964 return;
1965 }
1966 el.innerHTML = templates.map((t, i) => `
1967 <div style="border:1px solid #21262d;border-radius:6px;padding:12px;margin-bottom:10px;background:#0d1117">
1968 <div style="display:flex;gap:10px;align-items:center;margin-bottom:8px">
1969 <input type="text" value="${esc(t.name)}" placeholder="template name" style="flex:1;font-weight:600" onchange="updateROE(${i},'name',this.value)">
1970 <button class="sm danger" onclick="removeROE(${i})">remove</button>
1971 </div>
1972 <div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:6px">
1973 <div style="flex:1;min-width:200px"><label style="font-size:11px">channels (comma-separated)</label><input type="text" value="${esc((t.channels||[]).join(', '))}" onchange="updateROE(${i},'channels',this.value)" style="width:100%"></div>
1974 <div style="flex:1;min-width:200px"><label style="font-size:11px">permissions</label><input type="text" value="${esc((t.permissions||[]).join(', '))}" onchange="updateROE(${i},'permissions',this.value)" style="width:100%"></div>
1975 </div>
1976 <div style="display:flex;gap:10px">
1977 <div><label style="font-size:11px">msg/sec</label><input type="number" value="${t.rate_limit?.messages_per_second||''}" placeholder="10" style="width:70px" onchange="updateROERateLimit(${i},'messages_per_second',this.value)"></div>
1978 <div><label style="font-size:11px">burst</label><input type="number" value="${t.rate_limit?.burst||''}" placeholder="50" style="width:70px" onchange="updateROERateLimit(${i},'burst',this.value)"></div>
1979 <div style="flex:1"><label style="font-size:11px">description</label><input type="text" value="${esc(t.description||'')}" onchange="updateROE(${i},'description',this.value)" style="width:100%"></div>
1980 </div>
1981 </div>
1982 `).join('');
1983 }
1984
1985 function addROETemplate() {
1986 if (!currentPolicies.roe_templates) currentPolicies.roe_templates = [];
1987 currentPolicies.roe_templates.push({name: 'new-template', channels: [], permissions: []});
1988 renderROETemplates(currentPolicies.roe_templates);
1989 }
1990 function removeROE(i) {
1991 currentPolicies.roe_templates.splice(i, 1);
1992 renderROETemplates(currentPolicies.roe_templates);
1993 }
1994 function updateROE(i, field, val) {
1995 if (field === 'channels' || field === 'permissions') {
1996 currentPolicies.roe_templates[i][field] = val.split(',').map(s => s.trim()).filter(Boolean);
1997 } else {
1998 currentPolicies.roe_templates[i][field] = val;
1999 }
2000 }
2001 function updateROERateLimit(i, field, val) {
2002 if (!currentPolicies.roe_templates[i].rate_limit) currentPolicies.roe_templates[i].rate_limit = {};
2003 currentPolicies.roe_templates[i].rate_limit[field] = Number(val) || 0;
2004 }
2005
2006 // --- chat ---
2007 let chatChannel = null, chatSSE = null;
2008
2009 async function loadChannels() {
@@ -1838,11 +2074,11 @@
2074 async function loadNicklist(ch) {
2075 if (!ch) return;
2076 try {
2077 const slug = ch.replace(/^#/,'');
2078 const data = await api('GET', `/v1/channels/${slug}/users`);
2079 renderNicklist(data.users || [], data.channel_modes || '');
2080 } catch(e) {}
2081 }
2082 const SYSTEM_BOTS = new Set(['bridge','oracle','sentinel','steward','scribe','warden','snitch','herald','scroll','systembot','auditbot']);
2083 const AGENT_PREFIXES = ['claude-','codex-','gemini-','openclaw-'];
2084
@@ -1861,24 +2097,35 @@
2097 if (tier === 0) return '@';
2098 if (tier === 1) return '+';
2099 return '';
2100 }
2101
2102 function renderNicklist(users, channelModes) {
2103 const el = document.getElementById('nicklist-users');
2104 // users may be [{nick, modes}] or ["nick"] for backwards compat.
2105 const normalized = users.map(u => typeof u === 'string' ? {nick: u, modes: []} : u);
2106 // Sort: ops > system bots > agents > users, alpha within each tier.
2107 const sorted = normalized.slice().sort((a, b) => {
2108 const ta = nickTier(a.nick), tb = nickTier(b.nick);
2109 if (ta !== tb) return ta - tb;
2110 return a.nick.localeCompare(b.nick);
2111 });
2112 el.innerHTML = sorted.map(u => {
2113 const modes = u.modes || [];
2114 // IRC mode prefix: @ for op, + for voice
2115 let prefix = '';
2116 if (modes.includes('o') || modes.includes('a') || modes.includes('q')) prefix = '@';
2117 else if (modes.includes('v')) prefix = '+';
2118 else prefix = nickPrefix(u.nick);
2119 const tier = nickTier(u.nick);
2120 const cls = (modes.includes('o') || tier === 0) ? ' is-op' : tier === 1 ? ' is-bot' : '';
2121 const modeStr = modes.length ? ` [+${modes.join('')}]` : '';
2122 return `<div class="nicklist-nick${cls}" title="${esc(u.nick)}${modeStr}">${prefix}${esc(u.nick)}</div>`;
2123 }).join('');
2124 // Show channel modes in header if available.
2125 const modesEl = document.getElementById('chat-channel-modes');
2126 if (modesEl) modesEl.textContent = channelModes ? ` ${channelModes}` : '';
2127 }
2128 // Nick colors — deterministic hash over a palette
2129 const NICK_PALETTE = ['#58a6ff','#3fb950','#ffa657','#d2a8ff','#56d364','#79c0ff','#ff7b72','#a5d6ff','#f0883e','#39d353'];
2130 function nickColor(nick) {
2131 let h = 0;
@@ -1892,14 +2139,16 @@
2139 let _chatUnread = 0;
2140
2141 function appendMsg(msg, isHistory) {
2142 const area = document.getElementById('chat-msgs');
2143
2144 // Attribution: RELAYMSG delivers nicks as "user/bridge"; legacy uses "[nick] text".
2145 let displayNick = msg.nick;
2146 let displayText = msg.text;
2147 if (msg.nick && msg.nick.endsWith('/bridge')) {
2148 displayNick = msg.nick.slice(0, -'/bridge'.length);
2149 } else if (msg.nick === 'bridge') {
2150 const m = msg.text.match(/^\[([^\]]+)\] ([\s\S]*)$/);
2151 if (m) { displayNick = m[1]; displayText = m[2]; }
2152 }
2153
2154 const atMs = new Date(msg.at).getTime();
@@ -2542,10 +2791,73 @@
2791 try {
2792 await api('PUT', `/v1/admins/${encodeURIComponent(username)}/password`, { password: pw });
2793 alert('Password updated.');
2794 } catch(e) { alert('Failed: ' + e.message); }
2795 }
2796
2797 // --- API keys ---
2798 async function loadAPIKeys() {
2799 try {
2800 const keys = await api('GET', '/v1/api-keys');
2801 renderAPIKeys(keys || []);
2802 } catch(e) {
2803 document.getElementById('apikeys-list-container').innerHTML = '';
2804 }
2805 }
2806
2807 function renderAPIKeys(keys) {
2808 const el = document.getElementById('apikeys-list-container');
2809 if (!keys.length) { el.innerHTML = ''; return; }
2810 const rows = keys.map(k => {
2811 const status = k.active ? '<span style="color:#3fb950">active</span>' : '<span style="color:#f85149">revoked</span>';
2812 const scopes = (k.scopes || []).map(s => `<code style="font-size:11px;background:#21262d;padding:1px 5px;border-radius:3px">${esc(s)}</code>`).join(' ');
2813 const lastUsed = k.last_used ? fmtTime(k.last_used) : '—';
2814 const revokeBtn = k.active ? `<button class="sm danger" onclick="revokeAPIKey('${esc(k.id)}')">revoke</button>` : '';
2815 return `<tr>
2816 <td><strong>${esc(k.name)}</strong><br><span style="color:#8b949e;font-size:11px">${esc(k.id)}</span></td>
2817 <td>${scopes}</td>
2818 <td style="font-size:12px">${status}</td>
2819 <td style="color:#8b949e;font-size:12px">${lastUsed}</td>
2820 <td><div class="actions">${revokeBtn}</div></td>
2821 </tr>`;
2822 }).join('');
2823 el.innerHTML = `<table><thead><tr><th>name</th><th>scopes</th><th>status</th><th>last used</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
2824 }
2825
2826 async function createAPIKey(e) {
2827 e.preventDefault();
2828 const name = document.getElementById('new-apikey-name').value.trim();
2829 const expires = document.getElementById('new-apikey-expires').value.trim();
2830 const scopes = [...document.querySelectorAll('.apikey-scope:checked')].map(cb => cb.value);
2831 const resultEl = document.getElementById('add-apikey-result');
2832 if (!name) { resultEl.innerHTML = '<span style="color:#f85149">name is required</span>'; return; }
2833 if (!scopes.length) { resultEl.innerHTML = '<span style="color:#f85149">select at least one scope</span>'; return; }
2834 try {
2835 const body = { name, scopes };
2836 if (expires) body.expires_in = expires;
2837 const result = await api('POST', '/v1/api-keys', body);
2838 resultEl.innerHTML = `<div style="background:#0d1117;border:1px solid #3fb95044;border-radius:6px;padding:12px;margin-top:8px">
2839 <div style="color:#3fb950;font-weight:600;margin-bottom:6px">Key created: ${esc(result.name)}</div>
2840 <div style="margin-bottom:4px;font-size:12px;color:#8b949e">Copy this token now — it will not be shown again:</div>
2841 <code style="display:block;padding:8px;background:#161b22;border-radius:4px;word-break:break-all;user-select:all">${esc(result.token)}</code>
2842 </div>`;
2843 document.getElementById('new-apikey-name').value = '';
2844 document.getElementById('new-apikey-expires').value = '';
2845 document.querySelectorAll('.apikey-scope:checked').forEach(cb => cb.checked = false);
2846 loadAPIKeys();
2847 } catch(e) {
2848 resultEl.innerHTML = `<span style="color:#f85149">${esc(e.message)}</span>`;
2849 }
2850 }
2851
2852 async function revokeAPIKey(id) {
2853 if (!confirm('Revoke this API key? This cannot be undone.')) return;
2854 try {
2855 await api('DELETE', `/v1/api-keys/${encodeURIComponent(id)}`);
2856 loadAPIKeys();
2857 } catch(e) { alert('Failed: ' + e.message); }
2858 }
2859
2860 // --- AI / LLM tab ---
2861 async function loadAI() {
2862 await Promise.all([loadAIBackends(), loadAIKnown()]);
2863 }
@@ -2899,10 +3211,41 @@
3211 if (body) body.style.display = '';
3212 }
3213
3214 // --- settings / policies ---
3215 let currentPolicies = null;
3216 let _botCommands = {};
3217
3218 function renderOnJoinMessages(msgs) {
3219 const el = document.getElementById('onjoin-list');
3220 if (!msgs || !Object.keys(msgs).length) { el.innerHTML = '<div style="color:#8b949e;font-size:12px">No on-join instructions configured.</div>'; return; }
3221 el.innerHTML = Object.entries(msgs).sort().map(([ch, msg]) => `
3222 <div style="display:flex;gap:8px;align-items:center;padding:6px 0;border-bottom:1px solid #21262d">
3223 <code style="font-size:12px;min-width:120px">${esc(ch)}</code>
3224 <input type="text" value="${esc(msg)}" style="flex:1;font-size:12px" onchange="updateOnJoinMessage('${esc(ch)}',this.value)">
3225 <button class="sm danger" onclick="removeOnJoinMessage('${esc(ch)}')">remove</button>
3226 </div>
3227 `).join('');
3228 }
3229 function addOnJoinMessage() {
3230 const ch = document.getElementById('onjoin-new-channel').value.trim();
3231 const msg = document.getElementById('onjoin-new-message').value.trim();
3232 if (!ch || !msg) return;
3233 if (!currentPolicies.on_join_messages) currentPolicies.on_join_messages = {};
3234 currentPolicies.on_join_messages[ch] = msg;
3235 document.getElementById('onjoin-new-channel').value = '';
3236 document.getElementById('onjoin-new-message').value = '';
3237 renderOnJoinMessages(currentPolicies.on_join_messages);
3238 }
3239 function updateOnJoinMessage(ch, msg) {
3240 if (!currentPolicies.on_join_messages) currentPolicies.on_join_messages = {};
3241 currentPolicies.on_join_messages[ch] = msg;
3242 }
3243 function removeOnJoinMessage(ch) {
3244 if (currentPolicies.on_join_messages) delete currentPolicies.on_join_messages[ch];
3245 renderOnJoinMessages(currentPolicies.on_join_messages);
3246 }
3247 let _llmBackendNames = []; // cached backend names for oracle dropdown
3248
3249 async function loadSettings() {
3250 try {
3251 const [s, backends] = await Promise.all([
@@ -2910,15 +3253,18 @@
3253 api('GET', '/v1/llm/backends').catch(() => []),
3254 ]);
3255 _llmBackendNames = (backends || []).map(b => b.name);
3256 renderTLSStatus(s.tls);
3257 currentPolicies = s.policies;
3258 _botCommands = s.bot_commands || {};
3259 renderBehaviors(s.policies.behaviors || []);
3260 renderOnJoinMessages(s.policies.on_join_messages || {});
3261 renderAgentPolicy(s.policies.agent_policy || {});
3262 renderBridgePolicy(s.policies.bridge || {});
3263 renderLoggingPolicy(s.policies.logging || {});
3264 loadAdmins();
3265 loadAPIKeys();
3266 loadConfigCards();
3267 } catch(e) {
3268 document.getElementById('tls-badge').textContent = 'error';
3269 }
3270 }
@@ -2973,10 +3319,14 @@
3319 ` : ''}
3320 <span class="tag type-observer" style="font-size:11px;min-width:64px;text-align:center">${esc(b.nick)}</span>
3321 </div>
3322 </div>
3323 ${b.enabled && hasSchema(b.id) ? renderBehConfig(b) : ''}
3324 ${_botCommands[b.id] ? `<div style="padding:6px 16px 8px 42px;border-bottom:1px solid #21262d;background:#0d1117">
3325 <span style="font-size:11px;color:#8b949e;font-weight:600">commands:</span>
3326 ${_botCommands[b.id].map(c => `<code style="font-size:11px;margin-left:8px;background:#161b22;padding:1px 5px;border-radius:3px" title="${esc(c.description)}&#10;${esc(c.usage)}">${esc(c.command)}</code>`).join('')}
3327 </div>` : ''}
3328 </div>
3329 `).join('');
3330 }
3331
3332 function onBehaviorToggle(id, enabled) {
@@ -3222,14 +3572,17 @@
3572 // general
3573 document.getElementById('general-api-addr').value = cfg.api_addr || '';
3574 document.getElementById('general-mcp-addr').value = cfg.mcp_addr || '';
3575 // ergo
3576 const e = cfg.ergo || {};
3577 document.getElementById('ergo-network-name').value = e.network_name || '';
3578 document.getElementById('ergo-server-name').value = e.server_name || '';
3579 document.getElementById('ergo-irc-addr').value = e.irc_addr || '';
3580 document.getElementById('ergo-require-sasl').checked = !!e.require_sasl;
3581 document.getElementById('ergo-default-modes').value = e.default_channel_modes || '';
3582 document.getElementById('ergo-history-enabled').checked = !!(e.history && e.history.enabled);
3583 document.getElementById('ergo-external').checked = !!e.external;
3584 // tls
3585 const t = cfg.tls || {};
3586 document.getElementById('tls-domain').value = t.domain || '';
3587 document.getElementById('tls-email').value = t.email || '';
3588 document.getElementById('tls-allow-insecure').checked = !!t.allow_insecure;
@@ -3302,14 +3655,17 @@
3655 }
3656
3657 function saveErgoConfig() {
3658 saveConfigPatch({
3659 ergo: {
3660 network_name: document.getElementById('ergo-network-name').value.trim() || undefined,
3661 server_name: document.getElementById('ergo-server-name').value.trim() || undefined,
3662 irc_addr: document.getElementById('ergo-irc-addr').value.trim() || undefined,
3663 require_sasl: document.getElementById('ergo-require-sasl').checked,
3664 default_channel_modes: document.getElementById('ergo-default-modes').value.trim() || undefined,
3665 history: { enabled: document.getElementById('ergo-history-enabled').checked },
3666 external: document.getElementById('ergo-external').checked,
3667 }
3668 }, 'ergo-save-result');
3669 }
3670
3671 function saveTLSConfig() {
3672
3673 DDED internal/auth/apikeys.go
--- a/internal/auth/apikeys.go
+++ b/internal/auth/apikeys.go
@@ -0,0 +1,288 @@
1
+package auth
2
+
3
+import (
4
+ "crypto/rand"
5
+ "crypto/sha256"
6
+ "encoding/hex"
7
+ "encoding/json"
8
+ "fmt"
9
+ "os"
10
+ "strings"
11
+ "sync"
12
+ "time"
13
+
14
+ "github.com/oklog/ulid/v2"
15
+)
16
+
17
+// Scope represents a permission scope for an API key.
18
+type Scope string
19
+
20
+const (
21
+ ScopeAdmin Scope = "admin" // full access
22
+ ScopeAgents Scope = "agents" // agent registration, rotation, revocation
23
+ ScopeChannels Scope = "channels" // channel CRUD, join, messages, presence
24
+ ScopeTopology Scope = "topology" // channel provisioning, topology management
25
+ ScopeBots Scope = "bots" // bot configuration, start/stop
26
+ ScopeConfig Scope = "config" // server config read/write
27
+ ScopeRead Scope = "read" // read-only access to all GET endpoints
28
+ ScopeChat Scope = "chat" // send/receive messages only
29
+)
30
+
31
+// ValidScopes is the set of all recognised scopes.
32
+var ValidScopes = map[Scope]bool{
33
+ ScopeAdmin: true, ScopeAgents: true, ScopeChannels: true,
34
+ ScopeTopology: true, ScopeBots: true, ScopeConfig: true,
35
+ ScopeRead: true, ScopeChat: true,
36
+}
37
+
38
+// APIKey is a single API key record.
39
+type APIKey struct {
40
+ ID string `json:"id"`
41
+ Name string `json:"name"`
42
+ Hash string `json:"hash"` // SHA-256 of the plaintext token
43
+ Scopes []Scope `json:"scopes"`
44
+ CreatedAt time.Time `json:"created_at"`
45
+ LastUsed time.Time `json:"last_used,omitempty"`
46
+ ExpiresAt time.Time `json:"expires_at,omitempty"` // zero = never
47
+ Active bool `json:"active"`
48
+}
49
+
50
+// HasScope reports whether the key has the given scope (or admin, which implies all).
51
+func (k *APIKey) HasScope(s Scope) bool {
52
+ for _, scope := range k.Scopes {
53
+ if scope == ScopeAdmin || scope == s {
54
+ return true
55
+ }
56
+ }
57
+ return false
58
+}
59
+
60
+// IsExpired reports whether the key has passed its expiry time.
61
+func (k *APIKey) IsExpired() bool {
62
+ return !k.ExpiresAt.IsZero() && time.Now().After(k.ExpiresAt)
63
+}
64
+
65
+// APIKeyStore persists API keys to a JSON file.
66
+type APIKeyStore struct {
67
+ mu sync.RWMutex
68
+ path string
69
+ data []APIKey
70
+}
71
+
72
+// NewAPIKeyStore loads (or creates) the API key store at the given path.
73
+func NewAPIKeyStore(path string) (*APIKeyStore, error) {
74
+ s := &APIKeyStore{path: path}
75
+ if err := s.load(); err != nil {
76
+ return nil, err
77
+ }
78
+ return s, nil
79
+}
80
+
81
+// Create generates a new API key with the given name and scopes.
82
+// Returns the plaintext token (shown only once) and the stored key record.
83
+func (s *APIKeyStore) Create(name string, scopes []Scope, expiresAt time.Time) (plaintext string, key APIKey, err error) {
84
+ s.mu.Lock()
85
+ defer s.mu.Unlock()
86
+
87
+ token, err := genToken()
88
+ if err != nil {
89
+ return "", APIKey{}, fmt.Errorf("apikeys: generate token: %w", err)
90
+ }
91
+
92
+ key = APIKey{
93
+ ID: newULID(),
94
+ Name: name,
95
+ Hash: hashToken(token),
96
+ Scopes: scopes,
97
+ CreatedAt: time.Now().UTC(),
98
+ ExpiresAt: expiresAt,
99
+ Active: true,
100
+ }
101
+ s.data = append(s.data, key)
102
+ if err := s.save(); err != nil {
103
+ // Roll back.
104
+ s.data = s.data[:len(s.data)-1]
105
+ return "", APIKey{}, err
106
+ }
107
+ return token, key, nil
108
+}
109
+
110
+// Insert adds a pre-built API key with a known plaintext token.
111
+// Used for migrating the startup token into the store.
112
+func (s *APIKeyStore) Insert(name, plaintext string, scopes []Scope) (APIKey, error) {
113
+ s.mu.Lock()
114
+ defer s.mu.Unlock()
115
+
116
+ key := APIKey{
117
+ ID: newULID(),
118
+ Name: name,
119
+ Hash: hashToken(plaintext),
120
+ Scopes: scopes,
121
+ CreatedAt: time.Now().UTC(),
122
+ Active: true,
123
+ }
124
+ s.data = append(s.data, key)
125
+ if err := s.save(); err != nil {
126
+ s.data = s.data[:len(s.data)-1]
127
+ return APIKey{}, err
128
+ }
129
+ return key, nil
130
+}
131
+
132
+// Lookup finds an active, non-expired key by plaintext token.
133
+// Returns nil if no match.
134
+func (s *APIKeyStore) Lookup(token string) *APIKey {
135
+ hash := hashToken(token)
136
+ s.mu.RLock()
137
+ defer s.mu.RUnlock()
138
+ for i := range s.data {
139
+ if s.data[i].Hash == hash && s.data[i].Active && !s.data[i].IsExpired() {
140
+ k := s.data[i]
141
+ return &k
142
+ }
143
+ }
144
+ return nil
145
+}
146
+
147
+// TouchLastUsed updates the last-used timestamp for a key by ID.
148
+func (s *APIKeyStore) TouchLastUsed(id string) {
149
+ s.mu.Lock()
150
+ defer s.mu.Unlock()
151
+ for i := range s.data {
152
+ if s.data[i].ID == id {
153
+ s.data[i].LastUsed = time.Now().UTC()
154
+ _ = s.save() // best-effort persistence
155
+ return
156
+ }
157
+ }
158
+}
159
+
160
+// Get returns a key by ID, or nil if not found.
161
+func (s *APIKeyStore) Get(id string) *APIKey {
162
+ s.mu.RLock()
163
+ defer s.mu.RUnlock()
164
+ for i := range s.data {
165
+ if s.data[i].ID == id {
166
+ k := s.data[i]
167
+ return &k
168
+ }
169
+ }
170
+ return nil
171
+}
172
+
173
+// List returns all keys (active and revoked).
174
+func (s *APIKeyStore) List() []APIKey {
175
+ s.mu.RLock()
176
+ defer s.mu.RUnlock()
177
+ out := make([]APIKey, len(s.data))
178
+ copy(out, s.data)
179
+ return out
180
+}
181
+
182
+// Revoke deactivates a key by ID.
183
+func (s *APIKeyStore) Revoke(id string) error {
184
+ s.mu.Lock()
185
+ defer s.mu.Unlock()
186
+ for i := range s.data {
187
+ if s.data[i].ID == id {
188
+ if !s.data[i].Active {
189
+ return fmt.Errorf("apikeys: key %q already revoked", id)
190
+ }
191
+ s.data[i].Active = false
192
+ return s.save()
193
+ }
194
+ }
195
+ return fmt.Errorf("apikeys: key %q not found", id)
196
+}
197
+
198
+// Lookup (TokenValidator interface) reports whether the token is valid.
199
+// Satisfies the mcp.TokenValidator interface.
200
+func (s *APIKeyStore) ValidToken(token string) bool {
201
+ return s.Lookup(token) != nil
202
+}
203
+
204
+// TestStore creates an in-memory APIKeyStore with a single admin-scope key
205
+// for the given token. Intended for tests only — does not persist to disk.
206
+func TestStore(token string) *APIKeyStore {
207
+ s := &APIKeyStore{path: "", data: []APIKey{{
208
+ ID: "test-key",
209
+ Name: "test",
210
+ Hash: hashToken(token),
211
+ Scopes: []Scope{ScopeAdmin},
212
+ CreatedAt: time.Now().UTC(),
213
+ Active: true,
214
+ }}}
215
+ return s
216
+}
217
+
218
+// IsEmpty reports whether there are no keys.
219
+func (s *APIKeyStore) IsEmpty() bool {
220
+ s.mu.RLock()
221
+ defer s.mu.RUnlock()
222
+ return len(s.data) == 0
223
+}
224
+
225
+func (s *APIKeyStore) load() error {
226
+ raw, err := os.ReadFile(s.path)
227
+ if os.IsNotExist(err) {
228
+ return nil
229
+ }
230
+ if err != nil {
231
+ return fmt.Errorf("apikeys: read %s: %w", s.path, err)
232
+ }
233
+ if err := json.Unmarshal(raw, &s.data); err != nil {
234
+ return fmt.Errorf("apikeys: parse: %w", err)
235
+ }
236
+ return nil
237
+}
238
+
239
+func (s *APIKeyStore) save() error {
240
+ if s.path == "" {
241
+ return nil // in-memory only (tests)
242
+ }
243
+ raw, err := json.MarshalIndent(s.data, "", " ")
244
+ if err != nil {
245
+ return err
246
+ }
247
+ return os.WriteFile(s.path, raw, 0600)
248
+}
249
+
250
+func hashToken(token string) string {
251
+ h := sha256.Sum256([]byte(token))
252
+ return hex.EncodeToString(h[:])
253
+}
254
+
255
+func genToken() (string, error) {
256
+ b := make([]byte, 32)
257
+ if _, err := rand.Read(b); err != nil {
258
+ return "", err
259
+ }
260
+ return hex.EncodeToString(b), nil
261
+}
262
+
263
+func newULID() string {
264
+ entropy := ulid.Monotonic(rand.Reader, 0)
265
+ return ulid.MustNew(ulid.Timestamp(time.Now()), entropy).String()
266
+}
267
+
268
+// ParseScopes parses a comma-separated scope string into a slice.
269
+// Returns an error if any scope is unrecognised.
270
+func ParseScopes(s string) ([]Scope, error) {
271
+ parts := strings.Split(s, ",")
272
+ scopes := make([]Scope, 0, len(parts))
273
+ for _, p := range parts {
274
+ p = strings.TrimSpace(p)
275
+ if p == "" {
276
+ continue
277
+ }
278
+ scope := Scope(p)
279
+ if !ValidScopes[scope] {
280
+ return nil, fmt.Errorf("unknown scope %q", p)
281
+ }
282
+ scopes = append(scopes, scope)
283
+ }
284
+ if len(scopes) == 0 {
285
+ return nil, fmt.Errorf("at least one scope is required")
286
+ }
287
+ return scopes, nil
288
+}
--- a/internal/auth/apikeys.go
+++ b/internal/auth/apikeys.go
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/auth/apikeys.go
+++ b/internal/auth/apikeys.go
@@ -0,0 +1,288 @@
1 package auth
2
3 import (
4 "crypto/rand"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "fmt"
9 "os"
10 "strings"
11 "sync"
12 "time"
13
14 "github.com/oklog/ulid/v2"
15 )
16
17 // Scope represents a permission scope for an API key.
18 type Scope string
19
20 const (
21 ScopeAdmin Scope = "admin" // full access
22 ScopeAgents Scope = "agents" // agent registration, rotation, revocation
23 ScopeChannels Scope = "channels" // channel CRUD, join, messages, presence
24 ScopeTopology Scope = "topology" // channel provisioning, topology management
25 ScopeBots Scope = "bots" // bot configuration, start/stop
26 ScopeConfig Scope = "config" // server config read/write
27 ScopeRead Scope = "read" // read-only access to all GET endpoints
28 ScopeChat Scope = "chat" // send/receive messages only
29 )
30
31 // ValidScopes is the set of all recognised scopes.
32 var ValidScopes = map[Scope]bool{
33 ScopeAdmin: true, ScopeAgents: true, ScopeChannels: true,
34 ScopeTopology: true, ScopeBots: true, ScopeConfig: true,
35 ScopeRead: true, ScopeChat: true,
36 }
37
38 // APIKey is a single API key record.
39 type APIKey struct {
40 ID string `json:"id"`
41 Name string `json:"name"`
42 Hash string `json:"hash"` // SHA-256 of the plaintext token
43 Scopes []Scope `json:"scopes"`
44 CreatedAt time.Time `json:"created_at"`
45 LastUsed time.Time `json:"last_used,omitempty"`
46 ExpiresAt time.Time `json:"expires_at,omitempty"` // zero = never
47 Active bool `json:"active"`
48 }
49
50 // HasScope reports whether the key has the given scope (or admin, which implies all).
51 func (k *APIKey) HasScope(s Scope) bool {
52 for _, scope := range k.Scopes {
53 if scope == ScopeAdmin || scope == s {
54 return true
55 }
56 }
57 return false
58 }
59
60 // IsExpired reports whether the key has passed its expiry time.
61 func (k *APIKey) IsExpired() bool {
62 return !k.ExpiresAt.IsZero() && time.Now().After(k.ExpiresAt)
63 }
64
65 // APIKeyStore persists API keys to a JSON file.
66 type APIKeyStore struct {
67 mu sync.RWMutex
68 path string
69 data []APIKey
70 }
71
72 // NewAPIKeyStore loads (or creates) the API key store at the given path.
73 func NewAPIKeyStore(path string) (*APIKeyStore, error) {
74 s := &APIKeyStore{path: path}
75 if err := s.load(); err != nil {
76 return nil, err
77 }
78 return s, nil
79 }
80
81 // Create generates a new API key with the given name and scopes.
82 // Returns the plaintext token (shown only once) and the stored key record.
83 func (s *APIKeyStore) Create(name string, scopes []Scope, expiresAt time.Time) (plaintext string, key APIKey, err error) {
84 s.mu.Lock()
85 defer s.mu.Unlock()
86
87 token, err := genToken()
88 if err != nil {
89 return "", APIKey{}, fmt.Errorf("apikeys: generate token: %w", err)
90 }
91
92 key = APIKey{
93 ID: newULID(),
94 Name: name,
95 Hash: hashToken(token),
96 Scopes: scopes,
97 CreatedAt: time.Now().UTC(),
98 ExpiresAt: expiresAt,
99 Active: true,
100 }
101 s.data = append(s.data, key)
102 if err := s.save(); err != nil {
103 // Roll back.
104 s.data = s.data[:len(s.data)-1]
105 return "", APIKey{}, err
106 }
107 return token, key, nil
108 }
109
110 // Insert adds a pre-built API key with a known plaintext token.
111 // Used for migrating the startup token into the store.
112 func (s *APIKeyStore) Insert(name, plaintext string, scopes []Scope) (APIKey, error) {
113 s.mu.Lock()
114 defer s.mu.Unlock()
115
116 key := APIKey{
117 ID: newULID(),
118 Name: name,
119 Hash: hashToken(plaintext),
120 Scopes: scopes,
121 CreatedAt: time.Now().UTC(),
122 Active: true,
123 }
124 s.data = append(s.data, key)
125 if err := s.save(); err != nil {
126 s.data = s.data[:len(s.data)-1]
127 return APIKey{}, err
128 }
129 return key, nil
130 }
131
132 // Lookup finds an active, non-expired key by plaintext token.
133 // Returns nil if no match.
134 func (s *APIKeyStore) Lookup(token string) *APIKey {
135 hash := hashToken(token)
136 s.mu.RLock()
137 defer s.mu.RUnlock()
138 for i := range s.data {
139 if s.data[i].Hash == hash && s.data[i].Active && !s.data[i].IsExpired() {
140 k := s.data[i]
141 return &k
142 }
143 }
144 return nil
145 }
146
147 // TouchLastUsed updates the last-used timestamp for a key by ID.
148 func (s *APIKeyStore) TouchLastUsed(id string) {
149 s.mu.Lock()
150 defer s.mu.Unlock()
151 for i := range s.data {
152 if s.data[i].ID == id {
153 s.data[i].LastUsed = time.Now().UTC()
154 _ = s.save() // best-effort persistence
155 return
156 }
157 }
158 }
159
160 // Get returns a key by ID, or nil if not found.
161 func (s *APIKeyStore) Get(id string) *APIKey {
162 s.mu.RLock()
163 defer s.mu.RUnlock()
164 for i := range s.data {
165 if s.data[i].ID == id {
166 k := s.data[i]
167 return &k
168 }
169 }
170 return nil
171 }
172
173 // List returns all keys (active and revoked).
174 func (s *APIKeyStore) List() []APIKey {
175 s.mu.RLock()
176 defer s.mu.RUnlock()
177 out := make([]APIKey, len(s.data))
178 copy(out, s.data)
179 return out
180 }
181
182 // Revoke deactivates a key by ID.
183 func (s *APIKeyStore) Revoke(id string) error {
184 s.mu.Lock()
185 defer s.mu.Unlock()
186 for i := range s.data {
187 if s.data[i].ID == id {
188 if !s.data[i].Active {
189 return fmt.Errorf("apikeys: key %q already revoked", id)
190 }
191 s.data[i].Active = false
192 return s.save()
193 }
194 }
195 return fmt.Errorf("apikeys: key %q not found", id)
196 }
197
198 // Lookup (TokenValidator interface) reports whether the token is valid.
199 // Satisfies the mcp.TokenValidator interface.
200 func (s *APIKeyStore) ValidToken(token string) bool {
201 return s.Lookup(token) != nil
202 }
203
204 // TestStore creates an in-memory APIKeyStore with a single admin-scope key
205 // for the given token. Intended for tests only — does not persist to disk.
206 func TestStore(token string) *APIKeyStore {
207 s := &APIKeyStore{path: "", data: []APIKey{{
208 ID: "test-key",
209 Name: "test",
210 Hash: hashToken(token),
211 Scopes: []Scope{ScopeAdmin},
212 CreatedAt: time.Now().UTC(),
213 Active: true,
214 }}}
215 return s
216 }
217
218 // IsEmpty reports whether there are no keys.
219 func (s *APIKeyStore) IsEmpty() bool {
220 s.mu.RLock()
221 defer s.mu.RUnlock()
222 return len(s.data) == 0
223 }
224
225 func (s *APIKeyStore) load() error {
226 raw, err := os.ReadFile(s.path)
227 if os.IsNotExist(err) {
228 return nil
229 }
230 if err != nil {
231 return fmt.Errorf("apikeys: read %s: %w", s.path, err)
232 }
233 if err := json.Unmarshal(raw, &s.data); err != nil {
234 return fmt.Errorf("apikeys: parse: %w", err)
235 }
236 return nil
237 }
238
239 func (s *APIKeyStore) save() error {
240 if s.path == "" {
241 return nil // in-memory only (tests)
242 }
243 raw, err := json.MarshalIndent(s.data, "", " ")
244 if err != nil {
245 return err
246 }
247 return os.WriteFile(s.path, raw, 0600)
248 }
249
250 func hashToken(token string) string {
251 h := sha256.Sum256([]byte(token))
252 return hex.EncodeToString(h[:])
253 }
254
255 func genToken() (string, error) {
256 b := make([]byte, 32)
257 if _, err := rand.Read(b); err != nil {
258 return "", err
259 }
260 return hex.EncodeToString(b), nil
261 }
262
263 func newULID() string {
264 entropy := ulid.Monotonic(rand.Reader, 0)
265 return ulid.MustNew(ulid.Timestamp(time.Now()), entropy).String()
266 }
267
268 // ParseScopes parses a comma-separated scope string into a slice.
269 // Returns an error if any scope is unrecognised.
270 func ParseScopes(s string) ([]Scope, error) {
271 parts := strings.Split(s, ",")
272 scopes := make([]Scope, 0, len(parts))
273 for _, p := range parts {
274 p = strings.TrimSpace(p)
275 if p == "" {
276 continue
277 }
278 scope := Scope(p)
279 if !ValidScopes[scope] {
280 return nil, fmt.Errorf("unknown scope %q", p)
281 }
282 scopes = append(scopes, scope)
283 }
284 if len(scopes) == 0 {
285 return nil, fmt.Errorf("at least one scope is required")
286 }
287 return scopes, nil
288 }
--- internal/bots/auditbot/auditbot.go
+++ internal/bots/auditbot/auditbot.go
@@ -120,10 +120,11 @@
120120
PingTimeout: 30 * time.Second,
121121
SSL: false,
122122
})
123123
124124
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
125
+ cl.Cmd.Mode(cl.GetNick(), "+B")
125126
for _, ch := range b.channels {
126127
cl.Cmd.Join(ch)
127128
}
128129
b.log.Info("auditbot connected", "channels", b.channels, "audit_types", b.auditTypesList())
129130
})
130131
--- internal/bots/auditbot/auditbot.go
+++ internal/bots/auditbot/auditbot.go
@@ -120,10 +120,11 @@
120 PingTimeout: 30 * time.Second,
121 SSL: false,
122 })
123
124 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
125 for _, ch := range b.channels {
126 cl.Cmd.Join(ch)
127 }
128 b.log.Info("auditbot connected", "channels", b.channels, "audit_types", b.auditTypesList())
129 })
130
--- internal/bots/auditbot/auditbot.go
+++ internal/bots/auditbot/auditbot.go
@@ -120,10 +120,11 @@
120 PingTimeout: 30 * time.Second,
121 SSL: false,
122 })
123
124 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
125 cl.Cmd.Mode(cl.GetNick(), "+B")
126 for _, ch := range b.channels {
127 cl.Cmd.Join(ch)
128 }
129 b.log.Info("auditbot connected", "channels", b.channels, "audit_types", b.auditTypesList())
130 })
131
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -34,10 +34,11 @@
3434
type Message struct {
3535
At time.Time `json:"at"`
3636
Channel string `json:"channel"`
3737
Nick string `json:"nick"`
3838
Text string `json:"text"`
39
+ MsgID string `json:"msgid,omitempty"`
3940
Meta *Meta `json:"meta,omitempty"`
4041
}
4142
4243
// ringBuf is a fixed-capacity circular buffer of Messages.
4344
type ringBuf struct {
@@ -103,10 +104,13 @@
103104
104105
msgTotal atomic.Int64
105106
106107
joinCh chan string
107108
client *girc.Client
109
+
110
+ // RELAYMSG support detected from ISUPPORT.
111
+ relaySep string // separator (e.g. "/"), empty if unsupported
108112
}
109113
110114
// New creates a bridge Bot.
111115
func New(ircAddr, nick, password string, channels []string, bufSize int, webUserTTL time.Duration, log *slog.Logger) *Bot {
112116
if nick == "" {
@@ -172,10 +176,23 @@
172176
PingTimeout: 30 * time.Second,
173177
SSL: false,
174178
})
175179
176180
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
181
+ cl.Cmd.Mode(cl.GetNick(), "+B")
182
+ // Check RELAYMSG support from ISUPPORT (RPL_005).
183
+ if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
184
+ b.relaySep = sep
185
+ if b.log != nil {
186
+ b.log.Info("bridge: RELAYMSG supported", "separator", sep)
187
+ }
188
+ } else {
189
+ b.relaySep = ""
190
+ if b.log != nil {
191
+ b.log.Info("bridge: RELAYMSG not supported, using [nick] prefix fallback")
192
+ }
193
+ }
177194
if b.log != nil {
178195
b.log.Info("bridge connected")
179196
}
180197
for _, ch := range b.initChannels {
181198
cl.Cmd.Join(ch)
@@ -219,15 +236,20 @@
219236
nick := e.Source.Name
220237
if acct, ok := e.Tags.Get("account"); ok && acct != "" {
221238
nick = acct
222239
}
223240
241
+ var msgID string
242
+ if id, ok := e.Tags.Get("msgid"); ok {
243
+ msgID = id
244
+ }
224245
msg := Message{
225246
At: e.Timestamp,
226247
Channel: channel,
227248
Nick: nick,
228249
Text: e.Last(),
250
+ MsgID: msgID,
229251
}
230252
// Read meta-type from IRCv3 client tags if present.
231253
if metaType, ok := e.Tags.Get("+scuttlebot/meta-type"); ok && metaType != "" {
232254
msg.Meta = &Meta{Type: metaType}
233255
}
@@ -346,23 +368,35 @@
346368
// IRC receives only the plain text; SSE subscribers receive the full message
347369
// including meta for rich rendering in the web UI.
348370
//
349371
// When meta is present, key fields are attached as IRCv3 client-only tags
350372
// (+scuttlebot/meta-type) so any IRCv3 client can read them.
373
+//
374
+// When the server supports RELAYMSG (IRCv3), messages are attributed natively
375
+// so other clients see the real sender nick. Falls back to [nick] prefix.
351376
func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error {
352377
if b.client == nil {
353378
return fmt.Errorf("bridge: not connected")
354379
}
355
- ircText := text
356
- if senderNick != "" {
357
- ircText = "[" + senderNick + "] " + text
358
- }
359
- // Attach meta-type as a client-only tag if metadata is present.
380
+ // Build optional IRCv3 tag prefix for meta-type.
381
+ tagPrefix := ""
360382
if meta != nil && meta.Type != "" {
361
- b.client.Cmd.SendRawf("@+scuttlebot/meta-type=%s PRIVMSG %s :%s", meta.Type, channel, ircText)
383
+ tagPrefix = "@+scuttlebot/meta-type=" + meta.Type + " "
384
+ }
385
+ if senderNick != "" && b.relaySep != "" {
386
+ // Use RELAYMSG for native attribution.
387
+ b.client.Cmd.SendRawf("%sRELAYMSG %s %s :%s", tagPrefix, channel, senderNick, text)
362388
} else {
363
- b.client.Cmd.Message(channel, ircText)
389
+ ircText := text
390
+ if senderNick != "" {
391
+ ircText = "[" + senderNick + "] " + text
392
+ }
393
+ if tagPrefix != "" {
394
+ b.client.Cmd.SendRawf("%sPRIVMSG %s :%s", tagPrefix, channel, ircText)
395
+ } else {
396
+ b.client.Cmd.Message(channel, ircText)
397
+ }
364398
}
365399
366400
if senderNick != "" {
367401
b.TouchUser(channel, senderNick)
368402
}
@@ -434,10 +468,86 @@
434468
}
435469
b.mu.Unlock()
436470
437471
return nicks
438472
}
473
+
474
+// UserInfo describes a user with their IRC modes.
475
+type UserInfo struct {
476
+ Nick string `json:"nick"`
477
+ Modes []string `json:"modes,omitempty"` // e.g. ["o", "v", "B"]
478
+}
479
+
480
+// UsersWithModes returns the current user list with mode info for a channel.
481
+func (b *Bot) UsersWithModes(channel string) []UserInfo {
482
+ seen := make(map[string]bool)
483
+ var users []UserInfo
484
+
485
+ if b.client != nil {
486
+ if ch := b.client.LookupChannel(channel); ch != nil {
487
+ for _, u := range ch.Users(b.client) {
488
+ if u.Nick == b.nick {
489
+ continue
490
+ }
491
+ if seen[u.Nick] {
492
+ continue
493
+ }
494
+ seen[u.Nick] = true
495
+ var modes []string
496
+ if u.Perms != nil {
497
+ if perms, ok := u.Perms.Lookup(channel); ok {
498
+ if perms.Owner {
499
+ modes = append(modes, "q")
500
+ }
501
+ if perms.Admin {
502
+ modes = append(modes, "a")
503
+ }
504
+ if perms.Op {
505
+ modes = append(modes, "o")
506
+ }
507
+ if perms.HalfOp {
508
+ modes = append(modes, "h")
509
+ }
510
+ if perms.Voice {
511
+ modes = append(modes, "v")
512
+ }
513
+ }
514
+ }
515
+ users = append(users, UserInfo{Nick: u.Nick, Modes: modes})
516
+ }
517
+ }
518
+ }
519
+
520
+ now := time.Now()
521
+ b.mu.Lock()
522
+ cutoff := now.Add(-b.webUserTTL)
523
+ for nick, last := range b.webUsers[channel] {
524
+ if !last.After(cutoff) {
525
+ delete(b.webUsers[channel], nick)
526
+ continue
527
+ }
528
+ if !seen[nick] {
529
+ seen[nick] = true
530
+ users = append(users, UserInfo{Nick: nick})
531
+ }
532
+ }
533
+ b.mu.Unlock()
534
+
535
+ return users
536
+}
537
+
538
+// ChannelModes returns the channel mode string (e.g. "+mnt") for a channel.
539
+func (b *Bot) ChannelModes(channel string) string {
540
+ if b.client == nil {
541
+ return ""
542
+ }
543
+ ch := b.client.LookupChannel(channel)
544
+ if ch == nil {
545
+ return ""
546
+ }
547
+ return ch.Modes.String()
548
+}
439549
440550
// Stats returns a snapshot of bridge activity.
441551
func (b *Bot) Stats() Stats {
442552
b.mu.RLock()
443553
channels := len(b.joined)
444554
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -34,10 +34,11 @@
34 type Message struct {
35 At time.Time `json:"at"`
36 Channel string `json:"channel"`
37 Nick string `json:"nick"`
38 Text string `json:"text"`
 
39 Meta *Meta `json:"meta,omitempty"`
40 }
41
42 // ringBuf is a fixed-capacity circular buffer of Messages.
43 type ringBuf struct {
@@ -103,10 +104,13 @@
103
104 msgTotal atomic.Int64
105
106 joinCh chan string
107 client *girc.Client
 
 
 
108 }
109
110 // New creates a bridge Bot.
111 func New(ircAddr, nick, password string, channels []string, bufSize int, webUserTTL time.Duration, log *slog.Logger) *Bot {
112 if nick == "" {
@@ -172,10 +176,23 @@
172 PingTimeout: 30 * time.Second,
173 SSL: false,
174 })
175
176 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
 
 
 
 
 
 
 
 
 
 
 
 
177 if b.log != nil {
178 b.log.Info("bridge connected")
179 }
180 for _, ch := range b.initChannels {
181 cl.Cmd.Join(ch)
@@ -219,15 +236,20 @@
219 nick := e.Source.Name
220 if acct, ok := e.Tags.Get("account"); ok && acct != "" {
221 nick = acct
222 }
223
 
 
 
 
224 msg := Message{
225 At: e.Timestamp,
226 Channel: channel,
227 Nick: nick,
228 Text: e.Last(),
 
229 }
230 // Read meta-type from IRCv3 client tags if present.
231 if metaType, ok := e.Tags.Get("+scuttlebot/meta-type"); ok && metaType != "" {
232 msg.Meta = &Meta{Type: metaType}
233 }
@@ -346,23 +368,35 @@
346 // IRC receives only the plain text; SSE subscribers receive the full message
347 // including meta for rich rendering in the web UI.
348 //
349 // When meta is present, key fields are attached as IRCv3 client-only tags
350 // (+scuttlebot/meta-type) so any IRCv3 client can read them.
 
 
 
351 func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error {
352 if b.client == nil {
353 return fmt.Errorf("bridge: not connected")
354 }
355 ircText := text
356 if senderNick != "" {
357 ircText = "[" + senderNick + "] " + text
358 }
359 // Attach meta-type as a client-only tag if metadata is present.
360 if meta != nil && meta.Type != "" {
361 b.client.Cmd.SendRawf("@+scuttlebot/meta-type=%s PRIVMSG %s :%s", meta.Type, channel, ircText)
 
 
 
 
362 } else {
363 b.client.Cmd.Message(channel, ircText)
 
 
 
 
 
 
 
 
364 }
365
366 if senderNick != "" {
367 b.TouchUser(channel, senderNick)
368 }
@@ -434,10 +468,86 @@
434 }
435 b.mu.Unlock()
436
437 return nicks
438 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
440 // Stats returns a snapshot of bridge activity.
441 func (b *Bot) Stats() Stats {
442 b.mu.RLock()
443 channels := len(b.joined)
444
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -34,10 +34,11 @@
34 type Message struct {
35 At time.Time `json:"at"`
36 Channel string `json:"channel"`
37 Nick string `json:"nick"`
38 Text string `json:"text"`
39 MsgID string `json:"msgid,omitempty"`
40 Meta *Meta `json:"meta,omitempty"`
41 }
42
43 // ringBuf is a fixed-capacity circular buffer of Messages.
44 type ringBuf struct {
@@ -103,10 +104,13 @@
104
105 msgTotal atomic.Int64
106
107 joinCh chan string
108 client *girc.Client
109
110 // RELAYMSG support detected from ISUPPORT.
111 relaySep string // separator (e.g. "/"), empty if unsupported
112 }
113
114 // New creates a bridge Bot.
115 func New(ircAddr, nick, password string, channels []string, bufSize int, webUserTTL time.Duration, log *slog.Logger) *Bot {
116 if nick == "" {
@@ -172,10 +176,23 @@
176 PingTimeout: 30 * time.Second,
177 SSL: false,
178 })
179
180 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
181 cl.Cmd.Mode(cl.GetNick(), "+B")
182 // Check RELAYMSG support from ISUPPORT (RPL_005).
183 if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
184 b.relaySep = sep
185 if b.log != nil {
186 b.log.Info("bridge: RELAYMSG supported", "separator", sep)
187 }
188 } else {
189 b.relaySep = ""
190 if b.log != nil {
191 b.log.Info("bridge: RELAYMSG not supported, using [nick] prefix fallback")
192 }
193 }
194 if b.log != nil {
195 b.log.Info("bridge connected")
196 }
197 for _, ch := range b.initChannels {
198 cl.Cmd.Join(ch)
@@ -219,15 +236,20 @@
236 nick := e.Source.Name
237 if acct, ok := e.Tags.Get("account"); ok && acct != "" {
238 nick = acct
239 }
240
241 var msgID string
242 if id, ok := e.Tags.Get("msgid"); ok {
243 msgID = id
244 }
245 msg := Message{
246 At: e.Timestamp,
247 Channel: channel,
248 Nick: nick,
249 Text: e.Last(),
250 MsgID: msgID,
251 }
252 // Read meta-type from IRCv3 client tags if present.
253 if metaType, ok := e.Tags.Get("+scuttlebot/meta-type"); ok && metaType != "" {
254 msg.Meta = &Meta{Type: metaType}
255 }
@@ -346,23 +368,35 @@
368 // IRC receives only the plain text; SSE subscribers receive the full message
369 // including meta for rich rendering in the web UI.
370 //
371 // When meta is present, key fields are attached as IRCv3 client-only tags
372 // (+scuttlebot/meta-type) so any IRCv3 client can read them.
373 //
374 // When the server supports RELAYMSG (IRCv3), messages are attributed natively
375 // so other clients see the real sender nick. Falls back to [nick] prefix.
376 func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error {
377 if b.client == nil {
378 return fmt.Errorf("bridge: not connected")
379 }
380 // Build optional IRCv3 tag prefix for meta-type.
381 tagPrefix := ""
 
 
 
382 if meta != nil && meta.Type != "" {
383 tagPrefix = "@+scuttlebot/meta-type=" + meta.Type + " "
384 }
385 if senderNick != "" && b.relaySep != "" {
386 // Use RELAYMSG for native attribution.
387 b.client.Cmd.SendRawf("%sRELAYMSG %s %s :%s", tagPrefix, channel, senderNick, text)
388 } else {
389 ircText := text
390 if senderNick != "" {
391 ircText = "[" + senderNick + "] " + text
392 }
393 if tagPrefix != "" {
394 b.client.Cmd.SendRawf("%sPRIVMSG %s :%s", tagPrefix, channel, ircText)
395 } else {
396 b.client.Cmd.Message(channel, ircText)
397 }
398 }
399
400 if senderNick != "" {
401 b.TouchUser(channel, senderNick)
402 }
@@ -434,10 +468,86 @@
468 }
469 b.mu.Unlock()
470
471 return nicks
472 }
473
474 // UserInfo describes a user with their IRC modes.
475 type UserInfo struct {
476 Nick string `json:"nick"`
477 Modes []string `json:"modes,omitempty"` // e.g. ["o", "v", "B"]
478 }
479
480 // UsersWithModes returns the current user list with mode info for a channel.
481 func (b *Bot) UsersWithModes(channel string) []UserInfo {
482 seen := make(map[string]bool)
483 var users []UserInfo
484
485 if b.client != nil {
486 if ch := b.client.LookupChannel(channel); ch != nil {
487 for _, u := range ch.Users(b.client) {
488 if u.Nick == b.nick {
489 continue
490 }
491 if seen[u.Nick] {
492 continue
493 }
494 seen[u.Nick] = true
495 var modes []string
496 if u.Perms != nil {
497 if perms, ok := u.Perms.Lookup(channel); ok {
498 if perms.Owner {
499 modes = append(modes, "q")
500 }
501 if perms.Admin {
502 modes = append(modes, "a")
503 }
504 if perms.Op {
505 modes = append(modes, "o")
506 }
507 if perms.HalfOp {
508 modes = append(modes, "h")
509 }
510 if perms.Voice {
511 modes = append(modes, "v")
512 }
513 }
514 }
515 users = append(users, UserInfo{Nick: u.Nick, Modes: modes})
516 }
517 }
518 }
519
520 now := time.Now()
521 b.mu.Lock()
522 cutoff := now.Add(-b.webUserTTL)
523 for nick, last := range b.webUsers[channel] {
524 if !last.After(cutoff) {
525 delete(b.webUsers[channel], nick)
526 continue
527 }
528 if !seen[nick] {
529 seen[nick] = true
530 users = append(users, UserInfo{Nick: nick})
531 }
532 }
533 b.mu.Unlock()
534
535 return users
536 }
537
538 // ChannelModes returns the channel mode string (e.g. "+mnt") for a channel.
539 func (b *Bot) ChannelModes(channel string) string {
540 if b.client == nil {
541 return ""
542 }
543 ch := b.client.LookupChannel(channel)
544 if ch == nil {
545 return ""
546 }
547 return ch.Modes.String()
548 }
549
550 // Stats returns a snapshot of bridge activity.
551 func (b *Bot) Stats() Stats {
552 b.mu.RLock()
553 channels := len(b.joined)
554
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -34,10 +34,11 @@
3434
type Message struct {
3535
At time.Time `json:"at"`
3636
Channel string `json:"channel"`
3737
Nick string `json:"nick"`
3838
Text string `json:"text"`
39
+ MsgID string `json:"msgid,omitempty"`
3940
Meta *Meta `json:"meta,omitempty"`
4041
}
4142
4243
// ringBuf is a fixed-capacity circular buffer of Messages.
4344
type ringBuf struct {
@@ -103,10 +104,13 @@
103104
104105
msgTotal atomic.Int64
105106
106107
joinCh chan string
107108
client *girc.Client
109
+
110
+ // RELAYMSG support detected from ISUPPORT.
111
+ relaySep string // separator (e.g. "/"), empty if unsupported
108112
}
109113
110114
// New creates a bridge Bot.
111115
func New(ircAddr, nick, password string, channels []string, bufSize int, webUserTTL time.Duration, log *slog.Logger) *Bot {
112116
if nick == "" {
@@ -172,10 +176,23 @@
172176
PingTimeout: 30 * time.Second,
173177
SSL: false,
174178
})
175179
176180
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
181
+ cl.Cmd.Mode(cl.GetNick(), "+B")
182
+ // Check RELAYMSG support from ISUPPORT (RPL_005).
183
+ if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
184
+ b.relaySep = sep
185
+ if b.log != nil {
186
+ b.log.Info("bridge: RELAYMSG supported", "separator", sep)
187
+ }
188
+ } else {
189
+ b.relaySep = ""
190
+ if b.log != nil {
191
+ b.log.Info("bridge: RELAYMSG not supported, using [nick] prefix fallback")
192
+ }
193
+ }
177194
if b.log != nil {
178195
b.log.Info("bridge connected")
179196
}
180197
for _, ch := range b.initChannels {
181198
cl.Cmd.Join(ch)
@@ -219,15 +236,20 @@
219236
nick := e.Source.Name
220237
if acct, ok := e.Tags.Get("account"); ok && acct != "" {
221238
nick = acct
222239
}
223240
241
+ var msgID string
242
+ if id, ok := e.Tags.Get("msgid"); ok {
243
+ msgID = id
244
+ }
224245
msg := Message{
225246
At: e.Timestamp,
226247
Channel: channel,
227248
Nick: nick,
228249
Text: e.Last(),
250
+ MsgID: msgID,
229251
}
230252
// Read meta-type from IRCv3 client tags if present.
231253
if metaType, ok := e.Tags.Get("+scuttlebot/meta-type"); ok && metaType != "" {
232254
msg.Meta = &Meta{Type: metaType}
233255
}
@@ -346,23 +368,35 @@
346368
// IRC receives only the plain text; SSE subscribers receive the full message
347369
// including meta for rich rendering in the web UI.
348370
//
349371
// When meta is present, key fields are attached as IRCv3 client-only tags
350372
// (+scuttlebot/meta-type) so any IRCv3 client can read them.
373
+//
374
+// When the server supports RELAYMSG (IRCv3), messages are attributed natively
375
+// so other clients see the real sender nick. Falls back to [nick] prefix.
351376
func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error {
352377
if b.client == nil {
353378
return fmt.Errorf("bridge: not connected")
354379
}
355
- ircText := text
356
- if senderNick != "" {
357
- ircText = "[" + senderNick + "] " + text
358
- }
359
- // Attach meta-type as a client-only tag if metadata is present.
380
+ // Build optional IRCv3 tag prefix for meta-type.
381
+ tagPrefix := ""
360382
if meta != nil && meta.Type != "" {
361
- b.client.Cmd.SendRawf("@+scuttlebot/meta-type=%s PRIVMSG %s :%s", meta.Type, channel, ircText)
383
+ tagPrefix = "@+scuttlebot/meta-type=" + meta.Type + " "
384
+ }
385
+ if senderNick != "" && b.relaySep != "" {
386
+ // Use RELAYMSG for native attribution.
387
+ b.client.Cmd.SendRawf("%sRELAYMSG %s %s :%s", tagPrefix, channel, senderNick, text)
362388
} else {
363
- b.client.Cmd.Message(channel, ircText)
389
+ ircText := text
390
+ if senderNick != "" {
391
+ ircText = "[" + senderNick + "] " + text
392
+ }
393
+ if tagPrefix != "" {
394
+ b.client.Cmd.SendRawf("%sPRIVMSG %s :%s", tagPrefix, channel, ircText)
395
+ } else {
396
+ b.client.Cmd.Message(channel, ircText)
397
+ }
364398
}
365399
366400
if senderNick != "" {
367401
b.TouchUser(channel, senderNick)
368402
}
@@ -434,10 +468,86 @@
434468
}
435469
b.mu.Unlock()
436470
437471
return nicks
438472
}
473
+
474
+// UserInfo describes a user with their IRC modes.
475
+type UserInfo struct {
476
+ Nick string `json:"nick"`
477
+ Modes []string `json:"modes,omitempty"` // e.g. ["o", "v", "B"]
478
+}
479
+
480
+// UsersWithModes returns the current user list with mode info for a channel.
481
+func (b *Bot) UsersWithModes(channel string) []UserInfo {
482
+ seen := make(map[string]bool)
483
+ var users []UserInfo
484
+
485
+ if b.client != nil {
486
+ if ch := b.client.LookupChannel(channel); ch != nil {
487
+ for _, u := range ch.Users(b.client) {
488
+ if u.Nick == b.nick {
489
+ continue
490
+ }
491
+ if seen[u.Nick] {
492
+ continue
493
+ }
494
+ seen[u.Nick] = true
495
+ var modes []string
496
+ if u.Perms != nil {
497
+ if perms, ok := u.Perms.Lookup(channel); ok {
498
+ if perms.Owner {
499
+ modes = append(modes, "q")
500
+ }
501
+ if perms.Admin {
502
+ modes = append(modes, "a")
503
+ }
504
+ if perms.Op {
505
+ modes = append(modes, "o")
506
+ }
507
+ if perms.HalfOp {
508
+ modes = append(modes, "h")
509
+ }
510
+ if perms.Voice {
511
+ modes = append(modes, "v")
512
+ }
513
+ }
514
+ }
515
+ users = append(users, UserInfo{Nick: u.Nick, Modes: modes})
516
+ }
517
+ }
518
+ }
519
+
520
+ now := time.Now()
521
+ b.mu.Lock()
522
+ cutoff := now.Add(-b.webUserTTL)
523
+ for nick, last := range b.webUsers[channel] {
524
+ if !last.After(cutoff) {
525
+ delete(b.webUsers[channel], nick)
526
+ continue
527
+ }
528
+ if !seen[nick] {
529
+ seen[nick] = true
530
+ users = append(users, UserInfo{Nick: nick})
531
+ }
532
+ }
533
+ b.mu.Unlock()
534
+
535
+ return users
536
+}
537
+
538
+// ChannelModes returns the channel mode string (e.g. "+mnt") for a channel.
539
+func (b *Bot) ChannelModes(channel string) string {
540
+ if b.client == nil {
541
+ return ""
542
+ }
543
+ ch := b.client.LookupChannel(channel)
544
+ if ch == nil {
545
+ return ""
546
+ }
547
+ return ch.Modes.String()
548
+}
439549
440550
// Stats returns a snapshot of bridge activity.
441551
func (b *Bot) Stats() Stats {
442552
b.mu.RLock()
443553
channels := len(b.joined)
444554
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -34,10 +34,11 @@
34 type Message struct {
35 At time.Time `json:"at"`
36 Channel string `json:"channel"`
37 Nick string `json:"nick"`
38 Text string `json:"text"`
 
39 Meta *Meta `json:"meta,omitempty"`
40 }
41
42 // ringBuf is a fixed-capacity circular buffer of Messages.
43 type ringBuf struct {
@@ -103,10 +104,13 @@
103
104 msgTotal atomic.Int64
105
106 joinCh chan string
107 client *girc.Client
 
 
 
108 }
109
110 // New creates a bridge Bot.
111 func New(ircAddr, nick, password string, channels []string, bufSize int, webUserTTL time.Duration, log *slog.Logger) *Bot {
112 if nick == "" {
@@ -172,10 +176,23 @@
172 PingTimeout: 30 * time.Second,
173 SSL: false,
174 })
175
176 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
 
 
 
 
 
 
 
 
 
 
 
 
177 if b.log != nil {
178 b.log.Info("bridge connected")
179 }
180 for _, ch := range b.initChannels {
181 cl.Cmd.Join(ch)
@@ -219,15 +236,20 @@
219 nick := e.Source.Name
220 if acct, ok := e.Tags.Get("account"); ok && acct != "" {
221 nick = acct
222 }
223
 
 
 
 
224 msg := Message{
225 At: e.Timestamp,
226 Channel: channel,
227 Nick: nick,
228 Text: e.Last(),
 
229 }
230 // Read meta-type from IRCv3 client tags if present.
231 if metaType, ok := e.Tags.Get("+scuttlebot/meta-type"); ok && metaType != "" {
232 msg.Meta = &Meta{Type: metaType}
233 }
@@ -346,23 +368,35 @@
346 // IRC receives only the plain text; SSE subscribers receive the full message
347 // including meta for rich rendering in the web UI.
348 //
349 // When meta is present, key fields are attached as IRCv3 client-only tags
350 // (+scuttlebot/meta-type) so any IRCv3 client can read them.
 
 
 
351 func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error {
352 if b.client == nil {
353 return fmt.Errorf("bridge: not connected")
354 }
355 ircText := text
356 if senderNick != "" {
357 ircText = "[" + senderNick + "] " + text
358 }
359 // Attach meta-type as a client-only tag if metadata is present.
360 if meta != nil && meta.Type != "" {
361 b.client.Cmd.SendRawf("@+scuttlebot/meta-type=%s PRIVMSG %s :%s", meta.Type, channel, ircText)
 
 
 
 
362 } else {
363 b.client.Cmd.Message(channel, ircText)
 
 
 
 
 
 
 
 
364 }
365
366 if senderNick != "" {
367 b.TouchUser(channel, senderNick)
368 }
@@ -434,10 +468,86 @@
434 }
435 b.mu.Unlock()
436
437 return nicks
438 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
440 // Stats returns a snapshot of bridge activity.
441 func (b *Bot) Stats() Stats {
442 b.mu.RLock()
443 channels := len(b.joined)
444
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -34,10 +34,11 @@
34 type Message struct {
35 At time.Time `json:"at"`
36 Channel string `json:"channel"`
37 Nick string `json:"nick"`
38 Text string `json:"text"`
39 MsgID string `json:"msgid,omitempty"`
40 Meta *Meta `json:"meta,omitempty"`
41 }
42
43 // ringBuf is a fixed-capacity circular buffer of Messages.
44 type ringBuf struct {
@@ -103,10 +104,13 @@
104
105 msgTotal atomic.Int64
106
107 joinCh chan string
108 client *girc.Client
109
110 // RELAYMSG support detected from ISUPPORT.
111 relaySep string // separator (e.g. "/"), empty if unsupported
112 }
113
114 // New creates a bridge Bot.
115 func New(ircAddr, nick, password string, channels []string, bufSize int, webUserTTL time.Duration, log *slog.Logger) *Bot {
116 if nick == "" {
@@ -172,10 +176,23 @@
176 PingTimeout: 30 * time.Second,
177 SSL: false,
178 })
179
180 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
181 cl.Cmd.Mode(cl.GetNick(), "+B")
182 // Check RELAYMSG support from ISUPPORT (RPL_005).
183 if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
184 b.relaySep = sep
185 if b.log != nil {
186 b.log.Info("bridge: RELAYMSG supported", "separator", sep)
187 }
188 } else {
189 b.relaySep = ""
190 if b.log != nil {
191 b.log.Info("bridge: RELAYMSG not supported, using [nick] prefix fallback")
192 }
193 }
194 if b.log != nil {
195 b.log.Info("bridge connected")
196 }
197 for _, ch := range b.initChannels {
198 cl.Cmd.Join(ch)
@@ -219,15 +236,20 @@
236 nick := e.Source.Name
237 if acct, ok := e.Tags.Get("account"); ok && acct != "" {
238 nick = acct
239 }
240
241 var msgID string
242 if id, ok := e.Tags.Get("msgid"); ok {
243 msgID = id
244 }
245 msg := Message{
246 At: e.Timestamp,
247 Channel: channel,
248 Nick: nick,
249 Text: e.Last(),
250 MsgID: msgID,
251 }
252 // Read meta-type from IRCv3 client tags if present.
253 if metaType, ok := e.Tags.Get("+scuttlebot/meta-type"); ok && metaType != "" {
254 msg.Meta = &Meta{Type: metaType}
255 }
@@ -346,23 +368,35 @@
368 // IRC receives only the plain text; SSE subscribers receive the full message
369 // including meta for rich rendering in the web UI.
370 //
371 // When meta is present, key fields are attached as IRCv3 client-only tags
372 // (+scuttlebot/meta-type) so any IRCv3 client can read them.
373 //
374 // When the server supports RELAYMSG (IRCv3), messages are attributed natively
375 // so other clients see the real sender nick. Falls back to [nick] prefix.
376 func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error {
377 if b.client == nil {
378 return fmt.Errorf("bridge: not connected")
379 }
380 // Build optional IRCv3 tag prefix for meta-type.
381 tagPrefix := ""
 
 
 
382 if meta != nil && meta.Type != "" {
383 tagPrefix = "@+scuttlebot/meta-type=" + meta.Type + " "
384 }
385 if senderNick != "" && b.relaySep != "" {
386 // Use RELAYMSG for native attribution.
387 b.client.Cmd.SendRawf("%sRELAYMSG %s %s :%s", tagPrefix, channel, senderNick, text)
388 } else {
389 ircText := text
390 if senderNick != "" {
391 ircText = "[" + senderNick + "] " + text
392 }
393 if tagPrefix != "" {
394 b.client.Cmd.SendRawf("%sPRIVMSG %s :%s", tagPrefix, channel, ircText)
395 } else {
396 b.client.Cmd.Message(channel, ircText)
397 }
398 }
399
400 if senderNick != "" {
401 b.TouchUser(channel, senderNick)
402 }
@@ -434,10 +468,86 @@
468 }
469 b.mu.Unlock()
470
471 return nicks
472 }
473
474 // UserInfo describes a user with their IRC modes.
475 type UserInfo struct {
476 Nick string `json:"nick"`
477 Modes []string `json:"modes,omitempty"` // e.g. ["o", "v", "B"]
478 }
479
480 // UsersWithModes returns the current user list with mode info for a channel.
481 func (b *Bot) UsersWithModes(channel string) []UserInfo {
482 seen := make(map[string]bool)
483 var users []UserInfo
484
485 if b.client != nil {
486 if ch := b.client.LookupChannel(channel); ch != nil {
487 for _, u := range ch.Users(b.client) {
488 if u.Nick == b.nick {
489 continue
490 }
491 if seen[u.Nick] {
492 continue
493 }
494 seen[u.Nick] = true
495 var modes []string
496 if u.Perms != nil {
497 if perms, ok := u.Perms.Lookup(channel); ok {
498 if perms.Owner {
499 modes = append(modes, "q")
500 }
501 if perms.Admin {
502 modes = append(modes, "a")
503 }
504 if perms.Op {
505 modes = append(modes, "o")
506 }
507 if perms.HalfOp {
508 modes = append(modes, "h")
509 }
510 if perms.Voice {
511 modes = append(modes, "v")
512 }
513 }
514 }
515 users = append(users, UserInfo{Nick: u.Nick, Modes: modes})
516 }
517 }
518 }
519
520 now := time.Now()
521 b.mu.Lock()
522 cutoff := now.Add(-b.webUserTTL)
523 for nick, last := range b.webUsers[channel] {
524 if !last.After(cutoff) {
525 delete(b.webUsers[channel], nick)
526 continue
527 }
528 if !seen[nick] {
529 seen[nick] = true
530 users = append(users, UserInfo{Nick: nick})
531 }
532 }
533 b.mu.Unlock()
534
535 return users
536 }
537
538 // ChannelModes returns the channel mode string (e.g. "+mnt") for a channel.
539 func (b *Bot) ChannelModes(channel string) string {
540 if b.client == nil {
541 return ""
542 }
543 ch := b.client.LookupChannel(channel)
544 if ch == nil {
545 return ""
546 }
547 return ch.Modes.String()
548 }
549
550 // Stats returns a snapshot of bridge activity.
551 func (b *Bot) Stats() Stats {
552 b.mu.RLock()
553 channels := len(b.joined)
554
--- internal/bots/herald/herald.go
+++ internal/bots/herald/herald.go
@@ -151,10 +151,11 @@
151151
PingTimeout: 30 * time.Second,
152152
SSL: false,
153153
})
154154
155155
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
156
+ cl.Cmd.Mode(cl.GetNick(), "+B")
156157
for _, ch := range b.channels {
157158
cl.Cmd.Join(ch)
158159
}
159160
if b.log != nil {
160161
b.log.Info("herald connected", "channels", b.channels)
161162
--- internal/bots/herald/herald.go
+++ internal/bots/herald/herald.go
@@ -151,10 +151,11 @@
151 PingTimeout: 30 * time.Second,
152 SSL: false,
153 })
154
155 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
156 for _, ch := range b.channels {
157 cl.Cmd.Join(ch)
158 }
159 if b.log != nil {
160 b.log.Info("herald connected", "channels", b.channels)
161
--- internal/bots/herald/herald.go
+++ internal/bots/herald/herald.go
@@ -151,10 +151,11 @@
151 PingTimeout: 30 * time.Second,
152 SSL: false,
153 })
154
155 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
156 cl.Cmd.Mode(cl.GetNick(), "+B")
157 for _, ch := range b.channels {
158 cl.Cmd.Join(ch)
159 }
160 if b.log != nil {
161 b.log.Info("herald connected", "channels", b.channels)
162
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -20,10 +20,13 @@
2020
"strings"
2121
"sync"
2222
"time"
2323
2424
"github.com/lrstanley/girc"
25
+
26
+ "github.com/conflicthq/scuttlebot/pkg/chathistory"
27
+ "github.com/conflicthq/scuttlebot/pkg/toon"
2528
)
2629
2730
const (
2831
botNick = "oracle"
2932
defaultLimit = 50
@@ -124,10 +127,11 @@
124127
llm LLMProvider
125128
log *slog.Logger
126129
mu sync.Mutex
127130
lastReq map[string]time.Time // nick → last request time
128131
client *girc.Client
132
+ chFetch *chathistory.Fetcher // CHATHISTORY fetcher, nil if unsupported
129133
}
130134
131135
// New creates an oracle bot.
132136
func New(ircAddr, password string, channels []string, history HistoryFetcher, llm LLMProvider, log *slog.Logger) *Bot {
133137
return &Bot{
@@ -159,18 +163,26 @@
159163
Name: "scuttlebot oracle",
160164
SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
161165
PingDelay: 30 * time.Second,
162166
PingTimeout: 30 * time.Second,
163167
SSL: false,
168
+ SupportedCaps: map[string][]string{
169
+ "draft/chathistory": nil,
170
+ "chathistory": nil,
171
+ },
164172
})
173
+
174
+ b.chFetch = chathistory.New(c)
165175
166176
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
177
+ cl.Cmd.Mode(cl.GetNick(), "+B")
167178
for _, ch := range b.channels {
168179
cl.Cmd.Join(ch)
169180
}
181
+ hasCH := cl.HasCapability("chathistory") || cl.HasCapability("draft/chathistory")
170182
if b.log != nil {
171
- b.log.Info("oracle connected", "channels", b.channels)
183
+ b.log.Info("oracle connected", "channels", b.channels, "chathistory", hasCH)
172184
}
173185
})
174186
175187
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
176188
if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -234,12 +246,12 @@
234246
return
235247
}
236248
b.lastReq[nick] = time.Now()
237249
b.mu.Unlock()
238250
239
- // Fetch history.
240
- entries, err := b.history.Query(req.Channel, req.Limit)
251
+ // Fetch history — prefer CHATHISTORY if available, fall back to store.
252
+ entries, err := b.fetchHistory(ctx, req.Channel, req.Limit)
241253
if err != nil {
242254
cl.Cmd.Notice(nick, fmt.Sprintf("oracle: failed to fetch history for %s: %v", req.Channel, err))
243255
return
244256
}
245257
if len(entries) == 0 {
@@ -263,24 +275,51 @@
263275
if line != "" {
264276
cl.Cmd.Notice(nick, line)
265277
}
266278
}
267279
}
280
+
281
+func (b *Bot) fetchHistory(ctx context.Context, channel string, limit int) ([]HistoryEntry, error) {
282
+ if b.chFetch != nil && b.client != nil {
283
+ hasCH := b.client.HasCapability("chathistory") || b.client.HasCapability("draft/chathistory")
284
+ if hasCH {
285
+ chCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
286
+ defer cancel()
287
+ msgs, err := b.chFetch.Latest(chCtx, channel, limit)
288
+ if err == nil {
289
+ entries := make([]HistoryEntry, len(msgs))
290
+ for i, m := range msgs {
291
+ nick := m.Nick
292
+ if m.Account != "" {
293
+ nick = m.Account
294
+ }
295
+ entries[i] = HistoryEntry{
296
+ Nick: nick,
297
+ Raw: m.Text,
298
+ }
299
+ }
300
+ return entries, nil
301
+ }
302
+ if b.log != nil {
303
+ b.log.Warn("chathistory failed, falling back to store", "err", err)
304
+ }
305
+ }
306
+ }
307
+ return b.history.Query(channel, limit)
308
+}
268309
269310
func buildPrompt(channel string, entries []HistoryEntry) string {
270
- var sb strings.Builder
271
- fmt.Fprintf(&sb, "Summarize the following IRC conversation from %s.\n", channel)
272
- fmt.Fprintf(&sb, "Focus on: key decisions, actions taken, outstanding tasks, and important context.\n")
273
- fmt.Fprintf(&sb, "Be concise. %d messages:\n\n", len(entries))
274
- for _, e := range entries {
275
- if e.MessageType != "" {
276
- fmt.Fprintf(&sb, "[%s] (type=%s) %s\n", e.Nick, e.MessageType, e.Raw)
277
- } else {
278
- fmt.Fprintf(&sb, "[%s] %s\n", e.Nick, e.Raw)
311
+ // Convert to TOON entries for token-efficient LLM context.
312
+ toonEntries := make([]toon.Entry, len(entries))
313
+ for i, e := range entries {
314
+ toonEntries[i] = toon.Entry{
315
+ Nick: e.Nick,
316
+ MessageType: e.MessageType,
317
+ Text: e.Raw,
279318
}
280319
}
281
- return sb.String()
320
+ return toon.FormatPrompt(channel, toonEntries)
282321
}
283322
284323
func formatResponse(channel string, count int, summary string, format Format) string {
285324
switch format {
286325
case FormatJSON:
287326
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -20,10 +20,13 @@
20 "strings"
21 "sync"
22 "time"
23
24 "github.com/lrstanley/girc"
 
 
 
25 )
26
27 const (
28 botNick = "oracle"
29 defaultLimit = 50
@@ -124,10 +127,11 @@
124 llm LLMProvider
125 log *slog.Logger
126 mu sync.Mutex
127 lastReq map[string]time.Time // nick → last request time
128 client *girc.Client
 
129 }
130
131 // New creates an oracle bot.
132 func New(ircAddr, password string, channels []string, history HistoryFetcher, llm LLMProvider, log *slog.Logger) *Bot {
133 return &Bot{
@@ -159,18 +163,26 @@
159 Name: "scuttlebot oracle",
160 SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
161 PingDelay: 30 * time.Second,
162 PingTimeout: 30 * time.Second,
163 SSL: false,
 
 
 
 
164 })
 
 
165
166 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
167 for _, ch := range b.channels {
168 cl.Cmd.Join(ch)
169 }
 
170 if b.log != nil {
171 b.log.Info("oracle connected", "channels", b.channels)
172 }
173 })
174
175 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
176 if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -234,12 +246,12 @@
234 return
235 }
236 b.lastReq[nick] = time.Now()
237 b.mu.Unlock()
238
239 // Fetch history.
240 entries, err := b.history.Query(req.Channel, req.Limit)
241 if err != nil {
242 cl.Cmd.Notice(nick, fmt.Sprintf("oracle: failed to fetch history for %s: %v", req.Channel, err))
243 return
244 }
245 if len(entries) == 0 {
@@ -263,24 +275,51 @@
263 if line != "" {
264 cl.Cmd.Notice(nick, line)
265 }
266 }
267 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
269 func buildPrompt(channel string, entries []HistoryEntry) string {
270 var sb strings.Builder
271 fmt.Fprintf(&sb, "Summarize the following IRC conversation from %s.\n", channel)
272 fmt.Fprintf(&sb, "Focus on: key decisions, actions taken, outstanding tasks, and important context.\n")
273 fmt.Fprintf(&sb, "Be concise. %d messages:\n\n", len(entries))
274 for _, e := range entries {
275 if e.MessageType != "" {
276 fmt.Fprintf(&sb, "[%s] (type=%s) %s\n", e.Nick, e.MessageType, e.Raw)
277 } else {
278 fmt.Fprintf(&sb, "[%s] %s\n", e.Nick, e.Raw)
279 }
280 }
281 return sb.String()
282 }
283
284 func formatResponse(channel string, count int, summary string, format Format) string {
285 switch format {
286 case FormatJSON:
287
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -20,10 +20,13 @@
20 "strings"
21 "sync"
22 "time"
23
24 "github.com/lrstanley/girc"
25
26 "github.com/conflicthq/scuttlebot/pkg/chathistory"
27 "github.com/conflicthq/scuttlebot/pkg/toon"
28 )
29
30 const (
31 botNick = "oracle"
32 defaultLimit = 50
@@ -124,10 +127,11 @@
127 llm LLMProvider
128 log *slog.Logger
129 mu sync.Mutex
130 lastReq map[string]time.Time // nick → last request time
131 client *girc.Client
132 chFetch *chathistory.Fetcher // CHATHISTORY fetcher, nil if unsupported
133 }
134
135 // New creates an oracle bot.
136 func New(ircAddr, password string, channels []string, history HistoryFetcher, llm LLMProvider, log *slog.Logger) *Bot {
137 return &Bot{
@@ -159,18 +163,26 @@
163 Name: "scuttlebot oracle",
164 SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
165 PingDelay: 30 * time.Second,
166 PingTimeout: 30 * time.Second,
167 SSL: false,
168 SupportedCaps: map[string][]string{
169 "draft/chathistory": nil,
170 "chathistory": nil,
171 },
172 })
173
174 b.chFetch = chathistory.New(c)
175
176 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
177 cl.Cmd.Mode(cl.GetNick(), "+B")
178 for _, ch := range b.channels {
179 cl.Cmd.Join(ch)
180 }
181 hasCH := cl.HasCapability("chathistory") || cl.HasCapability("draft/chathistory")
182 if b.log != nil {
183 b.log.Info("oracle connected", "channels", b.channels, "chathistory", hasCH)
184 }
185 })
186
187 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
188 if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -234,12 +246,12 @@
246 return
247 }
248 b.lastReq[nick] = time.Now()
249 b.mu.Unlock()
250
251 // Fetch history — prefer CHATHISTORY if available, fall back to store.
252 entries, err := b.fetchHistory(ctx, req.Channel, req.Limit)
253 if err != nil {
254 cl.Cmd.Notice(nick, fmt.Sprintf("oracle: failed to fetch history for %s: %v", req.Channel, err))
255 return
256 }
257 if len(entries) == 0 {
@@ -263,24 +275,51 @@
275 if line != "" {
276 cl.Cmd.Notice(nick, line)
277 }
278 }
279 }
280
281 func (b *Bot) fetchHistory(ctx context.Context, channel string, limit int) ([]HistoryEntry, error) {
282 if b.chFetch != nil && b.client != nil {
283 hasCH := b.client.HasCapability("chathistory") || b.client.HasCapability("draft/chathistory")
284 if hasCH {
285 chCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
286 defer cancel()
287 msgs, err := b.chFetch.Latest(chCtx, channel, limit)
288 if err == nil {
289 entries := make([]HistoryEntry, len(msgs))
290 for i, m := range msgs {
291 nick := m.Nick
292 if m.Account != "" {
293 nick = m.Account
294 }
295 entries[i] = HistoryEntry{
296 Nick: nick,
297 Raw: m.Text,
298 }
299 }
300 return entries, nil
301 }
302 if b.log != nil {
303 b.log.Warn("chathistory failed, falling back to store", "err", err)
304 }
305 }
306 }
307 return b.history.Query(channel, limit)
308 }
309
310 func buildPrompt(channel string, entries []HistoryEntry) string {
311 // Convert to TOON entries for token-efficient LLM context.
312 toonEntries := make([]toon.Entry, len(entries))
313 for i, e := range entries {
314 toonEntries[i] = toon.Entry{
315 Nick: e.Nick,
316 MessageType: e.MessageType,
317 Text: e.Raw,
 
 
318 }
319 }
320 return toon.FormatPrompt(channel, toonEntries)
321 }
322
323 func formatResponse(channel string, count int, summary string, format Format) string {
324 switch format {
325 case FormatJSON:
326
--- internal/bots/scribe/scribe.go
+++ internal/bots/scribe/scribe.go
@@ -64,10 +64,11 @@
6464
PingTimeout: 30 * time.Second,
6565
SSL: false,
6666
})
6767
6868
c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) {
69
+ client.Cmd.Mode(client.GetNick(), "+B")
6970
for _, ch := range b.channels {
7071
client.Cmd.Join(ch)
7172
}
7273
b.log.Info("scribe connected and joined channels", "channels", b.channels)
7374
})
7475
--- internal/bots/scribe/scribe.go
+++ internal/bots/scribe/scribe.go
@@ -64,10 +64,11 @@
64 PingTimeout: 30 * time.Second,
65 SSL: false,
66 })
67
68 c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) {
 
69 for _, ch := range b.channels {
70 client.Cmd.Join(ch)
71 }
72 b.log.Info("scribe connected and joined channels", "channels", b.channels)
73 })
74
--- internal/bots/scribe/scribe.go
+++ internal/bots/scribe/scribe.go
@@ -64,10 +64,11 @@
64 PingTimeout: 30 * time.Second,
65 SSL: false,
66 })
67
68 c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) {
69 client.Cmd.Mode(client.GetNick(), "+B")
70 for _, ch := range b.channels {
71 client.Cmd.Join(ch)
72 }
73 b.log.Info("scribe connected and joined channels", "channels", b.channels)
74 })
75
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -21,10 +21,12 @@
2121
"time"
2222
2323
"github.com/lrstanley/girc"
2424
2525
"github.com/conflicthq/scuttlebot/internal/bots/scribe"
26
+ "github.com/conflicthq/scuttlebot/pkg/chathistory"
27
+ "github.com/conflicthq/scuttlebot/pkg/toon"
2628
)
2729
2830
const (
2931
botNick = "scroll"
3032
defaultLimit = 50
@@ -38,11 +40,12 @@
3840
password string
3941
channels []string
4042
store scribe.Store
4143
log *slog.Logger
4244
client *girc.Client
43
- rateLimit sync.Map // nick → last request time
45
+ history *chathistory.Fetcher // nil until connected, if CHATHISTORY is available
46
+ rateLimit sync.Map // nick → last request time
4447
}
4548
4649
// New creates a scroll Bot backed by the given scribe Store.
4750
func New(ircAddr, password string, channels []string, store scribe.Store, log *slog.Logger) *Bot {
4851
return &Bot{
@@ -72,17 +75,26 @@
7275
Name: "scuttlebot scroll",
7376
SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
7477
PingDelay: 30 * time.Second,
7578
PingTimeout: 30 * time.Second,
7679
SSL: false,
80
+ SupportedCaps: map[string][]string{
81
+ "draft/chathistory": nil,
82
+ "chathistory": nil,
83
+ },
7784
})
85
+
86
+ // Register CHATHISTORY batch handlers before connecting.
87
+ b.history = chathistory.New(c)
7888
7989
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, e girc.Event) {
90
+ cl.Cmd.Mode(cl.GetNick(), "+B")
8091
for _, ch := range b.channels {
8192
cl.Cmd.Join(ch)
8293
}
83
- b.log.Info("scroll connected", "channels", b.channels)
94
+ hasCH := cl.HasCapability("chathistory") || cl.HasCapability("draft/chathistory")
95
+ b.log.Info("scroll connected", "channels", b.channels, "chathistory", hasCH)
8496
})
8597
8698
// Only respond to DMs — ignore anything in a channel.
8799
c.Handlers.AddBg(girc.PRIVMSG, func(client *girc.Client, e girc.Event) {
88100
if len(e.Params) < 1 {
@@ -129,15 +141,15 @@
129141
}
130142
131143
req, err := ParseCommand(text)
132144
if err != nil {
133145
client.Cmd.Notice(nick, fmt.Sprintf("error: %s", err))
134
- client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>]")
146
+ client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>] [format=json|toon]")
135147
return
136148
}
137149
138
- entries, err := b.store.Query(req.Channel, req.Limit)
150
+ entries, err := b.fetchHistory(req)
139151
if err != nil {
140152
client.Cmd.Notice(nick, fmt.Sprintf("error fetching history: %s", err))
141153
return
142154
}
143155
@@ -144,16 +156,64 @@
144156
if len(entries) == 0 {
145157
client.Cmd.Notice(nick, fmt.Sprintf("no history found for %s", req.Channel))
146158
return
147159
}
148160
149
- client.Cmd.Notice(nick, fmt.Sprintf("--- replay %s (%d entries) ---", req.Channel, len(entries)))
150
- for _, e := range entries {
151
- line, _ := json.Marshal(e)
152
- client.Cmd.Notice(nick, string(line))
161
+ if req.Format == "toon" {
162
+ toonEntries := make([]toon.Entry, len(entries))
163
+ for i, e := range entries {
164
+ toonEntries[i] = toon.Entry{
165
+ Nick: e.Nick,
166
+ MessageType: e.MessageType,
167
+ Text: e.Raw,
168
+ At: e.At,
169
+ }
170
+ }
171
+ output := toon.Format(toonEntries, toon.Options{Channel: req.Channel})
172
+ for _, line := range strings.Split(output, "\n") {
173
+ if line != "" {
174
+ client.Cmd.Notice(nick, line)
175
+ }
176
+ }
177
+ } else {
178
+ client.Cmd.Notice(nick, fmt.Sprintf("--- replay %s (%d entries) ---", req.Channel, len(entries)))
179
+ for _, e := range entries {
180
+ line, _ := json.Marshal(e)
181
+ client.Cmd.Notice(nick, string(line))
182
+ }
183
+ client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel))
184
+ }
185
+}
186
+
187
+// fetchHistory tries CHATHISTORY first, falls back to scribe store.
188
+func (b *Bot) fetchHistory(req *replayRequest) ([]scribe.Entry, error) {
189
+ if b.history != nil && b.client != nil {
190
+ hasCH := b.client.HasCapability("chathistory") || b.client.HasCapability("draft/chathistory")
191
+ if hasCH {
192
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
193
+ defer cancel()
194
+ msgs, err := b.history.Latest(ctx, req.Channel, req.Limit)
195
+ if err == nil {
196
+ entries := make([]scribe.Entry, len(msgs))
197
+ for i, m := range msgs {
198
+ entries[i] = scribe.Entry{
199
+ At: m.At,
200
+ Channel: req.Channel,
201
+ Nick: m.Nick,
202
+ Kind: scribe.EntryKindRaw,
203
+ Raw: m.Text,
204
+ }
205
+ if m.Account != "" {
206
+ entries[i].Nick = m.Account
207
+ }
208
+ }
209
+ return entries, nil
210
+ }
211
+ b.log.Warn("chathistory failed, falling back to store", "err", err)
212
+ }
153213
}
154
- client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel))
214
+ return b.store.Query(req.Channel, req.Limit)
155215
}
156216
157217
func (b *Bot) checkRateLimit(nick string) bool {
158218
now := time.Now()
159219
if last, ok := b.rateLimit.Load(nick); ok {
@@ -167,11 +227,12 @@
167227
168228
// ReplayRequest is a parsed replay command.
169229
type replayRequest struct {
170230
Channel string
171231
Limit int
172
- Since int64 // unix ms, 0 = no filter
232
+ Since int64 // unix ms, 0 = no filter
233
+ Format string // "json" (default) or "toon"
173234
}
174235
175236
// ParseCommand parses a replay command string. Exported for testing.
176237
func ParseCommand(text string) (*replayRequest, error) {
177238
parts := strings.Fields(text)
@@ -205,10 +266,17 @@
205266
ts, err := strconv.ParseInt(kv[1], 10, 64)
206267
if err != nil {
207268
return nil, fmt.Errorf("invalid since=%q (must be unix milliseconds)", kv[1])
208269
}
209270
req.Since = ts
271
+ case "format":
272
+ switch strings.ToLower(kv[1]) {
273
+ case "json", "toon":
274
+ req.Format = strings.ToLower(kv[1])
275
+ default:
276
+ return nil, fmt.Errorf("unknown format %q (use json or toon)", kv[1])
277
+ }
210278
default:
211279
return nil, fmt.Errorf("unknown argument %q", kv[0])
212280
}
213281
}
214282
215283
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -21,10 +21,12 @@
21 "time"
22
23 "github.com/lrstanley/girc"
24
25 "github.com/conflicthq/scuttlebot/internal/bots/scribe"
 
 
26 )
27
28 const (
29 botNick = "scroll"
30 defaultLimit = 50
@@ -38,11 +40,12 @@
38 password string
39 channels []string
40 store scribe.Store
41 log *slog.Logger
42 client *girc.Client
43 rateLimit sync.Map // nick → last request time
 
44 }
45
46 // New creates a scroll Bot backed by the given scribe Store.
47 func New(ircAddr, password string, channels []string, store scribe.Store, log *slog.Logger) *Bot {
48 return &Bot{
@@ -72,17 +75,26 @@
72 Name: "scuttlebot scroll",
73 SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
74 PingDelay: 30 * time.Second,
75 PingTimeout: 30 * time.Second,
76 SSL: false,
 
 
 
 
77 })
 
 
 
78
79 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, e girc.Event) {
 
80 for _, ch := range b.channels {
81 cl.Cmd.Join(ch)
82 }
83 b.log.Info("scroll connected", "channels", b.channels)
 
84 })
85
86 // Only respond to DMs — ignore anything in a channel.
87 c.Handlers.AddBg(girc.PRIVMSG, func(client *girc.Client, e girc.Event) {
88 if len(e.Params) < 1 {
@@ -129,15 +141,15 @@
129 }
130
131 req, err := ParseCommand(text)
132 if err != nil {
133 client.Cmd.Notice(nick, fmt.Sprintf("error: %s", err))
134 client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>]")
135 return
136 }
137
138 entries, err := b.store.Query(req.Channel, req.Limit)
139 if err != nil {
140 client.Cmd.Notice(nick, fmt.Sprintf("error fetching history: %s", err))
141 return
142 }
143
@@ -144,16 +156,64 @@
144 if len(entries) == 0 {
145 client.Cmd.Notice(nick, fmt.Sprintf("no history found for %s", req.Channel))
146 return
147 }
148
149 client.Cmd.Notice(nick, fmt.Sprintf("--- replay %s (%d entries) ---", req.Channel, len(entries)))
150 for _, e := range entries {
151 line, _ := json.Marshal(e)
152 client.Cmd.Notice(nick, string(line))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153 }
154 client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel))
155 }
156
157 func (b *Bot) checkRateLimit(nick string) bool {
158 now := time.Now()
159 if last, ok := b.rateLimit.Load(nick); ok {
@@ -167,11 +227,12 @@
167
168 // ReplayRequest is a parsed replay command.
169 type replayRequest struct {
170 Channel string
171 Limit int
172 Since int64 // unix ms, 0 = no filter
 
173 }
174
175 // ParseCommand parses a replay command string. Exported for testing.
176 func ParseCommand(text string) (*replayRequest, error) {
177 parts := strings.Fields(text)
@@ -205,10 +266,17 @@
205 ts, err := strconv.ParseInt(kv[1], 10, 64)
206 if err != nil {
207 return nil, fmt.Errorf("invalid since=%q (must be unix milliseconds)", kv[1])
208 }
209 req.Since = ts
 
 
 
 
 
 
 
210 default:
211 return nil, fmt.Errorf("unknown argument %q", kv[0])
212 }
213 }
214
215
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -21,10 +21,12 @@
21 "time"
22
23 "github.com/lrstanley/girc"
24
25 "github.com/conflicthq/scuttlebot/internal/bots/scribe"
26 "github.com/conflicthq/scuttlebot/pkg/chathistory"
27 "github.com/conflicthq/scuttlebot/pkg/toon"
28 )
29
30 const (
31 botNick = "scroll"
32 defaultLimit = 50
@@ -38,11 +40,12 @@
40 password string
41 channels []string
42 store scribe.Store
43 log *slog.Logger
44 client *girc.Client
45 history *chathistory.Fetcher // nil until connected, if CHATHISTORY is available
46 rateLimit sync.Map // nick → last request time
47 }
48
49 // New creates a scroll Bot backed by the given scribe Store.
50 func New(ircAddr, password string, channels []string, store scribe.Store, log *slog.Logger) *Bot {
51 return &Bot{
@@ -72,17 +75,26 @@
75 Name: "scuttlebot scroll",
76 SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
77 PingDelay: 30 * time.Second,
78 PingTimeout: 30 * time.Second,
79 SSL: false,
80 SupportedCaps: map[string][]string{
81 "draft/chathistory": nil,
82 "chathistory": nil,
83 },
84 })
85
86 // Register CHATHISTORY batch handlers before connecting.
87 b.history = chathistory.New(c)
88
89 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, e girc.Event) {
90 cl.Cmd.Mode(cl.GetNick(), "+B")
91 for _, ch := range b.channels {
92 cl.Cmd.Join(ch)
93 }
94 hasCH := cl.HasCapability("chathistory") || cl.HasCapability("draft/chathistory")
95 b.log.Info("scroll connected", "channels", b.channels, "chathistory", hasCH)
96 })
97
98 // Only respond to DMs — ignore anything in a channel.
99 c.Handlers.AddBg(girc.PRIVMSG, func(client *girc.Client, e girc.Event) {
100 if len(e.Params) < 1 {
@@ -129,15 +141,15 @@
141 }
142
143 req, err := ParseCommand(text)
144 if err != nil {
145 client.Cmd.Notice(nick, fmt.Sprintf("error: %s", err))
146 client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>] [format=json|toon]")
147 return
148 }
149
150 entries, err := b.fetchHistory(req)
151 if err != nil {
152 client.Cmd.Notice(nick, fmt.Sprintf("error fetching history: %s", err))
153 return
154 }
155
@@ -144,16 +156,64 @@
156 if len(entries) == 0 {
157 client.Cmd.Notice(nick, fmt.Sprintf("no history found for %s", req.Channel))
158 return
159 }
160
161 if req.Format == "toon" {
162 toonEntries := make([]toon.Entry, len(entries))
163 for i, e := range entries {
164 toonEntries[i] = toon.Entry{
165 Nick: e.Nick,
166 MessageType: e.MessageType,
167 Text: e.Raw,
168 At: e.At,
169 }
170 }
171 output := toon.Format(toonEntries, toon.Options{Channel: req.Channel})
172 for _, line := range strings.Split(output, "\n") {
173 if line != "" {
174 client.Cmd.Notice(nick, line)
175 }
176 }
177 } else {
178 client.Cmd.Notice(nick, fmt.Sprintf("--- replay %s (%d entries) ---", req.Channel, len(entries)))
179 for _, e := range entries {
180 line, _ := json.Marshal(e)
181 client.Cmd.Notice(nick, string(line))
182 }
183 client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel))
184 }
185 }
186
187 // fetchHistory tries CHATHISTORY first, falls back to scribe store.
188 func (b *Bot) fetchHistory(req *replayRequest) ([]scribe.Entry, error) {
189 if b.history != nil && b.client != nil {
190 hasCH := b.client.HasCapability("chathistory") || b.client.HasCapability("draft/chathistory")
191 if hasCH {
192 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
193 defer cancel()
194 msgs, err := b.history.Latest(ctx, req.Channel, req.Limit)
195 if err == nil {
196 entries := make([]scribe.Entry, len(msgs))
197 for i, m := range msgs {
198 entries[i] = scribe.Entry{
199 At: m.At,
200 Channel: req.Channel,
201 Nick: m.Nick,
202 Kind: scribe.EntryKindRaw,
203 Raw: m.Text,
204 }
205 if m.Account != "" {
206 entries[i].Nick = m.Account
207 }
208 }
209 return entries, nil
210 }
211 b.log.Warn("chathistory failed, falling back to store", "err", err)
212 }
213 }
214 return b.store.Query(req.Channel, req.Limit)
215 }
216
217 func (b *Bot) checkRateLimit(nick string) bool {
218 now := time.Now()
219 if last, ok := b.rateLimit.Load(nick); ok {
@@ -167,11 +227,12 @@
227
228 // ReplayRequest is a parsed replay command.
229 type replayRequest struct {
230 Channel string
231 Limit int
232 Since int64 // unix ms, 0 = no filter
233 Format string // "json" (default) or "toon"
234 }
235
236 // ParseCommand parses a replay command string. Exported for testing.
237 func ParseCommand(text string) (*replayRequest, error) {
238 parts := strings.Fields(text)
@@ -205,10 +266,17 @@
266 ts, err := strconv.ParseInt(kv[1], 10, 64)
267 if err != nil {
268 return nil, fmt.Errorf("invalid since=%q (must be unix milliseconds)", kv[1])
269 }
270 req.Since = ts
271 case "format":
272 switch strings.ToLower(kv[1]) {
273 case "json", "toon":
274 req.Format = strings.ToLower(kv[1])
275 default:
276 return nil, fmt.Errorf("unknown format %q (use json or toon)", kv[1])
277 }
278 default:
279 return nil, fmt.Errorf("unknown argument %q", kv[0])
280 }
281 }
282
283
--- internal/bots/sentinel/sentinel.go
+++ internal/bots/sentinel/sentinel.go
@@ -146,10 +146,11 @@
146146
PingDelay: 30 * time.Second,
147147
PingTimeout: 30 * time.Second,
148148
})
149149
150150
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
151
+ cl.Cmd.Mode(cl.GetNick(), "+B")
151152
for _, ch := range b.cfg.Channels {
152153
cl.Cmd.Join(ch)
153154
}
154155
cl.Cmd.Join(b.cfg.ModChannel)
155156
if b.log != nil {
156157
--- internal/bots/sentinel/sentinel.go
+++ internal/bots/sentinel/sentinel.go
@@ -146,10 +146,11 @@
146 PingDelay: 30 * time.Second,
147 PingTimeout: 30 * time.Second,
148 })
149
150 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
151 for _, ch := range b.cfg.Channels {
152 cl.Cmd.Join(ch)
153 }
154 cl.Cmd.Join(b.cfg.ModChannel)
155 if b.log != nil {
156
--- internal/bots/sentinel/sentinel.go
+++ internal/bots/sentinel/sentinel.go
@@ -146,10 +146,11 @@
146 PingDelay: 30 * time.Second,
147 PingTimeout: 30 * time.Second,
148 })
149
150 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
151 cl.Cmd.Mode(cl.GetNick(), "+B")
152 for _, ch := range b.cfg.Channels {
153 cl.Cmd.Join(ch)
154 }
155 cl.Cmd.Join(b.cfg.ModChannel)
156 if b.log != nil {
157
--- internal/bots/snitch/snitch.go
+++ internal/bots/snitch/snitch.go
@@ -48,10 +48,14 @@
4848
// JoinPartWindow is the rolling window for join/part cycling. Default: 30s.
4949
JoinPartWindow time.Duration
5050
5151
// Channels is the list of channels to join on connect.
5252
Channels []string
53
+
54
+ // MonitorNicks is the list of nicks to track via IRC MONITOR.
55
+ // Snitch will alert when a monitored nick goes offline unexpectedly.
56
+ MonitorNicks []string
5357
}
5458
5559
func (c *Config) setDefaults() {
5660
if c.Nick == "" {
5761
c.Nick = defaultNick
@@ -135,18 +139,45 @@
135139
PingDelay: 30 * time.Second,
136140
PingTimeout: 30 * time.Second,
137141
})
138142
139143
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
144
+ cl.Cmd.Mode(cl.GetNick(), "+B")
140145
for _, ch := range b.cfg.Channels {
141146
cl.Cmd.Join(ch)
142147
}
143148
if b.cfg.AlertChannel != "" {
144149
cl.Cmd.Join(b.cfg.AlertChannel)
145150
}
151
+ if len(b.cfg.MonitorNicks) > 0 {
152
+ cl.Cmd.SendRawf("MONITOR + %s", strings.Join(b.cfg.MonitorNicks, ","))
153
+ }
146154
if b.log != nil {
147
- b.log.Info("snitch connected", "channels", b.cfg.Channels)
155
+ b.log.Info("snitch connected", "channels", b.cfg.Channels, "monitor", b.cfg.MonitorNicks)
156
+ }
157
+ })
158
+
159
+ // away-notify: track agents going idle or returning.
160
+ c.Handlers.AddBg(girc.AWAY, func(_ *girc.Client, e girc.Event) {
161
+ if e.Source == nil {
162
+ return
163
+ }
164
+ nick := e.Source.Name
165
+ reason := e.Last()
166
+ if reason != "" {
167
+ b.alert(fmt.Sprintf("agent away: %s (%s)", nick, reason))
168
+ }
169
+ })
170
+
171
+ c.Handlers.AddBg(girc.RPL_MONOFFLINE, func(_ *girc.Client, e girc.Event) {
172
+ nicks := e.Last()
173
+ for _, nick := range strings.Split(nicks, ",") {
174
+ nick = strings.TrimSpace(nick)
175
+ if nick == "" {
176
+ continue
177
+ }
178
+ b.alert(fmt.Sprintf("monitored nick offline: %s", nick))
148179
}
149180
})
150181
151182
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
152183
if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -202,10 +233,24 @@
202233
func (b *Bot) JoinChannel(channel string) {
203234
if b.client != nil {
204235
b.client.Cmd.Join(channel)
205236
}
206237
}
238
+
239
+// MonitorAdd adds nicks to the MONITOR list at runtime.
240
+func (b *Bot) MonitorAdd(nicks ...string) {
241
+ if b.client != nil && len(nicks) > 0 {
242
+ b.client.Cmd.SendRawf("MONITOR + %s", strings.Join(nicks, ","))
243
+ }
244
+}
245
+
246
+// MonitorRemove removes nicks from the MONITOR list at runtime.
247
+func (b *Bot) MonitorRemove(nicks ...string) {
248
+ if b.client != nil && len(nicks) > 0 {
249
+ b.client.Cmd.SendRawf("MONITOR - %s", strings.Join(nicks, ","))
250
+ }
251
+}
207252
208253
func (b *Bot) window(channel, nick string) *nickWindow {
209254
if b.windows[channel] == nil {
210255
b.windows[channel] = make(map[string]*nickWindow)
211256
}
212257
--- internal/bots/snitch/snitch.go
+++ internal/bots/snitch/snitch.go
@@ -48,10 +48,14 @@
48 // JoinPartWindow is the rolling window for join/part cycling. Default: 30s.
49 JoinPartWindow time.Duration
50
51 // Channels is the list of channels to join on connect.
52 Channels []string
 
 
 
 
53 }
54
55 func (c *Config) setDefaults() {
56 if c.Nick == "" {
57 c.Nick = defaultNick
@@ -135,18 +139,45 @@
135 PingDelay: 30 * time.Second,
136 PingTimeout: 30 * time.Second,
137 })
138
139 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
140 for _, ch := range b.cfg.Channels {
141 cl.Cmd.Join(ch)
142 }
143 if b.cfg.AlertChannel != "" {
144 cl.Cmd.Join(b.cfg.AlertChannel)
145 }
 
 
 
146 if b.log != nil {
147 b.log.Info("snitch connected", "channels", b.cfg.Channels)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148 }
149 })
150
151 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
152 if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -202,10 +233,24 @@
202 func (b *Bot) JoinChannel(channel string) {
203 if b.client != nil {
204 b.client.Cmd.Join(channel)
205 }
206 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
208 func (b *Bot) window(channel, nick string) *nickWindow {
209 if b.windows[channel] == nil {
210 b.windows[channel] = make(map[string]*nickWindow)
211 }
212
--- internal/bots/snitch/snitch.go
+++ internal/bots/snitch/snitch.go
@@ -48,10 +48,14 @@
48 // JoinPartWindow is the rolling window for join/part cycling. Default: 30s.
49 JoinPartWindow time.Duration
50
51 // Channels is the list of channels to join on connect.
52 Channels []string
53
54 // MonitorNicks is the list of nicks to track via IRC MONITOR.
55 // Snitch will alert when a monitored nick goes offline unexpectedly.
56 MonitorNicks []string
57 }
58
59 func (c *Config) setDefaults() {
60 if c.Nick == "" {
61 c.Nick = defaultNick
@@ -135,18 +139,45 @@
139 PingDelay: 30 * time.Second,
140 PingTimeout: 30 * time.Second,
141 })
142
143 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
144 cl.Cmd.Mode(cl.GetNick(), "+B")
145 for _, ch := range b.cfg.Channels {
146 cl.Cmd.Join(ch)
147 }
148 if b.cfg.AlertChannel != "" {
149 cl.Cmd.Join(b.cfg.AlertChannel)
150 }
151 if len(b.cfg.MonitorNicks) > 0 {
152 cl.Cmd.SendRawf("MONITOR + %s", strings.Join(b.cfg.MonitorNicks, ","))
153 }
154 if b.log != nil {
155 b.log.Info("snitch connected", "channels", b.cfg.Channels, "monitor", b.cfg.MonitorNicks)
156 }
157 })
158
159 // away-notify: track agents going idle or returning.
160 c.Handlers.AddBg(girc.AWAY, func(_ *girc.Client, e girc.Event) {
161 if e.Source == nil {
162 return
163 }
164 nick := e.Source.Name
165 reason := e.Last()
166 if reason != "" {
167 b.alert(fmt.Sprintf("agent away: %s (%s)", nick, reason))
168 }
169 })
170
171 c.Handlers.AddBg(girc.RPL_MONOFFLINE, func(_ *girc.Client, e girc.Event) {
172 nicks := e.Last()
173 for _, nick := range strings.Split(nicks, ",") {
174 nick = strings.TrimSpace(nick)
175 if nick == "" {
176 continue
177 }
178 b.alert(fmt.Sprintf("monitored nick offline: %s", nick))
179 }
180 })
181
182 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
183 if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -202,10 +233,24 @@
233 func (b *Bot) JoinChannel(channel string) {
234 if b.client != nil {
235 b.client.Cmd.Join(channel)
236 }
237 }
238
239 // MonitorAdd adds nicks to the MONITOR list at runtime.
240 func (b *Bot) MonitorAdd(nicks ...string) {
241 if b.client != nil && len(nicks) > 0 {
242 b.client.Cmd.SendRawf("MONITOR + %s", strings.Join(nicks, ","))
243 }
244 }
245
246 // MonitorRemove removes nicks from the MONITOR list at runtime.
247 func (b *Bot) MonitorRemove(nicks ...string) {
248 if b.client != nil && len(nicks) > 0 {
249 b.client.Cmd.SendRawf("MONITOR - %s", strings.Join(nicks, ","))
250 }
251 }
252
253 func (b *Bot) window(channel, nick string) *nickWindow {
254 if b.windows[channel] == nil {
255 b.windows[channel] = make(map[string]*nickWindow)
256 }
257
--- internal/bots/steward/steward.go
+++ internal/bots/steward/steward.go
@@ -130,10 +130,11 @@
130130
PingDelay: 30 * time.Second,
131131
PingTimeout: 30 * time.Second,
132132
})
133133
134134
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
135
+ cl.Cmd.Mode(cl.GetNick(), "+B")
135136
for _, ch := range b.cfg.Channels {
136137
cl.Cmd.Join(ch)
137138
}
138139
cl.Cmd.Join(b.cfg.ModChannel)
139140
if b.log != nil {
140141
--- internal/bots/steward/steward.go
+++ internal/bots/steward/steward.go
@@ -130,10 +130,11 @@
130 PingDelay: 30 * time.Second,
131 PingTimeout: 30 * time.Second,
132 })
133
134 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
135 for _, ch := range b.cfg.Channels {
136 cl.Cmd.Join(ch)
137 }
138 cl.Cmd.Join(b.cfg.ModChannel)
139 if b.log != nil {
140
--- internal/bots/steward/steward.go
+++ internal/bots/steward/steward.go
@@ -130,10 +130,11 @@
130 PingDelay: 30 * time.Second,
131 PingTimeout: 30 * time.Second,
132 })
133
134 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
135 cl.Cmd.Mode(cl.GetNick(), "+B")
136 for _, ch := range b.cfg.Channels {
137 cl.Cmd.Join(ch)
138 }
139 cl.Cmd.Join(b.cfg.ModChannel)
140 if b.log != nil {
141
--- internal/bots/systembot/systembot.go
+++ internal/bots/systembot/systembot.go
@@ -91,10 +91,11 @@
9191
PingTimeout: 30 * time.Second,
9292
SSL: false,
9393
})
9494
9595
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
96
+ cl.Cmd.Mode(cl.GetNick(), "+B")
9697
for _, ch := range b.channels {
9798
cl.Cmd.Join(ch)
9899
}
99100
b.log.Info("systembot connected", "channels", b.channels)
100101
})
101102
--- internal/bots/systembot/systembot.go
+++ internal/bots/systembot/systembot.go
@@ -91,10 +91,11 @@
91 PingTimeout: 30 * time.Second,
92 SSL: false,
93 })
94
95 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
96 for _, ch := range b.channels {
97 cl.Cmd.Join(ch)
98 }
99 b.log.Info("systembot connected", "channels", b.channels)
100 })
101
--- internal/bots/systembot/systembot.go
+++ internal/bots/systembot/systembot.go
@@ -91,10 +91,11 @@
91 PingTimeout: 30 * time.Second,
92 SSL: false,
93 })
94
95 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
96 cl.Cmd.Mode(cl.GetNick(), "+B")
97 for _, ch := range b.channels {
98 cl.Cmd.Join(ch)
99 }
100 b.log.Info("systembot connected", "channels", b.channels)
101 })
102
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -199,10 +199,11 @@
199199
PingTimeout: 30 * time.Second,
200200
SSL: false,
201201
})
202202
203203
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
204
+ cl.Cmd.Mode(cl.GetNick(), "+B")
204205
for _, ch := range b.initChannels {
205206
cl.Cmd.Join(ch)
206207
}
207208
for ch := range b.channelConfigs {
208209
cl.Cmd.Join(ch)
@@ -309,11 +310,19 @@
309310
switch action {
310311
case ActionWarn:
311312
cl.Cmd.Notice(nick, fmt.Sprintf("warden: warning — %s in %s", reason, channel))
312313
case ActionMute:
313314
cl.Cmd.Notice(nick, fmt.Sprintf("warden: muted in %s — %s", channel, reason))
314
- cl.Cmd.Mode(channel, "+q", nick)
315
+ // Use extended ban m: to mute — agent stays in channel but cannot speak.
316
+ mask := "m:" + nick + "!*@*"
317
+ cl.Cmd.Mode(channel, "+b", mask)
318
+ // Remove mute after cooldown so the agent can recover.
319
+ cs := b.channelStateFor(channel)
320
+ go func() {
321
+ time.Sleep(cs.cfg.CoolDown)
322
+ cl.Cmd.Mode(channel, "-b", mask)
323
+ }()
315324
case ActionKick:
316325
cl.Cmd.Kick(channel, nick, "warden: "+reason)
317326
}
318327
}
319328
320329
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -199,10 +199,11 @@
199 PingTimeout: 30 * time.Second,
200 SSL: false,
201 })
202
203 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
204 for _, ch := range b.initChannels {
205 cl.Cmd.Join(ch)
206 }
207 for ch := range b.channelConfigs {
208 cl.Cmd.Join(ch)
@@ -309,11 +310,19 @@
309 switch action {
310 case ActionWarn:
311 cl.Cmd.Notice(nick, fmt.Sprintf("warden: warning — %s in %s", reason, channel))
312 case ActionMute:
313 cl.Cmd.Notice(nick, fmt.Sprintf("warden: muted in %s — %s", channel, reason))
314 cl.Cmd.Mode(channel, "+q", nick)
 
 
 
 
 
 
 
 
315 case ActionKick:
316 cl.Cmd.Kick(channel, nick, "warden: "+reason)
317 }
318 }
319
320
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -199,10 +199,11 @@
199 PingTimeout: 30 * time.Second,
200 SSL: false,
201 })
202
203 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
204 cl.Cmd.Mode(cl.GetNick(), "+B")
205 for _, ch := range b.initChannels {
206 cl.Cmd.Join(ch)
207 }
208 for ch := range b.channelConfigs {
209 cl.Cmd.Join(ch)
@@ -309,11 +310,19 @@
310 switch action {
311 case ActionWarn:
312 cl.Cmd.Notice(nick, fmt.Sprintf("warden: warning — %s in %s", reason, channel))
313 case ActionMute:
314 cl.Cmd.Notice(nick, fmt.Sprintf("warden: muted in %s — %s", channel, reason))
315 // Use extended ban m: to mute — agent stays in channel but cannot speak.
316 mask := "m:" + nick + "!*@*"
317 cl.Cmd.Mode(channel, "+b", mask)
318 // Remove mute after cooldown so the agent can recover.
319 cs := b.channelStateFor(channel)
320 go func() {
321 time.Sleep(cs.cfg.CoolDown)
322 cl.Cmd.Mode(channel, "-b", mask)
323 }()
324 case ActionKick:
325 cl.Cmd.Kick(channel, nick, "warden: "+reason)
326 }
327 }
328
329
--- internal/config/config.go
+++ internal/config/config.go
@@ -278,10 +278,17 @@
278278
// Voice is a list of nicks to grant voice (+v) access.
279279
Voice []string `yaml:"voice" json:"voice,omitempty"`
280280
281281
// Autojoin is a list of bot nicks to invite when the channel is provisioned.
282282
Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"`
283
+
284
+ // Modes is a list of channel modes to set after provisioning (e.g. "+m" for moderated).
285
+ Modes []string `yaml:"modes" json:"modes,omitempty"`
286
+
287
+ // OnJoinMessage is sent to agents when they join this channel.
288
+ // Supports template variables: {nick}, {channel}.
289
+ OnJoinMessage string `yaml:"on_join_message" json:"on_join_message,omitempty"`
283290
}
284291
285292
// ChannelTypeConfig defines policy rules for a class of dynamically created channels.
286293
// Matched by prefix against channel names (e.g. prefix "task." matches "#task.gh-42").
287294
type ChannelTypeConfig struct {
@@ -295,17 +302,23 @@
295302
// Autojoin is a list of bot nicks to invite when a channel of this type is created.
296303
Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"`
297304
298305
// Supervision is the coordination channel where summaries should surface.
299306
Supervision string `yaml:"supervision" json:"supervision,omitempty"`
307
+
308
+ // Modes is a list of channel modes to set when provisioning (e.g. "+m" for moderated).
309
+ Modes []string `yaml:"modes" json:"modes,omitempty"`
300310
301311
// Ephemeral marks channels of this type for automatic cleanup.
302312
Ephemeral bool `yaml:"ephemeral" json:"ephemeral,omitempty"`
303313
304314
// TTL is the maximum lifetime of an ephemeral channel with no non-bot members.
305315
// Zero means no TTL; cleanup only occurs when the channel is empty.
306316
TTL Duration `yaml:"ttl" json:"ttl,omitempty"`
317
+
318
+ // OnJoinMessage is sent to agents when they join a channel of this type.
319
+ OnJoinMessage string `yaml:"on_join_message" json:"on_join_message,omitempty"`
307320
}
308321
309322
// Duration wraps time.Duration for YAML/JSON marshalling ("72h", "30m", etc.).
310323
type Duration struct {
311324
time.Duration
312325
--- internal/config/config.go
+++ internal/config/config.go
@@ -278,10 +278,17 @@
278 // Voice is a list of nicks to grant voice (+v) access.
279 Voice []string `yaml:"voice" json:"voice,omitempty"`
280
281 // Autojoin is a list of bot nicks to invite when the channel is provisioned.
282 Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"`
 
 
 
 
 
 
 
283 }
284
285 // ChannelTypeConfig defines policy rules for a class of dynamically created channels.
286 // Matched by prefix against channel names (e.g. prefix "task." matches "#task.gh-42").
287 type ChannelTypeConfig struct {
@@ -295,17 +302,23 @@
295 // Autojoin is a list of bot nicks to invite when a channel of this type is created.
296 Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"`
297
298 // Supervision is the coordination channel where summaries should surface.
299 Supervision string `yaml:"supervision" json:"supervision,omitempty"`
 
 
 
300
301 // Ephemeral marks channels of this type for automatic cleanup.
302 Ephemeral bool `yaml:"ephemeral" json:"ephemeral,omitempty"`
303
304 // TTL is the maximum lifetime of an ephemeral channel with no non-bot members.
305 // Zero means no TTL; cleanup only occurs when the channel is empty.
306 TTL Duration `yaml:"ttl" json:"ttl,omitempty"`
 
 
 
307 }
308
309 // Duration wraps time.Duration for YAML/JSON marshalling ("72h", "30m", etc.).
310 type Duration struct {
311 time.Duration
312
--- internal/config/config.go
+++ internal/config/config.go
@@ -278,10 +278,17 @@
278 // Voice is a list of nicks to grant voice (+v) access.
279 Voice []string `yaml:"voice" json:"voice,omitempty"`
280
281 // Autojoin is a list of bot nicks to invite when the channel is provisioned.
282 Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"`
283
284 // Modes is a list of channel modes to set after provisioning (e.g. "+m" for moderated).
285 Modes []string `yaml:"modes" json:"modes,omitempty"`
286
287 // OnJoinMessage is sent to agents when they join this channel.
288 // Supports template variables: {nick}, {channel}.
289 OnJoinMessage string `yaml:"on_join_message" json:"on_join_message,omitempty"`
290 }
291
292 // ChannelTypeConfig defines policy rules for a class of dynamically created channels.
293 // Matched by prefix against channel names (e.g. prefix "task." matches "#task.gh-42").
294 type ChannelTypeConfig struct {
@@ -295,17 +302,23 @@
302 // Autojoin is a list of bot nicks to invite when a channel of this type is created.
303 Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"`
304
305 // Supervision is the coordination channel where summaries should surface.
306 Supervision string `yaml:"supervision" json:"supervision,omitempty"`
307
308 // Modes is a list of channel modes to set when provisioning (e.g. "+m" for moderated).
309 Modes []string `yaml:"modes" json:"modes,omitempty"`
310
311 // Ephemeral marks channels of this type for automatic cleanup.
312 Ephemeral bool `yaml:"ephemeral" json:"ephemeral,omitempty"`
313
314 // TTL is the maximum lifetime of an ephemeral channel with no non-bot members.
315 // Zero means no TTL; cleanup only occurs when the channel is empty.
316 TTL Duration `yaml:"ttl" json:"ttl,omitempty"`
317
318 // OnJoinMessage is sent to agents when they join a channel of this type.
319 OnJoinMessage string `yaml:"on_join_message" json:"on_join_message,omitempty"`
320 }
321
322 // Duration wraps time.Duration for YAML/JSON marshalling ("72h", "30m", etc.).
323 type Duration struct {
324 time.Duration
325
--- internal/ergo/ircdconfig.go
+++ internal/ergo/ircdconfig.go
@@ -23,11 +23,13 @@
2323
{{- end}}
2424
casemapping: ascii
2525
enforce-utf8: true
2626
max-sendq: 96k
2727
relaymsg:
28
- enabled: false
28
+ enabled: true
29
+ separators: /
30
+ available-to-chanops: false
2931
ip-cloaking:
3032
enabled: false
3133
lookup-hostnames: false
3234
3335
datastore:
3436
--- internal/ergo/ircdconfig.go
+++ internal/ergo/ircdconfig.go
@@ -23,11 +23,13 @@
23 {{- end}}
24 casemapping: ascii
25 enforce-utf8: true
26 max-sendq: 96k
27 relaymsg:
28 enabled: false
 
 
29 ip-cloaking:
30 enabled: false
31 lookup-hostnames: false
32
33 datastore:
34
--- internal/ergo/ircdconfig.go
+++ internal/ergo/ircdconfig.go
@@ -23,11 +23,13 @@
23 {{- end}}
24 casemapping: ascii
25 enforce-utf8: true
26 max-sendq: 96k
27 relaymsg:
28 enabled: true
29 separators: /
30 available-to-chanops: false
31 ip-cloaking:
32 enabled: false
33 lookup-hostnames: false
34
35 datastore:
36
--- internal/ergo/manager.go
+++ internal/ergo/manager.go
@@ -115,10 +115,17 @@
115115
}
116116
wait = min(wait*2, restartMaxWait) //nolint:ineffassign,staticcheck
117117
}
118118
}
119119
}
120
+
121
+// UpdateConfig replaces the Ergo config, regenerates ircd.yaml, and rehashes.
122
+// Use when scuttlebot.yaml Ergo settings change at runtime.
123
+func (m *Manager) UpdateConfig(cfg config.ErgoConfig) error {
124
+ m.cfg = cfg
125
+ return m.Rehash()
126
+}
120127
121128
// Rehash reloads the Ergo config. Call after writing a new ircd.yaml.
122129
func (m *Manager) Rehash() error {
123130
if err := m.writeConfig(); err != nil {
124131
return fmt.Errorf("ergo: write config: %w", err)
125132
--- internal/ergo/manager.go
+++ internal/ergo/manager.go
@@ -115,10 +115,17 @@
115 }
116 wait = min(wait*2, restartMaxWait) //nolint:ineffassign,staticcheck
117 }
118 }
119 }
 
 
 
 
 
 
 
120
121 // Rehash reloads the Ergo config. Call after writing a new ircd.yaml.
122 func (m *Manager) Rehash() error {
123 if err := m.writeConfig(); err != nil {
124 return fmt.Errorf("ergo: write config: %w", err)
125
--- internal/ergo/manager.go
+++ internal/ergo/manager.go
@@ -115,10 +115,17 @@
115 }
116 wait = min(wait*2, restartMaxWait) //nolint:ineffassign,staticcheck
117 }
118 }
119 }
120
121 // UpdateConfig replaces the Ergo config, regenerates ircd.yaml, and rehashes.
122 // Use when scuttlebot.yaml Ergo settings change at runtime.
123 func (m *Manager) UpdateConfig(cfg config.ErgoConfig) error {
124 m.cfg = cfg
125 return m.Rehash()
126 }
127
128 // Rehash reloads the Ergo config. Call after writing a new ircd.yaml.
129 func (m *Manager) Rehash() error {
130 if err := m.writeConfig(); err != nil {
131 return fmt.Errorf("ergo: write config: %w", err)
132
--- internal/mcp/mcp.go
+++ internal/mcp/mcp.go
@@ -52,31 +52,32 @@
5252
type ChannelInfo struct {
5353
Name string `json:"name"`
5454
Topic string `json:"topic,omitempty"`
5555
Count int `json:"count"`
5656
}
57
+
58
+// TokenValidator validates API tokens.
59
+type TokenValidator interface {
60
+ ValidToken(token string) bool
61
+}
5762
5863
// Server is the MCP server.
5964
type Server struct {
6065
registry *registry.Registry
6166
channels ChannelLister
6267
sender Sender // optional — send_message returns error if nil
6368
history HistoryQuerier // optional — get_history returns error if nil
64
- tokens map[string]struct{}
69
+ tokens TokenValidator
6570
log *slog.Logger
6671
}
6772
6873
// New creates an MCP Server.
69
-func New(reg *registry.Registry, channels ChannelLister, tokens []string, log *slog.Logger) *Server {
70
- t := make(map[string]struct{}, len(tokens))
71
- for _, tok := range tokens {
72
- t[tok] = struct{}{}
73
- }
74
+func New(reg *registry.Registry, channels ChannelLister, tokens TokenValidator, log *slog.Logger) *Server {
7475
return &Server{
7576
registry: reg,
7677
channels: channels,
77
- tokens: t,
78
+ tokens: tokens,
7879
log: log,
7980
}
8081
}
8182
8283
// WithSender attaches an IRC relay client for send_message.
@@ -101,11 +102,11 @@
101102
// --- Auth ---
102103
103104
func (s *Server) authMiddleware(next http.Handler) http.Handler {
104105
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
105106
token := bearerToken(r)
106
- if _, ok := s.tokens[token]; !ok {
107
+ if !s.tokens.ValidToken(token) {
107108
writeRPCError(w, nil, -32001, "unauthorized")
108109
return
109110
}
110111
next.ServeHTTP(w, r)
111112
})
112113
--- internal/mcp/mcp.go
+++ internal/mcp/mcp.go
@@ -52,31 +52,32 @@
52 type ChannelInfo struct {
53 Name string `json:"name"`
54 Topic string `json:"topic,omitempty"`
55 Count int `json:"count"`
56 }
 
 
 
 
 
57
58 // Server is the MCP server.
59 type Server struct {
60 registry *registry.Registry
61 channels ChannelLister
62 sender Sender // optional — send_message returns error if nil
63 history HistoryQuerier // optional — get_history returns error if nil
64 tokens map[string]struct{}
65 log *slog.Logger
66 }
67
68 // New creates an MCP Server.
69 func New(reg *registry.Registry, channels ChannelLister, tokens []string, log *slog.Logger) *Server {
70 t := make(map[string]struct{}, len(tokens))
71 for _, tok := range tokens {
72 t[tok] = struct{}{}
73 }
74 return &Server{
75 registry: reg,
76 channels: channels,
77 tokens: t,
78 log: log,
79 }
80 }
81
82 // WithSender attaches an IRC relay client for send_message.
@@ -101,11 +102,11 @@
101 // --- Auth ---
102
103 func (s *Server) authMiddleware(next http.Handler) http.Handler {
104 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
105 token := bearerToken(r)
106 if _, ok := s.tokens[token]; !ok {
107 writeRPCError(w, nil, -32001, "unauthorized")
108 return
109 }
110 next.ServeHTTP(w, r)
111 })
112
--- internal/mcp/mcp.go
+++ internal/mcp/mcp.go
@@ -52,31 +52,32 @@
52 type ChannelInfo struct {
53 Name string `json:"name"`
54 Topic string `json:"topic,omitempty"`
55 Count int `json:"count"`
56 }
57
58 // TokenValidator validates API tokens.
59 type TokenValidator interface {
60 ValidToken(token string) bool
61 }
62
63 // Server is the MCP server.
64 type Server struct {
65 registry *registry.Registry
66 channels ChannelLister
67 sender Sender // optional — send_message returns error if nil
68 history HistoryQuerier // optional — get_history returns error if nil
69 tokens TokenValidator
70 log *slog.Logger
71 }
72
73 // New creates an MCP Server.
74 func New(reg *registry.Registry, channels ChannelLister, tokens TokenValidator, log *slog.Logger) *Server {
 
 
 
 
75 return &Server{
76 registry: reg,
77 channels: channels,
78 tokens: tokens,
79 log: log,
80 }
81 }
82
83 // WithSender attaches an IRC relay client for send_message.
@@ -101,11 +102,11 @@
102 // --- Auth ---
103
104 func (s *Server) authMiddleware(next http.Handler) http.Handler {
105 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
106 token := bearerToken(r)
107 if !s.tokens.ValidToken(token) {
108 writeRPCError(w, nil, -32001, "unauthorized")
109 return
110 }
111 next.ServeHTTP(w, r)
112 })
113
--- internal/mcp/mcp_test.go
+++ internal/mcp/mcp_test.go
@@ -19,10 +19,17 @@
1919
var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
2020
2121
const testToken = "test-mcp-token"
2222
2323
// --- mocks ---
24
+
25
+type tokenSet map[string]struct{}
26
+
27
+func (t tokenSet) ValidToken(tok string) bool {
28
+ _, ok := t[tok]
29
+ return ok
30
+}
2431
2532
type mockProvisioner struct {
2633
mu sync.Mutex
2734
accounts map[string]string
2835
}
@@ -93,11 +100,11 @@
93100
hist := &mockHistory{entries: map[string][]mcp.HistoryEntry{
94101
"#fleet": {
95102
{Nick: "agent-01", MessageType: "task.create", MessageID: "01HX", Raw: `{"v":1}`},
96103
},
97104
}}
98
- srv := mcp.New(reg, channels, []string{testToken}, testLog).
105
+ srv := mcp.New(reg, channels, tokenSet{testToken: {}}, testLog).
99106
WithSender(sender).
100107
WithHistory(hist)
101108
return httptest.NewServer(srv.Handler())
102109
}
103110
104111
--- internal/mcp/mcp_test.go
+++ internal/mcp/mcp_test.go
@@ -19,10 +19,17 @@
19 var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
20
21 const testToken = "test-mcp-token"
22
23 // --- mocks ---
 
 
 
 
 
 
 
24
25 type mockProvisioner struct {
26 mu sync.Mutex
27 accounts map[string]string
28 }
@@ -93,11 +100,11 @@
93 hist := &mockHistory{entries: map[string][]mcp.HistoryEntry{
94 "#fleet": {
95 {Nick: "agent-01", MessageType: "task.create", MessageID: "01HX", Raw: `{"v":1}`},
96 },
97 }}
98 srv := mcp.New(reg, channels, []string{testToken}, testLog).
99 WithSender(sender).
100 WithHistory(hist)
101 return httptest.NewServer(srv.Handler())
102 }
103
104
--- internal/mcp/mcp_test.go
+++ internal/mcp/mcp_test.go
@@ -19,10 +19,17 @@
19 var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
20
21 const testToken = "test-mcp-token"
22
23 // --- mocks ---
24
25 type tokenSet map[string]struct{}
26
27 func (t tokenSet) ValidToken(tok string) bool {
28 _, ok := t[tok]
29 return ok
30 }
31
32 type mockProvisioner struct {
33 mu sync.Mutex
34 accounts map[string]string
35 }
@@ -93,11 +100,11 @@
100 hist := &mockHistory{entries: map[string][]mcp.HistoryEntry{
101 "#fleet": {
102 {Nick: "agent-01", MessageType: "task.create", MessageID: "01HX", Raw: `{"v":1}`},
103 },
104 }}
105 srv := mcp.New(reg, channels, tokenSet{testToken: {}}, testLog).
106 WithSender(sender).
107 WithHistory(hist)
108 return httptest.NewServer(srv.Handler())
109 }
110
111
--- internal/topology/policy.go
+++ internal/topology/policy.go
@@ -11,10 +11,11 @@
1111
// ChannelType is the resolved policy for a class of channels.
1212
type ChannelType struct {
1313
Name string
1414
Prefix string
1515
Autojoin []string
16
+ Modes []string
1617
Supervision string
1718
Ephemeral bool
1819
TTL time.Duration
1920
}
2021
@@ -61,10 +62,11 @@
6162
for _, t := range cfg.Types {
6263
types = append(types, ChannelType{
6364
Name: t.Name,
6465
Prefix: t.Prefix,
6566
Autojoin: append([]string(nil), t.Autojoin...),
67
+ Modes: append([]string(nil), t.Modes...),
6668
Supervision: t.Supervision,
6769
Ephemeral: t.Ephemeral,
6870
TTL: t.TTL.Duration,
6971
})
7072
}
@@ -133,10 +135,18 @@
133135
if t := p.Match(channel); t != nil {
134136
return t.TTL
135137
}
136138
return 0
137139
}
140
+
141
+// ModesFor returns the channel modes for the given channel, or nil.
142
+func (p *Policy) ModesFor(channel string) []string {
143
+ if t := p.Match(channel); t != nil {
144
+ return append([]string(nil), t.Modes...)
145
+ }
146
+ return nil
147
+}
138148
139149
// StaticChannels returns the list of channels to provision at startup.
140150
func (p *Policy) StaticChannels() []config.StaticChannelConfig {
141151
return append([]config.StaticChannelConfig(nil), p.staticChannels...)
142152
}
143153
--- internal/topology/policy.go
+++ internal/topology/policy.go
@@ -11,10 +11,11 @@
11 // ChannelType is the resolved policy for a class of channels.
12 type ChannelType struct {
13 Name string
14 Prefix string
15 Autojoin []string
 
16 Supervision string
17 Ephemeral bool
18 TTL time.Duration
19 }
20
@@ -61,10 +62,11 @@
61 for _, t := range cfg.Types {
62 types = append(types, ChannelType{
63 Name: t.Name,
64 Prefix: t.Prefix,
65 Autojoin: append([]string(nil), t.Autojoin...),
 
66 Supervision: t.Supervision,
67 Ephemeral: t.Ephemeral,
68 TTL: t.TTL.Duration,
69 })
70 }
@@ -133,10 +135,18 @@
133 if t := p.Match(channel); t != nil {
134 return t.TTL
135 }
136 return 0
137 }
 
 
 
 
 
 
 
 
138
139 // StaticChannels returns the list of channels to provision at startup.
140 func (p *Policy) StaticChannels() []config.StaticChannelConfig {
141 return append([]config.StaticChannelConfig(nil), p.staticChannels...)
142 }
143
--- internal/topology/policy.go
+++ internal/topology/policy.go
@@ -11,10 +11,11 @@
11 // ChannelType is the resolved policy for a class of channels.
12 type ChannelType struct {
13 Name string
14 Prefix string
15 Autojoin []string
16 Modes []string
17 Supervision string
18 Ephemeral bool
19 TTL time.Duration
20 }
21
@@ -61,10 +62,11 @@
62 for _, t := range cfg.Types {
63 types = append(types, ChannelType{
64 Name: t.Name,
65 Prefix: t.Prefix,
66 Autojoin: append([]string(nil), t.Autojoin...),
67 Modes: append([]string(nil), t.Modes...),
68 Supervision: t.Supervision,
69 Ephemeral: t.Ephemeral,
70 TTL: t.TTL.Duration,
71 })
72 }
@@ -133,10 +135,18 @@
135 if t := p.Match(channel); t != nil {
136 return t.TTL
137 }
138 return 0
139 }
140
141 // ModesFor returns the channel modes for the given channel, or nil.
142 func (p *Policy) ModesFor(channel string) []string {
143 if t := p.Match(channel); t != nil {
144 return append([]string(nil), t.Modes...)
145 }
146 return nil
147 }
148
149 // StaticChannels returns the list of channels to provision at startup.
150 func (p *Policy) StaticChannels() []config.StaticChannelConfig {
151 return append([]config.StaticChannelConfig(nil), p.staticChannels...)
152 }
153
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -24,18 +24,24 @@
2424
Name string
2525
2626
// Topic is the initial channel topic (shared state header).
2727
Topic string
2828
29
- // Ops is a list of nicks to grant +o (channel operator) status.
29
+ // Ops is a list of nicks to grant +o (channel operator) status via AMODE.
3030
Ops []string
3131
32
- // Voice is a list of nicks to grant +v status.
32
+ // Voice is a list of nicks to grant +v status via AMODE.
3333
Voice []string
3434
3535
// Autojoin is a list of bot nicks to invite after provisioning.
3636
Autojoin []string
37
+
38
+ // Modes is a list of channel modes to set (e.g. "+m" for moderated).
39
+ Modes []string
40
+
41
+ // OnJoinMessage is sent to agents when they join this channel.
42
+ OnJoinMessage string
3743
}
3844
3945
// channelRecord tracks a provisioned channel for TTL-based reaping.
4046
type channelRecord struct {
4147
name string
@@ -207,15 +213,21 @@
207213
208214
if ch.Topic != "" {
209215
m.chanserv("TOPIC %s %s", ch.Name, ch.Topic)
210216
}
211217
218
+ // Use AMODE for persistent auto-mode on join (survives reconnects).
212219
for _, nick := range ch.Ops {
213
- m.chanserv("ACCESS %s ADD %s OP", ch.Name, nick)
220
+ m.chanserv("AMODE %s +o %s", ch.Name, nick)
214221
}
215222
for _, nick := range ch.Voice {
216
- m.chanserv("ACCESS %s ADD %s VOICE", ch.Name, nick)
223
+ m.chanserv("AMODE %s +v %s", ch.Name, nick)
224
+ }
225
+
226
+ // Apply channel modes (e.g. +m for moderated).
227
+ for _, mode := range ch.Modes {
228
+ m.client.Cmd.Mode(ch.Name, mode)
217229
}
218230
219231
if len(ch.Autojoin) > 0 {
220232
m.Invite(ch.Name, ch.Autojoin)
221233
}
@@ -274,33 +286,75 @@
274286
m.log.Info("reaping expired ephemeral channel", "channel", rec.name, "age", now.Sub(rec.provisionedAt).Round(time.Minute))
275287
m.DropChannel(rec.name)
276288
}
277289
}
278290
279
-// GrantAccess sets a ChanServ ACCESS entry for nick on the given channel.
280
-// level is "OP" or "VOICE". If level is empty, no access is granted.
291
+// GrantAccess sets a ChanServ AMODE entry for nick on the given channel.
292
+// level is "OP" or "VOICE". AMODE persists across reconnects — ChanServ
293
+// automatically applies the mode every time the nick joins.
281294
func (m *Manager) GrantAccess(nick, channel, level string) {
282295
if m.client == nil || level == "" {
283296
return
284297
}
285
- m.chanserv("ACCESS %s ADD %s %s", channel, nick, level)
286
- m.log.Info("granted channel access", "nick", nick, "channel", channel, "level", level)
298
+ switch strings.ToUpper(level) {
299
+ case "OP":
300
+ m.chanserv("AMODE %s +o %s", channel, nick)
301
+ case "VOICE":
302
+ m.chanserv("AMODE %s +v %s", channel, nick)
303
+ default:
304
+ m.log.Warn("unknown access level", "level", level)
305
+ return
306
+ }
307
+ m.log.Info("granted channel access (AMODE)", "nick", nick, "channel", channel, "level", level)
287308
}
288309
289
-// RevokeAccess removes a ChanServ ACCESS entry for nick on the given channel.
310
+// RevokeAccess removes ChanServ AMODE entries for nick on the given channel.
290311
func (m *Manager) RevokeAccess(nick, channel string) {
291312
if m.client == nil {
292313
return
293314
}
294
- m.chanserv("ACCESS %s DEL %s", channel, nick)
295
- m.log.Info("revoked channel access", "nick", nick, "channel", channel)
315
+ m.chanserv("AMODE %s -o %s", channel, nick)
316
+ m.chanserv("AMODE %s -v %s", channel, nick)
317
+ m.log.Info("revoked channel access (AMODE)", "nick", nick, "channel", channel)
296318
}
297319
298320
func (m *Manager) chanserv(format string, args ...any) {
299321
msg := fmt.Sprintf(format, args...)
300322
m.client.Cmd.Message("ChanServ", msg)
301323
}
324
+
325
+// ChannelInfo describes an active provisioned channel.
326
+type ChannelInfo struct {
327
+ Name string `json:"name"`
328
+ ProvisionedAt time.Time `json:"provisioned_at"`
329
+ Type string `json:"type,omitempty"`
330
+ Ephemeral bool `json:"ephemeral,omitempty"`
331
+ TTLSeconds int64 `json:"ttl_seconds,omitempty"`
332
+}
333
+
334
+// ListChannels returns all actively provisioned channels.
335
+func (m *Manager) ListChannels() []ChannelInfo {
336
+ m.mu.Lock()
337
+ defer m.mu.Unlock()
338
+ out := make([]ChannelInfo, 0, len(m.channels))
339
+ for _, rec := range m.channels {
340
+ ci := ChannelInfo{
341
+ Name: rec.name,
342
+ ProvisionedAt: rec.provisionedAt,
343
+ }
344
+ if m.policy != nil {
345
+ ci.Type = m.policy.TypeName(rec.name)
346
+ ci.Ephemeral = m.policy.IsEphemeral(rec.name)
347
+ ttl := m.policy.TTLFor(rec.name)
348
+ if ttl > 0 {
349
+ ci.TTLSeconds = int64(ttl.Seconds())
350
+ }
351
+ }
352
+ out = append(out, ci)
353
+ }
354
+ return out
355
+}
302356
303357
// ValidateName checks that a channel name follows scuttlebot conventions.
304358
func ValidateName(name string) error {
305359
if !strings.HasPrefix(name, "#") {
306360
return fmt.Errorf("topology: channel name must start with #: %q", name)
307361
308362
ADDED pkg/chathistory/chathistory.go
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -24,18 +24,24 @@
24 Name string
25
26 // Topic is the initial channel topic (shared state header).
27 Topic string
28
29 // Ops is a list of nicks to grant +o (channel operator) status.
30 Ops []string
31
32 // Voice is a list of nicks to grant +v status.
33 Voice []string
34
35 // Autojoin is a list of bot nicks to invite after provisioning.
36 Autojoin []string
 
 
 
 
 
 
37 }
38
39 // channelRecord tracks a provisioned channel for TTL-based reaping.
40 type channelRecord struct {
41 name string
@@ -207,15 +213,21 @@
207
208 if ch.Topic != "" {
209 m.chanserv("TOPIC %s %s", ch.Name, ch.Topic)
210 }
211
 
212 for _, nick := range ch.Ops {
213 m.chanserv("ACCESS %s ADD %s OP", ch.Name, nick)
214 }
215 for _, nick := range ch.Voice {
216 m.chanserv("ACCESS %s ADD %s VOICE", ch.Name, nick)
 
 
 
 
 
217 }
218
219 if len(ch.Autojoin) > 0 {
220 m.Invite(ch.Name, ch.Autojoin)
221 }
@@ -274,33 +286,75 @@
274 m.log.Info("reaping expired ephemeral channel", "channel", rec.name, "age", now.Sub(rec.provisionedAt).Round(time.Minute))
275 m.DropChannel(rec.name)
276 }
277 }
278
279 // GrantAccess sets a ChanServ ACCESS entry for nick on the given channel.
280 // level is "OP" or "VOICE". If level is empty, no access is granted.
 
281 func (m *Manager) GrantAccess(nick, channel, level string) {
282 if m.client == nil || level == "" {
283 return
284 }
285 m.chanserv("ACCESS %s ADD %s %s", channel, nick, level)
286 m.log.Info("granted channel access", "nick", nick, "channel", channel, "level", level)
 
 
 
 
 
 
 
 
287 }
288
289 // RevokeAccess removes a ChanServ ACCESS entry for nick on the given channel.
290 func (m *Manager) RevokeAccess(nick, channel string) {
291 if m.client == nil {
292 return
293 }
294 m.chanserv("ACCESS %s DEL %s", channel, nick)
295 m.log.Info("revoked channel access", "nick", nick, "channel", channel)
 
296 }
297
298 func (m *Manager) chanserv(format string, args ...any) {
299 msg := fmt.Sprintf(format, args...)
300 m.client.Cmd.Message("ChanServ", msg)
301 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
303 // ValidateName checks that a channel name follows scuttlebot conventions.
304 func ValidateName(name string) error {
305 if !strings.HasPrefix(name, "#") {
306 return fmt.Errorf("topology: channel name must start with #: %q", name)
307
308 DDED pkg/chathistory/chathistory.go
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -24,18 +24,24 @@
24 Name string
25
26 // Topic is the initial channel topic (shared state header).
27 Topic string
28
29 // Ops is a list of nicks to grant +o (channel operator) status via AMODE.
30 Ops []string
31
32 // Voice is a list of nicks to grant +v status via AMODE.
33 Voice []string
34
35 // Autojoin is a list of bot nicks to invite after provisioning.
36 Autojoin []string
37
38 // Modes is a list of channel modes to set (e.g. "+m" for moderated).
39 Modes []string
40
41 // OnJoinMessage is sent to agents when they join this channel.
42 OnJoinMessage string
43 }
44
45 // channelRecord tracks a provisioned channel for TTL-based reaping.
46 type channelRecord struct {
47 name string
@@ -207,15 +213,21 @@
213
214 if ch.Topic != "" {
215 m.chanserv("TOPIC %s %s", ch.Name, ch.Topic)
216 }
217
218 // Use AMODE for persistent auto-mode on join (survives reconnects).
219 for _, nick := range ch.Ops {
220 m.chanserv("AMODE %s +o %s", ch.Name, nick)
221 }
222 for _, nick := range ch.Voice {
223 m.chanserv("AMODE %s +v %s", ch.Name, nick)
224 }
225
226 // Apply channel modes (e.g. +m for moderated).
227 for _, mode := range ch.Modes {
228 m.client.Cmd.Mode(ch.Name, mode)
229 }
230
231 if len(ch.Autojoin) > 0 {
232 m.Invite(ch.Name, ch.Autojoin)
233 }
@@ -274,33 +286,75 @@
286 m.log.Info("reaping expired ephemeral channel", "channel", rec.name, "age", now.Sub(rec.provisionedAt).Round(time.Minute))
287 m.DropChannel(rec.name)
288 }
289 }
290
291 // GrantAccess sets a ChanServ AMODE entry for nick on the given channel.
292 // level is "OP" or "VOICE". AMODE persists across reconnects — ChanServ
293 // automatically applies the mode every time the nick joins.
294 func (m *Manager) GrantAccess(nick, channel, level string) {
295 if m.client == nil || level == "" {
296 return
297 }
298 switch strings.ToUpper(level) {
299 case "OP":
300 m.chanserv("AMODE %s +o %s", channel, nick)
301 case "VOICE":
302 m.chanserv("AMODE %s +v %s", channel, nick)
303 default:
304 m.log.Warn("unknown access level", "level", level)
305 return
306 }
307 m.log.Info("granted channel access (AMODE)", "nick", nick, "channel", channel, "level", level)
308 }
309
310 // RevokeAccess removes ChanServ AMODE entries for nick on the given channel.
311 func (m *Manager) RevokeAccess(nick, channel string) {
312 if m.client == nil {
313 return
314 }
315 m.chanserv("AMODE %s -o %s", channel, nick)
316 m.chanserv("AMODE %s -v %s", channel, nick)
317 m.log.Info("revoked channel access (AMODE)", "nick", nick, "channel", channel)
318 }
319
320 func (m *Manager) chanserv(format string, args ...any) {
321 msg := fmt.Sprintf(format, args...)
322 m.client.Cmd.Message("ChanServ", msg)
323 }
324
325 // ChannelInfo describes an active provisioned channel.
326 type ChannelInfo struct {
327 Name string `json:"name"`
328 ProvisionedAt time.Time `json:"provisioned_at"`
329 Type string `json:"type,omitempty"`
330 Ephemeral bool `json:"ephemeral,omitempty"`
331 TTLSeconds int64 `json:"ttl_seconds,omitempty"`
332 }
333
334 // ListChannels returns all actively provisioned channels.
335 func (m *Manager) ListChannels() []ChannelInfo {
336 m.mu.Lock()
337 defer m.mu.Unlock()
338 out := make([]ChannelInfo, 0, len(m.channels))
339 for _, rec := range m.channels {
340 ci := ChannelInfo{
341 Name: rec.name,
342 ProvisionedAt: rec.provisionedAt,
343 }
344 if m.policy != nil {
345 ci.Type = m.policy.TypeName(rec.name)
346 ci.Ephemeral = m.policy.IsEphemeral(rec.name)
347 ttl := m.policy.TTLFor(rec.name)
348 if ttl > 0 {
349 ci.TTLSeconds = int64(ttl.Seconds())
350 }
351 }
352 out = append(out, ci)
353 }
354 return out
355 }
356
357 // ValidateName checks that a channel name follows scuttlebot conventions.
358 func ValidateName(name string) error {
359 if !strings.HasPrefix(name, "#") {
360 return fmt.Errorf("topology: channel name must start with #: %q", name)
361
362 DDED pkg/chathistory/chathistory.go
--- a/pkg/chathistory/chathistory.go
+++ b/pkg/chathistory/chathistory.go
@@ -0,0 +1,183 @@
1
+// Package chathistory provides a synchronous wrapper around the IRCv3
2
+// CHATHISTORY extension for use with girc clients.
3
+//
4
+// Usage:
5
+//
6
+// fetcher := chathistory.New(client)
7
+// msgs, err := fetcher.Latest(ctx, "#channel", 50)
8
+package chathistory
9
+
10
+import (
11
+ "context"
12
+ "fmt"
13
+ "strings"
14
+ "sync"
15
+ "time"
16
+
17
+ "github.com/lrstanley/girc"
18
+)
19
+
20
+// Message is a single message returned by a CHATHISTORY query.
21
+type Message struct {
22
+ At time.Time
23
+ Nick string
24
+ Account string
25
+ Text string
26
+ MsgID string
27
+}
28
+
29
+// Fetcher sends CHATHISTORY commands and collects the batched responses.
30
+type Fetcher struct {
31
+ client *girc.Client
32
+
33
+ mu sync.Mutex
34
+ batches map[string]*batch // batchRef → accumulator
35
+ waiters map[string]chan []Message // channel → result (one waiter per channel)
36
+ handlers bool
37
+}
38
+
39
+type batch struct {
40
+ channel string
41
+ msgs []Message
42
+}
43
+
44
+// New creates a Fetcher and registers the necessary BATCH handlers on the
45
+// client. The client's Config.SupportedCaps should include
46
+// "draft/chathistory" (or "chathistory") so the capability is negotiated.
47
+func New(client *girc.Client) *Fetcher {
48
+ f := &Fetcher{
49
+ client: client,
50
+ batches: make(map[string]*batch),
51
+ waiters: make(map[string]chan []Message),
52
+ }
53
+ f.registerHandlers()
54
+ return f
55
+}
56
+
57
+func (f *Fetcher) registerHandlers() {
58
+ f.mu.Lock()
59
+ defer f.mu.Unlock()
60
+ if f.handlers {
61
+ return
62
+ }
63
+ f.handlers = true
64
+
65
+ // BATCH open/close.
66
+ f.client.Handlers.AddBg("BATCH", func(_ *girc.Client, e girc.Event) {
67
+ if len(e.Params) < 1 {
68
+ return
69
+ }
70
+ raw := e.Params[0]
71
+ if strings.HasPrefix(raw, "+") {
72
+ ref := raw[1:]
73
+ if len(e.Params) >= 2 && e.Params[1] == "chathistory" {
74
+ ch := ""
75
+ if len(e.Params) >= 3 {
76
+ ch = e.Params[2]
77
+ }
78
+ f.mu.Lock()
79
+ f.batches[ref] = &batch{channel: ch}
80
+ f.mu.Unlock()
81
+ }
82
+ } else if strings.HasPrefix(raw, "-") {
83
+ ref := raw[1:]
84
+ f.mu.Lock()
85
+ b, ok := f.batches[ref]
86
+ if ok {
87
+ delete(f.batches, ref)
88
+ if w, wok := f.waiters[b.channel]; wok {
89
+ delete(f.waiters, b.channel)
90
+ f.mu.Unlock()
91
+ w <- b.msgs
92
+ return
93
+ }
94
+ }
95
+ f.mu.Unlock()
96
+ }
97
+ })
98
+
99
+ // Collect PRIVMSGs tagged with a tracked batch ref.
100
+ f.client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
101
+ batchRef, ok := e.Tags.Get("batch")
102
+ if !ok || batchRef == "" {
103
+ return
104
+ }
105
+
106
+ f.mu.Lock()
107
+ b, tracked := f.batches[batchRef]
108
+ if !tracked {
109
+ f.mu.Unlock()
110
+ return
111
+ }
112
+
113
+ nick := ""
114
+ if e.Source != nil {
115
+ nick = e.Source.Name
116
+ }
117
+ acct, _ := e.Tags.Get("account")
118
+ msgID, _ := e.Tags.Get("msgid")
119
+
120
+ b.msgs = append(b.msgs, Message{
121
+ At: e.Timestamp,
122
+ Nick: nick,
123
+ Account: acct,
124
+ Text: e.Last(),
125
+ MsgID: msgID,
126
+ })
127
+ f.mu.Unlock()
128
+ })
129
+}
130
+
131
+// Latest fetches the N most recent messages from a channel using
132
+// CHATHISTORY LATEST. Blocks until the server responds or ctx expires.
133
+func (f *Fetcher) Latest(ctx context.Context, channel string, count int) ([]Message, error) {
134
+ result := make(chan []Message, 1)
135
+
136
+ f.mu.Lock()
137
+ f.waiters[channel] = result
138
+ f.mu.Unlock()
139
+
140
+ if err := f.client.Cmd.SendRawf("CHATHISTORY LATEST %s * %d", channel, count); err != nil {
141
+ f.mu.Lock()
142
+ delete(f.waiters, channel)
143
+ f.mu.Unlock()
144
+ return nil, fmt.Errorf("chathistory: send: %w", err)
145
+ }
146
+
147
+ select {
148
+ case msgs := <-result:
149
+ return msgs, nil
150
+ case <-ctx.Done():
151
+ f.mu.Lock()
152
+ delete(f.waiters, channel)
153
+ f.mu.Unlock()
154
+ return nil, ctx.Err()
155
+ }
156
+}
157
+
158
+// Before fetches up to count messages before the given timestamp.
159
+func (f *Fetcher) Before(ctx context.Context, channel string, before time.Time, count int) ([]Message, error) {
160
+ result := make(chan []Message, 1)
161
+
162
+ f.mu.Lock()
163
+ f.waiters[channel] = result
164
+ f.mu.Unlock()
165
+
166
+ ts := before.UTC().Format("2006-01-02T15:04:05.000Z")
167
+ if err := f.client.Cmd.SendRawf("CHATHISTORY BEFORE %s timestamp=%s %d", channel, ts, count); err != nil {
168
+ f.mu.Lock()
169
+ delete(f.waiters, channel)
170
+ f.mu.Unlock()
171
+ return nil, fmt.Errorf("chathistory: send: %w", err)
172
+ }
173
+
174
+ select {
175
+ case msgs := <-result:
176
+ return msgs, nil
177
+ case <-ctx.Done():
178
+ f.mu.Lock()
179
+ delete(f.waiters, channel)
180
+ f.mu.Unlock()
181
+ return nil, ctx.Err()
182
+ }
183
+}
--- a/pkg/chathistory/chathistory.go
+++ b/pkg/chathistory/chathistory.go
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pkg/chathistory/chathistory.go
+++ b/pkg/chathistory/chathistory.go
@@ -0,0 +1,183 @@
1 // Package chathistory provides a synchronous wrapper around the IRCv3
2 // CHATHISTORY extension for use with girc clients.
3 //
4 // Usage:
5 //
6 // fetcher := chathistory.New(client)
7 // msgs, err := fetcher.Latest(ctx, "#channel", 50)
8 package chathistory
9
10 import (
11 "context"
12 "fmt"
13 "strings"
14 "sync"
15 "time"
16
17 "github.com/lrstanley/girc"
18 )
19
20 // Message is a single message returned by a CHATHISTORY query.
21 type Message struct {
22 At time.Time
23 Nick string
24 Account string
25 Text string
26 MsgID string
27 }
28
29 // Fetcher sends CHATHISTORY commands and collects the batched responses.
30 type Fetcher struct {
31 client *girc.Client
32
33 mu sync.Mutex
34 batches map[string]*batch // batchRef → accumulator
35 waiters map[string]chan []Message // channel → result (one waiter per channel)
36 handlers bool
37 }
38
39 type batch struct {
40 channel string
41 msgs []Message
42 }
43
44 // New creates a Fetcher and registers the necessary BATCH handlers on the
45 // client. The client's Config.SupportedCaps should include
46 // "draft/chathistory" (or "chathistory") so the capability is negotiated.
47 func New(client *girc.Client) *Fetcher {
48 f := &Fetcher{
49 client: client,
50 batches: make(map[string]*batch),
51 waiters: make(map[string]chan []Message),
52 }
53 f.registerHandlers()
54 return f
55 }
56
57 func (f *Fetcher) registerHandlers() {
58 f.mu.Lock()
59 defer f.mu.Unlock()
60 if f.handlers {
61 return
62 }
63 f.handlers = true
64
65 // BATCH open/close.
66 f.client.Handlers.AddBg("BATCH", func(_ *girc.Client, e girc.Event) {
67 if len(e.Params) < 1 {
68 return
69 }
70 raw := e.Params[0]
71 if strings.HasPrefix(raw, "+") {
72 ref := raw[1:]
73 if len(e.Params) >= 2 && e.Params[1] == "chathistory" {
74 ch := ""
75 if len(e.Params) >= 3 {
76 ch = e.Params[2]
77 }
78 f.mu.Lock()
79 f.batches[ref] = &batch{channel: ch}
80 f.mu.Unlock()
81 }
82 } else if strings.HasPrefix(raw, "-") {
83 ref := raw[1:]
84 f.mu.Lock()
85 b, ok := f.batches[ref]
86 if ok {
87 delete(f.batches, ref)
88 if w, wok := f.waiters[b.channel]; wok {
89 delete(f.waiters, b.channel)
90 f.mu.Unlock()
91 w <- b.msgs
92 return
93 }
94 }
95 f.mu.Unlock()
96 }
97 })
98
99 // Collect PRIVMSGs tagged with a tracked batch ref.
100 f.client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
101 batchRef, ok := e.Tags.Get("batch")
102 if !ok || batchRef == "" {
103 return
104 }
105
106 f.mu.Lock()
107 b, tracked := f.batches[batchRef]
108 if !tracked {
109 f.mu.Unlock()
110 return
111 }
112
113 nick := ""
114 if e.Source != nil {
115 nick = e.Source.Name
116 }
117 acct, _ := e.Tags.Get("account")
118 msgID, _ := e.Tags.Get("msgid")
119
120 b.msgs = append(b.msgs, Message{
121 At: e.Timestamp,
122 Nick: nick,
123 Account: acct,
124 Text: e.Last(),
125 MsgID: msgID,
126 })
127 f.mu.Unlock()
128 })
129 }
130
131 // Latest fetches the N most recent messages from a channel using
132 // CHATHISTORY LATEST. Blocks until the server responds or ctx expires.
133 func (f *Fetcher) Latest(ctx context.Context, channel string, count int) ([]Message, error) {
134 result := make(chan []Message, 1)
135
136 f.mu.Lock()
137 f.waiters[channel] = result
138 f.mu.Unlock()
139
140 if err := f.client.Cmd.SendRawf("CHATHISTORY LATEST %s * %d", channel, count); err != nil {
141 f.mu.Lock()
142 delete(f.waiters, channel)
143 f.mu.Unlock()
144 return nil, fmt.Errorf("chathistory: send: %w", err)
145 }
146
147 select {
148 case msgs := <-result:
149 return msgs, nil
150 case <-ctx.Done():
151 f.mu.Lock()
152 delete(f.waiters, channel)
153 f.mu.Unlock()
154 return nil, ctx.Err()
155 }
156 }
157
158 // Before fetches up to count messages before the given timestamp.
159 func (f *Fetcher) Before(ctx context.Context, channel string, before time.Time, count int) ([]Message, error) {
160 result := make(chan []Message, 1)
161
162 f.mu.Lock()
163 f.waiters[channel] = result
164 f.mu.Unlock()
165
166 ts := before.UTC().Format("2006-01-02T15:04:05.000Z")
167 if err := f.client.Cmd.SendRawf("CHATHISTORY BEFORE %s timestamp=%s %d", channel, ts, count); err != nil {
168 f.mu.Lock()
169 delete(f.waiters, channel)
170 f.mu.Unlock()
171 return nil, fmt.Errorf("chathistory: send: %w", err)
172 }
173
174 select {
175 case msgs := <-result:
176 return msgs, nil
177 case <-ctx.Done():
178 f.mu.Lock()
179 delete(f.waiters, channel)
180 f.mu.Unlock()
181 return nil, ctx.Err()
182 }
183 }
--- pkg/ircagent/ircagent.go
+++ pkg/ircagent/ircagent.go
@@ -271,10 +271,17 @@
271271
text := strings.TrimSpace(e.Last())
272272
if senderNick == a.cfg.Nick {
273273
return
274274
}
275275
276
+ // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix.
277
+ if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
278
+ if idx := strings.Index(senderNick, sep); idx != -1 {
279
+ senderNick = senderNick[:idx]
280
+ }
281
+ }
282
+ // Fallback: parse legacy [nick] prefix from bridge bot.
276283
if strings.HasPrefix(text, "[") {
277284
if end := strings.Index(text, "] "); end != -1 {
278285
senderNick = text[1:end]
279286
text = text[end+2:]
280287
}
281288
--- pkg/ircagent/ircagent.go
+++ pkg/ircagent/ircagent.go
@@ -271,10 +271,17 @@
271 text := strings.TrimSpace(e.Last())
272 if senderNick == a.cfg.Nick {
273 return
274 }
275
 
 
 
 
 
 
 
276 if strings.HasPrefix(text, "[") {
277 if end := strings.Index(text, "] "); end != -1 {
278 senderNick = text[1:end]
279 text = text[end+2:]
280 }
281
--- pkg/ircagent/ircagent.go
+++ pkg/ircagent/ircagent.go
@@ -271,10 +271,17 @@
271 text := strings.TrimSpace(e.Last())
272 if senderNick == a.cfg.Nick {
273 return
274 }
275
276 // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix.
277 if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
278 if idx := strings.Index(senderNick, sep); idx != -1 {
279 senderNick = senderNick[:idx]
280 }
281 }
282 // Fallback: parse legacy [nick] prefix from bridge bot.
283 if strings.HasPrefix(text, "[") {
284 if end := strings.Index(text, "] "); end != -1 {
285 senderNick = text[1:end]
286 text = text[end+2:]
287 }
288
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -25,10 +25,11 @@
2525
nick string
2626
addr string
2727
agentType string
2828
pass string
2929
deleteOnClose bool
30
+ envelopeMode bool
3031
3132
mu sync.RWMutex
3233
channels []string
3334
messages []Message
3435
client *girc.Client
@@ -50,10 +51,11 @@
5051
nick: cfg.Nick,
5152
addr: cfg.IRC.Addr,
5253
agentType: cfg.IRC.AgentType,
5354
pass: cfg.IRC.Pass,
5455
deleteOnClose: cfg.IRC.DeleteOnClose,
56
+ envelopeMode: cfg.IRC.EnvelopeMode,
5557
channels: append([]string(nil), cfg.Channels...),
5658
messages: make([]Message, 0, defaultBufferSize),
5759
errCh: make(chan error, 1),
5860
}, nil
5961
}
@@ -126,27 +128,47 @@
126128
}
127129
if onJoined != nil {
128130
onJoined()
129131
}
130132
})
131
- client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
133
+ client.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
132134
if len(e.Params) < 1 || e.Source == nil {
133135
return
134136
}
135137
target := normalizeChannel(e.Params[0])
136138
if !c.hasChannel(target) {
137139
return
138140
}
141
+ // Prefer account-tag (IRCv3) over source nick.
139142
sender := e.Source.Name
143
+ if acct, ok := e.Tags.Get("account"); ok && acct != "" {
144
+ sender = acct
145
+ }
140146
text := strings.TrimSpace(e.Last())
147
+ // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix.
148
+ if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
149
+ if idx := strings.Index(sender, sep); idx != -1 {
150
+ sender = sender[:idx]
151
+ }
152
+ }
153
+ // Fallback: parse legacy [nick] prefix from bridge bot.
141154
if sender == "bridge" && strings.HasPrefix(text, "[") {
142155
if end := strings.Index(text, "] "); end != -1 {
143156
sender = text[1:end]
144157
text = strings.TrimSpace(text[end+2:])
145158
}
146159
}
147
- c.appendMessage(Message{At: time.Now(), Channel: target, Nick: sender, Text: text})
160
+ // Use server-time when available; fall back to local clock.
161
+ at := e.Timestamp
162
+ if at.IsZero() {
163
+ at = time.Now()
164
+ }
165
+ var msgID string
166
+ if id, ok := e.Tags.Get("msgid"); ok {
167
+ msgID = id
168
+ }
169
+ c.appendMessage(Message{At: at, Channel: target, Nick: sender, Text: text, MsgID: msgID})
148170
})
149171
150172
c.mu.Lock()
151173
c.client = client
152174
c.mu.Unlock()
@@ -221,26 +243,28 @@
221243
222244
func (c *ircConnector) PostTo(_ context.Context, channel, text string) error {
223245
return c.PostToWithMeta(context.Background(), channel, text, nil)
224246
}
225247
226
-// PostWithMeta sends text to all channels. Meta is ignored — IRC is text-only.
227
-func (c *ircConnector) PostWithMeta(_ context.Context, text string, _ json.RawMessage) error {
248
+// PostWithMeta sends text to all channels.
249
+// In envelope mode, wraps the message in a protocol.Envelope JSON.
250
+func (c *ircConnector) PostWithMeta(_ context.Context, text string, meta json.RawMessage) error {
228251
c.mu.RLock()
229252
client := c.client
230253
c.mu.RUnlock()
231254
if client == nil {
232255
return fmt.Errorf("sessionrelay: irc client not connected")
233256
}
257
+ msg := c.formatMessage(text, meta)
234258
for _, channel := range c.Channels() {
235
- client.Cmd.Message(channel, text)
259
+ client.Cmd.Message(channel, msg)
236260
}
237261
return nil
238262
}
239263
240
-// PostToWithMeta sends text to a specific channel. Meta is ignored — IRC is text-only.
241
-func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, _ json.RawMessage) error {
264
+// PostToWithMeta sends text to a specific channel.
265
+func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, meta json.RawMessage) error {
242266
c.mu.RLock()
243267
client := c.client
244268
c.mu.RUnlock()
245269
if client == nil {
246270
return fmt.Errorf("sessionrelay: irc client not connected")
@@ -247,13 +271,37 @@
247271
}
248272
channel = normalizeChannel(channel)
249273
if channel == "" {
250274
return fmt.Errorf("sessionrelay: post channel is required")
251275
}
252
- client.Cmd.Message(channel, text)
276
+ client.Cmd.Message(channel, c.formatMessage(text, meta))
253277
return nil
254278
}
279
+
280
+// formatMessage wraps text in a JSON envelope when envelope mode is enabled.
281
+func (c *ircConnector) formatMessage(text string, meta json.RawMessage) string {
282
+ if !c.envelopeMode {
283
+ return text
284
+ }
285
+ env := map[string]any{
286
+ "v": 1,
287
+ "type": "relay.message",
288
+ "from": c.nick,
289
+ "ts": time.Now().UnixMilli(),
290
+ "payload": map[string]any{
291
+ "text": text,
292
+ },
293
+ }
294
+ if len(meta) > 0 {
295
+ env["payload"] = json.RawMessage(meta)
296
+ }
297
+ data, err := json.Marshal(env)
298
+ if err != nil {
299
+ return text // fallback to plain text
300
+ }
301
+ return string(data)
302
+}
255303
256304
func (c *ircConnector) MessagesSince(_ context.Context, since time.Time) ([]Message, error) {
257305
c.mu.RLock()
258306
defer c.mu.RUnlock()
259307
260308
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -25,10 +25,11 @@
25 nick string
26 addr string
27 agentType string
28 pass string
29 deleteOnClose bool
 
30
31 mu sync.RWMutex
32 channels []string
33 messages []Message
34 client *girc.Client
@@ -50,10 +51,11 @@
50 nick: cfg.Nick,
51 addr: cfg.IRC.Addr,
52 agentType: cfg.IRC.AgentType,
53 pass: cfg.IRC.Pass,
54 deleteOnClose: cfg.IRC.DeleteOnClose,
 
55 channels: append([]string(nil), cfg.Channels...),
56 messages: make([]Message, 0, defaultBufferSize),
57 errCh: make(chan error, 1),
58 }, nil
59 }
@@ -126,27 +128,47 @@
126 }
127 if onJoined != nil {
128 onJoined()
129 }
130 })
131 client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
132 if len(e.Params) < 1 || e.Source == nil {
133 return
134 }
135 target := normalizeChannel(e.Params[0])
136 if !c.hasChannel(target) {
137 return
138 }
 
139 sender := e.Source.Name
 
 
 
140 text := strings.TrimSpace(e.Last())
 
 
 
 
 
 
 
141 if sender == "bridge" && strings.HasPrefix(text, "[") {
142 if end := strings.Index(text, "] "); end != -1 {
143 sender = text[1:end]
144 text = strings.TrimSpace(text[end+2:])
145 }
146 }
147 c.appendMessage(Message{At: time.Now(), Channel: target, Nick: sender, Text: text})
 
 
 
 
 
 
 
 
 
148 })
149
150 c.mu.Lock()
151 c.client = client
152 c.mu.Unlock()
@@ -221,26 +243,28 @@
221
222 func (c *ircConnector) PostTo(_ context.Context, channel, text string) error {
223 return c.PostToWithMeta(context.Background(), channel, text, nil)
224 }
225
226 // PostWithMeta sends text to all channels. Meta is ignored — IRC is text-only.
227 func (c *ircConnector) PostWithMeta(_ context.Context, text string, _ json.RawMessage) error {
 
228 c.mu.RLock()
229 client := c.client
230 c.mu.RUnlock()
231 if client == nil {
232 return fmt.Errorf("sessionrelay: irc client not connected")
233 }
 
234 for _, channel := range c.Channels() {
235 client.Cmd.Message(channel, text)
236 }
237 return nil
238 }
239
240 // PostToWithMeta sends text to a specific channel. Meta is ignored — IRC is text-only.
241 func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, _ json.RawMessage) error {
242 c.mu.RLock()
243 client := c.client
244 c.mu.RUnlock()
245 if client == nil {
246 return fmt.Errorf("sessionrelay: irc client not connected")
@@ -247,13 +271,37 @@
247 }
248 channel = normalizeChannel(channel)
249 if channel == "" {
250 return fmt.Errorf("sessionrelay: post channel is required")
251 }
252 client.Cmd.Message(channel, text)
253 return nil
254 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
256 func (c *ircConnector) MessagesSince(_ context.Context, since time.Time) ([]Message, error) {
257 c.mu.RLock()
258 defer c.mu.RUnlock()
259
260
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -25,10 +25,11 @@
25 nick string
26 addr string
27 agentType string
28 pass string
29 deleteOnClose bool
30 envelopeMode bool
31
32 mu sync.RWMutex
33 channels []string
34 messages []Message
35 client *girc.Client
@@ -50,10 +51,11 @@
51 nick: cfg.Nick,
52 addr: cfg.IRC.Addr,
53 agentType: cfg.IRC.AgentType,
54 pass: cfg.IRC.Pass,
55 deleteOnClose: cfg.IRC.DeleteOnClose,
56 envelopeMode: cfg.IRC.EnvelopeMode,
57 channels: append([]string(nil), cfg.Channels...),
58 messages: make([]Message, 0, defaultBufferSize),
59 errCh: make(chan error, 1),
60 }, nil
61 }
@@ -126,27 +128,47 @@
128 }
129 if onJoined != nil {
130 onJoined()
131 }
132 })
133 client.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
134 if len(e.Params) < 1 || e.Source == nil {
135 return
136 }
137 target := normalizeChannel(e.Params[0])
138 if !c.hasChannel(target) {
139 return
140 }
141 // Prefer account-tag (IRCv3) over source nick.
142 sender := e.Source.Name
143 if acct, ok := e.Tags.Get("account"); ok && acct != "" {
144 sender = acct
145 }
146 text := strings.TrimSpace(e.Last())
147 // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix.
148 if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
149 if idx := strings.Index(sender, sep); idx != -1 {
150 sender = sender[:idx]
151 }
152 }
153 // Fallback: parse legacy [nick] prefix from bridge bot.
154 if sender == "bridge" && strings.HasPrefix(text, "[") {
155 if end := strings.Index(text, "] "); end != -1 {
156 sender = text[1:end]
157 text = strings.TrimSpace(text[end+2:])
158 }
159 }
160 // Use server-time when available; fall back to local clock.
161 at := e.Timestamp
162 if at.IsZero() {
163 at = time.Now()
164 }
165 var msgID string
166 if id, ok := e.Tags.Get("msgid"); ok {
167 msgID = id
168 }
169 c.appendMessage(Message{At: at, Channel: target, Nick: sender, Text: text, MsgID: msgID})
170 })
171
172 c.mu.Lock()
173 c.client = client
174 c.mu.Unlock()
@@ -221,26 +243,28 @@
243
244 func (c *ircConnector) PostTo(_ context.Context, channel, text string) error {
245 return c.PostToWithMeta(context.Background(), channel, text, nil)
246 }
247
248 // PostWithMeta sends text to all channels.
249 // In envelope mode, wraps the message in a protocol.Envelope JSON.
250 func (c *ircConnector) PostWithMeta(_ context.Context, text string, meta json.RawMessage) error {
251 c.mu.RLock()
252 client := c.client
253 c.mu.RUnlock()
254 if client == nil {
255 return fmt.Errorf("sessionrelay: irc client not connected")
256 }
257 msg := c.formatMessage(text, meta)
258 for _, channel := range c.Channels() {
259 client.Cmd.Message(channel, msg)
260 }
261 return nil
262 }
263
264 // PostToWithMeta sends text to a specific channel.
265 func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, meta json.RawMessage) error {
266 c.mu.RLock()
267 client := c.client
268 c.mu.RUnlock()
269 if client == nil {
270 return fmt.Errorf("sessionrelay: irc client not connected")
@@ -247,13 +271,37 @@
271 }
272 channel = normalizeChannel(channel)
273 if channel == "" {
274 return fmt.Errorf("sessionrelay: post channel is required")
275 }
276 client.Cmd.Message(channel, c.formatMessage(text, meta))
277 return nil
278 }
279
280 // formatMessage wraps text in a JSON envelope when envelope mode is enabled.
281 func (c *ircConnector) formatMessage(text string, meta json.RawMessage) string {
282 if !c.envelopeMode {
283 return text
284 }
285 env := map[string]any{
286 "v": 1,
287 "type": "relay.message",
288 "from": c.nick,
289 "ts": time.Now().UnixMilli(),
290 "payload": map[string]any{
291 "text": text,
292 },
293 }
294 if len(meta) > 0 {
295 env["payload"] = json.RawMessage(meta)
296 }
297 data, err := json.Marshal(env)
298 if err != nil {
299 return text // fallback to plain text
300 }
301 return string(data)
302 }
303
304 func (c *ircConnector) MessagesSince(_ context.Context, since time.Time) ([]Message, error) {
305 c.mu.RLock()
306 defer c.mu.RUnlock()
307
308
--- pkg/sessionrelay/sessionrelay.go
+++ pkg/sessionrelay/sessionrelay.go
@@ -35,17 +35,21 @@
3535
type IRCConfig struct {
3636
Addr string
3737
Pass string
3838
AgentType string
3939
DeleteOnClose bool
40
+ // EnvelopeMode wraps outgoing messages in protocol.Envelope JSON.
41
+ // When true, agents in the channel can parse relay output as structured data.
42
+ EnvelopeMode bool
4043
}
4144
4245
type Message struct {
4346
At time.Time
4447
Channel string
4548
Nick string
4649
Text string
50
+ MsgID string
4751
}
4852
4953
type Connector interface {
5054
Connect(ctx context.Context) error
5155
Post(ctx context.Context, text string) error
5256
5357
ADDED pkg/toon/toon.go
5458
ADDED pkg/toon/toon_test.go
--- pkg/sessionrelay/sessionrelay.go
+++ pkg/sessionrelay/sessionrelay.go
@@ -35,17 +35,21 @@
35 type IRCConfig struct {
36 Addr string
37 Pass string
38 AgentType string
39 DeleteOnClose bool
 
 
 
40 }
41
42 type Message struct {
43 At time.Time
44 Channel string
45 Nick string
46 Text string
 
47 }
48
49 type Connector interface {
50 Connect(ctx context.Context) error
51 Post(ctx context.Context, text string) error
52
53 DDED pkg/toon/toon.go
54 DDED pkg/toon/toon_test.go
--- pkg/sessionrelay/sessionrelay.go
+++ pkg/sessionrelay/sessionrelay.go
@@ -35,17 +35,21 @@
35 type IRCConfig struct {
36 Addr string
37 Pass string
38 AgentType string
39 DeleteOnClose bool
40 // EnvelopeMode wraps outgoing messages in protocol.Envelope JSON.
41 // When true, agents in the channel can parse relay output as structured data.
42 EnvelopeMode bool
43 }
44
45 type Message struct {
46 At time.Time
47 Channel string
48 Nick string
49 Text string
50 MsgID string
51 }
52
53 type Connector interface {
54 Connect(ctx context.Context) error
55 Post(ctx context.Context, text string) error
56
57 DDED pkg/toon/toon.go
58 DDED pkg/toon/toon_test.go
--- a/pkg/toon/toon.go
+++ b/pkg/toon/toon.go
@@ -0,0 +1,121 @@
1
+// Package toon implements the TOON format — Token-Optimized Object Notation
2
+// for compact LLM context windows.
3
+//
4
+// TOON is designed for feeding IRC conversation history to language models.
5
+// It strips noise (joins, parts, status messages, repeated tool calls),
6
+// deduplicates, and compresses timestamps into relative offsets.
7
+//
8
+// Example output:
9
+//
10
+// #fleet 50msg 2h window
11
+// ---
12
+// claude-kohakku [orch] +0m
13
+// task.create {file: main.go, action: edit}
14
+// "editing main.go to add error handling"
15
+// leo [op] +2m
16
+// "looks good, ship it"
17
+// claude-kohakku [orch] +3m
18
+// task.complete {file: main.go, status: done}
19
+// ---
20
+// decisions: edit main.go error handling
21
+// actions: task.create → task.complete (main.go)
22
+package toon
23
+
24
+import (
25
+ "fmt"
26
+ "strings"
27
+ "time"
28
+)
29
+
30
+// Entry is a single message to include in the TOON output.
31
+type Entry struct {
32
+ Nick string
33
+ Type string // agent type: "orch", "worker", "op", "bot", "" for unknown
34
+ MessageType string // envelope type (e.g. "task.create"), empty for plain text
35
+ Text string
36
+ At time.Time
37
+}
38
+
39
+// Options controls TOON formatting.
40
+type Options struct {
41
+ Channel string
42
+ MaxEntries int // 0 = no limit
43
+}
44
+
45
+// Format renders a slice of entries into TOON format.
46
+func Format(entries []Entry, opts Options) string {
47
+ if len(entries) == 0 {
48
+ return ""
49
+ }
50
+
51
+ var b strings.Builder
52
+
53
+ // Header.
54
+ window := ""
55
+ if len(entries) >= 2 {
56
+ dur := entries[len(entries)-1].At.Sub(entries[0].At)
57
+ window = " " + compactDuration(dur) + " window"
58
+ }
59
+ ch := opts.Channel
60
+ if ch == "" {
61
+ ch = "channel"
62
+ }
63
+ fmt.Fprintf(&b, "%s %dmsg%s\n---\n", ch, len(entries), window)
64
+
65
+ // Body — group consecutive messages from same nick.
66
+ baseTime := entries[0].At
67
+ var lastNick string
68
+ for _, e := range entries {
69
+ offset := e.At.Sub(baseTime)
70
+ if e.Nick != lastNick {
71
+ tag := ""
72
+ if e.Type != "" {
73
+ tag = " [" + e.Type + "]"
74
+ }
75
+ fmt.Fprintf(&b, "%s%s +%s\n", e.Nick, tag, compactDuration(offset))
76
+ lastNick = e.Nick
77
+ }
78
+
79
+ if e.MessageType != "" {
80
+ fmt.Fprintf(&b, " %s\n", e.MessageType)
81
+ }
82
+ text := strings.TrimSpace(e.Text)
83
+ if text != "" && text != e.MessageType {
84
+ // Truncate very long messages to save tokens.
85
+ if len(text) > 200 {
86
+ text = text[:197] + "..."
87
+ }
88
+ fmt.Fprintf(&b, " \"%s\"\n", text)
89
+ }
90
+ }
91
+
92
+ b.WriteString("---\n")
93
+ return b.String()
94
+}
95
+
96
+// FormatPrompt wraps TOON-formatted history into an LLM summarization prompt.
97
+func FormatPrompt(channel string, entries []Entry) string {
98
+ toon := Format(entries, Options{Channel: channel})
99
+ var b strings.Builder
100
+ fmt.Fprintf(&b, "Summarize this IRC conversation. Focus on decisions, actions, and outcomes. Be concise.\n\n")
101
+ b.WriteString(toon)
102
+ return b.String()
103
+}
104
+
105
+func compactDuration(d time.Duration) string {
106
+ if d < time.Minute {
107
+ return fmt.Sprintf("%ds", int(d.Seconds()))
108
+ }
109
+ if d < time.Hour {
110
+ return fmt.Sprintf("%dm", int(d.Minutes()))
111
+ }
112
+ if d < 24*time.Hour {
113
+ h := int(d.Hours())
114
+ m := int(d.Minutes()) % 60
115
+ if m == 0 {
116
+ return fmt.Sprintf("%dh", h)
117
+ }
118
+ return fmt.Sprintf("%dh%dm", h, m)
119
+ }
120
+ return fmt.Sprintf("%dd", int(d.Hours()/24))
121
+}
--- a/pkg/toon/toon.go
+++ b/pkg/toon/toon.go
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pkg/toon/toon.go
+++ b/pkg/toon/toon.go
@@ -0,0 +1,121 @@
1 // Package toon implements the TOON format — Token-Optimized Object Notation
2 // for compact LLM context windows.
3 //
4 // TOON is designed for feeding IRC conversation history to language models.
5 // It strips noise (joins, parts, status messages, repeated tool calls),
6 // deduplicates, and compresses timestamps into relative offsets.
7 //
8 // Example output:
9 //
10 // #fleet 50msg 2h window
11 // ---
12 // claude-kohakku [orch] +0m
13 // task.create {file: main.go, action: edit}
14 // "editing main.go to add error handling"
15 // leo [op] +2m
16 // "looks good, ship it"
17 // claude-kohakku [orch] +3m
18 // task.complete {file: main.go, status: done}
19 // ---
20 // decisions: edit main.go error handling
21 // actions: task.create → task.complete (main.go)
22 package toon
23
24 import (
25 "fmt"
26 "strings"
27 "time"
28 )
29
30 // Entry is a single message to include in the TOON output.
31 type Entry struct {
32 Nick string
33 Type string // agent type: "orch", "worker", "op", "bot", "" for unknown
34 MessageType string // envelope type (e.g. "task.create"), empty for plain text
35 Text string
36 At time.Time
37 }
38
39 // Options controls TOON formatting.
40 type Options struct {
41 Channel string
42 MaxEntries int // 0 = no limit
43 }
44
45 // Format renders a slice of entries into TOON format.
46 func Format(entries []Entry, opts Options) string {
47 if len(entries) == 0 {
48 return ""
49 }
50
51 var b strings.Builder
52
53 // Header.
54 window := ""
55 if len(entries) >= 2 {
56 dur := entries[len(entries)-1].At.Sub(entries[0].At)
57 window = " " + compactDuration(dur) + " window"
58 }
59 ch := opts.Channel
60 if ch == "" {
61 ch = "channel"
62 }
63 fmt.Fprintf(&b, "%s %dmsg%s\n---\n", ch, len(entries), window)
64
65 // Body — group consecutive messages from same nick.
66 baseTime := entries[0].At
67 var lastNick string
68 for _, e := range entries {
69 offset := e.At.Sub(baseTime)
70 if e.Nick != lastNick {
71 tag := ""
72 if e.Type != "" {
73 tag = " [" + e.Type + "]"
74 }
75 fmt.Fprintf(&b, "%s%s +%s\n", e.Nick, tag, compactDuration(offset))
76 lastNick = e.Nick
77 }
78
79 if e.MessageType != "" {
80 fmt.Fprintf(&b, " %s\n", e.MessageType)
81 }
82 text := strings.TrimSpace(e.Text)
83 if text != "" && text != e.MessageType {
84 // Truncate very long messages to save tokens.
85 if len(text) > 200 {
86 text = text[:197] + "..."
87 }
88 fmt.Fprintf(&b, " \"%s\"\n", text)
89 }
90 }
91
92 b.WriteString("---\n")
93 return b.String()
94 }
95
96 // FormatPrompt wraps TOON-formatted history into an LLM summarization prompt.
97 func FormatPrompt(channel string, entries []Entry) string {
98 toon := Format(entries, Options{Channel: channel})
99 var b strings.Builder
100 fmt.Fprintf(&b, "Summarize this IRC conversation. Focus on decisions, actions, and outcomes. Be concise.\n\n")
101 b.WriteString(toon)
102 return b.String()
103 }
104
105 func compactDuration(d time.Duration) string {
106 if d < time.Minute {
107 return fmt.Sprintf("%ds", int(d.Seconds()))
108 }
109 if d < time.Hour {
110 return fmt.Sprintf("%dm", int(d.Minutes()))
111 }
112 if d < 24*time.Hour {
113 h := int(d.Hours())
114 m := int(d.Minutes()) % 60
115 if m == 0 {
116 return fmt.Sprintf("%dh", h)
117 }
118 return fmt.Sprintf("%dh%dm", h, m)
119 }
120 return fmt.Sprintf("%dd", int(d.Hours()/24))
121 }
--- a/pkg/toon/toon_test.go
+++ b/pkg/toon/toon_test.go
@@ -0,0 +1,65 @@
1
+package toon
2
+
3
+import (
4
+ "strings"
5
+ "testing"
6
+ "time"
7
+)
8
+
9
+func TestFormatEmpty(t *testing.T) {
10
+ if got := Format(nil, Options{}); got != "" {
11
+ t.Errorf("expected empty, got %q", got)
12
+ }
13
+}
14
+
15
+func TestFormatBasic(t *testing.T) {
16
+ base := time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC)
17
+ entries := []Entry{
18
+ {Nick: "alice", Type: "op", Text: "let's ship it", At: base},
19
+ {Nick: "claude-abc", Type: "orch", MessageType: "task.create", Text: "editing main.go", At: base.Add(2 * time.Minute)},
20
+ {Nick: "claude-abc", Type: "orch", MessageType: "task.complete", Text: "done", At: base.Add(5 * time.Minute)},
21
+ }
22
+ out := Format(entries, Options{Channel: "#fleet"})
23
+
24
+ // Header.
25
+ if !strings.HasPrefix(out, "#fleet 3msg") {
26
+ t.Errorf("header mismatch: %q", out)
27
+ }
28
+ // Grouped consecutive messages from claude-abc.
29
+ if strings.Count(out, "claude-abc") != 1 {
30
+ t.Errorf("expected nick grouping, got:\n%s", out)
31
+ }
32
+ // Contains message types.
33
+ if !strings.Contains(out, "task.create") || !strings.Contains(out, "task.complete") {
34
+ t.Errorf("missing message types:\n%s", out)
35
+ }
36
+}
37
+
38
+func TestFormatPrompt(t *testing.T) {
39
+ entries := []Entry{{Nick: "a", Text: "hello"}}
40
+ out := FormatPrompt("#test", entries)
41
+ if !strings.Contains(out, "Summarize") {
42
+ t.Errorf("prompt missing instruction:\n%s", out)
43
+ }
44
+ if !strings.Contains(out, "#test") {
45
+ t.Errorf("prompt missing channel:\n%s", out)
46
+ }
47
+}
48
+
49
+func TestCompactDuration(t *testing.T) {
50
+ tests := []struct {
51
+ d time.Duration
52
+ want string
53
+ }{
54
+ {30 * time.Second, "30s"},
55
+ {5 * time.Minute, "5m"},
56
+ {2 * time.Hour, "2h"},
57
+ {2*time.Hour + 30*time.Minute, "2h30m"},
58
+ {48 * time.Hour, "2d"},
59
+ }
60
+ for _, tt := range tests {
61
+ if got := compactDuration(tt.d); got != tt.want {
62
+ t.Errorf("compactDuration(%v) = %q, want %q", tt.d, got, tt.want)
63
+ }
64
+ }
65
+}
--- a/pkg/toon/toon_test.go
+++ b/pkg/toon/toon_test.go
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pkg/toon/toon_test.go
+++ b/pkg/toon/toon_test.go
@@ -0,0 +1,65 @@
1 package toon
2
3 import (
4 "strings"
5 "testing"
6 "time"
7 )
8
9 func TestFormatEmpty(t *testing.T) {
10 if got := Format(nil, Options{}); got != "" {
11 t.Errorf("expected empty, got %q", got)
12 }
13 }
14
15 func TestFormatBasic(t *testing.T) {
16 base := time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC)
17 entries := []Entry{
18 {Nick: "alice", Type: "op", Text: "let's ship it", At: base},
19 {Nick: "claude-abc", Type: "orch", MessageType: "task.create", Text: "editing main.go", At: base.Add(2 * time.Minute)},
20 {Nick: "claude-abc", Type: "orch", MessageType: "task.complete", Text: "done", At: base.Add(5 * time.Minute)},
21 }
22 out := Format(entries, Options{Channel: "#fleet"})
23
24 // Header.
25 if !strings.HasPrefix(out, "#fleet 3msg") {
26 t.Errorf("header mismatch: %q", out)
27 }
28 // Grouped consecutive messages from claude-abc.
29 if strings.Count(out, "claude-abc") != 1 {
30 t.Errorf("expected nick grouping, got:\n%s", out)
31 }
32 // Contains message types.
33 if !strings.Contains(out, "task.create") || !strings.Contains(out, "task.complete") {
34 t.Errorf("missing message types:\n%s", out)
35 }
36 }
37
38 func TestFormatPrompt(t *testing.T) {
39 entries := []Entry{{Nick: "a", Text: "hello"}}
40 out := FormatPrompt("#test", entries)
41 if !strings.Contains(out, "Summarize") {
42 t.Errorf("prompt missing instruction:\n%s", out)
43 }
44 if !strings.Contains(out, "#test") {
45 t.Errorf("prompt missing channel:\n%s", out)
46 }
47 }
48
49 func TestCompactDuration(t *testing.T) {
50 tests := []struct {
51 d time.Duration
52 want string
53 }{
54 {30 * time.Second, "30s"},
55 {5 * time.Minute, "5m"},
56 {2 * time.Hour, "2h"},
57 {2*time.Hour + 30*time.Minute, "2h30m"},
58 {48 * time.Hour, "2d"},
59 }
60 for _, tt := range tests {
61 if got := compactDuration(tt.d); got != tt.want {
62 t.Errorf("compactDuration(%v) = %q, want %q", tt.d, got, tt.want)
63 }
64 }
65 }

Keyboard Shortcuts

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