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:

Call(number)
enter specified digits and start calling
Hangup()
end current call
Answer()
answer current incoming call
SetDial(number)
set number edit field
GetDial()
get dial edit content
GetClipboardText()
text from system clipboard
SetClipboardText(text)
set text in system clipboard
Sleep(ms)
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)
ShowMessage
display standard modal Win32 message
MessageBox
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")
  else
  	ShowMessage("Result is other than \"Yes\"")
  end  
  
InputQuery
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)
  else
  	ShowMessage("Dialog was not accepted")
  end  
  
SwitchAudioSource(module, device)
change audio source used during current call or streaming, e.g. SwitchAudioSource("aufile", "test.wav"), SwitchAudioSource("winwave", "USB Phone")
SendDtmf(digits)
send DTMF characters to current call, e.g. SendDtmf("1234*#)
BlindTransfer(phone_num)
transfer (blind) current call to specified destination, e.g. BlindTransfer("123")
GetCallState()
returns current call state: integer value according to Callback::ua_state_e, i.e.
	enum ua_state_e
	{
		CALL_STATE_CLOSED = 0,
		CALL_STATE_INCOMING, // 1
		CALL_STATE_OUTGOING, // 2, etc.
		CALL_STATE_TRYING,
		CALL_STATE_RINGING,
		CALL_STATE_PROGRESS,
		CALL_STATE_ESTABLISHED,
	}   
    
GetRegistrationState()
returns current registration state; might be called from script assigned to "on registration state" event
GetRecordFile()
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
IsCallIncoming()
returns non-zero if call direction = outgoing
GetCallPeer()
returning caller or callee number (i.e. second party, depending on call direction)
GetStreamingState()
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
ShellExecute(...)
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
MainMenuShow(state)
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
ProgrammableButtonClick(buttonId)
simulate clicking specified button
RefreshAudioDevicesList()
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
ApplicationClose()
close application; intended to allow updating configuration with some sort of provisioning while application is not running
SetCallTargetUri(uri)
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))
SetInitialCallTarget(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
ReadContacts()
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
AppendContactNoteText(text)
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
Example:
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) 
else
	print("Window not found\n")
end

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
ClearVariable("name")
"unset" variable (remove "name" from variables map)
ClearAllVariables()
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)
QueueClear(queueName)
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).

Examples

Call to number from clipboard

txt = GetClipboardText()
print(string.format("Clipboard text = %s, dialing...\n", txt))
Call(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
SendDtmf(txt)  
  

"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
		do
			print(string.format("Non-zero at index %d (%s)\n", i, string.sub(txt, i, i)))
			break
		end
	else
		nonzero = i+1  
	end
end
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
end
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
SetDial(txt)
-- or: Call(txt)  
  

Call to specified number (conference room) and enter code

-- user config
number = "123456789"
dtmf = "1234"
-- end of user config
 
Call(number)
for i=1, 20, 1
do
	if (i == 20) then
		print("Timed out waiting for confirmed state\n")
		break;
	end
 
	Sleep(300)
	call_state = GetCallState()
	if call_state == 6 then
		-- CALL_STATE_ESTABLISHED
		Sleep(2000)
		SendDtmf("1234")
		break
	elseif call_state == 0 then
		-- CALL_STATE_CLOSED
		print("End of call\n")
		break;
	end
end
print("End of script\n")  
  

Enumerate audio devices

RefreshAudioDevicesList()
local name, valid
local id
local moduleName = "winwave"

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

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

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

WinAPI: GetAsyncKeyState()

print("*************\n")

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

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")
else
	print("SHIFT is up\n")
end

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

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

Back to tSIP softphone