41b9ec2571
- scrape_wechat_voice_calls: wraps POST /wechatweb/callRecord/queryWeChatVoiceList with page-number pagination (reuses paginate_by_page); normalizes records to the PhoneCall column shape with channel='voice_call'. Field mapping per phase0-findings §4: id->call_id, duration(str s)->call_duration(int), startTime(YYYY-MM-DD HH:MM:SS)->start_call_time(ms epoch), callStatus-> call_status, talker->call_number, talkerNickName->plan_customer_name, ossFileName->record_file (may be null). isSend (0=in/1=out) is inverted to incoming_call (1=in/0=out) so the column's meaning matches the phone_call channel. - upsert_voice_call: idempotent Core upsert into PhoneCall by call_id (sqlite/mysql dialect-aware). Adds call_duration/start_call_time to the _INT_FIELDS coercion net (scraper already emits ints; defensive for hand-built records). - Group-message test (Task 2.3): locks in that scrape_wechat_messages already handles chatroom conversations (friendId=@chatroom, roomid set, talker=member wxid) — yields room_id populated and talker_id = the sending member. No scraper change required. Suite: 123 -> 130 passing. ruff clean. Co-Authored-By: Claude <noreply@anthropic.com>
146 lines
4.9 KiB
Python
146 lines
4.9 KiB
Python
"""Tests for the WeChat message scraper on GROUP conversations (Task 2.3).
|
|
|
|
The existing :func:`scrape_wechat_messages` already maps ``roomid`` ->
|
|
``room_id`` and ``talker`` -> ``talker_id``, so group chats — where the
|
|
conversation id ``friendId`` is a ``xxx@chatroom`` id and every message
|
|
carries ``roomid`` set to that chatroom id plus a per-member ``talker`` wxid
|
|
(the sender inside the group) — should flow through unchanged.
|
|
|
|
This test locks that behavior in: a single group page yields messages with
|
|
``room_id`` populated to the chatroom id and ``talker_id`` equal to the
|
|
SENDING MEMBER's wxid (NOT the chatroom id), so group traffic is
|
|
attributable to the individual sender while still being scoped to the room.
|
|
|
|
No scraper code change is expected; if the scraper already handles it, this
|
|
test just guards the behavior against regressions.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import httpx
|
|
import respx
|
|
|
|
from app.scraper.wechat import scrape_wechat_messages
|
|
from app.session.yunke_client import YunkeClient
|
|
from tests.cassettes.msg_chats_fixture import make_msg_chats_response
|
|
|
|
BASE = "https://crm2.yunkecn.com"
|
|
MSG_CHATS_URL = f"{BASE}/wechatweb/msg/chats"
|
|
|
|
USERID = "a" * 32
|
|
COMPANY = "8m0atd"
|
|
|
|
WECHAT_ID = "wxid_staff_alice"
|
|
# The conversation id (friendId) for a group chat is the @chatroom id.
|
|
ROOM_ID = "12345678901@chatroom"
|
|
START = 1
|
|
END = 1784518000000 # the initial upper-bound cursor
|
|
|
|
|
|
def _primed_client() -> YunkeClient:
|
|
"""A YunkeClient already in a logged-in state (login flow skipped)."""
|
|
return YunkeClient(base_url=BASE, userid=USERID, company=COMPANY)
|
|
|
|
|
|
# Two group messages from two DIFFERENT members of the same chatroom. The
|
|
# `talker` is the individual member's wxid; `roomid` is the chatroom id (same
|
|
# for every message in the conversation); the upstream sets `oriTalker` to the
|
|
# chatroom id for group messages (it carries the conversation key, not the
|
|
# sender).
|
|
GROUP_MESSAGES = [
|
|
{
|
|
"msgSvrId": "grp-2000001",
|
|
"type": 1,
|
|
"messageType": 1,
|
|
"text": "hello group from alice",
|
|
"wechatId": WECHAT_ID,
|
|
"talker": "wxid_member_a1", # the SENDER (a group member)
|
|
"oriTalker": ROOM_ID, # upstream convention: oriTalker = chatroom id
|
|
"roomid": ROOM_ID,
|
|
"mine": 1,
|
|
"isSend": 1,
|
|
"timestamp": END - 600000,
|
|
"createTime": END - 600000,
|
|
"name": "Alice (group)",
|
|
"headUrl": "https://cdn.example.com/alice.png",
|
|
"isDel": 0,
|
|
"version": "v1.0",
|
|
},
|
|
{
|
|
"msgSvrId": "grp-2000002",
|
|
"type": 1,
|
|
"messageType": 1,
|
|
"text": "hi from bob",
|
|
"wechatId": WECHAT_ID,
|
|
"talker": "wxid_member_b2", # a DIFFERENT member
|
|
"oriTalker": ROOM_ID,
|
|
"roomid": ROOM_ID,
|
|
"mine": 0,
|
|
"isSend": 0,
|
|
"timestamp": END - 300000,
|
|
"createTime": END - 300000,
|
|
"name": "Bob (group)",
|
|
"headUrl": "https://cdn.example.com/bob.png",
|
|
"isDel": 0,
|
|
"version": "v1.0",
|
|
},
|
|
]
|
|
|
|
|
|
@respx.mock
|
|
async def test_scrape_wechat_messages_group_yields_room_id_and_member_talker():
|
|
"""Group messages keep ``room_id`` populated and ``talker_id`` = member wxid.
|
|
|
|
The scraper must NOT collapse ``talker_id`` to the chatroom id (that would
|
|
lose the per-message sender) and must NOT clear ``room_id`` (that would
|
|
make the message look like a 1:1 direct message).
|
|
"""
|
|
# `end` = oldest ts in batch; hasLast=false -> single page, no 2nd fetch.
|
|
page = make_msg_chats_response(
|
|
GROUP_MESSAGES, end=END - 600000, has_last=False
|
|
)
|
|
route = respx.post(MSG_CHATS_URL).mock(
|
|
return_value=httpx.Response(200, json=page)
|
|
)
|
|
|
|
client = _primed_client()
|
|
try:
|
|
results = [
|
|
r
|
|
async for r in scrape_wechat_messages(
|
|
client, WECHAT_ID, ROOM_ID, START, END
|
|
)
|
|
]
|
|
finally:
|
|
await client.aclose()
|
|
|
|
# Single page, hasLast=false -> exactly one call.
|
|
assert route.call_count == 1
|
|
assert len(results) == 2
|
|
|
|
r1, r2 = results
|
|
|
|
# Both messages carry the chatroom id on room_id...
|
|
assert r1["room_id"] == ROOM_ID
|
|
assert r2["room_id"] == ROOM_ID
|
|
|
|
# ...and the SENDING MEMBER's wxid on talker_id (NOT the chatroom id).
|
|
assert r1["talker_id"] == "wxid_member_a1"
|
|
assert r2["talker_id"] == "wxid_member_b2"
|
|
|
|
# ori_talker carries the chatroom id verbatim (upstream convention).
|
|
assert r1["ori_talker"] == ROOM_ID
|
|
assert r2["ori_talker"] == ROOM_ID
|
|
|
|
# Standard fields still flow through.
|
|
assert r1["channel"] == "wechat"
|
|
assert r1["owner_account_id"] == WECHAT_ID
|
|
assert r1["msg_id"] == "grp-2000001"
|
|
assert r1["text"] == "hello group from alice"
|
|
assert r1["raw_json"] == GROUP_MESSAGES[0]
|
|
|
|
# The friendId we passed in is the chatroom id; it is NOT stamped on the
|
|
# record separately (room_id already carries it, so there is no duplicate
|
|
# "conversation id" field on Message).
|
|
assert r2["msg_id"] == "grp-2000002"
|