Files
sm-plugins/EventNotifier/scripting/EventNotifier.sp
T
2026-09-28 21:40:26 +01:00

393 lines
9.6 KiB
SourcePawn

#include <sourcemod>
#include <ripext>
#define EVENTNOTIFIER_CORE
#include <EventNotifier>
#pragma semicolon 1
#pragma newdecls required
#define PLUGIN_VERSION "1.0"
ConVar g_cvUrl;
ConVar g_cvServer;
ConVar g_cvInterval;
ArrayList g_Events;
Handle g_hRefreshTimer;
bool g_bFetching;
bool g_bEverFetched;
GlobalForward g_fwdOnEventsUpdated;
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);
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_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");
}
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;
}
// ---------------------------------------------------------------------------
// 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;
}
// ---------------------------------------------------------------------------
// 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;
}