Custom modules for Anope 2.1 IRC Services
This commit is contained in:
commit
ae6b53df90
3 changed files with 3257 additions and 0 deletions
2021
m_apiauth.cpp
Normal file
2021
m_apiauth.cpp
Normal file
File diff suppressed because it is too large
Load diff
258
m_nickserv_oauth.cpp
Normal file
258
m_nickserv_oauth.cpp
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
/*
|
||||||
|
* m_nickserv_oauth.cpp
|
||||||
|
* 2026 Jean "reverse" Chevronnet
|
||||||
|
*
|
||||||
|
* Module for Anope IRC Services v2.1
|
||||||
|
* Adds IDENTIFYOAUTH command to NickServ for JWT-based authentication
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/// BEGIN CMAKE
|
||||||
|
/// find_package(OpenSSL REQUIRED)
|
||||||
|
/// target_link_libraries(${SO} PUBLIC OpenSSL::Crypto OpenSSL::SSL)
|
||||||
|
/// END CMAKE
|
||||||
|
|
||||||
|
#include "module.h"
|
||||||
|
#include <jwt-cpp/jwt.h>
|
||||||
|
#include <openssl/evp.h>
|
||||||
|
|
||||||
|
static Module *me;
|
||||||
|
|
||||||
|
class CommandNSIdentifyOAuth final : public Command
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
Anope::string jwt_secret;
|
||||||
|
Anope::string jwt_issuer;
|
||||||
|
|
||||||
|
CommandNSIdentifyOAuth(Module *creator)
|
||||||
|
: Command(creator, "nickserv/identifyoauth", 2, 2)
|
||||||
|
{
|
||||||
|
this->SetDesc(_("Identify to services using an OAuth/JWT token"));
|
||||||
|
this->SetSyntax(_("\037nickname\037 \037token\037"));
|
||||||
|
this->AllowUnregistered(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Execute(CommandSource &source, const std::vector<Anope::string> ¶ms) override
|
||||||
|
{
|
||||||
|
const Anope::string &username = params[0];
|
||||||
|
const Anope::string &token = params[1];
|
||||||
|
|
||||||
|
User *u = source.GetUser();
|
||||||
|
BotInfo *bi = Config->GetClient("NickServ");
|
||||||
|
|
||||||
|
if (!u || !bi)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Log(LOG_COMMAND, source, this) << "to authenticate as " << username << " using OAuth token";
|
||||||
|
|
||||||
|
// Find the account
|
||||||
|
NickAlias *na = NickAlias::Find(username);
|
||||||
|
if (!na)
|
||||||
|
{
|
||||||
|
source.Reply(_("Account \002%s\002 is not registered."), username.c_str());
|
||||||
|
Log(me, "oauth") << u->GetMask() << " tried to IDENTIFYOAUTH as \002" << username << "\002 but account does not exist";
|
||||||
|
u->BadPassword();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NickCore *nc = na->nc;
|
||||||
|
if (!nc)
|
||||||
|
{
|
||||||
|
source.Reply(_("Account \002%s\002 is not registered."), username.c_str());
|
||||||
|
u->BadPassword();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if account is suspended
|
||||||
|
if (nc->HasExt("NS_SUSPENDED"))
|
||||||
|
{
|
||||||
|
source.Reply(_("Your account has been suspended."));
|
||||||
|
Log(me, "oauth") << u->GetMask() << " tried to IDENTIFYOAUTH as suspended account \002" << username << "\002";
|
||||||
|
u->BadPassword();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate JWT token
|
||||||
|
try
|
||||||
|
{
|
||||||
|
std::string token_str(token.c_str());
|
||||||
|
auto decoded = jwt::decode(token_str);
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
Log(me, "oauth") << "Token decoded - Algorithm: " << decoded.get_algorithm();
|
||||||
|
if (decoded.has_issuer())
|
||||||
|
Log(me, "oauth") << "Token issuer: " << decoded.get_issuer();
|
||||||
|
if (decoded.has_payload_claim("sub"))
|
||||||
|
Log(me, "oauth") << "Token sub: " << decoded.get_payload_claim("sub").as_string();
|
||||||
|
|
||||||
|
// Verify token signature and issuer if configured
|
||||||
|
if (!this->jwt_secret.empty())
|
||||||
|
{
|
||||||
|
std::string secret_str(this->jwt_secret.c_str());
|
||||||
|
std::string expected_issuer(this->jwt_issuer.c_str());
|
||||||
|
|
||||||
|
Log(me, "oauth") << "Verifying with secret length: " << secret_str.length()
|
||||||
|
<< " expected issuer: " << expected_issuer;
|
||||||
|
|
||||||
|
auto verifier = jwt::verify()
|
||||||
|
.allow_algorithm(jwt::algorithm::hs256{secret_str});
|
||||||
|
|
||||||
|
// Only verify issuer if the token has one AND we have an expected issuer
|
||||||
|
if (!expected_issuer.empty() && decoded.has_issuer())
|
||||||
|
verifier.with_issuer(expected_issuer);
|
||||||
|
|
||||||
|
verifier.verify(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract username from token - check multiple possible claims
|
||||||
|
std::string token_username;
|
||||||
|
if (decoded.has_payload_claim("sub"))
|
||||||
|
token_username = decoded.get_payload_claim("sub").as_string();
|
||||||
|
else if (decoded.has_payload_claim("name"))
|
||||||
|
token_username = decoded.get_payload_claim("name").as_string();
|
||||||
|
else if (decoded.has_payload_claim("username"))
|
||||||
|
token_username = decoded.get_payload_claim("username").as_string();
|
||||||
|
|
||||||
|
// If no username in token, we need to look up by user_id
|
||||||
|
if (token_username.empty() && decoded.has_payload_claim("user_id"))
|
||||||
|
{
|
||||||
|
Log(me, "oauth") << "Token contains user_id but no username claim - cannot verify identity match";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify token username matches requested username
|
||||||
|
if (!token_username.empty())
|
||||||
|
{
|
||||||
|
Anope::string token_user_anope(token_username.c_str());
|
||||||
|
if (!token_user_anope.equals_ci(username))
|
||||||
|
{
|
||||||
|
source.Reply(_("Token username does not match requested account."));
|
||||||
|
Log(me, "oauth") << u->GetMask() << " tried to IDENTIFYOAUTH as \002" << username
|
||||||
|
<< "\002 but token is for \002" << token_username << "\002";
|
||||||
|
u->BadPassword();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check token expiration
|
||||||
|
if (decoded.has_expires_at())
|
||||||
|
{
|
||||||
|
auto exp_time = decoded.get_expires_at();
|
||||||
|
auto now = std::chrono::system_clock::now();
|
||||||
|
if (exp_time < now)
|
||||||
|
{
|
||||||
|
source.Reply(_("OAuth token has expired."));
|
||||||
|
Log(me, "oauth") << u->GetMask() << " tried to IDENTIFYOAUTH with expired token for \002" << username << "\002";
|
||||||
|
u->BadPassword();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token is valid - identify the user
|
||||||
|
u->Identify(na);
|
||||||
|
|
||||||
|
source.Reply(_("Password accepted - you are now recognized."));
|
||||||
|
Log(me, "oauth") << u->GetMask() << " successfully identified to account \002" << nc->display << "\002 using OAuth";
|
||||||
|
|
||||||
|
// If the user's current nickname matches the account, update display
|
||||||
|
if (u->nick.equals_ci(nc->display))
|
||||||
|
{
|
||||||
|
Configuration::Block modconf = Config->GetModule("ns_identify");
|
||||||
|
if (modconf.Get<bool>("modeonid", "yes"))
|
||||||
|
{
|
||||||
|
// Set correct modes in channels
|
||||||
|
for (const auto &[_, cc] : u->chans)
|
||||||
|
{
|
||||||
|
Channel *c = cc->chan;
|
||||||
|
if (c)
|
||||||
|
c->SetCorrectModes(u, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply configured user modes on identify
|
||||||
|
const Anope::string &modesonid = modconf.Get<const Anope::string>("modesonid");
|
||||||
|
if (!modesonid.empty())
|
||||||
|
u->SetModes(bi, modesonid);
|
||||||
|
}
|
||||||
|
|
||||||
|
FOREACH_MOD(OnNickIdentify, (u));
|
||||||
|
}
|
||||||
|
catch (const jwt::error::token_verification_exception &e)
|
||||||
|
{
|
||||||
|
source.Reply(_("Invalid or malformed OAuth token."));
|
||||||
|
Log(me, "oauth") << u->GetMask() << " failed JWT verification for \002" << username
|
||||||
|
<< "\002: " << e.what();
|
||||||
|
u->BadPassword();
|
||||||
|
}
|
||||||
|
catch (const std::exception &e)
|
||||||
|
{
|
||||||
|
source.Reply(_("OAuth authentication failed: Invalid token."));
|
||||||
|
Log(me, "oauth") << u->GetMask() << " IDENTIFYOAUTH error for \002" << username
|
||||||
|
<< "\002: " << e.what();
|
||||||
|
u->BadPassword();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool OnHelp(CommandSource &source, const Anope::string &subcommand) override
|
||||||
|
{
|
||||||
|
source.Reply(" ");
|
||||||
|
source.Reply(_("Authenticate to services using a JWT OAuth token obtained from\n"
|
||||||
|
"the web interface. This command is typically used automatically\n"
|
||||||
|
"by web chat clients and should not be used manually."));
|
||||||
|
source.Reply(" ");
|
||||||
|
source.Reply(_("Syntax: \002IDENTIFYOAUTH \037nickname\037 \037token\037\002"));
|
||||||
|
source.Reply(" ");
|
||||||
|
source.Reply(_("Example:"));
|
||||||
|
source.Reply(_(" /msg NickServ IDENTIFYOAUTH YourNick eyJhbGc..."));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnServHelp(CommandSource &source, HelpWrapper &help) override
|
||||||
|
{
|
||||||
|
source.Reply(_(" IDENTIFYOAUTH Identify using an OAuth token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnSyntaxError(CommandSource &source, const Anope::string &subcommand) override
|
||||||
|
{
|
||||||
|
source.Reply(_("Syntax: \002IDENTIFYOAUTH \037nickname\037 \037token\037\002"));
|
||||||
|
source.Reply(_("Type \002/msg %s HELP IDENTIFYOAUTH\002 for more information."), source.service->nick.c_str());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class ModuleNSIdentifyOAuth final : public Module
|
||||||
|
{
|
||||||
|
private:
|
||||||
|
CommandNSIdentifyOAuth commandnsidentifyoauth;
|
||||||
|
|
||||||
|
public:
|
||||||
|
ModuleNSIdentifyOAuth(const Anope::string &modname, const Anope::string &creator)
|
||||||
|
: Module(modname, creator, EXTRA | VENDOR)
|
||||||
|
, commandnsidentifyoauth(this)
|
||||||
|
{
|
||||||
|
me = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnReload(Configuration::Conf &conf) override
|
||||||
|
{
|
||||||
|
Configuration::Block &config = conf.GetModule(this);
|
||||||
|
|
||||||
|
// Read JWT configuration from module config
|
||||||
|
Anope::string jwt_secret = config.Get<const Anope::string>("jwt_secret", "");
|
||||||
|
Anope::string jwt_issuer = config.Get<const Anope::string>("jwt_issuer", "");
|
||||||
|
|
||||||
|
// Update command with new config
|
||||||
|
commandnsidentifyoauth.jwt_secret = jwt_secret;
|
||||||
|
commandnsidentifyoauth.jwt_issuer = jwt_issuer;
|
||||||
|
|
||||||
|
if (jwt_secret.empty())
|
||||||
|
{
|
||||||
|
Log(this) << "Warning: jwt_secret not configured - JWT signature verification disabled";
|
||||||
|
}
|
||||||
|
|
||||||
|
Log(this) << "IDENTIFYOAUTH command loaded (JWT verification: "
|
||||||
|
<< (jwt_secret.empty() ? "disabled" : "enabled") << ")";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
MODULE_INIT(ModuleNSIdentifyOAuth)
|
||||||
978
m_youtube.cpp
Normal file
978
m_youtube.cpp
Normal file
|
|
@ -0,0 +1,978 @@
|
||||||
|
/*
|
||||||
|
2024 Jean "reverse" Chevronnet
|
||||||
|
Module for Anope IRC Services v2.1.
|
||||||
|
Botserv read and answer youtube links.
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/// BEGIN CMAKE
|
||||||
|
/// target_link_libraries(${SO} PUBLIC curl ssl crypto yyjson)
|
||||||
|
/// END CMAKE
|
||||||
|
|
||||||
|
#include "module.h"
|
||||||
|
#include <curl/curl.h>
|
||||||
|
#include <yyjson.h>
|
||||||
|
#include <regex>
|
||||||
|
#include <chrono>
|
||||||
|
#include <sstream>
|
||||||
|
#include <cctype>
|
||||||
|
|
||||||
|
// ── Persistent video cache (written to m_youtube.module.json) ──
|
||||||
|
|
||||||
|
static constexpr const char *YOUTUBE_CACHE_DATA_TYPE = "YouTubeCache";
|
||||||
|
|
||||||
|
class YouTubeCacheEntry;
|
||||||
|
using youtube_cache_map = Anope::unordered_map<YouTubeCacheEntry *>;
|
||||||
|
static Serialize::Checker<youtube_cache_map> YouTubeCacheList(YOUTUBE_CACHE_DATA_TYPE);
|
||||||
|
|
||||||
|
class YouTubeCacheEntry final : public Serializable
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
Anope::string video_id;
|
||||||
|
Anope::string title;
|
||||||
|
Anope::string duration;
|
||||||
|
Anope::string view_count;
|
||||||
|
time_t expires_at = 0;
|
||||||
|
// Usage stats
|
||||||
|
uint64_t share_count = 0;
|
||||||
|
time_t first_seen = 0;
|
||||||
|
time_t last_seen = 0;
|
||||||
|
Anope::string last_channel;
|
||||||
|
Anope::string last_nick;
|
||||||
|
|
||||||
|
explicit YouTubeCacheEntry(const Anope::string &vid)
|
||||||
|
: Serializable(YOUTUBE_CACHE_DATA_TYPE)
|
||||||
|
, video_id(vid)
|
||||||
|
{
|
||||||
|
YouTubeCacheList->insert_or_assign(vid, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
~YouTubeCacheEntry() override
|
||||||
|
{
|
||||||
|
YouTubeCacheList->erase(this->video_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsExpired() const { return expires_at <= Anope::CurTime; }
|
||||||
|
|
||||||
|
static YouTubeCacheEntry *Find(const Anope::string &vid)
|
||||||
|
{
|
||||||
|
auto it = YouTubeCacheList->find(vid);
|
||||||
|
return it != YouTubeCacheList->end() ? it->second : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
static YouTubeCacheEntry *FindOrCreate(const Anope::string &vid)
|
||||||
|
{
|
||||||
|
auto *e = Find(vid);
|
||||||
|
return e ? e : new YouTubeCacheEntry(vid);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class YouTubeCacheDataType final : public Serialize::Type
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
YouTubeCacheDataType()
|
||||||
|
: Serialize::Type(YOUTUBE_CACHE_DATA_TYPE)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void Serialize(Serializable *obj, Serialize::Data &data) const override
|
||||||
|
{
|
||||||
|
const auto *e = static_cast<const YouTubeCacheEntry *>(obj);
|
||||||
|
data.Store("video_id", e->video_id);
|
||||||
|
data.Store("title", e->title);
|
||||||
|
data.Store("duration", e->duration);
|
||||||
|
data.Store("view_count", e->view_count);
|
||||||
|
data.Store("expires_at", e->expires_at);
|
||||||
|
data.Store("share_count", e->share_count);
|
||||||
|
data.Store("first_seen", e->first_seen);
|
||||||
|
data.Store("last_seen", e->last_seen);
|
||||||
|
data.Store("last_channel", e->last_channel);
|
||||||
|
data.Store("last_nick", e->last_nick);
|
||||||
|
}
|
||||||
|
|
||||||
|
Serializable *Unserialize(Serializable *obj, Serialize::Data &data) const override
|
||||||
|
{
|
||||||
|
Anope::string video_id;
|
||||||
|
data.TryLoad("video_id", video_id);
|
||||||
|
if (video_id.empty())
|
||||||
|
return nullptr;
|
||||||
|
|
||||||
|
YouTubeCacheEntry *e = nullptr;
|
||||||
|
if (obj)
|
||||||
|
e = anope_dynamic_static_cast<YouTubeCacheEntry *>(obj);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
e = YouTubeCacheEntry::Find(video_id);
|
||||||
|
if (!e)
|
||||||
|
e = new YouTubeCacheEntry(video_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
e->video_id = video_id;
|
||||||
|
data.TryLoad("title", e->title);
|
||||||
|
data.TryLoad("duration", e->duration);
|
||||||
|
data.TryLoad("view_count", e->view_count);
|
||||||
|
data.TryLoad("expires_at", e->expires_at);
|
||||||
|
data.TryLoad("share_count", e->share_count);
|
||||||
|
data.TryLoad("first_seen", e->first_seen);
|
||||||
|
data.TryLoad("last_seen", e->last_seen);
|
||||||
|
data.TryLoad("last_channel", e->last_channel);
|
||||||
|
data.TryLoad("last_nick", e->last_nick);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Per-channel statistics ──
|
||||||
|
|
||||||
|
static constexpr const char *YOUTUBE_CHAN_DATA_TYPE = "YouTubeChanStats";
|
||||||
|
|
||||||
|
class YouTubeChannelStats;
|
||||||
|
using youtube_chan_map = Anope::unordered_map<YouTubeChannelStats *>;
|
||||||
|
static Serialize::Checker<youtube_chan_map> YouTubeChanStatsList(YOUTUBE_CHAN_DATA_TYPE);
|
||||||
|
|
||||||
|
class YouTubeChannelStats final : public Serializable
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
Anope::string channel;
|
||||||
|
uint64_t link_count = 0;
|
||||||
|
uint64_t kick_count = 0;
|
||||||
|
|
||||||
|
explicit YouTubeChannelStats(const Anope::string &chan)
|
||||||
|
: Serializable(YOUTUBE_CHAN_DATA_TYPE)
|
||||||
|
, channel(chan)
|
||||||
|
{
|
||||||
|
YouTubeChanStatsList->insert_or_assign(chan, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
~YouTubeChannelStats() override
|
||||||
|
{
|
||||||
|
YouTubeChanStatsList->erase(this->channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
static YouTubeChannelStats *Find(const Anope::string &chan)
|
||||||
|
{
|
||||||
|
auto it = YouTubeChanStatsList->find(chan);
|
||||||
|
return it != YouTubeChanStatsList->end() ? it->second : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
static YouTubeChannelStats *FindOrCreate(const Anope::string &chan)
|
||||||
|
{
|
||||||
|
auto *e = Find(chan);
|
||||||
|
return e ? e : new YouTubeChannelStats(chan);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class YouTubeChanStatsDataType final : public Serialize::Type
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
YouTubeChanStatsDataType()
|
||||||
|
: Serialize::Type(YOUTUBE_CHAN_DATA_TYPE)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void Serialize(Serializable *obj, Serialize::Data &data) const override
|
||||||
|
{
|
||||||
|
const auto *e = static_cast<const YouTubeChannelStats *>(obj);
|
||||||
|
data.Store("channel", e->channel);
|
||||||
|
data.Store("link_count", e->link_count);
|
||||||
|
data.Store("kick_count", e->kick_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
Serializable *Unserialize(Serializable *obj, Serialize::Data &data) const override
|
||||||
|
{
|
||||||
|
Anope::string channel;
|
||||||
|
data.TryLoad("channel", channel);
|
||||||
|
if (channel.empty())
|
||||||
|
return nullptr;
|
||||||
|
|
||||||
|
YouTubeChannelStats *e = nullptr;
|
||||||
|
if (obj)
|
||||||
|
e = anope_dynamic_static_cast<YouTubeChannelStats *>(obj);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
e = YouTubeChannelStats::Find(channel);
|
||||||
|
if (!e)
|
||||||
|
e = new YouTubeChannelStats(channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
e->channel = channel;
|
||||||
|
data.TryLoad("link_count", e->link_count);
|
||||||
|
data.TryLoad("kick_count", e->kick_count);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Per-user statistics ──
|
||||||
|
|
||||||
|
static constexpr const char *YOUTUBE_USER_DATA_TYPE = "YouTubeUserStats";
|
||||||
|
|
||||||
|
class YouTubeUserStats;
|
||||||
|
using youtube_user_map = Anope::unordered_map<YouTubeUserStats *>;
|
||||||
|
static Serialize::Checker<youtube_user_map> YouTubeUserStatsList(YOUTUBE_USER_DATA_TYPE);
|
||||||
|
|
||||||
|
class YouTubeUserStats final : public Serializable
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
Anope::string nick;
|
||||||
|
uint64_t link_count = 0;
|
||||||
|
uint64_t kick_count = 0;
|
||||||
|
|
||||||
|
explicit YouTubeUserStats(const Anope::string &n)
|
||||||
|
: Serializable(YOUTUBE_USER_DATA_TYPE)
|
||||||
|
, nick(n)
|
||||||
|
{
|
||||||
|
YouTubeUserStatsList->insert_or_assign(n, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
~YouTubeUserStats() override
|
||||||
|
{
|
||||||
|
YouTubeUserStatsList->erase(this->nick);
|
||||||
|
}
|
||||||
|
|
||||||
|
static YouTubeUserStats *Find(const Anope::string &n)
|
||||||
|
{
|
||||||
|
auto it = YouTubeUserStatsList->find(n);
|
||||||
|
return it != YouTubeUserStatsList->end() ? it->second : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
static YouTubeUserStats *FindOrCreate(const Anope::string &n)
|
||||||
|
{
|
||||||
|
auto *e = Find(n);
|
||||||
|
return e ? e : new YouTubeUserStats(n);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class YouTubeUserStatsDataType final : public Serialize::Type
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
YouTubeUserStatsDataType()
|
||||||
|
: Serialize::Type(YOUTUBE_USER_DATA_TYPE)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void Serialize(Serializable *obj, Serialize::Data &data) const override
|
||||||
|
{
|
||||||
|
const auto *e = static_cast<const YouTubeUserStats *>(obj);
|
||||||
|
data.Store("nick", e->nick);
|
||||||
|
data.Store("link_count", e->link_count);
|
||||||
|
data.Store("kick_count", e->kick_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
Serializable *Unserialize(Serializable *obj, Serialize::Data &data) const override
|
||||||
|
{
|
||||||
|
Anope::string nick;
|
||||||
|
data.TryLoad("nick", nick);
|
||||||
|
if (nick.empty())
|
||||||
|
return nullptr;
|
||||||
|
|
||||||
|
YouTubeUserStats *e = nullptr;
|
||||||
|
if (obj)
|
||||||
|
e = anope_dynamic_static_cast<YouTubeUserStats *>(obj);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
e = YouTubeUserStats::Find(nick);
|
||||||
|
if (!e)
|
||||||
|
e = new YouTubeUserStats(nick);
|
||||||
|
}
|
||||||
|
|
||||||
|
e->nick = nick;
|
||||||
|
data.TryLoad("link_count", e->link_count);
|
||||||
|
data.TryLoad("kick_count", e->kick_count);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class CommandBSYouTube final : public Command
|
||||||
|
{
|
||||||
|
private:
|
||||||
|
Anope::string api_key;
|
||||||
|
Anope::string prefix;
|
||||||
|
Anope::string duration_text;
|
||||||
|
Anope::string seen_text;
|
||||||
|
Anope::string times_text;
|
||||||
|
Anope::string response_format;
|
||||||
|
|
||||||
|
// Spam tracking: key = "channel\nnick" -> list of link timestamps
|
||||||
|
std::map<Anope::string, std::vector<time_t>> spam_map;
|
||||||
|
// Spam config
|
||||||
|
int spam_limit = 3;
|
||||||
|
int spam_window = 60;
|
||||||
|
bool spam_kick_enabled = true;
|
||||||
|
Anope::string spam_kick_reason = "YouTube link spam";
|
||||||
|
int cache_ttl = 300;
|
||||||
|
|
||||||
|
static Anope::string UnescapeConfigString(const Anope::string &in)
|
||||||
|
{
|
||||||
|
// Anope's config parser only treats \" specially. We optionally support common
|
||||||
|
// escape sequences here so users can configure IRC formatting codes.
|
||||||
|
Anope::string out;
|
||||||
|
|
||||||
|
for (size_t i = 0; i < in.length(); ++i)
|
||||||
|
{
|
||||||
|
const char ch = in[i];
|
||||||
|
if (ch != '\\' || i + 1 >= in.length())
|
||||||
|
{
|
||||||
|
out.push_back(ch);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char next = in[i + 1];
|
||||||
|
if (next == 'n')
|
||||||
|
{
|
||||||
|
out.push_back('\n');
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
else if (next == 'r')
|
||||||
|
{
|
||||||
|
out.push_back('\r');
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
else if (next == 't')
|
||||||
|
{
|
||||||
|
out.push_back('\t');
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
else if (next == '\\' || next == '"')
|
||||||
|
{
|
||||||
|
out.push_back(next);
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
else if (next == 'x' && i + 3 < in.length() && std::isxdigit(static_cast<unsigned char>(in[i + 2])) && std::isxdigit(static_cast<unsigned char>(in[i + 3])))
|
||||||
|
{
|
||||||
|
auto hexval = [](char c) -> int
|
||||||
|
{
|
||||||
|
if (c >= '0' && c <= '9')
|
||||||
|
return c - '0';
|
||||||
|
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||||
|
if (c >= 'a' && c <= 'f')
|
||||||
|
return 10 + (c - 'a');
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const int value = (hexval(in[i + 2]) << 4) | hexval(in[i + 3]);
|
||||||
|
out.push_back(static_cast<char>(value));
|
||||||
|
i += 3;
|
||||||
|
}
|
||||||
|
else if (next >= '0' && next <= '7')
|
||||||
|
{
|
||||||
|
// Octal \NNN (up to 3 digits)
|
||||||
|
int value = 0;
|
||||||
|
size_t j = i + 1;
|
||||||
|
for (int digits = 0; digits < 3 && j < in.length(); ++digits, ++j)
|
||||||
|
{
|
||||||
|
char oc = in[j];
|
||||||
|
if (oc < '0' || oc > '7')
|
||||||
|
break;
|
||||||
|
value = (value << 3) | (oc - '0');
|
||||||
|
}
|
||||||
|
out.push_back(static_cast<char>(value & 0xFF));
|
||||||
|
i = j - 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Unknown escape; keep the next char and drop the backslash.
|
||||||
|
out.push_back(next);
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
Anope::string BuildResponse(const Anope::string &title, const Anope::string &duration, const Anope::string &viewCount) const
|
||||||
|
{
|
||||||
|
if (!this->response_format.empty())
|
||||||
|
{
|
||||||
|
// Simple token replacement (no printf-style formatting).
|
||||||
|
Anope::string out = this->response_format;
|
||||||
|
out = out.replace_all_cs("{title}", title);
|
||||||
|
out = out.replace_all_cs("{duration}", duration);
|
||||||
|
out = out.replace_all_cs("{views}", viewCount);
|
||||||
|
out = out.replace_all_cs("{viewCount}", viewCount);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backwards-compatible default.
|
||||||
|
return this->prefix + " " + title + " - " + this->duration_text + duration + " - " + this->seen_text + viewCount + this->times_text;
|
||||||
|
}
|
||||||
|
|
||||||
|
static size_t WriteCallback(void* contents, size_t size, size_t nmemb, void* userp)
|
||||||
|
{
|
||||||
|
((std::string*)userp)->append((char*)contents, size * nmemb);
|
||||||
|
return size * nmemb;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ParseISO8601Duration(const std::string &duration)
|
||||||
|
{
|
||||||
|
std::chrono::seconds seconds(0);
|
||||||
|
std::smatch match;
|
||||||
|
std::regex duration_regex("PT((\\d+)H)?((\\d+)M)?((\\d+)S)?");
|
||||||
|
|
||||||
|
if (std::regex_match(duration, match, duration_regex))
|
||||||
|
{
|
||||||
|
if (match[2].matched)
|
||||||
|
seconds += std::chrono::hours(std::stoi(match[2].str()));
|
||||||
|
if (match[4].matched)
|
||||||
|
seconds += std::chrono::minutes(std::stoi(match[4].str()));
|
||||||
|
if (match[6].matched)
|
||||||
|
seconds += std::chrono::seconds(std::stoi(match[6].str()));
|
||||||
|
}
|
||||||
|
|
||||||
|
auto h = std::chrono::duration_cast<std::chrono::hours>(seconds).count();
|
||||||
|
auto m = std::chrono::duration_cast<std::chrono::minutes>(seconds).count() % 60;
|
||||||
|
auto s = seconds.count() % 60;
|
||||||
|
|
||||||
|
std::ostringstream oss;
|
||||||
|
if (h > 0) oss << h << "h";
|
||||||
|
if (m > 0) oss << m << "m";
|
||||||
|
if (s > 0) oss << s << "s";
|
||||||
|
|
||||||
|
return oss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
void FetchYouTubeDetails(const Anope::string &api_key, const Anope::string &video_id, Anope::string &title, Anope::string &duration, Anope::string &viewCount)
|
||||||
|
{
|
||||||
|
if (api_key.empty())
|
||||||
|
{
|
||||||
|
Log(LOG_DEBUG) << "YouTube API key is not configured.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check persistent cache
|
||||||
|
auto *cached = YouTubeCacheEntry::Find(video_id);
|
||||||
|
if (cached && !cached->IsExpired())
|
||||||
|
{
|
||||||
|
title = cached->title;
|
||||||
|
duration = cached->duration;
|
||||||
|
viewCount = cached->view_count;
|
||||||
|
Log(LOG_DEBUG) << "m_youtube: cache hit for " << video_id;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep payload small.
|
||||||
|
const Anope::string api_url = "https://www.googleapis.com/youtube/v3/videos?id=" + video_id
|
||||||
|
+ "&part=snippet,contentDetails,statistics"
|
||||||
|
+ "&fields=items(snippet(title),contentDetails(duration),statistics(viewCount))"
|
||||||
|
+ "&key=" + api_key;
|
||||||
|
|
||||||
|
CURL *curl = curl_easy_init();
|
||||||
|
if (curl)
|
||||||
|
{
|
||||||
|
curl_easy_setopt(curl, CURLOPT_URL, api_url.c_str());
|
||||||
|
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, "Anope-m_youtube/1.0");
|
||||||
|
|
||||||
|
std::string response_string;
|
||||||
|
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response_string);
|
||||||
|
|
||||||
|
CURLcode res = curl_easy_perform(curl);
|
||||||
|
if (res == CURLE_OK)
|
||||||
|
{
|
||||||
|
long http_code = 0;
|
||||||
|
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||||
|
if (http_code != 200)
|
||||||
|
{
|
||||||
|
Log(LOG_DEBUG) << "YouTube API HTTP status " << http_code << " for video ID: " << video_id;
|
||||||
|
curl_easy_cleanup(curl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
yyjson_doc *jsonDoc = yyjson_read(response_string.c_str(), response_string.size(), 0);
|
||||||
|
if (!jsonDoc)
|
||||||
|
{
|
||||||
|
Log() << "JSON Parse Error: failed to parse YouTube API response";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
yyjson_val *root = yyjson_doc_get_root(jsonDoc);
|
||||||
|
yyjson_val *items = yyjson_obj_get(root, "items");
|
||||||
|
if (items && yyjson_is_arr(items) && yyjson_arr_size(items) > 0)
|
||||||
|
{
|
||||||
|
yyjson_val *item = yyjson_arr_get_first(items);
|
||||||
|
yyjson_val *snippet = yyjson_obj_get(item, "snippet");
|
||||||
|
yyjson_val *contentDetails = yyjson_obj_get(item, "contentDetails");
|
||||||
|
yyjson_val *statistics = yyjson_obj_get(item, "statistics");
|
||||||
|
|
||||||
|
if (snippet && contentDetails && statistics
|
||||||
|
&& yyjson_is_obj(snippet) && yyjson_is_obj(contentDetails) && yyjson_is_obj(statistics))
|
||||||
|
{
|
||||||
|
yyjson_val *titleVal = yyjson_obj_get(snippet, "title");
|
||||||
|
yyjson_val *durationVal = yyjson_obj_get(contentDetails, "duration");
|
||||||
|
yyjson_val *viewVal = yyjson_obj_get(statistics, "viewCount");
|
||||||
|
|
||||||
|
if (titleVal && yyjson_is_str(titleVal)
|
||||||
|
&& durationVal && yyjson_is_str(durationVal)
|
||||||
|
&& viewVal)
|
||||||
|
{
|
||||||
|
title = yyjson_get_str(titleVal);
|
||||||
|
duration = ParseISO8601Duration(yyjson_get_str(durationVal));
|
||||||
|
|
||||||
|
if (yyjson_is_str(viewVal))
|
||||||
|
viewCount = yyjson_get_str(viewVal);
|
||||||
|
else if (yyjson_is_uint(viewVal))
|
||||||
|
viewCount = Anope::ToString(yyjson_get_uint(viewVal));
|
||||||
|
else if (yyjson_is_sint(viewVal))
|
||||||
|
viewCount = Anope::ToString(yyjson_get_sint(viewVal));
|
||||||
|
else
|
||||||
|
Log() << "Invalid JSON response: statistics.viewCount is not a string or integer";
|
||||||
|
|
||||||
|
// Store in persistent cache
|
||||||
|
if (!title.empty())
|
||||||
|
{
|
||||||
|
auto *entry = YouTubeCacheEntry::FindOrCreate(video_id);
|
||||||
|
entry->title = title;
|
||||||
|
entry->duration = duration;
|
||||||
|
entry->view_count = viewCount;
|
||||||
|
entry->expires_at = Anope::CurTime + cache_ttl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log() << "Invalid JSON response: missing/invalid members in snippet, contentDetails, or statistics";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log() << "Invalid JSON response: missing/invalid snippet, contentDetails, or statistics";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log() << "Invalid JSON response: 'items' field missing, not an array, or empty";
|
||||||
|
}
|
||||||
|
yyjson_doc_free(jsonDoc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log() << "CURL Error: " << curl_easy_strerror(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_easy_cleanup(curl);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log() << "CURL Initialization Error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void HandleYouTubeLink(const Anope::string &api_key, ChannelInfo *ci, Channel *c, User *u, const Anope::string &message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Supports:
|
||||||
|
// - https://www.youtube.com/watch?v=VIDEOID
|
||||||
|
// - https://youtu.be/VIDEOID
|
||||||
|
// - https://www.youtube.com/shorts/VIDEOID
|
||||||
|
// - https://www.youtube.com/embed/VIDEOID
|
||||||
|
// Also accepts m.youtube.com and trailing query/fragment.
|
||||||
|
std::regex youtube_regex(
|
||||||
|
"https?://(?:www\\.|m\\.)?(?:youtube\\.com/(?:watch\\?v=|shorts/|embed/)|youtu\\.be/)([A-Za-z0-9_-]{11})(?:[?&#/].*)?",
|
||||||
|
std::regex::icase);
|
||||||
|
std::smatch match;
|
||||||
|
if (std::regex_search(message.begin(), message.end(), match, youtube_regex) && match.size() > 1)
|
||||||
|
{
|
||||||
|
// Spam / rate-limit check (only for live channel messages)
|
||||||
|
if (u && c && spam_kick_enabled && ci->bi)
|
||||||
|
{
|
||||||
|
Anope::string spam_key = ci->name + "\n" + u->nick;
|
||||||
|
auto ×tamps = spam_map[spam_key];
|
||||||
|
const time_t now = Anope::CurTime;
|
||||||
|
|
||||||
|
// Drop timestamps outside the window
|
||||||
|
timestamps.erase(
|
||||||
|
std::remove_if(timestamps.begin(), timestamps.end(),
|
||||||
|
[&](time_t t) { return now - t > spam_window; }),
|
||||||
|
timestamps.end());
|
||||||
|
|
||||||
|
timestamps.push_back(now);
|
||||||
|
|
||||||
|
if (static_cast<int>(timestamps.size()) > spam_limit)
|
||||||
|
{
|
||||||
|
timestamps.clear();
|
||||||
|
YouTubeChannelStats::FindOrCreate(ci->name)->kick_count++;
|
||||||
|
YouTubeUserStats::FindOrCreate(u->nick)->kick_count++;
|
||||||
|
Log(LOG_DEBUG) << "m_youtube: kicking " << u->nick << " from " << ci->name << " for URL spam";
|
||||||
|
c->Kick(ci->bi, u, spam_kick_reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Anope::string video_id = match.str(1).c_str();
|
||||||
|
Anope::string title, duration, viewCount;
|
||||||
|
FetchYouTubeDetails(api_key, video_id, title, duration, viewCount);
|
||||||
|
|
||||||
|
if (!title.empty() && !duration.empty() && !viewCount.empty())
|
||||||
|
{
|
||||||
|
IRCD->SendPrivmsg(*ci->bi, ci->name, this->BuildResponse(title, duration, viewCount));
|
||||||
|
ci->bi->lastmsg = Anope::CurTime;
|
||||||
|
|
||||||
|
if (u && c)
|
||||||
|
{
|
||||||
|
YouTubeChannelStats::FindOrCreate(ci->name)->link_count++;
|
||||||
|
YouTubeUserStats::FindOrCreate(u->nick)->link_count++;
|
||||||
|
auto *ce = YouTubeCacheEntry::Find(video_id);
|
||||||
|
if (ce)
|
||||||
|
{
|
||||||
|
ce->share_count++;
|
||||||
|
ce->last_seen = Anope::CurTime;
|
||||||
|
ce->last_channel = ci->name;
|
||||||
|
ce->last_nick = u->nick;
|
||||||
|
if (ce->first_seen == 0)
|
||||||
|
ce->first_seen = Anope::CurTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log(LOG_DEBUG) << "Failed to fetch YouTube details for video ID: " << video_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (const std::regex_error &e)
|
||||||
|
{
|
||||||
|
Log() << "Regex error: " << e.what();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public:
|
||||||
|
CommandBSYouTube(Module *creator) : Command(creator, "botserv/youtube", 2, 2)
|
||||||
|
{
|
||||||
|
this->SetDesc(_("Handles YouTube links and fetches video details"));
|
||||||
|
this->SetSyntax(_("\037channel\037 \037message\037"));
|
||||||
|
|
||||||
|
// Reasonable defaults (can be overridden in config).
|
||||||
|
this->prefix = "\x02\x03""01,00You\x03""00,04Tube\x0F\x02";
|
||||||
|
this->duration_text = "Duration: ";
|
||||||
|
this->seen_text = "Seen: ";
|
||||||
|
this->times_text = " times.";
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetAPIKey(const Anope::string &key)
|
||||||
|
{
|
||||||
|
this->api_key = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetResponseConfig(const Anope::string &prefix,
|
||||||
|
const Anope::string &duration_text,
|
||||||
|
const Anope::string &seen_text,
|
||||||
|
const Anope::string ×_text,
|
||||||
|
const Anope::string &response_format)
|
||||||
|
{
|
||||||
|
if (!prefix.empty())
|
||||||
|
this->prefix = UnescapeConfigString(prefix);
|
||||||
|
if (!duration_text.empty())
|
||||||
|
this->duration_text = UnescapeConfigString(duration_text);
|
||||||
|
if (!seen_text.empty())
|
||||||
|
this->seen_text = UnescapeConfigString(seen_text);
|
||||||
|
if (!times_text.empty())
|
||||||
|
this->times_text = UnescapeConfigString(times_text);
|
||||||
|
this->response_format = UnescapeConfigString(response_format);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetSpamConfig(int limit, int window, bool kick_enabled, const Anope::string &kick_reason, int ttl)
|
||||||
|
{
|
||||||
|
this->spam_limit = limit;
|
||||||
|
this->spam_window = window;
|
||||||
|
this->spam_kick_enabled = kick_enabled;
|
||||||
|
this->spam_kick_reason = kick_reason.empty() ? "YouTube link spam" : kick_reason;
|
||||||
|
this->cache_ttl = ttl;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Execute(CommandSource &source, const std::vector<Anope::string> ¶ms) override
|
||||||
|
{
|
||||||
|
const Anope::string &channel = params[0];
|
||||||
|
const Anope::string &message = params[1];
|
||||||
|
|
||||||
|
ChannelInfo *ci = ChannelInfo::Find(channel);
|
||||||
|
if (ci == NULL)
|
||||||
|
{
|
||||||
|
source.Reply(CHAN_X_NOT_REGISTERED, channel.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!source.AccessFor(ci).HasPriv("SAY") && !source.HasPriv("botserv/administration"))
|
||||||
|
{
|
||||||
|
source.Reply(ACCESS_DENIED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ci->bi)
|
||||||
|
{
|
||||||
|
source.Reply(BOT_NOT_ASSIGNED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ci->c || !ci->c->FindUser(ci->bi))
|
||||||
|
{
|
||||||
|
source.Reply(BOT_NOT_ON_CHANNEL, ci->name.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message.empty() && message[0] == '\001')
|
||||||
|
{
|
||||||
|
this->OnSyntaxError(source, "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HandleYouTubeLink(this->api_key, ci, nullptr, nullptr, message);
|
||||||
|
|
||||||
|
bool override = !source.AccessFor(ci).HasPriv("SAY");
|
||||||
|
Log(override ? LOG_OVERRIDE : LOG_COMMAND, source, this, ci) << "to fetch YouTube info";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool OnHelp(CommandSource &source, const Anope::string &subcommand) override
|
||||||
|
{
|
||||||
|
this->SendSyntax(source);
|
||||||
|
source.Reply(" ");
|
||||||
|
source.Reply(_("Handles YouTube links and fetches video details when a YouTube link is posted in the channel."));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
friend class YouTubeModule;
|
||||||
|
};
|
||||||
|
|
||||||
|
class CommandBSYouTubeStats final : public Command
|
||||||
|
{
|
||||||
|
private:
|
||||||
|
static Anope::string FormatTime(time_t t)
|
||||||
|
{
|
||||||
|
if (t == 0)
|
||||||
|
return "never";
|
||||||
|
char buf[32];
|
||||||
|
struct tm *tm_info = localtime(&t);
|
||||||
|
strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", tm_info);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
public:
|
||||||
|
CommandBSYouTubeStats(Module *creator) : Command(creator, "botserv/ytstats", 2, 2)
|
||||||
|
{
|
||||||
|
this->SetDesc(_("Show YouTube module usage statistics"));
|
||||||
|
this->SetSyntax(_("VIDEO \037video_id\037"));
|
||||||
|
this->SetSyntax(_("CHANNEL \037#channel\037"));
|
||||||
|
this->SetSyntax(_("USER \037nick\037"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send a message either through the channel bot (fantasy) or via source.Reply (PM).
|
||||||
|
void Say(CommandSource &source, const char *fmt, ...) ATTR_FORMAT(3, 4)
|
||||||
|
{
|
||||||
|
char buf[1024];
|
||||||
|
va_list args;
|
||||||
|
va_start(args, fmt);
|
||||||
|
vsnprintf(buf, sizeof(buf), fmt, args);
|
||||||
|
va_end(args);
|
||||||
|
|
||||||
|
if (source.c && source.c->ci && source.c->ci->bi)
|
||||||
|
{
|
||||||
|
IRCD->SendPrivmsg(*source.c->ci->bi, source.c->name, buf);
|
||||||
|
source.c->ci->bi->lastmsg = Anope::CurTime;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
source.Reply("%s", buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Execute(CommandSource &source, const std::vector<Anope::string> ¶ms) override
|
||||||
|
{
|
||||||
|
const Anope::string type = params[0].upper();
|
||||||
|
const Anope::string &target = params[1];
|
||||||
|
|
||||||
|
if (type == "VIDEO")
|
||||||
|
{
|
||||||
|
const YouTubeCacheEntry *e = YouTubeCacheEntry::Find(target);
|
||||||
|
if (!e)
|
||||||
|
{
|
||||||
|
Say(source, "No data found for video \002%s\002.", target.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Say(source, "Video \002%s\002: \"%s\" | %s | %s views",
|
||||||
|
target.c_str(), e->title.c_str(),
|
||||||
|
e->duration.c_str(), e->view_count.c_str());
|
||||||
|
Say(source, " Shared \002%s\002 time(s) | First seen: %s | Last seen: %s",
|
||||||
|
Anope::ToString(e->share_count).c_str(),
|
||||||
|
FormatTime(e->first_seen).c_str(),
|
||||||
|
FormatTime(e->last_seen).c_str());
|
||||||
|
if (!e->last_nick.empty())
|
||||||
|
Say(source, " Last posted by \002%s\002 in \002%s\002",
|
||||||
|
e->last_nick.c_str(), e->last_channel.c_str());
|
||||||
|
}
|
||||||
|
else if (type == "CHANNEL")
|
||||||
|
{
|
||||||
|
const YouTubeChannelStats *e = YouTubeChannelStats::Find(target);
|
||||||
|
if (!e)
|
||||||
|
{
|
||||||
|
Say(source, "No data found for channel \002%s\002.", target.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Say(source, "Channel \002%s\002: \002%s\002 link(s) posted, \002%s\002 spam kick(s).",
|
||||||
|
target.c_str(),
|
||||||
|
Anope::ToString(e->link_count).c_str(),
|
||||||
|
Anope::ToString(e->kick_count).c_str());
|
||||||
|
}
|
||||||
|
else if (type == "USER")
|
||||||
|
{
|
||||||
|
const YouTubeUserStats *e = YouTubeUserStats::Find(target);
|
||||||
|
if (!e)
|
||||||
|
{
|
||||||
|
Say(source, "No data found for user \002%s\002.", target.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Say(source, "User \002%s\002: \002%s\002 link(s) posted, \002%s\002 spam kick(s).",
|
||||||
|
target.c_str(),
|
||||||
|
Anope::ToString(e->link_count).c_str(),
|
||||||
|
Anope::ToString(e->kick_count).c_str());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this->OnSyntaxError(source, type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool OnHelp(CommandSource &source, const Anope::string &) override
|
||||||
|
{
|
||||||
|
this->SendSyntax(source);
|
||||||
|
source.Reply(" ");
|
||||||
|
source.Reply(_("Shows YouTube module usage statistics."));
|
||||||
|
source.Reply(_("VIDEO <id> Show share count, duration, and first/last seen info."));
|
||||||
|
source.Reply(_("CHANNEL <#chan> Show total links posted and spam kicks for a channel."));
|
||||||
|
source.Reply(_("USER <nick> Show total links posted and spam kicks for a user."));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class YouTubeModule final : public Module
|
||||||
|
{
|
||||||
|
CommandBSYouTube commandbsYouTube;
|
||||||
|
CommandBSYouTubeStats commandbsYouTubeStats;
|
||||||
|
YouTubeCacheDataType youtube_cache_type;
|
||||||
|
YouTubeChanStatsDataType youtube_chan_stats_type;
|
||||||
|
YouTubeUserStatsDataType youtube_user_stats_type;
|
||||||
|
Anope::string api_key;
|
||||||
|
Anope::string prefix;
|
||||||
|
Anope::string duration_text;
|
||||||
|
Anope::string seen_text;
|
||||||
|
Anope::string times_text;
|
||||||
|
Anope::string response_format;
|
||||||
|
|
||||||
|
void LoadConfig(Configuration::Conf &conf)
|
||||||
|
{
|
||||||
|
const auto &modconf = conf.GetModule(this);
|
||||||
|
|
||||||
|
// Prefer youtube_api_key but accept api_key for convenience.
|
||||||
|
this->api_key = modconf.Get<Anope::string>("youtube_api_key");
|
||||||
|
if (this->api_key.empty())
|
||||||
|
this->api_key = modconf.Get<Anope::string>("api_key");
|
||||||
|
|
||||||
|
if (this->api_key.empty())
|
||||||
|
Log() << "m_youtube: youtube_api_key not set in modules.conf; video info lookups are disabled.";
|
||||||
|
|
||||||
|
// Response text customization.
|
||||||
|
this->prefix = modconf.Get<Anope::string>("prefix", "\x02\x03""01,00You\x03""00,04Tube\x0F\x02");
|
||||||
|
|
||||||
|
// Allow short key names for convenience.
|
||||||
|
this->duration_text = modconf.Get<Anope::string>("duration_text");
|
||||||
|
if (this->duration_text.empty())
|
||||||
|
this->duration_text = modconf.Get<Anope::string>("duration", "Duration: ");
|
||||||
|
|
||||||
|
this->seen_text = modconf.Get<Anope::string>("seen_text");
|
||||||
|
if (this->seen_text.empty())
|
||||||
|
this->seen_text = modconf.Get<Anope::string>("seen", "Seen: ");
|
||||||
|
|
||||||
|
this->times_text = modconf.Get<Anope::string>("times_text");
|
||||||
|
if (this->times_text.empty())
|
||||||
|
this->times_text = modconf.Get<Anope::string>("times", " times.");
|
||||||
|
|
||||||
|
this->response_format = modconf.Get<Anope::string>("response_format");
|
||||||
|
if (this->response_format.empty())
|
||||||
|
this->response_format = modconf.Get<Anope::string>("format");
|
||||||
|
|
||||||
|
commandbsYouTube.SetAPIKey(this->api_key);
|
||||||
|
commandbsYouTube.SetResponseConfig(this->prefix, this->duration_text, this->seen_text, this->times_text, this->response_format);
|
||||||
|
|
||||||
|
const int spam_limit = modconf.Get<int>("spam_limit", "3");
|
||||||
|
const int spam_window = modconf.Get<int>("spam_window", "60");
|
||||||
|
const bool spam_kick = modconf.Get<bool>("spam_kick", "yes");
|
||||||
|
const Anope::string spam_reason = modconf.Get<Anope::string>("spam_kick_reason", "YouTube link spam");
|
||||||
|
const int cache_ttl = modconf.Get<int>("cache_ttl", "300");
|
||||||
|
commandbsYouTube.SetSpamConfig(spam_limit, spam_window, spam_kick, spam_reason, cache_ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public:
|
||||||
|
YouTubeModule(const Anope::string &modname, const Anope::string &creator)
|
||||||
|
: Module(modname, creator, VENDOR)
|
||||||
|
, commandbsYouTube(this)
|
||||||
|
, commandbsYouTubeStats(this)
|
||||||
|
{
|
||||||
|
// Configuration is loaded via OnReload.
|
||||||
|
}
|
||||||
|
|
||||||
|
~YouTubeModule()
|
||||||
|
{
|
||||||
|
// Delete every in-memory record on unload. Each ~Serializable removes
|
||||||
|
// itself from its list (and from Anope's global SerializableItems), so
|
||||||
|
// this must run while the module's code is still mapped -- otherwise the
|
||||||
|
// objects leak and leave dangling vtable pointers in the global list.
|
||||||
|
while (!YouTubeCacheList->empty())
|
||||||
|
{
|
||||||
|
auto it = YouTubeCacheList->begin();
|
||||||
|
YouTubeCacheEntry *e = it->second;
|
||||||
|
if (!e) { YouTubeCacheList->erase(it); continue; }
|
||||||
|
delete e;
|
||||||
|
}
|
||||||
|
while (!YouTubeChanStatsList->empty())
|
||||||
|
{
|
||||||
|
auto it = YouTubeChanStatsList->begin();
|
||||||
|
YouTubeChannelStats *e = it->second;
|
||||||
|
if (!e) { YouTubeChanStatsList->erase(it); continue; }
|
||||||
|
delete e;
|
||||||
|
}
|
||||||
|
while (!YouTubeUserStatsList->empty())
|
||||||
|
{
|
||||||
|
auto it = YouTubeUserStatsList->begin();
|
||||||
|
YouTubeUserStats *e = it->second;
|
||||||
|
if (!e) { YouTubeUserStatsList->erase(it); continue; }
|
||||||
|
delete e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnReload(Configuration::Conf &conf) override
|
||||||
|
{
|
||||||
|
this->LoadConfig(conf);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnShutdown() override
|
||||||
|
{
|
||||||
|
Anope::SaveDatabases();
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnRestart() override
|
||||||
|
{
|
||||||
|
Anope::SaveDatabases();
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnPrivmsg(User *u, Channel *c, Anope::string &msg, const Anope::map<Anope::string> &tags) override
|
||||||
|
{
|
||||||
|
if (u == nullptr || c == nullptr || c->ci == nullptr || c->ci->bi == nullptr || msg.empty() || msg[0] == '\1')
|
||||||
|
return;
|
||||||
|
|
||||||
|
commandbsYouTube.HandleYouTubeLink(this->api_key, c->ci, c, u, msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
MODULE_INIT(YouTubeModule)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue