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.

lmata 2026-04-05 14:44 trunk
Commit 5de1db0dd7c6f91f3dede65fed1beceddec11a1d4b3dab8c34ef0b06a481ceda
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -20,10 +20,12 @@
2020
Send(ctx context.Context, channel, text, senderNick string) error
2121
SendWithMeta(ctx context.Context, channel, text, senderNick string, meta *bridge.Meta) error
2222
Stats() bridge.Stats
2323
TouchUser(channel, nick string)
2424
Users(channel string) []string
25
+ UsersWithModes(channel string) []bridge.UserInfo
26
+ ChannelModes(channel string) string
2527
}
2628
2729
func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) {
2830
channel := "#" + r.PathValue("channel")
2931
s.bridge.JoinChannel(channel)
@@ -107,15 +109,16 @@
107109
w.WriteHeader(http.StatusNoContent)
108110
}
109111
110112
func (s *Server) handleChannelUsers(w http.ResponseWriter, r *http.Request) {
111113
channel := "#" + r.PathValue("channel")
112
- users := s.bridge.Users(channel)
114
+ users := s.bridge.UsersWithModes(channel)
113115
if users == nil {
114
- users = []string{}
116
+ users = []bridge.UserInfo{}
115117
}
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})
117120
}
118121
119122
// handleChannelStream serves an SSE stream of IRC messages for a channel.
120123
// Auth is via ?token= query param because EventSource doesn't support custom headers.
121124
func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) {
122125
--- 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 @@
3030
}
3131
func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil }
3232
func (b *stubChatBridge) SendWithMeta(_ context.Context, _, _, _ string, _ *bridge.Meta) error {
3333
return nil
3434
}
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 "" }
3739
func (b *stubChatBridge) TouchUser(channel, nick string) {
3840
b.touched = append(b.touched, struct{ channel, nick string }{channel: channel, nick: nick})
3941
}
4042
4143
func TestHandleChannelPresence(t *testing.T) {
4244
--- 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 @@
7171
Default bool `json:"default,omitempty"`
7272
}
7373
7474
// Policies is the full mutable settings blob, persisted to policies.json.
7575
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
8182
}
8283
8384
// defaultBehaviors lists every built-in bot with conservative defaults (disabled).
8485
var defaultBehaviors = []BehaviorConfig{
8586
{
@@ -151,10 +152,42 @@
151152
Description: "Acts on sentinel incident reports — issues warnings, mutes, or kicks based on severity. Operators can also issue direct commands via DM.",
152153
Nick: "steward",
153154
JoinAllChannels: true,
154155
},
155156
}
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
+}
156189
157190
// PolicyStore persists Policies to a JSON file or database.
158191
type PolicyStore struct {
159192
mu sync.RWMutex
160193
path string
161194
--- 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 @@
55
66
"github.com/conflicthq/scuttlebot/internal/config"
77
)
88
99
type settingsResponse struct {
10
- TLS tlsInfo `json:"tls"`
11
- Policies Policies `json:"policies"`
10
+ TLS tlsInfo `json:"tls"`
11
+ Policies Policies `json:"policies"`
12
+ BotCommands map[string][]BotCommand `json:"bot_commands,omitempty"`
1213
}
1314
1415
type tlsInfo struct {
1516
Enabled bool `json:"enabled"`
1617
Domain string `json:"domain,omitempty"`
@@ -33,10 +34,11 @@
3334
cfg := s.cfgStore.Get()
3435
resp.Policies.AgentPolicy = toAPIAgentPolicy(cfg.AgentPolicy)
3536
resp.Policies.Logging = toAPILogging(cfg.Logging)
3637
resp.Policies.Bridge.WebUserTTLMinutes = cfg.Bridge.WebUserTTLMinutes
3738
}
39
+ resp.BotCommands = botCommands
3840
writeJSON(w, http.StatusOK, resp)
3941
}
4042
4143
func toAPIAgentPolicy(c config.AgentPolicyConfig) AgentPolicy {
4244
return AgentPolicy{
4345
--- internal/api/settings.go
+++ internal/api/settings.go
@@ -5,12 +5,13 @@
5
6 "github.com/conflicthq/scuttlebot/internal/config"
7 )
8
9 type settingsResponse struct {
10 TLS tlsInfo `json:"tls"`
11 Policies Policies `json:"policies"`
 
12 }
13
14 type tlsInfo struct {
15 Enabled bool `json:"enabled"`
16 Domain string `json:"domain,omitempty"`
@@ -33,10 +34,11 @@
33 cfg := s.cfgStore.Get()
34 resp.Policies.AgentPolicy = toAPIAgentPolicy(cfg.AgentPolicy)
35 resp.Policies.Logging = toAPILogging(cfg.Logging)
36 resp.Policies.Bridge.WebUserTTLMinutes = cfg.Bridge.WebUserTTLMinutes
37 }
 
38 writeJSON(w, http.StatusOK, resp)
39 }
40
41 func toAPIAgentPolicy(c config.AgentPolicyConfig) AgentPolicy {
42 return AgentPolicy{
43
--- internal/api/settings.go
+++ internal/api/settings.go
@@ -5,12 +5,13 @@
5
6 "github.com/conflicthq/scuttlebot/internal/config"
7 )
8
9 type settingsResponse struct {
10 TLS tlsInfo `json:"tls"`
11 Policies Policies `json:"policies"`
12 BotCommands map[string][]BotCommand `json:"bot_commands,omitempty"`
13 }
14
15 type tlsInfo struct {
16 Enabled bool `json:"enabled"`
17 Domain string `json:"domain,omitempty"`
@@ -33,10 +34,11 @@
34 cfg := s.cfgStore.Get()
35 resp.Policies.AgentPolicy = toAPIAgentPolicy(cfg.AgentPolicy)
36 resp.Policies.Logging = toAPILogging(cfg.Logging)
37 resp.Policies.Bridge.WebUserTTLMinutes = cfg.Bridge.WebUserTTLMinutes
38 }
39 resp.BotCommands = botCommands
40 writeJSON(w, http.StatusOK, resp)
41 }
42
43 func toAPIAgentPolicy(c config.AgentPolicyConfig) AgentPolicy {
44 return AgentPolicy{
45
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -504,11 +504,11 @@
504504
<div class="chan-list" id="chan-list"></div>
505505
</div>
506506
<div class="sidebar-resize" id="resize-left" title="drag to resize"></div>
507507
<div class="chat-main">
508508
<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>
510510
<div class="spacer"></div>
511511
<span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span>
512512
<select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()">
513513
<option value="">— pick a user —</option>
514514
</select>
@@ -605,10 +605,28 @@
605605
</div>
606606
<div class="card-body" style="padding:0">
607607
<div id="behaviors-list"></div>
608608
</div>
609609
</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>
610628
611629
<!-- agent policy -->
612630
<div class="card" id="card-agentpolicy">
613631
<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>
614632
<div class="card-body">
@@ -1838,11 +1856,11 @@
18381856
async function loadNicklist(ch) {
18391857
if (!ch) return;
18401858
try {
18411859
const slug = ch.replace(/^#/,'');
18421860
const data = await api('GET', `/v1/channels/${slug}/users`);
1843
- renderNicklist(data.users || []);
1861
+ renderNicklist(data.users || [], data.channel_modes || '');
18441862
} catch(e) {}
18451863
}
18461864
const SYSTEM_BOTS = new Set(['bridge','oracle','sentinel','steward','scribe','warden','snitch','herald','scroll','systembot','auditbot']);
18471865
const AGENT_PREFIXES = ['claude-','codex-','gemini-','openclaw-'];
18481866
@@ -1861,24 +1879,35 @@
18611879
if (tier === 0) return '@';
18621880
if (tier === 1) return '+';
18631881
return '';
18641882
}
18651883
1866
-function renderNicklist(users) {
1884
+function renderNicklist(users, channelModes) {
18671885
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);
18681888
// 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);
18711891
if (ta !== tb) return ta - tb;
1872
- return a.localeCompare(b);
1892
+ return a.nick.localeCompare(b.nick);
18731893
});
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>`;
18791905
}).join('');
1906
+ // Show channel modes in header if available.
1907
+ const modesEl = document.getElementById('chat-channel-modes');
1908
+ if (modesEl) modesEl.textContent = channelModes ? ` ${channelModes}` : '';
18801909
}
18811910
// Nick colors — deterministic hash over a palette
18821911
const NICK_PALETTE = ['#58a6ff','#3fb950','#ffa657','#d2a8ff','#56d364','#79c0ff','#ff7b72','#a5d6ff','#f0883e','#39d353'];
18831912
function nickColor(nick) {
18841913
let h = 0;
@@ -2899,10 +2928,41 @@
28992928
if (body) body.style.display = '';
29002929
}
29012930
29022931
// --- settings / policies ---
29032932
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
+}
29042964
let _llmBackendNames = []; // cached backend names for oracle dropdown
29052965
29062966
async function loadSettings() {
29072967
try {
29082968
const [s, backends] = await Promise.all([
@@ -2910,11 +2970,13 @@
29102970
api('GET', '/v1/llm/backends').catch(() => []),
29112971
]);
29122972
_llmBackendNames = (backends || []).map(b => b.name);
29132973
renderTLSStatus(s.tls);
29142974
currentPolicies = s.policies;
2975
+ _botCommands = s.bot_commands || {};
29152976
renderBehaviors(s.policies.behaviors || []);
2977
+ renderOnJoinMessages(s.policies.on_join_messages || {});
29162978
renderAgentPolicy(s.policies.agent_policy || {});
29172979
renderBridgePolicy(s.policies.bridge || {});
29182980
renderLoggingPolicy(s.policies.logging || {});
29192981
loadAdmins();
29202982
loadConfigCards();
@@ -2973,10 +3035,14 @@
29733035
` : ''}
29743036
<span class="tag type-observer" style="font-size:11px;min-width:64px;text-align:center">${esc(b.nick)}</span>
29753037
</div>
29763038
</div>
29773039
${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)}&#10;${esc(c.usage)}">${esc(c.command)}</code>`).join('')}
3043
+ </div>` : ''}
29783044
</div>
29793045
`).join('');
29803046
}
29813047
29823048
function onBehaviorToggle(id, enabled) {
29833049
--- 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)}&#10;${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 @@
421421
}
422422
b.mu.Unlock()
423423
424424
return nicks
425425
}
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
+}
426502
427503
// Stats returns a snapshot of bridge activity.
428504
func (b *Bot) Stats() Stats {
429505
b.mu.RLock()
430506
channels := len(b.joined)
431507
--- 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 @@
278278
// Voice is a list of nicks to grant voice (+v) access.
279279
Voice []string `yaml:"voice" json:"voice,omitempty"`
280280
281281
// Autojoin is a list of bot nicks to invite when the channel is provisioned.
282282
Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"`
283
+
284
+ // 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"`
283287
}
284288
285289
// ChannelTypeConfig defines policy rules for a class of dynamically created channels.
286290
// Matched by prefix against channel names (e.g. prefix "task." matches "#task.gh-42").
287291
type ChannelTypeConfig struct {
@@ -302,10 +306,13 @@
302306
Ephemeral bool `yaml:"ephemeral" json:"ephemeral,omitempty"`
303307
304308
// TTL is the maximum lifetime of an ephemeral channel with no non-bot members.
305309
// Zero means no TTL; cleanup only occurs when the channel is empty.
306310
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"`
307314
}
308315
309316
// Duration wraps time.Duration for YAML/JSON marshalling ("72h", "30m", etc.).
310317
type Duration struct {
311318
time.Duration
312319
--- 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 @@
3232
// Voice is a list of nicks to grant +v status.
3333
Voice []string
3434
3535
// Autojoin is a list of bot nicks to invite after provisioning.
3636
Autojoin []string
37
+
38
+ // OnJoinMessage is sent to agents when they join this channel.
39
+ OnJoinMessage string
3740
}
3841
3942
// channelRecord tracks a provisioned channel for TTL-based reaping.
4043
type channelRecord struct {
4144
name string
4245
--- 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

Keyboard Shortcuts

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