feat: add WhatsApp integration plugin with Baileys bridge and QR pairing

This commit is contained in:
linuztx 2026-03-26 00:29:04 +08:00
parent b391d98460
commit 84c12b0c23
26 changed files with 4216 additions and 0 deletions

View file

@ -0,0 +1,49 @@
# WhatsApp Integration Plugin
Communicate with Agent Zero via WhatsApp using a Baileys-based Node.js bridge.
## Requirements
- **Node.js** (v18+) installed on the system
- A WhatsApp account on a phone (for QR code pairing)
## Setup
1. Enable the plugin in Settings > External > WhatsApp Integration
2. Configure allowed phone numbers (optional)
3. Start Agent Zero — the bridge will auto-start and display a QR code in the logs
4. Scan the QR code with WhatsApp on your phone
5. Send a message from an allowed number to start a chat
The WhatsApp session persists across restarts in `usr/whatsapp/sessions/`. No re-pairing needed unless you manually unlink the device from WhatsApp.
## Configuration
| Setting | Description | Default |
|---------|-------------|---------|
| `enabled` | Enable bridge and polling | `false` |
| `bridge_port` | Local HTTP port for bridge | `3100` |
| `poll_interval_seconds` | Poll frequency (min 2) | `3` |
| `allowed_users` | Phone numbers without + prefix | `[]` (all) |
| `project` | Activate project for WA chats | `""` |
| `agent_instructions` | Extra agent instructions | `""` |
## How It Works
1. The bridge connects to WhatsApp via Baileys and exposes HTTP endpoints on localhost
2. The plugin polls the bridge for new messages every few seconds
3. Incoming messages are routed to existing chats by WhatsApp chat ID or new chats are created
4. Agent responses are sent back via the bridge as WhatsApp messages
5. Media (images, documents) is supported in both directions
## Architecture
```
WhatsApp Phone
↕ (WhatsApp protocol via Baileys)
whatsapp-bridge/bridge.js (Node.js subprocess)
↕ (HTTP API on localhost)
Python helpers (wa_client, handler, bridge_manager)
↕ (Framework extensions)
Agent Zero
```

View file

@ -0,0 +1,79 @@
"""WhatsApp QR code pairing endpoint."""
from helpers.api import ApiHandler, Request
from helpers.errors import format_error
from helpers import files, plugins
PLUGIN_NAME = "_whatsapp_integration"
class QrCode(ApiHandler):
async def process(self, input: dict, request: Request) -> dict:
config = plugins.get_plugin_config(PLUGIN_NAME) or {}
port = int(config.get("bridge_port", 3100))
session_dir = files.get_abs_path("usr/whatsapp/sessions")
cache_dir = files.get_abs_path("usr/whatsapp/media")
allowed_users = config.get("allowed_users") or []
from plugins._whatsapp_integration.helpers.bridge_manager import (
ensure_bridge_http_up,
get_bridge_url,
is_process_alive,
)
from plugins._whatsapp_integration.helpers.wa_client import get_qr
base_url = get_bridge_url(port)
# Start bridge if not running
if not is_process_alive():
try:
ok = await ensure_bridge_http_up(
port, session_dir, cache_dir, allowed_users,
)
if not ok:
return {
"status": "error",
"message": "Failed to start bridge",
"qr": None,
}
except Exception as e:
return {
"status": "error",
"message": format_error(e),
"qr": None,
}
# Fetch QR status from bridge
try:
data = await get_qr(base_url)
status = data.get("status", "error")
qr = data.get("qr")
if status == "connected":
return {
"status": "connected",
"message": "WhatsApp is already connected",
"qr": None,
}
if status == "waiting_scan" and qr:
return {
"status": "waiting_scan",
"message": "Scan the QR code with WhatsApp",
"qr": qr,
}
return {
"status": "waiting_qr",
"message": "Generating QR code...",
"qr": None,
}
except Exception as e:
return {
"status": "error",
"message": f"Bridge not reachable: {format_error(e)}",
"qr": None,
}

View file

@ -0,0 +1,50 @@
"""Test WhatsApp bridge connection health."""
from helpers.api import ApiHandler, Request
from helpers.errors import format_error
from helpers import plugins
PLUGIN_NAME = "_whatsapp_integration"
class TestConnection(ApiHandler):
async def process(self, input: dict, request: Request) -> dict:
config = input.get("config") or plugins.get_plugin_config(PLUGIN_NAME) or {}
port = int(config.get("bridge_port", 3100))
results: list[dict] = []
await self._test_bridge(port, results)
ok = all(r["ok"] for r in results)
return {"success": ok, "results": results}
async def _test_bridge(self, port: int, results: list[dict]) -> None:
from plugins._whatsapp_integration.helpers.wa_client import get_health
from plugins._whatsapp_integration.helpers.bridge_manager import get_bridge_url
try:
health = await get_health(get_bridge_url(port))
status = health.get("status", "unknown")
queue = health.get("queueLength", 0)
uptime = health.get("uptime", 0)
if status == "connected":
results.append({
"test": "Bridge",
"ok": True,
"message": f"Connected (uptime: {uptime:.0f}s, queue: {queue})",
})
else:
results.append({
"test": "Bridge",
"ok": False,
"message": f"Bridge running but status: {status}",
})
except Exception as e:
results.append({
"test": "Bridge",
"ok": False,
"message": f"Bridge not reachable: {format_error(e)}",
})

View file

@ -0,0 +1,7 @@
enabled: false
bridge_port: 3100
poll_interval_seconds: 3
allowed_users: []
# Example: ["34652029134", "1234567890"]
project: ""
agent_instructions: ""

View file

