"""Thin client for the Feishu Open Platform Helpdesk API. The Helpdesk API needs TWO auth headers on every call: * ``Authorization: Bearer `` — the standard app token, obtained from the app's ``app_id`` / ``app_secret`` (reuses :class:`FeishuApprovalClient`'s cached token machinery). * ``X-Lark-Helpdesk-Authorization: base64(helpdesk_id:helpdesk_token)`` — the per-服务台 credential, generated by a helpdesk admin in 服务台后台 → 开发者选项. The app must also hold the ``helpdesk:all:readonly`` scope (granted + published on the Feishu open platform). Without it the API returns ``99991672`` (verified against prod 2026-06-22). Query-filter timestamps (``update_time_start`` / ``update_time_end`` / ``create_time_*``) are **epoch milliseconds** — same unit as the response timestamps (verified against prod: a seconds-valued window returns 0 rows). Pagination is 1-based ``page`` + ``page_size``; the response carries no ``has_more`` flag, so callers paginate until a page returns fewer than ``page_size`` rows. """ from __future__ import annotations import base64 import logging from typing import Any import httpx from app.approval.feishu_client import FeishuApprovalClient from app.core.exceptions import AppException logger = logging.getLogger(__name__) _BASE_URL = "https://open.feishu.cn/open-apis" # Feishu helpdesk tickets list caps page_size at 50. _PAGE_SIZE = 50 # Hard ceiling on pages per sync window so a misconfigured giant window # can't run unbounded. 50 × 400 = 20k tickets per window — far above any # 12-hour delta. _MAX_PAGES = 400 class FeishuHelpdeskClient: """One instance per integration row (driver/sync is per-invocation).""" def __init__( self, *, app_id: str, app_secret: str, helpdesk_id: str, helpdesk_token: str, ) -> None: if not helpdesk_id or not helpdesk_token: raise AppException( code="FEISHU_HELPDESK_NOT_CONFIGURED", message=( "飞书服务台 helpdesk_id / helpdesk_token 未配置。请到 " "管理后台 > 连接器 编辑该服务台连接器并填写。" ), status_code=503, ) # FeishuApprovalClient validates app_id/app_secret + caches the token. self._token_client = FeishuApprovalClient( app_id=app_id, app_secret=app_secret, ) self.helpdesk_id = helpdesk_id self._helpdesk_auth = base64.b64encode( f"{helpdesk_id}:{helpdesk_token}".encode(), ).decode() async def _headers(self) -> dict[str, str]: token = await self._token_client.get_tenant_access_token() return { "Authorization": f"Bearer {token}", "X-Lark-Helpdesk-Authorization": self._helpdesk_auth, } async def _get( self, path: str, *, params: dict[str, Any], ) -> dict[str, Any]: headers = await self._headers() async with httpx.AsyncClient( timeout=30, trust_env=False, follow_redirects=False, ) as client: resp = await client.get( f"{_BASE_URL}{path}", params=params, headers=headers, ) payload = resp.json() if payload.get("code") != 0: logger.warning( "feishu helpdesk: GET %s returned non-zero: %s", path, payload, ) raise AppException( code="FEISHU_HELPDESK_API_ERROR", message=( f"Feishu API error {payload.get('code')}: " f"{payload.get('msg', 'helpdesk request failed')}" ), status_code=502, ) return payload.get("data") or {} async def get_helpdesk_name(self) -> str | None: """Best-effort helpdesk display name (from the first ticket page). The list endpoint echoes ``helpdesk_name`` per ticket; there is no cheaper "describe helpdesk" read that this app's scope covers. Returns ``None`` when the helpdesk has no tickets yet. """ data = await self._get( "/helpdesk/v1/tickets", params={"page": 1, "page_size": 1}, ) tickets = data.get("tickets") or [] if tickets: name = tickets[0].get("helpdesk_name") if isinstance(name, str) and name.strip(): return name.strip() return None async def iter_tickets( self, *, update_time_start_ms: int | None = None, update_time_end_ms: int | None = None, ) -> list[dict[str, Any]]: """Return every ticket whose ``updated_at`` falls in the window. Both bounds are epoch-ms and optional (omit for a full pull). The list is accumulated across all pages. """ out: list[dict[str, Any]] = [] for page in range(1, _MAX_PAGES + 1): params: dict[str, Any] = {"page": page, "page_size": _PAGE_SIZE} if update_time_start_ms is not None: params["update_time_start"] = update_time_start_ms if update_time_end_ms is not None: params["update_time_end"] = update_time_end_ms data = await self._get("/helpdesk/v1/tickets", params=params) tickets = data.get("tickets") or [] out.extend(t for t in tickets if isinstance(t, dict)) if len(tickets) < _PAGE_SIZE: break else: logger.warning( "feishu helpdesk: hit _MAX_PAGES=%d for helpdesk_id=%s — " "window may be too wide; some tickets not fetched this run", _MAX_PAGES, self.helpdesk_id, ) return out async def test_connection_total(self) -> int: """Cheap health probe: one-row read returns the helpdesk total.""" data = await self._get( "/helpdesk/v1/tickets", params={"page": 1, "page_size": 1}, ) total = data.get("total") return int(total) if isinstance(total, int) else 0