summaryrefslogtreecommitdiff
path: root/xN/xN.go
diff options
context:
space:
mode:
Diffstat (limited to 'xN/xN.go')
-rw-r--r--xN/xN.go279
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)
+}