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.

criticalLuaudit check: custom-chat-channels

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.

Luau
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 a TextChannel. That keeps messages within each user's privacy and parental settings, visible to moderation and reportable.
  • Filtering the text with TextService is 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:SetDirectChatRequester on the channel, or TextChatService:CanUsersDirectChatAsync before 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 TextChannel under TextChatService for each party, team or conversation, and add players with AddUserAsync.
  • For whispers, check CanUsersDirectChatAsync first and call SetDirectChatRequester on the channel before adding users.
  • Have clients send with TextChannel:SendAsync, and remove the old remotes.

Vulnerable example

Vulnerable
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

Fixed
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