changeset 895:f0d6bc79aa32

irccd: implement hooks, closes #2342 @2h The hook mechanism is an alternative approach to plugins which allow the user to write lightweight scripts in any language.
author David Demelier <markand@malikania.fr>
date Thu, 05 Sep 2019 13:39:32 +0200
parents 6704e7ded799
children b594a9269f49
files CHANGES.md doc/examples/CMakeLists.txt doc/examples/irccd.conf.sample doc/examples/sample-hook.sh irccdctl/cli.cpp irccdctl/cli.hpp irccdctl/main.cpp libirccd-ctl/irccd/ctl/controller.cpp libirccd-daemon/CMakeLists.txt libirccd-daemon/irccd/daemon.hpp libirccd-daemon/irccd/daemon/bot.cpp libirccd-daemon/irccd/daemon/bot.hpp libirccd-daemon/irccd/daemon/hook.cpp libirccd-daemon/irccd/daemon/hook.hpp libirccd-daemon/irccd/daemon/hook_service.cpp libirccd-daemon/irccd/daemon/hook_service.hpp libirccd-daemon/irccd/daemon/server_service.cpp libirccd-daemon/irccd/daemon/transport_command.cpp libirccd-daemon/irccd/daemon/transport_command.hpp libirccd-test/irccd/test/cli_fixture.hpp libirccd-test/irccd/test/command_fixture.hpp man/irccd.1 man/irccd.conf.5 man/irccdctl.1 tests/src/irccdctl/CMakeLists.txt tests/src/irccdctl/cli-hook-add/CMakeLists.txt tests/src/irccdctl/cli-hook-add/main.cpp tests/src/irccdctl/cli-hook-list/CMakeLists.txt tests/src/irccdctl/cli-hook-list/main.cpp tests/src/irccdctl/cli-hook-remove/CMakeLists.txt tests/src/irccdctl/cli-hook-remove/main.cpp tests/src/libirccd-daemon/CMakeLists.txt tests/src/libirccd-daemon/command-hook-add/CMakeLists.txt tests/src/libirccd-daemon/command-hook-add/main.cpp tests/src/libirccd-daemon/command-hook-list/CMakeLists.txt tests/src/libirccd-daemon/command-hook-list/main.cpp tests/src/libirccd-daemon/command-hook-remove/CMakeLists.txt tests/src/libirccd-daemon/command-hook-remove/main.cpp tests/src/libirccd-daemon/hook/CMakeLists.txt tests/src/libirccd-daemon/hook/main.cpp tests/src/libirccd-daemon/hook/sample_hook.cpp
diffstat 41 files changed, 2575 insertions(+), 4 deletions(-) [+]
line wrap: on
line diff
--- a/CHANGES.md	Sun Sep 01 17:23:37 2019 +0200
+++ b/CHANGES.md	Thu Sep 05 13:39:32 2019 +0200
@@ -1,6 +1,14 @@
 IRC Client Daemon CHANGES
 =========================
 
+irccd current
+----------------------
+
+irccd:
+
+- Added a new hook system. Hooks consist of an alternative approach to plugins
+  to extend irccd in any language (#2342).
+
 irccd 3.0.1 2019-09-01
 ----------------------
 
--- a/doc/examples/CMakeLists.txt	Sun Sep 01 17:23:37 2019 +0200
+++ b/doc/examples/CMakeLists.txt	Thu Sep 05 13:39:32 2019 +0200
@@ -36,6 +36,11 @@
 	DESTINATION ${CMAKE_INSTALL_SYSCONFDIR}
 )
 
+install(
+	FILES ${examples_SOURCE_DIR}/sample-hook.sh
+	DESTINATION ${CMAKE_INSTALL_DOCDIR}/examples
+)
+
 irccd_set_global(CPACK_COMPONENT_EXAMPLES_HIDDEN On)
 irccd_set_global(CPACK_COMPONENT_EXAMPLES_DESCRIPTION "Install examples of configuration files")
 irccd_set_global(CPACK_COMPONENT_EXAMPLES_GROUP "Documentation")
--- a/doc/examples/irccd.conf.sample	Sun Sep 01 17:23:37 2019 +0200
+++ b/doc/examples/irccd.conf.sample	Thu Sep 05 13:39:32 2019 +0200
@@ -147,3 +147,10 @@
 #
 # [templates.hangman]
 # win = "you win!"
+
+# Section hooks:
+#    This sections lets you define hooks that are executed each time an IRC
+#    event arrives.
+#
+# [hooks]
+# mail = "/path/to/mail.py"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/doc/examples/sample-hook.sh	Thu Sep 05 13:39:32 2019 +0200
@@ -0,0 +1,130 @@
+#!/bin/sh
+#
+# sample-hook.sh -- a sample hook in shell script
+#
+# Copyright (c) 2013-2019 David Demelier <markand@malikania.fr>
+#
+# Permission to use, copy, modify, and/or distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+#
+
+onConnect()
+{
+	echo "event:   connect"
+	echo "server:  $2"
+}
+
+onDisconnect()
+{
+	echo "event:   disconnect"
+	echo "server:  $2"
+}
+
+onInvite()
+{
+	echo "event:   invite"
+	echo "server:  $2"
+	echo "origin:  $3"
+	echo "channel: $4"
+	echo "target:  $5"
+}
+
+onJoin()
+{
+	echo "event:   join"
+	echo "server:  $2"
+	echo "origin:  $3"
+	echo "channel: $4"
+}
+
+onKick()
+{
+	echo "event:   kick"
+	echo "server:  $2"
+	echo "origin:  $3"
+	echo "channel: $4"
+	echo "target:  $5"
+	echo "reason:  $6"
+}
+
+onMessage()
+{
+	echo "event:   message"
+	echo "server:  $2"
+	echo "origin:  $3"
+	echo "channel: $4"
+	echo "message: $5"
+}
+
+onMe()
+{
+	echo "event:   me"
+	echo "server:  $2"
+	echo "origin:  $3"
+	echo "channel: $4"
+	echo "message: $5"
+}
+
+onMode()
+{
+	echo "event:   mode"
+	echo "server:  $2"
+	echo "origin:  $3"
+	echo "channel: $4"
+	echo "mode:    $5"
+	echo "limit:   $6"
+	echo "user:    $7"
+	echo "mask:    $8"
+}
+
+onNick()
+{
+	echo "event:   nick"
+	echo "server:  $2"
+	echo "origin:  $3"
+	echo "nick:    $4"
+}
+
+onNotice()
+{
+	echo "event:   notice"
+	echo "server:  $2"
+	echo "origin:  $3"
+	echo "channel: $4"
+	echo "message: $5"
+}
+
+onPart()
+{
+	echo "event:   part"
+	echo "server:  $2"
+	echo "origin:  $3"
+	echo "channel: $4"
+	echo "reason:  $5"
+}
+
+onTopic()
+{
+	echo "event:   topic"
+	echo "server:  $2"
+	echo "origin:  $3"
+	echo "channel: $4"
+	echo "topic:   $5"
+}
+
+#
+# Call the appropriate function with the same name as the event and pass
+# arguments.
+#
+# Please keep quotes between $@ as some arguments are quoted (like messages).
+#
+$1 "$@"
--- a/irccdctl/cli.cpp	Sun Sep 01 17:23:37 2019 +0200
+++ b/irccdctl/cli.cpp	Thu Sep 05 13:39:32 2019 +0200
@@ -224,6 +224,9 @@
 // {{{ cli
 
 const std::vector<cli::constructor> cli::registry{
+	bind<hook_add_cli>(),
+	bind<hook_list_cli>(),
+	bind<hook_remove_cli>(),
 	bind<plugin_config_cli>(),
 	bind<plugin_info_cli>(),
 	bind<plugin_list_cli>(),
@@ -281,6 +284,74 @@
 
 // }}}
 
+// {{{ hook_add_cli
+
+auto hook_add_cli::get_name() const noexcept -> std::string_view
+{
+	return "hook-add";
+}
+
+void hook_add_cli::exec(ctl::controller& ctl, const std::vector<std::string>& argv)
+{
+	if (argv.size() < 2U)
+		throw std::invalid_argument("hook-add requires 2 arguments");
+
+	request(ctl, nlohmann::json::object({
+		{ "command",    "hook-add"              },
+		{ "id",         argv[0]                 },
+		{ "path",       argv[1]                 }
+	}));
+}
+
+// }}}
+
+// {{{ hook_list_cli
+
+auto hook_list_cli::get_name() const noexcept -> std::string_view
+{
+	return "hook-list";
+}
+
+void hook_list_cli::exec(ctl::controller& ctl, const std::vector<std::string>&)
+{
+	request(ctl, {{ "command", "hook-list" }}, [] (auto result) {
+		for (const auto& obj : result["list"]) {
+			if (!obj.is_object())
+				continue;
+
+			const deserializer document(obj);
+
+			std::cout << std::setw(16) << std::left;
+			std::cout << document.get<std::string>("id").value_or("(unknown)");
+			std::cout << " ";
+			std::cout << document.get<std::string>("path").value_or("(unknown)");
+			std::cout << std::endl;
+		}
+	});
+}
+
+// }}}
+
+// {{{ hook_remove_cli
+
+auto hook_remove_cli::get_name() const noexcept -> std::string_view
+{
+	return "hook-remove";
+}
+
+void hook_remove_cli::exec(ctl::controller& ctl, const std::vector<std::string>& argv)
+{
+	if (argv.size() < 1U)
+		throw std::invalid_argument("hook-remove requires 1 argument");
+
+	request(ctl, nlohmann::json::object({
+		{ "command",    "hook-remove"   },
+		{ "id",         argv[0]         },
+	}));
+}
+
+// }}}
+
 // {{{ plugin_config_cli
 
 void plugin_config_cli::set(ctl::controller& ctl, const std::vector<std::string>&args)
--- a/irccdctl/cli.hpp	Sun Sep 01 17:23:37 2019 +0200
+++ b/irccdctl/cli.hpp	Thu Sep 05 13:39:32 2019 +0200
@@ -105,6 +105,66 @@
 
 // }}}
 
+// {{{ hook_add_cli
+
+/**
+ * \brief Implementation of irccdctl hook-add.
+ */
+class hook_add_cli : public cli {
+public:
+	/**
+	 * \copydoc cli::get_name
+	 */
+	auto get_name() const noexcept -> std::string_view override;
+
+	/**
+	 * \copydoc cli::exec
+	 */
+	void exec(ctl::controller& irccdctl, const std::vector<std::string>& args) override;
+};
+
+// }}}
+
+// {{{ hook_list_cli
+
+/**
+ * \brief Implementation of irccdctl hook-list.
+ */
+class hook_list_cli : public cli {
+public:
+	/**
+	 * \copydoc cli::get_name
+	 */
+	auto get_name() const noexcept -> std::string_view override;
+
+	/**
+	 * \copydoc cli::exec
+	 */
+	void exec(ctl::controller& irccdctl, const std::vector<std::string>& args) override;
+};
+
+// }}}
+
+// {{{ hook_remove_cli
+
+/**
+ * \brief Implementation of irccdctl hook-remove.
+ */
+class hook_remove_cli : public cli {
+public:
+	/**
+	 * \copydoc cli::get_name
+	 */
+	auto get_name() const noexcept -> std::string_view override;
+
+	/**
+	 * \copydoc cli::exec
+	 */
+	void exec(ctl::controller& irccdctl, const std::vector<std::string>& args) override;
+};
+
+// }}}
+
 // {{{ plugin_config_cli
 
 /**
--- a/irccdctl/main.cpp	Sun Sep 01 17:23:37 2019 +0200
+++ b/irccdctl/main.cpp	Thu Sep 05 13:39:32 2019 +0200
@@ -72,7 +72,10 @@
 [[noreturn]]
 void usage()
 {
-	std::cerr << "usage: irccdctl plugin-config id [variable] [value]\n";
+	std::cerr << "usage: irccdctl hook-add id path\n";
+	std::cerr << "       irccdctl hook-list\n";
+	std::cerr << "       irccdctl hook-remove id\n";
+	std::cerr << "       irccdctl plugin-config id [variable] [value]\n";
 	std::cerr << "       irccdctl plugin-info id\n";
 	std::cerr << "       irccdctl plugin-list\n";
 	std::cerr << "       irccdctl plugin-load name\n";
--- a/libirccd-ctl/irccd/ctl/controller.cpp	Sun Sep 01 17:23:37 2019 +0200
+++ b/libirccd-ctl/irccd/ctl/controller.cpp	Thu Sep 05 13:39:32 2019 +0200
@@ -22,15 +22,17 @@
 #include <irccd/json_util.hpp>
 
 #include <irccd/daemon/bot.hpp>
-#include <irccd/daemon/server.hpp>
+#include <irccd/daemon/hook.hpp>
 #include <irccd/daemon/plugin.hpp>
 #include <irccd/daemon/rule.hpp>
+#include <irccd/daemon/server.hpp>
 
 #include "controller.hpp"
 
 using irccd::json_util::deserializer;
 
 using irccd::daemon::bot_error;
+using irccd::daemon::hook_error;
 using irccd::daemon::plugin_error;
 using irccd::daemon::rule_error;
 using irccd::daemon::server_error;
@@ -138,6 +140,8 @@
 				code = make_error_code(static_cast<plugin_error::error>(*e));
 			else if (*c == "rule")
 				code = make_error_code(static_cast<rule_error::error>(*e));
+			else if (*c == "hook")
+				code = make_error_code(static_cast<hook_error::error>(*e));
 		}
 
 		handler(std::move(code), std::move(msg));
--- a/libirccd-daemon/CMakeLists.txt	Sun Sep 01 17:23:37 2019 +0200
+++ b/libirccd-daemon/CMakeLists.txt	Thu Sep 05 13:39:32 2019 +0200
@@ -25,6 +25,10 @@
 	${libirccd-daemon_SOURCE_DIR}/irccd/daemon/bot.hpp
 	${libirccd-daemon_SOURCE_DIR}/irccd/daemon/dynlib_plugin.cpp
 	${libirccd-daemon_SOURCE_DIR}/irccd/daemon/dynlib_plugin.hpp
+	${libirccd-daemon_SOURCE_DIR}/irccd/daemon/hook.cpp
+	${libirccd-daemon_SOURCE_DIR}/irccd/daemon/hook.hpp
+	${libirccd-daemon_SOURCE_DIR}/irccd/daemon/hook_service.cpp
+	${libirccd-daemon_SOURCE_DIR}/irccd/daemon/hook_service.hpp
 	${libirccd-daemon_SOURCE_DIR}/irccd/daemon/irc.cpp
 	${libirccd-daemon_SOURCE_DIR}/irccd/daemon/irc.hpp
 	${libirccd-daemon_SOURCE_DIR}/irccd/daemon/logger.cpp
--- a/libirccd-daemon/irccd/daemon.hpp	Sun Sep 01 17:23:37 2019 +0200
+++ b/libirccd-daemon/irccd/daemon.hpp	Thu Sep 05 13:39:32 2019 +0200
@@ -28,6 +28,8 @@
 
 #include "daemon/bot.hpp"
 #include "daemon/dynlib_plugin.hpp"
+#include "daemon/hook.hpp"
+#include "daemon/hook_service.hpp"
 #include "daemon/irc.hpp"
 #include "daemon/logger.hpp"
 #include "daemon/plugin.hpp"
--- a/libirccd-daemon/irccd/daemon/bot.cpp	Sun Sep 01 17:23:37 2019 +0200
+++ b/libirccd-daemon/irccd/daemon/bot.cpp	Thu Sep 05 13:39:32 2019 +0200
@@ -26,6 +26,7 @@
 #include <irccd/system.hpp>
 
 #include "bot.hpp"
+#include "hook_service.hpp"
 #include "logger.hpp"
 #include "plugin_service.hpp"
 #include "rule_service.hpp"
@@ -185,10 +186,25 @@
 	sink_->set_filter(*filter_);
 }
 
+void bot::load_hooks()
+{
+	const auto sc = config_.get("hooks");
+
+	for (const auto& opt : sc) {
+		if (!string_util::is_identifier(opt.get_key()))
+			throw hook_error(hook_error::invalid_identifier, opt.get_key(), "");
+		if (opt.get_value().empty())
+			throw hook_error(hook_error::invalid_path, opt.get_key(), "");
+
+		hook_service_->add(hook(opt.get_key(), opt.get_value()));
+	}
+}
+
 bot::bot(boost::asio::io_service& service, std::string config)
 	: config_(std::move(config))
 	, service_(service)
 	, sink_(std::make_unique<logger::console_sink>())
+	, hook_service_(std::make_unique<hook_service>(*this))
 	, server_service_(std::make_unique<server_service>(*this))
 	, tpt_service_(std::make_unique<transport_service>(*this))
 	, rule_service_(std::make_unique<rule_service>(*this))
@@ -228,6 +244,11 @@
 	return *sink_;
 }
 
+auto bot::get_hooks() noexcept -> hook_service&
+{
+	return *hook_service_;
+}
+
 auto bot::get_servers() noexcept -> server_service&
 {
 	return *server_service_;
@@ -267,6 +288,7 @@
 	// [logs] and [templates] sections.
 	load_logs();
 	load_templates();
+	load_hooks();
 
 	if (!loaded_)
 		sink_->info("irccd", "") << "loading configuration from " << config_.get_path() << std::endl;
--- a/libirccd-daemon/irccd/daemon/bot.hpp	Sun Sep 01 17:23:37 2019 +0200
+++ b/libirccd-daemon/irccd/daemon/bot.hpp	Thu Sep 05 13:39:32 2019 +0200
@@ -47,6 +47,7 @@
 
 } // !logger
 
+class hook_service;
 class plugin_service;
 class rule_service;
 class server_service;
@@ -71,6 +72,7 @@
 	std::unique_ptr<logger::filter> filter_;
 
 	// Services.
+	std::unique_ptr<hook_service> hook_service_;
 	std::unique_ptr<server_service> server_service_;
 	std::unique_ptr<transport_service> tpt_service_;
 	std::unique_ptr<rule_service> rule_service_;
@@ -88,6 +90,7 @@
 	void load_logs_syslog();
 	void load_logs();
 	void load_templates();
+	void load_hooks();
 
 public:
 	/**
@@ -158,6 +161,13 @@
 	void set_log(std::unique_ptr<logger::sink> sink) noexcept;
 
 	/**
+	 * Access the hook service.
+	 *
+	 * \return the service
+	 */
+	auto get_hooks() noexcept -> hook_service&;
+
+	/**
 	 * Access the server service.
 	 *
 	 * \return the service
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libirccd-daemon/irccd/daemon/hook.cpp	Thu Sep 05 13:39:32 2019 +0200
@@ -0,0 +1,292 @@
+/*
+ * hook.cpp -- irccd hooks
+ *
+ * Copyright (c) 2013-2019 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <cassert>
+#include <initializer_list>
+#include <string_view>
+#include <sstream>
+
+#include <boost/process.hpp>
+
+#include <irccd/string_util.hpp>
+
+#include "bot.hpp"
+#include "hook.hpp"
+#include "logger.hpp"
+#include "server.hpp"
+
+using boost::process::args;
+using boost::process::child;
+using boost::process::exe;
+using boost::process::ipstream;
+using boost::process::std_out;
+
+using std::getline;
+using std::initializer_list;
+using std::ostringstream;
+using std::string;
+using std::string_view;
+
+using irccd::string_util::is_identifier;
+
+namespace irccd::daemon {
+
+namespace {
+
+void exec(bot& bot, hook& hook, initializer_list<string> arguments)
+{
+	try {
+		ipstream is;
+		child c(exe = hook.get_path(), args = arguments, std_out > is);
+
+		// Log everything that is output by the hook.
+		for (string line; c.running() && getline(is, line); )
+			bot.get_log().info(hook) << line << std::endl;
+
+		c.wait();
+	} catch (const std::exception& ex) {
+		throw hook_error(hook_error::exec_error, hook.get_id(), ex.what());
+	}
+}
+
+} // !namespace
+
+hook::hook(std::string id, std::string path) noexcept
+	: id_(std::move(id))
+	, path_(std::move(path))
+{
+	assert(is_identifier(id_));
+	assert(!path_.empty());
+}
+
+auto hook::get_id() const noexcept -> const std::string&
+{
+	return id_;
+}
+
+auto hook::get_path() const noexcept -> const std::string&
+{
+	return path_;
+}
+
+void hook::handle_connect(bot& bot, const connect_event& event)
+{
+	exec(bot, *this, {"onConnect", event.server->get_id()});
+}
+
+void hook::handle_disconnect(bot& bot, const disconnect_event& event)
+{
+	exec(bot, *this, {"onDisconnect", event.server->get_id()});
+}
+
+void hook::handle_invite(bot& bot, const invite_event& event)
+{
+	exec(bot, *this, {
+		"onInvite",
+		event.server->get_id(),
+		event.origin,
+		event.channel,
+		event.nickname
+	});
+}
+
+void hook::handle_join(bot& bot, const join_event& event)
+{
+	exec(bot, *this, {
+		"onJoin",
+		event.server->get_id(),
+		event.origin,
+		event.channel
+	});
+}
+
+void hook::handle_kick(bot& bot, const kick_event& event)
+{
+	exec(bot, *this, {
+		"onKick",
+		event.server->get_id(),
+		event.origin,
+		event.channel,
+		event.target,
+		event.reason
+	});
+}
+
+void hook::handle_message(bot& bot, const message_event& event)
+{
+	exec(bot, *this, {
+		"onMessage",
+		event.server->get_id(),
+		event.origin,
+		event.channel,
+		event.message
+	});
+}
+
+void hook::handle_me(bot& bot, const me_event& event)
+{
+	exec(bot, *this, {
+		"onMe",
+		event.server->get_id(),
+		event.origin,
+		event.channel,
+		event.message
+	});
+}
+
+void hook::handle_mode(bot& bot, const mode_event& event)
+{
+	exec(bot, *this, {
+		"onMode",
+		event.server->get_id(),
+		event.origin,
+		event.channel,
+		event.mode,
+		event.limit,
+		event.user,
+		event.mask
+	});
+}
+
+void hook::handle_nick(bot& bot, const nick_event& event)
+{
+	exec(bot, *this, {
+		"onNick",
+		event.server->get_id(),
+		event.origin,
+		event.nickname
+	});
+}
+
+void hook::handle_notice(bot& bot, const notice_event& event)
+{
+	exec(bot, *this, {
+		"onNotice",
+		event.server->get_id(),
+		event.origin,
+		event.channel,
+		event.message
+	});
+}
+
+void hook::handle_part(bot& bot, const part_event& event)
+{
+	exec(bot, *this, {
+		"onPart",
+		event.server->get_id(),
+		event.origin,
+		event.channel,
+		event.reason
+	});
+}
+
+void hook::handle_topic(bot& bot, const topic_event& event)
+{
+	exec(bot, *this, {
+		"onTopic",
+		event.server->get_id(),
+		event.origin,
+		event.channel,
+		event.topic
+	});
+}
+
+auto operator==(const hook& h1, const hook& h2) noexcept -> bool
+{
+	return h1.get_id() == h2.get_id() &&
+	       h1.get_path() == h2.get_path();
+}
+
+auto operator!=(const hook& h1, const hook& h2) noexcept -> bool
+{
+	return !(h1 == h2);
+}
+
+hook_error::hook_error(error errc, std::string id, std::string message)
+	: system_error(make_error_code(errc))
+	, id_(std::move(id))
+	, message_(std::move(message))
+{
+}
+
+auto hook_error::get_id() const noexcept -> const std::string&
+{
+	return id_;
+}
+
+auto hook_error::get_message() const noexcept -> const std::string&
+{
+	return message_;
+}
+
+auto hook_error::what() const noexcept -> const char*
+{
+	return message_.c_str();
+}
+
+auto hook_category() -> const std::error_category&
+{
+	static const class category : public std::error_category {
+	public:
+		auto name() const noexcept -> const char* override
+		{
+			return "hook";
+		}
+
+		auto message(int e) const -> std::string override
+		{
+			switch (static_cast<hook_error::error>(e)) {
+			case hook_error::not_found:
+				return "hook not found";
+			case hook_error::invalid_path:
+				return "invalid path given";
+			case hook_error::invalid_identifier:
+				return "invalid hook identifier";
+			case hook_error::exec_error:
+				return "hook exec error";
+			case hook_error::already_exists:
+				return "hook already exists";
+			default:
+				return "no error";
+			}
+		}
+	} category;
+
+	return category;
+}
+
+auto make_error_code(hook_error::error e) -> std::error_code
+{
+	return { static_cast<int>(e), hook_category() };
+}
+
+namespace logger {
+
+auto type_traits<hook>::get_category(const hook&) -> std::string_view
+{
+	return "hook";
+}
+
+auto type_traits<hook>::get_component(const hook& hook) -> std::string_view
+{
+	return hook.get_id();
+}
+
+} // !logger
+
+} // !irccd::daemon
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libirccd-daemon/irccd/daemon/hook.hpp	Thu Sep 05 13:39:32 2019 +0200
@@ -0,0 +1,331 @@
+/*
+ * hook.hpp -- irccd hooks
+ *
+ * Copyright (c) 2013-2019 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef IRCCD_DAEMON_HOOK_HPP
+#define IRCCD_DAEMON_HOOK_HPP
+
+/**
+ * \file irccd/daemon/hook.hpp
+ * \brief irccd hooks
+ */
+
+#include <irccd/sysconfig.hpp>
+
+#include <string>
+#include <string_view>
+#include <system_error>
+#include <type_traits>
+
+namespace irccd::daemon {
+
+class bot;
+
+struct connect_event;
+struct disconnect_event;
+struct invite_event;
+struct join_event;
+struct kick_event;
+struct me_event;
+struct message_event;
+struct mode_event;
+struct nick_event;
+struct notice_event;
+struct part_event;
+struct topic_event;
+
+/**
+ * \brief Event hook.
+ *
+ * A hook is a lightweight alternative to plugins, it is executed once an event
+ * arrive and can be written in any language.
+ */
+class hook {
+private:
+	std::string id_;
+	std::string path_;
+
+public:
+	/**
+	 * Construct a hook.
+	 *
+	 * This does not check the presence of the script.
+	 *
+	 * \pre id must be a valid identifier
+	 * \pre path must not be empty
+	 * \param id the hook id
+	 * \param path the path to the hook
+	 */
+	hook(std::string id, std::string path) noexcept;
+
+	/**
+	 * Get user unique id.
+	 *
+	 * \return the hook id
+	 */
+	auto get_id() const noexcept -> const std::string&;
+
+	/**
+	 * Get path to the hook.
+	 *
+	 * \return the path
+	 */
+	auto get_path() const noexcept -> const std::string&;
+
+	/**
+	 * Similar interface to plugin::handle_connect.
+	 *
+	 * \param bot the irccd instance
+	 * \param event the event
+	 */
+	void handle_connect(bot& bot, const connect_event& event);
+
+	/**
+	 * Similar interface to plugin::handle_disconnect.
+	 *
+	 * \param bot the irccd instance
+	 * \param event the event
+	 */
+	void handle_disconnect(bot& bot, const disconnect_event& event);
+
+	/**
+	 * Similar interface to plugin::handle_invite.
+	 *
+	 * \param bot the irccd instance
+	 * \param event the event
+	 */
+	void handle_invite(bot& bot, const invite_event& event);
+
+	/**
+	 * Similar interface to plugin::handle_join.
+	 *
+	 * \param bot the irccd instance
+	 * \param event the event
+	 */
+	void handle_join(bot& bot, const join_event& event);
+
+	/**
+	 * Similar interface to plugin::handle_kick.
+	 *
+	 * \param bot the irccd instance
+	 * \param event the event
+	 */
+	void handle_kick(bot& bot, const kick_event& event);
+
+	/**
+	 * Similar interface to plugin::handle_message.
+	 *
+	 * \param bot the irccd instance
+	 * \param event the event
+	 */
+	void handle_message(bot& bot, const message_event& event);
+
+	/**
+	 * Similar interface to plugin::handle_me.
+	 *
+	 * \param bot the irccd instance
+	 * \param event the event
+	 */
+	void handle_me(bot& bot, const me_event& event);
+
+	/**
+	 * Similar interface to plugin::handle_mode.
+	 *
+	 * \param bot the irccd instance
+	 * \param event the event
+	 */
+	void handle_mode(bot& bot, const mode_event& event);
+
+	/**
+	 * Similar interface to plugin::handle_nick.
+	 *
+	 * \param bot the irccd instance
+	 * \param event the event
+	 */
+	void handle_nick(bot& bot, const nick_event& event);
+
+	/**
+	 * Similar interface to plugin::handle_notice.
+	 *
+	 * \param bot the irccd instance
+	 * \param event the event
+	 */
+	void handle_notice(bot& bot, const notice_event& event);
+
+	/**
+	 * Similar interface to plugin::handle_part.
+	 *
+	 * \param bot the irccd instance
+	 * \param event the event
+	 */
+	void handle_part(bot& bot, const part_event& event);
+
+	/**
+	 * Similar interface to plugin::handle_topic.
+	 *
+	 * \param bot the irccd instance
+	 * \param event the event
+	 */
+	void handle_topic(bot& bot, const topic_event& event);
+};
+
+/**
+ * Equality operator.
+ *
+ * \param lhs the left side
+ * \param rhs the right side
+ * \return true if they equals
+ */
+auto operator==(const hook& lhs, const hook& rhs) noexcept -> bool;
+
+/**
+ * Equality operator.
+ *
+ * \param lhs the left side
+ * \param rhs the right side
+ * \return false if they equals
+ */
+auto operator!=(const hook& lhs, const hook& rhs) noexcept -> bool;
+
+/**
+ * \brief Hook error.
+ */
+class hook_error : public std::system_error {
+public:
+	/**
+	 * \brief Plugin related errors.
+	 */
+	enum error {
+		//!< No error.
+		no_error = 0,
+
+		//!< The specified identifier is invalid.
+		invalid_identifier,
+
+		//!< The specified hook is not found.
+		not_found,
+
+		//!< Invalid path given.
+		invalid_path,
+
+		//!< The hook was unable to run the function.
+		exec_error,
+
+		//!< The hook is already loaded.
+		already_exists,
+	};
+
+private:
+	std::string id_;
+	std::string message_;
+
+public:
+	/**
+	 * Constructor.
+	 *
+	 * \param code the error code
+	 * \param id the hook id
+	 * \param message the optional message (e.g. error from hook)
+	 */
+	hook_error(error code, std::string id, std::string message = "");
+
+	/**
+	 * Get the hook identifier.
+	 *
+	 * \return the id
+	 */
+	auto get_id() const noexcept -> const std::string&;
+
+	/**
+	 * Get the additional message.
+	 *
+	 * \return the message
+	 */
+	auto get_message() const noexcept -> const std::string&;
+
+	/**
+	 * Get message appropriate for use with logger.
+	 *
+	 * \return the error message
+	 */
+	auto what() const noexcept -> const char* override;
+};
+
+/**
+ * Get the hook error category singleton.
+ *
+ * \return the singleton
+ */
+auto hook_category() -> const std::error_category&;
+
+/**
+ * Create a std::error_code from hook_error::error enum.
+ *
+ * \param e the error code
+ * \return the error code
+ */
+auto make_error_code(hook_error::error e) -> std::error_code;
+
+namespace logger {
+
+template <typename T>
+struct type_traits;
+
+/**
+ * \brief Specialization for hook.
+ * \ingroup daemon-loggers-traits
+ */
+template <>
+struct type_traits<hook> {
+	/**
+	 * Get 'hook' category.
+	 *
+	 * \param hook the hook
+	 * \return hook
+	 */
+	static auto get_category(const hook& hook) -> std::string_view;
+
+	/**
+	 * Get the hook id.
+	 *
+	 * \param hook the hook
+	 * \return the hook id
+	 */
+	static auto get_component(const hook& hook) -> std::string_view;
+};
+
+} // !logger
+
+} // !irccd::daemon
+
+/**
+ * \cond IRCCD_HIDDEN_SYMBOLS
+ */
+
+namespace std {
+
+template <>
+struct is_error_code_enum<irccd::daemon::hook_error::error> : public std::true_type {
+};
+
+} // !std
+
+/**
+ * \endcond
+ */
+
+#endif // !IRCCD_DAEMON_PLUGIN_HPP
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libirccd-daemon/irccd/daemon/hook_service.cpp	Thu Sep 05 13:39:32 2019 +0200
@@ -0,0 +1,66 @@
+/*
+ * hook_service.cpp -- irccd hook service
+ *
+ * Copyright (c) 2013-2019 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <algorithm>
+
+#include "hook_service.hpp"
+
+using std::find;
+using std::move;
+
+namespace irccd::daemon {
+
+hook_service::hook_service(bot& bot) noexcept
+	: bot_(bot)
+{
+}
+
+auto hook_service::has(const hook& hook) const noexcept -> bool
+{
+	return find(hooks_.begin(), hooks_.end(), hook) != hooks_.end();
+}
+
+void hook_service::add(hook hook)
+{
+	if (has(hook))
+		throw hook_error(hook_error::already_exists, hook.get_id(), "");
+
+	hooks_.push_back(move(hook));
+}
+
+void hook_service::remove(const hook& hook) noexcept
+{
+	hooks_.erase(std::remove(hooks_.begin(), hooks_.end(), hook), hooks_.end());
+}
+
+auto hook_service::list() const noexcept -> const hooks&
+{
+	return hooks_;
+}
+
+auto hook_service::list() noexcept -> hooks&
+{
+	return hooks_;
+}
+
+void hook_service::clear() noexcept
+{
+	hooks_.clear();
+}
+
+} // !irccd::daemon
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libirccd-daemon/irccd/daemon/hook_service.hpp	Thu Sep 05 13:39:32 2019 +0200
@@ -0,0 +1,134 @@
+/*
+ * hook_service.hpp -- irccd hook service
+ *
+ * Copyright (c) 2013-2019 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef IRCCD_DAEMON_HOOK_SERVICE_HPP
+#define IRCCD_DAEMON_HOOK_SERVICE_HPP
+
+/**
+ * \file irccd/daemon/hook_service.hpp
+ * \brief Irccd hook service.
+ */
+
+#include <functional>
+#include <vector>
+#include <utility>
+
+#include "bot.hpp"
+#include "hook.hpp"
+#include "logger.hpp"
+
+namespace irccd::daemon {
+
+class bot;
+
+/**
+ * \brief Irccd hook service.
+ */
+class hook_service {
+public:
+	/**
+	 * List of hooks.
+	 */
+	using hooks = std::vector<hook>;
+
+private:
+	bot& bot_;
+	hooks hooks_;
+
+public:
+	/**
+	 * Constructor.
+	 *
+	 * \param bot the bot
+	 */
+	hook_service(bot& bot) noexcept;
+
+	/**
+	 * Tells if a hook already exists.
+	 *
+	 * \param hook the hook to check
+	 * \return true if hook is already present
+	 */
+	auto has(const hook& hook) const noexcept -> bool;
+
+	/**
+	 * Add a new hook.
+	 *
+	 * \param hook the hook
+	 * \throw hook_error if the hook is already present
+	 */
+	void add(hook hook);
+
+	/**
+	 * Remove the specified hook.
+	 *
+	 * \param hook the hook to remove
+	 */
+	void remove(const hook& hook) noexcept;
+
+	/**
+	 * Get the list of hooks.
+	 *
+	 * \return the hooks
+	 */
+	auto list() const noexcept -> const hooks&;
+
+	/**
+	 * Overloaded function.
+	 *
+	 * \return the hooks
+	 */
+	auto list() noexcept -> hooks&;
+
+	/**
+	 * Remove all hooks.
+	 */
+	void clear() noexcept;
+
+	/**
+	 * Convenient function to call a hook member function for all hook
+	 * present in the list.
+	 *
+	 * \param func the function to call (e.g. hook::handle_connect)
+	 * \param args the arguments to the hook function
+	 * \throw hook_error on errors
+	 */
+	template <typename Func, typename... Args>
+	void dispatch(Func&& func, Args&&... args);
+};
+
+template <typename Func, typename... Args>
+void hook_service::dispatch(Func&& func, Args&&... args)
+{
+	using std::invoke;
+	using std::forward;
+	using std::exception;
+
+	for (auto& hook : hooks_) {
+		// Protect to avoid stopping all next hooks.
+		try {
+			invoke(forward<Func>(func), hook, bot_, forward<Args>(args)...);
+		} catch (const exception& ex) {
+			bot_.get_log().warning(hook) << ex.what() << std::endl;
+		}
+	}
+}
+
+} // !irccd::daemon
+
+#endif // !IRCCD_DAEMON_HOOK_SERVICE_HPP
--- a/libirccd-daemon/irccd/daemon/server_service.cpp	Sun Sep 01 17:23:37 2019 +0200
+++ b/libirccd-daemon/irccd/daemon/server_service.cpp	Thu Sep 05 13:39:32 2019 +0200
@@ -22,6 +22,7 @@
 #include <irccd/string_util.hpp>
 
 #include "bot.hpp"
+#include "hook_service.hpp"
 #include "logger.hpp"
 #include "plugin_service.hpp"
 #include "rule_service.hpp"
@@ -102,6 +103,7 @@
 		{ "event",      "onConnect"             },
 		{ "server",     ev.server->get_id()     }
 	}));
