- Replace Webhook with App ID + App Secret + mobile number - Reuse AirDrama's feishu app (send private card messages) - Add test button in system settings - Add test-feishu API endpoint - Default monitor interval changed to 60 seconds - Token caching for feishu tenant_access_token Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
172 lines
5.9 KiB
Python
172 lines
5.9 KiB
Python
"""飞书自建应用通知(复用 AirDrama 的飞书应用,发私信卡片)"""
|
||
|
||
import json
|
||
import logging
|
||
import threading
|
||
|
||
import requests
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Token 缓存
|
||
_token_cache = {'token': '', 'expires': 0}
|
||
|
||
|
||
def _get_tenant_access_token(app_id: str, app_secret: str) -> str:
|
||
"""获取飞书 tenant_access_token(带简单缓存)"""
|
||
import time
|
||
if _token_cache['token'] and _token_cache['expires'] > time.time():
|
||
return _token_cache['token']
|
||
|
||
resp = requests.post(
|
||
'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal',
|
||
json={'app_id': app_id, 'app_secret': app_secret},
|
||
timeout=5,
|
||
)
|
||
data = resp.json()
|
||
if data.get('code') != 0:
|
||
raise RuntimeError(f'Feishu token error: {data}')
|
||
|
||
token = data['tenant_access_token']
|
||
expire = data.get('expire', 7200)
|
||
_token_cache['token'] = token
|
||
_token_cache['expires'] = time.time() + expire - 60 # 提前60秒过期
|
||
return token
|
||
|
||
|
||
def _get_open_id_by_mobile(token: str, mobile: str) -> str:
|
||
"""通过手机号查询飞书 open_id"""
|
||
resp = requests.post(
|
||
'https://open.feishu.cn/open-apis/contact/v3/users/batch_get_id',
|
||
headers={'Authorization': f'Bearer {token}'},
|
||
json={'mobiles': [mobile]},
|
||
timeout=5,
|
||
)
|
||
data = resp.json()
|
||
if data.get('code') != 0:
|
||
raise RuntimeError(f'Feishu user lookup error: {data}')
|
||
user_list = data.get('data', {}).get('user_list', [])
|
||
if user_list and user_list[0].get('user_id'):
|
||
return user_list[0]['user_id']
|
||
return None
|
||
|
||
|
||
def send_feishu_alert(webhook_url: str, title: str, content: str,
|
||
template: str = "red"):
|
||
"""发送飞书卡片消息到配置的接收人(非阻塞)
|
||
|
||
webhook_url 参数保留兼容性但不再使用,改为从 GlobalConfig 读取飞书应用配置。
|
||
"""
|
||
def _send():
|
||
try:
|
||
from apps.monitor.models import GlobalConfig
|
||
config = GlobalConfig.get_solo()
|
||
|
||
app_id = config.feishu_app_id
|
||
app_secret = config.feishu_app_secret
|
||
mobiles_str = config.feishu_alert_mobiles
|
||
|
||
if not app_id or not app_secret:
|
||
logger.warning(f"飞书应用未配置,跳过通知: {title}")
|
||
return
|
||
|
||
if not mobiles_str:
|
||
logger.warning(f"飞书告警手机号未配置,跳过通知: {title}")
|
||
return
|
||
|
||
mobiles = [m.strip() for m in mobiles_str.split(',') if m.strip()]
|
||
if not mobiles:
|
||
return
|
||
|
||
token = _get_tenant_access_token(app_id, app_secret)
|
||
|
||
card = {
|
||
'config': {'wide_screen_mode': True},
|
||
'header': {
|
||
'title': {'tag': 'plain_text', 'content': title},
|
||
'template': template,
|
||
},
|
||
'elements': [
|
||
{
|
||
'tag': 'div',
|
||
'text': {'tag': 'lark_md', 'content': content},
|
||
}
|
||
],
|
||
}
|
||
|
||
for mobile in mobiles:
|
||
try:
|
||
open_id = _get_open_id_by_mobile(token, mobile)
|
||
if not open_id:
|
||
logger.warning(f'未找到手机号 {mobile} 对应的飞书用户')
|
||
continue
|
||
|
||
resp = requests.post(
|
||
'https://open.feishu.cn/open-apis/im/v1/messages',
|
||
headers={'Authorization': f'Bearer {token}'},
|
||
params={'receive_id_type': 'open_id'},
|
||
json={
|
||
'receive_id': open_id,
|
||
'msg_type': 'interactive',
|
||
'content': json.dumps(card, ensure_ascii=False),
|
||
},
|
||
timeout=5,
|
||
)
|
||
data = resp.json()
|
||
if data.get('code') != 0:
|
||
logger.error(f'飞书发送失败 {mobile}: {data}')
|
||
else:
|
||
logger.info(f'飞书通知已发送 {mobile}: {title}')
|
||
except Exception as e:
|
||
logger.error(f'飞书通知错误 {mobile}: {e}')
|
||
|
||
except Exception as e:
|
||
logger.error(f"飞书通知发送失败: {e}")
|
||
|
||
thread = threading.Thread(target=_send, daemon=True)
|
||
thread.start()
|
||
|
||
|
||
def send_feishu_test(app_id: str, app_secret: str, mobile: str):
|
||
"""发送测试消息。Returns (success, message)。"""
|
||
try:
|
||
token = _get_tenant_access_token(app_id, app_secret)
|
||
open_id = _get_open_id_by_mobile(token, mobile)
|
||
if not open_id:
|
||
return False, f'未找到手机号 {mobile} 对应的飞书用户'
|
||
|
||
card = {
|
||
'config': {'wide_screen_mode': True},
|
||
'header': {
|
||
'title': {'tag': 'plain_text', 'content': 'AirGate 告警测试'},
|
||
'template': 'blue',
|
||
},
|
||
'elements': [
|
||
{
|
||
'tag': 'div',
|
||
'text': {
|
||
'tag': 'lark_md',
|
||
'content': '这是一条测试消息,说明飞书告警通道配置正常。',
|
||
},
|
||
},
|
||
],
|
||
}
|
||
|
||
resp = requests.post(
|
||
'https://open.feishu.cn/open-apis/im/v1/messages',
|
||
headers={'Authorization': f'Bearer {token}'},
|
||
params={'receive_id_type': 'open_id'},
|
||
json={
|
||
'receive_id': open_id,
|
||
'msg_type': 'interactive',
|
||
'content': json.dumps(card, ensure_ascii=False),
|
||
},
|
||
timeout=5,
|
||
)
|
||
data = resp.json()
|
||
if data.get('code') != 0:
|
||
return False, f'发送失败: {data.get("msg", "")}'
|
||
return True, '测试消息已发送'
|
||
except Exception as e:
|
||
return False, str(e)
|