ScuttleBot

feat: wire command router into all 10 system bots (#86) Every bot now responds to HELP, !commands, and DMs via the shared cmdparse framework. 22 commands registered across 10 bots: - auditbot: QUERY - scribe: SEARCH, STATS - herald: STATUS, TEST - oracle: SUMMARIZE - warden: WARN, MUTE, KICK, STATUS - scroll: REPLAY, SEARCH - systembot: STATUS, WHO - snitch: STATUS, ACKNOWLEDGE - sentinel: REPORT, STATUS, DISMISS - steward: ACT, OVERRIDE, STATUS All handlers return 'not implemented yet' — individual bot issues (#73-#82) will implement the real logic.

lmata 2026-04-05 03:12 trunk
Commit 520ad8ed949416b1f4ffe95180c7a876fcf1a13bb03c36e2acb7f5c31b272321
--- internal/bots/auditbot/auditbot.go
+++ internal/bots/auditbot/auditbot.go
@@ -20,10 +20,11 @@
2020
"strings"
2121
"time"
2222
2323
"github.com/lrstanley/girc"
2424
25
+ "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
2526
"github.com/conflicthq/scuttlebot/pkg/protocol"
2627
)
2728
2829
const botNick = "auditbot"
2930
@@ -132,17 +133,30 @@
132133
if ch := e.Last(); strings.HasPrefix(ch, "#") {
133134
cl.Cmd.Join(ch)
134135
}
135136
})
136137
137
- c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
138
+ router := cmdparse.NewRouter(botNick)
139
+ router.Register(cmdparse.Command{
140
+ Name: "query",
141
+ Usage: "QUERY <nick|#channel>",
142
+ Description: "show recent audit events for a nick or channel",
143
+ Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
144
+ })
145
+
146
+ c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
138147
if len(e.Params) < 1 {
139148
return
149
+ }
150
+ // Dispatch commands (DMs and channel messages).
151
+ if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
152
+ cl.Cmd.Message(reply.Target, reply.Text)
153
+ return
140154
}
141155
channel := e.Params[0]
142156
if !strings.HasPrefix(channel, "#") {
143
- return // ignore DMs
157
+ return // non-command DMs ignored
144158
}
145159
text := e.Last()
146160
env, err := protocol.Unmarshal([]byte(text))
147161
if err != nil {
148162
return // non-envelope PRIVMSG ignored
149163
--- internal/bots/auditbot/auditbot.go
+++ internal/bots/auditbot/auditbot.go
@@ -20,10 +20,11 @@
20 "strings"
21 "time"
22
23 "github.com/lrstanley/girc"
24
 
25 "github.com/conflicthq/scuttlebot/pkg/protocol"
26 )
27
28 const botNick = "auditbot"
29
@@ -132,17 +133,30 @@
132 if ch := e.Last(); strings.HasPrefix(ch, "#") {
133 cl.Cmd.Join(ch)
134 }
135 })
136
137 c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
 
 
 
 
 
 
 
 
138 if len(e.Params) < 1 {
139 return
 
 
 
 
 
140 }
141 channel := e.Params[0]
142 if !strings.HasPrefix(channel, "#") {
143 return // ignore DMs
144 }
145 text := e.Last()
146 env, err := protocol.Unmarshal([]byte(text))
147 if err != nil {
148 return // non-envelope PRIVMSG ignored
149
--- internal/bots/auditbot/auditbot.go
+++ internal/bots/auditbot/auditbot.go
@@ -20,10 +20,11 @@
20 "strings"
21 "time"
22
23 "github.com/lrstanley/girc"
24
25 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
26 "github.com/conflicthq/scuttlebot/pkg/protocol"
27 )
28
29 const botNick = "auditbot"
30
@@ -132,17 +133,30 @@
133 if ch := e.Last(); strings.HasPrefix(ch, "#") {
134 cl.Cmd.Join(ch)
135 }
136 })
137
138 router := cmdparse.NewRouter(botNick)
139 router.Register(cmdparse.Command{
140 Name: "query",
141 Usage: "QUERY <nick|#channel>",
142 Description: "show recent audit events for a nick or channel",
143 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
144 })
145
146 c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
147 if len(e.Params) < 1 {
148 return
149 }
150 // Dispatch commands (DMs and channel messages).
151 if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
152 cl.Cmd.Message(reply.Target, reply.Text)
153 return
154 }
155 channel := e.Params[0]
156 if !strings.HasPrefix(channel, "#") {
157 return // non-command DMs ignored
158 }
159 text := e.Last()
160 env, err := protocol.Unmarshal([]byte(text))
161 if err != nil {
162 return // non-envelope PRIVMSG ignored
163
--- internal/bots/herald/herald.go
+++ internal/bots/herald/herald.go
@@ -17,10 +17,12 @@
1717
"strings"
1818
"sync"
1919
"time"
2020
2121
"github.com/lrstanley/girc"
22
+
23
+ "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
2224
)
2325
2426
const botNick = "herald"
2527
2628
// Event is a notification pushed to herald for delivery.
@@ -164,10 +166,35 @@
164166
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
165167
if ch := e.Last(); strings.HasPrefix(ch, "#") {
166168
cl.Cmd.Join(ch)
167169
}
168170
})
171
+
172
+ router := cmdparse.NewRouter(botNick)
173
+ router.Register(cmdparse.Command{
174
+ Name: "status",
175
+ Usage: "STATUS",
176
+ Description: "show webhook endpoint status and recent events",
177
+ Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
178
+ })
179
+ router.Register(cmdparse.Command{
180
+ Name: "test",
181
+ Usage: "TEST #channel",
182
+ Description: "send a test event to a channel",
183
+ Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
184
+ })
185
+
186
+ c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
187
+ if len(e.Params) < 1 || e.Source == nil {
188
+ return
189
+ }
190
+ // Dispatch commands (DMs and channel messages).
191
+ if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
192
+ cl.Cmd.Message(reply.Target, reply.Text)
193
+ return
194
+ }
195
+ })
169196
170197
b.client = c
171198
172199
errCh := make(chan error, 1)
173200
go func() {
174201
--- internal/bots/herald/herald.go
+++ internal/bots/herald/herald.go
@@ -17,10 +17,12 @@
17 "strings"
18 "sync"
19 "time"
20
21 "github.com/lrstanley/girc"
 
 
22 )
23
24 const botNick = "herald"
25
26 // Event is a notification pushed to herald for delivery.
@@ -164,10 +166,35 @@
164 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
165 if ch := e.Last(); strings.HasPrefix(ch, "#") {
166 cl.Cmd.Join(ch)
167 }
168 })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
170 b.client = c
171
172 errCh := make(chan error, 1)
173 go func() {
174
--- internal/bots/herald/herald.go
+++ internal/bots/herald/herald.go
@@ -17,10 +17,12 @@
17 "strings"
18 "sync"
19 "time"
20
21 "github.com/lrstanley/girc"
22
23 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
24 )
25
26 const botNick = "herald"
27
28 // Event is a notification pushed to herald for delivery.
@@ -164,10 +166,35 @@
166 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
167 if ch := e.Last(); strings.HasPrefix(ch, "#") {
168 cl.Cmd.Join(ch)
169 }
170 })
171
172 router := cmdparse.NewRouter(botNick)
173 router.Register(cmdparse.Command{
174 Name: "status",
175 Usage: "STATUS",
176 Description: "show webhook endpoint status and recent events",
177 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
178 })
179 router.Register(cmdparse.Command{
180 Name: "test",
181 Usage: "TEST #channel",
182 Description: "send a test event to a channel",
183 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
184 })
185
186 c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
187 if len(e.Params) < 1 || e.Source == nil {
188 return
189 }
190 // Dispatch commands (DMs and channel messages).
191 if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
192 cl.Cmd.Message(reply.Target, reply.Text)
193 return
194 }
195 })
196
197 b.client = c
198
199 errCh := make(chan error, 1)
200 go func() {
201
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -20,10 +20,12 @@
2020
"strings"
2121
"sync"
2222
"time"
2323
2424
"github.com/lrstanley/girc"
25
+
26
+ "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
2527
)
2628
2729
const (
2830
botNick = "oracle"
2931
defaultLimit = 50
@@ -176,14 +178,26 @@
176178
if ch := e.Last(); strings.HasPrefix(ch, "#") {
177179
cl.Cmd.Join(ch)
178180
}
179181
})
180182
181
- // Only handle DMs — oracle ignores channel messages.
183
+ router := cmdparse.NewRouter(botNick)
184
+ router.Register(cmdparse.Command{
185
+ Name: "summarize",
186
+ Usage: "SUMMARIZE [#channel] [duration]",
187
+ Description: "summarize recent channel activity",
188
+ Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
189
+ })
190
+
182191
c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
183192
if len(e.Params) < 1 || e.Source == nil {
184193
return
194
+ }
195
+ // Dispatch commands (DMs and channel messages).
196
+ if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
197
+ cl.Cmd.Message(reply.Target, reply.Text)
198
+ return
185199
}
186200
target := e.Params[0]
187201
if strings.HasPrefix(target, "#") {
188202
return // channel message — ignore
189203
}
190204
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -20,10 +20,12 @@
20 "strings"
21 "sync"
22 "time"
23
24 "github.com/lrstanley/girc"
 
 
25 )
26
27 const (
28 botNick = "oracle"
29 defaultLimit = 50
@@ -176,14 +178,26 @@
176 if ch := e.Last(); strings.HasPrefix(ch, "#") {
177 cl.Cmd.Join(ch)
178 }
179 })
180
181 // Only handle DMs — oracle ignores channel messages.
 
 
 
 
 
 
 
