Ir al contenido

WhatsApp

APX corre un plugin de WhatsApp sobre una sesión de WhatsApp Web: una cuenta, vinculada por QR, que maneja el daemon. Las credenciales viven en ~/.apx/whatsapp/auth/default/ con permisos 0700quien tenga esa carpeta ES la cuenta.

Cada mensaje entrante termina en exactamente uno de estos, decidido por código, antes de que ningún modelo vea el mensaje. Si a un desconocido se le contesta o no nunca es un juicio que hace el modelo sobre un mensaje que está tratando de convencerlo.

Quién escribeQué correQué recibe
El dueño (el que nombra el roster)el turno completo del super-agent — tools, memoria, proyectosuna respuesta normal
Un contacto del roster con un rolun turno sellado: sin tools, sin memoria, sin ningún otro canal, con esa única conversación como historiauna respuesta corta en texto plano
Cualquier otrono corre nadasilencio — se registra, y se le avisa al dueño

El silencio para un desconocido es el resultado buscado, no una falla. Dos cosas pasan con todo mensaje que no es del dueño, conteste o no: se registra en el canal whatsapp con el JID del remitente como actor, y se le avisa al dueño desde el daemon, no desde el modelo — un turno sellado no tiene tools, así que no podría escalar aunque quisiera.

  1. Abrí Settings → WhatsApp en el panel web y apretá Pair. Aparece un QR.

    🖥️ SCREENSHOT · web Settings → WhatsApp — el QR de vinculación apx web, Settings → WhatsApp, apretar Pair
    Settings → WhatsApp — el QR de vinculación
  2. En el teléfono: WhatsApp → Configuración → Dispositivos vinculados → Vincular un dispositivo, y escanealo.

  3. Configurá owner_jid — el número desde el que escribís vos. APX no lo adivina.

La línea que vinculaste y la persona que la posee son cosas distintas. Un despliegue común vincula un número dedicado en el que contesta el asistente, mientras el humano le escribe desde su propio teléfono, otro. Poner “el dueño es el que vinculó” haría que la línea sea dueña de sí misma y dejaría al humano real como un desconocido, así que owner_jid se pregunta y nunca se deduce.

{
"whatsapp": {
"enabled": true,
"auto_reply": true,
"reply_to_groups": false,
"owner_jid": "1234567890@s.whatsapp.net",
"contacts": [{
"jid": "1234567891@s.whatsapp.net",
"name": "Sam",
"nickname": "Sammy",
"relationship": "mi contadora",
"bio": "quién es, en palabras del dueño",
"rules": "qué quiere el dueño que se haga con esta persona",
"role": "client",
"auto_reply": true
}],
"roles": { "client": { "auto_reply": true } }
}
}
  • El que escribe queda registrado automáticamente como guest. Eso no es permiso — a un guest igual nunca se le contesta. El registro existe para que el dueño tenga algo a lo que darle permitir.
  • role: "owner" no se puede otorgar desde el roster.
  • Un rol indefinido falla cerrado (silencio), así que un typo nunca amplía el acceso.
  • bio y rules llegan solo al turno que contesta a esa persona. Lo que está escrito sobre un contacto nunca está en el prompt que le contesta a otro.
  • Los grupos vienen apagados: todo lo que se dice en un grupo lo lee gente que nadie avaló.

Un mismo humano puede tener dos direcciones — un JID de teléfono y un …@lid opaco. Las dos caen en el mismo hilo.

Una cuenta de empresa rara vez escribe oraciones. Manda un menú, y APX lo desarma en texto antes de que ningún turno lo vea:

Hola! Con qué te ayudo?
[Opciones: 1. Autos | 2. Hogar | 3. Vida]

Y un tap que alguien hizo sobre uno se lee como [eligió: Autos]. Se decodifican las cuatro generaciones que siguen vivas — botones de respuesta rápida, listas, templates hidratados y nativeFlow (incluida una lista single_select escondida adentro de un solo botón, y carruseles) — más las encuestas, y todas envueltas en viewOnceMessage o ephemeralMessage, que es como las mandan habitualmente las cuentas de empresa.

Para contestar uno, pasale option a la herramienta send_whatsapp — el número tal como se mostró ("2"), el título exacto ("Autos") o el id de la opción. APX busca el último menú de ese chat en el ledger de mensajes y manda la selección real, así el bot ve su botón apretado y no una oración.

  • Los botones de respuesta rápida, las listas y los templates reciben un tap real.
  • Los menús nativeFlow y las encuestas se contestan tipeando la etiqueta de la opción — no hay encoder para interactiveResponseMessage, e inventar un nodo del protocolo sería poner un mensaje malformado en el server de otro. En la práctica es lo que hace una persona cuando el botón no abre.
  • Si un tap queda sin respuesta, reintentá con as_text: true.
  • Un fragmento ambiguo no matchea nada en vez de apretar un botón a la adivinanza: "seguro" contra Seguro de auto y Seguro de hogar se rechaza, y el rechazo trae las dos opciones.

El hilo registra la etiqueta, no el id del botón — una transcripción llena de ids opacos no es la conversación que pasó.

WhatsApp tiene su propia entrada en el riel, debajo de Code: el roster, el diccionario de figuritas, los pedidos que te están esperando y las conversaciones de esta línea, en una sola página. Ajustes → WhatsApp es el mismo panel — se gana el lugar en el riel porque es un lugar al que entrás mientras alguien te está escribiendo, no una página que configurás una vez.

Las respuestas esperan un par de segundos antes de salir (whatsapp.reply_delay_ms, 2500 ms por defecto, 0 para contestar al toque). No es pose de cortesía: la gente escribe de a tandas — “hola”, “che”, y recién ahí la pregunta — y una respuesta que llega en menos de un segundo contesta el primer tercio de una idea y manda tres mensajes por uno. Si llega otro mensaje del mismo chat mientras corre el reloj, el turno anterior se corre y el nuevo contesta todo. El “escribiendo…” está prendido mientras tanto, así que la espera se lee como que está pensando.

Un archivo que te mandan se guarda. Si es un PDF o algo de texto, además se lee: sus palabras entran en el turno, así el agente contesta lo que dice el presupuesto y no que llegó un presupuesto. El resto se guarda con su propio nombre y se ofrece en el panel.

Dos cosas no se descargan nunca, y el hilo dice cuál de las dos fue: programas y scripts (.exe .msi .sh .apk .dmg .ps1 .jar …, mirando la ÚLTIMA extensión, así que cotizacion.pdf.exe es un exe) y cualquier cosa de más de 25 MB. Acá nada ejecuta un adjunto — la negativa existe porque si no el archivo queda en tu carpeta de medios con un nombre que eligió un desconocido. Los comprimidos (zip, rar, 7z) se guardan y no se abren.

Un archivo que llegó antes de que esto existiera se puede traer después: apx whatsapp repair le pide el mensaje al teléfono y ahí sí lo baja.

Mandar uno también anda: el send_whatsapp del agente acepta un file (una ruta absoluta de esta máquina), un documento conserva su nombre y una imagen llega como foto, y el texto que lo acompañe queda como epígrafe.

Lo que entra se resuelve antes de que corra el turno, así el registro queda completo incluso para gente a la que nunca se le contesta.

Llega comoLo que ve el turno
Audio[audio] <transcripción> — transcripto localmente
Fotopíxeles que un modelo de visión puede mirar de verdad
Sticker[sticker: <significado>]
GIFsu primer frame
Videose rechaza — hay que decirlo derecho en vez de adivinar por el caption
Documento[document: <nombre> — not opened]
Reacciónse registra como [reaccionó ❤️], y nunca se contesta

Los stickers se aprenden una sola vez: la primera vez que aparece uno lo describe un modelo de visión y se guarda por hash de contenido en ~/.apx/whatsapp/stickers.json; cada aparición posterior reusa esas mismas palabras, así el historial queda coherente. El dueño puede reescribir el texto y el modelo nunca se lo pisa de vuelta.

Usá la herramienta send_whatsapp: { to, text }. to acepta un número de teléfono en cualquier formato o un JID completo. Solo texto plano — WhatsApp no renderiza markdown, así que los asteriscos y las backticks llegan como caracteres literales.

CampoQué hace
textel cuerpo, en texto plano
stickerdescribí uno en palabras; se matchea contra la biblioteca aprendida
optioncontestar un menú — el número, el título exacto o el id
as_textcon option: tipear la etiqueta en vez de apretar el botón de verdad
react_toponer un emoji sobre un mensaje en vez de mandar uno

Se entrega en el instante en que la llamada retorna. No hay borrador, no hay undo y no hay recall.

Los mensajes que el dueño escribe desde su propio teléfono también se registran (WhatsApp los espeja al dispositivo companion como fromMe). Se registran y nunca se contestan — contestarlos sería contestarse a uno mismo — y llevan meta.authored_by: "owner", así el hilo se lee como la conversación entera.

Todas las rutas cuelgan de /api/whatsapp.

GET /status estado de la sesión + resumen del roster (nunca el QR)
POST /pair arrancar la vinculación → { qr, qr_data_url }
POST /logout soltar las credenciales
PATCH /settings { enabled, auto_reply, reply_to_groups }
POST /send { jid, text }
GET /contacts PATCH · DELETE /contacts/:jid
POST /contacts/:jid/owner marcar ese contacto como el dueño
GET /roles PUT · DELETE /roles/:name
GET /suggestions POST /suggestions/:id/confirm · /dismiss
GET /stickers PATCH · DELETE /stickers/:key
GET /repair qué está mal en los chats (pregunta, no toca nada)
POST /repair { dry_run, force }
POST /choose { jid, option } tocar una opción del último menú de ese chat

Hay fallas que no se arreglan solas con el próximo mensaje. Una empresa cae en el roster sin nombre (una cuenta de negocio no manda push name), así que su hilo queda encabezado por una dirección cruda. Una conversación que abrió APX no se puede continuar, porque el roster se alimentaba sólo de quien escribía. Un menú queda escrito en el ledger como [empty message], por un decodificador que no conocía la forma en que llegó. Y el mensaje de un contacto arranca un turno que un restart mata a mitad de camino: WhatsApp no entrega ese mensaje dos veces, así que nada lo reintenta.

Ventana de terminal
apx whatsapp chats # qué está mal — no toca nada
apx whatsapp repair # arreglarlo
apx whatsapp repair --dry-run # ...o verlo primero
apx whatsapp repair --force # reintentar mensajes que un teléfono apagado nunca devolvió
apx whatsapp status

Qué hace repair:

  • liga la otra dirección de una persona (su @lid y su número son una fila, no dos),
  • le pone nombre a una fila sin nombre, con lo que el ledger ya le escuchó,
  • hace contestable a un invitado al que APX le escribió primero, marcado pending_review: contestable, no revisado,
  • le pide al teléfono cualquier mensaje que llegó ilegible. El teléfono todavía lo tiene; el reenvío se reconoce como una reparación, así que nunca se contesta ni se te reporta de nuevo. Un menú recuperado vuelve con sus opciones, y el panel las dibuja como botones.

Lo que no hace: inventar un nombre, promover a alguien sobre quien ya decidiste, fusionar dos filas que pueden tener notas tuyas, ni contestar un mensaje por su cuenta. Un chat que quedó sin respuesta se reporta, nunca se contesta automáticamente — escribirle a alguien horas después lo decidís vos.

SíntomaCausa
Conectado, pero no se le contesta a nadie — ni a vosowner_jid está vacío, así que todos resuelven como desconocidos
logged_outlas credenciales están muertas. El plugin no reintenta solo — reintentar con credenciales muertas en loop es como se te marca una cuenta. Hay que vincular de nuevo
Idle después de un restart, sin sesiónesperado cuando nunca se vinculó nada. El daemon no abre un socket ni produce un QR sin que se lo pidan
Las respuestas de un contacto abren un segundo hiloescribieron desde una dirección …@lid que APX todavía no plegó — apx whatsapp repair la liga
Un contacto escribió y no recibió nadacayó un restart con el turno corriendo. El mensaje ya fue entregado, así que nada lo reintenta: apx whatsapp chats lista todos los chats así, con el texto
Un hilo titulado con una dirección cruda, o un mensaje que dice [empty message]apx whatsapp repair