larperCord Buy Me a Coffee at ko-fi.com Português

Lua plugins

Plugins are small scripts that add features to the app with no recompiling: panels, right-click entries, commands, reminders, notes under messages. Every .lua file in

~/.config/larpercord/plugins/          ($XDG_CONFIG_HOME/larpercord/plugins, if set)

is a plugin. Open Settings → Plugins (Ctrl+,) to see what each one is, what it asks for and any error it hit, turn it on or off (the choice is kept in ~/.config/larpercord/plugins.kdl), approve the hosts it wants to talk to, open its panel and Reload after editing a file.

The plugins that come with the app

Settings → Plugins → Install bundled plugins copies the missing ones to your folder. It never overwrites a file you've edited and never removes anything.

PluginWhat it does
translate.luaRight-click a message, or the 🌐 on its toolbar, to see the translation under it. "Translate to…" picks another language for one message, its card sets the default target and source, and /tr es hello puts a translation in the message box. Off until you approve the host: see HTTP
reminders.lua/remind 10m call back, /remind tomorrow 9am pay the bill, /remind every day 8:30 standup, or "Remind me" on a message. Notifies you at the right time, also after a restart. What already fired can come back in 10 minutes
highlights.luaWords to watch across all channels: a notification, a number on the panel for what you haven't seen, and a history that takes you to the message. You can mute a channel
scamguard.luaMarks in red the classic Discord scams in other people's messages: addresses that imitate Discord or Steam (swapped letters, another alphabet, a disguised subdomain), fake free Nitro and gifts with a link, shortened links with bait, links that show one address and open another. Official addresses and talk with no link are never marked. "Not a scam" on a message keeps that address on a list for your account. /scam opens the panel with the latest ones. No network
cve.luaFinds CVE-2021-44228 and the like in messages and shows the severity, the CVSS score and a short summary under them, from the official CVE record. /cve CVE-2021-44228 looks one up by hand, and the panel lists what was seen, worst first. Only the number goes out, once you approve the host; each answer is kept for days
github.luaowner/repo#123 and links to GitHub issues, pull requests and repositories get a note with the title, the state and the stars. Public repositories only, with no login: GitHub allows few requests per hour without one, so answers are kept for a while and at the limit the notes just wait. Off until you approve the host
catchup.lua/missed or the panel sums up the unread messages in the open channel, or the last ones: how many from each person, mentions of you, questions left unanswered, links and files, each with a click to the message. It only looks when you ask
cleanlinks.luaRemoves tracking from the links you send (utm_*, fbclid, YouTube's and Spotify's si…). The app applies the rules when you send: the plugin doesn't see what you write
links.luaEvery link and file in the messages loaded in the open channel: search, open, copy, go to the message. It only looks while its panel is open and keeps nothing on disk

None of them keeps the text of a message after it's deleted or edited: the plugin hears about it and drops the old text. Highlights never writes other people's text to disk.

New version of a bundled plugin: when the app brings a newer @version of a file you have, its card shows Update to vX. It only changes on that click, and your file stays next to it as name.lua.bak (then .bak2, and so on): nothing is overwritten.

No longer bundled: bookmarks and the word filter became the app's own Saved messages and Alerts and filters; snippets, timestamps, stats and some demos (calc, shrug, dice…) left. If you have them they keep working, and Archive to antigos/ moves them to plugins/antigos/, which isn't loaded. Nothing is deleted. The app still shows <t:…> times as dates, with or without a plugin.

How plugins run

Plugin code runs on a thread of its own, never on the interface thread, so a slow or stuck plugin can't make the app stutter. The interface and that thread only talk through two queues.

An example

lua
-- @name Shrug
-- @name:pt Dar de ombros
-- @description /shrug sends a shrug.
-- @description:pt /shrug manda um dar de ombros.
-- @version 1.0.0
-- @capabilities send

larper.command("/shrug", function(args)
  local text = "¯\\_(ツ)_/¯"
  if args ~= "" then text = args .. " " .. text end
  larper.send(larper.channel(), text)
end)

Type /shrug no idea in the message box and the channel receives no idea ¯\_(ツ)_/¯. The /shrug itself never goes to Discord.

The manifest

The comments at the top of the file say what the plugin is. The app reads them without running anything, so the settings page shows them even for a plugin that's off.

