' 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