532 lines
22 KiB
Python
532 lines
22 KiB
Python
|
|
#!/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()
|