diff --git a/ibis/ibisctcpmessage.h b/ibis/ibisctcpmessage.h
new file mode 100644
--- /dev/null
+++ b/ibis/ibisctcpmessage.h
@@ -0,0 +1,148 @@
+/*
+ * 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 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_CTCP_MESSAGE_H
+#define IBIS_CTCP_MESSAGE_H
+
+#include <glib.h>
+#include <glib-object.h>
+
+#include "ibisversion.h"
+
+G_BEGIN_DECLS
+
+#define IBIS_TYPE_CTCP_MESSAGE (ibis_ctcp_message_get_type())
+
+/**
+ * IbisCTCPMessage:
+ *
+ * An object that represents a `CTCP` message.
+ *
+ * See the
+ * [draft CTCP RFC](https://datatracker.ietf.org/doc/html/draft-oakley-irc-ctcp-02)
+ * for more information.
+ *
+ * Since: 0.5
+ */
+IBIS_AVAILABLE_IN_0_5
+G_DECLARE_FINAL_TYPE(IbisCTCPMessage, ibis_ctcp_message, IBIS, CTCP_MESSAGE,
+                     GObject)
+
+/**
+ * ibis_ctcp_message_get_command:
+ * @message: The instance.
+ *
+ * Gets the command from @message.
+ *
+ * Returns: The command.
+ *
+ * Since: 0.5
+ */
+IBIS_AVAILABLE_IN_0_5
+const char *ibis_ctcp_message_get_command(IbisCTCPMessage *message);
+
+/**
+ * ibis_ctcp_message_new:
+ * @command: The command.
+ *
+ * Creates a new `CTCP` message with @command as the command.
+ *
+ * Returns: (transfer full): The new message.
+ *
+ * Since: 0.5
+ */
+IBIS_AVAILABLE_IN_0_5
+IbisCTCPMessage *ibis_ctcp_message_new(const char *command);
+
+/**
+ * ibis_ctcp_message_get_params:
+ * @message: The instance.
+ *
+ * Gets the parameters from @message.
+ *
+ * Returns: (transfer none) (nullable): The parameters.
+ *
+ * Since: 0.5
+ */
+IBIS_AVAILABLE_IN_0_5
+GStrv ibis_ctcp_message_get_params(IbisCTCPMessage *message);
+
+/**
+ * ibis_ctcp_message_set_params:
+ * @message: The instance.
+ * @...: The parameters to add followed by %NULL.
+ *
+ * Sets the parameters of @message.
+ *
+ * Since: 0.5
+ */
+IBIS_AVAILABLE_IN_0_5
+void ibis_ctcp_message_set_params(IbisCTCPMessage *message, ...) G_GNUC_NULL_TERMINATED;
+
+/**
+ * ibis_ctcp_message_set_paramsv: (rename-to ibis_ctcp_message_set_params):
+ * @message: The instance.
+ * @params: (transfer none) (nullable): The new parameters.
+ *
+ * Sets the parameters of @message to @params.
+ *
+ * Since: 0.5
+ */
+IBIS_AVAILABLE_IN_0_5
+void ibis_ctcp_message_set_paramsv(IbisCTCPMessage *message, GStrv params);
+
+/**
+ * ibis_ctcp_message_parse:
+ * @str: The string to parse.
+ *
+ * Attempts to parse a `CTCP` message from @str.
+ *
+ * If @str is not a valid `CTCP` message, %NULL will be returned.
+ *
+ * Returns: (transfer full) (nullable): The `CTCP` message if successful,
+ *          otherwise %NULL.
+ *
+ * Since: 0.5
+ */
+IBIS_AVAILABLE_IN_0_5
+IbisCTCPMessage *ibis_ctcp_message_parse(const char *str);
+
+/**
+ * ibis_ctcp_message_serialize:
+ * @message: The instance.
+ *
+ * Serializes @message to a string.
+ *
+ * Returns: (transfer full): The serialized version of @message.
+ *
+ * Since: 0.5
+ */
+IBIS_AVAILABLE_IN_0_5
+char *ibis_ctcp_message_serialize(IbisCTCPMessage *message);
+
+G_END_DECLS
+
+#endif /* IBIS_CTCP_MESSAGE_H */
# HG changeset patch
# Node ID 76c7e7ae68e10e6016148bb82f9cb2dcdcd7ca81
# Parent  9c9273302015c0b5607a20d959be139ae3d891e5
diff --git a/ibis/ibisctcpmessage.c b/ibis/ibisctcpmessage.c
new file mode 100644
--- /dev/null
+++ b/ibis/ibisctcpmessage.c
@@ -0,0 +1,282 @@
+/*
+ * 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 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 "ibisctcpmessage.h"
+
+#include "ibisconstants.h"
+#include "ibisstring.h"
+
+struct _IbisCTCPMessage {
+	GObject parent;
+
+	char *command;
+	GStrv params;
+};
+
+enum {
+	PROP_0,
+	PROP_COMMAND,
+	PROP_PARAMS,
+	N_PROPERTIES,
+};
+static GParamSpec *properties[N_PROPERTIES] = {NULL, };
+
+/******************************************************************************
+ * Helpers
+ *****************************************************************************/
+static void
+ibis_ctcp_message_set_command(IbisCTCPMessage *message, const char *command) {
+	g_return_if_fail(IBIS_IS_CTCP_MESSAGE(message));
+
+	if(g_set_str(&message->command, command)) {
+		g_object_notify_by_pspec(G_OBJECT(message), properties[PROP_COMMAND]);
+	}
+}
+
+/******************************************************************************
+ * GObject Implementation
+ *****************************************************************************/
+G_DEFINE_FINAL_TYPE(IbisCTCPMessage, ibis_ctcp_message, G_TYPE_OBJECT)
+
+static void
+ibis_ctcp_message_finalize(GObject *obj) {
+	IbisCTCPMessage *message = IBIS_CTCP_MESSAGE(obj);
+
+	g_clear_pointer(&message->command, g_free);
+	g_clear_pointer(&message->params, g_strfreev);
+
+	G_OBJECT_CLASS(ibis_ctcp_message_parent_class)->finalize(obj);
+}
+
+static void
+ibis_ctcp_message_get_property(GObject *obj, guint param_id, GValue *value,
+                               GParamSpec *pspec)
+{
+	IbisCTCPMessage *message = IBIS_CTCP_MESSAGE(obj);
+
+	switch(param_id) {
+	case PROP_COMMAND:
+		g_value_set_string(value, ibis_ctcp_message_get_command(message));
+		break;
+	case PROP_PARAMS:
+		g_value_set_boxed(value, ibis_ctcp_message_get_params(message));
+		break;
+	default:
+		G_OBJECT_WARN_INVALID_PROPERTY_ID(obj, param_id, pspec);
+		break;
+	}
+}
+
+static void
+ibis_ctcp_message_set_property(GObject *obj, guint param_id,
+                               const GValue *value,
+                               GParamSpec *pspec)
+{
+	IbisCTCPMessage *message = IBIS_CTCP_MESSAGE(obj);
+
+	switch(param_id) {
+	case PROP_COMMAND:
+		ibis_ctcp_message_set_command(message, g_value_get_string(value));
+		break;
+	case PROP_PARAMS:
+		ibis_ctcp_message_set_paramsv(message, g_value_get_boxed(value));
+		break;
+	default:
+		G_OBJECT_WARN_INVALID_PROPERTY_ID(obj, param_id, pspec);
+		break;
+	}
+}
+
+static void
+ibis_ctcp_message_init(G_GNUC_UNUSED IbisCTCPMessage *message) {
+}
+
+static void
+ibis_ctcp_message_class_init(IbisCTCPMessageClass *klass) {
+	GObjectClass *obj_class = G_OBJECT_CLASS(klass);
+
+	obj_class->finalize = ibis_ctcp_message_finalize;
+	obj_class->get_property = ibis_ctcp_message_get_property;
+	obj_class->set_property = ibis_ctcp_message_set_property;
+
+	/**
+	 * IbisCTCPMessage:command:
+	 *
+	 * The command for this message.
+	 *
+	 * Since: 0.5
+	 */
+	properties[PROP_COMMAND] = g_param_spec_string(
+		"command", NULL, NULL,
+		NULL,
+		G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisCTCPMessage:params:
+	 *
+	 * The params for this message.
+	 *
+	 * Since: 0.5
+	 */
+	properties[PROP_PARAMS] = g_param_spec_boxed(
+		"params", NULL, NULL,
+		G_TYPE_STRV,
+		G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS);
+
+	g_object_class_install_properties(obj_class, N_PROPERTIES, properties);
+}
+
+/******************************************************************************
+ * Public API
+ *****************************************************************************/
+const char *
+ibis_ctcp_message_get_command(IbisCTCPMessage *message) {
+	g_return_val_if_fail(IBIS_IS_CTCP_MESSAGE(message), NULL);
+
+	return message->command;
+}
+
+GStrv
+ibis_ctcp_message_get_params(IbisCTCPMessage *message) {
+	g_return_val_if_fail(IBIS_IS_CTCP_MESSAGE(message), NULL);
+
+	return message->params;
+}
+
+void
+ibis_ctcp_message_set_params(IbisCTCPMessage *message, ...) {
+	GStrv params = NULL;
+	GStrvBuilder *builder = NULL;
+	va_list args;
+	const char *param = NULL;
+
+	g_return_if_fail(IBIS_IS_CTCP_MESSAGE(message));
+
+	builder = g_strv_builder_new();
+
+	va_start(args, message);
+	while((param = va_arg(args, const char *)) != NULL) {
+		g_strv_builder_add(builder, param);
+	}
+	va_end(args);
+
+	params = g_strv_builder_end(builder);
+	g_clear_pointer(&builder, g_strv_builder_unref);
+
+	ibis_ctcp_message_set_paramsv(message, params);
+	g_strfreev(params);
+}
+
+void
+ibis_ctcp_message_set_paramsv(IbisCTCPMessage *message, GStrv params) {
+	g_return_if_fail(IBIS_IS_CTCP_MESSAGE(message));
+
+	if(message->params != params) {
+		g_clear_pointer(&message->params, g_strfreev);
+
+		if(params != NULL) {
+			message->params = g_strdupv(params);
+		}
+
+		g_object_notify_by_pspec(G_OBJECT(message), properties[PROP_PARAMS]);
+	}
+}
+
+IbisCTCPMessage *
+ibis_ctcp_message_new(const char *command) {
+	g_return_val_if_fail(!ibis_str_is_empty(command), NULL);
+
+	return g_object_new(
+		IBIS_TYPE_CTCP_MESSAGE,
+		"command", command,
+		NULL);
+}
+
+IbisCTCPMessage *
+ibis_ctcp_message_parse(const char *str) {
+	IbisCTCPMessage *message = NULL;
+	GStrv params = NULL;
+	char *clean = NULL;
+	gsize length = 0;
+	guint n_params = 0;
+
+	if(ibis_str_is_empty(str)) {
+		return NULL;
+	}
+
+	if(str[0] != IBIS_CTCP_DELIMITER) {
+		return NULL;
+	}
+
+	length = strlen(str);
+	if(str[length - 1] == IBIS_CTCP_DELIMITER) {
+		/* If the last character is a delimiter, copy the body
+		 * skipping both leading and trailing delimiters.
+		 */
+		clean = g_strndup(str + 1, length - 2);
+	} else {
+		/* Skip the leading delimiter. */
+		clean = g_strdup(str + 1);
+	}
+
+	params = g_strsplit(clean, " ", 2);
+	g_free(clean);
+
+	n_params = g_strv_length(params);
+	if(n_params == 0) {
+		return NULL;
+	}
+
+	message = ibis_ctcp_message_new(params[0]);
+
+	if(n_params > 1 && !ibis_str_is_empty(params[1])) {
+		GStrv remaining = g_strsplit(params[1], " ", 0);
+		ibis_ctcp_message_set_paramsv(message, remaining);
+		g_strfreev(remaining);
+	}
+
+	g_strfreev(params);
+
+	return message;
+}
+
+char *
+ibis_ctcp_message_serialize(IbisCTCPMessage *message) {
+	GString *str = NULL;
+
+	g_return_val_if_fail(IBIS_IS_CTCP_MESSAGE(message), NULL);
+
+	str = g_string_new("");
+	g_string_append_printf(str, "%c%s", IBIS_CTCP_DELIMITER, message->command);
+
+	if(message->params != NULL) {
+		char *body = NULL;
+
+		body = g_strjoinv(" ", message->params);
+		g_string_append_printf(str, " %s", body);
+		g_free(body);
+	}
+
+	g_string_append_c(str, IBIS_CTCP_DELIMITER);
+
+	return g_string_free_and_steal(str);
+}
diff --git a/ibis/ibismessage.h b/ibis/ibismessage.h
--- a/ibis/ibismessage.h
+++ b/ibis/ibismessage.h
@@ -30,6 +30,7 @@
 #include <glib.h>
 #include <glib-object.h>
 
+#include "ibisctcpmessage.h"
 #include "ibistags.h"
 #include "ibisversion.h"
 
@@ -111,6 +112,32 @@
 gboolean ibis_message_get_ctcp(IbisMessage *message);
 
 /**
+ * ibis_message_get_ctcp_message:
+ * @message: The instance.
+ *
+ * Gets the [class@CTCPMessage] from @message.
+ *
+ * Returns: (transfer none) (nullable): The `CTCP` message if set, otherwise
+ *           %NULL.
+ *
+ * Since: 0.5
+ */
+IBIS_AVAILABLE_IN_0_5
+IbisCTCPMessage *ibis_message_get_ctcp_message(IbisMessage *message);
+
+/**
+ * ibis_message_set_ctcp_message:
+ * @message: The instance.
+ * @ctcp_message: (transfer none) (nullable): The `CTCP` message instance.
+ *
+ * Sets the `CTCP` message of @message to @ctcp_message.
+ *
+ * Since: 0.5
+ */
+IBIS_AVAILABLE_IN_0_5
+void ibis_message_set_ctcp_message(IbisMessage *message, IbisCTCPMessage *ctcp_message);
+
+/**
  * ibis_message_set_ctcp:
  * @message: The instance.
  * @ctcp: Whether or not to make this a CTCP message.
@@ -121,8 +148,10 @@
  * will have no effect.
  *
  * Since: 0.1
+ *
+ * Deprecated: 0.5
  */
-IBIS_AVAILABLE_IN_0_1
+IBIS_DEPRECATED_FOR(ibis_message_set_ctcp_message)
 void ibis_message_set_ctcp(IbisMessage *message, gboolean ctcp);
 
 /**
diff --git a/ibis/ibismessage.c b/ibis/ibismessage.c
--- a/ibis/ibismessage.c
+++ b/ibis/ibismessage.c
@@ -25,6 +25,7 @@
 #include "ibismessage.h"
 
 #include "ibisconstants.h"
+#include "ibisctcpmessage.h"
 #include "ibisstring.h"
 
 enum {
@@ -37,6 +38,7 @@
 	PROP_TAGS,
 	PROP_ERROR,
 	PROP_CTCP,
+	PROP_CTCP_MESSAGE,
 	N_PROPERTIES,
 };
 static GParamSpec *properties[N_PROPERTIES] = {NULL, };
@@ -53,7 +55,7 @@
 	char *source;
 	IbisTags *tags;
 	GError *error;
-	gboolean ctcp;
+	IbisCTCPMessage *ctcp_message;
 };
 
 /******************************************************************************
@@ -70,10 +72,6 @@
 ibis_message_serialize_params(IbisMessage *message, GString *str) {
 	gboolean first = TRUE;
 
-	if(message->ctcp) {
-		g_string_append_printf(str, ":%c", IBIS_CTCP_DELIMITER);
-	}
-
 	for(guint i = 0; message->params[i] != NULL; i++) {
 		if(!first) {
 			g_string_append(str, " ");
@@ -84,18 +82,13 @@
 			const char *param = message->params[i];
 			gboolean prefix = FALSE;
 
-			/* We only need to prepend a prefix if the message is not a CTCP
-			 * message as the prefix was already added previously.
+			/* We only need the prefix if the parameter is empty, starts
+			 * with a :, or contains a space.
 			 */
-			if(!message->ctcp) {
-				/* We only need the prefix if the parameter is empty, starts
-				 * with a :, or contains a space.
-				 */
-				if(param[0] == '\0' || param[0] == ':') {
-					prefix = TRUE;
-				} else if(g_strstr_len(param, -1, " ") != NULL) {
-					prefix = TRUE;
-				}
+			if(param[0] == '\0' || param[0] == ':') {
+				prefix = TRUE;
+			} else if(g_strstr_len(param, -1, " ") != NULL) {
+				prefix = TRUE;
 			}
 
 			if(prefix) {
@@ -111,10 +104,6 @@
 			first = FALSE;
 		}
 	}
-
-	if(message->ctcp) {
-		g_string_append_c(str, IBIS_CTCP_DELIMITER);
-	}
 }
 
 static GString *
