From ed83fddcf4cb615e31c1b15281fa637c1f3c449a Mon Sep 17 00:00:00 2001 From: xxm Date: Fri, 21 Aug 2026 20:11:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(messenger):=20=E7=BB=9F=E4=B8=80=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=A4=84=E7=90=86=E8=80=85=E2=80=94=E2=80=94=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E8=80=85(=E8=84=9A=E6=9C=AC/job)=E9=A9=B1=E5=8A=A8?= =?UTF-8?q?=E9=80=9A=E9=81=93,=E4=B8=8D=E6=8C=89=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E5=88=A4=E9=80=9A=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/profile-scripts/messenger.py | 135 ++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 deploy/profile-scripts/messenger.py diff --git a/deploy/profile-scripts/messenger.py b/deploy/profile-scripts/messenger.py new file mode 100644 index 00000000..e6fa8d09 --- /dev/null +++ b/deploy/profile-scripts/messenger.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""messenger.py — 统一消息处理者(生产者与消费者解耦) + +核心原则:消费者(本模块)根据【生产者是谁】(哪个 cron job) 决定消息去向, +绝不根据消息内容判断。 + +通道配置(jobs.json 的 delivery 字段): + broadcast : 只写 broadcast_messages 表(不推 xmpp) + xmpp : 写 broadcast 归档 + 推 xmpp + both : 同上(保留兼容) + +用法: + from messenger import send + send(title="MoFin持仓异动", content="...") # 自动按当前脚本/job路由 +""" +import os, sys, json, sqlite3 +from datetime import datetime + +DB = "/home/hmo/MoFin/data/mofin.db" +JOBS_JSON = "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json" +DEFAULT_CHANNEL = "broadcast" + +# 脚本名 → job delivery 配置缓存 {script_basename: channel} +_script_channel_cache = None + + +def _load_script_channels(): + """读取 jobs.json:建立 {脚本basename: delivery通道} 映射""" + global _script_channel_cache + if _script_channel_cache is not None: + return _script_channel_cache + mapping = {} + try: + if os.path.exists(JOBS_JSON): + with open(JOBS_JSON, encoding="utf-8") as f: + jobs = json.load(f).get("jobs", []) + for j in jobs: + if not isinstance(j, dict): + continue + script = str(j.get("script", "")).split("/")[-1] + if not script or not script.endswith(".py"): + continue + channel = j.get("delivery") + if channel: + if script not in mapping or channel == "xmpp": + mapping[script] = channel + except Exception as e: + print(f" [messenger] 读取 job 通道配置失败: {e}", file=sys.stderr) + _script_channel_cache = mapping + return mapping + + +def get_channel(job_script=None): + """获取指定生产者脚本的通道。None=自动识别当前脚本""" + mapping = _load_script_channels() + if job_script: + return mapping.get(job_script, DEFAULT_CHANNEL) + script = os.path.basename(sys.argv[0]) if sys.argv else "mofin" + return mapping.get(script, DEFAULT_CHANNEL) + + +def _classify(title, content): + """内容分类(仅为 broadcast 表展示用,不用于通道决策)""" + text = (title + " " + content).lower() + words = { + "trading": ["买入", "卖出", "止损", "止盈", "加仓", "减仓", "持仓", "区间", "操作", "推荐"], + "system_error": ["llm端点", "api错误", "连接失败", "超时", "异常", "失败", "error", "exception", "告警", "故障"], + "health": ["健康", "体检", "完整性", "守卫", "cron"], + "market": ["大盘", "市场", "板块", "行业", "指数", "行情"], + "news": ["新闻", "消息", "资讯", "公告", "政策"], + "strategy": ["策略", "重评", "评估", "温区", "regime", "12维"], + } + for cat, kws in words.items(): + if any(kw in text for kw in kws): + return cat + return "general" + + +def send(title="", content="", source=None, channel=None, job_name=None): + """统一消息发送。生产者调用即可,不关心去向。 + + 通道决策优先级: + 1. 显式 channel 参数 + 2. 当前脚本名对应的 job 配置(自动识别生产者) + 3. 默认 broadcast + """ + now = datetime.now().isoformat() + producer = source or job_name or (os.path.basename(sys.argv[0]) if sys.argv else "mofin") + category = _classify(title, content) + + # 决定通道(生产者驱动:按脚本名查 job 配置) + if channel is None: + mapping = _load_script_channels() + script_key = None + if source and source.endswith(".py"): + script_key = source + elif producer and producer.endswith(".py"): + script_key = producer + else: + script_key = os.path.basename(sys.argv[0]) if sys.argv else "mofin" + channel = mapping.get(script_key, DEFAULT_CHANNEL) + + # 写 broadcast 表(所有通道归档,保证日志完整) + try: + conn = sqlite3.connect(DB, timeout=30) + conn.execute( + "INSERT INTO broadcast_messages (ts, category, title, content, source) VALUES (?,?,?,?,?)", + (now, category, title, content, producer)) + conn.commit() + conn.close() + except Exception as e: + print(f" [messenger] broadcast 写入失败: {e}", file=sys.stderr) + + # xmpp 通道:输出 stdout(hermes 捕获推 xmpp) + if channel in ("xmpp", "both"): + if title: + print(f"【{title}】{content}", flush=True) + else: + print(content, flush=True) + return "xmpp" + + return "broadcast" + + +def send_action_recommendation(title, content, source=None): + """操作推荐:强制 xmpp 通道(必须推送给用户)""" + return send(title=title, content=content, source=source, channel="xmpp") + + +if __name__ == "__main__": + print("=== messenger 自测 ===") + mapping = _load_script_channels() + for s, c in sorted(mapping.items()): + if "price" in s or "market" in s or "per_stock" in s or "anomaly" in s: + print(f" {s}: {c}") \ No newline at end of file