LineWhat it's for
-- @name BookmarksThe name (and -- @name:pt Favoritos in Portuguese)
-- @description …One or two sentences (and -- @description:pt …)
-- @version 1.0.0Shown next to the name
-- @capabilities menu, panel, storageWhat it asks for. Shown on its card, and the only things it can use
-- @hosts api.example.comThe hosts for larper.http, just the name. No IP address, port or Discord domain
CapabilityGives access to
messageslarper.on("message"), larper.on("edit"), larper.on("delete"), larper.messages
sendlarper.send
notifylarper.notify
storagelarper.storage
timerslarper.after, larper.every
panellarper.panel
menularper.menu
decoratelarper.decorate, larper.undecorate, larper.rules, larper.on("dismiss")
composerlarper.insert
httplarper.http (needs @hosts)
lsplarper.lsp
settingslarper.settings
completionslarper.completions
rewritelarper.rewrite (its card says "Changes what you send")
linkslarper.open (its card says "Opens links")

Commands, keybinds, larper.channel, larper.guild, larper.jump, larper.copy, larper.tr and larper.json don't need any capability. Calling something the manifest doesn't ask for raises an error that says what to add.

Files with no manifest keep working: they are "old API" plugins, with messages, send, notify and lsp. To use anything newer, add a manifest. A manifest with a mistake (an unknown capability, a bad host) stops the plugin from starting, and the card says why. A plugin that reads messages and can also send something out (messages with http or links) gets a warning line on its card, on top of the labels.

Limits

These are what make it safe to run other people's scripts next to your account.

Plugins run with your account: only use files you've read and understand.

API

Everything lives in the global table larper. Discord IDs are strings ("1234567890").

larper.on(event, function)

eventwhenargument
"message"a new message arrives in any channel (including your own)msg
"edit"someone edited a messagemsg, with the new text
"delete"a message was deleted{ id, channel } (IDs)
"dismiss"you closed this plugin's note with its ×{ id }
"ready"the app connected (or the plugin started while the app was already connected)none
"channel"you opened another channel{ id, name }, or nil
"lang"the app's language changed: draw your panel againnone
"panel"your panel opened or closed on the screentrue or false
"hosts"you approved or removed a host on the plugin's cardnone

msg has:

fieldexample
msg.content"good morning"
msg.author{ id = "1001", name = "ana" }
msg.channel{ id = "5000", name = "general" }
msg.guild{ id = "500", name = "Server" }, or nil in direct messages
msg.minetrue if you sent it
msg.ignoredtrue if you ignore whoever wrote it
msg.id"1350000000000000000"
msg.timeseconds since 1970
msg.attachmentshow many
msg.filesonly from larper.messages({ files = true }): a list of { name, url, size }
msg.reply_tothe ID of the message it replies to, if any
msg.mentions_meonly from larper.messages: true if it calls you (a mention, one of your roles, @everyone)

"message" and "edit" take a third argument, { contains = { "cve-", "://" } } (up to 16 pieces of text, ignoring case): your function only runs for messages with one of them, and the others don't even reach Lua. Use it whenever you can, so a plugin that cares about few messages costs nothing on the rest.

The history loaded when you open a channel doesn't fire "message", only what arrives live. System messages (someone joined, pinned…) don't either. A plugin never gets what was deleted, or a message's text from before it was edited, not even with the message log turned on.

lua
larper.on("message", function(msg)
  if not msg.mine and msg.content:lower():find("pizza", 1, true) then
    larper.notify(msg.author.name .. " talked about pizza in #" .. msg.channel.name)
  end
end)

larper.command("/name", function(args))

