--[[-------------------------------------------------------------------------- ManiaxSocial - the wire, and nothing but the wire. This file is deliberately free of every WoW API. It parses server lines, keeps the roster model, and meters the outbound queue; it never creates a frame, never calls SendAddonMessage, and never reads the clock itself - the caller passes `now` in. That is what lets tools/social-test load this exact file under gopher-lua and feed it malformed traffic, which is the only way the parser gets tested before a player finds the hole. THE PROTOCOL IS FROZEN (maniax-realm/docs/design-account-social.md). The server module is written against it. Nothing here may widen it. client -> server HELLO | LIST | ADD | ACCEPT DECLINE | DEL | NOTE PRIV <0|1> | HANDLE server -> client VER 1 ROSTER then F lines F handle|status|online|char|level|class|zone|note PRES handle|1|char|level|class|zone / PRES handle|0 REQ handle OK / ERR status: 0 = I asked and they have not answered, 1 = accepted, 2 = they asked me (wire-only; it lands in the request list, not the friend list). EVERYTHING ARRIVING IS UNTRUSTED. Lines are split on the first space and then on "|", short lines are tolerated field by field, unknown verbs are dropped in silence, and no line is ever allowed to throw - a Lua error in a CHAT_MSG_ADDON handler takes the whole event with it. TWO RULES FROM THE HOUSE DOCTRINE ARE LOAD-BEARING HERE Never cache a failure. A ROSTER whose F lines never turn up leaves the previous roster standing and retries; it does not become an empty list. The only thing that empties the roster is the server saying "ROSTER 0". Never trust stale local data. Nothing in here is saved to disk. The roster is re-requested on every login, because the copy on the server is the only copy that is true. ----------------------------------------------------------------------------]] ManiaxSocial = ManiaxSocial or {} local NS = ManiaxSocial NS.PREFIX = "MXSOC" NS.PROTOCOL_VERSION = 1 NS.STATUS_PENDING_OUT = 0 NS.STATUS_ACCEPTED = 1 NS.STATUS_PENDING_IN = 2 -- The server allows 4 messages a second per session. Three is the client's -- own ceiling: the margin covers the fact that our clock and the server's -- disagree about where a second starts. NS.SEND_PER_SECOND = 3 -- How long HELLO waits for VER before the tab says the service is not there, -- and the backoff it then retries on. Last value repeats forever - a realm -- that comes back an hour later still gets picked up without a reload. NS.HELLO_TIMEOUT = 8 NS.RETRY_DELAYS = { 15, 30, 60, 120, 300 } -- A ROSTER that never completes is a failed read, not an empty roster. NS.ROSTER_TIMEOUT = 10 local MAX_FRIENDS = 100 local MAX_REQUESTS = 20 --[[ small string helpers ------------------------------------------------- ]] -- Plain split on a one-character separator. Written out rather than using -- strsplit because strsplit is a WoW global and this file has to load in a -- bare interpreter as well. function NS.Split(text, sep) local out, from = {}, 1 if type(text) ~= "string" then return out end while true do local at = string.find(text, sep, from, true) if not at then out[#out + 1] = string.sub(text, from) return out end out[#out + 1] = string.sub(text, from, at - 1) from = at + 1 end end function NS.Trim(text) if type(text) ~= "string" then return "" end return (string.gsub(text, "^%s*(.-)%s*$", "%1")) end -- Field n of a pipe-split payload, "" when the line was short. local function field(parts, n) local v = parts[n] if v == nil then return "" end return NS.Trim(v) end local function number(parts, n) local v = tonumber(field(parts, n)) return v end --[[ state ---------------------------------------------------------------- ]] local function newState() return { available = false, -- a VER has been seen this session version = nil, handle = nil, -- our own handle, if the realm ever tells us showCharacter = nil, -- our own privacy flag, likewise friends = {}, -- array, server order byHandle = {}, -- handle -> friend record requests = {}, -- array of {handle = ...} requestSeen = {}, -- handle -> true, so REQ arrives twice harmlessly staging = nil, -- in-flight ROSTER lastError = nil, -- {code = , text = } resyncWanted = false, -- a PRES for a handle we do not know } end NS.state = newState() function NS.Reset() NS.state = newState() NS.queue = { pending = {}, sent = {} } end function NS.Friend(handle) return NS.state.byHandle[handle] end function NS.FriendCount() return #NS.state.friends end function NS.RequestCount() return #NS.state.requests end function NS.MaxFriends() return MAX_FRIENDS end function NS.MaxRequests() return MAX_REQUESTS end --[[ roster bookkeeping --------------------------------------------------- ]] local function commit(list) local state = NS.state state.friends = list state.byHandle = {} for i = 1, #list do state.byHandle[list[i].handle] = list[i] end end local function addRequest(handle) local state = NS.state if handle == "" or state.requestSeen[handle] then return false end state.requestSeen[handle] = true state.requests[#state.requests + 1] = { handle = handle } return true end function NS.DropRequest(handle) local state = NS.state state.requestSeen[handle] = nil for i = #state.requests, 1, -1 do if state.requests[i].handle == handle then table.remove(state.requests, i) end end end --[[ line parsing --------------------------------------------------------- ]] -- "F a|b|c" -> "F", "a|b|c". "LIST" -> "LIST", "". function NS.ParseLine(line) line = NS.Trim(line) if line == "" then return nil, "" end local at = string.find(line, " ", 1, true) if not at then return string.upper(line), "" end return string.upper(string.sub(line, 1, at - 1)), string.sub(line, at + 1) end local function friendFromFields(parts) local handle = field(parts, 1) if handle == "" then return nil end return { handle = handle, status = number(parts, 2) or NS.STATUS_ACCEPTED, online = field(parts, 3) == "1", char = field(parts, 4), level = number(parts, 5), class = field(parts, 6), zone = field(parts, 7), note = field(parts, 8), } end local handlers = {} handlers.VER = function(payload, now) local state = NS.state state.available = true state.version = tonumber(NS.Trim(payload)) or 1 state.lastError = nil return "VER" end handlers.ROSTER = function(payload, now) local expected = tonumber(NS.Trim(payload)) or 0 if expected <= 0 then -- The one case where emptying the list is the truth. NS.state.staging = nil commit({}) return "ROSTER" end NS.state.staging = { expected = expected, list = {}, started = now } return nil end handlers.F = function(payload, now) local rec = friendFromFields(NS.Split(payload, "|")) if not rec then return nil end local state = NS.state if rec.status == NS.STATUS_PENDING_IN then -- An incoming request travelling as a roster row. It belongs in the -- request list, and it is the same event as REQ, so it dedupes with it. if addRequest(rec.handle) then return "REQ", rec.handle end return nil end if state.staging then local list = state.staging.list list[#list + 1] = rec if #list >= state.staging.expected then commit(list) state.staging = nil return "ROSTER" end return nil end -- Outside a ROSTER an F line is a single-row update. local existing = state.byHandle[rec.handle] if existing then for k, v in pairs(rec) do existing[k] = v end else state.friends[#state.friends + 1] = rec state.byHandle[rec.handle] = rec end return "ROSTER" end handlers.PRES = function(payload, now) local parts = NS.Split(payload, "|") local handle = field(parts, 1) if handle == "" then return nil end local rec = NS.state.byHandle[handle] if not rec then -- Presence for someone not in our roster: our copy is behind. Ask for a -- fresh LIST rather than inventing a row out of a presence packet. NS.state.resyncWanted = true return nil end if field(parts, 2) == "1" then rec.online = true rec.char = field(parts, 3) rec.level = number(parts, 4) rec.class = field(parts, 5) rec.zone = field(parts, 6) else rec.online = false rec.char, rec.level, rec.class, rec.zone = "", nil, "", "" end return "PRES" end handlers.REQ = function(payload, now) local handle = NS.Trim(payload) if addRequest(handle) then return "REQ", handle end return nil end handlers.OK = function(payload, now) NS.state.lastError = nil return "OK", string.upper(NS.Trim(payload)) end handlers.ERR = function(payload, now) local rest = NS.Trim(payload) local at = string.find(rest, " ", 1, true) local code, text if at then code, text = string.sub(rest, 1, at - 1), NS.Trim(string.sub(rest, at + 1)) else code, text = rest, "" end NS.state.lastError = { code = code, text = text } return "ERR" end -- Not in the frozen protocol. Parsed anyway so that a later module which does -- tell the client its own handle and privacy flag needs no addon change; the -- current server never sends it and the fields simply stay nil. handlers.ME = function(payload, now) local parts = NS.Split(payload, "|") local handle = field(parts, 1) if handle ~= "" then NS.state.handle = handle end local priv = field(parts, 2) if priv == "0" or priv == "1" then NS.state.showCharacter = (priv == "1") end return "ME" end -- Returns an event name for the UI (or nil), plus an optional detail. -- Never throws: an unparseable line is a dropped line. function NS.HandleLine(line, now) local verb, payload = NS.ParseLine(line) if not verb then return nil end local fn = handlers[verb] if not fn then return nil end -- Any line at all proves the service is answering. if verb ~= "VER" then NS.state.available = true end return fn(payload, now or 0) end -- A ROSTER whose F lines stopped arriving. Keep whatever did arrive if it is -- something; keep the PREVIOUS roster if it is nothing. Never blank the list -- because a read failed. function NS.ExpireStaging(now) local staging = NS.state.staging if not staging then return false end if now - staging.started < NS.ROSTER_TIMEOUT then return false end NS.state.staging = nil if #staging.list > 0 then commit(staging.list) return true end return false end --[[ outbound queue ------------------------------------------------------- ]] NS.queue = { pending = {}, sent = {} } -- Messages are queued, not sent: the caller drains this on OnUpdate. Duplicate -- LIST requests collapse, because a queue that grows one entry per keypress is -- how a client trips the server's own rate limit. function NS.Send(message) message = NS.Trim(message) if message == "" then return false end local pending = NS.queue.pending if message == "LIST" then for i = 1, #pending do if pending[i] == "LIST" then return false end end end pending[#pending + 1] = message return true end function NS.Queued() return #NS.queue.pending end -- Returns the next message to put on the wire, or nil when the budget for -- this second is spent. Call it in a loop until it returns nil. function NS.Pump(now) local q = NS.queue if #q.pending == 0 then return nil end for i = #q.sent, 1, -1 do if now - q.sent[i] >= 1 then table.remove(q.sent, i) end end if #q.sent >= NS.SEND_PER_SECOND then return nil end local message = table.remove(q.pending, 1) q.sent[#q.sent + 1] = now return message end --[[ verbs ---------------------------------------------------------------- ]] function NS.Hello() return NS.Send("HELLO " .. NS.PROTOCOL_VERSION) end function NS.List() return NS.Send("LIST") end function NS.Add(characterName) characterName = NS.Trim(characterName or "") if characterName == "" then return false end return NS.Send("ADD " .. characterName) end function NS.Accept(handle) return NS.Send("ACCEPT " .. NS.Trim(handle or "")) end function NS.Decline(handle) return NS.Send("DECLINE " .. NS.Trim(handle or "")) end function NS.Remove(handle) return NS.Send("DEL " .. NS.Trim(handle or "")) end function NS.Note(handle, text) text = NS.Trim(text or "") -- The server sanitises and caps at 48; cutting here keeps the packet inside -- the 254-byte budget and keeps the edit box honest about what will stick. text = string.sub(text, 1, 48) return NS.Send("NOTE " .. NS.Trim(handle or "") .. " " .. text) end function NS.Privacy(show) return NS.Send("PRIV " .. (show and "1" or "0")) end function NS.SetHandle(handle) return NS.Send("HANDLE " .. NS.Trim(handle or "")) end -- ^[A-Za-z][A-Za-z0-9]{2,15}$ from the design doc, spelled in Lua patterns. function NS.HandleIsLegal(handle) handle = NS.Trim(handle or "") if #handle < 3 or #handle > 16 then return false end return string.find(handle, "^%a%w*$") ~= nil end