Files
yunke/yunke-server/app/sync/orchestrator.py
T
浅念 5ce33232c1 feat(sync): 聊天图片本地化(message media_hash+download_message_images+local-first rendering)
- Message 模型新增 media_hash 列 (sha256 FK -> Media.media_hash)
- Alembic 迁移 e5f6a7b8c9d0_message_media_hash (batch_alter_table, SQLite/MySQL 通用)
- app/scraper/media.py:download_message_images 批量下载器,镜像 download_call_recordings
  - 候选: media_hash IS NULL AND channel IN ('wechat','whatsapp') AND type IN ('image','video','file') AND 任一 file_*_url LIKE 'http%'
  - image 类型优先 file_th_url (缩略图,小快省),video/file 优先 file_url
  - per-row try/except + per-run tried set,失败不挂循环
- sync_message_images orchestrator 入口 + ENTITY_MESSAGE_IMAGES 常量 (提交自身 SyncRun,镜像 call_recordings)
- scheduler: _run_message_images + ENTITY_FUNCS['message_images'] + 加入 daily all job (尾部)
- MessageOut schema 暴露 media_hash 字段
- MessageBubble.vue local-first: media_hash 优先 /api/media/{hash},否则回退 OSS file_url/file_th_url/file_hd_url
- types.ts: Message 接口添加 media_hash 字段
- 新增 test_message_images_downloader.py (6 tests, 镜像 test_recording_downloader)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 18:24:11 +08:00

1261 lines
46 KiB
Python

