Lync Addin – Send SMS

21/05/2015

Many companies I work for are using Lync for communication inside and outside the company, unlike most IM tools Lync doesn’t allow offline messaging. Which means that when you try to send a message to a contact that is offline you’ll receive the following message:

We couldn’t send this message because User is unavailable or offline.

So I decided to provide a workaround (no I’m not adding offline messaging to Lync) that allows me to send an SMS message to anyone directly from Lync (also pulling user name Mobile Number).

* Sending SMS requires a server that supports that *

Download Source and Setup

Context Menu Conversation Window Extension (CWE)
image image
Before we start make sure you have the following in order to build the project:
Supported operating system software:
  • Microsoft Windows Server 2008 R2
  • Microsoft Windows 7 (64-bit)
  • Microsoft Windows 7 (32-bit)

Required software:

Project structure:

  • Silverlight – Lync 2013 CWE application must be Silverlight
  • WPF – Will be lunch from Context menu and from Desktop.
  • Shared – Classes with Lync API that need to be compiled in both Silverlight and Wpf Apps.
  • Common –  Portable Class library with all Send SMS assets.

Because CWE and Context menu apps are different (Silverlight and WPF) I had to rewrite the UI twice but I’ve use the same code base using Shared and Portable library.

image

Helpers

HttpUtility.cs – Native encoding Url from portable library.

