#include #include #define EVENTNOTIFIER_CORE #include #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(data); if (!root.HasKey("events") || root.IsNull("events")) { LogError("Event fetch failed: response has no 'events' array"); return; } JSONArray events = view_as(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(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(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(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; }