master sewn/kohai / cmd / senpai / main.go
  1package main
  2
  3import (
  4	"encoding/hex"
  5	"errors"
  6	"flag"
  7	"fmt"
  8	"hash/fnv"
  9	"io"
 10	"os"
 11	"os/signal"
 12	"os/user"
 13	"path"
 14	"path/filepath"
 15	"runtime"
 16	"strings"
 17	"syscall"
 18	"time"
 19
 20	"git.sr.ht/~delthas/senpai"
 21)
 22
 23func main() {
 24	var configPath string
 25	var nickname string
 26	var debug bool
 27	var version bool
 28	flag.StringVar(&configPath, "config", "", "path to the configuration file")
 29	flag.StringVar(&nickname, "nickname", "", "nick name/display name to use")
 30	flag.BoolVar(&debug, "debug", false, "show raw protocol data in the home buffer")
 31	flag.BoolVar(&version, "version", false, "show version info")
 32	flag.Parse()
 33
 34	if version {
 35		if v, ok := senpai.BuildVersion(); ok {
 36			fmt.Printf("senpai version %v\n", v)
 37		} else {
 38			fmt.Printf("senpai (unknown version)\n")
 39		}
 40		return
 41	}
 42
 43	var previousConfigPath string
 44	if configPath == "" {
 45		configDir, err := os.UserConfigDir()
 46		if err != nil {
 47			panic(err)
 48		}
 49		if runtime.GOOS == "darwin" {
 50			if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" && dir != configDir {
 51				previousConfigPath = configDir
 52				configDir = dir
 53			}
 54		}
 55		configPath = path.Join(configDir, "senpai", "senpai.scfg")
 56		if previousConfigPath != "" {
 57			previousConfigPath = path.Join(previousConfigPath, "senpai", "senpai.scfg")
 58		}
 59	}
 60
 61	cfg, err := senpai.LoadConfigFile(configPath)
 62	if err != nil && errors.Is(err, os.ErrNotExist) && previousConfigPath != "" {
 63		var ee error
 64		cfg, ee = senpai.LoadConfigFile(previousConfigPath)
 65		if ee == nil {
 66			err = nil
 67			fmt.Fprintf(os.Stderr, "Configuration assistant: Previous configuration file found at %q; the new default configuration location is now %q\n", previousConfigPath, configPath)
 68			fmt.Fprintf(os.Stderr, "Configuration assistant: You can run senpai with an explicit -config path argument, or move the file to its new location.\n")
 69			fmt.Fprintf(os.Stderr, "Configuration assistant: Would you like senpai to move the configuration file to the new location? (examples: yes, no) [optional, default: yes]: ")
 70			move := true
 71			scanBool(&move)
 72			if move {
 73				if err := copyContents(configPath, previousConfigPath); err != nil {
 74					fmt.Fprintf(os.Stderr, "failed to move the configuration file: %v\n", err)
 75					os.Exit(1)
 76					return
 77				}
 78				if err := os.Remove(previousConfigPath); err != nil {
 79					fmt.Fprintf(os.Stderr, "failed to delete the previous configuration file at %q: %v\n", previousConfigPath, err)
 80					os.Exit(1)
 81					return
 82				}
 83			}
 84		}
 85	}
 86	if err != nil {
 87		if !errors.Is(err, os.ErrNotExist) {
 88			fmt.Fprintf(os.Stderr, "failed to load the required configuration file at %q: %s\n", configPath, err)
 89			os.Exit(1)
 90			return
 91		}
 92		var host, port string
 93		tls := true
 94		var nick, password string
 95		fmt.Fprintf(os.Stderr, "The configuration file at %q was not found.\n", configPath)
 96		fmt.Fprintf(os.Stderr, "Configuration assistant: senpai will create a configuration file for you.\n\n")
 97		fmt.Fprintf(os.Stderr, "Important senpai information:\n")
 98		fmt.Fprintf(os.Stderr, "* senpai is able to connect to at most 1 server at a time.\n")
 99		fmt.Fprintf(os.Stderr, "* In order to connect to multiple networks, keep message history, search through your messages, and upload files, use an \x1B[1mIRC bouncer\x1B[0m and point senpai to the bouncer.\n")
100		fmt.Fprintf(os.Stderr, "* Most senpai users use senpai with the IRC bouncer software \x1B[1msoju\x1B[0m.\n")
101		fmt.Fprintf(os.Stderr, "** You can self-host \x1B[1msoju\x1B[0m yourself (it is free and open-source): https://soju.im/\n")
102		fmt.Fprintf(os.Stderr, "** You can also use a commercial hosted bouncer (uses \x1B[1msoju\x1B[0m underneath), endorsed by senpai: \x1B[1;4mhttps://irctoday.com/\x1B[0m\n\n")
103		fmt.Fprintf(os.Stderr, "Feel free to connect to your server now and configure a bouncer later to enable additional features.\n\n")
104		fmt.Fprintf(os.Stderr, "Configuration assistant: Enter your server host (examples: irc.libera.chat, irctoday.com): ")
105		for host == "" {
106			fmt.Scanln(&host)
107		}
108		fmt.Fprintf(os.Stderr, "Configuration assistant: Enter your server port (examples: 6667, 6697) [optional]: ")
109		fmt.Scanln(&port)
110		fmt.Fprintf(os.Stderr, "Configuration assistant: Enter whether your server uses TLS (examples: yes, no) [optional, default: yes]: ")
111		scanBool(&tls)
112		var defaultNick string
113		if u, err := user.Current(); err == nil {
114			defaultNick = u.Username
115			if _, name, ok := strings.Cut(defaultNick, "\\"); ok {
116				defaultNick = name
117			}
118			fmt.Fprintf(os.Stderr, "Configuration assistant: Enter your nickname [optional, default: %v]: ", defaultNick)
119		} else {
120			fmt.Fprintf(os.Stderr, "Configuration assistant: Enter your nickname: ")
121		}
122		fmt.Scanln(&nick)
123		for defaultNick == "" && nick == "" {
124			fmt.Scanln(&nick)
125		}
126		if nick == "" {
127			nick = defaultNick
128		}
129		fmt.Fprintf(os.Stderr, "Configuration assistant: Enter your password (only enter if you already have an account) [optional]: ")
130		fmt.Scanln(&password)
131
132		folderPath := path.Dir(configPath)
133		if err := os.MkdirAll(folderPath, 0700); err != nil {
134			fmt.Fprintf(os.Stderr, "failed to create the configuration file folder at %q: %s\n", folderPath, err)
135			os.Exit(1)
136			return
137		}
138		f, err := os.OpenFile(configPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
139		if err != nil {
140			fmt.Fprintf(os.Stderr, "failed to create the configuration file at %q: %s\n", configPath, err)
141			os.Exit(1)
142			return
143		}
144		var addr string
145		if !tls {
146			addr += "irc+insecure://"
147		}
148		addr += host
149		if port != "" {
150			addr += ":" + port
151		}
152		fmt.Fprintf(f, "address %q\n", addr)
153		fmt.Fprintf(f, "nickname %q\n", nick)
154		if password != "" {
155			fmt.Fprintf(f, "password %q\n", password)
156		}
157		f.Close()
158
159		cfg, err = senpai.LoadConfigFile(configPath)
160		if err != nil {
161			fmt.Fprintf(os.Stderr, "failed to load the configuration file at %q: %s\n", configPath, err)
162			os.Exit(1)
163			return
164		}
165
166		fmt.Fprintf(os.Stderr, "Configuration assistant: Configuration saved to %q. Now starting.", configPath)
167		for i := 0; i < 6; i++ {
168			time.Sleep(500 * time.Millisecond)
169			fmt.Fprintf(os.Stderr, ".")
170		}
171		fmt.Fprintf(os.Stderr, "\n")
172	}
173
174	cfg.Debug = cfg.Debug || debug
175	if nickname != "" {
176		cfg.Nick = nickname
177	}
178
179	app, err := senpai.NewApp(cfg)
180	if err != nil {
181		fmt.Fprintf(os.Stderr, "failed to run: %s\n", err)
182		os.Exit(1)
183		return
184	}
185
186	cfgHash := configPathHash(configPath)
187	if !cfg.Transient {
188		lastNetID, lastBuffer := getLastBuffer(cfgHash)
189		app.SwitchToBuffer(lastNetID, lastBuffer)
190		app.SetLastClose(getLastStamp(cfgHash))
191	}
192
193	sigCh := make(chan os.Signal, 1)
194	signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
195
196	go func() {
197		<-sigCh
198		app.Close()
199	}()
200
201	app.Run()
202	app.Close()
203	if !cfg.Transient {
204		writeLastBuffer(app, cfgHash)
205		writeLastStamp(app, cfgHash)
206	}
207}
208
209func scanBool(v *bool) {
210	for {
211		var s string
212		fmt.Scanln(&s)
213		if s == "" {
214			return
215		}
216		switch strings.ToLower(s) {
217		case "y", "yes":
218			*v = true
219			return
220		case "n", "no":
221			*v = false
222			return
223		}
224	}
225}
226
227func copyContents(dstPath string, srcPath string) error {
228	src, err := os.Open(srcPath)
229	if err != nil {
230		return fmt.Errorf("open %q: %v", srcPath, err)
231	}
232	defer src.Close()
233	dst, err := os.Create(dstPath)
234	if err != nil {
235		return fmt.Errorf("create %q: %v", dstPath, err)
236	}
237	defer dst.Close()
238	if _, err := io.Copy(dst, src); err != nil {
239		return fmt.Errorf("copy %q to %q: %v", srcPath, dstPath, err)
240	}
241	return nil
242}
243
244func cachePath() string {
245	cacheDir, err := os.UserCacheDir()
246	if err != nil {
247		panic(err)
248	}
249	cache := path.Join(cacheDir, "senpai")
250	err = os.MkdirAll(cache, 0755)
251	if err != nil {
252		panic(err)
253	}
254	return cache
255}
256
257func configPathHash(configPath string) string {
258	abs, err := filepath.Abs(configPath)
259	if err != nil {
260		abs = configPath
261	}
262	h := fnv.New32a()
263	h.Write([]byte(abs))
264	return hex.EncodeToString(h.Sum(nil))
265}
266
267func lastBufferPath(hash string) string {
268	name := "lastbuffer.txt"
269	if hash != "" {
270		name = "lastbuffer-" + hash + ".txt"
271	}
272	return path.Join(cachePath(), name)
273}
274
275func getLastBuffer(hash string) (netID, buffer string) {
276	p := lastBufferPath(hash)
277	buf, err := os.ReadFile(p)
278	if err != nil && hash != "" {
279		buf, err = os.ReadFile(lastBufferPath(""))
280		if err != nil {
281			return "", ""
282		}
283	} else if err != nil {
284		return "", ""
285	}
286
287	fields := strings.SplitN(strings.TrimSpace(string(buf)), " ", 2)
288	if len(fields) < 2 {
289		return "", ""
290	}
291
292	return fields[0], fields[1]
293}
294
295func writeLastBuffer(app *senpai.App, hash string) {
296	p := lastBufferPath(hash)
297	lastNetID, lastBuffer := app.CurrentBuffer()
298	err := os.WriteFile(p, []byte(fmt.Sprintf("%s %s", lastNetID, lastBuffer)), 0666)
299	if err != nil {
300		fmt.Fprintf(os.Stderr, "failed to write last buffer at %q: %s\n", p, err)
301	}
302}
303
304func lastStampPath(hash string) string {
305	name := "laststamp.txt"
306	if hash != "" {
307		name = "laststamp-" + hash + ".txt"
308	}
309	return path.Join(cachePath(), name)
310}
311
312func getLastStamp(hash string) time.Time {
313	p := lastStampPath(hash)
314	buf, err := os.ReadFile(p)
315	if err != nil && hash != "" {
316		buf, err = os.ReadFile(lastStampPath(""))
317		if err != nil {
318			return time.Time{}
319		}
320	} else if err != nil {
321		return time.Time{}
322	}
323
324	stamp := strings.TrimSpace(string(buf))
325	t, err := time.Parse(time.RFC3339Nano, stamp)
326	if err != nil {
327		return time.Time{}
328	}
329	return t
330}
331
332func writeLastStamp(app *senpai.App, hash string) {
333	p := lastStampPath(hash)
334	last := app.LastMessageTime()
335	if last.IsZero() {
336		return
337	}
338	err := os.WriteFile(p, []byte(last.UTC().Format(time.RFC3339Nano)), 0666)
339	if err != nil {
340		fmt.Fprintf(os.Stderr, "failed to write last stamp at %q: %s\n", p, err)
341	}
342}