feishu.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. """Publish editable ROI workbooks and row-level approval instructions."""
  2. from __future__ import annotations
  3. import json
  4. import os
  5. import time
  6. from pathlib import Path
  7. from typing import Any
  8. import httpx
  9. BASE_URL = "https://open.feishu.cn/open-apis"
  10. class RoiFeishuPublisher:
  11. def __init__(
  12. self,
  13. timeout: float = 30.0,
  14. *,
  15. require_chat_ids: bool = True,
  16. ) -> None:
  17. self.app_id = os.getenv("FEISHU_APP_ID", "").strip()
  18. self.app_secret = os.getenv("FEISHU_APP_SECRET", "").strip()
  19. primary_chat_id = (
  20. os.getenv("ROI_FEISHU_CHAT_ID", "").strip()
  21. or os.getenv("FEISHU_AD_PROJECT_CHAT_ID", "").strip()
  22. or os.getenv("RTC_COMMAND_CHAT_ID", "").strip()
  23. )
  24. operator_chat_id = os.getenv("FEISHU_OPERATOR_CHAT_ID", "").strip()
  25. self.chat_ids = list(
  26. dict.fromkeys(
  27. chat_id
  28. for chat_id in (primary_chat_id, operator_chat_id)
  29. if chat_id
  30. )
  31. )
  32. required = {
  33. "FEISHU_APP_ID": self.app_id,
  34. "FEISHU_APP_SECRET": self.app_secret,
  35. }
  36. if require_chat_ids:
  37. required[
  38. "ROI_FEISHU_CHAT_ID/FEISHU_AD_PROJECT_CHAT_ID/"
  39. "RTC_COMMAND_CHAT_ID/FEISHU_OPERATOR_CHAT_ID"
  40. ] = self.chat_ids
  41. missing = [name for name, value in required.items() if not value]
  42. if missing:
  43. raise RuntimeError(f"Missing Feishu configuration: {', '.join(missing)}")
  44. self.client = httpx.Client(timeout=timeout)
  45. def close(self) -> None:
  46. self.client.close()
  47. @staticmethod
  48. def _json(response: httpx.Response, action: str) -> dict[str, Any]:
  49. response.raise_for_status()
  50. payload = response.json()
  51. if payload.get("code") != 0:
  52. raise RuntimeError(f"{action} failed: {payload.get('msg', payload)}")
  53. return payload
  54. def _token(self) -> str:
  55. response = self.client.post(
  56. f"{BASE_URL}/auth/v3/tenant_access_token/internal",
  57. json={"app_id": self.app_id, "app_secret": self.app_secret},
  58. )
  59. return self._json(response, "get tenant token")["tenant_access_token"]
  60. @staticmethod
  61. def _headers(token: str) -> dict[str, str]:
  62. return {"Authorization": f"Bearer {token}"}
  63. def _upload(self, token: str, path: Path) -> str:
  64. with path.open("rb") as handle:
  65. response = self.client.post(
  66. f"{BASE_URL}/drive/v1/medias/upload_all",
  67. headers=self._headers(token),
  68. data={
  69. "file_name": path.name,
  70. "parent_type": "explorer",
  71. "parent_node": "",
  72. "size": str(path.stat().st_size),
  73. },
  74. files={"file": (path.name, handle, "application/octet-stream")},
  75. timeout=60,
  76. )
  77. return self._json(response, "upload ROI workbook")["data"]["file_token"]
  78. def _import_sheet(self, token: str, file_token: str, path: Path) -> str:
  79. response = self.client.post(
  80. f"{BASE_URL}/drive/v1/import_tasks",
  81. headers={**self._headers(token), "Content-Type": "application/json"},
  82. json={
  83. "file_extension": "xlsx",
  84. "file_token": file_token,
  85. "type": "sheet",
  86. "file_name": path.stem,
  87. "point": {"mount_type": 1, "mount_key": ""},
  88. },
  89. )
  90. return self._json(response, "create ROI sheet import")["data"]["ticket"]
  91. def _wait_import(self, token: str, ticket: str) -> dict[str, Any]:
  92. deadline = time.monotonic() + 60
  93. while time.monotonic() < deadline:
  94. response = self.client.get(
  95. f"{BASE_URL}/drive/v1/import_tasks/{ticket}",
  96. headers=self._headers(token),
  97. )
  98. result = (
  99. self._json(response, "wait ROI sheet import")
  100. .get("data", {})
  101. .get("result", {})
  102. )
  103. if result.get("job_status") == 0:
  104. return result
  105. if result.get("job_status") == 3:
  106. raise RuntimeError(
  107. f"ROI sheet import failed: {result.get('job_error_msg', 'unknown')}"
  108. )
  109. time.sleep(2)
  110. raise RuntimeError("ROI sheet import timed out after 60 seconds")
  111. def _set_editable_link(self, token: str, sheet_token: str) -> None:
  112. response = self.client.patch(
  113. f"{BASE_URL}/drive/v1/permissions/{sheet_token}/public",
  114. headers={**self._headers(token), "Content-Type": "application/json"},
  115. params={"type": "sheet"},
  116. json={
  117. "external_access_entity": "open",
  118. "link_share_entity": "anyone_editable",
  119. },
  120. )
  121. self._json(response, "set ROI sheet editable permission")
  122. def _send_card(
  123. self,
  124. token: str,
  125. *,
  126. run_id: str,
  127. batch_name: str,
  128. chat_id: str,
  129. url: str,
  130. summary: str,
  131. requires_approval: bool,
  132. ) -> str:
  133. if not requires_approval:
  134. instructions = "\n\n本批次没有可执行动作,无需审批。"
  135. else:
  136. instructions = (
  137. "\n\n请在有效期内打开表格,取消隐藏小程序三日汇总表的黄色"
  138. "【审批选择】列后逐行选择批准或拒绝。批准即为最终确认,系统自动执行。"
  139. )
  140. card = {
  141. "config": {"wide_screen_mode": True},
  142. "header": {
  143. "template": "orange" if requires_approval else "blue",
  144. "title": {"tag": "plain_text", "content": "日级 ROI 调控"},
  145. },
  146. "elements": [
  147. {
  148. "tag": "div",
  149. "text": {
  150. "tag": "lark_md",
  151. "content": (
  152. f"批次:**{batch_name}**\n"
  153. f"审批ID:`{run_id}`\n{summary}{instructions}"
  154. ),
  155. },
  156. },
  157. {"tag": "hr"},
  158. {
  159. "tag": "action",
  160. "actions": [
  161. {
  162. "tag": "button",
  163. "type": "primary",
  164. "text": {"tag": "plain_text", "content": "查看 ROI 明细"},
  165. "url": url,
  166. }
  167. ],
  168. },
  169. ],
  170. }
  171. response = self.client.post(
  172. f"{BASE_URL}/im/v1/messages",
  173. headers={**self._headers(token), "Content-Type": "application/json"},
  174. params={"receive_id_type": "chat_id"},
  175. json={
  176. "receive_id": chat_id,
  177. "msg_type": "interactive",
  178. "content": json.dumps(card, ensure_ascii=False),
  179. },
  180. )
  181. return self._json(response, "send ROI batch card")["data"]["message_id"]
  182. def send_execution_results(self, rows: list[dict[str, Any]]) -> None:
  183. if not rows:
  184. return
  185. token = self._token()
  186. for offset in range(0, len(rows), 15):
  187. chunk = rows[offset : offset + 15]
  188. details = []
  189. for row in chunk:
  190. cost = float(row.get("cost") or 0)
  191. roi = float(row.get("roi") or 0)
  192. result = str(row.get("execution_status") or "")
  193. error = str(row.get("error_message") or row.get("skip_reason") or "")
  194. details.append(
  195. f"- 账户 `{row['account_id']}` / 广告 `{row['adgroup_id']}` / "
  196. f"创意 `{row.get('dynamic_creative_id') or '-'}`\n"
  197. f" {row.get('adgroup_name') or ''} | 窗口成本 {cost:.2f} 元 | "
  198. f"预测ROI {roi:.3f} | **{result}**"
  199. + (f" | {error[:160]}" if error else "")
  200. )
  201. success_count = sum(
  202. row.get("execution_status") == "SUCCESS" for row in chunk
  203. )
  204. content = (
  205. f"本次处理 {len(chunk)} 条,成功 {success_count} 条,"
  206. f"其他 {len(chunk) - success_count} 条。\n\n"
  207. + "\n".join(details)
  208. )
  209. card = {
  210. "config": {"wide_screen_mode": True},
  211. "header": {
  212. "template": "blue" if success_count == len(chunk) else "orange",
  213. "title": {"tag": "plain_text", "content": "日级 ROI 审批执行结果"},
  214. },
  215. "elements": [
  216. {"tag": "div", "text": {"tag": "lark_md", "content": content}},
  217. {
  218. "tag": "action",
  219. "actions": [
  220. {
  221. "tag": "button",
  222. "type": "primary",
  223. "text": {"tag": "plain_text", "content": "查看审批表"},
  224. "url": str(chunk[0].get("sheet_url") or ""),
  225. }
  226. ],
  227. },
  228. ],
  229. }
  230. for chat_id in self.chat_ids:
  231. response = self.client.post(
  232. f"{BASE_URL}/im/v1/messages",
  233. headers={**self._headers(token), "Content-Type": "application/json"},
  234. params={"receive_id_type": "chat_id"},
  235. json={
  236. "receive_id": chat_id,
  237. "msg_type": "interactive",
  238. "content": json.dumps(card, ensure_ascii=False),
  239. },
  240. )
  241. self._json(response, "send ROI execution result")
  242. def send_service_alert(
  243. self,
  244. *,
  245. title: str,
  246. content: str,
  247. chat_id: str | None = None,
  248. ) -> str:
  249. target_chat_id = (
  250. (chat_id or "").strip()
  251. or os.getenv("ROI_FAILURE_FEISHU_CHAT_ID", "").strip()
  252. or os.getenv("FEISHU_OPERATOR_CHAT_ID", "").strip()
  253. )
  254. if not target_chat_id:
  255. raise RuntimeError("Missing ROI failure alert chat_id")
  256. card = {
  257. "config": {"wide_screen_mode": True},
  258. "header": {
  259. "template": "red",
  260. "title": {"tag": "plain_text", "content": title},
  261. },
  262. "elements": [
  263. {
  264. "tag": "div",
  265. "text": {"tag": "lark_md", "content": content},
  266. }
  267. ],
  268. }
  269. token = self._token()
  270. response = self.client.post(
  271. f"{BASE_URL}/im/v1/messages",
  272. headers={**self._headers(token), "Content-Type": "application/json"},
  273. params={"receive_id_type": "chat_id"},
  274. json={
  275. "receive_id": target_chat_id,
  276. "msg_type": "interactive",
  277. "content": json.dumps(card, ensure_ascii=False),
  278. },
  279. )
  280. return self._json(response, "send ROI service failure alert")["data"][
  281. "message_id"
  282. ]
  283. def publish(
  284. self,
  285. path: Path,
  286. *,
  287. run_id: str,
  288. batch_name: str,
  289. summary: str,
  290. requires_approval: bool,
  291. ) -> dict[str, str]:
  292. imported = self.upload_workbook(path)
  293. token = self._token()
  294. message_ids = [
  295. self._send_card(
  296. token,
  297. run_id=run_id,
  298. batch_name=batch_name,
  299. chat_id=chat_id,
  300. url=imported["url"],
  301. summary=summary,
  302. requires_approval=requires_approval,
  303. )
  304. for chat_id in self.chat_ids
  305. ]
  306. return {
  307. **imported,
  308. "message_id": message_ids[0],
  309. }
  310. def upload_workbook(self, path: Path) -> dict[str, str]:
  311. """Upload one workbook as an editable online sheet without notifying chats."""
  312. if not path.is_file():
  313. raise FileNotFoundError(path)
  314. token = self._token()
  315. file_token = self._upload(token, path)
  316. ticket = self._import_sheet(token, file_token, path)
  317. result = self._wait_import(token, ticket)
  318. url = str(result.get("url") or "")
  319. sheet_token = str(result.get("token") or "")
  320. if not url or not sheet_token:
  321. raise RuntimeError("ROI sheet import returned no URL/token")
  322. self._set_editable_link(token, sheet_token)
  323. return {
  324. "url": url,
  325. "sheet_token": sheet_token,
  326. }