commit ff5e01be6c858c11e3e1c16fff6bb2d213677547
parent 00f28bba4c5c6a5f99d229bc9fb651f106618d0c
Author: Natasha Kerensikova <natgh@instinctive.eu>
Date: Sat, 7 Jun 2025 16:44:35 +0000
Core functionality
Diffstat:
A | go.mod | | | 21 | +++++++++++++++++++++ |
A | go.sum | | | 26 | ++++++++++++++++++++++++++ |
A | main.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
+ }
+ }
+}