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}