mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-16 01:16:07 +00:00
feat: add itchat-uos WeChat adapter with QR code login
- Add itchat-uos adapter supporting personal WeChat via QR code login - Implement message/event converters for text, image, voice, sharing types - Bridge sync itchat callbacks to async LangBot pipeline via asyncio - Add QR login API endpoints with session management - Add frontend QR code login dialog integration - Fix plugin connector handler attribute check - Use fresh Core instance per login to avoid singleton state pollution
This commit is contained in:
@@ -22,6 +22,7 @@ dependencies = [
|
|||||||
"discord-py>=2.5.2",
|
"discord-py>=2.5.2",
|
||||||
"pynacl>=1.5.0", # Required for Discord voice support
|
"pynacl>=1.5.0", # Required for Discord voice support
|
||||||
"gewechat-client>=0.1.5",
|
"gewechat-client>=0.1.5",
|
||||||
|
"itchat-uos>=1.5.0.dev",
|
||||||
"lark-oapi>=1.5.5",
|
"lark-oapi>=1.5.5",
|
||||||
"mcp>=1.25.0",
|
"mcp>=1.25.0",
|
||||||
"nakuru-project-idk>=0.0.2.1",
|
"nakuru-project-idk>=0.0.2.1",
|
||||||
|
|||||||
@@ -650,3 +650,223 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
if session and session.get('task') and not session['task'].done():
|
if session and session.get('task') and not session['task'].done():
|
||||||
session['task'].cancel()
|
session['task'].cancel()
|
||||||
return self.success(data={})
|
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/<session_id>', 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/<session_id>', 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={})
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -736,7 +736,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
) -> context.EventContext:
|
) -> context.EventContext:
|
||||||
event_ctx = context.EventContext.from_event(event)
|
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._emitted_plugins = []
|
||||||
event_ctx._response_sources = []
|
event_ctx._response_sources = []
|
||||||
return event_ctx
|
return event_ctx
|
||||||
|
|||||||
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "itsdangerous"
|
name = "itsdangerous"
|
||||||
version = "2.2.0"
|
version = "2.2.0"
|
||||||
@@ -2035,6 +2048,7 @@ dependencies = [
|
|||||||
{ name = "ebooklib" },
|
{ name = "ebooklib" },
|
||||||
{ name = "gewechat-client" },
|
{ name = "gewechat-client" },
|
||||||
{ name = "html2text" },
|
{ name = "html2text" },
|
||||||
|
{ name = "itchat-uos" },
|
||||||
{ name = "langbot-plugin" },
|
{ name = "langbot-plugin" },
|
||||||
{ name = "langchain" },
|
{ name = "langchain" },
|
||||||
{ name = "langchain-core" },
|
{ name = "langchain-core" },
|
||||||
@@ -2123,6 +2137,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||||
|
{ name = "itchat-uos", specifier = ">=1.5.0.dev0" },
|
||||||
{ name = "langbot-plugin", specifier = "==0.4.6" },
|
{ name = "langbot-plugin", specifier = "==0.4.6" },
|
||||||
{ name = "langchain", specifier = ">=1.3.9" },
|
{ name = "langchain", specifier = ">=1.3.9" },
|
||||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pyreadline3"
|
name = "pyreadline3"
|
||||||
version = "3.5.4"
|
version = "3.5.4"
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot';
|
export type QrLoginPlatform =
|
||||||
|
| 'feishu'
|
||||||
|
| 'weixin'
|
||||||
|
| 'dingtalk'
|
||||||
|
| 'wecombot'
|
||||||
|
| 'itchat';
|
||||||
|
|
||||||
interface PlatformConfig {
|
interface PlatformConfig {
|
||||||
titleKey: string;
|
titleKey: string;
|
||||||
@@ -92,6 +97,20 @@ const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
|||||||
}),
|
}),
|
||||||
successNoteKey: 'wecombot.robotNameNote',
|
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 {
|
interface QrCodeLoginDialogProps {
|
||||||
@@ -116,6 +135,7 @@ export default function QrCodeLoginDialog({
|
|||||||
|
|
||||||
const [state, setState] = useState<DialogState>('connecting');
|
const [state, setState] = useState<DialogState>('connecting');
|
||||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||||
|
const qrDataUrlRef = useRef('');
|
||||||
const [expireIn, setExpireIn] = useState(0);
|
const [expireIn, setExpireIn] = useState(0);
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
@@ -176,6 +196,7 @@ export default function QrCodeLoginDialog({
|
|||||||
cleanedRef.current = false;
|
cleanedRef.current = false;
|
||||||
setState('connecting');
|
setState('connecting');
|
||||||
setQrDataUrl('');
|
setQrDataUrl('');
|
||||||
|
qrDataUrlRef.current = '';
|
||||||
setExpireIn(0);
|
setExpireIn(0);
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
|
|
||||||
@@ -204,12 +225,14 @@ export default function QrCodeLoginDialog({
|
|||||||
|
|
||||||
if (qr_data_url) {
|
if (qr_data_url) {
|
||||||
setQrDataUrl(qr_data_url);
|
setQrDataUrl(qr_data_url);
|
||||||
|
qrDataUrlRef.current = qr_data_url;
|
||||||
} else if (qr_url) {
|
} else if (qr_url) {
|
||||||
const dataUrl = await QRCode.toDataURL(qr_url, {
|
const dataUrl = await QRCode.toDataURL(qr_url, {
|
||||||
width: 224,
|
width: 224,
|
||||||
margin: 2,
|
margin: 2,
|
||||||
});
|
});
|
||||||
setQrDataUrl(dataUrl);
|
setQrDataUrl(dataUrl);
|
||||||
|
qrDataUrlRef.current = dataUrl;
|
||||||
}
|
}
|
||||||
setState('waiting');
|
setState('waiting');
|
||||||
|
|
||||||
@@ -289,6 +312,19 @@ export default function QrCodeLoginDialog({
|
|||||||
cleanup();
|
cleanup();
|
||||||
setExpireIn(0);
|
setExpireIn(0);
|
||||||
setState('expired');
|
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 {
|
} catch {
|
||||||
// ignore poll errors
|
// ignore poll errors
|
||||||
|
|||||||
@@ -1769,6 +1769,16 @@ const enUS = {
|
|||||||
loginSuccess: 'Login successful! Token has been filled in',
|
loginSuccess: 'Login successful! Token has been filled in',
|
||||||
loginFailed: 'Login failed',
|
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: {
|
dingtalk: {
|
||||||
createApp: 'One-Click Create DingTalk App',
|
createApp: 'One-Click Create DingTalk App',
|
||||||
scanQRCode:
|
scanQRCode:
|
||||||
|
|||||||
@@ -1691,6 +1691,15 @@ const zhHans = {
|
|||||||
loginSuccess: '登录成功!令牌已自动填入',
|
loginSuccess: '登录成功!令牌已自动填入',
|
||||||
loginFailed: '登录失败',
|
loginFailed: '登录失败',
|
||||||
},
|
},
|
||||||
|
itchat: {
|
||||||
|
scanLogin: '扫码登录微信',
|
||||||
|
scanQRCode: '请使用微信扫描以下二维码登录',
|
||||||
|
loginSuccess: '登录成功!会话已缓存,保存配置后即可使用',
|
||||||
|
loginFailed: '登录失败',
|
||||||
|
connecting: '正在启动微信登录...',
|
||||||
|
waitingForScan: '等待扫码中',
|
||||||
|
retry: '重试',
|
||||||
|
},
|
||||||
dingtalk: {
|
dingtalk: {
|
||||||
createApp: '一键创建钉钉应用',
|
createApp: '一键创建钉钉应用',
|
||||||
scanQRCode: '请使用钉钉扫描以下二维码,授权后将自动创建应用并填写凭据',
|
scanQRCode: '请使用钉钉扫描以下二维码,授权后将自动创建应用并填写凭据',
|
||||||
|
|||||||
Reference in New Issue
Block a user