diff --git a/bot/bot.go b/bot/bot.go index b146370..9cc03fb 100644 --- a/bot/bot.go +++ b/bot/bot.go @@ -470,6 +470,9 @@ func handleSASL(client *irc.Client, message *irc.Message, username, password str if hasCapability(message.Trailing(), "account-tag") { log.Info("server supports account-tag") } + if hasCapability(message.Trailing(), "server-time") { + log.Info("server supports server-time") + } client.Write("CAP REQ :" + request) } else { log.Warn("server did not advertise requested IRC capabilities") @@ -502,13 +505,16 @@ func handleSASL(client *irc.Client, message *irc.Message, username, password str } func capabilityRequest(advertised string, saslEnabled bool) string { - requested := make([]string, 0, 2) + requested := make([]string, 0, 3) if saslEnabled && hasCapability(advertised, "sasl") { requested = append(requested, "sasl") } if hasCapability(advertised, "account-tag") { requested = append(requested, "account-tag") } + if hasCapability(advertised, "server-time") { + requested = append(requested, "server-time") + } return strings.Join(requested, " ") } diff --git a/bot/message.go b/bot/message.go index 9d65e5c..9e9b591 100644 --- a/bot/message.go +++ b/bot/message.go @@ -21,7 +21,13 @@ func ParseMessage(m *irc.Message) Message { text = strings.Join(m.Params[1:], " ") } account, _ := m.GetTag("account") - return Message{Raw: m.String(), Nick: m.Name, User: m.User, Host: m.Host, Account: account, Command: m.Command, Target: target, Text: text, IsChannel: strings.HasPrefix(strings.ToLower(target), "#") || strings.HasPrefix(strings.ToLower(target), "&"), Timestamp: time.Now()} + timestamp := time.Now() + if rawTimestamp, ok := m.GetTag("time"); ok { + if parsed, err := time.Parse(time.RFC3339Nano, rawTimestamp); err == nil { + timestamp = parsed + } + } + return Message{Raw: m.String(), Nick: m.Name, User: m.User, Host: m.Host, Account: account, Command: m.Command, Target: target, Text: text, IsChannel: strings.HasPrefix(strings.ToLower(target), "#") || strings.HasPrefix(strings.ToLower(target), "&"), Timestamp: timestamp} } func (m Message) ReplyTarget() string { if m.IsChannel { diff --git a/bot/message_test.go b/bot/message_test.go index 4086364..32ca559 100644 --- a/bot/message_test.go +++ b/bot/message_test.go @@ -3,6 +3,7 @@ package bot import ( "gopkg.in/irc.v3" "testing" + "time" ) func TestParseMessage(t *testing.T) { @@ -12,6 +13,23 @@ func TestParseMessage(t *testing.T) { } } +func TestParseMessageUsesServerTimeWhenAvailable(t *testing.T) { + m := ParseMessage(irc.MustParseMessage("@account=alice;time=2026-08-02T19:00:00.123Z :alice!u@h PRIVMSG #test :hello")) + want := time.Date(2026, time.August, 2, 19, 0, 0, 123000000, time.UTC) + if !m.Timestamp.Equal(want) { + t.Fatalf("timestamp = %s, want %s", m.Timestamp, want) + } +} + +func TestParseMessageFallsBackToLocalTimeForInvalidServerTime(t *testing.T) { + before := time.Now() + m := ParseMessage(irc.MustParseMessage("@time=not-a-timestamp :alice!u@h PRIVMSG #test :hello")) + after := time.Now() + if m.Timestamp.Before(before) || m.Timestamp.After(after) { + t.Fatalf("timestamp = %s, want parse time between %s and %s", m.Timestamp, before, after) + } +} + func TestIsCommandRejectsEmptyPrefix(t *testing.T) { m := Message{Target: "#test", IsChannel: true, Text: "ordinary text"} if _, _, ok := IsCommand(m, ""); ok { @@ -74,11 +92,11 @@ func TestPrivateReloadIsOwnerOnly(t *testing.T) { } func TestCapabilityRequestIncludesAccountTag(t *testing.T) { - if got := capabilityRequest("multi-prefix sasl account-tag away", true); got != "sasl account-tag" { - t.Fatalf("capabilityRequest with SASL = %q, want %q", got, "sasl account-tag") + if got := capabilityRequest("multi-prefix sasl account-tag server-time away", true); got != "sasl account-tag server-time" { + t.Fatalf("capabilityRequest with SASL = %q, want %q", got, "sasl account-tag server-time") } - if got := capabilityRequest("account-tag", false); got != "account-tag" { - t.Fatalf("capabilityRequest without SASL = %q, want %q", got, "account-tag") + if got := capabilityRequest("account-tag server-time", false); got != "account-tag server-time" { + t.Fatalf("capabilityRequest without SASL = %q, want %q", got, "account-tag server-time") } if got := capabilityRequest("sasl", false); got != "" { t.Fatalf("capabilityRequest without account tag = %q, want empty", got) diff --git a/docs/security.md b/docs/security.md index 397174e..19a4c83 100644 --- a/docs/security.md +++ b/docs/security.md @@ -9,6 +9,11 @@ dependencies, and deployment configuration maintained. - Keep secrets in .env or a deployment secret store, never in Git. - Use authenticated IRC account names for owner controls; nicknames are not authorization proof. +- GoBot requests the IRCv3 `account-tag` capability when the server advertises + it, and uses that tag for owner checks; if the server does not provide it, + GoBot does not fall back to nickname-based ownership. +- GoBot also requests IRCv3 `server-time` when available so persisted message + timestamps reflect the IRC server clock. - The private `reload` command is accepted only from an authenticated account listed in `owner_accounts`; it cannot change ownership or connection settings.