Files
homelab/stacks/portal/clawbot.py
T

630 lines
28 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
# ==============================================================================
# HomeLab WeChat ClawBot Engine (Tencent iLink Bot Protocol v2.4.10)
# 100% 兼容 @tencent-weixin/openclaw-weixin 官方协议 (ilinkai.weixin.qq.com)
# 支持:
# 1. 生成微信 ClawBot 绑定二维码 & 轮询扫码状态 (支持 IDC 重定向与配对验证码)
# 2. 7x24h 长轮询接收微信消息 (getupdates) + "正在输入..." 状态推送 (sendtyping)
# 3. 微信内直接查询 Mac mini / 极空间状态、触发备份、执行终端指令、与 AGY AI 对话
# ==============================================================================
import base64
import json
import os
import random
import re
import subprocess
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
FIXED_BASE_URL = "https://ilinkai.weixin.qq.com"
DEFAULT_BOT_TYPE = "3"
CHANNEL_VERSION = "2.4.10"
# (2 << 16) | (4 << 8) | 10 = 132106
ILINK_CLIENT_VERSION = str((2 << 16) | (4 << 8) | 10)
ILINK_APP_ID = "bot"
STATE_DIR = Path("/Users/ixvii0iv/homelab/stacks/wechat-bot")
SESSION_FILE = STATE_DIR / "clawbot_session.json"
SYNC_BUF_FILE = STATE_DIR / "clawbot_sync_buf.txt"
DEFAULT_AGY_CONV_ID = "6293ebb4-df60-4db6-bf5d-df33433ba106"
def _random_wechat_uin() -> str:
uint32 = random.randint(10000000, 0xFFFFFFFF)
return base64.b64encode(str(uint32).encode("utf-8")).decode("utf-8")
def _common_headers() -> dict:
return {
"iLink-App-Id": ILINK_APP_ID,
"iLink-App-ClientVersion": ILINK_CLIENT_VERSION,
}
def _post_headers(token: str = "") -> dict:
h = {
"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token",
"X-WECHAT-UIN": _random_wechat_uin(),
**_common_headers(),
}
if token.strip():
h["Authorization"] = f"Bearer {token.strip()}"
return h
def _base_info() -> dict:
return {
"channel_version": CHANNEL_VERSION,
"bot_agent": "OpenClaw",
}
def _parse_lossless_json(raw_text: str) -> dict:
# Quote uint64 message_id / msg_id / svr_id fields so JS/Python keeps exact strings
patched = re.sub(
r'("(?:message_id|msg_id|svr_id)"\s*:\s*)(-?\d+)',
r'\1"\2"',
raw_text,
)
return json.loads(patched)
def _api_get(base_url: str, endpoint: str, timeout: float = 36.0) -> dict:
url = base_url.rstrip("/") + "/" + endpoint.lstrip("/")
req = urllib.request.Request(url, headers=_common_headers(), method="GET")
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
return _parse_lossless_json(raw)
def _api_post(base_url: str, endpoint: str, payload: dict, token: str = "", timeout: float = 20.0) -> dict:
url = base_url.rstrip("/") + "/" + endpoint.lstrip("/")
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(url, data=data, headers=_post_headers(token), method="POST")
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
return _parse_lossless_json(raw)
class ClawBotManager:
def __init__(self, telemetry_fn=None, action_runner_fn=None):
STATE_DIR.mkdir(parents=True, exist_ok=True)
self.telemetry_fn = telemetry_fn
self.action_runner_fn = action_runner_fn
self._lock = threading.Lock()
# Saved session credentials
self.session = self._load_session()
# Active QR login state
self.login_state = {
"active": False,
"qrcode": "",
"qrcode_url": "",
"status": "idle", # idle | wait | scaned | need_verifycode | confirmed | expired | error
"message": "尚未发起扫码登录",
"api_base_url": FIXED_BASE_URL,
"pending_verify_code": "",
"started_at": 0.0,
}
# Runtime monitor stats
self.last_event_ts = 0.0
self.last_inbound_text = ""
self.last_reply_text = ""
self.msg_count = 0
self._stop_event = threading.Event()
self._monitor_thread = None
if self.is_connected():
self._start_monitor_thread()
def _load_session(self) -> dict:
if SESSION_FILE.exists():
try:
return json.loads(SESSION_FILE.read_text(encoding="utf-8"))
except Exception:
pass
return {}
def _save_session(self, data: dict):
self.session = data
SESSION_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def is_connected(self) -> bool:
return bool(self.session.get("bot_token") and self.session.get("ilink_bot_id"))
def get_public_status(self) -> dict:
return {
"connected": self.is_connected(),
"bot_id": self.session.get("ilink_bot_id", ""),
"user_id": self.session.get("ilink_user_id", ""),
"base_url": self.session.get("baseurl", FIXED_BASE_URL),
"connected_at": self.session.get("connected_at", ""),
"msg_count": self.msg_count,
"last_inbound_text": self.last_inbound_text,
"last_reply_text": self.last_reply_text,
"login": {
"active": self.login_state["active"],
"qrcode_url": self.login_state["qrcode_url"],
"status": self.login_state["status"],
"message": self.login_state["message"],
},
}
# ------------------------------------------------------------------
# 1. QR Code Login Flow
# ------------------------------------------------------------------
def start_qr_login(self) -> dict:
try:
local_tokens = [self.session["bot_token"]] if self.session.get("bot_token") else []
resp = _api_post(
FIXED_BASE_URL,
f"ilink/bot/get_bot_qrcode?bot_type={DEFAULT_BOT_TYPE}",
{"local_token_list": local_tokens},
timeout=12.0,
)
qrcode = resp.get("qrcode", "")
qrcode_url = resp.get("qrcode_img_content", "")
if not qrcode or not qrcode_url:
raise RuntimeError(f"未获取到二维码: {resp}")
with self._lock:
self.login_state = {
"active": True,
"qrcode": qrcode,
"qrcode_url": qrcode_url,
"status": "wait",
"message": "请使用手机微信扫描二维码(或在微信 ClawBot 页面点击「开始扫一扫」)",
"api_base_url": FIXED_BASE_URL,
"pending_verify_code": "",
"started_at": time.time(),
}
t = threading.Thread(target=self._poll_qr_worker, args=(qrcode,), daemon=True)
t.start()
return {"ok": True, **self.get_public_status()}
except Exception as e:
with self._lock:
self.login_state["status"] = "error"
self.login_state["message"] = f"获取二维码失败: {e}"
return {"ok": False, "message": str(e)}
def submit_verify_code(self, code: str) -> dict:
clean = code.strip()
if not clean:
return {"ok": False, "message": "请输入有效的数字配对码"}
with self._lock:
self.login_state["pending_verify_code"] = clean
self.login_state["message"] = f"已提交配对码 {clean},正在向微信服务器验证..."
return {"ok": True, **self.get_public_status()}
def disconnect(self) -> dict:
self._stop_event.set()
if SESSION_FILE.exists():
SESSION_FILE.unlink()
if SYNC_BUF_FILE.exists():
SYNC_BUF_FILE.unlink()
self.session = {}
with self._lock:
self.login_state["active"] = False
self.login_state["status"] = "idle"
self.login_state["message"] = "已断开微信 ClawBot 连接"
return {"ok": True, **self.get_public_status()}
def _poll_qr_worker(self, initial_qrcode: str):
deadline = time.time() + 300.0 # 5 minutes
while time.time() < deadline:
with self._lock:
if not self.login_state["active"] or self.login_state["qrcode"] != initial_qrcode:
return
base_url = self.login_state["api_base_url"]
qrcode = self.login_state["qrcode"]
verify_code = self.login_state["pending_verify_code"]
endpoint = f"ilink/bot/get_qrcode_status?qrcode={urllib.parse.quote(qrcode)}"
if verify_code:
endpoint += f"&verify_code={urllib.parse.quote(verify_code)}"
try:
resp = _api_get(base_url, endpoint, timeout=35.0)
except Exception:
resp = {"status": "wait"}
st = resp.get("status", "wait")
with self._lock:
if self.login_state["qrcode"] != initial_qrcode:
return
self.login_state["status"] = st
if st == "wait":
pass
elif st == "scaned":
self.login_state["pending_verify_code"] = ""
self.login_state["message"] = "📱 已扫码!请在手机微信上点击确认授权..."
elif st == "scaned_but_redirect":
r_host = resp.get("redirect_host", "")
if r_host:
self.login_state["api_base_url"] = f"https://{r_host}"
self.login_state["message"] = "📱 已扫码,正在切换微信数据中心节点..."
elif st == "need_verifycode":
if self.login_state["pending_verify_code"]:
self.login_state["message"] = "❌ 配对码不匹配,请重新输入手机微信上显示的数字:"
self.login_state["pending_verify_code"] = ""
else:
self.login_state["message"] = "🔢 请输入手机微信屏幕上显示的配对数字:"
elif st == "binded_redirect":
self.login_state["active"] = False
self.login_state["message"] = "✅ 此微信已绑定过当前节点!"
return
elif st in ("expired", "verify_code_blocked"):
self.login_state["active"] = False
self.login_state["message"] = "⏳ 二维码已过期,请点击「刷新二维码」重新扫描"
return
elif st == "confirmed":
bot_token = resp.get("bot_token", "")
bot_id = resp.get("ilink_bot_id", "")
baseurl = resp.get("baseurl") or FIXED_BASE_URL
user_id = resp.get("ilink_user_id", "")
self._save_session(
{
"bot_token": bot_token,
"ilink_bot_id": bot_id,
"baseurl": baseurl,
"ilink_user_id": user_id,
"connected_at": time.strftime("%Y-%m-%d %H:%M:%S"),
}
)
self.login_state["active"] = False
self.login_state["message"] = "🎉 微信 ClawBot 绑定成功!现在可以直接在微信里收发消息了"
self._start_monitor_thread()
# Send immediate welcome message to the user on WeChat!
if user_id:
threading.Thread(
target=self.send_text_message,
args=(
user_id,
"✅ M6 Mac mini (HomeLab & AGY AI) 已成功连接微信 ClawBot!\n\n"
"你可以直接发送以下内容与我交互:\n"
"• 发送「状态」:查看 M6 Mac mini 与极空间 Z4 实时遥测\n"
"• 发送「备份」:立即打包备份核心数据至极空间 20TB\n"
"• 发送「屏幕」:查看 AGY 云端终端当前实时画面\n"
"• 发送「$ 命令」:在 Mac mini 上执行终端命令 (如 $ df -h)\n"
"• 直接发任何问题:直接转发给 Mac mini 上的 AI 助手回答",
),
daemon=True,
).start()
return
time.sleep(1.0)
# ------------------------------------------------------------------
# 2. Long-Poll Monitor & Outbound Messaging
# ------------------------------------------------------------------
def _start_monitor_thread(self):
if self._monitor_thread and self._monitor_thread.is_alive():
return
self._stop_event.clear()
self._monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True)
self._monitor_thread.start()
def send_text_message(self, to_user_id: str, text: str, context_token: str = "", run_id: str = ""):
if not self.is_connected() or not to_user_id or not text:
return
base_url = self.session.get("baseurl") or FIXED_BASE_URL
token = self.session.get("bot_token", "")
client_id = f"openclaw-weixin-{uuid.uuid4().hex[:12]}"
msg_obj = {
"from_user_id": "",
"to_user_id": to_user_id,
"client_id": client_id,
"message_type": 2, # BOT
"message_state": 2, # FINISH
"item_list": [{"type": 1, "text_item": {"text": text}}],
}
if context_token:
msg_obj["context_token"] = context_token
if run_id:
msg_obj["run_id"] = run_id
payload = {
"msg": msg_obj,
"base_info": _base_info(),
}
try:
_api_post(base_url, "ilink/bot/sendmessage", payload, token=token, timeout=15.0)
self.last_reply_text = text[:120]
except Exception as e:
print(f"[ClawBot] send_text_message error: {e}")
def _send_typing(self, to_user_id: str, context_token: str = ""):
if not self.is_connected() or not to_user_id:
return
base_url = self.session.get("baseurl") or FIXED_BASE_URL
token = self.session.get("bot_token", "")
try:
cfg = _api_post(
base_url,
"ilink/bot/getconfig",
{
"ilink_user_id": to_user_id,
"context_token": context_token,
"base_info": _base_info(),
},
token=token,
timeout=8.0,
)
ticket = cfg.get("typing_ticket", "")
if ticket:
_api_post(
base_url,
"ilink/bot/sendtyping",
{
"ilink_user_id": to_user_id,
"typing_ticket": ticket,
"status": 1,
"base_info": _base_info(),
},
token=token,
timeout=8.0,
)
except Exception:
pass
def _monitor_loop(self):
base_url = self.session.get("baseurl") or FIXED_BASE_URL
token = self.session.get("bot_token", "")
try:
_api_post(
base_url,
"ilink/bot/msg/notifystart",
{"base_info": _base_info()},
token=token,
timeout=10.0,
)
except Exception:
pass
sync_buf = ""
if SYNC_BUF_FILE.exists():
try:
sync_buf = SYNC_BUF_FILE.read_text(encoding="utf-8").strip()
except Exception:
pass
while not self._stop_event.is_set() and self.is_connected():
base_url = self.session.get("baseurl") or FIXED_BASE_URL
token = self.session.get("bot_token", "")
try:
resp = _api_post(
base_url,
"ilink/bot/getupdates",
{"get_updates_buf": sync_buf, "base_info": _base_info()},
token=token,
timeout=38.0,
)
new_buf = resp.get("get_updates_buf")
if new_buf:
sync_buf = new_buf
SYNC_BUF_FILE.write_text(sync_buf, encoding="utf-8")
msgs = resp.get("msgs") or []
for m in msgs:
# Only handle USER messages (message_type == 1)
if m.get("message_type") == 2:
continue
from_user = m.get("from_user_id", "")
ctx_token = m.get("context_token", "")
run_id = m.get("run_id", "")
items = m.get("item_list") or []
text_parts = []
for it in items:
t_type = it.get("type")
if t_type == 1 and it.get("text_item", {}).get("text"):
text_parts.append(it["text_item"]["text"])
elif t_type == 3 and it.get("voice_item", {}).get("text"):
text_parts.append(it["voice_item"]["text"])
user_text = "\n".join(text_parts).strip()
if from_user and user_text:
self.msg_count += 1
self.last_inbound_text = user_text
self.last_event_ts = time.time()
threading.Thread(
target=self._handle_inbound_message,
args=(from_user, user_text, ctx_token, run_id),
daemon=True,
).start()
except urllib.error.URLError as e:
# Normal long-poll timeout
if "timed out" in str(e).lower():
continue
time.sleep(2.0)
except TimeoutError:
continue
except Exception as e:
if "timed out" in str(e).lower():
continue
print(f"[ClawBot] getupdates error: {e}")
time.sleep(3.0)
# ------------------------------------------------------------------
# 3. Intelligent Command & AI Router for WeChat Messages
# ------------------------------------------------------------------
def _handle_inbound_message(self, from_user: str, text: str, ctx_token: str, run_id: str):
self._send_typing(from_user, ctx_token)
cmd = text.strip()
low = cmd.lower()
# 1. Help
if low in ("帮助", "help", "/help", "?"):
reply = (
"🍎 HomeLab & AGY 微信中控台指令清单:\n"
"━━━━━━━━━━━━━━\n"
"1️⃣ 发送「状态」或 /status\n"
" 查看 M6 Mac mini、极空间 20TB、联通公网 IP 与狮城专线实时指标\n"
"2️⃣ 发送「备份」或 /backup\n"
" 立即将 Vaultwarden 与核心配置打包灾备至极空间 Z4\n"
"3️⃣ 发送「刷新」或 /ddns\n"
" 立即刷新 Cloudflare 泛域名 DDNS 解析\n"
"4️⃣ 发送「屏幕」或 /screen\n"
" 抓取 Mac mini 上 tmux (agy) 终端的实时画面\n"
"5️⃣ 发送「$ <终端命令>」\n"
" 在 Mac mini 上直接运行 Shell 命令并返回结果 (如: $ df -h)\n"
"6️⃣ 发送「> <按键内容>」\n"
" 直接向 Mac mini 的 tmux (agy) 终端输入文字并回车\n"
"7️⃣ 直接发送任意问题\n"
" 自动调用 Mac mini AI 助手为你解答或执行任务"
)
self.send_text_message(from_user, reply, ctx_token, run_id)
return
# 2. Telemetry Status
if low in ("状态", "status", "/status", "监控"):
if self.telemetry_fn:
t = self.telemetry_fn()
mini = t["mini"]
nas = t["nas"]
net = t["network"]
reply = (
f"🍏 HomeLab 实时状态 ({t['timestamp']})\n"
f"━━━━━━━━━━━━━━\n"
f"🖥 M6 Mac mini ({mini['ip']})\n"
f"• CPU 负载: {mini['cpu_pct']}% (1m: {mini['load_1m']})\n"
f"• 内存占用: {mini['mem_used_gb']} / {mini['mem_total_gb']} GB ({mini['mem_pct']}%)\n"
f"• SSD 占用: {mini['disk_used_gb']} / {mini['disk_total_gb']} GB ({mini['disk_pct']}%)\n"
f"• 在线时长: {mini['uptime']}\n"
f"• AGY 终端: {'🟢 常驻运行中' if mini['agy_tmux'] else '⚪ 待机'}\n\n"
f"💾 极空间 Z4 ({nas['ip']})\n"
f"• 可用余量: {nas['free_tb']} TB / {nas['total_tb']} TB\n"
f"• 最近灾备: {nas['latest_backup']}\n\n"
f"🌐 网络与 AI 算力专线\n"
f"• 家庭公网 IP: {net['home_wan_ip']}\n"
f"• AI 分流策略: {net['ai_policy']}\n"
f"• 狮城节点延迟: {net['sg_delay_ms']} ms\n"
f"• 免端口中控台: https://nav.961504.xyz"
)
else:
reply = "状态采集接口未就绪"
self.send_text_message(from_user, reply, ctx_token, run_id)
return
# 3. Backup to ZSpace NAS
if low in ("备份", "backup", "/backup"):
out = self._exec_shell("/Users/ixvii0iv/homelab/scripts/backup-to-nas.sh", timeout=20.0)
self.send_text_message(from_user, f"✅ 极空间 Z4 灾备执行完毕:\n{out or '已完成备份'}", ctx_token, run_id)
return
# 4. Refresh DDNS
if low in ("刷新", "ddns", "/ddns"):
out = self._exec_shell("/Users/ixvii0iv/homelab/scripts/cloudflare-ddns.sh", timeout=15.0)
self.send_text_message(from_user, f"✅ Cloudflare DDNS 刷新结果:\n{out or '已同步最新公网 IP'}", ctx_token, run_id)
return
# 5. Capture tmux screen
if low in ("屏幕", "screen", "/screen", "tmux"):
out = self._exec_shell("/opt/homebrew/bin/tmux capture-pane -t agy -p | tail -n 25", timeout=5.0)
self.send_text_message(
from_user,
f"🖥 AGY 终端 (tmux: agy) 最新画面:\n━━━━━━━━━━━━━━\n{out or '(空)'}",
ctx_token,
run_id,
)
return
# 6. Direct Shell Command ($ ...)
if cmd.startswith("$"):
shell_cmd = cmd[1:].strip()
if not shell_cmd:
self.send_text_message(from_user, "请在 $ 后输入要执行的命令,例如:$ df -h", ctx_token, run_id)
return
out = self._exec_shell(shell_cmd, timeout=25.0)
if len(out) > 3500:
out = out[-3500:] + "\n...(已截断)"
self.send_text_message(from_user, f"💻 终端执行结果 ($ {shell_cmd}):\n━━━━━━━━━━━━━━\n{out or '(无输出)'}", ctx_token, run_id)
return
# 7. Inject keys into tmux agy session (> ...)
if cmd.startswith(">"):
keys = cmd[1:].strip()
safe_keys = keys.replace('"', '\\"')
self._exec_shell(f'/opt/homebrew/bin/tmux send-keys -t agy "{safe_keys}" C-m', timeout=5.0)
time.sleep(2.5)
out = self._exec_shell("/opt/homebrew/bin/tmux capture-pane -t agy -p | tail -n 25", timeout=5.0)
self.send_text_message(
from_user,
f"⌨️ 已向 AGY 终端发送指令并捕获最新画面:\n━━━━━━━━━━━━━━\n{out or '(已发送)'}",
ctx_token,
run_id,
)
return
# 7b. One-step Google OAuth Code injection from WeChat (e.g. "4/0A..." or "授权 4/0A...")
if cmd.startswith("4/") or low.startswith("授权 ") or low.startswith("auth "):
auth_code = cmd.split(None, 1)[-1].strip() if (" " in cmd) else cmd.strip()
safe_code = auth_code.replace('"', '\\"')
self._exec_shell(f'/opt/homebrew/bin/tmux send-keys -t agy "{safe_code}" C-m', timeout=5.0)
time.sleep(5.0)
out = self._exec_shell("/opt/homebrew/bin/tmux capture-pane -t agy -p | tail -n 20", timeout=5.0)
self.send_text_message(
from_user,
f"🔐 已将 Google 授权码注入 Mac mini 的 AGY 终端!\n"
f"已挂载当前会话 ID: {DEFAULT_AGY_CONV_ID}\n"
f"━━━━━━━━━━━━━━\n{out or '(授权处理中)'}\n\n"
f"现在你可以直接在微信里和我继续刚才的对话了!",
ctx_token,
run_id,
)
return
# 8. Natural language -> Resume exact migrated session (6293ebb4-df60-4db6-bf5d-df33433ba106) on Mac mini!
agy_out = self._exec_shell(
f'cd /Users/ixvii0iv/homelab && /Users/ixvii0iv/.local/bin/agy --conversation {DEFAULT_AGY_CONV_ID} --dangerously-skip-permissions -p {json.dumps(cmd)}',
timeout=120.0,
)
if agy_out and "authentication required" not in agy_out.lower():
if len(agy_out) > 3800:
agy_out = agy_out[:3800] + "\n...(内容较长,完整输出请查看 https://agy.961504.xyz)"
self.send_text_message(from_user, agy_out, ctx_token, run_id)
else:
oauth_url = (
"https://accounts.google.com/o/oauth2/auth?access_type=offline"
"&client_id=1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
"&code_challenge=6R2ML1GiPTslGESIIdY7kBlWPGi3Z7J_SUKiEb-wVc0&code_challenge_method=S256"
"&prompt=consent&redirect_uri=https%3A%2F%2Fantigravity.google%2Foauth-callback"
"&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform+"
"https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+"
"https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+"
"https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcclog+"
"https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fexperimentsandconfigs+"
"https%3A%2F%2Fwww.googleapis.com%2Fauth%2Faicode+openid&state=EHQdZm-HmDWTKGqBFxqf1g"
)
self.send_text_message(
from_user,
f"🤖 当前会话 ({DEFAULT_AGY_CONV_ID[:8]}...) 已同步至 Mac mini!\n\n"
"🔐 只需最后一步激活 Mac mini 的 Google AI 登录态:\n"
f"1️⃣ 点击打开此链接登录 Google:\n{oauth_url}\n\n"
"2️⃣ 复制页面生成的授权码(以 4/ 开头),直接发到这个微信对话框里,即可在微信无缝接力我们当前的完整会话!",
ctx_token,
run_id,
)
def _exec_shell(self, cmd: str, timeout: float = 15.0) -> str:
try:
env = os.environ.copy()
env["PATH"] = "/Users/ixvii0iv/.local/bin:/Users/ixvii0iv/.orbstack/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
env["HOME"] = "/Users/ixvii0iv"
env["LANG"] = "en_US.UTF-8"
res = subprocess.run(
cmd, shell=True, capture_output=True, text=True, timeout=timeout, env=env
)
out = (res.stdout or "") + ("\n" + res.stderr if res.stderr else "")
return out.strip()
except Exception as e:
return f"执行超时或异常: {e}"