feishu_notifier.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. """Send successful real-time control actions as a Feishu spreadsheet."""
  2. from __future__ import annotations
  3. import json
  4. import logging
  5. import os
  6. import time
  7. from datetime import datetime
  8. from decimal import Decimal
  9. from pathlib import Path
  10. from typing import Any
  11. import requests
  12. from openpyxl import Workbook
  13. from openpyxl.styles import Alignment, Font, PatternFill
  14. ROOT = Path(__file__).resolve().parent
  15. FEISHU_BASE_URL = "https://open.feishu.cn/open-apis"
  16. HEADERS = [
  17. "日期",
  18. "数据分区",
  19. "当前CPM",
  20. "账户ID",
  21. "广告ID",
  22. "广告名称",
  23. "人群包",
  24. "今日消耗(元)",
  25. "今日曝光",
  26. "今日点击",
  27. "基础出价(元)",
  28. "调整前出价(元)",
  29. "当前出价(元)",
  30. "调整前开始日期",
  31. "调整后开始日期",
  32. "动作",
  33. "执行结果",
  34. "错误信息",
  35. ]
  36. logger = logging.getLogger("tencent_realtime_control.feishu")
  37. def _yuan(fen: int) -> float:
  38. return float((Decimal(fen) / Decimal("100")).quantize(Decimal("0.00")))
  39. def _action_text(result: dict[str, Any], bid_up_ratio: Decimal) -> str:
  40. decision = result["decision"]
  41. bid_changed = bool(result.get("bid_change_needed"))
  42. status_changed = bool(result.get("status_change_needed"))
  43. if decision == "BOOST":
  44. percent = int((bid_up_ratio - Decimal("1")) * Decimal("100"))
  45. return f"出价上调{percent}%"
  46. if decision == "RESTORE":
  47. if bid_changed and status_changed:
  48. return "恢复基础出价并开启投放"
  49. if bid_changed:
  50. return "恢复基础出价"
  51. return "恢复投放"
  52. if decision == "PAUSE":
  53. target_date = str(result.get("target_begin_date") or "次日")
  54. return (
  55. f"恢复基础出价并延后至{target_date}(CPM过低)"
  56. if bid_changed
  57. else f"延后至{target_date}(CPM过低)"
  58. )
  59. if decision == "CUTOFF":
  60. return "恢复基础出价并关停(投放截止)" if bid_changed else "关停(投放截止)"
  61. return decision
  62. def notification_rows(
  63. *,
  64. now: datetime,
  65. results: list[dict[str, Any]],
  66. bid_up_ratio: Decimal,
  67. observed_partition: str | None,
  68. observed_cpm: Decimal | None,
  69. test_mode: bool = False,
  70. ) -> list[list[Any]]:
  71. rows: list[list[Any]] = []
  72. for result in results:
  73. expected_statuses = {"planned"} if test_mode else {"success", "failed"}
  74. if result.get("status") not in expected_statuses:
  75. continue
  76. is_bid_change = bool(result.get("bid_change_needed"))
  77. is_begin_date_change = bool(result.get("begin_date_change_needed"))
  78. is_pause = (
  79. result.get("decision") in {"PAUSE", "CUTOFF"}
  80. and bool(result.get("status_change_needed"))
  81. )
  82. if not is_bid_change and not is_begin_date_change and not is_pause:
  83. continue
  84. rows.append(
  85. [
  86. now.strftime("%Y-%m-%d %H:%M:%S"),
  87. observed_partition or "",
  88. float(observed_cpm) if observed_cpm is not None else "",
  89. str(result["account_id"]),
  90. str(result["adgroup_id"]),
  91. str(result.get("adgroup_name") or ""),
  92. str(result.get("audience_name") or ""),
  93. _yuan(int(result.get("today_cost_fen") or 0)),
  94. int(result.get("today_impressions") or 0),
  95. int(result.get("today_clicks") or 0),
  96. _yuan(int(result["base_bid_fen"])),
  97. _yuan(int(result["before_bid_fen"])),
  98. (
  99. _yuan(int(result["target_bid_fen"]))
  100. if result.get("status") != "failed"
  101. else ""
  102. ),
  103. str(result.get("before_begin_date") or ""),
  104. str(result.get("target_begin_date") or ""),
  105. (
  106. f"【模拟】{_action_text(result, bid_up_ratio)}"
  107. if test_mode
  108. else _action_text(result, bid_up_ratio)
  109. ),
  110. (
  111. "模拟"
  112. if test_mode
  113. else "成功"
  114. if result.get("status") == "success"
  115. else "失败"
  116. ),
  117. str(result.get("error") or ""),
  118. ]
  119. )
  120. return rows
  121. def create_notification_xlsx(
  122. *,
  123. run_id: str,
  124. now: datetime,
  125. rows: list[list[Any]],
  126. test_mode: bool = False,
  127. ) -> Path:
  128. output_dir = ROOT / "outputs"
  129. output_dir.mkdir(parents=True, exist_ok=True)
  130. mode = "test" if test_mode else "apply"
  131. path = output_dir / (
  132. f"realtime_actions_{mode}_{now.strftime('%Y%m%d_%H%M%S')}_"
  133. f"{run_id[:8]}.xlsx"
  134. )
  135. workbook = Workbook()
  136. sheet = workbook.active
  137. sheet.title = "实时调控动作_模拟" if test_mode else "实时调控动作"
  138. sheet.append(HEADERS)
  139. for row in rows:
  140. sheet.append(row)
  141. header_fill = PatternFill("solid", fgColor="1F4E78")
  142. for cell in sheet[1]:
  143. cell.fill = header_fill
  144. cell.font = Font(color="FFFFFF", bold=True)
  145. cell.alignment = Alignment(horizontal="center", vertical="center")
  146. widths = [
  147. 21, 18, 12, 14, 18, 30, 22, 16, 14, 14, 16, 16, 16, 18, 18, 38, 12, 55
  148. ]
  149. for index, width in enumerate(widths, start=1):
  150. sheet.column_dimensions[chr(64 + index)].width = width
  151. for row in sheet.iter_rows(min_row=2):
  152. for cell in row:
  153. cell.alignment = Alignment(horizontal="center", vertical="center")
  154. for column in ("C", "H", "K", "L", "M"):
  155. for cell in sheet[column][1:]:
  156. cell.number_format = "0.00"
  157. sheet.freeze_panes = "A2"
  158. sheet.auto_filter.ref = sheet.dimensions
  159. workbook.save(path)
  160. return path
  161. class FeishuNotifier:
  162. def __init__(self) -> None:
  163. self.app_id = os.getenv("FEISHU_APP_ID", "").strip()
  164. self.app_secret = os.getenv("FEISHU_APP_SECRET", "").strip()
  165. self.chat_id = (
  166. os.getenv("RTC_FEISHU_CHAT_ID", "").strip()
  167. or os.getenv("FEISHU_AD_PROJECT_CHAT_ID", "").strip()
  168. )
  169. self.timeout = int(os.getenv("RTC_FEISHU_TIMEOUT_SECONDS", "30"))
  170. self.session = requests.Session()
  171. def _require_config(self) -> None:
  172. missing = [
  173. name
  174. for name, value in (
  175. ("FEISHU_APP_ID", self.app_id),
  176. ("FEISHU_APP_SECRET", self.app_secret),
  177. ("RTC_FEISHU_CHAT_ID/FEISHU_AD_PROJECT_CHAT_ID", self.chat_id),
  178. )
  179. if not value
  180. ]
  181. if missing:
  182. raise RuntimeError(
  183. f"Missing Feishu notification configuration: {', '.join(missing)}"
  184. )
  185. def _tenant_token(self) -> str:
  186. response = self.session.post(
  187. f"{FEISHU_BASE_URL}/auth/v3/tenant_access_token/internal",
  188. json={"app_id": self.app_id, "app_secret": self.app_secret},
  189. timeout=self.timeout,
  190. )
  191. response.raise_for_status()
  192. payload = response.json()
  193. if payload.get("code") != 0:
  194. raise RuntimeError(f"Feishu token failed: {payload}")
  195. return str(payload["tenant_access_token"])
  196. @staticmethod
  197. def _headers(token: str) -> dict[str, str]:
  198. return {"Authorization": f"Bearer {token}"}
  199. def _upload(self, token: str, path: Path) -> str:
  200. with path.open("rb") as file:
  201. response = self.session.post(
  202. f"{FEISHU_BASE_URL}/drive/v1/medias/upload_all",
  203. headers=self._headers(token),
  204. data={
  205. "file_name": path.name,
  206. "parent_type": "explorer",
  207. "parent_node": "",
  208. "size": str(path.stat().st_size),
  209. },
  210. files={
  211. "file": (
  212. path.name,
  213. file,
  214. "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  215. )
  216. },
  217. timeout=max(self.timeout, 60),
  218. )
  219. response.raise_for_status()
  220. payload = response.json()
  221. if payload.get("code") != 0:
  222. raise RuntimeError(f"Feishu upload failed: {payload}")
  223. return str(payload["data"]["file_token"])
  224. def _import_sheet(self, token: str, file_token: str, path: Path) -> dict[str, Any]:
  225. response = self.session.post(
  226. f"{FEISHU_BASE_URL}/drive/v1/import_tasks",
  227. headers={**self._headers(token), "Content-Type": "application/json"},
  228. json={
  229. "file_extension": "xlsx",
  230. "file_token": file_token,
  231. "type": "sheet",
  232. "file_name": path.stem,
  233. "point": {"mount_type": 1, "mount_key": ""},
  234. },
  235. timeout=self.timeout,
  236. )
  237. response.raise_for_status()
  238. payload = response.json()
  239. if payload.get("code") != 0:
  240. raise RuntimeError(f"Feishu import task failed: {payload}")
  241. ticket = str(payload["data"]["ticket"])
  242. deadline = time.monotonic() + 60
  243. while time.monotonic() < deadline:
  244. response = self.session.get(
  245. f"{FEISHU_BASE_URL}/drive/v1/import_tasks/{ticket}",
  246. headers=self._headers(token),
  247. timeout=self.timeout,
  248. )
  249. response.raise_for_status()
  250. payload = response.json()
  251. if payload.get("code") != 0:
  252. raise RuntimeError(f"Feishu import query failed: {payload}")
  253. result = (payload.get("data") or {}).get("result") or {}
  254. if result.get("job_status") == 0:
  255. return result
  256. if result.get("job_status") == 3:
  257. raise RuntimeError(
  258. f"Feishu import failed: {result.get('job_error_msg')}"
  259. )
  260. time.sleep(2)
  261. raise RuntimeError("Feishu spreadsheet import timed out after 60 seconds")
  262. def _set_read_permission(
  263. self, token: str, sheet_token: str, file_type: str
  264. ) -> None:
  265. response = self.session.patch(
  266. f"{FEISHU_BASE_URL}/drive/v1/permissions/{sheet_token}/public",
  267. headers={**self._headers(token), "Content-Type": "application/json"},
  268. params={"type": file_type},
  269. json={
  270. "external_access_entity": "open",
  271. "link_share_entity": "anyone_readable",
  272. },
  273. timeout=self.timeout,
  274. )
  275. response.raise_for_status()
  276. payload = response.json()
  277. if payload.get("code") != 0:
  278. logger.warning("Feishu permission update failed: %s", payload)
  279. def _send_card(
  280. self,
  281. token: str,
  282. *,
  283. url: str,
  284. now: datetime,
  285. summary: dict[str, Any],
  286. test_mode: bool,
  287. ) -> None:
  288. action_counts = summary["action_counts"]
  289. action_text = ",".join(
  290. f"{name} {count} 条"
  291. for name, count in action_counts.items()
  292. if count
  293. )
  294. cpm_text = (
  295. f"{summary['observed_cpm']:.2f}"
  296. if summary.get("observed_cpm") is not None
  297. else "无(定时收尾)"
  298. )
  299. summary_lines = [
  300. f"**触发时间:** {now.strftime('%Y-%m-%d %H:%M:%S')}",
  301. ]
  302. if test_mode:
  303. summary_lines.append("**执行模式:** 仅模拟,未修改腾讯广告")
  304. summary_lines.extend(
  305. [
  306. f"**数据分区:** {summary.get('observed_partition') or '无'}",
  307. f"**当前 CPM:** {cpm_text}",
  308. f"**本轮决策:** {summary['decision']}",
  309. (
  310. f"**影响范围:** {summary['account_count']} 个账户,"
  311. f"{summary['row_count']} 条广告"
  312. ),
  313. f"**动作概要:** {action_text}",
  314. (
  315. "**受影响广告今日消耗:** "
  316. f"{summary['today_cost_yuan']:.2f} 元"
  317. ),
  318. f"**执行结果:** 成功 {summary['success_count']} 条,失败 "
  319. f"{summary['failure_count']} 条",
  320. ]
  321. )
  322. card = {
  323. "config": {"wide_screen_mode": True},
  324. "header": {
  325. "title": {
  326. "tag": "plain_text",
  327. "content": (
  328. "腾讯广告实时调控通知(模拟)"
  329. if test_mode
  330. else "腾讯广告实时调控通知"
  331. ),
  332. },
  333. "template": (
  334. "orange"
  335. if test_mode
  336. else "red"
  337. if summary["failure_count"]
  338. else "blue"
  339. ),
  340. },
  341. "elements": [
  342. {
  343. "tag": "div",
  344. "text": {
  345. "tag": "lark_md",
  346. "content": "\n".join(summary_lines),
  347. },
  348. },
  349. {
  350. "tag": "action",
  351. "actions": [
  352. {
  353. "tag": "button",
  354. "text": {"tag": "plain_text", "content": "打开动作明细"},
  355. "type": "primary",
  356. "url": url,
  357. }
  358. ],
  359. },
  360. ],
  361. }
  362. response = self.session.post(
  363. f"{FEISHU_BASE_URL}/im/v1/messages",
  364. headers={**self._headers(token), "Content-Type": "application/json"},
  365. params={"receive_id_type": "chat_id"},
  366. json={
  367. "receive_id": self.chat_id,
  368. "msg_type": "interactive",
  369. "content": json.dumps(card, ensure_ascii=False),
  370. },
  371. timeout=self.timeout,
  372. )
  373. response.raise_for_status()
  374. payload = response.json()
  375. if payload.get("code") != 0:
  376. raise RuntimeError(f"Feishu message failed: {payload}")
  377. def send_text(self, text: str) -> None:
  378. self._require_config()
  379. token = self._tenant_token()
  380. response = self.session.post(
  381. f"{FEISHU_BASE_URL}/im/v1/messages",
  382. headers={**self._headers(token), "Content-Type": "application/json"},
  383. params={"receive_id_type": "chat_id"},
  384. json={
  385. "receive_id": self.chat_id,
  386. "msg_type": "text",
  387. "content": json.dumps({"text": text}, ensure_ascii=False),
  388. },
  389. timeout=self.timeout,
  390. )
  391. response.raise_for_status()
  392. payload = response.json()
  393. if payload.get("code") != 0:
  394. raise RuntimeError(f"Feishu text message failed: {payload}")
  395. def send(
  396. self,
  397. *,
  398. path: Path,
  399. now: datetime,
  400. summary: dict[str, Any],
  401. test_mode: bool = False,
  402. ) -> str:
  403. self._require_config()
  404. token = self._tenant_token()
  405. file_token = self._upload(token, path)
  406. result = self._import_sheet(token, file_token, path)
  407. url = str(result.get("url") or "")
  408. if not url:
  409. raise RuntimeError("Feishu import succeeded without spreadsheet URL")
  410. sheet_token = str(result.get("token") or "")
  411. if sheet_token:
  412. self._set_read_permission(
  413. token,
  414. sheet_token,
  415. str(result.get("type") or "sheet"),
  416. )
  417. self._send_card(
  418. token,
  419. url=url,
  420. now=now,
  421. summary=summary,
  422. test_mode=test_mode,
  423. )
  424. return url
  425. def build_notification_summary(
  426. *,
  427. rows: list[list[Any]],
  428. results: list[dict[str, Any]],
  429. decision: str,
  430. observed_partition: str | None,
  431. observed_cpm: Decimal | None,
  432. test_mode: bool = False,
  433. ) -> dict[str, Any]:
  434. expected_statuses = {"planned"} if test_mode else {"success", "failed"}
  435. notified = [
  436. result
  437. for result in results
  438. if result.get("status") in expected_statuses
  439. and (
  440. result.get("bid_change_needed")
  441. or result.get("begin_date_change_needed")
  442. or (
  443. result.get("decision") in {"PAUSE", "CUTOFF"}
  444. and result.get("status_change_needed")
  445. )
  446. )
  447. ]
  448. return {
  449. "decision": decision,
  450. "observed_partition": observed_partition,
  451. "observed_cpm": float(observed_cpm) if observed_cpm is not None else None,
  452. "row_count": len(rows),
  453. "account_count": len({int(row["account_id"]) for row in notified}),
  454. "today_cost_yuan": sum(
  455. int(row.get("today_cost_fen") or 0) for row in notified
  456. )
  457. / 100,
  458. "success_count": sum(
  459. row.get("status") in {"success", "planned"} for row in notified
  460. ),
  461. "failure_count": sum(row.get("status") == "failed" for row in notified),
  462. "action_counts": {
  463. "上调": sum(row.get("decision") == "BOOST" for row in notified),
  464. "恢复基础价": sum(
  465. row.get("decision") == "RESTORE"
  466. and row.get("bid_change_needed")
  467. for row in notified
  468. ),
  469. "关停": sum(
  470. row.get("decision") == "CUTOFF"
  471. and row.get("status_change_needed")
  472. for row in notified
  473. ),
  474. "延后至次日": sum(
  475. row.get("decision") == "PAUSE"
  476. and row.get("begin_date_change_needed")
  477. for row in notified
  478. ),
  479. },
  480. }
  481. def send_action_notification(
  482. *,
  483. run_id: str,
  484. now: datetime,
  485. results: list[dict[str, Any]],
  486. bid_up_ratio: Decimal,
  487. decision: str,
  488. observed_partition: str | None,
  489. observed_cpm: Decimal | None,
  490. test_mode: bool = False,
  491. ) -> dict[str, Any]:
  492. rows = notification_rows(
  493. now=now,
  494. results=results,
  495. bid_up_ratio=bid_up_ratio,
  496. observed_partition=observed_partition,
  497. observed_cpm=observed_cpm,
  498. test_mode=test_mode,
  499. )
  500. if not rows:
  501. return {"status": "no_actions", "row_count": 0}
  502. path = create_notification_xlsx(
  503. run_id=run_id,
  504. now=now,
  505. rows=rows,
  506. test_mode=test_mode,
  507. )
  508. summary = build_notification_summary(
  509. rows=rows,
  510. results=results,
  511. decision=decision,
  512. observed_partition=observed_partition,
  513. observed_cpm=observed_cpm,
  514. test_mode=test_mode,
  515. )
  516. enabled = os.getenv("RTC_FEISHU_NOTIFY_ENABLED", "1").strip().lower() in {
  517. "1",
  518. "true",
  519. "yes",
  520. "y",
  521. "on",
  522. }
  523. if not enabled:
  524. return {
  525. "status": "disabled",
  526. "row_count": len(rows),
  527. "xlsx_path": str(path),
  528. }
  529. try:
  530. url = FeishuNotifier().send(
  531. path=path,
  532. now=now,
  533. summary=summary,
  534. test_mode=test_mode,
  535. )
  536. return {
  537. "status": "sent",
  538. "row_count": len(rows),
  539. "xlsx_path": str(path),
  540. "url": url,
  541. }
  542. except Exception as exc:
  543. logger.exception("Feishu action notification failed")
  544. return {
  545. "status": "failed",
  546. "row_count": len(rows),
  547. "xlsx_path": str(path),
  548. "error": str(exc),
  549. }
  550. def send_operator_reconciliation_notification(
  551. results: list[dict[str, Any]],
  552. ) -> None:
  553. rows = [
  554. result
  555. for result in results
  556. if result.get("decision") == "OPERATOR_MANUAL_OVERRIDE"
  557. ]
  558. if not rows:
  559. return
  560. details = "\n".join(
  561. f"- 账户 {row['account_id']} / 广告 {row['adgroup_id']}"
  562. for row in rows
  563. )
  564. FeishuNotifier().send_text(
  565. "检测到腾讯后台人工开启广告,已清除系统运营停止状态并尊重人工操作:\n"
  566. f"{details}"
  567. )