1 public static class HttpUtility 2 { 3 public static string UrlEncode(string str, Encoding e) 4 { 5 if (str == null) 6 return null; 7 8 byte[] bytes = UrlEncodeToBytes(str, e); 9 return Encoding.UTF8.GetString(bytes, 0, bytes.Length); 10 } 11 12 public static string UrlEncode(string str) 13 { 14 if (str == null) 15 return null; 16 return UrlEncode(str, Encoding.UTF8); 17 } 18 19 private static byte[] UrlEncodeToBytes(string str, Encoding e) 20 { 21 if (str == null) 22 return null; 23 byte[] bytes = e.GetBytes(str); 24 return UrlEncodeBytesToBytesInternal(bytes, 0, bytes.Length, false); 25 } 26 27 private static byte[] UrlEncodeBytesToBytesInternal(byte[] bytes, int offset, int count, bool alwaysCreateReturnValue) 28 { 29 int cSpaces = 0; 30 int cUnsafe = 0; 31 32 // count them first 33 for (int i = 0; i < count; i++) 34 { 35 char ch = (char)bytes[offset + i]; 36 37 if (ch == ' ') 38 cSpaces++; 39 else if (!IsSafe(ch)) 40 cUnsafe++; 41 } 42 43 // nothing to expand? 44 if (!alwaysCreateReturnValue && cSpaces == 0 && cUnsafe == 0) 45 return bytes; 46 47 // expand not 'safe' characters into %XX, spaces to +s 48 byte[] expandedBytes = new byte[count + cUnsafe * 2]; 49 int pos = 0; 50 51 for (int i = 0; i < count; i++) 52 { 53 byte b = bytes[offset + i]; 54 char ch = (char)b; 55 56 if (IsSafe(ch)) 57 { 58 expandedBytes[pos++] = b; 59 } 60 else if (ch == ' ') 61 { 62 expandedBytes[pos++] = (byte)'+'; 63 } 64 else 65 { 66 expandedBytes[pos++] = (byte)'%'; 67 expandedBytes[pos++] = (byte)IntToHex((b >> 4) & 0xf); 68 expandedBytes[pos++] = (byte)IntToHex(b & 0x0f); 69 } 70 } 71 72 return expandedBytes; 73 } 74 75 private static char IntToHex(int n) 76 { 77 Debug.Assert(n < 0x10); 78 79 if (n <= 9) 80 return (char)(n + (int)'0'); 81 else 82 return (char)(n - 10 + (int)'a'); 83 } 84 85 private static bool IsSafe(char ch) 86 { 87 if (ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9') 88 return true; 89 90 switch (ch) 91 { 92 case '-': 93 case '_': 94 case '.': 95 case '!': 96 case '*': 97 case '\'': 98 case '(': 99 case ')': 100 return true; 101 } 102 103 return false; 104 } 105 }

Settings.cs – Main settings file that contains the SMS server url, request method and more.

If your SMS server is based on rest or SOAP you can define it in the settings file, keep the arguments in the URL as it is, for example:

http://www.freesmsservice.com/sendSMS?Phones={0}&Message={1}

The app will inject the message and phone numbers into the URL.

1 public class Settings 2 { 3 public string SendButtonText = "Send"; 4 public string ClearButtonText = "Clear"; 5 public string NoPhoneFoundMessage = "Contact doesn't have mobile number defined."; 6 public string NoPhonesEnteredMessage = "Please enter at least on mobile number."; 7 public string NoMessageEnteredMessage = "Please enter SMS message and try again."; 8 public string InvalidPhoneNumber = "Invalid Phone Number"; 9 public string Loading = "Loading..."; 10 public string SuccessMessage = "SMS has been sent successfully!"; 11 public string Busy = "Sending in progress..."; 12 13 public bool RTL = false; 14 public int MaxSmsChars = 70; 15 public WebRequestType WebRequestType = WebRequestType.Rest; 16 public string ServiceUrl = "http://www.demoservice.com/service.svc"; 17 public string RestUrl = "http://www.demoservice.com/service?Phones={0}&Message={1}"; 18 public string ServiceMethod; 19 public string ServicePhonesParamName; 20 public string ServiceMessageParamName; 21 public HttpMethod RestMethod { get; set; } 22 }

If you don’t want to change the code and use the external settings files you can edit the html file located in the installation directory and it will override the default settings. (LyncSMSContextAddinPage.html)

1 <param name="initParams" value=" 2 SendButtonText=Send, 3 ClearButtonText=Clear, 4 NoPhoneFoundMessage=Contact doesn't have mobile number defined., 5 NoPhonesEnteredMessage=Please enter at least on mobile number., 6 NoMessageEnteredMessage=Please enter SMS message and try again., 7 Loading=Loading..., 8 SuccessMessage=SMS has been sent successfully!, 9 Busy=Sending in progress..., 10 InvalidPhoneNumber=Invalid Phone Number, 11 MaxSmsChars=70, 12 RTL=False, 13 WebRequestType=Rest, 14 ServiceUrl=http://www.demoservice.com/service.svc, 15 ServicePhonesParamName=phonesList, 16 ServiceMessageParamName=message, 17 ServiceMethod=SendSMS, 18 RestUrl=http://www.demoservice.com/service?Phones={0}&Message={1}" /> 19 <!--* Additonal Information *--> 20 <!-- WebRequestType -> Service or Rest--> 21 <!-- Service => clientaccesspolicy.xml required. Read more here: http://msdn.microsoft.com/en-us/library/cc645032(VS.95).aspx --> 22 <!-- RestMethod -> POST or GET--> 23 <!-- ServiceMethod -> String - Method Should Received Two Parameters (Phones, Message) -->

WPF Application

When you call external application from Lync Context Menu you can pass additional parameters, such as Contact info  and more.
[Add custom commands to Lync menus – https://msdn.microsoft.com/EN-US/library/jj945535.aspx]

For example: Path=”C:\\ExtApp1.exe /userId=%user-id% /contactId=%contact-id%”

I’m using those parameters to pass the location of the HTML file  htmlPath(the location can be changed in the Setup wizard) and the contactId which contains the phone numbers to send the SMS too.

To support Application arguments I’ve modified the OnStartup method in App.xaml.cs file.

1 protected override void OnStartup(StartupEventArgs e) 2 { 3 App.Current.DispatcherUnhandledException += Current_DispatcherUnhandledException; 4 try 5 { 6 string argsParam = @"/contactId:Contacts="; 7 string argsHtmlParam = @"/htmlPath:"; 8 if (e.Args.Length == 0) return; 9 10 foreach (string arg in e.Args) 11 { 12 if (arg.StartsWith(argsParam)) 13 { 14 int startIndex = arg.IndexOf(argsParam, System.StringComparison.Ordinal) + argsParam.Length; 15 var contacts = arg.Substring(startIndex); 16 17 Params.Contacts = contacts; 18 } 19 if (arg.StartsWith(argsHtmlParam)) 20 { 21 int startIndex = arg.IndexOf(argsHtmlParam, System.StringComparison.Ordinal) + argsHtmlParam.Length; 22 string htmlFile = ""; 23 htmlFile = arg.Substring(startIndex); 24 25 Params.HtmlFile = htmlFile; 26 } 27 } 28 } 29 catch (Exception ex) 30 { 31 MessageBox.Show("Reading Startup Arguments Error - " + ex.Message); 32 } 33 }

Let’s move to MainWindow.xaml.cs, our core for WPF app, there are two options for initialize the ViewModel, with Html file that contains the parameters or using the Default values.

The first line in the constructor will call – DefineVMModel method to check if there is an HTML file param and if so it will parse it into Dictionary<string, string>.

1 private void DefineVMModel() 2 { 3 if (string.IsNullOrEmpty(Params.HtmlFile) || !File.Exists(Params.HtmlFile)) 4 { 5 _vm = new MainViewModel(); 6 return; 7 } 8 9 try 10 { 11 using (StreamReader sr = new StreamReader(Params.HtmlFile)) 12 { 13 string fileContent = sr.ReadToEnd(); 14 int startIndex = fileContent.IndexOf(HtmlFileParamsArg, System.StringComparison.Ordinal) + 15 HtmlFileParamsArg.Length; 16 int endIndex = fileContent.IndexOf("\"", startIndex, System.StringComparison.Ordinal); 17 18 string values = fileContent.Substring(startIndex, (endIndex - startIndex)) 19 .Replace("\r\n", string.Empty); 20 string[] valuesArray = values.Split(','); 21 22 Dictionary<string, string> dictionary = valuesArray.ToDictionary(item => item.Split(new[] { '=' }, 2, StringSplitOptions.RemoveEmptyEntries)[0].Trim(), 23 item => item.Split(new[] { '=' }, 2, StringSplitOptions.RemoveEmptyEntries)[1].Trim()); 24 25 _vm = new MainViewModel(dictionary); 26 } 27 } 28 catch (Exception ex) 29 { 30 31 } 32 }

The second action in the constructor will be getting the contact phone number (assuming Contacts param available), this will require to acquire Lync client (You must add Lync SDK at this point).

(See additional data in comments inline)

1 try 2 { 3 client = LyncClient.GetClient(); 4 5 //Making sure Lync is valid for operations 6 while (client.Capabilities == LyncClientCapabilityTypes.Invalid) 7 { 8 System.Threading.Thread.Sleep(100); 9 client = LyncClient.GetClient(); 10 } 11 12 client.ClientDisconnected += client_ClientDisconnected; 13 client.StateChanged += client_StateChanged; 14 15 if (string.IsNullOrEmpty(Params.Contacts)) 16 return; 17 18 List<Contact> contacts = new List<Contact>(); 19 foreach (string contactSip in Params.Contacts.Split(',')) 20 { 21 //Contacts param can contain several contains contacts, for each we need to obtain the contact object. 22 var contact = client.ContactManager.GetContactByUri(contactSip.Replace("<", string.Empty).Replace(">", string.Empty)); 23 contacts.Add(contact); 24 } 25 26 foreach (Contact contact in contacts) 27 { 28 //Once we have contact object we'll ask Lync for Contact Information and search only for phone of type MobilePhone. 29 List<object> endpoints = (List<object>)contact.GetContactInformation(ContactInformationType.ContactEndpoints); 30 31 foreach (ContactEndpoint phone in endpoints.Cast<ContactEndpoint>().Where 32 (phone => phone.Type == Microsoft.Lync.Model.ContactEndpointType.MobilePhone)) 33 { 34 _vm.AddContact(phone.DisplayName); 35 } 36 } 37 } 38 catch (Exception exception) 39 { 40 MessageBox.Show(exception.Message, "Error While GetClient", MessageBoxButton.OK, MessageBoxImage.Error); 41 }

Regarding the UI, it’s pretty simple, the XAML is bind to the ViewModel, I’ll talk about the VM later in the post.

using Lync SDK allows me to add Lync Controls to my UI:

1 <controls:ContactSearchInputBox x:Name="contactSearchInputBox" VerticalAlignment="Top" Grid.Row="1" MaxResults="15" Margin="0"/> 2 <controls:ContactSearchResultList 3 Grid.Row="2" ItemsSource="{Binding ElementName=contactSearchInputBox, Path=Results}" 4 ResultsState="{Binding SearchState, ElementName=contactSearchInputBox}" SelectionMode="Single" SelectionChanged="ContactSearchResultList_SelectionChanged" Grid.ColumnSpan="2" Margin="0,0,-0.333,0"> 5 </controls:ContactSearchResultList>

image

Silverlight

The Silverlight app use the same concept as the WPF app except that Silverlight automatically receives the InitParams from the Html file in the MainPage constructor arguments.

As the Silverlight will use as CWE app we don’t need to ask for Lync client (as we already has it).

1 public MainPage(IDictionary<string, string> _settings) 2 { 3 InitializeComponent(); 4 5 _vm = new MainViewModel(_settings); 6 7 this.DataContext = _vm; 8 //_vm.MessageSent += VmOnMessageSent; 9 btnSend.Content = _vm.Settings.SendButtonText; 10 btnClear.Content = _vm.Settings.ClearButtonText; 11 lblLoading.Text = _vm.Settings.Loading; 12 13 LayoutRoot.FlowDirection = _vm.Settings.RTL 14 ? FlowDirection.RightToLeft 15 : FlowDirection.LeftToRight; 16 17 txtPhoneNumbers.FlowDirection = FlowDirection.LeftToRight; 18 19 try 20 { 21 _conversation = (Conversation)LyncClient.GetHostingConversation(); 22 if (_conversation == null) 23 return; 24 25 if (_conversation != null) 26 { 27 foreach (Participant participant in _conversation.Participants.Skip(1)) 28 { 29 object[] endpoints = (object[])participant.Contact.GetContactInformation(ContactInformationType.ContactEndpoints); 30 31 foreach (ContactEndpoint phone in endpoints.Cast<ContactEndpoint>(). 32 Where(phone => phone.Type == Microsoft.Lync.Model.ContactEndpointType.MobilePhone)) 33 { 34 _vm.AddContact(phone.DisplayName); 35 } 36 } 37 38 if (string.IsNullOrEmpty(_vm.PhoneNumbers)) 39 _vm.DisplayMessage(_vm.Settings.NoPhoneFoundMessage); // "Contact doesn't have mobile number defined."; 40 } 41 } 42 catch (Exception exception) 43 { 44 MessageBox.Show(exception.Message, "Error While GetHostingConversation", MessageBoxButton.OK); 45 } 46 }

ViewModel

ContactSearchResultListHandler – When you search a contact using Lync Contacts Search Control this handler will invoke, we need to extract the phone number of the selected contact from the list.

1 public void ContactSearchResultListHandler(SelectionChangedEventArgs e) 2 { 3 if (e.AddedItems.Count <= 0) return; 4 5 Microsoft.Lync.Controls.SearchResult searchResult = e.AddedItems[0] as Microsoft.Lync.Controls.SearchResult; 6 if (searchResult == null) return; 7 8 ContactModel contact = searchResult.Contact as ContactModel; 9 10 var mobilEndpoint = 11 contact.PresenceItems.Endpoints.FirstOrDefault(en => en.Type == ContactEndpointType.Mobile); 12 13 if (mobilEndpoint == null) 14 { 15 DisplayMessage(Settings.NoPhoneFoundMessage);// "Contact doesn't have mobile number defined."; 16 } 17 else 18 { 19 AddContact(mobilEndpoint.DisplayName); 20 } 21 }

AddContact – Will add the phone number to the PhoneNumbers property (displayed on the UI).

1 public void AddContact(string number) 2 { 3 number = Regex.Replace(number, @"[\D]", string.Empty).Trim(); 4 5 if (string.IsNullOrEmpty(PhoneNumbers)) 6 PhoneNumbers = number; 7 else if (!PhoneNumbers.Contains(number)) 8 { 9 PhoneNumbers = string.Format(PhoneNumbers.EndsWith(";") 10 ? "{0}{1}" : "{0};{1}", PhoneNumbers, number); 11 } 12 13 DisplayMessage(string.Empty); 14 }

Send SMS – Using both BackgroundWorker and ManualResetEvent to execute the send SMS message, this method will check either the settings are using Rest or Service and send the request based on the settings the user defined.

1 void _bg_DoWork(object sender, DoWorkEventArgs e) 2 { 3 var type = (WebRequestType)e.Argument; 4 SendSMSResponse response = new SendSMSResponse(); 5 ManualResetEvent.Reset(); 6 7 switch (type) 8 { 9 case WebRequestType.Service: 10 { 11 try 12 { 13 WebService ws = new WebService(Settings.ServiceUrl, Settings.ServiceMethod); 14 ws.ServiceResponseEvent += (s, args) => 15 { 16 ManualResetEvent.Set(); 17 18 response.IsError = !s; 19 response.Message = Settings.SuccessMessage; 20 }; 21 ws.Params.Add(Settings.ServicePhonesParamName, PhoneNumbers); 22 ws.Params.Add(Settings.ServiceMessageParamName, SmsMessage); 23 ws.Invoke(); 24 25 ManualResetEvent.WaitOne(); 26 } 27 catch (Exception ex) 28 { 29 response.IsError = true; 30 response.Message = ex.Message; 31 } 32 finally 33 { 34 e.Result = response; 35 } 36 } 37 break; 38 case WebRequestType.Rest: 39 try 40 { 41 Uri uri = new Uri(string.Format(Settings.RestUrl, PhoneNumbers, SmsMessage)); 42 WebClient client = new WebClient(); 43 client.Headers["Content-Type"] = "text/plain;charset=utf-8"; 44 client.OpenReadCompleted += (o, a) => 45 { 46 ManualResetEvent.Set(); 47 48 if (a.Error != null) 49 { 50 response.IsError = true; 51 response.Message = a.Error.Message; 52 return; 53 } 54 55 response.Message = Settings.SuccessMessage; 56 }; 57 client.OpenReadAsync(uri); 58 ManualResetEvent.WaitOne(); 59 } 60 catch (Exception ex) 61 { 62 response.IsError = true; 63 response.Message = ex.Message; 64 } 65 finally 66 { 67 e.Result = response; 68 } 69 break; 70 } 71 }

Install The Add in

Once we finished our logic and everything is ready we just need to change some registry keys so Lync will know out add in, let’s start with the custom command from the Context Menu, we need to launch out WPF and pass the contact argument and the location of the HTML file.

1 [HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Office\15.0\Lync\SessionManager\Apps\{3B3AAC0C-046A-4161-A44F-578B813E0BCF}] 2 "Name"="Send SMS" 3 "Path"="[ProgramFilesFolder][Manufacturer]\[ProductName]\SR.LyncSMS.App.exe /contactId:%contact-id% /htmlPath:"[ProgramFilesFolder][Manufacturer]\[ProductName]\LyncSMSContextAddinPage.html" 4 "ApplicationType"=dword:00000000 5 "SessionType"=dword:00000000 6 "Extensiblemenu"="MainWindowActions;MainWindowRightClick;ContactCardMenu;ConversationWindowContextual"

For CWE Silverlight application we need to specific where the Silverlight Html page locate.
[Install a CWE application in Lync SDK – https://msdn.microsoft.com/en-us/library/office/jj933101.aspx]

1 [HKEY_CURRENT_USER\Software\Microsoft\Communicator\ContextPackages\{310A0448-AF7C-49B0-9D8B-CC59A13E63E3}] 2 "DefaultContextPackage"="0" 3 "ExtensibilityApplicationType"="0" 4 "ExtensibilityWindowSize"="1" 5 "ExternalURL"="file:///[ProgramFilesFolder][Manufacturer]/[ProductName]/LyncSMSContextAddinPage.html" 6 "InternalURL"="file:///[ProgramFilesFolder][Manufacturer]/[ProductName]/LyncSMSContextAddinPage.html" 7 "Name"="Send SMS" 8

Run the setup, restart Lync and you should see both Send SMS from the Context Menu and CWE Window.

Download Source and Setup

Add comment
facebook linkedin twitter email

Leave a Reply