From 99beb8eb7c51209404833ad4d8605fbf65f86947 Mon Sep 17 00:00:00 2001
From: aoife cassidy <aoife@enby.space>
Date: Sat, 31 Aug 2024 17:35:52 -0700
Subject: [PATCH] =?UTF-8?q?implement=20a=20plural=20system=20manager,=20?=
 =?UTF-8?q?=C3=A0=20la=20PluralKit?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

this commit adds support for plural system management directly inside
Ergo, including the new PluralServ service.

read the blog post for more information:
https://enby.space/blog/pluralkit-in-irc/

this patch is licensed under MIT. its canonical origin is enby.space,
and it was authored by aoife cassidy <aoife@enby.space>.
---
 default.yaml      |   5 +
 irc/accounts.go   | 174 +++++++++++++++++++++++++++++++
 irc/channel.go    |   6 +-
 irc/config.go     |   1 +
 irc/errors.go     |   4 +
 irc/handlers.go   |  29 +++++-
 irc/pluralserv.go | 256 ++++++++++++++++++++++++++++++++++++++++++++++
 irc/services.go   |  16 ++-
 8 files changed, 482 insertions(+), 9 deletions(-)
 create mode 100644 irc/pluralserv.go

diff --git a/default.yaml b/default.yaml
index 7a62f227..a10659f7 100644
--- a/default.yaml
+++ b/default.yaml
@@ -375,6 +375,11 @@ server:
     # if you don't want to publicize how popular the server is
     suppress-lusers: false
 
+    # enables the PluralServ service, and allows users to appear with other nicks
+    # based on message pattern. refer to https://enby.space/blog/pluralkit-in-irc/
+    # for more information.
+    allow-plural: false
+
 # account options
 accounts:
     # is account authentication enabled, i.e., can users log into existing accounts?
diff --git a/irc/accounts.go b/irc/accounts.go
index 87a1d0c2..a4e13944 100644
--- a/irc/accounts.go
+++ b/irc/accounts.go
@@ -48,6 +48,7 @@ const (
 	keyAccountSuspended        = "account.suspended %s" // client realname stored as string
 	keyAccountPwReset          = "account.pwreset %s"
 	keyAccountEmailChange      = "account.emailchange %s"
+	keyAccountSystem           = "account.system %s"
 	// for an always-on client, a map of channel names they're in to their current modes
 	// (not to be confused with their amodes, which a non-always-on client can have):
 	keyAccountChannelToModes = "account.channeltomodes %s"
@@ -488,6 +489,167 @@ func (am *AccountManager) Register(client *Client, account string, callbackNames
 	}
 }
 
