From: codebuddy Date: Wed, 2 Sep 2026 23:25:14 +0000 (+0800) Subject: 家人看护推送系统:完整项目初始提交 X-Git-Url: http://acesimba.cloud/gitweb/?a=commitdiff_plain;h=HEAD;p=family-chat.git 家人看护推送系统:完整项目初始提交 - 基础:FastAPI + APScheduler + 企业微信自建应用,SQLite 存储 - 模板管理:多步消息编辑器(文字/图片),发送时机(延时/等回复), 未回复超时兜底,图片上传与 media_id 缓存 - 规则管理:每日/每周/每月三种周期,成员多选,起止日期自动失效 - 会话推进引擎:回复即时推进、超时兜底、并发竞态防护 - 每日记录:模板回复矩阵(行=步骤,列=执行),step_replies 全量记录 - 安全:手动推 60s 冷却 + 防抖确认;.env 与 data/ 不入库 - UI:清新风格(白底灰标题、淡绿/淡蓝/灰系),手机/iPad 适配 --- d4934d0812ac958a52dc98469e1ded429584681b diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c3dc396 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# ===== 企业微信(R1 Spike 必填) ===== +WECOM_CORP_ID=ww你的企业ID +WECOM_SECRET=应用Secret +WECOM_AGENT_ID=1000002 +WECOM_TOKEN=回调Token +WECOM_AES_KEY=回调EncodingAESKey(43字符) +WECOM_RECEIVE_ID=ww你的企业ID # 企业微信此处 = CorpID +WECOM_TEST_USER=mom # spike 发送/接收测试目标(通讯录账号) + +# ===== 腾讯云 ASR(R3 实测必填) ===== +TENCENT_SECRET_ID= +TENCENT_SECRET_KEY= + +# ===== spike 服务 ===== +SPIKE_PORT=1063 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f37f639 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Python +__pycache__/ +*.pyc +*.pyo + +# 虚拟环境 +.venv/ +venv/ + +# 密钥 / 本地配置(含企业微信与腾讯云凭据,禁止入库) +.env + +# 运行时产物 +*.log + +# 运行数据(家人隐私:数据库/备份/语音/图片,禁止入库) +data/ + +# 测试产物 +/tmp/ diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..ef8778d --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,31 @@ +# HANDOFF.md — 进度交接 + +> 按 SPEC §17 更新。最后更新:2026-09-01 + +## 当前状态:v1 初版已实现并跑通完整闭环 ✅ + +### 已实现(对照 SPEC) +- [x] P0 推送引擎:10 张表(SQLAlchemy/WAL)+ 种子;模板渲染;素材轮换;规则引擎(APScheduler 按星期 cron、幂等、静默顺延、misfire 15min);`config/config.yaml` 三段配置 +- [x] P1 反馈闭环:等待态(4h 超时)+ 归档;ASR 双引擎兜底(16k_zh_dialect / 16k_zh_en)+ ffmpeg 8k→16k 重采样 + 热词表;关键词先跑再 AI 打标(OpenAI 兼容);通知中心(WeCom 实现 + 路由 + 幂等账本);降噪(同级 30min 冷却、L2 单日上限 2) +- [x] P2 收尾:周报(周日自动 + 手动);素材体检(周日 HEAD + 存活不足告警);短信通道适配器(默认关闭,需报备);后台鉴权(密码登录 `220526` + HttpOnly 会话 cookie,URL 不带 token) +- [x] 企业微信回调:加解密回环已验证;推送/回调解密/反馈归档/分级/告警 全链路实测通过 +- [x] 手机优先 Web 后台:今日状态 / 看反馈 / 暂停恢复 / 手动推 / 编辑,已通过 nginx `/chatbot/` 验证 +- [x] 部署:systemd 常驻 + nginx 反代(1064,未动现有服务与端口) + +### 验证记录 +- 推送三段消息到老人微信实测成功(push_id 记录) +- 模拟加密回调 → 解密 → 反馈入库(膝盖疼→L2 身体不适)→ 等待态清除,实测通过 +- WeCom GET 验证握手(echostr 回显明文)经 nginx 实测通过 +- 分级矩阵:摔了一跤→L3、累→L1、膝盖疼→L2,单测通过 + +### 关键文件 +- `app/`:`api.py`(回调+API+后台路由) `scheduler.py`(规则引擎+推送) `feedback.py`(等待态) `asr.py` `ai.py` `grading.py` `notify.py` `materials.py` `templates_render.py` `weekly_report.py` `wecom.py`(加解密+客户端) `db.py`(10表) `seed.py` `web.py`(手机UI) `config.py` `server.py` +- `config/config.yaml`:成员/模板/规则/素材/热词/通知(改这里最常见) +- `deploy/`:`family-chat.service` `nginx-wechatbot.conf` `DEPLOY.md` +- `.env`:密钥(不提交) + +### 注意事项(下次接手必读) +- 路径大小写:真实项目目录是 `/home/chatbot`(小写)。`/home/Chatbot` 已设为指向它的软链接,写入任一路径都落到同一处。 +- 端口 1064;1063 是「学习山峰」不可碰。 +- 红线:不碰微信个人号 Hook、不骗老人(被问机器人如实说)、半夜不补发推送。 +- 待办:短信报备后开 `SMS_ENABLED`;生产接企微 OAuth2 替换密码登录;方言阈值用真人样音坐实。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..78ce7d1 --- /dev/null +++ b/README.md @@ -0,0 +1,151 @@ +# family-chat 家人看护推送系统(v1) + +给家里老人做的**微信看护推送系统**:按星期推送主题内容(锻炼 / 防诈骗 / 陪聊), +老人用**文字或语音**回复,系统回收反馈、判断情绪与身体状况,异常分级通知子女,每周出周报。 + +- 通道:企业微信自建应用 + 微信插件(老人在自己微信里收发,不装新 App) +- 后端:FastAPI + SQLite(WAL) + APScheduler +- 语音:腾讯云一句话识别(双引擎兜底 + 热词) +- AI:OpenAI 兼容接口(模型走环境变量) + +--- + +## 一、怎么用(手机后台) + +后台地址(手机浏览器打开): + +``` +https://acesimba.cloud/chatbot/ +``` + +打开后会进入**登录页**,输入密码即可(默认 `220526`)。密码在 `.env` 用 `ADMIN_PASSWORD` 设置,**上线前务必改掉**。 +登录后服务端下发 `HttpOnly` 会话 cookie,后续页面与接口凭 cookie 鉴权,**URL 里不再带 token**。 + +四个页面: +- **今日状态**:今天推了没、老人回了没、有无未处理告警 +- **看反馈**:按人按日期看原文与风险标记 +- **暂停/恢复**:某人生病/外出,一键停推送 +- **手动推**:调试或临时补发一条 + +底部 **编辑** 页可看规则、素材,并触发「素材体检」「生成周报」。 + +> 本地调试也可直接访问 `http://服务器IP:1064/chatbot/`,同样走密码登录(需防火墙放行 1064)。 + +--- + +## 二、怎么改(最常改的都在 `config/config.yaml`) + +`config.yaml` 不含任何密钥(密钥只在 `.env`),可以提交 git。改完**重启服务**生效: + +```bash +systemctl restart family-chat +``` + +### 1. 成员:谁是老人、谁是子女(收告警) +改 `config.yaml` 的 `family.members`: +```yaml +family: + members: + - wecom_userid: WangYu # 必须与企业微信通讯录里的「账号」完全一致 + name: 王瑜 + role: member # member=收推送的老人;admin=收告警的子女 + quiet_hours: "21:00-08:00" + - wecom_userid: yaya + name: 牙牙 + role: admin +``` +改了成员后,需重置数据库重新播种(会清空已有数据): +```bash +systemctl stop family-chat +rm -f data/family.db data/family.db-wal data/family.db-shm +systemctl start family-chat +``` + +### 2. 推送时间 / 星期 +改 `config.yaml` 的 `seed.rules`: +```yaml +seed: + rules: + - {days: "1,3,5", at: "09:00", template: exercise} # 周一三五 09:00 + - {days: "2,4,6", at: "09:00", template: anti_fraud} + - {days: "7", at: "10:00", template: casual} # 周日 +``` +`days`:`"1,3,5"` 周一三五;`"*"` 每天;`7` 周日。`at` 为 `HH:MM`。 +> 改 rules 后服务会自动热重载,无需重启(APScheduler 重新注册)。 + +### 3. 推送内容(模板) +改 `config.yaml` 的 `seed.templates`。一个模板按 `seq` 顺序播放,步骤类型: +- `text`:直接发一段话 +- `material`:按 `tag` 自动选一个视频链接(不写死链接,方便轮换) +- `question`:发一句提问,老人下一条回复会**归档为这条问题的反馈**(一个模板最多一个 question) +```yaml +- id: exercise + name: 锻炼身体 + theme: 锻炼 + steps: + - {seq: 1, type: text, content: "妈,今天天气还行,活动活动筋骨没?"} + - {seq: 2, type: material, tag: 锻炼} + - {seq: 3, type: question, content: "跟着做了没?哪儿觉得累就跟我说。", question_key: did_exercise, delay_sec: 20} +``` +`delay_sec` 是每条之间的间隔(模拟真人节奏,别连发)。 + +### 4. 素材(视频链接) +改 `config.yaml` 的 `seed.materials`,或直接在后台**编辑**页看,未来也可在后台增删(API 已支持): +```yaml +materials: + - {tag: 锻炼, title: "八段锦完整跟练", url: "https://...", platform: B站, source: official} +``` +`tag` 要和模板 `material` 步骤的 `tag` 对应;同 tag 多个时自动轮换(抽查过的优先、最久没用的优先)。 + +### 5. 语音识别热词 +改 `config.yaml` 的 `asr.hotwords`(家人名字、病名等,权重 1-11,最多 128 个)。 +改完**下次识别自动生效**,无需重启: +```yaml +asr: + hotwords: + - "欢欢|11" + - "膝盖|9" + - "降压药|9" +``` + +### 6. 接 AI 打标(情绪/身体判断) +在 `.env` 增加: +``` +AI_API_KEY=sk-xxx +AI_BASE_URL=https://你的兼容端点/v1 +AI_MODEL=gpt-4o-mini +``` +不设也能跑:关键词先判(摔倒/胸痛→L3 等),AI 失败自动降级到关键词,不影响主流程。 + +### 7. 短信告警(L3 第二通道) +默认**关闭**(短信需报备签名+模板,1-3 天)。要开:在 `.env` 设 `SMS_ENABLED=1` å¹¶å¡« `SMS_SECRET_ID/KEY/APP_ID/SIGN/TEMPLATE_ID`,重启。 + +--- + +## 三、怎么看风险分级 + +| 级别 | 触发 | 通知 | +|---|---|---| +| L0 | 正常 | 无 | +| L1 | 轻度负面、模糊不适、答非所问 | 仅进周报 | +| L2 | 明确身体不适、连续负面 | 企微推子女 | +| L3 | 摔倒/胸痛/喘不上气/轻生等关键词,或 AI 判高危 | 企微 + 短信 | + +防告警疲劳:同人同级 30 分钟内只发一次;L2 单日最多 2 条(超出进周报)。 +**建议上线前两周只记录不通知**,用真实数据调阈值(代码已支持,调 `config.yaml` 的 `notify` 段)。 + +--- + +## 四、常见操作 + +```bash +systemctl status family-chat # 看状态 +systemctl restart family-chat # 改配置后重启 +journalctl -u family-chat -f # 看实时日志 +``` + +## 五、已知限制 / 待办(按 SPEC) +- 短信通道未报备,默认关闭(需签名模板)。 +- 后台鉴权为**密码登录 + 会话 cookie**(默认密码 `220526`,`.env` 的 `ADMIN_PASSWORD` 可改;`ADMIN_SECRET` 可固定会话密钥)。生产如需更强鉴权可接企业微信 OAuth2(SPEC §11.2)。 +- 方言样本(关中话/低质量音频)的置信度阈值需真人样音坐实后微调(代码已做可选兼容)。 +- 连续未回复升级(连续 2/3 天)目前以「当天未回→L1」实现,跨天连续逻辑待后续增强。 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..c206d0e --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,2 @@ +"""family-chat 应用包""" +__version__ = "v1.0" diff --git a/app/__main__.py b/app/__main__.py new file mode 100644 index 0000000..f5f6e40 --- /dev/null +++ b/app/__main__.py @@ -0,0 +1,4 @@ +from .server import main + +if __name__ == "__main__": + main() diff --git a/app/ai.py b/app/ai.py new file mode 100644 index 0000000..2fbd873 --- /dev/null +++ b/app/ai.py @@ -0,0 +1,118 @@ +""" +app/ai.py — 小模型风险打标(OpenAI 兼容接口,urllib 直连,无 SDK 依赖) + 要求模型返回严格 JSON:{"mood":"正面|中性|负面|低落","physical":"正常|不适|异常","risk":0-3,"reason":"<=20字"} + 失败返回 None,由 grading 降级到关键词兜底。 +""" +import json +import urllib.request +import urllib.error +import logging + +logger = logging.getLogger("ai") + +_VALID_MOOD = {"正面", "中性", "负面", "低落"} +_VALID_PHYS = {"正常", "不适", "异常"} + + +def tag(text: str) -> dict | None: + """ + 对一段老人回复打标。成功返回 dict,失败/非法返回 None。 + """ + from .config import get_config + cfg = get_config() + if not cfg.ai_cfg.get("enabled") or not cfg.ai_api_key: + logger.info("[ai] 未启用或无密钥,跳过 AI 打标") + return None + sys_prompt = cfg.ai_cfg.get("system_prompt") or ( + "你是帮子女照看独居父母的助手。下面是一段父母(老人)的回复文字。" + "请判断情绪与身体状况,并给出风险等级。只输出一个 JSON,不要任何解释。" + '格式:{"mood":"正面|中性|负面|低落","physical":"正常|不适|异常","risk":0-3,"reason":"不超过20字"}' + "risk:0正常 1关注 2注意 3紧急(紧急含摔倒/胸痛/喘不上气/轻生倾向等)。" + ) + body = { + "model": cfg.ai_model, + "messages": [ + {"role": "system", "content": sys_prompt}, + {"role": "user", "content": text}, + ], + "temperature": 0, + "response_format": {"type": "json_object"}, + } + url = cfg.ai_base_url.rstrip("/") + "/chat/completions" + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request(url, data=data, headers={ + "Content-Type": "application/json", + "Authorization": "Bearer %s" % cfg.ai_api_key, + }) + try: + with urllib.request.urlopen(req, timeout=cfg.ai_cfg.get("timeout_sec", 15)) as resp: + result = json.loads(resp.read().decode("utf-8")) + content = result["choices"][0]["message"]["content"] + return _parse(content) + except urllib.error.HTTPError as e: + logger.warning("[ai] HTTP 错误 %s: %s", e.code, e.read().decode("utf-8")[:200]) + return None + except Exception as e: + logger.warning("[ai] 调用失败: %s", e) + return None + + +def reply_as_daughter(text: str) -> str | None: + """普通陪聊:以女儿口吻回一句(不入库为反馈)。失败返回 None。""" + from .config import get_config + cfg = get_config() + if not cfg.ai_api_key: + return None + sys_prompt = ("你是老人的女儿,在微信里陪独居父母聊天。用简短、口语化、关爱的语气回一句," + "不要解释、不要列要点,像真人发微信。不超过 30 字。") + body = { + "model": cfg.ai_model, + "messages": [ + {"role": "system", "content": sys_prompt}, + {"role": "user", "content": text}, + ], + "temperature": 0.7, + "max_tokens": 80, + } + url = cfg.ai_base_url.rstrip("/") + "/chat/completions" + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request(url, data=data, headers={ + "Content-Type": "application/json", + "Authorization": "Bearer %s" % cfg.ai_api_key, + }) + try: + with urllib.request.urlopen(req, timeout=cfg.ai_cfg.get("timeout_sec", 15)) as resp: + result = json.loads(resp.read().decode("utf-8")) + return result["choices"][0]["message"]["content"].strip() + except Exception as e: + logger.warning("[ai] 陪聊回复失败: %s", e) + return None + + +def _parse(content: str) -> dict | None: + try: + obj = json.loads(content) + except Exception: + # 容错:截取第一个 { } 之间的内容 + s = content.find("{") + e = content.rfind("}") + if s >= 0 and e > s: + try: + obj = json.loads(content[s:e + 1]) + except Exception: + return None + else: + return None + try: + mood = obj.get("mood") + physical = obj.get("physical") + risk = int(obj.get("risk", 0)) + reason = obj.get("reason", "")[:20] + if mood not in _VALID_MOOD: + mood = "中性" + if physical not in _VALID_PHYS: + physical = "正常" + risk = max(0, min(3, risk)) + return {"mood": mood, "physical": physical, "risk": risk, "reason": reason} + except Exception: + return None diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..7314868 --- /dev/null +++ b/app/api.py @@ -0,0 +1,672 @@ +""" +app/api.py — FastAPI 应用:企业微信回调 + 后台 API + 健康检查 +回调:GET 验签回显 echostr;POST 解密老人消息 → 文本/语音 → 反馈采集/陪聊 +后台:密码登录(/login)→ 设置 HttpOnly 会话 cookie → 页面与 API 凭 cookie 鉴权 +""" +import json +import logging +import os +import threading +import time +from datetime import datetime, timedelta + +from fastapi import FastAPI, Request, Query, HTTPException, Depends, Body +from fastapi.responses import (PlainTextResponse, HTMLResponse, JSONResponse, + RedirectResponse, FileResponse) + +from . import db as _db +from . import config as _cfg +from . import wecom, feedback as _fb, scheduler as _sch, weekly_report as _wr +from . import materials as _mat, notify as _notify +from .db import (Family, Member, Template, TemplateStep, Rule, Material, Push, + Feedback, Alert, Notification, WeeklyReport, get_sessionmaker, now_ts) + +logger = logging.getLogger("api") + +app = FastAPI(title="family-chat", version="v1.0") + +ADMIN_COOKIE = "fc_sid" + + +# --------------------------------------------------------------------------- +# 鉴权:密码登录 + 会话 cookie +# --------------------------------------------------------------------------- +def _authed(request: Request) -> bool: + return request.cookies.get(ADMIN_COOKIE) == _cfg.get_config().admin_secret + + +def _require(request: Request): + """API 鉴权依赖:未登录返回 401。""" + if not _authed(request): + raise HTTPException(status_code=401, detail="未登录") + + +LOGIN_HTML = """ + +登录 + +

家人看护后台

