feishu_command_service.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. """安全广告控制命令的飞书 WebSocket 入口。"""
  2. from __future__ import annotations
  3. import logging
  4. import os
  5. import threading
  6. import uuid
  7. from concurrent.futures import ThreadPoolExecutor
  8. from datetime import datetime, timedelta
  9. from typing import Any
  10. from zoneinfo import ZoneInfo
  11. from agent.tools.builtin.feishu.feishu_client import (
  12. ChatType,
  13. FeishuClient,
  14. FeishuMessageEvent,
  15. )
  16. from account_status_query import (
  17. build_account_status_card,
  18. create_account_status_xlsx,
  19. query_all_account_statuses,
  20. )
  21. from command_intent_parser import CommandIntentParser
  22. from feishu_notifier import FeishuNotifier
  23. from operator_commands import (
  24. ACTION_ACCOUNT_DELIVERY_STATUS,
  25. ACTION_CANCEL,
  26. ACTION_CONFIRM,
  27. ACTION_DAY_PAUSE,
  28. ACTION_REJECT,
  29. ACTION_RESUME,
  30. ACTION_STATUS,
  31. ACTION_STOP,
  32. ACTION_TODAY_SPEND,
  33. normalize_text,
  34. parse_command,
  35. )
  36. from operator_control import (
  37. cancel_command,
  38. execute_confirmed_command,
  39. pause_status_summary,
  40. preview_write_command,
  41. )
  42. from realtime_config import RealtimeControlConfig
  43. from storage import (
  44. close_operator_draft,
  45. list_pending_operator_commands,
  46. load_active_operator_draft,
  47. load_operator_command_by_source,
  48. save_operator_draft,
  49. )
  50. from today_spend_query import query_today_spend
  51. SHANGHAI = ZoneInfo("Asia/Shanghai")
  52. logger = logging.getLogger("tencent_realtime_control.feishu_commands")
  53. _ACTION_LABELS = {
  54. ACTION_DAY_PAUSE: "仅暂停今天",
  55. ACTION_STOP: "持续停止",
  56. ACTION_RESUME: "恢复投放",
  57. }
  58. def _split_ids(raw: str) -> set[str]:
  59. normalized = str(raw or "").replace(",", ",").replace(" ", ",")
  60. return {value.strip() for value in normalized.split(",") if value.strip()}
  61. class FeishuCommandService:
  62. def __init__(self, *, apply: bool) -> None:
  63. app_id = os.getenv("FEISHU_APP_ID", "").strip()
  64. app_secret = os.getenv("FEISHU_APP_SECRET", "").strip()
  65. if not app_id or not app_secret:
  66. raise RuntimeError("FEISHU_APP_ID and FEISHU_APP_SECRET are required")
  67. self.allowed_chat_id = (
  68. os.getenv("RTC_COMMAND_CHAT_ID", "").strip()
  69. or os.getenv("FEISHU_AD_PROJECT_CHAT_ID", "").strip()
  70. )
  71. self.allowed_open_ids = _split_ids(
  72. os.getenv("RTC_COMMAND_ALLOWED_OPEN_IDS", "")
  73. or os.getenv("FEISHU_OPERATOR_OPEN_ID", "")
  74. )
  75. if not self.allowed_chat_id:
  76. raise RuntimeError("RTC_COMMAND_CHAT_ID/FEISHU_AD_PROJECT_CHAT_ID is required")
  77. if not self.allowed_open_ids:
  78. raise RuntimeError(
  79. "RTC_COMMAND_ALLOWED_OPEN_IDS/FEISHU_OPERATOR_OPEN_ID is required"
  80. )
  81. self.apply = apply
  82. self.config = RealtimeControlConfig.from_env()
  83. self.delivery_start_hour = self.config.next_delivery_hour
  84. self.confirmation_ttl_minutes = int(
  85. os.getenv("RTC_COMMAND_CONFIRM_TTL_MINUTES", "10")
  86. )
  87. self.draft_ttl_minutes = int(os.getenv("RTC_COMMAND_DRAFT_TTL_MINUTES", "5"))
  88. if self.confirmation_ttl_minutes < 1 or self.draft_ttl_minutes < 1:
  89. raise ValueError("command TTL values must be positive")
  90. self.intent_parser = CommandIntentParser()
  91. self.client = FeishuClient(app_id=app_id, app_secret=app_secret)
  92. self.executor = ThreadPoolExecutor(
  93. max_workers=int(os.getenv("RTC_COMMAND_WORKERS", "2")),
  94. thread_name_prefix="feishu-ad-command",
  95. )
  96. self._conversation_locks: dict[tuple[str, str], threading.Lock] = {}
  97. self._conversation_locks_guard = threading.Lock()
  98. def start(self) -> Any:
  99. return self.client.start_websocket(
  100. on_message=self._enqueue_message,
  101. blocking=False,
  102. )
  103. def _enqueue_message(self, event: FeishuMessageEvent) -> None:
  104. if not self._authorized(event):
  105. return
  106. self.executor.submit(self._handle_serialized, event)
  107. def _handle_serialized(self, event: FeishuMessageEvent) -> None:
  108. key = (event.chat_id, event.sender_open_id)
  109. with self._conversation_locks_guard:
  110. lock = self._conversation_locks.setdefault(key, threading.Lock())
  111. with lock:
  112. self.handle_message(event)
  113. def _reply(self, event: FeishuMessageEvent, text: str) -> None:
  114. self.client.send_message(
  115. to=event.chat_id,
  116. text=text,
  117. reply_to_message_id=event.message_id,
  118. )
  119. def _reply_account_status_card(
  120. self,
  121. event: FeishuMessageEvent,
  122. summary: dict[str, Any],
  123. sheet_url: str,
  124. ) -> None:
  125. self.client.send_card(
  126. to=event.chat_id,
  127. card=build_account_status_card(summary, sheet_url=sheet_url),
  128. reply_to_message_id=event.message_id,
  129. )
  130. def _authorized(self, event: FeishuMessageEvent) -> bool:
  131. return (
  132. event.content_type in {"text", "post"}
  133. and event.chat_type == ChatType.GROUP
  134. and event.chat_id == self.allowed_chat_id
  135. and event.sender_open_id in self.allowed_open_ids
  136. and event.mentioned_bot
  137. )
  138. def _reply_pending_commands(self, event: FeishuMessageEvent, now: datetime) -> None:
  139. commands = list_pending_operator_commands(
  140. event.chat_id, event.sender_open_id, now
  141. )
  142. if not commands:
  143. self._reply(event, "当前没有待确认命令。")
  144. return
  145. lines = ["请指定要确认的命令ID:"]
  146. for command in commands:
  147. lines.append(
  148. f"- {command['command_id']} / {_ACTION_LABELS.get(command['action'], command['action'])} "
  149. f"/ {command['preview_account_count']}个账户 "
  150. f"/ {command['preview_ad_count']}条广告 "
  151. f"/ 今日消耗{int(command.get('preview_cost_fen') or 0) / 100:.2f}元"
  152. )
  153. self._reply(event, "\n".join(lines))
  154. def _handle_command_id_action(
  155. self,
  156. event: FeishuMessageEvent,
  157. now: datetime,
  158. ) -> bool:
  159. text = normalize_text(event.content)
  160. if text == "确认":
  161. self._reply_pending_commands(event, now)
  162. return True
  163. if text in {"取消", "取消对话", "取消草稿"}:
  164. close_operator_draft(event.chat_id, event.sender_open_id, "CANCELLED")
  165. self._reply(event, "当前未完成的对话已取消。")
  166. return True
  167. if not text.startswith(("确认", "取消", "拒绝")):
  168. return False
  169. parsed = parse_command(text)
  170. if parsed is None:
  171. return False
  172. if parsed.action == ACTION_CANCEL:
  173. if (parsed.command_id or "").startswith("roi_"):
  174. raise ValueError("ROI批次请在审批表黄色列逐行处理")
  175. command = cancel_command(parsed.command_id or "", event.sender_open_id, now)
  176. message = (
  177. f"命令 {command['command_id']} 已取消"
  178. if command["status"] == "CANCELLED"
  179. else f"命令 {command['command_id']} 未取消,当前状态:{command['status']}"
  180. )
  181. self._reply(event, message)
  182. return True
  183. if parsed.action == ACTION_REJECT:
  184. self._reply(event, "ROI批次请在审批表黄色列逐行批准或拒绝。")
  185. return True
  186. if parsed.action != ACTION_CONFIRM:
  187. return False
  188. if (parsed.command_id or "").startswith("roi_"):
  189. self._reply(event, "ROI批次请在审批表黄色列逐行批准或拒绝。")
  190. return True
  191. if not self.apply:
  192. self._reply(event, "当前服务为 dry-run,禁止执行腾讯写操作。")
  193. return True
  194. command = execute_confirmed_command(
  195. parsed.command_id or "",
  196. sender_open_id=event.sender_open_id,
  197. now=now,
  198. start_hour=self.delivery_start_hour,
  199. lock_name=self.config.lock_name,
  200. )
  201. account_ids = ",".join(
  202. str(value) for value in command.get("target_account_ids") or []
  203. )
  204. self._reply(
  205. event,
  206. f"命令 {command['command_id']} 执行完成\n"
  207. f"- 状态:{command['status']}\n"
  208. f"- 账户:{account_ids or '无'}\n"
  209. f"- 预览时今日消耗:"
  210. f"{int(command.get('preview_cost_fen') or 0) / 100:.2f}元\n"
  211. f"- 成功:{command.get('successes', 0)} 条\n"
  212. f"- 跳过:{command.get('skipped', 0)} 条\n"
  213. f"- 失败:{command.get('failures', 0)} 条",
  214. )
  215. return True
  216. def _save_incomplete_intent(
  217. self,
  218. event: FeishuMessageEvent,
  219. intent: Any,
  220. draft: dict[str, Any] | None,
  221. now: datetime,
  222. ) -> None:
  223. raw_text = "\n".join(
  224. value for value in ((draft or {}).get("raw_text"), event.content) if value
  225. )
  226. source_ids = list((draft or {}).get("source_message_ids") or [])
  227. source_ids.append(event.message_id)
  228. save_operator_draft({
  229. "draft_id": f"draft_{uuid.uuid4().hex}",
  230. "chat_id": event.chat_id,
  231. "sender_open_id": event.sender_open_id,
  232. "raw_text": raw_text,
  233. "action": intent.action,
  234. "scope_type": intent.scope_type,
  235. "account_ids": list(intent.account_ids),
  236. "missing_fields": list(intent.missing_fields or ("scope",)),
  237. "source_message_ids": source_ids,
  238. "expires_at": now + timedelta(minutes=self.draft_ttl_minutes),
  239. })
  240. if "clarification" in intent.missing_fields:
  241. question = "我没有准确理解该操作。请明确发送:暂停今天、持续停止、恢复投放或查询暂停状态。"
  242. else:
  243. if intent.action == ACTION_TODAY_SPEND:
  244. scope_hint = "自动化账户、全部账户,或一个/多个账户ID"
  245. else:
  246. scope_hint = "全部纳管账户,或一个/多个账户ID"
  247. question = (
  248. f"请指定操作范围:{scope_hint}。\n"
  249. f"该对话将在 {self.draft_ttl_minutes} 分钟后过期。"
  250. )
  251. self._reply(event, question)
  252. def _reply_preview(self, event: FeishuMessageEvent, command: dict[str, Any]) -> None:
  253. lines = [
  254. f"操作预览:{_ACTION_LABELS[command['action']]}",
  255. f"- 数据时间:{command['previewed_at'].strftime('%Y-%m-%d %H:%M:%S')}",
  256. ]
  257. if command["action"] == ACTION_DAY_PAUSE:
  258. lines.append(
  259. "- 次日自动投放:"
  260. f"{command['resume_at'].strftime('%Y-%m-%d %H:%M')}(腾讯投放时段)"
  261. )
  262. elif command["action"] == ACTION_STOP:
  263. lines.append("- 恢复方式:后续发送恢复命令")
  264. lines.append("- 影响账户:")
  265. for row in command.get("account_summaries") or []:
  266. lines.append(
  267. f" - {row['account_id']}:{row['ad_count']}条广告,"
  268. f"今日消耗{int(row['cost_fen']) / 100:.2f}元"
  269. )
  270. lines.extend([
  271. f"- 汇总:{command['preview_account_count']}个账户,"
  272. f"{command['preview_ad_count']}条广告,今日消耗"
  273. f"{int(command.get('preview_cost_fen') or 0) / 100:.2f}元",
  274. f"- 命令ID:{command['command_id']}",
  275. f"请在 {self.confirmation_ttl_minutes} 分钟内回复:确认 {command['command_id']}",
  276. f"取消命令请回复:取消 {command['command_id']}",
  277. ])
  278. self._reply(event, "\n".join(lines))
  279. def handle_message(self, event: FeishuMessageEvent) -> None:
  280. if not self._authorized(event):
  281. return
  282. now = datetime.now(SHANGHAI)
  283. try:
  284. existing = load_operator_command_by_source(event.message_id)
  285. if existing:
  286. self._reply(
  287. event,
  288. f"该消息已处理:{existing['command_id']},"
  289. f"当前状态 {existing['status']}。",
  290. )
  291. return
  292. if self._handle_command_id_action(event, now):
  293. return
  294. draft = load_active_operator_draft(
  295. event.chat_id, event.sender_open_id, now
  296. )
  297. try:
  298. intent = self.intent_parser.understand(event.content, draft=draft)
  299. except Exception as exc:
  300. logger.exception("Natural-language command parsing failed")
  301. self._reply(
  302. event,
  303. "自然语言理解暂时不可用,标准命令仍可使用:\n"
  304. "- 暂停全部\n- 暂停 账户ID\n- 停止 账户ID\n- 恢复 账户ID",
  305. )
  306. return
  307. if intent is None:
  308. self._reply(
  309. event,
  310. "未识别为投放控制命令。可使用:暂停今天、持续停止、恢复投放、查询暂停状态。",
  311. )
  312. return
  313. if (
  314. draft
  315. and intent.action
  316. and intent.action != draft.get("action")
  317. ):
  318. close_operator_draft(
  319. event.chat_id,
  320. event.sender_open_id,
  321. "SUPERSEDED",
  322. )
  323. draft = None
  324. if not intent.complete:
  325. self._save_incomplete_intent(event, intent, draft, now)
  326. return
  327. if draft:
  328. close_operator_draft(event.chat_id, event.sender_open_id, "COMPLETED")
  329. if intent.action == ACTION_STATUS:
  330. summary = pause_status_summary(now)
  331. accounts = ",".join(str(value) for value in summary["accounts"]) or "无"
  332. self._reply(
  333. event,
  334. "当前运营暂停状态\n"
  335. f"- 暂停广告:{summary['total']} 条\n"
  336. f"- 仅暂停今天:{summary['until_next_delivery']} 条\n"
  337. f"- 持续停止:{summary['until_manual']} 条\n"
  338. f"- 涉及账户:{accounts}",
  339. )
  340. return
  341. if intent.action == ACTION_ACCOUNT_DELIVERY_STATUS:
  342. summary = query_all_account_statuses(now=now)
  343. report_path = create_account_status_xlsx(summary)
  344. sheet_url = FeishuNotifier().import_spreadsheet(
  345. report_path,
  346. readonly_link=True,
  347. )
  348. self._reply_account_status_card(event, summary, sheet_url)
  349. return
  350. if intent.action == ACTION_TODAY_SPEND:
  351. summary = query_today_spend(
  352. intent.to_parsed_command(),
  353. now=now,
  354. )
  355. amount_label = (
  356. "今日总消耗"
  357. if summary["complete"]
  358. else "成功账户消耗合计"
  359. )
  360. status = "完整" if summary["complete"] else "部分数据"
  361. self._reply(
  362. event,
  363. f"今日投放总览({summary['scope_label']})\n"
  364. f"- 数据日期:{summary['data_date']}\n"
  365. f"- 查询时间:{summary['queried_at'].strftime('%Y-%m-%d %H:%M:%S')}\n"
  366. f"- 查询状态:{status}\n"
  367. f"- 账户数量:{summary['account_count']} 个\n"
  368. f"- 有消耗账户:{summary['spending_account_count']} 个\n"
  369. f"- {amount_label}:{summary['cost_fen'] / 100:.2f} 元\n"
  370. f"- 曝光:{summary['impressions']:,}\n"
  371. f"- 点击:{summary['clicks']:,}\n"
  372. f"- 转化:{summary['conversions']:,}\n"
  373. f"- 查询失败账户:{summary['failed_account_count']} 个",
  374. )
  375. return
  376. combined_text = "\n".join(
  377. value for value in ((draft or {}).get("raw_text"), event.content) if value
  378. )
  379. command = preview_write_command(
  380. intent.to_parsed_command(),
  381. now=now,
  382. source_message_id=event.message_id,
  383. chat_id=event.chat_id,
  384. sender_open_id=event.sender_open_id,
  385. sender_name=event.sender_name,
  386. confirmation_ttl_minutes=self.confirmation_ttl_minutes,
  387. start_hour=self.delivery_start_hour,
  388. raw_text=combined_text,
  389. parse_source=intent.parse_source,
  390. preview_lock_name=f"{self.config.lock_name}:operator-preview",
  391. intent={
  392. "action": intent.action,
  393. "scope_type": intent.scope_type,
  394. "account_ids": list(intent.account_ids),
  395. "confidence": intent.confidence,
  396. },
  397. )
  398. self._reply_preview(event, command)
  399. except Exception as exc:
  400. logger.exception("Feishu command failed")
  401. self._reply(event, f"命令处理失败:{exc}")