""" 企微通知服务 封装企业微信 API,支持: - 个人消息:button_interaction 模板卡片(原生按钮交互) - 群聊消息:image + news + @text 组合消息 - 媒体上传:图片上传获取 media_id - 卡片更新:按钮点击后更新卡片状态 """ import httpx import time from typing import Optional, List, Dict from app.utils.logger import logger # 告警类型中文映射(全局复用) ALARM_TYPE_NAMES = { "leave_post": "人员离岗", "intrusion": "周界入侵", } # 告警级别映射 ALARM_LEVEL_NAMES = {1: "提醒", 2: "一般", 3: "严重", 4: "紧急"} class WeChatService: """企微通知服务(单例)""" def __init__(self): self._enabled = False self._corp_id = "" self._agent_id = "" self._secret = "" self._token = "" self._encoding_aes_key = "" self._access_token = "" self._token_expire_at = 0 # 缓存 response_code,用于更新卡片状态 # key: task_id (alarm_id), value: response_code self._response_codes: Dict[str, str] = {} def init(self, config): """初始化企微配置""" self._enabled = config.enabled and bool(config.corp_id) and bool(config.secret) self._corp_id = config.corp_id self._agent_id = config.agent_id self._secret = config.secret self._token = config.token self._encoding_aes_key = config.encoding_aes_key if self._enabled: logger.info(f"企微通知服务已启用: corp_id={self._corp_id}") else: logger.info("企微通知服务未启用") @property def enabled(self) -> bool: return self._enabled @property def agent_id_int(self) -> int: return int(self._agent_id) if self._agent_id else 0 async def _get_access_token(self) -> str: """获取企微 access_token(带缓存)""" if self._access_token and time.time() < self._token_expire_at - 60: return self._access_token url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken" params = {"corpid": self._corp_id, "corpsecret": self._secret} async with httpx.AsyncClient(timeout=10) as client: resp = await client.get(url, params=params) data = resp.json() if data.get("errcode") != 0: raise Exception(f"获取 access_token 失败: {data}") self._access_token = data["access_token"] self._token_expire_at = time.time() + data.get("expires_in", 7200) logger.info("企微 access_token 已更新") return self._access_token def save_response_code(self, task_id: str, response_code: str): """保存卡片的 response_code(用于后续更新卡片状态)""" self._response_codes[task_id] = response_code def get_response_code(self, task_id: str) -> Optional[str]: """获取并消耗 response_code(只能用一次)""" return self._response_codes.pop(task_id, None) # ==================== 媒体上传 ==================== async def upload_media(self, image_data: bytes, filename: str = "alarm.jpg") -> Optional[str]: """ 上传临时素材到企微,返回 media_id(3天有效) 用于群聊发送图片消息。 """ if not self._enabled: return None try: access_token = await self._get_access_token() url = f"https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token={access_token}&type=image" async with httpx.AsyncClient(timeout=30) as client: files = {"media": (filename, image_data, "image/jpeg")} resp = await client.post(url, files=files) data = resp.json() if data.get("errcode") and data.get("errcode") != 0: logger.error(f"企微媒体上传失败: {data}") return None media_id = data.get("media_id", "") logger.info(f"企微媒体上传成功: media_id={media_id[:20]}...") return media_id except Exception as e: logger.error(f"企微媒体上传异常: {e}") return None async def _download_image(self, image_url: str) -> Optional[bytes]: """从 URL 下载图片数据""" try: async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client: resp = await client.get(image_url) if resp.status_code == 200: return resp.content logger.error(f"下载图片失败: status={resp.status_code}, url={image_url[:80]}") return None except Exception as e: logger.error(f"下载图片异常: {e}") return None async def upload_media_from_url(self, image_url: str) -> Optional[str]: """从 URL 下载图片后上传到企微,返回 media_id""" image_data = await self._download_image(image_url) if not image_data: return None return await self.upload_media(image_data) # ==================== 个人消息:按钮交互型模板卡片 ==================== async def send_alarm_card( self, user_ids: List[str], alarm_id: str, alarm_type: str, area_name: str, camera_name: str, description: str, event_time: str, alarm_level: int = 2, ) -> bool: """ 发送按钮交互型模板卡片(个人消息) 卡片展示告警信息 + 「前往处理」「误报忽略」按钮, 用户点击按钮后企微回调服务器。 """ if not self._enabled: logger.debug("企微未启用,跳过发送") return False try: access_token = await self._get_access_token() type_name = ALARM_TYPE_NAMES.get(alarm_type, alarm_type) level_name = ALARM_LEVEL_NAMES.get(alarm_level, "一般") msg = { "touser": "|".join(user_ids), "msgtype": "template_card", "agentid": self.agent_id_int, "template_card": { "card_type": "button_interaction", "task_id": alarm_id, "source": { "desc": "AI安防告警", "desc_color": 3 if alarm_level >= 3 else 0, }, "main_title": { "title": f"【{level_name}】{type_name}告警", "desc": description or f"{area_name} 检测到{type_name}", }, "sub_title_text": "请相关人员及时处理", "horizontal_content_list": [ {"keyname": "告警区域", "value": area_name or "未知区域"}, {"keyname": "摄像头", "value": camera_name or "未知"}, {"keyname": "告警时间", "value": event_time}, {"keyname": "告警级别", "value": level_name}, ], "card_action": { "type": 1, "url": "https://work.weixin.qq.com", }, "button_list": [ { "text": "前往处理", "style": 1, "key": f"handle_{alarm_id}", }, { "text": "误报忽略", "style": 2, "key": f"ignore_{alarm_id}", }, ], }, } url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}" async with httpx.AsyncClient(timeout=10) as client: resp = await client.post(url, json=msg) data = resp.json() if data.get("errcode") != 0: logger.error(f"企微卡片发送失败: {data}") return False response_code = data.get("response_code", "") if response_code: self.save_response_code(alarm_id, response_code) logger.info(f"企微卡片已发送: alarm={alarm_id}, users={user_ids}") return True except Exception as e: logger.error(f"企微卡片发送异常: {e}") return False async def update_alarm_card( self, response_code: str, user_ids: List[str], alarm_id: str, action: str, operator_name: str = "", ) -> bool: """更新模板卡片状态(按钮变灰 + 显示处理结果)""" if not self._enabled: return False try: access_token = await self._get_access_token() action_text = { "handle": f"处理中 - {operator_name}" if operator_name else "处理中", "ignore": f"已忽略 - {operator_name}" if operator_name else "已忽略", "complete": f"已处理 - {operator_name}" if operator_name else "已处理", } replace_text = action_text.get(action, "已处理") body = { "userids": user_ids, "agentid": self.agent_id_int, "response_code": response_code, "template_card": { "card_type": "button_interaction", "task_id": alarm_id, "main_title": { "title": replace_text, }, "sub_title_text": f"操作人:{operator_name}", "button_list": [ { "text": replace_text, "style": 3, "key": f"done_{alarm_id}", }, ], }, } url = f"https://qyapi.weixin.qq.com/cgi-bin/message/update_template_card?access_token={access_token}" async with httpx.AsyncClient(timeout=10) as client: resp = await client.post(url, json=body) data = resp.json() if data.get("errcode") != 0: logger.error(f"更新卡片失败: {data}") return False logger.info(f"卡片已更新: alarm={alarm_id}, action={action}") return True except Exception as e: logger.error(f"更新卡片异常: {e}") return False # ==================== 个人消息:文本 ==================== async def send_text_message(self, user_id: str, content: str) -> bool: """发送文本消息给指定用户""" if not self._enabled: return False try: access_token = await self._get_access_token() msg = { "touser": user_id, "msgtype": "text", "agentid": self.agent_id_int, "text": {"content": content}, } url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}" async with httpx.AsyncClient(timeout=10) as client: resp = await client.post(url, json=msg) data = resp.json() if data.get("errcode") != 0: logger.error(f"企微文本消息发送失败: {data}") return False logger.info(f"企微文本消息已发送: user={user_id}") return True except Exception as e: logger.error(f"发送文本消息异常: {e}") return False # ==================== 群聊消息 ==================== async def create_group_chat( self, name: str, owner: str, user_list: List[str], chat_id: str = "", ) -> Optional[str]: """创建企微群聊""" if not self._enabled: return None try: access_token = await self._get_access_token() body = { "name": name, "owner": owner, "userlist": user_list, } if chat_id: body["chatid"] = chat_id url = f"https://qyapi.weixin.qq.com/cgi-bin/appchat/create?access_token={access_token}" async with httpx.AsyncClient(timeout=10) as client: resp = await client.post(url, json=body) data = resp.json() if data.get("errcode") != 0: logger.error(f"创建群聊失败: {data}") return None chatid = data.get("chatid", "") logger.info(f"群聊已创建: name={name}, chatid={chatid}") return chatid except Exception as e: logger.error(f"创建群聊异常: {e}") return None async def send_group_text(self, chat_id: str, content: str) -> bool: """发送文本消息到群聊(支持 <@userid> 语法 @人员)""" if not self._enabled: return False try: access_token = await self._get_access_token() msg = { "chatid": chat_id, "msgtype": "text", "text": {"content": content}, } url = f"https://qyapi.weixin.qq.com/cgi-bin/appchat/send?access_token={access_token}" async with httpx.AsyncClient(timeout=10) as client: resp = await client.post(url, json=msg) data = resp.json() if data.get("errcode") != 0: logger.error(f"群聊文本发送失败: {data}") return False logger.info(f"群聊文本已发送: chatid={chat_id}") return True except Exception as e: logger.error(f"发送群聊文本异常: {e}") return False async def send_group_image(self, chat_id: str, media_id: str) -> bool: """发送图片消息到群聊""" if not self._enabled: return False try: access_token = await self._get_access_token() msg = { "chatid": chat_id, "msgtype": "image", "image": {"media_id": media_id}, } url = f"https://qyapi.weixin.qq.com/cgi-bin/appchat/send?access_token={access_token}" async with httpx.AsyncClient(timeout=10) as client: resp = await client.post(url, json=msg) data = resp.json() if data.get("errcode") != 0: logger.error(f"群聊图片发送失败: {data}") return False logger.info(f"群聊图片已发送: chatid={chat_id}") return True except Exception as e: logger.error(f"发送群聊图片异常: {e}") return False async def send_group_news( self, chat_id: str, title: str, description: str, url: str = "", picurl: str = "", ) -> bool: """发送图文(news)消息到群聊""" if not self._enabled: return False try: access_token = await self._get_access_token() article = { "title": title, "description": description, "url": url or "https://work.weixin.qq.com", } if picurl: article["picurl"] = picurl msg = { "chatid": chat_id, "msgtype": "news", "news": {"articles": [article]}, } api_url = f"https://qyapi.weixin.qq.com/cgi-bin/appchat/send?access_token={access_token}" async with httpx.AsyncClient(timeout=10) as client: resp = await client.post(api_url, json=msg) data = resp.json() if data.get("errcode") != 0: logger.error(f"群聊图文发送失败: {data}") return False logger.info(f"群聊图文已发送: chatid={chat_id}") return True except Exception as e: logger.error(f"发送群聊图文异常: {e}") return False async def send_group_alarm_combo( self, chat_id: str, alarm_id: str, alarm_type: str, area_name: str, camera_name: str, description: str, event_time: str, alarm_level: int = 2, snapshot_url: str = "", mention_user_ids: Optional[List[str]] = None, ) -> bool: """ 发送告警组合消息到群聊(3条消息) 1. image: 告警截图 2. news: 告警详情图文卡片 3. text: @相关人员提醒处理 """ if not self._enabled: return False type_name = ALARM_TYPE_NAMES.get(alarm_type, alarm_type) level_name = ALARM_LEVEL_NAMES.get(alarm_level, "一般") success = True # ---- 1. 发送告警截图(image 消息) ---- if snapshot_url: media_id = await self.upload_media_from_url(snapshot_url) if media_id: sent = await self.send_group_image(chat_id, media_id) if not sent: success = False logger.warning(f"群聊截图发送失败: alarm={alarm_id}") else: logger.warning(f"截图上传企微失败,跳过图片消息: alarm={alarm_id}") # ---- 2. 发送告警详情(news 图文卡片) ---- news_title = f"【{level_name}】{type_name}告警" news_desc = ( f"{description}\n\n" f"告警区域:{area_name or '未知区域'}\n" f"摄像头:{camera_name or '未知'}\n" f"告警时间:{event_time}\n" f"告警ID:{alarm_id}" ) # news 卡片的 picurl 可用 COS 预签名 URL(缩略图) sent = await self.send_group_news( chat_id=chat_id, title=news_title, description=news_desc, picurl=snapshot_url if snapshot_url and snapshot_url.startswith("http") else "", ) if not sent: success = False # ---- 3. @相关人员(text 消息) ---- if mention_user_ids: mentions = " ".join(f"<@{uid}>" for uid in mention_user_ids) text_content = f"{mentions} 请及时处理以上{type_name}告警" sent = await self.send_group_text(chat_id, text_content) if not sent: success = False if success: logger.info(f"群聊组合消息已发送: alarm={alarm_id}, chatid={chat_id}") return success # 全局单例 _wechat_service: Optional[WeChatService] = None def get_wechat_service() -> WeChatService: global _wechat_service if _wechat_service is None: _wechat_service = WeChatService() return _wechat_service