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 @@
"""Personal WeChat channel package."""
+261
View File
@@ -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"]
+31
View File
@@ -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
+27
View File
@@ -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
+34
View File
@@ -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" }),
}}
/>
);
}
+27
View File
@@ -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": "正在連接..."
}
}