/*
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 .
*/
/// BEGIN CMAKE
/// target_link_libraries(${SO} PUBLIC curl ssl crypto yyjson)
/// END CMAKE
#include "module.h"
#include
#include
#include
#include
#include
#include
// ── 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;
static Serialize::Checker 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(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(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;
static Serialize::Checker 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(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(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;
static Serialize::Checker 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(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(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> 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(in[i + 2])) && std::isxdigit(static_cast(in[i + 3])))
{
auto hexval = [](char c) -> int
{
if (c >= '0' && c <= '9')
return c - '0';
c = static_cast(std::tolower(static_cast(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(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(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(seconds).count();
auto m = std::chrono::duration_cast(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(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 ¶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 ¶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 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 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("youtube_api_key");
if (this->api_key.empty())
this->api_key = modconf.Get("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("prefix", "\x02\x03""01,00You\x03""00,04Tube\x0F\x02");
// Allow short key names for convenience.
this->duration_text = modconf.Get("duration_text");
if (this->duration_text.empty())
this->duration_text = modconf.Get("duration", "Duration: ");
this->seen_text = modconf.Get("seen_text");
if (this->seen_text.empty())
this->seen_text = modconf.Get("seen", "Seen: ");
this->times_text = modconf.Get("times_text");
if (this->times_text.empty())
this->times_text = modconf.Get("times", " times.");
this->response_format = modconf.Get("response_format");
if (this->response_format.empty())
this->response_format = modconf.Get("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("spam_limit", "3");
const int spam_window = modconf.Get("spam_window", "60");
const bool spam_kick = modconf.Get("spam_kick", "yes");
const Anope::string spam_reason = modconf.Get("spam_kick_reason", "YouTube link spam");
const int cache_ttl = modconf.Get("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 &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)