+// represents someone's system in PluralServ
+type SystemInfo struct {
+	Enabled      bool
+	Members      map[string]string
+	LatchEnabled bool
+	LastFront    string
+}
+
+// callback type implementing the actual business logic of system operations
+type systemMunger func(input SystemInfo) (output SystemInfo, err error)
+
+func (am *AccountManager) SystemSetEnabled(client *Client, enabled bool) (result SystemInfo, err error) {
+	munger := func(input SystemInfo) (output SystemInfo, err error) {
+		output = input
+		output.Enabled = enabled
+		return
+	}
+
+	return am.performSystemChange(client.Account(), munger)
+}
+
+func (am *AccountManager) SystemSet(client *Client, name string, tag string) (result SystemInfo, err error) {
+	munger := func(input SystemInfo) (output SystemInfo, err error) {
+		_, ok := input.Members[name]
+
+		output = input
+		if tag == "" {
+			if !ok {
+				err = errNoSuchMember
+				return
+			}
+			delete(output.Members, name)
+			return
+		} else {
+			if ok {
+				err = errMemberAlreadyExists
+				return
+			}
+			if !strings.Contains(tag, "text") {
+				err = errInvalidProxyTag
+				return
+			}
+			output.Members[name] = tag
+			return
+		}
+	}
+
+	return am.performSystemChange(client.Account(), munger)
+}
+
+func (am *AccountManager) SystemRename(client *Client, old string, new string) (result SystemInfo, err error) {
+	munger := func(input SystemInfo) (output SystemInfo, err error) {
+		tag, ok := input.Members[old]
+		if !ok {
+			err = errNoSuchMember
+			return
+		}
+
+		output = input
+		output.Members[new] = tag
+		delete(output.Members, old)
+		return
+	}
+
+	return am.performSystemChange(client.Account(), munger)
+}
+
+func (am *AccountManager) SystemSetProxy(client *Client, name string, tag string) (result SystemInfo, err error) {
+	munger := func(input SystemInfo) (output SystemInfo, err error) {
+		_, ok := input.Members[name]
+		if !ok {
+			err = errNoSuchMember
+			return
+		}
+		if !strings.Contains(tag, "name") {
+			err = errInvalidProxyTag
+			return
+		}
+
+		output = input
+		output.Members[name] = tag
+		return
+	}
+
+	return am.performSystemChange(client.Account(), munger)
+}
+
+func (am *AccountManager) SystemSetLatchEnabled(client *Client, enabled bool) (result SystemInfo, err error) {
+	munger := func(input SystemInfo) (output SystemInfo, err error) {
+		output = input
+		output.LatchEnabled = enabled
+		return
+	}
+
+	return am.performSystemChange(client.Account(), munger)
+}
+
+func (am *AccountManager) SystemSetLastFront(client *Client, fronter string) (result SystemInfo, err error) {
+	munger := func(input SystemInfo) (output SystemInfo, err error) {
+		output = input
+		output.LastFront = fronter
+		return
+	}
+
+	return am.performSystemChange(client.Account(), munger)
+}
+
+func (am *AccountManager) performSystemChange(account string, munger systemMunger) (result SystemInfo, err error) {
+	am.serialCacheUpdateMutex.Lock()
+	defer am.serialCacheUpdateMutex.Unlock()
+
+	account, err = CasefoldName(account)
+	if err != nil || account == "" {
+		err = errAccountDoesNotExist
+		return
+	}
+
+	if am.server.Defcon() <= 3 {
+		err = errFeatureDisabled
+		return
+	}
+
+	clientAccount, err := am.LoadAccount(account)
+	if err != nil {
+		err = errAccountDoesNotExist
+		return
+	} else if !clientAccount.Verified {
+		err = errAccountUnverified
+		return
+	}
+
+	if clientAccount.System.Members == nil {
+		clientAccount.System.Members = make(map[string]string)
+	}
+
+	result, err = munger(clientAccount.System)
+	if err != nil {
+		return
+	}
+
+	systext, err := json.Marshal(result)
+	if err != nil {
+		err = errAccountUpdateFailed
+		return
+	}
+	sysstr := string(systext)
+
+	key := fmt.Sprintf(keyAccountSystem, account)
+	err = am.server.store.Update(func(tx *buntdb.Tx) error {
+		_, _, err := tx.Set(key, sysstr, nil)
+		return err
+	})
+
+	if err != nil {
+		err = errAccountUpdateFailed
+		return
+	}
+
+	return result, nil
+}
+
 type registrationCallbackError struct {
 	underlying error
 }
@@ -1627,6 +1789,12 @@ func (am *AccountManager) deserializeRawAccount(raw rawClientAccount, cfName str
 			result.Suspended = sus
 		}
 	}
+	if raw.System != "" {
+		e := json.Unmarshal([]byte(raw.System), &result.System)
+		if e != nil {
+			am.server.logger.Warning("internal", "corrupt system data", result.Name, e.Error())
+		}
+	}
 	return
 }
 
@@ -1638,6 +1806,7 @@ func (am *AccountManager) loadRawAccount(tx *buntdb.Tx, casefoldedAccount string
 	verifiedKey := fmt.Sprintf(keyAccountVerified, casefoldedAccount)
 	nicksKey := fmt.Sprintf(keyAccountAdditionalNicks, casefoldedAccount)
 	vhostKey := fmt.Sprintf(keyAccountVHost, casefoldedAccount)
+	systemKey := fmt.Sprintf(keyAccountSystem, casefoldedAccount)
 	settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount)
 	suspendedKey := fmt.Sprintf(keyAccountSuspended, casefoldedAccount)
 
