Files
RRRosmontis 34089d0042 Initial import of Shirobana bot backend
Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
2026-11-11 00:25:30 +08:00

248 lines
11 KiB
Python
Raw Permalink 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.
# deepseek/deepseek_balance.py — DeepSeek 余额监控
# 文件夹化模块:配置(API key / 阈值 / 轮询间隔 / 开关)全部从本目录 setting.json 读取。
# 命令(管理员): .deepseek help / balance / threshold <int> / polling <int> / alert <on|off>
# 监控: 定时查询余额,低于阈值时向【所有已注册通道】的管理员私聊告警。
import asyncio
import json
import os
import aiohttp
import identity
from channel import push_to_user, send_message
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
SETTING_FILE = os.path.join(BASE_DIR, "setting.json")
# ---------------- 配置(从 setting.json 读取) ----------------
API_KEY = ""
API_BASE = "https://api.deepseek.com"
THRESHOLD = 3
POLLING_MINUTES = 3
ALERT_ENABLED = True
# 启动宽限(秒):首次余额查询前先等 TG 通道 register / NapCat 反向连接完成,
# 否则首次告警会因通道尚未注册而投递失败(启动竞态)。
MONITOR_STARTUP_DELAY = 30
def _load_setting() -> dict:
try:
with open(SETTING_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
data = {}
return data if isinstance(data, dict) else {}
def _save_setting(data: dict):
os.makedirs(os.path.dirname(SETTING_FILE), exist_ok=True)
with open(SETTING_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def _persist():
"""写回全部配置(保留 api_key / api_base 等字段)"""
data = _load_setting()
data.update({
"api_key": API_KEY,
"api_base": API_BASE,
"threshold": THRESHOLD,
"polling": POLLING_MINUTES,
"alert_enabled": ALERT_ENABLED,
})
_save_setting(data)
# 从持久化文件恢复上次设置(跨重启保留)
_s = _load_setting()
API_KEY = str(_s.get("api_key", ""))
API_BASE = str(_s.get("api_base", API_BASE)).rstrip("/")
THRESHOLD = int(_s.get("threshold", THRESHOLD))
POLLING_MINUTES = int(_s.get("polling", POLLING_MINUTES))
ALERT_ENABLED = bool(_s.get("alert_enabled", ALERT_ENABLED))
BALANCE_URL = f"{API_BASE}/user/balance"
_alerted = False
# 本次低余额事件中已成功投递的目标令牌 {"<channel>:<target_id>"}。
# 逐目标补投的依据:只重发不在其中的目标;余额恢复时清空。
_alert_delivered = set()
async def get_balance():
if not API_KEY:
print("[balance] setting.json 缺少 api_key,跳过查询")
return None
headers = {
"Authorization": f"Bearer {API_KEY}",
"Accept": "application/json"
}
try:
async with aiohttp.ClientSession() as session:
async with session.get(BALANCE_URL, headers=headers, timeout=30) as resp:
if resp.status == 200:
data = await resp.json()
for info in data.get("balance_infos") or []:
if info.get("currency") == "CNY":
return float(info["total_balance"])
return None
text = await resp.text()
print(f"[DEBUG][balance] 查询余额失败,状态码 {resp.status},响应体: {text[:200]}")
return None
except Exception as e:
print(f"[DEBUG][balance] 查询余额异常: {e}")
return None
async def check_balance_once():
global _alerted, _alert_delivered
if not ALERT_ENABLED:
return
balance = await get_balance()
if balance is None:
print("[DEBUG][balance] 本轮查询失败,跳过")
return
print(f"[DEBUG][balance] 轮询查询成功,当前余额 {balance} 元,阈值 {THRESHOLD} 元")
if balance < THRESHOLD:
if ALERT_ENABLED and not _alerted:
msg = (f"注意到 DeepSeek Platform 余额已低于阈值 ( {THRESHOLD} 元 ),请判别业务影响。\n"
f"当前余额 {balance} 元")
# 逐目标补投:按管理员身份扇出,本轮只对「尚未成功」的目标重发,
# 已成功的目标绝不重复推送(避免 TG 重复轰炸)。
pending_total, sent_now = 0, 0
for uid in identity.admin_uids():
tokens = {"%s:%s" % t: t for t in identity.push_targets(uid)}
# 目标被解绑/移除即视为消失:不在 tokens 里的历史令牌不再阻塞结案
todo = [t for token, t in tokens.items() if token not in _alert_delivered]
pending_total += len(todo)
if not todo:
continue
results = await push_to_user(uid, msg, tag="DeepSeek 余额告警", targets=todo)
for (channel_name, target_id), ok in results.items():
token = "%s:%s" % (channel_name, target_id)
sent_now += 1 if ok else 0
if ok:
_alert_delivered.add(token)
else:
print(f"[DEBUG][balance] 目标 {token} 本轮投递失败,下一轮补投")
# 结案判定:当前所有管理员的目标都已成功即结案;
# 目标集合为空(无管理员身份)时也正常结案,避免每轮空转刷「未结案」日志
remaining = 0
existing = 0
for uid in identity.admin_uids():
for t in identity.push_targets(uid):
existing += 1
if "%s:%s" % t not in _alert_delivered:
remaining += 1
if remaining == 0:
_alerted = True
if existing > 0:
print(f"[DEBUG][balance] 余额预警已结案(本事件补投 {sent_now} 个目标,"
f"累计成功 {existing} 个),当前余额 {balance} 元,阈值 {THRESHOLD} 元")
else:
print(f"[DEBUG][balance] 余额低于阈值但无管理员推送目标,直接结案,"
f"当前余额 {balance} 元,阈值 {THRESHOLD} 元")
else:
print(f"[DEBUG][balance] 余额预警未结案:本轮补投 {sent_now} 个成功 / "
f"{pending_total} 个待投,剩余 {remaining} 个目标未成功(共 {existing} 个),"
f"下一轮只补投未成功目标")
return
_alerted = True
else:
# 余额恢复:复位告警与逐目标投递记录
_alerted = False
_alert_delivered.clear()
async def monitor_loop():
print(f"[DEBUG][balance] 余额监控已启动,每 {POLLING_MINUTES} 分钟查询一次,阈值 {THRESHOLD} 元")
# 启动宽限:等 TG 通道 register 与 NapCat 反向连接完成后再做首次查询,
# 避免重启后的首次真实告警投递到空通道(那也是启动竞态吞告警的根因)。
await asyncio.sleep(MONITOR_STARTUP_DELAY)
while True:
try:
await check_balance_once()
except Exception as e:
print(f"[DEBUG][balance] 轮询异常: {e}")
await asyncio.sleep(POLLING_MINUTES * 60)
HELP_TEXT = (
"DeepSeek 余额监控指令\n"
"• .deepseek help 显示本帮助\n"
"• .deepseek balance 查询当前余额\n"
"• .deepseek threshold <int> 设置余额阈值(当前: {threshold} 元)\n"
"• .deepseek polling <int> 设置查询间隔(当前: {polling} 分钟)\n"
"• .deepseek alert <on|off> 开关余额监控(当前: {alert})\n"
)
async def handle_command(channel, msg_type, target_id, raw_text):
global THRESHOLD, POLLING_MINUTES, ALERT_ENABLED, _alerted, _alert_delivered
parts = raw_text.split(maxsplit=2)
subcmd = parts[1].lower() if len(parts) >= 2 else "help"
if subcmd == "help":
await send_message(channel, msg_type, target_id, HELP_TEXT.format(
threshold=THRESHOLD, polling=POLLING_MINUTES,
alert="开" if ALERT_ENABLED else "关"))
return
if subcmd == "balance":
balance = await get_balance()
if balance is None:
await send_message(channel, msg_type, target_id, "余额查询失败,请稍后重试")
else:
await send_message(channel, msg_type, target_id, f"DeepSeek 账户当前余额:{balance:.2f} 元")
return
if subcmd == "threshold":
if len(parts) < 3:
await send_message(channel, msg_type, target_id, "用法: .deepseek threshold <int>,例如 .deepseek threshold 3")
return
try:
value = int(parts[2].strip())
except ValueError:
await send_message(channel, msg_type, target_id, "阈值必须为整数,例如 .deepseek threshold 3")
return
if value < 0:
await send_message(channel, msg_type, target_id, "阈值不能为负数")
return
THRESHOLD = value
# 阈值变更构成新的告警判定基准:复位结案标志与投递记录,
# 否则旧事件的 _alerted/_alert_delivered 会压制新阈值下的告警
_alerted = False
_alert_delivered.clear()
_persist()
await send_message(channel, msg_type, target_id, f"已设置余额阈值为 {THRESHOLD} 元")
return
if subcmd == "polling":
if len(parts) < 3:
await send_message(channel, msg_type, target_id, "用法: .deepseek polling <int>,例如 .deepseek polling 3")
return
try:
value = int(parts[2].strip())
except ValueError:
await send_message(channel, msg_type, target_id, "查询间隔必须为整数分钟,例如 .deepseek polling 3")
return
if value < 1:
await send_message(channel, msg_type, target_id, "查询间隔至少为 1 分钟")
return
POLLING_MINUTES = value
_persist()
await send_message(channel, msg_type, target_id, f"已设置查询间隔为 {POLLING_MINUTES} 分钟")
return
if subcmd == "alert":
if len(parts) < 3 or parts[2].strip().lower() not in ("on", "off"):
await send_message(channel, msg_type, target_id, "用法: .deepseek alert <on|off>")
return
ALERT_ENABLED = parts[2].strip().lower() == "on"
# 开关切换即开始新事件:投递记录一并清空,
# 否则上次事件残留的成功令牌会让重启后的告警被整体跳过(静默吞告警)
_alerted = False
_alert_delivered.clear()
_persist()
if ALERT_ENABLED:
await send_message(channel, msg_type, target_id, "已开启余额监控")
else:
await send_message(channel, msg_type, target_id, "已关闭余额监控(暂停轮询)")
return
await send_message(channel, msg_type, target_id, "未知子指令,使用 .deepseek help 查看指令范围")