Files
vps-management-bot/projects/yt-stock-bot/yt_stock_bot.py

532 lines
22 KiB
Python
Raw Normal View History

2026-03-21 01:10:53 +08:00
#!/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()