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}