ScuttleBot

Merge pull request #140 from ConflictHQ/feature/116-web-ui-enhancements feat: web UI — bot commands, user modes, on-join instructions

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

Keyboard Shortcuts

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