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:
chengyongru
2026-07-19 23:30:49 +08:00
committed by GitHub
parent 7aaac37bca
commit 462a0dfb0f
388 changed files with 17093 additions and 5110 deletions
+1
View File
@@ -0,0 +1 @@
"""WhatsApp channel package."""
+35
View File
@@ -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",
)
+721
View File
@@ -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()
+25
View File
@@ -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()
+34
View File
@@ -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"]
+20
View File
@@ -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 laide 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 dautorisation"
}
}
}
}
}
@@ -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": "允許清單"
}
}
}
}
}