Files
MoFin/deploy/bot/xmpp_agent_core.py
T
hmo 29e0e6c8b4 fix(688775事件): bot不再静默吞消息 + 价格推送分级 + 小果假警报清理
1. bot call_hermes 失败: 10s重试一次, 仍失败回兜底消息(网关暂时不可用)
   ——此前连接被拒时用户消息被静默吞掉(688775事件根因)
2. price_monitor 推送分级: 破止损/重评确认信号/持仓急跌=ACTION直通;
   未确认进区提示=聚合成一条摘要走INFO(30min限1条+8行截断)
   ——今早一秒一条十几连发+思特威英诺特双发
3. alert_helper ACTION 增加5min内容去重(防竞态双发)
4. intraday_health_check 删除小果全部残留检查(已退役, 假警报
   导致知微旧自愈执行器烧150k+token/session去调查并直接改代码)
2026-07-21 10:41:32 +08:00

532 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Core XMPP Agent — shared logic for zhiwei / mohe / xxm bots.
Imports by xmpp_zhiwei_bot.py / xmpp_mohe_bot.py with --agent flag.
"""
import os, sys, json, time, logging, threading, traceback
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
from hashlib import md5
import slixmpp
from slixmpp import JID
import asyncio
# ── Per-agent configuration ──
PER_AGENT = {
"mohe": {
"jid": "mohe@yoin.fun",
"password": "hermes123",
"nick": "mohe",
"http_port": 5808,
"gateway_url": "http://localhost:8642/v1/chat/completions",
"gateway_api_key": "hermes123",
"session_id": "xmpp-mohe-v2",
"name_cn": "莫荷",
"mention": "@mohe/@莫荷",
},
"zhiwei": {
"jid": "zhiwei@yoin.fun",
"password": "2nw4psra",
"nick": "zhiwei",
"http_port": 5805,
"gateway_url": "http://localhost:8643/v1/chat/completions",
"gateway_api_key": "hermes123",
"session_id": "xmpp-zhiwei-v4", # v4=2026-07-21 bump: 刷新冻结指令(部署纪律/识图知识更新)
"name_cn": "知微",
"mention": "@知微/zhiwei",
},
}
_DEFAULT_AGENT = "mohe"
# ── Module-level config (populated by _apply_config after agent detection) ──
AGENT_NAME = ""
XMPP_JID = ""
XMPP_PASSWORD = ""
MUC_ROOM = "coregroup@conference.yoin.fun"
MUC_NICK = ""
AGENT_MENTION = ""
HTTP_PORT = 5808
AGENT_NICK = ""
ACK_DELAY = 120 # 真卡死才提示(普通 LLM 冷启动 20-100s 不应触发)
GATEWAY_URL = ""
GATEWAY_API_KEY = ""
GATEWAY_SESSION_ID = ""
GATEWAY_DEADLINE_SECONDS = 600
CALL_HERMES_TIMEOUT = 600 # agent 带工具调用(读文件/查数据)需几分钟
FALLBACK_REPLY = "请稍等,我在处理..."
# ── 图片 OCR 配置(截图消息 → SenseNova vision)──
OCR_CONFIG_FILE = "/home/hmo/.config/mofin/ocr_config.json"
OCR_TIMEOUT = 90
IMAGE_EXTS = ('.jpg', '.jpeg', '.png', '.webp', '.gif')
def _load_ocr_config():
"""从 /home/hmo/.config/mofin/ocr_config.json 读取 SenseNova 配置。
文件不存在或未配置 → 返回 (None, None, None),图片功能降级为仅提示收到。"""
try:
with open(OCR_CONFIG_FILE, 'r', encoding='utf-8') as f:
cfg = json.load(f)
return cfg.get('key'), cfg.get('base_url'), cfg.get('model', 'sensenova-6.7-flash-lite')
except Exception:
return None, None, None
def _download_image(url, timeout=30, retries=3):
"""下载图片字节,带重试。失败返回 None。
重试原因:客户端上传和发消息是并发的,bot 收到消息时上传可能还没写完,
立即 GET 会 404<100 字节错误页)。等 2 秒再试即可。
"""
import urllib.request
import ssl
import time as _t
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(1, retries + 1):
try:
req = urllib.request.Request(url, headers={'User-Agent': 'curl/8.5.0'})
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
data = resp.read()
if len(data) > 100:
return data
log.warning(f"图片下载第{attempt}次: 响应过小({len(data)}B, 可能上传未完成),重试...")
except Exception as e:
log.warning(f"图片下载第{attempt}次失败: {str(e)[:100]}")
if attempt < retries:
_t.sleep(2)
log.error(f"图片下载最终失败({retries}次尝试): {url}")
return None
def _ocr_image(img_data, prompt=None):
"""调 SenseNova vision OCR。返回 (ok, text)。"""
key, base, model = _load_ocr_config()
if not (key and base):
return False, "OCR未配置"
import base64
import urllib.request
b64 = base64.b64encode(img_data).decode()
text_prompt = prompt or "请识别这张图片中的所有文字内容,包括数字、股票名称、金额、日期。用中文回复。"
payload = json.dumps({
"model": model,
"messages": [{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}},
{"type": "text", "text": text_prompt},
]
}],
"max_tokens": 1500,
}).encode()
req = urllib.request.Request(
f"{base.rstrip('/')}/chat/completions", data=payload,
headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}"})
try:
with urllib.request.urlopen(req, timeout=OCR_TIMEOUT) as resp:
data = json.loads(resp.read().decode())
msg = data.get('choices', [{}])[0].get('message', {})
text = msg.get('content', '') or msg.get('reasoning', '')
return True, text.strip() if text.strip() else "(OCR无内容)"
except Exception as e:
return False, f"OCR失败: {str(e)[:120]}"
def _is_image_url(url):
u = url.lower().split('?')[0]
return ('upload.yoin.fun' in u or '/upload/' in u or u.endswith(IMAGE_EXTS))
def _process_image_message(url):
"""下载+OCR 一张截图,返回注入 LLM 的上下文文本。"""
img = _download_image(url)
if not img:
return (f"[老爸发来一张截图,bot 下载失败(HTTP错误),无法识别。"
f"图片URL: {url}\n"
f"注:bot 的识图管道是 SenseNova(商汤云OCR),不是小果也不是 tesseract。"
f"下载失败可能是 URL 被截断或 ejabberd 上传过期,请老爸重发一次试试。")
ok, text = _ocr_image(img)
if not ok:
return (f"[老爸发来一张截图,SenseNova OCR 识别失败: {text}。"
f"注:识图管道是 SenseNova,如持续失败请检查 OCR key 配额。")
return (f"[老爸发来一张截图,SenseNova OCR 识别内容如下]\n{text}\n"
f"[截图内容结束] 请基于截图内容回应老爸。")
# ── 全局队列 ──
_outbound_queue = []
_outbound_lock = threading.Lock()
_inbound_queue = []
_inbound_lock = threading.Lock()
RECENT_SENT_MAX = 50
# ── XMPP 消息日志(dashboard 健康Tab「最近对话」数据源)──
try:
sys.path.insert(0, '/home/hmo/MoFin') # 绝对路径(红线#7),相对解析会指到 /
from xmpp_logger import log_xmpp as _log_xmpp
_LOG_XMPP_OK = True
except Exception as _e:
_LOG_XMPP_OK = False
def _log_xmpp(*a, **kw):
pass
print(f"[WARN] xmpp_logger import 失败,消息日志降级为 no-op: {_e}", file=sys.stderr)
def _rs(p):
"""Parse agent arg from sys.argv, returns agent name string."""
agent = _DEFAULT_AGENT
skip_next = False
for i, a in enumerate(sys.argv[1:]):
if skip_next:
skip_next = False
continue
if a.startswith('--agent='):
agent = a.split('=', 1)[1]
elif a == '--agent' and i + 1 < len(sys.argv[1:]):
agent = sys.argv[i + 2]
skip_next = True
return agent
agent, is_mohe = _rs(None), None
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
stream=sys.stdout,
)
log = logging.getLogger('xmpp_agent')
def _apply_config(agent_name):
"""Set module-level config variables from PER_AGENT dict + env overrides."""
global AGENT_NAME, XMPP_JID, XMPP_PASSWORD, MUC_NICK, AGENT_MENTION
global HTTP_PORT, AGENT_NICK, GATEWAY_URL, GATEWAY_API_KEY, GATEWAY_SESSION_ID
cfg = PER_AGENT.get(agent_name, PER_AGENT.get(_DEFAULT_AGENT, {}))
AGENT_NAME = agent_name
XMPP_JID = os.environ.get('XMPP_JID', cfg.get('jid', ''))
XMPP_PASSWORD = os.environ.get('XMPP_PASSWORD', cfg.get('password', ''))
MUC_NICK = os.environ.get('MUC_NICK', cfg.get('nick', agent_name))
AGENT_MENTION = os.environ.get('AGENT_MENTION', cfg.get('mention', ''))
HTTP_PORT = int(os.environ.get('HTTP_PORT', cfg.get('http_port', 5808)))
AGENT_NICK = os.environ.get('AGENT_NICK', cfg.get('nick', agent_name))
GATEWAY_URL = os.environ.get('GATEWAY_URL', cfg.get('gateway_url', ''))
GATEWAY_API_KEY = os.environ.get('GATEWAY_API_KEY', cfg.get('gateway_api_key', ''))
GATEWAY_SESSION_ID = os.environ.get('GATEWAY_SESSION_ID', cfg.get('session_id', ''))
# ── Periodic ACK task ──
class AckManager:
def __init__(self):
self._active = {}
self._lock = threading.Lock()
def start(self, session_id, to_jid, msg_body):
"""Record an active LLM analysis and schedule the ACK."""
with self._lock:
self._active[session_id] = {
'to_jid': to_jid,
'body': msg_body[:80],
'started': time.time(),
'acked': False,
}
def ack(self, session_id):
with self._lock:
self._active.pop(session_id, None)
def tick(self, bot):
now = time.time()
to_send = []
with self._lock:
for sid, info in list(self._active.items()):
if not info['acked'] and now - info['started'] >= ACK_DELAY:
info['acked'] = True
to_send.append((info['to_jid'], FALLBACK_REPLY))
for jid, msg in to_send:
try:
bot.send_message(mto=jid, mbody=msg, mtype='chat')
except Exception:
pass
ack_mgr = AckManager()
# ── Slixmpp Bot ──
class XmppAgent(slixmpp.ClientXMPP):
def __init__(self, jid, password, room, nick):
super().__init__(jid, password)
self._room = room
self._nick = nick
self._muc_joined = False
self._recent_sent = []
self._reconnecting = False # 防递归重连
self.add_event_handler('session_start', self.on_start)
self.add_event_handler('message', self.on_msg)
self.add_event_handler('disconnected', self.on_disconnect)
self.register_plugin('xep_0030')
self.register_plugin('xep_0045')
self.register_plugin('xep_0199')
self.register_plugin('xep_0066') # OOB 附件(截图 URL
async def on_start(self, event):
self.send_presence()
await self.get_roster()
try:
await self.plugin['xep_0045'].join_muc(self._room, self._nick)
self._muc_joined = True
log.info(f"{AGENT_NAME} XMPP 就绪 (已加入 {self._room})")
except Exception as e:
log.error(f"{AGENT_NAME} MUC加入失败: {e}")
def on_disconnect(self, event):
self._muc_joined = False
log.info(f"{AGENT_NAME} XMPP 断开")
if self._reconnecting:
log.warning(f"{AGENT_NAME} 已在重连中,跳过递归重连")
return
self._reconnecting = True
try:
self.reconnect(wait=5.0, reason="断线自动重连")
except Exception as e:
log.warning(f"{AGENT_NAME} 重连失败: {e}")
finally:
self._reconnecting = False
def on_msg(self, msg):
if msg['type'] in ('chat', 'groupchat'):
body = str(msg['body']).strip()
# body 为空时检查 OOB 附件(XEP-0066:截图/文件以 OOB url 送达)
if not body:
try:
oob_url = str(msg['oob']['url'] or '').strip()
except Exception:
oob_url = ''
if oob_url and _is_image_url(oob_url):
body = f"[IMAGE] {oob_url}"
log.info(f"📩 收到图片(OOB): from={msg['from']} url={oob_url}")
else:
return
# body 本身是上传 URL(部分客户端把 URL 直接放 body)
elif body.startswith('http') and _is_image_url(body):
log.info(f"📩 收到图片(URL body): from={msg['from']} url={body}")
body = f"[IMAGE] {body}"
log.info(f"📩 收到: from={msg['from']} type={msg['type']} body={body[:60]}")
_log_xmpp('in', sender_str := str(msg['from']), f"{AGENT_NAME}@yoin.fun", body)
if ('[executor]' in body and 'gateway_zhiwei' in body):
log.info(f"过滤 executor 消息: {body[:80]}...")
return
sender = str(msg['from'])
msg_type = msg['type']
for s in self._recent_sent:
if body[:50] in s or s in body[:50]:
return
if msg_type == 'groupchat':
nick = sender.split('/')[-1] if '/' in sender else ''
if nick == AGENT_NICK:
return
mention_list = AGENT_MENTION.replace('@', '').split('/')
is_for_me = any(m in body for m in ['@' + m for m in mention_list] + mention_list)
if not is_for_me:
return
with _inbound_lock:
_inbound_queue.append((sender, body, msg_type))
def mark_sent(self, body: str):
self._recent_sent.append(body[:80])
if len(self._recent_sent) > RECENT_SENT_MAX * 2:
self._recent_sent = self._recent_sent[-RECENT_SENT_MAX:]
# ── Deliver loop ──
def _deliver_loop(bot):
global _outbound_queue
while True:
try:
items = []
with _outbound_lock:
items, _outbound_queue = _outbound_queue[:], []
for target, text, msg_type in items:
try:
async def _send(to, body, mtype):
bot.send_message(mto=to, mbody=body, mtype=mtype)
asyncio.run(_send(target, text, msg_type))
bot.mark_sent(text)
_log_xmpp('out', f"{AGENT_NAME}@yoin.fun", target, text)
log.info(f" 已发送到 {target}: {text[:80]}")
except Exception as e:
_log_xmpp('out', f"{AGENT_NAME}@yoin.fun", target, text, status='error', error=str(e)[:150])
log.error(f" 发送到 {target} 失败: {e}")
time.sleep(0.3)
except Exception as e:
log.error(f"_deliver_loop error: {e}")
time.sleep(1)
# ── Inbound processing loop ──
def _inbound_loop(bot):
global _inbound_queue
while True:
try:
time.sleep(0.2)
with _inbound_lock:
if not _inbound_queue:
continue
sender, body, msg_type = _inbound_queue.pop(0)
log.info(f"🔄 inbound处理: {body[:40]}")
ack_mgr.start(body[:40], sender, body)
# 截图消息:下载 + OCR → 注入上下文
if body.startswith('[IMAGE]'):
img_url = body[len('[IMAGE]'):].strip()
log.info(f"🖼️ 图片消息处理: {img_url[:80]}")
body = _process_image_message(img_url)
log.info(f"🖼️ OCR完成: {body[:100]}")
reply = call_hermes(body)
if not reply:
# 网关不可用/超时:绝不静默吞掉用户消息——重试一次,仍失败则兜底告知
log.warning("call_hermes 无响应(网关可能重启中),10s后重试一次")
time.sleep(10)
reply = call_hermes(body)
if not reply:
reply = "⚠️ 网关暂时不可用(可能在重启/网络波动),你的消息我已收到但未能处理。请几分钟后重发一次,或稍候我会自行恢复。"
log.error("call_hermes 重试仍失败,已回兜底消息")
ack_mgr.ack(body[:40])
if reply:
with _outbound_lock:
_outbound_queue.append((sender, reply, 'chat'))
except Exception as e:
log.error(f"_inbound_loop error: {e}")
time.sleep(1)
# ── HTTP SendHandler ──
class SendHandler(BaseHTTPRequestHandler):
def do_POST(self):
content_len = int(self.headers.get('Content-Length', 0))
post_body = self.rfile.read(content_len)
try:
data = json.loads(post_body)
target = data.get('to', '')
text = data.get('body', '')
if not target or not text:
self.send_response(400)
self.end_headers()
self.wfile.write(b'{"error":"missing to or body"}')
return
if '修复失败' in text and 'gateway_zhiwei' in text:
self.send_response(200)
self.end_headers()
self.wfile.write(b'{"ok":true,"filtered":true}')
return
msg_type = data.get('type', 'chat')
if text:
_outbound_queue.append((target, text, msg_type))
self.send_response(200)
self.end_headers()
self.wfile.write(b'{"ok":true}')
log.info(f"SendHandler: enqueued -> {target}: {text[:80]}")
except Exception as e:
traceback.print_exc()
self.send_response(500)
self.end_headers()
self.wfile.write(str({'error': str(e)}).encode())
def log_message(self, format, *args):
pass
def _run_http_server():
server = HTTPServer(('127.0.0.1', HTTP_PORT), SendHandler)
log.info(f"HTTP SendHandler listening on 127.0.0.1:{HTTP_PORT}")
server.serve_forever()
# ── call_hermes ──
def call_hermes(content: str, session_id=None) -> str:
now_str = time.strftime("[%Y-%m-%d %H:%M %A]", time.localtime())
timed_content = f"{now_str}\n{content}"
payload = {
'model': 'hermes-agent',
'messages': [
{'role': 'user', 'content': timed_content},
],
'stream': False,
}
if session_id is None:
session_id = GATEWAY_SESSION_ID
headers = {
'Content-Type': 'application/json',
'X-Hermes-Session-Id': session_id,
}
if GATEWAY_API_KEY:
headers['Authorization'] = f'Bearer {GATEWAY_API_KEY}'
import urllib.request
data_bytes = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(GATEWAY_URL, data=data_bytes, headers=headers, method='POST')
try:
resp = urllib.request.urlopen(req, timeout=CALL_HERMES_TIMEOUT)
resp_data = json.loads(resp.read().decode('utf-8'))
reply = ''
if 'choices' in resp_data and len(resp_data['choices']) > 0:
choice = resp_data['choices'][0]
if 'message' in choice and 'content' in choice['message']:
reply = choice['message']['content']
elif 'delta' in choice and 'content' in choice['delta']:
reply = choice['delta']['content']
if not reply:
reply = resp_data.get('response', '')
if not reply:
reply = str(resp_data)
return reply.strip()
except urllib.request.HTTPError as e:
err_body = e.read().decode('utf-8', errors='replace')
log.error(f"call_hermes HTTP {e.code}: {err_body[:200]}")
return ''
except Exception as e:
log.error(f"call_hermes error: {type(e).__name__}: {e}")
return ''
# ── ACK manager tick ──
def _ack_tick(bot):
while True:
try:
ack_mgr.tick(bot)
time.sleep(5)
except Exception:
time.sleep(5)
# ── Main ──
def main():
global is_mohe
agent_name = _rs(None)
_apply_config(agent_name)
is_mohe = (agent_name == 'mohe')
log.info(f"Starting XMPP Agent: {agent_name} (mohe={is_mohe})")
log.info(f" JID={XMPP_JID} HTTP_PORT={HTTP_PORT}")
log.info(f" GATEWAY={GATEWAY_URL}")
log.info(f" SESSION_ID={GATEWAY_SESSION_ID}")
bot = XmppAgent(XMPP_JID, XMPP_PASSWORD, MUC_ROOM, MUC_NICK)
bot.connect(host='127.0.0.1', port=5222)
t_deliver = threading.Thread(target=_deliver_loop, args=(bot,), daemon=True)
t_deliver.start()
t_inbound = threading.Thread(target=_inbound_loop, args=(bot,), daemon=True)
t_inbound.start()
t_ack = threading.Thread(target=_ack_tick, args=(bot,), daemon=True)
t_ack.start()
t_http = threading.Thread(target=_run_http_server, daemon=True)
t_http.start()
bot.loop.run_forever()
if __name__ == '__main__':
main()