588 lines
15 KiB
SourcePawn
588 lines
15 KiB
SourcePawn
#include <sourcemod>
|
|
#include <ripext>
|
|
#include <geoip>
|
|
|
|
#define EVENTNOTIFIER_CORE
|
|
#include <EventNotifier>
|
|
|
|
#pragma semicolon 1
|
|
#pragma newdecls required
|
|
|
|
#define PLUGIN_VERSION "1.1"
|
|
|
|
#define TIMEZONE_FILE "configs/eventnotifier_timezones.cfg"
|
|
#define TIMEZONE_NAME_LEN 64
|
|
|
|
enum struct TzPeriod
|
|
{
|
|
int start; // unix timestamp this offset applies from
|
|
int offset; // seconds east of UTC
|
|
char abbr[12];
|
|
}
|
|
|
|
ConVar g_cvUrl;
|
|
ConVar g_cvServer;
|
|
ConVar g_cvInterval;
|
|
ConVar g_cvFallbackZone;
|
|
|
|
ArrayList g_Events;
|
|
Handle g_hRefreshTimer;
|
|
bool g_bFetching;
|
|
bool g_bEverFetched;
|
|
|
|
GlobalForward g_fwdOnEventsUpdated;
|
|
|
|
// Zone name -> ArrayList of TzPeriod, ascending by start.
|
|
StringMap g_Timezones;
|
|
char g_sClientZone[MAXPLAYERS + 1][TIMEZONE_NAME_LEN];
|
|
|
|
public Plugin myinfo =
|
|
{
|
|
name = "EventNotifier",
|
|
author = "marisakirisame.net",
|
|
description = "Fetches upcoming Unloze events and exposes them to other plugins",
|
|
version = PLUGIN_VERSION,
|
|
url = "https://marisakirisame.net"
|
|
};
|
|
|
|
public APLRes AskPluginLoad2(Handle myself, bool late, char[] error, int err_max)
|
|
{
|
|
CreateNative("EventNotifier_GetEventCount", Native_GetEventCount);
|
|
CreateNative("EventNotifier_HasFetched", Native_HasFetched);
|
|
CreateNative("EventNotifier_FindEventByNumber", Native_FindEventByNumber);
|
|
CreateNative("EventNotifier_GetEvent", Native_GetEvent);
|
|
CreateNative("EventNotifier_Refresh", Native_Refresh);
|
|
CreateNative("EventNotifier_FormatClientTime", Native_FormatClientTime);
|
|
RegPluginLibrary("EventNotifier");
|
|
return APLRes_Success;
|
|
}
|
|
|
|
public void OnPluginStart()
|
|
{
|
|
g_Events = new ArrayList(sizeof(EventInfo));
|
|
g_fwdOnEventsUpdated = new GlobalForward("EventNotifier_OnEventsUpdated", ET_Ignore);
|
|
|
|
g_cvUrl = CreateConVar("sm_event_url", "https://marisakirisame.net/unloze-events.json",
|
|
"Base URL of the Unloze events API (query parameters are appended automatically)");
|
|
g_cvServer = CreateConVar("sm_event_server", "ze",
|
|
"Value of the 'server' query parameter sent to the events API");
|
|
g_cvInterval = CreateConVar("sm_event_refresh_interval", "1800",
|
|
"How often to re-fetch the event list, in seconds", _, true, 30.0);
|
|
g_cvFallbackZone = CreateConVar("sm_event_fallback_timezone", "UTC",
|
|
"Timezone (IANA name, e.g. Europe/London) used for players whose timezone cannot be looked up");
|
|
|
|
g_cvUrl.AddChangeHook(OnSourceChanged);
|
|
g_cvServer.AddChangeHook(OnSourceChanged);
|
|
g_cvInterval.AddChangeHook(OnIntervalChanged);
|
|
|
|
AutoExecConfig(true, "EventNotifier");
|
|
|
|
RegAdminCmd("sm_event_refresh", Command_EventRefresh, ADMFLAG_GENERIC, "Re-fetches the event list now");
|
|
|
|
LoadTimezones();
|
|
|
|
// Late load: look up everyone already on the server.
|
|
for (int client = 1; client <= MaxClients; client++)
|
|
{
|
|
if (IsClientConnected(client))
|
|
OnClientConnected(client);
|
|
}
|
|
}
|
|
|
|
public void OnClientConnected(int client)
|
|
{
|
|
g_sClientZone[client][0] = '\0';
|
|
if (IsFakeClient(client))
|
|
return;
|
|
|
|
char ip[32];
|
|
if (GetClientIP(client, ip, sizeof(ip)) && !GeoipTimezone(ip, g_sClientZone[client], sizeof(g_sClientZone[])))
|
|
g_sClientZone[client][0] = '\0';
|
|
}
|
|
|
|
public void OnClientDisconnect(int client)
|
|
{
|
|
g_sClientZone[client][0] = '\0';
|
|
}
|
|
|
|
public void OnConfigsExecuted()
|
|
{
|
|
RestartRefreshTimer();
|
|
FetchEvents();
|
|
}
|
|
|
|
public void OnSourceChanged(ConVar convar, const char[] oldValue, const char[] newValue)
|
|
{
|
|
FetchEvents();
|
|
}
|
|
|
|
public void OnIntervalChanged(ConVar convar, const char[] oldValue, const char[] newValue)
|
|
{
|
|
RestartRefreshTimer();
|
|
}
|
|
|
|
void RestartRefreshTimer()
|
|
{
|
|
delete g_hRefreshTimer;
|
|
g_hRefreshTimer = CreateTimer(g_cvInterval.FloatValue, Timer_Refresh, _, TIMER_REPEAT);
|
|
}
|
|
|
|
public Action Timer_Refresh(Handle timer)
|
|
{
|
|
FetchEvents();
|
|
return Plugin_Continue;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Natives
|
|
// ---------------------------------------------------------------------------
|
|
|
|
public any Native_GetEventCount(Handle plugin, int numParams)
|
|
{
|
|
return g_Events.Length;
|
|
}
|
|
|
|
public any Native_HasFetched(Handle plugin, int numParams)
|
|
{
|
|
return g_bEverFetched;
|
|
}
|
|
|
|
public any Native_FindEventByNumber(Handle plugin, int numParams)
|
|
{
|
|
return FindEventByNumber(GetNativeCell(1));
|
|
}
|
|
|
|
public any Native_GetEvent(Handle plugin, int numParams)
|
|
{
|
|
int index = GetNativeCell(1);
|
|
int size = GetNativeCell(3);
|
|
|
|
if (size < sizeof(EventInfo))
|
|
return ThrowNativeError(SP_ERROR_NATIVE, "EventInfo buffer too small (%d < %d), recompile against the current EventNotifier.inc", size, sizeof(EventInfo));
|
|
|
|
if (index < 0 || index >= g_Events.Length)
|
|
return false;
|
|
|
|
EventInfo ev;
|
|
g_Events.GetArray(index, ev);
|
|
SetNativeArray(2, ev, sizeof(EventInfo));
|
|
return true;
|
|
}
|
|
|
|
public any Native_Refresh(Handle plugin, int numParams)
|
|
{
|
|
FetchEvents();
|
|
return 0;
|
|
}
|
|
|
|
public any Native_FormatClientTime(Handle plugin, int numParams)
|
|
{
|
|
int client = GetNativeCell(1);
|
|
if (client < 0 || client > MaxClients)
|
|
return ThrowNativeError(SP_ERROR_NATIVE, "Invalid client index %d", client);
|
|
|
|
int maxlen = GetNativeCell(4);
|
|
char[] buffer = new char[maxlen];
|
|
FormatClientTime(client, GetNativeCell(2), buffer, maxlen, GetNativeCell(5));
|
|
SetNativeString(3, buffer, maxlen);
|
|
return 0;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fetching
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void FetchEvents()
|
|
{
|
|
if (g_bFetching)
|
|
return;
|
|
|
|
char url[256];
|
|
g_cvUrl.GetString(url, sizeof(url));
|
|
if (!url[0])
|
|
{
|
|
LogError("sm_event_url is empty, cannot fetch events");
|
|
return;
|
|
}
|
|
|
|
char server[64];
|
|
g_cvServer.GetString(server, sizeof(server));
|
|
|
|
HTTPRequest request = new HTTPRequest(url);
|
|
request.AppendQueryParam("server", "%s", server);
|
|
request.AppendQueryParam("future", "1");
|
|
request.SetHeader("Accept", "application/json");
|
|
request.Timeout = 20;
|
|
|
|
g_bFetching = true;
|
|
request.Get(OnEventsFetched);
|
|
}
|
|
|
|
public void OnEventsFetched(HTTPResponse response, any value, const char[] error)
|
|
{
|
|
g_bFetching = false;
|
|
|
|
if (error[0])
|
|
{
|
|
LogError("Event fetch failed: %s", error);
|
|
return;
|
|
}
|
|
|
|
if (response.Status != HTTPStatus_OK)
|
|
{
|
|
LogError("Event fetch failed: HTTP %d", response.Status);
|
|
return;
|
|
}
|
|
|
|
JSON data = response.Data;
|
|
if (data == null)
|
|
{
|
|
LogError("Event fetch failed: response was not valid JSON");
|
|
return;
|
|
}
|
|
|
|
JSONObject root = view_as<JSONObject>(data);
|
|
if (!root.HasKey("events") || root.IsNull("events"))
|
|
{
|
|
LogError("Event fetch failed: response has no 'events' array");
|
|
return;
|
|
}
|
|
|
|
JSONArray events = view_as<JSONArray>(root.Get("events"));
|
|
ArrayList parsed = new ArrayList(sizeof(EventInfo));
|
|
|
|
int count = events.Length;
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
if (events.IsNull(i))
|
|
continue;
|
|
|
|
JSONObject obj = view_as<JSONObject>(events.Get(i));
|
|
EventInfo ev;
|
|
ParseEvent(obj, ev);
|
|
delete obj;
|
|
|
|
parsed.PushArray(ev);
|
|
}
|
|
delete events;
|
|
|
|
// Soonest first so "the next N events" means something regardless of API order.
|
|
parsed.SortCustom(SortEventsByStart);
|
|
|
|
int previous = g_Events.Length;
|
|
delete g_Events;
|
|
g_Events = parsed;
|
|
|
|
if (!g_bEverFetched || previous != g_Events.Length)
|
|
LogMessage("Fetched %d upcoming event(s)", g_Events.Length);
|
|
g_bEverFetched = true;
|
|
|
|
Call_StartForward(g_fwdOnEventsUpdated);
|
|
Call_Finish();
|
|
}
|
|
|
|
// Ascending by start time, with unknown (0) start times last.
|
|
int SortEventsByStart(int index1, int index2, Handle array, Handle hndl)
|
|
{
|
|
ArrayList list = view_as<ArrayList>(array);
|
|
int a = list.Get(index1, EventInfo::start);
|
|
int b = list.Get(index2, EventInfo::start);
|
|
|
|
if (a == b)
|
|
return 0;
|
|
if (a == 0)
|
|
return 1;
|
|
if (b == 0)
|
|
return -1;
|
|
return (a < b) ? -1 : 1;
|
|
}
|
|
|
|
void ParseEvent(JSONObject obj, EventInfo ev)
|
|
{
|
|
char buf[64];
|
|
|
|
ev.number = (obj.HasKey("eventNumber") && !obj.IsNull("eventNumber")) ? obj.GetInt("eventNumber") : 0;
|
|
|
|
GetJsonString(obj, "name", ev.name, sizeof(ev.name));
|
|
if (!ev.name[0])
|
|
GetJsonString(obj, "title", ev.name, sizeof(ev.name));
|
|
|
|
GetJsonString(obj, "server", ev.server, sizeof(ev.server));
|
|
GetJsonString(obj, "rewards", ev.rewards, sizeof(ev.rewards));
|
|
GetJsonString(obj, "leader", ev.leader, sizeof(ev.leader));
|
|
GetJsonString(obj, "kickers", ev.kickers, sizeof(ev.kickers));
|
|
|
|
ev.start = 0;
|
|
if (GetJsonString(obj, "start", buf, sizeof(buf)))
|
|
ev.start = ParseIso8601(buf);
|
|
|
|
ev.end = 0;
|
|
if (GetJsonString(obj, "end", buf, sizeof(buf)))
|
|
ev.end = ParseIso8601(buf);
|
|
|
|
ev.maps[0] = '\0';
|
|
if (obj.HasKey("maps") && !obj.IsNull("maps"))
|
|
{
|
|
JSONArray maps = view_as<JSONArray>(obj.Get("maps"));
|
|
int len = maps.Length;
|
|
for (int i = 0; i < len; i++)
|
|
{
|
|
char map[128];
|
|
if (!maps.GetString(i, map, sizeof(map)) || !map[0])
|
|
continue;
|
|
|
|
if (ev.maps[0])
|
|
StrCat(ev.maps, sizeof(ev.maps), ", ");
|
|
StrCat(ev.maps, sizeof(ev.maps), map);
|
|
}
|
|
delete maps;
|
|
}
|
|
}
|
|
|
|
bool GetJsonString(JSONObject obj, const char[] key, char[] buffer, int maxlen)
|
|
{
|
|
buffer[0] = '\0';
|
|
if (!obj.HasKey(key) || obj.IsNull(key))
|
|
return false;
|
|
return obj.GetString(key, buffer, maxlen) && buffer[0] != '\0';
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
public Action Command_EventRefresh(int client, int args)
|
|
{
|
|
if (g_bFetching)
|
|
{
|
|
ReplyToCommand(client, "[Events] A fetch is already in progress.");
|
|
return Plugin_Handled;
|
|
}
|
|
|
|
FetchEvents();
|
|
ReplyToCommand(client, "[Events] Refreshing the event list.");
|
|
return Plugin_Handled;
|
|
}
|
|
|
|
int FindEventByNumber(int number)
|
|
{
|
|
int count = g_Events.Length;
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
if (g_Events.Get(i, EventInfo::number) == number)
|
|
return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Timezones
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Loads the zone table generated by tools/gen_timezones.py.
|
|
void LoadTimezones()
|
|
{
|
|
g_Timezones = new StringMap();
|
|
|
|
char path[PLATFORM_MAX_PATH];
|
|
BuildPath(Path_SM, path, sizeof(path), TIMEZONE_FILE);
|
|
|
|
KeyValues kv = new KeyValues("Timezones");
|
|
if (!kv.ImportFromFile(path))
|
|
{
|
|
LogError("Could not read %s, all times will be shown in UTC", path);
|
|
delete kv;
|
|
return;
|
|
}
|
|
|
|
if (kv.GotoFirstSubKey())
|
|
{
|
|
do
|
|
{
|
|
char zone[TIMEZONE_NAME_LEN];
|
|
kv.GetSectionName(zone, sizeof(zone));
|
|
|
|
ArrayList periods = new ArrayList(sizeof(TzPeriod));
|
|
if (kv.GotoFirstSubKey(false))
|
|
{
|
|
do
|
|
{
|
|
char key[16], value[32], parts[2][16];
|
|
kv.GetSectionName(key, sizeof(key));
|
|
kv.GetString(NULL_STRING, value, sizeof(value));
|
|
if (ExplodeString(value, " ", parts, sizeof(parts), sizeof(parts[])) != 2)
|
|
continue;
|
|
|
|
TzPeriod period;
|
|
period.start = StringToInt(key);
|
|
period.offset = StringToInt(parts[0]);
|
|
strcopy(period.abbr, sizeof(period.abbr), parts[1]);
|
|
periods.PushArray(period);
|
|
} while (kv.GotoNextKey(false));
|
|
kv.GoBack();
|
|
}
|
|
|
|
if (periods.Length > 0)
|
|
{
|
|
periods.SortCustom(SortPeriodsByStart);
|
|
g_Timezones.SetValue(zone, periods);
|
|
}
|
|
else
|
|
{
|
|
delete periods;
|
|
}
|
|
} while (kv.GotoNextKey());
|
|
}
|
|
delete kv;
|
|
|
|
LogMessage("Loaded %d timezone(s)", g_Timezones.Size);
|
|
}
|
|
|
|
int SortPeriodsByStart(int index1, int index2, Handle array, Handle hndl)
|
|
{
|
|
ArrayList list = view_as<ArrayList>(array);
|
|
int a = list.Get(index1, TzPeriod::start);
|
|
int b = list.Get(index2, TzPeriod::start);
|
|
return (a == b) ? 0 : ((a < b) ? -1 : 1);
|
|
}
|
|
|
|
// Finds the offset and abbreviation in effect at stamp for the client's zone,
|
|
// falling back to sm_event_fallback_timezone and then plain UTC.
|
|
void GetClientZonePeriod(int client, int stamp, TzPeriod out)
|
|
{
|
|
ArrayList periods;
|
|
if (client <= 0 || !g_sClientZone[client][0] || !g_Timezones.GetValue(g_sClientZone[client], periods))
|
|
{
|
|
char fallback[TIMEZONE_NAME_LEN];
|
|
g_cvFallbackZone.GetString(fallback, sizeof(fallback));
|
|
if (!g_Timezones.GetValue(fallback, periods))
|
|
periods = null;
|
|
}
|
|
|
|
if (periods == null)
|
|
{
|
|
out.start = 0;
|
|
out.offset = 0;
|
|
strcopy(out.abbr, sizeof(out.abbr), "UTC");
|
|
return;
|
|
}
|
|
|
|
periods.GetArray(0, out);
|
|
int count = periods.Length;
|
|
for (int i = 1; i < count && periods.Get(i, TzPeriod::start) <= stamp; i++)
|
|
periods.GetArray(i, out);
|
|
}
|
|
|
|
// Formats stamp in the client's timezone as "Thu 01 Oct 2026, 20:00 BST"
|
|
// (or without the year). FormatTime can't be used, it only knows the server's zone.
|
|
void FormatClientTime(int client, int stamp, char[] buffer, int maxlen, bool withYear)
|
|
{
|
|
static const char weekdays[][] = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" };
|
|
static const char months[][] = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
|
|
|
|
TzPeriod period;
|
|
GetClientZonePeriod(client, stamp, period);
|
|
|
|
int local = stamp + period.offset;
|
|
int days = local / 86400;
|
|
int secs = local % 86400;
|
|
|
|
int year, month, day;
|
|
CivilFromDays(days, year, month, day);
|
|
|
|
// 1970-01-01 was a Thursday.
|
|
int weekday = (days + 4) % 7;
|
|
|
|
if (withYear)
|
|
Format(buffer, maxlen, "%s %02d %s %d, %02d:%02d %s", weekdays[weekday], day, months[month - 1], year, secs / 3600, (secs % 3600) / 60, period.abbr);
|
|
else
|
|
Format(buffer, maxlen, "%s %02d %s, %02d:%02d %s", weekdays[weekday], day, months[month - 1], secs / 3600, (secs % 3600) / 60, period.abbr);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Time helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Parses dates into a UTC unix timestamp.
|
|
// Returns 0 when the string cannot be parsed.
|
|
int ParseIso8601(const char[] str)
|
|
{
|
|
int year, month, day, hour, minute, second;
|
|
if (!ReadFixedInt(str, 0, 4, year) || str[4] != '-'
|
|
|| !ReadFixedInt(str, 5, 2, month) || str[7] != '-'
|
|
|| !ReadFixedInt(str, 8, 2, day))
|
|
return 0;
|
|
|
|
int pos = 10;
|
|
if (str[pos] == 'T' || str[pos] == ' ')
|
|
{
|
|
if (!ReadFixedInt(str, 11, 2, hour) || str[13] != ':' || !ReadFixedInt(str, 14, 2, minute))
|
|
return 0;
|
|
pos = 16;
|
|
if (str[pos] == ':')
|
|
{
|
|
if (!ReadFixedInt(str, 17, 2, second))
|
|
return 0;
|
|
pos = 19;
|
|
}
|
|
if (str[pos] == '.')
|
|
{
|
|
pos++;
|
|
while (IsCharNumeric(str[pos]))
|
|
pos++;
|
|
}
|
|
}
|
|
|
|
int offset = 0;
|
|
if (str[pos] == '+' || str[pos] == '-')
|
|
{
|
|
int sign = (str[pos] == '-') ? -1 : 1;
|
|
int offHour, offMinute;
|
|
if (!ReadFixedInt(str, pos + 1, 2, offHour))
|
|
return 0;
|
|
int minPos = (str[pos + 3] == ':') ? pos + 4 : pos + 3;
|
|
ReadFixedInt(str, minPos, 2, offMinute);
|
|
offset = sign * (offHour * 3600 + offMinute * 60);
|
|
}
|
|
|
|
int days = DaysFromCivil(year, month, day);
|
|
return days * 86400 + hour * 3600 + minute * 60 + second - offset;
|
|
}
|
|
|
|
bool ReadFixedInt(const char[] str, int pos, int digits, int &out)
|
|
{
|
|
out = 0;
|
|
for (int i = 0; i < digits; i++)
|
|
{
|
|
if (!IsCharNumeric(str[pos + i]))
|
|
return false;
|
|
out = out * 10 + (str[pos + i] - '0');
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Days since 1970-01-01 for a proper date.
|
|
int DaysFromCivil(int y, int m, int d)
|
|
{
|
|
y -= (m <= 2) ? 1 : 0;
|
|
int era = ((y >= 0) ? y : y - 399) / 400;
|
|
int yoe = y - era * 400;
|
|
int doy = (153 * (m + ((m > 2) ? -3 : 9)) + 2) / 5 + d - 1;
|
|
int doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
|
return era * 146097 + doe - 719468;
|
|
}
|
|
|
|
// Inverse of DaysFromCivil: date for a day count since 1970-01-01.
|
|
void CivilFromDays(int z, int &y, int &m, int &d)
|
|
{
|
|
z += 719468;
|
|
int era = ((z >= 0) ? z : z - 146096) / 146097;
|
|
int doe = z - era * 146097;
|
|
int yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
|
int doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
int mp = (5 * doy + 2) / 153;
|
|
d = doy - (153 * mp + 2) / 5 + 1;
|
|
m = (mp < 10) ? mp + 3 : mp - 9;
|
|
y = yoe + era * 400 + ((m <= 2) ? 1 : 0);
|
|
}
|