diff --git a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py index 3e9bd154d..97eddfa56 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py @@ -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) diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index 995267cf5..c48b49e50 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -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 [ diff --git a/src/langbot/pkg/platform/sources/itchat.py b/src/langbot/pkg/platform/sources/itchat.py index 982eb33b4..0a4aab95a 100644 --- a/src/langbot/pkg/platform/sources/itchat.py +++ b/src/langbot/pkg/platform/sources/itchat.py @@ -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 diff --git a/web/src/app/home/bots/BotDetailContent.tsx b/web/src/app/home/bots/BotDetailContent.tsx index 8440e47ac..738b24136 100644 --- a/web/src/app/home/bots/BotDetailContent.tsx +++ b/web/src/app/home/bots/BotDetailContent.tsx @@ -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 ( +