// // Copyright (c) 2024, Přemysl Eric Janouch // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted. // // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES // WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF // MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY // SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES // WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION // OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN // CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. // // xN is a simple IRC notifier. package main import ( "bufio" "bytes" "crypto/tls" "errors" "flag" "fmt" "io" "log" "net" "net/url" "os" "os/user" "strconv" "strings" ) const projectName = "xN" var projectVersion = "?" var debugMode = false type parameters struct { conn net.Conn // underlying network connection username string // nickname + username + realname password string // server password target string // where to send text isuser bool // the target is a user rather than a channel chankey string // channel key skipjoin bool // whether to send external messages to channels usenotice bool // whether to use NOTICE rather than PRIVMSG message []string // lines of the message to send } func (p *parameters) send(command string, args ...string) error { var buf bytes.Buffer buf.WriteString(command) for i, arg := range args { buf.WriteRune(' ') if i+1 == len(args) { buf.WriteRune(':') } buf.WriteString(arg) } buf.WriteString("\r\n") _, err := p.conn.Write(buf.Bytes()) return err } const ( rplWELCOME = "001" errNICKNAMEINUSE = "433" ) func notify(p *parameters) error { // The intro should comfortably fit in the TCP send buffer whole. if p.password != "" { p.send("PASS", p.password) } p.send("USER", p.username, "0", "*", p.username) p.send("NICK", p.username) scanner, nickCounter, issue := bufio.NewScanner(p.conn), 1, "" for scanner.Scan() { if debugMode { log.Println(scanner.Text()) } m := ircParseMessage(scanner.Text()) switch m.command { case "PING": p.send("PONG", m.params...) case rplWELCOME: if !p.isuser && !p.skipjoin { if p.chankey != "" { p.send("JOIN", p.target, p.chankey) } else { p.send("JOIN", p.target) } } for _, line := range p.message { if p.usenotice { p.send("NOTICE", p.target, line) } else { p.send("PRIVMSG", p.target, line) } } p.send("QUIT") case errNICKNAMEINUSE: p.send("NICK", fmt.Sprintf("%s%d", p.username, nickCounter)) nickCounter++ default: // Prevent hanging on unsuccessful registrations. numeric, _ := strconv.Atoi(m.command) if numeric >= 400 && numeric <= 599 { if len(m.params) > 1 { issue = strings.Join(m.params[1:], " ") } else { issue = strings.Join(m.params, " ") } p.send("QUIT") } } } if err := scanner.Err(); err != nil { return err } if issue != "" { return errors.New(issue) } return nil } func parse(rawURL string, text []byte) ( p parameters, connect func() (net.Conn, error), err error) { u, err := url.Parse(rawURL) if err != nil { return p, nil, err } else if !u.IsAbs() || u.Opaque != "" { return p, nil, errors.New("need an absolute URL") } else if u.Path == "/" && u.Fragment != "" { // Try to handle the common but degenerate case. fragment := "%23" + u.Fragment u.Fragment, u.RawFragment = "", "" if u, err = url.Parse(u.String() + fragment); err != nil { return p, nil, err } } // Figure out registration details. p.username = projectName if u, _ := user.Current(); u != nil { p.username = u.Username } if u.User.Username() != "" { p.username = u.User.Username() } p.password, _ = u.User.Password() // Figure out the target, which for our intents must accept messages. path, _ := strings.CutPrefix(u.Path, "/") elements := strings.Split(path, ",") if path == "" || elements[0] == "" { return p, nil, errors.New("unspecified entity") } // The last entity type wins. p.target, p.isuser = elements[0], false for _, typ := range elements[1:] { switch typ { case "isuser": p.isuser = true case "ischannel": p.isuser = false case "isserver": // We do not support network names, and this is the default. default: return p, nil, errors.New("unsupported type: " + typ) } } if p.isuser { if i := strings.IndexAny(p.target, "!@"); i != -1 { p.target = p.target[:i] } } else if !strings.HasPrefix(p.target, "#") { // TODO(p): We should consult RPL_ISUPPORT rather than guess, // though other prefixes are rare. p.target = "#" + p.target } // Note that the draft RFC wants these to be case-insensitive. p.chankey = u.Query().Get("key") // Being able to skip channel join is our own requirement and invention, // as are notices (names taken from Travis CI configuration). p.skipjoin = u.Query().Has("skipjoin") p.usenotice = u.Query().Has("usenotice") // Ensure valid LF-separated UTF-8, and split it at lines. sanitized := strings.ReplaceAll(string([]rune(string(text))), "\r", "\n") for _, line := range strings.Split(sanitized, "\n") { if line != "" { p.message = append(p.message, line) } } hostname, port := u.Hostname(), u.Port() switch u.Scheme { case "irc": if port == "" { port = "6667" } connect = func() (net.Conn, error) { return net.Dial("tcp", net.JoinHostPort(hostname, port)) } case "ircs": if port == "" { port = "6697" } connect = func() (net.Conn, error) { return tls.Dial("tcp", net.JoinHostPort(hostname, port), nil) } default: err = errors.New("unsupported scheme: " + u.Scheme) } return } // notifyByURL sends the given text to the IRC server specified by a URL. // See draft-butcher-irc-url-04.txt for the URL scheme specification // this function loosely follows. func notifyByURL(rawURL string, text []byte) error { p, connect, err := parse(rawURL, text) if p.conn, err = connect(); err != nil { return err } defer p.conn.Close() return notify(&p) } func main() { flag.BoolVar(&debugMode, "debug", false, "run in verbose debug mode") version := flag.Bool("version", false, "show version and exit") flag.Usage = func() { f := flag.CommandLine.Output() fmt.Fprintf(f, "Usage: %s [OPTION]... URL...\n", os.Args[0]) flag.PrintDefaults() } flag.Parse() if flag.NArg() < 1 { flag.Usage() os.Exit(2) } if *version { fmt.Printf("%s %s\n", projectName, projectVersion) return } text, err := io.ReadAll(os.Stdin) if err != nil { log.Fatalln(err) } status := 0 for _, rawURL := range flag.Args() { if err := notifyByURL(rawURL, text); err != nil { status = 1 var ue *url.Error if errors.As(err, &ue) { log.Println(err) } else { log.Printf("notify %q: %s\n", rawURL, err) } } } os.Exit(status) }