175 lines
7.7 KiB
Python
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"]
|