Donnerstag, 27. April 2023

Teams Presence synchronization for a 3CX on premises

 The recent posts about setting Teams presence and being notified about Teams presence changes are the building blocks to synchronize the presence state between MS Teams and our 3CX PBX both ways.

  • a call on Teams sets the 3CX presence state to "Away" if it was "Available" before
  • if Teams becomes "Available" and 3CX state is "Away" => set 3CX to "Available"
  • a call on 3CX tries to set the Teams presence "Busy" - there is some logic behind the scenes, we can't control about overriding "Offline" and "Away" in Teams which comes in handy in this case

In order to interact with the 3CX PBX there is an inoffical call flow API for v16 which has been adopted by the 3cx-web-api project. Apart from TAPI and CRM integrations this seems to be the only way to react to call states as well as set the presence state.

The API is written in C# using the .Net 5.0 framework since 3CX is a .Net application. It would have been nice to incorporate the presence functionality I coded in Bash into the 3cx-web-api. Unfortunately my coding skills are somewhat basic, especially when it comes to MVC design patterns which are used throughout all Microsoft identity sample projects.

I did manage to extend the 3cx-web-api to handle the notification webhook. That spares the use of the rudimentary webserver+socat of the previous post.

In order to direct traffic to the API I modified the /etc/nginx/sites-enabled/3cxpbx (normally a sym-link) and added an upstream and location setting:

...
    upstream webapi {
        server 127.0.0.1:1234;
    }
...
    server {
...
        location ~ ^/secret-url-teams-presence-notification {
            proxy_pass          http://webapi;
        }
...

Note: in this setup the path-name is the only "secret", preventing anybody to tamper with your PBX!!!

After building the 3cx-web-api according to the README.md, the StdOut of the program can be used to handle presence changes in a Bash script:

#!/bin/bash

declare -A users

# listen port to start the WebApi on
port=1234

# extension to O365 user ID mapping
users["101"]='user1@example.org'
users["102"]='user2@example.org'

# status refresh time in seconds
refresh=10

# regex to match state and extension
busy_match="^\s*ID=.*;S=(Dialing|Connected);DN=(.*);Queue_Name="

# path to teams_presence_notification.sh
TEAMS_NOTIFY="./teams_presence_notification.sh"

# path to set_teams_presence.sh
TEAMS_PRESENCE="./set_teams_presence.sh"

# those paths are used to exchange data between teams_presence_notification.sh
key_file="/tmp/azure.key"
user_file="/tmp/azure.users"

if [ ! -f "${user_file}" ] ; then
  touch "${user_file}"
fi

declare -A extensions
declare -A statechange

userlist=""
# initialize some state data
for i in "${!users[@]}" ; do
  statechange[$i]=0
  extensions["${users[$i]}"]=$i
  userlist="${userlist} ${users[$i]}"
done

echo "You may now run:"
echo " ${TEAMS_NOTIFY}${userlist}"

update=$(date +%s)

# loop over StdOutput of the call processing API
while read line ; do
  now=$(date +%s)
  # exit if the API server received a /stop command
  if [[ "$line" =~ "Server Stop" ]] ; then
    exit 0
  # process notification lifecycleEvent
  elif [[ "$line" =~ "subscriptionRemoved" ]] ; then
    echo "recreating subscription"
    ${TEAMS_NOTIFY} "${userlist}"
  # process notification lifecycleEvent
  elif [[ "$line" =~ "reauthorizationRequired" ]] ; then
    echo "reauthorizing subscription"
    ${TEAMS_NOTIFY} reauthorize $(echo $line | jq -r '.value[0].subscriptionId')
  # process notification lifecycleEvent
  elif [[ "$line" =~ "missed" ]] ; then
    echo $line
  # process notification webhook
  elif [[ "$line" =~ "subscriptionId" ]] ; then
    # decrypt encryption key with private key
    key=$(echo $line | jq -r '.value[0].encryptedContent.dataKey' | base64 -d | \
        openssl rsautl -decrypt -inkey "${key_file}" -oaep | hexdump -e '16/1 "%02x"')
    # iv is first 32 bytes of encryption key
    iv=$(echo $key | head --bytes 32)
    # decrypt data payload
    json=$(echo $line | jq -r '.value[] | .encryptedContent.data' | base64 -d | \
        openssl enc -d -aes-256-cbc -K $key -iv $iv)
    # extract Azure-UserId from json
    userid=$(echo "$json" | jq -r '.id')
    if [ -n "$userid" ] ; then
      # the user - userId should have been placed in a "user_file" when the notification was created
      #  since the userId is static, it saves another API lookup call
      user=$(cat "${user_file}" | grep "${userid}" | cut -d' ' -f1)
      if [ -n "$user" ] ; then
        # if the user is configured with an extension
        if [ -n "${extensions[${user}]}" ] ; then
          # and a state-change did not occur within $refresh period
          if [ $((now - statechange[${extensions[${user}]}])) -gt $((15 * refresh / 10)) ] ; then
            # retrieve the Teams availability status
            status=$(echo "$json" | jq -r '.availability')
            # retrieve 3CX availability status
            localstatus=$(curl -s http://localhost:${port}/showstatus/${extensions[${user}]})
            if [[ "$status" =~ "Available" ]] && [[ "${localstatus}" =~ "Away" ]] ; then
              echo "Setting extension <${extensions[${user}]}> Available (was ${localstatus##*=})"
              curl -s http://localhost:${port}/setstatus/${extensions[${user}]}/avail > /dev/null
            elif [[ "$status" =~ "Busy"|"DoNotDisturb"|"BeRightBack" ]] &&
                [[ "${localstatus}" =~ "Available" ]] ; then
              echo "Setting extension <${extensions[${user}]}> Away (was ${localstatus##*=})"
              curl -s http://localhost:${port}/setstatus/${extensions[${user}]}/away > /dev/null
            else
              echo "UPN: ${user} - Ext: ${extensions[${user}]} - Teams: $status - 3cx: ${localstatus##*=}"
            fi
          fi
        else
          echo "User <${user}> is not configured with an extension"
        fi
      fi
    else
      echo "Error parsing response JSON:"
      echo $json
    fi
  # check if a call is being set up or already connected
  elif [[ "$line" =~ $busy_match ]] ; then
    ext=${BASH_REMATCH[2]}
    # process further if the extension is in the user mapping
    if [ -n "${users[$ext]}" ] ; then
      # has there been a state change during the last refresh period?
      if [ $((now - statechange[$ext])) -gt $((15 * refresh / 10)) ] ; then
        echo "Setting user <${users[$ext]}> busy"
        ${TEAMS_PRESENCE} "${users[$ext]}" busy
      fi
      statechange[$ext]=$now
    fi
  elif [[ "$line" =~ (<html>|Listening) ]] ; then
    # set any users to "available" who are still busy but not in the active call list
    for i in "${!statechange[@]}" ; do
      # extension is not currently in a call, but the time it was is less than 1.5 * refresh period
      if [ $((now - statechange[$i])) -gt 2 -a $((now - statechange[$i])) -lt $((15 * refresh / 10)) ] ; then
        echo "Setting user <${users[$i]}> available"
        ${TEAMS_PRESENCE} "${users[$i]}" available
      fi
    done
    # schedule an update on CallStatus
    (sleep $refresh ; curl -s http://localhost:${port}/showallcalls > /dev/null )&
  fi
done < <(dotnet 3cx-web-API-Master/bin/Debug/net5.0/WebAPICore.dll $port)
The files from the previous two posts handle the Microsoft communication.

Keine Kommentare:

Kommentar veröffentlichen