Lua
The Lua plugin is a shell around a Lua script. The server does the low-level work (sessions, ICE and DTLS, RTP routing, recording); your script decides what the plugin is: which requests it accepts, what it answers, and who receives whose media. Janus calls functions your script defines, and your script calls host functions Janus registers as Lua globals. You use it to write a custom plugin, such as an echo test or a small conferencing logic, without writing Rust or C.
Unlike the other plugins, this one exists only when a script is configured. There is no janus.plugin.lua to attach to unless general.script names a file that loads.
The Lua VM is C code
plugin-lua embeds Lua 5.3 through the mlua crate with vendored C sources. It needs a C compiler to build and is the documented exception to the pure-Rust rule (ADR-0008, under ADR-0007). It is Lua 5.3, the version the C server targets, not LuaJIT and not 5.4.
Enable and configure
Build with plugin-lua, then create janus.plugin.lua.jcfg:
general: {
path = "/etc/janus/lua"
script = "/etc/janus/lua/hello.lua"
#config = "/path/to/a/file"
}| Key | Meaning |
|---|---|
script | The Lua file to load. Required: without it the plugin is not registered. |
path | A folder added to Lua's module search path, so require("json") finds /etc/janus/lua/json.lua. Several folders go in one string separated by semicolons. |
config | A path that is passed unchanged to your init() function. Only your script uses it. |
The full table is in the Lua config reference. The plugin is checked at startup:
// The Lua plugin only starts when janus.plugin.lua.jcfg names a script in general.script.
// 1. A config file without general.script: the plugin is left out. Server log line:
// janus: plugin janus.plugin.lua is not loaded: general.script is not set in its config file
// plugins in GET /janus/info: (neither Lua nor Duktape)
// 2. The sample script from the C repo (echotest.lua) with its helper modules:
// Loading plugin 'libjanus_lua.so'...
// [echotest.lua] Loading...
// [echotest.lua] Loaded
// [echotest.lua] Initializing...
// [echotest.lua] Initialized
// GET /janus/info lists janus.plugin.echolua: {"name":"Lua based EchoTest","author":"Lorenzo Miniero","description":"This is echotest.lua, a Lua based clone of janus.plugin.echotest","version_string":"0.0.12","version":12}
// 3. A script that lacks the nine required functions: the plugin is not registered. Server log:
// Loading plugin 'libjanus_lua.so'...
// [WARN] The 'janus.plugin.lua' plugin could not be initialized
// plugins in GET /janus/info: (neither Lua nor Duktape)
// 4. A script with a syntax error: the plugin is not registered. Server log:
// Loading plugin 'libjanus_lua.so'...
// [WARN] The 'janus.plugin.lua' plugin could not be initialized
// plugins in GET /janus/info: (neither Lua nor Duktape)
// 5. A script file that does not exist: the plugin is not registered. Server log:
// janus: plugin janus.plugin.lua is not loaded: cannot read /srv/janus/lua/missing.lua: No such file or directory (os error 2
// plugins in GET /janus/info: (neither Lua nor Duktape)The transcript shows what the server logs for each case. A missing file and a config without general.script are logged with a reason. A script that lacks a required function, or has a syntax error, is not logged with a reason (see Known gaps): you only see The 'janus.plugin.lua' plugin could not be initialized.
A script to start from
This is hello.lua, the script the transcripts below run. It defines the nine required functions, a few optional ones, and answers three kinds of request.
-- hello.lua: the smallest useful Janus Lua plugin.
-- Janus calls the nine functions below; the script calls the host functions (pushEvent, timeCallback, ...).
local json = require("json")
function init(config)
janusLog(4, "hello.lua: init, config file = " .. tostring(config))
end
function destroy() end
function createSession(id) end
function destroySession(id) end
function setupMedia(id) end
function hangupMedia(id) end
function resumeScheduler() end
function querySession(id)
return json.encode({ script = "hello.lua", id = id })
end
-- Optional: Admin API message_plugin requests land here (a JSON string in, a JSON string out).
function handleAdminMessage(message)
return message
end
-- Package name and version: optional, Janus uses these instead of "janus.plugin.lua".
function getPackage() return "janus.plugin.hello" end
function getName() return "Hello script" end
function getVersion() return 1 end
function getVersionString() return "0.0.1" end
function getDescription() return "A tiny Lua plugin for the docs" end
function getAuthor() return "docs" end
-- Called later by timeCallback(); the argument is always a string.
function reply_later(arg)
local id, tr = string.match(arg, "^(%d+):(.*)$")
pushEvent(tonumber(id), tr, json.encode({ hello = "later" }), nil)
end
-- Return values: 0, json synchronous reply
-- 1, nil "I will answer later with pushEvent"
-- -1, text error (text goes into the error reply)
function handleMessage(id, tr, msg, jsep)
local req = json.decode(msg)
if req.request == "ping" then
return 0, json.encode({ hello = "world", you_sent = req })
elseif req.request == "later" then
-- id is a Lua float (session ids are pushed with lua_pushnumber), so format it as an integer first.
timeCallback("reply_later", string.format("%d:%s", id, tr), 300)
return 1, nil
end
return -1, "Unknown request"
endThe package name: by default the plugin is janus.plugin.lua. If the script defines getPackage(), the plugin is registered under that name instead (janus.plugin.hello here), and GET /janus/info lists it with the name, author, description and version from the script's getName, getAuthor, getDescription, getVersion and getVersionString.
Try it
Your own script: synchronous, asynchronous, error
// hello.lua (shown on the page) as the plugin. It sets its own package name, so the plugin is listed as janus.plugin.hello.
// → POST /janus
{
"janus": "create",
"transaction": "t-create"
}
// ← 200 OK
{
"janus": "success",
"transaction": "t-create",
"data": {
"id": 4100001234567908
}
}
// GET /janus/info lists it as janus.plugin.hello: {"name":"Hello script","author":"docs","description":"A tiny Lua plugin for the docs","version_string":"0.0.1","version":1}
// → POST /janus/4100001234567908
{
"janus": "attach",
"plugin": "janus.plugin.hello",
"transaction": "t-attach"
}
// ← 200 OK
{
"janus": "success",
"session_id": 4100001234567908,
"transaction": "t-attach",
"data": {
"id": 4100002469135799
}
}
// A synchronous request: handleMessage returned the reply, so the HTTP answer carries it directly.
// → POST /janus/4100001234567908/4100002469135799
{
"janus": "message",
"transaction": "t-1",
"body": {
"request": "ping",
"n": 1
}
}
// ← 200 OK
{
"janus": "success",
"session_id": 4100001234567908,
"transaction": "t-1",
"sender": 4100002469135799,
"plugindata": {
"plugin": "janus.plugin.hello",
"data": {
"hello": "world",
"you_sent": {
"n": 1,
"request": "ping"
}
}
}
}
// An asynchronous request: handleMessage returned `1`, the reply comes later through pushEvent with the same transaction.
// → POST /janus/4100001234567908/4100002469135799
{
"janus": "message",
"transaction": "t-later",
"body": {
"request": "later"
}
}
// ← 200 OK
{
"janus": "ack",
"session_id": 4100001234567908,
"transaction": "t-later"
}
// ⇠ event, long poll GET /janus/<session>
{
"janus": "event",
"session_id": 4100001234567908,
"transaction": "t-later",
"sender": 4100002469135799,
"plugindata": {
"plugin": "janus.plugin.hello",
"data": {
"hello": "later"
}
}
}
// An error: handleMessage returned a negative result.
// → POST /janus/4100001234567908/4100002469135799
{
"janus": "message",
"transaction": "t-2",
"body": {
"request": "nope"
}
}
// ← 200 OK
{
"janus": "error",
"session_id": 4100001234567908,
"transaction": "t-2",
"error": {
"code": 462,
"reason": "Unknown request"
}
}
// Admin API handle_info (not shown in full): plugin_specific is what querySession returned:
// "plugin_specific": {"id":2007654332,"script":"hello.lua"}
// Admin API message_plugin: handleAdminMessage got the request text and returned it.
// → POST /admin
{
"janus": "message_plugin",
"plugin": "janus.plugin.hello",
"request": {
"request": "hello"
},
"transaction": "t-admin",
"admin_secret": "janusoverlord"
}
// ← 200 OK
{
"janus": "success",
"transaction": "t-admin",
"response": {
"request": "hello"
}
}The sample echotest.lua from the C repo
The C repository ships src/plugins/lua/echotest.lua and videoroom.lua. They require three modules that C installs next to them (json, term.colors, socket) and two helper files (janus-sdp.lua, janus-logger.lua). This port has no such modules built in: the transcripts were recorded with pure-Lua stand-ins for json, term.colors and socket that live in parity/oracle/script-modules/lua/, plus the C sample's own helpers, all in the folder named by general.path.
The sample sets getPackage() to janus.plugin.echolua, so you attach to that name:
// echotest.lua from the C repo, loaded as the plugin (with pure-Lua stand-ins for json, term.colors and socket from parity/oracle/script-modules).
// The script sets its own package name: janus.plugin.echolua. The browser offer is the canned offer of parity/sip-peer/sdp.
// → POST /janus
{
"janus": "create",
"transaction": "t-create"
}
// ← 200 OK
{
"janus": "success",
"transaction": "t-create",
"data": {
"id": 4100001234567908
}
}
// → POST /janus/4100001234567908
{
"janus": "attach",
"plugin": "janus.plugin.echolua",
"transaction": "t-attach"
}
// ← 200 OK
{
"janus": "success",
"session_id": 4100001234567908,
"transaction": "t-attach",
"data": {
"id": 4100002469135799
}
}
// → POST /janus/4100001234567908/4100002469135799
{
"janus": "message",
"transaction": "t-1",
"body": {
"audio": true,
"video": true
}
}
// ← 200 OK
{
"janus": "success",
"session_id": 4100001234567908,
"transaction": "t-1",
"sender": 4100002469135799,
"plugindata": {
"plugin": "janus.plugin.echolua",
"data": {
"echotest": "response",
"result": "ok"
}
}
}
// → POST /janus/4100001234567908/4100002469135799
{
"janus": "message",
"transaction": "t-2",
"body": {
"audio": true,
"video": true
},
"jsep": {
"type": "offer",
"sdp": "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\ns=-\r\n…"
}
}
// ← 200 OK
{
"janus": "ack",
"session_id": 4100001234567908,
"transaction": "t-2"
}
// ⇠ event, long poll GET /janus/<session>
{
"janus": "event",
"session_id": 4100001234567908,
"transaction": "t-2",
"sender": 4100002469135799,
"plugindata": {
"plugin": "janus.plugin.echolua",
"data": {
"echotest": "event",
"result": "ok"
}
},
"jsep": {
"type": "answer",
"sdp": "v=0\r\no=- 4611731400430051336 2 IN IP4 192.0.2.10\r\ns=-\r\n…"
}
}
// Admin API handle_info (not shown in full): plugin_specific is what the script's querySession returned:
// "plugin_specific": {"id":2007654332,"script":"echotest.lua"}
// → POST /admin
{
"janus": "message_plugin",
"plugin": "janus.plugin.echolua",
"request": {
"request": "hello"
},
"transaction": "t-admin",
"admin_secret": "janusoverlord"
}
// ← 200 OK
{
"janus": "success",
"transaction": "t-admin",
"response": {
"request": "hello"
}
}
// Server log lines of the script (the script prints them with its own logger):
// [echotest.lua] Created new session: 2700277088.0
// [echotest.lua] Handling message for session: 2700277088.0
// [echotest.lua] Handling message for session: 2700277088.0
// [echotest.lua] Resuming coroutines
// [echotest.lua] Handling async message for session: 2700277088.0
// [echotest.lua] Got offer: v=0
// [echotest.lua] Pushing event:
// [echotest.lua] Coroutines resumed
// [echotest.lua] Queried session: 2700277088.0
// [echotest.lua] Got admin message: {"request": "hello"}The script parsed the offer, built an answer with its own SDP library, and pushed it from a coroutine that the scheduler resumed (pokeScheduler and resumeScheduler). With a real browser the media comes back too:
// echotest.lua with a real browser: headless Chrome with a fake camera and microphone.
// The script's setupMedia calls addRecipient(id, id), so the plugin relays the session's media back to itself.
// → POST /janus
{
"janus": "create",
"transaction": "t-create"
}
// ← 200 OK
{
"janus": "success",
"transaction": "t-create",
"data": {
"id": 4100001234567908
}
}
// → POST /janus/4100001234567908
{
"janus": "attach",
"plugin": "janus.plugin.echolua",
"transaction": "t-attach"
}
// ← 200 OK
{
"janus": "success",
"session_id": 4100001234567908,
"transaction": "t-attach",
"data": {
"id": 4100002469135799
}
}
// → POST /janus/4100001234567908/4100002469135799
{
"janus": "message",
"transaction": "t-1",
"body": {
"audio": true,
"video": true
},
"jsep": {
"type": "offer",
"sdp": "v=0\r\no=- 6100000000001000000 2 IN IP4 127.0.0.1\r\ns=-\r\n…"
}
}
// ← 200 OK
{
"janus": "ack",
"session_id": 4100001234567908,
"transaction": "t-1"
}
// ⇠ event, long poll GET /janus/<session>
{
"janus": "event",
"session_id": 4100001234567908,
"transaction": "t-1",
"sender": 4100002469135799,
"plugindata": {
"plugin": "janus.plugin.echolua",
"data": {
"echotest": "event",
"result": "ok"
}
},
"jsep": {
"type": "answer",
"sdp": "v=0\r\no=- 6100000000001000000 2 IN IP4 192.0.2.10\r\ns=-\r\n…"
}
}
// ⇠ event, long poll GET /janus/<session>
{
"janus": "webrtcup",
"session_id": 4100001234567908,
"sender": 4100002469135799
}
// Browser getStats about 4 seconds after webrtcup: sent audio yes, sent video yes; received back: audio packets > 0, video packets > 0.
// (counts: audio in 192, video in 226, audio out 207, video out 248)videoroom.lua is the second sample. It does not override the package name, so it is janus.plugin.lua:
// videoroom.lua from the C repo as the plugin. It does not override the package name, so the plugin is janus.plugin.lua.
// → POST /janus
{
"janus": "create",
"transaction": "t-create"
}
// ← 200 OK
{
"janus": "success",
"transaction": "t-create",
"data": {
"id": 4100001234567908
}
}
// → POST /janus/4100001234567908
{
"janus": "attach",
"plugin": "janus.plugin.lua",
"transaction": "t-attach"
}
// ← 200 OK
{
"janus": "success",
"session_id": 4100001234567908,
"transaction": "t-attach",
"data": {
"id": 4100002469135799
}
}
// → POST /janus/4100001234567908/4100002469135799
{
"janus": "message",
"transaction": "t-1",
"body": {
"request": "list"
}
}
// ← 200 OK
{
"janus": "success",
"session_id": 4100001234567908,
"transaction": "t-1",
"sender": 4100002469135799,
"plugindata": {
"plugin": "janus.plugin.lua",
"data": {
"list": [],
"videoroom": "success"
}
}
}
// → POST /janus/4100001234567908/4100002469135799
{
"janus": "message",
"transaction": "t-2",
"body": {
"request": "create",
"room": 1234,
"description": "Lua room"
}
}
// ← 200 OK
{
"janus": "success",
"session_id": 4100001234567908,
"transaction": "t-2",
"sender": 4100002469135799,
"plugindata": {
"plugin": "janus.plugin.lua",
"data": {
"room": 1234,
"videoroom": "created"
}
}
}
// → POST /janus/4100001234567908/4100002469135799
{
"janus": "message",
"transaction": "t-3",
"body": {
"request": "list"
}
}
// ← 200 OK
{
"janus": "success",
"session_id": 4100001234567908,
"transaction": "t-3",
"sender": 4100002469135799,
"plugindata": {
"plugin": "janus.plugin.lua",
"data": {
"list": {
"1234": {
"audioCodec": [
"opus"
],
"description": "Lua room",
"participants": [],
"privateIds": [],
"publishers": 3,
"roomId": 1234,
"videoCodec": [
"vp8"
]
}
},
"videoroom": "success"
}
}
}
// → POST /janus/4100001234567908/4100002469135799
{
"janus": "message",
"transaction": "t-4",
"body": {
"request": "exists",
"room": 1234
}
}
// ← 200 OK
{
"janus": "success",
"session_id": 4100001234567908,
"transaction": "t-4",
"sender": 4100002469135799,
"plugindata": {
"plugin": "janus.plugin.lua",
"data": {
"exists": true,
"room": 1234,
"videoroom": "success"
}
}
}The host functions the port registers
The next transcript runs a script that asks type() of every global. The reply is the script's own return value.
// probe.lua: reports the type of every global host function. The reply is exactly what the script returned.
// → POST /janus
{
"janus": "create",
"transaction": "t-create"
}
// ← 200 OK
{
"janus": "success",
"transaction": "t-create",
"data": {
"id": 4100001234567908
}
}
// → POST /janus/4100001234567908
{
"janus": "attach",
"plugin": "janus.plugin.lua",
"transaction": "t-attach"
}
// ← 200 OK
{
"janus": "success",
"session_id": 4100001234567908,
"transaction": "t-attach",
"data": {
"id": 4100002469135799
}
}
// → POST /janus/4100001234567908/4100002469135799
{
"janus": "message",
"transaction": "t-1",
"body": {
"request": "probe"
}
}
// ← 200 OK
{
"janus": "success",
"session_id": 4100001234567908,
"transaction": "t-1",
"sender": 4100002469135799,
"plugindata": {
"plugin": "janus.plugin.lua",
"data": {
"eventsIsEnabled": 0,
"lua": "Lua 5.3",
"types": {
"addRecipient": "function",
"closePc": "function",
"configureMedium": "function",
"endSession": "function",
"eventsIsEnabled": "function",
"getModulesFolder": "nil",
"janusLog": "function",
"notifyEvent": "function",
"pokeScheduler": "function",
"pushEvent": "function",
"readFile": "nil",
"relayBinaryData": "function",
"relayData": "function",
"relayRtcp": "function",
"relayRtp": "function",
"relayTextData": "function",
"removeRecipient": "function",
"sendPli": "function",
"setBitrate": "function",
"setPliFreq": "function",
"setSubstream": "function",
"setTemporalLayer": "function",
"startRecording": "function",
"stopRecording": "function",
"testExtraFunction": "function",
"timeCallback": "function"
},
"wrongArgs": -1
}
}
}Reference: what your script defines
Your script must define these nine functions. If one is missing, the plugin does not load.
| Function | Called when | Return value |
|---|---|---|
init(config) | the plugin starts. config is general.config, or nil | none |
destroy() | Janus shuts down | none |
createSession(id) | a client attaches | none |
destroySession(id) | the client detaches | none |
querySession(id) | the Admin API asks for the handle (handle_info) | a JSON string, shown as plugin_specific |
handleMessage(id, transaction, message, jsep) | the client sends message | two values: 0, json_text (reply now), 1, nil (reply later with pushEvent) or a negative number and an error text. message and jsep are JSON strings; jsep is nil when absent |
setupMedia(id) | the PeerConnection is up | none |
hangupMedia(id) | the PeerConnection is down | none |
resumeScheduler() | the scheduler was poked (pokeScheduler) | none (an empty function is fine) |
Optional functions:
| Function | Called when |
|---|---|
handleAdminMessage(message) | an Admin API message_plugin request arrives; return a JSON string |
incomingRtp(id, is_video, buf, len), incomingRtcp(id, is_video, buf, len) | an RTP or RTCP packet arrives. If defined, the default relay is skipped. Discouraged: slow |
incomingTextData(id, buf, len, label, protocol), incomingBinaryData(id, buf, len, label, protocol) | a data channel message arrives (the older incomingData is deprecated) |
dataReady(id) | the data channel is ready for sending |
slowLink(id, uplink, is_video) | Janus detected a slow link |
substreamChanged(id, substream), temporalLayerChanged(id, temporal) | the simulcast layer a session receives changed |
getVersion(), getVersionString(), getDescription(), getName(), getAuthor(), getPackage() | at startup; the first answer is kept |
Session ids are Lua numbers that are floats. Janus pushes each id with lua_pushnumber, so "id: " .. id prints 4233110103.0. Use string.format("%d", id) or math.tointeger(id) when you build a string key or a transaction (the log of the echotest sample shows the .0).
Reference: what your script can call
Each function returns 0 on success, -1 on a wrong argument count or an unknown session (janusLog returns nothing on a wrong count). The table follows janus_lua.c.
| Function | Arguments | What it does |
|---|---|---|
janusLog(level, text) | log level (number), string | write to the Janus log |
pushEvent(id, transaction, event, jsep) | session id, the transaction of the request, event as a JSON string, JSEP as a JSON string or nil | send an event to the client (the late reply to a request you answered with 1, nil) |
notifyEvent(id, event) | session id or 0, JSON string | send an event to event handlers |
eventsIsEnabled() | none | 1 if event handlers are enabled, else 0 |
closePc(id) | session id | close the PeerConnection |
endSession(id) | session id | detach the plugin handle |
configureMedium(id, medium, direction, enabled) | session id, "audio", "video" or "data", "in" or "out", boolean | allow or stop receiving or sending a medium |
addRecipient(id, recipient), removeRecipient(id, recipient) | session ids | route (or stop routing) the session's media to another session |
setBitrate(id, bps) | session id, number | force a bitrate with REMB |
setPliFreq(id, seconds) | session id, number | how often to send a PLI to the session |
setSubstream(id, n), setTemporalLayer(id, n) | session id, number | choose the simulcast layer |
sendPli(id) | session id | send a keyframe request now |
relayRtp(id, is_video, buf, len), relayRtcp(id, is_video, buf, len) | session id, boolean, string with the packet, length | send a packet you built to a session |
relayTextData(id, text, len [, label [, protocol]]), relayBinaryData(...) | session id, payload, length, optional label and protocol | send a data channel message (relayData is the deprecated name) |
startRecording(id, type, codec, folder, filename [, …]) | session id, then groups of four: "audio", "video" or "data", codec, folder, base file name | start .mjr recordings |
stopRecording(id, type [, type…]) | session id, then "audio", "video", "data" | stop recordings |
pokeScheduler() | none | wake the scheduler, which calls resumeScheduler() |
timeCallback(function_name, argument, ms) | global function name (string), a string or nil, delay | call that function after ms milliseconds; exactly three arguments |
testExtraFunction() | none | the sample "extra" function of the C plugin: returns 1234 |
getModulesFolder and readFile do not exist in Lua (they are Duktape only): the probe shows them as nil.
Behaviour you may not expect
- A script error is not fatal. An error raised inside
handleMessage, a reply that is not valid JSON, or no return value at all gives the client{"janus": "error", "error": {"code": 462, "reason": "Lua error"}}(verified), and the plugin keeps running. C callshandleMessagewithlua_call, which does not catch errors (janus_lua.cline 1958), so do not rely on the port's recovery matching C. - The error reply for a negative
handleMessageresult carries your text:{"janus": "error", "error": {"code": 462, "reason": "Unknown request"}}. - A function called through
timeCallbackruns with the string argument you gave it. - Numbers, floats and strings follow Lua 5.3 rules, including the
.0suffix when a whole float becomes text. Non-finite floats becomenullon the way to Janus, and a table (array or object) pushed from Janus into your script arrives asnil: pass JSON strings. - With
getPackage()the plugin is notjanus.plugin.luaany more. A client that attaches tojanus.plugin.luagets "no such plugin".
Known gaps
- gap Script load errors are silent for the reason. A script without the nine functions, or with a syntax error, makes the plugin disappear with only
The 'janus.plugin.lua' plugin could not be initializedin the log (see thelua-loadtranscript, cases 3 and 4). C logsFunction 'x' is missing in <file>andError loading Lua script <file>: <error>. The Duktape plugin of this port does log the reason, so the missing line is specific to the Lua plugin. - gap Runtime errors in callbacks are not logged. Verified at debug level 7: a
handleMessagethat callserror("handler boom")and atimeCallbackfunction that callserror("timer boom")leave no line in the server log. Only the client seesLua error. When a script misbehaves, add your ownjanusLogcalls. - The Lua 5.3 VM is a C binding (ADR-0008). Differences from C that the ADR keeps on purpose: non-finite floats read as
null, a non-UTF-8 string in a text position reads asnull(binary payloads are exempt), arrays and objects pushed to the script arenil,janusLogwith a wrong argument count returnsnil, and tracebacks are dropped from error text. getModulesFolderandreadFileare not provided (as in C's Lua plugin).- Simulcast layer choice and the recording layer choice follow C (
parity/status.json, wave 152); not exercised here. - Not exercised here:
relayRtpandrelayRtcpfrom a script,startRecording,relayTextData, the data callbacks andslowLink. They are written fromjanus_lua.cand the Rust host table. The echotest sample exercisesaddRecipient,configureMedium,pushEvent,notifyEvent,pokeSchedulerand theget*overrides;hello.luaexercisestimeCallbackandpushEvent.
Test it in Janus Bench
Open the Bench Run all checks page and run lua.echotest (group Lua). It attaches to janus.plugin.echolua, negotiates through the script and waits for echoed audio and video. The Bench has no result recorded for it yet; the lua-echotest-media transcript above is the same flow run by hand, and it echoed both audio and video.