feishu_command_service.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. """Feishu WebSocket entry for deterministic advertising control commands."""
  2. from __future__ import annotations
  3. import logging
  4. import os
  5. from concurrent.futures import ThreadPoolExecutor
  6. from datetime import datetime
  7. from typing import Any
  8. from zoneinfo import ZoneInfo
  9. from agent.tools.builtin.feishu.feishu_client import (
  10. ChatType,
  11. FeishuClient,
  12. FeishuMessageEvent,
  13. )
  14. from operator_commands import (
  15. ACTION_CANCEL,
  16. ACTION_CONFIRM,
  17. ACTION_DAY_PAUSE,
  18. ACTION_RESUME,
  19. ACTION_STATUS,
  20. ACTION_STOP,
  21. parse_command,
  22. )
  23. from operator_control import (
  24. cancel_command,
  25. execute_confirmed_command,
  26. pause_status_summary,
  27. preview_write_command,
  28. )
  29. from realtime_config import RealtimeControlConfig
  30. SHANGHAI = ZoneInfo("Asia/Shanghai")
  31. logger = logging.getLogger("tencent_realtime_control.feishu_commands")
  32. _ACTION_LABELS = {
  33. ACTION_DAY_PAUSE: "仅暂停今天",
  34. ACTION_STOP: "持续停止",
  35. ACTION_RESUME: "恢复投放",
  36. }
  37. def _split_ids(raw: str) -> set[str]:
  38. normalized = str(raw or "").replace(",", ",").replace(" ", ",")
  39. return {value.strip() for value in normalized.split(",") if value.strip()}
  40. class FeishuCommandService:
  41. def __init__(self, *, apply: bool) -> None:
  42. app_id = os.getenv("FEISHU_APP_ID", "").strip()
  43. app_secret = os.getenv("FEISHU_APP_SECRET", "").strip()
  44. if not app_id or not app_secret:
  45. raise RuntimeError("FEISHU_APP_ID and FEISHU_APP_SECRET are required")
  46. self.allowed_chat_id = (
  47. os.getenv("RTC_COMMAND_CHAT_ID", "").strip()
  48. or os.getenv("FEISHU_AD_PROJECT_CHAT_ID", "").strip()
  49. )
  50. self.allowed_open_ids = _split_ids(
  51. os.getenv("RTC_COMMAND_ALLOWED_OPEN_IDS", "")
  52. or os.getenv("FEISHU_OPERATOR_OPEN_ID", "")
  53. )
  54. if not self.allowed_chat_id:
  55. raise RuntimeError(
  56. "RTC_COMMAND_CHAT_ID/FEISHU_AD_PROJECT_CHAT_ID is required"
  57. )
  58. if not self.allowed_open_ids:
  59. raise RuntimeError(
  60. "RTC_COMMAND_ALLOWED_OPEN_IDS/FEISHU_OPERATOR_OPEN_ID is required"
  61. )
  62. self.apply = apply
  63. self.config = RealtimeControlConfig.from_env()
  64. self.confirmation_ttl_minutes = int(
  65. os.getenv("RTC_COMMAND_CONFIRM_TTL_MINUTES", "10")
  66. )
  67. if self.confirmation_ttl_minutes < 1:
  68. raise ValueError("RTC_COMMAND_CONFIRM_TTL_MINUTES must be positive")
  69. self.client = FeishuClient(app_id=app_id, app_secret=app_secret)
  70. self.executor = ThreadPoolExecutor(
  71. max_workers=int(os.getenv("RTC_COMMAND_WORKERS", "2")),
  72. thread_name_prefix="feishu-ad-command",
  73. )
  74. def start(self) -> Any:
  75. return self.client.start_websocket(
  76. on_message=self._enqueue_message,
  77. blocking=False,
  78. )
  79. def _enqueue_message(self, event: FeishuMessageEvent) -> None:
  80. self.executor.submit(self.handle_message, event)
  81. def _reply(self, event: FeishuMessageEvent, text: str) -> None:
  82. self.client.send_message(
  83. to=event.chat_id,
  84. text=text,
  85. reply_to_message_id=event.message_id,
  86. )
  87. def _authorized(self, event: FeishuMessageEvent) -> bool:
  88. if event.sender_open_id not in self.allowed_open_ids:
  89. return False
  90. if event.chat_type == ChatType.GROUP:
  91. return (
  92. event.chat_id == self.allowed_chat_id
  93. and event.mentioned_bot
  94. )
  95. return event.chat_type == ChatType.P2P
  96. def handle_message(self, event: FeishuMessageEvent) -> None:
  97. if event.content_type not in {"text", "post"}:
  98. return
  99. if not self._authorized(event):
  100. return
  101. try:
  102. parsed = parse_command(event.content)
  103. if parsed is None:
  104. return
  105. now = datetime.now(SHANGHAI)
  106. if parsed.action == ACTION_STATUS:
  107. summary = pause_status_summary()
  108. accounts = ",".join(str(value) for value in summary["accounts"]) or "无"
  109. self._reply(
  110. event,
  111. "当前运营暂停状态\n"
  112. f"- 暂停广告:{summary['total']} 条\n"
  113. f"- 仅暂停今天:{summary['until_next_delivery']} 条\n"
  114. f"- 持续停止:{summary['until_manual']} 条\n"
  115. f"- 涉及账户:{accounts}",
  116. )
  117. return
  118. if parsed.action == ACTION_CANCEL:
  119. command = cancel_command(
  120. parsed.command_id or "",
  121. event.sender_open_id,
  122. now,
  123. )
  124. message = (
  125. f"命令 {command['command_id']} 已取消"
  126. if command["status"] == "CANCELLED"
  127. else (
  128. f"命令 {command['command_id']} 未取消,"
  129. f"当前状态:{command['status']}"
  130. )
  131. )
  132. self._reply(
  133. event,
  134. message,
  135. )
  136. return
  137. if parsed.action == ACTION_CONFIRM:
  138. if not self.apply:
  139. self._reply(event, "当前服务为 dry-run,禁止执行腾讯写操作。")
  140. return
  141. command = execute_confirmed_command(
  142. parsed.command_id or "",
  143. sender_open_id=event.sender_open_id,
  144. now=now,
  145. start_hour=self.config.start_hour,
  146. lock_name=self.config.lock_name,
  147. )
  148. self._reply(
  149. event,
  150. f"命令 {command['command_id']} 执行完成\n"
  151. f"- 状态:{command['status']}\n"
  152. f"- 成功:{command.get('successes', 0)} 条\n"
  153. f"- 失败:{command.get('failures', 0)} 条",
  154. )
  155. return
  156. command = preview_write_command(
  157. parsed,
  158. now=now,
  159. source_message_id=event.message_id,
  160. chat_id=event.chat_id,
  161. sender_open_id=event.sender_open_id,
  162. sender_name=event.sender_name,
  163. confirmation_ttl_minutes=self.confirmation_ttl_minutes,
  164. start_hour=self.config.start_hour,
  165. )
  166. resume_text = (
  167. command["resume_at"].strftime("%Y-%m-%d %H:%M")
  168. if command.get("resume_at")
  169. else "仅人工明确恢复"
  170. )
  171. self._reply(
  172. event,
  173. f"操作预览:{_ACTION_LABELS[command['action']]}\n"
  174. f"- 账户:{command['preview_account_count']} 个\n"
  175. f"- 预计涉及广告:{command['preview_ad_count']} 条\n"
  176. f"- 恢复时间:{resume_text}\n"
  177. f"- 命令ID:{command['command_id']}\n"
  178. f"请在 {self.confirmation_ttl_minutes} 分钟内回复:"
  179. f"确认 {command['command_id']}\n"
  180. f"取消命令请回复:取消 {command['command_id']}",
  181. )
  182. except Exception as exc:
  183. logger.exception("Feishu command failed")
  184. self._reply(event, f"命令处理失败:{exc}")