' SOFTG API WhatsApp - cliente para o SOFTG ERP / PDV. ' Compativel com VB.NET / .NET Framework 4.8 (Visual Studio 2022). Usa somente sintaxe VB 2012 e assemblies do ' proprio Framework: System.Net.Http, System.Web.Extensions (JavaScriptSerializer) e System.Drawing (QR Code). ' Documentacao: https://api.whats.softgempresarialsistemas.com.br/ ' Nunca grave o token no codigo-fonte: leia da configuracao protegida do ERP (DPAPI, cofre, etc.). Option Strict On Option Explicit On Imports System.Collections.Generic Imports System.Drawing Imports System.IO Imports System.Net Imports System.Net.Http Imports System.Text Imports System.Threading Imports System.Threading.Tasks Imports System.Web.Script.Serialization Namespace SoftG.WhatsApp ''' Erro devolvido pela API ({"erro","codigo"}). Nunca contem o token. Public Class SoftGWhatsAppException Inherits Exception Private ReadOnly _statusHttp As Integer Private ReadOnly _codigo As String Private ReadOnly _retryAfter As Integer Public Sub New(statusHttp As Integer, codigo As String, mensagem As String, Optional retryAfterSegundos As Integer = 0) MyBase.New(mensagem) _statusHttp = statusHttp _codigo = codigo _retryAfter = retryAfterSegundos End Sub ''' 0 = falha de rede ou tempo esgotado; demais = HTTP da resposta. Public ReadOnly Property StatusHttp As Integer Get Return _statusHttp End Get End Property ''' Ex.: TOKEN_INVALIDO, WHATSAPP_DESCONECTADO, NUMERO_SEM_WHATSAPP, LIMITE_EXCEDIDO. Public ReadOnly Property Codigo As String Get Return _codigo End Get End Property ''' No 429 (limite por minuto), segundos a aguardar antes de repetir. Public ReadOnly Property RetryAfterSegundos As Integer Get Return _retryAfter End Get End Property ''' Falhas transitorias (rede, 429, 5xx) podem ser repetidas com a MESMA Idempotency-Key. Public ReadOnly Property PodeRepetir As Boolean Get Return _statusHttp = 0 OrElse _statusHttp = 429 OrElse _statusHttp >= 500 End Get End Property End Class Public Class EnvioResultado ''' Id da mensagem na SOFTG API (use em StatusAsync). Public Property Id As String Public Property WhatsappId As String Public Property Status As String Public Property Destino As String Public Property CriadoEm As String End Class Public Class StatusMensagem ''' ENVIADA, ENTREGUE, LIDA ou FALHOU. Public Property Status As String Public Property CriadoEm As String Public Property EntregueEm As String Public Property LidaEm As String Public Property Erro As String End Class Public Class ConexaoQr ''' open (conectado), connecting (aguardando leitura) ou close. Public Property Estado As String ''' PNG do QR Code em data URI (data:image/png;base64,...). Vazio quando ja conectado. Public Property Base64 As String Public Property ExpiraEm As String ''' QR Code pronto para um PictureBox. Nothing quando ja conectado. Public Property QrCodeImagem As Image End Class Public Class NumeroVerificado Public Property Numero As String Public Property Existe As Boolean Public Property Jid As String End Class Public Class SoftGWhatsAppClient Implements IDisposable ' Uma instancia de HttpClient por cliente evita esgotamento de sockets; nao criar uma por requisicao. Private ReadOnly _http As HttpClient Private ReadOnly _instancia As String Private ReadOnly _token As String Private ReadOnly _json As New JavaScriptSerializer() Shared Sub New() ' .NET Framework 4.8 em sistemas antigos pode negociar TLS 1.0; a API exige TLS 1.2 ou superior. ServicePointManager.SecurityProtocol = ServicePointManager.SecurityProtocol Or SecurityProtocolType.Tls12 End Sub ''' https://api.whats.softgempresarialsistemas.com.br/v1 ''' Ex.: cli_loja_exemplo_a1b2 (aba APIs do painel SoftG) ''' Token sgw_... da conexao Public Sub New(baseUrl As String, instancia As String, token As String) If String.IsNullOrWhiteSpace(baseUrl) Then Throw New ArgumentException("Informe a URL base.", "baseUrl") If String.IsNullOrWhiteSpace(instancia) Then Throw New ArgumentException("Informe a instancia.", "instancia") If String.IsNullOrWhiteSpace(token) Then Throw New ArgumentException("Informe o token.", "token") Dim uri As New Uri(baseUrl.TrimEnd("/"c) & "/") If uri.Scheme <> Uri.UriSchemeHttps AndAlso Not uri.IsLoopback Then Throw New ArgumentException("A SOFTG API WhatsApp exige HTTPS.", "baseUrl") End If _instancia = instancia.Trim() _token = token.Trim() _http = New HttpClient() _http.BaseAddress = uri _http.Timeout = TimeSpan.FromSeconds(120) _json.MaxJsonLength = 48 * 1024 * 1024 End Sub ''' Gera uma chave nova por operacao de negocio. Guarde-a junto do registro do ERP para repetir com seguranca. Public Shared Function NovaChaveIdempotencia(prefixo As String) As String Return prefixo & "-" & Guid.NewGuid().ToString("N") End Function ''' Testa URL, instancia e token. Retorna o estado do WhatsApp: open, connecting ou close. Public Async Function TestarAsync(Optional cancelamento As CancellationToken = Nothing) As Task(Of String) Dim resposta = Await ChamarAsync(HttpMethod.Get, "", Nothing, Nothing, cancelamento).ConfigureAwait(False) Return Texto(resposta, "estado") End Function ''' Estado da conexao: open, connecting ou close. Public Async Function EstadoAsync(Optional cancelamento As CancellationToken = Nothing) As Task(Of String) Dim resposta = Await ChamarAsync(HttpMethod.Get, "instance/connectionState/" & _instancia, Nothing, Nothing, cancelamento).ConfigureAwait(False) Return Texto(resposta, "estado") End Function ''' ''' Gera o QR Code para conectar o numero. Mostre QrCodeImagem e chame de novo a cada 20 s ate Estado = "open". ''' Public Async Function ConectarQrAsync(Optional cancelamento As CancellationToken = Nothing) As Task(Of ConexaoQr) Dim resposta = Await ChamarAsync(HttpMethod.Get, "instance/connect/" & _instancia, Nothing, Nothing, cancelamento).ConfigureAwait(False) Dim resultado As New ConexaoQr() resultado.Estado = Texto(resposta, "estado") resultado.Base64 = Texto(resposta, "base64") resultado.ExpiraEm = Texto(resposta, "expiraEm") If Not String.IsNullOrEmpty(resultado.Base64) Then Dim bytes As Byte() = Convert.FromBase64String(resultado.Base64.Substring(resultado.Base64.IndexOf(","c) + 1)) resultado.QrCodeImagem = Image.FromStream(New MemoryStream(bytes)) End If Return resultado End Function ''' Alternativa ao QR: codigo de 8 letras para digitar no celular (Conectar com numero de telefone). Public Async Function CodigoPareamentoAsync(numero As String, Optional cancelamento As CancellationToken = Nothing) As Task(Of String) Dim resposta = Await ChamarAsync(HttpMethod.Get, "instance/connect/" & _instancia & "?number=" & Uri.EscapeDataString(SoDigitos(numero)), Nothing, Nothing, cancelamento).ConfigureAwait(False) If Texto(resposta, "estado") = "open" Then Return "" Return Texto(resposta, "pairingCode") End Function ''' Desconecta o WhatsApp e apaga a sessao: para voltar e preciso ler um novo QR Code. Public Async Function DesconectarAsync(Optional cancelamento As CancellationToken = Nothing) As Task Await ChamarAsync(HttpMethod.Delete, "instance/logout/" & _instancia, Nothing, Nothing, cancelamento).ConfigureAwait(False) End Function ''' Envia texto. Use *negrito*, _italico_, ~tachado~ e vbLf para quebrar linha. Public Async Function EnviarTextoAsync(numero As String, texto As String, Optional idempotencyKey As String = Nothing, Optional cancelamento As CancellationToken = Nothing) As Task(Of EnvioResultado) Dim corpo As New Dictionary(Of String, Object)() corpo("number") = SoDigitos(numero) corpo("text") = texto Return ParaEnvio(Await ChamarAsync(HttpMethod.Post, "message/sendText/" & _instancia, corpo, idempotencyKey, cancelamento).ConfigureAwait(False)) End Function ''' ''' Envia PDF (DANFE, boleto), XML, imagem, video ou audio a partir de um arquivo local (ate 25 MB). ''' O tipo e definido pela extensao; o servidor confere a assinatura do arquivo. ''' Public Async Function EnviarArquivoAsync(numero As String, caminho As String, Optional legenda As String = Nothing, Optional idempotencyKey As String = Nothing, Optional cancelamento As CancellationToken = Nothing) As Task(Of EnvioResultado) Dim info As New FileInfo(caminho) If Not info.Exists Then Throw New FileNotFoundException("Arquivo nao encontrado.", caminho) If info.Length > 25L * 1024 * 1024 Then Throw New SoftGWhatsAppException(413, "ARQUIVO_MUITO_GRANDE", "Arquivo acima de 25 MB.") Dim mime As String = MimePorExtensao(info.Extension) Dim base64 As String = Convert.ToBase64String(File.ReadAllBytes(caminho)) Dim corpo As New Dictionary(Of String, Object)() corpo("number") = SoDigitos(numero) If mime.StartsWith("audio/") Then corpo("audio") = base64 Return ParaEnvio(Await ChamarAsync(HttpMethod.Post, "message/sendWhatsAppAudio/" & _instancia, corpo, idempotencyKey, cancelamento).ConfigureAwait(False)) End If corpo("mediatype") = If(mime.StartsWith("image/"), "image", If(mime.StartsWith("video/"), "video", "document")) corpo("mimetype") = mime corpo("media") = base64 corpo("fileName") = info.Name If Not String.IsNullOrEmpty(legenda) Then corpo("caption") = legenda Return ParaEnvio(Await ChamarAsync(HttpMethod.Post, "message/sendMedia/" & _instancia, corpo, idempotencyKey, cancelamento).ConfigureAwait(False)) End Function ''' Envia uma localizacao (ex.: endereco da loja). Public Async Function EnviarLocalizacaoAsync(numero As String, nome As String, endereco As String, latitude As Double, longitude As Double, Optional idempotencyKey As String = Nothing, Optional cancelamento As CancellationToken = Nothing) As Task(Of EnvioResultado) Dim corpo As New Dictionary(Of String, Object)() corpo("number") = SoDigitos(numero) corpo("name") = nome corpo("address") = endereco corpo("latitude") = latitude corpo("longitude") = longitude Return ParaEnvio(Await ChamarAsync(HttpMethod.Post, "message/sendLocation/" & _instancia, corpo, idempotencyKey, cancelamento).ConfigureAwait(False)) End Function ''' Confere quais numeros tem WhatsApp (ate 50 por chamada), resolvendo o 9o digito. Public Async Function VerificarNumerosAsync(numeros As IEnumerable(Of String), Optional cancelamento As CancellationToken = Nothing) As Task(Of List(Of NumeroVerificado)) Dim lista As New List(Of String)() For Each numero In numeros lista.Add(SoDigitos(numero)) Next Dim corpo As New Dictionary(Of String, Object)() corpo("numbers") = lista Dim bruto = Await ChamarBrutoAsync(HttpMethod.Post, "chat/whatsappNumbers/" & _instancia, corpo, Nothing, cancelamento).ConfigureAwait(False) Dim resultado As New List(Of NumeroVerificado)() For Each item In _json.Deserialize(Of List(Of Dictionary(Of String, Object)))(bruto) Dim verificado As New NumeroVerificado() verificado.Numero = Texto(item, "numero") verificado.Existe = item.ContainsKey("existe") AndAlso CBool(item("existe")) verificado.Jid = Texto(item, "jid") resultado.Add(verificado) Next Return resultado End Function ''' Status atual da mensagem enviada: ENVIADA, ENTREGUE, LIDA ou FALHOU. Public Async Function StatusAsync(id As String, Optional cancelamento As CancellationToken = Nothing) As Task(Of StatusMensagem) Dim resposta = Await ChamarAsync(HttpMethod.Get, "message/status/" & _instancia & "/" & Uri.EscapeDataString(id), Nothing, Nothing, cancelamento).ConfigureAwait(False) Dim status As New StatusMensagem() status.Status = Texto(resposta, "status") status.CriadoEm = Texto(resposta, "criadoEm") status.EntregueEm = Texto(resposta, "entregueEm") status.LidaEm = Texto(resposta, "lidaEm") status.Erro = Texto(resposta, "erro") Return status End Function Private Shared Function SoDigitos(numero As String) As String Dim sb As New StringBuilder() For Each c As Char In If(numero, "") If Char.IsDigit(c) Then sb.Append(c) Next Return sb.ToString() End Function Private Shared Function MimePorExtensao(extensao As String) As String Select Case extensao.ToLowerInvariant() Case ".pdf" : Return "application/pdf" Case ".xml" : Return "application/xml" Case ".txt" : Return "text/plain" Case ".csv" : Return "text/csv" Case ".jpg", ".jpeg" : Return "image/jpeg" Case ".png" : Return "image/png" Case ".webp" : Return "image/webp" Case ".gif" : Return "image/gif" Case ".mp4" : Return "video/mp4" Case ".mov" : Return "video/quicktime" Case ".ogg", ".opus" : Return "audio/ogg" Case ".mp3" : Return "audio/mpeg" Case ".m4a" : Return "audio/mp4" Case ".wav" : Return "audio/wav" Case ".docx" : Return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" Case ".xlsx" : Return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" Case ".zip" : Return "application/zip" Case Else : Throw New SoftGWhatsAppException(422, "MIDIA_INVALIDA", "Tipo de arquivo nao suportado: " & extensao) End Select End Function Private Shared Function Texto(dados As Dictionary(Of String, Object), chave As String) As String If dados Is Nothing OrElse Not dados.ContainsKey(chave) OrElse dados(chave) Is Nothing Then Return "" Return Convert.ToString(dados(chave), Globalization.CultureInfo.InvariantCulture) End Function Private Shared Function ParaEnvio(resposta As Dictionary(Of String, Object)) As EnvioResultado Dim envio As New EnvioResultado() envio.Id = Texto(resposta, "id") envio.WhatsappId = Texto(resposta, "whatsappId") envio.Status = Texto(resposta, "status") envio.Destino = Texto(resposta, "destino") envio.CriadoEm = Texto(resposta, "criadoEm") Return envio End Function Private Async Function ChamarAsync(metodo As HttpMethod, caminho As String, corpo As Object, idempotencyKey As String, cancelamento As CancellationToken) As Task(Of Dictionary(Of String, Object)) Dim bruto = Await ChamarBrutoAsync(metodo, caminho, corpo, idempotencyKey, cancelamento).ConfigureAwait(False) Return _json.Deserialize(Of Dictionary(Of String, Object))(bruto) End Function Private Async Function ChamarBrutoAsync(metodo As HttpMethod, caminho As String, corpo As Object, idempotencyKey As String, cancelamento As CancellationToken) As Task(Of String) Using request As New HttpRequestMessage(metodo, caminho) ' Token por requisicao (e nao em DefaultRequestHeaders) para nunca vazar entre clientes. request.Headers.Add("apikey", _token) If Not String.IsNullOrEmpty(idempotencyKey) Then request.Headers.Add("Idempotency-Key", idempotencyKey) If corpo IsNot Nothing Then request.Content = New StringContent(_json.Serialize(corpo), Encoding.UTF8, "application/json") Dim response As HttpResponseMessage Try response = Await _http.SendAsync(request, cancelamento).ConfigureAwait(False) Catch ex As HttpRequestException Throw New SoftGWhatsAppException(0, "REDE", "Falha de comunicacao com a SOFTG API WhatsApp: " & ex.Message) Catch ex As TaskCanceledException If cancelamento.IsCancellationRequested Then Throw Throw New SoftGWhatsAppException(0, "TEMPO_ESGOTADO", "Tempo limite excedido ao chamar a SOFTG API WhatsApp.") End Try Using response Dim texto As String = Await response.Content.ReadAsStringAsync().ConfigureAwait(False) If response.IsSuccessStatusCode Then Return texto Dim codigo As String = "HTTP_" & CInt(response.StatusCode).ToString() Dim mensagem As String = response.ReasonPhrase Try Dim erro = _json.Deserialize(Of Dictionary(Of String, Object))(texto) If erro IsNot Nothing Then If erro.ContainsKey("codigo") AndAlso erro("codigo") IsNot Nothing Then codigo = CStr(erro("codigo")) If erro.ContainsKey("erro") AndAlso erro("erro") IsNot Nothing Then mensagem = CStr(erro("erro")) End If Catch ex As ArgumentException ' Corpo nao JSON (ex.: pagina de erro do proxy): mantem o codigo HTTP. Catch ex As InvalidOperationException End Try Dim retryAfter As Integer = 0 If response.Headers.RetryAfter IsNot Nothing AndAlso response.Headers.RetryAfter.Delta.HasValue Then retryAfter = CInt(response.Headers.RetryAfter.Delta.Value.TotalSeconds) End If Throw New SoftGWhatsAppException(CInt(response.StatusCode), codigo, mensagem, retryAfter) End Using End Using End Function Public Sub Dispose() Implements IDisposable.Dispose _http.Dispose() End Sub End Class End Namespace