mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 20:50:58 +00:00
feat: enhance itchat adapter with runtime status tracking and UI integration
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import quart
|
import quart
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
from ... import group
|
from ... import group
|
||||||
from langbot.pkg.utils import importutil
|
from langbot.pkg.utils import importutil
|
||||||
|
|
||||||
@@ -688,6 +689,10 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
session_id = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
|
status_dir = os.path.join('data', 'itchat')
|
||||||
|
os.makedirs(status_dir, exist_ok=True)
|
||||||
|
default_status_path = os.path.join(status_dir, 'itchat.pkl')
|
||||||
|
qr_path = os.path.join(status_dir, f'{session_id}-QR.png')
|
||||||
|
|
||||||
session = {
|
session = {
|
||||||
'status': 'pending',
|
'status': 'pending',
|
||||||
@@ -704,12 +709,11 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
def _run_itchat_login():
|
def _run_itchat_login():
|
||||||
try:
|
try:
|
||||||
import os
|
|
||||||
|
|
||||||
from itchat.core import Core
|
from itchat.core import Core
|
||||||
from itchat.content import TEXT as _TEXT
|
from itchat.content import TEXT as _TEXT
|
||||||
|
from langbot.pkg.platform.sources.itchat import ItchatAdapter
|
||||||
|
|
||||||
for f in ('itchat.pkl', 'QR.png'):
|
for f in (qr_path,):
|
||||||
try:
|
try:
|
||||||
os.remove(f)
|
os.remove(f)
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -722,16 +726,8 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
try:
|
try:
|
||||||
_core.get_friends(update=True)
|
_core.get_friends(update=True)
|
||||||
user_info = _core.loginInfo.get('User', {})
|
user_info = _core.loginInfo.get('User', {})
|
||||||
nick = (
|
nick = ItchatAdapter._get_obj_value(user_info, 'NickName', 'unknown')
|
||||||
getattr(user_info, 'NickName', '') or user_info.get('NickName', '')
|
wxid = ItchatAdapter._get_obj_value(user_info, 'UserName')
|
||||||
if isinstance(user_info, dict)
|
|
||||||
else 'unknown'
|
|
||||||
)
|
|
||||||
wxid = (
|
|
||||||
getattr(user_info, 'UserName', '') or user_info.get('UserName', '')
|
|
||||||
if isinstance(user_info, dict)
|
|
||||||
else ''
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
nick = 'unknown'
|
nick = 'unknown'
|
||||||
wxid = ''
|
wxid = ''
|
||||||
@@ -742,8 +738,12 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
print(f'[itchat-login] Login success: {nick}', flush=True)
|
print(f'[itchat-login] Login success: {nick}', flush=True)
|
||||||
# Dump login status so the adapter can hot-reload it
|
# Dump login status so the adapter can hot-reload it
|
||||||
try:
|
try:
|
||||||
_core.dump_login_status('itchat.pkl')
|
account_status_path = ItchatAdapter.login_status_path_for_account(wxid)
|
||||||
print('[itchat-login] Session saved to itchat.pkl', flush=True)
|
_core.dump_login_status(account_status_path)
|
||||||
|
if account_status_path != default_status_path:
|
||||||
|
_core.dump_login_status(default_status_path)
|
||||||
|
session['login_status_path'] = account_status_path
|
||||||
|
print(f'[itchat-login] Session saved to {account_status_path}', flush=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Stop the message loop - we only needed the session for QR login
|
# Stop the message loop - we only needed the session for QR login
|
||||||
@@ -813,6 +813,7 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
'session_id': session_id,
|
'session_id': session_id,
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'nickname': session['nickname'],
|
'nickname': session['nickname'],
|
||||||
|
'wxid': session.get('wxid', ''),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -845,8 +846,7 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
if session['status'] == 'success':
|
if session['status'] == 'success':
|
||||||
data['nickname'] = session.get('nickname', '')
|
data['nickname'] = session.get('nickname', '')
|
||||||
data['wxid'] = session.get('wxid', '')
|
data['wxid'] = session.get('wxid', '')
|
||||||
# Keep session alive (don't pop) - itchat thread keeps running
|
_itchat_login_sessions.pop(session_id, None)
|
||||||
# so the pickle file is valid for the adapter to reuse
|
|
||||||
elif session['status'] == 'error':
|
elif session['status'] == 'error':
|
||||||
data['error'] = session['error']
|
data['error'] = session['error']
|
||||||
_itchat_login_sessions.pop(session_id, None)
|
_itchat_login_sessions.pop(session_id, None)
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ class BotService:
|
|||||||
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
|
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
|
||||||
if runtime_bot is not None:
|
if runtime_bot is not None:
|
||||||
adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id
|
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)
|
# Webhook URL for unified webhook adapters (independent of bot running state)
|
||||||
if persistence_bot['adapter'] in [
|
if persistence_bot['adapter'] in [
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
@@ -228,7 +230,7 @@ class ItchatEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
|
|
||||||
return platform_events.GroupMessage(
|
return platform_events.GroupMessage(
|
||||||
sender=platform_entities.GroupMember(
|
sender=platform_entities.GroupMember(
|
||||||
id=actual_nick or actual_user, # use nickname for @ mention
|
id=actual_user or actual_nick,
|
||||||
member_name=actual_nick or actual_user,
|
member_name=actual_nick or actual_user,
|
||||||
permission=platform_entities.Permission.Member,
|
permission=platform_entities.Permission.Member,
|
||||||
group=platform_entities.Group(
|
group=platform_entities.Group(
|
||||||
@@ -285,6 +287,11 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
_core: typing.Optional[ItchatCore] = pydantic.PrivateAttr(default=None)
|
_core: typing.Optional[ItchatCore] = pydantic.PrivateAttr(default=None)
|
||||||
_bot_nickname: str = pydantic.PrivateAttr(default='')
|
_bot_nickname: str = pydantic.PrivateAttr(default='')
|
||||||
_bot_uuid: typing.Optional[str] = pydantic.PrivateAttr(default=None)
|
_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:
|
class Config:
|
||||||
arbitrary_types_allowed = True
|
arbitrary_types_allowed = True
|
||||||
@@ -308,6 +315,67 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
self._logged_in = threading.Event()
|
self._logged_in = threading.Event()
|
||||||
self._itchat_thread = None
|
self._itchat_thread = None
|
||||||
self._core = ItchatCore()
|
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 or 'itchat'
|
||||||
|
|
||||||
|
@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:
|
||||||
|
filename = f'{cls._safe_status_name(account_id)}.pkl' if account_id else 'itchat.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 account_id:
|
||||||
|
account_path = self.login_status_path_for_account(account_id)
|
||||||
|
if os.path.exists(account_path):
|
||||||
|
return account_path
|
||||||
|
|
||||||
|
return self.login_status_path_for_account()
|
||||||
|
|
||||||
|
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):
|
def _on_login(self):
|
||||||
"""Called by itchat after successful QR code login."""
|
"""Called by itchat after successful QR code login."""
|
||||||
@@ -318,16 +386,8 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
# Get bot's own WeChat info from loginInfo['User']
|
# Get bot's own WeChat info from loginInfo['User']
|
||||||
user_info = self._core.loginInfo.get('User', {})
|
user_info = self._core.loginInfo.get('User', {})
|
||||||
nick_name = (
|
nick_name = self._get_obj_value(user_info, 'NickName')
|
||||||
getattr(user_info, 'NickName', '') or user_info.get('NickName', '')
|
user_name = self._get_obj_value(user_info, 'UserName')
|
||||||
if isinstance(user_info, dict)
|
|
||||||
else ''
|
|
||||||
)
|
|
||||||
user_name = (
|
|
||||||
getattr(user_info, 'UserName', '') or user_info.get('UserName', '')
|
|
||||||
if isinstance(user_info, dict)
|
|
||||||
else ''
|
|
||||||
)
|
|
||||||
|
|
||||||
# bot_account_id: config override or auto-detected wxid
|
# bot_account_id: config override or auto-detected wxid
|
||||||
# Used by AtBotRule for matching At.target
|
# Used by AtBotRule for matching At.target
|
||||||
@@ -336,23 +396,27 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
# _bot_nickname: config override or auto-detected nickname
|
# _bot_nickname: config override or auto-detected nickname
|
||||||
configured_nick = self.config.get('nickname', '').strip()
|
configured_nick = self.config.get('nickname', '').strip()
|
||||||
self._bot_nickname = configured_nick or nick_name
|
self._bot_nickname = configured_nick or nick_name
|
||||||
|
self._set_connection_status('connected')
|
||||||
|
|
||||||
# Log available groups
|
try:
|
||||||
chatrooms = self._core.search_chatrooms()
|
chatrooms = self._core.search_chatrooms() or []
|
||||||
group_names = []
|
group_names = []
|
||||||
for c in chatrooms:
|
for c in chatrooms:
|
||||||
name = getattr(c, 'NickName', '') or c.get('NickName', '') if isinstance(c, dict) else str(c)
|
name = self._get_obj_value(c, 'NickName', str(c))
|
||||||
if name:
|
if name:
|
||||||
group_names.append(name)
|
group_names.append(name)
|
||||||
|
|
||||||
if group_names:
|
if group_names:
|
||||||
self._log_sync(
|
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 ""}'
|
f'itchat login as {nick_name} ({user_name}) | Groups ({len(group_names)}): {", ".join(group_names[:10])}{"..." if len(group_names) > 10 else ""}'
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._log_sync(f'itchat login as {nick_name} ({user_name}) | No groups found')
|
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:
|
except Exception as e:
|
||||||
self.bot_account_id = f'WeChat Bot (Error: {e})'
|
self.bot_account_id = f'WeChat Bot (Error: {e})'
|
||||||
|
self._set_connection_status('error', str(e))
|
||||||
finally:
|
finally:
|
||||||
self._logged_in.set()
|
self._logged_in.set()
|
||||||
|
|
||||||
@@ -412,6 +476,7 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
def _on_exit(self):
|
def _on_exit(self):
|
||||||
"""Called by itchat on exit."""
|
"""Called by itchat on exit."""
|
||||||
|
self._set_connection_status('disconnected')
|
||||||
self._log_sync('itchat session exited')
|
self._log_sync('itchat session exited')
|
||||||
|
|
||||||
def _register_itchat_handlers(self):
|
def _register_itchat_handlers(self):
|
||||||
@@ -472,7 +537,7 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
self._loop,
|
self._loop,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.error(f'Error dispatching itchat message: {traceback.format_exc()}')
|
self._log_sync(f'Error dispatching itchat message: {traceback.format_exc()}', 'error')
|
||||||
|
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
@@ -513,7 +578,7 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
await loop.run_in_executor(None, self._core.send, f'@fil@{temp_path}', target_id)
|
await loop.run_in_executor(None, self._core.send, f'@fil@{temp_path}', target_id)
|
||||||
self._cleanup_temp(temp_path)
|
self._cleanup_temp(temp_path)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.error(f'Failed to send itchat message: {traceback.format_exc()}')
|
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]:
|
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."""
|
"""Save base64 or URL data to a temp file and return the path."""
|
||||||
@@ -540,7 +605,7 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
f.write(resp.content)
|
f.write(resp.content)
|
||||||
return temp_path
|
return temp_path
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.error(f'Failed to save temp file: {traceback.format_exc()}')
|
self._log_sync(f'Failed to save temp file: {traceback.format_exc()}', 'error')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _cleanup_temp(self, path: str):
|
def _cleanup_temp(self, path: str):
|
||||||
@@ -551,6 +616,34 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
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(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -567,7 +660,7 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
if not from_user:
|
if not from_user:
|
||||||
return
|
return
|
||||||
|
|
||||||
await self.send_message('friend', from_user, message)
|
await self.send_message('friend', from_user, self._prepare_reply_message(message_source, message))
|
||||||
|
|
||||||
def register_listener(
|
def register_listener(
|
||||||
self,
|
self,
|
||||||
@@ -594,6 +687,9 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
itchat will reuse it without requiring a new QR scan.
|
itchat will reuse it without requiring a new QR scan.
|
||||||
"""
|
"""
|
||||||
self._loop = asyncio.get_running_loop()
|
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...')
|
await self.logger.info('itchat adapter starting...')
|
||||||
|
|
||||||
@@ -603,27 +699,45 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
# Run itchat in a daemon thread (it blocks)
|
# Run itchat in a daemon thread (it blocks)
|
||||||
def _run_itchat():
|
def _run_itchat():
|
||||||
try:
|
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
|
# Use hotReload to reuse the cached session from QR login
|
||||||
# If no cache exists, fail fast instead of triggering QR login
|
# If no cache exists, fail fast instead of triggering QR login
|
||||||
result = self._core.load_login_status(
|
result = self._core.load_login_status(
|
||||||
'itchat.pkl', loginCallback=self._on_login, exitCallback=self._on_exit
|
status_path, loginCallback=self._on_login, exitCallback=self._on_exit
|
||||||
)
|
)
|
||||||
if result['BaseResponse']['Ret'] != 0:
|
if result.get('BaseResponse', {}).get('Ret') != 0:
|
||||||
self.logger.error(
|
self._startup_error = (
|
||||||
'No cached WeChat session found. Please scan the QR code in the bot config page first.'
|
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()
|
self._logged_in.set()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Session loaded, start message loop
|
# Session loaded, start message loop
|
||||||
self._log_sync('WeChat session loaded from cache')
|
self._log_sync(f'WeChat session loaded from cache: {status_path}')
|
||||||
|
|
||||||
# Clear stale messages that itchat fetched during hot-reload
|
# Clear stale messages that itchat fetched during hot-reload
|
||||||
self._drain_msglist()
|
self._drain_msglist()
|
||||||
|
|
||||||
self._core.run(blockThread=True)
|
self._core.run(blockThread=True)
|
||||||
|
self._set_connection_status('disconnected')
|
||||||
|
self._log_sync('itchat message loop stopped', 'error')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._log_sync(f'itchat run error: {e}', 'error')
|
error = f'itchat run error: {e}'
|
||||||
|
self._set_connection_status('error', error)
|
||||||
|
self._log_sync(error, 'error')
|
||||||
self._logged_in.set()
|
self._logged_in.set()
|
||||||
|
|
||||||
self._itchat_thread = threading.Thread(target=_run_itchat, daemon=True, name='itchat-thread')
|
self._itchat_thread = threading.Thread(target=_run_itchat, daemon=True, name='itchat-thread')
|
||||||
@@ -634,6 +748,8 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
if not self._logged_in.is_set():
|
if not self._logged_in.is_set():
|
||||||
raise RuntimeError('itchat login timed out (300s)')
|
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}')
|
await self.logger.info(f'itchat adapter running, bot: {self.bot_account_id}')
|
||||||
|
|
||||||
@@ -650,5 +766,6 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
self._core.isLogging = False
|
self._core.isLogging = False
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
self._set_connection_status('disconnected')
|
||||||
await self.logger.info('itchat adapter stopped')
|
await self.logger.info('itchat adapter stopped')
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
@@ -26,9 +27,79 @@ import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-ses
|
|||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { cn } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
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 }) {
|
export default function BotDetailContent({ id }: { id: string }) {
|
||||||
const isCreateMode = id === 'new';
|
const isCreateMode = id === 'new';
|
||||||
@@ -58,16 +129,24 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
// Enable state managed here so the header switch works
|
// Enable state managed here so the header switch works
|
||||||
const [botEnabled, setBotEnabled] = useState(true);
|
const [botEnabled, setBotEnabled] = useState(true);
|
||||||
const [enableLoaded, setEnableLoaded] = useState(false);
|
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
|
// Fetch bot enable state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCreateMode) {
|
if (!isCreateMode) {
|
||||||
httpClient.getBot(id).then((res) => {
|
fetchBotDetail();
|
||||||
setBotEnabled(res.bot.enable ?? true);
|
const timer = window.setInterval(fetchBotDetail, 5000);
|
||||||
setEnableLoaded(true);
|
return () => window.clearInterval(timer);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [id, isCreateMode]);
|
}, [fetchBotDetail, isCreateMode]);
|
||||||
|
|
||||||
const handleEnableToggle = useCallback(
|
const handleEnableToggle = useCallback(
|
||||||
async (checked: boolean) => {
|
async (checked: boolean) => {
|
||||||
@@ -95,9 +174,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
|
|
||||||
function handleFormSubmit() {
|
function handleFormSubmit() {
|
||||||
// Re-sync enable state after form save (form may update enable too)
|
// Re-sync enable state after form save (form may update enable too)
|
||||||
httpClient.getBot(id).then((res) => {
|
fetchBotDetail();
|
||||||
setBotEnabled(res.bot.enable ?? true);
|
|
||||||
});
|
|
||||||
refreshBots();
|
refreshBots();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +250,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<RuntimeStatusBadge status={getBotRuntimeStatus(botDetail)} />
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -179,6 +179,28 @@ export interface ApiRespPlatformBot {
|
|||||||
bot: Bot;
|
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 {
|
export interface Bot {
|
||||||
uuid?: string;
|
uuid?: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -191,7 +213,7 @@ export interface Bot {
|
|||||||
pipeline_routing_rules?: PipelineRoutingRule[];
|
pipeline_routing_rules?: PipelineRoutingRule[];
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
adapter_runtime_values?: object;
|
adapter_runtime_values?: BotAdapterRuntimeValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RoutingRuleOperator =
|
export type RoutingRuleOperator =
|
||||||
|
|||||||
@@ -354,6 +354,10 @@ const enUS = {
|
|||||||
log: 'Log',
|
log: 'Log',
|
||||||
configuration: 'Configuration',
|
configuration: 'Configuration',
|
||||||
logs: 'Logs',
|
logs: 'Logs',
|
||||||
|
runtimeConnected: 'Connected',
|
||||||
|
runtimeConnecting: 'Connecting',
|
||||||
|
runtimeDisconnected: 'Disconnected',
|
||||||
|
runtimeError: 'Connection error',
|
||||||
basicInfo: 'Basic Information',
|
basicInfo: 'Basic Information',
|
||||||
basicInfoDescription: 'Set the bot name and description',
|
basicInfoDescription: 'Set the bot name and description',
|
||||||
routingConnection: 'Routing & Connection',
|
routingConnection: 'Routing & Connection',
|
||||||
|
|||||||
@@ -360,6 +360,10 @@ const jaJP = {
|
|||||||
log: 'ログ',
|
log: 'ログ',
|
||||||
configuration: '設定',
|
configuration: '設定',
|
||||||
logs: 'ログ',
|
logs: 'ログ',
|
||||||
|
runtimeConnected: '接続済み',
|
||||||
|
runtimeConnecting: '接続中',
|
||||||
|
runtimeDisconnected: '切断済み',
|
||||||
|
runtimeError: '接続エラー',
|
||||||
basicInfo: '基本情報',
|
basicInfo: '基本情報',
|
||||||
basicInfoDescription: 'ボットの名前と説明を設定',
|
basicInfoDescription: 'ボットの名前と説明を設定',
|
||||||
routingConnection: 'ルーティングと接続',
|
routingConnection: 'ルーティングと接続',
|
||||||
|
|||||||
@@ -339,6 +339,10 @@ const zhHans = {
|
|||||||
log: '日志',
|
log: '日志',
|
||||||
configuration: '配置',
|
configuration: '配置',
|
||||||
logs: '日志',
|
logs: '日志',
|
||||||
|
runtimeConnected: '已连接',
|
||||||
|
runtimeConnecting: '连接中',
|
||||||
|
runtimeDisconnected: '已掉线',
|
||||||
|
runtimeError: '连接错误',
|
||||||
basicInfo: '基础信息',
|
basicInfo: '基础信息',
|
||||||
basicInfoDescription: '设置机器人名称和描述',
|
basicInfoDescription: '设置机器人名称和描述',
|
||||||
routingConnection: '路由与连接',
|
routingConnection: '路由与连接',
|
||||||
|
|||||||
Reference in New Issue
Block a user