3
0
mirror of https://github.com/ergochat/ergo.git synced 2024-11-29 07:29:31 +01:00

Merge pull request #1036 from slingamn/account_persistence.3

last round of feature changes
This commit is contained in:
Shivaram Lingamneni 2020-05-20 04:58:57 -07:00 committed by GitHub
commit 06b2cb2efc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 146 additions and 30 deletions

View File

@ -413,6 +413,9 @@ accounts:
# "disabled", "opt-in", "opt-out", or "mandatory". # "disabled", "opt-in", "opt-out", or "mandatory".
always-on: "disabled" 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 # vhosts controls the assignment of vhosts (strings displayed in place of the user's
# hostname/IP) by the HostServ service # hostname/IP) by the HostServ service
vhosts: vhosts:

View File

@ -19,6 +19,7 @@ import (
"github.com/oragono/oragono/irc/connection_limits" "github.com/oragono/oragono/irc/connection_limits"
"github.com/oragono/oragono/irc/email" "github.com/oragono/oragono/irc/email"
"github.com/oragono/oragono/irc/ldap" "github.com/oragono/oragono/irc/ldap"
"github.com/oragono/oragono/irc/modes"
"github.com/oragono/oragono/irc/passwd" "github.com/oragono/oragono/irc/passwd"
"github.com/oragono/oragono/irc/utils" "github.com/oragono/oragono/irc/utils"
"github.com/tidwall/buntdb" "github.com/tidwall/buntdb"
@ -40,6 +41,7 @@ const (
keyAccountChannels = "account.channels %s" // channels registered to the account keyAccountChannels = "account.channels %s" // channels registered to the account
keyAccountJoinedChannels = "account.joinedto %s" // channels a persistent client has joined keyAccountJoinedChannels = "account.joinedto %s" // channels a persistent client has joined
keyAccountLastSeen = "account.lastseen %s" keyAccountLastSeen = "account.lastseen %s"
keyAccountModes = "account.modes %s" // user modes for the always-on client as a string
keyVHostQueueAcctToId = "vhostQueue %s" keyVHostQueueAcctToId = "vhostQueue %s"
vhostRequestIdx = "vhostQueue" vhostRequestIdx = "vhostQueue"
@ -127,7 +129,7 @@ func (am *AccountManager) createAlwaysOnClients(config *Config) {
account, err := am.LoadAccount(accountName) account, err := am.LoadAccount(accountName)
if err == nil && account.Verified && if err == nil && account.Verified &&
persistenceEnabled(config.Accounts.Multiclient.AlwaysOn, account.Settings.AlwaysOn) { 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 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) { func (am *AccountManager) saveLastSeen(account string, lastSeen time.Time) {
key := fmt.Sprintf(keyAccountLastSeen, account) key := fmt.Sprintf(keyAccountLastSeen, account)
var val string var val string
@ -1884,6 +1908,7 @@ type AccountSettings struct {
AlwaysOn PersistentStatus AlwaysOn PersistentStatus
AutoreplayMissed bool AutoreplayMissed bool
DMHistory HistoryStatus DMHistory HistoryStatus
AutoAway PersistentStatus
} }
// ClientAccount represents a user account. // ClientAccount represents a user account.

View File

@ -48,6 +48,7 @@ type Client struct {
accountRegDate time.Time accountRegDate time.Time
accountSettings AccountSettings accountSettings AccountSettings
away bool away bool
autoAway bool
awayMessage string awayMessage string
brbTimer BrbTimer brbTimer BrbTimer
channels ChannelSet channels ChannelSet
@ -359,7 +360,7 @@ func (server *Server) RunClient(conn IRCConn) {
client.run(session) 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() now := time.Now().UTC()
config := server.Config() config := server.Config()
if lastSeen.IsZero() { if lastSeen.IsZero() {
@ -382,9 +383,10 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, chnames []string,
alwaysOn: true, alwaysOn: true,
} }
ApplyUserModeChanges(client, config.Accounts.defaultUserModes, false, nil)
client.SetMode(modes.TLS, true) client.SetMode(modes.TLS, true)
for _, m := range uModes {
client.SetMode(m, true)
}
client.writerSemaphore.Initialize(1) client.writerSemaphore.Initialize(1)
client.history.Initialize(0, 0) client.history.Initialize(0, 0)
client.brbTimer.Initialize(client) client.brbTimer.Initialize(client)
@ -393,7 +395,7 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, chnames []string,
client.resizeHistory(config) client.resizeHistory(config)
_, err := server.clients.SetNick(client, nil, account.Name) _, err, _ := server.clients.SetNick(client, nil, account.Name)
if err != nil { if err != nil {
server.logger.Error("internal", "could not establish always-on client", account.Name, err.Error()) server.logger.Error("internal", "could not establish always-on client", account.Name, err.Error())
return 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 // this is *probably* ok as long as the persisted memberships are accurate
server.channels.Join(client, chname, "", true, nil) 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) { func (client *Client) resizeHistory(config *Config) {
@ -588,6 +596,7 @@ func (client *Client) run(session *Session) {
isReattach := client.Registered() isReattach := client.Registered()
if isReattach { if isReattach {
session.idletimer.Touch()
if session.resumeDetails != nil { if session.resumeDetails != nil {
session.playResume() session.playResume()
session.resumeDetails = nil 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 // otherwise, destroys one specific session, only destroying the client if it
// has no more sessions. // has no more sessions.
func (client *Client) destroy(session *Session) { func (client *Client) destroy(session *Session) {
config := client.server.Config()
var sessionsToDestroy []*Session var sessionsToDestroy []*Session
client.stateMutex.Lock() client.stateMutex.Lock()
@ -1225,6 +1235,17 @@ func (client *Client) destroy(session *Session) {
client.dirtyBits |= IncludeLastSeen client.dirtyBits |= IncludeLastSeen
} }
exitedSnomaskSent := client.exitedSnomaskSent 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() client.stateMutex.Unlock()
// XXX there is no particular reason to persist this state here rather than // 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) client.server.stats.Remove(registered, invisible, operator)
} }
if autoAway {
dispatchAwayNotify(client, true, awayMessage)
}
if !shouldDestroy { if !shouldDestroy {
return return
} }
@ -1610,6 +1635,7 @@ func (client *Client) historyStatus(config *Config) (status HistoryStatus, targe
const ( const (
IncludeChannels uint = 1 << iota IncludeChannels uint = 1 << iota
IncludeLastSeen IncludeLastSeen
IncludeUserModes
) )
func (client *Client) markDirty(dirtyBits uint) { func (client *Client) markDirty(dirtyBits uint) {
@ -1668,4 +1694,18 @@ func (client *Client) performWrite() {
if (dirtyBits & IncludeLastSeen) != 0 { if (dirtyBits & IncludeLastSeen) != 0 {
client.server.accounts.saveLastSeen(account, lastSeen) 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)
}
} }

View File

@ -103,7 +103,7 @@ func (clients *ClientManager) Resume(oldClient *Client, session *Session) (err e
return errNickMissing return errNickMissing
} }
success, _, _ := oldClient.AddSession(session) success, _, _, _ := oldClient.AddSession(session)
if !success { if !success {
return errNickMissing 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 // 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() config := client.server.Config()
var newCfNick, newSkeleton string var newCfNick, newSkeleton string
@ -134,24 +134,24 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
if useAccountName { if useAccountName {
if registered && newNick != accountName && newNick != "" { if registered && newNick != accountName && newNick != "" {
return "", errNickAccountMismatch return "", errNickAccountMismatch, false
} }
newNick = accountName newNick = accountName
newCfNick = account newCfNick = account
newSkeleton, err = Skeleton(newNick) newSkeleton, err = Skeleton(newNick)
if err != nil { if err != nil {
return "", errNicknameInvalid return "", errNicknameInvalid, false
} }
} else { } else {
newNick = strings.TrimSpace(newNick) newNick = strings.TrimSpace(newNick)
if len(newNick) == 0 { if len(newNick) == 0 {
return "", errNickMissing return "", errNickMissing, false
} }
if account == "" && config.Accounts.NickReservation.ForceGuestFormat { if account == "" && config.Accounts.NickReservation.ForceGuestFormat {
newCfNick, err = CasefoldName(newNick) newCfNick, err = CasefoldName(newNick)
if err != nil { if err != nil {
return "", errNicknameInvalid return "", errNicknameInvalid, false
} }
if !config.Accounts.NickReservation.guestRegexpFolded.MatchString(newCfNick) { if !config.Accounts.NickReservation.guestRegexpFolded.MatchString(newCfNick) {
newNick = strings.Replace(config.Accounts.NickReservation.GuestFormat, "*", newNick, 1) 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) newCfNick, err = CasefoldName(newNick)
} }
if err != nil { if err != nil {
return "", errNicknameInvalid return "", errNicknameInvalid, false
} }
if len(newNick) > config.Limits.NickLen || len(newCfNick) > config.Limits.NickLen { if len(newNick) > config.Limits.NickLen || len(newCfNick) > config.Limits.NickLen {
return "", errNicknameInvalid return "", errNicknameInvalid, false
} }
newSkeleton, err = Skeleton(newNick) newSkeleton, err = Skeleton(newNick)
if err != nil { if err != nil {
return "", errNicknameInvalid return "", errNicknameInvalid, false
} }
if restrictedCasefoldedNicks[newCfNick] || restrictedSkeletons[newSkeleton] { if restrictedCasefoldedNicks[newCfNick] || restrictedSkeletons[newSkeleton] {
return "", errNicknameInvalid return "", errNicknameInvalid, false
} }
reservedAccount, method := client.server.accounts.EnforcementStatus(newCfNick, newSkeleton) reservedAccount, method := client.server.accounts.EnforcementStatus(newCfNick, newSkeleton)
if method == NickEnforcementStrict && reservedAccount != "" && reservedAccount != account { 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 { if currentClient != nil && currentClient != client && session != nil {
// these conditions forbid reattaching to an existing session: // these conditions forbid reattaching to an existing session:
if registered || !bouncerAllowed || account == "" || account != currentClient.Account() { if registered || !bouncerAllowed || account == "" || account != currentClient.Account() {
return "", errNicknameInUse return "", errNicknameInUse, false
} }
// check TLS modes // check TLS modes
if client.HasMode(modes.TLS) != currentClient.HasMode(modes.TLS) { if client.HasMode(modes.TLS) != currentClient.HasMode(modes.TLS) {
if useAccountName { if useAccountName {
// #955: this is fatal because they can't fix it by trying a different nick // #955: this is fatal because they can't fix it by trying a different nick
return "", errInsecureReattach return "", errInsecureReattach, false
} else { } else {
return "", errNicknameInUse return "", errNicknameInUse, false
} }
} }
reattachSuccessful, numSessions, lastSeen := currentClient.AddSession(session) reattachSuccessful, numSessions, lastSeen, back := currentClient.AddSession(session)
if !reattachSuccessful { if !reattachSuccessful {
return "", errNicknameInUse return "", errNicknameInUse, false
} }
if numSessions == 1 { if numSessions == 1 {
invisible := currentClient.HasMode(modes.Invisible) invisible := currentClient.HasMode(modes.Invisible)
@ -232,24 +232,24 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
// for performance reasons // for performance reasons
currentClient.SetNames("user", realname, true) currentClient.SetNames("user", realname, true)
// successful reattach! // successful reattach!
return newNick, nil return newNick, nil, back
} else if currentClient == client && currentClient.Nick() == newNick { } else if currentClient == client && currentClient.Nick() == newNick {
// see #1019: normally no-op nick changes are caught earlier, by performNickChange, // 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 // 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) // the proposed nickname is e.g. alice and the current nickname is Guest-alice)
return "", errNoop return "", errNoop, false
} }
// analogous checks for skeletons // analogous checks for skeletons
skeletonHolder := clients.bySkeleton[newSkeleton] skeletonHolder := clients.bySkeleton[newSkeleton]
if skeletonHolder != nil && skeletonHolder != client { if skeletonHolder != nil && skeletonHolder != client {
return "", errNicknameInUse return "", errNicknameInUse, false
} }
clients.removeInternal(client) clients.removeInternal(client)
clients.byNick[newCfNick] = client clients.byNick[newCfNick] = client
clients.bySkeleton[newSkeleton] = client clients.bySkeleton[newSkeleton] = client
client.updateNick(newNick, newCfNick, newSkeleton) client.updateNick(newNick, newCfNick, newSkeleton)
return newNick, nil return newNick, nil, false
} }
func (clients *ClientManager) AllClients() (result []*Client) { func (clients *ClientManager) AllClients() (result []*Client) {

View File

@ -221,6 +221,7 @@ type MulticlientConfig struct {
Enabled bool Enabled bool
AllowedByDefault bool `yaml:"allowed-by-default"` AllowedByDefault bool `yaml:"allowed-by-default"`
AlwaysOn PersistentStatus `yaml:"always-on"` AlwaysOn PersistentStatus `yaml:"always-on"`
AutoAway PersistentStatus `yaml:"auto-away"`
} }
type throttleConfig struct { type throttleConfig struct {

View File

@ -89,7 +89,7 @@ func (client *Client) AllSessionData(currentSession *Session) (data []SessionDat
return 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() client.stateMutex.Lock()
defer client.stateMutex.Unlock() defer client.stateMutex.Unlock()
@ -106,7 +106,13 @@ func (client *Client) AddSession(session *Session) (success bool, numSessions in
lastSeen = client.lastSeen lastSeen = client.lastSeen
} }
client.sessions = newSessions 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) { func (client *Client) removeSession(session *Session) (success bool, length int) {

View File

@ -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")) 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 // dispatch away-notify
details := client.Details() details := client.Details()
for session := range client.Friends(caps.AwayNotify) { 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") session.sendFromClientInternal(false, time.Time{}, "", details.nickMask, details.account, nil, "AWAY")
} }
} }
return false
} }
// BATCH {+,-}reference-tag type [params...] // BATCH {+,-}reference-tag type [params...]

View File

@ -102,6 +102,10 @@ func ApplyUserModeChanges(client *Client, changes modes.ModeChanges, force bool,
// can't do anything to TLS mode // can't do anything to TLS mode
} }
if len(applied) != 0 {
client.markDirty(IncludeUserModes)
}
// return the changes we could actually apply // return the changes we could actually apply
return applied return applied
} }

View File

@ -33,7 +33,7 @@ func performNickChange(server *Server, client *Client, target *Client, session *
hadNick := details.nick != "*" hadNick := details.nick != "*"
origNickMask := details.nickMask 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 { if err == errNicknameInUse {
rb.Add(nil, server.name, ERR_NICKNAMEINUSE, currentNick, utils.SafeErrorParam(nickname), client.t("Nickname is already in use")) rb.Add(nil, server.name, ERR_NICKNAMEINUSE, currentNick, utils.SafeErrorParam(nickname), client.t("Nickname is already in use"))
} else if err == errNicknameReserved { } 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() { for _, channel := range client.Channels() {
channel.AddHistoryItem(histItem, details.account) channel.AddHistoryItem(histItem, details.account)
} }

View File

@ -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] 2. 'ephemeral' [a limited amount of temporary history, not stored on disk]
3. 'on' [history stored in a permanent database, if available] 3. 'on' [history stored in a permanent database, if available]
4. 'default' [use the server default]`, 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, authRequired: true,
enabled: servCmdRequiresAuthEnabled, enabled: servCmdRequiresAuthEnabled,
@ -412,6 +416,18 @@ func displaySetting(settingName string, settings AccountSettings, client *Client
} else { } else {
nsNotice(rb, client.t("Your account is not configured to receive autoreplayed missed messages")) 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": case "dm-history":
effectiveValue := historyEnabled(config.History.Persistent.DirectMessages, settings.DMHistory) 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))) 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 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": case "dm-history":
var newValue HistoryStatus var newValue HistoryStatus
newValue, err = historyStatusFromString(params[1]) newValue, err = historyStatusFromString(params[1])

View File

@ -439,6 +439,9 @@ accounts:
# "disabled", "opt-in", "opt-out", or "mandatory". # "disabled", "opt-in", "opt-out", or "mandatory".
always-on: "opt-in" 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 # vhosts controls the assignment of vhosts (strings displayed in place of the user's
# hostname/IP) by the HostServ service # hostname/IP) by the HostServ service
vhosts: vhosts: