# HG changeset patch
# Node ID dd3b073260f1
# Parent  f579f80114bc
diff --git a/ibis/ibisconstants.h b/ibis/ibisconstants.h
--- a/ibis/ibisconstants.h
+++ b/ibis/ibisconstants.h
@@ -1038,6 +1038,318 @@
 #define IBIS_ERR_YOUREBANNEDCREEP "465" IBIS_AVAILABLE_MACRO_IN_0_1
 
 /**
+ * IBIS_FEATURE_AWAYLEN:
+ *
+ * A constant for the `AWAYLEN` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#awaylen-parameter](https://modern.ircdocs.horse/#awaylen-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_AWAYLEN "AWAYLEN" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
+ * IBIS_FEATURE_CASEMAPPING:
+ *
+ * A constant for the `CASEMAPPING` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#casemapping-parameter](https://modern.ircdocs.horse/#casemapping-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_CASEMAPPING "CASEMAPPING" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
+ * IBIS_FEATURE_CHANLIMIT:
+ *
+ * A constant for the `CHANLIMIT` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#chanlimit-parameter](https://modern.ircdocs.horse/#chanlimit-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_CHANLIMIT "CHANLIMIT" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
+ * IBIS_FEATURE_CHANMODES:
+ *
+ * A constant for the `CHANMODES` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#chanmodes-parameter](https://modern.ircdocs.horse/#chanmodes-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_CHANMODES "CHANMODES" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
+ * IBIS_FEATURE_CHANNELLEN:
+ *
+ * A constant for the `CHANNELLEN` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#channellen-parameter](https://modern.ircdocs.horse/#channellen-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_CHANNELLEN "CHANNELLEN" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
+ * IBIS_FEATURE_CHANTYPES:
+ *
+ * A constant for the `CHANTYPES` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#chantypes-parameter](https://modern.ircdocs.horse/#chantypes-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_CHANTYPES "CHANTYPES" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
+ * IBIS_FEATURE_ELIST:
+ *
+ * A constant for the `ELIST` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#elist-parameter](https://modern.ircdocs.horse/#elist-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_ELIST "ELIST" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
+ * IBIS_FEATURE_EXCEPTS:
+ *
+ * A constant for the `EXCEPTS` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#excepts-parameter](https://modern.ircdocs.horse/#excepts-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_EXCEPTS "EXCEPTS" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
+ * IBIS_FEATURE_EXTBAN:
+ *
+ * A constant for the `EXTBAN` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#extban-parameter](https://modern.ircdocs.horse/#extban-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_EXTBAN "EXTBAN" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
+ * IBIS_FEATURE_HOSTLEN:
+ *
+ * A constant for the `HOSTLEN` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#hostlen-parameter](https://modern.ircdocs.horse/#hostlen-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_HOSTLEN "HOSTLEN" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
+ * IBIS_FEATURE_INVEX:
+ *
+ * A constant for the `INVEX` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#invex-parameter](https://modern.ircdocs.horse/#invex-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_INVEX "INVEX" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
+ * IBIS_FEATURE_KICKLEN:
+ *
+ * A constant for the `KICKLEN` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#kicklen-parameter](https://modern.ircdocs.horse/#kicklen-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_KICKLEN "KICKLEN" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
+ * IBIS_FEATURE_MAXLIST:
+ *
+ * A constant for the `MAXLIST` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#maxlist-parameter](https://modern.ircdocs.horse/#maxlist-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_MAXLIST "MAXLIST" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
+ * IBIS_FEATURE_MAXTARGETS:
+ *
+ * A constant for the `MAXTARGETS` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#maxtargets-parameter](https://modern.ircdocs.horse/#maxtargets-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_MAXTARGETS "MAXTARGETS" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
+ * IBIS_FEATURE_MODES:
+ *
+ * A constant for the `MODES` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#modes-parameter](https://modern.ircdocs.horse/#modes-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_MODES "MODES" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
+ * IBIS_FEATURE_NETWORK:
+ *
+ * A constant for the `NETWORK` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#network-parameter](https://modern.ircdocs.horse/#network-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_NETWORK "NETWORK" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
+ * IBIS_FEATURE_NICKLEN:
+ *
+ * A constant for the `NICKLEN` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#nicklen-parameter](https://modern.ircdocs.horse/#nicklen-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_NICKLEN "NICKLEN" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
+ * IBIS_FEATURE_PREFIX:
+ *
+ * A constant for the `PREFIX` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#prefix-parameter](https://modern.ircdocs.horse/#prefix-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_PREFIX "PREFIX" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
+ * IBIS_FEATURE_SAFELIST:
+ *
+ * A constant for the `SAFELIST` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#safelist-parameter](https://modern.ircdocs.horse/#safelist-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_SAFELIST "SAFELIST" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
+ * IBIS_FEATURE_SILENCE:
+ *
+ * A constant for the `SILENCE` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#silence-parameter](https://modern.ircdocs.horse/#silence-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_SILENCE "SILENCE" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
+ * IBIS_FEATURE_STATUSMSG:
+ *
+ * A constant for the `STATUSMSG` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#statusmsg-parameter](https://modern.ircdocs.horse/#statusmsg-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_STATUSMSG "STATUSMSG" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
+ * IBIS_FEATURE_TARGMAX:
+ *
+ * A constant for the `TARGMAX` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#targmax-parameter](https://modern.ircdocs.horse/#targmax-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_TARGMAX "TARGMAX" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
+ * IBIS_FEATURE_TOPICLEN:
+ *
+ * A constant for the `TOPICLEN` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#topiclen-parameter](https://modern.ircdocs.horse/#topiclen-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_TOPICLEN "TOPICLEN" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
+ * IBIS_FEATURE_USERLEN:
+ *
+ * A constant for the `USERLEN` feature.
+ *
+ * See
+ * [modern.ircdocs.horse/#userlen-parameter](https://modern.ircdocs.horse/#userlen-parameter)
+ * for more information.
+ *
+ * Since: 0.3
+ */
+#define IBIS_FEATURE_USERLEN "USERLEN" IBIS_AVAILABLE_MACRO_IN_0_3
+
+/**
  * IBIS_MSG_ADMIN:
  *
  * A constant for the %ADMIN message.
diff --git a/ibis/ibisfeatures.h b/ibis/ibisfeatures.h
new file mode 100644
--- /dev/null
+++ b/ibis/ibisfeatures.h
@@ -0,0 +1,487 @@
+/*
+ * 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())
+
+/**
+ * IbisFeatures:
+ *
+ * An object that keeps track of the features a server supports.
+ *
+ */
+
+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:
+ * @features: The instance.
+ * @message: The message to parse.
+ *
+ * Parses an RPL_ISUPPORT message into a features object.
+ *
+ * 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.
+ * See [const@FEATURE_AWAYLEN].
+ *
+ * Returns: AWAYLEN or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+guint ibis_features_get_awaylen(IbisFeatures *features);
+
+/**
+ * ibis_features_get_casemapping:
+ * @features: The instance.
+ *
+ * Gets the CASEMAPPING parameter from @features.
+ * See [const@FEATURE_CASEMAPPING].
+ *
+ * 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.
+ * See [const@FEATURE_CHANLIMIT].
+ *
+ * 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.
+ * See [const@FEATURE_CHANMODES].
+ *
+ * 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.
+ * See [const@FEATURE_CHANTYPES].
+ *
+ * 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.
+ * See [const@FEATURE_CHANNELLEN].
+ *
+ * Returns: CHANNELLEN or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+guint ibis_features_get_channellen(IbisFeatures *features);
+
+/**
+ * ibis_features_get_elist:
+ * @features: The instance.
+ *
+ * Gets the ELIST parameter from @features.
+ * See [const@FEATURE_ELIST].
+ *
+ * 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.
+ * See [const@FEATURE_EXCEPTS].
+ *
+ * Returns: EXCEPTS or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+char ibis_features_get_excepts(IbisFeatures *features);
+
+/**
+ * ibis_features_get_extban:
+ * @features: The instance.
+ *
+ * Gets the EXTBAN parameter from @features.
+ * See [const@FEATURE_EXTBAN].
+ *
+ * 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.
+ * See [const@FEATURE_HOSTLEN].
+ *
+ * Returns: HOSTLEN or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+guint ibis_features_get_hostlen(IbisFeatures *features);
+
+/**
+ * ibis_features_get_invex:
+ * @features: The instance.
+ *
+ * Gets the INVEX parameter from @features.
+ * See [const@FEATURE_INVEX].
+ *
+ * Returns: INVEX or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+char ibis_features_get_invex(IbisFeatures *features);
+
+/**
+ * ibis_features_get_kicklen:
+ * @features: The instance.
+ *
+ * Gets the KICKLEN parameter from @features.
+ * See [const@FEATURE_KICKLEN].
+ *
+ * Returns: KICKLEN or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+guint ibis_features_get_kicklen(IbisFeatures *features);
+
+/**
+ * ibis_features_get_maxlist:
+ * @features: The instance.
+ *
+ * Gets the MAXLIST parameter from @features.
+ * See [const@FEATURE_MAXLIST].
+ *
+ * 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.
+ * See [const@FEATURE_MAXTARGETS].
+ *
+ * Returns: MAXTARGETS or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+guint ibis_features_get_maxtargets(IbisFeatures *features);
+
+/**
+ * ibis_features_get_modes:
+ * @features: The instance.
+ *
+ * Gets the MODES parameter from @features.
+ * See [const@FEATURE_MODES].
+ *
+ * Returns: MODES or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+guint ibis_features_get_modes(IbisFeatures *features);
+
+/**
+ * ibis_features_get_network:
+ * @features: The instance.
+ *
+ * Gets the NETWORK parameter from @features.
+ * See [const@FEATURE_NETWORK].
+ *
+ * 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.
+ * See [const@FEATURE_NICKLEN].
+ *
+ * Returns: NICKLEN or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+guint ibis_features_get_nicklen(IbisFeatures *features);
+
+/**
+ * ibis_features_get_prefix:
+ * @features: The instance.
+ *
+ * Gets the PREFIX parameter from @features.
+ * See [const@FEATURE_PREFIX].
+ *
+ * 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.
+ * See [const@FEATURE_SAFELIST].
+ *
+ * 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.
+ * See [const@FEATURE_SILENCE].
+ *
+ * Returns: SILENCE or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+guint ibis_features_get_silence(IbisFeatures *features);
+
+/**
+ * ibis_features_get_statusmsg:
+ * @features: The instance.
+ *
+ * Gets the STATUSMSG parameter from @features.
+ * See [const@FEATURE_STATUSMSG].
+ *
+ * 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.
+ * See [const@FEATURE_TARGMAX].
+ *
+ * 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.
+ * See [const@FEATURE_TOPICLEN].
+ *
+ * Returns: TOPICLEN or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+guint ibis_features_get_topiclen(IbisFeatures *features);
+
+/**
+ * ibis_features_get_userlen:
+ * @features: The instance.
+ *
+ * Gets the USERLEN parameter from @features.
+ * See [const@FEATURE_USERLEN].
+ *
+ * Returns: USERLEN or 0 if not set.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+guint ibis_features_get_userlen(IbisFeatures *features);
+
+/**
+ * ibis_features_get_uint:
+ * @features: The instance.
+ * @parameter: A parameter name.
+ *
+ * Gets the value of an unsigned integer parameter. Use this function to obtain
+ * the value of a parameter which is not covered by any of the properties of the
+ * [class@Features] class but was parsed from an [class@Message] using
+ * [method@Features.parse].
+ *
+ * Returns: The value of the parameter or 0 if it does not exist or is not
+ *          an unsigned integer parameter.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+guint ibis_features_get_uint(IbisFeatures *features, const char *parameter);
+
+/**
+ * ibis_features_get_string:
+ * @features: The instance.
+ * @parameter: A parameter name.
+ *
+ * Gets the value of a string parameter. Use this function to obtain
+ * the value of a parameter which is not covered by any of the properties of the
+ * [class@Features] class but was parsed from an [class@Message] using
+ * [method@Features.parse].
+ *
+ * Returns: (transfer none): The value of the parameter or %NULL if it does not
+ *          exist or is not an string parameter.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+const char *ibis_features_get_string(IbisFeatures *features, const char *parameter);
+
+/**
+ * ibis_features_get_bool:
+ * @features: The instance.
+ * @parameter: A parameter name.
+ *
+ * Gets the value of a boolean parameter. Use this function to obtain
+ * the value of a parameter which is not covered by any of the properties of the
+ * [class@Features] class but was parsed from an [class@Message] using
+ * [method@Features.parse].
+ *
+ * Returns: %TRUE if @parameter was set or %FALSE if it either does not exist or
+ *          is not a boolean parameter. A parameter is considered boolean if
+ *          it was advertised by the server without specifying a value.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+gboolean ibis_features_get_boolean(IbisFeatures *features, const char *parameter);
+
+/**
+ * ibis_features_get_char:
+ * @features: The instance.
+ * @parameter: A parameter name.
+ *
+ * Gets the value of a single character parameter. Use this function to obtain
+ * the value of a parameter which is not covered by any of the properties of the
+ * [class@Features] class but was parsed from an [class@Message] using
+ * [method@Features.parse].
+ *
+ * Returns: The value of the parameter or 0 if it does not
+ *          exist or is not a single character parameter.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_IN_0_3
+char ibis_features_get_char(IbisFeatures *features, const char *parameter);
+
+G_END_DECLS
+
+#endif /* IBIS_FEATURES_H */
diff --git a/ibis/ibisfeatures.c b/ibis/ibisfeatures.c
new file mode 100644
--- /dev/null
+++ b/ibis/ibisfeatures.c
@@ -0,0 +1,967 @@
+/*
+ * 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"
+#include "ibisstring.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;
+
+	GHashTable *features;
+};
+
+/******************************************************************************
+ * Helpers
+ *****************************************************************************/
+void
+ibis_features_free_gvalue(gpointer data) {
+	GValue* value = data;
+	g_value_unset(value);
+	g_free(value);
+}
+
+static void
+ibis_features_notify(IbisFeatures *features, const char *name) {
+	GString *property_name = NULL;
+	property_name = g_string_new(name);
+	property_name = g_string_ascii_down(property_name);
+
+	g_object_notify(G_OBJECT(features), property_name->str);
+
+	g_string_free(property_name, TRUE);
+}
+
+static void
+ibis_features_set_uint(IbisFeatures *features, const char *name,
+                       guint uvalue, gboolean notify)
+{
+	GValue *existing_value = NULL;
+	GValue *value = NULL;
+
+	existing_value = g_hash_table_lookup(features->features, name);
+	if(existing_value != NULL &&
+	   g_value_get_uint(existing_value) == uvalue) {
+		return;
+	}
+
+	value = g_new0(GValue, 1);
+	g_value_init(value, G_TYPE_UINT);
+	g_value_set_uint(value, uvalue);
+
+	g_hash_table_insert(features->features, g_strdup(name), value);
+
+	if(notify) {
+		ibis_features_notify(features, name);
+	}
+}
+
+static void
+ibis_features_set_string(IbisFeatures *features, const char *name,
+                         const char *svalue, gboolean notify)
+{
+	GValue *existing_value = NULL;
+	GValue *value = NULL;
+
+	existing_value = g_hash_table_lookup(features->features, name);
+	if(existing_value != NULL &&
+	   g_value_get_string(existing_value) == svalue) {
+		return;
+	}
+
+	value = g_new0(GValue, 1);
+	g_value_init(value, G_TYPE_STRING);
+	g_value_set_string(value, svalue);
+
+	g_hash_table_insert(features->features, g_strdup(name), value);
+
+	if(notify) {
+		ibis_features_notify(features, name);
+	}
+}
+
+static void
+ibis_features_set_char(IbisFeatures *features, const char *name,
+                       char cvalue, gboolean notify)
+{
+	GValue *existing_value = NULL;
+	GValue *value = NULL;
+
+	existing_value = g_hash_table_lookup(features->features, name);
+	if(existing_value != NULL &&
+	   g_value_get_schar(existing_value) == cvalue) {
+		return;
+	}
+
+	value = g_new0(GValue, 1);
+	g_value_init(value, G_TYPE_CHAR);
+	g_value_set_schar(value, cvalue);
+
+	g_hash_table_insert(features->features, g_strdup(name), value);
+
+	if(notify) {
+		ibis_features_notify(features, name);
+	}
+}
+
+static void
+ibis_features_set_boolean(IbisFeatures *features, const char *name,
+                          gboolean bvalue, gboolean notify)
+{
+	GValue *existing_value = NULL;
+	GValue *value = NULL;
+
+	existing_value = g_hash_table_lookup(features->features, name);
+	if(existing_value != NULL &&
+	   g_value_get_boolean(existing_value) == bvalue) {
+		return;
+	}
+
+	value = g_new0(GValue, 1);
+	g_value_init(value, G_TYPE_BOOLEAN);
+	g_value_set_boolean(value, bvalue);
+
+	g_hash_table_insert(features->features, g_strdup(name), value);
+
+	if(notify) {
+		ibis_features_notify(features, name);
+	}
+}
+
+static void
+ibis_features_set_feature(IbisFeatures *features, const char *feature,
+                          const char *value)
+{
+	g_return_if_fail(IBIS_IS_FEATURES(features));
+
+	if(ibis_str_equal(feature, IBIS_FEATURE_AWAYLEN)) {
+		ibis_features_set_uint(features, IBIS_FEATURE_AWAYLEN,
+		                       ibis_str_to_uint(value), TRUE);
+	} else if(ibis_str_equal(feature, IBIS_FEATURE_CASEMAPPING)) {
+		ibis_features_set_string(features, IBIS_FEATURE_CASEMAPPING, value, TRUE);
+	} else if(ibis_str_equal(feature, IBIS_FEATURE_CHANLIMIT)) {
+		ibis_features_set_string(features, IBIS_FEATURE_CHANLIMIT, value, TRUE);
+	} else if(ibis_str_equal(feature, IBIS_FEATURE_CHANMODES)) {
+		ibis_features_set_string(features, IBIS_FEATURE_CHANMODES, value, TRUE);
+	} else if(ibis_str_equal(feature, IBIS_FEATURE_CHANNELLEN)) {
+		ibis_features_set_uint(features, IBIS_FEATURE_CHANNELLEN,
+		                       ibis_str_to_uint(value), TRUE);
+	} else if(ibis_str_equal(feature, IBIS_FEATURE_CHANTYPES)) {
+		ibis_features_set_string(features, IBIS_FEATURE_CHANTYPES, value, TRUE);
+	} else if(ibis_str_equal(feature, IBIS_FEATURE_ELIST)) {
+		ibis_features_set_string(features, IBIS_FEATURE_ELIST, value, TRUE);
+	} else if(ibis_str_equal(feature, IBIS_FEATURE_EXCEPTS)) {
+		if(strlen(value) == 1) {
+			ibis_features_set_char(features, IBIS_FEATURE_EXCEPTS, value[0], TRUE);
+		} else {
+			ibis_features_set_char(features, IBIS_FEATURE_EXCEPTS, 'e', TRUE);
+		}
+	} else if(ibis_str_equal(feature, IBIS_FEATURE_EXTBAN)) {
+		ibis_features_set_string(features, IBIS_FEATURE_EXTBAN, value, TRUE);
+	} else if(ibis_str_equal(feature, IBIS_FEATURE_HOSTLEN)) {
+		ibis_features_set_uint(features, IBIS_FEATURE_HOSTLEN,
+		                       ibis_str_to_uint(value), TRUE);
+	} else if(ibis_str_equal(feature, IBIS_FEATURE_INVEX)) {
+		if(strlen(value) == 1) {
+			ibis_features_set_char(features, IBIS_FEATURE_INVEX, value[0], TRUE);
+		} else {
+			ibis_features_set_char(features, IBIS_FEATURE_INVEX, 'I', TRUE);
+		}
+		g_object_notify_by_pspec(G_OBJECT(features), properties[PROP_INVEX]);
+	} else if(ibis_str_equal(feature, IBIS_FEATURE_KICKLEN)) {
+		ibis_features_set_uint(features, IBIS_FEATURE_KICKLEN,
+		                       ibis_str_to_uint(value), TRUE);
+	} else if(ibis_str_equal(feature, IBIS_FEATURE_MAXLIST)) {
+		ibis_features_set_string(features, IBIS_FEATURE_MAXLIST, value, TRUE);
+	} else if(ibis_str_equal(feature, IBIS_FEATURE_MAXTARGETS)) {
+		ibis_features_set_uint(features, IBIS_FEATURE_MAXTARGETS,
+		                       ibis_str_to_uint(value), TRUE);
+	} else if(ibis_str_equal(feature, IBIS_FEATURE_MODES)) {
+		guint uvalue;
+		if(strlen(value) > 0) {
+			uvalue = ibis_str_to_uint(value);
+		} else {
+			uvalue = G_MAXUINT32;
+		}
+		ibis_features_set_uint(features, IBIS_FEATURE_MODES,
+		                       uvalue, TRUE);
+	} else if(ibis_str_equal(feature, IBIS_FEATURE_NETWORK)) {
+		ibis_features_set_string(features, IBIS_FEATURE_NETWORK, value, TRUE);
+	} else if(ibis_str_equal(feature, IBIS_FEATURE_NICKLEN)) {
+		ibis_features_set_uint(features, IBIS_FEATURE_NICKLEN,
+		                       ibis_str_to_uint(value), TRUE);
+	} else if(ibis_str_equal(feature, IBIS_FEATURE_PREFIX)) {
+		ibis_features_set_string(features, IBIS_FEATURE_PREFIX, value, TRUE);
+	} else if(ibis_str_equal(feature, IBIS_FEATURE_SAFELIST)) {
+		ibis_features_set_boolean(features, IBIS_FEATURE_SAFELIST, TRUE, TRUE);
+	} else if(ibis_str_equal(feature, IBIS_FEATURE_SILENCE)) {
+		ibis_features_set_uint(features, IBIS_FEATURE_SILENCE,
+		                       ibis_str_to_uint(value), TRUE);
+	} else if(ibis_str_equal(feature, IBIS_FEATURE_STATUSMSG)) {
+		ibis_features_set_string(features, IBIS_FEATURE_STATUSMSG, value, TRUE);
+	} else if(ibis_str_equal(feature, IBIS_FEATURE_TARGMAX)) {
+		ibis_features_set_string(features, IBIS_FEATURE_TARGMAX, value, TRUE);
+	} else if(ibis_str_equal(feature, IBIS_FEATURE_TOPICLEN)) {
+		ibis_features_set_uint(features, IBIS_FEATURE_TOPICLEN,
+		                       ibis_str_to_uint(value), TRUE);
+	} else if(ibis_str_equal(feature, IBIS_FEATURE_USERLEN)) {
+		ibis_features_set_uint(features, IBIS_FEATURE_USERLEN,
+		                       ibis_str_to_uint(value), TRUE);
+	} else {
+		GRegex *regex = NULL;
+		GValue *variant_value = g_new0(GValue, 1);
+
+		/* we're assuming we only get positive integers when receiving numeric
+		 * values
+		 */
+		regex = g_regex_new("^[0-9]+$", 0, 0, NULL);
+
+		if(g_regex_match(regex, value, 0, NULL)) {
+			g_value_init(variant_value, G_TYPE_UINT);
+			g_value_set_uint(variant_value, ibis_str_to_uint(value));
+		} else if(!ibis_str_is_empty(value)) {
+			g_value_init(variant_value, G_TYPE_STRING);
+			g_value_set_string(variant_value, value);
+		} else {
+			g_value_init(variant_value, G_TYPE_BOOLEAN);
+			g_value_set_boolean(variant_value, TRUE);
+		}
+		g_hash_table_insert(features->features, g_strdup(feature), variant_value);
+		g_regex_unref(regex);
+	}
+}
+
+/******************************************************************************
+ * 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_hash_table_destroy(features->features);
+
+	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_uint(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_uint(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_uint(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_uint(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_uint(value, ibis_features_get_maxtargets(features));
+		break;
+	case PROP_MODES:
+		g_value_set_uint(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_uint(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_uint(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_uint(value, ibis_features_get_topiclen(features));
+		break;
+	case PROP_USERLEN:
+		g_value_set_uint(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) {
+	features->features = g_hash_table_new_full(g_str_hash, g_str_equal,
+	                                                 g_free,
+	                                                 ibis_features_free_gvalue);
+	/* set the default values specified at
+	 * https://modern.ircdocs.horse/#rplisupport-parameters
+	 */
+	ibis_features_set_string(features, IBIS_FEATURE_CHANTYPES, "#", FALSE);
+	ibis_features_set_string(features, IBIS_FEATURE_PREFIX, "(ov)@+", FALSE);
+}
+
+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:
+	 *
+	 * The [const@FEATURE_AWAYLEN] parameter of the features object.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_AWAYLEN] = g_param_spec_uint(
+		"awaylen", NULL, NULL,
+		0, G_MAXUINT32, 0,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:casemapping:
+	 *
+	 * The [const@FEATURE_CASEMAPPING] parameter of the features object.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_CASEMAPPING] = g_param_spec_string(
+		"casemapping", NULL, NULL,
+		NULL,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:chanlimit:
+	 *
+	 * The [const@FEATURE_CHANLIMIT] parameter of the features object.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_CHANLIMIT] = g_param_spec_string(
+		"chanlimit", NULL, NULL,
+		NULL,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:chanmodes:
+	 *
+	 * The [const@FEATURE_CHANMODES] parameter of the features object.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_CHANMODES] = g_param_spec_string(
+		"chanmodes", NULL, NULL,
+		NULL,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:channellen:
+	 *
+	 * The [const@FEATURE_CHANNELLEN] parameter of the features object.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_CHANNELLEN] = g_param_spec_uint(
+		"channellen", NULL, NULL,
+		0, G_MAXUINT32, 0,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:chantypes:
+	 *
+	 * The [const@FEATURE_CHANTYPES] parameter of the features object.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_CHANTYPES] = g_param_spec_string(
+		"chantypes", NULL, NULL,
+		NULL,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:elist:
+	 *
+	 * The [const@FEATURE_ELIST] parameter of the features object.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_ELIST] = g_param_spec_string(
+		"elist", NULL, NULL,
+		NULL,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:excepts:
+	 *
+	 * The [const@FEATURE_EXCEPTS] parameter of the features object.
+	 *
+	 * 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:
+	 *
+	 * The [const@FEATURE_EXTBAN] parameter of the features object.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_EXTBAN] = g_param_spec_string(
+		"extban", NULL, NULL,
+		NULL,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:hostlen:
+	 *
+	 * The [const@FEATURE_HOSTLEN] parameter of the features object.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_HOSTLEN] = g_param_spec_uint(
+		"hostlen", NULL, NULL,
+		0, G_MAXUINT32, 0,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:invex:
+	 *
+	 * The [const@FEATURE_INVEX] parameter of the features object.
+	 *
+	 * 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:
+	 *
+	 * The [const@FEATURE_KICKLEN] parameter of the features object.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_KICKLEN] = g_param_spec_uint(
+		"kicklen", NULL, NULL,
+		0, G_MAXUINT32, 0,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:maxlist:
+	 *
+	 * The [const@FEATURE_MAXLIST] parameter of the features object.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_MAXLIST] = g_param_spec_string(
+		"maxlist", NULL, NULL,
+		NULL,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:maxtargets:
+	 *
+	 * The [const@FEATURE_MAXTARGETS] parameter of the features object.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_MAXTARGETS] = g_param_spec_uint(
+		"maxtargets", NULL, NULL,
+		0, G_MAXUINT32, 0,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:modes:
+	 *
+	 * The [const@FEATURE_MODES] parameter of the features object.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_MODES] = g_param_spec_uint(
+		"modes", NULL, NULL,
+		0, G_MAXUINT32, 0,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:network:
+	 *
+	 * The [const@FEATURE_NETWORK] parameter of the features object.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_NETWORK] = g_param_spec_string(
+		"network", NULL, NULL,
+		NULL,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:nicklen:
+	 *
+	 * The [const@FEATURE_NICKLEN] parameter of the features object.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_NICKLEN] = g_param_spec_uint(
+		"nicklen", NULL, NULL,
+		0, G_MAXUINT32, 0,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:prefix:
+	 *
+	 * The [const@FEATURE_PREFIX] parameter of the features object.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_PREFIX] = g_param_spec_string(
+		"prefix", NULL, NULL,
+		NULL,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:safelist:
+	 *
+	 * The [const@FEATURE_SAFELIST] parameter of the features object.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_SAFELIST] = g_param_spec_boolean(
+		"safelist", NULL, NULL,
+		FALSE,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:silence:
+	 *
+	 * The [const@FEATURE_SILENCE] parameter of the features object.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_SILENCE] = g_param_spec_uint(
+		"silence", NULL, NULL,
+		0, G_MAXUINT32, 0,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:statusmsg:
+	 *
+	 * The [const@FEATURE_STATUSMSG] parameter of the features object.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_STATUSMSG] = g_param_spec_string(
+		"statusmsg", NULL, NULL,
+		NULL,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:targmax:
+	 *
+	 * The [const@FEATURE_TARGMAX] parameter of the features object.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_TARGMAX] = g_param_spec_string(
+		"targmax", NULL, NULL,
+		NULL,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:topiclen:
+	 *
+	 * The [const@FEATURE_TOPICLEN] parameter of the features object.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_TOPICLEN] = g_param_spec_uint(
+		"topiclen", NULL, NULL,
+		0, G_MAXUINT32, 0,
+		G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisFeatures:userlen:
+	 *
+	 * The [const@FEATURE_USERLEN] parameter of the features object.
+	 *
+	 * Since: 0.3
+	 */
+	properties[PROP_USERLEN] = g_param_spec_uint(
+		"userlen", NULL, NULL,
+		0, G_MAXUINT32, 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;
+	}
+
+	/* freeze notification since ibis_features_set_feature will notify for changed
+	 * properties
+	 */
+	g_object_freeze_notify(G_OBJECT(features));
+
+	params = ibis_message_get_params(message);
+	param_count = g_strv_length(params);
+	regex = g_regex_new("(?<key>[A-Z0-9]+)(?:=(?<value>\\S+))?", 0, 0, 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);
+
+	g_object_thaw_notify(G_OBJECT(features));
+
+	return TRUE;
+}
+
+guint
+ibis_features_get_awaylen(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return ibis_features_get_uint(features, IBIS_FEATURE_AWAYLEN);
+}
+
+const char *
+ibis_features_get_casemapping(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), NULL);
+
+	return ibis_features_get_string(features, IBIS_FEATURE_CASEMAPPING);;
+}
+
+const char *
+ibis_features_get_chanlimit(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), NULL);
+
+	return ibis_features_get_string(features, IBIS_FEATURE_CHANLIMIT);
+}
+
+const char *
+ibis_features_get_chanmodes(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), NULL);
+
+	return ibis_features_get_string(features, IBIS_FEATURE_CHANMODES);
+}
+
+const char *
+ibis_features_get_chantypes(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), NULL);
+
+	return ibis_features_get_string(features, IBIS_FEATURE_CHANTYPES);
+}
+
+guint
+ibis_features_get_channellen(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return ibis_features_get_uint(features, IBIS_FEATURE_CHANNELLEN);
+}
+
+const char *
+ibis_features_get_elist(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), NULL);
+
+	return ibis_features_get_string(features, IBIS_FEATURE_ELIST);
+}
+
+char
+ibis_features_get_excepts(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return ibis_features_get_char(features, IBIS_FEATURE_EXCEPTS);
+}
+
+const char *
+ibis_features_get_extban(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), NULL);
+
+	return ibis_features_get_string(features, IBIS_FEATURE_EXTBAN);
+}
+
+guint
+ibis_features_get_hostlen(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return ibis_features_get_uint(features, IBIS_FEATURE_HOSTLEN);
+}
+
+char
+ibis_features_get_invex(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return ibis_features_get_char(features, IBIS_FEATURE_INVEX);
+}
+
+guint
+ibis_features_get_kicklen(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return ibis_features_get_uint(features, IBIS_FEATURE_KICKLEN);
+}
+
+const char *
+ibis_features_get_maxlist(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), NULL);
+
+	return ibis_features_get_string(features, IBIS_FEATURE_MAXLIST);
+}
+
+guint
+ibis_features_get_maxtargets(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return ibis_features_get_uint(features, IBIS_FEATURE_MAXTARGETS);
+}
+
+guint
+ibis_features_get_modes(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return ibis_features_get_uint(features, IBIS_FEATURE_MODES);
+}
+
+const char *
+ibis_features_get_network(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), NULL);
+
+	return ibis_features_get_string(features, IBIS_FEATURE_NETWORK);
+}
+
+guint
+ibis_features_get_nicklen(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return ibis_features_get_uint(features, IBIS_FEATURE_NICKLEN);
+}
+
+const char *
+ibis_features_get_prefix(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), NULL);
+
+	return ibis_features_get_string(features, IBIS_FEATURE_PREFIX);
+}
+
+gboolean
+ibis_features_get_safelist(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), FALSE);
+
+	return ibis_features_get_boolean(features, IBIS_FEATURE_SAFELIST);
+}
+
+guint
+ibis_features_get_silence(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return ibis_features_get_uint(features, IBIS_FEATURE_SILENCE);
+}
+
+const char *
+ibis_features_get_statusmsg(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), NULL);
+
+	return ibis_features_get_string(features, IBIS_FEATURE_STATUSMSG);
+}
+
+const char *
+ibis_features_get_targmax(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), NULL);
+
+	return ibis_features_get_string(features, IBIS_FEATURE_TARGMAX);
+}
+
+guint
+ibis_features_get_topiclen(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return ibis_features_get_uint(features, IBIS_FEATURE_TOPICLEN);
+}
+
+guint
+ibis_features_get_userlen(IbisFeatures *features) {
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	return ibis_features_get_uint(features, IBIS_FEATURE_USERLEN);
+}
+
+guint
+ibis_features_get_uint(IbisFeatures *features, const char *parameter) {
+	GValue *value = NULL;
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	value = g_hash_table_lookup(features->features, parameter);
+
+	if(value == NULL || !G_VALUE_HOLDS_UINT(value)) {
+		return 0;
+	}
+
+	return g_value_get_uint(value);
+}
+
+const char *
+ibis_features_get_string(IbisFeatures *features, const char *parameter) {
+	GValue *value = NULL;
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), NULL);
+
+	value = g_hash_table_lookup(features->features, parameter);
+
+	if(value == NULL || !G_VALUE_HOLDS_STRING(value)) {
+		return NULL;
+	}
+
+	return g_value_get_string(value);
+}
+
+gboolean
+ibis_features_get_boolean(IbisFeatures *features, const char *parameter) {
+	GValue *value = NULL;
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), FALSE);
+
+	value = g_hash_table_lookup(features->features, parameter);
+
+	if(value == NULL || !G_VALUE_HOLDS_BOOLEAN(value)) {
+		return FALSE;
+	}
+
+	return g_value_get_boolean(value);
+}
+
+char
+ibis_features_get_char(IbisFeatures *features, const char *parameter) {
+	GValue *value = NULL;
+	g_return_val_if_fail(IBIS_IS_FEATURES(features), 0);
+
+	value = g_hash_table_lookup(features->features, parameter);
+
+	if(value == NULL || !G_VALUE_HOLDS_CHAR(value)) {
+		return 0;
+	}
+
+	return g_value_get_schar(value);
+}
diff --git a/ibis/ibisstring.h b/ibis/ibisstring.h
--- a/ibis/ibisstring.h
+++ b/ibis/ibisstring.h
@@ -62,6 +62,27 @@
 	return (g_strcmp0(str1, str2) == 0);
 }
 
