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:
- directly from a script editor window
- from a file assigned to "Script" type button (and there are lots of buttons...)
- from a file assigned to one of the dozen events ("on startup", "on timer", "on call state change", "on BLF change", ...)
- from a file assigned to hotkey
- from a text passed through command line
- from a text passed from a plugin
Lua files should typically be placed in a "script" subdirectory.
Example of script assigned to a button:
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
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