master
config.go
1package senpai
2
3import (
4 "fmt"
5 "net/url"
6 "os"
7 "os/exec"
8 "path"
9 "strconv"
10 "strings"
11
12 "git.sr.ht/~rockorager/vaxis"
13 "github.com/containerd/console"
14
15 "git.sr.ht/~delthas/senpai/ui"
16
17 "codeberg.org/emersion/go-scfg"
18)
19
20func parseColor(s string, c *vaxis.Color) error {
21 if strings.HasPrefix(s, "#") {
22 hex, err := strconv.ParseInt(s[1:], 16, 32)
23 if err != nil {
24 return err
25 }
26
27 *c = vaxis.HexColor(uint32(hex))
28 return nil
29 }
30 ok := true
31 switch s {
32 case "black":
33 *c = vaxis.IndexColor(0)
34 case "maroon":
35 *c = vaxis.IndexColor(1)
36 case "green":
37 *c = vaxis.IndexColor(2)
38 case "olive":
39 *c = vaxis.IndexColor(3)
40 case "navy":
41 *c = vaxis.IndexColor(4)
42 case "purple":
43 *c = vaxis.IndexColor(5)
44 case "teal":
45 *c = vaxis.IndexColor(6)
46 case "silver":
47 *c = vaxis.IndexColor(7)
48 case "gray", "grey":
49 *c = vaxis.IndexColor(8)
50 case "red":
51 *c = vaxis.IndexColor(9)
52 case "lime":
53 *c = vaxis.IndexColor(10)
54 case "yellow":
55 *c = vaxis.IndexColor(11)
56 case "blue":
57 *c = vaxis.IndexColor(12)
58 case "fuschia":
59 *c = vaxis.IndexColor(13)
60 case "aqua":
61 *c = vaxis.IndexColor(14)
62 case "white":
63 *c = vaxis.IndexColor(15)
64 default:
65 ok = false
66 }
67 if ok {
68 return nil
69 }
70
71 code, err := strconv.Atoi(s)
72 if err != nil {
73 return err
74 }
75
76 if code == -1 {
77 *c = ui.ColorDefault
78 return nil
79 }
80
81 if code < 0 || code > 255 {
82 return fmt.Errorf("color code must be between 0-255. If you meant to use true colors, use #aabbcc notation")
83 }
84
85 *c = vaxis.IndexColor(uint8(code))
86
87 return nil
88}
89
90type Config struct {
91 Addr string
92 Nick string
93 Real string
94 User string
95 Password *string
96 TLS bool
97 TLSSkipVerify bool
98
99 Channels []string
100
101 ShowTypings bool
102 Typings bool
103 Mouse bool
104 NickPrefix bool
105 Buttons bool
106
107 Highlights []string
108 OnHighlightPath string
109 OnHighlightBeep bool
110 NickColWidth int
111 ChanColWidth int
112 ChanColEnabled bool
113 MemberColWidth int
114 MemberColEnabled bool
115 QuitMessages bool
116 TextMaxWidth int
117 StatusEnabled bool
118 Shortcuts map[string][]string
119
120 Colors ui.ConfigColors
121
122 Debug bool
123 Transient bool
124 LocalIntegrations bool
125
126 WithTTY string
127 WithConsole console.Console
128}
129
130func DefaultHighlightPath() (string, error) {
131 configDir, err := os.UserConfigDir()
132 if err != nil {
133 return "", err
134 }
135 return path.Join(configDir, "senpai", "highlight"), nil
136}
137
138func Defaults() Config {
139 return Config{
140 Addr: "",
141 Nick: "",
142 Real: "",
143 User: "",
144 Password: nil,
145 TLS: true,
146 TLSSkipVerify: false,
147 Channels: nil,
148 ShowTypings: true,
149 NickPrefix: true,
150 Typings: true,
151 Mouse: true,
152 Buttons: true,
153 Highlights: nil,
154 OnHighlightPath: "",
155 OnHighlightBeep: false,
156 NickColWidth: 14,
157 ChanColWidth: 16,
158 ChanColEnabled: true,
159 MemberColWidth: 16,
160 MemberColEnabled: true,
161 QuitMessages: true,
162 TextMaxWidth: 0,
163 StatusEnabled: true,
164 Colors: ui.ConfigColors{
165 Status: ui.ColorDefault, // Overriden by UI later.
166 Prompt: ui.ColorDefault,
167 Unread: ui.ColorDefault,
168 Nicks: ui.ColorScheme{
169 Type: ui.ColorSchemeBase,
170 Others: ui.ColorDefault,
171 },
172 },
173 Shortcuts: make(map[string][]string),
174 Debug: false,
175 Transient: false,
176 LocalIntegrations: true,
177 }
178}
179
180func ParseAddr(addr string, cfg *Config) error {
181 if addr == "" {
182 return nil
183 }
184 if !strings.Contains(addr, "://") {
185 addr = "irc://" + addr
186 }
187 u, err := url.Parse(addr)
188 if err != nil && strings.Contains(addr, "%") && !strings.Contains(addr, "%25") {
189 // Escape any unescaped IPv6 zone identifiers, since it is not intuitive that they should be escaped,
190 // especially when no scheme is provided.
191 addr = strings.ReplaceAll(addr, "%", "%25")
192 u, err = url.Parse(addr)
193 }
194 if err != nil {
195 return err
196 }
197 switch u.Scheme {
198 case "ircs+insecure":
199 cfg.TLS = true
200 cfg.TLSSkipVerify = true
201 case "ircs":
202 cfg.TLS = true
203 case "irc+insecure":
204 cfg.TLS = false
205 case "irc":
206 // Could be TLS or plaintext, keep TLS as is.
207 default:
208 return fmt.Errorf("invalid IRC addr scheme: %v", addr)
209 }
210 if u := u.User; u != nil {
211 cfg.User = u.Username()
212 if p, ok := u.Password(); ok {
213 cfg.Password = &p
214 } else {
215 cfg.Password = nil
216 }
217 }
218 cfg.Addr = u.Host
219 target, _, _ := strings.Cut(strings.TrimLeft(u.Path, "/"), "/")
220 if target != "" {
221 cfg.Channels = []string{target}
222 }
223 return nil
224}
225
226func LoadConfigFile(filename string) (Config, error) {
227 cfg := Defaults()
228
229 err := unmarshal(filename, &cfg)
230 if err != nil {
231 return Config{}, err
232 }
233 if err := ParseAddr(cfg.Addr, &cfg); err != nil {
234 return Config{}, err
235 }
236 return cfg, nil
237}
238
239func unmarshal(filename string, cfg *Config) (err error) {
240 directives, err := scfg.Load(filename)
241 if err != nil {
242 return fmt.Errorf("error parsing scfg: %w", err)
243 }
244
245 for _, d := range directives {
246 switch d.Name {
247 case "address":
248 if err := d.ParseParams(&cfg.Addr); err != nil {
249 return err
250 }
251 case "nickname":
252 if err := d.ParseParams(&cfg.Nick); err != nil {
253 return err
254 }
255 case "username":
256 if err := d.ParseParams(&cfg.User); err != nil {
257 return err
258 }
259 case "realname":
260 if err := d.ParseParams(&cfg.Real); err != nil {
261 return err
262 }
263 case "password":
264 // if a password-cmd is provided, don't use this value
265 if directives.Get("password-cmd") != nil {
266 continue
267 }
268
269 var password string
270 if err := d.ParseParams(&password); err != nil {
271 return err
272 }
273 cfg.Password = &password
274 case "password-cmd":
275 var cmdName string
276 if err := d.ParseParams(&cmdName); err != nil {
277 return err
278 }
279
280 cmd := exec.Command(cmdName, d.Params[1:]...)
281 var stdout []byte
282 if stdout, err = cmd.Output(); err != nil {
283 return fmt.Errorf("error running password command: %v", err)
284 }
285
286 passCmdOut := strings.Split(string(stdout), "\n")
287 if len(passCmdOut) < 1 || strings.TrimSpace(passCmdOut[0]) == "" {
288 return fmt.Errorf("password command returned no data")
289 }
290 cfg.Password = &passCmdOut[0]
291 case "channel":
292 // TODO: does this work with soju.im/bouncer-networks extension?
293 cfg.Channels = append(cfg.Channels, d.Params...)
294 case "highlight":
295 cfg.Highlights = append(cfg.Highlights, d.Params...)
296 case "on-highlight-path":
297 if err := d.ParseParams(&cfg.OnHighlightPath); err != nil {
298 return err
299 }
300 case "on-highlight-beep":
301 var onHighlightBeep string
302 if err := d.ParseParams(&onHighlightBeep); err != nil {
303 return err
304 }
305
306 if cfg.OnHighlightBeep, err = strconv.ParseBool(onHighlightBeep); err != nil {
307 return err
308 }
309 case "pane-widths":
310 for _, child := range d.Children {
311 switch child.Name {
312 case "nicknames":
313 var nicknames string
314 if err := child.ParseParams(&nicknames); err != nil {
315 return err
316 }
317
318 if cfg.NickColWidth, err = strconv.Atoi(nicknames); err != nil {
319 return err
320 }
321 case "channels":
322 var channelsStr string
323 if err := child.ParseParams(&channelsStr); err != nil {
324 return err
325 }
326 channels, err := strconv.Atoi(channelsStr)
327 if err != nil {
328 return err
329 }
330 if channels <= 0 {
331 cfg.ChanColEnabled = false
332 if channels < 0 {
333 cfg.ChanColWidth = -channels
334 }
335 } else {
336 cfg.ChanColWidth = channels
337 }
338 case "members":
339 var membersStr string
340 if err := child.ParseParams(&membersStr); err != nil {
341 return err
342 }
343 members, err := strconv.Atoi(membersStr)
344 if err != nil {
345 return err
346 }
347 if members <= 0 {
348 cfg.MemberColEnabled = false
349 if members < 0 {
350 cfg.MemberColWidth = -members
351 }
352 } else {
353 cfg.MemberColWidth = members
354 }
355 case "text":
356 var text string
357 if err := child.ParseParams(&text); err != nil {
358 return err
359 }
360
361 if cfg.TextMaxWidth, err = strconv.Atoi(text); err != nil {
362 return err
363 }
364 default:
365 return fmt.Errorf("unknown directive %q", child.Name)
366 }
367 }
368 case "tls":
369 var tls string
370 if err := d.ParseParams(&tls); err != nil {
371 return err
372 }
373
374 if cfg.TLS, err = strconv.ParseBool(tls); err != nil {
375 return err
376 }
377 case "show-typings":
378 var showTypings string
379 if err := d.ParseParams(&showTypings); err != nil {
380 return err
381 }
382
383 if cfg.ShowTypings, err = strconv.ParseBool(showTypings); err != nil {
384 return err
385 }
386 case "typings":
387 var typings string
388 if err := d.ParseParams(&typings); err != nil {
389 return err
390 }
391
392 if cfg.Typings, err = strconv.ParseBool(typings); err != nil {
393 return err
394 }
395 case "mouse":
396 var mouse string
397 if err := d.ParseParams(&mouse); err != nil {
398 return err
399 }
400
401 if cfg.Mouse, err = strconv.ParseBool(mouse); err != nil {
402 return err
403 }
404 case "buttons":
405 var buttons string
406 if err := d.ParseParams(&buttons); err != nil {
407 return err
408 }
409
410 if cfg.Buttons, err = strconv.ParseBool(buttons); err != nil {
411 return err
412 }
413 case "nick-prefix":
414 var nickPrefix string
415 if err := d.ParseParams(&nickPrefix); err != nil {
416 return err
417 }
418 if cfg.NickPrefix, err = strconv.ParseBool(nickPrefix); err != nil {
419 return err
420 }
421 case "colors":
422 for _, child := range d.Children {
423 var colorStr string
424 if err := child.ParseParams(&colorStr); err != nil {
425 return err
426 }
427
428 switch child.Name {
429 case "nicks":
430 switch colorStr {
431 case "base":
432 cfg.Colors.Nicks.Type = ui.ColorSchemeBase
433 case "extended":
434 cfg.Colors.Nicks.Type = ui.ColorSchemeExtended
435 case "fixed":
436 cfg.Colors.Nicks.Type = ui.ColorSchemeFixed
437 if len(child.Params) >= 2 {
438 if err = parseColor(child.Params[1], &cfg.Colors.Nicks.Others); err != nil {
439 return err
440 }
441 }
442 if len(child.Params) >= 3 {
443 if err = parseColor(child.Params[2], &cfg.Colors.Nicks.Self); err != nil {
444 return err
445 }
446 }
447 case "self":
448 var selfStr string
449 if err := child.ParseParams(nil, &selfStr); err != nil {
450 return err
451 }
452 if err = parseColor(selfStr, &cfg.Colors.Nicks.Self); err != nil {
453 return err
454 }
455 default:
456 return fmt.Errorf("unknown nick color scheme %q", colorStr)
457 }
458 continue
459 case "status":
460 if colorStr == "disabled" {
461 cfg.StatusEnabled = false
462 continue
463 }
464 }
465
466 var color vaxis.Color
467 if err = parseColor(colorStr, &color); err != nil {
468 return err
469 }
470 switch child.Name {
471 case "prompt":
472 cfg.Colors.Prompt = color
473 case "unread":
474 cfg.Colors.Unread = color
475 case "status":
476 cfg.Colors.Status = color
477 default:
478 return fmt.Errorf("unknown colors directive %q", child.Name)
479 }
480 }
481 case "shortcuts":
482 for _, child := range d.Children {
483 if err := child.ParseParams(nil); err != nil {
484 return err
485 }
486 cfg.Shortcuts[child.Name] = child.Params
487 }
488 case "quitmessages":
489 var quitmessages string
490 if err := d.ParseParams(&quitmessages); err != nil {
491 return err
492 }
493
494 if cfg.QuitMessages, err = strconv.ParseBool(quitmessages); err != nil {
495 return err
496 }
497 case "debug":
498 var debug string
499 if err := d.ParseParams(&debug); err != nil {
500 return err
501 }
502
503 if cfg.Debug, err = strconv.ParseBool(debug); err != nil {
504 return err
505 }
506 case "transient":
507 var transient string
508 if err := d.ParseParams(&transient); err != nil {
509 return err
510 }
511 if cfg.Transient, err = strconv.ParseBool(transient); err != nil {
512 return err
513 }
514 case "local-integrations":
515 var localIntegrations string
516 if err := d.ParseParams(&localIntegrations); err != nil {
517 return err
518 }
519 if cfg.LocalIntegrations, err = strconv.ParseBool(localIntegrations); err != nil {
520 return err
521 }
522 default:
523 return fmt.Errorf("unknown directive %q", d.Name)
524 }
525 }
526
527 return
528}