mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-05-19 16:31:30 +00:00
commit5193ef7501Author: frdel <38891707+frdel@users.noreply.github.com> Date: Tue Mar 31 09:47:02 2026 +0200 refactor: change default mode from dedicated to self-chat and reorder UI settings - Change default mode to self-chat across all modules - Update README to reflect self-chat as primary mode with security warning - Move session/media storage from usr/whatsapp to tmp/whatsapp - Reorder config UI: move Mode above Allowed Numbers - Add warning banner when allowed_numbers is empty in self-chat mode - Move Bridge Port and Poll Interval to bottom of settings - Update mode descriptions to clarify self-chat handles both self commit9fece911b5Author: frdel <38891707+frdel@users.noreply.github.com> Date: Tue Mar 31 09:20:35 2026 +0200 refactor: centralize WhatsApp storage paths and improve bridge dependency handling - Add storage_paths.py helper for consistent session/media/runtime paths - Replace hardcoded usr/whatsapp paths across all modules - Fix bridge lock to be event-loop-aware (recreate per loop) - Add automatic dependency reinstall on startup failures - Track bridge startup output for better error diagnostics - Add dependency state tracking with package.json hash validation - Implement force reinstall when node_modules appears commitbc511d221dAuthor: linuztx <linuztx@gmail.com> Date: Tue Mar 31 09:07:46 2026 +0800 fix: stop poll loop immediately when Node.js is not installed commita9554e132fAuthor: linuztx <linuztx@gmail.com> Date: Tue Mar 31 08:49:15 2026 +0800 fix: auto-reinstall corrupt node_modules and stop poll loop after repeated bridge failures _ensure_npm_install now verifies key package exists, not just node_modules dir. Wipes and reinstalls if corrupt. Poll loop stops after 5 consecutive bridge start failures instead of spamming errors and making A0 unusable. commit61fa1bf487Author: linuztx <linuztx@gmail.com> Date: Tue Mar 31 08:38:51 2026 +0800 fix: move allowed_numbers filtering from JS bridge to Python handler The JS bridge used LIDs (internal WhatsApp identifiers) for sender matching which never matched actual phone numbers. Moved filtering to Python handler.py where config is read fresh each poll cycle. - Add senderNumber (resolved phone) to bridge message payload - Filter in poll_messages() with normalized number comparison - Remove --allowed-numbers CLI arg and JS-side filtering - Fix ensure_bridge_http_up not recording _bridge_config - Fix falsy empty-dict check in bridge restart detection commit64ee177897Author: linuztx <linuztx@gmail.com> Date: Sat Mar 28 23:34:23 2026 +0800 refactor: move email agent instructions to system prompt and update prompt labels commit0f53b41d80Author: linuztx <linuztx@gmail.com> Date: Sat Mar 28 10:59:44 2026 +0800 Add node_modules to gitignore commiteb6a4d3ad2Author: linuztx <linuztx@gmail.com> Date: Sat Mar 28 10:53:59 2026 +0800 Add WhatsApp plugin thumbnail commit39bed4f538Author: linuztx <linuztx@gmail.com> Date: Sat Mar 28 10:51:47 2026 +0800 refactor: rename allowed_users to allowed_numbers across plugin commite4991b6e6eAuthor: linuztx <linuztx@gmail.com> Date: Fri Mar 27 21:58:29 2026 +0800 improve: move agent instructions from per-message to system prompt commit4f1be15fa7Author: linuztx <linuztx@gmail.com> Date: Fri Mar 27 21:00:25 2026 +0800 improve: add macOS port kill support and bridge process destructor cleanup commitf5349753d7Author: linuztx <linuztx@gmail.com> Date: Fri Mar 27 17:09:56 2026 +0800 improve: remove redundant bridge_manager from execute, rely on poll loop finally commit9d9dd4bd7fAuthor: linuztx <linuztx@gmail.com> Date: Fri Mar 27 14:41:14 2026 +0800 fix: stop bridge and poll loop when plugin is disabled or toggled off commit66b0a7d3e0Author: linuztx <linuztx@gmail.com> Date: Fri Mar 27 11:05:58 2026 +0800 improve: fix allowed users input, auto-strip + prefix, log ignored messages commit938e7b9312Author: linuztx <linuztx@gmail.com> Date: Thu Mar 26 23:26:42 2026 +0800 improve: add line break to allowed users description commit4ef64b9121Author: linuztx <linuztx@gmail.com> Date: Thu Mar 26 22:44:55 2026 +0800 feat: convert markdown to WhatsApp formatting before sending replies commitf549b49f44Author: linuztx <linuztx@gmail.com> Date: Thu Mar 26 22:34:56 2026 +0800 improve: add progress update instructions to system context prompt commit66e5d51dcfAuthor: linuztx <linuztx@gmail.com> Date: Thu Mar 26 22:23:32 2026 +0800 fix: stop typing indicator on agent error or generation failure commit3dd01cd04cAuthor: linuztx <linuztx@gmail.com> Date: Thu Mar 26 18:31:38 2026 +0800 improve: persistent typing indicator with poll-based refresh commit8d0ec86f15Author: linuztx <linuztx@gmail.com> Date: Thu Mar 26 17:11:25 2026 +0800 Update README.md commite664673c1cAuthor: linuztx <linuztx@gmail.com> Date: Thu Mar 26 16:05:44 2026 +0800 feat: add agent prefix to self-chat replies for visual distinction commit18c5716d10Author: linuztx <linuztx@gmail.com> Date: Thu Mar 26 15:43:01 2026 +0800 fix: clear typing indicator after sending reply in self-chat mode commit7c653c9d56Author: linuztx <linuztx@gmail.com> Date: Thu Mar 26 14:43:06 2026 +0800 improve: merge WhatsApp Link and Disconnect into single Account field commit57c95e6f13Author: linuztx <linuztx@gmail.com> Date: Thu Mar 26 14:11:05 2026 +0800 feat: add disconnect account option to switch WhatsApp accounts commitc62695356eAuthor: linuztx <linuztx@gmail.com> Date: Thu Mar 26 14:00:00 2026 +0800 improve: move mode description inline and reorder Allow Group after Allowed Users commit18a56ea446Author: linuztx <linuztx@gmail.com> Date: Thu Mar 26 13:44:17 2026 +0800 fix: remove duplicate typing indicator before sending reply commit44c90a118fAuthor: linuztx <linuztx@gmail.com> Date: Thu Mar 26 13:30:06 2026 +0800 improve: remove sender number from DM prompt commit64fe7d0302Author: linuztx <linuztx@gmail.com> Date: Thu Mar 26 13:17:29 2026 +0800 fix: handle documentWithCaptionMessage wrapper for captioned documents commit00b6657185Author: linuztx <linuztx@gmail.com> Date: Thu Mar 26 13:06:40 2026 +0800 feat: add attachment reader/writer with RFC and download all media types commit8041c085d2Author: linuztx <linuztx@gmail.com> Date: Thu Mar 26 11:45:17 2026 +0800 improve: update group prompt and reply instructions commit71a6eb7557Author: linuztx <linuztx@gmail.com> Date: Thu Mar 26 11:26:36 2026 +0800 feat: reply to specific messages in group chats with quote commit6bf63eb9c6Author: linuztx <linuztx@gmail.com> Date: Thu Mar 26 09:57:34 2026 +0800 feat: detect replies to bot messages in group chats commitb4492e0759Author: linuztx <linuztx@gmail.com> Date: Thu Mar 26 09:20:27 2026 +0800 improve: resolve group names and sender LIDs in bridge messages commit14e673f165Author: linuztx <linuztx@gmail.com> Date: Thu Mar 26 04:44:50 2026 +0800 feat: add allow_group toggle to respond only when mentioned in group chats commit40f4884319Author: linuztx <linuztx@gmail.com> Date: Thu Mar 26 03:20:02 2026 +0800 refactor: rename mode value from bot to dedicated commit50af7c2bdeAuthor: linuztx <linuztx@gmail.com> Date: Thu Mar 26 02:34:51 2026 +0800 fix: kill orphaned bridge process on port before starting new one commit45b21c093aAuthor: linuztx <linuztx@gmail.com> Date: Thu Mar 26 02:07:45 2026 +0800 improve: auto-restart bridge when config changes commita12183ba6eAuthor: linuztx <linuztx@gmail.com> Date: Thu Mar 26 01:39:55 2026 +0800 feat: add bot and self-chat mode selection for WhatsApp bridge commitbb8961ab73Author: linuztx <linuztx@gmail.com> Date: Thu Mar 26 00:56:56 2026 +0800 improve: send typing indicator immediately on message receive commit84c12b0c23Author: linuztx <linuztx@gmail.com> Date: Thu Mar 26 00:29:04 2026 +0800 feat: add WhatsApp integration plugin with Baileys bridge and QR pairing
548 lines
16 KiB
Python
548 lines
16 KiB
Python
"""
|
|
WhatsApp bridge subprocess manager.
|
|
|
|
No agent/tool dependencies.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import platform
|
|
import shutil
|
|
import subprocess
|
|
import threading
|
|
from collections import deque
|
|
from pathlib import Path
|
|
from typing import Any, Sequence
|
|
|
|
from helpers.print_style import PrintStyle
|
|
|
|
|
|
_bridge_lock: asyncio.Lock | None = None
|
|
_bridge_lock_loop: asyncio.AbstractEventLoop | None = None
|
|
_bridge_config: dict = {} # config the running bridge was started with
|
|
|
|
MAX_STARTUP_LOG_LINES = 80
|
|
STARTUP_WAIT_ATTEMPTS = 20
|
|
STARTUP_WAIT_SECONDS = 0.5
|
|
DEPENDENCY_FAILURE_MARKERS = (
|
|
"ERR_MODULE_NOT_FOUND",
|
|
"MODULE_NOT_FOUND",
|
|
"Cannot find module",
|
|
"Cannot find package",
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Process wrapper with destructor
|
|
# ------------------------------------------------------------------
|
|
|
|
class _BridgeProcess:
|
|
"""Thin wrapper around Popen — kills the process on garbage collection."""
|
|
|
|
def __init__(self, process: subprocess.Popen, port: int):
|
|
self._process = process
|
|
self._port = port
|
|
self._recent_output: deque[str] = deque(maxlen=MAX_STARTUP_LOG_LINES)
|
|
|
|
def poll(self) -> int | None:
|
|
return self._process.poll()
|
|
|
|
def terminate(self) -> None:
|
|
self._process.terminate()
|
|
|
|
def wait(self, timeout: float | None = None) -> int:
|
|
return self._process.wait(timeout=timeout)
|
|
|
|
def kill(self) -> None:
|
|
self._process.kill()
|
|
|
|
def remember_output(self, text: str) -> None:
|
|
self._recent_output.append(text)
|
|
|
|
def recent_output(self) -> str:
|
|
return "\n".join(self._recent_output)
|
|
|
|
@property
|
|
def stdout(self):
|
|
return self._process.stdout
|
|
|
|
def __del__(self) -> None:
|
|
try:
|
|
if self._process.poll() is None:
|
|
PrintStyle.error("WhatsApp: bridge still running on GC, killing")
|
|
self._process.terminate()
|
|
try:
|
|
self._process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
self._process.kill()
|
|
_kill_port_process(self._port)
|
|
except Exception as e:
|
|
PrintStyle.error(f"WhatsApp: bridge destructor error: {e}")
|
|
|
|
|
|
_bridge_process: _BridgeProcess | None = None
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
BRIDGE_DIR = str(Path(__file__).parent.parent / "whatsapp-bridge")
|
|
BRIDGE_SCRIPT = os.path.join(BRIDGE_DIR, "bridge.js")
|
|
BRIDGE_PACKAGE_JSON = os.path.join(BRIDGE_DIR, "package.json")
|
|
BRIDGE_PACKAGE_LOCK = os.path.join(BRIDGE_DIR, "package-lock.json")
|
|
BRIDGE_RUNTIME_DIR = os.path.join(REPO_ROOT, "usr", "whatsapp", "bridge-runtime")
|
|
BRIDGE_INSTALL_STATE = os.path.join(BRIDGE_RUNTIME_DIR, "deps-state.json")
|
|
BRIDGE_NPM_CACHE = os.path.join(BRIDGE_RUNTIME_DIR, "npm-cache")
|
|
NODE_MODULES_DIR = os.path.join(BRIDGE_DIR, "node_modules")
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Public API
|
|
# ------------------------------------------------------------------
|
|
|
|
async def start_bridge(
|
|
port: int,
|
|
session_dir: str,
|
|
cache_dir: str,
|
|
mode: str = "self-chat",
|
|
) -> bool:
|
|
async with _get_bridge_lock():
|
|
return await _ensure_bridge_started(
|
|
port=port,
|
|
session_dir=session_dir,
|
|
cache_dir=cache_dir,
|
|
mode=mode,
|
|
require_connection=True,
|
|
start_label="WhatsApp: starting bridge",
|
|
)
|
|
|
|
|
|
async def stop_bridge() -> None:
|
|
async with _get_bridge_lock():
|
|
_stop_bridge_process()
|
|
|
|
|
|
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,
|
|
mode: str = "self-chat",
|
|
) -> bool:
|
|
"""Start bridge if needed and wait for HTTP server only (not WA connection)."""
|
|
async with _get_bridge_lock():
|
|
return await _ensure_bridge_started(
|
|
port=port,
|
|
session_dir=session_dir,
|
|
cache_dir=cache_dir,
|
|
mode=mode,
|
|
require_connection=False,
|
|
start_label="WhatsApp: starting bridge for pairing",
|
|
)
|
|
|
|
|
|
def is_process_alive() -> bool:
|
|
return _bridge_process is not None and _bridge_process.poll() is None
|
|
|
|
|
|
def get_running_config() -> dict:
|
|
return dict(_bridge_config)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Internal
|
|
# ------------------------------------------------------------------
|
|
|
|
def _get_bridge_lock() -> asyncio.Lock:
|
|
global _bridge_lock, _bridge_lock_loop
|
|
loop = asyncio.get_running_loop()
|
|
if _bridge_lock is None or _bridge_lock_loop is not loop:
|
|
_bridge_lock = asyncio.Lock()
|
|
_bridge_lock_loop = loop
|
|
return _bridge_lock
|
|
|
|
|
|
async def _ensure_bridge_started(
|
|
*,
|
|
port: int,
|
|
session_dir: str,
|
|
cache_dir: str,
|
|
mode: str,
|
|
require_connection: bool,
|
|
start_label: str,
|
|
) -> bool:
|
|
global _bridge_process
|
|
|
|
if _bridge_process and _bridge_process.poll() is None:
|
|
if require_connection:
|
|
return True
|
|
if await _check_http_up(port):
|
|
return True
|
|
|
|
PrintStyle.warning("WhatsApp: bridge is running but HTTP is not responding, restarting")
|
|
_stop_bridge_process()
|
|
|
|
await _ensure_bridge_dependencies()
|
|
|
|
attempt = 0
|
|
while attempt < 2:
|
|
attempt += 1
|
|
success, output = await _start_bridge_once(
|
|
port=port,
|
|
session_dir=session_dir,
|
|
cache_dir=cache_dir,
|
|
mode=mode,
|
|
require_connection=require_connection,
|
|
start_label=start_label,
|
|
)
|
|
if success:
|
|
return True
|
|
|
|
if attempt == 1 and _looks_like_dependency_failure(output):
|
|
PrintStyle.warning(
|
|
"WhatsApp: bridge startup looks like a dependency issue, "
|
|
"reinstalling dependencies and retrying"
|
|
)
|
|
await _ensure_bridge_dependencies(force_reinstall=True)
|
|
continue
|
|
|
|
return False
|
|
|
|
return False
|
|
|
|
|
|
async def _start_bridge_once(
|
|
*,
|
|
port: int,
|
|
session_dir: str,
|
|
cache_dir: str,
|
|
mode: str,
|
|
require_connection: bool,
|
|
start_label: str,
|
|
) -> tuple[bool, str]:
|
|
global _bridge_process
|
|
|
|
cmd = [
|
|
"node", BRIDGE_SCRIPT,
|
|
"--port", str(port),
|
|
"--session", session_dir,
|
|
"--cache-dir", cache_dir,
|
|
"--mode", mode,
|
|
]
|
|
|
|
_kill_port_process(port)
|
|
PrintStyle.info(start_label)
|
|
_bridge_process = _BridgeProcess(subprocess.Popen(
|
|
cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
cwd=BRIDGE_DIR,
|
|
), port)
|
|
_start_log_reader(_bridge_process)
|
|
_bridge_config.clear()
|
|
_bridge_config.update({"port": port, "mode": mode})
|
|
|
|
healthy, output = await _wait_for_bridge_startup(
|
|
port=port,
|
|
require_connection=require_connection,
|
|
)
|
|
if healthy:
|
|
return True, output
|
|
|
|
if output:
|
|
PrintStyle.error(f"WhatsApp: bridge startup failed\n{output}")
|
|
return False, output
|
|
|
|
|
|
async def _wait_for_bridge_startup(*, port: int, require_connection: bool) -> tuple[bool, str]:
|
|
for _ in range(STARTUP_WAIT_ATTEMPTS):
|
|
await asyncio.sleep(STARTUP_WAIT_SECONDS)
|
|
|
|
process = _bridge_process
|
|
if process is None:
|
|
return False, ""
|
|
|
|
if process.poll() is not None:
|
|
output = _summarize_output(process.recent_output())
|
|
PrintStyle.error("WhatsApp: bridge process exited unexpectedly")
|
|
_clear_bridge_process()
|
|
return False, output
|
|
|
|
if require_connection:
|
|
if await _check_health(port):
|
|
return True, process.recent_output()
|
|
else:
|
|
if await _check_http_up(port):
|
|
return True, process.recent_output()
|
|
|
|
if require_connection:
|
|
PrintStyle.warning("WhatsApp: bridge started but not yet connected")
|
|
process = _bridge_process
|
|
return True, process.recent_output() if process else ""
|
|
|
|
process = _bridge_process
|
|
return False, _summarize_output(process.recent_output()) if process else ""
|
|
|
|
|
|
def _looks_like_dependency_failure(output: str) -> bool:
|
|
return any(marker in output for marker in DEPENDENCY_FAILURE_MARKERS)
|
|
|
|
|
|
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_bridge_dependencies(force_reinstall: bool = False) -> None:
|
|
expected_state = await _build_dependency_state()
|
|
|
|
if not force_reinstall:
|
|
install_state = _load_dependency_state()
|
|
if os.path.isdir(NODE_MODULES_DIR) and await _validate_bridge_dependencies():
|
|
if install_state is None:
|
|
_write_dependency_state(expected_state)
|
|
return
|
|
if install_state == expected_state:
|
|
return
|
|
|
|
await _reinstall_bridge_dependencies()
|
|
|
|
if not await _validate_bridge_dependencies():
|
|
raise RuntimeError("WhatsApp: bridge dependencies failed validation after reinstall")
|
|
|
|
_write_dependency_state(await _build_dependency_state())
|
|
|
|
|
|
async def _build_dependency_state() -> dict[str, Any]:
|
|
return {
|
|
"package_json_hash": _sha256_file(BRIDGE_PACKAGE_JSON),
|
|
"package_lock_hash": _sha256_file(BRIDGE_PACKAGE_LOCK) if os.path.isfile(BRIDGE_PACKAGE_LOCK) else "",
|
|
"platform": platform.system(),
|
|
"arch": platform.machine(),
|
|
"node_version": (await _run_subprocess(["node", "--version"], cwd=BRIDGE_DIR)).strip(),
|
|
"npm_version": (await _run_subprocess(["npm", "--version"], cwd=BRIDGE_DIR)).strip(),
|
|
}
|
|
|
|
|
|
async def _validate_bridge_dependencies() -> bool:
|
|
dependency_names = _bridge_dependency_names()
|
|
if not dependency_names or not os.path.isdir(NODE_MODULES_DIR):
|
|
return False
|
|
|
|
imports = ", ".join(json.dumps(name) for name in dependency_names)
|
|
script = (
|
|
f"for (const name of [{imports}]) {{ await import(name); }}\n"
|
|
"process.stdout.write('ok');\n"
|
|
)
|
|
|
|
try:
|
|
output = await _run_subprocess(
|
|
["node", "--input-type=module", "--eval", script],
|
|
cwd=BRIDGE_DIR,
|
|
)
|
|
except RuntimeError as e:
|
|
PrintStyle.warning(f"WhatsApp: dependency validation failed: {e}")
|
|
return False
|
|
return output.strip() == "ok"
|
|
|
|
|
|
async def _reinstall_bridge_dependencies() -> None:
|
|
_ensure_runtime_dir()
|
|
|
|
if os.path.isdir(NODE_MODULES_DIR):
|
|
PrintStyle.warning("WhatsApp: bridge dependencies missing, outdated, or corrupt; reinstalling")
|
|
shutil.rmtree(NODE_MODULES_DIR, ignore_errors=True)
|
|
else:
|
|
PrintStyle.info("WhatsApp: installing bridge dependencies")
|
|
|
|
if os.path.isfile(BRIDGE_INSTALL_STATE):
|
|
try:
|
|
os.remove(BRIDGE_INSTALL_STATE)
|
|
except OSError:
|
|
pass
|
|
|
|
commands: list[list[str]] = []
|
|
if os.path.isfile(BRIDGE_PACKAGE_LOCK):
|
|
commands.append(["npm", "ci", "--omit=dev", "--no-audit", "--no-fund"])
|
|
commands.append(["npm", "install", "--omit=dev", "--no-audit", "--no-fund"])
|
|
env = {"npm_config_cache": BRIDGE_NPM_CACHE}
|
|
|
|
last_error: RuntimeError | None = None
|
|
for command in commands:
|
|
try:
|
|
await _run_subprocess(command, cwd=BRIDGE_DIR, env=env)
|
|
return
|
|
except RuntimeError as e:
|
|
last_error = e
|
|
PrintStyle.warning(f"WhatsApp: {' '.join(command)} failed: {e}")
|
|
|
|
raise RuntimeError(str(last_error) if last_error else "npm install failed")
|
|
|
|
|
|
def _bridge_dependency_names() -> list[str]:
|
|
with open(BRIDGE_PACKAGE_JSON, "r", encoding="utf-8") as f:
|
|
package_json = json.load(f)
|
|
dependencies = package_json.get("dependencies") or {}
|
|
return sorted(dependencies.keys())
|
|
|
|
|
|
def _load_dependency_state() -> dict[str, Any] | None:
|
|
if not os.path.isfile(BRIDGE_INSTALL_STATE):
|
|
return None
|
|
try:
|
|
with open(BRIDGE_INSTALL_STATE, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except (OSError, json.JSONDecodeError):
|
|
return None
|
|
|
|
|
|
def _write_dependency_state(state: dict[str, Any]) -> None:
|
|
_ensure_runtime_dir()
|
|
with open(BRIDGE_INSTALL_STATE, "w", encoding="utf-8") as f:
|
|
json.dump(state, f, indent=2, sort_keys=True)
|
|
|
|
|
|
def _sha256_file(path: str) -> str:
|
|
digest = hashlib.sha256()
|
|
with open(path, "rb") as f:
|
|
for chunk in iter(lambda: f.read(65536), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _ensure_runtime_dir() -> None:
|
|
os.makedirs(BRIDGE_RUNTIME_DIR, exist_ok=True)
|
|
|
|
|
|
async def _run_subprocess(
|
|
command: Sequence[str],
|
|
*,
|
|
cwd: str,
|
|
env: dict[str, str] | None = None,
|
|
) -> str:
|
|
process_env = os.environ.copy()
|
|
if env:
|
|
process_env.update(env)
|
|
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*command,
|
|
cwd=cwd,
|
|
env=process_env,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.STDOUT,
|
|
)
|
|
stdout, _ = await proc.communicate()
|
|
output = stdout.decode("utf-8", errors="replace").strip()
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(output or f"{' '.join(command)} exited with code {proc.returncode}")
|
|
return output
|
|
|
|
|
|
def _stop_bridge_process() -> None:
|
|
global _bridge_process
|
|
if not _bridge_process:
|
|
return
|
|
try:
|
|
_bridge_process.terminate()
|
|
try:
|
|
_bridge_process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
_bridge_process.kill()
|
|
except Exception:
|
|
pass
|
|
_clear_bridge_process()
|
|
PrintStyle.info("WhatsApp: bridge stopped")
|
|
|
|
|
|
def _clear_bridge_process() -> None:
|
|
global _bridge_process
|
|
_bridge_process = None
|
|
_bridge_config.clear()
|
|
|
|
|
|
def _kill_port_process(port: int) -> None:
|
|
"""Kill any orphaned process listening on the given TCP port."""
|
|
try:
|
|
system = platform.system()
|
|
if system == "Windows":
|
|
result = subprocess.run(
|
|
["netstat", "-ano", "-p", "TCP"],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
for line in result.stdout.splitlines():
|
|
parts = line.split()
|
|
if len(parts) >= 5 and parts[3] == "LISTENING":
|
|
if parts[1].endswith(f":{port}"):
|
|
try:
|
|
subprocess.run(
|
|
["taskkill", "/PID", parts[4], "/F"],
|
|
capture_output=True, timeout=5,
|
|
)
|
|
except subprocess.SubprocessError:
|
|
pass
|
|
elif system == "Darwin":
|
|
result = subprocess.run(
|
|
["lsof", "-ti", f"tcp:{port}"],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
for pid_str in result.stdout.strip().splitlines():
|
|
try:
|
|
os.kill(int(pid_str.strip()), 9)
|
|
except (ValueError, OSError):
|
|
pass
|
|
else:
|
|
result = subprocess.run(
|
|
["fuser", f"{port}/tcp"],
|
|
capture_output=True, timeout=5,
|
|
)
|
|
if result.returncode == 0:
|
|
subprocess.run(
|
|
["fuser", "-k", f"{port}/tcp"],
|
|
capture_output=True, timeout=5,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _start_log_reader(process: _BridgeProcess) -> None:
|
|
def _reader() -> None:
|
|
assert process.stdout
|
|
for line in iter(process.stdout.readline, b""):
|
|
text = line.decode("utf-8", errors="replace").rstrip()
|
|
if text:
|
|
process.remember_output(text)
|
|
PrintStyle.standard(f"WhatsApp bridge: {text}")
|
|
process.stdout.close()
|
|
|
|
thread = threading.Thread(target=_reader, daemon=True)
|
|
thread.start()
|
|
|
|
|
|
def _summarize_output(output: str, max_lines: int = 12) -> str:
|
|
if not output:
|
|
return ""
|
|
lines = [line for line in output.splitlines() if line.strip()]
|
|
return "\n".join(lines[-max_lines:])
|