Files
vps-management-bot/projects/news-bot/bot.py
2026-03-21 01:10:53 +08:00

777 lines
27 KiB
Python
Raw 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.
"""
Telegram 财经快讯机器人 - 主入口
功能:自动抓取财经/科技快讯AI 评分过滤,推送到 Telegram
"""
import os
import time
import logging
import asyncio
from datetime import datetime
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import (
Application, CommandHandler, CallbackQueryHandler,
ContextTypes, MessageHandler, filters,
)
from telegram.constants import ParseMode
import sources
import scorer
import storage
import summarizer
# 日志配置
logging.basicConfig(
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
level=logging.INFO,
)
logger = logging.getLogger(__name__)
# 配置
BOT_TOKEN = os.environ.get("BOT_TOKEN", "")
ADMIN_ID = int(os.environ.get("ADMIN_ID", "165067365"))
# 分页状态缓存
_page_cache = {}
# ========== 工具函数 ==========
def _check_admin(user_id: int) -> bool:
"""检查是否为管理员"""
return user_id == ADMIN_ID
def _format_news_item(item: dict) -> str:
"""格式化单条新闻(支持 HTML 链接)"""
score = item.get("score", 0)
if score >= 9:
emoji = "🔥"
elif score >= 8:
emoji = ""
elif score >= 7:
emoji = "📌"
elif score >= 6:
emoji = "🔹"
else:
emoji = ""
src = item.get("source_name", "")
ts = item.get("time_str", "")
title = item.get("title", "")
url = item.get("url", "")
# 转义 HTML 特殊字符
import html as html_mod
title_safe = html_mod.escape(title)
if url:
title_display = f'<a href="{url}">{title_safe}</a>'
else:
title_display = title_safe
return f"{emoji} {title_display}\n 📡 {src} 🕐 {ts} 评分:{score}"
# ========== /start 命令 ==========
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""欢迎消息 + 主菜单"""
if not _check_admin(update.effective_user.id):
await update.message.reply_text("⛔ 仅限管理员使用")
return
keyboard = [
[InlineKeyboardButton("📰 最新快讯", callback_data="news_0"),
InlineKeyboardButton("📊 新闻总结", callback_data="summary_menu")],
[InlineKeyboardButton("📡 订阅源管理", callback_data="sources_menu"),
InlineKeyboardButton("🔑 关键词管理", callback_data="keywords_menu")],
[InlineKeyboardButton("⚙️ 设置", callback_data="settings_menu")],
]
text = (
"👋 <b>欢迎使用财经快讯机器人</b>\n\n"
"🤖 自动抓取金十、华尔街见闻、36氪、新浪财经快讯\n"
"📊 AI 评分过滤,只推送相关内容\n"
"⏰ 定时总结,不错过重要新闻\n\n"
"请选择功能:"
)
await update.message.reply_text(
text, parse_mode=ParseMode.HTML,
reply_markup=InlineKeyboardMarkup(keyboard),
)
# ========== /news 命令 ==========
async def cmd_news(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""查看最新快讯"""
if not _check_admin(update.effective_user.id):
return
await _show_news_page(update.message, 0)
async def _show_news_page(message_or_query, page: int):
"""显示新闻分页"""
settings = storage.get_settings()
news_list = storage.get_news(limit=100)
# 评分
scorer.score_and_filter(news_list, settings.get("keywords", []))
# 只显示评分 >= 4 的
news_list = [n for n in news_list if n.get("score", 0) >= 4]
news_list.sort(key=lambda x: x.get("timestamp", 0), reverse=True)
page_size = 10
total_pages = max(1, (len(news_list) + page_size - 1) // page_size)
page = max(0, min(page, total_pages - 1))
start = page * page_size
page_items = news_list[start:start + page_size]
if not page_items:
text = "📭 暂无快讯,等待抓取中..."
else:
lines = [f"📰 <b>最新快讯</b> (第{page+1}/{total_pages}页)\n"]
for item in page_items:
lines.append(_format_news_item(item))
text = "\n".join(lines)
# 分页按钮
buttons = []
if page > 0:
buttons.append(InlineKeyboardButton("⬅️ 上一页", callback_data=f"news_{page-1}"))
buttons.append(InlineKeyboardButton("🔄 刷新", callback_data=f"news_{page}"))
if page < total_pages - 1:
buttons.append(InlineKeyboardButton("➡️ 下一页", callback_data=f"news_{page+1}"))
keyboard = [buttons, [InlineKeyboardButton("🏠 主菜单", callback_data="main_menu")]]
if hasattr(message_or_query, "edit_message_text"):
try:
await message_or_query.edit_message_text(
text, parse_mode=ParseMode.HTML,
reply_markup=InlineKeyboardMarkup(keyboard),
)
except Exception:
await message_or_query.edit_message_text(
text, reply_markup=InlineKeyboardMarkup(keyboard),
)
else:
await message_or_query.reply_text(
text, parse_mode=ParseMode.HTML,
reply_markup=InlineKeyboardMarkup(keyboard),
)
# ========== /summary 命令 ==========
async def cmd_summary(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""手动触发新闻总结"""
if not _check_admin(update.effective_user.id):
return
await _show_summary_menu(update.message)
async def _show_summary_menu(message_or_query):
"""显示总结时间范围选择"""
keyboard = [
[InlineKeyboardButton("⏱ 最近1小时", callback_data="summary_最近1小时")],
[InlineKeyboardButton("🌅 上午", callback_data="summary_上午")],
[InlineKeyboardButton("📅 全天", callback_data="summary_全天")],
[InlineKeyboardButton("🏠 主菜单", callback_data="main_menu")],
]
text = "📊 <b>新闻总结</b>\n\n请选择时间范围:"
if hasattr(message_or_query, "edit_message_text"):
await message_or_query.edit_message_text(
text, parse_mode=ParseMode.HTML,
reply_markup=InlineKeyboardMarkup(keyboard),
)
else:
await message_or_query.reply_text(
text, parse_mode=ParseMode.HTML,
reply_markup=InlineKeyboardMarkup(keyboard),
)
# ========== /sources 命令 ==========
async def cmd_sources(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""查看/管理订阅源"""
if not _check_admin(update.effective_user.id):
return
await _show_sources_menu(update.message)
async def _show_sources_menu(message_or_query):
"""显示订阅源管理菜单"""
settings = storage.get_settings()
srcs = settings.get("sources", {})
source_info = {
"jin10": "金十数据",
"wallstreet": "华尔街见闻",
"kr36": "36氪",
"sina": "新浪财经",
}
keyboard = []
for key, name in source_info.items():
enabled = srcs.get(key, True)
status = "" if enabled else ""
keyboard.append([InlineKeyboardButton(
f"{status} {name}", callback_data=f"toggle_src_{key}"
)])
keyboard.append([InlineKeyboardButton("🏠 主菜单", callback_data="main_menu")])
text = "📡 <b>订阅源管理</b>\n\n点击切换开/关:"
if hasattr(message_or_query, "edit_message_text"):
await message_or_query.edit_message_text(
text, parse_mode=ParseMode.HTML,
reply_markup=InlineKeyboardMarkup(keyboard),
)
else:
await message_or_query.reply_text(
text, parse_mode=ParseMode.HTML,
reply_markup=InlineKeyboardMarkup(keyboard),
)
# ========== /keywords 命令 ==========
async def cmd_keywords(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""管理关注关键词"""
if not _check_admin(update.effective_user.id):
return
await _show_keywords_menu(update.message)
async def _show_keywords_menu(message_or_query):
"""显示关键词管理菜单"""
settings = storage.get_settings()
kw_list = settings.get("keywords", [])
# 每行显示3个关键词删除按钮
keyboard = []
row = []
for kw in kw_list[:30]:
row.append(InlineKeyboardButton(f"{kw}", callback_data=f"del_kw_{kw}"))
if len(row) == 3:
keyboard.append(row)
row = []
if row:
keyboard.append(row)
keyboard.append([InlineKeyboardButton(" 添加关键词", callback_data="add_kw_prompt")])
keyboard.append([InlineKeyboardButton("🏠 主菜单", callback_data="main_menu")])
text = f"🔑 <b>关键词管理</b>\n\n当前 {len(kw_list)} 个关键词\n(点击关键词可删除)"
if hasattr(message_or_query, "edit_message_text"):
await message_or_query.edit_message_text(
text, parse_mode=ParseMode.HTML,
reply_markup=InlineKeyboardMarkup(keyboard),
)
else:
await message_or_query.reply_text(
text, parse_mode=ParseMode.HTML,
reply_markup=InlineKeyboardMarkup(keyboard),
)
# ========== /settings 命令 ==========
async def cmd_settings(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""设置菜单"""
if not _check_admin(update.effective_user.id):
return
await _show_settings_menu(update.message)
async def _show_settings_menu(message_or_query):
"""显示设置菜单"""
settings = storage.get_settings()
push_on = settings.get("push_enabled", True)
min_score = settings.get("min_score", 6)
instant_score = settings.get("instant_score", 8)
interval = settings.get("batch_interval", 30)
push_txt = "✅ 推送已开" if push_on else "❌ 推送已关"
keyboard = [
[InlineKeyboardButton(push_txt, callback_data="toggle_push")],
[
InlineKeyboardButton(f"最低评分: {min_score} ", callback_data="score_min_down"),
InlineKeyboardButton(f"最低评分: {min_score} ", callback_data="score_min_up"),
],
[
InlineKeyboardButton(f"即时阈值: {instant_score} ", callback_data="score_instant_down"),
InlineKeyboardButton(f"即时阈值: {instant_score} ", callback_data="score_instant_up"),
],
[
InlineKeyboardButton(f"汇总间隔: {interval}min ", callback_data="interval_down"),
InlineKeyboardButton(f"汇总间隔: {interval}min ", callback_data="interval_up"),
],
[InlineKeyboardButton("🏠 主菜单", callback_data="main_menu")],
]
text = (
f"⚙️ <b>设置</b>\n\n"
f"推送状态: {'开启' if push_on else '关闭'}\n"
f"最低推送评分: {min_score}\n"
f"即时推送阈值: {instant_score}\n"
f"汇总推送间隔: {interval} 分钟"
)
if hasattr(message_or_query, "edit_message_text"):
await message_or_query.edit_message_text(
text, parse_mode=ParseMode.HTML,
reply_markup=InlineKeyboardMarkup(keyboard),
)
else:
await message_or_query.reply_text(
text, parse_mode=ParseMode.HTML,
reply_markup=InlineKeyboardMarkup(keyboard),
)
# ========== CallbackQuery 处理器 ==========
# 等待用户输入关键词的状态
_waiting_kw_add = set()
async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""统一处理所有按钮回调"""
query = update.callback_query
await query.answer()
data = query.data
user_id = query.from_user.id
if not _check_admin(user_id):
return
# ---------- 主菜单 ----------
if data == "main_menu":
keyboard = [
[InlineKeyboardButton("📰 最新快讯", callback_data="news_0"),
InlineKeyboardButton("📊 新闻总结", callback_data="summary_menu")],
[InlineKeyboardButton("📡 订阅源管理", callback_data="sources_menu"),
InlineKeyboardButton("🔑 关键词管理", callback_data="keywords_menu")],
[InlineKeyboardButton("⚙️ 设置", callback_data="settings_menu")],
]
await query.edit_message_text(
"🏠 <b>主菜单</b>\n\n请选择功能:",
parse_mode=ParseMode.HTML,
reply_markup=InlineKeyboardMarkup(keyboard),
)
# ---------- 新闻分页 ----------
elif data.startswith("news_"):
page = int(data.split("_", 1)[1])
await _show_news_page(query, page)
# ---------- 总结菜单 ----------
elif data == "summary_menu":
await _show_summary_menu(query)
elif data.startswith("summary_"):
period = data[len("summary_"):]
start_ts, end_ts = summarizer.get_period_range(period)
settings = storage.get_settings()
news_list = storage.get_news(limit=500, since_ts=start_ts)
news_list = [n for n in news_list if n.get("timestamp", 0) <= end_ts]
scorer.score_and_filter(news_list, settings.get("keywords", []))
news_list = [n for n in news_list if n.get("score", 0) >= settings.get("min_score", 6)]
text = summarizer.generate_summary(news_list, period)
back_btn = InlineKeyboardMarkup([[
InlineKeyboardButton("🔙 返回", callback_data="summary_menu"),
InlineKeyboardButton("🏠 主菜单", callback_data="main_menu"),
]])
try:
await query.edit_message_text(text, parse_mode=ParseMode.HTML, reply_markup=back_btn)
except Exception:
await query.edit_message_text(text, reply_markup=back_btn)
# ---------- 订阅源 ----------
elif data == "sources_menu":
await _show_sources_menu(query)
elif data.startswith("toggle_src_"):
src = data[len("toggle_src_"):]
new_state = storage.toggle_source(src)
state_txt = "开启" if new_state else "关闭"
await query.answer(f"{state_txt} {sources.SOURCE_NAMES.get(src, src)}", show_alert=False)
await _show_sources_menu(query)
# ---------- 关键词 ----------
elif data == "keywords_menu":
await _show_keywords_menu(query)
elif data.startswith("del_kw_"):
kw = data[len("del_kw_"):]
storage.remove_keyword(kw)
await query.answer(f"已删除关键词: {kw}", show_alert=False)
await _show_keywords_menu(query)
elif data == "add_kw_prompt":
_waiting_kw_add.add(user_id)
await query.edit_message_text(
"🔑 <b>添加关键词</b>\n\n请直接发送关键词文字(可空格分隔多个):",
parse_mode=ParseMode.HTML,
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("取消", callback_data="keywords_menu")
]]),
)
# ---------- 设置 ----------
elif data == "settings_menu":
await _show_settings_menu(query)
elif data == "toggle_push":
settings = storage.get_settings()
storage.update_settings({"push_enabled": not settings.get("push_enabled", True)})
await _show_settings_menu(query)
elif data == "score_min_up":
s = storage.get_settings()
storage.update_settings({"min_score": min(10, s.get("min_score", 6) + 1)})
await _show_settings_menu(query)
elif data == "score_min_down":
s = storage.get_settings()
storage.update_settings({"min_score": max(1, s.get("min_score", 6) - 1)})
await _show_settings_menu(query)
elif data == "score_instant_up":
s = storage.get_settings()
storage.update_settings({"instant_score": min(10, s.get("instant_score", 8) + 1)})
await _show_settings_menu(query)
elif data == "score_instant_down":
s = storage.get_settings()
storage.update_settings({"instant_score": max(1, s.get("instant_score", 8) - 1)})
await _show_settings_menu(query)
elif data == "interval_up":
s = storage.get_settings()
storage.update_settings({"batch_interval": min(120, s.get("batch_interval", 30) + 5)})
await _show_settings_menu(query)
elif data == "interval_down":
s = storage.get_settings()
storage.update_settings({"batch_interval": max(5, s.get("batch_interval", 30) - 5)})
await _show_settings_menu(query)
async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""处理普通文本消息(用于添加关键词)"""
user_id = update.effective_user.id
if not _check_admin(user_id):
return
if user_id not in _waiting_kw_add:
return
_waiting_kw_add.discard(user_id)
text = update.message.text.strip()
added = []
for kw in text.split():
if storage.add_keyword(kw):
added.append(kw)
if added:
await update.message.reply_text(
f"✅ 已添加关键词: {', '.join(added)}",
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("🔑 关键词管理", callback_data="keywords_menu")
]]),
)
else:
await update.message.reply_text("⚠️ 关键词已存在或无效")
# ========== 后台抓取任务 ==========
# 汇总队列评分6-7的新闻暂存
_batch_queue: list = []
_last_batch_push: float = 0.0
_pushed_in_memory: set = set() # 内存去重,防止并发重复推送
async def _fetch_and_process(app: Application):
"""核心抓取+处理循环"""
settings = storage.get_settings()
enabled = settings.get("sources", {})
push_on = settings.get("push_enabled", True)
min_score = settings.get("min_score", 6)
instant_score = settings.get("instant_score", 8)
logger.info("开始抓取新闻...")
try:
all_news = await sources.fetch_all(enabled)
except Exception as e:
logger.error(f"fetch_all 异常: {e}")
return
if not all_news:
return
# 评分
scorer.score_and_filter(all_news, settings.get("keywords", []))
# 去重(全局标题去重)
all_news = scorer.dedup_news(all_news)
# 过滤已推送(内存+文件双重去重)
pushed_ids = storage.get_pushed_ids() | _pushed_in_memory
new_items = [n for n in all_news if n["id"] not in pushed_ids and n.get("score", 0) >= min_score]
if not new_items:
return
# 保存到存储
storage.save_news(new_items)
if not push_on:
return
instant_items = [n for n in new_items if n.get("score", 0) >= instant_score]
batch_items = [n for n in new_items if min_score <= n.get("score", 0) < instant_score]
# 即时推送
for item in instant_items:
await _push_instant(app, item)
# 加入汇总队列(去重)
existing_ids = {n["id"] for n in _batch_queue}
for item in batch_items:
if item["id"] not in existing_ids:
_batch_queue.append(item)
existing_ids.add(item["id"])
# 标记已推送(即时+批量都标记,防止重复)
pushed = [n["id"] for n in instant_items + batch_items]
_pushed_in_memory.update(pushed)
storage.mark_pushed(pushed)
async def _push_instant(app: Application, item: dict):
"""立即推送单条重磅新闻"""
score = item.get("score", 0)
if score >= 9:
emoji = "🔥🔥🔥"
elif score >= 8:
emoji = "⚡ 重磅"
else:
emoji = "📌"
src = item.get("source_name", "")
title = item.get("title", "")
ts = item.get("time_str", "")
url = item.get("url", "")
import html as html_mod
title_safe = html_mod.escape(title)
if url:
title_display = f'<a href="{url}">{title_safe}</a>'
else:
title_display = f"<b>{title_safe}</b>"
text = f"{emoji}\n\n{title_display}\n\n📡 {src} 🕐 {ts} 评分:{score}"
try:
await app.bot.send_message(
chat_id=ADMIN_ID, text=text, parse_mode=ParseMode.HTML,
)
except Exception as e:
logger.error(f"即时推送失败: {e}")
try:
await app.bot.send_message(chat_id=ADMIN_ID, text=f"{emoji}\n\n{title}\n\n{src} {ts}")
except Exception as e2:
logger.error(f"即时推送备用失败: {e2}")
async def _push_batch(app: Application):
"""汇总推送每N分钟一次"""
global _batch_queue, _last_batch_push
if not _batch_queue:
return
settings = storage.get_settings()
interval = settings.get("batch_interval", 30) * 60
now = time.time()
if now - _last_batch_push < interval:
return
items = _batch_queue[:]
_batch_queue.clear()
_last_batch_push = now
# 标记已推送
storage.mark_pushed([n["id"] for n in items])
if not items:
return
import html as html_mod
from collections import defaultdict
# 按来源分组
source_map = defaultdict(list)
for item in sorted(items, key=lambda x: x.get("score", 0), reverse=True):
src = item.get("source_name", "未知")
source_map[src].append(item)
source_emoji = {
"金十数据": "💰", "华尔街见闻": "📈", "36氪": "🚀",
"新浪财经": "📊", "Google News": "🌐",
"Finviz": "📉", "TechCrunch": "💻",
}
lines = [f"🟡 <b>快讯汇总</b>{len(items)}条)"]
lines.append("")
for src, src_items in source_map.items():
emoji = source_emoji.get(src, "📰")
lines.append(f"{emoji} <b>{src}</b>{len(src_items)}")
for item in src_items[:10]:
title = item.get("title", "")
url = item.get("url", "")
title_safe = html_mod.escape(title)
if url:
lines.append(f" • <a href=\"{url}\">{title_safe}</a>")
else:
lines.append(f"{title_safe}")
if len(src_items) > 10:
lines.append(f" ... 还有{len(src_items) - 10}")
lines.append("")
lines.append(f"{datetime.now().strftime('%H:%M')}")
text = "\n".join(lines)
try:
await app.bot.send_message(
chat_id=ADMIN_ID, text=text, parse_mode=ParseMode.HTML,
)
except Exception as e:
logger.error(f"汇总推送失败: {e}")
try:
await app.bot.send_message(chat_id=ADMIN_ID, text="\n".join(lines[:15]))
except Exception as e2:
logger.error(f"汇总推送备用失败: {e2}")
async def _send_scheduled_summary(app: Application, period: str):
"""发送定时总结"""
settings = storage.get_settings()
start_ts, end_ts = summarizer.get_period_range(period)
news_list = storage.get_news(limit=500, since_ts=start_ts)
news_list = [n for n in news_list if n.get("timestamp", 0) <= end_ts]
scorer.score_and_filter(news_list, settings.get("keywords", []))
news_list = [n for n in news_list if n.get("score", 0) >= settings.get("min_score", 6)]
text = summarizer.generate_summary(news_list, period)
try:
await app.bot.send_message(
chat_id=ADMIN_ID, text=text, parse_mode=ParseMode.HTML,
)
except Exception as e:
logger.error(f"定时总结推送失败: {e}")
try:
await app.bot.send_message(chat_id=ADMIN_ID, text=text)
except Exception:
pass
# ========== 调度器 ==========
class Scheduler:
"""轻量调度器,基于 asyncio"""
def __init__(self, app: Application):
self.app = app
self._tasks: list[asyncio.Task] = []
def start(self):
self._tasks.append(asyncio.create_task(self._fast_loop()))
self._tasks.append(asyncio.create_task(self._slow_loop()))
self._tasks.append(asyncio.create_task(self._batch_loop()))
self._tasks.append(asyncio.create_task(self._summary_loop()))
logger.info("调度器已启动")
async def _fast_loop(self):
"""金十/华尔街见闻 每2分钟抓取"""
while True:
try:
await _fetch_and_process(self.app)
except Exception as e:
logger.error(f"fast_loop 异常: {e}")
await asyncio.sleep(120)
async def _slow_loop(self):
"""36氪/新浪 每5分钟额外触发fast_loop已包含全部这里可扩展"""
await asyncio.sleep(60) # 错开启动时间
while True:
try:
await _fetch_and_process(self.app)
except Exception as e:
logger.error(f"slow_loop 异常: {e}")
await asyncio.sleep(300)
async def _batch_loop(self):
"""每分钟检查是否需要汇总推送"""
await asyncio.sleep(30)
while True:
try:
await _push_batch(self.app)
except Exception as e:
logger.error(f"batch_loop 异常: {e}")
await asyncio.sleep(60)
async def _summary_loop(self):
"""定时总结 08:00 / 11:30 / 20:00"""
schedule = [
(8, 0, "昨晚到今早"),
(11, 30, "上午"),
(20, 0, "全天"),
]
triggered = set()
while True:
now = datetime.now()
key = f"{now.date()}"
for hour, minute, period in schedule:
tid = f"{key}_{hour}_{minute}"
if tid not in triggered:
# 在目标时间 ±2 分钟内触发
target = now.replace(hour=hour, minute=minute, second=0)
diff = abs((now - target).total_seconds())
if diff <= 120:
triggered.add(tid)
try:
await _send_scheduled_summary(self.app, period)
except Exception as e:
logger.error(f"定时总结异常: {e}")
# 每分钟检查一次
await asyncio.sleep(60)
def stop(self):
for t in self._tasks:
t.cancel()
# ========== 主函数 ==========
def main():
if not BOT_TOKEN:
logger.error("请设置 BOT_TOKEN 环境变量")
return
logger.info(f"机器人启动ADMIN_ID={ADMIN_ID}")
app = Application.builder().token(BOT_TOKEN).build()
# 注册命令
app.add_handler(CommandHandler("start", cmd_start))
app.add_handler(CommandHandler("news", cmd_news))
app.add_handler(CommandHandler("summary", cmd_summary))
app.add_handler(CommandHandler("sources", cmd_sources))
app.add_handler(CommandHandler("keywords", cmd_keywords))
app.add_handler(CommandHandler("settings", cmd_settings))
# 注册回调
app.add_handler(CallbackQueryHandler(handle_callback))
# 注册文本消息(用于添加关键词)
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text))
# 启动调度器(在 post_init 中启动,确保 event loop 已就绪)
scheduler = Scheduler(app)
async def post_init(application: Application):
scheduler.start()
logger.info("调度器已在 post_init 中启动")
app.post_init = post_init
logger.info("开始 polling...")
app.run_polling(drop_pending_updates=True)
if __name__ == "__main__":
main()