Ir al contenido

SDK de Go

El SDK de Go vive en sdk/go, dentro del repositorio del motor, como un módulo aparte. Tiene tres paquetes y dos falsos para tus tests:

Paquete Para qué
github.com/projectx27/motor-whatsapp/sdk/go (motor) El cliente de /v1: instancias, mensajes de texto e interactivos, comandos, eventos, suscripciones, entregas, replay y el catálogo.
…/sdk/go/webhook El receptor de webhooks: verifica la firma y guarda cada evento una sola vez en un inbox.
…/sdk/go/stream El cliente del stream de eventos por SSE, con reconexión por cursor.
…/sdk/go/webhook/webhooktest Un receptor falso para tests, con respuestas programables.
…/sdk/go/stream/streamtest Un stream falso en memoria para tests.

No cubre toda la API: lo que falta se hace con net/http y el mismo token (ver Lo que no trae).

Ventana de terminal
go get github.com/projectx27/motor-whatsapp/sdk/go@COMMIT_DEL_MOTOR
  • El repositorio del motor no es público. El SDK llega con el acceso: con él, marca el módulo como privado (GOPRIVATE=github.com/projectx27/*) para que go get no lo busque en el proxy público, o apunta a tu copia con una directiva replace.
  • Solo la biblioteca estándar. No arrastra el motor, ni whatsmeow, ni ninguna dependencia: su go.mod no tiene require.
  • Go 1.27 o posterior (la directiva go de su go.mod).
  • Sin versiones etiquetadas. El motor no publica tags: fija el SDK al commit del motor que corres, así sus tipos coinciden con el contrato que sirve.
  • El CI del motor corre las pruebas del SDK en un paso propio.
import motor "github.com/projectx27/motor-whatsapp/sdk/go"
c := motor.Nuevo("https://motor.ejemplo.com", apiKey)

motor.Nuevo arma un *motor.Cliente con un http.Client de 30 s de timeout. Sus campos son públicos: Base, Token y HTTP (cámbialo por el tuyo si necesitas otro timeout o un transporte propio). Cada pedido manda Authorization: Bearer <token> y, si lleva cuerpo, Content-Type: application/json.

Una respuesta de error del motor llega como *motor.Error:

Campo Qué es
Status El código HTTP.
Tipo El type del problem+json: urn:motor-whatsapp:error:<código> si es tipado.
Titulo · Detalle title y detail. Detalle viene en español neutro, listo para mostrar.
RetryAfter El Retry-After de la respuesta, si vino.
Codigo() El código tipado (cuota_agotada, no_listo…), o vacío.

Un 429 de ritmo en un envío no es un error para el SDK: el comando quedó guardado y llega en el resultado, con su NextTry. Ver Errores y Cuotas y límites.

cmd, err := c.EnviarTexto(ctx, instanciaID, "pedido-1042-confirmacion", motor.Texto{
Destino: motor.Destino{PeerRef: peerRef},
Texto: "Hola, tu pedido 1042 está confirmado.",
})
var e *motor.Error
if errors.As(err, &e) {
log.Printf("motor: %d %s %s", e.Status, e.Codigo(), e.Detalle)
}

Todos reciben un context.Context primero. clave es la Idempotency-Key.

Método Ruta Devuelve
CrearInstancia(ctx, clave, nombre, ownerRef) POST /v1/instances Instancia
Instancia(ctx, id) GET /v1/instances/{id} Instancia
Instancias(ctx) GET /v1/instances []Instancia (solo la primera página; ver abajo)
EnviarTexto(ctx, instanciaID, clave, Texto) POST /v1/instances/{id}/messages (tipo: text) Comando
EnviarInteractivo(ctx, instanciaID, clave, EnvioInteractivo) POST /v1/instances/{id}/messages (tipo: interactive) Comando
Comando(ctx, id) GET /v1/commands/{id} Comando
CancelarComando(ctx, id) POST /v1/commands/{id}/cancel Comando
Eventos(ctx, instanciaID, afterSeq, tipos, limite) GET /v1/instances/{id}/events PaginaEventos
CrearSuscripcion(ctx, NuevaSuscripcion) POST /v1/subscriptions Suscripcion, Secreto
Suscripciones(ctx) GET /v1/subscriptions []Suscripcion
CambiarEstadoSuscripcion(ctx, id, estado) PATCH /v1/subscriptions/{id} (activa o pausada) Suscripcion
RotarSecreto(ctx, id) POST /v1/subscriptions/{id}/rotate Secreto
EliminarSuscripcion(ctx, id) DELETE /v1/subscriptions/{id} —
Replay(ctx, suscripcionID, clave, PedidoReplay) POST /v1/subscriptions/{id}/replays Replay
Entregas(ctx, estado) GET /v1/deliveries []Entrega (las 200 más nuevas)
SeguimientoInteractivo(ctx, instanciaID, providerID) GET /v1/instances/{id}/interactive/{provider_id} Seguimiento
RespuestasInteractivas(ctx, instanciaID, providerID, cursor, limite) GET /v1/instances/{id}/interactive-responses PaginaRespuestas
ConsultarPerfilComercial(ctx, instanciaID, Destino) GET /v1/instances/{id}/business/profile PerfilComercial
Catalogo(ctx, instanciaID, Negocio, limite, cursor) GET /v1/instances/{id}/business/catalog PaginaCatalogo
Colecciones(ctx, instanciaID, Negocio, LimitesColecciones, cursor) GET /v1/instances/{id}/business/collections VistaColecciones

Para armar un interactivo hay constructores: motor.Botones(cuerpo, botones...), motor.Lista(cuerpo, botonLista, secciones...) y motor.Carrusel(cuerpo, tarjetas...), y los botones motor.Responder(id, texto), motor.CopiarCodigo(texto, codigo), motor.AbrirEnlace(texto, enlace), motor.Llamar(texto, telefono) y motor.BotonPIX(pix). Las reglas de cada variante están en Interactivos.

cmd, err := c.EnviarInteractivo(ctx, instanciaID, "encuesta-entrega-1042", motor.EnvioInteractivo{
Destino: motor.Destino{PeerRef: peerRef},
Interactivo: motor.Botones("¿Recibiste tu pedido?",
motor.Responder("si", "Sí"),
motor.Responder("no", "Todavía no")),
})

Hay dos formas, según la ruta:

  • El ledger, con seq: Eventos devuelve PaginaEventos con Eventos, NextAfterSeq y HasMore. Pide la siguiente con afterSeq = NextAfterSeq mientras HasMore sea true (limite de 1 a 500; 0 usa el del motor, 100).
  • Un cursor opaco: RespuestasInteractivas, Catalogo y Colecciones devuelven NextCursor; pásalo tal cual en la llamada siguiente.
desde := cursorGuardado // el último seq que procesaste
for {
p, err := c.Eventos(ctx, instanciaID, desde, []string{"message.received"}, 200)
if err != nil {
return err
}
for _, ev := range p.Eventos {
procesar(ev) // idempotente por ev.EventID
}
desde = p.NextAfterSeq
guardarCursor(desde)
if !p.HasMore {
break
}
}

El paquete webhook resuelve las tres reglas de un receptor: verificar la firma sobre el cuerpo crudo antes de parsear nada, guardar cada evento una sola vez y responder 2xx solo después del commit.

import "github.com/projectx27/motor-whatsapp/sdk/go/webhook"
inbox := webhook.InboxSQL{DB: db, Cliente: "motor-principal"} // Tabla: mw_inbox por omisión
if err := inbox.Crear(ctx); err != nil {
return err
}
http.Handle("/webhooks/motor", webhook.Receptor{
// key_id → secreto. Durante una rotación, las dos claves.
Verificador: webhook.Verificador{Claves: map[string]string{secreto.KeyID: secreto.Valor}},
Inbox: inbox,
Efecto: func(ctx context.Context, tx *sql.Tx, ev webhook.Evento) error {
// Tu lógica, en la MISMA transacción que el inbox.
return nil
},
})
Pieza Qué hace
Verificador Comprueba X-MW-Signature (v1, HMAC-SHA256) contra Claves (key_id → secreto), en tiempo constante, y rechaza un X-MW-Timestamp a más de Tolerancia (5 min por omisión). Errores: ErrSinFirma, ErrVencida, ErrFirma, ErrKeyDesconocida.
Receptor El http.Handler. Lee hasta MaxBody (8 MiB por omisión), verifica, parsea a webhook.Evento y llama a Inbox.Aceptar con tu Efecto. AlFallar (opcional) recibe el error de un evento que no se pudo guardar.
Inbox La interfaz: Aceptar(ctx, ev, efecto) (duplicado bool, err error).
InboxSQL El inbox sobre Postgres (database/sql): una tabla con clave (cliente, instancia, event_id). Un evento repetido devuelve duplicado sin volver a aplicar el efecto. Cliente distingue de qué motor vienen los eventos si recibes de más de uno.

Lo que responde el Receptor, y lo que hace el motor con eso:

Situación Respuesta El motor…
Guardado (o ya estaba) 204 La da por entregada.
El timestamp está fuera de tolerancia (un reloj desfasado) 408 Reintenta con una firma nueva; no suspende.
La firma no vale o ninguna key_id es conocida 401 Suspende la suscripción y abre una alerta.
El cuerpo pasa MaxBody o no es un evento 400 La marca fallida, sin reintentos.
El efecto o el inbox fallan 500 Reintenta con backoff.
No es POST 405 La marca fallida.

El receptor registra solo el event_id, el tipo y la clase del error de un evento que no pudo guardar: nunca su texto, que puede traer datos del mensaje. El formato de la firma, la rotación y los reintentos están en Webhooks.

webhooktest.Nuevo(claves) levanta un receptor falso (un httptest.Server) que verifica la firma como el de verdad y guarda en memoria. FijarResponder decide el status de cada pedido, FijarDemora agrega latencia, y Recibidos() y Efectos() dicen qué llegó.

El paquete stream lee el stream de eventos por SSE y reconecta solo con el último cursor entregado: no salta ni repite eventos.

import "github.com/projectx27/motor-whatsapp/sdk/go/stream"
s := stream.Abrir(stream.Opciones{
Base: "https://motor.ejemplo.com",
Token: apiKey, // scope events:read
Tipos: []string{"message.received", "message.updated"},
Cursor: cursorGuardado, // vacío: desde ahora
})
defer s.Cerrar()
for {
e, err := s.Siguiente(ctx)
if err != nil {
return err // ver la tabla de abajo
}
procesar(e.Evento) // idempotente por event_id
guardarCursor(e.Cursor) // DESPUÉS de procesar
}
Opción Qué es
Base · Token La URL del motor (sin /v1) y la API key.
Instancia Solo una instancia (/v1/instances/{id}/events/stream). Vacío: toda la app.
Instancias Un filtro de instancias del stream de la app.
Tipos Solo estos tipos.
Cursor Desde dónde reanudar. Vacío: desde ahora.
Contrato La versión del contrato de eventos que esperas (vacío: la vigente).
HTTP Tu http.Client, sin Timeout (el stream es largo).
Espera · MaxEspera El backoff entre reconexiones: de 1 s a 30 s por omisión.

Siguiente reconecta sola ante un corte reanudable (la red, un reinicio del motor, un cliente lento). Devuelve error cuando reconectar no sirve:

Error Qué pasó Qué hacer
*stream.CursorVencido El cursor tiene eventos más viejos que la retención del stream. Primer es el primer cursor disponible. Decide: reanudar con Primer acepta el salto; o lee lo perdido de otra forma.
*stream.Cerrado El motor cerró sin reanudación: Motivo es credencial_revocada o sin_scope. Cambia la key.
stream.ErrRechazado El motor rechazó el stream al abrir (401, 403, 404 o 422). Revisa la key, el scope o el filtro.
stream.ErrCerrado Llamaste a Cerrar. —
El error de ctx Venció tu contexto. La conexión sigue abierta para la próxima llamada. —

Cursor() dice el último entregado. Para tus tests, streamtest.Nueva(cursor) es una stream.Fuente en memoria: le agregas eventos con Agregar y cortes con Fallar. El protocolo del stream está en Streams SSE y WebSocket.

Para esto, usa HTTP directo con el mismo token (c.Base, c.Token y c.HTTP sirven):

  • GET /v1/whoami y GET /v1/capabilities.
  • Vincular un número (/v1/instances/{id}/pairing), las operaciones de la instancia (reconectar, pausar, desvincular, archivar) y cambiar su configuración (PATCH /v1/instances/{id}).
  • Los mensajes que no son texto ni interactivos: multimedia, ubicación, contactos, reacciones, edición, borrado, reenvío, encuestas y status.
  • Subir y descargar multimedia (/v1/instances/{id}/media/uploads, /v1/media/{id}).
  • Chats, contactos, consultas de identidad, grupos, comunidades, canales, perfil, privacidad, etiquetas, historial, presencia, lectura y llamadas.
  • Probar una suscripción (POST /v1/subscriptions/{id}/test), cambiarle otros campos que no sean el estado y ver una entrega con sus intentos (GET /v1/deliveries/{id}).
  • El stream por WebSocket y GET /v1/events/streams.
// Lo que el SDK no trae: HTTP directo con el mismo cliente.
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.Base+"/v1/whoami", nil)
req.Header.Set("Authorization", "Bearer "+c.Token)
res, err := c.HTTP.Do(req)

Todas las rutas están en la Referencia de /v1.