diff --git a/api/chat.lua b/api/chat.lua index d4c0362..bbe33f4 100644 --- a/api/chat.lua +++ b/api/chat.lua @@ -14,6 +14,12 @@ local _current_lobby = nil local _lobby_chat_active = false local _chat_topic = nil +-- Moderation UX state (reset on cleanup): +-- _muted = { [player_id] = true } — client-side mutes; muted senders are +-- dropped from the local display. The mute ACTION is local; we also send an +-- aggregate signal so moderation can auto-review a widely-muted player. +local _muted = {} + ----------------------------- -- MESSAGE DISPLAY ----------------------------- @@ -31,11 +37,7 @@ end ----------------------------- local function make_not_enabled_cb() - return function(text) - if text:sub(1, 1) == '/' then - MPAPI.chat.addMessage(localize('k_chat_unknown_command') .. ': ' .. text, COLOUR_SYSTEM) - return - end + return function(_) if MPAPI.connection_state.chat_enabled then MPAPI.chat.addMessage(localize('k_chat_lobby_only'), COLOUR_SYSTEM) else @@ -50,9 +52,25 @@ local function make_publish_fn(lobby) MPAPI.chat.addMessage(localize('k_chat_client_disabled'), COLOUR_SYSTEM) return end - MPAPI._internal.send_chat_message(lobby.code, text, function(err, _) + -- The server rejects whitespace-only messages; don't echo them either. + if text:match('^%s*$') then + return + end + -- Optimistic local echo: show the sender their own message instantly + -- instead of waiting ~1s for the moderated MQTT echo. Recipients are + -- unaffected — they still only ever receive the moderated message. + -- subscribe_chat drops our own MQTT echo so this doesn't double-render. + local own_name = MPAPI.chat._own_name or localize('k_you') + MPAPI.chat.addMessage(own_name .. ': ' .. text, COLOUR_OWN) + MPAPI._internal.send_chat_message(lobby.code, text, function(err, data) if err then - MPAPI.chat.addMessage('[!] ' .. tostring(err), COLOUR_SYSTEM) + -- The message above never reached anyone; say so with the + -- server's reason (moderated / rate-limited / unavailable). + MPAPI.chat.addMessage(localize('k_chat_not_sent') .. ' ' .. tostring(err), COLOUR_SYSTEM) + elseif data and type(data.publishText) == 'string' and data.publishText ~= text then + -- Moderation rewrote the message; the echo above showed the raw + -- form, so tell the sender what other players actually got. + MPAPI.chat.addMessage(localize('k_chat_sent_as') .. ' ' .. data.publishText, COLOUR_SYSTEM) end end) end @@ -68,9 +86,19 @@ local function subscribe_chat(lobby) return end + -- Own messages already rendered optimistically at send time + -- (make_publish_fn); drop the MQTT echo so they don't double-render. + if sender_id == lobby.player_id then + return + end + + -- Locally muted senders never render. + if _muted[sender_id] then + return + end + local name = data.displayName or sender_id - local colour = (sender_id == lobby.player_id) and COLOUR_OWN or COLOUR_INCOMING - MPAPI.chat.addMessage(name .. ': ' .. data.message, colour) + MPAPI.chat.addMessage(name .. ': ' .. data.message, COLOUR_INCOMING) end) end @@ -81,6 +109,25 @@ local function unsubscribe_chat() _chat_topic = nil end +----------------------------- +-- MODERATION ACTIONS +----------------------------- + +-- Mute player_id for this session: local drop + best-effort aggregate signal. +-- Used by the report overlay's MUTE button. (Slash commands were removed — +-- all moderation actions go through UI surfaces: player cards, pause menu, +-- post-match screen.) +function MPAPI.chat.mute_player(player_id, name) + _muted[player_id] = true + -- Best-effort aggregate signal; the local mute above stands regardless. + -- Bridge-guarded: the intake bridge (and its relay endpoint) ships in v2, + -- so on the forward-only v1 relay this is a clean local-only mute. + if _current_lobby and MPAPI._internal.mute_signal then + MPAPI._internal.mute_signal(_current_lobby.code, player_id, function() end) + end + MPAPI.chat.addMessage(localize('k_chat_muted') .. ' ' .. name, COLOUR_SYSTEM) +end + -- Wire up send callback + subscribe for the current lobby. -- Safe to call mid-session (e.g. after enabling chat while already in a lobby). -- No-ops if already active or conditions aren't met. @@ -96,13 +143,7 @@ local function activate_lobby_chat(announce) dp_compat.send_fn = publish else MPAPI.chat._send_fn = publish - console.setSendCallback(function(text) - if text:sub(1, 1) == '/' then - MPAPI.chat.addMessage(localize('k_chat_unknown_command') .. ': ' .. text, COLOUR_SYSTEM) - return - end - publish(text) - end) + console.setSendCallback(publish) end subscribe_chat(lobby) @@ -182,6 +223,7 @@ function MPAPI.chat.cleanup() _chat_topic = nil _current_lobby = nil _lobby_chat_active = false + _muted = {} if using_dp then dp_compat.send_fn = nil else diff --git a/localization/en-us.lua b/localization/en-us.lua index bf001e5..2a09476 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -50,7 +50,30 @@ return { k_chat_dp_compat_info = '[MultiplayerAPI] For compatibility with DebugPlus, all DebugPlus commands must be prefixed with /. Anything sent without a / will be sent as a chat message.', k_chat_ready_dp = '[MultiplayerAPI] Chat ready. Open console with / or T', k_chat_ready = '[MultiplayerAPI] Chat ready. Press T to open', - k_chat_unknown_command = 'Unknown command', + k_chat_not_sent = '[!] Not delivered to other players:', + k_chat_sent_as = '[!] Filtered — delivered to others as:', + -- Moderation actions (report overlay / mute — slash commands removed) + k_chat_report_sent = '[MultiplayerAPI] Reported to moderators:', + k_chat_muted = '[MultiplayerAPI] Muted for this session:', + -- Report/mute overlay (lobby player cards) + k_report_title = 'Report', + b_report_harassment = 'Harassment', + b_report_hate = 'Hate speech', + b_report_threats = 'Threats', + b_report_spam = 'Spam', + b_report_other = 'Other', + b_mute_player = 'Mute', + -- Report player (pause menu entry + multi-player picker) + b_report_player_cap = { 'REPORT', 'PLAYER' }, + k_report_pick_title = 'Report who?', + -- Post-match section builders (ui/post_match.lua — used by gamemode + -- mods' end screens: SPDRN win/lose, PVP next) + k_post_match_players = 'Players', + k_post_match_held = 'Your blocked messages', + k_post_match_held_error = 'Could not load blocked messages.', + k_appeal_sent = 'Appeal sent', + b_report_cap = 'REPORT', + b_appeal_cap = 'APPEAL', -- Chat section in account overlay k_chat_section_title = 'Chat', k_chat_status_enabled = 'Chat is enabled', @@ -87,20 +110,6 @@ return { b_open_lobby_cap = { 'OPEN', 'LOBBY' }, b_lobby_options_cap = { 'LOBBY', 'OPTIONS' }, b_leave_lobby_cap = { 'LEAVE', 'LOBBY' }, - b_start_game_cap = { 'START', 'GAME' }, - -- Shared matchmaking / lobby menu strings (used by all consumer mods, e.g. - -- Speedrunning and PvP), kept here so consumers don't each duplicate them. - b_leaderboard_cap = 'LEADERBOARD', - b_practice_cap = 'PRACTICE', - b_searching_cap = 'SEARCHING...', - b_cancel_search_cap = 'CANCEL', - b_ready_cap = 'READY', - b_unready_cap = 'UNREADY', - k_ranked_cap = 'RANKED', - k_casual_cap = 'CASUAL', - k_rating_cap = 'RATING', - k_best_score_cap = 'BEST', - k_waiting_for_players = 'Waiting for players...', -- Deck ban-pick draft k_banpick_title = 'DECK BAN', k_banpick_waiting = 'Selecting decks...', @@ -109,8 +118,6 @@ return { k_banpick_bans_left = 'Bans left:', k_banpick_decks_left = 'Decks left:', k_banpick_ban = 'Ban', - k_banpick_pick = 'Pick', - k_banpick_pick_turn = 'Your turn: pick your deck', k_banpick_banned = 'BANNED', k_banpick_selected = 'Selected:', k_banpick_selected_tag = 'Selected', diff --git a/networking/api_client/lobby.lua b/networking/api_client/lobby.lua index 1392438..c5e97de 100644 --- a/networking/api_client/lobby.lua +++ b/networking/api_client/lobby.lua @@ -40,7 +40,40 @@ function api_client:set_lobby_metadata(token, code, metadata, callback) return end - self:_setup_json_callback(callback) + self.pending_callback = callback + + self.mqtt.on_http_response = function(status, body) + self.mqtt.on_http_response = nil + self.mqtt.on_http_error = nil + local cb = self.pending_callback + self.pending_callback = nil + if not cb then + return + end + + if status < 200 or status >= 300 then + cb(MPAPI.make_error(MPAPI.ErrorKind.SERVER, 'Server returned status ' .. tostring(status) .. ': ' .. body), nil) + return + end + + local ok, data = pcall(api_client.json_decode, body) + if not ok or not data then + cb(MPAPI.make_error(MPAPI.ErrorKind.TRANSPORT, 'Failed to parse server response'), nil) + return + end + + cb(nil, data) + end + + self.mqtt.on_http_error = function(msg) + self.mqtt.on_http_response = nil + self.mqtt.on_http_error = nil + local cb = self.pending_callback + self.pending_callback = nil + if cb then + cb(MPAPI.make_error(MPAPI.ErrorKind.TRANSPORT, 'HTTP request failed: ' .. tostring(msg)), nil) + end + end local body = api_client.json_encode({ metadata = metadata }) self.mqtt:http_put_auth(self.base_url .. '/api/lobbies/' .. code .. '/metadata', body, token) @@ -52,27 +85,41 @@ function api_client:enable_chat(jwt_token, callback) return end - self:_enqueue(function(status, body) + self.pending_callback = callback + + self.mqtt.on_http_response = function(status, body) + self.mqtt.on_http_response = nil + self.mqtt.on_http_error = nil + local cb = self.pending_callback + self.pending_callback = nil + if not cb then return end + if status < 200 or status >= 300 then - callback(MPAPI.make_error(MPAPI.ErrorKind.SERVER, 'Server returned status ' .. tostring(status) .. ': ' .. body), nil) + cb(MPAPI.make_error(MPAPI.ErrorKind.SERVER, 'Server returned status ' .. tostring(status) .. ': ' .. body), nil) return end local ok, data = pcall(api_client.json_decode, body) if not ok or not data then - callback(MPAPI.make_error(MPAPI.ErrorKind.TRANSPORT, 'Failed to parse server response'), nil) + cb(MPAPI.make_error(MPAPI.ErrorKind.TRANSPORT, 'Failed to parse server response'), nil) return end if data.error then - callback(MPAPI.make_error(MPAPI.ErrorKind.SERVER, data.error), nil) + cb(MPAPI.make_error(MPAPI.ErrorKind.SERVER, data.error), nil) return end - callback(nil, data) - end, function(msg) - callback(MPAPI.make_error(MPAPI.ErrorKind.TRANSPORT, 'HTTP request failed: ' .. tostring(msg)), nil) - end) + cb(nil, data) + end + + self.mqtt.on_http_error = function(msg) + self.mqtt.on_http_response = nil + self.mqtt.on_http_error = nil + local cb = self.pending_callback + self.pending_callback = nil + if cb then cb(MPAPI.make_error(MPAPI.ErrorKind.TRANSPORT, 'HTTP request failed: ' .. tostring(msg)), nil) end + end self.mqtt:http_post_auth(self.base_url .. '/api/auth/chat/enable', '{}', jwt_token) end @@ -83,19 +130,100 @@ function api_client:send_chat_message(jwt_token, code, message, callback) return end - self:_enqueue(function(status, body) + self.pending_callback = callback + + self.mqtt.on_http_response = function(status, body) + self.mqtt.on_http_response = nil + self.mqtt.on_http_error = nil + local cb = self.pending_callback + self.pending_callback = nil + if not cb then return end + if status < 200 or status >= 300 then local ok, data = pcall(api_client.json_decode, body) - local emsg = (ok and data and data.error) or ('Server returned status ' .. tostring(status)) - callback(MPAPI.make_error(MPAPI.ErrorKind.SERVER, emsg), nil) + local msg = (ok and data and data.error) or ('Server returned status ' .. tostring(status)) + cb(MPAPI.make_error(MPAPI.ErrorKind.SERVER, msg), nil) return end - callback(nil, { ok = true }) - end, function(msg) - callback(MPAPI.make_error(MPAPI.ErrorKind.TRANSPORT, 'HTTP request failed: ' .. tostring(msg)), nil) - end) + -- Pass the response body through: on a moderation rewrite it carries + -- publishText (what other players actually received). + local ok, data = pcall(api_client.json_decode, body) + cb(nil, (ok and type(data) == 'table') and data or { ok = true }) + end + + self.mqtt.on_http_error = function(msg) + self.mqtt.on_http_response = nil + self.mqtt.on_http_error = nil + local cb = self.pending_callback + self.pending_callback = nil + if cb then cb(MPAPI.make_error(MPAPI.ErrorKind.TRANSPORT, 'HTTP request failed: ' .. tostring(msg)), nil) end + end local body = api_client.json_encode({ message = message }) self.mqtt:http_post_auth(self.base_url .. '/api/lobbies/' .. code .. '/chat', body, jwt_token) end + +----------------------------- +-- MODERATION INTAKE (report / appeal / mute-signal / held) +-- These feed the moderation review queue; they never gate gameplay, so they all +-- use the generic JSON callback and surface only their own success/error. +----------------------------- + +-- Report another player. `report_type` is a short category ('harassment', +-- 'slur', ...); `message` is the offending text or a note (optional). +function api_client:report_player(jwt_token, code, reported_player_id, report_type, message, callback) + if not self:_transport_ready() then + callback(MPAPI.make_error(MPAPI.ErrorKind.NOT_CONNECTED, 'MQTT thread not running'), nil) + return + end + + self:_setup_json_callback(callback) + + local body = api_client.json_encode({ + reportedPlayerId = reported_player_id, + type = report_type, + message = message, + }) + self.mqtt:http_post_auth(self.base_url .. '/api/lobbies/' .. code .. '/report', body, jwt_token) +end + +-- Contest one of your own messages that moderation blocked. `original_band` is +-- the band the client recorded when the block happened (optional). +function api_client:appeal_message(jwt_token, code, message, original_band, callback) + if not self:_transport_ready() then + callback(MPAPI.make_error(MPAPI.ErrorKind.NOT_CONNECTED, 'MQTT thread not running'), nil) + return + end + + self:_setup_json_callback(callback) + + local body = api_client.json_encode({ message = message, originalBand = original_band }) + self.mqtt:http_post_auth(self.base_url .. '/api/lobbies/' .. code .. '/appeal', body, jwt_token) +end + +-- Forward the aggregate mute signal (the local mute itself is client-side). +function api_client:mute_signal(jwt_token, code, muted_player_id, callback) + if not self:_transport_ready() then + callback(MPAPI.make_error(MPAPI.ErrorKind.NOT_CONNECTED, 'MQTT thread not running'), nil) + return + end + + self:_setup_json_callback(callback) + + local body = api_client.json_encode({ mutedPlayerId = muted_player_id }) + self.mqtt:http_post_auth(self.base_url .. '/api/lobbies/' .. code .. '/mute', body, jwt_token) +end + +-- Fetch this player's held (blocked) messages for the lobby — the post-game +-- appeal screen's data. Returns { held = { { message, band, createdAt }, ... } }. +function api_client:list_held(jwt_token, code, callback) + if not self:_transport_ready() then + callback(MPAPI.make_error(MPAPI.ErrorKind.NOT_CONNECTED, 'MQTT thread not running'), nil) + return + end + + self:_setup_json_callback(callback) + + self.mqtt:http_get_auth(self.base_url .. '/api/lobbies/' .. code .. '/held', jwt_token) +end