/*
* InspIRCd -- Internet Relay Chat Daemon
*
* Copyright (C) 2026 Sadie Powell <sadie@witchery.services>
*
* This file is part of InspIRCd. InspIRCd is free software: you can
* redistribute it and/or modify it under the terms of the GNU General Public
* License as published by the Free Software Foundation, version 2.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
* details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "inspircd.h"
#include "inspircd.h"
#include "clientprotocolmsg.h"
#include "numerichelper.h"
#include "utility/string.h"
struct SharedData final
{
// A reference to the +k user mode from the servicdes.
UserModeReference servprotectmode;
// Whether to sign messages with the executing operator's nick.
bool sign = true;
SharedData(const WeakModulePtr& mod)
: servprotectmode(mod, "protect")
{
}
};
class CommandSAJoin final
: public Command
{
private:
SharedData& data;
public:
CommandSAJoin(const WeakModulePtr& mod, SharedData& sd)
: Command(mod, "SAJOIN", 1)
, data(sd)
{
accepts_multiple_targets = true;
access_needed = CmdAccess::OPERATOR;
syntax = { "[<nick>] <channel>[,<channel>]+" };
translation = { TranslateType::NICK, TranslateType::TEXT };
}
CmdResult Handle(User* user, const Params& parameters) override
{
const unsigned int channelindex = (parameters.size() > 1) ? 1 : 0;
if (CommandParser::LoopCall(user, this, parameters, channelindex))
return CmdResult::FAILURE;
User* target = user;
if (parameters.size() > 1)
{
const auto& targetnick = parameters[0];
target = ServerInstance->Users.Find(targetnick, true);
if (!target)
{
user->WriteRemoteNumeric(Numerics::NoSuchNick(targetnick));
return CmdResult::FAILURE;
}
}
if (user != target)
{
// Force joining another user requires a special permission.
if (!user->HasPrivPermission("users/sajoin-others"))
{
user->WriteRemoteNumeric(Numerics::NoPrivileges("your server operator account does not have the users/sajoin-others privilege"));
return CmdResult::FAILURE;
}
if (target->IsModeSet(data.servprotectmode))
{
user->WriteRemoteNumeric(Numerics::NoPrivileges("you can not use the {} command on a protected service", this->service_name));
return CmdResult::FAILURE;
}
}
const auto& targetchan = parameters[channelindex];
if (user->IsLocal() && !ServerInstance->Channels.IsChannel(targetchan))
{
user->WriteRemoteNumeric(ERR_BADCHANMASK, targetchan, "Invalid channel name");
return CmdResult::FAILURE;
}
auto* chan = ServerInstance->Channels.Find(targetchan);
if (chan && chan->HasUser(target))
{
user->WriteRemoteNumeric(ERR_USERONCHANNEL, target->nick, chan->name, "is already on channel");
return CmdResult::FAILURE;
}
auto* ltarget = target->AsLocal();
if (!ltarget)
return CmdResult::SUCCESS; // User will be handled by their own server.
auto* memb = Channel::JoinUser(ltarget, targetchan, true);
if (!memb)
{
user->WriteRemoteNotice("*** {}: could not join {} to {}.", this->service_name, target->nick, targetchan);
return CmdResult::FAILURE;
}
ServerInstance->SNO.WriteGlobalSno('a', "{} ({}) used {} to make {} join {}", user->nick,
user->oper->GetName(), this->service_name, target->nick, memb->chan->name);
return CmdResult::SUCCESS;
}
RouteDescriptor GetRouting(User* user, const Params& parameters) override
{
return ROUTE_OPT_UCAST(parameters[0]);
}
};
class CommandSAKick final
: public Command
{
private:
SharedData& data;
public:
CommandSAKick(const WeakModulePtr& mod, SharedData& sd)
: Command(mod, "SAKICK", 2, 3)
, data(sd)
{
access_needed = CmdAccess::OPERATOR;
allow_empty_last_param = true;
syntax = { "<channel> <nick> [:<reason>]" };
translation = { TranslateType::TEXT, TranslateType::NICK, TranslateType::TEXT };
}
CmdResult Handle(User* user, const Params& parameters) override
{
const auto& targetnick = parameters[1];
auto* target = ServerInstance->Users.Find(targetnick, true);
if (!target)
{
user->WriteRemoteNumeric(Numerics::NoSuchNick(targetnick));
return CmdResult::FAILURE;
}
if (target->IsModeSet(data.servprotectmode))
{
user->WriteRemoteNumeric(Numerics::NoPrivileges("you can not use the {} command on a protected service", this->service_name));
return CmdResult::FAILURE;
}
const auto& targetchan = parameters[1];
auto* chan = ServerInstance->Channels.Find(targetchan);
if (!chan)
{
user->WriteRemoteNumeric(Numerics::NoSuchChannel(targetchan));
return CmdResult::FAILURE;
}
if (!chan->HasUser(target))
{
user->WriteRemoteNumeric(ERR_USERNOTINCHANNEL, target->nick, chan->name, "They are not on that channel");
return CmdResult::FAILURE;
}
if (!target->IsLocal())
return CmdResult::SUCCESS; // User will be handled by their own server.
const std::string reason(parameters.size() > 2 ? parameters.back() : target->nick, 0, ServerInstance->Config->Limits.MaxKick);
chan->KickUser(user, target, data.sign ? FMT::format("{} ({})", reason, user->nick) : reason);
ServerInstance->SNO.WriteGlobalSno('a', "{} ({}) used {} to kick {} from {} ({})", user->nick,
user->oper->GetName(), this->service_name, target->nick, chan->name, reason);
return CmdResult::SUCCESS;
}
RouteDescriptor GetRouting(User* user, const Params& parameters) override
{
return ROUTE_OPT_UCAST(parameters[1]);
}
};
class CommandSAMode final
: public Command
{
private:
SharedData& data;
bool logged = false;
public:
bool active = false;
CommandSAMode(const WeakModulePtr& mod, SharedData& sd)
: Command(mod, "SAMODE", 2)
, data(sd)
{
access_needed = CmdAccess::OPERATOR;
syntax = { "<target> (+|-)<modes> [<mode-parameters>]" };
}
CmdResult Handle(User* user, const Params& parameters) override
{
const auto& targetstr = parameters[0];
if (!ServerInstance->Channels.IsPrefix(targetstr[0]))
{
auto* target = ServerInstance->Users.FindNick(targetstr, true);
if (!target)
{
user->WriteNumeric(Numerics::NoSuchNick(targetstr));
return CmdResult::FAILURE;
}
if (target->IsModeSet(data.servprotectmode))
{
user->WriteRemoteNumeric(Numerics::NoPrivileges("you can not use the {} command on a protected service", this->service_name));
return CmdResult::FAILURE;
}
// Changing the modes of another user requires a special permission.
if (target != user && !user->HasPrivPermission("users/samode-usermodes"))
{
user->WriteRemoteNumeric(Numerics::NoPrivileges("your server operator account does not have the users/samode-usermodes privilege"));
return CmdResult::FAILURE;
}
}
// XXX: Make ModeParser clear LastParse
Modes::ChangeList emptychangelist;
ServerInstance->Modes.ProcessSingle(ServerInstance->FakeClient, nullptr, ServerInstance->FakeClient, emptychangelist);
this->logged = false;
this->active = true;
ServerInstance->Parser.CallHandler("MODE", parameters, user);
this->active = false;
if (!this->logged)
{
// If we haven't logged anything yet then the client queried the list of a listmode
// (e.g. /SAMODE #chan b), which was handled internally by the MODE command handler.
//
// Viewing the modes of a user or a channel could also result in this, but that is not
// possible with /SAMODE because we require at least 2 parameters.
this->LogUsage(user, targetstr, insp::join(insp::iterator_range(parameters.begin() + 1, parameters.end())));
}
return CmdResult::SUCCESS;
}
void LogUsage(const User* user, const std::string& target, const std::string& modes)
{
this->logged = true;
ServerInstance->SNO.WriteGlobalSno('a', "{} ({}) used {} to change the modes of {} to {}", user->nick,
user->oper->GetName(), this->service_name, target, modes);
}
};
class CommandSANick final
: public Command
{
private:
SharedData& data;
public:
CommandSANick(const WeakModulePtr& mod, SharedData& sd)
: Command(mod, "SANICK", 2)
, data(sd)
{
access_needed = CmdAccess::OPERATOR;
allow_empty_last_param = true;
syntax = { "<nick> <newnick>" };
translation = { TranslateType::NICK, TranslateType::TEXT };
}
CmdResult Handle(User* user, const Params& parameters) override
{
const auto& targetnick = parameters[0];
auto* target = ServerInstance->Users.Find(targetnick, true);
if (!target)
{
user->WriteRemoteNumeric(Numerics::NoSuchNick(targetnick));
return CmdResult::FAILURE;
}
if (target->IsModeSet(data.servprotectmode))
{
user->WriteRemoteNumeric(Numerics::NoPrivileges("you can not use the {} command on a protected service", this->service_name));
return CmdResult::FAILURE;
}
auto targetnewnick = parameters[1];
if (targetnewnick.empty())
{
user->WriteRemoteNumeric(ERR_NONICKNAMEGIVEN, "No nickname given");
return CmdResult::FAILURE;
}
if (targetnewnick == "0")
targetnewnick = target->uuid;
else if (!ServerInstance->Users.IsNick(targetnewnick))
{
user->WriteRemoteNumeric(ERR_ERRONEUSNICKNAME, targetnewnick, "Erroneous nickname");
return CmdResult::FAILURE;
}
if (ServerInstance->Users.FindNick(targetnewnick))
{
user->WriteRemoteNumeric(ERR_NICKNAMEINUSE, targetnewnick, "Nickname is already in use.");
return CmdResult::FAILURE;
}
if (!target->IsLocal())
return CmdResult::SUCCESS; // User will be handled by their own server.
if (!target->ChangeNick(targetnewnick))
{
user->WriteRemoteNotice("*** {}: could not change the nick of {} to {}.", this->service_name,
targetnick, targetnewnick);
return CmdResult::FAILURE;
}
ServerInstance->SNO.WriteGlobalSno('a', "{} ({}) used {} to change the nick of {} to {}", user->nick,
user->oper->GetName(), this->service_name, targetnick, targetnewnick);
return CmdResult::SUCCESS;
}
RouteDescriptor GetRouting(User* user, const Params& parameters) override
{
return ROUTE_OPT_UCAST(parameters[0]);
}
};
class CommandSAPart final
: public Command
{
private:
SharedData& data;
public:
CommandSAPart(const WeakModulePtr& mod, SharedData& sd)
: Command(mod, "SAPART", 2, 3)
, data(sd)
{
accepts_multiple_targets = true;
access_needed = CmdAccess::OPERATOR;
allow_empty_last_param = true;
syntax = { "<nick> <channel>[,<channel>]+ [:<reason>]" };
translation = { TranslateType::NICK, TranslateType::TEXT, TranslateType::TEXT };
}
CmdResult Handle(User* user, const Params& parameters) override
{
if (CommandParser::LoopCall(user, this, parameters, 1))
return CmdResult::FAILURE;
const auto& targetnick = parameters[0];
auto* target = ServerInstance->Users.Find(targetnick, true);
if (!target)
{
user->WriteRemoteNumeric(Numerics::NoSuchNick(targetnick));
return CmdResult::FAILURE;
}
if (target->IsModeSet(data.servprotectmode))
{
user->WriteRemoteNumeric(Numerics::NoPrivileges("you can not use the {} command on a protected service", this->service_name));
return CmdResult::FAILURE;
}
const auto& targetchan = parameters[0];
auto* chan = ServerInstance->Channels.Find(targetchan);
if (!chan)
{
user->WriteRemoteNumeric(Numerics::NoSuchChannel(targetchan));
return CmdResult::FAILURE;
}
if (!target->IsLocal())
return CmdResult::SUCCESS; // User will be handled by their own server.
const auto& reason = parameters.size() > 2 ? parameters.back() : "";
if (!chan->PartUser(target, data.sign ? FMT::format("{}{}({})", reason, reason.empty() ? "" : " ", user->nick) : reason))
{
user->WriteRemoteNumeric(ERR_USERNOTINCHANNEL, target->nick, chan->name, "They are not on that channel");
return CmdResult::FAILURE;
}
ServerInstance->SNO.WriteGlobalSno('a', "{} ({}) used {} to part {} from {} ({})", user->nick,
user->oper->GetName(), this->service_name, target->nick, chan->name, reason);
return CmdResult::FAILURE;
}
RouteDescriptor GetRouting(User* user, const Params& parameters) override
{
return ROUTE_OPT_UCAST(parameters[0]);
}
};
class CommandSAQuit final
: public Command
{
private:
SharedData& data;
public:
CommandSAQuit(const WeakModulePtr& mod, SharedData& sd)
: Command(mod, "SAQUIT", 2, 2)
, data(sd)
{
access_needed = CmdAccess::OPERATOR;
allow_empty_last_param = true;
syntax = { "<nick> :<reason>" };
translation = { TranslateType::NICK, TranslateType::TEXT };
}
CmdResult Handle(User* user, const Params& parameters) override
{
const auto& targetnick = parameters[0];
auto* target = ServerInstance->Users.Find(targetnick, true);
if (!target)
{
user->WriteRemoteNumeric(Numerics::NoSuchNick(targetnick));
return CmdResult::FAILURE;
}
if (target->IsModeSet(data.servprotectmode))
{
user->WriteRemoteNumeric(Numerics::NoPrivileges("you can not use the {} command on a protected service", this->service_name));
return CmdResult::FAILURE;
}
if (!target->IsLocal())
return CmdResult::SUCCESS; // User will be handled by their own server.
const auto& reason = parameters.size() > 2 ? parameters.back() : "";
ServerInstance->Users.QuitUser(target, data.sign ? FMT::format("{}{}({})", reason, reason.empty() ? "" : " ", user->nick) : reason);
ServerInstance->SNO.WriteGlobalSno('a', "{} ({}) used {} to quit {} ({})", user->nick,
user->oper->GetName(), this->service_name, target->nick, reason);
return CmdResult::FAILURE;
}
RouteDescriptor GetRouting(User* user, const Params& parameters) override
{
return ROUTE_OPT_UCAST(parameters[0]);
}
};
class CommandSATopic final
: public Command
{
public:
CommandSATopic(const WeakModulePtr& mod)
: Command(mod, "SATOPIC", 2, 2)
{
access_needed = CmdAccess::OPERATOR;
allow_empty_last_param = true;
syntax = { "<channel> :<topic>" };
}
CmdResult Handle(User* user, const Params& parameters) override
{
auto* target = ServerInstance->Channels.Find(parameters[0]);
if (!target)
{
user->WriteRemoteNumeric(Numerics::NoSuchChannel(parameters[0]));
return CmdResult::FAILURE;
}
const std::string topic(parameters[1], 0, ServerInstance->Config->Limits.MaxTopic);
if (target->topic == topic)
{
user->WriteRemoteNotice("*** {}: The topic on {} is already what you are trying to change it to.",
this->service_name, target->name);
return CmdResult::SUCCESS;
}
target->SetTopic(user, topic, ServerInstance->Time(), nullptr);
ServerInstance->SNO.WriteGlobalSno('a', "{} ({}) used {} to change the topic of {} ({})", user->nick,
user->oper->GetName(), this->service_name, target->name, topic);
return CmdResult::SUCCESS;
}
};
class ModuleSACommands final
: public Module
{
private:
SharedData data;
CommandSAJoin cmdsajoin;
CommandSAKick cmdsakick;
CommandSAMode cmdsamode;
CommandSANick cmdsanick;
CommandSAPart cmdsapart;
CommandSAQuit cmdsaquit;
CommandSATopic cmdsatopic;
public:
ModuleSACommands()
: Module(VF_VENDOR, "Adds various server operator-only versions of regular commands.")
, data(weak_from_this())
, cmdsajoin(weak_from_this(), data)
, cmdsakick(weak_from_this(), data)
, cmdsamode(weak_from_this(), data)
, cmdsanick(weak_from_this(), data)
, cmdsapart(weak_from_this(), data)
, cmdsaquit(weak_from_this(), data)
, cmdsatopic(weak_from_this())
{
}
void ReadConfig(ConfigStatus& status) override
{
const auto& tag = ServerInstance->Config->ConfValue("sacommands");
data.sign = tag->getBool("sign", true);
}
void Prioritize() override
{
ServerInstance->Modules.SetPriority(shared_from_this(), I_OnRawMode, PRIORITY_BEFORE, "disable");
ServerInstance->Modules.SetPriority(shared_from_this(), I_OnPreMode, PRIORITY_BEFORE, "override");
}
ModResult OnPreMode(User* source, User* dest, Channel* channel, Modes::ChangeList& modes) override
{
if (cmdsamode.active)
return MOD_RES_ALLOW;
return MOD_RES_PASSTHRU;
}
void OnMode(User* user, User* destuser, Channel* destchan, const Modes::ChangeList& modes, ModeParser::ModeProcessFlag processflags) override
{
if (!cmdsamode.active)
return;
auto modestr = ClientProtocol::Messages::Mode::ToModeLetters(modes);
for (const auto& item : modes.getlist())
{
if (!item.param.empty())
modestr.append(1, ' ').append(item.param);
}
cmdsamode.LogUsage(user, destuser ? destuser->nick : destchan->name, modestr);
}
};
MODULE_INIT(ModuleSACommands)