ScuttleBot
Merge pull request #140 from ConflictHQ/feature/116-web-ui-enhancements feat: web UI — bot commands, user modes, on-join instructions
Commit
6d94dfdb60113d8abf50e6aaba71d1ad8fc01e161ba95a513580383d2f3e1c8f
Parent
f64fe5f571b3116…
9 files changed
+6
-3
+4
-2
+51
-6
+4
-2
+77
-11
+76
+76
+7
+3
+6
-3
| --- internal/api/chat.go | ||
| +++ internal/api/chat.go | ||
| @@ -21,10 +21,12 @@ | ||
| 21 | 21 | Send(ctx context.Context, channel, text, senderNick string) error |
| 22 | 22 | SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *bridge.Meta) error |
| 23 | 23 | Stats() bridge.Stats |
| 24 | 24 | TouchUser(channel, nick string) |
| 25 | 25 | Users(channel string) []string |
| 26 | + UsersWithModes(channel string) []bridge.UserInfo | |
| 27 | + ChannelModes(channel string) string | |
| 26 | 28 | } |
| 27 | 29 | |
| 28 | 30 | func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) { |
| 29 | 31 | channel := "#" + r.PathValue("channel") |
| 30 | 32 | s.bridge.JoinChannel(channel) |
| @@ -108,15 +110,16 @@ | ||
| 108 | 110 | w.WriteHeader(http.StatusNoContent) |
| 109 | 111 | } |
| 110 | 112 | |
| 111 | 113 | func (s *Server) handleChannelUsers(w http.ResponseWriter, r *http.Request) { |
| 112 | 114 | channel := "#" + r.PathValue("channel") |
| 113 | - users := s.bridge.Users(channel) | |
| 115 | + users := s.bridge.UsersWithModes(channel) | |
| 114 | 116 | if users == nil { |
| 115 | - users = []string{} | |
| 117 | + users = []bridge.UserInfo{} | |
| 116 | 118 | } |
| 117 | - writeJSON(w, http.StatusOK, map[string]any{"users": users}) | |
| 119 | + modes := s.bridge.ChannelModes(channel) | |
| 120 | + writeJSON(w, http.StatusOK, map[string]any{"users": users, "channel_modes": modes}) | |
| 118 | 121 | } |
| 119 | 122 | |
| 120 | 123 | // handleChannelStream serves an SSE stream of IRC messages for a channel. |
| 121 | 124 | // Auth is via ?token= query param because EventSource doesn't support custom headers. |
| 122 | 125 | func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) { |
| 123 | 126 |
| --- internal/api/chat.go | |
| +++ internal/api/chat.go | |
| @@ -21,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 | } |
| 27 | |
| 28 | func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) { |
| 29 | channel := "#" + r.PathValue("channel") |
| 30 | s.bridge.JoinChannel(channel) |
| @@ -108,15 +110,16 @@ | |
| 108 | w.WriteHeader(http.StatusNoContent) |
| 109 | } |
| 110 | |
| 111 | func (s *Server) handleChannelUsers(w http.ResponseWriter, r *http.Request) { |
| 112 | channel := "#" + r.PathValue("channel") |
| 113 | users := s.bridge.Users(channel) |
| 114 | if users == nil { |
| 115 | users = []string{} |
| 116 | } |
| 117 | writeJSON(w, http.StatusOK, map[string]any{"users": users}) |
| 118 | } |
| 119 | |
| 120 | // handleChannelStream serves an SSE stream of IRC messages for a channel. |
| 121 | // Auth is via ?token= query param because EventSource doesn't support custom headers. |
| 122 | func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) { |
| 123 |
| --- internal/api/chat.go | |
| +++ internal/api/chat.go | |
| @@ -21,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) |
| @@ -108,15 +110,16 @@ | |
| 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 | // handleChannelStream serves an SSE stream of IRC messages for a channel. |
| 124 | // Auth is via ?token= query param because EventSource doesn't support custom headers. |
| 125 | func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) { |
| 126 |
+4
-2
| --- internal/api/chat_test.go | ||
| +++ internal/api/chat_test.go | ||
| @@ -31,12 +31,14 @@ | ||
| 31 | 31 | } |
| 32 | 32 | func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil } |
| 33 | 33 | func (b *stubChatBridge) SendWithMeta(_ context.Context, _, _, _ string, _ *bridge.Meta) error { |
| 34 | 34 | return nil |
| 35 | 35 | } |
| 36 | -func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} } | |
| 37 | -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 "" } | |
| 38 | 40 | func (b *stubChatBridge) TouchUser(channel, nick string) { |
| 39 | 41 | b.touched = append(b.touched, struct{ channel, nick string }{channel: channel, nick: nick}) |
| 40 | 42 | } |
| 41 | 43 | |
| 42 | 44 | func TestHandleChannelPresence(t *testing.T) { |
| 43 | 45 |
| --- internal/api/chat_test.go | |
| +++ internal/api/chat_test.go | |
| @@ -31,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) TouchUser(channel, nick string) { |
| 39 | b.touched = append(b.touched, struct{ channel, nick string }{channel: channel, nick: nick}) |
| 40 | } |
| 41 | |
| 42 | func TestHandleChannelPresence(t *testing.T) { |
| 43 |
| --- internal/api/chat_test.go | |
| +++ internal/api/chat_test.go | |
| @@ -31,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) { |
| 45 |
+51
-6
| --- internal/api/policies.go | ||
| +++ internal/api/policies.go | ||
| @@ -83,16 +83,17 @@ | ||
| 83 | 83 | } `json:"rate_limit,omitempty"` |
| 84 | 84 | } |
| 85 | 85 | |
| 86 | 86 | // Policies is the full mutable settings blob, persisted to policies.json. |
| 87 | 87 | type Policies struct { |
| 88 | - Behaviors []BehaviorConfig `json:"behaviors"` | |
| 89 | - AgentPolicy AgentPolicy `json:"agent_policy"` | |
| 90 | - Bridge BridgePolicy `json:"bridge"` | |
| 91 | - Logging LoggingPolicy `json:"logging"` | |
| 92 | - LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"` | |
| 93 | - ROETemplates []ROETemplate `json:"roe_templates,omitempty"` | |
| 88 | + Behaviors []BehaviorConfig `json:"behaviors"` | |
| 89 | + AgentPolicy AgentPolicy `json:"agent_policy"` | |
| 90 | + Bridge BridgePolicy `json:"bridge"` | |
| 91 | + Logging LoggingPolicy `json:"logging"` | |
| 92 | + LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"` | |
| 93 | + ROETemplates []ROETemplate `json:"roe_templates,omitempty"` | |
| 94 | + OnJoinMessages map[string]string `json:"on_join_messages,omitempty"` // channel → message template | |
| 94 | 95 | } |
| 95 | 96 | |
| 96 | 97 | // defaultBehaviors lists every built-in bot with conservative defaults (disabled). |
| 97 | 98 | var defaultBehaviors = []BehaviorConfig{ |
| 98 | 99 | { |
| @@ -164,10 +165,42 @@ | ||
| 164 | 165 | Description: "Acts on sentinel incident reports — issues warnings, mutes, or kicks based on severity. Operators can also issue direct commands via DM.", |
| 165 | 166 | Nick: "steward", |
| 166 | 167 | JoinAllChannels: true, |
| 167 | 168 | }, |
| 168 | 169 | } |
| 170 | + | |
| 171 | +// BotCommand describes a single command a bot responds to. | |
| 172 | +type BotCommand struct { | |
| 173 | + Command string `json:"command"` | |
| 174 | + Usage string `json:"usage"` | |
| 175 | + Description string `json:"description"` | |
| 176 | +} | |
| 177 | + | |
| 178 | +// botCommands maps bot ID to its available commands. | |
| 179 | +var botCommands = map[string][]BotCommand{ | |
| 180 | + "oracle": { | |
| 181 | + {Command: "summarize", Usage: "summarize #channel [last=N] [format=toon|json]", Description: "Summarize recent channel activity using an LLM."}, | |
| 182 | + }, | |
| 183 | + "scroll": { | |
| 184 | + {Command: "replay", Usage: "replay #channel [last=N] [since=<unix_ms>]", Description: "Replay recent channel history via DM."}, | |
| 185 | + }, | |
| 186 | + "steward": { | |
| 187 | + {Command: "mute", Usage: "mute <nick> [duration]", Description: "Mute a nick in the current channel."}, | |
| 188 | + {Command: "unmute", Usage: "unmute <nick>", Description: "Remove mute from a nick."}, | |
| 189 | + {Command: "kick", Usage: "kick <nick> [reason]", Description: "Kick a nick from the current channel."}, | |
| 190 | + {Command: "warn", Usage: "warn <nick> <message>", Description: "Send a warning notice to a nick."}, | |
| 191 | + }, | |
| 192 | + "warden": { | |
| 193 | + {Command: "status", Usage: "status", Description: "Show warden rate-limit status for all tracked nicks."}, | |
| 194 | + }, | |
| 195 | + "snitch": { | |
| 196 | + {Command: "status", Usage: "status", Description: "Show snitch monitoring status and alert history."}, | |
| 197 | + }, | |
| 198 | + "herald": { | |
| 199 | + {Command: "announce", Usage: "announce #channel <message>", Description: "Post an announcement to a channel."}, | |
| 200 | + }, | |
| 201 | +} | |
| 169 | 202 | |
| 170 | 203 | // PolicyStore persists Policies to a JSON file or database. |
| 171 | 204 | type PolicyStore struct { |
| 172 | 205 | mu sync.RWMutex |
| 173 | 206 | path string |
| @@ -240,10 +273,12 @@ | ||
| 240 | 273 | } |
| 241 | 274 | ps.data.AgentPolicy = p.AgentPolicy |
| 242 | 275 | ps.data.Bridge = p.Bridge |
| 243 | 276 | ps.data.Logging = p.Logging |
| 244 | 277 | ps.data.LLMBackends = p.LLMBackends |
| 278 | + ps.data.ROETemplates = p.ROETemplates | |
| 279 | + ps.data.OnJoinMessages = p.OnJoinMessages | |
| 245 | 280 | return nil |
| 246 | 281 | } |
| 247 | 282 | |
| 248 | 283 | func (ps *PolicyStore) save() error { |
| 249 | 284 | raw, err := json.MarshalIndent(ps.data, "", " ") |
| @@ -350,10 +385,20 @@ | ||
| 350 | 385 | |
| 351 | 386 | // Merge LLM backends if provided. |
| 352 | 387 | if patch.LLMBackends != nil { |
| 353 | 388 | ps.data.LLMBackends = patch.LLMBackends |
| 354 | 389 | } |
| 390 | + | |
| 391 | + // Merge ROE templates if provided. | |
| 392 | + if patch.ROETemplates != nil { | |
| 393 | + ps.data.ROETemplates = patch.ROETemplates | |
| 394 | + } | |
| 395 | + | |
| 396 | + // Merge on-join messages if provided. | |
| 397 | + if patch.OnJoinMessages != nil { | |
| 398 | + ps.data.OnJoinMessages = patch.OnJoinMessages | |
| 399 | + } | |
| 355 | 400 | |
| 356 | 401 | ps.normalize(&ps.data) |
| 357 | 402 | if err := ps.save(); err != nil { |
| 358 | 403 | return err |
| 359 | 404 | } |
| 360 | 405 |
| --- internal/api/policies.go | |
| +++ internal/api/policies.go | |
| @@ -83,16 +83,17 @@ | |
| 83 | } `json:"rate_limit,omitempty"` |
| 84 | } |
| 85 | |
| 86 | // Policies is the full mutable settings blob, persisted to policies.json. |
| 87 | type Policies struct { |
| 88 | Behaviors []BehaviorConfig `json:"behaviors"` |
| 89 | AgentPolicy AgentPolicy `json:"agent_policy"` |
| 90 | Bridge BridgePolicy `json:"bridge"` |
| 91 | Logging LoggingPolicy `json:"logging"` |
| 92 | LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"` |
| 93 | ROETemplates []ROETemplate `json:"roe_templates,omitempty"` |
| 94 | } |
| 95 | |
| 96 | // defaultBehaviors lists every built-in bot with conservative defaults (disabled). |
| 97 | var defaultBehaviors = []BehaviorConfig{ |
| 98 | { |
| @@ -164,10 +165,42 @@ | |
| 164 | Description: "Acts on sentinel incident reports — issues warnings, mutes, or kicks based on severity. Operators can also issue direct commands via DM.", |
| 165 | Nick: "steward", |
| 166 | JoinAllChannels: true, |
| 167 | }, |
| 168 | } |
| 169 | |
| 170 | // PolicyStore persists Policies to a JSON file or database. |
| 171 | type PolicyStore struct { |
| 172 | mu sync.RWMutex |
| 173 | path string |
| @@ -240,10 +273,12 @@ | |
| 240 | } |
| 241 | ps.data.AgentPolicy = p.AgentPolicy |
| 242 | ps.data.Bridge = p.Bridge |
| 243 | ps.data.Logging = p.Logging |
| 244 | ps.data.LLMBackends = p.LLMBackends |
| 245 | return nil |
| 246 | } |
| 247 | |
| 248 | func (ps *PolicyStore) save() error { |
| 249 | raw, err := json.MarshalIndent(ps.data, "", " ") |
| @@ -350,10 +385,20 @@ | |
| 350 | |
| 351 | // Merge LLM backends if provided. |
| 352 | if patch.LLMBackends != nil { |
| 353 | ps.data.LLMBackends = patch.LLMBackends |
| 354 | } |
| 355 | |
| 356 | ps.normalize(&ps.data) |
| 357 | if err := ps.save(); err != nil { |
| 358 | return err |
| 359 | } |
| 360 |
| --- internal/api/policies.go | |
| +++ internal/api/policies.go | |
| @@ -83,16 +83,17 @@ | |
| 83 | } `json:"rate_limit,omitempty"` |
| 84 | } |
| 85 | |
| 86 | // Policies is the full mutable settings blob, persisted to policies.json. |
| 87 | type Policies struct { |
| 88 | Behaviors []BehaviorConfig `json:"behaviors"` |
| 89 | AgentPolicy AgentPolicy `json:"agent_policy"` |
| 90 | Bridge BridgePolicy `json:"bridge"` |
| 91 | Logging LoggingPolicy `json:"logging"` |
| 92 | LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"` |
| 93 | ROETemplates []ROETemplate `json:"roe_templates,omitempty"` |
| 94 | OnJoinMessages map[string]string `json:"on_join_messages,omitempty"` // channel → message template |
| 95 | } |
| 96 | |
| 97 | // defaultBehaviors lists every built-in bot with conservative defaults (disabled). |
| 98 | var defaultBehaviors = []BehaviorConfig{ |
| 99 | { |
| @@ -164,10 +165,42 @@ | |
| 165 | Description: "Acts on sentinel incident reports — issues warnings, mutes, or kicks based on severity. Operators can also issue direct commands via DM.", |
| 166 | Nick: "steward", |
| 167 | JoinAllChannels: true, |
| 168 | }, |
| 169 | } |
| 170 | |
| 171 | // BotCommand describes a single command a bot responds to. |
| 172 | type BotCommand struct { |
| 173 | Command string `json:"command"` |
| 174 | Usage string `json:"usage"` |
| 175 | Description string `json:"description"` |
| 176 | } |
| 177 | |
| 178 | // botCommands maps bot ID to its available commands. |
| 179 | var botCommands = map[string][]BotCommand{ |
| 180 | "oracle": { |
| 181 | {Command: "summarize", Usage: "summarize #channel [last=N] [format=toon|json]", Description: "Summarize recent channel activity using an LLM."}, |
| 182 | }, |
| 183 | "scroll": { |
| 184 | {Command: "replay", Usage: "replay #channel [last=N] [since=<unix_ms>]", Description: "Replay recent channel history via DM."}, |
| 185 | }, |
| 186 | "steward": { |
| 187 | {Command: "mute", Usage: "mute <nick> [duration]", Description: "Mute a nick in the current channel."}, |
| 188 | {Command: "unmute", Usage: "unmute <nick>", Description: "Remove mute from a nick."}, |
| 189 | {Command: "kick", Usage: "kick <nick> [reason]", Description: "Kick a nick from the current channel."}, |
| 190 | {Command: "warn", Usage: "warn <nick> <message>", Description: "Send a warning notice to a nick."}, |
| 191 | }, |
| 192 | "warden": { |
| 193 | {Command: "status", Usage: "status", Description: "Show warden rate-limit status for all tracked nicks."}, |
| 194 | }, |
| 195 | "snitch": { |
| 196 | {Command: "status", Usage: "status", Description: "Show snitch monitoring status and alert history."}, |
| 197 | }, |
| 198 | "herald": { |
| 199 | {Command: "announce", Usage: "announce #channel <message>", Description: "Post an announcement to a channel."}, |
| 200 | }, |
| 201 | } |
| 202 | |
| 203 | // PolicyStore persists Policies to a JSON file or database. |
| 204 | type PolicyStore struct { |
| 205 | mu sync.RWMutex |
| 206 | path string |
| @@ -240,10 +273,12 @@ | |
| 273 | } |
| 274 | ps.data.AgentPolicy = p.AgentPolicy |
| 275 | ps.data.Bridge = p.Bridge |
| 276 | ps.data.Logging = p.Logging |
| 277 | ps.data.LLMBackends = p.LLMBackends |
| 278 | ps.data.ROETemplates = p.ROETemplates |
| 279 | ps.data.OnJoinMessages = p.OnJoinMessages |
| 280 | return nil |
| 281 | } |
| 282 | |
| 283 | func (ps *PolicyStore) save() error { |
| 284 | raw, err := json.MarshalIndent(ps.data, "", " ") |
| @@ -350,10 +385,20 @@ | |
| 385 | |
| 386 | // Merge LLM backends if provided. |
| 387 | if patch.LLMBackends != nil { |
| 388 | ps.data.LLMBackends = patch.LLMBackends |
| 389 | } |
| 390 | |
| 391 | // Merge ROE templates if provided. |
| 392 | if patch.ROETemplates != nil { |
| 393 | ps.data.ROETemplates = patch.ROETemplates |
| 394 | } |
| 395 | |
| 396 | // Merge on-join messages if provided. |
| 397 | if patch.OnJoinMessages != nil { |
| 398 | ps.data.OnJoinMessages = patch.OnJoinMessages |
| 399 | } |
| 400 | |
| 401 | ps.normalize(&ps.data) |
| 402 | if err := ps.save(); err != nil { |
| 403 | return err |
| 404 | } |
| 405 |
+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 |
+77
-11
| --- internal/api/ui/index.html | ||
| +++ internal/api/ui/index.html | ||
| @@ -534,11 +534,11 @@ | ||
| 534 | 534 | <div class="chan-list" id="chan-list"></div> |
| 535 | 535 | </div> |
| 536 | 536 | <div class="sidebar-resize" id="resize-left" title="drag to resize"></div> |
| 537 | 537 | <div class="chat-main"> |
| 538 | 538 | <div class="chat-topbar"> |
| 539 | - <span class="chat-ch-name" id="chat-ch-name">select a channel</span> | |
| 539 | + <span class="chat-ch-name" id="chat-ch-name">select a channel</span><span id="chat-channel-modes" style="color:#8b949e;font-size:11px;margin-left:6px"></span> | |
| 540 | 540 | <div class="spacer"></div> |
| 541 | 541 | <span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span> |
| 542 | 542 | <select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()"> |
| 543 | 543 | <option value="">— pick a user —</option> |
| 544 | 544 | </select> |
| @@ -665,10 +665,28 @@ | ||
| 665 | 665 | </div> |
| 666 | 666 | <div class="card-body" style="padding:0"> |
| 667 | 667 | <div id="behaviors-list"></div> |
| 668 | 668 | </div> |
| 669 | 669 | </div> |
| 670 | + | |
| 671 | + <!-- on-join instructions --> | |
| 672 | + <div class="card" id="card-onjoin"> | |
| 673 | + <div class="card-header" onclick="toggleCard('card-onjoin',event)"> | |
| 674 | + <h2>on-join instructions</h2><span class="card-desc">messages sent to agents when they join a channel</span><span class="collapse-icon">▾</span> | |
| 675 | + <div class="spacer"></div> | |
| 676 | + <button class="sm primary" onclick="event.stopPropagation();savePolicies()">save</button> | |
| 677 | + </div> | |
| 678 | + <div class="card-body"> | |
| 679 | + <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Per-channel instructions delivered to agents on join. Supports <code>{nick}</code> and <code>{channel}</code> template variables.</p> | |
| 680 | + <div id="onjoin-list"></div> | |
| 681 | + <div style="display:flex;gap:8px;margin-top:12px;align-items:flex-end"> | |
| 682 | + <div style="flex:0 0 160px"><label>channel</label><input type="text" id="onjoin-new-channel" placeholder="#channel" style="width:100%"></div> | |
| 683 | + <div style="flex:1"><label>message</label><input type="text" id="onjoin-new-message" placeholder="Welcome to {channel}, {nick}!" style="width:100%"></div> | |
| 684 | + <button class="sm primary" onclick="addOnJoinMessage()">add</button> | |
| 685 | + </div> | |
| 686 | + </div> | |
| 687 | + </div> | |
| 670 | 688 | |
| 671 | 689 | <!-- agent policy --> |
| 672 | 690 | <div class="card" id="card-agentpolicy"> |
| 673 | 691 | <div class="card-header" onclick="toggleCard('card-agentpolicy',event)"><h2>agent policy</h2><span class="card-desc">autojoin and check-in rules for all agents</span><span class="collapse-icon">▾</span><div class="spacer"></div><button class="sm primary" onclick="event.stopPropagation();saveAgentPolicy()">save</button></div> |
| 674 | 692 | <div class="card-body"> |
| @@ -2056,11 +2074,11 @@ | ||
| 2056 | 2074 | async function loadNicklist(ch) { |
| 2057 | 2075 | if (!ch) return; |
| 2058 | 2076 | try { |
| 2059 | 2077 | const slug = ch.replace(/^#/,''); |
| 2060 | 2078 | const data = await api('GET', `/v1/channels/${slug}/users`); |
| 2061 | - renderNicklist(data.users || []); | |
| 2079 | + renderNicklist(data.users || [], data.channel_modes || ''); | |
| 2062 | 2080 | } catch(e) {} |
| 2063 | 2081 | } |
| 2064 | 2082 | const SYSTEM_BOTS = new Set(['bridge','oracle','sentinel','steward','scribe','warden','snitch','herald','scroll','systembot','auditbot']); |
| 2065 | 2083 | const AGENT_PREFIXES = ['claude-','codex-','gemini-','openclaw-']; |
| 2066 | 2084 | |
| @@ -2079,24 +2097,35 @@ | ||
| 2079 | 2097 | if (tier === 0) return '@'; |
| 2080 | 2098 | if (tier === 1) return '+'; |
| 2081 | 2099 | return ''; |
| 2082 | 2100 | } |
| 2083 | 2101 | |
| 2084 | -function renderNicklist(users) { | |
| 2102 | +function renderNicklist(users, channelModes) { | |
| 2085 | 2103 | const el = document.getElementById('nicklist-users'); |
| 2104 | + // users may be [{nick, modes}] or ["nick"] for backwards compat. | |
| 2105 | + const normalized = users.map(u => typeof u === 'string' ? {nick: u, modes: []} : u); | |
| 2086 | 2106 | // Sort: ops > system bots > agents > users, alpha within each tier. |
| 2087 | - const sorted = users.slice().sort((a, b) => { | |
| 2088 | - const ta = nickTier(a), tb = nickTier(b); | |
| 2107 | + const sorted = normalized.slice().sort((a, b) => { | |
| 2108 | + const ta = nickTier(a.nick), tb = nickTier(b.nick); | |
| 2089 | 2109 | if (ta !== tb) return ta - tb; |
| 2090 | - return a.localeCompare(b); | |
| 2110 | + return a.nick.localeCompare(b.nick); | |
| 2091 | 2111 | }); |
| 2092 | - el.innerHTML = sorted.map(nick => { | |
| 2093 | - const tier = nickTier(nick); | |
| 2094 | - const prefix = nickPrefix(nick); | |
| 2095 | - const cls = tier === 1 ? ' is-bot' : tier === 0 ? ' is-op' : ''; | |
| 2096 | - return `<div class="nicklist-nick${cls}" title="${esc(nick)}">${prefix}${esc(nick)}</div>`; | |
| 2112 | + el.innerHTML = sorted.map(u => { | |
| 2113 | + const modes = u.modes || []; | |
| 2114 | + // IRC mode prefix: @ for op, + for voice | |
| 2115 | + let prefix = ''; | |
| 2116 | + if (modes.includes('o') || modes.includes('a') || modes.includes('q')) prefix = '@'; | |
| 2117 | + else if (modes.includes('v')) prefix = '+'; | |
| 2118 | + else prefix = nickPrefix(u.nick); | |
| 2119 | + const tier = nickTier(u.nick); | |
| 2120 | + const cls = (modes.includes('o') || tier === 0) ? ' is-op' : tier === 1 ? ' is-bot' : ''; | |
| 2121 | + const modeStr = modes.length ? ` [+${modes.join('')}]` : ''; | |
| 2122 | + return `<div class="nicklist-nick${cls}" title="${esc(u.nick)}${modeStr}">${prefix}${esc(u.nick)}</div>`; | |
| 2097 | 2123 | }).join(''); |
| 2124 | + // Show channel modes in header if available. | |
| 2125 | + const modesEl = document.getElementById('chat-channel-modes'); | |
| 2126 | + if (modesEl) modesEl.textContent = channelModes ? ` ${channelModes}` : ''; | |
| 2098 | 2127 | } |
| 2099 | 2128 | // Nick colors — deterministic hash over a palette |
| 2100 | 2129 | const NICK_PALETTE = ['#58a6ff','#3fb950','#ffa657','#d2a8ff','#56d364','#79c0ff','#ff7b72','#a5d6ff','#f0883e','#39d353']; |
| 2101 | 2130 | function nickColor(nick) { |
| 2102 | 2131 | let h = 0; |
| @@ -3182,10 +3211,41 @@ | ||
| 3182 | 3211 | if (body) body.style.display = ''; |
| 3183 | 3212 | } |
| 3184 | 3213 | |
| 3185 | 3214 | // --- settings / policies --- |
| 3186 | 3215 | let currentPolicies = null; |
| 3216 | +let _botCommands = {}; | |
| 3217 | + | |
| 3218 | +function renderOnJoinMessages(msgs) { | |
| 3219 | + const el = document.getElementById('onjoin-list'); | |
| 3220 | + if (!msgs || !Object.keys(msgs).length) { el.innerHTML = '<div style="color:#8b949e;font-size:12px">No on-join instructions configured.</div>'; return; } | |
| 3221 | + el.innerHTML = Object.entries(msgs).sort().map(([ch, msg]) => ` | |
| 3222 | + <div style="display:flex;gap:8px;align-items:center;padding:6px 0;border-bottom:1px solid #21262d"> | |
| 3223 | + <code style="font-size:12px;min-width:120px">${esc(ch)}</code> | |
| 3224 | + <input type="text" value="${esc(msg)}" style="flex:1;font-size:12px" onchange="updateOnJoinMessage('${esc(ch)}',this.value)"> | |
| 3225 | + <button class="sm danger" onclick="removeOnJoinMessage('${esc(ch)}')">remove</button> | |
| 3226 | + </div> | |
| 3227 | + `).join(''); | |
| 3228 | +} | |
| 3229 | +function addOnJoinMessage() { | |
| 3230 | + const ch = document.getElementById('onjoin-new-channel').value.trim(); | |
| 3231 | + const msg = document.getElementById('onjoin-new-message').value.trim(); | |
| 3232 | + if (!ch || !msg) return; | |
| 3233 | + if (!currentPolicies.on_join_messages) currentPolicies.on_join_messages = {}; | |
| 3234 | + currentPolicies.on_join_messages[ch] = msg; | |
| 3235 | + document.getElementById('onjoin-new-channel').value = ''; | |
| 3236 | + document.getElementById('onjoin-new-message').value = ''; | |
| 3237 | + renderOnJoinMessages(currentPolicies.on_join_messages); | |
| 3238 | +} | |
| 3239 | +function updateOnJoinMessage(ch, msg) { | |
| 3240 | + if (!currentPolicies.on_join_messages) currentPolicies.on_join_messages = {}; | |
| 3241 | + currentPolicies.on_join_messages[ch] = msg; | |
| 3242 | +} | |
| 3243 | +function removeOnJoinMessage(ch) { | |
| 3244 | + if (currentPolicies.on_join_messages) delete currentPolicies.on_join_messages[ch]; | |
| 3245 | + renderOnJoinMessages(currentPolicies.on_join_messages); | |
| 3246 | +} | |
| 3187 | 3247 | let _llmBackendNames = []; // cached backend names for oracle dropdown |
| 3188 | 3248 | |
| 3189 | 3249 | async function loadSettings() { |
| 3190 | 3250 | try { |
| 3191 | 3251 | const [s, backends] = await Promise.all([ |
| @@ -3193,11 +3253,13 @@ | ||
| 3193 | 3253 | api('GET', '/v1/llm/backends').catch(() => []), |
| 3194 | 3254 | ]); |
| 3195 | 3255 | _llmBackendNames = (backends || []).map(b => b.name); |
| 3196 | 3256 | renderTLSStatus(s.tls); |
| 3197 | 3257 | currentPolicies = s.policies; |
| 3258 | + _botCommands = s.bot_commands || {}; | |
| 3198 | 3259 | renderBehaviors(s.policies.behaviors || []); |
| 3260 | + renderOnJoinMessages(s.policies.on_join_messages || {}); | |
| 3199 | 3261 | renderAgentPolicy(s.policies.agent_policy || {}); |
| 3200 | 3262 | renderBridgePolicy(s.policies.bridge || {}); |
| 3201 | 3263 | renderLoggingPolicy(s.policies.logging || {}); |
| 3202 | 3264 | loadAdmins(); |
| 3203 | 3265 | loadAPIKeys(); |
| @@ -3257,10 +3319,14 @@ | ||
| 3257 | 3319 | ` : ''} |
| 3258 | 3320 | <span class="tag type-observer" style="font-size:11px;min-width:64px;text-align:center">${esc(b.nick)}</span> |
| 3259 | 3321 | </div> |
| 3260 | 3322 | </div> |
| 3261 | 3323 | ${b.enabled && hasSchema(b.id) ? renderBehConfig(b) : ''} |
| 3324 | + ${_botCommands[b.id] ? `<div style="padding:6px 16px 8px 42px;border-bottom:1px solid #21262d;background:#0d1117"> | |
| 3325 | + <span style="font-size:11px;color:#8b949e;font-weight:600">commands:</span> | |
| 3326 | + ${_botCommands[b.id].map(c => `<code style="font-size:11px;margin-left:8px;background:#161b22;padding:1px 5px;border-radius:3px" title="${esc(c.description)} ${esc(c.usage)}">${esc(c.command)}</code>`).join('')} | |
| 3327 | + </div>` : ''} | |
| 3262 | 3328 | </div> |
| 3263 | 3329 | `).join(''); |
| 3264 | 3330 | } |
| 3265 | 3331 | |
| 3266 | 3332 | function onBehaviorToggle(id, enabled) { |
| 3267 | 3333 |
| --- internal/api/ui/index.html | |
| +++ internal/api/ui/index.html | |
| @@ -534,11 +534,11 @@ | |
| 534 | <div class="chan-list" id="chan-list"></div> |
| 535 | </div> |
| 536 | <div class="sidebar-resize" id="resize-left" title="drag to resize"></div> |
| 537 | <div class="chat-main"> |
| 538 | <div class="chat-topbar"> |
| 539 | <span class="chat-ch-name" id="chat-ch-name">select a channel</span> |
| 540 | <div class="spacer"></div> |
| 541 | <span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span> |
| 542 | <select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()"> |
| 543 | <option value="">— pick a user —</option> |
| 544 | </select> |
| @@ -665,10 +665,28 @@ | |
| 665 | </div> |
| 666 | <div class="card-body" style="padding:0"> |
| 667 | <div id="behaviors-list"></div> |
| 668 | </div> |
| 669 | </div> |
| 670 | |
| 671 | <!-- agent policy --> |
| 672 | <div class="card" id="card-agentpolicy"> |
| 673 | <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> |
| 674 | <div class="card-body"> |
| @@ -2056,11 +2074,11 @@ | |
| 2056 | async function loadNicklist(ch) { |
| 2057 | if (!ch) return; |
| 2058 | try { |
| 2059 | const slug = ch.replace(/^#/,''); |
| 2060 | const data = await api('GET', `/v1/channels/${slug}/users`); |
| 2061 | renderNicklist(data.users || []); |
| 2062 | } catch(e) {} |
| 2063 | } |
| 2064 | const SYSTEM_BOTS = new Set(['bridge','oracle','sentinel','steward','scribe','warden','snitch','herald','scroll','systembot','auditbot']); |
| 2065 | const AGENT_PREFIXES = ['claude-','codex-','gemini-','openclaw-']; |
| 2066 | |
| @@ -2079,24 +2097,35 @@ | |
| 2079 | if (tier === 0) return '@'; |
| 2080 | if (tier === 1) return '+'; |
| 2081 | return ''; |
| 2082 | } |
| 2083 | |
| 2084 | function renderNicklist(users) { |
| 2085 | const el = document.getElementById('nicklist-users'); |
| 2086 | // Sort: ops > system bots > agents > users, alpha within each tier. |
| 2087 | const sorted = users.slice().sort((a, b) => { |
| 2088 | const ta = nickTier(a), tb = nickTier(b); |
| 2089 | if (ta !== tb) return ta - tb; |
| 2090 | return a.localeCompare(b); |
| 2091 | }); |
| 2092 | el.innerHTML = sorted.map(nick => { |
| 2093 | const tier = nickTier(nick); |
| 2094 | const prefix = nickPrefix(nick); |
| 2095 | const cls = tier === 1 ? ' is-bot' : tier === 0 ? ' is-op' : ''; |
| 2096 | return `<div class="nicklist-nick${cls}" title="${esc(nick)}">${prefix}${esc(nick)}</div>`; |
| 2097 | }).join(''); |
| 2098 | } |
| 2099 | // Nick colors — deterministic hash over a palette |
| 2100 | const NICK_PALETTE = ['#58a6ff','#3fb950','#ffa657','#d2a8ff','#56d364','#79c0ff','#ff7b72','#a5d6ff','#f0883e','#39d353']; |
| 2101 | function nickColor(nick) { |
| 2102 | let h = 0; |
| @@ -3182,10 +3211,41 @@ | |
| 3182 | if (body) body.style.display = ''; |
| 3183 | } |
| 3184 | |
| 3185 | // --- settings / policies --- |
| 3186 | let currentPolicies = null; |
| 3187 | let _llmBackendNames = []; // cached backend names for oracle dropdown |
| 3188 | |
| 3189 | async function loadSettings() { |
| 3190 | try { |
| 3191 | const [s, backends] = await Promise.all([ |
| @@ -3193,11 +3253,13 @@ | |
| 3193 | api('GET', '/v1/llm/backends').catch(() => []), |
| 3194 | ]); |
| 3195 | _llmBackendNames = (backends || []).map(b => b.name); |
| 3196 | renderTLSStatus(s.tls); |
| 3197 | currentPolicies = s.policies; |
| 3198 | renderBehaviors(s.policies.behaviors || []); |
| 3199 | renderAgentPolicy(s.policies.agent_policy || {}); |
| 3200 | renderBridgePolicy(s.policies.bridge || {}); |
| 3201 | renderLoggingPolicy(s.policies.logging || {}); |
| 3202 | loadAdmins(); |
| 3203 | loadAPIKeys(); |
| @@ -3257,10 +3319,14 @@ | |
| 3257 | ` : ''} |
| 3258 | <span class="tag type-observer" style="font-size:11px;min-width:64px;text-align:center">${esc(b.nick)}</span> |
| 3259 | </div> |
| 3260 | </div> |
| 3261 | ${b.enabled && hasSchema(b.id) ? renderBehConfig(b) : ''} |
| 3262 | </div> |
| 3263 | `).join(''); |
| 3264 | } |
| 3265 | |
| 3266 | function onBehaviorToggle(id, enabled) { |
| 3267 |
| --- internal/api/ui/index.html | |
| +++ internal/api/ui/index.html | |
| @@ -534,11 +534,11 @@ | |
| 534 | <div class="chan-list" id="chan-list"></div> |
| 535 | </div> |
| 536 | <div class="sidebar-resize" id="resize-left" title="drag to resize"></div> |
| 537 | <div class="chat-main"> |
| 538 | <div class="chat-topbar"> |
| 539 | <span class="chat-ch-name" id="chat-ch-name">select a channel</span><span id="chat-channel-modes" style="color:#8b949e;font-size:11px;margin-left:6px"></span> |
| 540 | <div class="spacer"></div> |
| 541 | <span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span> |
| 542 | <select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()"> |
| 543 | <option value="">— pick a user —</option> |
| 544 | </select> |
| @@ -665,10 +665,28 @@ | |
| 665 | </div> |
| 666 | <div class="card-body" style="padding:0"> |
| 667 | <div id="behaviors-list"></div> |
| 668 | </div> |
| 669 | </div> |
| 670 | |
| 671 | <!-- on-join instructions --> |
| 672 | <div class="card" id="card-onjoin"> |
| 673 | <div class="card-header" onclick="toggleCard('card-onjoin',event)"> |
| 674 | <h2>on-join instructions</h2><span class="card-desc">messages sent to agents when they join a channel</span><span class="collapse-icon">▾</span> |
| 675 | <div class="spacer"></div> |
| 676 | <button class="sm primary" onclick="event.stopPropagation();savePolicies()">save</button> |
| 677 | </div> |
| 678 | <div class="card-body"> |
| 679 | <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Per-channel instructions delivered to agents on join. Supports <code>{nick}</code> and <code>{channel}</code> template variables.</p> |
| 680 | <div id="onjoin-list"></div> |
| 681 | <div style="display:flex;gap:8px;margin-top:12px;align-items:flex-end"> |
| 682 | <div style="flex:0 0 160px"><label>channel</label><input type="text" id="onjoin-new-channel" placeholder="#channel" style="width:100%"></div> |
| 683 | <div style="flex:1"><label>message</label><input type="text" id="onjoin-new-message" placeholder="Welcome to {channel}, {nick}!" style="width:100%"></div> |
| 684 | <button class="sm primary" onclick="addOnJoinMessage()">add</button> |
| 685 | </div> |
| 686 | </div> |
| 687 | </div> |
| 688 | |
| 689 | <!-- agent policy --> |
| 690 | <div class="card" id="card-agentpolicy"> |
| 691 | <div class="card-header" onclick="toggleCard('card-agentpolicy',event)"><h2>agent policy</h2><span class="card-desc">autojoin and check-in rules for all agents</span><span class="collapse-icon">▾</span><div class="spacer"></div><button class="sm primary" onclick="event.stopPropagation();saveAgentPolicy()">save</button></div> |
| 692 | <div class="card-body"> |
| @@ -2056,11 +2074,11 @@ | |
| 2074 | async function loadNicklist(ch) { |
| 2075 | if (!ch) return; |
| 2076 | try { |
| 2077 | const slug = ch.replace(/^#/,''); |
| 2078 | const data = await api('GET', `/v1/channels/${slug}/users`); |
| 2079 | renderNicklist(data.users || [], data.channel_modes || ''); |
| 2080 | } catch(e) {} |
| 2081 | } |
| 2082 | const SYSTEM_BOTS = new Set(['bridge','oracle','sentinel','steward','scribe','warden','snitch','herald','scroll','systembot','auditbot']); |
| 2083 | const AGENT_PREFIXES = ['claude-','codex-','gemini-','openclaw-']; |
| 2084 | |
| @@ -2079,24 +2097,35 @@ | |
| 2097 | if (tier === 0) return '@'; |
| 2098 | if (tier === 1) return '+'; |
| 2099 | return ''; |
| 2100 | } |
| 2101 | |
| 2102 | function renderNicklist(users, channelModes) { |
| 2103 | const el = document.getElementById('nicklist-users'); |
| 2104 | // users may be [{nick, modes}] or ["nick"] for backwards compat. |
| 2105 | const normalized = users.map(u => typeof u === 'string' ? {nick: u, modes: []} : u); |
| 2106 | // Sort: ops > system bots > agents > users, alpha within each tier. |
| 2107 | const sorted = normalized.slice().sort((a, b) => { |
| 2108 | const ta = nickTier(a.nick), tb = nickTier(b.nick); |
| 2109 | if (ta !== tb) return ta - tb; |
| 2110 | return a.nick.localeCompare(b.nick); |
| 2111 | }); |
| 2112 | el.innerHTML = sorted.map(u => { |
| 2113 | const modes = u.modes || []; |
| 2114 | // IRC mode prefix: @ for op, + for voice |
| 2115 | let prefix = ''; |
| 2116 | if (modes.includes('o') || modes.includes('a') || modes.includes('q')) prefix = '@'; |
| 2117 | else if (modes.includes('v')) prefix = '+'; |
| 2118 | else prefix = nickPrefix(u.nick); |
| 2119 | const tier = nickTier(u.nick); |
| 2120 | const cls = (modes.includes('o') || tier === 0) ? ' is-op' : tier === 1 ? ' is-bot' : ''; |
| 2121 | const modeStr = modes.length ? ` [+${modes.join('')}]` : ''; |
| 2122 | return `<div class="nicklist-nick${cls}" title="${esc(u.nick)}${modeStr}">${prefix}${esc(u.nick)}</div>`; |
| 2123 | }).join(''); |
| 2124 | // Show channel modes in header if available. |
| 2125 | const modesEl = document.getElementById('chat-channel-modes'); |
| 2126 | if (modesEl) modesEl.textContent = channelModes ? ` ${channelModes}` : ''; |
| 2127 | } |
| 2128 | // Nick colors — deterministic hash over a palette |
| 2129 | const NICK_PALETTE = ['#58a6ff','#3fb950','#ffa657','#d2a8ff','#56d364','#79c0ff','#ff7b72','#a5d6ff','#f0883e','#39d353']; |
| 2130 | function nickColor(nick) { |
| 2131 | let h = 0; |
| @@ -3182,10 +3211,41 @@ | |
| 3211 | if (body) body.style.display = ''; |
| 3212 | } |
| 3213 | |
| 3214 | // --- settings / policies --- |
| 3215 | let currentPolicies = null; |
| 3216 | let _botCommands = {}; |
| 3217 | |
| 3218 | function renderOnJoinMessages(msgs) { |
| 3219 | const el = document.getElementById('onjoin-list'); |
| 3220 | if (!msgs || !Object.keys(msgs).length) { el.innerHTML = '<div style="color:#8b949e;font-size:12px">No on-join instructions configured.</div>'; return; } |
| 3221 | el.innerHTML = Object.entries(msgs).sort().map(([ch, msg]) => ` |
| 3222 | <div style="display:flex;gap:8px;align-items:center;padding:6px 0;border-bottom:1px solid #21262d"> |
| 3223 | <code style="font-size:12px;min-width:120px">${esc(ch)}</code> |
| 3224 | <input type="text" value="${esc(msg)}" style="flex:1;font-size:12px" onchange="updateOnJoinMessage('${esc(ch)}',this.value)"> |
| 3225 | <button class="sm danger" onclick="removeOnJoinMessage('${esc(ch)}')">remove</button> |
| 3226 | </div> |
| 3227 | `).join(''); |
| 3228 | } |
| 3229 | function addOnJoinMessage() { |
| 3230 | const ch = document.getElementById('onjoin-new-channel').value.trim(); |
| 3231 | const msg = document.getElementById('onjoin-new-message').value.trim(); |
| 3232 | if (!ch || !msg) return; |
| 3233 | if (!currentPolicies.on_join_messages) currentPolicies.on_join_messages = {}; |
| 3234 | currentPolicies.on_join_messages[ch] = msg; |
| 3235 | document.getElementById('onjoin-new-channel').value = ''; |
| 3236 | document.getElementById('onjoin-new-message').value = ''; |
| 3237 | renderOnJoinMessages(currentPolicies.on_join_messages); |
| 3238 | } |
| 3239 | function updateOnJoinMessage(ch, msg) { |
| 3240 | if (!currentPolicies.on_join_messages) currentPolicies.on_join_messages = {}; |
| 3241 | currentPolicies.on_join_messages[ch] = msg; |
| 3242 | } |
| 3243 | function removeOnJoinMessage(ch) { |
| 3244 | if (currentPolicies.on_join_messages) delete currentPolicies.on_join_messages[ch]; |
| 3245 | renderOnJoinMessages(currentPolicies.on_join_messages); |
| 3246 | } |
| 3247 | let _llmBackendNames = []; // cached backend names for oracle dropdown |
| 3248 | |
| 3249 | async function loadSettings() { |
| 3250 | try { |
| 3251 | const [s, backends] = await Promise.all([ |
| @@ -3193,11 +3253,13 @@ | |
| 3253 | api('GET', '/v1/llm/backends').catch(() => []), |
| 3254 | ]); |
| 3255 | _llmBackendNames = (backends || []).map(b => b.name); |
| 3256 | renderTLSStatus(s.tls); |
| 3257 | currentPolicies = s.policies; |
| 3258 | _botCommands = s.bot_commands || {}; |
| 3259 | renderBehaviors(s.policies.behaviors || []); |
| 3260 | renderOnJoinMessages(s.policies.on_join_messages || {}); |
| 3261 | renderAgentPolicy(s.policies.agent_policy || {}); |
| 3262 | renderBridgePolicy(s.policies.bridge || {}); |
| 3263 | renderLoggingPolicy(s.policies.logging || {}); |
| 3264 | loadAdmins(); |
| 3265 | loadAPIKeys(); |
| @@ -3257,10 +3319,14 @@ | |
| 3319 | ` : ''} |
| 3320 | <span class="tag type-observer" style="font-size:11px;min-width:64px;text-align:center">${esc(b.nick)}</span> |
| 3321 | </div> |
| 3322 | </div> |
| 3323 | ${b.enabled && hasSchema(b.id) ? renderBehConfig(b) : ''} |
| 3324 | ${_botCommands[b.id] ? `<div style="padding:6px 16px 8px 42px;border-bottom:1px solid #21262d;background:#0d1117"> |
| 3325 | <span style="font-size:11px;color:#8b949e;font-weight:600">commands:</span> |
| 3326 | ${_botCommands[b.id].map(c => `<code style="font-size:11px;margin-left:8px;background:#161b22;padding:1px 5px;border-radius:3px" title="${esc(c.description)} ${esc(c.usage)}">${esc(c.command)}</code>`).join('')} |
| 3327 | </div>` : ''} |
| 3328 | </div> |
| 3329 | `).join(''); |
| 3330 | } |
| 3331 | |
| 3332 | function onBehaviorToggle(id, enabled) { |
| 3333 |
| --- internal/bots/bridge/bridge.go | ||
| +++ internal/bots/bridge/bridge.go | ||
| @@ -451,10 +451,86 @@ | ||
| 451 | 451 | } |
| 452 | 452 | b.mu.Unlock() |
| 453 | 453 | |
| 454 | 454 | return nicks |
| 455 | 455 | } |
| 456 | + | |
| 457 | +// UserInfo describes a user with their IRC modes. | |
| 458 | +type UserInfo struct { | |
| 459 | + Nick string `json:"nick"` | |
| 460 | + Modes []string `json:"modes,omitempty"` // e.g. ["o", "v", "B"] | |
| 461 | +} | |
| 462 | + | |
| 463 | +// UsersWithModes returns the current user list with mode info for a channel. | |
| 464 | +func (b *Bot) UsersWithModes(channel string) []UserInfo { | |
| 465 | + seen := make(map[string]bool) | |
| 466 | + var users []UserInfo | |
| 467 | + | |
| 468 | + if b.client != nil { | |
| 469 | + if ch := b.client.LookupChannel(channel); ch != nil { | |
| 470 | + for _, u := range ch.Users(b.client) { | |
| 471 | + if u.Nick == b.nick { | |
| 472 | + continue | |
| 473 | + } | |
| 474 | + if seen[u.Nick] { | |
| 475 | + continue | |
| 476 | + } | |
| 477 | + seen[u.Nick] = true | |
| 478 | + var modes []string | |
| 479 | + if u.Perms != nil { | |
| 480 | + if perms, ok := u.Perms.Lookup(channel); ok { | |
| 481 | + if perms.Owner { | |
| 482 | + modes = append(modes, "q") | |
| 483 | + } | |
| 484 | + if perms.Admin { | |
| 485 | + modes = append(modes, "a") | |
| 486 | + } | |
| 487 | + if perms.Op { | |
| 488 | + modes = append(modes, "o") | |
| 489 | + } | |
| 490 | + if perms.HalfOp { | |
| 491 | + modes = append(modes, "h") | |
| 492 | + } | |
| 493 | + if perms.Voice { | |
| 494 | + modes = append(modes, "v") | |
| 495 | + } | |
| 496 | + } | |
| 497 | + } | |
| 498 | + users = append(users, UserInfo{Nick: u.Nick, Modes: modes}) | |
| 499 | + } | |
| 500 | + } | |
| 501 | + } | |
| 502 | + | |
| 503 | + now := time.Now() | |
| 504 | + b.mu.Lock() | |
| 505 | + cutoff := now.Add(-b.webUserTTL) | |
| 506 | + for nick, last := range b.webUsers[channel] { | |
| 507 | + if !last.After(cutoff) { | |
| 508 | + delete(b.webUsers[channel], nick) | |
| 509 | + continue | |
| 510 | + } | |
| 511 | + if !seen[nick] { | |
| 512 | + seen[nick] = true | |
| 513 | + users = append(users, UserInfo{Nick: nick}) | |
| 514 | + } | |
| 515 | + } | |
| 516 | + b.mu.Unlock() | |
| 517 | + | |
| 518 | + return users | |
| 519 | +} | |
| 520 | + | |
| 521 | +// ChannelModes returns the channel mode string (e.g. "+mnt") for a channel. | |
| 522 | +func (b *Bot) ChannelModes(channel string) string { | |
| 523 | + if b.client == nil { | |
| 524 | + return "" | |
| 525 | + } | |
| 526 | + ch := b.client.LookupChannel(channel) | |
| 527 | + if ch == nil { | |
| 528 | + return "" | |
| 529 | + } | |
| 530 | + return ch.Modes.String() | |
| 531 | +} | |
| 456 | 532 | |
| 457 | 533 | // Stats returns a snapshot of bridge activity. |
| 458 | 534 | func (b *Bot) Stats() Stats { |
| 459 | 535 | b.mu.RLock() |
| 460 | 536 | channels := len(b.joined) |
| 461 | 537 |
| --- internal/bots/bridge/bridge.go | |
| +++ internal/bots/bridge/bridge.go | |
| @@ -451,10 +451,86 @@ | |
| 451 | } |
| 452 | b.mu.Unlock() |
| 453 | |
| 454 | return nicks |
| 455 | } |
| 456 | |
| 457 | // Stats returns a snapshot of bridge activity. |
| 458 | func (b *Bot) Stats() Stats { |
| 459 | b.mu.RLock() |
| 460 | channels := len(b.joined) |
| 461 |
| --- internal/bots/bridge/bridge.go | |
| +++ internal/bots/bridge/bridge.go | |
| @@ -451,10 +451,86 @@ | |
| 451 | } |
| 452 | b.mu.Unlock() |
| 453 | |
| 454 | return nicks |
| 455 | } |
| 456 | |
| 457 | // UserInfo describes a user with their IRC modes. |
| 458 | type UserInfo struct { |
| 459 | Nick string `json:"nick"` |
| 460 | Modes []string `json:"modes,omitempty"` // e.g. ["o", "v", "B"] |
| 461 | } |
| 462 | |
| 463 | // UsersWithModes returns the current user list with mode info for a channel. |
| 464 | func (b *Bot) UsersWithModes(channel string) []UserInfo { |
| 465 | seen := make(map[string]bool) |
| 466 | var users []UserInfo |
| 467 | |
| 468 | if b.client != nil { |
| 469 | if ch := b.client.LookupChannel(channel); ch != nil { |
| 470 | for _, u := range ch.Users(b.client) { |
| 471 | if u.Nick == b.nick { |
| 472 | continue |
| 473 | } |
| 474 | if seen[u.Nick] { |
| 475 | continue |
| 476 | } |
| 477 | seen[u.Nick] = true |
| 478 | var modes []string |
| 479 | if u.Perms != nil { |
| 480 | if perms, ok := u.Perms.Lookup(channel); ok { |
| 481 | if perms.Owner { |
| 482 | modes = append(modes, "q") |
| 483 | } |
| 484 | if perms.Admin { |
| 485 | modes = append(modes, "a") |
| 486 | } |
| 487 | if perms.Op { |
| 488 | modes = append(modes, "o") |
| 489 | } |
| 490 | if perms.HalfOp { |
| 491 | modes = append(modes, "h") |
| 492 | } |
| 493 | if perms.Voice { |
| 494 | modes = append(modes, "v") |
| 495 | } |
| 496 | } |
| 497 | } |
| 498 | users = append(users, UserInfo{Nick: u.Nick, Modes: modes}) |
| 499 | } |
| 500 | } |
| 501 | } |
| 502 | |
| 503 | now := time.Now() |
| 504 | b.mu.Lock() |
| 505 | cutoff := now.Add(-b.webUserTTL) |
| 506 | for nick, last := range b.webUsers[channel] { |
| 507 | if !last.After(cutoff) { |
| 508 | delete(b.webUsers[channel], nick) |
| 509 | continue |
| 510 | } |
| 511 | if !seen[nick] { |
| 512 | seen[nick] = true |
| 513 | users = append(users, UserInfo{Nick: nick}) |
| 514 | } |
| 515 | } |
| 516 | b.mu.Unlock() |
| 517 | |
| 518 | return users |
| 519 | } |
| 520 | |
| 521 | // ChannelModes returns the channel mode string (e.g. "+mnt") for a channel. |
| 522 | func (b *Bot) ChannelModes(channel string) string { |
| 523 | if b.client == nil { |
| 524 | return "" |
| 525 | } |
| 526 | ch := b.client.LookupChannel(channel) |
| 527 | if ch == nil { |
| 528 | return "" |
| 529 | } |
| 530 | return ch.Modes.String() |
| 531 | } |
| 532 | |
| 533 | // Stats returns a snapshot of bridge activity. |
| 534 | func (b *Bot) Stats() Stats { |
| 535 | b.mu.RLock() |
| 536 | channels := len(b.joined) |
| 537 |
| --- internal/bots/bridge/bridge.go | ||
| +++ internal/bots/bridge/bridge.go | ||
| @@ -451,10 +451,86 @@ | ||
| 451 | 451 | } |
| 452 | 452 | b.mu.Unlock() |
| 453 | 453 | |
| 454 | 454 | return nicks |
| 455 | 455 | } |
| 456 | + | |
| 457 | +// UserInfo describes a user with their IRC modes. | |
| 458 | +type UserInfo struct { | |
| 459 | + Nick string `json:"nick"` | |
| 460 | + Modes []string `json:"modes,omitempty"` // e.g. ["o", "v", "B"] | |
| 461 | +} | |
| 462 | + | |
| 463 | +// UsersWithModes returns the current user list with mode info for a channel. | |
| 464 | +func (b *Bot) UsersWithModes(channel string) []UserInfo { | |
| 465 | + seen := make(map[string]bool) | |
| 466 | + var users []UserInfo | |
| 467 | + | |
| 468 | + if b.client != nil { | |
| 469 | + if ch := b.client.LookupChannel(channel); ch != nil { | |
| 470 | + for _, u := range ch.Users(b.client) { | |
| 471 | + if u.Nick == b.nick { | |
| 472 | + continue | |
| 473 | + } | |
| 474 | + if seen[u.Nick] { | |
| 475 | + continue | |
| 476 | + } | |
| 477 | + seen[u.Nick] = true | |
| 478 | + var modes []string | |
| 479 | + if u.Perms != nil { | |
| 480 | + if perms, ok := u.Perms.Lookup(channel); ok { | |
| 481 | + if perms.Owner { | |
| 482 | + modes = append(modes, "q") | |
| 483 | + } | |
| 484 | + if perms.Admin { | |
| 485 | + modes = append(modes, "a") | |
| 486 | + } | |
| 487 | + if perms.Op { | |
| 488 | + modes = append(modes, "o") | |
| 489 | + } | |
| 490 | + if perms.HalfOp { | |
| 491 | + modes = append(modes, "h") | |
| 492 | + } | |
| 493 | + if perms.Voice { | |
| 494 | + modes = append(modes, "v") | |
| 495 | + } | |
| 496 | + } | |
| 497 | + } | |
| 498 | + users = append(users, UserInfo{Nick: u.Nick, Modes: modes}) | |
| 499 | + } | |
| 500 | + } | |
| 501 | + } | |
| 502 | + | |
| 503 | + now := time.Now() | |
| 504 | + b.mu.Lock() | |
| 505 | + cutoff := now.Add(-b.webUserTTL) | |
| 506 | + for nick, last := range b.webUsers[channel] { | |
| 507 | + if !last.After(cutoff) { | |
| 508 | + delete(b.webUsers[channel], nick) | |
| 509 | + continue | |
| 510 | + } | |
| 511 | + if !seen[nick] { | |
| 512 | + seen[nick] = true | |
| 513 | + users = append(users, UserInfo{Nick: nick}) | |
| 514 | + } | |
| 515 | + } | |
| 516 | + b.mu.Unlock() | |
| 517 | + | |
| 518 | + return users | |
| 519 | +} | |
| 520 | + | |
| 521 | +// ChannelModes returns the channel mode string (e.g. "+mnt") for a channel. | |
| 522 | +func (b *Bot) ChannelModes(channel string) string { | |
| 523 | + if b.client == nil { | |
| 524 | + return "" | |
| 525 | + } | |
| 526 | + ch := b.client.LookupChannel(channel) | |
| 527 | + if ch == nil { | |
| 528 | + return "" | |
| 529 | + } | |
| 530 | + return ch.Modes.String() | |
| 531 | +} | |
| 456 | 532 | |
| 457 | 533 | // Stats returns a snapshot of bridge activity. |
| 458 | 534 | func (b *Bot) Stats() Stats { |
| 459 | 535 | b.mu.RLock() |
| 460 | 536 | channels := len(b.joined) |
| 461 | 537 |
| --- internal/bots/bridge/bridge.go | |
| +++ internal/bots/bridge/bridge.go | |
| @@ -451,10 +451,86 @@ | |
| 451 | } |
| 452 | b.mu.Unlock() |
| 453 | |
| 454 | return nicks |
| 455 | } |
| 456 | |
| 457 | // Stats returns a snapshot of bridge activity. |
| 458 | func (b *Bot) Stats() Stats { |
| 459 | b.mu.RLock() |
| 460 | channels := len(b.joined) |
| 461 |
| --- internal/bots/bridge/bridge.go | |
| +++ internal/bots/bridge/bridge.go | |
| @@ -451,10 +451,86 @@ | |
| 451 | } |
| 452 | b.mu.Unlock() |
| 453 | |
| 454 | return nicks |
| 455 | } |
| 456 | |
| 457 | // UserInfo describes a user with their IRC modes. |
| 458 | type UserInfo struct { |
| 459 | Nick string `json:"nick"` |
| 460 | Modes []string `json:"modes,omitempty"` // e.g. ["o", "v", "B"] |
| 461 | } |
| 462 | |
| 463 | // UsersWithModes returns the current user list with mode info for a channel. |
| 464 | func (b *Bot) UsersWithModes(channel string) []UserInfo { |
| 465 | seen := make(map[string]bool) |
| 466 | var users []UserInfo |
| 467 | |
| 468 | if b.client != nil { |
| 469 | if ch := b.client.LookupChannel(channel); ch != nil { |
| 470 | for _, u := range ch.Users(b.client) { |
| 471 | if u.Nick == b.nick { |
| 472 | continue |
| 473 | } |
| 474 | if seen[u.Nick] { |
| 475 | continue |
| 476 | } |
| 477 | seen[u.Nick] = true |
| 478 | var modes []string |
| 479 | if u.Perms != nil { |
| 480 | if perms, ok := u.Perms.Lookup(channel); ok { |
| 481 | if perms.Owner { |
| 482 | modes = append(modes, "q") |
| 483 | } |
| 484 | if perms.Admin { |
| 485 | modes = append(modes, "a") |
| 486 | } |
| 487 | if perms.Op { |
| 488 | modes = append(modes, "o") |
| 489 | } |
| 490 | if perms.HalfOp { |
| 491 | modes = append(modes, "h") |
| 492 | } |
| 493 | if perms.Voice { |
| 494 | modes = append(modes, "v") |
| 495 | } |
| 496 | } |
| 497 | } |
| 498 | users = append(users, UserInfo{Nick: u.Nick, Modes: modes}) |
| 499 | } |
| 500 | } |
| 501 | } |
| 502 | |
| 503 | now := time.Now() |
| 504 | b.mu.Lock() |
| 505 | cutoff := now.Add(-b.webUserTTL) |
| 506 | for nick, last := range b.webUsers[channel] { |
| 507 | if !last.After(cutoff) { |
| 508 | delete(b.webUsers[channel], nick) |
| 509 | continue |
| 510 | } |
| 511 | if !seen[nick] { |
| 512 | seen[nick] = true |
| 513 | users = append(users, UserInfo{Nick: nick}) |
| 514 | } |
| 515 | } |
| 516 | b.mu.Unlock() |
| 517 | |
| 518 | return users |
| 519 | } |
| 520 | |
| 521 | // ChannelModes returns the channel mode string (e.g. "+mnt") for a channel. |
| 522 | func (b *Bot) ChannelModes(channel string) string { |
| 523 | if b.client == nil { |
| 524 | return "" |
| 525 | } |
| 526 | ch := b.client.LookupChannel(channel) |
| 527 | if ch == nil { |
| 528 | return "" |
| 529 | } |
| 530 | return ch.Modes.String() |
| 531 | } |
| 532 | |
| 533 | // Stats returns a snapshot of bridge activity. |
| 534 | func (b *Bot) Stats() Stats { |
| 535 | b.mu.RLock() |
| 536 | channels := len(b.joined) |
| 537 |
| --- internal/config/config.go | ||
| +++ internal/config/config.go | ||
| @@ -281,10 +281,14 @@ | ||
| 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 | 283 | |
| 284 | 284 | // Modes is a list of channel modes to set after provisioning (e.g. "+m" for moderated). |
| 285 | 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"` | |
| 286 | 290 | } |
| 287 | 291 | |
| 288 | 292 | // ChannelTypeConfig defines policy rules for a class of dynamically created channels. |
| 289 | 293 | // Matched by prefix against channel names (e.g. prefix "task." matches "#task.gh-42"). |
| 290 | 294 | type ChannelTypeConfig struct { |
| @@ -308,10 +312,13 @@ | ||
| 308 | 312 | Ephemeral bool `yaml:"ephemeral" json:"ephemeral,omitempty"` |
| 309 | 313 | |
| 310 | 314 | // TTL is the maximum lifetime of an ephemeral channel with no non-bot members. |
| 311 | 315 | // Zero means no TTL; cleanup only occurs when the channel is empty. |
| 312 | 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"` | |
| 313 | 320 | } |
| 314 | 321 | |
| 315 | 322 | // Duration wraps time.Duration for YAML/JSON marshalling ("72h", "30m", etc.). |
| 316 | 323 | type Duration struct { |
| 317 | 324 | time.Duration |
| 318 | 325 |
| --- internal/config/config.go | |
| +++ internal/config/config.go | |
| @@ -281,10 +281,14 @@ | |
| 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 | |
| 288 | // ChannelTypeConfig defines policy rules for a class of dynamically created channels. |
| 289 | // Matched by prefix against channel names (e.g. prefix "task." matches "#task.gh-42"). |
| 290 | type ChannelTypeConfig struct { |
| @@ -308,10 +312,13 @@ | |
| 308 | Ephemeral bool `yaml:"ephemeral" json:"ephemeral,omitempty"` |
| 309 | |
| 310 | // TTL is the maximum lifetime of an ephemeral channel with no non-bot members. |
| 311 | // Zero means no TTL; cleanup only occurs when the channel is empty. |
| 312 | TTL Duration `yaml:"ttl" json:"ttl,omitempty"` |
| 313 | } |
| 314 | |
| 315 | // Duration wraps time.Duration for YAML/JSON marshalling ("72h", "30m", etc.). |
| 316 | type Duration struct { |
| 317 | time.Duration |
| 318 |
| --- internal/config/config.go | |
| +++ internal/config/config.go | |
| @@ -281,10 +281,14 @@ | |
| 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 { |
| @@ -308,10 +312,13 @@ | |
| 312 | Ephemeral bool `yaml:"ephemeral" json:"ephemeral,omitempty"` |
| 313 | |
| 314 | // TTL is the maximum lifetime of an ephemeral channel with no non-bot members. |
| 315 | // Zero means no TTL; cleanup only occurs when the channel is empty. |
| 316 | TTL Duration `yaml:"ttl" json:"ttl,omitempty"` |
| 317 | |
| 318 | // OnJoinMessage is sent to agents when they join a channel of this type. |
| 319 | OnJoinMessage string `yaml:"on_join_message" json:"on_join_message,omitempty"` |
| 320 | } |
| 321 | |
| 322 | // Duration wraps time.Duration for YAML/JSON marshalling ("72h", "30m", etc.). |
| 323 | type Duration struct { |
| 324 | time.Duration |
| 325 |
| --- internal/topology/topology.go | ||
| +++ internal/topology/topology.go | ||
| @@ -35,10 +35,13 @@ | ||
| 35 | 35 | // Autojoin is a list of bot nicks to invite after provisioning. |
| 36 | 36 | Autojoin []string |
| 37 | 37 | |
| 38 | 38 | // Modes is a list of channel modes to set (e.g. "+m" for moderated). |
| 39 | 39 | Modes []string |
| 40 | + | |
| 41 | + // OnJoinMessage is sent to agents when they join this channel. | |
| 42 | + OnJoinMessage string | |
| 40 | 43 | } |
| 41 | 44 | |
| 42 | 45 | // channelRecord tracks a provisioned channel for TTL-based reaping. |
| 43 | 46 | type channelRecord struct { |
| 44 | 47 | name string |
| 45 | 48 |
| --- internal/topology/topology.go | |
| +++ internal/topology/topology.go | |
| @@ -35,10 +35,13 @@ | |
| 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 | |
| 42 | // channelRecord tracks a provisioned channel for TTL-based reaping. |
| 43 | type channelRecord struct { |
| 44 | name string |
| 45 |
| --- internal/topology/topology.go | |
| +++ internal/topology/topology.go | |
| @@ -35,10 +35,13 @@ | |
| 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 |
| 48 |