Files
MoFin/deploy/bot/xmpp_agent_core.py
T

506 lines
18 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-v3",
"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):
"""下载图片字节。失败返回 None。"""
import urllib.request
import ssl
try:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
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()
return data if len(data) > 100 else None
except Exception as e:
log.error(f"图片下载失败 {url[:80]}: {e}")
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 "[老爸发来一张截图,但下载失败,无法识别]"
ok, text = _ocr_image(img)
if not ok:
return f"[老爸发来一张截图,OCR识别失败: {text}]"
return (f"[老爸发来一张截图,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, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..'))
from xmpp_logger import log_xmpp as _log_xmpp
except Exception:
def _log_xmpp(*a, **kw):
pass
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[:80]}")
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[:80]}")
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)
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()