whatsapp-mcp
by moonslayers
README.md
# whatsapp-mcp
Servidor **MCP** que permite a opencode (o cualquier cliente MCP) controlar **WhatsApp Desktop** vía **Chrome DevTools Protocol (CDP)**, usando la **sesión real del usuario** (misma cuenta, misma ventana, mismos datos) en lugar de una API no oficial. El control se hace sobre el DOM de `web.whatsapp.com` que renderiza el propio WhatsApp Desktop, con medidas anti-ban (rate limiting y typing simulado) para minimizar el riesgo de bloqueo de la cuenta.
## Arquitectura
```
┌────────────┐ JSON-RPC 2.0 (stdio) ┌──────────────────────────────┐
│ opencode │ ◀──────────────────────▶ │ MCP server │
│ (cliente) │ │ src/server.ts │
└────────────┘ │ (10 tools expuestas) │
└───────────────┬───────────────┘
│ CDP (WebSocket)
▼
┌──────────────────────────────────┐
│ WhatsApp Desktop (Electron) │
│ --remote-debugging-port=9222 │
│ web.whatsapp.com │
└──────────────────────────────────┘
```
Capas del proyecto:
| Capa | Archivos | Responsabilidad |
|---|---|---|
| **Config** | `src/config.ts` | Configuración con defaults y override por env (`WA_MCP_*`); objeto inmutable (`Object.freeze`). |
| **CDP** | `src/cdp/` | Cliente Chrome DevTools Protocol: descubrimiento de targets (HTTP `/json`), conexión WebSocket al target de WhatsApp, `Runtime.evaluate`, captura de pantalla y sesiones flatten para targets OOPIF (auto-attach, `evaluateOnSession`, `pressEscape`; registry de adjuntos que reacciona a `Target.targetInfoChanged` para poblar la URL de un OOPIF fresco). Implementado sobre el `WebSocket` nativo de Node (sin dependencias de runtime extra). |
| **WhatsApp** | `src/whatsapp/` | Operaciones de negocio sobre el DOM de WhatsApp: `chats.ts` (listar, no-leídos, buscar), `messages.ts` (leer mensajes; `readMessages` legacy + `readMessagesOlder` para paginación hacia atrás), `pagination.ts` (helpers puros de merge/dedupe y decisión de parada del loop de paginación), `send.ts` (enviar con typing simulado), `draft.ts` (leer/limpiar texto sin enviar del input), `media.ts` (descargar media de un mensaje a disco), `pdf.ts` (descarga de documentos PDF vía el visor OOPIF de WhatsApp), `dom.ts` (selectores y extractores del DOM) y `errors.ts` (errores tipados). |
| **Rate limit** | `src/ratelimit.ts` | `RateLimiter` anti-ban en memoria con ventana deslizante (3 políticas). Desacoplado del CDP para poder probarse sin WhatsApp; cubierto por tests unitarios (`npm test`, `test/ratelimit.test.ts`). |
| **Server** | `src/server.ts` | Registra las 10 tools MCP, traduce errores a resultados estructurados `{ ok:false, error, message, retryAfterMs?, detail? }` y gestiona el ciclo de vida (conexión on-demand, cierre limpio en SIGINT/SIGTERM). |
Flujo de una llamada típica: el server recibe `tools/call` por stdio → la tool correspondiente conecta (si no está cacheada) al target de WhatsApp vía CDP → evalúa JavaScript en la página → devuelve el resultado estructurado al cliente.
## Requisitos
- **Node.js >= 23** (se usa el type stripping nativo de TypeScript, sin build step; habilitado por defecto desde Node 23.6 — se probó con Node 24).
- **WhatsApp Desktop** instalado (ver **Compatibilidad**).
- Cliente MCP (p. ej. opencode) para consumir las tools.
## Compatibilidad (¿cuál WhatsApp funciona?)
El server **no usa una API no oficial**: controla el DOM de `web.whatsapp.com` que renderiza la app de escritorio de WhatsApp. Por lo tanto, lo que necesita es una app de escritorio **basada en Electron** que muestre la web de WhatsApp.
- ✅ **Probado y verificado en vivo**: paquete **AUR [`whatsapp-linux-desktop-bin`](https://aur.archlinux.org/packages/whatsapp-linux-desktop-bin)** (la app no oficial de WhatsApp para Linux), versión **1.0.1-1**, con binario en `/opt/WhatsApp Desktop/whatsapp-linux-desktop`. Es la que el wrapper `scripts/launch-whatsapp.sh` lanza por defecto.
- Los selectores del DOM (`src/whatsapp/dom.ts`) y los mecanismos CDP fueron verificados contra el build 2026 de esa app (Electron 32). Si WhatsApp actualiza su web y cambia el DOM, hay que actualizar los selectores (ver **Notas / limitaciones**).
- ⚠️ **Puede funcionar con otras** apps de escritorio de WhatsApp (WebCord, Ferdium, etc.) siempre que: (1) sean Electron y expongan `--remote-debugging-port`, y (2) rendericen `web.whatsapp.com` con el mismo DOM. **No están soportadas ni probadas**: ajusta `BIN` en `scripts/launch-whatsapp.sh` y verifica los selectores antes de usarlas.
- ❌ **No funciona** con WhatsApp Web en un navegador normal (necesitas el flag de CDP de un runtime controlable) ni con el cliente móvil.
Para saber qué tienes instalado:
```bash
pacman -Q | grep -i whatsapp # paquete + versión (p. ej. whatsapp-linux-desktop-bin 1.0.1-1)
ls /opt/ | grep -i whatsapp # binario (p. ej. "WhatsApp Desktop")
```
## Instalación
```bash
npm install
```
No hay step de compilación: `node src/server.ts` ejecuta el TypeScript directamente.
## Configuración
Todas las variables son opcionales y se leen del entorno con prefijo `WA_MCP_`. Ver `.env.example`.
| Variable | Default | Descripción |
|---|---|---|
| `WA_MCP_CDP_PORT` | `9222` | Puerto TCP donde Chrome/Electron expone el endpoint CDP. |
| `WA_MCP_CDP_HOST` | `127.0.0.1` | Interfaz donde escucha el endpoint CDP. |
| `WA_MCP_MEDIA_DIR` | `/tmp/opencode` | Directorio donde se escriben los medios descargados (imágenes, documentos, etc.). |
| `WA_MCP_RATE_MIN_INTERVAL_MS` | `3000` | Delay mínimo entre dos `send_message` cualquiera (global). |
| `WA_MCP_RATE_COOLDOWN_CHAT_MS` | `15000` | Delay mínimo entre dos mensajes al mismo chat. |
| `WA_MCP_RATE_MAX_PER_MINUTE` | `10` | Tope de mensajes por ventana deslizante de 60s (todos los chats). |
| `WA_MCP_TYPING_ENABLED` | `true` | Toggle del typing simulado. |
| `WA_MCP_TYPING_MIN_DELAY_MS` | `40` | Delay mínimo entre caracteres al teclear. |
| `WA_MCP_TYPING_MAX_DELAY_MS` | `120` | Delay máximo entre caracteres al teclear. |
| `WA_MCP_TYPING_PUNCTUATION_PAUSE_MS` | `350` | Pausa extra tras puntuación (`. , ; : ! ?` y salto de línea), con jitter 60–140%. |
| `WA_MCP_TYPING_THINK_BEFORE_SEND_MS` | `600` | Delay aleatorio (jitter 50–150%) entre terminar de teclear y pulsar enviar. |
| `WA_MCP_TYPING_MAX_MESSAGE_CHARS` | `400` | Mensajes más largos que esto omiten el typing simulado (inserción directa). |
## Uso con WhatsApp
### Lanzar WhatsApp con CDP
El server **solo puede controlar WhatsApp si la app corre con el flag de debugging de CDP**. El wrapper `scripts/launch-whatsapp.sh` lo garantiza de forma idempotente:
```bash
scripts/launch-whatsapp.sh # lanza (o reutiliza) WhatsApp con CDP en 127.0.0.1:9222
scripts/launch-whatsapp.sh --check # dry-run: solo informa qué haría, sin tocar nada
```
Tres casos que maneja el wrapper:
1. **Ya corriendo con el flag** (`--remote-debugging-port=9222`) → no hace nada; solo verifica que el endpoint CDP responda.
2. **Corriendo sin el flag** → termina esa instancia (SIGTERM → SIGKILL si es necesario), espera a que liberen los procesos hijos y relanza con el flag.
3. **No corriendo** → lo lanza directamente con el flag.
El wrapper usa `--no-sandbox` (la app no tiene `chrome-sandbox` setuid-root) y comprueba el endpoint CDP durante 15s tras lanzar. El login/sesión persiste en el `user-data-dir`, así que relanzar es seguro.
> **IMPORTANTE**: el server solo funciona con WhatsApp abierto y con la sesión iniciada. Si WhatsApp no está corriendo, las tools no crashean: devuelven un error estructurado accionable (`whatsapp_not_running`) indicando cómo lanzarlo.
### Override del .desktop de usuario
Para que WhatsApp siempre arranque con CDP (aunque se lance desde el menú, no solo desde el wrapper), el lanzador de aplicaciones del usuario (`~/.local/share/applications/whatsapp-linux-desktop.desktop`) apunta al wrapper:
```ini
Exec=/home/junior/Projects/whatsapp-mcp/scripts/launch-whatsapp.sh %U
```
El archivo fuente está en `desktop/whatsapp-linux-desktop.desktop`. Así cualquier apertura de WhatsApp pasa por el wrapper y garantiza el puerto CDP.
## Integración con opencode
Registra el server como MCP **local** (stdio) en `~/.config/opencode/opencode.json`:
```json
{
"mcp": {
"whatsapp": {
"type": "local",
"command": ["node", "/home/junior/Projects/whatsapp-mcp/src/server.ts"],
"enabled": true
}
}
}
```
Después de editar el archivo hay que **reiniciar opencode**. Y ojo: el server MCP es un proceso stdio long-running, así que **cualquier cambio bajo `src/*.ts`** (no solo la config) tampoco se recarga en vivo — `npm run verify` y los tests spawnean instancias frescas y pasan con el código nuevo, pero el cliente opencode en ejecución conserva el código viejo hasta el reinicio. Para verificar que quedó registrado, revisa que las tools `whatsapp_status`, `list_chats`, `read_messages`, `get_unread`, `search_contacts`, `read_draft`, `clear_draft`, `send_message`, `take_screenshot` y `download_media` estén disponibles para el agente.
> **Leer adjuntos de WhatsApp desde opencode**: `read_messages` reporta para cada mensaje su `id` y `type`. Para leer el contenido de un adjunto, llama a `download_media` con ese `id` (la media se escribe en `/tmp/opencode`, o el directorio de `WA_MCP_MEDIA_DIR`) y luego lee el `path` devuelto con las herramientas de archivo de opencode (o el agente `file-analyser`) para analizar la imagen, PDF, documento, etc.
> Nota: `send_message` envía mensajes reales. Configura los permisos de opencode (o el flujo de aprobación de tools) si quieres que cada envío requiera confirmación.
## Tools MCP
| Tool | Argumentos | Descripción |
|---|---|---|
| `whatsapp_status` | — | Estado de WhatsApp: `running`, `targetUrl`, `loggedIn` y mensaje accionable. Nunca falla (es el health check). |
| `list_chats` | `limit` (default 20) | Lista los chats renderizados en el panel (nombre, último mensaje, hora, no-leídos). |
| `read_messages` | `chat` (obligatorio), `limit` (default 20), `beforeTs` (opcional, unix en segundos), `older` (opcional, tope de seguridad) | Abre el chat indicado y lee sus últimos mensajes (autor, texto, hora/fecha, dirección, tipo). Pasando `beforeTs` y/u `older` activa la paginación hacia atrás y la respuesta añade `meta`. |
> **Leer mensajes más antiguos (paginación hacia atrás)**: la tool `read_messages` acepta `beforeTs` y `older` (ambos activan la paginación hacia atrás; el handler los cablea a `readMessagesOlder(chat, { beforeTs?, older?, limit? })` en `src/whatsapp/messages.ts`, con helpers puros en `src/whatsapp/pagination.ts`). Semántica: `beforeTs` = timestamp unix en **segundos** (el loop scrollea hacia arriba hasta que el mensaje más antiguo escaneado tiene `ts <= beforeTs`); `older` = tope de seguridad de mensajes únicos escaneados (null = sin tope); `limit` = recorte final del resultado. Con la paginación activa responde `{ ok, chat, count, messages, meta }` con `meta: { scannedCount, reachedTop, stoppedBy, beforeTs, older }`, donde `stoppedBy` es `'date'` (llegó a `beforeTs`), `'older'` (alcanzó el tope de seguridad), `'top'` (se alcanzó el **inicio real** del historial: tras el trigger de scroll completo no se montó nada nuevo y el scroller quedó arriba) o `'no-progress'` (parada de seguridad: un round no montó mensajes nuevos pero el scroll aún podía avanzar, o se agotó el límite interno de iteraciones). Sin `beforeTs`/`older` la respuesta es `{ ok, chat, count, messages }` (sin `meta`), contrato legacy idéntico. Los mensajes devueltos se ordenan cronológicamente (más antiguo primero) de forma determinista — el orden del DOM del virtualizer no es fiable. Nota: el chat se deja donde quedó el scroll (no se restaura la posición). ⚠️ **Rendimiento**: retroceder hasta `beforeTs` puede tardar — cada round espera a que WhatsApp renderice el chunk, y los scrolls que se atascan se desbloquean con una receta física de wheel; para historiales de años es normal que la llamada tarde decenas de segundos o minutos.
| `get_unread` | — | Chats con al menos un mensaje sin leer (nombre, último mensaje, contador). |
| `search_contacts` | `query` (obligatorio) | Busca chats usando el buscador real de WhatsApp (no un filtro local). |
| `read_draft` | `chat` (obligatorio) | Lee el draft (texto a medio escribir) del input del chat. Devuelve `draft` (texto) o `null` si el input está vacío. Solo lectura: no modifica nada. |
| `clear_draft` | `chat` (obligatorio) | Limpia el draft del input. **DESTRUCTIVO**: elimina el texto sin enviar del usuario; devuelve el texto eliminado (`previousDraft`). Usar solo con aprobación explícita. |
| `send_message` | `chat`, `text` (obligatorios), `clearDraft` (opcional, default false) | Envía un mensaje con typing simulado y pasando por el rate limiter. Si el chat tiene un draft, **aborta** con `draft_conflict` (sin tocar el draft) salvo que se pase `clearDraft: true`. Devuelve `delivered`, `sentAt`, `chatId`, `preview`. |
| `download_media` | `chat`, `messageId` (obligatorios), `destDir` (opcional) | Descarga la media (imagen, video, audio) y los **documentos PDF** del mensaje `messageId` (el `data-id` que reporta `read_messages`) y la escribe en `destDir` (default `WA_MCP_MEDIA_DIR`). **Solo lectura**: no envía mensajes ni pasa por el rate limiter. Los PDF se obtienen a través del visor interno de WhatsApp: se abre con un click físico (auto-attach con `waitForDebuggerOnStart:false`, el OOPIF se matchea por `type==='iframe'` o su URL `pdf-viewer`), se captura el archivo y se cierra con Escape **guardado** (solo se dispara con el visor abierto — escapar sobre la página principal deseleccionaría el chat); los documentos que no son PDF no se soportan (`media_unsupported`). Devuelve `path` absoluto, `filename`, `mimeType`, `sizeBytes`, `mediaType`. Requiere que el chat esté abierto y el mensaje renderizado (la media puede no estar cargada si salió del viewport). |
| `take_screenshot` | — | Captura PNG de la ventana de WhatsApp vía CDP y la devuelve como data URL base64. |
Errores: todas las tools (salvo `whatsapp_status`) devuelven `{ ok:false, error, message, ... }` con `isError: true` ante fallos, con claves estables (`whatsapp_not_running`, `not_logged_in`, `chat_not_found`, `rate_limited` con `retryAfterMs`, `draft_conflict`, `send_not_confirmed`, `message_not_found`, `media_unsupported`, `media_not_loaded`, `cdp_error`, `unexpected`).
## Seguridad anti-ban
- **Rate limiting** (`src/ratelimit.ts`): ventana deslizante de 60s con tres políticas evaluadas en orden antes de tocar el DOM — intervalo mínimo global (`minIntervalMs`), cooldown por chat (`cooldownPerChatMs`) y tope por minuto (`maxPerMinute`). Un envío bloqueado devuelve `rate_limited` con `retryAfterMs`. Nada se envía sin pasar por `checkSend`.
- **Typing simulado**: el texto se ingresa carácter a carácter con delays aleatorios configurables y pausas tras puntuación, más un delay de "pensar" antes de pulsar enviar. El indicador "escribiendo…" aparece para el receptor. Se omite (inserción directa, con `console.warn`) cuando el mensaje supera `WA_MCP_TYPING_MAX_MESSAGE_CHARS` o el toggle está apagado.
- **Manejo de drafts (texto sin enviar)**: si el chat tiene un draft en el input, `send_message` **aborta** con `draft_conflict` (incluye el texto del draft en el mensaje) en lugar de borrarlo o concatenarlo con el mensaje. El draft **nunca se sobreescribe sin consentimiento**: solo se limpia con `clear_draft` o pasando `clearDraft: true` explícitamente a `send_message`.
- **Sin broadcasts ni reenvíos automáticos**: no hay código que haga envíos masivos.
- **Confirmación de permisos**: `send_message` es una tool como las demás; puede exigirse aprobación manual desde el cliente MCP (permissions de opencode).
## Validación
```bash
npm run typecheck # tsc --noEmit (validación de tipos)
npm test # node:test (183 tests: RateLimiter, confirmación de envío, open-chat, media, PDF + guard de closeViewer, sesiones CDP, parseo de fechas MM/DD (parsePrePlainText), paginación — stableMessageKey/mergeMessagesByStableKey/decideStop/sortMessagesByTs — y loop de readMessagesOlder — scrollProbeMoved/roundsReachedTop)
node scripts/verify.mjs # batería completa de verificación
npm run verify # alias del anterior
```
`scripts/verify.mjs` ejecuta y reporta **PASS/FAIL/SKIP** por sección:
- **ENV**: versión de Node (>= 23) y alcance del endpoint CDP.
- **TYPECHECK**: `npx tsc --noEmit`.
- **MCP**: spawn del server, handshake (`initialize` + `notifications/initialized`), `tools/list` (las 10 tools), `whatsapp_status`, `list_chats`, `get_unread`, `take_screenshot` y cierre limpio con SIGTERM. `send_message` **nunca** se invoca (solo se comprueba que esté registrada); lo mismo para `download_media` (presencia en `tools/list` es suficiente).
Exit code `0` si no hay FAILs, `1` si algo falla. Si WhatsApp no está corriendo, los checks dependientes de CDP se reportan como **SKIP** (no FAIL) con un mensaje claro:
```bash
WA_MCP_CDP_PORT=9299 node scripts/verify.mjs # simula "WhatsApp caído"
```
## Troubleshooting
### "Sesión nueva / QR al lanzar"
WhatsApp Desktop **no tiene single-instance lock**: si arranca una segunda instancia (p. ej. la abres del menú estando ya abierta, o el wrapper relanza), dos procesos compiten por los mismos LevelDB y la segunda cae a un **estado vacío** (pantalla de QR/sesión nueva).
**Solución**: cerrar WhatsApp por completo y lanzarlo **solo** con el wrapper (una única instancia con el flag CDP):
```bash
scripts/launch-whatsapp.sh
```
El wrapper ya contempla el caso b (instancia sin flag → la termina y relanza). Evita abrir WhatsApp de cualquier otra forma mientras uses este server.
### "Puerto 9222 no responde"
Las tools devuelven `whatsapp_not_running`. Causa casi siempre: WhatsApp no está corriendo con el flag de CDP. Lánzalo con el wrapper y verifica:
```bash
scripts/launch-whatsapp.sh
curl http://127.0.0.1:9222/json/version # debe responder con el "Browser" de WhatsApp
```
### Errores comunes de las tools
| Error | Significado / solución |
|---|---|
| `whatsapp_not_running` | WhatsApp no está con CDP. Lanza con `scripts/launch-whatsapp.sh`. |
| `not_logged_in` | WhatsApp abierto pero en pantalla de QR/login. Completa el login en la ventana y reintenta. |
| `chat_not_found` | El nombre no coincide con el del chat list. Usa `list_chats` para ver los nombres exactos (incluyen emojis). Ten en cuenta que los emojis del nombre pueden CAMBIAR con el tiempo — matchea por el nombre base; `list_chats`/`search_contacts` devuelven el render actual. |
| `rate_limited` | Envío bloqueado por el rate limiter; respeta `retryAfterMs`. |
| `draft_conflict` | El chat tiene un draft (texto sin enviar) en el input. Usa `read_draft` para verlo, `clear_draft` o `send_message` con `clearDraft: true` para sobreescribirlo. |
| `input_not_found` / `send_not_confirmed` | El chat no cargó o el mensaje no se confirmó dentro del timeout. Reintenta. |
| `message_not_found` | `download_media`: el `messageId` no está en el DOM — el mensaje salió del viewport o el id es incorrecto. Re-pasa `read_messages` o scrollea el mensaje a la vista y reintenta. (El bug de Escape prematuro que deseleccionaba el chat ya está corregido: el visor solo se cierra con Escape cuando está abierto.) |
| `media_not_loaded` | `download_media`: se detectó media pero el blob no está disponible (blob URL revocado por virtualización) o el fetch falló. Para un documento, significa que el visor PDF no entregó el archivo (reintenta; el visor usa auto-attach CDP). Reintenta con el chat abierto y el mensaje en el viewport. |
| `media_unsupported` | `download_media`: el mensaje no tiene media descargable (texto/unknown, o un documento que no es PDF — este build solo descarga PDF a través del visor interno; los demás no exponen URL del archivo en el DOM). |
| `cdp_error` / `unexpected` | Problema de conexión o error inesperado; revisa los logs y verifica que WhatsApp sigue corriendo. |
## Notas / limitaciones
- **Selectores del DOM**: las tools dependen de la estructura del DOM de WhatsApp Web, que cambia con las actualizaciones. Los selectores actuales fueron verificados contra el build 2026 (Electron 32); si WhatsApp cambia su DOM, habrá que actualizar `src/whatsapp/dom.ts`, `src/whatsapp/send.ts`, `src/whatsapp/media.ts` y `src/whatsapp/pdf.ts`.
- **Descarga de media**: `download_media` necesita que el mensaje esté renderizado en el DOM (WhatsApp virtualiza `#main`). El blob URL de la media se revoca si el mensaje sale del viewport, así que extrae con el chat abierto y, si falla con `media_not_loaded`/`message_not_found`, scrollea el mensaje a la vista y reintenta. Los documentos **no exponen URL del archivo en el DOM** en este build; los **PDF** se descargan abriendo el visor interno de WhatsApp (OOPIF `webtp.whatsapp.net/pdf-viewer/`): el OOPIF se captura por `type === 'iframe'` (su URL real llega después vía `targetInfoChanged`), se captura el Blob que llega por `RENDER_PDF_PREVIEW` y se cierra el visor con Escape **guardado por sesión** (solo si sigue adjunta la sesión del visor abierto por esa invocación; nunca un iframe vacío ni la página principal — escapar sin visor deseleccionaría el chat). Antes de delegar al visor, `downloadMedia` re-verifica que el header de `#main` siga matcheando el chat (el fetch puede tardar 60s y el usuario pudo cambiar de chat). Los documentos que no son PDF no son descargables (`media_unsupported`).
- **Identificador de chat**: la clave es el **nombre** del chat tal como aparece en el chat list (el DOM no expone un JID estable para todas las operaciones). `send_message` intenta leer el JID del header cuando está disponible, pero la identificación sigue siendo por nombre. Las variantes de emoji del mismo nombre se tratan como el mismo chat: la verificación del header (`headerMatchesName`, emoji-strip + case-insensitive) hace que un nombre cuya emoji difiera del header abra correctamente el chat.
- **Dedupe estable en paginación**: WhatsApp virtualiza `#main` y **recicla los nodos `data-id`** (el mismo id se reasigna a mensajes distintos al scrollear). Por eso el paginador hacia atrás deduplica por una clave estable del contenido real del mensaje (`stableMessageKey`: autor + fecha/hora + dirección + tipo + texto), nunca por `data-id`. El `data-id` sigue siendo la referencia correcta para `download_media` (un mensaje puntual ya renderizado).
- **Typing simulado omitido en mensajes largos**: por encima de `WA_MCP_TYPING_MAX_MESSAGE_CHARS` el texto se inserta de golpe (con aviso en stderr), para no bloquear el envío con un tecleo interminable.
- **Conexión on-demand**: el server arranca sin tocar CDP y cada tool conecta/reutiliza la conexión WebSocket cacheada; se re-conecta si WhatsApp se relanza.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues