feat(wechat): deploy WeChat Work bidirectional gateway at https://wx.961504.xyz and lock qyapi to SG Azure
This commit is contained in:
1 parent
e9c44439e6
commit
a9f03f1a08
6 files changed
+280
-3
No files matched your search
@@ -0,0 +1,10 @@
|
|||||||
|
# ==============================================================================
|
||||||
|
# 企业微信自建应用 (接入个人微信) 配置文件
|
||||||
|
# 修改后无需重启,服务会自动热加载或通过 launchctl kickstart 重启
|
||||||
|
# ==============================================================================
|
||||||
|
WECOM_CORP_ID=""
|
||||||
|
WECOM_AGENT_ID=""
|
||||||
|
WECOM_SECRET=""
|
||||||
|
WECOM_TOKEN=""
|
||||||
|
WECOM_AES_KEY=""
|
||||||
|
WECOM_TO_USER="@all"
|
||||||
Executable
+247
@@ -0,0 +1,247 @@
|
|||||||
|
#!/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()
|
||||||
Reference in new issue
Block a user