Compare commits

...

46 Commits

Author SHA1 Message Date
7a01b29655 fix: update email_client and smtp_client for new config structure - email_client: use account.imap.server/port/ssl/starttls instead of flat fields - smtp_client: use s.ssl/starttls instead of s.use_ssl/use_starttls - tg_bot: fix _do_send_reply to use find_user_by_chat_id for email_accounts 2026-07-17 22:19:20 +08:00
c6180834bc chore: remove old YAML config files, keep config.example.json 2026-07-17 22:08:14 +08:00
f2b3793d4c 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 2026-07-17 22:05:59 +08:00
9906f1675f chore: remove reply_suggestions from email summarization prompt AI reply suggestions are now generated on-demand when user clicks full/short reply buttons, using style-specific prompts. 2026-07-17 21:02:50 +08:00
8386ab3c89 fix: always regenerate AI replies when selecting style, ignore summary cache The cached reply_suggestions from summarization use a generic prompt, not matching full/short style. Now always triggers fresh generation. 2026-07-17 21:01:03 +08:00
1d70fe2491 fix: pass links/is_promotion to _summary_keyboard in cancel and send_reply handlers 2026-07-17 20:56:59 +08:00
bedf5b887c feat: 2-col reply buttons, direct send, language-matched prompts - Buttons: [回复N 直接发送] [回复N 编辑] in 2-column layout - CALLBACK_SEND_REPLY: send immediately without editing - Prompts enforce matching email language (no Chinese forced on English) - Full mode: greeting + comma only, no [Your Name] placeholder - Tests: 5/5 passing 2026-07-17 20:49:11 +08:00
6ce9a806ea fix: clear reply suggestions cache on cancel, regenerate when switching styles - Cancel now clears reply_suggestions from email_context - Selecting style with empty cache triggers fresh AI generation - Selecting style with cache uses cached suggestions - Tests: 3/3 passing 2026-07-17 20:33:09 +08:00
84f2428aeb fix: send AI suggestion as MarkdownV2 code block for copy-on-tap 2026-07-17 20:28:46 +08:00
51064e48aa feat: AI reply suggestions show as copyable code block, user edits before sending - Click suggestion -> show text in backtick code block (tap to copy) - Enter awaiting_reply state so user can edit/send like normal reply - No direct email sending on selection anymore - Tests: 3/3 passing 2026-07-17 20:23:59 +08:00
ab2bf7facb feat: AI reply modes - full (formal email) and short (concise) - Split 'AI回复' button into 'AI完整回复' and 'AI简洁回复' - Full mode: complete email format with greeting/signature, 300 char limit - Short mode: brief text message style, 50 char limit - Style persists in conversation state for regen/hint consistency - Tests: 6/6 passing 2026-07-17 20:14:48 +08:00
2a9be9896f fix: restore links when returning to summary view _summary_keyboard was called without links param in VIEW_SUMM handler, causing extracted links to disappear after viewing original text. 2026-07-17 20:10:18 +08:00
2f29e03cec fix: read Telegram caption field for media messages Telegram sends text in 'caption' field (not 'text') when message has media. Handle both cases: text or caption, with text taking priority. 2026-07-17 20:04:27 +08:00
b2e219ece5 fix: send reply immediately on media-only message, no accumulation - Remove accumulation logic: media-only now sends as reply directly - All 3 cases (text-only, media-only, text+media) send immediately - Tests: 4/4 passing 2026-07-17 19:53:38 +08:00
131e440d6b style: simplify media accumulation - silent accumulate, immediate send on text 2026-07-17 19:52:08 +08:00
4002dec33c fix: add missing account_idx to save_email_context in tg_send_and_mark 2026-07-17 19:36:50 +08:00
d76a119190 feat: support attachments in manual reply (photo/video/document/voice) - Add _extract_media: extract media info from Telegram messages - Add _download_tg_file: download files from Telegram API - Modify _handle_message: accumulate attachments in awaiting_reply, send with text - Modify _handle_update: process messages without text (media-only) - Modify smtp_client.send_reply: support MIME multipart with attachments - Add comprehensive tests (11 tests, all passing) 2026-07-17 19:04:27 +08:00
d05277581c fix: save_conversation in CALLBACK_HINT, move AI hint to thread pool 2026-07-17 18:45:40 +08:00
fe24a07d15 fix: parse_mode always MarkdownV2, add MEMORY.md with project rules 2026-07-17 18:36:53 +08:00
0abb7de5d3 fix: move regenerate to AI thread pool, fix parse_mode None error 2026-07-17 18:30:57 +08:00
5ca739456d perf: reduce poll timeout to 1s and wait to 50ms for faster button response 2026-07-15 19:20:52 +08:00
c26d8aa294 fix: reduce long-poll timeout to 2s, make mark_as_seen async to unblock TG sender 2026-07-15 19:15:27 +08:00
c940a456bc fix: clear all database tables on startup 2026-07-15 19:09:41 +08:00
b226f0b597 fix: clear processed_emails on startup to avoid stale state 2026-07-15 19:09:02 +08:00
687e60ede4 fix: add error logging for send_thinking failures in email poller 2026-07-15 19:02:59 +08:00
da1b266350 fix: increase Telegram long-poll timeout to 5s and HTTP timeout to 15s 2026-07-15 18:51:04 +08:00
dcdfba11fa feat: show email subject and sender in all status messages 2026-07-15 18:39:58 +08:00
2822075f2f refactor: split TG worker into poller (callbacks) and sender (email summaries) threads 2026-07-15 18:35:01 +08:00
2744d9dd77 fix: process one queued email per poll cycle to keep callbacks responsive 2026-07-15 18:32:42 +08:00
17514302f9 fix: import is_uid_processed instead of is_uids_processed 2026-07-15 18:25:57 +08:00
d42022cc2e fix: ensure data directory exists before DB connection in all threads 2026-07-15 18:18:24 +08:00
54c4bf00fb refactor: replace all in-memory state with SQLite database 2026-07-14 14:49:23 +08:00
c3132f746a feat: AI generates plain_text field, view full text shows readable version instead of HTML 2026-07-14 14:37:12 +08:00
dd7fc61fd4 fix: remove all body truncation, show full email text everywhere 2026-07-14 14:34:17 +08:00
5113d78662 prompt: detailed instructions for extracting unsubscribe links from promotional emails 2026-07-13 08:38:05 +08:00
8b885f1c4c fix: use threading.Event for immediate shutdown instead of sleep loops 2026-07-13 08:34:02 +08:00
8b577b4c12 prompt: emphasize extracting unsubscribe links from promotional emails 2026-07-13 08:30:44 +08:00
26468aa43d fix: use ellipsis character instead of dots in status messages for MarkdownV2 compatibility 2026-07-13 08:28:23 +08:00
98147e6a6c fix: omit parse_mode from payload when None instead of sending null 2026-07-13 08:27:16 +08:00
bd5b824a6a fix: add parse_mode param to edit_message, status messages use plain text 2026-07-13 08:23:42 +08:00
01504e6295 feat: three-stage status: 获取邮件中 -> 等待处理 -> AI思考中 2026-07-13 08:19:36 +08:00
02b0941ed0 feat: two-stage status messages: 获取邮件中 -> AI思考中 2026-07-13 08:15:25 +08:00
85b02651b9 revert: use debug log for unselectable folders instead of warning 2026-07-13 08:11:30 +08:00
e74ae8be88 fix: quote folder names in IMAP SELECT for folders with spaces 2026-07-13 08:10:39 +08:00
f38259e4bb fix: mark_as_seen uses readonly=False to actually set Seen flag 2026-07-13 08:05:40 +08:00
5368812e45 fix: skip NoSelect folders, clean up debug logging 2026-07-13 08:04:26 +08:00
13 changed files with 791 additions and 328 deletions

3
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.venv/
__pycache__/
*.pyc
config.yaml
config.json
data/

6
MEMORY.md Normal file
View File

@@ -0,0 +1,6 @@
# AI Mail Bot - 记忆
## 固定规则
- **Telegram parse_mode 永远为 MarkdownV2**,不传 None。需要发纯文本时用 Markdown 转义包装,而不是去掉 parse_mode。
- **所有代码改动必须完整测试**,除非用户明确说不需要测试。没有测试的代码不算完成。

0
app.log Normal file
View File

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
}
}
]
}
]
}

View File

@@ -1,32 +0,0 @@
email_accounts:
- imap_server: "imap.gmail.com"
imap_port: 993
username: "your_email@gmail.com"
password: "your_app_password"
use_ssl: true
smtp:
server: "smtp.gmail.com"
port: 465
use_ssl: true
- imap_server: "imap.126.com"
imap_port: 993
username: "your_email@126.com"
password: "your_auth_code"
use_ssl: true
smtp:
server: "smtp.126.com"
port: 465
use_ssl: true
ai:
api_key: "sk-your_deepseek_api_key"
model: "deepseek-chat"
base_url: "https://api.deepseek.com"
telegram:
bot_token: "1234567890:ABCdefGHIjklmNOPqrstUVwxyz"
chat_id: "123456789"
polling:
interval_seconds: 60

132
main.py
View File

@@ -6,11 +6,12 @@ import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from src.config import load_config
from src.summarizer import poll_accounts, ai_process, tg_send_and_mark
from src.tg_bot import poll_telegram
from src.config import load_config, Config, UserConfig
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.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)
_queue_handler = logging.handlers.QueueHandler(_log_queue)
_console_handler = logging.StreamHandler()
@@ -28,13 +29,11 @@ _log_listener.start()
logger = logging.getLogger("main")
_running = True
_pending_lock = threading.Lock()
_pending_uids: set[bytes] = set()
_processing_uids: set[bytes] = set() # 正在处理含等待TG发送的邮件UID
_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()
# Queue 2: AI summaries → TG sender
# Queue 2: AI summaries → TG sender (user, info_dict)
_tg_queue: queue_module.Queue = queue_module.Queue()
@@ -42,31 +41,39 @@ def _signal_handler(signum, frame):
global _running
logger.info("收到退出信号,正在停止...")
_running = False
_shutdown_event.set()
def _email_poller(cfg):
def _email_poller(cfg: Config):
logger.info("邮件轮询线程已启动")
while _running:
try:
for acct_idx, mail in poll_accounts(cfg, skip_uids=_processing_uids):
for user in cfg.users:
if not _running:
return
with _pending_lock:
if mail.uid not in _processing_uids:
_processing_uids.add(mail.uid)
_pending_uids.add(mail.uid)
_email_queue.put((acct_idx, mail))
for acct_idx, mail in poll_accounts(user):
if not _running:
return
uid_str = mail.uid.decode("utf-8", errors="replace") if isinstance(mail.uid, bytes) else str(mail.uid)
if not is_uid_processed(uid_str):
mark_fetched(uid_str, mail.account_email, mail.folder,
mail.subject, mail.sender)
try:
tg_msg_id = send_thinking(cfg.bot_token, user.chat_id,
f"📥 *获取邮件中…*\n{_mail_label(mail)}")
logger.info(" [%s] 获取邮件中消息已发送: msg_id=%d", user.chat_id, 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:
logger.error(f"邮件轮询线程异常: {e}", exc_info=True)
time.sleep(5)
if _running:
for _ in range(cfg.polling.interval_seconds):
if not _running:
return
time.sleep(1)
_shutdown_event.wait(cfg.polling_interval)
def _ai_processor(cfg):
def _ai_processor(cfg: Config):
logger.info("AI 处理线程已启动 (并行 workers=10)")
max_workers = 10
with ThreadPoolExecutor(max_workers=max_workers) as executor:
@@ -74,57 +81,60 @@ def _ai_processor(cfg):
while _running:
while len(pending_futures) < max_workers:
try:
acct_idx, mail = _email_queue.get_nowait()
user, acct_idx, mail, tg_msg_id = _email_queue.get_nowait()
except queue_module.Empty:
break
future = executor.submit(ai_process, cfg, acct_idx, mail)
pending_futures[future] = (acct_idx, mail)
uid_str = mail.uid.decode("utf-8", errors="replace") if isinstance(mail.uid, bytes) else str(mail.uid)
mark_processing(uid_str)
future = executor.submit(ai_process, cfg, user, acct_idx, mail, tg_msg_id)
pending_futures[future] = (user, acct_idx, mail)
completed = [f for f in pending_futures if f.done()]
for future in completed:
acct_idx, mail = pending_futures.pop(future)
user, acct_idx, mail = pending_futures.pop(future)
try:
info = future.result()
if info:
_tg_queue.put(info)
_tg_queue.put((user, info))
except Exception as e:
logger.error(f"AI 处理邮件失败: {e}", exc_info=True)
# AI 失败也要移除 pending否则永远卡住
with _pending_lock:
_pending_uids.discard(mail.uid)
_processing_uids.discard(mail.uid)
uid_str = mail.uid.decode("utf-8", errors="replace") if isinstance(mail.uid, bytes) else str(mail.uid)
mark_failed(uid_str)
time.sleep(0.1 if pending_futures else 0.5)
def _tg_worker(cfg):
logger.info("TG 线程已启动")
def _tg_poller(cfg: Config):
logger.info("TG 轮询线程已启动(按钮回调专用)")
last_update_id = 0
while _running:
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:
logger.error(f"TG 轮询错误: {e}", exc_info=True)
while _running:
try:
info = _tg_queue.get_nowait()
tg_send_and_mark(cfg, info)
# 发送并标记已读成功后移除 pending_processing_uids 保留直到重启)
with _pending_lock:
_pending_uids.discard(info["uid"])
except queue_module.Empty:
break
except Exception as e:
logger.error(f"TG 发送失败: {e}", exc_info=True)
try:
with _pending_lock:
_pending_uids.discard(info["uid"])
except Exception:
pass
if _running:
time.sleep(1)
_shutdown_event.wait(0.05)
def _tg_sender(cfg: Config):
logger.info("TG 发送线程已启动(邮件摘要专用)")
while _running:
try:
user, info = _tg_queue.get_nowait()
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"])
mark_sent(uid_str)
except queue_module.Empty:
pass
except Exception as e:
logger.error(f"TG 发送失败: {e}", exc_info=True)
try:
uid_str = info["uid"].decode("utf-8", errors="replace") if isinstance(info["uid"], bytes) else str(info["uid"])
mark_failed(uid_str)
except Exception:
pass
if _running:
_shutdown_event.wait(0.2)
def main():
@@ -132,14 +142,19 @@ def main():
signal.signal(signal.SIGINT, _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)
logger.info(f"AI邮件摘要机器人已启动轮询间隔: {cfg.polling.interval_seconds}s")
init_db()
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 = [
threading.Thread(target=_email_poller, args=(cfg,), daemon=True),
threading.Thread(target=_ai_processor, args=(cfg,), daemon=True),
threading.Thread(target=_tg_worker, args=(cfg,), daemon=True),
threading.Thread(target=_tg_poller, args=(cfg,), daemon=True),
threading.Thread(target=_tg_sender, args=(cfg,), daemon=True),
]
for t in threads:
@@ -151,11 +166,12 @@ def main():
while _running:
tick += 1
if tick % 30 == 0:
logger.info("队列状态: email_queue=%d tg_queue=%d pending_uids=%d",
_email_queue.qsize(), _tg_queue.qsize(), len(_pending_uids))
time.sleep(1)
logger.info("队列状态: email_queue=%d tg_queue=%d",
_email_queue.qsize(), _tg_queue.qsize())
_shutdown_event.wait(1)
except KeyboardInterrupt:
_running = False
_shutdown_event.set()
for t in threads:
t.join(timeout=5)

View File

@@ -8,7 +8,7 @@ from src.retry import retry
logger = logging.getLogger(__name__)
SYSTEM_PROMPT = """你是一个邮件摘要助手。请分析邮件内容并以 JSON 格式返回结构化摘要。所有输出subject、summary 等)必须使用简体中文,但 reply_suggestions智能回复请使用与邮件相同的语言以便用户直接回复
SYSTEM_PROMPT = """你是一个邮件摘要助手。请分析邮件内容并以 JSON 格式返回结构化摘要。所有输出subject、summary 等)必须使用简体中文。
返回格式必须严格遵循以下 JSON schema
{
@@ -16,16 +16,12 @@ SYSTEM_PROMPT = """你是一个邮件摘要助手。请分析邮件内容并以
"recipient": "收件邮箱地址",
"sender": "发件人",
"category": "normal/promotion/notification",
"plain_text": "邮件全文的纯文本版本去除HTML标签保留所有文字内容保持原始段落结构不删减不修改任何内容",
"summary": "50字以内的核心摘要",
"verification_code": "",
"app_name": "",
"links": [{"text": "按钮文字", "url": "https://..."}],
"can_reply": true/false,
"reply_suggestions": [
{"text": "回复内容", "is_simple": true/false},
{"text": "回复内容", "is_simple": true/false},
{"text": "回复内容", "is_simple": true/false}
]
"can_reply": true/false
}
其中:
@@ -34,12 +30,11 @@ SYSTEM_PROMPT = """你是一个邮件摘要助手。请分析邮件内容并以
- verification_code: 仅当 category 为 "notification" 且邮件包含验证码时填写提取完整的验证码字符串纯数字、字母数字混合等均可如「123456」「abc-789」「701383」。非验证码邮件留空字符串
- app_name: 仅当 category 为 "notification" 且邮件包含验证码时填写,填写发送验证码的应用/平台名称如「GitHub」「微信」「Kraken」。非验证码邮件留空字符串
- links: 仅提取以下两类链接1邮件明确要求用户点击才能完成某操作的链接如确认邮箱、重置密码、查看账单、审批请求、查看通知详情等2退订/取消订阅链接。不要提取推广性质的链接如下载App、注册领优惠、查看更多商品等营销链接、追踪像素、邮件签名中的社交链接。每条包含简短描述文字和完整 URL。最多 3 条。没有符合条件的链接时为空数组
- 退订链接提取规则推广邮件必须提取退订链接。退订链接通常在邮件最底部包含以下关键词之一unsubscribe、退订、取消订阅、opt out、manage preferences、email preferences。即使链接被包裹在 HTML 标签中,也要提取 href 属性里的完整 URL。如果邮件底部同时有多个退订相关链接只提取最主要的那个
- plain_text: 将邮件全文转换为可读的纯文本版本。去除所有HTML标签、CSS样式、脚本代码保留完整的文字内容和原始段落结构。不删减、不修改、不总结任何内容完整保留原文所有信息
- summary 要简洁,把需要知道的信息浓缩成一句话,不需要分条列出
- 当 category 为 "promotion"summary 直接写成一句话:「[发送方名称] 在推广 [推广的产品/服务/活动]」,例如「腾讯云在推广双十一云服务器折扣」。此时 can_reply 为 falsereply_suggestions 为空数组
- can_reply 为 false 表示无需回复或不适合建议回复(此时忽略 reply_suggestions
- is_simple 为 true 表示纯确认性回复(如"好的""收到"),用户选择后可直发
- is_simple 为 false 表示涉及实质内容,需要用户确认再发送
- 每条回复控制在 50 字以内
- 当 category 为 "promotion"summary 直接写成一句话:「[发送方名称] 在推广 [推广的产品/服务/活动]」,例如「腾讯云在推广双十一云服务器折扣」。此时 can_reply 为 false
- can_reply 为 false 表示无需回复或不适合建议回复
只返回 JSON不要包含任何其他文字。"""
@@ -83,7 +78,12 @@ def expand_promo_detail(ai_cfg: AIConfig, sender: str, subject: str, body: str)
return result.get("details", [])
MORE_REPLIES_PROMPT = """你是一个邮件回复助手。根据以下邮件内容生成3个完全不同的建议回复。
MORE_REPLIES_PROMPT_SHORT = """你是一个邮件回复助手。根据以下邮件内容生成3个完全不同的简洁回复。
规则:
- 简洁模式:直接说要点,不需要称呼、落款、邮件格式。像微信/短信一样简短。
- 必须使用与邮件相同的语言回复(英文邮件用英文,中文邮件用中文)。
- 不要添加任何占位符如 [Your Name]。
以下是一些已经生成过的回复,请生成全新的回复,不要与已有回复重复。
@@ -98,22 +98,44 @@ MORE_REPLIES_PROMPT = """你是一个邮件回复助手。根据以下邮件内
每条回复控制在 50 字以内。
只返回 JSON不要包含任何其他文字。"""
MORE_REPLIES_PROMPT_FULL = """你是一个邮件回复助手。根据以下邮件内容生成3个不同的正式邮件回复。
规则:
- 完整模式:生成完整的邮件格式,包括称呼(如 Dear xxx,)和正文,结尾只写问候语逗号,不写落款,用户会自己编辑添加。
- 必须使用与邮件相同的语言回复(英文邮件用英文,中文邮件用中文)。
- 语气正式专业,适合商务邮件。
以下是一些已经生成过的回复,请生成全新的回复,不要与已有回复重复。
返回 JSON:
{
"suggestions": [
{"text": "回复内容"},
{"text": "回复内容"},
{"text": "回复内容"}
]
}
每条回复控制在 300 字以内。
只返回 JSON不要包含任何其他文字。"""
@retry()
def generate_more_replies(ai_cfg: AIConfig, sender: str, subject: str, body: str,
previous_suggestions: list[dict], user_hint: str = "") -> list[dict]:
logger.info("AI 换批请求: sender=%s subject=%s user_hint=%s prev=%d",
sender, subject, user_hint or "(无)", len(previous_suggestions))
previous_suggestions: list[dict], user_hint: str = "",
style: str = "short") -> list[dict]:
logger.info("AI 换批请求: sender=%s subject=%s user_hint=%s prev=%d 条 style=%s",
sender, subject, user_hint or "(无)", len(previous_suggestions), style)
content = f"发件人: {sender}\n主题: {subject}\n正文:\n{body}\n\n已生成过的回复:\n"
for i, s in enumerate(previous_suggestions, 1):
content += f"{i}. {s['text']}\n"
if user_hint:
content += f"\n用户要求: {user_hint}"
prompt = MORE_REPLIES_PROMPT_FULL if style == "full" else MORE_REPLIES_PROMPT_SHORT
payload = {
"model": ai_cfg.model,
"messages": [
{"role": "system", "content": MORE_REPLIES_PROMPT},
{"role": "system", "content": prompt},
{"role": "user", "content": content},
],
"response_format": {"type": "json_object"},

View File

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

177
src/database.py Normal file
View File

@@ -0,0 +1,177 @@
import json
import logging
import sqlite3
import threading
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
logger = logging.getLogger(__name__)
DB_PATH = Path(__file__).parent.parent / "data" / "mail_bot.db"
_local = threading.local()
def init_db():
"""初始化数据库表,启动时清空邮件处理记录"""
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = _get_conn()
conn.executescript("""
CREATE TABLE IF NOT EXISTS processed_emails (
uid TEXT PRIMARY KEY,
account TEXT NOT NULL,
folder TEXT NOT NULL DEFAULT 'INBOX',
subject TEXT,
sender TEXT,
status TEXT NOT NULL DEFAULT 'fetched',
created_at TEXT NOT NULL,
sent_at TEXT
);
CREATE TABLE IF NOT EXISTS email_contexts (
msg_id INTEGER PRIMARY KEY,
data TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS conversations (
chat_id INTEGER PRIMARY KEY,
data TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_processed_status ON processed_emails(status);
CREATE INDEX IF NOT EXISTS idx_processed_account ON processed_emails(account);
""")
conn.execute("DELETE FROM processed_emails")
conn.execute("DELETE FROM email_contexts")
conn.execute("DELETE FROM conversations")
conn.commit()
logger.info("数据库初始化完成: %s", DB_PATH)
def _get_conn() -> sqlite3.Connection:
if not hasattr(_local, "conn") or _local.conn is None:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
_local.conn = sqlite3.connect(str(DB_PATH), timeout=10)
_local.conn.execute("PRAGMA journal_mode=WAL")
_local.conn.execute("PRAGMA busy_timeout=5000")
_local.conn.row_factory = sqlite3.Row
return _local.conn
@contextmanager
def get_db():
conn = _get_conn()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
# ── processed_emails ────────────────────────────────────
def is_uid_processed(uid: str) -> bool:
with get_db() as conn:
row = conn.execute(
"SELECT 1 FROM processed_emails WHERE uid=? AND status != 'failed'",
(uid,)
).fetchone()
return row is not None
def is_uids_processed(uids: list[str]) -> set[str]:
"""批量检查,返回已处理的 uid 集合"""
if not uids:
return set()
with get_db() as conn:
placeholders = ",".join("?" * len(uids))
rows = conn.execute(
f"SELECT uid FROM processed_emails WHERE uid IN ({placeholders}) AND status != 'failed'",
uids
).fetchall()
return {row["uid"] for row in rows}
def mark_fetched(uid: str, account: str, folder: str = "INBOX",
subject: str = "", sender: str = ""):
with get_db() as conn:
conn.execute(
"""INSERT OR IGNORE INTO processed_emails
(uid, account, folder, subject, sender, status, created_at)
VALUES (?, ?, ?, ?, ?, 'fetched', ?)""",
(uid, account, folder, subject, sender, datetime.now().isoformat())
)
def mark_processing(uid: str):
with get_db() as conn:
conn.execute(
"UPDATE processed_emails SET status='processing' WHERE uid=?",
(uid,)
)
def mark_sent(uid: str):
with get_db() as conn:
conn.execute(
"UPDATE processed_emails SET status='sent', sent_at=? WHERE uid=?",
(datetime.now().isoformat(), uid)
)
def mark_failed(uid: str):
with get_db() as conn:
conn.execute(
"UPDATE processed_emails SET status='failed' WHERE uid=?",
(uid,)
)
# ── email_contexts ──────────────────────────────────────
def save_email_context(msg_id: int, data: dict):
with get_db() as conn:
conn.execute(
"""INSERT OR REPLACE INTO email_contexts (msg_id, data, created_at)
VALUES (?, ?, ?)""",
(msg_id, json.dumps(data, ensure_ascii=False), datetime.now().isoformat())
)
def load_email_context(msg_id: int) -> dict | None:
with get_db() as conn:
row = conn.execute(
"SELECT data FROM email_contexts WHERE msg_id=?", (msg_id,)
).fetchone()
if row:
return json.loads(row["data"])
return None
# ── conversations ───────────────────────────────────────
def save_conversation(chat_id: int, data: dict):
with get_db() as conn:
conn.execute(
"""INSERT OR REPLACE INTO conversations (chat_id, data, updated_at)
VALUES (?, ?, ?)""",
(chat_id, json.dumps(data, ensure_ascii=False), datetime.now().isoformat())
)
def load_conversation(chat_id: int) -> dict | None:
with get_db() as conn:
row = conn.execute(
"SELECT data FROM conversations WHERE chat_id=?", (chat_id,)
).fetchone()
if row:
return json.loads(row["data"])
return None
def delete_conversation(chat_id: int):
with get_db() as conn:
conn.execute("DELETE FROM conversations WHERE chat_id=?", (chat_id,))

View File

@@ -91,12 +91,13 @@ def _select_mailbox(conn, mailbox: str = "INBOX"):
def _login_and_prepare(account: EmailAccount, timeout: int = 30, select_inbox: bool = True):
logger.info("连接 %s:%d (超时%ds)", account.imap_server, account.imap_port, timeout)
if account.use_ssl:
conn = imaplib.IMAP4_SSL(account.imap_server, account.imap_port, timeout=timeout)
imap = account.imap
logger.info("连接 %s:%d (超时%ds)", imap.server, imap.port, timeout)
if imap.ssl:
conn = imaplib.IMAP4_SSL(imap.server, imap.port, timeout=timeout)
else:
conn = imaplib.IMAP4(account.imap_server, account.imap_port, timeout=timeout)
if account.use_starttls:
conn = imaplib.IMAP4(imap.server, imap.port, timeout=timeout)
if imap.starttls:
conn.starttls()
conn.login(account.username, account.password)
logger.info("登录成功: %s", account.username)
@@ -113,9 +114,8 @@ def fetch_unseen_emails(account: EmailAccount, skip_uids: set[bytes] | None = No
conn = _login_and_prepare(account, select_inbox=False)
if skip_uids is None:
skip_uids = set()
logger.info("fetch_unseen_emails: %s, skip_uids大小=%d", account.username, len(skip_uids))
# 列出所有文件夹
# 列出所有文件夹,跳过 NoSelect 文件夹
_, folder_data = conn.list()
folders = []
if folder_data:
@@ -123,6 +123,9 @@ def fetch_unseen_emails(account: EmailAccount, skip_uids: set[bytes] | None = No
if item is None:
continue
line = item.decode("utf-8", errors="replace")
# 跳过 \NoSelect 文件夹(父文件夹,不能直接 SELECT
if "\\NoSelect" in line:
continue
m = re.search(r'"([^"]*)"\s*$', line)
if m:
folders.append(m.group(1))
@@ -136,7 +139,7 @@ def fetch_unseen_emails(account: EmailAccount, skip_uids: set[bytes] | None = No
try:
status, _ = conn.select(folder, readonly=True)
if status != "OK":
logger.warning("无法选择文件夹: %s", folder)
logger.debug("无法选择文件夹: %s", folder)
continue
_, data = conn.uid("SEARCH", None, "UNSEEN")
uids = data[0].split() if data[0] else []
@@ -148,11 +151,7 @@ def fetch_unseen_emails(account: EmailAccount, skip_uids: set[bytes] | None = No
skipped += len(uids) - len(new_uids)
if not new_uids:
continue
if skipped > 0 or len(uids) > 0:
logger.info(" %s: %d 封未读 (%d 封跳过) skip_uids=%d",
folder, len(new_uids), len(uids) - len(new_uids), len(skip_uids))
if skip_uids and len(skip_uids) <= 5:
logger.info(" skip_uids 内容: %s", [u for u in skip_uids])
logger.info(" %s: %d 封未读 (%d 封跳过)", folder, len(new_uids), len(uids) - len(new_uids))
for uid in new_uids:
try:
@@ -206,7 +205,7 @@ def mark_as_seen(account: EmailAccount, uids_with_folder: list[tuple[bytes, str]
by_folder[folder].append(uid)
for folder, uids in by_folder.items():
try:
conn.select(folder, readonly=True)
conn.select(folder, readonly=False)
for uid in uids:
conn.uid("STORE", uid, "+FLAGS", "\\Seen")
logger.info(" %s: 标记 %d", folder, len(uids))

View File

@@ -2,6 +2,9 @@ import smtplib
import logging
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
from pathlib import Path
from src.config import EmailAccount
from src.retry import retry
@@ -9,30 +12,55 @@ logger = logging.getLogger(__name__)
@retry()
def send_reply(account: EmailAccount, to_addr: str, subject: str, body: str):
def send_reply(account: EmailAccount, to_addr: str, subject: str, body: str,
attachments: list[tuple[str, str]] | None = None):
"""发送回复邮件,支持附件。
Args:
attachments: [(本地文件路径, mime_type), ...] 列表
"""
if not account.smtp:
raise RuntimeError(f"账号 {account.username} 未配置 SMTP")
s = account.smtp
logger.info("SMTP 连接: %s:%d (ssl=%s)", s.server, s.port, s.use_ssl)
if s.use_ssl:
logger.info("SMTP 连接: %s:%d (ssl=%s)", s.server, s.port, s.ssl)
if s.ssl:
conn = smtplib.SMTP_SSL(s.server, s.port, timeout=15)
else:
conn = smtplib.SMTP(s.server, s.port, timeout=15)
if s.use_starttls:
if s.starttls:
conn.starttls()
conn.login(account.username, account.password)
logger.info("SMTP 登录成功")
reply_subject = subject if subject.lower().startswith("re:") else f"Re: {subject}"
logger.info("SMTP 发送: to=%s subject=%s body=%d 字符", to_addr, reply_subject, len(body))
logger.info("SMTP 发送: to=%s subject=%s body=%d 字符 attachments=%d",
to_addr, reply_subject, len(body), len(attachments or []))
if attachments:
msg = MIMEMultipart()
msg.attach(MIMEText(body, "plain", "utf-8"))
for filepath, mime_type in attachments:
p = Path(filepath)
if not p.exists():
logger.warning(" 附件不存在: %s", filepath)
continue
maintype, subtype = mime_type.split("/", 1)
with open(p, "rb") as f:
part = MIMEBase(maintype, subtype)
part.set_payload(f.read())
encoders.encode_base64(part)
part.add_header("Content-Disposition", "attachment", filename=p.name)
msg.attach(part)
logger.info(" 添加附件: %s (%s, %d bytes)", p.name, mime_type, p.stat().st_size)
else:
msg = MIMEMultipart("alternative")
msg.attach(MIMEText(body, "plain", "utf-8"))
msg = MIMEMultipart("alternative")
msg["From"] = account.username
msg["To"] = to_addr
msg["Subject"] = reply_subject
msg.attach(MIMEText(body, "plain", "utf-8"))
conn.sendmail(account.username, [to_addr], msg.as_string())
conn.quit()

View File

@@ -1,37 +1,57 @@
import logging
import re
import threading
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.ai_client import summarize_email
from src.tg_bot import send_summary, send_thinking, edit_message, format_summary
logger = logging.getLogger(__name__)
_MD_SPECIAL = re.compile(r"([_*\[\]()~`>#+\-=|{}.!\\])")
def poll_accounts(cfg: Config, skip_uids: set[bytes] | None = None) -> Generator[tuple[int, Email], None, None]:
for idx, acct in enumerate(cfg.email_accounts):
def _escape_md(text: str) -> str:
return _MD_SPECIAL.sub(r"\\\1", text)
def _mail_label(mail: Email) -> str:
sender_short = mail.sender.split("<")[0].strip() if "<" in mail.sender else mail.sender.split("@")[0]
subject_short = mail.subject[:30] + ("" if len(mail.subject) > 30 else "")
return f"{_escape_md(sender_short)} · {_escape_md(subject_short)}"
def poll_accounts(user: UserConfig, skip_uids: set[bytes] | None = None) -> Generator[tuple[int, Email], None, None]:
"""轮询指定用户的所有邮箱"""
for idx, acct in enumerate(user.email_accounts):
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)
if emails:
logger.info(f" 发现 {len(emails)} 封新邮件")
logger.info(" 发现 %d 封新邮件", len(emails))
for mail in emails:
yield idx, mail
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) -> Optional[dict]:
logger.info(f" 正在摘要: {mail.subject}")
# 先发思考中消息
thinking_msg_id = send_thinking(cfg.telegram, cfg.telegram.chat_id)
logger.info(f" 思考中消息已发送: msg_id={thinking_msg_id}")
def ai_process(cfg: Config, user: UserConfig, acct_idx: int, mail: Email, tg_msg_id: int = 0) -> Optional[dict]:
logger.info(" [%s] 正在摘要: %s", user.chat_id, mail.subject)
label = _mail_label(mail)
# 阶段1显示等待处理
if tg_msg_id:
edit_message(cfg.bot_token, user.chat_id, tg_msg_id, f"⏳ *等待处理…*\n{label}")
else:
tg_msg_id = send_thinking(cfg.bot_token, user.chat_id, f"⏳ *等待处理…*\n{label}")
summary = summarize_email(cfg.ai, mail.recipient, mail.subject, mail.sender, mail.body, mail.account_email)
# 阶段2AI 处理,更新为思考中
edit_message(cfg.bot_token, user.chat_id, tg_msg_id, f"🤖 *AI思考中…*\n{label}")
summary = summarize_email(user.ai, mail.recipient, mail.subject, mail.sender, mail.body, mail.account_email)
summary["recipient"] = mail.recipient
if "@" not in summary.get("sender", ""):
summary["sender"] = mail.sender
text = format_summary(summary, mail.account_email)
# 阶段3编辑为最终结果
return {
"text": text,
"data": summary,
@@ -43,44 +63,49 @@ def ai_process(cfg: Config, acct_idx: int, mail: Email) -> Optional[dict]:
"account_email": mail.account_email,
"uid": mail.uid,
"folder": mail.folder,
"thinking_msg_id": thinking_msg_id,
"thinking_msg_id": tg_msg_id if tg_msg_id else 0,
}
def tg_send_and_mark(cfg: Config, info: dict):
acct = cfg.email_accounts[info["acct_idx"]]
def tg_send_and_mark(cfg: Config, user: UserConfig, info: dict):
# 找到用户对应的邮箱账户
acct = user.email_accounts[info["acct_idx"]]
thinking_msg_id = info.get("thinking_msg_id")
if thinking_msg_id:
# 编辑思考中消息为实际摘要
from src.tg_bot import _summary_keyboard
from src.database import save_email_context
can_reply = info["data"].get("can_reply", True)
is_promotion = info["data"].get("category") == "promotion"
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))
# 保存上下文用于回调
from src.tg_bot import _email_contexts
_email_contexts[thinking_msg_id] = {
save_email_context(thinking_msg_id, {
"summary_text": info["text"],
"summary_data": info["data"],
"original_body": info["original_body"][:10000],
"original_body": info["original_body"],
"plain_text": info["data"].get("plain_text", ""),
"original_sender": info.get("original_sender", ""),
"original_recipient": info.get("original_recipient", ""),
"original_reply_to": info.get("original_reply_to", ""),
"account_email": info.get("account_email", ""),
"account_idx": info["acct_idx"],
"sender": info["data"].get("sender", ""),
"subject": info["data"].get("subject", ""),
"can_reply": can_reply,
}
})
else:
# 兜底:没有思考中消息就正常发送
send_summary(cfg.telegram, cfg.telegram.chat_id,
send_summary(cfg.bot_token, user.chat_id,
info["text"], info["data"],
info["original_body"], info["acct_idx"],
original_sender=info.get("original_sender", ""),
original_recipient=info.get("original_recipient", ""),
original_reply_to=info.get("original_reply_to", ""),
account_email=info.get("account_email", ""))
# 只有发送成功才标记已读
mark_as_seen(acct, [(info["uid"], info.get("folder", "INBOX"))])
logger.info(f" TG 发送成功,已标记已读")
# 标记已读放到后台线程
def _bg_mark():
try:
mark_as_seen(acct, [(info["uid"], info.get("folder", "INBOX"))])
except Exception:
pass
threading.Thread(target=_bg_mark, daemon=True).start()
logger.info(" [%s] TG 发送成功,标记已读已提交后台", user.chat_id)

View File

@@ -1,40 +1,41 @@
import json
import logging
import threading
from concurrent.futures import ThreadPoolExecutor
from email.utils import parseaddr
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.smtp_client import send_reply
from src.retry import retry
from src.database import save_email_context, load_email_context, save_conversation, load_conversation, delete_conversation
logger = logging.getLogger(__name__)
_email_contexts: dict[int, dict] = {}
# chat_id -> conversation state
_conversations: dict[int, dict] = {}
_ai_pool = ThreadPoolExecutor(max_workers=3, thread_name_prefix="ai_regen")
# ── Public API ──────────────────────────────────────────
@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,
original_sender: str = "", original_recipient: str = "",
original_reply_to: str = "", account_email: str = "") -> int:
can_reply = summary_data.get("can_reply", True)
is_promotion = summary_data.get("category") == "promotion"
links = summary_data.get("links", [])
result = _tg_req(tg_cfg, "sendMessage", {
result = _tg_req(bot_token, "sendMessage", {
"chat_id": chat_id,
"text": summary_text,
"parse_mode": "MarkdownV2",
"reply_markup": _summary_keyboard(can_reply, is_promotion, links),
})
msg_id = result["result"]["message_id"]
_email_contexts[msg_id] = {
save_email_context(msg_id, {
"summary_text": summary_text,
"summary_data": summary_data,
"original_body": original_body[:10000],
"original_body": original_body,
"original_sender": original_sender or summary_data.get("sender", ""),
"original_recipient": original_recipient or summary_data.get("recipient", ""),
"original_reply_to": original_reply_to,
@@ -43,29 +44,29 @@ def send_summary(tg_cfg: TelegramConfig, chat_id: str, summary_text: str,
"sender": summary_data.get("sender", ""),
"subject": summary_data.get("subject", ""),
"can_reply": can_reply,
}
})
return msg_id
@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:
payload = {"chat_id": chat_id, "text": text}
if 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"]
def send_thinking(tg_cfg: TelegramConfig, chat_id: str) -> int:
result = _tg_req(tg_cfg, "sendMessage", {
def send_thinking(bot_token: str, chat_id: str, text: str = "💭 思考中…") -> int:
result = _tg_req(bot_token, "sendMessage", {
"chat_id": chat_id,
"text": "💭 思考中...",
"text": text,
})
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):
payload = {
"chat_id": chat_id, "message_id": msg_id,
@@ -73,24 +74,24 @@ def edit_message(tg_cfg: TelegramConfig, chat_id: str, msg_id: int,
}
if 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:
_tg_req(tg_cfg, "deleteMessage", {
_tg_req(bot_token, "deleteMessage", {
"chat_id": chat_id, "message_id": msg_id,
})
except Exception:
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:
resp = requests.get(
f"https://api.telegram.org/bot{tg_cfg.bot_token}/getUpdates",
params={"offset": last_update_id, "timeout": 10},
timeout=15,
f"https://api.telegram.org/bot{bot_token}/getUpdates",
params={"offset": last_update_id, "timeout": 1},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
@@ -99,7 +100,7 @@ def poll_telegram(tg_cfg: TelegramConfig, cfg: Config, last_update_id: int) -> i
for update in data.get("result", []):
uid = update["update_id"]
try:
_handle_update(tg_cfg, cfg, update)
_handle_update(bot_token, cfg, update)
except Exception as e:
logger.warning("处理 update %d 失败: %s", uid, e)
last_update_id = uid + 1
@@ -168,8 +169,10 @@ def _escape(text: str) -> str:
CALLBACK_VIEW_ORIG = "v"
CALLBACK_VIEW_SUMM = "s"
CALLBACK_REPLY = "r"
CALLBACK_AI_REPLY = "a"
CALLBACK_AI_REPLY_FULL = "af"
CALLBACK_AI_REPLY_SHORT = "as"
CALLBACK_SELECT_REPLY = "sr"
CALLBACK_SEND_REPLY = "ss" # 直接发送(不需要编辑)
CALLBACK_REGEN = "rg"
CALLBACK_HINT = "h"
CALLBACK_CANCEL = "c"
@@ -189,7 +192,10 @@ def _summary_keyboard(can_reply: bool = True, is_promotion: bool = False, links:
],
]
if can_reply:
kb.append([{"text": "AI回复", "callback_data": CALLBACK_AI_REPLY}])
kb.append([
{"text": "AI完整回复", "callback_data": CALLBACK_AI_REPLY_FULL},
{"text": "AI简洁回复", "callback_data": CALLBACK_AI_REPLY_SHORT},
])
for link in (links or []):
url = link.get("url", "")
text = link.get("text", "打开链接")[:30]
@@ -207,7 +213,10 @@ def _orig_keyboard(can_reply: bool = True) -> dict:
],
]
if can_reply:
kb.append([{"text": "AI回复", "callback_data": CALLBACK_AI_REPLY}])
kb.append([
{"text": "AI完整回复", "callback_data": CALLBACK_AI_REPLY_FULL},
{"text": "AI简洁回复", "callback_data": CALLBACK_AI_REPLY_SHORT},
])
return {"inline_keyboard": kb}
@@ -222,8 +231,12 @@ def _cancel_keyboard() -> dict:
def _ai_reply_keyboard(msg_id: int, suggestions: list) -> dict:
kb = []
for i, s in enumerate(suggestions):
data = f"{CALLBACK_SELECT_REPLY}|{msg_id}|{i}"
kb.append([{"text": s["text"][:30], "callback_data": data}])
send_data = f"{CALLBACK_SEND_REPLY}|{msg_id}|{i}"
edit_data = f"{CALLBACK_SELECT_REPLY}|{msg_id}|{i}"
kb.append([
{"text": f"回复{i+1} 直接发送", "callback_data": send_data},
{"text": f"回复{i+1} 编辑", "callback_data": edit_data},
])
kb.append([
{"text": "换一批", "callback_data": f"{CALLBACK_REGEN}|{msg_id}"},
{"text": "我想说:", "callback_data": f"{CALLBACK_HINT}|{msg_id}"},
@@ -234,8 +247,8 @@ def _ai_reply_keyboard(msg_id: int, suggestions: list) -> dict:
# ── Telegram API ────────────────────────────────────────
def _tg_req(tg_cfg: TelegramConfig, method: str, payload: dict) -> dict:
url = f"https://api.telegram.org/bot{tg_cfg.bot_token}/{method}"
def _tg_req(bot_token: str, method: str, payload: dict) -> dict:
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()})
r = requests.post(url, json=payload, timeout=30)
if not r.ok:
@@ -249,35 +262,82 @@ def _tg_req(tg_cfg: TelegramConfig, method: str, payload: dict) -> dict:
# ── Update handling ─────────────────────────────────────
def _handle_update(tg_cfg: TelegramConfig, cfg: Config, update: dict):
def _do_regen(bot_token: str, cfg: Config, chat_id: int, msg_id: int, ctx: dict, conv: dict):
"""在 AI 线程池中执行换一批,不阻塞按钮轮询"""
user = find_user_by_chat_id(cfg, chat_id)
if not user:
return
style = conv.get("reply_style", "short")
try:
new_suggestions = generate_more_replies(
user.ai, ctx["sender"], ctx["subject"], ctx["original_body"],
conv.get("all_suggestions", []), style=style,
)
except Exception as e:
send_text(bot_token, str(chat_id), f"生成失败: {e}")
return
if new_suggestions:
conv["ai_suggestions"] = new_suggestions
conv["all_suggestions"].extend(new_suggestions)
save_conversation(chat_id, conv)
logger.info("[ai_regen] 新生成 %d 条回复 (style=%s)", len(new_suggestions), style)
_show_ai_suggestions(bot_token, chat_id, msg_id, new_suggestions)
def _do_ai_hint(bot_token: str, cfg: Config, chat_id: int, conv: dict, ctx: dict, hint: str):
"""在 AI 线程池中根据用户提示生成回复"""
user = find_user_by_chat_id(cfg, chat_id)
if not user:
return
style = conv.get("reply_style", "short")
try:
new_suggestions = generate_more_replies(
user.ai, ctx["sender"], ctx["subject"], ctx["original_body"],
conv.get("all_suggestions", []), user_hint=hint, style=style,
)
except Exception as e:
send_text(bot_token, str(chat_id), f"生成失败: {e}")
delete_conversation(chat_id)
return
if new_suggestions:
conv["ai_suggestions"] = new_suggestions
conv["all_suggestions"].extend(new_suggestions)
conv["state"] = "ai_reply_selection"
conv.pop("prompt_msg_id", None)
save_conversation(chat_id, conv)
logger.info("[ai_hint] 根据提示生成 %d 条新回复", len(new_suggestions))
_show_ai_suggestions(bot_token, chat_id, conv["summary_msg_id"], new_suggestions)
def _handle_update(bot_token: str, cfg: Config, update: dict):
if "callback_query" in update:
_handle_callback(tg_cfg, cfg, update["callback_query"])
elif "message" in update and "text" in update["message"]:
_handle_message(tg_cfg, cfg, update["message"])
_handle_callback(bot_token, cfg, update["callback_query"])
elif "message" in update:
_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"]
msg_id = cb["message"]["message_id"]
data = cb["data"]
cb_id = cb["id"]
try:
_tg_req(tg_cfg, "answerCallbackQuery", {"callback_query_id": cb_id})
_tg_req(bot_token, "answerCallbackQuery", {"callback_query_id": cb_id})
except Exception:
pass
parts = data.split("|")
action = parts[0]
ctx = _email_contexts.get(msg_id)
ctx = load_email_context(msg_id)
subject = ctx["subject"] if ctx else "?"
logger.info("TG 回调: action=%s msg_id=%d subject=%s", action, msg_id, subject)
if action == CALLBACK_VIEW_ORIG and ctx:
can_reply = ctx.get("can_reply", True)
text = f"*📄 原文 \\- {_escape(ctx['subject'])}*\n\n{_escape(ctx['original_body'][:3500])}"
_tg_req(tg_cfg, "editMessageText", {
text = f"*📄 原文 \\- {_escape(ctx['subject'])}*\n\n{_escape(ctx.get('plain_text', ctx['original_body']))}"
_tg_req(bot_token, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id,
"text": text, "parse_mode": "MarkdownV2",
"reply_markup": _orig_keyboard(can_reply),
@@ -286,121 +346,152 @@ def _handle_callback(tg_cfg: TelegramConfig, cfg: Config, cb: dict):
elif action == CALLBACK_VIEW_SUMM and ctx:
can_reply = ctx.get("can_reply", True)
_tg_req(tg_cfg, "editMessageText", {
is_promotion = ctx.get("summary_data", {}).get("category") == "promotion"
links = ctx.get("summary_data", {}).get("links", [])
_tg_req(bot_token, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id,
"text": ctx["summary_text"], "parse_mode": "MarkdownV2",
"reply_markup": _summary_keyboard(can_reply),
"reply_markup": _summary_keyboard(can_reply, is_promotion, links),
})
logger.info(" 切换回摘要视图")
elif action == CALLBACK_REPLY and ctx:
prompt_msg_id = send_text(
tg_cfg, str(chat_id), "请输入你的回复内容:",
bot_token, str(chat_id), "请输入你的回复内容:",
reply_markup=_cancel_keyboard(),
)
_conversations[chat_id] = {
save_conversation(chat_id, {
"state": "awaiting_reply", "summary_msg_id": msg_id,
"prompt_msg_id": prompt_msg_id,
}
})
logger.info(" 进入回复流程, prompt_msg_id=%d", prompt_msg_id)
elif action == CALLBACK_AI_REPLY and ctx:
suggestions = ctx["summary_data"].get("reply_suggestions", [])
if not suggestions:
send_text(tg_cfg, str(chat_id), "此邮件无需回复。")
logger.info(" AI 判定无需回复,隐藏")
return
elif action in (CALLBACK_AI_REPLY_FULL, CALLBACK_AI_REPLY_SHORT) and ctx:
style = "full" if action == CALLBACK_AI_REPLY_FULL else "short"
# 总是用对应风格重新生成,不使用摘要时的通用缓存
logger.info(" 重新生成 AI 回复 (style=%s)", style)
_tg_req(bot_token, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id,
"text": "🤖 *AI思考中…*", "parse_mode": "MarkdownV2",
})
conv = load_conversation(chat_id) or {}
conv["state"] = "ai_reply_selection"
conv["summary_msg_id"] = msg_id
conv["reply_style"] = style
conv["all_suggestions"] = []
save_conversation(chat_id, conv)
_ai_pool.submit(_do_regen, bot_token, cfg, chat_id, msg_id, ctx, conv)
_conversations[chat_id] = {
"state": "ai_reply_selection",
"summary_msg_id": msg_id,
"ai_suggestions": suggestions,
"all_suggestions": list(suggestions),
}
logger.info(" 显示 AI 回复建议 (%d 条)", len(suggestions))
_show_ai_suggestions(tg_cfg, chat_id, msg_id, suggestions)
elif action == CALLBACK_SEND_REPLY and ctx:
idx = int(parts[2])
conv = load_conversation(chat_id)
if not conv or conv.get("summary_msg_id") != msg_id:
return
suggestions = conv.get("ai_suggestions", [])
if idx >= len(suggestions):
return
sel_text = suggestions[idx]["text"]
logger.info(" 直接发送回复 #%d: %s", idx, sel_text[:60])
_do_send_reply(bot_token, cfg, chat_id, msg_id, ctx, sel_text)
send_text(bot_token, str(chat_id), f"✅ 回复已发送")
delete_conversation(chat_id)
can_reply = ctx.get("can_reply", True)
is_promotion = ctx.get("summary_data", {}).get("category") == "promotion"
links = ctx.get("summary_data", {}).get("links", [])
_tg_req(bot_token, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id,
"text": ctx["summary_text"], "parse_mode": "MarkdownV2",
"reply_markup": _summary_keyboard(can_reply, is_promotion, links),
})
elif action == CALLBACK_SELECT_REPLY and ctx:
idx = int(parts[2])
conv = _conversations.get(chat_id)
conv = load_conversation(chat_id)
if not conv or conv.get("summary_msg_id") != msg_id:
return
suggestions = conv.get("ai_suggestions", [])
if idx >= len(suggestions):
return
sel = suggestions[idx]
logger.info(" 选择回复 #%d: %s", idx, sel["text"][:60])
_do_send_reply(tg_cfg, cfg, chat_id, msg_id, ctx, sel["text"])
send_text(tg_cfg, str(chat_id), f"✅ 已发送: {sel['text']}")
_conversations.pop(chat_id, None)
can_reply = ctx.get("can_reply", True)
_tg_req(tg_cfg, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id,
"text": ctx["summary_text"], "parse_mode": "MarkdownV2",
"reply_markup": _summary_keyboard(can_reply),
sel_text = sel["text"]
logger.info(" 选择回复 #%d: %s", idx, sel_text[:60])
# 用代码块显示点击可复制MarkdownV2
escaped = sel_text.replace("\\", "\\\\").replace("`", "\\`")
_tg_req(bot_token, "sendMessage", {
"chat_id": str(chat_id),
"text": f"*建议回复(点击代码块复制):*\n`{escaped}`",
"parse_mode": "MarkdownV2",
})
# 进入 awaiting_reply 状态,用户可编辑后发送
prompt_msg_id = send_text(
bot_token, str(chat_id),
"请编辑后发送(或直接回复附件):",
reply_markup=_cancel_keyboard(),
)
conv["state"] = "awaiting_reply"
conv["summary_msg_id"] = msg_id
conv["prompt_msg_id"] = prompt_msg_id
conv["draft_text"] = sel_text
save_conversation(chat_id, conv)
logger.info(" 建议已展示,等待用户编辑发送")
elif action == CALLBACK_REGEN and ctx:
conv = _conversations.get(chat_id)
conv = load_conversation(chat_id)
if not conv or conv.get("summary_msg_id") != msg_id:
return
logger.info(" 换一批 (已有 %d 条历史)", len(conv.get("all_suggestions", [])))
# 先显示思考
_tg_req(tg_cfg, "editMessageText", {
# AI 处理
_tg_req(bot_token, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id,
"text": "💭 思考中...", "parse_mode": None,
"text": "🤖 *AI思考中…*", "parse_mode": "MarkdownV2",
})
try:
new_suggestions = generate_more_replies(
cfg.ai, ctx["sender"], ctx["subject"], ctx["original_body"],
conv.get("all_suggestions", []),
)
except Exception as e:
send_text(tg_cfg, str(chat_id), f"生成失败: {e}")
return
if new_suggestions:
conv["ai_suggestions"] = new_suggestions
conv["all_suggestions"].extend(new_suggestions)
logger.info(" 新生成 %d 条回复", len(new_suggestions))
_show_ai_suggestions(tg_cfg, chat_id, msg_id, new_suggestions)
# 丢到 AI 线程池处理,不阻塞按钮轮询
_ai_pool.submit(_do_regen, bot_token, cfg, chat_id, msg_id, ctx, conv)
elif action == CALLBACK_HINT and ctx:
prompt_msg_id = send_text(
tg_cfg, str(chat_id), "请输入你对回复的提示/要求:",
bot_token, str(chat_id), "请输入你对回复的提示/要求:",
reply_markup=_cancel_keyboard(),
)
conv = _conversations.get(chat_id)
conv = load_conversation(chat_id)
if conv and conv.get("summary_msg_id") == msg_id:
conv["state"] = "awaiting_ai_hint"
conv["prompt_msg_id"] = prompt_msg_id
save_conversation(chat_id, conv)
logger.info(" 等待用户输入 AI 提示")
elif action == CALLBACK_CANCEL:
_conversations.pop(chat_id, None)
delete_conversation(chat_id)
# 清除回复建议缓存,重新选择时会重新生成
if ctx:
ctx["summary_data"]["reply_suggestions"] = []
save_email_context(msg_id, ctx)
can_reply = ctx.get("can_reply", True)
_tg_req(tg_cfg, "editMessageText", {
is_promotion = ctx.get("summary_data", {}).get("category") == "promotion"
links = ctx.get("summary_data", {}).get("links", [])
_tg_req(bot_token, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id,
"text": ctx["summary_text"], "parse_mode": "MarkdownV2",
"reply_markup": _summary_keyboard(can_reply),
"reply_markup": _summary_keyboard(can_reply, is_promotion, links),
})
logger.info(" 取消, 恢复摘要视图")
else:
delete_message(tg_cfg, str(chat_id), msg_id)
delete_message(bot_token, str(chat_id), msg_id)
logger.info(" 取消, 删除提示消息")
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,
"text": "💭 思考中...", "parse_mode": None,
"text": "🤖 *AI思考中…*", "parse_mode": "MarkdownV2",
})
try:
details = expand_promo_detail(
cfg.ai, ctx["sender"], ctx["subject"], ctx["original_body"],
)
except Exception as e:
send_text(tg_cfg, str(chat_id), f"获取详情失败: {e}")
send_text(bot_token, str(chat_id), f"获取详情失败: {e}")
return
summary = ctx["summary_data"].get("summary", "")
detail_lines = "\n".join(f"\\- {_escape(d)}" for d in details) if details else "暂无更多详情"
@@ -411,7 +502,7 @@ def _handle_callback(tg_cfg: TelegramConfig, cfg: Config, cb: dict):
f"\n{_escape(summary)}\n"
f"\n*📋 详情:*\n{detail_lines}"
)
_tg_req(tg_cfg, "editMessageText", {
_tg_req(bot_token, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id,
"text": expanded, "parse_mode": "MarkdownV2",
"reply_markup": {"inline_keyboard": [
@@ -422,10 +513,11 @@ def _handle_callback(tg_cfg: TelegramConfig, cfg: Config, cb: dict):
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"]
text = msg["text"].strip()
conv = _conversations.get(chat_id)
text = msg.get("text") or msg.get("caption") or ""
text = text.strip()
conv = load_conversation(chat_id)
if not conv:
return
@@ -434,75 +526,146 @@ def _handle_message(tg_cfg: TelegramConfig, cfg: Config, msg: dict):
chat_id_str = str(chat_id)
prompt_msg_id = conv.get("prompt_msg_id")
state = conv.get("state")
media = _extract_media(msg)
logger.info("TG 消息: state=%s text=%s", state, text[:60])
logger.info("TG 消息: state=%s text=%s media=%d", state, text[:60] if text else "(无)", len(media))
if state == "awaiting_reply":
ctx = _email_contexts.get(conv["summary_msg_id"])
ctx = load_email_context(conv["summary_msg_id"])
if not ctx:
_conversations.pop(chat_id, None)
delete_message(tg_cfg, chat_id_str, user_msg_id)
delete_conversation(chat_id)
delete_message(bot_token, chat_id_str, user_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(" 上下文已丢失,清理")
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:
delete_message(tg_cfg, chat_id_str, prompt_msg_id)
_do_send_reply(tg_cfg, cfg, chat_id, conv["summary_msg_id"], ctx, text)
send_text(tg_cfg, chat_id_str, f"✅ 回复已发送: {text}")
_conversations.pop(chat_id, None)
delete_message(bot_token, chat_id_str, prompt_msg_id)
# 合并累积的附件
all_attachments = conv.get("pending_attachments", [])
all_attachments.extend(media)
_do_send_reply(bot_token, cfg, chat_id, conv["summary_msg_id"], ctx, text,
attachments=all_attachments or None)
attach_info = f" (+{len(all_attachments)}个附件)" if all_attachments else ""
send_text(bot_token, chat_id_str, f"✅ 回复已发送{attach_info}")
delete_conversation(chat_id)
logger.info(" 回复发送完成")
elif state == "awaiting_ai_hint":
ctx = _email_contexts.get(conv["summary_msg_id"])
ctx = load_email_context(conv["summary_msg_id"])
if not ctx:
_conversations.pop(chat_id, None)
delete_message(tg_cfg, chat_id_str, user_msg_id)
delete_conversation(chat_id)
delete_message(bot_token, chat_id_str, user_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
delete_message(tg_cfg, chat_id_str, user_msg_id)
delete_message(bot_token, chat_id_str, user_msg_id)
if prompt_msg_id:
delete_message(tg_cfg, chat_id_str, prompt_msg_id)
try:
new_suggestions = generate_more_replies(
cfg.ai, ctx["sender"], ctx["subject"], ctx["original_body"],
conv.get("all_suggestions", []), user_hint=text,
)
except Exception as e:
send_text(tg_cfg, chat_id_str, f"生成失败: {e}")
_conversations.pop(chat_id, None)
return
delete_message(bot_token, chat_id_str, prompt_msg_id)
if new_suggestions:
conv["ai_suggestions"] = new_suggestions
conv["all_suggestions"].extend(new_suggestions)
conv["state"] = "ai_reply_selection"
conv.pop("prompt_msg_id", None)
logger.info(" 根据提示生成 %d 条新回复", len(new_suggestions))
_show_ai_suggestions(tg_cfg, chat_id, conv["summary_msg_id"], new_suggestions)
# 更新消息为 AI 思考中
_tg_req(bot_token, "editMessageText", {
"chat_id": chat_id, "message_id": conv["summary_msg_id"],
"text": "🤖 *AI思考中…*", "parse_mode": "MarkdownV2",
})
# 丢到 AI 线程池处理
_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):
text = "*AI 建议回复,请选择:*\n\n"
for i, s in enumerate(suggestions, 1):
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,
"text": text, "parse_mode": "MarkdownV2",
"reply_markup": _ai_reply_keyboard(msg_id, suggestions),
})
def _do_send_reply(tg_cfg: TelegramConfig, cfg: Config,
chat_id: int, msg_id: int, ctx: dict, reply_text: str):
acct = cfg.email_accounts[ctx["account_idx"]]
def _download_tg_file(bot_token: str, file_id: str, filename: str) -> str:
"""从 Telegram 下载文件到本地 media/ 目录,返回本地路径"""
import os
from pathlib import Path
media_dir = Path("media")
media_dir.mkdir(exist_ok=True)
resp = requests.get(
f"https://api.telegram.org/bot{bot_token}/getFile",
params={"file_id": file_id}, timeout=30,
)
resp.raise_for_status()
file_path = resp.json()["result"]["file_path"]
dl = requests.get(
f"https://api.telegram.org/file/bot{bot_token}/{file_path}",
timeout=60,
)
dl.raise_for_status()
dest = media_dir / filename
dest.write_bytes(dl.content)
logger.info(" 下载附件: %s (%d bytes)", filename, len(dl.content))
return str(dest)
def _extract_media(msg: dict) -> list[dict]:
"""从 Telegram 消息中提取附件信息列表 [{file_id, filename, mime_type}]"""
attachments = []
# 照片取最大尺寸
if "photo" in msg:
photo = max(msg["photo"], key=lambda p: p.get("file_size", 0))
ext = ".jpg"
attachments.append({
"file_id": photo["file_id"],
"filename": f"photo_{photo['file_id'][:8]}{ext}",
"mime_type": "image/jpeg",
})
if "video" in msg:
v = msg["video"]
attachments.append({
"file_id": v["file_id"],
"filename": v.get("file_name", f"video_{v['file_id'][:8]}.mp4"),
"mime_type": v.get("mime_type", "video/mp4"),
})
if "document" in msg:
d = msg["document"]
attachments.append({
"file_id": d["file_id"],
"filename": d.get("file_name", f"doc_{d['file_id'][:8]}"),
"mime_type": d.get("mime_type", "application/octet-stream"),
})
if "voice" in msg:
vc = msg["voice"]
attachments.append({
"file_id": vc["file_id"],
"filename": f"voice_{vc['file_id'][:8]}.ogg",
"mime_type": vc.get("mime_type", "audio/ogg"),
})
if "sticker" in msg:
s = msg["sticker"]
if not s.get("is_animated"):
attachments.append({
"file_id": s["file_id"],
"filename": f"sticker_{s['file_id'][:8]}.webp",
"mime_type": "image/webp",
})
return attachments
def _do_send_reply(bot_token: str, cfg: Config,
chat_id: int, msg_id: int, ctx: dict, reply_text: str,
attachments: list[dict] | None = None):
user = find_user_by_chat_id(cfg, chat_id)
if not user:
send_text(bot_token, str(chat_id), "❌ 用户未找到。")
return
acct = user.email_accounts[ctx["account_idx"]]
if not acct.smtp:
send_text(tg_cfg, str(chat_id), "❌ 该邮箱未配置 SMTP无法发送回复。")
send_text(bot_token, str(chat_id), "❌ 该邮箱未配置 SMTP无法发送回复。")
return
to_addr = parseaddr(ctx.get("original_reply_to", ""))[1]
if not to_addr:
@@ -512,4 +675,15 @@ def _do_send_reply(tg_cfg: TelegramConfig, cfg: Config,
if not to_addr:
to_addr = ctx["sender"]
subject = ctx["subject"]
send_reply(acct, to_addr, subject, reply_text)
# 下载 Telegram 附件到本地
local_files = []
if attachments:
for att in attachments:
try:
path = _download_tg_file(bot_token, att["file_id"], att["filename"])
local_files.append((path, att["mime_type"]))
except Exception as e:
logger.error(" 附件下载失败: %s - %s", att["filename"], e)
send_reply(acct, to_addr, subject, reply_text, attachments=local_files or None)