Chat and streams
Read chat, react to Super Chats and members, filter messages before anyone sees them, and follow viewer counts.
Plugins see chat and stream numbers from the streams ForgeCast is watching: your channel's live stream, or the streams you follow by link in Developer mode (up to eight at once, all merged). Everything here needs chat.read, chat.middleware or stream.read in plugin.json.
Plugins only read. There is no function to post in chat.
What a chat line looks like
Every function below hands you a Chat table:
-- what a handler is given (this is a picture of the table, not code to run)
local chat = {
id = "Ugx…", -- unique id of the line
text = "hello everyone", -- the message as plain text (custom emoji appear as :shortcode:)
kind = "text", -- see the table below
ts = 1767225600000, -- when it was sent, in milliseconds since 1970
stream = "dQw4w9WgXcQ", -- the video id of the stream it came from
author = {
name = "@ann", -- display name, usually the @handle
id = "UC…", -- the viewer's channel id: use this to tell people apart
is_owner = false, -- the streamer
is_mod = false, -- a moderator
is_member = true, -- a channel member
},
amount = { value = 5, currency = "USD" }, -- Super Chats and Super Stickers only
amount_text = "$5.00", -- the amount as YouTube shows it
count = 5, -- memberships in a gift
headline = "Member for 6 months",
backlog = false, -- true for history read when a stream is first opened
simulated = false, -- true for chat from the Test environment
}chat.kind | What it is |
|---|---|
text | An ordinary message. |
super_chat | A Super Chat (has amount). |
super_sticker | A Super Sticker (has amount). |
member | A new member. |
milestone | A member milestone ("Member for 6 months"). |
gift | Memberships gifted to the community (has count). |
gift_received | Someone received a gifted membership. |
jewel | A gift sent in chat. |
Fields that don't apply are nil, so check before you use them: chat.amount_text or "a Super Chat". Every field is in the reference.
React to chat as it arrives
chats.on(event, handler) calls handler(chat) for each new line. The event names live in the global Event:
| Event | Fires for |
|---|---|
Event.NewChat | Every line, of any kind (after chat middleware has had its say). |
Event.SuperChat | Super Chats. |
Event.SuperSticker | Super Stickers. |
Event.Member | New members. |
Event.Milestone | Member milestones. |
Event.Gift | Gifted memberships. |
Event.Jewel | Gifts sent in chat. |
Event.ChatRemoved | A line was deleted or an author banned: the handler gets { stream, ids, authors }. |
A kind event fires in addition to Event.NewChat, not instead of it. You can pass the event as text too: chats.on("chat.super_chat", fn).
-- greet new members, thank Super Chats
chats.on(Event.Member, function(chat)
ui.notify({ title = "New member", message = chat.author.name, level = "success" })
end)
chats.on(Event.SuperChat, function(chat)
ui.notify({ title = chat.author.name .. " sent " .. (chat.amount_text or "a Super Chat"), message = chat.text })
end)A few things worth knowing:
- History is skipped. When a stream is first opened, ForgeCast reads its recent chat so the Dashboard isn't empty. Handlers don't get those lines, unless you ask:
chats.on(Event.NewChat, fn, { backlog = true }). They arrive withchat.backlog == true. chats.onreturns an id.chats.off(id)stops that handler.- A handler that errors doesn't stop anything: the error and a traceback go to the plugin's console, and the other handlers and the plugin carry on.
- Handlers must be quick: a handler has 250 ms; one that runs longer is stopped and the console says so. Do slow things with
timer.afteror an asynchronoushttpcall.
Read recent chat
Plugins can look at recent chat any time, whether or not anything just happened. chats.get(query) returns the newest lines, oldest first, up to limit (default 50, at most 300). The same query works for chats.count, chats.authors and chats.random.
| Query field | Meaning |
|---|---|
limit | At most this many lines (the newest ones). |
since | Only lines from the last N seconds. |
kind | One kind, or a list: kind = "super_chat", kind = { "super_chat", "member" }. |
author | A name ("@ann" or "ann") or a channel id. |
members_only | Members, moderators and the streamer. |
contains | Text to look for (ignores case). |
exclude | A list of names or channel ids to leave out. |
stream | Only this stream (a video id). |
local last_ten = chats.get({ limit = 10 })
local supers = chats.count({ kind = "super_chat", since = 3600 }) -- in the last hour
local who = chats.authors({ since = 600 }) -- people who wrote in the last 10 minutes, most active first
-- who[1] is { id, name, avatar, count, last_ts, is_member, is_mod, is_owner }chats.random picks a line, one per person by default so someone who types a lot doesn't win more often (set unique_author = false to turn that off). It returns nil when nothing matches, and with count = 3 it returns a list of up to three:
local winner = chats.random({ since = 600, kind = "text", exclude = { "@botname" } })
if winner then
ui.alert(winner.author.name .. " wins!") -- needs ui.modal
endFilter chat before anyone sees it
Middleware sees every new line first. What it decides is what the Dashboard, the pop-out, your alerts, your chat actions and other plugins get. It needs chat.middleware.
chats.use(function(chat)
if chat.text:lower():find("spam", 1, true) then
return false, "contains spam" -- hide it; the reason shows in this plugin's console
end
end)What a middleware function can return:
| Return | Effect |
|---|---|
nothing (or nil) | The line goes through. |
false, or false, "reason" | The line is hidden. |
| a string | The line's text becomes that string. |
{ text = "…" } | The line's text becomes that. |
{ drop = true, reason = "…" } | The line is hidden (the table form of false). |
- Middleware functions run one after another, in the order they were added. Each one sees the text as the ones before it changed it, and once one hides a line, the later ones never see it.
- Keep it fast and safe. A middleware function has 400 ms a line. One that takes longer is cut off and the line goes through. One that errors lets the line through and writes the error to the console. A plugin can never make chat stop.
- Middleware only reads the line. Look people up with
chat.author.id, not the display name, which people can change. - Do the counting in middleware and the drawing somewhere else (a timer): the Chat filter example shows how.
- Lines from the Test environment go through middleware exactly like real ones, with
chat.simulated == true.
Streams
stream.list() returns the streams being watched, one table each:
-- one entry of stream.list() (a picture of the table, not code to run)
local s = {
id = "dQw4w9WgXcQ", -- video id ("simulator" for the Test environment's)
title = "Friday night", -- the stream's title
phase = "live", -- "connecting", "live", "upcoming", "ended" or "error"
viewers = 120, -- watching now (nil when YouTube doesn't say)
peak_viewers = 150,
likes = 40,
total_views = 900,
started_at = 1767225600000, -- in milliseconds since 1970
chat = "on", -- "off", "connecting", "on", "limited" or "unavailable"
simulated = false, -- true for the Test environment's make-believe stream
}stream.state() adds them up: { streams, live, title, viewers, likes, total_views, started_at }. With one stream that is the stream; with several, viewers and likes are sums and title is "3 streams".
stream.on(event, handler) listens for changes. The handler gets the stream's table:
| Event | When |
|---|---|
Event.StreamUpdate | Viewers or likes changed (every few seconds). |
Event.StreamAdded | ForgeCast started watching a stream. |
Event.StreamRemoved | It stopped watching one: the handler gets { id }. |
Event.StreamStart | A stream is seen going live. This also fires for a stream that was already live when ForgeCast first found it, so after a restart in the middle of a stream you get it again. Remember what you announced. |
Event.StreamEnd | A stream ended. |
-- cheer at 100 viewers, once per stream
local cheered = {}
stream.on(Event.StreamUpdate, function(s)
if s.simulated or cheered[s.id] or (s.viewers or 0) < 100 then return end
cheered[s.id] = true
ui.notify({ title = "100 people are watching", message = s.title or s.id, level = "success" })
end)What a plugin does not get
Chat is read from the same place the Dashboard reads it, so it only exists while a stream is live (and for a short history when you open one). There are no subscriber events: YouTube doesn't publish them.

