Skip to content
ForgeCastDocs

Data, timers and the internet

Keep settings between runs, run code later, call web APIs, play sounds, trigger alerts and talk to other plugins.

Storage

storage (needs storage) keeps data on this computer, per plugin, between runs. It survives reloads, restarts and updates of ForgeCast, and no other plugin can read it.

local settings = storage.get("settings", { volume = 5, words = {} })   -- the second argument is the default

settings.volume = 8
storage.set("settings", settings)         -- saves it

storage.delete("old-key")                 -- true if it was there
print(table.concat(storage.keys(), ", ")) -- the names of everything saved, in order
storage.clear()                           -- forget everything
  • A value can be a string, number, boolean, or a table made of those (nested as deep as you like). Functions can't be saved. storage.set(key, nil) removes the key.
  • A key is 1 to 200 characters. A plugin can keep up to 1 MB and 5,000 keys. Going over fails with a message, and leaves what was there untouched.
  • storage.get returns a fresh copy each time: changing the table you got changes nothing until you call storage.set with it.
  • Tables that look like lists (keys 1, 2, 3…) come back as lists; any other table comes back with its keys. An empty table can come back as either, so don't rely on telling {} from [].
  • Nothing is encrypted. Don't store secrets that matter. (A webhook address, as in the example, is fine to keep on the person's own computer.)

When the plugin is turned off or reloaded, ForgeCast calls the function you register with plugin.on_unload(fn), so you can save a last time:

plugin.on_unload(function() storage.set("last-seen", time.now()) end)

Timers

local id = timer.every(1000, function() print("a second passed") end)   -- every second (50 ms at the least)
timer.after(5000, function() timer.cancel(id) end)                     -- once, in five seconds

timer.after(ms, fn) and timer.every(ms, fn) return an id; timer.cancel(id) stops either. A timer that errors writes the error to the console and keeps going, and the plugin's other timers are not affected. A plugin may have 200 timers and 200 event handlers.

Timers are how you draw regularly, wait before doing something, or do work in small pieces. A callback has 250 ms; never wait in a loop (while or busy-waiting), because that is also stopped.

Time, JSON, logging and helpers

local now = time.now()                         -- milliseconds since 1970 (compare with chat.ts)
local text = json.encode({ name = "ann", n = 3 })        -- '{"n":3,"name":"ann"}'   (json.encode(value, true) is indented)
local value, err = json.decode('{"a": 1}')              -- nil and a message when the text isn't JSON
print(util.clock())                            -- "12:30:05"
print(util.ago(chat.ts))                       -- "5s ago", "3m ago", "2h ago"

log.debug, log.info, log.warn and log.error write to the plugin's console (print is log.info). Several arguments are joined with spaces, and a table is printed with what is in it (a few levels deep). log.error doesn't stop anything.

util has the small things Lua leaves out:

FunctionDoes
util.trim(s)The text without spaces at either end.
util.starts_with(s, prefix), util.ends_with(s, suffix)What they say.
util.split(s, sep)Split on a plain separator (default ",").
util.contains(list, value), util.index_of(list, value)Is it in the list, and where.
util.remove_value(list, value)Remove the first match; true if it was there.
util.shuffle(list), util.pick(list)Shuffle in place; a random item (nil for an empty list).
util.keys(t)The keys of a table, sorted.
util.clock(ms), util.ago(ms)Text for a time.

Lua 5.4 itself is all there too: string, table, math, utf8, coroutine, and os.time, os.date, os.clock and os.difftime. require("name") loads another .lua file in the plugin's folder. See the sandbox for what is left out.

The internet

http (needs network) calls websites and APIs over https. It never blocks: you give it a function, and ForgeCast calls that function when the answer arrives.

http.get("https://api.example.com/status", function(res)
  if not res.ok then
    log.warn("couldn't read the status:", res.error or res.status)
    return
  end
  print("status is", res.json.state)      -- res.json is the body parsed, when it was JSON
end)

http.post("https://hooks.example.com/notify", { text = "We're live!" }, function(res)
  print("sent:", res.ok, res.status)       -- a table body is sent as JSON
end)

http.request({
  url = "https://api.example.com/things/7",
  method = "PUT",                          -- GET, POST, PUT, PATCH or DELETE
  headers = { Authorization = "Bearer " .. token },
  json = { name = "new name" },            -- or body = "plain text"
  timeout = 5,                             -- seconds, 1 to 15 (default 10)
}, function(res) print(res.status) end)

The answer res has:

FieldMeaning
okThe request worked and the status was 2xx.
statusThe HTTP status (0 when there was no answer).
bodyThe body as text.
jsonThe body parsed, when it is JSON.
headersThe response headers, with lower-case names.
errorWhy it failed (no answer, blocked…).

What the plugin sandbox enforces on every request:

  • https only, and public sites only. Addresses on this computer or the local network (localhost, 192.168.x.x, .local, and so on) and addresses with a user name or password are refused, so a plugin can't poke at your network.
  • Up to 8 requests waiting at once and 30 a minute. A request body can be 1 MB and an answer 2 MB.
  • Some headers (Host, Content-Length, Connection, Transfer-Encoding) are set by ForgeCast, not by the plugin.
  • A refused request raises an error that says why, before anything is sent. A request that was sent but failed (no connection, a timeout) arrives in the callback with ok = false and an error.

The network permission can send chat anywhere

A plugin with network can send anything it can see (chat, names, viewer numbers) to the sites it calls. People are warned when they approve it. Only ask for it when you need to, and only call addresses you control or trust.

Sounds and alerts

sounds.play("Coin")                 -- a sound from your Sound Library, by name (needs "sounds")

alerts.show({                       -- one of YOUR alerts, as if it had just happened (needs "alerts")
  type = "member.new",              -- "superchat" (default), "supersticker", "member.new",
                                    -- "member.gift", "member.milestone" or "subscriber.new"
  name = "Ann",
  message = "Welcome!",
  -- for money: amount = 5, currency = "USD", and detail = "…" for the second line
})

alerts.show uses the alert you designed under Alerts, so it is the best way to make a plugin celebrate on stream. Alerts raised this way look like real ones: use it for things that really happened.

app.open_url("https://example.com/docs")   -- opens in the browser; https only (needs "app.open_url")

local info = app.info()                    -- no permission needed
print(info.version, info.platform, info.api)
print(plugin.id, plugin.name, plugin.version, plugin.dir)

An interface can also contain ui.link("label", "https://…"), which opens in the browser when clicked and needs no permission.

Talking to other plugins

bus (needs bus) sends messages between your plugins:

-- in the first plugin
bus.emit("goal.reached", { goal = 100 })

-- in another plugin
bus.on("goal.reached", function(msg)
  print(msg.from, msg.data.goal)           -- "member-goal", 100
end)

A message has a name (1 to 60 letters, digits or . _ : -) and any JSON-like data up to 64 KB. The handler gets { from = "plugin-id", data = … }. bus.on returns an id for bus.off. Both plugins need the bus permission, and neither is told if nobody is listening.

Several files

Keep the code in pieces once it grows:

-- helpers.lua
local M = {}
function M.shout(text) return text:upper() .. "!" end
return M
-- main.lua
local helpers = require("helpers")     -- "lib.format" would be lib/format.lua
print(helpers.shout("hello"))

require reads .lua files inside the plugin's own folder only, and each file runs once (the second require returns the same value). Files in the folder trigger a reload when you save them in Developer mode.

On this page