+/**
+ * ibis_str_to_uint:
+ * @str: (nullable): A string to convert to an unsigned integer.
+ *
+ * Converts @str to an unsigned integer.
+ *
+ * Returns: The converted value or 0 on error.
+ *
+ * Since: 0.3
+ */
+IBIS_AVAILABLE_STATIC_INLINE_IN_0_3
+static inline guint
+ibis_str_to_uint(const char *str) {
+	if(ibis_str_is_empty(str)) {
+		return 0;
+	}
+
+	int ivalue = atoi(str);
+	return (guint)ivalue;
+}
+
 G_END_DECLS
 
 #endif /* IBIS_STRING_H */
diff --git a/ibis/ibisversion.h b/ibis/ibisversion.h
--- a/ibis/ibisversion.h
+++ b/ibis/ibisversion.h
@@ -159,7 +159,7 @@
  */
 #define IBIS_VERSION_0_2 (G_ENCODE_VERSION(0, 2))
 
-#if IBIS_VERSION_MAX_ALLOWED < IBIS_VERSION_0_1
+#if IBIS_VERSION_MAX_ALLOWED < IBIS_VERSION_0_2
 #define IBIS_AVAILABLE_IN_0_2 IBIS_UNAVAILABLE(0, 2)
 #define IBIS_AVAILABLE_STATIC_INLINE_IN_0_2 IBIS_UNAVAILABLE_STATIC_INLINE(0, 2)
 #define IBIS_AVAILABLE_MACRO_IN_0_2 IBIS_UNAVAILABLE_MACRO(0, 2)
