3
0
mirror of https://github.com/ergochat/ergo.git synced 2024-11-26 05:49:25 +01:00

Merge remote-tracking branch 'origin/master' into compat.4

This commit is contained in:
Shivaram Lingamneni 2019-05-11 21:56:41 -04:00
commit 5331afbdd4
17 changed files with 527 additions and 236 deletions

View File

@ -159,6 +159,12 @@ CAPDEFS = [
url="https://wiki.znc.in/Query_buffers", url="https://wiki.znc.in/Query_buffers",
standard="ZNC vendor", standard="ZNC vendor",
), ),
CapDef(
identifier="EventPlayback",
name="draft/event-playback",
url="https://github.com/ircv3/ircv3-specifications/pull/362",
standard="Proposed IRCv3",
),
] ]
def validate_defs(): def validate_defs():

View File

@ -237,7 +237,10 @@ func (am *AccountManager) BouncerAllowed(account string, session *Session) bool
if !config.Accounts.Bouncer.Enabled { if !config.Accounts.Bouncer.Enabled {
return false return false
} }
return config.Accounts.Bouncer.AllowedByDefault || session.capabilities.Has(caps.Bouncer) if config.Accounts.Bouncer.AllowedByDefault {
return true
}
return session != nil && session.capabilities.Has(caps.Bouncer)
} }
// Looks up the enforcement method stored in the database for an account // Looks up the enforcement method stored in the database for an account

View File

@ -7,7 +7,7 @@ package caps
const ( const (
// number of recognized capabilities: // number of recognized capabilities:
numCapabs = 24 numCapabs = 25
// length of the uint64 array that represents the bitset: // length of the uint64 array that represents the bitset:
bitsetLen = 1 bitsetLen = 1
) )
@ -108,6 +108,10 @@ const (
// ZNCSelfMessage is the ZNC vendor capability named "znc.in/self-message": // ZNCSelfMessage is the ZNC vendor capability named "znc.in/self-message":
// https://wiki.znc.in/Query_buffers // https://wiki.znc.in/Query_buffers
ZNCSelfMessage Capability = iota ZNCSelfMessage Capability = iota
// EventPlayback is the Proposed IRCv3 capability named "draft/event-playback":
// https://github.com/ircv3/ircv3-specifications/pull/362
EventPlayback Capability = iota
) )
// `capabilityNames[capab]` is the string name of the capability `capab` // `capabilityNames[capab]` is the string name of the capability `capab`
@ -137,5 +141,6 @@ var (
"userhost-in-names", "userhost-in-names",
"oragono.io/bnc", "oragono.io/bnc",
"znc.in/self-message", "znc.in/self-message",
"draft/event-playback",
} }
) )

View File

