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.
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
# 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 查看指令范围")
|
||||
Reference in New Issue
Block a user