diff --git a/pyproject.toml b/pyproject.toml index d476e7767..2ca43c9c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", 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 435e74e8a..3e9bd154d 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py @@ -650,3 +650,223 @@ 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() + + 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: + import os + + from itchat.core import Core + from itchat.content import TEXT as _TEXT + + for f in ('itchat.pkl', 'QR.png'): + 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 = ( + 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 '' + ) + except Exception: + nick = 'unknown' + wxid = '' + session['nickname'] = nick + session['wxid'] = wxid + session['status'] = 'success' + session['logged_in'].set() + 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) + except Exception: + pass + # 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'], + } + ) + + 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/', 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', '') + # Keep session alive (don't pop) - itchat thread keeps running + # so the pickle file is valid for the adapter to reuse + elif session['status'] == 'error': + data['error'] = session['error'] + _itchat_login_sessions.pop(session_id, None) + + return self.success(data=data) + + @self.route('/itchat/login/', 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={}) diff --git a/src/langbot/pkg/platform/sources/itchat.py b/src/langbot/pkg/platform/sources/itchat.py new file mode 100644 index 000000000..982eb33b4 --- /dev/null +++ b/src/langbot/pkg/platform/sources/itchat.py @@ -0,0 +1,654 @@ +"""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 tempfile +import threading +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_nick or actual_user, # use nickname for @ mention + 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) + + 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() + + 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 = ( + 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 '' + ) + + # 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 + + # 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) + + 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.bot_account_id = f'WeChat Bot (Error: {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._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.logger.error(f'Error dispatching itchat message: {traceback.format_exc()}') + + 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: + 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.logger.error(f'Failed to save temp file: {traceback.format_exc()}') + return None + + def _cleanup_temp(self, path: str): + """Remove a temp file.""" + try: + if os.path.exists(path): + os.remove(path) + except OSError: + pass + + 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, 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 a cached session file (itchat.pkl) exists from a previous QR login, + itchat will reuse it without requiring a new QR scan. + """ + self._loop = asyncio.get_running_loop() + + 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: + # 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 + ) + if result['BaseResponse']['Ret'] != 0: + self.logger.error( + 'No cached WeChat session found. Please scan the QR code in the bot config page first.' + ) + self._logged_in.set() + return + + # Session loaded, start message loop + self._log_sync('WeChat session loaded from cache') + + # Clear stale messages that itchat fetched during hot-reload + self._drain_msglist() + + self._core.run(blockThread=True) + except Exception as e: + self._log_sync(f'itchat run error: {e}', '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)') + + 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 + await self.logger.info('itchat adapter stopped') + return True diff --git a/src/langbot/pkg/platform/sources/itchat.yaml b/src/langbot/pkg/platform/sources/itchat.yaml new file mode 100644 index 000000000..fb3ed16cf --- /dev/null +++ b/src/langbot/pkg/platform/sources/itchat.yaml @@ -0,0 +1,73 @@ +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: 機器人帳號標識 + description: + en_US: Used for @-mention matching in groups. Auto-filled after QR login (wxid). You can change it to match your bot's display nickname if needed. + zh_Hans: 用于群聊 @ 检测匹配。扫码登录后自动填入(微信ID),可手动改为机器人昵称 + zh_Hant: 用於群聊 @ 檢測匹配。掃碼登入後自動填入(微信ID),可手動改為機器人暱稱 + type: string + required: false + 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 diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 6075d4b68..68d4103cd 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -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 diff --git a/uv.lock b/uv.lock index ef3b6fddb..aba621df3 100644 --- a/uv.lock +++ b/uv.lock @@ -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" diff --git a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx index 2cc14493f..f962fc030 100644 --- a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx +++ b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx @@ -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 = { }), 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('connecting'); const [qrDataUrl, setQrDataUrl] = useState(''); + const qrDataUrlRef = useRef(''); const [expireIn, setExpireIn] = useState(0); const [errorMessage, setErrorMessage] = useState(''); const pollTimerRef = useRef | 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 diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 9f95f650f..e2be46b65 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1769,6 +1769,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: diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 07def167c..a9c8f67a4 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1691,6 +1691,15 @@ const zhHans = { loginSuccess: '登录成功!令牌已自动填入', loginFailed: '登录失败', }, + itchat: { + scanLogin: '扫码登录微信', + scanQRCode: '请使用微信扫描以下二维码登录', + loginSuccess: '登录成功!会话已缓存,保存配置后即可使用', + loginFailed: '登录失败', + connecting: '正在启动微信登录...', + waitingForScan: '等待扫码中', + retry: '重试', + }, dingtalk: { createApp: '一键创建钉钉应用', scanQRCode: '请使用钉钉扫描以下二维码,授权后将自动创建应用并填写凭据',