@ -0,0 +1,76 @@
"""WhatsApp poll loop — start bridge and poll for incoming messages."""
import asyncio
from typing import Any
from helpers.extension import Extension
from helpers.errors import format_error
from helpers.print_style import PrintStyle
from helpers import files, plugins
PLUGIN_NAME: str = "_whatsapp_integration"
DEFAULT_INTERVAL: int = 3
MIN_INTERVAL: int = 2
# ------------------------------------------------------------------
# Extension entry point
# ------------------------------------------------------------------
class WhatsAppAutoPoll(Extension):
async def execute(self, **kwargs: Any) -> None:
import plugins._whatsapp_integration.helpers.handler as handler_mod
config = plugins.get_plugin_config(PLUGIN_NAME) or {}
enabled = config.get("enabled", False)
if not enabled:
if handler_mod._poll_task and not handler_mod._poll_task.done():
handler_mod._poll_task.cancel()
handler_mod._poll_task = None
return
if not handler_mod._poll_task or handler_mod._poll_task.done():
handler_mod._poll_task = asyncio.create_task(_poll_loop())
# ------------------------------------------------------------------
# Poll loop
# ------------------------------------------------------------------
async def _poll_loop() -> None:
from plugins._whatsapp_integration.helpers import bridge_manager
from plugins._whatsapp_integration.helpers.handler import poll_messages
bridge_started = False
while True:
config = plugins.get_plugin_config(PLUGIN_NAME) or {}
if not config.get("enabled", False):
break
port = int(config.get("bridge_port", 3100))
session_dir = files.get_abs_path("usr/whatsapp/sessions")
cache_dir = files.get_abs_path("usr/whatsapp/media")
allowed_users = config.get("allowed_users") or []
# Start bridge if needed
if not bridge_started or not bridge_manager.is_process_alive():
try:
bridge_started = await bridge_manager.start_bridge(
port, session_dir, cache_dir, allowed_users,
)
except Exception as e:
PrintStyle.error(f"WhatsApp bridge start error: {format_error(e)}")
await asyncio.sleep(10)
continue
try:
await poll_messages(config)
except Exception as e:
PrintStyle.error(f"WhatsApp poll error: {format_error(e)}")
sleep_sec = max(config.get("poll_interval_seconds", DEFAULT_INTERVAL), MIN_INTERVAL)
await asyncio.sleep(sleep_sec)

View file

@ -0,0 +1,78 @@
"""Auto-send WhatsApp reply when agent responds in a WhatsApp session."""
import asyncio
from helpers.extension import Extension
from helpers.print_style import PrintStyle
from agent import AgentContext, LoopData, UserMessage
from plugins._whatsapp_integration.helpers.handler import CTX_WA_CHAT_ID, CTX_WA_ATTACHMENTS
MAX_SEND_RETRIES: int = 2
CTX_SEND_FAILURES: str = "_wa_send_failures"
class WhatsAppAutoReply(Extension):
async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
if not self.agent or self.agent.number != 0:
return
context = self.agent.context
if not context.data.get(CTX_WA_CHAT_ID):
return
response_text = _extract_last_response(context)
if not response_text:
return
attachments = context.data.pop(CTX_WA_ATTACHMENTS, [])
if attachments:
PrintStyle.info(f"WhatsApp: sending reply with {len(attachments)} attachment(s)")
asyncio.create_task(self._send_reply(context, response_text, attachments))
async def _send_reply(
self, context: AgentContext, response_text: str, attachments: list[str],
):
from plugins._whatsapp_integration.helpers.handler import send_wa_reply
error = await send_wa_reply(context, response_text, attachments)
if not error:
context.data[CTX_SEND_FAILURES] = 0
return
failures = context.data.get(CTX_SEND_FAILURES, 0) + 1
context.data[CTX_SEND_FAILURES] = failures
if failures <= MAX_SEND_RETRIES:
_notify_agent_of_failure(context, error, failures)
else:
PrintStyle.error(
f"WhatsApp send failed {failures} times, giving up: {error}"
)
context.log.log(
type="error",
heading="WhatsApp send failed (max retries reached)",
content=error,
)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _extract_last_response(context: AgentContext) -> str:
with context.log._lock:
logs = list(context.log.logs)
if not logs:
return ""
for item in reversed(logs):
if item.type == "response":
return item.content or ""
return ""
def _notify_agent_of_failure(
context: AgentContext, error: str, attempt: int,
):
msg = context.agent0.read_prompt(
"fw.wa.send_failed.md", error=error, attempt=str(attempt),
max_retries=str(MAX_SEND_RETRIES),
)
context.log.log(type="error", heading="WhatsApp send failed", content=error)
context.communicate(UserMessage(message="", system_message=[msg]))

View file

@ -0,0 +1,22 @@
"""Inject WhatsApp conversation context into system prompt."""
from helpers.extension import Extension
from agent import LoopData
from plugins._whatsapp_integration.helpers.handler import CTX_WA_CHAT_ID
class WhatsAppContextPrompt(Extension):
async def execute(
self,
system_prompt: list[str] = [],
loop_data: LoopData = LoopData(),
**kwargs,
):
if not self.agent:
return
if self.agent.context.data.get(CTX_WA_CHAT_ID):
system_prompt.append(
self.agent.read_prompt("fw.wa.system_context_reply.md")
)

View file

@ -0,0 +1,60 @@
"""Intercept response tool in WhatsApp sessions for attachments and break_loop."""
from helpers.extension import Extension
from helpers.print_style import PrintStyle
from helpers.tool import Response
from plugins._whatsapp_integration.helpers.handler import CTX_WA_CHAT_ID, CTX_WA_ATTACHMENTS
class WhatsAppResponseIntercept(Extension):
async def execute(
self, tool_name: str = "", response: Response | None = None, **kwargs,
):
if tool_name != "response":
return
if not self.agent:
return
context = self.agent.context
if not context.data.get(CTX_WA_CHAT_ID):
return
tool = self.agent.loop_data.current_tool
if not tool:
return
# Capture attachments for later (process_chain_end) or inline send
attachments = tool.args.get("attachments", [])
if attachments:
context.data[CTX_WA_ATTACHMENTS] = attachments
# Check break_loop arg from agent
agent_break = tool.args.get("break_loop", True)
if agent_break is False and response:
await self._send_inline(context, tool, response)
async def _send_inline(
self, context, tool, response: Response,
):
from plugins._whatsapp_integration.helpers.handler import send_wa_reply
agent = self.agent
assert agent is not None
text = tool.args.get("text", tool.args.get("message", ""))
attachments = context.data.pop(CTX_WA_ATTACHMENTS, [])
if attachments:
PrintStyle.info(f"WhatsApp: sending update with {len(attachments)} attachment(s)")
error = await send_wa_reply(context, text, attachments or None)
if error:
result = agent.read_prompt("fw.wa.update_error.md", error=error)
else:
result = agent.read_prompt("fw.wa.update_ok.md")
# Override response: don't break loop, add result to history
response.break_loop = False
response.message = result
agent.hist_add_tool_result("response", result)

View file

@ -0,0 +1,196 @@
"""
WhatsApp bridge subprocess manager.
No agent/tool dependencies.
"""
import asyncio
import os
import subprocess
import threading
from pathlib import Path
from helpers.print_style import PrintStyle
_bridge_process: subprocess.Popen | None = None
_bridge_lock = asyncio.Lock()
BRIDGE_DIR = str(Path(__file__).parent.parent / "whatsapp-bridge")
BRIDGE_SCRIPT = os.path.join(BRIDGE_DIR, "bridge.js")
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
async def start_bridge(
port: int,
session_dir: str,
cache_dir: str,
allowed_users: list[str] | None = None,
) -> bool:
global _bridge_process
async with _bridge_lock:
# If process is alive, keep it (even if WA not yet connected — may be pairing)
if _bridge_process and _bridge_process.poll() is None:
return True
await _ensure_npm_install()
cmd = [
"node", BRIDGE_SCRIPT,
"--port", str(port),
"--session", session_dir,
"--cache-dir", cache_dir,
]
if allowed_users:
cmd += ["--allowed-users", ",".join(allowed_users)]
PrintStyle.info("WhatsApp: starting bridge")
_bridge_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=BRIDGE_DIR,
)
_start_log_reader(_bridge_process)
# Wait for bridge to become healthy
for _ in range(20):
await asyncio.sleep(0.5)
if _bridge_process.poll() is not None:
PrintStyle.error("WhatsApp: bridge process exited unexpectedly")
return False
if await _check_health(port):
return True
PrintStyle.warning("WhatsApp: bridge started but not yet connected")
return True
async def stop_bridge() -> None:
global _bridge_process
async with _bridge_lock:
if not _bridge_process:
return
try:
_bridge_process.terminate()
try:
_bridge_process.wait(timeout=5)
except subprocess.TimeoutExpired:
_bridge_process.kill()
except Exception:
pass
_bridge_process = None
PrintStyle.info("WhatsApp: bridge stopped")
async def is_bridge_running(port: int) -> bool:
if not _bridge_process or _bridge_process.poll() is not None:
return False
return await _check_health(port)
def get_bridge_url(port: int) -> str:
return f"http://127.0.0.1:{port}"
async def ensure_bridge_http_up(
port: int,
session_dir: str,
cache_dir: str,
allowed_users: list[str] | None = None,
) -> bool:
"""Start bridge if needed and wait for HTTP server only (not WA connection)."""
global _bridge_process
async with _bridge_lock:
if _bridge_process and _bridge_process.poll() is None:
if await _check_http_up(port):
return True
await _ensure_npm_install()
cmd = [
"node", BRIDGE_SCRIPT,
"--port", str(port),
"--session", session_dir,
"--cache-dir", cache_dir,
]
if allowed_users:
cmd += ["--allowed-users", ",".join(allowed_users)]
PrintStyle.info("WhatsApp: starting bridge for pairing")
_bridge_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=BRIDGE_DIR,
)
_start_log_reader(_bridge_process)
for _ in range(20):
await asyncio.sleep(0.5)
if _bridge_process.poll() is not None:
PrintStyle.error("WhatsApp: bridge process exited unexpectedly")
return False
if await _check_http_up(port):
return True
return False
def is_process_alive() -> bool:
return _bridge_process is not None and _bridge_process.poll() is None
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
async def _check_health(port: int) -> bool:
try:
from plugins._whatsapp_integration.helpers.wa_client import get_health
health = await get_health(get_bridge_url(port))
return health.get("status") == "connected"
except Exception:
return False
async def _check_http_up(port: int) -> bool:
try:
from plugins._whatsapp_integration.helpers.wa_client import get_health
await get_health(get_bridge_url(port))
return True
except Exception:
return False
async def _ensure_npm_install() -> None:
node_modules = os.path.join(BRIDGE_DIR, "node_modules")
if os.path.isdir(node_modules):
return
PrintStyle.info("WhatsApp: installing bridge dependencies")
proc = await asyncio.create_subprocess_exec(
"npm", "install", "--production",
cwd=BRIDGE_DIR,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
stdout, _ = await proc.communicate()
if proc.returncode != 0:
PrintStyle.error(f"WhatsApp: npm install failed: {stdout.decode()}")
raise RuntimeError("npm install failed")
def _start_log_reader(process: subprocess.Popen) -> None:
def _reader():
assert process.stdout
for line in iter(process.stdout.readline, b""):
text = line.decode("utf-8", errors="replace").rstrip()
if text:
PrintStyle.standard(f"WhatsApp bridge: {text}")
process.stdout.close()
thread = threading.Thread(target=_reader, daemon=True)
thread.start()

View file

@ -0,0 +1,230 @@
"""
WhatsApp handler — orchestrates poll, dispatch, and reply.
Requires agent context.
"""
import asyncio
import uuid
from agent import Agent, AgentContext, UserMessage
from helpers import plugins
from helpers import message_queue as mq
from helpers.persist_chat import save_tmp_chat
from helpers.print_style import PrintStyle
from helpers.errors import format_error
from initialize import initialize_agent
from plugins._whatsapp_integration.helpers import wa_client
from plugins._whatsapp_integration.helpers import bridge_manager
PLUGIN_NAME = "_whatsapp_integration"
MEDIA_FOLDER = "usr/whatsapp/media"
# Context data keys (no underscore prefix — must persist across restarts)
CTX_WA_CHAT_ID = "wa_chat_id"
CTX_WA_SENDER_NAME = "wa_sender_name"
CTX_WA_SENDER_NUMBER = "wa_sender_number"
CTX_WA_IS_GROUP = "wa_is_group"
CTX_WA_LAST_BODY = "wa_last_body"
# Transient — consumed per-reply, not persisted
CTX_WA_ATTACHMENTS = "_wa_response_attachments"
# Poll task — lives here (not in extension module) because
# extension modules are re-executed on each job_loop tick,
# which would reset module-level state and orphan running tasks.
_poll_task: asyncio.Task | None = None # type: ignore[type-arg]
# ------------------------------------------------------------------
# Poll loop
# ------------------------------------------------------------------
async def poll_messages(config: dict) -> None:
port = int(config.get("bridge_port", 3100))
base_url = bridge_manager.get_bridge_url(port)
try:
messages = await wa_client.get_messages(base_url)
except Exception as e:
PrintStyle.error(f"WhatsApp poll error: {format_error(e)}")
return
if not messages:
return
for msg in messages:
try:
await _dispatch_message(config, msg)
except Exception as e:
PrintStyle.error(f"WhatsApp dispatch error: {format_error(e)}")
# ------------------------------------------------------------------
# Dispatch a single inbound message
# ------------------------------------------------------------------
async def _dispatch_message(config: dict, msg: dict) -> None:
chat_id = msg.get("chatId", "")
existing = _find_chats_by_jid(chat_id)
if existing:
# Continue most recent chat for this JID
await _route_to_chat(config, msg, existing[0])
else:
await _start_new_chat(config, msg)
# ------------------------------------------------------------------
# Chat creation and routing
# ------------------------------------------------------------------
async def _start_new_chat(config: dict, msg: dict) -> None:
from helpers import projects
sender_name = msg.get("senderName", "Unknown")
sender_number = msg.get("senderId", "").replace("@s.whatsapp.net", "").replace("@lid", "")
chat_id = msg.get("chatId", "")
is_group = msg.get("isGroup", False)
agent_config = initialize_agent()
context = AgentContext(agent_config, name=f"WhatsApp: {sender_name[:50]}")
context.data[CTX_WA_CHAT_ID] = chat_id
context.data[CTX_WA_SENDER_NAME] = sender_name
context.data[CTX_WA_SENDER_NUMBER] = sender_number
context.data[CTX_WA_IS_GROUP] = is_group
context.data[CTX_WA_LAST_BODY] = msg.get("body", "")
project = config.get("project", "")
if project:
projects.activate_project(context.id, project)
save_tmp_chat(context)
user_msg = _build_user_message(context.agent0, msg, config)
system_ctx = context.agent0.read_prompt("fw.wa.system_context.md")
msg_id = str(uuid.uuid4())
attachments = msg.get("mediaUrls", [])
mq.log_user_message(
context, user_msg, attachments, message_id=msg_id, source=" (whatsapp)",
)
context.communicate(UserMessage(
message=user_msg,
system_message=[system_ctx],
attachments=attachments,
id=msg_id,
))
PrintStyle.success(
f"WhatsApp: new chat {context.id} for {sender_name} ({sender_number})"
)
async def _route_to_chat(
config: dict, msg: dict, context_id: str,
) -> None:
context = AgentContext.get(context_id)
if not context:
return
context.data[CTX_WA_LAST_BODY] = msg.get("body", "")
user_msg = _build_user_message(context.agent0, msg, config)
msg_id = str(uuid.uuid4())
attachments = msg.get("mediaUrls", [])
mq.log_user_message(
context, user_msg, attachments, message_id=msg_id, source=" (whatsapp)",
)
context.communicate(UserMessage(
message=user_msg,
attachments=attachments,
id=msg_id,
))
save_tmp_chat(context)
PrintStyle.info(f"WhatsApp: continuing chat {context_id}")
# ------------------------------------------------------------------
# Chat discovery
# ------------------------------------------------------------------
def _find_chats_by_jid(chat_id: str) -> list[str]:
"""Return context IDs for chats matching the given WhatsApp JID, newest first."""
results = []
for ctx_id, ctx in AgentContext._contexts.items():
if not isinstance(ctx, AgentContext):
continue
if ctx.data.get(CTX_WA_CHAT_ID) != chat_id:
continue
results.append(ctx_id)
results.sort(reverse=True)
return results
# ------------------------------------------------------------------
# Message builders
# ------------------------------------------------------------------
def _build_user_message(agent: Agent, msg: dict, config: dict) -> str:
sender_name = msg.get("senderName", "Unknown")
sender_number = msg.get("senderId", "").replace("@s.whatsapp.net", "").replace("@lid", "")
text = agent.read_prompt(
"fw.wa.user_message.md",
sender_name=sender_name,
sender_number=sender_number,
body=msg.get("body", ""),
)
instructions = config.get("agent_instructions", "")
if instructions:
text += agent.read_prompt(
"fw.wa.user_message_instructions.md", instructions=instructions,
)
return text
# ------------------------------------------------------------------
# Reply sending (called from process_chain_end extension)
# ------------------------------------------------------------------
async def send_wa_reply(
context: AgentContext,
response_text: str,
attachments: list[str] | None = None,
) -> str | None:
chat_id = context.data.get(CTX_WA_CHAT_ID)
if not chat_id:
return "No WhatsApp chat ID"
config = plugins.get_plugin_config(PLUGIN_NAME) or {}
port = int(config.get("bridge_port", 3100))
base_url = bridge_manager.get_bridge_url(port)
# Typing indicator
await wa_client.send_typing(base_url, chat_id)
# Send text
try:
result = await wa_client.send_message(base_url, chat_id, response_text)
if result.get("error"):
return result["error"]
except Exception as e:
return str(e)
# Send attachments
if attachments:
for file_path in attachments:
try:
result = await wa_client.send_media(
base_url, chat_id, file_path,
)
if result.get("error"):
PrintStyle.warning(f"WhatsApp: attachment error: {result['error']}")
except Exception as e:
PrintStyle.warning(f"WhatsApp: attachment error: {e}")
return None

View file

@ -0,0 +1,97 @@
"""
WhatsApp bridge HTTP client.
No agent/tool dependencies.
"""
import aiohttp
async def get_messages(base_url: str) -> list[dict]:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{base_url}/messages", timeout=aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status == 200:
return await resp.json()
return []
async def send_message(
base_url: str, chat_id: str, message: str,
) -> dict:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{base_url}/send",
json={"chatId": chat_id, "message": message},
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
return await resp.json()
async def send_media(
base_url: str,
chat_id: str,
file_path: str,
caption: str = "",
media_type: str = "",
file_name: str = "",
) -> dict:
payload: dict = {"chatId": chat_id, "filePath": file_path}
if caption:
payload["caption"] = caption
if media_type:
payload["mediaType"] = media_type
if file_name:
payload["fileName"] = file_name
async with aiohttp.ClientSession() as session:
async with session.post(
f"{base_url}/send-media",
json=payload,
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
return await resp.json()
async def send_typing(base_url: str, chat_id: str) -> None:
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{base_url}/typing",
json={"chatId": chat_id},
timeout=aiohttp.ClientTimeout(total=5),
) as resp:
await resp.json()
except Exception:
pass
async def get_health(base_url: str) -> dict:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{base_url}/health", timeout=aiohttp.ClientTimeout(total=5),
) as resp:
if resp.status == 200:
return await resp.json()
return {"status": "error", "queueLength": 0, "uptime": 0}
async def get_qr(base_url: str) -> dict:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{base_url}/qr", timeout=aiohttp.ClientTimeout(total=5),
) as resp:
if resp.status == 200:
return await resp.json()
return {"status": "error", "qr": None}
async def get_chat_info(base_url: str, chat_id: str) -> dict:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{base_url}/chat/{chat_id}",
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status == 200:
return await resp.json()
return {"name": "", "isGroup": False, "participants": []}

View file

@ -0,0 +1,8 @@
name: _whatsapp_integration
title: WhatsApp Integration
description: Communicate with Agent Zero via WhatsApp. Uses Baileys bridge for message sending and receiving.
version: 1.0.0
settings_sections:
- external
per_project_config: false
per_agent_config: false

View file

@ -0,0 +1 @@
whatsapp poll error: {{error}}

View file

@ -0,0 +1 @@
whatsapp poll complete: {{result}}

View file

@ -0,0 +1,3 @@
whatsapp send failed (attempt {{attempt}}/{{max_retries}}): {{error}}
previous response was NOT delivered to user
retry or inform user issue exists

View file

@ -0,0 +1,4 @@
whatsapp session user communicates via whatsapp
response tool sends whatsapp message dont use python
break_loop true > stop working and wait for user reply
break_loop false > only for mid-task progress updates then keep working

View file

@ -0,0 +1,33 @@
# WhatsApp session behavior
user communicates via whatsapp
response tool = send whatsapp message to user
dont use code to send message
break_loop true > stop working and wait for user reply
break_loop false > only for mid-task progress updates then keep working
include file paths in attachments array for sending files
usage:
~~~json
{
...
"tool_name": "response",
"tool_args": {
"text": "working on it...",
"break_loop": false
}
}
~~~
~~~json
{
...
"tool_name": "response",
"tool_args": {
"text": "Here is the file",
"attachments": [
"/path/file.png"
],
"break_loop": true
}
}
~~~

