mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-16 17:36:07 +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 mimetypes
|
||||
import asyncio
|
||||
import os
|
||||
from ... import group
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
@@ -688,6 +689,10 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
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 = {
|
||||
'status': 'pending',
|
||||
@@ -704,12 +709,11 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
|
||||
def _run_itchat_login():
|
||||
try:
|
||||
import os
|
||||
|
||||
from itchat.core import Core
|
||||
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:
|
||||
os.remove(f)
|
||||
except OSError:
|
||||
@@ -722,16 +726,8 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
_core.get_friends(update=True)
|
||||
user_info = _core.loginInfo.get('User', {})
|
||||
nick = (
|
||||
getattr(user_info, 'NickName', '') or user_info.get('NickName', '')
|
||||
if isinstance(user_info, dict)
|
||||
else 'unknown'
|
||||
)
|
||||
wxid = (
|
||||
getattr(user_info, 'UserName', '') or user_info.get('UserName', '')
|
||||
if isinstance(user_info, dict)
|
||||
else ''
|
||||
)
|
||||
nick = ItchatAdapter._get_obj_value(user_info, 'NickName', 'unknown')
|
||||
wxid = ItchatAdapter._get_obj_value(user_info, 'UserName')
|
||||
except Exception:
|
||||
nick = 'unknown'
|
||||
wxid = ''
|
||||
@@ -742,8 +738,12 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
print(f'[itchat-login] Login success: {nick}', flush=True)
|
||||
# Dump login status so the adapter can hot-reload it
|
||||
try:
|
||||
_core.dump_login_status('itchat.pkl')
|
||||
print('[itchat-login] Session saved to itchat.pkl', flush=True)
|
||||
account_status_path = ItchatAdapter.login_status_path_for_account(wxid)
|
||||
_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:
|
||||
pass
|
||||
# Stop the message loop - we only needed the session for QR login
|
||||
@@ -813,6 +813,7 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
'session_id': session_id,
|
||||
'status': 'success',
|
||||
'nickname': session['nickname'],
|
||||
'wxid': session.get('wxid', ''),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -845,8 +846,7 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
if session['status'] == 'success':
|
||||
data['nickname'] = session.get('nickname', '')
|
||||
data['wxid'] = session.get('wxid', '')
|
||||
# Keep session alive (don't pop) - itchat thread keeps running
|
||||
# so the pickle file is valid for the adapter to reuse
|
||||
_itchat_login_sessions.pop(session_id, None)
|
||||
elif session['status'] == 'error':
|
||||
data['error'] = session['error']
|
||||
_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)
|
||||
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 [
|
||||
|
||||
@@ -12,8 +12,10 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import typing
|
||||
|
||||
@@ -228,7 +230,7 @@ class ItchatEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
|
||||
return platform_events.GroupMessage(
|
||||
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,
|
||||
permission=platform_entities.Permission.Member,
|
||||
group=platform_entities.Group(
|
||||
@@ -285,6 +287,11 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
_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
|
||||
@@ -308,6 +315,67 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
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 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):
|
||||
"""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']
|
||||
user_info = self._core.loginInfo.get('User', {})
|
||||
nick_name = (
|
||||
getattr(user_info, 'NickName', '') or user_info.get('NickName', '')
|
||||
if isinstance(user_info, dict)
|
||||
else ''
|
||||
)
|
||||
user_name = (
|
||||
getattr(user_info, 'UserName', '') or user_info.get('UserName', '')
|
||||
if isinstance(user_info, dict)
|
||||
else ''
|
||||
)
|
||||
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
|
||||
@@ -336,23 +396,27 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
# _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')
|
||||
|
||||
# Log available groups
|
||||
chatrooms = self._core.search_chatrooms()
|
||||
group_names = []
|
||||
for c in chatrooms:
|
||||
name = getattr(c, 'NickName', '') or c.get('NickName', '') if isinstance(c, dict) else str(c)
|
||||
if name:
|
||||
group_names.append(name)
|
||||
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')
|
||||
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()
|
||||
|
||||
@@ -412,6 +476,7 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
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):
|
||||
@@ -472,7 +537,7 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
self._loop,
|
||||
)
|
||||
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(
|
||||
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)
|
||||
self._cleanup_temp(temp_path)
|
||||
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]:
|
||||
"""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)
|
||||
return temp_path
|
||||
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
|
||||
|
||||
def _cleanup_temp(self, path: str):
|
||||
@@ -551,6 +616,34 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
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,
|
||||
@@ -567,7 +660,7 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
if not from_user:
|
||||
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(
|
||||
self,
|
||||
@@ -594,6 +687,9 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
itchat will reuse it 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...')
|
||||
|
||||
@@ -603,27 +699,45 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
# 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(
|
||||
'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:
|
||||
self.logger.error(
|
||||
'No cached WeChat session found. Please scan the QR code in the bot config page first.'
|
||||
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('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
|
||||
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:
|
||||
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._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():
|
||||
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}')
|
||||
|
||||
@@ -650,5 +766,6 @@ class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
self._core.isLogging = False
|
||||
except Exception:
|
||||
pass
|
||||
self._set_connection_status('disconnected')
|
||||
await self.logger.info('itchat adapter stopped')
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user