Files
helper/native/text_service.py

175 lines
7.7 KiB
Python

"""好友、群聊和文件传输助手的原生文本服务。"""
from __future__ import annotations
import logging
import time
import ctypes as c
from collections import deque
from filehelper_service import NativeFileHelperService, ReceivedTextMessage
from wxbot_framework.mentions import parse_at_members
from run_selftest import TEXT_DLL, K, check, modules, wechat_candidates, run
from wxbot_framework.text import TextRouter, validate_target, validate_mentions
from wxbot_framework.account import read_current_account, read_current_profile, select_account_process, validate_account
from wxbot_framework.contacts import read_contact_name
logger = logging.getLogger("NativeText")
def run_text(mode, **options):
return run(mode, bridge_path=TEXT_DLL, **options)
def inspect_account(pid):
"""只读核对候选进程的当前账号,不向候选进程注入 DLL。"""
core = modules(pid).get("weixin.dll")
if not core:
return None
process = check(K.OpenProcess(0x410, False, pid), "只读核对微信账号")
try:
def read(address, size):
buffer = c.create_string_buffer(size)
count = c.c_size_t()
check(K.ReadProcessMemory(process, address, buffer, size, c.byref(count)), "只读核对账号字段")
return buffer.raw[:count.value]
return read_current_account(read, core[0])
finally:
K.CloseHandle(process)
def find_account_process(account=None):
candidates = []
for pid in wechat_candidates():
try:
candidates.append((pid, inspect_account(pid)))
except OSError:
# 枚举后退出的进程不参与选择。
pass
return select_account_process(candidates, account)
class NativeTextService(NativeFileHelperService):
_dll = TEXT_DLL
_poll_export = b"WxPollTextMessageV2"
_message_type = ReceivedTextMessage
_run = staticmethod(run_text)
def __init__(self, *, account=None, **options):
super().__init__(**options)
self.preferred_account = validate_account(account) if account is not None else None
self.router = TextRouter(include_self_groups=True)
self.chats = {}
self._next_account_check = 0
self._sent_texts = deque(maxlen=64)
self.mention_messages = 0
@property
def self_wxid(self):
return self.router.self_wxid
def start(self):
self.router = TextRouter(include_self_groups=True)
self.chats = {}
self._sent_texts.clear()
if self.pid is None:
self.pid = find_account_process(self.preferred_account)
elif self.preferred_account and inspect_account(self.pid) != self.preferred_account:
# 手动指定 PID 时也先只读核对,避免向其他账号加载 DLL。
raise RuntimeError("目标进程登录的不是绑定的机器人账号,等待正确账号登录")
result = super().start()
logger.info("当前登录微信ID=%s;好友和群聊文本已就绪", self.self_wxid)
return result
def _read_account(self):
return read_current_account(self._channel.read_memory, self._channel.core_base)
def _prepare_channel(self):
account = self._read_account()
if not account:
raise RuntimeError("微信尚未完成登录,自动等待当前账号就绪")
if self.preferred_account and account != self.preferred_account:
raise RuntimeError("目标进程登录的不是绑定的机器人账号,等待正确账号登录")
self.router.set_account(account)
self._next_account_check = time.monotonic() + 1
def _check_account(self, *, force=False):
if not force and time.monotonic() < self._next_account_check:
return
account = self._read_account()
if not account or account != self.self_wxid:
error = RuntimeError("微信已退出登录或当前账号发生变化,重新连接并自动识别")
self.last_error = error
self._stop.set()
raise error
self._next_account_check = time.monotonic() + 1
def _before_poll(self):
self._check_account()
def _sender_wechat_name(self, wxid):
if self._channel is None:
return None
name = read_contact_name(self._channel.read_memory, self._channel.core_base, wxid,
expected_account=self.self_wxid)
if name is None and wxid == self.self_wxid:
profile = read_current_profile(self._channel.read_memory, self._channel.core_base)
name = profile["name"] if profile and profile["wxid"] == wxid else None
return name
def _map_message(self, message):
if message.type != 1:
return None
previous = self.self_wxid
previous_unparsed = self.router.unparsed_groups
data = self.router.convert(message.sender.decode("utf-8"), message.receiver.decode("utf-8"),
message.text.decode("utf-8"), sequence=message.sequence,
thread_id=message.thread_id)
if self.self_wxid and not previous:
logger.info("当前账号已识别;好友和群聊文本已就绪")
if data:
name = self._sender_wechat_name(data["sender_id"])
if name:
data["sender_wechat_name"] = name
source = getattr(message, "mention_source", b"").decode("utf-8", errors="replace")
data["at_members"] = parse_at_members(source)
data["is_at_self"] = bool(data["room_wxid"] and self.self_wxid in data["at_members"])
if data["at_members"]:
self.mention_messages += 1
if data["is_self"] and any(chat == data["chat_id"] and text == data["msg"] and
time.monotonic() - sent_at < 120
for chat, text, sent_at in tuple(self._sent_texts)):
self.router.ignored_self += 1
data["bot_generated"] = True
data["dispatch_to_plugins"] = False
elif data["is_at_self"]:
data["dispatch_to_plugins"] = True
self.chats[data["chat_id"]] = {"chat_id": data["chat_id"],
"is_group": bool(data["room_wxid"])}
elif self.router.unparsed_groups > previous_unparsed:
# 第一条失败时提示;后续通过 status 查看累计数。
if self.router.unparsed_groups == 1:
logger.warning("群文本作者格式未匹配,已跳过;需要用测试群消息核对实际布局")
return data
def send_text(self, to_wxid, content):
return self._send_text(to_wxid, content)
def send_at_text(self, room_wxid, content, at_list):
members = validate_mentions(room_wxid, at_list)
return self._send_text(room_wxid, content, mentions=members)
def _send_text(self, to_wxid, content, *, mentions=None):
validate_target(to_wxid)
with self._calls:
if not self._owns_bridge or self._stop.is_set():
raise RuntimeError("原生文本服务没有运行")
self._check_account(force=True)
self._sent_texts.append((to_wxid, content, time.monotonic()))
self.last_result = self._run("--dispatch", pid=self.pid, text=content, target=to_wxid,
keep_loaded=True, preserve_report=self.preserve_reports, mentions=mentions)
if content.startswith("WXBOT_"):
logger.info("测试文本发送完成:chat_id=%s success=%s native_thread=%s",
to_wxid, self.last_result["success"],
self.last_result.get("report", {}).get("thread_id"))
return self.last_result["success"]