@ -536,6 +536,8 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
client.server.logger.Debug("join", fmt.Sprintf("%s joined channel %s", details.nick, chname)) client.server.logger.Debug("join", fmt.Sprintf("%s joined channel %s", details.nick, chname))
var message utils.SplitMessage
givenMode := func() (givenMode modes.Mode) { givenMode := func() (givenMode modes.Mode) {
channel.joinPartMutex.Lock() channel.joinPartMutex.Lock()
defer channel.joinPartMutex.Unlock() defer channel.joinPartMutex.Unlock()
@ -559,14 +561,15 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
channel.regenerateMembersCache() channel.regenerateMembersCache()
message := utils.SplitMessage{} message = utils.MakeSplitMessage("", true)
message.Msgid = details.realname histItem := history.Item{
channel.history.Add(history.Item{
Type: history.Join, Type: history.Join,
Nick: details.nickMask, Nick: details.nickMask,
AccountName: details.accountName, AccountName: details.accountName,
Message: message, Message: message,
}) }
histItem.Params[0] = details.realname
channel.history.Add(histItem)
return return
}() }()
@ -587,9 +590,9 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
continue continue
} }
if session.capabilities.Has(caps.ExtendedJoin) { if session.capabilities.Has(caps.ExtendedJoin) {
session.Send(nil, details.nickMask, "JOIN", chname, details.accountName, details.realname) session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, nil, "JOIN", chname, details.accountName, details.realname)
} else { } else {
session.Send(nil, details.nickMask, "JOIN", chname) session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, nil, "JOIN", chname)
} }
if givenMode != 0 { if givenMode != 0 {
session.Send(nil, client.server.name, "MODE", chname, modestr, details.nick) session.Send(nil, client.server.name, "MODE", chname, modestr, details.nick)
@ -598,9 +601,9 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
} }
if rb.session.capabilities.Has(caps.ExtendedJoin) { if rb.session.capabilities.Has(caps.ExtendedJoin) {
rb.Add(nil, details.nickMask, "JOIN", chname, details.accountName, details.realname) rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, nil, "JOIN", chname, details.accountName, details.realname)
} else { } else {
rb.Add(nil, details.nickMask, "JOIN", chname) rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, nil, "JOIN", chname)
} }
if rb.session.client == client { if rb.session.client == client {
@ -613,10 +616,13 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
rb.Flush(true) rb.Flush(true)
replayLimit := channel.server.Config().History.AutoreplayOnJoin replayLimit := channel.server.Config().History.AutoreplayOnJoin
if replayLimit > 0 { if 0 < replayLimit {
// TODO don't replay the client's own JOIN line?
items := channel.history.Latest(replayLimit) items := channel.history.Latest(replayLimit)
channel.replayHistoryItems(rb, items) if 0 < len(items) {
rb.Flush(true) channel.replayHistoryItems(rb, items, true)
rb.Flush(true)
}
} }
} }
@ -647,14 +653,16 @@ func (channel *Channel) Part(client *Client, message string, rb *ResponseBuffer)
channel.Quit(client) channel.Quit(client)
splitMessage := utils.MakeSplitMessage(message, true)
details := client.Details() details := client.Details()
for _, member := range channel.Members() { for _, member := range channel.Members() {
member.Send(nil, details.nickMask, "PART", chname, message) member.sendFromClientInternal(false, splitMessage.Time, splitMessage.Msgid, details.nickMask, details.accountName, nil, "PART", chname, message)
} }
rb.Add(nil, details.nickMask, "PART", chname, message) rb.AddFromClient(splitMessage.Time, splitMessage.Msgid, details.nickMask, details.accountName, nil, "PART", chname, message)
for _, session := range client.Sessions() { for _, session := range client.Sessions() {
if session != rb.session { if session != rb.session {
session.Send(nil, details.nickMask, "PART", chname, message) session.sendFromClientInternal(false, splitMessage.Time, splitMessage.Msgid, details.nickMask, details.accountName, nil, "PART", chname, message)
} }
} }
@ -662,7 +670,7 @@ func (channel *Channel) Part(client *Client, message string, rb *ResponseBuffer)
Type: history.Part, Type: history.Part,
Nick: details.nickMask, Nick: details.nickMask,
AccountName: details.accountName, AccountName: details.accountName,
Message: utils.MakeSplitMessage(message, true), Message: splitMessage,
}) })
client.server.logger.Debug("part", fmt.Sprintf("%s left channel %s", details.nick, chname)) client.server.logger.Debug("part", fmt.Sprintf("%s left channel %s", details.nick, chname))
@ -748,7 +756,7 @@ func (channel *Channel) resumeAndAnnounce(newClient, oldClient *Client) {
func (channel *Channel) replayHistoryForResume(newClient *Client, after time.Time, before time.Time) { func (channel *Channel) replayHistoryForResume(newClient *Client, after time.Time, before time.Time) {
items, complete := channel.history.Between(after, before, false, 0) items, complete := channel.history.Between(after, before, false, 0)
rb := NewResponseBuffer(newClient.Sessions()[0]) rb := NewResponseBuffer(newClient.Sessions()[0])
channel.replayHistoryItems(rb, items) channel.replayHistoryItems(rb, items, false)
if !complete && !newClient.resumeDetails.HistoryIncomplete { if !complete && !newClient.resumeDetails.HistoryIncomplete {
// warn here if we didn't warn already // warn here if we didn't warn already
rb.Add(nil, "HistServ", "NOTICE", channel.Name(), newClient.t("Some additional message history may have been lost")) rb.Add(nil, "HistServ", "NOTICE", channel.Name(), newClient.t("Some additional message history may have been lost"))
@ -759,50 +767,93 @@ func (channel *Channel) replayHistoryForResume(newClient *Client, after time.Tim
func stripMaskFromNick(nickMask string) (nick string) { func stripMaskFromNick(nickMask string) (nick string) {
index := strings.Index(nickMask, "!") index := strings.Index(nickMask, "!")
if index == -1 { if index == -1 {
return return nickMask
} }
return nickMask[0:index] return nickMask[0:index]
} }
func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.Item) { // munge the msgid corresponding to a replayable event,
// yielding a consistent msgid for the fake PRIVMSG from HistServ
func mungeMsgidForHistserv(token string) (result string) {
return fmt.Sprintf("_%s", token)
}
func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.Item, autoreplay bool) {
chname := channel.Name() chname := channel.Name()
client := rb.target client := rb.target
serverTime := rb.session.capabilities.Has(caps.ServerTime) eventPlayback := rb.session.capabilities.Has(caps.EventPlayback)
extendedJoin := rb.session.capabilities.Has(caps.ExtendedJoin)
if len(items) == 0 {
return
}
batchID := rb.StartNestedHistoryBatch(chname)
defer rb.EndNestedBatch(batchID)
for _, item := range items { for _, item := range items {
var tags map[string]string nick := stripMaskFromNick(item.Nick)
if serverTime {
tags = map[string]string{"time": item.Time.Format(IRCv3TimestampFormat)}
}
// TODO(#437) support history.Tagmsg
switch item.Type { switch item.Type {
case history.Privmsg: case history.Privmsg:
rb.AddSplitMessageFromClient(item.Nick, item.AccountName, tags, "PRIVMSG", chname, item.Message) rb.AddSplitMessageFromClient(item.Nick, item.AccountName, item.Tags, "PRIVMSG", chname, item.Message)
case history.Notice: case history.Notice:
rb.AddSplitMessageFromClient(item.Nick, item.AccountName, tags, "NOTICE", chname, item.Message) rb.AddSplitMessageFromClient(item.Nick, item.AccountName, item.Tags, "NOTICE", chname, item.Message)
case history.Join: case history.Tagmsg:
nick := stripMaskFromNick(item.Nick) if rb.session.capabilities.Has(caps.MessageTags) {
var message string rb.AddSplitMessageFromClient(item.Nick, item.AccountName, item.Tags, "TAGMSG", chname, item.Message)
if item.AccountName == "*" { }
message = fmt.Sprintf(client.t("%s joined the channel"), nick) case history.Join:
} else { if eventPlayback {
message = fmt.Sprintf(client.t("%[1]s [account: %[2]s] joined the channel"), nick, item.AccountName) if extendedJoin {
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "JOIN", chname, item.AccountName, item.Params[0])
} else {
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "JOIN", chname)
}
} else {
if autoreplay {
continue // #474
}
var message string
if item.AccountName == "*" {
message = fmt.Sprintf(client.t("%s joined the channel"), nick)
} else {
message = fmt.Sprintf(client.t("%[1]s [account: %[2]s] joined the channel"), nick, item.AccountName)
}
rb.AddFromClient(item.Message.Time, mungeMsgidForHistserv(item.Message.Msgid), "HistServ", "*", nil, "PRIVMSG", chname, message)
} }
rb.Add(tags, "HistServ", "PRIVMSG", chname, message)
case history.Part: case history.Part:
nick := stripMaskFromNick(item.Nick) if eventPlayback {
message := fmt.Sprintf(client.t("%[1]s left the channel (%[2]s)"), nick, item.Message.Message) rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "PART", chname, item.Message.Message)
rb.Add(tags, "HistServ", "PRIVMSG", chname, message) } else {
case history.Quit: if autoreplay {
nick := stripMaskFromNick(item.Nick) continue // #474
message := fmt.Sprintf(client.t("%[1]s quit (%[2]s)"), nick, item.Message.Message) }
rb.Add(tags, "HistServ", "PRIVMSG", chname, message) message := fmt.Sprintf(client.t("%[1]s left the channel (%[2]s)"), nick, item.Message.Message)
rb.AddFromClient(item.Message.Time, mungeMsgidForHistserv(item.Message.Msgid), "HistServ", "*", nil, "PRIVMSG", chname, message)
}
case history.Kick: case history.Kick:
nick := stripMaskFromNick(item.Nick) if eventPlayback {
// XXX Msgid is the kick target rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "KICK", chname, item.Params[0], item.Message.Message)
message := fmt.Sprintf(client.t("%[1]s kicked %[2]s (%[3]s)"), nick, item.Message.Msgid, item.Message.Message) } else {
rb.Add(tags, "HistServ", "PRIVMSG", chname, message) message := fmt.Sprintf(client.t("%[1]s kicked %[2]s (%[3]s)"), nick, item.Params[0], item.Message.Message)
rb.AddFromClient(item.Message.Time, mungeMsgidForHistserv(item.Message.Msgid), "HistServ", "*", nil, "PRIVMSG", chname, message)
}
case history.Quit:
if eventPlayback {
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "QUIT", item.Message.Message)
} else {
if autoreplay {
continue // #474
}
message := fmt.Sprintf(client.t("%[1]s quit (%[2]s)"), nick, item.Message.Message)
rb.AddFromClient(item.Message.Time, mungeMsgidForHistserv(item.Message.Msgid), "HistServ", "*", nil, "PRIVMSG", chname, message)
}
case history.Nick:
if eventPlayback {
rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "NICK", item.Params[0])
} else {
message := fmt.Sprintf(client.t("%[1]s changed nick to %[2]s"), nick, item.Params[0])
rb.AddFromClient(item.Message.Time, mungeMsgidForHistserv(item.Message.Msgid), "HistServ", "*", nil, "PRIVMSG", chname, message)
}
} }
} }
} }
@ -934,7 +985,7 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
tagsToUse = clientOnlyTags tagsToUse = clientOnlyTags
} }
if histType == history.Tagmsg && rb.session.capabilities.Has(caps.MessageTags) { if histType == history.Tagmsg && rb.session.capabilities.Has(caps.MessageTags) {
rb.AddFromClient(message.Msgid, nickmask, account, tagsToUse, command, chname) rb.AddFromClient(message.Time, message.Msgid, nickmask, account, tagsToUse, command, chname)
} else { } else {
rb.AddSplitMessageFromClient(nickmask, account, tagsToUse, command, chname, message) rb.AddSplitMessageFromClient(nickmask, account, tagsToUse, command, chname, message)
} }
@ -986,7 +1037,7 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
Message: message, Message: message,
Nick: nickmask, Nick: nickmask,
AccountName: account, AccountName: account,
Time: now, Tags: clientOnlyTags,
}) })
} }
@ -1082,27 +1133,29 @@ func (channel *Channel) Kick(client *Client, target *Client, comment string, rb
comment = comment[:kicklimit] comment = comment[:kicklimit]
} }
message := utils.MakeSplitMessage(comment, true)
clientMask := client.NickMaskString() clientMask := client.NickMaskString()
clientAccount := client.AccountName()
targetNick := target.Nick() targetNick := target.Nick()
chname := channel.Name() chname := channel.Name()
for _, member := range channel.Members() { for _, member := range channel.Members() {
for _, session := range member.Sessions() { for _, session := range member.Sessions() {
if session != rb.session { if session != rb.session {
session.Send(nil, clientMask, "KICK", chname, targetNick, comment) session.sendFromClientInternal(false, message.Time, message.Msgid, clientMask, clientAccount, nil, "KICK", chname, targetNick, comment)
} }
} }
} }
rb.Add(nil, clientMask, "KICK", chname, targetNick, comment) rb.Add(nil, clientMask, "KICK", chname, targetNick, comment)
message := utils.SplitMessage{} histItem := history.Item{
message.Message = comment
message.Msgid = targetNick // XXX abuse this field
channel.history.Add(history.Item{
Type: history.Kick, Type: history.Kick,
Nick: clientMask, Nick: clientMask,
AccountName: target.AccountName(), AccountName: target.AccountName(),
Message: message, Message: message,
}) }
histItem.Params[0] = targetNick
channel.history.Add(histItem)
channel.Quit(target) channel.Quit(target)
} }

View File

