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

554 lines
20 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管理$]
# [rule: ^sim状态(?:\s+\S+)?$]
# [rule: ^sim短信\s+\S+\s+\S+\s+[\s\S]+$]
# [platform: qq,wx,tg,tb,fs]
# [open_source: true]
# [version: 1.1.0]
# [public: false]
# [price: 0]
# [description: 管理员专用:兼容多个 SimAdmin 实例,支持多级菜单配置管理、状态查询和短信发送。配置桶 otto/simadmin_instances。]
"""
Autman Python 插件:SimAdmin 多实例状态查询、短信发送、多级菜单配置管理。
配置桶:otto / simadmin_instances
指令:
- sim帮助
- sim配置 / sim管理 进入多级菜单配置管理
- sim状态 查询全部实例
- sim状态 主卡 查询指定实例
- sim短信 主卡 10086 CXLL
- sim短信 all 10086 测试短信
"""
import json
import re
import sys
import traceback
import urllib.error
import urllib.request
import middleware
BUCKET = "otto"
CONFIG_KEY = "simadmin_instances"
DEFAULT_TIMEOUT = 10
LISTEN_TIMEOUT_MS = 120000
class SimAdminError(Exception):
pass
def get_sender():
return middleware.Sender(middleware.getSenderID())
def reply(sender, text):
sender.reply(str(text))
def listen_text(sender, prompt, allow_empty=False, default=None):
if prompt:
reply(sender, prompt)
value = sender.listen(LISTEN_TIMEOUT_MS)
if value is None:
raise SimAdminError("等待输入超时,已退出")
value = str(value).strip().strip('"')
if value.lower() in ("q", "quit", "exit", "退出", "取消"):
raise SimAdminError("已取消")
if value == "" and default is not None:
return default
if value == "" and not allow_empty:
raise SimAdminError("输入不能为空,已退出")
return value
def confirm(sender, prompt="确认执行?输入 yes 确认,其他取消"):
value = listen_text(sender, prompt, allow_empty=True)
return value.lower() in ("yes", "y", "确认", "")
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)
parts = [p.strip() for p in rest.split(",") if p.strip()]
if not parts:
raise SimAdminError("%s 行 URL 不能为空" % line_no)
raw = {"name": name.strip(), "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(include_disabled=False, allow_empty=False):
raw = middleware.bucketGet(BUCKET, CONFIG_KEY)
if is_blank(raw):
if allow_empty:
return []
raise SimAdminError("未配置 SimAdmin 实例。发送 sim管理 通过多级菜单添加。")
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)
if not include_disabled:
instances = [x for x in instances if x.get("enabled")]
if not instances and not allow_empty:
raise SimAdminError("没有启用的 SimAdmin 实例")
validate_unique_names(instances)
return instances
def validate_unique_names(instances):
names = set()
for item in instances:
if item["name"] in names:
raise SimAdminError("SimAdmin 实例名称重复: " + item["name"])
names.add(item["name"])
def save_instances(instances):
validate_unique_names(instances)
normalized = [normalize_instance(item, i) for i, item in enumerate(instances)]
middleware.bucketSet(BUCKET, CONFIG_KEY, json.dumps(normalized, ensure_ascii=False, indent=2))
def instance_brief(item, idx=None):
prefix = "" if idx is None else "%s. " % idx
state = "启用" if item.get("enabled") else "禁用"
token = mask_secret(item.get("token")) or "未设置"
return "%s%s [%s]\n %s timeout=%ss token=%s" % (
prefix, item["name"], state, item["base_url"], item["timeout"], token
)
def request_json(instance, method, path, body=None):
url = instance["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 {})
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_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):
lines = ["%s" % instance["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"))
lines.append("健康: %s 版本: %s" % (pick(health, "status", default="ok"), pick(health, "version", default="-")))
lines.append("SIM: %s" % (pick(sim, "phone_number", "msisdn", "number", "phone") or "号码未知"))
lines.append("ICCID: %s" % pick(sim, "iccid", "ICCID"))
imsi = pick(sim, "imsi", "IMSI")
if imsi != "-":
lines.append("IMSI: %s" % imsi)
lines.append("网络: %s / %s / 信号:%s" % (
pick(network, "operator", "operator_name", "network_operator", "provider"),
pick(network, "registration_state", "reg_state", "status", "state"),
pick(network, "signal_strength", "signal", "rssi", "rsrp"),
))
lines.append("系统: CPU %s / MEM %s / 温度 %s / 运行 %s" % (
fmt_percent(pick(stats, "cpu_usage", "cpu_percent", "cpu")),
fmt_percent(pick(stats, "memory_usage", "memory_percent", "mem")),
pick(stats, "temperature", "temp", "cpu_temperature"),
pick(stats, "uptime", "uptime_text", "system_uptime"),
))
lines.append("短信: 收 %s / 发 %s" % (
pick(sms_stats, "received", "received_count", "inbox", default="-"),
pick(sms_stats, "sent", "sent_count", "outbox", default="-"),
))
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 valid_phone(phone):
return bool(re.match(r"^\+?[0-9][0-9\- ]{1,30}$", phone or ""))
def handle_status(sender, message):
instances = load_instances()
parts = message.split(None, 1)
selected = find_instances(instances, parts[1].strip() if len(parts) > 1 else "")
reply(sender, "\n\n".join(format_instance_status(x) for x in selected))
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("短信内容过长,已拒绝发送")
selected = find_instances(load_instances(), target)
results = []
for instance in selected:
try:
payload = request_json(instance, "POST", "/api/sms/send", {"phone_number": phone, "content": content})
path = pick(unwrap_data(payload), "path", "message_path", "id", default="")
results.append("%s 发送成功%s" % (instance["name"], (" path=" + str(path)) if path else ""))
except Exception as e:
results.append("%s 发送失败: %s" % (instance["name"], str(e)))
reply(sender, "短信发送结果:\n" + "\n".join(results))
def choose_instance_by_menu(sender, instances, prompt="请选择实例编号"):
if not instances:
raise SimAdminError("暂无实例,请先添加")
lines = [prompt]
for idx, item in enumerate(instances, 1):
lines.append(instance_brief(item, idx))
lines.append("q. 返回")
value = listen_text(sender, "\n".join(lines))
if not value.isdigit():
raise SimAdminError("编号无效")
idx = int(value)
if idx < 1 or idx > len(instances):
raise SimAdminError("编号超出范围")
return idx - 1
def menu_list(sender):
instances = load_instances(include_disabled=True, allow_empty=True)
if not instances:
reply(sender, "当前没有 SimAdmin 实例。")
return
reply(sender, "SimAdmin 实例列表:\n" + "\n".join(instance_brief(item, idx) for idx, item in enumerate(instances, 1)))
def menu_add(sender):
instances = load_instances(include_disabled=True, allow_empty=True)
name = listen_text(sender, "请输入实例名称,例如:主卡")
if any(x["name"] == name for x in instances):
raise SimAdminError("实例名称已存在: " + name)
base_url = listen_text(sender, "请输入 SimAdmin 地址,例如:http://192.168.68.1:3000")
timeout = listen_text(sender, "请输入超时时间秒数,直接回车默认 10", allow_empty=True, default=str(DEFAULT_TIMEOUT))
token = listen_text(sender, "请输入 token,可直接回车留空", allow_empty=True, default="")
enabled_text = listen_text(sender, "是否启用?输入 1/是 启用,0/否 禁用;直接回车默认启用", allow_empty=True, default="1")
item = normalize_instance({
"name": name,
"base_url": base_url,
"timeout": timeout,
"token": token,
"enabled": parse_bool(enabled_text, True),
}, len(instances))
reply(sender, "即将添加:\n" + instance_brief(item) + "\n输入 yes 确认")
if not confirm(sender, "确认添加?"):
reply(sender, "已取消添加")
return
instances.append(item)
save_instances(instances)
reply(sender, "已添加 SimAdmin 实例:" + item["name"])
def menu_delete(sender):
instances = load_instances(include_disabled=True, allow_empty=True)
idx = choose_instance_by_menu(sender, instances, "请选择要删除的实例编号")
item = instances[idx]
if not confirm(sender, "确认删除【%s】?输入 yes 确认" % item["name"]):
reply(sender, "已取消删除")
return
removed = instances.pop(idx)
save_instances(instances)
reply(sender, "已删除 SimAdmin 实例:" + removed["name"])
def menu_edit(sender):
instances = load_instances(include_disabled=True, allow_empty=True)
idx = choose_instance_by_menu(sender, instances, "请选择要编辑的实例编号")
item = dict(instances[idx])
while True:
reply(sender, "\n".join([
"编辑【%s" % item["name"],
"1. 修改名称",
"2. 修改地址",
"3. 修改 timeout",
"4. 修改 token",
"5. 切换启用/禁用(当前:%s" % ("启用" if item.get("enabled") else "禁用"),
"6. 保存并返回",
"q. 放弃返回",
]))
choice = listen_text(sender, "请输入选项")
if choice == "1":
new_name = listen_text(sender, "请输入新名称")
if new_name != item["name"] and any(x["name"] == new_name for i, x in enumerate(instances) if i != idx):
reply(sender, "名称已存在,请重新选择")
else:
item["name"] = new_name
elif choice == "2":
item["base_url"] = normalize_base_url(listen_text(sender, "请输入新地址"))
elif choice == "3":
item["timeout"] = normalize_instance({**item, "timeout": listen_text(sender, "请输入 timeout 秒数 3-60")})["timeout"]
elif choice == "4":
item["token"] = listen_text(sender, "请输入新 token;直接回车清空", allow_empty=True, default="")
elif choice == "5":
item["enabled"] = not item.get("enabled")
reply(sender, "已切换为:" + ("启用" if item["enabled"] else "禁用"))
elif choice == "6":
instances[idx] = normalize_instance(item, idx)
save_instances(instances)
reply(sender, "已保存:" + item["name"])
return
else:
reply(sender, "已放弃编辑")
return
def menu_toggle(sender):
instances = load_instances(include_disabled=True, allow_empty=True)
idx = choose_instance_by_menu(sender, instances, "请选择要启用/禁用的实例编号")
instances[idx]["enabled"] = not instances[idx].get("enabled")
save_instances(instances)
reply(sender, "已将【%s】切换为:%s" % (instances[idx]["name"], "启用" if instances[idx]["enabled"] else "禁用"))
def menu_test(sender):
instances = load_instances(include_disabled=True, allow_empty=True)
idx = choose_instance_by_menu(sender, instances, "请选择要测试连通性的实例编号")
item = instances[idx]
reply(sender, format_instance_status(item))
def menu_export(sender):
instances = load_instances(include_disabled=True, allow_empty=True)
reply(sender, "当前配置 JSON\n" + json.dumps(instances, ensure_ascii=False, indent=2))
def handle_config_menu(sender):
while True:
instances = load_instances(include_disabled=True, allow_empty=True)
reply(sender, "\n".join([
"SimAdmin 配置管理(多级菜单)",
"当前实例数:%s" % len(instances),
"1. 查看实例列表",
"2. 添加实例",
"3. 编辑实例",
"4. 删除实例",
"5. 启用/禁用实例",
"6. 测试状态查询",
"7. 导出配置 JSON",
"q. 退出",
]))
choice = listen_text(sender, "请输入菜单编号")
if choice == "1":
menu_list(sender)
elif choice == "2":
menu_add(sender)
elif choice == "3":
menu_edit(sender)
elif choice == "4":
menu_delete(sender)
elif choice == "5":
menu_toggle(sender)
elif choice == "6":
menu_test(sender)
elif choice == "7":
menu_export(sender)
else:
reply(sender, "已退出 SimAdmin 配置管理")
return
def handle_config(sender):
# BOSS 希望通过多级菜单交互管理配置,因此 sim配置 直接进入菜单。
handle_config_menu(sender)
def handle_help(sender):
reply(sender, "\n".join([
"SimAdmin 多实例管理(仅管理员)",
"配置桶:otto / simadmin_instances",
"指令:",
"- sim帮助",
"- sim配置 / sim管理 进入多级菜单配置管理",
"- sim状态 查询全部启用实例",
"- sim状态 <实例名> 查询指定实例",
"- sim短信 <实例名|all> <号码> <内容>",
"菜单支持:查看、添加、编辑、删除、启用/禁用、测试、导出 JSON。",
]))
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 in ("sim配置", "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()