"""Publish editable ROI workbooks and row-level approval instructions.""" from __future__ import annotations import json import os import time from pathlib import Path from typing import Any import httpx BASE_URL = "https://open.feishu.cn/open-apis" class RoiFeishuPublisher: def __init__( self, timeout: float = 30.0, *, require_chat_ids: bool = True, ) -> None: self.app_id = os.getenv("FEISHU_APP_ID", "").strip() self.app_secret = os.getenv("FEISHU_APP_SECRET", "").strip() primary_chat_id = ( os.getenv("ROI_FEISHU_CHAT_ID", "").strip() or os.getenv("FEISHU_AD_PROJECT_CHAT_ID", "").strip() or os.getenv("RTC_COMMAND_CHAT_ID", "").strip() ) operator_chat_id = os.getenv("FEISHU_OPERATOR_CHAT_ID", "").strip() self.chat_ids = list( dict.fromkeys( chat_id for chat_id in (primary_chat_id, operator_chat_id) if chat_id ) ) required = { "FEISHU_APP_ID": self.app_id, "FEISHU_APP_SECRET": self.app_secret, } if require_chat_ids: required[ "ROI_FEISHU_CHAT_ID/FEISHU_AD_PROJECT_CHAT_ID/" "RTC_COMMAND_CHAT_ID/FEISHU_OPERATOR_CHAT_ID" ] = self.chat_ids missing = [name for name, value in required.items() if not value] if missing: raise RuntimeError(f"Missing Feishu configuration: {', '.join(missing)}") self.client = httpx.Client(timeout=timeout) def close(self) -> None: self.client.close() @staticmethod def _json(response: httpx.Response, action: str) -> dict[str, Any]: response.raise_for_status() payload = response.json() if payload.get("code") != 0: raise RuntimeError(f"{action} failed: {payload.get('msg', payload)}") return payload def _token(self) -> str: response = self.client.post( f"{BASE_URL}/auth/v3/tenant_access_token/internal", json={"app_id": self.app_id, "app_secret": self.app_secret}, ) return self._json(response, "get tenant token")["tenant_access_token"] @staticmethod def _headers(token: str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} def _upload(self, token: str, path: Path) -> str: with path.open("rb") as handle: response = self.client.post( f"{BASE_URL}/drive/v1/medias/upload_all", headers=self._headers(token), data={ "file_name": path.name, "parent_type": "explorer", "parent_node": "", "size": str(path.stat().st_size), }, files={"file": (path.name, handle, "application/octet-stream")}, timeout=60, ) return self._json(response, "upload ROI workbook")["data"]["file_token"] def _import_sheet(self, token: str, file_token: str, path: Path) -> str: response = self.client.post( f"{BASE_URL}/drive/v1/import_tasks", headers={**self._headers(token), "Content-Type": "application/json"}, json={ "file_extension": "xlsx", "file_token": file_token, "type": "sheet", "file_name": path.stem, "point": {"mount_type": 1, "mount_key": ""}, }, ) return self._json(response, "create ROI sheet import")["data"]["ticket"] def _wait_import(self, token: str, ticket: str) -> dict[str, Any]: deadline = time.monotonic() + 60 while time.monotonic() < deadline: response = self.client.get( f"{BASE_URL}/drive/v1/import_tasks/{ticket}", headers=self._headers(token), ) result = ( self._json(response, "wait ROI sheet import") .get("data", {}) .get("result", {}) ) if result.get("job_status") == 0: return result if result.get("job_status") == 3: raise RuntimeError( f"ROI sheet import failed: {result.get('job_error_msg', 'unknown')}" ) time.sleep(2) raise RuntimeError("ROI sheet import timed out after 60 seconds") def _set_editable_link(self, token: str, sheet_token: str) -> None: response = self.client.patch( f"{BASE_URL}/drive/v1/permissions/{sheet_token}/public", headers={**self._headers(token), "Content-Type": "application/json"}, params={"type": "sheet"}, json={ "external_access_entity": "open", "link_share_entity": "anyone_editable", }, ) self._json(response, "set ROI sheet editable permission") def _send_card( self, token: str, *, run_id: str, batch_name: str, chat_id: str, url: str, summary: str, requires_approval: bool, ) -> str: if not requires_approval: instructions = "\n\n本批次没有可执行动作,无需审批。" else: instructions = ( "\n\n请在有效期内打开表格,取消隐藏小程序三日汇总表的黄色" "【审批选择】列后逐行选择批准或拒绝。批准即为最终确认,系统自动执行。" ) card = { "config": {"wide_screen_mode": True}, "header": { "template": "orange" if requires_approval else "blue", "title": {"tag": "plain_text", "content": "日级 ROI 调控"}, }, "elements": [ { "tag": "div", "text": { "tag": "lark_md", "content": ( f"批次:**{batch_name}**\n" f"审批ID:`{run_id}`\n{summary}{instructions}" ), }, }, {"tag": "hr"}, { "tag": "action", "actions": [ { "tag": "button", "type": "primary", "text": {"tag": "plain_text", "content": "查看 ROI 明细"}, "url": url, } ], }, ], } response = self.client.post( f"{BASE_URL}/im/v1/messages", headers={**self._headers(token), "Content-Type": "application/json"}, params={"receive_id_type": "chat_id"}, json={ "receive_id": chat_id, "msg_type": "interactive", "content": json.dumps(card, ensure_ascii=False), }, ) return self._json(response, "send ROI batch card")["data"]["message_id"] def send_execution_results(self, rows: list[dict[str, Any]]) -> None: if not rows: return token = self._token() for offset in range(0, len(rows), 15): chunk = rows[offset : offset + 15] details = [] for row in chunk: cost = float(row.get("cost") or 0) roi = float(row.get("roi") or 0) result = str(row.get("execution_status") or "") error = str(row.get("error_message") or row.get("skip_reason") or "") details.append( f"- 账户 `{row['account_id']}` / 广告 `{row['adgroup_id']}` / " f"创意 `{row.get('dynamic_creative_id') or '-'}`\n" f" {row.get('adgroup_name') or ''} | 窗口成本 {cost:.2f} 元 | " f"预测ROI {roi:.3f} | **{result}**" + (f" | {error[:160]}" if error else "") ) success_count = sum( row.get("execution_status") == "SUCCESS" for row in chunk ) content = ( f"本次处理 {len(chunk)} 条,成功 {success_count} 条," f"其他 {len(chunk) - success_count} 条。\n\n" + "\n".join(details) ) card = { "config": {"wide_screen_mode": True}, "header": { "template": "blue" if success_count == len(chunk) else "orange", "title": {"tag": "plain_text", "content": "日级 ROI 审批执行结果"}, }, "elements": [ {"tag": "div", "text": {"tag": "lark_md", "content": content}}, { "tag": "action", "actions": [ { "tag": "button", "type": "primary", "text": {"tag": "plain_text", "content": "查看审批表"}, "url": str(chunk[0].get("sheet_url") or ""), } ], }, ], } for chat_id in self.chat_ids: response = self.client.post( f"{BASE_URL}/im/v1/messages", headers={**self._headers(token), "Content-Type": "application/json"}, params={"receive_id_type": "chat_id"}, json={ "receive_id": chat_id, "msg_type": "interactive", "content": json.dumps(card, ensure_ascii=False), }, ) self._json(response, "send ROI execution result") def send_service_alert( self, *, title: str, content: str, chat_id: str | None = None, ) -> str: target_chat_id = ( (chat_id or "").strip() or os.getenv("ROI_FAILURE_FEISHU_CHAT_ID", "").strip() or os.getenv("FEISHU_OPERATOR_CHAT_ID", "").strip() ) if not target_chat_id: raise RuntimeError("Missing ROI failure alert chat_id") card = { "config": {"wide_screen_mode": True}, "header": { "template": "red", "title": {"tag": "plain_text", "content": title}, }, "elements": [ { "tag": "div", "text": {"tag": "lark_md", "content": content}, } ], } token = self._token() response = self.client.post( f"{BASE_URL}/im/v1/messages", headers={**self._headers(token), "Content-Type": "application/json"}, params={"receive_id_type": "chat_id"}, json={ "receive_id": target_chat_id, "msg_type": "interactive", "content": json.dumps(card, ensure_ascii=False), }, ) return self._json(response, "send ROI service failure alert")["data"][ "message_id" ] def publish( self, path: Path, *, run_id: str, batch_name: str, summary: str, requires_approval: bool, ) -> dict[str, str]: imported = self.upload_workbook(path) token = self._token() message_ids = [ self._send_card( token, run_id=run_id, batch_name=batch_name, chat_id=chat_id, url=imported["url"], summary=summary, requires_approval=requires_approval, ) for chat_id in self.chat_ids ] return { **imported, "message_id": message_ids[0], } def upload_workbook(self, path: Path) -> dict[str, str]: """Upload one workbook as an editable online sheet without notifying chats.""" if not path.is_file(): raise FileNotFoundError(path) token = self._token() file_token = self._upload(token, path) ticket = self._import_sheet(token, file_token, path) result = self._wait_import(token, ticket) url = str(result.get("url") or "") sheet_token = str(result.get("token") or "") if not url or not sheet_token: raise RuntimeError("ROI sheet import returned no URL/token") self._set_editable_link(token, sheet_token) return { "url": url, "sheet_token": sheet_token, }