@@ -141,6 +130,14 @@
 	if(message->params != NULL) {
 		ibis_message_serialize_add_delimiter(str);
 		ibis_message_serialize_params(message, str);
+	} else if(IBIS_IS_CTCP_MESSAGE(message->ctcp_message)) {
+		char *body = NULL;
+
+		ibis_message_serialize_add_delimiter(str);
+
+		body = ibis_ctcp_message_serialize(message->ctcp_message);
+		g_string_append_printf(str, ":%s", body);
+		g_free(body);
 	}
 
 	/* Finally add the trailing \r\n if the string isn't empty. */
@@ -266,6 +263,7 @@
 	g_clear_pointer(&message->source, g_free);
 	g_clear_object(&message->tags);
 	g_clear_error(&message->error);
+	g_clear_object(&message->ctcp_message);
 
 	G_OBJECT_CLASS(ibis_message_parent_class)->finalize(obj);
 }
@@ -301,6 +299,9 @@
 	case PROP_CTCP:
 		g_value_set_boolean(value, ibis_message_get_ctcp(message));
 		break;
+	case PROP_CTCP_MESSAGE:
+		g_value_set_object(value, ibis_message_get_ctcp_message(message));
+		break;
 	default:
 		G_OBJECT_WARN_INVALID_PROPERTY_ID(obj, param_id, pspec);
 		break;