View file

@ -0,0 +1 @@
whatsapp update failed: {{error}}

View file

@ -0,0 +1 @@
whatsapp update sent continue working

View file

@ -0,0 +1,3 @@
[WhatsApp from {{sender_name}} ({{sender_number}})]
{{body}}

View file

@ -0,0 +1,2 @@
[Handler instructions: {{instructions}}]

View file

@ -0,0 +1,259 @@
<html>
<head>
<title>WhatsApp Integration</title>
</head>
<body>
<div x-data="{
testing: false,
test_results: null,
projects: [],
qr_visible: false,
qr_status: '',
qr_message: '',
qr_data_url: null,
qr_poll_timer: null,
async init() {
try {
const { callJsonApi } = await import('/js/api.js');
const res = await callJsonApi('projects', { action: 'list' });
this.projects = res.data || [];
} catch (e) { this.projects = []; }
},
allowed_text() {
return (config?.allowed_users || []).join(', ');
},
set_allowed(val) {
config.allowed_users = val.split(',').map(s => s.trim()).filter(s => s);
},
async test_connection() {
this.testing = true;
this.test_results = null;
try {
const { callJsonApi } = await import('/js/api.js');
const res = await callJsonApi('/plugins/_whatsapp_integration/test_connection', {
config: config
});
this.test_results = res;
} catch (e) {
this.test_results = { success: false, results: [{ test: 'Connection', ok: false, message: String(e) }] };
}
this.testing = false;
},
async show_qr() {
this.qr_visible = true;
this.qr_status = 'loading';
this.qr_message = 'Starting bridge...';
this.qr_data_url = null;
await this.poll_qr();
this.qr_poll_timer = setInterval(() => this.poll_qr(), 3000);
},
hide_qr() {
this.qr_visible = false;
this.qr_data_url = null;
this.qr_status = '';
if (this.qr_poll_timer) {
clearInterval(this.qr_poll_timer);
this.qr_poll_timer = null;
}
},
async poll_qr() {
try {
const { callJsonApi } = await import('/js/api.js');
const res = await callJsonApi('/plugins/_whatsapp_integration/qr_code', {});
this.qr_status = res.status || 'error';
this.qr_message = res.message || '';
this.qr_data_url = res.qr || null;
if (res.status === 'connected') {
if (this.qr_poll_timer) {
clearInterval(this.qr_poll_timer);
this.qr_poll_timer = null;
}
}
} catch (e) {
this.qr_status = 'error';
this.qr_message = String(e);
this.qr_data_url = null;
}
}
}">
<template x-if="config">
<div>
<div class="section-title">WhatsApp Integration</div>
<div class="section-description">
Connect Agent Zero to WhatsApp using a Baileys bridge.
Requires Node.js installed on the system.
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Enabled</div>
<div class="field-description">Enable WhatsApp bridge and message polling</div>
</div>
<div class="field-control">
<label class="toggle">
<input type="checkbox" x-model="config.enabled" />
<span class="toggler"></span>
</label>
</div>
</div>
<!-- WhatsApp Link section (shown when enabled) -->
<template x-if="config.enabled">
<div style="margin: 12px 0;">
<div class="field">
<div class="field-label">
<div class="field-title">WhatsApp Link</div>
<div class="field-description">Pair your WhatsApp account by scanning a QR code</div>
</div>
<div class="field-control">
<button class="btn btn-field" @click="show_qr()">
Show QR Code
</button>
</div>
</div>
<!-- QR Code modal -->
<template x-if="qr_visible">
<div style="margin-top: 8px; padding: 16px; border-radius: 8px;
border: 1px solid var(--border-color, #333);
text-align: center;">
<!-- Connected state -->
<template x-if="qr_status === 'connected'">
<div>
<div style="font-size: 1.5rem; margin-bottom: 8px;">&#10003;</div>
<div style="font-weight: 500; color: #4caf50;" x-text="qr_message"></div>
<button class="btn btn-field" @click="hide_qr()" style="margin-top: 12px;">
Close
</button>
</div>
</template>
<!-- QR code ready -->
<template x-if="qr_status === 'waiting_scan' && qr_data_url">
<div>
<div style="font-weight: 500; margin-bottom: 12px;">
Scan with WhatsApp on your phone
</div>
<img :src="qr_data_url" alt="WhatsApp QR Code"
style="width: 256px; height: 256px; border-radius: 8px;
background: white; padding: 4px;" />
<div style="margin-top: 8px; font-size: 0.8rem; opacity: 0.6;">
QR code refreshes automatically
</div>
<button class="btn btn-field" @click="hide_qr()" style="margin-top: 12px;">
Cancel
</button>
</div>
</template>
<!-- Loading / waiting for QR -->
<template x-if="qr_status !== 'connected' && !(qr_status === 'waiting_scan' && qr_data_url)">
<div>
<div style="font-weight: 500; margin-bottom: 8px;" x-text="qr_message || 'Connecting...'"></div>
<div style="font-size: 0.85rem; opacity: 0.6;">
<template x-if="qr_status === 'error'">
<span style="color: #f44336;" x-text="qr_message"></span>
</template>
<template x-if="qr_status !== 'error'">
<span>Please wait...</span>
</template>
</div>
<button class="btn btn-field" @click="hide_qr()" style="margin-top: 12px;">
Cancel
</button>
</div>
</template>
</div>
</template>
</div>
</template>
<div class="field">
<div class="field-label">
<div class="field-title">Bridge Port</div>
<div class="field-description">Local port for the WhatsApp bridge HTTP server</div>
</div>
<div class="field-control">
<input type="number" x-model.number="config.bridge_port" placeholder="3100" />
</div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Poll Interval (seconds)</div>
<div class="field-description">How often to check for new messages (minimum 2)</div>
</div>
<div class="field-control">
<input type="number" x-model.number="config.poll_interval_seconds" min="2" placeholder="3" />
</div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Allowed Users</div>
<div class="field-description">Comma-separated phone numbers without + prefix. Empty = allow all</div>
</div>
<div class="field-control">
<input type="text" :value="allowed_text()" @input="set_allowed($event.target.value)" placeholder="34652029134, 1234567890" />
</div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Project</div>
<div class="field-description">Project to activate for WhatsApp chats</div>
</div>
<div class="field-control">
<select :value="config.project" @change="config.project = $event.target.value">
<option value="">No project</option>
<template x-for="proj in projects" :key="proj.name">
<option :value="proj.name" x-text="proj.title || proj.name" :selected="config.project === proj.name"></option>
</template>
</select>
</div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Agent Instructions</div>
<div class="field-description">Extra instructions for the agent in WhatsApp chats</div>
</div>
<div class="field-control">
<textarea x-model="config.agent_instructions" rows="3" placeholder="e.g. Always respond concisely..."></textarea>
</div>
</div>
<!-- Test connection -->
<div style="margin-top: 16px; display: flex; align-items: center; gap: 12px;">
<button class="btn btn-field" @click="test_connection()" :disabled="testing">
<span x-show="!testing">Test Connection</span>
<span x-show="testing">Testing...</span>
</button>
</div>
<!-- Test results -->
<template x-if="test_results">
<div style="margin-top: 8px; padding: 8px 12px; border-radius: 6px; font-size: 0.85rem;
border: 1px solid var(--border-color, #333);">
<template x-for="r in test_results.results" :key="r.test">
<div style="display: flex; align-items: center; gap: 8px; padding: 4px 0;">
<span x-text="r.ok ? '✓' : '✗'"
:style="'font-weight: bold; color:' + (r.ok ? '#4caf50' : '#f44336')"></span>
<span style="font-weight: 500; min-width: 50px;" x-text="r.test"></span>
<span style="opacity: 0.8;" x-text="r.message"></span>
</div>
</template>
</div>
</template>
</div>
</template>
</div>
</body>
</html>

View file

@ -0,0 +1,471 @@
#!/usr/bin/env node
/**
* Agent Zero WhatsApp Bridge
*
* Standalone Node.js process that connects to WhatsApp via Baileys
* and exposes HTTP endpoints for the Python plugin.
*
* Endpoints:
* GET /messages - Poll for new incoming messages
* POST /send - Send a message { chatId, message, replyTo? }
* POST /edit - Edit a sent message { chatId, messageId, message }
* POST /send-media - Send media { chatId, filePath, mediaType?, caption?, fileName? }
* POST /typing - Send typing indicator { chatId }
* GET /chat/:id - Get chat info
* GET /health - Health check
*
* Usage:
* node bridge.js --port 3100 --session /path/to/session --cache-dir /path/to/media
*/
import { makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, downloadMediaMessage } from '@whiskeysockets/baileys';
import express from 'express';
import { Boom } from '@hapi/boom';
import pino from 'pino';
import path from 'path';
import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
import { randomBytes } from 'crypto';
import qrcode from 'qrcode-terminal';
import QRCode from 'qrcode';
// Parse CLI args
const args = process.argv.slice(2);
function getArg(name, defaultVal) {
const idx = args.indexOf(`--${name}`);
return idx !== -1 && args[idx + 1] ? args[idx + 1] : defaultVal;
}
const WHATSAPP_DEBUG =
typeof process !== 'undefined' &&
process.env &&
typeof process.env.WHATSAPP_DEBUG === 'string' &&
['1', 'true', 'yes', 'on'].includes(process.env.WHATSAPP_DEBUG.toLowerCase());
const PORT = parseInt(getArg('port', '3100'), 10);
const SESSION_DIR = getArg('session', path.join(process.env.HOME || '~', '.agent-zero', 'whatsapp', 'session'));
const CACHE_DIR = getArg('cache-dir', path.join(process.env.HOME || '~', '.agent-zero', 'whatsapp', 'media'));
const PAIR_ONLY = args.includes('--pair-only');
const ALLOWED_USERS = (getArg('allowed-users', '') || '').split(',').map(s => s.trim()).filter(Boolean);
mkdirSync(SESSION_DIR, { recursive: true });
mkdirSync(CACHE_DIR, { recursive: true });
// Build LID -> phone reverse map from session files (lid-mapping-{phone}.json)
function buildLidMap() {
const map = {};
try {
for (const f of readdirSync(SESSION_DIR)) {
const m = f.match(/^lid-mapping-(\d+)\.json$/);
if (!m) continue;
const phone = m[1];
const lid = JSON.parse(readFileSync(path.join(SESSION_DIR, f), 'utf8'));
if (lid) map[String(lid)] = phone;
}
} catch {}
return map;
}
let lidToPhone = buildLidMap();
const logger = pino({ level: 'warn' });
// Message queue for polling
const messageQueue = [];
const MAX_QUEUE_SIZE = 100;
// Track recently sent message IDs to prevent echo-back loops
const recentlySentIds = new Set();
const MAX_RECENT_IDS = 50;
let sock = null;
let connectionState = 'disconnected';
let latestQrDataUrl = null;
async function startSocket() {
const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR);
const { version } = await fetchLatestBaileysVersion();
sock = makeWASocket({
version,
auth: state,
logger,
printQRInTerminal: false,
browser: ['Agent Zero', 'Chrome', '120.0'],
syncFullHistory: false,
markOnlineOnConnect: false,
getMessage: async (key) => {
return { conversation: '' };
},
});
sock.ev.on('creds.update', () => { saveCreds(); lidToPhone = buildLidMap(); });
sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
console.log('\n[bridge] Scan this QR code with WhatsApp on your phone:\n');
qrcode.generate(qr, { small: true });
console.log('\n[bridge] Waiting for scan...\n');
QRCode.toDataURL(qr, { width: 256, margin: 2 }).then(url => {
latestQrDataUrl = url;
}).catch(() => {});
}
if (connection === 'close') {
const reason = new Boom(lastDisconnect?.error)?.output?.statusCode;
connectionState = 'disconnected';
if (reason === DisconnectReason.loggedOut) {
console.log('[bridge] Logged out. Delete session and restart to re-authenticate.');
process.exit(1);
} else {
if (reason === 515) {
console.log('[bridge] WhatsApp requested restart (code 515). Reconnecting...');
} else {
console.log(`[bridge] Connection closed (reason: ${reason}). Reconnecting in 3s...`);
}
setTimeout(startSocket, reason === 515 ? 1000 : 3000);
}
} else if (connection === 'open') {
connectionState = 'connected';
latestQrDataUrl = null;
console.log('[bridge] WhatsApp connected');
if (PAIR_ONLY) {
console.log('[bridge] Pairing complete. Credentials saved.');
setTimeout(() => process.exit(0), 2000);
}
}
});
sock.ev.on('messages.upsert', async ({ messages, type }) => {
if (type !== 'notify' && type !== 'append') return;
for (const msg of messages) {
if (!msg.message) continue;
const chatId = msg.key.remoteJid;
if (WHATSAPP_DEBUG) {
try {
console.log(JSON.stringify({
event: 'upsert', type,
fromMe: !!msg.key.fromMe, chatId,
senderId: msg.key.participant || chatId,
messageKeys: Object.keys(msg.message || {}),
}));
} catch {}
}
const senderId = msg.key.participant || chatId;
const isGroup = chatId.endsWith('@g.us');
const senderNumber = senderId.replace(/@.*/, '');
// Bot mode: all fromMe messages are echo-backs — skip
if (msg.key.fromMe) continue;
// Skip status broadcasts
if (chatId === 'status@broadcast') continue;
// Check allowlist (resolve LID -> phone if needed)
if (ALLOWED_USERS.length > 0) {
const resolvedNumber = lidToPhone[senderNumber] || senderNumber;
if (!ALLOWED_USERS.includes(resolvedNumber)) continue;
}
// Extract message body
let body = '';
let hasMedia = false;
let mediaType = '';
const mediaUrls = [];
if (msg.message.conversation) {
body = msg.message.conversation;
} else if (msg.message.extendedTextMessage?.text) {
body = msg.message.extendedTextMessage.text;
} else if (msg.message.imageMessage) {
body = msg.message.imageMessage.caption || '';
hasMedia = true;
mediaType = 'image';
try {
const buf = await downloadMediaMessage(msg, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage });
const mime = msg.message.imageMessage.mimetype || 'image/jpeg';
const extMap = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif' };
const ext = extMap[mime] || '.jpg';
const filePath = path.join(CACHE_DIR, `img_${randomBytes(6).toString('hex')}${ext}`);
writeFileSync(filePath, buf);
mediaUrls.push(filePath);
} catch (err) {
console.error('[bridge] Failed to download image:', err.message);
}
} else if (msg.message.videoMessage) {
body = msg.message.videoMessage.caption || '';
hasMedia = true;
mediaType = 'video';
} else if (msg.message.audioMessage || msg.message.pttMessage) {
hasMedia = true;
mediaType = msg.message.pttMessage ? 'ptt' : 'audio';
} else if (msg.message.documentMessage) {
body = msg.message.documentMessage.caption || msg.message.documentMessage.fileName || '';
hasMedia = true;
mediaType = 'document';
}
// For media without caption, use a placeholder
if (hasMedia && !body) {
body = `[${mediaType} received]`;
}
// Skip echo-backs via recently sent IDs
if (recentlySentIds.has(msg.key.id)) {
if (WHATSAPP_DEBUG) {
try { console.log(JSON.stringify({ event: 'ignored', reason: 'agent_echo', chatId, messageId: msg.key.id })); } catch {}
}
continue;
}
// Skip empty messages
if (!body && !hasMedia) {
if (WHATSAPP_DEBUG) {
try {
console.log(JSON.stringify({ event: 'ignored', reason: 'empty', chatId, messageKeys: Object.keys(msg.message || {}) }));
} catch {}
}
continue;
}
const event = {
messageId: msg.key.id,
chatId,
senderId,
senderName: msg.pushName || senderNumber,
chatName: isGroup ? (chatId.split('@')[0]) : (msg.pushName || senderNumber),
isGroup,
body,
hasMedia,
mediaType,
mediaUrls,
timestamp: msg.messageTimestamp,
};
messageQueue.push(event);
if (messageQueue.length > MAX_QUEUE_SIZE) {
messageQueue.shift();
}
}
});
}
// HTTP server
const app = express();
app.use(express.json());
// Poll for new messages
app.get('/messages', (req, res) => {
const msgs = messageQueue.splice(0, messageQueue.length);
res.json(msgs);
});
// Send a message
app.post('/send', async (req, res) => {
if (!sock || connectionState !== 'connected') {
return res.status(503).json({ error: 'Not connected to WhatsApp' });
}
const { chatId, message, replyTo } = req.body;
if (!chatId || !message) {
return res.status(400).json({ error: 'chatId and message are required' });
}
try {
const sent = await sock.sendMessage(chatId, { text: message });
if (sent?.key?.id) {
recentlySentIds.add(sent.key.id);
if (recentlySentIds.size > MAX_RECENT_IDS) {
recentlySentIds.delete(recentlySentIds.values().next().value);
}
}
res.json({ success: true, messageId: sent?.key?.id });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Edit a previously sent message
app.post('/edit', async (req, res) => {
if (!sock || connectionState !== 'connected') {
return res.status(503).json({ error: 'Not connected to WhatsApp' });
}
const { chatId, messageId, message } = req.body;
if (!chatId || !messageId || !message) {
return res.status(400).json({ error: 'chatId, messageId, and message are required' });
}
try {
const key = { id: messageId, fromMe: true, remoteJid: chatId };
await sock.sendMessage(chatId, { text: message, edit: key });
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// MIME type map and media type inference
const MIME_MAP = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
webp: 'image/webp', gif: 'image/gif',
mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo',
mkv: 'video/x-matroska', '3gp': 'video/3gpp',
pdf: 'application/pdf',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
};
function inferMediaType(ext) {
if (['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(ext)) return 'image';
if (['mp4', 'mov', 'avi', 'mkv', '3gp'].includes(ext)) return 'video';
if (['ogg', 'opus', 'mp3', 'wav', 'm4a'].includes(ext)) return 'audio';
return 'document';
}
// Send media natively
app.post('/send-media', async (req, res) => {
if (!sock || connectionState !== 'connected') {
return res.status(503).json({ error: 'Not connected to WhatsApp' });
}
const { chatId, filePath, mediaType, caption, fileName } = req.body;
if (!chatId || !filePath) {
return res.status(400).json({ error: 'chatId and filePath are required' });
}
try {
if (!existsSync(filePath)) {
return res.status(404).json({ error: `File not found: ${filePath}` });
}
const buffer = readFileSync(filePath);
const ext = filePath.toLowerCase().split('.').pop();
const type = mediaType || inferMediaType(ext);
let msgPayload;
switch (type) {
case 'image':
msgPayload = { image: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'image/jpeg' };
break;
case 'video':
msgPayload = { video: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'video/mp4' };
break;
case 'audio': {
const audioMime = (ext === 'ogg' || ext === 'opus') ? 'audio/ogg; codecs=opus' : 'audio/mpeg';
msgPayload = { audio: buffer, mimetype: audioMime, ptt: ext === 'ogg' || ext === 'opus' };
break;
}
case 'document':
default:
msgPayload = {
document: buffer,
fileName: fileName || path.basename(filePath),
caption: caption || undefined,
mimetype: MIME_MAP[ext] || 'application/octet-stream',
};
break;
}
const sent = await sock.sendMessage(chatId, msgPayload);
if (sent?.key?.id) {
recentlySentIds.add(sent.key.id);
if (recentlySentIds.size > MAX_RECENT_IDS) {
recentlySentIds.delete(recentlySentIds.values().next().value);
}
}
res.json({ success: true, messageId: sent?.key?.id });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Typing indicator
app.post('/typing', async (req, res) => {
if (!sock || connectionState !== 'connected') {
return res.status(503).json({ error: 'Not connected' });
}
const { chatId } = req.body;
if (!chatId) return res.status(400).json({ error: 'chatId required' });
try {
await sock.sendPresenceUpdate('composing', chatId);
res.json({ success: true });
} catch (err) {
res.json({ success: false });
}
});
// Chat info
app.get('/chat/:id', async (req, res) => {
const chatId = req.params.id;
const isGroup = chatId.endsWith('@g.us');
if (isGroup && sock) {
try {
const metadata = await sock.groupMetadata(chatId);
return res.json({
name: metadata.subject,
isGroup: true,
participants: metadata.participants.map(p => p.id),
});
} catch {
// Fall through to default
}
}
res.json({
name: chatId.replace(/@.*/, ''),
isGroup,
participants: [],
});
});
// QR code for web UI pairing
app.get('/qr', (req, res) => {
if (connectionState === 'connected') {
return res.json({ status: 'connected', qr: null });
}
if (latestQrDataUrl) {
return res.json({ status: 'waiting_scan', qr: latestQrDataUrl });
}
res.json({ status: 'waiting_qr', qr: null });
});
// Health check
app.get('/health', (req, res) => {
res.json({
status: connectionState,
queueLength: messageQueue.length,
uptime: process.uptime(),
});
});
// Start
if (PAIR_ONLY) {
console.log('[bridge] WhatsApp pairing mode');
console.log(`[bridge] Session: ${SESSION_DIR}`);
console.log();
startSocket();
} else {
app.listen(PORT, '127.0.0.1', () => {
console.log(`[bridge] WhatsApp bridge listening on port ${PORT}`);
console.log(`[bridge] Session: ${SESSION_DIR}`);
if (ALLOWED_USERS.length > 0) {
console.log(`[bridge] Allowed users: ${ALLOWED_USERS.join(', ')}`);
} else {
console.log('[bridge] No allowed users set — all messages will be processed');
}
console.log();
startSocket();
});
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,17 @@
{
"name": "a0-whatsapp-bridge",
"version": "1.0.0",
"description": "WhatsApp bridge for Agent Zero using Baileys",
"private": true,
"type": "module",
"scripts": {
"start": "node bridge.js"
},
"dependencies": {
"@whiskeysockets/baileys": "7.0.0-rc.9",
"express": "^4.21.0",
"qrcode-terminal": "^0.12.0",
"qrcode": "^1.5.0",
"pino": "^9.0.0"
}
}