+	bot_.get_hooks().dispatch(&hook::handle_connect, ev);
 
 	dispatch(ev.server->get_id(), /* origin */ "", /* channel */ "",
 		[=] (plugin&) -> std::string {
@@ -120,6 +122,7 @@
 		{ "event",      "onDisconnect"          },
 		{ "server",     ev.server->get_id()     }
 	}));
+	bot_.get_hooks().dispatch(&hook::handle_disconnect, ev);
 
 	dispatch(ev.server->get_id(), /* origin */ "", /* channel */ "",
 		[=] (plugin&) -> std::string {
@@ -144,6 +147,7 @@
 		{ "origin",     ev.origin               },
 		{ "channel",    ev.channel              }
 	}));
+	bot_.get_hooks().dispatch(&hook::handle_invite, ev);
 
 	dispatch(ev.server->get_id(), ev.origin, ev.channel,
 		[=] (plugin&) -> std::string {
@@ -167,6 +171,7 @@
 		{ "origin",     ev.origin               },
 		{ "channel",    ev.channel              }
 	}));
+	bot_.get_hooks().dispatch(&hook::handle_join, ev);
 
 	dispatch(ev.server->get_id(), ev.origin, ev.channel,
 		[=] (plugin&) -> std::string {
@@ -194,6 +199,7 @@
 		{ "target",     ev.target               },
 		{ "reason",     ev.reason               }
 	}));
