feishu_command_service.py 16 KB

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