ScuttleBot

Merge pull request #137 from ConflictHQ/feature/109-chanserv-amode feat: ChanServ AMODE for persistent access, configurable channel modes

noreply 2026-04-05 16:27 trunk merge
Commit c189ae5199d6e33092d85ebbb43868cfc2efaad3452c985fbe42749154c82cdf
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -204,10 +204,11 @@
204204
Name: sc.Name,
205205
Topic: sc.Topic,
206206
Ops: sc.Ops,
207207
Voice: sc.Voice,
208208
Autojoin: sc.Autojoin,
209
+ Modes: sc.Modes,
209210
})
210211
}
211212
if err := topoMgr.Provision(staticChannels); err != nil {
212213
log.Error("topology provision failed", "err", err)
213214
}
@@ -328,10 +329,11 @@
328329
staticChannels := make([]topology.ChannelConfig, 0, len(updated.Topology.Channels))
329330
for _, sc := range updated.Topology.Channels {
330331
staticChannels = append(staticChannels, topology.ChannelConfig{
331332
Name: sc.Name, Topic: sc.Topic,
332333
Ops: sc.Ops, Voice: sc.Voice, Autojoin: sc.Autojoin,
334
+ Modes: sc.Modes,
333335
})
334336
}
335337
if err := topoMgr.Provision(staticChannels); err != nil {
336338
log.Error("topology hot-reload failed", "err", err)
337339
}
338340
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -204,10 +204,11 @@
204 Name: sc.Name,
205 Topic: sc.Topic,
206 Ops: sc.Ops,
207 Voice: sc.Voice,
208 Autojoin: sc.Autojoin,
 
