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
106 Highlights []string
107 OnHighlightPath string
108 OnHighlightBeep bool
109 NickColWidth int
110 ChanColWidth int
111 ChanColEnabled bool
112 MemberColWidth int
113 MemberColEnabled bool
114 QuitMessages bool
115 TextMaxWidth int
116 StatusEnabled bool
117 Shortcuts map[string][]string
118
119 Colors ui.ConfigColors
120
121 Debug bool
122 Transient bool
123 LocalIntegrations bool
124
125 WithTTY string
126 WithConsole console.Console
127}
128
129func DefaultHighlightPath() (string, error) {
130 configDir, err := os.UserConfigDir()
131 if err != nil {
132 return "", err
133 }
134 return path.Join(configDir, "senpai", "highlight"), nil
135}
136
137func Defaults() Config {
138 return Config{
139 Addr: "",
140 Nick: "",
141 Real: "",
142 User: "",
143 Password: nil,
144 TLS: true,
145 TLSSkipVerify: false,
146 Channels: nil,
147 ShowTypings: true,
148 Typings: true,
149 Mouse: true,
150 Highlights: nil,
151 OnHighlightPath: "",
152 OnHighlightBeep: false,
153 NickColWidth: 14,
154 ChanColWidth: 16,
155 ChanColEnabled: true,
156 MemberColWidth: 16,
157 MemberColEnabled: true,
158 QuitMessages: true,
159 TextMaxWidth: 0,
160 StatusEnabled: true,
161 Colors: ui.ConfigColors{
162 Status: ui.ColorDefault, // Overriden by UI later.
163 Prompt: ui.ColorDefault,
164 Unread: ui.ColorDefault,
165 Nicks: ui.ColorScheme{
166 Type: ui.ColorSchemeBase,
167 Others: ui.ColorDefault,
168 },
169 },
170 Shortcuts: make(map[string][]string),
171 Debug: false,
172 Transient: false,
173 LocalIntegrations: true,
174 }
175}
176
177func ParseAddr(addr string, cfg *Config) error {
178 if addr == "" {
179 return nil
180 }
181 if !strings.Contains(addr, "://") {
182 addr = "irc://" + addr
183 }
184 u, err := url.Parse(addr)
185 if err != nil && strings.Contains(addr, "%") && !strings.Contains(addr, "%25") {
186 // Escape any unescaped IPv6 zone identifiers, since it is not intuitive that they should be escaped,
187 // especially when no scheme is provided.
188 addr = strings.ReplaceAll(addr, "%", "%25")
189 u, err = url.Parse(addr)
190 }
191 if err != nil {
192 return err
193 }
194 switch u.Scheme {
195 case "ircs+insecure":
196 cfg.TLS = true
197 cfg.TLSSkipVerify = true
198 case "ircs":
199 cfg.TLS = true
200 case "irc+insecure":
201 cfg.TLS = false
202 case "irc":
203 // Could be TLS or plaintext, keep TLS as is.
204 default:
205 return fmt.Errorf("invalid IRC addr scheme: %v", addr)
206 }
207 if u := u.User; u != nil {
208 cfg.User = u.Username()
209 if p, ok := u.Password(); ok {
210 cfg.Password = &p
211 } else {
212 cfg.Password = nil
213 }
214 }
215 cfg.Addr = u.Host
216 target, _, _ := strings.Cut(strings.TrimLeft(u.Path, "/"), "/")
217 if target != "" {
218 cfg.Channels = []string{target}
219 }
220 return nil
221}
222
223func LoadConfigFile(filename string) (Config, error) {
224 cfg := Defaults()
225
226 err := unmarshal(filename, &cfg)
227 if err != nil {
228 return Config{}, err
229 }
230 if err := ParseAddr(cfg.Addr, &cfg); err != nil {
231 return Config{}, err
232 }
233 return cfg, nil
234}
235
236func unmarshal(filename string, cfg *Config) (err error) {
237 directives, err := scfg.Load(filename)
238 if err != nil {
239 return fmt.Errorf("error parsing scfg: %w", err)
240 }
241
242 for _, d := range directives {
243 switch d.Name {
244 case "address":
245 if err := d.ParseParams(&cfg.Addr); err != nil {
246 return err
247 }
248 case "nickname":
249 if err := d.ParseParams(&cfg.Nick); err != nil {
250 return err
251 }
252 case "username":
253 if err := d.ParseParams(&cfg.User); err != nil {
254 return err
255 }
256 case "realname":
257 if err := d.ParseParams(&cfg.Real); err != nil {
258 return err
259 }
260 case "password":
261 // if a password-cmd is provided, don't use this value
262 if directives.Get("password-cmd") != nil {
263 continue
264 }
265
266 var password string
267 if err := d.ParseParams(&password); err != nil {
268 return err
269 }
270 cfg.Password = &password
271 case "password-cmd":
272 var cmdName string
273 if err := d.ParseParams(&cmdName); err != nil {
274 return err
275 }
276
277 cmd := exec.Command(cmdName, d.Params[1:]...)
278 var stdout []byte
279 if stdout, err = cmd.Output(); err != nil {
280 return fmt.Errorf("error running password command: %v", err)
281 }
282
283 passCmdOut := strings.Split(string(stdout), "\n")
284 if len(passCmdOut) < 1 || strings.TrimSpace(passCmdOut[0]) == "" {
285 return fmt.Errorf("password command returned no data")
286 }
287 cfg.Password = &passCmdOut[0]
288 case "channel":
289 // TODO: does this work with soju.im/bouncer-networks extension?
290 cfg.Channels = append(cfg.Channels, d.Params...)
291 case "highlight":
292 cfg.Highlights = append(cfg.Highlights, d.Params...)
293 case "on-highlight-path":
294 if err := d.ParseParams(&cfg.OnHighlightPath); err != nil {
295 return err
296 }
297 case "on-highlight-beep":
298 var onHighlightBeep string
299 if err := d.ParseParams(&onHighlightBeep); err != nil {
300 return err
301 }
302
303 if cfg.OnHighlightBeep, err = strconv.ParseBool(onHighlightBeep); err != nil {
304 return err
305 }
306 case "pane-widths":
307 for _, child := range d.Children {
308 switch child.Name {
309 case "nicknames":
310 var nicknames string
311 if err := child.ParseParams(&nicknames); err != nil {
312 return err
313 }
314
315 if cfg.NickColWidth, err = strconv.Atoi(nicknames); err != nil {
316 return err
317 }
318 case "channels":
319 var channelsStr string
320 if err := child.ParseParams(&channelsStr); err != nil {
321 return err
322 }
323 channels, err := strconv.Atoi(channelsStr)
324 if err != nil {
325 return err
326 }
327 if channels <= 0 {
328 cfg.ChanColEnabled = false
329 if channels < 0 {
330 cfg.ChanColWidth = -channels
331 }
332 } else {
333 cfg.ChanColWidth = channels
334 }
335 case "members":
336 var membersStr string
337 if err := child.ParseParams(&membersStr); err != nil {
338 return err
339 }
340 members, err := strconv.Atoi(membersStr)
341 if err != nil {
342 return err
343 }
344 if members <= 0 {
345 cfg.MemberColEnabled = false
346 if members < 0 {
347 cfg.MemberColWidth = -members
348 }
349 } else {
350 cfg.MemberColWidth = members
351 }
352 case "text":
353 var text string
354 if err := child.ParseParams(&text); err != nil {
355 return err
356 }
357
358 if cfg.TextMaxWidth, err = strconv.Atoi(text); err != nil {
359 return err
360 }
361 default:
362 return fmt.Errorf("unknown directive %q", child.Name)
363 }
364 }
365 case "tls":
366 var tls string
367 if err := d.ParseParams(&tls); err != nil {
368 return err
369 }
370
371 if cfg.TLS, err = strconv.ParseBool(tls); err != nil {
372 return err
373 }
374 case "show-typings":
375 var showTypings string
376 if err := d.ParseParams(&showTypings); err != nil {
377 return err
378 }
379
380 if cfg.ShowTypings, err = strconv.ParseBool(showTypings); err != nil {
381 return err
382 }
383 case "typings":
384 var typings string
385 if err := d.ParseParams(&typings); err != nil {
386 return err
387 }
388
389 if cfg.Typings, err = strconv.ParseBool(typings); err != nil {
390 return err
391 }
392 case "mouse":
393 var mouse string
394 if err := d.ParseParams(&mouse); err != nil {
395 return err
396 }
397
398 if cfg.Mouse, err = strconv.ParseBool(mouse); err != nil {
399 return err
400 }
401 case "nick-prefix":
402 var nickPrefix string
403 if err := d.ParseParams(&nickPrefix); err != nil {
404 return err
405 }
406 if cfg.NickPrefix, err = strconv.ParseBool(nickPrefix); err != nil {
407 return err
408 }
409 case "colors":
410 for _, child := range d.Children {
411 var colorStr string
412 if err := child.ParseParams(&colorStr); err != nil {
413 return err
414 }
415
416 switch child.Name {
417 case "nicks":
418 switch colorStr {
419 case "base":
420 cfg.Colors.Nicks.Type = ui.ColorSchemeBase
421 case "extended":
422 cfg.Colors.Nicks.Type = ui.ColorSchemeExtended
423 case "fixed":
424 cfg.Colors.Nicks.Type = ui.ColorSchemeFixed
425 if len(child.Params) >= 2 {
426 if err = parseColor(child.Params[1], &cfg.Colors.Nicks.Others); err != nil {
427 return err
428 }
429 }
430 if len(child.Params) >= 3 {
431 if err = parseColor(child.Params[2], &cfg.Colors.Nicks.Self); err != nil {
432 return err
433 }
434 }
435 case "self":
436 var selfStr string
437 if err := child.ParseParams(nil, &selfStr); err != nil {
438 return err
439 }
440 if err = parseColor(selfStr, &cfg.Colors.Nicks.Self); err != nil {
441 return err
442 }
443 default:
444 return fmt.Errorf("unknown nick color scheme %q", colorStr)
445 }
446 continue
447 case "status":
448 if colorStr == "disabled" {
449 cfg.StatusEnabled = false
450 continue
451 }
452 }
453
454 var color vaxis.Color
455 if err = parseColor(colorStr, &color); err != nil {
456 return err
457 }
458 switch child.Name {
459 case "prompt":
460 cfg.Colors.Prompt = color
461 case "unread":
462 cfg.Colors.Unread = color
463 case "status":
464 cfg.Colors.Status = color
465 default:
466 return fmt.Errorf("unknown colors directive %q", child.Name)
467 }
468 }
469 case "shortcuts":
470 for _, child := range d.Children {
471 if err := child.ParseParams(nil); err != nil {
472 return err
473 }
474 cfg.Shortcuts[child.Name] = child.Params
475 }
476 case "quitmessages":
477 var quitmessages string
478 if err := d.ParseParams(&quitmessages); err != nil {
479 return err
480 }
481
482 if cfg.QuitMessages, err = strconv.ParseBool(quitmessages); err != nil {
483 return err
484 }
485 case "debug":
486 var debug string
487 if err := d.ParseParams(&debug); err != nil {
488 return err
489 }
490
491 if cfg.Debug, err = strconv.ParseBool(debug); err != nil {
492 return err
493 }
494 case "transient":
495 var transient string
496 if err := d.ParseParams(&transient); err != nil {
497 return err
498 }
499 if cfg.Transient, err = strconv.ParseBool(transient); err != nil {
500 return err
501 }
502 case "local-integrations":
503 var localIntegrations string
504 if err := d.ParseParams(&localIntegrations); err != nil {
505 return err
506 }
507 if cfg.LocalIntegrations, err = strconv.ParseBool(localIntegrations); err != nil {
508 return err
509 }
510 default:
511 return fmt.Errorf("unknown directive %q", d.Name)
512 }
513 }
514
515 return
516}