ScuttleBot
merge main into feature/86-wire-bot-commands
Commit
e88e140fd0d4e671e628f00c1b9e39ff0bf00f5e030e101f15fbd68b3121335c
Parent
520ad8ed949416b…
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 @@ | ||
| 157 | 157 | - `+v` (voice) — trusted worker agents |
| 158 | 158 | - no mode — standard agents |
| 159 | 159 | |
| 160 | 160 | ### Built-in bots |
| 161 | 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. | |
| 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 | 163 | |
| 164 | 164 | | Bot | Nick | Role | |
| 165 | 165 | |-----|------|------| |
| 166 | 166 | | `auditbot` | auditbot | Immutable append-only audit trail of agent actions and credential events | |
| 167 | 167 | | `herald` | herald | Routes inbound webhook events to IRC channels | |
| 168 | 168 | | `oracle` | oracle | On-demand channel summarization via DM — calls any OpenAI-compatible LLM | |
| 169 | 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 | | |
| 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 | | |
| 172 | 174 | | `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 | | |
| 174 | 176 | |
| 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. | |
| 176 | 178 | |
| 177 | 179 | ### Scale |
| 178 | 180 | |
| 179 | 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. |
| 180 | 182 | |
| @@ -186,11 +188,12 @@ | ||
| 186 | 188 | |------|------|-------| |
| 187 | 189 | | Agent registry | `data/ergo/registry.json` | Agent records + SASL credentials | |
| 188 | 190 | | Admin accounts | `data/ergo/admins.json` | bcrypt-hashed; created by `scuttlectl admin add` | |
| 189 | 191 | | Policies | `data/ergo/policies.json` | Bot config, agent policy, logging settings | |
| 190 | 192 | | 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) | | |
| 192 | 195 | | Ergo state | `data/ergo/ircd.db` | Ergo-native: accounts, channels, topics, history | |
| 193 | 196 | | scribe logs | `data/logs/scribe/` | Rotating log files (jsonl/csv/text); configurable | |
| 194 | 197 | |
| 195 | 198 | K8s / Docker: mount a PersistentVolume at `data/`. Ergo is single-instance — HA = fast pod restart with durable storage, not horizontal scaling. |
| 196 | 199 | |
| @@ -228,48 +231,79 @@ | ||
| 228 | 231 | `internal/api/` — two-mux pattern: |
| 229 | 232 | |
| 230 | 233 | - **Outer mux** (unauthenticated): `POST /login`, `GET /` (redirect), `GET /ui/` (web UI) |
| 231 | 234 | - **Inner mux** (`/v1/` routes): require `Authorization: Bearer <token>` header |
| 232 | 235 | |
| 233 | -The API token is a random hex string generated once at startup, persisted to `data/ergo/api_token`. | |
| 234 | - | |
| 235 | 236 | ### Auth |
| 236 | 237 | |
| 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. | |
| 238 | 243 | |
| 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. | |
| 240 | 245 | |
| 241 | 246 | ### Key endpoints |
| 242 | 247 | |
| 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 | | |
| 258 | 289 | |
| 259 | 290 | --- |
| 260 | 291 | |
| 261 | 292 | ## Adding a New Bot |
| 262 | 293 | |
| 263 | 294 | 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`: | |
| 266 | 298 | - Add a case to `buildBot()` that constructs your bot from the spec config |
| 267 | 299 | - 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 | |
| 271 | 305 | |
| 272 | 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): |
| 273 | 307 | |
| 274 | 308 | ```go |
| 275 | 309 | type bot interface { |
| @@ -315,14 +349,28 @@ | ||
| 315 | 349 | go build ./cmd/scuttlectl # build CLI |
| 316 | 350 | go test ./... # run all tests |
| 317 | 351 | golangci-lint run # lint |
| 318 | 352 | |
| 319 | 353 | # Admin CLI |
| 354 | +scuttlectl status # server health | |
| 320 | 355 | scuttlectl admin list # list admin accounts |
| 321 | 356 | scuttlectl admin add alice # add admin (prompts for password) |
| 322 | 357 | scuttlectl admin passwd alice # change password |
| 323 | 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 | |
| 324 | 372 | |
| 325 | 373 | # Docker |
| 326 | 374 | docker compose -f deploy/compose/docker-compose.yml up |
| 327 | 375 | ``` |
| 328 | 376 | |
| 329 | 377 |
| --- 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 |
+45
-7
| --- cmd/scuttlebot/main.go | ||
| +++ cmd/scuttlebot/main.go | ||
| @@ -138,18 +138,31 @@ | ||
| 138 | 138 | } else if err := reg.SetDataPath(filepath.Join(cfg.Ergo.DataDir, "registry.json")); err != nil { |
| 139 | 139 | log.Error("registry load", "err", err) |
| 140 | 140 | os.Exit(1) |
| 141 | 141 | } |
| 142 | 142 | |
| 143 | - // Shared API token — persisted so the UI token survives restarts. | |
| 144 | - apiToken, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "api_token")) | |
| 143 | + // API key store — per-consumer tokens with scoped permissions. | |
| 144 | + apiKeyStore, err := auth.NewAPIKeyStore(filepath.Join(cfg.Ergo.DataDir, "api_keys.json")) | |
| 145 | 145 | if err != nil { |
| 146 | - log.Error("api token", "err", err) | |
| 146 | + log.Error("api key store", "err", err) | |
| 147 | 147 | os.Exit(1) |
| 148 | 148 | } |
| 149 | - log.Info("api token", "token", apiToken) // printed on every startup | |
| 150 | - tokens := []string{apiToken} | |
| 149 | + // Migrate legacy api_token into key store on first run. | |
| 150 | + if apiKeyStore.IsEmpty() { | |
| 151 | + apiToken, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "api_token")) | |
| 152 | + if err != nil { | |
| 153 | + log.Error("api token", "err", err) | |
| 154 | + os.Exit(1) | |
| 155 | + } | |
| 156 | + if _, err := apiKeyStore.Insert("server", apiToken, []auth.Scope{auth.ScopeAdmin}); err != nil { | |
| 157 | + log.Error("migrate api token to key store", "err", err) | |
| 158 | + os.Exit(1) | |
| 159 | + } | |
| 160 | + log.Info("migrated api_token to api_keys.json", "token", apiToken) | |
| 161 | + } else { | |
| 162 | + log.Info("api key store loaded", "keys", len(apiKeyStore.List())) | |
| 163 | + } | |
| 151 | 164 | |
| 152 | 165 | // Start bridge bot (powers the web chat UI). |
| 153 | 166 | var bridgeBot *bridge.Bot |
| 154 | 167 | if cfg.Bridge.Enabled { |
| 155 | 168 | if cfg.Bridge.Password == "" { |
| @@ -204,10 +217,11 @@ | ||
| 204 | 217 | Name: sc.Name, |
| 205 | 218 | Topic: sc.Topic, |
| 206 | 219 | Ops: sc.Ops, |
| 207 | 220 | Voice: sc.Voice, |
| 208 | 221 | Autojoin: sc.Autojoin, |
| 222 | + Modes: sc.Modes, | |
| 209 | 223 | }) |
| 210 | 224 | } |
| 211 | 225 | if err := topoMgr.Provision(staticChannels); err != nil { |
| 212 | 226 | log.Error("topology provision failed", "err", err) |
| 213 | 227 | } |
| @@ -230,10 +244,25 @@ | ||
| 230 | 244 | os.Exit(1) |
| 231 | 245 | } |
| 232 | 246 | } |
| 233 | 247 | if bridgeBot != nil { |
| 234 | 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 | + }) | |
| 235 | 264 | } |
| 236 | 265 | |
| 237 | 266 | // Admin store — bcrypt-hashed admin accounts. |
| 238 | 267 | adminStore, err := auth.NewAdminStore(filepath.Join(cfg.Ergo.DataDir, "admins.json")) |
| 239 | 268 | if err != nil { |
| @@ -328,19 +357,28 @@ | ||
| 328 | 357 | staticChannels := make([]topology.ChannelConfig, 0, len(updated.Topology.Channels)) |
| 329 | 358 | for _, sc := range updated.Topology.Channels { |
| 330 | 359 | staticChannels = append(staticChannels, topology.ChannelConfig{ |
| 331 | 360 | Name: sc.Name, Topic: sc.Topic, |
| 332 | 361 | Ops: sc.Ops, Voice: sc.Voice, Autojoin: sc.Autojoin, |
| 362 | + Modes: sc.Modes, | |
| 333 | 363 | }) |
| 334 | 364 | } |
| 335 | 365 | if err := topoMgr.Provision(staticChannels); err != nil { |
| 336 | 366 | log.Error("topology hot-reload failed", "err", err) |
| 337 | 367 | } |
| 338 | 368 | } |
| 339 | 369 | // Hot-reload bridge web TTL. |
| 340 | 370 | if bridgeBot != nil { |
| 341 | 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 | + } | |
| 342 | 380 | } |
| 343 | 381 | }) |
| 344 | 382 | |
| 345 | 383 | // Start HTTP REST API server. |
| 346 | 384 | var llmCfg *config.LLMConfig |
| @@ -352,11 +390,11 @@ | ||
| 352 | 390 | // non-nil (Go nil interface trap) and causes panics in setAgentModes. |
| 353 | 391 | var topoIface api.TopologyManager |
| 354 | 392 | if topoMgr != nil { |
| 355 | 393 | topoIface = topoMgr |
| 356 | 394 | } |
| 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) | |
| 358 | 396 | handler := apiSrv.Handler() |
| 359 | 397 | |
| 360 | 398 | var httpServer, tlsServer *http.Server |
| 361 | 399 | |
| 362 | 400 | if cfg.TLS.Domain != "" { |
| @@ -418,11 +456,11 @@ | ||
| 418 | 456 | } |
| 419 | 457 | }() |
| 420 | 458 | } |
| 421 | 459 | |
| 422 | 460 | // Start MCP server. |
| 423 | - mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, tokens, log) | |
| 461 | + mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, apiKeyStore, log) | |
| 424 | 462 | mcpServer := &http.Server{ |
| 425 | 463 | Addr: cfg.MCPAddr, |
| 426 | 464 | Handler: mcpSrv.Handler(), |
| 427 | 465 | } |
| 428 | 466 | go func() { |
| 429 | 467 |
| --- 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 @@ | ||
| 137 | 137 | // RemoveAdmin sends DELETE /v1/admins/{username}. |
| 138 | 138 | func (c *Client) RemoveAdmin(username string) error { |
| 139 | 139 | _, err := c.doNoBody("DELETE", "/v1/admins/"+username) |
| 140 | 140 | return err |
| 141 | 141 | } |
| 142 | + | |
| 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 | +} | |
| 142 | 193 | |
| 143 | 194 | // SetAdminPassword sends PUT /v1/admins/{username}/password. |
| 144 | 195 | func (c *Client) SetAdminPassword(username, password string) error { |
| 145 | 196 | _, err := c.put("/v1/admins/"+username+"/password", map[string]string{"password": password}) |
| 146 | 197 | return err |
| 147 | 198 |
| --- 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 |
+99
| --- cmd/scuttlectl/main.go | ||
| +++ cmd/scuttlectl/main.go | ||
| @@ -108,10 +108,28 @@ | ||
| 108 | 108 | requireArgs(args, 3, "scuttlectl admin passwd <username>") |
| 109 | 109 | cmdAdminPasswd(api, args[2]) |
| 110 | 110 | default: |
| 111 | 111 | fmt.Fprintf(os.Stderr, "unknown subcommand: admin %s\n", args[1]) |
| 112 | 112 | os.Exit(1) |
| 113 | + } | |
| 114 | + case "api-key", "api-keys": | |
| 115 | + if len(args) < 2 { | |
| 116 | + fmt.Fprintf(os.Stderr, "usage: scuttlectl api-key <list|create|revoke>\n") | |
| 117 | + os.Exit(1) | |
| 118 | + } | |
| 119 | + switch args[1] { | |
| 120 | + case "list": | |
| 121 | + cmdAPIKeyList(api, *jsonFlag) | |
| 122 | + case "create": | |
| 123 | + requireArgs(args, 3, "scuttlectl api-key create --name <name> --scopes <scope1,scope2>") | |
| 124 | + cmdAPIKeyCreate(api, args[2:], *jsonFlag) | |
| 125 | + case "revoke": | |
| 126 | + requireArgs(args, 3, "scuttlectl api-key revoke <id>") | |
| 127 | + cmdAPIKeyRevoke(api, args[2]) | |
| 128 | + default: | |
| 129 | + fmt.Fprintf(os.Stderr, "unknown subcommand: api-key %s\n", args[1]) | |
| 130 | + os.Exit(1) | |
| 113 | 131 | } |
| 114 | 132 | case "channels", "channel": |
| 115 | 133 | if len(args) < 2 { |
| 116 | 134 | fmt.Fprintf(os.Stderr, "usage: scuttlectl channels <list|users <channel>>\n") |
| 117 | 135 | os.Exit(1) |
| @@ -491,10 +509,88 @@ | ||
| 491 | 509 | fmt.Fprintf(tw, "password\t%s\n", creds.Password) |
| 492 | 510 | fmt.Fprintf(tw, "server\t%s\n", creds.Server) |
| 493 | 511 | tw.Flush() |
| 494 | 512 | fmt.Println("\nStore this password — it will not be shown again.") |
| 495 | 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 | +} | |
| 496 | 592 | |
| 497 | 593 | func usage() { |
| 498 | 594 | fmt.Fprintf(os.Stderr, `scuttlectl %s — scuttlebot management CLI |
| 499 | 595 | |
| 500 | 596 | Usage: |
| @@ -526,10 +622,13 @@ | ||
| 526 | 622 | backend rename <old> <new> rename a backend |
| 527 | 623 | admin list list admin accounts |
| 528 | 624 | admin add <username> add admin (prompts for password) |
| 529 | 625 | admin remove <username> remove admin |
| 530 | 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 | |
| 531 | 630 | `, version) |
| 532 | 631 | } |
| 533 | 632 | |
| 534 | 633 | func printJSON(raw json.RawMessage) { |
| 535 | 634 | var buf []byte |
| 536 | 635 |
| --- cmd/scuttlectl/main.go | |
| +++ cmd/scuttlectl/main.go | |
| @@ -108,10 +108,28 @@ | |
| 108 | requireArgs(args, 3, "scuttlectl admin passwd <username>") |
| 109 | cmdAdminPasswd(api, args[2]) |
| 110 | default: |
| 111 | fmt.Fprintf(os.Stderr, "unknown subcommand: admin %s\n", args[1]) |
| 112 | os.Exit(1) |
| 113 | } |
| 114 | case "channels", "channel": |
| 115 | if len(args) < 2 { |
| 116 | fmt.Fprintf(os.Stderr, "usage: scuttlectl channels <list|users <channel>>\n") |
| 117 | os.Exit(1) |
| @@ -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 @@ | ||
| 13 | 13 | enforce-utf8: true |
| 14 | 14 | lookup-hostnames: false |
| 15 | 15 | forward-confirm-hostnames: false |
| 16 | 16 | check-ident: false |
| 17 | 17 | relaymsg: |
| 18 | - enabled: false | |
| 18 | + enabled: true | |
| 19 | + separators: / | |
| 20 | + available-to-chanops: false | |
| 19 | 21 | ip-cloaking: |
| 20 | 22 | enabled: false |
| 21 | 23 | max-sendq: "1M" |
| 22 | 24 | ip-limits: |
| 23 | 25 | count-exempted: true |
| 24 | 26 |
| --- deploy/compose/ergo/ircd.yaml.tmpl | |
| +++ deploy/compose/ergo/ircd.yaml.tmpl | |
| @@ -13,11 +13,13 @@ | |
| 13 | enforce-utf8: true |
| 14 | lookup-hostnames: false |
| 15 | forward-confirm-hostnames: false |
| 16 | check-ident: false |
| 17 | relaymsg: |
| 18 | enabled: false |
| 19 | ip-cloaking: |
| 20 | enabled: false |
| 21 | max-sendq: "1M" |
| 22 | ip-limits: |
| 23 | count-exempted: true |
| 24 |
| --- deploy/compose/ergo/ircd.yaml.tmpl | |
| +++ deploy/compose/ergo/ircd.yaml.tmpl | |
| @@ -13,11 +13,13 @@ | |
| 13 | enforce-utf8: true |
| 14 | lookup-hostnames: false |
| 15 | forward-confirm-hostnames: false |
| 16 | check-ident: false |
| 17 | relaymsg: |
| 18 | enabled: true |
| 19 | separators: / |
| 20 | available-to-chanops: false |
| 21 | ip-cloaking: |
| 22 | enabled: false |
| 23 | max-sendq: "1M" |
| 24 | ip-limits: |
| 25 | count-exempted: true |
| 26 |
+81
| --- internal/api/agents.go | ||
| +++ internal/api/agents.go | ||
| @@ -12,10 +12,11 @@ | ||
| 12 | 12 | Nick string `json:"nick"` |
| 13 | 13 | Type registry.AgentType `json:"type"` |
| 14 | 14 | Channels []string `json:"channels"` |
| 15 | 15 | OpsChannels []string `json:"ops_channels,omitempty"` |
| 16 | 16 | Permissions []string `json:"permissions"` |
| 17 | + Skills []string `json:"skills,omitempty"` | |
| 17 | 18 | RateLimit *registry.RateLimitConfig `json:"rate_limit,omitempty"` |
| 18 | 19 | Rules *registry.EngagementRules `json:"engagement,omitempty"` |
| 19 | 20 | } |
| 20 | 21 | |
| 21 | 22 | type registerResponse struct { |
| @@ -57,10 +58,17 @@ | ||
| 57 | 58 | s.log.Error("register agent", "nick", req.Nick, "err", err) |
| 58 | 59 | writeError(w, http.StatusInternalServerError, "registration failed") |
| 59 | 60 | return |
| 60 | 61 | } |
| 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 | + } | |
| 62 | 70 | s.registry.Touch(req.Nick) |
| 63 | 71 | s.setAgentModes(req.Nick, req.Type, cfg) |
| 64 | 72 | writeJSON(w, http.StatusCreated, registerResponse{ |
| 65 | 73 | Credentials: creds, |
| 66 | 74 | Payload: payload, |
| @@ -149,10 +157,39 @@ | ||
| 149 | 157 | writeError(w, http.StatusInternalServerError, "deletion failed") |
| 150 | 158 | return |
| 151 | 159 | } |
| 152 | 160 | w.WriteHeader(http.StatusNoContent) |
| 153 | 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 | +} | |
| 154 | 191 | |
| 155 | 192 | func (s *Server) handleUpdateAgent(w http.ResponseWriter, r *http.Request) { |
| 156 | 193 | nick := r.PathValue("nick") |
| 157 | 194 | var req struct { |
| 158 | 195 | Channels []string `json:"channels"` |
| @@ -174,10 +211,23 @@ | ||
| 174 | 211 | w.WriteHeader(http.StatusNoContent) |
| 175 | 212 | } |
| 176 | 213 | |
| 177 | 214 | func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) { |
| 178 | 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 | + } | |
| 179 | 229 | writeJSON(w, http.StatusOK, map[string]any{"agents": agents}) |
| 180 | 230 | } |
| 181 | 231 | |
| 182 | 232 | func (s *Server) handleGetAgent(w http.ResponseWriter, r *http.Request) { |
| 183 | 233 | nick := r.PathValue("nick") |
| @@ -186,10 +236,41 @@ | ||
| 186 | 236 | writeError(w, http.StatusNotFound, err.Error()) |
| 187 | 237 | return |
| 188 | 238 | } |
| 189 | 239 | writeJSON(w, http.StatusOK, agent) |
| 190 | 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 | +} | |
| 191 | 272 | |
| 192 | 273 | // agentModeLevel maps an agent type to the ChanServ access level it should |
| 193 | 274 | // receive. Returns "" for types that get no special mode. |
| 194 | 275 | func agentModeLevel(t registry.AgentType) string { |
| 195 | 276 | switch t { |
| 196 | 277 |
| --- 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 |
+2
-1
| --- internal/api/api_test.go | ||
| +++ internal/api/api_test.go | ||
| @@ -8,10 +8,11 @@ | ||
| 8 | 8 | "net/http/httptest" |
| 9 | 9 | "sync" |
| 10 | 10 | "testing" |
| 11 | 11 | |
| 12 | 12 | "github.com/conflicthq/scuttlebot/internal/api" |
| 13 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 13 | 14 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 14 | 15 | "log/slog" |
| 15 | 16 | "os" |
| 16 | 17 | ) |
| 17 | 18 | |
| @@ -50,11 +51,11 @@ | ||
| 50 | 51 | const testToken = "test-api-token-abc123" |
| 51 | 52 | |
| 52 | 53 | func newTestServer(t *testing.T) *httptest.Server { |
| 53 | 54 | t.Helper() |
| 54 | 55 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 55 | - srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog) | |
| 56 | + srv := api.New(reg, auth.TestStore(testToken), nil, nil, nil, nil, nil, nil, "", testLog) | |
| 56 | 57 | return httptest.NewServer(srv.Handler()) |
| 57 | 58 | } |
| 58 | 59 | |
| 59 | 60 | func authHeader() http.Header { |
| 60 | 61 | h := http.Header{} |
| 61 | 62 | |
| 62 | 63 | ADDED internal/api/apikeys.go |
| --- internal/api/api_test.go | |
| +++ internal/api/api_test.go | |
| @@ -8,10 +8,11 @@ | |
| 8 | "net/http/httptest" |
| 9 | "sync" |
| 10 | "testing" |
| 11 | |
| 12 | "github.com/conflicthq/scuttlebot/internal/api" |
| 13 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 14 | "log/slog" |
| 15 | "os" |
| 16 | ) |
| 17 | |
| @@ -50,11 +51,11 @@ | |
| 50 | const testToken = "test-api-token-abc123" |
| 51 | |
| 52 | func newTestServer(t *testing.T) *httptest.Server { |
| 53 | t.Helper() |
| 54 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 55 | srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog) |
| 56 | return httptest.NewServer(srv.Handler()) |
| 57 | } |
| 58 | |
| 59 | func authHeader() http.Header { |
| 60 | h := http.Header{} |
| 61 | |
| 62 | DDED internal/api/apikeys.go |
| --- internal/api/api_test.go | |
| +++ internal/api/api_test.go | |
| @@ -8,10 +8,11 @@ | |
| 8 | "net/http/httptest" |
| 9 | "sync" |
| 10 | "testing" |
| 11 | |
| 12 | "github.com/conflicthq/scuttlebot/internal/api" |
| 13 | "github.com/conflicthq/scuttlebot/internal/auth" |
| 14 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 15 | "log/slog" |
| 16 | "os" |
| 17 | ) |
| 18 | |
| @@ -50,11 +51,11 @@ | |
| 51 | const testToken = "test-api-token-abc123" |
| 52 | |
| 53 | func newTestServer(t *testing.T) *httptest.Server { |
| 54 | t.Helper() |
| 55 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 56 | srv := api.New(reg, auth.TestStore(testToken), nil, nil, nil, nil, nil, nil, "", testLog) |
| 57 | return httptest.NewServer(srv.Handler()) |
| 58 | } |
| 59 | |
| 60 | func authHeader() http.Header { |
| 61 | h := http.Header{} |
| 62 | |
| 63 | DDED internal/api/apikeys.go |
+125
| --- a/internal/api/apikeys.go | ||
| +++ b/internal/api/apikeys.go | ||
| @@ -0,0 +1,125 @@ | ||
| 1 | +package api | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "encoding/json" | |
| 5 | + "net/http" | |
| 6 | + "time" | |
| 7 | + | |
| 8 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 9 | +) | |
| 10 | + | |
| 11 | +type createAPIKeyRequest struct { | |
| 12 | + Name string `json:"name"` | |
| 13 | + Scopes []string `json:"scopes"` | |
| 14 | + ExpiresIn string `json:"expires_in,omitempty"` // e.g. "720h" for 30 days, empty = never | |
| 15 | +} | |
| 16 | + | |
| 17 | +type createAPIKeyResponse struct { | |
| 18 | + ID string `json:"id"` | |
| 19 | + Name string `json:"name"` | |
| 20 | + Token string `json:"token"` // plaintext, shown only once | |
| 21 | + Scopes []auth.Scope `json:"scopes"` | |
| 22 | + CreatedAt time.Time `json:"created_at"` | |
| 23 | + ExpiresAt *time.Time `json:"expires_at,omitempty"` | |
| 24 | +} | |
| 25 | + | |
| 26 | +type apiKeyListEntry struct { | |
| 27 | + ID string `json:"id"` | |
| 28 | + Name string `json:"name"` | |
| 29 | + Scopes []auth.Scope `json:"scopes"` | |
| 30 | + CreatedAt time.Time `json:"created_at"` | |
| 31 | + LastUsed *time.Time `json:"last_used,omitempty"` | |
| 32 | + ExpiresAt *time.Time `json:"expires_at,omitempty"` | |
| 33 | + Active bool `json:"active"` | |
| 34 | +} | |
| 35 | + | |
| 36 | +// handleListAPIKeys handles GET /v1/api-keys. | |
| 37 | +func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) { | |
| 38 | + keys := s.apiKeys.List() | |
| 39 | + out := make([]apiKeyListEntry, len(keys)) | |
| 40 | + for i, k := range keys { | |
| 41 | + out[i] = apiKeyListEntry{ | |
| 42 | + ID: k.ID, | |
| 43 | + Name: k.Name, | |
| 44 | + Scopes: k.Scopes, | |
| 45 | + CreatedAt: k.CreatedAt, | |
| 46 | + Active: k.Active, | |
| 47 | + } | |
| 48 | + if !k.LastUsed.IsZero() { | |
| 49 | + t := k.LastUsed | |
| 50 | + out[i].LastUsed = &t | |
| 51 | + } | |
| 52 | + if !k.ExpiresAt.IsZero() { | |
| 53 | + t := k.ExpiresAt | |
| 54 | + out[i].ExpiresAt = &t | |
| 55 | + } | |
| 56 | + } | |
| 57 | + writeJSON(w, http.StatusOK, out) | |
| 58 | +} | |
| 59 | + | |
| 60 | +// handleCreateAPIKey handles POST /v1/api-keys. | |
| 61 | +func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) { | |
| 62 | + var req createAPIKeyRequest | |
| 63 | + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
| 64 | + writeError(w, http.StatusBadRequest, "invalid request body") | |
| 65 | + return | |
| 66 | + } | |
| 67 | + if req.Name == "" { | |
| 68 | + writeError(w, http.StatusBadRequest, "name is required") | |
| 69 | + return | |
| 70 | + } | |
| 71 | + | |
| 72 | + scopes := make([]auth.Scope, len(req.Scopes)) | |
| 73 | + for i, s := range req.Scopes { | |
| 74 | + scope := auth.Scope(s) | |
| 75 | + if !auth.ValidScopes[scope] { | |
| 76 | + writeError(w, http.StatusBadRequest, "unknown scope: "+s) | |
| 77 | + return | |
| 78 | + } | |
| 79 | + scopes[i] = scope | |
| 80 | + } | |
| 81 | + if len(scopes) == 0 { | |
| 82 | + writeError(w, http.StatusBadRequest, "at least one scope is required") | |
| 83 | + return | |
| 84 | + } | |
| 85 | + | |
| 86 | + var expiresAt time.Time | |
| 87 | + if req.ExpiresIn != "" { | |
| 88 | + dur, err := time.ParseDuration(req.ExpiresIn) | |
| 89 | + if err != nil { | |
| 90 | + writeError(w, http.StatusBadRequest, "invalid expires_in duration: "+err.Error()) | |
| 91 | + return | |
| 92 | + } | |
| 93 | + expiresAt = time.Now().Add(dur) | |
| 94 | + } | |
| 95 | + | |
| 96 | + token, key, err := s.apiKeys.Create(req.Name, scopes, expiresAt) | |
| 97 | + if err != nil { | |
| 98 | + s.log.Error("create api key", "err", err) | |
| 99 | + writeError(w, http.StatusInternalServerError, "failed to create API key") | |
| 100 | + return | |
| 101 | + } | |
| 102 | + | |
| 103 | + resp := createAPIKeyResponse{ | |
| 104 | + ID: key.ID, | |
| 105 | + Name: key.Name, | |
| 106 | + Token: token, | |
| 107 | + Scopes: key.Scopes, | |
| 108 | + CreatedAt: key.CreatedAt, | |
| 109 | + } | |
| 110 | + if !key.ExpiresAt.IsZero() { | |
| 111 | + t := key.ExpiresAt | |
| 112 | + resp.ExpiresAt = &t | |
| 113 | + } | |
| 114 | + writeJSON(w, http.StatusCreated, resp) | |
| 115 | +} | |
| 116 | + | |
| 117 | +// handleRevokeAPIKey handles DELETE /v1/api-keys/{id}. | |
| 118 | +func (s *Server) handleRevokeAPIKey(w http.ResponseWriter, r *http.Request) { | |
| 119 | + id := r.PathValue("id") | |
| 120 | + if err := s.apiKeys.Revoke(id); err != nil { | |
| 121 | + writeError(w, http.StatusNotFound, err.Error()) | |
| 122 | + return | |
| 123 | + } | |
| 124 | + w.WriteHeader(http.StatusNoContent) | |
| 125 | +} |
| --- a/internal/api/apikeys.go | |
| +++ b/internal/api/apikeys.go | |
| @@ -0,0 +1,125 @@ | |
| --- a/internal/api/apikeys.go | |
| +++ b/internal/api/apikeys.go | |
| @@ -0,0 +1,125 @@ | |
| 1 | package api |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "net/http" |
| 6 | "time" |
| 7 | |
| 8 | "github.com/conflicthq/scuttlebot/internal/auth" |
| 9 | ) |
| 10 | |
| 11 | type createAPIKeyRequest struct { |
| 12 | Name string `json:"name"` |
| 13 | Scopes []string `json:"scopes"` |
| 14 | ExpiresIn string `json:"expires_in,omitempty"` // e.g. "720h" for 30 days, empty = never |
| 15 | } |
| 16 | |
| 17 | type createAPIKeyResponse struct { |
| 18 | ID string `json:"id"` |
| 19 | Name string `json:"name"` |
| 20 | Token string `json:"token"` // plaintext, shown only once |
| 21 | Scopes []auth.Scope `json:"scopes"` |
| 22 | CreatedAt time.Time `json:"created_at"` |
| 23 | ExpiresAt *time.Time `json:"expires_at,omitempty"` |
| 24 | } |
| 25 | |
| 26 | type apiKeyListEntry struct { |
| 27 | ID string `json:"id"` |
| 28 | Name string `json:"name"` |
| 29 | Scopes []auth.Scope `json:"scopes"` |
| 30 | CreatedAt time.Time `json:"created_at"` |
| 31 | LastUsed *time.Time `json:"last_used,omitempty"` |
| 32 | ExpiresAt *time.Time `json:"expires_at,omitempty"` |
| 33 | Active bool `json:"active"` |
| 34 | } |
| 35 | |
| 36 | // handleListAPIKeys handles GET /v1/api-keys. |
| 37 | func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) { |
| 38 | keys := s.apiKeys.List() |
| 39 | out := make([]apiKeyListEntry, len(keys)) |
| 40 | for i, k := range keys { |
| 41 | out[i] = apiKeyListEntry{ |
| 42 | ID: k.ID, |
| 43 | Name: k.Name, |
| 44 | Scopes: k.Scopes, |
| 45 | CreatedAt: k.CreatedAt, |
| 46 | Active: k.Active, |
| 47 | } |
| 48 | if !k.LastUsed.IsZero() { |
| 49 | t := k.LastUsed |
| 50 | out[i].LastUsed = &t |
| 51 | } |
| 52 | if !k.ExpiresAt.IsZero() { |
| 53 | t := k.ExpiresAt |
| 54 | out[i].ExpiresAt = &t |
| 55 | } |
| 56 | } |
| 57 | writeJSON(w, http.StatusOK, out) |
| 58 | } |
| 59 | |
| 60 | // handleCreateAPIKey handles POST /v1/api-keys. |
| 61 | func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) { |
| 62 | var req createAPIKeyRequest |
| 63 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| 64 | writeError(w, http.StatusBadRequest, "invalid request body") |
| 65 | return |
| 66 | } |
| 67 | if req.Name == "" { |
| 68 | writeError(w, http.StatusBadRequest, "name is required") |
| 69 | return |
| 70 | } |
| 71 | |
| 72 | scopes := make([]auth.Scope, len(req.Scopes)) |
| 73 | for i, s := range req.Scopes { |
| 74 | scope := auth.Scope(s) |
| 75 | if !auth.ValidScopes[scope] { |
| 76 | writeError(w, http.StatusBadRequest, "unknown scope: "+s) |
| 77 | return |
| 78 | } |
| 79 | scopes[i] = scope |
| 80 | } |
| 81 | if len(scopes) == 0 { |
| 82 | writeError(w, http.StatusBadRequest, "at least one scope is required") |
| 83 | return |
| 84 | } |
| 85 | |
| 86 | var expiresAt time.Time |
| 87 | if req.ExpiresIn != "" { |
| 88 | dur, err := time.ParseDuration(req.ExpiresIn) |
| 89 | if err != nil { |
| 90 | writeError(w, http.StatusBadRequest, "invalid expires_in duration: "+err.Error()) |
| 91 | return |
| 92 | } |
| 93 | expiresAt = time.Now().Add(dur) |
| 94 | } |
| 95 | |
| 96 | token, key, err := s.apiKeys.Create(req.Name, scopes, expiresAt) |
| 97 | if err != nil { |
| 98 | s.log.Error("create api key", "err", err) |
| 99 | writeError(w, http.StatusInternalServerError, "failed to create API key") |
| 100 | return |
| 101 | } |
| 102 | |
| 103 | resp := createAPIKeyResponse{ |
| 104 | ID: key.ID, |
| 105 | Name: key.Name, |
| 106 | Token: token, |
| 107 | Scopes: key.Scopes, |
| 108 | CreatedAt: key.CreatedAt, |
| 109 | } |
| 110 | if !key.ExpiresAt.IsZero() { |
| 111 | t := key.ExpiresAt |
| 112 | resp.ExpiresAt = &t |
| 113 | } |
| 114 | writeJSON(w, http.StatusCreated, resp) |
| 115 | } |
| 116 | |
| 117 | // handleRevokeAPIKey handles DELETE /v1/api-keys/{id}. |
| 118 | func (s *Server) handleRevokeAPIKey(w http.ResponseWriter, r *http.Request) { |
| 119 | id := r.PathValue("id") |
| 120 | if err := s.apiKeys.Revoke(id); err != nil { |
| 121 | writeError(w, http.StatusNotFound, err.Error()) |
| 122 | return |
| 123 | } |
| 124 | w.WriteHeader(http.StatusNoContent) |
| 125 | } |
+90
-8
| --- internal/api/channels_topology.go | ||
| +++ internal/api/channels_topology.go | ||
| @@ -14,18 +14,21 @@ | ||
| 14 | 14 | ProvisionChannel(ch topology.ChannelConfig) error |
| 15 | 15 | DropChannel(channel string) |
| 16 | 16 | Policy() *topology.Policy |
| 17 | 17 | GrantAccess(nick, channel, level string) |
| 18 | 18 | RevokeAccess(nick, channel string) |
| 19 | + ListChannels() []topology.ChannelInfo | |
| 19 | 20 | } |
| 20 | 21 | |
| 21 | 22 | type provisionChannelRequest struct { |
| 22 | - 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"` | |
| 27 | 30 | } |
| 28 | 31 | |
| 29 | 32 | type provisionChannelResponse struct { |
| 30 | 33 | Channel string `json:"channel"` |
| 31 | 34 | Type string `json:"type,omitempty"` |
| @@ -51,28 +54,51 @@ | ||
| 51 | 54 | return |
| 52 | 55 | } |
| 53 | 56 | |
| 54 | 57 | policy := s.topoMgr.Policy() |
| 55 | 58 | |
| 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. | |
| 57 | 60 | autojoin := req.Autojoin |
| 58 | 61 | if len(autojoin) == 0 && policy != nil { |
| 59 | 62 | autojoin = policy.AutojoinFor(req.Name) |
| 60 | 63 | } |
| 64 | + var modes []string | |
| 65 | + if policy != nil { | |
| 66 | + modes = policy.ModesFor(req.Name) | |
| 67 | + } | |
| 61 | 68 | |
| 62 | 69 | ch := topology.ChannelConfig{ |
| 63 | 70 | Name: req.Name, |
| 64 | 71 | Topic: req.Topic, |
| 65 | 72 | Ops: req.Ops, |
| 66 | 73 | Voice: req.Voice, |
| 67 | 74 | Autojoin: autojoin, |
| 75 | + Modes: modes, | |
| 68 | 76 | } |
| 69 | 77 | if err := s.topoMgr.ProvisionChannel(ch); err != nil { |
| 70 | 78 | s.log.Error("provision channel", "channel", req.Name, "err", err) |
| 71 | 79 | writeError(w, http.StatusInternalServerError, "provision failed") |
| 72 | 80 | return |
| 73 | 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 | + } | |
| 74 | 100 | |
| 75 | 101 | resp := provisionChannelResponse{ |
| 76 | 102 | Channel: req.Name, |
| 77 | 103 | Autojoin: autojoin, |
| 78 | 104 | } |
| @@ -91,12 +117,13 @@ | ||
| 91 | 117 | Ephemeral bool `json:"ephemeral,omitempty"` |
| 92 | 118 | TTLSeconds int64 `json:"ttl_seconds,omitempty"` |
| 93 | 119 | } |
| 94 | 120 | |
| 95 | 121 | 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"` | |
| 98 | 125 | } |
| 99 | 126 | |
| 100 | 127 | // handleDropChannel handles DELETE /v1/topology/channels/{channel}. |
| 101 | 128 | // Drops the ChanServ registration of an ephemeral channel. |
| 102 | 129 | func (s *Server) handleDropChannel(w http.ResponseWriter, r *http.Request) { |
| @@ -110,10 +137,64 @@ | ||
| 110 | 137 | return |
| 111 | 138 | } |
| 112 | 139 | s.topoMgr.DropChannel(channel) |
| 113 | 140 | w.WriteHeader(http.StatusNoContent) |
| 114 | 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 | +} | |
| 115 | 196 | |
| 116 | 197 | // handleGetTopology handles GET /v1/topology. |
| 117 | 198 | // Returns the channel type rules and static channel names declared in config. |
| 118 | 199 | func (s *Server) handleGetTopology(w http.ResponseWriter, r *http.Request) { |
| 119 | 200 | if s.topoMgr == nil { |
| @@ -146,7 +227,8 @@ | ||
| 146 | 227 | } |
| 147 | 228 | |
| 148 | 229 | writeJSON(w, http.StatusOK, topologyResponse{ |
| 149 | 230 | StaticChannels: staticNames, |
| 150 | 231 | Types: typeInfos, |
| 232 | + ActiveChannels: s.topoMgr.ListChannels(), | |
| 151 | 233 | }) |
| 152 | 234 | } |
| 153 | 235 |
| --- 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 @@ | ||
| 9 | 9 | "net/http" |
| 10 | 10 | "net/http/httptest" |
| 11 | 11 | "testing" |
| 12 | 12 | "time" |
| 13 | 13 | |
| 14 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 14 | 15 | "github.com/conflicthq/scuttlebot/internal/config" |
| 15 | 16 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 16 | 17 | "github.com/conflicthq/scuttlebot/internal/topology" |
| 17 | 18 | ) |
| 18 | 19 | |
| @@ -48,10 +49,12 @@ | ||
| 48 | 49 | |
| 49 | 50 | func (s *stubTopologyManager) RevokeAccess(nick, channel string) { |
| 50 | 51 | s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel}) |
| 51 | 52 | } |
| 52 | 53 | |
| 54 | +func (s *stubTopologyManager) ListChannels() []topology.ChannelInfo { return nil } | |
| 55 | + | |
| 53 | 56 | // stubProvisioner is a minimal AccountProvisioner for agent registration tests. |
| 54 | 57 | type stubProvisioner struct { |
| 55 | 58 | accounts map[string]string |
| 56 | 59 | } |
| 57 | 60 | |
| @@ -74,11 +77,11 @@ | ||
| 74 | 77 | |
| 75 | 78 | func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) { |
| 76 | 79 | t.Helper() |
| 77 | 80 | reg := registry.New(nil, []byte("key")) |
| 78 | 81 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 79 | - srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler()) | |
| 82 | + srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler()) | |
| 80 | 83 | t.Cleanup(srv.Close) |
| 81 | 84 | return srv, "tok" |
| 82 | 85 | } |
| 83 | 86 | |
| 84 | 87 | // newTopoTestServerWithRegistry creates a test server with both topology and a |
| @@ -85,11 +88,11 @@ | ||
| 85 | 88 | // real registry backed by stubProvisioner, so agent registration works. |
| 86 | 89 | func newTopoTestServerWithRegistry(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) { |
| 87 | 90 | t.Helper() |
| 88 | 91 | reg := registry.New(newStubProvisioner(), []byte("key")) |
| 89 | 92 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 90 | - srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler()) | |
| 93 | + srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler()) | |
| 91 | 94 | t.Cleanup(srv.Close) |
| 92 | 95 | return srv, "tok" |
| 93 | 96 | } |
| 94 | 97 | |
| 95 | 98 | func TestHandleProvisionChannel(t *testing.T) { |
| 96 | 99 |
| --- internal/api/channels_topology_test.go | |
| +++ internal/api/channels_topology_test.go | |
| @@ -9,10 +9,11 @@ | |
| 9 | "net/http" |
| 10 | "net/http/httptest" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/conflicthq/scuttlebot/internal/config" |
| 15 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 16 | "github.com/conflicthq/scuttlebot/internal/topology" |
| 17 | ) |
| 18 | |
| @@ -48,10 +49,12 @@ | |
| 48 | |
| 49 | func (s *stubTopologyManager) RevokeAccess(nick, channel string) { |
| 50 | s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel}) |
| 51 | } |
| 52 | |
| 53 | // stubProvisioner is a minimal AccountProvisioner for agent registration tests. |
| 54 | type stubProvisioner struct { |
| 55 | accounts map[string]string |
| 56 | } |
| 57 | |
| @@ -74,11 +77,11 @@ | |
| 74 | |
| 75 | func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) { |
| 76 | t.Helper() |
| 77 | reg := registry.New(nil, []byte("key")) |
| 78 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 79 | srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler()) |
| 80 | t.Cleanup(srv.Close) |
| 81 | return srv, "tok" |
| 82 | } |
| 83 | |
| 84 | // newTopoTestServerWithRegistry creates a test server with both topology and a |
| @@ -85,11 +88,11 @@ | |
| 85 | // real registry backed by stubProvisioner, so agent registration works. |
| 86 | func newTopoTestServerWithRegistry(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) { |
| 87 | t.Helper() |
| 88 | reg := registry.New(newStubProvisioner(), []byte("key")) |
| 89 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 90 | srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler()) |
| 91 | t.Cleanup(srv.Close) |
| 92 | return srv, "tok" |
| 93 | } |
| 94 | |
| 95 | func TestHandleProvisionChannel(t *testing.T) { |
| 96 |
| --- internal/api/channels_topology_test.go | |
| +++ internal/api/channels_topology_test.go | |
| @@ -9,10 +9,11 @@ | |
| 9 | "net/http" |
| 10 | "net/http/httptest" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/conflicthq/scuttlebot/internal/auth" |
| 15 | "github.com/conflicthq/scuttlebot/internal/config" |
| 16 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 17 | "github.com/conflicthq/scuttlebot/internal/topology" |
| 18 | ) |
| 19 | |
| @@ -48,10 +49,12 @@ | |
| 49 | |
| 50 | func (s *stubTopologyManager) RevokeAccess(nick, channel string) { |
| 51 | s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel}) |
| 52 | } |
| 53 | |
| 54 | func (s *stubTopologyManager) ListChannels() []topology.ChannelInfo { return nil } |
| 55 | |
| 56 | // stubProvisioner is a minimal AccountProvisioner for agent registration tests. |
| 57 | type stubProvisioner struct { |
| 58 | accounts map[string]string |
| 59 | } |
| 60 | |
| @@ -74,11 +77,11 @@ | |
| 77 | |
| 78 | func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) { |
| 79 | t.Helper() |
| 80 | reg := registry.New(nil, []byte("key")) |
| 81 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 82 | srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler()) |
| 83 | t.Cleanup(srv.Close) |
| 84 | return srv, "tok" |
| 85 | } |
| 86 | |
| 87 | // newTopoTestServerWithRegistry creates a test server with both topology and a |
| @@ -85,11 +88,11 @@ | |
| 88 | // real registry backed by stubProvisioner, so agent registration works. |
| 89 | func newTopoTestServerWithRegistry(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) { |
| 90 | t.Helper() |
| 91 | reg := registry.New(newStubProvisioner(), []byte("key")) |
| 92 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 93 | srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler()) |
| 94 | t.Cleanup(srv.Close) |
| 95 | return srv, "tok" |
| 96 | } |
| 97 | |
| 98 | func TestHandleProvisionChannel(t *testing.T) { |
| 99 |
+43
-4
| --- internal/api/chat.go | ||
| +++ internal/api/chat.go | ||
| @@ -5,10 +5,11 @@ | ||
| 5 | 5 | "encoding/json" |
| 6 | 6 | "fmt" |
| 7 | 7 | "net/http" |
| 8 | 8 | "time" |
| 9 | 9 | |
| 10 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 10 | 11 | "github.com/conflicthq/scuttlebot/internal/bots/bridge" |
| 11 | 12 | ) |
| 12 | 13 | |
| 13 | 14 | // chatBridge is the interface the API layer requires from the bridge bot. |
| 14 | 15 | type chatBridge interface { |
| @@ -20,10 +21,12 @@ | ||
| 20 | 21 | Send(ctx context.Context, channel, text, senderNick string) error |
| 21 | 22 | SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *bridge.Meta) error |
| 22 | 23 | Stats() bridge.Stats |
| 23 | 24 | TouchUser(channel, nick string) |
| 24 | 25 | Users(channel string) []string |
| 26 | + UsersWithModes(channel string) []bridge.UserInfo | |
| 27 | + ChannelModes(channel string) string | |
| 25 | 28 | } |
| 26 | 29 | |
| 27 | 30 | func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) { |
| 28 | 31 | channel := "#" + r.PathValue("channel") |
| 29 | 32 | s.bridge.JoinChannel(channel) |
| @@ -107,22 +110,58 @@ | ||
| 107 | 110 | w.WriteHeader(http.StatusNoContent) |
| 108 | 111 | } |
| 109 | 112 | |
| 110 | 113 | func (s *Server) handleChannelUsers(w http.ResponseWriter, r *http.Request) { |
| 111 | 114 | channel := "#" + r.PathValue("channel") |
| 112 | - users := s.bridge.Users(channel) | |
| 115 | + users := s.bridge.UsersWithModes(channel) | |
| 113 | 116 | if users == nil { |
| 114 | - users = []string{} | |
| 117 | + users = []bridge.UserInfo{} | |
| 118 | + } | |
| 119 | + modes := s.bridge.ChannelModes(channel) | |
| 120 | + writeJSON(w, http.StatusOK, map[string]any{"users": users, "channel_modes": modes}) | |
| 121 | +} | |
| 122 | + | |
| 123 | +func (s *Server) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) { | |
| 124 | + channel := "#" + r.PathValue("channel") | |
| 125 | + if s.policies == nil { | |
| 126 | + writeJSON(w, http.StatusOK, ChannelDisplayConfig{}) | |
| 127 | + return | |
| 128 | + } | |
| 129 | + p := s.policies.Get() | |
| 130 | + cfg := p.Bridge.ChannelDisplay[channel] | |
| 131 | + writeJSON(w, http.StatusOK, cfg) | |
| 132 | +} | |
| 133 | + | |
| 134 | +func (s *Server) handlePutChannelConfig(w http.ResponseWriter, r *http.Request) { | |
| 135 | + channel := "#" + r.PathValue("channel") | |
| 136 | + if s.policies == nil { | |
| 137 | + writeError(w, http.StatusServiceUnavailable, "policies not configured") | |
| 138 | + return | |
| 139 | + } | |
| 140 | + var cfg ChannelDisplayConfig | |
| 141 | + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { | |
| 142 | + writeError(w, http.StatusBadRequest, "invalid request body") | |
| 143 | + return | |
| 144 | + } | |
| 145 | + p := s.policies.Get() | |
| 146 | + if p.Bridge.ChannelDisplay == nil { | |
| 147 | + p.Bridge.ChannelDisplay = make(map[string]ChannelDisplayConfig) | |
| 148 | + } | |
| 149 | + p.Bridge.ChannelDisplay[channel] = cfg | |
| 150 | + if err := s.policies.Set(p); err != nil { | |
| 151 | + writeError(w, http.StatusInternalServerError, "save failed") | |
| 152 | + return | |
| 115 | 153 | } |
| 116 | - writeJSON(w, http.StatusOK, map[string]any{"users": users}) | |
| 154 | + w.WriteHeader(http.StatusNoContent) | |
| 117 | 155 | } |
| 118 | 156 | |
| 119 | 157 | // handleChannelStream serves an SSE stream of IRC messages for a channel. |
| 120 | 158 | // Auth is via ?token= query param because EventSource doesn't support custom headers. |
| 121 | 159 | func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) { |
| 122 | 160 | token := r.URL.Query().Get("token") |
| 123 | - if _, ok := s.tokens[token]; !ok { | |
| 161 | + key := s.apiKeys.Lookup(token) | |
| 162 | + if key == nil || (!key.HasScope(auth.ScopeChannels) && !key.HasScope(auth.ScopeChat)) { | |
| 124 | 163 | writeError(w, http.StatusUnauthorized, "invalid or missing token") |
| 125 | 164 | return |
| 126 | 165 | } |
| 127 | 166 | |
| 128 | 167 | channel := "#" + r.PathValue("channel") |
| 129 | 168 |
| --- internal/api/chat.go | |
| +++ internal/api/chat.go | |
| @@ -5,10 +5,11 @@ | |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "net/http" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/conflicthq/scuttlebot/internal/bots/bridge" |
| 11 | ) |
| 12 | |
| 13 | // chatBridge is the interface the API layer requires from the bridge bot. |
| 14 | type chatBridge interface { |
| @@ -20,10 +21,12 @@ | |
| 20 | Send(ctx context.Context, channel, text, senderNick string) error |
| 21 | SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *bridge.Meta) error |
| 22 | Stats() bridge.Stats |
| 23 | TouchUser(channel, nick string) |
| 24 | Users(channel string) []string |
| 25 | } |
| 26 | |
| 27 | func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) { |
| 28 | channel := "#" + r.PathValue("channel") |
| 29 | s.bridge.JoinChannel(channel) |
| @@ -107,22 +110,58 @@ | |
| 107 | w.WriteHeader(http.StatusNoContent) |
| 108 | } |
| 109 | |
| 110 | func (s *Server) handleChannelUsers(w http.ResponseWriter, r *http.Request) { |
| 111 | channel := "#" + r.PathValue("channel") |
| 112 | users := s.bridge.Users(channel) |
| 113 | if users == nil { |
| 114 | users = []string{} |
| 115 | } |
| 116 | writeJSON(w, http.StatusOK, map[string]any{"users": users}) |
| 117 | } |
| 118 | |
| 119 | // handleChannelStream serves an SSE stream of IRC messages for a channel. |
| 120 | // Auth is via ?token= query param because EventSource doesn't support custom headers. |
| 121 | func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) { |
| 122 | token := r.URL.Query().Get("token") |
| 123 | if _, ok := s.tokens[token]; !ok { |
| 124 | writeError(w, http.StatusUnauthorized, "invalid or missing token") |
| 125 | return |
| 126 | } |
| 127 | |
| 128 | channel := "#" + r.PathValue("channel") |
| 129 |
| --- internal/api/chat.go | |
| +++ internal/api/chat.go | |
| @@ -5,10 +5,11 @@ | |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "net/http" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/conflicthq/scuttlebot/internal/auth" |
| 11 | "github.com/conflicthq/scuttlebot/internal/bots/bridge" |
| 12 | ) |
| 13 | |
| 14 | // chatBridge is the interface the API layer requires from the bridge bot. |
| 15 | type chatBridge interface { |
| @@ -20,10 +21,12 @@ | |
| 21 | Send(ctx context.Context, channel, text, senderNick string) error |
| 22 | SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *bridge.Meta) error |
| 23 | Stats() bridge.Stats |
| 24 | TouchUser(channel, nick string) |
| 25 | Users(channel string) []string |
| 26 | UsersWithModes(channel string) []bridge.UserInfo |
| 27 | ChannelModes(channel string) string |
| 28 | } |
| 29 | |
| 30 | func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) { |
| 31 | channel := "#" + r.PathValue("channel") |
| 32 | s.bridge.JoinChannel(channel) |
| @@ -107,22 +110,58 @@ | |
| 110 | w.WriteHeader(http.StatusNoContent) |
| 111 | } |
| 112 | |
| 113 | func (s *Server) handleChannelUsers(w http.ResponseWriter, r *http.Request) { |
| 114 | channel := "#" + r.PathValue("channel") |
| 115 | users := s.bridge.UsersWithModes(channel) |
| 116 | if users == nil { |
| 117 | users = []bridge.UserInfo{} |
| 118 | } |
| 119 | modes := s.bridge.ChannelModes(channel) |
| 120 | writeJSON(w, http.StatusOK, map[string]any{"users": users, "channel_modes": modes}) |
| 121 | } |
| 122 | |
| 123 | func (s *Server) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) { |
| 124 | channel := "#" + r.PathValue("channel") |
| 125 | if s.policies == nil { |
| 126 | writeJSON(w, http.StatusOK, ChannelDisplayConfig{}) |
| 127 | return |
| 128 | } |
| 129 | p := s.policies.Get() |
| 130 | cfg := p.Bridge.ChannelDisplay[channel] |
| 131 | writeJSON(w, http.StatusOK, cfg) |
| 132 | } |
| 133 | |
| 134 | func (s *Server) handlePutChannelConfig(w http.ResponseWriter, r *http.Request) { |
| 135 | channel := "#" + r.PathValue("channel") |
| 136 | if s.policies == nil { |
| 137 | writeError(w, http.StatusServiceUnavailable, "policies not configured") |
| 138 | return |
| 139 | } |
| 140 | var cfg ChannelDisplayConfig |
| 141 | if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { |
| 142 | writeError(w, http.StatusBadRequest, "invalid request body") |
| 143 | return |
| 144 | } |
| 145 | p := s.policies.Get() |
| 146 | if p.Bridge.ChannelDisplay == nil { |
| 147 | p.Bridge.ChannelDisplay = make(map[string]ChannelDisplayConfig) |
| 148 | } |
| 149 | p.Bridge.ChannelDisplay[channel] = cfg |
| 150 | if err := s.policies.Set(p); err != nil { |
| 151 | writeError(w, http.StatusInternalServerError, "save failed") |
| 152 | return |
| 153 | } |
| 154 | w.WriteHeader(http.StatusNoContent) |
| 155 | } |
| 156 | |
| 157 | // handleChannelStream serves an SSE stream of IRC messages for a channel. |
| 158 | // Auth is via ?token= query param because EventSource doesn't support custom headers. |
| 159 | func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) { |
| 160 | token := r.URL.Query().Get("token") |
| 161 | key := s.apiKeys.Lookup(token) |
| 162 | if key == nil || (!key.HasScope(auth.ScopeChannels) && !key.HasScope(auth.ScopeChat)) { |
| 163 | writeError(w, http.StatusUnauthorized, "invalid or missing token") |
| 164 | return |
| 165 | } |
| 166 | |
| 167 | channel := "#" + r.PathValue("channel") |
| 168 |
+7
-4
| --- internal/api/chat_test.go | ||
| +++ internal/api/chat_test.go | ||
| @@ -8,10 +8,11 @@ | ||
| 8 | 8 | "log/slog" |
| 9 | 9 | "net/http" |
| 10 | 10 | "net/http/httptest" |
| 11 | 11 | "testing" |
| 12 | 12 | |
| 13 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 13 | 14 | "github.com/conflicthq/scuttlebot/internal/bots/bridge" |
| 14 | 15 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 15 | 16 | ) |
| 16 | 17 | |
| 17 | 18 | type stubChatBridge struct { |
| @@ -30,12 +31,14 @@ | ||
| 30 | 31 | } |
| 31 | 32 | func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil } |
| 32 | 33 | func (b *stubChatBridge) SendWithMeta(_ context.Context, _, _, _ string, _ *bridge.Meta) error { |
| 33 | 34 | return nil |
| 34 | 35 | } |
| 35 | -func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} } | |
| 36 | -func (b *stubChatBridge) Users(string) []string { return nil } | |
| 36 | +func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} } | |
| 37 | +func (b *stubChatBridge) Users(string) []string { return nil } | |
| 38 | +func (b *stubChatBridge) UsersWithModes(string) []bridge.UserInfo { return nil } | |
| 39 | +func (b *stubChatBridge) ChannelModes(string) string { return "" } | |
| 37 | 40 | func (b *stubChatBridge) TouchUser(channel, nick string) { |
| 38 | 41 | b.touched = append(b.touched, struct{ channel, nick string }{channel: channel, nick: nick}) |
| 39 | 42 | } |
| 40 | 43 | |
| 41 | 44 | func TestHandleChannelPresence(t *testing.T) { |
| @@ -42,11 +45,11 @@ | ||
| 42 | 45 | t.Helper() |
| 43 | 46 | |
| 44 | 47 | bridgeStub := &stubChatBridge{} |
| 45 | 48 | reg := registry.New(nil, []byte("test-signing-key")) |
| 46 | 49 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 47 | - srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler()) | |
| 50 | + srv := httptest.NewServer(New(reg, auth.TestStore("token"), bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler()) | |
| 48 | 51 | defer srv.Close() |
| 49 | 52 | |
| 50 | 53 | body, _ := json.Marshal(map[string]string{"nick": "codex-test"}) |
| 51 | 54 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body)) |
| 52 | 55 | if err != nil { |
| @@ -75,11 +78,11 @@ | ||
| 75 | 78 | t.Helper() |
| 76 | 79 | |
| 77 | 80 | bridgeStub := &stubChatBridge{} |
| 78 | 81 | reg := registry.New(nil, []byte("test-signing-key")) |
| 79 | 82 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 80 | - srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler()) | |
| 83 | + srv := httptest.NewServer(New(reg, auth.TestStore("token"), bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler()) | |
| 81 | 84 | defer srv.Close() |
| 82 | 85 | |
| 83 | 86 | body, _ := json.Marshal(map[string]string{}) |
| 84 | 87 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body)) |
| 85 | 88 | if err != nil { |
| 86 | 89 |
| --- internal/api/chat_test.go | |
| +++ internal/api/chat_test.go | |
| @@ -8,10 +8,11 @@ | |
| 8 | "log/slog" |
| 9 | "net/http" |
| 10 | "net/http/httptest" |
| 11 | "testing" |
| 12 | |
| 13 | "github.com/conflicthq/scuttlebot/internal/bots/bridge" |
| 14 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 15 | ) |
| 16 | |
| 17 | type stubChatBridge struct { |
| @@ -30,12 +31,14 @@ | |
| 30 | } |
| 31 | func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil } |
| 32 | func (b *stubChatBridge) SendWithMeta(_ context.Context, _, _, _ string, _ *bridge.Meta) error { |
| 33 | return nil |
| 34 | } |
| 35 | func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} } |
| 36 | func (b *stubChatBridge) Users(string) []string { return nil } |
| 37 | func (b *stubChatBridge) TouchUser(channel, nick string) { |
| 38 | b.touched = append(b.touched, struct{ channel, nick string }{channel: channel, nick: nick}) |
| 39 | } |
| 40 | |
| 41 | func TestHandleChannelPresence(t *testing.T) { |
| @@ -42,11 +45,11 @@ | |
| 42 | t.Helper() |
| 43 | |
| 44 | bridgeStub := &stubChatBridge{} |
| 45 | reg := registry.New(nil, []byte("test-signing-key")) |
| 46 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 47 | srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler()) |
| 48 | defer srv.Close() |
| 49 | |
| 50 | body, _ := json.Marshal(map[string]string{"nick": "codex-test"}) |
| 51 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body)) |
| 52 | if err != nil { |
| @@ -75,11 +78,11 @@ | |
| 75 | t.Helper() |
| 76 | |
| 77 | bridgeStub := &stubChatBridge{} |
| 78 | reg := registry.New(nil, []byte("test-signing-key")) |
| 79 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 80 | srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler()) |
| 81 | defer srv.Close() |
| 82 | |
| 83 | body, _ := json.Marshal(map[string]string{}) |
| 84 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body)) |
| 85 | if err != nil { |
| 86 |
| --- internal/api/chat_test.go | |
| +++ internal/api/chat_test.go | |
| @@ -8,10 +8,11 @@ | |
| 8 | "log/slog" |
| 9 | "net/http" |
| 10 | "net/http/httptest" |
| 11 | "testing" |
| 12 | |
| 13 | "github.com/conflicthq/scuttlebot/internal/auth" |
| 14 | "github.com/conflicthq/scuttlebot/internal/bots/bridge" |
| 15 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 16 | ) |
| 17 | |
| 18 | type stubChatBridge struct { |
| @@ -30,12 +31,14 @@ | |
| 31 | } |
| 32 | func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil } |
| 33 | func (b *stubChatBridge) SendWithMeta(_ context.Context, _, _, _ string, _ *bridge.Meta) error { |
| 34 | return nil |
| 35 | } |
| 36 | func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} } |
| 37 | func (b *stubChatBridge) Users(string) []string { return nil } |
| 38 | func (b *stubChatBridge) UsersWithModes(string) []bridge.UserInfo { return nil } |
| 39 | func (b *stubChatBridge) ChannelModes(string) string { return "" } |
| 40 | func (b *stubChatBridge) TouchUser(channel, nick string) { |
| 41 | b.touched = append(b.touched, struct{ channel, nick string }{channel: channel, nick: nick}) |
| 42 | } |
| 43 | |
| 44 | func TestHandleChannelPresence(t *testing.T) { |
| @@ -42,11 +45,11 @@ | |
| 45 | t.Helper() |
| 46 | |
| 47 | bridgeStub := &stubChatBridge{} |
| 48 | reg := registry.New(nil, []byte("test-signing-key")) |
| 49 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 50 | srv := httptest.NewServer(New(reg, auth.TestStore("token"), bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler()) |
| 51 | defer srv.Close() |
| 52 | |
| 53 | body, _ := json.Marshal(map[string]string{"nick": "codex-test"}) |
| 54 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body)) |
| 55 | if err != nil { |
| @@ -75,11 +78,11 @@ | |
| 78 | t.Helper() |
| 79 | |
| 80 | bridgeStub := &stubChatBridge{} |
| 81 | reg := registry.New(nil, []byte("test-signing-key")) |
| 82 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 83 | srv := httptest.NewServer(New(reg, auth.TestStore("token"), bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler()) |
| 84 | defer srv.Close() |
| 85 | |
| 86 | body, _ := json.Marshal(map[string]string{}) |
| 87 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body)) |
| 88 | if err != nil { |
| 89 |
| --- internal/api/config_handlers_test.go | ||
| +++ internal/api/config_handlers_test.go | ||
| @@ -9,10 +9,11 @@ | ||
| 9 | 9 | "net/http/httptest" |
| 10 | 10 | "path/filepath" |
| 11 | 11 | "testing" |
| 12 | 12 | "time" |
| 13 | 13 | |
| 14 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 14 | 15 | "github.com/conflicthq/scuttlebot/internal/config" |
| 15 | 16 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 16 | 17 | ) |
| 17 | 18 | |
| 18 | 19 | func newCfgTestServer(t *testing.T) (*httptest.Server, *ConfigStore) { |
| @@ -25,11 +26,11 @@ | ||
| 25 | 26 | cfg.Ergo.DataDir = dir |
| 26 | 27 | |
| 27 | 28 | store := NewConfigStore(path, cfg) |
| 28 | 29 | reg := registry.New(nil, []byte("key")) |
| 29 | 30 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 30 | - srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, nil, store, "", log).Handler()) | |
| 31 | + srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, nil, store, "", log).Handler()) | |
| 31 | 32 | t.Cleanup(srv.Close) |
| 32 | 33 | return srv, store |
| 33 | 34 | } |
| 34 | 35 | |
| 35 | 36 | func TestHandleGetConfig(t *testing.T) { |
| 36 | 37 |
| --- internal/api/config_handlers_test.go | |
| +++ internal/api/config_handlers_test.go | |
| @@ -9,10 +9,11 @@ | |
| 9 | "net/http/httptest" |
| 10 | "path/filepath" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/conflicthq/scuttlebot/internal/config" |
| 15 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 16 | ) |
| 17 | |
| 18 | func newCfgTestServer(t *testing.T) (*httptest.Server, *ConfigStore) { |
| @@ -25,11 +26,11 @@ | |
| 25 | cfg.Ergo.DataDir = dir |
| 26 | |
| 27 | store := NewConfigStore(path, cfg) |
| 28 | reg := registry.New(nil, []byte("key")) |
| 29 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 30 | srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, nil, store, "", log).Handler()) |
| 31 | t.Cleanup(srv.Close) |
| 32 | return srv, store |
| 33 | } |
| 34 | |
| 35 | func TestHandleGetConfig(t *testing.T) { |
| 36 |
| --- internal/api/config_handlers_test.go | |
| +++ internal/api/config_handlers_test.go | |
| @@ -9,10 +9,11 @@ | |
| 9 | "net/http/httptest" |
| 10 | "path/filepath" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/conflicthq/scuttlebot/internal/auth" |
| 15 | "github.com/conflicthq/scuttlebot/internal/config" |
| 16 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 17 | ) |
| 18 | |
| 19 | func newCfgTestServer(t *testing.T) (*httptest.Server, *ConfigStore) { |
| @@ -25,11 +26,11 @@ | |
| 26 | cfg.Ergo.DataDir = dir |
| 27 | |
| 28 | store := NewConfigStore(path, cfg) |
| 29 | reg := registry.New(nil, []byte("key")) |
| 30 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 31 | srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, nil, store, "", log).Handler()) |
| 32 | t.Cleanup(srv.Close) |
| 33 | return srv, store |
| 34 | } |
| 35 | |
| 36 | func TestHandleGetConfig(t *testing.T) { |
| 37 |
+7
-5
| --- internal/api/login.go | ||
| +++ internal/api/login.go | ||
| @@ -93,15 +93,17 @@ | ||
| 93 | 93 | if !s.admins.Authenticate(req.Username, req.Password) { |
| 94 | 94 | writeError(w, http.StatusUnauthorized, "invalid credentials") |
| 95 | 95 | return |
| 96 | 96 | } |
| 97 | 97 | |
| 98 | - // Return the first API token — the shared server token. | |
| 99 | - var token string | |
| 100 | - for t := range s.tokens { | |
| 101 | - token = t | |
| 102 | - break | |
| 98 | + // Create a session API key for this admin login. | |
| 99 | + sessionName := "session:" + req.Username | |
| 100 | + token, _, err := s.apiKeys.Create(sessionName, []auth.Scope{auth.ScopeAdmin}, time.Now().Add(24*time.Hour)) | |
| 101 | + if err != nil { | |
| 102 | + s.log.Error("login: create session key", "err", err) | |
| 103 | + writeError(w, http.StatusInternalServerError, "failed to create session") | |
| 104 | + return | |
| 103 | 105 | } |
| 104 | 106 | |
| 105 | 107 | writeJSON(w, http.StatusOK, map[string]string{ |
| 106 | 108 | "token": token, |
| 107 | 109 | "username": req.Username, |
| 108 | 110 |
| --- internal/api/login.go | |
| +++ internal/api/login.go | |
| @@ -93,15 +93,17 @@ | |
| 93 | if !s.admins.Authenticate(req.Username, req.Password) { |
| 94 | writeError(w, http.StatusUnauthorized, "invalid credentials") |
| 95 | return |
| 96 | } |
| 97 | |
| 98 | // Return the first API token — the shared server token. |
| 99 | var token string |
| 100 | for t := range s.tokens { |
| 101 | token = t |
| 102 | break |
| 103 | } |
| 104 | |
| 105 | writeJSON(w, http.StatusOK, map[string]string{ |
| 106 | "token": token, |
| 107 | "username": req.Username, |
| 108 |
| --- internal/api/login.go | |
| +++ internal/api/login.go | |
| @@ -93,15 +93,17 @@ | |
| 93 | if !s.admins.Authenticate(req.Username, req.Password) { |
| 94 | writeError(w, http.StatusUnauthorized, "invalid credentials") |
| 95 | return |
| 96 | } |
| 97 | |
| 98 | // Create a session API key for this admin login. |
| 99 | sessionName := "session:" + req.Username |
| 100 | token, _, err := s.apiKeys.Create(sessionName, []auth.Scope{auth.ScopeAdmin}, time.Now().Add(24*time.Hour)) |
| 101 | if err != nil { |
| 102 | s.log.Error("login: create session key", "err", err) |
| 103 | writeError(w, http.StatusInternalServerError, "failed to create session") |
| 104 | return |
| 105 | } |
| 106 | |
| 107 | writeJSON(w, http.StatusOK, map[string]string{ |
| 108 | "token": token, |
| 109 | "username": req.Username, |
| 110 |
+2
-2
| --- internal/api/login_test.go | ||
| +++ internal/api/login_test.go | ||
| @@ -28,18 +28,18 @@ | ||
| 28 | 28 | admins := newAdminStore(t) |
| 29 | 29 | if err := admins.Add("admin", "hunter2"); err != nil { |
| 30 | 30 | t.Fatalf("Add admin: %v", err) |
| 31 | 31 | } |
| 32 | 32 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 33 | - srv := api.New(reg, []string{testToken}, nil, nil, admins, nil, nil, nil, "", testLog) | |
| 33 | + srv := api.New(reg, auth.TestStore(testToken), nil, nil, admins, nil, nil, nil, "", testLog) | |
| 34 | 34 | return httptest.NewServer(srv.Handler()), admins |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | 37 | func TestLoginNoAdmins(t *testing.T) { |
| 38 | 38 | // When admins is nil, login returns 404. |
| 39 | 39 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 40 | - srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog) | |
| 40 | + srv := api.New(reg, auth.TestStore(testToken), nil, nil, nil, nil, nil, nil, "", testLog) | |
| 41 | 41 | ts := httptest.NewServer(srv.Handler()) |
| 42 | 42 | defer ts.Close() |
| 43 | 43 | |
| 44 | 44 | resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil) |
| 45 | 45 | defer resp.Body.Close() |
| 46 | 46 |
| --- internal/api/login_test.go | |
| +++ internal/api/login_test.go | |
| @@ -28,18 +28,18 @@ | |
| 28 | admins := newAdminStore(t) |
| 29 | if err := admins.Add("admin", "hunter2"); err != nil { |
| 30 | t.Fatalf("Add admin: %v", err) |
| 31 | } |
| 32 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 33 | srv := api.New(reg, []string{testToken}, nil, nil, admins, nil, nil, nil, "", testLog) |
| 34 | return httptest.NewServer(srv.Handler()), admins |
| 35 | } |
| 36 | |
| 37 | func TestLoginNoAdmins(t *testing.T) { |
| 38 | // When admins is nil, login returns 404. |
| 39 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 40 | srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog) |
| 41 | ts := httptest.NewServer(srv.Handler()) |
| 42 | defer ts.Close() |
| 43 | |
| 44 | resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil) |
| 45 | defer resp.Body.Close() |
| 46 |
| --- internal/api/login_test.go | |
| +++ internal/api/login_test.go | |
| @@ -28,18 +28,18 @@ | |
| 28 | admins := newAdminStore(t) |
| 29 | if err := admins.Add("admin", "hunter2"); err != nil { |
| 30 | t.Fatalf("Add admin: %v", err) |
| 31 | } |
| 32 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 33 | srv := api.New(reg, auth.TestStore(testToken), nil, nil, admins, nil, nil, nil, "", testLog) |
| 34 | return httptest.NewServer(srv.Handler()), admins |
| 35 | } |
| 36 | |
| 37 | func TestLoginNoAdmins(t *testing.T) { |
| 38 | // When admins is nil, login returns 404. |
| 39 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 40 | srv := api.New(reg, auth.TestStore(testToken), nil, nil, nil, nil, nil, nil, "", testLog) |
| 41 | ts := httptest.NewServer(srv.Handler()) |
| 42 | defer ts.Close() |
| 43 | |
| 44 | resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil) |
| 45 | defer resp.Body.Close() |
| 46 |
+38
-2
| --- internal/api/middleware.go | ||
| +++ internal/api/middleware.go | ||
| @@ -1,26 +1,62 @@ | ||
| 1 | 1 | package api |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "context" | |
| 4 | 5 | "net/http" |
| 5 | 6 | "strings" |
| 7 | + | |
| 8 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 6 | 9 | ) |
| 7 | 10 | |
| 11 | +type ctxKey string | |
| 12 | + | |
| 13 | +const ctxAPIKey ctxKey = "apikey" | |
| 14 | + | |
| 15 | +// apiKeyFromContext returns the authenticated APIKey from the request context, | |
| 16 | +// or nil if not authenticated. | |
| 17 | +func apiKeyFromContext(ctx context.Context) *auth.APIKey { | |
| 18 | + k, _ := ctx.Value(ctxAPIKey).(*auth.APIKey) | |
| 19 | + return k | |
| 20 | +} | |
| 21 | + | |
| 22 | +// authMiddleware validates the Bearer token and injects the APIKey into context. | |
| 8 | 23 | func (s *Server) authMiddleware(next http.Handler) http.Handler { |
| 9 | 24 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 10 | 25 | token := bearerToken(r) |
| 11 | 26 | if token == "" { |
| 12 | 27 | writeError(w, http.StatusUnauthorized, "missing authorization header") |
| 13 | 28 | return |
| 14 | 29 | } |
| 15 | - if _, ok := s.tokens[token]; !ok { | |
| 30 | + key := s.apiKeys.Lookup(token) | |
| 31 | + if key == nil { | |
| 16 | 32 | writeError(w, http.StatusUnauthorized, "invalid token") |
| 17 | 33 | return |
| 18 | 34 | } |
| 19 | - next.ServeHTTP(w, r) | |
| 35 | + // Update last-used timestamp in the background. | |
| 36 | + go s.apiKeys.TouchLastUsed(key.ID) | |
| 37 | + | |
| 38 | + ctx := context.WithValue(r.Context(), ctxAPIKey, key) | |
| 39 | + next.ServeHTTP(w, r.WithContext(ctx)) | |
| 20 | 40 | }) |
| 21 | 41 | } |
| 42 | + | |
| 43 | +// requireScope returns middleware that rejects requests without the given scope. | |
| 44 | +func (s *Server) requireScope(scope auth.Scope, next http.HandlerFunc) http.HandlerFunc { | |
| 45 | + return func(w http.ResponseWriter, r *http.Request) { | |
| 46 | + key := apiKeyFromContext(r.Context()) | |
| 47 | + if key == nil { | |
| 48 | + writeError(w, http.StatusUnauthorized, "missing authentication") | |
| 49 | + return | |
| 50 | + } | |
| 51 | + if !key.HasScope(scope) { | |
| 52 | + writeError(w, http.StatusForbidden, "insufficient scope: requires "+string(scope)) | |
| 53 | + return | |
| 54 | + } | |
| 55 | + next(w, r) | |
| 56 | + } | |
| 57 | +} | |
| 22 | 58 | |
| 23 | 59 | func bearerToken(r *http.Request) string { |
| 24 | 60 | auth := r.Header.Get("Authorization") |
| 25 | 61 | token, found := strings.CutPrefix(auth, "Bearer ") |
| 26 | 62 | if !found { |
| 27 | 63 |
| --- internal/api/middleware.go | |
| +++ internal/api/middleware.go | |
| @@ -1,26 +1,62 @@ | |
| 1 | package api |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "strings" |
| 6 | ) |
| 7 | |
| 8 | func (s *Server) authMiddleware(next http.Handler) http.Handler { |
| 9 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 10 | token := bearerToken(r) |
| 11 | if token == "" { |
| 12 | writeError(w, http.StatusUnauthorized, "missing authorization header") |
| 13 | return |
| 14 | } |
| 15 | if _, ok := s.tokens[token]; !ok { |
| 16 | writeError(w, http.StatusUnauthorized, "invalid token") |
| 17 | return |
| 18 | } |
| 19 | next.ServeHTTP(w, r) |
| 20 | }) |
| 21 | } |
| 22 | |
| 23 | func bearerToken(r *http.Request) string { |
| 24 | auth := r.Header.Get("Authorization") |
| 25 | token, found := strings.CutPrefix(auth, "Bearer ") |
| 26 | if !found { |
| 27 |
| --- internal/api/middleware.go | |
| +++ internal/api/middleware.go | |
| @@ -1,26 +1,62 @@ | |
| 1 | package api |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "strings" |
| 7 | |
| 8 | "github.com/conflicthq/scuttlebot/internal/auth" |
| 9 | ) |
| 10 | |
| 11 | type ctxKey string |
| 12 | |
| 13 | const ctxAPIKey ctxKey = "apikey" |
| 14 | |
| 15 | // apiKeyFromContext returns the authenticated APIKey from the request context, |
| 16 | // or nil if not authenticated. |
| 17 | func apiKeyFromContext(ctx context.Context) *auth.APIKey { |
| 18 | k, _ := ctx.Value(ctxAPIKey).(*auth.APIKey) |
| 19 | return k |
| 20 | } |
| 21 | |
| 22 | // authMiddleware validates the Bearer token and injects the APIKey into context. |
| 23 | func (s *Server) authMiddleware(next http.Handler) http.Handler { |
| 24 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 25 | token := bearerToken(r) |
| 26 | if token == "" { |
| 27 | writeError(w, http.StatusUnauthorized, "missing authorization header") |
| 28 | return |
| 29 | } |
| 30 | key := s.apiKeys.Lookup(token) |
| 31 | if key == nil { |
| 32 | writeError(w, http.StatusUnauthorized, "invalid token") |
| 33 | return |
| 34 | } |
| 35 | // Update last-used timestamp in the background. |
| 36 | go s.apiKeys.TouchLastUsed(key.ID) |
| 37 | |
| 38 | ctx := context.WithValue(r.Context(), ctxAPIKey, key) |
| 39 | next.ServeHTTP(w, r.WithContext(ctx)) |
| 40 | }) |
| 41 | } |
| 42 | |
| 43 | // requireScope returns middleware that rejects requests without the given scope. |
| 44 | func (s *Server) requireScope(scope auth.Scope, next http.HandlerFunc) http.HandlerFunc { |
| 45 | return func(w http.ResponseWriter, r *http.Request) { |
| 46 | key := apiKeyFromContext(r.Context()) |
| 47 | if key == nil { |
| 48 | writeError(w, http.StatusUnauthorized, "missing authentication") |
| 49 | return |
| 50 | } |
| 51 | if !key.HasScope(scope) { |
| 52 | writeError(w, http.StatusForbidden, "insufficient scope: requires "+string(scope)) |
| 53 | return |
| 54 | } |
| 55 | next(w, r) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | func bearerToken(r *http.Request) string { |
| 60 | auth := r.Header.Get("Authorization") |
| 61 | token, found := strings.CutPrefix(auth, "Bearer ") |
| 62 | if !found { |
| 63 |
+71
-5
| --- internal/api/policies.go | ||
| +++ internal/api/policies.go | ||
| @@ -42,16 +42,24 @@ | ||
| 42 | 42 | Rotation string `json:"rotation"` // "none" | "daily" | "weekly" | "size" |
| 43 | 43 | MaxSizeMB int `json:"max_size_mb"` // size rotation threshold (MiB); 0 = unlimited |
| 44 | 44 | PerChannel bool `json:"per_channel"` // separate file per channel |
| 45 | 45 | MaxAgeDays int `json:"max_age_days"` // prune rotated files older than N days; 0 = keep all |
| 46 | 46 | } |
| 47 | + | |
| 48 | +// ChannelDisplayConfig holds per-channel rendering preferences. | |
| 49 | +type ChannelDisplayConfig struct { | |
| 50 | + MirrorDetail string `json:"mirror_detail,omitempty"` // "full", "compact", "minimal" | |
| 51 | + RenderMode string `json:"render_mode,omitempty"` // "rich", "text" | |
| 52 | +} | |
| 47 | 53 | |
| 48 | 54 | // BridgePolicy configures bridge-specific UI/relay behavior. |
| 49 | 55 | type BridgePolicy struct { |
| 50 | 56 | // WebUserTTLMinutes controls how long HTTP bridge sender nicks remain |
| 51 | 57 | // visible in the channel user list after their last post. |
| 52 | 58 | WebUserTTLMinutes int `json:"web_user_ttl_minutes"` |
| 59 | + // ChannelDisplay holds per-channel rendering config. | |
| 60 | + ChannelDisplay map[string]ChannelDisplayConfig `json:"channel_display,omitempty"` | |
| 53 | 61 | } |
| 54 | 62 | |
| 55 | 63 | // PolicyLLMBackend stores an LLM backend configuration in the policy store. |
| 56 | 64 | // This allows backends to be added and edited from the web UI rather than |
| 57 | 65 | // requiring a change to scuttlebot.yaml. |
| @@ -68,18 +76,32 @@ | ||
| 68 | 76 | AWSSecretKey string `json:"aws_secret_key,omitempty"` |
| 69 | 77 | Allow []string `json:"allow,omitempty"` |
| 70 | 78 | Block []string `json:"block,omitempty"` |
| 71 | 79 | Default bool `json:"default,omitempty"` |
| 72 | 80 | } |
| 81 | + | |
| 82 | +// ROETemplate is a rules-of-engagement template. | |
| 83 | +type ROETemplate struct { | |
| 84 | + Name string `json:"name"` | |
| 85 | + Description string `json:"description,omitempty"` | |
| 86 | + Channels []string `json:"channels,omitempty"` | |
| 87 | + Permissions []string `json:"permissions,omitempty"` | |
| 88 | + RateLimit struct { | |
| 89 | + MessagesPerSecond float64 `json:"messages_per_second,omitempty"` | |
| 90 | + Burst int `json:"burst,omitempty"` | |
| 91 | + } `json:"rate_limit,omitempty"` | |
| 92 | +} | |
| 73 | 93 | |
| 74 | 94 | // Policies is the full mutable settings blob, persisted to policies.json. |
| 75 | 95 | type Policies struct { |
| 76 | - Behaviors []BehaviorConfig `json:"behaviors"` | |
| 77 | - AgentPolicy AgentPolicy `json:"agent_policy"` | |
| 78 | - Bridge BridgePolicy `json:"bridge"` | |
| 79 | - Logging LoggingPolicy `json:"logging"` | |
| 80 | - LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"` | |
| 96 | + Behaviors []BehaviorConfig `json:"behaviors"` | |
| 97 | + AgentPolicy AgentPolicy `json:"agent_policy"` | |
| 98 | + Bridge BridgePolicy `json:"bridge"` | |
| 99 | + Logging LoggingPolicy `json:"logging"` | |
| 100 | + LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"` | |
| 101 | + ROETemplates []ROETemplate `json:"roe_templates,omitempty"` | |
| 102 | + OnJoinMessages map[string]string `json:"on_join_messages,omitempty"` // channel → message template | |
| 81 | 103 | } |
| 82 | 104 | |
| 83 | 105 | // defaultBehaviors lists every built-in bot with conservative defaults (disabled). |
| 84 | 106 | var defaultBehaviors = []BehaviorConfig{ |
| 85 | 107 | { |
| @@ -151,10 +173,42 @@ | ||
| 151 | 173 | Description: "Acts on sentinel incident reports — issues warnings, mutes, or kicks based on severity. Operators can also issue direct commands via DM.", |
| 152 | 174 | Nick: "steward", |
| 153 | 175 | JoinAllChannels: true, |
| 154 | 176 | }, |
| 155 | 177 | } |
| 178 | + | |
| 179 | +// BotCommand describes a single command a bot responds to. | |
| 180 | +type BotCommand struct { | |
| 181 | + Command string `json:"command"` | |
| 182 | + Usage string `json:"usage"` | |
| 183 | + Description string `json:"description"` | |
| 184 | +} | |
| 185 | + | |
| 186 | +// botCommands maps bot ID to its available commands. | |
| 187 | +var botCommands = map[string][]BotCommand{ | |
| 188 | + "oracle": { | |
| 189 | + {Command: "summarize", Usage: "summarize #channel [last=N] [format=toon|json]", Description: "Summarize recent channel activity using an LLM."}, | |
| 190 | + }, | |
| 191 | + "scroll": { | |
| 192 | + {Command: "replay", Usage: "replay #channel [last=N] [since=<unix_ms>]", Description: "Replay recent channel history via DM."}, | |
| 193 | + }, | |
| 194 | + "steward": { | |
| 195 | + {Command: "mute", Usage: "mute <nick> [duration]", Description: "Mute a nick in the current channel."}, | |
| 196 | + {Command: "unmute", Usage: "unmute <nick>", Description: "Remove mute from a nick."}, | |
| 197 | + {Command: "kick", Usage: "kick <nick> [reason]", Description: "Kick a nick from the current channel."}, | |
| 198 | + {Command: "warn", Usage: "warn <nick> <message>", Description: "Send a warning notice to a nick."}, | |
| 199 | + }, | |
| 200 | + "warden": { | |
| 201 | + {Command: "status", Usage: "status", Description: "Show warden rate-limit status for all tracked nicks."}, | |
| 202 | + }, | |
| 203 | + "snitch": { | |
| 204 | + {Command: "status", Usage: "status", Description: "Show snitch monitoring status and alert history."}, | |
| 205 | + }, | |
| 206 | + "herald": { | |
| 207 | + {Command: "announce", Usage: "announce #channel <message>", Description: "Post an announcement to a channel."}, | |
| 208 | + }, | |
| 209 | +} | |
| 156 | 210 | |
| 157 | 211 | // PolicyStore persists Policies to a JSON file or database. |
| 158 | 212 | type PolicyStore struct { |
| 159 | 213 | mu sync.RWMutex |
| 160 | 214 | path string |
| @@ -227,10 +281,12 @@ | ||
| 227 | 281 | } |
| 228 | 282 | ps.data.AgentPolicy = p.AgentPolicy |
| 229 | 283 | ps.data.Bridge = p.Bridge |
| 230 | 284 | ps.data.Logging = p.Logging |
| 231 | 285 | ps.data.LLMBackends = p.LLMBackends |
| 286 | + ps.data.ROETemplates = p.ROETemplates | |
| 287 | + ps.data.OnJoinMessages = p.OnJoinMessages | |
| 232 | 288 | return nil |
| 233 | 289 | } |
| 234 | 290 | |
| 235 | 291 | func (ps *PolicyStore) save() error { |
| 236 | 292 | raw, err := json.MarshalIndent(ps.data, "", " ") |
| @@ -337,10 +393,20 @@ | ||
| 337 | 393 | |
| 338 | 394 | // Merge LLM backends if provided. |
| 339 | 395 | if patch.LLMBackends != nil { |
| 340 | 396 | ps.data.LLMBackends = patch.LLMBackends |
| 341 | 397 | } |
| 398 | + | |
| 399 | + // Merge ROE templates if provided. | |
| 400 | + if patch.ROETemplates != nil { | |
| 401 | + ps.data.ROETemplates = patch.ROETemplates | |
| 402 | + } | |
| 403 | + | |
| 404 | + // Merge on-join messages if provided. | |
| 405 | + if patch.OnJoinMessages != nil { | |
| 406 | + ps.data.OnJoinMessages = patch.OnJoinMessages | |
| 407 | + } | |
| 342 | 408 | |
| 343 | 409 | ps.normalize(&ps.data) |
| 344 | 410 | if err := ps.save(); err != nil { |
| 345 | 411 | return err |
| 346 | 412 | } |
| 347 | 413 |
| --- internal/api/policies.go | |
| +++ internal/api/policies.go | |
| @@ -42,16 +42,24 @@ | |
| 42 | Rotation string `json:"rotation"` // "none" | "daily" | "weekly" | "size" |
| 43 | MaxSizeMB int `json:"max_size_mb"` // size rotation threshold (MiB); 0 = unlimited |
| 44 | PerChannel bool `json:"per_channel"` // separate file per channel |
| 45 | MaxAgeDays int `json:"max_age_days"` // prune rotated files older than N days; 0 = keep all |
| 46 | } |
| 47 | |
| 48 | // BridgePolicy configures bridge-specific UI/relay behavior. |
| 49 | type BridgePolicy struct { |
| 50 | // WebUserTTLMinutes controls how long HTTP bridge sender nicks remain |
| 51 | // visible in the channel user list after their last post. |
| 52 | WebUserTTLMinutes int `json:"web_user_ttl_minutes"` |
| 53 | } |
| 54 | |
| 55 | // PolicyLLMBackend stores an LLM backend configuration in the policy store. |
| 56 | // This allows backends to be added and edited from the web UI rather than |
| 57 | // requiring a change to scuttlebot.yaml. |
| @@ -68,18 +76,32 @@ | |
| 68 | AWSSecretKey string `json:"aws_secret_key,omitempty"` |
| 69 | Allow []string `json:"allow,omitempty"` |
| 70 | Block []string `json:"block,omitempty"` |
| 71 | Default bool `json:"default,omitempty"` |
| 72 | } |
| 73 | |
| 74 | // Policies is the full mutable settings blob, persisted to policies.json. |
| 75 | type Policies struct { |
| 76 | Behaviors []BehaviorConfig `json:"behaviors"` |
| 77 | AgentPolicy AgentPolicy `json:"agent_policy"` |
| 78 | Bridge BridgePolicy `json:"bridge"` |
| 79 | Logging LoggingPolicy `json:"logging"` |
| 80 | LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"` |
| 81 | } |
| 82 | |
| 83 | // defaultBehaviors lists every built-in bot with conservative defaults (disabled). |
| 84 | var defaultBehaviors = []BehaviorConfig{ |
| 85 | { |
| @@ -151,10 +173,42 @@ | |
| 151 | Description: "Acts on sentinel incident reports — issues warnings, mutes, or kicks based on severity. Operators can also issue direct commands via DM.", |
| 152 | Nick: "steward", |
| 153 | JoinAllChannels: true, |
| 154 | }, |
| 155 | } |
| 156 | |
| 157 | // PolicyStore persists Policies to a JSON file or database. |
| 158 | type PolicyStore struct { |
| 159 | mu sync.RWMutex |
| 160 | path string |
| @@ -227,10 +281,12 @@ | |
| 227 | } |
| 228 | ps.data.AgentPolicy = p.AgentPolicy |
| 229 | ps.data.Bridge = p.Bridge |
| 230 | ps.data.Logging = p.Logging |
| 231 | ps.data.LLMBackends = p.LLMBackends |
| 232 | return nil |
| 233 | } |
| 234 | |
| 235 | func (ps *PolicyStore) save() error { |
| 236 | raw, err := json.MarshalIndent(ps.data, "", " ") |
| @@ -337,10 +393,20 @@ | |
| 337 | |
| 338 | // Merge LLM backends if provided. |
| 339 | if patch.LLMBackends != nil { |
| 340 | ps.data.LLMBackends = patch.LLMBackends |
| 341 | } |
| 342 | |
| 343 | ps.normalize(&ps.data) |
| 344 | if err := ps.save(); err != nil { |
| 345 | return err |
| 346 | } |
| 347 |
| --- internal/api/policies.go | |
| +++ internal/api/policies.go | |
| @@ -42,16 +42,24 @@ | |
| 42 | Rotation string `json:"rotation"` // "none" | "daily" | "weekly" | "size" |
| 43 | MaxSizeMB int `json:"max_size_mb"` // size rotation threshold (MiB); 0 = unlimited |
| 44 | PerChannel bool `json:"per_channel"` // separate file per channel |
| 45 | MaxAgeDays int `json:"max_age_days"` // prune rotated files older than N days; 0 = keep all |
| 46 | } |
| 47 | |
| 48 | // ChannelDisplayConfig holds per-channel rendering preferences. |
| 49 | type ChannelDisplayConfig struct { |
| 50 | MirrorDetail string `json:"mirror_detail,omitempty"` // "full", "compact", "minimal" |
| 51 | RenderMode string `json:"render_mode,omitempty"` // "rich", "text" |
| 52 | } |
| 53 | |
| 54 | // BridgePolicy configures bridge-specific UI/relay behavior. |
| 55 | type BridgePolicy struct { |
| 56 | // WebUserTTLMinutes controls how long HTTP bridge sender nicks remain |
| 57 | // visible in the channel user list after their last post. |
| 58 | WebUserTTLMinutes int `json:"web_user_ttl_minutes"` |
| 59 | // ChannelDisplay holds per-channel rendering config. |
| 60 | ChannelDisplay map[string]ChannelDisplayConfig `json:"channel_display,omitempty"` |
| 61 | } |
| 62 | |
| 63 | // PolicyLLMBackend stores an LLM backend configuration in the policy store. |
| 64 | // This allows backends to be added and edited from the web UI rather than |
| 65 | // requiring a change to scuttlebot.yaml. |
| @@ -68,18 +76,32 @@ | |
| 76 | AWSSecretKey string `json:"aws_secret_key,omitempty"` |
| 77 | Allow []string `json:"allow,omitempty"` |
| 78 | Block []string `json:"block,omitempty"` |
| 79 | Default bool `json:"default,omitempty"` |
| 80 | } |
| 81 | |
| 82 | // ROETemplate is a rules-of-engagement template. |
| 83 | type ROETemplate struct { |
| 84 | Name string `json:"name"` |
| 85 | Description string `json:"description,omitempty"` |
| 86 | Channels []string `json:"channels,omitempty"` |
| 87 | Permissions []string `json:"permissions,omitempty"` |
| 88 | RateLimit struct { |
| 89 | MessagesPerSecond float64 `json:"messages_per_second,omitempty"` |
| 90 | Burst int `json:"burst,omitempty"` |
| 91 | } `json:"rate_limit,omitempty"` |
| 92 | } |
| 93 | |
| 94 | // Policies is the full mutable settings blob, persisted to policies.json. |
| 95 | type Policies struct { |
| 96 | Behaviors []BehaviorConfig `json:"behaviors"` |
| 97 | AgentPolicy AgentPolicy `json:"agent_policy"` |
| 98 | Bridge BridgePolicy `json:"bridge"` |
| 99 | Logging LoggingPolicy `json:"logging"` |
| 100 | LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"` |
| 101 | ROETemplates []ROETemplate `json:"roe_templates,omitempty"` |
| 102 | OnJoinMessages map[string]string `json:"on_join_messages,omitempty"` // channel → message template |
| 103 | } |
| 104 | |
| 105 | // defaultBehaviors lists every built-in bot with conservative defaults (disabled). |
| 106 | var defaultBehaviors = []BehaviorConfig{ |
| 107 | { |
| @@ -151,10 +173,42 @@ | |
| 173 | Description: "Acts on sentinel incident reports — issues warnings, mutes, or kicks based on severity. Operators can also issue direct commands via DM.", |
| 174 | Nick: "steward", |
| 175 | JoinAllChannels: true, |
| 176 | }, |
| 177 | } |
| 178 | |
| 179 | // BotCommand describes a single command a bot responds to. |
| 180 | type BotCommand struct { |
| 181 | Command string `json:"command"` |
| 182 | Usage string `json:"usage"` |
| 183 | Description string `json:"description"` |
| 184 | } |
| 185 | |
| 186 | // botCommands maps bot ID to its available commands. |
| 187 | var botCommands = map[string][]BotCommand{ |
| 188 | "oracle": { |
| 189 | {Command: "summarize", Usage: "summarize #channel [last=N] [format=toon|json]", Description: "Summarize recent channel activity using an LLM."}, |
| 190 | }, |
| 191 | "scroll": { |
| 192 | {Command: "replay", Usage: "replay #channel [last=N] [since=<unix_ms>]", Description: "Replay recent channel history via DM."}, |
| 193 | }, |
| 194 | "steward": { |
| 195 | {Command: "mute", Usage: "mute <nick> [duration]", Description: "Mute a nick in the current channel."}, |
| 196 | {Command: "unmute", Usage: "unmute <nick>", Description: "Remove mute from a nick."}, |
| 197 | {Command: "kick", Usage: "kick <nick> [reason]", Description: "Kick a nick from the current channel."}, |
| 198 | {Command: "warn", Usage: "warn <nick> <message>", Description: "Send a warning notice to a nick."}, |
| 199 | }, |
| 200 | "warden": { |
| 201 | {Command: "status", Usage: "status", Description: "Show warden rate-limit status for all tracked nicks."}, |
| 202 | }, |
| 203 | "snitch": { |
| 204 | {Command: "status", Usage: "status", Description: "Show snitch monitoring status and alert history."}, |
| 205 | }, |
| 206 | "herald": { |
| 207 | {Command: "announce", Usage: "announce #channel <message>", Description: "Post an announcement to a channel."}, |
| 208 | }, |
| 209 | } |
| 210 | |
| 211 | // PolicyStore persists Policies to a JSON file or database. |
| 212 | type PolicyStore struct { |
| 213 | mu sync.RWMutex |
| 214 | path string |
| @@ -227,10 +281,12 @@ | |
| 281 | } |
| 282 | ps.data.AgentPolicy = p.AgentPolicy |
| 283 | ps.data.Bridge = p.Bridge |
| 284 | ps.data.Logging = p.Logging |
| 285 | ps.data.LLMBackends = p.LLMBackends |
| 286 | ps.data.ROETemplates = p.ROETemplates |
| 287 | ps.data.OnJoinMessages = p.OnJoinMessages |
| 288 | return nil |
| 289 | } |
| 290 | |
| 291 | func (ps *PolicyStore) save() error { |
| 292 | raw, err := json.MarshalIndent(ps.data, "", " ") |
| @@ -337,10 +393,20 @@ | |
| 393 | |
| 394 | // Merge LLM backends if provided. |
| 395 | if patch.LLMBackends != nil { |
| 396 | ps.data.LLMBackends = patch.LLMBackends |
| 397 | } |
| 398 | |
| 399 | // Merge ROE templates if provided. |
| 400 | if patch.ROETemplates != nil { |
| 401 | ps.data.ROETemplates = patch.ROETemplates |
| 402 | } |
| 403 | |
| 404 | // Merge on-join messages if provided. |
| 405 | if patch.OnJoinMessages != nil { |
| 406 | ps.data.OnJoinMessages = patch.OnJoinMessages |
| 407 | } |
| 408 | |
| 409 | ps.normalize(&ps.data) |
| 410 | if err := ps.save(); err != nil { |
| 411 | return err |
| 412 | } |
| 413 |
+88
-60
| --- internal/api/server.go | ||
| +++ internal/api/server.go | ||
| @@ -7,18 +7,19 @@ | ||
| 7 | 7 | |
| 8 | 8 | import ( |
| 9 | 9 | "log/slog" |
| 10 | 10 | "net/http" |
| 11 | 11 | |
| 12 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 12 | 13 | "github.com/conflicthq/scuttlebot/internal/config" |
| 13 | 14 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 14 | 15 | ) |
| 15 | 16 | |
| 16 | 17 | // Server is the scuttlebot HTTP API server. |
| 17 | 18 | type Server struct { |
| 18 | 19 | registry *registry.Registry |
| 19 | - tokens map[string]struct{} | |
| 20 | + apiKeys *auth.APIKeyStore | |
| 20 | 21 | log *slog.Logger |
| 21 | 22 | bridge chatBridge // nil if bridge is disabled |
| 22 | 23 | policies *PolicyStore // nil if not configured |
| 23 | 24 | admins adminStore // nil if not configured |
| 24 | 25 | llmCfg *config.LLMConfig // nil if no LLM backends configured |
| @@ -31,18 +32,14 @@ | ||
| 31 | 32 | // New creates a new API Server. Pass nil for b to disable the chat bridge. |
| 32 | 33 | // Pass nil for admins to disable admin authentication endpoints. |
| 33 | 34 | // Pass nil for llmCfg to disable AI/LLM management endpoints. |
| 34 | 35 | // Pass nil for topo to disable topology provisioning endpoints. |
| 35 | 36 | // Pass nil for cfgStore to disable config read/write endpoints. |
| 36 | -func New(reg *registry.Registry, tokens []string, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, cfgStore *ConfigStore, tlsDomain string, log *slog.Logger) *Server { | |
| 37 | - tokenSet := make(map[string]struct{}, len(tokens)) | |
| 38 | - for _, t := range tokens { | |
| 39 | - tokenSet[t] = struct{}{} | |
| 40 | - } | |
| 37 | +func New(reg *registry.Registry, apiKeys *auth.APIKeyStore, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, cfgStore *ConfigStore, tlsDomain string, log *slog.Logger) *Server { | |
| 41 | 38 | return &Server{ |
| 42 | 39 | registry: reg, |
| 43 | - tokens: tokenSet, | |
| 40 | + apiKeys: apiKeys, | |
| 44 | 41 | log: log, |
| 45 | 42 | bridge: b, |
| 46 | 43 | policies: ps, |
| 47 | 44 | admins: admins, |
| 48 | 45 | llmCfg: llmCfg, |
| @@ -53,65 +50,96 @@ | ||
| 53 | 50 | } |
| 54 | 51 | } |
| 55 | 52 | |
| 56 | 53 | // Handler returns the HTTP handler with all routes registered. |
| 57 | 54 | // /v1/ routes require a valid Bearer token. /ui/ is served unauthenticated. |
| 55 | +// Scoped routes additionally check the API key's scopes. | |
| 58 | 56 | func (s *Server) Handler() http.Handler { |
| 59 | 57 | apiMux := http.NewServeMux() |
| 60 | - apiMux.HandleFunc("GET /v1/status", s.handleStatus) | |
| 61 | - apiMux.HandleFunc("GET /v1/metrics", s.handleMetrics) | |
| 62 | - if s.policies != nil { | |
| 63 | - apiMux.HandleFunc("GET /v1/settings", s.handleGetSettings) | |
| 64 | - apiMux.HandleFunc("GET /v1/settings/policies", s.handleGetPolicies) | |
| 65 | - apiMux.HandleFunc("PUT /v1/settings/policies", s.handlePutPolicies) | |
| 66 | - apiMux.HandleFunc("PATCH /v1/settings/policies", s.handlePatchPolicies) | |
| 67 | - } | |
| 68 | - apiMux.HandleFunc("GET /v1/agents", s.handleListAgents) | |
| 69 | - apiMux.HandleFunc("GET /v1/agents/{nick}", s.handleGetAgent) | |
| 70 | - apiMux.HandleFunc("PATCH /v1/agents/{nick}", s.handleUpdateAgent) | |
| 71 | - apiMux.HandleFunc("POST /v1/agents/register", s.handleRegister) | |
| 72 | - apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.handleRotate) | |
| 73 | - apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.handleAdopt) | |
| 74 | - apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.handleRevoke) | |
| 75 | - apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.handleDelete) | |
| 76 | - if s.bridge != nil { | |
| 77 | - apiMux.HandleFunc("GET /v1/channels", s.handleListChannels) | |
| 78 | - apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.handleJoinChannel) | |
| 79 | - apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.handleDeleteChannel) | |
| 80 | - apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.handleChannelMessages) | |
| 81 | - apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.handleSendMessage) | |
| 82 | - apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.handleChannelPresence) | |
| 83 | - apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.handleChannelUsers) | |
| 84 | - } | |
| 85 | - if s.topoMgr != nil { | |
| 86 | - apiMux.HandleFunc("POST /v1/channels", s.handleProvisionChannel) | |
| 87 | - apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.handleDropChannel) | |
| 88 | - apiMux.HandleFunc("GET /v1/topology", s.handleGetTopology) | |
| 89 | - } | |
| 90 | - if s.cfgStore != nil { | |
| 91 | - apiMux.HandleFunc("GET /v1/config", s.handleGetConfig) | |
| 92 | - apiMux.HandleFunc("PUT /v1/config", s.handlePutConfig) | |
| 93 | - apiMux.HandleFunc("GET /v1/config/history", s.handleGetConfigHistory) | |
| 94 | - apiMux.HandleFunc("GET /v1/config/history/{filename}", s.handleGetConfigHistoryEntry) | |
| 95 | - } | |
| 96 | - | |
| 97 | - if s.admins != nil { | |
| 98 | - apiMux.HandleFunc("GET /v1/admins", s.handleAdminList) | |
| 99 | - apiMux.HandleFunc("POST /v1/admins", s.handleAdminAdd) | |
| 100 | - apiMux.HandleFunc("DELETE /v1/admins/{username}", s.handleAdminRemove) | |
| 101 | - apiMux.HandleFunc("PUT /v1/admins/{username}/password", s.handleAdminSetPassword) | |
| 102 | - } | |
| 103 | - | |
| 104 | - // LLM / AI gateway endpoints. | |
| 105 | - apiMux.HandleFunc("GET /v1/llm/backends", s.handleLLMBackends) | |
| 106 | - apiMux.HandleFunc("POST /v1/llm/backends", s.handleLLMBackendCreate) | |
| 107 | - apiMux.HandleFunc("PUT /v1/llm/backends/{name}", s.handleLLMBackendUpdate) | |
| 108 | - apiMux.HandleFunc("DELETE /v1/llm/backends/{name}", s.handleLLMBackendDelete) | |
| 109 | - apiMux.HandleFunc("GET /v1/llm/backends/{name}/models", s.handleLLMModels) | |
| 110 | - apiMux.HandleFunc("POST /v1/llm/discover", s.handleLLMDiscover) | |
| 111 | - apiMux.HandleFunc("GET /v1/llm/known", s.handleLLMKnown) | |
| 112 | - apiMux.HandleFunc("POST /v1/llm/complete", s.handleLLMComplete) | |
| 58 | + | |
| 59 | + // Read-scope: status, metrics (also accessible with any scope via admin). | |
| 60 | + apiMux.HandleFunc("GET /v1/status", s.requireScope(auth.ScopeRead, s.handleStatus)) | |
| 61 | + apiMux.HandleFunc("GET /v1/metrics", s.requireScope(auth.ScopeRead, s.handleMetrics)) | |
| 62 | + | |
| 63 | + // Policies — admin scope. | |
| 64 | + if s.policies != nil { | |
| 65 | + apiMux.HandleFunc("GET /v1/settings", s.requireScope(auth.ScopeRead, s.handleGetSettings)) | |
| 66 | + apiMux.HandleFunc("GET /v1/settings/policies", s.requireScope(auth.ScopeRead, s.handleGetPolicies)) | |
| 67 | + apiMux.HandleFunc("PUT /v1/settings/policies", s.requireScope(auth.ScopeAdmin, s.handlePutPolicies)) | |
| 68 | + apiMux.HandleFunc("PATCH /v1/settings/policies", s.requireScope(auth.ScopeAdmin, s.handlePatchPolicies)) | |
| 69 | + } | |
| 70 | + | |
| 71 | + // Agents — agents scope. | |
| 72 | + apiMux.HandleFunc("GET /v1/agents", s.requireScope(auth.ScopeAgents, s.handleListAgents)) | |
| 73 | + apiMux.HandleFunc("GET /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleGetAgent)) | |
| 74 | + apiMux.HandleFunc("PATCH /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleUpdateAgent)) | |
| 75 | + apiMux.HandleFunc("POST /v1/agents/register", s.requireScope(auth.ScopeAgents, s.handleRegister)) | |
| 76 | + apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.requireScope(auth.ScopeAgents, s.handleRotate)) | |
| 77 | + apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.requireScope(auth.ScopeAgents, s.handleAdopt)) | |
| 78 | + apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.requireScope(auth.ScopeAgents, s.handleRevoke)) | |
| 79 | + apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleDelete)) | |
| 80 | + 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)) | |
| 113 | 141 | |
| 114 | 142 | outer := http.NewServeMux() |
| 115 | 143 | outer.HandleFunc("POST /login", s.handleLogin) |
| 116 | 144 | outer.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { |
| 117 | 145 | http.Redirect(w, r, "/ui/", http.StatusFound) |
| 118 | 146 |
| --- 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 |
+4
-2
| --- internal/api/settings.go | ||
| +++ internal/api/settings.go | ||
| @@ -5,12 +5,13 @@ | ||
| 5 | 5 | |
| 6 | 6 | "github.com/conflicthq/scuttlebot/internal/config" |
| 7 | 7 | ) |
| 8 | 8 | |
| 9 | 9 | type settingsResponse struct { |
| 10 | - TLS tlsInfo `json:"tls"` | |
| 11 | - Policies Policies `json:"policies"` | |
| 10 | + TLS tlsInfo `json:"tls"` | |
| 11 | + Policies Policies `json:"policies"` | |
| 12 | + BotCommands map[string][]BotCommand `json:"bot_commands,omitempty"` | |
| 12 | 13 | } |
| 13 | 14 | |
| 14 | 15 | type tlsInfo struct { |
| 15 | 16 | Enabled bool `json:"enabled"` |
| 16 | 17 | Domain string `json:"domain,omitempty"` |
| @@ -33,10 +34,11 @@ | ||
| 33 | 34 | cfg := s.cfgStore.Get() |
| 34 | 35 | resp.Policies.AgentPolicy = toAPIAgentPolicy(cfg.AgentPolicy) |
| 35 | 36 | resp.Policies.Logging = toAPILogging(cfg.Logging) |
| 36 | 37 | resp.Policies.Bridge.WebUserTTLMinutes = cfg.Bridge.WebUserTTLMinutes |
| 37 | 38 | } |
| 39 | + resp.BotCommands = botCommands | |
| 38 | 40 | writeJSON(w, http.StatusOK, resp) |
| 39 | 41 | } |
| 40 | 42 | |
| 41 | 43 | func toAPIAgentPolicy(c config.AgentPolicyConfig) AgentPolicy { |
| 42 | 44 | return AgentPolicy{ |
| 43 | 45 |
| --- internal/api/settings.go | |
| +++ internal/api/settings.go | |
| @@ -5,12 +5,13 @@ | |
| 5 | |
| 6 | "github.com/conflicthq/scuttlebot/internal/config" |
| 7 | ) |
| 8 | |
| 9 | type settingsResponse struct { |
| 10 | TLS tlsInfo `json:"tls"` |
| 11 | Policies Policies `json:"policies"` |
| 12 | } |
| 13 | |
| 14 | type tlsInfo struct { |
| 15 | Enabled bool `json:"enabled"` |
| 16 | Domain string `json:"domain,omitempty"` |
| @@ -33,10 +34,11 @@ | |
| 33 | cfg := s.cfgStore.Get() |
| 34 | resp.Policies.AgentPolicy = toAPIAgentPolicy(cfg.AgentPolicy) |
| 35 | resp.Policies.Logging = toAPILogging(cfg.Logging) |
| 36 | resp.Policies.Bridge.WebUserTTLMinutes = cfg.Bridge.WebUserTTLMinutes |
| 37 | } |
| 38 | writeJSON(w, http.StatusOK, resp) |
| 39 | } |
| 40 | |
| 41 | func toAPIAgentPolicy(c config.AgentPolicyConfig) AgentPolicy { |
| 42 | return AgentPolicy{ |
| 43 |
| --- internal/api/settings.go | |
| +++ internal/api/settings.go | |
| @@ -5,12 +5,13 @@ | |
| 5 | |
| 6 | "github.com/conflicthq/scuttlebot/internal/config" |
| 7 | ) |
| 8 | |
| 9 | type settingsResponse struct { |
| 10 | TLS tlsInfo `json:"tls"` |
| 11 | Policies Policies `json:"policies"` |
| 12 | BotCommands map[string][]BotCommand `json:"bot_commands,omitempty"` |
| 13 | } |
| 14 | |
| 15 | type tlsInfo struct { |
| 16 | Enabled bool `json:"enabled"` |
| 17 | Domain string `json:"domain,omitempty"` |
| @@ -33,10 +34,11 @@ | |
| 34 | cfg := s.cfgStore.Get() |
| 35 | resp.Policies.AgentPolicy = toAPIAgentPolicy(cfg.AgentPolicy) |
| 36 | resp.Policies.Logging = toAPILogging(cfg.Logging) |
| 37 | resp.Policies.Bridge.WebUserTTLMinutes = cfg.Bridge.WebUserTTLMinutes |
| 38 | } |
| 39 | resp.BotCommands = botCommands |
| 40 | writeJSON(w, http.StatusOK, resp) |
| 41 | } |
| 42 | |
| 43 | func toAPIAgentPolicy(c config.AgentPolicyConfig) AgentPolicy { |
| 44 | return AgentPolicy{ |
| 45 |
+401
-23
| --- internal/api/ui/index.html | ||
| +++ internal/api/ui/index.html | ||
| @@ -457,10 +457,11 @@ | ||
| 457 | 457 | <option value="revoked">revoked</option> |
| 458 | 458 | </select> |
| 459 | 459 | <div class="spacer"></div> |
| 460 | 460 | <span class="badge" id="agent-count" style="margin-right:4px">0</span> |
| 461 | 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> | |
| 462 | 463 | <button class="sm primary" onclick="openDrawer()">+ register agent</button> |
| 463 | 464 | </div> |
| 464 | 465 | <div id="agent-pagination" style="display:none;padding:4px 16px;font-size:12px;color:#8b949e;display:flex;align-items:center;gap:8px"> |
| 465 | 466 | <button class="sm" id="agent-prev" onclick="agentPage--;renderAgentTable()">← prev</button> |
| 466 | 467 | <span id="agent-page-info"></span> |
| @@ -485,10 +486,40 @@ | ||
| 485 | 486 | <button class="sm primary" onclick="quickJoin()">join</button> |
| 486 | 487 | </div> |
| 487 | 488 | </div> |
| 488 | 489 | <div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div> |
| 489 | 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> | |
| 490 | 521 | </div> |
| 491 | 522 | </div> |
| 492 | 523 | |
| 493 | 524 | <!-- CHAT --> |
| 494 | 525 | <div class="tab-pane" id="pane-chat"> |
| @@ -504,11 +535,11 @@ | ||
| 504 | 535 | <div class="chan-list" id="chan-list"></div> |
| 505 | 536 | </div> |
| 506 | 537 | <div class="sidebar-resize" id="resize-left" title="drag to resize"></div> |
| 507 | 538 | <div class="chat-main"> |
| 508 | 539 | <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> | |
| 510 | 541 | <div class="spacer"></div> |
| 511 | 542 | <span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span> |
| 512 | 543 | <select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()"> |
| 513 | 544 | <option value="">— pick a user —</option> |
| 514 | 545 | </select> |
| @@ -580,10 +611,40 @@ | ||
| 580 | 611 | <button type="submit" class="primary sm" style="margin-bottom:1px">add admin</button> |
| 581 | 612 | </form> |
| 582 | 613 | <div id="add-admin-result" style="margin-top:10px"></div> |
| 583 | 614 | </div> |
| 584 | 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> | |
| 585 | 646 | |
| 586 | 647 | <!-- tls --> |
| 587 | 648 | <div class="card" id="card-tls"> |
| 588 | 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> |
| 589 | 650 | <div class="card-body"> |
| @@ -605,10 +666,28 @@ | ||
| 605 | 666 | </div> |
| 606 | 667 | <div class="card-body" style="padding:0"> |
| 607 | 668 | <div id="behaviors-list"></div> |
| 608 | 669 | </div> |
| 609 | 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> | |
| 610 | 689 | |
| 611 | 690 | <!-- agent policy --> |
| 612 | 691 | <div class="card" id="card-agentpolicy"> |
| 613 | 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> |
| 614 | 693 | <div class="card-body"> |
| @@ -798,10 +877,31 @@ | ||
| 798 | 877 | </div> |
| 799 | 878 | <div class="setting-row"> |
| 800 | 879 | <div class="setting-label">IRC address</div> |
| 801 | 880 | <div class="setting-desc">Address Ergo listens on for IRC connections. Requires restart.</div> |
| 802 | 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> | |
| 803 | 903 | </div> |
| 804 | 904 | <div class="setting-row"> |
| 805 | 905 | <div class="setting-label">external mode</div> |
| 806 | 906 | <div class="setting-desc">Disable subprocess management — scuttlebot expects Ergo to already be running. Requires restart.</div> |
| 807 | 907 | <label style="display:flex;align-items:center;gap:6px;cursor:pointer"> |
| @@ -1578,11 +1678,11 @@ | ||
| 1578 | 1678 | const chs = (a.config?.channels||[]).map(c=>`<span class="tag ch">${esc(c)}</span>`).join(''); |
| 1579 | 1679 | const rev = a.revoked ? '<span class="tag revoked">revoked</span>' : ''; |
| 1580 | 1680 | const seen = a.last_seen ? relTime(a.last_seen) : 'never'; |
| 1581 | 1681 | const seenStyle = a.online ? 'color:#3fb950' : 'color:#8b949e'; |
| 1582 | 1682 | 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> | |
| 1584 | 1684 | <td><span class="tag type-${a.type}">${esc(a.type)}</span>${rev}</td> |
| 1585 | 1685 | <td>${chs||'<span style="color:#8b949e">—</span>'}</td> |
| 1586 | 1686 | <td style="white-space:nowrap;${seenStyle}">${seen}</td> |
| 1587 | 1687 | <td><div class="actions">${!a.revoked?` |
| 1588 | 1688 | <button class="sm" onclick="rotateAgent('${esc(a.nick)}')">rotate</button> |
| @@ -1590,11 +1690,11 @@ | ||
| 1590 | 1690 | <button class="sm danger" onclick="deleteAgent('${esc(a.nick)}')">delete</button></div></td> |
| 1591 | 1691 | </tr>`; |
| 1592 | 1692 | }); |
| 1593 | 1693 | renderTable('agents-container', null, rows, |
| 1594 | 1694 | 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','']); | |
| 1596 | 1696 | } |
| 1597 | 1697 | |
| 1598 | 1698 | async function revokeAgent(nick) { |
| 1599 | 1699 | if (!confirm(`Revoke "${nick}"? This cannot be undone.`)) return; |
| 1600 | 1700 | try { await api('POST', `/v1/agents/${nick}/revoke`); await loadAgents(); await loadStatus(); } |
| @@ -1603,10 +1703,31 @@ | ||
| 1603 | 1703 | async function deleteAgent(nick) { |
| 1604 | 1704 | if (!confirm(`Delete "${nick}"? This permanently removes the agent from the registry.`)) return; |
| 1605 | 1705 | try { await api('DELETE', `/v1/agents/${nick}`); await loadAgents(); await loadStatus(); } |
| 1606 | 1706 | catch(e) { alert('Delete failed: '+e.message); } |
| 1607 | 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 | +} | |
| 1608 | 1729 | async function rotateAgent(nick) { |
| 1609 | 1730 | try { |
| 1610 | 1731 | const creds = await api('POST', `/v1/agents/${nick}/rotate`); |
| 1611 | 1732 | // Show result in whichever drawer is relevant. |
| 1612 | 1733 | showCredentials(nick, creds, null, 'rotate'); |
| @@ -1726,10 +1847,19 @@ | ||
| 1726 | 1847 | allChannels = (data.channels || []).sort(); |
| 1727 | 1848 | renderChanList(); |
| 1728 | 1849 | } catch(e) { |
| 1729 | 1850 | document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>'; |
| 1730 | 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) {} | |
| 1731 | 1861 | } |
| 1732 | 1862 | |
| 1733 | 1863 | function renderChanList() { |
| 1734 | 1864 | const q = (document.getElementById('chan-search').value||'').toLowerCase(); |
| 1735 | 1865 | const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q)); |
| @@ -1764,10 +1894,138 @@ | ||
| 1764 | 1894 | await loadChanTab(); |
| 1765 | 1895 | renderChanSidebar((await api('GET','/v1/channels')).channels||[]); |
| 1766 | 1896 | } catch(e) { alert('Join failed: '+e.message); } |
| 1767 | 1897 | } |
| 1768 | 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 | +} | |
| 1769 | 2027 | |
| 1770 | 2028 | // --- chat --- |
| 1771 | 2029 | let chatChannel = null, chatSSE = null; |
| 1772 | 2030 | |
| 1773 | 2031 | async function loadChannels() { |
| @@ -1838,11 +2096,11 @@ | ||
| 1838 | 2096 | async function loadNicklist(ch) { |
| 1839 | 2097 | if (!ch) return; |
| 1840 | 2098 | try { |
| 1841 | 2099 | const slug = ch.replace(/^#/,''); |
| 1842 | 2100 | const data = await api('GET', `/v1/channels/${slug}/users`); |
| 1843 | - renderNicklist(data.users || []); | |
| 2101 | + renderNicklist(data.users || [], data.channel_modes || ''); | |
| 1844 | 2102 | } catch(e) {} |
| 1845 | 2103 | } |
| 1846 | 2104 | const SYSTEM_BOTS = new Set(['bridge','oracle','sentinel','steward','scribe','warden','snitch','herald','scroll','systembot','auditbot']); |
| 1847 | 2105 | const AGENT_PREFIXES = ['claude-','codex-','gemini-','openclaw-']; |
| 1848 | 2106 | |
| @@ -1861,24 +2119,35 @@ | ||
| 1861 | 2119 | if (tier === 0) return '@'; |
| 1862 | 2120 | if (tier === 1) return '+'; |
| 1863 | 2121 | return ''; |
| 1864 | 2122 | } |
| 1865 | 2123 | |
| 1866 | -function renderNicklist(users) { | |
| 2124 | +function renderNicklist(users, channelModes) { | |
| 1867 | 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); | |
| 1868 | 2128 | // 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); | |
| 1871 | 2131 | if (ta !== tb) return ta - tb; |
| 1872 | - return a.localeCompare(b); | |
| 2132 | + return a.nick.localeCompare(b.nick); | |
| 1873 | 2133 | }); |
| 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>`; | |
| 1879 | 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}` : ''; | |
| 1880 | 2149 | } |
| 1881 | 2150 | // Nick colors — deterministic hash over a palette |
| 1882 | 2151 | const NICK_PALETTE = ['#58a6ff','#3fb950','#ffa657','#d2a8ff','#56d364','#79c0ff','#ff7b72','#a5d6ff','#f0883e','#39d353']; |
| 1883 | 2152 | function nickColor(nick) { |
| 1884 | 2153 | let h = 0; |
| @@ -1892,14 +2161,16 @@ | ||
| 1892 | 2161 | let _chatUnread = 0; |
| 1893 | 2162 | |
| 1894 | 2163 | function appendMsg(msg, isHistory) { |
| 1895 | 2164 | const area = document.getElementById('chat-msgs'); |
| 1896 | 2165 | |
| 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". | |
| 1898 | 2167 | let displayNick = msg.nick; |
| 1899 | 2168 | 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') { | |
| 1901 | 2172 | const m = msg.text.match(/^\[([^\]]+)\] ([\s\S]*)$/); |
| 1902 | 2173 | if (m) { displayNick = m[1]; displayText = m[2]; } |
| 1903 | 2174 | } |
| 1904 | 2175 | |
| 1905 | 2176 | const atMs = new Date(msg.at).getTime(); |
| @@ -2542,10 +2813,73 @@ | ||
| 2542 | 2813 | try { |
| 2543 | 2814 | await api('PUT', `/v1/admins/${encodeURIComponent(username)}/password`, { password: pw }); |
| 2544 | 2815 | alert('Password updated.'); |
| 2545 | 2816 | } catch(e) { alert('Failed: ' + e.message); } |
| 2546 | 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 | +} | |
| 2547 | 2881 | |
| 2548 | 2882 | // --- AI / LLM tab --- |
| 2549 | 2883 | async function loadAI() { |
| 2550 | 2884 | await Promise.all([loadAIBackends(), loadAIKnown()]); |
| 2551 | 2885 | } |
| @@ -2899,10 +3233,41 @@ | ||
| 2899 | 3233 | if (body) body.style.display = ''; |
| 2900 | 3234 | } |
| 2901 | 3235 | |
| 2902 | 3236 | // --- settings / policies --- |
| 2903 | 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 | +} | |
| 2904 | 3269 | let _llmBackendNames = []; // cached backend names for oracle dropdown |
| 2905 | 3270 | |
| 2906 | 3271 | async function loadSettings() { |
| 2907 | 3272 | try { |
| 2908 | 3273 | const [s, backends] = await Promise.all([ |
| @@ -2910,15 +3275,18 @@ | ||
| 2910 | 3275 | api('GET', '/v1/llm/backends').catch(() => []), |
| 2911 | 3276 | ]); |
| 2912 | 3277 | _llmBackendNames = (backends || []).map(b => b.name); |
| 2913 | 3278 | renderTLSStatus(s.tls); |
| 2914 | 3279 | currentPolicies = s.policies; |
| 3280 | + _botCommands = s.bot_commands || {}; | |
| 2915 | 3281 | renderBehaviors(s.policies.behaviors || []); |
| 3282 | + renderOnJoinMessages(s.policies.on_join_messages || {}); | |
| 2916 | 3283 | renderAgentPolicy(s.policies.agent_policy || {}); |
| 2917 | 3284 | renderBridgePolicy(s.policies.bridge || {}); |
| 2918 | 3285 | renderLoggingPolicy(s.policies.logging || {}); |
| 2919 | 3286 | loadAdmins(); |
| 3287 | + loadAPIKeys(); | |
| 2920 | 3288 | loadConfigCards(); |
| 2921 | 3289 | } catch(e) { |
| 2922 | 3290 | document.getElementById('tls-badge').textContent = 'error'; |
| 2923 | 3291 | } |
| 2924 | 3292 | } |
| @@ -2973,10 +3341,14 @@ | ||
| 2973 | 3341 | ` : ''} |
| 2974 | 3342 | <span class="tag type-observer" style="font-size:11px;min-width:64px;text-align:center">${esc(b.nick)}</span> |
| 2975 | 3343 | </div> |
| 2976 | 3344 | </div> |
| 2977 | 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)} ${esc(c.usage)}">${esc(c.command)}</code>`).join('')} | |
| 3349 | + </div>` : ''} | |
| 2978 | 3350 | </div> |
| 2979 | 3351 | `).join(''); |
| 2980 | 3352 | } |
| 2981 | 3353 | |
| 2982 | 3354 | function onBehaviorToggle(id, enabled) { |
| @@ -3222,14 +3594,17 @@ | ||
| 3222 | 3594 | // general |
| 3223 | 3595 | document.getElementById('general-api-addr').value = cfg.api_addr || ''; |
| 3224 | 3596 | document.getElementById('general-mcp-addr').value = cfg.mcp_addr || ''; |
| 3225 | 3597 | // ergo |
| 3226 | 3598 | 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; | |
| 3231 | 3606 | // tls |
| 3232 | 3607 | const t = cfg.tls || {}; |
| 3233 | 3608 | document.getElementById('tls-domain').value = t.domain || ''; |
| 3234 | 3609 | document.getElementById('tls-email').value = t.email || ''; |
| 3235 | 3610 | document.getElementById('tls-allow-insecure').checked = !!t.allow_insecure; |
| @@ -3302,14 +3677,17 @@ | ||
| 3302 | 3677 | } |
| 3303 | 3678 | |
| 3304 | 3679 | function saveErgoConfig() { |
| 3305 | 3680 | saveConfigPatch({ |
| 3306 | 3681 | 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, | |
| 3311 | 3689 | } |
| 3312 | 3690 | }, 'ergo-save-result'); |
| 3313 | 3691 | } |
| 3314 | 3692 | |
| 3315 | 3693 | function saveTLSConfig() { |
| 3316 | 3694 | |
| 3317 | 3695 | 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)} ${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 |
+288
| --- a/internal/auth/apikeys.go | ||
| +++ b/internal/auth/apikeys.go | ||
| @@ -0,0 +1,288 @@ | ||
| 1 | +package auth | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "crypto/rand" | |
| 5 | + "crypto/sha256" | |
| 6 | + "encoding/hex" | |
| 7 | + "encoding/json" | |
| 8 | + "fmt" | |
| 9 | + "os" | |
| 10 | + "strings" | |
| 11 | + "sync" | |
| 12 | + "time" | |
| 13 | + | |
| 14 | + "github.com/oklog/ulid/v2" | |
| 15 | +) | |
| 16 | + | |
| 17 | +// Scope represents a permission scope for an API key. | |
| 18 | +type Scope string | |
| 19 | + | |
| 20 | +const ( | |
| 21 | + ScopeAdmin Scope = "admin" // full access | |
| 22 | + ScopeAgents Scope = "agents" // agent registration, rotation, revocation | |
| 23 | + ScopeChannels Scope = "channels" // channel CRUD, join, messages, presence | |
| 24 | + ScopeTopology Scope = "topology" // channel provisioning, topology management | |
| 25 | + ScopeBots Scope = "bots" // bot configuration, start/stop | |
| 26 | + ScopeConfig Scope = "config" // server config read/write | |
| 27 | + ScopeRead Scope = "read" // read-only access to all GET endpoints | |
| 28 | + ScopeChat Scope = "chat" // send/receive messages only | |
| 29 | +) | |
| 30 | + | |
| 31 | +// ValidScopes is the set of all recognised scopes. | |
| 32 | +var ValidScopes = map[Scope]bool{ | |
| 33 | + ScopeAdmin: true, ScopeAgents: true, ScopeChannels: true, | |
| 34 | + ScopeTopology: true, ScopeBots: true, ScopeConfig: true, | |
| 35 | + ScopeRead: true, ScopeChat: true, | |
| 36 | +} | |
| 37 | + | |
| 38 | +// APIKey is a single API key record. | |
| 39 | +type APIKey struct { | |
| 40 | + ID string `json:"id"` | |
| 41 | + Name string `json:"name"` | |
| 42 | + Hash string `json:"hash"` // SHA-256 of the plaintext token | |
| 43 | + Scopes []Scope `json:"scopes"` | |
| 44 | + CreatedAt time.Time `json:"created_at"` | |
| 45 | + LastUsed time.Time `json:"last_used,omitempty"` | |
| 46 | + ExpiresAt time.Time `json:"expires_at,omitempty"` // zero = never | |
| 47 | + Active bool `json:"active"` | |
| 48 | +} | |
| 49 | + | |
| 50 | +// HasScope reports whether the key has the given scope (or admin, which implies all). | |
| 51 | +func (k *APIKey) HasScope(s Scope) bool { | |
| 52 | + for _, scope := range k.Scopes { | |
| 53 | + if scope == ScopeAdmin || scope == s { | |
| 54 | + return true | |
| 55 | + } | |
| 56 | + } | |
| 57 | + return false | |
| 58 | +} | |
| 59 | + | |
| 60 | +// IsExpired reports whether the key has passed its expiry time. | |
| 61 | +func (k *APIKey) IsExpired() bool { | |
| 62 | + return !k.ExpiresAt.IsZero() && time.Now().After(k.ExpiresAt) | |
| 63 | +} | |
| 64 | + | |
| 65 | +// APIKeyStore persists API keys to a JSON file. | |
| 66 | +type APIKeyStore struct { | |
| 67 | + mu sync.RWMutex | |
| 68 | + path string | |
| 69 | + data []APIKey | |
| 70 | +} | |
| 71 | + | |
| 72 | +// NewAPIKeyStore loads (or creates) the API key store at the given path. | |
| 73 | +func NewAPIKeyStore(path string) (*APIKeyStore, error) { | |
| 74 | + s := &APIKeyStore{path: path} | |
| 75 | + if err := s.load(); err != nil { | |
| 76 | + return nil, err | |
| 77 | + } | |
| 78 | + return s, nil | |
| 79 | +} | |
| 80 | + | |
| 81 | +// Create generates a new API key with the given name and scopes. | |
| 82 | +// Returns the plaintext token (shown only once) and the stored key record. | |
| 83 | +func (s *APIKeyStore) Create(name string, scopes []Scope, expiresAt time.Time) (plaintext string, key APIKey, err error) { | |
| 84 | + s.mu.Lock() | |
| 85 | + defer s.mu.Unlock() | |
| 86 | + | |
| 87 | + token, err := genToken() | |
| 88 | + if err != nil { | |
| 89 | + return "", APIKey{}, fmt.Errorf("apikeys: generate token: %w", err) | |
| 90 | + } | |
| 91 | + | |
| 92 | + key = APIKey{ | |
| 93 | + ID: newULID(), | |
| 94 | + Name: name, | |
| 95 | + Hash: hashToken(token), | |
| 96 | + Scopes: scopes, | |
| 97 | + CreatedAt: time.Now().UTC(), | |
| 98 | + ExpiresAt: expiresAt, | |
| 99 | + Active: true, | |
| 100 | + } | |
| 101 | + s.data = append(s.data, key) | |
| 102 | + if err := s.save(); err != nil { | |
| 103 | + // Roll back. | |
| 104 | + s.data = s.data[:len(s.data)-1] | |
| 105 | + return "", APIKey{}, err | |
| 106 | + } | |
| 107 | + return token, key, nil | |
| 108 | +} | |
| 109 | + | |
| 110 | +// Insert adds a pre-built API key with a known plaintext token. | |
| 111 | +// Used for migrating the startup token into the store. | |
| 112 | +func (s *APIKeyStore) Insert(name, plaintext string, scopes []Scope) (APIKey, error) { | |
| 113 | + s.mu.Lock() | |
| 114 | + defer s.mu.Unlock() | |
| 115 | + | |
| 116 | + key := APIKey{ | |
| 117 | + ID: newULID(), | |
| 118 | + Name: name, | |
| 119 | + Hash: hashToken(plaintext), | |
| 120 | + Scopes: scopes, | |
| 121 | + CreatedAt: time.Now().UTC(), | |
| 122 | + Active: true, | |
| 123 | + } | |
| 124 | + s.data = append(s.data, key) | |
| 125 | + if err := s.save(); err != nil { | |
| 126 | + s.data = s.data[:len(s.data)-1] | |
| 127 | + return APIKey{}, err | |
| 128 | + } | |
| 129 | + return key, nil | |
| 130 | +} | |
| 131 | + | |
| 132 | +// Lookup finds an active, non-expired key by plaintext token. | |
| 133 | +// Returns nil if no match. | |
| 134 | +func (s *APIKeyStore) Lookup(token string) *APIKey { | |
| 135 | + hash := hashToken(token) | |
| 136 | + s.mu.RLock() | |
| 137 | + defer s.mu.RUnlock() | |
| 138 | + for i := range s.data { | |
| 139 | + if s.data[i].Hash == hash && s.data[i].Active && !s.data[i].IsExpired() { | |
| 140 | + k := s.data[i] | |
| 141 | + return &k | |
| 142 | + } | |
| 143 | + } | |
| 144 | + return nil | |
| 145 | +} | |
| 146 | + | |
| 147 | +// TouchLastUsed updates the last-used timestamp for a key by ID. | |
| 148 | +func (s *APIKeyStore) TouchLastUsed(id string) { | |
| 149 | + s.mu.Lock() | |
| 150 | + defer s.mu.Unlock() | |
| 151 | + for i := range s.data { | |
| 152 | + if s.data[i].ID == id { | |
| 153 | + s.data[i].LastUsed = time.Now().UTC() | |
| 154 | + _ = s.save() // best-effort persistence | |
| 155 | + return | |
| 156 | + } | |
| 157 | + } | |
| 158 | +} | |
| 159 | + | |
| 160 | +// Get returns a key by ID, or nil if not found. | |
| 161 | +func (s *APIKeyStore) Get(id string) *APIKey { | |
| 162 | + s.mu.RLock() | |
| 163 | + defer s.mu.RUnlock() | |
| 164 | + for i := range s.data { | |
| 165 | + if s.data[i].ID == id { | |
| 166 | + k := s.data[i] | |
| 167 | + return &k | |
| 168 | + } | |
| 169 | + } | |
| 170 | + return nil | |
| 171 | +} | |
| 172 | + | |
| 173 | +// List returns all keys (active and revoked). | |
| 174 | +func (s *APIKeyStore) List() []APIKey { | |
| 175 | + s.mu.RLock() | |
| 176 | + defer s.mu.RUnlock() | |
| 177 | + out := make([]APIKey, len(s.data)) | |
| 178 | + copy(out, s.data) | |
| 179 | + return out | |
| 180 | +} | |
| 181 | + | |
| 182 | +// Revoke deactivates a key by ID. | |
| 183 | +func (s *APIKeyStore) Revoke(id string) error { | |
| 184 | + s.mu.Lock() | |
| 185 | + defer s.mu.Unlock() | |
| 186 | + for i := range s.data { | |
| 187 | + if s.data[i].ID == id { | |
| 188 | + if !s.data[i].Active { | |
| 189 | + return fmt.Errorf("apikeys: key %q already revoked", id) | |
| 190 | + } | |
| 191 | + s.data[i].Active = false | |
| 192 | + return s.save() | |
| 193 | + } | |
| 194 | + } | |
| 195 | + return fmt.Errorf("apikeys: key %q not found", id) | |
| 196 | +} | |
| 197 | + | |
| 198 | +// Lookup (TokenValidator interface) reports whether the token is valid. | |
| 199 | +// Satisfies the mcp.TokenValidator interface. | |
| 200 | +func (s *APIKeyStore) ValidToken(token string) bool { | |
| 201 | + return s.Lookup(token) != nil | |
| 202 | +} | |
| 203 | + | |
| 204 | +// TestStore creates an in-memory APIKeyStore with a single admin-scope key | |
| 205 | +// for the given token. Intended for tests only — does not persist to disk. | |
| 206 | +func TestStore(token string) *APIKeyStore { | |
| 207 | + s := &APIKeyStore{path: "", data: []APIKey{{ | |
| 208 | + ID: "test-key", | |
| 209 | + Name: "test", | |
| 210 | + Hash: hashToken(token), | |
| 211 | + Scopes: []Scope{ScopeAdmin}, | |
| 212 | + CreatedAt: time.Now().UTC(), | |
| 213 | + Active: true, | |
| 214 | + }}} | |
| 215 | + return s | |
| 216 | +} | |
| 217 | + | |
| 218 | +// IsEmpty reports whether there are no keys. | |
| 219 | +func (s *APIKeyStore) IsEmpty() bool { | |
| 220 | + s.mu.RLock() | |
| 221 | + defer s.mu.RUnlock() | |
| 222 | + return len(s.data) == 0 | |
| 223 | +} | |
| 224 | + | |
| 225 | +func (s *APIKeyStore) load() error { | |
| 226 | + raw, err := os.ReadFile(s.path) | |
| 227 | + if os.IsNotExist(err) { | |
| 228 | + return nil | |
| 229 | + } | |
| 230 | + if err != nil { | |
| 231 | + return fmt.Errorf("apikeys: read %s: %w", s.path, err) | |
| 232 | + } | |
| 233 | + if err := json.Unmarshal(raw, &s.data); err != nil { | |
| 234 | + return fmt.Errorf("apikeys: parse: %w", err) | |
| 235 | + } | |
| 236 | + return nil | |
| 237 | +} | |
| 238 | + | |
| 239 | +func (s *APIKeyStore) save() error { | |
| 240 | + if s.path == "" { | |
| 241 | + return nil // in-memory only (tests) | |
| 242 | + } | |
| 243 | + raw, err := json.MarshalIndent(s.data, "", " ") | |
| 244 | + if err != nil { | |
| 245 | + return err | |
| 246 | + } | |
| 247 | + return os.WriteFile(s.path, raw, 0600) | |
| 248 | +} | |
| 249 | + | |
| 250 | +func hashToken(token string) string { | |
| 251 | + h := sha256.Sum256([]byte(token)) | |
| 252 | + return hex.EncodeToString(h[:]) | |
| 253 | +} | |
| 254 | + | |
| 255 | +func genToken() (string, error) { | |
| 256 | + b := make([]byte, 32) | |
| 257 | + if _, err := rand.Read(b); err != nil { | |
| 258 | + return "", err | |
| 259 | + } | |
| 260 | + return hex.EncodeToString(b), nil | |
| 261 | +} | |
| 262 | + | |
| 263 | +func newULID() string { | |
| 264 | + entropy := ulid.Monotonic(rand.Reader, 0) | |
| 265 | + return ulid.MustNew(ulid.Timestamp(time.Now()), entropy).String() | |
| 266 | +} | |
| 267 | + | |
| 268 | +// ParseScopes parses a comma-separated scope string into a slice. | |
| 269 | +// Returns an error if any scope is unrecognised. | |
| 270 | +func ParseScopes(s string) ([]Scope, error) { | |
| 271 | + parts := strings.Split(s, ",") | |
| 272 | + scopes := make([]Scope, 0, len(parts)) | |
| 273 | + for _, p := range parts { | |
| 274 | + p = strings.TrimSpace(p) | |
| 275 | + if p == "" { | |
| 276 | + continue | |
| 277 | + } | |
| 278 | + scope := Scope(p) | |
| 279 | + if !ValidScopes[scope] { | |
| 280 | + return nil, fmt.Errorf("unknown scope %q", p) | |
| 281 | + } | |
| 282 | + scopes = append(scopes, scope) | |
| 283 | + } | |
| 284 | + if len(scopes) == 0 { | |
| 285 | + return nil, fmt.Errorf("at least one scope is required") | |
| 286 | + } | |
| 287 | + return scopes, nil | |
| 288 | +} |
| --- a/internal/auth/apikeys.go | |
| +++ b/internal/auth/apikeys.go | |
| @@ -0,0 +1,288 @@ | |
| --- a/internal/auth/apikeys.go | |
| +++ b/internal/auth/apikeys.go | |
| @@ -0,0 +1,288 @@ | |
| 1 | package auth |
| 2 | |
| 3 | import ( |
| 4 | "crypto/rand" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "encoding/json" |
| 8 | "fmt" |
| 9 | "os" |
| 10 | "strings" |
| 11 | "sync" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/oklog/ulid/v2" |
| 15 | ) |
| 16 | |
| 17 | // Scope represents a permission scope for an API key. |
| 18 | type Scope string |
| 19 | |
| 20 | const ( |
| 21 | ScopeAdmin Scope = "admin" // full access |
| 22 | ScopeAgents Scope = "agents" // agent registration, rotation, revocation |
| 23 | ScopeChannels Scope = "channels" // channel CRUD, join, messages, presence |
| 24 | ScopeTopology Scope = "topology" // channel provisioning, topology management |
| 25 | ScopeBots Scope = "bots" // bot configuration, start/stop |
| 26 | ScopeConfig Scope = "config" // server config read/write |
| 27 | ScopeRead Scope = "read" // read-only access to all GET endpoints |
| 28 | ScopeChat Scope = "chat" // send/receive messages only |
| 29 | ) |
| 30 | |
| 31 | // ValidScopes is the set of all recognised scopes. |
| 32 | var ValidScopes = map[Scope]bool{ |
| 33 | ScopeAdmin: true, ScopeAgents: true, ScopeChannels: true, |
| 34 | ScopeTopology: true, ScopeBots: true, ScopeConfig: true, |
| 35 | ScopeRead: true, ScopeChat: true, |
| 36 | } |
| 37 | |
| 38 | // APIKey is a single API key record. |
| 39 | type APIKey struct { |
| 40 | ID string `json:"id"` |
| 41 | Name string `json:"name"` |
| 42 | Hash string `json:"hash"` // SHA-256 of the plaintext token |
| 43 | Scopes []Scope `json:"scopes"` |
| 44 | CreatedAt time.Time `json:"created_at"` |
| 45 | LastUsed time.Time `json:"last_used,omitempty"` |
| 46 | ExpiresAt time.Time `json:"expires_at,omitempty"` // zero = never |
| 47 | Active bool `json:"active"` |
| 48 | } |
| 49 | |
| 50 | // HasScope reports whether the key has the given scope (or admin, which implies all). |
| 51 | func (k *APIKey) HasScope(s Scope) bool { |
| 52 | for _, scope := range k.Scopes { |
| 53 | if scope == ScopeAdmin || scope == s { |
| 54 | return true |
| 55 | } |
| 56 | } |
| 57 | return false |
| 58 | } |
| 59 | |
| 60 | // IsExpired reports whether the key has passed its expiry time. |
| 61 | func (k *APIKey) IsExpired() bool { |
| 62 | return !k.ExpiresAt.IsZero() && time.Now().After(k.ExpiresAt) |
| 63 | } |
| 64 | |
| 65 | // APIKeyStore persists API keys to a JSON file. |
| 66 | type APIKeyStore struct { |
| 67 | mu sync.RWMutex |
| 68 | path string |
| 69 | data []APIKey |
| 70 | } |
| 71 | |
| 72 | // NewAPIKeyStore loads (or creates) the API key store at the given path. |
| 73 | func NewAPIKeyStore(path string) (*APIKeyStore, error) { |
| 74 | s := &APIKeyStore{path: path} |
| 75 | if err := s.load(); err != nil { |
| 76 | return nil, err |
| 77 | } |
| 78 | return s, nil |
| 79 | } |
| 80 | |
| 81 | // Create generates a new API key with the given name and scopes. |
| 82 | // Returns the plaintext token (shown only once) and the stored key record. |
| 83 | func (s *APIKeyStore) Create(name string, scopes []Scope, expiresAt time.Time) (plaintext string, key APIKey, err error) { |
| 84 | s.mu.Lock() |
| 85 | defer s.mu.Unlock() |
| 86 | |
| 87 | token, err := genToken() |
| 88 | if err != nil { |
| 89 | return "", APIKey{}, fmt.Errorf("apikeys: generate token: %w", err) |
| 90 | } |
| 91 | |
| 92 | key = APIKey{ |
| 93 | ID: newULID(), |
| 94 | Name: name, |
| 95 | Hash: hashToken(token), |
| 96 | Scopes: scopes, |
| 97 | CreatedAt: time.Now().UTC(), |
| 98 | ExpiresAt: expiresAt, |
| 99 | Active: true, |
| 100 | } |
| 101 | s.data = append(s.data, key) |
| 102 | if err := s.save(); err != nil { |
| 103 | // Roll back. |
| 104 | s.data = s.data[:len(s.data)-1] |
| 105 | return "", APIKey{}, err |
| 106 | } |
| 107 | return token, key, nil |
| 108 | } |
| 109 | |
| 110 | // Insert adds a pre-built API key with a known plaintext token. |
| 111 | // Used for migrating the startup token into the store. |
| 112 | func (s *APIKeyStore) Insert(name, plaintext string, scopes []Scope) (APIKey, error) { |
| 113 | s.mu.Lock() |
| 114 | defer s.mu.Unlock() |
| 115 | |
| 116 | key := APIKey{ |
| 117 | ID: newULID(), |
| 118 | Name: name, |
| 119 | Hash: hashToken(plaintext), |
| 120 | Scopes: scopes, |
| 121 | CreatedAt: time.Now().UTC(), |
| 122 | Active: true, |
| 123 | } |
| 124 | s.data = append(s.data, key) |
| 125 | if err := s.save(); err != nil { |
| 126 | s.data = s.data[:len(s.data)-1] |
| 127 | return APIKey{}, err |
| 128 | } |
| 129 | return key, nil |
| 130 | } |
| 131 | |
| 132 | // Lookup finds an active, non-expired key by plaintext token. |
| 133 | // Returns nil if no match. |
| 134 | func (s *APIKeyStore) Lookup(token string) *APIKey { |
| 135 | hash := hashToken(token) |
| 136 | s.mu.RLock() |
| 137 | defer s.mu.RUnlock() |
| 138 | for i := range s.data { |
| 139 | if s.data[i].Hash == hash && s.data[i].Active && !s.data[i].IsExpired() { |
| 140 | k := s.data[i] |
| 141 | return &k |
| 142 | } |
| 143 | } |
| 144 | return nil |
| 145 | } |
| 146 | |
| 147 | // TouchLastUsed updates the last-used timestamp for a key by ID. |
| 148 | func (s *APIKeyStore) TouchLastUsed(id string) { |
| 149 | s.mu.Lock() |
| 150 | defer s.mu.Unlock() |
| 151 | for i := range s.data { |
| 152 | if s.data[i].ID == id { |
| 153 | s.data[i].LastUsed = time.Now().UTC() |
| 154 | _ = s.save() // best-effort persistence |
| 155 | return |
| 156 | } |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | // Get returns a key by ID, or nil if not found. |
| 161 | func (s *APIKeyStore) Get(id string) *APIKey { |
| 162 | s.mu.RLock() |
| 163 | defer s.mu.RUnlock() |
| 164 | for i := range s.data { |
| 165 | if s.data[i].ID == id { |
| 166 | k := s.data[i] |
| 167 | return &k |
| 168 | } |
| 169 | } |
| 170 | return nil |
| 171 | } |
| 172 | |
| 173 | // List returns all keys (active and revoked). |
| 174 | func (s *APIKeyStore) List() []APIKey { |
| 175 | s.mu.RLock() |
| 176 | defer s.mu.RUnlock() |
| 177 | out := make([]APIKey, len(s.data)) |
| 178 | copy(out, s.data) |
| 179 | return out |
| 180 | } |
| 181 | |
| 182 | // Revoke deactivates a key by ID. |
| 183 | func (s *APIKeyStore) Revoke(id string) error { |
| 184 | s.mu.Lock() |
| 185 | defer s.mu.Unlock() |
| 186 | for i := range s.data { |
| 187 | if s.data[i].ID == id { |
| 188 | if !s.data[i].Active { |
| 189 | return fmt.Errorf("apikeys: key %q already revoked", id) |
| 190 | } |
| 191 | s.data[i].Active = false |
| 192 | return s.save() |
| 193 | } |
| 194 | } |
| 195 | return fmt.Errorf("apikeys: key %q not found", id) |
| 196 | } |
| 197 | |
| 198 | // Lookup (TokenValidator interface) reports whether the token is valid. |
| 199 | // Satisfies the mcp.TokenValidator interface. |
| 200 | func (s *APIKeyStore) ValidToken(token string) bool { |
| 201 | return s.Lookup(token) != nil |
| 202 | } |
| 203 | |
| 204 | // TestStore creates an in-memory APIKeyStore with a single admin-scope key |
| 205 | // for the given token. Intended for tests only — does not persist to disk. |
| 206 | func TestStore(token string) *APIKeyStore { |
| 207 | s := &APIKeyStore{path: "", data: []APIKey{{ |
| 208 | ID: "test-key", |
| 209 | Name: "test", |
| 210 | Hash: hashToken(token), |
| 211 | Scopes: []Scope{ScopeAdmin}, |
| 212 | CreatedAt: time.Now().UTC(), |
| 213 | Active: true, |
| 214 | }}} |
| 215 | return s |
| 216 | } |
| 217 | |
| 218 | // IsEmpty reports whether there are no keys. |
| 219 | func (s *APIKeyStore) IsEmpty() bool { |
| 220 | s.mu.RLock() |
| 221 | defer s.mu.RUnlock() |
| 222 | return len(s.data) == 0 |
| 223 | } |
| 224 | |
| 225 | func (s *APIKeyStore) load() error { |
| 226 | raw, err := os.ReadFile(s.path) |
| 227 | if os.IsNotExist(err) { |
| 228 | return nil |
| 229 | } |
| 230 | if err != nil { |
| 231 | return fmt.Errorf("apikeys: read %s: %w", s.path, err) |
| 232 | } |
| 233 | if err := json.Unmarshal(raw, &s.data); err != nil { |
| 234 | return fmt.Errorf("apikeys: parse: %w", err) |
| 235 | } |
| 236 | return nil |
| 237 | } |
| 238 | |
| 239 | func (s *APIKeyStore) save() error { |
| 240 | if s.path == "" { |
| 241 | return nil // in-memory only (tests) |
| 242 | } |
| 243 | raw, err := json.MarshalIndent(s.data, "", " ") |
| 244 | if err != nil { |
| 245 | return err |
| 246 | } |
| 247 | return os.WriteFile(s.path, raw, 0600) |
| 248 | } |
| 249 | |
| 250 | func hashToken(token string) string { |
| 251 | h := sha256.Sum256([]byte(token)) |
| 252 | return hex.EncodeToString(h[:]) |
| 253 | } |
| 254 | |
| 255 | func genToken() (string, error) { |
| 256 | b := make([]byte, 32) |
| 257 | if _, err := rand.Read(b); err != nil { |
| 258 | return "", err |
| 259 | } |
| 260 | return hex.EncodeToString(b), nil |
| 261 | } |
| 262 | |
| 263 | func newULID() string { |
| 264 | entropy := ulid.Monotonic(rand.Reader, 0) |
| 265 | return ulid.MustNew(ulid.Timestamp(time.Now()), entropy).String() |
| 266 | } |
| 267 | |
| 268 | // ParseScopes parses a comma-separated scope string into a slice. |
| 269 | // Returns an error if any scope is unrecognised. |
| 270 | func ParseScopes(s string) ([]Scope, error) { |
| 271 | parts := strings.Split(s, ",") |
| 272 | scopes := make([]Scope, 0, len(parts)) |
| 273 | for _, p := range parts { |
| 274 | p = strings.TrimSpace(p) |
| 275 | if p == "" { |
| 276 | continue |
| 277 | } |
| 278 | scope := Scope(p) |
| 279 | if !ValidScopes[scope] { |
| 280 | return nil, fmt.Errorf("unknown scope %q", p) |
| 281 | } |
| 282 | scopes = append(scopes, scope) |
| 283 | } |
| 284 | if len(scopes) == 0 { |
| 285 | return nil, fmt.Errorf("at least one scope is required") |
| 286 | } |
| 287 | return scopes, nil |
| 288 | } |
| --- internal/bots/auditbot/auditbot.go | ||
| +++ internal/bots/auditbot/auditbot.go | ||
| @@ -121,10 +121,11 @@ | ||
| 121 | 121 | PingTimeout: 30 * time.Second, |
| 122 | 122 | SSL: false, |
| 123 | 123 | }) |
| 124 | 124 | |
| 125 | 125 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 126 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 126 | 127 | for _, ch := range b.channels { |
| 127 | 128 | cl.Cmd.Join(ch) |
| 128 | 129 | } |
| 129 | 130 | b.log.Info("auditbot connected", "channels", b.channels, "audit_types", b.auditTypesList()) |
| 130 | 131 | }) |
| 131 | 132 |
| --- 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 @@ | ||
| 121 | 121 | PingTimeout: 30 * time.Second, |
| 122 | 122 | SSL: false, |
| 123 | 123 | }) |
| 124 | 124 | |
| 125 | 125 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 126 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 126 | 127 | for _, ch := range b.channels { |
| 127 | 128 | cl.Cmd.Join(ch) |
| 128 | 129 | } |
| 129 | 130 | b.log.Info("auditbot connected", "channels", b.channels, "audit_types", b.auditTypesList()) |
| 130 | 131 | }) |
| 131 | 132 |
| --- 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 |
+164
-20
| --- internal/bots/bridge/bridge.go | ||
| +++ internal/bots/bridge/bridge.go | ||
| @@ -34,10 +34,11 @@ | ||
| 34 | 34 | type Message struct { |
| 35 | 35 | At time.Time `json:"at"` |
| 36 | 36 | Channel string `json:"channel"` |
| 37 | 37 | Nick string `json:"nick"` |
| 38 | 38 | Text string `json:"text"` |
| 39 | + MsgID string `json:"msgid,omitempty"` | |
| 39 | 40 | Meta *Meta `json:"meta,omitempty"` |
| 40 | 41 | } |
| 41 | 42 | |
| 42 | 43 | // ringBuf is a fixed-capacity circular buffer of Messages. |
| 43 | 44 | type ringBuf struct { |
| @@ -101,12 +102,16 @@ | ||
| 101 | 102 | // webUserTTL controls how long bridge-posted HTTP nicks stay visible in Users(). |
| 102 | 103 | webUserTTL time.Duration |
| 103 | 104 | |
| 104 | 105 | msgTotal atomic.Int64 |
| 105 | 106 | |
| 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 | |
| 108 | 113 | } |
| 109 | 114 | |
| 110 | 115 | // New creates a bridge Bot. |
| 111 | 116 | func New(ircAddr, nick, password string, channels []string, bufSize int, webUserTTL time.Duration, log *slog.Logger) *Bot { |
| 112 | 117 | if nick == "" { |
| @@ -148,10 +153,22 @@ | ||
| 148 | 153 | } |
| 149 | 154 | b.mu.Lock() |
| 150 | 155 | b.webUserTTL = ttl |
| 151 | 156 | b.mu.Unlock() |
| 152 | 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 | +} | |
| 153 | 170 | |
| 154 | 171 | // Name returns the bot's IRC nick. |
| 155 | 172 | func (b *Bot) Name() string { return b.nick } |
| 156 | 173 | |
| 157 | 174 | // Start connects to IRC and begins bridging messages. Blocks until ctx is cancelled. |
| @@ -172,10 +189,23 @@ | ||
| 172 | 189 | PingTimeout: 30 * time.Second, |
| 173 | 190 | SSL: false, |
| 174 | 191 | }) |
| 175 | 192 | |
| 176 | 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 | + } | |
| 177 | 207 | if b.log != nil { |
| 178 | 208 | b.log.Info("bridge connected") |
| 179 | 209 | } |
| 180 | 210 | for _, ch := range b.initChannels { |
| 181 | 211 | cl.Cmd.Join(ch) |
| @@ -187,25 +217,33 @@ | ||
| 187 | 217 | b.JoinChannel(ch) |
| 188 | 218 | } |
| 189 | 219 | }) |
| 190 | 220 | |
| 191 | 221 | 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 { | |
| 193 | 223 | return |
| 194 | 224 | } |
| 195 | 225 | 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) | |
| 207 | 245 | } |
| 208 | 246 | }) |
| 209 | 247 | |
| 210 | 248 | c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) { |
| 211 | 249 | if len(e.Params) < 1 || e.Source == nil { |
| @@ -219,16 +257,26 @@ | ||
| 219 | 257 | nick := e.Source.Name |
| 220 | 258 | if acct, ok := e.Tags.Get("account"); ok && acct != "" { |
| 221 | 259 | nick = acct |
| 222 | 260 | } |
| 223 | 261 | |
| 224 | - b.dispatch(Message{ | |
| 262 | + var msgID string | |
| 263 | + if id, ok := e.Tags.Get("msgid"); ok { | |
| 264 | + msgID = id | |
| 265 | + } | |
| 266 | + msg := Message{ | |
| 225 | 267 | At: e.Timestamp, |
| 226 | 268 | Channel: channel, |
| 227 | 269 | Nick: nick, |
| 228 | 270 | 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) | |
| 230 | 278 | }) |
| 231 | 279 | |
| 232 | 280 | b.client = c |
| 233 | 281 | |
| 234 | 282 | errCh := make(chan error, 1) |
| @@ -338,19 +386,39 @@ | ||
| 338 | 386 | } |
| 339 | 387 | |
| 340 | 388 | // SendWithMeta sends a message to channel with optional structured metadata. |
| 341 | 389 | // IRC receives only the plain text; SSE subscribers receive the full message |
| 342 | 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. | |
| 343 | 397 | func (b *Bot) SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *Meta) error { |
| 344 | 398 | if b.client == nil { |
| 345 | 399 | return fmt.Errorf("bridge: not connected") |
| 346 | 400 | } |
| 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 + " " | |
| 350 | 405 | } |
| 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 | + } | |
| 352 | 420 | |
| 353 | 421 | if senderNick != "" { |
| 354 | 422 | b.TouchUser(channel, senderNick) |
| 355 | 423 | } |
| 356 | 424 | |
| @@ -421,10 +489,86 @@ | ||
| 421 | 489 | } |
| 422 | 490 | b.mu.Unlock() |
| 423 | 491 | |
| 424 | 492 | return nicks |
| 425 | 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 | +} | |
| 426 | 570 | |
| 427 | 571 | // Stats returns a snapshot of bridge activity. |
| 428 | 572 | func (b *Bot) Stats() Stats { |
| 429 | 573 | b.mu.RLock() |
| 430 | 574 | channels := len(b.joined) |
| 431 | 575 |
| --- 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 @@ | ||
| 153 | 153 | PingTimeout: 30 * time.Second, |
| 154 | 154 | SSL: false, |
| 155 | 155 | }) |
| 156 | 156 | |
| 157 | 157 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 158 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 158 | 159 | for _, ch := range b.channels { |
| 159 | 160 | cl.Cmd.Join(ch) |
| 160 | 161 | } |
| 161 | 162 | if b.log != nil { |
| 162 | 163 | b.log.Info("herald connected", "channels", b.channels) |
| 163 | 164 |
| --- 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 @@ | ||
| 153 | 153 | PingTimeout: 30 * time.Second, |
| 154 | 154 | SSL: false, |
| 155 | 155 | }) |
| 156 | 156 | |
| 157 | 157 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 158 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 158 | 159 | for _, ch := range b.channels { |
| 159 | 160 | cl.Cmd.Join(ch) |
| 160 | 161 | } |
| 161 | 162 | if b.log != nil { |
| 162 | 163 | b.log.Info("herald connected", "channels", b.channels) |
| 163 | 164 |
| --- 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 |
+51
-13
| --- internal/bots/oracle/oracle.go | ||
| +++ internal/bots/oracle/oracle.go | ||
| @@ -22,10 +22,12 @@ | ||
| 22 | 22 | "time" |
| 23 | 23 | |
| 24 | 24 | "github.com/lrstanley/girc" |
| 25 | 25 | |
| 26 | 26 | "github.com/conflicthq/scuttlebot/internal/bots/cmdparse" |
| 27 | + "github.com/conflicthq/scuttlebot/pkg/chathistory" | |
| 28 | + "github.com/conflicthq/scuttlebot/pkg/toon" | |
| 27 | 29 | ) |
| 28 | 30 | |
| 29 | 31 | const ( |
| 30 | 32 | botNick = "oracle" |
| 31 | 33 | defaultLimit = 50 |
| @@ -126,10 +128,11 @@ | ||
| 126 | 128 | llm LLMProvider |
| 127 | 129 | log *slog.Logger |
| 128 | 130 | mu sync.Mutex |
| 129 | 131 | lastReq map[string]time.Time // nick → last request time |
| 130 | 132 | client *girc.Client |
| 133 | + chFetch *chathistory.Fetcher // CHATHISTORY fetcher, nil if unsupported | |
| 131 | 134 | } |
| 132 | 135 | |
| 133 | 136 | // New creates an oracle bot. |
| 134 | 137 | func New(ircAddr, password string, channels []string, history HistoryFetcher, llm LLMProvider, log *slog.Logger) *Bot { |
| 135 | 138 | return &Bot{ |
| @@ -161,18 +164,26 @@ | ||
| 161 | 164 | Name: "scuttlebot oracle", |
| 162 | 165 | SASL: &girc.SASLPlain{User: botNick, Pass: b.password}, |
| 163 | 166 | PingDelay: 30 * time.Second, |
| 164 | 167 | PingTimeout: 30 * time.Second, |
| 165 | 168 | SSL: false, |
| 169 | + SupportedCaps: map[string][]string{ | |
| 170 | + "draft/chathistory": nil, | |
| 171 | + "chathistory": nil, | |
| 172 | + }, | |
| 166 | 173 | }) |
| 174 | + | |
| 175 | + b.chFetch = chathistory.New(c) | |
| 167 | 176 | |
| 168 | 177 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 178 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 169 | 179 | for _, ch := range b.channels { |
| 170 | 180 | cl.Cmd.Join(ch) |
| 171 | 181 | } |
| 182 | + hasCH := cl.HasCapability("chathistory") || cl.HasCapability("draft/chathistory") | |
| 172 | 183 | if b.log != nil { |
| 173 | - b.log.Info("oracle connected", "channels", b.channels) | |
| 184 | + b.log.Info("oracle connected", "channels", b.channels, "chathistory", hasCH) | |
| 174 | 185 | } |
| 175 | 186 | }) |
| 176 | 187 | |
| 177 | 188 | c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) { |
| 178 | 189 | if ch := e.Last(); strings.HasPrefix(ch, "#") { |
| @@ -248,12 +259,12 @@ | ||
| 248 | 259 | return |
| 249 | 260 | } |
| 250 | 261 | b.lastReq[nick] = time.Now() |
| 251 | 262 | b.mu.Unlock() |
| 252 | 263 | |
| 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) | |
| 255 | 266 | if err != nil { |
| 256 | 267 | cl.Cmd.Notice(nick, fmt.Sprintf("oracle: failed to fetch history for %s: %v", req.Channel, err)) |
| 257 | 268 | return |
| 258 | 269 | } |
| 259 | 270 | if len(entries) == 0 { |
| @@ -277,24 +288,51 @@ | ||
| 277 | 288 | if line != "" { |
| 278 | 289 | cl.Cmd.Notice(nick, line) |
| 279 | 290 | } |
| 280 | 291 | } |
| 281 | 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 | +} | |
| 282 | 322 | |
| 283 | 323 | 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, | |
| 293 | 331 | } |
| 294 | 332 | } |
| 295 | - return sb.String() | |
| 333 | + return toon.FormatPrompt(channel, toonEntries) | |
| 296 | 334 | } |
| 297 | 335 | |
| 298 | 336 | func formatResponse(channel string, count int, summary string, format Format) string { |
| 299 | 337 | switch format { |
| 300 | 338 | case FormatJSON: |
| 301 | 339 |
| --- 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 |
+51
-13
| --- internal/bots/oracle/oracle.go | ||
| +++ internal/bots/oracle/oracle.go | ||
| @@ -22,10 +22,12 @@ | ||
| 22 | 22 | "time" |
| 23 | 23 | |
| 24 | 24 | "github.com/lrstanley/girc" |
| 25 | 25 | |
| 26 | 26 | "github.com/conflicthq/scuttlebot/internal/bots/cmdparse" |
| 27 | + "github.com/conflicthq/scuttlebot/pkg/chathistory" | |
| 28 | + "github.com/conflicthq/scuttlebot/pkg/toon" | |
| 27 | 29 | ) |
| 28 | 30 | |
| 29 | 31 | const ( |
| 30 | 32 | botNick = "oracle" |
| 31 | 33 | defaultLimit = 50 |
| @@ -126,10 +128,11 @@ | ||
| 126 | 128 | llm LLMProvider |
| 127 | 129 | log *slog.Logger |
| 128 | 130 | mu sync.Mutex |
| 129 | 131 | lastReq map[string]time.Time // nick → last request time |
| 130 | 132 | client *girc.Client |
| 133 | + chFetch *chathistory.Fetcher // CHATHISTORY fetcher, nil if unsupported | |
| 131 | 134 | } |
| 132 | 135 | |
| 133 | 136 | // New creates an oracle bot. |
| 134 | 137 | func New(ircAddr, password string, channels []string, history HistoryFetcher, llm LLMProvider, log *slog.Logger) *Bot { |
| 135 | 138 | return &Bot{ |
| @@ -161,18 +164,26 @@ | ||
| 161 | 164 | Name: "scuttlebot oracle", |
| 162 | 165 | SASL: &girc.SASLPlain{User: botNick, Pass: b.password}, |
| 163 | 166 | PingDelay: 30 * time.Second, |
| 164 | 167 | PingTimeout: 30 * time.Second, |
| 165 | 168 | SSL: false, |
| 169 | + SupportedCaps: map[string][]string{ | |
| 170 | + "draft/chathistory": nil, | |
| 171 | + "chathistory": nil, | |
| 172 | + }, | |
| 166 | 173 | }) |
| 174 | + | |
| 175 | + b.chFetch = chathistory.New(c) | |
| 167 | 176 | |
| 168 | 177 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 178 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 169 | 179 | for _, ch := range b.channels { |
| 170 | 180 | cl.Cmd.Join(ch) |
| 171 | 181 | } |
| 182 | + hasCH := cl.HasCapability("chathistory") || cl.HasCapability("draft/chathistory") | |
| 172 | 183 | if b.log != nil { |
| 173 | - b.log.Info("oracle connected", "channels", b.channels) | |
| 184 | + b.log.Info("oracle connected", "channels", b.channels, "chathistory", hasCH) | |
| 174 | 185 | } |
| 175 | 186 | }) |
| 176 | 187 | |
| 177 | 188 | c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) { |
| 178 | 189 | if ch := e.Last(); strings.HasPrefix(ch, "#") { |
| @@ -248,12 +259,12 @@ | ||
| 248 | 259 | return |
| 249 | 260 | } |
| 250 | 261 | b.lastReq[nick] = time.Now() |
| 251 | 262 | b.mu.Unlock() |
| 252 | 263 | |
| 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) | |
| 255 | 266 | if err != nil { |
| 256 | 267 | cl.Cmd.Notice(nick, fmt.Sprintf("oracle: failed to fetch history for %s: %v", req.Channel, err)) |
| 257 | 268 | return |
| 258 | 269 | } |
| 259 | 270 | if len(entries) == 0 { |
| @@ -277,24 +288,51 @@ | ||
| 277 | 288 | if line != "" { |
| 278 | 289 | cl.Cmd.Notice(nick, line) |
| 279 | 290 | } |
| 280 | 291 | } |
| 281 | 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 | +} | |
| 282 | 322 | |
| 283 | 323 | 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, | |
| 293 | 331 | } |
| 294 | 332 | } |
| 295 | - return sb.String() | |
| 333 | + return toon.FormatPrompt(channel, toonEntries) | |
| 296 | 334 | } |
| 297 | 335 | |
| 298 | 336 | func formatResponse(channel string, count int, summary string, format Format) string { |
| 299 | 337 | switch format { |
| 300 | 338 | case FormatJSON: |
| 301 | 339 |
| --- 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 @@ | ||
| 65 | 65 | PingTimeout: 30 * time.Second, |
| 66 | 66 | SSL: false, |
| 67 | 67 | }) |
| 68 | 68 | |
| 69 | 69 | c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) { |
| 70 | + client.Cmd.Mode(client.GetNick(), "+B") | |
| 70 | 71 | for _, ch := range b.channels { |
| 71 | 72 | client.Cmd.Join(ch) |
| 72 | 73 | } |
| 73 | 74 | b.log.Info("scribe connected and joined channels", "channels", b.channels) |
| 74 | 75 | }) |
| 75 | 76 |
| --- 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 @@ | ||
| 65 | 65 | PingTimeout: 30 * time.Second, |
| 66 | 66 | SSL: false, |
| 67 | 67 | }) |
| 68 | 68 | |
| 69 | 69 | c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) { |
| 70 | + client.Cmd.Mode(client.GetNick(), "+B") | |
| 70 | 71 | for _, ch := range b.channels { |
| 71 | 72 | client.Cmd.Join(ch) |
| 72 | 73 | } |
| 73 | 74 | b.log.Info("scribe connected and joined channels", "channels", b.channels) |
| 74 | 75 | }) |
| 75 | 76 |
| --- 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 |
+78
-10
| --- internal/bots/scroll/scroll.go | ||
| +++ internal/bots/scroll/scroll.go | ||
| @@ -22,10 +22,12 @@ | ||
| 22 | 22 | |
| 23 | 23 | "github.com/lrstanley/girc" |
| 24 | 24 | |
| 25 | 25 | "github.com/conflicthq/scuttlebot/internal/bots/cmdparse" |
| 26 | 26 | "github.com/conflicthq/scuttlebot/internal/bots/scribe" |
| 27 | + "github.com/conflicthq/scuttlebot/pkg/chathistory" | |
| 28 | + "github.com/conflicthq/scuttlebot/pkg/toon" | |
| 27 | 29 | ) |
| 28 | 30 | |
| 29 | 31 | const ( |
| 30 | 32 | botNick = "scroll" |
| 31 | 33 | defaultLimit = 50 |
| @@ -39,11 +41,12 @@ | ||
| 39 | 41 | password string |
| 40 | 42 | channels []string |
| 41 | 43 | store scribe.Store |
| 42 | 44 | log *slog.Logger |
| 43 | 45 | 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 | |
| 45 | 48 | } |
| 46 | 49 | |
| 47 | 50 | // New creates a scroll Bot backed by the given scribe Store. |
| 48 | 51 | func New(ircAddr, password string, channels []string, store scribe.Store, log *slog.Logger) *Bot { |
| 49 | 52 | return &Bot{ |
| @@ -73,17 +76,26 @@ | ||
| 73 | 76 | Name: "scuttlebot scroll", |
| 74 | 77 | SASL: &girc.SASLPlain{User: botNick, Pass: b.password}, |
| 75 | 78 | PingDelay: 30 * time.Second, |
| 76 | 79 | PingTimeout: 30 * time.Second, |
| 77 | 80 | SSL: false, |
| 81 | + SupportedCaps: map[string][]string{ | |
| 82 | + "draft/chathistory": nil, | |
| 83 | + "chathistory": nil, | |
| 84 | + }, | |
| 78 | 85 | }) |
| 86 | + | |
| 87 | + // Register CHATHISTORY batch handlers before connecting. | |
| 88 | + b.history = chathistory.New(c) | |
| 79 | 89 | |
| 80 | 90 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, e girc.Event) { |
| 91 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 81 | 92 | for _, ch := range b.channels { |
| 82 | 93 | cl.Cmd.Join(ch) |
| 83 | 94 | } |
| 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) | |
| 85 | 97 | }) |
| 86 | 98 | |
| 87 | 99 | router := cmdparse.NewRouter(botNick) |
| 88 | 100 | router.Register(cmdparse.Command{ |
| 89 | 101 | Name: "replay", |
| @@ -148,15 +160,15 @@ | ||
| 148 | 160 | } |
| 149 | 161 | |
| 150 | 162 | req, err := ParseCommand(text) |
| 151 | 163 | if err != nil { |
| 152 | 164 | 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]") | |
| 154 | 166 | return |
| 155 | 167 | } |
| 156 | 168 | |
| 157 | - entries, err := b.store.Query(req.Channel, req.Limit) | |
| 169 | + entries, err := b.fetchHistory(req) | |
| 158 | 170 | if err != nil { |
| 159 | 171 | client.Cmd.Notice(nick, fmt.Sprintf("error fetching history: %s", err)) |
| 160 | 172 | return |
| 161 | 173 | } |
| 162 | 174 | |
| @@ -163,16 +175,64 @@ | ||
| 163 | 175 | if len(entries) == 0 { |
| 164 | 176 | client.Cmd.Notice(nick, fmt.Sprintf("no history found for %s", req.Channel)) |
| 165 | 177 | return |
| 166 | 178 | } |
| 167 | 179 | |
| 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 | + } | |
| 172 | 232 | } |
| 173 | - client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel)) | |
| 233 | + return b.store.Query(req.Channel, req.Limit) | |
| 174 | 234 | } |
| 175 | 235 | |
| 176 | 236 | func (b *Bot) checkRateLimit(nick string) bool { |
| 177 | 237 | now := time.Now() |
| 178 | 238 | if last, ok := b.rateLimit.Load(nick); ok { |
| @@ -186,11 +246,12 @@ | ||
| 186 | 246 | |
| 187 | 247 | // ReplayRequest is a parsed replay command. |
| 188 | 248 | type replayRequest struct { |
| 189 | 249 | Channel string |
| 190 | 250 | 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" | |
| 192 | 253 | } |
| 193 | 254 | |
| 194 | 255 | // ParseCommand parses a replay command string. Exported for testing. |
| 195 | 256 | func ParseCommand(text string) (*replayRequest, error) { |
| 196 | 257 | parts := strings.Fields(text) |
| @@ -224,10 +285,17 @@ | ||
| 224 | 285 | ts, err := strconv.ParseInt(kv[1], 10, 64) |
| 225 | 286 | if err != nil { |
| 226 | 287 | return nil, fmt.Errorf("invalid since=%q (must be unix milliseconds)", kv[1]) |
| 227 | 288 | } |
| 228 | 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 | + } | |
| 229 | 297 | default: |
| 230 | 298 | return nil, fmt.Errorf("unknown argument %q", kv[0]) |
| 231 | 299 | } |
| 232 | 300 | } |
| 233 | 301 | |
| 234 | 302 |
| --- 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 |
+78
-10
| --- internal/bots/scroll/scroll.go | ||
| +++ internal/bots/scroll/scroll.go | ||
| @@ -22,10 +22,12 @@ | ||
| 22 | 22 | |
| 23 | 23 | "github.com/lrstanley/girc" |
| 24 | 24 | |
| 25 | 25 | "github.com/conflicthq/scuttlebot/internal/bots/cmdparse" |
| 26 | 26 | "github.com/conflicthq/scuttlebot/internal/bots/scribe" |
| 27 | + "github.com/conflicthq/scuttlebot/pkg/chathistory" | |
| 28 | + "github.com/conflicthq/scuttlebot/pkg/toon" | |
| 27 | 29 | ) |
| 28 | 30 | |
| 29 | 31 | const ( |
| 30 | 32 | botNick = "scroll" |
| 31 | 33 | defaultLimit = 50 |
| @@ -39,11 +41,12 @@ | ||
| 39 | 41 | password string |
| 40 | 42 | channels []string |
| 41 | 43 | store scribe.Store |
| 42 | 44 | log *slog.Logger |
| 43 | 45 | 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 | |
| 45 | 48 | } |
| 46 | 49 | |
| 47 | 50 | // New creates a scroll Bot backed by the given scribe Store. |
| 48 | 51 | func New(ircAddr, password string, channels []string, store scribe.Store, log *slog.Logger) *Bot { |
| 49 | 52 | return &Bot{ |
| @@ -73,17 +76,26 @@ | ||
| 73 | 76 | Name: "scuttlebot scroll", |
| 74 | 77 | SASL: &girc.SASLPlain{User: botNick, Pass: b.password}, |
| 75 | 78 | PingDelay: 30 * time.Second, |
| 76 | 79 | PingTimeout: 30 * time.Second, |
| 77 | 80 | SSL: false, |
| 81 | + SupportedCaps: map[string][]string{ | |
| 82 | + "draft/chathistory": nil, | |
| 83 | + "chathistory": nil, | |
| 84 | + }, | |
| 78 | 85 | }) |
| 86 | + | |
| 87 | + // Register CHATHISTORY batch handlers before connecting. | |
| 88 | + b.history = chathistory.New(c) | |
| 79 | 89 | |
| 80 | 90 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, e girc.Event) { |
| 91 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 81 | 92 | for _, ch := range b.channels { |
| 82 | 93 | cl.Cmd.Join(ch) |
| 83 | 94 | } |
| 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) | |
| 85 | 97 | }) |
| 86 | 98 | |
| 87 | 99 | router := cmdparse.NewRouter(botNick) |
| 88 | 100 | router.Register(cmdparse.Command{ |
| 89 | 101 | Name: "replay", |
| @@ -148,15 +160,15 @@ | ||
| 148 | 160 | } |
| 149 | 161 | |
| 150 | 162 | req, err := ParseCommand(text) |
| 151 | 163 | if err != nil { |
| 152 | 164 | 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]") | |
| 154 | 166 | return |
| 155 | 167 | } |
| 156 | 168 | |
| 157 | - entries, err := b.store.Query(req.Channel, req.Limit) | |
| 169 | + entries, err := b.fetchHistory(req) | |
| 158 | 170 | if err != nil { |
| 159 | 171 | client.Cmd.Notice(nick, fmt.Sprintf("error fetching history: %s", err)) |
| 160 | 172 | return |
| 161 | 173 | } |
| 162 | 174 | |
| @@ -163,16 +175,64 @@ | ||
| 163 | 175 | if len(entries) == 0 { |
| 164 | 176 | client.Cmd.Notice(nick, fmt.Sprintf("no history found for %s", req.Channel)) |
| 165 | 177 | return |
| 166 | 178 | } |
| 167 | 179 | |
| 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 | + } | |
| 172 | 232 | } |
| 173 | - client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel)) | |
| 233 | + return b.store.Query(req.Channel, req.Limit) | |
| 174 | 234 | } |
| 175 | 235 | |
| 176 | 236 | func (b *Bot) checkRateLimit(nick string) bool { |
| 177 | 237 | now := time.Now() |
| 178 | 238 | if last, ok := b.rateLimit.Load(nick); ok { |
| @@ -186,11 +246,12 @@ | ||
| 186 | 246 | |
| 187 | 247 | // ReplayRequest is a parsed replay command. |
| 188 | 248 | type replayRequest struct { |
| 189 | 249 | Channel string |
| 190 | 250 | 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" | |
| 192 | 253 | } |
| 193 | 254 | |
| 194 | 255 | // ParseCommand parses a replay command string. Exported for testing. |
| 195 | 256 | func ParseCommand(text string) (*replayRequest, error) { |
| 196 | 257 | parts := strings.Fields(text) |
| @@ -224,10 +285,17 @@ | ||
| 224 | 285 | ts, err := strconv.ParseInt(kv[1], 10, 64) |
| 225 | 286 | if err != nil { |
| 226 | 287 | return nil, fmt.Errorf("invalid since=%q (must be unix milliseconds)", kv[1]) |
| 227 | 288 | } |
| 228 | 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 | + } | |
| 229 | 297 | default: |
| 230 | 298 | return nil, fmt.Errorf("unknown argument %q", kv[0]) |
| 231 | 299 | } |
| 232 | 300 | } |
| 233 | 301 | |
| 234 | 302 |
| --- 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 @@ | ||
| 148 | 148 | PingDelay: 30 * time.Second, |
| 149 | 149 | PingTimeout: 30 * time.Second, |
| 150 | 150 | }) |
| 151 | 151 | |
| 152 | 152 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 153 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 153 | 154 | for _, ch := range b.cfg.Channels { |
| 154 | 155 | cl.Cmd.Join(ch) |
| 155 | 156 | } |
| 156 | 157 | cl.Cmd.Join(b.cfg.ModChannel) |
| 157 | 158 | if b.log != nil { |
| 158 | 159 |
| --- 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 @@ | ||
| 148 | 148 | PingDelay: 30 * time.Second, |
| 149 | 149 | PingTimeout: 30 * time.Second, |
| 150 | 150 | }) |
| 151 | 151 | |
| 152 | 152 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 153 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 153 | 154 | for _, ch := range b.cfg.Channels { |
| 154 | 155 | cl.Cmd.Join(ch) |
| 155 | 156 | } |
| 156 | 157 | cl.Cmd.Join(b.cfg.ModChannel) |
| 157 | 158 | if b.log != nil { |
| 158 | 159 |
| --- 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 |
+46
-1
| --- internal/bots/snitch/snitch.go | ||
| +++ internal/bots/snitch/snitch.go | ||
| @@ -50,10 +50,14 @@ | ||
| 50 | 50 | // JoinPartWindow is the rolling window for join/part cycling. Default: 30s. |
| 51 | 51 | JoinPartWindow time.Duration |
| 52 | 52 | |
| 53 | 53 | // Channels is the list of channels to join on connect. |
| 54 | 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 | |
| 55 | 59 | } |
| 56 | 60 | |
| 57 | 61 | func (c *Config) setDefaults() { |
| 58 | 62 | if c.Nick == "" { |
| 59 | 63 | c.Nick = defaultNick |
| @@ -137,18 +141,45 @@ | ||
| 137 | 141 | PingDelay: 30 * time.Second, |
| 138 | 142 | PingTimeout: 30 * time.Second, |
| 139 | 143 | }) |
| 140 | 144 | |
| 141 | 145 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 146 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 142 | 147 | for _, ch := range b.cfg.Channels { |
| 143 | 148 | cl.Cmd.Join(ch) |
| 144 | 149 | } |
| 145 | 150 | if b.cfg.AlertChannel != "" { |
| 146 | 151 | cl.Cmd.Join(b.cfg.AlertChannel) |
| 147 | 152 | } |
| 153 | + if len(b.cfg.MonitorNicks) > 0 { | |
| 154 | + cl.Cmd.SendRawf("MONITOR + %s", strings.Join(b.cfg.MonitorNicks, ",")) | |
| 155 | + } | |
| 148 | 156 | 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)) | |
| 150 | 181 | } |
| 151 | 182 | }) |
| 152 | 183 | |
| 153 | 184 | c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) { |
| 154 | 185 | if ch := e.Last(); strings.HasPrefix(ch, "#") { |
| @@ -223,10 +254,24 @@ | ||
| 223 | 254 | func (b *Bot) JoinChannel(channel string) { |
| 224 | 255 | if b.client != nil { |
| 225 | 256 | b.client.Cmd.Join(channel) |
| 226 | 257 | } |
| 227 | 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 | +} | |
| 228 | 273 | |
| 229 | 274 | func (b *Bot) window(channel, nick string) *nickWindow { |
| 230 | 275 | if b.windows[channel] == nil { |
| 231 | 276 | b.windows[channel] = make(map[string]*nickWindow) |
| 232 | 277 | } |
| 233 | 278 |
| --- 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 |
+46
-1
| --- internal/bots/snitch/snitch.go | ||
| +++ internal/bots/snitch/snitch.go | ||
| @@ -50,10 +50,14 @@ | ||
| 50 | 50 | // JoinPartWindow is the rolling window for join/part cycling. Default: 30s. |
| 51 | 51 | JoinPartWindow time.Duration |
| 52 | 52 | |
| 53 | 53 | // Channels is the list of channels to join on connect. |
| 54 | 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 | |
| 55 | 59 | } |
| 56 | 60 | |
| 57 | 61 | func (c *Config) setDefaults() { |
| 58 | 62 | if c.Nick == "" { |
| 59 | 63 | c.Nick = defaultNick |
| @@ -137,18 +141,45 @@ | ||
| 137 | 141 | PingDelay: 30 * time.Second, |
| 138 | 142 | PingTimeout: 30 * time.Second, |
| 139 | 143 | }) |
| 140 | 144 | |
| 141 | 145 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 146 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 142 | 147 | for _, ch := range b.cfg.Channels { |
| 143 | 148 | cl.Cmd.Join(ch) |
| 144 | 149 | } |
| 145 | 150 | if b.cfg.AlertChannel != "" { |
| 146 | 151 | cl.Cmd.Join(b.cfg.AlertChannel) |
| 147 | 152 | } |
| 153 | + if len(b.cfg.MonitorNicks) > 0 { | |
| 154 | + cl.Cmd.SendRawf("MONITOR + %s", strings.Join(b.cfg.MonitorNicks, ",")) | |
| 155 | + } | |
| 148 | 156 | 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)) | |
| 150 | 181 | } |
| 151 | 182 | }) |
| 152 | 183 | |
| 153 | 184 | c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) { |
| 154 | 185 | if ch := e.Last(); strings.HasPrefix(ch, "#") { |
| @@ -223,10 +254,24 @@ | ||
| 223 | 254 | func (b *Bot) JoinChannel(channel string) { |
| 224 | 255 | if b.client != nil { |
| 225 | 256 | b.client.Cmd.Join(channel) |
| 226 | 257 | } |
| 227 | 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 | +} | |
| 228 | 273 | |
| 229 | 274 | func (b *Bot) window(channel, nick string) *nickWindow { |
| 230 | 275 | if b.windows[channel] == nil { |
| 231 | 276 | b.windows[channel] = make(map[string]*nickWindow) |
| 232 | 277 | } |
| 233 | 278 |
| --- 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 |
+4
-3
| --- internal/bots/steward/steward.go | ||
| +++ internal/bots/steward/steward.go | ||
| @@ -132,10 +132,11 @@ | ||
| 132 | 132 | PingDelay: 30 * time.Second, |
| 133 | 133 | PingTimeout: 30 * time.Second, |
| 134 | 134 | }) |
| 135 | 135 | |
| 136 | 136 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 137 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 137 | 138 | for _, ch := range b.cfg.Channels { |
| 138 | 139 | cl.Cmd.Join(ch) |
| 139 | 140 | } |
| 140 | 141 | cl.Cmd.Join(b.cfg.ModChannel) |
| 141 | 142 | if b.log != nil { |
| @@ -304,12 +305,12 @@ | ||
| 304 | 305 | b.log.Info("steward warn", "nick", nick, "channel", channel, "reason", reason) |
| 305 | 306 | } |
| 306 | 307 | } |
| 307 | 308 | |
| 308 | 309 | 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+"!*@*") | |
| 311 | 312 | key := channel + ":" + nick |
| 312 | 313 | b.mu.Lock() |
| 313 | 314 | b.mutes[key] = time.Now().Add(d) |
| 314 | 315 | b.mu.Unlock() |
| 315 | 316 | b.announce(c, fmt.Sprintf("muted %s in %s for %s", nick, channel, d.Round(time.Second))) |
| @@ -317,11 +318,11 @@ | ||
| 317 | 318 | b.log.Info("steward mute", "nick", nick, "channel", channel, "duration", d) |
| 318 | 319 | } |
| 319 | 320 | } |
| 320 | 321 | |
| 321 | 322 | 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+"!*@*") | |
| 323 | 324 | key := channel + ":" + nick |
| 324 | 325 | b.mu.Lock() |
| 325 | 326 | delete(b.mutes, key) |
| 326 | 327 | b.mu.Unlock() |
| 327 | 328 | b.announce(c, fmt.Sprintf("unmuted %s in %s", nick, channel)) |
| 328 | 329 |
| --- 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 |
+4
-3
| --- internal/bots/steward/steward.go | ||
| +++ internal/bots/steward/steward.go | ||
| @@ -132,10 +132,11 @@ | ||
| 132 | 132 | PingDelay: 30 * time.Second, |
| 133 | 133 | PingTimeout: 30 * time.Second, |
| 134 | 134 | }) |
| 135 | 135 | |
| 136 | 136 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 137 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 137 | 138 | for _, ch := range b.cfg.Channels { |
| 138 | 139 | cl.Cmd.Join(ch) |
| 139 | 140 | } |
| 140 | 141 | cl.Cmd.Join(b.cfg.ModChannel) |
| 141 | 142 | if b.log != nil { |
| @@ -304,12 +305,12 @@ | ||
| 304 | 305 | b.log.Info("steward warn", "nick", nick, "channel", channel, "reason", reason) |
| 305 | 306 | } |
| 306 | 307 | } |
| 307 | 308 | |
| 308 | 309 | 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+"!*@*") | |
| 311 | 312 | key := channel + ":" + nick |
| 312 | 313 | b.mu.Lock() |
| 313 | 314 | b.mutes[key] = time.Now().Add(d) |
| 314 | 315 | b.mu.Unlock() |
| 315 | 316 | b.announce(c, fmt.Sprintf("muted %s in %s for %s", nick, channel, d.Round(time.Second))) |
| @@ -317,11 +318,11 @@ | ||
| 317 | 318 | b.log.Info("steward mute", "nick", nick, "channel", channel, "duration", d) |
| 318 | 319 | } |
| 319 | 320 | } |
| 320 | 321 | |
| 321 | 322 | 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+"!*@*") | |
| 323 | 324 | key := channel + ":" + nick |
| 324 | 325 | b.mu.Lock() |
| 325 | 326 | delete(b.mutes, key) |
| 326 | 327 | b.mu.Unlock() |
| 327 | 328 | b.announce(c, fmt.Sprintf("unmuted %s in %s", nick, channel)) |
| 328 | 329 |
| --- 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 @@ | ||
| 93 | 93 | PingTimeout: 30 * time.Second, |
| 94 | 94 | SSL: false, |
| 95 | 95 | }) |
| 96 | 96 | |
| 97 | 97 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 98 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 98 | 99 | for _, ch := range b.channels { |
| 99 | 100 | cl.Cmd.Join(ch) |
| 100 | 101 | } |
| 101 | 102 | b.log.Info("systembot connected", "channels", b.channels) |
| 102 | 103 | }) |
| 103 | 104 |
| --- 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 @@ | ||
| 93 | 93 | PingTimeout: 30 * time.Second, |
| 94 | 94 | SSL: false, |
| 95 | 95 | }) |
| 96 | 96 | |
| 97 | 97 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 98 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 98 | 99 | for _, ch := range b.channels { |
| 99 | 100 | cl.Cmd.Join(ch) |
| 100 | 101 | } |
| 101 | 102 | b.log.Info("systembot connected", "channels", b.channels) |
| 102 | 103 | }) |
| 103 | 104 |
| --- 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 |
+10
-1
| --- internal/bots/warden/warden.go | ||
| +++ internal/bots/warden/warden.go | ||
| @@ -200,10 +200,11 @@ | ||
| 200 | 200 | PingTimeout: 30 * time.Second, |
| 201 | 201 | SSL: false, |
| 202 | 202 | }) |
| 203 | 203 | |
| 204 | 204 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 205 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 205 | 206 | for _, ch := range b.initChannels { |
| 206 | 207 | cl.Cmd.Join(ch) |
| 207 | 208 | } |
| 208 | 209 | for ch := range b.channelConfigs { |
| 209 | 210 | cl.Cmd.Join(ch) |
| @@ -341,11 +342,19 @@ | ||
| 341 | 342 | switch action { |
| 342 | 343 | case ActionWarn: |
| 343 | 344 | cl.Cmd.Notice(nick, fmt.Sprintf("warden: warning — %s in %s", reason, channel)) |
| 344 | 345 | case ActionMute: |
| 345 | 346 | 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 | + }() | |
| 347 | 356 | case ActionKick: |
| 348 | 357 | cl.Cmd.Kick(channel, nick, "warden: "+reason) |
| 349 | 358 | } |
| 350 | 359 | } |
| 351 | 360 | |
| 352 | 361 |
| --- 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 |
+10
-1
| --- internal/bots/warden/warden.go | ||
| +++ internal/bots/warden/warden.go | ||
| @@ -200,10 +200,11 @@ | ||
| 200 | 200 | PingTimeout: 30 * time.Second, |
| 201 | 201 | SSL: false, |
| 202 | 202 | }) |
| 203 | 203 | |
| 204 | 204 | c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) { |
| 205 | + cl.Cmd.Mode(cl.GetNick(), "+B") | |
| 205 | 206 | for _, ch := range b.initChannels { |
| 206 | 207 | cl.Cmd.Join(ch) |
| 207 | 208 | } |
| 208 | 209 | for ch := range b.channelConfigs { |
| 209 | 210 | cl.Cmd.Join(ch) |
| @@ -341,11 +342,19 @@ | ||
| 341 | 342 | switch action { |
| 342 | 343 | case ActionWarn: |
| 343 | 344 | cl.Cmd.Notice(nick, fmt.Sprintf("warden: warning — %s in %s", reason, channel)) |
| 344 | 345 | case ActionMute: |
| 345 | 346 | 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 | + }() | |
| 347 | 356 | case ActionKick: |
| 348 | 357 | cl.Cmd.Kick(channel, nick, "warden: "+reason) |
| 349 | 358 | } |
| 350 | 359 | } |
| 351 | 360 | |
| 352 | 361 |
| --- 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 @@ | ||
| 278 | 278 | // Voice is a list of nicks to grant voice (+v) access. |
| 279 | 279 | Voice []string `yaml:"voice" json:"voice,omitempty"` |
| 280 | 280 | |
| 281 | 281 | // Autojoin is a list of bot nicks to invite when the channel is provisioned. |
| 282 | 282 | Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"` |
| 283 | + | |
| 284 | + // Modes is a list of channel modes to set after provisioning (e.g. "+m" for moderated). | |
| 285 | + Modes []string `yaml:"modes" json:"modes,omitempty"` | |
| 286 | + | |
| 287 | + // OnJoinMessage is sent to agents when they join this channel. | |
| 288 | + // Supports template variables: {nick}, {channel}. | |
| 289 | + OnJoinMessage string `yaml:"on_join_message" json:"on_join_message,omitempty"` | |
| 283 | 290 | } |
| 284 | 291 | |
| 285 | 292 | // ChannelTypeConfig defines policy rules for a class of dynamically created channels. |
| 286 | 293 | // Matched by prefix against channel names (e.g. prefix "task." matches "#task.gh-42"). |
| 287 | 294 | type ChannelTypeConfig struct { |
| @@ -295,17 +302,23 @@ | ||
| 295 | 302 | // Autojoin is a list of bot nicks to invite when a channel of this type is created. |
| 296 | 303 | Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"` |
| 297 | 304 | |
| 298 | 305 | // Supervision is the coordination channel where summaries should surface. |
| 299 | 306 | Supervision string `yaml:"supervision" json:"supervision,omitempty"` |
| 307 | + | |
| 308 | + // Modes is a list of channel modes to set when provisioning (e.g. "+m" for moderated). | |
| 309 | + Modes []string `yaml:"modes" json:"modes,omitempty"` | |
| 300 | 310 | |
| 301 | 311 | // Ephemeral marks channels of this type for automatic cleanup. |
| 302 | 312 | Ephemeral bool `yaml:"ephemeral" json:"ephemeral,omitempty"` |
| 303 | 313 | |
| 304 | 314 | // TTL is the maximum lifetime of an ephemeral channel with no non-bot members. |
| 305 | 315 | // Zero means no TTL; cleanup only occurs when the channel is empty. |
| 306 | 316 | TTL Duration `yaml:"ttl" json:"ttl,omitempty"` |
| 317 | + | |
| 318 | + // OnJoinMessage is sent to agents when they join a channel of this type. | |
| 319 | + OnJoinMessage string `yaml:"on_join_message" json:"on_join_message,omitempty"` | |
| 307 | 320 | } |
| 308 | 321 | |
| 309 | 322 | // Duration wraps time.Duration for YAML/JSON marshalling ("72h", "30m", etc.). |
| 310 | 323 | type Duration struct { |
| 311 | 324 | time.Duration |
| 312 | 325 |
| --- internal/config/config.go | |
| +++ internal/config/config.go | |
| @@ -278,10 +278,17 @@ | |
| 278 | // Voice is a list of nicks to grant voice (+v) access. |
| 279 | Voice []string `yaml:"voice" json:"voice,omitempty"` |
| 280 | |
| 281 | // Autojoin is a list of bot nicks to invite when the channel is provisioned. |
| 282 | Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"` |
| 283 | } |
| 284 | |
| 285 | // ChannelTypeConfig defines policy rules for a class of dynamically created channels. |
| 286 | // Matched by prefix against channel names (e.g. prefix "task." matches "#task.gh-42"). |
| 287 | type ChannelTypeConfig struct { |
| @@ -295,17 +302,23 @@ | |
| 295 | // Autojoin is a list of bot nicks to invite when a channel of this type is created. |
| 296 | Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"` |
| 297 | |
| 298 | // Supervision is the coordination channel where summaries should surface. |
| 299 | Supervision string `yaml:"supervision" json:"supervision,omitempty"` |
| 300 | |
| 301 | // Ephemeral marks channels of this type for automatic cleanup. |
| 302 | Ephemeral bool `yaml:"ephemeral" json:"ephemeral,omitempty"` |
| 303 | |
| 304 | // TTL is the maximum lifetime of an ephemeral channel with no non-bot members. |
| 305 | // Zero means no TTL; cleanup only occurs when the channel is empty. |
| 306 | TTL Duration `yaml:"ttl" json:"ttl,omitempty"` |
| 307 | } |
| 308 | |
| 309 | // Duration wraps time.Duration for YAML/JSON marshalling ("72h", "30m", etc.). |
| 310 | type Duration struct { |
| 311 | time.Duration |
| 312 |
| --- internal/config/config.go | |
| +++ internal/config/config.go | |
| @@ -278,10 +278,17 @@ | |
| 278 | // Voice is a list of nicks to grant voice (+v) access. |
| 279 | Voice []string `yaml:"voice" json:"voice,omitempty"` |
| 280 | |
| 281 | // Autojoin is a list of bot nicks to invite when the channel is provisioned. |
| 282 | Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"` |
| 283 | |
| 284 | // Modes is a list of channel modes to set after provisioning (e.g. "+m" for moderated). |
| 285 | Modes []string `yaml:"modes" json:"modes,omitempty"` |
| 286 | |
| 287 | // OnJoinMessage is sent to agents when they join this channel. |
| 288 | // Supports template variables: {nick}, {channel}. |
| 289 | OnJoinMessage string `yaml:"on_join_message" json:"on_join_message,omitempty"` |
| 290 | } |
| 291 | |
| 292 | // ChannelTypeConfig defines policy rules for a class of dynamically created channels. |
| 293 | // Matched by prefix against channel names (e.g. prefix "task." matches "#task.gh-42"). |
| 294 | type ChannelTypeConfig struct { |
| @@ -295,17 +302,23 @@ | |
| 302 | // Autojoin is a list of bot nicks to invite when a channel of this type is created. |
| 303 | Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"` |
| 304 | |
| 305 | // Supervision is the coordination channel where summaries should surface. |
| 306 | Supervision string `yaml:"supervision" json:"supervision,omitempty"` |
| 307 | |
| 308 | // Modes is a list of channel modes to set when provisioning (e.g. "+m" for moderated). |
| 309 | Modes []string `yaml:"modes" json:"modes,omitempty"` |
| 310 | |
| 311 | // Ephemeral marks channels of this type for automatic cleanup. |
| 312 | Ephemeral bool `yaml:"ephemeral" json:"ephemeral,omitempty"` |
| 313 | |
| 314 | // TTL is the maximum lifetime of an ephemeral channel with no non-bot members. |
| 315 | // Zero means no TTL; cleanup only occurs when the channel is empty. |
| 316 | TTL Duration `yaml:"ttl" json:"ttl,omitempty"` |
| 317 | |
| 318 | // OnJoinMessage is sent to agents when they join a channel of this type. |
| 319 | OnJoinMessage string `yaml:"on_join_message" json:"on_join_message,omitempty"` |
| 320 | } |
| 321 | |
| 322 | // Duration wraps time.Duration for YAML/JSON marshalling ("72h", "30m", etc.). |
| 323 | type Duration struct { |
| 324 | time.Duration |
| 325 |
+3
-1
| --- internal/ergo/ircdconfig.go | ||
| +++ internal/ergo/ircdconfig.go | ||
| @@ -23,11 +23,13 @@ | ||
| 23 | 23 | {{- end}} |
| 24 | 24 | casemapping: ascii |
| 25 | 25 | enforce-utf8: true |
| 26 | 26 | max-sendq: 96k |
| 27 | 27 | relaymsg: |
| 28 | - enabled: false | |
| 28 | + enabled: true | |
| 29 | + separators: / | |
| 30 | + available-to-chanops: false | |
| 29 | 31 | ip-cloaking: |
| 30 | 32 | enabled: false |
| 31 | 33 | lookup-hostnames: false |
| 32 | 34 | |
| 33 | 35 | datastore: |
| 34 | 36 |
| --- internal/ergo/ircdconfig.go | |
| +++ internal/ergo/ircdconfig.go | |
| @@ -23,11 +23,13 @@ | |
| 23 | {{- end}} |
| 24 | casemapping: ascii |
| 25 | enforce-utf8: true |
| 26 | max-sendq: 96k |
| 27 | relaymsg: |
| 28 | enabled: false |
| 29 | ip-cloaking: |
| 30 | enabled: false |
| 31 | lookup-hostnames: false |
| 32 | |
| 33 | datastore: |
| 34 |
| --- internal/ergo/ircdconfig.go | |
| +++ internal/ergo/ircdconfig.go | |
| @@ -23,11 +23,13 @@ | |
| 23 | {{- end}} |
| 24 | casemapping: ascii |
| 25 | enforce-utf8: true |
| 26 | max-sendq: 96k |
| 27 | relaymsg: |
| 28 | enabled: true |
| 29 | separators: / |
| 30 | available-to-chanops: false |
| 31 | ip-cloaking: |
| 32 | enabled: false |
| 33 | lookup-hostnames: false |
| 34 | |
| 35 | datastore: |
| 36 |
| --- internal/ergo/manager.go | ||
| +++ internal/ergo/manager.go | ||
| @@ -115,10 +115,17 @@ | ||
| 115 | 115 | } |
| 116 | 116 | wait = min(wait*2, restartMaxWait) //nolint:ineffassign,staticcheck |
| 117 | 117 | } |
| 118 | 118 | } |
| 119 | 119 | } |
| 120 | + | |
| 121 | +// UpdateConfig replaces the Ergo config, regenerates ircd.yaml, and rehashes. | |
| 122 | +// Use when scuttlebot.yaml Ergo settings change at runtime. | |
| 123 | +func (m *Manager) UpdateConfig(cfg config.ErgoConfig) error { | |
| 124 | + m.cfg = cfg | |
| 125 | + return m.Rehash() | |
| 126 | +} | |
| 120 | 127 | |
| 121 | 128 | // Rehash reloads the Ergo config. Call after writing a new ircd.yaml. |
| 122 | 129 | func (m *Manager) Rehash() error { |
| 123 | 130 | if err := m.writeConfig(); err != nil { |
| 124 | 131 | return fmt.Errorf("ergo: write config: %w", err) |
| 125 | 132 |
| --- internal/ergo/manager.go | |
| +++ internal/ergo/manager.go | |
| @@ -115,10 +115,17 @@ | |
| 115 | } |
| 116 | wait = min(wait*2, restartMaxWait) //nolint:ineffassign,staticcheck |
| 117 | } |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | // Rehash reloads the Ergo config. Call after writing a new ircd.yaml. |
| 122 | func (m *Manager) Rehash() error { |
| 123 | if err := m.writeConfig(); err != nil { |
| 124 | return fmt.Errorf("ergo: write config: %w", err) |
| 125 |
| --- internal/ergo/manager.go | |
| +++ internal/ergo/manager.go | |
| @@ -115,10 +115,17 @@ | |
| 115 | } |
| 116 | wait = min(wait*2, restartMaxWait) //nolint:ineffassign,staticcheck |
| 117 | } |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | // UpdateConfig replaces the Ergo config, regenerates ircd.yaml, and rehashes. |
| 122 | // Use when scuttlebot.yaml Ergo settings change at runtime. |
| 123 | func (m *Manager) UpdateConfig(cfg config.ErgoConfig) error { |
| 124 | m.cfg = cfg |
| 125 | return m.Rehash() |
| 126 | } |
| 127 | |
| 128 | // Rehash reloads the Ergo config. Call after writing a new ircd.yaml. |
| 129 | func (m *Manager) Rehash() error { |
| 130 | if err := m.writeConfig(); err != nil { |
| 131 | return fmt.Errorf("ergo: write config: %w", err) |
| 132 |
+9
-8
| --- internal/mcp/mcp.go | ||
| +++ internal/mcp/mcp.go | ||
| @@ -52,31 +52,32 @@ | ||
| 52 | 52 | type ChannelInfo struct { |
| 53 | 53 | Name string `json:"name"` |
| 54 | 54 | Topic string `json:"topic,omitempty"` |
| 55 | 55 | Count int `json:"count"` |
| 56 | 56 | } |
| 57 | + | |
| 58 | +// TokenValidator validates API tokens. | |
| 59 | +type TokenValidator interface { | |
| 60 | + ValidToken(token string) bool | |
| 61 | +} | |
| 57 | 62 | |
| 58 | 63 | // Server is the MCP server. |
| 59 | 64 | type Server struct { |
| 60 | 65 | registry *registry.Registry |
| 61 | 66 | channels ChannelLister |
| 62 | 67 | sender Sender // optional — send_message returns error if nil |
| 63 | 68 | history HistoryQuerier // optional — get_history returns error if nil |
| 64 | - tokens map[string]struct{} | |
| 69 | + tokens TokenValidator | |
| 65 | 70 | log *slog.Logger |
| 66 | 71 | } |
| 67 | 72 | |
| 68 | 73 | // New creates an MCP Server. |
| 69 | -func New(reg *registry.Registry, channels ChannelLister, tokens []string, log *slog.Logger) *Server { | |
| 70 | - t := make(map[string]struct{}, len(tokens)) | |
| 71 | - for _, tok := range tokens { | |
| 72 | - t[tok] = struct{}{} | |
| 73 | - } | |
| 74 | +func New(reg *registry.Registry, channels ChannelLister, tokens TokenValidator, log *slog.Logger) *Server { | |
| 74 | 75 | return &Server{ |
| 75 | 76 | registry: reg, |
| 76 | 77 | channels: channels, |
| 77 | - tokens: t, | |
| 78 | + tokens: tokens, | |
| 78 | 79 | log: log, |
| 79 | 80 | } |
| 80 | 81 | } |
| 81 | 82 | |
| 82 | 83 | // WithSender attaches an IRC relay client for send_message. |
| @@ -101,11 +102,11 @@ | ||
| 101 | 102 | // --- Auth --- |
| 102 | 103 | |
| 103 | 104 | func (s *Server) authMiddleware(next http.Handler) http.Handler { |
| 104 | 105 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 105 | 106 | token := bearerToken(r) |
| 106 | - if _, ok := s.tokens[token]; !ok { | |
| 107 | + if !s.tokens.ValidToken(token) { | |
| 107 | 108 | writeRPCError(w, nil, -32001, "unauthorized") |
| 108 | 109 | return |
| 109 | 110 | } |
| 110 | 111 | next.ServeHTTP(w, r) |
| 111 | 112 | }) |
| 112 | 113 |
| --- internal/mcp/mcp.go | |
| +++ internal/mcp/mcp.go | |
| @@ -52,31 +52,32 @@ | |
| 52 | type ChannelInfo struct { |
| 53 | Name string `json:"name"` |
| 54 | Topic string `json:"topic,omitempty"` |
| 55 | Count int `json:"count"` |
| 56 | } |
| 57 | |
| 58 | // Server is the MCP server. |
| 59 | type Server struct { |
| 60 | registry *registry.Registry |
| 61 | channels ChannelLister |
| 62 | sender Sender // optional — send_message returns error if nil |
| 63 | history HistoryQuerier // optional — get_history returns error if nil |
| 64 | tokens map[string]struct{} |
| 65 | log *slog.Logger |
| 66 | } |
| 67 | |
| 68 | // New creates an MCP Server. |
| 69 | func New(reg *registry.Registry, channels ChannelLister, tokens []string, log *slog.Logger) *Server { |
| 70 | t := make(map[string]struct{}, len(tokens)) |
| 71 | for _, tok := range tokens { |
| 72 | t[tok] = struct{}{} |
| 73 | } |
| 74 | return &Server{ |
| 75 | registry: reg, |
| 76 | channels: channels, |
| 77 | tokens: t, |
| 78 | log: log, |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | // WithSender attaches an IRC relay client for send_message. |
| @@ -101,11 +102,11 @@ | |
| 101 | // --- Auth --- |
| 102 | |
| 103 | func (s *Server) authMiddleware(next http.Handler) http.Handler { |
| 104 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 105 | token := bearerToken(r) |
| 106 | if _, ok := s.tokens[token]; !ok { |
| 107 | writeRPCError(w, nil, -32001, "unauthorized") |
| 108 | return |
| 109 | } |
| 110 | next.ServeHTTP(w, r) |
| 111 | }) |
| 112 |
| --- internal/mcp/mcp.go | |
| +++ internal/mcp/mcp.go | |
| @@ -52,31 +52,32 @@ | |
| 52 | type ChannelInfo struct { |
| 53 | Name string `json:"name"` |
| 54 | Topic string `json:"topic,omitempty"` |
| 55 | Count int `json:"count"` |
| 56 | } |
| 57 | |
| 58 | // TokenValidator validates API tokens. |
| 59 | type TokenValidator interface { |
| 60 | ValidToken(token string) bool |
| 61 | } |
| 62 | |
| 63 | // Server is the MCP server. |
| 64 | type Server struct { |
| 65 | registry *registry.Registry |
| 66 | channels ChannelLister |
| 67 | sender Sender // optional — send_message returns error if nil |
| 68 | history HistoryQuerier // optional — get_history returns error if nil |
| 69 | tokens TokenValidator |
| 70 | log *slog.Logger |
| 71 | } |
| 72 | |
| 73 | // New creates an MCP Server. |
| 74 | func New(reg *registry.Registry, channels ChannelLister, tokens TokenValidator, log *slog.Logger) *Server { |
| 75 | return &Server{ |
| 76 | registry: reg, |
| 77 | channels: channels, |
| 78 | tokens: tokens, |
| 79 | log: log, |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | // WithSender attaches an IRC relay client for send_message. |
| @@ -101,11 +102,11 @@ | |
| 102 | // --- Auth --- |
| 103 | |
| 104 | func (s *Server) authMiddleware(next http.Handler) http.Handler { |
| 105 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 106 | token := bearerToken(r) |
| 107 | if !s.tokens.ValidToken(token) { |
| 108 | writeRPCError(w, nil, -32001, "unauthorized") |
| 109 | return |
| 110 | } |
| 111 | next.ServeHTTP(w, r) |
| 112 | }) |
| 113 |
+8
-1
| --- internal/mcp/mcp_test.go | ||
| +++ internal/mcp/mcp_test.go | ||
| @@ -19,10 +19,17 @@ | ||
| 19 | 19 | var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) |
| 20 | 20 | |
| 21 | 21 | const testToken = "test-mcp-token" |
| 22 | 22 | |
| 23 | 23 | // --- mocks --- |
| 24 | + | |
| 25 | +type tokenSet map[string]struct{} | |
| 26 | + | |
| 27 | +func (t tokenSet) ValidToken(tok string) bool { | |
| 28 | + _, ok := t[tok] | |
| 29 | + return ok | |
| 30 | +} | |
| 24 | 31 | |
| 25 | 32 | type mockProvisioner struct { |
| 26 | 33 | mu sync.Mutex |
| 27 | 34 | accounts map[string]string |
| 28 | 35 | } |
| @@ -93,11 +100,11 @@ | ||
| 93 | 100 | hist := &mockHistory{entries: map[string][]mcp.HistoryEntry{ |
| 94 | 101 | "#fleet": { |
| 95 | 102 | {Nick: "agent-01", MessageType: "task.create", MessageID: "01HX", Raw: `{"v":1}`}, |
| 96 | 103 | }, |
| 97 | 104 | }} |
| 98 | - srv := mcp.New(reg, channels, []string{testToken}, testLog). | |
| 105 | + srv := mcp.New(reg, channels, tokenSet{testToken: {}}, testLog). | |
| 99 | 106 | WithSender(sender). |
| 100 | 107 | WithHistory(hist) |
| 101 | 108 | return httptest.NewServer(srv.Handler()) |
| 102 | 109 | } |
| 103 | 110 | |
| 104 | 111 |
| --- internal/mcp/mcp_test.go | |
| +++ internal/mcp/mcp_test.go | |
| @@ -19,10 +19,17 @@ | |
| 19 | var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) |
| 20 | |
| 21 | const testToken = "test-mcp-token" |
| 22 | |
| 23 | // --- mocks --- |
| 24 | |
| 25 | type mockProvisioner struct { |
| 26 | mu sync.Mutex |
| 27 | accounts map[string]string |
| 28 | } |
| @@ -93,11 +100,11 @@ | |
| 93 | hist := &mockHistory{entries: map[string][]mcp.HistoryEntry{ |
| 94 | "#fleet": { |
| 95 | {Nick: "agent-01", MessageType: "task.create", MessageID: "01HX", Raw: `{"v":1}`}, |
| 96 | }, |
| 97 | }} |
| 98 | srv := mcp.New(reg, channels, []string{testToken}, testLog). |
| 99 | WithSender(sender). |
| 100 | WithHistory(hist) |
| 101 | return httptest.NewServer(srv.Handler()) |
| 102 | } |
| 103 | |
| 104 |
| --- internal/mcp/mcp_test.go | |
| +++ internal/mcp/mcp_test.go | |
| @@ -19,10 +19,17 @@ | |
| 19 | var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) |
| 20 | |
| 21 | const testToken = "test-mcp-token" |
| 22 | |
| 23 | // --- mocks --- |
| 24 | |
| 25 | type tokenSet map[string]struct{} |
| 26 | |
| 27 | func (t tokenSet) ValidToken(tok string) bool { |
| 28 | _, ok := t[tok] |
| 29 | return ok |
| 30 | } |
| 31 | |
| 32 | type mockProvisioner struct { |
| 33 | mu sync.Mutex |
| 34 | accounts map[string]string |
| 35 | } |
| @@ -93,11 +100,11 @@ | |
| 100 | hist := &mockHistory{entries: map[string][]mcp.HistoryEntry{ |
| 101 | "#fleet": { |
| 102 | {Nick: "agent-01", MessageType: "task.create", MessageID: "01HX", Raw: `{"v":1}`}, |
| 103 | }, |
| 104 | }} |
| 105 | srv := mcp.New(reg, channels, tokenSet{testToken: {}}, testLog). |
| 106 | WithSender(sender). |
| 107 | WithHistory(hist) |
| 108 | return httptest.NewServer(srv.Handler()) |
| 109 | } |
| 110 | |
| 111 |
| --- internal/registry/registry.go | ||
| +++ internal/registry/registry.go | ||
| @@ -35,10 +35,11 @@ | ||
| 35 | 35 | Nick string `json:"nick"` |
| 36 | 36 | Type AgentType `json:"type"` |
| 37 | 37 | Channels []string `json:"channels"` // convenience: same as Config.Channels |
| 38 | 38 | Permissions []string `json:"permissions"` // convenience: same as Config.Permissions |
| 39 | 39 | Config EngagementConfig `json:"config"` |
| 40 | + Skills []string `json:"skills,omitempty"` // agent capabilities (e.g. "go", "python", "react") | |
| 40 | 41 | CreatedAt time.Time `json:"created_at"` |
| 41 | 42 | Revoked bool `json:"revoked"` |
| 42 | 43 | LastSeen *time.Time `json:"last_seen,omitempty"` |
| 43 | 44 | Online bool `json:"online"` |
| 44 | 45 | } |
| @@ -354,10 +355,22 @@ | ||
| 354 | 355 | return nil |
| 355 | 356 | } |
| 356 | 357 | |
| 357 | 358 | // UpdateChannels replaces the channel list for an active agent. |
| 358 | 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 | + | |
| 359 | 372 | func (r *Registry) UpdateChannels(nick string, channels []string) error { |
| 360 | 373 | r.mu.Lock() |
| 361 | 374 | defer r.mu.Unlock() |
| 362 | 375 | agent, err := r.get(nick) |
| 363 | 376 | if err != nil { |
| 364 | 377 |
| --- 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 @@ | ||
| 11 | 11 | // ChannelType is the resolved policy for a class of channels. |
| 12 | 12 | type ChannelType struct { |
| 13 | 13 | Name string |
| 14 | 14 | Prefix string |
| 15 | 15 | Autojoin []string |
| 16 | + Modes []string | |
| 16 | 17 | Supervision string |
| 17 | 18 | Ephemeral bool |
| 18 | 19 | TTL time.Duration |
| 19 | 20 | } |
| 20 | 21 | |
| @@ -61,10 +62,11 @@ | ||
| 61 | 62 | for _, t := range cfg.Types { |
| 62 | 63 | types = append(types, ChannelType{ |
| 63 | 64 | Name: t.Name, |
| 64 | 65 | Prefix: t.Prefix, |
| 65 | 66 | Autojoin: append([]string(nil), t.Autojoin...), |
| 67 | + Modes: append([]string(nil), t.Modes...), | |
| 66 | 68 | Supervision: t.Supervision, |
| 67 | 69 | Ephemeral: t.Ephemeral, |
| 68 | 70 | TTL: t.TTL.Duration, |
| 69 | 71 | }) |
| 70 | 72 | } |
| @@ -133,10 +135,18 @@ | ||
| 133 | 135 | if t := p.Match(channel); t != nil { |
| 134 | 136 | return t.TTL |
| 135 | 137 | } |
| 136 | 138 | return 0 |
| 137 | 139 | } |
| 140 | + | |
| 141 | +// ModesFor returns the channel modes for the given channel, or nil. | |
| 142 | +func (p *Policy) ModesFor(channel string) []string { | |
| 143 | + if t := p.Match(channel); t != nil { | |
| 144 | + return append([]string(nil), t.Modes...) | |
| 145 | + } | |
| 146 | + return nil | |
| 147 | +} | |
| 138 | 148 | |
| 139 | 149 | // StaticChannels returns the list of channels to provision at startup. |
| 140 | 150 | func (p *Policy) StaticChannels() []config.StaticChannelConfig { |
| 141 | 151 | return append([]config.StaticChannelConfig(nil), p.staticChannels...) |
| 142 | 152 | } |
| 143 | 153 |
| --- internal/topology/policy.go | |
| +++ internal/topology/policy.go | |
| @@ -11,10 +11,11 @@ | |
| 11 | // ChannelType is the resolved policy for a class of channels. |
| 12 | type ChannelType struct { |
| 13 | Name string |
| 14 | Prefix string |
| 15 | Autojoin []string |
| 16 | Supervision string |
| 17 | Ephemeral bool |
| 18 | TTL time.Duration |
| 19 | } |
| 20 | |
| @@ -61,10 +62,11 @@ | |
| 61 | for _, t := range cfg.Types { |
| 62 | types = append(types, ChannelType{ |
| 63 | Name: t.Name, |
| 64 | Prefix: t.Prefix, |
| 65 | Autojoin: append([]string(nil), t.Autojoin...), |
| 66 | Supervision: t.Supervision, |
| 67 | Ephemeral: t.Ephemeral, |
| 68 | TTL: t.TTL.Duration, |
| 69 | }) |
| 70 | } |
| @@ -133,10 +135,18 @@ | |
| 133 | if t := p.Match(channel); t != nil { |
| 134 | return t.TTL |
| 135 | } |
| 136 | return 0 |
| 137 | } |
| 138 | |
| 139 | // StaticChannels returns the list of channels to provision at startup. |
| 140 | func (p *Policy) StaticChannels() []config.StaticChannelConfig { |
| 141 | return append([]config.StaticChannelConfig(nil), p.staticChannels...) |
| 142 | } |
| 143 |
| --- internal/topology/policy.go | |
| +++ internal/topology/policy.go | |
| @@ -11,10 +11,11 @@ | |
| 11 | // ChannelType is the resolved policy for a class of channels. |
| 12 | type ChannelType struct { |
| 13 | Name string |
| 14 | Prefix string |
| 15 | Autojoin []string |
| 16 | Modes []string |
| 17 | Supervision string |
| 18 | Ephemeral bool |
| 19 | TTL time.Duration |
| 20 | } |
| 21 | |
| @@ -61,10 +62,11 @@ | |
| 62 | for _, t := range cfg.Types { |
| 63 | types = append(types, ChannelType{ |
| 64 | Name: t.Name, |
| 65 | Prefix: t.Prefix, |
| 66 | Autojoin: append([]string(nil), t.Autojoin...), |
| 67 | Modes: append([]string(nil), t.Modes...), |
| 68 | Supervision: t.Supervision, |
| 69 | Ephemeral: t.Ephemeral, |
| 70 | TTL: t.TTL.Duration, |
| 71 | }) |
| 72 | } |
| @@ -133,10 +135,18 @@ | |
| 135 | if t := p.Match(channel); t != nil { |
| 136 | return t.TTL |
| 137 | } |
| 138 | return 0 |
| 139 | } |
| 140 | |
| 141 | // ModesFor returns the channel modes for the given channel, or nil. |
| 142 | func (p *Policy) ModesFor(channel string) []string { |
| 143 | if t := p.Match(channel); t != nil { |
| 144 | return append([]string(nil), t.Modes...) |
| 145 | } |
| 146 | return nil |
| 147 | } |
| 148 | |
| 149 | // StaticChannels returns the list of channels to provision at startup. |
| 150 | func (p *Policy) StaticChannels() []config.StaticChannelConfig { |
| 151 | return append([]config.StaticChannelConfig(nil), p.staticChannels...) |
| 152 | } |
| 153 |
+65
-11
| --- internal/topology/topology.go | ||
| +++ internal/topology/topology.go | ||
| @@ -24,18 +24,24 @@ | ||
| 24 | 24 | Name string |
| 25 | 25 | |
| 26 | 26 | // Topic is the initial channel topic (shared state header). |
| 27 | 27 | Topic string |
| 28 | 28 | |
| 29 | - // Ops is a list of nicks to grant +o (channel operator) status. | |
| 29 | + // Ops is a list of nicks to grant +o (channel operator) status via AMODE. | |
| 30 | 30 | Ops []string |
| 31 | 31 | |
| 32 | - // Voice is a list of nicks to grant +v status. | |
| 32 | + // Voice is a list of nicks to grant +v status via AMODE. | |
| 33 | 33 | Voice []string |
| 34 | 34 | |
| 35 | 35 | // Autojoin is a list of bot nicks to invite after provisioning. |
| 36 | 36 | Autojoin []string |
| 37 | + | |
| 38 | + // Modes is a list of channel modes to set (e.g. "+m" for moderated). | |
| 39 | + Modes []string | |
| 40 | + | |
| 41 | + // OnJoinMessage is sent to agents when they join this channel. | |
| 42 | + OnJoinMessage string | |
| 37 | 43 | } |
| 38 | 44 | |
| 39 | 45 | // channelRecord tracks a provisioned channel for TTL-based reaping. |
| 40 | 46 | type channelRecord struct { |
| 41 | 47 | name string |
| @@ -207,15 +213,21 @@ | ||
| 207 | 213 | |
| 208 | 214 | if ch.Topic != "" { |
| 209 | 215 | m.chanserv("TOPIC %s %s", ch.Name, ch.Topic) |
| 210 | 216 | } |
| 211 | 217 | |
| 218 | + // Use AMODE for persistent auto-mode on join (survives reconnects). | |
| 212 | 219 | for _, nick := range ch.Ops { |
| 213 | - m.chanserv("ACCESS %s ADD %s OP", ch.Name, nick) | |
| 220 | + m.chanserv("AMODE %s +o %s", ch.Name, nick) | |
| 214 | 221 | } |
| 215 | 222 | for _, nick := range ch.Voice { |
| 216 | - m.chanserv("ACCESS %s ADD %s VOICE", ch.Name, nick) | |
| 223 | + m.chanserv("AMODE %s +v %s", ch.Name, nick) | |
| 224 | + } | |
| 225 | + | |
| 226 | + // Apply channel modes (e.g. +m for moderated). | |
| 227 | + for _, mode := range ch.Modes { | |
| 228 | + m.client.Cmd.Mode(ch.Name, mode) | |
| 217 | 229 | } |
| 218 | 230 | |
| 219 | 231 | if len(ch.Autojoin) > 0 { |
| 220 | 232 | m.Invite(ch.Name, ch.Autojoin) |
| 221 | 233 | } |
| @@ -274,33 +286,75 @@ | ||
| 274 | 286 | m.log.Info("reaping expired ephemeral channel", "channel", rec.name, "age", now.Sub(rec.provisionedAt).Round(time.Minute)) |
| 275 | 287 | m.DropChannel(rec.name) |
| 276 | 288 | } |
| 277 | 289 | } |
| 278 | 290 | |
| 279 | -// GrantAccess sets a ChanServ ACCESS entry for nick on the given channel. | |
| 280 | -// level is "OP" or "VOICE". If level is empty, no access is granted. | |
| 291 | +// GrantAccess sets a ChanServ AMODE entry for nick on the given channel. | |
| 292 | +// level is "OP" or "VOICE". AMODE persists across reconnects — ChanServ | |
| 293 | +// automatically applies the mode every time the nick joins. | |
| 281 | 294 | func (m *Manager) GrantAccess(nick, channel, level string) { |
| 282 | 295 | if m.client == nil || level == "" { |
| 283 | 296 | return |
| 284 | 297 | } |
| 285 | - m.chanserv("ACCESS %s ADD %s %s", channel, nick, level) | |
| 286 | - m.log.Info("granted channel access", "nick", nick, "channel", channel, "level", level) | |
| 298 | + switch strings.ToUpper(level) { | |
| 299 | + case "OP": | |
| 300 | + m.chanserv("AMODE %s +o %s", channel, nick) | |
| 301 | + case "VOICE": | |
| 302 | + m.chanserv("AMODE %s +v %s", channel, nick) | |
| 303 | + default: | |
| 304 | + m.log.Warn("unknown access level", "level", level) | |
| 305 | + return | |
| 306 | + } | |
| 307 | + m.log.Info("granted channel access (AMODE)", "nick", nick, "channel", channel, "level", level) | |
| 287 | 308 | } |
| 288 | 309 | |
| 289 | -// RevokeAccess removes a ChanServ ACCESS entry for nick on the given channel. | |
| 310 | +// RevokeAccess removes ChanServ AMODE entries for nick on the given channel. | |
| 290 | 311 | func (m *Manager) RevokeAccess(nick, channel string) { |
| 291 | 312 | if m.client == nil { |
| 292 | 313 | return |
| 293 | 314 | } |
| 294 | - m.chanserv("ACCESS %s DEL %s", channel, nick) | |
| 295 | - m.log.Info("revoked channel access", "nick", nick, "channel", channel) | |
| 315 | + m.chanserv("AMODE %s -o %s", channel, nick) | |
| 316 | + m.chanserv("AMODE %s -v %s", channel, nick) | |
| 317 | + m.log.Info("revoked channel access (AMODE)", "nick", nick, "channel", channel) | |
| 296 | 318 | } |
| 297 | 319 | |
| 298 | 320 | func (m *Manager) chanserv(format string, args ...any) { |
| 299 | 321 | msg := fmt.Sprintf(format, args...) |
| 300 | 322 | m.client.Cmd.Message("ChanServ", msg) |
| 301 | 323 | } |
| 324 | + | |
| 325 | +// ChannelInfo describes an active provisioned channel. | |
| 326 | +type ChannelInfo struct { | |
| 327 | + Name string `json:"name"` | |
| 328 | + ProvisionedAt time.Time `json:"provisioned_at"` | |
| 329 | + Type string `json:"type,omitempty"` | |
| 330 | + Ephemeral bool `json:"ephemeral,omitempty"` | |
| 331 | + TTLSeconds int64 `json:"ttl_seconds,omitempty"` | |
| 332 | +} | |
| 333 | + | |
| 334 | +// ListChannels returns all actively provisioned channels. | |
| 335 | +func (m *Manager) ListChannels() []ChannelInfo { | |
| 336 | + m.mu.Lock() | |
| 337 | + defer m.mu.Unlock() | |
| 338 | + out := make([]ChannelInfo, 0, len(m.channels)) | |
| 339 | + for _, rec := range m.channels { | |
| 340 | + ci := ChannelInfo{ | |
| 341 | + Name: rec.name, | |
| 342 | + ProvisionedAt: rec.provisionedAt, | |
| 343 | + } | |
| 344 | + if m.policy != nil { | |
| 345 | + ci.Type = m.policy.TypeName(rec.name) | |
| 346 | + ci.Ephemeral = m.policy.IsEphemeral(rec.name) | |
| 347 | + ttl := m.policy.TTLFor(rec.name) | |
| 348 | + if ttl > 0 { | |
| 349 | + ci.TTLSeconds = int64(ttl.Seconds()) | |
| 350 | + } | |
| 351 | + } | |
| 352 | + out = append(out, ci) | |
| 353 | + } | |
| 354 | + return out | |
| 355 | +} | |
| 302 | 356 | |
| 303 | 357 | // ValidateName checks that a channel name follows scuttlebot conventions. |
| 304 | 358 | func ValidateName(name string) error { |
| 305 | 359 | if !strings.HasPrefix(name, "#") { |
| 306 | 360 | return fmt.Errorf("topology: channel name must start with #: %q", name) |
| 307 | 361 | |
| 308 | 362 | ADDED pkg/chathistory/chathistory.go |
| --- internal/topology/topology.go | |
| +++ internal/topology/topology.go | |
| @@ -24,18 +24,24 @@ | |
| 24 | Name string |
| 25 | |
| 26 | // Topic is the initial channel topic (shared state header). |
| 27 | Topic string |
| 28 | |
| 29 | // Ops is a list of nicks to grant +o (channel operator) status. |
| 30 | Ops []string |
| 31 | |
| 32 | // Voice is a list of nicks to grant +v status. |
| 33 | Voice []string |
| 34 | |
| 35 | // Autojoin is a list of bot nicks to invite after provisioning. |
| 36 | Autojoin []string |
| 37 | } |
| 38 | |
| 39 | // channelRecord tracks a provisioned channel for TTL-based reaping. |
| 40 | type channelRecord struct { |
| 41 | name string |
| @@ -207,15 +213,21 @@ | |
| 207 | |
| 208 | if ch.Topic != "" { |
| 209 | m.chanserv("TOPIC %s %s", ch.Name, ch.Topic) |
| 210 | } |
| 211 | |
| 212 | for _, nick := range ch.Ops { |
| 213 | m.chanserv("ACCESS %s ADD %s OP", ch.Name, nick) |
| 214 | } |
| 215 | for _, nick := range ch.Voice { |
| 216 | m.chanserv("ACCESS %s ADD %s VOICE", ch.Name, nick) |
| 217 | } |
| 218 | |
| 219 | if len(ch.Autojoin) > 0 { |
| 220 | m.Invite(ch.Name, ch.Autojoin) |
| 221 | } |
| @@ -274,33 +286,75 @@ | |
| 274 | m.log.Info("reaping expired ephemeral channel", "channel", rec.name, "age", now.Sub(rec.provisionedAt).Round(time.Minute)) |
| 275 | m.DropChannel(rec.name) |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | // GrantAccess sets a ChanServ ACCESS entry for nick on the given channel. |
| 280 | // level is "OP" or "VOICE". If level is empty, no access is granted. |
| 281 | func (m *Manager) GrantAccess(nick, channel, level string) { |
| 282 | if m.client == nil || level == "" { |
| 283 | return |
| 284 | } |
| 285 | m.chanserv("ACCESS %s ADD %s %s", channel, nick, level) |
| 286 | m.log.Info("granted channel access", "nick", nick, "channel", channel, "level", level) |
| 287 | } |
| 288 | |
| 289 | // RevokeAccess removes a ChanServ ACCESS entry for nick on the given channel. |
| 290 | func (m *Manager) RevokeAccess(nick, channel string) { |
| 291 | if m.client == nil { |
| 292 | return |
| 293 | } |
| 294 | m.chanserv("ACCESS %s DEL %s", channel, nick) |
| 295 | m.log.Info("revoked channel access", "nick", nick, "channel", channel) |
| 296 | } |
| 297 | |
| 298 | func (m *Manager) chanserv(format string, args ...any) { |
| 299 | msg := fmt.Sprintf(format, args...) |
| 300 | m.client.Cmd.Message("ChanServ", msg) |
| 301 | } |
| 302 | |
| 303 | // ValidateName checks that a channel name follows scuttlebot conventions. |
| 304 | func ValidateName(name string) error { |
| 305 | if !strings.HasPrefix(name, "#") { |
| 306 | return fmt.Errorf("topology: channel name must start with #: %q", name) |
| 307 | |
| 308 | DDED pkg/chathistory/chathistory.go |
| --- internal/topology/topology.go | |
| +++ internal/topology/topology.go | |
| @@ -24,18 +24,24 @@ | |
| 24 | Name string |
| 25 | |
| 26 | // Topic is the initial channel topic (shared state header). |
| 27 | Topic string |
| 28 | |
| 29 | // Ops is a list of nicks to grant +o (channel operator) status via AMODE. |
| 30 | Ops []string |
| 31 | |
| 32 | // Voice is a list of nicks to grant +v status via AMODE. |
| 33 | Voice []string |
| 34 | |
| 35 | // Autojoin is a list of bot nicks to invite after provisioning. |
| 36 | Autojoin []string |
| 37 | |
| 38 | // Modes is a list of channel modes to set (e.g. "+m" for moderated). |
| 39 | Modes []string |
| 40 | |
| 41 | // OnJoinMessage is sent to agents when they join this channel. |
| 42 | OnJoinMessage string |
| 43 | } |
| 44 | |
| 45 | // channelRecord tracks a provisioned channel for TTL-based reaping. |
| 46 | type channelRecord struct { |
| 47 | name string |
| @@ -207,15 +213,21 @@ | |
| 213 | |
| 214 | if ch.Topic != "" { |
| 215 | m.chanserv("TOPIC %s %s", ch.Name, ch.Topic) |
| 216 | } |
| 217 | |
| 218 | // Use AMODE for persistent auto-mode on join (survives reconnects). |
| 219 | for _, nick := range ch.Ops { |
| 220 | m.chanserv("AMODE %s +o %s", ch.Name, nick) |
| 221 | } |
| 222 | for _, nick := range ch.Voice { |
| 223 | m.chanserv("AMODE %s +v %s", ch.Name, nick) |
| 224 | } |
| 225 | |
| 226 | // Apply channel modes (e.g. +m for moderated). |
| 227 | for _, mode := range ch.Modes { |
| 228 | m.client.Cmd.Mode(ch.Name, mode) |
| 229 | } |
| 230 | |
| 231 | if len(ch.Autojoin) > 0 { |
| 232 | m.Invite(ch.Name, ch.Autojoin) |
| 233 | } |
| @@ -274,33 +286,75 @@ | |
| 286 | m.log.Info("reaping expired ephemeral channel", "channel", rec.name, "age", now.Sub(rec.provisionedAt).Round(time.Minute)) |
| 287 | m.DropChannel(rec.name) |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | // GrantAccess sets a ChanServ AMODE entry for nick on the given channel. |
| 292 | // level is "OP" or "VOICE". AMODE persists across reconnects — ChanServ |
| 293 | // automatically applies the mode every time the nick joins. |
| 294 | func (m *Manager) GrantAccess(nick, channel, level string) { |
| 295 | if m.client == nil || level == "" { |
| 296 | return |
| 297 | } |
| 298 | switch strings.ToUpper(level) { |
| 299 | case "OP": |
| 300 | m.chanserv("AMODE %s +o %s", channel, nick) |
| 301 | case "VOICE": |
| 302 | m.chanserv("AMODE %s +v %s", channel, nick) |
| 303 | default: |
| 304 | m.log.Warn("unknown access level", "level", level) |
| 305 | return |
| 306 | } |
| 307 | m.log.Info("granted channel access (AMODE)", "nick", nick, "channel", channel, "level", level) |
| 308 | } |
| 309 | |
| 310 | // RevokeAccess removes ChanServ AMODE entries for nick on the given channel. |
| 311 | func (m *Manager) RevokeAccess(nick, channel string) { |
| 312 | if m.client == nil { |
| 313 | return |
| 314 | } |
| 315 | m.chanserv("AMODE %s -o %s", channel, nick) |
| 316 | m.chanserv("AMODE %s -v %s", channel, nick) |
| 317 | m.log.Info("revoked channel access (AMODE)", "nick", nick, "channel", channel) |
| 318 | } |
| 319 | |
| 320 | func (m *Manager) chanserv(format string, args ...any) { |
| 321 | msg := fmt.Sprintf(format, args...) |
| 322 | m.client.Cmd.Message("ChanServ", msg) |
| 323 | } |
| 324 | |
| 325 | // ChannelInfo describes an active provisioned channel. |
| 326 | type ChannelInfo struct { |
| 327 | Name string `json:"name"` |
| 328 | ProvisionedAt time.Time `json:"provisioned_at"` |
| 329 | Type string `json:"type,omitempty"` |
| 330 | Ephemeral bool `json:"ephemeral,omitempty"` |
| 331 | TTLSeconds int64 `json:"ttl_seconds,omitempty"` |
| 332 | } |
| 333 | |
| 334 | // ListChannels returns all actively provisioned channels. |
| 335 | func (m *Manager) ListChannels() []ChannelInfo { |
| 336 | m.mu.Lock() |
| 337 | defer m.mu.Unlock() |
| 338 | out := make([]ChannelInfo, 0, len(m.channels)) |
| 339 | for _, rec := range m.channels { |
| 340 | ci := ChannelInfo{ |
| 341 | Name: rec.name, |
| 342 | ProvisionedAt: rec.provisionedAt, |
| 343 | } |
| 344 | if m.policy != nil { |
| 345 | ci.Type = m.policy.TypeName(rec.name) |
| 346 | ci.Ephemeral = m.policy.IsEphemeral(rec.name) |
| 347 | ttl := m.policy.TTLFor(rec.name) |
| 348 | if ttl > 0 { |
| 349 | ci.TTLSeconds = int64(ttl.Seconds()) |
| 350 | } |
| 351 | } |
| 352 | out = append(out, ci) |
| 353 | } |
| 354 | return out |
| 355 | } |
| 356 | |
| 357 | // ValidateName checks that a channel name follows scuttlebot conventions. |
| 358 | func ValidateName(name string) error { |
| 359 | if !strings.HasPrefix(name, "#") { |
| 360 | return fmt.Errorf("topology: channel name must start with #: %q", name) |
| 361 | |
| 362 | DDED pkg/chathistory/chathistory.go |
| --- a/pkg/chathistory/chathistory.go | ||
| +++ b/pkg/chathistory/chathistory.go | ||
| @@ -0,0 +1,183 @@ | ||
| 1 | +// Package chathistory provides a synchronous wrapper around the IRCv3 | |
| 2 | +// CHATHISTORY extension for use with girc clients. | |
| 3 | +// | |
| 4 | +// Usage: | |
| 5 | +// | |
| 6 | +// fetcher := chathistory.New(client) | |
| 7 | +// msgs, err := fetcher.Latest(ctx, "#channel", 50) | |
| 8 | +package chathistory | |
| 9 | + | |
| 10 | +import ( | |
| 11 | + "context" | |
| 12 | + "fmt" | |
| 13 | + "strings" | |
| 14 | + "sync" | |
| 15 | + "time" | |
| 16 | + | |
| 17 | + "github.com/lrstanley/girc" | |
| 18 | +) | |
| 19 | + | |
| 20 | +// Message is a single message returned by a CHATHISTORY query. | |
| 21 | +type Message struct { | |
| 22 | + At time.Time | |
| 23 | + Nick string | |
| 24 | + Account string | |
| 25 | + Text string | |
| 26 | + MsgID string | |
| 27 | +} | |
| 28 | + | |
| 29 | +// Fetcher sends CHATHISTORY commands and collects the batched responses. | |
| 30 | +type Fetcher struct { | |
| 31 | + client *girc.Client | |
| 32 | + | |
| 33 | + mu sync.Mutex | |
| 34 | + batches map[string]*batch // batchRef → accumulator | |
| 35 | + waiters map[string]chan []Message // channel → result (one waiter per channel) | |
| 36 | + handlers bool | |
| 37 | +} | |
| 38 | + | |
| 39 | +type batch struct { | |
| 40 | + channel string | |
| 41 | + msgs []Message | |
| 42 | +} | |
| 43 | + | |
| 44 | +// New creates a Fetcher and registers the necessary BATCH handlers on the | |
| 45 | +// client. The client's Config.SupportedCaps should include | |
| 46 | +// "draft/chathistory" (or "chathistory") so the capability is negotiated. | |
| 47 | +func New(client *girc.Client) *Fetcher { | |
| 48 | + f := &Fetcher{ | |
| 49 | + client: client, | |
| 50 | + batches: make(map[string]*batch), | |
| 51 | + waiters: make(map[string]chan []Message), | |
| 52 | + } | |
| 53 | + f.registerHandlers() | |
| 54 | + return f | |
| 55 | +} | |
| 56 | + | |
| 57 | +func (f *Fetcher) registerHandlers() { | |
| 58 | + f.mu.Lock() | |
| 59 | + defer f.mu.Unlock() | |
| 60 | + if f.handlers { | |
| 61 | + return | |
| 62 | + } | |
| 63 | + f.handlers = true | |
| 64 | + | |
| 65 | + // BATCH open/close. | |
| 66 | + f.client.Handlers.AddBg("BATCH", func(_ *girc.Client, e girc.Event) { | |
| 67 | + if len(e.Params) < 1 { | |
| 68 | + return | |
| 69 | + } | |
| 70 | + raw := e.Params[0] | |
| 71 | + if strings.HasPrefix(raw, "+") { | |
| 72 | + ref := raw[1:] | |
| 73 | + if len(e.Params) >= 2 && e.Params[1] == "chathistory" { | |
| 74 | + ch := "" | |
| 75 | + if len(e.Params) >= 3 { | |
| 76 | + ch = e.Params[2] | |
| 77 | + } | |
| 78 | + f.mu.Lock() | |
| 79 | + f.batches[ref] = &batch{channel: ch} | |
| 80 | + f.mu.Unlock() | |
| 81 | + } | |
| 82 | + } else if strings.HasPrefix(raw, "-") { | |
| 83 | + ref := raw[1:] | |
| 84 | + f.mu.Lock() | |
| 85 | + b, ok := f.batches[ref] | |
| 86 | + if ok { | |
| 87 | + delete(f.batches, ref) | |
| 88 | + if w, wok := f.waiters[b.channel]; wok { | |
| 89 | + delete(f.waiters, b.channel) | |
| 90 | + f.mu.Unlock() | |
| 91 | + w <- b.msgs | |
| 92 | + return | |
| 93 | + } | |
| 94 | + } | |
| 95 | + f.mu.Unlock() | |
| 96 | + } | |
| 97 | + }) | |
| 98 | + | |
| 99 | + // Collect PRIVMSGs tagged with a tracked batch ref. | |
| 100 | + f.client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) { | |
| 101 | + batchRef, ok := e.Tags.Get("batch") | |
| 102 | + if !ok || batchRef == "" { | |
| 103 | + return | |
| 104 | + } | |
| 105 | + | |
| 106 | + f.mu.Lock() | |
| 107 | + b, tracked := f.batches[batchRef] | |
| 108 | + if !tracked { | |
| 109 | + f.mu.Unlock() | |
| 110 | + return | |
| 111 | + } | |
| 112 | + | |
| 113 | + nick := "" | |
| 114 | + if e.Source != nil { | |
| 115 | + nick = e.Source.Name | |
| 116 | + } | |
| 117 | + acct, _ := e.Tags.Get("account") | |
| 118 | + msgID, _ := e.Tags.Get("msgid") | |
| 119 | + | |
| 120 | + b.msgs = append(b.msgs, Message{ | |
| 121 | + At: e.Timestamp, | |
| 122 | + Nick: nick, | |
| 123 | + Account: acct, | |
| 124 | + Text: e.Last(), | |
| 125 | + MsgID: msgID, | |
| 126 | + }) | |
| 127 | + f.mu.Unlock() | |
| 128 | + }) | |
| 129 | +} | |
| 130 | + | |
| 131 | +// Latest fetches the N most recent messages from a channel using | |
| 132 | +// CHATHISTORY LATEST. Blocks until the server responds or ctx expires. | |
| 133 | +func (f *Fetcher) Latest(ctx context.Context, channel string, count int) ([]Message, error) { | |
| 134 | + result := make(chan []Message, 1) | |
| 135 | + | |
| 136 | + f.mu.Lock() | |
| 137 | + f.waiters[channel] = result | |
| 138 | + f.mu.Unlock() | |
| 139 | + | |
| 140 | + if err := f.client.Cmd.SendRawf("CHATHISTORY LATEST %s * %d", channel, count); err != nil { | |
| 141 | + f.mu.Lock() | |
| 142 | + delete(f.waiters, channel) | |
| 143 | + f.mu.Unlock() | |
| 144 | + return nil, fmt.Errorf("chathistory: send: %w", err) | |
| 145 | + } | |
| 146 | + | |
| 147 | + select { | |
| 148 | + case msgs := <-result: | |
| 149 | + return msgs, nil | |
| 150 | + case <-ctx.Done(): | |
| 151 | + f.mu.Lock() | |
| 152 | + delete(f.waiters, channel) | |
| 153 | + f.mu.Unlock() | |
| 154 | + return nil, ctx.Err() | |
| 155 | + } | |
| 156 | +} | |
| 157 | + | |
| 158 | +// Before fetches up to count messages before the given timestamp. | |
| 159 | +func (f *Fetcher) Before(ctx context.Context, channel string, before time.Time, count int) ([]Message, error) { | |
| 160 | + result := make(chan []Message, 1) | |
| 161 | + | |
| 162 | + f.mu.Lock() | |
| 163 | + f.waiters[channel] = result | |
| 164 | + f.mu.Unlock() | |
| 165 | + | |
| 166 | + ts := before.UTC().Format("2006-01-02T15:04:05.000Z") | |
| 167 | + if err := f.client.Cmd.SendRawf("CHATHISTORY BEFORE %s timestamp=%s %d", channel, ts, count); err != nil { | |
| 168 | + f.mu.Lock() | |
| 169 | + delete(f.waiters, channel) | |
| 170 | + f.mu.Unlock() | |
| 171 | + return nil, fmt.Errorf("chathistory: send: %w", err) | |
| 172 | + } | |
| 173 | + | |
| 174 | + select { | |
| 175 | + case msgs := <-result: | |
| 176 | + return msgs, nil | |
| 177 | + case <-ctx.Done(): | |
| 178 | + f.mu.Lock() | |
| 179 | + delete(f.waiters, channel) | |
| 180 | + f.mu.Unlock() | |
| 181 | + return nil, ctx.Err() | |
| 182 | + } | |
| 183 | +} |
| --- a/pkg/chathistory/chathistory.go | |
| +++ b/pkg/chathistory/chathistory.go | |
| @@ -0,0 +1,183 @@ | |
| --- a/pkg/chathistory/chathistory.go | |
| +++ b/pkg/chathistory/chathistory.go | |
| @@ -0,0 +1,183 @@ | |
| 1 | // Package chathistory provides a synchronous wrapper around the IRCv3 |
| 2 | // CHATHISTORY extension for use with girc clients. |
| 3 | // |
| 4 | // Usage: |
| 5 | // |
| 6 | // fetcher := chathistory.New(client) |
| 7 | // msgs, err := fetcher.Latest(ctx, "#channel", 50) |
| 8 | package chathistory |
| 9 | |
| 10 | import ( |
| 11 | "context" |
| 12 | "fmt" |
| 13 | "strings" |
| 14 | "sync" |
| 15 | "time" |
| 16 | |
| 17 | "github.com/lrstanley/girc" |
| 18 | ) |
| 19 | |
| 20 | // Message is a single message returned by a CHATHISTORY query. |
| 21 | type Message struct { |
| 22 | At time.Time |
| 23 | Nick string |
| 24 | Account string |
| 25 | Text string |
| 26 | MsgID string |
| 27 | } |
| 28 | |
| 29 | // Fetcher sends CHATHISTORY commands and collects the batched responses. |
| 30 | type Fetcher struct { |
| 31 | client *girc.Client |
| 32 | |
| 33 | mu sync.Mutex |
| 34 | batches map[string]*batch // batchRef → accumulator |
| 35 | waiters map[string]chan []Message // channel → result (one waiter per channel) |
| 36 | handlers bool |
| 37 | } |
| 38 | |
| 39 | type batch struct { |
| 40 | channel string |
| 41 | msgs []Message |
| 42 | } |
| 43 | |
| 44 | // New creates a Fetcher and registers the necessary BATCH handlers on the |
| 45 | // client. The client's Config.SupportedCaps should include |
| 46 | // "draft/chathistory" (or "chathistory") so the capability is negotiated. |
| 47 | func New(client *girc.Client) *Fetcher { |
| 48 | f := &Fetcher{ |
| 49 | client: client, |
| 50 | batches: make(map[string]*batch), |
| 51 | waiters: make(map[string]chan []Message), |
| 52 | } |
| 53 | f.registerHandlers() |
| 54 | return f |
| 55 | } |
| 56 | |
| 57 | func (f *Fetcher) registerHandlers() { |
| 58 | f.mu.Lock() |
| 59 | defer f.mu.Unlock() |
| 60 | if f.handlers { |
| 61 | return |
| 62 | } |
| 63 | f.handlers = true |
| 64 | |
| 65 | // BATCH open/close. |
| 66 | f.client.Handlers.AddBg("BATCH", func(_ *girc.Client, e girc.Event) { |
| 67 | if len(e.Params) < 1 { |
| 68 | return |
| 69 | } |
| 70 | raw := e.Params[0] |
| 71 | if strings.HasPrefix(raw, "+") { |
| 72 | ref := raw[1:] |
| 73 | if len(e.Params) >= 2 && e.Params[1] == "chathistory" { |
| 74 | ch := "" |
| 75 | if len(e.Params) >= 3 { |
| 76 | ch = e.Params[2] |
| 77 | } |
| 78 | f.mu.Lock() |
| 79 | f.batches[ref] = &batch{channel: ch} |
| 80 | f.mu.Unlock() |
| 81 | } |
| 82 | } else if strings.HasPrefix(raw, "-") { |
| 83 | ref := raw[1:] |
| 84 | f.mu.Lock() |
| 85 | b, ok := f.batches[ref] |
| 86 | if ok { |
| 87 | delete(f.batches, ref) |
| 88 | if w, wok := f.waiters[b.channel]; wok { |
| 89 | delete(f.waiters, b.channel) |
| 90 | f.mu.Unlock() |
| 91 | w <- b.msgs |
| 92 | return |
| 93 | } |
| 94 | } |
| 95 | f.mu.Unlock() |
| 96 | } |
| 97 | }) |
| 98 | |
| 99 | // Collect PRIVMSGs tagged with a tracked batch ref. |
| 100 | f.client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) { |
| 101 | batchRef, ok := e.Tags.Get("batch") |
| 102 | if !ok || batchRef == "" { |
| 103 | return |
| 104 | } |
| 105 | |
| 106 | f.mu.Lock() |
| 107 | b, tracked := f.batches[batchRef] |
| 108 | if !tracked { |
| 109 | f.mu.Unlock() |
| 110 | return |
| 111 | } |
| 112 | |
| 113 | nick := "" |
| 114 | if e.Source != nil { |
| 115 | nick = e.Source.Name |
| 116 | } |
| 117 | acct, _ := e.Tags.Get("account") |
| 118 | msgID, _ := e.Tags.Get("msgid") |
| 119 | |
| 120 | b.msgs = append(b.msgs, Message{ |
| 121 | At: e.Timestamp, |
| 122 | Nick: nick, |
| 123 | Account: acct, |
| 124 | Text: e.Last(), |
| 125 | MsgID: msgID, |
| 126 | }) |
| 127 | f.mu.Unlock() |
| 128 | }) |
| 129 | } |
| 130 | |
| 131 | // Latest fetches the N most recent messages from a channel using |
| 132 | // CHATHISTORY LATEST. Blocks until the server responds or ctx expires. |
| 133 | func (f *Fetcher) Latest(ctx context.Context, channel string, count int) ([]Message, error) { |
| 134 | result := make(chan []Message, 1) |
| 135 | |
| 136 | f.mu.Lock() |
| 137 | f.waiters[channel] = result |
| 138 | f.mu.Unlock() |
| 139 | |
| 140 | if err := f.client.Cmd.SendRawf("CHATHISTORY LATEST %s * %d", channel, count); err != nil { |
| 141 | f.mu.Lock() |
| 142 | delete(f.waiters, channel) |
| 143 | f.mu.Unlock() |
| 144 | return nil, fmt.Errorf("chathistory: send: %w", err) |
| 145 | } |
| 146 | |
| 147 | select { |
| 148 | case msgs := <-result: |
| 149 | return msgs, nil |
| 150 | case <-ctx.Done(): |
| 151 | f.mu.Lock() |
| 152 | delete(f.waiters, channel) |
| 153 | f.mu.Unlock() |
| 154 | return nil, ctx.Err() |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | // Before fetches up to count messages before the given timestamp. |
| 159 | func (f *Fetcher) Before(ctx context.Context, channel string, before time.Time, count int) ([]Message, error) { |
| 160 | result := make(chan []Message, 1) |
| 161 | |
| 162 | f.mu.Lock() |
| 163 | f.waiters[channel] = result |
| 164 | f.mu.Unlock() |
| 165 | |
| 166 | ts := before.UTC().Format("2006-01-02T15:04:05.000Z") |
| 167 | if err := f.client.Cmd.SendRawf("CHATHISTORY BEFORE %s timestamp=%s %d", channel, ts, count); err != nil { |
| 168 | f.mu.Lock() |
| 169 | delete(f.waiters, channel) |
| 170 | f.mu.Unlock() |
| 171 | return nil, fmt.Errorf("chathistory: send: %w", err) |
| 172 | } |
| 173 | |
| 174 | select { |
| 175 | case msgs := <-result: |
| 176 | return msgs, nil |
| 177 | case <-ctx.Done(): |
| 178 | f.mu.Lock() |
| 179 | delete(f.waiters, channel) |
| 180 | f.mu.Unlock() |
| 181 | return nil, ctx.Err() |
| 182 | } |
| 183 | } |
+17
| --- pkg/client/client.go | ||
| +++ pkg/client/client.go | ||
| @@ -186,10 +186,27 @@ | ||
| 186 | 186 | text := e.Last() |
| 187 | 187 | env, err := protocol.Unmarshal([]byte(text)) |
| 188 | 188 | if err != nil { |
| 189 | 189 | return // non-JSON PRIVMSG (human chat) — silently ignored |
| 190 | 190 | } |
| 191 | + | |
| 192 | + // Populate IRCv3 transport metadata. | |
| 193 | + env.Channel = channel | |
| 194 | + env.ServerTime = e.Timestamp | |
| 195 | + if acct, ok := e.Tags.Get("account"); ok { | |
| 196 | + env.Account = acct | |
| 197 | + } | |
| 198 | + if msgID, ok := e.Tags.Get("msgid"); ok { | |
| 199 | + env.MsgID = msgID | |
| 200 | + } | |
| 201 | + if len(e.Tags) > 0 { | |
| 202 | + env.Tags = make(map[string]string, len(e.Tags)) | |
| 203 | + for k, v := range e.Tags { | |
| 204 | + env.Tags[k] = v | |
| 205 | + } | |
| 206 | + } | |
| 207 | + | |
| 191 | 208 | c.dispatch(ctx, env) |
| 192 | 209 | }) |
| 193 | 210 | |
| 194 | 211 | // NOTICE is ignored — system/human commentary, not agent traffic. |
| 195 | 212 | |
| 196 | 213 |
| --- pkg/client/client.go | |
| +++ pkg/client/client.go | |
| @@ -186,10 +186,27 @@ | |
| 186 | text := e.Last() |
| 187 | env, err := protocol.Unmarshal([]byte(text)) |
| 188 | if err != nil { |
| 189 | return // non-JSON PRIVMSG (human chat) — silently ignored |
| 190 | } |
| 191 | c.dispatch(ctx, env) |
| 192 | }) |
| 193 | |
| 194 | // NOTICE is ignored — system/human commentary, not agent traffic. |
| 195 | |
| 196 |
| --- pkg/client/client.go | |
| +++ pkg/client/client.go | |
| @@ -186,10 +186,27 @@ | |
| 186 | text := e.Last() |
| 187 | env, err := protocol.Unmarshal([]byte(text)) |
| 188 | if err != nil { |
| 189 | return // non-JSON PRIVMSG (human chat) — silently ignored |
| 190 | } |
| 191 | |
| 192 | // Populate IRCv3 transport metadata. |
| 193 | env.Channel = channel |
| 194 | env.ServerTime = e.Timestamp |
| 195 | if acct, ok := e.Tags.Get("account"); ok { |
| 196 | env.Account = acct |
| 197 | } |
| 198 | if msgID, ok := e.Tags.Get("msgid"); ok { |
| 199 | env.MsgID = msgID |
| 200 | } |
| 201 | if len(e.Tags) > 0 { |
| 202 | env.Tags = make(map[string]string, len(e.Tags)) |
| 203 | for k, v := range e.Tags { |
| 204 | env.Tags[k] = v |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | c.dispatch(ctx, env) |
| 209 | }) |
| 210 | |
| 211 | // NOTICE is ignored — system/human commentary, not agent traffic. |
| 212 | |
| 213 |
| --- pkg/ircagent/ircagent.go | ||
| +++ pkg/ircagent/ircagent.go | ||
| @@ -271,10 +271,17 @@ | ||
| 271 | 271 | text := strings.TrimSpace(e.Last()) |
| 272 | 272 | if senderNick == a.cfg.Nick { |
| 273 | 273 | return |
| 274 | 274 | } |
| 275 | 275 | |
| 276 | + // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix. | |
| 277 | + if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" { | |
| 278 | + if idx := strings.Index(senderNick, sep); idx != -1 { | |
| 279 | + senderNick = senderNick[:idx] | |
| 280 | + } | |
| 281 | + } | |
| 282 | + // Fallback: parse legacy [nick] prefix from bridge bot. | |
| 276 | 283 | if strings.HasPrefix(text, "[") { |
| 277 | 284 | if end := strings.Index(text, "] "); end != -1 { |
| 278 | 285 | senderNick = text[1:end] |
| 279 | 286 | text = text[end+2:] |
| 280 | 287 | } |
| 281 | 288 |
| --- pkg/ircagent/ircagent.go | |
| +++ pkg/ircagent/ircagent.go | |
| @@ -271,10 +271,17 @@ | |
| 271 | text := strings.TrimSpace(e.Last()) |
| 272 | if senderNick == a.cfg.Nick { |
| 273 | return |
| 274 | } |
| 275 | |
| 276 | if strings.HasPrefix(text, "[") { |
| 277 | if end := strings.Index(text, "] "); end != -1 { |
| 278 | senderNick = text[1:end] |
| 279 | text = text[end+2:] |
| 280 | } |
| 281 |
| --- pkg/ircagent/ircagent.go | |
| +++ pkg/ircagent/ircagent.go | |
| @@ -271,10 +271,17 @@ | |
| 271 | text := strings.TrimSpace(e.Last()) |
| 272 | if senderNick == a.cfg.Nick { |
| 273 | return |
| 274 | } |
| 275 | |
| 276 | // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix. |
| 277 | if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" { |
| 278 | if idx := strings.Index(senderNick, sep); idx != -1 { |
| 279 | senderNick = senderNick[:idx] |
| 280 | } |
| 281 | } |
| 282 | // Fallback: parse legacy [nick] prefix from bridge bot. |
| 283 | if strings.HasPrefix(text, "[") { |
| 284 | if end := strings.Index(text, "] "); end != -1 { |
| 285 | senderNick = text[1:end] |
| 286 | text = text[end+2:] |
| 287 | } |
| 288 |
| --- pkg/protocol/protocol.go | ||
| +++ pkg/protocol/protocol.go | ||
| @@ -33,10 +33,17 @@ | ||
| 33 | 33 | ID string `json:"id"` |
| 34 | 34 | From string `json:"from"` |
| 35 | 35 | To []string `json:"to,omitempty"` |
| 36 | 36 | TS int64 `json:"ts"` |
| 37 | 37 | Payload json.RawMessage `json:"payload,omitempty"` |
| 38 | + | |
| 39 | + // IRCv3 transport metadata — populated at receive time, not serialized. | |
| 40 | + Channel string `json:"-"` // channel the message arrived on | |
| 41 | + Account string `json:"-"` // account-tag: sender's NickServ account | |
| 42 | + MsgID string `json:"-"` // msgid tag: server-assigned message ID | |
| 43 | + ServerTime time.Time `json:"-"` // server-time tag: server-provided timestamp | |
| 44 | + Tags map[string]string `json:"-"` // all IRCv3 message tags | |
| 38 | 45 | } |
| 39 | 46 | |
| 40 | 47 | // New creates a new Envelope with a generated ID and current timestamp. |
| 41 | 48 | // To is left empty (unaddressed — matches all recipients). |
| 42 | 49 | func New(msgType, from string, payload any) (*Envelope, error) { |
| 43 | 50 |
| --- pkg/protocol/protocol.go | |
| +++ pkg/protocol/protocol.go | |
| @@ -33,10 +33,17 @@ | |
| 33 | ID string `json:"id"` |
| 34 | From string `json:"from"` |
| 35 | To []string `json:"to,omitempty"` |
| 36 | TS int64 `json:"ts"` |
| 37 | Payload json.RawMessage `json:"payload,omitempty"` |
| 38 | } |
| 39 | |
| 40 | // New creates a new Envelope with a generated ID and current timestamp. |
| 41 | // To is left empty (unaddressed — matches all recipients). |
| 42 | func New(msgType, from string, payload any) (*Envelope, error) { |
| 43 |
| --- pkg/protocol/protocol.go | |
| +++ pkg/protocol/protocol.go | |
| @@ -33,10 +33,17 @@ | |
| 33 | ID string `json:"id"` |
| 34 | From string `json:"from"` |
| 35 | To []string `json:"to,omitempty"` |
| 36 | TS int64 `json:"ts"` |
| 37 | Payload json.RawMessage `json:"payload,omitempty"` |
| 38 | |
| 39 | // IRCv3 transport metadata — populated at receive time, not serialized. |
| 40 | Channel string `json:"-"` // channel the message arrived on |
| 41 | Account string `json:"-"` // account-tag: sender's NickServ account |
| 42 | MsgID string `json:"-"` // msgid tag: server-assigned message ID |
| 43 | ServerTime time.Time `json:"-"` // server-time tag: server-provided timestamp |
| 44 | Tags map[string]string `json:"-"` // all IRCv3 message tags |
| 45 | } |
| 46 | |
| 47 | // New creates a new Envelope with a generated ID and current timestamp. |
| 48 | // To is left empty (unaddressed — matches all recipients). |
| 49 | func New(msgType, from string, payload any) (*Envelope, error) { |
| 50 |
+56
-8
| --- pkg/sessionrelay/irc.go | ||
| +++ pkg/sessionrelay/irc.go | ||
| @@ -25,10 +25,11 @@ | ||
| 25 | 25 | nick string |
| 26 | 26 | addr string |
| 27 | 27 | agentType string |
| 28 | 28 | pass string |
| 29 | 29 | deleteOnClose bool |
| 30 | + envelopeMode bool | |
| 30 | 31 | |
| 31 | 32 | mu sync.RWMutex |
| 32 | 33 | channels []string |
| 33 | 34 | messages []Message |
| 34 | 35 | client *girc.Client |
| @@ -50,10 +51,11 @@ | ||
| 50 | 51 | nick: cfg.Nick, |
| 51 | 52 | addr: cfg.IRC.Addr, |
| 52 | 53 | agentType: cfg.IRC.AgentType, |
| 53 | 54 | pass: cfg.IRC.Pass, |
| 54 | 55 | deleteOnClose: cfg.IRC.DeleteOnClose, |
| 56 | + envelopeMode: cfg.IRC.EnvelopeMode, | |
| 55 | 57 | channels: append([]string(nil), cfg.Channels...), |
| 56 | 58 | messages: make([]Message, 0, defaultBufferSize), |
| 57 | 59 | errCh: make(chan error, 1), |
| 58 | 60 | }, nil |
| 59 | 61 | } |
| @@ -126,27 +128,47 @@ | ||
| 126 | 128 | } |
| 127 | 129 | if onJoined != nil { |
| 128 | 130 | onJoined() |
| 129 | 131 | } |
| 130 | 132 | }) |
| 131 | - client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) { | |
| 133 | + client.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) { | |
| 132 | 134 | if len(e.Params) < 1 || e.Source == nil { |
| 133 | 135 | return |
| 134 | 136 | } |
| 135 | 137 | target := normalizeChannel(e.Params[0]) |
| 136 | 138 | if !c.hasChannel(target) { |
| 137 | 139 | return |
| 138 | 140 | } |
| 141 | + // Prefer account-tag (IRCv3) over source nick. | |
| 139 | 142 | sender := e.Source.Name |
| 143 | + if acct, ok := e.Tags.Get("account"); ok && acct != "" { | |
| 144 | + sender = acct | |
| 145 | + } | |
| 140 | 146 | text := strings.TrimSpace(e.Last()) |
| 147 | + // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix. | |
| 148 | + if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" { | |
| 149 | + if idx := strings.Index(sender, sep); idx != -1 { | |
| 150 | + sender = sender[:idx] | |
| 151 | + } | |
| 152 | + } | |
| 153 | + // Fallback: parse legacy [nick] prefix from bridge bot. | |
| 141 | 154 | if sender == "bridge" && strings.HasPrefix(text, "[") { |
| 142 | 155 | if end := strings.Index(text, "] "); end != -1 { |
| 143 | 156 | sender = text[1:end] |
| 144 | 157 | text = strings.TrimSpace(text[end+2:]) |
| 145 | 158 | } |
| 146 | 159 | } |
| 147 | - c.appendMessage(Message{At: time.Now(), Channel: target, Nick: sender, Text: text}) | |
| 160 | + // Use server-time when available; fall back to local clock. | |
| 161 | + at := e.Timestamp | |
| 162 | + if at.IsZero() { | |
| 163 | + at = time.Now() | |
| 164 | + } | |
| 165 | + var msgID string | |
| 166 | + if id, ok := e.Tags.Get("msgid"); ok { | |
| 167 | + msgID = id | |
| 168 | + } | |
| 169 | + c.appendMessage(Message{At: at, Channel: target, Nick: sender, Text: text, MsgID: msgID}) | |
| 148 | 170 | }) |
| 149 | 171 | |
| 150 | 172 | c.mu.Lock() |
| 151 | 173 | c.client = client |
| 152 | 174 | c.mu.Unlock() |
| @@ -221,26 +243,28 @@ | ||
| 221 | 243 | |
| 222 | 244 | func (c *ircConnector) PostTo(_ context.Context, channel, text string) error { |
| 223 | 245 | return c.PostToWithMeta(context.Background(), channel, text, nil) |
| 224 | 246 | } |
| 225 | 247 | |
| 226 | -// PostWithMeta sends text to all channels. Meta is ignored — IRC is text-only. | |
| 227 | -func (c *ircConnector) PostWithMeta(_ context.Context, text string, _ json.RawMessage) error { | |
| 248 | +// PostWithMeta sends text to all channels. | |
| 249 | +// In envelope mode, wraps the message in a protocol.Envelope JSON. | |
| 250 | +func (c *ircConnector) PostWithMeta(_ context.Context, text string, meta json.RawMessage) error { | |
| 228 | 251 | c.mu.RLock() |
| 229 | 252 | client := c.client |
| 230 | 253 | c.mu.RUnlock() |
| 231 | 254 | if client == nil { |
| 232 | 255 | return fmt.Errorf("sessionrelay: irc client not connected") |
| 233 | 256 | } |
| 257 | + msg := c.formatMessage(text, meta) | |
| 234 | 258 | for _, channel := range c.Channels() { |
| 235 | - client.Cmd.Message(channel, text) | |
| 259 | + client.Cmd.Message(channel, msg) | |
| 236 | 260 | } |
| 237 | 261 | return nil |
| 238 | 262 | } |
| 239 | 263 | |
| 240 | -// PostToWithMeta sends text to a specific channel. Meta is ignored — IRC is text-only. | |
| 241 | -func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, _ json.RawMessage) error { | |
| 264 | +// PostToWithMeta sends text to a specific channel. | |
| 265 | +func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, meta json.RawMessage) error { | |
| 242 | 266 | c.mu.RLock() |
| 243 | 267 | client := c.client |
| 244 | 268 | c.mu.RUnlock() |
| 245 | 269 | if client == nil { |
| 246 | 270 | return fmt.Errorf("sessionrelay: irc client not connected") |
| @@ -247,13 +271,37 @@ | ||
| 247 | 271 | } |
| 248 | 272 | channel = normalizeChannel(channel) |
| 249 | 273 | if channel == "" { |
| 250 | 274 | return fmt.Errorf("sessionrelay: post channel is required") |
| 251 | 275 | } |
| 252 | - client.Cmd.Message(channel, text) | |
| 276 | + client.Cmd.Message(channel, c.formatMessage(text, meta)) | |
| 253 | 277 | return nil |
| 254 | 278 | } |
| 279 | + | |
| 280 | +// formatMessage wraps text in a JSON envelope when envelope mode is enabled. | |
| 281 | +func (c *ircConnector) formatMessage(text string, meta json.RawMessage) string { | |
| 282 | + if !c.envelopeMode { | |
| 283 | + return text | |
| 284 | + } | |
| 285 | + env := map[string]any{ | |
| 286 | + "v": 1, | |
| 287 | + "type": "relay.message", | |
| 288 | + "from": c.nick, | |
| 289 | + "ts": time.Now().UnixMilli(), | |
| 290 | + "payload": map[string]any{ | |
| 291 | + "text": text, | |
| 292 | + }, | |
| 293 | + } | |
| 294 | + if len(meta) > 0 { | |
| 295 | + env["payload"] = json.RawMessage(meta) | |
| 296 | + } | |
| 297 | + data, err := json.Marshal(env) | |
| 298 | + if err != nil { | |
| 299 | + return text // fallback to plain text | |
| 300 | + } | |
| 301 | + return string(data) | |
| 302 | +} | |
| 255 | 303 | |
| 256 | 304 | func (c *ircConnector) MessagesSince(_ context.Context, since time.Time) ([]Message, error) { |
| 257 | 305 | c.mu.RLock() |
| 258 | 306 | defer c.mu.RUnlock() |
| 259 | 307 | |
| 260 | 308 |
| --- pkg/sessionrelay/irc.go | |
| +++ pkg/sessionrelay/irc.go | |
| @@ -25,10 +25,11 @@ | |
| 25 | nick string |
| 26 | addr string |
| 27 | agentType string |
| 28 | pass string |
| 29 | deleteOnClose bool |
| 30 | |
| 31 | mu sync.RWMutex |
| 32 | channels []string |
| 33 | messages []Message |
| 34 | client *girc.Client |
| @@ -50,10 +51,11 @@ | |
| 50 | nick: cfg.Nick, |
| 51 | addr: cfg.IRC.Addr, |
| 52 | agentType: cfg.IRC.AgentType, |
| 53 | pass: cfg.IRC.Pass, |
| 54 | deleteOnClose: cfg.IRC.DeleteOnClose, |
| 55 | channels: append([]string(nil), cfg.Channels...), |
| 56 | messages: make([]Message, 0, defaultBufferSize), |
| 57 | errCh: make(chan error, 1), |
| 58 | }, nil |
| 59 | } |
| @@ -126,27 +128,47 @@ | |
| 126 | } |
| 127 | if onJoined != nil { |
| 128 | onJoined() |
| 129 | } |
| 130 | }) |
| 131 | client.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) { |
| 132 | if len(e.Params) < 1 || e.Source == nil { |
| 133 | return |
| 134 | } |
| 135 | target := normalizeChannel(e.Params[0]) |
| 136 | if !c.hasChannel(target) { |
| 137 | return |
| 138 | } |
| 139 | sender := e.Source.Name |
| 140 | text := strings.TrimSpace(e.Last()) |
| 141 | if sender == "bridge" && strings.HasPrefix(text, "[") { |
| 142 | if end := strings.Index(text, "] "); end != -1 { |
| 143 | sender = text[1:end] |
| 144 | text = strings.TrimSpace(text[end+2:]) |
| 145 | } |
| 146 | } |
| 147 | c.appendMessage(Message{At: time.Now(), Channel: target, Nick: sender, Text: text}) |
| 148 | }) |
| 149 | |
| 150 | c.mu.Lock() |
| 151 | c.client = client |
| 152 | c.mu.Unlock() |
| @@ -221,26 +243,28 @@ | |
| 221 | |
| 222 | func (c *ircConnector) PostTo(_ context.Context, channel, text string) error { |
| 223 | return c.PostToWithMeta(context.Background(), channel, text, nil) |
| 224 | } |
| 225 | |
| 226 | // PostWithMeta sends text to all channels. Meta is ignored — IRC is text-only. |
| 227 | func (c *ircConnector) PostWithMeta(_ context.Context, text string, _ json.RawMessage) error { |
| 228 | c.mu.RLock() |
| 229 | client := c.client |
| 230 | c.mu.RUnlock() |
| 231 | if client == nil { |
| 232 | return fmt.Errorf("sessionrelay: irc client not connected") |
| 233 | } |
| 234 | for _, channel := range c.Channels() { |
| 235 | client.Cmd.Message(channel, text) |
| 236 | } |
| 237 | return nil |
| 238 | } |
| 239 | |
| 240 | // PostToWithMeta sends text to a specific channel. Meta is ignored — IRC is text-only. |
| 241 | func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, _ json.RawMessage) error { |
| 242 | c.mu.RLock() |
| 243 | client := c.client |
| 244 | c.mu.RUnlock() |
| 245 | if client == nil { |
| 246 | return fmt.Errorf("sessionrelay: irc client not connected") |
| @@ -247,13 +271,37 @@ | |
| 247 | } |
| 248 | channel = normalizeChannel(channel) |
| 249 | if channel == "" { |
| 250 | return fmt.Errorf("sessionrelay: post channel is required") |
| 251 | } |
| 252 | client.Cmd.Message(channel, text) |
| 253 | return nil |
| 254 | } |
| 255 | |
| 256 | func (c *ircConnector) MessagesSince(_ context.Context, since time.Time) ([]Message, error) { |
| 257 | c.mu.RLock() |
| 258 | defer c.mu.RUnlock() |
| 259 | |
| 260 |
| --- pkg/sessionrelay/irc.go | |
| +++ pkg/sessionrelay/irc.go | |
| @@ -25,10 +25,11 @@ | |
| 25 | nick string |
| 26 | addr string |
| 27 | agentType string |
| 28 | pass string |
| 29 | deleteOnClose bool |
| 30 | envelopeMode bool |
| 31 | |
| 32 | mu sync.RWMutex |
| 33 | channels []string |
| 34 | messages []Message |
| 35 | client *girc.Client |
| @@ -50,10 +51,11 @@ | |
| 51 | nick: cfg.Nick, |
| 52 | addr: cfg.IRC.Addr, |
| 53 | agentType: cfg.IRC.AgentType, |
| 54 | pass: cfg.IRC.Pass, |
| 55 | deleteOnClose: cfg.IRC.DeleteOnClose, |
| 56 | envelopeMode: cfg.IRC.EnvelopeMode, |
| 57 | channels: append([]string(nil), cfg.Channels...), |
| 58 | messages: make([]Message, 0, defaultBufferSize), |
| 59 | errCh: make(chan error, 1), |
| 60 | }, nil |
| 61 | } |
| @@ -126,27 +128,47 @@ | |
| 128 | } |
| 129 | if onJoined != nil { |
| 130 | onJoined() |
| 131 | } |
| 132 | }) |
| 133 | client.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) { |
| 134 | if len(e.Params) < 1 || e.Source == nil { |
| 135 | return |
| 136 | } |
| 137 | target := normalizeChannel(e.Params[0]) |
| 138 | if !c.hasChannel(target) { |
| 139 | return |
| 140 | } |
| 141 | // Prefer account-tag (IRCv3) over source nick. |
| 142 | sender := e.Source.Name |
| 143 | if acct, ok := e.Tags.Get("account"); ok && acct != "" { |
| 144 | sender = acct |
| 145 | } |
| 146 | text := strings.TrimSpace(e.Last()) |
| 147 | // RELAYMSG: server delivers as "nick/bridge" — strip the relay suffix. |
| 148 | if sep, ok := cl.GetServerOption("RELAYMSG"); ok && sep != "" { |
| 149 | if idx := strings.Index(sender, sep); idx != -1 { |
| 150 | sender = sender[:idx] |
| 151 | } |
| 152 | } |
| 153 | // Fallback: parse legacy [nick] prefix from bridge bot. |
| 154 | if sender == "bridge" && strings.HasPrefix(text, "[") { |
| 155 | if end := strings.Index(text, "] "); end != -1 { |
| 156 | sender = text[1:end] |
| 157 | text = strings.TrimSpace(text[end+2:]) |
| 158 | } |
| 159 | } |
| 160 | // Use server-time when available; fall back to local clock. |
| 161 | at := e.Timestamp |
| 162 | if at.IsZero() { |
| 163 | at = time.Now() |
| 164 | } |
| 165 | var msgID string |
| 166 | if id, ok := e.Tags.Get("msgid"); ok { |
| 167 | msgID = id |
| 168 | } |
| 169 | c.appendMessage(Message{At: at, Channel: target, Nick: sender, Text: text, MsgID: msgID}) |
| 170 | }) |
| 171 | |
| 172 | c.mu.Lock() |
| 173 | c.client = client |
| 174 | c.mu.Unlock() |
| @@ -221,26 +243,28 @@ | |
| 243 | |
| 244 | func (c *ircConnector) PostTo(_ context.Context, channel, text string) error { |
| 245 | return c.PostToWithMeta(context.Background(), channel, text, nil) |
| 246 | } |
| 247 | |
| 248 | // PostWithMeta sends text to all channels. |
| 249 | // In envelope mode, wraps the message in a protocol.Envelope JSON. |
| 250 | func (c *ircConnector) PostWithMeta(_ context.Context, text string, meta json.RawMessage) error { |
| 251 | c.mu.RLock() |
| 252 | client := c.client |
| 253 | c.mu.RUnlock() |
| 254 | if client == nil { |
| 255 | return fmt.Errorf("sessionrelay: irc client not connected") |
| 256 | } |
| 257 | msg := c.formatMessage(text, meta) |
| 258 | for _, channel := range c.Channels() { |
| 259 | client.Cmd.Message(channel, msg) |
| 260 | } |
| 261 | return nil |
| 262 | } |
| 263 | |
| 264 | // PostToWithMeta sends text to a specific channel. |
| 265 | func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, meta json.RawMessage) error { |
| 266 | c.mu.RLock() |
| 267 | client := c.client |
| 268 | c.mu.RUnlock() |
| 269 | if client == nil { |
| 270 | return fmt.Errorf("sessionrelay: irc client not connected") |
| @@ -247,13 +271,37 @@ | |
| 271 | } |
| 272 | channel = normalizeChannel(channel) |
| 273 | if channel == "" { |
| 274 | return fmt.Errorf("sessionrelay: post channel is required") |
| 275 | } |
| 276 | client.Cmd.Message(channel, c.formatMessage(text, meta)) |
| 277 | return nil |
| 278 | } |
| 279 | |
| 280 | // formatMessage wraps text in a JSON envelope when envelope mode is enabled. |
| 281 | func (c *ircConnector) formatMessage(text string, meta json.RawMessage) string { |
| 282 | if !c.envelopeMode { |
| 283 | return text |
| 284 | } |
| 285 | env := map[string]any{ |
| 286 | "v": 1, |
| 287 | "type": "relay.message", |
| 288 | "from": c.nick, |
| 289 | "ts": time.Now().UnixMilli(), |
| 290 | "payload": map[string]any{ |
| 291 | "text": text, |
| 292 | }, |
| 293 | } |
| 294 | if len(meta) > 0 { |
| 295 | env["payload"] = json.RawMessage(meta) |
| 296 | } |
| 297 | data, err := json.Marshal(env) |
| 298 | if err != nil { |
| 299 | return text // fallback to plain text |
| 300 | } |
| 301 | return string(data) |
| 302 | } |
| 303 | |
| 304 | func (c *ircConnector) MessagesSince(_ context.Context, since time.Time) ([]Message, error) { |
| 305 | c.mu.RLock() |
| 306 | defer c.mu.RUnlock() |
| 307 | |
| 308 |
| --- pkg/sessionrelay/sessionrelay.go | ||
| +++ pkg/sessionrelay/sessionrelay.go | ||
| @@ -35,17 +35,21 @@ | ||
| 35 | 35 | type IRCConfig struct { |
| 36 | 36 | Addr string |
| 37 | 37 | Pass string |
| 38 | 38 | AgentType string |
| 39 | 39 | DeleteOnClose bool |
| 40 | + // EnvelopeMode wraps outgoing messages in protocol.Envelope JSON. | |
| 41 | + // When true, agents in the channel can parse relay output as structured data. | |
| 42 | + EnvelopeMode bool | |
| 40 | 43 | } |
| 41 | 44 | |
| 42 | 45 | type Message struct { |
| 43 | 46 | At time.Time |
| 44 | 47 | Channel string |
| 45 | 48 | Nick string |
| 46 | 49 | Text string |
| 50 | + MsgID string | |
| 47 | 51 | } |
| 48 | 52 | |
| 49 | 53 | type Connector interface { |
| 50 | 54 | Connect(ctx context.Context) error |
| 51 | 55 | Post(ctx context.Context, text string) error |
| 52 | 56 | |
| 53 | 57 | ADDED pkg/toon/toon.go |
| 54 | 58 | ADDED pkg/toon/toon_test.go |
| --- pkg/sessionrelay/sessionrelay.go | |
| +++ pkg/sessionrelay/sessionrelay.go | |
| @@ -35,17 +35,21 @@ | |
| 35 | type IRCConfig struct { |
| 36 | Addr string |
| 37 | Pass string |
| 38 | AgentType string |
| 39 | DeleteOnClose bool |
| 40 | } |
| 41 | |
| 42 | type Message struct { |
| 43 | At time.Time |
| 44 | Channel string |
| 45 | Nick string |
| 46 | Text string |
| 47 | } |
| 48 | |
| 49 | type Connector interface { |
| 50 | Connect(ctx context.Context) error |
| 51 | Post(ctx context.Context, text string) error |
| 52 | |
| 53 | DDED pkg/toon/toon.go |
| 54 | DDED pkg/toon/toon_test.go |
| --- pkg/sessionrelay/sessionrelay.go | |
| +++ pkg/sessionrelay/sessionrelay.go | |
| @@ -35,17 +35,21 @@ | |
| 35 | type IRCConfig struct { |
| 36 | Addr string |
| 37 | Pass string |
| 38 | AgentType string |
| 39 | DeleteOnClose bool |
| 40 | // EnvelopeMode wraps outgoing messages in protocol.Envelope JSON. |
| 41 | // When true, agents in the channel can parse relay output as structured data. |
| 42 | EnvelopeMode bool |
| 43 | } |
| 44 | |
| 45 | type Message struct { |
| 46 | At time.Time |
| 47 | Channel string |
| 48 | Nick string |
| 49 | Text string |
| 50 | MsgID string |
| 51 | } |
| 52 | |
| 53 | type Connector interface { |
| 54 | Connect(ctx context.Context) error |
| 55 | Post(ctx context.Context, text string) error |
| 56 | |
| 57 | DDED pkg/toon/toon.go |
| 58 | DDED pkg/toon/toon_test.go |
+121
| --- a/pkg/toon/toon.go | ||
| +++ b/pkg/toon/toon.go | ||
| @@ -0,0 +1,121 @@ | ||
| 1 | +// Package toon implements the TOON format — Token-Optimized Object Notation | |
| 2 | +// for compact LLM context windows. | |
| 3 | +// | |
| 4 | +// TOON is designed for feeding IRC conversation history to language models. | |
| 5 | +// It strips noise (joins, parts, status messages, repeated tool calls), | |
| 6 | +// deduplicates, and compresses timestamps into relative offsets. | |
| 7 | +// | |
| 8 | +// Example output: | |
| 9 | +// | |
| 10 | +// #fleet 50msg 2h window | |
| 11 | +// --- | |
| 12 | +// claude-kohakku [orch] +0m | |
| 13 | +// task.create {file: main.go, action: edit} | |
| 14 | +// "editing main.go to add error handling" | |
| 15 | +// leo [op] +2m | |
| 16 | +// "looks good, ship it" | |
| 17 | +// claude-kohakku [orch] +3m | |
| 18 | +// task.complete {file: main.go, status: done} | |
| 19 | +// --- | |
| 20 | +// decisions: edit main.go error handling | |
| 21 | +// actions: task.create → task.complete (main.go) | |
| 22 | +package toon | |
| 23 | + | |
| 24 | +import ( | |
| 25 | + "fmt" | |
| 26 | + "strings" | |
| 27 | + "time" | |
| 28 | +) | |
| 29 | + | |
| 30 | +// Entry is a single message to include in the TOON output. | |
| 31 | +type Entry struct { | |
| 32 | + Nick string | |
| 33 | + Type string // agent type: "orch", "worker", "op", "bot", "" for unknown | |
| 34 | + MessageType string // envelope type (e.g. "task.create"), empty for plain text | |
| 35 | + Text string | |
| 36 | + At time.Time | |
| 37 | +} | |
| 38 | + | |
| 39 | +// Options controls TOON formatting. | |
| 40 | +type Options struct { | |
| 41 | + Channel string | |
| 42 | + MaxEntries int // 0 = no limit | |
| 43 | +} | |
| 44 | + | |
| 45 | +// Format renders a slice of entries into TOON format. | |
| 46 | +func Format(entries []Entry, opts Options) string { | |
| 47 | + if len(entries) == 0 { | |
| 48 | + return "" | |
| 49 | + } | |
| 50 | + | |
| 51 | + var b strings.Builder | |
| 52 | + | |
| 53 | + // Header. | |
| 54 | + window := "" | |
| 55 | + if len(entries) >= 2 { | |
| 56 | + dur := entries[len(entries)-1].At.Sub(entries[0].At) | |
| 57 | + window = " " + compactDuration(dur) + " window" | |
| 58 | + } | |
| 59 | + ch := opts.Channel | |
| 60 | + if ch == "" { | |
| 61 | + ch = "channel" | |
| 62 | + } | |
| 63 | + fmt.Fprintf(&b, "%s %dmsg%s\n---\n", ch, len(entries), window) | |
| 64 | + | |
| 65 | + // Body — group consecutive messages from same nick. | |
| 66 | + baseTime := entries[0].At | |
| 67 | + var lastNick string | |
| 68 | + for _, e := range entries { | |
| 69 | + offset := e.At.Sub(baseTime) | |
| 70 | + if e.Nick != lastNick { | |
| 71 | + tag := "" | |
| 72 | + if e.Type != "" { | |
| 73 | + tag = " [" + e.Type + "]" | |
| 74 | + } | |
| 75 | + fmt.Fprintf(&b, "%s%s +%s\n", e.Nick, tag, compactDuration(offset)) | |
| 76 | + lastNick = e.Nick | |
| 77 | + } | |
| 78 | + | |
| 79 | + if e.MessageType != "" { | |
| 80 | + fmt.Fprintf(&b, " %s\n", e.MessageType) | |
| 81 | + } | |
| 82 | + text := strings.TrimSpace(e.Text) | |
| 83 | + if text != "" && text != e.MessageType { | |
| 84 | + // Truncate very long messages to save tokens. | |
| 85 | + if len(text) > 200 { | |
| 86 | + text = text[:197] + "..." | |
| 87 | + } | |
| 88 | + fmt.Fprintf(&b, " \"%s\"\n", text) | |
| 89 | + } | |
| 90 | + } | |
| 91 | + | |
| 92 | + b.WriteString("---\n") | |
| 93 | + return b.String() | |
| 94 | +} | |
| 95 | + | |
| 96 | +// FormatPrompt wraps TOON-formatted history into an LLM summarization prompt. | |
| 97 | +func FormatPrompt(channel string, entries []Entry) string { | |
| 98 | + toon := Format(entries, Options{Channel: channel}) | |
| 99 | + var b strings.Builder | |
| 100 | + fmt.Fprintf(&b, "Summarize this IRC conversation. Focus on decisions, actions, and outcomes. Be concise.\n\n") | |
| 101 | + b.WriteString(toon) | |
| 102 | + return b.String() | |
| 103 | +} | |
| 104 | + | |
| 105 | +func compactDuration(d time.Duration) string { | |
| 106 | + if d < time.Minute { | |
| 107 | + return fmt.Sprintf("%ds", int(d.Seconds())) | |
| 108 | + } | |
| 109 | + if d < time.Hour { | |
| 110 | + return fmt.Sprintf("%dm", int(d.Minutes())) | |
| 111 | + } | |
| 112 | + if d < 24*time.Hour { | |
| 113 | + h := int(d.Hours()) | |
| 114 | + m := int(d.Minutes()) % 60 | |
| 115 | + if m == 0 { | |
| 116 | + return fmt.Sprintf("%dh", h) | |
| 117 | + } | |
| 118 | + return fmt.Sprintf("%dh%dm", h, m) | |
| 119 | + } | |
| 120 | + return fmt.Sprintf("%dd", int(d.Hours()/24)) | |
| 121 | +} |
| --- a/pkg/toon/toon.go | |
| +++ b/pkg/toon/toon.go | |
| @@ -0,0 +1,121 @@ | |
| --- a/pkg/toon/toon.go | |
| +++ b/pkg/toon/toon.go | |
| @@ -0,0 +1,121 @@ | |
| 1 | // Package toon implements the TOON format — Token-Optimized Object Notation |
| 2 | // for compact LLM context windows. |
| 3 | // |
| 4 | // TOON is designed for feeding IRC conversation history to language models. |
| 5 | // It strips noise (joins, parts, status messages, repeated tool calls), |
| 6 | // deduplicates, and compresses timestamps into relative offsets. |
| 7 | // |
| 8 | // Example output: |
| 9 | // |
| 10 | // #fleet 50msg 2h window |
| 11 | // --- |
| 12 | // claude-kohakku [orch] +0m |
| 13 | // task.create {file: main.go, action: edit} |
| 14 | // "editing main.go to add error handling" |
| 15 | // leo [op] +2m |
| 16 | // "looks good, ship it" |
| 17 | // claude-kohakku [orch] +3m |
| 18 | // task.complete {file: main.go, status: done} |
| 19 | // --- |
| 20 | // decisions: edit main.go error handling |
| 21 | // actions: task.create → task.complete (main.go) |
| 22 | package toon |
| 23 | |
| 24 | import ( |
| 25 | "fmt" |
| 26 | "strings" |
| 27 | "time" |
| 28 | ) |
| 29 | |
| 30 | // Entry is a single message to include in the TOON output. |
| 31 | type Entry struct { |
| 32 | Nick string |
| 33 | Type string // agent type: "orch", "worker", "op", "bot", "" for unknown |
| 34 | MessageType string // envelope type (e.g. "task.create"), empty for plain text |
| 35 | Text string |
| 36 | At time.Time |
| 37 | } |
| 38 | |
| 39 | // Options controls TOON formatting. |
| 40 | type Options struct { |
| 41 | Channel string |
| 42 | MaxEntries int // 0 = no limit |
| 43 | } |
| 44 | |
| 45 | // Format renders a slice of entries into TOON format. |
| 46 | func Format(entries []Entry, opts Options) string { |
| 47 | if len(entries) == 0 { |
| 48 | return "" |
| 49 | } |
| 50 | |
| 51 | var b strings.Builder |
| 52 | |
| 53 | // Header. |
| 54 | window := "" |
| 55 | if len(entries) >= 2 { |
| 56 | dur := entries[len(entries)-1].At.Sub(entries[0].At) |
| 57 | window = " " + compactDuration(dur) + " window" |
| 58 | } |
| 59 | ch := opts.Channel |
| 60 | if ch == "" { |
| 61 | ch = "channel" |
| 62 | } |
| 63 | fmt.Fprintf(&b, "%s %dmsg%s\n---\n", ch, len(entries), window) |
| 64 | |
| 65 | // Body — group consecutive messages from same nick. |
| 66 | baseTime := entries[0].At |
| 67 | var lastNick string |
| 68 | for _, e := range entries { |
| 69 | offset := e.At.Sub(baseTime) |
| 70 | if e.Nick != lastNick { |
| 71 | tag := "" |
| 72 | if e.Type != "" { |
| 73 | tag = " [" + e.Type + "]" |
| 74 | } |
| 75 | fmt.Fprintf(&b, "%s%s +%s\n", e.Nick, tag, compactDuration(offset)) |
| 76 | lastNick = e.Nick |
| 77 | } |
| 78 | |
| 79 | if e.MessageType != "" { |
| 80 | fmt.Fprintf(&b, " %s\n", e.MessageType) |
| 81 | } |
| 82 | text := strings.TrimSpace(e.Text) |
| 83 | if text != "" && text != e.MessageType { |
| 84 | // Truncate very long messages to save tokens. |
| 85 | if len(text) > 200 { |
| 86 | text = text[:197] + "..." |
| 87 | } |
| 88 | fmt.Fprintf(&b, " \"%s\"\n", text) |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | b.WriteString("---\n") |
| 93 | return b.String() |
| 94 | } |
| 95 | |
| 96 | // FormatPrompt wraps TOON-formatted history into an LLM summarization prompt. |
| 97 | func FormatPrompt(channel string, entries []Entry) string { |
| 98 | toon := Format(entries, Options{Channel: channel}) |
| 99 | var b strings.Builder |
| 100 | fmt.Fprintf(&b, "Summarize this IRC conversation. Focus on decisions, actions, and outcomes. Be concise.\n\n") |
| 101 | b.WriteString(toon) |
| 102 | return b.String() |
| 103 | } |
| 104 | |
| 105 | func compactDuration(d time.Duration) string { |
| 106 | if d < time.Minute { |
| 107 | return fmt.Sprintf("%ds", int(d.Seconds())) |
| 108 | } |
| 109 | if d < time.Hour { |
| 110 | return fmt.Sprintf("%dm", int(d.Minutes())) |
| 111 | } |
| 112 | if d < 24*time.Hour { |
| 113 | h := int(d.Hours()) |
| 114 | m := int(d.Minutes()) % 60 |
| 115 | if m == 0 { |
| 116 | return fmt.Sprintf("%dh", h) |
| 117 | } |
| 118 | return fmt.Sprintf("%dh%dm", h, m) |
| 119 | } |
| 120 | return fmt.Sprintf("%dd", int(d.Hours()/24)) |
| 121 | } |
+65
| --- a/pkg/toon/toon_test.go | ||
| +++ b/pkg/toon/toon_test.go | ||
| @@ -0,0 +1,65 @@ | ||
| 1 | +package toon | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "strings" | |
| 5 | + "testing" | |
| 6 | + "time" | |
| 7 | +) | |
| 8 | + | |
| 9 | +func TestFormatEmpty(t *testing.T) { | |
| 10 | + if got := Format(nil, Options{}); got != "" { | |
| 11 | + t.Errorf("expected empty, got %q", got) | |
| 12 | + } | |
| 13 | +} | |
| 14 | + | |
| 15 | +func TestFormatBasic(t *testing.T) { | |
| 16 | + base := time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC) | |
| 17 | + entries := []Entry{ | |
| 18 | + {Nick: "alice", Type: "op", Text: "let's ship it", At: base}, | |
| 19 | + {Nick: "claude-abc", Type: "orch", MessageType: "task.create", Text: "editing main.go", At: base.Add(2 * time.Minute)}, | |
| 20 | + {Nick: "claude-abc", Type: "orch", MessageType: "task.complete", Text: "done", At: base.Add(5 * time.Minute)}, | |
| 21 | + } | |
| 22 | + out := Format(entries, Options{Channel: "#fleet"}) | |
| 23 | + | |
| 24 | + // Header. | |
| 25 | + if !strings.HasPrefix(out, "#fleet 3msg") { | |
| 26 | + t.Errorf("header mismatch: %q", out) | |
| 27 | + } | |
| 28 | + // Grouped consecutive messages from claude-abc. | |
| 29 | + if strings.Count(out, "claude-abc") != 1 { | |
| 30 | + t.Errorf("expected nick grouping, got:\n%s", out) | |
| 31 | + } | |
| 32 | + // Contains message types. | |
| 33 | + if !strings.Contains(out, "task.create") || !strings.Contains(out, "task.complete") { | |
| 34 | + t.Errorf("missing message types:\n%s", out) | |
| 35 | + } | |
| 36 | +} | |
| 37 | + | |
| 38 | +func TestFormatPrompt(t *testing.T) { | |
| 39 | + entries := []Entry{{Nick: "a", Text: "hello"}} | |
| 40 | + out := FormatPrompt("#test", entries) | |
| 41 | + if !strings.Contains(out, "Summarize") { | |
| 42 | + t.Errorf("prompt missing instruction:\n%s", out) | |
| 43 | + } | |
| 44 | + if !strings.Contains(out, "#test") { | |
| 45 | + t.Errorf("prompt missing channel:\n%s", out) | |
| 46 | + } | |
| 47 | +} | |
| 48 | + | |
| 49 | +func TestCompactDuration(t *testing.T) { | |
| 50 | + tests := []struct { | |
| 51 | + d time.Duration | |
| 52 | + want string | |
| 53 | + }{ | |
| 54 | + {30 * time.Second, "30s"}, | |
| 55 | + {5 * time.Minute, "5m"}, | |
| 56 | + {2 * time.Hour, "2h"}, | |
| 57 | + {2*time.Hour + 30*time.Minute, "2h30m"}, | |
| 58 | + {48 * time.Hour, "2d"}, | |
| 59 | + } | |
| 60 | + for _, tt := range tests { | |
| 61 | + if got := compactDuration(tt.d); got != tt.want { | |
| 62 | + t.Errorf("compactDuration(%v) = %q, want %q", tt.d, got, tt.want) | |
| 63 | + } | |
| 64 | + } | |
| 65 | +} |
| --- a/pkg/toon/toon_test.go | |
| +++ b/pkg/toon/toon_test.go | |
| @@ -0,0 +1,65 @@ | |
| --- a/pkg/toon/toon_test.go | |
| +++ b/pkg/toon/toon_test.go | |
| @@ -0,0 +1,65 @@ | |
| 1 | package toon |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | "time" |
| 7 | ) |
| 8 | |
| 9 | func TestFormatEmpty(t *testing.T) { |
| 10 | if got := Format(nil, Options{}); got != "" { |
| 11 | t.Errorf("expected empty, got %q", got) |
| 12 | } |
| 13 | } |
| 14 | |
| 15 | func TestFormatBasic(t *testing.T) { |
| 16 | base := time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC) |
| 17 | entries := []Entry{ |
| 18 | {Nick: "alice", Type: "op", Text: "let's ship it", At: base}, |
| 19 | {Nick: "claude-abc", Type: "orch", MessageType: "task.create", Text: "editing main.go", At: base.Add(2 * time.Minute)}, |
| 20 | {Nick: "claude-abc", Type: "orch", MessageType: "task.complete", Text: "done", At: base.Add(5 * time.Minute)}, |
| 21 | } |
| 22 | out := Format(entries, Options{Channel: "#fleet"}) |
| 23 | |
| 24 | // Header. |
| 25 | if !strings.HasPrefix(out, "#fleet 3msg") { |
| 26 | t.Errorf("header mismatch: %q", out) |
| 27 | } |
| 28 | // Grouped consecutive messages from claude-abc. |
| 29 | if strings.Count(out, "claude-abc") != 1 { |
| 30 | t.Errorf("expected nick grouping, got:\n%s", out) |
| 31 | } |
| 32 | // Contains message types. |
| 33 | if !strings.Contains(out, "task.create") || !strings.Contains(out, "task.complete") { |
| 34 | t.Errorf("missing message types:\n%s", out) |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | func TestFormatPrompt(t *testing.T) { |
| 39 | entries := []Entry{{Nick: "a", Text: "hello"}} |
| 40 | out := FormatPrompt("#test", entries) |
| 41 | if !strings.Contains(out, "Summarize") { |
| 42 | t.Errorf("prompt missing instruction:\n%s", out) |
| 43 | } |
| 44 | if !strings.Contains(out, "#test") { |
| 45 | t.Errorf("prompt missing channel:\n%s", out) |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | func TestCompactDuration(t *testing.T) { |
| 50 | tests := []struct { |
| 51 | d time.Duration |
| 52 | want string |
| 53 | }{ |
| 54 | {30 * time.Second, "30s"}, |
| 55 | {5 * time.Minute, "5m"}, |
| 56 | {2 * time.Hour, "2h"}, |
| 57 | {2*time.Hour + 30*time.Minute, "2h30m"}, |
| 58 | {48 * time.Hour, "2d"}, |
| 59 | } |
| 60 | for _, tt := range tests { |
| 61 | if got := compactDuration(tt.d); got != tt.want { |
| 62 | t.Errorf("compactDuration(%v) = %q, want %q", tt.d, got, tt.want) |
| 63 | } |
| 64 | } |
| 65 | } |