Compare commits
7 Commits
f064f751f0
...
fb8a5521a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb8a5521a9 | ||
|
|
2670ca96c7 | ||
|
|
3711dd362b | ||
|
|
81840dfa31 | ||
|
|
31b1235d20 | ||
|
|
54d46d7e6b | ||
|
|
ee1a5b59b0 |
327
SubMind.py
327
SubMind.py
@@ -1,5 +1,7 @@
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import html
|
||||||
import requests
|
import requests
|
||||||
import datetime
|
import datetime
|
||||||
import dateparser
|
import dateparser
|
||||||
@@ -18,7 +20,7 @@ from telegram.ext import (
|
|||||||
CallbackContext, CallbackQueryHandler, ConversationHandler
|
CallbackContext, CallbackQueryHandler, ConversationHandler
|
||||||
)
|
)
|
||||||
from telegram.error import TelegramError
|
from telegram.error import TelegramError
|
||||||
from telegram.helpers import escape_markdown
|
from telegram.helpers import escape_html
|
||||||
|
|
||||||
# --- 加载 .env 和设置 ---
|
# --- 加载 .env 和设置 ---
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -64,13 +66,21 @@ def get_chinese_font():
|
|||||||
logger.info(f"Font '{font_name}' not found. Attempting to download...")
|
logger.info(f"Font '{font_name}' not found. Attempting to download...")
|
||||||
os.makedirs('fonts', exist_ok=True)
|
os.makedirs('fonts', exist_ok=True)
|
||||||
|
|
||||||
url = 'https://github.com/wweir/source-han-sans-sc/raw/refs/heads/master/SourceHanSansSC-Regular.otf'
|
|
||||||
|
urls = [
|
||||||
|
'https://github.com/wweir/source-han-sans-sc/raw/refs/heads/master/SourceHanSansSC-Regular.otf',
|
||||||
|
'https://cdn.jsdelivr.net/gh/wweir/source-han-sans-sc@master/SourceHanSansSC-Regular.otf',
|
||||||
|
'https://fastly.jsdelivr.net/gh/wweir/source-han-sans-sc@master/SourceHanSansSC-Regular.otf'
|
||||||
|
]
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for url in urls:
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, stream=True, headers=headers, timeout=10)
|
logger.info(f"Trying to download font from: {url}")
|
||||||
|
response = requests.get(url, stream=True, headers=headers, timeout=15)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
with open(font_path, 'wb') as f:
|
with open(font_path, 'wb') as f:
|
||||||
for chunk in response.iter_content(chunk_size=8192):
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
@@ -79,7 +89,10 @@ def get_chinese_font():
|
|||||||
fm._load_fontmanager(try_read_cache=False)
|
fm._load_fontmanager(try_read_cache=False)
|
||||||
return fm.FontProperties(fname=font_path)
|
return fm.FontProperties(fname=font_path)
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
logger.error(f"Failed to download font. Error: {e}")
|
logger.warning(f"Failed to download font from {url}. Error: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.error("All font download attempts failed. Falling back to default sans-serif.")
|
||||||
return fm.FontProperties(family='sans-serif')
|
return fm.FontProperties(family='sans-serif')
|
||||||
|
|
||||||
|
|
||||||
@@ -115,6 +128,8 @@ def init_db():
|
|||||||
cursor.execute("ALTER TABLE subscriptions ADD COLUMN reminder_on_due_date BOOLEAN DEFAULT TRUE")
|
cursor.execute("ALTER TABLE subscriptions ADD COLUMN reminder_on_due_date BOOLEAN DEFAULT TRUE")
|
||||||
if 'notes' not in columns:
|
if 'notes' not in columns:
|
||||||
cursor.execute("ALTER TABLE subscriptions ADD COLUMN notes TEXT")
|
cursor.execute("ALTER TABLE subscriptions ADD COLUMN notes TEXT")
|
||||||
|
if 'last_reminded_date' not in columns:
|
||||||
|
cursor.execute("ALTER TABLE subscriptions ADD COLUMN last_reminded_date DATE")
|
||||||
|
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS categories (
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
@@ -211,7 +226,7 @@ def convert_currency(amount, from_curr, to_curr):
|
|||||||
def parse_date(date_string: str) -> str:
|
def parse_date(date_string: str) -> str:
|
||||||
today = datetime.datetime.now()
|
today = datetime.datetime.now()
|
||||||
try:
|
try:
|
||||||
dt = dateparser.parse(date_string, languages=['en', 'zh'])
|
dt = dateparser.parse(date_string, languages=['en', 'zh'], settings={'TIMEZONE': 'Asia/Shanghai', 'RETURN_AS_TIMEZONE_AWARE': False})
|
||||||
if not dt:
|
if not dt:
|
||||||
return None
|
return None
|
||||||
has_year_info = any(c in date_string for c in ['年', '/']) or (re.search(r'\d{4}', date_string) is not None)
|
has_year_info = any(c in date_string for c in ['年', '/']) or (re.search(r'\d{4}', date_string) is not None)
|
||||||
@@ -335,9 +350,11 @@ def update_past_due_dates():
|
|||||||
async def check_and_send_reminders(context: CallbackContext):
|
async def check_and_send_reminders(context: CallbackContext):
|
||||||
logger.info("Running job: Checking for subscription reminders...")
|
logger.info("Running job: Checking for subscription reminders...")
|
||||||
today = datetime.date.today()
|
today = datetime.date.today()
|
||||||
|
today_str = today.strftime('%Y-%m-%d')
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("SELECT * FROM subscriptions WHERE reminders_enabled = TRUE AND next_due IS NOT NULL")
|
# 过滤掉今天已经提醒过的订阅
|
||||||
|
cursor.execute("SELECT * FROM subscriptions WHERE reminders_enabled = TRUE AND next_due IS NOT NULL AND (last_reminded_date IS NULL OR last_reminded_date != ?)", (today_str,))
|
||||||
subs_to_check = cursor.fetchall()
|
subs_to_check = cursor.fetchall()
|
||||||
|
|
||||||
for sub in subs_to_check:
|
for sub in subs_to_check:
|
||||||
@@ -345,7 +362,7 @@ async def check_and_send_reminders(context: CallbackContext):
|
|||||||
due_date = datetime.datetime.strptime(sub['next_due'], '%Y-%m-%d').date()
|
due_date = datetime.datetime.strptime(sub['next_due'], '%Y-%m-%d').date()
|
||||||
user_id = sub['user_id']
|
user_id = sub['user_id']
|
||||||
renewal_type = sub['renewal_type']
|
renewal_type = sub['renewal_type']
|
||||||
safe_sub_name = escape_markdown(sub['name'], version=2)
|
safe_sub_name = escape_html(sub['name'])
|
||||||
|
|
||||||
message = None
|
message = None
|
||||||
keyboard = None
|
keyboard = None
|
||||||
@@ -367,21 +384,25 @@ async def check_and_send_reminders(context: CallbackContext):
|
|||||||
reminder_date = due_date - datetime.timedelta(days=sub['reminder_days'])
|
reminder_date = due_date - datetime.timedelta(days=sub['reminder_days'])
|
||||||
if reminder_date == today:
|
if reminder_date == today:
|
||||||
days_left = (due_date - today).days
|
days_left = (due_date - today).days
|
||||||
days_text = f"*{days_left}天后*" if days_left > 0 else "*今天*"
|
days_text = f"<b>{days_left}天后</b>" if days_left > 0 else "<b>今天</b>"
|
||||||
message = f"🔔 *订阅即将到期提醒*\n\n您的手动续费订阅 `{safe_sub_name}` 将在 {days_text} 到期。"
|
message = f"🔔 *订阅即将到期提醒*\n\n您的手动续费订阅 `{safe_sub_name}` 将在 {days_text} 到期。"
|
||||||
|
|
||||||
if message:
|
if message:
|
||||||
await context.bot.send_message(
|
await context.bot.send_message(
|
||||||
chat_id=user_id,
|
chat_id=user_id,
|
||||||
text=message,
|
text=message,
|
||||||
parse_mode='MarkdownV2',
|
parse_mode='HTML',
|
||||||
reply_markup=keyboard
|
reply_markup=keyboard
|
||||||
)
|
)
|
||||||
|
# 记录今天已发送提醒
|
||||||
|
with get_db_connection() as update_conn:
|
||||||
|
update_cursor = update_conn.cursor()
|
||||||
|
update_cursor.execute("UPDATE subscriptions SET last_reminded_date = ? WHERE id = ?", (today_str, sub['id']))
|
||||||
|
update_conn.commit()
|
||||||
|
logger.info(f"Reminder sent for sub_id {sub['id']}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to process reminder for sub_id {sub.get('id', 'N/A')}: {e}")
|
logger.error(f"Failed to process reminder for sub_id {sub.get('id', 'N/A')}: {e}")
|
||||||
|
|
||||||
|
|
||||||
# --- 命令处理器 ---
|
# --- 命令处理器 ---
|
||||||
async def start(update: Update, context: CallbackContext):
|
async def start(update: Update, context: CallbackContext):
|
||||||
user_id = update.effective_user.id
|
user_id = update.effective_user.id
|
||||||
@@ -389,13 +410,13 @@ async def start(update: Update, context: CallbackContext):
|
|||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute('INSERT OR IGNORE INTO users (user_id) VALUES (?)', (user_id,))
|
cursor.execute('INSERT OR IGNORE INTO users (user_id) VALUES (?)', (user_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
await update.message.reply_text(f'欢迎使用 {escape_markdown(PROJECT_NAME, version=2)}!\n您的私人订阅智能管家。',
|
await update.message.reply_text(f'欢迎使用 <b>{escape_html(PROJECT_NAME)}</b>!\n您的私人订阅智能管家。',
|
||||||
parse_mode='MarkdownV2')
|
parse_mode='HTML')
|
||||||
|
|
||||||
|
|
||||||
async def help_command(update: Update, context: CallbackContext):
|
async def help_command(update: Update, context: CallbackContext):
|
||||||
help_text = fr"""
|
help_text = fr"""
|
||||||
*{escape_markdown(PROJECT_NAME, version=2)} 命令列表*
|
*{escape_html(PROJECT_NAME)} 命令列表*
|
||||||
*🌟 核心功能*
|
*🌟 核心功能*
|
||||||
/add\_sub \- 引导您添加一个新的订阅
|
/add\_sub \- 引导您添加一个新的订阅
|
||||||
/list\_subs \- 列出您的所有订阅
|
/list\_subs \- 列出您的所有订阅
|
||||||
@@ -408,7 +429,7 @@ async def help_command(update: Update, context: CallbackContext):
|
|||||||
/set\_currency \`<code>\` \- 设置您的主要货币
|
/set\_currency \`<code>\` \- 设置您的主要货币
|
||||||
/cancel \- 在任何流程中取消当前操作
|
/cancel \- 在任何流程中取消当前操作
|
||||||
"""
|
"""
|
||||||
await update.message.reply_text(help_text, parse_mode='MarkdownV2')
|
await update.message.reply_text(help_text, parse_mode='HTML')
|
||||||
|
|
||||||
|
|
||||||
def make_autopct(values, currency_code):
|
def make_autopct(values, currency_code):
|
||||||
@@ -425,17 +446,29 @@ def make_autopct(values, currency_code):
|
|||||||
|
|
||||||
async def stats(update: Update, context: CallbackContext):
|
async def stats(update: Update, context: CallbackContext):
|
||||||
user_id = update.effective_user.id
|
user_id = update.effective_user.id
|
||||||
await update.message.reply_text("正在为您生成订阅统计图表,请稍候...")
|
await update.message.reply_text("正在为您生成更美观的统计图,请稍候...")
|
||||||
|
|
||||||
|
def generate_chart():
|
||||||
font_prop = get_chinese_font()
|
font_prop = get_chinese_font()
|
||||||
main_currency = get_user_main_currency(user_id)
|
main_currency = get_user_main_currency(user_id)
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
df = pd.read_sql_query("SELECT * FROM subscriptions WHERE user_id = ?", conn, params=(user_id,))
|
df = pd.read_sql_query("SELECT * FROM subscriptions WHERE user_id = ?", conn, params=(user_id,))
|
||||||
if df.empty:
|
cursor = conn.cursor()
|
||||||
await update.message.reply_text("您还没有任何订阅数据。")
|
cursor.execute("SELECT from_currency, to_currency, rate FROM exchange_rates WHERE to_currency = ?", (main_currency.upper(),))
|
||||||
return
|
rate_cache = {(row['from_currency'], row['to_currency']): row['rate'] for row in cursor.fetchall()}
|
||||||
|
|
||||||
df['converted_cost'] = df.apply(lambda row: convert_currency(row['cost'], row['currency'], main_currency), axis=1)
|
if df.empty:
|
||||||
|
return False, "您还没有任何订阅数据。"
|
||||||
|
|
||||||
|
def fast_convert(amount, from_curr, to_curr):
|
||||||
|
if from_curr.upper() == to_curr.upper():
|
||||||
|
return amount
|
||||||
|
cached_rate = rate_cache.get((from_curr.upper(), to_curr.upper()))
|
||||||
|
if cached_rate is not None:
|
||||||
|
return amount * cached_rate
|
||||||
|
return convert_currency(amount, from_curr, to_curr)
|
||||||
|
|
||||||
|
df['converted_cost'] = df.apply(lambda row: fast_convert(row['cost'], row['currency'], main_currency), axis=1)
|
||||||
unit_to_days = {'day': 1, 'week': 7, 'month': 30.4375, 'year': 365.25}
|
unit_to_days = {'day': 1, 'week': 7, 'month': 30.4375, 'year': 365.25}
|
||||||
|
|
||||||
def normalize_to_monthly(row):
|
def normalize_to_monthly(row):
|
||||||
@@ -450,72 +483,173 @@ async def stats(update: Update, context: CallbackContext):
|
|||||||
category_costs = df.groupby('category')['monthly_cost'].sum().sort_values(ascending=False)
|
category_costs = df.groupby('category')['monthly_cost'].sum().sort_values(ascending=False)
|
||||||
|
|
||||||
if category_costs.empty or category_costs.sum() == 0:
|
if category_costs.empty or category_costs.sum() == 0:
|
||||||
await update.message.reply_text("您的订阅没有有效的费用信息。")
|
return False, "您的订阅没有有效的费用信息。"
|
||||||
return
|
|
||||||
|
|
||||||
plt.style.use('seaborn-v0_8-darkgrid')
|
max_categories = 8
|
||||||
fig, ax = plt.subplots(figsize=(12, 12))
|
if len(category_costs) > max_categories:
|
||||||
|
top = category_costs.iloc[:max_categories]
|
||||||
|
others_sum = category_costs.iloc[max_categories:].sum()
|
||||||
|
if others_sum > 0:
|
||||||
|
category_costs = pd.concat([top, pd.Series({'其他': others_sum})])
|
||||||
|
else:
|
||||||
|
category_costs = top
|
||||||
|
|
||||||
|
total_monthly = category_costs.sum()
|
||||||
|
currency_symbols = {'USD': '$', 'CNY': '¥', 'EUR': '€', 'GBP': '£', 'JPY': '¥'}
|
||||||
|
symbol = currency_symbols.get(main_currency.upper(), f'{main_currency.upper()} ')
|
||||||
|
|
||||||
|
def autopct_if_large(pct):
|
||||||
|
if pct < 4:
|
||||||
|
return ''
|
||||||
|
value = total_monthly * pct / 100
|
||||||
|
return f"{pct:.1f}%\n{symbol}{value:.2f}"
|
||||||
|
|
||||||
|
fig = plt.figure(figsize=(15, 8.5), facecolor='#FAFAFA')
|
||||||
|
gs = fig.add_gridspec(1, 2, width_ratios=[1.1, 1], wspace=0.15)
|
||||||
|
ax_pie = fig.add_subplot(gs[0, 0])
|
||||||
|
ax_bar = fig.add_subplot(gs[0, 1])
|
||||||
image_path = None
|
image_path = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
autopct_function = make_autopct(category_costs.values, main_currency)
|
theme_colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316', '#6366F1', '#84CC16']
|
||||||
|
if len(category_costs) > len(theme_colors):
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
extra_colors = [matplotlib.colors.to_hex(c) for c in plt.get_cmap('tab20').colors]
|
||||||
|
theme_colors.extend(extra_colors)
|
||||||
|
|
||||||
wedges, texts, autotexts = ax.pie(category_costs.values,
|
color_map = {cat: theme_colors[i] for i, cat in enumerate(category_costs.index)}
|
||||||
|
pie_colors = [color_map[cat] for cat in category_costs.index]
|
||||||
|
|
||||||
|
wedges, texts, autotexts = ax_pie.pie(
|
||||||
|
category_costs.values,
|
||||||
labels=category_costs.index,
|
labels=category_costs.index,
|
||||||
autopct=autopct_function,
|
autopct=autopct_if_large,
|
||||||
startangle=140,
|
startangle=140,
|
||||||
pctdistance=0.7,
|
counterclock=False,
|
||||||
labeldistance=1.05)
|
pctdistance=0.75,
|
||||||
|
labeldistance=1.1,
|
||||||
|
colors=pie_colors,
|
||||||
|
wedgeprops={'width': 0.35, 'edgecolor': '#FAFAFA', 'linewidth': 2.5}
|
||||||
|
)
|
||||||
|
|
||||||
ax.set_title('每月订阅支出分类统计', fontproperties=font_prop, fontsize=32, pad=20)
|
for t in texts:
|
||||||
|
t.set_fontproperties(font_prop)
|
||||||
|
t.set_fontsize(13)
|
||||||
|
t.set_color('#374151')
|
||||||
|
for t in autotexts:
|
||||||
|
t.set_fontproperties(font_prop)
|
||||||
|
t.set_fontsize(10)
|
||||||
|
t.set_color('#FFFFFF')
|
||||||
|
t.set_weight('bold')
|
||||||
|
|
||||||
for text in texts:
|
ax_pie.text(
|
||||||
text.set_fontproperties(font_prop)
|
0, 0,
|
||||||
text.set_fontsize(22)
|
f"月总支出\n{symbol}{total_monthly:.2f}",
|
||||||
|
ha='center', va='center',
|
||||||
|
fontproperties=font_prop,
|
||||||
|
fontsize=18,
|
||||||
|
color='#1F2937',
|
||||||
|
weight='bold'
|
||||||
|
)
|
||||||
|
ax_pie.set_title('支出占比结构', fontproperties=font_prop, fontsize=18, pad=20, color='#111827', weight='bold')
|
||||||
|
ax_pie.axis('equal')
|
||||||
|
|
||||||
for autotext in autotexts:
|
bar_series = category_costs.sort_values(ascending=True)
|
||||||
autotext.set_fontproperties(font_prop)
|
bar_colors = [color_map[cat] for cat in bar_series.index]
|
||||||
autotext.set_fontsize(20)
|
|
||||||
autotext.set_color('white')
|
|
||||||
|
|
||||||
ax.axis('equal')
|
bars = ax_bar.barh(bar_series.index, bar_series.values, color=bar_colors, height=0.6, alpha=0.95, edgecolor='none')
|
||||||
fig.tight_layout()
|
|
||||||
|
ax_bar.set_title('各类别月支出对比', fontproperties=font_prop, fontsize=18, pad=20, color='#111827', weight='bold')
|
||||||
|
ax_bar.set_xlabel(f'金额({main_currency.upper()})', fontproperties=font_prop, fontsize=12, color='#6B7280', labelpad=10)
|
||||||
|
|
||||||
|
ax_bar.spines['top'].set_visible(False)
|
||||||
|
ax_bar.spines['right'].set_visible(False)
|
||||||
|
ax_bar.spines['left'].set_visible(False)
|
||||||
|
ax_bar.spines['bottom'].set_color('#E5E7EB')
|
||||||
|
|
||||||
|
ax_bar.tick_params(axis='x', colors='#6B7280', labelsize=11)
|
||||||
|
ax_bar.tick_params(axis='y', length=0, pad=10)
|
||||||
|
|
||||||
|
ax_bar.grid(axis='x', color='#F3F4F6', linestyle='-', linewidth=1.5, alpha=1)
|
||||||
|
ax_bar.set_axisbelow(True)
|
||||||
|
|
||||||
|
for label in ax_bar.get_yticklabels():
|
||||||
|
label.set_fontproperties(font_prop)
|
||||||
|
label.set_fontsize(13)
|
||||||
|
label.set_color('#374151')
|
||||||
|
|
||||||
|
max_val = bar_series.max() if len(bar_series) else 0
|
||||||
|
offset = max_val * 0.02 if max_val > 0 else 0.1
|
||||||
|
for rect, value in zip(bars, bar_series.values):
|
||||||
|
ax_bar.text(
|
||||||
|
rect.get_width() + offset,
|
||||||
|
rect.get_y() + rect.get_height() / 2,
|
||||||
|
f"{symbol}{value:.2f}",
|
||||||
|
va='center',
|
||||||
|
ha='left',
|
||||||
|
fontproperties=font_prop,
|
||||||
|
fontsize=11,
|
||||||
|
color='#4B5563',
|
||||||
|
weight='bold'
|
||||||
|
)
|
||||||
|
|
||||||
|
fig.suptitle('📊 您的订阅支出洞察', fontproperties=font_prop, fontsize=24, color='#0F172A', y=1.02, weight='bold')
|
||||||
|
fig.tight_layout(rect=[0, 0, 1, 0.95])
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(prefix=f'stats_{user_id}_', suffix='.png', delete=False) as tmp:
|
with tempfile.NamedTemporaryFile(prefix=f'stats_{user_id}_', suffix='.png', delete=False) as tmp:
|
||||||
image_path = tmp.name
|
image_path = tmp.name
|
||||||
|
|
||||||
plt.savefig(image_path)
|
plt.savefig(image_path, dpi=250, bbox_inches='tight', facecolor=fig.get_facecolor())
|
||||||
|
return True, image_path
|
||||||
with open(image_path, 'rb') as photo:
|
|
||||||
await update.message.reply_photo(photo, caption="这是您按类别统计的每月订阅总支出。")
|
|
||||||
finally:
|
finally:
|
||||||
plt.close(fig)
|
plt.close(fig)
|
||||||
if image_path and os.path.exists(image_path):
|
|
||||||
os.remove(image_path)
|
success, result = await asyncio.to_thread(generate_chart)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
try:
|
||||||
|
with open(result, 'rb') as photo:
|
||||||
|
await update.message.reply_photo(photo, caption="✨ 已为您生成全新的精美订阅统计图!")
|
||||||
|
finally:
|
||||||
|
if os.path.exists(result):
|
||||||
|
os.remove(result)
|
||||||
|
else:
|
||||||
|
await update.message.reply_text(result)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# --- Import/Export Commands ---
|
# --- Import/Export Commands ---
|
||||||
async def export_command(update: Update, context: CallbackContext):
|
async def export_command(update: Update, context: CallbackContext):
|
||||||
user_id = update.effective_user.id
|
user_id = update.effective_user.id
|
||||||
|
|
||||||
|
# 将重度 I/O 和 CPU 绑定的 pandas 导出操作放入后台线程
|
||||||
|
def process_export():
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
df = pd.read_sql_query(
|
df = pd.read_sql_query(
|
||||||
"SELECT name, cost, currency, category, next_due, frequency_unit, frequency_value, renewal_type, notes FROM subscriptions WHERE user_id = ?",
|
"SELECT name, cost, currency, category, next_due, frequency_unit, frequency_value, renewal_type, notes FROM subscriptions WHERE user_id = ?",
|
||||||
conn, params=(user_id,))
|
conn, params=(user_id,))
|
||||||
if df.empty:
|
if df.empty:
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
tmp = tempfile.NamedTemporaryFile(prefix=f'export_{user_id}_', suffix='.csv', delete=False)
|
||||||
|
export_path = tmp.name
|
||||||
|
tmp.close()
|
||||||
|
|
||||||
|
df.to_csv(export_path, index=False, encoding='utf-8-sig')
|
||||||
|
return True, export_path
|
||||||
|
|
||||||
|
success, export_path = await asyncio.to_thread(process_export)
|
||||||
|
|
||||||
|
if not success:
|
||||||
await update.message.reply_text("您还没有任何订阅数据,无法导出。")
|
await update.message.reply_text("您还没有任何订阅数据,无法导出。")
|
||||||
return
|
return
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(prefix=f'export_{user_id}_', suffix='.csv', delete=False) as tmp:
|
|
||||||
export_path = tmp.name
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
df.to_csv(export_path, index=False, encoding='utf-8-sig')
|
|
||||||
|
|
||||||
with open(export_path, 'rb') as file:
|
with open(export_path, 'rb') as file:
|
||||||
await update.message.reply_document(document=file, filename='subscriptions.csv',
|
await update.message.reply_document(document=file, filename='subscriptions.csv',
|
||||||
caption="您的订阅数据已导出为 CSV 文件。")
|
caption="您的订阅数据已导出为 CSV 文件。")
|
||||||
finally:
|
finally:
|
||||||
if os.path.exists(export_path):
|
if export_path and os.path.exists(export_path):
|
||||||
os.remove(export_path)
|
os.remove(export_path)
|
||||||
|
|
||||||
|
|
||||||
@@ -612,8 +746,8 @@ async def import_upload_received(update: Update, context: CallbackContext):
|
|||||||
# --- Add Subscription Conversation ---
|
# --- Add Subscription Conversation ---
|
||||||
async def add_sub_start(update: Update, context: CallbackContext):
|
async def add_sub_start(update: Update, context: CallbackContext):
|
||||||
context.user_data['new_sub_data'] = {}
|
context.user_data['new_sub_data'] = {}
|
||||||
await update.message.reply_text("好的,我们来添加一个新订阅。\n\n第一步:请输入订阅的 *名称*",
|
await update.message.reply_text("好的,我们来添加一个新订阅。\n\n第一步:请输入订阅的 <b>名称</b>",
|
||||||
parse_mode='MarkdownV2')
|
parse_mode='HTML')
|
||||||
return ADD_NAME
|
return ADD_NAME
|
||||||
|
|
||||||
|
|
||||||
@@ -632,7 +766,7 @@ async def add_name_received(update: Update, context: CallbackContext):
|
|||||||
await update.message.reply_text(f"订阅名称过长,请控制在 {MAX_NAME_LEN} 个字符以内。")
|
await update.message.reply_text(f"订阅名称过长,请控制在 {MAX_NAME_LEN} 个字符以内。")
|
||||||
return ADD_NAME
|
return ADD_NAME
|
||||||
sub_data['name'] = name
|
sub_data['name'] = name
|
||||||
await update.message.reply_text("第二步:请输入订阅 *费用*", parse_mode='MarkdownV2')
|
await update.message.reply_text("第二步:请输入订阅 <b>费用</b>", parse_mode='HTML')
|
||||||
return ADD_COST
|
return ADD_COST
|
||||||
|
|
||||||
|
|
||||||
@@ -651,7 +785,7 @@ async def add_cost_received(update: Update, context: CallbackContext):
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
await update.message.reply_text("费用必须是有效的非负数字。")
|
await update.message.reply_text("费用必须是有效的非负数字。")
|
||||||
return ADD_COST
|
return ADD_COST
|
||||||
await update.message.reply_text("第三步:请输入 *货币* 代码(例如 USD, CNY)", parse_mode='MarkdownV2')
|
await update.message.reply_text("第三步:请输入 <b>货币</b> 代码(例如 USD, CNY)", parse_mode='HTML')
|
||||||
return ADD_CURRENCY
|
return ADD_CURRENCY
|
||||||
|
|
||||||
|
|
||||||
@@ -667,7 +801,7 @@ async def add_currency_received(update: Update, context: CallbackContext):
|
|||||||
await update.message.reply_text("请输入有效的三字母货币代码(如 USD, CNY)。")
|
await update.message.reply_text("请输入有效的三字母货币代码(如 USD, CNY)。")
|
||||||
return ADD_CURRENCY
|
return ADD_CURRENCY
|
||||||
sub_data['currency'] = currency
|
sub_data['currency'] = currency
|
||||||
await update.message.reply_text("第四步:请为订阅指定一个 *类别*", parse_mode='MarkdownV2')
|
await update.message.reply_text("第四步:请为订阅指定一个 <b>类别</b>", parse_mode='HTML')
|
||||||
return ADD_CATEGORY
|
return ADD_CATEGORY
|
||||||
|
|
||||||
|
|
||||||
@@ -691,7 +825,7 @@ async def add_category_received(update: Update, context: CallbackContext):
|
|||||||
cursor.execute("INSERT OR IGNORE INTO categories (user_id, name) VALUES (?, ?)", (user_id, category_name))
|
cursor.execute("INSERT OR IGNORE INTO categories (user_id, name) VALUES (?, ?)", (user_id, category_name))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
await update.message.reply_text("第五步:请输入 *下一次付款日期*(例如 2025\\-10\\-01 或 10月1日)",
|
await update.message.reply_text("第五步:请输入 *下一次付款日期*(例如 2025\\-10\\-01 或 10月1日)",
|
||||||
parse_mode='MarkdownV2')
|
parse_mode='HTML')
|
||||||
return ADD_NEXT_DUE
|
return ADD_NEXT_DUE
|
||||||
|
|
||||||
|
|
||||||
@@ -713,8 +847,8 @@ async def add_next_due_received(update: Update, context: CallbackContext):
|
|||||||
[InlineKeyboardButton("月", callback_data='freq_unit_month'),
|
[InlineKeyboardButton("月", callback_data='freq_unit_month'),
|
||||||
InlineKeyboardButton("年", callback_data='freq_unit_year')]
|
InlineKeyboardButton("年", callback_data='freq_unit_year')]
|
||||||
]
|
]
|
||||||
await update.message.reply_text("第六步:请选择付款周期的*单位*", reply_markup=InlineKeyboardMarkup(keyboard),
|
await update.message.reply_text("第六步:请选择付款周期的<b>单位</b>", reply_markup=InlineKeyboardMarkup(keyboard),
|
||||||
parse_mode='MarkdownV2')
|
parse_mode='HTML')
|
||||||
return ADD_FREQ_UNIT
|
return ADD_FREQ_UNIT
|
||||||
|
|
||||||
|
|
||||||
@@ -731,7 +865,7 @@ async def add_freq_unit_received(update: Update, context: CallbackContext):
|
|||||||
await query.edit_message_text("错误:无效的周期单位,请重试。")
|
await query.edit_message_text("错误:无效的周期单位,请重试。")
|
||||||
return ConversationHandler.END
|
return ConversationHandler.END
|
||||||
sub_data['unit'] = unit
|
sub_data['unit'] = unit
|
||||||
await query.edit_message_text("第七步:请输入周期的*数量*(例如:每3个月,输入 3)", parse_mode='Markdown')
|
await query.edit_message_text("第七步:请输入周期的<b>数量</b>(例如:每3个月,输入 3)", parse_mode='Markdown')
|
||||||
return ADD_FREQ_VALUE
|
return ADD_FREQ_VALUE
|
||||||
|
|
||||||
|
|
||||||
@@ -754,8 +888,8 @@ async def add_freq_value_received(update: Update, context: CallbackContext):
|
|||||||
[InlineKeyboardButton("自动续费", callback_data='renewal_auto'),
|
[InlineKeyboardButton("自动续费", callback_data='renewal_auto'),
|
||||||
InlineKeyboardButton("手动续费", callback_data='renewal_manual')]
|
InlineKeyboardButton("手动续费", callback_data='renewal_manual')]
|
||||||
]
|
]
|
||||||
await update.message.reply_text("第八步:请选择 *续费方式*", reply_markup=InlineKeyboardMarkup(keyboard),
|
await update.message.reply_text("第八步:请选择 <b>续费方式</b>", reply_markup=InlineKeyboardMarkup(keyboard),
|
||||||
parse_mode='MarkdownV2')
|
parse_mode='HTML')
|
||||||
return ADD_RENEWAL_TYPE
|
return ADD_RENEWAL_TYPE
|
||||||
|
|
||||||
|
|
||||||
@@ -790,8 +924,8 @@ async def add_notes_received(update: Update, context: CallbackContext):
|
|||||||
return ADD_NOTES
|
return ADD_NOTES
|
||||||
sub_data['notes'] = note if note else None
|
sub_data['notes'] = note if note else None
|
||||||
save_subscription(update.effective_user.id, sub_data)
|
save_subscription(update.effective_user.id, sub_data)
|
||||||
await update.message.reply_text(text=f"✅ 订阅 '{escape_markdown(sub_data.get('name', ''), version=2)}' 已添加!",
|
await update.message.reply_text(text=f"✅ 订阅 '{escape_html(sub_data.get('name', ''))}' 已添加!",
|
||||||
parse_mode='MarkdownV2')
|
parse_mode='HTML')
|
||||||
_clear_action_state(context, ['new_sub_data'])
|
_clear_action_state(context, ['new_sub_data'])
|
||||||
return ConversationHandler.END
|
return ConversationHandler.END
|
||||||
|
|
||||||
@@ -806,8 +940,8 @@ async def skip_notes(update: Update, context: CallbackContext):
|
|||||||
|
|
||||||
sub_data['notes'] = None
|
sub_data['notes'] = None
|
||||||
save_subscription(update.effective_user.id, sub_data)
|
save_subscription(update.effective_user.id, sub_data)
|
||||||
await update.message.reply_text(text=f"✅ 订阅 '{escape_markdown(sub_data.get('name', ''), version=2)}' 已添加!",
|
await update.message.reply_text(text=f"✅ 订阅 '{escape_html(sub_data.get('name', ''))}' 已添加!",
|
||||||
parse_mode='MarkdownV2')
|
parse_mode='HTML')
|
||||||
_clear_action_state(context, ['new_sub_data'])
|
_clear_action_state(context, ['new_sub_data'])
|
||||||
return ConversationHandler.END
|
return ConversationHandler.END
|
||||||
|
|
||||||
@@ -879,11 +1013,8 @@ async def show_subscription_view(update: Update, context: CallbackContext, sub_i
|
|||||||
freq_text = format_frequency(sub['frequency_unit'], sub['frequency_value'])
|
freq_text = format_frequency(sub['frequency_unit'], sub['frequency_value'])
|
||||||
main_currency = get_user_main_currency(user_id)
|
main_currency = get_user_main_currency(user_id)
|
||||||
converted_cost = convert_currency(cost, currency, main_currency)
|
converted_cost = convert_currency(cost, currency, main_currency)
|
||||||
safe_name, safe_category, safe_freq = escape_markdown(name, version=2), escape_markdown(category,
|
safe_name, safe_category, safe_freq = escape_html(name), escape_html(category), escape_html(freq_text)
|
||||||
version=2), escape_markdown(
|
cost_str, converted_cost_str = escape_html(f"{cost:.2f}"), escape_html(f"{converted_cost:.2f}")
|
||||||
freq_text, version=2)
|
|
||||||
cost_str, converted_cost_str = escape_markdown(f"{cost:.2f}", version=2), escape_markdown(f"{converted_cost:.2f}",
|
|
||||||
version=2)
|
|
||||||
renewal_text = "手动续费" if renewal_type == 'manual' else "自动续费"
|
renewal_text = "手动续费" if renewal_type == 'manual' else "自动续费"
|
||||||
reminder_status = "开启" if reminders_enabled else "关闭"
|
reminder_status = "开启" if reminders_enabled else "关闭"
|
||||||
text = (f"*订阅详情: {safe_name}*\n\n"
|
text = (f"*订阅详情: {safe_name}*\n\n"
|
||||||
@@ -893,7 +1024,7 @@ async def show_subscription_view(update: Update, context: CallbackContext, sub_i
|
|||||||
f"\\- *续费方式*: `{renewal_text}`\n"
|
f"\\- *续费方式*: `{renewal_text}`\n"
|
||||||
f"\\- *提醒状态*: `{reminder_status}`")
|
f"\\- *提醒状态*: `{reminder_status}`")
|
||||||
if notes:
|
if notes:
|
||||||
text += f"\n\\- *备注*: {escape_markdown(notes, version=2)}"
|
text += f"\n\\- *备注*: {escape_html(notes)}"
|
||||||
keyboard_buttons = [
|
keyboard_buttons = [
|
||||||
[InlineKeyboardButton("✏️ 编辑", callback_data=f'edit_{sub_id}'),
|
[InlineKeyboardButton("✏️ 编辑", callback_data=f'edit_{sub_id}'),
|
||||||
InlineKeyboardButton("🗑️ 删除", callback_data=f'delete_{sub_id}')],
|
InlineKeyboardButton("🗑️ 删除", callback_data=f'delete_{sub_id}')],
|
||||||
@@ -914,10 +1045,10 @@ async def show_subscription_view(update: Update, context: CallbackContext, sub_i
|
|||||||
logger.debug(f"Generated buttons for sub_id {sub_id}: edit_{sub_id}, remind_{sub_id}")
|
logger.debug(f"Generated buttons for sub_id {sub_id}: edit_{sub_id}, remind_{sub_id}")
|
||||||
if update.callback_query:
|
if update.callback_query:
|
||||||
await update.callback_query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard_buttons),
|
await update.callback_query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard_buttons),
|
||||||
parse_mode='MarkdownV2')
|
parse_mode='HTML')
|
||||||
elif update.effective_message:
|
elif update.effective_message:
|
||||||
await update.effective_message.reply_text(text, reply_markup=InlineKeyboardMarkup(keyboard_buttons),
|
await update.effective_message.reply_text(text, reply_markup=InlineKeyboardMarkup(keyboard_buttons),
|
||||||
parse_mode='MarkdownV2')
|
parse_mode='HTML')
|
||||||
|
|
||||||
|
|
||||||
async def button_callback_handler(update: Update, context: CallbackContext):
|
async def button_callback_handler(update: Update, context: CallbackContext):
|
||||||
@@ -946,11 +1077,11 @@ async def button_callback_handler(update: Update, context: CallbackContext):
|
|||||||
context.user_data['list_subs_in_category'] = category
|
context.user_data['list_subs_in_category'] = category
|
||||||
context.user_data['list_subs_in_category_id'] = category_id
|
context.user_data['list_subs_in_category_id'] = category_id
|
||||||
keyboard = await get_subs_list_keyboard(user_id, category_filter=category)
|
keyboard = await get_subs_list_keyboard(user_id, category_filter=category)
|
||||||
msg_text = f"分类“{escape_markdown(category, version=2)}”下的订阅:"
|
msg_text = f"分类 <b>{escape_html(category)}</b> 下的订阅:"
|
||||||
if not keyboard:
|
if not keyboard:
|
||||||
msg_text = f"分类“{escape_markdown(category, version=2)}”下没有订阅。"
|
msg_text = f"分类 <b>{escape_html(category)}</b> 下没有订阅。"
|
||||||
keyboard = InlineKeyboardMarkup([[InlineKeyboardButton("« 返回分类列表", callback_data='list_categories')]])
|
keyboard = InlineKeyboardMarkup([[InlineKeyboardButton("« 返回分类列表", callback_data='list_categories')]])
|
||||||
await query.edit_message_text(msg_text, reply_markup=keyboard, parse_mode='MarkdownV2')
|
await query.edit_message_text(msg_text, reply_markup=keyboard, parse_mode='HTML')
|
||||||
return
|
return
|
||||||
if data == 'list_categories':
|
if data == 'list_categories':
|
||||||
context.user_data.pop('list_subs_in_category', None)
|
context.user_data.pop('list_subs_in_category', None)
|
||||||
@@ -1020,10 +1151,10 @@ async def button_callback_handler(update: Update, context: CallbackContext):
|
|||||||
(new_date_str, sub_id, user_id)
|
(new_date_str, sub_id, user_id)
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
safe_sub_name = escape_markdown(sub['name'], version=2)
|
safe_sub_name = escape_html(sub['name'])
|
||||||
await query.edit_message_text(
|
await query.edit_message_text(
|
||||||
text=f"✅ *续费成功*\n\n您的订阅 `{safe_sub_name}` 新的到期日为: `{new_date_str}`",
|
text=f"✅ <b>续费成功</b>\n\n您的订阅 <code>{safe_sub_name}</code> 新的到期日为: <code>{new_date_str}</code>",
|
||||||
parse_mode='MarkdownV2',
|
parse_mode='HTML',
|
||||||
reply_markup=None
|
reply_markup=None
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -1031,7 +1162,7 @@ async def button_callback_handler(update: Update, context: CallbackContext):
|
|||||||
else:
|
else:
|
||||||
await query.answer("续费失败:此订阅可能已被删除或无权限。", show_alert=True)
|
await query.answer("续费失败:此订阅可能已被删除或无权限。", show_alert=True)
|
||||||
await query.edit_message_text(text=query.message.text + "\n\n*(错误:此订阅不存在或无权限)*",
|
await query.edit_message_text(text=query.message.text + "\n\n*(错误:此订阅不存在或无权限)*",
|
||||||
parse_mode='MarkdownV2', reply_markup=None)
|
parse_mode='HTML', reply_markup=None)
|
||||||
|
|
||||||
elif action == 'delete':
|
elif action == 'delete':
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
@@ -1058,12 +1189,12 @@ async def button_callback_handler(update: Update, context: CallbackContext):
|
|||||||
if 'list_subs_in_category' in context.user_data:
|
if 'list_subs_in_category' in context.user_data:
|
||||||
category = context.user_data['list_subs_in_category']
|
category = context.user_data['list_subs_in_category']
|
||||||
keyboard = await get_subs_list_keyboard(user_id, category_filter=category)
|
keyboard = await get_subs_list_keyboard(user_id, category_filter=category)
|
||||||
msg_text = f"分类“{escape_markdown(category, version=2)}”下的订阅:"
|
msg_text = f"分类 <b>{escape_html(category)}</b> 下的订阅:"
|
||||||
if not keyboard:
|
if not keyboard:
|
||||||
msg_text = f"分类“{escape_markdown(category, version=2)}”下没有订阅。"
|
msg_text = f"分类 <b>{escape_html(category)}</b> 下没有订阅。"
|
||||||
keyboard = InlineKeyboardMarkup(
|
keyboard = InlineKeyboardMarkup(
|
||||||
[[InlineKeyboardButton("« 返回分类列表", callback_data='list_categories')]])
|
[[InlineKeyboardButton("« 返回分类列表", callback_data='list_categories')]])
|
||||||
await query.edit_message_text(msg_text, reply_markup=keyboard, parse_mode='MarkdownV2')
|
await query.edit_message_text(msg_text, reply_markup=keyboard, parse_mode='HTML')
|
||||||
else:
|
else:
|
||||||
keyboard = await get_subs_list_keyboard(user_id)
|
keyboard = await get_subs_list_keyboard(user_id)
|
||||||
if not keyboard:
|
if not keyboard:
|
||||||
@@ -1139,16 +1270,16 @@ async def edit_field_selected(update: Update, context: CallbackContext):
|
|||||||
[InlineKeyboardButton("月", callback_data='freq_unit_month'),
|
[InlineKeyboardButton("月", callback_data='freq_unit_month'),
|
||||||
InlineKeyboardButton("年", callback_data='freq_unit_year')]
|
InlineKeyboardButton("年", callback_data='freq_unit_year')]
|
||||||
]
|
]
|
||||||
await query.edit_message_text("请选择新的周期*单位*", reply_markup=InlineKeyboardMarkup(keyboard),
|
await query.edit_message_text("请选择新的周期<b>单位</b>", reply_markup=InlineKeyboardMarkup(keyboard),
|
||||||
parse_mode='MarkdownV2')
|
parse_mode='HTML')
|
||||||
return EDIT_FREQ_UNIT
|
return EDIT_FREQ_UNIT
|
||||||
else:
|
else:
|
||||||
field_map = {'name': '名称', 'cost': '费用', 'currency': '货币', 'category': '类别', 'next_due': '下次付款日',
|
field_map = {'name': '名称', 'cost': '费用', 'currency': '货币', 'category': '类别', 'next_due': '下次付款日',
|
||||||
'notes': '备注'}
|
'notes': '备注'}
|
||||||
prompt = f"好的,请输入新的 *{field_map.get(field_to_edit, field_to_edit)}* 值:"
|
prompt = f"好的,请输入新的 <b>{field_map.get(field_to_edit, field_to_edit)}</b> 值:"
|
||||||
if field_to_edit == 'notes':
|
if field_to_edit == 'notes':
|
||||||
prompt += "\n(如需清空备注,请输入 /empty )"
|
prompt += "\n(如需清空备注,请输入 /empty )"
|
||||||
await query.edit_message_text(prompt, parse_mode='MarkdownV2')
|
await query.edit_message_text(prompt, parse_mode='HTML')
|
||||||
return EDIT_GET_NEW_VALUE
|
return EDIT_GET_NEW_VALUE
|
||||||
|
|
||||||
|
|
||||||
@@ -1160,7 +1291,7 @@ async def edit_freq_unit_received(update: Update, context: CallbackContext):
|
|||||||
await query.edit_message_text("错误:无效的周期单位,请重试。")
|
await query.edit_message_text("错误:无效的周期单位,请重试。")
|
||||||
return ConversationHandler.END
|
return ConversationHandler.END
|
||||||
context.user_data['new_freq_unit'] = unit
|
context.user_data['new_freq_unit'] = unit
|
||||||
await query.edit_message_text("好的,现在请输入新的周期*数量*。", parse_mode='MarkdownV2')
|
await query.edit_message_text("好的,现在请输入新的周期<b>数量</b>。", parse_mode='HTML')
|
||||||
return EDIT_FREQ_VALUE
|
return EDIT_FREQ_VALUE
|
||||||
|
|
||||||
|
|
||||||
@@ -1209,7 +1340,7 @@ async def edit_new_value_received(update: Update, context: CallbackContext):
|
|||||||
await update.effective_message.reply_text("错误:未选择要编辑的字段。")
|
await update.effective_message.reply_text("错误:未选择要编辑的字段。")
|
||||||
return ConversationHandler.END
|
return ConversationHandler.END
|
||||||
db_field = EDITABLE_SUB_FIELDS.get(field)
|
db_field = EDITABLE_SUB_FIELDS.get(field)
|
||||||
if not db_field:
|
if not db_field or not db_field.isidentifier():
|
||||||
if update.effective_message:
|
if update.effective_message:
|
||||||
await update.effective_message.reply_text("错误:不允许编辑该字段。")
|
await update.effective_message.reply_text("错误:不允许编辑该字段。")
|
||||||
logger.warning(f"Blocked unsafe field update attempt: {field}")
|
logger.warning(f"Blocked unsafe field update attempt: {field}")
|
||||||
@@ -1335,13 +1466,13 @@ async def _display_reminder_settings(query: CallbackQuery, context: CallbackCont
|
|||||||
[InlineKeyboardButton(enabled_text, callback_data='remindaction_toggle_enabled')],
|
[InlineKeyboardButton(enabled_text, callback_data='remindaction_toggle_enabled')],
|
||||||
[InlineKeyboardButton(due_date_text, callback_data='remindaction_toggle_due_date')]
|
[InlineKeyboardButton(due_date_text, callback_data='remindaction_toggle_due_date')]
|
||||||
]
|
]
|
||||||
safe_name = escape_markdown(sub['name'], version=2)
|
safe_name = escape_html(sub['name'])
|
||||||
current_status = f"*🔔 提醒设置: {safe_name}*\n\n"
|
current_status = f"<b>🔔 提醒设置: {safe_name}</b>\n\n"
|
||||||
if sub['renewal_type'] == 'manual':
|
if sub['renewal_type'] == 'manual':
|
||||||
current_status += f"当前提前提醒: *{sub['reminder_days']}天*\n"
|
current_status += f"当前提前提醒: *{sub['reminder_days']}天*\n"
|
||||||
keyboard.append([InlineKeyboardButton("⚙️ 更改提前天数", callback_data='remindaction_ask_days')])
|
keyboard.append([InlineKeyboardButton("⚙️ 更改提前天数", callback_data='remindaction_ask_days')])
|
||||||
keyboard.append([InlineKeyboardButton("« 返回详情", callback_data=f'view_{sub_id}')])
|
keyboard.append([InlineKeyboardButton("« 返回详情", callback_data=f'view_{sub_id}')])
|
||||||
await query.edit_message_text(current_status, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode='MarkdownV2')
|
await query.edit_message_text(current_status, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode='HTML')
|
||||||
|
|
||||||
|
|
||||||
async def remind_settings_start(update: Update, context: CallbackContext):
|
async def remind_settings_start(update: Update, context: CallbackContext):
|
||||||
@@ -1443,7 +1574,7 @@ async def remind_days_received(update: Update, context: CallbackContext):
|
|||||||
async def set_currency(update: Update, context: CallbackContext):
|
async def set_currency(update: Update, context: CallbackContext):
|
||||||
user_id, args = update.effective_user.id, context.args
|
user_id, args = update.effective_user.id, context.args
|
||||||
if len(args) != 1:
|
if len(args) != 1:
|
||||||
await update.message.reply_text("用法: /set_currency `<code>`(例如 /set_currency USD)", parse_mode='MarkdownV2')
|
await update.message.reply_text("用法: /set_currency <code>代码</code>(例如 /set_currency USD)", parse_mode='HTML')
|
||||||
return
|
return
|
||||||
new_currency = args[0].upper()
|
new_currency = args[0].upper()
|
||||||
if len(new_currency) != 3 or not new_currency.isalpha():
|
if len(new_currency) != 3 or not new_currency.isalpha():
|
||||||
@@ -1457,8 +1588,9 @@ async def set_currency(update: Update, context: CallbackContext):
|
|||||||
ON CONFLICT(user_id) DO UPDATE SET main_currency = excluded.main_currency
|
ON CONFLICT(user_id) DO UPDATE SET main_currency = excluded.main_currency
|
||||||
""", (user_id, new_currency))
|
""", (user_id, new_currency))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
await update.message.reply_text(f"您的主货币已设为 {escape_markdown(new_currency, version=2)}。",
|
await update.message.reply_text(f"您的主货币已设为 <b>{escape_html(new_currency)}</b>。",
|
||||||
parse_mode='MarkdownV2')
|
parse_mode='HTML')
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
|
||||||
async def cancel(update: Update, context: CallbackContext):
|
async def cancel(update: Update, context: CallbackContext):
|
||||||
@@ -1477,6 +1609,9 @@ def main():
|
|||||||
logger.critical("TELEGRAM_TOKEN 环境变量未设置!")
|
logger.critical("TELEGRAM_TOKEN 环境变量未设置!")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not EXCHANGE_API_KEY:
|
||||||
|
logger.info("未配置 EXCHANGE_API_KEY,多货币换算将降级为只使用本地缓存(若无缓存则不转换)。")
|
||||||
|
|
||||||
application = Application.builder().token(TELEGRAM_TOKEN).build()
|
application = Application.builder().token(TELEGRAM_TOKEN).build()
|
||||||
|
|
||||||
async def post_init(app: Application):
|
async def post_init(app: Application):
|
||||||
|
|||||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
python-telegram-bot>=20.0
|
||||||
|
pandas
|
||||||
|
matplotlib
|
||||||
|
python-dateutil
|
||||||
|
dateparser
|
||||||
|
python-dotenv
|
||||||
|
requests
|
||||||
Reference in New Issue
Block a user