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 @@
|
||||
"""Personal WeChat channel package."""
|
||||
@@ -0,0 +1,261 @@
|
||||
"""WeChat-owned interactive connection flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from nanobot.channels.connect import ChannelConnectError, QueryParams, query_first
|
||||
from nanobot.config.loader import load_config
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WeixinConnectSession:
|
||||
id: str
|
||||
qrcode_id: str
|
||||
qr_url: str
|
||||
channel: Any
|
||||
current_poll_base_url: str
|
||||
refresh_count: int
|
||||
created_wall: float
|
||||
deadline: float
|
||||
last_error: str | None = None
|
||||
|
||||
|
||||
class WeixinConnectStore:
|
||||
"""In-memory WeChat QR login sessions for the WebUI."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._sessions: dict[str, WeixinConnectSession] = {}
|
||||
|
||||
async def handle(self, action: str, query: QueryParams) -> dict[str, Any]:
|
||||
"""Handle one generic settings connection action."""
|
||||
if action == "start":
|
||||
force = (query_first(query, "force") or "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}
|
||||
return await self.start(force=force)
|
||||
|
||||
session_id = (query_first(query, "session_id") or "").strip()
|
||||
if not session_id:
|
||||
raise ChannelConnectError("missing WeChat connect session")
|
||||
if action == "poll":
|
||||
return await self.poll(session_id)
|
||||
if action == "cancel":
|
||||
return await self.cancel(session_id)
|
||||
raise ChannelConnectError(f"unsupported WeChat connect action: {action}", status=404)
|
||||
|
||||
async def start(self, *, force: bool = False) -> dict[str, Any]:
|
||||
await self._cleanup()
|
||||
|
||||
channel = self._build_channel()
|
||||
if force:
|
||||
# Preserve the working account until a replacement scan succeeds.
|
||||
channel._token = ""
|
||||
channel._get_updates_buf = ""
|
||||
elif channel._load_state():
|
||||
return {
|
||||
"session_id": "",
|
||||
"status": "succeeded",
|
||||
"message": "WeChat is already connected.",
|
||||
"interval_ms": 2000,
|
||||
}
|
||||
|
||||
channel._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(60, connect=30),
|
||||
follow_redirects=True,
|
||||
)
|
||||
channel._running = True
|
||||
try:
|
||||
qrcode_id, qr_url = await channel._fetch_qr_code()
|
||||
except Exception as exc:
|
||||
await self._close_channel(channel)
|
||||
raise ChannelConnectError(
|
||||
f"Unable to start WeChat QR login: {exc}",
|
||||
status=502,
|
||||
) from exc
|
||||
|
||||
session_id = secrets.token_urlsafe(18)
|
||||
now_wall = time.time()
|
||||
self._sessions[session_id] = WeixinConnectSession(
|
||||
id=session_id,
|
||||
qrcode_id=qrcode_id,
|
||||
qr_url=qr_url,
|
||||
channel=channel,
|
||||
current_poll_base_url=channel.config.base_url,
|
||||
refresh_count=0,
|
||||
created_wall=now_wall,
|
||||
deadline=time.monotonic() + 600,
|
||||
)
|
||||
return self._start_payload(self._sessions[session_id])
|
||||
|
||||
async def poll(self, session_id: str) -> dict[str, Any]:
|
||||
await self._cleanup()
|
||||
session = self._sessions.get(session_id)
|
||||
if session is None:
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"status": "expired",
|
||||
"message": "This WeChat login has expired. Start again.",
|
||||
}
|
||||
|
||||
try:
|
||||
status_data = await session.channel._api_get_with_base(
|
||||
base_url=session.current_poll_base_url,
|
||||
endpoint="ilink/bot/get_qrcode_status",
|
||||
params={"qrcode": session.qrcode_id},
|
||||
auth=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
if session.channel._is_retryable_qr_poll_error(exc):
|
||||
session.last_error = str(exc)
|
||||
return self._pending_payload(session)
|
||||
self._sessions.pop(session_id, None)
|
||||
await self._close_channel(session.channel)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"status": "failed",
|
||||
"message": f"WeChat QR login failed: {exc}",
|
||||
}
|
||||
|
||||
if not isinstance(status_data, dict):
|
||||
return self._pending_payload(session)
|
||||
|
||||
status = status_data.get("status", "")
|
||||
if status == "confirmed":
|
||||
token = str(status_data.get("bot_token", "") or "")
|
||||
if not token:
|
||||
self._sessions.pop(session_id, None)
|
||||
await self._close_channel(session.channel)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"status": "failed",
|
||||
"message": "WeChat confirmed the scan but returned no token.",
|
||||
}
|
||||
base_url = str(status_data.get("baseurl", "") or "")
|
||||
session.channel._token = token
|
||||
if base_url:
|
||||
session.channel.config.base_url = base_url
|
||||
session.channel._save_state()
|
||||
self._sessions.pop(session_id, None)
|
||||
await self._close_channel(session.channel)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"status": "succeeded",
|
||||
"message": "WeChat is connected.",
|
||||
"account": str(status_data.get("ilink_user_id", "") or ""),
|
||||
}
|
||||
|
||||
if status == "scaned_but_redirect":
|
||||
redirect_host = str(status_data.get("redirect_host", "") or "").strip()
|
||||
if redirect_host:
|
||||
session.current_poll_base_url = (
|
||||
redirect_host
|
||||
if redirect_host.startswith(("http://", "https://"))
|
||||
else f"https://{redirect_host}"
|
||||
)
|
||||
return self._pending_payload(session)
|
||||
|
||||
if status == "expired":
|
||||
from nanobot.channels.weixin.runtime import MAX_QR_REFRESH_COUNT
|
||||
|
||||
session.refresh_count += 1
|
||||
if session.refresh_count > MAX_QR_REFRESH_COUNT:
|
||||
self._sessions.pop(session_id, None)
|
||||
await self._close_channel(session.channel)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"status": "expired",
|
||||
"message": "This WeChat QR code expired. Start again.",
|
||||
}
|
||||
try:
|
||||
session.qrcode_id, session.qr_url = await session.channel._fetch_qr_code()
|
||||
except Exception as exc:
|
||||
self._sessions.pop(session_id, None)
|
||||
await self._close_channel(session.channel)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"status": "failed",
|
||||
"message": f"Could not refresh WeChat QR code: {exc}",
|
||||
}
|
||||
session.current_poll_base_url = session.channel.config.base_url
|
||||
return self._pending_payload(session)
|
||||
|
||||
return self._pending_payload(session)
|
||||
|
||||
async def cancel(self, session_id: str) -> dict[str, Any]:
|
||||
session = self._sessions.pop(session_id, None)
|
||||
if session is not None:
|
||||
await self._close_channel(session.channel)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"status": "cancelled",
|
||||
"message": "WeChat login cancelled.",
|
||||
}
|
||||
|
||||
async def _cleanup(self) -> None:
|
||||
now = time.monotonic()
|
||||
expired = [
|
||||
session_id
|
||||
for session_id, session in self._sessions.items()
|
||||
if now >= session.deadline
|
||||
]
|
||||
for session_id in expired:
|
||||
session = self._sessions.pop(session_id, None)
|
||||
if session is not None:
|
||||
await self._close_channel(session.channel)
|
||||
|
||||
@staticmethod
|
||||
def _build_channel() -> Any:
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.weixin.runtime import WeixinChannel
|
||||
|
||||
section = getattr(load_config().channels, "weixin", None)
|
||||
if hasattr(section, "model_dump"):
|
||||
config = section.model_dump(mode="json", by_alias=True)
|
||||
elif isinstance(section, dict):
|
||||
config = dict(section)
|
||||
else:
|
||||
config = {}
|
||||
return WeixinChannel(config, MessageBus())
|
||||
|
||||
@staticmethod
|
||||
async def _close_channel(channel: Any) -> None:
|
||||
channel._running = False
|
||||
client = getattr(channel, "_client", None)
|
||||
if client is not None:
|
||||
with suppress(Exception):
|
||||
await client.aclose()
|
||||
channel._client = None
|
||||
|
||||
@staticmethod
|
||||
def _start_payload(session: WeixinConnectSession) -> dict[str, Any]:
|
||||
return {
|
||||
"session_id": session.id,
|
||||
"status": "pending",
|
||||
"qr_url": session.qr_url,
|
||||
"interval_ms": 2000,
|
||||
"expires_at_ms": int((session.created_wall + 600) * 1000),
|
||||
"message": "Scan with WeChat to connect.",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _pending_payload(session: WeixinConnectSession) -> dict[str, Any]:
|
||||
return {
|
||||
"session_id": session.id,
|
||||
"status": "pending",
|
||||
"qr_url": session.qr_url,
|
||||
"interval_ms": 2000,
|
||||
"expires_at_ms": int((session.created_wall + 600) * 1000),
|
||||
"message": "Waiting for WeChat scan.",
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["WeixinConnectStore"]
|
||||
@@ -0,0 +1,31 @@
|
||||
"""WeChat management contract."""
|
||||
|
||||
from nanobot.channels._manifest import field, required
|
||||
from nanobot.channels.contracts import ChannelManagementSpec, ChannelSetupSpec
|
||||
from nanobot.channels.plugin import ChannelPlugin
|
||||
from nanobot.channels.weixin.state import local_state_present
|
||||
from nanobot.channels.weixin.validation import validate
|
||||
|
||||
SETUP_SPEC = ChannelSetupSpec(
|
||||
fields={
|
||||
"token": field("secret"),
|
||||
"allowFrom": field("list"),
|
||||
},
|
||||
required=(required("token"),),
|
||||
official_url="https://weixin.qq.com/",
|
||||
validator=validate,
|
||||
)
|
||||
|
||||
PLUGIN = ChannelPlugin(
|
||||
name="weixin",
|
||||
display_name="WeChat",
|
||||
runtime=f"{__package__}.runtime:WeixinChannel",
|
||||
connector=f"{__package__}.connect:WeixinConnectStore",
|
||||
setup=SETUP_SPEC,
|
||||
management=ChannelManagementSpec(local_state_present=local_state_present),
|
||||
dependencies=(
|
||||
"qrcode[pil]>=8.0",
|
||||
"pycryptodome>=3.20.0",
|
||||
),
|
||||
webui="webui/index.tsx",
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
"""WeChat-owned persisted login-state detection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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_dir = channel_field_value(section, "stateDir")
|
||||
state_dir = (
|
||||
Path(str(configured_dir)).expanduser()
|
||||
if configured_dir
|
||||
else get_config_path().parent / "weixin"
|
||||
)
|
||||
try:
|
||||
payload = json.loads((state_dir / "account.json").read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, TypeError):
|
||||
return False
|
||||
return bool(str(payload.get("token") or "").strip())
|
||||
|
||||
|
||||
__all__ = ["local_state_present"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Weixin channel package."""
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.channels.weixin.connect import WeixinConnectStore
|
||||
from nanobot.channels.weixin.runtime import WeixinChannel
|
||||
from nanobot.config.loader import save_config
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weixin_connect_store_saves_confirmed_qr_login(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
state_dir = tmp_path / "weixin-state"
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(
|
||||
Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}),
|
||||
config_path,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||
return "qr-1", "https://qr.example/1"
|
||||
|
||||
async def fake_api_get_with_base(
|
||||
self: WeixinChannel,
|
||||
*,
|
||||
base_url: str,
|
||||
endpoint: str,
|
||||
params: dict[str, Any],
|
||||
auth: bool,
|
||||
) -> dict[str, str]:
|
||||
assert base_url == "https://ilinkai.weixin.qq.com"
|
||||
assert endpoint == "ilink/bot/get_qrcode_status"
|
||||
assert params == {"qrcode": "qr-1"}
|
||||
assert auth is False
|
||||
return {
|
||||
"status": "confirmed",
|
||||
"bot_token": "wx-token",
|
||||
"baseurl": "https://weixin.example",
|
||||
"ilink_user_id": "wx-user",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
|
||||
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
|
||||
|
||||
store = WeixinConnectStore()
|
||||
|
||||
started = await store.start()
|
||||
assert started["status"] == "pending"
|
||||
assert started["qr_url"] == "https://qr.example/1"
|
||||
|
||||
completed = await store.poll(started["session_id"])
|
||||
assert completed["status"] == "succeeded"
|
||||
assert completed["account"] == "wx-user"
|
||||
|
||||
saved = json.loads((state_dir / "account.json").read_text())
|
||||
assert saved["token"] == "wx-token"
|
||||
assert saved["base_url"] == "https://weixin.example"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weixin_reconnect_keeps_existing_account_until_scan_succeeds(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
state_dir = tmp_path / "weixin-state"
|
||||
state_dir.mkdir()
|
||||
existing = {
|
||||
"token": "working-token",
|
||||
"base_url": "https://working.weixin.example",
|
||||
"context_tokens": {"user-1": "context-1"},
|
||||
}
|
||||
state_file = state_dir / "account.json"
|
||||
state_file.write_text(json.dumps(existing), encoding="utf-8")
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(
|
||||
Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}),
|
||||
config_path,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||
return "qr-reconnect", "https://qr.example/reconnect"
|
||||
|
||||
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
|
||||
|
||||
store = WeixinConnectStore()
|
||||
started = await store.start(force=True)
|
||||
|
||||
assert json.loads(state_file.read_text(encoding="utf-8")) == existing
|
||||
cancelled = await store.cancel(started["session_id"])
|
||||
assert cancelled["status"] == "cancelled"
|
||||
assert json.loads(state_file.read_text(encoding="utf-8")) == existing
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
"""WeChat 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("token")):
|
||||
checks.append(
|
||||
check("local_state", "Local login state", "pass", "Saved local login state was detected.")
|
||||
)
|
||||
return payload("weixin", "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("weixin"),
|
||||
)
|
||||
)
|
||||
return payload(
|
||||
"weixin",
|
||||
"needs_setup",
|
||||
checks,
|
||||
missing_fields=["terminal_login"],
|
||||
can_enable=False,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["validate"]
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { channelTranslator } from "@/channel-plugins/i18n";
|
||||
import type { ChannelPluginConnectFlowProps } from "@/channel-plugins/types";
|
||||
import { ChannelQrConnectFlow } from "@/components/settings/channels/ChannelQrConnectFlow";
|
||||
|
||||
export function WeixinConnectFlow({
|
||||
token,
|
||||
idleLabel,
|
||||
connectRequestId,
|
||||
onFeaturesUpdate,
|
||||
}: ChannelPluginConnectFlowProps) {
|
||||
const { t } = useTranslation();
|
||||
const tx = channelTranslator(t, "weixin");
|
||||
return (
|
||||
<ChannelQrConnectFlow
|
||||
token={token}
|
||||
channelName="weixin"
|
||||
idleLabel={idleLabel}
|
||||
connectRequestId={connectRequestId}
|
||||
forceOnRepeat
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
labels={{
|
||||
qrAlt: tx("custom.qrAlt", "WeChat login QR code"),
|
||||
scanTitle: tx("custom.scanTitle", "Scan with WeChat"),
|
||||
scanDescription: tx(
|
||||
"custom.scanDescription",
|
||||
"Use WeChat on your phone to scan this code. nanobot saves the account state locally after login.",
|
||||
),
|
||||
waiting: tx("custom.waiting", "Waiting for WeChat scan..."),
|
||||
connected: tx("custom.connected", "WeChat is connected."),
|
||||
stopped: tx("custom.stopped", "WeChat login stopped."),
|
||||
connecting: tx("custom.connecting", "Connecting..."),
|
||||
scanAgain: t("settings.channels.scanAgain", { defaultValue: "Scan again" }),
|
||||
connect: t("settings.channels.connect", { defaultValue: "Connect" }),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ChannelUiContribution } from "@/channel-plugins/types";
|
||||
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
|
||||
|
||||
import { WeixinConnectFlow } from "./WeixinConnectFlow";
|
||||
|
||||
export default {
|
||||
ConnectFlow: WeixinConnectFlow,
|
||||
canConnectBeforeConfigured: true,
|
||||
aliases: {
|
||||
wechat: {},
|
||||
},
|
||||
presentation: {
|
||||
displayName: "WeChat",
|
||||
initials: "WX",
|
||||
color: "#07C160",
|
||||
logoUrl: "https://weixin.qq.com/favicon.ico",
|
||||
setup: {
|
||||
mode: "connect",
|
||||
command: "nanobot channels login weixin",
|
||||
docsUrl: chatAppGuideUrl("wechat"),
|
||||
manualFields: [
|
||||
{ key: "channels.weixin.allowFrom" },
|
||||
{ key: "channels.weixin.token" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies ChannelUiContribution;
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"description": "Use nanobot from WeChat conversations.",
|
||||
"requirements": "WeChat channel setup and gateway",
|
||||
"setup": {
|
||||
"primaryAction": "Connect WeChat",
|
||||
"docsLabel": "Open WeChat setup",
|
||||
"officialLabel": "Open WeChat",
|
||||
"tryIt": "After the QR login finishes, send a WeChat DM to the connected account.",
|
||||
"summary": "WeChat signs in with a QR code and saves the account state locally.",
|
||||
"steps": [
|
||||
"Click Connect and scan the QR code with WeChat on your phone.",
|
||||
"Keep the local gateway running while WeChat receives messages.",
|
||||
"Send a direct test message to confirm the account is connected."
|
||||
],
|
||||
"fields": {
|
||||
"allowFrom": {
|
||||
"label": "Allowed users",
|
||||
"placeholder": "User IDs, comma separated"
|
||||
},
|
||||
"token": {
|
||||
"label": "Token",
|
||||
"placeholder": "Saved by QR login"
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"qrAlt": "WeChat login QR code",
|
||||
"scanTitle": "Scan with WeChat",
|
||||
"scanDescription": "Use WeChat on your phone to scan this code. nanobot saves the account state locally after login.",
|
||||
"waiting": "Waiting for WeChat scan...",
|
||||
"connected": "WeChat is connected.",
|
||||
"stopped": "WeChat login stopped.",
|
||||
"connecting": "Connecting..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"description": "Usa nanobot desde conversaciones de WeChat.",
|
||||
"requirements": "Configuración del canal WeChat y gateway",
|
||||
"setup": {
|
||||
"primaryAction": "Conectar WeChat",
|
||||
"docsLabel": "Abrir guía de WeChat",
|
||||
"officialLabel": "Abrir WeChat",
|
||||
"tryIt": "Tras iniciar sesión por QR, envía un DM al usuario conectado.",
|
||||
"summary": "WeChat inicia sesión con un QR y guarda el estado de la cuenta localmente.",
|
||||
"steps": [
|
||||
"Haz clic en Conectar y escanea el QR con WeChat.",
|
||||
"Mantén el gateway local activo mientras WeChat recibe mensajes.",
|
||||
"Envía un mensaje directo de prueba para confirmar la conexión."
|
||||
],
|
||||
"fields": {
|
||||
"allowFrom": {
|
||||
"label": "Usuarios permitidos",
|
||||
"placeholder": "ID de usuario separados por comas"
|
||||
},
|
||||
"token": {
|
||||
"label": "Token",
|
||||
"placeholder": "Guardado al iniciar sesión por QR"
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"qrAlt": "Código QR de inicio de WeChat",
|
||||
"scanTitle": "Escanea con WeChat",
|
||||
"scanDescription": "Escanea con WeChat en tu teléfono. nanobot guarda el estado localmente después del inicio.",
|
||||
"waiting": "Esperando el escaneo de WeChat...",
|
||||
"connected": "WeChat está conectado.",
|
||||
"stopped": "Inicio de WeChat detenido.",
|
||||
"connecting": "Conectando..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"description": "Utilisez nanobot depuis les conversations WeChat.",
|
||||
"requirements": "Configuration du canal WeChat et passerelle",
|
||||
"setup": {
|
||||
"primaryAction": "Connecter WeChat",
|
||||
"docsLabel": "Ouvrir le guide WeChat",
|
||||
"officialLabel": "Ouvrir WeChat",
|
||||
"tryIt": "Après la connexion par QR code, envoyez un message privé au compte connecté.",
|
||||
"summary": "WeChat se connecte par QR code et enregistre localement l’état du compte.",
|
||||
"steps": [
|
||||
"Cliquez sur Connecter et scannez le QR code avec WeChat.",
|
||||
"Gardez la passerelle locale active pendant la réception des messages.",
|
||||
"Envoyez un message privé test pour confirmer la connexion."
|
||||
],
|
||||
"fields": {
|
||||
"allowFrom": {
|
||||
"label": "Utilisateurs autorisés",
|
||||
"placeholder": "ID utilisateur séparés par des virgules"
|
||||
},
|
||||
"token": {
|
||||
"label": "Jeton",
|
||||
"placeholder": "Enregistré après la connexion QR"
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"qrAlt": "QR code de connexion WeChat",
|
||||
"scanTitle": "Scanner avec WeChat",
|
||||
"scanDescription": "Scannez ce code avec WeChat. nanobot enregistre localement l’état du compte après la connexion.",
|
||||
"waiting": "En attente du scan WeChat...",
|
||||
"connected": "WeChat est connecté.",
|
||||
"stopped": "Connexion WeChat arrêtée.",
|
||||
"connecting": "Connexion..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"description": "Gunakan nanobot dari percakapan WeChat.",
|
||||
"requirements": "Setup channel WeChat dan gateway",
|
||||
"setup": {
|
||||
"primaryAction": "Hubungkan WeChat",
|
||||
"docsLabel": "Buka panduan WeChat",
|
||||
"officialLabel": "Buka WeChat",
|
||||
"tryIt": "Setelah login QR, kirim DM ke akun yang terhubung.",
|
||||
"summary": "WeChat login dengan kode QR dan menyimpan status akun secara lokal.",
|
||||
"steps": [
|
||||
"Klik Hubungkan dan pindai QR dengan WeChat.",
|
||||
"Biarkan gateway lokal berjalan saat WeChat menerima pesan.",
|
||||
"Kirim pesan langsung uji untuk memastikan akun terhubung."
|
||||
],
|
||||
"fields": {
|
||||
"allowFrom": {
|
||||
"label": "Pengguna yang diizinkan",
|
||||
"placeholder": "ID pengguna, dipisahkan koma"
|
||||
},
|
||||
"token": {
|
||||
"label": "Token",
|
||||
"placeholder": "Disimpan saat login QR"
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"qrAlt": "Kode QR login WeChat",
|
||||
"scanTitle": "Pindai dengan WeChat",
|
||||
"scanDescription": "Pindai dengan WeChat di ponsel. nanobot menyimpan status akun secara lokal setelah login.",
|
||||
"waiting": "Menunggu pemindaian WeChat...",
|
||||
"connected": "WeChat sudah terhubung.",
|
||||
"stopped": "Login WeChat dihentikan.",
|
||||
"connecting": "Menghubungkan..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"description": "WeChat の会話から nanobot を利用します。",
|
||||
"requirements": "WeChat チャンネル設定とゲートウェイ",
|
||||
"setup": {
|
||||
"primaryAction": "WeChat に接続",
|
||||
"docsLabel": "WeChat 設定ガイドを開く",
|
||||
"officialLabel": "WeChat を開く",
|
||||
"tryIt": "QR ログイン後、接続したアカウントに WeChat の DM を送信します。",
|
||||
"summary": "WeChat は QR コードでログインし、アカウント状態をローカルに保存します。",
|
||||
"steps": [
|
||||
"接続をクリックし、スマートフォンの WeChat で QR コードを読み取ります。",
|
||||
"WeChat がメッセージを受信する間、ローカルゲートウェイを起動しておきます。",
|
||||
"DM でテストし、アカウントの接続を確認します。"
|
||||
],
|
||||
"fields": {
|
||||
"allowFrom": {
|
||||
"label": "許可するユーザー",
|
||||
"placeholder": "ユーザー ID(カンマ区切り)"
|
||||
},
|
||||
"token": {
|
||||
"label": "トークン",
|
||||
"placeholder": "QR ログインで保存"
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"qrAlt": "WeChat ログイン QR コード",
|
||||
"scanTitle": "WeChat でスキャン",
|
||||
"scanDescription": "スマートフォンの WeChat でスキャンしてください。ログイン後、nanobot が状態をローカルに保存します。",
|
||||
"waiting": "WeChat のスキャンを待っています...",
|
||||
"connected": "WeChat に接続しました。",
|
||||
"stopped": "WeChat ログインを停止しました。",
|
||||
"connecting": "接続中..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"description": "WeChat 대화에서 nanobot을 사용합니다.",
|
||||
"requirements": "WeChat 채널 설정 및 게이트웨이",
|
||||
"setup": {
|
||||
"primaryAction": "WeChat 연결",
|
||||
"docsLabel": "WeChat 설정 가이드 열기",
|
||||
"officialLabel": "WeChat 열기",
|
||||
"tryIt": "QR 로그인 후 연결된 계정으로 WeChat DM을 보내세요.",
|
||||
"summary": "WeChat은 QR 코드로 로그인하고 계정 상태를 로컬에 저장합니다.",
|
||||
"steps": [
|
||||
"연결을 클릭하고 휴대폰 WeChat으로 QR 코드를 스캔하세요.",
|
||||
"WeChat이 메시지를 받는 동안 로컬 게이트웨이를 실행해 두세요.",
|
||||
"DM으로 테스트해 계정 연결을 확인하세요."
|
||||
],
|
||||
"fields": {
|
||||
"allowFrom": {
|
||||
"label": "허용된 사용자",
|
||||
"placeholder": "사용자 ID, 쉼표로 구분"
|
||||
},
|
||||
"token": {
|
||||
"label": "토큰",
|
||||
"placeholder": "QR 로그인으로 저장됨"
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"qrAlt": "WeChat 로그인 QR 코드",
|
||||
"scanTitle": "WeChat으로 스캔",
|
||||
"scanDescription": "휴대폰 WeChat으로 스캔하세요. 로그인 후 nanobot이 계정 상태를 로컬에 저장합니다.",
|
||||
"waiting": "WeChat 스캔을 기다리는 중...",
|
||||
"connected": "WeChat이 연결되었습니다.",
|
||||
"stopped": "WeChat 로그인이 중지되었습니다.",
|
||||
"connecting": "연결 중..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"description": "Use o nanobot em conversas do WeChat.",
|
||||
"requirements": "Configuração do canal WeChat e gateway",
|
||||
"setup": {
|
||||
"primaryAction": "Conectar WeChat",
|
||||
"docsLabel": "Abrir guia do WeChat",
|
||||
"officialLabel": "Abrir WeChat",
|
||||
"tryIt": "Após o login por QR, envie uma DM à conta conectada.",
|
||||
"summary": "O WeChat entra por QR code e salva o estado da conta localmente.",
|
||||
"steps": [
|
||||
"Clique em Conectar e escaneie o QR com o WeChat.",
|
||||
"Mantenha o gateway local ativo enquanto o WeChat recebe mensagens.",
|
||||
"Envie uma mensagem direta de teste para confirmar a conexão."
|
||||
],
|
||||
"fields": {
|
||||
"allowFrom": {
|
||||
"label": "Usuários permitidos",
|
||||
"placeholder": "IDs de usuário separados por vírgulas"
|
||||
},
|
||||
"token": {
|
||||
"label": "Token",
|
||||
"placeholder": "Salvo pelo login via QR"
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"qrAlt": "QR code de login do WeChat",
|
||||
"scanTitle": "Escaneie com o WeChat",
|
||||
"scanDescription": "Escaneie com o WeChat no celular. O nanobot salva o estado localmente após o login.",
|
||||
"waiting": "Aguardando leitura do WeChat...",
|
||||
"connected": "WeChat está conectado.",
|
||||
"stopped": "Login do WeChat interrompido.",
|
||||
"connecting": "Conectando..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"description": "Sử dụng nanobot từ các cuộc trò chuyện WeChat.",
|
||||
"requirements": "Cài đặt kênh WeChat và gateway",
|
||||
"setup": {
|
||||
"primaryAction": "Kết nối WeChat",
|
||||
"docsLabel": "Mở hướng dẫn WeChat",
|
||||
"officialLabel": "Mở WeChat",
|
||||
"tryIt": "Sau khi đăng nhập QR, gửi tin nhắn riêng đến tài khoản đã kết nối.",
|
||||
"summary": "WeChat đăng nhập bằng mã QR và lưu trạng thái tài khoản cục bộ.",
|
||||
"steps": [
|
||||
"Nhấn Kết nối và quét QR bằng WeChat.",
|
||||
"Giữ gateway cục bộ chạy khi WeChat nhận tin nhắn.",
|
||||
"Gửi tin nhắn riêng thử để xác nhận kết nối."
|
||||
],
|
||||
"fields": {
|
||||
"allowFrom": {
|
||||
"label": "Người dùng được phép",
|
||||
"placeholder": "ID người dùng, phân tách bằng dấu phẩy"
|
||||
},
|
||||
"token": {
|
||||
"label": "Token",
|
||||
"placeholder": "Được lưu khi đăng nhập QR"
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"qrAlt": "Mã QR đăng nhập WeChat",
|
||||
"scanTitle": "Quét bằng WeChat",
|
||||
"scanDescription": "Quét bằng WeChat trên điện thoại. nanobot lưu trạng thái cục bộ sau khi đăng nhập.",
|
||||
"waiting": "Đang chờ quét WeChat...",
|
||||
"connected": "WeChat đã kết nối.",
|
||||
"stopped": "Đăng nhập WeChat đã dừng.",
|
||||
"connecting": "Đang kết nối..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"displayName": "微信",
|
||||
"description": "在微信会话中使用 nanobot。",
|
||||
"requirements": "微信渠道配置和网关",
|
||||
"setup": {
|
||||
"primaryAction": "连接微信",
|
||||
"docsLabel": "打开微信配置指南",
|
||||
"officialLabel": "打开微信",
|
||||
"tryIt": "二维码登录完成后,向已连接的账户发送一条微信私信。",
|
||||
"summary": "微信通过二维码登录,并将账户状态保存在本地。",
|
||||
"steps": [
|
||||
"点击连接,用手机微信扫描二维码。",
|
||||
"微信接收消息期间请保持本地网关运行。",
|
||||
"发送一条私信测试,确认账户已连接。"
|
||||
],
|
||||
"fields": {
|
||||
"allowFrom": {
|
||||
"label": "允许的用户",
|
||||
"placeholder": "用户 ID,用逗号分隔"
|
||||
},
|
||||
"token": {
|
||||
"label": "令牌",
|
||||
"placeholder": "二维码登录后自动保存"
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"qrAlt": "微信登录二维码",
|
||||
"scanTitle": "使用微信扫码",
|
||||
"scanDescription": "用手机微信扫描此二维码。登录后,nanobot 会将账户状态保存在本地。",
|
||||
"waiting": "正在等待微信扫码...",
|
||||
"connected": "微信已连接。",
|
||||
"stopped": "微信登录已停止。",
|
||||
"connecting": "正在连接..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"displayName": "微信",
|
||||
"description": "在微信對話中使用 nanobot。",
|
||||
"requirements": "微信渠道設定和閘道",
|
||||
"setup": {
|
||||
"primaryAction": "連接微信",
|
||||
"docsLabel": "開啟微信設定指南",
|
||||
"officialLabel": "開啟微信",
|
||||
"tryIt": "二維碼登入完成後,向已連接的帳戶傳送一則微信私訊。",
|
||||
"summary": "微信透過二維碼登入,並將帳戶狀態儲存在本機。",
|
||||
"steps": [
|
||||
"點擊連接,用手機微信掃描二維碼。",
|
||||
"微信接收訊息期間請保持本機閘道執行。",
|
||||
"傳送一則私訊測試,確認帳戶已連接。"
|
||||
],
|
||||
"fields": {
|
||||
"allowFrom": {
|
||||
"label": "允許的使用者",
|
||||
"placeholder": "使用者 ID,以逗號分隔"
|
||||
},
|
||||
"token": {
|
||||
"label": "權杖",
|
||||
"placeholder": "二維碼登入後自動儲存"
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"qrAlt": "微信登入二維碼",
|
||||
"scanTitle": "使用微信掃碼",
|
||||
"scanDescription": "用手機微信掃描此二維碼。登入後,nanobot 會將帳戶狀態儲存在本機。",
|
||||
"waiting": "正在等待微信掃碼...",
|
||||
"connected": "微信已連接。",
|
||||
"stopped": "微信登入已停止。",
|
||||
"connecting": "正在連接..."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user