feat: multi-user support - Config format: JSON with bot_token (global), users array - Each user has chat_id, AI config, email_accounts - Each email account has separate IMAP/SMTP with ssl/starttls - Config module: new dataclasses (UserConfig, ImapConfig, SmtpConfig) - find_user_by_chat_id() for callback dispatch - Main: email poller iterates all users, queues carry user object - Summarizer: accepts UserConfig, uses user-specific AI and chat_id - TG bot: all functions use bot_token string instead of TelegramConfig - Callback/message handlers look up user by chat_id - AI regen/hint threads use find_user_by_chat_id for AI config - Config example: config.example.json - Tests: 3/3 passing

This commit is contained in:
2026-07-17 22:05:59 +08:00
parent 9906f1675f
commit f2b3793d4c
5 changed files with 213 additions and 155 deletions

32
config.example.json Normal file
View File

@@ -0,0 +1,32 @@
{
"bot_token": "your_bot_token_here",
"polling_interval": 30,
"users": [
{
"chat_id": "your_telegram_chat_id",
"ai": {
"api_key": "your_deepseek_api_key",
"model": "deepseek-v4-flash",
"base_url": "https://api.deepseek.com"
},
"email_accounts": [
{
"username": "you@126.com",
"password": "your_imap_password",
"imap": {
"server": "imap.126.com",
"port": 993,
"ssl": true,
"starttls": false
},
"smtp": {
"server": "smtp.126.com",
"port": 465,
"ssl": true,
"starttls": false
}
}
]
}
]
}

70
main.py
View File

@@ -6,12 +6,12 @@ import sys
import threading import threading
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from src.config import load_config from src.config import load_config, Config, UserConfig
from src.summarizer import poll_accounts, ai_process, tg_send_and_mark, _mail_label from src.summarizer import poll_accounts, ai_process, tg_send_and_mark, _mail_label
from src.tg_bot import poll_telegram, send_thinking from src.tg_bot import poll_telegram, send_thinking
from src.database import init_db, is_uid_processed, mark_fetched, mark_processing, mark_sent, mark_failed from src.database import init_db, is_uid_processed, mark_fetched, mark_processing, mark_sent, mark_failed
# Thread-safe logging: all log records go through a single QueueListener thread # Thread-safe logging
_log_queue = queue_module.Queue(-1) _log_queue = queue_module.Queue(-1)
_queue_handler = logging.handlers.QueueHandler(_log_queue) _queue_handler = logging.handlers.QueueHandler(_log_queue)
_console_handler = logging.StreamHandler() _console_handler = logging.StreamHandler()
@@ -31,9 +31,9 @@ logger = logging.getLogger("main")
_running = True _running = True
_shutdown_event = threading.Event() _shutdown_event = threading.Event()
# Queue 1: raw emails → AI processor # Queue 1: raw emails → AI processor (user, acct_idx, mail, tg_msg_id)
_email_queue: queue_module.Queue = queue_module.Queue() _email_queue: queue_module.Queue = queue_module.Queue()
# Queue 2: AI summaries → TG sender # Queue 2: AI summaries → TG sender (user, info_dict)
_tg_queue: queue_module.Queue = queue_module.Queue() _tg_queue: queue_module.Queue = queue_module.Queue()
@@ -44,33 +44,36 @@ def _signal_handler(signum, frame):
_shutdown_event.set() _shutdown_event.set()
def _email_poller(cfg): def _email_poller(cfg: Config):
logger.info("邮件轮询线程已启动") logger.info("邮件轮询线程已启动")
while _running: while _running:
try: try:
for acct_idx, mail in poll_accounts(cfg): for user in cfg.users:
if not _running: if not _running:
return return
uid_str = mail.uid.decode("utf-8", errors="replace") if isinstance(mail.uid, bytes) else str(mail.uid) for acct_idx, mail in poll_accounts(user):
if not is_uid_processed(uid_str): if not _running:
mark_fetched(uid_str, mail.account_email, mail.folder, return
mail.subject, mail.sender) uid_str = mail.uid.decode("utf-8", errors="replace") if isinstance(mail.uid, bytes) else str(mail.uid)
try: if not is_uid_processed(uid_str):
tg_msg_id = send_thinking(cfg.telegram, cfg.telegram.chat_id, mark_fetched(uid_str, mail.account_email, mail.folder,
f"📥 *获取邮件中…*\n{_mail_label(mail)}") mail.subject, mail.sender)
logger.info(" 获取邮件中消息已发送: msg_id=%d", tg_msg_id) try:
except Exception as e: tg_msg_id = send_thinking(cfg.bot_token, user.chat_id,
logger.error(" 获取邮件中消息发送失败: %s", e) f"📥 *获取邮件中…*\n{_mail_label(mail)}")
tg_msg_id = 0 logger.info(" [%s] 获取邮件中消息已发送: msg_id=%d", user.chat_id, tg_msg_id)
_email_queue.put((acct_idx, mail, tg_msg_id)) except Exception as e:
logger.error(" [%s] 获取邮件中消息发送失败: %s", user.chat_id, e)
tg_msg_id = 0
_email_queue.put((user, acct_idx, mail, tg_msg_id))
except Exception as e: except Exception as e:
logger.error(f"邮件轮询线程异常: {e}", exc_info=True) logger.error(f"邮件轮询线程异常: {e}", exc_info=True)
time.sleep(5) time.sleep(5)
if _running: if _running:
_shutdown_event.wait(cfg.polling.interval_seconds) _shutdown_event.wait(cfg.polling_interval)
def _ai_processor(cfg): def _ai_processor(cfg: Config):
logger.info("AI 处理线程已启动 (并行 workers=10)") logger.info("AI 处理线程已启动 (并行 workers=10)")
max_workers = 10 max_workers = 10
with ThreadPoolExecutor(max_workers=max_workers) as executor: with ThreadPoolExecutor(max_workers=max_workers) as executor:
@@ -78,21 +81,21 @@ def _ai_processor(cfg):
while _running: while _running:
while len(pending_futures) < max_workers: while len(pending_futures) < max_workers:
try: try:
acct_idx, mail, tg_msg_id = _email_queue.get_nowait() user, acct_idx, mail, tg_msg_id = _email_queue.get_nowait()
except queue_module.Empty: except queue_module.Empty:
break break
uid_str = mail.uid.decode("utf-8", errors="replace") if isinstance(mail.uid, bytes) else str(mail.uid) uid_str = mail.uid.decode("utf-8", errors="replace") if isinstance(mail.uid, bytes) else str(mail.uid)
mark_processing(uid_str) mark_processing(uid_str)
future = executor.submit(ai_process, cfg, acct_idx, mail, tg_msg_id) future = executor.submit(ai_process, cfg, user, acct_idx, mail, tg_msg_id)
pending_futures[future] = (acct_idx, mail) pending_futures[future] = (user, acct_idx, mail)
completed = [f for f in pending_futures if f.done()] completed = [f for f in pending_futures if f.done()]
for future in completed: for future in completed:
acct_idx, mail = pending_futures.pop(future) user, acct_idx, mail = pending_futures.pop(future)
try: try:
info = future.result() info = future.result()
if info: if info:
_tg_queue.put(info) _tg_queue.put((user, info))
except Exception as e: except Exception as e:
logger.error(f"AI 处理邮件失败: {e}", exc_info=True) logger.error(f"AI 处理邮件失败: {e}", exc_info=True)
uid_str = mail.uid.decode("utf-8", errors="replace") if isinstance(mail.uid, bytes) else str(mail.uid) uid_str = mail.uid.decode("utf-8", errors="replace") if isinstance(mail.uid, bytes) else str(mail.uid)
@@ -101,24 +104,24 @@ def _ai_processor(cfg):
time.sleep(0.1 if pending_futures else 0.5) time.sleep(0.1 if pending_futures else 0.5)
def _tg_poller(cfg): def _tg_poller(cfg: Config):
logger.info("TG 轮询线程已启动(按钮回调专用)") logger.info("TG 轮询线程已启动(按钮回调专用)")
last_update_id = 0 last_update_id = 0
while _running: while _running:
try: try:
last_update_id = poll_telegram(cfg.telegram, cfg, last_update_id) last_update_id = poll_telegram(cfg.bot_token, cfg, last_update_id)
except Exception as e: except Exception as e:
logger.error(f"TG 轮询错误: {e}", exc_info=True) logger.error(f"TG 轮询错误: {e}", exc_info=True)
if _running: if _running:
_shutdown_event.wait(0.05) _shutdown_event.wait(0.05)
def _tg_sender(cfg): def _tg_sender(cfg: Config):
logger.info("TG 发送线程已启动(邮件摘要专用)") logger.info("TG 发送线程已启动(邮件摘要专用)")
while _running: while _running:
try: try:
info = _tg_queue.get_nowait() user, info = _tg_queue.get_nowait()
tg_send_and_mark(cfg, info) tg_send_and_mark(cfg, user, info)
uid_str = info["uid"].decode("utf-8", errors="replace") if isinstance(info["uid"], bytes) else str(info["uid"]) uid_str = info["uid"].decode("utf-8", errors="replace") if isinstance(info["uid"], bytes) else str(info["uid"])
mark_sent(uid_str) mark_sent(uid_str)
except queue_module.Empty: except queue_module.Empty:
@@ -139,10 +142,13 @@ def main():
signal.signal(signal.SIGINT, _signal_handler) signal.signal(signal.SIGINT, _signal_handler)
signal.signal(signal.SIGTERM, _signal_handler) signal.signal(signal.SIGTERM, _signal_handler)
cfg_path = sys.argv[1] if len(sys.argv) > 1 else "config.yaml" cfg_path = sys.argv[1] if len(sys.argv) > 1 else "config.json"
cfg = load_config(cfg_path) cfg = load_config(cfg_path)
init_db() init_db()
logger.info(f"AI邮件摘要机器人已启动轮询间隔: {cfg.polling.interval_seconds}s")
user_count = len(cfg.users)
acct_count = sum(len(u.email_accounts) for u in cfg.users)
logger.info(f"AI邮件摘要机器人已启动: {user_count} 用户, {acct_count} 邮箱, 轮询间隔: {cfg.polling_interval}s")
threads = [ threads = [
threading.Thread(target=_email_poller, args=(cfg,), daemon=True), threading.Thread(target=_email_poller, args=(cfg,), daemon=True),

View File

@@ -1,25 +1,30 @@
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import List, Optional from typing import List, Optional
import yaml import json
@dataclass
class ImapConfig:
server: str
port: int = 993
ssl: bool = True
starttls: bool = False
@dataclass @dataclass
class SmtpConfig: class SmtpConfig:
server: str server: str
port: int port: int = 465
use_ssl: bool = True ssl: bool = True
use_starttls: bool = False starttls: bool = False
@dataclass @dataclass
class EmailAccount: class EmailAccount:
imap_server: str
imap_port: int
username: str username: str
password: str password: str
use_ssl: bool = True imap: ImapConfig
use_starttls: bool = False smtp: SmtpConfig
smtp: Optional[SmtpConfig] = None
@dataclass @dataclass
@@ -30,40 +35,50 @@ class AIConfig:
@dataclass @dataclass
class TelegramConfig: class UserConfig:
bot_token: str
chat_id: str chat_id: str
ai: AIConfig
email_accounts: List[EmailAccount] = field(default_factory=list)
@dataclass
class PollingConfig:
interval_seconds: int
@dataclass @dataclass
class Config: class Config:
email_accounts: List[EmailAccount] bot_token: str
ai: AIConfig polling_interval: int
telegram: TelegramConfig users: List[UserConfig] = field(default_factory=list)
polling: PollingConfig
def load_config(path: str) -> Config: def load_config(path: str) -> Config:
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
raw = yaml.safe_load(f) raw = json.load(f)
accounts = [] users = []
for a in raw["email_accounts"]: for u in raw.get("users", []):
if "smtp" in a and a["smtp"] is not None: email_accounts = []
a["smtp"] = SmtpConfig(**a["smtp"]) for a in u.get("email_accounts", []):
accounts.append(EmailAccount(**a)) email_accounts.append(EmailAccount(
ai = AIConfig(**raw["ai"]) username=a["username"],
tg = TelegramConfig(**raw["telegram"]) password=a["password"],
polling = PollingConfig(**raw["polling"]) imap=ImapConfig(**a.get("imap", {})),
smtp=SmtpConfig(**a.get("smtp", {})),
))
users.append(UserConfig(
chat_id=str(u["chat_id"]),
ai=AIConfig(**u["ai"]),
email_accounts=email_accounts,
))
return Config( return Config(
email_accounts=accounts, bot_token=raw["bot_token"],
ai=ai, polling_interval=raw.get("polling_interval", 30),
telegram=tg, users=users,
polling=polling,
) )
def find_user_by_chat_id(cfg: Config, chat_id: int | str) -> Optional[UserConfig]:
"""根据 chat_id 查找用户"""
cid = str(chat_id)
for user in cfg.users:
if user.chat_id == cid:
return user
return None

View File

@@ -2,7 +2,7 @@ import logging
import re import re
import threading import threading
from typing import Generator, Optional from typing import Generator, Optional
from src.config import Config from src.config import Config, UserConfig
from src.email_client import fetch_unseen_emails, mark_as_seen, Email from src.email_client import fetch_unseen_emails, mark_as_seen, Email
from src.ai_client import summarize_email from src.ai_client import summarize_email
from src.tg_bot import send_summary, send_thinking, edit_message, format_summary from src.tg_bot import send_summary, send_thinking, edit_message, format_summary
@@ -21,31 +21,32 @@ def _mail_label(mail: Email) -> str:
return f"{_escape_md(sender_short)} · {_escape_md(subject_short)}" return f"{_escape_md(sender_short)} · {_escape_md(subject_short)}"
def poll_accounts(cfg: Config, skip_uids: set[bytes] | None = None) -> Generator[tuple[int, Email], None, None]: def poll_accounts(user: UserConfig, skip_uids: set[bytes] | None = None) -> Generator[tuple[int, Email], None, None]:
for idx, acct in enumerate(cfg.email_accounts): """轮询指定用户的所有邮箱"""
for idx, acct in enumerate(user.email_accounts):
try: try:
logger.info(f"检查邮箱: {acct.username}") logger.info("检查邮箱: %s (user=%s)", acct.username, user.chat_id)
emails = fetch_unseen_emails(acct, skip_uids=skip_uids) emails = fetch_unseen_emails(acct, skip_uids=skip_uids)
if emails: if emails:
logger.info(f" 发现 {len(emails)} 封新邮件") logger.info(" 发现 %d 封新邮件", len(emails))
for mail in emails: for mail in emails:
yield idx, mail yield idx, mail
except Exception as e: except Exception as e:
logger.error(f"轮询 {acct.username} 失败: {e}", exc_info=True) logger.error("轮询 %s 失败: %s", acct.username, e, exc_info=True)
def ai_process(cfg: Config, acct_idx: int, mail: Email, tg_msg_id: int = 0) -> Optional[dict]: def ai_process(cfg: Config, user: UserConfig, acct_idx: int, mail: Email, tg_msg_id: int = 0) -> Optional[dict]:
logger.info(f" 正在摘要: {mail.subject}") logger.info(" [%s] 正在摘要: %s", user.chat_id, mail.subject)
label = _mail_label(mail) label = _mail_label(mail)
# 阶段1显示等待处理 # 阶段1显示等待处理
if tg_msg_id: if tg_msg_id:
edit_message(cfg.telegram, cfg.telegram.chat_id, tg_msg_id, f"⏳ *等待处理…*\n{label}") edit_message(cfg.bot_token, user.chat_id, tg_msg_id, f"⏳ *等待处理…*\n{label}")
else: else:
tg_msg_id = send_thinking(cfg.telegram, cfg.telegram.chat_id, f"⏳ *等待处理…*\n{label}") tg_msg_id = send_thinking(cfg.bot_token, user.chat_id, f"⏳ *等待处理…*\n{label}")
# 阶段2AI 处理,更新为思考中 # 阶段2AI 处理,更新为思考中
edit_message(cfg.telegram, cfg.telegram.chat_id, tg_msg_id, f"🤖 *AI思考中…*\n{label}") edit_message(cfg.bot_token, user.chat_id, tg_msg_id, f"🤖 *AI思考中…*\n{label}")
summary = summarize_email(cfg.ai, mail.recipient, mail.subject, mail.sender, mail.body, mail.account_email) summary = summarize_email(user.ai, mail.recipient, mail.subject, mail.sender, mail.body, mail.account_email)
summary["recipient"] = mail.recipient summary["recipient"] = mail.recipient
if "@" not in summary.get("sender", ""): if "@" not in summary.get("sender", ""):
summary["sender"] = mail.sender summary["sender"] = mail.sender
@@ -66,19 +67,18 @@ def ai_process(cfg: Config, acct_idx: int, mail: Email, tg_msg_id: int = 0) -> O
} }
def tg_send_and_mark(cfg: Config, info: dict): def tg_send_and_mark(cfg: Config, user: UserConfig, info: dict):
acct = cfg.email_accounts[info["acct_idx"]] # 找到用户对应的邮箱账户
acct = user.email_accounts[info["acct_idx"]]
thinking_msg_id = info.get("thinking_msg_id") thinking_msg_id = info.get("thinking_msg_id")
if thinking_msg_id: if thinking_msg_id:
# 编辑思考中消息为实际摘要
from src.tg_bot import _summary_keyboard from src.tg_bot import _summary_keyboard
from src.database import save_email_context from src.database import save_email_context
can_reply = info["data"].get("can_reply", True) can_reply = info["data"].get("can_reply", True)
is_promotion = info["data"].get("category") == "promotion" is_promotion = info["data"].get("category") == "promotion"
links = info["data"].get("links", []) links = info["data"].get("links", [])
edit_message(cfg.telegram, cfg.telegram.chat_id, thinking_msg_id, edit_message(cfg.bot_token, user.chat_id, thinking_msg_id,
info["text"], _summary_keyboard(can_reply, is_promotion, links)) info["text"], _summary_keyboard(can_reply, is_promotion, links))
# 保存上下文用于回调
save_email_context(thinking_msg_id, { save_email_context(thinking_msg_id, {
"summary_text": info["text"], "summary_text": info["text"],
"summary_data": info["data"], "summary_data": info["data"],
@@ -94,19 +94,18 @@ def tg_send_and_mark(cfg: Config, info: dict):
"can_reply": can_reply, "can_reply": can_reply,
}) })
else: else:
# 兜底:没有思考中消息就正常发送 send_summary(cfg.bot_token, user.chat_id,
send_summary(cfg.telegram, cfg.telegram.chat_id,
info["text"], info["data"], info["text"], info["data"],
info["original_body"], info["acct_idx"], info["original_body"], info["acct_idx"],
original_sender=info.get("original_sender", ""), original_sender=info.get("original_sender", ""),
original_recipient=info.get("original_recipient", ""), original_recipient=info.get("original_recipient", ""),
original_reply_to=info.get("original_reply_to", ""), original_reply_to=info.get("original_reply_to", ""),
account_email=info.get("account_email", "")) account_email=info.get("account_email", ""))
# 标记已读放到后台线程,不阻塞 TG 发送 # 标记已读放到后台线程
def _bg_mark(): def _bg_mark():
try: try:
mark_as_seen(acct, [(info["uid"], info.get("folder", "INBOX"))]) mark_as_seen(acct, [(info["uid"], info.get("folder", "INBOX"))])
except Exception: except Exception:
pass pass
threading.Thread(target=_bg_mark, daemon=True).start() threading.Thread(target=_bg_mark, daemon=True).start()
logger.info(f" TG 发送成功,标记已读已提交后台") logger.info(" [%s] TG 发送成功,标记已读已提交后台", user.chat_id)

