Why whispers and party chat need TextChatService
Chat between players must go through a TextChannel, even if you filter it. How to build whispers and party chat that respect players' settings.
What it looks like
Players send messages to each other through the game's own remotes: whispers, party chat, team chat or a custom chat window. The server may filter the text, then passes it on with FireClient or FireAllClients. Nothing goes through a TextChannel.
remotes.Whisper.OnServerEvent:Connect(function(player, target, text) remotes.ShowWhisper:FireClient(target, player, filter(text, player, target)) end)
How to detect it
- Find remotes that carry text one player wrote to other players as a conversation: direct messages, party, team, guild or trade chat, and replies to comments.
- Roblox's chat guidelines say every game that offers text chat between users must use
TextChatService, and that chat messages, including direct messages and team messages, must go through aTextChannel. That keeps messages within each user's privacy and parental settings, visible to moderation and reportable. - Filtering the text with
TextServiceis still required, but it doesn't make a remote-based chat acceptable. Report this check even when Player text shown to others without filtering passes. - For direct chat, look for
TextChannel:SetDirectChatRequesteron the channel, orTextChatService:CanUsersDirectChatAsyncbefore a whisper channel is opened. - The same guidelines list text that isn't chat: developer menus, game status updates, admin announcements, pet names and moderation logs. Those don't need a
TextChannel, only filtering where a player wrote them.
What an exploiter could do
They message players whose settings or parents don't allow it, including younger players who have direct chat turned off. Their messages can't be reported through Roblox's tools, so abuse goes unseen. Roblox's guidelines make TextChatService a requirement under the Misusing Roblox Systems Community Standard, so the game is breaking Roblox's rules as well.
How to fix it
- Create a
TextChannelunderTextChatServicefor each party, team or conversation, and add players withAddUserAsync. - For whispers, check
CanUsersDirectChatAsyncfirst and callSetDirectChatRequesteron the channel before adding users. - Have clients send with
TextChannel:SendAsync, and remove the old remotes.
Vulnerable example
local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local TextService = game:GetService("TextService") local remotes = ReplicatedStorage.Remotes local partyOf: { [Player]: { Player } } = {} remotes.PartyChat.OnServerEvent:Connect(function(player: Player, text: unknown) local party = partyOf[player] if not party or typeof(text) ~= "string" or #text > 200 then return end local ok, result = pcall(TextService.FilterStringAsync, TextService, text, player.UserId) if not ok then return end for _, member in party do local shown, filtered = pcall(result.GetNonChatStringForUserAsync, result, member.UserId) if shown and member ~= player then remotes.ShowPartyChat:FireClient(member, player, filtered) end end end) Players.PlayerRemoving:Connect(function(player) partyOf[player] = nil end)
Fixed example
local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local TextChatService = game:GetService("TextChatService") local remotes = ReplicatedStorage.Remotes local whispers: { [string]: { channel: TextChannel, members: { Player } } } = {} local function keyFor(a: Player, b: Player): string return `{math.min(a.UserId, b.UserId)}_{math.max(a.UserId, b.UserId)}` end remotes.OpenWhisper.OnServerEvent:Connect(function(player: Player, target: unknown) if typeof(target) ~= "Instance" or not target:IsA("Player") or target == player then return end local key = keyFor(player, target) if whispers[key] then return end local ok, allowed = pcall(TextChatService.CanUsersDirectChatAsync, TextChatService, player.UserId, { target.UserId }) if not ok or #allowed == 0 then return end local channel = Instance.new("TextChannel") channel.Name = `Whisper_{key}` channel.Parent = TextChatService channel:SetDirectChatRequester(player) channel:AddUserAsync(player.UserId) channel:AddUserAsync(target.UserId) whispers[key] = { channel = channel, members = { player, target } } remotes.WhisperOpened:FireClient(player, channel) remotes.WhisperOpened:FireClient(target, channel) end) Players.PlayerRemoving:Connect(function(player) for key, whisper in whispers do if table.find(whisper.members, player) then whisper.channel:Destroy() whispers[key] = nil end end end)
Clients send messages with channel:SendAsync(text), which filters them and applies each user's settings.
Common false positives
- Text that isn't a conversation, such as pet names, sign text or admin announcements, belongs to Player text shown to others without filtering and, for signs and boards, Signs and boards players can change more than once a minute.
- Remotes that only open, close or name a
TextChannel, like the fixed example, are fine. - System messages the game writes itself, shown with
DisplaySystemMessage, aren't chat between users.