commit b469005

Kalyan Sriram  ·  2021-11-22 18:37:40 +0000 UTC
parent 6014ba1
config: replace YAML with scfg config format

This patch replaces the YAML configuration format with scfg
(https://git.sr.ht/~emersion/scfg).

Additionally, a few things about configuration are cleaned up:
* abbreviated names are expanded (addr -> address, nick -> nickname)
* negative bools switched to positive (no-tls -> tls)
* independent column widths are grouped under the "pane-width"
  directive
* implementation of default configuration values is improved
* password-cmd is executed directly (with scfg field parsing)
  instead of with "sh -c".
* on-highlight is now a file, $XDG_CONFIG_HOME/senpai/highlight by
  default, which can be changed with the on-highlight-path directive
9 files changed,  +358, -151
M app.go
M go.mod
M go.sum
+5, -5
 1@@ -14,12 +14,12 @@ senpai is an IRC client that works best with bouncers:
 2 
 3 ```shell
 4 mkdir -p ~/.config/senpai
 5-cat <<EOF >~/.config/senpai/senpai.yaml
 6-addr: chat.sr.ht
 7-nick: senpai
 8-password: "my password can't be this cute (2010)"
 9+cat <<EOF >~/.config/senpai/senpai.scfg
10+address chat.sr.ht
11+nickname senpai
12+password "my password can't be this cute (2010)"
13 # alternatively, specify a command to fetch your password:
14-# password-cmd: "gopass show irc/<username>"
15+# password-cmd  gopass show irc/<username>
16 EOF
17 go run ./cmd/senpai
18 ```
M app.go
+31, -17
  1@@ -2,6 +2,7 @@ package senpai
  2 
  3 import (
  4 	"crypto/tls"
  5+	"errors"
  6 	"fmt"
  7 	"net"
  8 	"os"
  9@@ -111,10 +112,7 @@ func NewApp(cfg Config) (app *App, err error) {
 10 		}
 11 	}
 12 
 13-	mouse := true
 14-	if cfg.Mouse != nil {
 15-		mouse = *cfg.Mouse
 16-	}
 17+	mouse := cfg.Mouse
 18 
 19 	app.win, err = ui.New(ui.Config{
 20 		NickColWidth:   cfg.NickColWidth,
 21@@ -320,10 +318,10 @@ func (app *App) tryConnect() (conn net.Conn, err error) {
 22 	if colonIdx <= bracketIdx {
 23 		// either colonIdx < 0, or the last colon is before a ']' (end
 24 		// of IPv6 address. -> missing port
 25-		if app.cfg.NoTLS {
 26-			addr += ":6667"
 27-		} else {
 28+		if app.cfg.TLS {
 29 			addr += ":6697"
 30+		} else {
 31+			addr += ":6667"
 32 		}
 33 	}
 34 
 35@@ -332,7 +330,7 @@ func (app *App) tryConnect() (conn net.Conn, err error) {
 36 		return
 37 	}
 38 
 39-	if !app.cfg.NoTLS {
 40+	if app.cfg.TLS {
 41 		host, _, _ := net.SplitHostPort(addr) // should succeed since net.Dial did.
 42 		conn = tls.Client(conn, &tls.Config{
 43 			ServerName: host,
 44@@ -889,22 +887,38 @@ func (app *App) isHighlight(s *irc.Session, content string) bool {
 45 	return false
 46 }
 47 
 48-// notifyHighlight executes the "on-highlight" command according to the given
 49+// notifyHighlight executes the script at "on-highlight-path" according to the given
 50 // message context.
 51 func (app *App) notifyHighlight(buffer, nick, content string) {
 52-	if app.cfg.OnHighlight == "" {
 53-		return
 54+	path := app.cfg.OnHighlightPath
 55+	if path == "" {
 56+		defaultHighlightPath, err := DefaultHighlightPath()
 57+		if err != nil {
 58+			return
 59+		}
 60+		path = defaultHighlightPath
 61 	}
 62-	sh, err := exec.LookPath("sh")
 63-	if err != nil {
 64+
 65+	netID, curBuffer := app.win.CurrentBuffer()
 66+	if _, err := os.Stat(app.cfg.OnHighlightPath); errors.Is(err, os.ErrNotExist) {
 67+		// only error out if the user specified a highlight path
 68+		// if default path unreachable, simple bail
 69+		if app.cfg.OnHighlightPath != "" {
 70+			body := fmt.Sprintf("Unable to find on-highlight command at path: %q", app.cfg.OnHighlightPath)
 71+			app.addStatusLine(netID, ui.Line{
 72+				At:        time.Now(),
 73+				Head:      "!!",
 74+				HeadColor: tcell.ColorRed,
 75+				Body:      ui.PlainString(body),
 76+			})
 77+		}
 78 		return
 79 	}
 80-	netID, curBuffer := app.win.CurrentBuffer()
 81 	here := "0"
 82 	if buffer == curBuffer { // TODO also check netID
 83 		here = "1"
 84 	}
 85-	cmd := exec.Command(sh, "-c", app.cfg.OnHighlight)
 86+	cmd := exec.Command(app.cfg.OnHighlightPath)
 87 	cmd.Env = append(os.Environ(),
 88 		fmt.Sprintf("BUFFER=%s", buffer),
 89 		fmt.Sprintf("HERE=%s", here),
 90@@ -913,7 +927,7 @@ func (app *App) notifyHighlight(buffer, nick, content string) {
 91 	)
 92 	output, err := cmd.CombinedOutput()
 93 	if err != nil {
 94-		body := fmt.Sprintf("Failed to invoke on-highlight command: %v. Output: %q", err, string(output))
 95+		body := fmt.Sprintf("Failed to invoke on-highlight command at path: %v. Output: %q", err, string(output))
 96 		app.addStatusLine(netID, ui.Line{
 97 			At:        time.Now(),
 98 			Head:      "!!",
 99@@ -928,7 +942,7 @@ func (app *App) notifyHighlight(buffer, nick, content string) {
100 func (app *App) typing() {
101 	netID, buffer := app.win.CurrentBuffer()
102 	s := app.sessions[netID]
103-	if s == nil || app.cfg.NoTypings {
104+	if s == nil || !app.cfg.Typings {
105 		return
106 	}
107 	if buffer == "" {
+1, -1
1@@ -34,7 +34,7 @@ func main() {
2 		if err != nil {
3 			panic(err)
4 		}
5-		configPath = path.Join(configDir, "senpai", "senpai.yaml")
6+		configPath = path.Join(configDir, "senpai", "senpai.scfg")
7 	}
8 
9 	cfg, err := senpai.LoadConfigFile(configPath)
+2, -2
 1@@ -108,7 +108,7 @@ func parseFlags() {
 2 			if err != nil {
 3 				panic(err)
 4 			}
 5-			configPath = path.Join(configDir, "senpai", "senpai.yaml")
 6+			configPath = path.Join(configDir, "senpai", "senpai.scfg")
 7 		}
 8 
 9 		cfg, err := senpai.LoadConfigFile(configPath)
10@@ -121,6 +121,6 @@ func parseFlags() {
11 		if cfg.Password != nil {
12 			password = *cfg.Password
13 		}
14-		useTLS = !cfg.NoTLS
15+		useTLS = cfg.TLS
16 	}
17 }
+215, -57
  1@@ -3,21 +3,20 @@ package senpai
  2 import (
  3 	"errors"
  4 	"fmt"
  5-	"io/ioutil"
  6+	"os"
  7 	"os/exec"
  8+	"path"
  9 	"strconv"
 10 	"strings"
 11 
 12 	"github.com/gdamore/tcell/v2"
 13 
 14-	"gopkg.in/yaml.v2"
 15+	"git.sr.ht/~emersion/go-scfg"
 16 )
 17 
 18 type Color tcell.Color
 19 
 20-func (c *Color) UnmarshalText(data []byte) error {
 21-	s := string(data)
 22-
 23+func parseColor(s string, c *Color) error {
 24 	if strings.HasPrefix(s, "#") {
 25 		hex, err := strconv.ParseInt(s[1:], 16, 32)
 26 		if err != nil {
 27@@ -47,34 +46,73 @@ func (c *Color) UnmarshalText(data []byte) error {
 28 	return nil
 29 }
 30 
 31+type ConfigColors struct {
 32+	Prompt Color
 33+}
 34+
 35 type Config struct {
 36-	Addr        string
 37-	Nick        string
 38-	Real        string
 39-	User        string
 40-	Password    *string
 41-	PasswordCmd string `yaml:"password-cmd"`
 42-	NoTLS       bool   `yaml:"no-tls"`
 43-	Channels    []string
 44-
 45-	NoTypings bool `yaml:"no-typings"`
 46-	Mouse     *bool
 47-
 48-	Highlights     []string
 49-	OnHighlight    string `yaml:"on-highlight"`
 50-	NickColWidth   int    `yaml:"nick-column-width"`
 51-	ChanColWidth   int    `yaml:"chan-column-width"`
 52-	MemberColWidth int    `yaml:"member-column-width"`
 53-
 54-	Colors struct {
 55-		Prompt Color
 56-	}
 57+	Addr     string
 58+	Nick     string
 59+	Real     string
 60+	User     string
 61+	Password *string
 62+	TLS      bool
 63+	Channels []string
 64+
 65+	Typings bool
 66+	Mouse   bool
 67+
 68+	Highlights      []string
 69+	OnHighlightPath string
 70+	NickColWidth    int
 71+	ChanColWidth    int
 72+	MemberColWidth  int
 73+
 74+	Colors ConfigColors
 75 
 76 	Debug bool
 77 }
 78 
 79-func ParseConfig(buf []byte) (cfg Config, err error) {
 80-	err = yaml.Unmarshal(buf, &cfg)
 81+func DefaultHighlightPath() (string, error) {
 82+	configDir, err := os.UserConfigDir()
 83+	if err != nil {
 84+		return "", err
 85+	}
 86+	return path.Join(configDir, "senpai", "highlight"), nil
 87+}
 88+
 89+func Defaults() (cfg Config, err error) {
 90+	cfg = Config{
 91+		Addr:            "",
 92+		Nick:            "",
 93+		Real:            "",
 94+		User:            "",
 95+		Password:        nil,
 96+		TLS:             true,
 97+		Channels:        nil,
 98+		Typings:         true,
 99+		Mouse:           true,
100+		Highlights:      nil,
101+		OnHighlightPath: "",
102+		NickColWidth:    16,
103+		ChanColWidth:    0,
104+		MemberColWidth:  0,
105+		Colors: ConfigColors{
106+			Prompt: Color(tcell.ColorDefault),
107+		},
108+		Debug: false,
109+	}
110+
111+	return
112+}
113+
114+func ParseConfig(filename string) (cfg Config, err error) {
115+	cfg, err = Defaults()
116+	if err != nil {
117+		return
118+	}
119+
120+	err = unmarshal(filename, &cfg)
121 	if err != nil {
122 		return cfg, err
123 	}
124@@ -90,45 +128,165 @@ func ParseConfig(buf []byte) (cfg Config, err error) {
125 	if cfg.Real == "" {
126 		cfg.Real = cfg.Nick
127 	}
128-	if cfg.PasswordCmd != "" {
129-		password, err := runPasswordCmd(cfg.PasswordCmd)
130-		if err != nil {
131-			return cfg, err
132-		}
133-		cfg.Password = &password
134-	}
135-	if cfg.NickColWidth <= 0 {
136-		cfg.NickColWidth = 16
137-	}
138-	if cfg.ChanColWidth < 0 {
139-		cfg.ChanColWidth = 0
140-	}
141-	if cfg.MemberColWidth < 0 {
142-		cfg.MemberColWidth = 0
143-	}
144 	return
145 }
146 
147 func LoadConfigFile(filename string) (cfg Config, err error) {
148-	var buf []byte
149-
150-	buf, err = ioutil.ReadFile(filename)
151-	if err != nil {
152-		return cfg, fmt.Errorf("failed to read the file: %s", err)
153-	}
154-
155-	cfg, err = ParseConfig(buf)
156+	cfg, err = ParseConfig(filename)
157 	if err != nil {
158 		return cfg, fmt.Errorf("invalid content found in the file: %s", err)
159 	}
160 	return
161 }
162 
163-func runPasswordCmd(command string) (password string, err error) {
164-	cmd := exec.Command("sh", "-c", command)
165-	stdout, err := cmd.Output()
166-	if err == nil {
167-		password = strings.TrimSuffix(string(stdout), "\n")
168+func unmarshal(filename string, cfg *Config) (err error) {
169+	directives, err := scfg.Load(filename)
170+	if err != nil {
171+		return fmt.Errorf("error parsing scfg: %s", err)
172+	}
173+
174+	for _, d := range directives {
175+		switch d.Name {
176+		case "address":
177+			if err := d.ParseParams(&cfg.Addr); err != nil {
178+				return err
179+			}
180+		case "nickname":
181+			if err := d.ParseParams(&cfg.Nick); err != nil {
182+				return err
183+			}
184+		case "username":
185+			if err := d.ParseParams(&cfg.User); err != nil {
186+				return err
187+			}
188+		case "realname":
189+			if err := d.ParseParams(&cfg.Real); err != nil {
190+				return err
191+			}
192+		case "password":
193+			// if a password-cmd is provided, don't use this value
194+			if directives.Get("password-cmd") != nil {
195+				continue
196+			}
197+
198+			var password string
199+			if err := d.ParseParams(&password); err != nil {
200+				return err
201+			}
202+			cfg.Password = &password
203+		case "password-cmd":
204+			var cmdName string
205+			if err := d.ParseParams(&cmdName); err != nil {
206+				return err
207+			}
208+
209+			cmd := exec.Command(cmdName, d.Params[1:]...)
210+			var stdout []byte
211+			if stdout, err = cmd.Output(); err != nil {
212+				return fmt.Errorf("error running password command: %s", err)
213+			}
214+
215+			password := strings.TrimSuffix(string(stdout), "\n")
216+			cfg.Password = &password
217+		case "channel":
218+			// TODO: does this work with soju.im/bouncer-networks extension?
219+			cfg.Channels = append(cfg.Channels, d.Params...)
220+		case "highlight":
221+			cfg.Highlights = append(cfg.Highlights, d.Params...)
222+		case "on-highlight-path":
223+			if err := d.ParseParams(&cfg.OnHighlightPath); err != nil {
224+				return err
225+			}
226+		case "pane-widths":
227+			for _, child := range d.Children {
228+				switch child.Name {
229+				case "nicknames":
230+					var nicknames string
231+					if err := child.ParseParams(&nicknames); err != nil {
232+						return err
233+					}
234+
235+					if cfg.NickColWidth, err = strconv.Atoi(nicknames); err != nil {
236+						return err
237+					}
238+				case "channels":
239+					var channels string
240+					if err := child.ParseParams(&channels); err != nil {
241+						return err
242+					}
243+
244+					if cfg.ChanColWidth, err = strconv.Atoi(channels); err != nil {
245+						return err
246+					}
247+				case "members":
248+					var members string
249+					if err := child.ParseParams(&members); err != nil {
250+						return err
251+					}
252+
253+					if cfg.MemberColWidth, err = strconv.Atoi(members); err != nil {
254+						return err
255+					}
256+				default:
257+					return fmt.Errorf("unknown directive %q", child.Name)
258+				}
259+			}
260+		case "tls":
261+			var tls string
262+			if err := d.ParseParams(&tls); err != nil {
263+				return err
264+			}
265+
266+			if cfg.TLS, err = strconv.ParseBool(tls); err != nil {
267+				return err
268+			}
269+		case "typings":
270+			var typings string
271+			if err := d.ParseParams(&typings); err != nil {
272+				return err
273+			}
274+
275+			if cfg.Typings, err = strconv.ParseBool(typings); err != nil {
276+				return err
277+			}
278+		case "mouse":
279+			var mouse string
280+			if err := d.ParseParams(&mouse); err != nil {
281+				return err
282+			}
283+
284+			if cfg.Mouse, err = strconv.ParseBool(mouse); err != nil {
285+				return err
286+			}
287+		case "colors":
288+			for _, child := range d.Children {
289+				switch child.Name {
290+				case "prompt":
291+					var prompt string
292+					if err := child.ParseParams(&prompt); err != nil {
293+						return err
294+					}
295+
296+					fmt.Println(prompt)
297+					if err = parseColor(prompt, &cfg.Colors.Prompt); err != nil {
298+						return err
299+					}
300+				default:
301+					return fmt.Errorf("unknown directive %q", child.Name)
302+				}
303+			}
304+		case "debug":
305+			var debug string
306+			if err := d.ParseParams(&debug); err != nil {
307+				return err
308+			}
309+
310+			if cfg.Debug, err = strconv.ParseBool(debug); err != nil {
311+				return err
312+			}
313+		default:
314+			return fmt.Errorf("unknown directive %q", d.Name)
315+		}
316 	}
317 
318 	return
+1, -1
1@@ -31,7 +31,7 @@ extensions, such as:
2 senpai needs a configuration file to start.  It searches for it in the following
3 location:
4 
5-	$XDG_CONFIG_HOME/senpai/senpai.yaml
6+	$XDG_CONFIG_HOME/senpai/senpai.scfg
7 
8 If unset, $XDG_CONFIG_HOME defaults to *~/.config*.
9 
+96, -61
  1@@ -6,34 +6,35 @@ senpai - Configuration file format and settings
  2 
  3 # DESCRIPTION
  4 
  5-A senpai configuration file is a YAML file.
  6+A senpai configuration file is a scfg file (see https://git.sr.ht/~emersion/scfg).
  7+The config file has one directive per line.
  8 
  9 Some settings are required, the others are optional.
 10 
 11 # SETTINGS
 12 
 13-*addr* (required)
 14+*address* (required)
 15 	The address (_host[:port]_) of the IRC server. senpai uses TLS connections
 16 	by default unless you specify *no-tls* option. TLS connections default to
 17 	port 6697, plain-text use port 6667.
 18 
 19-*nick* (required)
 20+*nickname* (required)
 21 	Your nickname, sent with a _NICK_ IRC message. It mustn't contain spaces or
 22 	colons (*:*).
 23 
 24-*real*
 25+*realname*
 26 	Your real name, or actually just a field that will be available to others
 27 	and may contain spaces and colons.  Sent with the _USER_ IRC message.  By
 28 	default, the value of *nick* is used.
 29 
 30-*user*
 31+*username*
 32 	Your username, sent with the _USER_ IRC message and also used for SASL
 33 	authentication.  By default, the value of *nick* is used.
 34 
 35 *password*
 36 	Your password, used for SASL authentication. See also *password-cmd*.
 37 
 38-*password-cmd*
 39+*password-cmd* command [arguments...]
 40 	Alternatively to providing your SASL authentication password directly in
 41 	plaintext, you can specify a command to be run to fetch the password at
 42 	runtime. This is useful if you store your passwords in a separate (probably
 43@@ -41,18 +42,31 @@ Some settings are required, the others are optional.
 44 	_pass_ or _gopass_. If a *password-cmd* is provided, the value of *password*
 45 	will be ignored and the output of *password-cmd* will be used for login.
 46 
 47-*channels*
 48-	A list of channel names that senpai will automatically join at startup and
 49-	server reconnect.
 50+*channel*
 51+	A spaced separated list of channel names that senpai will automatically join 
 52+	at startup and server reconnect. This directive can be specified multiple times.
 53 
 54-*highlights*
 55-	A list of keywords that will trigger a notification and a display indicator
 56-	when said by others.  By default, senpai will use your current nickname.
 57+*highlight*
 58+	A space separated list of keywords that will trigger a notification and a 
 59+	display indicator when said by others. This directive can be specified
 60+	multiple times.
 61 
 62-*on-highlight*
 63-	A command to be executed via _sh_ when you are highlighted.  The following
 64-	environment variables are set with repect to the highlight, THEY MUST APPEAR
 65-	QUOTED IN THE SETTING, OR YOU WILL BE OPEN TO SHELL INJECTION ATTACKS.
 66+	By default, senpai will use your current nickname.
 67+
 68+*on-highlight-path*
 69+	Alternative path to a shell script to be executed when you are highlighted. By default,
 70+	senpai looks for a highlight shell script at $XDG_CONFIG_HOME/senpai/highlight.
 71+	If no file is found at that path, and an alternate path is not provided,
 72+	highlight command execution is disabled.
 73+
 74+	If unset, $XDG_CONFIG_HOME defaults to *~/.config/*.
 75+
 76+	Before the highlight script is executed, the following environment
 77+	variables are populated:
 78+	
 79+	Shell scripts MUST ENSURE VARIABLES appear QUOTED in the script file,
 80+	OR YOU WILL BE OPEN TO SHELL INJECTION ATTACKS. Shell scripts must also 
 81+	ensure characters like '\*' and '?' are not expanded.
 82 
 83 [[ *Environment variable*
 84 :< *Description*
 85@@ -72,65 +86,80 @@ Some settings are required, the others are optional.
 86 	To get around this, you can double the backslash with the following snippet:
 87 
 88 ```
 89-on-highlight: |
 90-    escape() {
 91-        printf "%s" "$1" | sed 's#\\#\\\\#g'
 92-    }
 93-    notify-send "[$BUFFER] $SENDER" "$(escape "$MESSAGE")"
 94+#!/bin/sh
 95+escape() {
 96+	printf "%s" "$1" | sed 's#\\#\\\\#g'
 97+}
 98+
 99+notify-send "[$BUFFER] $SENDER" "$(escape "$MESSAGE")"
100 ```
101 
102-*nick-column-width*
103-	The number of cells that the column for nicknames occupies in the timeline.
104-	By default, 16.
105+*pane-widths* { ... }
106+	Configure the width of various UI panes. 
107 
108-*chan-column-width*
109-	Make the channel list vertical, with a width equals to the given amount of
110-	cells.  By default, the channel list is horizontal.
111+	Pane widths are set as sub-directives of the main *pane-widths* directive:
112+
113+```
114+pane-widths {
115+    nicknames 16
116+}
117+```
118 
119-*member-column-width*
120-	Show the list of channel members on the right of the screen, with a width
121-	equals to the given amount of cells.
122+	This directive supports the following sub-directives:
123 
124-*no-tls*
125-	Disable TLS encryption.  Defaults to false.
126+	*nicknames*
127+		The number of cells that the column for nicknames occupies in the timeline.
128+		By default, 16.
129 
130-*no-typings*
131-	Prevent senpai from sending typing notifications which let others know when
132-	you are typing a message.  Defaults to false.
133+	*channels*
134+		Make the channel list vertical, with a width equals to the given amount of
135+		cells.  By default, the channel list is horizontal.
136+
137+	*members*
138+		Show the list of channel members on the right of the screen, with a width
139+		equals to the given amount of cells.
140+
141+*tls*
142+	Enable TLS encryption.  Defaults to true.
143+
144+*typings*
145+	Send typing notifications which let others know when you are typing a message.
146+	Defaults to true.
147 
148 *mouse*
149 	Enable or disable mouse support.  Defaults to true.
150 
151-*colors*
152+*colors* { ... }
153 	Settings for colors of different UI elements.
154 
155 	Colors are represented as numbers from 0 to 255 for 256 default terminal
156 	colors respectively. -1 has special meaning of default terminal color. To
157 	use true colors, *#*_rrggbb_ notation is supported.
158 
159-	Colors are set as sub-options of the main *colors* option:
160+	Colors are set as sub-directives of the main *colors* directive:
161 
162 ```
163-colors:
164-    prompt: 3 # green
165+colors {
166+    prompt 3 # green
167+}
168 ```
169 
170-[[ *Sub-option*
171+[[ *Sub-directive*
172 :< *Description*
173 |  prompt
174 :  color for ">"-prompt that appears in command mode
175 
176 *debug*
177 	Dump all sent and received data to the home buffer, useful for debugging.
178-	By default, false.
179+	Defaults to false.
180 
181 # EXAMPLES
182 
183 A minimal configuration file to connect to Libera.Chat as "Guest123456":
184 
185 ```
186-addr: irc.libera.chat
187-nick: Guest123456
188+address irc.libera.chat
189+nickname Guest123456
190 ```
191 
192 A more advanced configuration file that enables SASL authentication, fetches the
193@@ -140,24 +169,30 @@ notifications on highlight and decreases the width of the nick column to 12
194 need to know if the terminal emulator that runs senpai has focus):
195 
196 ```
197-addr: irc.libera.chat
198-nick: Guest123456
199-user: senpai
200-real: Guest von Lenon
201-password-cmd: "gopass show irc/guest" # use your favorite CLI password solution here
202-channels: ["#rahxephon"]
203-highlights:
204-	- guest
205-	- senpai
206-on-highlight: |
207-    escape() {
208-        printf "%s" "$1" | sed 's#\\#\\\\#g'
209-    }
210-    FOCUS=$(swaymsg -t get_tree | jq '..|objects|select(.focused==true)|.name' | grep senpai | wc -l)
211-    if [ "$HERE" -eq 0 ] || [ $FOCUS -eq 0 ]; then
212-        notify-send "[$BUFFER] $SENDER" "$(escape "$MESSAGE")"
213-    fi
214-nick-column-width: 12
215+address irc.libera.chat
216+nickname Guest123456
217+username senpai
218+realname "Guest von Lenon"
219+password-cmd gopass show irc/guest # use your favorite CLI password solution here
220+channel "#rahxephon"
221+highlight guest senpai
222+highlight lenon # don't know why you'd split it into multiple lines, but you can if you want
223+pane-widths {
224+	nicknames 12
225+}
226+```
227+
228+And the highlight file (*~/.config/senpai/highlight*):
229+```
230+#!/bin/sh
231+
232+escape() {
233+	printf "%s" "$1" | sed 's#\\#\\\\#g'
234+}
235+FOCUS=$(swaymsg -t get_tree | jq '..|objects|select(.focused==true)|.name' | grep senpai | wc -l)
236+if [ "$HERE" -eq 0 ] || [ $FOCUS -eq 0 ]; then
237+	notify-send "[$BUFFER] $SENDER" "$(escape "$MESSAGE")"
238+fi
239 ```
240 
241 # SEE ALSO
M go.mod
+1, -1
 1@@ -3,11 +3,11 @@ module git.sr.ht/~taiite/senpai
 2 go 1.16
 3 
 4 require (
 5+	git.sr.ht/~emersion/go-scfg v0.0.0-20201019143924-142a8aa629fc
 6 	github.com/gdamore/tcell/v2 v2.3.11
 7 	github.com/mattn/go-runewidth v0.0.10
 8 	golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf
 9 	golang.org/x/time v0.0.0-20210611083556-38a9dc6acbc6
10-	gopkg.in/yaml.v2 v2.3.0
11 	mvdan.cc/xurls/v2 v2.3.0
12 )
13 
M go.sum
+6, -6
 1@@ -1,11 +1,15 @@
 2+git.sr.ht/~emersion/go-scfg v0.0.0-20201019143924-142a8aa629fc h1:51BD67xFX+bozd3ZRuOUfalrhx4/nQSh6A9lI08rYOk=
 3+git.sr.ht/~emersion/go-scfg v0.0.0-20201019143924-142a8aa629fc/go.mod h1:t+Ww6SR24yYnXzEWiNlOY0AFo5E9B73X++10lrSpp4U=
 4+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 5+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 6 github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
 7 github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
 8+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
 9+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
10 github.com/hhirtz/tcell/v2 v2.3.12-0.20210807133752-5d743c3ab0c9 h1:YE0ZsDHfDGR0MeB6YLSGW8tjoxOXZKX3XbB0ytGDX4M=
11 github.com/hhirtz/tcell/v2 v2.3.12-0.20210807133752-5d743c3ab0c9/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU=
12-github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
13 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
14 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
15-github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
16 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
17 github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac=
18 github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
19@@ -23,11 +27,7 @@ golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
20 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
21 golang.org/x/time v0.0.0-20210611083556-38a9dc6acbc6 h1:Vv0JUPWTyeqUq42B2WJ1FeIDjjvGKoA2Ss+Ts0lAVbs=
22 golang.org/x/time v0.0.0-20210611083556-38a9dc6acbc6/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
23-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
24-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
25 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
26 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
27-gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
28-gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
29 mvdan.cc/xurls/v2 v2.3.0 h1:59Olnbt67UKpxF1EwVBopJvkSUBmgtb468E4GVWIZ1I=
30 mvdan.cc/xurls/v2 v2.3.0/go.mod h1:AjuTy7gEiUArFMjgBBDU4SMxlfUYsRokpJQgNWOt3e4=