Command Listeners
Running code before or after commands the game, the engine or other plugins own — and blocking them.
A listener is a callback on a command that already exists: jointeam, say,
kill, an engine command, another plugin's command. It runs around the
command rather than replacing it, and a pre listener decides whether the
command runs at all.
#include "source2mod/ISource2ModCommands.h"
// No spectators: refuse "jointeam 1".
REGISTER_CON_LISTENER("jointeam", [](const Source2ModCommandContext& context, const Source2ModCommandArgs& args, bool post) -> Action
{
if (args.ArgC() >= 2 && atoi(args.Arg(1)) == 1)
{
Reply(context, "Spectating is disabled on this server.");
return Action::Supersede;
}
return Action::Ignore;
}, false);public void OnPluginStart()
{
Source2ModCommands.RegisterConListener("jointeam", Listen_JoinTeam, false);
}
// No spectators: refuse "jointeam 1".
public Source2ModAction Listen_JoinTeam(Source2ModCommandContext context, Source2ModCommandArgs args, bool post)
{
char team[8];
args.Arg(1, team, sizeof(team));
if (args.ArgC() >= 2 && Source2ModUtils.StringToInt(team) == 1)
{
Reply(context, "Spectating is disabled on this server.");
return Action_Supersede;
}
return Action_Ignore;
}(Reply is the helper from the Commands page.)
The API
using CommandHandler = Source2ModCallback<Action(const Source2ModCommandContext&, const Source2ModCommandArgs&, bool post)>;
virtual Source2ModHookId RegisterConListener(const char* pchName, CommandHandler handler, bool post) = 0;
virtual bool UnregisterConListener(const char* pchName, const CommandHandler& handler, bool post) = 0;
virtual bool UnregisterConListener(Source2ModHookId id) = 0;
#define REGISTER_CON_LISTENER(pchName, handler, post) g_pSource2ModCommands->RegisterConListener(pchName, handler, post)pchName— the command to listen to, in lower case (the dispatcher lower-cases what was typed before it looks for listeners).post—falseto run before the command,trueto run after it. Register twice if you need both.- The handler is a lambda, a function or an object and a method
(
SOURCE2MOD_MEMBER(this, &MyPlugin::OnJoinTeam)); the core reads which plugin it belongs to off the handler, so there is no plugin id to pass. It gets the sameSource2ModCommandContextandSource2ModCommandArgsa command handler gets — see arguments and the caller. - In a script it is
Source2ModCommands.RegisterConListener(name, handler, post)with a namedpublicfunction that returns aSource2ModAction.
A plugin can register several listeners on the same command. They are called in
registration order. RegisterConListener returns a Source2ModHookId;
g_pSource2ModCommands->UnregisterConListener(id) drops that one listener, and
UnregisterConListener(name, handler, post) drops a function or
object-and-method listener passed again (a lambda only goes by id). A script
unregisters by id only. Unloading the plugin drops all of them.
// in Load()
REGISTER_CON_LISTENER("say", OnPlayerChat, false);
// later, to turn the filter off
g_pSource2ModCommands->UnregisterConListener("say", OnPlayerChat, false);int g_ChatFilter;
// in OnPluginStart()
g_ChatFilter = Source2ModCommands.RegisterConListener("say", OnPlayerChat, false);
// later, to turn the filter off
Source2ModCommands.UnregisterConListener(g_ChatFilter);Return values
Listeners return an Action (in a script
Action_Ignore, Action_Override, Action_Supersede):
| Return | Pre (post == false) | Post (post == true) |
|---|---|---|
Action::Ignore | Nothing changes; the next listener runs, then the command. | Nothing. |
Action::Override | The command still runs, but the remaining pre listeners are skipped — and for a console command the post listeners too. | Nothing. |
Action::Supersede | The command does not run. The remaining listeners — pre and post — are skipped. | The command already ran; only the remaining post listeners are skipped. |
In practice: return Ignore when you only watch, Supersede when you block,
and avoid Override — for a command there is no return value to override, and
it silently cuts the other listeners off.
A Supersede from your listener also stops every listener registered after it,
including other plugins' and the handler of a command registered with
REGISTER_CON_COMMAND on the same name. Block only what you mean to block.
Pre and post together
REGISTER_CON_LISTENER("jointeam", [](const Source2ModCommandContext& context, const Source2ModCommandArgs& args, bool post) -> Action
{
// Pre: decide.
return Action::Ignore;
}, false);
REGISTER_CON_LISTENER("jointeam", [](const Source2ModCommandContext& context, const Source2ModCommandArgs& args, bool post) -> Action
{
// Post: the team change has been processed -- react to it.
SOURCE2MOD_LOG(&g_Plugin, "Slot %d ran \"%s\"\n", context.GetPlayerSlot().Get(), args.GetCommandString());
return Action::Ignore;
}, true);public void OnPluginStart()
{
Source2ModCommands.RegisterConListener("jointeam", Listen_JoinTeamPre, false);
Source2ModCommands.RegisterConListener("jointeam", Listen_JoinTeamPost, true);
}
public Source2ModAction Listen_JoinTeamPre(Source2ModCommandContext context, Source2ModCommandArgs args, bool post)
{
// Pre: decide.
return Action_Ignore;
}
public Source2ModAction Listen_JoinTeamPost(Source2ModCommandContext context, Source2ModCommandArgs args, bool post)
{
// Post: the team change has been processed -- react to it.
char command[256];
args.GetCommandString(command, sizeof(command));
Source2ModApi.Log("Slot %d ran \"%s\"", context.GetPlayerSlot().Get(), command);
return Action_Ignore;
}Which commands can be listened to
The core hooks the engine's command dispatch, so a listener sees:
- every console command run from the server console or RCON;
- console commands a client runs that go through the engine's command system;
jointeam, which the game handles as a raw client command — the core routes it to listeners separately;- chat:
sayandsay_team(see below), and every chat command typed with a chat trigger.
A client command the game handles itself, outside the console system, does not
reach listeners (apart from jointeam). For those, hook
ISource2GameClients::ClientCommand with KHOOK_VIRTUAL — the sample plugin
does exactly that; see Hooking.
Filtering chat
Plain chat messages arrive as say (all chat) and say_team (team chat). A
pre listener on them is a chat filter; Supersede drops the message. This is
how FUNPLAY's Public plugin stops empty and repeated messages:
static Action OnPlayerChat(const Source2ModCommandContext& ctx, const Source2ModCommandArgs& args, bool post)
{
CCSPlayerController* player = CCSPlayerController::FromSlot(ctx.GetPlayerSlot().Get());
if (!player)
return Action::Ignore;
// The message is everything after "say", possibly wrapped in quotes.
std::string message = args.ArgS();
if (!message.empty() && message.front() == '"') message.erase(message.begin());
if (!message.empty() && message.back() == '"') message.pop_back();
if (message.empty())
return Action::Supersede;
return Action::Ignore;
}
// in Load()
REGISTER_CON_LISTENER("say", OnPlayerChat, false);
REGISTER_CON_LISTENER("say_team", OnPlayerChat, false);public Source2ModAction OnPlayerChat(Source2ModCommandContext ctx, Source2ModCommandArgs args, bool post)
{
CPlayerSlot slot = ctx.GetPlayerSlot();
if (!slot.IsValid() || CCSPlayerController.FromSlot(slot) == NULL_ENTITY)
return Action_Ignore;
// The message is everything after "say", possibly wrapped in quotes.
char message[256];
args.ArgS(message, sizeof(message));
int len = Source2ModUtils.Length(message);
if (len > 0 && message[len - 1] == '"')
message[--len] = '\0';
int start = (message[0] == '"') ? 1 : 0;
if (message[start] == '\0')
return Action_Supersede;
return Action_Ignore;
}
// in OnPluginStart()
Source2ModCommands.RegisterConListener("say", OnPlayerChat, false);
Source2ModCommands.RegisterConListener("say_team", OnPlayerChat, false);A message that starts with a chat trigger (!, /, …) does not reach the
say listeners — the core dispatches it as the command it names instead. With a
pre listener on that command name you can see it (and, with Supersede, hide
the chat line); a say filter only ever sees ordinary chat.
Listeners vs. commands
| You want to… | Use |
|---|---|
| Add a new command | REGISTER_CON_COMMAND |
| Add a chat word without a console command | REGISTER_CHAT_LISTENER |
| Watch or block an existing command | REGISTER_CON_LISTENER |
| Replace an existing command | REGISTER_CON_LISTENER, pre, do your thing, return Action::Supersede |
In a script the three are Source2ModCommands.RegisterConCommand,
RegisterChatListener and RegisterConListener.
Full reference: ISource2ModCommands.