"""The sync brain — coordinates scrapers, repos, watermarks, and the SyncRun log.
Each public ``sync_*`` function follows the same shape:
read watermark -> scrape -> upsert -> (advance watermark) -> log SyncRun
Two flavors of "incremental" live here, per Task 2.7:
* **Time-window incremental** (messages only). The message endpoint pages
backward over a ``(start, end]`` timestamp window, so a real incremental query
is possible. ``sync_wechat_messages`` reads ``last_timestamp`` for
``(message, wechat, "<wechat_id>:<friend_id>")``, queries
``[last_timestamp, now]`` via :func:`scrape_wechat_messages`, upserts each
record, **best-effort downloads media** for media-bearing messages, and
advances the watermark to ``max(timestamp)``. A re-run with no new messages
therefore re-queries the empty window and fetches nothing — the watermark
advanced past the previously-seen rows.
* **Full re-scrape + idempotent upsert** (every other entity). The list
endpoints (friends / accounts / groups / voice_calls / departments) have no
``since`` param, so "incremental" here = re-scrape everything and rely on the
repos' idempotent Core upsert (same PK twice -> one refreshed row). The
watermark records ``last_synced_at`` only — no cursor advance, because there
is no cursor to advance.
Failure policy: a scraper that raises is caught, recorded as a ``SyncRun`` row
with ``status='failed'`` and the error string, and a failed :class:`SyncResult`
is returned — the orchestrator does NOT re-raise, so a single broken entity
does not abort a multi-entity batch. The caller decides whether to surface /
retry. Media-download failures are best-effort: a media error is logged and the
sync continues (the message is still upserted; the watermark still advances).
Transactions: the orchestrator never commits. Every write is flushed into the
caller's ``AsyncSession``; the caller owns the commit / rollback. This keeps a
multi-entity batch atomic at the caller's discretion and lets a test roll a
failed advance back by simply not committing. Exception:
:func:`sync_call_recordings` commits its own ``SyncRun`` because
:func:`download_call_recordings` commits per batch internally, so the run
bookkeeping must land in the same transaction boundary.
"""
from __future__ import annotations
import dataclasses
import datetime as dt
import logging
from collections.abc import AsyncIterator, Callable
from typing import Any
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Department, SyncRun, WechatAccount, WhatsappAccount
from app.scraper.call import scrape_phone_calls, scrape_sms
from app.scraper.media import (
download_call_recordings,
download_message_images,
download_message_media,
download_voice_clip_audio,
)
from app.scraper.voice_clip import scrape_voice_clips
from app.scraper.wechat import (
scrape_departments,
scrape_wechat_accounts,
scrape_wechat_friends,
scrape_wechat_groups,
scrape_wechat_messages,
scrape_wechat_voice_calls,
)
from app.scraper.whatsapp import (
scrape_wa_voice_calls,
scrape_whatsapp_accounts,
scrape_whatsapp_friends,
scrape_whatsapp_groups,
scrape_whatsapp_messages,
)
from app.session.yunke_client import YunkeClient
from app.storage.object_store import ObjectStore
from app.storage.repos import (
upsert_chat_group,
upsert_customer,
upsert_department,
upsert_messages,
upsert_phone_call,
upsert_sms,
upsert_voice_call,
upsert_voice_clip,
upsert_wechat_account,
upsert_whatsapp_account,
)
from app.sync.watermarks import get_last_timestamp, set_watermark
logger = logging.getLogger(__name__)
__all__ = [
"ENTITY_CALL_RECORDINGS",
"ENTITY_MESSAGE_IMAGES",
"ENTITY_VOICE_CLIP_AUDIO",
"SyncResult",
"sync_all_wechat",
"sync_call_recordings",
"sync_departments",
"sync_message_images",
"sync_phone_calls",
"sync_sms",
"sync_voice_clip_audio",
"sync_voice_clips",
"sync_wa_voice_calls",
"sync_wechat_accounts",
"sync_wechat_friends",
"sync_wechat_groups",
"sync_wechat_messages",
"sync_wechat_voice_calls",
"sync_whatsapp_accounts",
"sync_whatsapp_friends",
"sync_whatsapp_groups",
"sync_whatsapp_messages",
]
# ---------------------------------------------------------------------------
# Stable SyncRun / SyncState keys (entity_type / channel labels)
# ---------------------------------------------------------------------------
# entity_type labels — kept as module constants so the SyncRun / SyncState keys
# are stable strings shared between the orchestrator and any dashboard / ops
# consumer (e.g. Task 6.4 monitoring).
ENTITY_DEPARTMENT = "department"
ENTITY_WECHAT_ACCOUNT = "wechat_account"
ENTITY_WECHAT_FRIEND = "wechat_friend"
ENTITY_WECHAT_GROUP = "wechat_group"
ENTITY_WECHAT_VOICE_CALL = "wechat_voice_call"
ENTITY_WA_VOICE_CALL = "wa_voice_call"
# M3 (WhatsApp accounts + friends). The WA account scraper wraps
# ``businessSalesList`` (WhatsappAccount table); the WA friend scraper wraps
# ``businessFriendList`` (Customer table, channel="whatsapp"). Both share the
# org's department tree with WeChat (sync_departments seeds it).
ENTITY_WHATSAPP_ACCOUNT = "whatsapp_account"
ENTITY_WHATSAPP_FRIEND = "whatsapp_friend"
# WhatsApp chat-groups share the ``ChatGroup`` table with wechat groups (the
# ``channel`` column distinguishes them). The scraper wraps
# ``businessFriendList`` with the group-type switch (UNVERIFIED param — see
# :func:`app.scraper.whatsapp.scrape_whatsapp_groups`); records land with
# ``channel='whatsapp'``.
ENTITY_WHATSAPP_GROUP = "whatsapp_group"
ENTITY_MESSAGE = "message"
# M4 (/pc/ CRM module) — phone calls + SMS share no channel column with the
# wechat-family entities; they track their own SyncRun / SyncState rows.
ENTITY_PHONE_CALL = "phone_call"
ENTITY_SMS = "sms"
# Recording-localization pass: not a scrape — reads already-mirrored
# ``phone_call`` rows and downloads their OSS ``record_file`` into the local
# object store (records a SyncRun with this entity_type; no SyncState row
# because the phone_call table itself is the cursor).
ENTITY_CALL_RECORDINGS = "call_recordings"
# 长语音 (voice_clip) — per-employee scrape of ``/wechatweb/call/list``. The
# sync walks every distinct user_id across both staff-account tables and stamps
# the channel locally (WechatAccount -> wechat, WhatsappAccount -> whatsapp).
ENTITY_VOICE_CLIP = "voice_clip"
# Voice-clip audio localization pass: not a scrape — reads already-mirrored
# ``voice_clip`` rows and downloads their OSS ``oss_file_name`` into the local
# object store (records a SyncRun with this entity_type; no SyncState row
# because the voice_clip table itself is the cursor). Mirrors call_recordings.
ENTITY_VOICE_CLIP_AUDIO = "voice_clip_audio"
# Message image / media localization pass: not a scrape — reads already-mirrored
# ``message`` rows whose ``file_url`` / ``file_th_url`` / ``file_hd_url`` point
# at OSS and have no ``media_hash`` yet, downloads each into the local object
# store (records a SyncRun with this entity_type; no SyncState row because the
# message table itself is the cursor). Mirrors call_recordings / voice_clip_audio.
ENTITY_MESSAGE_IMAGES = "message_images"
CHANNEL_WECHAT = "wechat"
CHANNEL_WHATSAPP = "whatsapp"
# ---------------------------------------------------------------------------
# SyncResult — small per-run outcome returned to the caller
# ---------------------------------------------------------------------------
@dataclasses.dataclass(frozen=True)
class SyncResult:
"""Outcome of one sync run (one entity / one conversation).
Mirrors the essential fields of the persisted ``SyncRun`` row so callers can
react to a run without re-querying the DB. ``status`` is one of:
* ``"success"`` — the scrape + upsert completed (the SyncRun row is
persisted with ``status='success'``).
* ``"failed"`` — the scrape/upsert raised; ``error`` carries the
exception text (the SyncRun row is persisted with ``status='failed'``).
* ``"skipped"`` — a precondition was unmet (e.g. no resolvable root
``department_id`` for an endpoint that requires one); ``error`` carries
the reason. NO SyncRun row is recorded (a skip is neither success nor
failure), so a skipped entity does not advance the watermark.
"""
entity: str
status: str
fetched: int
upserted: int
channel: str | None = None
scope_id: str | None = None
error: str | None = None
# ---------------------------------------------------------------------------
# Time helpers
# ---------------------------------------------------------------------------
def _now() -> dt.datetime:
"""Aware UTC now (never naive). Used for SyncRun timestamps."""
return dt.datetime.now(dt.UTC)
def _now_ms() -> int:
"""Current time as ms-epoch (the message scraper's timestamp unit)."""
return int(_now().timestamp() * 1000)
# ---------------------------------------------------------------------------
# SyncRun bookkeeping
# ---------------------------------------------------------------------------
async def _start_run(
session: AsyncSession,
entity_type: str,
*,
channel: str | None = None,
scope_id: str | None = None,
) -> SyncRun:
"""Open a ``running`` SyncRun row and flush it (assigning run_id)."""
run = SyncRun(
entity_type=entity_type,
channel=channel,
scope_id=scope_id,
status="running",
started_at=_now(),
)
session.add(run)
await session.flush()
return run
async def _finish_run(
session: AsyncSession,
run: SyncRun,
*,
status: str,
fetched: int,
upserted: int,
error: str | None = None,
) -> None:
"""Stamp a SyncRun row with final status / counts / finished_at and flush."""
run.status = status
run.records_fetched = fetched
run.records_upserted = upserted
run.error = error
run.finished_at = _now()
await session.flush()
# ---------------------------------------------------------------------------
# Messages — time-window incremental (the workhorse)
# ---------------------------------------------------------------------------
def _message_scope(wechat_id: str, friend_id: str) -> str:
"""Scope-id key for a 1:1 (or room) WeChat conversation."""
return f"{wechat_id}:{friend_id}"
async def sync_wechat_messages(
session: AsyncSession,
client: YunkeClient,
store: ObjectStore,
wechat_id: str,
friend_id: str,
*,
types: str = "",
) -> SyncResult:
"""Incremental sync of one WeChat conversation's messages.
Reads ``last_timestamp`` for ``(message, wechat, "<wechat_id>:<friend_id>")``
(default ``0`` on first sync), queries ``[last_timestamp, now]`` via the
scraper, upserts every yielded message, best-effort downloads media for
each, and advances the watermark to ``max(timestamp)``.
Returns a :class:`SyncResult`. On a scraper failure, records a
``status='failed'`` SyncRun with the error and returns a failed result
(does NOT re-raise).
"""
scope_id = _message_scope(wechat_id, friend_id)
last_ts = await get_last_timestamp(
session,
ENTITY_MESSAGE,
channel=CHANNEL_WECHAT,
scope_id=scope_id,
default=0,
)
end = _now_ms()
run = await _start_run(
session,
ENTITY_MESSAGE,
channel=CHANNEL_WECHAT,
scope_id=scope_id,
)
# --- Scrape (may raise) ---
try:
msgs: list[dict[str, Any]] = [
msg
async for msg in scrape_wechat_messages(
client, wechat_id, friend_id, last_ts, end, types=types
)
]
except Exception as exc:
logger.exception(
"scrape_wechat_messages failed for %s", scope_id
)
error = repr(exc)
await _finish_run(
session, run, status="failed", fetched=0, upserted=0, error=error
)
return SyncResult(
entity=ENTITY_MESSAGE,
status="failed",
fetched=0,
upserted=0,
channel=CHANNEL_WECHAT,
scope_id=scope_id,
error=error,
)
# --- Upsert (idempotent; same msg_id twice -> one refreshed row) ---
upserted = await upsert_messages(session, msgs) if msgs else 0
# --- Best-effort media download (never aborts the sync) ---
for msg in msgs:
try:
await download_message_media(client, msg, store, session)
except Exception:
logger.warning(
"media download failed for msg %s; sync continues",
msg.get("msg_id"),
exc_info=True,
)
# --- Advance watermark to max(timestamp) seen in this batch ---
timestamps = [m.get("timestamp") for m in msgs if m.get("timestamp") is not None]
if timestamps:
await set_watermark(
session,
ENTITY_MESSAGE,
channel=CHANNEL_WECHAT,
scope_id=scope_id,
last_timestamp=max(timestamps),
)
await _finish_run(
session, run, status="success", fetched=len(msgs), upserted=upserted
)
return SyncResult(
entity=ENTITY_MESSAGE,
status="success",
fetched=len(msgs),
upserted=upserted,
channel=CHANNEL_WECHAT,
scope_id=scope_id,
)
# ---------------------------------------------------------------------------
# Full re-scrape entities — idempotent upsert, no since-param
# ---------------------------------------------------------------------------
# Type aliases for readability (a scrape factory takes the client captured at
# call time and returns an async generator of records; an upsert takes the
# session + one record).
_ScrapeFactory = Callable[[], AsyncIterator[dict[str, Any]]]
_UpsertOne = Callable[[AsyncSession, dict[str, Any]], Any]
async def _sync_full_rescrape(
session: AsyncSession,
*,
entity_type: str,
channel: str | None,
scope_id: str | None,
scrape: _ScrapeFactory,
upsert: _UpsertOne,
) -> SyncResult:
"""Shared body for the "re-scrape all + idempotent upsert" entities.
These list endpoints have no ``since`` param, so "incremental" = re-scrape
everything and rely on the repos' idempotent Core upsert (same PK twice ->
one refreshed row). The watermark records ``last_synced_at`` only; no
version/timestamp cursor is advanced because there is no upstream window to
close. A scraper failure is recorded as a failed SyncRun and a failed
SyncResult is returned (no re-raise).
"""
run = await _start_run(
session, entity_type, channel=channel, scope_id=scope_id
)
try:
recs: list[dict[str, Any]] = [rec async for rec in scrape()]
except Exception as exc:
logger.exception("scrape failed for %s", entity_type)
error = repr(exc)
await _finish_run(
session, run, status="failed", fetched=0, upserted=0, error=error
)
return SyncResult(
entity=entity_type,
status="failed",
fetched=0,
upserted=0,
channel=channel,
scope_id=scope_id,
error=error,
)
for rec in recs:
await upsert(session, rec)
# Stamp the watermark's last_synced_at (no cursor advance — see docstring).
await set_watermark(
session, entity_type, channel=channel, scope_id=scope_id
)
await _finish_run(
session, run, status="success", fetched=len(recs), upserted=len(recs)
)
return SyncResult(
entity=entity_type,
status="success",
fetched=len(recs),
upserted=len(recs),
channel=channel,
scope_id=scope_id,
)
async def sync_departments(
session: AsyncSession, client: YunkeClient
) -> SyncResult:
"""Full re-scrape of the department tree and idempotent upsert."""
return await _sync_full_rescrape(
session,
entity_type=ENTITY_DEPARTMENT,
channel=None,
scope_id=None,
scrape=lambda: scrape_departments(client),
upsert=upsert_department,
)
async def _find_root_department_id(
session: AsyncSession,
) -> str | None:
"""Resolve the company-root department id from the mirrored ``Department`` table.
The ``wxUser/list`` form body REQUIRES ``departmentId`` (the company-root
dept id) — empty fails with ``code -90000``. The root is a node whose
``parent_id`` is falsy (``None`` / ``""`` / ``"0"`` — the yunke tree
convention). If several roots exist, the first by ``dept_id`` is returned
(with a warning) so a multi-root org still scrapes. Returns ``None`` when
no root is present so the caller can skip gracefully rather than send a
known-bad empty filter.
"""
stmt = (
sa.select(Department)
.where(
sa.or_(
Department.parent_id.is_(None),
Department.parent_id == "",
Department.parent_id == "0",
)
)
.order_by(Department.dept_id.asc())
.limit(1)
)
row = (await session.execute(stmt)).scalars().first()
return row.dept_id if row is not None else None
async def sync_wechat_accounts(
session: AsyncSession,
client: YunkeClient,
*,
department_id: str | None = None,
) -> SyncResult:
"""Full re-scrape of all WeChat staff accounts (+ embedded staff rows).
``department_id`` is the company-root dept id required by the
``wxUser/list`` form body. When omitted, the orchestrator resolves it from
the mirrored ``Department`` table (a root node whose ``parent_id`` is
falsy) — which is why :func:`sync_departments` must run first. If no root
can be resolved, the scrape is SKIPPED with a logged warning (returns a
``status='skipped'`` :class:`SyncResult` and records no ``SyncRun``) so a
missing org tree does not abort a multi-entity batch.
"""
resolved = department_id
if resolved is None:
resolved = await _find_root_department_id(session)
if not resolved:
logger.warning(
"sync_wechat_accounts: no root department_id resolved"
" (sync departments first?); skipping wxUser/list scrape"
)
return SyncResult(
entity=ENTITY_WECHAT_ACCOUNT,
status="skipped",
fetched=0,
upserted=0,
error="no root department_id available",
)
return await _sync_full_rescrape(
session,
entity_type=ENTITY_WECHAT_ACCOUNT,
channel=None,
scope_id=None,
scrape=lambda: scrape_wechat_accounts(client, department_id=resolved),
upsert=upsert_wechat_account,
)
async def sync_wechat_friends(
session: AsyncSession, client: YunkeClient
) -> SyncResult:
"""Full re-scrape of all WeChat friends (customers) and idempotent upsert."""
return await _sync_full_rescrape(
session,
entity_type=ENTITY_WECHAT_FRIEND,
channel=CHANNEL_WECHAT,
scope_id=None,
scrape=lambda: scrape_wechat_friends(client),
upsert=upsert_customer,
)
async def sync_wechat_groups(
session: AsyncSession, client: YunkeClient
) -> SyncResult:
"""Full re-scrape of all WeChat chat-groups (+members) and idempotent upsert."""
return await _sync_full_rescrape(
session,
entity_type=ENTITY_WECHAT_GROUP,
channel=CHANNEL_WECHAT,
scope_id=None,
scrape=lambda: scrape_wechat_groups(client),
upsert=upsert_chat_group,
)
async def sync_wechat_voice_calls(
session: AsyncSession,
client: YunkeClient,
*,
department_id: str | None = None,
) -> SyncResult:
"""Full re-scrape of all WeChat voice-call records and idempotent upsert.
``department_id`` is the company-root dept id REQUIRED by the
``queryWeChatVoiceList`` form body — without it (and the accompanying
``callSource`` / ``isRoom`` / ``startTime`` / ``endTime`` filters) the
endpoint returns 200 with an EMPTY ``data.data`` array (0 rows; live
diagnosis 2026-08-07). When omitted, the orchestrator resolves it from
the mirrored ``Department`` table via :func:`_find_root_department_id`
— which is why :func:`sync_departments` must run first. If no root can
be resolved, the scrape is SKIPPED with a logged warning (returns a
``status='skipped'`` :class:`SyncResult` and records no ``SyncRun``) so
a missing org tree does not abort a multi-entity batch.
"""
resolved = department_id
if resolved is None:
resolved = await _find_root_department_id(session)
if not resolved:
logger.warning(
"sync_wechat_voice_calls: no root department_id resolved"
" (sync departments first?); skipping queryWeChatVoiceList scrape"
)
return SyncResult(
entity=ENTITY_WECHAT_VOICE_CALL,
status="skipped",
fetched=0,
upserted=0,
error="no root department_id available",
)
return await _sync_full_rescrape(
session,
entity_type=ENTITY_WECHAT_VOICE_CALL,
channel=None,
scope_id=None,
scrape=lambda: scrape_wechat_voice_calls(client, department_id=resolved),
upsert=upsert_voice_call,
)
async def sync_wa_voice_calls(
session: AsyncSession,
client: YunkeClient,
*,
department_id: str | None = None,
) -> SyncResult:
"""Full re-scrape of all WhatsApp voice-call records and idempotent upsert.
Mirrors :func:`sync_wechat_voice_calls`: the WA variant of the unified
``queryWeChatVoiceList`` endpoint (``callSource=4``) has no ``since``
param, so "incremental" = re-scrape everything and rely on
:func:`upsert_voice_call`'s idempotent Core upsert (same ``call_id``
twice -> one refreshed row). The ``channel="wa_voice_call"`` value the
scraper stamps distinguishes WA rows from WeChat rows in the shared
``PhoneCall`` table; the SyncRun row records ``entity_type='wa_voice_call'``.
``department_id`` resolution mirrors :func:`sync_wechat_accounts`: the
``queryWeChatVoiceList`` form body REQUIRES the company-root
``departmentId`` (live diagnosis 2026-08-07 — without it the endpoint
returns 200 + empty ``data.data``, 0 rows). When omitted, the
orchestrator resolves it from the mirrored ``Department`` table; if no
root can be resolved, the scrape is SKIPPED with a logged warning and a
``status='skipped'`` :class:`SyncResult`.
"""
resolved = department_id
if resolved is None:
resolved = await _find_root_department_id(session)
if not resolved:
logger.warning(
"sync_wa_voice_calls: no root department_id resolved"
" (sync departments first?); skipping queryWeChatVoiceList scrape"
)
return SyncResult(
entity=ENTITY_WA_VOICE_CALL,
status="skipped",
fetched=0,
upserted=0,
error="no root department_id available",
)
return await _sync_full_rescrape(
session,
entity_type=ENTITY_WA_VOICE_CALL,
channel=None,
scope_id=None,
scrape=lambda: scrape_wa_voice_calls(client, department_id=resolved),
upsert=upsert_voice_call,
)
async def sync_whatsapp_accounts(
session: AsyncSession,
client: YunkeClient,
) -> SyncResult:
"""Full re-scrape of all WhatsApp staff accounts (+ embedded staff rows).
Mirrors :func:`sync_wechat_accounts` but the WA ``businessSalesList``
endpoint needs NO ``department_id`` (its body is just the page slice), so
there is no root-department resolution / skip path here. Idempotent upsert
via :func:`upsert_whatsapp_account` (same ``uid`` twice -> one refreshed
row; the companion :func:`upsert_staff_from_account` covers the embedded
staff row automatically).
"""
return await _sync_full_rescrape(
session,
entity_type=ENTITY_WHATSAPP_ACCOUNT,
channel=CHANNEL_WHATSAPP,
scope_id=None,
scrape=lambda: scrape_whatsapp_accounts(client),
upsert=upsert_whatsapp_account,
)
async def sync_whatsapp_friends(
session: AsyncSession,
client: YunkeClient,
*,
department_id: str | None = None,
) -> SyncResult:
"""Full re-scrape of all WhatsApp friends (customers) and idempotent upsert.
Mirrors :func:`sync_wechat_accounts`'s department-resolution path: the WA
``businessFriendList`` form body REQUIRES ``departmentId`` (the company-root
dept id) or it returns empty (live payload carries ``departmentId`` +
``type``). When omitted, the orchestrator resolves it from the mirrored
``Department`` table via :func:`_find_root_department_id` — which is why
:func:`sync_departments` must run first. If no root can be resolved, the
scrape is SKIPPED with a logged warning (returns a ``status='skipped'``
:class:`SyncResult` and records no ``SyncRun``) so a missing org tree does
not abort a multi-entity batch.
Records land in the shared :class:`Customer` table with
``channel='whatsapp'`` (stamped by :func:`_normalize_whatsapp_friend`),
so :func:`upsert_customer` is reused directly — exactly the same pattern as
:func:`sync_wechat_friends`.
"""
resolved = department_id
if resolved is None:
resolved = await _find_root_department_id(session)
if not resolved:
logger.warning(
"sync_whatsapp_friends: no root department_id resolved"
" (sync departments first?); skipping businessFriendList scrape"
)
return SyncResult(
entity=ENTITY_WHATSAPP_FRIEND,
status="skipped",
fetched=0,
upserted=0,
error="no root department_id available",
)
return await _sync_full_rescrape(
session,
entity_type=ENTITY_WHATSAPP_FRIEND,
channel=CHANNEL_WHATSAPP,
scope_id=None,
scrape=lambda: scrape_whatsapp_friends(client, department_id=resolved),
upsert=upsert_customer,
)
async def sync_whatsapp_groups(
session: AsyncSession,
client: YunkeClient,
*,
department_id: str | None = None,
) -> SyncResult:
"""Full re-scrape of all WhatsApp chat-groups and idempotent upsert.
Mirrors :func:`sync_wechat_groups` (the WA analogue) but with the WA
department-resolution path borrowed from :func:`sync_whatsapp_friends`:
the WA ``businessFriendList`` body REQUIRES ``departmentId`` (the
company-root dept id) or it returns empty. When omitted, the orchestrator
resolves it from the mirrored ``Department`` table via
:func:`_find_root_department_id` — which is why :func:`sync_departments`
must run first. If no root can be resolved, the scrape is SKIPPED with a
logged warning (returns a ``status='skipped'`` :class:`SyncResult` and
records no ``SyncRun``) so a missing org tree does not abort a multi-entity
batch.
Records land in the shared :class:`ChatGroup` table with
``channel='whatsapp'`` (stamped by :func:`_normalize_whatsapp_group`), so
:func:`upsert_chat_group` is reused directly — exactly the same pattern as
:func:`sync_wechat_groups`. The ``channel`` column distinguishes WA groups
from WeChat groups.
.. note::
UNVERIFIED (2026-08-11, risk-control): the WA group ``type="1"`` body
switch is inferred, not captured. See
:func:`app.scraper.whatsapp.scrape_whatsapp_groups`. If a post-risk-
control smoke returns 0 rows, the param name is the likely culprit.
"""
resolved = department_id
if resolved is None:
resolved = await _find_root_department_id(session)
if not resolved:
logger.warning(
"sync_whatsapp_groups: no root department_id resolved"
" (sync departments first?); skipping businessFriendList scrape"
)
return SyncResult(
entity=ENTITY_WHATSAPP_GROUP,
status="skipped",
fetched=0,
upserted=0,
error="no root department_id available",
)
return await _sync_full_rescrape(
session,
entity_type=ENTITY_WHATSAPP_GROUP,
channel=CHANNEL_WHATSAPP,
scope_id=None,
scrape=lambda: scrape_whatsapp_groups(client, department_id=resolved),
upsert=upsert_chat_group,
)
async def sync_whatsapp_messages(
session: AsyncSession,
client: YunkeClient,
uid: str,
friend_uid: str,
) -> SyncResult:
"""Incremental sync of one WhatsApp conversation's messages.
Mirrors :func:`sync_wechat_messages` for the WA channel: reads
``last_timestamp`` for ``(message, whatsapp, "<uid>:<friend_uid>")``
(default ``0`` on first sync), queries ``[last_timestamp, now]`` via
:func:`scrape_whatsapp_messages`, upserts every yielded message into the
shared ``message`` table (``channel='whatsapp'`` — stamped by the scraper's
normalizer), and advances the watermark to ``max(timestamp)``. A re-run
with no new messages re-queries the empty window and fetches nothing —
the watermark advanced past the previously-seen rows.
Per Phase 1 this entrypoint does NOT do best-effort media download (unlike
:func:`sync_wechat_messages`); WA media localization is deferred to
Phase 2. The message record itself is still upserted (so the conversation
history is mirrored) — only the media fetch is skipped.
Returns a :class:`SyncResult`. On a scraper failure, records a
``status='failed'`` SyncRun with the error and returns a failed result
(does NOT re-raise), so a single broken conversation does NOT abort a
multi-conversation batch.
"""
scope_id = f"{uid}:{friend_uid}"
last_ts = await get_last_timestamp(
session,
ENTITY_MESSAGE,
channel=CHANNEL_WHATSAPP,
scope_id=scope_id,
default=0,
)
end = _now_ms()
run = await _start_run(
session,
ENTITY_MESSAGE,
channel=CHANNEL_WHATSAPP,
scope_id=scope_id,
)
# --- Scrape (may raise) ---
try:
msgs: list[dict[str, Any]] = [
msg
async for msg in scrape_whatsapp_messages(
client, uid, friend_uid, last_ts, end
)
]
except Exception as exc:
logger.exception(
"scrape_whatsapp_messages failed for %s", scope_id
)
error = repr(exc)
await _finish_run(
session, run, status="failed", fetched=0, upserted=0, error=error
)
return SyncResult(
entity=ENTITY_MESSAGE,
status="failed",
fetched=0,
upserted=0,
channel=CHANNEL_WHATSAPP,
scope_id=scope_id,
error=error,
)
# --- Upsert (idempotent; same msg_id twice -> one refreshed row) ---
upserted = await upsert_messages(session, msgs) if msgs else 0
# NOTE: no media download for WA (Phase 2; records-only for now).
# --- Advance watermark to max(timestamp) seen in this batch ---
timestamps = [
m.get("timestamp") for m in msgs if m.get("timestamp") is not None
]
if timestamps:
await set_watermark(
session,
ENTITY_MESSAGE,
channel=CHANNEL_WHATSAPP,
scope_id=scope_id,
last_timestamp=max(timestamps),
)
await _finish_run(
session, run, status="success", fetched=len(msgs), upserted=upserted
)
return SyncResult(
entity=ENTITY_MESSAGE,
status="success",
fetched=len(msgs),
upserted=upserted,
channel=CHANNEL_WHATSAPP,
scope_id=scope_id,
)
async def sync_call_recordings(
session: AsyncSession, client: YunkeClient, store: ObjectStore
) -> SyncResult:
"""Download localized copies of call recordings for already-mirrored calls.
Not a scrape: reads local ``phone_call`` rows whose ``record_file`` (OSS
URL) has no ``record_media_hash`` yet, downloads each via the public OSS
URL, and links it. Records a ``SyncRun`` (``entity_type='call_recordings'``);
``fetched`` = calls needing download, ``upserted`` = calls localized.
Unlike the other ``sync_*`` functions (which defer the commit to the
caller), this one commits its own ``SyncRun`` row: the underlying
:func:`download_call_recordings` commits per batch as it advances, so the
run bookkeeping must land in the same transaction boundary to stay
consistent with what was actually persisted. Because it commits internally,
it must NOT be added to a batched runner (:func:`_run_all` /
:func:`sync_all_wechat`) without reconsidering per-batch atomicity — a
batched wrapper can no longer treat the whole entity set as one rollback
unit once this func has already persisted its own rows.
Failure policy mirrors every other ``sync_*``: a downloader exception is
recorded as a ``status='failed'`` ``SyncRun`` and a failed
:class:`SyncResult` is returned (no re-raise), so a broken recording pass
does NOT abort a multi-entity batch.
"""
run = await _start_run(session, ENTITY_CALL_RECORDINGS)
try:
attempted, localized = await download_call_recordings(
client, store, session
)
await _finish_run(
session, run, status="success", fetched=attempted, upserted=localized
)
await session.commit()
return SyncResult(
entity=ENTITY_CALL_RECORDINGS,
status="success",
fetched=attempted,
upserted=localized,
)
except Exception as exc:
logger.exception("sync_call_recordings failed")
await _finish_run(
session, run, status="failed", fetched=0, upserted=0, error=repr(exc)
)
await session.commit()
return SyncResult(
entity=ENTITY_CALL_RECORDINGS,
status="failed",
fetched=0,
upserted=0,
error=repr(exc),
)
async def sync_phone_calls(
session: AsyncSession, client: YunkeClient
) -> SyncResult:
"""Full re-scrape of all phone-call records (``/pc/callLog/list``) and
idempotent upsert into :class:`PhoneCall` (``channel="phone_call"``).
Mirrors :func:`sync_wechat_voice_calls`: the callLog endpoint has no
``since`` param, so "incremental" = re-scrape everything and rely on the
repo's idempotent Core upsert (same ``call_id`` twice -> one refreshed
row). Records a SyncRun row (``entity_type='phone_call'``) and stamps the
watermark's ``last_synced_at`` (no cursor advance — there is no upstream
window to close). A scraper failure is recorded as a failed SyncRun and a
failed SyncResult is returned (no re-raise), so a phone-call scrape that
500s does NOT abort a larger batch.
"""
return await _sync_full_rescrape(
session,
entity_type=ENTITY_PHONE_CALL,
channel=None,
scope_id=None,
scrape=lambda: scrape_phone_calls(client),
upsert=upsert_phone_call,
)
async def sync_sms(
session: AsyncSession, client: YunkeClient
) -> SyncResult:
"""Full re-scrape of all SMS records (``/pc/customer/smsLogsList``) and
idempotent upsert into :class:`Sms`.
Mirrors :func:`sync_phone_calls`: the smsLogsList endpoint has no
``since`` param, so "incremental" = re-scrape everything and rely on the
repo's idempotent Core upsert (same ``sms_id`` twice -> one refreshed
row). Records a SyncRun row (``entity_type='sms'``) and stamps the
watermark's ``last_synced_at``. A scraper failure is recorded as a failed
SyncRun + SyncResult without re-raising.
"""
return await _sync_full_rescrape(
session,
entity_type=ENTITY_SMS,
channel=None,
scope_id=None,
scrape=lambda: scrape_sms(client),
upsert=upsert_sms,
)
# ---------------------------------------------------------------------------
# VoiceClip (长语音) — per-employee scrape across both staff-account tables
# ---------------------------------------------------------------------------
async def _collect_voice_clip_staff(
session: AsyncSession,
) -> list[tuple[str, str]]:
"""Return ``(user_id, channel)`` pairs for voice-clip scraping.
One pair per DISTINCT ``user_id`` across both staff-account tables. The
``call/list`` endpoint is scoped per employee (``userId`` request field)
and is shared by WeChat + WhatsApp staff (distinct ``userId`` ranges);
each employee is scraped exactly once. WeChat accounts take precedence on
collision (deterministic — in practice the two ``userId`` ranges are
disjoint, so collisions are not expected).
The returned ``channel`` is the LOCAL messaging-channel label stamped on
every scraped record: ``wechat`` for ids found in :class:`WechatAccount`,
``whatsapp`` for ids found only in :class:`WhatsappAccount`. The endpoint
response does NOT carry the channel, so it must be resolved here from the
owning account table.
"""
pairs: list[tuple[str, str]] = []
seen: set[str] = set()
wechat_rows = (
await session.execute(
sa.select(WechatAccount.user_id)
.where(WechatAccount.user_id.is_not(None))
.where(WechatAccount.user_id != "")
.distinct()
)
).all()
for (uid,) in wechat_rows:
if uid and uid not in seen:
seen.add(uid)
pairs.append((uid, CHANNEL_WECHAT))
wa_rows = (
await session.execute(
sa.select(WhatsappAccount.user_id)
.where(WhatsappAccount.user_id.is_not(None))
.where(WhatsappAccount.user_id != "")
.distinct()
)
).all()
for (uid,) in wa_rows:
if uid and uid not in seen:
seen.add(uid)
pairs.append((uid, CHANNEL_WHATSAPP))
return pairs
async def sync_voice_clips(
session: AsyncSession, client: YunkeClient
) -> SyncResult:
"""Full re-scrape of all 长语音 (voice_clip) records across all staff.
Walks every distinct ``user_id`` in the local staff-account tables
(:class:`WechatAccount` + :class:`WhatsappAccount`) and, per employee,
scrapes ``POST /wechatweb/call/list`` (body ``{pageNum, pageSize,
userId}``), upserting each record into :class:`VoiceClip`. The channel is
stamped locally (``wechat`` / ``whatsapp``) based on which account table
owns the ``user_id`` — the shared endpoint does not carry it.
Mirrors :func:`sync_wechat_voice_calls`'s failure policy + SyncRun
bookkeeping, but iterates MULTIPLE per-employee scrape streams (rather than
one department-scoped stream). A scraper failure on one employee is caught,
recorded as ``status='failed'`` for the whole entity, and a failed
:class:`SyncResult` is returned (no re-raise) — the caller decides whether
to surface / retry.
When no staff ``user_id`` is present (e.g. accounts not yet synced), the
scrape is SKIPPED with a logged warning and a ``status='skipped'``
:class:`SyncResult` (no ``SyncRun`` recorded), so a fresh DB does not abort
a larger batch.
"""
pairs = await _collect_voice_clip_staff(session)
if not pairs:
logger.warning(
"sync_voice_clips: no staff user_ids found in wechat_account /"
" whatsapp_account (sync accounts first?); skipping call/list scrape"
)
return SyncResult(
entity=ENTITY_VOICE_CLIP,
status="skipped",
fetched=0,
upserted=0,
error="no staff user_ids available",
)
run = await _start_run(session, ENTITY_VOICE_CLIP)
fetched = 0
upserted = 0
try:
for uid, channel in pairs:
async for rec in scrape_voice_clips(client, uid, channel=channel):
await upsert_voice_clip(session, rec)
fetched += 1
upserted += 1
except Exception as exc:
logger.exception("scrape_voice_clips failed")
error = repr(exc)
await _finish_run(
session, run, status="failed",
fetched=fetched, upserted=upserted, error=error,
)
return SyncResult(
entity=ENTITY_VOICE_CLIP,
status="failed",
fetched=fetched,
upserted=upserted,
error=error,
)
await set_watermark(session, ENTITY_VOICE_CLIP)
await _finish_run(
session, run, status="success", fetched=fetched, upserted=upserted
)
return SyncResult(
entity=ENTITY_VOICE_CLIP,
status="success",
fetched=fetched,
upserted=upserted,
)
async def sync_voice_clip_audio(
session: AsyncSession, client: YunkeClient, store: ObjectStore
) -> SyncResult:
"""Download localized copies of voice_clip audio for already-mirrored clips.
Not a scrape: reads local ``voice_clip`` rows whose ``oss_file_name`` (OSS
URL) has no ``media_hash`` yet, downloads each via the public OSS URL, and
links it. Records a ``SyncRun`` (``entity_type='voice_clip_audio'``);
``fetched`` = clips needing download, ``upserted`` = clips localized.
Mirrors :func:`sync_call_recordings` exactly (same commit semantics):
commits its own ``SyncRun`` row because :func:`download_voice_clip_audio`
commits per batch internally, so the run bookkeeping must land in the same
transaction boundary to stay consistent with what was actually persisted.
Failure policy mirrors every other ``sync_*``: a downloader exception is
recorded as a ``status='failed'`` ``SyncRun`` and a failed
:class:`SyncResult` is returned (no re-raise), so a broken audio pass does
NOT abort a multi-entity batch.
"""
run = await _start_run(session, ENTITY_VOICE_CLIP_AUDIO)
try:
attempted, localized = await download_voice_clip_audio(
client, store, session
)
await _finish_run(
session, run, status="success", fetched=attempted, upserted=localized
)
await session.commit()
return SyncResult(
entity=ENTITY_VOICE_CLIP_AUDIO,
status="success",
fetched=attempted,
upserted=localized,
)
except Exception as exc:
logger.exception("sync_voice_clip_audio failed")
await _finish_run(
session, run, status="failed", fetched=0, upserted=0, error=repr(exc)
)
await session.commit()
return SyncResult(
entity=ENTITY_VOICE_CLIP_AUDIO,
status="failed",
fetched=0,
upserted=0,
error=repr(exc),
)
async def sync_message_images(
session: AsyncSession, client: YunkeClient, store: ObjectStore
) -> SyncResult:
"""Download localized copies of chat-message media for already-mirrored
messages.
Not a scrape: reads local ``message`` rows whose ``file_url`` /
``file_th_url`` / ``file_hd_url`` point at OSS, have ``type`` in
('image', 'video', 'file'), are on the wechat / whatsapp channel, and have
no ``media_hash`` yet; downloads each and links it. Records a ``SyncRun``
(``entity_type='message_images'``); ``fetched`` = messages needing
download, ``upserted`` = messages localized.
Mirrors :func:`sync_call_recordings` / :func:`sync_voice_clip_audio`
exactly (same commit semantics): commits its own ``SyncRun`` row because
:func:`download_message_images` commits per batch internally, so the run
bookkeeping must land in the same transaction boundary to stay consistent
with what was actually persisted.
Failure policy mirrors every other ``sync_*``: a downloader exception is
recorded as a ``status='failed'`` ``SyncRun`` and a failed
:class:`SyncResult` is returned (no re-raise), so a broken image pass does
NOT abort a multi-entity batch.
"""
run = await _start_run(session, ENTITY_MESSAGE_IMAGES)
try:
attempted, localized = await download_message_images(
client, store, session
)
await _finish_run(
session, run, status="success", fetched=attempted, upserted=localized
)
await session.commit()
return SyncResult(
entity=ENTITY_MESSAGE_IMAGES,
status="success",
fetched=attempted,
upserted=localized,
)
except Exception as exc:
logger.exception("sync_message_images failed")
await _finish_run(
session, run, status="failed", fetched=0, upserted=0, error=repr(exc)
)
await session.commit()
return SyncResult(
entity=ENTITY_MESSAGE_IMAGES,
status="failed",
fetched=0,
upserted=0,
error=repr(exc),
)
# ---------------------------------------------------------------------------
# Coordinator
# ---------------------------------------------------------------------------
async def sync_all_wechat(
session: AsyncSession,
client: YunkeClient,
store: ObjectStore,
*,
conversations: list[tuple[str, str]] | None = None,
) -> list[SyncResult]:
"""Run the full WeChat sync sequence: org/entities first, then messages.
Order: departments → accounts → friends → groups → voice_calls →
wa_voice_calls, then the per-conversation message sync for every
``(wechat_id, friend_id)`` in ``conversations``. Each step returns its own
:class:`SyncResult` so a single failed entity does NOT abort the batch
(the failure is recorded and the next entity proceeds). Returns the
results in execution order.
``conversations`` is the explicit "which direct / room chats to pull
messages for" list. Full auto-discovery from the talkers endpoint is a
LATER concern (Task 2.5 wires the scraper; wiring it into the coordinator
is intentionally deferred to keep this entrypoint bounded and testable).
When ``conversations`` is ``None`` / empty, only the six full-re-scrape
entities run.
"""
results: list[SyncResult] = [
await sync_departments(session, client),
await sync_wechat_accounts(session, client),
await sync_wechat_friends(session, client),
await sync_wechat_groups(session, client),
await sync_wechat_voice_calls(session, client),
await sync_wa_voice_calls(session, client),
]
for wechat_id, friend_id in conversations or []:
results.append(
await sync_wechat_messages(
session, client, store, wechat_id, friend_id
)
)
return results