# HG changeset patch
# Node ID ee14efdddeac58f064cb5379ae53f487c8b940ed
# Parent  3978d8a6af7f1de301fb6bcc7dd6b80a75ef4253
diff --git a/libpurple/core.c b/libpurple/core.c
--- a/libpurple/core.c
+++ b/libpurple/core.c
@@ -35,6 +35,7 @@
 #include "proxy.h"
 #include "purpleaccountmanager.h"
 #include "purpleaccountmanagerprivate.h"
+#include "purplecommandmanagerprivate.h"
 #include "purpleconnection.h"
 #include "purplecontactmanagerprivate.h"
 #include "purpleconversationmanagerprivate.h"
@@ -142,6 +143,7 @@
 	purple_contact_manager_startup();
 	purple_presence_manager_startup();
 	purple_conversation_manager_startup();
+	purple_command_manager_startup();
 	purple_whiteboard_manager_startup();
 
 	/* Setup the history adapter. */
@@ -207,6 +209,7 @@
 
 	/* Save .xml files, remove signals, etc. */
 	purple_idle_manager_shutdown();
+	purple_command_manager_shutdown();
 	purple_whiteboard_manager_shutdown();
 	purple_conversation_manager_shutdown();
 	purple_presence_manager_shutdown();
diff --git a/libpurple/meson.build b/libpurple/meson.build
--- a/libpurple/meson.build
+++ b/libpurple/meson.build
@@ -20,6 +20,7 @@
 	'purplebadges.c',
 	'purplechanneljoindetails.c',
 	'purplecommand.c',
+	'purplecommandmanager.c',
 	'purpleconnection.c',
 	'purplecontact.c',
 	'purplecontactinfo.c',
@@ -111,6 +112,7 @@
 	'purplebadges.h',
 	'purplechanneljoindetails.h',
 	'purplecommand.h',
+	'purplecommandmanager.h',
 	'purpleconnection.h',
 	'purplecontact.h',
 	'purplecontactinfo.h',
@@ -176,6 +178,7 @@
 purple_private_headers = [
 	'purpleaccountprivate.h',
 	'purpleaccountmanagerprivate.h',
+	'purplecommandmanagerprivate.h',
 	'purplecontactmanagerprivate.h',
 	'purpleconversationmanagerprivate.h',
 	'purplecredentialmanagerprivate.h',
diff --git a/libpurple/purplecommand.c b/libpurple/purplecommand.c
--- a/libpurple/purplecommand.c
+++ b/libpurple/purplecommand.c
@@ -317,6 +317,11 @@
 	 *
 	 * The [class@Tags] for the command.
 	 *
+	 * These tags will be used with [property@Conversation:tags] in a call to
+	 * [method@Tags.contains] to determine if the command is valid for a
+	 * conversation. Likewise, if this doesn't contain any tags, it will match
+	 * all conversations.
+	 *
 	 * Since: 3.0
 	 */
 	properties[PROP_TAGS] = g_param_spec_object(
diff --git a/libpurple/purplecommand.h b/libpurple/purplecommand.h
--- a/libpurple/purplecommand.h
+++ b/libpurple/purplecommand.h
@@ -175,6 +175,8 @@
  * [method@Tags.contains] to determine if command is available for a
  * [class@Conversation].
  *
+ * If this is empty, it will match all conversations.
+ *
  * Returns: (transfer none): The tags object.
  *
  * Since: 3.0
diff --git a/libpurple/purplecommandmanager.c b/libpurple/purplecommandmanager.c
new file mode 100644
--- /dev/null
+++ b/libpurple/purplecommandmanager.c
@@ -0,0 +1,400 @@
+/*
+ * Purple - Internet Messaging Library
+ * Copyright (C) Pidgin Developers <devel@pidgin.im>
+ *
+ * Purple 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 "purplecommandmanager.h"
+#include "purplecommandmanagerprivate.h"
+
+#include "util.h"
+
+struct _PurpleCommandManager {
+	GObject parent;
+
+	GPtrArray *commands;
+};
+
+static PurpleCommandManager *default_manager = NULL;
+
+/******************************************************************************
+ * GListModel Implementation
+ *****************************************************************************/
+static GType
+purple_command_manager_get_item_type(G_GNUC_UNUSED GListModel *list) {
+	return PURPLE_TYPE_COMMAND;
+}
+
+static guint
+purple_command_manager_get_n_items(GListModel *list) {
+	PurpleCommandManager *manager = PURPLE_COMMAND_MANAGER(list);
+
+	return manager->commands->len;
+}
+
+static gpointer
+purple_command_manager_get_item(GListModel *list, guint position) {
+	PurpleCommandManager *manager = PURPLE_COMMAND_MANAGER(list);
+	PurpleCommand *command = NULL;
+
+	if(position < manager->commands->len) {
+		command = g_ptr_array_index(manager->commands, position);
+		g_object_ref(command);
+	}
+
+	return command;
+}
+
+static void
+purple_command_manager_list_model_init(GListModelInterface *iface) {
+	iface->get_item_type = purple_command_manager_get_item_type;
+	iface->get_n_items = purple_command_manager_get_n_items;
+	iface->get_item = purple_command_manager_get_item;
+}
+
+/******************************************************************************
+ * GObject Implementation
+ *****************************************************************************/
+G_DEFINE_FINAL_TYPE_WITH_CODE(PurpleCommandManager, purple_command_manager,
+                              G_TYPE_OBJECT,
+                              G_IMPLEMENT_INTERFACE(G_TYPE_LIST_MODEL,
+                                                    purple_command_manager_list_model_init))
+
+static void
+purple_command_manager_finalize(GObject *obj) {
+	PurpleCommandManager *manager = PURPLE_COMMAND_MANAGER(obj);
+
+	g_clear_pointer(&manager->commands, g_ptr_array_unref);
+
+	G_OBJECT_CLASS(purple_command_manager_parent_class)->finalize(obj);
+}
+
+static void
+purple_command_manager_init(PurpleCommandManager *manager) {
+	manager->commands = g_ptr_array_new_full(10, g_object_unref);
+}
+
+static void
+purple_command_manager_class_init(PurpleCommandManagerClass *klass) {
+	GObjectClass *obj_class = G_OBJECT_CLASS(klass);
+
+	obj_class->finalize = purple_command_manager_finalize;
+}
+
+static gboolean
+purple_command_manager_commands_equal(gconstpointer a, gconstpointer b) {
+	PurpleCommand *command1 = (gpointer)a;
+	PurpleCommand *command2 = (gpointer)b;
+	const char *name1 = NULL;
+	const char *name2 = NULL;
+
+	name1 = purple_command_get_name(command1);
+	name2 = purple_command_get_name(command2);
+
+	if(purple_strequal(name1, name2)) {
+		const char *source1 = NULL;
+		const char *source2 = NULL;
+
+		source1 = purple_command_get_source(command1);
+		source2 = purple_command_get_source(command2);
+
+		if(purple_strequal(source1, source2)) {
+			return TRUE;
+		}
+	}
+
+	return FALSE;
+}
+
+/******************************************************************************
+ * Private API
+ *****************************************************************************/
+void
+purple_command_manager_startup(void) {
+	if(default_manager == NULL) {
+		default_manager = purple_command_manager_new();
+		if(PURPLE_IS_COMMAND_MANAGER(default_manager)) {
+			g_object_add_weak_pointer(G_OBJECT(default_manager),
+			                          (gpointer *)&default_manager);
+		}
+	}
+}
+
+void
+purple_command_manager_shutdown(void) {
+	g_clear_object(&default_manager);
+}
+
+/******************************************************************************
+ * Public API
+ *****************************************************************************/
+void
+purple_command_manager_add(PurpleCommandManager *manager,
+                           PurpleCommand *command)
+{
+	gboolean found = FALSE;
+
+	g_return_if_fail(PURPLE_IS_COMMAND_MANAGER(manager));
+	g_return_if_fail(PURPLE_IS_COMMAND(command));
+
+	/* If the manager already knows about the command, we do nothing. */
+	found = g_ptr_array_find_with_equal_func(manager->commands, command,
+	                                         purple_command_manager_commands_equal,
+	                                         NULL);
+	if(!found) {
+		g_ptr_array_add(manager->commands, command);
+
+		g_list_model_items_changed(G_LIST_MODEL(manager),
+		                           manager->commands->len - 1, 0, 1);
+	} else {
+		g_object_unref(command);
+	}
+}
+
+PurpleCommand *
+purple_command_manager_find(PurpleCommandManager *manager,
+                            PurpleConversation *conversation,
+                            const char *name)
+{
+	PurpleCommand *command = NULL;
+	PurpleTags *conversation_tags = NULL;
+	int current_priority = 0;
+
+	g_return_val_if_fail(PURPLE_IS_COMMAND_MANAGER(manager), NULL);
+	g_return_val_if_fail(!purple_strempty(name), NULL);
+
+	if(PURPLE_IS_CONVERSATION(conversation)) {
+		conversation_tags = purple_conversation_get_tags(conversation);
+	}
+
+	for(guint i = 0; i < manager->commands->len; i++) {
+		PurpleCommand *candidate = NULL;
+		const char *candidate_name = NULL;
+		int candidate_priority = 0;
+
+		candidate = g_ptr_array_index(manager->commands, i);
+		if(!PURPLE_IS_COMMAND(candidate)) {
+			continue;
+		}
+
+		candidate_name = purple_command_get_name(candidate);
+		if(!purple_strequal(candidate_name, name)) {
+			continue;
+		}
+
+		if(conversation_tags != NULL) {
+			PurpleTags *command_tags = purple_command_get_tags(candidate);
+
+			if(!purple_tags_contains(conversation_tags, command_tags)) {
+				continue;
+			}
+		}
+
+		candidate_priority = purple_command_get_priority(candidate);
+
+		if(command == NULL || candidate_priority > current_priority) {
+			command = candidate;
+			current_priority = candidate_priority;
+		}
+	}
+
+	return command;
+}
+
+GListModel *
+purple_command_manager_find_all(PurpleCommandManager *manager,
+                                PurpleConversation *conversation,
+                                const char *name)
+{
+	PurpleTags *conversation_tags = NULL;
+	GListStore *commands = NULL;
+
+	g_return_val_if_fail(PURPLE_IS_COMMAND_MANAGER(manager), NULL);
+	g_return_val_if_fail(!purple_strempty(name), NULL);
+
+	if(PURPLE_IS_CONVERSATION(conversation)) {
+		conversation_tags = purple_conversation_get_tags(conversation);
+	}
+
+	commands = g_list_store_new(PURPLE_TYPE_COMMAND);
+
+	for(guint i = 0; i < manager->commands->len; i++) {
+		PurpleCommand *command = NULL;
+		const char *command_name = NULL;
+
+		command = g_ptr_array_index(manager->commands, i);
+		if(!PURPLE_IS_COMMAND(command)) {
+			continue;
+		}
+
+		command_name = purple_command_get_name(command);
+		if(!purple_strequal(command_name, name)) {
+			continue;
+		}
+
+		if(conversation_tags != NULL) {
+			PurpleTags *command_tags = purple_command_get_tags(command);
+
+			if(!purple_tags_contains(conversation_tags, command_tags)) {
+				continue;
+			}
+		}
+
+		g_list_store_append(commands, command);
+	}
+
+	return G_LIST_MODEL(commands);
+}
+
+gboolean
+purple_command_manager_find_and_execute(PurpleCommandManager *manager,
+                                        PurpleConversation *conversation,
+                                        const char *command_line)
+{
+	PurpleCommand *command = NULL;
+	GStrv params = NULL;
+	char *command_name = NULL;
+	gboolean ret = FALSE;
+
+	g_return_val_if_fail(PURPLE_IS_COMMAND_MANAGER(manager), FALSE);
+	g_return_val_if_fail(PURPLE_IS_CONVERSATION(conversation), FALSE);
+	g_return_val_if_fail(!purple_strempty(command_line), FALSE);
+
+	params = g_strsplit(command_line, " ", -1);
+	command_name = params[0];
+
+	command = purple_command_manager_find(manager, conversation, command_name);
+	if(PURPLE_IS_COMMAND(command)) {
+		purple_command_executev(command, conversation, params + 1);
+
+		ret = TRUE;
+	}
+
+	g_strfreev(params);
+
+	return ret;
+}
+
+PurpleCommandManager *
+purple_command_manager_get_default(void) {
+	return default_manager;
+}
+
+GListModel *
+purple_command_manager_get_default_as_model(void) {
+	return G_LIST_MODEL(default_manager);
+}
+
+GListModel *
+purple_command_manager_get_commands_for_conversation(PurpleCommandManager *manager,
+                                                     PurpleConversation *conversation)
+{
+	PurpleTags *conversation_tags = NULL;
+	GListStore *commands = NULL;
+
+	g_return_val_if_fail(PURPLE_IS_COMMAND_MANAGER(manager), NULL);
+	g_return_val_if_fail(PURPLE_IS_CONVERSATION(conversation), NULL);
+
+	commands = g_list_store_new(PURPLE_TYPE_COMMAND);
+	conversation_tags = purple_conversation_get_tags(conversation);
+
+	for(guint i = 0; i < manager->commands->len; i++) {
+		PurpleCommand *command = NULL;
+		PurpleTags *command_tags = NULL;
+
+		command = g_ptr_array_index(manager->commands, i);
+		command_tags = purple_command_get_tags(command);
+
+		if(purple_tags_contains(conversation_tags, command_tags)) {
+			g_list_store_append(commands, command);
+		}
+	}
+
+	return G_LIST_MODEL(commands);
+}
+
+PurpleCommandManager *
+purple_command_manager_new(void) {
+	return g_object_new(PURPLE_TYPE_COMMAND_MANAGER, NULL);
+}
+
+gboolean
+purple_command_manager_remove(PurpleCommandManager *manager, const char *name,
+                              const char *source)
+{
+	g_return_val_if_fail(PURPLE_IS_COMMAND_MANAGER(manager), FALSE);
+
+	for(guint i = 0; i < manager->commands->len; i++) {
+		PurpleCommand *command = NULL;
+		const char *command_name = NULL;
+		const char *command_source = NULL;
+
+		command = g_ptr_array_index(manager->commands, i);
+		if(!PURPLE_IS_COMMAND(command)) {
+			continue;
+		}
+
+		command_name = purple_command_get_name(command);
+		command_source = purple_command_get_source(command);
+		if(purple_strequal(command_name, name) &&
+		   purple_strequal(command_source, source))
+		{
+			g_ptr_array_remove_index(manager->commands, i);
+
+			g_list_model_items_changed(G_LIST_MODEL(manager), i, 1, 0);
+
+			return TRUE;
+		}
+	}
+
+	return FALSE;
+}
+
+void
+purple_command_manager_remove_all_with_source(PurpleCommandManager *manager,
+                                              const char *source)
+{
+	g_return_if_fail(PURPLE_IS_COMMAND_MANAGER(manager));
+	g_return_if_fail(!purple_strempty(source));
+
+	/* Since GPtrArray shifts everything down on a remove, we only increment
+	 * when we haven't removed a command.
+	 */
+	for(guint i = 0; i < manager->commands->len;) {
+		PurpleCommand *command = NULL;
+		const char *command_source = NULL;
+
+		command = g_ptr_array_index(manager->commands, i);
+		if(!PURPLE_IS_COMMAND(command)) {
+			continue;
+		}
+
+		command_source = purple_command_get_source(command);
+		if(purple_strequal(command_source, source)) {
+			g_ptr_array_remove_index(manager->commands, i);
+
+			/* TODO: optimize this so we notify when a group of items is
+			 * removed instead of notifying for every remove.
+			 */
+			g_list_model_items_changed(G_LIST_MODEL(manager), i, 1, 0);
+		} else {
+			i++;
+		}
+	}
+}
diff --git a/libpurple/purplecommandmanager.h b/libpurple/purplecommandmanager.h
new file mode 100644
--- /dev/null
+++ b/libpurple/purplecommandmanager.h
@@ -0,0 +1,228 @@
+/*
+ * Purple - Internet Messaging Library
+ * Copyright (C) Pidgin Developers <devel@pidgin.im>
+ *
+ * Purple 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(PURPLE_GLOBAL_HEADER_INSIDE) && !defined(PURPLE_COMPILATION)
+# error "only <purple.h> may be included directly"
+#endif
+
+#ifndef PURPLE_COMMAND_MANAGER_H
+#define PURPLE_COMMAND_MANAGER_H
+
+#include <glib.h>
+#include <glib-object.h>
+
+#include "purplecommand.h"
+#include "purpleconversation.h"
+#include "purpleversion.h"
+
+G_BEGIN_DECLS
+
+/**
+ * PurpleCommandManager:
+ *
+ * A manager of [class@Command] objects.
+ *
+ * libpurple, user interfaces, and plugins can manage the commands that are
+ * available with [method@CommandManager.add] and
+ * [method@CommandManager.remove].
+ *
+ * User interfaces can use [method@CommandManager.find_and_execute] to quickly
+ * find and execute a command. They can also use
+ * [method@CommandManager.find_all] to get a list of all commands which can be
+ * presented to a user.
+ *
+ * Both [method@CommandManager.find] and [method@CommandManager.find_all] take
+ * an optional [class@Conversation] parameter. If it is not %NULL, its
+ * [property@Conversation:tags] property will be searched with
+ * [method@Tags.contains] using the [property@Command:tags] as the needle to
+ * determine which commands are available for the [class@Conversation].
+ *
+ * When plugins are being unloaded, they should call
+ * [method@CommandManager.remove_all_with_source] to remove their commands.
+ *
+ * Since: 3.0
+ */
+
+#define PURPLE_TYPE_COMMAND_MANAGER (purple_command_manager_get_type())
+
+PURPLE_AVAILABLE_IN_3_0
+G_DECLARE_FINAL_TYPE(PurpleCommandManager, purple_command_manager, PURPLE,
+                     COMMAND_MANAGER, GObject)
+
+/**
+ * purple_command_manager_add:
+ * @manager: The instance.
+ * @command: (transfer full): The new command to add.
+ *
+ * Adds @command to @manager.
+ *
+ * If @manager already has a command with a matching name and source, @command
+ * will not be added but ownership will still be taken.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+void purple_command_manager_add(PurpleCommandManager *manager, PurpleCommand *command);
+
+/**
+ * purple_command_manager_find:
+ * @manager: The instance.
+ * @conversation: (nullable): An optional conversation.
+ * @name: The name of the command.
+ *
+ * Finds the command with a name of @name with the highest priority.
+ *
+ * If @conversation is not %NULL commands will be filtered to only include
+ * those that are valid for @conversation by using [method@Tags.contains] on
+ * @conversation with the tags of the found commands.
+ *
+ * Returns: (transfer none): The command if found, otherwise %NULL.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+PurpleCommand *purple_command_manager_find(PurpleCommandManager *manager, PurpleConversation *conversation, const char *name);
+
+/**
+ * purple_command_manager_find_all:
+ * @manager: The instance.
+ * @conversation: (nullable): An optional conversation.
+ * @name: The name of the command.
+ *
+ * Finds all commands that match @name in @manager.
+ *
+ * If @conversation is not %NULL, [method@Tags.contains] will be used to filter
+ * out commands whose tags don't match @conversation.
+ *
+ * Returns: (transfer full): The list of found plugins which could be empty.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+GListModel *purple_command_manager_find_all(PurpleCommandManager *manager, PurpleConversation *conversation, const char *name);
+
+/**
+ * purple_command_manager_find_and_execute:
+ * @manager: The instance.
+ * @conversation: The conversation to use.
+ * @command_line: The command line including the name and arguments.
+ *
+ * Attempts to find a [class@Command] in @manager and execute it.
+ *
+ * @command_line should be a space separate string of the command name and any
+ * arguments. It should not include a leading `/` or any other prefix.
+ *
+ * This method is a helper around [method@CommandManager.find] and
+ * [method@Command.execute].
+ *
+ * Returns: %TRUE if a command was found and executed, otherwise %FALSE.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+gboolean purple_command_manager_find_and_execute(PurpleCommandManager *manager, PurpleConversation *conversation, const char *command_line);
+
+/**
+ * purple_command_manager_get_commands_for_conversation:
+ * @manager: The instance.
+ * @conversation: The conversation.
+ *
+ * Gets a list of [class@Command]'s that are available for @conversation.
+ *
+ * Internally this filters the list of commands with [method@Tags.contains] to
+ * check that all of [property@Command:tags] are contained in
+ * [property@Conversation:tags].
+ *
+ * Returns: (transfer full): The list of commands, which may be empty.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+GListModel *purple_command_manager_get_commands_for_conversation(PurpleCommandManager *manager, PurpleConversation *conversation);
+
+/**
+ * purple_command_manager_get_default:
+ *
+ * Gets the default instance that libpurple is using.
+ *
+ * Returns: (transfer none): The instance.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+PurpleCommandManager *purple_command_manager_get_default(void);
+
+/**
+ * purple_command_manager_get_default_as_model:
+ *
+ * Gets the default instance of the manager but cast to [iface@Gio.ListModel].
+ *
+ * Returns: (transfer none): The manager cast as a [iface@Gio.ListModel].
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+GListModel *purple_command_manager_get_default_as_model(void);
+
+/**
+ * purple_command_manager_new:
+ *
+ * Creates a new instance.
+ *
+ * This is typically only used by libpurple but you can get the default
+ * instance that libpurple is using with [func@CommandManager.get_default].
+ *
+ * Returns: (transfer full): The new instance.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+PurpleCommandManager *purple_command_manager_new(void);
+
+/**
+ * purple_command_manager_remove:
+ * @manager: The instance.
+ * @name: The name of the command.
+ * @source: The source of the command.
+ *
+ * Attempts to remove the first command with @name and @source from @manager.
+ *
+ * Returns: %TRUE if a command is found and removed, otherwise %FALSE.
+ */
+PURPLE_AVAILABLE_IN_3_0
+gboolean purple_command_manager_remove(PurpleCommandManager *manager, const char *name, const char *source);
+
+/**
+ * purple_command_manager_remove_all_with_source:
+ * @manager: The instance.
+ * @source: The source whose commands to remove.
+ *
+ * Removes all commands from @manager that have a source of @source.
+ *
+ * Since: 3.0
+ */
+PURPLE_AVAILABLE_IN_3_0
+void purple_command_manager_remove_all_with_source(PurpleCommandManager *manager, const char *source);
+
+G_END_DECLS
+
+#endif /* PURPLE_COMMAND_MANAGER_H */
diff --git a/libpurple/purplecommandmanagerprivate.h b/libpurple/purplecommandmanagerprivate.h
new file mode 100644
--- /dev/null
+++ b/libpurple/purplecommandmanagerprivate.h
@@ -0,0 +1,56 @@
+/*
+ * Purple - Internet Messaging Library
+ * Copyright (C) Pidgin Developers <devel@pidgin.im>
+ *
+ * Purple 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(PURPLE_GLOBAL_HEADER_INSIDE) && !defined(PURPLE_COMPILATION)
+# error "only <purple.h> may be included directly"
+#endif
+
+#ifndef PURPLE_COMMAND_MANAGER_PRIVATE_H
+#define PURPLE_COMMAND_MANAGER_PRIVATE_H
+
+#include <glib.h>
+
+G_BEGIN_DECLS
+
+/**
+ * purple_command_manager_startup: (skip)
+ *
+ * Initializes the default [class@CommandManager].
+ *
+ * Since: 3.0
+ */
+G_GNUC_INTERNAL
+void purple_command_manager_startup(void);
+
+/**
+ * purple_command_manager_shutdown: (skip)
+ *
+ * Cleans up the default [class@CommandManager].
+ *
+ * Since: 3.0
+ */
+G_GNUC_INTERNAL
+void purple_command_manager_shutdown(void);
+
+G_END_DECLS
+
+#endif /* PURPLE_COMMAND_MANAGER_PRIVATE_H */
diff --git a/libpurple/tests/meson.build b/libpurple/tests/meson.build
--- a/libpurple/tests/meson.build
+++ b/libpurple/tests/meson.build
@@ -8,6 +8,7 @@
     'circular_buffer',
     'create_conversation_details',
     'command',
+    'command_manager',
     'contact',
     'contact_info',
     'contact_manager',
diff --git a/libpurple/tests/test_command_manager.c b/libpurple/tests/test_command_manager.c
new file mode 100644
--- /dev/null
+++ b/libpurple/tests/test_command_manager.c
@@ -0,0 +1,419 @@
+/*
+ * Purple - Internet Messaging Library
+ * Copyright (C) Pidgin Developers <devel@pidgin.im>
+ *
+ * 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 <purple.h>
+
+/******************************************************************************
+ * Resuable callbacks
+ *****************************************************************************/
+static void
+test_purple_command_manager_items_changed_cb(GListModel *model,
+                                             G_GNUC_UNUSED guint position,
+                                             G_GNUC_UNUSED guint removed,
+                                             G_GNUC_UNUSED guint added,
+                                             gpointer data)
+{
+	guint *counter = data;
+
+	g_assert_true(PURPLE_IS_COMMAND_MANAGER(model));
+
+	*counter = *counter + 1;
+}
+
+/******************************************************************************
+ * Tests
+ *****************************************************************************/
+static void
+test_purple_command_manager_new(void) {
+	PurpleCommandManager *manager = NULL;
+
+	manager = purple_command_manager_new();
+	g_assert_true(PURPLE_IS_COMMAND_MANAGER(manager));
+
+	g_assert_finalize_object(manager);
+}
+
+static void
+test_purple_command_manager_add_remove(void) {
+	PurpleCommand *command = NULL;
+	PurpleCommandManager *manager = NULL;
+	GListModel *model = NULL;
+	gboolean ret = FALSE;
+	guint counter = 0;
+
+	manager = purple_command_manager_new();
+	model = G_LIST_MODEL(manager);
+	g_signal_connect(manager, "items-changed",
+	                 G_CALLBACK(test_purple_command_manager_items_changed_cb),
+	                 &counter);
+
+	g_assert_cmpuint(g_list_model_get_n_items(model), ==, 0);
+
+	command = purple_command_new("test-command", "test", 0);
+	/* We use command again in the next test. */
+	g_object_ref(command);
+	purple_command_manager_add(manager, command);
+	g_assert_cmpuint(g_list_model_get_n_items(model), ==, 1);
+	g_assert_cmpuint(counter, ==, 1);
+
+	/* Duplicate adds should be ignore. */
+	counter = 0;
+	purple_command_manager_add(manager, command);
+	g_assert_cmpuint(g_list_model_get_n_items(model), ==, 1);
+	g_assert_cmpuint(counter, ==, 0);
+
+	/* Duplicate command but as a new pointer and make sure it doesn't get
+	 * added.
+	 */
+	counter = 0;
+	command = purple_command_new("test-command", "test", 0);
+	purple_command_manager_add(manager, command);
+	g_assert_cmpuint(g_list_model_get_n_items(model), ==, 1);
+	g_assert_cmpuint(counter, ==, 0);
+
+	/* Name and command must match. */
+	counter = 0;
+	ret = purple_command_manager_remove(manager, "unknown-command", "test");
+	g_assert_false(ret);
+	g_assert_cmpuint(g_list_model_get_n_items(model), ==, 1);
+	g_assert_cmpuint(counter, ==, 0);
+
+	counter = 0;
+	ret = purple_command_manager_remove(manager, "test-command", "unknown");
+	g_assert_false(ret);
+	g_assert_cmpuint(g_list_model_get_n_items(model), ==, 1);
+	g_assert_cmpuint(counter, ==, 0);
+
+	/* Do the real remove. */
+	counter = 0;
+	ret = purple_command_manager_remove(manager, "test-command", "test");
+	g_assert_true(ret);
+	g_assert_cmpuint(g_list_model_get_n_items(model), ==, 0);
+	g_assert_cmpuint(counter, ==, 1);
+
+	/* Duplicate removes should return false as the item is gone. */
+	counter = 0;
+	ret = purple_command_manager_remove(manager, "test-command", "test");
+	g_assert_false(ret);
+	g_assert_cmpuint(g_list_model_get_n_items(model), ==, 0);
+	g_assert_cmpuint(counter, ==, 0);
+
+	g_assert_finalize_object(manager);
+}
+
+static void
+test_purple_command_manager_remove_all_with_source(void) {
+	PurpleCommand *command = NULL;
+	PurpleCommandManager *manager = NULL;
+	GListModel *model = NULL;
+	guint counter = 0;
+
+	manager = purple_command_manager_new();
+	model = G_LIST_MODEL(manager);
+	g_signal_connect(manager, "items-changed",
+	                 G_CALLBACK(test_purple_command_manager_items_changed_cb),
+	                 &counter);
+
+	/* Make sure remove all works on an empty manager. */
+	counter = 0;
+	purple_command_manager_remove_all_with_source(manager, "test-1");
+	g_assert_cmpuint(g_list_model_get_n_items(model), ==, 0);
+	g_assert_cmpuint(counter, ==, 0);
+
+	/* Add 3 commands, two with the same source and another with a different
+	 * source in between them.
+	 */
+	command = purple_command_new("privmsg", "test-1", 0);
+	purple_command_manager_add(manager, command);
+
+	command = purple_command_new("xyzzy", "test-2", 0);
+	purple_command_manager_add(manager, command);
+
+	command = purple_command_new("query", "test-1", 0);
+	purple_command_manager_add(manager, command);
+
+	g_assert_cmpuint(g_list_model_get_n_items(model), ==, 3);
+
+	/* Remove the commands with test-1 as a source and verify that the second
+	 * command still exists.
+	 */
+	counter = 0;
+	purple_command_manager_remove_all_with_source(manager, "test-1");
+	g_assert_cmpuint(g_list_model_get_n_items(model), ==, 1);
+	g_assert_cmpuint(counter, ==, 2);
+
+	command = purple_command_manager_find(manager, NULL, "xyzzy");
+	g_assert_true(PURPLE_IS_COMMAND(command));
+
+	/* Add another command with a source of test-2. */
+	command = purple_command_new("quit", "test-2", 0);
+	purple_command_manager_add(manager, command);
+
+	g_assert_cmpuint(g_list_model_get_n_items(model), ==, 2);
+
+	/* Now remove all commands with a source of test-2. */
+	counter = 0;
+	purple_command_manager_remove_all_with_source(manager, "test-2");
+	g_assert_cmpuint(g_list_model_get_n_items(model), ==, 0);
+	g_assert_cmpuint(counter, ==, 2);
+
+	g_assert_finalize_object(manager);
+}
+
+static void
+test_purple_command_manager_find(void) {
+	PurpleCommand *command = NULL;
+	PurpleCommandManager *manager = NULL;
+	PurpleConversation *conversation = NULL;
+	PurpleTags *tags = NULL;
+
+	manager = purple_command_manager_new();
+
+	/* We need at least one conversation to test filtering, so we just create
+	 * one with a tag of test=true.
+	 */
+	conversation = g_object_new(PURPLE_TYPE_CONVERSATION, NULL);
+	tags = purple_conversation_get_tags(conversation);
+	purple_tags_add(tags, "test=true");
+
+	/* Test that find works with nothing added. This is unlikely to happen in
+	 * practice, but it's good to test for.
+	 */
+	command = purple_command_manager_find(manager, NULL, "unknown");
+	g_assert_null(command);
+
+	command = purple_command_manager_find(manager, conversation, "unknown");
+	g_assert_null(command);
+
+	/* Now add a test command and verify that we can find it. */
+	command = purple_command_new("test", "test-1", 0);
+	tags = purple_command_get_tags(command);
+	purple_tags_add(tags, "test=true");
+	purple_command_manager_add(manager, command);
+
+	command = purple_command_manager_find(manager, NULL, "test");
+	g_assert_true(PURPLE_IS_COMMAND(command));
+
+	command = purple_command_manager_find(manager, conversation, "test");
+	g_assert_true(PURPLE_IS_COMMAND(command));
+
+	/* Now add the command again but with a lower priority. */
+	command = purple_command_new("test", "test-2", -100);
+	tags = purple_command_get_tags(command);
+	purple_tags_add(tags, "test=true");
+	purple_command_manager_add(manager, command);
+
+	command = purple_command_manager_find(manager, NULL, "test");
+	g_assert_true(PURPLE_IS_COMMAND(command));
+	g_assert_cmpuint(purple_command_get_priority(command), ==, 0);
+
+	command = purple_command_manager_find(manager, conversation, "test");
+	g_assert_true(PURPLE_IS_COMMAND(command));
+	g_assert_cmpuint(purple_command_get_priority(command), ==, 0);
+
+	/* Now add the command again but with a higher priority. */
+	command = purple_command_new("test", "test-3", 100);
+	tags = purple_command_get_tags(command);
+	purple_tags_add(tags, "test=true");
+	purple_command_manager_add(manager, command);
+
+	command = purple_command_manager_find(manager, NULL, "test");
+	g_assert_true(PURPLE_IS_COMMAND(command));
+	g_assert_cmpuint(purple_command_get_priority(command), ==, 100);
+
+	command = purple_command_manager_find(manager, conversation, "test");
+	g_assert_true(PURPLE_IS_COMMAND(command));
+	g_assert_cmpuint(purple_command_get_priority(command), ==, 100);
+
+	/* Finally add the command again, but with a much higher priority and tags
+	 * that won't match the conversation.
+	 */
+	command = purple_command_new("test", "test-4", 1337);
+	tags = purple_command_get_tags(command);
+	purple_tags_add(tags, "test=false");
+	purple_command_manager_add(manager, command);
+
+	command = purple_command_manager_find(manager, NULL, "test");
+	g_assert_true(PURPLE_IS_COMMAND(command));
+	g_assert_cmpuint(purple_command_get_priority(command), ==, 1337);
+
+	command = purple_command_manager_find(manager, conversation, "test");
+	g_assert_true(PURPLE_IS_COMMAND(command));
+	g_assert_cmpuint(purple_command_get_priority(command), ==, 100);
+
+	/* Cleanup. */
+	g_assert_finalize_object(conversation);
+	g_assert_finalize_object(manager);
+}
+
+static void
+test_purple_command_manager_find_all(void) {
+	PurpleCommand *command = NULL;
+	PurpleCommandManager *manager = NULL;
+	PurpleConversation *conversation = NULL;
+	PurpleTags *tags = NULL;
+	GListModel *commands = NULL;
+
+	manager = purple_command_manager_new();
+
+	/* We need at least one conversation to test filtering, so we just create
+	 * one with a tag of test=true.
+	 */
+	conversation = g_object_new(PURPLE_TYPE_CONVERSATION, NULL);
+	tags = purple_conversation_get_tags(conversation);
+	purple_tags_add(tags, "test=true");
+
+	/* Create and add our commands that all share a name. One with no tags,
+	 * another with the same tags as the conversation, one without the
+	 * conversation tags, and another with a different priority but the
+	 * conversation tags.
+	 */
+	command = purple_command_new("test", "test-1", 0);
+	purple_command_manager_add(manager, command);
+
+	command = purple_command_new("test", "test-2", 0);
+	tags = purple_command_get_tags(command);
+	purple_tags_add(tags, "test=true");
+	purple_command_manager_add(manager, command);
+
+	command = purple_command_new("test", "test-3", 0);
+	tags = purple_command_get_tags(command);
+	purple_tags_add(tags, "test=false");
+	purple_command_manager_add(manager, command);
+
+	command = purple_command_new("test", "test-4", 100);
+	tags = purple_command_get_tags(command);
+	purple_tags_add(tags, "test=true");
+	purple_command_manager_add(manager, command);
+
+	/* Check an unknown command. */
+	commands = purple_command_manager_find_all(manager, NULL, "unknown");
+	g_assert_cmpuint(g_list_model_get_n_items(commands), ==, 0);
+	g_clear_object(&commands);
+
+	/* Check without the conversation. */
+	commands = purple_command_manager_find_all(manager, NULL, "test");
+	g_assert_cmpuint(g_list_model_get_n_items(commands), ==, 4);
+	g_clear_object(&commands);
+
+	/* Check with the conversation. */
+	commands = purple_command_manager_find_all(manager, conversation, "test");
+	g_assert_cmpuint(g_list_model_get_n_items(commands), ==, 3);
+	g_clear_object(&commands);
+
+	g_assert_finalize_object(conversation);
+	g_assert_finalize_object(manager);
+}
+
+/******************************************************************************
+ * Find and Execute tests
+ *****************************************************************************/
+static void
+test_purple_command_manager_find_and_execute_counter(PurpleCommand *command,
+                                                     PurpleConversation *conversation,
+                                                     G_GNUC_UNUSED GStrv params,
+                                                     gpointer data)
+{
+	guint *counter = data;
+
+	g_assert_true(PURPLE_IS_COMMAND(command));
+	g_assert_true(PURPLE_IS_CONVERSATION(conversation));
+
+	*counter = *counter + 1;
+}
+
+static void
+test_purple_command_manager_find_and_execute_executed(PurpleCommand *command,
+                                                      PurpleConversation *conversation,
+                                                      GStrv params,
+                                                      gpointer data)
+{
+	g_assert_true(PURPLE_IS_COMMAND(command));
+	g_assert_true(PURPLE_IS_CONVERSATION(conversation));
+	g_assert_cmpstrv(params, data);
+}
+
+static void
+test_purple_command_manager_find_and_execute(void) {
+	PurpleCommand *command = NULL;
+	PurpleCommandManager *manager = NULL;
+	PurpleConversation *conversation = NULL;
+	gboolean result = FALSE;
+	guint counter = 0;
+	const char * const expected_args[] = {"arg1", "arg2", NULL};
+
+	manager = purple_command_manager_new();
+
+	conversation = g_object_new(PURPLE_TYPE_CONVERSATION, NULL);
+
+	/* Make sure we can't find anything in an empty manager. */
+	result = purple_command_manager_find_and_execute(manager, conversation,
+	                                                 "unknown");
+	g_assert_false(result);
+
+	/* Again but with some arguments this time. */
+	result = purple_command_manager_find_and_execute(manager, conversation,
+	                                                 "unknown arg1 arg2");
+	g_assert_false(result);
+
+	/* Create and add the command. */
+	command = purple_command_new("test", "test-1", 0);
+	g_signal_connect(command, "executed",
+	                 G_CALLBACK(test_purple_command_manager_find_and_execute_counter),
+	                 &counter);
+	g_signal_connect(command, "executed",
+	                 G_CALLBACK(test_purple_command_manager_find_and_execute_executed),
+	                 (gpointer)expected_args);
+	purple_command_manager_add(manager, command);
+
+	/* Try to execute it. */
+	counter = 0;
+	result = purple_command_manager_find_and_execute(manager, conversation,
+	                                                 "test arg1 arg2");
+	g_assert_true(result);
+	g_assert_cmpuint(counter, ==, 1);
+
+	g_assert_finalize_object(conversation);
+	g_assert_finalize_object(manager);
+}
+
+/******************************************************************************
+ * Main
+ *****************************************************************************/
+int
+main(int argc, char *argv[]) {
+	g_test_init(&argc, &argv, NULL);
+	g_test_set_nonfatal_assertions();
+
+	g_test_add_func("/command-manager/new", test_purple_command_manager_new);
+	g_test_add_func("/command-manager/add-remove",
+	                test_purple_command_manager_add_remove);
+
+	g_test_add_func("/command-manager/remove-all-with-source",
+	                test_purple_command_manager_remove_all_with_source);
+
+	g_test_add_func("/command-manager/find", test_purple_command_manager_find);
+	g_test_add_func("/command-manager/find-all",
+	                test_purple_command_manager_find_all);
+	g_test_add_func("/command-manager/find-and-execute",
+	                test_purple_command_manager_find_and_execute);
+
+	return g_test_run();
+}