182 c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
183 if len(e.Params) < 1 || e.Source == nil {
184 return
 
 
 
 
 
185 }
186 target := e.Params[0]
187 if strings.HasPrefix(target, "#") {
188 return // channel message — ignore
189 }
190
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -20,10 +20,12 @@
20 "strings"
21 "sync"
22 "time"
23
24 "github.com/lrstanley/girc"
25
26 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
27 )
28
29 const (
30 botNick = "oracle"
31 defaultLimit = 50
@@ -176,14 +178,26 @@
178 if ch := e.Last(); strings.HasPrefix(ch, "#") {
179 cl.Cmd.Join(ch)
180 }
181 })
182
183 router := cmdparse.NewRouter(botNick)
184 router.Register(cmdparse.Command{
185 Name: "summarize",
186 Usage: "SUMMARIZE [#channel] [duration]",
187 Description: "summarize recent channel activity",
188 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
189 })
190
191 c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
192 if len(e.Params) < 1 || e.Source == nil {
193 return
194 }
195 // Dispatch commands (DMs and channel messages).
196 if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
197 cl.Cmd.Message(reply.Target, reply.Text)
198 return
199 }
200 target := e.Params[0]
201 if strings.HasPrefix(target, "#") {
202 return // channel message — ignore
203 }
204
--- internal/bots/scribe/scribe.go
+++ internal/bots/scribe/scribe.go
@@ -15,10 +15,11 @@
1515
"strings"
1616
"time"
1717
1818
"github.com/lrstanley/girc"
1919
20
+ "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
2021
"github.com/conflicthq/scuttlebot/pkg/protocol"
2122
)
2223
2324
const botNick = "scribe"
2425
@@ -75,19 +76,38 @@
7576
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
7677
if ch := e.Last(); strings.HasPrefix(ch, "#") {
7778
cl.Cmd.Join(ch)
7879
}
7980
})
81
+
82
+ router := cmdparse.NewRouter(botNick)
83
+ router.Register(cmdparse.Command{
84
+ Name: "search",
85
+ Usage: "SEARCH <term>",
86
+ Description: "search channel logs",
87
+ Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
88
+ })
89
+ router.Register(cmdparse.Command{
90
+ Name: "stats",
91
+ Usage: "STATS",
92
+ Description: "show channel message statistics",
93
+ Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
94
+ })
8095
8196
// Log PRIVMSG — the agent message stream.
8297
c.Handlers.AddBg(girc.PRIVMSG, func(client *girc.Client, e girc.Event) {
83
- if len(e.Params) < 1 {
98
+ if len(e.Params) < 1 || e.Source == nil {
99
+ return
100
+ }
101
+ // Dispatch commands (DMs and channel messages).
102
+ if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
103
+ client.Cmd.Message(reply.Target, reply.Text)
84104
return
85105
}
86106
channel := e.Params[0]
87107
if !strings.HasPrefix(channel, "#") {
88
- return // ignore DMs to scribe itself
108
+ return // non-command DMs ignored
89109
}
90110
text := e.Last()
91111
nick := e.Source.Name
92112
b.writeEntry(channel, nick, text)
93113
})
94114
--- internal/bots/scribe/scribe.go
+++ internal/bots/scribe/scribe.go
@@ -15,10 +15,11 @@
15 "strings"
16 "time"
17
18 "github.com/lrstanley/girc"
19
 