209 })
210 }
211 if err := topoMgr.Provision(staticChannels); err != nil {
212 log.Error("topology provision failed", "err", err)
213 }
@@ -328,10 +329,11 @@
328 staticChannels := make([]topology.ChannelConfig, 0, len(updated.Topology.Channels))
329 for _, sc := range updated.Topology.Channels {
330 staticChannels = append(staticChannels, topology.ChannelConfig{
331 Name: sc.Name, Topic: sc.Topic,
332 Ops: sc.Ops, Voice: sc.Voice, Autojoin: sc.Autojoin,
 
333 })
334 }
335 if err := topoMgr.Provision(staticChannels); err != nil {
336 log.Error("topology hot-reload failed", "err", err)
337 }
338
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -204,10 +204,11 @@
204 Name: sc.Name,
205 Topic: sc.Topic,
206 Ops: sc.Ops,
207 Voice: sc.Voice,
208 Autojoin: sc.Autojoin,
209 Modes: sc.Modes,
210 })
211 }
212 if err := topoMgr.Provision(staticChannels); err != nil {
213 log.Error("topology provision failed", "err", err)
214 }
@@ -328,10 +329,11 @@
329 staticChannels := make([]topology.ChannelConfig, 0, len(updated.Topology.Channels))
330 for _, sc := range updated.Topology.Channels {
331 staticChannels = append(staticChannels, topology.ChannelConfig{
332 Name: sc.Name, Topic: sc.Topic,
333 Ops: sc.Ops, Voice: sc.Voice, Autojoin: sc.Autojoin,
334 Modes: sc.Modes,
335 })
336 }
337 if err := topoMgr.Provision(staticChannels); err != nil {
338 log.Error("topology hot-reload failed", "err", err)
339 }
340
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -51,22 +51,27 @@
5151
return
5252
}
5353
5454
policy := s.topoMgr.Policy()
5555
56
- // Merge autojoin from policy if the caller didn't specify any.
56
+ // Merge autojoin and modes from policy if the caller didn't specify any.
5757
autojoin := req.Autojoin
5858
if len(autojoin) == 0 && policy != nil {
5959
autojoin = policy.AutojoinFor(req.Name)
6060
}
61
+ var modes []string
62
+ if policy != nil {
63
+ modes = policy.ModesFor(req.Name)
64
+ }
6165
6266
ch := topology.ChannelConfig{
6367
Name: req.Name,
6468
Topic: req.Topic,
6569
Ops: req.Ops,
6670
Voice: req.Voice,
6771
Autojoin: autojoin,
72
+ Modes: modes,
6873
}
6974
if err := s.topoMgr.ProvisionChannel(ch); err != nil {
7075
s.log.Error("provision channel", "channel", req.Name, "err", err)
7176
writeError(w, http.StatusInternalServerError, "provision failed")
7277
return
7378
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -51,22 +51,27 @@
51 return
52 }
53
54 policy := s.topoMgr.Policy()
55
56 // Merge autojoin from policy if the caller didn't specify any.
57 autojoin := req.Autojoin
58 if len(autojoin) == 0 && policy != nil {
59 autojoin = policy.AutojoinFor(req.Name)
60 }
 
 
 
 
61
62 ch := topology.ChannelConfig{
63 Name: req.Name,
64 Topic: req.Topic,
65 Ops: req.Ops,
66 Voice: req.Voice,
67 Autojoin: autojoin,
 
68 }
69 if err := s.topoMgr.ProvisionChannel(ch); err != nil {
70 s.log.Error("provision channel", "channel", req.Name, "err", err)
71 writeError(w, http.StatusInternalServerError, "provision failed")
72 return
73
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -51,22 +51,27 @@
51 return
52 }
53
54 policy := s.topoMgr.Policy()
55
56 // Merge autojoin and modes from policy if the caller didn't specify any.
57 autojoin := req.Autojoin
58 if len(autojoin) == 0 && policy != nil {
59 autojoin = policy.AutojoinFor(req.Name)
60 }
61 var modes []string
62 if policy != nil {
63 modes = policy.ModesFor(req.Name)
64 }
65
66 ch := topology.ChannelConfig{
67 Name: req.Name,
68 Topic: req.Topic,
69 Ops: req.Ops,
70 Voice: req.Voice,
71 Autojoin: autojoin,
72 Modes: modes,
73 }
74 if err := s.topoMgr.ProvisionChannel(ch); err != nil {
75 s.log.Error("provision channel", "channel", req.Name, "err", err)
76 writeError(w, http.StatusInternalServerError, "provision failed")
77 return
78
--- internal/config/config.go
+++ internal/config/config.go
@@ -278,10 +278,13 @@
278278
// Voice is a list of nicks to grant voice (+v) access.
279279
Voice []string `yaml:"voice" json:"voice,omitempty"`
280280
281281
// Autojoin is a list of bot nicks to invite when the channel is provisioned.
282282
Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"`
283
+
284
+ // Modes is a list of channel modes to set after provisioning (e.g. "+m" for moderated).
285
+ Modes []string `yaml:"modes" json:"modes,omitempty"`
283286
}
284287
285288
// ChannelTypeConfig defines policy rules for a class of dynamically created channels.
286289
// Matched by prefix against channel names (e.g. prefix "task." matches "#task.gh-42").
287290
type ChannelTypeConfig struct {
@@ -295,10 +298,13 @@
295298
// Autojoin is a list of bot nicks to invite when a channel of this type is created.
296299
Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"`
297300
298301
// Supervision is the coordination channel where summaries should surface.
299302
Supervision string `yaml:"supervision" json:"supervision,omitempty"`
303
+
304
+ // Modes is a list of channel modes to set when provisioning (e.g. "+m" for moderated).
305
+ Modes []string `yaml:"modes" json:"modes,omitempty"`
300306
301307
// Ephemeral marks channels of this type for automatic cleanup.
302308
Ephemeral bool `yaml:"ephemeral" json:"ephemeral,omitempty"`
303309
304310
// TTL is the maximum lifetime of an ephemeral channel with no non-bot members.
305311
--- internal/config/config.go
+++ internal/config/config.go
@@ -278,10 +278,13 @@
278 // Voice is a list of nicks to grant voice (+v) access.
279 Voice []string `yaml:"voice" json:"voice,omitempty"`
280
281 // Autojoin is a list of bot nicks to invite when the channel is provisioned.
282 Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"`
 
 
 
283 }
284
285 // ChannelTypeConfig defines policy rules for a class of dynamically created channels.
286 // Matched by prefix against channel names (e.g. prefix "task." matches "#task.gh-42").
287 type ChannelTypeConfig struct {
@@ -295,10 +298,13 @@
295 // Autojoin is a list of bot nicks to invite when a channel of this type is created.
296 Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"`
297
298 // Supervision is the coordination channel where summaries should surface.
299 Supervision string `yaml:"supervision" json:"supervision,omitempty"`
 
 
 
300
301 // Ephemeral marks channels of this type for automatic cleanup.
302 Ephemeral bool `yaml:"ephemeral" json:"ephemeral,omitempty"`
303
304 // TTL is the maximum lifetime of an ephemeral channel with no non-bot members.
305
--- internal/config/config.go
+++ internal/config/config.go
@@ -278,10 +278,13 @@
278 // Voice is a list of nicks to grant voice (+v) access.
279 Voice []string `yaml:"voice" json:"voice,omitempty"`
280
281 // Autojoin is a list of bot nicks to invite when the channel is provisioned.
282 Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"`
283
284 // 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 {
@@ -295,10 +298,13 @@
298 // Autojoin is a list of bot nicks to invite when a channel of this type is created.
299 Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"`
300
301 // Supervision is the coordination channel where summaries should surface.
302 Supervision string `yaml:"supervision" json:"supervision,omitempty"`
303
304 // Modes is a list of channel modes to set when provisioning (e.g. "+m" for moderated).
305 Modes []string `yaml:"modes" json:"modes,omitempty"`
306
307 // Ephemeral marks channels of this type for automatic cleanup.
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
--- internal/topology/policy.go
+++ internal/topology/policy.go
@@ -11,10 +11,11 @@
1111
// ChannelType is the resolved policy for a class of channels.
1212
type ChannelType struct {
1313
Name string
1414
Prefix string
1515
Autojoin []string
16
+ Modes []string
1617
Supervision string
1718
Ephemeral bool
1819
TTL time.Duration
1920
}
2021
@@ -61,10 +62,11 @@
6162
for _, t := range cfg.Types {
6263
types = append(types, ChannelType{
6364
Name: t.Name,
6465
Prefix: t.Prefix,
6566
Autojoin: append([]string(nil), t.Autojoin...),
67
+ Modes: append([]string(nil), t.Modes...),
6668
Supervision: t.Supervision,
6769
Ephemeral: t.Ephemeral,
6870
TTL: t.TTL.Duration,
6971
})
7072
}
@@ -133,10 +135,18 @@
133135
if t := p.Match(channel); t != nil {
134136
return t.TTL
135137
}
136138
return 0
137139
}
140
+
141
+// ModesFor returns the channel modes for the given channel, or nil.
142
+func (p *Policy) ModesFor(channel string) []string {
143
+ if t := p.Match(channel); t != nil {
144
+ return append([]string(nil), t.Modes...)
145
+ }
146
+ return nil
147
+}
138148
139149
// StaticChannels returns the list of channels to provision at startup.
140150
func (p *Policy) StaticChannels() []config.StaticChannelConfig {
141151
return append([]config.StaticChannelConfig(nil), p.staticChannels...)
142152
}
143153
--- internal/topology/policy.go
+++ internal/topology/policy.go
@@ -11,10 +11,11 @@
11 // ChannelType is the resolved policy for a class of channels.
12 type ChannelType struct {
13 Name string
14 Prefix string
15 Autojoin []string
 
16 Supervision string
17 Ephemeral bool
18 TTL time.Duration
19 }
20
@@ -61,10 +62,11 @@
61 for _, t := range cfg.Types {
62 types = append(types, ChannelType{
63 Name: t.Name,
64 Prefix: t.Prefix,
65 Autojoin: append([]string(nil), t.Autojoin...),
 
66 Supervision: t.Supervision,
67 Ephemeral: t.Ephemeral,
68 TTL: t.TTL.Duration,
69 })
70 }
@@ -133,10 +135,18 @@
133 if t := p.Match(channel); t != nil {
134 return t.TTL
135 }
136 return 0
137 }
 
 
 
 
 
 
 
 
138
139 // StaticChannels returns the list of channels to provision at startup.
140 func (p *Policy) StaticChannels() []config.StaticChannelConfig {
141 return append([]config.StaticChannelConfig(nil), p.staticChannels...)
142 }
143
--- internal/topology/policy.go
+++ internal/topology/policy.go
@@ -11,10 +11,11 @@
11 // ChannelType is the resolved policy for a class of channels.
12 type ChannelType struct {
13 Name string
14 Prefix string
15 Autojoin []string
16 Modes []string
17 Supervision string
18 Ephemeral bool
19 TTL time.Duration
20 }
21
@@ -61,10 +62,11 @@
62 for _, t := range cfg.Types {
63 types = append(types, ChannelType{
64 Name: t.Name,
65 Prefix: t.Prefix,
66 Autojoin: append([]string(nil), t.Autojoin...),
67 Modes: append([]string(nil), t.Modes...),
68 Supervision: t.Supervision,
69 Ephemeral: t.Ephemeral,
70 TTL: t.TTL.Duration,
71 })
72 }
@@ -133,10 +135,18 @@
135 if t := p.Match(channel); t != nil {
136 return t.TTL
137 }
138 return 0
139 }
140
141 // ModesFor returns the channel modes for the given channel, or nil.
142 func (p *Policy) ModesFor(channel string) []string {
143 if t := p.Match(channel); t != nil {
144 return append([]string(nil), t.Modes...)
145 }
146 return nil
147 }
148
149 // StaticChannels returns the list of channels to provision at startup.
150 func (p *Policy) StaticChannels() []config.StaticChannelConfig {
151 return append([]config.StaticChannelConfig(nil), p.staticChannels...)
152 }
153
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -24,18 +24,21 @@
2424
Name string
2525
2626
// Topic is the initial channel topic (shared state header).
2727
Topic string
2828
29
- // Ops is a list of nicks to grant +o (channel operator) status.
29
+ // Ops is a list of nicks to grant +o (channel operator) status via AMODE.
3030
Ops []string
3131
32
- // Voice is a list of nicks to grant +v status.
32
+ // Voice is a list of nicks to grant +v status via AMODE.
3333
Voice []string
3434
3535
// Autojoin is a list of bot nicks to invite after provisioning.
3636
Autojoin []string
37
+
38
+ // Modes is a list of channel modes to set (e.g. "+m" for moderated).
39
+ Modes []string
3740
}
3841
3942
// channelRecord tracks a provisioned channel for TTL-based reaping.
4043
type channelRecord struct {
4144
name string
@@ -207,15 +210,21 @@
207210
208211
if ch.Topic != "" {
209212
m.chanserv("TOPIC %s %s", ch.Name, ch.Topic)
210213
}
211214
215
+ // Use AMODE for persistent auto-mode on join (survives reconnects).
212216
for _, nick := range ch.Ops {
213
- m.chanserv("ACCESS %s ADD %s OP", ch.Name, nick)
217
+ m.chanserv("AMODE %s +o %s", ch.Name, nick)
214218
}
215219
for _, nick := range ch.Voice {
216
- m.chanserv("ACCESS %s ADD %s VOICE", ch.Name, nick)
220
+ m.chanserv("AMODE %s +v %s", ch.Name, nick)
221
+ }
222
+
223
+ // Apply channel modes (e.g. +m for moderated).
224
+ for _, mode := range ch.Modes {
225
+ m.client.Cmd.Mode(ch.Name, mode)
217226
}
218227
219228
if len(ch.Autojoin) > 0 {
220229
m.Invite(ch.Name, ch.Autojoin)
221230
}
@@ -274,27 +283,37 @@
274283
m.log.Info("reaping expired ephemeral channel", "channel", rec.name, "age", now.Sub(rec.provisionedAt).Round(time.Minute))
275284
m.DropChannel(rec.name)
276285
}
277286
}
278287
279
-// GrantAccess sets a ChanServ ACCESS entry for nick on the given channel.
280
-// level is "OP" or "VOICE". If level is empty, no access is granted.
288
+// GrantAccess sets a ChanServ AMODE entry for nick on the given channel.
289
+// level is "OP" or "VOICE". AMODE persists across reconnects — ChanServ
290
+// automatically applies the mode every time the nick joins.
281291
func (m *Manager) GrantAccess(nick, channel, level string) {
282292
if m.client == nil || level == "" {
283293
return
284294
}
285
- m.chanserv("ACCESS %s ADD %s %s", channel, nick, level)
286
- m.log.Info("granted channel access", "nick", nick, "channel", channel, "level", level)
295
+ switch strings.ToUpper(level) {
296
+ case "OP":
297
+ m.chanserv("AMODE %s +o %s", channel, nick)
298
+ case "VOICE":
299
+ m.chanserv("AMODE %s +v %s", channel, nick)
300
+ default:
301
+ m.log.Warn("unknown access level", "level", level)
302
+ return
303
+ }
304
+ m.log.Info("granted channel access (AMODE)", "nick", nick, "channel", channel, "level", level)
287305
}
288306
289
-// RevokeAccess removes a ChanServ ACCESS entry for nick on the given channel.
307
+// RevokeAccess removes ChanServ AMODE entries for nick on the given channel.
290308
func (m *Manager) RevokeAccess(nick, channel string) {
291309
if m.client == nil {
292310
return
293311
}
294
- m.chanserv("ACCESS %s DEL %s", channel, nick)
295
- m.log.Info("revoked channel access", "nick", nick, "channel", channel)
312
+ m.chanserv("AMODE %s -o %s", channel, nick)
313
+ m.chanserv("AMODE %s -v %s", channel, nick)
314
+ m.log.Info("revoked channel access (AMODE)", "nick", nick, "channel", channel)
296315
}
297316
298317
func (m *Manager) chanserv(format string, args ...any) {
299318
msg := fmt.Sprintf(format, args...)
300319
m.client.Cmd.Message("ChanServ", msg)
301320
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -24,18 +24,21 @@
24 Name string
25
26 // Topic is the initial channel topic (shared state header).
27 Topic string
28
29 // Ops is a list of nicks to grant +o (channel operator) status.
30 Ops []string
31
32 // Voice is a list of nicks to grant +v status.
33 Voice []string
34
35 // Autojoin is a list of bot nicks to invite after provisioning.
36 Autojoin []string
 
 
 
37 }
38
39 // channelRecord tracks a provisioned channel for TTL-based reaping.
40 type channelRecord struct {
41 name string
@@ -207,15 +210,21 @@
207
208 if ch.Topic != "" {
209 m.chanserv("TOPIC %s %s", ch.Name, ch.Topic)
210 }
211
 
212 for _, nick := range ch.Ops {
213 m.chanserv("ACCESS %s ADD %s OP", ch.Name, nick)
214 }
215 for _, nick := range ch.Voice {
216 m.chanserv("ACCESS %s ADD %s VOICE", ch.Name, nick)
 
 
 
 
 
217 }
218
219 if len(ch.Autojoin) > 0 {
220 m.Invite(ch.Name, ch.Autojoin)
221 }
@@ -274,27 +283,37 @@
274 m.log.Info("reaping expired ephemeral channel", "channel", rec.name, "age", now.Sub(rec.provisionedAt).Round(time.Minute))
275 m.DropChannel(rec.name)
276 }
277 }
278
279 // GrantAccess sets a ChanServ ACCESS entry for nick on the given channel.
280 // level is "OP" or "VOICE". If level is empty, no access is granted.
 
