Repository: ergochat/ergo

Issue:
METADATA / property / taxonomy
I understand that IRCv3 side of things is going on at https://github.com/ircv3/ircv3-specifications/pull/339 and they are missing implementations for figuring out potential isssues.

Personally I am missing metadata for things like pronouns, [URL](https://aminda.eu/), [proof that the account belongs to me](https://aminda.eu/txt/irc.txt) and what I am using the particular account to, which on Atheme/Libera.Chat I have done with `/msg NickServ set property <key> <value>`. It would be nice if `atheme2json.py` would be able to import those.

Additionally I understand that [IRCCloud sets avatars via METADATA on servers supporting it](https://www.irccloud.com/faq#faq-avatars) and `coerce-ident` breaks IRCCloud avatars.

Discussion:
@Mikaela: As a curiosity, [Keyoxide](https://docs.keyoxide.org/getting-started/what-is-keyoxide/) also uses [SET PROPERTY/TAXONOMY for identity confirmations for IRC](https://docs.keyoxide.org/service-providers/irc/).@None: Why in the hell would you need pronouns, on a IRC server? Just put them in your nickname and stop begging for attention@Mikaela: I find myself missing this for Keyoxide again (while previously I just called it as a curiosity). Keyoxide could be another proof of identity and help with person managed password reset process by being another proof of identity and also linking the account to other profiles even if the original registered email was down.

For example my Keyoxide profile confirms that my LiberaChat accounts belong to me.

- https://keyoxide.org/aspe:keyoxide.org:LGWNUB7QG4M326FXXBH5Z6PLVY@slingamn: I would definitely like to see an implementation of https://github.com/ircv3/ircv3-specifications/pull/501 in Ergo, but I don't have the time to do it myself.@clbm87: Just wanted to follow up on this... I think metadata would be a pretty useful feature for categorization and for linking to external services?

@ValwareIRC: > Just wanted to follow up on this... I think metadata would be a pretty useful feature for categorization and for linking to external services?

you mean... like you could be the oauth provider for other things? ;o

Solution patch:
diff --git a/default.yaml b/default.yaml
index f0342204..22fd27fd 100644
--- a/default.yaml
+++ b/default.yaml
@@ -1087,6 +1087,16 @@ history:
 # e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details.
 allow-environment-overrides: true
 
+# experimental IRC metadata support for setting key/value data on channels and nicknames.
+metadata:
+    # can clients store metadata?
+    enabled: true
+    # how many keys can a client subscribe to? 
+    # set to 0 to disable subscriptions or -1 to allow unlimited subscriptions.
+    max-subs: 100
+    # how many keys can a user store about themselves? set to -1 to allow unlimited keys.
+    max-keys: 1000
+
 # experimental support for mobile push notifications
 # see the manual for potential security, privacy, and performance implications.
 # DO NOT enable if you are running a Tor or I2P hidden service (i.e. one
diff --git a/gencapdefs.py b/gencapdefs.py
index 859aaa3d..45453c2b 100644
--- a/gencapdefs.py
+++ b/gencapdefs.py
@@ -237,6 +237,13 @@ CAPDEFS = [
         url="https://github.com/ircv3/ircv3-specifications/pull/471",
         standard="Soju/Goguma vendor",
     ),
+    CapDef(
+        identifier="Metadata",
+        name="draft/metadata-2",
+        url="https://ircv3.net/specs/extensions/metadata",
+        standard="draft IRCv3",
+    ),
+
 ]
 
 def validate_defs():
diff --git a/irc/caps/defs.go b/irc/caps/defs.go
index 5f747d49..a6377884 100644
--- a/irc/caps/defs.go
+++ b/irc/caps/defs.go
@@ -7,7 +7,7 @@ package caps
 
 const (
 	// number of recognized capabilities:
-	numCapabs = 37
+	numCapabs = 38
 	// length of the uint32 array that represents the bitset:
 	bitsetLen = 2
 )
@@ -65,6 +65,10 @@ const (
 	// https://github.com/progval/ircv3-specifications/blob/redaction/extensions/message-redaction.md
 	MessageRedaction Capability = iota
 
+	// Metadata is the draft IRCv3 capability named "draft/metadata-2":
+	// https://ircv3.net/specs/extensions/metadata
+	Metadata Capability = iota
+
 	// Multiline is the proposed IRCv3 capability named "draft/multiline":
 	// https://github.com/ircv3/ircv3-specifications/pull/398
 	Multiline Capability = iota
@@ -178,6 +182,7 @@ var (
 		"draft/extended-isupport",
 		"draft/languages",
 		"draft/message-redaction",
+		"draft/metadata-2",
 		"draft/multiline",
 		"draft/no-implicit-names",
 		"draft/persistence",
diff --git a/irc/channel.go b/irc/channel.go
index 1dc55f43..1b449a70 100644
--- a/irc/channel.go
+++ b/irc/channel.go
@@ -55,6 +55,7 @@ type Channel struct {
 	dirtyBits         uint
 	settings          ChannelSettings
 	uuid              utils.UUID
+	metadata          map[string]string
 	// these caches are paired to allow iteration over channel members without holding the lock
 	membersCache    []*Client
 	memberDataCache []*memberData
@@ -126,6 +127,7 @@ func (channel *Channel) applyRegInfo(chanReg RegisteredChannel) {
 	channel.userLimit = chanReg.UserLimit
 	channel.settings = chanReg.Settings
 	channel.forward = chanReg.Forward
+	channel.metadata = chanReg.Metadata
 
 	for _, mode := range chanReg.Modes {
 		channel.flags.SetMode(mode, true)
@@ -163,6 +165,7 @@ func (channel *Channel) ExportRegistration() (info RegisteredChannel) {
 	info.AccountToUMode = maps.Clone(channel.accountToUMode)
 
 	info.Settings = channel.settings
+	info.Metadata = channel.metadata
 
 	return
 }
@@ -892,6 +895,10 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
 		rb.Add(nil, client.server.name, "MARKREAD", chname, client.GetReadMarker(chcfname))
 	}
 
+	if rb.session.capabilities.Has(caps.Metadata) {
+		syncChannelMetadata(client.server, rb, channel)
+	}
+
 	if rb.session.client == client {
 		// don't send topic and names for a SAJOIN of a different client
 		channel.SendTopic(client, rb, false)
diff --git a/irc/channelreg.go b/irc/channelreg.go
index 1978b4ef..3e61e460 100644
--- a/irc/channelreg.go
+++ b/irc/channelreg.go
@@ -63,6 +63,8 @@ type RegisteredChannel struct {
 	Invites map[string]MaskInfo
 	// Settings are the chanserv-modifiable settings
 	Settings ChannelSettings
+	// Metadata set using the METADATA command
+	Metadata map[string]string
 }
 
 func (r *RegisteredChannel) Serialize() ([]byte, error) {
diff --git a/irc/client.go b/irc/client.go
index 2e814ce9..25aa72cf 100644
--- a/irc/client.go
+++ b/irc/client.go
@@ -131,6 +131,7 @@ type Client struct {
 	clearablePushMessages   map[string]time.Time
 	pushSubscriptionsExist  atomic.Uint32 // this is a cache on len(pushSubscriptions) != 0
 	pushQueue               pushQueue
+	metadata                map[string]string
 }
 
 type saslStatus struct {
@@ -214,6 +215,8 @@ type Session struct {
 	batch MultilineBatch
 
 	webPushEndpoint string // goroutine-local: web push endpoint registered by the current session
+
+	metadataSubscriptions utils.HashSet[string]
 }
 
 // MultilineBatch tracks the state of a client-to-server multiline batch.
@@ -1129,6 +1132,7 @@ func (client *Client) SetNick(nick, nickCasefolded, skeleton string) (success bo
 	client.nickCasefolded = nickCasefolded
 	client.skeleton = skeleton
 	client.updateNickMaskNoMutex()
+
 	return true
 }
 
diff --git a/irc/commands.go b/irc/commands.go
index 54016b96..d5387306 100644
--- a/irc/commands.go
+++ b/irc/commands.go
@@ -209,6 +209,10 @@ func init() {
 			handler:   markReadHandler,
 			minParams: 0, // send FAIL instead of ERR_NEEDMOREPARAMS
 		},
+		"METADATA": {
+			handler:   metadataHandler,
+			minParams: 2,
+		},
 		"MODE": {
 			handler:   modeHandler,
 			minParams: 1,
diff --git a/irc/config.go b/irc/config.go
index b10e08e6..67f2ec03 100644
--- a/irc/config.go
+++ b/irc/config.go
@@ -723,6 +723,14 @@ type Config struct {
 		} `yaml:"tagmsg-storage"`
 	}
 
+	Metadata struct {
+		// BeforeConnect       int `yaml:"before-connect"` todo: this
+		Enabled       bool
+		MaxSubs       int `yaml:"max-subs"`
+		MaxKeys       int `yaml:"max-keys"`
+		MaxValueBytes int `yaml:"max-value-length"` // todo: currently unenforced!!
+	}
+
 	WebPush struct {
 		Enabled          bool
 		Timeout          time.Duration
@@ -1637,6 +1645,25 @@ func LoadConfig(filename string) (config *Config, err error) {
 		}
 	}
 
+	if !config.Metadata.Enabled {
+		config.Server.supportedCaps.Disable(caps.Metadata)
+	} else {
+		var metadataValues []string
+		if config.Metadata.MaxSubs >= 0 {
+			metadataValues = append(metadataValues, fmt.Sprintf("max-subs=%d", config.Metadata.MaxSubs))
+		}
+		if config.Metadata.MaxKeys > 0 {
+			metadataValues = append(metadataValues, fmt.Sprintf("max-keys=%d", config.Metadata.MaxKeys))
+		}
+		if config.Metadata.MaxValueBytes > 0 {
+			metadataValues = append(metadataValues, fmt.Sprintf("max-value-bytes=%d", config.Metadata.MaxValueBytes))
+		}
+		if len(metadataValues) != 0 {
+			config.Server.capValues[caps.Metadata] = strings.Join(metadataValues, ",")
+		}
+
+	}
+
 	err = config.processExtjwt()
 	if err != nil {
 		return nil, err
diff --git a/irc/getters.go b/irc/getters.go
index 29f97c75..5feadaa3 100644
--- a/irc/getters.go
+++ b/irc/getters.go
@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"maps"
 	"net"
+	"slices"
 	"time"
 
 	"github.com/ergochat/ergo/irc/caps"
@@ -797,10 +798,12 @@ func (channel *Channel) Settings() (result ChannelSettings) {
 }
 
 func (channel *Channel) SetSettings(settings ChannelSettings) {
+	defer channel.MarkDirty(IncludeSettings)
+
 	channel.stateMutex.Lock()
+	defer channel.stateMutex.Unlock()
+
 	channel.settings = settings
-	channel.stateMutex.Unlock()
-	channel.MarkDirty(IncludeSettings)
 }
 
 func (channel *Channel) setForward(forward string) {
@@ -827,3 +830,163 @@ func (channel *Channel) UUID() utils.UUID {
 	defer channel.stateMutex.RUnlock()
 	return channel.uuid
 }
+
+func (session *Session) isSubscribedTo(key string) bool {
+	session.client.stateMutex.RLock()
+	defer session.client.stateMutex.RUnlock()
+
+	return session.metadataSubscriptions.Has(key)
+}
+
+func (session *Session) SubscribeTo(keys ...string) ([]string, error) {
+	session.client.stateMutex.Lock()
+	defer session.client.stateMutex.Unlock()
+
+	if session.metadataSubscriptions == nil {
+		session.metadataSubscriptions = make(utils.HashSet[string])
+	}
+
+	var added []string
+
+	maxSubs := session.client.server.Config().Metadata.MaxSubs
+
+	for _, k := range keys {
+		if !session.metadataSubscriptions.Has(k) {
+			if len(session.metadataSubscriptions) > maxSubs {
+				return added, errMetadataTooManySubs
+			}
+			added = append(added, k)
+			session.metadataSubscriptions.Add(k)
+		}
+	}
+
+	return added, nil
+}
+
+func (session *Session) UnsubscribeFrom(keys ...string) []string {
+	session.client.stateMutex.Lock()
+	defer session.client.stateMutex.Unlock()
+
+	var removed []string
+
+	for k := range session.metadataSubscriptions {
+		if slices.Contains(keys, k) {
+			removed = append(removed, k)
+			session.metadataSubscriptions.Remove(k)
+		}
+	}
+
+	return removed
+}
+
+func (session *Session) MetadataSubscriptions() utils.HashSet[string] {
+	session.client.stateMutex.Lock()
+	defer session.client.stateMutex.Unlock()
+
+	return maps.Clone(session.metadataSubscriptions)
+}
+
+func (channel *Channel) GetMetadata(key string) (string, bool) {
+	channel.stateMutex.RLock()
+	defer channel.stateMutex.RUnlock()
+
+	val, ok := channel.metadata[key]
+	return val, ok
+}
+
+func (channel *Channel) SetMetadata(key string, value string) {
+	defer channel.MarkDirty(IncludeAllAttrs)
+
+	channel.stateMutex.Lock()
+	defer channel.stateMutex.Unlock()
+
+	if channel.metadata == nil {
+		channel.metadata = make(map[string]string)
+	}
+
+	channel.metadata[key] = value
+}
+
+func (channel *Channel) ListMetadata() map[string]string {
+	channel.stateMutex.RLock()
+	defer channel.stateMutex.RUnlock()
+
+	return maps.Clone(channel.metadata)
+}
+
+func (channel *Channel) DeleteMetadata(key string) {
+	defer channel.MarkDirty(IncludeAllAttrs)
+
+	channel.stateMutex.Lock()
+	defer channel.stateMutex.Unlock()
+
+	delete(channel.metadata, key)
+}
+
+func (channel *Channel) ClearMetadata() map[string]string {
+	defer channel.MarkDirty(IncludeAllAttrs)
+	channel.stateMutex.Lock()
+	defer channel.stateMutex.Unlock()
+
+	oldMap := channel.metadata
+	channel.metadata = nil
+
+	return oldMap
+}
+
+func (channel *Channel) CountMetadata() int {
+	channel.stateMutex.RLock()
+	defer channel.stateMutex.RUnlock()
+
+	return len(channel.metadata)
+}
+
+func (client *Client) GetMetadata(key string) (string, bool) {
+	client.stateMutex.RLock()
+	defer client.stateMutex.RUnlock()
+
+	val, ok := client.metadata[key]
+	return val, ok
+}
+
+func (client *Client) SetMetadata(key string, value string) {
+	client.stateMutex.Lock()
+	defer client.stateMutex.Unlock()
+
+	if client.metadata == nil {
+		client.metadata = make(map[string]string)
+	}
+
+	client.metadata[key] = value
+}
+
+func (client *Client) ListMetadata() map[string]string {
+	client.stateMutex.RLock()
+	defer client.stateMutex.RUnlock()
+
+	return maps.Clone(client.metadata)
+}
+
+func (client *Client) DeleteMetadata(key string) {
+	client.stateMutex.Lock()
+	defer client.stateMutex.Unlock()
+
+	delete(client.metadata, key)
+}
+
+func (client *Client) ClearMetadata() map[string]string {
+	client.stateMutex.Lock()
+	defer client.stateMutex.Unlock()
+
+	oldMap := client.metadata
+	client.metadata = nil
+
+	return oldMap
+}
+
+func (client *Client) CountMetadata() int {
+	client.stateMutex.RLock()
+	defer client.stateMutex.RUnlock()
+
+	return len(client.metadata)
+}
diff --git a/irc/handlers.go b/irc/handlers.go
index c606637f..6d727268 100644
--- a/irc/handlers.go
+++ b/irc/handlers.go
@@ -9,11 +9,13 @@ package irc
 import (
 	"bytes"
 	"fmt"
+	"maps"
 	"net"
 	"os"
 	"runtime"
 	"runtime/debug"
 	"runtime/pprof"
+	"slices"
 	"sort"
 	"strconv"
 	"strings"
@@ -3097,6 +3099,201 @@ func markReadHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res
 	return
 }
 
+// METADATA <target> <subcommand> [<and so on>...]
+func metadataHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) (exiting bool) {
+	originalTarget := msg.Params[0]
+	target := originalTarget
+
+	if !server.Config().Metadata.Enabled {
+		rb.Add(nil, server.name, "FAIL", "METADATA", "FORBIDDEN", originalTarget, "Metadata is disabled on this server")
+		return
+	}
+
+	subcommand := strings.ToLower(msg.Params[1])
+
+	invalidTarget := func() {
+		rb.Add(nil, server.name, "FAIL", "METADATA", "INVALID_TARGET", originalTarget, client.t("Invalid metadata target"))
+	}
+	noKeyPerms := func(key string) {
+		rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_NO_PERMISSION", originalTarget, key, client.t("You do not have permission to perform this action"))
+	}
+
+	if target == "*" {
+		target = client.Nick()
+	}
+
+	targetClient := server.clients.Get(target)
+	targetChannel := server.channels.Get(target)
+	if !metadataCanISeeThisTarget(client, target) {
+		invalidTarget()
+		return
+	}
+
+	var t MetadataHaver
+	if targetClient != nil {
+		t = targetClient
+	}
+	if targetChannel != nil {
+		t = targetChannel
+	}
+	if t == nil {
+		invalidTarget()
+		return
+	}
+
+	needsKey := subcommand == "set" || subcommand == "get" || subcommand == "sub" || subcommand == "unsub"
+	if needsKey && len(msg.Params) <= 2 {
+		rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.Nick(), msg.Command, client.t("Not enough parameters"))
+		return
+	}
+
+	switch subcommand {
+	case "set":
+		key := strings.ToLower(msg.Params[2])
+		if metadataKeyIsEvil(key) {
+			rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_INVALID", key, client.t("Invalid key name"))
+			return
+		}
+
+		if !metadataCanIEditThisKey(client, target, key) {
+			noKeyPerms(key)
+			return
+		}
+
+		if len(msg.Params) > 3 {
+			value := msg.Params[3]
+			const maxCombinedLen = 350
+
+			if len(key)+len(value) > maxCombinedLen {
+				rb.Add(nil, server.name, "FAIL", "METADATA", "VALUE_INVALID", client.t("Value is too long"))
+				return
+			}
+
+			maxKeys := server.Config().Metadata.MaxKeys
+			isSelf := targetClient != nil && client == targetClient
+
+			if isSelf && maxKeys > 0 && t.CountMetadata() >= maxKeys {
+				rb.Add(nil, server.name, "FAIL", "METADATA", "LIMIT_REACHED", client.nick, client.t("You have too many keys set on yourself"))
+				return
+			}
+
+			server.logger.Debug("metadata", "setting", key, value, "on", target)
+
+			t.SetMetadata(key, value)
+			notifySubscribers(server, rb.session, target, key, value)
+
+			rb.Add(nil, server.name, RPL_KEYVALUE, client.Nick(), originalTarget, key, "*", value)
+		} else {
+			server.logger.Debug("metadata", "deleting", key, "on", target)
+			t.DeleteMetadata(key)
+			notifySubscribers(server, rb.session, target, key, "")
+
+			rb.Add(nil, server.name, RPL_KEYNOTSET, client.Nick(), target, key, client.t("Key deleted"))
+		}
+
+	case "get":
+		batchId := rb.StartNestedBatch("metadata")
+		defer rb.EndNestedBatch(batchId)
+
+		for _, key := range msg.Params[2:] {
+			if metadataKeyIsEvil(key) {
+				rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_INVALID", key, client.t("Invalid key name"))
+				continue
+			}
+
+			val, ok := t.GetMetadata(key)
+			if !ok {
+				rb.Add(nil, server.name, RPL_KEYNOTSET, client.Nick(), target, key, client.t("Key is not set"))
+				continue
+			}
+
+			visibility := "*"
+			rb.Add(nil, server.name, RPL_KEYVALUE, client.Nick(), originalTarget, key, visibility, val)
+		}
+
+	case "list":
+		values := t.ListMetadata()
+
+		batchId := rb.StartNestedBatch("metadata")
+		defer rb.EndNestedBatch(batchId)
+
+		for key, val := range values {
+			visibility := "*"
+
+			rb.Add(nil, server.name, RPL_KEYVALUE, client.Nick(), originalTarget, key, visibility, val)
+		}
+
+	case "clear":
+		if !metadataCanIEditThisTarget(client, target) {
+			invalidTarget()
+			return
+		}
+
+		values := t.ClearMetadata()
+
+		batchId := rb.StartNestedBatch("metadata")
+		defer rb.EndNestedBatch(batchId)
+
+		for key, val := range values {
+			visibility := "*"
+			rb.Add(nil, server.name, RPL_KEYVALUE, client.Nick(), originalTarget, key, visibility, val)
+		}
+
+	case "sub":
+		keys := msg.Params[2:]
+		server.logger.Debug("metadata", client.nick, "has subscrumbled to", strings.Join(keys, ", "))
+		added, err := rb.session.SubscribeTo(keys...)
+		if err == errMetadataTooManySubs {
+			bad := keys[len(added)] // get the key that broke the camel's back
+			rb.Add(nil, server.name, "FAIL", "METADATA", "TOO_MANY_SUBS", bad, client.t("Too many subscriptions"))
+		}
+
+		lineLength := MaxLineLen - len(server.name) - len(RPL_METADATASUBOK) - len(client.Nick()) - 10
+
+		chunked := utils.ChunkifyParams(slices.Values(added), lineLength)
+		for _, line := range chunked {
+			params := append([]string{client.Nick()}, line...)
+			rb.Add(nil, server.name, RPL_METADATASUBOK, params...)
+		}
+
+	case "unsub":
+		keys := msg.Params[2:]
+		server.logger.Debug("metadata", client.nick, "has UNsubscrumbled to", strings.Join(keys, ", "))
+		removed := rb.session.UnsubscribeFrom(keys...)
+
+		lineLength := MaxLineLen - len(server.name) - len(RPL_METADATAUNSUBOK) - len(client.Nick()) - 10
+		chunked := utils.ChunkifyParams(slices.Values(removed), lineLength)
+		for _, line := range chunked {
+			params := append([]string{client.Nick()}, line...)
+			rb.Add(nil, server.name, RPL_METADATAUNSUBOK, params...)
+		}
+
+	case "subs":
+		lineLength := MaxLineLen - len(server.name) - len(RPL_METADATASUBS) - len(client.Nick()) - 10 // for safety
+
+		subs := rb.session.MetadataSubscriptions()
+
+		chunked := utils.ChunkifyParams(maps.Keys(subs), lineLength)
+		for _, line := range chunked {
+			params := append([]string{client.Nick()}, line...)
+			rb.Add(nil, server.name, RPL_METADATASUBS, params...)
+		}
+
+	case "sync":
+		if targetChannel != nil {
+			syncChannelMetadata(server, rb, targetChannel)
+		}
+		if targetClient != nil {
+			syncClientMetadata(server, rb, targetClient)
+		}
+
+	default:
+		rb.Add(nil, server.name, "FAIL", "METADATA", "SUBCOMMAND_INVALID", msg.Params[1], client.t("Invalid subcommand"))
+	}
+
+	return
+}
+
 // REHASH
 func rehashHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
 	nick := client.Nick()
diff --git a/irc/help.go b/irc/help.go
index 6a6824a0..d516c43e 100644
--- a/irc/help.go
+++ b/irc/help.go
@@ -339,6 +339,12 @@ command is processed by that server.`,
 MARKREAD updates an IRCv3 read message marker. It is not intended for use by
 end users. For more details, see the latest draft of the read-marker
 specification.`,
+	},
+	"metadata": {
+		text: `METADATA <target> <subcommand> [<everything else>...]
+		
+Retrieve and meddle with metadata for the given target.
+Have a look at https://ircv3.net/specs/extensions/metadata for interesting technical information.`,
 	},
 	"mode": {
 		text: `MODE <target> [<modestring> [<mode arguments>...]]
diff --git a/irc/metadata.go b/irc/metadata.go
new file mode 100644
index 00000000..d7094065
--- /dev/null
+++ b/irc/metadata.go
@@ -0,0 +1,164 @@
+package irc
+
+import (
+	"errors"
+	"regexp"
+	"strings"
+
+	"github.com/ergochat/ergo/irc/caps"
+	"github.com/ergochat/ergo/irc/modes"
+	"github.com/ergochat/ergo/irc/utils"
+)
+
+var (
+	errMetadataTooManySubs = errors.New("too many subscriptions")
+	errMetadataNotFound    = errors.New("key not found")
+)
+
+type MetadataHaver = interface {
+	SetMetadata(key string, value string)
+	GetMetadata(key string) (string, bool)
+	DeleteMetadata(key string)
+	ListMetadata() map[string]string
+	ClearMetadata() map[string]string
+	CountMetadata() int
+}
+
+func notifySubscribers(server *Server, session *Session, target string, key string, value string) {
+	var notify utils.HashSet[*Session] = make(utils.HashSet[*Session])
+	targetChannel := server.channels.Get(target)
+	targetClient := server.clients.Get(target)
+
+	if targetClient != nil {
+		notify = targetClient.FriendsMonitors(caps.Metadata)
+		// notify clients about changes regarding themselves
+		for _, s := range targetClient.Sessions() {
+			notify.Add(s)
+		}
+	}
+	if targetChannel != nil {
+		members := targetChannel.Members()
+		for _, m := range members {
+			for _, s := range m.Sessions() {
+				if s.capabilities.Has(caps.Metadata) {
+					notify.Add(s)
+				}
+			}
+		}
+	}
+
+	// don't notify the session that made the change
+	notify.Remove(session)
+
+	for s := range notify {
+		if !s.isSubscribedTo(key) {
+			continue
+		}
+
+		if value != "" {
+			s.Send(nil, server.name, "METADATA", target, key, "*", value)
+		} else {
+			s.Send(nil, server.name, "METADATA", target, key, "*")
+		}
+	}
+}
+
+func syncClientMetadata(server *Server, rb *ResponseBuffer, target *Client) {
+	if len(rb.session.MetadataSubscriptions()) == 0 {
+		return
+	}
+
+	batchId := rb.StartNestedBatch("metadata")
+	defer rb.EndNestedBatch(batchId)
+
+	values := target.ListMetadata()
+	for k, v := range values {
+		if rb.session.isSubscribedTo(k) {
+			visibility := "*"
+			rb.Add(nil, server.name, "METADATA", target.Nick(), k, visibility, v)
+		}
+	}
+}
+
+func syncChannelMetadata(server *Server, rb *ResponseBuffer, target *Channel) {
+	if len(rb.session.MetadataSubscriptions()) == 0 {
+		return
+	}
+
+	batchId := rb.StartNestedBatch("metadata")
+	defer rb.EndNestedBatch(batchId)
+
+	values := target.ListMetadata()
+	for k, v := range values {
+		if rb.session.isSubscribedTo(k) {
+			visibility := "*"
+			rb.Add(nil, server.name, "METADATA", target.Name(), k, visibility, v)
+		}
+	}
+
+	for _, client := range target.Members() {
+		values := client.ListMetadata()
+		for k, v := range values {
+			if rb.session.isSubscribedTo(k) {
+				visibility := "*"
+				rb.Add(nil, server.name, "METADATA", client.Nick(), k, visibility, v)
+			}
+		}
+	}
+}
+
+var metadataEvilCharsRegexp = regexp.MustCompile("[^A-Za-z0-9_./:-]+")
+
+func metadataKeyIsEvil(key string) bool {
+	key = strings.TrimSpace(key) // just in case
+
+	return len(key) == 0 || // key needs to contain stuff
+		key[0] == ':' || // key can't start with a colon
+		metadataEvilCharsRegexp.MatchString(key) // key can't contain the stuff it can't contain
+}
+
+func metadataCanIEditThisKey(client *Client, target string, _ string) bool {
+	if !metadataCanIEditThisTarget(client, target) { // you can't edit keys on targets you can't edit.
+		return false
+	}
+
+	// todo: we don't actually do anything regarding visibility yet so there's not much to do here
+
+	return true
+}
+
+func metadataCanIEditThisTarget(client *Client, target string) bool {
+	if !metadataCanISeeThisTarget(client, target) { // you can't edit what you can't see. a wise man told me this once
+		return false
+	}
+
+	if client.HasRoleCapabs("sajoin") { // sajoin opers can do whatever they want
+		return true
+	}
+
+	if target == client.Nick() { // your right to swing your fist ends where my nose begins
+		return true
+	}
+
+	// if you're a channel operator, knock yourself out
+	channel := client.server.channels.Get(target)
+	if channel != nil && channel.ClientIsAtLeast(client, modes.Operator) {
+		return true
+	}
+
+	return false
+}
+
+func metadataCanISeeThisTarget(client *Client, target string) bool {
+	if client.HasRoleCapabs("sajoin") { // sajoin opers can do whatever they want
+		return true
+	}
+
+	// check if the user is in the channel
+	channel := client.server.channels.Get(target)
+	if channel != nil && !channel.hasClient(client) {
+		return false
+	}
+
+	return true
+}
diff --git a/irc/numerics.go b/irc/numerics.go
index 97d4604d..bf5a6d60 100644
--- a/irc/numerics.go
+++ b/irc/numerics.go
@@ -183,6 +183,11 @@ const (
 	RPL_MONLIST            = "732"
 	RPL_ENDOFMONLIST       = "733"
 	ERR_MONLISTFULL        = "734"
+	RPL_KEYVALUE           = "761" // metadata numerics
+	RPL_KEYNOTSET          = "766"
+	RPL_METADATASUBOK      = "770"
+	RPL_METADATAUNSUBOK    = "771"
+	RPL_METADATASUBS       = "772"
 	RPL_LOGGEDIN           = "900"
 	RPL_LOGGEDOUT          = "901"
 	ERR_NICKLOCKED         = "902"
diff --git a/irc/utils/chunks.go b/irc/utils/chunks.go
new file mode 100644
index 00000000..30376626
--- /dev/null
+++ b/irc/utils/chunks.go
@@ -0,0 +1,28 @@
+package utils
+
+import "iter"
+
+func ChunkifyParams(params iter.Seq[string], maxChars int) [][]string {
+	var chunked [][]string
+
+	var acc []string
+	var length = 0
+
+	for p := range params {
+		length = length + len(p) + 1 // (accounting for the space)
+
+		if length > maxChars {
+			chunked = append(chunked, acc)
+			acc = []string{}
+			length = 0
+		}
+
+		acc = append(acc, p)
+	}
+
+	if len(acc) != 0 {
+		chunked = append(chunked, acc)
+	}
+
+	return chunked
+}
diff --git a/traditional.yaml b/traditional.yaml
index df6ff052..55f16ea7 100644
--- a/traditional.yaml
+++ b/traditional.yaml
@@ -1058,6 +1058,13 @@ history:
 # e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details.
 allow-environment-overrides: true
 
+# experimental IRC metadata support for setting key/value data on channels and nicknames.
+metadata:
+    # can clients use the metadata command?
+    enabled: true
+    # how many keys can a client subscribe to?
+    max-subs: 1000
+
 # experimental support for mobile push notifications
 # see the manual for potential security, privacy, and performance implications.
 # DO NOT enable if you are running a Tor or I2P hidden service (i.e. one



Test patch:
diff --git a/irc/metadata_test.go b/irc/metadata_test.go
new file mode 100644
index 00000000..93942abb
--- /dev/null
+++ b/irc/metadata_test.go
@@ -0,0 +1,21 @@
+package irc
+
+import "testing"
+
+func TestKeyCheck(t *testing.T) {
+	cases := []struct {
+		input  string
+		isEvil bool
+	}{
+		{"ImNormal", false},
+		{":imevil", true},
+		{"key£with$not%allowed^chars", true},
+		{"key.that:s_completely/normal-and.fine", false},
+	}
+
+	for _, c := range cases {
+		if metadataKeyIsEvil(c.input) != c.isEvil {
+			t.Errorf("%s should have returned %v. but it didn't. so that's not great", c.input, c.isEvil)
+		}
+	}
+}