@@ -1654,6 +1823,7 @@ func (am *AccountManager) loadRawAccount(tx *buntdb.Tx, casefoldedAccount string
 	result.VHost, _ = tx.Get(vhostKey)
 	result.Settings, _ = tx.Get(settingsKey)
 	result.Suspended, _ = tx.Get(suspendedKey)
+	result.System, _ = tx.Get(systemKey)
 
 	if _, e = tx.Get(verifiedKey); e == nil {
 		result.Verified = true
@@ -1843,6 +2013,7 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
 	suspendedKey := fmt.Sprintf(keyAccountSuspended, casefoldedAccount)
 	pwResetKey := fmt.Sprintf(keyAccountPwReset, casefoldedAccount)
 	emailChangeKey := fmt.Sprintf(keyAccountEmailChange, casefoldedAccount)
+	systemKey := fmt.Sprintf(keyAccountSystem, casefoldedAccount)
 
 	var clients []*Client
 	defer func() {
@@ -1901,6 +2072,7 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
 		tx.Delete(suspendedKey)
 		tx.Delete(pwResetKey)
 		tx.Delete(emailChangeKey)
+		tx.Delete(systemKey)
 
 		return nil
 	})
@@ -2421,6 +2593,7 @@ type ClientAccount struct {
 	AdditionalNicks []string
 	VHost           VHostInfo
 	Settings        AccountSettings
+	System          SystemInfo
 }
 
 // convenience for passing around raw serialized account data
@@ -2433,4 +2606,5 @@ type rawClientAccount struct {
 	VHost           string
 	Settings        string
 	Suspended       string
+	System          string
 }
diff --git a/irc/channel.go b/irc/channel.go
index 664ea14d..01a357db 100644
--- a/irc/channel.go
+++ b/irc/channel.go
@@ -1295,7 +1295,7 @@ func msgCommandToHistType(command string) (history.ItemType, error) {
 	}
 }
 
-func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mode, clientOnlyTags map[string]string, client *Client, message utils.SplitMessage, rb *ResponseBuffer) {
+func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mode, clientOnlyTags map[string]string, client *Client, message utils.SplitMessage, rb *ResponseBuffer, sendAsMember string) {
 	histType, err := msgCommandToHistType(command)
 	if err != nil {
 		return
@@ -1344,6 +1344,10 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
 		}
 	}
 
+	if sendAsMember != "" {
+		details.nickMask = sendAsMember
+	}
+
 	// send echo-message
 	rb.addEchoMessage(clientOnlyTags, details.nickMask, details.accountName, command, chname, message)
 
diff --git a/irc/config.go b/irc/config.go
index 5216f16e..0a38405c 100644
--- a/irc/config.go
+++ b/irc/config.go
@@ -607,6 +607,7 @@ type Config struct {
 		OverrideServicesHostname string              `yaml:"override-services-hostname"`
 		MaxLineLen               int                 `yaml:"max-line-len"`
 		SuppressLusers           bool                `yaml:"suppress-lusers"`
+		AllowPlural              bool                `yaml:"allow-plural"`
 	}
 
 	Roleplay struct {
diff --git a/irc/errors.go b/irc/errors.go
index aed797eb..e8115788 100644
--- a/irc/errors.go
+++ b/irc/errors.go
@@ -77,6 +77,10 @@ var (
 	errInvalidAccountRename           = errors.New("Account renames can only change the casefolding of the account name")
 	errNameReserved                   = errors.New(`Name reserved due to a prior registration`)
 	errInvalidBearerTokenType         = errors.New("invalid bearer token type")
+	errNoMembers                      = errors.New(`You do not have any members in your system`)
+	errNoSuchMember                   = errors.New(`No such member`)
+	errMemberAlreadyExists            = errors.New(`Member with this name already exists`)
+	errInvalidProxyTag                = errors.New(`Invalid proxy tag; refer to PluralKit's documentation`)
 )
 
 // String Errors
diff --git a/irc/handlers.go b/irc/handlers.go
index 0f264a87..80659829 100644
--- a/irc/handlers.go
+++ b/irc/handlers.go
@@ -11,6 +11,7 @@ import (
 	"fmt"
 	"net"
 	"os"
+	"regexp"
 	"runtime"
 	"runtime/debug"
 	"runtime/pprof"
@@ -570,7 +571,7 @@ func batchHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon
 			// XXX changing the label inside a handler is a bit dodgy, but it works here
 			// because there's no way we could have triggered a flush up to this point
 			rb.Label = batch.responseLabel
-			dispatchMessageToTarget(client, batch.tags, histType, batch.command, batch.target, batch.message, rb)
+			dispatchMessageToTarget(client, batch.tags, histType, batch.command, batch.target, batch.message, rb, "")
 		}
 	}
 
