Files
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

1317 lines
49 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
WeChat state accessor.
Provides convenient access to WeChat app state with comparison utilities.
"""
from __future__ import annotations
import copy
import re
from typing import Any
from bench_env.task.base import BaseApp
from bench_env.task.common_tasks import match_duration, match_time
from bench_env.task.tencent_meeting.app import TencentMeeting
from bench_env.task.utils import city_aliases, extract_numbers, norm
WECHAT_CONTACT_PARAM = {
"type": "string",
"default": "陈静",
"source": "apps.wechat.contacts[name]",
"description": "目标联系人",
}
WECHAT_SEND_CHANGES = ["wechat.chats"]
WECHAT_MOMENT_CHANGES = ["wechat.moments"]
class Wechat(BaseApp):
"""
WeChat state accessor.
Usage:
wechat = Wechat(input.apps["wechat"])
wechat.user_name
wechat.contacts
# With init state for comparison
wechat = Wechat(input.apps["wechat"], init=input.apps_init["wechat"])
wechat.init.user_name
"""
# =========================================================================
# User properties
# =========================================================================
@property
def user(self) -> dict[str, Any]:
"""Current user object."""
return self.get("user")
@property
def user_id(self) -> str:
return str(self.user["wxid"])
@property
def user_name(self) -> str:
return str(self.user["name"])
@property
def user_avatar(self) -> str:
return str(self.user["avatar"])
# =========================================================================
# Settings shortcuts
# =========================================================================
@property
def settings(self) -> dict[str, Any]:
"""User settings."""
return self.get("settings")
@property
def subscriptions(self) -> list[dict[str, Any]]:
return self.get_list("subscriptions")
@property
def authorized_apps(self) -> list[dict[str, Any]]:
return self.get_list("authorizedApps")
@property
def auth(self) -> dict[str, Any]:
return self.get("auth")
@property
def session(self) -> dict[str, Any]:
return self.auth["session"]
@property
def accounts(self) -> list[dict[str, Any]]:
return self.auth["accounts"]
@property
def login_attempts(self) -> list[dict[str, Any]]:
return self.auth["loginAttempts"]
@property
def verification_attempts(self) -> list[dict[str, Any]]:
return self.auth["verificationAttempts"]
@property
def verification_codes(self) -> list[dict[str, Any]]:
return self.auth["verificationCodes"]
@property
def privacy_settings(self) -> dict[str, Any]:
return self.settings["privacy"]
@property
def general_settings(self) -> dict[str, Any]:
return self.settings["general"]
# =========================================================================
# Data lists
# =========================================================================
@property
def contacts(self) -> list[dict]:
"""Contact list."""
return self.get_list("contacts")
@property
def moments(self) -> list[dict]:
"""Moments list."""
return self.get_list("moments")
@property
def chats(self) -> list[dict]:
"""Chat list."""
return self.get_list("chats")
# =========================================================================
# Helper methods
# =========================================================================
@staticmethod
def _norm_name(s: str) -> str:
"""
Normalize display names / aliases for fuzzy matching.
Example: "blank." -> "blank"
"""
s = (s or "").strip().lower()
return re.sub(r"[\s\.\-_\u3002]+", "", s)
def find_contact(self, name: str) -> dict | None:
"""Find contact by name."""
for contact in self.contacts:
if contact.get("name") == name:
return contact
return None
def find_contact_wxid(self, name_or_alias: str) -> str:
"""
Find contact wxid by fuzzy name/alias match.
Returns empty string if not found.
"""
target = self._norm_name(name_or_alias)
if not target:
return ""
for c in self.contacts:
if not isinstance(c, dict):
continue
name = self._norm_name(str(c.get("name") or ""))
alias = self._norm_name(str(c.get("alias") or ""))
if name == target or alias == target or name.startswith(target) or alias.startswith(target):
return str(c.get("wxid") or "")
return ""
def require_contact_wxid(self, name_or_alias: str) -> str:
"""按名称或别名查找联系人 wxid,找不到则抛错。"""
wxid = self.find_contact_wxid(name_or_alias)
if not wxid:
raise ValueError(f"任务设计错误:联系人 '{name_or_alias}' 不存在。")
return wxid
def contact_by_wxid(self, wxid: str) -> dict[str, Any]:
"""按 wxid 获取联系人。"""
for contact in self.contacts:
if str(contact["wxid"]) == str(wxid):
return contact
raise ValueError(f"任务设计错误:联系人 wxid '{wxid}' 不存在。")
def contact_by_name(self, name_or_alias: str) -> dict[str, Any]:
"""按名称或别名获取联系人。"""
return self.contact_by_wxid(self.require_contact_wxid(name_or_alias))
def find_chat(self, name: str) -> dict | None:
"""Find chat by contact name."""
for chat in self.chats:
if chat.get("name") == name or chat.get("contactName") == name:
return chat
return None
def find_subscription_by_keywords(self, *keywords: str) -> dict[str, Any] | None:
for item in self.subscriptions:
text = str(item["membershipType"])
if all(keyword in text for keyword in keywords):
return item
return None
def new_subscription_by_membership_type(self, membership_type: str) -> dict[str, Any] | None:
target = str(membership_type).strip()
init_ids: set[str] = set()
if self.has_init:
init_ids = {
str(item.get("id") or "")
for item in self.init.subscriptions
if str(item.get("id") or "")
}
for item in self.subscriptions:
if str(item.get("membershipType") or "").strip() != target:
continue
if str(item.get("id") or "") in init_ids:
continue
return item
return None
def find_authorized_app_by_name(self, name: str) -> dict[str, Any] | None:
target = str(name).strip()
for item in self.authorized_apps:
if str(item["name"]).strip() == target:
return item
return None
def account_by_phone(self, phone: str) -> dict[str, Any] | None:
target = str(phone)
for account in self.accounts:
if str(account["phone"]) == target:
return account
return None
def verification_codes_for_phone(self, phone: str) -> list[dict[str, Any]]:
target = str(phone)
return [item for item in self.verification_codes if str(item["phone"]) == target]
def verification_attempts_for_phone(self, phone: str) -> list[dict[str, Any]]:
target = str(phone)
return [item for item in self.verification_attempts if str(item["phone"]) == target]
def trusted_devices(self, phone: str) -> list[dict[str, Any]]:
return self.auth["trustedDevicesByPhone"][str(phone)]
def last_sent_text_to(self, target_wxid: str) -> str:
"""
Get the last text message content sent by current user to target.
Returns empty string if not found.
"""
if not target_wxid:
return ""
me = str((self.user or {}).get("wxid") or "")
for chat in self.chats:
if not isinstance(chat, dict):
continue
if str(chat.get("id") or "") != target_wxid:
continue
messages = chat.get("messages") or []
for m in reversed(messages):
if not isinstance(m, dict):
continue
if m.get("type") != "text":
continue
if str(m.get("senderId") or "") != me:
continue
return str(m.get("content") or "").strip()
return ""
def has_sent_text_to(self, target_wxid: str, expected: str, *, allow_contains: bool = True) -> bool:
"""
Check whether current user has sent a text message to target.
By default, allows substring match to be tolerant to templates like "城市:北京".
"""
expected = str(expected or "").strip()
if not target_wxid or not expected:
return False
last = self.last_sent_text_to(target_wxid)
return last == expected or (allow_contains and expected in last)
def latest_moment(self) -> dict | None:
"""Get the latest (first) moment."""
return self.moments[0] if self.moments else None
def moment_has_content(self, content: str) -> bool:
"""Check if any moment has the given content."""
return any(m.get("content") == content for m in self.moments)
# =========================================================================
# Unified lookup
# =========================================================================
def get_chat_with(self, contact_name: str) -> dict | None:
"""通过联系人名查找聊天(含多级 fallback)。"""
wxid = self.find_contact_wxid(contact_name)
if wxid:
for c in self.chats:
if c.get("id") == wxid:
return c
chat = self.find_chat(contact_name)
if chat:
return chat
for c in self.chats:
if c.get("user", {}).get("name") == contact_name:
return c
return None
def find_chat_wxid_by_chat_name(self, chat_name: str) -> str:
target = str(chat_name or "").strip()
if not target:
return ""
for chat in self.get_list("chats"):
user = chat.get("user") or {}
if str(user.get("name") or "").strip() == target:
return str(user.get("wxid") or "")
for chat in self.get_list("chats"):
user = chat.get("user") or {}
if target in str(user.get("name") or ""):
return str(user.get("wxid") or "")
return ""
def last_sent_text_by_name(self, contact_name: str) -> str:
wxid = self.find_contact_wxid(contact_name)
return self.last_sent_text_to(wxid) if wxid else ""
def has_sent_text_contains(self, contact_name: str, expected_substring: str) -> bool:
wxid = self.find_contact_wxid(contact_name)
return self.has_sent_text_to(wxid, expected_substring, allow_contains=True) if wxid else False
def last_sent_text_by_chat_name(self, chat_name: str) -> str:
wxid = self.find_chat_wxid_by_chat_name(chat_name)
return self.last_sent_text_to(wxid) if wxid else ""
def has_sent_text_contains_by_chat_name(
self, chat_name: str, expected_substring: str
) -> bool:
wxid = self.find_chat_wxid_by_chat_name(chat_name)
return self.has_sent_text_to(wxid, expected_substring, allow_contains=True) if wxid else False
def chat_by_wxid(self, wxid: str) -> dict | None:
if not wxid:
return None
for chat in self.chats:
if str((chat or {}).get("id") or "") == str(wxid):
return chat
return None
def prepare_state_with_contact(
self,
*,
name: str,
alias: str | None = None,
wxid: str | None = None,
avatar: str = "",
) -> dict[str, Any]:
"""返回注入一条新联系人后的 app state。同名联系人已存在时原样返回。
场景:任务需要给某个不在默认联系人列表里的人(如"林若溪"、"母亲")发消息时,
在 `_prepare()` 中调用以保证 `find_contact_wxid(name)` 可命中。
"""
existing = self.find_contact_wxid(name)
if existing:
return copy.deepcopy(self.raw)
next_state = copy.deepcopy(self.raw)
contact_wxid = str(wxid or f"wxid_{self._norm_name(name) or 'contact'}").strip()
# 避免和现有 wxid 冲突
used = {str(c.get("wxid") or "") for c in next_state.get("contacts") or []}
base_wxid = contact_wxid
suffix = 1
while contact_wxid in used:
contact_wxid = f"{base_wxid}_{suffix}"
suffix += 1
new_contact: dict[str, Any] = {
"wxid": contact_wxid,
"name": str(name),
"alias": str(alias) if alias else "",
"avatar": str(avatar),
"isStarred": False,
"isBlacklisted": False,
"isMuted": False,
}
contacts = list(next_state.get("contacts") or [])
contacts.append(new_contact)
next_state["contacts"] = contacts
return next_state
def ensure_chat_with_contact(self, contact_name: str) -> dict[str, Any]:
wxid = self.require_contact_wxid(contact_name)
chat = self.chat_by_wxid(wxid)
if chat is not None:
return chat
contact = self.contact_by_wxid(wxid)
chat = {
"id": wxid,
"user": {
"wxid": wxid,
"name": str(contact["name"]),
"avatar": str(contact.get("avatar") or ""),
},
"isMuted": False,
"isSticky": False,
"isAlert": False,
"messages": [],
}
self.chats.insert(0, chat)
return chat
def prepare_state_with_incoming_text(
self,
contact_name: str,
content: str,
*,
message_id: str,
timestamp: int,
) -> dict[str, Any]:
next_state = copy.deepcopy(self.raw)
next_wechat = Wechat(next_state)
chat = next_wechat.ensure_chat_with_contact(contact_name)
sender_id = str((chat.get("user") or {}).get("wxid") or chat.get("id") or "")
chat["messages"] = list(chat.get("messages") or [])
chat["messages"].append(
{
"id": message_id,
"type": "text",
"content": content,
"senderId": sender_id,
"timestamp": int(timestamp),
}
)
return next_state
def received_texts_from(self, contact_name: str) -> list[str]:
"""获取联系人发来的所有文本消息。"""
wxid = self.require_contact_wxid(contact_name)
chat = self.chat_by_wxid(wxid)
if chat is None:
raise ValueError(f"任务设计错误:联系人 '{contact_name}' 没有聊天记录。")
texts: list[str] = []
for message in chat["messages"]:
if str(message["senderId"]) != wxid:
continue
if str(message["type"]) != "text":
continue
texts.append(str(message["content"]).strip())
if not texts:
raise ValueError(f"任务设计错误:联系人 '{contact_name}' 没有文本消息。")
return texts
def last_received_text_from(self, contact_name: str) -> str:
"""获取联系人最近发来的一条文本消息。"""
return self.received_texts_from(contact_name)[-1]
def last_received_text_with_amount_from(self, contact_name: str) -> str:
"""从新到旧查找对方发来、且能解析出至少一个金额的最后一则文本(用于转账请求后对方又发感谢语等)。"""
for text in reversed(self.received_texts_from(contact_name)):
if extract_numbers(text):
return text
raise ValueError(
f"联系人 {contact_name!r} 的来信中没有可解析的金额(请确认对方发过含数字金额的消息)。"
)
def contact_steps(self, contact_name: str) -> int:
"""获取联系人的微信运动步数。"""
contact = self.contact_by_name(contact_name)
return int(contact["steps"])
def top_stepper(self) -> tuple[str, int]:
"""返回联系人里步数最高的人名和步数。"""
top_contact = max(self.contacts, key=lambda contact: int(contact["steps"]))
return str(top_contact["name"]), int(top_contact["steps"])
@staticmethod
def _friend_names(env_state: dict[str, Any]) -> list[str]:
"""返回所有非自己的联系人名称列表。"""
wechat = env_state.get("apps", {}).get("wechat", {})
user_wxid = (wechat.get("user") or {}).get("wxid", "")
return [
c["name"] for c in wechat.get("contacts", [])
if isinstance(c, dict) and c.get("wxid") != user_wxid and c.get("name")
]
@staticmethod
def sample_friend_name(env_state: dict[str, Any], rng: Any) -> str | None:
"""从联系人列表随机采样一个非自己的联系人名称。"""
names = Wechat._friend_names(env_state)
return rng.choice(names) if names else None
@staticmethod
def sample_two_friend_names(env_state: dict[str, Any], rng: Any) -> dict[str, str] | None:
"""采样两个不同的非自己联系人名称。"""
names = Wechat._friend_names(env_state)
if len(names) < 2:
return None
picked = rng.sample(names, 2)
return {"target": picked[0], "notify_to": picked[1]}
@staticmethod
def sample_diff_steps_pair(env_state: dict[str, Any], rng: Any) -> dict[str, str] | None:
"""采样两个步数不同的非自己联系人。"""
wechat_state = env_state.get("apps", {}).get("wechat", {})
user_wxid = (wechat_state.get("user") or {}).get("wxid", "")
contacts = [
c for c in wechat_state.get("contacts", [])
if isinstance(c, dict) and c.get("wxid") != user_wxid
and c.get("name") and "steps" in c
]
rng.shuffle(contacts)
for i, c1 in enumerate(contacts):
for c2 in contacts[i + 1:]:
if int(c1["steps"]) != int(c2["steps"]):
return {"contact1": c1["name"], "contact2": c2["name"]}
return None
def authorized_app_id(self, app_name: str) -> str:
"""根据授权应用名称获取 app id。"""
for app in self.get_list("authorizedApps"):
if str(app["name"]) == app_name:
return str(app["id"])
raise ValueError(f"任务设计错误:授权应用 '{app_name}' 不存在。")
def new_sent_texts_to(self, contact_name: str) -> list[str]:
"""获取本次任务中新发给联系人的所有文本消息。"""
wxid = self.require_contact_wxid(contact_name)
me = str(self.user["wxid"])
current_chat = self.chat_by_wxid(wxid)
if current_chat is None:
return []
initial_ids: set[str] = set()
if self.has_init:
initial_chat = self.init.chat_by_wxid(wxid)
if initial_chat is not None:
for message in initial_chat["messages"]:
message_id = str(message["id"])
if message_id:
initial_ids.add(message_id)
texts: list[str] = []
for message in current_chat["messages"]:
message_id = str(message["id"])
if message_id and message_id in initial_ids:
continue
if str(message["senderId"]) != me:
continue
if str(message["type"]) != "text":
continue
texts.append(str(message["content"]).strip())
return texts
def new_sent_image_paths_to(self, contact_name: str) -> list[str]:
"""获取本次任务中新发给联系人的所有图片路径。"""
wxid = self.require_contact_wxid(contact_name)
me = str(self.user["wxid"])
current_chat = self.chat_by_wxid(wxid)
if current_chat is None:
return []
initial_ids: set[str] = set()
if self.has_init:
initial_chat = self.init.chat_by_wxid(wxid)
if initial_chat is not None:
for message in initial_chat["messages"]:
message_id = str(message["id"])
if message_id:
initial_ids.add(message_id)
paths: list[str] = []
for message in current_chat["messages"]:
message_id = str(message["id"])
if message_id and message_id in initial_ids:
continue
if str(message["senderId"]) != me:
continue
if str(message["type"]) != "image":
continue
paths.append(str(message["content"]).strip())
return paths
def new_moments_by_me(self) -> list[dict[str, Any]]:
"""获取本次任务中新发的自己的朋友圈。"""
my_wxid = str(self.user["wxid"])
initial_ids: set[str] = set()
if self.has_init:
for moment in self.init.moments:
if str(moment["wxid"]) == my_wxid:
initial_ids.add(str(moment["id"]))
moments: list[dict[str, Any]] = []
for moment in self.moments:
if str(moment["wxid"]) != my_wxid:
continue
if str(moment["id"]) in initial_ids:
continue
moments.append(moment)
return moments
def _latest_new_moment_content(self) -> str:
moments = self.new_moments_by_me()
return str(moments[0]["content"]).strip() if moments else ""
def has_new_sent_text_contains(
self, contact_name: str, expected_substring: str
) -> tuple[bool, str]:
wxid = self.find_contact_wxid(contact_name)
if not wxid:
raise ValueError(f"任务设计错误:联系人 '{contact_name}' 不存在。")
me = str((self.user or {}).get("wxid") or "")
current_chat = self.chat_by_wxid(wxid) or {}
current_messages = (
current_chat.get("messages") or [] if isinstance(current_chat, dict) else []
)
initial_ids: set[str] = set()
if self.has_init:
initial_chat = self.init.chat_by_wxid(wxid) or {}
initial_messages = (
initial_chat.get("messages") or []
if isinstance(initial_chat, dict)
else []
)
for message in initial_messages:
if isinstance(message, dict):
message_id = str(message.get("id") or "")
if message_id:
initial_ids.add(message_id)
for message in reversed(current_messages):
if not isinstance(message, dict):
continue
message_id = str(message.get("id") or "")
if message_id and message_id in initial_ids:
continue
if str(message.get("senderId") or "") != me:
continue
if str(message.get("type") or "") != "text":
continue
content = str(message.get("content") or "").strip()
if expected_substring and expected_substring in content:
return True, content
return False, self.last_sent_text_to(wxid)
def last_new_sent_text_contains(
self, contact_name: str, expected_substring: str
) -> tuple[bool, str]:
wxid = self.find_contact_wxid(contact_name)
if not wxid:
raise ValueError(f"任务设计错误:联系人 '{contact_name}' 不存在。")
me = str((self.user or {}).get("wxid") or "")
current_chat = self.chat_by_wxid(wxid) or {}
current_messages = (
current_chat.get("messages") or [] if isinstance(current_chat, dict) else []
)
initial_ids: set[str] = set()
if self.has_init:
initial_chat = self.init.chat_by_wxid(wxid) or {}
initial_messages = (
initial_chat.get("messages") or []
if isinstance(initial_chat, dict)
else []
)
for message in initial_messages:
if isinstance(message, dict):
message_id = str(message.get("id") or "")
if message_id:
initial_ids.add(message_id)
last_message = None
for message in reversed(current_messages):
if not isinstance(message, dict):
continue
if str(message.get("senderId") or "") != me:
continue
if str(message.get("type") or "") != "text":
continue
last_message = message
break
if not isinstance(last_message, dict):
return False, self.last_sent_text_to(wxid)
message_id = str(last_message.get("id") or "")
content = str(last_message.get("content") or "").strip()
if message_id and message_id in initial_ids:
return False, content
return (expected_substring != "" and expected_substring in content), content
def last_n_new_sent_texts(self, contact_name: str, n: int) -> list[str]:
wxid = self.find_contact_wxid(contact_name)
if not wxid:
raise ValueError(f"任务设计错误:联系人 '{contact_name}' 不存在。")
me = str((self.user or {}).get("wxid") or "")
if int(n) <= 0:
return []
current_chat = self.chat_by_wxid(wxid) or {}
current_messages = (
current_chat.get("messages") or [] if isinstance(current_chat, dict) else []
)
initial_ids: set[str] = set()
if self.has_init:
initial_chat = self.init.chat_by_wxid(wxid) or {}
initial_messages = (
initial_chat.get("messages") or []
if isinstance(initial_chat, dict)
else []
)
for message in initial_messages:
if isinstance(message, dict):
message_id = str(message.get("id") or "")
if message_id:
initial_ids.add(message_id)
picked: list[str] = []
for message in reversed(current_messages):
if not isinstance(message, dict):
continue
message_id = str(message.get("id") or "")
if message_id and message_id in initial_ids:
continue
if str(message.get("senderId") or "") != me:
continue
if str(message.get("type") or "") != "text":
continue
picked.append(str(message.get("content") or "").strip())
if len(picked) >= int(n):
break
picked.reverse()
return picked
# =========================================================================
# Check methods — return standard list[dict] for check_goals
# =========================================================================
def check_message_to(
self, contact_name: str, predicate, *, field: str = "message"
) -> list[dict]:
"""检查是否给联系人发了满足条件的消息。
Args:
contact_name: 联系人名称
predicate: ``(message_content: str) -> bool``
field: check result 中的 field 名
"""
chat = self.get_chat_with(contact_name)
if not chat:
return [{"field": field, "expected": f"chat with '{contact_name}'",
"actual": "not found", "passed": False}]
my_wxid = self.user.get("wxid")
for m in reversed(chat.get("messages", [])):
if m.get("senderId") == my_wxid:
content = m.get("content", "")
if predicate(content):
return [{"field": field, "expected": "message matching predicate",
"actual": content[:80], "passed": True}]
return [{"field": field, "expected": "message matching predicate",
"actual": "no matching message", "passed": False}]
def check_moment_posted(
self, predicate, *, field: str = "moment"
) -> list[dict]:
"""检查是否发了满足条件的朋友圈。
Args:
predicate: ``(content: str) -> bool``
field: check result 中的 field 名
"""
my_wxid = self.user.get("wxid")
for m in self.moments:
if m.get("wxid") == my_wxid and predicate(m.get("content", "")):
return [{"field": field, "expected": "moment matching predicate",
"actual": m.get("content", "")[:80], "passed": True}]
return [{"field": field, "expected": "moment matching predicate",
"actual": "no matching moment", "passed": False}]
def new_received_texts_from(self, contact_name: str) -> list[str]:
"""获取本次任务中联系人新发来的文本消息(对比 init 差集)。"""
wxid = self.require_contact_wxid(contact_name)
current_chat = self.chat_by_wxid(wxid)
if current_chat is None:
return []
initial_ids: set[str] = set()
if self.has_init:
initial_chat = self.init.chat_by_wxid(wxid)
if initial_chat is not None:
for message in initial_chat["messages"]:
message_id = str(message["id"])
if message_id:
initial_ids.add(message_id)
texts: list[str] = []
for message in current_chat["messages"]:
message_id = str(message["id"])
if message_id and message_id in initial_ids:
continue
if str(message["senderId"]) == wxid and str(message["type"]) == "text":
texts.append(str(message["content"]).strip())
return texts
def check_new_sent_to(
self, contact_name: str, *keywords: str, field: str | None = None
) -> dict[str, Any]:
"""验证是否新给联系人发了包含所有关键词的消息。"""
if field is None:
field = f"sent_to_{contact_name}"
texts = self.new_sent_texts_to(contact_name)
actual = texts[-1] if texts else "(none)"
passed = bool(texts) and all(keyword in actual for keyword in keywords)
return {
"field": field,
"expected": f"new msg to '{contact_name}' with {list(keywords)}",
"actual": actual,
"passed": passed,
}
def joined_new_texts_to(self, contact_name: str) -> str:
"""拼接本次任务中新发给联系人的所有文本消息(换行分隔)。
Agent 可能分多条消息发信息,此方法合并后用于子串匹配。
"""
texts = self.new_sent_texts_to(contact_name)
return "\n".join(texts) if texts else ""
def check_new_sent_contains(
self, contact_name: str, *keywords: str, field: str | None = None
) -> dict[str, Any]:
"""验证新消息(跨消息合并)是否包含所有关键词。"""
if field is None:
field = f"sent_to_{contact_name}"
joined = self.joined_new_texts_to(contact_name)
passed = bool(joined) and all(kw in joined for kw in keywords)
return {
"field": field,
"expected": f"new msgs to '{contact_name}' with {list(keywords)}",
"actual": joined[:200] or "(none)",
"passed": passed,
}
def check_new_sent_norm_contains(
self,
contact_name: str,
*keywords: str,
field: str | None = None,
last_only: bool = False,
) -> dict[str, Any]:
"""验证新消息归一化后包含所有关键词。"""
if field is None:
field = f"sent_to_{contact_name}"
texts = self.new_sent_texts_to(contact_name)
actual = texts[-1] if last_only and texts else self.joined_new_texts_to(contact_name)
actual_norm = norm(actual)
passed = bool(actual) and all(norm(keyword) in actual_norm for keyword in keywords)
scope = "msg" if last_only else "msgs"
return {
"field": field,
"expected": f"new {scope} to '{contact_name}' with normalized {list(keywords)}",
"actual": actual[:200] or "(none)",
"passed": passed,
}
def check_new_sent_norm_excludes(
self,
contact_name: str,
*forbidden_keywords: str,
field: str | None = None,
) -> dict[str, Any]:
"""验证新消息归一化后不包含指定干扰关键词。"""
if field is None:
field = f"sent_to_{contact_name}_excludes"
joined = self.joined_new_texts_to(contact_name)
joined_norm = norm(joined)
forbidden_hits = [
keyword
for keyword in forbidden_keywords
if norm(keyword) and norm(keyword) in joined_norm
]
return {
"field": field,
"expected": f"new msgs to '{contact_name}' without normalized {list(forbidden_keywords)}",
"actual": {
"text": joined[:200] or "(none)",
"forbidden_hits": forbidden_hits,
},
"passed": bool(joined) and not forbidden_hits,
}
def check_new_sent_contains_number(
self,
contact_name: str,
expected: float,
*,
tolerance: float = 0.01,
field: str | None = None,
) -> dict[str, Any]:
if field is None:
field = f"sent_to_{contact_name}_number"
joined = self.joined_new_texts_to(contact_name)
numbers = extract_numbers(joined)
passed = bool(joined) and any(abs(num - float(expected)) <= tolerance for num in numbers)
return {
"field": field,
"expected": {"number": expected, "tolerance": tolerance},
"actual": {"text": joined[:200] or "(none)", "numbers": numbers[:20]},
"passed": passed,
}
def check_new_sent_number_near_any_keyword(
self,
contact_name: str,
expected: float,
keywords: list[str],
*,
window: int = 12,
tolerance: float = 0.01,
field: str | None = None,
) -> dict[str, Any]:
"""验证新消息中某个数字出现在任一上下文关键词附近。"""
if field is None:
field = f"sent_to_{contact_name}_number_near_keyword"
joined = self.joined_new_texts_to(contact_name)
search_text = re.sub(
r"\b[A-Za-z]+(?:-[A-Za-z0-9]+)+\b",
lambda match: " " * len(match.group(0)),
joined or "",
)
hits: list[dict[str, Any]] = []
for match in re.finditer(
r"(?<![0-9A-Za-z_.])(-?(?:\d{1,3}(?:[,]\d{3})+|\d+)(?:\.\d+)?)(?![0-9A-Za-z_.])",
search_text,
):
try:
number = float(match.group(1).replace(",", "").replace("", ""))
except Exception:
continue
left = max(0, match.start() - int(window))
right = min(len(joined), match.end() + int(window))
context = joined[left:right]
near_keywords = [keyword for keyword in keywords if keyword and keyword in context]
if abs(number - float(expected)) <= tolerance and near_keywords:
hits.append({"number": number, "context": context})
return {
"field": field,
"expected": {
"number": expected,
"near_any_keyword": keywords,
"window": int(window),
},
"actual": {"text": joined[:200] or "(none)", "hits": hits[:5]},
"passed": bool(joined) and bool(hits),
}
def check_new_sent_images_exact(
self,
contact_name: str,
expected_paths: list[str],
*,
field: str | None = None,
) -> dict[str, Any]:
"""验证本次新发给联系人的图片路径集合与期望完全一致。"""
if field is None:
field = f"sent_images_to_{contact_name}"
actual_paths = self.new_sent_image_paths_to(contact_name)
return {
"field": field,
"expected": sorted(expected_paths),
"actual": sorted(actual_paths),
"passed": sorted(actual_paths) == sorted(expected_paths),
}
def check_new_sent_any_of(
self,
contact_name: str,
labels: list[str],
*extra_keywords: str,
field: str | None = None,
) -> dict[str, Any]:
"""验证新消息包含 labels 中任意一个 + 所有 extra_keywords(跨消息合并)。"""
if field is None:
field = f"sent_to_{contact_name}"
joined = self.joined_new_texts_to(contact_name)
label_ok = any(label in joined for label in labels) if labels else True
keywords_ok = all(kw in joined for kw in extra_keywords)
passed = bool(joined) and label_ok and keywords_ok
return {
"field": field,
"expected": f"any of {labels[:3]}{'…' if len(labels) > 3 else ''}"
+ (f" + {list(extra_keywords)}" if extra_keywords else ""),
"actual": joined[:200] or "(none)",
"passed": passed,
}
def check_new_subscription_created(
self, membership_type: str, *, field: str = "wechat.subscription.created"
) -> dict[str, Any]:
subscription = self.new_subscription_by_membership_type(membership_type)
return {
"field": field,
"expected": str(membership_type),
"actual": subscription.get("membershipType") if subscription else None,
"passed": subscription is not None,
}
def check_new_subscription_auto_renew(
self,
membership_type: str,
*,
expected: bool,
field: str = "wechat.subscription.auto_renew",
) -> dict[str, Any]:
subscription = self.new_subscription_by_membership_type(membership_type)
actual = None if subscription is None else bool(subscription.get("autoRenew"))
return {
"field": field,
"expected": bool(expected),
"actual": actual,
"passed": subscription is not None and actual == bool(expected),
}
def check_new_transfer_notification(
self,
contact_name: str,
amount: float,
*,
tolerance: float = 0.02,
field: str = "wechat.notify.sent",
) -> dict[str, Any]:
joined = self.joined_new_texts_to(contact_name)
numbers = extract_numbers(joined)
mentions_amount = any(abs(num - float(amount)) <= tolerance for num in numbers)
transfer_keywords = (
"已转",
"转了",
"转账",
"转给",
"打款",
"汇款",
"汇过去",
"转款",
"支付宝转",
"给你转",
)
mentions_transfer = any(keyword in joined for keyword in transfer_keywords) or bool(
re.search(r"转\s*[\d-,.]", joined)
)
return {
"field": field,
"expected": {"contact": contact_name, "amount": float(amount)},
"actual": joined[:200] or "(none)",
"passed": bool(joined) and mentions_transfer and mentions_amount,
}
def check_blacklisted(self, name: str) -> dict[str, Any]:
"""验证联系人是否已被拉黑。"""
contact = self.contact_by_name(name)
actual = contact.get("isBlacklisted") is True
return {
"field": "blacklisted",
"expected": True,
"actual": actual,
"passed": actual,
}
def check_new_moment_with(
self, *keywords: str, field: str = "moment"
) -> dict[str, Any]:
"""验证是否新发了包含所有关键词的朋友圈。"""
moments = self.new_moments_by_me()
actual = str(moments[0]["content"]).strip() if moments else "(none)"
passed = bool(moments) and all(keyword in actual for keyword in keywords)
return {
"field": field,
"expected": f"new moment with {list(keywords)}",
"actual": actual,
"passed": passed,
}
def check_new_moment_contains(
self,
*keywords: str,
field: str = "moment",
) -> dict[str, Any]:
"""验证最新新朋友圈归一化后包含所有关键词。"""
actual = self._latest_new_moment_content()
actual_norm = norm(actual)
passed = bool(actual) and all(norm(keyword) in actual_norm for keyword in keywords)
return {
"field": field,
"expected": f"new moment with normalized {list(keywords)}",
"actual": actual or "(none)",
"passed": passed,
}
def check_new_moment_contains_number(
self,
expected: float,
*,
tolerance: float = 0.01,
field: str = "moment_number",
) -> dict[str, Any]:
"""验证最新新朋友圈包含指定数值。"""
actual = self._latest_new_moment_content()
numbers = extract_numbers(actual)
passed = bool(actual) and any(abs(num - float(expected)) <= tolerance for num in numbers)
return {
"field": field,
"expected": {"number": expected, "tolerance": tolerance},
"actual": {"text": actual[:200] or "(none)", "numbers": numbers[:20]},
"passed": passed,
}
def check_new_moment_contains_labels_and_number(
self,
labels: list[str],
amount: float,
*,
tolerance: float = 0.01,
field: str = "moment",
) -> dict[str, Any]:
"""验证最新新朋友圈包含任一标签和指定金额。"""
actual = self._latest_new_moment_content()
actual_norm = norm(actual)
numbers = extract_numbers(actual)
label_ok = any(norm(label) in actual_norm for label in labels) if labels else True
amount_ok = any(
abs(num - float(amount)) <= tolerance or abs(abs(num) - float(amount)) <= tolerance
for num in numbers
)
return {
"field": field,
"expected": {"labels": labels[:3], "amount": float(amount)},
"actual": {"text": actual[:200] or "(none)", "numbers": numbers[:20]},
"passed": bool(actual) and label_ok and amount_ok,
}
def check_new_moment_no_images(self) -> dict[str, Any]:
"""验证新发的朋友圈没有附带图片(纯文字)。"""
moments = self.new_moments_by_me()
has_images = bool(moments[0].get("images")) if moments else False
return {
"field": "moment_no_images",
"expected": "no images",
"actual": "has images" if has_images else "text only",
"passed": bool(moments) and not has_images,
}
def check_new_sent_match_time(
self,
contact_name: str,
time_hhmm: str,
*,
field: str | None = None,
) -> dict[str, Any]:
"""新消息(多消息合并)中含指定 HH:MM(match_time 语义)。"""
if field is None:
field = "wechat_upcoming_time"
joined = self.joined_new_texts_to(contact_name)
passed = bool(joined) and match_time(time_hhmm, joined)
return {
"field": field,
"expected": f"新消息含时间 {time_hhmm}",
"actual": joined[:300] or "(none)",
"passed": passed,
}
def check_new_sent_match_duration(
self,
contact_name: str,
expected_duration_zh: str,
*,
field: str | None = None,
) -> dict[str, Any]:
"""新消息(多消息合并)与期望时长表述 match_duration 匹配。"""
if field is None:
field = "wechat_total_duration"
joined = self.joined_new_texts_to(contact_name)
passed = bool(joined) and match_duration(expected_duration_zh, joined)
return {
"field": field,
"expected": expected_duration_zh,
"actual": joined[:400] or "(none)",
"passed": passed,
}
def check_no_new_sent_to(
self,
contact_name: str,
*,
field: str | None = None,
summary: str = "无未读时不应新发给联系人转发短信",
) -> dict[str, Any]:
"""验证未给联系人发送新文本(与 init 差集为空)。"""
if field is None:
field = "wechat_no_forward"
texts = self.new_sent_texts_to(contact_name)
return {
"field": field,
"expected": summary,
"actual": texts,
"passed": len(texts) == 0,
}
def check_new_sent_meeting_id(
self,
contact_name: str,
meeting_id: str,
*,
field: str | None = None,
) -> dict[str, Any]:
"""新消息中含会议号(忽略空格,允许数字间有空格)。"""
if field is None:
field = f"wechat_meeting_{contact_name}"
mid = re.sub(r"\s+", "", str(meeting_id))
joined = self.joined_new_texts_to(contact_name)
if not mid:
return {
"field": field,
"expected": "含会议号",
"actual": joined[:300] or "(none)",
"passed": False,
}
pat = TencentMeeting.meeting_id_pattern(mid)
passed = bool(joined) and bool(pat.search(joined))
return {
"field": field,
"expected": "含会议号",
"actual": joined[:300] or "(none)",
"passed": passed,
}
def check_new_sent_meeting_id_and_password(
self,
contact_name: str,
meeting_id: str,
password: str,
*,
field: str | None = None,
) -> dict[str, Any]:
"""新消息同时含会议号(pattern)与密码串。"""
if field is None:
field = "wechat_share"
mid = re.sub(r"\s+", "", str(meeting_id))
joined = self.joined_new_texts_to(contact_name)
if not mid:
return {
"field": field,
"expected": f"会议号+密码 {password}",
"actual": joined[:400] or "(none)",
"passed": False,
}
pat = TencentMeeting.meeting_id_pattern(mid)
passed = bool(joined) and bool(pat.search(joined)) and str(password) in joined
return {
"field": field,
"expected": f"会议号+密码 {password}",
"actual": joined[:400] or "(none)",
"passed": passed,
}
def check_new_sent_route_duration_and_meeting_time(
self,
contact_name: str,
route_duration_text: str,
meeting_time_hhmm: str,
*,
field: str | None = None,
) -> dict[str, Any]:
"""新消息同时含驾车历时(match_duration)与会议时间(match_time)。"""
if field is None:
field = "wechat_eta_and_meeting"
joined = self.joined_new_texts_to(contact_name)
passed = (
bool(joined)
and match_duration(route_duration_text, joined)
and match_time(meeting_time_hhmm, joined)
)
return {
"field": field,
"expected": f"历时≈{route_duration_text},会议时间≈{meeting_time_hhmm}",
"actual": joined[:500] or "(none)",
"passed": passed,
}
def check_new_sent_compare_city_temp(
self,
contact_name: str,
*,
city1: str,
temp1: str | int | float,
city2: str,
temp2: str | int | float,
winner: str,
field: str = "compare_city_temp_share",
) -> dict[str, Any]:
"""验证新消息包含正确的温度比较结果。
只验证核心事实:
- 赢家城市名出现在消息中
- 赢家温度值出现在消息中
不绑定特定比较句式(如"比…更暖和"),Agent 可以用任何
合理方式表达比较结论。
"""
actual = self.joined_new_texts_to(contact_name)
temp1_str = str(temp1)
temp2_str = str(temp2)
if winner == "一样":
# 平局:消息中包含至少一个城市名和温度值即可
passed = bool(actual) and (
temp1_str in actual or temp2_str in actual
) and (city1 in actual or city2 in actual)
else:
winner_temp = temp1_str if winner == city1 else temp2_str
winner_aliases = city_aliases(winner)
# 核心事实:赢家城市名 + 赢家温度值
has_winner = any(alias in (actual or "") for alias in winner_aliases)
has_temp = winner_temp in (actual or "")
passed = bool(actual) and has_winner and has_temp
return {
"field": field,
"expected": {
"city1": city1,
"temp1": temp1_str,
"city2": city2,
"temp2": temp2_str,
"winner": winner,
},
"actual": actual or "(none)",
"passed": passed,
}