diff options
Diffstat (limited to 'xN/xN.go')
-rw-r--r-- | xN/xN.go | 279 |
1 files changed, 279 insertions, 0 deletions
diff --git a/xN/xN.go b/xN/xN.go new file mode 100644 index 0000000..807de69 --- /dev/null +++ b/xN/xN.go @@ -0,0 +1,279 @@ +// +// Copyright (c) 2024, Přemysl Eric Janouch <p@janouch.name> +// +// 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 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) +} |