natsim

NATS ↔ Instant Messaging Bridge
git clone https://git.instinctive.eu/natsim.git
Log | Files | Refs | README | LICENSE

commit ff5e01be6c858c11e3e1c16fff6bb2d213677547
parent 00f28bba4c5c6a5f99d229bc9fb651f106618d0c
Author: Natasha Kerensikova <natgh@instinctive.eu>
Date:   Sat,  7 Jun 2025 16:44:35 +0000

Core functionality
Diffstat:
Ago.mod | 21+++++++++++++++++++++
Ago.sum | 26++++++++++++++++++++++++++
Amain.go | 317+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 364 insertions(+), 0 deletions(-)

diff --git a/go.mod b/go.mod @@ -0,0 +1,21 @@ +module instinctive.eu/go/natsim + +go 1.23.0 + +toolchain go1.23.9 + +require ( + github.com/nats-io/nats.go v1.42.0 + github.com/pelletier/go-toml/v2 v2.2.4 + github.com/thoj/go-ircevent v0.0.0-20210723090443-73e444401d64 +) + +require ( + github.com/klauspost/compress v1.18.0 // indirect + github.com/nats-io/nkeys v0.4.11 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/go.sum b/go.sum @@ -0,0 +1,26 @@ +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/nats-io/nats.go v1.42.0 h1:ynIMupIOvf/ZWH/b2qda6WGKGNSjwOUutTpWRvAmhaM= +github.com/nats-io/nats.go v1.42.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= +github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/thoj/go-ircevent v0.0.0-20210723090443-73e444401d64 h1:l/T7dYuJEQZOwVOpjIXr1180aM9PZL/d1MnMVIxefX4= +github.com/thoj/go-ircevent v0.0.0-20210723090443-73e444401d64/go.mod h1:Q1NAJOuRdQCqN/VIWdnaaEhV8LpeO2rtlBP7/iDJNII= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/main.go b/main.go @@ -0,0 +1,317 @@ +package main + +import ( + "errors" + "log" + "os" + "runtime/debug" + "strings" + + "github.com/nats-io/nats.go" + "github.com/pelletier/go-toml/v2" + "github.com/thoj/go-ircevent" +) + +type command struct { + name string + arg string +} + +func main() { + setVersion() + + config_file := "natsim.toml" + if len(os.Args) > 1 { + config_file = os.Args[1] + } + + im, err := NewNatsIM(config_file) + if err != nil { + log.Fatal(err) + } + + log.Println("natsim " + version + " started") + + im.irc.Loop() +} + +/**************** Construction ****************/ + +type LineMark struct { + Start string + Mid string + End string +} + +type IrcConfig struct { + Channel string + Server string + Nick string + Cmd LineMark + Send LineMark + Show LineMark + MaxLine int + ContSuffix string + ContPrefix string +} + +type NatsConfig struct { + Server string + NkeySeed string + Subjects []string +} + +type NatsIM struct { + Irc IrcConfig + Nats NatsConfig + + irc *irc.Connection + nc *nats.Conn + cmdQueue chan command + ircQueue chan string + buf strings.Builder +} + +func NewNatsIM(configPath string) (*NatsIM, error) { + natsim := &NatsIM{ + Irc: IrcConfig{ + Nick: "natsim", + Cmd: LineMark{ + Start: "!", + Mid: " ", + }, + Send: LineMark{Mid: ": "}, + Show: LineMark{Mid: ": "}, + }, + Nats: NatsConfig{ + Subjects: []string{">"}, + }, + } + + f, err := os.Open(configPath) + if err != nil { + return nil, err + } + defer func() { + if err := f.Close(); err != nil { + log.Println("Closing configuration", configPath, err) + } + }() + + d := toml.NewDecoder(f).DisallowUnknownFields() + err = d.Decode(natsim) + if err != nil { + var details *toml.StrictMissingError + if errors.As(err, &details) { + for _, missing := range details.Errors { + log.Println(missing) + } + } + return nil, err + } + + if natsim.Irc.MaxLine > 0 && len(natsim.Irc.ContPrefix)+len(natsim.Irc.ContSuffix) >= natsim.Irc.MaxLine { + natsim.Irc.ContPrefix = "" + natsim.Irc.ContSuffix = "" + } + + natsim.cmdQueue = make(chan command, 10) + natsim.ircQueue = make(chan string, 10) + + opt, err := nats.NkeyOptionFromSeed(natsim.Nats.NkeySeed) + if err != nil { + return nil, err + } + + natsim.nc, err = nats.Connect(natsim.Nats.Server, opt) + if err != nil { + return nil, err + } + + natsim.irc = irc.IRC(natsim.Irc.Nick, "natsim") + natsim.irc.AddCallback("001", natsim.ircJoin) + natsim.irc.AddCallback("366", func(e *irc.Event) {}) + natsim.irc.AddCallback("PRIVMSG", natsim.ircReceive) + + err = natsim.irc.Connect(natsim.Irc.Server) + if err != nil { + natsim.Close() + return nil, err + } + + go natsim.doCommands() + go natsim.ircSender() + + return natsim, nil +} + +func (natsim *NatsIM) Close() { + if natsim.irc != nil { + natsim.irc.Quit() + natsim.irc = nil + } + + if natsim.nc != nil { + natsim.nc.Close() + natsim.nc = nil + } + + close(natsim.cmdQueue) + close(natsim.ircQueue) +} + +/**************** Command Goroutine ****************/ + +func (natsim *NatsIM) doCommands() { + for { + cmd, ok := <-natsim.cmdQueue + if !ok { + return + } + + switch cmd.name { + case "quit": + log.Println("Quit command", cmd.arg) + natsim.irc.QuitMessage = cmd.arg + natsim.Close() + + case "subscribeAll": + for _, subject := range natsim.Nats.Subjects { + if _, err := natsim.nc.Subscribe(subject, natsim.natsReceive); err != nil { + natsim.ircSendError("Subscribe", err) + } + } + + case "version": + natsim.ircSend("natsim " + version) + + default: + natsim.ircSend("Unknown command: " + cmd.name) + } + } +} + +/**************** IRC Callbacks ****************/ + +func (natsim *NatsIM) ircJoin(e *irc.Event) { + natsim.irc.Join(natsim.Irc.Channel) + natsim.cmdQueue <- command{name: "subscribeAll", arg: ""} +} + +func (natsim *NatsIM) ircReceive(e *irc.Event) { + msg := e.Message() + if name, arg, found := unpackMark(natsim.Irc.Cmd, msg, true); found { + natsim.cmdQueue <- command{name: name, arg: arg} + } else if subject, data, found := unpackMark(natsim.Irc.Send, msg, false); found { + if err := natsim.nc.Publish(subject, []byte(data)); err != nil { + natsim.ircSendError("Publish", err) + } + } +} + +func (natsim *NatsIM) ircSendError(context string, err error) { + prefix := "[E]" + if context != "" { + prefix += context + ": " + } + natsim.ircSend(prefix + err.Error()) +} + +func (natsim *NatsIM) ircSend(s string) { + if natsim.Irc.MaxLine <= 0 || len(s) < natsim.Irc.MaxLine { + natsim.ircQueue <- s + } else { + for offset := 0; offset < len(s); { + l := len(s) - offset + natsim.buf.Reset() + if offset > 0 { + natsim.buf.WriteString(natsim.Irc.ContPrefix) + } + + if natsim.buf.Len()+l <= natsim.Irc.MaxLine { + natsim.buf.WriteString(s[offset:]) + } else { + l = natsim.Irc.MaxLine - natsim.buf.Len() - len(natsim.Irc.ContSuffix) + natsim.buf.WriteString(s[offset : offset+l]) + natsim.buf.WriteString(natsim.Irc.ContSuffix) + } + + natsim.ircQueue <- natsim.buf.String() + offset += l + } + } +} + +func (natsim *NatsIM) ircSender() { + for { + line, ok := <-natsim.ircQueue + if !ok { + return + } + + // TODO: rate limitation + + natsim.irc.Privmsg(natsim.Irc.Channel, line) + } +} + +/**************** Nats Callbacks ****************/ + +func (natsim *NatsIM) natsReceive(m *nats.Msg) { + msg := packMark(natsim.Irc.Show, m.Subject, string(m.Data)) + natsim.ircSend(msg) +} + +/**************** Tools ****************/ + +func packMark(mark LineMark, name, arg string) string { + return mark.Start + name + mark.Mid + arg + mark.End +} + +func unpackMark(mark LineMark, line string, optional bool) (string, string, bool) { + if strings.HasPrefix(line, mark.Start) && strings.HasSuffix(line, mark.End) { + inside := line[len(mark.Start) : len(line)-len(mark.End)] + if mark.Mid == "" { + return inside, "", true + } else if name, arg, found := strings.Cut(inside, mark.Mid); found { + return name, arg, true + } else { + return inside, "", optional + } + } else { + return "", "", false + } +} + +var version = "(unknown)" + +func setVersion() { + info, ok := debug.ReadBuildInfo() + if !ok { + return + } + + version = info.Main.Version + + if version == "(devel)" { + vcs := "" + rev := "" + dirty := "" + for _, setting := range info.Settings { + switch setting.Key { + case "vcs": + vcs = setting.Value + "-" + case "vcs.revision": + rev = setting.Value[0:8] + case "vcs.modified": + if setting.Value == "true" { + dirty = "*" + } + } + } + + if rev != "" { + version = vcs + rev + dirty + } + } +}