#!/usr/bin/env python3 # /// script # dependencies = [ # "cryptography", # "requests", # ] # /// """ Mac mini 家庭 All-in-One 微信双向控制与通知网关 (WeCom <-> Personal WeChat) 监听端口: 3005 (由 Azure https://wx.961504.xyz:443 通过 Tailscale 专线转发) """ import base64 import hashlib import json import os import socket import struct import subprocess import threading import time import urllib.parse import xml.etree.ElementTree as ET from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path import requests from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes BASE_DIR = Path(__file__).resolve().parent CONFIG_PATH = BASE_DIR / "config.env" def load_config() -> dict: cfg = { "WECOM_CORP_ID": "", "WECOM_AGENT_ID": "", "WECOM_SECRET": "", "WECOM_TOKEN": "", "WECOM_AES_KEY": "", "WECOM_TO_USER": "@all", } if CONFIG_PATH.exists(): for line in CONFIG_PATH.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue k, v = line.split("=", 1) cfg[k.strip()] = v.strip().strip('"').strip("'") return cfg class WXBizMsgCrypt: """企业微信官方消息加解密算法实现 (AES-256-CBC + PKCS#7 32字节填充)""" def __init__(self, token: str, encoding_aes_key: str, receive_id: str): self.token = token self.receive_id = receive_id self.key = base64.b64decode(encoding_aes_key + "=") def verify_signature(self, msg_signature: str, timestamp: str, nonce: str, encrypt_msg: str) -> bool: items = sorted([self.token, timestamp, nonce, encrypt_msg]) sha1 = hashlib.sha1("".join(items).encode("utf-8")).hexdigest() return sha1 == msg_signature def decrypt(self, encrypt_msg: str) -> str: cipher_bytes = base64.b64decode(encrypt_msg) cipher = Cipher(algorithms.AES(self.key), modes.CBC(self.key[:16]), backend=default_backend()) decryptor = cipher.decryptor() plain = decryptor.update(cipher_bytes) + decryptor.finalize() pad_len = plain[-1] content = plain[:-pad_len] msg_len = struct.unpack("!I", content[16:20])[0] raw_msg = content[20 : 20 + msg_len].decode("utf-8") return raw_msg TOKEN_CACHE = {"token": "", "expires_at": 0.0} def send_wecom_text(content: str, to_user: str = None) -> dict: cfg = load_config() corp_id = cfg["WECOM_CORP_ID"] secret = cfg["WECOM_SECRET"] agent_id = cfg["WECOM_AGENT_ID"] if not (corp_id and secret and agent_id): return {"errcode": -1, "errmsg": "WeCom credentials not configured yet"} now = time.time() if not TOKEN_CACHE["token"] or now >= TOKEN_CACHE["expires_at"]: resp = requests.get( "https://qyapi.weixin.qq.com/cgi-bin/gettoken", params={"corpid": corp_id, "corpsecret": secret}, timeout=10, ).json() if resp.get("errcode") != 0: return resp TOKEN_CACHE["token"] = resp["access_token"] TOKEN_CACHE["expires_at"] = now + resp.get("expires_in", 7200) - 200 # 企业微信单条文本上限 2048 字节,超长自动截断 if len(content.encode("utf-8")) > 1900: content = content[:600] + "\n...(内容已截断)" payload = { "touser": to_user or cfg.get("WECOM_TO_USER") or "@all", "msgtype": "text", "agentid": int(agent_id), "text": {"content": content}, } return requests.post( f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={TOKEN_CACHE['token']}", json=payload, timeout=10, ).json() def handle_user_command(user_id: str, text: str): text = text.strip() try: if text in ("状态", "/status", "status", "体检"): cmd = ( "export PATH=/opt/homebrew/bin:$PATH; " "echo '🖥️ Mac mini (M6) 状态报告'; " "echo \"⏱️ 运行时间: $(uptime | sed 's/.*up //; s/, [0-9]* users.*//')\"; " "echo \"💾 磁盘剩余: $(df -h / | awk 'NR==2{print $4 \" (已用 \" $5 \")\"}')\"; " "echo \"🌐 家里公网: $(cat ~/homelab/backups/last_wan_ip.txt 2>/dev/null || echo 未知)\"; " "echo \"🐳 运行容器: $(docker ps --format '{{.Names}}' | tr '\\n' ' ')\"" ) out = subprocess.check_output(cmd, shell=True, text=True, timeout=15) send_wecom_text(out.strip(), to_user=user_id) elif text.startswith("/sh "): shell_cmd = text[4:].strip() out = subprocess.check_output( f"export PATH=/opt/homebrew/bin:$HOME/.local/bin:$PATH; {shell_cmd}", shell=True, stderr=subprocess.STDOUT, text=True, timeout=60, ) send_wecom_text(f"✅ 执行完成:\n{out.strip() or '(无输出)'}", to_user=user_id) else: send_wecom_text( f"🤖 Mac mini 已收到指令:\n「{text}」\n\n" "可用快捷指令:\n" "• 发送「状态」:查看 Mac mini 实时健康度与公网 IP\n" "• 发送「/sh <命令>」:远程在 Mac mini 上执行终端命令", to_user=user_id, ) except Exception as e: send_wecom_text(f"⚠️ 执行异常: {e}", to_user=user_id) class GatewayHandler(BaseHTTPRequestHandler): def do_GET(self): parsed = urllib.parse.urlparse(self.path) if parsed.path == "/wechat/callback": qs = urllib.parse.parse_qs(parsed.query) msg_sig = qs.get("msg_signature", [""])[0] timestamp = qs.get("timestamp", [""])[0] nonce = qs.get("nonce", [""])[0] echostr = qs.get("echostr", [""])[0] cfg = load_config() if not (cfg["WECOM_TOKEN"] and cfg["WECOM_AES_KEY"] and cfg["WECOM_CORP_ID"]): self.send_response(400) self.end_headers() self.wfile.write(b"WeCom callback parameters not configured yet") return crypt = WXBizMsgCrypt(cfg["WECOM_TOKEN"], cfg["WECOM_AES_KEY"], cfg["WECOM_CORP_ID"]) if not crypt.verify_signature(msg_sig, timestamp, nonce, echostr): self.send_response(403) self.end_headers() self.wfile.write(b"Signature verification failed") return plain_echo = crypt.decrypt(echostr) self.send_response(200) self.send_header("Content-Type", "text/plain; charset=utf-8") self.end_headers() self.wfile.write(plain_echo.encode("utf-8")) return cfg = load_config() configured = bool(cfg["WECOM_CORP_ID"] and cfg["WECOM_SECRET"]) self.send_response(200) self.send_header("Content-Type", "application/json; charset=utf-8") self.end_headers() self.wfile.write( json.dumps( { "service": "macmini-wechat-gateway", "status": "ok", "wecom_configured": configured, "callback_url": "https://wx.961504.xyz/wechat/callback", "notify_webhook": "https://wx.961504.xyz/notify", }, ensure_ascii=False, ).encode("utf-8") ) def do_POST(self): parsed = urllib.parse.urlparse(self.path) length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(length).decode("utf-8") if parsed.path == "/wechat/callback": qs = urllib.parse.parse_qs(parsed.query) msg_sig = qs.get("msg_signature", [""])[0] timestamp = qs.get("timestamp", [""])[0] nonce = qs.get("nonce", [""])[0] cfg = load_config() crypt = WXBizMsgCrypt(cfg["WECOM_TOKEN"], cfg["WECOM_AES_KEY"], cfg["WECOM_CORP_ID"]) root = ET.fromstring(body) encrypt_node = root.find("Encrypt") if encrypt_node is not None and crypt.verify_signature(msg_sig, timestamp, nonce, encrypt_node.text): xml_plain = crypt.decrypt(encrypt_node.text) msg_root = ET.fromstring(xml_plain) msg_type = msg_root.findtext("MsgType", "") from_user = msg_root.findtext("FromUserName", "@all") if msg_type == "text": content = msg_root.findtext("Content", "") threading.Thread(target=handle_user_command, args=(from_user, content), daemon=True).start() self.send_response(200) self.end_headers() self.wfile.write(b"success") return if parsed.path == "/notify": try: data = json.loads(body) if body else {} msg = data.get("msg") or data.get("text") or body except Exception: msg = body res = send_wecom_text(f"🔔 【家庭服务器通知】\n{msg}") self.send_response(200) self.send_header("Content-Type", "application/json; charset=utf-8") self.end_headers() self.wfile.write(json.dumps(res, ensure_ascii=False).encode("utf-8")) return self.send_response(404) self.end_headers() if __name__ == "__main__": server = ThreadingHTTPServer(("0.0.0.0", 3005), GatewayHandler) print("WeChat Gateway listening on 0.0.0.0:3005...") server.serve_forever()