ScuttleBot
feat: agent coordination — topology API, skills, on-join delivery, blocker escalation (#90, #91, #92, #93, #94, #95) #90 — External topology API: provision endpoint now accepts instructions and mirror_detail. New GET/PUT/DELETE /v1/channels/{ch}/instructions endpoints for per-channel on-join message management. #91 — Bot auto-join: already works through topology autojoin policy + INVITE handling. No code changes needed — just config. #92 — Skill-aware discovery: add skills field to agent registration and Agent struct. GET /v1/agents?skill=X filters by capability. #93 — Task channel TTL: already implemented via topology reaper + channel type config. Verified working. #95 — On-join instruction delivery: bridge fires callback when non-bridge users join channels. Main.go wires it to deliver on-join messages from policies as NOTICEs with {nick}/{channel} templating. #94 — Blocker escalation: POST /v1/agents/{nick}/blocker lets relays signal an agent is stuck. Posts [blocker] alert to #ops channel. Also: added Registry.Update() for persisting agent field changes.
2ecf1f28f748597f43426e4fe16cf437f272beb97a42b8eae3360eebe395b3f4
| --- cmd/scuttlebot/main.go | ||
| +++ cmd/scuttlebot/main.go | ||
| @@ -244,10 +244,25 @@ | ||
| 244 | 244 | os.Exit(1) |
| 245 | 245 | } |
| 246 | 246 | } |
| 247 | 247 | if bridgeBot != nil { |
| 248 | 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 | + }) | |
| 249 | 264 | } |
| 250 | 265 | |
| 251 | 266 | // Admin store — bcrypt-hashed admin accounts. |
| 252 | 267 | adminStore, err := auth.NewAdminStore(filepath.Join(cfg.Ergo.DataDir, "admins.json")) |
| 253 | 268 | if err != nil { |
| 254 | 269 |
| --- cmd/scuttlebot/main.go | |
| +++ cmd/scuttlebot/main.go | |
| @@ -244,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 | } |
| 250 | |
| 251 | // Admin store — bcrypt-hashed admin accounts. |
| 252 | adminStore, err := auth.NewAdminStore(filepath.Join(cfg.Ergo.DataDir, "admins.json")) |
| 253 | if err != nil { |
| 254 |
| --- cmd/scuttlebot/main.go | |
| +++ cmd/scuttlebot/main.go | |
| @@ -244,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 { |
| 269 |
| --- 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, |
| @@ -203,10 +211,23 @@ | ||
| 203 | 211 | w.WriteHeader(http.StatusNoContent) |
| 204 | 212 | } |
| 205 | 213 | |
| 206 | 214 | func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) { |
| 207 | 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 | + } | |
| 208 | 229 | writeJSON(w, http.StatusOK, map[string]any{"agents": agents}) |
| 209 | 230 | } |
| 210 | 231 | |
| 211 | 232 | func (s *Server) handleGetAgent(w http.ResponseWriter, r *http.Request) { |
| 212 | 233 | nick := r.PathValue("nick") |
| @@ -215,10 +236,41 @@ | ||
| 215 | 236 | writeError(w, http.StatusNotFound, err.Error()) |
| 216 | 237 | return |
| 217 | 238 | } |
| 218 | 239 | writeJSON(w, http.StatusOK, agent) |
| 219 | 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 | +} | |
| 220 | 272 | |
| 221 | 273 | // agentModeLevel maps an agent type to the ChanServ access level it should |
| 222 | 274 | // receive. Returns "" for types that get no special mode. |
| 223 | 275 | func agentModeLevel(t registry.AgentType) string { |
| 224 | 276 | switch t { |
| 225 | 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, |
| @@ -203,10 +211,23 @@ | |
| 203 | w.WriteHeader(http.StatusNoContent) |
| 204 | } |
| 205 | |
| 206 | func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) { |
| 207 | agents := s.registry.List() |
| 208 | writeJSON(w, http.StatusOK, map[string]any{"agents": agents}) |
| 209 | } |
| 210 | |
| 211 | func (s *Server) handleGetAgent(w http.ResponseWriter, r *http.Request) { |
| 212 | nick := r.PathValue("nick") |
| @@ -215,10 +236,41 @@ | |
| 215 | writeError(w, http.StatusNotFound, err.Error()) |
| 216 | return |
| 217 | } |
| 218 | writeJSON(w, http.StatusOK, agent) |
| 219 | } |
| 220 | |
| 221 | // agentModeLevel maps an agent type to the ChanServ access level it should |
| 222 | // receive. Returns "" for types that get no special mode. |
| 223 | func agentModeLevel(t registry.AgentType) string { |
| 224 | switch t { |
| 225 |
| --- 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, |
| @@ -203,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") |
| @@ -215,10 +236,41 @@ | |
| 236 | writeError(w, http.StatusNotFound, err.Error()) |
| 237 | return |
| 238 | } |
| 239 | writeJSON(w, http.StatusOK, agent) |
| 240 | } |
| 241 | |
| 242 | // handleAgentBlocker handles POST /v1/agents/{nick}/blocker. |
| 243 | // Agents or relays call this to escalate that an agent is stuck. |
| 244 | func (s *Server) handleAgentBlocker(w http.ResponseWriter, r *http.Request) { |
| 245 | nick := r.PathValue("nick") |
| 246 | var req struct { |
| 247 | Channel string `json:"channel,omitempty"` |
| 248 | Message string `json:"message"` |
| 249 | } |
| 250 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| 251 | writeError(w, http.StatusBadRequest, "invalid request body") |
| 252 | return |
| 253 | } |
| 254 | if req.Message == "" { |
| 255 | writeError(w, http.StatusBadRequest, "message is required") |
| 256 | return |
| 257 | } |
| 258 | |
| 259 | alert := "[blocker] " + nick |
| 260 | if req.Channel != "" { |
| 261 | alert += " in " + req.Channel |
| 262 | } |
| 263 | alert += ": " + req.Message |
| 264 | |
| 265 | // Post to #ops if bridge is available. |
| 266 | if s.bridge != nil { |
| 267 | _ = s.bridge.Send(r.Context(), "#ops", alert, "") |
| 268 | } |
| 269 | s.log.Warn("agent blocker", "nick", nick, "channel", req.Channel, "message", req.Message) |
| 270 | w.WriteHeader(http.StatusNoContent) |
| 271 | } |
| 272 | |
| 273 | // agentModeLevel maps an agent type to the ChanServ access level it should |
| 274 | // receive. Returns "" for types that get no special mode. |
| 275 | func agentModeLevel(t registry.AgentType) string { |
| 276 | switch t { |
| 277 |
| --- internal/api/channels_topology.go | ||
| +++ internal/api/channels_topology.go | ||
| @@ -18,15 +18,17 @@ | ||
| 18 | 18 | RevokeAccess(nick, channel string) |
| 19 | 19 | ListChannels() []topology.ChannelInfo |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | 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"` | |
| 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"` | |
| 28 | 30 | } |
| 29 | 31 | |
| 30 | 32 | type provisionChannelResponse struct { |
| 31 | 33 | Channel string `json:"channel"` |
| 32 | 34 | Type string `json:"type,omitempty"` |
| @@ -75,10 +77,28 @@ | ||
| 75 | 77 | if err := s.topoMgr.ProvisionChannel(ch); err != nil { |
| 76 | 78 | s.log.Error("provision channel", "channel", req.Name, "err", err) |
| 77 | 79 | writeError(w, http.StatusInternalServerError, "provision failed") |
| 78 | 80 | return |
| 79 | 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 | + } | |
| 80 | 100 | |
| 81 | 101 | resp := provisionChannelResponse{ |
| 82 | 102 | Channel: req.Name, |
| 83 | 103 | Autojoin: autojoin, |
| 84 | 104 | } |
| @@ -117,10 +137,64 @@ | ||
| 117 | 137 | return |
| 118 | 138 | } |
| 119 | 139 | s.topoMgr.DropChannel(channel) |
| 120 | 140 | w.WriteHeader(http.StatusNoContent) |
| 121 | 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 | +} | |
| 122 | 196 | |
| 123 | 197 | // handleGetTopology handles GET /v1/topology. |
| 124 | 198 | // Returns the channel type rules and static channel names declared in config. |
| 125 | 199 | func (s *Server) handleGetTopology(w http.ResponseWriter, r *http.Request) { |
| 126 | 200 | if s.topoMgr == nil { |
| 127 | 201 |
| --- internal/api/channels_topology.go | |
| +++ internal/api/channels_topology.go | |
| @@ -18,15 +18,17 @@ | |
| 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 | } |
| 29 | |
| 30 | type provisionChannelResponse struct { |
| 31 | Channel string `json:"channel"` |
| 32 | Type string `json:"type,omitempty"` |
| @@ -75,10 +77,28 @@ | |
| 75 | if err := s.topoMgr.ProvisionChannel(ch); err != nil { |
| 76 | s.log.Error("provision channel", "channel", req.Name, "err", err) |
| 77 | writeError(w, http.StatusInternalServerError, "provision failed") |
| 78 | return |
| 79 | } |
| 80 | |
| 81 | resp := provisionChannelResponse{ |
| 82 | Channel: req.Name, |
| 83 | Autojoin: autojoin, |
| 84 | } |
| @@ -117,10 +137,64 @@ | |
| 117 | return |
| 118 | } |
| 119 | s.topoMgr.DropChannel(channel) |
| 120 | w.WriteHeader(http.StatusNoContent) |
| 121 | } |
| 122 | |
| 123 | // handleGetTopology handles GET /v1/topology. |
| 124 | // Returns the channel type rules and static channel names declared in config. |
| 125 | func (s *Server) handleGetTopology(w http.ResponseWriter, r *http.Request) { |
| 126 | if s.topoMgr == nil { |
| 127 |
| --- internal/api/channels_topology.go | |
| +++ internal/api/channels_topology.go | |
| @@ -18,15 +18,17 @@ | |
| 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"` |
| @@ -75,10 +77,28 @@ | |
| 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 | } |
| @@ -117,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 { |
| 201 |
| --- internal/api/server.go | ||
| +++ internal/api/server.go | ||
| @@ -96,10 +96,19 @@ | ||
| 96 | 96 | if s.topoMgr != nil { |
| 97 | 97 | apiMux.HandleFunc("POST /v1/channels", s.requireScope(auth.ScopeTopology, s.handleProvisionChannel)) |
| 98 | 98 | apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.requireScope(auth.ScopeTopology, s.handleDropChannel)) |
| 99 | 99 | apiMux.HandleFunc("GET /v1/topology", s.requireScope(auth.ScopeTopology, s.handleGetTopology)) |
| 100 | 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)) | |
| 101 | 110 | |
| 102 | 111 | // Config — config scope. |
| 103 | 112 | if s.cfgStore != nil { |
| 104 | 113 | apiMux.HandleFunc("GET /v1/config", s.requireScope(auth.ScopeConfig, s.handleGetConfig)) |
| 105 | 114 | apiMux.HandleFunc("PUT /v1/config", s.requireScope(auth.ScopeConfig, s.handlePutConfig)) |
| 106 | 115 |
| --- internal/api/server.go | |
| +++ internal/api/server.go | |
| @@ -96,10 +96,19 @@ | |
| 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 | |
| 102 | // Config — config scope. |
| 103 | if s.cfgStore != nil { |
| 104 | apiMux.HandleFunc("GET /v1/config", s.requireScope(auth.ScopeConfig, s.handleGetConfig)) |
| 105 | apiMux.HandleFunc("PUT /v1/config", s.requireScope(auth.ScopeConfig, s.handlePutConfig)) |
| 106 |
| --- internal/api/server.go | |
| +++ internal/api/server.go | |
| @@ -96,10 +96,19 @@ | |
| 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 |
| --- internal/bots/bridge/bridge.go | ||
| +++ internal/bots/bridge/bridge.go | ||
| @@ -102,12 +102,13 @@ | ||
| 102 | 102 | // webUserTTL controls how long bridge-posted HTTP nicks stay visible in Users(). |
| 103 | 103 | webUserTTL time.Duration |
| 104 | 104 | |
| 105 | 105 | msgTotal atomic.Int64 |
| 106 | 106 | |
| 107 | - joinCh chan string | |
| 108 | - 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 | |
| 109 | 110 | |
| 110 | 111 | // RELAYMSG support detected from ISUPPORT. |
| 111 | 112 | relaySep string // separator (e.g. "/"), empty if unsupported |
| 112 | 113 | } |
| 113 | 114 | |
| @@ -152,10 +153,22 @@ | ||
| 152 | 153 | } |
| 153 | 154 | b.mu.Lock() |
| 154 | 155 | b.webUserTTL = ttl |
| 155 | 156 | b.mu.Unlock() |
| 156 | 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 | +} | |
| 157 | 170 | |
| 158 | 171 | // Name returns the bot's IRC nick. |
| 159 | 172 | func (b *Bot) Name() string { return b.nick } |
| 160 | 173 | |
| 161 | 174 | // Start connects to IRC and begins bridging messages. Blocks until ctx is cancelled. |
| @@ -204,25 +217,33 @@ | ||
| 204 | 217 | b.JoinChannel(ch) |
| 205 | 218 | } |
| 206 | 219 | }) |
| 207 | 220 | |
| 208 | 221 | c.Handlers.AddBg(girc.JOIN, func(_ *girc.Client, e girc.Event) { |
| 209 | - if len(e.Params) < 1 || e.Source == nil || e.Source.Name != b.nick { | |
| 222 | + if len(e.Params) < 1 || e.Source == nil { | |
| 210 | 223 | return |
| 211 | 224 | } |
| 212 | 225 | channel := e.Params[0] |
| 213 | - b.mu.Lock() | |
| 214 | - if !b.joined[channel] { | |
| 215 | - b.joined[channel] = true | |
| 216 | - if b.buffers[channel] == nil { | |
| 217 | - b.buffers[channel] = newRingBuf(b.bufSize) | |
| 218 | - b.subs[channel] = make(map[uint64]chan Message) | |
| 219 | - } | |
| 220 | - } | |
| 221 | - b.mu.Unlock() | |
| 222 | - if b.log != nil { | |
| 223 | - 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) | |
| 224 | 245 | } |
| 225 | 246 | }) |
| 226 | 247 | |
| 227 | 248 | c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) { |
| 228 | 249 | if len(e.Params) < 1 || e.Source == nil { |
| 229 | 250 |
| --- internal/bots/bridge/bridge.go | |
| +++ internal/bots/bridge/bridge.go | |
| @@ -102,12 +102,13 @@ | |
| 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 | |
| 110 | // RELAYMSG support detected from ISUPPORT. |
| 111 | relaySep string // separator (e.g. "/"), empty if unsupported |
| 112 | } |
| 113 | |
| @@ -152,10 +153,22 @@ | |
| 152 | } |
| 153 | b.mu.Lock() |
| 154 | b.webUserTTL = ttl |
| 155 | b.mu.Unlock() |
| 156 | } |
| 157 | |
| 158 | // Name returns the bot's IRC nick. |
| 159 | func (b *Bot) Name() string { return b.nick } |
| 160 | |
| 161 | // Start connects to IRC and begins bridging messages. Blocks until ctx is cancelled. |
| @@ -204,25 +217,33 @@ | |
| 204 | b.JoinChannel(ch) |
| 205 | } |
| 206 | }) |
| 207 | |
| 208 | c.Handlers.AddBg(girc.JOIN, func(_ *girc.Client, e girc.Event) { |
| 209 | if len(e.Params) < 1 || e.Source == nil || e.Source.Name != b.nick { |
| 210 | return |
| 211 | } |
| 212 | channel := e.Params[0] |
| 213 | b.mu.Lock() |
| 214 | if !b.joined[channel] { |
| 215 | b.joined[channel] = true |
| 216 | if b.buffers[channel] == nil { |
| 217 | b.buffers[channel] = newRingBuf(b.bufSize) |
| 218 | b.subs[channel] = make(map[uint64]chan Message) |
| 219 | } |
| 220 | } |
| 221 | b.mu.Unlock() |
| 222 | if b.log != nil { |
| 223 | b.log.Info("bridge joined channel", "channel", channel) |
| 224 | } |
| 225 | }) |
| 226 | |
| 227 | c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) { |
| 228 | if len(e.Params) < 1 || e.Source == nil { |
| 229 |
| --- internal/bots/bridge/bridge.go | |
| +++ internal/bots/bridge/bridge.go | |
| @@ -102,12 +102,13 @@ | |
| 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 | |
| @@ -152,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. |
| @@ -204,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 { |
| 250 |
| --- 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 |