More Custom Chat Functionality — Wrapper for TextChatService.OnIncomingMessage

First, let's go over the problem.

Developers often want to customize the appearance or change the content of chat messages (coloring text, adding name tags, swapping words around etc).

In this example, there’s an added [VIP] tag to the player’s name:

A guide on the Creator Hub suggests that developers should assign the callback function TextChatService.OnIncomingMessage to do customization like that.

You can customize the appearance of chat message bodies and prefixes using rich text tags and TextChatService.OnIncomingMessage callbacks.

However, there is a problem which this warning in the guide sums up quite well:

I talked about this months ago in the TextChatService release thread:

There are some popular modules which are designed to allow developers to customize chat message appearance. These developers have done good work, but all of the modules suffer from the same “invasive” problem by using their own OnIncomingMessage binding:

Let’s say we want to use TextChat+ to give ourselves a purple Developer tag:

...
["Tags"] = {
	[1] = {
		["Enabled"] = true,
		["Name"] = "Developer",
		["Prefix"] = "<font color='#b480ff'>DEVELOPER</font>",
...

image

Now, let’s make our own script which colors any instance of the word LOL red when it’s chatted:

local word = "lol"
local color = BrickColor.Red().Color

game:GetService("TextChatService").OnIncomingMessage = function(tcm)
	local new = Instance.new("TextChatMessageProperties")
	new.PrefixText = tcm.PrefixText
	local word_pattern = "%f[%a]%a+%f[%A]" -- thanks chatgpt
	new.Text = string.gsub(tcm.Text, word_pattern, function(instance)
		if string.lower(instance) == word then
			return string.format("<font color='#%s'>%s</font>", color:ToHex(), instance)
		else
			return instance
		end
	end)
	return new
end

image

If we try and use both the red LOL and the purple chat tag at the same time, however:

image

only the chat tag works, because TextChat+ reached the line of code where it binds to TextChatService.OnIncomingMessage a little after my script did.

TextChat+ here is being invasive by making my own script not work! Bummer. If I put a nasty task.wait(1) at the top of my script, the LOL thing works, but then my chat tag is gone. So only one of them can work at a time.

If all you want to do is have chat tags, then those modules will work for you. However, as soon as you want to customize the chat more, you have to scrap the pre-made modules entirely.

Now, you understand the problem. Roblox does not have a built-in solution to this. In order to have multiple modifiers using OnIncomingMessage, there will first need to be a single location in the code where that callback function is assigned:

I have not seen anyone doing this so far.


TextChatService.OnIncomingMessage Wrapper

Get it here, but read on:

I already made a post about both the problem and my solution regarding this issue, but I figured it was worth a proper thread rather than just a big reply. I quoted that post a few times when I was explaining the problem above.


Here is a technical explanation of the solution.

My solution to this “overriding bindings” problem is a wrapper function for TextChatService.OnIncomingMessage. The wrapper binds to Roblox’s callback once, then allows the developer to input new callback functions which would normally be assigned directly to OnIncomingMessage.

Your callback functions should look no different than the ones you use in your existing TextChatService.OnIncomingMessage bindings, except for the fact that they no longer receive a TextChatMessage instance because those cannot be created by the developer (me):

Instead, the wrapper module provides a ‘replica’ of TextChatMessage — your function will receive a table (dictionary) which has all of the same entries associated with it as the real thing. Your functions should still return either a TextChatMessageProperties instance or nil as is normal per OnIncomingMessage’s documentation:

This wrapper module uses a priority system where you assign a priority (integer) to each function you give to the wrapper. The highest priority is 1, which means the callback function that has priority level 1 is run first. The order that the wrapper runs your callback functions goes from highest to lowest priority (1 → 2 → 3 …).

If you do not give your callback function a priority, it is given the lowest priority level.

There is no limit to the number of callback functions you can have, but you can only use priority numbers within the range of the number of existing callbacks, plus 1.

Examples for using the priority system:

If you have 3 existing callback functions "A" "B" and "C", and you want to insert a new callback function "D", consider the following before-and-after states of these different operations that you could use to add that callback. All the ‘after’ tables (arrays) are the states directly after the ‘before,’ rather than this whole list happening in a chained order. The “func” keyword in the following examples is just shorthand for typing an actual function.

Before:

{[1] = {Name = "A", Callback = func},
[2] = {Name = "B", Callback = func},
[3] = {Name = "C", Callback = func}}
After inserting the callback with no priority specified:
Wrapper:AddCallback("D", func)
{[1] = {Name = "A", Callback = func},
[2] = {Name = "B", Callback = func},
[3] = {Name = "C", Callback = func},
[4] = {Name = "D", Callback = func}}

Doing it this way works the same as you would expect with table.insert by putting it at the very end.

After with priority 1, the highest:
Wrapper:AddCallback("D", func, 1)
{[1] = {Name = "D", Callback = func},
[2] = {Name = "A", Callback = func},
[3] = {Name = "B", Callback = func},
[4] = {Name = "C", Callback = func}}

You can see all the other callbacks get pushed down in priority.

After with priority 3:
Wrapper:AddCallback("D", func, 3)
{[1] = {Name = "A", Callback = func},
[2] = {Name = "B", Callback = func},
[3] = {Name = "D", Callback = func},
[4] = {Name = "C", Callback = func}}
After with priority -1:
Wrapper:AddCallback("D", func, -1)
{[1] = {Name = "A", Callback = func},
[2] = {Name = "B", Callback = func},
[3] = {Name = "C", Callback = func},
[4] = {Name = "D", Callback = func}}

Inputting zero or negative numbers gives your callback function lowest priority (runs latest), just like if you specified no priority.

After with priority 10:
Wrapper:AddCallback("D", func, 10)
{[1] = {Name = "A", Callback = func},
[2] = {Name = "B", Callback = func},
[3] = {Name = "C", Callback = func},
[4] = {Name = "D", Callback = func}}
After inserting with "B"'s priority:
Wrapper:AddCallback("D", func, Wrapper:GetCallbackPriority("B"))
{[1] = {Name = "A", Callback = func},
[2] = {Name = "D", Callback = func},
[3] = {Name = "B", Callback = func},
[4] = {Name = "C", Callback = func}}

If all of your old TextChatService.OnIncomingMessage assignments are updated to use the wrapper format, then everything should work together.

Examples of use cases

With the priority system in mind, understand that the execution order of your callback functions can be important. Take a look at two examples where we use the wrapper module to customize chat.

PrefixText example

Let’s say I want to do two things in the chat:

  • I want to add a (You) tag in front of my name

  • I want everyone’s names to be capitalized

Here’s some code to do that, using the wrapper module:

local storage = game:GetService("ReplicatedStorage")
local wrapperName = "TextChatService.OnIncomingMessage Wrapper" -- long name
local Wrapper = require(storage:WaitForChild(wrapperName))

local function getUserIdFromTextChatMessage(textChatMessage)
	return tonumber(textChatMessage.MessageId:match("-?%d+"))
    -- TextChatMessage.MessageId begins with a User ID
    -- Local server players have negative ID's, hence the "-?" pattern
end

Wrapper:AddCallback("Add Tag Before My Name", function(fakeTextChatMessage)
	local userId = getUserIdFromTextChatMessage(fakeTextChatMessage)
	if userId == game.Players.LocalPlayer.UserId then
		local taggedMsg = Instance.new("TextChatMessageProperties")
		local newTag = "(You)"
		local oldPrefix = fakeTextChatMessage.PrefixText
		taggedMsg.PrefixText = string.format("%s %s", newTag, oldPrefix)
		taggedMsg.Text = fakeTextChatMessage.Text
		return taggedMsg
	end
end)

Wrapper:AddCallback("Capitalize Name", function(fakeTextChatMessage)
	local new = Instance.new("TextChatMessageProperties")
	new.PrefixText = string.upper(fakeTextChatMessage.PrefixText)
	new.Text = fakeTextChatMessage.Text
	return new
end)

As an aside, “fakeTextChatMessage” just refers to the fact that the wrapper provides a replica of TextChatMessage, as described in the ‘technical solution’ section further up this post

But look, there’s a problem:

image

Sure, we have the two separate functions working together, but the tag is capitalized “(YOU)”!

The order of the callback functions does matter in some cases like this, where I want the tag to be added after my name is capitalized.

Here’s the capitalization callback added such that it runs before the (You) tag callback (entire function has been abbreviated with “func” here for brevity):

Wrapper:AddCallback("Add Tag Before My Name", func)
local AddTagPriority = Wrapper:GetCallbackPriority("Add Tag Before My Name")
Wrapper:AddCallback("Capitalize Name", func, AddTagPriority)

The add-tag callback’s priority gets pushed down (+1), making it run after the capitalization callback.

Here’s how it was before:

Wrapper:AddCallback("Add Tag Before My Name", func)
Wrapper:AddCallback("Capitalize Name", func)

We can see the tag is no longer capitalized:

and indeed, the tag is only applied to your name.

Text example

Consider another two things I want to do with chat, but this time it’s affecting messages rather than names:

  • Replace all numbers in messages with :stuck_out_tongue_closed_eyes: emoji

  • Give messages a random color

I will spare you the function contents themselves and instead only show you the wrapper context (again with the function abbreviated as “func”), since the PrefixText example already gives you an idea of how your functions should look. If you want to study the functions, the .rbxm files are available below.

Here’s the setup according to those bullet points:

Wrapper:AddCallback("Numbers -> Emojis", func)
Wrapper:AddCallback("Random Color Text", func)

Here’s the outcome:

Works nicely!

However, if I get the order mixed up and have the numbers converted to emojis after coloring the text:

Wrapper:AddCallback("Random Color Text", func)
Wrapper:AddCallback("Numbers -> Emojis", func)

then it seems like things break:

This is because TextChatService uses RichText, which allows developers to customize text appearance like text color in this example. Decimal numbers often appear in the hex codes used to represent the text color, and those hex codes are part of the text just as much as the readable text is.

Here’s an actual RichText sample before Roblox’s chat system converts it to the readable form displayed to the user:

<font color="#FF0000">red-colored text goes here</font>

When the decimal values in that hex codes get substituted for emojis, the entire RichText evaluation process terminates and Roblox just displays the whole thing to the user.

<font color="#FF😝😝😝😝">red-colored text goes here</font>

But if you look closely at that video, you can see the number I entered did get converted to an emoji, as I intended; just with that nasty side effect because of the bad ordering of the callback functions.

Both PrefixText and Text examples working together:

To test with these two example scripts, place them into ReplicatedStorage and then also place the wrapper module there:

TextChatService OnIncomingMessage Example Callbacks.rbxm (3.0 KB)

You’ll see that even though they’re two separate scripts, they both work to affect the chat. This could not be done easily before. The goal of the wrapper module is accomplished!

This is not a full documentation. I may add significant edits to this post in the future, including a FAQ section. More functionality may be added to the wrapper module as well.

Changelog

(never done one of these before, let’s see how I do)


2023-11-29T12:50:00Z

Changed all the functions in the wrapper module such that they should be called with colons : rather than dot notation . — this is to support events I will add in the future that the developer indexes with dot notation, and I don’t want them to look the same as functions.

12 Likes