From 2275bed0004262c55da8116505257ca41403c2e8 Mon Sep 17 00:00:00 2001 From: leah Date: Fri, 13 Jun 2025 18:10:37 +0100 Subject: [PATCH] add a bit of metadata-2 support --- default.yaml | 10 +++ gencapdefs.py | 7 ++ irc/caps/defs.go | 7 +- irc/channel.go | 3 + irc/channelreg.go | 2 + irc/client.go | 4 + irc/commands.go | 4 + irc/config.go | 27 ++++++ irc/getters.go | 155 +++++++++++++++++++++++++++++++++ irc/handlers.go | 200 +++++++++++++++++++++++++++++++++++++++++++ irc/help.go | 6 ++ irc/metadata.go | 121 ++++++++++++++++++++++++++ irc/metadata_test.go | 21 +++++ irc/numerics.go | 5 ++ irc/utils/chunks.go | 22 +++++ traditional.yaml | 7 ++ 16 files changed, 600 insertions(+), 1 deletion(-) create mode 100644 irc/metadata.go create mode 100644 irc/metadata_test.go create mode 100644 irc/utils/chunks.go diff --git a/default.yaml b/default.yaml index f0342204..7eb5f74c 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 given target store? 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..ddbf5bcc 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="MetadataTwoJudgementDay", + 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..cb78a430 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 + // MetadataTwoJudgementDay is the draft IRCv3 capability named "draft/metadata-2": + // https://ircv3.net/specs/extensions/metadata + MetadataTwoJudgementDay 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..2212570e 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -55,6 +55,7 @@ type Channel struct { dirtyBits uint settings ChannelSettings uuid utils.UUID + metadata MetadataStore // 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 } diff --git a/irc/channelreg.go b/irc/channelreg.go index 1978b4ef..03e23dfd 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 MetadataStore } func (r *RegisteredChannel) Serialize() ([]byte, error) { diff --git a/irc/client.go b/irc/client.go index 2e814ce9..15ee0f73 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 MetadataStore } 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 []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..f186850f 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"` + } + 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.MetadataTwoJudgementDay) + } 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.MetadataTwoJudgementDay] = strings.Join(metadataValues, ",") + } + + } + err = config.processExtjwt() if err != nil { return nil, err diff --git a/irc/getters.go b/irc/getters.go index 29f97c75..1a0d9f03 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" @@ -827,3 +828,157 @@ 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 slices.Contains(session.metadataSubscriptions, key) +} + +func (session *Session) SubscribeTo(keys ...string) ([]string, error) { + session.client.stateMutex.Lock() + defer session.client.stateMutex.Unlock() + + var added []string + + maxSubs := session.client.server.Config().Metadata.MaxSubs + + for _, k := range keys { + if !slices.Contains(session.metadataSubscriptions, k) { + if len(session.metadataSubscriptions) > maxSubs { + return added, errMetadataTooManySubs + } + added = append(added, k) + session.metadataSubscriptions = append(session.metadataSubscriptions, k) + } + } + + return added, nil +} + +func (session *Session) UnsubscribeFrom(keys ...string) []string { + session.client.stateMutex.Lock() + defer session.client.stateMutex.Unlock() + + var removed []string + + new := slices.DeleteFunc(session.metadataSubscriptions, + func(keyName string) bool { + if slices.Contains(keys, keyName) { + removed = append(removed, keyName) + return true + } else { + return false + } + }, + ) + + session.metadataSubscriptions = new + + return removed +} + +func (session *Session) MetadataSubscriptions() []string { + session.client.stateMutex.Lock() + defer session.client.stateMutex.Unlock() + + return slices.Clone(session.metadataSubscriptions) +} + +func (channel *Channel) GetMetadata(key string) (string, error) { + channel.stateMutex.RLock() + defer channel.stateMutex.RUnlock() + + val, ok := channel.metadata[key] + if !ok { + return "", errMetadataNotFound + } + return val, nil +} + +func (channel *Channel) SetMetadata(key string, value string) { + channel.stateMutex.Lock() + + if channel.metadata == nil { + channel.metadata = make(MetadataStore) + } + + channel.metadata[key] = value + channel.stateMutex.Unlock() + channel.MarkDirty(IncludeAllAttrs) +} + +func (channel *Channel) ListMetadata() MetadataStore { + channel.stateMutex.RLock() + defer channel.stateMutex.RUnlock() + + return maps.Clone(channel.metadata) +} + +func (channel *Channel) DeleteMetadata(key string) { + channel.stateMutex.Lock() + delete(channel.metadata, key) + + channel.stateMutex.Unlock() + channel.MarkDirty(IncludeAllAttrs) +} + +func (channel *Channel) ClearMetadata() MetadataStore { + channel.stateMutex.Lock() + + oldMap := channel.metadata + channel.metadata = make(MetadataStore) + + channel.stateMutex.Unlock() + channel.MarkDirty(IncludeAllAttrs) + return oldMap +} + +func (client *Client) GetMetadata(key string) (string, error) { + client.stateMutex.RLock() + defer client.stateMutex.RUnlock() + + val, ok := client.metadata[key] + if !ok { + return "", errMetadataNotFound + } + return val, nil +} + +func (client *Client) SetMetadata(key string, value string) { + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + + if client.metadata == nil { + client.metadata = make(MetadataStore) + } + + client.metadata[key] = value + + // coming soon: https://www.youtube.com/watch?v=K14JkFfWUzc +} + +func (client *Client) ListMetadata() MetadataStore { + 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() MetadataStore { + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + + oldMap := client.metadata + client.metadata = make(MetadataStore) + + return oldMap +} diff --git a/irc/handlers.go b/irc/handlers.go index c606637f..c03bfc18 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -3097,6 +3097,206 @@ func markReadHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res return } +// METADATA [...] +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 + } + + 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, err := t.GetMetadata(key) + if err == errMetadataNotFound { + 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(added, lineLength) + for _, line := range chunked { + params := append([]string{client.Nick()}, line...) + rb.Add(nil, server.name, RPL_METADATASUBS, 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(removed, lineLength) + for _, line := range chunked { + params := append([]string{client.Nick()}, line...) + rb.Add(nil, server.name, RPL_METADATASUBS, params...) + } + + case "subs": + lineLength := MaxLineLen - len(server.name) - len(RPL_METADATASUBS) - len(client.Nick()) - 10 // for safety + + chunked := utils.ChunkifyParams(rb.session.MetadataSubscriptions(), lineLength) + for _, line := range chunked { + params := append([]string{client.Nick()}, line...) + rb.Add(nil, server.name, RPL_METADATASUBS, params...) + } + + case "sync": + batchId := rb.StartNestedBatch("metadata") + defer rb.EndNestedBatch(batchId) + + values := t.ListMetadata() + for k, v := range values { + if rb.session.isSubscribedTo(k) { + visibility := "*" + rb.Add(nil, server.name, "METADATA", target, k, visibility, v) + } + } + if targetChannel != nil { + for _, client := range targetChannel.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) + } + } + } + } + + 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 [...] + +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 [ [...]] diff --git a/irc/metadata.go b/irc/metadata.go new file mode 100644 index 00000000..fdc86b4a --- /dev/null +++ b/irc/metadata.go @@ -0,0 +1,121 @@ +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 MetadataStore = map[string]string + +type MetadataHaver = interface { + SetMetadata(key string, value string) + GetMetadata(key string) (string, error) + DeleteMetadata(key string) + ListMetadata() MetadataStore + ClearMetadata() MetadataStore +} + +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.MetadataTwoJudgementDay) + // 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.MetadataTwoJudgementDay) { + 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, "*") + } + } +} + +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/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) + } + } +} 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..fb541ec5 --- /dev/null +++ b/irc/utils/chunks.go @@ -0,0 +1,22 @@ +package utils + +func ChunkifyParams(params []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) + } + + 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