Files
autman-plugins/plugins/simadmin/simadmin_multi.py
T

409 lines
14 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.
# [title: SimAdmin多实例管理]
# [language: python]
# [service: BOSS]
# [disable: false]
# [admin: true]
# [rule: ^sim帮助$]
# [rule: ^sim配置$]
# [rule: ^sim状态(?:\s+\S+)?$]
# [rule: ^sim短信\s+\S+\s+\S+\s+[\s\S]+$]
# [platform: qq,wx,tg,tb,fs]
# [open_source: true]
# [version: 1.0.0]
# [public: false]
# [price: 0]
# [description: 管理员专用:兼容多个 SimAdmin 实例,支持状态查询和短信发送。配置桶 otto/simadmin_instancesJSON数组或别名=URL多行。]
"""
Autman Python 插件:SimAdmin 多实例状态查询和短信发送。
配置方式(Autman 数据桶):
- bucket: otto
- key: simadmin_instances
- value 支持两种格式:
1) JSON 数组(推荐):
[
{"name":"主卡", "base_url":"http://192.168.68.1:3000", "token":"可选", "timeout":10},
{"name":"副卡", "base_url":"http://192.168.68.2:3000"}
]
2) 多行简写:
主卡=http://192.168.68.1:3000
副卡=http://192.168.68.2:3000,token=xxx,timeout=10
指令:
- sim帮助
- sim配置
- sim状态 查询全部实例
- sim状态 主卡 查询指定实例
- sim短信 主卡 10086 CXLL
- sim短信 all 10086 测试短信 (向全部实例发送,不建议滥用)
"""
import json
import re
import sys
import traceback
import urllib.error
import urllib.parse
import urllib.request
import middleware
BUCKET = "otto"
CONFIG_KEY = "simadmin_instances"
DEFAULT_TIMEOUT = 10
class SimAdminError(Exception):
pass
def get_sender():
return middleware.Sender(middleware.getSenderID())
def reply(sender, text):
sender.reply(str(text))
def is_blank(value):
return value is None or str(value).strip() == ""
def mask_secret(value):
if not value:
return ""
s = str(value)
if len(s) <= 8:
return "***"
return s[:3] + "***" + s[-3:]
def normalize_base_url(url):
if is_blank(url):
raise SimAdminError("base_url 不能为空")
url = str(url).strip().rstrip("/")
if not re.match(r"^https?://", url, re.I):
raise SimAdminError("base_url 必须以 http:// 或 https:// 开头: " + url)
return url
def parse_bool(value, default=False):
if value is None:
return default
s = str(value).strip().lower()
if s in ("1", "true", "yes", "y", "on", "启用", ""):
return True
if s in ("0", "false", "no", "n", "off", "禁用", ""):
return False
return default
def normalize_instance(raw, index=0):
if not isinstance(raw, dict):
raise SimAdminError("实例配置必须是对象")
name = str(raw.get("name") or raw.get("alias") or raw.get("id") or ("sim" + str(index + 1))).strip()
base_url = normalize_base_url(raw.get("base_url") or raw.get("url") or raw.get("host"))
timeout = raw.get("timeout", raw.get("timeout_seconds", DEFAULT_TIMEOUT))
try:
timeout = int(timeout)
except Exception:
timeout = DEFAULT_TIMEOUT
timeout = max(3, min(timeout, 60))
token = str(raw.get("token") or raw.get("access_token") or raw.get("api_key") or "").strip()
headers = raw.get("headers") if isinstance(raw.get("headers"), dict) else {}
enabled = parse_bool(raw.get("enabled"), True)
return {
"name": name,
"base_url": base_url,
"timeout": timeout,
"token": token,
"headers": headers,
"enabled": enabled,
}
def parse_line_config(text):
instances = []
for line_no, line in enumerate(str(text).splitlines(), 1):
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
raise SimAdminError("%s 行配置缺少 =" % line_no)
name, rest = line.split("=", 1)
name = name.strip()
parts = [p.strip() for p in rest.split(",") if p.strip()]
if not parts:
raise SimAdminError("%s 行 URL 不能为空" % line_no)
raw = {"name": name, "base_url": parts[0]}
for part in parts[1:]:
if "=" in part:
k, v = part.split("=", 1)
raw[k.strip()] = v.strip()
instances.append(normalize_instance(raw, len(instances)))
return instances
def load_instances():
raw = middleware.bucketGet(BUCKET, CONFIG_KEY)
if is_blank(raw):
raise SimAdminError(
"未配置 SimAdmin 实例。请在数据桶 %s/%s 写入 JSON数组或多行 alias=url 配置。" % (BUCKET, CONFIG_KEY)
)
text = str(raw).strip()
if text.startswith("[") or text.startswith("{"):
try:
data = json.loads(text)
except Exception as e:
raise SimAdminError("SimAdmin 配置 JSON 解析失败: " + str(e))
if isinstance(data, dict):
if isinstance(data.get("instances"), list):
data = data["instances"]
else:
data = [data]
if not isinstance(data, list):
raise SimAdminError("SimAdmin 配置必须是 JSON 数组或对象")
instances = [normalize_instance(item, i) for i, item in enumerate(data)]
else:
instances = parse_line_config(text)
instances = [x for x in instances if x.get("enabled")]
if not instances:
raise SimAdminError("没有启用的 SimAdmin 实例")
names = set()
for item in instances:
if item["name"] in names:
raise SimAdminError("SimAdmin 实例名称重复: " + item["name"])
names.add(item["name"])
return instances
def request_json(instance, method, path, body=None):
base_url = instance["base_url"]
url = base_url + path
data = None
headers = {"Accept": "application/json"}
if body is not None:
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
headers.update(instance.get("headers") or {})
# SimAdmin 当前 API 无鉴权;这里兼容未来带 token 的反代/网关。
token = instance.get("token") or ""
if token:
headers.setdefault("Authorization", "Bearer " + token)
headers.setdefault("X-API-Token", token)
req = urllib.request.Request(url, data=data, headers=headers, method=method.upper())
try:
with urllib.request.urlopen(req, timeout=instance["timeout"]) as resp:
raw = resp.read().decode("utf-8", errors="replace")
status_code = getattr(resp, "status", resp.getcode())
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
raise SimAdminError("HTTP %s: %s" % (e.code, raw[:300]))
except Exception as e:
raise SimAdminError(str(e))
if not raw:
return {"status_code": status_code}
try:
payload = json.loads(raw)
except Exception:
return {"status_code": status_code, "raw": raw}
if isinstance(payload, dict) and payload.get("status") == "error":
raise SimAdminError(str(payload.get("message") or payload))
return payload
def unwrap_data(payload):
if isinstance(payload, dict) and isinstance(payload.get("data"), dict):
return payload.get("data")
return payload if isinstance(payload, dict) else {}
def pick(data, *keys, **kwargs):
default = kwargs.get("default", "-")
if not isinstance(data, dict):
return default
for key in keys:
if key in data and data[key] not in (None, ""):
return data[key]
return default
def fmt_bytes(value):
try:
n = float(value)
except Exception:
return str(value) if value not in (None, "") else "-"
units = ["B", "KB", "MB", "GB", "TB"]
idx = 0
while n >= 1024 and idx < len(units) - 1:
n /= 1024.0
idx += 1
if idx == 0:
return "%d%s" % (n, units[idx])
return "%.1f%s" % (n, units[idx])
def fmt_percent(value):
if value in (None, ""):
return "-"
try:
n = float(value)
if n <= 1:
n *= 100
return "%.1f%%" % n
except Exception:
return str(value)
def format_instance_status(instance):
name = instance["name"]
lines = ["%s" % name]
try:
health = request_json(instance, "GET", "/api/health")
sim = unwrap_data(request_json(instance, "GET", "/api/sim"))
network = unwrap_data(request_json(instance, "GET", "/api/network"))
stats = unwrap_data(request_json(instance, "GET", "/api/stats"))
sms_stats = unwrap_data(request_json(instance, "GET", "/api/sms/stats"))
health_status = pick(health, "status", default="ok")
version = pick(health, "version", default="-")
iccid = pick(sim, "iccid", "ICCID")
imsi = pick(sim, "imsi", "IMSI")
msisdn = pick(sim, "phone_number", "msisdn", "number", "phone")
operator = pick(network, "operator", "operator_name", "network_operator", "provider")
reg = pick(network, "registration_state", "reg_state", "status", "state")
signal = pick(network, "signal_strength", "signal", "rssi", "rsrp")
uptime = pick(stats, "uptime", "uptime_text", "system_uptime")
cpu = pick(stats, "cpu_usage", "cpu_percent", "cpu")
mem = pick(stats, "memory_usage", "memory_percent", "mem")
temp = pick(stats, "temperature", "temp", "cpu_temperature")
sent = pick(sms_stats, "sent", "sent_count", "outbox", default="-")
inbox = pick(sms_stats, "received", "received_count", "inbox", default="-")
lines.append("健康: %s 版本: %s" % (health_status, version))
lines.append("SIM: %s" % (msisdn if msisdn != "-" else "号码未知"))
lines.append("ICCID: %s" % iccid)
if imsi != "-":
lines.append("IMSI: %s" % imsi)
lines.append("网络: %s / %s / 信号:%s" % (operator, reg, signal))
lines.append("系统: CPU %s / MEM %s / 温度 %s / 运行 %s" % (fmt_percent(cpu), fmt_percent(mem), temp, uptime))
lines.append("短信: 收 %s / 发 %s" % (inbox, sent))
except Exception as e:
lines.append("查询失败: " + str(e))
return "\n".join(lines)
def find_instances(instances, target):
target = (target or "").strip()
if target in ("", "全部", "all", "ALL", "*"):
return instances
matched = [x for x in instances if x["name"] == target]
if matched:
return matched
matched = [x for x in instances if target.lower() in x["name"].lower()]
if matched:
return matched
raise SimAdminError("未找到 SimAdmin 实例: %s。可用: %s" % (target, ", ".join(x["name"] for x in instances)))
def handle_status(sender, message):
instances = load_instances()
parts = message.split(None, 1)
target = parts[1].strip() if len(parts) > 1 else ""
selected = find_instances(instances, target)
blocks = [format_instance_status(x) for x in selected]
reply(sender, "\n\n".join(blocks))
def valid_phone(phone):
# 兼容手机号、服务号、国际号码;只做安全字符校验,不强制中国手机号。
return bool(re.match(r"^\+?[0-9][0-9\- ]{1,30}$", phone or ""))
def handle_sms(sender, message):
m = re.match(r"^sim短信\s+(\S+)\s+(\S+)\s+([\s\S]+)$", message)
if not m:
raise SimAdminError("格式错误:sim短信 <实例名|all> <手机号> <内容>")
target, phone, content = m.group(1), m.group(2), m.group(3).strip()
if not valid_phone(phone):
raise SimAdminError("手机号/号码格式不合法: " + phone)
if not content:
raise SimAdminError("短信内容不能为空")
if len(content) > 1000:
raise SimAdminError("短信内容过长,已拒绝发送")
instances = load_instances()
selected = find_instances(instances, target)
results = []
for instance in selected:
try:
payload = request_json(instance, "POST", "/api/sms/send", {
"phone_number": phone,
"content": content,
})
data = unwrap_data(payload)
path = pick(data, "path", "message_path", "id", default="")
suffix = "" if not path else " path=" + str(path)
results.append("%s 发送成功%s" % (instance["name"], suffix))
except Exception as e:
results.append("%s 发送失败: %s" % (instance["name"], str(e)))
reply(sender, "短信发送结果:\n" + "\n".join(results))
def handle_config(sender):
instances = load_instances()
lines = ["SimAdmin 实例配置:"]
for item in instances:
lines.append("- %s => %s timeout=%ss token=%s" % (
item["name"], item["base_url"], item["timeout"], mask_secret(item.get("token")) or "未设置"
))
reply(sender, "\n".join(lines))
def handle_help(sender):
reply(sender, "\n".join([
"SimAdmin 多实例管理(仅管理员)",
"配置桶:otto / simadmin_instances",
"指令:",
"- sim帮助",
"- sim配置",
"- sim状态 查询全部实例",
"- sim状态 <实例名> 查询指定实例",
"- sim短信 <实例名|all> <号码> <内容>",
"示例:sim短信 主卡 10086 CXLL",
]))
def main():
sender = get_sender()
try:
message = (sender.getMessage() or "").strip().strip('"')
if not sender.isAdmin():
reply(sender, "仅管理员可用")
return
if message == "sim帮助":
handle_help(sender)
elif message == "sim配置":
handle_config(sender)
elif message.startswith("sim状态"):
handle_status(sender, message)
elif message.startswith("sim短信"):
handle_sms(sender, message)
else:
handle_help(sender)
except SimAdminError as e:
reply(sender, "SimAdmin插件错误:" + str(e))
except Exception as e:
traceback.print_exc(file=sys.stderr)
reply(sender, "SimAdmin插件异常:" + str(e))
if __name__ == "__main__":
main()