tSIP softphone: Lua scripting

tSIP has built-in Lua scripting capabilitity, very useful for all sorts of customization.

There are various ways to run scripts:

Lua files should typically be placed in a "script" subdirectory.

Example of script assigned to a button:
Script button configuration

Lua is dynamically typed and even trivial typo might make script fail during the runtime. Using built-in Lua editor is recommended as it is able to use luacheck to detect early some common problems. Built-in editor passes to luacheck list of custom tSIP Lua functions to avoid false positives for undefined identifiers.

General-purpose programming language like Lua would not know much about telephony, not to mention some niche softphone. Fortunately Lua is extensible. tSIP adds following custom commands:

enter specified digits and start calling
end current call
answer current incoming call
set number edit field
get dial edit content
text from system clipboard
set text in system clipboard
pause execution for specified time in miliseconds; not recommended as UI is blocked but can be used in combination with Beep() to create audible feedback
Beep(freq, time)
equivalent for WinAPI Beep() (PC speaker or - with 64 bit windows - default audio output)
display standard modal Win32 message
direct equivalent of WinAPI function with the same name
  MessageBox("message with just [OK] button", "message title", 0)
  MessageBox("message with ICON_INFORMATION", "message title", 64)
  local res = MessageBox("message with MB_YESNO and question icon", "message title", 4+32)
  if res == 6 then
  	ShowMessage("\"Yes\" was pressed")
  	ShowMessage("Result is other than \"Yes\"")
direct equivalent of VCL function with the same name, displays modal dialog allowing to take text input from the user
  local caption = "Some dialog caption"
  local prompt = "Some dialog prompt"
  local defaultText = "Default text"
  local text, isAccepted = InputQuery(caption, prompt, defaultText)
  if isAccepted then
  	ShowMessage("Dialog accepted, text = " .. text)
  	ShowMessage("Dialog was not accepted")