281 func (m *Manager) GrantAccess(nick, channel, level string) {
282 if m.client == nil || level == "" {
283 return
284 }
285 m.chanserv("ACCESS %s ADD %s %s", channel, nick, level)
286 m.log.Info("granted channel access", "nick", nick, "channel", channel, "level", level)
 
 
 
 
 
 
 
 
287 }
288
289 // RevokeAccess removes a ChanServ ACCESS entry for nick on the given channel.
290 func (m *Manager) RevokeAccess(nick, channel string) {
291 if m.client == nil {
292 return
293 }
294 m.chanserv("ACCESS %s DEL %s", channel, nick)
295 m.log.Info("revoked channel access", "nick", nick, "channel", channel)
 
296 }
297
298 func (m *Manager) chanserv(format string, args ...any) {
299 msg := fmt.Sprintf(format, args...)
300 m.client.Cmd.Message("ChanServ", msg)
301
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -24,18 +24,21 @@
24 Name string
25
26 // Topic is the initial channel topic (shared state header).
27 Topic string
28
29 // Ops is a list of nicks to grant +o (channel operator) status via AMODE.
30 Ops []string
31
32 // Voice is a list of nicks to grant +v status via AMODE.
33 Voice []string
34
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
@@ -207,15 +210,21 @@
210
211 if ch.Topic != "" {
212 m.chanserv("TOPIC %s %s", ch.Name, ch.Topic)
213 }
214
215 // Use AMODE for persistent auto-mode on join (survives reconnects).
216 for _, nick := range ch.Ops {
217 m.chanserv("AMODE %s +o %s", ch.Name, nick)
218 }
219 for _, nick := range ch.Voice {
220 m.chanserv("AMODE %s +v %s", ch.Name, nick)
221 }
222
223 // Apply channel modes (e.g. +m for moderated).
224 for _, mode := range ch.Modes {
225 m.client.Cmd.Mode(ch.Name, mode)
226 }
227
228 if len(ch.Autojoin) > 0 {
229 m.Invite(ch.Name, ch.Autojoin)
230 }
@@ -274,27 +283,37 @@
283 m.log.Info("reaping expired ephemeral channel", "channel", rec.name, "age", now.Sub(rec.provisionedAt).Round(time.Minute))
284 m.DropChannel(rec.name)
285 }
286 }
287
288 // GrantAccess sets a ChanServ AMODE entry for nick on the given channel.
289 // level is "OP" or "VOICE". AMODE persists across reconnects — ChanServ
290 // automatically applies the mode every time the nick joins.
291 func (m *Manager) GrantAccess(nick, channel, level string) {
292 if m.client == nil || level == "" {
293 return
294 }
295 switch strings.ToUpper(level) {
296 case "OP":
297 m.chanserv("AMODE %s +o %s", channel, nick)
298 case "VOICE":
299 m.chanserv("AMODE %s +v %s", channel, nick)
300 default:
301 m.log.Warn("unknown access level", "level", level)
302 return
303 }
304 m.log.Info("granted channel access (AMODE)", "nick", nick, "channel", channel, "level", level)
305 }
306
307 // RevokeAccess removes ChanServ AMODE entries for nick on the given channel.
308 func (m *Manager) RevokeAccess(nick, channel string) {
309 if m.client == nil {
310 return
311 }
312 m.chanserv("AMODE %s -o %s", channel, nick)
313 m.chanserv("AMODE %s -v %s", channel, nick)
314 m.log.Info("revoked channel access (AMODE)", "nick", nick, "channel", channel)
315 }
316
317 func (m *Manager) chanserv(format string, args ...any) {
318 msg := fmt.Sprintf(format, args...)
319 m.client.Cmd.Message("ChanServ", msg)
320

Keyboard Shortcuts

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