20 "github.com/conflicthq/scuttlebot/pkg/protocol"
21 )
22
23 const botNick = "scribe"
24
@@ -75,19 +76,38 @@
75 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
76 if ch := e.Last(); strings.HasPrefix(ch, "#") {
77 cl.Cmd.Join(ch)
78 }
79 })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
81 // Log PRIVMSG — the agent message stream.
82 c.Handlers.AddBg(girc.PRIVMSG, func(client *girc.Client, e girc.Event) {
83 if len(e.Params) < 1 {
 
 
 
 
 
84 return
85 }
86 channel := e.Params[0]
87 if !strings.HasPrefix(channel, "#") {
88 return // ignore DMs to scribe itself
89 }
90 text := e.Last()
91 nick := e.Source.Name
92 b.writeEntry(channel, nick, text)
93 })
94
--- internal/bots/scribe/scribe.go
+++ internal/bots/scribe/scribe.go
@@ -15,10 +15,11 @@
15 "strings"
16 "time"
17
18 "github.com/lrstanley/girc"
19
20 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
21 "github.com/conflicthq/scuttlebot/pkg/protocol"
22 )
23
24 const botNick = "scribe"
25
@@ -75,19 +76,38 @@
76 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
77 if ch := e.Last(); strings.HasPrefix(ch, "#") {
78 cl.Cmd.Join(ch)
79 }
80 })
81
82 router := cmdparse.NewRouter(botNick)
83 router.Register(cmdparse.Command{
84 Name: "search",
85 Usage: "SEARCH <term>",
86 Description: "search channel logs",
87 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
88 })
89 router.Register(cmdparse.Command{
90 Name: "stats",
91 Usage: "STATS",
92 Description: "show channel message statistics",
93 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
94 })
95
96 // Log PRIVMSG — the agent message stream.
97 c.Handlers.AddBg(girc.PRIVMSG, func(client *girc.Client, e girc.Event) {
98 if len(e.Params) < 1 || e.Source == nil {
99 return
100 }
101 // Dispatch commands (DMs and channel messages).
102 if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
103 client.Cmd.Message(reply.Target, reply.Text)
104 return
105 }
106 channel := e.Params[0]
107 if !strings.HasPrefix(channel, "#") {
108 return // non-command DMs ignored
109 }
110 text := e.Last()
111 nick := e.Source.Name
112 b.writeEntry(channel, nick, text)
113 })
114
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -20,10 +20,11 @@
2020
"sync"
2121
"time"
2222
2323
"github.com/lrstanley/girc"
2424
25
+ "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
2526
"github.com/conflicthq/scuttlebot/internal/bots/scribe"
2627
)
2728
2829
const (
2930
botNick = "scroll"
@@ -81,13 +82,31 @@
8182
cl.Cmd.Join(ch)
8283
}
8384
b.log.Info("scroll connected", "channels", b.channels)
8485
})
8586
86
- // Only respond to DMs — ignore anything in a channel.
87
+ router := cmdparse.NewRouter(botNick)
88
+ router.Register(cmdparse.Command{
89
+ Name: "replay",
90
+ Usage: "REPLAY [#channel] [count]",
91
+ Description: "replay recent channel messages",
92
+ Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
93
+ })
94
+ router.Register(cmdparse.Command{
95
+ Name: "search",
96
+ Usage: "SEARCH [#channel] <term>",
97
+ Description: "search channel history",
98
+ Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
99
+ })
100
+
87101
c.Handlers.AddBg(girc.PRIVMSG, func(client *girc.Client, e girc.Event) {
88
- if len(e.Params) < 1 {
102
+ if len(e.Params) < 1 || e.Source == nil {
103
+ return
104
+ }
105
+ // Dispatch commands (DMs and channel messages).
106
+ if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
107
+ client.Cmd.Message(reply.Target, reply.Text)
89108
return
90109
}
91110
target := e.Params[0]
92111
if strings.HasPrefix(target, "#") {
93112
return // channel message, ignore
94113
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -20,10 +20,11 @@
20 "sync"
21 "time"
22
23 "github.com/lrstanley/girc"
24
 
25 "github.com/conflicthq/scuttlebot/internal/bots/scribe"
26 )
27
28 const (
29 botNick = "scroll"
@@ -81,13 +82,31 @@
81 cl.Cmd.Join(ch)
82 }
83 b.log.Info("scroll connected", "channels", b.channels)
84 })
85
86 // Only respond to DMs — ignore anything in a channel.
 
 
 
 
 
 
 
 
 
 
 
 
 