@@ -2295,6 +2296,26 @@ func messageHandler(server *Server, client *Client, msg ircmsg.Message, rb *Resp
 		return false
 	}
 
+	// check if message matches system member proxy or account has latch enabled
+	var sendAsMember string
+	if account, err := server.accounts.LoadAccount(client.Account()); msg.Command == "PRIVMSG" && err == nil {
+		if account.System.Enabled && len(account.System.Members) > 0 {
+			for member, tag := range account.System.Members {
+				tagAsRegex := strings.Replace(regexp.QuoteMeta(tag), "text", "(.*)", 1)
+				regex := regexp.MustCompile("^" + tagAsRegex + "$")
+				if matches := regex.FindStringSubmatch(message); len(matches) > 1 {
+					sendAsMember = member
+					message = matches[1]
+					server.accounts.SystemSetLastFront(client, sendAsMember)
+					break
+				}
+			}
+			if account.System.LatchEnabled && account.System.LastFront != "" {
+				sendAsMember = account.System.LastFront
+			}
+		}
+	}
+
 	for i, targetString := range targets {
 		// max of four targets per privmsg
 		if i == maxTargets {
@@ -2312,12 +2333,12 @@ func messageHandler(server *Server, client *Client, msg ircmsg.Message, rb *Resp
 
 		// each target gets distinct msgids
 		splitMsg := utils.MakeMessage(message)
-		dispatchMessageToTarget(client, clientOnlyTags, histType, msg.Command, targetString, splitMsg, rb)
+		dispatchMessageToTarget(client, clientOnlyTags, histType, msg.Command, targetString, splitMsg, rb, sendAsMember)
 	}
 	return false
 }
 
-func dispatchMessageToTarget(client *Client, tags map[string]string, histType history.ItemType, command, target string, message utils.SplitMessage, rb *ResponseBuffer) {
+func dispatchMessageToTarget(client *Client, tags map[string]string, histType history.ItemType, command, target string, message utils.SplitMessage, rb *ResponseBuffer, sendAsMember string) {
 	server := client.server
 
 	prefixes, target := modes.SplitChannelMembershipPrefixes(target)
@@ -2333,7 +2354,7 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi
 			}
 			return
 		}
-		channel.SendSplitMessage(command, lowestPrefix, tags, client, message, rb)
+		channel.SendSplitMessage(command, lowestPrefix, tags, client, message, rb, sendAsMember)
 	} else if target[0] == '$' && len(target) > 2 && client.Oper().HasRoleCapab("massmessage") {
 		details := client.Details()
 		matcher, err := utils.CompileGlob(target[2:], false)
diff --git a/irc/pluralserv.go b/irc/pluralserv.go
new file mode 100644
index 00000000..b6485f60
--- /dev/null
+++ b/irc/pluralserv.go
@@ -0,0 +1,256 @@
+// Copyright (c) 2017 Daniel Oaks <daniel@danieloaks.net>
+// released under the MIT license
+
+package irc
+
+import (
+	"fmt"
+	"strings"
+)
+
+const pluralservHelp = `PluralServ lets you manage your plural system members.`
+
+func pluralservEnabled(config *Config) bool {
+	return config.Server.AllowPlural && config.Accounts.AuthenticationEnabled
+}
+
+var (
+	pluralservCommands = map[string]*serviceCommand{
+		"on": {
+			handler: psOnOffHandler,
+			help: `Syntax: $bON$b
+
+ON enables PluralServ for your messages.`,
+			helpShort: `$bON$b enables PluralServ.`,
+			enabled:   pluralservEnabled,
+		},
+		"off": {
+			handler: psOnOffHandler,
+			help: `Syntax: $bOFF$b
+
+ON disables PluralServ for your messages.`,
+			helpShort:    `$bOFF$b disables PluralServ.`,
+			enabled:      pluralservEnabled,
+			authRequired: true,
+		},
+		"info": {
+			handler: psInfoHandler,
+			help: `Syntax: $bINFO [nickname]$b
+
+INFO gives you information about the given (or your own) system members.`,
+			helpShort:    `$bINFO$b lists the system attached to a nickname.`,
+			enabled:      pluralservEnabled,
+			authRequired: true,
+		},
+		"add": {
+			handler: psAddHandler,
+			help: `Syntax: $bADD <name> <tag>$b
+
+ADD adds a new member to your system with the given name and proxy tag. The
+proxy tag syntax follows PluralKit's syntax.`,
+			helpShort:    `$bADD$b adds a system member.`,
+			enabled:      pluralservEnabled,
+			authRequired: true,
+			minParams:    2,
+		},
+		"del": {
+			handler: psAddHandler,
+			help: `Syntax: $DEL <member>$b
+
+DEL deletes a member from your system.`,
+			helpShort:    `$bDEL$b deletes a system member.`,
+			enabled:      pluralservEnabled,
+			authRequired: true,
+			minParams:    1,
+		},
+		"rename": {
+			handler: psRenameHandler,
+			help: `Syntax: $bRENAME <member> <name>$b
+
+RENAME renames a member to the given name.`,
+			helpShort:    `$bRENAME$b renames a member.`,
+			enabled:      pluralservEnabled,
+			authRequired: true,
+			minParams:    2,
+		},
+		"setproxy": {
+			handler: psSetproxyHandler,
+			help: `Syntax: $bSETPROXY <member> <tag>$b
+
+SETPROXY sets a new proxy tag for the given member. The proxy tag syntax
+follows PluralKit's syntax.`,
+			helpShort:    `$bSETPROXY$b sets a new proxy tag.`,
+			enabled:      pluralservEnabled,
+			authRequired: true,
+			minParams:    2,
+		},
+		"latch": {
+			handler: psLatchHandler,
+			help: `Syntax: $bLATCH <on|off>$b
+
+LATCH controls whether messages not fitting any proxy tag will show up as the
+member who last fronted.`,
+			helpShort:    `$bLATCH$b controls autoproxy latch.`,
+			enabled:      pluralservEnabled,
+			authRequired: true,
+			minParams:    1,
+		},
+	}
+)
+
+func psOnOffHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
+	enable := false
+	if command == "on" {
+		enable = true
+	}
+
+	_, err := server.accounts.SystemSetEnabled(client, enable)
+	if err != nil {
+		service.Notice(rb, client.t("An error occurred"))
+	} else if enable {
+		service.Notice(rb, client.t("Successfully enabled your system"))
+	} else {
+		service.Notice(rb, client.t("Successfully disabled your system"))
+	}
+}
+
+func psAddHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
+	name := params[0]
+	var tag string
+
+	if command == "add" {
+		tag = strings.Join(params[1:], " ")
+	}
+	// else: command == "del", tag == ""
+
+	if account, _ := server.accounts.LoadAccount(client.Account()); !account.System.Enabled {
+		service.Notice(rb, client.t("PluralServ is disabled; enable it with /PS ON"))
+		return
+	}
+
+	_, err := server.accounts.SystemSet(client, name, tag)
+	if err == errNoSuchMember || err == errMemberAlreadyExists || err == errInvalidProxyTag {
+		service.Notice(rb, client.t(err.Error()))
+	} else if err != nil {
+		service.Notice(rb, client.t("An error occurred"))
+	} else if tag != "" {
+		service.Notice(rb, fmt.Sprintf(client.t("Successfully added member %s to system"), name))
+	} else {
+		service.Notice(rb, fmt.Sprintf(client.t("Successfully deleted member %s from system"), name))
+	}
+}
+
+func psInfoHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
+	if !server.Config().Accounts.AuthenticationEnabled && !client.HasRoleCapabs("accreg") {
+		service.Notice(rb, client.t("This command has been disabled by the server administrators"))
+		return
+	}
+
+	var accountName string
+	if len(params) > 0 {
+		nick := params[0]
+		if server.Config().Accounts.NickReservation.Enabled {
+			accountName = server.accounts.NickToAccount(nick)
+			if accountName == "" {
+				service.Notice(rb, client.t("That nickname is not registered"))
+				return
+			}
+		} else {
+			accountName = nick
+		}
+	} else {
+		accountName = client.Account()
+		if accountName == "" {
+			service.Notice(rb, client.t("You're not logged into an account"))
+			return
+		}
+	}
+
+	account, err := server.accounts.LoadAccount(accountName)
+	if err != nil || !account.Verified {
+		if server.accounts.accountWasUnregistered(accountName) {
+			service.Notice(rb, client.t("Name reserved due to a prior registration"))
+		} else {
+			service.Notice(rb, client.t("Account does not exist"))
+		}
+		return
+	}
+
+	if !account.System.Enabled {
+		service.Notice(rb, fmt.Sprintf(client.t("Account %s has not enabled PluralServ"), accountName))
+		return
+	}
+
+	if len(account.System.Members) != 0 {
+		service.Notice(rb, fmt.Sprintf(client.t("Account %s has %d system members."), account.Name, len(account.System.Members)))
+		i := 1
+		for member, tag := range account.System.Members {
+			service.Notice(rb, fmt.Sprintf(client.t("%d: %s (%s)"), i, member, tag))
+			i++
+		}
+	} else {
+		service.Notice(rb, fmt.Sprintf(client.t("Account %s has no members"), accountName))
+	}
+}
+
+func psRenameHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
+	if account, _ := server.accounts.LoadAccount(client.Account()); !account.System.Enabled {
+		service.Notice(rb, client.t("PluralServ is disabled; enable it with /PS ON"))
+		return
+	}
+
+	oldName, newName := params[0], params[1]
+	_, err := server.accounts.SystemRename(client, oldName, newName)
+
+	if err != nil {
+		service.Notice(rb, fmt.Sprintf(client.t("Couldn't rename member: %s"), client.t(err.Error())))
+		return
+	}
+
+	service.Notice(rb, client.t("Successfully renamed member"))
+}
+
+func psSetproxyHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
+	if account, _ := server.accounts.LoadAccount(client.Account()); !account.System.Enabled {
+		service.Notice(rb, client.t("PluralServ is disabled; enable it with /PS ON"))
+		return
+	}
+
+	member, tag := params[0], strings.Join(params[1:], " ")
+	_, err := server.accounts.SystemSetProxy(client, member, tag)
+
+	if err == errInvalidProxyTag {
+		service.Notice(rb, client.t(err.Error()))
+	} else if err != nil {
+		service.Notice(rb, fmt.Sprintf(client.t("Couldn't set member proxy: %s"), client.t(err.Error())))
+		return
+	}
+
+	service.Notice(rb, fmt.Sprintf(client.t("Successfully set member proxy for %s"), member))
+}
+
+func psLatchHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
+	if account, _ := server.accounts.LoadAccount(client.Account()); !account.System.Enabled {
+		service.Notice(rb, client.t("PluralServ is disabled; enable it with /PS ON"))
+		return
+	}
+
+	enable := false
+	if params[0] == "on" {
+		enable = true
+	} else if params[0] == "off" {
+		enable = false
+	} else {
+		service.Notice(rb, client.t(errInvalidParams.Error()))
+		return
+	}
+
+	_, err := server.accounts.SystemSetLatchEnabled(client, enable)
+	if err != nil {
+		service.Notice(rb, client.t("An error occurred"))
+	} else if enable {
+		service.Notice(rb, client.t("Successfully enabled latch mode"))
+	} else {
+		service.Notice(rb, client.t("Successfully disabled latch mode"))
+	}
+}
diff --git a/irc/services.go b/irc/services.go
index 7bd9c5f1..0d82edd3 100644
--- a/irc/services.go
+++ b/irc/services.go
@@ -93,14 +93,22 @@ var (
 		Commands:       histservCommands,
 		HelpBanner:     histservHelp,
 	}
+	pluralservService = &ircService{
+		Name:           "PluralServ",
+		ShortName:      "PS",
+		CommandAliases: []string{"PLURALSERV", "PS"},
+		Commands:       pluralservCommands,
+		HelpBanner:     pluralservHelp,
+	}
 )
 
 // all services, by lowercase name
 var ErgoServices = map[string]*ircService{
-	"nickserv": nickservService,
-	"chanserv": chanservService,
-	"hostserv": hostservService,
-	"histserv": histservService,
+	"nickserv":   nickservService,
+	"chanserv":   chanservService,
+	"hostserv":   hostservService,
+	"histserv":   histservService,
+	"pluralserv": pluralservService,
 }
 
 func (service *ircService) Notice(rb *ResponseBuffer, text string) {
-- 
2.45.2

