18f415b363
- 新增 WECHAT_API_BASE_URL 配置项,支持企微 API 代理转发 - _send_message 增加 API 错误日志(errcode + errmsg) - _get_access_token 增加失败日志 - handle_text 不再为空,返回菜单引导提示 - 防重入触发时通知用户等待时间 - menu.py API 调用统一使用配置化的 base URL Co-Authored-By: Claude <noreply@anthropic.com>
388 lines
16 KiB
Python
388 lines
16 KiB
Python
import asyncio
|
|
import logging
|
|
import time
|
|
import xml.etree.ElementTree as ET
|
|
|
|
from app.config import settings
|
|
from app.wechat.crypto import WXBizMsgCrypt
|
|
from app.wechat.client import WeChatClient
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 防重入:记录最近一次触发爬取的时间戳,60秒内不重复执行
|
|
_last_crawl_time: float = 0.0
|
|
_crawl_lock = asyncio.Lock()
|
|
|
|
# 防重入:最新公告爬取,60秒内不重复执行
|
|
_last_latest_time: float = 0.0
|
|
_latest_lock = asyncio.Lock()
|
|
|
|
|
|
class WeChatMessageHandler:
|
|
def __init__(self):
|
|
self.wxcpt = WXBizMsgCrypt(
|
|
sToken=settings.wechat_token,
|
|
sEncodingAESKey=settings.wechat_encoding_aes_key,
|
|
sReceiveId=settings.wechat_corp_id,
|
|
)
|
|
self.client = WeChatClient()
|
|
|
|
def verify_url(
|
|
self, msg_signature: str, timestamp: str, nonce: str, echostr: str
|
|
) -> str | None:
|
|
ret, sEchoStr = self.wxcpt.VerifyURL(
|
|
msg_signature, timestamp, nonce, echostr
|
|
)
|
|
if ret == 0:
|
|
return (
|
|
sEchoStr.decode("utf-8")
|
|
if isinstance(sEchoStr, bytes)
|
|
else sEchoStr
|
|
)
|
|
return None
|
|
|
|
def decrypt_message(
|
|
self,
|
|
post_data: str,
|
|
msg_signature: str,
|
|
timestamp: str,
|
|
nonce: str,
|
|
) -> ET.Element | None:
|
|
ret, xml_content = self.wxcpt.DecryptMsg(
|
|
post_data, msg_signature, timestamp, nonce
|
|
)
|
|
if ret != 0:
|
|
return None
|
|
return ET.fromstring(xml_content)
|
|
|
|
def encrypt_response(
|
|
self, response_xml: str, nonce: str, timestamp: str
|
|
) -> str | None:
|
|
ret, encrypted = self.wxcpt.EncryptMsg(response_xml, nonce, timestamp)
|
|
if ret == 0:
|
|
return encrypted
|
|
return None
|
|
|
|
async def handle_event(
|
|
self, event: str, event_key: str | None, from_user: str
|
|
) -> str | None:
|
|
if event != "click" or not event_key:
|
|
return None
|
|
|
|
if event_key == "today_stats":
|
|
return await self._handle_today_stats(from_user)
|
|
elif event_key == "trigger_crawl":
|
|
return await self._handle_trigger_crawl(from_user)
|
|
elif event_key == "sync_holidays":
|
|
return await self._handle_sync_holidays(from_user)
|
|
elif event_key == "monitor_config":
|
|
return await self._handle_monitor_config(from_user)
|
|
elif event_key == "system_status":
|
|
return await self._handle_system_status(from_user)
|
|
elif event_key == "workday_status":
|
|
return await self._handle_workday_status(from_user)
|
|
elif event_key == "latest_announcements":
|
|
return await self._handle_latest_announcements(from_user)
|
|
elif event_key == "pause_scheduler":
|
|
return await self._handle_pause_scheduler(from_user)
|
|
elif event_key == "resume_scheduler":
|
|
return await self._handle_resume_scheduler(from_user)
|
|
elif event_key == "toggle_ai":
|
|
return await self._handle_toggle_ai(from_user)
|
|
return None
|
|
|
|
async def handle_text(self, content: str, from_user: str) -> str | None:
|
|
"""文本消息:返回帮助提示"""
|
|
help_text = (
|
|
"请使用菜单操作:\n"
|
|
"---\n"
|
|
"📋 最新公告 - 获取最新公告\n"
|
|
"📊 查询 → 监控配置/系统状态/今日工作日\n"
|
|
"⚙️ 系统管理 → 立即爬取/同步节假日/AI 分析"
|
|
)
|
|
ok = await self.client.send_text(help_text, from_user)
|
|
if not ok:
|
|
logger.warning(f"发送帮助消息失败, touser={from_user}")
|
|
return None
|
|
|
|
async def _handle_today_stats(self, from_user: str) -> str | None:
|
|
from app.api.deps import get_db
|
|
|
|
try:
|
|
async for db in get_db():
|
|
from sqlalchemy import func, select
|
|
from app.models.announcement import Announcement
|
|
|
|
total_result = await db.execute(
|
|
select(func.count()).select_from(Announcement)
|
|
)
|
|
total = total_result.scalar() or 0
|
|
|
|
today_result = await db.execute(
|
|
select(func.count()).where(
|
|
func.date(Announcement.publish_date) == func.current_date()
|
|
).select_from(Announcement)
|
|
)
|
|
today = today_result.scalar() or 0
|
|
|
|
text = f"今日新增: {today} 条\n累计公告: {total} 条"
|
|
await self.client.send_text(text, from_user)
|
|
except Exception as e:
|
|
logger.error(f"查询统计失败: {e}")
|
|
await self.client.send_text("查询失败,请稍后再试", from_user)
|
|
|
|
async def _handle_trigger_crawl(self, from_user: str) -> str | None:
|
|
global _last_crawl_time
|
|
from app.api.deps import get_crawl_service
|
|
|
|
# 防重入:企业微信会对同一事件重试多次,60秒内只执行一次
|
|
async with _crawl_lock:
|
|
now = time.monotonic()
|
|
if now - _last_crawl_time < 60:
|
|
remaining = int(60 - (now - _last_crawl_time))
|
|
logger.info(f"爬取请求被忽略(防重入),距上次 {now - _last_crawl_time:.1f}s")
|
|
await self.client.send_text(
|
|
f"爬取任务进行中,请 {remaining} 秒后再试", from_user
|
|
)
|
|
return None
|
|
_last_crawl_time = now
|
|
|
|
await self.client.send_text("开始爬取,请稍候...", from_user)
|
|
|
|
try:
|
|
service = get_crawl_service()
|
|
results = await service.run_all()
|
|
total = sum(r.total_count for r in results)
|
|
stored = sum(
|
|
r.pipeline_result.stored for r in results
|
|
if r.pipeline_result
|
|
)
|
|
notified = sum(
|
|
r.pipeline_result.notified for r in results
|
|
if r.pipeline_result
|
|
)
|
|
errors = [r.error_message for r in results if not r.success]
|
|
msg = f"爬取完成\n抓取: {total} 条\n新增: {stored} 条\n推送: {notified} 条"
|
|
if errors:
|
|
msg += f"\n异常: {errors[0][:50]}"
|
|
await self.client.send_text(msg, from_user)
|
|
except Exception as e:
|
|
logger.error(f"手动爬取失败: {e}")
|
|
await self.client.send_text(f"爬取失败: {e}", from_user)
|
|
|
|
async def _handle_sync_holidays(self, from_user: str) -> str | None:
|
|
from app.services.holiday_service import now_in_china, sync_holidays
|
|
from app.api.deps import get_db
|
|
|
|
try:
|
|
async for db in get_db():
|
|
year = now_in_china().year
|
|
count = await sync_holidays(db, year)
|
|
await self.client.send_text(
|
|
f"已同步 {year} 年节假日\n共 {count} 条记录", from_user,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"同步节假日失败: {e}")
|
|
await self.client.send_text(f"同步失败: {e}", from_user)
|
|
|
|
async def _handle_monitor_config(self, from_user: str) -> str | None:
|
|
import json
|
|
from app.services.ai_state import get_ai_status_text
|
|
try:
|
|
keywords = settings.crawler_keywords
|
|
sources = json.loads(settings.announcement_sources)
|
|
source_names = "、".join(v["name"] for v in sources.values())
|
|
text = (
|
|
f"📋 监控配置\n"
|
|
f"---\n"
|
|
f"监控关键词: {', '.join(keywords)}\n"
|
|
f"爬取页数: {settings.crawler_max_pages} 页\n"
|
|
f"定时规则: {settings.scheduler_cron}\n"
|
|
f"公告来源: {source_names}\n"
|
|
f"---\n"
|
|
f"🤖 AI 分析: {get_ai_status_text()}\n"
|
|
f"AI 模型: {settings.ai_model}\n"
|
|
f"重点标记: {settings.ai_analysis_title}"
|
|
)
|
|
await self.client.send_text(text, from_user)
|
|
except Exception as e:
|
|
logger.error(f"查询监控配置失败: {e}")
|
|
await self.client.send_text("查询失败,请稍后再试", from_user)
|
|
|
|
async def _handle_system_status(self, from_user: str) -> str | None:
|
|
from app.api.deps import get_db
|
|
from sqlalchemy import func, select
|
|
from app.models.announcement import Announcement
|
|
from app.services.ai_state import get_ai_status_text
|
|
|
|
try:
|
|
async for db in get_db():
|
|
total_result = await db.execute(
|
|
select(func.count()).select_from(Announcement)
|
|
)
|
|
total = total_result.scalar() or 0
|
|
|
|
today_result = await db.execute(
|
|
select(func.count()).where(
|
|
func.date(Announcement.publish_date) == func.current_date()
|
|
).select_from(Announcement)
|
|
)
|
|
today = today_result.scalar() or 0
|
|
|
|
unsent_result = await db.execute(
|
|
select(func.count()).where(
|
|
Announcement.is_sent == False, # noqa: E712
|
|
Announcement.keyword_matched == True, # noqa: E712
|
|
).select_from(Announcement)
|
|
)
|
|
unsent = unsent_result.scalar() or 0
|
|
|
|
# AI 标记统计
|
|
ai_relevant_result = await db.execute(
|
|
select(func.count()).where(
|
|
Announcement.ai_relevant == True, # noqa: E712
|
|
).select_from(Announcement)
|
|
)
|
|
ai_relevant = ai_relevant_result.scalar() or 0
|
|
|
|
scheduler_status = "已启用" if settings.scheduler_enabled else "已禁用"
|
|
text = (
|
|
f"📊 系统状态\n"
|
|
f"---\n"
|
|
f"累计公告: {total} 条\n"
|
|
f"今日新增: {today} 条\n"
|
|
f"待推送: {unsent} 条\n"
|
|
f"---\n"
|
|
f"定时任务: {scheduler_status}\n"
|
|
f"定时规则: {settings.scheduler_cron}\n"
|
|
f"---\n"
|
|
f"🤖 AI 分析: {get_ai_status_text()}\n"
|
|
f"AI 标记项目: {ai_relevant} 条"
|
|
)
|
|
await self.client.send_text(text, from_user)
|
|
except Exception as e:
|
|
logger.error(f"查询系统状态失败: {e}")
|
|
await self.client.send_text("查询失败,请稍后再试", from_user)
|
|
|
|
async def _handle_workday_status(self, from_user: str) -> str | None:
|
|
from app.services.holiday_service import now_in_china, is_workday
|
|
from app.api.deps import get_db
|
|
|
|
try:
|
|
async for db in get_db():
|
|
today = now_in_china()
|
|
workday = await is_workday(db, today)
|
|
status = "工作日,正常爬取" if workday else "非工作日,跳过爬取"
|
|
text = f"今天 {today.strftime('%Y-%m-%d %A')}\n{status}"
|
|
await self.client.send_text(text, from_user)
|
|
except Exception as e:
|
|
logger.error(f"查询工作日状态失败: {e}")
|
|
await self.client.send_text("查询失败,请稍后再试", from_user)
|
|
|
|
async def _handle_latest_announcements(self, from_user: str) -> str | None:
|
|
global _last_latest_time
|
|
from app.api.deps import get_db, get_crawl_service
|
|
from sqlalchemy import select, asc, or_
|
|
from app.models.announcement import Announcement
|
|
|
|
# 防重入:60秒内整个函数只执行一次(含推送)
|
|
async with _latest_lock:
|
|
now = time.monotonic()
|
|
if (now - _last_latest_time) < 60:
|
|
logger.info("最新公告请求被忽略(防重入)")
|
|
return None
|
|
_last_latest_time = now
|
|
|
|
try:
|
|
await self.client.send_text("正在获取最新公告,请稍候...", from_user)
|
|
service = get_crawl_service()
|
|
await service.run_all()
|
|
|
|
# 查:广西政采网关键词匹配 + 大化县政府网全部,取最新6条后按时间升序推送
|
|
async for db in get_db():
|
|
from sqlalchemy import select, desc, or_
|
|
result = await db.execute(
|
|
select(Announcement)
|
|
.where(or_(
|
|
Announcement.keyword_matched == True, # noqa: E712
|
|
Announcement.source_code == "dahuagov",
|
|
))
|
|
.order_by(desc(Announcement.publish_date))
|
|
.limit(6)
|
|
)
|
|
items = result.scalars().all()
|
|
|
|
if not items:
|
|
await self.client.send_text("暂无公告", from_user)
|
|
return None
|
|
|
|
# 按时间升序推送,企微里向上滑即为时间正序
|
|
for ann in sorted(items, key=lambda a: a.publish_date):
|
|
title = ann.title if len(ann.title) <= 128 else ann.title[:125] + "..."
|
|
purchase_name = ann.purchase_name or ""
|
|
if len(purchase_name) > 25:
|
|
purchase_name = purchase_name[:22] + "..."
|
|
time_str = ann.publish_date.strftime("%Y-%m-%d %H:%M") if ann.publish_date else "时间未知"
|
|
source_name = ann.source_name or ann.source_code or ""
|
|
description = f"{source_name} | {purchase_name} | {time_str}"
|
|
await self.client.send_textcard(title, description, ann.content_url or "", from_user)
|
|
await asyncio.sleep(1)
|
|
except Exception as e:
|
|
logger.error(f"查询最新公告失败: {e}")
|
|
await self.client.send_text("查询失败,请稍后再试", from_user)
|
|
|
|
async def _handle_pause_scheduler(self, from_user: str) -> str | None:
|
|
try:
|
|
from app.scheduler.jobs import scheduler
|
|
if scheduler.running:
|
|
scheduler.pause()
|
|
await self.client.send_text("定时任务已暂停", from_user)
|
|
else:
|
|
await self.client.send_text("定时任务未在运行", from_user)
|
|
except Exception as e:
|
|
logger.error(f"暂停定时任务失败: {e}")
|
|
await self.client.send_text(f"操作失败: {e}", from_user)
|
|
|
|
async def _handle_toggle_ai(self, from_user: str) -> str | None:
|
|
from app.services.ai_state import (
|
|
get_ai_status_text,
|
|
get_whitelist,
|
|
is_ai_enabled,
|
|
is_whitelisted,
|
|
set_ai_enabled,
|
|
)
|
|
|
|
# 白名单校验
|
|
whitelist = get_whitelist()
|
|
if whitelist and not is_whitelisted(from_user):
|
|
await self.client.send_text(
|
|
f"⚠️ 你没有权限操作 AI 分析开关\n"
|
|
f"当前仅以下用户可操作:\n{', '.join(whitelist)}",
|
|
from_user,
|
|
)
|
|
return None
|
|
|
|
current = is_ai_enabled()
|
|
changed = set_ai_enabled(not current)
|
|
if not changed:
|
|
await self.client.send_text(
|
|
f"AI 分析当前已是「{get_ai_status_text()}」状态,无需切换",
|
|
from_user,
|
|
)
|
|
return None
|
|
|
|
await self.client.send_text(
|
|
f"✅ AI 分析已切换为「{get_ai_status_text()}」\n"
|
|
f"下次爬取触发时生效",
|
|
from_user,
|
|
)
|
|
|
|
async def _handle_resume_scheduler(self, from_user: str) -> str | None:
|
|
try:
|
|
from app.scheduler.jobs import scheduler
|
|
scheduler.resume()
|
|
await self.client.send_text("定时任务已恢复", from_user)
|
|
except Exception as e:
|
|
logger.error(f"恢复定时任务失败: {e}")
|
|
await self.client.send_text(f"操作失败: {e}", from_user)
|