ScuttleBot
Merge branch 'main' into feature/123-sdk-ircv3-tags Resolves conflicts between IRCv3 tag support (feature) and RELAYMSG native attribution + API key management (main). Both sets of changes are preserved: meta-type client tags on send/receive, IRCv3 transport metadata in SDK Envelope, RELAYMSG with suffix stripping, and all new scuttlectl commands.
Commit
38781b672358eee613a1263c267403aea1d3fc42735863b5616abce99bda2937
Parent
c77ae38a39b0ed6…
45 files changed
+30
-7
+18
-29
+97
-179
+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
+129
-6
+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
+17
+7
+7
+56
-8
+4
+121
+65
~
cmd/scuttlebot/main.go
~
cmd/scuttlectl/internal/apiclient/apiclient.go
~
cmd/scuttlectl/main.go
~
deploy/compose/ergo/ircd.yaml.tmpl
~
internal/api/api_test.go
~
internal/api/apikeys.go
~
internal/api/channels_topology.go
~
internal/api/channels_topology_test.go
~
internal/api/chat.go
~
internal/api/chat_test.go
~
internal/api/config_handlers_test.go
~
internal/api/login.go
~
internal/api/login_test.go
~
internal/api/middleware.go
~
internal/api/policies.go
~
internal/api/server.go
~
internal/api/settings.go
~
internal/api/ui/index.html
~
internal/auth/apikeys.go
~
internal/bots/auditbot/auditbot.go
~
internal/bots/bridge/bridge.go
~
internal/bots/herald/herald.go
~
internal/bots/oracle/oracle.go
~
internal/bots/scribe/scribe.go
~
internal/bots/scroll/scroll.go
~
internal/bots/sentinel/sentinel.go
~
internal/bots/snitch/snitch.go
~
internal/bots/steward/steward.go
~
internal/bots/systembot/systembot.go
~
internal/bots/warden/warden.go
~
internal/config/config.go
~
internal/ergo/ircdconfig.go
~
internal/ergo/manager.go
~
internal/mcp/mcp.go
~
internal/mcp/mcp_test.go
~
internal/topology/policy.go
~
internal/topology/topology.go
~
pkg/chathistory/chathistory.go
~
pkg/client/client.go
~
pkg/ircagent/ircagent.go
~
pkg/protocol/protocol.go
~
pkg/sessionrelay/irc.go
~
pkg/sessionrelay/sessionrelay.go
~
pkg/toon/toon.go
~
pkg/toon/toon_test.go
+30
-7
| --- cmd/scuttlebot/main.go | ||
| +++ cmd/scuttlebot/main.go | ||
| @@ -138,18 +138,31 @@ | ||
| 138 | 138 | } else if err := reg.SetDataPath(filepath.Join(cfg.Ergo.DataDir, "registry.json")); err != nil { |
| 139 | 139 | log.Error("registry load", "err", err) |
| 140 | 140 | os.Exit(1) |
| 141 | 141 | } |
| 142 | 142 | |
| 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")) | |
| 145 | 145 | if err != nil { |
| 146 | - log.Error("api token", "err", err) | |
| 146 | + log.Error("api key store", "err", err) | |
| 147 | 147 | os.Exit(1) |
| 148 | 148 | } |
| 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 | + } | |
| 151 | 164 | |
| 152 | 165 | // Start bridge bot (powers the web chat UI). |
| 153 | 166 | var bridgeBot *bridge.Bot |
| 154 | 167 | if cfg.Bridge.Enabled { |
| 155 | 168 | if cfg.Bridge.Password == "" { |
| @@ -204,10 +217,11 @@ | ||
| 204 | 217 | Name: sc.Name, |
| 205 | 218 | Topic: sc.Topic, |
| 206 | 219 | Ops: sc.Ops, |
| 207 | 220 | Voice: sc.Voice, |
| 208 | 221 | Autojoin: sc.Autojoin, |
| 222 | + Modes: sc.Modes, | |
| 209 | 223 | }) |
| 210 | 224 | } |
| 211 | 225 | if err := topoMgr.Provision(staticChannels); err != nil { |
| 212 | 226 | log.Error("topology provision failed", "err", err) |
| 213 | 227 | } |
| @@ -328,19 +342,28 @@ | ||
| 328 | 342 | staticChannels := make([]topology.ChannelConfig, 0, len(updated.Topology.Channels)) |
| 329 | 343 | for _, sc := range updated.Topology.Channels { |
| 330 | 344 | staticChannels = append(staticChannels, topology.ChannelConfig{ |
| 331 | 345 | Name: sc.Name, Topic: sc.Topic, |
| 332 | 346 | Ops: sc.Ops, Voice: sc.Voice, Autojoin: sc.Autojoin, |
| 347 | + Modes: sc.Modes, | |
| 333 | 348 | }) |
| 334 | 349 | } |
| 335 | 350 | if err := topoMgr.Provision(staticChannels); err != nil { |
| 336 | 351 | log.Error("topology hot-reload failed", "err", err) |
| 337 | 352 | } |
| 338 | 353 | } |
| 339 | 354 | // Hot-reload bridge web TTL. |
| 340 | 355 | if bridgeBot != nil { |
| 341 | 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 | + } | |
| 342 | 365 | } |
| 343 | 366 | }) |
| 344 | 367 | |
| 345 | 368 | // Start HTTP REST API server. |
| 346 | 369 | var llmCfg *config.LLMConfig |
| @@ -352,11 +375,11 @@ | ||
| 352 | 375 | // non-nil (Go nil interface trap) and causes panics in setAgentModes. |
| 353 | 376 | var topoIface api.TopologyManager |
| 354 | 377 | if topoMgr != nil { |
| 355 | 378 | topoIface = topoMgr |
| 356 | 379 | } |
| 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) | |
| 358 | 381 | handler := apiSrv.Handler() |
| 359 | 382 | |
| 360 | 383 | var httpServer, tlsServer *http.Server |
| 361 | 384 | |
| 362 | 385 | if cfg.TLS.Domain != "" { |
| @@ -418,11 +441,11 @@ | ||
| 418 | 441 | } |
| 419 | 442 | }() |
| 420 | 443 | } |
| 421 | 444 | |
| 422 | 445 | // Start MCP server. |
| 423 | - mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, tokens, log) | |
| 446 | + mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, apiKeyStore, log) | |
| 424 | 447 | mcpServer := &http.Server{ |
| 425 | 448 | Addr: cfg.MCPAddr, |
| 426 | 449 | Handler: mcpSrv.Handler(), |
| 427 | 450 | } |
| 428 | 451 | go func() { |
| 429 | 452 |
| --- 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 | ||
| @@ -138,39 +138,28 @@ | ||
| 138 | 138 | func (c *Client) RemoveAdmin(username string) error { |
| 139 | 139 | _, err := c.doNoBody("DELETE", "/v1/admins/"+username) |
| 140 | 140 | return err |
| 141 | 141 | } |
| 142 | 142 | |
| 143 | -// GetTopology returns GET /v1/topology. | |
| 144 | -func (c *Client) GetTopology() (json.RawMessage, error) { | |
| 145 | - return c.get("/v1/topology") | |
| 146 | -} | |
| 147 | - | |
| 148 | -// ProvisionChannel sends POST /v1/channels. | |
| 149 | -func (c *Client) ProvisionChannel(name string) (json.RawMessage, error) { | |
| 150 | - return c.post("/v1/channels", map[string]string{"name": name}) | |
| 151 | -} | |
| 152 | - | |
| 153 | -// DropChannel sends DELETE /v1/topology/channels/{channel}. | |
| 154 | -func (c *Client) DropChannel(channel string) error { | |
| 155 | - _, err := c.doNoBody("DELETE", "/v1/topology/channels/"+strings.TrimPrefix(channel, "#")) | |
| 156 | - return err | |
| 157 | -} | |
| 158 | - | |
| 159 | -// GetConfig returns GET /v1/config. | |
| 160 | -func (c *Client) GetConfig() (json.RawMessage, error) { | |
| 161 | - return c.get("/v1/config") | |
| 162 | -} | |
| 163 | - | |
| 164 | -// GetConfigHistory returns GET /v1/config/history. | |
| 165 | -func (c *Client) GetConfigHistory() (json.RawMessage, error) { | |
| 166 | - return c.get("/v1/config/history") | |
| 167 | -} | |
| 168 | - | |
| 169 | -// GetSettings returns GET /v1/settings. | |
| 170 | -func (c *Client) GetSettings() (json.RawMessage, error) { | |
| 171 | - return c.get("/v1/settings") | |
| 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 | |
| 172 | 161 | } |
| 173 | 162 | |
| 174 | 163 | // SetAdminPassword sends PUT /v1/admins/{username}/password. |
| 175 | 164 | func (c *Client) SetAdminPassword(username, password string) error { |
| 176 | 165 | _, err := c.put("/v1/admins/"+username+"/password", map[string]string{"password": password}) |
| 177 | 166 |
| --- cmd/scuttlectl/internal/apiclient/apiclient.go | |
| +++ cmd/scuttlectl/internal/apiclient/apiclient.go | |
| @@ -138,39 +138,28 @@ | |
| 138 | func (c *Client) RemoveAdmin(username string) error { |
| 139 | _, err := c.doNoBody("DELETE", "/v1/admins/"+username) |
| 140 | return err |
| 141 | } |
| 142 | |
| 143 | // GetTopology returns GET /v1/topology. |
| 144 | func (c *Client) GetTopology() (json.RawMessage, error) { |
| 145 | return c.get("/v1/topology") |
| 146 | } |
| 147 | |
| 148 | // ProvisionChannel sends POST /v1/channels. |
| 149 | func (c *Client) ProvisionChannel(name string) (json.RawMessage, error) { |
| 150 | return c.post("/v1/channels", map[string]string{"name": name}) |
| 151 | } |
| 152 | |
| 153 | // DropChannel sends DELETE /v1/topology/channels/{channel}. |
| 154 | func (c *Client) DropChannel(channel string) error { |
| 155 | _, err := c.doNoBody("DELETE", "/v1/topology/channels/"+strings.TrimPrefix(channel, "#")) |
| 156 | return err |
| 157 | } |
| 158 | |
| 159 | // GetConfig returns GET /v1/config. |
| 160 | func (c *Client) GetConfig() (json.RawMessage, error) { |
| 161 | return c.get("/v1/config") |
| 162 | } |
| 163 | |
| 164 | // GetConfigHistory returns GET /v1/config/history. |
| 165 | func (c *Client) GetConfigHistory() (json.RawMessage, error) { |
| 166 | return c.get("/v1/config/history") |
| 167 | } |
| 168 | |
| 169 | // GetSettings returns GET /v1/settings. |
| 170 | func (c *Client) GetSettings() (json.RawMessage, error) { |
| 171 | return c.get("/v1/settings") |
| 172 | } |
| 173 | |
| 174 | // SetAdminPassword sends PUT /v1/admins/{username}/password. |
| 175 | func (c *Client) SetAdminPassword(username, password string) error { |
| 176 | _, err := c.put("/v1/admins/"+username+"/password", map[string]string{"password": password}) |
| 177 |
| --- cmd/scuttlectl/internal/apiclient/apiclient.go | |
| +++ cmd/scuttlectl/internal/apiclient/apiclient.go | |
| @@ -138,39 +138,28 @@ | |
| 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 |
+97
-179
| --- cmd/scuttlectl/main.go | ||
| +++ cmd/scuttlectl/main.go | ||
| @@ -108,10 +108,28 @@ | ||
| 108 | 108 | requireArgs(args, 3, "scuttlectl admin passwd <username>") |
| 109 | 109 | cmdAdminPasswd(api, args[2]) |
| 110 | 110 | default: |
| 111 | 111 | fmt.Fprintf(os.Stderr, "unknown subcommand: admin %s\n", args[1]) |
| 112 | 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) | |
| 113 | 131 | } |
| 114 | 132 | case "channels", "channel": |
| 115 | 133 | if len(args) < 2 { |
| 116 | 134 | fmt.Fprintf(os.Stderr, "usage: scuttlectl channels <list|users <channel>>\n") |
| 117 | 135 | os.Exit(1) |
| @@ -148,54 +166,10 @@ | ||
| 148 | 166 | cmdBackendRename(api, args[2], args[3]) |
| 149 | 167 | default: |
| 150 | 168 | fmt.Fprintf(os.Stderr, "unknown subcommand: backend %s\n", args[1]) |
| 151 | 169 | os.Exit(1) |
| 152 | 170 | } |
| 153 | - case "topology", "topo": | |
| 154 | - if len(args) < 2 { | |
| 155 | - fmt.Fprintf(os.Stderr, "usage: scuttlectl topology <list|provision|drop>\n") | |
| 156 | - os.Exit(1) | |
| 157 | - } | |
| 158 | - switch args[1] { | |
| 159 | - case "list", "show": | |
| 160 | - cmdTopologyList(api, *jsonFlag) | |
| 161 | - case "provision", "create": | |
| 162 | - requireArgs(args, 3, "scuttlectl topology provision #channel") | |
| 163 | - cmdTopologyProvision(api, args[2], *jsonFlag) | |
| 164 | - case "drop", "rm": | |
| 165 | - requireArgs(args, 3, "scuttlectl topology drop #channel") | |
| 166 | - cmdTopologyDrop(api, args[2]) | |
| 167 | - default: | |
| 168 | - fmt.Fprintf(os.Stderr, "unknown subcommand: topology %s\n", args[1]) | |
| 169 | - os.Exit(1) | |
| 170 | - } | |
| 171 | - case "config": | |
| 172 | - if len(args) < 2 { | |
| 173 | - fmt.Fprintf(os.Stderr, "usage: scuttlectl config <show|history>\n") | |
| 174 | - os.Exit(1) | |
| 175 | - } | |
| 176 | - switch args[1] { | |
| 177 | - case "show", "get": | |
| 178 | - cmdConfigShow(api, *jsonFlag) | |
| 179 | - case "history": | |
| 180 | - cmdConfigHistory(api, *jsonFlag) | |
| 181 | - default: | |
| 182 | - fmt.Fprintf(os.Stderr, "unknown subcommand: config %s\n", args[1]) | |
| 183 | - os.Exit(1) | |
| 184 | - } | |
| 185 | - case "bot", "bots": | |
| 186 | - if len(args) < 2 { | |
| 187 | - fmt.Fprintf(os.Stderr, "usage: scuttlectl bot <list>\n") | |
| 188 | - os.Exit(1) | |
| 189 | - } | |
| 190 | - switch args[1] { | |
| 191 | - case "list": | |
| 192 | - cmdBotList(api, *jsonFlag) | |
| 193 | - default: | |
| 194 | - fmt.Fprintf(os.Stderr, "unknown subcommand: bot %s\n", args[1]) | |
| 195 | - os.Exit(1) | |
| 196 | - } | |
| 197 | 171 | default: |
| 198 | 172 | fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0]) |
| 199 | 173 | usage() |
| 200 | 174 | os.Exit(1) |
| 201 | 175 | } |
| @@ -536,139 +510,86 @@ | ||
| 536 | 510 | fmt.Fprintf(tw, "server\t%s\n", creds.Server) |
| 537 | 511 | tw.Flush() |
| 538 | 512 | fmt.Println("\nStore this password — it will not be shown again.") |
| 539 | 513 | } |
| 540 | 514 | |
| 541 | -// --- topology --- | |
| 542 | - | |
| 543 | -func cmdTopologyList(api *apiclient.Client, asJSON bool) { | |
| 544 | - raw, err := api.GetTopology() | |
| 545 | - die(err) | |
| 546 | - if asJSON { | |
| 547 | - printJSON(raw) | |
| 548 | - return | |
| 549 | - } | |
| 550 | - var data struct { | |
| 551 | - StaticChannels []string `json:"static_channels"` | |
| 552 | - Types []struct { | |
| 553 | - Name string `json:"name"` | |
| 554 | - Prefix string `json:"prefix"` | |
| 555 | - Autojoin []string `json:"autojoin"` | |
| 556 | - Ephemeral bool `json:"ephemeral"` | |
| 557 | - TTL int64 `json:"ttl_seconds"` | |
| 558 | - } `json:"types"` | |
| 559 | - } | |
| 560 | - must(json.Unmarshal(raw, &data)) | |
| 561 | - | |
| 562 | - tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |
| 563 | - fmt.Fprintln(tw, "STATIC CHANNELS") | |
| 564 | - for _, ch := range data.StaticChannels { | |
| 565 | - fmt.Fprintf(tw, " %s\n", ch) | |
| 566 | - } | |
| 567 | - if len(data.Types) > 0 { | |
| 568 | - fmt.Fprintln(tw, "\nCHANNEL TYPES") | |
| 569 | - fmt.Fprintln(tw, " NAME\tPREFIX\tAUTOJOIN\tEPHEMERAL\tTTL") | |
| 570 | - for _, t := range data.Types { | |
| 571 | - ttl := "—" | |
| 572 | - if t.TTL > 0 { | |
| 573 | - ttl = fmt.Sprintf("%dh", t.TTL/3600) | |
| 574 | - } | |
| 575 | - eph := "no" | |
| 576 | - if t.Ephemeral { | |
| 577 | - eph = "yes" | |
| 578 | - } | |
| 579 | - fmt.Fprintf(tw, " %s\t#%s*\t%s\t%s\t%s\n", t.Name, t.Prefix, strings.Join(t.Autojoin, ","), eph, ttl) | |
| 580 | - } | |
| 581 | - } | |
| 582 | - tw.Flush() | |
| 583 | -} | |
| 584 | - | |
| 585 | -func cmdTopologyProvision(api *apiclient.Client, channel string, asJSON bool) { | |
| 586 | - if !strings.HasPrefix(channel, "#") { | |
| 587 | - channel = "#" + channel | |
| 588 | - } | |
| 589 | - raw, err := api.ProvisionChannel(channel) | |
| 590 | - die(err) | |
| 591 | - if asJSON { | |
| 592 | - printJSON(raw) | |
| 593 | - return | |
| 594 | - } | |
| 595 | - fmt.Printf("Channel provisioned: %s\n", channel) | |
| 596 | -} | |
| 597 | - | |
| 598 | -func cmdTopologyDrop(api *apiclient.Client, channel string) { | |
| 599 | - if !strings.HasPrefix(channel, "#") { | |
| 600 | - channel = "#" + channel | |
| 601 | - } | |
| 602 | - die(api.DropChannel(channel)) | |
| 603 | - fmt.Printf("Channel dropped: %s\n", channel) | |
| 604 | -} | |
| 605 | - | |
| 606 | -// --- config --- | |
| 607 | - | |
| 608 | -func cmdConfigShow(api *apiclient.Client, asJSON bool) { | |
| 609 | - raw, err := api.GetConfig() | |
| 610 | - die(err) | |
| 611 | - printJSON(raw) // always JSON — config is a complex nested object | |
| 612 | -} | |
| 613 | - | |
| 614 | -func cmdConfigHistory(api *apiclient.Client, asJSON bool) { | |
| 615 | - raw, err := api.GetConfigHistory() | |
| 616 | - die(err) | |
| 617 | - if asJSON { | |
| 618 | - printJSON(raw) | |
| 619 | - return | |
| 620 | - } | |
| 621 | - var data struct { | |
| 622 | - Entries []struct { | |
| 623 | - Filename string `json:"filename"` | |
| 624 | - At string `json:"at"` | |
| 625 | - } `json:"entries"` | |
| 626 | - } | |
| 627 | - must(json.Unmarshal(raw, &data)) | |
| 628 | - if len(data.Entries) == 0 { | |
| 629 | - fmt.Println("no config history") | |
| 630 | - return | |
| 631 | - } | |
| 632 | - tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |
| 633 | - fmt.Fprintln(tw, "SNAPSHOT\tTIME") | |
| 634 | - for _, e := range data.Entries { | |
| 635 | - fmt.Fprintf(tw, "%s\t%s\n", e.Filename, e.At) | |
| 636 | - } | |
| 637 | - tw.Flush() | |
| 638 | -} | |
| 639 | - | |
| 640 | -// --- bots --- | |
| 641 | - | |
| 642 | -func cmdBotList(api *apiclient.Client, asJSON bool) { | |
| 643 | - raw, err := api.GetSettings() | |
| 644 | - die(err) | |
| 645 | - if asJSON { | |
| 646 | - printJSON(raw) | |
| 647 | - return | |
| 648 | - } | |
| 649 | - var data struct { | |
| 650 | - Policies struct { | |
| 651 | - Behaviors []struct { | |
| 652 | - ID string `json:"id"` | |
| 653 | - Name string `json:"name"` | |
| 654 | - Nick string `json:"nick"` | |
| 655 | - Enabled bool `json:"enabled"` | |
| 656 | - } `json:"behaviors"` | |
| 657 | - } `json:"policies"` | |
| 658 | - } | |
| 659 | - must(json.Unmarshal(raw, &data)) | |
| 660 | - tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |
| 661 | - fmt.Fprintln(tw, "BOT\tNICK\tSTATUS") | |
| 662 | - for _, b := range data.Policies.Behaviors { | |
| 663 | - status := "disabled" | |
| 664 | - if b.Enabled { | |
| 665 | - status = "enabled" | |
| 666 | - } | |
| 667 | - fmt.Fprintf(tw, "%s\t%s\t%s\n", b.Name, b.Nick, status) | |
| 668 | - } | |
| 669 | - tw.Flush() | |
| 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) | |
| 670 | 591 | } |
| 671 | 592 | |
| 672 | 593 | func usage() { |
| 673 | 594 | fmt.Fprintf(os.Stderr, `scuttlectl %s — scuttlebot management CLI |
| 674 | 595 | |
| @@ -701,16 +622,13 @@ | ||
| 701 | 622 | backend rename <old> <new> rename a backend |
| 702 | 623 | admin list list admin accounts |
| 703 | 624 | admin add <username> add admin (prompts for password) |
| 704 | 625 | admin remove <username> remove admin |
| 705 | 626 | admin passwd <username> change admin password (prompts) |
| 706 | - topology list show topology (static channels, types) | |
| 707 | - topology provision #channel provision a new channel via ChanServ | |
| 708 | - topology drop #channel drop a channel | |
| 709 | - config show dump current config (JSON) | |
| 710 | - config history show config change history | |
| 711 | - bot list show system bot status | |
| 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 | |
| 712 | 630 | `, version) |
| 713 | 631 | } |
| 714 | 632 | |
| 715 | 633 | func printJSON(raw json.RawMessage) { |
| 716 | 634 | var buf []byte |
| 717 | 635 |
| --- 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) |
| @@ -148,54 +166,10 @@ | |
| 148 | cmdBackendRename(api, args[2], args[3]) |
| 149 | default: |
| 150 | fmt.Fprintf(os.Stderr, "unknown subcommand: backend %s\n", args[1]) |
| 151 | os.Exit(1) |
| 152 | } |
| 153 | case "topology", "topo": |
| 154 | if len(args) < 2 { |
| 155 | fmt.Fprintf(os.Stderr, "usage: scuttlectl topology <list|provision|drop>\n") |
| 156 | os.Exit(1) |
| 157 | } |
| 158 | switch args[1] { |
| 159 | case "list", "show": |
| 160 | cmdTopologyList(api, *jsonFlag) |
| 161 | case "provision", "create": |
| 162 | requireArgs(args, 3, "scuttlectl topology provision #channel") |
| 163 | cmdTopologyProvision(api, args[2], *jsonFlag) |
| 164 | case "drop", "rm": |
| 165 | requireArgs(args, 3, "scuttlectl topology drop #channel") |
| 166 | cmdTopologyDrop(api, args[2]) |
| 167 | default: |
| 168 | fmt.Fprintf(os.Stderr, "unknown subcommand: topology %s\n", args[1]) |
| 169 | os.Exit(1) |
| 170 | } |
| 171 | case "config": |
| 172 | if len(args) < 2 { |
| 173 | fmt.Fprintf(os.Stderr, "usage: scuttlectl config <show|history>\n") |
| 174 | os.Exit(1) |
| 175 | } |
| 176 | switch args[1] { |
| 177 | case "show", "get": |
| 178 | cmdConfigShow(api, *jsonFlag) |
| 179 | case "history": |
| 180 | cmdConfigHistory(api, *jsonFlag) |
| 181 | default: |
| 182 | fmt.Fprintf(os.Stderr, "unknown subcommand: config %s\n", args[1]) |
| 183 | os.Exit(1) |
| 184 | } |
| 185 | case "bot", "bots": |
| 186 | if len(args) < 2 { |
| 187 | fmt.Fprintf(os.Stderr, "usage: scuttlectl bot <list>\n") |
| 188 | os.Exit(1) |
| 189 | } |
| 190 | switch args[1] { |
| 191 | case "list": |
| 192 | cmdBotList(api, *jsonFlag) |
| 193 | default: |
| 194 | fmt.Fprintf(os.Stderr, "unknown subcommand: bot %s\n", args[1]) |
| 195 | os.Exit(1) |
| 196 | } |
| 197 | default: |
| 198 | fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0]) |
| 199 | usage() |
| 200 | os.Exit(1) |
| 201 | } |
| @@ -536,139 +510,86 @@ | |
| 536 | fmt.Fprintf(tw, "server\t%s\n", creds.Server) |
| 537 | tw.Flush() |
| 538 | fmt.Println("\nStore this password — it will not be shown again.") |
| 539 | } |
| 540 | |
| 541 | // --- topology --- |
| 542 | |
| 543 | func cmdTopologyList(api *apiclient.Client, asJSON bool) { |
| 544 | raw, err := api.GetTopology() |
| 545 | die(err) |
| 546 | if asJSON { |
| 547 | printJSON(raw) |
| 548 | return |
| 549 | } |
| 550 | var data struct { |
| 551 | StaticChannels []string `json:"static_channels"` |
| 552 | Types []struct { |
| 553 | Name string `json:"name"` |
| 554 | Prefix string `json:"prefix"` |
| 555 | Autojoin []string `json:"autojoin"` |
| 556 | Ephemeral bool `json:"ephemeral"` |
| 557 | TTL int64 `json:"ttl_seconds"` |
| 558 | } `json:"types"` |
| 559 | } |
| 560 | must(json.Unmarshal(raw, &data)) |
| 561 | |
| 562 | tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) |
| 563 | fmt.Fprintln(tw, "STATIC CHANNELS") |
| 564 | for _, ch := range data.StaticChannels { |
| 565 | fmt.Fprintf(tw, " %s\n", ch) |
| 566 | } |
| 567 | if len(data.Types) > 0 { |
| 568 | fmt.Fprintln(tw, "\nCHANNEL TYPES") |
| 569 | fmt.Fprintln(tw, " NAME\tPREFIX\tAUTOJOIN\tEPHEMERAL\tTTL") |
| 570 | for _, t := range data.Types { |
| 571 | ttl := "—" |
| 572 | if t.TTL > 0 { |
| 573 | ttl = fmt.Sprintf("%dh", t.TTL/3600) |
| 574 | } |
| 575 | eph := "no" |
| 576 | if t.Ephemeral { |
| 577 | eph = "yes" |
| 578 | } |
| 579 | fmt.Fprintf(tw, " %s\t#%s*\t%s\t%s\t%s\n", t.Name, t.Prefix, strings.Join(t.Autojoin, ","), eph, ttl) |
| 580 | } |
| 581 | } |
| 582 | tw.Flush() |
| 583 | } |
| 584 | |
| 585 | func cmdTopologyProvision(api *apiclient.Client, channel string, asJSON bool) { |
| 586 | if !strings.HasPrefix(channel, "#") { |
| 587 | channel = "#" + channel |
| 588 | } |
| 589 | raw, err := api.ProvisionChannel(channel) |
| 590 | die(err) |
| 591 | if asJSON { |
| 592 | printJSON(raw) |
| 593 | return |
| 594 | } |
| 595 | fmt.Printf("Channel provisioned: %s\n", channel) |
| 596 | } |
| 597 | |
| 598 | func cmdTopologyDrop(api *apiclient.Client, channel string) { |
| 599 | if !strings.HasPrefix(channel, "#") { |
| 600 | channel = "#" + channel |
| 601 | } |
| 602 | die(api.DropChannel(channel)) |
| 603 | fmt.Printf("Channel dropped: %s\n", channel) |
| 604 | } |
| 605 | |
| 606 | // --- config --- |
| 607 | |
| 608 | func cmdConfigShow(api *apiclient.Client, asJSON bool) { |
| 609 | raw, err := api.GetConfig() |
| 610 | die(err) |
| 611 | printJSON(raw) // always JSON — config is a complex nested object |
| 612 | } |
| 613 | |
| 614 | func cmdConfigHistory(api *apiclient.Client, asJSON bool) { |
| 615 | raw, err := api.GetConfigHistory() |
| 616 | die(err) |
| 617 | if asJSON { |
| 618 | printJSON(raw) |
| 619 | return |
| 620 | } |
| 621 | var data struct { |
| 622 | Entries []struct { |
| 623 | Filename string `json:"filename"` |
| 624 | At string `json:"at"` |
| 625 | } `json:"entries"` |
| 626 | } |
| 627 | must(json.Unmarshal(raw, &data)) |
| 628 | if len(data.Entries) == 0 { |
| 629 | fmt.Println("no config history") |
| 630 | return |
| 631 | } |
| 632 | tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) |
| 633 | fmt.Fprintln(tw, "SNAPSHOT\tTIME") |
| 634 | for _, e := range data.Entries { |
| 635 | fmt.Fprintf(tw, "%s\t%s\n", e.Filename, e.At) |
| 636 | } |
| 637 | tw.Flush() |
| 638 | } |
| 639 | |
| 640 | // --- bots --- |
| 641 | |
| 642 | func cmdBotList(api *apiclient.Client, asJSON bool) { |
| 643 | raw, err := api.GetSettings() |
| 644 | die(err) |
| 645 | if asJSON { |
| 646 | printJSON(raw) |
| 647 | return |
| 648 | } |
| 649 | var data struct { |
| 650 | Policies struct { |
| 651 | Behaviors []struct { |
| 652 | ID string `json:"id"` |
| 653 | Name string `json:"name"` |
| 654 | Nick string `json:"nick"` |
| 655 | Enabled bool `json:"enabled"` |
| 656 | } `json:"behaviors"` |
| 657 | } `json:"policies"` |
| 658 | } |
| 659 | must(json.Unmarshal(raw, &data)) |
| 660 | tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) |
| 661 | fmt.Fprintln(tw, "BOT\tNICK\tSTATUS") |
| 662 | for _, b := range data.Policies.Behaviors { |
| 663 | status := "disabled" |
| 664 | if b.Enabled { |
| 665 | status = "enabled" |
| 666 | } |
| 667 | fmt.Fprintf(tw, "%s\t%s\t%s\n", b.Name, b.Nick, status) |
| 668 | } |
| 669 | tw.Flush() |
| 670 | } |
| 671 | |
| 672 | func usage() { |
| 673 | fmt.Fprintf(os.Stderr, `scuttlectl %s — scuttlebot management CLI |
| 674 | |
| @@ -701,16 +622,13 @@ | |
| 701 | backend rename <old> <new> rename a backend |
| 702 | admin list list admin accounts |
| 703 | admin add <username> add admin (prompts for password) |
| 704 | admin remove <username> remove admin |
| 705 | admin passwd <username> change admin password (prompts) |
| 706 | topology list show topology (static channels, types) |
| 707 | topology provision #channel provision a new channel via ChanServ |
| 708 | topology drop #channel drop a channel |
| 709 | config show dump current config (JSON) |
| 710 | config history show config change history |
| 711 | bot list show system bot status |
| 712 | `, version) |
| 713 | } |
| 714 | |
| 715 | func printJSON(raw json.RawMessage) { |
| 716 | var buf []byte |
| 717 |
| --- 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) |
| @@ -148,54 +166,10 @@ | |
| 166 | cmdBackendRename(api, args[2], args[3]) |
| 167 | default: |
| 168 | fmt.Fprintf(os.Stderr, "unknown subcommand: backend %s\n", args[1]) |
| 169 | os.Exit(1) |
| 170 | } |
| 171 | default: |
| 172 | fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0]) |
| 173 | usage() |
| 174 | os.Exit(1) |
| 175 | } |
| @@ -536,139 +510,86 @@ | |
| 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 | |
| @@ -701,16 +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 @@ | ||
| 13 | 13 | enforce-utf8: true |
| 14 | 14 | lookup-hostnames: false |
| 15 | 15 | forward-confirm-hostnames: false |
| 16 | 16 | check-ident: false |
| 17 | 17 | relaymsg: |
| 18 | - enabled: false | |
| 18 | + enabled: true | |
| 19 | + separators: / | |
| 20 | + available-to-chanops: false | |
| 19 | 21 | ip-cloaking: |
| 20 | 22 | enabled: false |
| 21 | 23 | max-sendq: "1M" |
| 22 | 24 | ip-limits: |
| 23 | 25 | count-exempted: true |
| 24 | 26 |
| --- 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 |
+2
-1
| --- internal/api/api_test.go | ||
| +++ internal/api/api_test.go | ||
| @@ -8,10 +8,11 @@ | ||
| 8 | 8 | "net/http/httptest" |
| 9 | 9 | "sync" |
| 10 | 10 | "testing" |
| 11 | 11 | |
| 12 | 12 | "github.com/conflicthq/scuttlebot/internal/api" |
| 13 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 13 | 14 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 14 | 15 | "log/slog" |
| 15 | 16 | "os" |
| 16 | 17 | ) |
| 17 | 18 | |
| @@ -50,11 +51,11 @@ | ||
| 50 | 51 | const testToken = "test-api-token-abc123" |
| 51 | 52 | |
| 52 | 53 | func newTestServer(t *testing.T) *httptest.Server { |
| 53 | 54 | t.Helper() |
| 54 | 55 | 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) | |
| 56 | 57 | return httptest.NewServer(srv.Handler()) |
| 57 | 58 | } |
| 58 | 59 | |
| 59 | 60 | func authHeader() http.Header { |
| 60 | 61 | h := http.Header{} |
| 61 | 62 | |
| 62 | 63 | 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 |
+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 | +} |
| --- 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 | } |
+11
-3
| --- internal/api/channels_topology.go | ||
| +++ internal/api/channels_topology.go | ||
| @@ -14,10 +14,11 @@ | ||
| 14 | 14 | ProvisionChannel(ch topology.ChannelConfig) error |
| 15 | 15 | DropChannel(channel string) |
| 16 | 16 | Policy() *topology.Policy |
| 17 | 17 | GrantAccess(nick, channel, level string) |
| 18 | 18 | RevokeAccess(nick, channel string) |
| 19 | + ListChannels() []topology.ChannelInfo | |
| 19 | 20 | } |
| 20 | 21 | |
| 21 | 22 | type provisionChannelRequest struct { |
| 22 | 23 | Name string `json:"name"` |
| 23 | 24 | Topic string `json:"topic,omitempty"` |
| @@ -51,22 +52,27 @@ | ||
| 51 | 52 | return |
| 52 | 53 | } |
| 53 | 54 | |
| 54 | 55 | policy := s.topoMgr.Policy() |
| 55 | 56 | |
| 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. | |
| 57 | 58 | autojoin := req.Autojoin |
| 58 | 59 | if len(autojoin) == 0 && policy != nil { |
| 59 | 60 | autojoin = policy.AutojoinFor(req.Name) |
| 60 | 61 | } |
| 62 | + var modes []string | |
| 63 | + if policy != nil { | |
| 64 | + modes = policy.ModesFor(req.Name) | |
| 65 | + } | |
| 61 | 66 | |
| 62 | 67 | ch := topology.ChannelConfig{ |
| 63 | 68 | Name: req.Name, |
| 64 | 69 | Topic: req.Topic, |
| 65 | 70 | Ops: req.Ops, |
| 66 | 71 | Voice: req.Voice, |
| 67 | 72 | Autojoin: autojoin, |
| 73 | + Modes: modes, | |
| 68 | 74 | } |
| 69 | 75 | if err := s.topoMgr.ProvisionChannel(ch); err != nil { |
| 70 | 76 | s.log.Error("provision channel", "channel", req.Name, "err", err) |
| 71 | 77 | writeError(w, http.StatusInternalServerError, "provision failed") |
| 72 | 78 | return |
| @@ -91,12 +97,13 @@ | ||
| 91 | 97 | Ephemeral bool `json:"ephemeral,omitempty"` |
| 92 | 98 | TTLSeconds int64 `json:"ttl_seconds,omitempty"` |
| 93 | 99 | } |
| 94 | 100 | |
| 95 | 101 | 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"` | |
| 98 | 105 | } |
| 99 | 106 | |
| 100 | 107 | // handleDropChannel handles DELETE /v1/topology/channels/{channel}. |
| 101 | 108 | // Drops the ChanServ registration of an ephemeral channel. |
| 102 | 109 | func (s *Server) handleDropChannel(w http.ResponseWriter, r *http.Request) { |
| @@ -146,7 +153,8 @@ | ||
| 146 | 153 | } |
| 147 | 154 | |
| 148 | 155 | writeJSON(w, http.StatusOK, topologyResponse{ |
| 149 | 156 | StaticChannels: staticNames, |
| 150 | 157 | Types: typeInfos, |
| 158 | + ActiveChannels: s.topoMgr.ListChannels(), | |
| 151 | 159 | }) |
| 152 | 160 | } |
| 153 | 161 |
| --- 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 @@ | ||
| 9 | 9 | "net/http" |
| 10 | 10 | "net/http/httptest" |
| 11 | 11 | "testing" |
| 12 | 12 | "time" |
| 13 | 13 | |
| 14 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 14 | 15 | "github.com/conflicthq/scuttlebot/internal/config" |
| 15 | 16 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 16 | 17 | "github.com/conflicthq/scuttlebot/internal/topology" |
| 17 | 18 | ) |
| 18 | 19 | |
| @@ -48,10 +49,12 @@ | ||
| 48 | 49 | |
| 49 | 50 | func (s *stubTopologyManager) RevokeAccess(nick, channel string) { |
| 50 | 51 | s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel}) |
| 51 | 52 | } |
| 52 | 53 | |
| 54 | +func (s *stubTopologyManager) ListChannels() []topology.ChannelInfo { return nil } | |
| 55 | + | |
| 53 | 56 | // stubProvisioner is a minimal AccountProvisioner for agent registration tests. |
| 54 | 57 | type stubProvisioner struct { |
| 55 | 58 | accounts map[string]string |
| 56 | 59 | } |
| 57 | 60 | |
| @@ -74,11 +77,11 @@ | ||
| 74 | 77 | |
| 75 | 78 | func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) { |
| 76 | 79 | t.Helper() |
| 77 | 80 | reg := registry.New(nil, []byte("key")) |
| 78 | 81 | 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()) | |
| 80 | 83 | t.Cleanup(srv.Close) |
| 81 | 84 | return srv, "tok" |
| 82 | 85 | } |
| 83 | 86 | |
| 84 | 87 | // newTopoTestServerWithRegistry creates a test server with both topology and a |
| @@ -85,11 +88,11 @@ | ||
| 85 | 88 | // real registry backed by stubProvisioner, so agent registration works. |
| 86 | 89 | func newTopoTestServerWithRegistry(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) { |
| 87 | 90 | t.Helper() |
| 88 | 91 | reg := registry.New(newStubProvisioner(), []byte("key")) |
| 89 | 92 | 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()) | |
| 91 | 94 | t.Cleanup(srv.Close) |
| 92 | 95 | return srv, "tok" |
| 93 | 96 | } |
| 94 | 97 | |
| 95 | 98 | func TestHandleProvisionChannel(t *testing.T) { |
| 96 | 99 |
| --- 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 |
+43
-4
| --- internal/api/chat.go | ||
| +++ internal/api/chat.go | ||
| @@ -5,10 +5,11 @@ | ||
| 5 | 5 | "encoding/json" |
| 6 | 6 | "fmt" |
| 7 | 7 | "net/http" |
| 8 | 8 | "time" |
| 9 | 9 | |
| 10 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 10 | 11 | "github.com/conflicthq/scuttlebot/internal/bots/bridge" |
| 11 | 12 | ) |
| 12 | 13 | |
| 13 | 14 | // chatBridge is the interface the API layer requires from the bridge bot. |
| 14 | 15 | type chatBridge interface { |
| @@ -20,10 +21,12 @@ | ||
| 20 | 21 | Send(ctx context.Context, channel, text, senderNick string) error |
| 21 | 22 | SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *bridge.Meta) error |
| 22 | 23 | Stats() bridge.Stats |
| 23 | 24 | TouchUser(channel, nick string) |
| 24 | 25 | Users(channel string) []string |
| 26 | + UsersWithModes(channel string) []bridge.UserInfo | |
| 27 | + ChannelModes(channel string) string | |
| 25 | 28 | } |
| 26 | 29 | |
| 27 | 30 | func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) { |
| 28 | 31 | channel := "#" + r.PathValue("channel") |
| 29 | 32 | s.bridge.JoinChannel(channel) |
| @@ -107,22 +110,58 @@ | ||
| 107 | 110 | w.WriteHeader(http.StatusNoContent) |
| 108 | 111 | } |
| 109 | 112 | |
| 110 | 113 | func (s *Server) handleChannelUsers(w http.ResponseWriter, r *http.Request) { |
| 111 | 114 | channel := "#" + r.PathValue("channel") |
| 112 | - users := s.bridge.Users(channel) | |
| 115 | + users := s.bridge.UsersWithModes(channel) | |
| 113 | 116 | 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 | |
| 115 | 153 | } |
| 116 | - writeJSON(w, http.StatusOK, map[string]any{"users": users}) | |
| 154 | + w.WriteHeader(http.StatusNoContent) | |
| 117 | 155 | } |
| 118 | 156 | |
| 119 | 157 | // handleChannelStream serves an SSE stream of IRC messages for a channel. |
| 120 | 158 | // Auth is via ?token= query param because EventSource doesn't support custom headers. |
| 121 | 159 | func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) { |
| 122 | 160 | 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)) { | |
| 124 | 163 | writeError(w, http.StatusUnauthorized, "invalid or missing token") |
| 125 | 164 | return |
| 126 | 165 | } |
| 127 | 166 | |
| 128 | 167 | channel := "#" + r.PathValue("channel") |
| 129 | 168 |
| --- 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 |
+7
-4
| --- internal/api/chat_test.go | ||
| +++ internal/api/chat_test.go | ||
| @@ -8,10 +8,11 @@ | ||
| 8 | 8 | "log/slog" |
| 9 | 9 | "net/http" |
| 10 | 10 | "net/http/httptest" |
| 11 | 11 | "testing" |
| 12 | 12 | |
| 13 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 13 | 14 | "github.com/conflicthq/scuttlebot/internal/bots/bridge" |
| 14 | 15 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 15 | 16 | ) |
| 16 | 17 | |
| 17 | 18 | type stubChatBridge struct { |
| @@ -30,12 +31,14 @@ | ||
| 30 | 31 | } |
| 31 | 32 | func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil } |
| 32 | 33 | func (b *stubChatBridge) SendWithMeta(_ context.Context, _, _, _ string, _ *bridge.Meta) error { |
| 33 | 34 | return nil |
| 34 | 35 | } |
| 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 "" } | |
| 37 | 40 | func (b *stubChatBridge) TouchUser(channel, nick string) { |
| 38 | 41 | b.touched = append(b.touched, struct{ channel, nick string }{channel: channel, nick: nick}) |
| 39 | 42 | } |
| 40 | 43 | |
| 41 | 44 | func TestHandleChannelPresence(t *testing.T) { |
| @@ -42,11 +45,11 @@ | ||
| 42 | 45 | t.Helper() |
| 43 | 46 | |
| 44 | 47 | bridgeStub := &stubChatBridge{} |
| 45 | 48 | reg := registry.New(nil, []byte("test-signing-key")) |
| 46 | 49 | 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()) | |
| 48 | 51 | defer srv.Close() |
| 49 | 52 | |
| 50 | 53 | body, _ := json.Marshal(map[string]string{"nick": "codex-test"}) |
| 51 | 54 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body)) |
| 52 | 55 | if err != nil { |
| @@ -75,11 +78,11 @@ | ||
| 75 | 78 | t.Helper() |
| 76 | 79 | |
| 77 | 80 | bridgeStub := &stubChatBridge{} |
| 78 | 81 | reg := registry.New(nil, []byte("test-signing-key")) |
| 79 | 82 | 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()) | |
| 81 | 84 | defer srv.Close() |
| 82 | 85 | |
| 83 | 86 | body, _ := json.Marshal(map[string]string{}) |
| 84 | 87 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body)) |
| 85 | 88 | if err != nil { |
| 86 | 89 |
| --- 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 @@ | ||
| 9 | 9 | "net/http/httptest" |
| 10 | 10 | "path/filepath" |
| 11 | 11 | "testing" |
| 12 | 12 | "time" |
| 13 | 13 | |
| 14 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 14 | 15 | "github.com/conflicthq/scuttlebot/internal/config" |
| 15 | 16 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 16 | 17 | ) |
| 17 | 18 | |
| 18 | 19 | func newCfgTestServer(t *testing.T) (*httptest.Server, *ConfigStore) { |
| @@ -25,11 +26,11 @@ | ||
| 25 | 26 | cfg.Ergo.DataDir = dir |
| 26 | 27 | |
| 27 | 28 | store := NewConfigStore(path, cfg) |
| 28 | 29 | reg := registry.New(nil, []byte("key")) |
| 29 | 30 | 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()) | |
| 31 | 32 | t.Cleanup(srv.Close) |
| 32 | 33 | return srv, store |
| 33 | 34 | } |
| 34 | 35 | |
| 35 | 36 | func TestHandleGetConfig(t *testing.T) { |
| 36 | 37 |
| --- 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 |
+7
-5
| --- internal/api/login.go | ||
| +++ internal/api/login.go | ||
| @@ -93,15 +93,17 @@ | ||
| 93 | 93 | if !s.admins.Authenticate(req.Username, req.Password) { |
| 94 | 94 | writeError(w, http.StatusUnauthorized, "invalid credentials") |
| 95 | 95 | return |
| 96 | 96 | } |
| 97 | 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 | |
| 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 | |
| 103 | 105 | } |
| 104 | 106 | |
| 105 | 107 | writeJSON(w, http.StatusOK, map[string]string{ |
| 106 | 108 | "token": token, |
| 107 | 109 | "username": req.Username, |
| 108 | 110 |
| --- 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 |
+2
-2
| --- internal/api/login_test.go | ||
| +++ internal/api/login_test.go | ||
| @@ -28,18 +28,18 @@ | ||
| 28 | 28 | admins := newAdminStore(t) |
| 29 | 29 | if err := admins.Add("admin", "hunter2"); err != nil { |
| 30 | 30 | t.Fatalf("Add admin: %v", err) |
| 31 | 31 | } |
| 32 | 32 | 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) | |
| 34 | 34 | return httptest.NewServer(srv.Handler()), admins |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | 37 | func TestLoginNoAdmins(t *testing.T) { |
| 38 | 38 | // When admins is nil, login returns 404. |
| 39 | 39 | 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) | |
| 41 | 41 | ts := httptest.NewServer(srv.Handler()) |
| 42 | 42 | defer ts.Close() |
| 43 | 43 | |
| 44 | 44 | resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil) |
| 45 | 45 | defer resp.Body.Close() |
| 46 | 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, []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 |
+38
-2
| --- internal/api/middleware.go | ||
| +++ internal/api/middleware.go | ||
| @@ -1,26 +1,62 @@ | ||
| 1 | 1 | package api |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "context" | |
| 4 | 5 | "net/http" |
| 5 | 6 | "strings" |
| 7 | + | |
| 8 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 6 | 9 | ) |
| 7 | 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. | |
| 8 | 23 | func (s *Server) authMiddleware(next http.Handler) http.Handler { |
| 9 | 24 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 10 | 25 | token := bearerToken(r) |
| 11 | 26 | if token == "" { |
| 12 | 27 | writeError(w, http.StatusUnauthorized, "missing authorization header") |
| 13 | 28 | return |
| 14 | 29 | } |
| 15 | - if _, ok := s.tokens[token]; !ok { | |
| 30 | + key := s.apiKeys.Lookup(token) | |
| 31 | + if key == nil { | |
| 16 | 32 | writeError(w, http.StatusUnauthorized, "invalid token") |
| 17 | 33 | return |
| 18 | 34 | } |
| 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)) | |
| 20 | 40 | }) |
| 21 | 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 | +} | |
| 22 | 58 | |
| 23 | 59 | func bearerToken(r *http.Request) string { |
| 24 | 60 | auth := r.Header.Get("Authorization") |
| 25 | 61 | token, found := strings.CutPrefix(auth, "Bearer ") |
| 26 | 62 | if !found { |
| 27 | 63 |
| --- 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 |
+71
-5
| --- internal/api/policies.go | ||
| +++ internal/api/policies.go | ||
| @@ -42,16 +42,24 @@ | ||
| 42 | 42 | Rotation string `json:"rotation"` // "none" | "daily" | "weekly" | "size" |
| 43 | 43 | MaxSizeMB int `json:"max_size_mb"` // size rotation threshold (MiB); 0 = unlimited |
| 44 | 44 | PerChannel bool `json:"per_channel"` // separate file per channel |
| 45 | 45 | MaxAgeDays int `json:"max_age_days"` // prune rotated files older than N days; 0 = keep all |
| 46 | 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 | +} | |
| 47 | 53 | |
| 48 | 54 | // BridgePolicy configures bridge-specific UI/relay behavior. |
| 49 | 55 | type BridgePolicy struct { |
| 50 | 56 | // WebUserTTLMinutes controls how long HTTP bridge sender nicks remain |
| 51 | 57 | // visible in the channel user list after their last post. |
| 52 | 58 | WebUserTTLMinutes int `json:"web_user_ttl_minutes"` |
| 59 | + // ChannelDisplay holds per-channel rendering config. | |
| 60 | + ChannelDisplay map[string]ChannelDisplayConfig `json:"channel_display,omitempty"` | |
| 53 | 61 | } |
| 54 | 62 | |
| 55 | 63 | // PolicyLLMBackend stores an LLM backend configuration in the policy store. |
| 56 | 64 | // This allows backends to be added and edited from the web UI rather than |
| 57 | 65 | // requiring a change to scuttlebot.yaml. |
| @@ -68,18 +76,32 @@ | ||
| 68 | 76 | AWSSecretKey string `json:"aws_secret_key,omitempty"` |
| 69 | 77 | Allow []string `json:"allow,omitempty"` |
| 70 | 78 | Block []string `json:"block,omitempty"` |
| 71 | 79 | Default bool `json:"default,omitempty"` |
| 72 | 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 | +} | |
| 73 | 93 | |
| 74 | 94 | // Policies is the full mutable settings blob, persisted to policies.json. |
| 75 | 95 | 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 | |
| 81 | 103 | } |
| 82 | 104 | |
| 83 | 105 | // defaultBehaviors lists every built-in bot with conservative defaults (disabled). |
| 84 | 106 | var defaultBehaviors = []BehaviorConfig{ |
| 85 | 107 | { |
| @@ -151,10 +173,42 @@ | ||
| 151 | 173 | Description: "Acts on sentinel incident reports — issues warnings, mutes, or kicks based on severity. Operators can also issue direct commands via DM.", |
| 152 | 174 | Nick: "steward", |
| 153 | 175 | JoinAllChannels: true, |
| 154 | 176 | }, |
| 155 | 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 | +} | |
| 156 | 210 | |
| 157 | 211 | // PolicyStore persists Policies to a JSON file or database. |
| 158 | 212 | type PolicyStore struct { |
| 159 | 213 | mu sync.RWMutex |
| 160 | 214 | path string |
| @@ -227,10 +281,12 @@ | ||
| 227 | 281 | } |
| 228 | 282 | ps.data.AgentPolicy = p.AgentPolicy |
| 229 | 283 | ps.data.Bridge = p.Bridge |
| 230 | 284 | ps.data.Logging = p.Logging |
| 231 | 285 | ps.data.LLMBackends = p.LLMBackends |
| 286 | + ps.data.ROETemplates = p.ROETemplates | |
| 287 | + ps.data.OnJoinMessages = p.OnJoinMessages | |
| 232 | 288 | return nil |
| 233 | 289 | } |
| 234 | 290 | |
| 235 | 291 | func (ps *PolicyStore) save() error { |
| 236 | 292 | raw, err := json.MarshalIndent(ps.data, "", " ") |
| @@ -337,10 +393,20 @@ | ||
| 337 | 393 | |
| 338 | 394 | // Merge LLM backends if provided. |
| 339 | 395 | if patch.LLMBackends != nil { |
| 340 | 396 | ps.data.LLMBackends = patch.LLMBackends |
| 341 | 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 | + } | |
| 342 | 408 | |
| 343 | 409 | ps.normalize(&ps.data) |
| 344 | 410 | if err := ps.save(); err != nil { |
| 345 | 411 | return err |
| 346 | 412 | } |
| 347 | 413 |
| --- 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 |
+78
-60
| --- internal/api/server.go | ||
| +++ internal/api/server.go | ||
| @@ -7,18 +7,19 @@ | ||
| 7 | 7 | |
| 8 | 8 | import ( |
| 9 | 9 | "log/slog" |
| 10 | 10 | "net/http" |
| 11 | 11 | |
| 12 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 12 | 13 | "github.com/conflicthq/scuttlebot/internal/config" |
| 13 | 14 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 14 | 15 | ) |
| 15 | 16 | |
| 16 | 17 | // Server is the scuttlebot HTTP API server. |
| 17 | 18 | type Server struct { |
| 18 | 19 | registry *registry.Registry |
| 19 | - tokens map[string]struct{} | |
| 20 | + apiKeys *auth.APIKeyStore | |
| 20 | 21 | log *slog.Logger |
| 21 | 22 | bridge chatBridge // nil if bridge is disabled |
| 22 | 23 | policies *PolicyStore // nil if not configured |
| 23 | 24 | admins adminStore // nil if not configured |
| 24 | 25 | llmCfg *config.LLMConfig // nil if no LLM backends configured |
| @@ -31,18 +32,14 @@ | ||
| 31 | 32 | // New creates a new API Server. Pass nil for b to disable the chat bridge. |
| 32 | 33 | // Pass nil for admins to disable admin authentication endpoints. |
| 33 | 34 | // Pass nil for llmCfg to disable AI/LLM management endpoints. |
| 34 | 35 | // Pass nil for topo to disable topology provisioning endpoints. |
| 35 | 36 | // 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 { | |
| 41 | 38 | return &Server{ |
| 42 | 39 | registry: reg, |
| 43 | - tokens: tokenSet, | |
| 40 | + apiKeys: apiKeys, | |
| 44 | 41 | log: log, |
| 45 | 42 | bridge: b, |
| 46 | 43 | policies: ps, |
| 47 | 44 | admins: admins, |
| 48 | 45 | llmCfg: llmCfg, |
| @@ -53,65 +50,86 @@ | ||
| 53 | 50 | } |
| 54 | 51 | } |
| 55 | 52 | |
| 56 | 53 | // Handler returns the HTTP handler with all routes registered. |
| 57 | 54 | // /v1/ routes require a valid Bearer token. /ui/ is served unauthenticated. |
| 55 | +// Scoped routes additionally check the API key's scopes. | |
| 58 | 56 | func (s *Server) Handler() http.Handler { |
| 59 | 57 | 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)) | |
| 113 | 131 | |
| 114 | 132 | outer := http.NewServeMux() |
| 115 | 133 | outer.HandleFunc("POST /login", s.handleLogin) |
| 116 | 134 | outer.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { |
| 117 | 135 | http.Redirect(w, r, "/ui/", http.StatusFound) |
| 118 | 136 |
| --- 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 |
+4
-2
| --- internal/api/settings.go | ||
| +++ internal/api/settings.go | ||
| @@ -5,12 +5,13 @@ | ||
| 5 | 5 | |
| 6 | 6 | "github.com/conflicthq/scuttlebot/internal/config" |
| 7 | 7 | ) |
| 8 | 8 | |
| 9 | 9 | 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"` | |
| 12 | 13 | } |
| 13 | 14 | |
| 14 | 15 | type tlsInfo struct { |
| 15 | 16 | Enabled bool `json:"enabled"` |
| 16 | 17 | Domain string `json:"domain,omitempty"` |
| @@ -33,10 +34,11 @@ | ||
| 33 | 34 | cfg := s.cfgStore.Get() |
| 34 | 35 | resp.Policies.AgentPolicy = toAPIAgentPolicy(cfg.AgentPolicy) |
| 35 | 36 | resp.Policies.Logging = toAPILogging(cfg.Logging) |
| 36 | 37 | resp.Policies.Bridge.WebUserTTLMinutes = cfg.Bridge.WebUserTTLMinutes |
| 37 | 38 | } |
| 39 | + resp.BotCommands = botCommands | |
| 38 | 40 | writeJSON(w, http.StatusOK, resp) |
| 39 | 41 | } |
| 40 | 42 | |
| 41 | 43 | func toAPIAgentPolicy(c config.AgentPolicyConfig) AgentPolicy { |
| 42 | 44 | return AgentPolicy{ |
| 43 | 45 |
| --- 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 |
+377
-21
| --- internal/api/ui/index.html | ||
| +++ internal/api/ui/index.html | ||
| @@ -485,10 +485,40 @@ | ||
| 485 | 485 | <button class="sm primary" onclick="quickJoin()">join</button> |
| 486 | 486 | </div> |
| 487 | 487 | </div> |
| 488 | 488 | <div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div> |
| 489 | 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> | |
| 490 | 520 | </div> |
| 491 | 521 | </div> |
| 492 | 522 | |
| 493 | 523 | <!-- CHAT --> |
| 494 | 524 | <div class="tab-pane" id="pane-chat"> |
| @@ -504,11 +534,11 @@ | ||
| 504 | 534 | <div class="chan-list" id="chan-list"></div> |
| 505 | 535 | </div> |
| 506 | 536 | <div class="sidebar-resize" id="resize-left" title="drag to resize"></div> |
| 507 | 537 | <div class="chat-main"> |
| 508 | 538 | <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> | |
| 510 | 540 | <div class="spacer"></div> |
| 511 | 541 | <span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span> |
| 512 | 542 | <select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()"> |
| 513 | 543 | <option value="">— pick a user —</option> |
| 514 | 544 | </select> |
| @@ -580,10 +610,40 @@ | ||
| 580 | 610 | <button type="submit" class="primary sm" style="margin-bottom:1px">add admin</button> |
| 581 | 611 | </form> |
| 582 | 612 | <div id="add-admin-result" style="margin-top:10px"></div> |
| 583 | 613 | </div> |
| 584 | 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> | |
| 585 | 645 | |
| 586 | 646 | <!-- tls --> |
| 587 | 647 | <div class="card" id="card-tls"> |
| 588 | 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> |
| 589 | 649 | <div class="card-body"> |
| @@ -605,10 +665,28 @@ | ||
| 605 | 665 | </div> |
| 606 | 666 | <div class="card-body" style="padding:0"> |
| 607 | 667 | <div id="behaviors-list"></div> |
| 608 | 668 | </div> |
| 609 | 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> | |
| 610 | 688 | |
| 611 | 689 | <!-- agent policy --> |
| 612 | 690 | <div class="card" id="card-agentpolicy"> |
| 613 | 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> |
| 614 | 692 | <div class="card-body"> |
| @@ -798,10 +876,31 @@ | ||
| 798 | 876 | </div> |
| 799 | 877 | <div class="setting-row"> |
| 800 | 878 | <div class="setting-label">IRC address</div> |
| 801 | 879 | <div class="setting-desc">Address Ergo listens on for IRC connections. Requires restart.</div> |
| 802 | 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> | |
| 803 | 902 | </div> |
| 804 | 903 | <div class="setting-row"> |
| 805 | 904 | <div class="setting-label">external mode</div> |
| 806 | 905 | <div class="setting-desc">Disable subprocess management — scuttlebot expects Ergo to already be running. Requires restart.</div> |
| 807 | 906 | <label style="display:flex;align-items:center;gap:6px;cursor:pointer"> |
| @@ -1726,10 +1825,19 @@ | ||
| 1726 | 1825 | allChannels = (data.channels || []).sort(); |
| 1727 | 1826 | renderChanList(); |
| 1728 | 1827 | } catch(e) { |
| 1729 | 1828 | document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>'; |
| 1730 | 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) {} | |
| 1731 | 1839 | } |
| 1732 | 1840 | |
| 1733 | 1841 | function renderChanList() { |
| 1734 | 1842 | const q = (document.getElementById('chan-search').value||'').toLowerCase(); |
| 1735 | 1843 | const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q)); |
| @@ -1764,10 +1872,138 @@ | ||
| 1764 | 1872 | await loadChanTab(); |
| 1765 | 1873 | renderChanSidebar((await api('GET','/v1/channels')).channels||[]); |
| 1766 | 1874 | } catch(e) { alert('Join failed: '+e.message); } |
| 1767 | 1875 | } |
| 1768 | 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 | +} | |
| 1769 | 2005 | |
| 1770 | 2006 | // --- chat --- |
| 1771 | 2007 | let chatChannel = null, chatSSE = null; |
| 1772 | 2008 | |
| 1773 | 2009 | async function loadChannels() { |
| @@ -1838,11 +2074,11 @@ | ||
| 1838 | 2074 | async function loadNicklist(ch) { |
| 1839 | 2075 | if (!ch) return; |
| 1840 | 2076 | try { |
| 1841 | 2077 | const slug = ch.replace(/^#/,''); |
| 1842 | 2078 | const data = await api('GET', `/v1/channels/${slug}/users`); |
| 1843 | - renderNicklist(data.users || []); | |
| 2079 | + renderNicklist(data.users || [], data.channel_modes || ''); | |
| 1844 | 2080 | } catch(e) {} |
| 1845 | 2081 | } |
| 1846 | 2082 | const SYSTEM_BOTS = new Set(['bridge','oracle','sentinel','steward','scribe','warden','snitch','herald','scroll','systembot','auditbot']); |
| 1847 | 2083 | const AGENT_PREFIXES = ['claude-','codex-','gemini-','openclaw-']; |
| 1848 | 2084 | |
| @@ -1861,24 +2097,35 @@ | ||
| 1861 | 2097 | if (tier === 0) return '@'; |
| 1862 | 2098 | if (tier === 1) return '+'; |
| 1863 | 2099 | return ''; |
| 1864 | 2100 | } |
| 1865 | 2101 | |
| 1866 | -function renderNicklist(users) { | |
| 2102 | +function renderNicklist(users, channelModes) { | |
| 1867 | 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); | |
| 1868 | 2106 | // 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); | |
| 1871 | 2109 | if (ta !== tb) return ta - tb; |
| 1872 | - return a.localeCompare(b); | |
| 2110 | + return a.nick.localeCompare(b.nick); | |
| 1873 | 2111 | }); |
| 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>`; | |
| 1879 | 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}` : ''; | |
| 1880 | 2127 | } |
| 1881 | 2128 | // Nick colors — deterministic hash over a palette |
| 1882 | 2129 | const NICK_PALETTE = ['#58a6ff','#3fb950','#ffa657','#d2a8ff','#56d364','#79c0ff','#ff7b72','#a5d6ff','#f0883e','#39d353']; |
| 1883 | 2130 | function nickColor(nick) { |
| 1884 | 2131 | let h = 0; |
| @@ -1892,14 +2139,16 @@ | ||
| 1892 | 2139 | let _chatUnread = 0; |
| 1893 | 2140 | |
| 1894 | 2141 | function appendMsg(msg, isHistory) { |
| 1895 | 2142 | const area = document.getElementById('chat-msgs'); |
| 1896 | 2143 | |
| 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". | |
| 1898 | 2145 | let displayNick = msg.nick; |
| 1899 | 2146 | 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') { | |
| 1901 | 2150 | const m = msg.text.match(/^\[([^\]]+)\] ([\s\S]*)$/); |
| 1902 | 2151 | if (m) { displayNick = m[1]; displayText = m[2]; } |
| 1903 | 2152 | } |
| 1904 | 2153 | |
| 1905 | 2154 | const atMs = new Date(msg.at).getTime(); |
| @@ -2542,10 +2791,73 @@ | ||
| 2542 | 2791 | try { |
| 2543 | 2792 | await api('PUT', `/v1/admins/${encodeURIComponent(username)}/password`, { password: pw }); |
| 2544 | 2793 | alert('Password updated.'); |
| 2545 | 2794 | } catch(e) { alert('Failed: ' + e.message); } |
| 2546 | 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 | +} | |
| 2547 | 2859 | |
| 2548 | 2860 | // --- AI / LLM tab --- |
| 2549 | 2861 | async function loadAI() { |
| 2550 | 2862 | await Promise.all([loadAIBackends(), loadAIKnown()]); |
| 2551 | 2863 | } |
| @@ -2899,10 +3211,41 @@ | ||
| 2899 | 3211 | if (body) body.style.display = ''; |
| 2900 | 3212 | } |
| 2901 | 3213 | |
| 2902 | 3214 | // --- settings / policies --- |
| 2903 | 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 | +} | |
| 2904 | 3247 | let _llmBackendNames = []; // cached backend names for oracle dropdown |
| 2905 | 3248 | |
| 2906 | 3249 | async function loadSettings() { |
| 2907 | 3250 | try { |
| 2908 | 3251 | const [s, backends] = await Promise.all([ |
| @@ -2910,15 +3253,18 @@ | ||
| 2910 | 3253 | api('GET', '/v1/llm/backends').catch(() => []), |
| 2911 | 3254 | ]); |
| 2912 | 3255 | _llmBackendNames = (backends || []).map(b => b.name); |
| 2913 | 3256 | renderTLSStatus(s.tls); |
| 2914 | 3257 | currentPolicies = s.policies; |
| 3258 | + _botCommands = s.bot_commands || {}; | |
| 2915 | 3259 | renderBehaviors(s.policies.behaviors || []); |
| 3260 | + renderOnJoinMessages(s.policies.on_join_messages || {}); | |
| 2916 | 3261 | renderAgentPolicy(s.policies.agent_policy || {}); |
| 2917 | 3262 | renderBridgePolicy(s.policies.bridge || {}); |
| 2918 | 3263 | renderLoggingPolicy(s.policies.logging || {}); |
| 2919 | 3264 | loadAdmins(); |
| 3265 | + loadAPIKeys(); | |
| 2920 | 3266 | loadConfigCards(); |
| 2921 | 3267 | } catch(e) { |
| 2922 | 3268 | document.getElementById('tls-badge').textContent = 'error'; |
| 2923 | 3269 | } |
| 2924 | 3270 | } |
| @@ -2973,10 +3319,14 @@ | ||
| 2973 | 3319 | ` : ''} |
| 2974 | 3320 | <span class="tag type-observer" style="font-size:11px;min-width:64px;text-align:center">${esc(b.nick)}</span> |
| 2975 | 3321 | </div> |
| 2976 | 3322 | </div> |
| 2977 | 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)} ${esc(c.usage)}">${esc(c.command)}</code>`).join('')} | |
| 3327 | + </div>` : ''} | |
| 2978 | 3328 | </div> |
| 2979 | 3329 | `).join(''); |
| 2980 | 3330 | } |
| 2981 | 3331 | |
| 2982 | 3332 | function onBehaviorToggle(id, enabled) { |
| @@ -3222,14 +3572,17 @@ | ||
| 3222 | 3572 | // general |
| 3223 | 3573 | document.getElementById('general-api-addr').value = cfg.api_addr || ''; |
| 3224 | 3574 | document.getElementById('general-mcp-addr').value = cfg.mcp_addr || ''; |
| 3225 | 3575 | // ergo |
| 3226 | 3576 | 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; | |
| 3231 | 3584 | // tls |
| 3232 | 3585 | const t = cfg.tls || {}; |
| 3233 | 3586 | document.getElementById('tls-domain').value = t.domain || ''; |
| 3234 | 3587 | document.getElementById('tls-email').value = t.email || ''; |
| 3235 | 3588 | document.getElementById('tls-allow-insecure').checked = !!t.allow_insecure; |
| @@ -3302,14 +3655,17 @@ | ||
| 3302 | 3655 | } |
| 3303 | 3656 | |
| 3304 | 3657 | function saveErgoConfig() { |
| 3305 | 3658 | saveConfigPatch({ |
| 3306 | 3659 | 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, | |
| 3311 | 3667 | } |
| 3312 | 3668 | }, 'ergo-save-result'); |
| 3313 | 3669 | } |
| 3314 | 3670 | |
| 3315 | 3671 | function saveTLSConfig() { |
| 3316 | 3672 | |
| 3317 | 3673 | 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)} ${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 |
+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 | +} |
| --- 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 @@ | ||
| 120 | 120 | PingTimeout: 30 * time.Second, |
| 121 | 121 | SSL: false, |
| 122 | 122 | }) |
| 123 | 123 | |
| 124 | 124 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 125 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 125 | 126 | for _, ch := range b.channels { |
| 126 | 127 | cl.Cmd.Join(ch) |
| 127 | 128 | } |
| 128 | 129 | b.log.Info("auditbot connected", "channels", b.channels, "audit_types", b.auditTypesList()) |
| 129 | 130 | }) |
| 130 | 131 |
| --- 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 |
+129
-6
| --- internal/bots/bridge/bridge.go | ||
| +++ internal/bots/bridge/bridge.go | ||
| @@ -34,10 +34,11 @@ | ||
| 34 | 34 | type Message struct { |
| 35 | 35 | At time.Time `json:"at"` |
| 36 | 36 | Channel string `json:"channel"` |
| 37 | 37 | Nick string `json:"nick"` |
| 38 | 38 | Text string `json:"text"` |
| 39 | + MsgID string `json:"msgid,omitempty"` | |
| 39 | 40 | Meta *Meta `json:"meta,omitempty"` |
| 40 | 41 | } |
| 41 | 42 | |
| 42 | 43 | // ringBuf is a fixed-capacity circular buffer of Messages. |
| 43 | 44 | type ringBuf struct { |
| @@ -103,10 +104,13 @@ | ||
| 103 | 104 | |
| 104 | 105 | msgTotal atomic.Int64 |
| 105 | 106 | |
| 106 | 107 | joinCh chan string |
| 107 | 108 | client *girc.Client |
| 109 | + | |
| 110 | + // RELAYMSG support detected from ISUPPORT. | |
| 111 | + relaySep string // separator (e.g. "/"), empty if unsupported | |
| 108 | 112 | } |
| 109 | 113 | |
| 110 | 114 | // New creates a bridge Bot. |
| 111 | 115 | func New(ircAddr, nick, password string, channels []string, bufSize int, webUserTTL time.Duration, log *slog.Logger) *Bot { |
| 112 | 116 | if nick == "" { |
| @@ -172,10 +176,23 @@ | ||
| 172 | 176 | PingTimeout: 30 * time.Second, |
| 173 | 177 | SSL: false, |
| 174 | 178 | }) |
| 175 | 179 | |
| 176 | 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 | + } | |
| 177 | 194 | if b.log != nil { |
| 178 | 195 | b.log.Info("bridge connected") |
| 179 | 196 | } |
| 180 | 197 | for _, ch := range b.initChannels { |
| 181 | 198 | cl.Cmd.Join(ch) |
| @@ -219,16 +236,26 @@ | ||
| 219 | 236 | nick := e.Source.Name |
| 220 | 237 | if acct, ok := e.Tags.Get("account"); ok && acct != "" { |
| 221 | 238 | nick = acct |
| 222 | 239 | } |
| 223 | 240 | |
| 224 | - b.dispatch(Message{ | |
| 241 | + var msgID string | |
| 242 | + if id, ok := e.Tags.Get("msgid"); ok { | |
| 243 | + msgID = id | |
| 244 | + } | |
| 245 | + msg := Message{ | |
| 225 | 246 | At: e.Timestamp, |
| 226 | 247 | Channel: channel, |
| 227 | 248 | Nick: nick, |
| 228 | 249 | Text: e.Last(), |
| 229 | - }) | |
| 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 | + } | |
| 256 | + b.dispatch(msg) | |
| 230 | 257 | }) |
| 231 | 258 | |
| 232 | 259 | b.client = c |
| 233 | 260 | |
| 234 | 261 | errCh := make(chan error, 1) |
| @@ -338,19 +365,39 @@ | ||
| 338 | 365 | } |
| 339 | 366 | |
| 340 | 367 | // SendWithMeta sends a message to channel with optional structured metadata. |
| 341 | 368 | // IRC receives only the plain text; SSE subscribers receive the full message |
| 342 | 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. | |
| 343 | 376 | func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error { |
| 344 | 377 | if b.client == nil { |
| 345 | 378 | return fmt.Errorf("bridge: not connected") |
| 346 | 379 | } |
| 347 | - ircText := text | |
| 348 | - if senderNick != "" { | |
| 349 | - ircText = "[" + senderNick + "] " + text | |
| 380 | + // Build optional IRCv3 tag prefix for meta-type. | |
| 381 | + tagPrefix := "" | |
| 382 | + if meta != nil && meta.Type != "" { | |
| 383 | + tagPrefix = "@+scuttlebot/meta-type=" + meta.Type + " " | |
| 350 | 384 | } |
| 351 | - b.client.Cmd.Message(channel, ircText) | |
| 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 | + } | |
| 352 | 399 | |
| 353 | 400 | if senderNick != "" { |
| 354 | 401 | b.TouchUser(channel, senderNick) |
| 355 | 402 | } |
| 356 | 403 | |
| @@ -421,10 +468,86 @@ | ||
| 421 | 468 | } |
| 422 | 469 | b.mu.Unlock() |
| 423 | 470 | |
| 424 | 471 | return nicks |
| 425 | 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 | +} | |
| 426 | 549 | |
| 427 | 550 | // Stats returns a snapshot of bridge activity. |
| 428 | 551 | func (b *Bot) Stats() Stats { |
| 429 | 552 | b.mu.RLock() |
| 430 | 553 | channels := len(b.joined) |
| 431 | 554 |
| --- 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,16 +236,26 @@ | |
| 219 | nick := e.Source.Name |
| 220 | if acct, ok := e.Tags.Get("account"); ok && acct != "" { |
| 221 | nick = acct |
| 222 | } |
| 223 | |
| 224 | b.dispatch(Message{ |
| 225 | At: e.Timestamp, |
| 226 | Channel: channel, |
| 227 | Nick: nick, |
| 228 | Text: e.Last(), |
| 229 | }) |
| 230 | }) |
| 231 | |
| 232 | b.client = c |
| 233 | |
| 234 | errCh := make(chan error, 1) |
| @@ -338,19 +365,39 @@ | |
| 338 | } |
| 339 | |
| 340 | // SendWithMeta sends a message to channel with optional structured metadata. |
| 341 | // IRC receives only the plain text; SSE subscribers receive the full message |
| 342 | // including meta for rich rendering in the web UI. |
| 343 | func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error { |
| 344 | if b.client == nil { |
| 345 | return fmt.Errorf("bridge: not connected") |
| 346 | } |
| 347 | ircText := text |
| 348 | if senderNick != "" { |
| 349 | ircText = "[" + senderNick + "] " + text |
| 350 | } |
| 351 | b.client.Cmd.Message(channel, ircText) |
| 352 | |
| 353 | if senderNick != "" { |
| 354 | b.TouchUser(channel, senderNick) |
| 355 | } |
| 356 | |
| @@ -421,10 +468,86 @@ | |
| 421 | } |
| 422 | b.mu.Unlock() |
| 423 | |
| 424 | return nicks |
| 425 | } |
| 426 | |
| 427 | // Stats returns a snapshot of bridge activity. |
| 428 | func (b *Bot) Stats() Stats { |
| 429 | b.mu.RLock() |
| 430 | channels := len(b.joined) |
| 431 |
| --- 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,16 +236,26 @@ | |
| 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 | } |
| 256 | b.dispatch(msg) |
| 257 | }) |
| 258 | |
| 259 | b.client = c |
| 260 | |
| 261 | errCh := make(chan error, 1) |
| @@ -338,19 +365,39 @@ | |
| 365 | } |
| 366 | |
| 367 | // SendWithMeta sends a message to channel with optional structured metadata. |
| 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 | } |
| 403 | |
| @@ -421,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 @@ | ||
| 151 | 151 | PingTimeout: 30 * time.Second, |
| 152 | 152 | SSL: false, |
| 153 | 153 | }) |
| 154 | 154 | |
| 155 | 155 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 156 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 156 | 157 | for _, ch := range b.channels { |
| 157 | 158 | cl.Cmd.Join(ch) |
| 158 | 159 | } |
| 159 | 160 | if b.log != nil { |
| 160 | 161 | b.log.Info("herald connected", "channels", b.channels) |
| 161 | 162 |
| --- 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 |
+52
-13
| --- internal/bots/oracle/oracle.go | ||
| +++ internal/bots/oracle/oracle.go | ||
| @@ -20,10 +20,13 @@ | ||
| 20 | 20 | "strings" |
| 21 | 21 | "sync" |
| 22 | 22 | "time" |
| 23 | 23 | |
| 24 | 24 | "github.com/lrstanley/girc" |
| 25 | + | |
| 26 | + "github.com/conflicthq/scuttlebot/pkg/chathistory" | |
| 27 | + "github.com/conflicthq/scuttlebot/pkg/toon" | |
| 25 | 28 | ) |
| 26 | 29 | |
| 27 | 30 | const ( |
| 28 | 31 | botNick = "oracle" |
| 29 | 32 | defaultLimit = 50 |
| @@ -124,10 +127,11 @@ | ||
| 124 | 127 | llm LLMProvider |
| 125 | 128 | log *slog.Logger |
| 126 | 129 | mu sync.Mutex |
| 127 | 130 | lastReq map[string]time.Time // nick → last request time |
| 128 | 131 | client *girc.Client |
| 132 | + chFetch *chathistory.Fetcher // CHATHISTORY fetcher, nil if unsupported | |
| 129 | 133 | } |
| 130 | 134 | |
| 131 | 135 | // New creates an oracle bot. |
| 132 | 136 | func New(ircAddr, password string, channels []string, history HistoryFetcher, llm LLMProvider, log *slog.Logger) *Bot { |
| 133 | 137 | return &Bot{ |
| @@ -159,18 +163,26 @@ | ||
| 159 | 163 | Name: "scuttlebot oracle", |
| 160 | 164 | SASL: &girc.SASLPlain{User: botNick, Pass: b.password}, |
| 161 | 165 | PingDelay: 30 * time.Second, |
| 162 | 166 | PingTimeout: 30 * time.Second, |
| 163 | 167 | SSL: false, |
| 168 | + SupportedCaps: map[string][]string{ | |
| 169 | + "draft/chathistory": nil, | |
| 170 | + "chathistory": nil, | |
| 171 | + }, | |
| 164 | 172 | }) |
| 173 | + | |
| 174 | + b.chFetch = chathistory.New(c) | |
| 165 | 175 | |
| 166 | 176 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 177 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 167 | 178 | for _, ch := range b.channels { |
| 168 | 179 | cl.Cmd.Join(ch) |
| 169 | 180 | } |
| 181 | + hasCH := cl.HasCapability("chathistory") || cl.HasCapability("draft/chathistory") | |
| 170 | 182 | if b.log != nil { |
| 171 | - b.log.Info("oracle connected", "channels", b.channels) | |
| 183 | + b.log.Info("oracle connected", "channels", b.channels, "chathistory", hasCH) | |
| 172 | 184 | } |
| 173 | 185 | }) |
| 174 | 186 | |
| 175 | 187 | c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) { |
| 176 | 188 | if ch := e.Last(); strings.HasPrefix(ch, "#") { |
| @@ -234,12 +246,12 @@ | ||
| 234 | 246 | return |
| 235 | 247 | } |
| 236 | 248 | b.lastReq[nick] = time.Now() |
| 237 | 249 | b.mu.Unlock() |
| 238 | 250 | |
| 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) | |
| 241 | 253 | if err != nil { |
| 242 | 254 | cl.Cmd.Notice(nick, fmt.Sprintf("oracle: failed to fetch history for %s: %v", req.Channel, err)) |
| 243 | 255 | return |
| 244 | 256 | } |
| 245 | 257 | if len(entries) == 0 { |
| @@ -263,24 +275,51 @@ | ||
| 263 | 275 | if line != "" { |
| 264 | 276 | cl.Cmd.Notice(nick, line) |
| 265 | 277 | } |
| 266 | 278 | } |
| 267 | 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 | +} | |
| 268 | 309 | |
| 269 | 310 | 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, | |
| 279 | 318 | } |
| 280 | 319 | } |
| 281 | - return sb.String() | |
| 320 | + return toon.FormatPrompt(channel, toonEntries) | |
| 282 | 321 | } |
| 283 | 322 | |
| 284 | 323 | func formatResponse(channel string, count int, summary string, format Format) string { |
| 285 | 324 | switch format { |
| 286 | 325 | case FormatJSON: |
| 287 | 326 |
| --- 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 @@ | ||
| 64 | 64 | PingTimeout: 30 * time.Second, |
| 65 | 65 | SSL: false, |
| 66 | 66 | }) |
| 67 | 67 | |
| 68 | 68 | c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) { |
| 69 | + client.Cmd.Mode(client.GetNick(), "+B") | |
| 69 | 70 | for _, ch := range b.channels { |
| 70 | 71 | client.Cmd.Join(ch) |
| 71 | 72 | } |
| 72 | 73 | b.log.Info("scribe connected and joined channels", "channels", b.channels) |
| 73 | 74 | }) |
| 74 | 75 |
| --- 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 |
+78
-10
| --- internal/bots/scroll/scroll.go | ||
| +++ internal/bots/scroll/scroll.go | ||
| @@ -21,10 +21,12 @@ | ||
| 21 | 21 | "time" |
| 22 | 22 | |
| 23 | 23 | "github.com/lrstanley/girc" |
| 24 | 24 | |
| 25 | 25 | "github.com/conflicthq/scuttlebot/internal/bots/scribe" |
| 26 | + "github.com/conflicthq/scuttlebot/pkg/chathistory" | |
| 27 | + "github.com/conflicthq/scuttlebot/pkg/toon" | |
| 26 | 28 | ) |
| 27 | 29 | |
| 28 | 30 | const ( |
| 29 | 31 | botNick = "scroll" |
| 30 | 32 | defaultLimit = 50 |
| @@ -38,11 +40,12 @@ | ||
| 38 | 40 | password string |
| 39 | 41 | channels []string |
| 40 | 42 | store scribe.Store |
| 41 | 43 | log *slog.Logger |
| 42 | 44 | 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 | |
| 44 | 47 | } |
| 45 | 48 | |
| 46 | 49 | // New creates a scroll Bot backed by the given scribe Store. |
| 47 | 50 | func New(ircAddr, password string, channels []string, store scribe.Store, log *slog.Logger) *Bot { |
| 48 | 51 | return &Bot{ |
| @@ -72,17 +75,26 @@ | ||
| 72 | 75 | Name: "scuttlebot scroll", |
| 73 | 76 | SASL: &girc.SASLPlain{User: botNick, Pass: b.password}, |
| 74 | 77 | PingDelay: 30 * time.Second, |
| 75 | 78 | PingTimeout: 30 * time.Second, |
| 76 | 79 | SSL: false, |
| 80 | + SupportedCaps: map[string][]string{ | |
| 81 | + "draft/chathistory": nil, | |
| 82 | + "chathistory": nil, | |
| 83 | + }, | |
| 77 | 84 | }) |
| 85 | + | |
| 86 | + // Register CHATHISTORY batch handlers before connecting. | |
| 87 | + b.history = chathistory.New(c) | |
| 78 | 88 | |
| 79 | 89 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, e girc.Event) { |
| 90 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 80 | 91 | for _, ch := range b.channels { |
| 81 | 92 | cl.Cmd.Join(ch) |
| 82 | 93 | } |
| 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) | |
| 84 | 96 | }) |
| 85 | 97 | |
| 86 | 98 | // Only respond to DMs — ignore anything in a channel. |
| 87 | 99 | c.Handlers.AddBg(girc.PRIVMSG, func(client *girc.Client, e girc.Event) { |
| 88 | 100 | if len(e.Params) < 1 { |
| @@ -129,15 +141,15 @@ | ||
| 129 | 141 | } |
| 130 | 142 | |
| 131 | 143 | req, err := ParseCommand(text) |
| 132 | 144 | if err != nil { |
| 133 | 145 | 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]") | |
| 135 | 147 | return |
| 136 | 148 | } |
| 137 | 149 | |
| 138 | - entries, err := b.store.Query(req.Channel, req.Limit) | |
| 150 | + entries, err := b.fetchHistory(req) | |
| 139 | 151 | if err != nil { |
| 140 | 152 | client.Cmd.Notice(nick, fmt.Sprintf("error fetching history: %s", err)) |
| 141 | 153 | return |
| 142 | 154 | } |
| 143 | 155 | |
| @@ -144,16 +156,64 @@ | ||
| 144 | 156 | if len(entries) == 0 { |
| 145 | 157 | client.Cmd.Notice(nick, fmt.Sprintf("no history found for %s", req.Channel)) |
| 146 | 158 | return |
| 147 | 159 | } |
| 148 | 160 | |
| 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 | + } | |
| 153 | 213 | } |
| 154 | - client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel)) | |
| 214 | + return b.store.Query(req.Channel, req.Limit) | |
| 155 | 215 | } |
| 156 | 216 | |
| 157 | 217 | func (b *Bot) checkRateLimit(nick string) bool { |
| 158 | 218 | now := time.Now() |
| 159 | 219 | if last, ok := b.rateLimit.Load(nick); ok { |
| @@ -167,11 +227,12 @@ | ||
| 167 | 227 | |
| 168 | 228 | // ReplayRequest is a parsed replay command. |
| 169 | 229 | type replayRequest struct { |
| 170 | 230 | Channel string |
| 171 | 231 | 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" | |
| 173 | 234 | } |
| 174 | 235 | |
| 175 | 236 | // ParseCommand parses a replay command string. Exported for testing. |
| 176 | 237 | func ParseCommand(text string) (*replayRequest, error) { |
| 177 | 238 | parts := strings.Fields(text) |
| @@ -205,10 +266,17 @@ | ||
| 205 | 266 | ts, err := strconv.ParseInt(kv[1], 10, 64) |
| 206 | 267 | if err != nil { |
| 207 | 268 | return nil, fmt.Errorf("invalid since=%q (must be unix milliseconds)", kv[1]) |
| 208 | 269 | } |
| 209 | 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 | + } | |
| 210 | 278 | default: |
| 211 | 279 | return nil, fmt.Errorf("unknown argument %q", kv[0]) |
| 212 | 280 | } |
| 213 | 281 | } |
| 214 | 282 | |
| 215 | 283 |
| --- 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 @@ | ||
| 146 | 146 | PingDelay: 30 * time.Second, |
| 147 | 147 | PingTimeout: 30 * time.Second, |
| 148 | 148 | }) |
| 149 | 149 | |
| 150 | 150 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 151 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 151 | 152 | for _, ch := range b.cfg.Channels { |
| 152 | 153 | cl.Cmd.Join(ch) |
| 153 | 154 | } |
| 154 | 155 | cl.Cmd.Join(b.cfg.ModChannel) |
| 155 | 156 | if b.log != nil { |
| 156 | 157 |
| --- 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 |
+46
-1
| --- internal/bots/snitch/snitch.go | ||
| +++ internal/bots/snitch/snitch.go | ||
| @@ -48,10 +48,14 @@ | ||
| 48 | 48 | // JoinPartWindow is the rolling window for join/part cycling. Default: 30s. |
| 49 | 49 | JoinPartWindow time.Duration |
| 50 | 50 | |
| 51 | 51 | // Channels is the list of channels to join on connect. |
| 52 | 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 | |
| 53 | 57 | } |
| 54 | 58 | |
| 55 | 59 | func (c *Config) setDefaults() { |
| 56 | 60 | if c.Nick == "" { |
| 57 | 61 | c.Nick = defaultNick |
| @@ -135,18 +139,45 @@ | ||
| 135 | 139 | PingDelay: 30 * time.Second, |
| 136 | 140 | PingTimeout: 30 * time.Second, |
| 137 | 141 | }) |
| 138 | 142 | |
| 139 | 143 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 144 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 140 | 145 | for _, ch := range b.cfg.Channels { |
| 141 | 146 | cl.Cmd.Join(ch) |
| 142 | 147 | } |
| 143 | 148 | if b.cfg.AlertChannel != "" { |
| 144 | 149 | cl.Cmd.Join(b.cfg.AlertChannel) |
| 145 | 150 | } |
| 151 | + if len(b.cfg.MonitorNicks) > 0 { | |
| 152 | + cl.Cmd.SendRawf("MONITOR + %s", strings.Join(b.cfg.MonitorNicks, ",")) | |
| 153 | + } | |
| 146 | 154 | 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)) | |
| 148 | 179 | } |
| 149 | 180 | }) |
| 150 | 181 | |
| 151 | 182 | c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) { |
| 152 | 183 | if ch := e.Last(); strings.HasPrefix(ch, "#") { |
| @@ -202,10 +233,24 @@ | ||
| 202 | 233 | func (b *Bot) JoinChannel(channel string) { |
| 203 | 234 | if b.client != nil { |
| 204 | 235 | b.client.Cmd.Join(channel) |
| 205 | 236 | } |
| 206 | 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 | +} | |
| 207 | 252 | |
| 208 | 253 | func (b *Bot) window(channel, nick string) *nickWindow { |
| 209 | 254 | if b.windows[channel] == nil { |
| 210 | 255 | b.windows[channel] = make(map[string]*nickWindow) |
| 211 | 256 | } |
| 212 | 257 |
| --- 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 @@ | ||
| 130 | 130 | PingDelay: 30 * time.Second, |
| 131 | 131 | PingTimeout: 30 * time.Second, |
| 132 | 132 | }) |
| 133 | 133 | |
| 134 | 134 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 135 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 135 | 136 | for _, ch := range b.cfg.Channels { |
| 136 | 137 | cl.Cmd.Join(ch) |
| 137 | 138 | } |
| 138 | 139 | cl.Cmd.Join(b.cfg.ModChannel) |
| 139 | 140 | if b.log != nil { |
| 140 | 141 |
| --- 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 @@ | ||
| 91 | 91 | PingTimeout: 30 * time.Second, |
| 92 | 92 | SSL: false, |
| 93 | 93 | }) |
| 94 | 94 | |
| 95 | 95 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 96 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 96 | 97 | for _, ch := range b.channels { |
| 97 | 98 | cl.Cmd.Join(ch) |
| 98 | 99 | } |
| 99 | 100 | b.log.Info("systembot connected", "channels", b.channels) |
| 100 | 101 | }) |
| 101 | 102 |
| --- 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 |
+10
-1
| --- internal/bots/warden/warden.go | ||
| +++ internal/bots/warden/warden.go | ||
| @@ -199,10 +199,11 @@ | ||
| 199 | 199 | PingTimeout: 30 * time.Second, |
| 200 | 200 | SSL: false, |
| 201 | 201 | }) |
| 202 | 202 | |
| 203 | 203 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 204 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 204 | 205 | for _, ch := range b.initChannels { |
| 205 | 206 | cl.Cmd.Join(ch) |
| 206 | 207 | } |
| 207 | 208 | for ch := range b.channelConfigs { |
| 208 | 209 | cl.Cmd.Join(ch) |
| @@ -309,11 +310,19 @@ | ||
| 309 | 310 | switch action { |
| 310 | 311 | case ActionWarn: |
| 311 | 312 | cl.Cmd.Notice(nick, fmt.Sprintf("warden: warning — %s in %s", reason, channel)) |
| 312 | 313 | case ActionMute: |
| 313 | 314 | 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 | + }() | |
| 315 | 324 | case ActionKick: |
| 316 | 325 | cl.Cmd.Kick(channel, nick, "warden: "+reason) |
| 317 | 326 | } |
| 318 | 327 | } |
| 319 | 328 | |
| 320 | 329 |
| --- 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 @@ | ||
| 278 | 278 | // Voice is a list of nicks to grant voice (+v) access. |
| 279 | 279 | Voice []string `yaml:"voice" json:"voice,omitempty"` |
| 280 | 280 | |
| 281 | 281 | // Autojoin is a list of bot nicks to invite when the channel is provisioned. |
| 282 | 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"` | |
| 283 | 290 | } |
| 284 | 291 | |
| 285 | 292 | // ChannelTypeConfig defines policy rules for a class of dynamically created channels. |
| 286 | 293 | // Matched by prefix against channel names (e.g. prefix "task." matches "#task.gh-42"). |
| 287 | 294 | type ChannelTypeConfig struct { |
| @@ -295,17 +302,23 @@ | ||
| 295 | 302 | // Autojoin is a list of bot nicks to invite when a channel of this type is created. |
| 296 | 303 | Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"` |
| 297 | 304 | |
| 298 | 305 | // Supervision is the coordination channel where summaries should surface. |
| 299 | 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"` | |
| 300 | 310 | |
| 301 | 311 | // Ephemeral marks channels of this type for automatic cleanup. |
| 302 | 312 | Ephemeral bool `yaml:"ephemeral" json:"ephemeral,omitempty"` |
| 303 | 313 | |
| 304 | 314 | // TTL is the maximum lifetime of an ephemeral channel with no non-bot members. |
| 305 | 315 | // Zero means no TTL; cleanup only occurs when the channel is empty. |
| 306 | 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"` | |
| 307 | 320 | } |
| 308 | 321 | |
| 309 | 322 | // Duration wraps time.Duration for YAML/JSON marshalling ("72h", "30m", etc.). |
| 310 | 323 | type Duration struct { |
| 311 | 324 | time.Duration |
| 312 | 325 |
| --- 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 |
+3
-1
| --- internal/ergo/ircdconfig.go | ||
| +++ internal/ergo/ircdconfig.go | ||
| @@ -23,11 +23,13 @@ | ||
| 23 | 23 | {{- end}} |
| 24 | 24 | casemapping: ascii |
| 25 | 25 | enforce-utf8: true |
| 26 | 26 | max-sendq: 96k |
| 27 | 27 | relaymsg: |
| 28 | - enabled: false | |
| 28 | + enabled: true | |
| 29 | + separators: / | |
| 30 | + available-to-chanops: false | |
| 29 | 31 | ip-cloaking: |
| 30 | 32 | enabled: false |
| 31 | 33 | lookup-hostnames: false |
| 32 | 34 | |
| 33 | 35 | datastore: |
| 34 | 36 |
| --- 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 @@ | ||
| 115 | 115 | } |
| 116 | 116 | wait = min(wait*2, restartMaxWait) //nolint:ineffassign,staticcheck |
| 117 | 117 | } |
| 118 | 118 | } |
| 119 | 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 | +} | |
| 120 | 127 | |
| 121 | 128 | // Rehash reloads the Ergo config. Call after writing a new ircd.yaml. |
| 122 | 129 | func (m *Manager) Rehash() error { |
| 123 | 130 | if err := m.writeConfig(); err != nil { |
| 124 | 131 | return fmt.Errorf("ergo: write config: %w", err) |
| 125 | 132 |
| --- 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 |
+9
-8
| --- internal/mcp/mcp.go | ||
| +++ internal/mcp/mcp.go | ||
| @@ -52,31 +52,32 @@ | ||
| 52 | 52 | type ChannelInfo struct { |
| 53 | 53 | Name string `json:"name"` |
| 54 | 54 | Topic string `json:"topic,omitempty"` |
| 55 | 55 | Count int `json:"count"` |
| 56 | 56 | } |
| 57 | + | |
| 58 | +// TokenValidator validates API tokens. | |
| 59 | +type TokenValidator interface { | |
| 60 | + ValidToken(token string) bool | |
| 61 | +} | |
| 57 | 62 | |
| 58 | 63 | // Server is the MCP server. |
| 59 | 64 | type Server struct { |
| 60 | 65 | registry *registry.Registry |
| 61 | 66 | channels ChannelLister |
| 62 | 67 | sender Sender // optional — send_message returns error if nil |
| 63 | 68 | history HistoryQuerier // optional — get_history returns error if nil |
| 64 | - tokens map[string]struct{} | |
| 69 | + tokens TokenValidator | |
| 65 | 70 | log *slog.Logger |
| 66 | 71 | } |
| 67 | 72 | |
| 68 | 73 | // 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 { | |
| 74 | 75 | return &Server{ |
| 75 | 76 | registry: reg, |
| 76 | 77 | channels: channels, |
| 77 | - tokens: t, | |
| 78 | + tokens: tokens, | |
| 78 | 79 | log: log, |
| 79 | 80 | } |
| 80 | 81 | } |
| 81 | 82 | |
| 82 | 83 | // WithSender attaches an IRC relay client for send_message. |
| @@ -101,11 +102,11 @@ | ||
| 101 | 102 | // --- Auth --- |
| 102 | 103 | |
| 103 | 104 | func (s *Server) authMiddleware(next http.Handler) http.Handler { |
| 104 | 105 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 105 | 106 | token := bearerToken(r) |
| 106 | - if _, ok := s.tokens[token]; !ok { | |
| 107 | + if !s.tokens.ValidToken(token) { | |
| 107 | 108 | writeRPCError(w, nil, -32001, "unauthorized") |
| 108 | 109 | return |
| 109 | 110 | } |
| 110 | 111 | next.ServeHTTP(w, r) |
| 111 | 112 | }) |
| 112 | 113 |
| --- 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 |
+8
-1
| --- internal/mcp/mcp_test.go | ||
| +++ internal/mcp/mcp_test.go | ||
| @@ -19,10 +19,17 @@ | ||
| 19 | 19 | var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) |
| 20 | 20 | |
| 21 | 21 | const testToken = "test-mcp-token" |
| 22 | 22 | |
| 23 | 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 | +} | |
| 24 | 31 | |
| 25 | 32 | type mockProvisioner struct { |
| 26 | 33 | mu sync.Mutex |
| 27 | 34 | accounts map[string]string |
| 28 | 35 | } |
| @@ -93,11 +100,11 @@ | ||
| 93 | 100 | hist := &mockHistory{entries: map[string][]mcp.HistoryEntry{ |
| 94 | 101 | "#fleet": { |
| 95 | 102 | {Nick: "agent-01", MessageType: "task.create", MessageID: "01HX", Raw: `{"v":1}`}, |
| 96 | 103 | }, |
| 97 | 104 | }} |
| 98 | - srv := mcp.New(reg, channels, []string{testToken}, testLog). | |
| 105 | + srv := mcp.New(reg, channels, tokenSet{testToken: {}}, testLog). | |
| 99 | 106 | WithSender(sender). |
| 100 | 107 | WithHistory(hist) |
| 101 | 108 | return httptest.NewServer(srv.Handler()) |
| 102 | 109 | } |
| 103 | 110 | |
| 104 | 111 |
| --- 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 @@ | ||
| 11 | 11 | // ChannelType is the resolved policy for a class of channels. |
| 12 | 12 | type ChannelType struct { |
| 13 | 13 | Name string |
| 14 | 14 | Prefix string |
| 15 | 15 | Autojoin []string |
| 16 | + Modes []string | |
| 16 | 17 | Supervision string |
| 17 | 18 | Ephemeral bool |
| 18 | 19 | TTL time.Duration |
| 19 | 20 | } |
| 20 | 21 | |
| @@ -61,10 +62,11 @@ | ||
| 61 | 62 | for _, t := range cfg.Types { |
| 62 | 63 | types = append(types, ChannelType{ |
| 63 | 64 | Name: t.Name, |
| 64 | 65 | Prefix: t.Prefix, |
| 65 | 66 | Autojoin: append([]string(nil), t.Autojoin...), |
| 67 | + Modes: append([]string(nil), t.Modes...), | |
| 66 | 68 | Supervision: t.Supervision, |
| 67 | 69 | Ephemeral: t.Ephemeral, |
| 68 | 70 | TTL: t.TTL.Duration, |
| 69 | 71 | }) |
| 70 | 72 | } |
| @@ -133,10 +135,18 @@ | ||
| 133 | 135 | if t := p.Match(channel); t != nil { |
| 134 | 136 | return t.TTL |
| 135 | 137 | } |
| 136 | 138 | return 0 |
| 137 | 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 | +} | |
| 138 | 148 | |
| 139 | 149 | // StaticChannels returns the list of channels to provision at startup. |
| 140 | 150 | func (p *Policy) StaticChannels() []config.StaticChannelConfig { |
| 141 | 151 | return append([]config.StaticChannelConfig(nil), p.staticChannels...) |
| 142 | 152 | } |
| 143 | 153 |
| --- 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 |
+65
-11
| --- internal/topology/topology.go | ||
| +++ internal/topology/topology.go | ||
| @@ -24,18 +24,24 @@ | ||
| 24 | 24 | Name string |
| 25 | 25 | |
| 26 | 26 | // Topic is the initial channel topic (shared state header). |
| 27 | 27 | Topic string |
| 28 | 28 | |
| 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. | |
| 30 | 30 | Ops []string |
| 31 | 31 | |
| 32 | - // Voice is a list of nicks to grant +v status. | |
| 32 | + // Voice is a list of nicks to grant +v status via AMODE. | |
| 33 | 33 | Voice []string |
| 34 | 34 | |
| 35 | 35 | // Autojoin is a list of bot nicks to invite after provisioning. |
| 36 | 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 | |
| 37 | 43 | } |
| 38 | 44 | |
| 39 | 45 | // channelRecord tracks a provisioned channel for TTL-based reaping. |
| 40 | 46 | type channelRecord struct { |
| 41 | 47 | name string |
| @@ -207,15 +213,21 @@ | ||
| 207 | 213 | |
| 208 | 214 | if ch.Topic != "" { |
| 209 | 215 | m.chanserv("TOPIC %s %s", ch.Name, ch.Topic) |
| 210 | 216 | } |
| 211 | 217 | |
| 218 | + // Use AMODE for persistent auto-mode on join (survives reconnects). | |
| 212 | 219 | 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) | |
| 214 | 221 | } |
| 215 | 222 | 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) | |
| 217 | 229 | } |
| 218 | 230 | |
| 219 | 231 | if len(ch.Autojoin) > 0 { |
| 220 | 232 | m.Invite(ch.Name, ch.Autojoin) |
| 221 | 233 | } |
| @@ -274,33 +286,75 @@ | ||
| 274 | 286 | m.log.Info("reaping expired ephemeral channel", "channel", rec.name, "age", now.Sub(rec.provisionedAt).Round(time.Minute)) |
| 275 | 287 | m.DropChannel(rec.name) |
| 276 | 288 | } |
| 277 | 289 | } |
| 278 | 290 | |
| 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. | |
| 281 | 294 | func (m *Manager) GrantAccess(nick, channel, level string) { |
| 282 | 295 | if m.client == nil || level == "" { |
| 283 | 296 | return |
| 284 | 297 | } |
| 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) | |
| 287 | 308 | } |
| 288 | 309 | |
| 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. | |
| 290 | 311 | func (m *Manager) RevokeAccess(nick, channel string) { |
| 291 | 312 | if m.client == nil { |
| 292 | 313 | return |
| 293 | 314 | } |
| 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) | |
| 296 | 318 | } |
| 297 | 319 | |
| 298 | 320 | func (m *Manager) chanserv(format string, args ...any) { |
| 299 | 321 | msg := fmt.Sprintf(format, args...) |
| 300 | 322 | m.client.Cmd.Message("ChanServ", msg) |
| 301 | 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 | +} | |
| 302 | 356 | |
| 303 | 357 | // ValidateName checks that a channel name follows scuttlebot conventions. |
| 304 | 358 | func ValidateName(name string) error { |
| 305 | 359 | if !strings.HasPrefix(name, "#") { |
| 306 | 360 | return fmt.Errorf("topology: channel name must start with #: %q", name) |
| 307 | 361 | |
| 308 | 362 | 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 | } |
+17
| --- pkg/client/client.go | ||
| +++ pkg/client/client.go | ||
| @@ -186,10 +186,27 @@ | ||
| 186 | 186 | text := e.Last() |
| 187 | 187 | env, err := protocol.Unmarshal([]byte(text)) |
| 188 | 188 | if err != nil { |
| 189 | 189 | return // non-JSON PRIVMSG (human chat) — silently ignored |
| 190 | 190 | } |
| 191 | + | |
| 192 | + // Populate IRCv3 transport metadata. | |
| 193 | + env.Channel = channel | |
| 194 | + env.ServerTime = e.Timestamp | |
| 195 | + if acct, ok := e.Tags.Get("account"); ok { | |
| 196 | + env.Account = acct | |
| 197 | + } | |
| 198 | + if msgID, ok := e.Tags.Get("msgid"); ok { | |
| 199 | + env.MsgID = msgID | |
| 200 | + } | |
| 201 | + if len(e.Tags) > 0 { | |
| 202 | + env.Tags = make(map[string]string, len(e.Tags)) | |
| 203 | + for k, v := range e.Tags { | |
| 204 | + env.Tags[k] = v | |
| 205 | + } | |
| 206 | + } | |
| 207 | + | |
| 191 | 208 | c.dispatch(ctx, env) |
| 192 | 209 | }) |
| 193 | 210 | |
| 194 | 211 | // NOTICE is ignored — system/human commentary, not agent traffic. |
| 195 | 212 | |
| 196 | 213 |
| --- pkg/client/client.go | |
| +++ pkg/client/client.go | |
| @@ -186,10 +186,27 @@ | |
| 186 | text := e.Last() |
| 187 | env, err := protocol.Unmarshal([]byte(text)) |
| 188 | if err != nil { |
| 189 | return // non-JSON PRIVMSG (human chat) — silently ignored |
| 190 | } |
| 191 | c.dispatch(ctx, env) |
| 192 | }) |
| 193 | |
| 194 | // NOTICE is ignored — system/human commentary, not agent traffic. |
| 195 | |
| 196 |
| --- pkg/client/client.go | |
| +++ pkg/client/client.go | |
| @@ -186,10 +186,27 @@ | |
| 186 | text := e.Last() |
| 187 | env, err := protocol.Unmarshal([]byte(text)) |
| 188 | if err != nil { |
| 189 | return // non-JSON PRIVMSG (human chat) — silently ignored |
| 190 | } |
| 191 | |
| 192 | // Populate IRCv3 transport metadata. |
| 193 | env.Channel = channel |
| 194 | env.ServerTime = e.Timestamp |
| 195 | if acct, ok := e.Tags.Get("account"); ok { |
| 196 | env.Account = acct |
| 197 | } |
| 198 | if msgID, ok := e.Tags.Get("msgid"); ok { |
| 199 | env.MsgID = msgID |
| 200 | } |
| 201 | if len(e.Tags) > 0 { |
| 202 | env.Tags = make(map[string]string, len(e.Tags)) |
| 203 | for k, v := range e.Tags { |
| 204 | env.Tags[k] = v |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | c.dispatch(ctx, env) |
| 209 | }) |
| 210 | |
| 211 | // NOTICE is ignored — system/human commentary, not agent traffic. |
| 212 | |
| 213 |
| --- pkg/ircagent/ircagent.go | ||
| +++ pkg/ircagent/ircagent.go | ||
| @@ -271,10 +271,17 @@ | ||
| 271 | 271 | text := strings.TrimSpace(e.Last()) |
| 272 | 272 | if senderNick == a.cfg.Nick { |
| 273 | 273 | return |
| 274 | 274 | } |
| 275 | 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. | |
| 276 | 283 | if strings.HasPrefix(text, "[") { |
| 277 | 284 | if end := strings.Index(text, "] "); end != -1 { |
| 278 | 285 | senderNick = text[1:end] |
| 279 | 286 | text = text[end+2:] |
| 280 | 287 | } |
| 281 | 288 |
| --- 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/protocol/protocol.go | ||
| +++ pkg/protocol/protocol.go | ||
| @@ -33,10 +33,17 @@ | ||
| 33 | 33 | ID string `json:"id"` |
| 34 | 34 | From string `json:"from"` |
| 35 | 35 | To []string `json:"to,omitempty"` |
| 36 | 36 | TS int64 `json:"ts"` |
| 37 | 37 | Payload json.RawMessage `json:"payload,omitempty"` |
| 38 | + | |
| 39 | + // IRCv3 transport metadata — populated at receive time, not serialized. | |
| 40 | + Channel string `json:"-"` // channel the message arrived on | |
| 41 | + Account string `json:"-"` // account-tag: sender's NickServ account | |
| 42 | + MsgID string `json:"-"` // msgid tag: server-assigned message ID | |
| 43 | + ServerTime time.Time `json:"-"` // server-time tag: server-provided timestamp | |
| 44 | + Tags map[string]string `json:"-"` // all IRCv3 message tags | |
| 38 | 45 | } |
| 39 | 46 | |
| 40 | 47 | // New creates a new Envelope with a generated ID and current timestamp. |
| 41 | 48 | // To is left empty (unaddressed — matches all recipients). |
| 42 | 49 | func New(msgType, from string, payload any) (*Envelope, error) { |
| 43 | 50 |
| --- pkg/protocol/protocol.go | |
| +++ pkg/protocol/protocol.go | |
| @@ -33,10 +33,17 @@ | |
| 33 | ID string `json:"id"` |
| 34 | From string `json:"from"` |
| 35 | To []string `json:"to,omitempty"` |
| 36 | TS int64 `json:"ts"` |
| 37 | Payload json.RawMessage `json:"payload,omitempty"` |
| 38 | } |
| 39 | |
| 40 | // New creates a new Envelope with a generated ID and current timestamp. |
| 41 | // To is left empty (unaddressed — matches all recipients). |
| 42 | func New(msgType, from string, payload any) (*Envelope, error) { |
| 43 |
| --- pkg/protocol/protocol.go | |
| +++ pkg/protocol/protocol.go | |
| @@ -33,10 +33,17 @@ | |
| 33 | ID string `json:"id"` |
| 34 | From string `json:"from"` |
| 35 | To []string `json:"to,omitempty"` |
| 36 | TS int64 `json:"ts"` |
| 37 | Payload json.RawMessage `json:"payload,omitempty"` |
| 38 | |
| 39 | // IRCv3 transport metadata — populated at receive time, not serialized. |
| 40 | Channel string `json:"-"` // channel the message arrived on |
| 41 | Account string `json:"-"` // account-tag: sender's NickServ account |
| 42 | MsgID string `json:"-"` // msgid tag: server-assigned message ID |
| 43 | ServerTime time.Time `json:"-"` // server-time tag: server-provided timestamp |
| 44 | Tags map[string]string `json:"-"` // all IRCv3 message tags |
| 45 | } |
| 46 | |
| 47 | // New creates a new Envelope with a generated ID and current timestamp. |
| 48 | // To is left empty (unaddressed — matches all recipients). |
| 49 | func New(msgType, from string, payload any) (*Envelope, error) { |
| 50 |
+56
-8
| --- pkg/sessionrelay/irc.go | ||
| +++ pkg/sessionrelay/irc.go | ||
| @@ -25,10 +25,11 @@ | ||
| 25 | 25 | nick string |
| 26 | 26 | addr string |
| 27 | 27 | agentType string |
| 28 | 28 | pass string |
| 29 | 29 | deleteOnClose bool |
| 30 | + envelopeMode bool | |
| 30 | 31 | |
| 31 | 32 | mu sync.RWMutex |
| 32 | 33 | channels []string |
| 33 | 34 | messages []Message |
| 34 | 35 | client *girc.Client |
| @@ -50,10 +51,11 @@ | ||
| 50 | 51 | nick: cfg.Nick, |
| 51 | 52 | addr: cfg.IRC.Addr, |
| 52 | 53 | agentType: cfg.IRC.AgentType, |
| 53 | 54 | pass: cfg.IRC.Pass, |
| 54 | 55 | deleteOnClose: cfg.IRC.DeleteOnClose, |
| 56 | + envelopeMode: cfg.IRC.EnvelopeMode, | |
| 55 | 57 | channels: append([]string(nil), cfg.Channels...), |
| 56 | 58 | messages: make([]Message, 0, defaultBufferSize), |
| 57 | 59 | errCh: make(chan error, 1), |
| 58 | 60 | }, nil |
| 59 | 61 | } |
| @@ -126,27 +128,47 @@ | ||
| 126 | 128 | } |
| 127 | 129 | if onJoined != nil { |
| 128 | 130 | onJoined() |
| 129 | 131 | } |
| 130 | 132 | }) |
| 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) { | |
| 132 | 134 | if len(e.Params) < 1 || e.Source == nil { |
| 133 | 135 | return |
| 134 | 136 | } |
| 135 | 137 | target := normalizeChannel(e.Params[0]) |
| 136 | 138 | if !c.hasChannel(target) { |
| 137 | 139 | return |
| 138 | 140 | } |
| 141 | + // Prefer account-tag (IRCv3) over source nick. | |
| 139 | 142 | sender := e.Source.Name |
| 143 | + if acct, ok := e.Tags.Get("account"); ok && acct != "" { | |
| 144 | + sender = acct | |
| 145 | + } | |
| 140 | 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. | |
| 141 | 154 | if sender == "bridge" && strings.HasPrefix(text, "[") { |
| 142 | 155 | if end := strings.Index(text, "] "); end != -1 { |
| 143 | 156 | sender = text[1:end] |
| 144 | 157 | text = strings.TrimSpace(text[end+2:]) |
| 145 | 158 | } |
| 146 | 159 | } |
| 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}) | |
| 148 | 170 | }) |
| 149 | 171 | |
| 150 | 172 | c.mu.Lock() |
| 151 | 173 | c.client = client |
| 152 | 174 | c.mu.Unlock() |
| @@ -221,26 +243,28 @@ | ||
| 221 | 243 | |
| 222 | 244 | func (c *ircConnector) PostTo(_ context.Context, channel, text string) error { |
| 223 | 245 | return c.PostToWithMeta(context.Background(), channel, text, nil) |
| 224 | 246 | } |
| 225 | 247 | |
| 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 { | |
| 228 | 251 | c.mu.RLock() |
| 229 | 252 | client := c.client |
| 230 | 253 | c.mu.RUnlock() |
| 231 | 254 | if client == nil { |
| 232 | 255 | return fmt.Errorf("sessionrelay: irc client not connected") |
| 233 | 256 | } |
| 257 | + msg := c.formatMessage(text, meta) | |
| 234 | 258 | for _, channel := range c.Channels() { |
| 235 | - client.Cmd.Message(channel, text) | |
| 259 | + client.Cmd.Message(channel, msg) | |
| 236 | 260 | } |
| 237 | 261 | return nil |
| 238 | 262 | } |
| 239 | 263 | |
| 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 { | |
| 242 | 266 | c.mu.RLock() |
| 243 | 267 | client := c.client |
| 244 | 268 | c.mu.RUnlock() |
| 245 | 269 | if client == nil { |
| 246 | 270 | return fmt.Errorf("sessionrelay: irc client not connected") |
| @@ -247,13 +271,37 @@ | ||
| 247 | 271 | } |
| 248 | 272 | channel = normalizeChannel(channel) |
| 249 | 273 | if channel == "" { |
| 250 | 274 | return fmt.Errorf("sessionrelay: post channel is required") |
| 251 | 275 | } |
| 252 | - client.Cmd.Message(channel, text) | |
| 276 | + client.Cmd.Message(channel, c.formatMessage(text, meta)) | |
| 253 | 277 | return nil |
| 254 | 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 | +} | |
| 255 | 303 | |
| 256 | 304 | func (c *ircConnector) MessagesSince(_ context.Context, since time.Time) ([]Message, error) { |
| 257 | 305 | c.mu.RLock() |
| 258 | 306 | defer c.mu.RUnlock() |
| 259 | 307 | |
| 260 | 308 |
| --- 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 @@ | ||
| 35 | 35 | type IRCConfig struct { |
| 36 | 36 | Addr string |
| 37 | 37 | Pass string |
| 38 | 38 | AgentType string |
| 39 | 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 | |
| 40 | 43 | } |
| 41 | 44 | |
| 42 | 45 | type Message struct { |
| 43 | 46 | At time.Time |
| 44 | 47 | Channel string |
| 45 | 48 | Nick string |
| 46 | 49 | Text string |
| 50 | + MsgID string | |
| 47 | 51 | } |
| 48 | 52 | |
| 49 | 53 | type Connector interface { |
| 50 | 54 | Connect(ctx context.Context) error |
| 51 | 55 | Post(ctx context.Context, text string) error |
| 52 | 56 | |
| 53 | 57 | ADDED pkg/toon/toon.go |
| 54 | 58 | 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 |
+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.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 | } |
+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 | +} |
| --- 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 | } |