Skip to content

Commit

Permalink
Merge pull request #1926 from slingamn/readmarker.6
Browse files Browse the repository at this point in the history
implement draft/read-marker capability
  • Loading branch information
slingamn authored Apr 8, 2022
2 parents 2c488f5 + 1adda8d commit 1f08c97
Show file tree
Hide file tree
Showing 12 changed files with 191 additions and 80 deletions.
6 changes: 6 additions & 0 deletions gencapdefs.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,12 @@
url="https://github.com/ircv3/ircv3-specifications/pull/466",
standard="draft IRCv3",
),
CapDef(
identifier="ReadMarker",
name="draft/read-marker",
url="https://github.com/ircv3/ircv3-specifications/pull/489",
standard="draft IRCv3",
),
]

def validate_defs():
Expand Down
18 changes: 15 additions & 3 deletions irc/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const (
keyCertToAccount = "account.creds.certfp %s"
keyAccountChannels = "account.channels %s" // channels registered to the account
keyAccountLastSeen = "account.lastseen %s"
keyAccountReadMarkers = "account.readmarkers %s"
keyAccountModes = "account.modes %s" // user modes for the always-on client as a string
keyAccountRealname = "account.realname %s" // client realname stored as string
keyAccountSuspended = "account.suspended %s" // client realname stored as string
Expand Down Expand Up @@ -647,9 +648,18 @@ func (am *AccountManager) loadModes(account string) (uModes modes.Modes) {

func (am *AccountManager) saveLastSeen(account string, lastSeen map[string]time.Time) {
key := fmt.Sprintf(keyAccountLastSeen, account)
am.saveTimeMap(account, key, lastSeen)
}

func (am *AccountManager) saveReadMarkers(account string, readMarkers map[string]time.Time) {
key := fmt.Sprintf(keyAccountReadMarkers, account)
am.saveTimeMap(account, key, readMarkers)
}

func (am *AccountManager) saveTimeMap(account, key string, timeMap map[string]time.Time) {
var val string
if len(lastSeen) != 0 {
text, _ := json.Marshal(lastSeen)
if len(timeMap) != 0 {
text, _ := json.Marshal(timeMap)
val = string(text)
}
err := am.server.store.Update(func(tx *buntdb.Tx) error {
Expand All @@ -661,7 +671,7 @@ func (am *AccountManager) saveLastSeen(account string, lastSeen map[string]time.
return nil
})
if err != nil {
am.server.logger.Error("internal", "error persisting lastSeen", account, err.Error())
am.server.logger.Error("internal", "error persisting timeMap", key, err.Error())
}
}

Expand Down Expand Up @@ -1739,6 +1749,7 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
channelsKey := fmt.Sprintf(keyAccountChannels, casefoldedAccount)
joinedChannelsKey := fmt.Sprintf(keyAccountChannelToModes, casefoldedAccount)
lastSeenKey := fmt.Sprintf(keyAccountLastSeen, casefoldedAccount)
readMarkersKey := fmt.Sprintf(keyAccountReadMarkers, casefoldedAccount)
unregisteredKey := fmt.Sprintf(keyAccountUnregistered, casefoldedAccount)
modesKey := fmt.Sprintf(keyAccountModes, casefoldedAccount)
realnameKey := fmt.Sprintf(keyAccountRealname, casefoldedAccount)
Expand Down Expand Up @@ -1801,6 +1812,7 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
tx.Delete(channelsKey)
tx.Delete(joinedChannelsKey)
tx.Delete(lastSeenKey)
tx.Delete(readMarkersKey)
tx.Delete(modesKey)
tx.Delete(realnameKey)
tx.Delete(suspendedKey)
Expand Down
7 changes: 6 additions & 1 deletion irc/caps/defs.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ package caps

const (
// number of recognized capabilities:
numCapabs = 28
numCapabs = 29
// length of the uint64 array that represents the bitset:
bitsetLen = 1
)
Expand Down Expand Up @@ -65,6 +65,10 @@ const (
// https://github.com/ircv3/ircv3-specifications/pull/398
Multiline Capability = iota

// ReadMarker is the draft IRCv3 capability named "draft/read-marker":
// https://github.com/ircv3/ircv3-specifications/pull/489
ReadMarker Capability = iota

// Relaymsg is the proposed IRCv3 capability named "draft/relaymsg":
// https://github.com/ircv3/ircv3-specifications/pull/417
Relaymsg Capability = iota
Expand Down Expand Up @@ -142,6 +146,7 @@ var (
"draft/extended-monitor",
"draft/languages",
"draft/multiline",
"draft/read-marker",
"draft/relaymsg",
"echo-message",
"ergo.chat/nope",
Expand Down
13 changes: 11 additions & 2 deletions irc/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -881,6 +881,10 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, isBot, nil, "JOIN", chname)
}

if rb.session.capabilities.Has(caps.ReadMarker) {
rb.Add(nil, client.server.name, "MARKREAD", chname, client.GetReadMarker(chcfname))
}

if rb.session.client == client {
// don't send topic and names for a SAJOIN of a different client
channel.SendTopic(client, rb, false)
Expand Down Expand Up @@ -964,10 +968,15 @@ func (channel *Channel) playJoinForSession(session *Session) {
client := session.client
sessionRb := NewResponseBuffer(session)
details := client.Details()
chname := channel.Name()
if session.capabilities.Has(caps.ExtendedJoin) {
sessionRb.Add(nil, details.nickMask, "JOIN", channel.Name(), details.accountName, details.realname)
sessionRb.Add(nil, details.nickMask, "JOIN", chname, details.accountName, details.realname)
} else {
sessionRb.Add(nil, details.nickMask, "JOIN", channel.Name())
sessionRb.Add(nil, details.nickMask, "JOIN", chname)
}
if session.capabilities.Has(caps.ReadMarker) {
chcfname := channel.NameCasefolded()
sessionRb.Add(nil, client.server.name, "MARKREAD", chname, client.GetReadMarker(chcfname))
}
channel.SendTopic(client, sessionRb, false)
channel.Names(client, sessionRb)
Expand Down
67 changes: 8 additions & 59 deletions irc/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ const (
IRCv3TimestampFormat = utils.IRCv3TimestampFormat
// limit the number of device IDs a client can use, as a DoS mitigation
maxDeviceIDsPerClient = 64
// controls how often often we write an autoreplay-missed client's
// deviceid->lastseentime mapping to the database
lastSeenWriteInterval = time.Hour
// maximum total read markers that can be stored
// (writeback of read markers is controlled by lastSeen logic)
maxReadMarkers = 256
)

const (
Expand Down Expand Up @@ -83,7 +83,7 @@ type Client struct {
languages []string
lastActive time.Time // last time they sent a command that wasn't PONG or similar
lastSeen map[string]time.Time // maps device ID (including "") to time of last received command
lastSeenLastWrite time.Time // last time `lastSeen` was written to the datastore
readMarkers map[string]time.Time // maps casefolded target to time of last read marker
loginThrottle connection_limits.GenericThrottle
nextSessionID int64 // Incremented when a new session is established
nick string
Expand All @@ -101,6 +101,7 @@ type Client struct {
requireSASL bool
registered bool
registerCmdSent bool // already sent the draft/register command, can't send it again
dirtyTimestamps bool // lastSeen or readMarkers is dirty
registrationTimer *time.Timer
server *Server
skeleton string
Expand Down Expand Up @@ -745,41 +746,23 @@ func (client *Client) playReattachMessages(session *Session) {
// Touch indicates that we received a line from the client (so the connection is healthy
// at this time, modulo network latency and fakelag).
func (client *Client) Touch(session *Session) {
var markDirty bool
now := time.Now().UTC()
client.stateMutex.Lock()
if client.registered {
client.updateIdleTimer(session, now)
if client.alwaysOn {
client.setLastSeen(now, session.deviceID)
if now.Sub(client.lastSeenLastWrite) > lastSeenWriteInterval {
markDirty = true
client.lastSeenLastWrite = now
}
client.dirtyTimestamps = true
}
}
client.stateMutex.Unlock()
if markDirty {
client.markDirty(IncludeLastSeen)
}
}

func (client *Client) setLastSeen(now time.Time, deviceID string) {
if client.lastSeen == nil {
client.lastSeen = make(map[string]time.Time)
}
client.lastSeen[deviceID] = now
// evict the least-recently-used entry if necessary
if maxDeviceIDsPerClient < len(client.lastSeen) {
var minLastSeen time.Time
var minClientId string
for deviceID, lastSeen := range client.lastSeen {
if minLastSeen.IsZero() || lastSeen.Before(minLastSeen) {
minClientId, minLastSeen = deviceID, lastSeen
}
}
delete(client.lastSeen, minClientId)
}
updateLRUMap(client.lastSeen, deviceID, now, maxDeviceIDsPerClient)
}

func (client *Client) updateIdleTimer(session *Session, now time.Time) {
Expand Down Expand Up @@ -1191,7 +1174,6 @@ func (client *Client) Quit(message string, session *Session) {
func (client *Client) destroy(session *Session) {
config := client.server.Config()
var sessionsToDestroy []*Session
var saveLastSeen bool
var quitMessage string

client.stateMutex.Lock()
Expand Down Expand Up @@ -1223,20 +1205,6 @@ func (client *Client) destroy(session *Session) {
}
}

// save last seen if applicable:
if alwaysOn {
if client.accountSettings.AutoreplayMissed {
saveLastSeen = true
} else {
for _, session := range sessionsToDestroy {
if session.deviceID != "" {
saveLastSeen = true
break
}
}
}
}

// should we destroy the whole client this time?
shouldDestroy := !client.destroyed && remainingSessions == 0 && !alwaysOn
// decrement stats on a true destroy, or for the removal of the last connected session
Expand All @@ -1246,9 +1214,6 @@ func (client *Client) destroy(session *Session) {
// if it's our job to destroy it, don't let anyone else try
client.destroyed = true
}
if saveLastSeen {
client.dirtyBits |= IncludeLastSeen
}

becameAutoAway := false
var awayMessage string
Expand All @@ -1266,14 +1231,6 @@ func (client *Client) destroy(session *Session) {

client.stateMutex.Unlock()

// XXX there is no particular reason to persist this state here rather than
// any other place: it would be correct to persist it after every `Touch`. However,
// I'm not comfortable introducing that many database writes, and I don't want to
// design a throttle.
if saveLastSeen {
client.wakeWriter()
}

// destroy all applicable sessions:
for _, session := range sessionsToDestroy {
if session.client != client {
Expand Down Expand Up @@ -1784,18 +1741,13 @@ func (client *Client) handleRegisterTimeout() {
func (client *Client) copyLastSeen() (result map[string]time.Time) {
client.stateMutex.RLock()
defer client.stateMutex.RUnlock()
result = make(map[string]time.Time, len(client.lastSeen))
for id, lastSeen := range client.lastSeen {
result[id] = lastSeen
}
return
return utils.CopyMap(client.lastSeen)
}

// these are bit flags indicating what part of the client status is "dirty"
// and needs to be read from memory and written to the db
const (
IncludeChannels uint = 1 << iota
IncludeLastSeen
IncludeUserModes
IncludeRealname
)
Expand Down Expand Up @@ -1853,9 +1805,6 @@ func (client *Client) performWrite(additionalDirtyBits uint) {
}
client.server.accounts.saveChannels(account, channelToModes)
}
if (dirtyBits & IncludeLastSeen) != 0 {
client.server.accounts.saveLastSeen(account, client.copyLastSeen())
}
if (dirtyBits & IncludeUserModes) != 0 {
uModes := make(modes.Modes, 0, len(modes.SupportedUserModes))
for _, m := range modes.SupportedUserModes {
Expand Down
6 changes: 5 additions & 1 deletion irc/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ func (cmd *Command) Run(server *Server, client *Client, session *Session, msg ir
}

if client.registered {
client.Touch(session)
client.Touch(session) // even if `exiting`, we bump the lastSeen timestamp
}

return exiting
Expand Down Expand Up @@ -178,6 +178,10 @@ func init() {
handler: lusersHandler,
minParams: 0,
},
"MARKREAD": {
handler: markReadHandler,
minParams: 0, // send FAIL instead of ERR_NEEDMOREPARAMS
},
"MODE": {
handler: modeHandler,
minParams: 1,
Expand Down
57 changes: 57 additions & 0 deletions irc/getters.go
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,63 @@ func (client *Client) checkAlwaysOnExpirationNoMutex(config *Config, ignoreRegis
return true
}

func (client *Client) GetReadMarker(cfname string) (result string) {
client.stateMutex.RLock()
t, ok := client.readMarkers[cfname]
client.stateMutex.RUnlock()
if ok {
return t.Format(IRCv3TimestampFormat)
}
return "*"
}

func (client *Client) copyReadMarkers() (result map[string]time.Time) {
client.stateMutex.RLock()
defer client.stateMutex.RUnlock()
return utils.CopyMap(client.readMarkers)
}

func (client *Client) SetReadMarker(cfname string, now time.Time) (result time.Time) {
client.stateMutex.Lock()
defer client.stateMutex.Unlock()

if client.readMarkers == nil {
client.readMarkers = make(map[string]time.Time)
}
result = updateLRUMap(client.readMarkers, cfname, now, maxReadMarkers)
client.dirtyTimestamps = true
return
}

func updateLRUMap(lru map[string]time.Time, key string, val time.Time, maxItems int) (result time.Time) {
if currentVal := lru[key]; currentVal.After(val) {
return currentVal
}

lru[key] = val
// evict the least-recently-used entry if necessary
if maxItems < len(lru) {
var minKey string
var minVal time.Time
for key, val := range lru {
if minVal.IsZero() || val.Before(minVal) {
minKey, minVal = key, val
}
}
delete(lru, minKey)
}
return val
}

func (client *Client) shouldFlushTimestamps() (result bool) {
client.stateMutex.Lock()
defer client.stateMutex.Unlock()

result = client.dirtyTimestamps && client.registered && client.alwaysOn
client.dirtyTimestamps = false
return
}

func (channel *Channel) Name() string {
channel.stateMutex.RLock()
defer channel.stateMutex.RUnlock()
Expand Down
Loading

0 comments on commit 1f08c97

Please sign in to comment.