Files
vps-management-bot/projects/yt-stock-bot/yt_stock_bot.py
2026-03-21 01:10:53 +08:00

532 lines
22 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
import json
import os
import re
import sys
import time
import traceback
import urllib.request
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
BASE_API = "https://server.cloud.yt.net/api/v1"
DEFAULT_CONFIG = {
"telegram_token": "",
"allowed_chat_ids": [165067365],
"yt_email": "",
"yt_password": "",
"monitor_enabled": True,
"monitor_interval": 5,
"monitored_categories": ["sz-bgp"],
"auto_order_plan_ids": [53, 54, 55],
"auto_order": True,
"stop_on_success": True,
"last_update_id": 0,
"last_seen_stock": {},
"last_order_attempt": {},
}
class Bot:
def __init__(self, config_path: str):
self.config_path = Path(config_path)
self.config_path.parent.mkdir(parents=True, exist_ok=True)
self.config = self._load_config()
def _load_config(self) -> Dict[str, Any]:
if self.config_path.exists():
with self.config_path.open("r", encoding="utf-8") as f:
data = json.load(f)
else:
data = {}
merged = dict(DEFAULT_CONFIG)
merged.update(data)
self._save_config(merged)
return merged
def _save_config(self, data: Optional[Dict[str, Any]] = None):
if data is not None:
self.config = data
tmp = self.config_path.with_suffix(".tmp")
with tmp.open("w", encoding="utf-8") as f:
json.dump(self.config, f, ensure_ascii=False, indent=2)
os.replace(tmp, self.config_path)
try:
os.chmod(self.config_path, 0o600)
except Exception:
pass
def api_json(self, url: str, method: str = "GET", data: Optional[dict] = None, headers: Optional[dict] = None, timeout: int = 20) -> dict:
req_headers = {"Content-Type": "application/json"}
if headers:
req_headers.update(headers)
body = None
if data is not None:
body = json.dumps(data).encode()
req = urllib.request.Request(url, data=body, headers=req_headers, method=method)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def tg_api(self, method: str, data: Optional[dict] = None, timeout: int = 20) -> dict:
token = self.config["telegram_token"]
if not token:
raise RuntimeError("telegram_token 未配置")
url = f"https://api.telegram.org/bot{token}/{method}"
return self.api_json(url, method="POST", data=data or {}, timeout=timeout)
def answer_callback(self, callback_id: str, text: str = "已执行"):
try:
self.tg_api("answerCallbackQuery", {"callback_query_id": callback_id, "text": text})
except Exception as e:
print(f"answer_callback failed: {e}", file=sys.stderr)
def command_keyboard(self) -> dict:
return {
"inline_keyboard": [
[
{"text": "状态", "callback_data": "/status"},
{"text": "立即检查", "callback_data": "/check"},
],
[
{"text": "分类列表", "callback_data": "/categories"},
{"text": "当前产品", "callback_data": "/plans"},
],
[
{"text": "开始监控", "callback_data": "/startmon"},
{"text": "停止监控", "callback_data": "/stopmon"},
],
[
{"text": "自动下单开", "callback_data": "/autoon"},
{"text": "自动下单关", "callback_data": "/autooff"},
],
]
}
def send_message(self, chat_id: int, text: str, reply_markup: Optional[dict] = None):
try:
payload = {"chat_id": chat_id, "text": text}
if reply_markup is not None:
payload["reply_markup"] = reply_markup
self.tg_api("sendMessage", payload)
except Exception as e:
print(f"send_message failed: {e}", file=sys.stderr)
def edit_message(self, chat_id: int, message_id: int, text: str, reply_markup: Optional[dict] = None):
try:
payload = {"chat_id": chat_id, "message_id": message_id, "text": text}
if reply_markup is not None:
payload["reply_markup"] = reply_markup
self.tg_api("editMessageText", payload)
except Exception as e:
print(f"edit_message failed: {e}", file=sys.stderr)
def send_panel(self, chat_id: int, text: str):
self.send_message(chat_id, text, reply_markup=self.command_keyboard())
def send_plans_panel(self, chat_id: int, slug: Optional[str] = None):
self.send_message(chat_id, self.plans_text(slug), reply_markup=self.plans_keyboard(slug))
def broadcast(self, text: str):
for chat_id in self.config.get("allowed_chat_ids", []):
self.send_panel(chat_id, text)
def login(self) -> str:
email = self.config.get("yt_email", "")
password = self.config.get("yt_password", "")
if not email or not password:
raise RuntimeError("YT 账号未配置")
resp = self.api_json(
f"{BASE_API}/auth/login",
method="POST",
data={"email": email, "password": password},
)
if not resp.get("success"):
raise RuntimeError(resp.get("message", "登录失败"))
return resp["data"]["token"]
def get_all_plans(self) -> Tuple[List[dict], Dict[str, dict]]:
resp = self.api_json(f"{BASE_API}/auth/plans")
if not resp.get("success"):
raise RuntimeError(resp.get("message", "获取套餐失败"))
data = resp.get("data", {})
cages = {c["slug"]: c for c in data.get("cages", [])}
cage_by_id = {c["id"]: c for c in data.get("cages", [])}
plans = []
for p in data.get("plans", []):
p = dict(p)
cage = cage_by_id.get(p.get("cage"), {})
p["cage_info"] = cage
p["slug"] = cage.get("slug", "")
plans.append(p)
return plans, cages
def get_monitored_plans(self) -> List[dict]:
plans, _ = self.get_all_plans()
monitored = set(self.config.get("monitored_categories", []))
return [p for p in plans if p.get("slug") in monitored]
def parse_yt_link(self, text: str) -> Optional[str]:
m = re.search(r"cloud\.yt\.net/server/([a-z0-9-]+)", text, re.I)
if not m:
return None
return m.group(1).lower()
def add_link(self, text: str) -> Tuple[str, List[dict]]:
slug = self.parse_yt_link(text)
if not slug:
raise RuntimeError("链接格式不对,发 cloud.yt.net/server/xxx 这种")
plans, cages = self.get_all_plans()
cage = cages.get(slug)
matched = [p for p in plans if p.get("slug") == slug]
if not cage or not matched:
raise RuntimeError(f"没识别到分类:{slug}")
arr = set(self.config.get("monitored_categories", []))
arr.add(slug)
self.config["monitored_categories"] = sorted(arr)
self._save_config()
matched.sort(key=lambda x: x.get("id", 0))
return slug, matched
def get_templates(self, token: str, plan_id: int) -> List[dict]:
resp = self.api_json(
f"{BASE_API}/vps/templates/plan/{plan_id}",
headers={"Authorization": f"Bearer {token}"},
)
if not resp.get("success"):
raise RuntimeError(resp.get("message", "获取模板失败"))
return resp.get("data", [])
def order_plan(self, token: str, plan_id: int, template_id: int) -> dict:
return self.api_json(
f"{BASE_API}/vps/order/{plan_id}",
method="POST",
headers={"Authorization": f"Bearer {token}"},
data={"Template": template_id},
)
def categories_text(self) -> str:
plans, cages = self.get_all_plans()
counts: Dict[str, int] = {}
monitored = set(self.config.get("monitored_categories", []))
for p in plans:
slug = p.get("slug") or "-"
counts[slug] = counts.get(slug, 0) + 1
lines = ["YT 分类列表:"]
for slug, cage in sorted(cages.items()):
mark = "" if slug in monitored else "▫️"
lines.append(f"{mark} {slug} | {cage.get('label', cage.get('name', slug))} | {counts.get(slug, 0)} 个产品")
lines += ["", "命令:/watch 分类slug", "命令:/unwatch 分类slug"]
return "\n".join(lines)
def get_display_plans(self, slug: Optional[str] = None) -> List[dict]:
plans, _ = self.get_all_plans()
monitored = set(self.config.get("monitored_categories", []))
if slug:
plans = [p for p in plans if p.get("slug") == slug]
else:
plans = [p for p in plans if p.get("slug") in monitored]
plans.sort(key=lambda x: (x.get("slug", ""), x.get("id", 0)))
return plans
def plans_text(self, slug: Optional[str] = None) -> str:
plans = self.get_display_plans(slug)
auto_ids = set(int(x) for x in self.config.get("auto_order_plan_ids", []))
lines = [f"产品列表{'' + slug + '' if slug else '(当前监控分类)'}"]
for p in plans:
auto = "" if int(p.get("id", 0)) in auto_ids else "▫️"
lines.append(
f"{auto} ID={p['id']} | {p['name']} | {p.get('slug','-')} | stock={p.get('stock',0)} | ¥{p.get('price','?')}"
)
lines += ["", "点下面按钮可直接开关自动下单"]
return "\n".join(lines)
def plans_keyboard(self, slug: Optional[str] = None) -> dict:
plans = self.get_display_plans(slug)
auto_ids = set(int(x) for x in self.config.get("auto_order_plan_ids", []))
rows = []
for p in plans:
pid = int(p["id"])
mark = "" if pid in auto_ids else "▫️"
rows.append([
{
"text": f"{mark} {p['name']}",
"callback_data": f"toggle_auto:{pid}:{slug or ''}",
}
])
rows.append([
{"text": "刷新产品", "callback_data": f"show_plans:{slug or ''}"},
{"text": "状态", "callback_data": "/status"},
])
return {"inline_keyboard": rows}
def status_text(self) -> str:
plans = self.get_monitored_plans()
auto_ids = set(int(x) for x in self.config.get("auto_order_plan_ids", []))
lines = [
"YT 补货 Bot 状态:",
f"监控开关:{'' if self.config.get('monitor_enabled') else ''}",
f"监控间隔:{self.config.get('monitor_interval')}",
f"监控分类:{', '.join(self.config.get('monitored_categories', [])) or '-'}",
f"自动下单总开关:{'' if self.config.get('auto_order') else ''}",
f"自动下单产品数:{len(auto_ids)}",
"",
"当前监控产品:",
]
for p in plans:
auto = "[自动]" if int(p.get("id", 0)) in auto_ids else "[仅监控]"
lines.append(f"- {p['name']} {auto} stock={p.get('stock', 0)} price=¥{p.get('price', '?')}")
return "\n".join(lines)
def toggle_auto_plan(self, plan_id: int) -> bool:
arr = set(int(x) for x in self.config.get("auto_order_plan_ids", []))
enabled = plan_id not in arr
if enabled:
arr.add(plan_id)
else:
arr.discard(plan_id)
self.config["auto_order_plan_ids"] = sorted(arr)
self._save_config()
return enabled
def handle_command(self, chat_id: int, text: str):
parts = text.strip().split(maxsplit=1)
cmd = parts[0].lower()
arg = parts[1].strip() if len(parts) > 1 else ""
if cmd == "/start":
self.send_panel(chat_id, "YT 补货 Bot 在线。")
return
if cmd == "/status":
self.send_panel(chat_id, self.status_text())
return
if cmd == "/categories":
self.send_panel(chat_id, self.categories_text())
return
if cmd == "/plans":
self.send_plans_panel(chat_id, arg or None)
return
if cmd == "/addlink":
if not arg:
self.send_panel(chat_id, "用法:/addlink https://cloud.yt.net/server/sz-bgp")
return
slug, matched = self.add_link(arg)
self.send_message(chat_id, f"已加入监控链接:{slug}\n识别到 {len(matched)} 个产品,点下面按钮选择自动下单。", reply_markup=self.plans_keyboard(slug))
return
if cmd == "/listlinks":
arr = self.config.get("monitored_categories", [])
self.send_panel(chat_id, "当前监控链接:\n" + "\n".join(f"- https://cloud.yt.net/server/{x}" for x in arr))
return
if cmd == "/check":
msg = self.check_once(manual=True)
self.send_panel(chat_id, msg or "当前没有新变化")
return
if cmd == "/startmon":
self.config["monitor_enabled"] = True
self._save_config()
self.send_panel(chat_id, "监控已开启")
return
if cmd == "/stopmon":
self.config["monitor_enabled"] = False
self._save_config()
self.send_panel(chat_id, "监控已停止")
return
if cmd == "/autoon":
self.config["auto_order"] = True
self._save_config()
self.send_panel(chat_id, "自动下单总开关已开启")
return
if cmd == "/autooff":
self.config["auto_order"] = False
self._save_config()
self.send_panel(chat_id, "自动下单总开关已关闭")
return
if cmd == "/setinterval":
if not arg.isdigit():
self.send_panel(chat_id, "用法:/setinterval 5")
return
val = max(3, int(arg))
self.config["monitor_interval"] = val
self._save_config()
self.send_panel(chat_id, f"监控间隔已设为 {val}")
return
if cmd == "/watch":
if not arg:
self.send_panel(chat_id, "用法:/watch 分类slug")
return
arr = set(self.config.get("monitored_categories", []))
arr.add(arg)
self.config["monitored_categories"] = sorted(arr)
self._save_config()
self.send_panel(chat_id, f"已加入监控分类:{arg}")
return
if cmd == "/unwatch":
if not arg:
self.send_panel(chat_id, "用法:/unwatch 分类slug")
return
arr = set(self.config.get("monitored_categories", []))
arr.discard(arg)
self.config["monitored_categories"] = sorted(arr)
self._save_config()
self.send_panel(chat_id, f"已移除监控分类:{arg}")
return
if cmd == "/autoadd":
if not arg.isdigit():
self.send_panel(chat_id, "用法:/autoadd 产品ID")
return
arr = set(int(x) for x in self.config.get("auto_order_plan_ids", []))
arr.add(int(arg))
self.config["auto_order_plan_ids"] = sorted(arr)
self._save_config()
self.send_panel(chat_id, f"已加入自动下单ID={arg}")
return
if cmd == "/autodel":
if not arg.isdigit():
self.send_panel(chat_id, "用法:/autodel 产品ID")
return
arr = set(int(x) for x in self.config.get("auto_order_plan_ids", []))
arr.discard(int(arg))
self.config["auto_order_plan_ids"] = sorted(arr)
self._save_config()
self.send_panel(chat_id, f"已移除自动下单ID={arg}")
return
self.send_panel(chat_id, "未知命令")
def poll_updates(self):
offset = int(self.config.get("last_update_id", 0)) + 1
resp = self.tg_api("getUpdates", {"offset": offset, "timeout": 5, "allowed_updates": ["message", "callback_query"]}, timeout=15)
for item in resp.get("result", []):
self.config["last_update_id"] = item["update_id"]
self._save_config()
callback = item.get("callback_query") or {}
if callback:
msg = callback.get("message") or {}
chat_id = ((msg.get("chat") or {}).get("id"))
message_id = msg.get("message_id")
if chat_id in self.config.get("allowed_chat_ids", []):
data = (callback.get("data") or "").strip()
if data.startswith("toggle_auto:"):
_, pid, slug = data.split(":", 2)
enabled = self.toggle_auto_plan(int(pid))
self.edit_message(chat_id, message_id, self.plans_text(slug or None), self.plans_keyboard(slug or None))
self.answer_callback(callback.get("id"), "已加入自动下单" if enabled else "已取消自动下单")
elif data.startswith("show_plans:"):
_, slug = data.split(":", 1)
self.edit_message(chat_id, message_id, self.plans_text(slug or None), self.plans_keyboard(slug or None))
self.answer_callback(callback.get("id"), "已刷新")
else:
self.answer_callback(callback.get("id"), "已执行")
if data.startswith("/"):
self.handle_command(chat_id, data)
continue
msg = item.get("message") or {}
chat = msg.get("chat") or {}
chat_id = chat.get("id")
if chat_id not in self.config.get("allowed_chat_ids", []):
continue
text = (msg.get("text") or "").strip()
if text.startswith("/"):
self.handle_command(chat_id, text)
def check_once(self, manual: bool = False) -> Optional[str]:
plans = self.get_monitored_plans()
plans.sort(key=lambda x: (x.get("slug", ""), x.get("id", 0)))
last_seen = self.config.setdefault("last_seen_stock", {})
newly_available = []
currently_available = []
for p in plans:
pid = str(p["id"])
stock = int(p.get("stock", 0) or 0)
prev = int(last_seen.get(pid, 0) or 0)
if stock > 0:
currently_available.append(p)
if stock > 0 and prev <= 0:
newly_available.append(p)
last_seen[pid] = stock
self._save_config()
if manual:
if currently_available:
lines = ["当前有货:"]
for p in currently_available:
auto = "[自动]" if int(p["id"]) in set(int(x) for x in self.config.get("auto_order_plan_ids", [])) else "[仅监控]"
lines.append(f"- ID={p['id']} {p['name']} {auto} stock={p['stock']} price=¥{p.get('price','?')}")
return "\n".join(lines)
return "当前无货"
if not newly_available:
return None
auto_ids = set(int(x) for x in self.config.get("auto_order_plan_ids", []))
lines = ["发现新补货:"]
for p in newly_available:
auto = "[自动下单]" if int(p["id"]) in auto_ids and self.config.get("auto_order") else "[仅通知]"
lines.append(f"- ID={p['id']} {p['name']} {auto} stock={p['stock']} price=¥{p.get('price','?')}")
order_results = []
success_count = 0
if self.config.get("auto_order"):
token = self.login()
now = int(time.time())
attempts = self.config.setdefault("last_order_attempt", {})
for p in newly_available:
plan_id = int(p["id"])
if plan_id not in auto_ids:
continue
key = str(plan_id)
if now - int(attempts.get(key, 0)) < 20:
order_results.append(f"- {p['name']}: 跳过20秒内已尝试过")
continue
attempts[key] = now
template_id = 1
try:
templates = self.get_templates(token, plan_id)
if templates:
template_id = int(templates[0]["id"])
resp = self.order_plan(token, plan_id, template_id)
if resp.get("success"):
success_count += 1
order_results.append(f"- {p['name']}: 下单成功")
else:
order_results.append(f"- {p['name']}: 下单失败 -> {resp.get('message', '未知错误')}")
except Exception as e:
order_results.append(f"- {p['name']}: 下单异常 -> {e}")
if success_count > 0 and self.config.get("stop_on_success", True):
self.config["monitor_enabled"] = False
self._save_config()
order_results.append("已自动关闭监控(下单成功后停机)")
if order_results:
lines += [""] + order_results
return "\n".join(lines)
def run(self):
next_check = 0.0
while True:
try:
self.poll_updates()
except Exception as e:
print(f"poll_updates error: {e}", file=sys.stderr)
time.sleep(3)
try:
interval = max(3, int(self.config.get("monitor_interval", 5)))
if self.config.get("monitor_enabled") and time.time() >= next_check:
msg = self.check_once(manual=False)
if msg:
self.broadcast(msg)
next_check = time.time() + interval
except Exception as e:
print(f"监控异常: {e}", file=sys.stderr)
traceback.print_exc()
time.sleep(5)
if __name__ == "__main__":
config_path = sys.argv[1] if len(sys.argv) > 1 else "/opt/yt-stock-bot/config.json"
Bot(config_path).run()