mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-16 09:26:07 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ed107c9d5 | |||
| 0cce418956 | |||
| d4e8ccd161 | |||
| 78fb40a28a |
@@ -22,6 +22,7 @@ dependencies = [
|
||||
"discord-py>=2.5.2",
|
||||
"pynacl>=1.5.0", # Required for Discord voice support
|
||||
"gewechat-client>=0.1.5",
|
||||
"itchat-uos>=1.5.0.dev",
|
||||
"lark-oapi>=1.5.5",
|
||||
"mcp>=1.25.0",
|
||||
"nakuru-project-idk>=0.0.2.1",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import quart
|
||||
import mimetypes
|
||||
import asyncio
|
||||
import os
|
||||
from ... import group
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
@@ -650,3 +651,224 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
if session and session.get('task') and not session['task'].done():
|
||||
session['task'].cancel()
|
||||
return self.success(data={})
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Itchat WeChat QR Code Login
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
_itchat_login_sessions: dict = {}
|
||||
_ITCHAT_SESSION_TTL = 600 # 10 minutes (allows multiple QR regenerations)
|
||||
|
||||
def _cleanup_expired_itchat_sessions():
|
||||
import time
|
||||
|
||||
now = time.time()
|
||||
expired = [
|
||||
sid for sid, s in _itchat_login_sessions.items() if now - s.get('created_at', 0) > _ITCHAT_SESSION_TTL
|
||||
]
|
||||
for sid in expired:
|
||||
session = _itchat_login_sessions.pop(sid, None)
|
||||
if session:
|
||||
core = session.get('core')
|
||||
if core:
|
||||
try:
|
||||
core.alive = False
|
||||
core.isLogging = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@self.route('/itchat/login', methods=['POST'])
|
||||
async def _() -> str:
|
||||
"""Start itchat WeChat QR code login. Returns session_id + QR code data URL."""
|
||||
import uuid
|
||||
import time
|
||||
import base64
|
||||
import threading
|
||||
|
||||
_cleanup_expired_itchat_sessions()
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
loop = asyncio.get_running_loop()
|
||||
status_dir = os.path.join('data', 'itchat')
|
||||
os.makedirs(status_dir, exist_ok=True)
|
||||
qr_path = os.path.join(status_dir, f'{session_id}-QR.png')
|
||||
|
||||
session = {
|
||||
'status': 'pending',
|
||||
'qr_data_url': None,
|
||||
'expire_at': None,
|
||||
'nickname': None,
|
||||
'error': None,
|
||||
'created_at': time.time(),
|
||||
'thread': None,
|
||||
'logged_in': threading.Event(),
|
||||
'core': None,
|
||||
}
|
||||
_itchat_login_sessions[session_id] = session
|
||||
|
||||
def _run_itchat_login():
|
||||
try:
|
||||
from itchat.core import Core
|
||||
from itchat.content import TEXT as _TEXT
|
||||
from langbot.pkg.platform.sources.itchat import ItchatAdapter
|
||||
|
||||
for f in (qr_path,):
|
||||
try:
|
||||
os.remove(f)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
_core = Core()
|
||||
session['core'] = _core
|
||||
|
||||
def on_login():
|
||||
try:
|
||||
_core.get_friends(update=True)
|
||||
user_info = _core.loginInfo.get('User', {})
|
||||
nick = ItchatAdapter._get_obj_value(user_info, 'NickName', 'unknown')
|
||||
wxid = ItchatAdapter._get_obj_value(user_info, 'UserName')
|
||||
except Exception:
|
||||
nick = 'unknown'
|
||||
wxid = ''
|
||||
session['nickname'] = nick
|
||||
session['wxid'] = wxid
|
||||
print(f'[itchat-login] Login success: {nick}', flush=True)
|
||||
# Dump login status so the adapter can hot-reload it
|
||||
try:
|
||||
if not wxid:
|
||||
raise ValueError('Unable to detect WeChat wxid after login')
|
||||
account_status_path = ItchatAdapter.login_status_path_for_account(wxid)
|
||||
_core.dump_login_status(account_status_path)
|
||||
session['login_status_path'] = account_status_path
|
||||
session['status'] = 'success'
|
||||
print(f'[itchat-login] Session saved to {account_status_path}', flush=True)
|
||||
except Exception as e:
|
||||
session['status'] = 'error'
|
||||
session['error'] = str(e)
|
||||
print(f'[itchat-login] Failed to save session: {e}', flush=True)
|
||||
finally:
|
||||
session['logged_in'].set()
|
||||
# Stop the message loop - we only needed the session for QR login
|
||||
_core.alive = False
|
||||
|
||||
def on_qr(**kwargs):
|
||||
qr_bytes = kwargs.get('qrcode', b'')
|
||||
status = kwargs.get('status', '')
|
||||
print(f'[itchat-login] QR callback: status={status}, bytes={len(qr_bytes)}', flush=True)
|
||||
if status == '200':
|
||||
return
|
||||
# Only update QR image on new QR generation (status='0')
|
||||
# or when status changes to '408' (timeout, QR may refresh)
|
||||
if qr_bytes and status == '0':
|
||||
b64 = base64.b64encode(qr_bytes).decode('utf-8')
|
||||
|
||||
def _update():
|
||||
session['qr_data_url'] = f'data:image/png;base64,{b64}'
|
||||
session['expire_at'] = time.time() + 120
|
||||
session['status'] = 'waiting'
|
||||
|
||||
loop.call_soon_threadsafe(_update)
|
||||
|
||||
# Register a dummy text handler
|
||||
@_core.msg_register([_TEXT])
|
||||
def _dummy(msg):
|
||||
pass
|
||||
|
||||
print('[itchat-login] Step 3: Calling auto_login...', flush=True)
|
||||
_core.auto_login(
|
||||
hotReload=False,
|
||||
loginCallback=on_login,
|
||||
qrCallback=on_qr,
|
||||
)
|
||||
print('[itchat-login] Step 4: auto_login returned, starting run...', flush=True)
|
||||
_core.run(blockThread=True)
|
||||
print('[itchat-login] Step 5: run() returned', flush=True)
|
||||
except SystemExit as e:
|
||||
print(f'[itchat-login] SystemExit: {e}', flush=True)
|
||||
session['status'] = 'error'
|
||||
session['error'] = f'itchat exited: {e}'
|
||||
session['logged_in'].set()
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
print(f'[itchat-login] Exception: {traceback.format_exc()}', flush=True)
|
||||
session['status'] = 'error'
|
||||
session['error'] = str(e)
|
||||
session['logged_in'].set()
|
||||
|
||||
t = threading.Thread(target=_run_itchat_login, daemon=True)
|
||||
t.start()
|
||||
session['thread'] = t
|
||||
|
||||
# Wait for QR code to be ready (max 15 seconds)
|
||||
for _ in range(30):
|
||||
if session['qr_data_url'] or session['error'] or session['status'] == 'success':
|
||||
break
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
if session['error']:
|
||||
return self.http_status(502, -1, session['error'])
|
||||
|
||||
if session['status'] == 'success':
|
||||
return self.success(
|
||||
data={
|
||||
'session_id': session_id,
|
||||
'status': 'success',
|
||||
'nickname': session['nickname'],
|
||||
'wxid': session.get('wxid', ''),
|
||||
}
|
||||
)
|
||||
|
||||
if not session['qr_data_url']:
|
||||
session['status'] = 'error'
|
||||
session['error'] = 'Timeout waiting for QR code'
|
||||
return self.http_status(504, -1, 'Timeout waiting for QR code')
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'session_id': session_id,
|
||||
'qr_data_url': session['qr_data_url'],
|
||||
'expire_at': session['expire_at'],
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/itchat/login/status/<session_id>', methods=['GET'])
|
||||
async def _(session_id: str) -> str:
|
||||
"""Poll itchat login status."""
|
||||
session = _itchat_login_sessions.get(session_id)
|
||||
if not session:
|
||||
return self.http_status(404, -1, 'Session not found')
|
||||
|
||||
data = {
|
||||
'status': session['status'],
|
||||
'qr_data_url': session['qr_data_url'],
|
||||
'expire_at': session['expire_at'],
|
||||
}
|
||||
|
||||
if session['status'] == 'success':
|
||||
data['nickname'] = session.get('nickname', '')
|
||||
data['wxid'] = session.get('wxid', '')
|
||||
_itchat_login_sessions.pop(session_id, None)
|
||||
elif session['status'] == 'error':
|
||||
data['error'] = session['error']
|
||||
_itchat_login_sessions.pop(session_id, None)
|
||||
|
||||
return self.success(data=data)
|
||||
|
||||
@self.route('/itchat/login/<session_id>', methods=['DELETE'])
|
||||
async def _(session_id: str) -> str:
|
||||
"""Cancel and clean up an itchat login session."""
|
||||
session = _itchat_login_sessions.pop(session_id, None)
|
||||
if session:
|
||||
core = session.get('core')
|
||||
if core:
|
||||
try:
|
||||
core.alive = False
|
||||
core.isLogging = False
|
||||
except Exception:
|
||||
pass
|
||||
thread = session.get('thread')
|
||||
if thread and thread.is_alive():
|
||||
# Thread is daemon, will die with the process
|
||||
pass
|
||||
return self.success(data={})
|
||||
|
||||
@@ -57,6 +57,8 @@ class BotService:
|
||||
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
|
||||
if runtime_bot is not None:
|
||||
adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id
|
||||
if hasattr(runtime_bot.adapter, 'get_runtime_status'):
|
||||
adapter_runtime_values['runtime_status'] = runtime_bot.adapter.get_runtime_status()
|
||||
|
||||
# Webhook URL for unified webhook adapters (independent of bot running state)
|
||||
if persistence_bot['adapter'] in [
|
||||
|
||||
@@ -0,0 +1,772 @@
|
||||
"""itchat-uos adapter for LangBot.
|
||||
|
||||
Uses the itchat-uos WeChat Web library to integrate personal WeChat accounts
|
||||
with LangBot via QR code login.
|
||||
|
||||
Reference: https://github.com/littlecodersh/ItChat
|
||||
UOS fork: https://github.com/why2lyj/ItChat-uos
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import typing
|
||||
|
||||
from itchat.content import TEXT, PICTURE, RECORDING, VIDEO, SHARING
|
||||
|
||||
import pydantic
|
||||
|
||||
from itchat.core import Core as ItchatCore
|
||||
|
||||
try:
|
||||
import queue
|
||||
except ImportError:
|
||||
import Queue as queue
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
from langbot.pkg.platform.logger import EventLogger
|
||||
|
||||
|
||||
class ItchatMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
"""Converts between LangBot MessageChain and itchat message dicts."""
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(
|
||||
message_chain: platform_message.MessageChain,
|
||||
) -> list[dict]:
|
||||
"""LangBot MessageChain -> list of itchat-sendable items.
|
||||
|
||||
Each item is a dict with 'type' and the relevant content field.
|
||||
The adapter's send_message() will call itchat.send() accordingly.
|
||||
"""
|
||||
items: list[dict] = []
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.Plain):
|
||||
if component.text:
|
||||
items.append({'type': 'text', 'content': component.text})
|
||||
|
||||
elif isinstance(component, platform_message.Image):
|
||||
if component.base64:
|
||||
items.append({'type': 'image', 'base64': component.base64})
|
||||
elif component.url:
|
||||
items.append({'type': 'image', 'url': component.url})
|
||||
|
||||
elif isinstance(component, platform_message.Voice):
|
||||
if component.base64:
|
||||
items.append({'type': 'voice', 'base64': component.base64})
|
||||
elif component.url:
|
||||
items.append({'type': 'voice', 'url': component.url})
|
||||
|
||||
elif isinstance(component, platform_message.File):
|
||||
if component.base64:
|
||||
items.append({'type': 'file', 'base64': component.base64, 'name': component.name or 'file'})
|
||||
elif component.url:
|
||||
items.append({'type': 'file', 'url': component.url, 'name': component.name or 'file'})
|
||||
|
||||
elif isinstance(component, platform_message.At):
|
||||
items.append({'type': 'text', 'content': f'@{component.target} '})
|
||||
|
||||
elif isinstance(component, platform_message.AtAll):
|
||||
items.append({'type': 'text', 'content': '@所有人 '})
|
||||
|
||||
elif isinstance(component, platform_message.Forward):
|
||||
for node in component.node_list:
|
||||
if node.message_chain:
|
||||
items.extend(await ItchatMessageConverter.yiri2target(node.message_chain))
|
||||
|
||||
elif isinstance(component, platform_message.Unknown):
|
||||
pass # skip unknown outbound
|
||||
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def target2yiri(msg: dict) -> platform_message.MessageChain:
|
||||
"""Convert an itchat msg dict to a LangBot MessageChain."""
|
||||
components: list[platform_message.MessageComponent] = []
|
||||
msg_type = msg.get('Type', '')
|
||||
|
||||
if msg_type == 'Text':
|
||||
text = msg.get('Text', '')
|
||||
if text:
|
||||
components.append(platform_message.Plain(text=text))
|
||||
|
||||
elif msg_type == 'Picture':
|
||||
try:
|
||||
temp_dir = tempfile.gettempdir()
|
||||
file_path = os.path.join(temp_dir, msg.get('FileName', 'image.jpg'))
|
||||
msg.download(file_path)
|
||||
if os.path.exists(file_path):
|
||||
with open(file_path, 'rb') as f:
|
||||
img_bytes = f.read()
|
||||
b64 = base64.b64encode(img_bytes).decode('utf-8')
|
||||
components.append(platform_message.Image(base64=f'data:image/jpeg;base64,{b64}'))
|
||||
os.remove(file_path)
|
||||
else:
|
||||
components.append(platform_message.Unknown(text='[Image download failed]'))
|
||||
except Exception:
|
||||
components.append(platform_message.Unknown(text='[Image download failed]'))
|
||||
|
||||
elif msg_type == 'Recording':
|
||||
try:
|
||||
temp_dir = tempfile.gettempdir()
|
||||
file_path = os.path.join(temp_dir, msg.get('FileName', 'voice.mp3'))
|
||||
msg.download(file_path)
|
||||
if os.path.exists(file_path):
|
||||
with open(file_path, 'rb') as f:
|
||||
voice_bytes = f.read()
|
||||
b64 = base64.b64encode(voice_bytes).decode('utf-8')
|
||||
components.append(platform_message.Voice(base64=b64))
|
||||
os.remove(file_path)
|
||||
else:
|
||||
components.append(platform_message.Unknown(text='[Voice download failed]'))
|
||||
except Exception:
|
||||
components.append(platform_message.Unknown(text='[Voice download failed]'))
|
||||
|
||||
elif msg_type == 'Sharing':
|
||||
text = msg.get('Text', '')
|
||||
url = msg.get('Url', '')
|
||||
content = text
|
||||
if url and url not in text:
|
||||
content = f'{text}\n{url}' if text else url
|
||||
if content:
|
||||
components.append(platform_message.Plain(text=content))
|
||||
|
||||
elif msg_type == 'Video':
|
||||
components.append(platform_message.Unknown(text='[Video]'))
|
||||
|
||||
elif msg_type == 'Map':
|
||||
components.append(platform_message.Unknown(text='[Location]'))
|
||||
|
||||
elif msg_type == 'Card':
|
||||
components.append(platform_message.Unknown(text='[Contact Card]'))
|
||||
|
||||
elif msg_type == 'Note':
|
||||
text = msg.get('Text', '')
|
||||
if text:
|
||||
components.append(platform_message.Unknown(text=f'[Note: {text}]'))
|
||||
|
||||
else:
|
||||
text = msg.get('Text', '')
|
||||
if text:
|
||||
components.append(platform_message.Plain(text=text))
|
||||
else:
|
||||
components.append(platform_message.Unknown(text=f'[Unsupported message type: {msg_type}]'))
|
||||
|
||||
return platform_message.MessageChain(components)
|
||||
|
||||
|
||||
class ItchatEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
"""Converts itchat msg dicts to LangBot events."""
|
||||
|
||||
def __init__(self, adapter_ref: typing.Callable[[], typing.Any]):
|
||||
"""adapter_ref is a callable returning the ItchatAdapter instance."""
|
||||
self._get_adapter = adapter_ref
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.MessageEvent) -> dict:
|
||||
return event.source_platform_object
|
||||
|
||||
def target2yiri(self, msg: dict) -> typing.Optional[platform_events.MessageEvent]:
|
||||
"""Convert itchat msg to FriendMessage or GroupMessage."""
|
||||
from_user = msg.get('FromUserName', '')
|
||||
if not from_user:
|
||||
return None
|
||||
|
||||
adapter = self._get_adapter()
|
||||
bot_account_id = adapter.bot_account_id
|
||||
bot_nickname = adapter._bot_nickname
|
||||
|
||||
message_chain = ItchatMessageConverter.target2yiri(msg)
|
||||
if not message_chain:
|
||||
return None
|
||||
|
||||
# Determine if this is a group message
|
||||
# itchat uses '@@' prefix for chatroom IDs (not '@chatroom' suffix)
|
||||
is_group = '@@' in from_user
|
||||
timestamp = msg.get('CreateTime', 0)
|
||||
|
||||
if is_group:
|
||||
# Actual sender within the group
|
||||
actual_user = msg.get('ActualUserName', '')
|
||||
actual_nick = msg.get('ActualNickName', '')
|
||||
if not actual_nick:
|
||||
actual_nick = actual_user
|
||||
|
||||
# Prepend @bot if the bot was mentioned
|
||||
# itchat uses 'IsAt' (capital I, capital A) in produce_group_chat
|
||||
if msg.get('IsAt', False):
|
||||
# Strip @bot_nickname from the text content to avoid LLM confusion
|
||||
if bot_nickname:
|
||||
at_pattern = '@' + bot_nickname + (' ' if ' ' in msg.get('Content', '') else ' ')
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.Plain):
|
||||
if component.text.startswith(at_pattern):
|
||||
component.text = component.text[len(at_pattern) :]
|
||||
elif at_pattern in component.text:
|
||||
component.text = component.text.replace(at_pattern, '')
|
||||
break
|
||||
message_chain = platform_message.MessageChain(
|
||||
[platform_message.At(target=bot_account_id)] + list(message_chain)
|
||||
)
|
||||
|
||||
# Try to get group display name
|
||||
group_obj = msg.get('User', {})
|
||||
group_name = ''
|
||||
if hasattr(group_obj, 'NickName'):
|
||||
group_name = group_obj.NickName
|
||||
elif isinstance(group_obj, dict):
|
||||
group_name = group_obj.get('NickName', '')
|
||||
|
||||
return platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=actual_user or actual_nick,
|
||||
member_name=actual_nick or actual_user,
|
||||
permission=platform_entities.Permission.Member,
|
||||
group=platform_entities.Group(
|
||||
id=from_user,
|
||||
name=group_name or from_user,
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=timestamp,
|
||||
source_platform_object=msg,
|
||||
)
|
||||
else:
|
||||
# Private / friend message
|
||||
sender_nick = ''
|
||||
user_obj = msg.get('User', {})
|
||||
if hasattr(user_obj, 'NickName'):
|
||||
sender_nick = user_obj.NickName
|
||||
elif isinstance(user_obj, dict):
|
||||
sender_nick = user_obj.get('NickName', '')
|
||||
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=from_user,
|
||||
nickname=sender_nick or from_user,
|
||||
remark='',
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=timestamp,
|
||||
source_platform_object=msg,
|
||||
)
|
||||
|
||||
|
||||
class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
"""LangBot adapter for itchat-uos (WeChat Web)."""
|
||||
|
||||
name: str = 'itchat'
|
||||
|
||||
config: dict
|
||||
logger: EventLogger
|
||||
|
||||
message_converter: ItchatMessageConverter
|
||||
event_converter: ItchatEventConverter
|
||||
|
||||
listeners: typing.Dict[
|
||||
typing.Type[platform_events.Event],
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = {}
|
||||
|
||||
_loop: typing.Optional[asyncio.AbstractEventLoop] = pydantic.PrivateAttr(default=None)
|
||||
_logged_in: typing.Optional[threading.Event] = pydantic.PrivateAttr(default=None)
|
||||
_itchat_thread: typing.Optional[threading.Thread] = pydantic.PrivateAttr(default=None)
|
||||
_core: typing.Optional[ItchatCore] = pydantic.PrivateAttr(default=None)
|
||||
_bot_nickname: str = pydantic.PrivateAttr(default='')
|
||||
_bot_uuid: typing.Optional[str] = pydantic.PrivateAttr(default=None)
|
||||
_startup_error: typing.Optional[str] = pydantic.PrivateAttr(default=None)
|
||||
_connection_status: str = pydantic.PrivateAttr(default='disconnected')
|
||||
_connection_error: str = pydantic.PrivateAttr(default='')
|
||||
_last_connected_at: typing.Optional[float] = pydantic.PrivateAttr(default=None)
|
||||
_last_disconnected_at: typing.Optional[float] = pydantic.PrivateAttr(default=None)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
message_converter = ItchatMessageConverter()
|
||||
# Event converter needs a reference to self for bot_account_id + nickname
|
||||
event_converter = ItchatEventConverter(adapter_ref=lambda: self)
|
||||
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
message_converter=message_converter,
|
||||
event_converter=event_converter,
|
||||
listeners={},
|
||||
bot_account_id='',
|
||||
)
|
||||
|
||||
# Initialize private attributes (can't be class-level defaults due to pickle)
|
||||
self._loop = None
|
||||
self._logged_in = threading.Event()
|
||||
self._itchat_thread = None
|
||||
self._core = ItchatCore()
|
||||
self._startup_error = None
|
||||
self._connection_status = 'disconnected'
|
||||
self._connection_error = ''
|
||||
self._last_connected_at = None
|
||||
self._last_disconnected_at = None
|
||||
|
||||
@staticmethod
|
||||
def _get_obj_value(obj: typing.Any, key: str, default: str = '') -> str:
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default) or default
|
||||
return getattr(obj, key, default) or default
|
||||
|
||||
@staticmethod
|
||||
def _safe_status_name(value: str) -> str:
|
||||
cleaned = re.sub(r'[^A-Za-z0-9_.@-]+', '_', value.strip())
|
||||
cleaned = cleaned.strip('._')
|
||||
return cleaned
|
||||
|
||||
@staticmethod
|
||||
def login_status_dir() -> str:
|
||||
path = os.path.join('data', 'itchat')
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
@classmethod
|
||||
def login_status_path_for_account(cls, account_id: str) -> str:
|
||||
safe_name = cls._safe_status_name(account_id)
|
||||
if not safe_name:
|
||||
raise ValueError('account_id is required for itchat login status')
|
||||
filename = f'{safe_name}.pkl'
|
||||
return os.path.join(cls.login_status_dir(), filename)
|
||||
|
||||
def _login_status_path(self) -> str:
|
||||
configured_path = self.config.get('login_status_path', '').strip()
|
||||
if configured_path:
|
||||
return configured_path
|
||||
|
||||
account_id = self.config.get('account_id', '').strip()
|
||||
if not account_id:
|
||||
raise ValueError('account_id is required. Please scan the QR code and save this bot first.')
|
||||
|
||||
return self.login_status_path_for_account(account_id)
|
||||
|
||||
def set_bot_uuid(self, bot_uuid: str):
|
||||
self._bot_uuid = bot_uuid
|
||||
|
||||
def _set_connection_status(self, status: str, error: str = ''):
|
||||
self._connection_status = status
|
||||
self._connection_error = error
|
||||
now = time.time()
|
||||
if status == 'connected':
|
||||
self._last_connected_at = now
|
||||
elif status in {'disconnected', 'error'}:
|
||||
self._last_disconnected_at = now
|
||||
|
||||
def get_runtime_status(self) -> dict:
|
||||
return {
|
||||
'connection_status': self._connection_status,
|
||||
'connection_error': self._connection_error,
|
||||
'last_connected_at': self._last_connected_at,
|
||||
'last_disconnected_at': self._last_disconnected_at,
|
||||
}
|
||||
|
||||
def _on_login(self):
|
||||
"""Called by itchat after successful QR code login."""
|
||||
try:
|
||||
# Refresh contacts
|
||||
self._core.get_friends(update=True)
|
||||
self._core.get_chatrooms(update=True)
|
||||
|
||||
# Get bot's own WeChat info from loginInfo['User']
|
||||
user_info = self._core.loginInfo.get('User', {})
|
||||
nick_name = self._get_obj_value(user_info, 'NickName')
|
||||
user_name = self._get_obj_value(user_info, 'UserName')
|
||||
|
||||
# bot_account_id: config override or auto-detected wxid
|
||||
# Used by AtBotRule for matching At.target
|
||||
configured_id = self.config.get('account_id', '').strip()
|
||||
self.bot_account_id = configured_id or user_name or nick_name or 'itchat-bot'
|
||||
# _bot_nickname: config override or auto-detected nickname
|
||||
configured_nick = self.config.get('nickname', '').strip()
|
||||
self._bot_nickname = configured_nick or nick_name
|
||||
self._set_connection_status('connected')
|
||||
|
||||
try:
|
||||
chatrooms = self._core.search_chatrooms() or []
|
||||
group_names = []
|
||||
for c in chatrooms:
|
||||
name = self._get_obj_value(c, 'NickName', str(c))
|
||||
if name:
|
||||
group_names.append(name)
|
||||
|
||||
if group_names:
|
||||
self._log_sync(
|
||||
f'itchat login as {nick_name} ({user_name}) | Groups ({len(group_names)}): {", ".join(group_names[:10])}{"..." if len(group_names) > 10 else ""}'
|
||||
)
|
||||
else:
|
||||
self._log_sync(f'itchat login as {nick_name} ({user_name}) | No groups found')
|
||||
except Exception as e:
|
||||
self._log_sync(f'itchat login as {nick_name} ({user_name}) | Failed to list groups: {e}', 'warning')
|
||||
except Exception as e:
|
||||
self.bot_account_id = f'WeChat Bot (Error: {e})'
|
||||
self._set_connection_status('error', str(e))
|
||||
finally:
|
||||
self._logged_in.set()
|
||||
|
||||
def _log_sync(self, msg: str, level: str = 'info'):
|
||||
"""Thread-safe logging from itchat's sync thread."""
|
||||
try:
|
||||
if self._loop and not self._loop.is_closed():
|
||||
log_fn = getattr(self.logger, level)
|
||||
asyncio.run_coroutine_threadsafe(log_fn(msg), self._loop)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _drain_msglist(self):
|
||||
"""Clear all stale messages from the msgList queue.
|
||||
|
||||
itchat's load_login_status fetches old messages via get_msg() and
|
||||
pushes them into msgList. We drain them to avoid replaying history.
|
||||
"""
|
||||
try:
|
||||
q = self._core.msgList
|
||||
while True:
|
||||
q.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
def _on_qr_callback(self, **kwargs):
|
||||
"""Called by itchat when QR code is generated or status changes.
|
||||
|
||||
Args:
|
||||
uuid: QR code uuid
|
||||
status: '200' = logged in, '201' = confirmed on phone, '408' = timeout
|
||||
qrcode: raw bytes of the QR code PNG image
|
||||
"""
|
||||
status = kwargs.get('status', '')
|
||||
qr_bytes = kwargs.get('qrcode', b'')
|
||||
|
||||
if status == '200':
|
||||
# Login success, no need to show QR
|
||||
return
|
||||
|
||||
# Only show QR on new QR generation (status='0') to avoid spamming
|
||||
if not qr_bytes or status != '0':
|
||||
return
|
||||
|
||||
try:
|
||||
b64 = base64.b64encode(qr_bytes).decode('utf-8')
|
||||
if self._loop and not self._loop.is_closed():
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.logger.info(
|
||||
'Please scan the QR code to login WeChat:',
|
||||
images=[platform_message.Image(base64=f'data:image/png;base64,{b64}')],
|
||||
),
|
||||
self._loop,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_exit(self):
|
||||
"""Called by itchat on exit."""
|
||||
self._set_connection_status('disconnected')
|
||||
self._log_sync('itchat session exited')
|
||||
|
||||
def _register_itchat_handlers(self):
|
||||
"""Register itchat message decorators by re-registering handlers."""
|
||||
|
||||
@self._core.msg_register([TEXT])
|
||||
def _on_text(msg):
|
||||
self._dispatch_itchat_message(msg)
|
||||
|
||||
@self._core.msg_register([TEXT], isGroupChat=True)
|
||||
def _on_group_text(msg):
|
||||
self._dispatch_itchat_message(msg)
|
||||
|
||||
@self._core.msg_register([PICTURE])
|
||||
def _on_picture(msg):
|
||||
self._dispatch_itchat_message(msg)
|
||||
|
||||
@self._core.msg_register([PICTURE], isGroupChat=True)
|
||||
def _on_group_picture(msg):
|
||||
self._dispatch_itchat_message(msg)
|
||||
|
||||
@self._core.msg_register([RECORDING])
|
||||
def _on_recording(msg):
|
||||
self._dispatch_itchat_message(msg)
|
||||
|
||||
@self._core.msg_register([RECORDING], isGroupChat=True)
|
||||
def _on_group_recording(msg):
|
||||
self._dispatch_itchat_message(msg)
|
||||
|
||||
@self._core.msg_register([SHARING])
|
||||
def _on_sharing(msg):
|
||||
self._dispatch_itchat_message(msg)
|
||||
|
||||
@self._core.msg_register([SHARING], isGroupChat=True)
|
||||
def _on_group_sharing(msg):
|
||||
self._dispatch_itchat_message(msg)
|
||||
|
||||
@self._core.msg_register([VIDEO])
|
||||
def _on_video(msg):
|
||||
self._dispatch_itchat_message(msg)
|
||||
|
||||
@self._core.msg_register([VIDEO], isGroupChat=True)
|
||||
def _on_group_video(msg):
|
||||
self._dispatch_itchat_message(msg)
|
||||
|
||||
def _dispatch_itchat_message(self, msg: dict):
|
||||
"""Bridge itchat callback (sync, in itchat thread) to async listener."""
|
||||
try:
|
||||
event = self.event_converter.target2yiri(msg)
|
||||
if event is None:
|
||||
return
|
||||
|
||||
event_type = type(event)
|
||||
if event_type in self.listeners and self._loop:
|
||||
callback = self.listeners[event_type]
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
callback(event, self),
|
||||
self._loop,
|
||||
)
|
||||
except Exception:
|
||||
self._log_sync(f'Error dispatching itchat message: {traceback.format_exc()}', 'error')
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
message: platform_message.MessageChain,
|
||||
):
|
||||
"""Send a message to a user or group via itchat."""
|
||||
items = await self.message_converter.yiri2target(message)
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Merge consecutive text items to avoid splitting messages
|
||||
merged = []
|
||||
for item in items:
|
||||
if item['type'] == 'text' and merged and merged[-1]['type'] == 'text':
|
||||
merged[-1]['content'] += item['content']
|
||||
else:
|
||||
merged.append(item)
|
||||
|
||||
for item in merged:
|
||||
try:
|
||||
if item['type'] == 'text':
|
||||
await loop.run_in_executor(None, self._core.send, item['content'], target_id)
|
||||
elif item['type'] == 'image':
|
||||
# Save to temp file then send
|
||||
temp_path = self._save_to_temp(item, 'image')
|
||||
if temp_path:
|
||||
await loop.run_in_executor(None, self._core.send, f'@img@{temp_path}', target_id)
|
||||
self._cleanup_temp(temp_path)
|
||||
elif item['type'] == 'voice':
|
||||
temp_path = self._save_to_temp(item, 'voice')
|
||||
if temp_path:
|
||||
await loop.run_in_executor(None, self._core.send, f'@fil@{temp_path}', target_id)
|
||||
self._cleanup_temp(temp_path)
|
||||
elif item['type'] == 'file':
|
||||
temp_path = self._save_to_temp(item, 'file')
|
||||
if temp_path:
|
||||
await loop.run_in_executor(None, self._core.send, f'@fil@{temp_path}', target_id)
|
||||
self._cleanup_temp(temp_path)
|
||||
except Exception:
|
||||
await self.logger.error(f'Failed to send itchat message: {traceback.format_exc()}')
|
||||
|
||||
def _save_to_temp(self, item: dict, prefix: str) -> typing.Optional[str]:
|
||||
"""Save base64 or URL data to a temp file and return the path."""
|
||||
try:
|
||||
if 'base64' in item:
|
||||
b64_data = item['base64']
|
||||
# Strip data URI prefix if present
|
||||
if ',' in b64_data:
|
||||
b64_data = b64_data.split(',', 1)[1]
|
||||
file_bytes = base64.b64decode(b64_data)
|
||||
suffix = '.jpg' if prefix == 'image' else ('.mp3' if prefix == 'voice' else '.bin')
|
||||
fd, temp_path = tempfile.mkstemp(suffix=suffix, prefix=f'itchat_{prefix}_')
|
||||
with os.fdopen(fd, 'wb') as f:
|
||||
f.write(file_bytes)
|
||||
return temp_path
|
||||
elif 'url' in item:
|
||||
import requests
|
||||
|
||||
resp = requests.get(item['url'], timeout=30)
|
||||
if resp.status_code == 200:
|
||||
suffix = '.jpg' if prefix == 'image' else ('.mp3' if prefix == 'voice' else '.bin')
|
||||
fd, temp_path = tempfile.mkstemp(suffix=suffix, prefix=f'itchat_{prefix}_')
|
||||
with os.fdopen(fd, 'wb') as f:
|
||||
f.write(resp.content)
|
||||
return temp_path
|
||||
except Exception:
|
||||
self._log_sync(f'Failed to save temp file: {traceback.format_exc()}', 'error')
|
||||
return None
|
||||
|
||||
def _cleanup_temp(self, path: str):
|
||||
"""Remove a temp file."""
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _prepare_reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
) -> platform_message.MessageChain:
|
||||
"""Render group sender mentions with display names while keeping internal IDs stable."""
|
||||
if not isinstance(message_source, platform_events.GroupMessage):
|
||||
return message
|
||||
|
||||
source_msg = message_source.source_platform_object or {}
|
||||
actual_user = source_msg.get('ActualUserName', '')
|
||||
actual_nick = source_msg.get('ActualNickName', '')
|
||||
if not actual_user or not actual_nick:
|
||||
return message
|
||||
|
||||
components: list[platform_message.MessageComponent] = []
|
||||
changed = False
|
||||
for component in message:
|
||||
if isinstance(component, platform_message.At) and str(component.target) == str(actual_user):
|
||||
components.append(platform_message.Plain(text=f'@{actual_nick} '))
|
||||
changed = True
|
||||
else:
|
||||
components.append(component)
|
||||
|
||||
if not changed:
|
||||
return message
|
||||
return platform_message.MessageChain(components)
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
"""Reply to a received message."""
|
||||
source_msg = message_source.source_platform_object
|
||||
if not source_msg:
|
||||
return
|
||||
|
||||
# For group messages, reply to the group; for private, reply to the sender
|
||||
from_user = source_msg.get('FromUserName', '')
|
||||
if not from_user:
|
||||
return
|
||||
|
||||
await self.send_message('friend', from_user, self._prepare_reply_message(message_source, message))
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
self.listeners[event_type] = callback
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
self.listeners.pop(event_type, None)
|
||||
|
||||
async def run_async(self):
|
||||
"""Start the itchat adapter.
|
||||
|
||||
If an account-specific cached session file exists from a previous QR login,
|
||||
itchat will reuse data/itchat/<account_id>.pkl without requiring a new QR scan.
|
||||
"""
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._logged_in.clear()
|
||||
self._startup_error = None
|
||||
self._set_connection_status('connecting')
|
||||
|
||||
await self.logger.info('itchat adapter starting...')
|
||||
|
||||
# Register itchat message handlers BEFORE calling itchat.auto_login()
|
||||
self._register_itchat_handlers()
|
||||
|
||||
# Run itchat in a daemon thread (it blocks)
|
||||
def _run_itchat():
|
||||
try:
|
||||
status_path = self._login_status_path()
|
||||
if not os.path.exists(status_path):
|
||||
self._startup_error = (
|
||||
f'No cached WeChat session found at {status_path}. '
|
||||
'Please scan the QR code in the bot config page first.'
|
||||
)
|
||||
self._set_connection_status('error', self._startup_error)
|
||||
self._log_sync(self._startup_error, 'error')
|
||||
self._logged_in.set()
|
||||
return
|
||||
|
||||
# Use hotReload to reuse the cached session from QR login
|
||||
# If no cache exists, fail fast instead of triggering QR login
|
||||
result = self._core.load_login_status(
|
||||
status_path, loginCallback=self._on_login, exitCallback=self._on_exit
|
||||
)
|
||||
if result.get('BaseResponse', {}).get('Ret') != 0:
|
||||
self._startup_error = (
|
||||
f'Cached WeChat session at {status_path} is invalid. '
|
||||
'Please scan the QR code in the bot config page again.'
|
||||
)
|
||||
self._set_connection_status('error', self._startup_error)
|
||||
self._log_sync(self._startup_error, 'error')
|
||||
self._logged_in.set()
|
||||
return
|
||||
|
||||
# Session loaded, start message loop
|
||||
self._log_sync(f'WeChat session loaded from cache: {status_path}')
|
||||
|
||||
# Clear stale messages that itchat fetched during hot-reload
|
||||
self._drain_msglist()
|
||||
|
||||
self._core.run(blockThread=True)
|
||||
self._set_connection_status('disconnected')
|
||||
self._log_sync('itchat message loop stopped', 'error')
|
||||
except Exception as e:
|
||||
error = f'itchat run error: {e}'
|
||||
self._set_connection_status('error', error)
|
||||
self._log_sync(error, 'error')
|
||||
self._logged_in.set()
|
||||
|
||||
self._itchat_thread = threading.Thread(target=_run_itchat, daemon=True, name='itchat-thread')
|
||||
self._itchat_thread.start()
|
||||
|
||||
# Wait for login to complete (with timeout)
|
||||
await asyncio.get_event_loop().run_in_executor(None, lambda: self._logged_in.wait(timeout=300))
|
||||
|
||||
if not self._logged_in.is_set():
|
||||
raise RuntimeError('itchat login timed out (300s)')
|
||||
if self._startup_error:
|
||||
raise RuntimeError(self._startup_error)
|
||||
|
||||
await self.logger.info(f'itchat adapter running, bot: {self.bot_account_id}')
|
||||
|
||||
# Keep the adapter alive
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def kill(self) -> bool:
|
||||
"""Stop the itchat adapter."""
|
||||
try:
|
||||
self._core.alive = False
|
||||
self._core.isLogging = False
|
||||
except Exception:
|
||||
pass
|
||||
self._set_connection_status('disconnected')
|
||||
await self.logger.info('itchat adapter stopped')
|
||||
return True
|
||||
@@ -0,0 +1,75 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: itchat
|
||||
label:
|
||||
en_US: Itchat WeChat
|
||||
zh_Hans: 个人微信 (itchat)
|
||||
zh_Hant: 個人微信 (itchat)
|
||||
ja_JP: 個人WeChat (itchat)
|
||||
description:
|
||||
en_US: Personal WeChat adapter via itchat-uos, supports QR code login and text/image/voice messages
|
||||
zh_Hans: 基于 itchat-uos 的个人微信适配器,扫码登录,支持文本/图片/语音消息
|
||||
zh_Hant: 基於 itchat-uos 的個人微信適配器,掃碼登入,支援文字/圖片/語音訊息
|
||||
icon: wechat.png
|
||||
spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://github.com/littlecodersh/ItChat
|
||||
en: https://github.com/littlecodersh/ItChat
|
||||
config:
|
||||
- name: qr-login
|
||||
label:
|
||||
en_US: Scan QR Login
|
||||
zh_Hans: 扫码登录
|
||||
zh_Hant: 掃碼登入
|
||||
description:
|
||||
en_US: Scan QR code with WeChat to login. The session will be cached for the adapter to reuse.
|
||||
zh_Hans: 使用微信扫码登录,登录状态将被缓存供适配器复用
|
||||
zh_Hant: 使用微信掃碼登入,登入狀態將被快取供適配器復用
|
||||
type: qr-code-login
|
||||
login_platform: itchat
|
||||
required: false
|
||||
- name: account_id
|
||||
label:
|
||||
en_US: Bot Account ID
|
||||
zh_Hans: 机器人账号标识
|
||||
zh_Hant: 機器人帳號標識
|
||||
ja_JP: ボットアカウントID
|
||||
description:
|
||||
en_US: Auto-filled after QR login with the WeChat wxid. Used for @-mention matching and to load data/itchat/<account_id>.pkl; do not change it to a nickname.
|
||||
zh_Hans: 扫码登录后自动填入微信 wxid。用于群聊 @ 匹配,并加载 data/itchat/<account_id>.pkl;不要改成昵称。
|
||||
zh_Hant: 掃碼登入後自動填入微信 wxid。用於群聊 @ 匹配,並載入 data/itchat/<account_id>.pkl;不要改成暱稱。
|
||||
ja_JP: QRログイン後にWeChatのwxidが自動入力されます。@メンション判定と data/itchat/<account_id>.pkl の読み込みに使うため、ニックネームへ変更しないでください。
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: nickname
|
||||
label:
|
||||
en_US: Bot Nickname
|
||||
zh_Hans: 机器人昵称
|
||||
zh_Hant: 機器人暱稱
|
||||
description:
|
||||
en_US: The display nickname of the bot. Used to strip @nickname from incoming group messages. Auto-filled after QR login.
|
||||
zh_Hans: 机器人的微信昵称。用于删除群聊消息中的 @昵称 前缀。扫码登录后自动填入
|
||||
zh_Hant: 機器人的微信暱稱。用於刪除群聊訊息中的 @暱稱 前綴。掃碼登入後自動填入
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
- name: hot_reload
|
||||
label:
|
||||
en_US: Hot Reload
|
||||
zh_Hans: 登录缓存
|
||||
zh_Hant: 登入快取
|
||||
description:
|
||||
en_US: Persist login session to avoid repeated QR code scans on restart
|
||||
zh_Hans: 保存登录状态到本地,重启后无需重新扫码
|
||||
zh_Hant: 儲存登入狀態到本機,重啟後無需重新掃碼
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
execution:
|
||||
python:
|
||||
path: ./itchat.py
|
||||
attr: ItchatAdapter
|
||||
@@ -736,7 +736,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
) -> context.EventContext:
|
||||
event_ctx = context.EventContext.from_event(event)
|
||||
|
||||
if not self.is_enable_plugin:
|
||||
if not self.is_enable_plugin or not hasattr(self, 'handler'):
|
||||
event_ctx._emitted_plugins = []
|
||||
event_ctx._response_sources = []
|
||||
return event_ctx
|
||||
|
||||
@@ -1814,6 +1814,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itchat-uos"
|
||||
version = "1.5.0.dev0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pypng" },
|
||||
{ name = "pyqrcode" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/31/2b/0be7e46195dc3c461518b046a6fb9c34c97cd2fd44847b840a0011686575/itchat_uos-1.5.0.dev0-py3-none-any.whl", hash = "sha256:0293b77cab31fa8c9c2144ea8b5636d43836f47855beba0c603ad3fb1ff89625", size = 52507, upload-time = "2022-07-14T02:38:32.507Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itsdangerous"
|
||||
version = "2.2.0"
|
||||
@@ -2035,6 +2048,7 @@ dependencies = [
|
||||
{ name = "ebooklib" },
|
||||
{ name = "gewechat-client" },
|
||||
{ name = "html2text" },
|
||||
{ name = "itchat-uos" },
|
||||
{ name = "langbot-plugin" },
|
||||
{ name = "langchain" },
|
||||
{ name = "langchain-core" },
|
||||
@@ -2123,6 +2137,7 @@ requires-dist = [
|
||||
{ name = "ebooklib", specifier = ">=0.18" },
|
||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||
{ name = "itchat-uos", specifier = ">=1.5.0.dev0" },
|
||||
{ name = "langbot-plugin", specifier = "==0.4.6" },
|
||||
{ name = "langchain", specifier = ">=1.3.9" },
|
||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||
@@ -4517,6 +4532,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyqrcode"
|
||||
version = "1.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/37/61/f07226075c347897937d4086ef8e55f0a62ae535e28069884ac68d979316/PyQRCode-1.2.1.tar.gz", hash = "sha256:fdbf7634733e56b72e27f9bce46e4550b75a3a2c420414035cae9d9d26b234d5", size = 36989, upload-time = "2016-06-20T03:28:03.411Z" }
|
||||
|
||||
[[package]]
|
||||
name = "pyreadline3"
|
||||
version = "3.5.4"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
@@ -26,9 +27,79 @@ import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-ses
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Settings,
|
||||
FileText,
|
||||
Users,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
CircleCheck,
|
||||
CircleAlert,
|
||||
Loader2,
|
||||
CircleOff,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import type { Bot, BotAdapterRuntimeStatus } from '@/app/infra/entities/api';
|
||||
|
||||
function getBotRuntimeStatus(bot: Bot | null): BotAdapterRuntimeStatus | null {
|
||||
return bot?.adapter_runtime_values?.runtime_status ?? null;
|
||||
}
|
||||
|
||||
function RuntimeStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: BotAdapterRuntimeStatus | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!status) return null;
|
||||
|
||||
const value = status?.connection_status ?? 'disconnected';
|
||||
|
||||
const config = {
|
||||
connected: {
|
||||
label: t('bots.runtimeConnected'),
|
||||
className: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700',
|
||||
icon: CircleCheck,
|
||||
},
|
||||
connecting: {
|
||||
label: t('bots.runtimeConnecting'),
|
||||
className: 'border-amber-500/30 bg-amber-500/10 text-amber-700',
|
||||
icon: Loader2,
|
||||
},
|
||||
disconnected: {
|
||||
label: t('bots.runtimeDisconnected'),
|
||||
className: 'border-muted-foreground/20 bg-muted text-muted-foreground',
|
||||
icon: CircleOff,
|
||||
},
|
||||
error: {
|
||||
label: t('bots.runtimeError'),
|
||||
className: 'border-destructive/30 bg-destructive/10 text-destructive',
|
||||
icon: CircleAlert,
|
||||
},
|
||||
}[value];
|
||||
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('h-6 gap-1.5 px-2 text-xs', config.className)}
|
||||
>
|
||||
<Icon
|
||||
className={cn('size-3.5', value === 'connecting' && 'animate-spin')}
|
||||
/>
|
||||
{config.label}
|
||||
</Badge>
|
||||
{status?.connection_error && (
|
||||
<span className="max-w-[360px] truncate text-xs text-destructive">
|
||||
{status.connection_error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BotDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
@@ -58,16 +129,24 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
// Enable state managed here so the header switch works
|
||||
const [botEnabled, setBotEnabled] = useState(true);
|
||||
const [enableLoaded, setEnableLoaded] = useState(false);
|
||||
const [botDetail, setBotDetail] = useState<Bot | null>(null);
|
||||
|
||||
const fetchBotDetail = useCallback(async () => {
|
||||
if (isCreateMode) return;
|
||||
const res = await httpClient.getBot(id);
|
||||
setBotDetail(res.bot);
|
||||
setBotEnabled(res.bot.enable ?? true);
|
||||
setEnableLoaded(true);
|
||||
}, [id, isCreateMode]);
|
||||
|
||||
// Fetch bot enable state
|
||||
useEffect(() => {
|
||||
if (!isCreateMode) {
|
||||
httpClient.getBot(id).then((res) => {
|
||||
setBotEnabled(res.bot.enable ?? true);
|
||||
setEnableLoaded(true);
|
||||
});
|
||||
fetchBotDetail();
|
||||
const timer = window.setInterval(fetchBotDetail, 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
}
|
||||
}, [id, isCreateMode]);
|
||||
}, [fetchBotDetail, isCreateMode]);
|
||||
|
||||
const handleEnableToggle = useCallback(
|
||||
async (checked: boolean) => {
|
||||
@@ -95,9 +174,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
|
||||
function handleFormSubmit() {
|
||||
// Re-sync enable state after form save (form may update enable too)
|
||||
httpClient.getBot(id).then((res) => {
|
||||
setBotEnabled(res.bot.enable ?? true);
|
||||
});
|
||||
fetchBotDetail();
|
||||
refreshBots();
|
||||
}
|
||||
|
||||
@@ -173,6 +250,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
<RuntimeStatusBadge status={getBotRuntimeStatus(botDetail)} />
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
@@ -624,7 +624,10 @@ export default function DynamicFormComponent({
|
||||
onSuccess={(credentials) => {
|
||||
for (const [key, value] of Object.entries(credentials)) {
|
||||
if (value) {
|
||||
form.setValue(key as keyof FormValues, value as never);
|
||||
form.setValue(key as keyof FormValues, value as never, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -67,6 +67,18 @@ import SettingsDialog, {
|
||||
import ToolResourceSelectors from '@/app/home/components/dynamic-form/ToolResourceSelectors';
|
||||
import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '@/app/home/components/models-dialog/types';
|
||||
|
||||
const EMPTY_SELECT_ITEM_VALUE = '__langbot_empty_select_item_value__';
|
||||
|
||||
function toSelectValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function hasNonEmptyUuid<T extends { uuid?: string | null }>(
|
||||
item: T,
|
||||
): item is T & { uuid: string } {
|
||||
return typeof item.uuid === 'string' && item.uuid.length > 0;
|
||||
}
|
||||
|
||||
export default function DynamicFormItemComponent({
|
||||
config,
|
||||
field,
|
||||
@@ -380,19 +392,33 @@ export default function DynamicFormItemComponent({
|
||||
</div>
|
||||
);
|
||||
|
||||
case DynamicFormItemType.SELECT:
|
||||
case DynamicFormItemType.SELECT: {
|
||||
const hasEmptyOption =
|
||||
config.options?.some((option) => option.name === '') ?? false;
|
||||
const selectValue =
|
||||
hasEmptyOption && field.value === ''
|
||||
? EMPTY_SELECT_ITEM_VALUE
|
||||
: toSelectValue(field.value);
|
||||
|
||||
return (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<Select
|
||||
value={selectValue}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === EMPTY_SELECT_ITEM_VALUE ? '' : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full max-w-md bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('common.select')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{config.options?.map((option) => (
|
||||
{config.options?.map((option, index) => (
|
||||
<SelectItem
|
||||
key={option.name}
|
||||
value={option.name}
|
||||
description={option.name}
|
||||
key={`${option.name}-${index}`}
|
||||
value={
|
||||
option.name === '' ? EMPTY_SELECT_ITEM_VALUE : option.name
|
||||
}
|
||||
description={option.name || undefined}
|
||||
>
|
||||
{extractI18nObject(option.label)}
|
||||
</SelectItem>
|
||||
@@ -401,6 +427,7 @@ export default function DynamicFormItemComponent({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
case DynamicFormItemType.LLM_MODEL_SELECTOR:
|
||||
// Separate space models from regular models
|
||||
@@ -455,7 +482,7 @@ export default function DynamicFormItemComponent({
|
||||
{Object.entries(groupedModels).map(([providerName, models]) => (
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>{providerName}</SelectLabel>
|
||||
{models.map((model) => (
|
||||
{models.filter(hasNonEmptyUuid).map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{model.name}
|
||||
@@ -559,7 +586,7 @@ export default function DynamicFormItemComponent({
|
||||
{providerName}
|
||||
</span>
|
||||
</SelectLabel>
|
||||
{models.map((model) => (
|
||||
{models.filter(hasNonEmptyUuid).map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{model.name}
|
||||
@@ -656,7 +683,7 @@ export default function DynamicFormItemComponent({
|
||||
([providerName, models]) => (
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>{providerName}</SelectLabel>
|
||||
{models.map((model) => (
|
||||
{models.filter(hasNonEmptyUuid).map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
@@ -748,7 +775,7 @@ export default function DynamicFormItemComponent({
|
||||
{providerName}
|
||||
</span>
|
||||
</SelectLabel>
|
||||
{models.map((model) => (
|
||||
{models.filter(hasNonEmptyUuid).map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
@@ -813,7 +840,7 @@ export default function DynamicFormItemComponent({
|
||||
([providerName, models]) => (
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>{providerName}</SelectLabel>
|
||||
{models.map((model) => (
|
||||
{models.filter(hasNonEmptyUuid).map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
@@ -908,7 +935,7 @@ export default function DynamicFormItemComponent({
|
||||
([providerName, models]) => (
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>{providerName}</SelectLabel>
|
||||
{models.map((model) => (
|
||||
{models.filter(hasNonEmptyUuid).map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{model.name}
|
||||
@@ -1013,7 +1040,7 @@ export default function DynamicFormItemComponent({
|
||||
{providerName}
|
||||
</span>
|
||||
</SelectLabel>
|
||||
{models.map((model) => (
|
||||
{models.filter(hasNonEmptyUuid).map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{model.name}
|
||||
@@ -1223,8 +1250,8 @@ export default function DynamicFormItemComponent({
|
||||
{Object.entries(kbsByEngine).map(([engineName, kbs]) => (
|
||||
<SelectGroup key={engineName}>
|
||||
<SelectLabel>{engineName}</SelectLabel>
|
||||
{kbs.map((base) => (
|
||||
<SelectItem key={base.uuid} value={base.uuid ?? ''}>
|
||||
{kbs.filter(hasNonEmptyUuid).map((base) => (
|
||||
<SelectItem key={base.uuid} value={base.uuid}>
|
||||
<div className="flex items-center gap-2">
|
||||
{base.emoji && (
|
||||
<span className="text-sm shrink-0">{base.emoji}</span>
|
||||
@@ -1417,8 +1444,8 @@ export default function DynamicFormItemComponent({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{bots.map((bot) => (
|
||||
<SelectItem key={bot.uuid} value={bot.uuid ?? ''}>
|
||||
{bots.filter(hasNonEmptyUuid).map((bot) => (
|
||||
<SelectItem key={bot.uuid} value={bot.uuid}>
|
||||
{bot.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
@@ -16,7 +16,12 @@ import {
|
||||
} from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot';
|
||||
export type QrLoginPlatform =
|
||||
| 'feishu'
|
||||
| 'weixin'
|
||||
| 'dingtalk'
|
||||
| 'wecombot'
|
||||
| 'itchat';
|
||||
|
||||
interface PlatformConfig {
|
||||
titleKey: string;
|
||||
@@ -92,6 +97,20 @@ const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
||||
}),
|
||||
successNoteKey: 'wecombot.robotNameNote',
|
||||
},
|
||||
itchat: {
|
||||
titleKey: 'itchat.scanLogin',
|
||||
connectingKey: 'itchat.connecting',
|
||||
scanQRCodeKey: 'itchat.scanQRCode',
|
||||
waitingKey: 'itchat.waitingForScan',
|
||||
successKey: 'itchat.loginSuccess',
|
||||
failedKey: 'itchat.loginFailed',
|
||||
retryKey: 'itchat.retry',
|
||||
apiBase: '/api/v1/platform/adapters/itchat/login',
|
||||
extractSuccess: (data) => ({
|
||||
account_id: data.wxid || '',
|
||||
nickname: data.nickname || '',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
interface QrCodeLoginDialogProps {
|
||||
@@ -116,6 +135,7 @@ export default function QrCodeLoginDialog({
|
||||
|
||||
const [state, setState] = useState<DialogState>('connecting');
|
||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||
const qrDataUrlRef = useRef('');
|
||||
const [expireIn, setExpireIn] = useState(0);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -176,6 +196,7 @@ export default function QrCodeLoginDialog({
|
||||
cleanedRef.current = false;
|
||||
setState('connecting');
|
||||
setQrDataUrl('');
|
||||
qrDataUrlRef.current = '';
|
||||
setExpireIn(0);
|
||||
setErrorMessage('');
|
||||
|
||||
@@ -204,12 +225,14 @@ export default function QrCodeLoginDialog({
|
||||
|
||||
if (qr_data_url) {
|
||||
setQrDataUrl(qr_data_url);
|
||||
qrDataUrlRef.current = qr_data_url;
|
||||
} else if (qr_url) {
|
||||
const dataUrl = await QRCode.toDataURL(qr_url, {
|
||||
width: 224,
|
||||
margin: 2,
|
||||
});
|
||||
setQrDataUrl(dataUrl);
|
||||
qrDataUrlRef.current = dataUrl;
|
||||
}
|
||||
setState('waiting');
|
||||
|
||||
@@ -289,6 +312,19 @@ export default function QrCodeLoginDialog({
|
||||
cleanup();
|
||||
setExpireIn(0);
|
||||
setState('expired');
|
||||
} else if (status === 'waiting') {
|
||||
// Update QR data URL if regenerated (e.g. itchat QR expiry)
|
||||
if (rest.qr_data_url && rest.qr_data_url !== qrDataUrlRef.current) {
|
||||
setQrDataUrl(rest.qr_data_url);
|
||||
qrDataUrlRef.current = rest.qr_data_url;
|
||||
}
|
||||
if (rest.expire_at) {
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
Math.floor(rest.expire_at - Date.now() / 1000),
|
||||
);
|
||||
setExpireIn(remaining);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore poll errors
|
||||
|
||||
@@ -179,6 +179,28 @@ export interface ApiRespPlatformBot {
|
||||
bot: Bot;
|
||||
}
|
||||
|
||||
export type BotAdapterConnectionStatus =
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'disconnected'
|
||||
| 'error';
|
||||
|
||||
export interface BotAdapterRuntimeStatus {
|
||||
connection_status?: BotAdapterConnectionStatus;
|
||||
connection_error?: string;
|
||||
last_connected_at?: number | null;
|
||||
last_disconnected_at?: number | null;
|
||||
}
|
||||
|
||||
export interface BotAdapterRuntimeValues {
|
||||
bot_account_id?: string;
|
||||
webhook_url?: string | null;
|
||||
webhook_full_url?: string | null;
|
||||
extra_webhook_full_url?: string | null;
|
||||
runtime_status?: BotAdapterRuntimeStatus;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Bot {
|
||||
uuid?: string;
|
||||
name: string;
|
||||
@@ -191,7 +213,7 @@ export interface Bot {
|
||||
pipeline_routing_rules?: PipelineRoutingRule[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
adapter_runtime_values?: object;
|
||||
adapter_runtime_values?: BotAdapterRuntimeValues;
|
||||
}
|
||||
|
||||
export type RoutingRuleOperator =
|
||||
|
||||
@@ -354,6 +354,10 @@ const enUS = {
|
||||
log: 'Log',
|
||||
configuration: 'Configuration',
|
||||
logs: 'Logs',
|
||||
runtimeConnected: 'Connected',
|
||||
runtimeConnecting: 'Connecting',
|
||||
runtimeDisconnected: 'Disconnected',
|
||||
runtimeError: 'Connection error',
|
||||
basicInfo: 'Basic Information',
|
||||
basicInfoDescription: 'Set the bot name and description',
|
||||
routingConnection: 'Routing & Connection',
|
||||
@@ -1769,6 +1773,16 @@ const enUS = {
|
||||
loginSuccess: 'Login successful! Token has been filled in',
|
||||
loginFailed: 'Login failed',
|
||||
},
|
||||
itchat: {
|
||||
scanLogin: 'Scan QR Login',
|
||||
scanQRCode: 'Scan the QR code below with WeChat to login',
|
||||
loginSuccess:
|
||||
'Login successful! Session cached. Save config to start using.',
|
||||
loginFailed: 'Login failed',
|
||||
connecting: 'Starting WeChat login...',
|
||||
waitingForScan: 'Waiting for scan',
|
||||
retry: 'Retry',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: 'One-Click Create DingTalk App',
|
||||
scanQRCode:
|
||||
|
||||
@@ -360,6 +360,10 @@ const jaJP = {
|
||||
log: 'ログ',
|
||||
configuration: '設定',
|
||||
logs: 'ログ',
|
||||
runtimeConnected: '接続済み',
|
||||
runtimeConnecting: '接続中',
|
||||
runtimeDisconnected: '切断済み',
|
||||
runtimeError: '接続エラー',
|
||||
basicInfo: '基本情報',
|
||||
basicInfoDescription: 'ボットの名前と説明を設定',
|
||||
routingConnection: 'ルーティングと接続',
|
||||
|
||||
@@ -339,6 +339,10 @@ const zhHans = {
|
||||
log: '日志',
|
||||
configuration: '配置',
|
||||
logs: '日志',
|
||||
runtimeConnected: '已连接',
|
||||
runtimeConnecting: '连接中',
|
||||
runtimeDisconnected: '已掉线',
|
||||
runtimeError: '连接错误',
|
||||
basicInfo: '基础信息',
|
||||
basicInfoDescription: '设置机器人名称和描述',
|
||||
routingConnection: '路由与连接',
|
||||
@@ -1691,6 +1695,15 @@ const zhHans = {
|
||||
loginSuccess: '登录成功!令牌已自动填入',
|
||||
loginFailed: '登录失败',
|
||||
},
|
||||
itchat: {
|
||||
scanLogin: '扫码登录微信',
|
||||
scanQRCode: '请使用微信扫描以下二维码登录',
|
||||
loginSuccess: '登录成功!会话已缓存,保存配置后即可使用',
|
||||
loginFailed: '登录失败',
|
||||
connecting: '正在启动微信登录...',
|
||||
waitingForScan: '等待扫码中',
|
||||
retry: '重试',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: '一键创建钉钉应用',
|
||||
scanQRCode: '请使用钉钉扫描以下二维码,授权后将自动创建应用并填写凭据',
|
||||
|
||||
Reference in New Issue
Block a user