mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-15 06:50:58 +00:00
e211d3aae6
- filter the bot's own messages to prevent reply loops
- use startswith('@@') for group detection
- use ActualUserName as stable GroupMember id
- fill Friend.remark from RemarkName
- strip @mention prefix with a regex
772 lines
30 KiB
Python
772 lines
30 KiB
Python
"""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 re
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
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
|
||
|
||
# Ignore the bot's own messages to avoid reply loops
|
||
bot_user_name = getattr(adapter._core.storageClass, 'userName', '')
|
||
if from_user == bot_account_id or (bot_user_name and from_user == bot_user_name):
|
||
return None
|
||
|
||
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 = from_user.startswith('@@')
|
||
timestamp = msg.get('CreateTime', 0)
|
||
|
||
if is_group:
|
||
# Actual sender within the group
|
||
actual_user = msg.get('ActualUserName', '')
|
||
actual_nick = msg.get('ActualNickName', '') or actual_user
|
||
if not actual_user:
|
||
return None
|
||
|
||
# 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_re = re.compile(re.escape('@' + bot_nickname) + r'[ ]?')
|
||
for component in message_chain:
|
||
if isinstance(component, platform_message.Plain):
|
||
component.text = at_re.sub('', component.text, count=1)
|
||
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_user,
|
||
member_name=actual_nick,
|
||
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
|
||
user_obj = msg.get('User', {})
|
||
sender_nick = adapter._get_obj_value(user_obj, 'NickName')
|
||
sender_remark = adapter._get_obj_value(user_obj, 'RemarkName')
|
||
|
||
return platform_events.FriendMessage(
|
||
sender=platform_entities.Friend(
|
||
id=from_user,
|
||
nickname=sender_nick or from_user,
|
||
remark=sender_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)
|
||
_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
|
||
|
||
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()
|
||
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
|
||
|
||
@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:
|
||
safe_name = cls._safe_status_name(account_id)
|
||
if not safe_name:
|
||
raise ValueError('account_id is required for itchat login status')
|
||
filename = f'{safe_name}.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 not account_id:
|
||
raise ValueError('account_id is required. Please scan the QR code and save this bot first.')
|
||
|
||
return self.login_status_path_for_account(account_id)
|
||
|
||
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."""
|
||
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 = 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
|
||
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
|
||
self._set_connection_status('connected')
|
||
|
||
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')
|
||
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()
|
||
|
||
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._set_connection_status('disconnected')
|
||
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._log_sync(f'Error dispatching itchat message: {traceback.format_exc()}', 'error')
|
||
|
||
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:
|
||
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."""
|
||
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._log_sync(f'Failed to save temp file: {traceback.format_exc()}', 'error')
|
||
return None
|
||
|
||
def _cleanup_temp(self, path: str):
|
||
"""Remove a temp file."""
|
||
try:
|
||
if os.path.exists(path):
|
||
os.remove(path)
|
||
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,
|
||
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, self._prepare_reply_message(message_source, 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 an account-specific cached session file exists from a previous QR login,
|
||
itchat will reuse data/itchat/<account_id>.pkl 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...')
|
||
|
||
# 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:
|
||
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(
|
||
status_path, loginCallback=self._on_login, exitCallback=self._on_exit
|
||
)
|
||
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(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:
|
||
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')
|
||
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)')
|
||
if self._startup_error:
|
||
raise RuntimeError(self._startup_error)
|
||
|
||
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
|
||
self._set_connection_status('disconnected')
|
||
await self.logger.info('itchat adapter stopped')
|
||
return True
|