ScuttleBot
feat: web UI — bot command reference, user modes, on-join instructions (#116, #117, #113) #117 — User modes in chat: GET /v1/channels/{channel}/users now returns {nick, modes} objects with IRC modes (+o, +v, etc.) and channel_modes string. UI shows @ prefix for ops, + for voice, modes in tooltip. #116 — Bot command reference: settings response includes bot_commands map with command name, usage, and description per bot. Rendered inline on behavior cards as hoverable command badges. #113 — On-join instructions: add on_join_messages map to policies (channel → message template with {nick}/{channel} variables). Settings tab has a dedicated card for managing per-channel on-join messages. Also added OnJoinMessage field to StaticChannelConfig and ChannelTypeConfig.
5de1db0dd7c6f91f3dede65fed1beceddec11a1d4b3dab8c34ef0b06a481ceda
| --- internal/api/chat.go | ||
| +++ internal/api/chat.go | ||
| @@ -20,10 +20,12 @@ | ||
| 20 | 20 | Send(ctx context.Context, channel, text, senderNick string) error |
| 21 | 21 | SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *bridge.Meta) error |
| 22 | 22 | Stats() bridge.Stats |
| 23 | 23 | TouchUser(channel, nick string) |
| 24 | 24 | Users(channel string) []string |
| 25 | + UsersWithModes(channel string) []bridge.UserInfo | |
| 26 | + ChannelModes(channel string) string | |
| 25 | 27 | } |
| 26 | 28 | |
| 27 | 29 | func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) { |
| 28 | 30 | channel := "#" + r.PathValue("channel") |
| 29 | 31 | s.bridge.JoinChannel(channel) |
| @@ -107,15 +109,16 @@ | ||
| 107 | 109 | w.WriteHeader(http.StatusNoContent) |
| 108 | 110 | } |
| 109 | 111 | |
| 110 | 112 | func (s *Server) handleChannelUsers(w http.ResponseWriter, r *http.Request) { |
| 111 | 113 | channel := "#" + r.PathValue("channel") |
| 112 | - users := s.bridge.Users(channel) | |
| 114 | + users := s.bridge.UsersWithModes(channel) | |
| 113 | 115 | if users == nil { |
| 114 | - users = []string{} | |
| 116 | + users = []bridge.UserInfo{} | |
| 115 | 117 | } |
| 116 | - writeJSON(w, http.StatusOK, map[string]any{"users": users}) | |
| 118 | + modes := s.bridge.ChannelModes(channel) | |
| 119 | + writeJSON(w, http.StatusOK, map[string]any{"users": users, "channel_modes": modes}) | |
| 117 | 120 | } |
| 118 | 121 | |
| 119 | 122 | // handleChannelStream serves an SSE stream of IRC messages for a channel. |
| 120 | 123 | // Auth is via ?token= query param because EventSource doesn't support custom headers. |
| 121 | 124 | func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) { |
| 122 | 125 |
| --- internal/api/chat.go | |
| +++ internal/api/chat.go | |
| @@ -20,10 +20,12 @@ | |
| 20 | Send(ctx context.Context, channel, text, senderNick string) error |
| 21 | SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *bridge.Meta) error |
| 22 | Stats() bridge.Stats |
| 23 | TouchUser(channel, nick string) |
| 24 | Users(channel string) []string |
| 25 | } |
| 26 | |
| 27 | func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) { |
| 28 | channel := "#" + r.PathValue("channel") |
| 29 | s.bridge.JoinChannel(channel) |
| @@ -107,15 +109,16 @@ | |
| 107 | w.WriteHeader(http.StatusNoContent) |
| 108 | } |
| 109 | |
| 110 | func (s *Server) handleChannelUsers(w http.ResponseWriter, r *http.Request) { |
| 111 | channel := "#" + r.PathValue("channel") |
| 112 | users := s.bridge.Users(channel) |
| 113 | if users == nil { |
| 114 | users = []string{} |
| 115 | } |
| 116 | writeJSON(w, http.StatusOK, map[string]any{"users": users}) |
| 117 | } |
| 118 | |
| 119 | // handleChannelStream serves an SSE stream of IRC messages for a channel. |
| 120 | // Auth is via ?token= query param because EventSource doesn't support custom headers. |
| 121 | func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) { |
| 122 |
| --- internal/api/chat.go | |
| +++ internal/api/chat.go | |
| @@ -20,10 +20,12 @@ | |
| 20 | Send(ctx context.Context, channel, text, senderNick string) error |
| 21 | SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *bridge.Meta) error |
| 22 | Stats() bridge.Stats |
| 23 | TouchUser(channel, nick string) |
| 24 | Users(channel string) []string |
| 25 | UsersWithModes(channel string) []bridge.UserInfo |
| 26 | ChannelModes(channel string) string |
| 27 | } |
| 28 | |
| 29 | func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) { |
| 30 | channel := "#" + r.PathValue("channel") |
| 31 | s.bridge.JoinChannel(channel) |
| @@ -107,15 +109,16 @@ | |
| 109 | w.WriteHeader(http.StatusNoContent) |
| 110 | } |
| 111 | |
| 112 | func (s *Server) handleChannelUsers(w http.ResponseWriter, r *http.Request) { |
| 113 | channel := "#" + r.PathValue("channel") |
| 114 | users := s.bridge.UsersWithModes(channel) |
| 115 | if users == nil { |
| 116 | users = []bridge.UserInfo{} |
| 117 | } |
| 118 | modes := s.bridge.ChannelModes(channel) |
| 119 | writeJSON(w, http.StatusOK, map[string]any{"users": users, "channel_modes": modes}) |
| 120 | } |
| 121 | |
| 122 | // handleChannelStream serves an SSE stream of IRC messages for a channel. |
| 123 | // Auth is via ?token= query param because EventSource doesn't support custom headers. |
| 124 | func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) { |
| 125 |
| --- internal/api/chat_test.go | ||
| +++ internal/api/chat_test.go | ||
| @@ -30,12 +30,14 @@ | ||
| 30 | 30 | } |
| 31 | 31 | func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil } |
| 32 | 32 | func (b *stubChatBridge) SendWithMeta(_ context.Context, _, _, _ string, _ *bridge.Meta) error { |
| 33 | 33 | return nil |
| 34 | 34 | } |
| 35 | -func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} } | |
| 36 | -func (b *stubChatBridge) Users(string) []string { return nil } | |
| 35 | +func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} } | |
| 36 | +func (b *stubChatBridge) Users(string) []string { return nil } | |
| 37 | +func (b *stubChatBridge) UsersWithModes(string) []bridge.UserInfo { return nil } | |
| 38 | +func (b *stubChatBridge) ChannelModes(string) string { return "" } | |
| 37 | 39 | func (b *stubChatBridge) TouchUser(channel, nick string) { |
| 38 | 40 | b.touched = append(b.touched, struct{ channel, nick string }{channel: channel, nick: nick}) |
| 39 | 41 | } |
| 40 | 42 | |
| 41 | 43 | func TestHandleChannelPresence(t *testing.T) { |
| 42 | 44 |
| --- internal/api/chat_test.go | |
| +++ internal/api/chat_test.go | |
| @@ -30,12 +30,14 @@ | |
| 30 | } |
| 31 | func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil } |
| 32 | func (b *stubChatBridge) SendWithMeta(_ context.Context, _, _, _ string, _ *bridge.Meta) error { |
| 33 | return nil |
| 34 | } |
| 35 | func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} } |
| 36 | func (b *stubChatBridge) Users(string) []string { return nil } |
| 37 | func (b *stubChatBridge) TouchUser(channel, nick string) { |
| 38 | b.touched = append(b.touched, struct{ channel, nick string }{channel: channel, nick: nick}) |
| 39 | } |
| 40 | |
| 41 | func TestHandleChannelPresence(t *testing.T) { |
| 42 |
| --- internal/api/chat_test.go | |
| +++ internal/api/chat_test.go | |
| @@ -30,12 +30,14 @@ | |
| 30 | } |
| 31 | func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil } |
| 32 | func (b *stubChatBridge) SendWithMeta(_ context.Context, _, _, _ string, _ *bridge.Meta) error { |
| 33 | return nil |
| 34 | } |
| 35 | func (b *stubChatBridge) Stats() bridge.Stats { return bridge.Stats{} } |
| 36 | func (b *stubChatBridge) Users(string) []string { return nil } |
| 37 | func (b *stubChatBridge) UsersWithModes(string) []bridge.UserInfo { return nil } |
| 38 | func (b *stubChatBridge) ChannelModes(string) string { return "" } |
| 39 | func (b *stubChatBridge) TouchUser(channel, nick string) { |
| 40 | b.touched = append(b.touched, struct{ channel, nick string }{channel: channel, nick: nick}) |
| 41 | } |
| 42 | |
| 43 | func TestHandleChannelPresence(t *testing.T) { |
| 44 |
| --- internal/api/policies.go | ||
| +++ internal/api/policies.go | ||
| @@ -71,15 +71,16 @@ | ||
| 71 | 71 | Default bool `json:"default,omitempty"` |
| 72 | 72 | } |
| 73 | 73 | |
| 74 | 74 | // Policies is the full mutable settings blob, persisted to policies.json. |
| 75 | 75 | type Policies struct { |
| 76 | - Behaviors []BehaviorConfig `json:"behaviors"` | |
| 77 | - AgentPolicy AgentPolicy `json:"agent_policy"` | |
| 78 | - Bridge BridgePolicy `json:"bridge"` | |
| 79 | - Logging LoggingPolicy `json:"logging"` | |
| 80 | - LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"` | |
| 76 | + Behaviors []BehaviorConfig `json:"behaviors"` | |
| 77 | + AgentPolicy AgentPolicy `json:"agent_policy"` | |
| 78 | + Bridge BridgePolicy `json:"bridge"` | |
| 79 | + Logging LoggingPolicy `json:"logging"` | |
| 80 | + LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"` | |
| 81 | + OnJoinMessages map[string]string `json:"on_join_messages,omitempty"` // channel → message template | |
| 81 | 82 | } |
| 82 | 83 | |
| 83 | 84 | // defaultBehaviors lists every built-in bot with conservative defaults (disabled). |
| 84 | 85 | var defaultBehaviors = []BehaviorConfig{ |
| 85 | 86 | { |
| @@ -151,10 +152,42 @@ | ||
| 151 | 152 | Description: "Acts on sentinel incident reports — issues warnings, mutes, or kicks based on severity. Operators can also issue direct commands via DM.", |
| 152 | 153 | Nick: "steward", |
| 153 | 154 | JoinAllChannels: true, |
| 154 | 155 | }, |
| 155 | 156 | } |
| 157 | + | |
| 158 | +// BotCommand describes a single command a bot responds to. | |
| 159 | +type BotCommand struct { | |
| 160 | + Command string `json:"command"` | |
| 161 | + Usage string `json:"usage"` | |
| 162 | + Description string `json:"description"` | |
| 163 | +} | |
| 164 | + | |
| 165 | +// botCommands maps bot ID to its available commands. | |
| 166 | +var botCommands = map[string][]BotCommand{ | |
| 167 | + "oracle": { | |
| 168 | + {Command: "summarize", Usage: "summarize #channel [last=N] [format=toon|json]", Description: "Summarize recent channel activity using an LLM."}, | |
| 169 | + }, | |
| 170 | + "scroll": { | |
| 171 | + {Command: "replay", Usage: "replay #channel [last=N] [since=<unix_ms>]", Description: "Replay recent channel history via DM."}, | |
| 172 | + }, | |
| 173 | + "steward": { | |
| 174 | + {Command: "mute", Usage: "mute <nick> [duration]", Description: "Mute a nick in the current channel."}, | |
| 175 | + {Command: "unmute", Usage: "unmute <nick>", Description: "Remove mute from a nick."}, | |
| 176 | + {Command: "kick", Usage: "kick <nick> [reason]", Description: "Kick a nick from the current channel."}, | |
| 177 | + {Command: "warn", Usage: "warn <nick> <message>", Description: "Send a warning notice to a nick."}, | |
| 178 | + }, | |
| 179 | + "warden": { | |
| 180 | + {Command: "status", Usage: "status", Description: "Show warden rate-limit status for all tracked nicks."}, | |
| 181 | + }, | |
| 182 | + "snitch": { | |
| 183 | + {Command: "status", Usage: "status", Description: "Show snitch monitoring status and alert history."}, | |
| 184 | + }, | |
| 185 | + "herald": { | |
| 186 | + {Command: "announce", Usage: "announce #channel <message>", Description: "Post an announcement to a channel."}, | |
| 187 | + }, | |
| 188 | +} | |
| 156 | 189 | |
| 157 | 190 | // PolicyStore persists Policies to a JSON file or database. |
| 158 | 191 | type PolicyStore struct { |
| 159 | 192 | mu sync.RWMutex |
| 160 | 193 | path string |
| 161 | 194 |
| --- internal/api/policies.go | |
| +++ internal/api/policies.go | |
| @@ -71,15 +71,16 @@ | |
| 71 | Default bool `json:"default,omitempty"` |
| 72 | } |
| 73 | |
| 74 | // Policies is the full mutable settings blob, persisted to policies.json. |
| 75 | type Policies struct { |
| 76 | Behaviors []BehaviorConfig `json:"behaviors"` |
| 77 | AgentPolicy AgentPolicy `json:"agent_policy"` |
| 78 | Bridge BridgePolicy `json:"bridge"` |
| 79 | Logging LoggingPolicy `json:"logging"` |
| 80 | LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"` |
| 81 | } |
| 82 | |
| 83 | // defaultBehaviors lists every built-in bot with conservative defaults (disabled). |
| 84 | var defaultBehaviors = []BehaviorConfig{ |
| 85 | { |
| @@ -151,10 +152,42 @@ | |
| 151 | Description: "Acts on sentinel incident reports — issues warnings, mutes, or kicks based on severity. Operators can also issue direct commands via DM.", |
| 152 | Nick: "steward", |
| 153 | JoinAllChannels: true, |
| 154 | }, |
| 155 | } |
| 156 | |
| 157 | // PolicyStore persists Policies to a JSON file or database. |
| 158 | type PolicyStore struct { |
| 159 | mu sync.RWMutex |
| 160 | path string |
| 161 |
| --- internal/api/policies.go | |
| +++ internal/api/policies.go | |
| @@ -71,15 +71,16 @@ | |
| 71 | Default bool `json:"default,omitempty"` |
| 72 | } |
| 73 | |
| 74 | // Policies is the full mutable settings blob, persisted to policies.json. |
| 75 | type Policies struct { |
| 76 | Behaviors []BehaviorConfig `json:"behaviors"` |
| 77 | AgentPolicy AgentPolicy `json:"agent_policy"` |
| 78 | Bridge BridgePolicy `json:"bridge"` |
| 79 | Logging LoggingPolicy `json:"logging"` |
| 80 | LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"` |
| 81 | OnJoinMessages map[string]string `json:"on_join_messages,omitempty"` // channel → message template |
| 82 | } |
| 83 | |
| 84 | // defaultBehaviors lists every built-in bot with conservative defaults (disabled). |
| 85 | var defaultBehaviors = []BehaviorConfig{ |
| 86 | { |
| @@ -151,10 +152,42 @@ | |
| 152 | Description: "Acts on sentinel incident reports — issues warnings, mutes, or kicks based on severity. Operators can also issue direct commands via DM.", |
| 153 | Nick: "steward", |
| 154 | JoinAllChannels: true, |
| 155 | }, |
| 156 | } |
| 157 | |
| 158 | // BotCommand describes a single command a bot responds to. |
| 159 | type BotCommand struct { |
| 160 | Command string `json:"command"` |
| 161 | Usage string `json:"usage"` |
| 162 | Description string `json:"description"` |
| 163 | } |
| 164 | |
| 165 | // botCommands maps bot ID to its available commands. |
| 166 | var botCommands = map[string][]BotCommand{ |
| 167 | "oracle": { |
| 168 | {Command: "summarize", Usage: "summarize #channel [last=N] [format=toon|json]", Description: "Summarize recent channel activity using an LLM."}, |
| 169 | }, |
| 170 | "scroll": { |
| 171 | {Command: "replay", Usage: "replay #channel [last=N] [since=<unix_ms>]", Description: "Replay recent channel history via DM."}, |
| 172 | }, |
| 173 | "steward": { |
| 174 | {Command: "mute", Usage: "mute <nick> [duration]", Description: "Mute a nick in the current channel."}, |
| 175 | {Command: "unmute", Usage: "unmute <nick>", Description: "Remove mute from a nick."}, |
| 176 | {Command: "kick", Usage: "kick <nick> [reason]", Description: "Kick a nick from the current channel."}, |
| 177 | {Command: "warn", Usage: "warn <nick> <message>", Description: "Send a warning notice to a nick."}, |
| 178 | }, |
| 179 | "warden": { |
| 180 | {Command: "status", Usage: "status", Description: "Show warden rate-limit status for all tracked nicks."}, |
| 181 | }, |
| 182 | "snitch": { |
| 183 | {Command: "status", Usage: "status", Description: "Show snitch monitoring status and alert history."}, |
| 184 | }, |
| 185 | "herald": { |
| 186 | {Command: "announce", Usage: "announce #channel <message>", Description: "Post an announcement to a channel."}, |
| 187 | }, |
| 188 | } |
| 189 | |
| 190 | // PolicyStore persists Policies to a JSON file or database. |
| 191 | type PolicyStore struct { |
| 192 | mu sync.RWMutex |
| 193 | path string |
| 194 |
| --- 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 |
| --- internal/api/ui/index.html | ||
| +++ internal/api/ui/index.html | ||
| @@ -504,11 +504,11 @@ | ||
| 504 | 504 | <div class="chan-list" id="chan-list"></div> |
| 505 | 505 | </div> |
| 506 | 506 | <div class="sidebar-resize" id="resize-left" title="drag to resize"></div> |
| 507 | 507 | <div class="chat-main"> |
| 508 | 508 | <div class="chat-topbar"> |
| 509 | - <span class="chat-ch-name" id="chat-ch-name">select a channel</span> | |
| 509 | + <span class="chat-ch-name" id="chat-ch-name">select a channel</span><span id="chat-channel-modes" style="color:#8b949e;font-size:11px;margin-left:6px"></span> | |
| 510 | 510 | <div class="spacer"></div> |
| 511 | 511 | <span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span> |
| 512 | 512 | <select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()"> |
| 513 | 513 | <option value="">— pick a user —</option> |
| 514 | 514 | </select> |
| @@ -605,10 +605,28 @@ | ||
| 605 | 605 | </div> |
| 606 | 606 | <div class="card-body" style="padding:0"> |
| 607 | 607 | <div id="behaviors-list"></div> |
| 608 | 608 | </div> |
| 609 | 609 | </div> |
| 610 | + | |
| 611 | + <!-- on-join instructions --> | |
| 612 | + <div class="card" id="card-onjoin"> | |
| 613 | + <div class="card-header" onclick="toggleCard('card-onjoin',event)"> | |
| 614 | + <h2>on-join instructions</h2><span class="card-desc">messages sent to agents when they join a channel</span><span class="collapse-icon">▾</span> | |
| 615 | + <div class="spacer"></div> | |
| 616 | + <button class="sm primary" onclick="event.stopPropagation();savePolicies()">save</button> | |
| 617 | + </div> | |
| 618 | + <div class="card-body"> | |
| 619 | + <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> | |
| 620 | + <div id="onjoin-list"></div> | |
| 621 | + <div style="display:flex;gap:8px;margin-top:12px;align-items:flex-end"> | |
| 622 | + <div style="flex:0 0 160px"><label>channel</label><input type="text" id="onjoin-new-channel" placeholder="#channel" style="width:100%"></div> | |
| 623 | + <div style="flex:1"><label>message</label><input type="text" id="onjoin-new-message" placeholder="Welcome to {channel}, {nick}!" style="width:100%"></div> | |
| 624 | + <button class="sm primary" onclick="addOnJoinMessage()">add</button> | |
| 625 | + </div> | |
| 626 | + </div> | |
| 627 | + </div> | |
| 610 | 628 | |
| 611 | 629 | <!-- agent policy --> |
| 612 | 630 | <div class="card" id="card-agentpolicy"> |
| 613 | 631 | <div class="card-header" onclick="toggleCard('card-agentpolicy',event)"><h2>agent policy</h2><span class="card-desc">autojoin and check-in rules for all agents</span><span class="collapse-icon">▾</span><div class="spacer"></div><button class="sm primary" onclick="event.stopPropagation();saveAgentPolicy()">save</button></div> |
| 614 | 632 | <div class="card-body"> |
| @@ -1838,11 +1856,11 @@ | ||
| 1838 | 1856 | async function loadNicklist(ch) { |
| 1839 | 1857 | if (!ch) return; |
| 1840 | 1858 | try { |
| 1841 | 1859 | const slug = ch.replace(/^#/,''); |
| 1842 | 1860 | const data = await api('GET', `/v1/channels/${slug}/users`); |
| 1843 | - renderNicklist(data.users || []); | |
| 1861 | + renderNicklist(data.users || [], data.channel_modes || ''); | |
| 1844 | 1862 | } catch(e) {} |
| 1845 | 1863 | } |
| 1846 | 1864 | const SYSTEM_BOTS = new Set(['bridge','oracle','sentinel','steward','scribe','warden','snitch','herald','scroll','systembot','auditbot']); |
| 1847 | 1865 | const AGENT_PREFIXES = ['claude-','codex-','gemini-','openclaw-']; |
| 1848 | 1866 | |
| @@ -1861,24 +1879,35 @@ | ||
| 1861 | 1879 | if (tier === 0) return '@'; |
| 1862 | 1880 | if (tier === 1) return '+'; |
| 1863 | 1881 | return ''; |
| 1864 | 1882 | } |
| 1865 | 1883 | |
| 1866 | -function renderNicklist(users) { | |
| 1884 | +function renderNicklist(users, channelModes) { | |
| 1867 | 1885 | const el = document.getElementById('nicklist-users'); |
| 1886 | + // users may be [{nick, modes}] or ["nick"] for backwards compat. | |
| 1887 | + const normalized = users.map(u => typeof u === 'string' ? {nick: u, modes: []} : u); | |
| 1868 | 1888 | // Sort: ops > system bots > agents > users, alpha within each tier. |
| 1869 | - const sorted = users.slice().sort((a, b) => { | |
| 1870 | - const ta = nickTier(a), tb = nickTier(b); | |
| 1889 | + const sorted = normalized.slice().sort((a, b) => { | |
| 1890 | + const ta = nickTier(a.nick), tb = nickTier(b.nick); | |
| 1871 | 1891 | if (ta !== tb) return ta - tb; |
| 1872 | - return a.localeCompare(b); | |
| 1892 | + return a.nick.localeCompare(b.nick); | |
| 1873 | 1893 | }); |
| 1874 | - el.innerHTML = sorted.map(nick => { | |
| 1875 | - const tier = nickTier(nick); | |
| 1876 | - const prefix = nickPrefix(nick); | |
| 1877 | - const cls = tier === 1 ? ' is-bot' : tier === 0 ? ' is-op' : ''; | |
| 1878 | - return `<div class="nicklist-nick${cls}" title="${esc(nick)}">${prefix}${esc(nick)}</div>`; | |
| 1894 | + el.innerHTML = sorted.map(u => { | |
| 1895 | + const modes = u.modes || []; | |
| 1896 | + // IRC mode prefix: @ for op, + for voice | |
| 1897 | + let prefix = ''; | |
| 1898 | + if (modes.includes('o') || modes.includes('a') || modes.includes('q')) prefix = '@'; | |
| 1899 | + else if (modes.includes('v')) prefix = '+'; | |
| 1900 | + else prefix = nickPrefix(u.nick); | |
| 1901 | + const tier = nickTier(u.nick); | |
| 1902 | + const cls = (modes.includes('o') || tier === 0) ? ' is-op' : tier === 1 ? ' is-bot' : ''; | |
| 1903 | + const modeStr = modes.length ? ` [+${modes.join('')}]` : ''; | |
| 1904 | + return `<div class="nicklist-nick${cls}" title="${esc(u.nick)}${modeStr}">${prefix}${esc(u.nick)}</div>`; | |
| 1879 | 1905 | }).join(''); |
| 1906 | + // Show channel modes in header if available. | |
| 1907 | + const modesEl = document.getElementById('chat-channel-modes'); | |
| 1908 | + if (modesEl) modesEl.textContent = channelModes ? ` ${channelModes}` : ''; | |
| 1880 | 1909 | } |
| 1881 | 1910 | // Nick colors — deterministic hash over a palette |
| 1882 | 1911 | const NICK_PALETTE = ['#58a6ff','#3fb950','#ffa657','#d2a8ff','#56d364','#79c0ff','#ff7b72','#a5d6ff','#f0883e','#39d353']; |
| 1883 | 1912 | function nickColor(nick) { |
| 1884 | 1913 | let h = 0; |
| @@ -2899,10 +2928,41 @@ | ||
| 2899 | 2928 | if (body) body.style.display = ''; |
| 2900 | 2929 | } |
| 2901 | 2930 | |
| 2902 | 2931 | // --- settings / policies --- |
| 2903 | 2932 | let currentPolicies = null; |
| 2933 | +let _botCommands = {}; | |
| 2934 | + | |
| 2935 | +function renderOnJoinMessages(msgs) { | |
| 2936 | + const el = document.getElementById('onjoin-list'); | |
| 2937 | + if (!msgs || !Object.keys(msgs).length) { el.innerHTML = '<div style="color:#8b949e;font-size:12px">No on-join instructions configured.</div>'; return; } | |
| 2938 | + el.innerHTML = Object.entries(msgs).sort().map(([ch, msg]) => ` | |
| 2939 | + <div style="display:flex;gap:8px;align-items:center;padding:6px 0;border-bottom:1px solid #21262d"> | |
| 2940 | + <code style="font-size:12px;min-width:120px">${esc(ch)}</code> | |
| 2941 | + <input type="text" value="${esc(msg)}" style="flex:1;font-size:12px" onchange="updateOnJoinMessage('${esc(ch)}',this.value)"> | |
| 2942 | + <button class="sm danger" onclick="removeOnJoinMessage('${esc(ch)}')">remove</button> | |
| 2943 | + </div> | |
| 2944 | + `).join(''); | |
| 2945 | +} | |
| 2946 | +function addOnJoinMessage() { | |
| 2947 | + const ch = document.getElementById('onjoin-new-channel').value.trim(); | |
| 2948 | + const msg = document.getElementById('onjoin-new-message').value.trim(); | |
| 2949 | + if (!ch || !msg) return; | |
| 2950 | + if (!currentPolicies.on_join_messages) currentPolicies.on_join_messages = {}; | |
| 2951 | + currentPolicies.on_join_messages[ch] = msg; | |
| 2952 | + document.getElementById('onjoin-new-channel').value = ''; | |
| 2953 | + document.getElementById('onjoin-new-message').value = ''; | |
| 2954 | + renderOnJoinMessages(currentPolicies.on_join_messages); | |
| 2955 | +} | |
| 2956 | +function updateOnJoinMessage(ch, msg) { | |
| 2957 | + if (!currentPolicies.on_join_messages) currentPolicies.on_join_messages = {}; | |
| 2958 | + currentPolicies.on_join_messages[ch] = msg; | |
| 2959 | +} | |
| 2960 | +function removeOnJoinMessage(ch) { | |
| 2961 | + if (currentPolicies.on_join_messages) delete currentPolicies.on_join_messages[ch]; | |
| 2962 | + renderOnJoinMessages(currentPolicies.on_join_messages); | |
| 2963 | +} | |
| 2904 | 2964 | let _llmBackendNames = []; // cached backend names for oracle dropdown |
| 2905 | 2965 | |
| 2906 | 2966 | async function loadSettings() { |
| 2907 | 2967 | try { |
| 2908 | 2968 | const [s, backends] = await Promise.all([ |
| @@ -2910,11 +2970,13 @@ | ||
| 2910 | 2970 | api('GET', '/v1/llm/backends').catch(() => []), |
| 2911 | 2971 | ]); |
| 2912 | 2972 | _llmBackendNames = (backends || []).map(b => b.name); |
| 2913 | 2973 | renderTLSStatus(s.tls); |
| 2914 | 2974 | currentPolicies = s.policies; |
| 2975 | + _botCommands = s.bot_commands || {}; | |
| 2915 | 2976 | renderBehaviors(s.policies.behaviors || []); |
| 2977 | + renderOnJoinMessages(s.policies.on_join_messages || {}); | |
| 2916 | 2978 | renderAgentPolicy(s.policies.agent_policy || {}); |
| 2917 | 2979 | renderBridgePolicy(s.policies.bridge || {}); |
| 2918 | 2980 | renderLoggingPolicy(s.policies.logging || {}); |
| 2919 | 2981 | loadAdmins(); |
| 2920 | 2982 | loadConfigCards(); |
| @@ -2973,10 +3035,14 @@ | ||
| 2973 | 3035 | ` : ''} |
| 2974 | 3036 | <span class="tag type-observer" style="font-size:11px;min-width:64px;text-align:center">${esc(b.nick)}</span> |
| 2975 | 3037 | </div> |
| 2976 | 3038 | </div> |
| 2977 | 3039 | ${b.enabled && hasSchema(b.id) ? renderBehConfig(b) : ''} |
| 3040 | + ${_botCommands[b.id] ? `<div style="padding:6px 16px 8px 42px;border-bottom:1px solid #21262d;background:#0d1117"> | |
| 3041 | + <span style="font-size:11px;color:#8b949e;font-weight:600">commands:</span> | |
| 3042 | + ${_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('')} | |
| 3043 | + </div>` : ''} | |
| 2978 | 3044 | </div> |
| 2979 | 3045 | `).join(''); |
| 2980 | 3046 | } |
| 2981 | 3047 | |
| 2982 | 3048 | function onBehaviorToggle(id, enabled) { |
| 2983 | 3049 |
| --- internal/api/ui/index.html | |
| +++ internal/api/ui/index.html | |
| @@ -504,11 +504,11 @@ | |
| 504 | <div class="chan-list" id="chan-list"></div> |
| 505 | </div> |
| 506 | <div class="sidebar-resize" id="resize-left" title="drag to resize"></div> |
| 507 | <div class="chat-main"> |
| 508 | <div class="chat-topbar"> |
| 509 | <span class="chat-ch-name" id="chat-ch-name">select a channel</span> |
| 510 | <div class="spacer"></div> |
| 511 | <span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span> |
| 512 | <select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()"> |
| 513 | <option value="">— pick a user —</option> |
| 514 | </select> |
| @@ -605,10 +605,28 @@ | |
| 605 | </div> |
| 606 | <div class="card-body" style="padding:0"> |
| 607 | <div id="behaviors-list"></div> |
| 608 | </div> |
| 609 | </div> |
| 610 | |
| 611 | <!-- agent policy --> |
| 612 | <div class="card" id="card-agentpolicy"> |
| 613 | <div class="card-header" onclick="toggleCard('card-agentpolicy',event)"><h2>agent policy</h2><span class="card-desc">autojoin and check-in rules for all agents</span><span class="collapse-icon">▾</span><div class="spacer"></div><button class="sm primary" onclick="event.stopPropagation();saveAgentPolicy()">save</button></div> |
| 614 | <div class="card-body"> |
| @@ -1838,11 +1856,11 @@ | |
| 1838 | async function loadNicklist(ch) { |
| 1839 | if (!ch) return; |
| 1840 | try { |
| 1841 | const slug = ch.replace(/^#/,''); |
| 1842 | const data = await api('GET', `/v1/channels/${slug}/users`); |
| 1843 | renderNicklist(data.users || []); |
| 1844 | } catch(e) {} |
| 1845 | } |
| 1846 | const SYSTEM_BOTS = new Set(['bridge','oracle','sentinel','steward','scribe','warden','snitch','herald','scroll','systembot','auditbot']); |
| 1847 | const AGENT_PREFIXES = ['claude-','codex-','gemini-','openclaw-']; |
| 1848 | |
| @@ -1861,24 +1879,35 @@ | |
| 1861 | if (tier === 0) return '@'; |
| 1862 | if (tier === 1) return '+'; |
| 1863 | return ''; |
| 1864 | } |
| 1865 | |
| 1866 | function renderNicklist(users) { |
| 1867 | const el = document.getElementById('nicklist-users'); |
| 1868 | // Sort: ops > system bots > agents > users, alpha within each tier. |
| 1869 | const sorted = users.slice().sort((a, b) => { |
| 1870 | const ta = nickTier(a), tb = nickTier(b); |
| 1871 | if (ta !== tb) return ta - tb; |
| 1872 | return a.localeCompare(b); |
| 1873 | }); |
| 1874 | el.innerHTML = sorted.map(nick => { |
| 1875 | const tier = nickTier(nick); |
| 1876 | const prefix = nickPrefix(nick); |
| 1877 | const cls = tier === 1 ? ' is-bot' : tier === 0 ? ' is-op' : ''; |
| 1878 | return `<div class="nicklist-nick${cls}" title="${esc(nick)}">${prefix}${esc(nick)}</div>`; |
| 1879 | }).join(''); |
| 1880 | } |
| 1881 | // Nick colors — deterministic hash over a palette |
| 1882 | const NICK_PALETTE = ['#58a6ff','#3fb950','#ffa657','#d2a8ff','#56d364','#79c0ff','#ff7b72','#a5d6ff','#f0883e','#39d353']; |
| 1883 | function nickColor(nick) { |
| 1884 | let h = 0; |
| @@ -2899,10 +2928,41 @@ | |
| 2899 | if (body) body.style.display = ''; |
| 2900 | } |
| 2901 | |
| 2902 | // --- settings / policies --- |
| 2903 | let currentPolicies = null; |
| 2904 | let _llmBackendNames = []; // cached backend names for oracle dropdown |
| 2905 | |
| 2906 | async function loadSettings() { |
| 2907 | try { |
| 2908 | const [s, backends] = await Promise.all([ |
| @@ -2910,11 +2970,13 @@ | |
| 2910 | api('GET', '/v1/llm/backends').catch(() => []), |
| 2911 | ]); |
| 2912 | _llmBackendNames = (backends || []).map(b => b.name); |
| 2913 | renderTLSStatus(s.tls); |
| 2914 | currentPolicies = s.policies; |
| 2915 | renderBehaviors(s.policies.behaviors || []); |
| 2916 | renderAgentPolicy(s.policies.agent_policy || {}); |
| 2917 | renderBridgePolicy(s.policies.bridge || {}); |
| 2918 | renderLoggingPolicy(s.policies.logging || {}); |
| 2919 | loadAdmins(); |
| 2920 | loadConfigCards(); |
| @@ -2973,10 +3035,14 @@ | |
| 2973 | ` : ''} |
| 2974 | <span class="tag type-observer" style="font-size:11px;min-width:64px;text-align:center">${esc(b.nick)}</span> |
| 2975 | </div> |
| 2976 | </div> |
| 2977 | ${b.enabled && hasSchema(b.id) ? renderBehConfig(b) : ''} |
| 2978 | </div> |
| 2979 | `).join(''); |
| 2980 | } |
| 2981 | |
| 2982 | function onBehaviorToggle(id, enabled) { |
| 2983 |
| --- internal/api/ui/index.html | |
| +++ internal/api/ui/index.html | |
| @@ -504,11 +504,11 @@ | |
| 504 | <div class="chan-list" id="chan-list"></div> |
| 505 | </div> |
| 506 | <div class="sidebar-resize" id="resize-left" title="drag to resize"></div> |
| 507 | <div class="chat-main"> |
| 508 | <div class="chat-topbar"> |
| 509 | <span class="chat-ch-name" id="chat-ch-name">select a channel</span><span id="chat-channel-modes" style="color:#8b949e;font-size:11px;margin-left:6px"></span> |
| 510 | <div class="spacer"></div> |
| 511 | <span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span> |
| 512 | <select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()"> |
| 513 | <option value="">— pick a user —</option> |
| 514 | </select> |
| @@ -605,10 +605,28 @@ | |
| 605 | </div> |
| 606 | <div class="card-body" style="padding:0"> |
| 607 | <div id="behaviors-list"></div> |
| 608 | </div> |
| 609 | </div> |
| 610 | |
| 611 | <!-- on-join instructions --> |
| 612 | <div class="card" id="card-onjoin"> |
| 613 | <div class="card-header" onclick="toggleCard('card-onjoin',event)"> |
| 614 | <h2>on-join instructions</h2><span class="card-desc">messages sent to agents when they join a channel</span><span class="collapse-icon">▾</span> |
| 615 | <div class="spacer"></div> |
| 616 | <button class="sm primary" onclick="event.stopPropagation();savePolicies()">save</button> |
| 617 | </div> |
| 618 | <div class="card-body"> |
| 619 | <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> |
| 620 | <div id="onjoin-list"></div> |
| 621 | <div style="display:flex;gap:8px;margin-top:12px;align-items:flex-end"> |
| 622 | <div style="flex:0 0 160px"><label>channel</label><input type="text" id="onjoin-new-channel" placeholder="#channel" style="width:100%"></div> |
| 623 | <div style="flex:1"><label>message</label><input type="text" id="onjoin-new-message" placeholder="Welcome to {channel}, {nick}!" style="width:100%"></div> |
| 624 | <button class="sm primary" onclick="addOnJoinMessage()">add</button> |
| 625 | </div> |
| 626 | </div> |
| 627 | </div> |
| 628 | |
| 629 | <!-- agent policy --> |
| 630 | <div class="card" id="card-agentpolicy"> |
| 631 | <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> |
| 632 | <div class="card-body"> |
| @@ -1838,11 +1856,11 @@ | |
| 1856 | async function loadNicklist(ch) { |
| 1857 | if (!ch) return; |
| 1858 | try { |
| 1859 | const slug = ch.replace(/^#/,''); |
| 1860 | const data = await api('GET', `/v1/channels/${slug}/users`); |
| 1861 | renderNicklist(data.users || [], data.channel_modes || ''); |
| 1862 | } catch(e) {} |
| 1863 | } |
| 1864 | const SYSTEM_BOTS = new Set(['bridge','oracle','sentinel','steward','scribe','warden','snitch','herald','scroll','systembot','auditbot']); |
| 1865 | const AGENT_PREFIXES = ['claude-','codex-','gemini-','openclaw-']; |
| 1866 | |
| @@ -1861,24 +1879,35 @@ | |
| 1879 | if (tier === 0) return '@'; |
| 1880 | if (tier === 1) return '+'; |
| 1881 | return ''; |
| 1882 | } |
| 1883 | |
| 1884 | function renderNicklist(users, channelModes) { |
| 1885 | const el = document.getElementById('nicklist-users'); |
| 1886 | // users may be [{nick, modes}] or ["nick"] for backwards compat. |
| 1887 | const normalized = users.map(u => typeof u === 'string' ? {nick: u, modes: []} : u); |
| 1888 | // Sort: ops > system bots > agents > users, alpha within each tier. |
| 1889 | const sorted = normalized.slice().sort((a, b) => { |
| 1890 | const ta = nickTier(a.nick), tb = nickTier(b.nick); |
| 1891 | if (ta !== tb) return ta - tb; |
| 1892 | return a.nick.localeCompare(b.nick); |
| 1893 | }); |
| 1894 | el.innerHTML = sorted.map(u => { |
| 1895 | const modes = u.modes || []; |
| 1896 | // IRC mode prefix: @ for op, + for voice |
| 1897 | let prefix = ''; |
| 1898 | if (modes.includes('o') || modes.includes('a') || modes.includes('q')) prefix = '@'; |
| 1899 | else if (modes.includes('v')) prefix = '+'; |
| 1900 | else prefix = nickPrefix(u.nick); |
| 1901 | const tier = nickTier(u.nick); |
| 1902 | const cls = (modes.includes('o') || tier === 0) ? ' is-op' : tier === 1 ? ' is-bot' : ''; |
| 1903 | const modeStr = modes.length ? ` [+${modes.join('')}]` : ''; |
| 1904 | return `<div class="nicklist-nick${cls}" title="${esc(u.nick)}${modeStr}">${prefix}${esc(u.nick)}</div>`; |
| 1905 | }).join(''); |
| 1906 | // Show channel modes in header if available. |
| 1907 | const modesEl = document.getElementById('chat-channel-modes'); |
| 1908 | if (modesEl) modesEl.textContent = channelModes ? ` ${channelModes}` : ''; |
| 1909 | } |
| 1910 | // Nick colors — deterministic hash over a palette |
| 1911 | const NICK_PALETTE = ['#58a6ff','#3fb950','#ffa657','#d2a8ff','#56d364','#79c0ff','#ff7b72','#a5d6ff','#f0883e','#39d353']; |
| 1912 | function nickColor(nick) { |
| 1913 | let h = 0; |
| @@ -2899,10 +2928,41 @@ | |
| 2928 | if (body) body.style.display = ''; |
| 2929 | } |
| 2930 | |
| 2931 | // --- settings / policies --- |
| 2932 | let currentPolicies = null; |
| 2933 | let _botCommands = {}; |
| 2934 | |
| 2935 | function renderOnJoinMessages(msgs) { |
| 2936 | const el = document.getElementById('onjoin-list'); |
| 2937 | if (!msgs || !Object.keys(msgs).length) { el.innerHTML = '<div style="color:#8b949e;font-size:12px">No on-join instructions configured.</div>'; return; } |
| 2938 | el.innerHTML = Object.entries(msgs).sort().map(([ch, msg]) => ` |
| 2939 | <div style="display:flex;gap:8px;align-items:center;padding:6px 0;border-bottom:1px solid #21262d"> |
| 2940 | <code style="font-size:12px;min-width:120px">${esc(ch)}</code> |
| 2941 | <input type="text" value="${esc(msg)}" style="flex:1;font-size:12px" onchange="updateOnJoinMessage('${esc(ch)}',this.value)"> |
| 2942 | <button class="sm danger" onclick="removeOnJoinMessage('${esc(ch)}')">remove</button> |
| 2943 | </div> |
| 2944 | `).join(''); |
| 2945 | } |
| 2946 | function addOnJoinMessage() { |
| 2947 | const ch = document.getElementById('onjoin-new-channel').value.trim(); |
| 2948 | const msg = document.getElementById('onjoin-new-message').value.trim(); |
| 2949 | if (!ch || !msg) return; |
| 2950 | if (!currentPolicies.on_join_messages) currentPolicies.on_join_messages = {}; |
| 2951 | currentPolicies.on_join_messages[ch] = msg; |
| 2952 | document.getElementById('onjoin-new-channel').value = ''; |
| 2953 | document.getElementById('onjoin-new-message').value = ''; |
| 2954 | renderOnJoinMessages(currentPolicies.on_join_messages); |
| 2955 | } |
| 2956 | function updateOnJoinMessage(ch, msg) { |
| 2957 | if (!currentPolicies.on_join_messages) currentPolicies.on_join_messages = {}; |
| 2958 | currentPolicies.on_join_messages[ch] = msg; |
| 2959 | } |
| 2960 | function removeOnJoinMessage(ch) { |
| 2961 | if (currentPolicies.on_join_messages) delete currentPolicies.on_join_messages[ch]; |
| 2962 | renderOnJoinMessages(currentPolicies.on_join_messages); |
| 2963 | } |
| 2964 | let _llmBackendNames = []; // cached backend names for oracle dropdown |
| 2965 | |
| 2966 | async function loadSettings() { |
| 2967 | try { |
| 2968 | const [s, backends] = await Promise.all([ |
| @@ -2910,11 +2970,13 @@ | |
| 2970 | api('GET', '/v1/llm/backends').catch(() => []), |
| 2971 | ]); |
| 2972 | _llmBackendNames = (backends || []).map(b => b.name); |
| 2973 | renderTLSStatus(s.tls); |
| 2974 | currentPolicies = s.policies; |
| 2975 | _botCommands = s.bot_commands || {}; |
| 2976 | renderBehaviors(s.policies.behaviors || []); |
| 2977 | renderOnJoinMessages(s.policies.on_join_messages || {}); |
| 2978 | renderAgentPolicy(s.policies.agent_policy || {}); |
| 2979 | renderBridgePolicy(s.policies.bridge || {}); |
| 2980 | renderLoggingPolicy(s.policies.logging || {}); |
| 2981 | loadAdmins(); |
| 2982 | loadConfigCards(); |
| @@ -2973,10 +3035,14 @@ | |
| 3035 | ` : ''} |
| 3036 | <span class="tag type-observer" style="font-size:11px;min-width:64px;text-align:center">${esc(b.nick)}</span> |
| 3037 | </div> |
| 3038 | </div> |
| 3039 | ${b.enabled && hasSchema(b.id) ? renderBehConfig(b) : ''} |
| 3040 | ${_botCommands[b.id] ? `<div style="padding:6px 16px 8px 42px;border-bottom:1px solid #21262d;background:#0d1117"> |
| 3041 | <span style="font-size:11px;color:#8b949e;font-weight:600">commands:</span> |
| 3042 | ${_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('')} |
| 3043 | </div>` : ''} |
| 3044 | </div> |
| 3045 | `).join(''); |
| 3046 | } |
| 3047 | |
| 3048 | function onBehaviorToggle(id, enabled) { |
| 3049 |
| --- internal/bots/bridge/bridge.go | ||
| +++ internal/bots/bridge/bridge.go | ||
| @@ -421,10 +421,86 @@ | ||
| 421 | 421 | } |
| 422 | 422 | b.mu.Unlock() |
| 423 | 423 | |
| 424 | 424 | return nicks |
| 425 | 425 | } |
| 426 | + | |
| 427 | +// UserInfo describes a user with their IRC modes. | |
| 428 | +type UserInfo struct { | |
| 429 | + Nick string `json:"nick"` | |
| 430 | + Modes []string `json:"modes,omitempty"` // e.g. ["o", "v", "B"] | |
| 431 | +} | |
| 432 | + | |
| 433 | +// UsersWithModes returns the current user list with mode info for a channel. | |
| 434 | +func (b *Bot) UsersWithModes(channel string) []UserInfo { | |
| 435 | + seen := make(map[string]bool) | |
| 436 | + var users []UserInfo | |
| 437 | + | |
| 438 | + if b.client != nil { | |
| 439 | + if ch := b.client.LookupChannel(channel); ch != nil { | |
| 440 | + for _, u := range ch.Users(b.client) { | |
| 441 | + if u.Nick == b.nick { | |
| 442 | + continue | |
| 443 | + } | |
| 444 | + if seen[u.Nick] { | |
| 445 | + continue | |
| 446 | + } | |
| 447 | + seen[u.Nick] = true | |
| 448 | + var modes []string | |
| 449 | + if u.Perms != nil { | |
| 450 | + if perms, ok := u.Perms.Lookup(channel); ok { | |
| 451 | + if perms.Owner { | |
| 452 | + modes = append(modes, "q") | |
| 453 | + } | |
| 454 | + if perms.Admin { | |
| 455 | + modes = append(modes, "a") | |
| 456 | + } | |
| 457 | + if perms.Op { | |
| 458 | + modes = append(modes, "o") | |
| 459 | + } | |
| 460 | + if perms.HalfOp { | |
| 461 | + modes = append(modes, "h") | |
| 462 | + } | |
| 463 | + if perms.Voice { | |
| 464 | + modes = append(modes, "v") | |
| 465 | + } | |
| 466 | + } | |
| 467 | + } | |
| 468 | + users = append(users, UserInfo{Nick: u.Nick, Modes: modes}) | |
| 469 | + } | |
| 470 | + } | |
| 471 | + } | |
| 472 | + | |
| 473 | + now := time.Now() | |
| 474 | + b.mu.Lock() | |
| 475 | + cutoff := now.Add(-b.webUserTTL) | |
| 476 | + for nick, last := range b.webUsers[channel] { | |
| 477 | + if !last.After(cutoff) { | |
| 478 | + delete(b.webUsers[channel], nick) | |
| 479 | + continue | |
| 480 | + } | |
| 481 | + if !seen[nick] { | |
| 482 | + seen[nick] = true | |
| 483 | + users = append(users, UserInfo{Nick: nick}) | |
| 484 | + } | |
| 485 | + } | |
| 486 | + b.mu.Unlock() | |
| 487 | + | |
| 488 | + return users | |
| 489 | +} | |
| 490 | + | |
| 491 | +// ChannelModes returns the channel mode string (e.g. "+mnt") for a channel. | |
| 492 | +func (b *Bot) ChannelModes(channel string) string { | |
| 493 | + if b.client == nil { | |
| 494 | + return "" | |
| 495 | + } | |
| 496 | + ch := b.client.LookupChannel(channel) | |
| 497 | + if ch == nil { | |
| 498 | + return "" | |
| 499 | + } | |
| 500 | + return ch.Modes.String() | |
| 501 | +} | |
| 426 | 502 | |
| 427 | 503 | // Stats returns a snapshot of bridge activity. |
| 428 | 504 | func (b *Bot) Stats() Stats { |
| 429 | 505 | b.mu.RLock() |
| 430 | 506 | channels := len(b.joined) |
| 431 | 507 |
| --- internal/bots/bridge/bridge.go | |
| +++ internal/bots/bridge/bridge.go | |
| @@ -421,10 +421,86 @@ | |
| 421 | } |
| 422 | b.mu.Unlock() |
| 423 | |
| 424 | return nicks |
| 425 | } |
| 426 | |
| 427 | // Stats returns a snapshot of bridge activity. |
| 428 | func (b *Bot) Stats() Stats { |
| 429 | b.mu.RLock() |
| 430 | channels := len(b.joined) |
| 431 |
| --- internal/bots/bridge/bridge.go | |
| +++ internal/bots/bridge/bridge.go | |
| @@ -421,10 +421,86 @@ | |
| 421 | } |
| 422 | b.mu.Unlock() |
| 423 | |
| 424 | return nicks |
| 425 | } |
| 426 | |
| 427 | // UserInfo describes a user with their IRC modes. |
| 428 | type UserInfo struct { |
| 429 | Nick string `json:"nick"` |
| 430 | Modes []string `json:"modes,omitempty"` // e.g. ["o", "v", "B"] |
| 431 | } |
| 432 | |
| 433 | // UsersWithModes returns the current user list with mode info for a channel. |
| 434 | func (b *Bot) UsersWithModes(channel string) []UserInfo { |
| 435 | seen := make(map[string]bool) |
| 436 | var users []UserInfo |
| 437 | |
| 438 | if b.client != nil { |
| 439 | if ch := b.client.LookupChannel(channel); ch != nil { |
| 440 | for _, u := range ch.Users(b.client) { |
| 441 | if u.Nick == b.nick { |
| 442 | continue |
| 443 | } |
| 444 | if seen[u.Nick] { |
| 445 | continue |
| 446 | } |
| 447 | seen[u.Nick] = true |
| 448 | var modes []string |
| 449 | if u.Perms != nil { |
| 450 | if perms, ok := u.Perms.Lookup(channel); ok { |
| 451 | if perms.Owner { |
| 452 | modes = append(modes, "q") |
| 453 | } |
| 454 | if perms.Admin { |
| 455 | modes = append(modes, "a") |
| 456 | } |
| 457 | if perms.Op { |
| 458 | modes = append(modes, "o") |
| 459 | } |
| 460 | if perms.HalfOp { |
| 461 | modes = append(modes, "h") |
| 462 | } |
| 463 | if perms.Voice { |
| 464 | modes = append(modes, "v") |
| 465 | } |
| 466 | } |
| 467 | } |
| 468 | users = append(users, UserInfo{Nick: u.Nick, Modes: modes}) |
| 469 | } |
| 470 | } |
| 471 | } |
| 472 | |
| 473 | now := time.Now() |
| 474 | b.mu.Lock() |
| 475 | cutoff := now.Add(-b.webUserTTL) |
| 476 | for nick, last := range b.webUsers[channel] { |
| 477 | if !last.After(cutoff) { |
| 478 | delete(b.webUsers[channel], nick) |
| 479 | continue |
| 480 | } |
| 481 | if !seen[nick] { |
| 482 | seen[nick] = true |
| 483 | users = append(users, UserInfo{Nick: nick}) |
| 484 | } |
| 485 | } |
| 486 | b.mu.Unlock() |
| 487 | |
| 488 | return users |
| 489 | } |
| 490 | |
| 491 | // ChannelModes returns the channel mode string (e.g. "+mnt") for a channel. |
| 492 | func (b *Bot) ChannelModes(channel string) string { |
| 493 | if b.client == nil { |
| 494 | return "" |
| 495 | } |
| 496 | ch := b.client.LookupChannel(channel) |
| 497 | if ch == nil { |
| 498 | return "" |
| 499 | } |
| 500 | return ch.Modes.String() |
| 501 | } |
| 502 | |
| 503 | // Stats returns a snapshot of bridge activity. |
| 504 | func (b *Bot) Stats() Stats { |
| 505 | b.mu.RLock() |
| 506 | channels := len(b.joined) |
| 507 |
| --- internal/config/config.go | ||
| +++ internal/config/config.go | ||
| @@ -278,10 +278,14 @@ | ||
| 278 | 278 | // Voice is a list of nicks to grant voice (+v) access. |
| 279 | 279 | Voice []string `yaml:"voice" json:"voice,omitempty"` |
| 280 | 280 | |
| 281 | 281 | // Autojoin is a list of bot nicks to invite when the channel is provisioned. |
| 282 | 282 | Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"` |
| 283 | + | |
| 284 | + // OnJoinMessage is sent to agents when they join this channel. | |
| 285 | + // Supports template variables: {nick}, {channel}. | |
| 286 | + OnJoinMessage string `yaml:"on_join_message" json:"on_join_message,omitempty"` | |
| 283 | 287 | } |
| 284 | 288 | |
| 285 | 289 | // ChannelTypeConfig defines policy rules for a class of dynamically created channels. |
| 286 | 290 | // Matched by prefix against channel names (e.g. prefix "task." matches "#task.gh-42"). |
| 287 | 291 | type ChannelTypeConfig struct { |
| @@ -302,10 +306,13 @@ | ||
| 302 | 306 | Ephemeral bool `yaml:"ephemeral" json:"ephemeral,omitempty"` |
| 303 | 307 | |
| 304 | 308 | // TTL is the maximum lifetime of an ephemeral channel with no non-bot members. |
| 305 | 309 | // Zero means no TTL; cleanup only occurs when the channel is empty. |
| 306 | 310 | TTL Duration `yaml:"ttl" json:"ttl,omitempty"` |
| 311 | + | |
| 312 | + // OnJoinMessage is sent to agents when they join a channel of this type. | |
| 313 | + OnJoinMessage string `yaml:"on_join_message" json:"on_join_message,omitempty"` | |
| 307 | 314 | } |
| 308 | 315 | |
| 309 | 316 | // Duration wraps time.Duration for YAML/JSON marshalling ("72h", "30m", etc.). |
| 310 | 317 | type Duration struct { |
| 311 | 318 | time.Duration |
| 312 | 319 |
| --- internal/config/config.go | |
| +++ internal/config/config.go | |
| @@ -278,10 +278,14 @@ | |
| 278 | // Voice is a list of nicks to grant voice (+v) access. |
| 279 | Voice []string `yaml:"voice" json:"voice,omitempty"` |
| 280 | |
| 281 | // Autojoin is a list of bot nicks to invite when the channel is provisioned. |
| 282 | Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"` |
| 283 | } |
| 284 | |
| 285 | // ChannelTypeConfig defines policy rules for a class of dynamically created channels. |
| 286 | // Matched by prefix against channel names (e.g. prefix "task." matches "#task.gh-42"). |
| 287 | type ChannelTypeConfig struct { |
| @@ -302,10 +306,13 @@ | |
| 302 | Ephemeral bool `yaml:"ephemeral" json:"ephemeral,omitempty"` |
| 303 | |
| 304 | // TTL is the maximum lifetime of an ephemeral channel with no non-bot members. |
| 305 | // Zero means no TTL; cleanup only occurs when the channel is empty. |
| 306 | TTL Duration `yaml:"ttl" json:"ttl,omitempty"` |
| 307 | } |
| 308 | |
| 309 | // Duration wraps time.Duration for YAML/JSON marshalling ("72h", "30m", etc.). |
| 310 | type Duration struct { |
| 311 | time.Duration |
| 312 |
| --- internal/config/config.go | |
| +++ internal/config/config.go | |
| @@ -278,10 +278,14 @@ | |
| 278 | // Voice is a list of nicks to grant voice (+v) access. |
| 279 | Voice []string `yaml:"voice" json:"voice,omitempty"` |
| 280 | |
| 281 | // Autojoin is a list of bot nicks to invite when the channel is provisioned. |
| 282 | Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"` |
| 283 | |
| 284 | // OnJoinMessage is sent to agents when they join this channel. |
| 285 | // Supports template variables: {nick}, {channel}. |
| 286 | OnJoinMessage string `yaml:"on_join_message" json:"on_join_message,omitempty"` |
| 287 | } |
| 288 | |
| 289 | // ChannelTypeConfig defines policy rules for a class of dynamically created channels. |
| 290 | // Matched by prefix against channel names (e.g. prefix "task." matches "#task.gh-42"). |
| 291 | type ChannelTypeConfig struct { |
| @@ -302,10 +306,13 @@ | |
| 306 | Ephemeral bool `yaml:"ephemeral" json:"ephemeral,omitempty"` |
| 307 | |
| 308 | // TTL is the maximum lifetime of an ephemeral channel with no non-bot members. |
| 309 | // Zero means no TTL; cleanup only occurs when the channel is empty. |
| 310 | TTL Duration `yaml:"ttl" json:"ttl,omitempty"` |
| 311 | |
| 312 | // OnJoinMessage is sent to agents when they join a channel of this type. |
| 313 | OnJoinMessage string `yaml:"on_join_message" json:"on_join_message,omitempty"` |
| 314 | } |
| 315 | |
| 316 | // Duration wraps time.Duration for YAML/JSON marshalling ("72h", "30m", etc.). |
| 317 | type Duration struct { |
| 318 | time.Duration |
| 319 |
| --- internal/topology/topology.go | ||
| +++ internal/topology/topology.go | ||
| @@ -32,10 +32,13 @@ | ||
| 32 | 32 | // Voice is a list of nicks to grant +v status. |
| 33 | 33 | Voice []string |
| 34 | 34 | |
| 35 | 35 | // Autojoin is a list of bot nicks to invite after provisioning. |
| 36 | 36 | Autojoin []string |
| 37 | + | |
| 38 | + // OnJoinMessage is sent to agents when they join this channel. | |
| 39 | + OnJoinMessage string | |
| 37 | 40 | } |
| 38 | 41 | |
| 39 | 42 | // channelRecord tracks a provisioned channel for TTL-based reaping. |
| 40 | 43 | type channelRecord struct { |
| 41 | 44 | name string |
| 42 | 45 |
| --- internal/topology/topology.go | |
| +++ internal/topology/topology.go | |
| @@ -32,10 +32,13 @@ | |
| 32 | // Voice is a list of nicks to grant +v status. |
| 33 | Voice []string |
| 34 | |
| 35 | // Autojoin is a list of bot nicks to invite after provisioning. |
| 36 | Autojoin []string |
| 37 | } |
| 38 | |
| 39 | // channelRecord tracks a provisioned channel for TTL-based reaping. |
| 40 | type channelRecord struct { |
| 41 | name string |
| 42 |
| --- internal/topology/topology.go | |
| +++ internal/topology/topology.go | |
| @@ -32,10 +32,13 @@ | |
| 32 | // Voice is a list of nicks to grant +v status. |
| 33 | Voice []string |
| 34 | |
| 35 | // Autojoin is a list of bot nicks to invite after provisioning. |
| 36 | Autojoin []string |
| 37 | |
| 38 | // OnJoinMessage is sent to agents when they join this channel. |
| 39 | OnJoinMessage string |
| 40 | } |
| 41 | |
| 42 | // channelRecord tracks a provisioned channel for TTL-based reaping. |
| 43 | type channelRecord struct { |
| 44 | name string |
| 45 |