87 c.Handlers.AddBg(girc.PRIVMSG, func(client *girc.Client, e girc.Event) {
88 if len(e.Params) < 1 {
 
 
 
 
 
89 return
90 }
91 target := e.Params[0]
92 if strings.HasPrefix(target, "#") {
93 return // channel message, ignore
94
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -20,10 +20,11 @@
20 "sync"
21 "time"
22
23 "github.com/lrstanley/girc"
24
25 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
26 "github.com/conflicthq/scuttlebot/internal/bots/scribe"
27 )
28
29 const (
30 botNick = "scroll"
@@ -81,13 +82,31 @@
82 cl.Cmd.Join(ch)
83 }
84 b.log.Info("scroll connected", "channels", b.channels)
85 })
86
87 router := cmdparse.NewRouter(botNick)
88 router.Register(cmdparse.Command{
89 Name: "replay",
90 Usage: "REPLAY [#channel] [count]",
91 Description: "replay recent channel messages",
92 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
93 })
94 router.Register(cmdparse.Command{
95 Name: "search",
96 Usage: "SEARCH [#channel] <term>",
97 Description: "search channel history",
98 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
99 })
100
101 c.Handlers.AddBg(girc.PRIVMSG, func(client *girc.Client, e girc.Event) {
102 if len(e.Params) < 1 || e.Source == nil {
103 return
104 }
105 // Dispatch commands (DMs and channel messages).
106 if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
107 client.Cmd.Message(reply.Target, reply.Text)
108 return
109 }
110 target := e.Params[0]
111 if strings.HasPrefix(target, "#") {
112 return // channel message, ignore
113
--- internal/bots/sentinel/sentinel.go
+++ internal/bots/sentinel/sentinel.go
@@ -20,10 +20,12 @@
2020
"strings"
2121
"sync"
2222
"time"
2323
2424
"github.com/lrstanley/girc"
25
+
26
+ "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
2527
)
2628
2729
const defaultNick = "sentinel"
2830
2931
// LLMProvider calls a language model to evaluate channel content.
@@ -161,17 +163,42 @@
161163
if ch := e.Last(); strings.HasPrefix(ch, "#") {
162164
cl.Cmd.Join(ch)
163165
}
164166
})
165167
166
- c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
168
+ router := cmdparse.NewRouter(b.cfg.Nick)
169
+ router.Register(cmdparse.Command{
170
+ Name: "report",
171
+ Usage: "REPORT [#channel]",
172
+ Description: "on-demand policy review",
173
+ Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
174
+ })
175
+ router.Register(cmdparse.Command{
176
+ Name: "status",
177
+ Usage: "STATUS",
178
+ Description: "show current incidents",
179
+ Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
180
+ })
181
+ router.Register(cmdparse.Command{
182
+ Name: "dismiss",
183
+ Usage: "DISMISS <incident-id>",
184
+ Description: "dismiss a false positive",
185
+ Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
186
+ })
187
+
188
+ c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
167189
if len(e.Params) < 1 || e.Source == nil {
168190
return
191
+ }
192
+ // Dispatch commands (DMs and channel messages).
193
+ if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
194
+ cl.Cmd.Message(reply.Target, reply.Text)
195
+ return
169196
}
170197
channel := e.Params[0]
171198
if !strings.HasPrefix(channel, "#") {
172
- return // ignore DMs
199
+ return // non-command DMs ignored
173200
}
174201
if channel == b.cfg.ModChannel {
175202
return // don't analyse the mod channel itself
176203
}
177204
nick := e.Source.Name
178205
--- internal/bots/sentinel/sentinel.go
+++ internal/bots/sentinel/sentinel.go
@@ -20,10 +20,12 @@
20 "strings"
21 "sync"
22 "time"
23
24 "github.com/lrstanley/girc"
 
 
25 )
26
27 const defaultNick = "sentinel"
28
29 // LLMProvider calls a language model to evaluate channel content.
@@ -161,17 +163,42 @@
161 if ch := e.Last(); strings.HasPrefix(ch, "#") {
162 cl.Cmd.Join(ch)
163 }
164 })
165
166 c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167 if len(e.Params) < 1 || e.Source == nil {
168 return
 
 
 
 
 
169 }
170 channel := e.Params[0]
171 if !strings.HasPrefix(channel, "#") {
172 return // ignore DMs
173 }
174 if channel == b.cfg.ModChannel {
175 return // don't analyse the mod channel itself
176 }
177 nick := e.Source.Name
178
--- internal/bots/sentinel/sentinel.go
+++ internal/bots/sentinel/sentinel.go
@@ -20,10 +20,12 @@
20 "strings"
21 "sync"
22 "time"
23
24 "github.com/lrstanley/girc"
25
26 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
27 )
28
29 const defaultNick = "sentinel"
30
31 // LLMProvider calls a language model to evaluate channel content.
@@ -161,17 +163,42 @@
163 if ch := e.Last(); strings.HasPrefix(ch, "#") {
164 cl.Cmd.Join(ch)
165 }
166 })
167
168 router := cmdparse.NewRouter(b.cfg.Nick)
169 router.Register(cmdparse.Command{
170 Name: "report",
171 Usage: "REPORT [#channel]",
172 Description: "on-demand policy review",
173 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
174 })
175 router.Register(cmdparse.Command{
176 Name: "status",
177 Usage: "STATUS",
178 Description: "show current incidents",
179 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
180 })
181 router.Register(cmdparse.Command{
182 Name: "dismiss",
183 Usage: "DISMISS <incident-id>",
184 Description: "dismiss a false positive",
185 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
186 })
187
188 c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
189 if len(e.Params) < 1 || e.Source == nil {
190 return
191 }
192 // Dispatch commands (DMs and channel messages).
193 if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
194 cl.Cmd.Message(reply.Target, reply.Text)
195 return
196 }
197 channel := e.Params[0]
198 if !strings.HasPrefix(channel, "#") {
199 return // non-command DMs ignored
200 }
201 if channel == b.cfg.ModChannel {
202 return // don't analyse the mod channel itself
203 }
204 nick := e.Source.Name
205
--- internal/bots/snitch/snitch.go
+++ internal/bots/snitch/snitch.go
@@ -17,10 +17,12 @@
1717
"strings"
1818
"sync"
1919
"time"
2020
2121
"github.com/lrstanley/girc"
22
+
23
+ "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
2224
)
2325
2426
const defaultNick = "snitch"
2527
2628
// Config controls snitch's thresholds and alert destination.
@@ -165,14 +167,33 @@
165167
if len(e.Params) < 1 || e.Source == nil {
166168
return
167169
}
168170
b.recordJoinPart(e.Params[0], e.Source.Name)
169171
})
172
+
173
+ router := cmdparse.NewRouter(b.cfg.Nick)
174
+ router.Register(cmdparse.Command{
175
+ Name: "status",
176
+ Usage: "STATUS",
177
+ Description: "show current active alerts",
178
+ Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
179
+ })
180
+ router.Register(cmdparse.Command{
181
+ Name: "acknowledge",
182
+ Usage: "ACKNOWLEDGE <alert-id>",
183
+ Description: "acknowledge an alert",
184
+ Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
185
+ })
170186
171187
c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
172188
if len(e.Params) < 1 || e.Source == nil {
173189
return
190
+ }
191
+ // Dispatch commands (DMs and channel messages).
192
+ if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
193
+ c.Cmd.Message(reply.Target, reply.Text)
194
+ return
174195
}
175196
channel := e.Params[0]
176197
nick := e.Source.Name
177198
if nick == b.cfg.Nick {
178199
return
179200
--- internal/bots/snitch/snitch.go
+++ internal/bots/snitch/snitch.go
@@ -17,10 +17,12 @@
17 "strings"
18 "sync"
19 "time"
20
21 "github.com/lrstanley/girc"
 
 
22 )
23
24 const defaultNick = "snitch"
25
26 // Config controls snitch's thresholds and alert destination.
@@ -165,14 +167,33 @@
165 if len(e.Params) < 1 || e.Source == nil {
166 return
167 }
168 b.recordJoinPart(e.Params[0], e.Source.Name)
169 })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
171 c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
172 if len(e.Params) < 1 || e.Source == nil {
173 return
 
 
 
 
 
174 }
175 channel := e.Params[0]
176 nick := e.Source.Name
177 if nick == b.cfg.Nick {
178 return
179
--- internal/bots/snitch/snitch.go
+++ internal/bots/snitch/snitch.go
@@ -17,10 +17,12 @@
17 "strings"
18 "sync"
19 "time"
20
21 "github.com/lrstanley/girc"
22
23 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
24 )
25
26 const defaultNick = "snitch"
27
28 // Config controls snitch's thresholds and alert destination.
@@ -165,14 +167,33 @@
167 if len(e.Params) < 1 || e.Source == nil {
168 return
169 }
170 b.recordJoinPart(e.Params[0], e.Source.Name)
171 })
172
173 router := cmdparse.NewRouter(b.cfg.Nick)
174 router.Register(cmdparse.Command{
175 Name: "status",
176 Usage: "STATUS",
177 Description: "show current active alerts",
178 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
179 })
180 router.Register(cmdparse.Command{
181 Name: "acknowledge",
182 Usage: "ACKNOWLEDGE <alert-id>",
183 Description: "acknowledge an alert",
184 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
185 })
186
187 c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
188 if len(e.Params) < 1 || e.Source == nil {
189 return
190 }
191 // Dispatch commands (DMs and channel messages).
192 if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
193 c.Cmd.Message(reply.Target, reply.Text)
194 return
195 }
196 channel := e.Params[0]
197 nick := e.Source.Name
198 if nick == b.cfg.Nick {
199 return
200
--- internal/bots/steward/steward.go
+++ internal/bots/steward/steward.go
@@ -29,10 +29,12 @@
2929
"strings"
3030
"sync"
3131
"time"
3232
3333
"github.com/lrstanley/girc"
34
+
35
+ "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
3436
)
3537
3638
const defaultNick = "steward"
3739
3840
// Config controls steward's behaviour.
@@ -144,14 +146,39 @@
144146
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
145147
if ch := e.Last(); strings.HasPrefix(ch, "#") {
146148
cl.Cmd.Join(ch)
147149
}
148150
})
151
+
152
+ router := cmdparse.NewRouter(b.cfg.Nick)
153
+ router.Register(cmdparse.Command{
154
+ Name: "act",
155
+ Usage: "ACT <incident-id>",
156
+ Description: "manually trigger action on incident",
157
+ Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
158
+ })
159
+ router.Register(cmdparse.Command{
160
+ Name: "override",
161
+ Usage: "OVERRIDE <incident-id>",
162
+ Description: "override pending action",
163
+ Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
164
+ })
165
+ router.Register(cmdparse.Command{
166
+ Name: "status",
167
+ Usage: "STATUS",
168
+ Description: "show current pending actions",
169
+ Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
170
+ })
149171
150172
c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
151173
if len(e.Params) < 1 || e.Source == nil {
152174
return
175
+ }
176
+ // Dispatch commands (DMs and channel messages).
177
+ if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
178
+ c.Cmd.Message(reply.Target, reply.Text)
179
+ return
153180
}
154181
target := e.Params[0]
155182
nick := e.Source.Name
156183
text := strings.TrimSpace(e.Last())
157184
158185
--- internal/bots/steward/steward.go
+++ internal/bots/steward/steward.go
@@ -29,10 +29,12 @@
29 "strings"
30 "sync"
31 "time"
32
33 "github.com/lrstanley/girc"
 
 
34 )
35
36 const defaultNick = "steward"
37
38 // Config controls steward's behaviour.
@@ -144,14 +146,39 @@
144 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
145 if ch := e.Last(); strings.HasPrefix(ch, "#") {
146 cl.Cmd.Join(ch)
147 }
148 })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
150 c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
151 if len(e.Params) < 1 || e.Source == nil {
152 return
 
 
 
 
 
153 }
154 target := e.Params[0]
155 nick := e.Source.Name
156 text := strings.TrimSpace(e.Last())
157
158
--- internal/bots/steward/steward.go
+++ internal/bots/steward/steward.go
@@ -29,10 +29,12 @@
29 "strings"
30 "sync"
31 "time"
32
33 "github.com/lrstanley/girc"
34
35 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
36 )
37
38 const defaultNick = "steward"
39
40 // Config controls steward's behaviour.
@@ -144,14 +146,39 @@
146 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
147 if ch := e.Last(); strings.HasPrefix(ch, "#") {
148 cl.Cmd.Join(ch)
149 }
150 })
151
152 router := cmdparse.NewRouter(b.cfg.Nick)
153 router.Register(cmdparse.Command{
154 Name: "act",
155 Usage: "ACT <incident-id>",
156 Description: "manually trigger action on incident",
157 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
158 })
159 router.Register(cmdparse.Command{
160 Name: "override",
161 Usage: "OVERRIDE <incident-id>",
162 Description: "override pending action",
163 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
164 })
165 router.Register(cmdparse.Command{
166 Name: "status",
167 Usage: "STATUS",
168 Description: "show current pending actions",
169 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
170 })
171
172 c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
173 if len(e.Params) < 1 || e.Source == nil {
174 return
175 }
176 // Dispatch commands (DMs and channel messages).
177 if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
178 c.Cmd.Message(reply.Target, reply.Text)
179 return
180 }
181 target := e.Params[0]
182 nick := e.Source.Name
183 text := strings.TrimSpace(e.Last())
184
185
--- internal/bots/systembot/systembot.go
+++ internal/bots/systembot/systembot.go
@@ -17,10 +17,12 @@
1717
"strconv"
1818
"strings"
1919
"time"
2020
2121
"github.com/lrstanley/girc"
22
+
23
+ "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
2224
)
2325
2426
const botNick = "systembot"
2527
2628
// EntryKind classifies a system event.
@@ -176,10 +178,35 @@
176178
if e.Source != nil {
177179
nick = e.Source.Name
178180
}
179181
b.write(Entry{Kind: KindMode, Channel: channel, Nick: nick, Text: strings.Join(e.Params, " ")})
180182
})
183
+
184
+ router := cmdparse.NewRouter(botNick)
185
+ router.Register(cmdparse.Command{
186
+ Name: "status",
187
+ Usage: "STATUS",
188
+ Description: "show connected users and channel counts",
189
+ Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
190
+ })
191
+ router.Register(cmdparse.Command{
192
+ Name: "who",
193
+ Usage: "WHO [#channel]",
194
+ Description: "show detailed user list",
195
+ Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
196
+ })
197
+
198
+ c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
199
+ if len(e.Params) < 1 || e.Source == nil {
200
+ return
201
+ }
202
+ // Dispatch commands (DMs and channel messages).
203
+ if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
204
+ cl.Cmd.Message(reply.Target, reply.Text)
205
+ return
206
+ }
207
+ })
181208
182209
b.client = c
183210
184211
errCh := make(chan error, 1)
185212
go func() {
186213
--- internal/bots/systembot/systembot.go
+++ internal/bots/systembot/systembot.go
@@ -17,10 +17,12 @@
17 "strconv"
18 "strings"
19 "time"
20
21 "github.com/lrstanley/girc"
 
 
22 )
23
24 const botNick = "systembot"
25
26 // EntryKind classifies a system event.
@@ -176,10 +178,35 @@
176 if e.Source != nil {
177 nick = e.Source.Name
178 }
179 b.write(Entry{Kind: KindMode, Channel: channel, Nick: nick, Text: strings.Join(e.Params, " ")})
180 })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
182 b.client = c
183
184 errCh := make(chan error, 1)
185 go func() {
186
--- internal/bots/systembot/systembot.go
+++ internal/bots/systembot/systembot.go
@@ -17,10 +17,12 @@
17 "strconv"
18 "strings"
19 "time"
20
21 "github.com/lrstanley/girc"
22
23 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
24 )
25
26 const botNick = "systembot"
27
28 // EntryKind classifies a system event.
@@ -176,10 +178,35 @@
178 if e.Source != nil {
179 nick = e.Source.Name
180 }
181 b.write(Entry{Kind: KindMode, Channel: channel, Nick: nick, Text: strings.Join(e.Params, " ")})
182 })
183
184 router := cmdparse.NewRouter(botNick)
185 router.Register(cmdparse.Command{
186 Name: "status",
187 Usage: "STATUS",
188 Description: "show connected users and channel counts",
189 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
190 })
191 router.Register(cmdparse.Command{
192 Name: "who",
193 Usage: "WHO [#channel]",
194 Description: "show detailed user list",
195 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
196 })
197
198 c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
199 if len(e.Params) < 1 || e.Source == nil {
200 return
201 }
202 // Dispatch commands (DMs and channel messages).
203 if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
204 cl.Cmd.Message(reply.Target, reply.Text)
205 return
206 }
207 })
208
209 b.client = c
210
211 errCh := make(chan error, 1)
212 go func() {
213
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -18,10 +18,11 @@
1818
"sync"
1919
"time"
2020
2121
"github.com/lrstanley/girc"
2222
23
+ "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
2324
"github.com/conflicthq/scuttlebot/pkg/protocol"
2425
)
2526
2627
const botNick = "warden"
2728
@@ -215,18 +216,49 @@
215216
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
216217
if ch := e.Last(); strings.HasPrefix(ch, "#") {
217218
cl.Cmd.Join(ch)
218219
}
219220
})
221
+
222
+ router := cmdparse.NewRouter(botNick)
223
+ router.Register(cmdparse.Command{
224
+ Name: "warn",
225
+ Usage: "WARN <nick> [reason]",
226
+ Description: "issue a warning to a user",
227
+ Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
228
+ })
229
+ router.Register(cmdparse.Command{
230
+ Name: "mute",
231
+ Usage: "MUTE <nick> [duration]",
232
+ Description: "mute a user",
233
+ Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
234
+ })
235
+ router.Register(cmdparse.Command{
236
+ Name: "kick",
237
+ Usage: "KICK <nick> [reason]",
238
+ Description: "kick a user from channel",
239
+ Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
240
+ })
241
+ router.Register(cmdparse.Command{
242
+ Name: "status",
243
+ Usage: "STATUS",
244
+ Description: "show current warnings and mutes",
245
+ Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
246
+ })
220247
221248
c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
222249
if len(e.Params) < 1 || e.Source == nil {
223250
return
251
+ }
252
+ // Dispatch commands (DMs and channel messages).
253
+ if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
254
+ cl.Cmd.Message(reply.Target, reply.Text)
255
+ return
224256
}
225257
channel := e.Params[0]
226258
if !strings.HasPrefix(channel, "#") {
227
- return
259
+ return // non-command DMs ignored
228260
}
229261
nick := e.Source.Name
230262
text := e.Last()
231263
232264
cs := b.channelStateFor(channel)
233265
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -18,10 +18,11 @@
18 "sync"
19 "time"
20
21 "github.com/lrstanley/girc"
22
 
