280 lines
6.9 KiB
Go
280 lines
6.9 KiB
Go
|
//
|
||
|
// 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)
|
||
|
}
|