+
+
+
+
""" + + +@app.get("/login") +def login_page(request: Request): + if _authed(request): + return RedirectResponse("/chatbot/") + return HTMLResponse(LOGIN_HTML) + + +@app.post("/login") +async def login_post(request: Request): + try: + data = await request.json() + pw = (data or {}).get("password", "") + except Exception: + pw = "" + if pw == _cfg.get_config().admin_password: + resp = RedirectResponse("/chatbot/", status_code=302) + resp.set_cookie(ADMIN_COOKIE, _cfg.get_config().admin_secret, + httponly=True, path="/", max_age=60 * 60 * 24 * 30, samesite="lax") + return resp + return HTMLResponse(LOGIN_HTML) + + +# --------------------------------------------------------------------------- +# 企业微信回调 +# --------------------------------------------------------------------------- +@app.get("/wecom/callback") +def wecom_verify(msg_signature: str, timestamp: str, nonce: str, echostr: str): + crypt = wecom.make_crypt() + try: + if not crypt.verify_signature(msg_signature, timestamp, nonce, echostr): + return PlainTextResponse("error", status_code=400) + plain = crypt.decrypt(echostr) + except Exception as e: + logger.warning("[callback] 验签失败: %s", e) + return PlainTextResponse("error", status_code=400) + return PlainTextResponse(plain) + + +def _handle_message(plain_xml: str): + try: + d = wecom.parse_xml(plain_xml) + msg_type = d.get("MsgType", "") + from_user = d.get("FromUserName", "") + if msg_type == "event": + return + sm = get_sessionmaker() + sess = sm() + member = sess.query(Member).filter(Member.wecom_userid == from_user).first() + sess.close() + if not member: + logger.warning("[callback] 未知成员: %s", from_user) + return + if msg_type == "text": + _fb.collect(member, d.get("Content", ""), "text") + try: + _sch.on_member_reply(member.id, d.get("Content", ""), "text") + except Exception: + logger.exception("[callback] 会话推进失败") + elif msg_type == "voice": + media_id = d.get("MediaId", "") + cfg = _cfg.get_config() + amr_path = os.path.join(cfg.media_dir, "voice_%d.amr" % now_ts()) + client = wecom.make_client() + try: + client.download_media(media_id, amr_path) + vres = _fb.process_voice(member, amr_path, cfg.media_dir) + try: + _sch.on_member_reply(member.id, + vres.get("text") or "(语音未识别)", "voice") + except Exception: + logger.exception("[callback] 会话推进失败") + except Exception as e: + logger.exception("[callback] 语音处理失败: %s", e) + else: + logger.info("[callback] 忽略消息类型: %s", msg_type) + except Exception: + logger.exception("[callback] 处理异常") + + +@app.post("/wecom/callback") +async def wecom_callback(request: Request, + msg_signature: str = Query(None), + timestamp: str = Query(None), + nonce: str = Query(None)): + body = (await request.body()).decode("utf-8") + crypt = wecom.make_crypt() + try: + enc = wecom.parse_xml(body).get("Encrypt", "") + if not enc: + return PlainTextResponse("success") + if not crypt.verify_signature(msg_signature, timestamp, nonce, enc): + return PlainTextResponse("success", status_code=400) + plain = crypt.decrypt(enc) + except Exception as e: + logger.warning("[callback] 解密失败: %s", e) + return PlainTextResponse("success") + threading.Thread(target=_handle_message, args=(plain,), daemon=True).start() + return PlainTextResponse("success") + + +@app.get("/health") +def health(): + return {"status": "ok", "time": now_ts()} + + +# --------------------------------------------------------------------------- +# 仪表盘 +# --------------------------------------------------------------------------- +@app.get("/api/dashboard", dependencies=[Depends(_require)]) +def dashboard(): + today = datetime.now().strftime("%Y-%m-%d") + sm = get_sessionmaker() + sess = sm() + try: + pushes = sess.query(Push).filter(Push.push_date == today).all() + fbs = sess.query(Feedback).filter(Feedback.created_at >= now_ts() - 86400).all() + pending = sess.query(Alert).filter(Alert.resolved == 0, Alert.level >= 2).count() + members = [{"id": m.id, "name": m.name, "role": m.role, "enabled": m.enabled, + "wecom_userid": m.wecom_userid} for m in sess.query(Member).all()] + push_list = [{"member": (sess.get(Member, p.member_id).name if sess.get(Member, p.member_id) else p.member_id), + "template_id": p.template_id, "status": p.status, + "sent_at": p.sent_at} for p in pushes] + fb_list = [{"id": f.id, "member": (sess.get(Member, f.member_id).name if sess.get(Member, f.member_id) else f.member_id), + "answer": f.answer, "mood": f.mood, "physical": f.physical, + "risk": f.risk_level, "created_at": f.created_at, + "input_type": f.input_type} for f in fbs] + return {"today": today, "pushes": push_list, "feedbacks": fb_list, + "pending_alerts": pending, "members": members} + finally: + sess.close() + + +# --------------------------------------------------------------------------- +# 成员:暂停/恢复 +# --------------------------------------------------------------------------- +@app.post("/api/members/{member_id}/toggle", dependencies=[Depends(_require)]) +def toggle_member(member_id: int): + with _db.session_scope() as sess: + m = sess.get(Member, member_id) + if not m: + raise HTTPException(404, "成员不存在") + m.enabled = 0 if m.enabled else 1 + return {"id": m.id, "enabled": m.enabled} + + +# --------------------------------------------------------------------------- +# 模板 CRUD +# --------------------------------------------------------------------------- +@app.get("/api/templates", dependencies=[Depends(_require)]) +def list_templates(): + sm = get_sessionmaker(); sess = sm() + try: + out = [] + for t in sess.query(Template).all(): + steps = [{"seq": s.seq, "type": s.type, "content": s.content, "tag": s.tag, + "question_key": s.question_key, "delay_sec": s.delay_sec, + "trigger_type": s.trigger_type or "delay", + "reply_timeout_sec": s.reply_timeout_sec} + for s in sess.query(TemplateStep).filter( + TemplateStep.template_id == t.id).order_by(TemplateStep.seq)] + out.append({"id": t.id, "name": t.name, "theme": t.theme, + "enabled": t.enabled, "steps": steps}) + return out + finally: + sess.close() + + +@app.post("/api/templates", dependencies=[Depends(_require)]) +def create_template(payload: dict = Body(...)): + with _db.session_scope() as sess: + fam = sess.query(Family).first() + t = Template(family_id=fam.id if fam else 1, name=payload["name"], + theme=payload.get("theme"), enabled=1, + created_at=now_ts(), updated_at=now_ts()) + sess.add(t); sess.flush() + _save_steps(sess, t.id, payload.get("steps", [])) + return {"id": t.id} + + +@app.put("/api/templates/{tid}", dependencies=[Depends(_require)]) +def update_template(tid: int, payload: dict = Body(...)): + with _db.session_scope() as sess: + t = sess.get(Template, tid) + if not t: + raise HTTPException(404, "模板不存在") + t.name = payload.get("name", t.name) + t.theme = payload.get("theme", t.theme) + t.updated_at = now_ts() + sess.query(TemplateStep).filter(TemplateStep.template_id == tid).delete() + _save_steps(sess, tid, payload.get("steps", [])) + return {"id": tid} + + +@app.delete("/api/templates/{tid}", dependencies=[Depends(_require)]) +def delete_template(tid: int): + with _db.session_scope() as sess: + t = sess.get(Template, tid) + if not t: + raise HTTPException(404, "模板不存在") + sess.delete(t) + return {"ok": True} + + +def _save_steps(sess, template_id, steps): + for i, s in enumerate(steps): + sess.add(TemplateStep( + template_id=template_id, seq=s.get("seq", i + 1), type=s["type"], + content=s.get("content"), tag=s.get("tag"), + question_key=s.get("question_key"), delay_sec=s.get("delay_sec", 0), + trigger_type=s.get("trigger_type", "delay") or "delay", + reply_timeout_sec=s.get("reply_timeout_sec"))) + + +@app.post("/api/templates/{tid}/preview", dependencies=[Depends(_require)]) +def preview_template(tid: int, user: str = Query(None)): + cfg = _cfg.get_config() + target = user or cfg.wecom_test_user + fam = _first_family() + res = _sch.send_template_to_userid(target, tid, fam, deferrable=False) + return {"result": res} + + +# --------------------------------------------------------------------------- +# 图片上传 / 访问(模板编辑器用) +# --------------------------------------------------------------------------- +@app.post("/api/upload/image", dependencies=[Depends(_require)]) +async def upload_image(request: Request, name: str = Query("image.jpg")): + from . import images as _images + data = await request.body() + try: + fn = _images.save_image(name, data) + except ValueError as e: + raise HTTPException(400, str(e)) + return {"filename": fn} + + +@app.get("/api/media/images/{filename}", dependencies=[Depends(_require)]) +def get_image(filename: str): + from . import images as _images + try: + path = _images.image_path(filename) + except ValueError: + raise HTTPException(404, "图片不存在") + if not os.path.exists(path): + raise HTTPException(404, "图片不存在") + return FileResponse(path) + + +# --------------------------------------------------------------------------- +# 规则 CRUD +# --------------------------------------------------------------------------- +_RULE_FIELDS = ("name", "member_ids", "period_type", "days", "month_day", + "at_time", "start_date", "end_date", "template_id", + "priority", "enabled", "member_id") + + +@app.get("/api/rules", dependencies=[Depends(_require)]) +def list_rules(): + sm = get_sessionmaker(); sess = sm() + try: + out = [] + for r in sess.query(Rule).all(): + mids = _parse_ids(r.member_ids) + names = [] + for i in mids: + m = sess.get(Member, i) + if m: + names.append(m.name) + tpl = sess.get(Template, r.template_id) + out.append({"id": r.id, "name": r.name or ("规则%d" % r.id), + "member_ids": r.member_ids, "member_names": names, + "period_type": r.period_type or "weekly", + "days": r.days, "month_day": r.month_day, + "at_time": r.at_time, "start_date": r.start_date, + "end_date": r.end_date, "template_id": r.template_id, + "template_name": tpl.name if tpl else "#%s" % r.template_id, + "priority": r.priority, "enabled": r.enabled}) + return out + finally: + sess.close() + + +def _parse_ids(s): + if not s: + return [] + return [int(p) for p in str(s).split(",") if p.strip().isdigit()] + + +@app.post("/api/rules", dependencies=[Depends(_require)]) +def create_rule(payload: dict = Body(...)): + with _db.session_scope() as sess: + fam = sess.query(Family).first() + r = Rule(family_id=fam.id if fam else 1, + name=payload.get("name") or "未命名规则", + member_id=payload.get("member_id"), + member_ids=payload.get("member_ids"), + period_type=payload.get("period_type") or "weekly", + days=payload.get("days") or "*", + month_day=payload.get("month_day"), + at_time=payload["at_time"], + start_date=payload.get("start_date") or None, + end_date=payload.get("end_date") or None, + template_id=payload["template_id"], + priority=payload.get("priority", 0), + enabled=1) + sess.add(r); sess.flush(); rid = r.id + _sch.reload_rules() + return {"id": rid} + + +@app.put("/api/rules/{rid}", dependencies=[Depends(_require)]) +def update_rule(rid: int, payload: dict = Body(...)): + with _db.session_scope() as sess: + r = sess.get(Rule, rid) + if not r: + raise HTTPException(404, "规则不存在") + for k in _RULE_FIELDS: + if k in payload: + setattr(r, k, payload[k]) + if not (r.name or "").strip(): + r.name = "规则%d" % rid + _sch.reload_rules() + return {"id": rid} + + +@app.delete("/api/rules/{rid}", dependencies=[Depends(_require)]) +def delete_rule(rid: int): + with _db.session_scope() as sess: + r = sess.get(Rule, rid) + if not r: + raise HTTPException(404, "规则不存在") + sess.delete(r) + _sch.reload_rules() + return {"ok": True} + + +# --------------------------------------------------------------------------- +# 素材 CRUD +# --------------------------------------------------------------------------- +@app.get("/api/materials", dependencies=[Depends(_require)]) +def list_materials(tag: str = Query(None)): + sm = get_sessionmaker(); sess = sm() + try: + q = sess.query(Material) + if tag: + q = q.filter(Material.tag == tag) + out = [{"id": m.id, "tag": m.tag, "title": m.title, "url": m.url, + "platform": m.platform, "source": m.source, "checked": m.checked, + "alive": m.alive, "used_count": m.used_count, + "last_used_at": m.last_used_at} for m in q.all()] + return out + finally: + sess.close() + + +@app.post("/api/materials", dependencies=[Depends(_require)]) +def create_material(payload: dict = Body(...)): + with _db.session_scope() as sess: + fam = sess.query(Family).first() + m = Material(family_id=fam.id if fam else 1, tag=payload["tag"], + title=payload["title"], url=payload["url"], + platform=payload.get("platform"), source=payload.get("source"), + checked=0, alive=1, fail_count=0, used_count=0, created_at=now_ts()) + sess.add(m); sess.flush(); return {"id": m.id} + + +@app.put("/api/materials/{mid}", dependencies=[Depends(_require)]) +def update_material(mid: int, payload: dict = Body(...)): + with _db.session_scope() as sess: + m = sess.get(Material, mid) + if not m: + raise HTTPException(404, "素材不存在") + for k in ("tag", "title", "url", "platform", "source", "checked", "alive"): + if k in payload: + setattr(m, k, payload[k]) + return {"id": mid} + + +@app.delete("/api/materials/{mid}", dependencies=[Depends(_require)]) +def delete_material(mid: int): + with _db.session_scope() as sess: + m = sess.get(Material, mid) + if not m: + raise HTTPException(404, "素材不存在") + sess.delete(m) + return {"ok": True} + + +@app.post("/api/materials/import", dependencies=[Depends(_require)]) +def import_materials(payload: dict = Body(...)): + items = payload.get("items", []) + with _db.session_scope() as sess: + fam = sess.query(Family).first() + fid = fam.id if fam else 1 + cnt = 0 + for it in items: + sess.add(Material(family_id=fid, tag=it["tag"], title=it["title"], + url=it["url"], platform=it.get("platform"), + source=it.get("source"), checked=0, alive=1, + fail_count=0, used_count=0, created_at=now_ts())) + cnt += 1 + return {"imported": cnt} + + +@app.post("/api/materials/check", dependencies=[Depends(_require)]) +def check_materials(): + alerts = _mat.health_check() + return {"checked": True, "alerts": alerts} + + +# --------------------------------------------------------------------------- +# 反馈 / 告警 +# --------------------------------------------------------------------------- +@app.get("/api/feedbacks", dependencies=[Depends(_require)]) +def list_feedbacks(member: int = Query(None), risk: int = Query(None), + from_: str = Query(None), to: str = Query(None)): + sm = get_sessionmaker(); sess = sm() + try: + q = sess.query(Feedback) + if member: + q = q.filter(Feedback.member_id == member) + if risk is not None: + q = q.filter(Feedback.risk_level == risk) + if from_: + q = q.filter(Feedback.created_at >= int(datetime.strptime(from_, "%Y-%m-%d").timestamp())) + if to: + q = q.filter(Feedback.created_at < int((datetime.strptime(to, "%Y-%m-%d") + timedelta(days=1)).timestamp())) + fbs = q.order_by(Feedback.created_at.desc()).limit(200).all() + out = [] + for f in fbs: + m = sess.query(Member).get(f.member_id) + out.append({"id": f.id, "member": m.name if m else f.member_id, + "question": f.question, "answer": f.answer, + "input_type": f.input_type, "mood": f.mood, + "physical": f.physical, "risk": f.risk_level, + "reason": f.ai_reason, "created_at": f.created_at}) + return out + finally: + sess.close() + + +@app.get("/api/alerts", dependencies=[Depends(_require)]) +def list_alerts(resolved: int = Query(0)): + sm = get_sessionmaker(); sess = sm() + try: + q = sess.query(Alert).filter(Alert.resolved == resolved) + out = [{"id": a.id, "member_id": a.member_id, "level": a.level, + "reason": a.reason, "created_at": a.created_at, + "resolved": a.resolved} for a in q.order_by(Alert.created_at.desc()).limit(200)] + return out + finally: + sess.close() + + +@app.post("/api/alerts/{aid}/resolve", dependencies=[Depends(_require)]) +def resolve_alert(aid: int, payload: dict = Body(None)): + by = (payload or {}).get("by", "admin") + with _db.session_scope() as sess: + a = sess.get(Alert, aid) + if not a: + raise HTTPException(404, "告警不存在") + a.resolved = 1 + a.resolved_by = by + a.resolved_at = now_ts() + return {"id": aid, "resolved": 1} + + +# --------------------------------------------------------------------------- +# 模板回复矩阵(每日记录-模板回复总览) +# --------------------------------------------------------------------------- +@app.get("/api/matrix", dependencies=[Depends(_require)]) +def reply_matrix(template_id: int, member_id: int): + """行=模板步骤,列=每次执行,格=该步的回复(无记录显示未回复)。 + 旧 Feedback 数据按 push_id+问题内容回填;提问步附风险/情绪标签。""" + from .db import StepReply + sm = get_sessionmaker(); sess = sm() + try: + steps = (sess.query(TemplateStep) + .filter(TemplateStep.template_id == template_id) + .order_by(TemplateStep.seq.asc()).all()) + if not steps: + return {"steps": [], "executions": []} + pushes = (sess.query(Push) + .filter(Push.member_id == member_id, + Push.template_id == template_id, + Push.status == "sent") + .order_by(Push.sent_at.desc()).all()) # 最新执行放最前一列 + pids = [p.id for p in pushes] or [0] + srs = (sess.query(StepReply) + .filter(StepReply.member_id == member_id, + StepReply.template_id == template_id).all()) + fbs = (sess.query(Feedback) + .filter(Feedback.member_id == member_id, + Feedback.push_id.in_(pids)).all()) + content2seq = {s.content: s.seq for s in steps} + # 单元格: {(push_id, seq): [cell,...]} + cells = {} + for r in srs: + cells.setdefault((r.push_id, r.seq), []).append({ + "answer": r.answer, "input_type": r.input_type, + "risk": None, "mood": None, "created_at": r.created_at}) + # 旧 Feedback 回填 / 给 step_reply 附风险标签 + for f in fbs: + seq = content2seq.get(f.question) + if seq is None: + continue + key = (f.push_id, seq) + if key not in cells: + cells[key] = [{"answer": f.answer, "input_type": f.input_type, + "risk": f.risk_level, "mood": f.mood, + "created_at": f.created_at}] + else: + for c in cells[key]: + if c["risk"] is None and c["answer"] == f.answer: + c["risk"], c["mood"] = f.risk_level, f.mood + executions = [] + for p in pushes: + dt = datetime.fromtimestamp(p.sent_at) if p.sent_at else None + executions.append({ + "push_id": p.id, + "date": dt.strftime("%m-%d %H:%M") if dt else p.push_date, + "cells": {str(s.seq): cells.get((p.id, s.seq), []) + for s in steps}}) + return {"steps": [{"seq": s.seq, "type": s.type, "content": s.content or ""} + for s in steps], + "executions": executions} + finally: + sess.close() + + +# --------------------------------------------------------------------------- +# 手动推送 / 周报 / 短信回执 +# --------------------------------------------------------------------------- +PUSH_COOLDOWN_SEC = 60 # 手动推冷却:同成员同模板 60s 内只能发一次 + + +@app.post("/api/push/{member_id}", dependencies=[Depends(_require)]) +def manual_push(member_id: int, template_id: int = Query(None), rule_id: int = Query(None)): + # 解析模板(可能由 rule_id 带出) + if template_id is None and rule_id: + sm = get_sessionmaker(); sess = sm() + try: + r = sess.get(Rule, rule_id) + template_id = r.template_id if r else None + finally: + sess.close() + # 冷却检查(防止误连点骚扰老人) + if template_id is not None: + sm = get_sessionmaker(); sess = sm() + try: + last = sess.query(Push).filter( + Push.member_id == member_id, Push.template_id == template_id, + Push.sent_at > now_ts() - PUSH_COOLDOWN_SEC).first() + finally: + sess.close() + if last: + raise HTTPException(429, "该模板 %d 秒内已发过一次,请稍后再试" % PUSH_COOLDOWN_SEC) + res = _sch.manual_push(member_id, template_id, rule_id) + return res + + +@app.post("/api/report/weekly", dependencies=[Depends(_require)]) +def gen_weekly(): + content = _wr.generate_and_send() + return {"content": content} + + +@app.post("/notify/sms/receipt") +async def sms_receipt(request: Request): + body = (await request.body()).decode("utf-8", "ignore") + logger.info("[sms-receipt] %s", body[:500]) + return JSONResponse({"status": "ok"}) + + +# --------------------------------------------------------------------------- +# 后台页面(手机优先,cookie 鉴权) +# --------------------------------------------------------------------------- +from . import web + +def _page_guard(func): + """页面鉴权:未登录直接由路由函数返回跳转,确保一定重定向。 + (依赖里 return Response 在部分 FastAPI 版本不短路,故在 handler 内处理。)""" + def _wrapped(request: Request): + if not _authed(request): + return RedirectResponse("/login") + return func(request) + return _wrapped + +for _route in web.routes: + app.add_api_route(_route["path"], _page_guard(_route["func"]), methods=_route["methods"]) + + +# --------------------------------------------------------------------------- +# 辅助 +# --------------------------------------------------------------------------- +def _first_family(): + sm = get_sessionmaker(); sess = sm() + try: + f = sess.query(Family).first() + return f.id if f else 1 + finally: + sess.close() diff --git a/app/asr.py b/app/asr.py new file mode 100644 index 0000000..d7e8204 --- /dev/null +++ b/app/asr.py @@ -0,0 +1,162 @@ +""" +app/asr.py — 腾讯云一句话识别(双引擎兜底 + ffmpeg 重采样 + 热词) + - 主引擎 16k_zh_dialect(含关中话/西安话),低置信度/空结果时换 16k_zh_en 兜底 + - 微信语音 amr 8k → ffmpeg 重采样 16k 单声道 + - 热词:首次识别前把 config 里的热词表注册成腾讯云词表,拿到 VocabularyId 作 HotwordId + - 沿用 spike 验证过的 client.call 原始字典调用,绕过 SDK SentenceRecognition 序列化 bug +""" +import os +import json +import base64 +import subprocess +import logging + +logger = logging.getLogger("asr") + + +def _client(): + from tencentcloud.common import credential + from tencentcloud.common.profile.client_profile import ClientProfile + from tencentcloud.common.profile.http_profile import HttpProfile + from tencentcloud.asr.v20190614 import asr_client + from .config import get_config + cfg = get_config() + cred = credential.Credential(cfg.tencent_secret_id, cfg.tencent_secret_key) + hp = HttpProfile() + hp.reqTimeout = 15 + cp = ClientProfile() + cp.httpProfile = hp + return asr_client.AsrClient(cred, "ap-guangzhou", cp) + + +def resample_to_16k(src_path: str, dst_path: str) -> str: + """ffmpeg 重采样为 16k 单声道 wav(一句话识别只收 16k)。""" + cmd = ["ffmpeg", "-y", "-i", src_path, "-ar", "16000", "-ac", "1", "-f", "wav", dst_path] + subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return dst_path + + +def _call_once(client, engine: str, data_b64: str, data_len: int, hotword_id: str): + """调用一次 SentenceRecognition,返回 (text, confidence)。失败抛异常。""" + params = { + "EngSerViceType": engine, + "SourceType": 1, # 1 = 上传音频数据 + "VoiceFormat": "wav", + "SubServiceType": 2, # 2 = 一句话识别 + "ProjectId": 0, + "UsrAudioKey": "familychat", + "Data": data_b64, + "DataLen": data_len, + } + if hotword_id: + params["HotwordId"] = hotword_id + resp = client.call("SentenceRecognition", params) + raw = json.loads(resp.decode("utf-8")) + if "Response" not in raw: + raise RuntimeError("ASR 返回结构异常: %s" % str(raw)[:200]) + r = raw["Response"] + if "Error" in r: + raise RuntimeError("ASR 错误 %s: %s" % (r["Error"].get("Code"), r["Error"].get("Message"))) + text = r.get("Result", "") or "" + conf = r.get("Confidence") + # Confidence 在某些引擎/版本返回整数 0-100,也可能缺失;统一为 0-1 或 None + conf = (conf / 100.0) if isinstance(conf, (int, float)) else None + return text.strip(), conf + + +# ---- 热词表管理(腾讯云托管词表 → VocabularyId → HotwordId) ---- +_HOTWORD_CACHE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "data", "hotword_id.txt") + + +def ensure_hotword_id(force=False) -> str: + """把 config.asr.hotwords 注册为腾讯云词表,返回 VocabularyId(失败返回 '')。""" + from .config import get_config + cfg = get_config() + hotwords = cfg.asr_hotwords() + if not hotwords: + return "" + if (not force) and os.path.exists(_HOTWORD_CACHE): + try: + return open(_HOTWORD_CACHE, encoding="utf-8").read().strip() + except Exception: + pass + try: + client = _client() + words = [] + for item in hotwords: + if "|" in item: + w, weight = item.rsplit("|", 1) + try: + words.append({"Word": w.strip(), "Weight": int(weight)}) + except ValueError: + words.append({"Word": w.strip(), "Weight": 5}) + else: + words.append({"Word": item.strip(), "Weight": 5}) + if not words: + return "" + params = { + "Name": "family_chat_hotwords", + "WordWeights": words, + } + resp = client.call("CreateAsrVocab", params) + raw = json.loads(resp.decode("utf-8")) + vid = raw.get("Response", {}).get("VocabularyId", "") + if vid: + os.makedirs(os.path.dirname(_HOTWORD_CACHE), exist_ok=True) + with open(_HOTWORD_CACHE, "w", encoding="utf-8") as f: + f.write(vid) + logger.info("[asr] 热词表已注册 VocabularyId=%s", vid) + return vid + except Exception as e: + logger.warning("[asr] 热词表注册失败(将跳过热词): %s", e) + return "" + + +def transcribe_audio_file(wav_path: str) -> dict: + """ + 对本地 wav(应为 16k 单声道)做双引擎识别。 + 返回 {"text":..., "confidence":..., "engine":...} + """ + from .config import get_config + cfg = get_config() + hotword_id = ensure_hotword_id() + with open(wav_path, "rb") as f: + raw = f.read() + data_b64 = base64.b64encode(raw).decode("ascii") + client = _client() + primary = cfg.asr.get("engine_primary", "16k_zh_dialect") + secondary = cfg.asr.get("engine_secondary", "16k_zh_en") + retry_below = float(cfg.asr.get("confidence_retry_below", 0.7)) + accept_above = float(cfg.asr.get("confidence_accept_above", 0.5)) + + text, conf = _call_once(client, primary, data_b64, len(raw), hotword_id) + engine_used = primary + if (conf is not None and (conf < retry_below)) or (not text): + # 兜底:备用引擎 + try: + t2, c2 = _call_once(client, secondary, data_b64, len(raw), hotword_id) + if t2 and (conf is None or c2 is None or c2 >= conf): + text, conf, engine_used = t2, c2, secondary + except Exception as e: + logger.warning("[asr] 备用引擎失败: %s", e) + + if not text: + return {"text": "", "confidence": conf, "engine": engine_used, "empty": True} + # 仍低置信度:标记,由上层决定是否要求重说 + low = (conf is not None and conf < accept_above) + return {"text": text, "confidence": conf, "engine": engine_used, "low_confidence": low} + + +def transcribe_wecom_voice(amr_path: str, media_dir: str) -> dict: + """微信语音 amr(8k) → 重采样 16k wav → 识别。""" + wav_path = os.path.join(media_dir, "asr_%d.wav" % int(__import__("time").time() * 1000)) + try: + resample_to_16k(amr_path, wav_path) + return transcribe_audio_file(wav_path) + finally: + try: + if os.path.exists(wav_path): + os.remove(wav_path) + except Exception: + pass diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..4e7d3f8 --- /dev/null +++ b/app/config.py @@ -0,0 +1,123 @@ +""" +app/config.py — 配置加载 + - 密钥从 .env(load_dotenv,不入库) + - 模板/规则/素材/通知等从 config.yaml + - 单例 Config,全局复用 +""" +import os +import threading +import yaml + +try: + from . import __version__ +except Exception: + __version__ = "v1.0" + +_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def load_dotenv(path=None): + """从 .env 读入环境变量(仅当未设置时)。""" + if path is None: + path = os.path.join(_BASE_DIR, ".env") + if not os.path.exists(path): + return + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'")) + + +class Config: + _instance = None + _lock = threading.Lock() + + def __init__(self): + load_dotenv() + self.base_dir = _BASE_DIR + # ---- 企业微信(来自 .env) ---- + self.wecom_corp_id = os.environ.get("WECOM_CORP_ID", "") + self.wecom_secret = os.environ.get("WECOM_SECRET", "") + self.wecom_agent_id = int(os.environ.get("WECOM_AGENT_ID", "0")) + self.wecom_token = os.environ.get("WECOM_TOKEN", "") + self.wecom_aes_key = os.environ.get("WECOM_AES_KEY", "") + self.wecom_receive_id = os.environ.get("WECOM_RECEIVE_ID", "") + self.wecom_test_user = os.environ.get("WECOM_TEST_USER", "mom") + + # ---- 腾讯云 ASR(来自 .env) ---- + self.tencent_secret_id = os.environ.get("TENCENT_SECRET_ID", "") + self.tencent_secret_key = os.environ.get("TENCENT_SECRET_KEY", "") + + # ---- AI 打标(来自 .env,OpenAI 兼容) ---- + self.ai_api_key = os.environ.get("AI_API_KEY", "") + self.ai_base_url = os.environ.get("AI_BASE_URL", "https://api.openai.com/v1") + self.ai_model = os.environ.get("AI_MODEL", "gpt-4o-mini") + + # ---- 后台鉴权(密码登录 + 会话 cookie) ---- + # 登录密码:默认 220526,可用 .env 的 ADMIN_PASSWORD 覆盖 + self.admin_password = os.environ.get("ADMIN_PASSWORD", "220526") + # 会话密钥(cookie 值):可用 .env 的 ADMIN_SECRET 固定,否则每次启动随机 + self.admin_secret = os.environ.get("ADMIN_SECRET") or os.urandom(16).hex() + + # ---- 短信(默认关闭,需报备) ---- + self.sms_enabled = os.environ.get("SMS_ENABLED", "0") in ("1", "true", "True") + self.sms_secret_id = os.environ.get("SMS_SECRET_ID", "") + self.sms_secret_key = os.environ.get("SMS_SECRET_KEY", "") + self.sms_app_id = os.environ.get("SMS_APP_ID", "") + self.sms_sign = os.environ.get("SMS_SIGN", "") + self.sms_template_id = os.environ.get("SMS_TEMPLATE_ID", "") + + # ---- 数据库 ---- + self.db_path = os.path.join(_BASE_DIR, "data", "family.db") + self.media_dir = os.path.join(_BASE_DIR, "data", "media") + + # ---- 以下来自 config.yaml ---- + self.server = {"host": "0.0.0.0", "port": 1064, + "public_base_url": "https://acesimba.cloud"} + self.asr = {"engine_primary": "16k_zh_dialect", "engine_secondary": "16k_zh_en", + "confidence_retry_below": 0.7, "confidence_accept_above": 0.5, + "hotwords": []} + self.ai_cfg = {"enabled": True, "timeout_sec": 15, "system_prompt": ""} + self.notify = {"routing": {1: [], 2: ["wecom"], 3: ["wecom", "sms"]}, + "degrade": {}, "budget": {"sms_monthly_cny": 20}, + "cooldown_minutes": 30, "l2_daily_cap": 2, "wait_state_hours": 4} + self.family = {"name": "家里", "members": []} + self.seed = {"templates": [], "rules": [], "materials": []} + + self._load_yaml() + + def _load_yaml(self): + path = os.path.join(_BASE_DIR, "config", "config.yaml") + if not os.path.exists(path): + # 允许只跑 spike 或仅用 example + example = os.path.join(_BASE_DIR, "config", "config.example.yaml") + if os.path.exists(example): + path = example + if not os.path.exists(path): + return + with open(path, encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + self.server.update(data.get("server", {}) or {}) + self.asr.update(data.get("asr", {}) or {}) + self.ai_cfg.update(data.get("ai", {}) or {}) + self.notify.update(data.get("notify", {}) or {}) + self.family = data.get("family", self.family) or self.family + self.seed = data.get("seed", self.seed) or self.seed + + # 便捷读取 + def asr_hotwords(self): + return self.asr.get("hotwords", []) or [] + + def wait_state_seconds(self): + return int(self.notify.get("wait_state_hours", 4)) * 3600 + + +def get_config() -> Config: + if Config._instance is None: + with Config._lock: + if Config._instance is None: + Config._instance = Config() + return Config._instance diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..67d43d5 --- /dev/null +++ b/app/db.py @@ -0,0 +1,323 @@ +""" +app/db.py — SQLAlchemy 模型 + 引擎(SQLite WAL) +按 SPEC §4 的 10 张表定义。create_all 为建表来源(等价 schema.sql)。 +""" +import os +import time +from contextlib import contextmanager + +from sqlalchemy import ( + Column, Integer, String, Text, Float, ForeignKey, create_engine, event +) +from sqlalchemy.orm import declarative_base, sessionmaker, relationship + +from .config import get_config + +Base = declarative_base() + + +class Family(Base): + __tablename__ = "families" + id = Column(Integer, primary_key=True) + name = Column(String, nullable=False) + timezone = Column(String, nullable=False, default="Asia/Shanghai") + created_at = Column(Integer, nullable=False) + + +class Member(Base): + __tablename__ = "members" + id = Column(Integer, primary_key=True) + family_id = Column(Integer, ForeignKey("families.id"), nullable=False) + wecom_userid = Column(String, nullable=False, unique=True) + name = Column(String, nullable=False) + role = Column(String, nullable=False) # admin | member + phone = Column(String) + persona = Column(String, nullable=False, default="elder") + quiet_hours = Column(String, default="21:00-08:00") + enabled = Column(Integer, nullable=False, default=1) + created_at = Column(Integer, nullable=False) + + +class Template(Base): + __tablename__ = "templates" + id = Column(Integer, primary_key=True) + family_id = Column(Integer, nullable=False) + name = Column(String, nullable=False) + theme = Column(String) + enabled = Column(Integer, nullable=False, default=1) + created_at = Column(Integer, nullable=False) + updated_at = Column(Integer, nullable=False) + + +class TemplateStep(Base): + __tablename__ = "template_steps" + id = Column(Integer, primary_key=True) + template_id = Column(Integer, ForeignKey("templates.id", ondelete="CASCADE"), nullable=False) + seq = Column(Integer, nullable=False) + type = Column(String, nullable=False) # text | material | question | image + content = Column(Text) # text=正文 image=图片文件名 + tag = Column(String) + question_key = Column(String) + delay_sec = Column(Integer, nullable=False, default=0) + trigger_type = Column(String, nullable=False, default="delay") # delay=上一条发出后 | after_reply=上一条回复后 + reply_timeout_sec = Column(Integer) # 该条发出后多久没回复就发下一条(NULL=不兜底) + __table_args__ = ({"sqlite_autoincrement": False},) + + +class Rule(Base): + __tablename__ = "rules" + id = Column(Integer, primary_key=True) + family_id = Column(Integer, nullable=False) + member_id = Column(Integer) # 旧字段(NULL=全家),已由 member_ids 取代 + member_ids = Column(String) # 发送对象,逗号分隔,如 "1,2";空=全家 + name = Column(String, default="") # 规则名称 + period_type = Column(String, nullable=False, default="weekly") # daily | weekly | monthly + days = Column(String, nullable=False) # weekly: "1,3,5" / "*" + month_day = Column(Integer) # monthly: 每月几号 + at_time = Column(String, nullable=False) # "09:00" + start_date = Column(String) # YYYY-MM-DD,空=立即开始 + end_date = Column(String) # YYYY-MM-DD,空=不结束 + template_id = Column(Integer, ForeignKey("templates.id"), nullable=False) + priority = Column(Integer, nullable=False, default=0) + enabled = Column(Integer, nullable=False, default=1) + + +class Material(Base): + __tablename__ = "materials" + id = Column(Integer, primary_key=True) + family_id = Column(Integer, nullable=False) + tag = Column(String, nullable=False) + title = Column(String, nullable=False) + url = Column(String, nullable=False) + platform = Column(String) + source = Column(String) + checked = Column(Integer, nullable=False, default=0) + alive = Column(Integer, nullable=False, default=1) + fail_count = Column(Integer, nullable=False, default=0) + used_count = Column(Integer, nullable=False, default=0) + last_used_at = Column(Integer) + created_at = Column(Integer, nullable=False) + + +class Push(Base): + __tablename__ = "pushes" + id = Column(Integer, primary_key=True) + member_id = Column(Integer, ForeignKey("members.id"), nullable=False) + rule_id = Column(Integer, ForeignKey("rules.id")) + template_id = Column(Integer, nullable=False) + push_date = Column(String, nullable=False) # YYYY-MM-DD + material_id = Column(Integer) + status = Column(String, nullable=False) # sent | failed | skipped + sent_at = Column(Integer) + + +class Feedback(Base): + __tablename__ = "feedbacks" + id = Column(Integer, primary_key=True) + member_id = Column(Integer, ForeignKey("members.id"), nullable=False) + push_id = Column(Integer, ForeignKey("pushes.id")) + question_key = Column(String) + question = Column(Text) + answer = Column(Text, nullable=False) + input_type = Column(String, nullable=False) # text | voice + asr_confidence = Column(Float) + mood = Column(String) + physical = Column(String) + risk_level = Column(Integer, nullable=False, default=0) + ai_reason = Column(Text) + created_at = Column(Integer, nullable=False) + + +class Alert(Base): + __tablename__ = "alerts" + id = Column(Integer, primary_key=True) + member_id = Column(Integer, nullable=False) + feedback_id = Column(Integer, ForeignKey("feedbacks.id")) + level = Column(Integer, nullable=False) + reason = Column(String, nullable=False) + channels = Column(String) + resolved = Column(Integer, nullable=False, default=0) + resolved_by = Column(String) + created_at = Column(Integer, nullable=False) + resolved_at = Column(Integer) + + +class Notification(Base): + __tablename__ = "notifications" + id = Column(Integer, primary_key=True) + alert_id = Column(Integer, ForeignKey("alerts.id")) + channel = Column(String, nullable=False) # wecom | sms | email | webhook + target = Column(String, nullable=False) + status = Column(String, nullable=False) # sent | failed | skipped | degraded + cost_cny = Column(Float, nullable=False, default=0) + vendor_msg = Column(Text) + retry_count = Column(Integer, nullable=False, default=0) + created_at = Column(Integer, nullable=False) + + +class WeeklyReport(Base): + __tablename__ = "weekly_reports" + id = Column(Integer, primary_key=True) + family_id = Column(Integer, nullable=False) + week_start = Column(String, nullable=False) + content = Column(Text, nullable=False) + sent_at = Column(Integer) + __table_args__ = ({"sqlite_autoincrement": False},) + + +class WaitState(Base): + """等待反馈态:某成员处于「刚被提问,等回复」的状态。超时自动失效。""" + __tablename__ = "wait_states" + id = Column(Integer, primary_key=True) + member_id = Column(Integer, nullable=False) + push_id = Column(Integer) + question_key = Column(String) + question = Column(Text) + expires_at = Column(Integer, nullable=False) + created_at = Column(Integer, nullable=False) + + +class TemplateSession(Base): + """模板推送会话:记录某成员某次模板推送走到第几步。 + 支持两种推进:delay 定时推进 / after_reply 等回复推进(超时兜底)。""" + __tablename__ = "template_sessions" + id = Column(Integer, primary_key=True) + member_id = Column(Integer, ForeignKey("members.id"), nullable=False) + template_id = Column(Integer, nullable=False) + rule_id = Column(Integer) + push_id = Column(Integer) + cur_step = Column(Integer, nullable=False, default=0) # 已发送的最后一步下标(0-based) + waiting_for = Column(String, default="") # "reply"=在等回复后推进 + status = Column(String, nullable=False, default="active") # active | done | aborted + created_at = Column(Integer, nullable=False) + updated_at = Column(Integer, nullable=False) + + +class StepReply(Base): + """模板会话中每一步的回复记录(回复总览矩阵数据源,供后续总结提炼)。 + seq 与 template_steps.seq 对齐(1-based)。""" + __tablename__ = "step_replies" + id = Column(Integer, primary_key=True) + member_id = Column(Integer, nullable=False) + template_id = Column(Integer, nullable=False) + push_id = Column(Integer) # 哪次执行(推送) + session_id = Column(Integer) # 哪个会话 + seq = Column(Integer, nullable=False) # 回答的是第几步(1-based) + question = Column(Text) # 该步内容快照(模板修改不影响历史) + answer = Column(Text) # 回复原文(语音为转写文本) + input_type = Column(String, nullable=False, default="text") # text | voice + created_at = Column(Integer, nullable=False) + + +# --------------------------------------------------------------------------- +# 增量迁移(SQLite ADD COLUMN) +# --------------------------------------------------------------------------- +_MIGRATIONS = { + "rules": [ + ("name", "VARCHAR DEFAULT ''"), + ("member_ids", "VARCHAR"), + ("period_type", "VARCHAR DEFAULT 'weekly'"), + ("month_day", "INTEGER"), + ("start_date", "VARCHAR"), + ("end_date", "VARCHAR"), + ], + "template_steps": [ + ("trigger_type", "VARCHAR DEFAULT 'delay'"), + ("reply_timeout_sec", "INTEGER"), + ], +} + + +def _migrate(engine): + """为旧库补充新增列,并把旧数据迁移到新语义。幂等。""" + from sqlalchemy import text + with engine.connect() as conn: + for table, cols in _MIGRATIONS.items(): + rows = conn.execute(text("PRAGMA table_info(%s)" % table)).fetchall() + existing = {r[1] for r in rows} + if not existing: + continue # 表还不存在(create_all 会按新结构建) + for col, ddl in cols: + if col not in existing: + conn.execute(text("ALTER TABLE %s ADD COLUMN %s %s" % (table, col, ddl))) + # 旧规则语义迁移 + conn.execute(text( + "UPDATE rules SET period_type='daily' " + "WHERE days='*' AND (period_type IS NULL OR period_type='')")) + conn.execute(text( + "UPDATE rules SET period_type='weekly' " + "WHERE days<>'*' AND (period_type IS NULL OR period_type='')")) + conn.execute(text( + "UPDATE rules SET member_ids=CAST(member_id AS TEXT) " + "WHERE member_id IS NOT NULL AND (member_ids IS NULL OR member_ids='')")) + conn.execute(text( + "UPDATE rules SET name='规则'||CAST(id AS TEXT) " + "WHERE name IS NULL OR name=''")) + conn.commit() + + +# --------------------------------------------------------------------------- +# 引擎与会话 +# --------------------------------------------------------------------------- +_engine = None +_Session = None + + +def _make_engine(): + cfg = get_config() + os.makedirs(os.path.dirname(cfg.db_path), exist_ok=True) + os.makedirs(cfg.media_dir, exist_ok=True) + url = "sqlite:///%s" % cfg.db_path + eng = create_engine(url, connect_args={"check_same_thread": False}, + pool_pre_ping=True, future=True) + # 打开 WAL,提升并发读写 + @event.listens_for(eng, "connect") + def _set_pragma(dbapi_conn, conn_record): + cur = dbapi_conn.cursor() + cur.execute("PRAGMA journal_mode=WAL;") + cur.execute("PRAGMA foreign_keys=ON;") + cur.close() + + return eng + + +def init_db(): + """建表(若不存在)+ 增量迁移 + 确保目录。返回 engine。""" + global _engine, _Session + if _engine is None: + _engine = _make_engine() + _Session = sessionmaker(bind=_engine, future=True) + Base.metadata.create_all(_engine) + _migrate(_engine) + return _engine + + +def get_engine(): + if _engine is None: + init_db() + return _engine + + +def get_sessionmaker(): + if _Session is None: + init_db() + return _Session + + +@contextmanager +def session_scope(): + """事务性 session:正常提交,异常回滚。""" + sm = get_sessionmaker() + session = sm() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + +def now_ts(): + return int(time.time()) diff --git a/app/feedback.py b/app/feedback.py new file mode 100644 index 0000000..b23acd1 --- /dev/null +++ b/app/feedback.py @@ -0,0 +1,110 @@ +""" +app/feedback.py — 反馈采集与等待态(SPEC §8.1) + 流程: + 收到消息 → 是否处于「等待反馈」态? + 是 → 语音走 ASR(若需要)→ 记 feedback → 风险打标 → 清除等待态 → 派发告警 + 否 → 普通陪聊(AI 接话,不入库为反馈);被问机器人如实说(C3) +""" +import logging +import time + +from .db import (Feedback, WaitState, Member, get_sessionmaker, session_scope, now_ts) +from . import grading, ai, asr, notify, config as _cfg + +logger = logging.getLogger("feedback") + +ROBOT_HONEST_REPLY = ( + "这是欢欢给你弄的一个陪你说话的小东西,话是它替我说的,但我每天都看你说的话。" +) + + +def set_wait_state(member_id: int, push_id, question_key, question, hours: int = 4): + with session_scope() as sess: + # 同一人只保留一个等待态 + sess.query(WaitState).filter(WaitState.member_id == member_id).delete() + sess.add(WaitState( + member_id=member_id, push_id=push_id, question_key=question_key, + question=question, expires_at=now_ts() + hours * 3600, created_at=now_ts())) + + +def get_active_wait_state(member_id: int): + sm = get_sessionmaker() + sess = sm() + try: + ws = (sess.query(WaitState) + .filter(WaitState.member_id == member_id, + WaitState.expires_at > now_ts()) + .order_by(WaitState.created_at.desc()) + .first()) + if not ws: + return None + return {"member_id": ws.member_id, "push_id": ws.push_id, + "question_key": ws.question_key, "question": ws.question, + "id": ws.id} + finally: + sess.close() + + +def clear_wait_state(member_id: int): + with session_scope() as sess: + sess.query(WaitState).filter(WaitState.member_id == member_id).delete() + + +def clear_expired_wait_states(): + with session_scope() as sess: + sess.query(WaitState).filter(WaitState.expires_at <= now_ts()).delete() + + +def collect(member: Member, raw_text: str, input_type: str, asr_confidence=None) -> dict: + """ + 处理一条老人消息。返回 { + kind: "feedback" | "chat", + feedback_id?, grading?, alert?, reply? (给老人的回执文本,可选) + } + """ + ws = get_active_wait_state(member.id) + if ws: + # 入库为反馈 + ai_result = None + if input_type == "voice" or True: + # 文本也跑一次 AI 打标,丰富情绪/身体判断 + try: + ai_result = ai.tag(raw_text) + except Exception as e: + logger.warning("[feedback] AI 打标异常: %s", e) + g = grading.analyze(raw_text, ai_result) + with session_scope() as sess: + fb = Feedback( + member_id=member.id, push_id=ws.get("push_id"), + question_key=ws.get("question_key"), question=ws.get("question"), + answer=raw_text, input_type=input_type, + asr_confidence=asr_confidence, mood=g["mood"], physical=g["physical"], + risk_level=g["risk"], ai_reason=g.get("reason"), created_at=now_ts()) + sess.add(fb) + sess.flush() + fb_id = fb.id + clear_wait_state(member.id) + alert = notify.handle_grading_result(member.id, g, feedback_id=fb_id) + logger.info("[feedback] 已归档 member=%s risk=%d reason=%s", + member.id, g["risk"], g["reason"]) + return {"kind": "feedback", "feedback_id": fb_id, "grading": g, + "alert": (alert.id if alert else None)} + + # 非等待态:普通陪聊 + if grading.is_robot_question(raw_text): + return {"kind": "chat", "reply": ROBOT_HONEST_REPLY} + reply = ai.reply_as_daughter(raw_text) + return {"kind": "chat", "reply": reply or "妈,我在呢,你说~"} + + +def process_voice(member: Member, amr_path: str, media_dir: str) -> dict: + """下载好的微信语音(amr) → 重采样 → ASR → collect。返回结果带转写文本。""" + res = asr.transcribe_wecom_voice(amr_path, media_dir) + text = res.get("text", "") + conf = res.get("confidence") + if not text: + # 没听清,请重说(保存原始音频已在 download 时落盘) + return {"kind": "chat", "reply": "没太听清,你再说一遍?", "text": None} + out = collect(member, text, "voice", asr_confidence=conf) + out["text"] = text + return out diff --git a/app/grading.py b/app/grading.py new file mode 100644 index 0000000..a51560a --- /dev/null +++ b/app/grading.py @@ -0,0 +1,98 @@ +""" +app/grading.py — 风险分级(SPEC §8.3 / §8.4) + 顺序:关键词先跑,命中直接 L3(不等 AI)。 + 再融合 AI 打标结果,取两者的较高风险。 + 返回结构化结果,供 notify 做降噪与路由。 +""" +import logging +import re + +logger = logging.getLogger("grading") + +# L3 紧急关键词(命中直接 L3) +L3_KEYWORDS = [ + "摔倒", "摔了", "摔一跤", "跌倒", "摔跟头", + "胸痛", "胸口疼", "胸口痛", "喘不上气", "喘不过气", "憋气", "呼吸困难", + "不想活", "活不下去", "轻生", "自杀", "不想过了", + "救命", "晕倒", "晕厥", "昏迷", "中风", "脑梗", "心梗", +] +# 身体不适关键词(→ physical 不适,可能升 L2) +PHYSICAL_WORDS = [ + "头晕", "头疼", "头痛", "膝盖", "腰疼", "腰酸痛", "腿疼", "关节", + "乏力", "没劲", "恶心", "吃不下", "发烧", "发热", "咳嗽", "心慌", "气短", + "不舒服", "难受", "疼", "痛", "胀", "麻", +] +# 情绪负面关键词(→ mood 负面,可能升 L1) +NEGATIVE_WORDS = [ + "累", "烦", "闷", "孤单", "寂寞", "想你", "哭了", "难受", "不开心", + "难过", "委屈", "害怕", "慌", "郁闷", "无聊", "没意思", +] + +# C3:被问是不是机器人,必须如实说 +ROBOT_QUESTION_PATTERNS = ["机器人", "是不是机器", "你是机器", "自动的", "真人还是"] + + +def is_robot_question(text: str) -> bool: + return any(p in text for p in ROBOT_QUESTION_PATTERNS) + + +def analyze(text: str, ai_result: dict | None) -> dict: + """ + 返回 { + risk: 0-3, mood, physical, reason, + keyword_hit: bool, hit_word: str, + } + """ + risk = 0 + mood = "中性" + physical = "正常" + reason = "" + keyword_hit = False + hit_word = "" + + # 1) 关键词先跑 + for w in L3_KEYWORDS: + if w in text: + risk = 3 + keyword_hit = True + hit_word = w + physical = "异常" + mood = "低落" + reason = "关键词命中: %s" % w + break + if not keyword_hit: + for w in PHYSICAL_WORDS: + if w in text: + physical = "不适" + risk = max(risk, 2) + reason = reason or ("身体不适: %s" % w) + break + for w in NEGATIVE_WORDS: + if w in text: + mood = "负面" + risk = max(risk, 1) + reason = reason or ("情绪低落: %s" % w) + break + + # 2) 融合 AI 结果(AI 出错也不影响,因为关键词已兜底) + if ai_result: + ai_risk = ai_result.get("risk", 0) or 0 + risk = max(risk, ai_risk) + # 仅在关键词未给具体值时,采用 AI 的 mood/physical + if not keyword_hit: + if ai_result.get("mood"): + mood = ai_result["mood"] + if ai_result.get("physical"): + physical = ai_result["physical"] + if ai_risk >= 1 and not keyword_hit: + reason = ai_result.get("reason") or reason + # 关键词 L3 时仍记录 AI 理由(更细) + if keyword_hit and ai_result.get("reason"): + reason = "%s;AI:%s" % (reason, ai_result["reason"]) + + risk = max(0, min(3, int(risk))) + return { + "risk": risk, "mood": mood, "physical": physical, + "reason": (reason or "正常")[:60], + "keyword_hit": keyword_hit, "hit_word": hit_word, + } diff --git a/app/images.py b/app/images.py new file mode 100644 index 0000000..78a3c4b --- /dev/null +++ b/app/images.py @@ -0,0 +1,70 @@ +""" +app/images.py — 图片素材管理 + - 模板编辑器上传的图片保存在 data/media/images/ + - 发送时上传企业微信拿 media_id(临时素材 3 天有效),进程内缓存 2 天,过期自动重传 +""" +import logging +import os +import threading +import time +import uuid + +from .config import get_config + +logger = logging.getLogger("images") + +ALLOWED_EXT = (".jpg", ".jpeg", ".png", ".gif") +MAX_SIZE = 10 * 1024 * 1024 # 10MB +_MEDIA_VALID_SEC = 2 * 86400 # 临时素材官方 3 天有效,缓存 2 天 + +_cache = {} # filename -> (media_id, expire_ts) +_lock = threading.Lock() + + +def images_dir() -> str: + d = os.path.join(get_config().media_dir, "images") + os.makedirs(d, exist_ok=True) + return d + + +def image_path(filename: str) -> str: + """校验文件名安全(防路径穿越)并返回绝对路径。""" + if not filename or "/" in filename or "\\" in filename or ".." in filename: + raise ValueError("非法文件名") + return os.path.join(images_dir(), filename) + + +def save_image(orig_name: str, data: bytes) -> str: + """保存上传图片,返回生成的文件名。""" + if not data: + raise ValueError("空文件") + if len(data) > MAX_SIZE: + raise ValueError("图片超过 10MB") + ext = os.path.splitext(orig_name or "")[1].lower() + if ext not in ALLOWED_EXT: + ext = ".jpg" + fn = "img_%d_%s%s" % (int(time.time() * 1000), uuid.uuid4().hex[:8], ext) + path = os.path.join(images_dir(), fn) + with open(path, "wb") as f: + f.write(data) + logger.info("[images] 已保存 %s (%d KB)", fn, len(data) // 1024) + return fn + + +def send_image_to(client, userid: str, filename: str) -> dict: + """发送图片消息(自动处理 media_id 缓存/重传)。""" + path = image_path(filename) + if not os.path.exists(path): + return {"errcode": -1, "errmsg": "图片文件不存在: %s" % filename} + with _lock: + hit = _cache.get(filename) + if hit and hit[1] > time.time(): + media_id = hit[0] + else: + media_id = None + if media_id is None: + media_id = client.upload_media(path, "image") + with _lock: + _cache[filename] = (media_id, time.time() + _MEDIA_VALID_SEC) + logger.info("[images] %s 已上传企业微信 media_id=%s", filename, media_id) + return client.send_image(userid, media_id) diff --git a/app/materials.py b/app/materials.py new file mode 100644 index 0000000..d73f6c1 --- /dev/null +++ b/app/materials.py @@ -0,0 +1,114 @@ +""" +app/materials.py — 素材子系统 + - pick_material:按 SPEC §7.1 轮换(抽查优先 → 未用过优先 → 最久未用 → 用得最少) + - mark_used:更新 used_count / last_used_at + - health_check:HEAD 体检,连续 2 次失败置 alive=0;存活 < 3 告警 +""" +import time +import logging +import urllib.request + +from .db import Material, session_scope, now_ts +from . import notify + +logger = logging.getLogger("materials") + + +def pick_material(family_id: int, tag: str): + """返回 Material 或 None(该 tag 无存活素材)。""" + from .db import get_sessionmaker + sm = get_sessionmaker() + sess = sm() + try: + mat = ( + sess.query(Material) + .filter(Material.family_id == family_id, Material.tag == tag, + Material.alive == 1) + .order_by( + (Material.checked == 1).desc(), + (Material.last_used_at.is_(None)).desc(), + Material.last_used_at.asc(), + Material.used_count.asc(), + ) + .first() + ) + return mat + finally: + sess.close() + + +def mark_used(material_id: int): + from .db import get_sessionmaker + sm = get_sessionmaker() + sess = sm() + try: + mat = sess.get(Material, material_id) + if mat: + mat.used_count += 1 + mat.last_used_at = now_ts() + sess.commit() + finally: + sess.close() + + +def alive_count(family_id: int, tag: str) -> int: + from .db import get_sessionmaker + sm = get_sessionmaker() + sess = sm() + try: + return sess.query(Material).filter( + Material.family_id == family_id, Material.tag == tag, + Material.alive == 1).count() + finally: + sess.close() + + +def health_check(): + """每周体检:HEAD 请求,连续 2 次失败 → alive=0;存活<3 告警。返回告警消息列表。""" + from .db import get_sessionmaker, Family + sm = get_sessionmaker() + sess = sm() + alerts = [] + try: + mats = sess.query(Material).filter(Material.alive == 1).all() + for m in mats: + ok = _head_ok(m.url) + if ok: + m.fail_count = 0 + else: + m.fail_count += 1 + if m.fail_count >= 2: + m.alive = 0 + alerts.append("素材失效已剔除: %s (%s)" % (m.title, m.url)) + sess.commit() + # 各 tag 存活数 + fam = sess.query(Family).first() + fid = fam.id if fam else 1 + tags = [t for (t,) in sess.query(Material.tag).filter( + Material.family_id == fid).distinct()] + for tag in tags: + if alive_count(fid, tag) < 3: + msg = "素材不足预警: tag=%s 存活仅 %d 条,请补充" % (tag, alive_count(fid, tag)) + alerts.append(msg) + finally: + sess.close() + if alerts: + notify.alert_admins("\n".join(alerts), level=2, reason="素材体检") + return alerts + + +def _head_ok(url: str, timeout: int = 3) -> bool: + try: + req = urllib.request.Request(url, method="HEAD", headers={ + "User-Agent": "Mozilla/5.0 (compatible; family-chat/1.0)"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status < 400 + except Exception: + # HEAD 可能被拒,退回 GET 探测 + try: + req = urllib.request.Request(url, method="GET", headers={ + "User-Agent": "Mozilla/5.0 (compatible; family-chat/1.0)"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status < 400 + except Exception: + return False diff --git a/app/notify.py b/app/notify.py new file mode 100644 index 0000000..0910ae0 --- /dev/null +++ b/app/notify.py @@ -0,0 +1,228 @@ +""" +app/notify.py — 通知中心(SPEC §10) + - Notifier 协议:wecom / sms(sms 默认关闭,需报备) + - 路由:L1 不通知(进周报);L2 企微;L3 企微+短信 + - 降噪:同成员同级冷却 30min;L2 单日上限 2 条(超出跳过发送,仍留痕进周报) + - 幂等:notifications 唯一(alert_id, channel, target) +""" +import time +import logging +from dataclasses import dataclass + +from .db import Alert, Notification, Member, get_sessionmaker, session_scope, now_ts +from . import config as _cfg + +logger = logging.getLogger("notify") + + +@dataclass +class Receipt: + status: str # sent | failed | skipped | degraded + vendor_msg: str = "" + cost_cny: float = 0.0 + + +class WeComNotifier: + name = "wecom" + + def send(self, alert: Alert, target_userid: str) -> Receipt: + from .wecom import make_client + client = make_client() + text = "【家人看护提醒 L%d】\n%s" % (alert.level, alert.reason) + try: + r = client.send_text(target_userid, text) + if r.get("errcode", 0) == 0: + return Receipt("sent", str(r)) + return Receipt("failed", str(r)) + except Exception as e: + return Receipt("failed", str(e)) + + +class SmsNotifier: + name = "sms" + + def send(self, alert: Alert, target_phone: str) -> Receipt: + from .config import get_config + cfg = get_config() + if not cfg.sms_enabled: + return Receipt("skipped", "短信未启用") + # 适配腾讯云短信:{1}=家人名 {2}=时间/位置 {3}=情况(≤12字) + try: + from tencentcloud.common import credential + from tencentcloud.common.profile.client_profile import ClientProfile + from tencentcloud.common.profile.http_profile import HttpProfile + from tencentcloud.sms.v20210111 import sms_client + except Exception as e: + return Receipt("failed", "短信 SDK 不可用: %s" % e) + try: + cred = credential.Credential(cfg.sms_secret_id, cfg.sms_secret_key) + hp = HttpProfile() + hp.reqTimeout = 10 + cp = ClientProfile() + cp.httpProfile = hp + client = sms_client.SmsClient(cred, "ap-guangzhou", cp) + params = { + "PhoneNumberSet": ["+86%s" % target_phone], + "SmsSdkAppId": cfg.sms_app_id, + "SignName": cfg.sms_sign, + "TemplateId": cfg.sms_template_id, + "TemplateParamSet": [alert.reason[:12] or "情况异常", time.strftime("%H:%M")], + } + resp = client.call("SendSms", params) + raw = resp if isinstance(resp, dict) else __import__("json").loads(resp.decode("utf-8")) + # 简化判断 + if raw.get("Response", {}).get("SendStatusSet"): + return Receipt("sent", str(raw)[:200], cost_cny=0.04) + return Receipt("failed", str(raw)[:200]) + except Exception as e: + return Receipt("failed", str(e)) + + +_NOTIFIERS = {"wecom": WeComNotifier(), "sms": SmsNotifier()} + + +# --------------------------------------------------------------------------- +# 告警创建与派发 +# --------------------------------------------------------------------------- +def create_alert(member_id: int, level: int, reason: str, feedback_id=None) -> Alert: + with session_scope() as sess: + a = Alert(member_id=member_id, feedback_id=feedback_id, level=level, + reason=reason, created_at=now_ts()) + sess.add(a) + sess.flush() + aid = a.id + # 重新查以便返回(session 关闭前) + return Alert(member_id=member_id, feedback_id=feedback_id, level=level, + reason=reason, created_at=a.created_at, id=aid) + # 上面 session_scope 已关闭,返回的对象脱离 session,但字段已填充,够用 + + +def admin_targets(channel: str): + """返回某通道的目标列表:wecom→admin userid 串;sms→admin 手机号。""" + sm = get_sessionmaker() + sess = sm() + try: + admins = sess.query(Member).filter(Member.role == "admin").all() + if channel == "wecom": + ids = [m.wecom_userid for m in admins if m.wecom_userid] + return ["|".join(ids)] if ids else [] + elif channel == "sms": + return [m.phone for m in admins if m.phone] + return [] + finally: + sess.close() + + +def _cooldown_ok(member_id: int, level: int, cooldown_min: int) -> bool: + sm = get_sessionmaker() + sess = sm() + try: + since = now_ts() - cooldown_min * 60 + cnt = sess.query(Alert).filter( + Alert.member_id == member_id, Alert.level == level, + Alert.created_at >= since).count() + return cnt == 0 + finally: + sess.close() + + +def _l2_under_cap(member_id: int, cap: int) -> bool: + sm = get_sessionmaker() + sess = sm() + try: + today_start = now_ts() - (now_ts() % 86400) # 粗略按当天 + cnt = sess.query(Alert).filter( + Alert.member_id == member_id, Alert.level == 2, + Alert.created_at >= today_start).count() + return cnt < cap + finally: + sess.close() + + +def dispatch(alert: Alert) -> list: + """按路由派发,应用降噪与幂等。返回发出的通知列表。""" + from .config import get_config + cfg = get_config() + routing = cfg.notify.get("routing", {}) + channels = routing.get(alert.level, []) or [] + if not channels: + return [] # L1 不通知 + + # 降噪 + cd = int(cfg.notify.get("cooldown_minutes", 30)) + if not _cooldown_ok(alert.member_id, alert.level, cd): + logger.info("[notify] 冷却期内跳过 L%d 告警 (member=%s)", alert.level, alert.member_id) + return [] + if alert.level == 2: + cap = int(cfg.notify.get("l2_daily_cap", 2)) + if not _l2_under_cap(alert.member_id, cap): + logger.info("[notify] L2 单日上限,跳过发送(进周报) member=%s", alert.member_id) + return [] + + sent = [] + for ch in channels: + notifier = _NOTIFIERS.get(ch) + if not notifier: + continue + targets = admin_targets(ch) + if not targets: + continue + for tgt in targets: + # 幂等:已发过则跳过 + if _notified(alert.id, ch, tgt): + continue + receipt = notifier.send(alert, tgt) + _record(alert.id, ch, tgt, receipt) + sent.append((ch, tgt, receipt)) + # L3 企微失败 → 降级短信 + if receipt.status == "failed" and alert.level == 3 and ch == "wecom": + for dch in cfg.notify.get("degrade", {}).get("wecom_failed_on_level_3", []): + dn = _NOTIFIERS.get(dch) + if dn: + for dt in admin_targets(dch): + if not _notified(alert.id, dch, dt): + r2 = dn.send(alert, dt) + _record(alert.id, dch, dt, r2) + sent.append((dch, dt, r2)) + return sent + + +def _notified(alert_id, channel, target) -> bool: + sm = get_sessionmaker() + sess = sm() + try: + return sess.query(Notification).filter( + Notification.alert_id == alert_id, Notification.channel == channel, + Notification.target == target).first() is not None + finally: + sess.close() + + +def _record(alert_id, channel, target, receipt: Receipt): + with session_scope() as sess: + sess.add(Notification( + alert_id=alert_id, channel=channel, target=target, + status=receipt.status, cost_cny=receipt.cost_cny, + vendor_msg=receipt.vendor_msg[:500], created_at=now_ts())) + + +def alert_admins(text: str, level: int = 2, reason: str = "系统提醒"): + """系统级提醒(素材体检等):直接建告警并派发,member_id 用 0 哨兵。""" + a = create_alert(0, level, "%s\n%s" % (reason, text)) + try: + dispatch(a) + except Exception as e: + logger.warning("[notify] alert_admins 派发失败: %s", e) + + +def handle_grading_result(member_id: int, grading: dict, feedback_id=None): + """feedback 收集后调用:按风险等级建告警并派发。""" + level = grading.get("risk", 0) + if level <= 0: + return None + a = create_alert(member_id, level, grading.get("reason", "风险反馈"), feedback_id) + try: + dispatch(a) + except Exception as e: + logger.warning("[notify] 派发失败: %s", e) + return a diff --git a/app/scheduler.py b/app/scheduler.py new file mode 100644 index 0000000..4e77cc7 --- /dev/null +++ b/app/scheduler.py @@ -0,0 +1,661 @@ +""" +app/scheduler.py — 规则引擎 + 推送闭环 + - 每个 rule 一个 APScheduler cron job(每日/每周/每月三种周期),配置变更热重载 + - 规则支持起止日期:不在区间内不发送;过期规则不再注册 + - 幂等:pushes 唯一(member_id, rule_id, push_date) + - 静默时段顺延到结束后 30 分钟 + - 模板会话推进:每步支持「上一条发出后延时」或「等老人回复后」两种时机, + 每步可设「未回复超时」兜底(超时即发下一条);回复到达立即推进 + - misfire_grace_time=900(15min 内补发,超时不补) + - 每日维护:清过期等待态、未回复升级、清僵尸会话、周日周报+素材体检 +""" +import time +import logging +from datetime import datetime, timedelta + +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.date import DateTrigger + +from .db import (Family, Member, Rule, Template, TemplateStep, TemplateSession, + StepReply, Push, get_sessionmaker, session_scope, now_ts) +from . import templates_render, materials, feedback, notify, weekly_report, config as _cfg +from . import images as _images + +logger = logging.getLogger("scheduler") + +_sched = None +_DOW_MAP = {"1": "mon", "2": "tue", "3": "wed", "4": "thu", "5": "fri", "6": "sat", "7": "sun"} +SESSION_STALE_HOURS = 48 # 会话最长存活,超过视为僵尸清理 +STEP_MISFIRE_GRACE = 3600 # 步骤推进任务 1h 内可补发 + + +def get_scheduler() -> BackgroundScheduler: + global _sched + if _sched is None: + _sched = BackgroundScheduler(timezone="Asia/Shanghai") + return _sched + + +def _first_family_id(): + sm = get_sessionmaker() + sess = sm() + try: + f = sess.query(Family).first() + return f.id if f else 1 + finally: + sess.close() + + +# --------------------------------------------------------------------------- +# 规则 → CronTrigger(每日 / 每周 / 每月) +# --------------------------------------------------------------------------- +def _cron_for_rule(rule: Rule) -> CronTrigger: + hh, mm = (rule.at_time.strip() + ":00")[:8].split(":")[:2] + hour, minute = int(hh), int(mm) + ptype = (rule.period_type or "weekly").strip() + if ptype == "daily": + return CronTrigger(hour=hour, minute=minute) + if ptype == "monthly": + day = int(rule.month_day or 1) + return CronTrigger(day=day, hour=hour, minute=minute) # 当月无该日则自然跳过 + # weekly + days = (rule.days or "").strip() + if days in ("*", ""): + return CronTrigger(hour=hour, minute=minute) + parts = [p.strip() for p in days.split(",") if p.strip()] + dow = ",".join(_DOW_MAP.get(p, p) for p in parts) + return CronTrigger(day_of_week=dow, hour=hour, minute=minute) + + +def _rule_in_date_range(rule: Rule, today: str) -> bool: + if rule.start_date and today < rule.start_date: + return False + if rule.end_date and today > rule.end_date: + return False + return True + + +def _parse_member_ids(member_ids) -> list: + if not member_ids: + return [] + out = [] + for p in str(member_ids).split(","): + p = p.strip() + if p.isdigit(): + out.append(int(p)) + return out + + +# --------------------------------------------------------------------------- +# 静默时段 +# --------------------------------------------------------------------------- +def _in_quiet_hours(qh: str) -> bool: + if not qh: + return False + try: + start_s, end_s = [x.strip() for x in qh.split("-")] + sh, sm_ = int(start_s.split(":")[0]), int(start_s.split(":")[1]) + eh, em = int(end_s.split(":")[0]), int(end_s.split(":")[1]) + except Exception: + return False + now = datetime.now() + cur = now.hour * 60 + now.minute + s = sh * 60 + sm_ + e = eh * 60 + em + if s <= e: + return s <= cur < e + else: # 跨午夜,如 21:00-08:00 + return cur >= s or cur < e + + +def _quiet_end_dt(qh: str) -> datetime: + """静默时段结束时刻 +30min。""" + try: + end_s = qh.split("-")[1].strip() + eh, em = int(end_s.split(":")[0]), int(end_s.split(":")[1]) + except Exception: + eh, em = 8, 30 + now = datetime.now() + end = now.replace(hour=eh, minute=em, second=0, microsecond=0) + if end <= now: + end += timedelta(days=1) + return end + timedelta(minutes=30) + + +# --------------------------------------------------------------------------- +# 模板步骤 → 待发送单元 +# --------------------------------------------------------------------------- +def _load_units(template_id: int) -> list: + """按 seq 读取模板步骤为发送单元(素材在实际发送时才选取)。""" + sm = get_sessionmaker() + sess = sm() + try: + steps = (sess.query(TemplateStep) + .filter(TemplateStep.template_id == template_id) + .order_by(TemplateStep.seq.asc()) + .all()) + return [{ + "type": s.type, + "content": s.content or "", + "tag": s.tag, + "question_key": s.question_key, + "delay_sec": s.delay_sec or 0, + "trigger_type": s.trigger_type or "delay", + "reply_timeout_sec": s.reply_timeout_sec, + } for s in steps] + finally: + sess.close() + + +def _send_unit(client, member, unit: dict, family_id: int): + """发送单个单元。返回 (ok, material_id, warning)。""" + if unit["type"] == "material": + mat = materials.pick_material(family_id, unit.get("tag")) + if not mat: + return False, None, "素材缺失(tag=%s),跳过该步" % unit.get("tag") + r = client.send_news(member.wecom_userid, mat.title, mat.url) + return r.get("errcode", 0) == 0, (mat.id if r.get("errcode", 0) == 0 else None), None + if unit["type"] == "image": + r = _images.send_image_to(client, member.wecom_userid, unit["content"]) + if r.get("errcode", 0) != 0: + logger.warning("[scheduler] 图片发送失败 %s: %s", unit["content"], r) + return r.get("errcode", 0) == 0, None, None + # text / question 都按文本发 + r = client.send_text(member.wecom_userid, unit["content"]) + if r.get("errcode", 0) != 0: + logger.warning("[scheduler] 文本发送失败: %s", r) + return r.get("errcode", 0) == 0, None, None + + +# --------------------------------------------------------------------------- +# 模板会话推进 +# --------------------------------------------------------------------------- +def _remove_session_jobs(sid: int): + sched = get_scheduler() + for job in sched.get_jobs(): + if job.id.startswith("step-%d-" % sid) or job.id.startswith("tmout-%d-" % sid): + try: + sched.remove_job(job.id) + except Exception: + pass + + +def _abort_member_session(member_id: int): + """中止该成员**所有**活动会话(新推送开始前调用;并发推送可能遗留多个)。""" + sm = get_sessionmaker() + sess = sm() + try: + stale = (sess.query(TemplateSession) + .filter(TemplateSession.member_id == member_id, + TemplateSession.status == "active") + .all()) + for s in stale: + s.status = "aborted" + s.updated_at = now_ts() + sess.commit() + _remove_session_jobs(s.id) + if stale: + logger.info("[scheduler] %d 个旧会话因新推送被中止 (member=%d)", + len(stale), member_id) + finally: + sess.close() + + +def _schedule_advance(sid: int, cur_step: int, unit_sent: dict, next_unit: dict): + """发送完 cur_step 后,按下一单元的触发时机安排推进任务。""" + sched = get_scheduler() + now = datetime.now() + # 未回复超时兜底:无论下一单元是延时还是等回复,超时到即推进 + timeout = unit_sent.get("reply_timeout_sec") + if timeout: + sched.add_job(_advance_session, trigger=DateTrigger(run_date=now + timedelta(seconds=timeout)), + args=[sid, cur_step, "timeout"], + id="tmout-%d-%d" % (sid, cur_step), replace_existing=True, + misfire_grace_time=STEP_MISFIRE_GRACE) + if next_unit["trigger_type"] == "after_reply": + # 等回复:不排定时任务,由 on_member_reply 推进(可选超时兜底) + with session_scope() as sess: + s = sess.get(TemplateSession, sid) + if s and s.status == "active": + s.waiting_for = "reply" + s.updated_at = now_ts() + return + # 上一条发出后延时推进 + delay = next_unit.get("delay_sec", 0) + sched.add_job(_advance_session, trigger=DateTrigger(run_date=now + timedelta(seconds=delay)), + args=[sid, cur_step, "delay"], + id="step-%d-%d" % (sid, cur_step), replace_existing=True, + misfire_grace_time=STEP_MISFIRE_GRACE) + + +def _advance_session(sid: int, expect_step: int, via: str): + """推进会话:发送下一条消息。expect_step 不匹配(已被推进过/中止)则忽略。""" + sm = get_sessionmaker() + sess = sm() + try: + s = sess.get(TemplateSession, sid) + if not s or s.status != "active" or s.cur_step != expect_step: + return + # 防御:同一成员只允许最新的 active 会话推进(并发推送可能遗留多个 active) + newest = (sess.query(TemplateSession.id) + .filter(TemplateSession.member_id == s.member_id, + TemplateSession.status == "active") + .order_by(TemplateSession.id.desc()).first()) + if newest and newest[0] != sid: + s.status = "aborted" + s.updated_at = now_ts() + sess.commit() + _remove_session_jobs(sid) + logger.info("[scheduler] 会话 %d 非最新活动会话,中止", sid) + return + member = sess.get(Member, s.member_id) + if not member or not member.enabled: + s.status = "aborted" + s.updated_at = now_ts() + sess.commit() + _remove_session_jobs(sid) + return + sid_, mid, tpl_id, rule_id, push_id = s.id, s.member_id, s.template_id, s.rule_id, s.push_id + sess.close() + + units = _load_units(tpl_id) + nxt = expect_step + 1 + if nxt >= len(units): + _finish_session(sid_) + return + fam_id = _first_family_id() + from .wecom import make_client + client = make_client() + unit = units[nxt] + ok, material_id, warning = _send_unit(client, member, unit, fam_id) + if warning: + notify.alert_admins(warning, level=2, reason="推送素材缺失") + if material_id: + materials.mark_used(material_id) + logger.info("[scheduler] 会话%d推进 step=%d via=%s ok=%s type=%s", + sid_, nxt, via, ok, unit["type"]) + + sm2 = get_sessionmaker() + sess = sm2() + s = sess.get(TemplateSession, sid_) + if not s or s.status != "active": + sess.close() + return + s.cur_step = nxt + s.waiting_for = "" + s.updated_at = now_ts() + sess.commit() + sess.close() + + # 问题步骤:设置等待反馈态 + if unit["type"] == "question" and ok: + fb_hours = int(_cfg.get_config().notify.get("wait_state_hours", 4)) + feedback.set_wait_state(mid, push_id, unit.get("question_key"), + unit.get("content"), hours=fb_hours) + # 还有下一条 → 安排推进;没有了 → 结束 + if nxt + 1 < len(units): + _schedule_advance(sid_, nxt, unit, units[nxt + 1]) + else: + _finish_session(sid_) + except Exception as e: + logger.exception("[scheduler] 会话推进异常 sid=%s: %s", sid, e) + finally: + try: + sess.close() + except Exception: + pass + + +def _finish_session(sid: int): + with session_scope() as sess: + s = sess.get(TemplateSession, sid) + if s and s.status == "active": + s.status = "done" + s.waiting_for = "" + s.updated_at = now_ts() + _remove_session_jobs(sid) + + +def on_member_reply(member_id: int, text: str = "", input_type: str = "text"): + """收到老人回复后调用: + - 会话进行中:把回复记录到 step_replies(对应到已发送的最新一步) + - 最新会话在等回复:立即推进下一条;顺带清理遗留旧会话""" + sm = get_sessionmaker() + sess = sm() + try: + actives = (sess.query(TemplateSession) + .filter(TemplateSession.member_id == member_id, + TemplateSession.status == "active") + .order_by(TemplateSession.id.desc()).all()) + if not actives: + return + newest = actives[0] + stale_ids = [] + # 防御:中止并发遗留的旧会话 + for s in actives[1:]: + s.status = "aborted" + s.updated_at = now_ts() + stale_ids.append(s.id) + sess.commit() + sid, cur, waiting = newest.id, newest.cur_step, newest.waiting_for + tpl_id, push_id = newest.template_id, newest.push_id + sess.close() + for old_id in stale_ids: + _remove_session_jobs(old_id) + # 记录该步的回复(无论下一步是等回复还是延时,都归到最新已发送的一步) + if text: + try: + units = _load_units(tpl_id) + q = units[cur]["content"] if 0 <= cur < len(units) else "" + with session_scope() as sess2: + sess2.add(StepReply( + member_id=member_id, template_id=tpl_id, + push_id=push_id, session_id=sid, + seq=cur + 1, question=q, answer=text, + input_type=input_type, created_at=now_ts())) + logger.info("[scheduler] 已记录回复 member=%d session=%d step=%d", + member_id, sid, cur + 1) + except Exception as e: + logger.exception("[scheduler] 记录回复失败 member=%s: %s", member_id, e) + if waiting != "reply": + return + _remove_session_jobs(sid) + _advance_session(sid, cur, "reply") + except Exception as e: + logger.exception("[scheduler] 回复推进异常 member=%s: %s", member_id, e) + finally: + try: + sess.close() + except Exception: + pass + + +# --------------------------------------------------------------------------- +# 推送入口 +# --------------------------------------------------------------------------- +def send_template_to_member(member: Member, template_id: int, rule_id=None, + family_id=None, deferrable=True) -> dict: + """对单个成员执行一次模板推送(首条立即发送,后续按步骤时机推进)。""" + if family_id is None: + family_id = _first_family_id() + if not member.enabled: + return {"skipped": "member disabled"} + + # 静默时段顺延 + if deferrable and _in_quiet_hours(member.quiet_hours or ""): + run_at = _quiet_end_dt(member.quiet_hours) + sched = get_scheduler() + sched.add_job(_deferred_send, trigger=DateTrigger(run_date=run_at), + args=[member.id, template_id, rule_id, family_id], + id="defer-%d-%d-%d" % (member.id, template_id, now_ts()), + replace_existing=False) + logger.info("[scheduler] %s 在静默时段,顺延到 %s", member.name, run_at) + return {"deferred": str(run_at)} + + # 幂等 + push_date = datetime.now().strftime("%Y-%m-%d") + if rule_id is not None: + if _push_exists(member.id, rule_id, push_date): + return {"skipped": "already pushed today"} + + units = _load_units(template_id) + if not units: + return {"error": "模板没有可发送的步骤: %s" % template_id} + + from .wecom import make_client + client = make_client() + + # 预览/测试(非库内成员):全部步骤顺序即时发送(旧行为) + if not getattr(member, "id", 0): + sent = 0 + warnings = [] + for i, unit in enumerate(units): + if i > 0 and unit["trigger_type"] == "delay": + time.sleep(min(unit.get("delay_sec", 0), 60)) + ok, material_id, warning = _send_unit(client, member, unit, family_id) + if warning: + warnings.append(warning) + notify.alert_admins(warning, level=2, reason="推送素材缺失") + if ok: + sent += 1 + if material_id: + materials.mark_used(material_id) + if warnings: + return {"ok": True, "steps": sent, "warnings": warnings} + return {"ok": True, "steps": sent} + + # 正式推送:中止旧会话 → 发第一条 → 建会话 → 安排推进 + _abort_member_session(member.id) + ok, material_id, warning = _send_unit(client, member, units[0], family_id) + if warning: + notify.alert_admins(warning, level=2, reason="推送素材缺失") + if material_id: + materials.mark_used(material_id) + + with session_scope() as sess: + p = Push(member_id=member.id, rule_id=rule_id, template_id=template_id, + push_date=push_date, material_id=material_id, + status="sent" if ok else "failed", sent_at=now_ts()) + sess.add(p) + sess.flush() + pid = p.id + s = TemplateSession(member_id=member.id, template_id=template_id, + rule_id=rule_id, push_id=pid, cur_step=0, + waiting_for="", status="active", + created_at=now_ts(), updated_at=now_ts()) + sess.add(s) + sess.flush() + sid = s.id + + # 首条是问题:设置等待反馈态 + if units[0]["type"] == "question" and ok: + fb_hours = int(_cfg.get_config().notify.get("wait_state_hours", 4)) + feedback.set_wait_state(member.id, pid, units[0].get("question_key"), + units[0].get("content"), hours=fb_hours) + + if len(units) > 1: + _schedule_advance(sid, 0, units[0], units[1]) + else: + _finish_session(sid) + + logger.info("[scheduler] 已推送 member=%s template=%d session=%d", + member.name, template_id, sid) + return {"ok": True, "steps": 1, "push_id": pid, "session_id": sid} + + +def send_template_to_userid(userid: str, template_id: int, family_id=None, deferrable=True) -> dict: + """给指定 userid(不一定是库内成员,如预览/测试)直接推送。""" + class _M: + id = 0 + wecom_userid = userid + name = userid + enabled = 1 + quiet_hours = "" + return send_template_to_member(_M(), template_id, rule_id=None, + family_id=family_id, deferrable=deferrable) + + +def _deferred_send(member_id, template_id, rule_id, family_id): + sm = get_sessionmaker() + sess = sm() + try: + m = sess.get(Member, member_id) + finally: + sess.close() + if m: + send_template_to_member(m, template_id, rule_id, family_id, deferrable=False) + + +def _push_exists(member_id, rule_id, push_date) -> bool: + sm = get_sessionmaker() + sess = sm() + try: + return sess.query(Push).filter( + Push.member_id == member_id, Push.rule_id == rule_id, + Push.push_date == push_date).first() is not None + finally: + sess.close() + + +# --------------------------------------------------------------------------- +# 规则执行 +# --------------------------------------------------------------------------- +def run_rule(rule_id: int): + sm = get_sessionmaker() + sess = sm() + try: + rule = sess.get(Rule, rule_id) + if not rule or rule.enabled != 1: + return + today = datetime.now().strftime("%Y-%m-%d") + if not _rule_in_date_range(rule, today): + logger.info("[scheduler] 规则 %d(%s) 不在起止日期内,跳过", rule_id, rule.name) + return + fam_id = _first_family_id() + mids = _parse_member_ids(rule.member_ids) + if mids: + members = [sess.get(Member, i) for i in mids] + else: + members = sess.query(Member).filter( + Member.family_id == fam_id, Member.role == "member", + Member.enabled == 1).all() + finally: + sess.close() + for m in members: + if m: + send_template_to_member(m, rule.template_id, rule_id, fam_id) + + +def manual_push(member_id: int, template_id: int = None, rule_id=None) -> dict: + sm = get_sessionmaker() + sess = sm() + try: + m = sess.get(Member, member_id) + if not m: + return {"error": "成员不存在"} + if template_id is None: + if rule_id: + rule = sess.get(Rule, rule_id) + template_id = rule.template_id if rule else None + if template_id is None: + return {"error": "缺少模板"} + fam_id = _first_family_id() + finally: + sess.close() + return send_template_to_member(m, template_id, rule_id, fam_id, deferrable=False) + + +# --------------------------------------------------------------------------- +# 维护 +# --------------------------------------------------------------------------- +def _daily_maintenance(): + """每日 23:00 维护:清过期等待态、僵尸会话、未回复升级、周日周报+体检。""" + logger.info("[scheduler] 每日维护开始") + feedback.clear_expired_wait_states() + _expire_stale_sessions() + _escalate_unanswered() + # 周日:周报 + 素材体检 + if datetime.now().weekday() == 6: # Monday=0, Sunday=6 + try: + weekly_report.generate_and_send(week_start=_this_monday()) + except Exception as e: + logger.warning("[scheduler] 周报生成失败: %s", e) + try: + materials.health_check() + except Exception as e: + logger.warning("[scheduler] 素材体检失败: %s", e) + + +def _expire_stale_sessions(): + """清理超过 48h 仍 active 的僵尸会话。""" + cutoff = now_ts() - SESSION_STALE_HOURS * 3600 + sm = get_sessionmaker() + sess = sm() + try: + stale = sess.query(TemplateSession).filter( + TemplateSession.status == "active", + TemplateSession.created_at < cutoff).all() + for s in stale: + s.status = "aborted" + s.updated_at = now_ts() + _remove_session_jobs(s.id) + sess.commit() + if stale: + logger.info("[scheduler] 清理僵尸会话 %d 个", len(stale)) + finally: + sess.close() + + +def _escalate_unanswered(): + """应回未回:有 question 的推送当天无反馈 → L1;连续多日无反馈由周报体现。""" + sm = get_sessionmaker() + sess = sm() + try: + from .db import Feedback, WaitState + today = datetime.now().strftime("%Y-%m-%d") + pushes = sess.query(Push).filter(Push.push_date == today, + Push.status == "sent").all() + for p in pushes: + # 该推送是否含 question(通过 template 的 steps 判断太重,简化:有 wait_state 曾设置过?) + # 简化:检查该成员当天是否有任何反馈 + has = sess.query(Feedback).filter( + Feedback.member_id == p.member_id, + Feedback.created_at >= now_ts() - 86400).first() + if not has: + # 仅告警一次/天(降噪由 notify 冷却处理) + notify.create_alert(p.member_id, 1, "今日未收到该成员回复", feedback_id=None) + finally: + sess.close() + + +def _this_monday(): + d = datetime.now() + monday = d - timedelta(days=d.weekday()) + return monday.strftime("%Y-%m-%d") + + +# --------------------------------------------------------------------------- +# 规则注册 / 启动 +# --------------------------------------------------------------------------- +def reload_rules(): + sched = get_scheduler() + # 移除旧 rule job + for job in sched.get_jobs(): + if job.id.startswith("rule-"): + sched.remove_job(job.id) + sm = get_sessionmaker() + sess = sm() + today = datetime.now().strftime("%Y-%m-%d") + n = 0 + try: + rules = sess.query(Rule).filter(Rule.enabled == 1).all() + for r in rules: + try: + if r.end_date and today > r.end_date: + continue # 已过期,不再注册 + sched.add_job(run_rule, trigger=_cron_for_rule(r), args=[r.id], + id="rule-%d" % r.id, replace_existing=True, + misfire_grace_time=900) + n += 1 + except Exception as e: + logger.warning("[scheduler] 规则 %d 注册失败: %s", r.id, e) + finally: + sess.close() + logger.info("[scheduler] 已加载 %d 条规则", n) + + +def start_scheduler(): + sched = get_scheduler() + if sched.running: + return + # 服务重启后,旧的进行中会话任务已丢失,全部中止(下次推送会新建) + with session_scope() as sess: + sess.query(TemplateSession).filter(TemplateSession.status == "active").update( + {TemplateSession.status: "aborted", TemplateSession.updated_at: now_ts()}) + reload_rules() + # 每日维护 + sched.add_job(_daily_maintenance, trigger=CronTrigger(hour=23, minute=0), + id="daily-maintenance", replace_existing=True) + sched.start() + logger.info("[scheduler] 已启动") diff --git a/app/seed.py b/app/seed.py new file mode 100644 index 0000000..6f8e413 --- /dev/null +++ b/app/seed.py @@ -0,0 +1,69 @@ +""" +app/seed.py — 首次启动播种(库为空时写入 config 里的 family/members/templates/rules/materials) + 之后在 Web 后台编辑,不回写 config.yaml。 +""" +import logging + +from .db import (Family, Member, Template, TemplateStep, Rule, Material, + get_sessionmaker, now_ts) +from .config import get_config + +logger = logging.getLogger("seed") + + +def seed_if_empty() -> bool: + sm = get_sessionmaker() + sess = sm() + try: + if sess.query(Family).first(): + return False + cfg = get_config() + fam = Family(name=cfg.family.get("name", "家里"), + timezone="Asia/Shanghai", created_at=now_ts()) + sess.add(fam) + sess.flush() + fid = fam.id + + for m in cfg.family.get("members", []): + sess.add(Member( + family_id=fid, wecom_userid=m["wecom_userid"], name=m.get("name", m["wecom_userid"]), + role=m.get("role", "member"), phone=m.get("phone", ""), + persona=("elder" if m.get("role") == "member" else "admin"), + quiet_hours=m.get("quiet_hours", "21:00-08:00"), + enabled=1, created_at=now_ts())) + + id_map = {} + for t in cfg.seed.get("templates", []): + tpl = Template(family_id=fid, name=t["name"], theme=t.get("theme"), + enabled=1, created_at=now_ts(), updated_at=now_ts()) + sess.add(tpl) + sess.flush() + id_map[t["id"]] = tpl.id + for s in t.get("steps", []): + sess.add(TemplateStep( + template_id=tpl.id, seq=s["seq"], type=s["type"], + content=s.get("content"), tag=s.get("tag"), + question_key=s.get("question_key"), + delay_sec=s.get("delay_sec", 0))) + + for r in cfg.seed.get("rules", []): + tid = id_map.get(r["template"]) + if tid is None: + logger.warning("[seed] 规则引用了未知模板 %s,跳过", r["template"]) + continue + sess.add(Rule(family_id=fid, member_id=None, days=r["days"], + at_time=r["at"], template_id=tid, priority=0, enabled=1)) + + for m in cfg.seed.get("materials", []): + sess.add(Material( + family_id=fid, tag=m["tag"], title=m["title"], url=m["url"], + platform=m.get("platform"), source=m.get("source"), + checked=0, alive=1, fail_count=0, used_count=0, created_at=now_ts())) + + sess.commit() + logger.info("[seed] 已播种 family=%s 模板=%d 规则=%d 素材=%d", + fid, len(cfg.seed.get("templates", [])), len(cfg.seed.get("rules", [])), + len(cfg.seed.get("materials", []))) + return True + finally: + sess.close() diff --git a/app/server.py b/app/server.py new file mode 100644 index 0000000..c169dcf --- /dev/null +++ b/app/server.py @@ -0,0 +1,29 @@ +""" +app/server.py — 服务入口 + 启动:建库 → 播种 → 启动调度器 → 起 FastAPI(uvicorn) + 运行:python3 -m app.server +""" +import logging + +from .db import init_db +from . import seed, scheduler +from .config import get_config +from .api import app + +logging.basicConfig(level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s") + + +def main(): + cfg = get_config() + init_db() + seed.seed_if_empty() + scheduler.start_scheduler() + import uvicorn + uvicorn.run(app, host=cfg.server.get("host", "0.0.0.0"), + port=int(cfg.server.get("port", 1064)), + log_level="info") + + +if __name__ == "__main__": + main() diff --git a/app/templates_render.py b/app/templates_render.py new file mode 100644 index 0000000..97fabc4 --- /dev/null +++ b/app/templates_render.py @@ -0,0 +1,60 @@ +""" +app/templates_render.py — 按 seq 渲染模板为「发送计划」 + 每个 step 变成一条待发送单元:text / question / material / image + material 步骤在渲染时按 tag 选素材(缺素材则跳过该步并告警) +""" +import logging + +from .db import Template, TemplateStep, get_sessionmaker +from . import materials + +logger = logging.getLogger("templates") + + +def build_push_plan(template_id: int, family_id: int): + """ + 返回 (plan, material_id, warnings) + plan: list of dict: + {type, content?, question_key?, delay_sec, material?{title,url}} + """ + sm = get_sessionmaker() + sess = sm() + warnings = [] + material_id = None + try: + tpl = sess.get(Template, template_id) + if not tpl or tpl.enabled != 1: + return None, None, ["模板不存在或已禁用: %s" % template_id] + steps = ( + sess.query(TemplateStep) + .filter(TemplateStep.template_id == template_id) + .order_by(TemplateStep.seq.asc()) + .all() + ) + plan = [] + for s in steps: + unit = { + "type": s.type, + "delay_sec": s.delay_sec or 0, + "seq": s.seq, + "trigger_type": s.trigger_type or "delay", + "reply_timeout_sec": s.reply_timeout_sec, + } + if s.type == "text": + unit["content"] = s.content or "" + elif s.type == "question": + unit["content"] = s.content or "" + unit["question_key"] = s.question_key + elif s.type == "material": + mat = materials.pick_material(family_id, s.tag) + if not mat: + warnings.append("素材缺失(tag=%s),跳过该步" % s.tag) + continue + unit["material"] = {"title": mat.title, "url": mat.url} + material_id = mat.id + elif s.type == "image": + unit["content"] = s.content or "" + plan.append(unit) + return plan, material_id, warnings + finally: + sess.close() diff --git a/app/web.py b/app/web.py new file mode 100644 index 0000000..b12c88d --- /dev/null +++ b/app/web.py @@ -0,0 +1,561 @@ +""" +app/web.py — 手机优先后台页面 + 六个功能页,纯 HTML+JS,调用 /api(凭登录 cookie 鉴权,无需 token)。 + 页面用相对 BASE(由 JS 按当前路径推算),无论从 http://ip:1064/ 还是 + https://域名/chatbot/ 访问都正常。未登录会被重定向到 /login。 + 路由在 api.py 末尾统一注册(带页面鉴权依赖)。 +""" +from fastapi import Request +from fastapi.responses import HTMLResponse + + +def _page(title, body): + return f""" + +{title} + + +
{title}
+ +
+ +{body} +""" + + +def _today(req: Request): + body = """ +""" + return HTMLResponse(_page("今日状态", body)) + + +def _feedback(req: Request): + body = """ +""" + return HTMLResponse(_page("看反馈", body)) + + +def _control(req: Request): + body = """ +""" + return HTMLResponse(_page("暂停/恢复", body)) + + +def _push(req: Request): + body = """ +""" + return HTMLResponse(_page("手动推", body)) + + +# --------------------------------------------------------------------------- +# 模板管理:列表 + 新建/编辑(多步消息编辑器:文字/图片、发送时机、未回复超时) +# --------------------------------------------------------------------------- +def _manage(req: Request): + body = """ +""" + return HTMLResponse(_page("模板管理", body)) + + +# --------------------------------------------------------------------------- +# 规则管理:列表 + 新建/编辑(名称/模板单选/成员多选/周期/起止日期) +# --------------------------------------------------------------------------- +def _rules(req: Request): + body = """ +""" + return HTMLResponse(_page("规则管理", body)) + + +# --------------------------------------------------------------------------- +# 每日记录:汇总入口页(后续更多内容挂这里)+ 模板回复总览子页面 +# --------------------------------------------------------------------------- +def _daily(req: Request): + body = """ +""" + return HTMLResponse(_page("每日记录", body)) + + +def _daily_replies(req: Request): + body = """ +""" + return HTMLResponse(_page("模板回复总览", body)) + + +routes = [ + {"path": "/", "func": _today, "methods": ["GET"]}, + {"path": "/feedback", "func": _feedback, "methods": ["GET"]}, + {"path": "/daily", "func": _daily, "methods": ["GET"]}, + {"path": "/daily/replies", "func": _daily_replies, "methods": ["GET"]}, + {"path": "/control", "func": _control, "methods": ["GET"]}, + {"path": "/push", "func": _push, "methods": ["GET"]}, + {"path": "/manage", "func": _manage, "methods": ["GET"]}, + {"path": "/rules", "func": _rules, "methods": ["GET"]}, +] diff --git a/app/wecom.py b/app/wecom.py new file mode 100644 index 0000000..2d7bc3f --- /dev/null +++ b/app/wecom.py @@ -0,0 +1,227 @@ +""" +app/wecom.py — 企业微信加解密 + 主动调用(从 spike/wecom.py 移入,已实测) + - WXBizMsgCrypt:回调消息加解密(AES-256-CBC + PKCS7 block=32) + - WeComClient:access_token 缓存、主动发消息、下载媒体、被动回复加密 +依赖标准库 + cryptography。 +""" +import base64 +import hashlib +import json +import os +import struct +import threading +import time +import urllib.parse +import urllib.request +import logging + +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +logger = logging.getLogger("wecom") + +_BLOCK = 32 # 企业微信 PKCS7 块大小 + + +def _pkcs7_pad(data: bytes) -> bytes: + pad = _BLOCK - (len(data) % _BLOCK) + if pad == 0: + pad = _BLOCK + return data + bytes([pad]) * pad + + +def _pkcs7_unpad(data: bytes) -> bytes: + pad = data[-1] + if pad < 1 or pad > _BLOCK: + raise ValueError("bad pkcs7 padding") + return data[:-pad] + + +class WXBizMsgCrypt: + def __init__(self, token: str, encoding_aes_key: str, receive_id: str): + if len(encoding_aes_key) != 43: + raise ValueError("EncodingAESKey 必须为 43 字符") + self.token = token + self.receive_id = receive_id + self.aes_key = base64.b64decode(encoding_aes_key + "=") # 32 bytes + self.iv = self.aes_key[:16] + + def _signature(self, *parts: str) -> str: + sha = hashlib.sha1() + sha.update("".join(sorted(parts)).encode("utf-8")) + return sha.hexdigest() + + def verify_signature(self, msg_signature: str, timestamp: str, nonce: str, encrypt: str) -> bool: + return self._signature(self.token, timestamp, nonce, encrypt) == msg_signature + + def decrypt(self, encrypt: str) -> str: + ct = base64.b64decode(encrypt) + cipher = Cipher(algorithms.AES(self.aes_key), modes.CBC(self.iv)) + dec = cipher.decryptor() + plain = dec.update(ct) + dec.finalize() + plain = _pkcs7_unpad(plain) + content = plain[16:] + msg_len = struct.unpack(">I", content[:4])[0] + msg = content[4:4 + msg_len] + rid = content[4 + msg_len:] + if rid.decode("utf-8") != self.receive_id: + raise ValueError("receive_id 不匹配") + return msg.decode("utf-8") + + def decrypt_message(self, msg_signature: str, timestamp: str, nonce: str, encrypt: str) -> str: + if not self.verify_signature(msg_signature, timestamp, nonce, encrypt): + raise ValueError("签名校验失败") + return self.decrypt(encrypt) + + def encrypt(self, text: str, nonce: str): + rand = os.urandom(16) + text_b = text.encode("utf-8") + msg = rand + struct.pack(">I", len(text_b)) + text_b + self.receive_id.encode("utf-8") + msg = _pkcs7_pad(msg) + cipher = Cipher(algorithms.AES(self.aes_key), modes.CBC(self.iv)) + enc = cipher.encryptor() + out = enc.update(msg) + enc.finalize() + encrypt = base64.b64encode(out).decode("ascii") + ts = str(int(time.time())) + sig = self._signature(self.token, ts, nonce, encrypt) + return encrypt, sig, ts + + def encrypt_reply(self, reply_xml: str, nonce: str) -> str: + encrypt, sig, ts = self.encrypt(reply_xml, nonce) + return ( + "" + "" + "" + "" + % (encrypt, sig, ts, nonce) + ) + + +class WeComClient: + """主动调用:access_token 缓存 + 发文本/图文 + 下载媒体。线程安全。""" + + def __init__(self, corp_id: str, secret: str, agent_id: int): + self.corp_id = corp_id + self.secret = secret + self.agent_id = agent_id + self._token = None + self._expire_at = 0 + self._lock = threading.Lock() + + def get_access_token(self, force: bool = False) -> str: + with self._lock: + if self._token and not force and time.time() < self._expire_at - 60: + return self._token + url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?" + urllib.parse.urlencode( + {"corpid": self.corp_id, "corpsecret": self.secret}) + data = self._get_json(url) + if data.get("errcode", 0) != 0: + raise RuntimeError("gettoken 失败: %s" % data) + self._token = data["access_token"] + self._expire_at = time.time() + data.get("expires_in", 7200) + return self._token + + def send_text(self, userid: str, text: str) -> dict: + token = self.get_access_token() + url = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=" + token + body = { + "touser": userid, + "msgtype": "text", + "agentid": self.agent_id, + "text": {"content": text}, + "safe": 0, + } + return self._post_json(url, body) + + def send_news(self, userid: str, title: str, url: str, desc: str = "", picurl: str = "") -> dict: + token = self.get_access_token() + api = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=" + token + body = { + "touser": userid, + "msgtype": "news", + "agentid": self.agent_id, + "news": {"articles": [{"title": title, "description": desc, + "url": url, "picurl": picurl}]}, + } + return self._post_json(api, body) + + def download_media(self, media_id: str, save_path: str) -> str: + token = self.get_access_token() + url = "https://qyapi.weixin.qq.com/cgi-bin/media/get?" + urllib.parse.urlencode( + {"access_token": token, "media_id": media_id}) + req = urllib.request.Request(url) + with urllib.request.urlopen(req, timeout=15) as resp: + data = resp.read() + with open(save_path, "wb") as f: + f.write(data) + return save_path + + def upload_media(self, file_path: str, media_type: str = "image") -> str: + """上传临时素材(multipart),返回 media_id(3 天有效)。""" + token = self.get_access_token() + url = ("https://qyapi.weixin.qq.com/cgi-bin/media/upload?" + "access_token=%s&type=%s" % (token, media_type)) + filename = os.path.basename(file_path) + with open(file_path, "rb") as f: + file_data = f.read() + boundary = "----codebuddy%s" % int(time.time() * 1000) + body = ( + ("--%s\r\nContent-Disposition: form-data; name=\"media\"; " + "filename=\"%s\"\r\nContent-Type: application/octet-stream\r\n\r\n" + % (boundary, filename)).encode("utf-8") + + file_data + + ("\r\n--%s--\r\n" % boundary).encode("utf-8") + ) + req = urllib.request.Request( + url, data=body, + headers={"Content-Type": "multipart/form-data; boundary=%s" % boundary}) + with urllib.request.urlopen(req, timeout=60) as resp: + data = json.loads(resp.read().decode("utf-8")) + if "media_id" not in data: + raise RuntimeError("素材上传失败: %s" % data) + return data["media_id"] + + def send_image(self, userid: str, media_id: str) -> dict: + token = self.get_access_token() + url = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=" + token + body = { + "touser": userid, + "msgtype": "image", + "agentid": self.agent_id, + "image": {"media_id": media_id}, + } + return self._post_json(url, body) + + @staticmethod + def _get_json(url: str) -> dict: + with urllib.request.urlopen(url, timeout=15) as resp: + return json.loads(resp.read().decode("utf-8")) + + @staticmethod + def _post_json(url: str, body: dict) -> dict: + data = json.dumps(body, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=15) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def make_client(cfg=None): + if cfg is None: + from .config import get_config + cfg = get_config() + return WeComClient(cfg.wecom_corp_id, cfg.wecom_secret, cfg.wecom_agent_id) + + +def make_crypt(cfg=None): + if cfg is None: + from .config import get_config + cfg = get_config() + return WXBizMsgCrypt(cfg.wecom_token, cfg.wecom_aes_key, cfg.wecom_receive_id) + + +def parse_xml(xml: str) -> dict: + """极简 XML 解析(企业微信回调字段有限,够用)。返回标签->文本。""" + import re + out = {} + for m in re.finditer(r"<(\w+)>(?:|([^<]*))", xml, re.S): + out[m.group(1)] = (m.group(2) if m.group(2) is not None else m.group(3) or "").strip() + return out diff --git a/app/weekly_report.py b/app/weekly_report.py new file mode 100644 index 0000000..14cc4c7 --- /dev/null +++ b/app/weekly_report.py @@ -0,0 +1,94 @@ +""" +app/weekly_report.py — 周报(SPEC §14 P2-1) + 每周日生成:反馈汇总 / 情绪趋势 / 未回复次数 / 本月短信花费。 + 生成后推送给 admin(企微),并入库 weekly_reports。 +""" +import logging +from datetime import datetime, timedelta + +from .db import (WeeklyReport, Feedback, Member, Notification, Alert, + get_sessionmaker, session_scope, now_ts) +from . import notify + +logger = logging.getLogger("weekly_report") + + +def build_content(week_start: str) -> str: + start = datetime.strptime(week_start, "%Y-%m-%d") + end = start + timedelta(days=7) + s_ts, e_ts = int(start.timestamp()), int(end.timestamp()) + + sm = get_sessionmaker() + sess = sm() + try: + fbs = sess.query(Feedback).filter( + Feedback.created_at >= s_ts, Feedback.created_at < e_ts).all() + members = {m.id: m.name for m in sess.query(Member).all()} + total = len(fbs) + by_mood = {} + by_risk = {0: 0, 1: 0, 2: 0, 3: 0} + for f in fbs: + by_mood[f.mood] = by_mood.get(f.mood, 0) + 1 + by_risk[f.risk_level] = by_risk.get(f.risk_level, 0) + 1 + # 未回复:本周有推送但无反馈的成员天数(简化:按推送数 - 反馈数估算) + # 短信花费:本月 + month_start = datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0) + sms_cost = sess.query(Notification).filter( + Notification.channel == "sms", + Notification.created_at >= int(month_start.timestamp())).with_entities( + Notification.cost_cny).all() + sms_cost = sum((c[0] or 0) for c in sms_cost) + alerts = sess.query(Alert).filter( + Alert.created_at >= s_ts, Alert.created_at < e_ts, + Alert.level >= 2).count() + finally: + sess.close() + + lines = [] + lines.append("📋 家人看护周报(%s ~ %s)" % (week_start, (end - timedelta(days=1)).strftime("%Y-%m-%d"))) + lines.append("— 反馈 %d 条" % total) + if by_mood: + lines.append("— 情绪: " + ",".join("%s %d" % (k, v) for k, v in by_mood.items())) + lines.append("— 风险: L0 %d / L1 %d / L2 %d / L3 %d" % ( + by_risk[0], by_risk[1], by_risk[2], by_risk[3])) + lines.append("— L2+ 告警 %d 次" % alerts) + lines.append("— 本月短信花费 ¥%.2f" % sms_cost) + if by_risk[3] or by_risk[2]: + lines.append("⚠️ 本周有高风险反馈,请关注。") + lines.append("(详情见后台「看反馈」)") + return "\n".join(lines) + + +def generate_and_send(week_start: str = None) -> str: + if week_start is None: + d = datetime.now() + monday = d - timedelta(days=d.weekday()) + week_start = monday.strftime("%Y-%m-%d") + content = build_content(week_start) + # 入库(唯一 family_id, week_start) + fam_id = 1 + sm = get_sessionmaker() + sess = sm() + try: + existing = sess.query(WeeklyReport).filter( + WeeklyReport.family_id == fam_id, WeeklyReport.week_start == week_start).first() + if existing: + existing.content = content + sess.commit() + wid = existing.id + else: + wr = WeeklyReport(family_id=fam_id, week_start=week_start, content=content) + sess.add(wr) + sess.flush() + wid = wr.id + sess.commit() + finally: + sess.close() + # 推送给 admin + try: + a = notify.create_alert(0, 1, "周报\n" + content) + notify.dispatch(a) + except Exception as e: + logger.warning("[weekly] 周报推送失败: %s", e) + logger.info("[weekly] 周报已生成 id=%s", wid) + return content diff --git a/config/config.example.yaml b/config/config.example.yaml new file mode 100644 index 0000000..4d1a0b2 --- /dev/null +++ b/config/config.example.yaml @@ -0,0 +1,129 @@ +# family-chat 配置文件(复制为 config.yaml 后修改) +# 安全:config.yaml 含模板/规则/素材,不含密钥;密钥只在 .env 中。 +# 本文件应提交 git(不含凭据),.env 不提交。 + +# --------------------------------------------------------------------------- +# 家庭与成员(一期通常一家,可多预留) +# members 中 role=admin 的是子女(可编辑 + 收告警); +# role=member 的是父母(只收推送)。 +# wecom_userid 必须与企业微信通讯录里的「账号」完全一致(区分大小写)。 +# 注意:企业微信通讯录目前成员为 WangYu / yaya / tiantian,按需填。 +# --------------------------------------------------------------------------- +family: + name: 家里 + members: + - wecom_userid: WangYu # ← 收推送的老人账号(按通讯录实际改) + name: 王瑜 + role: member + phone: "" # 短信告警用,可空 + quiet_hours: "21:00-08:00" + - wecom_userid: yaya # ← 收告警的子女账号(按通讯录实际改) + name: 牙牙 + role: admin + phone: "" + quiet_hours: "" + +# --------------------------------------------------------------------------- +# 推送服务端口(SPEC:避开 1060/1061/1062/2341/4999,学习山峰占 1063,故用 1064) +# 回调对外地址:https://<域名>/wecom/callback +# --------------------------------------------------------------------------- +server: + host: 0.0.0.0 + port: 1064 + # 对外基础地址,用于生成素材/回执等绝对链接(一般同 nginx 反代域名) + public_base_url: "https://acesimba.cloud" + +# --------------------------------------------------------------------------- +# ASR 双引擎 + 热词 +# 主引擎 16k_zh_dialect(含西安话/关中话);备用 16k_zh_en(低质量音频兜底) +# 热词权重 1-11,最多 128 个;改完即时生效(每次识别重读),无需重启。 +# --------------------------------------------------------------------------- +asr: + engine_primary: "16k_zh_dialect" + engine_secondary: "16k_zh_en" + # 置信度阈值(腾讯云字段若返回则生效;无则返回时仅在结果为空触发重试) + confidence_retry_below: 0.7 + confidence_accept_above: 0.5 + hotwords: + - "欢欢|11" + - "西安|8" + - "膝盖|9" + - "降压药|9" + - "头晕|9" + - "王瑜|9" + +# --------------------------------------------------------------------------- +# AI 打标(OpenAI 兼容接口,模型走 .env 的 AI_API_KEY / AI_BASE_URL / AI_MODEL) +# 要求返回严格 JSON:{"mood":"正面|中性|负面|低落","physical":"正常|不适|异常","risk":0-3,"reason":"<=20字"} +# 失败降级到关键词兜底,不影响主流程。 +# --------------------------------------------------------------------------- +ai: + enabled: true + timeout_sec: 15 + # 系统提示,约束女儿口吻与打标格式 + system_prompt: | + 你是帮子女照看独居父母的助手。下面是一段父母(老人)的回复文字。 + 请判断情绪与身体状况,并给出风险等级。只输出一个 JSON,不要任何解释。 + 格式:{"mood":"正面|中性|负面|低落","physical":"正常|不适|异常","risk":0-3,"reason":"不超过20字"} + risk:0正常 1关注 2注意 3紧急(紧急含摔倒/胸痛/喘不上气/轻生倾向等)。 + +# --------------------------------------------------------------------------- +# 通知路由(SPEC §10.3) +# L1 不通知(进周报);L2 企微;L3 企微+短信。 +# 短信默认关闭(需报备签名模板),打开见 .env 的 SMS_ENABLED。 +# --------------------------------------------------------------------------- +notify: + routing: + 1: [] + 2: ["wecom"] + 3: ["wecom", "sms"] + degrade: + wecom_failed_on_level_3: ["sms"] + sms_failed: ["email"] + budget: + sms_monthly_cny: 20 + # 降噪(SPEC §8.5) + cooldown_minutes: 30 # 同一成员同一级别冷却 + l2_daily_cap: 2 # L2 单日上限,超出合并进周报 + wait_state_hours: 4 # 等待反馈态超时 + +# --------------------------------------------------------------------------- +# 种子数据:首次启动(库为空)时写入。之后在 Web 后台编辑,不回写此文件。 +# --------------------------------------------------------------------------- +seed: + # 模板:steps 按顺序播放,最多一个 question + templates: + - id: exercise + name: 锻炼身体 + theme: 锻炼 + steps: + - {seq: 1, type: text, content: "妈,今天天气还行,活动活动筋骨没?"} + - {seq: 2, type: material, tag: 锻炼} + - {seq: 3, type: question, content: "跟着做了没?哪儿觉得累就跟我说,别硬撑。", question_key: did_exercise, delay_sec: 20} + - id: anti_fraud + name: 防诈骗提醒 + theme: 防诈骗 + steps: + - {seq: 1, type: text, content: "跟你说个最近挺多的事,别嫌我啰嗦。"} + - {seq: 2, type: material, tag: 防诈骗} + - {seq: 3, type: question, content: "最近有没有接到奇怪的电话或者短信?", question_key: met_scam} + - id: casual + name: 随便聊聊 + theme: 日常 + steps: + - {seq: 1, type: question, content: "今天干啥了?", question_key: daily} + + # 规则:days "1,3,5" 周一三五;"*" 每天;at "HH:MM" + rules: + - {days: "1,3,5", at: "09:00", template: exercise} + - {days: "2,4,6", at: "09:00", template: anti_fraud} + - {days: "7", at: "10:00", template: casual} + + # 素材:tag 与模板 material 的 tag 对应;url 为视频链接 + materials: + - {tag: 锻炼, title: "八段锦完整跟练", url: "https://www.bilibili.com/video/BV1cT4y1o7q1", platform: B站, source: official} + - {tag: 锻炼, title: "椅子操适合老人", url: "https://www.bilibili.com/video/BV1aT411u7G7", platform: B站, source: official} + - {tag: 锻炼, title: "晨起拉伸5分钟", url: "https://www.bilibili.com/video/BV1Lp4y1s7vV", platform: B站, source: media} + - {tag: 防诈骗, title: "冒充客服诈骗揭秘", url: "https://www.bilibili.com/video/BV1oP4y1k7hK", platform: B站, source: official} + - {tag: 防诈骗, title: "养老投资骗局", url: "https://www.bilibili.com/video/BV1cT4y1o7q1", platform: B站, source: official} + - {tag: 防诈骗, title: "保健品套路", url: "https://www.bilibili.com/video/BV1aT411u7G7", platform: B站, source: media} diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..4d1a0b2 --- /dev/null +++ b/config/config.yaml @@ -0,0 +1,129 @@ +# family-chat 配置文件(复制为 config.yaml 后修改) +# 安全:config.yaml 含模板/规则/素材,不含密钥;密钥只在 .env 中。 +# 本文件应提交 git(不含凭据),.env 不提交。 + +# --------------------------------------------------------------------------- +# 家庭与成员(一期通常一家,可多预留) +# members 中 role=admin 的是子女(可编辑 + 收告警); +# role=member 的是父母(只收推送)。 +# wecom_userid 必须与企业微信通讯录里的「账号」完全一致(区分大小写)。 +# 注意:企业微信通讯录目前成员为 WangYu / yaya / tiantian,按需填。 +# --------------------------------------------------------------------------- +family: + name: 家里 + members: + - wecom_userid: WangYu # ← 收推送的老人账号(按通讯录实际改) + name: 王瑜 + role: member + phone: "" # 短信告警用,可空 + quiet_hours: "21:00-08:00" + - wecom_userid: yaya # ← 收告警的子女账号(按通讯录实际改) + name: 牙牙 + role: admin + phone: "" + quiet_hours: "" + +# --------------------------------------------------------------------------- +# 推送服务端口(SPEC:避开 1060/1061/1062/2341/4999,学习山峰占 1063,故用 1064) +# 回调对外地址:https://<域名>/wecom/callback +# --------------------------------------------------------------------------- +server: + host: 0.0.0.0 + port: 1064 + # 对外基础地址,用于生成素材/回执等绝对链接(一般同 nginx 反代域名) + public_base_url: "https://acesimba.cloud" + +# --------------------------------------------------------------------------- +# ASR 双引擎 + 热词 +# 主引擎 16k_zh_dialect(含西安话/关中话);备用 16k_zh_en(低质量音频兜底) +# 热词权重 1-11,最多 128 个;改完即时生效(每次识别重读),无需重启。 +# --------------------------------------------------------------------------- +asr: + engine_primary: "16k_zh_dialect" + engine_secondary: "16k_zh_en" + # 置信度阈值(腾讯云字段若返回则生效;无则返回时仅在结果为空触发重试) + confidence_retry_below: 0.7 + confidence_accept_above: 0.5 + hotwords: + - "欢欢|11" + - "西安|8" + - "膝盖|9" + - "降压药|9" + - "头晕|9" + - "王瑜|9" + +# --------------------------------------------------------------------------- +# AI 打标(OpenAI 兼容接口,模型走 .env 的 AI_API_KEY / AI_BASE_URL / AI_MODEL) +# 要求返回严格 JSON:{"mood":"正面|中性|负面|低落","physical":"正常|不适|异常","risk":0-3,"reason":"<=20字"} +# 失败降级到关键词兜底,不影响主流程。 +# --------------------------------------------------------------------------- +ai: + enabled: true + timeout_sec: 15 + # 系统提示,约束女儿口吻与打标格式 + system_prompt: | + 你是帮子女照看独居父母的助手。下面是一段父母(老人)的回复文字。 + 请判断情绪与身体状况,并给出风险等级。只输出一个 JSON,不要任何解释。 + 格式:{"mood":"正面|中性|负面|低落","physical":"正常|不适|异常","risk":0-3,"reason":"不超过20字"} + risk:0正常 1关注 2注意 3紧急(紧急含摔倒/胸痛/喘不上气/轻生倾向等)。 + +# --------------------------------------------------------------------------- +# 通知路由(SPEC §10.3) +# L1 不通知(进周报);L2 企微;L3 企微+短信。 +# 短信默认关闭(需报备签名模板),打开见 .env 的 SMS_ENABLED。 +# --------------------------------------------------------------------------- +notify: + routing: + 1: [] + 2: ["wecom"] + 3: ["wecom", "sms"] + degrade: + wecom_failed_on_level_3: ["sms"] + sms_failed: ["email"] + budget: + sms_monthly_cny: 20 + # 降噪(SPEC §8.5) + cooldown_minutes: 30 # 同一成员同一级别冷却 + l2_daily_cap: 2 # L2 单日上限,超出合并进周报 + wait_state_hours: 4 # 等待反馈态超时 + +# --------------------------------------------------------------------------- +# 种子数据:首次启动(库为空)时写入。之后在 Web 后台编辑,不回写此文件。 +# --------------------------------------------------------------------------- +seed: + # 模板:steps 按顺序播放,最多一个 question + templates: + - id: exercise + name: 锻炼身体 + theme: 锻炼 + steps: + - {seq: 1, type: text, content: "妈,今天天气还行,活动活动筋骨没?"} + - {seq: 2, type: material, tag: 锻炼} + - {seq: 3, type: question, content: "跟着做了没?哪儿觉得累就跟我说,别硬撑。", question_key: did_exercise, delay_sec: 20} + - id: anti_fraud + name: 防诈骗提醒 + theme: 防诈骗 + steps: + - {seq: 1, type: text, content: "跟你说个最近挺多的事,别嫌我啰嗦。"} + - {seq: 2, type: material, tag: 防诈骗} + - {seq: 3, type: question, content: "最近有没有接到奇怪的电话或者短信?", question_key: met_scam} + - id: casual + name: 随便聊聊 + theme: 日常 + steps: + - {seq: 1, type: question, content: "今天干啥了?", question_key: daily} + + # 规则:days "1,3,5" 周一三五;"*" 每天;at "HH:MM" + rules: + - {days: "1,3,5", at: "09:00", template: exercise} + - {days: "2,4,6", at: "09:00", template: anti_fraud} + - {days: "7", at: "10:00", template: casual} + + # 素材:tag 与模板 material 的 tag 对应;url 为视频链接 + materials: + - {tag: 锻炼, title: "八段锦完整跟练", url: "https://www.bilibili.com/video/BV1cT4y1o7q1", platform: B站, source: official} + - {tag: 锻炼, title: "椅子操适合老人", url: "https://www.bilibili.com/video/BV1aT411u7G7", platform: B站, source: official} + - {tag: 锻炼, title: "晨起拉伸5分钟", url: "https://www.bilibili.com/video/BV1Lp4y1s7vV", platform: B站, source: media} + - {tag: 防诈骗, title: "冒充客服诈骗揭秘", url: "https://www.bilibili.com/video/BV1oP4y1k7hK", platform: B站, source: official} + - {tag: 防诈骗, title: "养老投资骗局", url: "https://www.bilibili.com/video/BV1cT4y1o7q1", platform: B站, source: official} + - {tag: 防诈骗, title: "保健品套路", url: "https://www.bilibili.com/video/BV1aT411u7G7", platform: B站, source: media} diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md new file mode 100644 index 0000000..50da2b2 --- /dev/null +++ b/deploy/DEPLOY.md @@ -0,0 +1,51 @@ +# 部署说明(family-chat v1) + +环境:腾讯云 Lighthouse Ubuntu,Python 3.12,nginx 已在运行,ffmpeg 已装。 +服务器 IP:124.222.157.124。内部端口 **1064**(避开 1060/1061/1062/2341/4999;1063 被「学习山峰」占用,不动)。 + +## 1. 依赖安装(系统 Python,与 spike 一致) +```bash +pip install --break-system-packages -r requirements.txt +# 关键依赖:fastapi uvicorn sqlalchemy apscheduler pyyaml jinja2 cryptography tencentcloud-sdk-python +``` + +## 2. 配置 +- `.env`(已存在,含密钥,**不提交 git**):企业微信 + 腾讯云 ASR + AI 密钥。 + 需补充:`ADMIN_TOKEN=改一个强口令`(后台登录用)。可选:`AI_API_KEY/AI_BASE_URL/AI_MODEL`、`SMS_*`。 +- `config/config.yaml`(已存在,可提交):成员、模板、规则、素材、热词、通知路由。 + 首次启动若只有 `config.example.yaml`,复制一份为 `config.yaml` 再改。 + +## 3. 数据库 +SQLite 在 `data/family.db`(WAL)。首次启动自动建表并播种 `config.yaml` 的种子数据。 +重置:`systemctl stop family-chat && rm -f data/family.db* && systemctl start family-chat`。 + +## 4. 常驻服务(systemd) +```bash +cp deploy/family-chat.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable --now family-chat +systemctl status family-chat +``` +改代码/配置后:`systemctl restart family-chat`。 + +## 5. nginx 反代(新增 conf.d,不改现有站点) +```bash +cp deploy/nginx-wechatbot.conf /etc/nginx/conf.d/wechatbot.conf +nginx -t && systemctl reload nginx +``` +- 回调:`https://acesimba.cloud/wecom/callback` → 1064 +- 后台:`https://acesimba.cloud/chatbot/` → 1064(剥离 /chatbot 前缀) + +## 6. 企业微信后台(人工,代码不管) +1. 应用名/logo 设成家属称呼与本人照片(C1 约束:让老人觉得是女儿发的)。 +2. 「接收消息 → API 接收」URL 填 `https://acesimba.cloud/wecom/callback`,Token/AESKey 取自 `.env`。 + **等本服务起来后再点保存**(否则验证不过)。 +3. 「微信插件」勾选允许在微信里收发;让老人微信扫码关注。 +4. 通讯录成员账号须与 `config.yaml` 的 `wecom_userid` 完全一致。 + +## 7. 验证闭环 +- 健康检查:`curl https://acesimba.cloud/wecom/callback` 应被拒绝(无参数); + 带正确参数时回显 echostr(保存验证)。 +- 手动推一条:后台「手动推」选成员+模板;老人微信应收到三段消息。 +- 老人用文字/语音回复 → 后台「看反馈」出现记录与风险标记;命中 L3 时子女企微收到提醒。 +- 周报:后台「编辑」页点「生成周报」,或每周日自动生成并推子女。 diff --git a/deploy/family-chat.service b/deploy/family-chat.service new file mode 100644 index 0000000..258e39d --- /dev/null +++ b/deploy/family-chat.service @@ -0,0 +1,14 @@ +[Unit] +Description=family-chat 家人看护推送系统 +After=network.target + +[Service] +Type=simple +WorkingDirectory=/home/chatbot +ExecStart=/usr/bin/python3 -m app.server +Restart=always +RestartSec=5 +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/nginx-wechatbot.conf b/deploy/nginx-wechatbot.conf new file mode 100644 index 0000000..fe14e5f --- /dev/null +++ b/deploy/nginx-wechatbot.conf @@ -0,0 +1,60 @@ +# ============================================================ +# family-chat 回调反代(路径方式,复用 acesimba.cloud 现有证书) +# +# 回调地址: https://acesimba.cloud/wecom/callback +# +# 说明: +# - 子域名 wechatbot.acesimba.cloud 配不通,改用路径方式挂在已配置好的 +# acesimba.cloud 下面,复用其现有 Let's Encrypt 证书(无需 certbot)。 +# - 1063 端口被「学习山峰」(/home/study-mountion/backend) 占用, +# 依 SPEC「不碰学习山峰」原则,本服务改用 1064。 +# - 本文件是【新增】的 conf.d 片段,不修改任何现有 nginx 配置。 +# +# 应用方式(确认后执行): +# cp deploy/nginx-wechatbot.conf /etc/nginx/conf.d/wechatbot.conf +# nginx -t && systemctl reload nginx +# ============================================================ + +server { + listen 80; + server_name acesimba.cloud; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name acesimba.cloud; + + # 复用 acesimba.cloud 现有证书 + ssl_certificate /etc/letsencrypt/live/acesimba.cloud/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/acesimba.cloud/privkey.pem; + + # 企业微信回调 → 本机 1064(app 监听 /wecom/callback) + location /wecom/ { + proxy_pass http://127.0.0.1:1064; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 后台登录页(密码登录,app 监听 /login)→ 本机 1064 + # 未登录访问 /chatbot/ 会被后端 302 跳到 /login,故此处需放开。 + location = /login { + proxy_pass http://127.0.0.1:1064; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 手机后台(今日/反馈/暂停/手动推/编辑)→ 本机 1064,剥离 /chatbot 前缀 + # 访问:https://acesimba.cloud/chatbot/ (页面内 JS 用 BASE 自适应) + location /chatbot/ { + proxy_pass http://127.0.0.1:1064/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/doc/RISK_AND_PLAN.md b/doc/RISK_AND_PLAN.md new file mode 100644 index 0000000..df05497 --- /dev/null +++ b/doc/RISK_AND_PLAN.md @@ -0,0 +1,173 @@ +# family-chat 风险清单与实施计划 + +> 配套文档:`SPEC.md`(v1.0)。本文件只做两件事: +> 1. 把上线前必须看清的风险列成可跟踪的清单; +> 2. 给出分阶段、可验收的实施路线。 +> +> 状态:规划阶段,未开始编码。 +> 日期:2026-08-31 + +--- + +## 一、风险清单(Risk Register) + +严重程度:🔴 阻断级(不过此关项目不成立)|🟠 高(会显著影响效果/成本)|🟡 中(可缓解,需留意)|🟢 低(已知可接受) + +| ID | 风险 | 等级 | 影响 | 验证 / 触发条件 | 缓解措施 | +|---|---|---|---|---|---| +| R1 | **企业微信「微信插件」在 2026 仍能让普通微信用户收发自建应用消息** | 🔴 | 整条 C1 链路地基。若插件失效或受限,老人无法在微信里收发,项目核心假设崩塌 | Spike 阶段端到端验证:建组织→建应用→开插件→服务端发消息→微信回复看回调 | 先跑 Spike(见 §二 Phase 0)。若失败,回退方案:家属改用企业微信 App 收发(需装 App,违背"不装新 App"但功能保住);或评估其他合规通道 | +| R2 | **C1 身份伪装不彻底** | 🟠 | 老人收到的不是"女儿个人号私聊",而是"企业微信服务消息"形态,详情页/通讯录仍露企业信息 | 产品预期管理:和家属明确告知这是形态限制,非 bug | 头像用真人照片、名字用家里称呼、口吻用 persona;不掩饰残留差异(SPEC 9 节已诚实列出) | +| R3 | **ASR 置信度字段缺失/改名导致重试逻辑失效** | 🟠 | 8.2 的双引擎兜底依赖 `Confidence` 字段;若接口不返回,低质量语音无法触发兜底,转写直接废 | 实测 `SentenceRecognition` 响应结构,确认 `Confidence` 是否存在 | 代码写成可选字段兼容:有字段按阈值判,无字段仅在"结果为空"时触发重试(SPEC 8.2 已要求) | +| R4 | **关中话 / 小声远距语音识别准确率不足** | 🟠 | 老人多半声音小、离手机远,识别错会直接误判身体状况 | P1 验收:普通话 + 关中话各测;再录"人声小、离半米"一条验证 `16k_zh_en` 兜底 | 双引擎兜底 + 热词表(膝盖/降压药/头晕等)+ 原始 amr 存 30 天供回听调优 | +| R5 | **素材链接体检误判** | 🟡 | 小红书/抖音对无头请求或全 200 或全拒,可能误杀优质素材 | 体检实现后用 `source=official` 链接优先;观察误杀率 | 看"连续"失败次数而非单次;优先 official 源;误杀仅降权,连续 2 次才剔除(SPEC 7.2 已定) | +| R6 | **告警疲劳 / 误报致家属忽略** | 🟠 | 误报几次后家属开始忽略告警,整套系统失效 | 上线前 2 周只记录不通知,用真实数据校准阈值 | 降噪规则(30min 冷却 / L2 单日≤2 / 已 resolved 不重发 / 连续 2-3 天才升级)+ 静默期(SPEC 8.5) | +| R7 | **触碰服务器现有服务 / 端口冲突** | 🟡 | 影响 words / 学习山峰 / gitweb / nginx 现有站点,属高危操作 | 部署时核对端口、nginx 配置 | 只新增 server 块反代,**不改现有 nginx 配置**;内部端口固定 **1063**(1060/1061/1062/2341/4999 禁用);不碰 words 等现有服务(SPEC 3、16、17) | +| R8 | **access_token 失效 / 缓存异常** | 🟡 | 所有主动发消息、素材下载依赖 token | 复用 `app/wecom.py`(已测试),联调时观察 token 刷新 | 依赖现有已测实现;加监控告警 token 获取失败 | +| R9 | **等待态边界混淆** | 🟡 | 老人主动发起的闲聊被误当成某问题的反馈,或 4h 超时后漏判 | 代码区分"等待反馈态"与"普通陪聊态" | 等待态默认 4h 超时自动清除;超时前半夜一句话不被当成白天问题反馈(SPEC 8.1) | +| R10 | **短信报备周期阻塞 L3 第二通道** | 🟡 | 签名+模板审核 1-3 天,且变量位有限(腾讯云 `{1}{2}`),上线初期 L3 可能只有企微 | 尽早启动报备,不卡主流程 | `SMS_ENABLED` 开关 + 月度预算 ¥20 双保险;报备完成前 L3 仅走企微(SPEC 10.4) | +| R11 | **静默时段与推送冲突** | 🟢 | 默认 quiet 21:00-08:00,推送 09:00/周日 10:00 均不在静默内,正常 | 配置时核对时间点 | 落在静默内的推送顺延到时段结束后 30min(SPEC 6) | +| R12 | **微信插件消息送达率 / 后台限制** | 🟡 | 老人微信可能不弹通知、或不及时,导致"应回未回" | 实测老人手机收推送的及时性与通知展示 | 周报统计未回复;连续未回升级 L1→L2→L3;建议每周至少 1 条真人语音兜底(SPEC 9) | +| R13 | **隐私与数据安全** | 🟡 | 老人语音(含健康信息)存 30 天、反馈入库,存在泄露风险 | 服务器仅家人可访问;`.env` 不入库;数据库文件权限控制 | 原始音频加密/限访问;DB 不对外开放;遵守"不骗老人"底线,数据用途透明 | +| R14 | **成本失控** | 🟡 | ASR / SMS / AI token 意外放大 | 监控 `notifications.cost_cny` 与 ASR 调用量 | 双引擎仅低置信度才二次调用;SMS 月度预算上限;AI 用小模型 + 严格 JSON 省 token(SPEC 8.4、10.3) | +| R15 | **OAuth 鉴权依赖域名 / 开发后门遗留** | 🟡 | 后台用企业微信 OAuth 需可信域名(纯 IP 不行);`ADMIN_TOKEN` 开发绕过若留生产是漏洞 | 部署前强制关闭 `ADMIN_TOKEN`;OAuth 必须配域名 | 配置开关区分 dev/prod;生产禁用令牌绕过(SPEC 11.2) | +| R16 | **跨天升级 job 规格未显式定义** | 🟡 | "连续 2/3 天负面""连续 3 天无反馈"需跨日扫描,SPEC 未单列该 job | 实施时在调度里补一条每日 00:30 扫描 job | 由 feedbacks/alerts 历史聚合判定,写入 alerts 或置 risk_level,再走通知路由 | +| R17 | **V1 复用边界误用** | 🟢 | 误改不兼容的 `store.py / bot.py / scheduler.py` | 代码评审时核对 | 仅复用 `wecom.py / asr.py / ai.py / smoke.py`;其余重写(SPEC 15) | +| R18 | **misfire 超 15min 当天不补发** | 🟢 | 服务器 09:00 起 15min 外宕机,当天推送丢失 | 监控服务存活;告警推送失败 | 已知且接受(半夜补发比不发更糟);记日志,周报体现缺失(SPEC 6) | +| R19 | **热词表每次识别读取的性能** | 🟢 | 每次 ASR 读配置,量极小可忽略 | — | 可选内存缓存 + 文件 mtime 失效;一期直接读文件即可 | +| R20 | **同时刻多规则冲突** | 🟢 | 同日同时刻多规则可能重复推送 | 单元测试同刻多规则 | 取 `priority` 最高,其余跳过并记日志(SPEC 6) | + +### 风险处置优先级 + +``` +必须先解:R1(Spike)→ R3(接口实测) +上线前必须解:R6(静默校准)、R10(短信报备启动)、R15(后门关闭) +持续监控:R4、R5、R12、R13、R14 +已知接受:R11、R18、R19、R20 +``` + +--- + +## 二、实施计划(分阶段、可验收) + +> 总原则(SPEC 17):只新增不修改不碰现有服务;高风险操作先列确认再执行;端口冲突主动上报;改完 git commit 写清原因;诚实告知成本与风险。 + +### Phase 0 · 可行性 Spike(最高优先,约 0.5 天) + +**目标**:在写任何推送引擎前,验证 R1 是否成立。 + +- [ ] 注册企业微信组织(不认证,免费),组织名起得像家 +- [ ] 建自建应用:应用名=家里称呼、logo=家属照片、可见范围=全家,记 AgentId/Secret +- [ ] 应用详情填企业可信 IP = 服务器公网 IP +- [ ] 微信插件:勾选"允许成员在微信插件中接收和回复";家人微信扫码关注 +- [ ] 通讯录添加成员 `mom`,与 `members.wecom_userid` 一致 +- [ ] 用 `app/wecom.py`(复用)向 `mom` 主动发一条文本 +- [ ] **用老人微信回复一条,确认回调能收到消息** +- [ ] 结论:R1 通过 → 继续;R1 失败 → 启动回退方案(见 R1 缓解) + +**验收**:服务端发出、老人微信收到、老人回复被服务端回调捕获,三件事全成立。 + +**出口闸门**:R1 不过,不进入 P0。 + +--- + +### P0 · 推送引擎(先跑通"推得出去") + +| 任务 | 内容 | 对应 SPEC | 依赖 | +|---|---|---|---| +| P0-1 | `app/schema.sql` 建 10 张表 + 初始化迁移 | §4 | — | +| P0-2 | `app/store.py` 重写,CRUD 覆盖新表 | §4 | P0-1 | +| P0-3 | `app/template.py` 按 seq 渲染 steps(text/material/question/image) | §5 | P0-2 | +| P0-4 | `app/materials.py` 实现 7.1 轮换查询(抽查优先、未用优先) | §7.1 | P0-2 | +| P0-5 | `app/scheduler.py` 重写:cron 每 rule 一 job、幂等、静默顺延、misfire、素材缺失跳过 | §6 | P0-2/3/4 | +| P0-6 | `config/config.example.yaml` 加 templates/rules/materials 三段 + 端口 1063 | §5、§3 | — | +| P0-7 | 联调:手动触发一次推送,微信看到三段消息 | §14 P0-7 | P0-1~6 | + +**验收**:`POST /api/push/mom` 能在微信收到"文本 + 视频链接 + 提问"三条,且重复触发不产生第二条(`pushes` 唯一约束生效)。 + +--- + +### P1 · 反馈闭环、告警、手机后台 + +| 任务 | 内容 | 对应 SPEC | 依赖 | +|---|---|---|---| +| P1-1 | 反馈采集:等待态(4h 超时)+ 归档进 `feedbacks`;区分普通陪聊 | §8.1 | P0 | +| P1-2 | `app/asr.py` 改双引擎兜底(8.2)+ ffmpeg 8k→16k 重采样 + 热词表(**先实测 R3**) | §8.2 | P0、R3 | +| P1-3 | 风险打标:关键词先跑(命中直接 L3)→ 小模型 JSON 打标 L1/L2 | §8.3/8.4 | P1-1 | +| P1-4 | 通知中心:`Notifier` 协议 + 企微实现 + 路由 + 幂等 + 账本 | §10 | P1-3 | +| P1-5 | 降噪:30min 冷却、L2 单日≤2、已 resolved 不重发、连续 2-3 天升级 job(补 R16) | §8.5 | P1-4 | +| P1-6 | 手机优先 Web 后台:今日状态 / 看反馈 / 暂停恢复 / 手动推一条 | §11 | P1-4 | + +**验收**: +1. 老人发语音"我摔了一跤" → 转文字 → 命中关键词 L3 → 家属企微收到告警 → 后台可见该反馈; +2. **普通话 + 关中话各测一遍**确认 `16k_zh_dialect` 转写正确;再录"人声小、离半米"一条确认 `16k_zh_en` 兜底救回; +3. 热词表生效:含"膝盖"的话不被识别成别字。 + +--- + +### P2 · 收尾 + +| 任务 | 内容 | 对应 SPEC | 依赖 | +|---|---|---|---| +| P2-1 | 周报:每周日生成,含反馈汇总/情绪趋势/未回复次数/本月短信花费 | §14 P2-1 | P1 | +| P2-2 | 素材体检:每周 HEAD 检测 + 存活数不足告警 | §7.2 | P0-4 | +| P2-3 | OAuth 鉴权(需域名) | §11.2 | 域名就位 | +| P2-4 | 短信通道接入(含签名/模板报备,启动 R10) | §10.4 | 报备完成 | + +--- + +## 三、上线策略(关键,防告警疲劳) + +``` +P2 完成后 ──► 灰度试运行 2 周: + 推送照常发、反馈照常采集与打标、告警【只入库不通知】 + ↓ + 用真实数据观察:误报率、方言识别准确率、未回复率 + ↓ + 校准阈值(冷却/上限/升级天数/关键词) + ↓ + 打开通知 → 正式运行 +``` + +> 依据 SPEC 8.5:误报几次后家属会忽略告警,整套系统白做。此 2 周静默期是硬要求,不可跳过。 + +--- + +## 四、待家属确认事项(开工前阻塞项) + +来自 SPEC §12,落到本计划作为前置条件: + +| # | 待确认 | 阻塞阶段 | 负责 | +|---|---|---|---| +| 1 | 方言已确认(接近普通话 + 关中话) | 已定 | — | +| 2 | admin 企业微信 userid + 手机号 | P1(告警接收人) | 家属 | +| 3 | 老人通讯录账号(mom/dad) | P0 Spike | 家属 | +| 4 | 域名与子域(回调 + 后台 OAuth 必需) | P0 / P2-3 | 家属/环境 | +| 5 | 短信服务商与签名主体 | P2-4 | 家属 | +| 6 | 推送时间点(暂 09:00 / 周日 10:00) | P0 配置 | 家属 | +| 7 | 素材平台优先级(小红书/抖音/视频号/B站) | P0 素材 | 家属 | + +--- + +## 五、运维与部署注意 + +- 服务器:124.222.157.124(腾讯云 Lighthouse,Ubuntu);内部端口 **1063**;nginx 已运行,**只加 server 块**;域名已备案,建议子域 `family.<域名>`。 +- 部署:systemd 常驻(与现有服务一致);不改现有 nginx 站点;不碰 words/学习山峰/gitweb/现有配置/腾讯云 stargate 监控。 +- 已具备:nginx、ffmpeg、python3 3.12.3(venv)、git、certbot、pm2、node。 +- 文档同步:每完成一阶段更新 `HANDOFF.md` 进度(SPEC 17.6)。 + +--- + +## 六、风险→任务对照速查 + +| 风险 | 由哪个阶段兜底 | +|---|---| +| R1 | Phase 0 Spike | +| R3 | P1-2(先实测) | +| R4 | P1-2 + P1 验收 2/3 | +| R5 | P2-2 | +| R6 | 上线策略 2 周静默 | +| R7 | 部署注意(§五) | +| R10 | P2-4(尽早启动报备) | +| R15 | P2-3 / 部署检查 | +| R16 | P1-5 补 job | +| R17 | 代码评审 | diff --git a/doc/SPEC.md b/doc/SPEC.md new file mode 100644 index 0000000..7195821 --- /dev/null +++ b/doc/SPEC.md @@ -0,0 +1,685 @@ +# family-chat 开发规格说明书 + +> **用途**:这份文件是给执行开发的 AI 看的施工图纸,自包含,不依赖任何历史对话。 +> 读完即可开工。标注【已定】的条目不要再讨论方案,直接执行。 +> 设计论证(选型理由、调研证据)见 `docs/DESIGN.md`,本文档只讲"做什么、怎么做、验收标准"。 + +- 版本:v1.0 +- 日期:2026-08-31 +- 目标环境:云端 AI 开发环境(git / 调试 / 带备案域名均已具备) + +--- + +## 1. 项目是什么 + +给家里老人做的**微信看护推送系统**。 + +每天按星期推送不同主题的内容(周一三五锻炼、周二四六防诈骗),内容含文本 + 视频链接 + 一个提问; +老人用**文字或语音**回复;系统回收反馈、判断情绪与身体状况, +异常时按 L1/L2/L3 分级通知家属(企业微信 / 短信),每周出一份周报。 + +### 1.1 老人视角的完整链路 + +``` +早上 09:00,微信消息列表顶部弹出一条未读 + 头像:女儿的照片 + 名字:女儿在家里的称呼 + 内容:"妈,今天天气还行,活动活动筋骨没?" + → 跟着一条视频链接(按主题轮换,不重复) + → 再跟一句:"跟着做了没?哪儿觉得累就跟我说,别硬撑。" + +老人点进去,按住说话:"做啦,就是膝盖有点疼" + → 语音转文字 → 存为反馈 → 判断:身体不适,L2 + → 女儿的企业微信收到一条提醒 +``` + +### 1.2 三条不可妥协的约束 + +| # | 约束 | 原因 | +|---|---|---| +| C1 | **消息必须看起来是女儿本人发的** | 老人收到的不是"系统通知",是"女儿的关心"。头像是她本人照片、名字是家里称呼、口吻是女儿。见第 9 节 | +| C2 | **合规,不碰微信个人号自动化** | Hook / 协议逆向会导致永久封号且不可申诉,连带微信支付。已排除 | +| C3 | **被直接问"是不是机器人"必须如实说** | 骗老人一旦识破,伤害比不用这套系统更大 | + +--- + +## 2. 技术选型【已定】 + +| 项 | 选型 | 一句话理由 | +|---|---|---| +| 通道 | 企业微信**自建应用 + 微信插件** | 官方 API,零封号风险,家人在自己微信里收发、不装新 App,个人免费注册组织(无需营业执照、无需认证) | +| 后端 | FastAPI + SQLite(WAL) | 与现有项目栈一致;预留 PostgreSQL 迁移 | +| 调度 | APScheduler(应用内) | 规则变更可热重载 | +| 语音 | 腾讯云 ASR 一句话识别 | 免费额度够用;**方言需换引擎,见第 12 节待确认** | +| AI | OpenAI 兼容接口,模型走环境变量 | 换模型只改 `.env` | +| 部署 | systemd 常驻 | 与现有服务一致 | + +### 2.1 已排除的方案(不要再提) + +- **itchat / 微信个人号 Hook**:2026 年已大面积失效,且封号永久 +- **公众号订阅号**:只能被动回复,5 秒超时,无法每天主动找人聊 +- **公众号服务号**:每月 4 次群发 + 300 元/年认证 +- **企业微信「客户联系」(外部联系人)**:看似是普通微信好友对话,但 + ① 主动推送只能走群发,每客户每天 1 条,**且必须成员在 App 手动点确认** + ② **没有任何接口可以自动回复外部联系人**(官方技术支持原话:"暂无相关接口支持您所描述的功能") + ③ 唯一能回的 `externalcontact/message/send` 仅限教育行业 K12 +- **电话外呼**:一期不做,先看微信回复率数据 + +--- + +## 3. 运行环境 + +| 项 | 值 | 备注 | +|---|---|---| +| 部署服务器 | 124.222.157.124(腾讯云 Lighthouse,Ubuntu) | 若云端开发环境的部署目标不同,以实际为准 | +| **内部端口** | **1063** | ⚠️ 1060/1061/1062 已被 words-v1/words-v2/words 占用,**不可使用** | +| 对外端口 | 80 / 443 | nginx 已在运行,占用中。**新增 server 块反代即可,不要改现有配置** | +| 域名 | 由云端环境提供(已备案) | 回调与 Web 后台都挂它下面。建议子域 `family.<你的域名>` | +| HTTPS | certbot 可签,或复用现有证书体系 | 企业微信回调支持 http,但 Web 后台 OAuth 建议 https | + +**服务器上已具备**:nginx、ffmpeg(语音转码要用)、python3 3.12.3(venv 可用)、git、certbot、pm2、node。环境无需额外配置。 + +**服务器上不要碰**:words / words-v1 / words-v2 / 学习山峰 / gitweb / nginx 现有站点配置 / 腾讯云 stargate 监控。 + +--- + +## 4. 数据模型 + +10 张表,SQLite 语法。关键约束已标注用途。 + +```sql +-- 家庭(一期只有一行,为多家庭预留) +CREATE TABLE families ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + timezone TEXT NOT NULL DEFAULT 'Asia/Shanghai', + created_at INTEGER NOT NULL +); + +-- 成员:role 是整个权限模型的核心 +CREATE TABLE members ( + id INTEGER PRIMARY KEY, + family_id INTEGER NOT NULL REFERENCES families(id), + wecom_userid TEXT NOT NULL UNIQUE, -- 企业微信通讯录"账号",如 mom / dad + name TEXT NOT NULL, + role TEXT NOT NULL, -- admin=子女(可编辑+收告警) / member=父母(只收推送) + phone TEXT, -- 短信告警用 + persona TEXT NOT NULL DEFAULT 'elder', + quiet_hours TEXT DEFAULT '21:00-08:00', + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL +); + +CREATE TABLE templates ( + id INTEGER PRIMARY KEY, + family_id INTEGER NOT NULL, + name TEXT NOT NULL, -- "锻炼身体" + theme TEXT, -- 主题标签,用于素材检索与周报归类 + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +-- 一次推送按顺序播放的"配方" +CREATE TABLE template_steps ( + id INTEGER PRIMARY KEY, + template_id INTEGER NOT NULL REFERENCES templates(id) ON DELETE CASCADE, + seq INTEGER NOT NULL, + type TEXT NOT NULL, -- text | material | question | image + content TEXT, -- text/question 的话术 + tag TEXT, -- material 的素材标签 + question_key TEXT, -- question 的字段名,反馈按它归档 + delay_sec INTEGER NOT NULL DEFAULT 0, -- 与上一条的间隔,模拟真人节奏 + UNIQUE(template_id, seq) +); + +CREATE TABLE rules ( + id INTEGER PRIMARY KEY, + family_id INTEGER NOT NULL, + member_id INTEGER, -- NULL=全家适用 + days TEXT NOT NULL, -- "1,3,5" 周一三五;"*" 每天 + at_time TEXT NOT NULL, -- "09:00" + template_id INTEGER NOT NULL REFERENCES templates(id), + priority INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE materials ( + id INTEGER PRIMARY KEY, + family_id INTEGER NOT NULL, + tag TEXT NOT NULL, -- 锻炼 / 防诈骗 / 防跌倒 / 饮食 + title TEXT NOT NULL, + url TEXT NOT NULL, + platform TEXT, -- 小红书 / 抖音 / 视频号 / B站 + source TEXT, -- official=政务媒体号 / media / personal + checked INTEGER NOT NULL DEFAULT 0, -- 人工是否抽查过 + alive INTEGER NOT NULL DEFAULT 1, -- 链接体检结果 + fail_count INTEGER NOT NULL DEFAULT 0, + used_count INTEGER NOT NULL DEFAULT 0, + last_used_at INTEGER, + created_at INTEGER NOT NULL +); + +-- 幂等关键:同一人同一规则同一天只会有一条 +CREATE TABLE pushes ( + id INTEGER PRIMARY KEY, + member_id INTEGER NOT NULL REFERENCES members(id), + rule_id INTEGER REFERENCES rules(id), + template_id INTEGER NOT NULL, + push_date TEXT NOT NULL, -- YYYY-MM-DD + material_id INTEGER, + status TEXT NOT NULL, -- sent | failed | skipped + sent_at INTEGER, + UNIQUE(member_id, rule_id, push_date) +); + +-- 核心资产 +CREATE TABLE feedbacks ( + id INTEGER PRIMARY KEY, + member_id INTEGER NOT NULL REFERENCES members(id), + push_id INTEGER REFERENCES pushes(id), + question_key TEXT, + question TEXT, + answer TEXT NOT NULL, -- 语音已转文字 + input_type TEXT NOT NULL, -- text | voice + asr_confidence REAL, + mood TEXT, -- 正面 / 中性 / 负面 / 低落 + physical TEXT, -- 正常 / 不适 / 异常 + risk_level INTEGER NOT NULL DEFAULT 0, -- 0正常 1关注 2注意 3紧急 + ai_reason TEXT, + created_at INTEGER NOT NULL +); + +CREATE TABLE alerts ( + id INTEGER PRIMARY KEY, + member_id INTEGER NOT NULL, + feedback_id INTEGER REFERENCES feedbacks(id), + level INTEGER NOT NULL, + reason TEXT NOT NULL, + channels TEXT, -- 实际发出的通道,逗号分隔 + resolved INTEGER NOT NULL DEFAULT 0, + resolved_by TEXT, + created_at INTEGER NOT NULL, + resolved_at INTEGER +); + +-- 通知账本:幂等 + 成本统计 +CREATE TABLE notifications ( + id INTEGER PRIMARY KEY, + alert_id INTEGER REFERENCES alerts(id), + channel TEXT NOT NULL, -- wecom | sms | email | webhook + target TEXT NOT NULL, + status TEXT NOT NULL, -- sent | failed | skipped | degraded + cost_cny REAL NOT NULL DEFAULT 0, + vendor_msg TEXT, + retry_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + UNIQUE(alert_id, channel, target) -- 幂等:同一告警同一通道同一人只发一次 +); + +CREATE TABLE weekly_reports ( + id INTEGER PRIMARY KEY, + family_id INTEGER NOT NULL, + week_start TEXT NOT NULL, + content TEXT NOT NULL, + sent_at INTEGER, + UNIQUE(family_id, week_start) +); +``` + +--- + +## 5. 模板与规则 DSL + +一期用 YAML 配置,二期落库 + Web 后台编辑。 + +```yaml +templates: + - id: exercise + name: 锻炼身体 + theme: 锻炼 + steps: + - seq: 1 + type: text + content: "妈,今天天气还行,活动活动筋骨没?" + - seq: 2 + type: material # 具体链接在推送时才决定,模板只声明标签 + tag: 锻炼 + - seq: 3 + type: question # 发出后,下一条回复计为该问题的反馈 + content: "跟着做了没?哪儿觉得累就跟我说,别硬撑。" + question_key: did_exercise + delay_sec: 20 # 模拟真人打字间隔,三条连发很假 + + - id: anti_fraud + name: 防诈骗提醒 + theme: 防诈骗 + steps: + - seq: 1 + type: text + content: "跟你说个最近挺多的事,别嫌我啰嗦。" + - seq: 2 + type: material + tag: 防诈骗 + - seq: 3 + type: question + content: "最近有没有接到奇怪的电话或者短信?" + question_key: met_scam + + - id: casual + name: 随便聊聊 + theme: 日常 + steps: + - seq: 1 + type: question + content: "今天干啥了?" + question_key: daily + +rules: + - days: "1,3,5" # 周一三五 + at: "09:00" + template: exercise + - days: "2,4,6" # 周二四六 + at: "09:00" + template: anti_fraud + - days: "7" # 周日不安排任务,纯陪聊 + at: "10:00" + template: casual +``` + +**约束**: +- 一个模板最多一个 `question`,避免连珠炮追问 +- `material` 的 `tag` 必须能在 materials 表找到存活素材,否则跳过该步并告警 + +--- + +## 6. 规则引擎 + +| 问题 | 规则 | +|---|---| +| 触发 | APScheduler cron,每个 rule 一个 job,配置变更热重载 | +| 同日多规则 | 各自按 `at_time` 独立触发,允许一天多次 | +| 同时刻冲突 | 取 `priority` 最高的,其余跳过并记日志 | +| **幂等** | `pushes` 唯一约束 `(member_id, rule_id, push_date)` | +| **宕机补发** | `misfire_grace_time=900`(15 分钟内补发);**超时不补发**,只记录——半夜补发比不发更糟 | +| 静默时段 | 落在 `quiet_hours` 内的推送顺延到时段结束后 30 分钟 | +| 素材缺失 | 该 tag 无存活素材 → 跳过 material 步骤,继续后续步骤,并发告警提示补货 | + +--- + +## 7. 素材子系统 + +### 7.1 轮换算法 + +```sql +SELECT * FROM materials +WHERE family_id=? AND tag=? AND alive=1 +ORDER BY (checked=1) DESC, -- 抽查过的优先 + (last_used_at IS NULL) DESC, -- 没用过的优先 + last_used_at ASC, + used_count ASC +LIMIT 1; +``` + +### 7.2 链接体检 + +- 每周日凌晨跑,对每个 `alive=1` 素材发 HEAD 请求(3 秒超时,随机 UA) +- 连续 2 次失败 → `alive=0`,剔除出候选 +- 某 tag 存活素材 < 3 条 → 告警让家属补货 + +⚠️ 已知:小红书 / 抖音对无头请求可能一律返回 200 或一律拒绝,体检会有误判。 +缓解方式是看**连续**失败次数而非单次,并优先用 `source=official` 的链接。 + +--- + +## 8. 反馈采集与风险分级 + +### 8.1 采集 + +``` +收到消息 → 是否处于"等待反馈"状态? + 是 → 语音走 ASR → 记为 feedback → 风险打标 → 清除等待态 + 否 → 普通陪聊(AI 接话,不入库为反馈) +``` + +等待态默认 4 小时超时,超时自动清除——避免半夜一句话被当成白天问题的反馈。 + +### 8.2 语音链路【方言已确认:接近普通话 + 陕西关中话】 + +``` +微信语音 → 企业微信 media/get 下载(amr,8k 采样) + → ffmpeg 重采样为 16k 单声道(一句话识别只收 16k 引擎) + → 腾讯云 ASR 一句话识别(SentenceRecognition) + → 文本 + 置信度入库 +``` + +#### 引擎选型【已定,已查证官方文档】 + +腾讯云一句话识别支持两类可用引擎,**均覆盖关中话(官方列表中称"西安话"/"陕西")**: + +| 引擎 | 覆盖 | 适用 | +|---|---|---| +| `16k_zh_dialect` | 普通话 + 23 种方言,含**西安话** | **主引擎**。方言覆盖最广,老人"基本接近普通话"时表现最好 | +| `16k_zh_en` | 中英粤 + 7 种方言(含**陕西**),大模型版 | **备用引擎**。官方说明:针对**噪声大、回音大、人声小、人声远**等低质量音频识别准确率极大提升 | + +老人多半声音偏小、离手机有距离、环境可能有杂音,所以备用引擎选 `16k_zh_en` 而不是另一个普通话引擎。 + +#### 双引擎兜底策略 + +``` +识别(16k_zh_dialect) + ↓ +置信度 < 0.7 或结果为空? + 是 → 用 16k_zh_en 再识别一次,取置信度更高的那个 + 否 → 直接用 + ↓ +仍然为空或置信度 < 0.5 → 回"没听清,你再说一遍?",保存原始音频备查 +``` + +只在低置信度时才多调一次,老人一条语音几秒,成本可忽略。 + +⚠️ **实现注意**:`SentenceRecognition` 响应中的 `Confidence` 字段需实测确认是否存在。 +代码要写成**可选字段兼容**——有就按阈值判断,没有就只在"结果为空"时触发重试。 + +#### 热词表【强烈建议做】 + +腾讯云支持热词:单个"热词|权重",权重 1-11(11 为超级热词,慎用),最多 128 个。 +家庭场景下这东西提升非常明显,尤其是**身体健康相关的词**("膝盖"被识别成"膝盖腾"就废了)。 + +```yaml +asr: + hotwords: + - "欢欢|11" # 家人名字,超级热词 + - "西安|8" + - "膝盖|9" + - "降压药|9" + - "头晕|9" + - "<小区名>|8" + - "<常用医院名>|8" +``` + +做成配置文件,家属随时可以加,改完不用重启(每次识别时读取)。 + +#### 其他约束 + +- 一句话识别限制:时长 ≤ 60s、文件 ≤ 3MB。老人语音一般几秒到几十秒,够用 +- 支持的音频格式含 amr,但**采样率必须是 16k**,所以重采样那步省不掉 +- 建议把原始 amr 存 30 天,识别不准时可以人工回听,也方便后续调优 + +### 8.3 分级矩阵 + +| 级别 | 触发条件(满足任一) | 通知 | +|---|---|---| +| L0 正常 | — | 无 | +| L1 关注 | 情绪轻度负面、模糊不适("有点累")、答非所问 | 入库,进周报 | +| L2 注意 | 明确身体不适、连续 2 天负面、当天应回未回 | 企业微信推 admin(合并成一条) | +| L3 紧急 | 关键词命中(摔倒/胸痛/喘不上气/不想活了)、AI 判定高危、连续 3 天无反馈 | 企微 + 短信,立即 | + +**顺序**:关键词先跑,命中直接 L3,不等 AI(省时间,也防模型把"摔倒了"判成 L0)。 + +### 8.4 AI 打标 + +用小模型(省 token),要求输出严格 JSON: + +```json +{"mood":"正面|中性|负面|低落","physical":"正常|不适|异常","risk":0-3,"reason":"≤20字"} +``` + +返回非法 JSON 或调用失败 → 降级到关键词兜底,不会因为打标失败就不发。 + +### 8.5 降噪【重要,防止告警疲劳】 + +| 规则 | 阈值 | +|---|---| +| 同一成员同一级别冷却 | 30 分钟 | +| L2 单日上限 | 2 条,超出合并进周报 | +| L3 | 不设限 | +| 已 resolved | 不再重复 | +| 连续未回复 | 需连续 2/3 天才升级,单日漏回只记 L1 | + +**强烈建议**:上线前两周只记录不通知,用真实数据把阈值调准,再打开推送。 +误报几次之后家属就会开始忽略告警,整套系统就白做了。 + +--- + +## 9. 身份呈现【C1 约束的落地】 + +老人在微信消息列表只看到三样:**头像、名字、消息内容**,三样全部可控。 + +| 元素 | 做法 | +|---|---| +| 头像 | 企业微信应用 logo 用家属本人照片 | +| 名字 | **企业名和应用名都设成同一个家里称呼**(微信插件会话标题显示哪个取决于版本,两个都改最保险) | +| 内容 | 女儿口吻,由 persona 约束 | + +**残留差异**(诚实列出,不掩饰):会话详情页仍会显示企业信息; +"通讯录 → 我的企业"里也能找到这个入口。老人几乎不会点进去。 + +**诚信底线**:默认不主动提 AI;被直接问"你是不是机器人"必须如实说。建议话术: +> "这是欢欢给你弄的一个陪你说话的小东西,话是它替我说的,但我每天都看你说的话。" + +**真人介入机制**(别让系统变成甩手掌柜): +1. 家属可随时在企业微信 App 里手动插话,消息同样到老人微信 +2. **每周至少一条家属本人录的真人语音**——一条真人语音抵得过三十条 AI 文本 + +--- + +## 10. 通知中心 + +### 10.1 通道抽象 + +```python +class Notifier(Protocol): + name: str # wecom | sms | email | webhook + capabilities: Capability # 富文本? 成本/条? 频率限制? 需报备? 有回执? + async def send(self, alert: Alert, target: str) -> Receipt + async def health_check(self) -> bool +``` + +`Receipt(status, vendor_msg, cost_cny)` 统一写进 `notifications` 表。 + +### 10.2 通道对比 + +| 通道 | 成本 | 富文本 | 报备 | 送达 | 回执 | 频率限制 | +|---|---|---|---|---|---|---| +| 企业微信应用消息 | 0 | ✓ | 无 | 好 | 仅发送结果 | 成员数 × 200/天 | +| 短信 | ~¥0.03-0.05/条 | ✗ | **签名+模板审核 1-3 天** | 最强 | 有 | 同号 30s/条、1h/5条、1天/10条 | +| 邮件 | 0 | ✓ | 无 | 一般 | 无 | 取决于服务商 | +| Webhook | 0 | ✓ | 无 | 好 | 有 | 取决于服务商 | + +### 10.3 路由与降级 + +```yaml +notify: + routing: + 1: [] # L1 不通知,进周报 + 2: [wecom] + 3: [wecom, sms] + degrade: + wecom_failed_on_level_3: [sms] # 只有 L3 企微失败才降级到短信(省钱) + sms_failed: [email] + budget: + sms_monthly_cny: 20 # 超预算自动停用短信,只走企微并发告警 +``` + +- **幂等**:`notifications` 唯一约束 `(alert_id, channel, target)` +- **重试**:指数退避 3 次(1s / 4s / 15s),仍失败走降级链 +- **回执**:短信回执是异步回调,需 `POST /notify/sms/receipt` 端点更新状态 + +### 10.4 短信特殊说明 + +四个通道里唯一需要外部审核的,单独规划: + +1. **签名 + 模板都要报备**,模板变量位有限(腾讯云是 `{1}{2}` 形式) +2. 建议模板:`【签名】您的家人{1}在{2}回复中出现{3},请尽快联系确认。` + ——**三个变量,AI 的 reason 必须压缩到 12 字内** +3. **vendor adapter 必须独立**:腾讯云 / 阿里云 / 华为云三家 SDK 与签名算法完全不同, + 用适配器模式隔离,`SmsNotifier` 只依赖抽象的 `SmsVendor` +4. **双保险**:`SMS_ENABLED` 开关 + 月度预算上限,防止模型误判导致短信轰炸 +5. 长期未收到回执的告警应升级(重发或换通道) + +--- + +## 11. Web 后台(手机优先) + +### 11.1 为什么提前 + +家属不会一直坐在电脑前,**下班后要能在手机上管理**。所以后台从 P3 提前,且**按手机浏览器设计**: +大按钮、大字体、竖屏、只放四个功能。 + +| 页面 | 功能 | +|---|---| +| 今日状态 | 今天推了没、老人回了没、有无未处理告警 | +| 看反馈 | 按人按日期看原文与风险标记,语音显示转写结果 | +| 暂停 / 恢复 | 某人生病住院或外出,一键停推送 | +| 手动推一条 | 调试或临时补发 | + +### 11.2 鉴权 + +- 生产:企业微信 OAuth2 网页授权(`snsapi_base` 静默授权拿 userid)→ 查 `members.role` 判权限 + ⚠️ 需要在自建应用配置"网页授权及 JS-SDK"的可信域名,**必须用域名,纯 IP 不行** +- 开发期:可用 `ADMIN_TOKEN` 口令绕开,不阻塞主流程 + +### 11.3 权限矩阵 + +| 功能 | admin(子女) | member(父母) | +|---|---|---| +| 查看自己收到的推送 | ✓ | ✓ | +| 编辑模板 / 规则 / 素材 | ✓ | ✗ | +| 查看全部反馈与告警 | ✓ | ✗ | +| 标记告警已处理 | ✓ | ✗ | +| 查看周报 | ✓ | ✗ | + +### 11.4 API 清单 + +``` +POST /wecom/callback 企业微信回调(消息 + 事件) +GET /health 健康检查 + +GET/POST/PUT/DELETE /api/templates 模板 CRUD(steps 全量替换) +POST /api/templates/{id}/preview 发给自己看效果 +GET/POST/PUT/DELETE /api/rules 规则 CRUD +GET/POST/PUT/DELETE /api/materials 素材 CRUD +POST /api/materials/import 导入候选 YAML +POST /api/materials/check 手动触发链接体检 + +GET /api/feedbacks?member=&from=&to=&risk= 反馈查询 +GET /api/alerts?resolved=0 告警查询 +POST /api/alerts/{id}/resolve 标记处理 + +POST /api/push/{member_id} 手动触发一次推送 +POST /api/report/weekly 手动生成周报 +POST /notify/sms/receipt 短信回执回调 +``` + +--- + +## 12. 待确认事项(开工前需要家属提供) + +| # | 问题 | 影响 | +|---|---|---| +| 1 | ~~老人说方言吗?~~ **已确认:基本接近普通话,也有陕西关中话** | 已按此定型,见 8.2 节。主引擎 `16k_zh_dialect`,备用 `16k_zh_en` | +| 2 | 家属(admin)的企业微信 userid 与手机号 | 告警接收人 | +| 3 | 老人的企业微信通讯录账号(如 mom / dad) | `members.wecom_userid` | +| 4 | 域名与子域 | 回调 + 后台 | +| 5 | 短信服务商与签名主体 | L3 告警第二通道 | +| 6 | 推送时间点(暂定 09:00 / 周日 10:00) | 按老人作息定 | +| 7 | 素材平台优先级(小红书 / 抖音 / 视频号 / B站) | 决定素材从哪找 | + +--- + +## 13. 企业微信配置步骤(人工做,代码不管) + +1. 注册,主体选「企业」或「其他组织」,组织名起得像家(如"家里") + ——**不认证也能用,免费** +2. 「我的企业 → 企业信息」最下方复制**企业 ID** +3. 「应用管理 → 自建 → 创建应用」 + - 应用名 = 家里称呼(见第 9 节) + - **logo = 家属本人照片**(见第 9 节) + - 可见范围勾全家 + - 记下 AgentId 和 Secret +4. 应用详情最下方「开发者接口」→ 填**企业可信 IP** = 服务器公网 IP +5. 「接收消息 → 设置 API 接收」 + - URL:`https://<域名>/wecom/callback` + - Token、EncodingAESKey 点"随机获取",三个值填进 `.env` + - ⚠️ **等服务起来再点保存**,否则验证通不过 +6. 「我的企业 → 微信插件」 + - 勾选 **允许成员在微信插件中接收和回复聊天消息**(漏了这步老人收不到) + - 让家人微信扫码关注 +7. 「通讯录 → 添加成员」:账号填 `mom` / `dad`,**必须与 `members.wecom_userid` 一致** + +--- + +## 14. 开发任务清单 + +### P0 · 推送引擎(先跑通"推得出去") + +- [ ] **P0-1** 建表:`app/schema.sql`,照第 4 节 DDL 建 10 张表 + 初始化迁移 +- [ ] **P0-2** 存储层:`app/store.py` 重写,CRUD 覆盖新表 +- [ ] **P0-3** 模板渲染:`app/template.py`,按 seq 渲染 steps +- [ ] **P0-4** 素材轮换:`app/materials.py`,实现第 7.1 的查询 +- [ ] **P0-5** 规则引擎:`app/scheduler.py` 重写,第 6 节全部规则 +- [ ] **P0-6** 新版配置:`config/config.example.yaml` 加 templates / rules / materials 三段 +- [ ] **P0-7** 联调:手动触发一次推送,在微信里看到三段消息 + +**验收**:`POST /api/push/mom` 能在微信收到"文本 + 视频链接 + 提问"三条,且重复触发不会产生第二条。 + +### P1 · 反馈、告警、手机后台 + +- [ ] **P1-1** 反馈采集:等待态(4h 超时)+ 归档进 `feedbacks` +- [ ] **P1-2** ASR 接入:`app/asr.py` 已有腾讯云实现,需改成**双引擎兜底**(8.2 节)+ ffmpeg 重采样 8k→16k + 热词表 +- [ ] **P1-3** 风险打标:关键词先跑,再小模型判 L1/L2 +- [ ] **P1-4** 通知中心:`Notifier` 协议 + 企微实现 + 路由 + 幂等 + 账本 +- [ ] **P1-5** 降噪:冷却 30min、L2 单日上限 2 条 +- [ ] **P1-6** 手机优先 Web 后台:四个页面(第 11 节) + +**验收**: +1. 老人发一条语音"我摔了一跤" → 转文字 → 判 L3 → 家属企微收到告警 → 后台能看到这条反馈 +2. **普通话和关中话各测一遍**:用方言说一条内容,确认 `16k_zh_dialect` 能正确转写; + 再录一条"人声小、离手机半米远"的,确认 `16k_zh_en` 兜底能救回来 +3. 热词表生效验证:说一句含"膝盖"的话,确认不会识别成别的字 + +### P2 · 收尾 + +- [ ] **P2-1** 周报:每周日生成,含反馈汇总 / 情绪趋势 / 未回复次数 / 本月短信花费 +- [ ] **P2-2** 素材体检:每周 HEAD 检测 + 存活数不足告警 +- [ ] **P2-3** OAuth 鉴权(需域名) +- [ ] **P2-4** 短信通道接入(含报备) + +--- + +## 15. 参考实现 + +仓库里已有一版 **V1 陪聊原型**(`app/` 下 8 个模块,24 项冒烟测试通过), +**数据模型与 V2 不兼容,不要在上面改**,但以下部分可直接复用: + +| 文件 | 可复用内容 | +|---|---| +| `app/wecom.py` | 企业微信加解密(WXBizMsgCrypt)、access_token 缓存、主动发消息、素材下载——**已测试,直接用** | +| `app/asr.py` | 腾讯云 ASR 签名与调用 | +| `app/ai.py` | OpenAI 兼容客户端 | +| `tests/smoke.py` | 测试写法参考(加解密、回调端到端、鉴权) | + +**V1 里不要复用**:`store.py`(表结构不同)、`bot.py`(陪聊模型)、`scheduler.py`(时间点触发,非星期规则)。 + +--- + +## 16. 禁止事项 + +1. **不要**为了实现"普通微信好友"去接个人号 Hook / 协议自动化——封号永久且不可申诉 +2. **不要**在 V1 的表结构上改(不兼容) +3. **不要**骗老人:被问是不是机器人,如实说 +4. **不要**半夜补发推送(misfire 超时就跳过,只记日志) +5. **不要**动服务器上现有服务(words / 学习山峰 / gitweb / nginx 现有配置) +6. **不要**使用 1060 / 1061 / 1062 / 2341 / 4999 端口 + +--- + +## 17. 协作规矩 + +1. 高风险操作(动现有服务、删改数据、覆盖线上配置)**先列出来确认再执行** +2. 只新增不修改不碰现有服务 +3. 端口冲突主动上报 +4. 改完 git commit,信息写清楚为什么改 +5. 诚实告知成本与风险,别粉饰 +6. 完成一个阶段更新 `HANDOFF.md` 的进度 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ce1e366 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +# family-chat 运行依赖(与 spike 阶段一致:系统 Python + --break-system-packages 安装) +cryptography>=41 +fastapi +uvicorn +sqlalchemy>=2.0 +apscheduler>=3.10 +pyyaml +jinja2 +tencentcloud-sdk-python +# 注:AI 打标用标准库 urllib 直接调用 OpenAI 兼容接口,无需 openai SDK diff --git a/spike/README.md b/spike/README.md new file mode 100644 index 0000000..8315dab --- /dev/null +++ b/spike/README.md @@ -0,0 +1,100 @@ +# Spike 运行手册(解 R1 + R3) + +本目录是 **可行性探针**,目的是在写正式推送引擎之前,先把两个最高风险验证掉: +- **R1**:企业微信「微信插件」能否让普通微信用户收发自建应用消息(端到端)。 +- **R3**:腾讯云一句话识别的返回里到底有没有 `Confidence` 字段(决定双引擎兜底逻辑是否成立)。 + +代码只依赖标准库 + `cryptography`(已装),`tencentcloud` SDK 需 pip 安装。 +**凭据未到前,本手册标注「可现在做」的步骤都能跑;标「需凭据」的步骤等 .env 填好再跑。** + +--- + +## 0. 准备(可现在做) + +```bash +cd /home/chatbot +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # 先留空,拿到凭据再填 +python3 wecom.py # 自测:加密->解密 回环,无需凭据 +``` + +预期:`[self-test] OK: 加密->解密 回环一致` + +--- + +## 1. 解 R1(企业微信端到端) + +### 1.1 企业微信配置(家属在网页做,见 SPEC §13) +1. 注册组织(不认证免费),组织名起得像家。 +2. 建自建应用:应用名=家里称呼、logo=家属照片、可见范围勾全家;记 AgentId / Secret。 +3. 应用详情填「企业可信 IP」= 服务器公网 IP。 +4. 微信插件:勾选「允许成员在微信插件中接收和回复」;家人微信扫码关注。 +5. 通讯录添加成员 `mom`,账号必须与 `WECOM_TEST_USER` 一致。 + +### 1.2 填 .env(需凭据) +把 CorpID / Secret / AgentId / Token / EncodingAESKey 填入 `.env`。 + +### 1.3 步骤A:主动发消息(验证「推得出去」) +```bash +python3 spike/send.py +``` +- 预期:`errcode=0`,且**老人微信里收到这条文本**。 +- 收不到 → 检查微信插件是否开启、可见范围、mom 账号拼写。 + +### 1.4 步骤B:收回调(验证「回得进来」) +1. 启动服务:`python3 spike/app.py`(监听 1063)。 +2. nginx 新增 server 块反代 `https://<域名>/wecom/callback` → `127.0.0.1:1063`。 + ⚠️ 只加新 server 块,**不改现有 nginx 配置**。 +3. 企业微信后台「接收消息 → 设置 API 接收」填 `https://<域名>/wecom/callback`, + Token / EncodingAESKey 与 .env 一致;**等服务起来再点保存**。 +4. 用老人微信**回复一条消息**。 +5. 看 `spike/app.py` 日志是否打印 `[callback] 收到并解密: <内容>`。 + +**R1 通过判定**:步骤A 老人收到 + 步骤B 服务端日志收到并解密成功。 +任一步失败 → R1 不成立,启动 SPEC R1 缓解里的回退方案(家属改用企业微信 App,或重新评估通道)。 + +--- + +## 2. 解 R3(ASR Confidence 字段实测) + +### 2.1 准备 +```bash +pip install tencentcloud-sdk-python +# 在 .env 填 TENCENT_SECRET_ID / TENCENT_SECRET_KEY +# 准备一段 16k 单声道音频(ffmpeg 重采样) +ffmpeg -i in.amr -ar 16000 -ac 1 out.wav +``` + +### 2.2 跑探针(需凭据) +```bash +python3 spike/asr_probe.py out.wav # 主引擎 +python3 spike/asr_probe.py out.wav 16k_zh_en # 备用引擎 +``` + +### 2.3 看结论 +脚本会打印原始返回,并给出: +- `含 Confidence 字段: True/False` +- `含 Result 字段: True/False` + +**R3 通过判定与代码落地规则**(SPEC 8.2): +- 若 `Confidence` 存在 → `wecom.py`/asr 模块按阈值判断(<0.7 触发备用引擎重试)。 +- 若不存在 → 代码退化为「仅在结果为空时触发重试」。 +- 无论哪种,都必须在「结果为空」时重试,不能让转写直接废掉。 + +--- + +## 3. 出口闸门 + +| 项 | 通过条件 | 失败动作 | +|---|---|---| +| R1 | A 收到 + B 解密成功 | 启动回退方案,不进 P0 | +| R3 | 明确 Confidence 是否存在,并据此写死兜底分支 | 不可跳过,双引擎逻辑依赖此结论 | + +两者都过 → 回到 RISK_AND_PLAN.md 进入 **P0 推送引擎**。 + +--- + +## 4. 清理 +spike 跑完验证后,`spike/` 与 `wecom.py` 可保留作参考;若正式项目已引入 V1 的 `app/wecom.py`, +则本 `wecom.py` 仅作 spike 记录,正式代码以 V1 那份为准(避免两套实现漂移)。 diff --git a/spike/app.py b/spike/app.py new file mode 100644 index 0000000..958a0de --- /dev/null +++ b/spike/app.py @@ -0,0 +1,65 @@ +"""R1 Spike 步骤B:启动回调接收服务,验证老人微信回复能被服务端收到并解密。 + +启动: python3 spike/app.py (默认端口 1063) +企业微信后台「接收消息 → 设置 API 接收」URL 填: https://<域名>/wecom/callback + +注意:企业微信验证回调时要求服务端能公网访问,因此本服务需经 nginx 反代到 1063, + 且回调 URL 必须 https(或企业微信支持 http,但建议 https)。 +""" +import os +import sys +import xml.etree.ElementTree as ET + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from spike.common import get_config, make_crypt +from fastapi import FastAPI, Request, Response + +cfg = get_config() +crypt = make_crypt(cfg) +app = FastAPI() + + +def _xml_text(xml: str, tag: str): + try: + return ET.fromstring(xml).find(tag).text + except Exception: + return None + + +@app.get("/wecom/callback") +def verify(timestamp: str = "", nonce: str = "", msg_signature: str = "", echostr: str = ""): + # URL 验证:解密 echostr 并原样返回明文 + try: + plain = crypt.decrypt_message(msg_signature, timestamp, nonce, echostr) + except Exception as e: + return Response("verify failed: %s" % e, status_code=400) + return Response(plain) + + +@app.post("/wecom/callback") +async def callback(timestamp: str = "", nonce: str = "", msg_signature: str = "", + request: Request = None): + body = await request.body() + xml = body.decode("utf-8") + encrypt = _xml_text(xml, "Encrypt") + if not encrypt: + return Response("success") + try: + msg = crypt.decrypt_message(msg_signature, timestamp, nonce, encrypt) + print("[callback] 收到并解密:", msg) + # spike 阶段只记录,不自动回复(避免误用被动回复 5 秒约束;回老人用主动消息) + except Exception as e: + print("[callback] 解密失败:", e) + return Response("success") # 企业微信要求返回 success + + +@app.get("/health") +def health(): + return {"ok": True} + + +if __name__ == "__main__": + import uvicorn + port = int(os.environ.get("SPIKE_PORT", "1063")) + print("spike callback listening on :%d (nginx 反代到 /wecom/callback)" % port) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/spike/asr_probe.py b/spike/asr_probe.py new file mode 100644 index 0000000..5809090 --- /dev/null +++ b/spike/asr_probe.py @@ -0,0 +1,92 @@ +""" +R3 接口实测:调用腾讯云一句话识别,打印原始返回,确认是否含 Confidence 字段。 + +依赖: pip install tencentcloud-sdk-python +配置: 在 .env 增加 TENCENT_SECRET_ID / TENCENT_SECRET_KEY +用法: python3 spike/asr_probe.py <音频路径或url> [engine] + engine 默认 16k_zh_dialect(主引擎);可传 16k_zh_en 测备用引擎 + +音频准备(一句话识别只收 16k): + ffmpeg -i in.amr -ar 16000 -ac 1 out.wav + (时长 ≤ 60s、文件 ≤ 3MB;amr 也支持但采样率必须 16k) +""" +import os +import sys +import json +import base64 + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from spike.common import load_dotenv + + +def main(): + load_dotenv() + sid = os.environ.get("TENCENT_SECRET_ID") + skey = os.environ.get("TENCENT_SECRET_KEY") + if not sid or not skey: + raise SystemExit("缺少 TENCENT_SECRET_ID / TENCENT_SECRET_KEY") + if len(sys.argv) < 2: + raise SystemExit("用法: asr_probe.py <音频文件或url> [engine]") + path = sys.argv[1] + engine = sys.argv[2] if len(sys.argv) > 2 else "16k_zh_dialect" + + try: + from tencentcloud.common import credential + from tencentcloud.common.profile.client_profile import ClientProfile + from tencentcloud.common.profile.http_profile import HttpProfile + from tencentcloud.asr.v20190614 import asr_client, models + except ImportError: + raise SystemExit("请先安装: pip install tencentcloud-sdk-python") + + cred = credential.Credential(sid, skey) + hp = HttpProfile() + hp.reqTimeout = 10 + cp = ClientProfile() + cp.httpProfile = hp + client = asr_client.AsrClient(cred, "ap-guangzhou", cp) + + # 注意: 该 SDK 版本的 SentenceRecognitionRequest 序列化有 bug(如 ChannelNum→HannelNum), + # 故直接用 client.call 传原始字典,绕过有 bug 的请求模型。正式 asr.py 也将采用此方式。 + if path.startswith("http://") or path.startswith("https://"): + voice_format = path.rsplit(".", 1)[-1].lower() if "." in path else "wav" + params = { + "EngSerViceType": engine, + "SourceType": 0, # 0 = 音频 URL + "VoiceFormat": voice_format, + "SubServiceType": 2, # 2 = 一句话识别 + "ProjectId": 0, + "UsrAudioKey": "spike", + "Url": path, + } + else: + with open(path, "rb") as f: + raw_bytes = f.read() + voice_format = path.rsplit(".", 1)[-1].lower() if "." in path else "wav" + params = { + "EngSerViceType": engine, + "SourceType": 1, # 1 = 上传音频数据 + "VoiceFormat": voice_format, + "SubServiceType": 2, # 2 = 一句话识别 + "ProjectId": 0, + "UsrAudioKey": "spike", + "Data": base64.b64encode(raw_bytes).decode("ascii"), + "DataLen": len(raw_bytes), + } + + try: + resp_content = client.call("SentenceRecognition", params) + except Exception as e: + print("=== 调用返回错误(仍可确认连通/鉴权) ===") + print(e) + return + + raw = json.loads(resp_content.decode("utf-8")) + print("=== 原始返回 ===") + print(json.dumps(raw, ensure_ascii=False, indent=2)) + print("=== R3 结论 ===") + print("含 Confidence 字段:", "Confidence" in raw) + print("含 Result 字段:", "Result" in raw) + + +if __name__ == "__main__": + main() diff --git a/spike/common.py b/spike/common.py new file mode 100644 index 0000000..46c53bb --- /dev/null +++ b/spike/common.py @@ -0,0 +1,37 @@ +"""spike 共享:从 .env 或环境变量加载配置,构建 WeComClient / WXBizMsgCrypt。""" +import os + + +def load_dotenv(path=".env"): + if not os.path.exists(path): + return + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'")) + + +def get_config(): + load_dotenv() + required = ["WECOM_CORP_ID", "WECOM_SECRET", "WECOM_AGENT_ID", + "WECOM_TOKEN", "WECOM_AES_KEY", "WECOM_RECEIVE_ID"] + cfg = {k: os.environ.get(k, "") for k in required} + missing = [k for k, v in cfg.items() if not v] + if missing: + raise SystemExit("缺少环境变量: %s\n请复制 .env.example 为 .env 并填写" % missing) + cfg["WECOM_AGENT_ID"] = int(cfg["WECOM_AGENT_ID"]) + cfg["WECOM_TEST_USER"] = os.environ.get("WECOM_TEST_USER", "mom") + return cfg + + +def make_client(cfg): + from wecom import WeComClient + return WeComClient(cfg["WECOM_CORP_ID"], cfg["WECOM_SECRET"], cfg["WECOM_AGENT_ID"]) + + +def make_crypt(cfg): + from wecom import WXBizMsgCrypt + return WXBizMsgCrypt(cfg["WECOM_TOKEN"], cfg["WECOM_AES_KEY"], cfg["WECOM_RECEIVE_ID"]) diff --git a/spike/sample.wav b/spike/sample.wav new file mode 100644 index 0000000..b312ef1 Binary files /dev/null and b/spike/sample.wav differ diff --git a/spike/send.py b/spike/send.py new file mode 100644 index 0000000..da550a7 --- /dev/null +++ b/spike/send.py @@ -0,0 +1,30 @@ +"""R1 Spike 步骤A:向 mom 主动发一条文本,验证企业微信通道可达。 + +跑通条件: + - 已完成 SPEC §13 的企业微信配置(含微信插件开启、mom 已在通讯录且可见范围勾选) + - 已填好 .env +用法:python3 spike/send.py +""" +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from spike.common import get_config, make_client + + +def main(): + cfg = get_config() + client = make_client(cfg) + target = cfg.get("WECOM_TEST_USER", "mom") + text = "[spike] 通道验证 %s" % time.strftime("%H:%M:%S") + res = client.send_text(target, text) + print("send_text ->", res) + if res.get("errcode") == 0: + print("OK: 已向 %s 发送,请在微信里确认是否收到(需已开启微信插件)" % target) + else: + print("FAIL: 发送失败,见 errmsg;常见原因:agentid 错 / 可见范围未勾 mom / 微信插件未开") + + +if __name__ == "__main__": + main() diff --git a/test_edit_feature.py b/test_edit_feature.py new file mode 100644 index 0000000..dfec4d0 --- /dev/null +++ b/test_edit_feature.py @@ -0,0 +1,88 @@ +"""验证脚本:登录后台 API,测试模板/规则新字段、图片上传、会话推进逻辑。""" +import json +import sys + +import requests + +from app.config import get_config + +BASE = "http://127.0.0.1:1064" +cfg = get_config() + +s = requests.Session() +r = s.post(BASE + "/login", json={"password": cfg.admin_password}, allow_redirects=False) +print("login:", r.status_code) +assert r.status_code in (302, 200), "登录失败" + +# 1. 模板列表(新字段) +r = s.get(BASE + "/api/templates") +tpls = r.json() +print("\n[templates] status=%d count=%d" % (r.status_code, len(tpls))) +t0 = tpls[0]["steps"][0] +assert "trigger_type" in t0 and "reply_timeout_sec" in t0, "模板步骤缺少新字段" +print(" step new fields OK:", t0) + +# 2. 新建模板(文字+图片步骤,含发送时机/超时) +payload = { + "name": "测试-编辑功能验证", + "steps": [ + {"seq": 1, "type": "text", "content": "妈,早上好呀", "trigger_type": "delay", "delay_sec": 0, "reply_timeout_sec": None}, + {"seq": 2, "type": "text", "content": "记得吃早饭哦", "trigger_type": "after_reply", "delay_sec": 0, "reply_timeout_sec": 1800}, + {"seq": 3, "type": "text", "content": "那我不打扰你啦", "trigger_type": "delay", "delay_sec": 60, "reply_timeout_sec": None}, + ], +} +r = s.post(BASE + "/api/templates", json=payload) +print("\n[create template]", r.status_code, r.json()) +tid = r.json()["id"] + +# 3. 图片上传(1x1 PNG) +import base64 +png = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" +) +r = s.post(BASE + "/api/upload/image?name=test.png", data=png) +print("[upload image]", r.status_code, r.json()) +fn = r.json()["filename"] + +# 图片访问 +r = s.get(BASE + "/api/media/images/" + fn) +print("[fetch image]", r.status_code, r.headers.get("content-type")) + +# 4. 新建规则(每月 1 号 08:30,指定成员,带起止日期) +r = s.get(BASE + "/api/dashboard") +member_id = [m for m in r.json()["members"] if m["role"] == "member"][0]["id"] +rule_payload = { + "name": "测试-月度规则", + "template_id": tid, + "member_ids": str(member_id), + "period_type": "monthly", + "days": "*", + "month_day": 1, + "at_time": "08:30", + "start_date": "2026-09-01", + "end_date": "2026-12-31", +} +r = s.post(BASE + "/api/rules", json=rule_payload) +print("\n[create rule]", r.status_code, r.json()) +rid = r.json()["id"] + +# 5. 规则列表(验证回显) +r = s.get(BASE + "/api/rules") +rules = r.json() +mine = [x for x in rules if x["id"] == rid][0] +print("[rule echo]", json.dumps(mine, ensure_ascii=False)) +assert mine["period_type"] == "monthly" and mine["month_day"] == 1 +assert mine["member_names"], "成员名未解析" + +# 6. 页面可访问 +for path in ["/", "/manage", "/rules"]: + r = s.get(BASE + path) + print("[page %s]" % path, r.status_code, len(r.text), "bytes") + +# 7. 清理测试数据(规则→模板,注意 FK) +r = s.delete(BASE + "/api/rules/%d" % rid) +print("\n[cleanup rule]", r.status_code) +r = s.delete(BASE + "/api/templates/%d" % tid) +print("[cleanup template]", r.status_code) + +print("\nALL TESTS PASSED") diff --git a/test_session_engine.py b/test_session_engine.py new file mode 100644 index 0000000..d5e3883 --- /dev/null +++ b/test_session_engine.py @@ -0,0 +1,153 @@ +"""会话推进引擎验证(mock 微信客户端,不实际发消息)。 +场景:3 步模板 = [文字(立即), 文字(等回复,首条超时30min兜底), 文字(延时10s)] +验证:首条立即发 → 等 reply 状态 + 超时任务 → 回复推进第2条 → 延时推进第3条 → 会话完成 +""" +import time + +import app.scheduler as sched +from app.db import (init_db, get_sessionmaker, session_scope, now_ts, + Member, Family, Template, TemplateStep, TemplateSession, Push) +from app import scheduler + +init_db() + +# 测试进程内启动调度器(服务进程本来就有,这里为独立验证) +scheduler.get_scheduler().start() + +# ---- mock 微信客户端 ---- +sent_log = [] + + +class FakeClient: + def send_text(self, userid, text): + sent_log.append(("text", userid, text)) + return {"errcode": 0} + + def send_news(self, userid, title, url, desc="", picurl=""): + sent_log.append(("news", userid, title)) + return {"errcode": 0} + + def send_image(self, userid, media_id): + sent_log.append(("image", userid, media_id)) + return {"errcode": 0} + + +scheduler.make_client_orig = None +import app.wecom as wecom_mod +wecom_mod.make_client = lambda cfg=None: FakeClient() +# scheduler 里是函数内 import,patch 模块函数即可 +sched_wecom = None +# send_template_to_member / _advance_session 内部 `from .wecom import make_client` +# 每次 import 都会拿到 wecom 模块属性 → 已被 patch + +# ---- 造测试数据 ---- +with session_scope() as sess: + fam = sess.query(Family).first() + fid = fam.id + m = Member(family_id=fid, wecom_userid="test_session_user", name="测试成员", + role="member", persona="elder", enabled=1, created_at=now_ts()) + sess.add(m) + sess.flush() + mid = m.id + t = Template(family_id=fid, name="测试-会话推进", enabled=1, + created_at=now_ts(), updated_at=now_ts()) + sess.add(t) + sess.flush() + tid = t.id + sess.add_all([ + TemplateStep(template_id=tid, seq=1, type="text", content="第一条", + delay_sec=0, trigger_type="delay", reply_timeout_sec=1800), + TemplateStep(template_id=tid, seq=2, type="text", content="第二条", + delay_sec=0, trigger_type="after_reply", reply_timeout_sec=None), + TemplateStep(template_id=tid, seq=3, type="text", content="第三条", + delay_sec=2, trigger_type="delay", reply_timeout_sec=None), + ]) + +try: + sm = get_sessionmaker() + sess = sm() + member = sess.get(Member, mid) + sess.close() + + # 1. 首条推送 + res = scheduler.send_template_to_member(member, tid, family_id=fid, deferrable=False) + print("push:", res) + assert res.get("ok"), res + + sm = get_sessionmaker() + sess = sm() + s = sess.query(TemplateSession).filter(TemplateSession.member_id == mid, + TemplateSession.status == "active").first() + assert s, "会话未创建" + sid, cur, waiting = s.id, s.cur_step, s.waiting_for + sess.close() + print("session: id=%s cur_step=%d waiting_for=%r" % (sid, cur, waiting)) + assert cur == 0 and waiting == "reply", "应停在等回复态" + + jobs = {j.id: str(j.trigger) for j in scheduler.get_scheduler().get_jobs() + if j.id.startswith(("step-%d-" % sid, "tmout-%d-" % sid))} + print("jobs:", jobs) + assert any(j.startswith("tmout-%d-" % sid) for j in jobs), "超时兜底任务未注册" + assert sent_log[-1] == ("text", "test_session_user", "第一条") + + # 2. 模拟回复 → 立即推进第 2 条 + scheduler.on_member_reply(mid) + time.sleep(0.3) + sm = get_sessionmaker() + sess = sm() + s = sess.get(TemplateSession, sid) + print("after reply: cur_step=%d waiting=%r status=%r" % (s.cur_step, s.waiting_for, s.status)) + assert s.cur_step == 1 and s.status == "active" + sess.close() + assert sent_log[-1] == ("text", "test_session_user", "第二条"), sent_log + # 第3条是 delay 2s → 应有 step 任务 + jobs = [j.id for j in scheduler.get_scheduler().get_jobs() + if j.id.startswith("step-%d-" % sid)] + assert jobs, "延时推进任务未注册" + print("delay job:", jobs) + + # 3. 等延时到期 → 第 3 条 → 会话完成 + time.sleep(2.5) + sm = get_sessionmaker() + sess = sm() + s = sess.get(TemplateSession, sid) + print("after delay: cur_step=%d status=%r" % (s.cur_step, s.status)) + assert s.cur_step == 2 and s.status == "done", "会话未完成" + sess.close() + assert sent_log[-1] == ("text", "test_session_user", "第三条"), sent_log + + # 4. 超时兜底路径:重新推一次,不回复,手动触发超时任务 + res = scheduler.send_template_to_member(member, tid, family_id=fid, deferrable=False) + sm = get_sessionmaker() + sess = sm() + s = sess.query(TemplateSession).filter(TemplateSession.member_id == mid, + TemplateSession.status == "active").first() + sid2 = s.id + sess.close() + n_before = len(sent_log) + scheduler._advance_session(sid2, 0, "timeout") # 模拟超时触发 + sm = get_sessionmaker() + sess = sm() + s = sess.get(TemplateSession, sid2) + print("after timeout: cur_step=%d waiting=%r" % (s.cur_step, s.waiting_for)) + assert s.cur_step == 1, "超时未推进" + sess.close() + assert len(sent_log) == n_before + 1 and sent_log[-1][2] == "第二条" + # 清理:中止会话 + scheduler._finish_session(sid2) + + print("\nSESSION ENGINE ALL PASSED") + print("sent order:", [x[2] for x in sent_log]) + +finally: + # ---- 清理测试数据 ---- + from app.db import Feedback, WaitState + with session_scope() as sess: + sess.query(TemplateSession).filter(TemplateSession.member_id == mid).delete() + sess.query(Push).filter(Push.member_id == mid).delete() + sess.query(WaitState).filter(WaitState.member_id == mid).delete() + sess.query(TemplateStep).filter(TemplateStep.template_id == tid).delete() + sess.query(Template).filter(Template.id == tid).delete() + sess.query(Member).filter(Member.id == mid).delete() + print("test data cleaned") + scheduler.get_scheduler().shutdown(wait=False) diff --git a/wecom.py b/wecom.py new file mode 100644 index 0000000..53444cc --- /dev/null +++ b/wecom.py @@ -0,0 +1,179 @@ +""" +wecom.py — 企业微信最小工具(spike 用,从零实现) + +仅依赖标准库 + cryptography。提供: + - WXBizMsgCrypt:回调消息加解密(腾讯官方算法:AES-256-CBC + PKCS7(block=32)) + - WeComClient:access_token 缓存、主动发文本、下载媒体 + - 末尾 self-test:无需任何凭据,验证「加密→解密」回环正确 + +注意:这是 spike 用的最小实现。若正式项目已有 V1 的 app/wecom.py(SPEC §15 说已测试), + 应以那份为准;本文件仅用于 spike 阶段,不要长期维护两份实现。 +""" +import base64 +import hashlib +import json +import os +import struct +import time +import urllib.parse +import urllib.request +import logging + +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +logger = logging.getLogger("wecom") + +_BLOCK = 32 # 企业微信 PKCS7 块大小 + + +def _pkcs7_pad(data: bytes) -> bytes: + pad = _BLOCK - (len(data) % _BLOCK) + if pad == 0: + pad = _BLOCK + return data + bytes([pad]) * pad + + +def _pkcs7_unpad(data: bytes) -> bytes: + pad = data[-1] + if pad < 1 or pad > _BLOCK: + raise ValueError("bad pkcs7 padding") + return data[:-pad] + + +class WXBizMsgCrypt: + def __init__(self, token: str, encoding_aes_key: str, receive_id: str): + if len(encoding_aes_key) != 43: + raise ValueError("EncodingAESKey 必须为 43 字符") + self.token = token + self.receive_id = receive_id + self.aes_key = base64.b64decode(encoding_aes_key + "=") # 32 bytes + self.iv = self.aes_key[:16] + + # ---- 签名 ---- + def _signature(self, *parts: str) -> str: + sha = hashlib.sha1() + sha.update("".join(sorted(parts)).encode("utf-8")) + return sha.hexdigest() + + def verify_signature(self, msg_signature: str, timestamp: str, nonce: str, encrypt: str) -> bool: + return self._signature(self.token, timestamp, nonce, encrypt) == msg_signature + + # ---- 解密 ---- + def decrypt(self, encrypt: str) -> str: + ct = base64.b64decode(encrypt) + cipher = Cipher(algorithms.AES(self.aes_key), modes.CBC(self.iv)) + dec = cipher.decryptor() + plain = dec.update(ct) + dec.finalize() + plain = _pkcs7_unpad(plain) + # plain = random(16) + len(4) + msg + receive_id + content = plain[16:] + msg_len = struct.unpack(">I", content[:4])[0] + msg = content[4:4 + msg_len] + rid = content[4 + msg_len:] + if rid.decode("utf-8") != self.receive_id: + raise ValueError("receive_id 不匹配") + return msg.decode("utf-8") + + def decrypt_message(self, msg_signature: str, timestamp: str, nonce: str, encrypt: str) -> str: + if not self.verify_signature(msg_signature, timestamp, nonce, encrypt): + raise ValueError("签名校验失败") + return self.decrypt(encrypt) + + # ---- 加密(被动回复用,spike 一般只记录不回复) ---- + def encrypt(self, text: str, nonce: str): + rand = os.urandom(16) + text_b = text.encode("utf-8") + msg = rand + struct.pack(">I", len(text_b)) + text_b + self.receive_id.encode("utf-8") + msg = _pkcs7_pad(msg) + cipher = Cipher(algorithms.AES(self.aes_key), modes.CBC(self.iv)) + enc = cipher.encryptor() + out = enc.update(msg) + enc.finalize() + encrypt = base64.b64encode(out).decode("ascii") + ts = str(int(time.time())) + sig = self._signature(self.token, ts, nonce, encrypt) + return encrypt, sig, ts + + def encrypt_reply(self, reply_xml: str, nonce: str) -> str: + encrypt, sig, ts = self.encrypt(reply_xml, nonce) + return ( + "" + "" + "" + "" + % (encrypt, sig, ts, nonce) + ) + + +class WeComClient: + """主动调用:access_token 缓存 + 发文本 + 下载媒体。""" + + def __init__(self, corp_id: str, secret: str, agent_id: int): + self.corp_id = corp_id + self.secret = secret + self.agent_id = agent_id + self._token = None + self._expire_at = 0 + + def get_access_token(self, force: bool = False) -> str: + if self._token and not force and time.time() < self._expire_at - 60: + return self._token + url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?" + urllib.parse.urlencode( + {"corpid": self.corp_id, "corpsecret": self.secret}) + data = self._get_json(url) + if data.get("errcode", 0) != 0: + raise RuntimeError("gettoken 失败: %s" % data) + self._token = data["access_token"] + self._expire_at = time.time() + data.get("expires_in", 7200) + return self._token + + def send_text(self, userid: str, text: str) -> dict: + token = self.get_access_token() + url = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=" + token + body = { + "touser": userid, + "msgtype": "text", + "agentid": self.agent_id, + "text": {"content": text}, + } + return self._post_json(url, body) + + def download_media(self, media_id: str, save_path: str) -> str: + token = self.get_access_token() + url = "https://qyapi.weixin.qq.com/cgi-bin/media/get?" + urllib.parse.urlencode( + {"access_token": token, "media_id": media_id}) + req = urllib.request.Request(url) + with urllib.request.urlopen(req, timeout=10) as resp: + data = resp.read() + with open(save_path, "wb") as f: + f.write(data) + return save_path + + @staticmethod + def _get_json(url: str) -> dict: + with urllib.request.urlopen(url, timeout=10) as resp: + return json.loads(resp.read().decode("utf-8")) + + @staticmethod + def _post_json(url: str, body: dict) -> dict: + data = json.dumps(body, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def _self_test(): + aes_key = base64.b64encode(os.urandom(32)).decode("ascii")[:43] # 43 字符 + rid = "wwspikecorpid" + crypt = WXBizMsgCrypt("spike_token", aes_key, rid) + sample = "" + nonce = "12345" + encrypt, sig, ts = crypt.encrypt(sample, nonce) + assert crypt.verify_signature(sig, ts, nonce, encrypt), "签名失败" + out = crypt.decrypt_message(sig, ts, nonce, encrypt) + assert out == sample, "解密结果不符: %s" % out + print("[self-test] OK: 加密->解密 回环一致") + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + _self_test()