@ -94,7 +94,14 @@ type Client struct {
type Session struct { type Session struct {
client *Client client *Client
socket *Socket ctime time.Time
atime time.Time
socket *Socket
realIP net.IP
proxiedIP net.IP
rawHostname string
idletimer IdleTimer idletimer IdleTimer
fakelag Fakelag fakelag Fakelag
@ -104,9 +111,6 @@ type Session struct {
maxlenRest uint32 maxlenRest uint32
capState caps.State capState caps.State
capVersion caps.Version capVersion caps.Version
// TODO track per-connection real IP, proxied IP, and hostname here,
// so we can list attached sessions and their details
} }
// sets the session quit message, if there isn't one already // sets the session quit message, if there isn't one already
@ -187,6 +191,8 @@ func RunNewClient(server *Server, conn clientConn) {
socket: socket, socket: socket,
capVersion: caps.Cap301, capVersion: caps.Cap301,
capState: caps.NoneState, capState: caps.NoneState,
ctime: now,
atime: now,
} }
session.SetMaxlenRest() session.SetMaxlenRest()
client.sessions = []*Session{session} client.sessions = []*Session{session}
@ -197,20 +203,29 @@ func RunNewClient(server *Server, conn clientConn) {
client.certfp, _ = socket.CertFP() client.certfp, _ = socket.CertFP()
} }
remoteAddr := conn.Conn.RemoteAddr()
if conn.IsTor { if conn.IsTor {
client.SetMode(modes.TLS, true) client.SetMode(modes.TLS, true)
client.realIP = utils.IPv4LoopbackAddress session.realIP = utils.AddrToIP(remoteAddr)
client.rawHostname = config.Server.TorListeners.Vhost // cover up details of the tor proxying infrastructure (not a user privacy concern,
// but a hardening measure):
session.proxiedIP = utils.IPv4LoopbackAddress
session.rawHostname = config.Server.TorListeners.Vhost
} else { } else {
remoteAddr := conn.Conn.RemoteAddr() session.realIP = utils.AddrToIP(remoteAddr)
client.realIP = utils.AddrToIP(remoteAddr) // set the hostname for this client (may be overridden later by PROXY or WEBIRC)
// Set the hostname for this client session.rawHostname = utils.LookupHostname(session.realIP.String())
// (may be overridden by a later PROXY command from stunnel) if utils.AddrIsLocal(remoteAddr) {
client.rawHostname = utils.LookupHostname(client.realIP.String()) // treat local connections as secure (may be overridden later by WEBIRC)
client.SetMode(modes.TLS, true)
}
if config.Server.CheckIdent && !utils.AddrIsUnix(remoteAddr) { if config.Server.CheckIdent && !utils.AddrIsUnix(remoteAddr) {
client.doIdentLookup(conn.Conn) client.doIdentLookup(conn.Conn)
} }
} }
client.realIP = session.realIP
client.rawHostname = session.rawHostname
client.proxiedIP = session.proxiedIP
client.run(session) client.run(session)
} }
@ -308,8 +323,10 @@ func (client *Client) run(session *Session) {
session.resetFakelag() session.resetFakelag()
isReattach := client.Registered() isReattach := client.Registered()
// don't reset the nick timer during a reattach if isReattach {
if !isReattach { client.playReattachMessages(session)
} else {
// don't reset the nick timer during a reattach
client.nickTimer.Initialize(client) client.nickTimer.Initialize(client)
} }
@ -371,14 +388,14 @@ func (client *Client) run(session *Session) {
break break
} else if session.client != client { } else if session.client != client {
// bouncer reattach // bouncer reattach
session.playReattachMessages()
go session.client.run(session) go session.client.run(session)
break break
} }
} }
} }
func (session *Session) playReattachMessages() { func (client *Client) playReattachMessages(session *Session) {
client.server.playRegistrationBurst(session)
for _, channel := range session.client.Channels() { for _, channel := range session.client.Channels() {
channel.playJoinForSession(session) channel.playJoinForSession(session)
} }
@ -389,10 +406,13 @@ func (session *Session) playReattachMessages() {
// //
// Active updates when the client was last 'active' (i.e. the user should be sitting in front of their client). // Active updates when the client was last 'active' (i.e. the user should be sitting in front of their client).
func (client *Client) Active() { func (client *Client) Active(session *Session) {
// TODO normalize all times to utc?
now := time.Now()
client.stateMutex.Lock() client.stateMutex.Lock()
defer client.stateMutex.Unlock() defer client.stateMutex.Unlock()
client.atime = time.Now() session.atime = now
client.atime = now
} }
// Ping sends the client a PING message. // Ping sends the client a PING message.
@ -487,7 +507,7 @@ func (client *Client) tryResume() (success bool) {
} }
} }
privmsgMatcher := func(item history.Item) bool { privmsgMatcher := func(item history.Item) bool {
return item.Type == history.Privmsg || item.Type == history.Notice return item.Type == history.Privmsg || item.Type == history.Notice || item.Type == history.Tagmsg
} }
privmsgHistory := oldClient.history.Match(privmsgMatcher, false, 0) privmsgHistory := oldClient.history.Match(privmsgMatcher, false, 0)
lastDiscarded := oldClient.history.LastDiscarded() lastDiscarded := oldClient.history.LastDiscarded()
@ -495,8 +515,7 @@ func (client *Client) tryResume() (success bool) {
oldestLostMessage = lastDiscarded oldestLostMessage = lastDiscarded
} }
for _, item := range privmsgHistory { for _, item := range privmsgHistory {
// TODO this is the nickmask, fix that sender := server.clients.Get(stripMaskFromNick(item.Nick))
sender := server.clients.Get(item.Nick)
if sender != nil { if sender != nil {
friends.Add(sender) friends.Add(sender)
} }
@ -559,8 +578,13 @@ func (client *Client) tryResumeChannels() {
} }
func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.Item, complete bool) { func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.Item, complete bool) {
var batchID string
nick := client.Nick() nick := client.Nick()
serverTime := rb.session.capabilities.Has(caps.ServerTime) if 0 < len(items) {
batchID = rb.StartNestedHistoryBatch(nick)
}
allowTags := rb.session.capabilities.Has(caps.MessageTags)
for _, item := range items { for _, item := range items {
var command string var command string
switch item.Type { switch item.Type {
@ -568,15 +592,23 @@ func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.I
command = "PRIVMSG" command = "PRIVMSG"
case history.Notice: case history.Notice:
command = "NOTICE" command = "NOTICE"
case history.Tagmsg:
if allowTags {
command = "TAGMSG"
} else {
continue
}
default: default:
continue continue
} }
var tags map[string]string var tags map[string]string
if serverTime { if allowTags {
tags = map[string]string{"time": item.Time.Format(IRCv3TimestampFormat)} tags = item.Tags
} }
rb.AddSplitMessageFromClient(item.Nick, item.AccountName, tags, command, nick, item.Message) rb.AddSplitMessageFromClient(item.Nick, item.AccountName, tags, command, nick, item.Message)
} }
rb.EndNestedBatch(batchID)
if !complete { if !complete {
rb.Add(nil, "HistServ", "NOTICE", nick, client.t("Some additional message history may have been lost")) rb.Add(nil, "HistServ", "NOTICE", nick, client.t("Some additional message history may have been lost"))
} }
@ -891,14 +923,11 @@ func (client *Client) destroy(beingResumed bool, session *Session) {
// allow destroy() to execute at most once // allow destroy() to execute at most once
client.stateMutex.Lock() client.stateMutex.Lock()
nickMaskString := client.nickMaskString details := client.detailsNoMutex()
accountName := client.accountName wasReattach := session != nil && session.client != client
alreadyDestroyed := len(client.sessions) == 0
sessionRemoved := false sessionRemoved := false
var remainingSessions int var remainingSessions int
if session == nil { if session == nil {
sessionRemoved = !alreadyDestroyed
sessionsToDestroy = client.sessions sessionsToDestroy = client.sessions
client.sessions = nil client.sessions = nil
remainingSessions = 0 remainingSessions = 0
@ -908,27 +937,42 @@ func (client *Client) destroy(beingResumed bool, session *Session) {
sessionsToDestroy = []*Session{session} sessionsToDestroy = []*Session{session}
} }
} }
var quitMessage string
if 0 < len(sessionsToDestroy) {
quitMessage = sessionsToDestroy[0].quitMessage
}
client.stateMutex.Unlock() client.stateMutex.Unlock()
if alreadyDestroyed || !sessionRemoved { if len(sessionsToDestroy) == 0 {
return return
} }
// destroy all applicable sessions:
var quitMessage string
for _, session := range sessionsToDestroy { for _, session := range sessionsToDestroy {
if session.client != client { if session.client != client {
// session has been attached to a new client; do not destroy it // session has been attached to a new client; do not destroy it
continue continue
} }
session.idletimer.Stop() session.idletimer.Stop()
session.socket.Close()
// send quit/error message to client if they haven't been sent already // send quit/error message to client if they haven't been sent already
client.Quit("", session) client.Quit("", session)
quitMessage = session.quitMessage
session.socket.Close()
// remove from connection limits
var source string
if client.isTor {
client.server.torLimiter.RemoveClient()
source = "tor"
} else {
ip := session.realIP
if session.proxiedIP != nil {
ip = session.proxiedIP
}
client.server.connectionLimiter.RemoveClient(ip)
source = ip.String()
}
client.server.logger.Info("localconnect-ip", fmt.Sprintf("disconnecting session of %s from %s", details.nick, source))
} }
// ok, now destroy the client, unless it still has sessions:
if remainingSessions != 0 { if remainingSessions != 0 {
return return
} }
@ -939,39 +983,37 @@ func (client *Client) destroy(beingResumed bool, session *Session) {
defer client.server.semaphores.ClientDestroy.Release() defer client.server.semaphores.ClientDestroy.Release()
if beingResumed { if beingResumed {
client.server.logger.Debug("quit", fmt.Sprintf("%s is being resumed", client.nick)) client.server.logger.Debug("quit", fmt.Sprintf("%s is being resumed", details.nick))
} else { } else if !wasReattach {
client.server.logger.Debug("quit", fmt.Sprintf("%s is no longer on the server", client.nick)) client.server.logger.Debug("quit", fmt.Sprintf("%s is no longer on the server", details.nick))
} }
if !beingResumed { registered := client.Registered()
if !beingResumed && registered {
client.server.whoWas.Append(client.WhoWas()) client.server.whoWas.Append(client.WhoWas())
} }
// remove from connection limits
if client.isTor {
client.server.torLimiter.RemoveClient()
} else {
client.server.connectionLimiter.RemoveClient(client.IP())
}
client.server.resumeManager.Delete(client) client.server.resumeManager.Delete(client)
// alert monitors // alert monitors
client.server.monitorManager.AlertAbout(client, false) if registered {
client.server.monitorManager.AlertAbout(client, false)
}
// clean up monitor state // clean up monitor state
client.server.monitorManager.RemoveAll(client) client.server.monitorManager.RemoveAll(client)
splitQuitMessage := utils.MakeSplitMessage(quitMessage, true)
// clean up channels // clean up channels
// (note that if this is a reattach, client has no channels and therefore no friends)
friends := make(ClientSet) friends := make(ClientSet)
for _, channel := range client.Channels() { for _, channel := range client.Channels() {
if !beingResumed { if !beingResumed {
channel.Quit(client) channel.Quit(client)
channel.history.Add(history.Item{ channel.history.Add(history.Item{
Type: history.Quit, Type: history.Quit,
Nick: nickMaskString, Nick: details.nickMask,
AccountName: accountName, AccountName: details.accountName,
Message: utils.MakeSplitMessage(quitMessage, true), Message: splitQuitMessage,
}) })
} }
for _, member := range channel.Members() { for _, member := range channel.Members() {
@ -1006,14 +1048,14 @@ func (client *Client) destroy(beingResumed bool, session *Session) {
if quitMessage == "" { if quitMessage == "" {
quitMessage = "Exited" quitMessage = "Exited"
} }
friend.Send(nil, client.nickMaskString, "QUIT", quitMessage) friend.sendFromClientInternal(false, splitQuitMessage.Time, splitQuitMessage.Msgid, details.nickMask, details.accountName, nil, "QUIT", quitMessage)
} }
} }
if !client.exitedSnomaskSent { if !client.exitedSnomaskSent {
if beingResumed { if beingResumed {
client.server.snomasks.Send(sno.LocalQuits, fmt.Sprintf(ircfmt.Unescape("%s$r is resuming their connection, old client has been destroyed"), client.nick)) client.server.snomasks.Send(sno.LocalQuits, fmt.Sprintf(ircfmt.Unescape("%s$r is resuming their connection, old client has been destroyed"), client.nick))
} else { } else {
client.server.snomasks.Send(sno.LocalQuits, fmt.Sprintf(ircfmt.Unescape("%s$r exited the network"), client.nick)) client.server.snomasks.Send(sno.LocalQuits, fmt.Sprintf(ircfmt.Unescape("%s$r exited the network"), details.nick))
} }
} }
} }
@ -1030,15 +1072,7 @@ func (session *Session) sendSplitMsgFromClientInternal(blocking bool, serverTime
} }
} }
// SendFromClient sends an IRC line coming from a specific client. // Sends a line with `nickmask` as the prefix, adding `time` and `account` tags if supported
// Adds account-tag to the line as well.
func (client *Client) SendFromClient(msgid string, from *Client, tags map[string]string, command string, params ...string) error {
return client.sendFromClientInternal(false, time.Time{}, msgid, from.NickMaskString(), from.AccountName(), tags, command, params...)
}
// this is SendFromClient, but directly exposing nickmask and accountName,
// for things like history replay and CHGHOST where they no longer (necessarily)
// correspond to the current state of a client
func (client *Client) sendFromClientInternal(blocking bool, serverTime time.Time, msgid string, nickmask, accountName string, tags map[string]string, command string, params ...string) (err error) { func (client *Client) sendFromClientInternal(blocking bool, serverTime time.Time, msgid string, nickmask, accountName string, tags map[string]string, command string, params ...string) (err error) {
for _, session := range client.Sessions() { for _, session := range client.Sessions() {
err_ := session.sendFromClientInternal(blocking, serverTime, msgid, nickmask, accountName, tags, command, params...) err_ := session.sendFromClientInternal(blocking, serverTime, msgid, nickmask, accountName, tags, command, params...)
@ -1061,7 +1095,10 @@ func (session *Session) sendFromClientInternal(blocking bool, serverTime time.Ti
} }
// attach server-time // attach server-time
if session.capabilities.Has(caps.ServerTime) { if session.capabilities.Has(caps.ServerTime) {
msg.SetTag("time", time.Now().UTC().Format(IRCv3TimestampFormat)) if serverTime.IsZero() {
serverTime = time.Now().UTC()
}
msg.SetTag("time", serverTime.Format(IRCv3TimestampFormat))
} }
return session.SendRawMessage(msg, blocking) return session.SendRawMessage(msg, blocking)

View File

@ -152,7 +152,7 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
currentClient := clients.byNick[newcfnick] currentClient := clients.byNick[newcfnick]
// the client may just be changing case // the client may just be changing case
if currentClient != nil && currentClient != client { if currentClient != nil && currentClient != client && session != nil {
// these conditions forbid reattaching to an existing session: // these conditions forbid reattaching to an existing session:
if client.Registered() || !bouncerAllowed || account == "" || account != currentClient.Account() || client.isTor != currentClient.isTor || client.HasMode(modes.TLS) != currentClient.HasMode(modes.TLS) { if client.Registered() || !bouncerAllowed || account == "" || account != currentClient.Account() || client.isTor != currentClient.isTor || client.HasMode(modes.TLS) != currentClient.HasMode(modes.TLS) {
return errNicknameInUse return errNicknameInUse
@ -160,9 +160,7 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
if !currentClient.AddSession(session) { if !currentClient.AddSession(session) {
return errNicknameInUse return errNicknameInUse
} }
// successful reattach. temporarily assign them the nick they'll have going forward // successful reattach!
// (the current `client` will be discarded at the end of command execution)
client.updateNick(currentClient.Nick(), newcfnick, newSkeleton)
return nil return nil
} }
// analogous checks for skeletons // analogous checks for skeletons

View File

@ -58,8 +58,8 @@ func (cmd *Command) Run(server *Server, client *Client, session *Session, msg ir
session.idletimer.Touch() session.idletimer.Touch()
} }
if !cmd.leaveClientIdle { if client.registered && !cmd.leaveClientIdle {
client.Active() client.Active(session)
} }
return exiting return exiting

View File

@ -73,7 +73,9 @@ func (client *Client) ApplyProxiedIP(session *Session, proxiedIP string, tls boo
client.stateMutex.Lock() client.stateMutex.Lock()
defer client.stateMutex.Unlock() defer client.stateMutex.Unlock()
session.proxiedIP = parsedProxiedIP
client.proxiedIP = parsedProxiedIP client.proxiedIP = parsedProxiedIP
session.rawHostname = rawHostname
client.rawHostname = rawHostname client.rawHostname = rawHostname
// nickmask will be updated when the client completes registration // nickmask will be updated when the client completes registration
// set tls info // set tls info

View File

@ -4,6 +4,7 @@
package irc package irc
import ( import (
"net"
"sync/atomic" "sync/atomic"
"time" "time"
"unsafe" "unsafe"
@ -64,6 +65,37 @@ func (client *Client) Sessions() (sessions []*Session) {
return return
} }
type SessionData struct {
ctime time.Time
atime time.Time
ip net.IP
hostname string
}
func (client *Client) AllSessionData(currentSession *Session) (data []SessionData, currentIndex int) {
currentIndex = -1
client.stateMutex.RLock()
defer client.stateMutex.RUnlock()
data = make([]SessionData, len(client.sessions))
for i, session := range client.sessions {
if session == currentSession {
currentIndex = i
}
data[i] = SessionData{
atime: session.atime,
ctime: session.ctime,
hostname: session.rawHostname,
}
if session.proxiedIP != nil {
data[i].ip = session.proxiedIP
} else {
data[i].ip = session.realIP
}
}
return
}
func (client *Client) AddSession(session *Session) (success bool) { func (client *Client) AddSession(session *Session) (success bool) {
client.stateMutex.Lock() client.stateMutex.Lock()
defer client.stateMutex.Unlock() defer client.stateMutex.Unlock()
@ -291,7 +323,10 @@ func (client *Client) WhoWas() (result WhoWas) {
func (client *Client) Details() (result ClientDetails) { func (client *Client) Details() (result ClientDetails) {
client.stateMutex.RLock() client.stateMutex.RLock()
defer client.stateMutex.RUnlock() defer client.stateMutex.RUnlock()
return client.detailsNoMutex()
}
func (client *Client) detailsNoMutex() (result ClientDetails) {
result.nick = client.nick result.nick = client.nick
result.nickCasefolded = client.nickCasefolded result.nickCasefolded = client.nickCasefolded
result.username = client.username result.username = client.username

View File

@ -594,36 +594,36 @@ func capHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Respo
// e.g., CHATHISTORY #ircv3 BETWEEN timestamp=YYYY-MM-DDThh:mm:ss.sssZ timestamp=YYYY-MM-DDThh:mm:ss.sssZ + 100 // e.g., CHATHISTORY #ircv3 BETWEEN timestamp=YYYY-MM-DDThh:mm:ss.sssZ timestamp=YYYY-MM-DDThh:mm:ss.sssZ + 100
func chathistoryHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) (exiting bool) { func chathistoryHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) (exiting bool) {
config := server.Config() config := server.Config()
// batch type is chathistory; send an empty batch if necessary
rb.InitializeBatch("chathistory", true)
var items []history.Item var items []history.Item
success := false success := false
var hist *history.Buffer var hist *history.Buffer
var channel *Channel var channel *Channel
defer func() { defer func() {
if success { // successful responses are sent as a chathistory or history batch
if success && 0 < len(items) {
batchType := "chathistory"
if rb.session.capabilities.Has(caps.EventPlayback) {
batchType = "history"
}
rb.ForceBatchStart(batchType, true)
if channel == nil { if channel == nil {
client.replayPrivmsgHistory(rb, items, true) client.replayPrivmsgHistory(rb, items, true)
} else { } else {
channel.replayHistoryItems(rb, items) channel.replayHistoryItems(rb, items, false)
} }
}
rb.Send(true) // terminate the chathistory batch
if success && len(items) > 0 {
return return
} }
newRb := NewResponseBuffer(rb.session)
newRb.Label = rb.Label // same label, new batch // errors are sent either without a batch, or in a draft/labeled-response batch as usual
// TODO: send `WARN CHATHISTORY MAX_MESSAGES_EXCEEDED` when appropriate // TODO: send `WARN CHATHISTORY MAX_MESSAGES_EXCEEDED` when appropriate
if hist == nil { if hist == nil {
newRb.Add(nil, server.name, "ERR", "CHATHISTORY", "NO_SUCH_CHANNEL") rb.Add(nil, server.name, "ERR", "CHATHISTORY", "NO_SUCH_CHANNEL")
} else if len(items) == 0 { } else if len(items) == 0 {
newRb.Add(nil, server.name, "ERR", "CHATHISTORY", "NO_TEXT_TO_SEND") rb.Add(nil, server.name, "ERR", "CHATHISTORY", "NO_TEXT_TO_SEND")
} else if !success { } else if !success {
newRb.Add(nil, server.name, "ERR", "CHATHISTORY", "NEED_MORE_PARAMS") rb.Add(nil, server.name, "ERR", "CHATHISTORY", "NEED_MORE_PARAMS")
} }
newRb.Send(true)
}() }()
target := msg.Params[0] target := msg.Params[0]
@ -753,7 +753,7 @@ func chathistoryHandler(server *Server, client *Client, msg ircmsg.IrcMessage, r
} }
} else { } else {
matches = func(item history.Item) bool { matches = func(item history.Item) bool {
return before == item.Time.Before(timestamp) return before == item.Message.Time.Before(timestamp)
} }
} }
items = hist.Match(matches, !before, limit) items = hist.Match(matches, !before, limit)
@ -776,7 +776,7 @@ func chathistoryHandler(server *Server, client *Client, msg ircmsg.IrcMessage, r
} }
} else { } else {
matches = func(item history.Item) bool { matches = func(item history.Item) bool {
return item.Time.After(timestamp) return item.Message.Time.After(timestamp)
} }
} }
items = hist.Match(matches, false, limit) items = hist.Match(matches, false, limit)
@ -799,16 +799,16 @@ func chathistoryHandler(server *Server, client *Client, msg ircmsg.IrcMessage, r
} }
} else { } else {
initialMatcher = func(item history.Item) (result bool) { initialMatcher = func(item history.Item) (result bool) {
return item.Time.Before(timestamp) return item.Message.Time.Before(timestamp)
} }
} }
var halfLimit int var halfLimit int
halfLimit = (limit + 1) / 2 halfLimit = (limit + 1) / 2
firstPass := hist.Match(initialMatcher, false, halfLimit) firstPass := hist.Match(initialMatcher, false, halfLimit)
if len(firstPass) > 0 { if len(firstPass) > 0 {
timeWindowStart := firstPass[0].Time timeWindowStart := firstPass[0].Message.Time
items = hist.Match(func(item history.Item) bool { items = hist.Match(func(item history.Item) bool {
return item.Time.Equal(timeWindowStart) || item.Time.After(timeWindowStart) return item.Message.Time.Equal(timeWindowStart) || item.Message.Time.After(timeWindowStart)
}, true, limit) }, true, limit)
} }
success = true success = true
@ -1118,7 +1118,7 @@ func historyHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R
items := hist.Latest(limit) items := hist.Latest(limit)
if channel != nil { if channel != nil {
channel.replayHistoryItems(rb, items) channel.replayHistoryItems(rb, items, false)
} else { } else {
client.replayPrivmsgHistory(rb, items, true) client.replayPrivmsgHistory(rb, items, true)
} }
@ -1963,7 +1963,6 @@ func messageHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R
for i, targetString := range targets { for i, targetString := range targets {
// each target gets distinct msgids // each target gets distinct msgids
splitMsg := utils.MakeSplitMessage(message, !rb.session.capabilities.Has(caps.MaxLine)) splitMsg := utils.MakeSplitMessage(message, !rb.session.capabilities.Has(caps.MaxLine))
now := time.Now().UTC()
// max of four targets per privmsg // max of four targets per privmsg
if i > maxTargets-1 { if i > maxTargets-1 {
@ -2012,17 +2011,17 @@ func messageHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R
if histType == history.Tagmsg { if histType == history.Tagmsg {
// don't send TAGMSG at all if they don't have the tags cap // don't send TAGMSG at all if they don't have the tags cap
if session.capabilities.Has(caps.MessageTags) { if session.capabilities.Has(caps.MessageTags) {
session.sendFromClientInternal(false, now, splitMsg.Msgid, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick) session.sendFromClientInternal(false, splitMsg.Time, splitMsg.Msgid, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick)
} }
} else { } else {
session.sendSplitMsgFromClientInternal(false, now, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick, splitMsg) session.sendSplitMsgFromClientInternal(false, splitMsg.Time, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick, splitMsg)
} }
} }
} }
// an echo-message may need to be included in the response: // an echo-message may need to be included in the response:
if rb.session.capabilities.Has(caps.EchoMessage) { if rb.session.capabilities.Has(caps.EchoMessage) {
if histType == history.Tagmsg && rb.session.capabilities.Has(caps.MessageTags) { if histType == history.Tagmsg && rb.session.capabilities.Has(caps.MessageTags) {
rb.AddFromClient(splitMsg.Msgid, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick) rb.AddFromClient(splitMsg.Time, splitMsg.Msgid, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick)
} else { } else {
rb.AddSplitMessageFromClient(nickMaskString, accountName, clientOnlyTags, msg.Command, tnick, splitMsg) rb.AddSplitMessageFromClient(nickMaskString, accountName, clientOnlyTags, msg.Command, tnick, splitMsg)
} }
@ -2033,9 +2032,9 @@ func messageHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R
continue continue
} }
if histType == history.Tagmsg && rb.session.capabilities.Has(caps.MessageTags) { if histType == history.Tagmsg && rb.session.capabilities.Has(caps.MessageTags) {
session.sendFromClientInternal(false, now, splitMsg.Msgid, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick) session.sendFromClientInternal(false, splitMsg.Time, splitMsg.Msgid, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick)
} else { } else {
session.sendSplitMsgFromClientInternal(false, now, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick, splitMsg) session.sendSplitMsgFromClientInternal(false, splitMsg.Time, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick, splitMsg)
} }
} }
if histType != history.Notice && user.Away() { if histType != history.Notice && user.Away() {

View File

@ -22,25 +22,52 @@ const (
Quit Quit
Mode Mode
Tagmsg Tagmsg
Nick
) )
// a Tagmsg that consists entirely of transient tags is not stored
var transientTags = map[string]bool{
"+draft/typing": true,
"+typing": true, // future-proofing
}
// Item represents an event (e.g., a PRIVMSG or a JOIN) and its associated data // Item represents an event (e.g., a PRIVMSG or a JOIN) and its associated data
type Item struct { type Item struct {
Type ItemType Type ItemType
Time time.Time
Nick string Nick string
// this is the uncasefolded account name, if there's no account it should be set to "*" // this is the uncasefolded account name, if there's no account it should be set to "*"
AccountName string AccountName string
Message utils.SplitMessage
// for non-privmsg items, we may stuff some other data in here // for non-privmsg items, we may stuff some other data in here
Message utils.SplitMessage
Tags map[string]string
Params [1]string
} }
// HasMsgid tests whether a message has the message id `msgid`. // HasMsgid tests whether a message has the message id `msgid`.
func (item *Item) HasMsgid(msgid string) bool { func (item *Item) HasMsgid(msgid string) bool {
// XXX we stuff other data in the Msgid field sometimes, if item.Message.Msgid == msgid {
// don't match it by accident return true
return (item.Type == Privmsg || item.Type == Notice) && item.Message.Msgid == msgid }
for _, pair := range item.Message.Wrapped {
if pair.Msgid == msgid {
return true
}
}
return false
}
func (item *Item) isStorable() bool {
if item.Type == Tagmsg {
for name := range item.Tags {
if !transientTags[name] {
return true
}
}
return false // all tags were blacklisted
} else {
return true
}
} }
type Predicate func(item Item) (matches bool) type Predicate func(item Item) (matches bool)
@ -94,8 +121,12 @@ func (list *Buffer) Add(item Item) {
return return
} }
if item.Time.IsZero() { if !item.isStorable() {
item.Time = time.Now().UTC() return
}
if item.Message.Time.IsZero() {
item.Message.Time = time.Now().UTC()
} }
list.Lock() list.Lock()
@ -114,8 +145,8 @@ func (list *Buffer) Add(item Item) {
list.end = (list.end + 1) % len(list.buffer) list.end = (list.end + 1) % len(list.buffer)
list.start = list.end // advance start as well, overwriting first entry list.start = list.end // advance start as well, overwriting first entry
// record the timestamp of the overwritten item // record the timestamp of the overwritten item
if list.lastDiscarded.Before(list.buffer[pos].Time) { if list.lastDiscarded.Before(list.buffer[pos].Message.Time) {
list.lastDiscarded = list.buffer[pos].Time list.lastDiscarded = list.buffer[pos].Message.Time
} }
} }
@ -144,7 +175,7 @@ func (list *Buffer) Between(after, before time.Time, ascending bool, limit int)
complete = after.Equal(list.lastDiscarded) || after.After(list.lastDiscarded) complete = after.Equal(list.lastDiscarded) || after.After(list.lastDiscarded)
satisfies := func(item Item) bool { satisfies := func(item Item) bool {
return (after.IsZero() || item.Time.After(after)) && (before.IsZero() || item.Time.Before(before)) return (after.IsZero() || item.Message.Time.After(after)) && (before.IsZero() || item.Message.Time.Before(before))
} }
return list.matchInternal(satisfies, ascending, limit), complete return list.matchInternal(satisfies, ascending, limit), complete
@ -264,8 +295,8 @@ func (list *Buffer) Resize(size int) {
} }
// update lastDiscarded for discarded entries // update lastDiscarded for discarded entries
for i := list.start; i != start; i = (i + 1) % len(list.buffer) { for i := list.start; i != start; i = (i + 1) % len(list.buffer) {
if list.lastDiscarded.Before(list.buffer[i].Time) { if list.lastDiscarded.Before(list.buffer[i].Message.Time) {
list.lastDiscarded = list.buffer[i].Time list.lastDiscarded = list.buffer[i].Message.Time
} }
} }
} }

