#!/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()