feat: enhance itchat adapter with runtime status tracking and UI integration

This commit is contained in:
fdc310
2026-07-02 13:56:24 +08:00
parent 78fb40a28a
commit d4e8ccd161
8 changed files with 292 additions and 61 deletions
@@ -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)
+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 [
+151 -34
View File
@@ -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
+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"
+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 =
+4
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',
+4
View File
@@ -360,6 +360,10 @@ const jaJP = {
log: 'ログ',
configuration: '設定',
logs: 'ログ',
runtimeConnected: '接続済み',
runtimeConnecting: '接続中',
runtimeDisconnected: '切断済み',
runtimeError: '接続エラー',
basicInfo: '基本情報',
basicInfoDescription: 'ボットの名前と説明を設定',
routingConnection: 'ルーティングと接続',
+4
View File
@@ -339,6 +339,10 @@ const zhHans = {
log: '日志',
configuration: '配置',
logs: '日志',
runtimeConnected: '已连接',
runtimeConnecting: '连接中',
runtimeDisconnected: '已掉线',
runtimeError: '连接错误',
basicInfo: '基础信息',
basicInfoDescription: '设置机器人名称和描述',
routingConnection: '路由与连接',