Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 58 additions & 16 deletions api/chat.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----------------------------
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
41 changes: 24 additions & 17 deletions localization/en-us.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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...',
Expand All @@ -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',
Expand Down
160 changes: 144 additions & 16 deletions networking/api_client/lobby.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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