diff --git a/ibis/ibisfeatures.h b/ibis/ibisfeatures.h
new file mode 100644
--- /dev/null
+++ b/ibis/ibisfeatures.h
@@ -0,0 +1,383 @@
+/*
+ * Ibis - IRCv3 Library
+ * Copyright (C) 2022-2024 Ibis Developers <devel@pidgin.im>
+ *
+ * Ibis is the legal property of its developers, whose names are too numerous
+ * to list here.  Please refer to the COPYRIGHT file distributed with this
+ * source distribution.
+ *
+ * This library is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this library; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#if !defined(IBIS_GLOBAL_HEADER_INSIDE) && !defined(IBIS_COMPILATION)
+# error "only <ibis.h> may be included directly"
+#endif
+
+#ifndef IBIS_FEATURES_H
+#define IBIS_FEATURES_H
+
+#include <glib.h>
+#include <glib-object.h>
+#include "ibisversion.h"
+#include "ibismessage.h"
+
+G_BEGIN_DECLS
+
+#define IBIS_TYPE_FEATURES (ibis_features_get_type())
+
+IBIS_AVAILABLE_IN_0_3
+G_DECLARE_FINAL_TYPE(IbisFeatures, ibis_features, IBIS, FEATURES, GObject)
+
+/**
+ * ibis_features_new:
+ *
+ * Create a new features object.
+ *
+ * Returns: (transfer full): The new features object.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+IbisFeatures *ibis_features_new(void);
+
+/**
+ * ibis_features_parse:
+ *
+ * Parses an RPL_ISUPPORT message into a features object.
+ *
+ * @features: The instance.
+ * @message: The message to parse.
+ *
+ * Returns: Whether parsing was successful.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+gboolean ibis_features_parse(IbisFeatures *features, IbisMessage *message);
+
+/**
+ * ibis_features_get_awaylen:
+ * @features: The instance.
+ *
+ * Gets the AWAYLEN parameter from @features.
+ *
+ * Returns: AWAYLEN or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+guint64 ibis_features_get_awaylen(IbisFeatures *features);
+
+/**
+ * ibis_features_get_casemapping:
+ * @features: The instance.
+ *
+ * Gets the CASEMAPPING parameter from @features.
+ *
+ * Returns: CASEMAPPING or %NULL if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+const char *ibis_features_get_casemapping(IbisFeatures *features);
+
+/**
+ * ibis_features_get_chanlimit:
+ * @features: The instance.
+ *
+ * Gets the CHANLIMIT parameter from @features.
+ *
+ * Returns: CHANLIMIT or %NULL if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+const char *ibis_features_get_chanlimit(IbisFeatures *features);
+
+/**
+ * ibis_features_get_chanmodes:
+ * @features: The instance.
+ *
+ * Gets the CHANMODES parameter from @features.
+ *
+ * Returns: CHANMODES or %NULL if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+const char *ibis_features_get_chanmodes(IbisFeatures *features);
+
+/**
+ * ibis_features_get_chantypes:
+ * @features: The instance.
+ *
+ * Gets the CHANTYPES parameter from @features.
+ *
+ * Returns: CHANTYPES or the default value of '#' if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+const char *ibis_features_get_chantypes(IbisFeatures *features);
+
+/**
+ * ibis_features_get_channellen:
+ * @features: The instance.
+ *
+ * Gets the CHANNELLEN parameter from @features.
+ *
+ * Returns: CHANNELLEN or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+guint64 ibis_features_get_channellen(IbisFeatures *features);
+
+/**
+ * ibis_features_get_elist:
+ * @features: The instance.
+ *
+ * Gets the ELIST parameter from @features.
+ *
+ * Returns: ELIST or %NULL if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+const char *ibis_features_get_elist(IbisFeatures *features);
+
+/**
+ * ibis_features_get_excepts:
+ * @features: The instance.
+ *
+ * Gets the EXCEPTS parameter from @features.
+ *
+ * Returns: EXCEPTS or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+gchar ibis_features_get_excepts(IbisFeatures *features);
+
+/**
+ * ibis_features_get_extban:
+ * @features: The instance.
+ *
+ * Gets the EXTBAN parameter from @features.
+ *
+ * Returns: EXTBAN or %NULL if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+const char *ibis_features_get_extban(IbisFeatures *features);
+
+/**
+ * ibis_features_get_hostlen:
+ * @features: The instance.
+ *
+ * Gets the HOSTLEN parameter from @features.
+ *
+ * Returns: HOSTLEN or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+guint64 ibis_features_get_hostlen(IbisFeatures *features);
+
+/**
+ * ibis_features_get_invex:
+ * @features: The instance.
+ *
+ * Gets the INVEX parameter from @features.
+ *
+ * Returns: INVEX or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+gchar ibis_features_get_invex(IbisFeatures *features);
+
+/**
+ * ibis_features_get_kicklen:
+ * @features: The instance.
+ *
+ * Gets the KICKLEN parameter from @features.
+ *
+ * Returns: KICKLEN or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+guint64 ibis_features_get_kicklen(IbisFeatures *features);
+
+/**
+ * ibis_features_get_maxlist:
+ * @features: The instance.
+ *
+ * Gets the MAXLIST parameter from @features.
+ *
+ * Returns: MAXLIST or %NULL if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+const char *ibis_features_get_maxlist(IbisFeatures *features);
+
+/**
+ * ibis_features_get_maxtargets:
+ * @features: The instance.
+ *
+ * Gets the MAXTARGETS parameter from @features.
+ *
+ * Returns: MAXTARGETS or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+guint64 ibis_features_get_maxtargets(IbisFeatures *features);
+
+/**
+ * ibis_features_get_modes:
+ * @features: The instance.
+ *
+ * Gets the MODES parameter from @features.
+ *
+ * Returns: MODES or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+guint64 ibis_features_get_modes(IbisFeatures *features);
+
+/**
+ * ibis_features_get_network:
+ * @features: The instance.
+ *
+ * Gets the NETWORK parameter from @features.
+ *
+ * Returns: NETWORK or %NULL if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+const char *ibis_features_get_network(IbisFeatures *features);
+
+/**
+ * ibis_features_get_nicklen:
+ * @features: The instance.
+ *
+ * Gets the NICKLEN parameter from @features.
+ *
+ * Returns: NICKLEN or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+guint64 ibis_features_get_nicklen(IbisFeatures *features);
+
+/**
+ * ibis_features_get_prefix:
+ * @features: The instance.
+ *
+ * Gets the PREFIX parameter from @features.
+ *
+ * Returns: PREFIX or the default value of '(ov)@+' if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+const char *ibis_features_get_prefix(IbisFeatures *features);
+
+/**
+ * ibis_features_get_safelist:
+ * @features: The instance.
+ *
+ * Gets the SAFELIST parameter from @features.
+ *
+ * Returns: %TRUE if SAFELIST parameter is advertised, otherwise %FALSE.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+gboolean ibis_features_get_safelist(IbisFeatures *features);
+
+/**
+ * ibis_features_get_silence:
+ * @features: The instance.
+ *
+ * Gets the SILENCE parameter from @features.
+ *
+ * Returns: SILENCE or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+guint64 ibis_features_get_silence(IbisFeatures *features);
+
+/**
+ * ibis_features_get_statusmsg:
+ * @features: The instance.
+ *
+ * Gets the STATUSMSG parameter from @features.
+ *
+ * Returns: STATUSMSG or %NULL if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+const char *ibis_features_get_statusmsg(IbisFeatures *features);
+
+/**
+ * ibis_features_get_targmax:
+ * @features: The instance.
+ *
+ * Gets the TARGMAX parameter from @features.
+ *
+ * Returns: TARGMAX or %NULL if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+const char *ibis_features_get_targmax(IbisFeatures *features);
+
+/**
+ * ibis_features_get_topiclen:
+ * @features: The instance.
+ *
+ * Gets the TOPICLEN parameter from @features.
+ *
+ * Returns: TOPICLEN or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+guint64 ibis_features_get_topiclen(IbisFeatures *features);
+
+/**
+ * ibis_features_get_userlen:
+ * @features: The instance.
+ *
+ * Gets the USERLEN parameter from @features.
+ *
+ * Returns: USERLEN or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+guint64 ibis_features_get_userlen(IbisFeatures *features);
+
+G_END_DECLS
+
+#endif /* IBIS_FEATURES_H */
# HG changeset patch
# Node ID d001e3474807
# Parent  f579f80114bc
diff --git a/ibis/ibisfeatures.c b/ibis/ibisfeatures.c
new file mode 100644
--- /dev/null
+++ b/ibis/ibisfeatures.c
@@ -0,0 +1,735 @@
+/*
+ * Ibis - IRCv3 Library
+ * Copyright (C) 2022-2024 Ibis Developers <devel@pidgin.im>
+ *
+ * Ibis is the legal property of its developers, whose names are too numerous
+ * to list here.  Please refer to the COPYRIGHT file distributed with this
+ * source distribution.
+ *
+ * This library is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this library; if not, see <https://www.gnu.org/licenses/>.
+ */
+#include "ibisfeatures.h"
+#include "ibisconstants.h"
+
+enum {
+	PROP_0,
+	PROP_AWAYLEN,
+	PROP_CASEMAPPING,
+	PROP_CHANLIMIT,
+	PROP_CHANMODES,
+	PROP_CHANNELLEN,
+	PROP_CHANTYPES,
+	PROP_ELIST,
+	PROP_EXCEPTS,
+	PROP_EXTBAN,
+	PROP_HOSTLEN,
+	PROP_INVEX,
+	PROP_KICKLEN,
+	PROP_MAXLIST,
+	PROP_MAXTARGETS,
+	PROP_MODES,
+	PROP_NETWORK,
+	PROP_NICKLEN,
+	PROP_PREFIX,
+	PROP_SAFELIST,
+	PROP_SILENCE,
+	PROP_STATUSMSG,
+	PROP_TARGMAX,
+	PROP_TOPICLEN,
+	PROP_USERLEN,
+	N_PROPERTIES,
+};
+
+static GParamSpec *properties[N_PROPERTIES] = {NULL, };
+
+struct _IbisFeatures {
+	GObject parent;
+
+	guint64 awaylen;
+	char *casemapping;
+	char *chanlimit;
+	char *chanmodes;
+	guint64 channellen;
+	char *chantypes;
+	char *elist;
+	gchar excepts;
+	char *extban;
+	guint64 hostlen;
+	gchar invex;
+	guint64 kicklen;
+	char *maxlist;
+	guint64 maxtargets;
+	guint64 modes;
+	char *network;
+	guint64 nicklen;
+	char *prefix;
+	gboolean safelist;
+	guint64 silence;
+	char *statusmsg;
+	char *targmax;
+	guint64 topiclen;
+	guint64 userlen;
+};
+
+/******************************************************************************
+ * Helpers
+ *****************************************************************************/
+static void
+ibis_features_set_feature(IbisFeatures *features, const char *feature,
+                          const char *value)
+{
+	g_return_if_fail(IBIS_IS_FEATURES(features));
+
+	if(g_strcmp0(feature, "AWAYLEN") == 0) {
+		features->awaylen = g_ascii_strtoull(value, NULL, 10);
+	} else if(g_strcmp0(feature, "CASEMAPPING") == 0) {
+		g_set_str(&features->casemapping, value);
+	} else if(g_strcmp0(feature, "CHANLIMIT") == 0) {
+		g_set_str(&features->chanlimit, value);
+	} else if(g_strcmp0(feature, "CHANMODES") == 0) {
+		g_set_str(&features->chanmodes, value);
+	} else if(g_strcmp0(feature, "CHANNELLEN") == 0) {
+		features->channellen = g_ascii_strtoull(value, NULL, 10);
+	} else if(g_strcmp0(feature, "CHANTYPES") == 0) {
+		g_set_str(&features->chantypes, value);
+	} else if(g_strcmp0(feature, "ELIST") == 0) {
+		g_set_str(&features->elist, value);
+	} else if(g_strcmp0(feature, "EXCEPTS") == 0) {
+		if(strlen(value) == 1) {
+			features->excepts = value[0];
+		} else {
+			features->excepts = 'e';
+		}
+	} else if(g_strcmp0(feature, "EXTBAN") == 0) {
+		g_set_str(&features->extban, value);
+	} else if(g_strcmp0(feature, "HOSTLEN") == 0) {
+		features->hostlen = g_ascii_strtoull(value, NULL, 10);
+	} else if(g_strcmp0(feature, "INVEX") == 0) {
+		if(strlen(value) == 1) {
+			features->invex = value[0];
+		} else {
+			features->invex = 'I';
+		}
+	} else if(g_strcmp0(feature, "KICKLEN") == 0) {
+		features->kicklen = g_ascii_strtoull(value, NULL, 10);
+	} else if(g_strcmp0(feature, "MAXLIST") == 0) {
+		g_set_str(&features->maxlist, value);
+	} else if(g_strcmp0(feature, "MAXTARGETS") == 0) {
+		features->maxtargets = g_ascii_strtoull(value, NULL, 10);
+	} else if(g_strcmp0(feature, "MODES") == 0) {
+		if(strlen(value) > 0) {
+			features->modes = g_ascii_strtoull(value, NULL, 10);
+		} else {
+			features->modes = G_MAXUINT64;
+		}
+	} else if(g_strcmp0(feature, "NETWORK") == 0) {
+		g_set_str(&features->network, value);
+	} else if(g_strcmp0(feature, "NICKLEN") == 0) {
+		features->nicklen = g_ascii_strtoull(value, NULL, 10);
+	} else if(g_strcmp0(feature, "PREFIX") == 0) {
+		g_set_str(&features->prefix, value);
+	} else if(g_strcmp0(feature, "SAFELIST") == 0) {
+		features->safelist = TRUE;
+	} else if(g_strcmp0(feature, "SILENCE") == 0) {
+		features->silence = g_ascii_strtoull(value, NULL, 10);
+	} else if(g_strcmp0(feature, "STATUSMSG") == 0) {
+		g_set_str(&features->statusmsg, value);
+	} else if(g_strcmp0(feature, "TARGMAX") == 0) {
+		g_set_str(&features->targmax, value);
+	} else if(g_strcmp0(feature, "TOPICLEN") == 0) {
+		features->topiclen = g_ascii_strtoull(value, NULL, 10);
+	} else if(g_strcmp0(feature, "USERLEN") == 0) {
+		features->userlen = g_ascii_strtoull(value, NULL, 10);
+	}
+}
+
+/******************************************************************************
+ * GObject Implementation
+ *****************************************************************************/
+G_DEFINE_FINAL_TYPE(IbisFeatures, ibis_features, G_TYPE_OBJECT)
+
+static void
+ibis_features_finalize(GObject *obj) {
+	IbisFeatures *features = IBIS_FEATURES(obj);
+
+	g_clear_pointer(&features->casemapping, g_free);
+	g_clear_pointer(&features->chanlimit, g_free);
+	g_clear_pointer(&features->chanmodes, g_free);
+	g_clear_pointer(&features->chantypes, g_free);
+	g_clear_pointer(&features->elist, g_free);
+	g_clear_pointer(&features->extban, g_free);
+	g_clear_pointer(&features->maxlist, g_free);
+	g_clear_pointer(&features->network, g_free);
+	g_clear_pointer(&features->prefix, g_free);
+	g_clear_pointer(&features->statusmsg, g_free);
+	g_clear_pointer(&features->targmax, g_free);
+
+	G_OBJECT_CLASS(ibis_features_parent_class)->finalize(obj);
+}
+static void
+ibis_features_get_property(GObject *obj, guint param_id, GValue *value,
+                           GParamSpec *pspec)
+{
+	IbisFeatures *features = IBIS_FEATURES(obj);
+
+	switch(param_id) {
+	case PROP_AWAYLEN:
+		g_value_set_uint64(value, ibis_features_get_awaylen(features));
+		break;
+	case PROP_CASEMAPPING:
+		g_value_set_string(value, ibis_features_get_casemapping(features));
+		break;
+	case PROP_CHANLIMIT:
+		g_value_set_string(value, ibis_features_get_chanlimit(features));
+		break;
+	case PROP_CHANMODES:
+		g_value_set_string(value, ibis_features_get_chanmodes(features));
+		break;
+	case PROP_CHANNELLEN:
+		g_value_set_uint64(value, ibis_features_get_channellen(features));
+		break;
+	case PROP_CHANTYPES:
+		g_value_set_string(value, ibis_features_get_chantypes(features));
+		break;
+	case PROP_ELIST:
+		g_value_set_string(value, ibis_features_get_elist(features));
+		break;
+	case PROP_EXCEPTS:
+		g_value_set_schar(value, ibis_features_get_excepts(features));
+		break;
+	case PROP_EXTBAN:
+		g_value_set_string(value, ibis_features_get_extban(features));
+		break;
+	case PROP_HOSTLEN:
+		g_value_set_uint64(value, ibis_features_get_hostlen(features));
+		break;
+	case PROP_INVEX:
+		g_value_set_schar(value, ibis_features_get_invex(features));
+		break;
+	case PROP_KICKLEN:
+		g_value_set_uint64(value, ibis_features_get_kicklen(features));
+		break;
+	case PROP_MAXLIST:
+		g_value_set_string(value, ibis_features_get_maxlist(features));
+		break;
+	case PROP_MAXTARGETS:
+		g_value_set_uint64(value, ibis_features_get_maxtargets(features));
+		break;
+	case PROP_MODES:
+		g_value_set_uint64(value, ibis_features_get_modes(features));
+		break;
+	case PROP_NETWORK:
+		g_value_set_string(value, ibis_features_get_network(features));
+		break;
+	case PROP_NICKLEN:
+		g_value_set_uint64(value, ibis_features_get_nicklen(features));
+		break;
+	case PROP_PREFIX:
+		g_value_set_string(value, ibis_features_get_prefix(features));
+		break;
+	case PROP_SAFELIST:
+		g_value_set_boolean(value, ibis_features_get_safelist(features));
+		break;
+	case PROP_SILENCE:
+		g_value_set_uint64(value, ibis_features_get_silence(features));
+		break;
+	case PROP_STATUSMSG:
+		g_value_set_string(value, ibis_features_get_statusmsg(features));
+		break;
+	case PROP_TARGMAX:
+		g_value_set_string(value, ibis_features_get_targmax(features));
+		break;
+	case PROP_TOPICLEN:
+		g_value_set_uint64(value, ibis_features_get_topiclen(features));
+		break;
+	case PROP_USERLEN:
+		g_value_set_uint64(value, ibis_features_get_userlen(features));
+		break;
+	default:
+		G_OBJECT_WARN_INVALID_PROPERTY_ID(obj, param_id, pspec);
+		break;
+	}
+}
+
+static void
+ibis_features_init(IbisFeatures *features) {
+	/* set the default values specified at
+	   https://modern.ircdocs.horse/#rplisupport-parameters */
+	g_set_str(&features->chantypes, "#");
+	g_set_str(&features->prefix, "(ov)@+");
+}
+
+static void
+ibis_features_class_init(IbisFeaturesClass *klass) {
+	GObjectClass *obj_class = G_OBJECT_CLASS(klass);
+
+	obj_class->get_property = ibis_features_get_property;
+	obj_class->finalize = ibis_features_finalize;
+
+	/**
+	 * IbisFeatures:AWAYLEN:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_AWAYLEN] = g_param_spec_uint64(
+		"awaylen", NULL, NULL,
+		0, G_MAXUINT64, 0,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:CASEMAPPING:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_CASEMAPPING] = g_param_spec_string(
+		"casemapping", NULL, NULL,
+		NULL,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:CHANLIMIT:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_CHANLIMIT] = g_param_spec_string(
+		"chanlimit", NULL, NULL,
+		NULL,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:CHANMODES:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_CHANMODES] = g_param_spec_string(
+		"chanmodes", NULL, NULL,
+		NULL,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:CHANNELLEN:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_CHANNELLEN] = g_param_spec_uint64(
+		"channellen", NULL, NULL,
+		0, G_MAXUINT64, 0,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:CHANTYPES:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_CHANTYPES] = g_param_spec_string(
+		"chantypes", NULL, NULL,
+		NULL,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:ELIST:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_ELIST] = g_param_spec_string(
+		"elist", NULL, NULL,
+		NULL,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:EXCEPTS:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_EXCEPTS] = g_param_spec_char(
+		"excepts", NULL, NULL,
+		0, G_MAXINT8, 0,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:EXTBAN:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_EXTBAN] = g_param_spec_string(
+		"extban", NULL, NULL,
+		NULL,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:HOSTLEN:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_HOSTLEN] = g_param_spec_uint64(
+		"hostlen", NULL, NULL,
+		0, G_MAXUINT64, 0,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:INVEX:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_INVEX] = g_param_spec_char(
+		"invex", NULL, NULL,
+		0, G_MAXINT8, 0,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:KICKLEN:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_KICKLEN] = g_param_spec_uint64(
+		"kicklen", NULL, NULL,
+		0, G_MAXUINT64, 0,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:MAXLIST:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_MAXLIST] = g_param_spec_string(
+		"maxlist", NULL, NULL,
+		NULL,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:MAXTARGETS:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_MAXTARGETS] = g_param_spec_uint64(
+		"maxtargets", NULL, NULL,
+		0, G_MAXUINT64, 0,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:MODES:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_MODES] = g_param_spec_uint64(
+		"modes", NULL, NULL,
+		0, G_MAXUINT64, 0,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:NETWORK:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_NETWORK] = g_param_spec_string(
+		"network", NULL, NULL,
+		NULL,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:NICKLEN:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_NICKLEN] = g_param_spec_uint64(
+		"nicklen", NULL, NULL,
+		0, G_MAXUINT64, 0,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:PREFIX:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_PREFIX] = g_param_spec_string(
+		"prefix", NULL, NULL,
+		NULL,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:SAFELIST:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_SAFELIST] = g_param_spec_boolean(
+		"safelist", NULL, NULL,
+		FALSE,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:SILENCE:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_SILENCE] = g_param_spec_uint64(
+		"silence", NULL, NULL,
+		0, G_MAXUINT64, 0,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:STATUSMSG:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_STATUSMSG] = g_param_spec_string(
+		"statusmsg", NULL, NULL,
+		NULL,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:TARGMAX:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_TARGMAX] = g_param_spec_string(
+		"targmax", NULL, NULL,
+		NULL,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:TOPICLEN:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_TOPICLEN] = g_param_spec_uint64(
+		"topiclen", NULL, NULL,
+		0, G_MAXUINT64, 0,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:USERLEN:
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_USERLEN] = g_param_spec_uint64(
+		"userlen", NULL, NULL,
+		0, G_MAXUINT64, 0,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	g_object_class_install_properties(obj_class, N_PROPERTIES, properties);
+}
+
+
+/******************************************************************************
+ * Public API
+ *****************************************************************************/
+IbisFeatures *
+ibis_features_new(void) {
+	return g_object_new(IBIS_TYPE_FEATURES, NULL);
+}
+
+gboolean
+ibis_features_parse(IbisFeatures *features, IbisMessage *message) {
+	GStrv params = NULL;
+	guint param_count = 0;
+	guint i;
+	GRegex *regex = NULL;
+	GMatchInfo *info = NULL;
+	gboolean matches = FALSE;
+	const char *command = NULL;
+
+	command = ibis_message_get_command(message);
+	if(g_strcmp0(command, IBIS_RPL_ISUPPORT) != 0) {
+		return FALSE;
+	}
+
+	params = ibis_message_get_params(message);
+	param_count = g_strv_length(params);
+	regex = g_regex_new("(?<key>[A-Z]+)(?:=(?<value>\\S+))?", 0, 0, NULL);
+	g_assert(regex != NULL);
+	for(i = 0; i < param_count; ++i) {
+		matches = g_regex_match_full(regex, params[i], -1, 0, 0, &info, NULL);
+		if (matches && g_match_info_matches(info)) {
+			char *key = NULL;
+			char *value = NULL;
+
+			key = g_match_info_fetch_named(info, "key");
+			value = g_match_info_fetch_named(info, "value");
+			ibis_features_set_feature(features, key, value);
+			g_free(key);
+			g_free(value);
+		}
+		g_match_info_unref(info);
+	}
+	g_regex_unref(regex);
+	return TRUE;
+}
+
+guint64
+ibis_features_get_awaylen(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return features->awaylen;
+}
+
+const char *
+ibis_features_get_casemapping(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), NULL);
+
+	return features->casemapping;
+}
+
+const char *
+ibis_features_get_chanlimit(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), NULL);
+
+	return features->chanlimit;
+}
+
+const char *
+ibis_features_get_chanmodes(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), NULL);
+
+	return features->chanmodes;
+}
+
+const char *
+ibis_features_get_chantypes(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), NULL);
+
+	return features->chantypes;
+}
+
+guint64
+ibis_features_get_channellen(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return features->channellen;
+}
+
+const char *
+ibis_features_get_elist(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), NULL);
+
+	return features->elist;
+}
+
+gchar
+ibis_features_get_excepts(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return features->excepts;
+}
+
+const char *
+ibis_features_get_extban(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), NULL);
+
+	return features->extban;
+}
+
+guint64
+ibis_features_get_hostlen(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return features->hostlen;
+}
+
+gchar
+ibis_features_get_invex(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return features->invex;
+}
+
+guint64
+ibis_features_get_kicklen(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return features->kicklen;
+}
+
+const char *
+ibis_features_get_maxlist(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), NULL);
+
+	return features->maxlist;
+}
+
+guint64
+ibis_features_get_maxtargets(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return features->maxtargets;
+}
+
+guint64
+ibis_features_get_modes(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return features->modes;
+}
+
+const char *
+ibis_features_get_network(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), NULL);
+
+	return features->network;
+}
+
+guint64
+ibis_features_get_nicklen(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return features->nicklen;
+}
+
+const char *
+ibis_features_get_prefix(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), NULL);
+
+	return features->prefix;
+}
+
+gboolean
+ibis_features_get_safelist(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), FALSE);
+
+	return features->safelist;
+}
+
+guint64
+ibis_features_get_silence(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return features->silence;
+}
+
+const char *
+ibis_features_get_statusmsg(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), NULL);
+
+	return features->statusmsg;
+}
+
+const char *
+ibis_features_get_targmax(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), NULL);
+
+	return features->targmax;
+}
+
+guint64
+ibis_features_get_topiclen(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return features->topiclen;
+}
+
+guint64
+ibis_features_get_userlen(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return features->userlen;
+}
diff --git a/ibis/ibisversion.h b/ibis/ibisversion.h
--- a/ibis/ibisversion.h
+++ b/ibis/ibisversion.h
@@ -173,6 +173,30 @@
 #define IBIS_AVAILABLE_TYPE_IN_0_2
 #endif
 
+/**
+ * IBIS_VERSION_0_3:
+ *
+ * A macro that evaluates to the 0.3 version of ibis, in a format that can be
+ * used by the C pre-processor.
+ *
+ * Since: 0.3
+ */
+#define IBIS_VERSION_0_3 (G_ENCODE_VERSION(0, 3))
+
+#if IBIS_VERSION_MAX_ALLOWED < IBIS_VERSION_0_1
+#define IBIS_AVAILABLE_IN_0_3 IBIS_UNAVAILABLE(0, 3)
+#define IBIS_AVAILABLE_STATIC_INLINE_IN_0_3 IBIS_UNAVAILABLE_STATIC_INLINE(0, 3)
+#define IBIS_AVAILABLE_MACRO_IN_0_3 IBIS_UNAVAILABLE_MACRO(0, 3)
+#define IBIS_AVAILABLE_ENUMERATOR_IN_0_3 IBIS_UNAVAILABLE_ENUMERATOR(0, 3)
+#define IBIS_AVAILABLE_TYPE_IN_0_3 IBIS_UNAVAILABLE_TYPE(0, 3)
+#else
+#define IBIS_AVAILABLE_IN_0_3 _IBIS_EXTERN
+#define IBIS_AVAILABLE_STATIC_INLINE_IN_0_3
+#define IBIS_AVAILABLE_MACRO_IN_0_3
+#define IBIS_AVAILABLE_ENUMERATOR_IN_0_3
+#define IBIS_AVAILABLE_TYPE_IN_0_3
+#endif
+
 G_BEGIN_DECLS
 
 /**
diff --git a/ibis/meson.build b/ibis/meson.build
--- a/ibis/meson.build
+++ b/ibis/meson.build
@@ -1,6 +1,7 @@
 IBIS_SOURCES = [
   'ibiscapabilities.c',
   'ibisclient.c',
+  'ibisfeatures.c',
   'ibisformatting.c',
   'ibismessage.c',
   'ibissasl.c',
@@ -14,6 +15,7 @@
   'ibisclient.h',
   'ibisconstants.h',
   'ibiscore.h',
+  'ibisfeatures.h',
   'ibisformatting.h',
   'ibismessage.h',
   'ibissasl.h',
diff --git a/ibis/tests/meson.build b/ibis/tests/meson.build
--- a/ibis/tests/meson.build
+++ b/ibis/tests/meson.build
@@ -1,5 +1,6 @@
 test_programs = [
   'client',
+  'features',
   'formatting',
   'message',
   'parser',
diff --git a/ibis/tests/test_features.c b/ibis/tests/test_features.c
new file mode 100644
--- /dev/null
+++ b/ibis/tests/test_features.c
@@ -0,0 +1,173 @@
+/*
+ * Ibis - IRCv3 Library
+ * Copyright (C) 2022-2024 Ibis Developers
+ *
+ * Ibis is the legal property of its developers, whose names are too numerous
+ * to list here.  Please refer to the COPYRIGHT file distributed with this
+ * source distribution.
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this library; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <glib.h>
+
+#include <ibis.h>
+
+static void
+test_ibis_features_new(void) {
+	IbisFeatures *features = NULL;
+
+	features = ibis_features_new();
+
+	g_assert_finalize_object(features);
+}
+
+static void
+test_ibis_features_parse_fail(void) {
+	IbisMessage *message = NULL;
+	IbisFeatures *features = NULL;
+
+	message = ibis_message_new(IBIS_MSG_PRIVMSG);
+	features = ibis_features_new();
+
+	g_assert_false(ibis_features_parse(features, message));
+
+	g_assert_finalize_object(message);
+	g_assert_finalize_object(features);
+}
+
+static void
+test_ibis_features_parse_all(void) {
+	IbisMessage *message = NULL;
+	IbisFeatures *features = NULL;
+	GStrvBuilder *builder = NULL;
+	GStrv msg_params = NULL;
+
+	builder = g_strv_builder_new();
+	g_strv_builder_add_many(builder,
+	                        "AWAYLEN=390",
+	                        "CASEMAPPING=rfc1459-strict",
+	                        "CHANLIMIT=#:100",
+	                        "CHANMODES=Ibe,k,fl,CEMRUimnstu",
+	                        "CHANNELLEN=64",
+	                        "CHANTYPES=#",
+	                        "ELIST=U",
+	                        "EXCEPTS",
+	                        "EXTBAN=,m",
+	                        "HOSTLEN=63",
+	                        "INVEX",
+	                        "KICKLEN=390",
+	                        "MAXLIST=beI:60",
+	                        "MAXTARGETS=4",
+	                        "MODES",
+	                        "NETWORK=ErgoTest",
+	                        "NICKLEN=32",
+	                        "PREFIX=(qaohv)~&@%+",
+	                        "SAFELIST",
+	                        "SILENCE=15",
+	                        "STATUSMSG=~&@%+",
+	                        "TARGMAX=NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,"
+	                        "PRIVMSG:4,TAGMSG:4,NOTICE:4,MONITOR:100",
+	                        "TOPICLEN=390",
+	                        "USERLEN=18",
+	                        NULL);
+	msg_params = g_strv_builder_end(builder);
+	g_strv_builder_unref(builder);
+
+	message = g_object_new(
+		IBIS_TYPE_MESSAGE,
+		"command", IBIS_RPL_ISUPPORT,
+		"params", msg_params,
+		NULL);
+	features = ibis_features_new();
+
+	g_assert_true(ibis_features_parse(features, message));
+
+	g_assert_cmpuint(ibis_features_get_awaylen(features), ==, 390);
+	g_assert_cmpstr(ibis_features_get_casemapping(features), ==,
+	                "rfc1459-strict");
+	g_assert_cmpstr(ibis_features_get_chanlimit(features), ==, "#:100");
+	g_assert_cmpstr(ibis_features_get_chanmodes(features), ==,
+	                "Ibe,k,fl,CEMRUimnstu");
+	g_assert_cmpstr(ibis_features_get_chantypes(features), ==, "#");
+	g_assert_cmpuint(ibis_features_get_channellen(features), ==, 64);
+	g_assert_cmpstr(ibis_features_get_elist(features), ==, "U");
+	g_assert_cmpint(ibis_features_get_excepts(features), ==, 'e');
+	g_assert_cmpstr(ibis_features_get_extban(features), ==, ",m");
+	g_assert_cmpuint(ibis_features_get_hostlen(features), ==, 63);
+	g_assert_cmpint(ibis_features_get_invex(features), ==, 'I');
+	g_assert_cmpuint(ibis_features_get_kicklen(features), ==, 390);
+	g_assert_cmpstr(ibis_features_get_maxlist(features), ==, "beI:60");
+	g_assert_cmpuint(ibis_features_get_maxtargets(features), ==, 4);
+	g_assert_cmpuint(ibis_features_get_modes(features), ==, G_MAXUINT64);
+	g_assert_cmpstr(ibis_features_get_network(features), ==, "ErgoTest");
+	g_assert_cmpuint(ibis_features_get_nicklen(features), ==, 32);
+	g_assert_cmpstr(ibis_features_get_prefix(features), ==, "(qaohv)~&@%+");
+	g_assert_true(ibis_features_get_safelist(features));
+	g_assert_cmpuint(ibis_features_get_silence(features), ==, 15);
+	g_assert_cmpstr(ibis_features_get_statusmsg(features), ==, "~&@%+");
+	g_assert_cmpstr(ibis_features_get_targmax(features), ==,
+	                "NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:4,TAGMSG:4,"
+	                "NOTICE:4,MONITOR:100");
+	g_assert_cmpuint(ibis_features_get_topiclen(features), ==, 390);
+	g_assert_cmpuint(ibis_features_get_userlen(features), ==, 18);
+
+	g_strfreev(msg_params);
+	g_assert_finalize_object(message);
+	g_assert_finalize_object(features);
+}
+
+static void
+test_ibis_features_no_parse(void) {
+	IbisFeatures *features = NULL;
+
+	features = ibis_features_new();
+	g_assert_cmpuint(ibis_features_get_awaylen(features), ==, 0);
+	g_assert_cmpstr(ibis_features_get_casemapping(features), ==, NULL);
+	g_assert_cmpstr(ibis_features_get_chanlimit(features), ==, NULL);
+	g_assert_cmpstr(ibis_features_get_chanmodes(features), ==, NULL);
+	g_assert_cmpuint(ibis_features_get_channellen(features), ==, 0);
+	g_assert_cmpstr(ibis_features_get_chantypes(features), ==, "#");
+	g_assert_cmpstr(ibis_features_get_elist(features), ==, NULL);
+	g_assert_cmpint(ibis_features_get_excepts(features), ==, 0);
+	g_assert_cmpstr(ibis_features_get_extban(features), ==, NULL);
+	g_assert_cmpuint(ibis_features_get_hostlen(features), ==, 0);
+	g_assert_cmpint(ibis_features_get_invex(features), ==, 0);
+	g_assert_cmpuint(ibis_features_get_kicklen(features), ==, 0);
+	g_assert_cmpstr(ibis_features_get_maxlist(features), ==, NULL);
+	g_assert_cmpuint(ibis_features_get_maxtargets(features), ==, 0);
+	g_assert_cmpuint(ibis_features_get_modes(features), ==, 0);
+	g_assert_cmpstr(ibis_features_get_network(features), ==, NULL);
+	g_assert_cmpuint(ibis_features_get_nicklen(features), ==, 0);
+	g_assert_cmpstr(ibis_features_get_prefix(features), ==, "(ov)@+");
+	g_assert_false(ibis_features_get_safelist(features));
+	g_assert_cmpuint(ibis_features_get_silence(features), ==, 0);
+	g_assert_cmpstr(ibis_features_get_statusmsg(features), ==, NULL);
+	g_assert_cmpstr(ibis_features_get_targmax(features), ==, NULL);
+	g_assert_cmpuint(ibis_features_get_topiclen(features), ==, 0);
+	g_assert_cmpuint(ibis_features_get_userlen(features), ==, 0);
+	g_assert_finalize_object(features);
+}
+
+int
+main(int argc, char *argv[]) {
+	g_test_init(&argc, &argv, NULL);
+
+	g_test_add_func("/ibis/features/new", test_ibis_features_new);
+	g_test_add_func("/ibis/features/parse_fail", test_ibis_features_parse_fail);
+	g_test_add_func("/ibis/features/parse_all", test_ibis_features_parse_all);
+	g_test_add_func("/ibis/features/no_parse", test_ibis_features_no_parse);
+
+	return g_test_run();
+}