View File

@ -87,6 +87,12 @@ func easyParse(timestamp string) time.Time {
return result return result
} }
func easyItem(nick string, timestamp string) (result Item) {
result.Message.Time = easyParse(timestamp)
result.Nick = nick
return
}
func assertEqual(supplied, expected interface{}, t *testing.T) { func assertEqual(supplied, expected interface{}, t *testing.T) {
if !reflect.DeepEqual(supplied, expected) { if !reflect.DeepEqual(supplied, expected) {
t.Errorf("expected %v but got %v", expected, supplied) t.Errorf("expected %v but got %v", expected, supplied)
@ -97,30 +103,19 @@ func TestBuffer(t *testing.T) {
start := easyParse("2006-01-01 00:00:00Z") start := easyParse("2006-01-01 00:00:00Z")
buf := NewHistoryBuffer(3) buf := NewHistoryBuffer(3)
buf.Add(Item{ buf.Add(easyItem("testnick0", "2006-01-01 15:04:05Z"))
Nick: "testnick0",
Time: easyParse("2006-01-01 15:04:05Z"),
})
buf.Add(Item{ buf.Add(easyItem("testnick1", "2006-01-02 15:04:05Z"))
Nick: "testnick1",
Time: easyParse("2006-01-02 15:04:05Z"),
})
buf.Add(Item{ buf.Add(easyItem("testnick2", "2006-01-03 15:04:05Z"))
Nick: "testnick2",
Time: easyParse("2006-01-03 15:04:05Z"),
})
since, complete := buf.Between(start, time.Now(), false, 0) since, complete := buf.Between(start, time.Now(), false, 0)
assertEqual(complete, true, t) assertEqual(complete, true, t)
assertEqual(toNicks(since), []string{"testnick0", "testnick1", "testnick2"}, t) assertEqual(toNicks(since), []string{"testnick0", "testnick1", "testnick2"}, t)
// add another item, evicting the first // add another item, evicting the first
buf.Add(Item{ buf.Add(easyItem("testnick3", "2006-01-04 15:04:05Z"))
Nick: "testnick3",
Time: easyParse("2006-01-04 15:04:05Z"),
})
since, complete = buf.Between(start, time.Now(), false, 0) since, complete = buf.Between(start, time.Now(), false, 0)
assertEqual(complete, false, t) assertEqual(complete, false, t)
assertEqual(toNicks(since), []string{"testnick1", "testnick2", "testnick3"}, t) assertEqual(toNicks(since), []string{"testnick1", "testnick2", "testnick3"}, t)
@ -139,18 +134,9 @@ func TestBuffer(t *testing.T) {
assertEqual(toNicks(since), []string{"testnick2", "testnick3"}, t) assertEqual(toNicks(since), []string{"testnick2", "testnick3"}, t)
buf.Resize(5) buf.Resize(5)
buf.Add(Item{ buf.Add(easyItem("testnick4", "2006-01-05 15:04:05Z"))
Nick: "testnick4", buf.Add(easyItem("testnick5", "2006-01-06 15:04:05Z"))
Time: easyParse("2006-01-05 15:04:05Z"), buf.Add(easyItem("testnick6", "2006-01-07 15:04:05Z"))
})
buf.Add(Item{
Nick: "testnick5",
Time: easyParse("2006-01-06 15:04:05Z"),
})
buf.Add(Item{
Nick: "testnick6",
Time: easyParse("2006-01-07 15:04:05Z"),
})
since, complete = buf.Between(easyParse("2006-01-03 00:00:00Z"), time.Now(), false, 0) since, complete = buf.Between(easyParse("2006-01-03 00:00:00Z"), time.Now(), false, 0)
assertEqual(complete, true, t) assertEqual(complete, true, t)
assertEqual(toNicks(since), []string{"testnick2", "testnick3", "testnick4", "testnick5", "testnick6"}, t) assertEqual(toNicks(since), []string{"testnick2", "testnick3", "testnick4", "testnick5", "testnick6"}, t)

View File

@ -11,7 +11,9 @@ import (
"strings" "strings"
"github.com/goshuirc/irc-go/ircfmt" "github.com/goshuirc/irc-go/ircfmt"
"github.com/oragono/oragono/irc/history"
"github.com/oragono/oragono/irc/sno" "github.com/oragono/oragono/irc/sno"
"github.com/oragono/oragono/irc/utils"
) )
var ( var (
@ -44,7 +46,7 @@ func performNickChange(server *Server, client *Client, target *Client, session *
hadNick := target.HasNick() hadNick := target.HasNick()
origNickMask := target.NickMaskString() origNickMask := target.NickMaskString()
whowas := target.WhoWas() details := target.Details()
err = client.server.clients.SetNick(target, session, nickname) err = client.server.clients.SetNick(target, session, nickname)
if err == errNicknameInUse { if err == errNicknameInUse {
rb.Add(nil, server.name, ERR_NICKNAMEINUSE, currentNick, nickname, client.t("Nickname is already in use")) rb.Add(nil, server.name, ERR_NICKNAMEINUSE, currentNick, nickname, client.t("Nickname is already in use"))
@ -57,18 +59,31 @@ func performNickChange(server *Server, client *Client, target *Client, session *
return false return false
} }
message := utils.MakeSplitMessage("", true)
histItem := history.Item{
Type: history.Nick,
Nick: origNickMask,
AccountName: details.accountName,
Message: message,
}
histItem.Params[0] = nickname
client.server.logger.Debug("nick", fmt.Sprintf("%s changed nickname to %s [%s]", origNickMask, nickname, cfnick)) client.server.logger.Debug("nick", fmt.Sprintf("%s changed nickname to %s [%s]", origNickMask, nickname, cfnick))
if hadNick { if hadNick {
target.server.snomasks.Send(sno.LocalNicks, fmt.Sprintf(ircfmt.Unescape("$%s$r changed nickname to %s"), whowas.nick, nickname)) target.server.snomasks.Send(sno.LocalNicks, fmt.Sprintf(ircfmt.Unescape("$%s$r changed nickname to %s"), details.nick, nickname))
target.server.whoWas.Append(whowas) target.server.whoWas.Append(details.WhoWas)
rb.Add(nil, origNickMask, "NICK", nickname) rb.AddFromClient(message.Time, message.Msgid, origNickMask, details.accountName, nil, "NICK", nickname)
for session := range target.Friends() { for session := range target.Friends() {
if session != rb.session { if session != rb.session {
session.Send(nil, origNickMask, "NICK", nickname) session.sendFromClientInternal(false, message.Time, message.Msgid, origNickMask, details.accountName, nil, "NICK", nickname)
} }
} }
} }
for _, channel := range client.Channels() {
channel.history.Add(histItem)
}
target.nickTimer.Touch(rb) target.nickTimer.Touch(rb)
if target.Registered() { if target.Registered() {

View File

@ -7,6 +7,8 @@ import (
"fmt" "fmt"
"github.com/goshuirc/irc-go/ircfmt" "github.com/goshuirc/irc-go/ircfmt"
"github.com/oragono/oragono/irc/modes"
) )
// "enabled" callbacks for specific nickserv commands // "enabled" callbacks for specific nickserv commands
@ -26,6 +28,10 @@ func nsEnforceEnabled(config *Config) bool {
return servCmdRequiresNickRes(config) && config.Accounts.NickReservation.AllowCustomEnforcement return servCmdRequiresNickRes(config) && config.Accounts.NickReservation.AllowCustomEnforcement
} }
func servCmdRequiresBouncerEnabled(config *Config) bool {
return config.Accounts.Bouncer.Enabled
}
var ( var (
// ZNC's nickserv module will not detect this unless it is: // ZNC's nickserv module will not detect this unless it is:
// 1. sent with prefix `nickserv` // 1. sent with prefix `nickserv`
@ -142,6 +148,16 @@ an administrator can set use this command to set up user accounts.`,
capabs: []string{"accreg"}, capabs: []string{"accreg"},
minParams: 2, minParams: 2,
}, },
"sessions": {
handler: nsSessionsHandler,
help: `Syntax: $bSESSIONS [nickname]$b
SESSIONS lists information about the sessions currently attached, via
the server's bouncer functionality, to your nickname. An administrator
can use this command to list another user's sessions.`,
helpShort: `$bSESSIONS$b lists the sessions attached to a nickname.`,
enabled: servCmdRequiresBouncerEnabled,
},
"unregister": { "unregister": {
handler: nsUnregisterHandler, handler: nsUnregisterHandler,
help: `Syntax: $bUNREGISTER <username> [code]$b help: `Syntax: $bUNREGISTER <username> [code]$b
@ -569,3 +585,34 @@ func nsEnforceHandler(server *Server, client *Client, command string, params []s
} }
} }
} }
func nsSessionsHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
target := client
if 0 < len(params) {
// same permissions check as RPL_WHOISACTUALLY for now:
if !client.HasMode(modes.Operator) {
nsNotice(rb, client.t("Command restricted"))
return
}
target = server.clients.Get(params[0])
if target == nil {
nsNotice(rb, client.t("No such nick"))
return
}
}
sessionData, currentIndex := target.AllSessionData(rb.session)
nsNotice(rb, fmt.Sprintf(client.t("Nickname %s has %d attached session(s)"), target.Nick(), len(sessionData)))
for i, session := range sessionData {
if currentIndex == i {
nsNotice(rb, fmt.Sprintf(client.t("Session %d (currently attached session):"), i+1))
} else {
nsNotice(rb, fmt.Sprintf(client.t("Session %d:"), i+1))
}
nsNotice(rb, fmt.Sprintf(client.t("IP address: %s"), session.ip.String()))
nsNotice(rb, fmt.Sprintf(client.t("Hostname: %s"), session.hostname))
nsNotice(rb, fmt.Sprintf(client.t("Created at: %s"), session.ctime.Format(IRCv3TimestampFormat)))
nsNotice(rb, fmt.Sprintf(client.t("Last active: %s"), session.atime.Format(IRCv3TimestampFormat)))
}
}

View File

@ -23,8 +23,19 @@ const (
// buffer will silently create a batch if required and label the outgoing messages as // buffer will silently create a batch if required and label the outgoing messages as
// necessary (or leave it off and simply tag the outgoing message). // necessary (or leave it off and simply tag the outgoing message).
type ResponseBuffer struct { type ResponseBuffer struct {
Label string Label string // label if this is a labeled response batch
batchID string batchID string // ID of the labeled response batch, if one has been initiated
batchType string // type of the labeled response batch (possibly `history` or `chathistory`)
// stack of batch IDs of nested batches, which are handled separately
// from the underlying labeled-response batch. starting a new nested batch
// unconditionally enqueues its batch start message; subsequent messages
// are tagged with the nested batch ID, until nested batch end.
// (the nested batch start itself may have no batch tag, or the batch tag of the
// underlying labeled-response batch, or the batch tag of the next outermost
// nested batch.)
nestedBatches []string
messages []ircmsg.IrcMessage messages []ircmsg.IrcMessage
finalized bool finalized bool
target *Client target *Client
@ -40,8 +51,9 @@ func GetLabel(msg ircmsg.IrcMessage) string {
// NewResponseBuffer returns a new ResponseBuffer. // NewResponseBuffer returns a new ResponseBuffer.
func NewResponseBuffer(session *Session) *ResponseBuffer { func NewResponseBuffer(session *Session) *ResponseBuffer {
return &ResponseBuffer{ return &ResponseBuffer{
session: session, session: session,
target: session.client, target: session.client,
batchType: defaultBatchType,
} }
} }
@ -54,6 +66,9 @@ func (rb *ResponseBuffer) AddMessage(msg ircmsg.IrcMessage) {
return return
} }
if 0 < len(rb.nestedBatches) {
msg.SetTag("batch", rb.nestedBatches[len(rb.nestedBatches)-1])
}
rb.messages = append(rb.messages, msg) rb.messages = append(rb.messages, msg)
} }
@ -63,9 +78,11 @@ func (rb *ResponseBuffer) Add(tags map[string]string, prefix string, command str
} }
// AddFromClient adds a new message from a specific client to our queue. // AddFromClient adds a new message from a specific client to our queue.
func (rb *ResponseBuffer) AddFromClient(msgid string, fromNickMask string, fromAccount string, tags map[string]string, command string, params ...string) { func (rb *ResponseBuffer) AddFromClient(time time.Time, msgid string, fromNickMask string, fromAccount string, tags map[string]string, command string, params ...string) {
msg := ircmsg.MakeMessage(nil, fromNickMask, command, params...) msg := ircmsg.MakeMessage(nil, fromNickMask, command, params...)
msg.UpdateTags(tags) if rb.session.capabilities.Has(caps.MessageTags) {
msg.UpdateTags(tags)
}
// attach account-tag // attach account-tag
if rb.session.capabilities.Has(caps.AccountTag) && fromAccount != "*" { if rb.session.capabilities.Has(caps.AccountTag) && fromAccount != "*" {
@ -75,6 +92,10 @@ func (rb *ResponseBuffer) AddFromClient(msgid string, fromNickMask string, fromA
if len(msgid) > 0 && rb.session.capabilities.Has(caps.MessageTags) { if len(msgid) > 0 && rb.session.capabilities.Has(caps.MessageTags) {
msg.SetTag("draft/msgid", msgid) msg.SetTag("draft/msgid", msgid)
} }
// attach server-time
if rb.session.capabilities.Has(caps.ServerTime) && !msg.HasTag("time") {
msg.SetTag("time", time.UTC().Format(IRCv3TimestampFormat))
}
rb.AddMessage(msg) rb.AddMessage(msg)
} }
@ -82,33 +103,31 @@ func (rb *ResponseBuffer) AddFromClient(msgid string, fromNickMask string, fromA
// AddSplitMessageFromClient adds a new split message from a specific client to our queue. // AddSplitMessageFromClient adds a new split message from a specific client to our queue.
func (rb *ResponseBuffer) AddSplitMessageFromClient(fromNickMask string, fromAccount string, tags map[string]string, command string, target string, message utils.SplitMessage) { func (rb *ResponseBuffer) AddSplitMessageFromClient(fromNickMask string, fromAccount string, tags map[string]string, command string, target string, message utils.SplitMessage) {
if rb.session.capabilities.Has(caps.MaxLine) || message.Wrapped == nil { if rb.session.capabilities.Has(caps.MaxLine) || message.Wrapped == nil {
rb.AddFromClient(message.Msgid, fromNickMask, fromAccount, tags, command, target, message.Message) rb.AddFromClient(message.Time, message.Msgid, fromNickMask, fromAccount, tags, command, target, message.Message)
} else { } else {
for _, messagePair := range message.Wrapped { for _, messagePair := range message.Wrapped {
rb.AddFromClient(messagePair.Msgid, fromNickMask, fromAccount, tags, command, target, messagePair.Message) rb.AddFromClient(message.Time, messagePair.Msgid, fromNickMask, fromAccount, tags, command, target, messagePair.Message)
} }
} }
} }
// InitializeBatch forcibly starts a batch of batch `batchType`. // ForceBatchStart forcibly starts a batch of batch `batchType`.
// Normally, Send/Flush will decide automatically whether to start a batch // Normally, Send/Flush will decide automatically whether to start a batch
// of type draft/labeled-response. This allows changing the batch type // of type draft/labeled-response. This allows changing the batch type
// and forcing the creation of a possibly empty batch. // and forcing the creation of a possibly empty batch.
func (rb *ResponseBuffer) InitializeBatch(batchType string, blocking bool) { func (rb *ResponseBuffer) ForceBatchStart(batchType string, blocking bool) {
rb.sendBatchStart(batchType, blocking) rb.batchType = batchType
rb.sendBatchStart(blocking)
} }
func (rb *ResponseBuffer) sendBatchStart(batchType string, blocking bool) { func (rb *ResponseBuffer) sendBatchStart(blocking bool) {
if rb.batchID != "" { if rb.batchID != "" {
// batch already initialized // batch already initialized
return return
} }
// formerly this combined time.Now.UnixNano() in base 36 with an incrementing counter,
// also in base 36. but let's just use a uuidv4-alike (26 base32 characters):
rb.batchID = utils.GenerateSecretToken() rb.batchID = utils.GenerateSecretToken()
message := ircmsg.MakeMessage(nil, rb.target.server.name, "BATCH", "+"+rb.batchID, rb.batchType)
message := ircmsg.MakeMessage(nil, rb.target.server.name, "BATCH", "+"+rb.batchID, batchType)
if rb.Label != "" { if rb.Label != "" {
message.SetTag(caps.LabelTagName, rb.Label) message.SetTag(caps.LabelTagName, rb.Label)
} }
@ -125,6 +144,50 @@ func (rb *ResponseBuffer) sendBatchEnd(blocking bool) {
rb.session.SendRawMessage(message, blocking) rb.session.SendRawMessage(message, blocking)
} }
// Starts a nested batch (see the ResponseBuffer struct definition for a description of
// how this works)
func (rb *ResponseBuffer) StartNestedBatch(batchType string, params ...string) (batchID string) {
batchID = utils.GenerateSecretToken()
msgParams := make([]string, len(params)+2)
msgParams[0] = "+" + batchID
msgParams[1] = batchType
copy(msgParams[2:], params)
rb.AddMessage(ircmsg.MakeMessage(nil, rb.target.server.name, "BATCH", msgParams...))
rb.nestedBatches = append(rb.nestedBatches, batchID)
return
}
// Ends a nested batch
func (rb *ResponseBuffer) EndNestedBatch(batchID string) {
if batchID == "" {
return
}
if 0 == len(rb.nestedBatches) || rb.nestedBatches[len(rb.nestedBatches)-1] != batchID {
rb.target.server.logger.Error("internal", "inconsistent batch nesting detected")
debug.PrintStack()
return
}
rb.nestedBatches = rb.nestedBatches[0 : len(rb.nestedBatches)-1]
rb.AddMessage(ircmsg.MakeMessage(nil, rb.target.server.name, "BATCH", "-"+batchID))
}
// Convenience to start a nested batch for history lines, at the highest level
// supported by the client (`history`, `chathistory`, or no batch, in descending order).
func (rb *ResponseBuffer) StartNestedHistoryBatch(params ...string) (batchID string) {
var batchType string
if rb.session.capabilities.Has(caps.EventPlayback) {
batchType = "history"
} else if rb.session.capabilities.Has(caps.Batch) {
batchType = "chathistory"
}
if batchType != "" {
batchID = rb.StartNestedBatch(batchType, params...)
}
return
}
// Send sends all messages in the buffer to the client. // Send sends all messages in the buffer to the client.
// Afterwards, the buffer is in an undefined state and MUST NOT be used further. // Afterwards, the buffer is in an undefined state and MUST NOT be used further.
// If `blocking` is true you MUST be sending to the client from its own goroutine. // If `blocking` is true you MUST be sending to the client from its own goroutine.
@ -158,7 +221,7 @@ func (rb *ResponseBuffer) flushInternal(final bool, blocking bool) error {
if useLabel && !useBatch && len(rb.messages) == 1 && rb.batchID == "" { if useLabel && !useBatch && len(rb.messages) == 1 && rb.batchID == "" {
rb.messages[0].SetTag(caps.LabelTagName, rb.Label) rb.messages[0].SetTag(caps.LabelTagName, rb.Label)
} else if useBatch { } else if useBatch {
rb.sendBatchStart(defaultBatchType, blocking) rb.sendBatchStart(blocking)
} }
// send each message out // send each message out
@ -168,8 +231,9 @@ func (rb *ResponseBuffer) flushInternal(final bool, blocking bool) error {
message.SetTag("time", time.Now().UTC().Format(IRCv3TimestampFormat)) message.SetTag("time", time.Now().UTC().Format(IRCv3TimestampFormat))
} }
// attach batch ID // attach batch ID, unless this message was part of a nested batch and is
if rb.batchID != "" { // already tagged
if rb.batchID != "" && !message.HasTag("batch") {
message.SetTag("batch", rb.batchID) message.SetTag("batch", rb.batchID)
} }

View File

@ -395,32 +395,43 @@ func (server *Server) tryRegister(c *Client, session *Session) {
} }
} }
reattached := session.client != c if session.client != c {
// reattached, bail out.
if !reattached { // we'll play the reg burst later, on the new goroutine associated with
// registration has succeeded: // (thisSession, otherClient). This is to avoid having to transfer state
c.SetRegistered() // like nickname, hostname, etc. to show the correct values in the reg burst.
return
// count new user in statistics
server.stats.ChangeTotal(1)
if !resumed {
server.monitorManager.AlertAbout(c, true)
}
} }
// registration has succeeded:
c.SetRegistered()
// count new user in statistics
server.stats.ChangeTotal(1)
server.playRegistrationBurst(session)
if resumed {
c.tryResumeChannels()
} else {
server.monitorManager.AlertAbout(c, true)
}
}
func (server *Server) playRegistrationBurst(session *Session) {
c := session.client
// continue registration // continue registration
server.logger.Info("localconnect", fmt.Sprintf("Client connected [%s] [u:%s] [r:%s]", c.nick, c.username, c.realname)) d := c.Details()
server.snomasks.Send(sno.LocalConnects, fmt.Sprintf("Client connected [%s] [u:%s] [h:%s] [ip:%s] [r:%s]", c.nick, c.username, c.rawHostname, c.IPString(), c.realname)) server.logger.Info("localconnect", fmt.Sprintf("Client connected [%s] [u:%s] [r:%s]", d.nick, d.username, d.realname))
server.snomasks.Send(sno.LocalConnects, fmt.Sprintf("Client connected [%s] [u:%s] [h:%s] [ip:%s] [r:%s]", d.nick, d.username, c.RawHostname(), c.IPString(), d.realname))
// send welcome text // send welcome text
//NOTE(dan): we specifically use the NICK here instead of the nickmask //NOTE(dan): we specifically use the NICK here instead of the nickmask
// see http://modern.ircdocs.horse/#rplwelcome-001 for details on why we avoid using the nickmask // see http://modern.ircdocs.horse/#rplwelcome-001 for details on why we avoid using the nickmask
c.Send(nil, server.name, RPL_WELCOME, c.nick, fmt.Sprintf(c.t("Welcome to the Internet Relay Network %s"), c.nick)) session.Send(nil, server.name, RPL_WELCOME, d.nick, fmt.Sprintf(c.t("Welcome to the Internet Relay Network %s"), d.nick))
c.Send(nil, server.name, RPL_YOURHOST, c.nick, fmt.Sprintf(c.t("Your host is %[1]s, running version %[2]s"), server.name, Ver)) session.Send(nil, server.name, RPL_YOURHOST, d.nick, fmt.Sprintf(c.t("Your host is %[1]s, running version %[2]s"), server.name, Ver))
c.Send(nil, server.name, RPL_CREATED, c.nick, fmt.Sprintf(c.t("This server was created %s"), server.ctime.Format(time.RFC1123))) session.Send(nil, server.name, RPL_CREATED, d.nick, fmt.Sprintf(c.t("This server was created %s"), server.ctime.Format(time.RFC1123)))
//TODO(dan): Look at adding last optional [<channel modes with a parameter>] parameter //TODO(dan): Look at adding last optional [<channel modes with a parameter>] parameter
c.Send(nil, server.name, RPL_MYINFO, c.nick, server.name, Ver, supportedUserModesString, supportedChannelModesString) session.Send(nil, server.name, RPL_MYINFO, d.nick, server.name, Ver, supportedUserModesString, supportedChannelModesString)
rb := NewResponseBuffer(session) rb := NewResponseBuffer(session)
c.RplISupport(rb) c.RplISupport(rb)
@ -429,14 +440,10 @@ func (server *Server) tryRegister(c *Client, session *Session) {
modestring := c.ModeString() modestring := c.ModeString()
if modestring != "+" { if modestring != "+" {
c.Send(nil, c.nickMaskString, RPL_UMODEIS, c.nick, c.ModeString()) session.Send(nil, d.nickMask, RPL_UMODEIS, d.nick, modestring)
} }
if server.logger.IsLoggingRawIO() { if server.logger.IsLoggingRawIO() {
c.Notice(c.t("This server is in debug mode and is logging all user I/O. If you do not wish for everything you send to be readable by the server owner(s), please disconnect.")) session.Send(nil, c.server.name, "NOTICE", d.nick, c.t("This server is in debug mode and is logging all user I/O. If you do not wish for everything you send to be readable by the server owner(s), please disconnect."))
}
if resumed {
c.tryResumeChannels()
} }
} }

View File

@ -4,6 +4,7 @@
package utils package utils
import "bytes" import "bytes"
import "time"
// WordWrap wraps the given text into a series of lines that don't exceed lineWidth characters. // WordWrap wraps the given text into a series of lines that don't exceed lineWidth characters.
func WordWrap(text string, lineWidth int) []string { func WordWrap(text string, lineWidth int) []string {
@ -59,6 +60,7 @@ type MessagePair struct {
type SplitMessage struct { type SplitMessage struct {
MessagePair MessagePair
Wrapped []MessagePair // if this is nil, `Message` didn't need wrapping and can be sent to anyone Wrapped []MessagePair // if this is nil, `Message` didn't need wrapping and can be sent to anyone
Time time.Time
} }
const defaultLineWidth = 400 const defaultLineWidth = 400
@ -66,6 +68,7 @@ const defaultLineWidth = 400
func MakeSplitMessage(original string, origIs512 bool) (result SplitMessage) { func MakeSplitMessage(original string, origIs512 bool) (result SplitMessage) {
result.Message = original result.Message = original
result.Msgid = GenerateSecretToken() result.Msgid = GenerateSecretToken()
result.Time = time.Now().UTC()
if !origIs512 && defaultLineWidth < len(original) { if !origIs512 && defaultLineWidth < len(original) {
wrapped := WordWrap(original, defaultLineWidth) wrapped := WordWrap(original, defaultLineWidth)