diff --git a/conventional.yaml b/conventional.yaml index a5c95b28..2f362b47 100644 --- a/conventional.yaml +++ b/conventional.yaml @@ -413,6 +413,9 @@ accounts: # "disabled", "opt-in", "opt-out", or "mandatory". always-on: "disabled" + # whether to mark always-on clients away when they have no active connections: + auto-away: "opt-in" + # vhosts controls the assignment of vhosts (strings displayed in place of the user's # hostname/IP) by the HostServ service vhosts: diff --git a/irc/accounts.go b/irc/accounts.go index bb33ef1e..44b94bcc 100644 --- a/irc/accounts.go +++ b/irc/accounts.go @@ -19,6 +19,7 @@ import ( "github.com/oragono/oragono/irc/connection_limits" "github.com/oragono/oragono/irc/email" "github.com/oragono/oragono/irc/ldap" + "github.com/oragono/oragono/irc/modes" "github.com/oragono/oragono/irc/passwd" "github.com/oragono/oragono/irc/utils" "github.com/tidwall/buntdb" @@ -40,6 +41,7 @@ const ( keyAccountChannels = "account.channels %s" // channels registered to the account keyAccountJoinedChannels = "account.joinedto %s" // channels a persistent client has joined keyAccountLastSeen = "account.lastseen %s" + keyAccountModes = "account.modes %s" // user modes for the always-on client as a string keyVHostQueueAcctToId = "vhostQueue %s" vhostRequestIdx = "vhostQueue" @@ -127,7 +129,7 @@ func (am *AccountManager) createAlwaysOnClients(config *Config) { account, err := am.LoadAccount(accountName) if err == nil && account.Verified && persistenceEnabled(config.Accounts.Multiclient.AlwaysOn, account.Settings.AlwaysOn) { - am.server.AddAlwaysOnClient(account, am.loadChannels(accountName), am.loadLastSeen(accountName)) + am.server.AddAlwaysOnClient(account, am.loadChannels(accountName), am.loadLastSeen(accountName), am.loadModes(accountName)) } } } @@ -594,6 +596,28 @@ func (am *AccountManager) loadChannels(account string) (channels []string) { return } +func (am *AccountManager) saveModes(account string, uModes modes.Modes) { + modeStr := uModes.String() + key := fmt.Sprintf(keyAccountModes, account) + am.server.store.Update(func(tx *buntdb.Tx) error { + tx.Set(key, modeStr, nil) + return nil + }) +} + +func (am *AccountManager) loadModes(account string) (uModes modes.Modes) { + key := fmt.Sprintf(keyAccountModes, account) + var modeStr string + am.server.store.View(func(tx *buntdb.Tx) error { + modeStr, _ = tx.Get(key) + return nil + }) + for _, m := range modeStr { + uModes = append(uModes, modes.Mode(m)) + } + return +} + func (am *AccountManager) saveLastSeen(account string, lastSeen time.Time) { key := fmt.Sprintf(keyAccountLastSeen, account) var val string @@ -1884,6 +1908,7 @@ type AccountSettings struct { AlwaysOn PersistentStatus AutoreplayMissed bool DMHistory HistoryStatus + AutoAway PersistentStatus } // ClientAccount represents a user account. diff --git a/irc/client.go b/irc/client.go index ecdccfd5..6283b0a4 100644 --- a/irc/client.go +++ b/irc/client.go @@ -48,6 +48,7 @@ type Client struct { accountRegDate time.Time accountSettings AccountSettings away bool + autoAway bool awayMessage string brbTimer BrbTimer channels ChannelSet @@ -359,7 +360,7 @@ func (server *Server) RunClient(conn IRCConn) { client.run(session) } -func (server *Server) AddAlwaysOnClient(account ClientAccount, chnames []string, lastSeen time.Time) { +func (server *Server) AddAlwaysOnClient(account ClientAccount, chnames []string, lastSeen time.Time, uModes modes.Modes) { now := time.Now().UTC() config := server.Config() if lastSeen.IsZero() { @@ -382,9 +383,10 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, chnames []string, alwaysOn: true, } - ApplyUserModeChanges(client, config.Accounts.defaultUserModes, false, nil) - client.SetMode(modes.TLS, true) + for _, m := range uModes { + client.SetMode(m, true) + } client.writerSemaphore.Initialize(1) client.history.Initialize(0, 0) client.brbTimer.Initialize(client) @@ -393,7 +395,7 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, chnames []string, client.resizeHistory(config) - _, err := server.clients.SetNick(client, nil, account.Name) + _, err, _ := server.clients.SetNick(client, nil, account.Name) if err != nil { server.logger.Error("internal", "could not establish always-on client", account.Name, err.Error()) return @@ -409,6 +411,12 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, chnames []string, // this is *probably* ok as long as the persisted memberships are accurate server.channels.Join(client, chname, "", true, nil) } + + if persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway) { + client.autoAway = true + client.away = true + client.awayMessage = client.t("User is currently disconnected") + } } func (client *Client) resizeHistory(config *Config) { @@ -588,6 +596,7 @@ func (client *Client) run(session *Session) { isReattach := client.Registered() if isReattach { + session.idletimer.Touch() if session.resumeDetails != nil { session.playResume() session.resumeDetails = nil @@ -1187,6 +1196,7 @@ func (client *Client) Quit(message string, session *Session) { // otherwise, destroys one specific session, only destroying the client if it // has no more sessions. func (client *Client) destroy(session *Session) { + config := client.server.Config() var sessionsToDestroy []*Session client.stateMutex.Lock() @@ -1225,6 +1235,17 @@ func (client *Client) destroy(session *Session) { client.dirtyBits |= IncludeLastSeen } exitedSnomaskSent := client.exitedSnomaskSent + + autoAway := false + var awayMessage string + if alwaysOn && remainingSessions == 0 && persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway) { + autoAway = true + client.autoAway = true + client.away = true + awayMessage = config.languageManager.Translate(client.languages, `Disconnected from the server`) + client.awayMessage = awayMessage + } + client.stateMutex.Unlock() // XXX there is no particular reason to persist this state here rather than @@ -1272,6 +1293,10 @@ func (client *Client) destroy(session *Session) { client.server.stats.Remove(registered, invisible, operator) } + if autoAway { + dispatchAwayNotify(client, true, awayMessage) + } + if !shouldDestroy { return } @@ -1610,6 +1635,7 @@ func (client *Client) historyStatus(config *Config) (status HistoryStatus, targe const ( IncludeChannels uint = 1 << iota IncludeLastSeen + IncludeUserModes ) func (client *Client) markDirty(dirtyBits uint) { @@ -1668,4 +1694,18 @@ func (client *Client) performWrite() { if (dirtyBits & IncludeLastSeen) != 0 { client.server.accounts.saveLastSeen(account, lastSeen) } + if (dirtyBits & IncludeUserModes) != 0 { + uModes := make(modes.Modes, 0, len(modes.SupportedUserModes)) + for _, m := range modes.SupportedUserModes { + switch m { + case modes.Operator, modes.ServerNotice: + // these can't be persisted because they depend on the operator block + default: + if client.HasMode(m) { + uModes = append(uModes, m) + } + } + } + client.server.accounts.saveModes(account, uModes) + } } diff --git a/irc/client_lookup_set.go b/irc/client_lookup_set.go index 316738d3..26544453 100644 --- a/irc/client_lookup_set.go +++ b/irc/client_lookup_set.go @@ -103,7 +103,7 @@ func (clients *ClientManager) Resume(oldClient *Client, session *Session) (err e return errNickMissing } - success, _, _ := oldClient.AddSession(session) + success, _, _, _ := oldClient.AddSession(session) if !success { return errNickMissing } @@ -112,7 +112,7 @@ func (clients *ClientManager) Resume(oldClient *Client, session *Session) (err e } // SetNick sets a client's nickname, validating it against nicknames in use -func (clients *ClientManager) SetNick(client *Client, session *Session, newNick string) (setNick string, err error) { +func (clients *ClientManager) SetNick(client *Client, session *Session, newNick string) (setNick string, err error, returnedFromAway bool) { config := client.server.Config() var newCfNick, newSkeleton string @@ -134,24 +134,24 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick if useAccountName { if registered && newNick != accountName && newNick != "" { - return "", errNickAccountMismatch + return "", errNickAccountMismatch, false } newNick = accountName newCfNick = account newSkeleton, err = Skeleton(newNick) if err != nil { - return "", errNicknameInvalid + return "", errNicknameInvalid, false } } else { newNick = strings.TrimSpace(newNick) if len(newNick) == 0 { - return "", errNickMissing + return "", errNickMissing, false } if account == "" && config.Accounts.NickReservation.ForceGuestFormat { newCfNick, err = CasefoldName(newNick) if err != nil { - return "", errNicknameInvalid + return "", errNicknameInvalid, false } if !config.Accounts.NickReservation.guestRegexpFolded.MatchString(newCfNick) { newNick = strings.Replace(config.Accounts.NickReservation.GuestFormat, "*", newNick, 1) @@ -163,23 +163,23 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick newCfNick, err = CasefoldName(newNick) } if err != nil { - return "", errNicknameInvalid + return "", errNicknameInvalid, false } if len(newNick) > config.Limits.NickLen || len(newCfNick) > config.Limits.NickLen { - return "", errNicknameInvalid + return "", errNicknameInvalid, false } newSkeleton, err = Skeleton(newNick) if err != nil { - return "", errNicknameInvalid + return "", errNicknameInvalid, false } if restrictedCasefoldedNicks[newCfNick] || restrictedSkeletons[newSkeleton] { - return "", errNicknameInvalid + return "", errNicknameInvalid, false } reservedAccount, method := client.server.accounts.EnforcementStatus(newCfNick, newSkeleton) if method == NickEnforcementStrict && reservedAccount != "" && reservedAccount != account { - return "", errNicknameReserved + return "", errNicknameReserved, false } } @@ -204,20 +204,20 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick if currentClient != nil && currentClient != client && session != nil { // these conditions forbid reattaching to an existing session: if registered || !bouncerAllowed || account == "" || account != currentClient.Account() { - return "", errNicknameInUse + return "", errNicknameInUse, false } // check TLS modes if client.HasMode(modes.TLS) != currentClient.HasMode(modes.TLS) { if useAccountName { // #955: this is fatal because they can't fix it by trying a different nick - return "", errInsecureReattach + return "", errInsecureReattach, false } else { - return "", errNicknameInUse + return "", errNicknameInUse, false } } - reattachSuccessful, numSessions, lastSeen := currentClient.AddSession(session) + reattachSuccessful, numSessions, lastSeen, back := currentClient.AddSession(session) if !reattachSuccessful { - return "", errNicknameInUse + return "", errNicknameInUse, false } if numSessions == 1 { invisible := currentClient.HasMode(modes.Invisible) @@ -232,24 +232,24 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick // for performance reasons currentClient.SetNames("user", realname, true) // successful reattach! - return newNick, nil + return newNick, nil, back } else if currentClient == client && currentClient.Nick() == newNick { // see #1019: normally no-op nick changes are caught earlier, by performNickChange, // but they are not detected there when force-guest-format is enabled (because // the proposed nickname is e.g. alice and the current nickname is Guest-alice) - return "", errNoop + return "", errNoop, false } // analogous checks for skeletons skeletonHolder := clients.bySkeleton[newSkeleton] if skeletonHolder != nil && skeletonHolder != client { - return "", errNicknameInUse + return "", errNicknameInUse, false } clients.removeInternal(client) clients.byNick[newCfNick] = client clients.bySkeleton[newSkeleton] = client client.updateNick(newNick, newCfNick, newSkeleton) - return newNick, nil + return newNick, nil, false } func (clients *ClientManager) AllClients() (result []*Client) { diff --git a/irc/config.go b/irc/config.go index 91156172..277413e1 100644 --- a/irc/config.go +++ b/irc/config.go @@ -221,6 +221,7 @@ type MulticlientConfig struct { Enabled bool AllowedByDefault bool `yaml:"allowed-by-default"` AlwaysOn PersistentStatus `yaml:"always-on"` + AutoAway PersistentStatus `yaml:"auto-away"` } type throttleConfig struct { diff --git a/irc/getters.go b/irc/getters.go index 41ddbe87..d51bce87 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -89,7 +89,7 @@ func (client *Client) AllSessionData(currentSession *Session) (data []SessionDat return } -func (client *Client) AddSession(session *Session) (success bool, numSessions int, lastSeen time.Time) { +func (client *Client) AddSession(session *Session) (success bool, numSessions int, lastSeen time.Time, back bool) { client.stateMutex.Lock() defer client.stateMutex.Unlock() @@ -106,7 +106,13 @@ func (client *Client) AddSession(session *Session) (success bool, numSessions in lastSeen = client.lastSeen } client.sessions = newSessions - return true, len(client.sessions), lastSeen + if client.autoAway { + back = true + client.autoAway = false + client.away = false + client.awayMessage = "" + } + return true, len(client.sessions), lastSeen, back } func (client *Client) removeSession(session *Session) (success bool, length int) { diff --git a/irc/handlers.go b/irc/handlers.go index d8f89d19..ed723ce9 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -316,6 +316,11 @@ func awayHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp rb.Add(nil, server.name, RPL_UNAWAY, client.nick, client.t("You are no longer marked as being away")) } + dispatchAwayNotify(client, isAway, awayMessage) + return false +} + +func dispatchAwayNotify(client *Client, isAway bool, awayMessage string) { // dispatch away-notify details := client.Details() for session := range client.Friends(caps.AwayNotify) { @@ -325,8 +330,6 @@ func awayHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp session.sendFromClientInternal(false, time.Time{}, "", details.nickMask, details.account, nil, "AWAY") } } - - return false } // BATCH {+,-}reference-tag type [params...] diff --git a/irc/modes.go b/irc/modes.go index 11912544..06dd8e51 100644 --- a/irc/modes.go +++ b/irc/modes.go @@ -102,6 +102,10 @@ func ApplyUserModeChanges(client *Client, changes modes.ModeChanges, force bool, // can't do anything to TLS mode } + if len(applied) != 0 { + client.markDirty(IncludeUserModes) + } + // return the changes we could actually apply return applied } diff --git a/irc/nickname.go b/irc/nickname.go index bc396f79..9c54120f 100644 --- a/irc/nickname.go +++ b/irc/nickname.go @@ -33,7 +33,7 @@ func performNickChange(server *Server, client *Client, target *Client, session * hadNick := details.nick != "*" origNickMask := details.nickMask - assignedNickname, err := client.server.clients.SetNick(target, session, nickname) + assignedNickname, err, back := client.server.clients.SetNick(target, session, nickname) if err == errNicknameInUse { rb.Add(nil, server.name, ERR_NICKNAMEINUSE, currentNick, utils.SafeErrorParam(nickname), client.t("Nickname is already in use")) } else if err == errNicknameReserved { @@ -80,6 +80,10 @@ func performNickChange(server *Server, client *Client, target *Client, session * } } + if back { + dispatchAwayNotify(session.client, false, "") + } + for _, channel := range client.Channels() { channel.AddHistoryItem(histItem, details.account) } diff --git a/irc/nickserv.go b/irc/nickserv.go index 47e2a18e..bbe61cb4 100644 --- a/irc/nickserv.go +++ b/irc/nickserv.go @@ -289,6 +289,10 @@ how the history of your direct messages is stored. Your options are: 2. 'ephemeral' [a limited amount of temporary history, not stored on disk] 3. 'on' [history stored in a permanent database, if available] 4. 'default' [use the server default]`, + `$bAUTO-AWAY$b +'auto-away' is only effective for always-on clients. If enabled, you will +automatically be marked away when all your sessions are disconnected, and +automatically return from away when you connect again.`, }, authRequired: true, enabled: servCmdRequiresAuthEnabled, @@ -412,6 +416,18 @@ func displaySetting(settingName string, settings AccountSettings, client *Client } else { nsNotice(rb, client.t("Your account is not configured to receive autoreplayed missed messages")) } + case "auto-away": + stored := settings.AutoAway + alwaysOn := persistenceEnabled(config.Accounts.Multiclient.AlwaysOn, settings.AlwaysOn) + actual := persistenceEnabled(config.Accounts.Multiclient.AutoAway, settings.AutoAway) + nsNotice(rb, fmt.Sprintf(client.t("Your stored auto-away setting is: %s"), persistentStatusToString(stored))) + if actual && alwaysOn { + nsNotice(rb, client.t("Given current server settings, auto-away is enabled for your client")) + } else if actual && !alwaysOn { + nsNotice(rb, client.t("Because your client is not always-on, auto-away is disabled")) + } else if !actual { + nsNotice(rb, client.t("Given current server settings, auto-away is disabled for your client")) + } case "dm-history": effectiveValue := historyEnabled(config.History.Persistent.DirectMessages, settings.DMHistory) csNotice(rb, fmt.Sprintf(client.t("Your stored direct message history setting is: %s"), historyStatusToString(settings.DMHistory))) @@ -530,6 +546,17 @@ func nsSetHandler(server *Server, client *Client, command string, params []strin return } } + case "auto-away": + var newValue PersistentStatus + newValue, err = persistentStatusFromString(params[1]) + // "opt-in" and "opt-out" don't make sense as user preferences + if err == nil && newValue != PersistentOptIn && newValue != PersistentOptOut { + munger = func(in AccountSettings) (out AccountSettings, err error) { + out = in + out.AutoAway = newValue + return + } + } case "dm-history": var newValue HistoryStatus newValue, err = historyStatusFromString(params[1]) diff --git a/oragono.yaml b/oragono.yaml index c2c02716..8b71ea62 100644 --- a/oragono.yaml +++ b/oragono.yaml @@ -439,6 +439,9 @@ accounts: # "disabled", "opt-in", "opt-out", or "mandatory". always-on: "opt-in" + # whether to mark always-on clients away when they have no active connections: + auto-away: "opt-in" + # vhosts controls the assignment of vhosts (strings displayed in place of the user's # hostname/IP) by the HostServ service vhosts: