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

680 lines
25 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.
"""
SMS app state accessor.
"""
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_value
from bench_env.task.tencent_meeting.app import TencentMeeting
from bench_env.task.utils import norm
SMS_EXISTING_SENDERS = [
"华为云",
"抖音月付",
"10690955998",
"建设银行",
"中国电信",
"中国联通",
]
SMS_UNREAD_SENDERS = {
"华为云": "华为云",
"抖音月付": "抖音月付",
"建设银行": "建设银行",
"中国电信": "中国电信",
"中国联通": "中国联通",
}
SMS_NEW_RECIPIENTS = ["张三", "李四", "王五"]
SMS_RECIPIENT_PARAM = {
"type": "enum",
"values": SMS_NEW_RECIPIENTS,
"default": "张三",
"description": "短信收件人(真人联系人,发送到新会话)",
}
SMS_COMPARE_MESSAGE_COUNT_PAIRS = [
("中国电信", "中国联通"),
("中国电信", "10690955998"),
("华为云", "中国联通"),
]
SMS_SEND_CHANGES = [
"os.providers.sms.conversations",
"os.providers.sms.messagesByConversationId",
]
def _provider(input: Any, *, init: bool = False) -> dict:
"""Get SMS provider state from os.providers.sms."""
src = input.os_init if init else input.os
return (src.get("providers") or {}).get("sms") or {}
def _contacts_list(input: Any) -> list[dict[str, Any]]:
"""Extract contacts list from os.providers.contacts."""
try:
return (input.os.get("providers") or {}).get("contacts", {}).get("contacts") or []
except Exception:
return []
def sms_from_input(input: Any) -> "Sms":
"""Create Sms accessor with current + init provider state."""
return Sms(
_provider(input),
init=_provider(input, init=True),
contacts=_contacts_list(input),
)
def sms_init_from_input(input: Any) -> "Sms":
"""Create Sms accessor from init provider state only."""
return Sms(_provider(input, init=True))
class Sms(BaseApp):
"""
SMS state accessor.
Usage:
sms = Sms(input.apps["sms"])
sms.conversations
sms.messages_by_conversation_id
"""
def __init__(
self,
state: dict[str, Any],
init: dict[str, Any] | None = None,
contacts: list[dict[str, Any]] | None = None,
):
super().__init__(state, init=init)
self._contacts = contacts or []
def _phone_numbers_for_contact(self, name: str) -> list[str]:
"""Look up phone numbers for a contact by display name."""
target = norm(name)
numbers: list[str] = []
for contact in self._contacts:
if target != norm(str(contact.get("displayName") or "")):
continue
for phone in contact.get("phones") or []:
num = re.sub(r"[^\d+]", "", str(phone.get("number") or ""))
if num:
numbers.append(num)
return numbers
def _contact_names_for_phone(self, phone: str) -> list[str]:
"""通过号码反查联系人名,只接受规范化后的等价号码匹配。"""
target_variants = set(self._phone_variants(phone))
if not target_variants:
return []
names: list[str] = []
for contact in self._contacts:
display = str(contact.get("displayName") or "")
if not display:
continue
for ph in contact.get("phones") or []:
contact_variants = set(self._phone_variants(str(ph.get("number") or "")))
if target_variants & contact_variants:
names.append(display)
break
return names
@property
def conversations(self) -> list[dict[str, Any]]:
return self.get_list("conversations")
@property
def messages_by_conversation_id(self) -> dict[str, list[dict[str, Any]]]:
return self.get("messagesByConversationId", {})
@property
def unread_conversation_count(self) -> int:
return sum(1 for conversation in self.conversations if conversation["isUnread"])
@property
def all_conversations_read(self) -> bool:
return self.unread_conversation_count == 0
def latest_unread_sender(self) -> str:
for conversation in self.conversations:
if conversation.get("isUnread"):
return str(conversation["sender"])
raise ValueError("No unread conversations found")
def conversation_by_sender(self, sender_name: str) -> dict[str, Any]:
target = norm(sender_name)
for conversation in self.conversations:
if target == norm(conversation["sender"]):
return conversation
raise ValueError(f"Conversation for sender '{sender_name}' not found")
def messages_for_sender(self, sender_name: str) -> list[dict[str, Any]]:
conversation = self.conversation_by_sender(sender_name)
conversation_id = str(conversation["id"])
messages = self.messages_by_conversation_id[conversation_id]
if not messages:
raise ValueError(f"No messages found for sender '{sender_name}'")
return messages
def message_count_for(self, sender_name: str) -> int:
return len(self.messages_for_sender(sender_name))
def latest_message_from(self, sender_name: str) -> dict[str, Any]:
return self.messages_for_sender(sender_name)[-1]
def latest_incoming_message_from(self, sender_name: str) -> dict[str, Any]:
messages = self.messages_for_sender(sender_name)
for message in reversed(messages):
if not message["isOutgoing"]:
return message
raise ValueError(f"No incoming message found for sender '{sender_name}'")
def latest_incoming_content_from(self, sender_name: str) -> str:
return str(self.latest_incoming_message_from(sender_name)["content"])
@staticmethod
def _generated_conversation_id(sender_name: str) -> str:
return f"conv_{str(sender_name).encode('utf-8').hex()[:24]}"
def ensure_conversation_with_sender(self, sender_name: str) -> dict[str, Any]:
for conversation in self.conversations:
if norm(str(conversation.get("sender") or "")) == norm(sender_name):
return conversation
conversation = {
"id": self._generated_conversation_id(sender_name),
"sender": str(sender_name),
"timestamp": "",
"avatarColor": "#3482FF",
"avatarText": str(sender_name)[:1] or "#",
"isUnread": False,
"simSlot": 1,
"messageCount": 0,
}
self.conversations.insert(0, conversation)
self.messages_by_conversation_id[conversation["id"]] = []
return conversation
def prepare_state_with_incoming_message(
self,
sender_name: str,
content: str,
*,
message_id: str,
timestamp: str,
is_unread: bool = True,
) -> dict[str, Any]:
next_state = copy.deepcopy(self.raw)
next_state.setdefault("conversations", copy.deepcopy(self.get_list("conversations")))
next_state.setdefault(
"messagesByConversationId",
copy.deepcopy(self.get("messagesByConversationId", {})),
)
next_sms = Sms(next_state)
conversation = next_sms.ensure_conversation_with_sender(sender_name)
conversation_id = str(conversation["id"])
messages = list(next_sms.messages_by_conversation_id.get(conversation_id) or [])
messages.append(
{
"id": message_id,
"content": str(content),
"timestamp": str(timestamp),
"isOutgoing": False,
}
)
next_sms.messages_by_conversation_id[conversation_id] = messages
conversation["timestamp"] = str(timestamp)
conversation["isUnread"] = bool(is_unread)
conversation["messageCount"] = len(messages)
next_sms.raw["conversations"] = [
conversation,
*[
item
for item in next_sms.conversations
if str(item.get("id") or "") != conversation_id
],
]
return next_state
def sender_with_most_messages(self) -> str:
pairs = [
(conversation["sender"], self.message_count_for(conversation["sender"]))
for conversation in self.conversations
]
best_sender, best_count = max(pairs, key=lambda item: item[1])
if sum(1 for _, count in pairs if count == best_count) > 1:
raise ValueError("Multiple senders are tied for most messages")
return str(best_sender)
def sender_with_more_messages(self, sender1: str, sender2: str) -> str:
count1 = self.message_count_for(sender1)
count2 = self.message_count_for(sender2)
if count1 == count2:
raise ValueError(f"Message counts are tied for '{sender1}' and '{sender2}'")
return sender1 if count1 > count2 else sender2
@staticmethod
def sample_compare_pair(_env_state: dict[str, Any], rng: Any) -> dict[str, str]:
sender1, sender2 = rng.choice(SMS_COMPARE_MESSAGE_COUNT_PAIRS)
return {"sender1": sender1, "sender2": sender2}
def new_outgoing_messages_to(self, sender_name: str) -> list[dict[str, Any]]:
if not self.has_init:
raise ValueError("Init state is required to diff new outgoing messages")
current_ids = self.find_conversation_ids(sender_name)
if not current_ids:
return []
initial_map = self.init.messages_by_conversation_id
messages: list[dict[str, Any]] = []
for conversation_id in current_ids:
current_messages = self.messages_by_conversation_id[conversation_id]
initial_ids = {
str(message["id"])
for message in initial_map.get(conversation_id, [])
}
messages.extend(
message
for message in current_messages
if message["isOutgoing"] and str(message["id"]) not in initial_ids
)
return messages
def latest_message_to(self, sender_name: str) -> dict[str, Any] | None:
target = norm(sender_name)
best = None
for conversation in self.conversations:
if target and target not in norm(conversation.get("sender")):
continue
conversation_id = str(conversation.get("id") or "")
messages = self.messages_by_conversation_id.get(conversation_id) or []
if not messages:
continue
last = messages[-1]
if best is None:
best = last
continue
if (last.get("timestamp") or 0) >= (best.get("timestamp") or 0):
best = last
return best
def find_conversation_ids(self, sender_name: str) -> list[str]:
target = norm(sender_name)
ids: list[str] = []
for conversation in self.conversations:
if target and target not in norm(conversation.get("sender")):
continue
conversation_id = str(conversation.get("id") or "")
if conversation_id:
ids.append(conversation_id)
if ids:
return ids
# Fallback 1: match via conversation.phoneNumber field (set by contact selection flow)
phones = self._phone_numbers_for_contact(sender_name)
if phones:
for conversation in self.conversations:
conv_phone = re.sub(r"[^\d+]", "", str(conversation.get("phoneNumber") or ""))
if not conv_phone:
continue
for phone in phones:
if conv_phone == phone or conv_phone.endswith(phone) or phone.endswith(conv_phone):
conversation_id = str(conversation.get("id") or "")
if conversation_id:
ids.append(conversation_id)
break
if ids:
return ids
# Fallback 2: resolve contact name to phone numbers and match against sender
if not phones:
phones = []
for conversation in self.conversations:
sender = re.sub(r"[^\d+]", "", str(conversation.get("sender") or ""))
if not sender:
continue
for phone in phones:
if sender == phone or sender.endswith(phone) or phone.endswith(sender):
conversation_id = str(conversation.get("id") or "")
if conversation_id:
ids.append(conversation_id)
break
if ids:
return ids
# Fallback 3: 任务参数给的是手机号,但实际通过联系人选择后,会话 sender 是联系人名。
for contact_name in self._contact_names_for_phone(sender_name):
contact_target = norm(contact_name)
for conversation in self.conversations:
if contact_target and contact_target not in norm(conversation.get("sender")):
continue
conversation_id = str(conversation.get("id") or "")
if conversation_id and conversation_id not in ids:
ids.append(conversation_id)
return ids
def new_outgoing_message(
self, sender_name: str, expected_message: str
) -> dict[str, Any] | None:
expected = str(expected_message or "")
def _sms_norm(value: str) -> str:
return re.sub(r"[\s\.\,\!\?,。!?;;:]", "", value or "")
current_map = self.messages_by_conversation_id or {}
try:
initial_map = self.init.messages_by_conversation_id or {}
except Exception:
initial_map = {}
for conversation_id in self.find_conversation_ids(sender_name):
current_messages = current_map.get(conversation_id) or []
initial_messages = initial_map.get(conversation_id) or []
initial_ids = {
str((message or {}).get("id") or "")
for message in initial_messages
if isinstance(message, dict)
}
for message in 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 not bool(message.get("isOutgoing")):
continue
content = str(message.get("content") or "")
if expected in content or _sms_norm(expected) == _sms_norm(content):
return message
return None
def check_new_sent_to(
self, sender_name: str, *keywords: str, field: str | None = None
) -> dict[str, Any]:
if field is None:
field = f"sent_to_{sender_name}"
messages = self.new_outgoing_messages_to(sender_name)
matched = next(
(
message
for message in messages
if all(keyword in str(message["content"]) for keyword in keywords)
),
None,
)
actual = str(matched["content"]) if matched else None
return {
"field": field,
"expected": f"new message to '{sender_name}' containing {list(keywords)}",
"actual": actual,
"passed": matched is not None,
}
def check_new_sent_any_of(
self,
sender_name: str,
labels: list[str],
*extra_keywords: str,
field: str | None = None,
) -> dict[str, Any]:
if field is None:
field = f"sent_to_{sender_name}"
messages = self.new_outgoing_messages_to(sender_name)
matched = next(
(
message
for message in messages
if any(label and label in str(message.get("content") or "") for label in labels)
and all(keyword in str(message.get("content") or "") for keyword in extra_keywords)
),
None,
)
actual = str(matched.get("content") or "") if matched else None
return {
"field": field,
"expected": {
"recipient": sender_name,
"any_of": labels,
"keywords": list(extra_keywords),
},
"actual": actual,
"passed": matched is not None,
}
def check_new_sent_contains_number(
self,
sender_name: str,
expected: float,
*extra_keywords: str,
field: str | None = None,
) -> dict[str, Any]:
if field is None:
field = f"sent_to_{sender_name}_number"
messages = self.new_outgoing_messages_to(sender_name)
matched = next(
(
message
for message in messages
if match_value(expected, str(message.get("content") or ""))
and all(keyword in str(message.get("content") or "") for keyword in extra_keywords)
),
None,
)
actual = str(matched.get("content") or "") if matched else None
return {
"field": field,
"expected": {
"recipient": sender_name,
"number": expected,
"keywords": list(extra_keywords),
},
"actual": actual,
"passed": matched is not None,
}
@staticmethod
def _normalize_phone_for_match(value: str) -> str:
return "".join(ch for ch in str(value or "") if ch.isdigit())
@staticmethod
def _phone_variants(phone: str) -> list[str]:
"""生成电话号码的国内/国际两种纯数字形式,用于宽松匹配。
固话(国内带前导 0,国际不带):
国内 010 8854 4114 → 01088544114 (11 位,0 开头)
国际 +86 10 8854 4114 → 861088544114 (12 位,86+10 位)
手机(国内不带 0,国际加 86):
国内 138 0000 0000 → 13800000000 (11 位,1 开头)
国际 +86 138 0000 0000 → 8613800000000 (13 位,86+11 位)
过短的数字串(< 7 位)不产生任何变体,避免 substring 匹配误过。
"""
digits = "".join(ch for ch in str(phone or "") if ch.isdigit())
if len(digits) < 7:
return []
variants: set[str] = {digits}
if digits.startswith("86"):
rest = digits[2:]
if len(rest) == 10: # 国际固话 → 国内补 "0"
variants.add("0" + rest)
elif len(rest) == 11 and rest[0] == "1": # 国际手机 → 国内直接去掉 86
variants.add(rest)
elif len(digits) == 11:
if digits[0] == "0": # 国内固话 → 国际去 "0" 加 "86"
variants.add("86" + digits[1:])
elif digits[0] == "1": # 国内手机 → 国际加 "86"
variants.add("86" + digits)
return list(variants)
def check_new_sent_contains_phone(
self,
sender_name: str,
phone: str,
*,
field: str | None = None,
) -> dict[str, Any]:
if field is None:
field = f"sent_phone_to_{sender_name}"
variants = self._phone_variants(phone)
messages = self.new_outgoing_messages_to(sender_name)
matched = next(
(
message
for message in messages
if variants
and any(
v in self._normalize_phone_for_match(str(message.get("content") or ""))
for v in variants
)
),
None,
)
actual = str(matched.get("content") or "") if matched else None
return {
"field": field,
"expected": f"new message to '{sender_name}' containing phone {phone}",
"actual": actual,
"passed": matched is not None,
}
def check_no_new_sent_to(
self, sender_name: str, *, field: str | None = None
) -> dict[str, Any]:
if field is None:
field = f"no_new_sent_to_{sender_name}"
messages = self.new_outgoing_messages_to(sender_name)
return {
"field": field,
"expected": f"no new message to '{sender_name}'",
"actual": [str(message.get("content") or "") for message in messages],
"passed": len(messages) == 0,
}
def check_all_read(self, field: str = "all_read") -> dict[str, Any]:
return {
"field": field,
"expected": True,
"actual": self.all_conversations_read,
"passed": self.all_conversations_read,
}
def senders_with_keyword(self, keyword: str) -> list[str]:
"""返回所有收到过含 keyword 消息的会话 sender 名称列表。"""
init_map = self.init.messages_by_conversation_id if self.has_init else {}
curr_map = self.messages_by_conversation_id
senders: list[str] = []
for conv in self.conversations:
cid = str(conv["id"])
all_msgs = list(init_map.get(cid) or []) + list(curr_map.get(cid) or [])
has_kw = any(
not m.get("isOutgoing") and keyword in str(m.get("content") or "")
for m in all_msgs
if isinstance(m, dict)
)
if has_kw:
senders.append(str(conv["sender"]))
return senders
def check_replied_to_all(
self,
senders: list[str],
reply: str,
*,
field: str = "replied_to_all",
) -> dict[str, Any]:
"""验证是否给所有指定 sender 都发了包含 reply 的新消息。"""
sent_count = sum(
1 for sender in senders
if self.new_outgoing_messages_to(sender)
and any(reply in str(m["content"]) for m in self.new_outgoing_messages_to(sender))
)
return {
"field": field,
"expected": f"reply '{reply}' to all {len(senders)} senders",
"actual": f"{sent_count}/{len(senders)}",
"passed": sent_count == len(senders) and len(senders) > 0,
}
def check_conversation_deleted(self, sender_name: str, field: str = "deleted") -> dict[str, Any]:
target = norm(sender_name)
still_exists = any(target == norm(c["sender"]) for c in self.conversations)
return {
"field": field,
"expected": f"conversation '{sender_name}' deleted",
"actual": f"conversation '{sender_name}' still exists" if still_exists else f"conversation '{sender_name}' deleted",
"passed": not still_exists,
}
def check_new_outgoing_contains_meeting_id(
self,
recipient_display: str,
meeting_id: str,
*,
field: str | None = None,
) -> dict[str, Any]:
"""最新一条新发往外短信正文中含规范化会议号(忽略空格,兼容 digit 间空白 pattern)。"""
if field is None:
field = f"sms_meeting_{recipient_display}"
mid = re.sub(r"\s+", "", str(meeting_id))
msgs = self.new_outgoing_messages_to(recipient_display)
text = str(msgs[-1]["content"]) if msgs else ""
if not mid:
return {
"field": field,
"expected": f"短信含会议号",
"actual": text[:200] or "(none)",
"passed": False,
}
pat = TencentMeeting.meeting_id_pattern(mid)
passed = bool(msgs) and bool(pat.search(text))
return {
"field": field,
"expected": f"短信含会议号 {mid}",
"actual": text[:200] or "(none)",
"passed": passed,
}
def check_new_outgoing_contains_meeting_id_and_password(
self,
recipient_display: str,
meeting_id: str,
password: str,
*,
field: str | None = None,
) -> dict[str, Any]:
"""最新一条新发往外短信正文中含会议号(忽略空格)和密码。"""
if field is None:
field = f"sms_meeting_share_{recipient_display}"
mid = re.sub(r"\s+", "", str(meeting_id))
msgs = self.new_outgoing_messages_to(recipient_display)
text = str(msgs[-1]["content"]) if msgs else ""
if not mid:
passed = False
else:
passed = (
bool(msgs)
and bool(TencentMeeting.meeting_id_pattern(mid).search(text))
and str(password) in text
)
return {
"field": field,
"expected": f"短信含会议号 {mid} 和密码 {password}",
"actual": text[:200] or "(none)",
"passed": passed,
}