230 lines
6.9 KiB
Python
230 lines
6.9 KiB
Python
#!/usr/bin/env python3
|
|
"""TG 群管机器人 v2 — FastAPI + Tortoise ORM + python-telegram-bot"""
|
|
import os
|
|
import time
|
|
import asyncio
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
|
|
# 代理(必须在其他 import 之前)
|
|
PROXY_URL = os.getenv("PROXY_URL", "http://192.168.123.10:11055")
|
|
TG_PROXY_URLS = [x.strip() for x in os.getenv(
|
|
"TG_PROXY_URLS",
|
|
",".join([
|
|
PROXY_URL,
|
|
"http://192.168.31.130:11055",
|
|
"socks5://192.168.31.130:11088",
|
|
"socks5://192.168.123.10:11088",
|
|
]),
|
|
).split(",") if x.strip()]
|
|
os.environ["HTTPS_PROXY"] = PROXY_URL
|
|
os.environ["HTTP_PROXY"] = PROXY_URL
|
|
os.environ["NO_PROXY"] = os.getenv("NO_PROXY", "127.0.0.1,localhost")
|
|
|
|
import uvicorn
|
|
from fastapi import FastAPI
|
|
from telegram import BotCommand, BotCommandScopeChat, Update
|
|
from telegram.ext import Application, ContextTypes
|
|
|
|
import config
|
|
from db import init_db, close_db
|
|
from handlers import register_all
|
|
|
|
# ============ 日志 ============
|
|
|
|
log_fmt = "%(asctime)s [%(levelname)s] %(message)s"
|
|
log_datefmt = "%Y-%m-%d %H:%M:%S"
|
|
|
|
logging.basicConfig(level=logging.INFO, format=log_fmt, datefmt=log_datefmt)
|
|
logger = logging.getLogger("spam_guard")
|
|
|
|
# 阻止 httpx 刷屏
|
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
|
|
|
# ============ Telegram Bot ============
|
|
|
|
tg_app: Application = None
|
|
_last_polling_error_log = 0.0
|
|
_proxy_index = 0
|
|
|
|
|
|
def current_tg_proxy() -> str:
|
|
return TG_PROXY_URLS[_proxy_index % len(TG_PROXY_URLS)]
|
|
|
|
|
|
def rotate_tg_proxy() -> str:
|
|
global _proxy_index
|
|
_proxy_index = (_proxy_index + 1) % len(TG_PROXY_URLS)
|
|
return current_tg_proxy()
|
|
|
|
|
|
def polling_error_callback(exc):
|
|
"""压缩 Telegram polling 网络异常日志,避免代理偶发断连刷 traceback。"""
|
|
global _last_polling_error_log
|
|
now = time.time()
|
|
# polling 会自动重试;同类网络波动 60 秒内只提示一次。
|
|
if now - _last_polling_error_log < 60:
|
|
return
|
|
_last_polling_error_log = now
|
|
logger.warning(f"⚠️ TG polling 长轮询连接被代理中断,已自动重试: {type(exc).__name__}: {exc}")
|
|
|
|
|
|
async def global_error_handler(update: object, context: ContextTypes.DEFAULT_TYPE):
|
|
"""全局错误兜底,避免命令无响应且无日志。"""
|
|
err = context.error
|
|
logger.error(f"❌ Handler 异常: {type(err).__name__}: {err}")
|
|
try:
|
|
if isinstance(update, Update) and update.effective_chat:
|
|
await context.bot.send_message(chat_id=update.effective_chat.id, text="❌ 指令处理失败,请稍后重试")
|
|
except Exception as e:
|
|
logger.warning(f"⚠️ 错误提示发送失败: {type(e).__name__}: {e}")
|
|
|
|
|
|
async def start_tg_bot():
|
|
global tg_app
|
|
|
|
from telegram.request import HTTPXRequest
|
|
|
|
active_proxy = current_tg_proxy()
|
|
logger.info(f"🌐 TG 使用代理: {active_proxy}")
|
|
proxy_request = HTTPXRequest(
|
|
proxy=active_proxy,
|
|
connection_pool_size=64,
|
|
connect_timeout=30.0,
|
|
read_timeout=30.0,
|
|
write_timeout=30.0,
|
|
pool_timeout=30.0,
|
|
)
|
|
get_updates_request = HTTPXRequest(
|
|
proxy=active_proxy,
|
|
connection_pool_size=8,
|
|
connect_timeout=30.0,
|
|
read_timeout=15.0,
|
|
write_timeout=30.0,
|
|
pool_timeout=30.0,
|
|
)
|
|
tg_app = (
|
|
Application.builder()
|
|
.token(config.TG_BOT_TOKEN)
|
|
.request(proxy_request)
|
|
.get_updates_request(get_updates_request)
|
|
.build()
|
|
)
|
|
|
|
register_all(tg_app)
|
|
tg_app.add_error_handler(global_error_handler)
|
|
|
|
await tg_app.initialize()
|
|
await tg_app.start()
|
|
await tg_app.updater.start_polling(
|
|
poll_interval=0.5,
|
|
timeout=3,
|
|
drop_pending_updates=False,
|
|
error_callback=polling_error_callback,
|
|
)
|
|
|
|
# 菜单
|
|
commands = [
|
|
BotCommand("ask", "🤖 向知识库提问"),
|
|
BotCommand("stats", "📊 今日统计"),
|
|
BotCommand("checkin", "✅ 每日签到"),
|
|
BotCommand("trivia", "🎮 知识答题"),
|
|
BotCommand("lottery", "🎁 发起群内抽奖"),
|
|
BotCommand("shop", "🛒 积分商城"),
|
|
BotCommand("panel", "🎛️ 操作面板"),
|
|
BotCommand("bind", "🔗 绑定账号"),
|
|
BotCommand("me", "👤 查看授权信息"),
|
|
BotCommand("dban", "🗑️ 删除并封禁"),
|
|
BotCommand("dmute", "🔇 删除并禁言"),
|
|
BotCommand("dwarn", "⚠️ 删除并警告"),
|
|
BotCommand("help", "📖 帮助"),
|
|
]
|
|
await tg_app.bot.delete_my_commands()
|
|
await tg_app.bot.set_my_commands(commands, scope=BotCommandScopeChat(chat_id=config.TG_CHAT_ID))
|
|
logger.info(f"✅ TG Bot 已启动,菜单命令已设置 (群组: {config.TG_CHAT_ID})")
|
|
|
|
|
|
async def stop_tg_bot():
|
|
global tg_app
|
|
if tg_app:
|
|
try:
|
|
if tg_app.updater and tg_app.updater.running:
|
|
await tg_app.updater.stop()
|
|
if tg_app.running:
|
|
await tg_app.stop()
|
|
await tg_app.shutdown()
|
|
logger.info("🛑 TG Bot 已停止")
|
|
except Exception as e:
|
|
logger.warning(f"⚠️ TG Bot 停止/清理异常: {type(e).__name__}: {e}")
|
|
finally:
|
|
tg_app = None
|
|
|
|
|
|
# ============ FastAPI ============
|
|
|
|
from web.api import app as web_app
|
|
|
|
|
|
async def restart_tg_bot_runtime():
|
|
"""供 Web API 调用:只重启 TG Bot,不重启 Web。"""
|
|
await stop_tg_bot()
|
|
asyncio.create_task(start_tg_bot_safe())
|
|
|
|
|
|
async def start_tg_bot_safe():
|
|
"""启动 TG Bot;初始化阶段网络失败时压缩日志并自动重试。"""
|
|
delay = 10
|
|
while True:
|
|
try:
|
|
await start_tg_bot()
|
|
return
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as e:
|
|
next_proxy = rotate_tg_proxy()
|
|
logger.error(f"❌ TG Bot 启动失败,将在 {delay}s 后用备用代理重试: {type(e).__name__}: {e}; next={next_proxy}")
|
|
try:
|
|
await stop_tg_bot()
|
|
except Exception:
|
|
pass
|
|
await asyncio.sleep(delay)
|
|
delay = min(120, delay * 2)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app):
|
|
# 初始化数据库
|
|
await init_db()
|
|
# 启动 TG Bot
|
|
import web.api as web_api
|
|
web_api.set_restart_bot_callback(restart_tg_bot_runtime)
|
|
task = asyncio.create_task(start_tg_bot_safe())
|
|
|
|
def log_task_result(t: asyncio.Task):
|
|
if t.cancelled():
|
|
return
|
|
exc = t.exception()
|
|
if exc:
|
|
logger.error("❌ TG Bot 任务异常结束", exc_info=exc)
|
|
|
|
task.add_done_callback(log_task_result)
|
|
logger.info("🚀 服务启动完成")
|
|
try:
|
|
yield
|
|
finally:
|
|
await stop_tg_bot()
|
|
await close_db()
|
|
logger.info("🛑 服务已关闭")
|
|
|
|
|
|
web_app.router.lifespan_context = lifespan
|
|
|
|
|
|
def run():
|
|
uvicorn.run(web_app, host="0.0.0.0", port=config.WEB_PORT, log_level="info", access_log=False)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
run()
|