Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion bot/bot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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, " ")
}

Expand Down
8 changes: 7 additions & 1 deletion bot/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
26 changes: 22 additions & 4 deletions bot/message_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package bot
import (
"gopkg.in/irc.v3"
"testing"
"time"
)

func TestParseMessage(t *testing.T) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down