ScuttleBot

merge main into feature/86-wire-bot-commands

lmata 2026-04-05 17:51 trunk merge
Commit e88e140fd0d4e671e628f00c1b9e39ff0bf00f5e030e101f15fbd68b3121335c
58 files changed +78 -30 +45 -7 +51 +99 +3 -1 +81 +2 -1 +125 +90 -8 +5 -2 +43 -4 +7 -4 +2 -1 +7 -5 +2 -2 +38 -2 +71 -5 +88 -60 +4 -2 +401 -23 +288 +1 +1 +164 -20 +1 +1 +51 -13 +51 -13 +1 +1 +78 -10 +78 -10 +1 +1 +46 -1 +46 -1 +4 -3 +4 -3 +1 +1 +10 -1 +10 -1 +13 +3 -1 +7 +9 -8 +8 -1 +13 +10 +65 -11 +183 +17 +7 +7 +56 -8 +4 +121 +65
~ bootstrap.md ~ cmd/scuttlebot/main.go ~ cmd/scuttlectl/internal/apiclient/apiclient.go ~ cmd/scuttlectl/main.go ~ deploy/compose/ergo/ircd.yaml.tmpl ~ internal/api/agents.go ~ 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/auditbot/auditbot.go ~ internal/bots/bridge/bridge.go ~ internal/bots/herald/herald.go ~ internal/bots/herald/herald.go ~ internal/bots/oracle/oracle.go ~ internal/bots/oracle/oracle.go ~ internal/bots/scribe/scribe.go ~ internal/bots/scribe/scribe.go ~ internal/bots/scroll/scroll.go ~ internal/bots/scroll/scroll.go ~ internal/bots/sentinel/sentinel.go ~ internal/bots/sentinel/sentinel.go ~ internal/bots/snitch/snitch.go ~ internal/bots/snitch/snitch.go ~ internal/bots/steward/steward.go ~ internal/bots/steward/steward.go ~ internal/bots/systembot/systembot.go ~ internal/bots/systembot/systembot.go ~ internal/bots/warden/warden.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/registry/registry.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
+78 -30
--- bootstrap.md
+++ bootstrap.md
@@ -157,24 +157,26 @@
157157
- `+v` (voice) — trusted worker agents
158158
- no mode — standard agents
159159
160160
### Built-in bots
161161
162
-All 8 bots are implemented. Enabled/configured via the web UI or `scuttlectl`. The manager (`internal/bots/manager/`) starts/stops them dynamically when policies change.
162
+All 10 bots are implemented. Enabled/configured via the web UI or `scuttlectl bot list`. The manager (`internal/bots/manager/`) starts/stops them dynamically when policies change. All bots set `+B` (bot) user mode on connect and auto-accept INVITE.
163163
164164
| Bot | Nick | Role |
165165
|-----|------|------|
166166
| `auditbot` | auditbot | Immutable append-only audit trail of agent actions and credential events |
167167
| `herald` | herald | Routes inbound webhook events to IRC channels |
168168
| `oracle` | oracle | On-demand channel summarization via DM — calls any OpenAI-compatible LLM |
169169
| `scribe` | scribe | Structured message logging to rotating files (jsonl/csv/text) |
170
-| `scroll` | scroll | History replay to PM on request |
171
-| `snitch` | snitch | Flood and join/part cycling detection — alerts operators via DM or channel |
170
+| `scroll` | scroll | History replay to PM on request (`replay #channel [format=toon]`) |
171
+| `sentinel` | sentinel | LLM-powered channel observer — detects policy violations, posts structured incident reports to mod channel. Never takes enforcement action. |
172
+| `snitch` | snitch | Flood and join/part cycling detection, MONITOR-based presence tracking, away-notify alerts |
173
+| `steward` | steward | Acts on sentinel incident reports — issues warnings, mutes (extended ban `m:`), or kicks based on severity |
172174
| `systembot` | systembot | Logs IRC system events (joins, parts, quits, mode changes) |
173
-| `warden` | warden | Channel moderation — warn → mute → kick on flood |
175
+| `warden` | warden | Channel moderation — warn → mute (extended ban) → kick on flood |
174176
175
-Oracle reads history from scribe's log files (pointed at the same dir). Configure `api_key_env` to the name of the env var holding the API key (e.g. `ORACLE_OPENAI_API_KEY`), and `base_url` for non-OpenAI providers.
177
+Oracle uses TOON format (`pkg/toon/`) for token-efficient LLM context. Scroll supports `format=toon` for compact replay output. Configure `api_key_env` to the name of the env var holding the API key (e.g. `ORACLE_OPENAI_API_KEY`), and `base_url` for non-OpenAI providers.
176178
177179
### Scale
178180
179181
Target: 100s to low 1000s of agents on a private network. Single Ergo instance handles this comfortably (documented up to 10k clients, 2k per channel). Ergo scales up (multi-core), not out — no horizontal clustering today. Federation is planned upstream but has no timeline; not a scuttlebot concern for now.
180182
@@ -186,11 +188,12 @@
186188
|------|------|-------|
187189
| Agent registry | `data/ergo/registry.json` | Agent records + SASL credentials |
188190
| Admin accounts | `data/ergo/admins.json` | bcrypt-hashed; created by `scuttlectl admin add` |
189191
| Policies | `data/ergo/policies.json` | Bot config, agent policy, logging settings |
190192
| Bot passwords | `data/ergo/bot_passwords.json` | Auto-generated SASL passwords for system bots |
191
-| API token | `data/ergo/api_token` | Bearer token for API auth; stable across restarts |
193
+| API token | `data/ergo/api_token` | Legacy token; migrated to api_keys.json on first run |
194
+| API keys | `data/ergo/api_keys.json` | Per-consumer tokens with scoped permissions (SHA-256 hashed) |
192195
| Ergo state | `data/ergo/ircd.db` | Ergo-native: accounts, channels, topics, history |
193196
| scribe logs | `data/logs/scribe/` | Rotating log files (jsonl/csv/text); configurable |
194197
195198
K8s / Docker: mount a PersistentVolume at `data/`. Ergo is single-instance — HA = fast pod restart with durable storage, not horizontal scaling.
196199
@@ -228,48 +231,79 @@
228231
`internal/api/` — two-mux pattern:
229232
230233
- **Outer mux** (unauthenticated): `POST /login`, `GET /` (redirect), `GET /ui/` (web UI)
231234
- **Inner mux** (`/v1/` routes): require `Authorization: Bearer <token>` header
232235
233
-The API token is a random hex string generated once at startup, persisted to `data/ergo/api_token`.
234
-
235236
### Auth
236237
237
-`POST /login` accepts `{username, password}` and returns `{token, username}`. The token is the shared server API token. Rate limited to 10 attempts per minute per IP.
238
+API keys are per-consumer tokens with scoped permissions. Each key has a name, scopes, optional expiry, and last-used tracking. Scopes: `admin`, `agents`, `channels`, `chat`, `topology`, `bots`, `config`, `read`. The `admin` scope implies all others.
239
+
240
+`POST /login` accepts `{username, password}` and returns a 24h session token with admin scope. Rate limited to 10 attempts per minute per IP.
241
+
242
+On first run, the legacy `api_token` file is migrated into `api_keys.json` as the first admin-scope key. New keys are created via `POST /v1/api-keys`, `scuttlectl api-key create`, or the web UI settings tab.
238243
239
-Admin accounts are managed via `scuttlectl admin` or the web UI settings → admin accounts card. First run auto-creates an `admin` account with a random password printed to the log.
244
+Admin accounts managed via `scuttlectl admin` or web UI. First run auto-creates `admin` with a random password printed to the log.
240245
241246
### Key endpoints
242247
243
-| Method | Path | Description |
244
-|--------|------|-------------|
245
-| `POST` | `/login` | Username/password login (unauthenticated) |
246
-| `GET` | `/v1/status` | Server status |
247
-| `GET` | `/v1/metrics` | Runtime metrics + bridge stats |
248
-| `GET/PUT` | `/v1/settings/policies` | Bot config, agent policy, logging |
249
-| `GET` | `/v1/agents` | List all registered agents |
250
-| `POST` | `/v1/agents/register` | Register an agent |
251
-| `POST` | `/v1/agents/{nick}/rotate` | Rotate credentials |
252
-| `POST` | `/v1/agents/{nick}/revoke` | Revoke agent |
253
-| `GET` | `/v1/channels` | List joined channels |
254
-| `GET` | `/v1/channels/{ch}/stream` | SSE stream of channel messages |
255
-| `GET/POST` | `/v1/admins` | List / add admin accounts |
256
-| `DELETE` | `/v1/admins/{username}` | Remove admin |
257
-| `PUT` | `/v1/admins/{username}/password` | Change password |
248
+All `/v1/` endpoints require a Bearer token with the appropriate scope.
249
+
250
+| Method | Path | Scope | Description |
251
+|--------|------|-------|-------------|
252
+| `POST` | `/login` | — | Username/password login (unauthenticated) |
253
+| `GET` | `/v1/status` | read | Server status |
254
+| `GET` | `/v1/metrics` | read | Runtime metrics + bridge stats |
255
+| `GET` | `/v1/settings` | read | Full settings (policies, TLS, bot commands) |
256
+| `GET/PUT/PATCH` | `/v1/settings/policies` | admin | Bot config, agent policy, logging |
257
+| `GET` | `/v1/agents` | agents | List all registered agents |
258
+| `GET` | `/v1/agents/{nick}` | agents | Get single agent |
259
+| `PATCH` | `/v1/agents/{nick}` | agents | Update agent |
260
+| `POST` | `/v1/agents/register` | agents | Register an agent |
261
+| `POST` | `/v1/agents/{nick}/rotate` | agents | Rotate credentials |
262
+| `POST` | `/v1/agents/{nick}/adopt` | agents | Adopt existing IRC nick |
263
+| `POST` | `/v1/agents/{nick}/revoke` | agents | Revoke agent credentials |
264
+| `DELETE` | `/v1/agents/{nick}` | agents | Delete agent |
265
+| `GET` | `/v1/channels` | channels | List joined channels |
266
+| `POST` | `/v1/channels/{ch}/join` | channels | Join channel |
267
+| `DELETE` | `/v1/channels/{ch}` | channels | Leave channel |
268
+| `GET` | `/v1/channels/{ch}/messages` | channels | Get message history |
269
+| `POST` | `/v1/channels/{ch}/messages` | chat | Send message |
270
+| `POST` | `/v1/channels/{ch}/presence` | chat | Touch presence (keep web user visible) |
271
+| `GET` | `/v1/channels/{ch}/users` | channels | User list with IRC modes |
272
+| `GET` | `/v1/channels/{ch}/config` | channels | Per-channel display config |
273
+| `PUT` | `/v1/channels/{ch}/config` | channels | Set display config (mirror detail, render mode) |
274
+| `GET` | `/v1/channels/{ch}/stream` | channels | SSE stream (`?token=` query param auth) |
275
+| `POST` | `/v1/channels` | topology | Provision channel via ChanServ |
276
+| `DELETE` | `/v1/topology/channels/{ch}` | topology | Drop channel |
277
+| `GET` | `/v1/topology` | topology | Channel types, static channels, active channels |
278
+| `GET/PUT` | `/v1/config` | config | Server config read/write |
279
+| `GET` | `/v1/config/history` | config | Config change history |
280
+| `GET/POST` | `/v1/admins` | admin | List / add admin accounts |
281
+| `DELETE` | `/v1/admins/{username}` | admin | Remove admin |
282
+| `PUT` | `/v1/admins/{username}/password` | admin | Change password |
283
+| `GET/POST` | `/v1/api-keys` | admin | List / create API keys |
284
+| `DELETE` | `/v1/api-keys/{id}` | admin | Revoke API key |
285
+| `GET/POST/PUT/DELETE` | `/v1/llm/backends[/{name}]` | bots | LLM backend CRUD |
286
+| `GET` | `/v1/llm/backends/{name}/models` | bots | List models for backend |
287
+| `POST` | `/v1/llm/discover` | bots | Discover models from provider |
288
+| `POST` | `/v1/llm/complete` | bots | LLM completion proxy |
258289
259290
---
260291
261292
## Adding a New Bot
262293
263294
1. Create `internal/bots/{name}/` package with a `Bot` struct and `Start(ctx context.Context) error` method
264
-2. Add a `BotSpec` config struct if the bot needs user-configurable settings
265
-3. Register in `internal/bots/manager/manager.go`:
295
+2. Set `+B` user mode on connect, handle INVITE for auto-join
296
+3. Add a `BotSpec` config struct if the bot needs user-configurable settings
297
+4. Register in `internal/bots/manager/manager.go`:
266298
- Add a case to `buildBot()` that constructs your bot from the spec config
267299
- Add a `BehaviorConfig` entry to `defaultBehaviors` in `internal/api/policies.go`
268
-4. Add the UI config schema to `BEHAVIOR_SCHEMAS` in `internal/api/ui/index.html`
269
-5. Write tests: bot logic, config parsing, edge cases. IRC connection can be skipped in unit tests.
270
-6. Update this bootstrap
300
+5. Add commands to `botCommands` map in `internal/api/policies.go` for the web UI command reference
301
+6. Add the UI config schema to `BEHAVIOR_SCHEMAS` in `internal/api/ui/index.html`
302
+7. Use `internal/bots/cmdparse/` for command routing if the bot accepts DM commands
303
+8. Write tests: bot logic, config parsing, edge cases. IRC connection can be skipped in unit tests.
304
+9. Update this bootstrap
271305
272306
No separate registration file or global registry. The manager builds bots by ID from the `BotSpec`. Bots satisfy the `bot` interface (unexported in manager package):
273307
274308
```go
275309
type bot interface {
@@ -315,14 +349,28 @@
315349
go build ./cmd/scuttlectl # build CLI
316350
go test ./... # run all tests
317351
golangci-lint run # lint
318352
319353
# Admin CLI
354
+scuttlectl status # server health
320355
scuttlectl admin list # list admin accounts
321356
scuttlectl admin add alice # add admin (prompts for password)
322357
scuttlectl admin passwd alice # change password
323358
scuttlectl admin remove alice # remove admin
359
+scuttlectl api-key list # list API keys
360
+scuttlectl api-key create --name "relay" --scopes chat,channels
361
+scuttlectl api-key revoke <id> # revoke key
362
+scuttlectl topology list # show channel types + static channels
363
+scuttlectl topology provision #channel # create channel
364
+scuttlectl topology drop #channel # remove channel
365
+scuttlectl config show # dump config JSON
366
+scuttlectl config history # config change history
367
+scuttlectl bot list # show system bot status
368
+scuttlectl agent list # list agents
369
+scuttlectl agent register <nick> --type worker --channels #fleet
370
+scuttlectl agent rotate <nick> # rotate credentials
371
+scuttlectl backend list # LLM backends
324372
325373
# Docker
326374
docker compose -f deploy/compose/docker-compose.yml up
327375
```
328376
329377
--- bootstrap.md
+++ bootstrap.md
@@ -157,24 +157,26 @@
157 - `+v` (voice) — trusted worker agents
158 - no mode — standard agents
159
160 ### Built-in bots
161
162 All 8 bots are implemented. Enabled/configured via the web UI or `scuttlectl`. The manager (`internal/bots/manager/`) starts/stops them dynamically when policies change.
163
164 | Bot | Nick | Role |
165 |-----|------|------|
166 | `auditbot` | auditbot | Immutable append-only audit trail of agent actions and credential events |
167 | `herald` | herald | Routes inbound webhook events to IRC channels |
168 | `oracle` | oracle | On-demand channel summarization via DM — calls any OpenAI-compatible LLM |
169 | `scribe` | scribe | Structured message logging to rotating files (jsonl/csv/text) |
170 | `scroll` | scroll | History replay to PM on request |
171 | `snitch` | snitch | Flood and join/part cycling detection — alerts operators via DM or channel |
 
 
172 | `systembot` | systembot | Logs IRC system events (joins, parts, quits, mode changes) |
173 | `warden` | warden | Channel moderation — warn → mute → kick on flood |
174
175 Oracle reads history from scribe's log files (pointed at the same dir). Configure `api_key_env` to the name of the env var holding the API key (e.g. `ORACLE_OPENAI_API_KEY`), and `base_url` for non-OpenAI providers.
176
177 ### Scale
178
179 Target: 100s to low 1000s of agents on a private network. Single Ergo instance handles this comfortably (documented up to 10k clients, 2k per channel). Ergo scales up (multi-core), not out — no horizontal clustering today. Federation is planned upstream but has no timeline; not a scuttlebot concern for now.
180
@@ -186,11 +188,12 @@
186 |------|------|-------|
187 | Agent registry | `data/ergo/registry.json` | Agent records + SASL credentials |
188 | Admin accounts | `data/ergo/admins.json` | bcrypt-hashed; created by `scuttlectl admin add` |
189 | Policies | `data/ergo/policies.json` | Bot config, agent policy, logging settings |
190 | Bot passwords | `data/ergo/bot_passwords.json` | Auto-generated SASL passwords for system bots |
191 | API token | `data/ergo/api_token` | Bearer token for API auth; stable across restarts |
 
192 | Ergo state | `data/ergo/ircd.db` | Ergo-native: accounts, channels, topics, history |
193 | scribe logs | `data/logs/scribe/` | Rotating log files (jsonl/csv/text); configurable |
194
195 K8s / Docker: mount a PersistentVolume at `data/`. Ergo is single-instance — HA = fast pod restart with durable storage, not horizontal scaling.
196
@@ -228,48 +231,79 @@
228 `internal/api/` — two-mux pattern:
229
230 - **Outer mux** (unauthenticated): `POST /login`, `GET /` (redirect), `GET /ui/` (web UI)
231 - **Inner mux** (`/v1/` routes): require `Authorization: Bearer <token>` header
232
233 The API token is a random hex string generated once at startup, persisted to `data/ergo/api_token`.
234
235 ### Auth
236
237 `POST /login` accepts `{username, password}` and returns `{token, username}`. The token is the shared server API token. Rate limited to 10 attempts per minute per IP.
 
 
 
 
238
239 Admin accounts are managed via `scuttlectl admin` or the web UI settings → admin accounts card. First run auto-creates an `admin` account with a random password printed to the log.
240
241 ### Key endpoints
242
243 | Method | Path | Description |
244 |--------|------|-------------|
245 | `POST` | `/login` | Username/password login (unauthenticated) |
246 | `GET` | `/v1/status` | Server status |
247 | `GET` | `/v1/metrics` | Runtime metrics + bridge stats |
248 | `GET/PUT` | `/v1/settings/policies` | Bot config, agent policy, logging |
249 | `GET` | `/v1/agents` | List all registered agents |
250 | `POST` | `/v1/agents/register` | Register an agent |
251 | `POST` | `/v1/agents/{nick}/rotate` | Rotate credentials |
252 | `POST` | `/v1/agents/{nick}/revoke` | Revoke agent |
253 | `GET` | `/v1/channels` | List joined channels |
254 | `GET` | `/v1/channels/{ch}/stream` | SSE stream of channel messages |
255 | `GET/POST` | `/v1/admins` | List / add admin accounts |
256 | `DELETE` | `/v1/admins/{username}` | Remove admin |
257 | `PUT` | `/v1/admins/{username}/password` | Change password |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
259 ---
260
261 ## Adding a New Bot
262
263 1. Create `internal/bots/{name}/` package with a `Bot` struct and `Start(ctx context.Context) error` method
264 2. Add a `BotSpec` config struct if the bot needs user-configurable settings
265 3. Register in `internal/bots/manager/manager.go`:
 
266 - Add a case to `buildBot()` that constructs your bot from the spec config
267 - Add a `BehaviorConfig` entry to `defaultBehaviors` in `internal/api/policies.go`
268 4. Add the UI config schema to `BEHAVIOR_SCHEMAS` in `internal/api/ui/index.html`
269 5. Write tests: bot logic, config parsing, edge cases. IRC connection can be skipped in unit tests.
270 6. Update this bootstrap
 
 
271
272 No separate registration file or global registry. The manager builds bots by ID from the `BotSpec`. Bots satisfy the `bot` interface (unexported in manager package):
273
274 ```go
275 type bot interface {
@@ -315,14 +349,28 @@
315 go build ./cmd/scuttlectl # build CLI
316 go test ./... # run all tests
317 golangci-lint run # lint
318
319 # Admin CLI
 
320 scuttlectl admin list # list admin accounts
321 scuttlectl admin add alice # add admin (prompts for password)
322 scuttlectl admin passwd alice # change password
323 scuttlectl admin remove alice # remove admin
 
 
 
 
 
 
 
 
 
 
 
 
 
324
325 # Docker
326 docker compose -f deploy/compose/docker-compose.yml up
327 ```
328
329
--- bootstrap.md
+++ bootstrap.md
@@ -157,24 +157,26 @@
157 - `+v` (voice) — trusted worker agents
158 - no mode — standard agents
159
160 ### Built-in bots
161
162 All 10 bots are implemented. Enabled/configured via the web UI or `scuttlectl bot list`. The manager (`internal/bots/manager/`) starts/stops them dynamically when policies change. All bots set `+B` (bot) user mode on connect and auto-accept INVITE.
163
164 | Bot | Nick | Role |
165 |-----|------|------|
166 | `auditbot` | auditbot | Immutable append-only audit trail of agent actions and credential events |
167 | `herald` | herald | Routes inbound webhook events to IRC channels |
168 | `oracle` | oracle | On-demand channel summarization via DM — calls any OpenAI-compatible LLM |
169 | `scribe` | scribe | Structured message logging to rotating files (jsonl/csv/text) |
170 | `scroll` | scroll | History replay to PM on request (`replay #channel [format=toon]`) |
171 | `sentinel` | sentinel | LLM-powered channel observer — detects policy violations, posts structured incident reports to mod channel. Never takes enforcement action. |
172 | `snitch` | snitch | Flood and join/part cycling detection, MONITOR-based presence tracking, away-notify alerts |
173 | `steward` | steward | Acts on sentinel incident reports — issues warnings, mutes (extended ban `m:`), or kicks based on severity |
174 | `systembot` | systembot | Logs IRC system events (joins, parts, quits, mode changes) |
175 | `warden` | warden | Channel moderation — warn → mute (extended ban) → kick on flood |
176
177 Oracle uses TOON format (`pkg/toon/`) for token-efficient LLM context. Scroll supports `format=toon` for compact replay output. Configure `api_key_env` to the name of the env var holding the API key (e.g. `ORACLE_OPENAI_API_KEY`), and `base_url` for non-OpenAI providers.
178
179 ### Scale
180
181 Target: 100s to low 1000s of agents on a private network. Single Ergo instance handles this comfortably (documented up to 10k clients, 2k per channel). Ergo scales up (multi-core), not out — no horizontal clustering today. Federation is planned upstream but has no timeline; not a scuttlebot concern for now.
182
@@ -186,11 +188,12 @@
188 |------|------|-------|
189 | Agent registry | `data/ergo/registry.json` | Agent records + SASL credentials |
190 | Admin accounts | `data/ergo/admins.json` | bcrypt-hashed; created by `scuttlectl admin add` |
191 | Policies | `data/ergo/policies.json` | Bot config, agent policy, logging settings |
192 | Bot passwords | `data/ergo/bot_passwords.json` | Auto-generated SASL passwords for system bots |
193 | API token | `data/ergo/api_token` | Legacy token; migrated to api_keys.json on first run |
194 | API keys | `data/ergo/api_keys.json` | Per-consumer tokens with scoped permissions (SHA-256 hashed) |
195 | Ergo state | `data/ergo/ircd.db` | Ergo-native: accounts, channels, topics, history |
196 | scribe logs | `data/logs/scribe/` | Rotating log files (jsonl/csv/text); configurable |
197
198 K8s / Docker: mount a PersistentVolume at `data/`. Ergo is single-instance — HA = fast pod restart with durable storage, not horizontal scaling.
199
@@ -228,48 +231,79 @@
231 `internal/api/` — two-mux pattern:
232
233 - **Outer mux** (unauthenticated): `POST /login`, `GET /` (redirect), `GET /ui/` (web UI)
234 - **Inner mux** (`/v1/` routes): require `Authorization: Bearer <token>` header
235
 
 
236 ### Auth
237
238 API keys are per-consumer tokens with scoped permissions. Each key has a name, scopes, optional expiry, and last-used tracking. Scopes: `admin`, `agents`, `channels`, `chat`, `topology`, `bots`, `config`, `read`. The `admin` scope implies all others.
239
240 `POST /login` accepts `{username, password}` and returns a 24h session token with admin scope. Rate limited to 10 attempts per minute per IP.
241
242 On first run, the legacy `api_token` file is migrated into `api_keys.json` as the first admin-scope key. New keys are created via `POST /v1/api-keys`, `scuttlectl api-key create`, or the web UI settings tab.
243
244 Admin accounts managed via `scuttlectl admin` or web UI. First run auto-creates `admin` with a random password printed to the log.
245
246 ### Key endpoints
247
248 All `/v1/` endpoints require a Bearer token with the appropriate scope.
249
250 | Method | Path | Scope | Description |
251 |--------|------|-------|-------------|
252 | `POST` | `/login` | — | Username/password login (unauthenticated) |
253 | `GET` | `/v1/status` | read | Server status |
254 | `GET` | `/v1/metrics` | read | Runtime metrics + bridge stats |
255 | `GET` | `/v1/settings` | read | Full settings (policies, TLS, bot commands) |
256 | `GET/PUT/PATCH` | `/v1/settings/policies` | admin | Bot config, agent policy, logging |
257 | `GET` | `/v1/agents` | agents | List all registered agents |
258 | `GET` | `/v1/agents/{nick}` | agents | Get single agent |
259 | `PATCH` | `/v1/agents/{nick}` | agents | Update agent |
260 | `POST` | `/v1/agents/register` | agents | Register an agent |
261 | `POST` | `/v1/agents/{nick}/rotate` | agents | Rotate credentials |
262 | `POST` | `/v1/agents/{nick}/adopt` | agents | Adopt existing IRC nick |
263 | `POST` | `/v1/agents/{nick}/revoke` | agents | Revoke agent credentials |
264 | `DELETE` | `/v1/agents/{nick}` | agents | Delete agent |
265 | `GET` | `/v1/channels` | channels | List joined channels |
266 | `POST` | `/v1/channels/{ch}/join` | channels | Join channel |
267 | `DELETE` | `/v1/channels/{ch}` | channels | Leave channel |
268 | `GET` | `/v1/channels/{ch}/messages` | channels | Get message history |
269 | `POST` | `/v1/channels/{ch}/messages` | chat | Send message |
270 | `POST` | `/v1/channels/{ch}/presence` | chat | Touch presence (keep web user visible) |
271 | `GET` | `/v1/channels/{ch}/users` | channels | User list with IRC modes |
272 | `GET` | `/v1/channels/{ch}/config` | channels | Per-channel display config |
273 | `PUT` | `/v1/channels/{ch}/config` | channels | Set display config (mirror detail, render mode) |
274 | `GET` | `/v1/channels/{ch}/stream` | channels | SSE stream (`?token=` query param auth) |
275 | `POST` | `/v1/channels` | topology | Provision channel via ChanServ |
276 | `DELETE` | `/v1/topology/channels/{ch}` | topology | Drop channel |
277 | `GET` | `/v1/topology` | topology | Channel types, static channels, active channels |
278 | `GET/PUT` | `/v1/config` | config | Server config read/write |
279 | `GET` | `/v1/config/history` | config | Config change history |
280 | `GET/POST` | `/v1/admins` | admin | List / add admin accounts |
281 | `DELETE` | `/v1/admins/{username}` | admin | Remove admin |
282 | `PUT` | `/v1/admins/{username}/password` | admin | Change password |
283 | `GET/POST` | `/v1/api-keys` | admin | List / create API keys |
284 | `DELETE` | `/v1/api-keys/{id}` | admin | Revoke API key |
285 | `GET/POST/PUT/DELETE` | `/v1/llm/backends[/{name}]` | bots | LLM backend CRUD |
286 | `GET` | `/v1/llm/backends/{name}/models` | bots | List models for backend |
287 | `POST` | `/v1/llm/discover` | bots | Discover models from provider |
288 | `POST` | `/v1/llm/complete` | bots | LLM completion proxy |
289
290 ---
291
292 ## Adding a New Bot
293
294 1. Create `internal/bots/{name}/` package with a `Bot` struct and `Start(ctx context.Context) error` method
295 2. Set `+B` user mode on connect, handle INVITE for auto-join
296 3. Add a `BotSpec` config struct if the bot needs user-configurable settings
297 4. Register in `internal/bots/manager/manager.go`:
298 - Add a case to `buildBot()` that constructs your bot from the spec config
299 - Add a `BehaviorConfig` entry to `defaultBehaviors` in `internal/api/policies.go`
300 5. Add commands to `botCommands` map in `internal/api/policies.go` for the web UI command reference
301 6. Add the UI config schema to `BEHAVIOR_SCHEMAS` in `internal/api/ui/index.html`
302 7. Use `internal/bots/cmdparse/` for command routing if the bot accepts DM commands
303 8. Write tests: bot logic, config parsing, edge cases. IRC connection can be skipped in unit tests.
304 9. Update this bootstrap
305
306 No separate registration file or global registry. The manager builds bots by ID from the `BotSpec`. Bots satisfy the `bot` interface (unexported in manager package):
307
308 ```go
309 type bot interface {
@@ -315,14 +349,28 @@
349 go build ./cmd/scuttlectl # build CLI
350 go test ./... # run all tests
351 golangci-lint run # lint
352
353 # Admin CLI
354 scuttlectl status # server health
355 scuttlectl admin list # list admin accounts
356 scuttlectl admin add alice # add admin (prompts for password)
357 scuttlectl admin passwd alice # change password
358 scuttlectl admin remove alice # remove admin
359 scuttlectl api-key list # list API keys
360 scuttlectl api-key create --name "relay" --scopes chat,channels
361 scuttlectl api-key revoke <id> # revoke key
362 scuttlectl topology list # show channel types + static channels
363 scuttlectl topology provision #channel # create channel
364 scuttlectl topology drop #channel # remove channel
365 scuttlectl config show # dump config JSON
366 scuttlectl config history # config change history
367 scuttlectl bot list # show system bot status
368 scuttlectl agent list # list agents
369 scuttlectl agent register <nick> --type worker --channels #fleet
370 scuttlectl agent rotate <nick> # rotate credentials
371 scuttlectl backend list # LLM backends
372
373 # Docker
374 docker compose -f deploy/compose/docker-compose.yml up
375 ```
376
377
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -138,18 +138,31 @@
138138
} else if err := reg.SetDataPath(filepath.Join(cfg.Ergo.DataDir, "registry.json")); err != nil {
139139
log.Error("registry load", "err", err)
140140
os.Exit(1)
141141
}
142142
143
- // Shared API token — persisted so the UI token survives restarts.
144
- apiToken, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "api_token"))
143
+ // API key store — per-consumer tokens with scoped permissions.
144
+ apiKeyStore, err := auth.NewAPIKeyStore(filepath.Join(cfg.Ergo.DataDir, "api_keys.json"))
145145
if err != nil {
146
- log.Error("api token", "err", err)
146
+ log.Error("api key store", "err", err)
147147
os.Exit(1)
148148
}
149
- log.Info("api token", "token", apiToken) // printed on every startup
150
- tokens := []string{apiToken}
149
+ // Migrate legacy api_token into key store on first run.
150
+ if apiKeyStore.IsEmpty() {
151
+ apiToken, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "api_token"))
152
+ if err != nil {
153
+ log.Error("api token", "err", err)
154
+ os.Exit(1)
155
+ }
156
+ if _, err := apiKeyStore.Insert("server", apiToken, []auth.Scope{auth.ScopeAdmin}); err != nil {
157
+ log.Error("migrate api token to key store", "err", err)
158
+ os.Exit(1)
159
+ }
160
+ log.Info("migrated api_token to api_keys.json", "token", apiToken)
161
+ } else {
162
+ log.Info("api key store loaded", "keys", len(apiKeyStore.List()))
163
+ }
151164
152165
// Start bridge bot (powers the web chat UI).
153166
var bridgeBot *bridge.Bot
154167
if cfg.Bridge.Enabled {
155168
if cfg.Bridge.Password == "" {
@@ -204,10 +217,11 @@
204217
Name: sc.Name,
205218
Topic: sc.Topic,
206219
Ops: sc.Ops,
207220
Voice: sc.Voice,
208221
Autojoin: sc.Autojoin,
222
+ Modes: sc.Modes,
209223
})
210224
}
211225
if err := topoMgr.Provision(staticChannels); err != nil {
212226
log.Error("topology provision failed", "err", err)
213227
}
@@ -230,10 +244,25 @@
230244
os.Exit(1)
231245
}
232246
}
233247
if bridgeBot != nil {
234248
bridgeBot.SetWebUserTTL(time.Duration(policyStore.Get().Bridge.WebUserTTLMinutes) * time.Minute)
249
+ // Deliver on-join instructions when agents join channels.
250
+ bridgeBot.SetOnUserJoin(func(channel, nick string) {
251
+ p := policyStore.Get()
252
+ msg, ok := p.OnJoinMessages[channel]
253
+ if !ok || msg == "" {
254
+ return
255
+ }
256
+ msg = strings.ReplaceAll(msg, "{nick}", nick)
257
+ msg = strings.ReplaceAll(msg, "{channel}", channel)
258
+ for _, line := range strings.Split(msg, "\n") {
259
+ if line != "" {
260
+ bridgeBot.Notice(nick, line)
261
+ }
262
+ }
263
+ })
235264
}
236265
237266
// Admin store — bcrypt-hashed admin accounts.
238267
adminStore, err := auth.NewAdminStore(filepath.Join(cfg.Ergo.DataDir, "admins.json"))
239268
if err != nil {
@@ -328,19 +357,28 @@
328357
staticChannels := make([]topology.ChannelConfig, 0, len(updated.Topology.Channels))
329358
for _, sc := range updated.Topology.Channels {
330359
staticChannels = append(staticChannels, topology.ChannelConfig{
331360
Name: sc.Name, Topic: sc.Topic,
332361
Ops: sc.Ops, Voice: sc.Voice, Autojoin: sc.Autojoin,
362
+ Modes: sc.Modes,
333363
})
334364
}
335365
if err := topoMgr.Provision(staticChannels); err != nil {
336366
log.Error("topology hot-reload failed", "err", err)
337367
}
338368
}
339369
// Hot-reload bridge web TTL.
340370
if bridgeBot != nil {
341371
bridgeBot.SetWebUserTTL(time.Duration(updated.Bridge.WebUserTTLMinutes) * time.Minute)
372
+ }
373
+ // Regenerate ircd.yaml and rehash Ergo on config changes.
374
+ if ergoMgr != nil {
375
+ if err := ergoMgr.UpdateConfig(updated.Ergo); err != nil {
376
+ log.Error("ergo config hot-reload failed", "err", err)
377
+ } else {
378
+ log.Info("ergo config reloaded")
379
+ }
342380
}
343381
})
344382
345383
// Start HTTP REST API server.
346384
var llmCfg *config.LLMConfig
@@ -352,11 +390,11 @@
352390
// non-nil (Go nil interface trap) and causes panics in setAgentModes.
353391
var topoIface api.TopologyManager
354392
if topoMgr != nil {
355393
topoIface = topoMgr
356394
}
357
- apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log)
395
+ apiSrv := api.New(reg, apiKeyStore, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log)
358396
handler := apiSrv.Handler()
359397
360398
var httpServer, tlsServer *http.Server
361399
362400
if cfg.TLS.Domain != "" {
@@ -418,11 +456,11 @@
418456
}
419457
}()
420458
}
421459
422460
// Start MCP server.
423
- mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, tokens, log)
461
+ mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, apiKeyStore, log)
424462
mcpServer := &http.Server{
425463
Addr: cfg.MCPAddr,
426464
Handler: mcpSrv.Handler(),
427465
}
428466
go func() {
429467
--- 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 }
@@ -230,10 +244,25 @@
230 os.Exit(1)
231 }
232 }
233 if bridgeBot != nil {
234 bridgeBot.SetWebUserTTL(time.Duration(policyStore.Get().Bridge.WebUserTTLMinutes) * time.Minute)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235 }
236
237 // Admin store — bcrypt-hashed admin accounts.
238 adminStore, err := auth.NewAdminStore(filepath.Join(cfg.Ergo.DataDir, "admins.json"))
239 if err != nil {
@@ -328,19 +357,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 +390,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 +456,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 }
@@ -230,10 +244,25 @@
244 os.Exit(1)
245 }
246 }
247 if bridgeBot != nil {
248 bridgeBot.SetWebUserTTL(time.Duration(policyStore.Get().Bridge.WebUserTTLMinutes) * time.Minute)
249 // Deliver on-join instructions when agents join channels.
250 bridgeBot.SetOnUserJoin(func(channel, nick string) {
251 p := policyStore.Get()
252 msg, ok := p.OnJoinMessages[channel]
253 if !ok || msg == "" {
254 return
255 }
256 msg = strings.ReplaceAll(msg, "{nick}", nick)
257 msg = strings.ReplaceAll(msg, "{channel}", channel)
258 for _, line := range strings.Split(msg, "\n") {
259 if line != "" {
260 bridgeBot.Notice(nick, line)
261 }
262 }
263 })
264 }
265
266 // Admin store — bcrypt-hashed admin accounts.
267 adminStore, err := auth.NewAdminStore(filepath.Join(cfg.Ergo.DataDir, "admins.json"))
268 if err != nil {
@@ -328,19 +357,28 @@
357 staticChannels := make([]topology.ChannelConfig, 0, len(updated.Topology.Channels))
358 for _, sc := range updated.Topology.Channels {
359 staticChannels = append(staticChannels, topology.ChannelConfig{
360 Name: sc.Name, Topic: sc.Topic,
361 Ops: sc.Ops, Voice: sc.Voice, Autojoin: sc.Autojoin,
362 Modes: sc.Modes,
363 })
364 }
365 if err := topoMgr.Provision(staticChannels); err != nil {
366 log.Error("topology hot-reload failed", "err", err)
367 }
368 }
369 // Hot-reload bridge web TTL.
370 if bridgeBot != nil {
371 bridgeBot.SetWebUserTTL(time.Duration(updated.Bridge.WebUserTTLMinutes) * time.Minute)
372 }
373 // Regenerate ircd.yaml and rehash Ergo on config changes.
374 if ergoMgr != nil {
375 if err := ergoMgr.UpdateConfig(updated.Ergo); err != nil {
376 log.Error("ergo config hot-reload failed", "err", err)
377 } else {
378 log.Info("ergo config reloaded")
379 }
380 }
381 })
382
383 // Start HTTP REST API server.
384 var llmCfg *config.LLMConfig
@@ -352,11 +390,11 @@
390 // non-nil (Go nil interface trap) and causes panics in setAgentModes.
391 var topoIface api.TopologyManager
392 if topoMgr != nil {
393 topoIface = topoMgr
394 }
395 apiSrv := api.New(reg, apiKeyStore, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log)
396 handler := apiSrv.Handler()
397
398 var httpServer, tlsServer *http.Server
399
400 if cfg.TLS.Domain != "" {
@@ -418,11 +456,11 @@
456 }
457 }()
458 }
459
460 // Start MCP server.
461 mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, apiKeyStore, log)
462 mcpServer := &http.Server{
463 Addr: cfg.MCPAddr,
464 Handler: mcpSrv.Handler(),
465 }
466 go func() {
467
--- cmd/scuttlectl/internal/apiclient/apiclient.go
+++ cmd/scuttlectl/internal/apiclient/apiclient.go
@@ -137,10 +137,61 @@
137137
// RemoveAdmin sends DELETE /v1/admins/{username}.
138138
func (c *Client) RemoveAdmin(username string) error {
139139
_, err := c.doNoBody("DELETE", "/v1/admins/"+username)
140140
return err
141141
}
142
+
143
+// ListAPIKeys returns GET /v1/api-keys.
144
+func (c *Client) ListAPIKeys() (json.RawMessage, error) {
145
+ return c.get("/v1/api-keys")
146
+}
147
+
148
+// CreateAPIKey sends POST /v1/api-keys.
149
+func (c *Client) CreateAPIKey(name string, scopes []string, expiresIn string) (json.RawMessage, error) {
150
+ body := map[string]any{"name": name, "scopes": scopes}
151
+ if expiresIn != "" {
152
+ body["expires_in"] = expiresIn
153
+ }
154
+ return c.post("/v1/api-keys", body)
155
+}
156
+
157
+// RevokeAPIKey sends DELETE /v1/api-keys/{id}.
158
+func (c *Client) RevokeAPIKey(id string) error {
159
+ _, err := c.doNoBody("DELETE", "/v1/api-keys/"+id)
160
+ return err
161
+}
162
+
163
+// GetTopology returns GET /v1/topology.
164
+func (c *Client) GetTopology() (json.RawMessage, error) {
165
+ return c.get("/v1/topology")
166
+}
167
+
168
+// ProvisionChannel sends POST /v1/channels.
169
+func (c *Client) ProvisionChannel(name string) (json.RawMessage, error) {
170
+ return c.post("/v1/channels", map[string]string{"name": name})
171
+}
172
+
173
+// DropChannel sends DELETE /v1/topology/channels/{channel}.
174
+func (c *Client) DropChannel(channel string) error {
175
+ _, err := c.doNoBody("DELETE", "/v1/topology/channels/"+strings.TrimPrefix(channel, "#"))
176
+ return err
177
+}
178
+
179
+// GetConfig returns GET /v1/config.
180
+func (c *Client) GetConfig() (json.RawMessage, error) {
181
+ return c.get("/v1/config")
182
+}
183
+
184
+// GetConfigHistory returns GET /v1/config/history.
185
+func (c *Client) GetConfigHistory() (json.RawMessage, error) {
186
+ return c.get("/v1/config/history")
187
+}
188
+
189
+// GetSettings returns GET /v1/settings.
190
+func (c *Client) GetSettings() (json.RawMessage, error) {
191
+ return c.get("/v1/settings")
192
+}
142193
143194
// SetAdminPassword sends PUT /v1/admins/{username}/password.
144195
func (c *Client) SetAdminPassword(username, password string) error {
145196
_, err := c.put("/v1/admins/"+username+"/password", map[string]string{"password": password})
146197
return err
147198
--- cmd/scuttlectl/internal/apiclient/apiclient.go
+++ cmd/scuttlectl/internal/apiclient/apiclient.go
@@ -137,10 +137,61 @@
137 // RemoveAdmin sends DELETE /v1/admins/{username}.
138 func (c *Client) RemoveAdmin(username string) error {
139 _, err := c.doNoBody("DELETE", "/v1/admins/"+username)
140 return err
141 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
143 // SetAdminPassword sends PUT /v1/admins/{username}/password.
144 func (c *Client) SetAdminPassword(username, password string) error {
145 _, err := c.put("/v1/admins/"+username+"/password", map[string]string{"password": password})
146 return err
147
--- cmd/scuttlectl/internal/apiclient/apiclient.go
+++ cmd/scuttlectl/internal/apiclient/apiclient.go
@@ -137,10 +137,61 @@
137 // RemoveAdmin sends DELETE /v1/admins/{username}.
138 func (c *Client) RemoveAdmin(username string) error {
139 _, err := c.doNoBody("DELETE", "/v1/admins/"+username)
140 return err
141 }
142
143 // ListAPIKeys returns GET /v1/api-keys.
144 func (c *Client) ListAPIKeys() (json.RawMessage, error) {
145 return c.get("/v1/api-keys")
146 }
147
148 // CreateAPIKey sends POST /v1/api-keys.
149 func (c *Client) CreateAPIKey(name string, scopes []string, expiresIn string) (json.RawMessage, error) {
150 body := map[string]any{"name": name, "scopes": scopes}
151 if expiresIn != "" {
152 body["expires_in"] = expiresIn
153 }
154 return c.post("/v1/api-keys", body)
155 }
156
157 // RevokeAPIKey sends DELETE /v1/api-keys/{id}.
158 func (c *Client) RevokeAPIKey(id string) error {
159 _, err := c.doNoBody("DELETE", "/v1/api-keys/"+id)
160 return err
161 }
162
163 // GetTopology returns GET /v1/topology.
164 func (c *Client) GetTopology() (json.RawMessage, error) {
165 return c.get("/v1/topology")
166 }
167
168 // ProvisionChannel sends POST /v1/channels.
169 func (c *Client) ProvisionChannel(name string) (json.RawMessage, error) {
170 return c.post("/v1/channels", map[string]string{"name": name})
171 }
172
173 // DropChannel sends DELETE /v1/topology/channels/{channel}.
174 func (c *Client) DropChannel(channel string) error {
175 _, err := c.doNoBody("DELETE", "/v1/topology/channels/"+strings.TrimPrefix(channel, "#"))
176 return err
177 }
178
179 // GetConfig returns GET /v1/config.
180 func (c *Client) GetConfig() (json.RawMessage, error) {
181 return c.get("/v1/config")
182 }
183
184 // GetConfigHistory returns GET /v1/config/history.
185 func (c *Client) GetConfigHistory() (json.RawMessage, error) {
186 return c.get("/v1/config/history")
187 }
188
189 // GetSettings returns GET /v1/settings.
190 func (c *Client) GetSettings() (json.RawMessage, error) {
191 return c.get("/v1/settings")
192 }
193
194 // SetAdminPassword sends PUT /v1/admins/{username}/password.
195 func (c *Client) SetAdminPassword(username, password string) error {
196 _, err := c.put("/v1/admins/"+username+"/password", map[string]string{"password": password})
197 return err
198
--- cmd/scuttlectl/main.go
+++ cmd/scuttlectl/main.go
@@ -108,10 +108,28 @@
108108
requireArgs(args, 3, "scuttlectl admin passwd <username>")
109109
cmdAdminPasswd(api, args[2])
110110
default:
111111
fmt.Fprintf(os.Stderr, "unknown subcommand: admin %s\n", args[1])
112112
os.Exit(1)
113
+ }
114
+ case "api-key", "api-keys":
115
+ if len(args) < 2 {
116
+ fmt.Fprintf(os.Stderr, "usage: scuttlectl api-key <list|create|revoke>\n")
117
+ os.Exit(1)
118
+ }
119
+ switch args[1] {
120
+ case "list":
121
+ cmdAPIKeyList(api, *jsonFlag)
122
+ case "create":
123
+ requireArgs(args, 3, "scuttlectl api-key create --name <name> --scopes <scope1,scope2>")
124
+ cmdAPIKeyCreate(api, args[2:], *jsonFlag)
125
+ case "revoke":
126
+ requireArgs(args, 3, "scuttlectl api-key revoke <id>")
127
+ cmdAPIKeyRevoke(api, args[2])
128
+ default:
129
+ fmt.Fprintf(os.Stderr, "unknown subcommand: api-key %s\n", args[1])
130
+ os.Exit(1)
113131
}
114132
case "channels", "channel":
115133
if len(args) < 2 {
116134
fmt.Fprintf(os.Stderr, "usage: scuttlectl channels <list|users <channel>>\n")
117135
os.Exit(1)
@@ -491,10 +509,88 @@
491509
fmt.Fprintf(tw, "password\t%s\n", creds.Password)
492510
fmt.Fprintf(tw, "server\t%s\n", creds.Server)
493511
tw.Flush()
494512
fmt.Println("\nStore this password — it will not be shown again.")
495513
}
514
+
515
+func cmdAPIKeyList(api *apiclient.Client, asJSON bool) {
516
+ raw, err := api.ListAPIKeys()
517
+ die(err)
518
+ if asJSON {
519
+ printJSON(raw)
520
+ return
521
+ }
522
+
523
+ var keys []struct {
524
+ ID string `json:"id"`
525
+ Name string `json:"name"`
526
+ Scopes []string `json:"scopes"`
527
+ CreatedAt string `json:"created_at"`
528
+ LastUsed *string `json:"last_used"`
529
+ ExpiresAt *string `json:"expires_at"`
530
+ Active bool `json:"active"`
531
+ }
532
+ must(json.Unmarshal(raw, &keys))
533
+
534
+ if len(keys) == 0 {
535
+ fmt.Println("no API keys")
536
+ return
537
+ }
538
+
539
+ tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
540
+ fmt.Fprintln(tw, "ID\tNAME\tSCOPES\tACTIVE\tLAST USED")
541
+ for _, k := range keys {
542
+ lastUsed := "-"
543
+ if k.LastUsed != nil {
544
+ lastUsed = *k.LastUsed
545
+ }
546
+ status := "yes"
547
+ if !k.Active {
548
+ status = "revoked"
549
+ }
550
+ fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", k.ID, k.Name, strings.Join(k.Scopes, ","), status, lastUsed)
551
+ }
552
+ tw.Flush()
553
+}
554
+
555
+func cmdAPIKeyCreate(api *apiclient.Client, args []string, asJSON bool) {
556
+ fs := flag.NewFlagSet("api-key create", flag.ExitOnError)
557
+ nameFlag := fs.String("name", "", "key name (required)")
558
+ scopesFlag := fs.String("scopes", "", "comma-separated scopes (required)")
559
+ expiresFlag := fs.String("expires", "", "expiry duration (e.g. 720h for 30 days)")
560
+ _ = fs.Parse(args)
561
+
562
+ if *nameFlag == "" || *scopesFlag == "" {
563
+ fmt.Fprintln(os.Stderr, "usage: scuttlectl api-key create --name <name> --scopes <scope1,scope2> [--expires 720h]")
564
+ os.Exit(1)
565
+ }
566
+
567
+ scopes := strings.Split(*scopesFlag, ",")
568
+ raw, err := api.CreateAPIKey(*nameFlag, scopes, *expiresFlag)
569
+ die(err)
570
+
571
+ if asJSON {
572
+ printJSON(raw)
573
+ return
574
+ }
575
+
576
+ var key struct {
577
+ ID string `json:"id"`
578
+ Name string `json:"name"`
579
+ Token string `json:"token"`
580
+ }
581
+ must(json.Unmarshal(raw, &key))
582
+
583
+ fmt.Printf("API key created: %s\n\n", key.Name)
584
+ fmt.Printf(" Token: %s\n\n", key.Token)
585
+ fmt.Println("Store this token — it will not be shown again.")
586
+}
587
+
588
+func cmdAPIKeyRevoke(api *apiclient.Client, id string) {
589
+ die(api.RevokeAPIKey(id))
590
+ fmt.Printf("API key revoked: %s\n", id)
591
+}
496592
497593
func usage() {
498594
fmt.Fprintf(os.Stderr, `scuttlectl %s — scuttlebot management CLI
499595
500596
Usage:
@@ -526,10 +622,13 @@
526622
backend rename <old> <new> rename a backend
527623
admin list list admin accounts
528624
admin add <username> add admin (prompts for password)
529625
admin remove <username> remove admin
530626
admin passwd <username> change admin password (prompts)
627
+ api-key list list API keys
628
+ api-key create --name <name> --scopes <s1,s2> [--expires 720h]
629
+ api-key revoke <id> revoke an API key
531630
`, version)
532631
}
533632
534633
func printJSON(raw json.RawMessage) {
535634
var buf []byte
536635
--- cmd/scuttlectl/main.go
+++ cmd/scuttlectl/main.go
@@ -108,10 +108,28 @@
108 requireArgs(args, 3, "scuttlectl admin passwd <username>")
109 cmdAdminPasswd(api, args[2])
110 default:
111 fmt.Fprintf(os.Stderr, "unknown subcommand: admin %s\n", args[1])
112 os.Exit(1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113 }
114 case "channels", "channel":
115 if len(args) < 2 {
116 fmt.Fprintf(os.Stderr, "usage: scuttlectl channels <list|users <channel>>\n")
117 os.Exit(1)
@@ -491,10 +509,88 @@
491 fmt.Fprintf(tw, "password\t%s\n", creds.Password)
492 fmt.Fprintf(tw, "server\t%s\n", creds.Server)
493 tw.Flush()
494 fmt.Println("\nStore this password — it will not be shown again.")
495 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
496
497 func usage() {
498 fmt.Fprintf(os.Stderr, `scuttlectl %s — scuttlebot management CLI
499
500 Usage:
@@ -526,10 +622,13 @@
526 backend rename <old> <new> rename a backend
527 admin list list admin accounts
528 admin add <username> add admin (prompts for password)
529 admin remove <username> remove admin
530 admin passwd <username> change admin password (prompts)
 
 
 
531 `, version)
532 }
533
534 func printJSON(raw json.RawMessage) {
535 var buf []byte
536
--- cmd/scuttlectl/main.go
+++ cmd/scuttlectl/main.go
@@ -108,10 +108,28 @@
108 requireArgs(args, 3, "scuttlectl admin passwd <username>")
109 cmdAdminPasswd(api, args[2])
110 default:
111 fmt.Fprintf(os.Stderr, "unknown subcommand: admin %s\n", args[1])
112 os.Exit(1)
113 }
114 case "api-key", "api-keys":
115 if len(args) < 2 {
116 fmt.Fprintf(os.Stderr, "usage: scuttlectl api-key <list|create|revoke>\n")
117 os.Exit(1)
118 }
119 switch args[1] {
120 case "list":
121 cmdAPIKeyList(api, *jsonFlag)
122 case "create":
123 requireArgs(args, 3, "scuttlectl api-key create --name <name> --scopes <scope1,scope2>")
124 cmdAPIKeyCreate(api, args[2:], *jsonFlag)
125 case "revoke":
126 requireArgs(args, 3, "scuttlectl api-key revoke <id>")
127 cmdAPIKeyRevoke(api, args[2])
128 default:
129 fmt.Fprintf(os.Stderr, "unknown subcommand: api-key %s\n", args[1])
130 os.Exit(1)
131 }
132 case "channels", "channel":
133 if len(args) < 2 {
134 fmt.Fprintf(os.Stderr, "usage: scuttlectl channels <list|users <channel>>\n")
135 os.Exit(1)
@@ -491,10 +509,88 @@
509 fmt.Fprintf(tw, "password\t%s\n", creds.Password)
510 fmt.Fprintf(tw, "server\t%s\n", creds.Server)
511 tw.Flush()
512 fmt.Println("\nStore this password — it will not be shown again.")
513 }
514
515 func cmdAPIKeyList(api *apiclient.Client, asJSON bool) {
516 raw, err := api.ListAPIKeys()
517 die(err)
518 if asJSON {
519 printJSON(raw)
520 return
521 }
522
523 var keys []struct {
524 ID string `json:"id"`
525 Name string `json:"name"`
526 Scopes []string `json:"scopes"`
527 CreatedAt string `json:"created_at"`
528 LastUsed *string `json:"last_used"`
529 ExpiresAt *string `json:"expires_at"`
530 Active bool `json:"active"`
531 }
532 must(json.Unmarshal(raw, &keys))
533
534 if len(keys) == 0 {
535 fmt.Println("no API keys")
536 return
537 }
538
539 tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
540 fmt.Fprintln(tw, "ID\tNAME\tSCOPES\tACTIVE\tLAST USED")
541 for _, k := range keys {
542 lastUsed := "-"
543 if k.LastUsed != nil {
544 lastUsed = *k.LastUsed
545 }
546 status := "yes"
547 if !k.Active {
548 status = "revoked"
549 }
550 fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", k.ID, k.Name, strings.Join(k.Scopes, ","), status, lastUsed)
551 }
552 tw.Flush()
553 }
554
555 func cmdAPIKeyCreate(api *apiclient.Client, args []string, asJSON bool) {
556 fs := flag.NewFlagSet("api-key create", flag.ExitOnError)
557 nameFlag := fs.String("name", "", "key name (required)")
558 scopesFlag := fs.String("scopes", "", "comma-separated scopes (required)")
559 expiresFlag := fs.String("expires", "", "expiry duration (e.g. 720h for 30 days)")
560 _ = fs.Parse(args)
561
562 if *nameFlag == "" || *scopesFlag == "" {
563 fmt.Fprintln(os.Stderr, "usage: scuttlectl api-key create --name <name> --scopes <scope1,scope2> [--expires 720h]")
564 os.Exit(1)
565 }
566
567 scopes := strings.Split(*scopesFlag, ",")
568 raw, err := api.CreateAPIKey(*nameFlag, scopes, *expiresFlag)
569 die(err)
570
571 if asJSON {
572 printJSON(raw)
573 return
574 }
575
576 var key struct {
577 ID string `json:"id"`
578 Name string `json:"name"`
579 Token string `json:"token"`
580 }
581 must(json.Unmarshal(raw, &key))
582
583 fmt.Printf("API key created: %s\n\n", key.Name)
584 fmt.Printf(" Token: %s\n\n", key.Token)
585 fmt.Println("Store this token — it will not be shown again.")
586 }
587
588 func cmdAPIKeyRevoke(api *apiclient.Client, id string) {
589 die(api.RevokeAPIKey(id))
590 fmt.Printf("API key revoked: %s\n", id)
591 }
592
593 func usage() {
594 fmt.Fprintf(os.Stderr, `scuttlectl %s — scuttlebot management CLI
595
596 Usage:
@@ -526,10 +622,13 @@
622 backend rename <old> <new> rename a backend
623 admin list list admin accounts
624 admin add <username> add admin (prompts for password)
625 admin remove <username> remove admin
626 admin passwd <username> change admin password (prompts)
627 api-key list list API keys
628 api-key create --name <name> --scopes <s1,s2> [--expires 720h]
629 api-key revoke <id> revoke an API key
630 `, version)
631 }
632
633 func printJSON(raw json.RawMessage) {
634 var buf []byte
635
--- deploy/compose/ergo/ircd.yaml.tmpl
+++ deploy/compose/ergo/ircd.yaml.tmpl
@@ -13,11 +13,13 @@
1313
enforce-utf8: true
1414
lookup-hostnames: false
1515
forward-confirm-hostnames: false
1616
check-ident: false
1717
relaymsg:
18
- enabled: false
18
+ enabled: true
19
+ separators: /
20
+ available-to-chanops: false
1921
ip-cloaking:
2022
enabled: false
2123
max-sendq: "1M"
2224
ip-limits:
2325
count-exempted: true
2426
--- deploy/compose/ergo/ircd.yaml.tmpl
+++ deploy/compose/ergo/ircd.yaml.tmpl
@@ -13,11 +13,13 @@
13 enforce-utf8: true
14 lookup-hostnames: false
15 forward-confirm-hostnames: false
16 check-ident: false
17 relaymsg:
18 enabled: false
 
 
19 ip-cloaking:
20 enabled: false
21 max-sendq: "1M"
22 ip-limits:
23 count-exempted: true
24
--- deploy/compose/ergo/ircd.yaml.tmpl
+++ deploy/compose/ergo/ircd.yaml.tmpl
@@ -13,11 +13,13 @@
13 enforce-utf8: true
14 lookup-hostnames: false
15 forward-confirm-hostnames: false
16 check-ident: false
17 relaymsg:
18 enabled: true
19 separators: /
20 available-to-chanops: false
21 ip-cloaking:
22 enabled: false
23 max-sendq: "1M"
24 ip-limits:
25 count-exempted: true
26
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -12,10 +12,11 @@
1212
Nick string `json:"nick"`
1313
Type registry.AgentType `json:"type"`
1414
Channels []string `json:"channels"`
1515
OpsChannels []string `json:"ops_channels,omitempty"`
1616
Permissions []string `json:"permissions"`
17
+ Skills []string `json:"skills,omitempty"`
1718
RateLimit *registry.RateLimitConfig `json:"rate_limit,omitempty"`
1819
Rules *registry.EngagementRules `json:"engagement,omitempty"`
1920
}
2021
2122
type registerResponse struct {
@@ -57,10 +58,17 @@
5758
s.log.Error("register agent", "nick", req.Nick, "err", err)
5859
writeError(w, http.StatusInternalServerError, "registration failed")
5960
return
6061
}
6162
63
+ // Set skills if provided.
64
+ if len(req.Skills) > 0 {
65
+ if agent, err := s.registry.Get(req.Nick); err == nil {
66
+ agent.Skills = req.Skills
67
+ _ = s.registry.Update(agent)
68
+ }
69
+ }
6270
s.registry.Touch(req.Nick)
6371
s.setAgentModes(req.Nick, req.Type, cfg)
6472
writeJSON(w, http.StatusCreated, registerResponse{
6573
Credentials: creds,
6674
Payload: payload,
@@ -149,10 +157,39 @@
149157
writeError(w, http.StatusInternalServerError, "deletion failed")
150158
return
151159
}
152160
w.WriteHeader(http.StatusNoContent)
153161
}
162
+
163
+// handleBulkDeleteAgents handles POST /v1/agents/bulk-delete.
164
+func (s *Server) handleBulkDeleteAgents(w http.ResponseWriter, r *http.Request) {
165
+ var req struct {
166
+ Nicks []string `json:"nicks"`
167
+ }
168
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
169
+ writeError(w, http.StatusBadRequest, "invalid request body")
170
+ return
171
+ }
172
+ if len(req.Nicks) == 0 {
173
+ writeError(w, http.StatusBadRequest, "nicks list is required")
174
+ return
175
+ }
176
+
177
+ var deleted, failed int
178
+ for _, nick := range req.Nicks {
179
+ if agent, err := s.registry.Get(nick); err == nil {
180
+ s.removeAgentModes(nick, agent.Channels)
181
+ }
182
+ if err := s.registry.Delete(nick); err != nil {
183
+ s.log.Warn("bulk delete: failed", "nick", nick, "err", err)
184
+ failed++
185
+ } else {
186
+ deleted++
187
+ }
188
+ }
189
+ writeJSON(w, http.StatusOK, map[string]int{"deleted": deleted, "failed": failed})
190
+}
154191
155192
func (s *Server) handleUpdateAgent(w http.ResponseWriter, r *http.Request) {
156193
nick := r.PathValue("nick")
157194
var req struct {
158195
Channels []string `json:"channels"`
@@ -174,10 +211,23 @@
174211
w.WriteHeader(http.StatusNoContent)
175212
}
176213
177214
func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) {
178215
agents := s.registry.List()
216
+ // Filter by skill if ?skill= query param is present.
217
+ if skill := r.URL.Query().Get("skill"); skill != "" {
218
+ filtered := make([]*registry.Agent, 0)
219
+ for _, a := range agents {
220
+ for _, s := range a.Skills {
221
+ if strings.EqualFold(s, skill) {
222
+ filtered = append(filtered, a)
223
+ break
224
+ }
225
+ }
226
+ }
227
+ agents = filtered
228
+ }
179229
writeJSON(w, http.StatusOK, map[string]any{"agents": agents})
180230
}
181231
182232
func (s *Server) handleGetAgent(w http.ResponseWriter, r *http.Request) {
183233
nick := r.PathValue("nick")
@@ -186,10 +236,41 @@
186236
writeError(w, http.StatusNotFound, err.Error())
187237
return
188238
}
189239
writeJSON(w, http.StatusOK, agent)
190240
}
241
+
242
+// handleAgentBlocker handles POST /v1/agents/{nick}/blocker.
243
+// Agents or relays call this to escalate that an agent is stuck.
244
+func (s *Server) handleAgentBlocker(w http.ResponseWriter, r *http.Request) {
245
+ nick := r.PathValue("nick")
246
+ var req struct {
247
+ Channel string `json:"channel,omitempty"`
248
+ Message string `json:"message"`
249
+ }
250
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
251
+ writeError(w, http.StatusBadRequest, "invalid request body")
252
+ return
253
+ }
254
+ if req.Message == "" {
255
+ writeError(w, http.StatusBadRequest, "message is required")
256
+ return
257
+ }
258
+
259
+ alert := "[blocker] " + nick
260
+ if req.Channel != "" {
261
+ alert += " in " + req.Channel
262
+ }
263
+ alert += ": " + req.Message
264
+
265
+ // Post to #ops if bridge is available.
266
+ if s.bridge != nil {
267
+ _ = s.bridge.Send(r.Context(), "#ops", alert, "")
268
+ }
269
+ s.log.Warn("agent blocker", "nick", nick, "channel", req.Channel, "message", req.Message)
270
+ w.WriteHeader(http.StatusNoContent)
271
+}
191272
192273
// agentModeLevel maps an agent type to the ChanServ access level it should
193274
// receive. Returns "" for types that get no special mode.
194275
func agentModeLevel(t registry.AgentType) string {
195276
switch t {
196277
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -12,10 +12,11 @@
12 Nick string `json:"nick"`
13 Type registry.AgentType `json:"type"`
14 Channels []string `json:"channels"`
15 OpsChannels []string `json:"ops_channels,omitempty"`
16 Permissions []string `json:"permissions"`
 
17 RateLimit *registry.RateLimitConfig `json:"rate_limit,omitempty"`
18 Rules *registry.EngagementRules `json:"engagement,omitempty"`
19 }
20
21 type registerResponse struct {
@@ -57,10 +58,17 @@
57 s.log.Error("register agent", "nick", req.Nick, "err", err)
58 writeError(w, http.StatusInternalServerError, "registration failed")
59 return
60 }
61
 
 
 
 
 
 
 
62 s.registry.Touch(req.Nick)
63 s.setAgentModes(req.Nick, req.Type, cfg)
64 writeJSON(w, http.StatusCreated, registerResponse{
65 Credentials: creds,
66 Payload: payload,
@@ -149,10 +157,39 @@
149 writeError(w, http.StatusInternalServerError, "deletion failed")
150 return
151 }
152 w.WriteHeader(http.StatusNoContent)
153 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
155 func (s *Server) handleUpdateAgent(w http.ResponseWriter, r *http.Request) {
156 nick := r.PathValue("nick")
157 var req struct {
158 Channels []string `json:"channels"`
@@ -174,10 +211,23 @@
174 w.WriteHeader(http.StatusNoContent)
175 }
176
177 func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) {
178 agents := s.registry.List()
 
 
 
 
 
 
 
 
 
 
 
 
 
179 writeJSON(w, http.StatusOK, map[string]any{"agents": agents})
180 }
181
182 func (s *Server) handleGetAgent(w http.ResponseWriter, r *http.Request) {
183 nick := r.PathValue("nick")
@@ -186,10 +236,41 @@
186 writeError(w, http.StatusNotFound, err.Error())
187 return
188 }
189 writeJSON(w, http.StatusOK, agent)
190 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
192 // agentModeLevel maps an agent type to the ChanServ access level it should
193 // receive. Returns "" for types that get no special mode.
194 func agentModeLevel(t registry.AgentType) string {
195 switch t {
196
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -12,10 +12,11 @@
12 Nick string `json:"nick"`
13 Type registry.AgentType `json:"type"`
14 Channels []string `json:"channels"`
15 OpsChannels []string `json:"ops_channels,omitempty"`
16 Permissions []string `json:"permissions"`
17 Skills []string `json:"skills,omitempty"`
18 RateLimit *registry.RateLimitConfig `json:"rate_limit,omitempty"`
19 Rules *registry.EngagementRules `json:"engagement,omitempty"`
20 }
21
22 type registerResponse struct {
@@ -57,10 +58,17 @@
58 s.log.Error("register agent", "nick", req.Nick, "err", err)
59 writeError(w, http.StatusInternalServerError, "registration failed")
60 return
61 }
62
63 // Set skills if provided.
64 if len(req.Skills) > 0 {
65 if agent, err := s.registry.Get(req.Nick); err == nil {
66 agent.Skills = req.Skills
67 _ = s.registry.Update(agent)
68 }
69 }
70 s.registry.Touch(req.Nick)
71 s.setAgentModes(req.Nick, req.Type, cfg)
72 writeJSON(w, http.StatusCreated, registerResponse{
73 Credentials: creds,
74 Payload: payload,
@@ -149,10 +157,39 @@
157 writeError(w, http.StatusInternalServerError, "deletion failed")
158 return
159 }
160 w.WriteHeader(http.StatusNoContent)
161 }
162
163 // handleBulkDeleteAgents handles POST /v1/agents/bulk-delete.
164 func (s *Server) handleBulkDeleteAgents(w http.ResponseWriter, r *http.Request) {
165 var req struct {
166 Nicks []string `json:"nicks"`
167 }
168 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
169 writeError(w, http.StatusBadRequest, "invalid request body")
170 return
171 }
172 if len(req.Nicks) == 0 {
173 writeError(w, http.StatusBadRequest, "nicks list is required")
174 return
175 }
176
177 var deleted, failed int
178 for _, nick := range req.Nicks {
179 if agent, err := s.registry.Get(nick); err == nil {
180 s.removeAgentModes(nick, agent.Channels)
181 }
182 if err := s.registry.Delete(nick); err != nil {
183 s.log.Warn("bulk delete: failed", "nick", nick, "err", err)
184 failed++
185 } else {
186 deleted++
187 }
188 }
189 writeJSON(w, http.StatusOK, map[string]int{"deleted": deleted, "failed": failed})
190 }
191
192 func (s *Server) handleUpdateAgent(w http.ResponseWriter, r *http.Request) {
193 nick := r.PathValue("nick")
194 var req struct {
195 Channels []string `json:"channels"`
@@ -174,10 +211,23 @@
211 w.WriteHeader(http.StatusNoContent)
212 }
213
214 func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) {
215 agents := s.registry.List()
216 // Filter by skill if ?skill= query param is present.
217 if skill := r.URL.Query().Get("skill"); skill != "" {
218 filtered := make([]*registry.Agent, 0)
219 for _, a := range agents {
220 for _, s := range a.Skills {
221 if strings.EqualFold(s, skill) {
222 filtered = append(filtered, a)
223 break
224 }
225 }
226 }
227 agents = filtered
228 }
229 writeJSON(w, http.StatusOK, map[string]any{"agents": agents})
230 }
231
232 func (s *Server) handleGetAgent(w http.ResponseWriter, r *http.Request) {
233 nick := r.PathValue("nick")
@@ -186,10 +236,41 @@
236 writeError(w, http.StatusNotFound, err.Error())
237 return
238 }
239 writeJSON(w, http.StatusOK, agent)
240 }
241
242 // handleAgentBlocker handles POST /v1/agents/{nick}/blocker.
243 // Agents or relays call this to escalate that an agent is stuck.
244 func (s *Server) handleAgentBlocker(w http.ResponseWriter, r *http.Request) {
245 nick := r.PathValue("nick")
246 var req struct {
247 Channel string `json:"channel,omitempty"`
248 Message string `json:"message"`
249 }
250 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
251 writeError(w, http.StatusBadRequest, "invalid request body")
252 return
253 }
254 if req.Message == "" {
255 writeError(w, http.StatusBadRequest, "message is required")
256 return
257 }
258
259 alert := "[blocker] " + nick
260 if req.Channel != "" {
261 alert += " in " + req.Channel
262 }
263 alert += ": " + req.Message
264
265 // Post to #ops if bridge is available.
266 if s.bridge != nil {
267 _ = s.bridge.Send(r.Context(), "#ops", alert, "")
268 }
269 s.log.Warn("agent blocker", "nick", nick, "channel", req.Channel, "message", req.Message)
270 w.WriteHeader(http.StatusNoContent)
271 }
272
273 // agentModeLevel maps an agent type to the ChanServ access level it should
274 // receive. Returns "" for types that get no special mode.
275 func agentModeLevel(t registry.AgentType) string {
276 switch t {
277
--- internal/api/api_test.go
+++ internal/api/api_test.go
@@ -8,10 +8,11 @@
88
"net/http/httptest"
99
"sync"
1010
"testing"
1111
1212
"github.com/conflicthq/scuttlebot/internal/api"
13
+ "github.com/conflicthq/scuttlebot/internal/auth"
1314
"github.com/conflicthq/scuttlebot/internal/registry"
1415
"log/slog"
1516
"os"
1617
)
1718
@@ -50,11 +51,11 @@
5051
const testToken = "test-api-token-abc123"
5152
5253
func newTestServer(t *testing.T) *httptest.Server {
5354
t.Helper()
5455
reg := registry.New(newMock(), []byte("test-signing-key"))
55
- srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog)
56
+ srv := api.New(reg, auth.TestStore(testToken), nil, nil, nil, nil, nil, nil, "", testLog)
5657
return httptest.NewServer(srv.Handler())
5758
}
5859
5960
func authHeader() http.Header {
6061
h := http.Header{}
6162
6263
ADDED internal/api/apikeys.go
--- internal/api/api_test.go
+++ internal/api/api_test.go
@@ -8,10 +8,11 @@
8 "net/http/httptest"
9 "sync"
10 "testing"
11
12 "github.com/conflicthq/scuttlebot/internal/api"
 
13 "github.com/conflicthq/scuttlebot/internal/registry"
14 "log/slog"
15 "os"
16 )
17
@@ -50,11 +51,11 @@
50 const testToken = "test-api-token-abc123"
51
52 func newTestServer(t *testing.T) *httptest.Server {
53 t.Helper()
54 reg := registry.New(newMock(), []byte("test-signing-key"))
55 srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog)
56 return httptest.NewServer(srv.Handler())
57 }
58
59 func authHeader() http.Header {
60 h := http.Header{}
61
62 DDED internal/api/apikeys.go
--- internal/api/api_test.go
+++ internal/api/api_test.go
@@ -8,10 +8,11 @@
8 "net/http/httptest"
9 "sync"
10 "testing"
11
12 "github.com/conflicthq/scuttlebot/internal/api"
13 "github.com/conflicthq/scuttlebot/internal/auth"
14 "github.com/conflicthq/scuttlebot/internal/registry"
15 "log/slog"
16 "os"
17 )
18
@@ -50,11 +51,11 @@
51 const testToken = "test-api-token-abc123"
52
53 func newTestServer(t *testing.T) *httptest.Server {
54 t.Helper()
55 reg := registry.New(newMock(), []byte("test-signing-key"))
56 srv := api.New(reg, auth.TestStore(testToken), nil, nil, nil, nil, nil, nil, "", testLog)
57 return httptest.NewServer(srv.Handler())
58 }
59
60 func authHeader() http.Header {
61 h := http.Header{}
62
63 DDED internal/api/apikeys.go
--- a/internal/api/apikeys.go
+++ b/internal/api/apikeys.go
@@ -0,0 +1,125 @@
1
+package api
2
+
3
+import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "time"
7
+
8
+ "github.com/conflicthq/scuttlebot/internal/auth"
9
+)
10
+
11
+type createAPIKeyRequest struct {
12
+ Name string `json:"name"`
13
+ Scopes []string `json:"scopes"`
14
+ ExpiresIn string `json:"expires_in,omitempty"` // e.g. "720h" for 30 days, empty = never
15
+}
16
+
17
+type createAPIKeyResponse struct {
18
+ ID string `json:"id"`
19
+ Name string `json:"name"`
20
+ Token string `json:"token"` // plaintext, shown only once
21
+ Scopes []auth.Scope `json:"scopes"`
22
+ CreatedAt time.Time `json:"created_at"`
23
+ ExpiresAt *time.Time `json:"expires_at,omitempty"`
24
+}
25
+
26
+type apiKeyListEntry struct {
27
+ ID string `json:"id"`
28
+ Name string `json:"name"`
29
+ Scopes []auth.Scope `json:"scopes"`
30
+ CreatedAt time.Time `json:"created_at"`
31
+ LastUsed *time.Time `json:"last_used,omitempty"`
32
+ ExpiresAt *time.Time `json:"expires_at,omitempty"`
33
+ Active bool `json:"active"`
34
+}
35
+
36
+// handleListAPIKeys handles GET /v1/api-keys.
37
+func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
38
+ keys := s.apiKeys.List()
39
+ out := make([]apiKeyListEntry, len(keys))
40
+ for i, k := range keys {
41
+ out[i] = apiKeyListEntry{
42
+ ID: k.ID,
43
+ Name: k.Name,
44
+ Scopes: k.Scopes,
45
+ CreatedAt: k.CreatedAt,
46
+ Active: k.Active,
47
+ }
48
+ if !k.LastUsed.IsZero() {
49
+ t := k.LastUsed
50
+ out[i].LastUsed = &t
51
+ }
52
+ if !k.ExpiresAt.IsZero() {
53
+ t := k.ExpiresAt
54
+ out[i].ExpiresAt = &t
55
+ }
56
+ }
57
+ writeJSON(w, http.StatusOK, out)
58
+}
59
+
60
+// handleCreateAPIKey handles POST /v1/api-keys.
61
+func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
62
+ var req createAPIKeyRequest
63
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
64
+ writeError(w, http.StatusBadRequest, "invalid request body")
65
+ return
66
+ }
67
+ if req.Name == "" {
68
+ writeError(w, http.StatusBadRequest, "name is required")
69
+ return
70
+ }
71
+
72
+ scopes := make([]auth.Scope, len(req.Scopes))
73
+ for i, s := range req.Scopes {
74
+ scope := auth.Scope(s)
75
+ if !auth.ValidScopes[scope] {
76
+ writeError(w, http.StatusBadRequest, "unknown scope: "+s)
77
+ return
78
+ }
79
+ scopes[i] = scope
80
+ }
81
+ if len(scopes) == 0 {
82
+ writeError(w, http.StatusBadRequest, "at least one scope is required")
83
+ return
84
+ }
85
+
86
+ var expiresAt time.Time
87
+ if req.ExpiresIn != "" {
88
+ dur, err := time.ParseDuration(req.ExpiresIn)
89
+ if err != nil {
90
+ writeError(w, http.StatusBadRequest, "invalid expires_in duration: "+err.Error())
91
+ return
92
+ }
93
+ expiresAt = time.Now().Add(dur)
94
+ }
95
+
96
+ token, key, err := s.apiKeys.Create(req.Name, scopes, expiresAt)
97
+ if err != nil {
98
+ s.log.Error("create api key", "err", err)
99
+ writeError(w, http.StatusInternalServerError, "failed to create API key")
100
+ return
101
+ }
102
+
103
+ resp := createAPIKeyResponse{
104
+ ID: key.ID,
105
+ Name: key.Name,
106
+ Token: token,
107
+ Scopes: key.Scopes,
108
+ CreatedAt: key.CreatedAt,
109
+ }
110
+ if !key.ExpiresAt.IsZero() {
111
+ t := key.ExpiresAt
112
+ resp.ExpiresAt = &t
113
+ }
114
+ writeJSON(w, http.StatusCreated, resp)
115
+}
116
+
117
+// handleRevokeAPIKey handles DELETE /v1/api-keys/{id}.
118
+func (s *Server) handleRevokeAPIKey(w http.ResponseWriter, r *http.Request) {
119
+ id := r.PathValue("id")
120
+ if err := s.apiKeys.Revoke(id); err != nil {
121
+ writeError(w, http.StatusNotFound, err.Error())
122
+ return
123
+ }
124
+ w.WriteHeader(http.StatusNoContent)
125
+}
--- a/internal/api/apikeys.go
+++ b/internal/api/apikeys.go
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/api/apikeys.go
+++ b/internal/api/apikeys.go
@@ -0,0 +1,125 @@
1 package api
2
3 import (
4 "encoding/json"
5 "net/http"
6 "time"
7
8 "github.com/conflicthq/scuttlebot/internal/auth"
9 )
10
11 type createAPIKeyRequest struct {
12 Name string `json:"name"`
13 Scopes []string `json:"scopes"`
14 ExpiresIn string `json:"expires_in,omitempty"` // e.g. "720h" for 30 days, empty = never
15 }
16
17 type createAPIKeyResponse struct {
18 ID string `json:"id"`
19 Name string `json:"name"`
20 Token string `json:"token"` // plaintext, shown only once
21 Scopes []auth.Scope `json:"scopes"`
22 CreatedAt time.Time `json:"created_at"`
23 ExpiresAt *time.Time `json:"expires_at,omitempty"`
24 }
25
26 type apiKeyListEntry struct {
27 ID string `json:"id"`
28 Name string `json:"name"`
29 Scopes []auth.Scope `json:"scopes"`
30 CreatedAt time.Time `json:"created_at"`
31 LastUsed *time.Time `json:"last_used,omitempty"`
32 ExpiresAt *time.Time `json:"expires_at,omitempty"`
33 Active bool `json:"active"`
34 }
35
36 // handleListAPIKeys handles GET /v1/api-keys.
37 func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
38 keys := s.apiKeys.List()
39 out := make([]apiKeyListEntry, len(keys))
40 for i, k := range keys {
41 out[i] = apiKeyListEntry{
42 ID: k.ID,
43 Name: k.Name,
44 Scopes: k.Scopes,
45 CreatedAt: k.CreatedAt,
46 Active: k.Active,
47 }
48 if !k.LastUsed.IsZero() {
49 t := k.LastUsed
50 out[i].LastUsed = &t
51 }
52 if !k.ExpiresAt.IsZero() {
53 t := k.ExpiresAt
54 out[i].ExpiresAt = &t
55 }
56 }
57 writeJSON(w, http.StatusOK, out)
58 }
59
60 // handleCreateAPIKey handles POST /v1/api-keys.
61 func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
62 var req createAPIKeyRequest
63 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
64 writeError(w, http.StatusBadRequest, "invalid request body")
65 return
66 }
67 if req.Name == "" {
68 writeError(w, http.StatusBadRequest, "name is required")
69 return
70 }
71
72 scopes := make([]auth.Scope, len(req.Scopes))
73 for i, s := range req.Scopes {
74 scope := auth.Scope(s)
75 if !auth.ValidScopes[scope] {
76 writeError(w, http.StatusBadRequest, "unknown scope: "+s)
77 return
78 }
79 scopes[i] = scope
80 }
81 if len(scopes) == 0 {
82 writeError(w, http.StatusBadRequest, "at least one scope is required")
83 return
84 }
85
86 var expiresAt time.Time
87 if req.ExpiresIn != "" {
88 dur, err := time.ParseDuration(req.ExpiresIn)
89 if err != nil {
90 writeError(w, http.StatusBadRequest, "invalid expires_in duration: "+err.Error())
91 return
92 }
93 expiresAt = time.Now().Add(dur)
94 }
95
96 token, key, err := s.apiKeys.Create(req.Name, scopes, expiresAt)
97 if err != nil {
98 s.log.Error("create api key", "err", err)
99 writeError(w, http.StatusInternalServerError, "failed to create API key")
100 return
101 }
102
103 resp := createAPIKeyResponse{
104 ID: key.ID,
105 Name: key.Name,
106 Token: token,
107 Scopes: key.Scopes,
108 CreatedAt: key.CreatedAt,
109 }
110 if !key.ExpiresAt.IsZero() {
111 t := key.ExpiresAt
112 resp.ExpiresAt = &t
113 }
114 writeJSON(w, http.StatusCreated, resp)
115 }
116
117 // handleRevokeAPIKey handles DELETE /v1/api-keys/{id}.
118 func (s *Server) handleRevokeAPIKey(w http.ResponseWriter, r *http.Request) {
119 id := r.PathValue("id")
120 if err := s.apiKeys.Revoke(id); err != nil {
121 writeError(w, http.StatusNotFound, err.Error())
122 return
123 }
124 w.WriteHeader(http.StatusNoContent)
125 }
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -14,18 +14,21 @@
1414
ProvisionChannel(ch topology.ChannelConfig) error
1515
DropChannel(channel string)
1616
Policy() *topology.Policy
1717
GrantAccess(nick, channel, level string)
1818
RevokeAccess(nick, channel string)
19
+ ListChannels() []topology.ChannelInfo
1920
}
2021
2122
type provisionChannelRequest struct {
22
- Name string `json:"name"`
23
- Topic string `json:"topic,omitempty"`
24
- Ops []string `json:"ops,omitempty"`
25
- Voice []string `json:"voice,omitempty"`
26
- Autojoin []string `json:"autojoin,omitempty"`
23
+ Name string `json:"name"`
24
+ Topic string `json:"topic,omitempty"`
25
+ Ops []string `json:"ops,omitempty"`
26
+ Voice []string `json:"voice,omitempty"`
27
+ Autojoin []string `json:"autojoin,omitempty"`
28
+ Instructions string `json:"instructions,omitempty"`
29
+ MirrorDetail string `json:"mirror_detail,omitempty"`
2730
}
2831
2932
type provisionChannelResponse struct {
3033
Channel string `json:"channel"`
3134
Type string `json:"type,omitempty"`
@@ -51,28 +54,51 @@
5154
return
5255
}
5356
5457
policy := s.topoMgr.Policy()
5558
56
- // Merge autojoin from policy if the caller didn't specify any.
59
+ // Merge autojoin and modes from policy if the caller didn't specify any.
5760
autojoin := req.Autojoin
5861
if len(autojoin) == 0 && policy != nil {
5962
autojoin = policy.AutojoinFor(req.Name)
6063
}
64
+ var modes []string
65
+ if policy != nil {
66
+ modes = policy.ModesFor(req.Name)
67
+ }
6168
6269
ch := topology.ChannelConfig{
6370
Name: req.Name,
6471
Topic: req.Topic,
6572
Ops: req.Ops,
6673
Voice: req.Voice,
6774
Autojoin: autojoin,
75
+ Modes: modes,
6876
}
6977
if err := s.topoMgr.ProvisionChannel(ch); err != nil {
7078
s.log.Error("provision channel", "channel", req.Name, "err", err)
7179
writeError(w, http.StatusInternalServerError, "provision failed")
7280
return
7381
}
82
+
83
+ // Save instructions to policies if provided.
84
+ if req.Instructions != "" && s.policies != nil {
85
+ p := s.policies.Get()
86
+ if p.OnJoinMessages == nil {
87
+ p.OnJoinMessages = make(map[string]string)
88
+ }
89
+ p.OnJoinMessages[req.Name] = req.Instructions
90
+ if req.MirrorDetail != "" {
91
+ if p.Bridge.ChannelDisplay == nil {
92
+ p.Bridge.ChannelDisplay = make(map[string]ChannelDisplayConfig)
93
+ }
94
+ cfg := p.Bridge.ChannelDisplay[req.Name]
95
+ cfg.MirrorDetail = req.MirrorDetail
96
+ p.Bridge.ChannelDisplay[req.Name] = cfg
97
+ }
98
+ _ = s.policies.Set(p)
99
+ }
74100
75101
resp := provisionChannelResponse{
76102
Channel: req.Name,
77103
Autojoin: autojoin,
78104
}
@@ -91,12 +117,13 @@
91117
Ephemeral bool `json:"ephemeral,omitempty"`
92118
TTLSeconds int64 `json:"ttl_seconds,omitempty"`
93119
}
94120
95121
type topologyResponse struct {
96
- StaticChannels []string `json:"static_channels"`
97
- Types []channelTypeInfo `json:"types"`
122
+ StaticChannels []string `json:"static_channels"`
123
+ Types []channelTypeInfo `json:"types"`
124
+ ActiveChannels []topology.ChannelInfo `json:"active_channels,omitempty"`
98125
}
99126
100127
// handleDropChannel handles DELETE /v1/topology/channels/{channel}.
101128
// Drops the ChanServ registration of an ephemeral channel.
102129
func (s *Server) handleDropChannel(w http.ResponseWriter, r *http.Request) {
@@ -110,10 +137,64 @@
110137
return
111138
}
112139
s.topoMgr.DropChannel(channel)
113140
w.WriteHeader(http.StatusNoContent)
114141
}
142
+
143
+// handleGetInstructions handles GET /v1/channels/{channel}/instructions.
144
+func (s *Server) handleGetInstructions(w http.ResponseWriter, r *http.Request) {
145
+ channel := "#" + r.PathValue("channel")
146
+ if s.policies == nil {
147
+ writeJSON(w, http.StatusOK, map[string]string{"channel": channel, "instructions": ""})
148
+ return
149
+ }
150
+ p := s.policies.Get()
151
+ msg := p.OnJoinMessages[channel]
152
+ writeJSON(w, http.StatusOK, map[string]string{"channel": channel, "instructions": msg})
153
+}
154
+
155
+// handlePutInstructions handles PUT /v1/channels/{channel}/instructions.
156
+func (s *Server) handlePutInstructions(w http.ResponseWriter, r *http.Request) {
157
+ channel := "#" + r.PathValue("channel")
158
+ if s.policies == nil {
159
+ writeError(w, http.StatusServiceUnavailable, "policies not configured")
160
+ return
161
+ }
162
+ var req struct {
163
+ Instructions string `json:"instructions"`
164
+ }
165
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
166
+ writeError(w, http.StatusBadRequest, "invalid request body")
167
+ return
168
+ }
169
+ p := s.policies.Get()
170
+ if p.OnJoinMessages == nil {
171
+ p.OnJoinMessages = make(map[string]string)
172
+ }
173
+ p.OnJoinMessages[channel] = req.Instructions
174
+ if err := s.policies.Set(p); err != nil {
175
+ writeError(w, http.StatusInternalServerError, "save failed")
176
+ return
177
+ }
178
+ w.WriteHeader(http.StatusNoContent)
179
+}
180
+
181
+// handleDeleteInstructions handles DELETE /v1/channels/{channel}/instructions.
182
+func (s *Server) handleDeleteInstructions(w http.ResponseWriter, r *http.Request) {
183
+ channel := "#" + r.PathValue("channel")
184
+ if s.policies == nil {
185
+ writeError(w, http.StatusServiceUnavailable, "policies not configured")
186
+ return
187
+ }
188
+ p := s.policies.Get()
189
+ delete(p.OnJoinMessages, channel)
190
+ if err := s.policies.Set(p); err != nil {
191
+ writeError(w, http.StatusInternalServerError, "save failed")
192
+ return
193
+ }
194
+ w.WriteHeader(http.StatusNoContent)
195
+}
115196
116197
// handleGetTopology handles GET /v1/topology.
117198
// Returns the channel type rules and static channel names declared in config.
118199
func (s *Server) handleGetTopology(w http.ResponseWriter, r *http.Request) {
119200
if s.topoMgr == nil {
@@ -146,7 +227,8 @@
146227
}
147228
148229
writeJSON(w, http.StatusOK, topologyResponse{
149230
StaticChannels: staticNames,
150231
Types: typeInfos,
232
+ ActiveChannels: s.topoMgr.ListChannels(),
151233
})
152234
}
153235
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -14,18 +14,21 @@
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"`
24 Ops []string `json:"ops,omitempty"`
25 Voice []string `json:"voice,omitempty"`
26 Autojoin []string `json:"autojoin,omitempty"`
 
 
27 }
28
29 type provisionChannelResponse struct {
30 Channel string `json:"channel"`
31 Type string `json:"type,omitempty"`
@@ -51,28 +54,51 @@
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
73 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
75 resp := provisionChannelResponse{
76 Channel: req.Name,
77 Autojoin: autojoin,
78 }
@@ -91,12 +117,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) {
@@ -110,10 +137,64 @@
110 return
111 }
112 s.topoMgr.DropChannel(channel)
113 w.WriteHeader(http.StatusNoContent)
114 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
116 // handleGetTopology handles GET /v1/topology.
117 // Returns the channel type rules and static channel names declared in config.
118 func (s *Server) handleGetTopology(w http.ResponseWriter, r *http.Request) {
119 if s.topoMgr == nil {
@@ -146,7 +227,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,18 +14,21 @@
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"`
25 Ops []string `json:"ops,omitempty"`
26 Voice []string `json:"voice,omitempty"`
27 Autojoin []string `json:"autojoin,omitempty"`
28 Instructions string `json:"instructions,omitempty"`
29 MirrorDetail string `json:"mirror_detail,omitempty"`
30 }
31
32 type provisionChannelResponse struct {
33 Channel string `json:"channel"`
34 Type string `json:"type,omitempty"`
@@ -51,28 +54,51 @@
54 return
55 }
56
57 policy := s.topoMgr.Policy()
58
59 // Merge autojoin and modes from policy if the caller didn't specify any.
60 autojoin := req.Autojoin
61 if len(autojoin) == 0 && policy != nil {
62 autojoin = policy.AutojoinFor(req.Name)
63 }
64 var modes []string
65 if policy != nil {
66 modes = policy.ModesFor(req.Name)
67 }
68
69 ch := topology.ChannelConfig{
70 Name: req.Name,
71 Topic: req.Topic,
72 Ops: req.Ops,
73 Voice: req.Voice,
74 Autojoin: autojoin,
75 Modes: modes,
76 }
77 if err := s.topoMgr.ProvisionChannel(ch); err != nil {
78 s.log.Error("provision channel", "channel", req.Name, "err", err)
79 writeError(w, http.StatusInternalServerError, "provision failed")
80 return
81 }
82
83 // Save instructions to policies if provided.
84 if req.Instructions != "" && s.policies != nil {
85 p := s.policies.Get()
86 if p.OnJoinMessages == nil {
87 p.OnJoinMessages = make(map[string]string)
88 }
89 p.OnJoinMessages[req.Name] = req.Instructions
90 if req.MirrorDetail != "" {
91 if p.Bridge.ChannelDisplay == nil {
92 p.Bridge.ChannelDisplay = make(map[string]ChannelDisplayConfig)
93 }
94 cfg := p.Bridge.ChannelDisplay[req.Name]
95 cfg.MirrorDetail = req.MirrorDetail
96 p.Bridge.ChannelDisplay[req.Name] = cfg
97 }
98 _ = s.policies.Set(p)
99 }
100
101 resp := provisionChannelResponse{
102 Channel: req.Name,
103 Autojoin: autojoin,
104 }
@@ -91,12 +117,13 @@
117 Ephemeral bool `json:"ephemeral,omitempty"`
118 TTLSeconds int64 `json:"ttl_seconds,omitempty"`
119 }
120
121 type topologyResponse struct {
122 StaticChannels []string `json:"static_channels"`
123 Types []channelTypeInfo `json:"types"`
124 ActiveChannels []topology.ChannelInfo `json:"active_channels,omitempty"`
125 }
126
127 // handleDropChannel handles DELETE /v1/topology/channels/{channel}.
128 // Drops the ChanServ registration of an ephemeral channel.
129 func (s *Server) handleDropChannel(w http.ResponseWriter, r *http.Request) {
@@ -110,10 +137,64 @@
137 return
138 }
139 s.topoMgr.DropChannel(channel)
140 w.WriteHeader(http.StatusNoContent)
141 }
142
143 // handleGetInstructions handles GET /v1/channels/{channel}/instructions.
144 func (s *Server) handleGetInstructions(w http.ResponseWriter, r *http.Request) {
145 channel := "#" + r.PathValue("channel")
146 if s.policies == nil {
147 writeJSON(w, http.StatusOK, map[string]string{"channel": channel, "instructions": ""})
148 return
149 }
150 p := s.policies.Get()
151 msg := p.OnJoinMessages[channel]
152 writeJSON(w, http.StatusOK, map[string]string{"channel": channel, "instructions": msg})
153 }
154
155 // handlePutInstructions handles PUT /v1/channels/{channel}/instructions.
156 func (s *Server) handlePutInstructions(w http.ResponseWriter, r *http.Request) {
157 channel := "#" + r.PathValue("channel")
158 if s.policies == nil {
159 writeError(w, http.StatusServiceUnavailable, "policies not configured")
160 return
161 }
162 var req struct {
163 Instructions string `json:"instructions"`
164 }
165 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
166 writeError(w, http.StatusBadRequest, "invalid request body")
167 return
168 }
169 p := s.policies.Get()
170 if p.OnJoinMessages == nil {
171 p.OnJoinMessages = make(map[string]string)
172 }
173 p.OnJoinMessages[channel] = req.Instructions
174 if err := s.policies.Set(p); err != nil {
175 writeError(w, http.StatusInternalServerError, "save failed")
176 return
177 }
178 w.WriteHeader(http.StatusNoContent)
179 }
180
181 // handleDeleteInstructions handles DELETE /v1/channels/{channel}/instructions.
182 func (s *Server) handleDeleteInstructions(w http.ResponseWriter, r *http.Request) {
183 channel := "#" + r.PathValue("channel")
184 if s.policies == nil {
185 writeError(w, http.StatusServiceUnavailable, "policies not configured")
186 return
187 }
188 p := s.policies.Get()
189 delete(p.OnJoinMessages, channel)
190 if err := s.policies.Set(p); err != nil {
191 writeError(w, http.StatusInternalServerError, "save failed")
192 return
193 }
194 w.WriteHeader(http.StatusNoContent)
195 }
196
197 // handleGetTopology handles GET /v1/topology.
198 // Returns the channel type rules and static channel names declared in config.
199 func (s *Server) handleGetTopology(w http.ResponseWriter, r *http.Request) {
200 if s.topoMgr == nil {
@@ -146,7 +227,8 @@
227 }
228
229 writeJSON(w, http.StatusOK, topologyResponse{
230 StaticChannels: staticNames,
231 Types: typeInfos,
232 ActiveChannels: s.topoMgr.ListChannels(),
233 })
234 }
235
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -9,10 +9,11 @@
99
"net/http"
1010
"net/http/httptest"
1111
"testing"
1212
"time"
1313
14
+ "github.com/conflicthq/scuttlebot/internal/auth"
1415
"github.com/conflicthq/scuttlebot/internal/config"
1516
"github.com/conflicthq/scuttlebot/internal/registry"
1617
"github.com/conflicthq/scuttlebot/internal/topology"
1718
)
1819
@@ -48,10 +49,12 @@
4849
4950
func (s *stubTopologyManager) RevokeAccess(nick, channel string) {
5051
s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel})
5152
}
5253
54
+func (s *stubTopologyManager) ListChannels() []topology.ChannelInfo { return nil }
55
+
5356
// stubProvisioner is a minimal AccountProvisioner for agent registration tests.
5457
type stubProvisioner struct {
5558
accounts map[string]string
5659
}
5760
@@ -74,11 +77,11 @@
7477
7578
func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
7679
t.Helper()
7780
reg := registry.New(nil, []byte("key"))
7881
log := slog.New(slog.NewTextHandler(io.Discard, nil))
79
- srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler())
82
+ srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler())
8083
t.Cleanup(srv.Close)
8184
return srv, "tok"
8285
}
8386
8487
// newTopoTestServerWithRegistry creates a test server with both topology and a
@@ -85,11 +88,11 @@
8588
// real registry backed by stubProvisioner, so agent registration works.
8689
func newTopoTestServerWithRegistry(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
8790
t.Helper()
8891
reg := registry.New(newStubProvisioner(), []byte("key"))
8992
log := slog.New(slog.NewTextHandler(io.Discard, nil))
90
- srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler())
93
+ srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler())
9194
t.Cleanup(srv.Close)
9295
return srv, "tok"
9396
}
9497
9598
func TestHandleProvisionChannel(t *testing.T) {
9699
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -9,10 +9,11 @@
9 "net/http"
10 "net/http/httptest"
11 "testing"
12 "time"
13
 
14 "github.com/conflicthq/scuttlebot/internal/config"
15 "github.com/conflicthq/scuttlebot/internal/registry"
16 "github.com/conflicthq/scuttlebot/internal/topology"
17 )
18
@@ -48,10 +49,12 @@
48
49 func (s *stubTopologyManager) RevokeAccess(nick, channel string) {
50 s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel})
51 }
52
 
 
53 // stubProvisioner is a minimal AccountProvisioner for agent registration tests.
54 type stubProvisioner struct {
55 accounts map[string]string
56 }
57
@@ -74,11 +77,11 @@
74
75 func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
76 t.Helper()
77 reg := registry.New(nil, []byte("key"))
78 log := slog.New(slog.NewTextHandler(io.Discard, nil))
79 srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler())
80 t.Cleanup(srv.Close)
81 return srv, "tok"
82 }
83
84 // newTopoTestServerWithRegistry creates a test server with both topology and a
@@ -85,11 +88,11 @@
85 // real registry backed by stubProvisioner, so agent registration works.
86 func newTopoTestServerWithRegistry(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
87 t.Helper()
88 reg := registry.New(newStubProvisioner(), []byte("key"))
89 log := slog.New(slog.NewTextHandler(io.Discard, nil))
90 srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler())
91 t.Cleanup(srv.Close)
92 return srv, "tok"
93 }
94
95 func TestHandleProvisionChannel(t *testing.T) {
96
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -9,10 +9,11 @@
9 "net/http"
10 "net/http/httptest"
11 "testing"
12 "time"
13
14 "github.com/conflicthq/scuttlebot/internal/auth"
15 "github.com/conflicthq/scuttlebot/internal/config"
16 "github.com/conflicthq/scuttlebot/internal/registry"
17 "github.com/conflicthq/scuttlebot/internal/topology"
18 )
19
@@ -48,10 +49,12 @@
49
50 func (s *stubTopologyManager) RevokeAccess(nick, channel string) {
51 s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel})
52 }
53
54 func (s *stubTopologyManager) ListChannels() []topology.ChannelInfo { return nil }
55
56 // stubProvisioner is a minimal AccountProvisioner for agent registration tests.
57 type stubProvisioner struct {
58 accounts map[string]string
59 }
60
@@ -74,11 +77,11 @@
77
78 func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
79 t.Helper()
80 reg := registry.New(nil, []byte("key"))
81 log := slog.New(slog.NewTextHandler(io.Discard, nil))
82 srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler())
83 t.Cleanup(srv.Close)
84 return srv, "tok"
85 }
86
87 // newTopoTestServerWithRegistry creates a test server with both topology and a
@@ -85,11 +88,11 @@
88 // real registry backed by stubProvisioner, so agent registration works.
89 func newTopoTestServerWithRegistry(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
90 t.Helper()
91 reg := registry.New(newStubProvisioner(), []byte("key"))
92 log := slog.New(slog.NewTextHandler(io.Discard, nil))
93 srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler())
94 t.Cleanup(srv.Close)
95 return srv, "tok"
96 }
97
98 func TestHandleProvisionChannel(t *testing.T) {
99
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -5,10 +5,11 @@
55
"encoding/json"
66
"fmt"
77
"net/http"
88
"time"
99
10
+ "github.com/conflicthq/scuttlebot/internal/auth"
1011
"github.com/conflicthq/scuttlebot/internal/bots/bridge"
1112
)
1213
1314
// chatBridge is the interface the API layer requires from the bridge bot.
1415
type chatBridge interface {
@@ -20,10 +21,12 @@
2021
Send(ctx context.Context, channel, text, senderNick string) error
2122
SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *bridge.Meta) error
2223
Stats() bridge.Stats
2324
TouchUser(channel, nick string)
2425
Users(channel string) []string
26
+ UsersWithModes(channel string) []bridge.UserInfo
27
+ ChannelModes(channel string) string
2528
}
2629
2730
func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) {
2831
channel := "#" + r.PathValue("channel")
2932
s.bridge.JoinChannel(channel)
@@ -107,22 +110,58 @@
107110
w.WriteHeader(http.StatusNoContent)
108111
}
109112
110113
func (s *Server) handleChannelUsers(w http.ResponseWriter, r *http.Request) {
111114
channel := "#" + r.PathValue("channel")
112
- users := s.bridge.Users(channel)
115
+ users := s.bridge.UsersWithModes(channel)
113116
if users == nil {
114
- users = []string{}
117
+ users = []bridge.UserInfo{}
118
+ }
119
+ modes := s.bridge.ChannelModes(channel)
120
+ writeJSON(w, http.StatusOK, map[string]any{"users": users, "channel_modes": modes})
121
+}
122
+
123
+func (s *Server) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) {
124
+ channel := "#" + r.PathValue("channel")
125
+ if s.policies == nil {
126
+ writeJSON(w, http.StatusOK, ChannelDisplayConfig{})
127
+ return
128
+ }
129
+ p := s.policies.Get()
130
+ cfg := p.Bridge.ChannelDisplay[channel]
131
+ writeJSON(w, http.StatusOK, cfg)
132
+}
133
+
134
+func (s *Server) handlePutChannelConfig(w http.ResponseWriter, r *http.Request) {
135
+ channel := "#" + r.PathValue("channel")
136
+ if s.policies == nil {
137
+ writeError(w, http.StatusServiceUnavailable, "policies not configured")
138
+ return
139
+ }
140
+ var cfg ChannelDisplayConfig
141
+ if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
142
+ writeError(w, http.StatusBadRequest, "invalid request body")
143
+ return
144
+ }
145
+ p := s.policies.Get()
146
+ if p.Bridge.ChannelDisplay == nil {
147
+ p.Bridge.ChannelDisplay = make(map[string]ChannelDisplayConfig)
148
+ }
149
+ p.Bridge.ChannelDisplay[channel] = cfg
150
+ if err := s.policies.Set(p); err != nil {
151
+ writeError(w, http.StatusInternalServerError, "save failed")
152
+ return
115153
}
116
- writeJSON(w, http.StatusOK, map[string]any{"users": users})
154
+ w.WriteHeader(http.StatusNoContent)
117155
}
118156
119157
// handleChannelStream serves an SSE stream of IRC messages for a channel.
120158
// Auth is via ?token= query param because EventSource doesn't support custom headers.
121159
func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) {
122160
token := r.URL.Query().Get("token")
123
- if _, ok := s.tokens[token]; !ok {
161
+ key := s.apiKeys.Lookup(token)
162
+ if key == nil || (!key.HasScope(auth.ScopeChannels) && !key.HasScope(auth.ScopeChat)) {
124163
writeError(w, http.StatusUnauthorized, "invalid or missing token")
125164
return
126165
}
127166
128167
channel := "#" + r.PathValue("channel")
129168
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -5,10 +5,11 @@
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "time"
9
 
10 "github.com/conflicthq/scuttlebot/internal/bots/bridge"
11 )
12
13 // chatBridge is the interface the API layer requires from the bridge bot.
14 type chatBridge interface {
@@ -20,10 +21,12 @@
20 Send(ctx context.Context, channel, text, senderNick string) error
21 SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *bridge.Meta) error
22 Stats() bridge.Stats
23 TouchUser(channel, nick string)
24 Users(channel string) []string
 
 
25 }
26
27 func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) {
28 channel := "#" + r.PathValue("channel")
29 s.bridge.JoinChannel(channel)
@@ -107,22 +110,58 @@
107 w.WriteHeader(http.StatusNoContent)
108 }
109
110 func (s *Server) handleChannelUsers(w http.ResponseWriter, r *http.Request) {
111 channel := "#" + r.PathValue("channel")
112 users := s.bridge.Users(channel)
113 if users == nil {
114 users = []string{}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115 }
116 writeJSON(w, http.StatusOK, map[string]any{"users": users})
117 }
118
119 // handleChannelStream serves an SSE stream of IRC messages for a channel.
120 // Auth is via ?token= query param because EventSource doesn't support custom headers.
121 func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) {
122 token := r.URL.Query().Get("token")
123 if _, ok := s.tokens[token]; !ok {
 
124 writeError(w, http.StatusUnauthorized, "invalid or missing token")
125 return
126 }
127
128 channel := "#" + r.PathValue("channel")
129
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -5,10 +5,11 @@
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "time"
9
10 "github.com/conflicthq/scuttlebot/internal/auth"
11 "github.com/conflicthq/scuttlebot/internal/bots/bridge"
12 )
13
14 // chatBridge is the interface the API layer requires from the bridge bot.
15 type chatBridge interface {
@@ -20,10 +21,12 @@
21 Send(ctx context.Context, channel, text, senderNick string) error
22 SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *bridge.Meta) error
23 Stats() bridge.Stats
24 TouchUser(channel, nick string)
25 Users(channel string) []string
26 UsersWithModes(channel string) []bridge.UserInfo
27 ChannelModes(channel string) string
28 }
29
30 func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) {
31 channel := "#" + r.PathValue("channel")
32 s.bridge.JoinChannel(channel)
@@ -107,22 +110,58 @@
110 w.WriteHeader(http.StatusNoContent)
111 }
112
113 func (s *Server) handleChannelUsers(w http.ResponseWriter, r *http.Request) {
114 channel := "#" + r.PathValue("channel")
115 users := s.bridge.UsersWithModes(channel)
116 if users == nil {
117 users = []bridge.UserInfo{}
118 }
119 modes := s.bridge.ChannelModes(channel)
120 writeJSON(w, http.StatusOK, map[string]any{"users": users, "channel_modes": modes})
121 }
122
123 func (s *Server) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) {
124 channel := "#" + r.PathValue("channel")
125 if s.policies == nil {
126 writeJSON(w, http.StatusOK, ChannelDisplayConfig{})
127 return
128 }
129 p := s.policies.Get()
130 cfg := p.Bridge.ChannelDisplay[channel]
131 writeJSON(w, http.StatusOK, cfg)
132 }
133
134 func (s *Server) handlePutChannelConfig(w http.ResponseWriter, r *http.Request) {
135 channel := "#" + r.PathValue("channel")
136 if s.policies == nil {
137 writeError(w, http.StatusServiceUnavailable, "policies not configured")
138 return
139 }
140 var cfg ChannelDisplayConfig
141 if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
142 writeError(w, http.StatusBadRequest, "invalid request body")
143 return
144 }
145 p := s.policies.Get()
146 if p.Bridge.ChannelDisplay == nil {
147 p.Bridge.ChannelDisplay = make(map[string]ChannelDisplayConfig)
148 }
149 p.Bridge.ChannelDisplay[channel] = cfg
150 if err := s.policies.Set(p); err != nil {
151 writeError(w, http.StatusInternalServerError, "save failed")
152 return
153 }
154 w.WriteHeader(http.StatusNoContent)
155 }
156
157 // handleChannelStream serves an SSE stream of IRC messages for a channel.
158 // Auth is via ?token= query param because EventSource doesn't support custom headers.
159 func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) {
160 token := r.URL.Query().Get("token")
161 key := s.apiKeys.Lookup(token)
162 if key == nil || (!key.HasScope(auth.ScopeChannels) && !key.HasScope(auth.ScopeChat)) {
163 writeError(w, http.StatusUnauthorized, "invalid or missing token")
164 return
165 }
166
167 channel := "#" + r.PathValue("channel")
168
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -8,10 +8,11 @@
88
"log/slog"
99
"net/http"
1010
"net/http/httptest"
1111
"testing"
1212
13
+ "github.com/conflicthq/scuttlebot/internal/auth"
1314
"github.com/conflicthq/scuttlebot/internal/bots/bridge"
1415
"github.com/conflicthq/scuttlebot/internal/registry"
1516
)
1617
1718
type stubChatBridge struct {
@@ -30,12 +31,14 @@
3031
}
3132
func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil }
3233
func (b *stubChatBridge) SendWithMeta(_ context.Context, _, _, _ string, _ *bridge.Meta) error {
3334
return nil
3435
}
35
-func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} }
36
-func (b *stubChatBridge) Users(string) []string { return nil }
36
+func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} }
37
+func (b *stubChatBridge) Users(string) []string { return nil }
38
+func (b *stubChatBridge) UsersWithModes(string) []bridge.UserInfo { return nil }
39
+func (b *stubChatBridge) ChannelModes(string) string { return "" }
3740
func (b *stubChatBridge) TouchUser(channel, nick string) {
3841
b.touched = append(b.touched, struct{ channel, nick string }{channel: channel, nick: nick})
3942
}
4043
4144
func TestHandleChannelPresence(t *testing.T) {
@@ -42,11 +45,11 @@
4245
t.Helper()
4346
4447
bridgeStub := &stubChatBridge{}
4548
reg := registry.New(nil, []byte("test-signing-key"))
4649
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
47
- srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
50
+ srv := httptest.NewServer(New(reg, auth.TestStore("token"), bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
4851
defer srv.Close()
4952
5053
body, _ := json.Marshal(map[string]string{"nick": "codex-test"})
5154
req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
5255
if err != nil {
@@ -75,11 +78,11 @@
7578
t.Helper()
7679
7780
bridgeStub := &stubChatBridge{}
7881
reg := registry.New(nil, []byte("test-signing-key"))
7982
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
80
- srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
83
+ srv := httptest.NewServer(New(reg, auth.TestStore("token"), bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
8184
defer srv.Close()
8285
8386
body, _ := json.Marshal(map[string]string{})
8487
req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
8588
if err != nil {
8689
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -8,10 +8,11 @@
8 "log/slog"
9 "net/http"
10 "net/http/httptest"
11 "testing"
12
 
13 "github.com/conflicthq/scuttlebot/internal/bots/bridge"
14 "github.com/conflicthq/scuttlebot/internal/registry"
15 )
16
17 type stubChatBridge struct {
@@ -30,12 +31,14 @@
30 }
31 func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil }
32 func (b *stubChatBridge) SendWithMeta(_ context.Context, _, _, _ string, _ *bridge.Meta) error {
33 return nil
34 }
35 func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} }
36 func (b *stubChatBridge) Users(string) []string { return nil }
 
 
37 func (b *stubChatBridge) TouchUser(channel, nick string) {
38 b.touched = append(b.touched, struct{ channel, nick string }{channel: channel, nick: nick})
39 }
40
41 func TestHandleChannelPresence(t *testing.T) {
@@ -42,11 +45,11 @@
42 t.Helper()
43
44 bridgeStub := &stubChatBridge{}
45 reg := registry.New(nil, []byte("test-signing-key"))
46 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
47 srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
48 defer srv.Close()
49
50 body, _ := json.Marshal(map[string]string{"nick": "codex-test"})
51 req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
52 if err != nil {
@@ -75,11 +78,11 @@
75 t.Helper()
76
77 bridgeStub := &stubChatBridge{}
78 reg := registry.New(nil, []byte("test-signing-key"))
79 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
80 srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
81 defer srv.Close()
82
83 body, _ := json.Marshal(map[string]string{})
84 req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
85 if err != nil {
86
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -8,10 +8,11 @@
8 "log/slog"
9 "net/http"
10 "net/http/httptest"
11 "testing"
12
13 "github.com/conflicthq/scuttlebot/internal/auth"
14 "github.com/conflicthq/scuttlebot/internal/bots/bridge"
15 "github.com/conflicthq/scuttlebot/internal/registry"
16 )
17
18 type stubChatBridge struct {
@@ -30,12 +31,14 @@
31 }
32 func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil }
33 func (b *stubChatBridge) SendWithMeta(_ context.Context, _, _, _ string, _ *bridge.Meta) error {
34 return nil
35 }
36 func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} }
37 func (b *stubChatBridge) Users(string) []string { return nil }
38 func (b *stubChatBridge) UsersWithModes(string) []bridge.UserInfo { return nil }
39 func (b *stubChatBridge) ChannelModes(string) string { return "" }
40 func (b *stubChatBridge) TouchUser(channel, nick string) {
41 b.touched = append(b.touched, struct{ channel, nick string }{channel: channel, nick: nick})
42 }
43
44 func TestHandleChannelPresence(t *testing.T) {
@@ -42,11 +45,11 @@
45 t.Helper()
46
47 bridgeStub := &stubChatBridge{}
48 reg := registry.New(nil, []byte("test-signing-key"))
49 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
50 srv := httptest.NewServer(New(reg, auth.TestStore("token"), bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
51 defer srv.Close()
52
53 body, _ := json.Marshal(map[string]string{"nick": "codex-test"})
54 req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
55 if err != nil {
@@ -75,11 +78,11 @@
78 t.Helper()
79
80 bridgeStub := &stubChatBridge{}
81 reg := registry.New(nil, []byte("test-signing-key"))
82 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
83 srv := httptest.NewServer(New(reg, auth.TestStore("token"), bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler())
84 defer srv.Close()
85
86 body, _ := json.Marshal(map[string]string{})
87 req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body))
88 if err != nil {
89
--- internal/api/config_handlers_test.go
+++ internal/api/config_handlers_test.go
@@ -9,10 +9,11 @@
99
"net/http/httptest"
1010
"path/filepath"
1111
"testing"
1212
"time"
1313
14
+ "github.com/conflicthq/scuttlebot/internal/auth"
1415
"github.com/conflicthq/scuttlebot/internal/config"
1516
"github.com/conflicthq/scuttlebot/internal/registry"
1617
)
1718
1819
func newCfgTestServer(t *testing.T) (*httptest.Server, *ConfigStore) {
@@ -25,11 +26,11 @@
2526
cfg.Ergo.DataDir = dir
2627
2728
store := NewConfigStore(path, cfg)
2829
reg := registry.New(nil, []byte("key"))
2930
log := slog.New(slog.NewTextHandler(io.Discard, nil))
30
- srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, nil, store, "", log).Handler())
31
+ srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, nil, store, "", log).Handler())
3132
t.Cleanup(srv.Close)
3233
return srv, store
3334
}
3435
3536
func TestHandleGetConfig(t *testing.T) {
3637
--- internal/api/config_handlers_test.go
+++ internal/api/config_handlers_test.go
@@ -9,10 +9,11 @@
9 "net/http/httptest"
10 "path/filepath"
11 "testing"
12 "time"
13
 
14 "github.com/conflicthq/scuttlebot/internal/config"
15 "github.com/conflicthq/scuttlebot/internal/registry"
16 )
17
18 func newCfgTestServer(t *testing.T) (*httptest.Server, *ConfigStore) {
@@ -25,11 +26,11 @@
25 cfg.Ergo.DataDir = dir
26
27 store := NewConfigStore(path, cfg)
28 reg := registry.New(nil, []byte("key"))
29 log := slog.New(slog.NewTextHandler(io.Discard, nil))
30 srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, nil, store, "", log).Handler())
31 t.Cleanup(srv.Close)
32 return srv, store
33 }
34
35 func TestHandleGetConfig(t *testing.T) {
36
--- internal/api/config_handlers_test.go
+++ internal/api/config_handlers_test.go
@@ -9,10 +9,11 @@
9 "net/http/httptest"
10 "path/filepath"
11 "testing"
12 "time"
13
14 "github.com/conflicthq/scuttlebot/internal/auth"
15 "github.com/conflicthq/scuttlebot/internal/config"
16 "github.com/conflicthq/scuttlebot/internal/registry"
17 )
18
19 func newCfgTestServer(t *testing.T) (*httptest.Server, *ConfigStore) {
@@ -25,11 +26,11 @@
26 cfg.Ergo.DataDir = dir
27
28 store := NewConfigStore(path, cfg)
29 reg := registry.New(nil, []byte("key"))
30 log := slog.New(slog.NewTextHandler(io.Discard, nil))
31 srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, nil, store, "", log).Handler())
32 t.Cleanup(srv.Close)
33 return srv, store
34 }
35
36 func TestHandleGetConfig(t *testing.T) {
37
--- internal/api/login.go
+++ internal/api/login.go
@@ -93,15 +93,17 @@
9393
if !s.admins.Authenticate(req.Username, req.Password) {
9494
writeError(w, http.StatusUnauthorized, "invalid credentials")
9595
return
9696
}
9797
98
- // Return the first API token — the shared server token.
99
- var token string
100
- for t := range s.tokens {
101
- token = t
102
- break
98
+ // Create a session API key for this admin login.
99
+ sessionName := "session:" + req.Username
100
+ token, _, err := s.apiKeys.Create(sessionName, []auth.Scope{auth.ScopeAdmin}, time.Now().Add(24*time.Hour))
101
+ if err != nil {
102
+ s.log.Error("login: create session key", "err", err)
103
+ writeError(w, http.StatusInternalServerError, "failed to create session")
104
+ return
103105
}
104106
105107
writeJSON(w, http.StatusOK, map[string]string{
106108
"token": token,
107109
"username": req.Username,
108110
--- internal/api/login.go
+++ internal/api/login.go
@@ -93,15 +93,17 @@
93 if !s.admins.Authenticate(req.Username, req.Password) {
94 writeError(w, http.StatusUnauthorized, "invalid credentials")
95 return
96 }
97
98 // Return the first API token — the shared server token.
99 var token string
100 for t := range s.tokens {
101 token = t
102 break
 
 
103 }
104
105 writeJSON(w, http.StatusOK, map[string]string{
106 "token": token,
107 "username": req.Username,
108
--- internal/api/login.go
+++ internal/api/login.go
@@ -93,15 +93,17 @@
93 if !s.admins.Authenticate(req.Username, req.Password) {
94 writeError(w, http.StatusUnauthorized, "invalid credentials")
95 return
96 }
97
98 // Create a session API key for this admin login.
99 sessionName := "session:" + req.Username
100 token, _, err := s.apiKeys.Create(sessionName, []auth.Scope{auth.ScopeAdmin}, time.Now().Add(24*time.Hour))
101 if err != nil {
102 s.log.Error("login: create session key", "err", err)
103 writeError(w, http.StatusInternalServerError, "failed to create session")
104 return
105 }
106
107 writeJSON(w, http.StatusOK, map[string]string{
108 "token": token,
109 "username": req.Username,
110
--- internal/api/login_test.go
+++ internal/api/login_test.go
@@ -28,18 +28,18 @@
2828
admins := newAdminStore(t)
2929
if err := admins.Add("admin", "hunter2"); err != nil {
3030
t.Fatalf("Add admin: %v", err)
3131
}
3232
reg := registry.New(newMock(), []byte("test-signing-key"))
33
- srv := api.New(reg, []string{testToken}, nil, nil, admins, nil, nil, nil, "", testLog)
33
+ srv := api.New(reg, auth.TestStore(testToken), nil, nil, admins, nil, nil, nil, "", testLog)
3434
return httptest.NewServer(srv.Handler()), admins
3535
}
3636
3737
func TestLoginNoAdmins(t *testing.T) {
3838
// When admins is nil, login returns 404.
3939
reg := registry.New(newMock(), []byte("test-signing-key"))
40
- srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog)
40
+ srv := api.New(reg, auth.TestStore(testToken), nil, nil, nil, nil, nil, nil, "", testLog)
4141
ts := httptest.NewServer(srv.Handler())
4242
defer ts.Close()
4343
4444
resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil)
4545
defer resp.Body.Close()
4646
--- internal/api/login_test.go
+++ internal/api/login_test.go
@@ -28,18 +28,18 @@
28 admins := newAdminStore(t)
29 if err := admins.Add("admin", "hunter2"); err != nil {
30 t.Fatalf("Add admin: %v", err)
31 }
32 reg := registry.New(newMock(), []byte("test-signing-key"))
33 srv := api.New(reg, []string{testToken}, nil, nil, admins, nil, nil, nil, "", testLog)
34 return httptest.NewServer(srv.Handler()), admins
35 }
36
37 func TestLoginNoAdmins(t *testing.T) {
38 // When admins is nil, login returns 404.
39 reg := registry.New(newMock(), []byte("test-signing-key"))
40 srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog)
41 ts := httptest.NewServer(srv.Handler())
42 defer ts.Close()
43
44 resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil)
45 defer resp.Body.Close()
46
--- internal/api/login_test.go
+++ internal/api/login_test.go
@@ -28,18 +28,18 @@
28 admins := newAdminStore(t)
29 if err := admins.Add("admin", "hunter2"); err != nil {
30 t.Fatalf("Add admin: %v", err)
31 }
32 reg := registry.New(newMock(), []byte("test-signing-key"))
33 srv := api.New(reg, auth.TestStore(testToken), nil, nil, admins, nil, nil, nil, "", testLog)
34 return httptest.NewServer(srv.Handler()), admins
35 }
36
37 func TestLoginNoAdmins(t *testing.T) {
38 // When admins is nil, login returns 404.
39 reg := registry.New(newMock(), []byte("test-signing-key"))
40 srv := api.New(reg, auth.TestStore(testToken), nil, nil, nil, nil, nil, nil, "", testLog)
41 ts := httptest.NewServer(srv.Handler())
42 defer ts.Close()
43
44 resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil)
45 defer resp.Body.Close()
46
--- internal/api/middleware.go
+++ internal/api/middleware.go
@@ -1,26 +1,62 @@
11
package api
22
33
import (
4
+ "context"
45
"net/http"
56
"strings"
7
+
8
+ "github.com/conflicthq/scuttlebot/internal/auth"
69
)
710
11
+type ctxKey string
12
+
13
+const ctxAPIKey ctxKey = "apikey"
14
+
15
+// apiKeyFromContext returns the authenticated APIKey from the request context,
16
+// or nil if not authenticated.
17
+func apiKeyFromContext(ctx context.Context) *auth.APIKey {
18
+ k, _ := ctx.Value(ctxAPIKey).(*auth.APIKey)
19
+ return k
20
+}
21
+
22
+// authMiddleware validates the Bearer token and injects the APIKey into context.
823
func (s *Server) authMiddleware(next http.Handler) http.Handler {
924
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1025
token := bearerToken(r)
1126
if token == "" {
1227
writeError(w, http.StatusUnauthorized, "missing authorization header")
1328
return
1429
}
15
- if _, ok := s.tokens[token]; !ok {
30
+ key := s.apiKeys.Lookup(token)
31
+ if key == nil {
1632
writeError(w, http.StatusUnauthorized, "invalid token")
1733
return
1834
}
19
- next.ServeHTTP(w, r)
35
+ // Update last-used timestamp in the background.
36
+ go s.apiKeys.TouchLastUsed(key.ID)
37
+
38
+ ctx := context.WithValue(r.Context(), ctxAPIKey, key)
39
+ next.ServeHTTP(w, r.WithContext(ctx))
2040
})
2141
}
42
+
43
+// requireScope returns middleware that rejects requests without the given scope.
44
+func (s *Server) requireScope(scope auth.Scope, next http.HandlerFunc) http.HandlerFunc {
45
+ return func(w http.ResponseWriter, r *http.Request) {
46
+ key := apiKeyFromContext(r.Context())
47
+ if key == nil {
48
+ writeError(w, http.StatusUnauthorized, "missing authentication")
49
+ return
50
+ }
51
+ if !key.HasScope(scope) {
52
+ writeError(w, http.StatusForbidden, "insufficient scope: requires "+string(scope))
53
+ return
54
+ }
55
+ next(w, r)
56
+ }
57
+}
2258
2359
func bearerToken(r *http.Request) string {
2460
auth := r.Header.Get("Authorization")
2561
token, found := strings.CutPrefix(auth, "Bearer ")
2662
if !found {
2763
--- internal/api/middleware.go
+++ internal/api/middleware.go
@@ -1,26 +1,62 @@
1 package api
2
3 import (
 
4 "net/http"
5 "strings"
 
 
6 )
7
 
 
 
 
 
 
 
 
 
 
 
 
8 func (s *Server) authMiddleware(next http.Handler) http.Handler {
9 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
10 token := bearerToken(r)
11 if token == "" {
12 writeError(w, http.StatusUnauthorized, "missing authorization header")
13 return
14 }
15 if _, ok := s.tokens[token]; !ok {
 
16 writeError(w, http.StatusUnauthorized, "invalid token")
17 return
18 }
19 next.ServeHTTP(w, r)
 
 
 
 
20 })
21 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
23 func bearerToken(r *http.Request) string {
24 auth := r.Header.Get("Authorization")
25 token, found := strings.CutPrefix(auth, "Bearer ")
26 if !found {
27
--- internal/api/middleware.go
+++ internal/api/middleware.go
@@ -1,26 +1,62 @@
1 package api
2
3 import (
4 "context"
5 "net/http"
6 "strings"
7
8 "github.com/conflicthq/scuttlebot/internal/auth"
9 )
10
11 type ctxKey string
12
13 const ctxAPIKey ctxKey = "apikey"
14
15 // apiKeyFromContext returns the authenticated APIKey from the request context,
16 // or nil if not authenticated.
17 func apiKeyFromContext(ctx context.Context) *auth.APIKey {
18 k, _ := ctx.Value(ctxAPIKey).(*auth.APIKey)
19 return k
20 }
21
22 // authMiddleware validates the Bearer token and injects the APIKey into context.
23 func (s *Server) authMiddleware(next http.Handler) http.Handler {
24 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25 token := bearerToken(r)
26 if token == "" {
27 writeError(w, http.StatusUnauthorized, "missing authorization header")
28 return
29 }
30 key := s.apiKeys.Lookup(token)
31 if key == nil {
32 writeError(w, http.StatusUnauthorized, "invalid token")
33 return
34 }
35 // Update last-used timestamp in the background.
36 go s.apiKeys.TouchLastUsed(key.ID)
37
38 ctx := context.WithValue(r.Context(), ctxAPIKey, key)
39 next.ServeHTTP(w, r.WithContext(ctx))
40 })
41 }
42
43 // requireScope returns middleware that rejects requests without the given scope.
44 func (s *Server) requireScope(scope auth.Scope, next http.HandlerFunc) http.HandlerFunc {
45 return func(w http.ResponseWriter, r *http.Request) {
46 key := apiKeyFromContext(r.Context())
47 if key == nil {
48 writeError(w, http.StatusUnauthorized, "missing authentication")
49 return
50 }
51 if !key.HasScope(scope) {
52 writeError(w, http.StatusForbidden, "insufficient scope: requires "+string(scope))
53 return
54 }
55 next(w, r)
56 }
57 }
58
59 func bearerToken(r *http.Request) string {
60 auth := r.Header.Get("Authorization")
61 token, found := strings.CutPrefix(auth, "Bearer ")
62 if !found {
63
--- internal/api/policies.go
+++ internal/api/policies.go
@@ -42,16 +42,24 @@
4242
Rotation string `json:"rotation"` // "none" | "daily" | "weekly" | "size"
4343
MaxSizeMB int `json:"max_size_mb"` // size rotation threshold (MiB); 0 = unlimited
4444
PerChannel bool `json:"per_channel"` // separate file per channel
4545
MaxAgeDays int `json:"max_age_days"` // prune rotated files older than N days; 0 = keep all
4646
}
47
+
48
+// ChannelDisplayConfig holds per-channel rendering preferences.
49
+type ChannelDisplayConfig struct {
50
+ MirrorDetail string `json:"mirror_detail,omitempty"` // "full", "compact", "minimal"
51
+ RenderMode string `json:"render_mode,omitempty"` // "rich", "text"
52
+}
4753
4854
// BridgePolicy configures bridge-specific UI/relay behavior.
4955
type BridgePolicy struct {
5056
// WebUserTTLMinutes controls how long HTTP bridge sender nicks remain
5157
// visible in the channel user list after their last post.
5258
WebUserTTLMinutes int `json:"web_user_ttl_minutes"`
59
+ // ChannelDisplay holds per-channel rendering config.
60
+ ChannelDisplay map[string]ChannelDisplayConfig `json:"channel_display,omitempty"`
5361
}
5462
5563
// PolicyLLMBackend stores an LLM backend configuration in the policy store.
5664
// This allows backends to be added and edited from the web UI rather than
5765
// requiring a change to scuttlebot.yaml.
@@ -68,18 +76,32 @@
6876
AWSSecretKey string `json:"aws_secret_key,omitempty"`
6977
Allow []string `json:"allow,omitempty"`
7078
Block []string `json:"block,omitempty"`
7179
Default bool `json:"default,omitempty"`
7280
}
81
+
82
+// ROETemplate is a rules-of-engagement template.
83
+type ROETemplate struct {
84
+ Name string `json:"name"`
85
+ Description string `json:"description,omitempty"`
86
+ Channels []string `json:"channels,omitempty"`
87
+ Permissions []string `json:"permissions,omitempty"`
88
+ RateLimit struct {
89
+ MessagesPerSecond float64 `json:"messages_per_second,omitempty"`
90
+ Burst int `json:"burst,omitempty"`
91
+ } `json:"rate_limit,omitempty"`
92
+}
7393
7494
// Policies is the full mutable settings blob, persisted to policies.json.
7595
type Policies struct {
76
- Behaviors []BehaviorConfig `json:"behaviors"`
77
- AgentPolicy AgentPolicy `json:"agent_policy"`
78
- Bridge BridgePolicy `json:"bridge"`
79
- Logging LoggingPolicy `json:"logging"`
80
- LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"`
96
+ Behaviors []BehaviorConfig `json:"behaviors"`
97
+ AgentPolicy AgentPolicy `json:"agent_policy"`
98
+ Bridge BridgePolicy `json:"bridge"`
99
+ Logging LoggingPolicy `json:"logging"`
100
+ LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"`
101
+ ROETemplates []ROETemplate `json:"roe_templates,omitempty"`
102
+ OnJoinMessages map[string]string `json:"on_join_messages,omitempty"` // channel → message template
81103
}
82104
83105
// defaultBehaviors lists every built-in bot with conservative defaults (disabled).
84106
var defaultBehaviors = []BehaviorConfig{
85107
{
@@ -151,10 +173,42 @@
151173
Description: "Acts on sentinel incident reports — issues warnings, mutes, or kicks based on severity. Operators can also issue direct commands via DM.",
152174
Nick: "steward",
153175
JoinAllChannels: true,
154176
},
155177
}
178
+
179
+// BotCommand describes a single command a bot responds to.
180
+type BotCommand struct {
181
+ Command string `json:"command"`
182
+ Usage string `json:"usage"`
183
+ Description string `json:"description"`
184
+}
185
+
186
+// botCommands maps bot ID to its available commands.
187
+var botCommands = map[string][]BotCommand{
188
+ "oracle": {
189
+ {Command: "summarize", Usage: "summarize #channel [last=N] [format=toon|json]", Description: "Summarize recent channel activity using an LLM."},
190
+ },
191
+ "scroll": {
192
+ {Command: "replay", Usage: "replay #channel [last=N] [since=<unix_ms>]", Description: "Replay recent channel history via DM."},
193
+ },
194
+ "steward": {
195
+ {Command: "mute", Usage: "mute <nick> [duration]", Description: "Mute a nick in the current channel."},
196
+ {Command: "unmute", Usage: "unmute <nick>", Description: "Remove mute from a nick."},
197
+ {Command: "kick", Usage: "kick <nick> [reason]", Description: "Kick a nick from the current channel."},
198
+ {Command: "warn", Usage: "warn <nick> <message>", Description: "Send a warning notice to a nick."},
199
+ },
200
+ "warden": {
201
+ {Command: "status", Usage: "status", Description: "Show warden rate-limit status for all tracked nicks."},
202
+ },
203
+ "snitch": {
204
+ {Command: "status", Usage: "status", Description: "Show snitch monitoring status and alert history."},
205
+ },
206
+ "herald": {
207
+ {Command: "announce", Usage: "announce #channel <message>", Description: "Post an announcement to a channel."},
208
+ },
209
+}
156210
157211
// PolicyStore persists Policies to a JSON file or database.
158212
type PolicyStore struct {
159213
mu sync.RWMutex
160214
path string
@@ -227,10 +281,12 @@
227281
}
228282
ps.data.AgentPolicy = p.AgentPolicy
229283
ps.data.Bridge = p.Bridge
230284
ps.data.Logging = p.Logging
231285
ps.data.LLMBackends = p.LLMBackends
286
+ ps.data.ROETemplates = p.ROETemplates
287
+ ps.data.OnJoinMessages = p.OnJoinMessages
232288
return nil
233289
}
234290
235291
func (ps *PolicyStore) save() error {
236292
raw, err := json.MarshalIndent(ps.data, "", " ")
@@ -337,10 +393,20 @@
337393
338394
// Merge LLM backends if provided.
339395
if patch.LLMBackends != nil {
340396
ps.data.LLMBackends = patch.LLMBackends
341397
}
398
+
399
+ // Merge ROE templates if provided.
400
+ if patch.ROETemplates != nil {
401
+ ps.data.ROETemplates = patch.ROETemplates
402
+ }
403
+
404
+ // Merge on-join messages if provided.
405
+ if patch.OnJoinMessages != nil {
406
+ ps.data.OnJoinMessages = patch.OnJoinMessages
407
+ }
342408
343409
ps.normalize(&ps.data)
344410
if err := ps.save(); err != nil {
345411
return err
346412
}
347413
--- internal/api/policies.go
+++ internal/api/policies.go
@@ -42,16 +42,24 @@
42 Rotation string `json:"rotation"` // "none" | "daily" | "weekly" | "size"
43 MaxSizeMB int `json:"max_size_mb"` // size rotation threshold (MiB); 0 = unlimited
44 PerChannel bool `json:"per_channel"` // separate file per channel
45 MaxAgeDays int `json:"max_age_days"` // prune rotated files older than N days; 0 = keep all
46 }
 
 
 
 
 
 
47
48 // BridgePolicy configures bridge-specific UI/relay behavior.
49 type BridgePolicy struct {
50 // WebUserTTLMinutes controls how long HTTP bridge sender nicks remain
51 // visible in the channel user list after their last post.
52 WebUserTTLMinutes int `json:"web_user_ttl_minutes"`
 
 
53 }
54
55 // PolicyLLMBackend stores an LLM backend configuration in the policy store.
56 // This allows backends to be added and edited from the web UI rather than
57 // requiring a change to scuttlebot.yaml.
@@ -68,18 +76,32 @@
68 AWSSecretKey string `json:"aws_secret_key,omitempty"`
69 Allow []string `json:"allow,omitempty"`
70 Block []string `json:"block,omitempty"`
71 Default bool `json:"default,omitempty"`
72 }
 
 
 
 
 
 
 
 
 
 
 
 
73
74 // Policies is the full mutable settings blob, persisted to policies.json.
75 type Policies struct {
76 Behaviors []BehaviorConfig `json:"behaviors"`
77 AgentPolicy AgentPolicy `json:"agent_policy"`
78 Bridge BridgePolicy `json:"bridge"`
79 Logging LoggingPolicy `json:"logging"`
80 LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"`
 
 
81 }
82
83 // defaultBehaviors lists every built-in bot with conservative defaults (disabled).
84 var defaultBehaviors = []BehaviorConfig{
85 {
@@ -151,10 +173,42 @@
151 Description: "Acts on sentinel incident reports — issues warnings, mutes, or kicks based on severity. Operators can also issue direct commands via DM.",
152 Nick: "steward",
153 JoinAllChannels: true,
154 },
155 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
157 // PolicyStore persists Policies to a JSON file or database.
158 type PolicyStore struct {
159 mu sync.RWMutex
160 path string
@@ -227,10 +281,12 @@
227 }
228 ps.data.AgentPolicy = p.AgentPolicy
229 ps.data.Bridge = p.Bridge
230 ps.data.Logging = p.Logging
231 ps.data.LLMBackends = p.LLMBackends
 
 
232 return nil
233 }
234
235 func (ps *PolicyStore) save() error {
236 raw, err := json.MarshalIndent(ps.data, "", " ")
@@ -337,10 +393,20 @@
337
338 // Merge LLM backends if provided.
339 if patch.LLMBackends != nil {
340 ps.data.LLMBackends = patch.LLMBackends
341 }
 
 
 
 
 
 
 
 
 
 
342
343 ps.normalize(&ps.data)
344 if err := ps.save(); err != nil {
345 return err
346 }
347
--- internal/api/policies.go
+++ internal/api/policies.go
@@ -42,16 +42,24 @@
42 Rotation string `json:"rotation"` // "none" | "daily" | "weekly" | "size"
43 MaxSizeMB int `json:"max_size_mb"` // size rotation threshold (MiB); 0 = unlimited
44 PerChannel bool `json:"per_channel"` // separate file per channel
45 MaxAgeDays int `json:"max_age_days"` // prune rotated files older than N days; 0 = keep all
46 }
47
48 // ChannelDisplayConfig holds per-channel rendering preferences.
49 type ChannelDisplayConfig struct {
50 MirrorDetail string `json:"mirror_detail,omitempty"` // "full", "compact", "minimal"
51 RenderMode string `json:"render_mode,omitempty"` // "rich", "text"
52 }
53
54 // BridgePolicy configures bridge-specific UI/relay behavior.
55 type BridgePolicy struct {
56 // WebUserTTLMinutes controls how long HTTP bridge sender nicks remain
57 // visible in the channel user list after their last post.
58 WebUserTTLMinutes int `json:"web_user_ttl_minutes"`
59 // ChannelDisplay holds per-channel rendering config.
60 ChannelDisplay map[string]ChannelDisplayConfig `json:"channel_display,omitempty"`
61 }
62
63 // PolicyLLMBackend stores an LLM backend configuration in the policy store.
64 // This allows backends to be added and edited from the web UI rather than
65 // requiring a change to scuttlebot.yaml.
@@ -68,18 +76,32 @@
76 AWSSecretKey string `json:"aws_secret_key,omitempty"`
77 Allow []string `json:"allow,omitempty"`
78 Block []string `json:"block,omitempty"`
79 Default bool `json:"default,omitempty"`
80 }
81
82 // ROETemplate is a rules-of-engagement template.
83 type ROETemplate struct {
84 Name string `json:"name"`
85 Description string `json:"description,omitempty"`
86 Channels []string `json:"channels,omitempty"`
87 Permissions []string `json:"permissions,omitempty"`
88 RateLimit struct {
89 MessagesPerSecond float64 `json:"messages_per_second,omitempty"`
90 Burst int `json:"burst,omitempty"`
91 } `json:"rate_limit,omitempty"`
92 }
93
94 // Policies is the full mutable settings blob, persisted to policies.json.
95 type Policies struct {
96 Behaviors []BehaviorConfig `json:"behaviors"`
97 AgentPolicy AgentPolicy `json:"agent_policy"`
98 Bridge BridgePolicy `json:"bridge"`
99 Logging LoggingPolicy `json:"logging"`
100 LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"`
101 ROETemplates []ROETemplate `json:"roe_templates,omitempty"`
102 OnJoinMessages map[string]string `json:"on_join_messages,omitempty"` // channel → message template
103 }
104
105 // defaultBehaviors lists every built-in bot with conservative defaults (disabled).
106 var defaultBehaviors = []BehaviorConfig{
107 {
@@ -151,10 +173,42 @@
173 Description: "Acts on sentinel incident reports — issues warnings, mutes, or kicks based on severity. Operators can also issue direct commands via DM.",
174 Nick: "steward",
175 JoinAllChannels: true,
176 },
177 }
178
179 // BotCommand describes a single command a bot responds to.
180 type BotCommand struct {
181 Command string `json:"command"`
182 Usage string `json:"usage"`
183 Description string `json:"description"`
184 }
185
186 // botCommands maps bot ID to its available commands.
187 var botCommands = map[string][]BotCommand{
188 "oracle": {
189 {Command: "summarize", Usage: "summarize #channel [last=N] [format=toon|json]", Description: "Summarize recent channel activity using an LLM."},
190 },
191 "scroll": {
192 {Command: "replay", Usage: "replay #channel [last=N] [since=<unix_ms>]", Description: "Replay recent channel history via DM."},
193 },
194 "steward": {
195 {Command: "mute", Usage: "mute <nick> [duration]", Description: "Mute a nick in the current channel."},
196 {Command: "unmute", Usage: "unmute <nick>", Description: "Remove mute from a nick."},
197 {Command: "kick", Usage: "kick <nick> [reason]", Description: "Kick a nick from the current channel."},
198 {Command: "warn", Usage: "warn <nick> <message>", Description: "Send a warning notice to a nick."},
199 },
200 "warden": {
201 {Command: "status", Usage: "status", Description: "Show warden rate-limit status for all tracked nicks."},
202 },
203 "snitch": {
204 {Command: "status", Usage: "status", Description: "Show snitch monitoring status and alert history."},
205 },
206 "herald": {
207 {Command: "announce", Usage: "announce #channel <message>", Description: "Post an announcement to a channel."},
208 },
209 }
210
211 // PolicyStore persists Policies to a JSON file or database.
212 type PolicyStore struct {
213 mu sync.RWMutex
214 path string
@@ -227,10 +281,12 @@
281 }
282 ps.data.AgentPolicy = p.AgentPolicy
283 ps.data.Bridge = p.Bridge
284 ps.data.Logging = p.Logging
285 ps.data.LLMBackends = p.LLMBackends
286 ps.data.ROETemplates = p.ROETemplates
287 ps.data.OnJoinMessages = p.OnJoinMessages
288 return nil
289 }
290
291 func (ps *PolicyStore) save() error {
292 raw, err := json.MarshalIndent(ps.data, "", " ")
@@ -337,10 +393,20 @@
393
394 // Merge LLM backends if provided.
395 if patch.LLMBackends != nil {
396 ps.data.LLMBackends = patch.LLMBackends
397 }
398
399 // Merge ROE templates if provided.
400 if patch.ROETemplates != nil {
401 ps.data.ROETemplates = patch.ROETemplates
402 }
403
404 // Merge on-join messages if provided.
405 if patch.OnJoinMessages != nil {
406 ps.data.OnJoinMessages = patch.OnJoinMessages
407 }
408
409 ps.normalize(&ps.data)
410 if err := ps.save(); err != nil {
411 return err
412 }
413
--- internal/api/server.go
+++ internal/api/server.go
@@ -7,18 +7,19 @@
77
88
import (
99
"log/slog"
1010
"net/http"
1111
12
+ "github.com/conflicthq/scuttlebot/internal/auth"
1213
"github.com/conflicthq/scuttlebot/internal/config"
1314
"github.com/conflicthq/scuttlebot/internal/registry"
1415
)
1516
1617
// Server is the scuttlebot HTTP API server.
1718
type Server struct {
1819
registry *registry.Registry
19
- tokens map[string]struct{}
20
+ apiKeys *auth.APIKeyStore
2021
log *slog.Logger
2122
bridge chatBridge // nil if bridge is disabled
2223
policies *PolicyStore // nil if not configured
2324
admins adminStore // nil if not configured
2425
llmCfg *config.LLMConfig // nil if no LLM backends configured
@@ -31,18 +32,14 @@
3132
// New creates a new API Server. Pass nil for b to disable the chat bridge.
3233
// Pass nil for admins to disable admin authentication endpoints.
3334
// Pass nil for llmCfg to disable AI/LLM management endpoints.
3435
// Pass nil for topo to disable topology provisioning endpoints.
3536
// Pass nil for cfgStore to disable config read/write endpoints.
36
-func New(reg *registry.Registry, tokens []string, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, cfgStore *ConfigStore, tlsDomain string, log *slog.Logger) *Server {
37
- tokenSet := make(map[string]struct{}, len(tokens))
38
- for _, t := range tokens {
39
- tokenSet[t] = struct{}{}
40
- }
37
+func New(reg *registry.Registry, apiKeys *auth.APIKeyStore, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, cfgStore *ConfigStore, tlsDomain string, log *slog.Logger) *Server {
4138
return &Server{
4239
registry: reg,
43
- tokens: tokenSet,
40
+ apiKeys: apiKeys,
4441
log: log,
4542
bridge: b,
4643
policies: ps,
4744
admins: admins,
4845
llmCfg: llmCfg,
@@ -53,65 +50,96 @@
5350
}
5451
}
5552
5653
// Handler returns the HTTP handler with all routes registered.
5754
// /v1/ routes require a valid Bearer token. /ui/ is served unauthenticated.
55
+// Scoped routes additionally check the API key's scopes.
5856
func (s *Server) Handler() http.Handler {
5957
apiMux := http.NewServeMux()
60
- apiMux.HandleFunc("GET /v1/status", s.handleStatus)
61
- apiMux.HandleFunc("GET /v1/metrics", s.handleMetrics)
62
- if s.policies != nil {
63
- apiMux.HandleFunc("GET /v1/settings", s.handleGetSettings)
64
- apiMux.HandleFunc("GET /v1/settings/policies", s.handleGetPolicies)
65
- apiMux.HandleFunc("PUT /v1/settings/policies", s.handlePutPolicies)
66
- apiMux.HandleFunc("PATCH /v1/settings/policies", s.handlePatchPolicies)
67
- }
68
- apiMux.HandleFunc("GET /v1/agents", s.handleListAgents)
69
- apiMux.HandleFunc("GET /v1/agents/{nick}", s.handleGetAgent)
70
- apiMux.HandleFunc("PATCH /v1/agents/{nick}", s.handleUpdateAgent)
71
- apiMux.HandleFunc("POST /v1/agents/register", s.handleRegister)
72
- apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.handleRotate)
73
- apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.handleAdopt)
74
- apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.handleRevoke)
75
- apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.handleDelete)
76
- if s.bridge != nil {
77
- apiMux.HandleFunc("GET /v1/channels", s.handleListChannels)
78
- apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.handleJoinChannel)
79
- apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.handleDeleteChannel)
80
- apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.handleChannelMessages)
81
- apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.handleSendMessage)
82
- apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.handleChannelPresence)
83
- apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.handleChannelUsers)
84
- }
85
- if s.topoMgr != nil {
86
- apiMux.HandleFunc("POST /v1/channels", s.handleProvisionChannel)
87
- apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.handleDropChannel)
88
- apiMux.HandleFunc("GET /v1/topology", s.handleGetTopology)
89
- }
90
- if s.cfgStore != nil {
91
- apiMux.HandleFunc("GET /v1/config", s.handleGetConfig)
92
- apiMux.HandleFunc("PUT /v1/config", s.handlePutConfig)
93
- apiMux.HandleFunc("GET /v1/config/history", s.handleGetConfigHistory)
94
- apiMux.HandleFunc("GET /v1/config/history/{filename}", s.handleGetConfigHistoryEntry)
95
- }
96
-
97
- if s.admins != nil {
98
- apiMux.HandleFunc("GET /v1/admins", s.handleAdminList)
99
- apiMux.HandleFunc("POST /v1/admins", s.handleAdminAdd)
100
- apiMux.HandleFunc("DELETE /v1/admins/{username}", s.handleAdminRemove)
101
- apiMux.HandleFunc("PUT /v1/admins/{username}/password", s.handleAdminSetPassword)
102
- }
103
-
104
- // LLM / AI gateway endpoints.
105
- apiMux.HandleFunc("GET /v1/llm/backends", s.handleLLMBackends)
106
- apiMux.HandleFunc("POST /v1/llm/backends", s.handleLLMBackendCreate)
107
- apiMux.HandleFunc("PUT /v1/llm/backends/{name}", s.handleLLMBackendUpdate)
108
- apiMux.HandleFunc("DELETE /v1/llm/backends/{name}", s.handleLLMBackendDelete)
109
- apiMux.HandleFunc("GET /v1/llm/backends/{name}/models", s.handleLLMModels)
110
- apiMux.HandleFunc("POST /v1/llm/discover", s.handleLLMDiscover)
111
- apiMux.HandleFunc("GET /v1/llm/known", s.handleLLMKnown)
112
- apiMux.HandleFunc("POST /v1/llm/complete", s.handleLLMComplete)
58
+
59
+ // Read-scope: status, metrics (also accessible with any scope via admin).
60
+ apiMux.HandleFunc("GET /v1/status", s.requireScope(auth.ScopeRead, s.handleStatus))
61
+ apiMux.HandleFunc("GET /v1/metrics", s.requireScope(auth.ScopeRead, s.handleMetrics))
62
+
63
+ // Policies — admin scope.
64
+ if s.policies != nil {
65
+ apiMux.HandleFunc("GET /v1/settings", s.requireScope(auth.ScopeRead, s.handleGetSettings))
66
+ apiMux.HandleFunc("GET /v1/settings/policies", s.requireScope(auth.ScopeRead, s.handleGetPolicies))
67
+ apiMux.HandleFunc("PUT /v1/settings/policies", s.requireScope(auth.ScopeAdmin, s.handlePutPolicies))
68
+ apiMux.HandleFunc("PATCH /v1/settings/policies", s.requireScope(auth.ScopeAdmin, s.handlePatchPolicies))
69
+ }
70
+
71
+ // Agents — agents scope.
72
+ apiMux.HandleFunc("GET /v1/agents", s.requireScope(auth.ScopeAgents, s.handleListAgents))
73
+ apiMux.HandleFunc("GET /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleGetAgent))
74
+ apiMux.HandleFunc("PATCH /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleUpdateAgent))
75
+ apiMux.HandleFunc("POST /v1/agents/register", s.requireScope(auth.ScopeAgents, s.handleRegister))
76
+ apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.requireScope(auth.ScopeAgents, s.handleRotate))
77
+ apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.requireScope(auth.ScopeAgents, s.handleAdopt))
78
+ apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.requireScope(auth.ScopeAgents, s.handleRevoke))
79
+ apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleDelete))
80
+ apiMux.HandleFunc("POST /v1/agents/bulk-delete", s.requireScope(auth.ScopeAgents, s.handleBulkDeleteAgents))
81
+
82
+ // Channels — channels scope (read), chat scope (send).
83
+ if s.bridge != nil {
84
+ apiMux.HandleFunc("GET /v1/channels", s.requireScope(auth.ScopeChannels, s.handleListChannels))
85
+ apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.requireScope(auth.ScopeChannels, s.handleJoinChannel))
86
+ apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.requireScope(auth.ScopeChannels, s.handleDeleteChannel))
87
+ apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChannels, s.handleChannelMessages))
88
+ apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChat, s.handleSendMessage))
89
+ apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.requireScope(auth.ScopeChat, s.handleChannelPresence))
90
+ apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.requireScope(auth.ScopeChannels, s.handleChannelUsers))
91
+ apiMux.HandleFunc("GET /v1/channels/{channel}/config", s.requireScope(auth.ScopeChannels, s.handleGetChannelConfig))
92
+ apiMux.HandleFunc("PUT /v1/channels/{channel}/config", s.requireScope(auth.ScopeAdmin, s.handlePutChannelConfig))
93
+ }
94
+
95
+ // Topology — topology scope.
96
+ if s.topoMgr != nil {
97
+ apiMux.HandleFunc("POST /v1/channels", s.requireScope(auth.ScopeTopology, s.handleProvisionChannel))
98
+ apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.requireScope(auth.ScopeTopology, s.handleDropChannel))
99
+ apiMux.HandleFunc("GET /v1/topology", s.requireScope(auth.ScopeTopology, s.handleGetTopology))
100
+ }
101
+ // Blocker escalation — agents can signal they're stuck.
102
+ if s.bridge != nil {
103
+ apiMux.HandleFunc("POST /v1/agents/{nick}/blocker", s.requireScope(auth.ScopeAgents, s.handleAgentBlocker))
104
+ }
105
+
106
+ // Instructions — available even without topology (uses policies store).
107
+ apiMux.HandleFunc("GET /v1/channels/{channel}/instructions", s.requireScope(auth.ScopeTopology, s.handleGetInstructions))
108
+ apiMux.HandleFunc("PUT /v1/channels/{channel}/instructions", s.requireScope(auth.ScopeTopology, s.handlePutInstructions))
109
+ apiMux.HandleFunc("DELETE /v1/channels/{channel}/instructions", s.requireScope(auth.ScopeTopology, s.handleDeleteInstructions))
110
+
111
+ // Config — config scope.
112
+ if s.cfgStore != nil {
113
+ apiMux.HandleFunc("GET /v1/config", s.requireScope(auth.ScopeConfig, s.handleGetConfig))
114
+ apiMux.HandleFunc("PUT /v1/config", s.requireScope(auth.ScopeConfig, s.handlePutConfig))
115
+ apiMux.HandleFunc("GET /v1/config/history", s.requireScope(auth.ScopeConfig, s.handleGetConfigHistory))
116
+ apiMux.HandleFunc("GET /v1/config/history/{filename}", s.requireScope(auth.ScopeConfig, s.handleGetConfigHistoryEntry))
117
+ }
118
+
119
+ // Admin — admin scope.
120
+ if s.admins != nil {
121
+ apiMux.HandleFunc("GET /v1/admins", s.requireScope(auth.ScopeAdmin, s.handleAdminList))
122
+ apiMux.HandleFunc("POST /v1/admins", s.requireScope(auth.ScopeAdmin, s.handleAdminAdd))
123
+ apiMux.HandleFunc("DELETE /v1/admins/{username}", s.requireScope(auth.ScopeAdmin, s.handleAdminRemove))
124
+ apiMux.HandleFunc("PUT /v1/admins/{username}/password", s.requireScope(auth.ScopeAdmin, s.handleAdminSetPassword))
125
+ }
126
+
127
+ // API key management — admin scope.
128
+ apiMux.HandleFunc("GET /v1/api-keys", s.requireScope(auth.ScopeAdmin, s.handleListAPIKeys))
129
+ apiMux.HandleFunc("POST /v1/api-keys", s.requireScope(auth.ScopeAdmin, s.handleCreateAPIKey))
130
+ apiMux.HandleFunc("DELETE /v1/api-keys/{id}", s.requireScope(auth.ScopeAdmin, s.handleRevokeAPIKey))
131
+
132
+ // LLM / AI gateway — bots scope.
133
+ apiMux.HandleFunc("GET /v1/llm/backends", s.requireScope(auth.ScopeBots, s.handleLLMBackends))
134
+ apiMux.HandleFunc("POST /v1/llm/backends", s.requireScope(auth.ScopeBots, s.handleLLMBackendCreate))
135
+ apiMux.HandleFunc("PUT /v1/llm/backends/{name}", s.requireScope(auth.ScopeBots, s.handleLLMBackendUpdate))
136
+ apiMux.HandleFunc("DELETE /v1/llm/backends/{name}", s.requireScope(auth.ScopeBots, s.handleLLMBackendDelete))
137
+ apiMux.HandleFunc("GET /v1/llm/backends/{name}/models", s.requireScope(auth.ScopeBots, s.handleLLMModels))
138
+ apiMux.HandleFunc("POST /v1/llm/discover", s.requireScope(auth.ScopeBots, s.handleLLMDiscover))
139
+ apiMux.HandleFunc("GET /v1/llm/known", s.requireScope(auth.ScopeBots, s.handleLLMKnown))
140
+ apiMux.HandleFunc("POST /v1/llm/complete", s.requireScope(auth.ScopeBots, s.handleLLMComplete))
113141
114142
outer := http.NewServeMux()
115143
outer.HandleFunc("POST /login", s.handleLogin)
116144
outer.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
117145
http.Redirect(w, r, "/ui/", http.StatusFound)
118146
--- 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,96 @@
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,96 @@
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 apiMux.HandleFunc("POST /v1/agents/bulk-delete", s.requireScope(auth.ScopeAgents, s.handleBulkDeleteAgents))
81
82 // Channels — channels scope (read), chat scope (send).
83 if s.bridge != nil {
84 apiMux.HandleFunc("GET /v1/channels", s.requireScope(auth.ScopeChannels, s.handleListChannels))
85 apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.requireScope(auth.ScopeChannels, s.handleJoinChannel))
86 apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.requireScope(auth.ScopeChannels, s.handleDeleteChannel))
87 apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChannels, s.handleChannelMessages))
88 apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChat, s.handleSendMessage))
89 apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.requireScope(auth.ScopeChat, s.handleChannelPresence))
90 apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.requireScope(auth.ScopeChannels, s.handleChannelUsers))
91 apiMux.HandleFunc("GET /v1/channels/{channel}/config", s.requireScope(auth.ScopeChannels, s.handleGetChannelConfig))
92 apiMux.HandleFunc("PUT /v1/channels/{channel}/config", s.requireScope(auth.ScopeAdmin, s.handlePutChannelConfig))
93 }
94
95 // Topology — topology scope.
96 if s.topoMgr != nil {
97 apiMux.HandleFunc("POST /v1/channels", s.requireScope(auth.ScopeTopology, s.handleProvisionChannel))
98 apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.requireScope(auth.ScopeTopology, s.handleDropChannel))
99 apiMux.HandleFunc("GET /v1/topology", s.requireScope(auth.ScopeTopology, s.handleGetTopology))
100 }
101 // Blocker escalation — agents can signal they're stuck.
102 if s.bridge != nil {
103 apiMux.HandleFunc("POST /v1/agents/{nick}/blocker", s.requireScope(auth.ScopeAgents, s.handleAgentBlocker))
104 }
105
106 // Instructions — available even without topology (uses policies store).
107 apiMux.HandleFunc("GET /v1/channels/{channel}/instructions", s.requireScope(auth.ScopeTopology, s.handleGetInstructions))
108 apiMux.HandleFunc("PUT /v1/channels/{channel}/instructions", s.requireScope(auth.ScopeTopology, s.handlePutInstructions))
109 apiMux.HandleFunc("DELETE /v1/channels/{channel}/instructions", s.requireScope(auth.ScopeTopology, s.handleDeleteInstructions))
110
111 // Config — config scope.
112 if s.cfgStore != nil {
113 apiMux.HandleFunc("GET /v1/config", s.requireScope(auth.ScopeConfig, s.handleGetConfig))
114 apiMux.HandleFunc("PUT /v1/config", s.requireScope(auth.ScopeConfig, s.handlePutConfig))
115 apiMux.HandleFunc("GET /v1/config/history", s.requireScope(auth.ScopeConfig, s.handleGetConfigHistory))
116 apiMux.HandleFunc("GET /v1/config/history/{filename}", s.requireScope(auth.ScopeConfig, s.handleGetConfigHistoryEntry))
117 }
118
119 // Admin — admin scope.
120 if s.admins != nil {
121 apiMux.HandleFunc("GET /v1/admins", s.requireScope(auth.ScopeAdmin, s.handleAdminList))
122 apiMux.HandleFunc("POST /v1/admins", s.requireScope(auth.ScopeAdmin, s.handleAdminAdd))
123 apiMux.HandleFunc("DELETE /v1/admins/{username}", s.requireScope(auth.ScopeAdmin, s.handleAdminRemove))
124 apiMux.HandleFunc("PUT /v1/admins/{username}/password", s.requireScope(auth.ScopeAdmin, s.handleAdminSetPassword))
125 }
126
127 // API key management — admin scope.
128 apiMux.HandleFunc("GET /v1/api-keys", s.requireScope(auth.ScopeAdmin, s.handleListAPIKeys))
129 apiMux.HandleFunc("POST /v1/api-keys", s.requireScope(auth.ScopeAdmin, s.handleCreateAPIKey))
130 apiMux.HandleFunc("DELETE /v1/api-keys/{id}", s.requireScope(auth.ScopeAdmin, s.handleRevokeAPIKey))
131
132 // LLM / AI gateway — bots scope.
133 apiMux.HandleFunc("GET /v1/llm/backends", s.requireScope(auth.ScopeBots, s.handleLLMBackends))
134 apiMux.HandleFunc("POST /v1/llm/backends", s.requireScope(auth.ScopeBots, s.handleLLMBackendCreate))
135 apiMux.HandleFunc("PUT /v1/llm/backends/{name}", s.requireScope(auth.ScopeBots, s.handleLLMBackendUpdate))
136 apiMux.HandleFunc("DELETE /v1/llm/backends/{name}", s.requireScope(auth.ScopeBots, s.handleLLMBackendDelete))
137 apiMux.HandleFunc("GET /v1/llm/backends/{name}/models", s.requireScope(auth.ScopeBots, s.handleLLMModels))
138 apiMux.HandleFunc("POST /v1/llm/discover", s.requireScope(auth.ScopeBots, s.handleLLMDiscover))
139 apiMux.HandleFunc("GET /v1/llm/known", s.requireScope(auth.ScopeBots, s.handleLLMKnown))
140 apiMux.HandleFunc("POST /v1/llm/complete", s.requireScope(auth.ScopeBots, s.handleLLMComplete))
141
142 outer := http.NewServeMux()
143 outer.HandleFunc("POST /login", s.handleLogin)
144 outer.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
145 http.Redirect(w, r, "/ui/", http.StatusFound)
146
--- internal/api/settings.go
+++ internal/api/settings.go
@@ -5,12 +5,13 @@
55
66
"github.com/conflicthq/scuttlebot/internal/config"
77
)
88
99
type settingsResponse struct {
10
- TLS tlsInfo `json:"tls"`
11
- Policies Policies `json:"policies"`
10
+ TLS tlsInfo `json:"tls"`
11
+ Policies Policies `json:"policies"`
12
+ BotCommands map[string][]BotCommand `json:"bot_commands,omitempty"`
1213
}
1314
1415
type tlsInfo struct {
1516
Enabled bool `json:"enabled"`
1617
Domain string `json:"domain,omitempty"`
@@ -33,10 +34,11 @@
3334
cfg := s.cfgStore.Get()
3435
resp.Policies.AgentPolicy = toAPIAgentPolicy(cfg.AgentPolicy)
3536
resp.Policies.Logging = toAPILogging(cfg.Logging)
3637
resp.Policies.Bridge.WebUserTTLMinutes = cfg.Bridge.WebUserTTLMinutes
3738
}
39
+ resp.BotCommands = botCommands
3840
writeJSON(w, http.StatusOK, resp)
3941
}
4042
4143
func toAPIAgentPolicy(c config.AgentPolicyConfig) AgentPolicy {
4244
return AgentPolicy{
4345
--- internal/api/settings.go
+++ internal/api/settings.go
@@ -5,12 +5,13 @@
5
6 "github.com/conflicthq/scuttlebot/internal/config"
7 )
8
9 type settingsResponse struct {
10 TLS tlsInfo `json:"tls"`
11 Policies Policies `json:"policies"`
 
12 }
13
14 type tlsInfo struct {
15 Enabled bool `json:"enabled"`
16 Domain string `json:"domain,omitempty"`
@@ -33,10 +34,11 @@
33 cfg := s.cfgStore.Get()
34 resp.Policies.AgentPolicy = toAPIAgentPolicy(cfg.AgentPolicy)
35 resp.Policies.Logging = toAPILogging(cfg.Logging)
36 resp.Policies.Bridge.WebUserTTLMinutes = cfg.Bridge.WebUserTTLMinutes
37 }
 
38 writeJSON(w, http.StatusOK, resp)
39 }
40
41 func toAPIAgentPolicy(c config.AgentPolicyConfig) AgentPolicy {
42 return AgentPolicy{
43
--- internal/api/settings.go
+++ internal/api/settings.go
@@ -5,12 +5,13 @@
5
6 "github.com/conflicthq/scuttlebot/internal/config"
7 )
8
9 type settingsResponse struct {
10 TLS tlsInfo `json:"tls"`
11 Policies Policies `json:"policies"`
12 BotCommands map[string][]BotCommand `json:"bot_commands,omitempty"`
13 }
14
15 type tlsInfo struct {
16 Enabled bool `json:"enabled"`
17 Domain string `json:"domain,omitempty"`
@@ -33,10 +34,11 @@
34 cfg := s.cfgStore.Get()
35 resp.Policies.AgentPolicy = toAPIAgentPolicy(cfg.AgentPolicy)
36 resp.Policies.Logging = toAPILogging(cfg.Logging)
37 resp.Policies.Bridge.WebUserTTLMinutes = cfg.Bridge.WebUserTTLMinutes
38 }
39 resp.BotCommands = botCommands
40 writeJSON(w, http.StatusOK, resp)
41 }
42
43 func toAPIAgentPolicy(c config.AgentPolicyConfig) AgentPolicy {
44 return AgentPolicy{
45
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -457,10 +457,11 @@
457457
<option value="revoked">revoked</option>
458458
</select>
459459
<div class="spacer"></div>
460460
<span class="badge" id="agent-count" style="margin-right:4px">0</span>
461461
<button class="sm" onclick="loadAgents()">↻ refresh</button>
462
+ <button class="sm danger" id="bulk-delete-btn" style="display:none" onclick="bulkDeleteAgents()">delete selected</button>
462463
<button class="sm primary" onclick="openDrawer()">+ register agent</button>
463464
</div>
464465
<div id="agent-pagination" style="display:none;padding:4px 16px;font-size:12px;color:#8b949e;display:flex;align-items:center;gap:8px">
465466
<button class="sm" id="agent-prev" onclick="agentPage--;renderAgentTable()">← prev</button>
466467
<span id="agent-page-info"></span>
@@ -485,10 +486,40 @@
485486
<button class="sm primary" onclick="quickJoin()">join</button>
486487
</div>
487488
</div>
488489
<div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div>
489490
</div>
491
+
492
+ <!-- topology panel -->
493
+ <div class="card" id="card-topology">
494
+ <div class="card-header" onclick="toggleCard('card-topology',event)">
495
+ <h2>topology</h2><span class="card-desc">channel types, provisioning rules, active task channels</span><span class="collapse-icon">▾</span>
496
+ <div class="spacer"></div>
497
+ <div style="display:flex;gap:6px;align-items:center">
498
+ <input type="text" id="provision-channel-input" placeholder="#project.name" style="width:160px;padding:5px 8px;font-size:12px" autocomplete="off">
499
+ <button class="sm primary" onclick="provisionChannel()">provision</button>
500
+ </div>
501
+ </div>
502
+ <div class="card-body" style="padding:0">
503
+ <div id="topology-types"></div>
504
+ <div id="topology-active" style="padding:12px 16px"><div class="empty">loading topology…</div></div>
505
+ </div>
506
+ </div>
507
+
508
+ <!-- ROE templates -->
509
+ <div class="card" id="card-roe">
510
+ <div class="card-header" onclick="toggleCard('card-roe',event)">
511
+ <h2>ROE templates</h2><span class="card-desc">rules-of-engagement presets for agent registration</span><span class="collapse-icon">▾</span>
512
+ <div class="spacer"></div>
513
+ <button class="sm primary" onclick="event.stopPropagation();savePolicies()">save</button>
514
+ </div>
515
+ <div class="card-body">
516
+ <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>
517
+ <div id="roe-list"></div>
518
+ <button class="sm" onclick="addROETemplate()" style="margin-top:10px">+ add template</button>
519
+ </div>
520
+ </div>
490521
</div>
491522
</div>
492523
493524
<!-- CHAT -->
494525
<div class="tab-pane" id="pane-chat">
@@ -504,11 +535,11 @@
504535
<div class="chan-list" id="chan-list"></div>
505536
</div>
506537
<div class="sidebar-resize" id="resize-left" title="drag to resize"></div>
507538
<div class="chat-main">
508539
<div class="chat-topbar">
509
- <span class="chat-ch-name" id="chat-ch-name">select a channel</span>
540
+ <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>
510541
<div class="spacer"></div>
511542
<span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span>
512543
<select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()">
513544
<option value="">— pick a user —</option>
514545
</select>
@@ -580,10 +611,40 @@
580611
<button type="submit" class="primary sm" style="margin-bottom:1px">add admin</button>
581612
</form>
582613
<div id="add-admin-result" style="margin-top:10px"></div>
583614
</div>
584615
</div>
616
+
617
+ <!-- api keys -->
618
+ <div class="card" id="card-apikeys">
619
+ <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>
620
+ <div id="apikeys-list-container"></div>
621
+ <div class="card-body" style="border-top:1px solid #21262d">
622
+ <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>
623
+ <form id="add-apikey-form" onsubmit="createAPIKey(event)" style="display:flex;flex-direction:column;gap:10px">
624
+ <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
625
+ <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>
626
+ <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>
627
+ </div>
628
+ <div>
629
+ <label style="margin-bottom:6px;display:block">scopes</label>
630
+ <div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px">
631
+ <label><input type="checkbox" value="admin" class="apikey-scope"> admin</label>
632
+ <label><input type="checkbox" value="agents" class="apikey-scope"> agents</label>
633
+ <label><input type="checkbox" value="channels" class="apikey-scope"> channels</label>
634
+ <label><input type="checkbox" value="chat" class="apikey-scope"> chat</label>
635
+ <label><input type="checkbox" value="topology" class="apikey-scope"> topology</label>
636
+ <label><input type="checkbox" value="bots" class="apikey-scope"> bots</label>
637
+ <label><input type="checkbox" value="config" class="apikey-scope"> config</label>
638
+ <label><input type="checkbox" value="read" class="apikey-scope"> read</label>
639
+ </div>
640
+ </div>
641
+ <button type="submit" class="primary sm" style="align-self:flex-start">create key</button>
642
+ </form>
643
+ <div id="add-apikey-result" style="margin-top:10px"></div>
644
+ </div>
645
+ </div>
585646
586647
<!-- tls -->
587648
<div class="card" id="card-tls">
588649
<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>
589650
<div class="card-body">
@@ -605,10 +666,28 @@
605666
</div>
606667
<div class="card-body" style="padding:0">
607668
<div id="behaviors-list"></div>
608669
</div>
609670
</div>
671
+
672
+ <!-- on-join instructions -->
673
+ <div class="card" id="card-onjoin">
674
+ <div class="card-header" onclick="toggleCard('card-onjoin',event)">
675
+ <h2>on-join instructions</h2><span class="card-desc">messages sent to agents when they join a channel</span><span class="collapse-icon">▾</span>
676
+ <div class="spacer"></div>
677
+ <button class="sm primary" onclick="event.stopPropagation();savePolicies()">save</button>
678
+ </div>
679
+ <div class="card-body">
680
+ <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>
681
+ <div id="onjoin-list"></div>
682
+ <div style="display:flex;gap:8px;margin-top:12px;align-items:flex-end">
683
+ <div style="flex:0 0 160px"><label>channel</label><input type="text" id="onjoin-new-channel" placeholder="#channel" style="width:100%"></div>
684
+ <div style="flex:1"><label>message</label><input type="text" id="onjoin-new-message" placeholder="Welcome to {channel}, {nick}!" style="width:100%"></div>
685
+ <button class="sm primary" onclick="addOnJoinMessage()">add</button>
686
+ </div>
687
+ </div>
688
+ </div>
610689
611690
<!-- agent policy -->
612691
<div class="card" id="card-agentpolicy">
613692
<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>
614693
<div class="card-body">
@@ -798,10 +877,31 @@
798877
</div>
799878
<div class="setting-row">
800879
<div class="setting-label">IRC address</div>
801880
<div class="setting-desc">Address Ergo listens on for IRC connections. Requires restart.</div>
802881
<input type="text" id="ergo-irc-addr" placeholder="127.0.0.1:6667" style="width:180px;padding:4px 8px;font-size:12px">
882
+ </div>
883
+ <div class="setting-row">
884
+ <div class="setting-label">require SASL</div>
885
+ <div class="setting-desc">Enforce SASL authentication for all IRC connections. Only registered accounts can connect. Hot-reloads.</div>
886
+ <label style="display:flex;align-items:center;gap:6px;cursor:pointer">
887
+ <input type="checkbox" id="ergo-require-sasl">
888
+ <span style="font-size:12px">enforce SASL</span>
889
+ </label>
890
+ </div>
891
+ <div class="setting-row">
892
+ <div class="setting-label">default channel modes</div>
893
+ <div class="setting-desc">Modes applied to new channels (e.g. "+n", "+Rn"). Hot-reloads.</div>
894
+ <input type="text" id="ergo-default-modes" placeholder="+n" style="width:120px;padding:4px 8px;font-size:12px">
895
+ </div>
896
+ <div class="setting-row">
897
+ <div class="setting-label">message history</div>
898
+ <div class="setting-desc">Enable persistent message history (CHATHISTORY). Hot-reloads.</div>
899
+ <label style="display:flex;align-items:center;gap:6px;cursor:pointer">
900
+ <input type="checkbox" id="ergo-history-enabled">
901
+ <span style="font-size:12px">enabled</span>
902
+ </label>
803903
</div>
804904
<div class="setting-row">
805905
<div class="setting-label">external mode</div>
806906
<div class="setting-desc">Disable subprocess management — scuttlebot expects Ergo to already be running. Requires restart.</div>
807907
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
@@ -1578,11 +1678,11 @@
15781678
const chs = (a.config?.channels||[]).map(c=>`<span class="tag ch">${esc(c)}</span>`).join('');
15791679
const rev = a.revoked ? '<span class="tag revoked">revoked</span>' : '';
15801680
const seen = a.last_seen ? relTime(a.last_seen) : 'never';
15811681
const seenStyle = a.online ? 'color:#3fb950' : 'color:#8b949e';
15821682
return `<tr${a.revoked?' style="opacity:0.5"':''}>
1583
- <td>${presenceDot(a)} <strong>${esc(a.nick)}</strong></td>
1683
+ <td><input type="checkbox" class="agent-select" value="${esc(a.nick)}" onchange="updateBulkBtn()" style="margin-right:6px">${presenceDot(a)} <strong>${esc(a.nick)}</strong></td>
15841684
<td><span class="tag type-${a.type}">${esc(a.type)}</span>${rev}</td>
15851685
<td>${chs||'<span style="color:#8b949e">—</span>'}</td>
15861686
<td style="white-space:nowrap;${seenStyle}">${seen}</td>
15871687
<td><div class="actions">${!a.revoked?`
15881688
<button class="sm" onclick="rotateAgent('${esc(a.nick)}')">rotate</button>
@@ -1590,11 +1690,11 @@
15901690
<button class="sm danger" onclick="deleteAgent('${esc(a.nick)}')">delete</button></div></td>
15911691
</tr>`;
15921692
});
15931693
renderTable('agents-container', null, rows,
15941694
bots.length ? 'no agents match the filter' : 'no agents registered yet',
1595
- ['nick','type','channels','last seen','']);
1695
+ ['<input type="checkbox" id="agent-select-all" onchange="toggleSelectAllAgents(this.checked)" style="margin-right:6px">nick','type','channels','last seen','']);
15961696
}
15971697
15981698
async function revokeAgent(nick) {
15991699
if (!confirm(`Revoke "${nick}"? This cannot be undone.`)) return;
16001700
try { await api('POST', `/v1/agents/${nick}/revoke`); await loadAgents(); await loadStatus(); }
@@ -1603,10 +1703,31 @@
16031703
async function deleteAgent(nick) {
16041704
if (!confirm(`Delete "${nick}"? This permanently removes the agent from the registry.`)) return;
16051705
try { await api('DELETE', `/v1/agents/${nick}`); await loadAgents(); await loadStatus(); }
16061706
catch(e) { alert('Delete failed: '+e.message); }
16071707
}
1708
+function toggleSelectAllAgents(checked) {
1709
+ document.querySelectorAll('.agent-select').forEach(cb => cb.checked = checked);
1710
+ updateBulkBtn();
1711
+}
1712
+function updateBulkBtn() {
1713
+ const checked = document.querySelectorAll('.agent-select:checked');
1714
+ const btn = document.getElementById('bulk-delete-btn');
1715
+ btn.style.display = checked.length > 0 ? '' : 'none';
1716
+ btn.textContent = `delete selected (${checked.length})`;
1717
+}
1718
+async function bulkDeleteAgents() {
1719
+ const nicks = [...document.querySelectorAll('.agent-select:checked')].map(cb => cb.value);
1720
+ if (!nicks.length) return;
1721
+ if (!confirm(`Delete ${nicks.length} agent(s)? This permanently removes them from the registry.\n\n${nicks.join(', ')}`)) return;
1722
+ try {
1723
+ const result = await api('POST', '/v1/agents/bulk-delete', {nicks});
1724
+ await loadAgents();
1725
+ await loadStatus();
1726
+ if (result.failed > 0) alert(`Deleted ${result.deleted}, failed ${result.failed}`);
1727
+ } catch(e) { alert('Bulk delete failed: ' + e.message); }
1728
+}
16081729
async function rotateAgent(nick) {
16091730
try {
16101731
const creds = await api('POST', `/v1/agents/${nick}/rotate`);
16111732
// Show result in whichever drawer is relevant.
16121733
showCredentials(nick, creds, null, 'rotate');
@@ -1726,10 +1847,19 @@
17261847
allChannels = (data.channels || []).sort();
17271848
renderChanList();
17281849
} catch(e) {
17291850
document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>';
17301851
}
1852
+ loadTopology();
1853
+ // Load ROE templates from policies for the ROE card.
1854
+ try {
1855
+ const s = await api('GET', '/v1/settings');
1856
+ if (s && s.policies) {
1857
+ currentPolicies = s.policies;
1858
+ renderROETemplates(s.policies.roe_templates || []);
1859
+ }
1860
+ } catch(e) {}
17311861
}
17321862
17331863
function renderChanList() {
17341864
const q = (document.getElementById('chan-search').value||'').toLowerCase();
17351865
const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q));
@@ -1764,10 +1894,138 @@
17641894
await loadChanTab();
17651895
renderChanSidebar((await api('GET','/v1/channels')).channels||[]);
17661896
} catch(e) { alert('Join failed: '+e.message); }
17671897
}
17681898
document.getElementById('quick-join-input').addEventListener('keydown', e => { if(e.key==='Enter')quickJoin(); });
1899
+
1900
+// --- topology panel (#115) + task channels (#114) ---
1901
+async function loadTopology() {
1902
+ try {
1903
+ const data = await api('GET', '/v1/topology');
1904
+ renderTopologyTypes(data.types || []);
1905
+ renderTopologyActive(data.active_channels || [], data.types || []);
1906
+ } catch(e) {
1907
+ document.getElementById('topology-types').innerHTML = '';
1908
+ document.getElementById('topology-active').innerHTML = '<div style="color:#8b949e;font-size:12px">topology not configured</div>';
1909
+ }
1910
+}
1911
+
1912
+function renderTopologyTypes(types) {
1913
+ if (!types.length) { document.getElementById('topology-types').innerHTML = ''; return; }
1914
+ const rows = types.map(t => {
1915
+ const ttl = t.ttl_seconds > 0 ? `${Math.round(t.ttl_seconds/3600)}h` : '—';
1916
+ const tags = [];
1917
+ if (t.ephemeral) tags.push('<span style="background:#f8514922;color:#f85149;padding:1px 5px;border-radius:3px;font-size:10px">ephemeral</span>');
1918
+ if (t.supervision) tags.push(`<span style="font-size:11px;color:#8b949e">→ ${esc(t.supervision)}</span>`);
1919
+ return `<tr>
1920
+ <td><strong>${esc(t.name)}</strong></td>
1921
+ <td><code style="font-size:11px">#${esc(t.prefix)}*</code></td>
1922
+ <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>
1923
+ <td style="font-size:12px">${ttl}</td>
1924
+ <td>${tags.join(' ')}</td>
1925
+ </tr>`;
1926
+ }).join('');
1927
+ 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>`;
1928
+}
1929
+
1930
+function renderTopologyActive(channels, types) {
1931
+ const el = document.getElementById('topology-active');
1932
+ const tasks = channels.filter(c => c.ephemeral || c.type === 'task');
1933
+ if (!tasks.length) {
1934
+ el.innerHTML = '<div style="color:#8b949e;font-size:12px;padding:4px 0">no active task channels</div>';
1935
+ return;
1936
+ }
1937
+ const rows = tasks.map(c => {
1938
+ const age = c.provisioned_at ? timeSince(new Date(c.provisioned_at)) : '—';
1939
+ const ttl = c.ttl_seconds > 0 ? `${Math.round(c.ttl_seconds/3600)}h` : '—';
1940
+ return `<tr>
1941
+ <td><strong>${esc(c.name)}</strong></td>
1942
+ <td style="font-size:12px;color:#8b949e">${esc(c.type || '—')}</td>
1943
+ <td style="font-size:12px">${age}</td>
1944
+ <td style="font-size:12px">${ttl}</td>
1945
+ <td><button class="sm danger" onclick="dropChannel('${esc(c.name)}')">drop</button></td>
1946
+ </tr>`;
1947
+ }).join('');
1948
+ 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>`;
1949
+}
1950
+
1951
+function timeSince(date) {
1952
+ const s = Math.floor((new Date() - date) / 1000);
1953
+ if (s < 60) return s + 's';
1954
+ if (s < 3600) return Math.floor(s/60) + 'm';
1955
+ if (s < 86400) return Math.floor(s/3600) + 'h';
1956
+ return Math.floor(s/86400) + 'd';
1957
+}
1958
+
1959
+async function provisionChannel() {
1960
+ let ch = document.getElementById('provision-channel-input').value.trim();
1961
+ if (!ch) return;
1962
+ if (!ch.startsWith('#')) ch = '#' + ch;
1963
+ try {
1964
+ await api('POST', '/v1/channels', {name: ch});
1965
+ document.getElementById('provision-channel-input').value = '';
1966
+ loadTopology();
1967
+ loadChanTab();
1968
+ } catch(e) { alert('Provision failed: ' + e.message); }
1969
+}
1970
+
1971
+async function dropChannel(ch) {
1972
+ if (!confirm('Drop channel ' + ch + '? This unregisters it from ChanServ.')) return;
1973
+ const slug = ch.replace(/^#/,'');
1974
+ try {
1975
+ await api('DELETE', `/v1/topology/channels/${slug}`);
1976
+ loadTopology();
1977
+ loadChanTab();
1978
+ } catch(e) { alert('Drop failed: ' + e.message); }
1979
+}
1980
+
1981
+// --- ROE template editor (#118) ---
1982
+function renderROETemplates(templates) {
1983
+ const el = document.getElementById('roe-list');
1984
+ if (!templates || !templates.length) {
1985
+ el.innerHTML = '<div style="color:#8b949e;font-size:12px">No ROE templates defined. Click + add template to create one.</div>';
1986
+ return;
1987
+ }
1988
+ el.innerHTML = templates.map((t, i) => `
1989
+ <div style="border:1px solid #21262d;border-radius:6px;padding:12px;margin-bottom:10px;background:#0d1117">
1990
+ <div style="display:flex;gap:10px;align-items:center;margin-bottom:8px">
1991
+ <input type="text" value="${esc(t.name)}" placeholder="template name" style="flex:1;font-weight:600" onchange="updateROE(${i},'name',this.value)">
1992
+ <button class="sm danger" onclick="removeROE(${i})">remove</button>
1993
+ </div>
1994
+ <div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:6px">
1995
+ <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>
1996
+ <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>
1997
+ </div>
1998
+ <div style="display:flex;gap:10px">
1999
+ <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>
2000
+ <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>
2001
+ <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>
2002
+ </div>
2003
+ </div>
2004
+ `).join('');
2005
+}
2006
+
2007
+function addROETemplate() {
2008
+ if (!currentPolicies.roe_templates) currentPolicies.roe_templates = [];
2009
+ currentPolicies.roe_templates.push({name: 'new-template', channels: [], permissions: []});
2010
+ renderROETemplates(currentPolicies.roe_templates);
2011
+}
2012
+function removeROE(i) {
2013
+ currentPolicies.roe_templates.splice(i, 1);
2014
+ renderROETemplates(currentPolicies.roe_templates);
2015
+}
2016
+function updateROE(i, field, val) {
2017
+ if (field === 'channels' || field === 'permissions') {
2018
+ currentPolicies.roe_templates[i][field] = val.split(',').map(s => s.trim()).filter(Boolean);
2019
+ } else {
2020
+ currentPolicies.roe_templates[i][field] = val;
2021
+ }
2022
+}
2023
+function updateROERateLimit(i, field, val) {
2024
+ if (!currentPolicies.roe_templates[i].rate_limit) currentPolicies.roe_templates[i].rate_limit = {};
2025
+ currentPolicies.roe_templates[i].rate_limit[field] = Number(val) || 0;
2026
+}
17692027
17702028
// --- chat ---
17712029
let chatChannel = null, chatSSE = null;
17722030
17732031
async function loadChannels() {
@@ -1838,11 +2096,11 @@
18382096
async function loadNicklist(ch) {
18392097
if (!ch) return;
18402098
try {
18412099
const slug = ch.replace(/^#/,'');
18422100
const data = await api('GET', `/v1/channels/${slug}/users`);
1843
- renderNicklist(data.users || []);
2101
+ renderNicklist(data.users || [], data.channel_modes || '');
18442102
} catch(e) {}
18452103
}
18462104
const SYSTEM_BOTS = new Set(['bridge','oracle','sentinel','steward','scribe','warden','snitch','herald','scroll','systembot','auditbot']);
18472105
const AGENT_PREFIXES = ['claude-','codex-','gemini-','openclaw-'];
18482106
@@ -1861,24 +2119,35 @@
18612119
if (tier === 0) return '@';
18622120
if (tier === 1) return '+';
18632121
return '';
18642122
}
18652123
1866
-function renderNicklist(users) {
2124
+function renderNicklist(users, channelModes) {
18672125
const el = document.getElementById('nicklist-users');
2126
+ // users may be [{nick, modes}] or ["nick"] for backwards compat.
2127
+ const normalized = users.map(u => typeof u === 'string' ? {nick: u, modes: []} : u);
18682128
// 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);
2129
+ const sorted = normalized.slice().sort((a, b) => {
2130
+ const ta = nickTier(a.nick), tb = nickTier(b.nick);
18712131
if (ta !== tb) return ta - tb;
1872
- return a.localeCompare(b);
2132
+ return a.nick.localeCompare(b.nick);
18732133
});
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>`;
2134
+ el.innerHTML = sorted.map(u => {
2135
+ const modes = u.modes || [];
2136
+ // IRC mode prefix: @ for op, + for voice
2137
+ let prefix = '';
2138
+ if (modes.includes('o') || modes.includes('a') || modes.includes('q')) prefix = '@';
2139
+ else if (modes.includes('v')) prefix = '+';
2140
+ else prefix = nickPrefix(u.nick);
2141
+ const tier = nickTier(u.nick);
2142
+ const cls = (modes.includes('o') || tier === 0) ? ' is-op' : tier === 1 ? ' is-bot' : '';
2143
+ const modeStr = modes.length ? ` [+${modes.join('')}]` : '';
2144
+ return `<div class="nicklist-nick${cls}" title="${esc(u.nick)}${modeStr}">${prefix}${esc(u.nick)}</div>`;
18792145
}).join('');
2146
+ // Show channel modes in header if available.
2147
+ const modesEl = document.getElementById('chat-channel-modes');
2148
+ if (modesEl) modesEl.textContent = channelModes ? ` ${channelModes}` : '';
18802149
}
18812150
// Nick colors — deterministic hash over a palette
18822151
const NICK_PALETTE = ['#58a6ff','#3fb950','#ffa657','#d2a8ff','#56d364','#79c0ff','#ff7b72','#a5d6ff','#f0883e','#39d353'];
18832152
function nickColor(nick) {
18842153
let h = 0;
@@ -1892,14 +2161,16 @@
18922161
let _chatUnread = 0;
18932162
18942163
function appendMsg(msg, isHistory) {
18952164
const area = document.getElementById('chat-msgs');
18962165
1897
- // Parse "[nick] text" sent by the bridge bot on behalf of a web user
2166
+ // Attribution: RELAYMSG delivers nicks as "user/bridge"; legacy uses "[nick] text".
18982167
let displayNick = msg.nick;
18992168
let displayText = msg.text;
1900
- if (msg.nick === 'bridge') {
2169
+ if (msg.nick && msg.nick.endsWith('/bridge')) {
2170
+ displayNick = msg.nick.slice(0, -'/bridge'.length);
2171
+ } else if (msg.nick === 'bridge') {
19012172
const m = msg.text.match(/^\[([^\]]+)\] ([\s\S]*)$/);
19022173
if (m) { displayNick = m[1]; displayText = m[2]; }
19032174
}
19042175
19052176
const atMs = new Date(msg.at).getTime();
@@ -2542,10 +2813,73 @@
25422813
try {
25432814
await api('PUT', `/v1/admins/${encodeURIComponent(username)}/password`, { password: pw });
25442815
alert('Password updated.');
25452816
} catch(e) { alert('Failed: ' + e.message); }
25462817
}
2818
+
2819
+// --- API keys ---
2820
+async function loadAPIKeys() {
2821
+ try {
2822
+ const keys = await api('GET', '/v1/api-keys');
2823
+ renderAPIKeys(keys || []);
2824
+ } catch(e) {
2825
+ document.getElementById('apikeys-list-container').innerHTML = '';
2826
+ }
2827
+}
2828
+
2829
+function renderAPIKeys(keys) {
2830
+ const el = document.getElementById('apikeys-list-container');
2831
+ if (!keys.length) { el.innerHTML = ''; return; }
2832
+ const rows = keys.map(k => {
2833
+ const status = k.active ? '<span style="color:#3fb950">active</span>' : '<span style="color:#f85149">revoked</span>';
2834
+ const scopes = (k.scopes || []).map(s => `<code style="font-size:11px;background:#21262d;padding:1px 5px;border-radius:3px">${esc(s)}</code>`).join(' ');
2835
+ const lastUsed = k.last_used ? fmtTime(k.last_used) : '—';
2836
+ const revokeBtn = k.active ? `<button class="sm danger" onclick="revokeAPIKey('${esc(k.id)}')">revoke</button>` : '';
2837
+ return `<tr>
2838
+ <td><strong>${esc(k.name)}</strong><br><span style="color:#8b949e;font-size:11px">${esc(k.id)}</span></td>
2839
+ <td>${scopes}</td>
2840
+ <td style="font-size:12px">${status}</td>
2841
+ <td style="color:#8b949e;font-size:12px">${lastUsed}</td>
2842
+ <td><div class="actions">${revokeBtn}</div></td>
2843
+ </tr>`;
2844
+ }).join('');
2845
+ 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>`;
2846
+}
2847
+
2848
+async function createAPIKey(e) {
2849
+ e.preventDefault();
2850
+ const name = document.getElementById('new-apikey-name').value.trim();
2851
+ const expires = document.getElementById('new-apikey-expires').value.trim();
2852
+ const scopes = [...document.querySelectorAll('.apikey-scope:checked')].map(cb => cb.value);
2853
+ const resultEl = document.getElementById('add-apikey-result');
2854
+ if (!name) { resultEl.innerHTML = '<span style="color:#f85149">name is required</span>'; return; }
2855
+ if (!scopes.length) { resultEl.innerHTML = '<span style="color:#f85149">select at least one scope</span>'; return; }
2856
+ try {
2857
+ const body = { name, scopes };
2858
+ if (expires) body.expires_in = expires;
2859
+ const result = await api('POST', '/v1/api-keys', body);
2860
+ resultEl.innerHTML = `<div style="background:#0d1117;border:1px solid #3fb95044;border-radius:6px;padding:12px;margin-top:8px">
2861
+ <div style="color:#3fb950;font-weight:600;margin-bottom:6px">Key created: ${esc(result.name)}</div>
2862
+ <div style="margin-bottom:4px;font-size:12px;color:#8b949e">Copy this token now — it will not be shown again:</div>
2863
+ <code style="display:block;padding:8px;background:#161b22;border-radius:4px;word-break:break-all;user-select:all">${esc(result.token)}</code>
2864
+ </div>`;
2865
+ document.getElementById('new-apikey-name').value = '';
2866
+ document.getElementById('new-apikey-expires').value = '';
2867
+ document.querySelectorAll('.apikey-scope:checked').forEach(cb => cb.checked = false);
2868
+ loadAPIKeys();
2869
+ } catch(e) {
2870
+ resultEl.innerHTML = `<span style="color:#f85149">${esc(e.message)}</span>`;
2871
+ }
2872
+}
2873
+
2874
+async function revokeAPIKey(id) {
2875
+ if (!confirm('Revoke this API key? This cannot be undone.')) return;
2876
+ try {
2877
+ await api('DELETE', `/v1/api-keys/${encodeURIComponent(id)}`);
2878
+ loadAPIKeys();
2879
+ } catch(e) { alert('Failed: ' + e.message); }
2880
+}
25472881
25482882
// --- AI / LLM tab ---
25492883
async function loadAI() {
25502884
await Promise.all([loadAIBackends(), loadAIKnown()]);
25512885
}
@@ -2899,10 +3233,41 @@
28993233
if (body) body.style.display = '';
29003234
}
29013235
29023236
// --- settings / policies ---
29033237
let currentPolicies = null;
3238
+let _botCommands = {};
3239
+
3240
+function renderOnJoinMessages(msgs) {
3241
+ const el = document.getElementById('onjoin-list');
3242
+ if (!msgs || !Object.keys(msgs).length) { el.innerHTML = '<div style="color:#8b949e;font-size:12px">No on-join instructions configured.</div>'; return; }
3243
+ el.innerHTML = Object.entries(msgs).sort().map(([ch, msg]) => `
3244
+ <div style="display:flex;gap:8px;align-items:center;padding:6px 0;border-bottom:1px solid #21262d">
3245
+ <code style="font-size:12px;min-width:120px">${esc(ch)}</code>
3246
+ <input type="text" value="${esc(msg)}" style="flex:1;font-size:12px" onchange="updateOnJoinMessage('${esc(ch)}',this.value)">
3247
+ <button class="sm danger" onclick="removeOnJoinMessage('${esc(ch)}')">remove</button>
3248
+ </div>
3249
+ `).join('');
3250
+}
3251
+function addOnJoinMessage() {
3252
+ const ch = document.getElementById('onjoin-new-channel').value.trim();
3253
+ const msg = document.getElementById('onjoin-new-message').value.trim();
3254
+ if (!ch || !msg) return;
3255
+ if (!currentPolicies.on_join_messages) currentPolicies.on_join_messages = {};
3256
+ currentPolicies.on_join_messages[ch] = msg;
3257
+ document.getElementById('onjoin-new-channel').value = '';
3258
+ document.getElementById('onjoin-new-message').value = '';
3259
+ renderOnJoinMessages(currentPolicies.on_join_messages);
3260
+}
3261
+function updateOnJoinMessage(ch, msg) {
3262
+ if (!currentPolicies.on_join_messages) currentPolicies.on_join_messages = {};
3263
+ currentPolicies.on_join_messages[ch] = msg;
3264
+}
3265
+function removeOnJoinMessage(ch) {
3266
+ if (currentPolicies.on_join_messages) delete currentPolicies.on_join_messages[ch];
3267
+ renderOnJoinMessages(currentPolicies.on_join_messages);
3268
+}
29043269
let _llmBackendNames = []; // cached backend names for oracle dropdown
29053270
29063271
async function loadSettings() {
29073272
try {
29083273
const [s, backends] = await Promise.all([
@@ -2910,15 +3275,18 @@
29103275
api('GET', '/v1/llm/backends').catch(() => []),
29113276
]);
29123277
_llmBackendNames = (backends || []).map(b => b.name);
29133278
renderTLSStatus(s.tls);
29143279
currentPolicies = s.policies;
3280
+ _botCommands = s.bot_commands || {};
29153281
renderBehaviors(s.policies.behaviors || []);
3282
+ renderOnJoinMessages(s.policies.on_join_messages || {});
29163283
renderAgentPolicy(s.policies.agent_policy || {});
29173284
renderBridgePolicy(s.policies.bridge || {});
29183285
renderLoggingPolicy(s.policies.logging || {});
29193286
loadAdmins();
3287
+ loadAPIKeys();
29203288
loadConfigCards();
29213289
} catch(e) {
29223290
document.getElementById('tls-badge').textContent = 'error';
29233291
}
29243292
}
@@ -2973,10 +3341,14 @@
29733341
` : ''}
29743342
<span class="tag type-observer" style="font-size:11px;min-width:64px;text-align:center">${esc(b.nick)}</span>
29753343
</div>
29763344
</div>
29773345
${b.enabled && hasSchema(b.id) ? renderBehConfig(b) : ''}
3346
+ ${_botCommands[b.id] ? `<div style="padding:6px 16px 8px 42px;border-bottom:1px solid #21262d;background:#0d1117">
3347
+ <span style="font-size:11px;color:#8b949e;font-weight:600">commands:</span>
3348
+ ${_botCommands[b.id].map(c => `<code style="font-size:11px;margin-left:8px;background:#161b22;padding:1px 5px;border-radius:3px" title="${esc(c.description)}&#10;${esc(c.usage)}">${esc(c.command)}</code>`).join('')}
3349
+ </div>` : ''}
29783350
</div>
29793351
`).join('');
29803352
}
29813353
29823354
function onBehaviorToggle(id, enabled) {
@@ -3222,14 +3594,17 @@
32223594
// general
32233595
document.getElementById('general-api-addr').value = cfg.api_addr || '';
32243596
document.getElementById('general-mcp-addr').value = cfg.mcp_addr || '';
32253597
// ergo
32263598
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;
3599
+ document.getElementById('ergo-network-name').value = e.network_name || '';
3600
+ document.getElementById('ergo-server-name').value = e.server_name || '';
3601
+ document.getElementById('ergo-irc-addr').value = e.irc_addr || '';
3602
+ document.getElementById('ergo-require-sasl').checked = !!e.require_sasl;
3603
+ document.getElementById('ergo-default-modes').value = e.default_channel_modes || '';
3604
+ document.getElementById('ergo-history-enabled').checked = !!(e.history && e.history.enabled);
3605
+ document.getElementById('ergo-external').checked = !!e.external;
32313606
// tls
32323607
const t = cfg.tls || {};
32333608
document.getElementById('tls-domain').value = t.domain || '';
32343609
document.getElementById('tls-email').value = t.email || '';
32353610
document.getElementById('tls-allow-insecure').checked = !!t.allow_insecure;
@@ -3302,14 +3677,17 @@
33023677
}
33033678
33043679
function saveErgoConfig() {
33053680
saveConfigPatch({
33063681
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,
3682
+ network_name: document.getElementById('ergo-network-name').value.trim() || undefined,
3683
+ server_name: document.getElementById('ergo-server-name').value.trim() || undefined,
3684
+ irc_addr: document.getElementById('ergo-irc-addr').value.trim() || undefined,
3685
+ require_sasl: document.getElementById('ergo-require-sasl').checked,
3686
+ default_channel_modes: document.getElementById('ergo-default-modes').value.trim() || undefined,
3687
+ history: { enabled: document.getElementById('ergo-history-enabled').checked },
3688
+ external: document.getElementById('ergo-external').checked,
33113689
}
33123690
}, 'ergo-save-result');
33133691
}
33143692
33153693
function saveTLSConfig() {
33163694
33173695
ADDED internal/auth/apikeys.go
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -457,10 +457,11 @@
457 <option value="revoked">revoked</option>
458 </select>
459 <div class="spacer"></div>
460 <span class="badge" id="agent-count" style="margin-right:4px">0</span>
461 <button class="sm" onclick="loadAgents()">↻ refresh</button>
 
462 <button class="sm primary" onclick="openDrawer()">+ register agent</button>
463 </div>
464 <div id="agent-pagination" style="display:none;padding:4px 16px;font-size:12px;color:#8b949e;display:flex;align-items:center;gap:8px">
465 <button class="sm" id="agent-prev" onclick="agentPage--;renderAgentTable()">← prev</button>
466 <span id="agent-page-info"></span>
@@ -485,10 +486,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 +535,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 +611,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 +666,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 +877,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">
@@ -1578,11 +1678,11 @@
1578 const chs = (a.config?.channels||[]).map(c=>`<span class="tag ch">${esc(c)}</span>`).join('');
1579 const rev = a.revoked ? '<span class="tag revoked">revoked</span>' : '';
1580 const seen = a.last_seen ? relTime(a.last_seen) : 'never';
1581 const seenStyle = a.online ? 'color:#3fb950' : 'color:#8b949e';
1582 return `<tr${a.revoked?' style="opacity:0.5"':''}>
1583 <td>${presenceDot(a)} <strong>${esc(a.nick)}</strong></td>
1584 <td><span class="tag type-${a.type}">${esc(a.type)}</span>${rev}</td>
1585 <td>${chs||'<span style="color:#8b949e">—</span>'}</td>
1586 <td style="white-space:nowrap;${seenStyle}">${seen}</td>
1587 <td><div class="actions">${!a.revoked?`
1588 <button class="sm" onclick="rotateAgent('${esc(a.nick)}')">rotate</button>
@@ -1590,11 +1690,11 @@
1590 <button class="sm danger" onclick="deleteAgent('${esc(a.nick)}')">delete</button></div></td>
1591 </tr>`;
1592 });
1593 renderTable('agents-container', null, rows,
1594 bots.length ? 'no agents match the filter' : 'no agents registered yet',
1595 ['nick','type','channels','last seen','']);
1596 }
1597
1598 async function revokeAgent(nick) {
1599 if (!confirm(`Revoke "${nick}"? This cannot be undone.`)) return;
1600 try { await api('POST', `/v1/agents/${nick}/revoke`); await loadAgents(); await loadStatus(); }
@@ -1603,10 +1703,31 @@
1603 async function deleteAgent(nick) {
1604 if (!confirm(`Delete "${nick}"? This permanently removes the agent from the registry.`)) return;
1605 try { await api('DELETE', `/v1/agents/${nick}`); await loadAgents(); await loadStatus(); }
1606 catch(e) { alert('Delete failed: '+e.message); }
1607 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1608 async function rotateAgent(nick) {
1609 try {
1610 const creds = await api('POST', `/v1/agents/${nick}/rotate`);
1611 // Show result in whichever drawer is relevant.
1612 showCredentials(nick, creds, null, 'rotate');
@@ -1726,10 +1847,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 +1894,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 +2096,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 +2119,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 +2161,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 +2813,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 +3233,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 +3275,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 +3341,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 +3594,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 +3677,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
@@ -457,10 +457,11 @@
457 <option value="revoked">revoked</option>
458 </select>
459 <div class="spacer"></div>
460 <span class="badge" id="agent-count" style="margin-right:4px">0</span>
461 <button class="sm" onclick="loadAgents()">↻ refresh</button>
462 <button class="sm danger" id="bulk-delete-btn" style="display:none" onclick="bulkDeleteAgents()">delete selected</button>
463 <button class="sm primary" onclick="openDrawer()">+ register agent</button>
464 </div>
465 <div id="agent-pagination" style="display:none;padding:4px 16px;font-size:12px;color:#8b949e;display:flex;align-items:center;gap:8px">
466 <button class="sm" id="agent-prev" onclick="agentPage--;renderAgentTable()">← prev</button>
467 <span id="agent-page-info"></span>
@@ -485,10 +486,40 @@
486 <button class="sm primary" onclick="quickJoin()">join</button>
487 </div>
488 </div>
489 <div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div>
490 </div>
491
492 <!-- topology panel -->
493 <div class="card" id="card-topology">
494 <div class="card-header" onclick="toggleCard('card-topology',event)">
495 <h2>topology</h2><span class="card-desc">channel types, provisioning rules, active task channels</span><span class="collapse-icon">▾</span>
496 <div class="spacer"></div>
497 <div style="display:flex;gap:6px;align-items:center">
498 <input type="text" id="provision-channel-input" placeholder="#project.name" style="width:160px;padding:5px 8px;font-size:12px" autocomplete="off">
499 <button class="sm primary" onclick="provisionChannel()">provision</button>
500 </div>
501 </div>
502 <div class="card-body" style="padding:0">
503 <div id="topology-types"></div>
504 <div id="topology-active" style="padding:12px 16px"><div class="empty">loading topology…</div></div>
505 </div>
506 </div>
507
508 <!-- ROE templates -->
509 <div class="card" id="card-roe">
510 <div class="card-header" onclick="toggleCard('card-roe',event)">
511 <h2>ROE templates</h2><span class="card-desc">rules-of-engagement presets for agent registration</span><span class="collapse-icon">▾</span>
512 <div class="spacer"></div>
513 <button class="sm primary" onclick="event.stopPropagation();savePolicies()">save</button>
514 </div>
515 <div class="card-body">
516 <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>
517 <div id="roe-list"></div>
518 <button class="sm" onclick="addROETemplate()" style="margin-top:10px">+ add template</button>
519 </div>
520 </div>
521 </div>
522 </div>
523
524 <!-- CHAT -->
525 <div class="tab-pane" id="pane-chat">
@@ -504,11 +535,11 @@
535 <div class="chan-list" id="chan-list"></div>
536 </div>
537 <div class="sidebar-resize" id="resize-left" title="drag to resize"></div>
538 <div class="chat-main">
539 <div class="chat-topbar">
540 <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>
541 <div class="spacer"></div>
542 <span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span>
543 <select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()">
544 <option value="">— pick a user —</option>
545 </select>
@@ -580,10 +611,40 @@
611 <button type="submit" class="primary sm" style="margin-bottom:1px">add admin</button>
612 </form>
613 <div id="add-admin-result" style="margin-top:10px"></div>
614 </div>
615 </div>
616
617 <!-- api keys -->
618 <div class="card" id="card-apikeys">
619 <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>
620 <div id="apikeys-list-container"></div>
621 <div class="card-body" style="border-top:1px solid #21262d">
622 <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>
623 <form id="add-apikey-form" onsubmit="createAPIKey(event)" style="display:flex;flex-direction:column;gap:10px">
624 <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
625 <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>
626 <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>
627 </div>
628 <div>
629 <label style="margin-bottom:6px;display:block">scopes</label>
630 <div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px">
631 <label><input type="checkbox" value="admin" class="apikey-scope"> admin</label>
632 <label><input type="checkbox" value="agents" class="apikey-scope"> agents</label>
633 <label><input type="checkbox" value="channels" class="apikey-scope"> channels</label>
634 <label><input type="checkbox" value="chat" class="apikey-scope"> chat</label>
635 <label><input type="checkbox" value="topology" class="apikey-scope"> topology</label>
636 <label><input type="checkbox" value="bots" class="apikey-scope"> bots</label>
637 <label><input type="checkbox" value="config" class="apikey-scope"> config</label>
638 <label><input type="checkbox" value="read" class="apikey-scope"> read</label>
639 </div>
640 </div>
641 <button type="submit" class="primary sm" style="align-self:flex-start">create key</button>
642 </form>
643 <div id="add-apikey-result" style="margin-top:10px"></div>
644 </div>
645 </div>
646
647 <!-- tls -->
648 <div class="card" id="card-tls">
649 <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>
650 <div class="card-body">
@@ -605,10 +666,28 @@
666 </div>
667 <div class="card-body" style="padding:0">
668 <div id="behaviors-list"></div>
669 </div>
670 </div>
671
672 <!-- on-join instructions -->
673 <div class="card" id="card-onjoin">
674 <div class="card-header" onclick="toggleCard('card-onjoin',event)">
675 <h2>on-join instructions</h2><span class="card-desc">messages sent to agents when they join a channel</span><span class="collapse-icon">▾</span>
676 <div class="spacer"></div>
677 <button class="sm primary" onclick="event.stopPropagation();savePolicies()">save</button>
678 </div>
679 <div class="card-body">
680 <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>
681 <div id="onjoin-list"></div>
682 <div style="display:flex;gap:8px;margin-top:12px;align-items:flex-end">
683 <div style="flex:0 0 160px"><label>channel</label><input type="text" id="onjoin-new-channel" placeholder="#channel" style="width:100%"></div>
684 <div style="flex:1"><label>message</label><input type="text" id="onjoin-new-message" placeholder="Welcome to {channel}, {nick}!" style="width:100%"></div>
685 <button class="sm primary" onclick="addOnJoinMessage()">add</button>
686 </div>
687 </div>
688 </div>
689
690 <!-- agent policy -->
691 <div class="card" id="card-agentpolicy">
692 <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>
693 <div class="card-body">
@@ -798,10 +877,31 @@
877 </div>
878 <div class="setting-row">
879 <div class="setting-label">IRC address</div>
880 <div class="setting-desc">Address Ergo listens on for IRC connections. Requires restart.</div>
881 <input type="text" id="ergo-irc-addr" placeholder="127.0.0.1:6667" style="width:180px;padding:4px 8px;font-size:12px">
882 </div>
883 <div class="setting-row">
884 <div class="setting-label">require SASL</div>
885 <div class="setting-desc">Enforce SASL authentication for all IRC connections. Only registered accounts can connect. Hot-reloads.</div>
886 <label style="display:flex;align-items:center;gap:6px;cursor:pointer">
887 <input type="checkbox" id="ergo-require-sasl">
888 <span style="font-size:12px">enforce SASL</span>
889 </label>
890 </div>
891 <div class="setting-row">
892 <div class="setting-label">default channel modes</div>
893 <div class="setting-desc">Modes applied to new channels (e.g. "+n", "+Rn"). Hot-reloads.</div>
894 <input type="text" id="ergo-default-modes" placeholder="+n" style="width:120px;padding:4px 8px;font-size:12px">
895 </div>
896 <div class="setting-row">
897 <div class="setting-label">message history</div>
898 <div class="setting-desc">Enable persistent message history (CHATHISTORY). Hot-reloads.</div>
899 <label style="display:flex;align-items:center;gap:6px;cursor:pointer">
900 <input type="checkbox" id="ergo-history-enabled">
901 <span style="font-size:12px">enabled</span>
902 </label>
903 </div>
904 <div class="setting-row">
905 <div class="setting-label">external mode</div>
906 <div class="setting-desc">Disable subprocess management — scuttlebot expects Ergo to already be running. Requires restart.</div>
907 <label style="display:flex;align-items:center;gap:6px;cursor:pointer">
@@ -1578,11 +1678,11 @@
1678 const chs = (a.config?.channels||[]).map(c=>`<span class="tag ch">${esc(c)}</span>`).join('');
1679 const rev = a.revoked ? '<span class="tag revoked">revoked</span>' : '';
1680 const seen = a.last_seen ? relTime(a.last_seen) : 'never';
1681 const seenStyle = a.online ? 'color:#3fb950' : 'color:#8b949e';
1682 return `<tr${a.revoked?' style="opacity:0.5"':''}>
1683 <td><input type="checkbox" class="agent-select" value="${esc(a.nick)}" onchange="updateBulkBtn()" style="margin-right:6px">${presenceDot(a)} <strong>${esc(a.nick)}</strong></td>
1684 <td><span class="tag type-${a.type}">${esc(a.type)}</span>${rev}</td>
1685 <td>${chs||'<span style="color:#8b949e">—</span>'}</td>
1686 <td style="white-space:nowrap;${seenStyle}">${seen}</td>
1687 <td><div class="actions">${!a.revoked?`
1688 <button class="sm" onclick="rotateAgent('${esc(a.nick)}')">rotate</button>
@@ -1590,11 +1690,11 @@
1690 <button class="sm danger" onclick="deleteAgent('${esc(a.nick)}')">delete</button></div></td>
1691 </tr>`;
1692 });
1693 renderTable('agents-container', null, rows,
1694 bots.length ? 'no agents match the filter' : 'no agents registered yet',
1695 ['<input type="checkbox" id="agent-select-all" onchange="toggleSelectAllAgents(this.checked)" style="margin-right:6px">nick','type','channels','last seen','']);
1696 }
1697
1698 async function revokeAgent(nick) {
1699 if (!confirm(`Revoke "${nick}"? This cannot be undone.`)) return;
1700 try { await api('POST', `/v1/agents/${nick}/revoke`); await loadAgents(); await loadStatus(); }
@@ -1603,10 +1703,31 @@
1703 async function deleteAgent(nick) {
1704 if (!confirm(`Delete "${nick}"? This permanently removes the agent from the registry.`)) return;
1705 try { await api('DELETE', `/v1/agents/${nick}`); await loadAgents(); await loadStatus(); }
1706 catch(e) { alert('Delete failed: '+e.message); }
1707 }
1708 function toggleSelectAllAgents(checked) {
1709 document.querySelectorAll('.agent-select').forEach(cb => cb.checked = checked);
1710 updateBulkBtn();
1711 }
1712 function updateBulkBtn() {
1713 const checked = document.querySelectorAll('.agent-select:checked');
1714 const btn = document.getElementById('bulk-delete-btn');
1715 btn.style.display = checked.length > 0 ? '' : 'none';
1716 btn.textContent = `delete selected (${checked.length})`;
1717 }
1718 async function bulkDeleteAgents() {
1719 const nicks = [...document.querySelectorAll('.agent-select:checked')].map(cb => cb.value);
1720 if (!nicks.length) return;
1721 if (!confirm(`Delete ${nicks.length} agent(s)? This permanently removes them from the registry.\n\n${nicks.join(', ')}`)) return;
1722 try {
1723 const result = await api('POST', '/v1/agents/bulk-delete', {nicks});
1724 await loadAgents();
1725 await loadStatus();
1726 if (result.failed > 0) alert(`Deleted ${result.deleted}, failed ${result.failed}`);
1727 } catch(e) { alert('Bulk delete failed: ' + e.message); }
1728 }
1729 async function rotateAgent(nick) {
1730 try {
1731 const creds = await api('POST', `/v1/agents/${nick}/rotate`);
1732 // Show result in whichever drawer is relevant.
1733 showCredentials(nick, creds, null, 'rotate');
@@ -1726,10 +1847,19 @@
1847 allChannels = (data.channels || []).sort();
1848 renderChanList();
1849 } catch(e) {
1850 document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>';
1851 }
1852 loadTopology();
1853 // Load ROE templates from policies for the ROE card.
1854 try {
1855 const s = await api('GET', '/v1/settings');
1856 if (s && s.policies) {
1857 currentPolicies = s.policies;
1858 renderROETemplates(s.policies.roe_templates || []);
1859 }
1860 } catch(e) {}
1861 }
1862
1863 function renderChanList() {
1864 const q = (document.getElementById('chan-search').value||'').toLowerCase();
1865 const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q));
@@ -1764,10 +1894,138 @@
1894 await loadChanTab();
1895 renderChanSidebar((await api('GET','/v1/channels')).channels||[]);
1896 } catch(e) { alert('Join failed: '+e.message); }
1897 }
1898 document.getElementById('quick-join-input').addEventListener('keydown', e => { if(e.key==='Enter')quickJoin(); });
1899
1900 // --- topology panel (#115) + task channels (#114) ---
1901 async function loadTopology() {
1902 try {
1903 const data = await api('GET', '/v1/topology');
1904 renderTopologyTypes(data.types || []);
1905 renderTopologyActive(data.active_channels || [], data.types || []);
1906 } catch(e) {
1907 document.getElementById('topology-types').innerHTML = '';
1908 document.getElementById('topology-active').innerHTML = '<div style="color:#8b949e;font-size:12px">topology not configured</div>';
1909 }
1910 }
1911
1912 function renderTopologyTypes(types) {
1913 if (!types.length) { document.getElementById('topology-types').innerHTML = ''; return; }
1914 const rows = types.map(t => {
1915 const ttl = t.ttl_seconds > 0 ? `${Math.round(t.ttl_seconds/3600)}h` : '—';
1916 const tags = [];
1917 if (t.ephemeral) tags.push('<span style="background:#f8514922;color:#f85149;padding:1px 5px;border-radius:3px;font-size:10px">ephemeral</span>');
1918 if (t.supervision) tags.push(`<span style="font-size:11px;color:#8b949e">→ ${esc(t.supervision)}</span>`);
1919 return `<tr>
1920 <td><strong>${esc(t.name)}</strong></td>
1921 <td><code style="font-size:11px">#${esc(t.prefix)}*</code></td>
1922 <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>
1923 <td style="font-size:12px">${ttl}</td>
1924 <td>${tags.join(' ')}</td>
1925 </tr>`;
1926 }).join('');
1927 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>`;
1928 }
1929
1930 function renderTopologyActive(channels, types) {
1931 const el = document.getElementById('topology-active');
1932 const tasks = channels.filter(c => c.ephemeral || c.type === 'task');
1933 if (!tasks.length) {
1934 el.innerHTML = '<div style="color:#8b949e;font-size:12px;padding:4px 0">no active task channels</div>';
1935 return;
1936 }
1937 const rows = tasks.map(c => {
1938 const age = c.provisioned_at ? timeSince(new Date(c.provisioned_at)) : '—';
1939 const ttl = c.ttl_seconds > 0 ? `${Math.round(c.ttl_seconds/3600)}h` : '—';
1940 return `<tr>
1941 <td><strong>${esc(c.name)}</strong></td>
1942 <td style="font-size:12px;color:#8b949e">${esc(c.type || '—')}</td>
1943 <td style="font-size:12px">${age}</td>
1944 <td style="font-size:12px">${ttl}</td>
1945 <td><button class="sm danger" onclick="dropChannel('${esc(c.name)}')">drop</button></td>
1946 </tr>`;
1947 }).join('');
1948 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>`;
1949 }
1950
1951 function timeSince(date) {
1952 const s = Math.floor((new Date() - date) / 1000);
1953 if (s < 60) return s + 's';
1954 if (s < 3600) return Math.floor(s/60) + 'm';
1955 if (s < 86400) return Math.floor(s/3600) + 'h';
1956 return Math.floor(s/86400) + 'd';
1957 }
1958
1959 async function provisionChannel() {
1960 let ch = document.getElementById('provision-channel-input').value.trim();
1961 if (!ch) return;
1962 if (!ch.startsWith('#')) ch = '#' + ch;
1963 try {
1964 await api('POST', '/v1/channels', {name: ch});
1965 document.getElementById('provision-channel-input').value = '';
1966 loadTopology();
1967 loadChanTab();
1968 } catch(e) { alert('Provision failed: ' + e.message); }
1969 }
1970
1971 async function dropChannel(ch) {
1972 if (!confirm('Drop channel ' + ch + '? This unregisters it from ChanServ.')) return;
1973 const slug = ch.replace(/^#/,'');
1974 try {
1975 await api('DELETE', `/v1/topology/channels/${slug}`);
1976 loadTopology();
1977 loadChanTab();
1978 } catch(e) { alert('Drop failed: ' + e.message); }
1979 }
1980
1981 // --- ROE template editor (#118) ---
1982 function renderROETemplates(templates) {
1983 const el = document.getElementById('roe-list');
1984 if (!templates || !templates.length) {
1985 el.innerHTML = '<div style="color:#8b949e;font-size:12px">No ROE templates defined. Click + add template to create one.</div>';
1986 return;
1987 }
1988 el.innerHTML = templates.map((t, i) => `
1989 <div style="border:1px solid #21262d;border-radius:6px;padding:12px;margin-bottom:10px;background:#0d1117">
1990 <div style="display:flex;gap:10px;align-items:center;margin-bottom:8px">
1991 <input type="text" value="${esc(t.name)}" placeholder="template name" style="flex:1;font-weight:600" onchange="updateROE(${i},'name',this.value)">
1992 <button class="sm danger" onclick="removeROE(${i})">remove</button>
1993 </div>
1994 <div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:6px">
1995 <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>
1996 <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>
1997 </div>
1998 <div style="display:flex;gap:10px">
1999 <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>
2000 <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>
2001 <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>
2002 </div>
2003 </div>
2004 `).join('');
2005 }
2006
2007 function addROETemplate() {
2008 if (!currentPolicies.roe_templates) currentPolicies.roe_templates = [];
2009 currentPolicies.roe_templates.push({name: 'new-template', channels: [], permissions: []});
2010 renderROETemplates(currentPolicies.roe_templates);
2011 }
2012 function removeROE(i) {
2013 currentPolicies.roe_templates.splice(i, 1);
2014 renderROETemplates(currentPolicies.roe_templates);
2015 }
2016 function updateROE(i, field, val) {
2017 if (field === 'channels' || field === 'permissions') {
2018 currentPolicies.roe_templates[i][field] = val.split(',').map(s => s.trim()).filter(Boolean);
2019 } else {
2020 currentPolicies.roe_templates[i][field] = val;
2021 }
2022 }
2023 function updateROERateLimit(i, field, val) {
2024 if (!currentPolicies.roe_templates[i].rate_limit) currentPolicies.roe_templates[i].rate_limit = {};
2025 currentPolicies.roe_templates[i].rate_limit[field] = Number(val) || 0;
2026 }
2027
2028 // --- chat ---
2029 let chatChannel = null, chatSSE = null;
2030
2031 async function loadChannels() {
@@ -1838,11 +2096,11 @@
2096 async function loadNicklist(ch) {
2097 if (!ch) return;
2098 try {
2099 const slug = ch.replace(/^#/,'');
2100 const data = await api('GET', `/v1/channels/${slug}/users`);
2101 renderNicklist(data.users || [], data.channel_modes || '');
2102 } catch(e) {}
2103 }
2104 const SYSTEM_BOTS = new Set(['bridge','oracle','sentinel','steward','scribe','warden','snitch','herald','scroll','systembot','auditbot']);
2105 const AGENT_PREFIXES = ['claude-','codex-','gemini-','openclaw-'];
2106
@@ -1861,24 +2119,35 @@
2119 if (tier === 0) return '@';
2120 if (tier === 1) return '+';
2121 return '';
2122 }
2123
2124 function renderNicklist(users, channelModes) {
2125 const el = document.getElementById('nicklist-users');
2126 // users may be [{nick, modes}] or ["nick"] for backwards compat.
2127 const normalized = users.map(u => typeof u === 'string' ? {nick: u, modes: []} : u);
2128 // Sort: ops > system bots > agents > users, alpha within each tier.
2129 const sorted = normalized.slice().sort((a, b) => {
2130 const ta = nickTier(a.nick), tb = nickTier(b.nick);
2131 if (ta !== tb) return ta - tb;
2132 return a.nick.localeCompare(b.nick);
2133 });
2134 el.innerHTML = sorted.map(u => {
2135 const modes = u.modes || [];
2136 // IRC mode prefix: @ for op, + for voice
2137 let prefix = '';
2138 if (modes.includes('o') || modes.includes('a') || modes.includes('q')) prefix = '@';
2139 else if (modes.includes('v')) prefix = '+';
2140 else prefix = nickPrefix(u.nick);
2141 const tier = nickTier(u.nick);
2142 const cls = (modes.includes('o') || tier === 0) ? ' is-op' : tier === 1 ? ' is-bot' : '';
2143 const modeStr = modes.length ? ` [+${modes.join('')}]` : '';
2144 return `<div class="nicklist-nick${cls}" title="${esc(u.nick)}${modeStr}">${prefix}${esc(u.nick)}</div>`;
2145 }).join('');
2146 // Show channel modes in header if available.
2147 const modesEl = document.getElementById('chat-channel-modes');
2148 if (modesEl) modesEl.textContent = channelModes ? ` ${channelModes}` : '';
2149 }
2150 // Nick colors — deterministic hash over a palette
2151 const NICK_PALETTE = ['#58a6ff','#3fb950','#ffa657','#d2a8ff','#56d364','#79c0ff','#ff7b72','#a5d6ff','#f0883e','#39d353'];
2152 function nickColor(nick) {
2153 let h = 0;
@@ -1892,14 +2161,16 @@
2161 let _chatUnread = 0;
2162
2163 function appendMsg(msg, isHistory) {
2164 const area = document.getElementById('chat-msgs');
2165
2166 // Attribution: RELAYMSG delivers nicks as "user/bridge"; legacy uses "[nick] text".
2167 let displayNick = msg.nick;
2168 let displayText = msg.text;
2169 if (msg.nick && msg.nick.endsWith('/bridge')) {
2170 displayNick = msg.nick.slice(0, -'/bridge'.length);
2171 } else if (msg.nick === 'bridge') {
2172 const m = msg.text.match(/^\[([^\]]+)\] ([\s\S]*)$/);
2173 if (m) { displayNick = m[1]; displayText = m[2]; }
2174 }
2175
2176 const atMs = new Date(msg.at).getTime();
@@ -2542,10 +2813,73 @@
2813 try {
2814 await api('PUT', `/v1/admins/${encodeURIComponent(username)}/password`, { password: pw });
2815 alert('Password updated.');
2816 } catch(e) { alert('Failed: ' + e.message); }
2817 }
2818
2819 // --- API keys ---
2820 async function loadAPIKeys() {
2821 try {
2822 const keys = await api('GET', '/v1/api-keys');
2823 renderAPIKeys(keys || []);
2824 } catch(e) {
2825 document.getElementById('apikeys-list-container').innerHTML = '';
2826 }
2827 }
2828
2829 function renderAPIKeys(keys) {
2830 const el = document.getElementById('apikeys-list-container');
2831 if (!keys.length) { el.innerHTML = ''; return; }
2832 const rows = keys.map(k => {
2833 const status = k.active ? '<span style="color:#3fb950">active</span>' : '<span style="color:#f85149">revoked</span>';
2834 const scopes = (k.scopes || []).map(s => `<code style="font-size:11px;background:#21262d;padding:1px 5px;border-radius:3px">${esc(s)}</code>`).join(' ');
2835 const lastUsed = k.last_used ? fmtTime(k.last_used) : '—';
2836 const revokeBtn = k.active ? `<button class="sm danger" onclick="revokeAPIKey('${esc(k.id)}')">revoke</button>` : '';
2837 return `<tr>
2838 <td><strong>${esc(k.name)}</strong><br><span style="color:#8b949e;font-size:11px">${esc(k.id)}</span></td>
2839 <td>${scopes}</td>
2840 <td style="font-size:12px">${status}</td>
2841 <td style="color:#8b949e;font-size:12px">${lastUsed}</td>
2842 <td><div class="actions">${revokeBtn}</div></td>
2843 </tr>`;
2844 }).join('');
2845 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>`;
2846 }
2847
2848 async function createAPIKey(e) {
2849 e.preventDefault();
2850 const name = document.getElementById('new-apikey-name').value.trim();
2851 const expires = document.getElementById('new-apikey-expires').value.trim();
2852 const scopes = [...document.querySelectorAll('.apikey-scope:checked')].map(cb => cb.value);
2853 const resultEl = document.getElementById('add-apikey-result');
2854 if (!name) { resultEl.innerHTML = '<span style="color:#f85149">name is required</span>'; return; }
2855 if (!scopes.length) { resultEl.innerHTML = '<span style="color:#f85149">select at least one scope</span>'; return; }
2856 try {
2857 const body = { name, scopes };
2858 if (expires) body.expires_in = expires;
2859 const result = await api('POST', '/v1/api-keys', body);
2860 resultEl.innerHTML = `<div style="background:#0d1117;border:1px solid #3fb95044;border-radius:6px;padding:12px;margin-top:8px">
2861 <div style="color:#3fb950;font-weight:600;margin-bottom:6px">Key created: ${esc(result.name)}</div>
2862 <div style="margin-bottom:4px;font-size:12px;color:#8b949e">Copy this token now — it will not be shown again:</div>
2863 <code style="display:block;padding:8px;background:#161b22;border-radius:4px;word-break:break-all;user-select:all">${esc(result.token)}</code>
2864 </div>`;
2865 document.getElementById('new-apikey-name').value = '';
2866 document.getElementById('new-apikey-expires').value = '';
2867 document.querySelectorAll('.apikey-scope:checked').forEach(cb => cb.checked = false);
2868 loadAPIKeys();
2869 } catch(e) {
2870 resultEl.innerHTML = `<span style="color:#f85149">${esc(e.message)}</span>`;
2871 }
2872 }
2873
2874 async function revokeAPIKey(id) {
2875 if (!confirm('Revoke this API key? This cannot be undone.')) return;
2876 try {
2877 await api('DELETE', `/v1/api-keys/${encodeURIComponent(id)}`);
2878 loadAPIKeys();
2879 } catch(e) { alert('Failed: ' + e.message); }
2880 }
2881
2882 // --- AI / LLM tab ---
2883 async function loadAI() {
2884 await Promise.all([loadAIBackends(), loadAIKnown()]);
2885 }
@@ -2899,10 +3233,41 @@
3233 if (body) body.style.display = '';
3234 }
3235
3236 // --- settings / policies ---
3237 let currentPolicies = null;
3238 let _botCommands = {};
3239
3240 function renderOnJoinMessages(msgs) {
3241 const el = document.getElementById('onjoin-list');
3242 if (!msgs || !Object.keys(msgs).length) { el.innerHTML = '<div style="color:#8b949e;font-size:12px">No on-join instructions configured.</div>'; return; }
3243 el.innerHTML = Object.entries(msgs).sort().map(([ch, msg]) => `
3244 <div style="display:flex;gap:8px;align-items:center;padding:6px 0;border-bottom:1px solid #21262d">
3245 <code style="font-size:12px;min-width:120px">${esc(ch)}</code>
3246 <input type="text" value="${esc(msg)}" style="flex:1;font-size:12px" onchange="updateOnJoinMessage('${esc(ch)}',this.value)">
3247 <button class="sm danger" onclick="removeOnJoinMessage('${esc(ch)}')">remove</button>
3248 </div>
3249 `).join('');
3250 }
3251 function addOnJoinMessage() {
3252 const ch = document.getElementById('onjoin-new-channel').value.trim();
3253 const msg = document.getElementById('onjoin-new-message').value.trim();
3254 if (!ch || !msg) return;
3255 if (!currentPolicies.on_join_messages) currentPolicies.on_join_messages = {};
3256 currentPolicies.on_join_messages[ch] = msg;
3257 document.getElementById('onjoin-new-channel').value = '';
3258 document.getElementById('onjoin-new-message').value = '';
3259 renderOnJoinMessages(currentPolicies.on_join_messages);
3260 }
3261 function updateOnJoinMessage(ch, msg) {
3262 if (!currentPolicies.on_join_messages) currentPolicies.on_join_messages = {};
3263 currentPolicies.on_join_messages[ch] = msg;
3264 }
3265 function removeOnJoinMessage(ch) {
3266 if (currentPolicies.on_join_messages) delete currentPolicies.on_join_messages[ch];
3267 renderOnJoinMessages(currentPolicies.on_join_messages);
3268 }
3269 let _llmBackendNames = []; // cached backend names for oracle dropdown
3270
3271 async function loadSettings() {
3272 try {
3273 const [s, backends] = await Promise.all([
@@ -2910,15 +3275,18 @@
3275 api('GET', '/v1/llm/backends').catch(() => []),
3276 ]);
3277 _llmBackendNames = (backends || []).map(b => b.name);
3278 renderTLSStatus(s.tls);
3279 currentPolicies = s.policies;
3280 _botCommands = s.bot_commands || {};
3281 renderBehaviors(s.policies.behaviors || []);
3282 renderOnJoinMessages(s.policies.on_join_messages || {});
3283 renderAgentPolicy(s.policies.agent_policy || {});
3284 renderBridgePolicy(s.policies.bridge || {});
3285 renderLoggingPolicy(s.policies.logging || {});
3286 loadAdmins();
3287 loadAPIKeys();
3288 loadConfigCards();
3289 } catch(e) {
3290 document.getElementById('tls-badge').textContent = 'error';
3291 }
3292 }
@@ -2973,10 +3341,14 @@
3341 ` : ''}
3342 <span class="tag type-observer" style="font-size:11px;min-width:64px;text-align:center">${esc(b.nick)}</span>
3343 </div>
3344 </div>
3345 ${b.enabled && hasSchema(b.id) ? renderBehConfig(b) : ''}
3346 ${_botCommands[b.id] ? `<div style="padding:6px 16px 8px 42px;border-bottom:1px solid #21262d;background:#0d1117">
3347 <span style="font-size:11px;color:#8b949e;font-weight:600">commands:</span>
3348 ${_botCommands[b.id].map(c => `<code style="font-size:11px;margin-left:8px;background:#161b22;padding:1px 5px;border-radius:3px" title="${esc(c.description)}&#10;${esc(c.usage)}">${esc(c.command)}</code>`).join('')}
3349 </div>` : ''}
3350 </div>
3351 `).join('');
3352 }
3353
3354 function onBehaviorToggle(id, enabled) {
@@ -3222,14 +3594,17 @@
3594 // general
3595 document.getElementById('general-api-addr').value = cfg.api_addr || '';
3596 document.getElementById('general-mcp-addr').value = cfg.mcp_addr || '';
3597 // ergo
3598 const e = cfg.ergo || {};
3599 document.getElementById('ergo-network-name').value = e.network_name || '';
3600 document.getElementById('ergo-server-name').value = e.server_name || '';
3601 document.getElementById('ergo-irc-addr').value = e.irc_addr || '';
3602 document.getElementById('ergo-require-sasl').checked = !!e.require_sasl;
3603 document.getElementById('ergo-default-modes').value = e.default_channel_modes || '';
3604 document.getElementById('ergo-history-enabled').checked = !!(e.history && e.history.enabled);
3605 document.getElementById('ergo-external').checked = !!e.external;
3606 // tls
3607 const t = cfg.tls || {};
3608 document.getElementById('tls-domain').value = t.domain || '';
3609 document.getElementById('tls-email').value = t.email || '';
3610 document.getElementById('tls-allow-insecure').checked = !!t.allow_insecure;
@@ -3302,14 +3677,17 @@
3677 }
3678
3679 function saveErgoConfig() {
3680 saveConfigPatch({
3681 ergo: {
3682 network_name: document.getElementById('ergo-network-name').value.trim() || undefined,
3683 server_name: document.getElementById('ergo-server-name').value.trim() || undefined,
3684 irc_addr: document.getElementById('ergo-irc-addr').value.trim() || undefined,
3685 require_sasl: document.getElementById('ergo-require-sasl').checked,
3686 default_channel_modes: document.getElementById('ergo-default-modes').value.trim() || undefined,
3687 history: { enabled: document.getElementById('ergo-history-enabled').checked },
3688 external: document.getElementById('ergo-external').checked,
3689 }
3690 }, 'ergo-save-result');
3691 }
3692
3693 function saveTLSConfig() {
3694
3695 DDED internal/auth/apikeys.go
--- a/internal/auth/apikeys.go
+++ b/internal/auth/apikeys.go
@@ -0,0 +1,288 @@
1
+package auth
2
+
3
+import (
4
+ "crypto/rand"
5
+ "crypto/sha256"
6
+ "encoding/hex"
7
+ "encoding/json"
8
+ "fmt"
9
+ "os"
10
+ "strings"
11
+ "sync"
12
+ "time"
13
+
14
+ "github.com/oklog/ulid/v2"
15
+)
16
+
17
+// Scope represents a permission scope for an API key.
18
+type Scope string
19
+
20
+const (
21
+ ScopeAdmin Scope = "admin" // full access
22
+ ScopeAgents Scope = "agents" // agent registration, rotation, revocation
23
+ ScopeChannels Scope = "channels" // channel CRUD, join, messages, presence
24
+ ScopeTopology Scope = "topology" // channel provisioning, topology management
25
+ ScopeBots Scope = "bots" // bot configuration, start/stop
26
+ ScopeConfig Scope = "config" // server config read/write
27
+ ScopeRead Scope = "read" // read-only access to all GET endpoints
28
+ ScopeChat Scope = "chat" // send/receive messages only
29
+)
30
+
31
+// ValidScopes is the set of all recognised scopes.
32
+var ValidScopes = map[Scope]bool{
33
+ ScopeAdmin: true, ScopeAgents: true, ScopeChannels: true,
34
+ ScopeTopology: true, ScopeBots: true, ScopeConfig: true,
35
+ ScopeRead: true, ScopeChat: true,
36
+}
37
+
38
+// APIKey is a single API key record.
39
+type APIKey struct {
40
+ ID string `json:"id"`
41
+ Name string `json:"name"`
42
+ Hash string `json:"hash"` // SHA-256 of the plaintext token
43
+ Scopes []Scope `json:"scopes"`
44
+ CreatedAt time.Time `json:"created_at"`
45
+ LastUsed time.Time `json:"last_used,omitempty"`
46
+ ExpiresAt time.Time `json:"expires_at,omitempty"` // zero = never
47
+ Active bool `json:"active"`
48
+}
49
+
50
+// HasScope reports whether the key has the given scope (or admin, which implies all).
51
+func (k *APIKey) HasScope(s Scope) bool {
52
+ for _, scope := range k.Scopes {
53
+ if scope == ScopeAdmin || scope == s {
54
+ return true
55
+ }
56
+ }
57
+ return false
58
+}
59
+
60
+// IsExpired reports whether the key has passed its expiry time.
61
+func (k *APIKey) IsExpired() bool {
62
+ return !k.ExpiresAt.IsZero() && time.Now().After(k.ExpiresAt)
63
+}
64
+
65
+// APIKeyStore persists API keys to a JSON file.
66
+type APIKeyStore struct {
67
+ mu sync.RWMutex
68
+ path string
69
+ data []APIKey
70
+}
71
+
72
+// NewAPIKeyStore loads (or creates) the API key store at the given path.
73
+func NewAPIKeyStore(path string) (*APIKeyStore, error) {
74
+ s := &APIKeyStore{path: path}
75
+ if err := s.load(); err != nil {
76
+ return nil, err
77
+ }
78
+ return s, nil
79
+}
80
+
81
+// Create generates a new API key with the given name and scopes.
82
+// Returns the plaintext token (shown only once) and the stored key record.
83
+func (s *APIKeyStore) Create(name string, scopes []Scope, expiresAt time.Time) (plaintext string, key APIKey, err error) {
84
+ s.mu.Lock()
85
+ defer s.mu.Unlock()
86
+
87
+ token, err := genToken()
88
+ if err != nil {
89
+ return "", APIKey{}, fmt.Errorf("apikeys: generate token: %w", err)
90
+ }
91
+
92
+ key = APIKey{
93
+ ID: newULID(),
94
+ Name: name,
95
+ Hash: hashToken(token),
96
+ Scopes: scopes,
97
+ CreatedAt: time.Now().UTC(),
98
+ ExpiresAt: expiresAt,
99
+ Active: true,
100
+ }
101
+ s.data = append(s.data, key)
102
+ if err := s.save(); err != nil {
103
+ // Roll back.
104
+ s.data = s.data[:len(s.data)-1]
105
+ return "", APIKey{}, err
106
+ }
107
+ return token, key, nil
108
+}
109
+
110
+// Insert adds a pre-built API key with a known plaintext token.
111
+// Used for migrating the startup token into the store.
112
+func (s *APIKeyStore) Insert(name, plaintext string, scopes []Scope) (APIKey, error) {
113
+ s.mu.Lock()
114
+ defer s.mu.Unlock()
115
+
116
+ key := APIKey{
117
+ ID: newULID(),
118
+ Name: name,
119
+ Hash: hashToken(plaintext),
120
+ Scopes: scopes,
121
+ CreatedAt: time.Now().UTC(),
122
+ Active: true,
123
+ }
124
+ s.data = append(s.data, key)
125
+ if err := s.save(); err != nil {
126
+ s.data = s.data[:len(s.data)-1]
127
+ return APIKey{}, err
128
+ }
129
+ return key, nil
130
+}
131
+
132
+// Lookup finds an active, non-expired key by plaintext token.
133
+// Returns nil if no match.
134
+func (s *APIKeyStore) Lookup(token string) *APIKey {
135
+ hash := hashToken(token)
136
+ s.mu.RLock()
137
+ defer s.mu.RUnlock()
138
+ for i := range s.data {
139
+ if s.data[i].Hash == hash && s.data[i].Active && !s.data[i].IsExpired() {
140
+ k := s.data[i]
141
+ return &k
142
+ }
143
+ }
144
+ return nil
145
+}
146
+
147
+// TouchLastUsed updates the last-used timestamp for a key by ID.
148
+func (s *APIKeyStore) TouchLastUsed(id string) {
149
+ s.mu.Lock()
150
+ defer s.mu.Unlock()
151
+ for i := range s.data {
152
+ if s.data[i].ID == id {
153
+ s.data[i].LastUsed = time.Now().UTC()
154
+ _ = s.save() // best-effort persistence
155
+ return
156
+ }
157
+ }
158
+}
159
+
160
+// Get returns a key by ID, or nil if not found.
161
+func (s *APIKeyStore) Get(id string) *APIKey {
162
+ s.mu.RLock()
163
+ defer s.mu.RUnlock()
164
+ for i := range s.data {
165
+ if s.data[i].ID == id {
166
+ k := s.data[i]
167
+ return &k
168
+ }
169
+ }
170
+ return nil
171
+}
172
+
173
+// List returns all keys (active and revoked).
174
+func (s *APIKeyStore) List() []APIKey {
175
+ s.mu.RLock()
176
+ defer s.mu.RUnlock()
177
+ out := make([]APIKey, len(s.data))
178
+ copy(out, s.data)
179
+ return out
180
+}
181
+
182
+// Revoke deactivates a key by ID.
183
+func (s *APIKeyStore) Revoke(id string) error {
184
+ s.mu.Lock()
185
+ defer s.mu.Unlock()
186
+ for i := range s.data {
187
+ if s.data[i].ID == id {
188
+ if !s.data[i].Active {
189
+ return fmt.Errorf("apikeys: key %q already revoked", id)
190
+ }
191
+ s.data[i].Active = false
192
+ return s.save()
193
+ }
194
+ }
195
+ return fmt.Errorf("apikeys: key %q not found", id)
196
+}
197
+
198
+// Lookup (TokenValidator interface) reports whether the token is valid.
199
+// Satisfies the mcp.TokenValidator interface.
200
+func (s *APIKeyStore) ValidToken(token string) bool {
201
+ return s.Lookup(token) != nil
202
+}
203
+
204
+// TestStore creates an in-memory APIKeyStore with a single admin-scope key
205
+// for the given token. Intended for tests only — does not persist to disk.
206
+func TestStore(token string) *APIKeyStore {
207
+ s := &APIKeyStore{path: "", data: []APIKey{{
208
+ ID: "test-key",
209
+ Name: "test",
210
+ Hash: hashToken(token),
211
+ Scopes: []Scope{ScopeAdmin},
212
+ CreatedAt: time.Now().UTC(),
213
+ Active: true,
214
+ }}}
215
+ return s
216
+}
217
+
218
+// IsEmpty reports whether there are no keys.
219
+func (s *APIKeyStore) IsEmpty() bool {
220
+ s.mu.RLock()
221
+ defer s.mu.RUnlock()
222
+ return len(s.data) == 0
223
+}
224
+
225
+func (s *APIKeyStore) load() error {
226
+ raw, err := os.ReadFile(s.path)
227
+ if os.IsNotExist(err) {
228
+ return nil
229
+ }
230
+ if err != nil {
231
+ return fmt.Errorf("apikeys: read %s: %w", s.path, err)
232
+ }
233
+ if err := json.Unmarshal(raw, &s.data); err != nil {
234
+ return fmt.Errorf("apikeys: parse: %w", err)
235
+ }
236
+ return nil
237
+}
238
+
239
+func (s *APIKeyStore) save() error {
240
+ if s.path == "" {
241
+ return nil // in-memory only (tests)
242
+ }
243
+ raw, err := json.MarshalIndent(s.data, "", " ")
244
+ if err != nil {
245
+ return err
246
+ }
247
+ return os.WriteFile(s.path, raw, 0600)
248
+}
249
+
250
+func hashToken(token string) string {
251
+ h := sha256.Sum256([]byte(token))
252
+ return hex.EncodeToString(h[:])
253
+}
254
+
255
+func genToken() (string, error) {
256
+ b := make([]byte, 32)
257
+ if _, err := rand.Read(b); err != nil {
258
+ return "", err
259
+ }
260
+ return hex.EncodeToString(b), nil
261
+}
262
+
263
+func newULID() string {
264
+ entropy := ulid.Monotonic(rand.Reader, 0)
265
+ return ulid.MustNew(ulid.Timestamp(time.Now()), entropy).String()
266
+}
267
+
268
+// ParseScopes parses a comma-separated scope string into a slice.
269
+// Returns an error if any scope is unrecognised.
270
+func ParseScopes(s string) ([]Scope, error) {
271
+ parts := strings.Split(s, ",")
272
+ scopes := make([]Scope, 0, len(parts))
273
+ for _, p := range parts {
274
+ p = strings.TrimSpace(p)
275
+ if p == "" {
276
+ continue
277
+ }
278
+ scope := Scope(p)
279
+ if !ValidScopes[scope] {
280
+ return nil, fmt.Errorf("unknown scope %q", p)
281
+ }
282
+ scopes = append(scopes, scope)
283
+ }
284
+ if len(scopes) == 0 {
285
+ return nil, fmt.Errorf("at least one scope is required")
286
+ }
287
+ return scopes, nil
288
+}
--- a/internal/auth/apikeys.go
+++ b/internal/auth/apikeys.go
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/auth/apikeys.go
+++ b/internal/auth/apikeys.go
@@ -0,0 +1,288 @@
1 package auth
2
3 import (
4 "crypto/rand"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "fmt"
9 "os"
10 "strings"
11 "sync"
12 "time"
13
14 "github.com/oklog/ulid/v2"
15 )
16
17 // Scope represents a permission scope for an API key.
18 type Scope string
19
20 const (
21 ScopeAdmin Scope = "admin" // full access
22 ScopeAgents Scope = "agents" // agent registration, rotation, revocation
23 ScopeChannels Scope = "channels" // channel CRUD, join, messages, presence
24 ScopeTopology Scope = "topology" // channel provisioning, topology management
25 ScopeBots Scope = "bots" // bot configuration, start/stop
26 ScopeConfig Scope = "config" // server config read/write
27 ScopeRead Scope = "read" // read-only access to all GET endpoints
28 ScopeChat Scope = "chat" // send/receive messages only
29 )
30
31 // ValidScopes is the set of all recognised scopes.
32 var ValidScopes = map[Scope]bool{
33 ScopeAdmin: true, ScopeAgents: true, ScopeChannels: true,
34 ScopeTopology: true, ScopeBots: true, ScopeConfig: true,
35 ScopeRead: true, ScopeChat: true,
36 }
37
38 // APIKey is a single API key record.
39 type APIKey struct {
40 ID string `json:"id"`
41 Name string `json:"name"`
42 Hash string `json:"hash"` // SHA-256 of the plaintext token
43 Scopes []Scope `json:"scopes"`
44 CreatedAt time.Time `json:"created_at"`
45 LastUsed time.Time `json:"last_used,omitempty"`
46 ExpiresAt time.Time `json:"expires_at,omitempty"` // zero = never
47 Active bool `json:"active"`
48 }
49
50 // HasScope reports whether the key has the given scope (or admin, which implies all).
51 func (k *APIKey) HasScope(s Scope) bool {
52 for _, scope := range k.Scopes {
53 if scope == ScopeAdmin || scope == s {
54 return true
55 }
56 }
57 return false
58 }
59
60 // IsExpired reports whether the key has passed its expiry time.
61 func (k *APIKey) IsExpired() bool {
62 return !k.ExpiresAt.IsZero() && time.Now().After(k.ExpiresAt)
63 }
64
65 // APIKeyStore persists API keys to a JSON file.
66 type APIKeyStore struct {
67 mu sync.RWMutex
68 path string
69 data []APIKey
70 }
71
72 // NewAPIKeyStore loads (or creates) the API key store at the given path.
73 func NewAPIKeyStore(path string) (*APIKeyStore, error) {
74 s := &APIKeyStore{path: path}
75 if err := s.load(); err != nil {
76 return nil, err
77 }
78 return s, nil
79 }
80
81 // Create generates a new API key with the given name and scopes.
82 // Returns the plaintext token (shown only once) and the stored key record.
83 func (s *APIKeyStore) Create(name string, scopes []Scope, expiresAt time.Time) (plaintext string, key APIKey, err error) {
84 s.mu.Lock()
85 defer s.mu.Unlock()
86
87 token, err := genToken()
88 if err != nil {
89 return "", APIKey{}, fmt.Errorf("apikeys: generate token: %w", err)
90 }
91
92 key = APIKey{
93 ID: newULID(),
94 Name: name,
95 Hash: hashToken(token),
96 Scopes: scopes,
97 CreatedAt: time.Now().UTC(),
98 ExpiresAt: expiresAt,
99 Active: true,
100 }
101 s.data = append(s.data, key)
102 if err := s.save(); err != nil {
103 // Roll back.
104 s.data = s.data[:len(s.data)-1]
105 return "", APIKey{}, err
106 }
107 return token, key, nil
108 }
109
110 // Insert adds a pre-built API key with a known plaintext token.
111 // Used for migrating the startup token into the store.
112 func (s *APIKeyStore) Insert(name, plaintext string, scopes []Scope) (APIKey, error) {
113 s.mu.Lock()
114 defer s.mu.Unlock()
115
116 key := APIKey{
117 ID: newULID(),
118 Name: name,
119 Hash: hashToken(plaintext),
120 Scopes: scopes,
121 CreatedAt: time.Now().UTC(),
122 Active: true,
123 }
124 s.data = append(s.data, key)
125 if err := s.save(); err != nil {
126 s.data = s.data[:len(s.data)-1]
127 return APIKey{}, err
128 }
129 return key, nil
130 }
131
132 // Lookup finds an active, non-expired key by plaintext token.
133 // Returns nil if no match.
134 func (s *APIKeyStore) Lookup(token string) *APIKey {
135 hash := hashToken(token)
136 s.mu.RLock()
137 defer s.mu.RUnlock()
138 for i := range s.data {
139 if s.data[i].Hash == hash && s.data[i].Active && !s.data[i].IsExpired() {
140 k := s.data[i]
141 return &k
142 }
143 }
144 return nil
145 }
146
147 // TouchLastUsed updates the last-used timestamp for a key by ID.
148 func (s *APIKeyStore) TouchLastUsed(id string) {
149 s.mu.Lock()
150 defer s.mu.Unlock()
151 for i := range s.data {
152 if s.data[i].ID == id {
153 s.data[i].LastUsed = time.Now().UTC()
154 _ = s.save() // best-effort persistence
155 return
156 }
157 }
158 }
159
160 // Get returns a key by ID, or nil if not found.
161 func (s *APIKeyStore) Get(id string) *APIKey {
162 s.mu.RLock()
163 defer s.mu.RUnlock()
164 for i := range s.data {
165 if s.data[i].ID == id {
166 k := s.data[i]
167 return &k
168 }
169 }
170 return nil
171 }
172
173 // List returns all keys (active and revoked).
174 func (s *APIKeyStore) List() []APIKey {
175 s.mu.RLock()
176 defer s.mu.RUnlock()
177 out := make([]APIKey, len(s.data))
178 copy(out, s.data)
179 return out
180 }
181
182 // Revoke deactivates a key by ID.
183 func (s *APIKeyStore) Revoke(id string) error {
184 s.mu.Lock()
185 defer s.mu.Unlock()
186 for i := range s.data {
187 if s.data[i].ID == id {
188 if !s.data[i].Active {
189 return fmt.Errorf("apikeys: key %q already revoked", id)
190 }
191 s.data[i].Active = false
192 return s.save()
193 }
194 }
195 return fmt.Errorf("apikeys: key %q not found", id)
196 }
197
198 // Lookup (TokenValidator interface) reports whether the token is valid.
199 // Satisfies the mcp.TokenValidator interface.
200 func (s *APIKeyStore) ValidToken(token string) bool {
201 return s.Lookup(token) != nil
202 }
203
204 // TestStore creates an in-memory APIKeyStore with a single admin-scope key
205 // for the given token. Intended for tests only — does not persist to disk.
206 func TestStore(token string) *APIKeyStore {
207 s := &APIKeyStore{path: "", data: []APIKey{{
208 ID: "test-key",
209 Name: "test",
210 Hash: hashToken(token),
211 Scopes: []Scope{ScopeAdmin},
212 CreatedAt: time.Now().UTC(),
213 Active: true,
214 }}}
215 return s
216 }
217
218 // IsEmpty reports whether there are no keys.
219 func (s *APIKeyStore) IsEmpty() bool {
220 s.mu.RLock()
221 defer s.mu.RUnlock()
222 return len(s.data) == 0
223 }
224
225 func (s *APIKeyStore) load() error {
226 raw, err := os.ReadFile(s.path)
227 if os.IsNotExist(err) {
228 return nil
229 }
230 if err != nil {
231 return fmt.Errorf("apikeys: read %s: %w", s.path, err)
232 }
233 if err := json.Unmarshal(raw, &s.data); err != nil {
234 return fmt.Errorf("apikeys: parse: %w", err)
235 }
236 return nil
237 }
238
239 func (s *APIKeyStore) save() error {
240 if s.path == "" {
241 return nil // in-memory only (tests)
242 }
243 raw, err := json.MarshalIndent(s.data, "", " ")
244 if err != nil {
245 return err
246 }
247 return os.WriteFile(s.path, raw, 0600)
248 }
249
250 func hashToken(token string) string {
251 h := sha256.Sum256([]byte(token))
252 return hex.EncodeToString(h[:])
253 }
254
255 func genToken() (string, error) {
256 b := make([]byte, 32)
257 if _, err := rand.Read(b); err != nil {
258 return "", err
259 }
260 return hex.EncodeToString(b), nil
261 }
262
263 func newULID() string {
264 entropy := ulid.Monotonic(rand.Reader, 0)
265 return ulid.MustNew(ulid.Timestamp(time.Now()), entropy).String()
266 }
267
268 // ParseScopes parses a comma-separated scope string into a slice.
269 // Returns an error if any scope is unrecognised.
270 func ParseScopes(s string) ([]Scope, error) {
271 parts := strings.Split(s, ",")
272 scopes := make([]Scope, 0, len(parts))
273 for _, p := range parts {
274 p = strings.TrimSpace(p)
275 if p == "" {
276 continue
277 }
278 scope := Scope(p)
279 if !ValidScopes[scope] {
280 return nil, fmt.Errorf("unknown scope %q", p)
281 }
282 scopes = append(scopes, scope)
283 }
284 if len(scopes) == 0 {
285 return nil, fmt.Errorf("at least one scope is required")
286 }
287 return scopes, nil
288 }
--- internal/bots/auditbot/auditbot.go
+++ internal/bots/auditbot/auditbot.go
@@ -121,10 +121,11 @@
121121
PingTimeout: 30 * time.Second,
122122
SSL: false,
123123
})
124124
125125
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
126
+ cl.Cmd.Mode(cl.GetNick(), "+B")
126127
for _, ch := range b.channels {
127128
cl.Cmd.Join(ch)
128129
}
129130
b.log.Info("auditbot connected", "channels", b.channels, "audit_types", b.auditTypesList())
130131
})
131132
--- internal/bots/auditbot/auditbot.go
+++ internal/bots/auditbot/auditbot.go
@@ -121,10 +121,11 @@
121 PingTimeout: 30 * time.Second,
122 SSL: false,
123 })
124
125 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
126 for _, ch := range b.channels {
127 cl.Cmd.Join(ch)
128 }
129 b.log.Info("auditbot connected", "channels", b.channels, "audit_types", b.auditTypesList())
130 })
131
--- internal/bots/auditbot/auditbot.go
+++ internal/bots/auditbot/auditbot.go
@@ -121,10 +121,11 @@
121 PingTimeout: 30 * time.Second,
122 SSL: false,
123 })
124
125 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
126 cl.Cmd.Mode(cl.GetNick(), "+B")
127 for _, ch := range b.channels {
128 cl.Cmd.Join(ch)
129 }
130 b.log.Info("auditbot connected", "channels", b.channels, "audit_types", b.auditTypesList())
131 })
132
--- internal/bots/auditbot/auditbot.go
+++ internal/bots/auditbot/auditbot.go
@@ -121,10 +121,11 @@
121121
PingTimeout: 30 * time.Second,
122122
SSL: false,
123123
})
124124
125125
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
126
+ cl.Cmd.Mode(cl.GetNick(), "+B")
126127
for _, ch := range b.channels {
127128
cl.Cmd.Join(ch)
128129
}
129130
b.log.Info("auditbot connected", "channels", b.channels, "audit_types", b.auditTypesList())
130131
})
131132
--- internal/bots/auditbot/auditbot.go
+++ internal/bots/auditbot/auditbot.go
@@ -121,10 +121,11 @@
121 PingTimeout: 30 * time.Second,
122 SSL: false,
123 })
124
125 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
126 for _, ch := range b.channels {
127 cl.Cmd.Join(ch)
128 }
129 b.log.Info("auditbot connected", "channels", b.channels, "audit_types", b.auditTypesList())
130 })
131
--- internal/bots/auditbot/auditbot.go
+++ internal/bots/auditbot/auditbot.go
@@ -121,10 +121,11 @@
121 PingTimeout: 30 * time.Second,
122 SSL: false,
123 })
124
125 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
126 cl.Cmd.Mode(cl.GetNick(), "+B")
127 for _, ch := range b.channels {
128 cl.Cmd.Join(ch)
129 }
130 b.log.Info("auditbot connected", "channels", b.channels, "audit_types", b.auditTypesList())
131 })
132
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -34,10 +34,11 @@
3434
type Message struct {
3535
At time.Time `json:"at"`
3636
Channel string `json:"channel"`
3737
Nick string `json:"nick"`
3838
Text string `json:"text"`
39
+ MsgID string `json:"msgid,omitempty"`
3940
Meta *Meta `json:"meta,omitempty"`
4041
}
4142
4243
// ringBuf is a fixed-capacity circular buffer of Messages.
4344
type ringBuf struct {
@@ -101,12 +102,16 @@
101102
// webUserTTL controls how long bridge-posted HTTP nicks stay visible in Users().
102103
webUserTTL time.Duration
103104
104105
msgTotal atomic.Int64
105106
106
- joinCh chan string
107
- client *girc.Client
107
+ joinCh chan string
108
+ client *girc.Client
109
+ onUserJoin func(channel, nick string) // optional callback when a non-bridge user joins
110
+
111
+ // RELAYMSG support detected from ISUPPORT.
112
+ relaySep string // separator (e.g. "/"), empty if unsupported
108113
}
109114
110115
// New creates a bridge Bot.
111116
func New(ircAddr, nick, password string, channels []string, bufSize int, webUserTTL time.Duration, log *slog.Logger) *Bot {
112117
if nick == "" {
@@ -148,10 +153,22 @@
148153
}
149154
b.mu.Lock()
150155
b.webUserTTL = ttl
151156
b.mu.Unlock()
152157
}
158
+
159
+// SetOnUserJoin registers a callback invoked when a non-bridge user joins a channel.
160
+func (b *Bot) SetOnUserJoin(fn func(channel, nick string)) {
161
+ b.onUserJoin = fn
162
+}
163
+
164
+// Notice sends an IRC NOTICE to the given target (nick or channel).
165
+func (b *Bot) Notice(target, text string) {
166
+ if b.client != nil {
167
+ b.client.Cmd.Notice(target, text)
168
+ }
169
+}
153170
154171
// Name returns the bot's IRC nick.
155172
func (b *Bot) Name() string { return b.nick }
156173
157174
// Start connects to IRC and begins bridging messages. Blocks until ctx is cancelled.
@@ -172,10 +189,23 @@
172189
PingTimeout: 30 * time.Second,
173190
SSL: false,
174191
})
175192
176193
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
194
+ cl.Cmd.Mode(cl.GetNick(), "+B")
195
+ // Check RELAYMSG support from ISUPPORT (RPL_005).
196
+ if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
197
+ b.relaySep = sep
198
+ if b.log != nil {
199
+ b.log.Info("bridge: RELAYMSG supported", "separator", sep)
200
+ }
201
+ } else {
202
+ b.relaySep = ""
203
+ if b.log != nil {
204
+ b.log.Info("bridge: RELAYMSG not supported, using [nick] prefix fallback")
205
+ }
206
+ }
177207
if b.log != nil {
178208
b.log.Info("bridge connected")
179209
}
180210
for _, ch := range b.initChannels {
181211
cl.Cmd.Join(ch)
@@ -187,25 +217,33 @@
187217
b.JoinChannel(ch)
188218
}
189219
})
190220
191221
c.Handlers.AddBg(girc.JOIN, func(_ *girc.Client, e girc.Event) {
192
- if len(e.Params) < 1 || e.Source == nil || e.Source.Name != b.nick {
222
+ if len(e.Params) < 1 || e.Source == nil {
193223
return
194224
}
195225
channel := e.Params[0]
196
- b.mu.Lock()
197
- if !b.joined[channel] {
198
- b.joined[channel] = true
199
- if b.buffers[channel] == nil {
200
- b.buffers[channel] = newRingBuf(b.bufSize)
201
- b.subs[channel] = make(map[uint64]chan Message)
202
- }
203
- }
204
- b.mu.Unlock()
205
- if b.log != nil {
206
- b.log.Info("bridge joined channel", "channel", channel)
226
+ nick := e.Source.Name
227
+
228
+ if nick == b.nick {
229
+ // Bridge itself joined — initialize buffers.
230
+ b.mu.Lock()
231
+ if !b.joined[channel] {
232
+ b.joined[channel] = true
233
+ if b.buffers[channel] == nil {
234
+ b.buffers[channel] = newRingBuf(b.bufSize)
235
+ b.subs[channel] = make(map[uint64]chan Message)
236
+ }
237
+ }
238
+ b.mu.Unlock()
239
+ if b.log != nil {
240
+ b.log.Info("bridge joined channel", "channel", channel)
241
+ }
242
+ } else if b.onUserJoin != nil {
243
+ // Another user joined — fire callback for on-join instructions.
244
+ go b.onUserJoin(channel, nick)
207245
}
208246
})
209247
210248
c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
211249
if len(e.Params) < 1 || e.Source == nil {
@@ -219,16 +257,26 @@
219257
nick := e.Source.Name
220258
if acct, ok := e.Tags.Get("account"); ok && acct != "" {
221259
nick = acct
222260
}
223261
224
- b.dispatch(Message{
262
+ var msgID string
263
+ if id, ok := e.Tags.Get("msgid"); ok {
264
+ msgID = id
265
+ }
266
+ msg := Message{
225267
At: e.Timestamp,
226268
Channel: channel,
227269
Nick: nick,
228270
Text: e.Last(),
229
- })
271
+ MsgID: msgID,
272
+ }
273
+ // Read meta-type from IRCv3 client tags if present.
274
+ if metaType, ok := e.Tags.Get("+scuttlebot/meta-type"); ok && metaType != "" {
275
+ msg.Meta = &Meta{Type: metaType}
276
+ }
277
+ b.dispatch(msg)
230278
})
231279
232280
b.client = c
233281
234282
errCh := make(chan error, 1)
@@ -338,19 +386,39 @@
338386
}
339387
340388
// SendWithMeta sends a message to channel with optional structured metadata.
341389
// IRC receives only the plain text; SSE subscribers receive the full message
342390
// including meta for rich rendering in the web UI.
391
+//
392
+// When meta is present, key fields are attached as IRCv3 client-only tags
393
+// (+scuttlebot/meta-type) so any IRCv3 client can read them.
394
+//
395
+// When the server supports RELAYMSG (IRCv3), messages are attributed natively
396
+// so other clients see the real sender nick. Falls back to [nick] prefix.
343397
func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error {
344398
if b.client == nil {
345399
return fmt.Errorf("bridge: not connected")
346400
}
347
- ircText := text
348
- if senderNick != "" {
349
- ircText = "[" + senderNick + "] " + text
401
+ // Build optional IRCv3 tag prefix for meta-type.
402
+ tagPrefix := ""
403
+ if meta != nil && meta.Type != "" {
404
+ tagPrefix = "@+scuttlebot/meta-type=" + meta.Type + " "
350405
}
351
- b.client.Cmd.Message(channel, ircText)
406
+ if senderNick != "" && b.relaySep != "" {
407
+ // Use RELAYMSG for native attribution.
408
+ b.client.Cmd.SendRawf("%sRELAYMSG %s %s :%s", tagPrefix, channel, senderNick, text)
409
+ } else {
410
+ ircText := text
411
+ if senderNick != "" {
412
+ ircText = "[" + senderNick + "] " + text
413
+ }
414
+ if tagPrefix != "" {
415
+ b.client.Cmd.SendRawf("%sPRIVMSG %s :%s", tagPrefix, channel, ircText)
416
+ } else {
417
+ b.client.Cmd.Message(channel, ircText)
418
+ }
419
+ }
352420
353421
if senderNick != "" {
354422
b.TouchUser(channel, senderNick)
355423
}
356424
@@ -421,10 +489,86 @@
421489
}
422490
b.mu.Unlock()
423491
424492
return nicks
425493
}
494
+
495
+// UserInfo describes a user with their IRC modes.
496
+type UserInfo struct {
497
+ Nick string `json:"nick"`
498
+ Modes []string `json:"modes,omitempty"` // e.g. ["o", "v", "B"]
499
+}
500
+
501
+// UsersWithModes returns the current user list with mode info for a channel.
502
+func (b *Bot) UsersWithModes(channel string) []UserInfo {
503
+ seen := make(map[string]bool)
504
+ var users []UserInfo
505
+
506
+ if b.client != nil {
507
+ if ch := b.client.LookupChannel(channel); ch != nil {
508
+ for _, u := range ch.Users(b.client) {
509
+ if u.Nick == b.nick {
510
+ continue
511
+ }
512
+ if seen[u.Nick] {
513
+ continue
514
+ }
515
+ seen[u.Nick] = true
516
+ var modes []string
517
+ if u.Perms != nil {
518
+ if perms, ok := u.Perms.Lookup(channel); ok {
519
+ if perms.Owner {
520
+ modes = append(modes, "q")
521
+ }
522
+ if perms.Admin {
523
+ modes = append(modes, "a")
524
+ }
525
+ if perms.Op {
526
+ modes = append(modes, "o")
527
+ }
528
+ if perms.HalfOp {
529
+ modes = append(modes, "h")
530
+ }
531
+ if perms.Voice {
532
+ modes = append(modes, "v")
533
+ }
534
+ }
535
+ }
536
+ users = append(users, UserInfo{Nick: u.Nick, Modes: modes})
537
+ }
538
+ }
539
+ }
540
+
541
+ now := time.Now()
542
+ b.mu.Lock()
543
+ cutoff := now.Add(-b.webUserTTL)
544
+ for nick, last := range b.webUsers[channel] {
545
+ if !last.After(cutoff) {
546
+ delete(b.webUsers[channel], nick)
547
+ continue
548
+ }
549
+ if !seen[nick] {
550
+ seen[nick] = true
551
+ users = append(users, UserInfo{Nick: nick})
552
+ }
553
+ }
554
+ b.mu.Unlock()
555
+
556
+ return users
557
+}
558
+
559
+// ChannelModes returns the channel mode string (e.g. "+mnt") for a channel.
560
+func (b *Bot) ChannelModes(channel string) string {
561
+ if b.client == nil {
562
+ return ""
563
+ }
564
+ ch := b.client.LookupChannel(channel)
565
+ if ch == nil {
566
+ return ""
567
+ }
568
+ return ch.Modes.String()
569
+}
426570
427571
// Stats returns a snapshot of bridge activity.
428572
func (b *Bot) Stats() Stats {
429573
b.mu.RLock()
430574
channels := len(b.joined)
431575
--- 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 {
@@ -101,12 +102,16 @@
101 // webUserTTL controls how long bridge-posted HTTP nicks stay visible in Users().
102 webUserTTL time.Duration
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 == "" {
@@ -148,10 +153,22 @@
148 }
149 b.mu.Lock()
150 b.webUserTTL = ttl
151 b.mu.Unlock()
152 }
 
 
 
 
 
 
 
 
 
 
 
 
153
154 // Name returns the bot's IRC nick.
155 func (b *Bot) Name() string { return b.nick }
156
157 // Start connects to IRC and begins bridging messages. Blocks until ctx is cancelled.
@@ -172,10 +189,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)
@@ -187,25 +217,33 @@
187 b.JoinChannel(ch)
188 }
189 })
190
191 c.Handlers.AddBg(girc.JOIN, func(_ *girc.Client, e girc.Event) {
192 if len(e.Params) < 1 || e.Source == nil || e.Source.Name != b.nick {
193 return
194 }
195 channel := e.Params[0]
196 b.mu.Lock()
197 if !b.joined[channel] {
198 b.joined[channel] = true
199 if b.buffers[channel] == nil {
200 b.buffers[channel] = newRingBuf(b.bufSize)
201 b.subs[channel] = make(map[uint64]chan Message)
202 }
203 }
204 b.mu.Unlock()
205 if b.log != nil {
206 b.log.Info("bridge joined channel", "channel", channel)
 
 
 
 
 
 
 
 
207 }
208 })
209
210 c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
211 if len(e.Params) < 1 || e.Source == nil {
@@ -219,16 +257,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 +386,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 +489,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 {
@@ -101,12 +102,16 @@
102 // webUserTTL controls how long bridge-posted HTTP nicks stay visible in Users().
103 webUserTTL time.Duration
104
105 msgTotal atomic.Int64
106
107 joinCh chan string
108 client *girc.Client
109 onUserJoin func(channel, nick string) // optional callback when a non-bridge user joins
110
111 // RELAYMSG support detected from ISUPPORT.
112 relaySep string // separator (e.g. "/"), empty if unsupported
113 }
114
115 // New creates a bridge Bot.
116 func New(ircAddr, nick, password string, channels []string, bufSize int, webUserTTL time.Duration, log *slog.Logger) *Bot {
117 if nick == "" {
@@ -148,10 +153,22 @@
153 }
154 b.mu.Lock()
155 b.webUserTTL = ttl
156 b.mu.Unlock()
157 }
158
159 // SetOnUserJoin registers a callback invoked when a non-bridge user joins a channel.
160 func (b *Bot) SetOnUserJoin(fn func(channel, nick string)) {
161 b.onUserJoin = fn
162 }
163
164 // Notice sends an IRC NOTICE to the given target (nick or channel).
165 func (b *Bot) Notice(target, text string) {
166 if b.client != nil {
167 b.client.Cmd.Notice(target, text)
168 }
169 }
170
171 // Name returns the bot's IRC nick.
172 func (b *Bot) Name() string { return b.nick }
173
174 // Start connects to IRC and begins bridging messages. Blocks until ctx is cancelled.
@@ -172,10 +189,23 @@
189 PingTimeout: 30 * time.Second,
190 SSL: false,
191 })
192
193 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
194 cl.Cmd.Mode(cl.GetNick(), "+B")
195 // Check RELAYMSG support from ISUPPORT (RPL_005).
196 if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
197 b.relaySep = sep
198 if b.log != nil {
199 b.log.Info("bridge: RELAYMSG supported", "separator", sep)
200 }
201 } else {
202 b.relaySep = ""
203 if b.log != nil {
204 b.log.Info("bridge: RELAYMSG not supported, using [nick] prefix fallback")
205 }
206 }
207 if b.log != nil {
208 b.log.Info("bridge connected")
209 }
210 for _, ch := range b.initChannels {
211 cl.Cmd.Join(ch)
@@ -187,25 +217,33 @@
217 b.JoinChannel(ch)
218 }
219 })
220
221 c.Handlers.AddBg(girc.JOIN, func(_ *girc.Client, e girc.Event) {
222 if len(e.Params) < 1 || e.Source == nil {
223 return
224 }
225 channel := e.Params[0]
226 nick := e.Source.Name
227
228 if nick == b.nick {
229 // Bridge itself joined — initialize buffers.
230 b.mu.Lock()
231 if !b.joined[channel] {
232 b.joined[channel] = true
233 if b.buffers[channel] == nil {
234 b.buffers[channel] = newRingBuf(b.bufSize)
235 b.subs[channel] = make(map[uint64]chan Message)
236 }
237 }
238 b.mu.Unlock()
239 if b.log != nil {
240 b.log.Info("bridge joined channel", "channel", channel)
241 }
242 } else if b.onUserJoin != nil {
243 // Another user joined — fire callback for on-join instructions.
244 go b.onUserJoin(channel, nick)
245 }
246 })
247
248 c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
249 if len(e.Params) < 1 || e.Source == nil {
@@ -219,16 +257,26 @@
257 nick := e.Source.Name
258 if acct, ok := e.Tags.Get("account"); ok && acct != "" {
259 nick = acct
260 }
261
262 var msgID string
263 if id, ok := e.Tags.Get("msgid"); ok {
264 msgID = id
265 }
266 msg := Message{
267 At: e.Timestamp,
268 Channel: channel,
269 Nick: nick,
270 Text: e.Last(),
271 MsgID: msgID,
272 }
273 // Read meta-type from IRCv3 client tags if present.
274 if metaType, ok := e.Tags.Get("+scuttlebot/meta-type"); ok && metaType != "" {
275 msg.Meta = &Meta{Type: metaType}
276 }
277 b.dispatch(msg)
278 })
279
280 b.client = c
281
282 errCh := make(chan error, 1)
@@ -338,19 +386,39 @@
386 }
387
388 // SendWithMeta sends a message to channel with optional structured metadata.
389 // IRC receives only the plain text; SSE subscribers receive the full message
390 // including meta for rich rendering in the web UI.
391 //
392 // When meta is present, key fields are attached as IRCv3 client-only tags
393 // (+scuttlebot/meta-type) so any IRCv3 client can read them.
394 //
395 // When the server supports RELAYMSG (IRCv3), messages are attributed natively
396 // so other clients see the real sender nick. Falls back to [nick] prefix.
397 func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error {
398 if b.client == nil {
399 return fmt.Errorf("bridge: not connected")
400 }
401 // Build optional IRCv3 tag prefix for meta-type.
402 tagPrefix := ""
403 if meta != nil && meta.Type != "" {
404 tagPrefix = "@+scuttlebot/meta-type=" + meta.Type + " "
405 }
406 if senderNick != "" && b.relaySep != "" {
407 // Use RELAYMSG for native attribution.
408 b.client.Cmd.SendRawf("%sRELAYMSG %s %s :%s", tagPrefix, channel, senderNick, text)
409 } else {
410 ircText := text
411 if senderNick != "" {
412 ircText = "[" + senderNick + "] " + text
413 }
414 if tagPrefix != "" {
415 b.client.Cmd.SendRawf("%sPRIVMSG %s :%s", tagPrefix, channel, ircText)
416 } else {
417 b.client.Cmd.Message(channel, ircText)
418 }
419 }
420
421 if senderNick != "" {
422 b.TouchUser(channel, senderNick)
423 }
424
@@ -421,10 +489,86 @@
489 }
490 b.mu.Unlock()
491
492 return nicks
493 }
494
495 // UserInfo describes a user with their IRC modes.
496 type UserInfo struct {
497 Nick string `json:"nick"`
498 Modes []string `json:"modes,omitempty"` // e.g. ["o", "v", "B"]
499 }
500
501 // UsersWithModes returns the current user list with mode info for a channel.
502 func (b *Bot) UsersWithModes(channel string) []UserInfo {
503 seen := make(map[string]bool)
504 var users []UserInfo
505
506 if b.client != nil {
507 if ch := b.client.LookupChannel(channel); ch != nil {
508 for _, u := range ch.Users(b.client) {
509 if u.Nick == b.nick {
510 continue
511 }
512 if seen[u.Nick] {
513 continue
514 }
515 seen[u.Nick] = true
516 var modes []string
517 if u.Perms != nil {
518 if perms, ok := u.Perms.Lookup(channel); ok {
519 if perms.Owner {
520 modes = append(modes, "q")
521 }
522 if perms.Admin {
523 modes = append(modes, "a")
524 }
525 if perms.Op {
526 modes = append(modes, "o")
527 }
528 if perms.HalfOp {
529 modes = append(modes, "h")
530 }
531 if perms.Voice {
532 modes = append(modes, "v")
533 }
534 }
535 }
536 users = append(users, UserInfo{Nick: u.Nick, Modes: modes})
537 }
538 }
539 }
540
541 now := time.Now()
542 b.mu.Lock()
543 cutoff := now.Add(-b.webUserTTL)
544 for nick, last := range b.webUsers[channel] {
545 if !last.After(cutoff) {
546 delete(b.webUsers[channel], nick)
547 continue
548 }
549 if !seen[nick] {
550 seen[nick] = true
551 users = append(users, UserInfo{Nick: nick})
552 }
553 }
554 b.mu.Unlock()
555
556 return users
557 }
558
559 // ChannelModes returns the channel mode string (e.g. "+mnt") for a channel.
560 func (b *Bot) ChannelModes(channel string) string {
561 if b.client == nil {
562 return ""
563 }
564 ch := b.client.LookupChannel(channel)
565 if ch == nil {
566 return ""
567 }
568 return ch.Modes.String()
569 }
570
571 // Stats returns a snapshot of bridge activity.
572 func (b *Bot) Stats() Stats {
573 b.mu.RLock()
574 channels := len(b.joined)
575
--- internal/bots/herald/herald.go
+++ internal/bots/herald/herald.go
@@ -153,10 +153,11 @@
153153
PingTimeout: 30 * time.Second,
154154
SSL: false,
155155
})
156156
157157
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
158
+ cl.Cmd.Mode(cl.GetNick(), "+B")
158159
for _, ch := range b.channels {
159160
cl.Cmd.Join(ch)
160161
}
161162
if b.log != nil {
162163
b.log.Info("herald connected", "channels", b.channels)
163164
--- internal/bots/herald/herald.go
+++ internal/bots/herald/herald.go
@@ -153,10 +153,11 @@
153 PingTimeout: 30 * time.Second,
154 SSL: false,
155 })
156
157 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
158 for _, ch := range b.channels {
159 cl.Cmd.Join(ch)
160 }
161 if b.log != nil {
162 b.log.Info("herald connected", "channels", b.channels)
163
--- internal/bots/herald/herald.go
+++ internal/bots/herald/herald.go
@@ -153,10 +153,11 @@
153 PingTimeout: 30 * time.Second,
154 SSL: false,
155 })
156
157 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
158 cl.Cmd.Mode(cl.GetNick(), "+B")
159 for _, ch := range b.channels {
160 cl.Cmd.Join(ch)
161 }
162 if b.log != nil {
163 b.log.Info("herald connected", "channels", b.channels)
164
--- internal/bots/herald/herald.go
+++ internal/bots/herald/herald.go
@@ -153,10 +153,11 @@
153153
PingTimeout: 30 * time.Second,
154154
SSL: false,
155155
})
156156
157157
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
158
+ cl.Cmd.Mode(cl.GetNick(), "+B")
158159
for _, ch := range b.channels {
159160
cl.Cmd.Join(ch)
160161
}
161162
if b.log != nil {
162163
b.log.Info("herald connected", "channels", b.channels)
163164
--- internal/bots/herald/herald.go
+++ internal/bots/herald/herald.go
@@ -153,10 +153,11 @@
153 PingTimeout: 30 * time.Second,
154 SSL: false,
155 })
156
157 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
158 for _, ch := range b.channels {
159 cl.Cmd.Join(ch)
160 }
161 if b.log != nil {
162 b.log.Info("herald connected", "channels", b.channels)
163
--- internal/bots/herald/herald.go
+++ internal/bots/herald/herald.go
@@ -153,10 +153,11 @@
153 PingTimeout: 30 * time.Second,
154 SSL: false,
155 })
156
157 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
158 cl.Cmd.Mode(cl.GetNick(), "+B")
159 for _, ch := range b.channels {
160 cl.Cmd.Join(ch)
161 }
162 if b.log != nil {
163 b.log.Info("herald connected", "channels", b.channels)
164
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -22,10 +22,12 @@
2222
"time"
2323
2424
"github.com/lrstanley/girc"
2525
2626
"github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
27
+ "github.com/conflicthq/scuttlebot/pkg/chathistory"
28
+ "github.com/conflicthq/scuttlebot/pkg/toon"
2729
)
2830
2931
const (
3032
botNick = "oracle"
3133
defaultLimit = 50
@@ -126,10 +128,11 @@
126128
llm LLMProvider
127129
log *slog.Logger
128130
mu sync.Mutex
129131
lastReq map[string]time.Time // nick → last request time
130132
client *girc.Client
133
+ chFetch *chathistory.Fetcher // CHATHISTORY fetcher, nil if unsupported
131134
}
132135
133136
// New creates an oracle bot.
134137
func New(ircAddr, password string, channels []string, history HistoryFetcher, llm LLMProvider, log *slog.Logger) *Bot {
135138
return &Bot{
@@ -161,18 +164,26 @@
161164
Name: "scuttlebot oracle",
162165
SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
163166
PingDelay: 30 * time.Second,
164167
PingTimeout: 30 * time.Second,
165168
SSL: false,
169
+ SupportedCaps: map[string][]string{
170
+ "draft/chathistory": nil,
171
+ "chathistory": nil,
172
+ },
166173
})
174
+
175
+ b.chFetch = chathistory.New(c)
167176
168177
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
178
+ cl.Cmd.Mode(cl.GetNick(), "+B")
169179
for _, ch := range b.channels {
170180
cl.Cmd.Join(ch)
171181
}
182
+ hasCH := cl.HasCapability("chathistory") || cl.HasCapability("draft/chathistory")
172183
if b.log != nil {
173
- b.log.Info("oracle connected", "channels", b.channels)
184
+ b.log.Info("oracle connected", "channels", b.channels, "chathistory", hasCH)
174185
}
175186
})
176187
177188
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
178189
if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -248,12 +259,12 @@
248259
return
249260
}
250261
b.lastReq[nick] = time.Now()
251262
b.mu.Unlock()
252263
253
- // Fetch history.
254
- entries, err := b.history.Query(req.Channel, req.Limit)
264
+ // Fetch history — prefer CHATHISTORY if available, fall back to store.
265
+ entries, err := b.fetchHistory(ctx, req.Channel, req.Limit)
255266
if err != nil {
256267
cl.Cmd.Notice(nick, fmt.Sprintf("oracle: failed to fetch history for %s: %v", req.Channel, err))
257268
return
258269
}
259270
if len(entries) == 0 {
@@ -277,24 +288,51 @@
277288
if line != "" {
278289
cl.Cmd.Notice(nick, line)
279290
}
280291
}
281292
}
293
+
294
+func (b *Bot) fetchHistory(ctx context.Context, channel string, limit int) ([]HistoryEntry, error) {
295
+ if b.chFetch != nil && b.client != nil {
296
+ hasCH := b.client.HasCapability("chathistory") || b.client.HasCapability("draft/chathistory")
297
+ if hasCH {
298
+ chCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
299
+ defer cancel()
300
+ msgs, err := b.chFetch.Latest(chCtx, channel, limit)
301
+ if err == nil {
302
+ entries := make([]HistoryEntry, len(msgs))
303
+ for i, m := range msgs {
304
+ nick := m.Nick
305
+ if m.Account != "" {
306
+ nick = m.Account
307
+ }
308
+ entries[i] = HistoryEntry{
309
+ Nick: nick,
310
+ Raw: m.Text,
311
+ }
312
+ }
313
+ return entries, nil
314
+ }
315
+ if b.log != nil {
316
+ b.log.Warn("chathistory failed, falling back to store", "err", err)
317
+ }
318
+ }
319
+ }
320
+ return b.history.Query(channel, limit)
321
+}
282322
283323
func buildPrompt(channel string, entries []HistoryEntry) string {
284
- var sb strings.Builder
285
- fmt.Fprintf(&sb, "Summarize the following IRC conversation from %s.\n", channel)
286
- fmt.Fprintf(&sb, "Focus on: key decisions, actions taken, outstanding tasks, and important context.\n")
287
- fmt.Fprintf(&sb, "Be concise. %d messages:\n\n", len(entries))
288
- for _, e := range entries {
289
- if e.MessageType != "" {
290
- fmt.Fprintf(&sb, "[%s] (type=%s) %s\n", e.Nick, e.MessageType, e.Raw)
291
- } else {
292
- fmt.Fprintf(&sb, "[%s] %s\n", e.Nick, e.Raw)
324
+ // Convert to TOON entries for token-efficient LLM context.
325
+ toonEntries := make([]toon.Entry, len(entries))
326
+ for i, e := range entries {
327
+ toonEntries[i] = toon.Entry{
328
+ Nick: e.Nick,
329
+ MessageType: e.MessageType,
330
+ Text: e.Raw,
293331
}
294332
}
295
- return sb.String()
333
+ return toon.FormatPrompt(channel, toonEntries)
296334
}
297335
298336
func formatResponse(channel string, count int, summary string, format Format) string {
299337
switch format {
300338
case FormatJSON:
301339
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -22,10 +22,12 @@
22 "time"
23
24 "github.com/lrstanley/girc"
25
26 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
 
 
27 )
28
29 const (
30 botNick = "oracle"
31 defaultLimit = 50
@@ -126,10 +128,11 @@
126 llm LLMProvider
127 log *slog.Logger
128 mu sync.Mutex
129 lastReq map[string]time.Time // nick → last request time
130 client *girc.Client
 
131 }
132
133 // New creates an oracle bot.
134 func New(ircAddr, password string, channels []string, history HistoryFetcher, llm LLMProvider, log *slog.Logger) *Bot {
135 return &Bot{
@@ -161,18 +164,26 @@
161 Name: "scuttlebot oracle",
162 SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
163 PingDelay: 30 * time.Second,
164 PingTimeout: 30 * time.Second,
165 SSL: false,
 
 
 
 
166 })
 
 
167
168 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
169 for _, ch := range b.channels {
170 cl.Cmd.Join(ch)
171 }
 
172 if b.log != nil {
173 b.log.Info("oracle connected", "channels", b.channels)
174 }
175 })
176
177 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
178 if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -248,12 +259,12 @@
248 return
249 }
250 b.lastReq[nick] = time.Now()
251 b.mu.Unlock()
252
253 // Fetch history.
254 entries, err := b.history.Query(req.Channel, req.Limit)
255 if err != nil {
256 cl.Cmd.Notice(nick, fmt.Sprintf("oracle: failed to fetch history for %s: %v", req.Channel, err))
257 return
258 }
259 if len(entries) == 0 {
@@ -277,24 +288,51 @@
277 if line != "" {
278 cl.Cmd.Notice(nick, line)
279 }
280 }
281 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
283 func buildPrompt(channel string, entries []HistoryEntry) string {
284 var sb strings.Builder
285 fmt.Fprintf(&sb, "Summarize the following IRC conversation from %s.\n", channel)
286 fmt.Fprintf(&sb, "Focus on: key decisions, actions taken, outstanding tasks, and important context.\n")
287 fmt.Fprintf(&sb, "Be concise. %d messages:\n\n", len(entries))
288 for _, e := range entries {
289 if e.MessageType != "" {
290 fmt.Fprintf(&sb, "[%s] (type=%s) %s\n", e.Nick, e.MessageType, e.Raw)
291 } else {
292 fmt.Fprintf(&sb, "[%s] %s\n", e.Nick, e.Raw)
293 }
294 }
295 return sb.String()
296 }
297
298 func formatResponse(channel string, count int, summary string, format Format) string {
299 switch format {
300 case FormatJSON:
301
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -22,10 +22,12 @@
22 "time"
23
24 "github.com/lrstanley/girc"
25
26 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
27 "github.com/conflicthq/scuttlebot/pkg/chathistory"
28 "github.com/conflicthq/scuttlebot/pkg/toon"
29 )
30
31 const (
32 botNick = "oracle"
33 defaultLimit = 50
@@ -126,10 +128,11 @@
128 llm LLMProvider
129 log *slog.Logger
130 mu sync.Mutex
131 lastReq map[string]time.Time // nick → last request time
132 client *girc.Client
133 chFetch *chathistory.Fetcher // CHATHISTORY fetcher, nil if unsupported
134 }
135
136 // New creates an oracle bot.
137 func New(ircAddr, password string, channels []string, history HistoryFetcher, llm LLMProvider, log *slog.Logger) *Bot {
138 return &Bot{
@@ -161,18 +164,26 @@
164 Name: "scuttlebot oracle",
165 SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
166 PingDelay: 30 * time.Second,
167 PingTimeout: 30 * time.Second,
168 SSL: false,
169 SupportedCaps: map[string][]string{
170 "draft/chathistory": nil,
171 "chathistory": nil,
172 },
173 })
174
175 b.chFetch = chathistory.New(c)
176
177 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
178 cl.Cmd.Mode(cl.GetNick(), "+B")
179 for _, ch := range b.channels {
180 cl.Cmd.Join(ch)
181 }
182 hasCH := cl.HasCapability("chathistory") || cl.HasCapability("draft/chathistory")
183 if b.log != nil {
184 b.log.Info("oracle connected", "channels", b.channels, "chathistory", hasCH)
185 }
186 })
187
188 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
189 if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -248,12 +259,12 @@
259 return
260 }
261 b.lastReq[nick] = time.Now()
262 b.mu.Unlock()
263
264 // Fetch history — prefer CHATHISTORY if available, fall back to store.
265 entries, err := b.fetchHistory(ctx, req.Channel, req.Limit)
266 if err != nil {
267 cl.Cmd.Notice(nick, fmt.Sprintf("oracle: failed to fetch history for %s: %v", req.Channel, err))
268 return
269 }
270 if len(entries) == 0 {
@@ -277,24 +288,51 @@
288 if line != "" {
289 cl.Cmd.Notice(nick, line)
290 }
291 }
292 }
293
294 func (b *Bot) fetchHistory(ctx context.Context, channel string, limit int) ([]HistoryEntry, error) {
295 if b.chFetch != nil && b.client != nil {
296 hasCH := b.client.HasCapability("chathistory") || b.client.HasCapability("draft/chathistory")
297 if hasCH {
298 chCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
299 defer cancel()
300 msgs, err := b.chFetch.Latest(chCtx, channel, limit)
301 if err == nil {
302 entries := make([]HistoryEntry, len(msgs))
303 for i, m := range msgs {
304 nick := m.Nick
305 if m.Account != "" {
306 nick = m.Account
307 }
308 entries[i] = HistoryEntry{
309 Nick: nick,
310 Raw: m.Text,
311 }
312 }
313 return entries, nil
314 }
315 if b.log != nil {
316 b.log.Warn("chathistory failed, falling back to store", "err", err)
317 }
318 }
319 }
320 return b.history.Query(channel, limit)
321 }
322
323 func buildPrompt(channel string, entries []HistoryEntry) string {
324 // Convert to TOON entries for token-efficient LLM context.
325 toonEntries := make([]toon.Entry, len(entries))
326 for i, e := range entries {
327 toonEntries[i] = toon.Entry{
328 Nick: e.Nick,
329 MessageType: e.MessageType,
330 Text: e.Raw,
 
 
331 }
332 }
333 return toon.FormatPrompt(channel, toonEntries)
334 }
335
336 func formatResponse(channel string, count int, summary string, format Format) string {
337 switch format {
338 case FormatJSON:
339
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -22,10 +22,12 @@
2222
"time"
2323
2424
"github.com/lrstanley/girc"
2525
2626
"github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
27
+ "github.com/conflicthq/scuttlebot/pkg/chathistory"
28
+ "github.com/conflicthq/scuttlebot/pkg/toon"
2729
)
2830
2931
const (
3032
botNick = "oracle"
3133
defaultLimit = 50
@@ -126,10 +128,11 @@
126128
llm LLMProvider
127129
log *slog.Logger
128130
mu sync.Mutex
129131
lastReq map[string]time.Time // nick → last request time
130132
client *girc.Client
133
+ chFetch *chathistory.Fetcher // CHATHISTORY fetcher, nil if unsupported
131134
}
132135
133136
// New creates an oracle bot.
134137
func New(ircAddr, password string, channels []string, history HistoryFetcher, llm LLMProvider, log *slog.Logger) *Bot {
135138
return &Bot{
@@ -161,18 +164,26 @@
161164
Name: "scuttlebot oracle",
162165
SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
163166
PingDelay: 30 * time.Second,
164167
PingTimeout: 30 * time.Second,
165168
SSL: false,
169
+ SupportedCaps: map[string][]string{
170
+ "draft/chathistory": nil,
171
+ "chathistory": nil,
172
+ },
166173
})
174
+
175
+ b.chFetch = chathistory.New(c)
167176
168177
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
178
+ cl.Cmd.Mode(cl.GetNick(), "+B")
169179
for _, ch := range b.channels {
170180
cl.Cmd.Join(ch)
171181
}
182
+ hasCH := cl.HasCapability("chathistory") || cl.HasCapability("draft/chathistory")
172183
if b.log != nil {
173
- b.log.Info("oracle connected", "channels", b.channels)
184
+ b.log.Info("oracle connected", "channels", b.channels, "chathistory", hasCH)
174185
}
175186
})
176187
177188
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
178189
if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -248,12 +259,12 @@
248259
return
249260
}
250261
b.lastReq[nick] = time.Now()
251262
b.mu.Unlock()
252263
253
- // Fetch history.
254
- entries, err := b.history.Query(req.Channel, req.Limit)
264
+ // Fetch history — prefer CHATHISTORY if available, fall back to store.
265
+ entries, err := b.fetchHistory(ctx, req.Channel, req.Limit)
255266
if err != nil {
256267
cl.Cmd.Notice(nick, fmt.Sprintf("oracle: failed to fetch history for %s: %v", req.Channel, err))
257268
return
258269
}
259270
if len(entries) == 0 {
@@ -277,24 +288,51 @@
277288
if line != "" {
278289
cl.Cmd.Notice(nick, line)
279290
}
280291
}
281292
}
293
+
294
+func (b *Bot) fetchHistory(ctx context.Context, channel string, limit int) ([]HistoryEntry, error) {
295
+ if b.chFetch != nil && b.client != nil {
296
+ hasCH := b.client.HasCapability("chathistory") || b.client.HasCapability("draft/chathistory")
297
+ if hasCH {
298
+ chCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
299
+ defer cancel()
300
+ msgs, err := b.chFetch.Latest(chCtx, channel, limit)
301
+ if err == nil {
302
+ entries := make([]HistoryEntry, len(msgs))
303
+ for i, m := range msgs {
304
+ nick := m.Nick
305
+ if m.Account != "" {
306
+ nick = m.Account
307
+ }
308
+ entries[i] = HistoryEntry{
309
+ Nick: nick,
310
+ Raw: m.Text,
311
+ }
312
+ }
313
+ return entries, nil
314
+ }
315
+ if b.log != nil {
316
+ b.log.Warn("chathistory failed, falling back to store", "err", err)
317
+ }
318
+ }
319
+ }
320
+ return b.history.Query(channel, limit)
321
+}
282322
283323
func buildPrompt(channel string, entries []HistoryEntry) string {
284
- var sb strings.Builder
285
- fmt.Fprintf(&sb, "Summarize the following IRC conversation from %s.\n", channel)
286
- fmt.Fprintf(&sb, "Focus on: key decisions, actions taken, outstanding tasks, and important context.\n")
287
- fmt.Fprintf(&sb, "Be concise. %d messages:\n\n", len(entries))
288
- for _, e := range entries {
289
- if e.MessageType != "" {
290
- fmt.Fprintf(&sb, "[%s] (type=%s) %s\n", e.Nick, e.MessageType, e.Raw)
291
- } else {
292
- fmt.Fprintf(&sb, "[%s] %s\n", e.Nick, e.Raw)
324
+ // Convert to TOON entries for token-efficient LLM context.
325
+ toonEntries := make([]toon.Entry, len(entries))
326
+ for i, e := range entries {
327
+ toonEntries[i] = toon.Entry{
328
+ Nick: e.Nick,
329
+ MessageType: e.MessageType,
330
+ Text: e.Raw,
293331
}
294332
}
295
- return sb.String()
333
+ return toon.FormatPrompt(channel, toonEntries)
296334
}
297335
298336
func formatResponse(channel string, count int, summary string, format Format) string {
299337
switch format {
300338
case FormatJSON:
301339
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -22,10 +22,12 @@
22 "time"
23
24 "github.com/lrstanley/girc"
25
26 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
 
 
27 )
28
29 const (
30 botNick = "oracle"
31 defaultLimit = 50
@@ -126,10 +128,11 @@
126 llm LLMProvider
127 log *slog.Logger
128 mu sync.Mutex
129 lastReq map[string]time.Time // nick → last request time
130 client *girc.Client
 
131 }
132
133 // New creates an oracle bot.
134 func New(ircAddr, password string, channels []string, history HistoryFetcher, llm LLMProvider, log *slog.Logger) *Bot {
135 return &Bot{
@@ -161,18 +164,26 @@
161 Name: "scuttlebot oracle",
162 SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
163 PingDelay: 30 * time.Second,
164 PingTimeout: 30 * time.Second,
165 SSL: false,
 
 
 
 
166 })
 
 
167
168 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
169 for _, ch := range b.channels {
170 cl.Cmd.Join(ch)
171 }
 
172 if b.log != nil {
173 b.log.Info("oracle connected", "channels", b.channels)
174 }
175 })
176
177 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
178 if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -248,12 +259,12 @@
248 return
249 }
250 b.lastReq[nick] = time.Now()
251 b.mu.Unlock()
252
253 // Fetch history.
254 entries, err := b.history.Query(req.Channel, req.Limit)
255 if err != nil {
256 cl.Cmd.Notice(nick, fmt.Sprintf("oracle: failed to fetch history for %s: %v", req.Channel, err))
257 return
258 }
259 if len(entries) == 0 {
@@ -277,24 +288,51 @@
277 if line != "" {
278 cl.Cmd.Notice(nick, line)
279 }
280 }
281 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
283 func buildPrompt(channel string, entries []HistoryEntry) string {
284 var sb strings.Builder
285 fmt.Fprintf(&sb, "Summarize the following IRC conversation from %s.\n", channel)
286 fmt.Fprintf(&sb, "Focus on: key decisions, actions taken, outstanding tasks, and important context.\n")
287 fmt.Fprintf(&sb, "Be concise. %d messages:\n\n", len(entries))
288 for _, e := range entries {
289 if e.MessageType != "" {
290 fmt.Fprintf(&sb, "[%s] (type=%s) %s\n", e.Nick, e.MessageType, e.Raw)
291 } else {
292 fmt.Fprintf(&sb, "[%s] %s\n", e.Nick, e.Raw)
293 }
294 }
295 return sb.String()
296 }
297
298 func formatResponse(channel string, count int, summary string, format Format) string {
299 switch format {
300 case FormatJSON:
301
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -22,10 +22,12 @@
22 "time"
23
24 "github.com/lrstanley/girc"
25
26 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
27 "github.com/conflicthq/scuttlebot/pkg/chathistory"
28 "github.com/conflicthq/scuttlebot/pkg/toon"
29 )
30
31 const (
32 botNick = "oracle"
33 defaultLimit = 50
@@ -126,10 +128,11 @@
128 llm LLMProvider
129 log *slog.Logger
130 mu sync.Mutex
131 lastReq map[string]time.Time // nick → last request time
132 client *girc.Client
133 chFetch *chathistory.Fetcher // CHATHISTORY fetcher, nil if unsupported
134 }
135
136 // New creates an oracle bot.
137 func New(ircAddr, password string, channels []string, history HistoryFetcher, llm LLMProvider, log *slog.Logger) *Bot {
138 return &Bot{
@@ -161,18 +164,26 @@
164 Name: "scuttlebot oracle",
165 SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
166 PingDelay: 30 * time.Second,
167 PingTimeout: 30 * time.Second,
168 SSL: false,
169 SupportedCaps: map[string][]string{
170 "draft/chathistory": nil,
171 "chathistory": nil,
172 },
173 })
174
175 b.chFetch = chathistory.New(c)
176
177 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
178 cl.Cmd.Mode(cl.GetNick(), "+B")
179 for _, ch := range b.channels {
180 cl.Cmd.Join(ch)
181 }
182 hasCH := cl.HasCapability("chathistory") || cl.HasCapability("draft/chathistory")
183 if b.log != nil {
184 b.log.Info("oracle connected", "channels", b.channels, "chathistory", hasCH)
185 }
186 })
187
188 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
189 if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -248,12 +259,12 @@
259 return
260 }
261 b.lastReq[nick] = time.Now()
262 b.mu.Unlock()
263
264 // Fetch history — prefer CHATHISTORY if available, fall back to store.
265 entries, err := b.fetchHistory(ctx, req.Channel, req.Limit)
266 if err != nil {
267 cl.Cmd.Notice(nick, fmt.Sprintf("oracle: failed to fetch history for %s: %v", req.Channel, err))
268 return
269 }
270 if len(entries) == 0 {
@@ -277,24 +288,51 @@
288 if line != "" {
289 cl.Cmd.Notice(nick, line)
290 }
291 }
292 }
293
294 func (b *Bot) fetchHistory(ctx context.Context, channel string, limit int) ([]HistoryEntry, error) {
295 if b.chFetch != nil && b.client != nil {
296 hasCH := b.client.HasCapability("chathistory") || b.client.HasCapability("draft/chathistory")
297 if hasCH {
298 chCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
299 defer cancel()
300 msgs, err := b.chFetch.Latest(chCtx, channel, limit)
301 if err == nil {
302 entries := make([]HistoryEntry, len(msgs))
303 for i, m := range msgs {
304 nick := m.Nick
305 if m.Account != "" {
306 nick = m.Account
307 }
308 entries[i] = HistoryEntry{
309 Nick: nick,
310 Raw: m.Text,
311 }
312 }
313 return entries, nil
314 }
315 if b.log != nil {
316 b.log.Warn("chathistory failed, falling back to store", "err", err)
317 }
318 }
319 }
320 return b.history.Query(channel, limit)
321 }
322
323 func buildPrompt(channel string, entries []HistoryEntry) string {
324 // Convert to TOON entries for token-efficient LLM context.
325 toonEntries := make([]toon.Entry, len(entries))
326 for i, e := range entries {
327 toonEntries[i] = toon.Entry{
328 Nick: e.Nick,
329 MessageType: e.MessageType,
330 Text: e.Raw,
 
 
331 }
332 }
333 return toon.FormatPrompt(channel, toonEntries)
334 }
335
336 func formatResponse(channel string, count int, summary string, format Format) string {
337 switch format {
338 case FormatJSON:
339
--- internal/bots/scribe/scribe.go
+++ internal/bots/scribe/scribe.go
@@ -65,10 +65,11 @@
6565
PingTimeout: 30 * time.Second,
6666
SSL: false,
6767
})
6868
6969
c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) {
70
+ client.Cmd.Mode(client.GetNick(), "+B")
7071
for _, ch := range b.channels {
7172
client.Cmd.Join(ch)
7273
}
7374
b.log.Info("scribe connected and joined channels", "channels", b.channels)
7475
})
7576
--- internal/bots/scribe/scribe.go
+++ internal/bots/scribe/scribe.go
@@ -65,10 +65,11 @@
65 PingTimeout: 30 * time.Second,
66 SSL: false,
67 })
68
69 c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) {
 
70 for _, ch := range b.channels {
71 client.Cmd.Join(ch)
72 }
73 b.log.Info("scribe connected and joined channels", "channels", b.channels)
74 })
75
--- internal/bots/scribe/scribe.go
+++ internal/bots/scribe/scribe.go
@@ -65,10 +65,11 @@
65 PingTimeout: 30 * time.Second,
66 SSL: false,
67 })
68
69 c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) {
70 client.Cmd.Mode(client.GetNick(), "+B")
71 for _, ch := range b.channels {
72 client.Cmd.Join(ch)
73 }
74 b.log.Info("scribe connected and joined channels", "channels", b.channels)
75 })
76
--- internal/bots/scribe/scribe.go
+++ internal/bots/scribe/scribe.go
@@ -65,10 +65,11 @@
6565
PingTimeout: 30 * time.Second,
6666
SSL: false,
6767
})
6868
6969
c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) {
70
+ client.Cmd.Mode(client.GetNick(), "+B")
7071
for _, ch := range b.channels {
7172
client.Cmd.Join(ch)
7273
}
7374
b.log.Info("scribe connected and joined channels", "channels", b.channels)
7475
})
7576
--- internal/bots/scribe/scribe.go
+++ internal/bots/scribe/scribe.go
@@ -65,10 +65,11 @@
65 PingTimeout: 30 * time.Second,
66 SSL: false,
67 })
68
69 c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) {
 
70 for _, ch := range b.channels {
71 client.Cmd.Join(ch)
72 }
73 b.log.Info("scribe connected and joined channels", "channels", b.channels)
74 })
75
--- internal/bots/scribe/scribe.go
+++ internal/bots/scribe/scribe.go
@@ -65,10 +65,11 @@
65 PingTimeout: 30 * time.Second,
66 SSL: false,
67 })
68
69 c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) {
70 client.Cmd.Mode(client.GetNick(), "+B")
71 for _, ch := range b.channels {
72 client.Cmd.Join(ch)
73 }
74 b.log.Info("scribe connected and joined channels", "channels", b.channels)
75 })
76
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -22,10 +22,12 @@
2222
2323
"github.com/lrstanley/girc"
2424
2525
"github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
2626
"github.com/conflicthq/scuttlebot/internal/bots/scribe"
27
+ "github.com/conflicthq/scuttlebot/pkg/chathistory"
28
+ "github.com/conflicthq/scuttlebot/pkg/toon"
2729
)
2830
2931
const (
3032
botNick = "scroll"
3133
defaultLimit = 50
@@ -39,11 +41,12 @@
3941
password string
4042
channels []string
4143
store scribe.Store
4244
log *slog.Logger
4345
client *girc.Client
44
- rateLimit sync.Map // nick → last request time
46
+ history *chathistory.Fetcher // nil until connected, if CHATHISTORY is available
47
+ rateLimit sync.Map // nick → last request time
4548
}
4649
4750
// New creates a scroll Bot backed by the given scribe Store.
4851
func New(ircAddr, password string, channels []string, store scribe.Store, log *slog.Logger) *Bot {
4952
return &Bot{
@@ -73,17 +76,26 @@
7376
Name: "scuttlebot scroll",
7477
SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
7578
PingDelay: 30 * time.Second,
7679
PingTimeout: 30 * time.Second,
7780
SSL: false,
81
+ SupportedCaps: map[string][]string{
82
+ "draft/chathistory": nil,
83
+ "chathistory": nil,
84
+ },
7885
})
86
+
87
+ // Register CHATHISTORY batch handlers before connecting.
88
+ b.history = chathistory.New(c)
7989
8090
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, e girc.Event) {
91
+ cl.Cmd.Mode(cl.GetNick(), "+B")
8192
for _, ch := range b.channels {
8293
cl.Cmd.Join(ch)
8394
}
84
- b.log.Info("scroll connected", "channels", b.channels)
95
+ hasCH := cl.HasCapability("chathistory") || cl.HasCapability("draft/chathistory")
96
+ b.log.Info("scroll connected", "channels", b.channels, "chathistory", hasCH)
8597
})
8698
8799
router := cmdparse.NewRouter(botNick)
88100
router.Register(cmdparse.Command{
89101
Name: "replay",
@@ -148,15 +160,15 @@
148160
}
149161
150162
req, err := ParseCommand(text)
151163
if err != nil {
152164
client.Cmd.Notice(nick, fmt.Sprintf("error: %s", err))
153
- client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>]")
165
+ client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>] [format=json|toon]")
154166
return
155167
}
156168
157
- entries, err := b.store.Query(req.Channel, req.Limit)
169
+ entries, err := b.fetchHistory(req)
158170
if err != nil {
159171
client.Cmd.Notice(nick, fmt.Sprintf("error fetching history: %s", err))
160172
return
161173
}
162174
@@ -163,16 +175,64 @@
163175
if len(entries) == 0 {
164176
client.Cmd.Notice(nick, fmt.Sprintf("no history found for %s", req.Channel))
165177
return
166178
}
167179
168
- client.Cmd.Notice(nick, fmt.Sprintf("--- replay %s (%d entries) ---", req.Channel, len(entries)))
169
- for _, e := range entries {
170
- line, _ := json.Marshal(e)
171
- client.Cmd.Notice(nick, string(line))
180
+ if req.Format == "toon" {
181
+ toonEntries := make([]toon.Entry, len(entries))
182
+ for i, e := range entries {
183
+ toonEntries[i] = toon.Entry{
184
+ Nick: e.Nick,
185
+ MessageType: e.MessageType,
186
+ Text: e.Raw,
187
+ At: e.At,
188
+ }
189
+ }
190
+ output := toon.Format(toonEntries, toon.Options{Channel: req.Channel})
191
+ for _, line := range strings.Split(output, "\n") {
192
+ if line != "" {
193
+ client.Cmd.Notice(nick, line)
194
+ }
195
+ }
196
+ } else {
197
+ client.Cmd.Notice(nick, fmt.Sprintf("--- replay %s (%d entries) ---", req.Channel, len(entries)))
198
+ for _, e := range entries {
199
+ line, _ := json.Marshal(e)
200
+ client.Cmd.Notice(nick, string(line))
201
+ }
202
+ client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel))
203
+ }
204
+}
205
+
206
+// fetchHistory tries CHATHISTORY first, falls back to scribe store.
207
+func (b *Bot) fetchHistory(req *replayRequest) ([]scribe.Entry, error) {
208
+ if b.history != nil && b.client != nil {
209
+ hasCH := b.client.HasCapability("chathistory") || b.client.HasCapability("draft/chathistory")
210
+ if hasCH {
211
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
212
+ defer cancel()
213
+ msgs, err := b.history.Latest(ctx, req.Channel, req.Limit)
214
+ if err == nil {
215
+ entries := make([]scribe.Entry, len(msgs))
216
+ for i, m := range msgs {
217
+ entries[i] = scribe.Entry{
218
+ At: m.At,
219
+ Channel: req.Channel,
220
+ Nick: m.Nick,
221
+ Kind: scribe.EntryKindRaw,
222
+ Raw: m.Text,
223
+ }
224
+ if m.Account != "" {
225
+ entries[i].Nick = m.Account
226
+ }
227
+ }
228
+ return entries, nil
229
+ }
230
+ b.log.Warn("chathistory failed, falling back to store", "err", err)
231
+ }
172232
}
173
- client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel))
233
+ return b.store.Query(req.Channel, req.Limit)
174234
}
175235
176236
func (b *Bot) checkRateLimit(nick string) bool {
177237
now := time.Now()
178238
if last, ok := b.rateLimit.Load(nick); ok {
@@ -186,11 +246,12 @@
186246
187247
// ReplayRequest is a parsed replay command.
188248
type replayRequest struct {
189249
Channel string
190250
Limit int
191
- Since int64 // unix ms, 0 = no filter
251
+ Since int64 // unix ms, 0 = no filter
252
+ Format string // "json" (default) or "toon"
192253
}
193254
194255
// ParseCommand parses a replay command string. Exported for testing.
195256
func ParseCommand(text string) (*replayRequest, error) {
196257
parts := strings.Fields(text)
@@ -224,10 +285,17 @@
224285
ts, err := strconv.ParseInt(kv[1], 10, 64)
225286
if err != nil {
226287
return nil, fmt.Errorf("invalid since=%q (must be unix milliseconds)", kv[1])
227288
}
228289
req.Since = ts
290
+ case "format":
291
+ switch strings.ToLower(kv[1]) {
292
+ case "json", "toon":
293
+ req.Format = strings.ToLower(kv[1])
294
+ default:
295
+ return nil, fmt.Errorf("unknown format %q (use json or toon)", kv[1])
296
+ }
229297
default:
230298
return nil, fmt.Errorf("unknown argument %q", kv[0])
231299
}
232300
}
233301
234302
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -22,10 +22,12 @@
22
23 "github.com/lrstanley/girc"
24
25 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
26 "github.com/conflicthq/scuttlebot/internal/bots/scribe"
 
 
27 )
28
29 const (
30 botNick = "scroll"
31 defaultLimit = 50
@@ -39,11 +41,12 @@
39 password string
40 channels []string
41 store scribe.Store
42 log *slog.Logger
43 client *girc.Client
44 rateLimit sync.Map // nick → last request time
 
45 }
46
47 // New creates a scroll Bot backed by the given scribe Store.
48 func New(ircAddr, password string, channels []string, store scribe.Store, log *slog.Logger) *Bot {
49 return &Bot{
@@ -73,17 +76,26 @@
73 Name: "scuttlebot scroll",
74 SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
75 PingDelay: 30 * time.Second,
76 PingTimeout: 30 * time.Second,
77 SSL: false,
 
 
 
 
78 })
 
 
 
79
80 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, e girc.Event) {
 
81 for _, ch := range b.channels {
82 cl.Cmd.Join(ch)
83 }
84 b.log.Info("scroll connected", "channels", b.channels)
 
85 })
86
87 router := cmdparse.NewRouter(botNick)
88 router.Register(cmdparse.Command{
89 Name: "replay",
@@ -148,15 +160,15 @@
148 }
149
150 req, err := ParseCommand(text)
151 if err != nil {
152 client.Cmd.Notice(nick, fmt.Sprintf("error: %s", err))
153 client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>]")
154 return
155 }
156
157 entries, err := b.store.Query(req.Channel, req.Limit)
158 if err != nil {
159 client.Cmd.Notice(nick, fmt.Sprintf("error fetching history: %s", err))
160 return
161 }
162
@@ -163,16 +175,64 @@
163 if len(entries) == 0 {
164 client.Cmd.Notice(nick, fmt.Sprintf("no history found for %s", req.Channel))
165 return
166 }
167
168 client.Cmd.Notice(nick, fmt.Sprintf("--- replay %s (%d entries) ---", req.Channel, len(entries)))
169 for _, e := range entries {
170 line, _ := json.Marshal(e)
171 client.Cmd.Notice(nick, string(line))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172 }
173 client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel))
174 }
175
176 func (b *Bot) checkRateLimit(nick string) bool {
177 now := time.Now()
178 if last, ok := b.rateLimit.Load(nick); ok {
@@ -186,11 +246,12 @@
186
187 // ReplayRequest is a parsed replay command.
188 type replayRequest struct {
189 Channel string
190 Limit int
191 Since int64 // unix ms, 0 = no filter
 
192 }
193
194 // ParseCommand parses a replay command string. Exported for testing.
195 func ParseCommand(text string) (*replayRequest, error) {
196 parts := strings.Fields(text)
@@ -224,10 +285,17 @@
224 ts, err := strconv.ParseInt(kv[1], 10, 64)
225 if err != nil {
226 return nil, fmt.Errorf("invalid since=%q (must be unix milliseconds)", kv[1])
227 }
228 req.Since = ts
 
 
 
 
 
 
 
229 default:
230 return nil, fmt.Errorf("unknown argument %q", kv[0])
231 }
232 }
233
234
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -22,10 +22,12 @@
22
23 "github.com/lrstanley/girc"
24
25 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
26 "github.com/conflicthq/scuttlebot/internal/bots/scribe"
27 "github.com/conflicthq/scuttlebot/pkg/chathistory"
28 "github.com/conflicthq/scuttlebot/pkg/toon"
29 )
30
31 const (
32 botNick = "scroll"
33 defaultLimit = 50
@@ -39,11 +41,12 @@
41 password string
42 channels []string
43 store scribe.Store
44 log *slog.Logger
45 client *girc.Client
46 history *chathistory.Fetcher // nil until connected, if CHATHISTORY is available
47 rateLimit sync.Map // nick → last request time
48 }
49
50 // New creates a scroll Bot backed by the given scribe Store.
51 func New(ircAddr, password string, channels []string, store scribe.Store, log *slog.Logger) *Bot {
52 return &Bot{
@@ -73,17 +76,26 @@
76 Name: "scuttlebot scroll",
77 SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
78 PingDelay: 30 * time.Second,
79 PingTimeout: 30 * time.Second,
80 SSL: false,
81 SupportedCaps: map[string][]string{
82 "draft/chathistory": nil,
83 "chathistory": nil,
84 },
85 })
86
87 // Register CHATHISTORY batch handlers before connecting.
88 b.history = chathistory.New(c)
89
90 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, e girc.Event) {
91 cl.Cmd.Mode(cl.GetNick(), "+B")
92 for _, ch := range b.channels {
93 cl.Cmd.Join(ch)
94 }
95 hasCH := cl.HasCapability("chathistory") || cl.HasCapability("draft/chathistory")
96 b.log.Info("scroll connected", "channels", b.channels, "chathistory", hasCH)
97 })
98
99 router := cmdparse.NewRouter(botNick)
100 router.Register(cmdparse.Command{
101 Name: "replay",
@@ -148,15 +160,15 @@
160 }
161
162 req, err := ParseCommand(text)
163 if err != nil {
164 client.Cmd.Notice(nick, fmt.Sprintf("error: %s", err))
165 client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>] [format=json|toon]")
166 return
167 }
168
169 entries, err := b.fetchHistory(req)
170 if err != nil {
171 client.Cmd.Notice(nick, fmt.Sprintf("error fetching history: %s", err))
172 return
173 }
174
@@ -163,16 +175,64 @@
175 if len(entries) == 0 {
176 client.Cmd.Notice(nick, fmt.Sprintf("no history found for %s", req.Channel))
177 return
178 }
179
180 if req.Format == "toon" {
181 toonEntries := make([]toon.Entry, len(entries))
182 for i, e := range entries {
183 toonEntries[i] = toon.Entry{
184 Nick: e.Nick,
185 MessageType: e.MessageType,
186 Text: e.Raw,
187 At: e.At,
188 }
189 }
190 output := toon.Format(toonEntries, toon.Options{Channel: req.Channel})
191 for _, line := range strings.Split(output, "\n") {
192 if line != "" {
193 client.Cmd.Notice(nick, line)
194 }
195 }
196 } else {
197 client.Cmd.Notice(nick, fmt.Sprintf("--- replay %s (%d entries) ---", req.Channel, len(entries)))
198 for _, e := range entries {
199 line, _ := json.Marshal(e)
200 client.Cmd.Notice(nick, string(line))
201 }
202 client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel))
203 }
204 }
205
206 // fetchHistory tries CHATHISTORY first, falls back to scribe store.
207 func (b *Bot) fetchHistory(req *replayRequest) ([]scribe.Entry, error) {
208 if b.history != nil && b.client != nil {
209 hasCH := b.client.HasCapability("chathistory") || b.client.HasCapability("draft/chathistory")
210 if hasCH {
211 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
212 defer cancel()
213 msgs, err := b.history.Latest(ctx, req.Channel, req.Limit)
214 if err == nil {
215 entries := make([]scribe.Entry, len(msgs))
216 for i, m := range msgs {
217 entries[i] = scribe.Entry{
218 At: m.At,
219 Channel: req.Channel,
220 Nick: m.Nick,
221 Kind: scribe.EntryKindRaw,
222 Raw: m.Text,
223 }
224 if m.Account != "" {
225 entries[i].Nick = m.Account
226 }
227 }
228 return entries, nil
229 }
230 b.log.Warn("chathistory failed, falling back to store", "err", err)
231 }
232 }
233 return b.store.Query(req.Channel, req.Limit)
234 }
235
236 func (b *Bot) checkRateLimit(nick string) bool {
237 now := time.Now()
238 if last, ok := b.rateLimit.Load(nick); ok {
@@ -186,11 +246,12 @@
246
247 // ReplayRequest is a parsed replay command.
248 type replayRequest struct {
249 Channel string
250 Limit int
251 Since int64 // unix ms, 0 = no filter
252 Format string // "json" (default) or "toon"
253 }
254
255 // ParseCommand parses a replay command string. Exported for testing.
256 func ParseCommand(text string) (*replayRequest, error) {
257 parts := strings.Fields(text)
@@ -224,10 +285,17 @@
285 ts, err := strconv.ParseInt(kv[1], 10, 64)
286 if err != nil {
287 return nil, fmt.Errorf("invalid since=%q (must be unix milliseconds)", kv[1])
288 }
289 req.Since = ts
290 case "format":
291 switch strings.ToLower(kv[1]) {
292 case "json", "toon":
293 req.Format = strings.ToLower(kv[1])
294 default:
295 return nil, fmt.Errorf("unknown format %q (use json or toon)", kv[1])
296 }
297 default:
298 return nil, fmt.Errorf("unknown argument %q", kv[0])
299 }
300 }
301
302
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -22,10 +22,12 @@
2222
2323
"github.com/lrstanley/girc"
2424
2525
"github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
2626
"github.com/conflicthq/scuttlebot/internal/bots/scribe"
27
+ "github.com/conflicthq/scuttlebot/pkg/chathistory"
28
+ "github.com/conflicthq/scuttlebot/pkg/toon"
2729
)
2830
2931
const (
3032
botNick = "scroll"
3133
defaultLimit = 50
@@ -39,11 +41,12 @@
3941
password string
4042
channels []string
4143
store scribe.Store
4244
log *slog.Logger
4345
client *girc.Client
44
- rateLimit sync.Map // nick → last request time
46
+ history *chathistory.Fetcher // nil until connected, if CHATHISTORY is available
47
+ rateLimit sync.Map // nick → last request time
4548
}
4649
4750
// New creates a scroll Bot backed by the given scribe Store.
4851
func New(ircAddr, password string, channels []string, store scribe.Store, log *slog.Logger) *Bot {
4952
return &Bot{
@@ -73,17 +76,26 @@
7376
Name: "scuttlebot scroll",
7477
SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
7578
PingDelay: 30 * time.Second,
7679
PingTimeout: 30 * time.Second,
7780
SSL: false,
81
+ SupportedCaps: map[string][]string{
82
+ "draft/chathistory": nil,
83
+ "chathistory": nil,
84
+ },
7885
})
86
+
87
+ // Register CHATHISTORY batch handlers before connecting.
88
+ b.history = chathistory.New(c)
7989
8090
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, e girc.Event) {
91
+ cl.Cmd.Mode(cl.GetNick(), "+B")
8192
for _, ch := range b.channels {
8293
cl.Cmd.Join(ch)
8394
}
84
- b.log.Info("scroll connected", "channels", b.channels)
95
+ hasCH := cl.HasCapability("chathistory") || cl.HasCapability("draft/chathistory")
96
+ b.log.Info("scroll connected", "channels", b.channels, "chathistory", hasCH)
8597
})
8698
8799
router := cmdparse.NewRouter(botNick)
88100
router.Register(cmdparse.Command{
89101
Name: "replay",
@@ -148,15 +160,15 @@
148160
}
149161
150162
req, err := ParseCommand(text)
151163
if err != nil {
152164
client.Cmd.Notice(nick, fmt.Sprintf("error: %s", err))
153
- client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>]")
165
+ client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>] [format=json|toon]")
154166
return
155167
}
156168
157
- entries, err := b.store.Query(req.Channel, req.Limit)
169
+ entries, err := b.fetchHistory(req)
158170
if err != nil {
159171
client.Cmd.Notice(nick, fmt.Sprintf("error fetching history: %s", err))
160172
return
161173
}
162174
@@ -163,16 +175,64 @@
163175
if len(entries) == 0 {
164176
client.Cmd.Notice(nick, fmt.Sprintf("no history found for %s", req.Channel))
165177
return
166178
}
167179
168
- client.Cmd.Notice(nick, fmt.Sprintf("--- replay %s (%d entries) ---", req.Channel, len(entries)))
169
- for _, e := range entries {
170
- line, _ := json.Marshal(e)
171
- client.Cmd.Notice(nick, string(line))
180
+ if req.Format == "toon" {
181
+ toonEntries := make([]toon.Entry, len(entries))
182
+ for i, e := range entries {
183
+ toonEntries[i] = toon.Entry{
184
+ Nick: e.Nick,
185
+ MessageType: e.MessageType,
186
+ Text: e.Raw,
187
+ At: e.At,
188
+ }
189
+ }
190
+ output := toon.Format(toonEntries, toon.Options{Channel: req.Channel})
191
+ for _, line := range strings.Split(output, "\n") {
192
+ if line != "" {
193
+ client.Cmd.Notice(nick, line)
194
+ }
195
+ }
196
+ } else {
197
+ client.Cmd.Notice(nick, fmt.Sprintf("--- replay %s (%d entries) ---", req.Channel, len(entries)))
198
+ for _, e := range entries {
199
+ line, _ := json.Marshal(e)
200
+ client.Cmd.Notice(nick, string(line))
201
+ }
202
+ client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel))
203
+ }
204
+}
205
+
206
+// fetchHistory tries CHATHISTORY first, falls back to scribe store.
207
+func (b *Bot) fetchHistory(req *replayRequest) ([]scribe.Entry, error) {
208
+ if b.history != nil && b.client != nil {
209
+ hasCH := b.client.HasCapability("chathistory") || b.client.HasCapability("draft/chathistory")
210
+ if hasCH {
211
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
212
+ defer cancel()
213
+ msgs, err := b.history.Latest(ctx, req.Channel, req.Limit)
214
+ if err == nil {
215
+ entries := make([]scribe.Entry, len(msgs))
216
+ for i, m := range msgs {
217
+ entries[i] = scribe.Entry{
218
+ At: m.At,
219
+ Channel: req.Channel,
220
+ Nick: m.Nick,
221
+ Kind: scribe.EntryKindRaw,
222
+ Raw: m.Text,
223
+ }
224
+ if m.Account != "" {
225
+ entries[i].Nick = m.Account
226
+ }
227
+ }
228
+ return entries, nil
229
+ }
230
+ b.log.Warn("chathistory failed, falling back to store", "err", err)
231
+ }
172232
}
173
- client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel))
233
+ return b.store.Query(req.Channel, req.Limit)
174234
}
175235
176236
func (b *Bot) checkRateLimit(nick string) bool {
177237
now := time.Now()
178238
if last, ok := b.rateLimit.Load(nick); ok {
@@ -186,11 +246,12 @@
186246
187247
// ReplayRequest is a parsed replay command.
188248
type replayRequest struct {
189249
Channel string
190250
Limit int
191
- Since int64 // unix ms, 0 = no filter
251
+ Since int64 // unix ms, 0 = no filter
252
+ Format string // "json" (default) or "toon"
192253
}
193254
194255
// ParseCommand parses a replay command string. Exported for testing.
195256
func ParseCommand(text string) (*replayRequest, error) {
196257
parts := strings.Fields(text)
@@ -224,10 +285,17 @@
224285
ts, err := strconv.ParseInt(kv[1], 10, 64)
225286
if err != nil {
226287
return nil, fmt.Errorf("invalid since=%q (must be unix milliseconds)", kv[1])
227288
}
228289
req.Since = ts
290
+ case "format":
291
+ switch strings.ToLower(kv[1]) {
292
+ case "json", "toon":
293
+ req.Format = strings.ToLower(kv[1])
294
+ default:
295
+ return nil, fmt.Errorf("unknown format %q (use json or toon)", kv[1])
296
+ }
229297
default:
230298
return nil, fmt.Errorf("unknown argument %q", kv[0])
231299
}
232300
}
233301
234302
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -22,10 +22,12 @@
22
23 "github.com/lrstanley/girc"
24
25 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
26 "github.com/conflicthq/scuttlebot/internal/bots/scribe"
 
 
27 )
28
29 const (
30 botNick = "scroll"
31 defaultLimit = 50
@@ -39,11 +41,12 @@
39 password string
40 channels []string
41 store scribe.Store
42 log *slog.Logger
43 client *girc.Client
44 rateLimit sync.Map // nick → last request time
 
45 }
46
47 // New creates a scroll Bot backed by the given scribe Store.
48 func New(ircAddr, password string, channels []string, store scribe.Store, log *slog.Logger) *Bot {
49 return &Bot{
@@ -73,17 +76,26 @@
73 Name: "scuttlebot scroll",
74 SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
75 PingDelay: 30 * time.Second,
76 PingTimeout: 30 * time.Second,
77 SSL: false,
 
 
 
 
78 })
 
 
 
79
80 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, e girc.Event) {
 
81 for _, ch := range b.channels {
82 cl.Cmd.Join(ch)
83 }
84 b.log.Info("scroll connected", "channels", b.channels)
 
85 })
86
87 router := cmdparse.NewRouter(botNick)
88 router.Register(cmdparse.Command{
89 Name: "replay",
@@ -148,15 +160,15 @@
148 }
149
150 req, err := ParseCommand(text)
151 if err != nil {
152 client.Cmd.Notice(nick, fmt.Sprintf("error: %s", err))
153 client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>]")
154 return
155 }
156
157 entries, err := b.store.Query(req.Channel, req.Limit)
158 if err != nil {
159 client.Cmd.Notice(nick, fmt.Sprintf("error fetching history: %s", err))
160 return
161 }
162
@@ -163,16 +175,64 @@
163 if len(entries) == 0 {
164 client.Cmd.Notice(nick, fmt.Sprintf("no history found for %s", req.Channel))
165 return
166 }
167
168 client.Cmd.Notice(nick, fmt.Sprintf("--- replay %s (%d entries) ---", req.Channel, len(entries)))
169 for _, e := range entries {
170 line, _ := json.Marshal(e)
171 client.Cmd.Notice(nick, string(line))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172 }
173 client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel))
174 }
175
176 func (b *Bot) checkRateLimit(nick string) bool {
177 now := time.Now()
178 if last, ok := b.rateLimit.Load(nick); ok {
@@ -186,11 +246,12 @@
186
187 // ReplayRequest is a parsed replay command.
188 type replayRequest struct {
189 Channel string
190 Limit int
191 Since int64 // unix ms, 0 = no filter
 
192 }
193
194 // ParseCommand parses a replay command string. Exported for testing.
195 func ParseCommand(text string) (*replayRequest, error) {
196 parts := strings.Fields(text)
@@ -224,10 +285,17 @@
224 ts, err := strconv.ParseInt(kv[1], 10, 64)
225 if err != nil {
226 return nil, fmt.Errorf("invalid since=%q (must be unix milliseconds)", kv[1])
227 }
228 req.Since = ts
 
 
 
 
 
 
 
229 default:
230 return nil, fmt.Errorf("unknown argument %q", kv[0])
231 }
232 }
233
234
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -22,10 +22,12 @@
22
23 "github.com/lrstanley/girc"
24
25 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
26 "github.com/conflicthq/scuttlebot/internal/bots/scribe"
27 "github.com/conflicthq/scuttlebot/pkg/chathistory"
28 "github.com/conflicthq/scuttlebot/pkg/toon"
29 )
30
31 const (
32 botNick = "scroll"
33 defaultLimit = 50
@@ -39,11 +41,12 @@
41 password string
42 channels []string
43 store scribe.Store
44 log *slog.Logger
45 client *girc.Client
46 history *chathistory.Fetcher // nil until connected, if CHATHISTORY is available
47 rateLimit sync.Map // nick → last request time
48 }
49
50 // New creates a scroll Bot backed by the given scribe Store.
51 func New(ircAddr, password string, channels []string, store scribe.Store, log *slog.Logger) *Bot {
52 return &Bot{
@@ -73,17 +76,26 @@
76 Name: "scuttlebot scroll",
77 SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
78 PingDelay: 30 * time.Second,
79 PingTimeout: 30 * time.Second,
80 SSL: false,
81 SupportedCaps: map[string][]string{
82 "draft/chathistory": nil,
83 "chathistory": nil,
84 },
85 })
86
87 // Register CHATHISTORY batch handlers before connecting.
88 b.history = chathistory.New(c)
89
90 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, e girc.Event) {
91 cl.Cmd.Mode(cl.GetNick(), "+B")
92 for _, ch := range b.channels {
93 cl.Cmd.Join(ch)
94 }
95 hasCH := cl.HasCapability("chathistory") || cl.HasCapability("draft/chathistory")
96 b.log.Info("scroll connected", "channels", b.channels, "chathistory", hasCH)
97 })
98
99 router := cmdparse.NewRouter(botNick)
100 router.Register(cmdparse.Command{
101 Name: "replay",
@@ -148,15 +160,15 @@
160 }
161
162 req, err := ParseCommand(text)
163 if err != nil {
164 client.Cmd.Notice(nick, fmt.Sprintf("error: %s", err))
165 client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>] [format=json|toon]")
166 return
167 }
168
169 entries, err := b.fetchHistory(req)
170 if err != nil {
171 client.Cmd.Notice(nick, fmt.Sprintf("error fetching history: %s", err))
172 return
173 }
174
@@ -163,16 +175,64 @@
175 if len(entries) == 0 {
176 client.Cmd.Notice(nick, fmt.Sprintf("no history found for %s", req.Channel))
177 return
178 }
179
180 if req.Format == "toon" {
181 toonEntries := make([]toon.Entry, len(entries))
182 for i, e := range entries {
183 toonEntries[i] = toon.Entry{
184 Nick: e.Nick,
185 MessageType: e.MessageType,
186 Text: e.Raw,
187 At: e.At,
188 }
189 }
190 output := toon.Format(toonEntries, toon.Options{Channel: req.Channel})
191 for _, line := range strings.Split(output, "\n") {
192 if line != "" {
193 client.Cmd.Notice(nick, line)
194 }
195 }
196 } else {
197 client.Cmd.Notice(nick, fmt.Sprintf("--- replay %s (%d entries) ---", req.Channel, len(entries)))
198 for _, e := range entries {
199 line, _ := json.Marshal(e)
200 client.Cmd.Notice(nick, string(line))
201 }
202 client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel))
203 }
204 }
205
206 // fetchHistory tries CHATHISTORY first, falls back to scribe store.
207 func (b *Bot) fetchHistory(req *replayRequest) ([]scribe.Entry, error) {
208 if b.history != nil && b.client != nil {
209 hasCH := b.client.HasCapability("chathistory") || b.client.HasCapability("draft/chathistory")
210 if hasCH {
211 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
212 defer cancel()
213 msgs, err := b.history.Latest(ctx, req.Channel, req.Limit)
214 if err == nil {
215 entries := make([]scribe.Entry, len(msgs))
216 for i, m := range msgs {
217 entries[i] = scribe.Entry{
218 At: m.At,
219 Channel: req.Channel,
220 Nick: m.Nick,
221 Kind: scribe.EntryKindRaw,
222 Raw: m.Text,
223 }
224 if m.Account != "" {
225 entries[i].Nick = m.Account
226 }
227 }
228 return entries, nil
229 }
230 b.log.Warn("chathistory failed, falling back to store", "err", err)
231 }
232 }
233 return b.store.Query(req.Channel, req.Limit)
234 }
235
236 func (b *Bot) checkRateLimit(nick string) bool {
237 now := time.Now()
238 if last, ok := b.rateLimit.Load(nick); ok {
@@ -186,11 +246,12 @@
246
247 // ReplayRequest is a parsed replay command.
248 type replayRequest struct {
249 Channel string
250 Limit int
251 Since int64 // unix ms, 0 = no filter
252 Format string // "json" (default) or "toon"
253 }
254
255 // ParseCommand parses a replay command string. Exported for testing.
256 func ParseCommand(text string) (*replayRequest, error) {
257 parts := strings.Fields(text)
@@ -224,10 +285,17 @@
285 ts, err := strconv.ParseInt(kv[1], 10, 64)
286 if err != nil {
287 return nil, fmt.Errorf("invalid since=%q (must be unix milliseconds)", kv[1])
288 }
289 req.Since = ts
290 case "format":
291 switch strings.ToLower(kv[1]) {
292 case "json", "toon":
293 req.Format = strings.ToLower(kv[1])
294 default:
295 return nil, fmt.Errorf("unknown format %q (use json or toon)", kv[1])
296 }
297 default:
298 return nil, fmt.Errorf("unknown argument %q", kv[0])
299 }
300 }
301
302
--- internal/bots/sentinel/sentinel.go
+++ internal/bots/sentinel/sentinel.go
@@ -148,10 +148,11 @@
148148
PingDelay: 30 * time.Second,
149149
PingTimeout: 30 * time.Second,
150150
})
151151
152152
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
153
+ cl.Cmd.Mode(cl.GetNick(), "+B")
153154
for _, ch := range b.cfg.Channels {
154155
cl.Cmd.Join(ch)
155156
}
156157
cl.Cmd.Join(b.cfg.ModChannel)
157158
if b.log != nil {
158159
--- internal/bots/sentinel/sentinel.go
+++ internal/bots/sentinel/sentinel.go
@@ -148,10 +148,11 @@
148 PingDelay: 30 * time.Second,
149 PingTimeout: 30 * time.Second,
150 })
151
152 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
153 for _, ch := range b.cfg.Channels {
154 cl.Cmd.Join(ch)
155 }
156 cl.Cmd.Join(b.cfg.ModChannel)
157 if b.log != nil {
158
--- internal/bots/sentinel/sentinel.go
+++ internal/bots/sentinel/sentinel.go
@@ -148,10 +148,11 @@
148 PingDelay: 30 * time.Second,
149 PingTimeout: 30 * time.Second,
150 })
151
152 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
153 cl.Cmd.Mode(cl.GetNick(), "+B")
154 for _, ch := range b.cfg.Channels {
155 cl.Cmd.Join(ch)
156 }
157 cl.Cmd.Join(b.cfg.ModChannel)
158 if b.log != nil {
159
--- internal/bots/sentinel/sentinel.go
+++ internal/bots/sentinel/sentinel.go
@@ -148,10 +148,11 @@
148148
PingDelay: 30 * time.Second,
149149
PingTimeout: 30 * time.Second,
150150
})
151151
152152
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
153
+ cl.Cmd.Mode(cl.GetNick(), "+B")
153154
for _, ch := range b.cfg.Channels {
154155
cl.Cmd.Join(ch)
155156
}
156157
cl.Cmd.Join(b.cfg.ModChannel)
157158
if b.log != nil {
158159
--- internal/bots/sentinel/sentinel.go
+++ internal/bots/sentinel/sentinel.go
@@ -148,10 +148,11 @@
148 PingDelay: 30 * time.Second,
149 PingTimeout: 30 * time.Second,
150 })
151
152 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
153 for _, ch := range b.cfg.Channels {
154 cl.Cmd.Join(ch)
155 }
156 cl.Cmd.Join(b.cfg.ModChannel)
157 if b.log != nil {
158
--- internal/bots/sentinel/sentinel.go
+++ internal/bots/sentinel/sentinel.go
@@ -148,10 +148,11 @@
148 PingDelay: 30 * time.Second,
149 PingTimeout: 30 * time.Second,
150 })
151
152 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
153 cl.Cmd.Mode(cl.GetNick(), "+B")
154 for _, ch := range b.cfg.Channels {
155 cl.Cmd.Join(ch)
156 }
157 cl.Cmd.Join(b.cfg.ModChannel)
158 if b.log != nil {
159
--- internal/bots/snitch/snitch.go
+++ internal/bots/snitch/snitch.go
@@ -50,10 +50,14 @@
5050
// JoinPartWindow is the rolling window for join/part cycling. Default: 30s.
5151
JoinPartWindow time.Duration
5252
5353
// Channels is the list of channels to join on connect.
5454
Channels []string
55
+
56
+ // MonitorNicks is the list of nicks to track via IRC MONITOR.
57
+ // Snitch will alert when a monitored nick goes offline unexpectedly.
58
+ MonitorNicks []string
5559
}
5660
5761
func (c *Config) setDefaults() {
5862
if c.Nick == "" {
5963
c.Nick = defaultNick
@@ -137,18 +141,45 @@
137141
PingDelay: 30 * time.Second,
138142
PingTimeout: 30 * time.Second,
139143
})
140144
141145
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
146
+ cl.Cmd.Mode(cl.GetNick(), "+B")
142147
for _, ch := range b.cfg.Channels {
143148
cl.Cmd.Join(ch)
144149
}
145150
if b.cfg.AlertChannel != "" {
146151
cl.Cmd.Join(b.cfg.AlertChannel)
147152
}
153
+ if len(b.cfg.MonitorNicks) > 0 {
154
+ cl.Cmd.SendRawf("MONITOR + %s", strings.Join(b.cfg.MonitorNicks, ","))
155
+ }
148156
if b.log != nil {
149
- b.log.Info("snitch connected", "channels", b.cfg.Channels)
157
+ b.log.Info("snitch connected", "channels", b.cfg.Channels, "monitor", b.cfg.MonitorNicks)
158
+ }
159
+ })
160
+
161
+ // away-notify: track agents going idle or returning.
162
+ c.Handlers.AddBg(girc.AWAY, func(_ *girc.Client, e girc.Event) {
163
+ if e.Source == nil {
164
+ return
165
+ }
166
+ nick := e.Source.Name
167
+ reason := e.Last()
168
+ if reason != "" {
169
+ b.alert(fmt.Sprintf("agent away: %s (%s)", nick, reason))
170
+ }
171
+ })
172
+
173
+ c.Handlers.AddBg(girc.RPL_MONOFFLINE, func(_ *girc.Client, e girc.Event) {
174
+ nicks := e.Last()
175
+ for _, nick := range strings.Split(nicks, ",") {
176
+ nick = strings.TrimSpace(nick)
177
+ if nick == "" {
178
+ continue
179
+ }
180
+ b.alert(fmt.Sprintf("monitored nick offline: %s", nick))
150181
}
151182
})
152183
153184
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
154185
if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -223,10 +254,24 @@
223254
func (b *Bot) JoinChannel(channel string) {
224255
if b.client != nil {
225256
b.client.Cmd.Join(channel)
226257
}
227258
}
259
+
260
+// MonitorAdd adds nicks to the MONITOR list at runtime.
261
+func (b *Bot) MonitorAdd(nicks ...string) {
262
+ if b.client != nil && len(nicks) > 0 {
263
+ b.client.Cmd.SendRawf("MONITOR + %s", strings.Join(nicks, ","))
264
+ }
265
+}
266
+
267
+// MonitorRemove removes nicks from the MONITOR list at runtime.
268
+func (b *Bot) MonitorRemove(nicks ...string) {
269
+ if b.client != nil && len(nicks) > 0 {
270
+ b.client.Cmd.SendRawf("MONITOR - %s", strings.Join(nicks, ","))
271
+ }
272
+}
228273
229274
func (b *Bot) window(channel, nick string) *nickWindow {
230275
if b.windows[channel] == nil {
231276
b.windows[channel] = make(map[string]*nickWindow)
232277
}
233278
--- internal/bots/snitch/snitch.go
+++ internal/bots/snitch/snitch.go
@@ -50,10 +50,14 @@
50 // JoinPartWindow is the rolling window for join/part cycling. Default: 30s.
51 JoinPartWindow time.Duration
52
53 // Channels is the list of channels to join on connect.
54 Channels []string
 
 
 
 
55 }
56
57 func (c *Config) setDefaults() {
58 if c.Nick == "" {
59 c.Nick = defaultNick
@@ -137,18 +141,45 @@
137 PingDelay: 30 * time.Second,
138 PingTimeout: 30 * time.Second,
139 })
140
141 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
142 for _, ch := range b.cfg.Channels {
143 cl.Cmd.Join(ch)
144 }
145 if b.cfg.AlertChannel != "" {
146 cl.Cmd.Join(b.cfg.AlertChannel)
147 }
 
 
 
148 if b.log != nil {
149 b.log.Info("snitch connected", "channels", b.cfg.Channels)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150 }
151 })
152
153 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
154 if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -223,10 +254,24 @@
223 func (b *Bot) JoinChannel(channel string) {
224 if b.client != nil {
225 b.client.Cmd.Join(channel)
226 }
227 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
229 func (b *Bot) window(channel, nick string) *nickWindow {
230 if b.windows[channel] == nil {
231 b.windows[channel] = make(map[string]*nickWindow)
232 }
233
--- internal/bots/snitch/snitch.go
+++ internal/bots/snitch/snitch.go
@@ -50,10 +50,14 @@
50 // JoinPartWindow is the rolling window for join/part cycling. Default: 30s.
51 JoinPartWindow time.Duration
52
53 // Channels is the list of channels to join on connect.
54 Channels []string
55
56 // MonitorNicks is the list of nicks to track via IRC MONITOR.
57 // Snitch will alert when a monitored nick goes offline unexpectedly.
58 MonitorNicks []string
59 }
60
61 func (c *Config) setDefaults() {
62 if c.Nick == "" {
63 c.Nick = defaultNick
@@ -137,18 +141,45 @@
141 PingDelay: 30 * time.Second,
142 PingTimeout: 30 * time.Second,
143 })
144
145 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
146 cl.Cmd.Mode(cl.GetNick(), "+B")
147 for _, ch := range b.cfg.Channels {
148 cl.Cmd.Join(ch)
149 }
150 if b.cfg.AlertChannel != "" {
151 cl.Cmd.Join(b.cfg.AlertChannel)
152 }
153 if len(b.cfg.MonitorNicks) > 0 {
154 cl.Cmd.SendRawf("MONITOR + %s", strings.Join(b.cfg.MonitorNicks, ","))
155 }
156 if b.log != nil {
157 b.log.Info("snitch connected", "channels", b.cfg.Channels, "monitor", b.cfg.MonitorNicks)
158 }
159 })
160
161 // away-notify: track agents going idle or returning.
162 c.Handlers.AddBg(girc.AWAY, func(_ *girc.Client, e girc.Event) {
163 if e.Source == nil {
164 return
165 }
166 nick := e.Source.Name
167 reason := e.Last()
168 if reason != "" {
169 b.alert(fmt.Sprintf("agent away: %s (%s)", nick, reason))
170 }
171 })
172
173 c.Handlers.AddBg(girc.RPL_MONOFFLINE, func(_ *girc.Client, e girc.Event) {
174 nicks := e.Last()
175 for _, nick := range strings.Split(nicks, ",") {
176 nick = strings.TrimSpace(nick)
177 if nick == "" {
178 continue
179 }
180 b.alert(fmt.Sprintf("monitored nick offline: %s", nick))
181 }
182 })
183
184 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
185 if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -223,10 +254,24 @@
254 func (b *Bot) JoinChannel(channel string) {
255 if b.client != nil {
256 b.client.Cmd.Join(channel)
257 }
258 }
259
260 // MonitorAdd adds nicks to the MONITOR list at runtime.
261 func (b *Bot) MonitorAdd(nicks ...string) {
262 if b.client != nil && len(nicks) > 0 {
263 b.client.Cmd.SendRawf("MONITOR + %s", strings.Join(nicks, ","))
264 }
265 }
266
267 // MonitorRemove removes nicks from the MONITOR list at runtime.
268 func (b *Bot) MonitorRemove(nicks ...string) {
269 if b.client != nil && len(nicks) > 0 {
270 b.client.Cmd.SendRawf("MONITOR - %s", strings.Join(nicks, ","))
271 }
272 }
273
274 func (b *Bot) window(channel, nick string) *nickWindow {
275 if b.windows[channel] == nil {
276 b.windows[channel] = make(map[string]*nickWindow)
277 }
278
--- internal/bots/snitch/snitch.go
+++ internal/bots/snitch/snitch.go
@@ -50,10 +50,14 @@
5050
// JoinPartWindow is the rolling window for join/part cycling. Default: 30s.
5151
JoinPartWindow time.Duration
5252
5353
// Channels is the list of channels to join on connect.
5454
Channels []string
55
+
56
+ // MonitorNicks is the list of nicks to track via IRC MONITOR.
57
+ // Snitch will alert when a monitored nick goes offline unexpectedly.
58
+ MonitorNicks []string
5559
}
5660
5761
func (c *Config) setDefaults() {
5862
if c.Nick == "" {
5963
c.Nick = defaultNick
@@ -137,18 +141,45 @@
137141
PingDelay: 30 * time.Second,
138142
PingTimeout: 30 * time.Second,
139143
})
140144
141145
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
146
+ cl.Cmd.Mode(cl.GetNick(), "+B")
142147
for _, ch := range b.cfg.Channels {
143148
cl.Cmd.Join(ch)
144149
}
145150
if b.cfg.AlertChannel != "" {
146151
cl.Cmd.Join(b.cfg.AlertChannel)
147152
}
153
+ if len(b.cfg.MonitorNicks) > 0 {
154
+ cl.Cmd.SendRawf("MONITOR + %s", strings.Join(b.cfg.MonitorNicks, ","))
155
+ }
148156
if b.log != nil {
149
- b.log.Info("snitch connected", "channels", b.cfg.Channels)
157
+ b.log.Info("snitch connected", "channels", b.cfg.Channels, "monitor", b.cfg.MonitorNicks)
158
+ }
159
+ })
160
+
161
+ // away-notify: track agents going idle or returning.
162
+ c.Handlers.AddBg(girc.AWAY, func(_ *girc.Client, e girc.Event) {
163
+ if e.Source == nil {
164
+ return
165
+ }
166
+ nick := e.Source.Name
167
+ reason := e.Last()
168
+ if reason != "" {
169
+ b.alert(fmt.Sprintf("agent away: %s (%s)", nick, reason))
170
+ }
171
+ })
172
+
173
+ c.Handlers.AddBg(girc.RPL_MONOFFLINE, func(_ *girc.Client, e girc.Event) {
174
+ nicks := e.Last()
175
+ for _, nick := range strings.Split(nicks, ",") {
176
+ nick = strings.TrimSpace(nick)
177
+ if nick == "" {
178
+ continue
179
+ }
180
+ b.alert(fmt.Sprintf("monitored nick offline: %s", nick))
150181
}
151182
})
152183
153184
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
154185
if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -223,10 +254,24 @@
223254
func (b *Bot) JoinChannel(channel string) {
224255
if b.client != nil {
225256
b.client.Cmd.Join(channel)
226257
}
227258
}
259
+
260
+// MonitorAdd adds nicks to the MONITOR list at runtime.
261
+func (b *Bot) MonitorAdd(nicks ...string) {
262
+ if b.client != nil && len(nicks) > 0 {
263
+ b.client.Cmd.SendRawf("MONITOR + %s", strings.Join(nicks, ","))
264
+ }
265
+}
266
+
267
+// MonitorRemove removes nicks from the MONITOR list at runtime.
268
+func (b *Bot) MonitorRemove(nicks ...string) {
269
+ if b.client != nil && len(nicks) > 0 {
270
+ b.client.Cmd.SendRawf("MONITOR - %s", strings.Join(nicks, ","))
271
+ }
272
+}
228273
229274
func (b *Bot) window(channel, nick string) *nickWindow {
230275
if b.windows[channel] == nil {
231276
b.windows[channel] = make(map[string]*nickWindow)
232277
}
233278
--- internal/bots/snitch/snitch.go
+++ internal/bots/snitch/snitch.go
@@ -50,10 +50,14 @@
50 // JoinPartWindow is the rolling window for join/part cycling. Default: 30s.
51 JoinPartWindow time.Duration
52
53 // Channels is the list of channels to join on connect.
54 Channels []string
 
 
 
 
55 }
56
57 func (c *Config) setDefaults() {
58 if c.Nick == "" {
59 c.Nick = defaultNick
@@ -137,18 +141,45 @@
137 PingDelay: 30 * time.Second,
138 PingTimeout: 30 * time.Second,
139 })
140
141 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
142 for _, ch := range b.cfg.Channels {
143 cl.Cmd.Join(ch)
144 }
145 if b.cfg.AlertChannel != "" {
146 cl.Cmd.Join(b.cfg.AlertChannel)
147 }
 
 
 
148 if b.log != nil {
149 b.log.Info("snitch connected", "channels", b.cfg.Channels)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150 }
151 })
152
153 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
154 if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -223,10 +254,24 @@
223 func (b *Bot) JoinChannel(channel string) {
224 if b.client != nil {
225 b.client.Cmd.Join(channel)
226 }
227 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
229 func (b *Bot) window(channel, nick string) *nickWindow {
230 if b.windows[channel] == nil {
231 b.windows[channel] = make(map[string]*nickWindow)
232 }
233
--- internal/bots/snitch/snitch.go
+++ internal/bots/snitch/snitch.go
@@ -50,10 +50,14 @@
50 // JoinPartWindow is the rolling window for join/part cycling. Default: 30s.
51 JoinPartWindow time.Duration
52
53 // Channels is the list of channels to join on connect.
54 Channels []string
55
56 // MonitorNicks is the list of nicks to track via IRC MONITOR.
57 // Snitch will alert when a monitored nick goes offline unexpectedly.
58 MonitorNicks []string
59 }
60
61 func (c *Config) setDefaults() {
62 if c.Nick == "" {
63 c.Nick = defaultNick
@@ -137,18 +141,45 @@
141 PingDelay: 30 * time.Second,
142 PingTimeout: 30 * time.Second,
143 })
144
145 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
146 cl.Cmd.Mode(cl.GetNick(), "+B")
147 for _, ch := range b.cfg.Channels {
148 cl.Cmd.Join(ch)
149 }
150 if b.cfg.AlertChannel != "" {
151 cl.Cmd.Join(b.cfg.AlertChannel)
152 }
153 if len(b.cfg.MonitorNicks) > 0 {
154 cl.Cmd.SendRawf("MONITOR + %s", strings.Join(b.cfg.MonitorNicks, ","))
155 }
156 if b.log != nil {
157 b.log.Info("snitch connected", "channels", b.cfg.Channels, "monitor", b.cfg.MonitorNicks)
158 }
159 })
160
161 // away-notify: track agents going idle or returning.
162 c.Handlers.AddBg(girc.AWAY, func(_ *girc.Client, e girc.Event) {
163 if e.Source == nil {
164 return
165 }
166 nick := e.Source.Name
167 reason := e.Last()
168 if reason != "" {
169 b.alert(fmt.Sprintf("agent away: %s (%s)", nick, reason))
170 }
171 })
172
173 c.Handlers.AddBg(girc.RPL_MONOFFLINE, func(_ *girc.Client, e girc.Event) {
174 nicks := e.Last()
175 for _, nick := range strings.Split(nicks, ",") {
176 nick = strings.TrimSpace(nick)
177 if nick == "" {
178 continue
179 }
180 b.alert(fmt.Sprintf("monitored nick offline: %s", nick))
181 }
182 })
183
184 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
185 if ch := e.Last(); strings.HasPrefix(ch, "#") {
@@ -223,10 +254,24 @@
254 func (b *Bot) JoinChannel(channel string) {
255 if b.client != nil {
256 b.client.Cmd.Join(channel)
257 }
258 }
259
260 // MonitorAdd adds nicks to the MONITOR list at runtime.
261 func (b *Bot) MonitorAdd(nicks ...string) {
262 if b.client != nil && len(nicks) > 0 {
263 b.client.Cmd.SendRawf("MONITOR + %s", strings.Join(nicks, ","))
264 }
265 }
266
267 // MonitorRemove removes nicks from the MONITOR list at runtime.
268 func (b *Bot) MonitorRemove(nicks ...string) {
269 if b.client != nil && len(nicks) > 0 {
270 b.client.Cmd.SendRawf("MONITOR - %s", strings.Join(nicks, ","))
271 }
272 }
273
274 func (b *Bot) window(channel, nick string) *nickWindow {
275 if b.windows[channel] == nil {
276 b.windows[channel] = make(map[string]*nickWindow)
277 }
278
--- internal/bots/steward/steward.go
+++ internal/bots/steward/steward.go
@@ -132,10 +132,11 @@
132132
PingDelay: 30 * time.Second,
133133
PingTimeout: 30 * time.Second,
134134
})
135135
136136
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
137
+ cl.Cmd.Mode(cl.GetNick(), "+B")
137138
for _, ch := range b.cfg.Channels {
138139
cl.Cmd.Join(ch)
139140
}
140141
cl.Cmd.Join(b.cfg.ModChannel)
141142
if b.log != nil {
@@ -304,12 +305,12 @@
304305
b.log.Info("steward warn", "nick", nick, "channel", channel, "reason", reason)
305306
}
306307
}
307308
308309
func (b *Bot) mute(c *girc.Client, nick, channel string, d time.Duration) {
309
- // +q (quiet) mode — supported by Ergo.
310
- c.Cmd.Mode(channel, "+q", nick)
310
+ // Extended ban m: to mute — agent stays in channel but cannot speak.
311
+ c.Cmd.Mode(channel, "+b", "m:"+nick+"!*@*")
311312
key := channel + ":" + nick
312313
b.mu.Lock()
313314
b.mutes[key] = time.Now().Add(d)
314315
b.mu.Unlock()
315316
b.announce(c, fmt.Sprintf("muted %s in %s for %s", nick, channel, d.Round(time.Second)))
@@ -317,11 +318,11 @@
317318
b.log.Info("steward mute", "nick", nick, "channel", channel, "duration", d)
318319
}
319320
}
320321
321322
func (b *Bot) unmute(c *girc.Client, nick, channel string) {
322
- c.Cmd.Mode(channel, "-q", nick)
323
+ c.Cmd.Mode(channel, "-b", "m:"+nick+"!*@*")
323324
key := channel + ":" + nick
324325
b.mu.Lock()
325326
delete(b.mutes, key)
326327
b.mu.Unlock()
327328
b.announce(c, fmt.Sprintf("unmuted %s in %s", nick, channel))
328329
--- internal/bots/steward/steward.go
+++ internal/bots/steward/steward.go
@@ -132,10 +132,11 @@
132 PingDelay: 30 * time.Second,
133 PingTimeout: 30 * time.Second,
134 })
135
136 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
137 for _, ch := range b.cfg.Channels {
138 cl.Cmd.Join(ch)
139 }
140 cl.Cmd.Join(b.cfg.ModChannel)
141 if b.log != nil {
@@ -304,12 +305,12 @@
304 b.log.Info("steward warn", "nick", nick, "channel", channel, "reason", reason)
305 }
306 }
307
308 func (b *Bot) mute(c *girc.Client, nick, channel string, d time.Duration) {
309 // +q (quiet) mode — supported by Ergo.
310 c.Cmd.Mode(channel, "+q", nick)
311 key := channel + ":" + nick
312 b.mu.Lock()
313 b.mutes[key] = time.Now().Add(d)
314 b.mu.Unlock()
315 b.announce(c, fmt.Sprintf("muted %s in %s for %s", nick, channel, d.Round(time.Second)))
@@ -317,11 +318,11 @@
317 b.log.Info("steward mute", "nick", nick, "channel", channel, "duration", d)
318 }
319 }
320
321 func (b *Bot) unmute(c *girc.Client, nick, channel string) {
322 c.Cmd.Mode(channel, "-q", nick)
323 key := channel + ":" + nick
324 b.mu.Lock()
325 delete(b.mutes, key)
326 b.mu.Unlock()
327 b.announce(c, fmt.Sprintf("unmuted %s in %s", nick, channel))
328
--- internal/bots/steward/steward.go
+++ internal/bots/steward/steward.go
@@ -132,10 +132,11 @@
132 PingDelay: 30 * time.Second,
133 PingTimeout: 30 * time.Second,
134 })
135
136 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
137 cl.Cmd.Mode(cl.GetNick(), "+B")
138 for _, ch := range b.cfg.Channels {
139 cl.Cmd.Join(ch)
140 }
141 cl.Cmd.Join(b.cfg.ModChannel)
142 if b.log != nil {
@@ -304,12 +305,12 @@
305 b.log.Info("steward warn", "nick", nick, "channel", channel, "reason", reason)
306 }
307 }
308
309 func (b *Bot) mute(c *girc.Client, nick, channel string, d time.Duration) {
310 // Extended ban m: to mute — agent stays in channel but cannot speak.
311 c.Cmd.Mode(channel, "+b", "m:"+nick+"!*@*")
312 key := channel + ":" + nick
313 b.mu.Lock()
314 b.mutes[key] = time.Now().Add(d)
315 b.mu.Unlock()
316 b.announce(c, fmt.Sprintf("muted %s in %s for %s", nick, channel, d.Round(time.Second)))
@@ -317,11 +318,11 @@
318 b.log.Info("steward mute", "nick", nick, "channel", channel, "duration", d)
319 }
320 }
321
322 func (b *Bot) unmute(c *girc.Client, nick, channel string) {
323 c.Cmd.Mode(channel, "-b", "m:"+nick+"!*@*")
324 key := channel + ":" + nick
325 b.mu.Lock()
326 delete(b.mutes, key)
327 b.mu.Unlock()
328 b.announce(c, fmt.Sprintf("unmuted %s in %s", nick, channel))
329
--- internal/bots/steward/steward.go
+++ internal/bots/steward/steward.go
@@ -132,10 +132,11 @@
132132
PingDelay: 30 * time.Second,
133133
PingTimeout: 30 * time.Second,
134134
})
135135
136136
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
137
+ cl.Cmd.Mode(cl.GetNick(), "+B")
137138
for _, ch := range b.cfg.Channels {
138139
cl.Cmd.Join(ch)
139140
}
140141
cl.Cmd.Join(b.cfg.ModChannel)
141142
if b.log != nil {
@@ -304,12 +305,12 @@
304305
b.log.Info("steward warn", "nick", nick, "channel", channel, "reason", reason)
305306
}
306307
}
307308
308309
func (b *Bot) mute(c *girc.Client, nick, channel string, d time.Duration) {
309
- // +q (quiet) mode — supported by Ergo.
310
- c.Cmd.Mode(channel, "+q", nick)
310
+ // Extended ban m: to mute — agent stays in channel but cannot speak.
311
+ c.Cmd.Mode(channel, "+b", "m:"+nick+"!*@*")
311312
key := channel + ":" + nick
312313
b.mu.Lock()
313314
b.mutes[key] = time.Now().Add(d)
314315
b.mu.Unlock()
315316
b.announce(c, fmt.Sprintf("muted %s in %s for %s", nick, channel, d.Round(time.Second)))
@@ -317,11 +318,11 @@
317318
b.log.Info("steward mute", "nick", nick, "channel", channel, "duration", d)
318319
}
319320
}
320321
321322
func (b *Bot) unmute(c *girc.Client, nick, channel string) {
322
- c.Cmd.Mode(channel, "-q", nick)
323
+ c.Cmd.Mode(channel, "-b", "m:"+nick+"!*@*")
323324
key := channel + ":" + nick
324325
b.mu.Lock()
325326
delete(b.mutes, key)
326327
b.mu.Unlock()
327328
b.announce(c, fmt.Sprintf("unmuted %s in %s", nick, channel))
328329
--- internal/bots/steward/steward.go
+++ internal/bots/steward/steward.go
@@ -132,10 +132,11 @@
132 PingDelay: 30 * time.Second,
133 PingTimeout: 30 * time.Second,
134 })
135
136 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
137 for _, ch := range b.cfg.Channels {
138 cl.Cmd.Join(ch)
139 }
140 cl.Cmd.Join(b.cfg.ModChannel)
141 if b.log != nil {
@@ -304,12 +305,12 @@
304 b.log.Info("steward warn", "nick", nick, "channel", channel, "reason", reason)
305 }
306 }
307
308 func (b *Bot) mute(c *girc.Client, nick, channel string, d time.Duration) {
309 // +q (quiet) mode — supported by Ergo.
310 c.Cmd.Mode(channel, "+q", nick)
311 key := channel + ":" + nick
312 b.mu.Lock()
313 b.mutes[key] = time.Now().Add(d)
314 b.mu.Unlock()
315 b.announce(c, fmt.Sprintf("muted %s in %s for %s", nick, channel, d.Round(time.Second)))
@@ -317,11 +318,11 @@
317 b.log.Info("steward mute", "nick", nick, "channel", channel, "duration", d)
318 }
319 }
320
321 func (b *Bot) unmute(c *girc.Client, nick, channel string) {
322 c.Cmd.Mode(channel, "-q", nick)
323 key := channel + ":" + nick
324 b.mu.Lock()
325 delete(b.mutes, key)
326 b.mu.Unlock()
327 b.announce(c, fmt.Sprintf("unmuted %s in %s", nick, channel))
328
--- internal/bots/steward/steward.go
+++ internal/bots/steward/steward.go
@@ -132,10 +132,11 @@
132 PingDelay: 30 * time.Second,
133 PingTimeout: 30 * time.Second,
134 })
135
136 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
137 cl.Cmd.Mode(cl.GetNick(), "+B")
138 for _, ch := range b.cfg.Channels {
139 cl.Cmd.Join(ch)
140 }
141 cl.Cmd.Join(b.cfg.ModChannel)
142 if b.log != nil {
@@ -304,12 +305,12 @@
305 b.log.Info("steward warn", "nick", nick, "channel", channel, "reason", reason)
306 }
307 }
308
309 func (b *Bot) mute(c *girc.Client, nick, channel string, d time.Duration) {
310 // Extended ban m: to mute — agent stays in channel but cannot speak.
311 c.Cmd.Mode(channel, "+b", "m:"+nick+"!*@*")
312 key := channel + ":" + nick
313 b.mu.Lock()
314 b.mutes[key] = time.Now().Add(d)
315 b.mu.Unlock()
316 b.announce(c, fmt.Sprintf("muted %s in %s for %s", nick, channel, d.Round(time.Second)))
@@ -317,11 +318,11 @@
318 b.log.Info("steward mute", "nick", nick, "channel", channel, "duration", d)
319 }
320 }
321
322 func (b *Bot) unmute(c *girc.Client, nick, channel string) {
323 c.Cmd.Mode(channel, "-b", "m:"+nick+"!*@*")
324 key := channel + ":" + nick
325 b.mu.Lock()
326 delete(b.mutes, key)
327 b.mu.Unlock()
328 b.announce(c, fmt.Sprintf("unmuted %s in %s", nick, channel))
329
--- internal/bots/systembot/systembot.go
+++ internal/bots/systembot/systembot.go
@@ -93,10 +93,11 @@
9393
PingTimeout: 30 * time.Second,
9494
SSL: false,
9595
})
9696
9797
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
98
+ cl.Cmd.Mode(cl.GetNick(), "+B")
9899
for _, ch := range b.channels {
99100
cl.Cmd.Join(ch)
100101
}
101102
b.log.Info("systembot connected", "channels", b.channels)
102103
})
103104
--- internal/bots/systembot/systembot.go
+++ internal/bots/systembot/systembot.go
@@ -93,10 +93,11 @@
93 PingTimeout: 30 * time.Second,
94 SSL: false,
95 })
96
97 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
98 for _, ch := range b.channels {
99 cl.Cmd.Join(ch)
100 }
101 b.log.Info("systembot connected", "channels", b.channels)
102 })
103
--- internal/bots/systembot/systembot.go
+++ internal/bots/systembot/systembot.go
@@ -93,10 +93,11 @@
93 PingTimeout: 30 * time.Second,
94 SSL: false,
95 })
96
97 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
98 cl.Cmd.Mode(cl.GetNick(), "+B")
99 for _, ch := range b.channels {
100 cl.Cmd.Join(ch)
101 }
102 b.log.Info("systembot connected", "channels", b.channels)
103 })
104
--- internal/bots/systembot/systembot.go
+++ internal/bots/systembot/systembot.go
@@ -93,10 +93,11 @@
9393
PingTimeout: 30 * time.Second,
9494
SSL: false,
9595
})
9696
9797
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
98
+ cl.Cmd.Mode(cl.GetNick(), "+B")
9899
for _, ch := range b.channels {
99100
cl.Cmd.Join(ch)
100101
}
101102
b.log.Info("systembot connected", "channels", b.channels)
102103
})
103104
--- internal/bots/systembot/systembot.go
+++ internal/bots/systembot/systembot.go
@@ -93,10 +93,11 @@
93 PingTimeout: 30 * time.Second,
94 SSL: false,
95 })
96
97 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
98 for _, ch := range b.channels {
99 cl.Cmd.Join(ch)
100 }
101 b.log.Info("systembot connected", "channels", b.channels)
102 })
103
--- internal/bots/systembot/systembot.go
+++ internal/bots/systembot/systembot.go
@@ -93,10 +93,11 @@
93 PingTimeout: 30 * time.Second,
94 SSL: false,
95 })
96
97 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
98 cl.Cmd.Mode(cl.GetNick(), "+B")
99 for _, ch := range b.channels {
100 cl.Cmd.Join(ch)
101 }
102 b.log.Info("systembot connected", "channels", b.channels)
103 })
104
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -200,10 +200,11 @@
200200
PingTimeout: 30 * time.Second,
201201
SSL: false,
202202
})
203203
204204
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
205
+ cl.Cmd.Mode(cl.GetNick(), "+B")
205206
for _, ch := range b.initChannels {
206207
cl.Cmd.Join(ch)
207208
}
208209
for ch := range b.channelConfigs {
209210
cl.Cmd.Join(ch)
@@ -341,11 +342,19 @@
341342
switch action {
342343
case ActionWarn:
343344
cl.Cmd.Notice(nick, fmt.Sprintf("warden: warning — %s in %s", reason, channel))
344345
case ActionMute:
345346
cl.Cmd.Notice(nick, fmt.Sprintf("warden: muted in %s — %s", channel, reason))
346
- cl.Cmd.Mode(channel, "+q", nick)
347
+ // Use extended ban m: to mute — agent stays in channel but cannot speak.
348
+ mask := "m:" + nick + "!*@*"
349
+ cl.Cmd.Mode(channel, "+b", mask)
350
+ // Remove mute after cooldown so the agent can recover.
351
+ cs := b.channelStateFor(channel)
352
+ go func() {
353
+ time.Sleep(cs.cfg.CoolDown)
354
+ cl.Cmd.Mode(channel, "-b", mask)
355
+ }()
347356
case ActionKick:
348357
cl.Cmd.Kick(channel, nick, "warden: "+reason)
349358
}
350359
}
351360
352361
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -200,10 +200,11 @@
200 PingTimeout: 30 * time.Second,
201 SSL: false,
202 })
203
204 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
205 for _, ch := range b.initChannels {
206 cl.Cmd.Join(ch)
207 }
208 for ch := range b.channelConfigs {
209 cl.Cmd.Join(ch)
@@ -341,11 +342,19 @@
341 switch action {
342 case ActionWarn:
343 cl.Cmd.Notice(nick, fmt.Sprintf("warden: warning — %s in %s", reason, channel))
344 case ActionMute:
345 cl.Cmd.Notice(nick, fmt.Sprintf("warden: muted in %s — %s", channel, reason))
346 cl.Cmd.Mode(channel, "+q", nick)
 
 
 
 
 
 
 
 
347 case ActionKick:
348 cl.Cmd.Kick(channel, nick, "warden: "+reason)
349 }
350 }
351
352
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -200,10 +200,11 @@
200 PingTimeout: 30 * time.Second,
201 SSL: false,
202 })
203
204 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
205 cl.Cmd.Mode(cl.GetNick(), "+B")
206 for _, ch := range b.initChannels {
207 cl.Cmd.Join(ch)
208 }
209 for ch := range b.channelConfigs {
210 cl.Cmd.Join(ch)
@@ -341,11 +342,19 @@
342 switch action {
343 case ActionWarn:
344 cl.Cmd.Notice(nick, fmt.Sprintf("warden: warning — %s in %s", reason, channel))
345 case ActionMute:
346 cl.Cmd.Notice(nick, fmt.Sprintf("warden: muted in %s — %s", channel, reason))
347 // Use extended ban m: to mute — agent stays in channel but cannot speak.
348 mask := "m:" + nick + "!*@*"
349 cl.Cmd.Mode(channel, "+b", mask)
350 // Remove mute after cooldown so the agent can recover.
351 cs := b.channelStateFor(channel)
352 go func() {
353 time.Sleep(cs.cfg.CoolDown)
354 cl.Cmd.Mode(channel, "-b", mask)
355 }()
356 case ActionKick:
357 cl.Cmd.Kick(channel, nick, "warden: "+reason)
358 }
359 }
360
361
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -200,10 +200,11 @@
200200
PingTimeout: 30 * time.Second,
201201
SSL: false,
202202
})
203203
204204
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
205
+ cl.Cmd.Mode(cl.GetNick(), "+B")
205206
for _, ch := range b.initChannels {
206207
cl.Cmd.Join(ch)
207208
}
208209
for ch := range b.channelConfigs {
209210
cl.Cmd.Join(ch)
@@ -341,11 +342,19 @@
341342
switch action {
342343
case ActionWarn:
343344
cl.Cmd.Notice(nick, fmt.Sprintf("warden: warning — %s in %s", reason, channel))
344345
case ActionMute:
345346
cl.Cmd.Notice(nick, fmt.Sprintf("warden: muted in %s — %s", channel, reason))
346
- cl.Cmd.Mode(channel, "+q", nick)
347
+ // Use extended ban m: to mute — agent stays in channel but cannot speak.
348
+ mask := "m:" + nick + "!*@*"
349
+ cl.Cmd.Mode(channel, "+b", mask)
350
+ // Remove mute after cooldown so the agent can recover.
351
+ cs := b.channelStateFor(channel)
352
+ go func() {
353
+ time.Sleep(cs.cfg.CoolDown)
354
+ cl.Cmd.Mode(channel, "-b", mask)
355
+ }()
347356
case ActionKick:
348357
cl.Cmd.Kick(channel, nick, "warden: "+reason)
349358
}
350359
}
351360
352361
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -200,10 +200,11 @@
200 PingTimeout: 30 * time.Second,
201 SSL: false,
202 })
203
204 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
 
205 for _, ch := range b.initChannels {
206 cl.Cmd.Join(ch)
207 }
208 for ch := range b.channelConfigs {
209 cl.Cmd.Join(ch)
@@ -341,11 +342,19 @@
341 switch action {
342 case ActionWarn:
343 cl.Cmd.Notice(nick, fmt.Sprintf("warden: warning — %s in %s", reason, channel))
344 case ActionMute:
345 cl.Cmd.Notice(nick, fmt.Sprintf("warden: muted in %s — %s", channel, reason))
346 cl.Cmd.Mode(channel, "+q", nick)
 
 
 
 
 
 
 
 
347 case ActionKick:
348 cl.Cmd.Kick(channel, nick, "warden: "+reason)
349 }
350 }
351
352
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -200,10 +200,11 @@
200 PingTimeout: 30 * time.Second,
201 SSL: false,
202 })
203
204 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
205 cl.Cmd.Mode(cl.GetNick(), "+B")
206 for _, ch := range b.initChannels {
207 cl.Cmd.Join(ch)
208 }
209 for ch := range b.channelConfigs {
210 cl.Cmd.Join(ch)
@@ -341,11 +342,19 @@
342 switch action {
343 case ActionWarn:
344 cl.Cmd.Notice(nick, fmt.Sprintf("warden: warning — %s in %s", reason, channel))
345 case ActionMute:
346 cl.Cmd.Notice(nick, fmt.Sprintf("warden: muted in %s — %s", channel, reason))
347 // Use extended ban m: to mute — agent stays in channel but cannot speak.
348 mask := "m:" + nick + "!*@*"
349 cl.Cmd.Mode(channel, "+b", mask)
350 // Remove mute after cooldown so the agent can recover.
351 cs := b.channelStateFor(channel)
352 go func() {
353 time.Sleep(cs.cfg.CoolDown)
354 cl.Cmd.Mode(channel, "-b", mask)
355 }()
356 case ActionKick:
357 cl.Cmd.Kick(channel, nick, "warden: "+reason)
358 }
359 }
360
361
--- internal/config/config.go
+++ internal/config/config.go
@@ -278,10 +278,17 @@
278278
// Voice is a list of nicks to grant voice (+v) access.
279279
Voice []string `yaml:"voice" json:"voice,omitempty"`
280280
281281
// Autojoin is a list of bot nicks to invite when the channel is provisioned.
282282
Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"`
283
+
284
+ // Modes is a list of channel modes to set after provisioning (e.g. "+m" for moderated).
285
+ Modes []string `yaml:"modes" json:"modes,omitempty"`
286
+
287
+ // OnJoinMessage is sent to agents when they join this channel.
288
+ // Supports template variables: {nick}, {channel}.
289
+ OnJoinMessage string `yaml:"on_join_message" json:"on_join_message,omitempty"`
283290
}
284291
285292
// ChannelTypeConfig defines policy rules for a class of dynamically created channels.
286293
// Matched by prefix against channel names (e.g. prefix "task." matches "#task.gh-42").
287294
type ChannelTypeConfig struct {
@@ -295,17 +302,23 @@
295302
// Autojoin is a list of bot nicks to invite when a channel of this type is created.
296303
Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"`
297304
298305
// Supervision is the coordination channel where summaries should surface.
299306
Supervision string `yaml:"supervision" json:"supervision,omitempty"`
307
+
308
+ // Modes is a list of channel modes to set when provisioning (e.g. "+m" for moderated).
309
+ Modes []string `yaml:"modes" json:"modes,omitempty"`
300310
301311
// Ephemeral marks channels of this type for automatic cleanup.
302312
Ephemeral bool `yaml:"ephemeral" json:"ephemeral,omitempty"`
303313
304314
// TTL is the maximum lifetime of an ephemeral channel with no non-bot members.
305315
// Zero means no TTL; cleanup only occurs when the channel is empty.
306316
TTL Duration `yaml:"ttl" json:"ttl,omitempty"`
317
+
318
+ // OnJoinMessage is sent to agents when they join a channel of this type.
319
+ OnJoinMessage string `yaml:"on_join_message" json:"on_join_message,omitempty"`
307320
}
308321
309322
// Duration wraps time.Duration for YAML/JSON marshalling ("72h", "30m", etc.).
310323
type Duration struct {
311324
time.Duration
312325
--- internal/config/config.go
+++ internal/config/config.go
@@ -278,10 +278,17 @@
278 // Voice is a list of nicks to grant voice (+v) access.
279 Voice []string `yaml:"voice" json:"voice,omitempty"`
280
281 // Autojoin is a list of bot nicks to invite when the channel is provisioned.
282 Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"`
 
 
 
 
 
 
 
283 }
284
285 // ChannelTypeConfig defines policy rules for a class of dynamically created channels.
286 // Matched by prefix against channel names (e.g. prefix "task." matches "#task.gh-42").
287 type ChannelTypeConfig struct {
@@ -295,17 +302,23 @@
295 // Autojoin is a list of bot nicks to invite when a channel of this type is created.
296 Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"`
297
298 // Supervision is the coordination channel where summaries should surface.
299 Supervision string `yaml:"supervision" json:"supervision,omitempty"`
 
 
 
300
301 // Ephemeral marks channels of this type for automatic cleanup.
302 Ephemeral bool `yaml:"ephemeral" json:"ephemeral,omitempty"`
303
304 // TTL is the maximum lifetime of an ephemeral channel with no non-bot members.
305 // Zero means no TTL; cleanup only occurs when the channel is empty.
306 TTL Duration `yaml:"ttl" json:"ttl,omitempty"`
 
 
 
307 }
308
309 // Duration wraps time.Duration for YAML/JSON marshalling ("72h", "30m", etc.).
310 type Duration struct {
311 time.Duration
312
--- internal/config/config.go
+++ internal/config/config.go
@@ -278,10 +278,17 @@
278 // Voice is a list of nicks to grant voice (+v) access.
279 Voice []string `yaml:"voice" json:"voice,omitempty"`
280
281 // Autojoin is a list of bot nicks to invite when the channel is provisioned.
282 Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"`
283
284 // Modes is a list of channel modes to set after provisioning (e.g. "+m" for moderated).
285 Modes []string `yaml:"modes" json:"modes,omitempty"`
286
287 // OnJoinMessage is sent to agents when they join this channel.
288 // Supports template variables: {nick}, {channel}.
289 OnJoinMessage string `yaml:"on_join_message" json:"on_join_message,omitempty"`
290 }
291
292 // ChannelTypeConfig defines policy rules for a class of dynamically created channels.
293 // Matched by prefix against channel names (e.g. prefix "task." matches "#task.gh-42").
294 type ChannelTypeConfig struct {
@@ -295,17 +302,23 @@
302 // Autojoin is a list of bot nicks to invite when a channel of this type is created.
303 Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"`
304
305 // Supervision is the coordination channel where summaries should surface.
306 Supervision string `yaml:"supervision" json:"supervision,omitempty"`
307
308 // Modes is a list of channel modes to set when provisioning (e.g. "+m" for moderated).
309 Modes []string `yaml:"modes" json:"modes,omitempty"`
310
311 // Ephemeral marks channels of this type for automatic cleanup.
312 Ephemeral bool `yaml:"ephemeral" json:"ephemeral,omitempty"`
313
314 // TTL is the maximum lifetime of an ephemeral channel with no non-bot members.
315 // Zero means no TTL; cleanup only occurs when the channel is empty.
316 TTL Duration `yaml:"ttl" json:"ttl,omitempty"`
317
318 // OnJoinMessage is sent to agents when they join a channel of this type.
319 OnJoinMessage string `yaml:"on_join_message" json:"on_join_message,omitempty"`
320 }
321
322 // Duration wraps time.Duration for YAML/JSON marshalling ("72h", "30m", etc.).
323 type Duration struct {
324 time.Duration
325
--- internal/ergo/ircdconfig.go
+++ internal/ergo/ircdconfig.go
@@ -23,11 +23,13 @@
2323
{{- end}}
2424
casemapping: ascii
2525
enforce-utf8: true
2626
max-sendq: 96k
2727
relaymsg:
28
- enabled: false
28
+ enabled: true
29
+ separators: /
30
+ available-to-chanops: false
2931
ip-cloaking:
3032
enabled: false
3133
lookup-hostnames: false
3234
3335
datastore:
3436
--- internal/ergo/ircdconfig.go
+++ internal/ergo/ircdconfig.go
@@ -23,11 +23,13 @@
23 {{- end}}
24 casemapping: ascii
25 enforce-utf8: true
26 max-sendq: 96k
27 relaymsg:
28 enabled: false
 
 
29 ip-cloaking:
30 enabled: false
31 lookup-hostnames: false
32
33 datastore:
34
--- internal/ergo/ircdconfig.go
+++ internal/ergo/ircdconfig.go
@@ -23,11 +23,13 @@
23 {{- end}}
24 casemapping: ascii
25 enforce-utf8: true
26 max-sendq: 96k
27 relaymsg:
28 enabled: true
29 separators: /
30 available-to-chanops: false
31 ip-cloaking:
32 enabled: false
33 lookup-hostnames: false
34
35 datastore:
36
--- internal/ergo/manager.go
+++ internal/ergo/manager.go
@@ -115,10 +115,17 @@
115115
}
116116
wait = min(wait*2, restartMaxWait) //nolint:ineffassign,staticcheck
117117
}
118118
}
119119
}
120
+
121
+// UpdateConfig replaces the Ergo config, regenerates ircd.yaml, and rehashes.
122
+// Use when scuttlebot.yaml Ergo settings change at runtime.
123
+func (m *Manager) UpdateConfig(cfg config.ErgoConfig) error {
124
+ m.cfg = cfg
125
+ return m.Rehash()
126
+}
120127
121128
// Rehash reloads the Ergo config. Call after writing a new ircd.yaml.
122129
func (m *Manager) Rehash() error {
123130
if err := m.writeConfig(); err != nil {
124131
return fmt.Errorf("ergo: write config: %w", err)
125132
--- internal/ergo/manager.go
+++ internal/ergo/manager.go
@@ -115,10 +115,17 @@
115 }
116 wait = min(wait*2, restartMaxWait) //nolint:ineffassign,staticcheck
117 }
118 }
119 }
 
 
 
 
 
 
 
120
121 // Rehash reloads the Ergo config. Call after writing a new ircd.yaml.
122 func (m *Manager) Rehash() error {
123 if err := m.writeConfig(); err != nil {
124 return fmt.Errorf("ergo: write config: %w", err)
125
--- internal/ergo/manager.go
+++ internal/ergo/manager.go
@@ -115,10 +115,17 @@
115 }
116 wait = min(wait*2, restartMaxWait) //nolint:ineffassign,staticcheck
117 }
118 }
119 }
120
121 // UpdateConfig replaces the Ergo config, regenerates ircd.yaml, and rehashes.
122 // Use when scuttlebot.yaml Ergo settings change at runtime.
123 func (m *Manager) UpdateConfig(cfg config.ErgoConfig) error {
124 m.cfg = cfg
125 return m.Rehash()
126 }
127
128 // Rehash reloads the Ergo config. Call after writing a new ircd.yaml.
129 func (m *Manager) Rehash() error {
130 if err := m.writeConfig(); err != nil {
131 return fmt.Errorf("ergo: write config: %w", err)
132
--- internal/mcp/mcp.go
+++ internal/mcp/mcp.go
@@ -52,31 +52,32 @@
5252
type ChannelInfo struct {
5353
Name string `json:"name"`
5454
Topic string `json:"topic,omitempty"`
5555
Count int `json:"count"`
5656
}
57
+
58
+// TokenValidator validates API tokens.
59
+type TokenValidator interface {
60
+ ValidToken(token string) bool
61
+}
5762
5863
// Server is the MCP server.
5964
type Server struct {
6065
registry *registry.Registry
6166
channels ChannelLister
6267
sender Sender // optional — send_message returns error if nil
6368
history HistoryQuerier // optional — get_history returns error if nil
64
- tokens map[string]struct{}
69
+ tokens TokenValidator
6570
log *slog.Logger
6671
}
6772
6873
// New creates an MCP Server.
69
-func New(reg *registry.Registry, channels ChannelLister, tokens []string, log *slog.Logger) *Server {
70
- t := make(map[string]struct{}, len(tokens))
71
- for _, tok := range tokens {
72
- t[tok] = struct{}{}
73
- }
74
+func New(reg *registry.Registry, channels ChannelLister, tokens TokenValidator, log *slog.Logger) *Server {
7475
return &Server{
7576
registry: reg,
7677
channels: channels,
77
- tokens: t,
78
+ tokens: tokens,
7879
log: log,
7980
}
8081
}
8182
8283
// WithSender attaches an IRC relay client for send_message.
@@ -101,11 +102,11 @@
101102
// --- Auth ---
102103
103104
func (s *Server) authMiddleware(next http.Handler) http.Handler {
104105
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
105106
token := bearerToken(r)
106
- if _, ok := s.tokens[token]; !ok {
107
+ if !s.tokens.ValidToken(token) {
107108
writeRPCError(w, nil, -32001, "unauthorized")
108109
return
109110
}
110111
next.ServeHTTP(w, r)
111112
})
112113
--- internal/mcp/mcp.go
+++ internal/mcp/mcp.go
@@ -52,31 +52,32 @@
52 type ChannelInfo struct {
53 Name string `json:"name"`
54 Topic string `json:"topic,omitempty"`
55 Count int `json:"count"`
56 }
 
 
 
 
 
57
58 // Server is the MCP server.
59 type Server struct {
60 registry *registry.Registry
61 channels ChannelLister
62 sender Sender // optional — send_message returns error if nil
63 history HistoryQuerier // optional — get_history returns error if nil
64 tokens map[string]struct{}
65 log *slog.Logger
66 }
67
68 // New creates an MCP Server.
69 func New(reg *registry.Registry, channels ChannelLister, tokens []string, log *slog.Logger) *Server {
70 t := make(map[string]struct{}, len(tokens))
71 for _, tok := range tokens {
72 t[tok] = struct{}{}
73 }
74 return &Server{
75 registry: reg,
76 channels: channels,
77 tokens: t,
78 log: log,
79 }
80 }
81
82 // WithSender attaches an IRC relay client for send_message.
@@ -101,11 +102,11 @@
101 // --- Auth ---
102
103 func (s *Server) authMiddleware(next http.Handler) http.Handler {
104 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
105 token := bearerToken(r)
106 if _, ok := s.tokens[token]; !ok {
107 writeRPCError(w, nil, -32001, "unauthorized")
108 return
109 }
110 next.ServeHTTP(w, r)
111 })
112
--- internal/mcp/mcp.go
+++ internal/mcp/mcp.go
@@ -52,31 +52,32 @@
52 type ChannelInfo struct {
53 Name string `json:"name"`
54 Topic string `json:"topic,omitempty"`
55 Count int `json:"count"`
56 }
57
58 // TokenValidator validates API tokens.
59 type TokenValidator interface {
60 ValidToken(token string) bool
61 }
62
63 // Server is the MCP server.
64 type Server struct {
65 registry *registry.Registry
66 channels ChannelLister
67 sender Sender // optional — send_message returns error if nil
68 history HistoryQuerier // optional — get_history returns error if nil
69 tokens TokenValidator
70 log *slog.Logger
71 }
72
73 // New creates an MCP Server.
74 func New(reg *registry.Registry, channels ChannelLister, tokens TokenValidator, log *slog.Logger) *Server {
 
 
 
 
75 return &Server{
76 registry: reg,
77 channels: channels,
78 tokens: tokens,
79 log: log,
80 }
81 }
82
83 // WithSender attaches an IRC relay client for send_message.
@@ -101,11 +102,11 @@
102 // --- Auth ---
103
104 func (s *Server) authMiddleware(next http.Handler) http.Handler {
105 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
106 token := bearerToken(r)
107 if !s.tokens.ValidToken(token) {
108 writeRPCError(w, nil, -32001, "unauthorized")
109 return
110 }
111 next.ServeHTTP(w, r)
112 })
113
--- internal/mcp/mcp_test.go
+++ internal/mcp/mcp_test.go
@@ -19,10 +19,17 @@
1919
var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
2020
2121
const testToken = "test-mcp-token"
2222
2323
// --- mocks ---
24
+
25
+type tokenSet map[string]struct{}
26
+
27
+func (t tokenSet) ValidToken(tok string) bool {
28
+ _, ok := t[tok]
29
+ return ok
30
+}
2431
2532
type mockProvisioner struct {
2633
mu sync.Mutex
2734
accounts map[string]string
2835
}
@@ -93,11 +100,11 @@
93100
hist := &mockHistory{entries: map[string][]mcp.HistoryEntry{
94101
"#fleet": {
95102
{Nick: "agent-01", MessageType: "task.create", MessageID: "01HX", Raw: `{"v":1}`},
96103
},
97104
}}
98
- srv := mcp.New(reg, channels, []string{testToken}, testLog).
105
+ srv := mcp.New(reg, channels, tokenSet{testToken: {}}, testLog).
99106
WithSender(sender).
100107
WithHistory(hist)
101108
return httptest.NewServer(srv.Handler())
102109
}
103110
104111
--- internal/mcp/mcp_test.go
+++ internal/mcp/mcp_test.go
@@ -19,10 +19,17 @@
19 var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
20
21 const testToken = "test-mcp-token"
22
23 // --- mocks ---
 
 
 
 
 
 
 
24
25 type mockProvisioner struct {
26 mu sync.Mutex
27 accounts map[string]string
28 }
@@ -93,11 +100,11 @@
93 hist := &mockHistory{entries: map[string][]mcp.HistoryEntry{
94 "#fleet": {
95 {Nick: "agent-01", MessageType: "task.create", MessageID: "01HX", Raw: `{"v":1}`},
96 },
97 }}
98 srv := mcp.New(reg, channels, []string{testToken}, testLog).
99 WithSender(sender).
100 WithHistory(hist)
101 return httptest.NewServer(srv.Handler())
102 }
103
104
--- internal/mcp/mcp_test.go
+++ internal/mcp/mcp_test.go
@@ -19,10 +19,17 @@
19 var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
20
21 const testToken = "test-mcp-token"
22
23 // --- mocks ---
24
25 type tokenSet map[string]struct{}
26
27 func (t tokenSet) ValidToken(tok string) bool {
28 _, ok := t[tok]
29 return ok
30 }
31
32 type mockProvisioner struct {
33 mu sync.Mutex
34 accounts map[string]string
35 }
@@ -93,11 +100,11 @@
100 hist := &mockHistory{entries: map[string][]mcp.HistoryEntry{
101 "#fleet": {
102 {Nick: "agent-01", MessageType: "task.create", MessageID: "01HX", Raw: `{"v":1}`},
103 },
104 }}
105 srv := mcp.New(reg, channels, tokenSet{testToken: {}}, testLog).
106 WithSender(sender).
107 WithHistory(hist)
108 return httptest.NewServer(srv.Handler())
109 }
110
111
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -35,10 +35,11 @@
3535
Nick string `json:"nick"`
3636
Type AgentType `json:"type"`
3737
Channels []string `json:"channels"` // convenience: same as Config.Channels
3838
Permissions []string `json:"permissions"` // convenience: same as Config.Permissions
3939
Config EngagementConfig `json:"config"`
40
+ Skills []string `json:"skills,omitempty"` // agent capabilities (e.g. "go", "python", "react")
4041
CreatedAt time.Time `json:"created_at"`
4142
Revoked bool `json:"revoked"`
4243
LastSeen *time.Time `json:"last_seen,omitempty"`
4344
Online bool `json:"online"`
4445
}
@@ -354,10 +355,22 @@
354355
return nil
355356
}
356357
357358
// UpdateChannels replaces the channel list for an active agent.
358359
// Used by relay brokers to sync runtime /join and /part changes back to the registry.
360
+// Update persists changes to an existing agent record.
361
+func (r *Registry) Update(agent *Agent) error {
362
+ r.mu.Lock()
363
+ defer r.mu.Unlock()
364
+ if _, ok := r.agents[agent.Nick]; !ok {
365
+ return fmt.Errorf("registry: agent %q not found", agent.Nick)
366
+ }
367
+ r.agents[agent.Nick] = agent
368
+ r.saveOne(agent)
369
+ return nil
370
+}
371
+
359372
func (r *Registry) UpdateChannels(nick string, channels []string) error {
360373
r.mu.Lock()
361374
defer r.mu.Unlock()
362375
agent, err := r.get(nick)
363376
if err != nil {
364377
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -35,10 +35,11 @@
35 Nick string `json:"nick"`
36 Type AgentType `json:"type"`
37 Channels []string `json:"channels"` // convenience: same as Config.Channels
38 Permissions []string `json:"permissions"` // convenience: same as Config.Permissions
39 Config EngagementConfig `json:"config"`
 
40 CreatedAt time.Time `json:"created_at"`
41 Revoked bool `json:"revoked"`
42 LastSeen *time.Time `json:"last_seen,omitempty"`
43 Online bool `json:"online"`
44 }
@@ -354,10 +355,22 @@
354 return nil
355 }
356
357 // UpdateChannels replaces the channel list for an active agent.
358 // Used by relay brokers to sync runtime /join and /part changes back to the registry.
 
 
 
 
 
 
 
 
 
 
 
 
359 func (r *Registry) UpdateChannels(nick string, channels []string) error {
360 r.mu.Lock()
361 defer r.mu.Unlock()
362 agent, err := r.get(nick)
363 if err != nil {
364
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -35,10 +35,11 @@
35 Nick string `json:"nick"`
36 Type AgentType `json:"type"`
37 Channels []string `json:"channels"` // convenience: same as Config.Channels
38 Permissions []string `json:"permissions"` // convenience: same as Config.Permissions
39 Config EngagementConfig `json:"config"`
40 Skills []string `json:"skills,omitempty"` // agent capabilities (e.g. "go", "python", "react")
41 CreatedAt time.Time `json:"created_at"`
42 Revoked bool `json:"revoked"`
43 LastSeen *time.Time `json:"last_seen,omitempty"`
44 Online bool `json:"online"`
45 }
@@ -354,10 +355,22 @@
355 return nil
356 }
357
358 // UpdateChannels replaces the channel list for an active agent.
359 // Used by relay brokers to sync runtime /join and /part changes back to the registry.
360 // Update persists changes to an existing agent record.
361 func (r *Registry) Update(agent *Agent) error {
362 r.mu.Lock()
363 defer r.mu.Unlock()
364 if _, ok := r.agents[agent.Nick]; !ok {
365 return fmt.Errorf("registry: agent %q not found", agent.Nick)
366 }
367 r.agents[agent.Nick] = agent
368 r.saveOne(agent)
369 return nil
370 }
371
372 func (r *Registry) UpdateChannels(nick string, channels []string) error {
373 r.mu.Lock()
374 defer r.mu.Unlock()
375 agent, err := r.get(nick)
376 if err != nil {
377
--- internal/topology/policy.go
+++ internal/topology/policy.go
@@ -11,10 +11,11 @@
1111
// ChannelType is the resolved policy for a class of channels.
1212
type ChannelType struct {
1313
Name string
1414
Prefix string
1515
Autojoin []string
16
+ Modes []string
1617
Supervision string
1718
Ephemeral bool
1819
TTL time.Duration
1920
}
2021
@@ -61,10 +62,11 @@
6162
for _, t := range cfg.Types {
6263
types = append(types, ChannelType{
6364
Name: t.Name,
6465
Prefix: t.Prefix,
6566
Autojoin: append([]string(nil), t.Autojoin...),
67
+ Modes: append([]string(nil), t.Modes...),
6668
Supervision: t.Supervision,
6769
Ephemeral: t.Ephemeral,
6870
TTL: t.TTL.Duration,
6971
})
7072
}
@@ -133,10 +135,18 @@
133135
if t := p.Match(channel); t != nil {
134136
return t.TTL
135137
}
136138
return 0
137139
}
140
+
141
+// ModesFor returns the channel modes for the given channel, or nil.
142
+func (p *Policy) ModesFor(channel string) []string {
143
+ if t := p.Match(channel); t != nil {
144
+ return append([]string(nil), t.Modes...)
145
+ }
146
+ return nil
147
+}
138148
139149
// StaticChannels returns the list of channels to provision at startup.
140150
func (p *Policy) StaticChannels() []config.StaticChannelConfig {
141151
return append([]config.StaticChannelConfig(nil), p.staticChannels...)
142152
}
143153
--- internal/topology/policy.go
+++ internal/topology/policy.go
@@ -11,10 +11,11 @@
11 // ChannelType is the resolved policy for a class of channels.
12 type ChannelType struct {
13 Name string
14 Prefix string
15 Autojoin []string
 
16 Supervision string
17 Ephemeral bool
18 TTL time.Duration
19 }
20
@@ -61,10 +62,11 @@
61 for _, t := range cfg.Types {
62 types = append(types, ChannelType{
63 Name: t.Name,
64 Prefix: t.Prefix,
65 Autojoin: append([]string(nil), t.Autojoin...),
 
66 Supervision: t.Supervision,
67 Ephemeral: t.Ephemeral,
68 TTL: t.TTL.Duration,
69 })
70 }
@@ -133,10 +135,18 @@
133 if t := p.Match(channel); t != nil {
134 return t.TTL
135 }
136 return 0
137 }
 
 
 
 
 
 
 
 
138
139 // StaticChannels returns the list of channels to provision at startup.
140 func (p *Policy) StaticChannels() []config.StaticChannelConfig {
141 return append([]config.StaticChannelConfig(nil), p.staticChannels...)
142 }
143
--- internal/topology/policy.go
+++ internal/topology/policy.go
@@ -11,10 +11,11 @@
11 // ChannelType is the resolved policy for a class of channels.
12 type ChannelType struct {
13 Name string
14 Prefix string
15 Autojoin []string
16 Modes []string
17 Supervision string
18 Ephemeral bool
19 TTL time.Duration
20 }
21
@@ -61,10 +62,11 @@
62 for _, t := range cfg.Types {
63 types = append(types, ChannelType{
64 Name: t.Name,
65 Prefix: t.Prefix,
66 Autojoin: append([]string(nil), t.Autojoin...),
67 Modes: append([]string(nil), t.Modes...),
68 Supervision: t.Supervision,
69 Ephemeral: t.Ephemeral,
70 TTL: t.TTL.Duration,
71 })
72 }
@@ -133,10 +135,18 @@
135 if t := p.Match(channel); t != nil {
136 return t.TTL
137 }
138 return 0
139 }
140
141 // ModesFor returns the channel modes for the given channel, or nil.
142 func (p *Policy) ModesFor(channel string) []string {
143 if t := p.Match(channel); t != nil {
144 return append([]string(nil), t.Modes...)
145 }
146 return nil
147 }
148
149 // StaticChannels returns the list of channels to provision at startup.
150 func (p *Policy) StaticChannels() []config.StaticChannelConfig {
151 return append([]config.StaticChannelConfig(nil), p.staticChannels...)
152 }
153
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -24,18 +24,24 @@
2424
Name string
2525
2626
// Topic is the initial channel topic (shared state header).
2727
Topic string
2828
29
- // Ops is a list of nicks to grant +o (channel operator) status.
29
+ // Ops is a list of nicks to grant +o (channel operator) status via AMODE.
3030
Ops []string
3131
32
- // Voice is a list of nicks to grant +v status.
32
+ // Voice is a list of nicks to grant +v status via AMODE.
3333
Voice []string
3434
3535
// Autojoin is a list of bot nicks to invite after provisioning.
3636
Autojoin []string
37
+
38
+ // Modes is a list of channel modes to set (e.g. "+m" for moderated).
39
+ Modes []string
40
+
41
+ // OnJoinMessage is sent to agents when they join this channel.
42
+ OnJoinMessage string
3743
}
3844
3945
// channelRecord tracks a provisioned channel for TTL-based reaping.
4046
type channelRecord struct {
4147
name string
@@ -207,15 +213,21 @@
207213
208214
if ch.Topic != "" {
209215
m.chanserv("TOPIC %s %s", ch.Name, ch.Topic)
210216
}
211217
218
+ // Use AMODE for persistent auto-mode on join (survives reconnects).
212219
for _, nick := range ch.Ops {
213
- m.chanserv("ACCESS %s ADD %s OP", ch.Name, nick)
220
+ m.chanserv("AMODE %s +o %s", ch.Name, nick)
214221
}
215222
for _, nick := range ch.Voice {
216
- m.chanserv("ACCESS %s ADD %s VOICE", ch.Name, nick)
223
+ m.chanserv("AMODE %s +v %s", ch.Name, nick)
224
+ }
225
+
226
+ // Apply channel modes (e.g. +m for moderated).
227
+ for _, mode := range ch.Modes {
228
+ m.client.Cmd.Mode(ch.Name, mode)
217229
}
218230
219231
if len(ch.Autojoin) > 0 {
220232
m.Invite(ch.Name, ch.Autojoin)
221233
}
@@ -274,33 +286,75 @@
274286
m.log.Info("reaping expired ephemeral channel", "channel", rec.name, "age", now.Sub(rec.provisionedAt).Round(time.Minute))
275287
m.DropChannel(rec.name)
276288
}
277289
}
278290
279
-// GrantAccess sets a ChanServ ACCESS entry for nick on the given channel.
280
-// level is "OP" or "VOICE". If level is empty, no access is granted.
291
+// GrantAccess sets a ChanServ AMODE entry for nick on the given channel.
292
+// level is "OP" or "VOICE". AMODE persists across reconnects — ChanServ
293
+// automatically applies the mode every time the nick joins.
281294
func (m *Manager) GrantAccess(nick, channel, level string) {
282295
if m.client == nil || level == "" {
283296
return
284297
}
285
- m.chanserv("ACCESS %s ADD %s %s", channel, nick, level)
286
- m.log.Info("granted channel access", "nick", nick, "channel", channel, "level", level)
298
+ switch strings.ToUpper(level) {
299
+ case "OP":
300
+ m.chanserv("AMODE %s +o %s", channel, nick)
301
+ case "VOICE":
302
+ m.chanserv("AMODE %s +v %s", channel, nick)
303
+ default:
304
+ m.log.Warn("unknown access level", "level", level)
305
+ return
306
+ }
307
+ m.log.Info("granted channel access (AMODE)", "nick", nick, "channel", channel, "level", level)
287308
}
288309
289
-// RevokeAccess removes a ChanServ ACCESS entry for nick on the given channel.
310
+// RevokeAccess removes ChanServ AMODE entries for nick on the given channel.
290311
func (m *Manager) RevokeAccess(nick, channel string) {
291312
if m.client == nil {
292313
return
293314
}
294
- m.chanserv("ACCESS %s DEL %s", channel, nick)
295
- m.log.Info("revoked channel access", "nick", nick, "channel", channel)
315
+ m.chanserv("AMODE %s -o %s", channel, nick)
316
+ m.chanserv("AMODE %s -v %s", channel, nick)
317
+ m.log.Info("revoked channel access (AMODE)", "nick", nick, "channel", channel)
296318
}
297319
298320
func (m *Manager) chanserv(format string, args ...any) {
299321
msg := fmt.Sprintf(format, args...)
300322
m.client.Cmd.Message("ChanServ", msg)
301323
}
324
+
325
+// ChannelInfo describes an active provisioned channel.
326
+type ChannelInfo struct {
327
+ Name string `json:"name"`
328
+ ProvisionedAt time.Time `json:"provisioned_at"`
329
+ Type string `json:"type,omitempty"`
330
+ Ephemeral bool `json:"ephemeral,omitempty"`
331
+ TTLSeconds int64 `json:"ttl_seconds,omitempty"`
332
+}
333
+
334
+// ListChannels returns all actively provisioned channels.
335
+func (m *Manager) ListChannels() []ChannelInfo {
336
+ m.mu.Lock()
337
+ defer m.mu.Unlock()
338
+ out := make([]ChannelInfo, 0, len(m.channels))
339
+ for _, rec := range m.channels {
340
+ ci := ChannelInfo{
341
+ Name: rec.name,
342
+ ProvisionedAt: rec.provisionedAt,
343
+ }
344
+ if m.policy != nil {
345
+ ci.Type = m.policy.TypeName(rec.name)
346
+ ci.Ephemeral = m.policy.IsEphemeral(rec.name)
347
+ ttl := m.policy.TTLFor(rec.name)
348
+ if ttl > 0 {
349
+ ci.TTLSeconds = int64(ttl.Seconds())
350
+ }
351
+ }
352
+ out = append(out, ci)
353
+ }
354
+ return out
355
+}
302356
303357
// ValidateName checks that a channel name follows scuttlebot conventions.
304358
func ValidateName(name string) error {
305359
if !strings.HasPrefix(name, "#") {
306360
return fmt.Errorf("topology: channel name must start with #: %q", name)
307361
308362
ADDED pkg/chathistory/chathistory.go
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -24,18 +24,24 @@
24 Name string
25
26 // Topic is the initial channel topic (shared state header).
27 Topic string
28
29 // Ops is a list of nicks to grant +o (channel operator) status.
30 Ops []string
31
32 // Voice is a list of nicks to grant +v status.
33 Voice []string
34
35 // Autojoin is a list of bot nicks to invite after provisioning.
36 Autojoin []string
 
 
 
 
 
 
37 }
38
39 // channelRecord tracks a provisioned channel for TTL-based reaping.
40 type channelRecord struct {
41 name string
@@ -207,15 +213,21 @@
207
208 if ch.Topic != "" {
209 m.chanserv("TOPIC %s %s", ch.Name, ch.Topic)
210 }
211
 
212 for _, nick := range ch.Ops {
213 m.chanserv("ACCESS %s ADD %s OP", ch.Name, nick)
214 }
215 for _, nick := range ch.Voice {
216 m.chanserv("ACCESS %s ADD %s VOICE", ch.Name, nick)
 
 
 
 
 
217 }
218
219 if len(ch.Autojoin) > 0 {
220 m.Invite(ch.Name, ch.Autojoin)
221 }
@@ -274,33 +286,75 @@
274 m.log.Info("reaping expired ephemeral channel", "channel", rec.name, "age", now.Sub(rec.provisionedAt).Round(time.Minute))
275 m.DropChannel(rec.name)
276 }
277 }
278
279 // GrantAccess sets a ChanServ ACCESS entry for nick on the given channel.
280 // level is "OP" or "VOICE". If level is empty, no access is granted.
 
281 func (m *Manager) GrantAccess(nick, channel, level string) {
282 if m.client == nil || level == "" {
283 return
284 }
285 m.chanserv("ACCESS %s ADD %s %s", channel, nick, level)
286 m.log.Info("granted channel access", "nick", nick, "channel", channel, "level", level)
 
 
 
 
 
 
 
 
287 }
288
289 // RevokeAccess removes a ChanServ ACCESS entry for nick on the given channel.
290 func (m *Manager) RevokeAccess(nick, channel string) {
291 if m.client == nil {
292 return
293 }
294 m.chanserv("ACCESS %s DEL %s", channel, nick)
295 m.log.Info("revoked channel access", "nick", nick, "channel", channel)
 
296 }
297
298 func (m *Manager) chanserv(format string, args ...any) {
299 msg := fmt.Sprintf(format, args...)
300 m.client.Cmd.Message("ChanServ", msg)
301 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
303 // ValidateName checks that a channel name follows scuttlebot conventions.
304 func ValidateName(name string) error {
305 if !strings.HasPrefix(name, "#") {
306 return fmt.Errorf("topology: channel name must start with #: %q", name)
307
308 DDED pkg/chathistory/chathistory.go
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -24,18 +24,24 @@
24 Name string
25
26 // Topic is the initial channel topic (shared state header).
27 Topic string
28
29 // Ops is a list of nicks to grant +o (channel operator) status via AMODE.
30 Ops []string
31
32 // Voice is a list of nicks to grant +v status via AMODE.
33 Voice []string
34
35 // Autojoin is a list of bot nicks to invite after provisioning.
36 Autojoin []string
37
38 // Modes is a list of channel modes to set (e.g. "+m" for moderated).
39 Modes []string
40
41 // OnJoinMessage is sent to agents when they join this channel.
42 OnJoinMessage string
43 }
44
45 // channelRecord tracks a provisioned channel for TTL-based reaping.
46 type channelRecord struct {
47 name string
@@ -207,15 +213,21 @@
213
214 if ch.Topic != "" {
215 m.chanserv("TOPIC %s %s", ch.Name, ch.Topic)
216 }
217
218 // Use AMODE for persistent auto-mode on join (survives reconnects).
219 for _, nick := range ch.Ops {
220 m.chanserv("AMODE %s +o %s", ch.Name, nick)
221 }
222 for _, nick := range ch.Voice {
223 m.chanserv("AMODE %s +v %s", ch.Name, nick)
224 }
225
226 // Apply channel modes (e.g. +m for moderated).
227 for _, mode := range ch.Modes {
228 m.client.Cmd.Mode(ch.Name, mode)
229 }
230
231 if len(ch.Autojoin) > 0 {
232 m.Invite(ch.Name, ch.Autojoin)
233 }
@@ -274,33 +286,75 @@
286 m.log.Info("reaping expired ephemeral channel", "channel", rec.name, "age", now.Sub(rec.provisionedAt).Round(time.Minute))
287 m.DropChannel(rec.name)
288 }
289 }
290
291 // GrantAccess sets a ChanServ AMODE entry for nick on the given channel.
292 // level is "OP" or "VOICE". AMODE persists across reconnects — ChanServ
293 // automatically applies the mode every time the nick joins.
294 func (m *Manager) GrantAccess(nick, channel, level string) {
295 if m.client == nil || level == "" {
296 return
297 }
298 switch strings.ToUpper(level) {
299 case "OP":
300 m.chanserv("AMODE %s +o %s", channel, nick)
301 case "VOICE":
302 m.chanserv("AMODE %s +v %s", channel, nick)
303 default:
304 m.log.Warn("unknown access level", "level", level)
305 return
306 }
307 m.log.Info("granted channel access (AMODE)", "nick", nick, "channel", channel, "level", level)
308 }
309
310 // RevokeAccess removes ChanServ AMODE entries for nick on the given channel.
311 func (m *Manager) RevokeAccess(nick, channel string) {
312 if m.client == nil {
313 return
314 }
315 m.chanserv("AMODE %s -o %s", channel, nick)
316 m.chanserv("AMODE %s -v %s", channel, nick)
317 m.log.Info("revoked channel access (AMODE)", "nick", nick, "channel", channel)
318 }
319
320 func (m *Manager) chanserv(format string, args ...any) {
321 msg := fmt.Sprintf(format, args...)
322 m.client.Cmd.Message("ChanServ", msg)
323 }
324
325 // ChannelInfo describes an active provisioned channel.
326 type ChannelInfo struct {
327 Name string `json:"name"`
328 ProvisionedAt time.Time `json:"provisioned_at"`
329 Type string `json:"type,omitempty"`
330 Ephemeral bool `json:"ephemeral,omitempty"`
331 TTLSeconds int64 `json:"ttl_seconds,omitempty"`
332 }
333
334 // ListChannels returns all actively provisioned channels.
335 func (m *Manager) ListChannels() []ChannelInfo {
336 m.mu.Lock()
337 defer m.mu.Unlock()
338 out := make([]ChannelInfo, 0, len(m.channels))
339 for _, rec := range m.channels {
340 ci := ChannelInfo{
341 Name: rec.name,
342 ProvisionedAt: rec.provisionedAt,
343 }
344 if m.policy != nil {
345 ci.Type = m.policy.TypeName(rec.name)
346 ci.Ephemeral = m.policy.IsEphemeral(rec.name)
347 ttl := m.policy.TTLFor(rec.name)
348 if ttl > 0 {
349 ci.TTLSeconds = int64(ttl.Seconds())
350 }
351 }
352 out = append(out, ci)
353 }
354 return out
355 }
356
357 // ValidateName checks that a channel name follows scuttlebot conventions.
358 func ValidateName(name string) error {
359 if !strings.HasPrefix(name, "#") {
360 return fmt.Errorf("topology: channel name must start with #: %q", name)
361
362 DDED pkg/chathistory/chathistory.go
--- a/pkg/chathistory/chathistory.go
+++ b/pkg/chathistory/chathistory.go
@@ -0,0 +1,183 @@
1
+// Package chathistory provides a synchronous wrapper around the IRCv3
2
+// CHATHISTORY extension for use with girc clients.
3
+//
4
+// Usage:
5
+//
6
+// fetcher := chathistory.New(client)
7
+// msgs, err := fetcher.Latest(ctx, "#channel", 50)
8
+package chathistory
9
+
10
+import (
11
+ "context"
12
+ "fmt"
13
+ "strings"
14
+ "sync"
15
+ "time"
16
+
17
+ "github.com/lrstanley/girc"
18
+)
19
+
20
+// Message is a single message returned by a CHATHISTORY query.
21
+type Message struct {
22
+ At time.Time
23
+ Nick string
24
+ Account string
25
+ Text string
26
+ MsgID string
27
+}
28
+
29
+// Fetcher sends CHATHISTORY commands and collects the batched responses.
30
+type Fetcher struct {
31
+ client *girc.Client
32
+
33
+ mu sync.Mutex
34
+ batches map[string]*batch // batchRef → accumulator
35
+ waiters map[string]chan []Message // channel → result (one waiter per channel)
36
+ handlers bool
37
+}
38
+
39
+type batch struct {
40
+ channel string
41
+ msgs []Message
42
+}
43
+
44
+// New creates a Fetcher and registers the necessary BATCH handlers on the
45
+// client. The client's Config.SupportedCaps should include
46
+// "draft/chathistory" (or "chathistory") so the capability is negotiated.
47
+func New(client *girc.Client) *Fetcher {
48
+ f := &Fetcher{
49
+ client: client,
50
+ batches: make(map[string]*batch),
51
+ waiters: make(map[string]chan []Message),
52
+ }
53
+ f.registerHandlers()
54
+ return f
55
+}
56
+
57
+func (f *Fetcher) registerHandlers() {
58
+ f.mu.Lock()
59
+ defer f.mu.Unlock()
60
+ if f.handlers {
61
+ return
62
+ }
63
+ f.handlers = true
64
+
65
+ // BATCH open/close.
66
+ f.client.Handlers.AddBg("BATCH", func(_ *girc.Client, e girc.Event) {
67
+ if len(e.Params) < 1 {
68
+ return
69
+ }
70
+ raw := e.Params[0]
71
+ if strings.HasPrefix(raw, "+") {
72
+ ref := raw[1:]
73
+ if len(e.Params) >= 2 && e.Params[1] == "chathistory" {
74
+ ch := ""
75
+ if len(e.Params) >= 3 {
76
+ ch = e.Params[2]
77
+ }
78
+ f.mu.Lock()
79
+ f.batches[ref] = &batch{channel: ch}
80
+ f.mu.Unlock()
81
+ }
82
+ } else if strings.HasPrefix(raw, "-") {
83
+ ref := raw[1:]
84
+ f.mu.Lock()
85
+ b, ok := f.batches[ref]
86
+ if ok {
87
+ delete(f.batches, ref)
88
+ if w, wok := f.waiters[b.channel]; wok {
89
+ delete(f.waiters, b.channel)
90
+ f.mu.Unlock()
91
+ w <- b.msgs
92
+ return
93
+ }
94
+ }
95
+ f.mu.Unlock()
96
+ }
97
+ })
98
+
99
+ // Collect PRIVMSGs tagged with a tracked batch ref.
100
+ f.client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
101
+ batchRef, ok := e.Tags.Get("batch")
102
+ if !ok || batchRef == "" {
103
+ return
104
+ }
105
+
106
+ f.mu.Lock()
107
+ b, tracked := f.batches[batchRef]
108
+ if !tracked {
109
+ f.mu.Unlock()
110
+ return
111
+ }
112
+
113
+ nick := ""
114
+ if e.Source != nil {
115
+ nick = e.Source.Name
116
+ }
117
+ acct, _ := e.Tags.Get("account")
118
+ msgID, _ := e.Tags.Get("msgid")
119
+
120
+ b.msgs = append(b.msgs, Message{
121
+ At: e.Timestamp,
122
+ Nick: nick,
123
+ Account: acct,
124
+ Text: e.Last(),
125
+ MsgID: msgID,
126
+ })
127
+ f.mu.Unlock()
128
+ })
129
+}
130
+
131
+// Latest fetches the N most recent messages from a channel using
132
+// CHATHISTORY LATEST. Blocks until the server responds or ctx expires.
133
+func (f *Fetcher) Latest(ctx context.Context, channel string, count int) ([]Message, error) {
134
+ result := make(chan []Message, 1)
135
+
136
+ f.mu.Lock()
137
+ f.waiters[channel] = result
138
+ f.mu.Unlock()
139
+
140
+ if err := f.client.Cmd.SendRawf("CHATHISTORY LATEST %s * %d", channel, count); err != nil {
141
+ f.mu.Lock()
142
+ delete(f.waiters, channel)
143
+ f.mu.Unlock()
144
+ return nil, fmt.Errorf("chathistory: send: %w", err)
145
+ }
146
+
147
+ select {
148
+ case msgs := <-result:
149
+ return msgs, nil
150
+ case <-ctx.Done():
151
+ f.mu.Lock()
152
+ delete(f.waiters, channel)
153
+ f.mu.Unlock()
154
+ return nil, ctx.Err()
155
+ }
156
+}
157
+
158
+// Before fetches up to count messages before the given timestamp.
159
+func (f *Fetcher) Before(ctx context.Context, channel string, before time.Time, count int) ([]Message, error) {
160
+ result := make(chan []Message, 1)
161
+
162
+ f.mu.Lock()
163
+ f.waiters[channel] = result
164
+ f.mu.Unlock()
165
+
166
+ ts := before.UTC().Format("2006-01-02T15:04:05.000Z")
167
+ if err := f.client.Cmd.SendRawf("CHATHISTORY BEFORE %s timestamp=%s %d", channel, ts, count); err != nil {
168
+ f.mu.Lock()
169
+ delete(f.waiters, channel)
170
+ f.mu.Unlock()
171
+ return nil, fmt.Errorf("chathistory: send: %w", err)
172
+ }
173
+
174
+ select {
175
+ case msgs := <-result:
176
+ return msgs, nil
177
+ case <-ctx.Done():
178
+ f.mu.Lock()
179
+ delete(f.waiters, channel)
180
+ f.mu.Unlock()
181
+ return nil, ctx.Err()
182
+ }
183
+}
--- a/pkg/chathistory/chathistory.go
+++ b/pkg/chathistory/chathistory.go
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pkg/chathistory/chathistory.go
+++ b/pkg/chathistory/chathistory.go
@@ -0,0 +1,183 @@
1 // Package chathistory provides a synchronous wrapper around the IRCv3
2 // CHATHISTORY extension for use with girc clients.
3 //
4 // Usage:
5 //
6 // fetcher := chathistory.New(client)
7 // msgs, err := fetcher.Latest(ctx, "#channel", 50)
8 package chathistory
9
10 import (
11 "context"
12 "fmt"
13 "strings"
14 "sync"
15 "time"
16
17 "github.com/lrstanley/girc"
18 )
19
20 // Message is a single message returned by a CHATHISTORY query.
21 type Message struct {
22 At time.Time
23 Nick string
24 Account string
25 Text string
26 MsgID string
27 }
28
29 // Fetcher sends CHATHISTORY commands and collects the batched responses.
30 type Fetcher struct {
31 client *girc.Client
32
33 mu sync.Mutex
34 batches map[string]*batch // batchRef → accumulator
35 waiters map[string]chan []Message // channel → result (one waiter per channel)
36 handlers bool
37 }
38
39 type batch struct {
40 channel string
41 msgs []Message
42 }
43
44 // New creates a Fetcher and registers the necessary BATCH handlers on the
45 // client. The client's Config.SupportedCaps should include
46 // "draft/chathistory" (or "chathistory") so the capability is negotiated.
47 func New(client *girc.Client) *Fetcher {
48 f := &Fetcher{
49 client: client,
50 batches: make(map[string]*batch),
51 waiters: make(map[string]chan []Message),
52 }
53 f.registerHandlers()
54 return f
55 }
56
57 func (f *Fetcher) registerHandlers() {
58 f.mu.Lock()
59 defer f.mu.Unlock()
60 if f.handlers {
61 return
62 }
63 f.handlers = true
64
65 // BATCH open/close.
66 f.client.Handlers.AddBg("BATCH", func(_ *girc.Client, e girc.Event) {
67 if len(e.Params) < 1 {
68 return
69 }
70 raw := e.Params[0]
71 if strings.HasPrefix(raw, "+") {
72 ref := raw[1:]
73 if len(e.Params) >= 2 && e.Params[1] == "chathistory" {
74 ch := ""
75 if len(e.Params) >= 3 {
76 ch = e.Params[2]
77 }
78 f.mu.Lock()
79 f.batches[ref] = &batch{channel: ch}
80 f.mu.Unlock()
81 }
82 } else if strings.HasPrefix(raw, "-") {
83 ref := raw[1:]
84 f.mu.Lock()
85 b, ok := f.batches[ref]
86 if ok {
87 delete(f.batches, ref)
88 if w, wok := f.waiters[b.channel]; wok {
89 delete(f.waiters, b.channel)
90 f.mu.Unlock()
91 w <- b.msgs
92 return
93 }
94 }
95 f.mu.Unlock()
96 }
97 })
98
99 // Collect PRIVMSGs tagged with a tracked batch ref.
100 f.client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
101 batchRef, ok := e.Tags.Get("batch")
102 if !ok || batchRef == "" {
103 return
104 }
105
106 f.mu.Lock()
107 b, tracked := f.batches[batchRef]
108 if !tracked {
109 f.mu.Unlock()
110 return
111 }
112
113 nick := ""
114 if e.Source != nil {
115 nick = e.Source.Name
116 }
117 acct, _ := e.Tags.Get("account")
118 msgID, _ := e.Tags.Get("msgid")
119
120 b.msgs = append(b.msgs, Message{
121 At: e.Timestamp,
122 Nick: nick,
123 Account: acct,
124 Text: e.Last(),
125 MsgID: msgID,
126 })
127 f.mu.Unlock()
128 })
129 }
130
131 // Latest fetches the N most recent messages from a channel using
132 // CHATHISTORY LATEST. Blocks until the server responds or ctx expires.
133 func (f *Fetcher) Latest(ctx context.Context, channel string, count int) ([]Message, error) {
134 result := make(chan []Message, 1)
135
136 f.mu.Lock()
137 f.waiters[channel] = result
138 f.mu.Unlock()
139
140 if err := f.client.Cmd.SendRawf("CHATHISTORY LATEST %s * %d", channel, count); err != nil {
141 f.mu.Lock()
142 delete(f.waiters, channel)
143 f.mu.Unlock()
144 return nil, fmt.Errorf("chathistory: send: %w", err)
145 }
146
147 select {
148 case msgs := <-result:
149 return msgs, nil
150 case <-ctx.Done():
151 f.mu.Lock()
152 delete(f.waiters, channel)
153 f.mu.Unlock()
154 return nil, ctx.Err()
155 }
156 }
157
158 // Before fetches up to count messages before the given timestamp.
159 func (f *Fetcher) Before(ctx context.Context, channel string, before time.Time, count int) ([]Message, error) {
160 result := make(chan []Message, 1)
161
162 f.mu.Lock()
163 f.waiters[channel] = result
164 f.mu.Unlock()
165
166 ts := before.UTC().Format("2006-01-02T15:04:05.000Z")
167 if err := f.client.Cmd.SendRawf("CHATHISTORY BEFORE %s timestamp=%s %d", channel, ts, count); err != nil {
168 f.mu.Lock()
169 delete(f.waiters, channel)
170 f.mu.Unlock()
171 return nil, fmt.Errorf("chathistory: send: %w", err)
172 }
173
174 select {
175 case msgs := <-result:
176 return msgs, nil
177 case <-ctx.Done():
178 f.mu.Lock()
179 delete(f.waiters, channel)
180 f.mu.Unlock()
181 return nil, ctx.Err()
182 }
183 }
--- pkg/client/client.go
+++ pkg/client/client.go
@@ -186,10 +186,27 @@
186186
text := e.Last()
187187
env, err := protocol.Unmarshal([]byte(text))
188188
if err != nil {
189189
return // non-JSON PRIVMSG (human chat) — silently ignored
190190
}
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
+
191208
c.dispatch(ctx, env)
192209
})
193210
194211
// NOTICE is ignored — system/human commentary, not agent traffic.
195212
196213
--- 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 @@
271271
text := strings.TrimSpace(e.Last())
272272
if senderNick == a.cfg.Nick {
273273
return
274274
}
275275
276
+ // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix.
277
+ if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
278
+ if idx := strings.Index(senderNick, sep); idx != -1 {
279
+ senderNick = senderNick[:idx]
280
+ }
281
+ }
282
+ // Fallback: parse legacy [nick] prefix from bridge bot.
276283
if strings.HasPrefix(text, "[") {
277284
if end := strings.Index(text, "] "); end != -1 {
278285
senderNick = text[1:end]
279286
text = text[end+2:]
280287
}
281288
--- pkg/ircagent/ircagent.go
+++ pkg/ircagent/ircagent.go
@@ -271,10 +271,17 @@
271 text := strings.TrimSpace(e.Last())
272 if senderNick == a.cfg.Nick {
273 return
274 }
275
 
 
 
 
 
 
 
276 if strings.HasPrefix(text, "[") {
277 if end := strings.Index(text, "] "); end != -1 {
278 senderNick = text[1:end]
279 text = text[end+2:]
280 }
281
--- pkg/ircagent/ircagent.go
+++ pkg/ircagent/ircagent.go
@@ -271,10 +271,17 @@
271 text := strings.TrimSpace(e.Last())
272 if senderNick == a.cfg.Nick {
273 return
274 }
275
276 // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix.
277 if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
278 if idx := strings.Index(senderNick, sep); idx != -1 {
279 senderNick = senderNick[:idx]
280 }
281 }
282 // Fallback: parse legacy [nick] prefix from bridge bot.
283 if strings.HasPrefix(text, "[") {
284 if end := strings.Index(text, "] "); end != -1 {
285 senderNick = text[1:end]
286 text = text[end+2:]
287 }
288
--- pkg/protocol/protocol.go
+++ pkg/protocol/protocol.go
@@ -33,10 +33,17 @@
3333
ID string `json:"id"`
3434
From string `json:"from"`
3535
To []string `json:"to,omitempty"`
3636
TS int64 `json:"ts"`
3737
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
3845
}
3946
4047
// New creates a new Envelope with a generated ID and current timestamp.
4148
// To is left empty (unaddressed — matches all recipients).
4249
func New(msgType, from string, payload any) (*Envelope, error) {
4350
--- 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
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -25,10 +25,11 @@
2525
nick string
2626
addr string
2727
agentType string
2828
pass string
2929
deleteOnClose bool
30
+ envelopeMode bool
3031
3132
mu sync.RWMutex
3233
channels []string
3334
messages []Message
3435
client *girc.Client
@@ -50,10 +51,11 @@
5051
nick: cfg.Nick,
5152
addr: cfg.IRC.Addr,
5253
agentType: cfg.IRC.AgentType,
5354
pass: cfg.IRC.Pass,
5455
deleteOnClose: cfg.IRC.DeleteOnClose,
56
+ envelopeMode: cfg.IRC.EnvelopeMode,
5557
channels: append([]string(nil), cfg.Channels...),
5658
messages: make([]Message, 0, defaultBufferSize),
5759
errCh: make(chan error, 1),
5860
}, nil
5961
}
@@ -126,27 +128,47 @@
126128
}
127129
if onJoined != nil {
128130
onJoined()
129131
}
130132
})
131
- client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
133
+ client.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
132134
if len(e.Params) < 1 || e.Source == nil {
133135
return
134136
}
135137
target := normalizeChannel(e.Params[0])
136138
if !c.hasChannel(target) {
137139
return
138140
}
141
+ // Prefer account-tag (IRCv3) over source nick.
139142
sender := e.Source.Name
143
+ if acct, ok := e.Tags.Get("account"); ok && acct != "" {
144
+ sender = acct
145
+ }
140146
text := strings.TrimSpace(e.Last())
147
+ // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix.
148
+ if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
149
+ if idx := strings.Index(sender, sep); idx != -1 {
150
+ sender = sender[:idx]
151
+ }
152
+ }
153
+ // Fallback: parse legacy [nick] prefix from bridge bot.
141154
if sender == "bridge" && strings.HasPrefix(text, "[") {
142155
if end := strings.Index(text, "] "); end != -1 {
143156
sender = text[1:end]
144157
text = strings.TrimSpace(text[end+2:])
145158
}
146159
}
147
- c.appendMessage(Message{At: time.Now(), Channel: target, Nick: sender, Text: text})
160
+ // Use server-time when available; fall back to local clock.
161
+ at := e.Timestamp
162
+ if at.IsZero() {
163
+ at = time.Now()
164
+ }
165
+ var msgID string
166
+ if id, ok := e.Tags.Get("msgid"); ok {
167
+ msgID = id
168
+ }
169
+ c.appendMessage(Message{At: at, Channel: target, Nick: sender, Text: text, MsgID: msgID})
148170
})
149171
150172
c.mu.Lock()
151173
c.client = client
152174
c.mu.Unlock()
@@ -221,26 +243,28 @@
221243
222244
func (c *ircConnector) PostTo(_ context.Context, channel, text string) error {
223245
return c.PostToWithMeta(context.Background(), channel, text, nil)
224246
}
225247
226
-// PostWithMeta sends text to all channels. Meta is ignored — IRC is text-only.
227
-func (c *ircConnector) PostWithMeta(_ context.Context, text string, _ json.RawMessage) error {
248
+// PostWithMeta sends text to all channels.
249
+// In envelope mode, wraps the message in a protocol.Envelope JSON.
250
+func (c *ircConnector) PostWithMeta(_ context.Context, text string, meta json.RawMessage) error {
228251
c.mu.RLock()
229252
client := c.client
230253
c.mu.RUnlock()
231254
if client == nil {
232255
return fmt.Errorf("sessionrelay: irc client not connected")
233256
}
257
+ msg := c.formatMessage(text, meta)
234258
for _, channel := range c.Channels() {
235
- client.Cmd.Message(channel, text)
259
+ client.Cmd.Message(channel, msg)
236260
}
237261
return nil
238262
}
239263
240
-// PostToWithMeta sends text to a specific channel. Meta is ignored — IRC is text-only.
241
-func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, _ json.RawMessage) error {
264
+// PostToWithMeta sends text to a specific channel.
265
+func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, meta json.RawMessage) error {
242266
c.mu.RLock()
243267
client := c.client
244268
c.mu.RUnlock()
245269
if client == nil {
246270
return fmt.Errorf("sessionrelay: irc client not connected")
@@ -247,13 +271,37 @@
247271
}
248272
channel = normalizeChannel(channel)
249273
if channel == "" {
250274
return fmt.Errorf("sessionrelay: post channel is required")
251275
}
252
- client.Cmd.Message(channel, text)
276
+ client.Cmd.Message(channel, c.formatMessage(text, meta))
253277
return nil
254278
}
279
+
280
+// formatMessage wraps text in a JSON envelope when envelope mode is enabled.
281
+func (c *ircConnector) formatMessage(text string, meta json.RawMessage) string {
282
+ if !c.envelopeMode {
283
+ return text
284
+ }
285
+ env := map[string]any{
286
+ "v": 1,
287
+ "type": "relay.message",
288
+ "from": c.nick,
289
+ "ts": time.Now().UnixMilli(),
290
+ "payload": map[string]any{
291
+ "text": text,
292
+ },
293
+ }
294
+ if len(meta) > 0 {
295
+ env["payload"] = json.RawMessage(meta)
296
+ }
297
+ data, err := json.Marshal(env)
298
+ if err != nil {
299
+ return text // fallback to plain text
300
+ }
301
+ return string(data)
302
+}
255303
256304
func (c *ircConnector) MessagesSince(_ context.Context, since time.Time) ([]Message, error) {
257305
c.mu.RLock()
258306
defer c.mu.RUnlock()
259307
260308
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -25,10 +25,11 @@
25 nick string
26 addr string
27 agentType string
28 pass string
29 deleteOnClose bool
 
30
31 mu sync.RWMutex
32 channels []string
33 messages []Message
34 client *girc.Client
@@ -50,10 +51,11 @@
50 nick: cfg.Nick,
51 addr: cfg.IRC.Addr,
52 agentType: cfg.IRC.AgentType,
53 pass: cfg.IRC.Pass,
54 deleteOnClose: cfg.IRC.DeleteOnClose,
 
55 channels: append([]string(nil), cfg.Channels...),
56 messages: make([]Message, 0, defaultBufferSize),
57 errCh: make(chan error, 1),
58 }, nil
59 }
@@ -126,27 +128,47 @@
126 }
127 if onJoined != nil {
128 onJoined()
129 }
130 })
131 client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
132 if len(e.Params) < 1 || e.Source == nil {
133 return
134 }
135 target := normalizeChannel(e.Params[0])
136 if !c.hasChannel(target) {
137 return
138 }
 
139 sender := e.Source.Name
 
 
 
140 text := strings.TrimSpace(e.Last())
 
 
 
 
 
 
 
141 if sender == "bridge" && strings.HasPrefix(text, "[") {
142 if end := strings.Index(text, "] "); end != -1 {
143 sender = text[1:end]
144 text = strings.TrimSpace(text[end+2:])
145 }
146 }
147 c.appendMessage(Message{At: time.Now(), Channel: target, Nick: sender, Text: text})
 
 
 
 
 
 
 
 
 
148 })
149
150 c.mu.Lock()
151 c.client = client
152 c.mu.Unlock()
@@ -221,26 +243,28 @@
221
222 func (c *ircConnector) PostTo(_ context.Context, channel, text string) error {
223 return c.PostToWithMeta(context.Background(), channel, text, nil)
224 }
225
226 // PostWithMeta sends text to all channels. Meta is ignored — IRC is text-only.
227 func (c *ircConnector) PostWithMeta(_ context.Context, text string, _ json.RawMessage) error {
 
228 c.mu.RLock()
229 client := c.client
230 c.mu.RUnlock()
231 if client == nil {
232 return fmt.Errorf("sessionrelay: irc client not connected")
233 }
 
234 for _, channel := range c.Channels() {
235 client.Cmd.Message(channel, text)
236 }
237 return nil
238 }
239
240 // PostToWithMeta sends text to a specific channel. Meta is ignored — IRC is text-only.
241 func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, _ json.RawMessage) error {
242 c.mu.RLock()
243 client := c.client
244 c.mu.RUnlock()
245 if client == nil {
246 return fmt.Errorf("sessionrelay: irc client not connected")
@@ -247,13 +271,37 @@
247 }
248 channel = normalizeChannel(channel)
249 if channel == "" {
250 return fmt.Errorf("sessionrelay: post channel is required")
251 }
252 client.Cmd.Message(channel, text)
253 return nil
254 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
256 func (c *ircConnector) MessagesSince(_ context.Context, since time.Time) ([]Message, error) {
257 c.mu.RLock()
258 defer c.mu.RUnlock()
259
260
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -25,10 +25,11 @@
25 nick string
26 addr string
27 agentType string
28 pass string
29 deleteOnClose bool
30 envelopeMode bool
31
32 mu sync.RWMutex
33 channels []string
34 messages []Message
35 client *girc.Client
@@ -50,10 +51,11 @@
51 nick: cfg.Nick,
52 addr: cfg.IRC.Addr,
53 agentType: cfg.IRC.AgentType,
54 pass: cfg.IRC.Pass,
55 deleteOnClose: cfg.IRC.DeleteOnClose,
56 envelopeMode: cfg.IRC.EnvelopeMode,
57 channels: append([]string(nil), cfg.Channels...),
58 messages: make([]Message, 0, defaultBufferSize),
59 errCh: make(chan error, 1),
60 }, nil
61 }
@@ -126,27 +128,47 @@
128 }
129 if onJoined != nil {
130 onJoined()
131 }
132 })
133 client.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
134 if len(e.Params) < 1 || e.Source == nil {
135 return
136 }
137 target := normalizeChannel(e.Params[0])
138 if !c.hasChannel(target) {
139 return
140 }
141 // Prefer account-tag (IRCv3) over source nick.
142 sender := e.Source.Name
143 if acct, ok := e.Tags.Get("account"); ok && acct != "" {
144 sender = acct
145 }
146 text := strings.TrimSpace(e.Last())
147 // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix.
148 if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" {
149 if idx := strings.Index(sender, sep); idx != -1 {
150 sender = sender[:idx]
151 }
152 }
153 // Fallback: parse legacy [nick] prefix from bridge bot.
154 if sender == "bridge" && strings.HasPrefix(text, "[") {
155 if end := strings.Index(text, "] "); end != -1 {
156 sender = text[1:end]
157 text = strings.TrimSpace(text[end+2:])
158 }
159 }
160 // Use server-time when available; fall back to local clock.
161 at := e.Timestamp
162 if at.IsZero() {
163 at = time.Now()
164 }
165 var msgID string
166 if id, ok := e.Tags.Get("msgid"); ok {
167 msgID = id
168 }
169 c.appendMessage(Message{At: at, Channel: target, Nick: sender, Text: text, MsgID: msgID})
170 })
171
172 c.mu.Lock()
173 c.client = client
174 c.mu.Unlock()
@@ -221,26 +243,28 @@
243
244 func (c *ircConnector) PostTo(_ context.Context, channel, text string) error {
245 return c.PostToWithMeta(context.Background(), channel, text, nil)
246 }
247
248 // PostWithMeta sends text to all channels.
249 // In envelope mode, wraps the message in a protocol.Envelope JSON.
250 func (c *ircConnector) PostWithMeta(_ context.Context, text string, meta json.RawMessage) error {
251 c.mu.RLock()
252 client := c.client
253 c.mu.RUnlock()
254 if client == nil {
255 return fmt.Errorf("sessionrelay: irc client not connected")
256 }
257 msg := c.formatMessage(text, meta)
258 for _, channel := range c.Channels() {
259 client.Cmd.Message(channel, msg)
260 }
261 return nil
262 }
263
264 // PostToWithMeta sends text to a specific channel.
265 func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, meta json.RawMessage) error {
266 c.mu.RLock()
267 client := c.client
268 c.mu.RUnlock()
269 if client == nil {
270 return fmt.Errorf("sessionrelay: irc client not connected")
@@ -247,13 +271,37 @@
271 }
272 channel = normalizeChannel(channel)
273 if channel == "" {
274 return fmt.Errorf("sessionrelay: post channel is required")
275 }
276 client.Cmd.Message(channel, c.formatMessage(text, meta))
277 return nil
278 }
279
280 // formatMessage wraps text in a JSON envelope when envelope mode is enabled.
281 func (c *ircConnector) formatMessage(text string, meta json.RawMessage) string {
282 if !c.envelopeMode {
283 return text
284 }
285 env := map[string]any{
286 "v": 1,
287 "type": "relay.message",
288 "from": c.nick,
289 "ts": time.Now().UnixMilli(),
290 "payload": map[string]any{
291 "text": text,
292 },
293 }
294 if len(meta) > 0 {
295 env["payload"] = json.RawMessage(meta)
296 }
297 data, err := json.Marshal(env)
298 if err != nil {
299 return text // fallback to plain text
300 }
301 return string(data)
302 }
303
304 func (c *ircConnector) MessagesSince(_ context.Context, since time.Time) ([]Message, error) {
305 c.mu.RLock()
306 defer c.mu.RUnlock()
307
308
--- pkg/sessionrelay/sessionrelay.go
+++ pkg/sessionrelay/sessionrelay.go
@@ -35,17 +35,21 @@
3535
type IRCConfig struct {
3636
Addr string
3737
Pass string
3838
AgentType string
3939
DeleteOnClose bool
40
+ // EnvelopeMode wraps outgoing messages in protocol.Envelope JSON.
41
+ // When true, agents in the channel can parse relay output as structured data.
42
+ EnvelopeMode bool
4043
}
4144
4245
type Message struct {
4346
At time.Time
4447
Channel string
4548
Nick string
4649
Text string
50
+ MsgID string
4751
}
4852
4953
type Connector interface {
5054
Connect(ctx context.Context) error
5155
Post(ctx context.Context, text string) error
5256
5357
ADDED pkg/toon/toon.go
5458
ADDED pkg/toon/toon_test.go
--- pkg/sessionrelay/sessionrelay.go
+++ pkg/sessionrelay/sessionrelay.go
@@ -35,17 +35,21 @@
35 type IRCConfig struct {
36 Addr string
37 Pass string
38 AgentType string
39 DeleteOnClose bool
 
 
 
40 }
41
42 type Message struct {
43 At time.Time
44 Channel string
45 Nick string
46 Text string
 
47 }
48
49 type Connector interface {
50 Connect(ctx context.Context) error
51 Post(ctx context.Context, text string) error
52
53 DDED pkg/toon/toon.go
54 DDED pkg/toon/toon_test.go
--- pkg/sessionrelay/sessionrelay.go
+++ pkg/sessionrelay/sessionrelay.go
@@ -35,17 +35,21 @@
35 type IRCConfig struct {
36 Addr string
37 Pass string
38 AgentType string
39 DeleteOnClose bool
40 // EnvelopeMode wraps outgoing messages in protocol.Envelope JSON.
41 // When true, agents in the channel can parse relay output as structured data.
42 EnvelopeMode bool
43 }
44
45 type Message struct {
46 At time.Time
47 Channel string
48 Nick string
49 Text string
50 MsgID string
51 }
52
53 type Connector interface {
54 Connect(ctx context.Context) error
55 Post(ctx context.Context, text string) error
56
57 DDED pkg/toon/toon.go
58 DDED pkg/toon/toon_test.go
--- a/pkg/toon/toon.go
+++ b/pkg/toon/toon.go
@@ -0,0 +1,121 @@
1
+// Package toon implements the TOON format — Token-Optimized Object Notation
2
+// for compact LLM context windows.
3
+//
4
+// TOON is designed for feeding IRC conversation history to language models.
5
+// It strips noise (joins, parts, status messages, repeated tool calls),
6
+// deduplicates, and compresses timestamps into relative offsets.
7
+//
8
+// Example output:
9
+//
10
+// #fleet 50msg 2h window
11
+// ---
12
+// claude-kohakku [orch] +0m
13
+// task.create {file: main.go, action: edit}
14
+// "editing main.go to add error handling"
15
+// leo [op] +2m
16
+// "looks good, ship it"
17
+// claude-kohakku [orch] +3m
18
+// task.complete {file: main.go, status: done}
19
+// ---
20
+// decisions: edit main.go error handling
21
+// actions: task.create → task.complete (main.go)
22
+package toon
23
+
24
+import (
25
+ "fmt"
26
+ "strings"
27
+ "time"
28
+)
29
+
30
+// Entry is a single message to include in the TOON output.
31
+type Entry struct {
32
+ Nick string
33
+ Type string // agent type: "orch", "worker", "op", "bot", "" for unknown
34
+ MessageType string // envelope type (e.g. "task.create"), empty for plain text
35
+ Text string
36
+ At time.Time
37
+}
38
+
39
+// Options controls TOON formatting.
40
+type Options struct {
41
+ Channel string
42
+ MaxEntries int // 0 = no limit
43
+}
44
+
45
+// Format renders a slice of entries into TOON format.
46
+func Format(entries []Entry, opts Options) string {
47
+ if len(entries) == 0 {
48
+ return ""
49
+ }
50
+
51
+ var b strings.Builder
52
+
53
+ // Header.
54
+ window := ""
55
+ if len(entries) >= 2 {
56
+ dur := entries[len(entries)-1].At.Sub(entries[0].At)
57
+ window = " " + compactDuration(dur) + " window"
58
+ }
59
+ ch := opts.Channel
60
+ if ch == "" {
61
+ ch = "channel"
62
+ }
63
+ fmt.Fprintf(&b, "%s %dmsg%s\n---\n", ch, len(entries), window)
64
+
65
+ // Body — group consecutive messages from same nick.
66
+ baseTime := entries[0].At
67
+ var lastNick string
68
+ for _, e := range entries {
69
+ offset := e.At.Sub(baseTime)
70
+ if e.Nick != lastNick {
71
+ tag := ""
72
+ if e.Type != "" {
73
+ tag = " [" + e.Type + "]"
74
+ }
75
+ fmt.Fprintf(&b, "%s%s +%s\n", e.Nick, tag, compactDuration(offset))
76
+ lastNick = e.Nick
77
+ }
78
+
79
+ if e.MessageType != "" {
80
+ fmt.Fprintf(&b, " %s\n", e.MessageType)
81
+ }
82
+ text := strings.TrimSpace(e.Text)
83
+ if text != "" && text != e.MessageType {
84
+ // Truncate very long messages to save tokens.
85
+ if len(text) > 200 {
86
+ text = text[:197] + "..."
87
+ }
88
+ fmt.Fprintf(&b, " \"%s\"\n", text)
89
+ }
90
+ }
91
+
92
+ b.WriteString("---\n")
93
+ return b.String()
94
+}
95
+
96
+// FormatPrompt wraps TOON-formatted history into an LLM summarization prompt.
97
+func FormatPrompt(channel string, entries []Entry) string {
98
+ toon := Format(entries, Options{Channel: channel})
99
+ var b strings.Builder
100
+ fmt.Fprintf(&b, "Summarize this IRC conversation. Focus on decisions, actions, and outcomes. Be concise.\n\n")
101
+ b.WriteString(toon)
102
+ return b.String()
103
+}
104
+
105
+func compactDuration(d time.Duration) string {
106
+ if d < time.Minute {
107
+ return fmt.Sprintf("%ds", int(d.Seconds()))
108
+ }
109
+ if d < time.Hour {
110
+ return fmt.Sprintf("%dm", int(d.Minutes()))
111
+ }
112
+ if d < 24*time.Hour {
113
+ h := int(d.Hours())
114
+ m := int(d.Minutes()) % 60
115
+ if m == 0 {
116
+ return fmt.Sprintf("%dh", h)
117
+ }
118
+ return fmt.Sprintf("%dh%dm", h, m)
119
+ }
120
+ return fmt.Sprintf("%dd", int(d.Hours()/24))
121
+}
--- a/pkg/toon/toon.go
+++ b/pkg/toon/toon.go
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pkg/toon/toon.go
+++ b/pkg/toon/toon.go
@@ -0,0 +1,121 @@
1 // Package toon implements the TOON format — Token-Optimized Object Notation
2 // for compact LLM context windows.
3 //
4 // TOON is designed for feeding IRC conversation history to language models.
5 // It strips noise (joins, parts, status messages, repeated tool calls),
6 // deduplicates, and compresses timestamps into relative offsets.
7 //
8 // Example output:
9 //
10 // #fleet 50msg 2h window
11 // ---
12 // claude-kohakku [orch] +0m
13 // task.create {file: main.go, action: edit}
14 // "editing main.go to add error handling"
15 // leo [op] +2m
16 // "looks good, ship it"
17 // claude-kohakku [orch] +3m
18 // task.complete {file: main.go, status: done}
19 // ---
20 // decisions: edit main.go error handling
21 // actions: task.create → task.complete (main.go)
22 package toon
23
24 import (
25 "fmt"
26 "strings"
27 "time"
28 )
29
30 // Entry is a single message to include in the TOON output.
31 type Entry struct {
32 Nick string
33 Type string // agent type: "orch", "worker", "op", "bot", "" for unknown
34 MessageType string // envelope type (e.g. "task.create"), empty for plain text
35 Text string
36 At time.Time
37 }
38
39 // Options controls TOON formatting.
40 type Options struct {
41 Channel string
42 MaxEntries int // 0 = no limit
43 }
44
45 // Format renders a slice of entries into TOON format.
46 func Format(entries []Entry, opts Options) string {
47 if len(entries) == 0 {
48 return ""
49 }
50
51 var b strings.Builder
52
53 // Header.
54 window := ""
55 if len(entries) >= 2 {
56 dur := entries[len(entries)-1].At.Sub(entries[0].At)
57 window = " " + compactDuration(dur) + " window"
58 }
59 ch := opts.Channel
60 if ch == "" {
61 ch = "channel"
62 }
63 fmt.Fprintf(&b, "%s %dmsg%s\n---\n", ch, len(entries), window)
64
65 // Body — group consecutive messages from same nick.
66 baseTime := entries[0].At
67 var lastNick string
68 for _, e := range entries {
69 offset := e.At.Sub(baseTime)
70 if e.Nick != lastNick {
71 tag := ""
72 if e.Type != "" {
73 tag = " [" + e.Type + "]"
74 }
75 fmt.Fprintf(&b, "%s%s +%s\n", e.Nick, tag, compactDuration(offset))
76 lastNick = e.Nick
77 }
78
79 if e.MessageType != "" {
80 fmt.Fprintf(&b, " %s\n", e.MessageType)
81 }
82 text := strings.TrimSpace(e.Text)
83 if text != "" && text != e.MessageType {
84 // Truncate very long messages to save tokens.
85 if len(text) > 200 {
86 text = text[:197] + "..."
87 }
88 fmt.Fprintf(&b, " \"%s\"\n", text)
89 }
90 }
91
92 b.WriteString("---\n")
93 return b.String()
94 }
95
96 // FormatPrompt wraps TOON-formatted history into an LLM summarization prompt.
97 func FormatPrompt(channel string, entries []Entry) string {
98 toon := Format(entries, Options{Channel: channel})
99 var b strings.Builder
100 fmt.Fprintf(&b, "Summarize this IRC conversation. Focus on decisions, actions, and outcomes. Be concise.\n\n")
101 b.WriteString(toon)
102 return b.String()
103 }
104
105 func compactDuration(d time.Duration) string {
106 if d < time.Minute {
107 return fmt.Sprintf("%ds", int(d.Seconds()))
108 }
109 if d < time.Hour {
110 return fmt.Sprintf("%dm", int(d.Minutes()))
111 }
112 if d < 24*time.Hour {
113 h := int(d.Hours())
114 m := int(d.Minutes()) % 60
115 if m == 0 {
116 return fmt.Sprintf("%dh", h)
117 }
118 return fmt.Sprintf("%dh%dm", h, m)
119 }
120 return fmt.Sprintf("%dd", int(d.Hours()/24))
121 }
--- a/pkg/toon/toon_test.go
+++ b/pkg/toon/toon_test.go
@@ -0,0 +1,65 @@
1
+package toon
2
+
3
+import (
4
+ "strings"
5
+ "testing"
6
+ "time"
7
+)
8
+
9
+func TestFormatEmpty(t *testing.T) {
10
+ if got := Format(nil, Options{}); got != "" {
11
+ t.Errorf("expected empty, got %q", got)
12
+ }
13
+}
14
+
15
+func TestFormatBasic(t *testing.T) {
16
+ base := time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC)
17
+ entries := []Entry{
18
+ {Nick: "alice", Type: "op", Text: "let's ship it", At: base},
19
+ {Nick: "claude-abc", Type: "orch", MessageType: "task.create", Text: "editing main.go", At: base.Add(2 * time.Minute)},
20
+ {Nick: "claude-abc", Type: "orch", MessageType: "task.complete", Text: "done", At: base.Add(5 * time.Minute)},
21
+ }
22
+ out := Format(entries, Options{Channel: "#fleet"})
23
+
24
+ // Header.
25
+ if !strings.HasPrefix(out, "#fleet 3msg") {
26
+ t.Errorf("header mismatch: %q", out)
27
+ }
28
+ // Grouped consecutive messages from claude-abc.
29
+ if strings.Count(out, "claude-abc") != 1 {
30
+ t.Errorf("expected nick grouping, got:\n%s", out)
31
+ }
32
+ // Contains message types.
33
+ if !strings.Contains(out, "task.create") || !strings.Contains(out, "task.complete") {
34
+ t.Errorf("missing message types:\n%s", out)
35
+ }
36
+}
37
+
38
+func TestFormatPrompt(t *testing.T) {
39
+ entries := []Entry{{Nick: "a", Text: "hello"}}
40
+ out := FormatPrompt("#test", entries)
41
+ if !strings.Contains(out, "Summarize") {
42
+ t.Errorf("prompt missing instruction:\n%s", out)
43
+ }
44
+ if !strings.Contains(out, "#test") {
45
+ t.Errorf("prompt missing channel:\n%s", out)
46
+ }
47
+}
48
+
49
+func TestCompactDuration(t *testing.T) {
50
+ tests := []struct {
51
+ d time.Duration
52
+ want string
53
+ }{
54
+ {30 * time.Second, "30s"},
55
+ {5 * time.Minute, "5m"},
56
+ {2 * time.Hour, "2h"},
57
+ {2*time.Hour + 30*time.Minute, "2h30m"},
58
+ {48 * time.Hour, "2d"},
59
+ }
60
+ for _, tt := range tests {
61
+ if got := compactDuration(tt.d); got != tt.want {
62
+ t.Errorf("compactDuration(%v) = %q, want %q", tt.d, got, tt.want)
63
+ }
64
+ }
65
+}
--- a/pkg/toon/toon_test.go
+++ b/pkg/toon/toon_test.go
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pkg/toon/toon_test.go
+++ b/pkg/toon/toon_test.go
@@ -0,0 +1,65 @@
1 package toon
2
3 import (
4 "strings"
5 "testing"
6 "time"
7 )
8
9 func TestFormatEmpty(t *testing.T) {
10 if got := Format(nil, Options{}); got != "" {
11 t.Errorf("expected empty, got %q", got)
12 }
13 }
14
15 func TestFormatBasic(t *testing.T) {
16 base := time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC)
17 entries := []Entry{
18 {Nick: "alice", Type: "op", Text: "let's ship it", At: base},
19 {Nick: "claude-abc", Type: "orch", MessageType: "task.create", Text: "editing main.go", At: base.Add(2 * time.Minute)},
20 {Nick: "claude-abc", Type: "orch", MessageType: "task.complete", Text: "done", At: base.Add(5 * time.Minute)},
21 }
22 out := Format(entries, Options{Channel: "#fleet"})
23
24 // Header.
25 if !strings.HasPrefix(out, "#fleet 3msg") {
26 t.Errorf("header mismatch: %q", out)
27 }
28 // Grouped consecutive messages from claude-abc.
29 if strings.Count(out, "claude-abc") != 1 {
30 t.Errorf("expected nick grouping, got:\n%s", out)
31 }
32 // Contains message types.
33 if !strings.Contains(out, "task.create") || !strings.Contains(out, "task.complete") {
34 t.Errorf("missing message types:\n%s", out)
35 }
36 }
37
38 func TestFormatPrompt(t *testing.T) {
39 entries := []Entry{{Nick: "a", Text: "hello"}}
40 out := FormatPrompt("#test", entries)
41 if !strings.Contains(out, "Summarize") {
42 t.Errorf("prompt missing instruction:\n%s", out)
43 }
44 if !strings.Contains(out, "#test") {
45 t.Errorf("prompt missing channel:\n%s", out)
46 }
47 }
48
49 func TestCompactDuration(t *testing.T) {
50 tests := []struct {
51 d time.Duration
52 want string
53 }{
54 {30 * time.Second, "30s"},
55 {5 * time.Minute, "5m"},
56 {2 * time.Hour, "2h"},
57 {2*time.Hour + 30*time.Minute, "2h30m"},
58 {48 * time.Hour, "2d"},
59 }
60 for _, tt := range tests {
61 if got := compactDuration(tt.d); got != tt.want {
62 t.Errorf("compactDuration(%v) = %q, want %q", tt.d, got, tt.want)
63 }
64 }
65 }

Keyboard Shortcuts

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