mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 10:11:46 +03:00
refactor(channels): make built-in channels self-contained (#4908)
* refactor(channels): own setup and instance contracts * refactor(channels): isolate management contracts * refactor(channels): normalize activation contracts * fix(channels): enforce management contracts * refactor(channels): finish setup ownership migration * fix(channels): harden management contracts * fix(channels): enforce lazy loading and runtime ownership * fix(feishu): make multi-instance startup idempotent * fix(webui): render channel setup contracts cleanly * fix(feishu): stop websocket clients cleanly * fix(channels): enforce persistence and activation gates * fix(channels): preserve global feature action scope * fix(channels): apply defaults for single plugins * fix(channels): enforce management contract boundaries * refactor(feishu): remove identity helper indirection * fix(channels): preserve management setup contracts * refactor(channels): generalize instance settings UI * refactor(channels): package channel plugins with web UI metadata * refactor(channels): make built-ins self-contained packages * test(channels): colocate tests with channel packages * fix(dingtalk): use official brand icon * feat(channels): colocate webui translations * docs(channels): clarify plugin ownership * test(exec): remove output wait race * refactor(channels): unify plugin descriptors * fix(channels): enforce descriptor-owned contracts * refactor(channels): finish package-owned plugin setup * refactor(channels): use repository-owned packages only * fix(channels): self-describe dependencies and runtime state * fix(channels): warn about legacy entry points
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""WhatsApp channel package."""
|
||||
@@ -0,0 +1,35 @@
|
||||
"""WhatsApp management contract."""
|
||||
|
||||
from nanobot.channels._manifest import DIRECT_GROUP_POLICIES, field
|
||||
from nanobot.channels.contracts import ChannelManagementSpec, ChannelSetupSpec
|
||||
from nanobot.channels.plugin import ChannelPlugin
|
||||
from nanobot.channels.whatsapp.state import local_state_present
|
||||
from nanobot.channels.whatsapp.validation import validate
|
||||
|
||||
SETUP_SPEC = ChannelSetupSpec(
|
||||
fields={
|
||||
"allowFrom": field("list", snapshot=False),
|
||||
"groupPolicy": field(
|
||||
"enum",
|
||||
choices=DIRECT_GROUP_POLICIES,
|
||||
default="open",
|
||||
snapshot=False,
|
||||
),
|
||||
"databasePath": field(writable=False, snapshot=False),
|
||||
},
|
||||
official_url="https://faq.whatsapp.com/",
|
||||
validator=validate,
|
||||
)
|
||||
|
||||
PLUGIN = ChannelPlugin(
|
||||
name="whatsapp",
|
||||
display_name="WhatsApp",
|
||||
runtime=f"{__package__}.runtime:WhatsAppChannel",
|
||||
setup=SETUP_SPEC,
|
||||
management=ChannelManagementSpec(local_state_present=local_state_present),
|
||||
dependencies=(
|
||||
"neonize>=0.3.18.post0,<0.4.0",
|
||||
"segno>=1.6.1,<2.0.0",
|
||||
),
|
||||
webui="webui/index.ts",
|
||||
)
|
||||
@@ -0,0 +1,721 @@
|
||||
"""WhatsApp channel implementation using neonize."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import mimetypes
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, NamedTuple
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir, get_runtime_subdir
|
||||
from nanobot.config.schema import Base
|
||||
|
||||
|
||||
class WhatsAppConfig(Base):
|
||||
"""WhatsApp channel configuration."""
|
||||
|
||||
enabled: bool = False
|
||||
allow_from: list[str] = Field(default_factory=list)
|
||||
group_policy: Literal["open", "mention"] = "open"
|
||||
database_path: str = ""
|
||||
lid_mappings: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class _NeonizeAPI(NamedTuple):
|
||||
NewAClient: Any
|
||||
ConnectedEv: Any
|
||||
DisconnectedEv: Any
|
||||
MessageEv: Any
|
||||
PairStatusEv: Any
|
||||
build_jid: Any
|
||||
|
||||
|
||||
class _MediaInfo(NamedTuple):
|
||||
kind: str
|
||||
message: Any
|
||||
mimetype: str
|
||||
filename: str
|
||||
is_voice: bool = False
|
||||
|
||||
|
||||
_NEONIZE_API: _NeonizeAPI | None = None
|
||||
_JID_RE = re.compile(r"^(?P<user>[^@]+)@(?P<server>[^@]+)$")
|
||||
_LEGACY_BRIDGE_CONFIG_FIELDS = ("bridgeUrl", "bridgeToken", "bridge_url", "bridge_token")
|
||||
|
||||
|
||||
def _default_database_path() -> Path:
|
||||
return get_runtime_subdir("whatsapp-auth") / "neonize.db"
|
||||
|
||||
|
||||
def _legacy_bridge_config_fields(config: dict[str, Any]) -> list[str]:
|
||||
return [field for field in _LEGACY_BRIDGE_CONFIG_FIELDS if field in config]
|
||||
|
||||
|
||||
def _load_neonize() -> _NeonizeAPI:
|
||||
global _NEONIZE_API
|
||||
if _NEONIZE_API is not None:
|
||||
return _NEONIZE_API
|
||||
|
||||
try:
|
||||
from neonize.aioze.client import NewAClient
|
||||
from neonize.aioze.events import ConnectedEv, DisconnectedEv, MessageEv, PairStatusEv
|
||||
from neonize.utils.jid import build_jid
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"WhatsApp dependencies not installed. Run: nanobot plugins enable whatsapp"
|
||||
) from exc
|
||||
|
||||
_NEONIZE_API = _NeonizeAPI(
|
||||
NewAClient=NewAClient,
|
||||
ConnectedEv=ConnectedEv,
|
||||
DisconnectedEv=DisconnectedEv,
|
||||
MessageEv=MessageEv,
|
||||
PairStatusEv=PairStatusEv,
|
||||
build_jid=build_jid,
|
||||
)
|
||||
return _NEONIZE_API
|
||||
|
||||
|
||||
def _has_field(message: Any, name: str) -> bool:
|
||||
if message is None:
|
||||
return False
|
||||
|
||||
has_field = getattr(message, "HasField", None)
|
||||
if callable(has_field):
|
||||
try:
|
||||
return bool(has_field(name))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
list_fields = getattr(message, "ListFields", None)
|
||||
if callable(list_fields):
|
||||
try:
|
||||
return any(getattr(field, "name", "") == name for field, _ in list_fields())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
value = getattr(message, name, None)
|
||||
return value is not None and value != "" and value != b""
|
||||
|
||||
|
||||
def _message_field(message: Any, *names: str) -> Any:
|
||||
for name in names:
|
||||
if _has_field(message, name):
|
||||
return getattr(message, name)
|
||||
return None
|
||||
|
||||
|
||||
def _safe_attr(obj: Any, name: str, default: Any = None) -> Any:
|
||||
if obj is None:
|
||||
return default
|
||||
return getattr(obj, name, default)
|
||||
|
||||
|
||||
def _jid_to_string(jid: Any) -> str:
|
||||
if jid is None:
|
||||
return ""
|
||||
if isinstance(jid, str):
|
||||
return jid.strip()
|
||||
if bool(_safe_attr(jid, "IsEmpty", False)):
|
||||
return ""
|
||||
|
||||
user = str(_safe_attr(jid, "User", "") or "").strip()
|
||||
server = str(_safe_attr(jid, "Server", "") or "").strip()
|
||||
if user and server:
|
||||
return f"{user}@{server}"
|
||||
return server or user
|
||||
|
||||
|
||||
def _normalize_jid(raw: Any) -> str:
|
||||
jid = _jid_to_string(raw).strip()
|
||||
if not jid:
|
||||
return ""
|
||||
if jid.endswith("@lid.whatsapp.net"):
|
||||
return jid[: -len(".whatsapp.net")]
|
||||
return jid
|
||||
|
||||
|
||||
def _bare_jid(raw: Any) -> str:
|
||||
jid = _normalize_jid(raw)
|
||||
if "@" not in jid:
|
||||
return jid
|
||||
return jid.split("@", 1)[0].split(":", 1)[0]
|
||||
|
||||
|
||||
def _classify_sender_ids(jids: list[Any]) -> tuple[str, str]:
|
||||
phone_id = ""
|
||||
lid_id = ""
|
||||
|
||||
for raw in jids:
|
||||
jid = _normalize_jid(raw)
|
||||
if not jid:
|
||||
continue
|
||||
match = _JID_RE.match(jid)
|
||||
if match:
|
||||
user = match.group("user").split(":", 1)[0]
|
||||
server = match.group("server")
|
||||
if server in {"s.whatsapp.net", "c.us"}:
|
||||
phone_id = phone_id or user
|
||||
elif server in {"lid", "lid.whatsapp.net"}:
|
||||
lid_id = lid_id or user
|
||||
continue
|
||||
|
||||
if not phone_id:
|
||||
phone_id = jid
|
||||
|
||||
return phone_id, lid_id
|
||||
|
||||
|
||||
def _context_infos(message: Any) -> list[Any]:
|
||||
infos: list[Any] = []
|
||||
for container in (
|
||||
message,
|
||||
_message_field(message, "extendedTextMessage"),
|
||||
_message_field(message, "imageMessage"),
|
||||
_message_field(message, "videoMessage"),
|
||||
_message_field(message, "audioMessage"),
|
||||
_message_field(message, "documentMessage"),
|
||||
_message_field(message, "stickerMessage"),
|
||||
):
|
||||
context = _message_field(container, "contextInfo")
|
||||
if context is not None:
|
||||
infos.append(context)
|
||||
return infos
|
||||
|
||||
|
||||
def _message_text(message: Any) -> str:
|
||||
conversation = str(_safe_attr(message, "conversation", "") or "").strip()
|
||||
if conversation:
|
||||
return conversation
|
||||
|
||||
extended = _message_field(message, "extendedTextMessage")
|
||||
text = str(_safe_attr(extended, "text", "") or "").strip()
|
||||
if text:
|
||||
return text
|
||||
|
||||
for field_name in ("imageMessage", "videoMessage", "documentMessage", "stickerMessage"):
|
||||
media_message = _message_field(message, field_name)
|
||||
caption = str(_safe_attr(media_message, "caption", "") or "").strip()
|
||||
if caption:
|
||||
return caption
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _media_message(message: Any) -> _MediaInfo | None:
|
||||
image = _message_field(message, "imageMessage")
|
||||
if image is not None:
|
||||
return _MediaInfo(
|
||||
kind="image",
|
||||
message=image,
|
||||
mimetype=str(_safe_attr(image, "mimetype", "") or "image/jpeg"),
|
||||
filename=str(_safe_attr(image, "fileName", "") or ""),
|
||||
)
|
||||
|
||||
video = _message_field(message, "videoMessage")
|
||||
if video is not None:
|
||||
return _MediaInfo(
|
||||
kind="video",
|
||||
message=video,
|
||||
mimetype=str(_safe_attr(video, "mimetype", "") or "video/mp4"),
|
||||
filename=str(_safe_attr(video, "fileName", "") or ""),
|
||||
)
|
||||
|
||||
audio = _message_field(message, "audioMessage")
|
||||
if audio is not None:
|
||||
return _MediaInfo(
|
||||
kind="audio",
|
||||
message=audio,
|
||||
mimetype=str(_safe_attr(audio, "mimetype", "") or "audio/ogg"),
|
||||
filename=str(_safe_attr(audio, "fileName", "") or ""),
|
||||
is_voice=bool(_safe_attr(audio, "PTT", False) or _safe_attr(audio, "ptt", False)),
|
||||
)
|
||||
|
||||
document = _message_field(message, "documentMessage")
|
||||
if document is not None:
|
||||
return _MediaInfo(
|
||||
kind="file",
|
||||
message=document,
|
||||
mimetype=str(_safe_attr(document, "mimetype", "") or "application/octet-stream"),
|
||||
filename=str(
|
||||
_safe_attr(document, "fileName", "")
|
||||
or _safe_attr(document, "title", "")
|
||||
or ""
|
||||
),
|
||||
)
|
||||
|
||||
sticker = _message_field(message, "stickerMessage")
|
||||
if sticker is not None:
|
||||
return _MediaInfo(
|
||||
kind="sticker",
|
||||
message=sticker,
|
||||
mimetype=str(_safe_attr(sticker, "mimetype", "") or "image/webp"),
|
||||
filename=str(_safe_attr(sticker, "fileName", "") or ""),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class WhatsAppChannel(BaseChannel):
|
||||
"""WhatsApp channel using neonize's async WhatsApp client."""
|
||||
|
||||
name = "whatsapp"
|
||||
display_name = "WhatsApp"
|
||||
|
||||
@classmethod
|
||||
def default_config(cls) -> dict[str, Any]:
|
||||
return WhatsAppConfig().model_dump(by_alias=True)
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
legacy_bridge_fields = _legacy_bridge_config_fields(config) if isinstance(config, dict) else []
|
||||
if isinstance(config, dict):
|
||||
config = WhatsAppConfig.model_validate(config)
|
||||
super().__init__(config, bus)
|
||||
if legacy_bridge_fields:
|
||||
self.logger.warning(
|
||||
"Ignoring deprecated WhatsApp bridge config fields: {}. "
|
||||
"Run 'nanobot channels login whatsapp' to create a neonize session.",
|
||||
", ".join(legacy_bridge_fields),
|
||||
)
|
||||
self._client: Any | None = None
|
||||
self._connected = False
|
||||
self._processed_message_ids: OrderedDict[str, None] = OrderedDict()
|
||||
self._lid_to_phone = self._load_lid_mappings()
|
||||
self._self_jids: set[str] = set()
|
||||
self._started_at = 0.0
|
||||
|
||||
def _database_path(self) -> Path:
|
||||
configured = self.config.database_path.strip()
|
||||
return Path(configured).expanduser() if configured else _default_database_path()
|
||||
|
||||
def _load_lid_mappings(self) -> dict[str, str]:
|
||||
mapping: dict[str, str] = {}
|
||||
for lid, phone in self.config.lid_mappings.items():
|
||||
phone_text = str(phone).strip()
|
||||
if phone_text:
|
||||
mapping[str(lid).strip()] = phone_text
|
||||
return mapping
|
||||
|
||||
def _new_client(self) -> Any:
|
||||
api = _load_neonize()
|
||||
db_path = self._database_path()
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return api.NewAClient(str(db_path))
|
||||
|
||||
async def login(self, force: bool = False) -> bool:
|
||||
db_path = self._database_path()
|
||||
if force:
|
||||
self._reset_database(db_path)
|
||||
|
||||
client = self._new_client()
|
||||
login_result = asyncio.get_running_loop().create_future()
|
||||
self._register_handlers(client, login_result=login_result, handle_messages=False)
|
||||
|
||||
try:
|
||||
self.logger.info("Starting WhatsApp login with neonize...")
|
||||
connect_task = await client.connect()
|
||||
self._fail_login_on_connect_task_done(connect_task, login_result)
|
||||
await login_result
|
||||
self.logger.info("WhatsApp login complete")
|
||||
return True
|
||||
except Exception as exc:
|
||||
self.logger.error("WhatsApp login failed: {}", exc)
|
||||
return False
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
await client.stop()
|
||||
|
||||
async def start(self) -> None:
|
||||
self._running = True
|
||||
self._started_at = time.time()
|
||||
client = self._new_client()
|
||||
self._client = client
|
||||
self._register_handlers(client, handle_messages=True)
|
||||
|
||||
try:
|
||||
self.logger.info("Connecting WhatsApp channel with neonize...")
|
||||
await client.connect()
|
||||
await client.idle()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
finally:
|
||||
self._running = False
|
||||
self._connected = False
|
||||
if self._client is client:
|
||||
self._client = None
|
||||
with suppress(Exception):
|
||||
await client.stop()
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._running = False
|
||||
self._connected = False
|
||||
client = self._client
|
||||
self._client = None
|
||||
if client is not None:
|
||||
await client.stop()
|
||||
|
||||
@staticmethod
|
||||
def _fail_login_on_connect_task_done(
|
||||
connect_task: asyncio.Task[Any] | None,
|
||||
login_result: asyncio.Future[None],
|
||||
) -> None:
|
||||
if connect_task is None:
|
||||
return
|
||||
|
||||
def _on_done(task: asyncio.Task[Any]) -> None:
|
||||
try:
|
||||
exc = task.exception()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
if login_result.done():
|
||||
return
|
||||
if exc is not None:
|
||||
login_result.set_exception(exc)
|
||||
else:
|
||||
login_result.set_exception(
|
||||
RuntimeError("WhatsApp connection ended before login completed")
|
||||
)
|
||||
|
||||
connect_task.add_done_callback(_on_done)
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
client = self._client
|
||||
if client is None or not self._connected:
|
||||
raise RuntimeError("WhatsApp channel is not connected")
|
||||
|
||||
to = self._build_jid(msg.chat_id)
|
||||
if msg.content:
|
||||
await client.send_message(to, msg.content)
|
||||
|
||||
for media_path in msg.media or []:
|
||||
await self._send_media(client, to, media_path)
|
||||
|
||||
def _build_jid(self, raw: str) -> Any:
|
||||
api = _load_neonize()
|
||||
target = raw.strip()
|
||||
match = _JID_RE.match(_normalize_jid(target))
|
||||
if not match:
|
||||
return api.build_jid(target)
|
||||
|
||||
user = match.group("user").split(":", 1)[0]
|
||||
server = match.group("server")
|
||||
return api.build_jid(user, server)
|
||||
|
||||
async def _send_media(self, client: Any, to: Any, media_path: str) -> None:
|
||||
path = str(Path(media_path).expanduser())
|
||||
mime, _ = mimetypes.guess_type(path)
|
||||
mimetype = mime or "application/octet-stream"
|
||||
if mimetype.startswith("image/"):
|
||||
await client.send_image(to, path)
|
||||
elif mimetype.startswith("video/"):
|
||||
await client.send_video(to, path)
|
||||
elif mimetype.startswith("audio/"):
|
||||
await client.send_audio(to, path)
|
||||
else:
|
||||
await client.send_document(
|
||||
to,
|
||||
path,
|
||||
filename=Path(path).name,
|
||||
mimetype=mimetype,
|
||||
)
|
||||
|
||||
def _register_handlers(
|
||||
self,
|
||||
client: Any,
|
||||
*,
|
||||
login_result: asyncio.Future[None] | None = None,
|
||||
handle_messages: bool,
|
||||
) -> None:
|
||||
api = _load_neonize()
|
||||
|
||||
@client.qr
|
||||
async def _on_qr(_: Any, qr_data: bytes) -> None:
|
||||
import segno
|
||||
|
||||
self.logger.info("Scan the WhatsApp QR code with Linked Devices")
|
||||
segno.make_qr(qr_data).terminal(compact=True)
|
||||
|
||||
@client.event(api.ConnectedEv)
|
||||
async def _on_connected(current_client: Any, _: Any) -> None:
|
||||
self._connected = True
|
||||
try:
|
||||
await self._remember_self_jids(current_client)
|
||||
except Exception as exc:
|
||||
if login_result is not None and not login_result.done():
|
||||
login_result.set_exception(exc)
|
||||
raise
|
||||
if login_result is not None and not login_result.done():
|
||||
login_result.set_result(None)
|
||||
self.logger.info("WhatsApp connected")
|
||||
|
||||
@client.event(api.DisconnectedEv)
|
||||
async def _on_disconnected(_: Any, event: Any) -> None:
|
||||
self._connected = False
|
||||
if login_result is not None and not login_result.done():
|
||||
login_result.set_exception(
|
||||
RuntimeError(f"WhatsApp disconnected before login completed: {event}")
|
||||
)
|
||||
self.logger.warning("WhatsApp disconnected: {}", event)
|
||||
|
||||
@client.event(api.PairStatusEv)
|
||||
async def _on_pair_status(_: Any, event: Any) -> None:
|
||||
error = str(_safe_attr(event, "Error", "") or "")
|
||||
if error:
|
||||
exc = RuntimeError(f"WhatsApp pair status error: {error}")
|
||||
if login_result is not None and not login_result.done():
|
||||
login_result.set_exception(exc)
|
||||
raise exc
|
||||
self.logger.info("WhatsApp pair status: {}", event)
|
||||
|
||||
if not handle_messages:
|
||||
return
|
||||
|
||||
@client.event(api.MessageEv)
|
||||
async def _on_message(current_client: Any, event: Any) -> None:
|
||||
try:
|
||||
await self._handle_neonize_message(current_client, event)
|
||||
except Exception:
|
||||
self.logger.exception("Error handling WhatsApp message")
|
||||
raise
|
||||
|
||||
async def _remember_self_jids(self, client: Any) -> None:
|
||||
device = _safe_attr(client, "me")
|
||||
if device is None:
|
||||
device = await client.get_me()
|
||||
|
||||
for attr in ("JID", "LID"):
|
||||
jid = _normalize_jid(_safe_attr(device, attr))
|
||||
if jid:
|
||||
self._self_jids.add(jid)
|
||||
self._self_jids.add(_bare_jid(jid))
|
||||
|
||||
async def _send_read_receipt(self, client: Any, source: Any, message_id: str) -> None:
|
||||
"""Send a read receipt (blue double-check) for an incoming message.
|
||||
|
||||
Best-effort: any failure is logged at debug level and swallowed so it
|
||||
never blocks message processing.
|
||||
"""
|
||||
if not message_id:
|
||||
return
|
||||
try:
|
||||
from neonize.utils.enum import ReceiptType
|
||||
|
||||
chat = _safe_attr(source, "Chat")
|
||||
sender = _safe_attr(source, "Sender")
|
||||
if chat is None or sender is None:
|
||||
return
|
||||
await client.mark_read(
|
||||
message_id,
|
||||
chat=chat,
|
||||
sender=sender,
|
||||
receipt=ReceiptType.READ,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - read receipt is best-effort
|
||||
self.logger.debug("Failed to send WhatsApp read receipt: {}", exc)
|
||||
|
||||
async def _handle_neonize_message(self, client: Any, event: Any) -> None:
|
||||
info = _safe_attr(event, "Info")
|
||||
message = _safe_attr(event, "Message")
|
||||
source = _safe_attr(info, "MessageSource")
|
||||
if info is None or message is None or source is None:
|
||||
raise ValueError("WhatsApp MessageEv is missing Info, Message, or MessageSource")
|
||||
|
||||
if bool(_safe_attr(source, "IsFromMe", False)):
|
||||
return
|
||||
|
||||
chat_jid = _normalize_jid(_safe_attr(source, "Chat"))
|
||||
if not chat_jid:
|
||||
raise ValueError("WhatsApp message has no chat JID")
|
||||
if chat_jid == "status@broadcast":
|
||||
return
|
||||
|
||||
timestamp = float(_safe_attr(info, "Timestamp", 0) or 0)
|
||||
if self._started_at and timestamp and timestamp < self._started_at:
|
||||
return
|
||||
|
||||
is_group = bool(_safe_attr(source, "IsGroup", False))
|
||||
if is_group and self.config.group_policy == "mention":
|
||||
if not self._is_addressed_to_bot(message):
|
||||
return
|
||||
|
||||
message_id = str(_safe_attr(info, "ID", "") or "")
|
||||
if message_id:
|
||||
if message_id in self._processed_message_ids:
|
||||
return
|
||||
self._processed_message_ids[message_id] = None
|
||||
while len(self._processed_message_ids) > 1000:
|
||||
self._processed_message_ids.popitem(last=False)
|
||||
|
||||
# Mark the incoming message as read (blue double-check). Best-effort.
|
||||
await self._send_read_receipt(client, source, message_id)
|
||||
|
||||
participant_jid = _normalize_jid(_safe_attr(source, "Sender"))
|
||||
sender_alt_jid = _normalize_jid(_safe_attr(source, "SenderAlt"))
|
||||
sender_candidates = [sender_alt_jid, participant_jid]
|
||||
if not is_group:
|
||||
sender_candidates.append(chat_jid)
|
||||
|
||||
phone_id, lid_id = _classify_sender_ids(sender_candidates)
|
||||
if phone_id and lid_id:
|
||||
self._lid_to_phone[lid_id] = phone_id
|
||||
|
||||
sender_id = phone_id or self._lid_to_phone.get(lid_id, "") or lid_id
|
||||
if not sender_id:
|
||||
raise ValueError("WhatsApp message has no resolvable sender ID")
|
||||
metadata = {
|
||||
"message_id": message_id or None,
|
||||
"timestamp": int(timestamp) if timestamp else None,
|
||||
"is_group": is_group,
|
||||
"is_forwarded": self._is_forwarded(message),
|
||||
"participant": participant_jid or None,
|
||||
"sender_alt": sender_alt_jid or None,
|
||||
"lid": lid_id or None,
|
||||
"phone": phone_id or None,
|
||||
"is_reply_to_bot": self._is_reply_to_bot(message),
|
||||
}
|
||||
sender_allowed = self.is_allowed(sender_id)
|
||||
group_allow_id = self._group_allow_id(chat_jid) if is_group else None
|
||||
authorization_id = sender_id if sender_allowed else group_allow_id
|
||||
if authorization_id is None:
|
||||
self.logger.info(
|
||||
"Passing unauthorized WhatsApp sender {} to pairing flow "
|
||||
"(phone={}, lid={}, chat={})",
|
||||
sender_id,
|
||||
phone_id or "",
|
||||
lid_id or "",
|
||||
chat_jid,
|
||||
)
|
||||
await self._handle_message(
|
||||
sender_id=sender_id,
|
||||
chat_id=chat_jid,
|
||||
content=_message_text(message),
|
||||
media=[],
|
||||
metadata=metadata,
|
||||
is_dm=not is_group,
|
||||
)
|
||||
return
|
||||
|
||||
text = _message_text(message)
|
||||
media_paths: list[str] = []
|
||||
media = _media_message(message)
|
||||
if media is not None:
|
||||
path = await self._download_media(client, event, media)
|
||||
if media.kind == "audio" and media.is_voice:
|
||||
transcription = await self.transcribe_audio(path)
|
||||
if transcription:
|
||||
text = transcription
|
||||
else:
|
||||
media_paths.append(path)
|
||||
text = self._append_media_tag(text, "audio", path)
|
||||
else:
|
||||
media_paths.append(path)
|
||||
text = self._append_media_tag(text, media.kind, path)
|
||||
|
||||
if not text and not media_paths:
|
||||
return
|
||||
|
||||
await self._handle_message(
|
||||
sender_id=sender_id,
|
||||
chat_id=chat_jid,
|
||||
content=text,
|
||||
media=media_paths,
|
||||
metadata=metadata,
|
||||
is_dm=not is_group,
|
||||
authorization_id=authorization_id,
|
||||
)
|
||||
|
||||
def _group_allow_id(self, chat_jid: str) -> str | None:
|
||||
if self.is_allowed(chat_jid):
|
||||
return chat_jid
|
||||
bare_chat_id = _bare_jid(chat_jid)
|
||||
if bare_chat_id and bare_chat_id != chat_jid and self.is_allowed(bare_chat_id):
|
||||
return bare_chat_id
|
||||
return None
|
||||
|
||||
def _is_addressed_to_bot(self, message: Any) -> bool:
|
||||
return self._was_mentioned(message) or self._is_reply_to_bot(message)
|
||||
|
||||
def _was_mentioned(self, message: Any) -> bool:
|
||||
if not self._self_jids:
|
||||
return False
|
||||
for context in _context_infos(message):
|
||||
mentioned = (
|
||||
_safe_attr(context, "mentionedJID")
|
||||
or _safe_attr(context, "mentionedJid")
|
||||
or _safe_attr(context, "mentioned_jid")
|
||||
or []
|
||||
)
|
||||
for jid in mentioned:
|
||||
normalized = _normalize_jid(jid)
|
||||
if normalized in self._self_jids or _bare_jid(normalized) in self._self_jids:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_reply_to_bot(self, message: Any) -> bool:
|
||||
if not self._self_jids:
|
||||
return False
|
||||
for context in _context_infos(message):
|
||||
participant = _normalize_jid(
|
||||
_safe_attr(context, "participant")
|
||||
or _safe_attr(context, "Participant")
|
||||
or ""
|
||||
)
|
||||
if participant in self._self_jids or _bare_jid(participant) in self._self_jids:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_forwarded(message: Any) -> bool:
|
||||
for context in _context_infos(message):
|
||||
if bool(_safe_attr(context, "isForwarded", False)):
|
||||
return True
|
||||
if int(_safe_attr(context, "forwardingScore", 0) or 0) > 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _download_media(self, client: Any, event: Any, media: _MediaInfo) -> str:
|
||||
info = _safe_attr(event, "Info")
|
||||
message_id = str(_safe_attr(info, "ID", "") or "")
|
||||
path = self._media_path(message_id, media)
|
||||
await client.download_any(_safe_attr(event, "Message"), str(path))
|
||||
return str(path)
|
||||
|
||||
def _media_path(self, message_id: str, media: _MediaInfo) -> Path:
|
||||
media_dir = get_media_dir("whatsapp")
|
||||
safe_id = re.sub(r"[^A-Za-z0-9_.-]+", "_", message_id or str(int(time.time())))
|
||||
filename = Path(media.filename).name if media.filename else ""
|
||||
suffix = Path(filename).suffix if filename else ""
|
||||
if not suffix:
|
||||
suffix = mimetypes.guess_extension(media.mimetype) or {
|
||||
"image": ".jpg",
|
||||
"video": ".mp4",
|
||||
"audio": ".ogg",
|
||||
"sticker": ".webp",
|
||||
}.get(media.kind, ".bin")
|
||||
return media_dir / f"wa_{safe_id}_{secrets.token_hex(4)}{suffix}"
|
||||
|
||||
@staticmethod
|
||||
def _append_media_tag(text: str, kind: str, path: str) -> str:
|
||||
label = kind if kind in {"image", "video", "audio", "sticker"} else "file"
|
||||
tag = f"[{label}: {path}]"
|
||||
return f"{text}\n{tag}" if text else tag
|
||||
|
||||
@staticmethod
|
||||
def _reset_database(path: Path) -> None:
|
||||
for candidate in (
|
||||
path,
|
||||
path.with_suffix(path.suffix + "-shm"),
|
||||
path.with_suffix(path.suffix + "-wal"),
|
||||
):
|
||||
if candidate.exists():
|
||||
candidate.unlink()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""WhatsApp-owned persisted login-state detection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.channels.contracts import channel_field_value
|
||||
from nanobot.config.loader import get_config_path
|
||||
|
||||
|
||||
def local_state_present(section: Any) -> bool:
|
||||
configured_path = channel_field_value(section, "databasePath")
|
||||
database_path = (
|
||||
Path(str(configured_path)).expanduser()
|
||||
if configured_path
|
||||
else get_config_path().parent / "whatsapp-auth" / "neonize.db"
|
||||
)
|
||||
try:
|
||||
return database_path.is_file() and database_path.stat().st_size > 0
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["local_state_present"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the WhatsApp channel package."""
|
||||
@@ -0,0 +1,577 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import nanobot.channels.whatsapp.runtime as whatsapp_module
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.whatsapp.runtime import (
|
||||
WhatsAppChannel,
|
||||
_legacy_bridge_config_fields,
|
||||
_NeonizeAPI,
|
||||
)
|
||||
|
||||
|
||||
class _Proto:
|
||||
def __init__(self, **kwargs):
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
def HasField(self, name: str) -> bool: # noqa: N802 - protobuf compatibility
|
||||
return _is_set(getattr(self, name, None))
|
||||
|
||||
def ListFields(self): # noqa: N802 - protobuf compatibility
|
||||
return [
|
||||
(SimpleNamespace(name=name), value)
|
||||
for name, value in self.__dict__.items()
|
||||
if _is_set(value)
|
||||
]
|
||||
|
||||
|
||||
def _is_set(value) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, (str, bytes, list, tuple, dict, set)):
|
||||
return bool(value)
|
||||
return True
|
||||
|
||||
|
||||
def _jid(user: str, server: str) -> _Proto:
|
||||
return _Proto(User=user, Server=server, IsEmpty=False)
|
||||
|
||||
|
||||
def _event(
|
||||
*,
|
||||
message: _Proto,
|
||||
message_id: str = "m1",
|
||||
chat: _Proto | None = None,
|
||||
sender: _Proto | None = None,
|
||||
sender_alt: _Proto | None = None,
|
||||
is_group: bool = False,
|
||||
timestamp: int = 1,
|
||||
is_from_me: bool = False,
|
||||
) -> _Proto:
|
||||
source = _Proto(
|
||||
Chat=chat or _jid("15551234567", "s.whatsapp.net"),
|
||||
Sender=sender,
|
||||
SenderAlt=sender_alt,
|
||||
IsGroup=is_group,
|
||||
IsFromMe=is_from_me,
|
||||
)
|
||||
return _Proto(
|
||||
Info=_Proto(ID=message_id, Timestamp=timestamp, MessageSource=source),
|
||||
Message=message,
|
||||
)
|
||||
|
||||
|
||||
def _make_channel(config: dict | None = None) -> WhatsAppChannel:
|
||||
merged = {"enabled": True, "allowFrom": ["*"]}
|
||||
if config:
|
||||
merged.update(config)
|
||||
ch = WhatsAppChannel(merged, MagicMock())
|
||||
ch._started_at = 0
|
||||
return ch
|
||||
|
||||
|
||||
def _patch_neonize_api(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
whatsapp_module,
|
||||
"_NEONIZE_API",
|
||||
_NeonizeAPI(
|
||||
NewAClient=object,
|
||||
ConnectedEv=object(),
|
||||
DisconnectedEv=object(),
|
||||
MessageEv=object(),
|
||||
PairStatusEv=object(),
|
||||
build_jid=lambda user, server="s.whatsapp.net": (user, server),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _patch_receipt_type(monkeypatch):
|
||||
neonize = types.ModuleType("neonize")
|
||||
utils = types.ModuleType("neonize.utils")
|
||||
enum = types.ModuleType("neonize.utils.enum")
|
||||
|
||||
class ReceiptType:
|
||||
READ = "read"
|
||||
|
||||
enum.ReceiptType = ReceiptType
|
||||
neonize.utils = utils
|
||||
utils.enum = enum
|
||||
monkeypatch.setitem(sys.modules, "neonize", neonize)
|
||||
monkeypatch.setitem(sys.modules, "neonize.utils", utils)
|
||||
monkeypatch.setitem(sys.modules, "neonize.utils.enum", enum)
|
||||
return ReceiptType
|
||||
|
||||
|
||||
class _FakeLoginClient:
|
||||
def __init__(self) -> None:
|
||||
self.handlers = {}
|
||||
self.me = _Proto(JID=_jid("bot", "s.whatsapp.net"), LID=_jid("BOTLID", "lid"))
|
||||
self.stop = AsyncMock()
|
||||
|
||||
def event(self, event_type):
|
||||
def register(func):
|
||||
self.handlers[event_type] = func
|
||||
return func
|
||||
|
||||
return register
|
||||
|
||||
def qr(self, func):
|
||||
self.qr_handler = func
|
||||
return func
|
||||
|
||||
async def connect(self) -> None:
|
||||
await self.handlers[whatsapp_module._NEONIZE_API.ConnectedEv](self, _Proto())
|
||||
|
||||
|
||||
class _FailingConnectLoginClient(_FakeLoginClient):
|
||||
async def connect(self) -> asyncio.Task[None]:
|
||||
async def fail() -> None:
|
||||
raise RuntimeError("dial failed")
|
||||
|
||||
return asyncio.create_task(fail())
|
||||
|
||||
|
||||
def test_default_config_has_no_bridge_fields() -> None:
|
||||
config = WhatsAppChannel.default_config()
|
||||
|
||||
assert "bridgeUrl" not in config
|
||||
assert "bridgeToken" not in config
|
||||
assert config["databasePath"] == ""
|
||||
|
||||
|
||||
def test_legacy_bridge_config_fields_are_detected() -> None:
|
||||
assert _legacy_bridge_config_fields({"bridgeUrl": "ws://localhost:3001"}) == ["bridgeUrl"]
|
||||
assert _legacy_bridge_config_fields({"bridgeToken": "secret"}) == ["bridgeToken"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_succeeds_when_connected(monkeypatch) -> None:
|
||||
_patch_neonize_api(monkeypatch)
|
||||
client = _FakeLoginClient()
|
||||
ch = _make_channel()
|
||||
ch._new_client = MagicMock(return_value=client)
|
||||
|
||||
assert await ch.login() is True
|
||||
assert ch._self_jids == {"bot@s.whatsapp.net", "bot", "BOTLID@lid", "BOTLID"}
|
||||
client.stop.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_fails_when_connect_task_fails(monkeypatch) -> None:
|
||||
_patch_neonize_api(monkeypatch)
|
||||
client = _FailingConnectLoginClient()
|
||||
ch = _make_channel()
|
||||
ch._new_client = MagicMock(return_value=client)
|
||||
|
||||
assert await ch.login() is False
|
||||
client.stop.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_text_uses_neonize_send_message(monkeypatch) -> None:
|
||||
_patch_neonize_api(monkeypatch)
|
||||
client = SimpleNamespace(
|
||||
send_message=AsyncMock(),
|
||||
send_image=AsyncMock(),
|
||||
send_video=AsyncMock(),
|
||||
send_audio=AsyncMock(),
|
||||
send_document=AsyncMock(),
|
||||
)
|
||||
ch = _make_channel()
|
||||
ch._client = client
|
||||
ch._connected = True
|
||||
|
||||
await ch.send(OutboundMessage(channel="whatsapp", chat_id="12345@s.whatsapp.net", content="hi"))
|
||||
|
||||
client.send_message.assert_awaited_once_with(("12345", "s.whatsapp.net"), "hi")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None:
|
||||
_patch_neonize_api(monkeypatch)
|
||||
client = SimpleNamespace(
|
||||
send_message=AsyncMock(),
|
||||
send_image=AsyncMock(),
|
||||
send_video=AsyncMock(),
|
||||
send_audio=AsyncMock(),
|
||||
send_document=AsyncMock(),
|
||||
)
|
||||
ch = _make_channel()
|
||||
ch._client = client
|
||||
ch._connected = True
|
||||
|
||||
await ch.send(
|
||||
OutboundMessage(
|
||||
channel="whatsapp",
|
||||
chat_id="12345@s.whatsapp.net",
|
||||
content="",
|
||||
media=["photo.jpg", "clip.mp4", "voice.ogg", "report.pdf"],
|
||||
)
|
||||
)
|
||||
|
||||
jid = ("12345", "s.whatsapp.net")
|
||||
client.send_image.assert_awaited_once_with(jid, "photo.jpg")
|
||||
client.send_video.assert_awaited_once_with(jid, "clip.mp4")
|
||||
client.send_audio.assert_awaited_once_with(jid, "voice.ogg")
|
||||
client.send_document.assert_awaited_once_with(
|
||||
jid,
|
||||
"report.pdf",
|
||||
filename="report.pdf",
|
||||
mimetype="application/pdf",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_when_disconnected_raises() -> None:
|
||||
ch = _make_channel()
|
||||
|
||||
with pytest.raises(RuntimeError, match="not connected"):
|
||||
await ch.send(OutboundMessage(channel="whatsapp", chat_id="123", content="hi"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_policy_mention_skips_unmentioned_group_message() -> None:
|
||||
ch = _make_channel({"groupPolicy": "mention"})
|
||||
ch._self_jids = {"bot@s.whatsapp.net", "bot"}
|
||||
ch._handle_message = AsyncMock()
|
||||
|
||||
await ch._handle_neonize_message(
|
||||
SimpleNamespace(download_any=AsyncMock()),
|
||||
_event(
|
||||
message=_Proto(conversation="hello group"),
|
||||
chat=_jid("120363000", "g.us"),
|
||||
sender=_jid("SENDERLID", "lid"),
|
||||
is_group=True,
|
||||
),
|
||||
)
|
||||
|
||||
ch._handle_message.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_policy_mention_accepts_mention_and_prefers_phone_sender() -> None:
|
||||
ch = _make_channel({"groupPolicy": "mention"})
|
||||
ch._self_jids = {"bot@s.whatsapp.net", "bot"}
|
||||
ch._handle_message = AsyncMock()
|
||||
context = _Proto(mentionedJID=["bot@s.whatsapp.net"])
|
||||
message = _Proto(extendedTextMessage=_Proto(text="hello @bot", contextInfo=context))
|
||||
|
||||
await ch._handle_neonize_message(
|
||||
SimpleNamespace(download_any=AsyncMock()),
|
||||
_event(
|
||||
message=message,
|
||||
chat=_jid("120363000", "g.us"),
|
||||
sender=_jid("LID99", "lid"),
|
||||
sender_alt=_jid("15559998888", "s.whatsapp.net"),
|
||||
is_group=True,
|
||||
),
|
||||
)
|
||||
|
||||
kwargs = ch._handle_message.await_args.kwargs
|
||||
assert kwargs["sender_id"] == "15559998888"
|
||||
assert kwargs["chat_id"] == "120363000@g.us"
|
||||
assert kwargs["metadata"]["lid"] == "LID99"
|
||||
assert kwargs["metadata"]["phone"] == "15559998888"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_policy_mention_accepts_reply_to_bot() -> None:
|
||||
ch = _make_channel({"groupPolicy": "mention"})
|
||||
ch._self_jids = {"bot@s.whatsapp.net", "bot"}
|
||||
ch._handle_message = AsyncMock()
|
||||
context = _Proto(participant="bot@s.whatsapp.net")
|
||||
message = _Proto(extendedTextMessage=_Proto(text="reply", contextInfo=context))
|
||||
|
||||
await ch._handle_neonize_message(
|
||||
SimpleNamespace(download_any=AsyncMock()),
|
||||
_event(
|
||||
message=message,
|
||||
chat=_jid("120363000", "g.us"),
|
||||
sender=_jid("SENDERLID", "lid"),
|
||||
is_group=True,
|
||||
),
|
||||
)
|
||||
|
||||
kwargs = ch._handle_message.await_args.kwargs
|
||||
assert kwargs["metadata"]["is_reply_to_bot"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_sender_id_uses_participant_not_group_jid() -> None:
|
||||
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["SENDERLID"]}, MagicMock())
|
||||
ch._started_at = 0
|
||||
ch._handle_message = AsyncMock()
|
||||
|
||||
await ch._handle_neonize_message(
|
||||
SimpleNamespace(download_any=AsyncMock()),
|
||||
_event(
|
||||
message=_Proto(conversation="hi"),
|
||||
chat=_jid("120363000", "g.us"),
|
||||
sender=_jid("SENDERLID", "lid"),
|
||||
is_group=True,
|
||||
),
|
||||
)
|
||||
|
||||
kwargs = ch._handle_message.await_args.kwargs
|
||||
assert kwargs["sender_id"] == "SENDERLID"
|
||||
assert kwargs["metadata"]["participant"] == "SENDERLID@lid"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("allowed_group", ["120363000@g.us", "120363000"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_allow_from_accepts_group_jid_or_bare_id(allowed_group: str) -> None:
|
||||
bus = MessageBus()
|
||||
ch = WhatsAppChannel({"enabled": True, "allowFrom": [allowed_group]}, bus)
|
||||
ch._started_at = 0
|
||||
|
||||
await ch._handle_neonize_message(
|
||||
SimpleNamespace(download_any=AsyncMock()),
|
||||
_event(
|
||||
message=_Proto(conversation="hi"),
|
||||
chat=_jid("120363000", "g.us"),
|
||||
sender=_jid("SENDERLID", "lid"),
|
||||
is_group=True,
|
||||
),
|
||||
)
|
||||
|
||||
assert bus.inbound_size == 1
|
||||
msg = await bus.consume_inbound()
|
||||
assert msg.sender_id == "SENDERLID"
|
||||
assert msg.chat_id == "120363000@g.us"
|
||||
assert msg.content == "hi"
|
||||
assert msg.metadata["participant"] == "SENDERLID@lid"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_allow_from_does_not_allow_same_participant_in_other_group() -> None:
|
||||
bus = MessageBus()
|
||||
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["120363000"]}, bus)
|
||||
ch._started_at = 0
|
||||
|
||||
await ch._handle_neonize_message(
|
||||
SimpleNamespace(download_any=AsyncMock()),
|
||||
_event(
|
||||
message=_Proto(conversation="hi"),
|
||||
chat=_jid("120363999", "g.us"),
|
||||
sender=_jid("SENDERLID", "lid"),
|
||||
is_group=True,
|
||||
),
|
||||
)
|
||||
|
||||
assert bus.inbound_size == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_receipt_is_requested_once_after_dedup() -> None:
|
||||
ch = _make_channel()
|
||||
ch._send_read_receipt = AsyncMock()
|
||||
ch._handle_message = AsyncMock()
|
||||
client = SimpleNamespace(download_any=AsyncMock())
|
||||
event = _event(
|
||||
message=_Proto(conversation="hi"),
|
||||
sender=_jid("15551234567", "s.whatsapp.net"),
|
||||
)
|
||||
|
||||
await ch._handle_neonize_message(client, event)
|
||||
await ch._handle_neonize_message(client, event)
|
||||
|
||||
ch._send_read_receipt.assert_awaited_once_with(
|
||||
client,
|
||||
event.Info.MessageSource,
|
||||
"m1",
|
||||
)
|
||||
ch._handle_message.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_read_receipt_uses_mark_read_and_swallows_failures(monkeypatch) -> None:
|
||||
receipt_type = _patch_receipt_type(monkeypatch)
|
||||
ch = _make_channel()
|
||||
source = _event(
|
||||
message=_Proto(conversation="hi"),
|
||||
sender=_jid("15551234567", "s.whatsapp.net"),
|
||||
).Info.MessageSource
|
||||
client = SimpleNamespace(
|
||||
mark_read=AsyncMock(),
|
||||
download_any=AsyncMock(),
|
||||
)
|
||||
|
||||
await ch._send_read_receipt(client, source, "m1")
|
||||
|
||||
client.mark_read.assert_awaited_once_with(
|
||||
"m1",
|
||||
chat=source.Chat,
|
||||
sender=source.Sender,
|
||||
receipt=receipt_type.READ,
|
||||
)
|
||||
|
||||
failing_client = SimpleNamespace(
|
||||
mark_read=AsyncMock(side_effect=RuntimeError("boom")),
|
||||
download_any=AsyncMock(),
|
||||
)
|
||||
|
||||
await ch._send_read_receipt(failing_client, source, "m2")
|
||||
|
||||
failing_client.mark_read.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lid_to_phone_cache_resolves_lid_only_messages() -> None:
|
||||
ch = _make_channel()
|
||||
ch._handle_message = AsyncMock()
|
||||
|
||||
await ch._handle_neonize_message(
|
||||
SimpleNamespace(download_any=AsyncMock()),
|
||||
_event(
|
||||
message=_Proto(conversation="first"),
|
||||
message_id="c1",
|
||||
chat=_jid("LID99", "lid"),
|
||||
sender=_jid("LID99", "lid"),
|
||||
sender_alt=_jid("5559999", "s.whatsapp.net"),
|
||||
),
|
||||
)
|
||||
await ch._handle_neonize_message(
|
||||
SimpleNamespace(download_any=AsyncMock()),
|
||||
_event(
|
||||
message=_Proto(conversation="second"),
|
||||
message_id="c2",
|
||||
chat=_jid("LID99", "lid"),
|
||||
sender=_jid("LID99", "lid"),
|
||||
),
|
||||
)
|
||||
|
||||
assert ch._handle_message.await_args_list[1].kwargs["sender_id"] == "5559999"
|
||||
|
||||
|
||||
def test_lid_mappings_from_config() -> None:
|
||||
ch = WhatsAppChannel(
|
||||
{"enabled": True, "lidMappings": {"123456789012345": "15551234567"}},
|
||||
MagicMock(),
|
||||
)
|
||||
|
||||
assert ch._lid_to_phone == {"123456789012345": "15551234567"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_media_is_downloaded_and_forwarded(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setattr(whatsapp_module, "get_media_dir", lambda channel: tmp_path / channel)
|
||||
ch = _make_channel()
|
||||
ch._handle_message = AsyncMock()
|
||||
client = SimpleNamespace(download_any=AsyncMock())
|
||||
message = _Proto(
|
||||
imageMessage=_Proto(
|
||||
caption="look",
|
||||
mimetype="image/jpeg",
|
||||
)
|
||||
)
|
||||
|
||||
await ch._handle_neonize_message(
|
||||
client,
|
||||
_event(message=message, sender_alt=_jid("15551234567", "s.whatsapp.net")),
|
||||
)
|
||||
|
||||
client.download_any.assert_awaited_once()
|
||||
kwargs = ch._handle_message.await_args.kwargs
|
||||
assert kwargs["content"].startswith("look\n[image: ")
|
||||
assert len(kwargs["media"]) == 1
|
||||
assert kwargs["media"][0].endswith(".jpg")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_message_transcribes_and_drops_media_when_successful(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
monkeypatch.setattr(whatsapp_module, "get_media_dir", lambda channel: tmp_path / channel)
|
||||
ch = _make_channel()
|
||||
ch._handle_message = AsyncMock()
|
||||
ch.transcribe_audio = AsyncMock(return_value="Hello from audio")
|
||||
client = SimpleNamespace(download_any=AsyncMock())
|
||||
message = _Proto(audioMessage=_Proto(mimetype="audio/ogg", PTT=True))
|
||||
|
||||
await ch._handle_neonize_message(
|
||||
client,
|
||||
_event(message=message, sender_alt=_jid("15551234567", "s.whatsapp.net")),
|
||||
)
|
||||
|
||||
ch.transcribe_audio.assert_awaited_once()
|
||||
kwargs = ch._handle_message.await_args.kwargs
|
||||
assert kwargs["content"] == "Hello from audio"
|
||||
assert kwargs["media"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthorized_voice_message_does_not_download_or_transcribe(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
monkeypatch.setattr(whatsapp_module, "get_media_dir", lambda channel: tmp_path / channel)
|
||||
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["allowed"]}, MagicMock())
|
||||
ch._started_at = 0
|
||||
ch._handle_message = AsyncMock()
|
||||
ch.transcribe_audio = AsyncMock(return_value="blocked audio")
|
||||
client = SimpleNamespace(download_any=AsyncMock())
|
||||
|
||||
await ch._handle_neonize_message(
|
||||
client,
|
||||
_event(
|
||||
message=_Proto(audioMessage=_Proto(mimetype="audio/ogg", PTT=True)),
|
||||
chat=_jid("blocked", "s.whatsapp.net"),
|
||||
sender=_jid("blocked", "s.whatsapp.net"),
|
||||
),
|
||||
)
|
||||
|
||||
client.download_any.assert_not_awaited()
|
||||
ch.transcribe_audio.assert_not_awaited()
|
||||
ch._handle_message.assert_awaited_once()
|
||||
kwargs = ch._handle_message.await_args.kwargs
|
||||
assert kwargs["sender_id"] == "blocked"
|
||||
assert kwargs["content"] == ""
|
||||
assert kwargs["media"] == []
|
||||
assert kwargs["is_dm"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthorized_dm_uses_base_pairing_flow(monkeypatch) -> None:
|
||||
_patch_neonize_api(monkeypatch)
|
||||
monkeypatch.setattr("nanobot.channels.base.generate_code", lambda _ch, _sid: "ABCD-EFGH")
|
||||
monkeypatch.setattr("nanobot.channels.base.is_approved", lambda _ch, _sid: False)
|
||||
client = SimpleNamespace(send_message=AsyncMock(), download_any=AsyncMock())
|
||||
ch = WhatsAppChannel({"enabled": True, "allowFrom": []}, MagicMock())
|
||||
ch._client = client
|
||||
ch._connected = True
|
||||
ch._started_at = 0
|
||||
|
||||
await ch._handle_neonize_message(
|
||||
client,
|
||||
_event(
|
||||
message=_Proto(conversation="hello"),
|
||||
chat=_jid("blocked", "s.whatsapp.net"),
|
||||
sender=_jid("blocked", "s.whatsapp.net"),
|
||||
),
|
||||
)
|
||||
|
||||
client.download_any.assert_not_awaited()
|
||||
client.send_message.assert_awaited_once()
|
||||
assert client.send_message.await_args.args[0] == ("blocked", "s.whatsapp.net")
|
||||
assert "ABCD-EFGH" in client.send_message.await_args.args[1]
|
||||
|
||||
|
||||
def test_reset_database_removes_sqlite_sidecars(tmp_path) -> None:
|
||||
db = tmp_path / "neonize.db"
|
||||
wal = tmp_path / "neonize.db-wal"
|
||||
shm = tmp_path / "neonize.db-shm"
|
||||
for path in (db, wal, shm):
|
||||
path.write_text("x", encoding="utf-8")
|
||||
|
||||
WhatsAppChannel._reset_database(db)
|
||||
|
||||
assert not db.exists()
|
||||
assert not wal.exists()
|
||||
assert not shm.exists()
|
||||
@@ -0,0 +1,34 @@
|
||||
"""WhatsApp setup validation owned by the channel package."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from nanobot.channels.contracts import ChannelValidationContext
|
||||
from nanobot.channels.validation import check, enabled, official_action, payload, string_value
|
||||
|
||||
|
||||
def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]:
|
||||
checks: list[dict[str, Any]] = []
|
||||
if enabled(values) or string_value(values.get("databasePath")):
|
||||
checks.append(
|
||||
check("local_state", "Local login state", "pass", "Saved local login state was detected.")
|
||||
)
|
||||
return payload("whatsapp", "configured", checks, can_enable=True)
|
||||
checks.append(
|
||||
check(
|
||||
"terminal_login",
|
||||
"Terminal login",
|
||||
"skipped",
|
||||
"This channel uses a terminal QR login flow.",
|
||||
action_url=official_action("whatsapp"),
|
||||
)
|
||||
)
|
||||
return payload(
|
||||
"whatsapp",
|
||||
"needs_setup",
|
||||
checks,
|
||||
missing_fields=["terminal_login"],
|
||||
can_enable=False,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["validate"]
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ChannelUiContribution } from "@/channel-plugins/types";
|
||||
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
|
||||
|
||||
export default {
|
||||
presentation: {
|
||||
displayName: "WhatsApp",
|
||||
initials: "WA",
|
||||
color: "#25D366",
|
||||
logoUrl: "https://www.whatsapp.com/favicon.ico",
|
||||
setup: {
|
||||
mode: "connect",
|
||||
command: "nanobot channels login whatsapp",
|
||||
docsUrl: chatAppGuideUrl("whatsapp"),
|
||||
manualFields: [
|
||||
{ key: "channels.whatsapp.allowFrom" },
|
||||
{ key: "channels.whatsapp.groupPolicy" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies ChannelUiContribution;
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"description": "Use nanobot from WhatsApp conversations.",
|
||||
"requirements": "WhatsApp connection setup and gateway",
|
||||
"setup": {
|
||||
"primaryAction": "Connect WhatsApp",
|
||||
"docsLabel": "Open WhatsApp setup",
|
||||
"officialLabel": "Open WhatsApp help",
|
||||
"tryIt": "After terminal login finishes, send a WhatsApp DM to the connected account.",
|
||||
"summary": "WhatsApp is connected by scanning a QR code from the account that should run the bot.",
|
||||
"steps": [
|
||||
"Run the WhatsApp login command shown below.",
|
||||
"Scan the QR code in the terminal with WhatsApp on your phone.",
|
||||
"Return here after login, enable WhatsApp, then send a direct test message."
|
||||
],
|
||||
"fields": {
|
||||
"allowFrom": {
|
||||
"label": "Allowed contacts",
|
||||
"placeholder": "Phone numbers or WhatsApp IDs"
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "Group behavior",
|
||||
"choices": {
|
||||
"mention": "Mention only",
|
||||
"open": "All messages",
|
||||
"allowlist": "Allowlist"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"description": "Usa nanobot desde conversaciones de WhatsApp.",
|
||||
"requirements": "Configuración de conexión de WhatsApp y gateway",
|
||||
"setup": {
|
||||
"primaryAction": "Conectar WhatsApp",
|
||||
"docsLabel": "Abrir guía de WhatsApp",
|
||||
"officialLabel": "Abrir la ayuda de WhatsApp",
|
||||
"tryIt": "Tras iniciar sesión en la terminal, envía un DM a la cuenta conectada.",
|
||||
"summary": "WhatsApp se conecta escaneando un QR con la cuenta que ejecutará el bot.",
|
||||
"steps": [
|
||||
"Ejecuta el comando de inicio de WhatsApp mostrado abajo.",
|
||||
"Escanea el QR de la terminal con WhatsApp en tu teléfono.",
|
||||
"Vuelve aquí tras iniciar sesión, activa WhatsApp y envía un mensaje directo de prueba."
|
||||
],
|
||||
"fields": {
|
||||
"allowFrom": {
|
||||
"label": "Contactos permitidos",
|
||||
"placeholder": "Números o ID de WhatsApp"
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "Comportamiento en grupos",
|
||||
"choices": {
|
||||
"mention": "Solo menciones",
|
||||
"open": "Todos los mensajes",
|
||||
"allowlist": "Lista permitida"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"description": "Utilisez nanobot depuis les conversations WhatsApp.",
|
||||
"requirements": "Configuration de la connexion WhatsApp et passerelle",
|
||||
"setup": {
|
||||
"primaryAction": "Connecter WhatsApp",
|
||||
"docsLabel": "Ouvrir le guide WhatsApp",
|
||||
"officialLabel": "Ouvrir l’aide WhatsApp",
|
||||
"tryIt": "Après la connexion dans le terminal, envoyez un message privé au compte connecté.",
|
||||
"summary": "WhatsApp se connecte en scannant un QR code avec le compte qui exécutera le bot.",
|
||||
"steps": [
|
||||
"Exécutez la commande de connexion WhatsApp ci-dessous.",
|
||||
"Scannez le QR code du terminal avec WhatsApp sur votre téléphone.",
|
||||
"Revenez ici après la connexion, activez WhatsApp et envoyez un message privé test."
|
||||
],
|
||||
"fields": {
|
||||
"allowFrom": {
|
||||
"label": "Contacts autorisés",
|
||||
"placeholder": "Numéros ou ID WhatsApp"
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "Comportement en groupe",
|
||||
"choices": {
|
||||
"mention": "Mentions uniquement",
|
||||
"open": "Tous les messages",
|
||||
"allowlist": "Liste d’autorisation"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"description": "Gunakan nanobot dari percakapan WhatsApp.",
|
||||
"requirements": "Setup koneksi WhatsApp dan gateway",
|
||||
"setup": {
|
||||
"primaryAction": "Hubungkan WhatsApp",
|
||||
"docsLabel": "Buka panduan WhatsApp",
|
||||
"officialLabel": "Buka bantuan WhatsApp",
|
||||
"tryIt": "Setelah login terminal selesai, kirim DM ke akun yang terhubung.",
|
||||
"summary": "WhatsApp terhubung dengan memindai kode QR dari akun yang akan menjalankan bot.",
|
||||
"steps": [
|
||||
"Jalankan perintah login WhatsApp yang ditampilkan di bawah.",
|
||||
"Pindai kode QR di terminal dengan WhatsApp di ponsel.",
|
||||
"Kembali ke sini setelah login, aktifkan WhatsApp, lalu kirim pesan langsung uji."
|
||||
],
|
||||
"fields": {
|
||||
"allowFrom": {
|
||||
"label": "Kontak yang diizinkan",
|
||||
"placeholder": "Nomor telepon atau ID WhatsApp"
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "Perilaku grup",
|
||||
"choices": {
|
||||
"mention": "Hanya sebutan",
|
||||
"open": "Semua pesan",
|
||||
"allowlist": "Daftar izin"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"description": "WhatsApp の会話から nanobot を利用します。",
|
||||
"requirements": "WhatsApp 接続設定とゲートウェイ",
|
||||
"setup": {
|
||||
"primaryAction": "WhatsApp に接続",
|
||||
"docsLabel": "WhatsApp 設定ガイドを開く",
|
||||
"officialLabel": "WhatsApp ヘルプを開く",
|
||||
"tryIt": "ターミナルでのログイン後、接続したアカウントに WhatsApp の DM を送信します。",
|
||||
"summary": "ボットを動かす WhatsApp アカウントで QR コードを読み取って接続します。",
|
||||
"steps": [
|
||||
"下に表示された WhatsApp ログインコマンドを実行します。",
|
||||
"スマートフォンの WhatsApp でターミナルの QR コードを読み取ります。",
|
||||
"ログイン後に戻り、WhatsApp を有効にして DM でテストします。"
|
||||
],
|
||||
"fields": {
|
||||
"allowFrom": {
|
||||
"label": "許可する連絡先",
|
||||
"placeholder": "電話番号または WhatsApp ID"
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "グループでの動作",
|
||||
"choices": {
|
||||
"mention": "メンションのみ",
|
||||
"open": "すべてのメッセージ",
|
||||
"allowlist": "許可リスト"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"description": "WhatsApp 대화에서 nanobot을 사용합니다.",
|
||||
"requirements": "WhatsApp 연결 설정 및 게이트웨이",
|
||||
"setup": {
|
||||
"primaryAction": "WhatsApp 연결",
|
||||
"docsLabel": "WhatsApp 설정 가이드 열기",
|
||||
"officialLabel": "WhatsApp 도움말 열기",
|
||||
"tryIt": "터미널 로그인이 끝나면 연결된 계정으로 WhatsApp DM을 보내세요.",
|
||||
"summary": "봇을 실행할 WhatsApp 계정으로 QR 코드를 스캔해 연결합니다.",
|
||||
"steps": [
|
||||
"아래 표시된 WhatsApp 로그인 명령을 실행하세요.",
|
||||
"휴대폰 WhatsApp으로 터미널의 QR 코드를 스캔하세요.",
|
||||
"로그인 후 여기로 돌아와 WhatsApp을 활성화하고 DM으로 테스트하세요."
|
||||
],
|
||||
"fields": {
|
||||
"allowFrom": {
|
||||
"label": "허용된 연락처",
|
||||
"placeholder": "전화번호 또는 WhatsApp ID"
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "그룹 동작",
|
||||
"choices": {
|
||||
"mention": "멘션만",
|
||||
"open": "모든 메시지",
|
||||
"allowlist": "허용 목록"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"description": "Use o nanobot em conversas do WhatsApp.",
|
||||
"requirements": "Configuração da conexão do WhatsApp e gateway",
|
||||
"setup": {
|
||||
"primaryAction": "Conectar WhatsApp",
|
||||
"docsLabel": "Abrir guia do WhatsApp",
|
||||
"officialLabel": "Abrir ajuda do WhatsApp",
|
||||
"tryIt": "Após o login no terminal, envie uma DM à conta conectada.",
|
||||
"summary": "O WhatsApp conecta ao escanear um QR code com a conta que executará o bot.",
|
||||
"steps": [
|
||||
"Execute o comando de login do WhatsApp mostrado abaixo.",
|
||||
"Escaneie o QR code do terminal com o WhatsApp no celular.",
|
||||
"Volte aqui após o login, ative o WhatsApp e envie uma mensagem direta de teste."
|
||||
],
|
||||
"fields": {
|
||||
"allowFrom": {
|
||||
"label": "Contatos permitidos",
|
||||
"placeholder": "Números ou IDs do WhatsApp"
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "Comportamento em grupos",
|
||||
"choices": {
|
||||
"mention": "Somente menções",
|
||||
"open": "Todas as mensagens",
|
||||
"allowlist": "Lista de permissão"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"description": "Sử dụng nanobot từ các cuộc trò chuyện WhatsApp.",
|
||||
"requirements": "Cài đặt kết nối WhatsApp và gateway",
|
||||
"setup": {
|
||||
"primaryAction": "Kết nối WhatsApp",
|
||||
"docsLabel": "Mở hướng dẫn WhatsApp",
|
||||
"officialLabel": "Mở trợ giúp WhatsApp",
|
||||
"tryIt": "Sau khi đăng nhập terminal, gửi tin nhắn riêng đến tài khoản đã kết nối.",
|
||||
"summary": "WhatsApp kết nối bằng cách quét mã QR từ tài khoản sẽ chạy bot.",
|
||||
"steps": [
|
||||
"Chạy lệnh đăng nhập WhatsApp hiển thị bên dưới.",
|
||||
"Quét mã QR trong terminal bằng WhatsApp trên điện thoại.",
|
||||
"Sau khi đăng nhập, quay lại đây, bật WhatsApp và gửi tin nhắn riêng thử."
|
||||
],
|
||||
"fields": {
|
||||
"allowFrom": {
|
||||
"label": "Liên hệ được phép",
|
||||
"placeholder": "Số điện thoại hoặc ID WhatsApp"
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "Hành vi trong nhóm",
|
||||
"choices": {
|
||||
"mention": "Chỉ khi được nhắc",
|
||||
"open": "Mọi tin nhắn",
|
||||
"allowlist": "Danh sách cho phép"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"description": "在 WhatsApp 会话中使用 nanobot。",
|
||||
"requirements": "WhatsApp 连接配置和网关",
|
||||
"setup": {
|
||||
"primaryAction": "连接 WhatsApp",
|
||||
"docsLabel": "打开 WhatsApp 配置指南",
|
||||
"officialLabel": "打开 WhatsApp 帮助",
|
||||
"tryIt": "终端登录完成后,向已连接的账户发送一条 WhatsApp 私信。",
|
||||
"summary": "使用要运行机器人的 WhatsApp 账户扫描二维码即可完成连接。",
|
||||
"steps": [
|
||||
"运行下方显示的 WhatsApp 登录命令。",
|
||||
"用手机 WhatsApp 扫描终端中的二维码。",
|
||||
"登录后返回这里,启用 WhatsApp,然后发送一条私信测试。"
|
||||
],
|
||||
"fields": {
|
||||
"allowFrom": {
|
||||
"label": "允许的联系人",
|
||||
"placeholder": "电话号码或 WhatsApp ID"
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "群组行为",
|
||||
"choices": {
|
||||
"mention": "仅提及时",
|
||||
"open": "所有消息",
|
||||
"allowlist": "白名单"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"description": "在 WhatsApp 對話中使用 nanobot。",
|
||||
"requirements": "WhatsApp 連接設定和閘道",
|
||||
"setup": {
|
||||
"primaryAction": "連接 WhatsApp",
|
||||
"docsLabel": "開啟 WhatsApp 設定指南",
|
||||
"officialLabel": "開啟 WhatsApp 說明",
|
||||
"tryIt": "終端機登入完成後,向已連接的帳戶傳送一則 WhatsApp 私訊。",
|
||||
"summary": "使用要執行機器人的 WhatsApp 帳戶掃描二維碼即可完成連接。",
|
||||
"steps": [
|
||||
"執行下方顯示的 WhatsApp 登入指令。",
|
||||
"用手機 WhatsApp 掃描終端機中的二維碼。",
|
||||
"登入後返回這裡,啟用 WhatsApp,然後傳送一則私訊測試。"
|
||||
],
|
||||
"fields": {
|
||||
"allowFrom": {
|
||||
"label": "允許的聯絡人",
|
||||
"placeholder": "電話號碼或 WhatsApp ID"
|
||||
},
|
||||
"groupPolicy": {
|
||||
"label": "群組行為",
|
||||
"choices": {
|
||||
"mention": "僅提及時",
|
||||
"open": "所有訊息",
|
||||
"allowlist": "允許清單"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user