+	bot_.get_hooks().dispatch(&hook::handle_kick, ev);
 
 	dispatch(ev.server->get_id(), ev.origin, ev.channel,
 		[=] (plugin&) -> std::string {
@@ -219,6 +225,7 @@
 		{ "channel",    ev.channel              },
 		{ "message",    ev.message              }
 	}));
+	bot_.get_hooks().dispatch(&hook::handle_message, ev);
 
 	dispatch(ev.server->get_id(), ev.origin, ev.channel,
 		[=] (plugin& plugin) -> std::string {
@@ -260,6 +267,7 @@
 		{ "target",     ev.channel              },
 		{ "message",    ev.message              }
 	}));
+	bot_.get_hooks().dispatch(&hook::handle_me, ev);
 
 	dispatch(ev.server->get_id(), ev.origin, ev.channel,
 		[=] (plugin&) -> std::string {
@@ -291,6 +299,7 @@
 		{ "user",       ev.user                 },
 		{ "mask",       ev.mask                 }
 	}));
+	bot_.get_hooks().dispatch(&hook::handle_mode, ev);
 
 	dispatch(ev.server->get_id(), ev.origin, /* channel */ "",
 		[=] (plugin &) -> std::string {
@@ -342,6 +351,7 @@
 		{ "origin",     ev.origin               },
 		{ "nickname",   ev.nickname             }
 	}));
+	bot_.get_hooks().dispatch(&hook::handle_nick, ev);
 
 	dispatch(ev.server->get_id(), ev.origin, /* channel */ "",
 		[=] (plugin&) -> std::string {
@@ -367,6 +377,7 @@
 		{ "channel",    ev.channel              },
 		{ "message",    ev.message              }
 	}));
+	bot_.get_hooks().dispatch(&hook::handle_notice, ev);
 
 	dispatch(ev.server->get_id(), ev.origin, /* channel */ "",
 		[=] (plugin&) -> std::string {
@@ -392,6 +403,7 @@
 		{ "channel",    ev.channel              },
 		{ "reason",     ev.reason               }
 	}));
+	bot_.get_hooks().dispatch(&hook::handle_part, ev);
 
 	dispatch(ev.server->get_id(), ev.origin, ev.channel,
 		[=] (plugin&) -> std::string {
@@ -417,6 +429,7 @@
 		{ "channel",    ev.channel              },
 		{ "topic",      ev.topic                }
 	}));
+	bot_.get_hooks().dispatch(&hook::handle_topic, ev);
 
 	dispatch(ev.server->get_id(), ev.origin, ev.channel,
 		[=] (plugin&) -> std::string {
--- a/libirccd-daemon/irccd/daemon/transport_command.cpp	Sun Sep 01 17:23:37 2019 +0200
+++ b/libirccd-daemon/irccd/daemon/transport_command.cpp	Thu Sep 05 13:39:32 2019 +0200
@@ -21,6 +21,7 @@
 #include <irccd/string_util.hpp>
 
 #include "bot.hpp"
+#include "hook_service.hpp"
 #include "plugin.hpp"
 #include "plugin_service.hpp"
 #include "rule.hpp"
@@ -93,6 +94,9 @@
 auto transport_command::registry() noexcept -> const std::vector<constructor>&
 {
 	static const std::vector<transport_command::constructor> list{
+		bind<hook_add_command>(),
+		bind<hook_list_command>(),
+		bind<hook_remove_command>(),
 		bind<plugin_config_command>(),
 		bind<plugin_info_command>(),
 		bind<plugin_list_command>(),
@@ -126,6 +130,84 @@
 	return list;
 }
 
+// {{{ hook_add_command
+
+auto hook_add_command::get_name() const noexcept -> std::string_view
+{
+	return "hook-add";
+}
+
+void hook_add_command::exec(bot& bot, transport_client& client, const document& args)
+{
+	const auto id = args.get<std::string>("id");
+	const auto path = args.get<std::string>("path");
+
+	if (!id || !string_util::is_identifier(*id))
+		throw hook_error(hook_error::invalid_identifier, id.value_or(""));
+	if (!path || path->empty())
+		throw hook_error(hook_error::invalid_path, *id, path.value_or(""));
+
+	// The add function may throws already_exists
+	bot.get_hooks().add(hook(*id, *path));
+	client.success("hook-add");
+}
+
+// }}}
+
+// {{{ hook_list_command
+
+auto hook_list_command::get_name() const noexcept -> std::string_view
+{
+	return "hook-list";
+}
+
+void hook_list_command::exec(bot& bot, transport_client& client, const document&)
+{
+	auto array = nlohmann::json::array();
+
+	for (const auto& hook : bot.get_hooks().list())
+		array.push_back(nlohmann::json::object({
+			{ "id",         hook.get_id()   },
+			{ "path",       hook.get_path() }
+		}));
+
+	client.write({
+		{ "command",    "hook-list"             },
+		{ "list",       std::move(array)        }
+	});
+}
+
+// }}}
+
+// {{{ hook_remove_command
+
+auto hook_remove_command::get_name() const noexcept -> std::string_view
+{
+	return "hook-remove";
+}
+
+void hook_remove_command::exec(bot& bot, transport_client& client, const document& args)
+{
+	const auto id = args.get<std::string>("id");
+
+	if (!id || !string_util::is_identifier(*id))
+		throw hook_error(hook_error::invalid_identifier, id.value_or(""));
+
+	// We don't use hook_service::remove because it never fails.
+	auto& hooks = bot.get_hooks().list();
+	auto it = std::find_if(hooks.begin(), hooks.end(), [&] (const auto& hook) {
+		return hook.get_id() == *id;
+	});
+
+	if (it == hooks.end())
+		throw hook_error(hook_error::not_found, *id);
+
+	hooks.erase(it);
+	client.success("hook-remove");
+}
+
+// }}}
+
 // {{{ plugin_config_command
 
 auto plugin_config_command::get_name() const noexcept -> std::string_view
--- a/libirccd-daemon/irccd/daemon/transport_command.hpp	Sun Sep 01 17:23:37 2019 +0200
+++ b/libirccd-daemon/irccd/daemon/transport_command.hpp	Thu Sep 05 13:39:32 2019 +0200
@@ -91,6 +91,80 @@
 
 // }}}
 
+// {{{ hook_add_command
+
+/**
+ * \brief Implementation of hook-add transport command.
+ * \ingroup daemon-transport-commands
+ *
+ * Replies:
+ *
+ * - hook_error::already_exists
+ * - hook_error::invalid_identifier
+ * - hook_error::invalid_path
+ */
+class hook_add_command : public transport_command {
+public:
+	/**
+	 * \copydoc transport_command::get_name
+	 */
+	auto get_name() const noexcept -> std::string_view override;
+
+	/**
+	 * \copydoc transport_command::exec
+	 */
+	void exec(bot& bot, transport_client& client, const document& args) override;
+};
+
+// }}}
+
+// {{{ hook_list_command
+
+/**
+ * \brief Implementation of hook-list transport command.
+ * \ingroup daemon-transport-commands
+ */
+class hook_list_command : public transport_command {
+public:
+	/**
+	 * \copydoc transport_command::get_name
+	 */
+	auto get_name() const noexcept -> std::string_view override;
+
+	/**
+	 * \copydoc transport_command::exec
+	 */
+	void exec(bot& bot, transport_client& client, const document& args) override;
+};
+
+// }}}
+
+// {{{ hook_remove_command
+
+/**
+ * \brief Implementation of hook-remove transport command.
+ * \ingroup daemon-transport-commands
+ *
+ * Replies:
+ *
+ * - hook_error::invalid_identifier
+ * - hook_error::not_found
+ */
+class hook_remove_command : public transport_command {
+public:
+	/**
+	 * \copydoc transport_command::get_name
+	 */
+	auto get_name() const noexcept -> std::string_view override;
+
+	/**
+	 * \copydoc transport_command::exec
+	 */
+	void exec(bot& bot, transport_client& client, const document& args) override;
+};
+
+// }}}
+
 // {{{ plugin_config_command
 
 /**
--- a/libirccd-test/irccd/test/cli_fixture.hpp	Sun Sep 01 17:23:37 2019 +0200
+++ b/libirccd-test/irccd/test/cli_fixture.hpp	Thu Sep 05 13:39:32 2019 +0200
@@ -31,6 +31,7 @@
 #include <boost/asio.hpp>
 
 #include <irccd/daemon/bot.hpp>
+#include <irccd/daemon/hook_service.hpp>
 #include <irccd/daemon/plugin_service.hpp>
 #include <irccd/daemon/rule_service.hpp>
 #include <irccd/daemon/server_service.hpp>
--- a/libirccd-test/irccd/test/command_fixture.hpp	Sun Sep 01 17:23:37 2019 +0200
+++ b/libirccd-test/irccd/test/command_fixture.hpp	Thu Sep 05 13:39:32 2019 +0200
@@ -28,6 +28,7 @@
 #include <irccd/daemon/rule_service.hpp>
 #include <irccd/daemon/server_service.hpp>
 #include <irccd/daemon/transport_client.hpp>
+#include <irccd/daemon/hook_service.hpp>
 
 #include "irccd_fixture.hpp"
 #include "mock_server.hpp"
@@ -58,6 +59,7 @@
 	 * \brief The fake transport_client stream.
 	 */
 	std::shared_ptr<mock_stream> stream_;
+
 	/**
 	 * \brief Client sending request.
 	 */
--- a/man/irccd.1	Sun Sep 01 17:23:37 2019 +0200
+++ b/man/irccd.1	Thu Sep 05 13:39:32 2019 +0200
@@ -61,7 +61,7 @@
 .Sh PLUGINS
 The
 .Nm
-program can runs plugins once IRC events are received. For example, if someone
+program can run plugins once IRC events are received. For example, if someone
 sends you a private message plugins will be invoked with that event. Both native
 plugins written in C++ and Javascript are supported (if enabled at compile
 time).
@@ -173,6 +173,38 @@
 See additional documentation in their own manual page in the form
 .Xr irccd-plugin-name 7
 where name is the actual plugin name.
+.\" HOOKS
+.Sh HOOKS
+Hooks are a different and more lightweight approach to plugins, they are
+executed upon incoming events and spawned each time a new event arrives.
+.Pp
+In contrast to plugins, differences are:
+.Pp
+.Bl -bullet -compact
+.It
+Hooks can not be filtered with rules.
+.It
+Hooks does not support all events. These events are not supported:
+.Em onLoad , onUnload , onReload , onCommand , onNames , onWhois .
+.It
+Hooks can be written in any language.
+.It
+Execution may be slower since scripting languages require to fire up the
+interpreter each time a new event is available.
+.El
+.Pp
+Each hook will receive as positional argument the event name (similar to plugin
+events) and the event arguments.
+.Pp
+An example of hook in shell script is available as
+.Pa @CMAKE_INSTALL_FULL_DOCDIR@/examples/sample-hook.sh
+file.
+.Pp
+See also the section
+.Va [hooks]
+in
+.Xr irccd.conf 5
+manual page to enable hooks.
 .\" TRANSPORTS
 .Sh TRANSPORTS
 The daemon can be controlled at runtime using the dedicated
--- a/man/irccd.conf.5	Sun Sep 01 17:23:37 2019 +0200
+++ b/man/irccd.conf.5	Thu Sep 05 13:39:32 2019 +0200
@@ -208,6 +208,7 @@
 The section is redefinable per plugin basis using the
 .Va [paths.<plugin>]
 syntax.
+.\" [plugins]
 .Ss plugins
 This section is used to load plugins.
 .Pp
@@ -216,6 +217,10 @@
 path (including the .js extension).
 .Pp
 Warning: remember to add an empty string for searching plugins.
+.\" [hooks]
+.Ss hooks
+This sections stores every hooks in key-value pairs. The option key denotes the
+hook id and the value must be a path to the actual hook file.
 .\" [transport]
 .Ss transport
 This section defines transports that are used to communicate through clients
@@ -355,6 +360,11 @@
 channels = "#staff"
 plugins = "reboot"
 action = accept
+
+# Example of hooks
+# This create an hook named "mail" with the given path.
+[hooks]
+mail = "/path/to/mail.py"
 .Ed
 .\" SEE ALSO
 .Sh SEE ALSO
--- a/man/irccdctl.1	Sun Sep 01 17:23:37 2019 +0200
+++ b/man/irccdctl.1	Thu Sep 05 13:39:32 2019 +0200
@@ -22,8 +22,20 @@
 .Nd irccd controller agent
 .\" SYNOPSIS
 .Sh SYNOPSIS
+.\" hook-add
 .Nm
+.Cm hook-add
+.Ar id
+.Ar path
+.\" hook-list
+.Nm
+.Cm hook-list
+.\" hook-remove
+.Nm
+.Cm hook-remove
+.Ar id
 .\" plugin-config
+.Nm
 .Cm plugin-config
 .Ar id
 .Op Ar variable
@@ -223,6 +235,20 @@
 .\" COMMANDS
 .Sh COMMANDS
 .Bl -tag -width xxxxxxxx-yyyyyyyyy
+.\" hook-add
+.It Cm hook-add
+Add a new hook with
+.Ar id
+as unique identifier and
+.Ar path
+as local path (on the machine where irccd is running).
+.\" hook-list
+.It Cm hook-list
+List active hooks.
+.\" hook-remove
+.It Cm hook-remove
+Remove a hook with identifier
+.Ar id .
 .\" plugin-config
 .It Cm plugin-config
 Manipulate a configuration variable for the plugin specified by
--- a/tests/src/irccdctl/CMakeLists.txt	Sun Sep 01 17:23:37 2019 +0200
+++ b/tests/src/irccdctl/CMakeLists.txt	Thu Sep 05 13:39:32 2019 +0200
@@ -25,6 +25,10 @@
 	add_subdirectory(cli-plugin-unload)
 endif ()
 
+add_subdirectory(cli-hook-add)
+add_subdirectory(cli-hook-list)
+add_subdirectory(cli-hook-remove)
+
 add_subdirectory(cli-rule-add)
 add_subdirectory(cli-rule-edit)
 add_subdirectory(cli-rule-info)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/src/irccdctl/cli-hook-add/CMakeLists.txt	Thu Sep 05 13:39:32 2019 +0200
@@ -0,0 +1,25 @@
+#
+# CMakeLists.txt -- CMake build system for irccd
+#
+# Copyright (c) 2013-2019 David Demelier <markand@malikania.fr>
+#
+# Permission to use, copy, modify, and/or distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+#
+
+irccd_define_test(
+	NAME cli-hook-add
+	SOURCES main.cpp
+	LIBRARIES libirccd
+	DEPENDS irccd irccdctl
+	FLAGS IRCCDCTL_EXECUTABLE="$<TARGET_FILE:irccdctl>"
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/src/irccdctl/cli-hook-add/main.cpp	Thu Sep 05 13:39:32 2019 +0200
@@ -0,0 +1,117 @@
+/*
+ * main.cpp -- test irccdctl hook-add
+ *
+ * Copyright (c) 2013-2019 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#define BOOST_TEST_MODULE "irccdctl hook-add"
+#include <boost/test/unit_test.hpp>
+
+#include <irccd/test/cli_fixture.hpp>
+
+using namespace irccd::test;
+
+using irccd::daemon::hook;
+
+namespace irccd {
+
+namespace {
+
+class hook_add_fixture : public cli_fixture {
+public:
+	hook_add_fixture()
+		: cli_fixture(IRCCDCTL_EXECUTABLE)
+	{
+	}
+};
+
+BOOST_FIXTURE_TEST_SUITE(hook_add_suite, hook_add_fixture)
+
+BOOST_AUTO_TEST_CASE(basic)
+{
+	start();
+
+	// true -> /bin/true
+	{
+		const auto [code, out, err] = exec({ "hook-add", "true", "/bin/true" });
+
+		BOOST_TEST(!code);
+		BOOST_TEST(out.size() == 0U);
+		BOOST_TEST(err.size() == 0U);
+	}
+
+	// false -> /bin/false
+	{
+		const auto [code, out, err] = exec({ "hook-add", "false", "/bin/false" });
+
+		BOOST_TEST(!code);
+		BOOST_TEST(out.size() == 0U);
+		BOOST_TEST(err.size() == 0U);
+	}
+
+	BOOST_TEST(bot_.get_hooks().list().size() == 2U);
+	BOOST_TEST(bot_.get_hooks().list()[0].get_id() == "true");
+	BOOST_TEST(bot_.get_hooks().list()[0].get_path() == "/bin/true");
+	BOOST_TEST(bot_.get_hooks().list()[1].get_id() == "false");
+	BOOST_TEST(bot_.get_hooks().list()[1].get_path() == "/bin/false");
+}
+
+BOOST_AUTO_TEST_SUITE(errors)
+
+BOOST_AUTO_TEST_CASE(invalid_identifier)
+{
+	start();
+
+	const auto [code, out, err] = exec({ "hook-add", "#@#@", "/bin/true" });
+
+	BOOST_TEST(code);
+	BOOST_TEST(out.size() == 0U);
+	BOOST_TEST(err.size() == 1U);
+	BOOST_TEST(err[0] == "abort: invalid hook identifier");
+}
+
+BOOST_AUTO_TEST_CASE(invalid_path)
+{
+	start();
+
+	const auto [code, out, err] = exec({ "hook-add", "true", "\"\"" });
+
+	BOOST_TEST(code);
+	BOOST_TEST(out.size() == 0U);
+	BOOST_TEST(err.size() == 1U);
+	BOOST_TEST(err[0] == "abort: invalid path given");
+}
+
+BOOST_AUTO_TEST_CASE(already_exists)
+{
+	bot_.get_hooks().add(hook("true", "/bin/true"));
+
+	start();
+
+	const auto [code, out, err] = exec({ "hook-add", "true", "/bin/true" });
+
+	BOOST_TEST(code);
+	BOOST_TEST(out.size() == 0U);
+	BOOST_TEST(err.size() == 1U);
+	BOOST_TEST(err[0] == "abort: hook already exists");
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+
+BOOST_AUTO_TEST_SUITE_END()
+
+} // !namespace
+
+} // !irccd
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/src/irccdctl/cli-hook-list/CMakeLists.txt	Thu Sep 05 13:39:32 2019 +0200
@@ -0,0 +1,25 @@
+#
+# CMakeLists.txt -- CMake build system for irccd
+#
+# Copyright (c) 2013-2019 David Demelier <markand@malikania.fr>
+#
+# Permission to use, copy, modify, and/or distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+#
+
+irccd_define_test(
+	NAME cli-hook-list
+	SOURCES main.cpp
+	LIBRARIES libirccd
+	DEPENDS irccd irccdctl
+	FLAGS IRCCDCTL_EXECUTABLE="$<TARGET_FILE:irccdctl>"
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/src/irccdctl/cli-hook-list/main.cpp	Thu Sep 05 13:39:32 2019 +0200
@@ -0,0 +1,61 @@
+/*
+ * main.cpp -- test irccdctl hook-list
+ *
+ * Copyright (c) 2013-2019 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#define BOOST_TEST_MODULE "irccdctl hook-list"
+#include <boost/test/unit_test.hpp>
+
+#include <irccd/test/cli_fixture.hpp>
+
+using namespace irccd::test;
+
+using irccd::daemon::hook;
+
+namespace irccd {
+
+namespace {
+
+class hook_list_fixture : public cli_fixture {
+public:
+	hook_list_fixture()
+		: cli_fixture(IRCCDCTL_EXECUTABLE)
+	{
+		bot_.get_hooks().add(hook("true", "/bin/true"));
+		bot_.get_hooks().add(hook("false", "/bin/false"));
+	}
+};
+
+BOOST_FIXTURE_TEST_SUITE(hook_list_suite, hook_list_fixture)
+
+BOOST_AUTO_TEST_CASE(basic)
+{
+	start();
+
+	const auto [code, out, err] = exec({ "hook-list" });
+
+	BOOST_TEST(!code);
+	BOOST_TEST(out.size() == 2U);
+	BOOST_TEST(err.size() == 0U);
+	BOOST_TEST(out[0] == "true             /bin/true");
+	BOOST_TEST(out[1] == "false            /bin/false");
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+
+} // !namespace
+
+} // !irccd
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/src/irccdctl/cli-hook-remove/CMakeLists.txt	Thu Sep 05 13:39:32 2019 +0200
@@ -0,0 +1,25 @@
+#
+# CMakeLists.txt -- CMake build system for irccd
+#
+# Copyright (c) 2013-2019 David Demelier <markand@malikania.fr>
+#
+# Permission to use, copy, modify, and/or distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+#
+
+irccd_define_test(
+	NAME cli-hook-remove
+	SOURCES main.cpp
+	LIBRARIES libirccd
+	DEPENDS irccd irccdctl
+	FLAGS IRCCDCTL_EXECUTABLE="$<TARGET_FILE:irccdctl>"
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/src/irccdctl/cli-hook-remove/main.cpp	Thu Sep 05 13:39:32 2019 +0200
@@ -0,0 +1,90 @@
+/*
+ * main.cpp -- test irccdctl hook-remove
+ *
+ * Copyright (c) 2013-2019 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#define BOOST_TEST_MODULE "irccdctl hook-remove"
+#include <boost/test/unit_test.hpp>
+
+#include <irccd/test/cli_fixture.hpp>
+
+using namespace irccd::test;
+
+using irccd::daemon::hook;
+
+namespace irccd {
+
+namespace {
+
+class hook_remove_fixture : public cli_fixture {
+public:
+	hook_remove_fixture()
+		: cli_fixture(IRCCDCTL_EXECUTABLE)
+	{
+		bot_.get_hooks().add(hook("true", "/bin/true"));
+		bot_.get_hooks().add(hook("false", "/bin/false"));
+	}
+};
+
+BOOST_FIXTURE_TEST_SUITE(hook_remove_suite, hook_remove_fixture)
+
+BOOST_AUTO_TEST_CASE(basic)
+{
+	start();
+
+	const auto [code, out, err] = exec({ "hook-remove", "false" });
+
+	BOOST_TEST(!code);
+	BOOST_TEST(out.size() == 0U);
+	BOOST_TEST(err.size() == 0U);
+	BOOST_TEST(bot_.get_hooks().list().size() == 1U);
+	BOOST_TEST(bot_.get_hooks().list()[0].get_id() == "true");
+	BOOST_TEST(bot_.get_hooks().list()[0].get_path() == "/bin/true");
+}
+
+BOOST_AUTO_TEST_SUITE(errors)
+
+BOOST_AUTO_TEST_CASE(invalid_identifier)
+{
+	start();
+
+	const auto [code, out, err] = exec({ "hook-remove", "#@#@" });
+
+	BOOST_TEST(code);
+	BOOST_TEST(out.size() == 0U);
+	BOOST_TEST(err.size() == 1U);
+	BOOST_TEST(err[0] == "abort: invalid hook identifier");
+}
+
+BOOST_AUTO_TEST_CASE(not_found)
+{
+	start();
+
+	const auto [code, out, err] = exec({ "hook-remove", "nonexistent" });
+
+	BOOST_TEST(code);
+	BOOST_TEST(out.size() == 0U);
+	BOOST_TEST(err.size() == 1U);
+	BOOST_TEST(err[0] == "abort: hook not found");
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+
+BOOST_AUTO_TEST_SUITE_END()
+
+} // !namespace
+
+} // !irccd
--- a/tests/src/libirccd-daemon/CMakeLists.txt	Sun Sep 01 17:23:37 2019 +0200
+++ b/tests/src/libirccd-daemon/CMakeLists.txt	Thu Sep 05 13:39:32 2019 +0200
@@ -16,18 +16,24 @@
 # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 #
 
+add_subdirectory(command-hook-add)
+add_subdirectory(command-hook-list)
+add_subdirectory(command-hook-remove)
+
 add_subdirectory(command-plugin-config)
 add_subdirectory(command-plugin-info)
 add_subdirectory(command-plugin-list)
 add_subdirectory(command-plugin-load)
 add_subdirectory(command-plugin-reload)
 add_subdirectory(command-plugin-unload)
+
 add_subdirectory(command-rule-add)
 add_subdirectory(command-rule-edit)
 add_subdirectory(command-rule-info)
 add_subdirectory(command-rule-list)
 add_subdirectory(command-rule-move)
 add_subdirectory(command-rule-remove)
+
 add_subdirectory(command-server-connect)
 add_subdirectory(command-server-disconnect)
 add_subdirectory(command-server-info)
@@ -45,12 +51,13 @@
 add_subdirectory(command-server-topic)
 
 add_subdirectory(dynlib-plugin)
+add_subdirectory(hook)
 add_subdirectory(irc)
 add_subdirectory(logger)
 add_subdirectory(plugin-service)
 add_subdirectory(rule-service)
 add_subdirectory(rule-util)
 add_subdirectory(server)
+add_subdirectory(server-service)
 add_subdirectory(server-util)
-add_subdirectory(server-service)
 add_subdirectory(transports)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/src/libirccd-daemon/command-hook-add/CMakeLists.txt	Thu Sep 05 13:39:32 2019 +0200
@@ -0,0 +1,23 @@
+#
+# CMakeLists.txt -- CMake build system for irccd
+#
+# Copyright (c) 2013-2019 David Demelier <markand@malikania.fr>
+#
+# Permission to use, copy, modify, and/or distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+#
+
+irccd_define_test(
+	NAME command-hook-add
+	SOURCES main.cpp
+	LIBRARIES libirccd libirccd-ctl
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/src/libirccd-daemon/command-hook-add/main.cpp	Thu Sep 05 13:39:32 2019 +0200
@@ -0,0 +1,106 @@
+/*
+ * main.cpp -- test hook-add remote command
+ *
+ * Copyright (c) 2013-2019 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#define BOOST_TEST_MODULE "hook-add"
+#include <boost/test/unit_test.hpp>
+
+#include <irccd/test/command_fixture.hpp>
+
+using std::string;
+
+using irccd::daemon::hook;
+using irccd::daemon::hook_error;
+
+namespace irccd {
+
+namespace {
+
+BOOST_FIXTURE_TEST_SUITE(hook_add_fixture_suite, test::command_fixture)
+
+BOOST_AUTO_TEST_CASE(basic)
+{
+	request({
+		{ "command",    "hook-add"      },
+		{ "id",         "true"          },
+		{ "path",       "/bin/true"     }
+	});
+	request({
+		{ "command",    "hook-add"      },
+		{ "id",         "false"         },
+		{ "path",       "/bin/false"    }
+	});
+
+	BOOST_TEST(bot_.get_hooks().list().size() == 2U);
+	BOOST_TEST(bot_.get_hooks().list()[0].get_id() == "true");
+	BOOST_TEST(bot_.get_hooks().list()[0].get_path() == "/bin/true");
+	BOOST_TEST(bot_.get_hooks().list()[1].get_id() == "false");
+	BOOST_TEST(bot_.get_hooks().list()[1].get_path() == "/bin/false");
+}
+
+BOOST_AUTO_TEST_SUITE(errors)
+
+BOOST_AUTO_TEST_CASE(invalid_identifier)
+{
+	const auto json = request({
+		{ "command",    "hook-add"      },
+		{ "id"    ,     "#@#@"          }
+	});
+
+	BOOST_TEST(json.size() == 4U);
+	BOOST_TEST(json["command"].get<string>() == "hook-add");
+	BOOST_TEST(json["error"].get<int>() == hook_error::invalid_identifier);
+	BOOST_TEST(json["errorCategory"].get<string>() == "hook");
+}
+
+BOOST_AUTO_TEST_CASE(invalid_path)
+{
+	const auto json = request({
+		{ "command",    "hook-add"      },
+		{ "id",         "true"          },
+		{ "path",       1234            }
+	});
+
+	BOOST_TEST(json.size() == 4U);
+	BOOST_TEST(json["command"].get<string>() == "hook-add");
+	BOOST_TEST(json["error"].get<int>() == hook_error::invalid_path);
+	BOOST_TEST(json["errorCategory"].get<string>() == "hook");
+}
+
+BOOST_AUTO_TEST_CASE(already_exists)
+{
+	bot_.get_hooks().add(hook("true", "/bin/true"));
+
+	const auto json = request({
+		{ "command",    "hook-add"      },
+		{ "id",         "true"          },
+		{ "path",       "/bin/true"     }
+	});
+
+	BOOST_TEST(json.size() == 4U);
+	BOOST_TEST(json["command"].get<string>() == "hook-add");
+	BOOST_TEST(json["error"].get<int>() == hook_error::already_exists);
+	BOOST_TEST(json["errorCategory"].get<string>() == "hook");
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+
+BOOST_AUTO_TEST_SUITE_END()
+
+} // !namespace
+
+} // !irccd
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/src/libirccd-daemon/command-hook-list/CMakeLists.txt	Thu Sep 05 13:39:32 2019 +0200
@@ -0,0 +1,23 @@
+#
+# CMakeLists.txt -- CMake build system for irccd
+#
+# Copyright (c) 2013-2019 David Demelier <markand@malikania.fr>
+#
+# Permission to use, copy, modify, and/or distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+#
+
+irccd_define_test(
+	NAME command-hook-list
+	SOURCES main.cpp
+	LIBRARIES libirccd libirccd-ctl
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/src/libirccd-daemon/command-hook-list/main.cpp	Thu Sep 05 13:39:32 2019 +0200
@@ -0,0 +1,54 @@
+/*
+ * main.cpp -- test hook-list remote command
+ *
+ * Copyright (c) 2013-2019 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#define BOOST_TEST_MODULE "hook-list"
+#include <boost/test/unit_test.hpp>
+
+#include <irccd/test/command_fixture.hpp>
+
+using std::string;
+
+using irccd::daemon::hook;
+
+namespace irccd {
+
+namespace {
+
+BOOST_FIXTURE_TEST_SUITE(hook_list_fixture_suite, test::command_fixture)
+
+BOOST_AUTO_TEST_CASE(basic)
+{
+	bot_.get_hooks().add(hook("true", "/bin/true"));
+	bot_.get_hooks().add(hook("false", "/bin/false"));
+
+	const auto json = request({{"command", "hook-list"}});
+
+	BOOST_TEST(json.size() == 2U);
+	BOOST_TEST(json["command"].get<string>() == "hook-list");
+	BOOST_TEST(json["list"].size() == 2U);
+	BOOST_TEST(json["list"][0]["id"].get<string>() == "true");
+	BOOST_TEST(json["list"][0]["path"].get<string>() == "/bin/true");
+	BOOST_TEST(json["list"][1]["id"].get<string>() == "false");
+	BOOST_TEST(json["list"][1]["path"].get<string>() == "/bin/false");
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+
+} // !namespace
+
+} // !irccd
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/src/libirccd-daemon/command-hook-remove/CMakeLists.txt	Thu Sep 05 13:39:32 2019 +0200
@@ -0,0 +1,23 @@
+#
+# CMakeLists.txt -- CMake build system for irccd
+#
+# Copyright (c) 2013-2019 David Demelier <markand@malikania.fr>
+#
+# Permission to use, copy, modify, and/or distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+#
+
+irccd_define_test(
+	NAME command-hook-remove
+	SOURCES main.cpp
+	LIBRARIES libirccd libirccd-ctl
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/src/libirccd-daemon/command-hook-remove/main.cpp	Thu Sep 05 13:39:32 2019 +0200
@@ -0,0 +1,94 @@
+/*
+ * main.cpp -- test hook-remove remote command
+ *
+ * Copyright (c) 2013-2019 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#define BOOST_TEST_MODULE "hook-remove"
+#include <boost/test/unit_test.hpp>
+
+#include <irccd/test/command_fixture.hpp>
+
+using std::string;
+
+using irccd::daemon::hook;
+using irccd::daemon::hook_error;
+
+namespace irccd {
+
+namespace {
+
+BOOST_FIXTURE_TEST_SUITE(hook_remove_fixture_suite, test::command_fixture)
+
+BOOST_AUTO_TEST_CASE(basic)
+{
+	bot_.get_hooks().add(hook("true", "/bin/true"));
+	bot_.get_hooks().add(hook("false", "/bin/false"));
+
+	const auto json = request({
+		{ "command",    "hook-remove"   },
+		{ "id",         "false"         },
+	});
+
+	BOOST_TEST(json.size() == 1U);
+	BOOST_TEST(json["command"].get<string>() == "hook-remove");
+	BOOST_TEST(bot_.get_hooks().list().size() == 1U);
+	BOOST_TEST(bot_.get_hooks().list()[0].get_id() == "true");
+	BOOST_TEST(bot_.get_hooks().list()[0].get_path() == "/bin/true");
+}
+
+BOOST_AUTO_TEST_SUITE(errors)
+
+BOOST_AUTO_TEST_CASE(invalid_identifier)
+{
+	const auto json = request({
+		{ "command",    "hook-remove"   },
+		{ "action",     "#@#@"          }
+	});
+
+	BOOST_TEST(json.size() == 4U);
+	BOOST_TEST(json["command"].get<string>() == "hook-remove");
+	BOOST_TEST(json["error"].get<int>() == hook_error::invalid_identifier);
+	BOOST_TEST(json["errorCategory"].get<string>() == "hook");
+}
+
+BOOST_AUTO_TEST_CASE(not_found)
+{
+	request({
+		{ "command",    "hook-add"      },
+		{ "id",         "true"          },
+		{ "path",       "/bin/true"     }
+	});
+
+	stream_->clear();
+
+	const auto json = request({
+		{ "command",    "hook-remove"   },
+		{ "id",         "nonexistent"   },
+	});
+
+	BOOST_TEST(json.size() == 4U);
+	BOOST_TEST(json["command"].get<string>() == "hook-remove");
+	BOOST_TEST(json["error"].get<int>() == hook_error::not_found);
+	BOOST_TEST(json["errorCategory"].get<string>() == "hook");
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+
+BOOST_AUTO_TEST_SUITE_END()
+
+} // !namespace
+
+} // !irccd
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/src/libirccd-daemon/hook/CMakeLists.txt	Thu Sep 05 13:39:32 2019 +0200
@@ -0,0 +1,48 @@
+#
+# CMakeLists.txt -- CMake build system for irccd
+#
+# Copyright (c) 2013-2019 David Demelier <markand@malikania.fr>
+#
+# Permission to use, copy, modify, and/or distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+#
+
+find_package(Boost REQUIRED QUIET)
+
+add_executable(sample-hook sample_hook.cpp)
+
+set_target_properties(
+	sample-hook
+	PROPERTIES
+		PREFIX ""
+		FOLDER "test"
+		RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
+		LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
+)
+
+foreach (cfg ${CMAKE_CONFIGURATION_TYPES})
+	string(TOUPPER ${cfg} cfg)
+	set_target_properties(
+		sample-hook
+		PROPERTIES
+			RUNTIME_OUTPUT_DIRECTORY_${cfg} ${CMAKE_CURRENT_BINARY_DIR}
+			LIBRARY_OUTPUT_DIRECTORY_${cfg} ${CMAKE_CURRENT_BINARY_DIR}
+	)
+endforeach ()
+
+irccd_define_test(
+	NAME hook
+	SOURCES main.cpp
+	LIBRARIES libirccd libirccd-test
+	DEPENDS sample-hook
+	FLAGS HOOK_FILE="$<TARGET_FILE:sample-hook>"
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/src/libirccd-daemon/hook/main.cpp	Thu Sep 05 13:39:32 2019 +0200
@@ -0,0 +1,280 @@
+/*
+ * main.cpp -- test hook functions
+ *
+ * Copyright (c) 2013-2019 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#define BOOST_TEST_MODULE "hook"
+#include <boost/test/unit_test.hpp>
+
+#include <string>
+#include <vector>
+
+#include <irccd/daemon/bot.hpp>
+#include <irccd/daemon/hook.hpp>
+#include <irccd/daemon/logger.hpp>
+
+#include <irccd/test/mock_server.hpp>
+
+using std::string;
+using std::vector;
+using std::shared_ptr;
+using std::make_unique;
+using std::make_shared;
+
+using boost::asio::io_context;
+
+using irccd::daemon::logger::sink;
+
+using irccd::test::mock_server;
+
+namespace irccd::daemon {
+
+namespace {
+
+/*
+ * Since stdout/stderr from the hook is logger through the irccd's logger, we'll
+ * gonna store every message logged into it and compare if the values are
+ * appropriate.
+ */
+class memory_sink : public logger::sink {
+public:
+	using list = vector<string>;
+
+private:
+	list debug_;
+	list warning_;
+	list info_;
+
+public:
+	auto get_debug() const noexcept -> const list&;
+	auto get_info() const noexcept -> const list&;
+	auto get_warning() const noexcept -> const list&;
+	void write_debug(const std::string& line) override;
+	void write_info(const std::string& line) override;
+	void write_warning(const std::string& line) override;
+};
+
+auto memory_sink::get_debug() const noexcept -> const list&
+{
+	return debug_;
+}
+
+auto memory_sink::get_info() const noexcept -> const list&
+{
+	return info_;
+}
+
+auto memory_sink::get_warning() const noexcept -> const list&
+{
+	return warning_;
+}
+
+void memory_sink::write_debug(const std::string& line)
+{
+	debug_.push_back(line);
+}
+
+void memory_sink::write_info(const std::string& line)
+{
+	info_.push_back(line);
+}
+
+void memory_sink::write_warning(const std::string& line)
+{
+	warning_.push_back(line);
+}
+
+class hook_fixture {
+protected:
+	io_context io_;
+	bot bot_{io_};
+	hook hook_{"test", HOOK_FILE};
+	memory_sink* sink_{nullptr};
+	shared_ptr<mock_server> server_;
+
+public:
+	hook_fixture();
+};
+
+hook_fixture::hook_fixture()
+	: server_(make_shared<mock_server>(io_, "test"))
+{
+	auto sink = make_unique<memory_sink>();
+
+	sink_ = sink.get();
+	bot_.set_log(std::move(sink));
+	bot_.get_log().set_verbose(true);
+}
+
+BOOST_FIXTURE_TEST_SUITE(hook_fixture_suite, hook_fixture)
+
+BOOST_AUTO_TEST_CASE(connect)
+{
+	hook_.handle_connect(bot_, {server_});
+
+	BOOST_TEST(sink_->get_info().size() == 2U);
+	BOOST_TEST(sink_->get_warning().empty());
+	BOOST_TEST(sink_->get_info()[0] == "hook test: event:   onConnect");
+	BOOST_TEST(sink_->get_info()[1] == "hook test: server:  test");
+}
+
+BOOST_AUTO_TEST_CASE(disconnect)
+{
+	hook_.handle_disconnect(bot_, {server_});
+
+	BOOST_TEST(sink_->get_info().size() == 2U);
+	BOOST_TEST(sink_->get_warning().empty());
+	BOOST_TEST(sink_->get_info()[0] == "hook test: event:   onDisconnect");
+	BOOST_TEST(sink_->get_info()[1] == "hook test: server:  test");
+}
+
+BOOST_AUTO_TEST_CASE(invite)
+{
+	hook_.handle_invite(bot_, {server_, "jean", "#staff", "NiReaS"});
+
+	BOOST_TEST(sink_->get_info().size() == 5U);
+	BOOST_TEST(sink_->get_warning().empty());
+	BOOST_TEST(sink_->get_info()[0] == "hook test: event:   onInvite");
+	BOOST_TEST(sink_->get_info()[1] == "hook test: server:  test");
+	BOOST_TEST(sink_->get_info()[2] == "hook test: origin:  jean");
+	BOOST_TEST(sink_->get_info()[3] == "hook test: channel: #staff");
+	BOOST_TEST(sink_->get_info()[4] == "hook test: target:  NiReaS");
+}
+
+BOOST_AUTO_TEST_CASE(join)
+{
+	hook_.handle_join(bot_, {server_, "jean", "#staff"});
+
+	BOOST_TEST(sink_->get_info().size() == 4U);
+	BOOST_TEST(sink_->get_warning().empty());
+	BOOST_TEST(sink_->get_info()[0] == "hook test: event:   onJoin");
+	BOOST_TEST(sink_->get_info()[1] == "hook test: server:  test");
+	BOOST_TEST(sink_->get_info()[2] == "hook test: origin:  jean");
+	BOOST_TEST(sink_->get_info()[3] == "hook test: channel: #staff");
+}
+
+BOOST_AUTO_TEST_CASE(kick)
+{
+	hook_.handle_kick(bot_, {server_, "jean", "#staff", "NiReaS", "stop it"});
+
+	BOOST_TEST(sink_->get_info().size() == 6U);
+	BOOST_TEST(sink_->get_warning().empty());
+	BOOST_TEST(sink_->get_info()[0] == "hook test: event:   onKick");
+	BOOST_TEST(sink_->get_info()[1] == "hook test: server:  test");
+	BOOST_TEST(sink_->get_info()[2] == "hook test: origin:  jean");
+	BOOST_TEST(sink_->get_info()[3] == "hook test: channel: #staff");
+	BOOST_TEST(sink_->get_info()[4] == "hook test: target:  NiReaS");
+	BOOST_TEST(sink_->get_info()[5] == "hook test: reason:  stop it");
+}
+
+BOOST_AUTO_TEST_CASE(message)
+{
+	hook_.handle_message(bot_, {server_, "jean", "#staff", "coucou tout le monde"});
+
+	BOOST_TEST(sink_->get_info().size() == 5U);
+	BOOST_TEST(sink_->get_warning().empty());
+	BOOST_TEST(sink_->get_info()[0] == "hook test: event:   onMessage");
+	BOOST_TEST(sink_->get_info()[1] == "hook test: server:  test");
+	BOOST_TEST(sink_->get_info()[2] == "hook test: origin:  jean");
+	BOOST_TEST(sink_->get_info()[3] == "hook test: channel: #staff");
+	BOOST_TEST(sink_->get_info()[4] == "hook test: message: coucou tout le monde");
+}
+
+BOOST_AUTO_TEST_CASE(me)
+{
+	hook_.handle_me(bot_, {server_, "jean", "#staff", "coucou tout le monde"});
+
+	BOOST_TEST(sink_->get_info().size() == 5U);
+	BOOST_TEST(sink_->get_warning().empty());
+	BOOST_TEST(sink_->get_info()[0] == "hook test: event:   onMe");
+	BOOST_TEST(sink_->get_info()[1] == "hook test: server:  test");
+	BOOST_TEST(sink_->get_info()[2] == "hook test: origin:  jean");
+	BOOST_TEST(sink_->get_info()[3] == "hook test: channel: #staff");
+	BOOST_TEST(sink_->get_info()[4] == "hook test: message: coucou tout le monde");
+}
+
+BOOST_AUTO_TEST_CASE(mode)
+{
+	hook_.handle_mode(bot_, {server_, "jean", "#staff", "+o", "franck", "abc", "xyz" });
+
+	BOOST_TEST(sink_->get_info().size() == 8U);
+	BOOST_TEST(sink_->get_warning().empty());
+	BOOST_TEST(sink_->get_info()[0] == "hook test: event:   onMode");
+	BOOST_TEST(sink_->get_info()[1] == "hook test: server:  test");
+	BOOST_TEST(sink_->get_info()[2] == "hook test: origin:  jean");
+	BOOST_TEST(sink_->get_info()[3] == "hook test: channel: #staff");
+	BOOST_TEST(sink_->get_info()[4] == "hook test: mode:    +o");
+	BOOST_TEST(sink_->get_info()[5] == "hook test: limit:   franck");
+	BOOST_TEST(sink_->get_info()[6] == "hook test: user:    abc");
+	BOOST_TEST(sink_->get_info()[7] == "hook test: mask:    xyz");
+}
+
+BOOST_AUTO_TEST_CASE(nick)
+{
+	hook_.handle_nick(bot_, {server_, "jean", "doctor"});
+
+	BOOST_TEST(sink_->get_info().size() == 4U);
+	BOOST_TEST(sink_->get_warning().empty());
+	BOOST_TEST(sink_->get_info()[0] == "hook test: event:   onNick");
+	BOOST_TEST(sink_->get_info()[1] == "hook test: server:  test");
+	BOOST_TEST(sink_->get_info()[2] == "hook test: origin:  jean");
+	BOOST_TEST(sink_->get_info()[3] == "hook test: nick:    doctor");
+}
+
+BOOST_AUTO_TEST_CASE(notice)
+{
+	hook_.handle_notice(bot_, {server_, "jean", "#staff", "coucou tout le monde"});
+
+	BOOST_TEST(sink_->get_info().size() == 5U);
+	BOOST_TEST(sink_->get_warning().empty());
+	BOOST_TEST(sink_->get_info()[0] == "hook test: event:   onNotice");
+	BOOST_TEST(sink_->get_info()[1] == "hook test: server:  test");
+	BOOST_TEST(sink_->get_info()[2] == "hook test: origin:  jean");
+	BOOST_TEST(sink_->get_info()[3] == "hook test: channel: #staff");
+	BOOST_TEST(sink_->get_info()[4] == "hook test: message: coucou tout le monde");
+}
+
+BOOST_AUTO_TEST_CASE(part)
+{
+	hook_.handle_part(bot_, {server_, "jean", "#windows", "je n'aime pas ici"});
+
+	BOOST_TEST(sink_->get_info().size() == 5U);
+	BOOST_TEST(sink_->get_warning().empty());
+	BOOST_TEST(sink_->get_info()[0] == "hook test: event:   onPart");
+	BOOST_TEST(sink_->get_info()[1] == "hook test: server:  test");
+	BOOST_TEST(sink_->get_info()[2] == "hook test: origin:  jean");
+	BOOST_TEST(sink_->get_info()[3] == "hook test: channel: #windows");
+	BOOST_TEST(sink_->get_info()[4] == "hook test: reason:  je n'aime pas ici");
+}
+
+BOOST_AUTO_TEST_CASE(topic)
+{
+	hook_.handle_topic(bot_, {server_, "jean", "#windows", "attention Windows est un malware"});
+
+	BOOST_TEST(sink_->get_info().size() == 5U);
+	BOOST_TEST(sink_->get_warning().empty());
+	BOOST_TEST(sink_->get_info()[0] == "hook test: event:   onTopic");
+	BOOST_TEST(sink_->get_info()[1] == "hook test: server:  test");
+	BOOST_TEST(sink_->get_info()[2] == "hook test: origin:  jean");
+	BOOST_TEST(sink_->get_info()[3] == "hook test: channel: #windows");
+	BOOST_TEST(sink_->get_info()[4] == "hook test: topic:   attention Windows est un malware");
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+
+} // !namespace
+
+} // !irccd::daemon
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/src/libirccd-daemon/hook/sample_hook.cpp	Thu Sep 05 13:39:32 2019 +0200
@@ -0,0 +1,177 @@
+/*
+ * sample_hook.cpp -- sample hook for unit tests
+ *
+ * Copyright (c) 2013-2019 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <iostream>
+#include <functional>
+#include <string_view>
+#include <unordered_map>
+
+using std::function;
+using std::string_view;
+using std::unordered_map;
+using std::cerr;
+using std::cout;
+using std::endl;
+
+namespace {
+
+auto print(int argc, char** argv, int index) -> string_view
+{
+	return argc > index ? argv[index] : "";
+}
+
+void handle_onConnect(int argc, char** argv)
+{
+	cout << "event:   onConnect" << endl;
+	cout << "server:  " << print(argc, argv, 0) << endl;
+}
+
+void handle_onDisconnect(int argc, char** argv)
+{
+	cout << "event:   onDisconnect" << endl;
+	cout << "server:  " << print(argc, argv, 0) << endl;
+}
+
+void handle_onInvite(int argc, char** argv)
+{
+	cout << "event:   onInvite" << endl;
+	cout << "server:  " << print(argc, argv, 0) << endl;
+	cout << "origin:  " << print(argc, argv, 1) << endl;
+	cout << "channel: " << print(argc, argv, 2) << endl;
+	cout << "target:  " << print(argc, argv, 3) << endl;
+}
+
+void handle_onJoin(int argc, char** argv)
+{
+	cout << "event:   onJoin" << endl;
+	cout << "server:  " << print(argc, argv, 0) << endl;
+	cout << "origin:  " << print(argc, argv, 1) << endl;
+	cout << "channel: " << print(argc, argv, 2) << endl;
+}
+
+void handle_onKick(int argc, char** argv)
+{
+	cout << "event:   onKick" << endl;
+	cout << "server:  " << print(argc, argv, 0) << endl;
+	cout << "origin:  " << print(argc, argv, 1) << endl;
+	cout << "channel: " << print(argc, argv, 2) << endl;
+	cout << "target:  " << print(argc, argv, 3) << endl;
+	cout << "reason:  " << print(argc, argv, 4) << endl;
+}
+
+void handle_onMessage(int argc, char** argv)
+{
+	cout << "event:   onMessage" << endl;
+	cout << "server:  " << print(argc, argv, 0) << endl;
+	cout << "origin:  " << print(argc, argv, 1) << endl;
+	cout << "channel: " << print(argc, argv, 2) << endl;
+	cout << "message: " << print(argc, argv, 3) << endl;
+}
+
+void handle_onMe(int argc, char** argv)
+{
+	cout << "event:   onMe" << endl;
+	cout << "server:  " << print(argc, argv, 0) << endl;
+	cout << "origin:  " << print(argc, argv, 1) << endl;
+	cout << "channel: " << print(argc, argv, 2) << endl;
+	cout << "message: " << print(argc, argv, 3) << endl;
+}
+
+void handle_onMode(int argc, char** argv)
+{
+	cout << "event:   onMode" << endl;
+	cout << "server:  " << print(argc, argv, 0) << endl;
+	cout << "origin:  " << print(argc, argv, 1) << endl;
+	cout << "channel: " << print(argc, argv, 2) << endl;
+	cout << "mode:    " << print(argc, argv, 3) << endl;
+	cout << "limit:   " << print(argc, argv, 4) << endl;
+	cout << "user:    " << print(argc, argv, 5) << endl;
+	cout << "mask:    " << print(argc, argv, 6) << endl;
+}
+
+void handle_onNick(int argc, char** argv)
+{
+	cout << "event:   onNick" << endl;
+	cout << "server:  " << print(argc, argv, 0) << endl;
+	cout << "origin:  " << print(argc, argv, 1) << endl;
+	cout << "nick:    " << print(argc, argv, 2) << endl;
+}
+
+void handle_onNotice(int argc, char** argv)
+{
+	cout << "event:   onNotice" << endl;
+	cout << "server:  " << print(argc, argv, 0) << endl;
+	cout << "origin:  " << print(argc, argv, 1) << endl;
+	cout << "channel: " << print(argc, argv, 2) << endl;
+	cout << "message: " << print(argc, argv, 3) << endl;
+}
+
+void handle_onPart(int argc, char** argv)
+{
+	cout << "event:   onPart" << endl;
+	cout << "server:  " << print(argc, argv, 0) << endl;
+	cout << "origin:  " << print(argc, argv, 1) << endl;
+	cout << "channel: " << print(argc, argv, 2) << endl;
+	cout << "reason:  " << print(argc, argv, 3) << endl;
+}
+
+void handle_onTopic(int argc, char** argv)
+{
+	cout << "event:   onTopic" << endl;
+	cout << "server:  " << print(argc, argv, 0) << endl;
+	cout << "origin:  " << print(argc, argv, 1) << endl;
+	cout << "channel: " << print(argc, argv, 2) << endl;
+	cout << "topic:   " << print(argc, argv, 3) << endl;
+}
+
+unordered_map<string_view, function<void (int, char**)>> handlers{
+	{ "onConnect",    handle_onConnect          },
+	{ "onDisconnect", handle_onDisconnect       },
+	{ "onInvite",     handle_onInvite           },
+	{ "onJoin",       handle_onJoin             },
+	{ "onKick",       handle_onKick             },
+	{ "onMessage",    handle_onMessage          },
+	{ "onMe",         handle_onMe               },
+	{ "onMode",       handle_onMode             },
+	{ "onNick",       handle_onNick             },
+	{ "onNotice",     handle_onNotice           },
+	{ "onPart",       handle_onPart             },
+	{ "onTopic",      handle_onTopic            }
+};
+
+} // !namespace
+
+int main(int argc, char** argv)
+{
+	--argc;
+	++argv;
+
+	if (argc == 0) {
+		cerr << "abort: no command given" << endl;
+		return 1;
+	}
+
+	const auto handler = handlers.find(argv[0]);
+
+	if (handler == handlers.end()) {
+		cerr << "abort: unknown message hook: " << argv[0] << endl;
+		return 1;
+	}
+
+	handler->second(--argc, ++argv);
+}