2026-03-06 13:35:40 +08:00
|
|
|
|
"""
|
|
|
|
|
|
企微通知服务
|
|
|
|
|
|
|
2026-03-11 17:43:58 +08:00
|
|
|
|
封装企业微信 API,支持:
|
|
|
|
|
|
- 个人消息:button_interaction 模板卡片(原生按钮交互)
|
|
|
|
|
|
- 群聊消息:image + news + @text 组合消息
|
|
|
|
|
|
- 媒体上传:图片上传获取 media_id
|
|
|
|
|
|
- 卡片更新:按钮点击后更新卡片状态
|
2026-03-06 13:35:40 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
2026-03-19 10:01:46 +08:00
|
|
|
|
import asyncio
|
2026-03-06 13:35:40 +08:00
|
|
|
|
import httpx
|
|
|
|
|
|
import time
|
2026-03-11 14:36:58 +08:00
|
|
|
|
from typing import Optional, List, Dict
|
2026-03-06 13:35:40 +08:00
|
|
|
|
|
2026-03-09 12:31:55 +08:00
|
|
|
|
from app.utils.logger import logger
|
2026-03-06 13:35:40 +08:00
|
|
|
|
|
2026-03-11 17:43:58 +08:00
|
|
|
|
# 告警类型中文映射(全局复用)
|
|
|
|
|
|
ALARM_TYPE_NAMES = {
|
|
|
|
|
|
"leave_post": "人员离岗",
|
|
|
|
|
|
"intrusion": "周界入侵",
|
2026-03-16 16:54:48 +08:00
|
|
|
|
"illegal_parking": "车辆违停",
|
|
|
|
|
|
"vehicle_congestion": "车辆拥堵",
|
2026-03-11 17:43:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 告警级别映射
|
2026-03-18 16:39:16 +08:00
|
|
|
|
ALARM_LEVEL_NAMES = {0: "紧急", 1: "重要", 2: "普通", 3: "轻微"}
|
2026-03-11 17:43:58 +08:00
|
|
|
|
|
2026-03-06 13:35:40 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-03-19 09:21:45 +08:00
|
|
|
|
self._service_base_url = ""
|
2026-03-11 14:36:58 +08:00
|
|
|
|
# 缓存 response_code,用于更新卡片状态
|
|
|
|
|
|
# key: task_id (alarm_id), value: response_code
|
|
|
|
|
|
self._response_codes: Dict[str, str] = {}
|
2026-03-06 13:35:40 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-03-19 09:21:45 +08:00
|
|
|
|
self._service_base_url = getattr(config, "service_base_url", "").rstrip("/")
|
|
|
|
|
|
self._group_chat_id = getattr(config, "group_chat_id", "")
|
2026-03-06 13:35:40 +08:00
|
|
|
|
|
|
|
|
|
|
if self._enabled:
|
|
|
|
|
|
logger.info(f"企微通知服务已启用: corp_id={self._corp_id}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.info("企微通知服务未启用")
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def enabled(self) -> bool:
|
|
|
|
|
|
return self._enabled
|
|
|
|
|
|
|
2026-03-11 17:43:58 +08:00
|
|
|
|
@property
|
|
|
|
|
|
def agent_id_int(self) -> int:
|
|
|
|
|
|
return int(self._agent_id) if self._agent_id else 0
|
|
|
|
|
|
|
2026-03-19 09:21:45 +08:00
|
|
|
|
def _alarm_detail_url(self, alarm_id: str) -> str:
|
|
|
|
|
|
"""构造告警详情页 URL"""
|
|
|
|
|
|
if self._service_base_url:
|
|
|
|
|
|
return f"{self._service_base_url}/aiot/alarm/list?alarmId={alarm_id}"
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
2026-03-06 13:35:40 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-03-11 14:36:58 +08:00
|
|
|
|
def save_response_code(self, task_id: str, response_code: str):
|
2026-03-13 13:37:34 +08:00
|
|
|
|
"""保存卡片的 response_code(内存缓存 + 数据库持久化)"""
|
2026-03-11 14:36:58 +08:00
|
|
|
|
self._response_codes[task_id] = response_code
|
2026-03-13 13:37:34 +08:00
|
|
|
|
try:
|
|
|
|
|
|
from app.models import get_session, AlarmEventExt
|
|
|
|
|
|
db = get_session()
|
|
|
|
|
|
try:
|
|
|
|
|
|
ext = db.query(AlarmEventExt).filter(
|
|
|
|
|
|
AlarmEventExt.alarm_id == task_id,
|
|
|
|
|
|
AlarmEventExt.ext_type == "WECHAT_RESPONSE_CODE",
|
|
|
|
|
|
).first()
|
|
|
|
|
|
if ext:
|
|
|
|
|
|
ext.ext_data = {"response_code": response_code}
|
|
|
|
|
|
else:
|
|
|
|
|
|
ext = AlarmEventExt(
|
|
|
|
|
|
alarm_id=task_id,
|
|
|
|
|
|
ext_type="WECHAT_RESPONSE_CODE",
|
|
|
|
|
|
ext_data={"response_code": response_code},
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(ext)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
finally:
|
|
|
|
|
|
db.close()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"持久化 response_code 失败: {e}")
|
2026-03-11 14:36:58 +08:00
|
|
|
|
|
|
|
|
|
|
def get_response_code(self, task_id: str) -> Optional[str]:
|
2026-03-13 13:37:34 +08:00
|
|
|
|
"""获取 response_code(优先内存缓存,回退数据库查询)"""
|
|
|
|
|
|
code = self._response_codes.pop(task_id, None)
|
|
|
|
|
|
if code:
|
|
|
|
|
|
return code
|
|
|
|
|
|
try:
|
|
|
|
|
|
from app.models import get_session, AlarmEventExt
|
|
|
|
|
|
db = get_session()
|
|
|
|
|
|
try:
|
|
|
|
|
|
ext = db.query(AlarmEventExt).filter(
|
|
|
|
|
|
AlarmEventExt.alarm_id == task_id,
|
|
|
|
|
|
AlarmEventExt.ext_type == "WECHAT_RESPONSE_CODE",
|
|
|
|
|
|
).first()
|
|
|
|
|
|
if ext and ext.ext_data:
|
|
|
|
|
|
return ext.ext_data.get("response_code", "")
|
|
|
|
|
|
finally:
|
|
|
|
|
|
db.close()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"查询 response_code 失败: {e}")
|
|
|
|
|
|
return None
|
2026-03-11 14:36:58 +08:00
|
|
|
|
|
2026-03-11 17:43:58 +08:00
|
|
|
|
# ==================== 媒体上传 ====================
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-03-20 11:05:12 +08:00
|
|
|
|
async def download_media(self, media_id: str) -> Optional[bytes]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
下载企微临时素材(图片/文件)
|
|
|
|
|
|
|
|
|
|
|
|
企微临时素材 3 天过期,需要立即下载并持久化到 COS。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
media_id: 企微消息中的 MediaId
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
图片二进制数据,失败返回 None
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not self._enabled:
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
access_token = await self._get_access_token()
|
|
|
|
|
|
url = f"https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token={access_token}&media_id={media_id}"
|
|
|
|
|
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
|
|
|
|
resp = await client.get(url)
|
|
|
|
|
|
content_type = resp.headers.get("content-type", "")
|
|
|
|
|
|
if resp.status_code == 200 and ("image" in content_type or "octet" in content_type):
|
|
|
|
|
|
logger.info(f"企微素材下载成功: media_id={media_id}, size={len(resp.content)}")
|
|
|
|
|
|
return resp.content
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.error(f"企微素材下载失败: media_id={media_id}, status={resp.status_code}, type={content_type}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"企微素材下载异常: media_id={media_id}, error={e}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-03-11 17:43:58 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
# ==================== 个人消息:按钮交互型模板卡片 ====================
|
|
|
|
|
|
|
2026-03-06 13:35:40 +08:00
|
|
|
|
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:
|
|
|
|
|
|
"""
|
2026-03-11 17:43:58 +08:00
|
|
|
|
发送按钮交互型模板卡片(个人消息)
|
2026-03-06 13:35:40 +08:00
|
|
|
|
|
2026-03-11 17:43:58 +08:00
|
|
|
|
卡片展示告警信息 + 「前往处理」「误报忽略」按钮,
|
|
|
|
|
|
用户点击按钮后企微回调服务器。
|
2026-03-06 13:35:40 +08:00
|
|
|
|
"""
|
|
|
|
|
|
if not self._enabled:
|
|
|
|
|
|
logger.debug("企微未启用,跳过发送")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
access_token = await self._get_access_token()
|
|
|
|
|
|
|
2026-03-11 17:43:58 +08:00
|
|
|
|
type_name = ALARM_TYPE_NAMES.get(alarm_type, alarm_type)
|
2026-03-18 16:39:16 +08:00
|
|
|
|
level_name = ALARM_LEVEL_NAMES.get(alarm_level, "普通")
|
2026-03-06 13:35:40 +08:00
|
|
|
|
|
|
|
|
|
|
msg = {
|
|
|
|
|
|
"touser": "|".join(user_ids),
|
2026-03-11 14:36:58 +08:00
|
|
|
|
"msgtype": "template_card",
|
2026-03-11 17:43:58 +08:00
|
|
|
|
"agentid": self.agent_id_int,
|
2026-03-11 14:36:58 +08:00
|
|
|
|
"template_card": {
|
|
|
|
|
|
"card_type": "button_interaction",
|
|
|
|
|
|
"task_id": alarm_id,
|
2026-03-11 17:43:58 +08:00
|
|
|
|
"source": {
|
|
|
|
|
|
"desc": "AI安防告警",
|
2026-03-18 16:39:16 +08:00
|
|
|
|
"desc_color": 3 if alarm_level <= 1 else 0,
|
2026-03-11 17:43:58 +08:00
|
|
|
|
},
|
2026-03-11 14:36:58 +08:00
|
|
|
|
"main_title": {
|
|
|
|
|
|
"title": f"【{level_name}】{type_name}告警",
|
|
|
|
|
|
"desc": description or f"{area_name} 检测到{type_name}",
|
|
|
|
|
|
},
|
2026-03-11 17:43:58 +08:00
|
|
|
|
"sub_title_text": "请相关人员及时处理",
|
2026-03-11 14:36:58 +08:00
|
|
|
|
"horizontal_content_list": [
|
2026-03-11 17:43:58 +08:00
|
|
|
|
{"keyname": "告警区域", "value": area_name or "未知区域"},
|
|
|
|
|
|
{"keyname": "摄像头", "value": camera_name or "未知"},
|
|
|
|
|
|
{"keyname": "告警时间", "value": event_time},
|
|
|
|
|
|
{"keyname": "告警级别", "value": level_name},
|
2026-03-11 14:36:58 +08:00
|
|
|
|
],
|
|
|
|
|
|
"card_action": {
|
|
|
|
|
|
"type": 1,
|
2026-03-19 09:21:45 +08:00
|
|
|
|
"url": self._alarm_detail_url(alarm_id) or "https://work.weixin.qq.com",
|
2026-03-11 14:36:58 +08:00
|
|
|
|
},
|
|
|
|
|
|
"button_list": [
|
|
|
|
|
|
{
|
2026-03-12 16:34:54 +08:00
|
|
|
|
"text": "确认接单",
|
2026-03-11 14:36:58 +08:00
|
|
|
|
"style": 1,
|
2026-03-12 16:34:54 +08:00
|
|
|
|
"key": f"confirm_{alarm_id}",
|
2026-03-11 14:36:58 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"text": "误报忽略",
|
|
|
|
|
|
"style": 2,
|
|
|
|
|
|
"key": f"ignore_{alarm_id}",
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
2026-03-06 13:35:40 +08:00
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-03-11 17:43:58 +08:00
|
|
|
|
logger.error(f"企微卡片发送失败: {data}")
|
2026-03-06 13:35:40 +08:00
|
|
|
|
return False
|
|
|
|
|
|
|
2026-03-11 14:36:58 +08:00
|
|
|
|
response_code = data.get("response_code", "")
|
|
|
|
|
|
if response_code:
|
|
|
|
|
|
self.save_response_code(alarm_id, response_code)
|
|
|
|
|
|
|
2026-03-11 17:43:58 +08:00
|
|
|
|
logger.info(f"企微卡片已发送: alarm={alarm_id}, users={user_ids}")
|
2026-03-06 13:35:40 +08:00
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
2026-03-11 17:43:58 +08:00
|
|
|
|
logger.error(f"企微卡片发送异常: {e}")
|
2026-03-06 13:35:40 +08:00
|
|
|
|
return False
|
|
|
|
|
|
|
2026-03-12 16:34:54 +08:00
|
|
|
|
async def update_alarm_card_step2(
|
|
|
|
|
|
self,
|
|
|
|
|
|
response_code: str,
|
|
|
|
|
|
user_ids: List[str],
|
|
|
|
|
|
alarm_id: str,
|
|
|
|
|
|
operator_name: str = "",
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""
|
|
|
|
|
|
更新卡片到第二步:确认接单后显示「已处理完成」「标记误报」按钮
|
|
|
|
|
|
|
|
|
|
|
|
利用 update_template_card 将卡片更新为新的交互卡片(非终态),
|
|
|
|
|
|
用户点击后会生成新的 response_code,实现链式更新。
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not self._enabled:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
access_token = await self._get_access_token()
|
|
|
|
|
|
|
|
|
|
|
|
body = {
|
|
|
|
|
|
"userids": user_ids,
|
|
|
|
|
|
"agentid": self.agent_id_int,
|
|
|
|
|
|
"response_code": response_code,
|
|
|
|
|
|
"template_card": {
|
|
|
|
|
|
"card_type": "button_interaction",
|
|
|
|
|
|
"task_id": alarm_id,
|
|
|
|
|
|
"source": {
|
|
|
|
|
|
"desc": "AI安防告警 - 处理中",
|
2026-03-13 10:23:51 +08:00
|
|
|
|
"desc_color": 1,
|
2026-03-12 16:34:54 +08:00
|
|
|
|
},
|
|
|
|
|
|
"main_title": {
|
|
|
|
|
|
"title": f"已接单 - {operator_name}" if operator_name else "已接单",
|
|
|
|
|
|
},
|
|
|
|
|
|
"sub_title_text": "请完成处理后选择操作",
|
|
|
|
|
|
"button_list": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"text": "已处理完成",
|
|
|
|
|
|
"style": 1,
|
|
|
|
|
|
"key": f"complete_{alarm_id}",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"text": "标记误报",
|
|
|
|
|
|
"style": 2,
|
|
|
|
|
|
"key": f"false_{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"更新卡片到步骤2失败: {data}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"卡片已更新到步骤2: alarm={alarm_id}, operator={operator_name}")
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"更新卡片步骤2异常: {e}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
async def update_alarm_card_terminal(
|
2026-03-11 14:36:58 +08:00
|
|
|
|
self,
|
|
|
|
|
|
response_code: str,
|
|
|
|
|
|
user_ids: List[str],
|
|
|
|
|
|
alarm_id: str,
|
|
|
|
|
|
action: str,
|
|
|
|
|
|
operator_name: str = "",
|
|
|
|
|
|
) -> bool:
|
2026-03-13 10:23:51 +08:00
|
|
|
|
"""
|
|
|
|
|
|
更新卡片到终态(仅替换按钮文案,不重绘整张卡片)
|
|
|
|
|
|
|
|
|
|
|
|
使用 button.replace_name 方式更新,原卡片内容保持不变,
|
|
|
|
|
|
按钮变为不可点击状态并显示处理结果文案。
|
|
|
|
|
|
"""
|
2026-03-11 14:36:58 +08:00
|
|
|
|
if not self._enabled:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
access_token = await self._get_access_token()
|
|
|
|
|
|
|
|
|
|
|
|
action_text = {
|
|
|
|
|
|
"complete": f"已处理 - {operator_name}" if operator_name else "已处理",
|
2026-03-12 16:34:54 +08:00
|
|
|
|
"false": f"已标记误报 - {operator_name}" if operator_name else "已标记误报",
|
|
|
|
|
|
"ignore": f"已忽略 - {operator_name}" if operator_name else "已忽略",
|
|
|
|
|
|
"auto_resolve": "系统自动结单",
|
2026-03-11 14:36:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
replace_text = action_text.get(action, "已处理")
|
|
|
|
|
|
|
|
|
|
|
|
body = {
|
2026-03-16 16:54:48 +08:00
|
|
|
|
"userids": user_ids if user_ids else [],
|
2026-03-11 17:43:58 +08:00
|
|
|
|
"agentid": self.agent_id_int,
|
2026-03-11 14:36:58 +08:00
|
|
|
|
"response_code": response_code,
|
2026-03-13 10:23:51 +08:00
|
|
|
|
"button": {
|
|
|
|
|
|
"replace_name": replace_text,
|
2026-03-11 14:36:58 +08:00
|
|
|
|
},
|
|
|
|
|
|
}
|
2026-03-16 16:54:48 +08:00
|
|
|
|
# 如果 user_ids 为空(如边缘自动结单),用 atall=1 更新全部接收人
|
|
|
|
|
|
if not user_ids:
|
|
|
|
|
|
body.pop("userids")
|
|
|
|
|
|
body["atall"] = 1
|
2026-03-11 14:36:58 +08:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-03-12 16:34:54 +08:00
|
|
|
|
logger.error(f"更新卡片终态失败: {data}")
|
2026-03-11 14:36:58 +08:00
|
|
|
|
return False
|
|
|
|
|
|
|
2026-03-12 16:34:54 +08:00
|
|
|
|
logger.info(f"卡片已更新到终态: alarm={alarm_id}, action={action}")
|
2026-03-11 14:36:58 +08:00
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
2026-03-12 16:34:54 +08:00
|
|
|
|
logger.error(f"更新卡片终态异常: {e}")
|
2026-03-11 14:36:58 +08:00
|
|
|
|
return False
|
|
|
|
|
|
|
2026-03-11 17:43:58 +08:00
|
|
|
|
# ==================== 个人消息:文本 ====================
|
|
|
|
|
|
|
2026-03-09 10:42:32 +08:00
|
|
|
|
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",
|
2026-03-11 17:43:58 +08:00
|
|
|
|
"agentid": self.agent_id_int,
|
2026-03-09 10:42:32 +08:00
|
|
|
|
"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
|
|
|
|
|
|
|
2026-03-11 14:36:58 +08:00
|
|
|
|
# ==================== 群聊消息 ====================
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-03-12 14:37:52 +08:00
|
|
|
|
"""发送文本消息到群聊"""
|
2026-03-11 14:36:58 +08:00
|
|
|
|
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:
|
2026-03-11 17:43:58 +08:00
|
|
|
|
logger.error(f"群聊文本发送失败: {data}")
|
2026-03-11 14:36:58 +08:00
|
|
|
|
return False
|
2026-03-11 17:43:58 +08:00
|
|
|
|
logger.info(f"群聊文本已发送: chatid={chat_id}")
|
2026-03-11 14:36:58 +08:00
|
|
|
|
return True
|
|
|
|
|
|
except Exception as e:
|
2026-03-11 17:43:58 +08:00
|
|
|
|
logger.error(f"发送群聊文本异常: {e}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
2026-03-12 14:37:52 +08:00
|
|
|
|
async def send_group_markdown(self, chat_id: str, content: str) -> bool:
|
|
|
|
|
|
"""发送 markdown 消息到群聊(@人员使用此方式)"""
|
|
|
|
|
|
if not self._enabled:
|
|
|
|
|
|
return False
|
|
|
|
|
|
try:
|
|
|
|
|
|
access_token = await self._get_access_token()
|
|
|
|
|
|
msg = {
|
|
|
|
|
|
"chatid": chat_id,
|
|
|
|
|
|
"msgtype": "markdown",
|
|
|
|
|
|
"markdown": {"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"群聊markdown发送失败: {data}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
logger.info(f"群聊markdown已发送: chatid={chat_id}")
|
|
|
|
|
|
return True
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"发送群聊markdown异常: {e}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
2026-03-11 17:43:58 +08:00
|
|
|
|
async def send_group_image(self, chat_id: str, media_id: str) -> bool:
|
|
|
|
|
|
"""发送图片消息到群聊"""
|
|
|
|
|
|
if not self._enabled:
|
2026-03-11 14:36:58 +08:00
|
|
|
|
return False
|
2026-03-11 17:43:58 +08:00
|
|
|
|
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()
|
2026-03-11 22:16:24 +08:00
|
|
|
|
article = {
|
|
|
|
|
|
"title": title,
|
|
|
|
|
|
"description": description,
|
|
|
|
|
|
}
|
2026-03-19 09:21:45 +08:00
|
|
|
|
if url:
|
|
|
|
|
|
article["url"] = url
|
2026-03-11 17:43:58 +08:00
|
|
|
|
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:
|
|
|
|
|
|
"""
|
2026-03-19 09:21:45 +08:00
|
|
|
|
发送告警通知到群聊(2条消息)
|
2026-03-11 17:43:58 +08:00
|
|
|
|
|
2026-03-19 09:21:45 +08:00
|
|
|
|
1. image: 告警截图(可点击放大查看)
|
|
|
|
|
|
2. markdown: 告警详情 + @人员
|
2026-03-11 17:43:58 +08:00
|
|
|
|
"""
|
|
|
|
|
|
if not self._enabled:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
type_name = ALARM_TYPE_NAMES.get(alarm_type, alarm_type)
|
2026-03-18 16:39:16 +08:00
|
|
|
|
level_name = ALARM_LEVEL_NAMES.get(alarm_level, "普通")
|
2026-03-11 17:43:58 +08:00
|
|
|
|
success = True
|
|
|
|
|
|
|
2026-03-19 09:21:45 +08:00
|
|
|
|
# ---- 1. 发送告警截图(image 消息,可点击放大) ----
|
2026-03-11 17:43:58 +08:00
|
|
|
|
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}")
|
2026-03-19 10:01:46 +08:00
|
|
|
|
else:
|
|
|
|
|
|
# 等待企微完成图片投递,避免后续文字消息先于图片到达
|
|
|
|
|
|
await asyncio.sleep(1)
|
2026-03-11 17:43:58 +08:00
|
|
|
|
else:
|
|
|
|
|
|
logger.warning(f"截图上传企微失败,跳过图片消息: alarm={alarm_id}")
|
|
|
|
|
|
|
2026-03-19 09:21:45 +08:00
|
|
|
|
# ---- 2. 告警详情 + @人员(markdown 消息) ----
|
|
|
|
|
|
mention_text = ""
|
|
|
|
|
|
if mention_user_ids:
|
|
|
|
|
|
mentions = " ".join(f"<@{uid}>" for uid in mention_user_ids)
|
|
|
|
|
|
mention_text = f"\n{mentions} 请及时处理"
|
|
|
|
|
|
|
|
|
|
|
|
md_content = (
|
|
|
|
|
|
f"**【{level_name}】{type_name}告警**\n"
|
|
|
|
|
|
f">告警区域:<font color=\"info\">{area_name or '未知区域'}</font>\n"
|
|
|
|
|
|
f">摄像头:<font color=\"info\">{camera_name or '未知'}</font>\n"
|
|
|
|
|
|
f">告警时间:<font color=\"warning\">{event_time}</font>\n"
|
|
|
|
|
|
f">描述:{description}"
|
|
|
|
|
|
f"{mention_text}"
|
2026-03-11 17:43:58 +08:00
|
|
|
|
)
|
2026-03-19 09:21:45 +08:00
|
|
|
|
sent = await self.send_group_markdown(chat_id, md_content)
|
2026-03-11 17:43:58 +08:00
|
|
|
|
if not sent:
|
|
|
|
|
|
success = False
|
|
|
|
|
|
|
|
|
|
|
|
if success:
|
2026-03-19 09:21:45 +08:00
|
|
|
|
logger.info(f"群聊告警通知已发送: alarm={alarm_id}, chatid={chat_id}")
|
2026-03-11 17:43:58 +08:00
|
|
|
|
return success
|
2026-03-11 14:36:58 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-06 13:35:40 +08:00
|
|
|
|
# 全局单例
|
|
|
|
|
|
_wechat_service: Optional[WeChatService] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_wechat_service() -> WeChatService:
|
|
|
|
|
|
global _wechat_service
|
|
|
|
|
|
if _wechat_service is None:
|
|
|
|
|
|
_wechat_service = WeChatService()
|
|
|
|
|
|
return _wechat_service
|