SwitchAudioSource(module, device)
change audio source used during current call or streaming, e.g. SwitchAudioSource("aufile", "test.wav"), SwitchAudioSource("winwave", "USB Phone")
send DTMF characters to current call, e.g. SendDtmf("1234*#)
transfer (blind) current call to specified destination, e.g. BlindTransfer("123")
returns current call state: integer value according to Callback::ua_state_e, i.e.
	enum ua_state_e
returns current registration state; might be called from script assigned to "on registration state" event
returning name of call recording file, empty string if there is no recording; valid after call is established (recording started), cleared on new call, intented to be used mostly at CALL_STATE_CLOSED state
returns non-zero if call direction = outgoing
returning caller or callee number (i.e. second party, depending on call direction)
information if RTP streaming is currently active - int as in Callback::paging_tx_state_e enum
GetInitialCallTarget() and SetInitialCallTarget(number)
function pair intented to be use for "on making call" event, allowing to override number dialed by the user - see HOWTO list, SIP originate for example use
built-in Lua os.execute() displays nasty command line windows; this function gives access to WinAPI ShellExecute; example: ShellExecute("open", "nircmd.exe", "speak text \"Luke, I am your father\"", nil, 1)
SetButtonCaption(btnId, text)
set text for button with specified id
SetButtonCaption2(btnId, text)
set caption for second text line of the button
SetButtonDown(btnId, state)
set "pressed" (0 or 1) state for button with specified id
state = GetButtonDown(btnId)
check if button is in pressed (returning 1) or normal state (returning 0)
SetButtonImage(btnId, file.bmp)
set bitmap for button with specified id
show (state = 1) or hide (state = 0) main menu of the application
PluginSendMessageText(dllName, text)
pass text (command, data...) to specified dll plugin (must be supported by dll itself); dll name must contain file extension (e.g. "NameOfDll.dll")
PluginEnable(dllName, state)
enable/disable plugin, e.g. PluginEnable("TTS.dll", 1)
srcType, srcTypeIsSet = GetExecSourceType()
check what type of event was caused script execution; for script assigned to button srcType would be equal to 0, see ScriptExec.h for full list of event types
srcId, srcIdIsSet = GetExecSourceId()
get additional info for execution origin; for scripts assigned to button srcId would containt id of the button thus same lua source file could be assigned to multiple buttons and run in different way depending on execution source
number, state = GetBlfState(contactId)
get BLF state id for specified contact; this function is intented to be called from "on blf state" event where contactId is passed as GetExecSourceId()
status = RecordStart(filename, channels)
start recording current call into specified file, either mono (channels = 1) or stereo (channels = 2)
filename = GetExeName()
get application executable name with full path
state = GetRecordingState()
check if recording is running
inviteText = GetCallInitialRxInvite()
get full text of initial INVITE received for incoming call
codecName = GetCallCodecName()
get name of codec used in current call (typically to be used in "on call state" script when call is established)
description = GetContactName(number)
get number description from phonebook
ShowTrayNotifier(description, uri, incoming)
show small tray notification window; if incoming is true then "Answer" button would be visible
name = GetUserName()
get user name from configuration; might be used to differenciate application instances, e.g. refer to folder with name containing user name
simulate clicking specified button
refresh list of audio devices (before enumerating them with GetAudioDevice())
name, valid = GetAudioDevice(moduleName, direction, id)
enumerating audio devices one by one; moduleName = "winwave" or "portaudio", direction = "in" (recording device) or "out" (playback device), id = index starting from 0; returning name (string) of the device and valid (int): 1 if name is valid / device exists, 0 otherwise
status = UpdateSettings(jsonString)
update main configuration with specified JSON; JSON does not have to contain complete configuration, only what needs to be changed; status would be non-zero on error (e.g. invalid JSON passed)
uid = SendCustomRequest(uri, method, extraHeaderLines)
send arbitrary SIP request to specified URI; returning request id needed to check request status; used by "Scan LAN with OPTIONS" example
uri, method, extraHeaderLines = GetCustomRequest(uid)
get information about initiated custom request; used by "Scan LAN with OPTIONS" example
haveReply, err, sipStatusCode = GetCustomRequestReply(requestUid)
check if reply for custom request is available, get error code and optionally SIP answer code; used by "Scan LAN with OPTIONS" example
close application; intended to allow updating configuration with some sort of provisioning while application is not running
Purpose: preprocessing number with "on make call" script event, e.g.:
target = GetInitialCallTarget()
print(string.format("Initial target: %s\n", target))
target = target:gsub("[^0123456789*#ABCD]", "")
print(string.format("Processed target: %s\n", target))
This allows to programmatically strip unwanted characters from number passed e.g. by click-to-call.
level = GetAudioRxSignalLevel()
measures peak audio signal values from RX direction with 100 ms interval; used by "Lenny" script example
count = GetAudioErrorCount()
if "Disconnect call on audio error" is unchecked this function allows to detect end of wave file used as audio source; used by "Lenny" script
re-read (refresh) contact list from file on disk; may be used after contact list (phonebook) is overwritten by other application or fetched from some remote server by script using curl
add text to opened contact note window; intended to automatically add call information (like date, time) to contact note, may be used with "on contact note open" event
SendTextMessage(target, text, sendImmediately)
send SIP/SIMPLE MESSAGE to number or URI; if sendImmedately = 0 then text is just added to message window
SetAppStatus(id, priority, text)
change application status visible as hint in system tray; added text is associated with id parameter, so it can be changed/deleted later; priority specifies order when building final tray hint text, lower number goes before higher number and default application status (showing e.g. if softphone is registered or not) has priority = 0

As function list grows some of them were separated into "tsip_winapi" module.
Note: for brevity module name is omitted at function definitions.

hWnd = FindWindow(className, windowName)
finds handle to window, see WinAPI
SendMessage(hWnd, msg, wParam, lParam)
sends message to window with specified handle, see WinAPI
keyState = GetAsyncKey(vKey)
reads current state of specific keyboard key; useful e.g. for defining script keys with different behavior depending on Shift/Ctrl/Alt state
local winapi = require("tsip_winapi")
local hWnd = winapi.FindWindow(nil, "Opera Video Cache Player")
if hWnd ~= 0 then
	-- 16 = WM_CLOSE
	print("Sending WM_CLOSE\n")
	winapi.SendMessage(hWnd, 16, 0, 0) 
	print("Window not found\n")

Functions that allow passing data between different scripts or from one script execution to another are worth special mention. As scripts are running in GUI thread context they are intented to run to completion in short time (i.e. use of Sleep() should be limited even if it does not block GUI message processing) and they are mostly uninterruptible. As some uses require keeping some state data (e.g. original call target that was replaced in case of SIP originate function) following function were added:

SetVariable("name", "value")
set text "value" for variable with specified "name" (variables are holding text and are indexed by text)
value, isset = GetVariable("name")
read back variable value; function returns two variables (Lua can do this) and if variable was not set before then isset equals 0
"unset" variable (remove "name" from variables map)
clear ("unset") all variables

Variables can be also set and cleared by plugins. Along with PluginSendMessageText function they allow bidirectional communication between scripts and plugins - see using FT232RL module as GPIO example.

Another method of connecting scripts and plugins is using queues. Same as with variables, queues are indexed by their names and holding strings as values. With version 0.1.64 following Lua functions were added:

QueuePush(queueName, stringValue)
pushing value to queue; if queue with specified name does not exist it is created
local value, isValid = QueuePop(queueName)
take (with removing) value from queue; isValid is set to 1 if successful (queue exists and is not empty)
delete whole queue

Output of print() is passed to the application log window - this is the main debugging tool.

Function list above might contains some typos or some functions might be accidentally omitted. Function list available in tSIP built-in script window Help (with convenient full-text search) might be more reliable as it is built on a factory pattern. Help menu contains also over a dozen examples - some containing basic language constructs (like loops and conditionals) for Lua, some with practical applications (scanning local network for SIP endpoints using OPTIONS). Other reference sources are pages from tSIP "howto" list, few github gist pages that should be indexed by google and - finally - source code itself (ScriptExec.cpp file).


Call to number from clipboard

txt = GetClipboardText()
print(string.format("Clipboard text = %s, dialing...\n", txt))

Send DTMF from clipboard, removing non-DTMF characters first

txt = GetClipboardText()
print(string.format("Clipboard text = %s, sending DTMF...\n", txt))
txt = txt:gsub("[^0123456789*#ABCD]", "")	-- cleans non-DTMF chars

"Normalize" number entered in dial box

-- get number from softphone dial edit
txt = GetDial()
print(string.format("Dial text = %s\n", txt))
-- remove leading zeroes
nonzero = 1
for i=1, string.len(txt) do
	if string.sub(txt, i, i) ~= "0" then
			print(string.format("Non-zero at index %d (%s)\n", i, string.sub(txt, i, i)))
		nonzero = i+1  
txt = string.sub(txt, nonzero)
print(string.format("Leading zeroes removed: %s\n", txt))
-- add (default) country prefix code if not present
if string.len(txt) == 9 then
	txt = "48" .. txt
print(string.format("Country code added: %s\n", txt))
-- add CO access code for PABX
txt = "00" .. txt
print(string.format("Setting number to dial: %s\n", txt))
-- set processed number back in dial edit
-- or: Call(txt)  

Call to specified number (conference room) and enter code

-- user config
number = "123456789"
dtmf = "1234"
-- end of user config
for i=1, 20, 1
	if (i == 20) then
		print("Timed out waiting for confirmed state\n")
	call_state = GetCallState()
	if call_state == 6 then
	elseif call_state == 0 then
		print("End of call\n")
print("End of script\n")  

Enumerate audio devices

local name, valid
local id
local moduleName = "winwave"

print(string.format("Devices for %s module:\n", moduleName))

print("  Input devices:\n")
id = 0
	name, valid = GetAudioDevice(moduleName, "in", id)
	if valid == 1 then
		print(string.format("    #%d: %s\n", id, name))
	id = id + 1
until valid == 0

print("  Output devices:\n")
id = 0
	name, valid = GetAudioDevice(moduleName, "out", id)
	if valid == 1 then
		print(string.format("    #%d: %s\n", id, name))
	id = id + 1
until valid == 0

WinAPI: GetAsyncKeyState()


function testflag(set, flag)
  return set % (2*flag) >= flag

local winapi = require("tsip_winapi")
-- https://docs.microsoft.com/en-us/windows/desktop/inputdev/virtual-key-codes
-- VK_SHIFT = 0x10 = 16
-- VK_CONTROL = 0x11 = 17
-- VK_MENU (Alt) = 0x12 = 18
-- etc.
keyState = winapi.GetAsyncKeyState(16)
if testflag(keyState, 32768) then
	print("SHIFT is down\n")
	print("SHIFT is up\n")

keyState = winapi.GetAsyncKeyState(17)
if testflag(keyState, 32768) then
	print("CONTROL is down\n")
	print("CONTROL is up\n")

keyState = winapi.GetAsyncKeyState(18)
if testflag(keyState, 32768) then
	print("ALT is down\n")
	print("ALT is up\n")

Back to tSIP softphone

 "Cookie monsters": 8151029    Parse time: 0.000 s