Compare commits

...

4 Commits

Author SHA1 Message Date
fdc310 1ed107c9d5 feat(dynamic-form): enhance select handling with empty option support and UUID filtering 2026-07-06 13:36:58 +08:00
fdc310 0cce418956 feat(itchat): improve login session handling and error reporting 2026-07-02 14:40:13 +08:00
fdc310 d4e8ccd161 feat: enhance itchat adapter with runtime status tracking and UI integration 2026-07-02 13:56:24 +08:00
fdc310 78fb40a28a feat: add itchat-uos WeChat adapter with QR code login
- Add itchat-uos adapter supporting personal WeChat via QR code login
- Implement message/event converters for text, image, voice, sharing types
- Bridge sync itchat callbacks to async LangBot pipeline via asyncio
- Add QR login API endpoints with session management
- Add frontend QR code login dialog integration
- Fix plugin connector handler attribute check
- Use fresh Core instance per login to avoid singleton state pollution
2026-07-01 18:03:26 +08:00
15 changed files with 1320 additions and 30 deletions
+1
View File
@@ -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={})
+2
View File
@@ -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 [
+772
View File
@@ -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
+1 -1
View File
@@ -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
Generated
+21
View File
@@ -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"
+87 -9
View File
@@ -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
+23 -1
View File
@@ -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 =
+14
View File
@@ -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:
+4
View File
@@ -360,6 +360,10 @@ const jaJP = {
log: 'ログ',
configuration: '設定',
logs: 'ログ',
runtimeConnected: '接続済み',
runtimeConnecting: '接続中',
runtimeDisconnected: '切断済み',
runtimeError: '接続エラー',
basicInfo: '基本情報',
basicInfoDescription: 'ボットの名前と説明を設定',
routingConnection: 'ルーティングと接続',
+13
View File
@@ -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: '请使用钉钉扫描以下二维码,授权后将自动创建应用并填写凭据',