@@ -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_3
+#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,238 @@
+/*
+ * 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>
+
+/******************************************************************************
+ * Callbacks
+ *****************************************************************************/
+static void
+test_ibis_features_property_notify_counter(G_GNUC_UNUSED GObject *self,
+                                           G_GNUC_UNUSED GParamSpec *pspec,
+                                           gpointer data)
+{
+	guint *counter = data;
+	*counter = *counter + 1;
+}
+
+/******************************************************************************
+ * Tests
+ *****************************************************************************/
+static void
+test_ibis_features_new(void) {
+	IbisFeatures *features = NULL;
+
+	features = ibis_features_new();
+	g_assert_true(IBIS_IS_FEATURES(features));
+
+	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;
+	guint notify_counter = 0;
+
+	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 = ibis_message_new(IBIS_RPL_ISUPPORT);
+	ibis_message_set_paramsv(message, msg_params);
+
+	features = ibis_features_new();
+	g_signal_connect(features, "notify",
+	                 G_CALLBACK(test_ibis_features_property_notify_counter),
+	                 &notify_counter);
+	g_assert_cmpuint(notify_counter, ==, 0);
+
+	g_assert_true(ibis_features_parse(features, message));
+	g_assert_cmpuint(notify_counter, ==, g_strv_length(msg_params));
+
+	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_MAXUINT32);
+	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_parse_unknown(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,
+	                        "MONITOR=100",
+	                        "UTF8MAPPING=rfc8265",
+	                        "UTF8ONLY",
+	                        NULL);
+	msg_params = g_strv_builder_end(builder);
+	g_strv_builder_unref(builder);
+
+	message = ibis_message_new(IBIS_RPL_ISUPPORT);
+	ibis_message_set_paramsv(message, msg_params);
+
+	features = ibis_features_new();
+
+	g_assert_true(ibis_features_parse(features, message));
+	g_assert_cmpuint(ibis_features_get_uint(features, "MONITOR"), ==, 100);
+	g_assert_cmpstr(ibis_features_get_string(features, "UTF8MAPPING"), ==, "rfc8265");
+	g_assert_true(ibis_features_get_boolean(features, "UTF8ONLY"));
+
+	/* try using the wrong types */
+	g_assert_cmpstr(ibis_features_get_string(features, "MONITOR"), ==, NULL);
+	g_assert_false(ibis_features_get_boolean(features, "MONITOR"));
+	g_assert_cmpuint(ibis_features_get_uint(features, "UTF8MAPPING"), ==, 0);
+	g_assert_false(ibis_features_get_boolean(features, "UTF8MAPPING"));
+	g_assert_cmpuint(ibis_features_get_uint(features, "UTF8ONLY"), ==, 0);
+	g_assert_cmpstr(ibis_features_get_string(features, "UTF8ONLY"), ==, NULL);
+
+	/* try some really unknown parameters */
+	g_assert_cmpuint(ibis_features_get_uint(features, "MOONITOR"), ==, 0);
+	g_assert_cmpstr(ibis_features_get_string(features, "UTF8MAPING"), ==, NULL);
+	g_assert_false(ibis_features_get_boolean(features, "UTF9ONLY"));
+
+	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/parse_unknown", test_ibis_features_parse_unknown);
+	g_test_add_func("/ibis/features/no_parse", test_ibis_features_no_parse);
+
+	return g_test_run();
+}
diff --git a/meson.build b/meson.build
--- a/meson.build
+++ b/meson.build
@@ -2,7 +2,7 @@
   default_options : ['c_std=c17', 'warning_level=2'],
   license : 'LGPL-2.1-or-later',
   meson_version : '>=1.0.0',
-  version : '0.2.2-dev')
+  version : '0.3.0-dev')
 
 toplevel_inc = include_directories('.')
 
