natsim

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

main.go (32464B)


      1 /*
      2  * Copyright (c) 2025, Natacha Porté
      3  *
      4  * Permission to use, copy, modify, and distribute this software for any
      5  * purpose with or without fee is hereby granted, provided that the above
      6  * copyright notice and this permission notice appear in all copies.
      7  *
      8  * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
      9  * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
     10  * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
     11  * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
     12  * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
     13  * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
     14  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
     15  */
     16 
     17 package main
     18 
     19 import (
     20 	"database/sql"
     21 	"embed"
     22 	"encoding/base64"
     23 	"encoding/hex"
     24 	"errors"
     25 	"fmt"
     26 	"log"
     27 	"os"
     28 	"regexp"
     29 	"runtime/debug"
     30 	"strconv"
     31 	"strings"
     32 	"sync/atomic"
     33 	"time"
     34 
     35 	"github.com/dustin/go-humanize"
     36 	_ "github.com/glebarez/go-sqlite"
     37 	"github.com/nats-io/nats.go"
     38 	"github.com/pelletier/go-toml/v2"
     39 	"github.com/thoj/go-ircevent"
     40 )
     41 
     42 type command struct {
     43 	name string
     44 	arg  string
     45 }
     46 
     47 func main() {
     48 	setVersion()
     49 
     50 	config_file := "natsim.toml"
     51 	if len(os.Args) > 1 {
     52 		config_file = os.Args[1]
     53 	}
     54 
     55 	im, err := NewNatsIM(config_file)
     56 	if err != nil {
     57 		log.Fatal(err)
     58 	}
     59 
     60 	log.Println("natsim " + version + " started")
     61 
     62 	im.irc.Loop()
     63 }
     64 
     65 /**************** Construction ****************/
     66 
     67 type LineMark struct {
     68 	Start string
     69 	Mid   string
     70 	End   string
     71 }
     72 
     73 type IrcConfig struct {
     74 	Channel       string
     75 	Server        string
     76 	Nick          string
     77 	Cmd           LineMark
     78 	Send          LineMark
     79 	Show          LineMark
     80 	ShowReply     *LineMark
     81 	ShowHeader    *LineMark
     82 	MaxLine       int
     83 	ContSuffix    string
     84 	ContPrefix    string
     85 	AntiFlood     antiflood
     86 	Filter        []FilterElement
     87 	AutoClear     bool
     88 	nextClear     bool
     89 	AllowCmd      []string
     90 	AllowSend     []string
     91 	BlockCmd      []string
     92 	BlockSend     []string
     93 	MaxQuoteRatio float32
     94 	MaxBase64     int
     95 	MaxHex        int
     96 }
     97 
     98 type LogConfig struct {
     99 	SqlDriver     string
    100 	SqlConnection string
    101 	Filter        []FilterElement
    102 }
    103 
    104 type NatsConfig struct {
    105 	Name                 string
    106 	Server               string
    107 	NkeySeed             string
    108 	Subjects             []string
    109 	Filter               []FilterElement
    110 	RetryOnFailedConnect bool
    111 }
    112 
    113 type NatsIM struct {
    114 	Irc  IrcConfig
    115 	Log  LogConfig
    116 	Nats NatsConfig
    117 
    118 	irc            *irc.Connection
    119 	nc             *nats.Conn
    120 	subs           []*nats.Subscription
    121 	curMsg         nats.Msg
    122 	db             *sql.DB
    123 	ensureSubject  *sql.Stmt
    124 	insertReceived *sql.Stmt
    125 	insertRHeader  *sql.Stmt
    126 	insertSent     *sql.Stmt
    127 	insertSHeader  *sql.Stmt
    128 	cmdQueue       chan command
    129 	ircQueue       chan string
    130 	dropped        atomic.Uint32
    131 }
    132 
    133 func NewNatsIM(configPath string) (*NatsIM, error) {
    134 	natsim := &NatsIM{
    135 		Irc: IrcConfig{
    136 			Nick:          "natsim",
    137 			Cmd:           LineMark{Start: "!", Mid: " "},
    138 			Send:          LineMark{Mid: ": "},
    139 			Show:          LineMark{Mid: ": "},
    140 			AutoClear:     true,
    141 			MaxQuoteRatio: 2.0,
    142 			MaxBase64:     256,
    143 			MaxHex:        64,
    144 		},
    145 		Nats: NatsConfig{
    146 			Name:     "nastim",
    147 			Subjects: []string{">"},
    148 		},
    149 	}
    150 
    151 	f, err := os.Open(configPath)
    152 	if err != nil {
    153 		return nil, err
    154 	}
    155 	defer func() {
    156 		if err := f.Close(); err != nil {
    157 			log.Println("Closing configuration", configPath, err)
    158 		}
    159 	}()
    160 
    161 	d := toml.NewDecoder(f).DisallowUnknownFields()
    162 	err = d.Decode(natsim)
    163 	if err != nil {
    164 		var details *toml.StrictMissingError
    165 		if errors.As(err, &details) {
    166 			for _, missing := range details.Errors {
    167 				log.Println(missing)
    168 			}
    169 		}
    170 		return nil, err
    171 	}
    172 
    173 	if natsim.Irc.MaxLine > 0 && len(natsim.Irc.ContPrefix)+len(natsim.Irc.ContSuffix) >= natsim.Irc.MaxLine {
    174 		natsim.Irc.ContPrefix = ""
    175 		natsim.Irc.ContSuffix = ""
    176 	}
    177 
    178 	if natsim.Log.SqlDriver != "" {
    179 		if err := natsim.logInit(); err != nil {
    180 			natsim.Close()
    181 			return nil, err
    182 		}
    183 	}
    184 
    185 	natsim.Irc.nextClear = natsim.Irc.AutoClear
    186 
    187 	natsim.cmdQueue = make(chan command, 10)
    188 	natsim.ircQueue = make(chan string, 10)
    189 
    190 	natsim.irc = irc.IRC(natsim.Irc.Nick, "natsim")
    191 	natsim.irc.AddCallback("001", natsim.ircJoin)
    192 	natsim.irc.AddCallback("366", natsim.ircJoined)
    193 	natsim.irc.AddCallback("PRIVMSG", natsim.ircReceive)
    194 
    195 	err = natsim.irc.Connect(natsim.Irc.Server)
    196 	if err != nil {
    197 		natsim.irc = nil
    198 		natsim.Close()
    199 		return nil, err
    200 	}
    201 
    202 	go natsim.doCommands()
    203 	go natsim.ircSender()
    204 
    205 	return natsim, nil
    206 }
    207 
    208 func (natsim *NatsIM) Close() {
    209 	if natsim.irc != nil {
    210 		natsim.irc.Quit()
    211 		natsim.irc = nil
    212 	}
    213 
    214 	if natsim.nc != nil {
    215 		natsim.nc.Close()
    216 		natsim.nc = nil
    217 	}
    218 
    219 	if natsim.ensureSubject != nil {
    220 		if err := natsim.ensureSubject.Close(); err != nil {
    221 			log.Println("Close ensureSubject:", err)
    222 		}
    223 		natsim.ensureSubject = nil
    224 	}
    225 
    226 	if natsim.insertReceived != nil {
    227 		if err := natsim.insertReceived.Close(); err != nil {
    228 			log.Println("Close insertReceived:", err)
    229 		}
    230 		natsim.insertReceived = nil
    231 	}
    232 
    233 	if natsim.insertRHeader != nil {
    234 		if err := natsim.insertRHeader.Close(); err != nil {
    235 			log.Println("Close insertRHeader:", err)
    236 		}
    237 		natsim.insertRHeader = nil
    238 	}
    239 
    240 	if natsim.insertSent != nil {
    241 		if err := natsim.insertSent.Close(); err != nil {
    242 			log.Println("Close insertSent:", err)
    243 		}
    244 		natsim.insertSent = nil
    245 	}
    246 
    247 	if natsim.insertSHeader != nil {
    248 		if err := natsim.insertSHeader.Close(); err != nil {
    249 			log.Println("Close insertSHeader:", err)
    250 		}
    251 		natsim.insertSHeader = nil
    252 	}
    253 
    254 	if natsim.db != nil {
    255 		if err := natsim.db.Close(); err != nil {
    256 			log.Println("Close log DB:", err)
    257 		}
    258 		natsim.db = nil
    259 	}
    260 
    261 	close(natsim.cmdQueue)
    262 	close(natsim.ircQueue)
    263 }
    264 
    265 /**************** Command Goroutine ****************/
    266 
    267 func (natsim *NatsIM) doCommands() {
    268 	for {
    269 		cmd, ok := <-natsim.cmdQueue
    270 		if !ok {
    271 			return
    272 		}
    273 
    274 		switch cmd.name {
    275 		case "allowcmd":
    276 			updateNickList(cmd.arg, &natsim.Irc.AllowCmd, &natsim.Irc.BlockCmd)
    277 			natsim.ircSendf("%s - %s", strNickList(natsim.Irc.AllowCmd), strNickList(natsim.Irc.BlockCmd))
    278 
    279 		case "allowsend":
    280 			updateNickList(cmd.arg, &natsim.Irc.AllowSend, &natsim.Irc.BlockSend)
    281 			natsim.ircSendf("%s - %s", strNickList(natsim.Irc.AllowSend), strNickList(natsim.Irc.BlockSend))
    282 
    283 		case "autoclear":
    284 			switch cmd.arg {
    285 			case "":
    286 				temp := ""
    287 				state := "off"
    288 				if natsim.Irc.AutoClear != natsim.Irc.nextClear {
    289 					temp = "temporarily "
    290 				}
    291 				if natsim.Irc.AutoClear {
    292 					state = "on"
    293 				}
    294 				natsim.ircSendf("Autoclear is %s%s", temp, state)
    295 			case "after":
    296 				natsim.Irc.AutoClear = false
    297 				natsim.Irc.nextClear = true
    298 			case "on":
    299 				natsim.Irc.AutoClear = true
    300 				natsim.Irc.nextClear = true
    301 			case "once":
    302 				natsim.Irc.AutoClear = true
    303 				natsim.Irc.nextClear = false
    304 			case "off":
    305 				natsim.Irc.AutoClear = false
    306 				natsim.Irc.nextClear = false
    307 			default:
    308 				natsim.ircSendf("Unknown autoclear option %q", cmd.arg)
    309 			}
    310 
    311 		case "b64data":
    312 			if decoded, err := b64Decode(cmd.arg); err != nil {
    313 				natsim.ircSendError("b64Decode", err)
    314 			} else {
    315 				natsim.curMsg.Data = append(natsim.curMsg.Data, []byte(decoded)...)
    316 			}
    317 
    318 		case "blockcmd":
    319 			updateNickList(cmd.arg, &natsim.Irc.BlockCmd, &natsim.Irc.AllowCmd)
    320 			natsim.ircSendf("%s - %s", strNickList(natsim.Irc.BlockCmd), strNickList(natsim.Irc.AllowCmd))
    321 
    322 		case "blocksend":
    323 			updateNickList(cmd.arg, &natsim.Irc.BlockSend, &natsim.Irc.AllowSend)
    324 			natsim.ircSendf("%s - %s", strNickList(natsim.Irc.BlockSend), strNickList(natsim.Irc.AllowSend))
    325 
    326 		case "cleardata":
    327 			natsim.curMsg.Data = []byte{}
    328 
    329 		case "clearmsg":
    330 			natsim.curMsg = nats.Msg{}
    331 
    332 		case "curmsg":
    333 			var sb strings.Builder
    334 			sb.WriteString("[WIP]")
    335 			sb.WriteString(packMark(natsim.Irc.Show,
    336 				natsim.curMsg.Subject,
    337 				natsim.ircQuoteData(natsim.curMsg.Data)))
    338 
    339 			if natsim.curMsg.Reply != "" {
    340 				show := LineMark{Start: "Reply-To:"}
    341 				if natsim.Irc.ShowReply != nil {
    342 					show = *natsim.Irc.ShowReply
    343 				}
    344 				sb.WriteString(show.Start)
    345 				sb.WriteString(natsim.curMsg.Reply)
    346 				sb.WriteString(show.End)
    347 			}
    348 
    349 			show := LineMark{Mid: ": "}
    350 			if natsim.Irc.ShowHeader != nil {
    351 				show = *natsim.Irc.ShowHeader
    352 			}
    353 			for key, values := range natsim.curMsg.Header {
    354 				for _, value := range values {
    355 					sb.WriteString(packMark(show, key, value))
    356 				}
    357 			}
    358 
    359 			natsim.ircSend(sb.String())
    360 
    361 		case "delheader":
    362 			index := -1
    363 			key := cmd.arg
    364 
    365 			if strings.HasPrefix(cmd.arg, "* ") {
    366 				key = key[2:]
    367 			} else if before, after, found := strings.Cut(cmd.arg, " "); found {
    368 				if n, err := strconv.Atoi(before); err == nil {
    369 					index = n
    370 					key = after
    371 				}
    372 			}
    373 
    374 			if natsim.curMsg.Header == nil || natsim.curMsg.Header[key] == nil {
    375 				natsim.ircSendf("No recorded header %q", key)
    376 			} else if index < 0 {
    377 				delete(natsim.curMsg.Header, key)
    378 			} else if index < len(natsim.curMsg.Header[key]) {
    379 				natsim.curMsg.Header[key] = append(natsim.curMsg.Header[key][:index], natsim.curMsg.Header[key][index+1:]...)
    380 			} else {
    381 				natsim.ircSendf("No index %d in header %q", index, key)
    382 			}
    383 
    384 		case "data":
    385 			natsim.curMsg.Data = append(natsim.curMsg.Data, []byte(cmd.arg)...)
    386 
    387 		case "filter":
    388 			var plist *[]FilterElement
    389 			var name string
    390 			index := -1
    391 			place, eltstr, _ := strings.Cut(cmd.arg, " ")
    392 			uplace := strings.ToUpper(place)
    393 
    394 			if uplace == "NATS" {
    395 				plist = &natsim.Nats.Filter
    396 				name = "N"
    397 			} else if uplace == "LOG" {
    398 				plist = &natsim.Log.Filter
    399 				name = "L"
    400 			} else if uplace == "IRC" {
    401 				plist = &natsim.Irc.Filter
    402 				name = "I"
    403 			} else if n, err := strconv.Atoi(uplace[1:]); err == nil && (uplace[0:1] == "N" || uplace[0:1] == "L" || uplace[0:1] == "I") {
    404 				index = n - 1
    405 				name = uplace[0:1]
    406 				switch name {
    407 				case "N":
    408 					plist = &natsim.Nats.Filter
    409 				case "L":
    410 					plist = &natsim.Log.Filter
    411 				case "I":
    412 					plist = &natsim.Irc.Filter
    413 				}
    414 			} else {
    415 				natsim.ircSendf("Unable to parse place %q", uplace)
    416 			}
    417 
    418 			var elt FilterElement
    419 			if plist != nil {
    420 				err := elt.UnmarshalText([]byte(eltstr))
    421 				if err != nil {
    422 					natsim.ircSendError("Parse FilterElement", err)
    423 					plist = nil
    424 				}
    425 			}
    426 
    427 			if plist != nil {
    428 				if index < 0 {
    429 					index += len(*plist) + 1
    430 				}
    431 
    432 				if index >= 0 && index < len(*plist) {
    433 					*plist = append((*plist)[:index+1], (*plist)[index:]...)
    434 					(*plist)[index] = elt
    435 				} else {
    436 					index = len(*plist)
    437 					*plist = append(*plist, elt)
    438 				}
    439 				natsim.ircSendf("Inserted filter element %s%d/%d", name, index+1, len(*plist))
    440 			}
    441 
    442 		case "filters":
    443 			var buf strings.Builder
    444 			buf.WriteString(fmt.Sprintf("Active filters: %d NATS, %d log, %d IRC", len(natsim.Nats.Filter), len(natsim.Log.Filter), len(natsim.Irc.Filter)))
    445 			WriteFilter(&buf, "\n N", natsim.Nats.Filter)
    446 			WriteFilter(&buf, "\n L", natsim.Log.Filter)
    447 			WriteFilter(&buf, "\n I", natsim.Irc.Filter)
    448 			natsim.ircSend(buf.String())
    449 
    450 		case "hdata":
    451 			if decoded, err := hexDecode(cmd.arg); err != nil {
    452 				natsim.ircSendError("hexDecode", err)
    453 			} else {
    454 				natsim.curMsg.Data = append(natsim.curMsg.Data, []byte(decoded)...)
    455 			}
    456 
    457 		case "header":
    458 			sep := ": "
    459 			if natsim.Irc.ShowHeader != nil {
    460 				sep = natsim.Irc.ShowHeader.Mid
    461 			}
    462 			if key, value, found := strings.Cut(cmd.arg, sep); !found {
    463 				natsim.ircSendf("No header separator %q", sep)
    464 			} else {
    465 				if natsim.curMsg.Header == nil {
    466 					natsim.curMsg.Header = make(nats.Header)
    467 				}
    468 				natsim.curMsg.Header[key] = append(natsim.curMsg.Header[key], value)
    469 			}
    470 
    471 		case "max-base64":
    472 			fallthrough
    473 		case "maxbase64":
    474 			if val, err := strconv.Atoi(cmd.arg); err != nil {
    475 				natsim.ircSendError("Parse MaxBase64", err)
    476 			} else {
    477 				natsim.Irc.MaxBase64 = val
    478 			}
    479 
    480 		case "max-hex":
    481 			fallthrough
    482 		case "maxhex":
    483 			if val, err := strconv.Atoi(cmd.arg); err != nil {
    484 				natsim.ircSendError("Parse MaxHex", err)
    485 			} else {
    486 				natsim.Irc.MaxHex = val
    487 			}
    488 
    489 		case "max-quote-ratio":
    490 			fallthrough
    491 		case "maxquoteratio":
    492 			if val, err := strconv.ParseFloat(cmd.arg, 32); err != nil {
    493 				natsim.ircSendError("Parse MaxQuoteRatio", err)
    494 			} else {
    495 				natsim.Irc.MaxQuoteRatio = float32(val)
    496 			}
    497 
    498 		case "new-inbox":
    499 			fallthrough
    500 		case "newinbox":
    501 			inbox := natsim.nc.NewInbox()
    502 			natsim.curMsg.Reply = inbox
    503 			natsim.ircSendf("Reply-To: %q", inbox)
    504 
    505 		case "qdata":
    506 			if unquoted, err := strconv.Unquote(cmd.arg); err != nil {
    507 				natsim.ircSendError("Unquote", err)
    508 			} else {
    509 				natsim.curMsg.Data = append(natsim.curMsg.Data, []byte(unquoted)...)
    510 			}
    511 
    512 		case "quit":
    513 			log.Println("Quit command", cmd.arg)
    514 			natsim.irc.QuitMessage = cmd.arg
    515 			natsim.Close()
    516 
    517 		case "reply-to":
    518 			fallthrough
    519 		case "replyto":
    520 			natsim.curMsg.Reply = cmd.arg
    521 
    522 		case "send":
    523 			if natsim.curMsg.Subject == "" {
    524 				natsim.ircSend("Cannot send message without subject")
    525 			} else if err := natsim.nc.PublishMsg(&natsim.curMsg); err != nil {
    526 				natsim.ircSendError("Publish", err)
    527 			} else {
    528 				natsim.logSent(&natsim.curMsg)
    529 			}
    530 			if natsim.Irc.AutoClear {
    531 				natsim.curMsg = nats.Msg{}
    532 			}
    533 			natsim.Irc.AutoClear = natsim.Irc.nextClear
    534 
    535 		case "status":
    536 			var buf strings.Builder
    537 
    538 			if err := natsim.nc.LastError(); err != nil {
    539 				buf.WriteString("Last error: ")
    540 				buf.WriteString(err.Error())
    541 				buf.WriteString("\n")
    542 			}
    543 
    544 			buf.WriteString(natsim.nc.Status().String())
    545 
    546 			if url := natsim.nc.ConnectedUrlRedacted(); url != "" {
    547 				buf.WriteString(" to ")
    548 				buf.WriteString(url)
    549 			}
    550 
    551 			if rtt, err := natsim.nc.RTT(); err == nil {
    552 				buf.WriteString(", RTT ")
    553 				buf.WriteString(rtt.String())
    554 			}
    555 
    556 			buf.WriteString(fmt.Sprintf(", %d subscriptions\n%s", natsim.nc.NumSubscriptions(), natsim.natsStats()))
    557 			natsim.ircSend(buf.String())
    558 
    559 		case "subject":
    560 			natsim.curMsg.Subject = cmd.arg
    561 
    562 		case "subscribe":
    563 			if s, err := natsim.nc.Subscribe(cmd.arg, natsim.natsReceive); err != nil {
    564 				natsim.ircSendError("Subscribe", err)
    565 			} else {
    566 				natsim.subs = append(natsim.subs, s)
    567 				natsim.ircSendf("Subscribed to %q", s.Subject)
    568 			}
    569 
    570 		case "subscriptions":
    571 			var buf strings.Builder
    572 			buf.WriteString(fmt.Sprintf("Current subscriptions (%d):", len(natsim.subs)))
    573 			for i, s := range natsim.subs {
    574 				buf.WriteString(fmt.Sprintf("\n%d. %s", i+1, s.Subject))
    575 			}
    576 			natsim.ircSend(buf.String())
    577 
    578 		case "unfilter":
    579 			uplace := strings.ToUpper(cmd.arg)
    580 
    581 			if uplace == "NATS" {
    582 				n := len(natsim.Nats.Filter)
    583 				natsim.Nats.Filter = []FilterElement{}
    584 				natsim.ircSendf("Removed %d NATS filter elements", n)
    585 			} else if uplace == "LOG" {
    586 				n := len(natsim.Log.Filter)
    587 				natsim.Log.Filter = []FilterElement{}
    588 				natsim.ircSendf("Removed %d log filter elements", n)
    589 			} else if uplace == "IRC" {
    590 				n := len(natsim.Irc.Filter)
    591 				natsim.Irc.Filter = []FilterElement{}
    592 				natsim.ircSendf("Removed %d IRC filter elements", n)
    593 			} else if n, err := strconv.Atoi(uplace[1:]); err == nil && (uplace[0:1] == "N" || uplace[0:1] == "L" || uplace[0:1] == "I") {
    594 				var plist *[]FilterElement
    595 				index := n - 1
    596 				name := uplace[0:1]
    597 				switch name {
    598 				case "N":
    599 					plist = &natsim.Nats.Filter
    600 				case "L":
    601 					plist = &natsim.Log.Filter
    602 				case "I":
    603 					plist = &natsim.Irc.Filter
    604 				}
    605 				if n < 0 {
    606 					index = len(*plist) + n
    607 				}
    608 				if index < 0 || index >= len(*plist) {
    609 					natsim.ircSendf("Bad filter index %d for %s%d", index, name, len(*plist))
    610 				} else {
    611 					*plist = append((*plist)[:index], (*plist)[index+1:]...)
    612 					natsim.ircSendf("Removed filter %s%d/%d", name, index+1, len(*plist)+1)
    613 				}
    614 			} else {
    615 				natsim.ircSendf("Unable to parse place %q", uplace)
    616 			}
    617 
    618 		case "unsubscribe":
    619 			if n, err := strconv.Atoi(cmd.arg); err == nil && n > 0 && n <= len(natsim.subs) {
    620 				if err = natsim.subs[n-1].Unsubscribe(); err != nil {
    621 					natsim.ircSendError("Unsubscribe", err)
    622 				} else {
    623 					natsim.ircSendf("Unsubscribed from %q", natsim.subs[n-1].Subject)
    624 					natsim.subs = append(natsim.subs[:n-1], natsim.subs[n:]...)
    625 				}
    626 			} else {
    627 				n := 0
    628 				for i, s := range natsim.subs {
    629 					if s.Subject != cmd.arg {
    630 						natsim.subs[n] = natsim.subs[i]
    631 						n++
    632 					} else if err = s.Unsubscribe(); err != nil {
    633 						natsim.ircSendError("Unsubscribe", err)
    634 					}
    635 				}
    636 				natsim.ircSendf("Unsubscribed from %d subjects", len(natsim.subs)-n)
    637 				natsim.subs = natsim.subs[:n]
    638 			}
    639 
    640 		case "version":
    641 			natsim.ircSendf("natsim %s", version)
    642 
    643 		default:
    644 			natsim.ircSendf("Unknown command %q", cmd.name)
    645 		}
    646 	}
    647 }
    648 
    649 /**************** IRC Callbacks ****************/
    650 
    651 func (natsim *NatsIM) ircJoin(e *irc.Event) {
    652 	natsim.irc.Join(natsim.Irc.Channel)
    653 }
    654 
    655 func (natsim *NatsIM) ircJoined(e *irc.Event) {
    656 	optSeed, err := nats.NkeyOptionFromSeed(natsim.Nats.NkeySeed)
    657 	if err != nil {
    658 		natsim.ircSendError("NkeyOptionFromSeed", err)
    659 		return
    660 	}
    661 
    662 	natsim.nc, err = nats.Connect(natsim.Nats.Server,
    663 		optSeed,
    664 		nats.Name(natsim.Nats.Name),
    665 		nats.RetryOnFailedConnect(natsim.Nats.RetryOnFailedConnect),
    666 		nats.ConnectHandler(natsim.natsConnected),
    667 		nats.DisconnectErrHandler(natsim.natsDisconnected),
    668 		nats.ReconnectHandler(natsim.natsReconnected),
    669 		nats.ReconnectErrHandler(natsim.natsReconnectErr))
    670 	if err != nil {
    671 		natsim.ircSendError("Connect", err)
    672 		return
    673 	}
    674 
    675 	for _, subject := range natsim.Nats.Subjects {
    676 		if s, err := natsim.nc.Subscribe(subject, natsim.natsReceive); err != nil {
    677 			natsim.ircSendError("Subscribe", err)
    678 		} else {
    679 			natsim.subs = append(natsim.subs, s)
    680 		}
    681 	}
    682 }
    683 
    684 func (natsim *NatsIM) ircQuoteData(data []byte) string {
    685 	strdata := string(data)
    686 	suffix := 0
    687 	for len(strdata) > 0 && strdata[len(strdata)-1] == '\n' {
    688 		suffix++
    689 		strdata = strdata[0 : len(strdata)-1]
    690 	}
    691 
    692 	var quoted strings.Builder
    693 	for i, line := range strings.Split(strdata, "\n") {
    694 		if i > 0 {
    695 			quoted.WriteString("\n")
    696 		}
    697 		qline := strconv.QuoteToGraphic(line)
    698 		if qline[0] != '"' || qline[len(qline)-1] != '"' {
    699 			panic("Expected double-quotes")
    700 		}
    701 		quoted.WriteString(qline[1 : len(qline)-1])
    702 	}
    703 	quoted.WriteString(strings.Repeat("\\n", suffix))
    704 
    705 	if natsim.Irc.MaxQuoteRatio < 0 || quoted.Len() < int(natsim.Irc.MaxQuoteRatio*float32(len(data))) {
    706 		s := quoted.String()
    707 		switch s[0] {
    708 		case '"', '#', '|', '<':
    709 			return "\"" + s + "\""
    710 		default:
    711 			return s
    712 		}
    713 	} else if natsim.Irc.MaxHex < 0 || 2*len(data) <= natsim.Irc.MaxHex {
    714 		return "#" + hex.EncodeToString(data) + "#"
    715 	} else if natsim.Irc.MaxBase64 < 0 || base64.StdEncoding.EncodedLen(len(data)) <= natsim.Irc.MaxBase64 {
    716 		return "|" + base64.StdEncoding.EncodeToString(data) + "|"
    717 	} else {
    718 		return fmt.Sprintf("<%d-byte message>", len(data))
    719 	}
    720 }
    721 
    722 func (natsim *NatsIM) ircReceive(e *irc.Event) {
    723 	msg := e.Message()
    724 	if name, arg, found := unpackMark(natsim.Irc.Cmd, msg, true); found {
    725 		if nickAllowed(e.Nick, natsim.Irc.AllowSend, natsim.Irc.BlockSend) {
    726 			natsim.cmdQueue <- command{name: name, arg: arg}
    727 		}
    728 	} else if subject, data, found := unpackMark(natsim.Irc.Send, msg, false); found {
    729 		if nickAllowed(e.Nick, natsim.Irc.AllowCmd, natsim.Irc.BlockCmd) {
    730 			if len(data) >= 2 && data[0] == data[len(data)-1] && (data[0] == '"' || data[0] == '`' || data[0] == '#' || data[0] == '|') {
    731 				switch data[0] {
    732 				case '#':
    733 					if decoded, err := hexDecode(data[1 : len(data)-1]); err != nil {
    734 						natsim.ircSendError("hexDecode", err)
    735 						return
    736 					} else {
    737 						natsim.curMsg.Data = decoded
    738 					}
    739 				case '|':
    740 					if decoded, err := b64Decode(data[1 : len(data)-1]); err != nil {
    741 						natsim.ircSendError("b64Decode", err)
    742 						return
    743 					} else {
    744 						natsim.curMsg.Data = decoded
    745 					}
    746 				default:
    747 					if unquoted, err := strconv.Unquote(data); err != nil {
    748 						natsim.ircSendError("Unquote", err)
    749 						return
    750 					} else {
    751 						natsim.curMsg.Data = []byte(unquoted)
    752 					}
    753 				}
    754 			} else if unquoted, err := strconv.Unquote("\"" + data + "\""); err == nil {
    755 				natsim.curMsg.Data = []byte(unquoted)
    756 			} else {
    757 				natsim.curMsg.Data = []byte(data)
    758 			}
    759 
    760 			natsim.curMsg.Subject = subject
    761 
    762 			if err := natsim.nc.PublishMsg(&natsim.curMsg); err != nil {
    763 				natsim.ircSendError("Publish", err)
    764 			} else {
    765 				natsim.logSent(&natsim.curMsg)
    766 			}
    767 			if natsim.Irc.AutoClear {
    768 				natsim.curMsg = nats.Msg{}
    769 			}
    770 			natsim.Irc.AutoClear = natsim.Irc.nextClear
    771 		}
    772 	}
    773 }
    774 
    775 func (natsim *NatsIM) ircSendError(context string, err error) {
    776 	prefix := "[E] "
    777 	if context != "" {
    778 		prefix += context + ": "
    779 	}
    780 	natsim.ircSend(prefix + err.Error())
    781 }
    782 
    783 func (natsim *NatsIM) ircSend(s string) {
    784 	select {
    785 	case natsim.ircQueue <- s:
    786 	default:
    787 		natsim.dropped.Add(1)
    788 	}
    789 }
    790 
    791 func (natsim *NatsIM) ircSendf(format string, a ...interface{}) {
    792 	natsim.ircSend(fmt.Sprintf(format, a...))
    793 }
    794 
    795 func (natsim *NatsIM) ircSender() {
    796 	var dropped uint32
    797 	var nindex = 0
    798 	floodend := make([]time.Time, natsim.Irc.AntiFlood.count)
    799 	delay := natsim.Irc.AntiFlood.delay / time.Duration(natsim.Irc.AntiFlood.count)
    800 	prev := time.Now()
    801 
    802 	for {
    803 		var lines []string
    804 
    805 		if len(natsim.ircQueue) == 0 {
    806 			dropped += natsim.dropped.Swap(0)
    807 		}
    808 
    809 		if dropped > 0 {
    810 			select {
    811 			case s, ok := <-natsim.ircQueue:
    812 				if ok {
    813 					lines = natsim.ircSplit(s)
    814 				} else {
    815 					return
    816 				}
    817 			case <-time.After(delay):
    818 				dropped += natsim.dropped.Swap(0)
    819 				lines = []string{fmt.Sprintf("Dropped %d messages", dropped)}
    820 				dropped = 0
    821 			}
    822 		} else {
    823 			s, ok := <-natsim.ircQueue
    824 			if ok {
    825 				lines = natsim.ircSplit(s)
    826 			} else {
    827 				return
    828 			}
    829 		}
    830 
    831 		for _, line := range lines {
    832 			if time.Until(floodend[nindex]) > 0 {
    833 				time.Sleep(time.Until(prev.Add(delay)))
    834 			}
    835 
    836 			natsim.irc.Privmsg(natsim.Irc.Channel, line)
    837 
    838 			prev = time.Now()
    839 			floodend[nindex] = prev.Add(natsim.Irc.AntiFlood.delay)
    840 			nindex = (nindex + 1) % natsim.Irc.AntiFlood.count
    841 		}
    842 	}
    843 }
    844 
    845 func (natsim *NatsIM) ircSplit(s string) []string {
    846 	var result []string
    847 
    848 	for _, line := range strings.Split(s, "\n") {
    849 		if natsim.Irc.MaxLine <= 0 || len(line) < natsim.Irc.MaxLine {
    850 			result = append(result, line)
    851 		} else {
    852 			for offset := 0; offset < len(line); {
    853 				var buf strings.Builder
    854 				l := len(line) - offset
    855 				if offset > 0 {
    856 					buf.WriteString(natsim.Irc.ContPrefix)
    857 				}
    858 
    859 				if buf.Len()+l <= natsim.Irc.MaxLine {
    860 					buf.WriteString(line[offset:])
    861 				} else {
    862 					l = natsim.Irc.MaxLine - buf.Len() - len(natsim.Irc.ContSuffix)
    863 					buf.WriteString(line[offset : offset+l])
    864 					buf.WriteString(natsim.Irc.ContSuffix)
    865 				}
    866 
    867 				result = append(result, buf.String())
    868 				offset += l
    869 			}
    870 		}
    871 	}
    872 
    873 	return result
    874 }
    875 
    876 /**************** Nats Callbacks ****************/
    877 
    878 func (natsim *NatsIM) natsConnected(c *nats.Conn) {
    879 	natsim.ircSend("Connected to " + c.ConnectedUrlRedacted())
    880 }
    881 
    882 func (natsim *NatsIM) natsDisconnected(c *nats.Conn, err error) {
    883 	if err != nil {
    884 		natsim.ircSendError("Disconnected", err)
    885 	}
    886 }
    887 
    888 func (natsim *NatsIM) natsReceive(m *nats.Msg) {
    889 	if !IsKept(m.Subject, m.Data, natsim.Nats.Filter, true) {
    890 		return
    891 	}
    892 
    893 	if IsKept(m.Subject, m.Data, natsim.Log.Filter, true) {
    894 		natsim.logReceived(m)
    895 	}
    896 
    897 	if !IsKept(m.Subject, m.Data, natsim.Irc.Filter, true) {
    898 		return
    899 	}
    900 
    901 	var sb strings.Builder
    902 	sb.WriteString(packMark(natsim.Irc.Show, m.Subject, natsim.ircQuoteData(m.Data)))
    903 
    904 	if m.Reply != "" && natsim.Irc.ShowReply != nil {
    905 		sb.WriteString(natsim.Irc.ShowReply.Start)
    906 		sb.WriteString(m.Reply)
    907 		sb.WriteString(natsim.Irc.ShowReply.End)
    908 	}
    909 
    910 	if natsim.Irc.ShowHeader != nil {
    911 		for key, values := range m.Header {
    912 			for _, value := range values {
    913 				sb.WriteString(packMark(*natsim.Irc.ShowHeader, key, value))
    914 			}
    915 		}
    916 	}
    917 
    918 	natsim.ircSend(sb.String())
    919 }
    920 
    921 func (natsim *NatsIM) natsReconnected(c *nats.Conn) {
    922 	natsim.ircSend("Reconnected to " + c.ConnectedUrlRedacted())
    923 }
    924 
    925 func (natsim *NatsIM) natsReconnectErr(c *nats.Conn, err error) {
    926 	natsim.ircSendError("Reconnect", err)
    927 }
    928 
    929 func (natsim *NatsIM) natsStats() string {
    930 	stats := natsim.nc.Stats()
    931 	return fmt.Sprintf("%d reconnections, %s / %s msg in, %s / %s msg out",
    932 		stats.Reconnects,
    933 		humanize.IBytes(stats.InBytes),
    934 		humanizeNum(stats.InMsgs),
    935 		humanize.IBytes(stats.OutBytes),
    936 		humanizeNum(stats.OutMsgs))
    937 }
    938 
    939 /**************** Log to Database ****************/
    940 
    941 //go:embed init.sql
    942 var embeddedSQL embed.FS
    943 
    944 func (natsim *NatsIM) logInit() error {
    945 	if natsim.Log.SqlDriver == "" {
    946 		return nil
    947 	}
    948 
    949 	var err error
    950 
    951 	natsim.db, err = sql.Open(natsim.Log.SqlDriver, natsim.Log.SqlConnection)
    952 	if err != nil {
    953 		log.Println("sql.Open:", err)
    954 		return err
    955 	}
    956 
    957 	var version int
    958 	if err = natsim.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
    959 		log.Println("query user_verison", err)
    960 		return err
    961 	}
    962 
    963 	switch version {
    964 	case 0:
    965 		initSQL, err := embeddedSQL.ReadFile("init.sql")
    966 		if err != nil {
    967 			log.Println("embedded.ReadFile:", err)
    968 			return err
    969 		}
    970 
    971 		if _, err = natsim.db.Exec(string(initSQL)); err != nil {
    972 			log.Println("Init log DB:", err)
    973 			return err
    974 		}
    975 
    976 	case 1:
    977 
    978 	default:
    979 		log.Println("Unsupported database version:", version)
    980 		return errors.New("unsupported database version")
    981 	}
    982 
    983 	natsim.ensureSubject, err = natsim.db.Prepare("INSERT INTO subjects(name) SELECT ? WHERE NOT EXISTS (SELECT 1 FROM subjects WHERE name = ?);")
    984 	if err != nil {
    985 		log.Println("Prepare ensureSubject:", err)
    986 		return err
    987 	}
    988 
    989 	natsim.insertReceived, err = natsim.db.Prepare("INSERT INTO received(timestamp,subject_id,reply_subject_id,data) VALUES (?, (SELECT id FROM subjects WHERE name = ?), (SELECT id FROM subjects WHERE name = ?), ?);")
    990 	if err != nil {
    991 		log.Println("Prepare insertReceived:", err)
    992 		return err
    993 	}
    994 
    995 	natsim.insertRHeader, err = natsim.db.Prepare("INSERT INTO received_headers_view(msg_id,key,value) VALUES (?,?,?);")
    996 	if err != nil {
    997 		log.Println("Prepare insertRHeader:", err)
    998 		return err
    999 	}
   1000 
   1001 	natsim.insertSent, err = natsim.db.Prepare("INSERT INTO sent(timestamp,subject_id,reply_subject_id,data) VALUES (?, (SELECT id FROM subjects WHERE name = ?), (SELECT id FROM subjects WHERE name = ?), ?);")
   1002 	if err != nil {
   1003 		log.Println("Prepare insertSent:", err)
   1004 		return err
   1005 	}
   1006 
   1007 	natsim.insertSHeader, err = natsim.db.Prepare("INSERT INTO sent_headers_view(msg_id,key,value) VALUES (?,?,?);")
   1008 	if err != nil {
   1009 		log.Println("Prepare insertSHeader:", err)
   1010 		return err
   1011 	}
   1012 
   1013 	return nil
   1014 }
   1015 
   1016 func (natsim *NatsIM) logMsg(msg *nats.Msg, insertMsg, insertHeader *sql.Stmt) {
   1017 	if natsim.db == nil || natsim.insertReceived == nil {
   1018 		return
   1019 	}
   1020 
   1021 	if _, err := natsim.ensureSubject.Exec(msg.Subject, msg.Subject); err != nil {
   1022 		natsim.ircSendError("ensureSubject.Exec", err)
   1023 		return
   1024 	}
   1025 
   1026 	var reply sql.NullString
   1027 	if msg.Reply != "" {
   1028 		if _, err := natsim.ensureSubject.Exec(msg.Reply, msg.Reply); err != nil {
   1029 			natsim.ircSendError("ensureReply.Exec", err)
   1030 			return
   1031 		}
   1032 		reply = sql.NullString{String: msg.Reply, Valid: true}
   1033 	}
   1034 
   1035 	t := float64(time.Now().UnixNano())/8.64e13 + 2440587.5
   1036 	if r, err := insertMsg.Exec(t, msg.Subject, reply, msg.Data); err != nil {
   1037 		natsim.ircSendError("insertMsg.Exec", err)
   1038 	} else if id, err := r.LastInsertId(); err != nil {
   1039 		natsim.ircSendError("LastInsertId", err)
   1040 	} else if id <= 0 {
   1041 		natsim.ircSendf("LastInsertId returned invalid id %d", id)
   1042 	} else {
   1043 		for key, values := range msg.Header {
   1044 			for _, value := range values {
   1045 				if _, err := insertHeader.Exec(id, key, value); err != nil {
   1046 					natsim.ircSendf("insertHeader(%q, %q): %s", key, value, err)
   1047 				}
   1048 			}
   1049 		}
   1050 	}
   1051 }
   1052 
   1053 func (natsim *NatsIM) logReceived(msg *nats.Msg) {
   1054 	natsim.logMsg(msg, natsim.insertReceived, natsim.insertRHeader)
   1055 }
   1056 
   1057 func (natsim *NatsIM) logSent(msg *nats.Msg) {
   1058 	natsim.logMsg(msg, natsim.insertSent, natsim.insertSHeader)
   1059 }
   1060 
   1061 /**************** Message Filters ****************/
   1062 
   1063 type FilterElement struct {
   1064 	Result bool
   1065 	Part   FilterPart
   1066 	Test   *regexp.Regexp
   1067 }
   1068 
   1069 type FilterPart int
   1070 
   1071 const (
   1072 	FilterSubject FilterPart = iota
   1073 	FilterData
   1074 )
   1075 
   1076 func (element *FilterElement) Match(subject string, data []byte) bool {
   1077 	var b []byte
   1078 	switch element.Part {
   1079 	case FilterSubject:
   1080 		b = []byte(subject)
   1081 	case FilterData:
   1082 		b = data
   1083 	default:
   1084 		panic("Unexpected part")
   1085 	}
   1086 
   1087 	return element.Test.Match(b)
   1088 }
   1089 
   1090 func (element *FilterElement) String() string {
   1091 	r := "drop "
   1092 	if element.Result {
   1093 		r = "pass "
   1094 	}
   1095 
   1096 	p := ""
   1097 	switch element.Part {
   1098 	case FilterSubject:
   1099 		p = "subject "
   1100 	case FilterData:
   1101 		p = "data "
   1102 	default:
   1103 		panic("Unexpected part")
   1104 	}
   1105 
   1106 	return r + p + element.Test.String()
   1107 }
   1108 
   1109 func (element *FilterElement) UnmarshalText(text []byte) error {
   1110 	s := string(text)
   1111 
   1112 	switch {
   1113 	case strings.HasPrefix(s, "pass "):
   1114 		element.Result = true
   1115 		s = s[5:]
   1116 	case strings.HasPrefix(s, "drop "):
   1117 		element.Result = false
   1118 		s = s[5:]
   1119 	default:
   1120 		return fmt.Errorf("malformed filter %q", s)
   1121 	}
   1122 
   1123 	switch {
   1124 	case strings.HasPrefix(s, "subject "):
   1125 		element.Part = FilterSubject
   1126 		s = s[8:]
   1127 	case strings.HasPrefix(s, "data "):
   1128 		element.Part = FilterData
   1129 		s = s[5:]
   1130 	default:
   1131 		return fmt.Errorf("bad filter part %q", s)
   1132 	}
   1133 
   1134 	re, err := regexp.Compile(s)
   1135 	element.Test = re
   1136 	return err
   1137 }
   1138 
   1139 func WriteFilter(buf *strings.Builder, prefix string, filter []FilterElement) {
   1140 	for i, element := range filter {
   1141 		line := fmt.Sprintf("%s%d. %s", prefix, i+1, element.String())
   1142 		buf.WriteString(line)
   1143 	}
   1144 }
   1145 
   1146 func IsKept(subject string, data []byte, elements []FilterElement, base bool) bool {
   1147 	for _, element := range elements {
   1148 		if element.Match(subject, data) {
   1149 			return element.Result
   1150 		}
   1151 	}
   1152 
   1153 	return base
   1154 }
   1155 
   1156 /**************** Nick Filters ****************/
   1157 
   1158 func updateNickList(nick string, directList, oppositeList *[]string) {
   1159 	if nick == "" {
   1160 		return
   1161 	}
   1162 
   1163 	n := 0
   1164 	for _, v := range *oppositeList {
   1165 		if v != nick {
   1166 			(*oppositeList)[n] = v
   1167 			n++
   1168 		}
   1169 	}
   1170 
   1171 	if n < len(*oppositeList) {
   1172 		*oppositeList = (*oppositeList)[0:n]
   1173 		return
   1174 	}
   1175 
   1176 	for _, v := range *directList {
   1177 		if v == nick {
   1178 			return
   1179 		}
   1180 	}
   1181 
   1182 	*directList = append(*directList, nick)
   1183 }
   1184 
   1185 func nickAllowed(nick string, allowedList, blockedList []string) bool {
   1186 	for _, blocked := range blockedList {
   1187 		if nick == blocked {
   1188 			return false
   1189 		}
   1190 	}
   1191 
   1192 	for _, allowed := range allowedList {
   1193 		if nick == allowed {
   1194 			return true
   1195 		}
   1196 	}
   1197 
   1198 	return len(allowedList) == 0
   1199 }
   1200 
   1201 func strNickList(nickList []string) string {
   1202 	if len(nickList) == 0 {
   1203 		return "[]"
   1204 	}
   1205 
   1206 	var sb strings.Builder
   1207 
   1208 	for i, nick := range nickList {
   1209 		if i == 0 {
   1210 			sb.WriteString("[ ")
   1211 		} else {
   1212 			sb.WriteString(", ")
   1213 		}
   1214 
   1215 		sb.WriteString(fmt.Sprintf("%q", nick))
   1216 	}
   1217 
   1218 	sb.WriteString(" ]")
   1219 	return sb.String()
   1220 }
   1221 
   1222 /**************** Tools ****************/
   1223 
   1224 type antiflood struct {
   1225 	count int
   1226 	delay time.Duration
   1227 }
   1228 
   1229 func (af *antiflood) UnmarshalText(text []byte) error {
   1230 	if before, after, found := strings.Cut(string(text), "/"); found {
   1231 		if n, err := strconv.Atoi(before); err != nil {
   1232 			return err
   1233 		} else {
   1234 			af.count = n
   1235 		}
   1236 
   1237 		if d, err := time.ParseDuration(after); err != nil {
   1238 			return err
   1239 		} else {
   1240 			af.delay = d
   1241 		}
   1242 
   1243 	} else if d, err := time.ParseDuration(string(text)); err != nil {
   1244 		return err
   1245 	} else {
   1246 		af.count = 1
   1247 		af.delay = d
   1248 	}
   1249 
   1250 	return nil
   1251 }
   1252 
   1253 func b64Decode(s string) ([]byte, error) {
   1254 	stripped := strings.ReplaceAll(s, " ", "")
   1255 	return base64.StdEncoding.DecodeString(stripped)
   1256 }
   1257 
   1258 func hexDecode(s string) ([]byte, error) {
   1259 	stripped := strings.ReplaceAll(s, " ", "")
   1260 	return hex.DecodeString(stripped)
   1261 }
   1262 
   1263 func humanizeNum(n uint64) string {
   1264 	num, unit, found := strings.Cut(humanize.Bytes(n), " ")
   1265 	if !found || unit == "" || unit[len(unit)-1:] != "B" {
   1266 		panic("Unexpected huamized result")
   1267 	}
   1268 	return num + unit[:len(unit)-1]
   1269 }
   1270 
   1271 func packMark(mark LineMark, name, arg string) string {
   1272 	return mark.Start + name + mark.Mid + arg + mark.End
   1273 }
   1274 
   1275 func unpackMark(mark LineMark, line string, optional bool) (string, string, bool) {
   1276 	if strings.HasPrefix(line, mark.Start) && strings.HasSuffix(line, mark.End) {
   1277 		inside := line[len(mark.Start) : len(line)-len(mark.End)]
   1278 		if mark.Mid == "" {
   1279 			return inside, "", true
   1280 		} else if name, arg, found := strings.Cut(inside, mark.Mid); found {
   1281 			return name, arg, true
   1282 		} else {
   1283 			return inside, "", optional
   1284 		}
   1285 	} else {
   1286 		return "", "", false
   1287 	}
   1288 }
   1289 
   1290 var version = "(unknown)"
   1291 
   1292 func setVersion() {
   1293 	info, ok := debug.ReadBuildInfo()
   1294 	if !ok {
   1295 		return
   1296 	}
   1297 
   1298 	version = info.Main.Version
   1299 
   1300 	if version == "(devel)" {
   1301 		vcs := ""
   1302 		rev := ""
   1303 		dirty := ""
   1304 		for _, setting := range info.Settings {
   1305 			switch setting.Key {
   1306 			case "vcs":
   1307 				vcs = setting.Value + "-"
   1308 			case "vcs.revision":
   1309 				rev = setting.Value[0:8]
   1310 			case "vcs.modified":
   1311 				if setting.Value == "true" {
   1312 					dirty = "*"
   1313 				}
   1314 			}
   1315 		}
   1316 
   1317 		if rev != "" {
   1318 			version = vcs + rev + dirty
   1319 		}
   1320 	}
   1321 }