23 "github.com/conflicthq/scuttlebot/pkg/protocol"
24 )
25
26 const botNick = "warden"
27
@@ -215,18 +216,49 @@
215 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
216 if ch := e.Last(); strings.HasPrefix(ch, "#") {
217 cl.Cmd.Join(ch)
218 }
219 })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
221 c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
222 if len(e.Params) < 1 || e.Source == nil {
223 return
 
 
 
 
 
224 }
225 channel := e.Params[0]
226 if !strings.HasPrefix(channel, "#") {
227 return
228 }
229 nick := e.Source.Name
230 text := e.Last()
231
232 cs := b.channelStateFor(channel)
233
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -18,10 +18,11 @@
18 "sync"
19 "time"
20
21 "github.com/lrstanley/girc"
22
23 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
24 "github.com/conflicthq/scuttlebot/pkg/protocol"
25 )
26
27 const botNick = "warden"
28
@@ -215,18 +216,49 @@
216 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
217 if ch := e.Last(); strings.HasPrefix(ch, "#") {
218 cl.Cmd.Join(ch)
219 }
220 })
221
222 router := cmdparse.NewRouter(botNick)
223 router.Register(cmdparse.Command{
224 Name: "warn",
225 Usage: "WARN <nick> [reason]",
226 Description: "issue a warning to a user",
227 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
228 })
229 router.Register(cmdparse.Command{
230 Name: "mute",
231 Usage: "MUTE <nick> [duration]",
232 Description: "mute a user",
233 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
234 })
235 router.Register(cmdparse.Command{
236 Name: "kick",
237 Usage: "KICK <nick> [reason]",
238 Description: "kick a user from channel",
239 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
240 })
241 router.Register(cmdparse.Command{
242 Name: "status",
243 Usage: "STATUS",
244 Description: "show current warnings and mutes",
245 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
246 })
247
248 c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
249 if len(e.Params) < 1 || e.Source == nil {
250 return
251 }
252 // Dispatch commands (DMs and channel messages).
253 if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
254 cl.Cmd.Message(reply.Target, reply.Text)
255 return
256 }
257 channel := e.Params[0]
258 if !strings.HasPrefix(channel, "#") {
259 return // non-command DMs ignored
260 }
261 nick := e.Source.Name
262 text := e.Last()
263
264 cs := b.channelStateFor(channel)
265

Keyboard Shortcuts

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