@@ -0,0 +1,646 @@
#!/usr/bin/env python3
"""
MoWeChat — 莫荷微信 Bot (Linux iLink 版)
替换方案:将 Windows wxhelper DLL 注入方案替换为腾讯官方 iLink Bot API。
架构:微信 → iLink Bot API (ilinkai.weixin.qq.com) → Hermes Gateway (:8642)
依赖:
pip install weixin-bot-sdk aiohttp requests
首次运行:
python3 wechat_agent.py
终端显示二维码 → 用莫荷的手机微信扫码 → 凭证保存后自动运行
版本: 1.0
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import sys
import threading
import time
import uuid
from http . server import HTTPServer , BaseHTTPRequestHandler
from urllib . parse import urlparse
from weixin_bot import WeixinBot , IncomingMessage
# ── Configuration ──────────────────────────────────────────────
SCRIPT_DIR = os . path . dirname ( os . path . abspath ( __file__ ) )
PROJECT_ROOT = os . path . dirname ( SCRIPT_DIR ) # gateway/
AGENTS_ROOT = os . path . dirname ( PROJECT_ROOT ) # AgentsMeeting/
LOG_DIR = os . path . join ( AGENTS_ROOT , " logs " )
TEMP_DIR = os . path . join ( AGENTS_ROOT , " temp " )
# Hermes Gateway — same endpoint as Windows wechat_agent.py uses
HERMES_API = " http://192.168.1.246:8642/v1/chat/completions "
HERMES_KEY = " hermes123 "
# Mohe's iLink Bot user_id (set after login)
MOHE_USER_ID = None
# ── Setup ──────────────────────────────────────────────────────
os . makedirs ( LOG_DIR , exist_ok = True )
os . makedirs ( TEMP_DIR , exist_ok = True )
logging . basicConfig (
level = logging . INFO ,
format = " %(asctime)s [ %(levelname)s ] %(message)s " ,
datefmt = " % H: % M: % S " ,
handlers = [
logging . FileHandler ( os . path . join ( LOG_DIR , " wechat_agent_linux.log " ) , encoding = " utf-8 " ) ,
logging . StreamHandler ( ) ,
] ,
)
log = logging . getLogger ( " mohe " )
# ── PID lock ───────────────────────────────────────────────────
PID_FILE = os . path . join ( TEMP_DIR , " wechat_agent_linux.pid " )
def acquire_pid_lock ( ) :
""" Write PID file, exit if another instance is running. """
if os . path . exists ( PID_FILE ) :
try :
with open ( PID_FILE ) as f :
old_pid = int ( f . read ( ) . strip ( ) )
# Check if process still exists
os . kill ( old_pid , 0 )
log . error ( f " Another instance running (PID { old_pid } ). Exiting. " )
sys . exit ( 1 )
except ( ProcessLookupError , ValueError ) :
pass # Stale PID file
with open ( PID_FILE , " w " ) as f :
f . write ( str ( os . getpid ( ) ) )
def release_pid_lock ( ) :
try :
os . remove ( PID_FILE )
except OSError :
pass
# ── Hermes Gateway API ─────────────────────────────────────────
import requests as _requests
def call_hermes ( wxid_or_user : str , content : str ) - > str | None :
"""
Send message to Hermes Gateway, get reply.
Returns the reply text, or None if silent/no reply.
"""
headers = {
" Authorization " : f " Bearer { HERMES_KEY } " ,
" X-Hermes-Session-Id " : " sisyphus " ,
" Content-Type " : " application/json " ,
}
is_group = " @chatroom " in wxid_or_user
system_prompt = (
" 你是莫荷,女生。群聊中回复要短。 "
if is_group
else " 你是莫荷,女生。回复简短自然,像朋友聊天。 "
)
# Prefix with sender info like the Windows version does
chat_type = " Group " if is_group else " Private "
prefixed = f " [ { chat_type } ][ { wxid_or_user } ] { content } "
body = {
" model " : " hermes-agent " ,
" messages " : [
{ " role " : " system " , " content " : system_prompt } ,
{ " role " : " user " , " content " : prefixed } ,
] ,
}
try :
r = _requests . post (
HERMES_API ,
json = body ,
headers = headers ,
timeout = 60 ,
)
if r . status_code == 200 :
data = r . json ( )
choice = data [ " choices " ] [ 0 ]
finish_reason = choice . get ( " finish_reason " , " " )
if finish_reason == " silent " :
log . info ( " Hermes: __SILENT__ (group skip) " )
return None
return choice [ " message " ] [ " content " ]
log . warning ( f " Hermes HTTP { r . status_code } : { r . text [ : 200 ] } " )
except Exception as e :
log . error ( f " Hermes API error: { e } " )
return None
# ── Image Generation (SenseNova) ───────────────────────────────
SENSENOVA_KEY = " sk-aRNj3UwKSLPsDfh15QNTPwbHxahblfaO "
SENSENOVA_URL = " https://token.sensenova.cn/v1 "
def generate_image ( prompt : str , ratio : str = " 1:1 " ) - > str | None :
""" Generate image via SenseNova API. Returns URL or None. """
size_map = {
" 1:1 " : " 2048x2048 " , " 16:9 " : " 2752x1536 " , " 9:16 " : " 1536x2752 " ,
" 3:2 " : " 2496x1664 " , " 2:3 " : " 1664x2496 " , " 3:4 " : " 1760x2368 " , " 4:3 " : " 2368x1760 " ,
}
size = size_map . get ( ratio , " 2048x2048 " )
try :
r = _requests . post (
SENSENOVA_URL + " /images/generations " ,
json = {
" model " : " sensenova-u1-fast " ,
" prompt " : prompt ,
" size " : size ,
" response_format " : " url " ,
} ,
headers = {
" Authorization " : f " Bearer { SENSENOVA_KEY } " ,
" Content-Type " : " application/json " ,
} ,
timeout = 180 ,
)
if r . status_code == 200 :
return r . json ( ) [ " data " ] [ 0 ] [ " url " ]
log . warning ( f " Image gen HTTP { r . status_code } : { r . text [ : 200 ] } " )
except Exception as e :
log . error ( f " Image gen error: { e } " )
return None
# ── OCR via Doubao Vision API ──────────────────────────────────
DOUBAO_KEY = " b0359bed-09f2-49e2-a53c-32ba057412e3 "
def ocr_image_from_url ( img_url : str ) - > str | None :
""" Download image from URL and OCR it. Returns text or None. """
try :
r = _requests . get ( img_url , timeout = 30 )
if r . status_code != 200 :
log . warning ( f " Image download HTTP { r . status_code } " )
return None
import base64
b64 = base64 . b64encode ( r . content ) . decode ( )
return _ocr_base64 ( b64 )
except Exception as e :
log . error ( f " Image download/OCR error: { e } " )
return None
def _ocr_base64 ( b64_data : str ) - > str | None :
""" OCR from base64-encoded image data. """
try :
r = _requests . post (
" https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions " ,
json = {
" model " : " doubao-seed-code " ,
" messages " : [ {
" role " : " user " ,
" content " : [
{ " type " : " text " , " text " : " 请识别这张图片中的所有中文和英文字符,保持原文输出,包括数字、表格、百分比的完整结构。严格逐行逐列输出所有数据,不要省略、不要总结。 " } ,
{ " type " : " image_url " , " image_url " : { " url " : f " data:image/jpeg;base64, { b64_data } " } } ,
] ,
} ] ,
} ,
headers = { " Authorization " : f " Bearer { DOUBAO_KEY } " , " Content-Type " : " application/json " } ,
timeout = 60 ,
)
if r . status_code == 200 :
text = r . json ( ) [ " choices " ] [ 0 ] [ " message " ] [ " content " ] . strip ( )
log . info ( f " OCR OK ( { len ( text ) } chars) " )
return text
log . warning ( f " OCR HTTP { r . status_code } : { r . text [ : 200 ] } " )
except Exception as e :
log . error ( f " OCR error: { e } " )
return None
# ── Article Processor ──────────────────────────────────────────
def fetch_article ( url : str ) - > dict | None :
""" Fetch article content via local article_processor (:5810). """
try :
import urllib . request as ur
req = ur . Request (
" http://127.0.0.1:5810/process " ,
data = json . dumps ( { " url " : url } ) . encode ( " utf-8 " ) ,
headers = { " Content-Type " : " application/json " } ,
)
with ur . urlopen ( req , timeout = 180 ) as resp :
result = json . loads ( resp . read ( ) . decode ( " utf-8 " ) )
if result . get ( " status " ) == " ok " :
return {
" title " : result . get ( " title " , " " ) ,
" content " : result . get ( " content " , " " ) [ : 3000 ] ,
" images " : result . get ( " images_ocr " , 0 ) ,
}
log . warning ( f " Article processor error: { result . get ( ' error ' , ' ' ) [ : 100 ] } " )
except Exception as e :
log . error ( f " Article fetch error: { e } " )
return None
# ── Main Message Handler ──────────────────────────────────────
def build_message_display_id ( msg : IncomingMessage , bot : WeixinBot ) - > str :
"""
Build a display identifier for the sender.
Since iLink uses internal user_ids, we use the user_id as the identifier
and supplement with available info.
"""
return msg . user_id
async def handle_incoming ( msg : IncomingMessage , bot : WeixinBot ) :
""" Process an incoming message from WeChat via iLink SDK. """
log . info ( f " [ { msg . type } ] { msg . user_id } : { ( msg . text or ' ' ) [ : 80 ] } " )
fu = msg . user_id
content = msg . text or " "
msg_type = msg . type
# ── text message ──
if msg_type == " text " :
handler_input = content
# Detect forwarded articles
if " mp.weixin.qq.com " in content :
url_match = re . search ( r " https?://mp \ .weixin \ .qq \ .com[^ \" ' \ s<)<> \ [ \ ]]+ " , content )
if url_match :
article = fetch_article ( url_match . group ( 0 ) )
if article :
title = article . get ( " title " , " " )
article_text = article . get ( " content " , " " ) [ : 2000 ]
images = article . get ( " images " , 0 )
handler_input = (
f " [老莫转发了一篇文章] \n 标题: { title } \n "
+ ( f " ( { images } 张图片已OCR) \n " if images else " " )
+ f " \n { article_text } "
)
reply = call_hermes ( fu , handler_input )
if reply and reply . strip ( ) :
await process_reply ( reply , fu , bot )
# ── image message ──
elif msg_type == " image " :
log . info ( f " Image from { fu } , attempting OCR... " )
# Get image URL from the raw message
img_url = None
for item in msg . raw . get ( " item_list " , [ ] ) :
if item . get ( " type " ) in ( 2 , ) : # IMAGE
img_item = item . get ( " image_item " , { } )
# Try direct URL first, then CDN
img_url = img_item . get ( " url " , " " )
if not img_url :
# CDN media - need decryption
media = img_item . get ( " media " , { } )
aes_key = media . get ( " aes_key " , " " )
encrypt_query = media . get ( " encrypt_query_param " , " " )
# For now, log the CDN info and continue
log . info ( f " Image has CDN media: aes_key= { aes_key [ : 15 ] } ... " )
img_url = None
break
ocr_text = None
if img_url :
ocr_text = ocr_image_from_url ( img_url )
else :
log . info ( " No direct image URL, sending raw iLink image to Hermes for description " )
handler_input = " [老莫发送了一张图片] "
reply = call_hermes ( fu , handler_input )
if reply and reply . strip ( ) :
await bot . reply ( msg , reply . strip ( ) )
return
if ocr_text :
handler_input = f " [老莫发送了一张图片,OCR识别结果如下] \n { ocr_text } "
else :
handler_input = " [老莫发送了一张图片,但OCR识别失败,无法读取内容] "
reply = call_hermes ( fu , handler_input )
if reply and reply . strip ( ) :
await bot . reply ( msg , reply . strip ( ) )
# ── voice message ──
elif msg_type == " voice " :
reply = call_hermes ( fu , " [voice message] " )
if reply and reply . strip ( ) :
await bot . reply ( msg , reply . strip ( ) )
# ── unknown type ──
else :
log . info ( f " Unhandled message type: { msg_type } " )
async def process_reply ( reply : str , fu : str , bot : WeixinBot ) :
"""
Process Hermes reply text, handling tags like [FILE], [IMG], [EMOJI].
Uses bot.reply(msg) or bot.send(user_id, text) based on context.
"""
if not reply or not reply . strip ( ) :
return
clean = reply
# ── [FILE] tag ──
fm = re . search ( r ' \ [FILE \ ](.*?) \ [/FILE \ ] ' , clean )
if fm :
url = fm . group ( 1 ) . strip ( )
log . info ( f " [FILE] URL: { url } " )
# iLink doesn't natively support file sending via simple URL.
# Send as text with download link for now.
clean = re . sub ( r ' \ s* \ [FILE \ ].*? \ [/FILE \ ] \ s* ' , ' ' , clean ) . strip ( )
extra = f " \n 文件链接: { url } "
if clean . strip ( ) :
clean + = extra
else :
clean = f " 文件: { url } "
# ── [IMG] tag ──
im = re . search ( r ' \ [IMG \ ](.*?) \ [/IMG \ ] ' , clean )
if im :
cmd = im . group ( 1 ) . strip ( )
clean = re . sub ( r ' \ s* \ [IMG \ ].*? \ [/IMG \ ] \ s* ' , ' ' , clean ) . strip ( )
if cmd . startswith ( " generate: " ) or cmd . startswith ( " draw: " ) :
parts = cmd . split ( " : " , 1 ) [ 1 ] . strip ( )
ratio = " 1:1 "
if " | " in parts :
ratio = parts . split ( " | " ) [ 1 ] . strip ( )
prompt = parts . split ( " | " ) [ 0 ] . strip ( )
else :
prompt = parts
log . info ( f " [IMG] generate: { prompt [ : 40 ] } [ { ratio } ] " )
img_url = generate_image ( prompt , ratio )
if img_url :
# For images, we can't send via iLink natively yet.
# Send the URL as a text message.
extra = f " \n 图片已生成: { img_url } "
if clean . strip ( ) :
clean + = extra
else :
clean = f " 图片已生成: { img_url } "
else :
extra = " \n [图片生成失败] "
clean + = extra if clean . strip ( ) else extra . strip ( )
else :
# Regular image URL
extra = f " \n 图片: { cmd } "
if clean . strip ( ) :
clean + = extra
else :
clean = f " 图片: { cmd } "
# ── [EMOJI] tag ──
em = re . search ( r ' \ [EMOJI \ ](.*?) \ [/EMOJI \ ] ' , clean )
if em :
url = em . group ( 1 ) . strip ( )
log . info ( f " [EMOJI] URL: { url } " )
clean = re . sub ( r ' \ s* \ [EMOJI \ ].*? \ [/EMOJI \ ] \ s* ' , ' ' , clean ) . strip ( )
# ── [CONTACT] tag — not supported via iLink ──
clean = re . sub ( r ' \ s* \ [CONTACT: \ w+ \ ] \ s* ' , ' ' , clean )
# ── [ROOM_MEMBERS] tag — not supported via iLink ──
clean = re . sub ( r ' \ s* \ [ROOM_MEMBERS: \ S+ \ ] \ s* ' , ' ' , clean )
# ── [HISTORY] tag — not supported via iLink ──
clean = re . sub ( r ' \ s* \ [HISTORY: \ S+?: \ d+ \ ] \ s* ' , ' ' , clean )
# ── [PAT] tag — not supported via iLink ──
clean = re . sub ( r ' \ s* \ [PAT: \ S+: \ S+ \ ] \ s* ' , ' ' , clean )
# ── Send remaining text ──
clean = clean . strip ( )
if clean :
await bot . send ( fu , clean )
# ── 5801 HTTP Server ──────────────────────────────────────────
# Receives messages from Hermes/xxm to forward to WeChat.
# Uses asyncio queue to bridge sync HTTP → async iLink SDK.
_message_queue : asyncio . Queue [ dict ] = asyncio . Queue ( )
_http_server_ready = threading . Event ( )
class HermesMsgHandler ( BaseHTTPRequestHandler ) :
""" HTTP server that receives Hermes messages and queues them for iLink sending. """
def do_POST ( self ) :
body = self . rfile . read ( int ( self . headers . get ( " Content-Length " , 0 ) ) )
try :
d = json . loads ( body )
log . info ( f " 5801 POST: { json . dumps ( d , ensure_ascii = False ) [ : 200 ] } " )
to = d . get ( " to " , " " ) or d . get ( " wxid " , " " )
msg = d . get ( " message " , " " ) or d . get ( " content " , " " )
path = self . path
# History query endpoint
if path in ( " /history " , " /api/chatHistory " ) :
wxid = d . get ( " wxid " , " " ) or d . get ( " to " , " " )
count = d . get ( " count " , 10 ) or d . get ( " limit " , 10 )
self . _send_json ( {
" ok " : True ,
" note " : " iLink SDK does not support history query. Use Hermes session_search instead. " ,
" messages " : [ ] ,
} )
return
# Stop endpoint
if path == " /stop " :
_message_queue . put_nowait ( { " type " : " stop " } )
self . _send_json ( { " ok " : True , " status " : " stopped " } )
return
# Queue the message for async processing (queue consumed by the iLink message loop)
_message_queue . put_nowait ( {
" type " : " outgoing " ,
" to " : to ,
" message " : msg ,
} )
except Exception as e :
log . error ( f " 5801 handler error: { e } " )
self . send_response ( 200 )
self . end_headers ( )
def do_GET ( self ) :
parsed = urlparse ( self . path )
if parsed . path == " /health " :
self . _send_json ( {
" ok " : True ,
" platform " : " linux-ilink " ,
" mohe_user_id " : MOHE_USER_ID or " not_logged_in " ,
} )
return
if parsed . path in ( " /history " , " /api/chatHistory " ) :
import urllib . parse as up
params = up . parse_qs ( parsed . query )
wxid = params . get ( " wxid " , [ " " ] ) [ 0 ]
count = params . get ( " count " , [ " 10 " ] ) [ 0 ]
self . _send_json ( {
" ok " : True ,
" note " : " iLink SDK does not support history query. " ,
" messages " : [ ] ,
} )
return
self . send_response ( 200 )
self . send_header ( " Content-Type " , " application/json " )
self . end_headers ( )
self . wfile . write ( b ' { " ok " :true} ' )
def _send_json ( self , data ) :
body = json . dumps ( data , ensure_ascii = False ) . encode ( " utf-8 " )
self . send_response ( 200 )
self . send_header ( " Content-Type " , " application/json; charset=utf-8 " )
self . send_header ( " Content-Length " , str ( len ( body ) ) )
self . end_headers ( )
self . wfile . write ( body )
def log_message ( self , * a ) :
pass
def start_http_server ( ) :
""" Start the sync HTTP server in a daemon thread. """
server = HTTPServer ( ( " 0.0.0.0 " , 5801 ) , HermesMsgHandler )
t = threading . Thread ( target = server . serve_forever , daemon = True )
t . start ( )
log . info ( " HTTP :5801 ready (Hermes message receiver) " )
_http_server_ready . set ( )
return server
# ── Queue Consumer ────────────────────────────────────────────
# Consumes the 5801 message queue inside the iLink event loop.
async def consume_outgoing_queue ( bot : WeixinBot ) :
"""
Async task: consumes messages from 5801 queue and sends via iLink.
Runs inside the same event loop as the iLink bot.
"""
while True :
try :
item = await _message_queue . get ( )
msg_type = item . get ( " type " )
if msg_type == " stop " :
log . info ( " Queue consumer: stop signal received " )
bot . stop ( )
break
if msg_type == " outgoing " :
to = item . get ( " to " , " " )
msg = item . get ( " message " , " " )
if to and msg :
try :
await bot . send ( to , msg )
log . info ( f " 5801 -> { to } : { msg [ : 80 ] } " )
except RuntimeError as e :
log . warning ( f " 5801 send failed (no context token for { to } ): { e } " )
except Exception as e :
log . error ( f " 5801 send error: { e } " )
except asyncio . CancelledError :
break
except Exception as e :
log . error ( f " Queue consumer error: { e } " )
# ── Outbound Message Router (replacement for session_router) ──
# Handles messages from xxm/Hermes that need to be sent to WeChat.
# In the iLink world, all outgoing messages go through bot.send().
async def route_hermes_message ( msg_text : str , bot : WeixinBot ) :
""" Route a message from Hermes to WeChat via iLink. """
# For now, route to Dad's user_id if known.
# In practice, the 5801 server with 'to' field is the primary path.
log . info ( f " Hermes message: { msg_text [ : 100 ] } " )
# The 5801 server handles the actual sending with explicit 'to' field.
# This function is for messages without an explicit target.
# ── Main ──────────────────────────────────────────────────────
def main ( ) :
acquire_pid_lock ( )
log . info ( " === MoWeChat Agent (Linux/iLink v1.0) === " )
# Start 5801 HTTP server (in a thread)
http_server = start_http_server ( )
_http_server_ready . wait ( timeout = 5 )
# Create and run the iLink bot
bot = WeixinBot ( )
try :
# Login (QR scan first time, then auto)
log . info ( " Logging in to iLink Bot API... " )
creds = bot . login ( )
global MOHE_USER_ID
MOHE_USER_ID = creds . user_id
log . info ( f " Logged in as { MOHE_USER_ID } " )
except Exception as e :
log . error ( f " Login failed: { e } " )
release_pid_lock ( )
sys . exit ( 1 )
# Register message handler
@bot.on_message
async def handle ( msg : IncomingMessage ) :
await handle_incoming ( msg , bot )
# Register queue consumer (runs inside bot's event loop)
# We schedule this before bot.run() by patching into the _run_loop flow.
# Since _run_loop is private, we use a different approach:
# Override the start of _run_loop by wrapping it.
# Actually, bot.run() creates its own event loop with asyncio.run().
# We need to hook into that loop. Let's save the original _run_loop
# and wrap it to also start the queue consumer.
original_run_loop = bot . _run_loop
async def wrapped_run_loop ( ) :
# Start queue consumer as a background task
asyncio . create_task ( consume_outgoing_queue ( bot ) )
# Run the original loop
await original_run_loop ( )
bot . _run_loop = wrapped_run_loop
try :
log . info ( " Bot starting. Press Ctrl+C to stop. " )
bot . run ( )
except KeyboardInterrupt :
log . info ( " Shutting down... " )
except Exception as e :
log . error ( f " Bot error: { e } " )
finally :
bot . stop ( )
http_server . shutdown ( )
release_pid_lock ( )
log . info ( " Stopped. " )
if __name__ == " __main__ " :
main ( )