@@ -333,7 +334,12 @@
 		ibis_message_set_error(message, g_value_get_boxed(value));
 		break;
 	case PROP_CTCP:
+		G_GNUC_BEGIN_IGNORE_DEPRECATIONS
 		ibis_message_set_ctcp(message, g_value_get_boolean(value));
+		G_GNUC_END_IGNORE_DEPRECATIONS
+		break;
+	case PROP_CTCP_MESSAGE:
+		ibis_message_set_ctcp_message(message, g_value_get_object(value));
 		break;
 	default:
 		G_OBJECT_WARN_INVALID_PROPERTY_ID(obj, param_id, pspec);
@@ -469,6 +475,18 @@
 		FALSE,
 		G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS);
 
+	/**
+	 * IbisMessage:ctcp-message:
+	 *
+	 * The `CTCP` message that makes up the body of this message.
+	 *
+	 * Since: 0.5
+	 */
+	properties[PROP_CTCP_MESSAGE] = g_param_spec_object(
+		"ctcp-message", NULL, NULL,
+		IBIS_TYPE_CTCP_MESSAGE,
+		G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS);
+
 	g_object_class_install_properties(obj_class, N_PROPERTIES, properties);
 }
 
@@ -739,35 +757,20 @@
 	   ibis_str_equal(command, IBIS_MSG_NOTICE))
 	{
 		if(n_params >= 1) {
+			IbisCTCPMessage *ctcp_message = NULL;
 			char *body = NULL;
 			guint last = n_params - 1;
 
 			body = params[last];
-			if(body[0] == IBIS_CTCP_DELIMITER) {
-				char *clean = NULL;
-				gsize length = 0;
-
-				length = strlen(body);
-				if(body[length - 1] == IBIS_CTCP_DELIMITER) {
-					/* If the last character is a delimiter, copy the body
-					 * skipping both leading and trailing delimiters.
-					 */
-					clean = g_strndup(body + 1, length - 2);
-				} else {
-					/* Skip the leading delimiter. */
-					clean = g_strdup(body + 1);
-				}
-
-				/* Replace the old value with the cleaned value. */
-				g_free(params[last]);
-				params[last] = clean;
-
-				ibis_message_set_ctcp(message, TRUE);
+			ctcp_message = ibis_ctcp_message_parse(body);
+			if(IBIS_IS_CTCP_MESSAGE(ctcp_message)) {
+				ibis_message_set_ctcp_message(message, ctcp_message);
 			}
+			g_clear_object(&ctcp_message);
 		}
 	}
 
-	if(params != NULL) {
+	if(params != NULL && message->ctcp_message == NULL) {
 		ibis_message_set_paramsv(message, params);
 	}
 
@@ -797,22 +800,49 @@
 ibis_message_get_ctcp(IbisMessage *message) {
 	g_return_val_if_fail(IBIS_IS_MESSAGE(message), FALSE);
 
-	return message->ctcp;
+	return IBIS_IS_CTCP_MESSAGE(message->ctcp_message);
 }
 
 void
 ibis_message_set_ctcp(IbisMessage *message, gboolean ctcp) {
 	g_return_if_fail(IBIS_IS_MESSAGE(message));
 
+	if(ctcp) {
+		IbisCTCPMessage *ctcp_message = NULL;
+
+		ctcp_message = ibis_ctcp_message_new("UNKNOWN");
+		ibis_message_set_ctcp_message(message, ctcp_message);
+		g_clear_object(&ctcp_message);
+	} else {
+		ibis_message_set_ctcp_message(message, NULL);
+	}
+}
+
+IbisCTCPMessage *
+ibis_message_get_ctcp_message(IbisMessage *message) {
+	g_return_val_if_fail(IBIS_IS_MESSAGE(message), NULL);
+
+	return message->ctcp_message;
+}
+
+void
+ibis_message_set_ctcp_message(IbisMessage *message,
+                              IbisCTCPMessage *ctcp_message)
+{
+	g_return_if_fail(IBIS_IS_MESSAGE(message));
+
 	if(!ibis_str_equal(message->command, IBIS_MSG_PRIVMSG) &&
 	   !ibis_str_equal(message->command, IBIS_MSG_NOTICE))
 	{
 		return;
 	}
 
-	if(message->ctcp != ctcp) {
-		message->ctcp = ctcp;
+	if(g_set_object(&message->ctcp_message, ctcp_message)) {
+		GObject *obj = G_OBJECT(message);
 
-		g_object_notify_by_pspec(G_OBJECT(message), properties[PROP_CTCP]);
+		g_object_freeze_notify(obj);
+		g_object_notify_by_pspec(obj, properties[PROP_CTCP]);
+		g_object_notify_by_pspec(obj, properties[PROP_CTCP_MESSAGE]);
+		g_object_thaw_notify(obj);
 	}
 }
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',
+  'ibisctcpmessage.c',
   'ibisfeatures.c',
   'ibisformatting.c',
   'ibismessage.c',
@@ -15,6 +16,7 @@
   'ibisclient.h',
   'ibisconstants.h',
   'ibiscore.h',
+  'ibisctcpmessage.h',
   'ibisfeatures.h',
   'ibisformatting.h',
   'ibismessage.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',
+  'ctcp_message',
   'features',
   'formatting',
   'message',
diff --git a/ibis/tests/test_ctcp_message.c b/ibis/tests/test_ctcp_message.c
new file mode 100644
--- /dev/null
+++ b/ibis/tests/test_ctcp_message.c
@@ -0,0 +1,293 @@
+/*
+ * 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>
+
+/******************************************************************************
+ * Basic Tests
+ *****************************************************************************/
+static void
+test_ibis_ctcp_message_new_null(void) {
+	if(g_test_subprocess()) {
+		IbisCTCPMessage *message = NULL;
+
+		message = ibis_ctcp_message_new(NULL);
+
+		g_assert_finalize_object(message);
+
+		g_assert_not_reached();
+	}
+
+	g_test_trap_subprocess(NULL, 0, 0);
+	g_test_trap_assert_stderr("*Ibis-CRITICAL*ibis_str_is_empty*failed*");
+}
+
+static void
+test_ibis_ctcp_message_new_normal(void) {
+	IbisCTCPMessage *message = NULL;
+
+	message = ibis_ctcp_message_new(IBIS_CTCP_VERSION);
+	g_assert_true(IBIS_IS_CTCP_MESSAGE(message));
+
+	g_assert_finalize_object(message);
+}
+
+static void
+test_ibis_ctcp_message_properties(void) {
+	IbisCTCPMessage *message = NULL;
+	GStrv params1 = NULL;
+	char *command = NULL;
+	const char *params[] = {"Pidgin", "3.0", NULL};
+
+	message = g_object_new(
+		IBIS_TYPE_CTCP_MESSAGE,
+		"command", IBIS_CTCP_VERSION,
+		"params", params,
+		NULL);
+
+	g_assert_true(IBIS_IS_CTCP_MESSAGE(message));
+
+	g_object_get(
+		G_OBJECT(message),
+		"command", &command,
+		"params", &params1,
+		NULL);
+
+	g_assert_cmpstr(command, ==, IBIS_CTCP_VERSION);
+	g_clear_pointer(&command, g_free);
+
+	g_assert_cmpstrv(params1, params);
+	g_strfreev(params1);
+
+	g_assert_finalize_object(message);
+}
+
+/******************************************************************************
+ * Parse Tests
+ *****************************************************************************/
+static void
+test_ibis_ctcp_message_parse_null(void) {
+	IbisCTCPMessage *message = NULL;
+
+	message = ibis_ctcp_message_parse(NULL);
+	g_assert_null(message);
+}
+
+static void
+test_ibis_ctcp_message_parse_not_ctcp(void) {
+	IbisCTCPMessage *message = NULL;
+
+	message = ibis_ctcp_message_parse("hiya");
+	g_assert_null(message);
+}
+
+static void
+test_ibis_ctcp_message_parse_leading_delimiter_only(void) {
+	IbisCTCPMessage *message = NULL;
+	const char *command = NULL;
+
+	message = ibis_ctcp_message_parse("\001VERSION");
+	g_assert_true(IBIS_IS_CTCP_MESSAGE(message));
+
+	command = ibis_ctcp_message_get_command(message);
+	g_assert_cmpstr(command, ==, "VERSION");
+
+	g_assert_finalize_object(message);
+}
+
+static void
+test_ibis_ctcp_message_parse_trailing_delimiter_only(void) {
+	IbisCTCPMessage *message = NULL;
+
+	message = ibis_ctcp_message_parse("VERSION\001");
+	g_assert_null(message);
+}
+
+static void
+test_ibis_ctcp_message_parse_command_only(void) {
+	IbisCTCPMessage *message = NULL;
+	const char *command = NULL;
+
+	message = ibis_ctcp_message_parse("\001VERSION\001");
+	g_assert_true(IBIS_IS_CTCP_MESSAGE(message));
+
+	command = ibis_ctcp_message_get_command(message);
+	g_assert_cmpstr(command, ==, "VERSION");
+
+	g_assert_finalize_object(message);
+}
+
+static void
+test_ibis_ctcp_message_parse_empty_params(void) {
+	IbisCTCPMessage *message = NULL;
+	GStrv params = NULL;
+	const char *command = NULL;
+
+	message = ibis_ctcp_message_parse("\001VERSION \001");
+	g_assert_true(IBIS_IS_CTCP_MESSAGE(message));
+
+	command = ibis_ctcp_message_get_command(message);
+	g_assert_cmpstr(command, ==, "VERSION");
+
+	params = ibis_ctcp_message_get_params(message);
+	g_assert_null(params);
+
+	g_assert_finalize_object(message);
+}
+
+static void
+test_ibis_ctcp_message_parse_single_param(void) {
+	IbisCTCPMessage *message = NULL;
+	GStrv params = NULL;
+	const char *expected[] = {"Foo", NULL};
+	const char *command = NULL;
+
+	message = ibis_ctcp_message_parse("\001PING Foo\001");
+	g_assert_true(IBIS_IS_CTCP_MESSAGE(message));
+
+	command = ibis_ctcp_message_get_command(message);
+	g_assert_cmpstr(command, ==, "PING");
+
+	params = ibis_ctcp_message_get_params(message);
+	g_assert_cmpstrv(params, expected);
+
+	g_assert_finalize_object(message);
+}
+
+static void
+test_ibis_ctcp_message_parse_multiple_params(void) {
+	IbisCTCPMessage *message = NULL;
+	GStrv params = NULL;
+	const char *expected[] = {"Pidgin", "3.0", "Final",
+	                          "2038-01-19T03:14:07Z", NULL};
+	const char *command = NULL;
+
+	message = ibis_ctcp_message_parse("\001VERSION "
+	                                  "Pidgin 3.0 Final 2038-01-19T03:14:07Z"
+	                                  "\001");
+	g_assert_true(IBIS_IS_CTCP_MESSAGE(message));
+
+	command = ibis_ctcp_message_get_command(message);
+	g_assert_cmpstr(command, ==, "VERSION");
+
+	params = ibis_ctcp_message_get_params(message);
+	g_assert_cmpstrv(params, expected);
+
+	g_assert_finalize_object(message);
+}
+
+/******************************************************************************
+ * Serialize Tests
+ *****************************************************************************/
+static void
+test_ibis_ctcp_message_serialize_command_only(void) {
+	IbisCTCPMessage *message = NULL;
+	char *serialized = NULL;
+	const char *expected = "\001" IBIS_CTCP_VERSION "\001";
+
+	message = ibis_ctcp_message_new(IBIS_CTCP_VERSION);
+
+	serialized = ibis_ctcp_message_serialize(message);
+	g_assert_cmpstr(serialized, ==, expected);
+	g_clear_pointer(&serialized, g_free);
+
+	g_assert_finalize_object(message);
+}
+
+static void
+test_ibis_ctcp_message_serialize_single_param(void) {
+	IbisCTCPMessage *message = NULL;
+	char *serialized = NULL;
+	const char *expected = "\001" IBIS_CTCP_VERSION " Pidgin\001";
+	const char *params[] = {"Pidgin", NULL};
+
+	message = ibis_ctcp_message_new(IBIS_CTCP_VERSION);
+	ibis_ctcp_message_set_paramsv(message, (GStrv)params);
+
+	serialized = ibis_ctcp_message_serialize(message);
+	g_assert_cmpstr(serialized, ==, expected);
+	g_clear_pointer(&serialized, g_free);
+
+	g_assert_finalize_object(message);
+}
+
+static void
+test_ibis_ctcp_message_serialize_multiple_params(void) {
+	IbisCTCPMessage *message = NULL;
+	char *serialized = NULL;
+	const char *expected = "\001" IBIS_CTCP_VERSION " Pidgin 3.0 Final\001";
+	const char *params[] = {"Pidgin", "3.0", "Final", NULL};
+
+	message = ibis_ctcp_message_new(IBIS_CTCP_VERSION);
+	ibis_ctcp_message_set_paramsv(message, (GStrv)params);
+
+	serialized = ibis_ctcp_message_serialize(message);
+	g_assert_cmpstr(serialized, ==, expected);
+	g_clear_pointer(&serialized, g_free);
+
+	g_assert_finalize_object(message);
+}
+
+/******************************************************************************
+ * Main
+ *****************************************************************************/
+int
+main(int argc, char *argv[]) {
+	g_test_init(&argc, &argv, NULL);
+	g_test_set_nonfatal_assertions();
+
+	g_test_add_func("/ibis/ctcp-message/new/null",
+	                test_ibis_ctcp_message_new_null);
+	g_test_add_func("/ibis/ctcp-message/new/normal",
+	                test_ibis_ctcp_message_new_normal);
+
+	g_test_add_func("/ibis/ctcp-message/properties",
+	                test_ibis_ctcp_message_properties);
+
+	g_test_add_func("/ibis/ctcp-message/parse/null",
+	                test_ibis_ctcp_message_parse_null);
+	g_test_add_func("/ibis/ctcp-message/parse/not-ctcp",
+	                test_ibis_ctcp_message_parse_not_ctcp);
+	g_test_add_func("/ibis/ctcp-message/parse/leading-delimiter-only",
+	                test_ibis_ctcp_message_parse_leading_delimiter_only);
+	g_test_add_func("/ibis/ctcp-message/parse/trailing-delimiter-only",
+	                test_ibis_ctcp_message_parse_trailing_delimiter_only);
+	g_test_add_func("/ibis/ctcp-message/parse/command-only",
+	                test_ibis_ctcp_message_parse_command_only);
+	g_test_add_func("/ibis/ctcp-message/parse/empty-params",
+	                test_ibis_ctcp_message_parse_empty_params);
+	g_test_add_func("/ibis/ctcp-message/parse/single-param",
+	                test_ibis_ctcp_message_parse_single_param);
+	g_test_add_func("/ibis/ctcp-message/parse/multiple-params",
+	                test_ibis_ctcp_message_parse_multiple_params);
+
+	g_test_add_func("/ibis/ctcp-message/serialize/command-only",
+	                test_ibis_ctcp_message_serialize_command_only);
+	g_test_add_func("/ibis/ctcp-message/serialize/single-param",
+	                test_ibis_ctcp_message_serialize_single_param);
+	g_test_add_func("/ibis/ctcp-message/serialize/multiple-params",
+	                test_ibis_ctcp_message_serialize_multiple_params);
+
+	return g_test_run();
+}
diff --git a/ibis/tests/test_message.c b/ibis/tests/test_message.c
--- a/ibis/tests/test_message.c
+++ b/ibis/tests/test_message.c
@@ -154,7 +154,9 @@
 	message = ibis_message_new(IBIS_MSG_PRIVMSG);
 
 	g_assert_false(ibis_message_get_ctcp(message));
+	G_GNUC_BEGIN_IGNORE_DEPRECATIONS
 	ibis_message_set_ctcp(message, TRUE);
+	G_GNUC_END_IGNORE_DEPRECATIONS
 	g_assert_true(ibis_message_get_ctcp(message));
 
 	g_assert_finalize_object(message);
@@ -167,7 +169,9 @@
 	message = ibis_message_new(IBIS_MSG_NOTICE);
 
 	g_assert_false(ibis_message_get_ctcp(message));
+	G_GNUC_BEGIN_IGNORE_DEPRECATIONS
 	ibis_message_set_ctcp(message, TRUE);
+	G_GNUC_END_IGNORE_DEPRECATIONS
 	g_assert_true(ibis_message_get_ctcp(message));
 
 	g_assert_finalize_object(message);
@@ -180,7 +184,9 @@
 	message = ibis_message_new("OTHER");
 
 	g_assert_false(ibis_message_get_ctcp(message));
+	G_GNUC_BEGIN_IGNORE_DEPRECATIONS
 	ibis_message_set_ctcp(message, TRUE);
+	G_GNUC_END_IGNORE_DEPRECATIONS
 	g_assert_false(ibis_message_get_ctcp(message));
 
 	g_assert_finalize_object(message);
@@ -391,33 +397,39 @@
 
 static void
 test_ibis_message_serialize_ctcp_no_params(void) {
+	IbisCTCPMessage *ctcp_message = NULL;
 	IbisMessage *message = NULL;
 	char *serialized = NULL;
 
 	message = ibis_message_new(IBIS_MSG_PRIVMSG);
-	ibis_message_set_ctcp(message, TRUE);
-	ibis_message_set_params(message, "VERSION", NULL);
+
+	ctcp_message = ibis_ctcp_message_new("VERSION");
+	ibis_message_set_ctcp_message(message, ctcp_message);
+	g_clear_object(&ctcp_message);
 
 	serialized = ibis_message_serialize_to_string(message);
+	g_assert_cmpstr(serialized, ==, "PRIVMSG :\001VERSION\001\r\n");
+	g_clear_pointer(&serialized, g_free);
 
-	g_assert_cmpstr(serialized, ==, "PRIVMSG :\001VERSION\001\r\n");
-
-	g_clear_pointer(&serialized, g_free);
 	g_assert_finalize_object(message);
 }
 
 static void
 test_ibis_message_serialize_ctcp_single_param(void) {
+	IbisCTCPMessage *ctcp_message = NULL;
 	IbisMessage *message = NULL;
 	char *serialized = NULL;
+	const char *params[] = {"slaps", "grim", "with", "a", "large", "trout",
+	                        NULL};
 
 	message = ibis_message_new(IBIS_MSG_PRIVMSG);
-	ibis_message_set_ctcp(message, TRUE);
-	ibis_message_set_params(message, "ACTION",
-	                        "slaps grim with a large trout", NULL);
+
+	ctcp_message = ibis_ctcp_message_new("ACTION");
+	ibis_ctcp_message_set_paramsv(ctcp_message, (GStrv)params);
+	ibis_message_set_ctcp_message(message, ctcp_message);
+	g_clear_object(&ctcp_message);
 
 	serialized = ibis_message_serialize_to_string(message);
-
 	g_assert_cmpstr(serialized, ==,
 	                "PRIVMSG :\001ACTION slaps grim with a large trout\001\r\n");
 
@@ -427,13 +439,17 @@
 
 static void
 test_ibis_message_serialize_ctcp_multiple_params(void) {
+	IbisCTCPMessage *ctcp_message = NULL;
 	IbisMessage *message = NULL;
 	char *serialized = NULL;
+	const char *ctcp_params[] = {"SEND", "pidgin.ico", "::1", "0", NULL};
 
 	message = ibis_message_new(IBIS_MSG_PRIVMSG);
-	ibis_message_set_ctcp(message, TRUE);
-	ibis_message_set_params(message, "DCC", "SEND", "pidgin.ico", "::1", "0",
-	                        NULL);
+
+	ctcp_message = ibis_ctcp_message_new(IBIS_CTCP_DCC);
+	ibis_ctcp_message_set_paramsv(ctcp_message, (GStrv)ctcp_params);
+	ibis_message_set_ctcp_message(message, ctcp_message);
+	g_clear_object(&ctcp_message);
 
 	serialized = ibis_message_serialize_to_string(message);
 
@@ -446,12 +462,17 @@
 
 static void
 test_ibis_message_serialize_ctcp_mixed_params(void) {
+	IbisCTCPMessage *ctcp_message = NULL;
 	IbisMessage *message = NULL;
 	char *serialized = NULL;
+	const char *ctcp_params[] = {"SEND", "pidgin.ico ::1 0", NULL};
 
 	message = ibis_message_new(IBIS_MSG_PRIVMSG);
-	ibis_message_set_ctcp(message, TRUE);
-	ibis_message_set_params(message, "DCC", "SEND", "pidgin.ico ::1 0", NULL);
+
+	ctcp_message = ibis_ctcp_message_new(IBIS_CTCP_DCC);
+	ibis_ctcp_message_set_paramsv(ctcp_message, (GStrv)ctcp_params);
+	ibis_message_set_ctcp_message(message, ctcp_message);
+	g_clear_object(&ctcp_message);
 
 	serialized = ibis_message_serialize_to_string(message);
 
diff --git a/ibis/tests/test_parser.c b/ibis/tests/test_parser.c
--- a/ibis/tests/test_parser.c
+++ b/ibis/tests/test_parser.c
@@ -32,7 +32,7 @@
 	char *command;
 	guint n_params;
 	const char * const params[16];
-	gboolean ctcp;
+	IbisCTCPMessage *ctcp_message;
 } TestIbisParserData;
 
 /******************************************************************************
@@ -47,7 +47,6 @@
 	char *upper_command = NULL;
 	const char *raw_message = NULL;
 	const char *source = NULL;
-	guint n_params = 0;
 
 	message = ibis_message_parse(raw, &error);
 	g_assert_no_error(error);
@@ -66,10 +65,6 @@
 	source = ibis_message_get_source(message);
 	g_assert_cmpstr(source, ==, d->source);
 
-	if(d->ctcp) {
-		g_assert_true(ibis_message_get_ctcp(message));
-	}
-
 	/* Make sure we have an expected tags hash table before checking them. */
 	if(d->tags != NULL) {
 		IbisTags *tags = NULL;
@@ -79,18 +74,28 @@
 		g_assert_true(ibis_tags_equal(d->tags, tags));
 	}
 
-	/* Check the parameters to make sure they match. */
-	params = ibis_message_get_params(message);
-	if(params != NULL) {
-		n_params = g_strv_length(params);
-	}
+	if(IBIS_IS_CTCP_MESSAGE(d->ctcp_message)) {
+		IbisCTCPMessage *ctcp_message = NULL;
+		const char *command1 = NULL;
+		const char *command2 = NULL;
+		GStrv params1 = NULL;
+		GStrv params2 = NULL;
+
+		ctcp_message = ibis_message_get_ctcp_message(message);
 
-	if(d->n_params > 0) {
-		g_assert_cmpuint(n_params, ==, d->n_params);
+		command1 = ibis_ctcp_message_get_command(ctcp_message);
+		command2 = ibis_ctcp_message_get_command(d->ctcp_message);
+		g_assert_cmpstr(command1, ==, command2);
 
-		for(guint i = 0; i < d->n_params; i++) {
-			g_assert_cmpstr(params[i], ==, d->params[i]);
-		}
+		params1 = ibis_ctcp_message_get_params(ctcp_message);
+		params2 = ibis_ctcp_message_get_params(d->ctcp_message);
+		g_assert_cmpstrv(params1, params2);
+
+		g_clear_object(&d->ctcp_message);
+	} else {
+		/* Check the parameters to make sure they match. */
+		params = ibis_message_get_params(message);
+		g_assert_cmpstrv(params, d->params);
 	}
 
 	/* Cleanup everything the caller allocated. */
@@ -108,9 +113,10 @@
 		.command = IBIS_MSG_PRIVMSG,
 		.n_params = 1,
 		.params = {IBIS_CTCP_VERSION},
-		.ctcp = TRUE,
 	};
 
+	data.ctcp_message = ibis_ctcp_message_new(IBIS_CTCP_VERSION);
+
 	test_ibis_parser("PRIVMSG :\001VERSION\001", &data);
 }
 
@@ -120,9 +126,10 @@
 		.command = IBIS_MSG_NOTICE,
 		.n_params = 1,
 		.params = {IBIS_CTCP_VERSION},
-		.ctcp = TRUE,
 	};
 
+	data.ctcp_message = ibis_ctcp_message_new(IBIS_CTCP_VERSION);
+
 	test_ibis_parser("NOTICE :\001VERSION\001", &data);
 }
 
@@ -132,7 +139,6 @@
 		.command = "OTHER",
 		.n_params = 1,
 		.params = {"\001VERSION\001"},
-		.ctcp = FALSE,
 	};
 
 	test_ibis_parser("OTHER :\001VERSION\001", &data);
@@ -144,9 +150,10 @@
 		.command = IBIS_MSG_PRIVMSG,
 		.n_params = 1,
 		.params = {IBIS_CTCP_VERSION},
-		.ctcp = TRUE,
 	};
 
+	data.ctcp_message = ibis_ctcp_message_new(IBIS_CTCP_VERSION);
+
 	test_ibis_parser("PRIVMSG :\001VERSION", &data);
 }
 
