#include #include #include #define EVENTNOTIFIER_CORE #include #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(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; } // --------------------------------------------------------------------------- // 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(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); }