mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-10-19 15:33:54 +00:00
feat: add WhatsApp integration plugin with Baileys bridge and QR pairing
This commit is contained in:
parent
b391d98460
commit
84c12b0c23
26 changed files with 4216 additions and 0 deletions
49
plugins/_whatsapp_integration/README.md
Normal file
49
plugins/_whatsapp_integration/README.md
Normal 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
|
||||
```
|
||||
79
plugins/_whatsapp_integration/api/qr_code.py
Normal file
79
plugins/_whatsapp_integration/api/qr_code.py
Normal 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,
|
||||
}
|
||||
50
plugins/_whatsapp_integration/api/test_connection.py
Normal file
50
plugins/_whatsapp_integration/api/test_connection.py
Normal 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)}",
|
||||
})
|
||||
7
plugins/_whatsapp_integration/default_config.yaml
Normal file
7
plugins/_whatsapp_integration/default_config.yaml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
enabled: false
|
||||
bridge_port: 3100
|
||||
poll_interval_seconds: 3
|
||||
allowed_users: []
|
||||
# Example: ["34652029134", "1234567890"]
|
||||
project: ""
|
||||
agent_instructions: ""
|
||||
|
|
@ -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)
|
||||
|
|
@ -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]))
|
||||
|
|
@ -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")
|
||||
)
|
||||
|
|
@ -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)
|
||||
0
plugins/_whatsapp_integration/helpers/__init__.py
Normal file
0
plugins/_whatsapp_integration/helpers/__init__.py
Normal file
196
plugins/_whatsapp_integration/helpers/bridge_manager.py
Normal file
196
plugins/_whatsapp_integration/helpers/bridge_manager.py
Normal 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()
|
||||
230
plugins/_whatsapp_integration/helpers/handler.py
Normal file
230
plugins/_whatsapp_integration/helpers/handler.py
Normal 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
|
||||
97
plugins/_whatsapp_integration/helpers/wa_client.py
Normal file
97
plugins/_whatsapp_integration/helpers/wa_client.py
Normal 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": []}
|
||||
8
plugins/_whatsapp_integration/plugin.yaml
Normal file
8
plugins/_whatsapp_integration/plugin.yaml
Normal 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
|
||||
|
|
@ -0,0 +1 @@
|
|||
whatsapp poll error: {{error}}
|
||||
1
plugins/_whatsapp_integration/prompts/fw.wa.poll_ok.md
Normal file
1
plugins/_whatsapp_integration/prompts/fw.wa.poll_ok.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
whatsapp poll complete: {{result}}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
~~~
|
||||
|
|
@ -0,0 +1 @@
|
|||
whatsapp update failed: {{error}}
|
||||
1
plugins/_whatsapp_integration/prompts/fw.wa.update_ok.md
Normal file
1
plugins/_whatsapp_integration/prompts/fw.wa.update_ok.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
whatsapp update sent continue working
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
[WhatsApp from {{sender_name}} ({{sender_number}})]
|
||||
|
||||
{{body}}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
[Handler instructions: {{instructions}}]
|
||||
259
plugins/_whatsapp_integration/webui/config.html
Normal file
259
plugins/_whatsapp_integration/webui/config.html
Normal 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;">✓</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>
|
||||
471
plugins/_whatsapp_integration/whatsapp-bridge/bridge.js
Normal file
471
plugins/_whatsapp_integration/whatsapp-bridge/bridge.js
Normal 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();
|
||||
});
|
||||
}
|
||||
2468
plugins/_whatsapp_integration/whatsapp-bridge/package-lock.json
generated
Normal file
2468
plugins/_whatsapp_integration/whatsapp-bridge/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
17
plugins/_whatsapp_integration/whatsapp-bridge/package.json
Normal file
17
plugins/_whatsapp_integration/whatsapp-bridge/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue