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:
32
config.example.json
Normal file
32
config.example.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
54
main.py
54
main.py
@@ -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,11 +44,14 @@ 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:
|
||||||
|
return
|
||||||
|
for acct_idx, mail in poll_accounts(user):
|
||||||
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)
|
uid_str = mail.uid.decode("utf-8", errors="replace") if isinstance(mail.uid, bytes) else str(mail.uid)
|
||||||
@@ -56,21 +59,21 @@ def _email_poller(cfg):
|
|||||||
mark_fetched(uid_str, mail.account_email, mail.folder,
|
mark_fetched(uid_str, mail.account_email, mail.folder,
|
||||||
mail.subject, mail.sender)
|
mail.subject, mail.sender)
|
||||||
try:
|
try:
|
||||||
tg_msg_id = send_thinking(cfg.telegram, cfg.telegram.chat_id,
|
tg_msg_id = send_thinking(cfg.bot_token, user.chat_id,
|
||||||
f"📥 *获取邮件中…*\n{_mail_label(mail)}")
|
f"📥 *获取邮件中…*\n{_mail_label(mail)}")
|
||||||
logger.info(" 获取邮件中消息已发送: msg_id=%d", tg_msg_id)
|
logger.info(" [%s] 获取邮件中消息已发送: msg_id=%d", user.chat_id, tg_msg_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(" 获取邮件中消息发送失败: %s", e)
|
logger.error(" [%s] 获取邮件中消息发送失败: %s", user.chat_id, e)
|
||||||
tg_msg_id = 0
|
tg_msg_id = 0
|
||||||
_email_queue.put((acct_idx, mail, tg_msg_id))
|
_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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|
||||||
# 阶段2:AI 处理,更新为思考中
|
# 阶段2:AI 处理,更新为思考中
|
||||||
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)
|
||||||
|
|||||||
142
src/tg_bot.py
142
src/tg_bot.py
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user