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".
HOOK_CON_COMMAND("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.HookConCommand("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 HookConCommand(const char* pchName, CommandHandler handler, bool post) = 0;
virtual bool UnhookConCommand(const char* pchName, const CommandHandler& handler, bool post) = 0;
virtual bool UnhookConCommand(Source2ModHookId id) = 0;
#define HOOK_CON_COMMAND(pchName, handler, post) g_pSource2ModCommands->HookConCommand(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.HookConCommand(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. HookConCommand returns a Source2ModHookId;
g_pSource2ModCommands->UnhookConCommand(id) drops that one listener, and
UnhookConCommand(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()
HOOK_CON_COMMAND("say", OnPlayerChat, false);
// later, to turn the filter off
g_pSource2ModCommands->UnhookConCommand("say", OnPlayerChat, false);int g_ChatFilter;
// in OnPluginStart()
g_ChatFilter = Source2ModCommands.HookConCommand("say", OnPlayerChat, false);
// later, to turn the filter off
Source2ModCommands.UnhookConCommand(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
HOOK_CON_COMMAND("jointeam", [](const Source2ModCommandContext& context, const Source2ModCommandArgs& args, bool post) -> Action
{
// Pre: decide.
return Action::Ignore;
}, false);
HOOK_CON_COMMAND("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.HookConCommand("jointeam", Listen_JoinTeamPre, false);
Source2ModCommands.HookConCommand("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; three ways to see chat), 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 as a Function point — the sample plugin
does exactly that; see Virtual functions.
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. A filter
that stops empty 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()
HOOK_CON_COMMAND("say", OnPlayerChat, false);
HOOK_CON_COMMAND("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.HookConCommand("say", OnPlayerChat, false);
Source2ModCommands.HookConCommand("say_team", OnPlayerChat, false);A message that starts with a chat trigger (!, /, …) and names a Source2Mod
command does not reach the say listeners: the core dispatches it as that
command instead. With a pre listener on the command name you can see it (and,
with Supersede, hide the chat line). A / line that names no Source2Mod
command is ordinary chat, as in SourceMod, so another framework on the same
server (CounterStrikeSharp's /css_x commands) still gets it.
Three ways to see chat
| Where | What it sees |
|---|---|
HOOK_CON_COMMAND("say" / "say_team") | The say command as it is dispatched, before the game handles it: ordinary chat only (see above). |
HookPoint::PlayerChat | Host_Say, where the game prints the line: the controller, the line, teamOnly. Pre may Supersede to drop it. !command lines come here too. |
The player_chat game event | Fired once per line that went out, after the say command. The game defines it but never fires it; Source2Mod fires it unless another framework already did (CounterStrikeSharp fires its own), so it never arrives twice. |
A chat formatter (print the line yourself, drop the original) belongs on
PlayerChat; FUNPLAY's mode plugins work that way. Logging and statistics
fit the player_chat event.
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 | HOOK_CON_COMMAND |
| Replace an existing command | HOOK_CON_COMMAND, pre, do your thing, return Action::Supersede |
In a script the three are Source2ModCommands.RegisterConCommand,
RegisterChatListener and HookConCommand.
Full reference: ISource2ModCommands.