View File

@@ -4,7 +4,7 @@ import threading
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from email.utils import parseaddr from email.utils import parseaddr
import requests import requests
from src.config import TelegramConfig, Config from src.config import Config, UserConfig, find_user_by_chat_id
from src.ai_client import generate_more_replies, expand_promo_detail from src.ai_client import generate_more_replies, expand_promo_detail
from src.smtp_client import send_reply from src.smtp_client import send_reply
from src.retry import retry from src.retry import retry
@@ -18,14 +18,14 @@ _ai_pool = ThreadPoolExecutor(max_workers=3, thread_name_prefix="ai_regen")
# ── Public API ────────────────────────────────────────── # ── Public API ──────────────────────────────────────────
@retry() @retry()
def send_summary(tg_cfg: TelegramConfig, chat_id: str, summary_text: str, def send_summary(bot_token: str, chat_id: str, summary_text: str,
summary_data: dict, original_body: str, account_idx: int, summary_data: dict, original_body: str, account_idx: int,
original_sender: str = "", original_recipient: str = "", original_sender: str = "", original_recipient: str = "",
original_reply_to: str = "", account_email: str = "") -> int: original_reply_to: str = "", account_email: str = "") -> int:
can_reply = summary_data.get("can_reply", True) can_reply = summary_data.get("can_reply", True)
is_promotion = summary_data.get("category") == "promotion" is_promotion = summary_data.get("category") == "promotion"
links = summary_data.get("links", []) links = summary_data.get("links", [])
result = _tg_req(tg_cfg, "sendMessage", { result = _tg_req(bot_token, "sendMessage", {
"chat_id": chat_id, "chat_id": chat_id,
"text": summary_text, "text": summary_text,
"parse_mode": "MarkdownV2", "parse_mode": "MarkdownV2",
@@ -49,24 +49,24 @@ def send_summary(tg_cfg: TelegramConfig, chat_id: str, summary_text: str,
@retry() @retry()
def send_text(tg_cfg: TelegramConfig, chat_id: str, text: str, def send_text(bot_token: str, chat_id: str, text: str,
reply_markup: dict = None) -> int: reply_markup: dict = None) -> int:
payload = {"chat_id": chat_id, "text": text} payload = {"chat_id": chat_id, "text": text}
if reply_markup: if reply_markup:
payload["reply_markup"] = reply_markup payload["reply_markup"] = reply_markup
result = _tg_req(tg_cfg, "sendMessage", payload) result = _tg_req(bot_token, "sendMessage", payload)
return result["result"]["message_id"] return result["result"]["message_id"]
def send_thinking(tg_cfg: TelegramConfig, chat_id: str, text: str = "💭 思考中…") -> int: def send_thinking(bot_token: str, chat_id: str, text: str = "💭 思考中…") -> int:
result = _tg_req(tg_cfg, "sendMessage", { result = _tg_req(bot_token, "sendMessage", {
"chat_id": chat_id, "chat_id": chat_id,
"text": text, "text": text,
}) })
return result["result"]["message_id"] return result["result"]["message_id"]
def edit_message(tg_cfg: TelegramConfig, chat_id: str, msg_id: int, def edit_message(bot_token: str, chat_id: str, msg_id: int,
text: str, reply_markup: dict = None): text: str, reply_markup: dict = None):
payload = { payload = {
"chat_id": chat_id, "message_id": msg_id, "chat_id": chat_id, "message_id": msg_id,
@@ -74,22 +74,22 @@ def edit_message(tg_cfg: TelegramConfig, chat_id: str, msg_id: int,
} }
if reply_markup: if reply_markup:
payload["reply_markup"] = reply_markup payload["reply_markup"] = reply_markup
_tg_req(tg_cfg, "editMessageText", payload) _tg_req(bot_token, "editMessageText", payload)
def delete_message(tg_cfg: TelegramConfig, chat_id: str, msg_id: int): def delete_message(bot_token: str, chat_id: str, msg_id: int):
try: try:
_tg_req(tg_cfg, "deleteMessage", { _tg_req(bot_token, "deleteMessage", {
"chat_id": chat_id, "message_id": msg_id, "chat_id": chat_id, "message_id": msg_id,
}) })
except Exception: except Exception:
pass pass
def poll_telegram(tg_cfg: TelegramConfig, cfg: Config, last_update_id: int) -> int: def poll_telegram(bot_token: str, cfg: Config, last_update_id: int) -> int:
try: try:
resp = requests.get( resp = requests.get(
f"https://api.telegram.org/bot{tg_cfg.bot_token}/getUpdates", f"https://api.telegram.org/bot{bot_token}/getUpdates",
params={"offset": last_update_id, "timeout": 1}, params={"offset": last_update_id, "timeout": 1},
timeout=10, timeout=10,
) )
@@ -100,7 +100,7 @@ def poll_telegram(tg_cfg: TelegramConfig, cfg: Config, last_update_id: int) -> i
for update in data.get("result", []): for update in data.get("result", []):
uid = update["update_id"] uid = update["update_id"]
try: try:
_handle_update(tg_cfg, cfg, update) _handle_update(bot_token, cfg, update)
except Exception as e: except Exception as e:
logger.warning("处理 update %d 失败: %s", uid, e) logger.warning("处理 update %d 失败: %s", uid, e)
last_update_id = uid + 1 last_update_id = uid + 1
@@ -247,8 +247,8 @@ def _ai_reply_keyboard(msg_id: int, suggestions: list) -> dict:
# ── Telegram API ──────────────────────────────────────── # ── Telegram API ────────────────────────────────────────
def _tg_req(tg_cfg: TelegramConfig, method: str, payload: dict) -> dict: def _tg_req(bot_token: str, method: str, payload: dict) -> dict:
url = f"https://api.telegram.org/bot{tg_cfg.bot_token}/{method}" url = f"https://api.telegram.org/bot{bot_token}/{method}"
logger.info("Telegram API %s: %s", method, {k: (v if k != "reply_markup" else "...") for k, v in payload.items()}) logger.info("Telegram API %s: %s", method, {k: (v if k != "reply_markup" else "...") for k, v in payload.items()})
r = requests.post(url, json=payload, timeout=30) r = requests.post(url, json=payload, timeout=30)
if not r.ok: if not r.ok:
@@ -262,35 +262,41 @@ def _tg_req(tg_cfg: TelegramConfig, method: str, payload: dict) -> dict:
# ── Update handling ───────────────────────────────────── # ── Update handling ─────────────────────────────────────
def _do_regen(cfg: Config, chat_id: int, msg_id: int, ctx: dict, conv: dict): def _do_regen(bot_token: str, cfg: Config, chat_id: int, msg_id: int, ctx: dict, conv: dict):
"""在 AI 线程池中执行换一批,不阻塞按钮轮询""" """在 AI 线程池中执行换一批,不阻塞按钮轮询"""
user = find_user_by_chat_id(cfg, chat_id)
if not user:
return
style = conv.get("reply_style", "short") style = conv.get("reply_style", "short")
try: try:
new_suggestions = generate_more_replies( new_suggestions = generate_more_replies(
cfg.ai, ctx["sender"], ctx["subject"], ctx["original_body"], user.ai, ctx["sender"], ctx["subject"], ctx["original_body"],
conv.get("all_suggestions", []), style=style, conv.get("all_suggestions", []), style=style,
) )
except Exception as e: except Exception as e:
send_text(cfg.telegram, str(chat_id), f"生成失败: {e}") send_text(bot_token, str(chat_id), f"生成失败: {e}")
return return
if new_suggestions: if new_suggestions:
conv["ai_suggestions"] = new_suggestions conv["ai_suggestions"] = new_suggestions
conv["all_suggestions"].extend(new_suggestions) conv["all_suggestions"].extend(new_suggestions)
save_conversation(chat_id, conv) save_conversation(chat_id, conv)
logger.info("[ai_regen] 新生成 %d 条回复 (style=%s)", len(new_suggestions), style) logger.info("[ai_regen] 新生成 %d 条回复 (style=%s)", len(new_suggestions), style)
_show_ai_suggestions(cfg.telegram, chat_id, msg_id, new_suggestions) _show_ai_suggestions(bot_token, chat_id, msg_id, new_suggestions)
def _do_ai_hint(cfg: Config, chat_id: int, conv: dict, ctx: dict, hint: str): def _do_ai_hint(bot_token: str, cfg: Config, chat_id: int, conv: dict, ctx: dict, hint: str):
"""在 AI 线程池中根据用户提示生成回复""" """在 AI 线程池中根据用户提示生成回复"""
user = find_user_by_chat_id(cfg, chat_id)
if not user:
return
style = conv.get("reply_style", "short") style = conv.get("reply_style", "short")
try: try:
new_suggestions = generate_more_replies( new_suggestions = generate_more_replies(
cfg.ai, ctx["sender"], ctx["subject"], ctx["original_body"], user.ai, ctx["sender"], ctx["subject"], ctx["original_body"],
conv.get("all_suggestions", []), user_hint=hint, style=style, conv.get("all_suggestions", []), user_hint=hint, style=style,
) )
except Exception as e: except Exception as e:
send_text(cfg.telegram, str(chat_id), f"生成失败: {e}") send_text(bot_token, str(chat_id), f"生成失败: {e}")
delete_conversation(chat_id) delete_conversation(chat_id)
return return
if new_suggestions: if new_suggestions:
@@ -300,24 +306,24 @@ def _do_ai_hint(cfg: Config, chat_id: int, conv: dict, ctx: dict, hint: str):
conv.pop("prompt_msg_id", None) conv.pop("prompt_msg_id", None)
save_conversation(chat_id, conv) save_conversation(chat_id, conv)
logger.info("[ai_hint] 根据提示生成 %d 条新回复", len(new_suggestions)) logger.info("[ai_hint] 根据提示生成 %d 条新回复", len(new_suggestions))
_show_ai_suggestions(cfg.telegram, chat_id, conv["summary_msg_id"], new_suggestions) _show_ai_suggestions(bot_token, chat_id, conv["summary_msg_id"], new_suggestions)
def _handle_update(tg_cfg: TelegramConfig, cfg: Config, update: dict): def _handle_update(bot_token: str, cfg: Config, update: dict):
if "callback_query" in update: if "callback_query" in update:
_handle_callback(tg_cfg, cfg, update["callback_query"]) _handle_callback(bot_token, cfg, update["callback_query"])
elif "message" in update: elif "message" in update:
_handle_message(tg_cfg, cfg, update["message"]) _handle_message(bot_token, cfg, update["message"])
def _handle_callback(tg_cfg: TelegramConfig, cfg: Config, cb: dict): def _handle_callback(bot_token: str, cfg: Config, cb: dict):
chat_id = cb["message"]["chat"]["id"] chat_id = cb["message"]["chat"]["id"]
msg_id = cb["message"]["message_id"] msg_id = cb["message"]["message_id"]
data = cb["data"] data = cb["data"]
cb_id = cb["id"] cb_id = cb["id"]
try: try:
_tg_req(tg_cfg, "answerCallbackQuery", {"callback_query_id": cb_id}) _tg_req(bot_token, "answerCallbackQuery", {"callback_query_id": cb_id})
except Exception: except Exception:
pass pass
@@ -331,7 +337,7 @@ def _handle_callback(tg_cfg: TelegramConfig, cfg: Config, cb: dict):
if action == CALLBACK_VIEW_ORIG and ctx: if action == CALLBACK_VIEW_ORIG and ctx:
can_reply = ctx.get("can_reply", True) can_reply = ctx.get("can_reply", True)
text = f"*📄 原文 \\- {_escape(ctx['subject'])}*\n\n{_escape(ctx.get('plain_text', ctx['original_body']))}" text = f"*📄 原文 \\- {_escape(ctx['subject'])}*\n\n{_escape(ctx.get('plain_text', ctx['original_body']))}"
_tg_req(tg_cfg, "editMessageText", { _tg_req(bot_token, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id, "chat_id": chat_id, "message_id": msg_id,
"text": text, "parse_mode": "MarkdownV2", "text": text, "parse_mode": "MarkdownV2",
"reply_markup": _orig_keyboard(can_reply), "reply_markup": _orig_keyboard(can_reply),
@@ -342,7 +348,7 @@ def _handle_callback(tg_cfg: TelegramConfig, cfg: Config, cb: dict):
can_reply = ctx.get("can_reply", True) can_reply = ctx.get("can_reply", True)
is_promotion = ctx.get("summary_data", {}).get("category") == "promotion" is_promotion = ctx.get("summary_data", {}).get("category") == "promotion"
links = ctx.get("summary_data", {}).get("links", []) links = ctx.get("summary_data", {}).get("links", [])
_tg_req(tg_cfg, "editMessageText", { _tg_req(bot_token, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id, "chat_id": chat_id, "message_id": msg_id,
"text": ctx["summary_text"], "parse_mode": "MarkdownV2", "text": ctx["summary_text"], "parse_mode": "MarkdownV2",
"reply_markup": _summary_keyboard(can_reply, is_promotion, links), "reply_markup": _summary_keyboard(can_reply, is_promotion, links),
@@ -351,7 +357,7 @@ def _handle_callback(tg_cfg: TelegramConfig, cfg: Config, cb: dict):
elif action == CALLBACK_REPLY and ctx: elif action == CALLBACK_REPLY and ctx:
prompt_msg_id = send_text( prompt_msg_id = send_text(
tg_cfg, str(chat_id), "请输入你的回复内容:", bot_token, str(chat_id), "请输入你的回复内容:",
reply_markup=_cancel_keyboard(), reply_markup=_cancel_keyboard(),
) )
save_conversation(chat_id, { save_conversation(chat_id, {
@@ -364,7 +370,7 @@ def _handle_callback(tg_cfg: TelegramConfig, cfg: Config, cb: dict):
style = "full" if action == CALLBACK_AI_REPLY_FULL else "short" style = "full" if action == CALLBACK_AI_REPLY_FULL else "short"
# 总是用对应风格重新生成,不使用摘要时的通用缓存 # 总是用对应风格重新生成,不使用摘要时的通用缓存
logger.info(" 重新生成 AI 回复 (style=%s)", style) logger.info(" 重新生成 AI 回复 (style=%s)", style)
_tg_req(tg_cfg, "editMessageText", { _tg_req(bot_token, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id, "chat_id": chat_id, "message_id": msg_id,
"text": "🤖 *AI思考中…*", "parse_mode": "MarkdownV2", "text": "🤖 *AI思考中…*", "parse_mode": "MarkdownV2",
}) })
@@ -374,7 +380,7 @@ def _handle_callback(tg_cfg: TelegramConfig, cfg: Config, cb: dict):
conv["reply_style"] = style conv["reply_style"] = style
conv["all_suggestions"] = [] conv["all_suggestions"] = []
save_conversation(chat_id, conv) save_conversation(chat_id, conv)
_ai_pool.submit(_do_regen, cfg, chat_id, msg_id, ctx, conv) _ai_pool.submit(_do_regen, bot_token, cfg, chat_id, msg_id, ctx, conv)
elif action == CALLBACK_SEND_REPLY and ctx: elif action == CALLBACK_SEND_REPLY and ctx:
idx = int(parts[2]) idx = int(parts[2])
@@ -386,13 +392,13 @@ def _handle_callback(tg_cfg: TelegramConfig, cfg: Config, cb: dict):
return return
sel_text = suggestions[idx]["text"] sel_text = suggestions[idx]["text"]
logger.info(" 直接发送回复 #%d: %s", idx, sel_text[:60]) logger.info(" 直接发送回复 #%d: %s", idx, sel_text[:60])
_do_send_reply(tg_cfg, cfg, chat_id, msg_id, ctx, sel_text) _do_send_reply(bot_token, cfg, chat_id, msg_id, ctx, sel_text)
send_text(tg_cfg, str(chat_id), f"✅ 回复已发送") send_text(bot_token, str(chat_id), f"✅ 回复已发送")
delete_conversation(chat_id) delete_conversation(chat_id)
can_reply = ctx.get("can_reply", True) can_reply = ctx.get("can_reply", True)
is_promotion = ctx.get("summary_data", {}).get("category") == "promotion" is_promotion = ctx.get("summary_data", {}).get("category") == "promotion"
links = ctx.get("summary_data", {}).get("links", []) links = ctx.get("summary_data", {}).get("links", [])
_tg_req(tg_cfg, "editMessageText", { _tg_req(bot_token, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id, "chat_id": chat_id, "message_id": msg_id,
"text": ctx["summary_text"], "parse_mode": "MarkdownV2", "text": ctx["summary_text"], "parse_mode": "MarkdownV2",
"reply_markup": _summary_keyboard(can_reply, is_promotion, links), "reply_markup": _summary_keyboard(can_reply, is_promotion, links),
@@ -412,7 +418,7 @@ def _handle_callback(tg_cfg: TelegramConfig, cfg: Config, cb: dict):
# 用代码块显示点击可复制MarkdownV2 # 用代码块显示点击可复制MarkdownV2
escaped = sel_text.replace("\\", "\\\\").replace("`", "\\`") escaped = sel_text.replace("\\", "\\\\").replace("`", "\\`")
_tg_req(tg_cfg, "sendMessage", { _tg_req(bot_token, "sendMessage", {
"chat_id": str(chat_id), "chat_id": str(chat_id),
"text": f"*建议回复(点击代码块复制):*\n`{escaped}`", "text": f"*建议回复(点击代码块复制):*\n`{escaped}`",
"parse_mode": "MarkdownV2", "parse_mode": "MarkdownV2",
@@ -420,7 +426,7 @@ def _handle_callback(tg_cfg: TelegramConfig, cfg: Config, cb: dict):
# 进入 awaiting_reply 状态,用户可编辑后发送 # 进入 awaiting_reply 状态,用户可编辑后发送
prompt_msg_id = send_text( prompt_msg_id = send_text(
tg_cfg, str(chat_id), bot_token, str(chat_id),
"请编辑后发送(或直接回复附件):", "请编辑后发送(或直接回复附件):",
reply_markup=_cancel_keyboard(), reply_markup=_cancel_keyboard(),
) )
@@ -437,16 +443,16 @@ def _handle_callback(tg_cfg: TelegramConfig, cfg: Config, cb: dict):
return return
logger.info(" 换一批 (已有 %d 条历史)", len(conv.get("all_suggestions", []))) logger.info(" 换一批 (已有 %d 条历史)", len(conv.get("all_suggestions", [])))
# AI 处理中 # AI 处理中
_tg_req(tg_cfg, "editMessageText", { _tg_req(bot_token, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id, "chat_id": chat_id, "message_id": msg_id,
"text": "🤖 *AI思考中…*", "parse_mode": "MarkdownV2", "text": "🤖 *AI思考中…*", "parse_mode": "MarkdownV2",
}) })
# 丢到 AI 线程池处理,不阻塞按钮轮询 # 丢到 AI 线程池处理,不阻塞按钮轮询
_ai_pool.submit(_do_regen, cfg, chat_id, msg_id, ctx, conv) _ai_pool.submit(_do_regen, bot_token, cfg, chat_id, msg_id, ctx, conv)
elif action == CALLBACK_HINT and ctx: elif action == CALLBACK_HINT and ctx:
prompt_msg_id = send_text( prompt_msg_id = send_text(
tg_cfg, str(chat_id), "请输入你对回复的提示/要求:", bot_token, str(chat_id), "请输入你对回复的提示/要求:",
reply_markup=_cancel_keyboard(), reply_markup=_cancel_keyboard(),
) )
conv = load_conversation(chat_id) conv = load_conversation(chat_id)
@@ -465,18 +471,18 @@ def _handle_callback(tg_cfg: TelegramConfig, cfg: Config, cb: dict):
can_reply = ctx.get("can_reply", True) can_reply = ctx.get("can_reply", True)
is_promotion = ctx.get("summary_data", {}).get("category") == "promotion" is_promotion = ctx.get("summary_data", {}).get("category") == "promotion"
links = ctx.get("summary_data", {}).get("links", []) links = ctx.get("summary_data", {}).get("links", [])
_tg_req(tg_cfg, "editMessageText", { _tg_req(bot_token, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id, "chat_id": chat_id, "message_id": msg_id,
"text": ctx["summary_text"], "parse_mode": "MarkdownV2", "text": ctx["summary_text"], "parse_mode": "MarkdownV2",
"reply_markup": _summary_keyboard(can_reply, is_promotion, links), "reply_markup": _summary_keyboard(can_reply, is_promotion, links),
}) })
logger.info(" 取消, 恢复摘要视图") logger.info(" 取消, 恢复摘要视图")
else: else:
delete_message(tg_cfg, str(chat_id), msg_id) delete_message(bot_token, str(chat_id), msg_id)
logger.info(" 取消, 删除提示消息") logger.info(" 取消, 删除提示消息")
elif action == CALLBACK_PROMO_DETAIL and ctx: elif action == CALLBACK_PROMO_DETAIL and ctx:
_tg_req(tg_cfg, "editMessageText", { _tg_req(bot_token, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id, "chat_id": chat_id, "message_id": msg_id,
"text": "🤖 *AI思考中…*", "parse_mode": "MarkdownV2", "text": "🤖 *AI思考中…*", "parse_mode": "MarkdownV2",
}) })
@@ -485,7 +491,7 @@ def _handle_callback(tg_cfg: TelegramConfig, cfg: Config, cb: dict):
cfg.ai, ctx["sender"], ctx["subject"], ctx["original_body"], cfg.ai, ctx["sender"], ctx["subject"], ctx["original_body"],
) )
except Exception as e: except Exception as e:
send_text(tg_cfg, str(chat_id), f"获取详情失败: {e}") send_text(bot_token, str(chat_id), f"获取详情失败: {e}")
return return
summary = ctx["summary_data"].get("summary", "") summary = ctx["summary_data"].get("summary", "")
detail_lines = "\n".join(f"\\- {_escape(d)}" for d in details) if details else "暂无更多详情" detail_lines = "\n".join(f"\\- {_escape(d)}" for d in details) if details else "暂无更多详情"
@@ -496,7 +502,7 @@ def _handle_callback(tg_cfg: TelegramConfig, cfg: Config, cb: dict):
f"\n{_escape(summary)}\n" f"\n{_escape(summary)}\n"
f"\n*📋 详情:*\n{detail_lines}" f"\n*📋 详情:*\n{detail_lines}"
) )
_tg_req(tg_cfg, "editMessageText", { _tg_req(bot_token, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id, "chat_id": chat_id, "message_id": msg_id,
"text": expanded, "parse_mode": "MarkdownV2", "text": expanded, "parse_mode": "MarkdownV2",
"reply_markup": {"inline_keyboard": [ "reply_markup": {"inline_keyboard": [
@@ -507,7 +513,7 @@ def _handle_callback(tg_cfg: TelegramConfig, cfg: Config, cb: dict):
logger.info(" 显示推广详情") logger.info(" 显示推广详情")
def _handle_message(tg_cfg: TelegramConfig, cfg: Config, msg: dict): def _handle_message(bot_token: str, cfg: Config, msg: dict):
chat_id = msg["chat"]["id"] chat_id = msg["chat"]["id"]
text = msg.get("text") or msg.get("caption") or "" text = msg.get("text") or msg.get("caption") or ""
text = text.strip() text = text.strip()
@@ -528,24 +534,24 @@ def _handle_message(tg_cfg: TelegramConfig, cfg: Config, msg: dict):
ctx = load_email_context(conv["summary_msg_id"]) ctx = load_email_context(conv["summary_msg_id"])
if not ctx: if not ctx:
delete_conversation(chat_id) delete_conversation(chat_id)
delete_message(tg_cfg, chat_id_str, user_msg_id) delete_message(bot_token, chat_id_str, user_msg_id)
if prompt_msg_id: if prompt_msg_id:
delete_message(tg_cfg, chat_id_str, prompt_msg_id) delete_message(bot_token, chat_id_str, prompt_msg_id)
logger.info(" 上下文已丢失,清理") logger.info(" 上下文已丢失,清理")
return return
delete_message(tg_cfg, chat_id_str, user_msg_id) delete_message(bot_token, chat_id_str, user_msg_id)
if prompt_msg_id: if prompt_msg_id:
delete_message(tg_cfg, chat_id_str, prompt_msg_id) delete_message(bot_token, chat_id_str, prompt_msg_id)
# 合并累积的附件 # 合并累积的附件
all_attachments = conv.get("pending_attachments", []) all_attachments = conv.get("pending_attachments", [])
all_attachments.extend(media) all_attachments.extend(media)
_do_send_reply(tg_cfg, cfg, chat_id, conv["summary_msg_id"], ctx, text, _do_send_reply(bot_token, cfg, chat_id, conv["summary_msg_id"], ctx, text,
attachments=all_attachments or None) attachments=all_attachments or None)
attach_info = f" (+{len(all_attachments)}个附件)" if all_attachments else "" attach_info = f" (+{len(all_attachments)}个附件)" if all_attachments else ""
send_text(tg_cfg, chat_id_str, f"✅ 回复已发送{attach_info}") send_text(bot_token, chat_id_str, f"✅ 回复已发送{attach_info}")
delete_conversation(chat_id) delete_conversation(chat_id)
logger.info(" 回复发送完成") logger.info(" 回复发送完成")
@@ -553,50 +559,50 @@ def _handle_message(tg_cfg: TelegramConfig, cfg: Config, msg: dict):
ctx = load_email_context(conv["summary_msg_id"]) ctx = load_email_context(conv["summary_msg_id"])
if not ctx: if not ctx:
delete_conversation(chat_id) delete_conversation(chat_id)
delete_message(tg_cfg, chat_id_str, user_msg_id) delete_message(bot_token, chat_id_str, user_msg_id)
if prompt_msg_id: if prompt_msg_id:
delete_message(tg_cfg, chat_id_str, prompt_msg_id) delete_message(bot_token, chat_id_str, prompt_msg_id)
return return
delete_message(tg_cfg, chat_id_str, user_msg_id) delete_message(bot_token, chat_id_str, user_msg_id)
if prompt_msg_id: if prompt_msg_id:
delete_message(tg_cfg, chat_id_str, prompt_msg_id) delete_message(bot_token, chat_id_str, prompt_msg_id)
# 更新消息为 AI 思考中 # 更新消息为 AI 思考中
_tg_req(tg_cfg, "editMessageText", { _tg_req(bot_token, "editMessageText", {
"chat_id": chat_id, "message_id": conv["summary_msg_id"], "chat_id": chat_id, "message_id": conv["summary_msg_id"],
"text": "🤖 *AI思考中…*", "parse_mode": "MarkdownV2", "text": "🤖 *AI思考中…*", "parse_mode": "MarkdownV2",
}) })
# 丢到 AI 线程池处理 # 丢到 AI 线程池处理
_ai_pool.submit(_do_ai_hint, cfg, chat_id, conv, ctx, text) _ai_pool.submit(_do_ai_hint, bot_token, cfg, chat_id, conv, ctx, text)
def _show_ai_suggestions(tg_cfg: TelegramConfig, chat_id: int, msg_id: int, def _show_ai_suggestions(bot_token: str, chat_id: int, msg_id: int,
suggestions: list): suggestions: list):
text = "*AI 建议回复,请选择:*\n\n" text = "*AI 建议回复,请选择:*\n\n"
for i, s in enumerate(suggestions, 1): for i, s in enumerate(suggestions, 1):
text += f"{i}\\. {_escape(s['text'])}\n" text += f"{i}\\. {_escape(s['text'])}\n"
_tg_req(tg_cfg, "editMessageText", { _tg_req(bot_token, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id, "chat_id": chat_id, "message_id": msg_id,
"text": text, "parse_mode": "MarkdownV2", "text": text, "parse_mode": "MarkdownV2",
"reply_markup": _ai_reply_keyboard(msg_id, suggestions), "reply_markup": _ai_reply_keyboard(msg_id, suggestions),
}) })
def _download_tg_file(tg_cfg: TelegramConfig, file_id: str, filename: str) -> str: def _download_tg_file(bot_token: str, file_id: str, filename: str) -> str:
"""从 Telegram 下载文件到本地 media/ 目录,返回本地路径""" """从 Telegram 下载文件到本地 media/ 目录,返回本地路径"""
import os import os
from pathlib import Path from pathlib import Path
media_dir = Path("media") media_dir = Path("media")
media_dir.mkdir(exist_ok=True) media_dir.mkdir(exist_ok=True)
resp = requests.get( resp = requests.get(
f"https://api.telegram.org/bot{tg_cfg.bot_token}/getFile", f"https://api.telegram.org/bot{bot_token}/getFile",
params={"file_id": file_id}, timeout=30, params={"file_id": file_id}, timeout=30,
) )
resp.raise_for_status() resp.raise_for_status()
file_path = resp.json()["result"]["file_path"] file_path = resp.json()["result"]["file_path"]
dl = requests.get( dl = requests.get(
f"https://api.telegram.org/file/bot{tg_cfg.bot_token}/{file_path}", f"https://api.telegram.org/file/bot{bot_token}/{file_path}",
timeout=60, timeout=60,
) )
dl.raise_for_status() dl.raise_for_status()
@@ -650,12 +656,12 @@ def _extract_media(msg: dict) -> list[dict]:
return attachments return attachments
def _do_send_reply(tg_cfg: TelegramConfig, cfg: Config, def _do_send_reply(bot_token: str, cfg: Config,
chat_id: int, msg_id: int, ctx: dict, reply_text: str, chat_id: int, msg_id: int, ctx: dict, reply_text: str,
attachments: list[dict] | None = None): attachments: list[dict] | None = None):
acct = cfg.email_accounts[ctx["account_idx"]] acct = cfg.email_accounts[ctx["account_idx"]]
if not acct.smtp: if not acct.smtp:
send_text(tg_cfg, str(chat_id), "❌ 该邮箱未配置 SMTP无法发送回复。") send_text(bot_token, str(chat_id), "❌ 该邮箱未配置 SMTP无法发送回复。")
return return
to_addr = parseaddr(ctx.get("original_reply_to", ""))[1] to_addr = parseaddr(ctx.get("original_reply_to", ""))[1]
if not to_addr: if not to_addr:
@@ -671,7 +677,7 @@ def _do_send_reply(tg_cfg: TelegramConfig, cfg: Config,
if attachments: if attachments:
for att in attachments: for att in attachments:
try: try:
path = _download_tg_file(tg_cfg, att["file_id"], att["filename"]) path = _download_tg_file(bot_token, att["file_id"], att["filename"])
local_files.append((path, att["mime_type"])) local_files.append((path, att["mime_type"]))
except Exception as e: except Exception as e:
logger.error(" 附件下载失败: %s - %s", att["filename"], e) logger.error(" 附件下载失败: %s - %s", att["filename"], e)