A local command: when you send a message that starts with /name, the function runs with the rest of the text (args, trimmed at both ends, "" if there's nothing) and nothing goes to Discord. The name is / followed by letters, numbers, _ or - (up to 32). If two plugins register the same name, the first one in file name order wins. A /something that no enabled plugin knows is sent as normal text.

larper.keybind("ctrl+shift+x", function)

A keyboard shortcut. Modifiers: ctrl, shift, alt, super; keys: a–z, 0–9, f1–f24, enter, space, tab, backspace, delete, insert, home, end, pageup, pagedown, up, down, left, right, , . / ; - =. Letters and numbers need ctrl, alt or super (otherwise they'd fire while you type); F keys can go alone. The shortcut works outside settings and the command palette, including with the text box focused. The app's own shortcuts (Ctrl+K, Ctrl+W…) keep working alongside it: pick another combination.

larper.notify(text, [options])

Shows a notice at the top of the window. With the window unfocused (and "Desktop notifications" on), a system notification goes out too, except in focus mode. With { desktop = true } the system notification goes out even with the window focused or in focus mode, for something the person explicitly asked for, like a reminder. At most 20 per 10 seconds and 6 system notifications a minute per plugin (20 a minute across all of them); the rest are dropped silently.

larper.send(channel, text)

Sends a real message, as if you had typed it. channel can be nil (the open channel), a table with an id (like larper.channel() or msg.channel) or the ID as a string. text is 1 to 2000 bytes. At most 5 per gesture. See Limits.

larper.channel() and larper.guild()

The open channel ({ id, name }) and its server ({ id, name }). nil when no channel is open (friends screen) or, for guild(), in a direct message.

larper.messages([options], function(list))

The messages loaded in the open channel, oldest first, as a copy. Options: channel (an ID), limit (200 by default, up to 500) and files (true also brings msg.files, so only who asks pays for it). contains works as in larper.on. The list also has list.last_read: the ID of the last message already read before you opened this channel, when there are unread ones. The answer arrives in function, later. It returns false, and never calls the function, if you ask again within half a second.

larper.jump(channel, message)

Opens the channel and goes to the message, fetching it if needed. Needs a gesture.

larper.insert(text)

Puts text in the message box. It never sends. Needs a gesture, or the answer to an HTTP request made in one.

larper.copy(text) and larper.open(url)

larper.copy puts text on the clipboard. larper.open opens an http:// or https:// address in the browser, and needs the links capability: an address can carry data, so the card warns when the plugin also reads messages. Both need a gesture.

larper.completions(trigger, list)

Suggestions in the message box while someone types the trigger at the start of a word:

lua
larper.completions(";", { { name = "hi", text = "Hello, how are you?", hint = "greeting" } })

Typing ;h shows the list, and picking one puts text in the box, instead of ;h. It never sends. The list stays with the app, so the search runs on every key without calling your plugin; call this again when it changes. The trigger is one of ; ! $ % & + = ? ^. Up to 256 suggestions and 256 KB per plugin.

larper.rewrite(list)

Fixed rules for the text the person sends:

lua
larper.rewrite({
  { strip_params = { "utm_*", "fbclid" } },                 -- in links, anywhere
  { strip_params = { "si" }, host = "youtu.be" },           -- only there and on its subdomains
  { replace_host = "old.example.com", with = "new.example.com" },
  { word = "btw", with = "by the way" },                    -- whole words, ignoring case; no mention or link
})

The app applies them when sending, after mentions are resolved. Your plugin doesn't see what was written and nothing runs while typing. Code blocks, mentions and emoji stay as they are. If the result wouldn't fit in a message, the text goes as written. Each call replaces the plugin's rules; up to 256, counting each parameter. It doesn't touch what larper.send sends, or edits.

larper.lang() and larper.tr(pt, en)

larper.lang() is "pt" or "en", and larper.tr("olá", "hello") is the text in the interface's language. Plugins should use it for all their text, and draw again on larper.on("lang", …).

larper.after(seconds, function), larper.every(seconds, function), larper.cancel(id)

Timers that run while the plugin is on, and die with it. Both return an ID for larper.cancel. every has a minimum of 1 second. A plugin can have up to 64. To survive a restart, keep the time in larper.storage and start the timer again when the plugin loads, as reminders.lua does.

larper.storage

Data that stays between restarts, separate for each plugin and account, on this computer (~/.config/larpercord/plugin-data/). Up to 1 MB per plugin.

FunctionWhat it does
larper.storage.get(key)The value, or nil. It's a copy: changing it doesn't save
larper.storage.set(key, value)Saves a string, number, boolean or table (a list, or with string keys). nil deletes. Raises an error if it doesn't fit, leaving the old value there
larper.storage.delete(key)Deletes
larper.storage.keys()The list of keys
larper.storage.clear()Deletes everything

It goes to disk shortly after the change, off the interface thread, and when the plugin stops. A file that can't be read is kept next to it as .bad, and the plugin starts empty with the error on its card.

larper.panel

A panel the app draws in a side panel the person can dock on either side and resize. Reach it with the button in the channel header, the command palette (Panel: name) or Open panel on the plugin's card. You describe it as data; publish again whenever something changes.

lua
local function draw()
  larper.panel.set({
    title = larper.tr("Meu painel", "My panel"),
    { type = "heading", text = "Heading" },
    { type = "text", text = "Text that wraps", style = "muted" },
    { type = "input", key = "search", placeholder = "Search", value = query,
      on_change = function(v) query = v; draw() end,
      on_submit = function(v) end },
    { type = "row",
      { type = "button", label = "Save", style = "accent", on_click = function() end },
      { type = "button", label = "Delete", style = "danger", on_click = function() end },
    },
    { type = "bar", label = "ana", value = 12, max = 30, text = "12" },
    { type = "select", key = "kind", label = "Show", value = kind,
      options = { { value = "all", label = "Everything" }, { value = "new", label = "New" } },
      on_change = function(v) kind = v; draw() end },
    { type = "toggle", key = "sound", label = "Sound", hint = "Optional text below", value = sound,
      on_change = function(on) sound = on end },
    { type = "separator" },
    { type = "list", empty = "Nothing here yet.", items = {
      { title = "Title", subtitle = "Up to 4 lines", meta = "12:30",
        on_click = function() end,
        actions = { { label = "Remove", style = "danger", on_click = function() end } } },
    } },
  })
end
TypeFields
texttext, style (muted, accent, danger)
headingtext
separatornone
rowbuttons in its list part, wrapping when they don't fit
buttonlabel, style, on_click
inputkey (required), placeholder, value, on_change(text), on_submit(text) (Enter), clear (empties on Enter)
barlabel, value, max, text, style
selectkey (required), label, value, options ({ value, label }), on_change(value)
togglekey (required), label, hint, value, on_change(true or false)
listitems, empty (the text when there are none)
list itemtitle, subtitle, meta, on_click, actions (buttons)

Any of them takes a key. While someone is typing in an input, publishing doesn't overwrite what's there; with the focus elsewhere, value wins. larper.panel.open() (needs a gesture) and larper.panel.close() show and hide it. Up to 4000 items and 1 MB of text. A click on an option or a toggle counts as a gesture. A badge next to title (a short text, like "3") shows on the panel's tab and as a dot on the channel header button, which then opens that panel first. Publish without it to clear.

larper.settings.set(list)

Options on the plugin's card in Settings → Plugins, with the same types as a panel, usually select and toggle. They show while the plugin is on. Your plugin keeps the value, with larper.storage, and publishes again on "lang":

lua
-- @capabilities settings, storage
local target = larper.storage.get("target") or "auto"
local function options()
  larper.settings.set({
    { type = "select", key = "target", label = larper.tr("Traduzir para", "Translate to"), value = target,
      options = { { value = "auto", label = "App language" }, { value = "es", label = "Spanish" } },
      on_change = function(v) target = v; larper.storage.set("target", v) end },
  })
end
larper.on("lang", options)
options()

larper.menu(where, label, function, [options])

An entry on the right-click menu. where is "message" (the function receives msg, as in larper.on, for the message you clicked) or "user" (it receives { id, name }). label is a string or { pt = "…", en = "…" }. Up to 4 per plugin. Options:

OptionWhat it does
choicesA second list, up to 16 { value, pt, en }. Clicking the entry opens it, and the value picked is the function's second argument
toolbarAn emoji: the entry also becomes a button on the toolbar of the hovered message, with the label as a tooltip. Only for "message", and at most 3 across all plugins
lua
larper.menu("message", { pt = "Traduzir para…", en = "Translate to…" }, function(msg, lang)
  translate(msg, lang)
end, { choices = { { value = "es", pt = "Espanhol", en = "Spanish" } } })

larper.decorate(message, options) and larper.undecorate(message)

Marks a message, only on your screen. message is an ID or a msg.

OptionWhat it does
badgeA small label under the message
noteA line of text under the message, up to 2000 bytes, with an × to dismiss
collapsetrue collapses it to one line, with "show"
labelThe text of that line
style"danger" shows it in red, for a warning

Up to 4096 per plugin. When a message is deleted, what was on it goes too. When the person closes a note with its ×, larper.on("dismiss") tells you.

larper.rules(list)

The same thing as rules the app applies on its own, including to messages that arrive later, without calling your plugin:

lua
larper.rules({
  { word = "spoiler", collapse = true, label = "Collapsed by my filter" },
  { user = "1234567890", badge = "!" },
})

word matches whole words, ignoring case. Each call replaces the plugin's rules. Up to 256. Your own messages are never affected.

larper.http

Requests to the outside world, off the interface thread. This is the only network a plugin has.

lua
-- @capabilities http
-- @hosts api.example.com
larper.http.get("https://api.example.com/x?q=" .. larper.urlencode(text), function(res)
  if res.ok then local data = larper.json.decode(res.body) end
end)

larper.http.get(url, [options], function) and larper.http.post(url, options, function). Options: body (up to 64 KB) and headers. The function receives { ok, status, body, error }. error is "not_approved", "rate_limited", "too_big" or "network".

larper.json.encode(value), larper.json.decode(text) and larper.urlencode(text)

decode returns the value, or nil and an error message. encode takes what larper.storage.set takes.

larper.lsp(lang, argv)

Proposes a language server for code blocks in the message box (code diagnostics): lang is the block tag or a language key from that page ("zig", "python", "haskell"), and argv is the program and its arguments, with no shell involved.

lua
larper.lsp("haskell", {"haskell-language-server-wrapper", "--lsp"})

This does not run anything. The proposal appears under Settings → Code diagnostics, off, with the full command. It only runs after you turn it on, for that plugin, language and exact command; changing the command turns it off again. The plugin never sees what you type or what the server answers. A plugin can propose up to 8 servers; calling this again for the same language replaces the proposal.

print(...)

Goes to the app's log ([name.lua] … in the terminal), not to the interface.

Source of this page on GitHub