Compare commits

..

98 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
8c088ddff5 debug: add skip_uids logging to diagnose re-fetch issue 2026-07-13 07:54:13 +08:00
0ec2048bc5 fix: never discard from _processing_uids to prevent re-fetching 2026-07-13 07:49:09 +08:00
155a6dfc53 fix: skip already-processing UIDs at FETCH level to avoid downloading email content 2026-07-13 07:35:28 +08:00
d31ec18ea3 fix: restore -FLAGS Seen after FETCH, protected by _processing_uids 2026-07-13 07:13:08 +08:00
02cf4b88b6 fix: add _processing_uids to prevent re-fetching emails being processed 2026-07-13 07:12:43 +08:00
ae140df3fb chore: remove accidental file 2026-07-13 06:57:28 +08:00
e9398526e6 fix: login without selecting INBOX first so conn.list() returns all folders 2026-07-13 06:57:06 +08:00
479b855390 feat: scan all IMAP folders for unread emails, not just INBOX 2026-07-13 00:16:28 +08:00
46bc7c7057 prompt: force simplified Chinese for all AI outputs except reply suggestions 2026-07-13 00:12:50 +08:00
bbc43f8972 fix: keep pending_uids until TG send completes to prevent duplicate processing 2026-07-12 23:51:45 +08:00
87e4b0b01e prompt: refine link extraction to exclude promotional links 2026-07-12 10:15:25 +08:00
fc4c5a175c feat: show thinking indicator during AI processing, accept non-numeric verification codes 2026-07-11 22:54:06 +08:00
1c7d0c1b00 fix: mark emails back as UNSEEN after FETCH to counter server auto-read 2026-07-11 22:45:31 +08:00
5320b10317 fix: per-email error handling with connection rebuild, remove @retry on fetch 2026-07-11 22:43:39 +08:00
d8807ffbb5 fix: remove body truncation for large emails 2026-07-11 22:38:40 +08:00
8d40ed8b19 fix: use BODY.PEEK for large emails to prevent server auto-marking as read on timeout 2026-07-11 22:38:07 +08:00
57191ab00e feat: remove body truncation for all AI requests, increase API timeout to 120s 2026-07-11 22:32:47 +08:00
13b76c00ab feat: add tell me more button for promotion emails with AI-generated details 2026-07-11 22:17:10 +08:00
cf0f7d3956 style: restructure email formats with 收件账户 and app_name for verification 2026-07-11 22:11:15 +08:00
e56bdef0f7 fix: use single backtick for tap-to-copy, remove copy_text button 2026-07-11 22:05:02 +08:00
6d1fe29df4 fix: copy_text field must be an object with text key 2026-07-11 21:57:19 +08:00
05b15d9ee3 fix: use copy_text field instead of duplicate text key 2026-07-11 21:52:07 +08:00
03c18cdb76 fix: copy button shows only the code number 2026-07-11 21:49:54 +08:00
b835812f38 feat: add copy_text button for verification codes 2026-07-11 21:47:54 +08:00
070c32071a style: beautify email formats with type labels and consistent layout 2026-07-11 21:42:35 +08:00
5a21aeee14 chore: remove test scripts 2026-07-11 21:35:58 +08:00
8007fa1cc4 fix: always use MarkdownV2 for all message types 2026-07-11 21:35:22 +08:00
43994d2b0d fix: remove null parse_mode from tg payload, add error logging 2026-07-11 21:22:20 +08:00
a992af0e02 refactor: remove action_required/action_items/key_points, keep summary concise 2026-07-11 19:46:18 +08:00
1957eb2a73 feat: extract clickable links from email and show as URL buttons 2026-07-11 19:38:33 +08:00
be4e957af9 feat: verification emails displayed as platform + code 2026-07-08 16:15:51 +08:00
9991e4ef17 feat: promotional emails summarized as who promotes what 2026-07-08 16:13:54 +08:00
ea6358019d chore: AI并行workers 5→10 2026-07-06 20:13:15 +08:00
d70b817fd4 feat: AI并行处理 + 修复邮件丢失
- _ai_processor 改用 ThreadPoolExecutor(5 workers) 并行调用AI
- _tg_worker 每轮排空所有待发TG消息(原来每轮只取1条)
- fetch_unseen_emails 单封FETCH失败不再丢弃整个批次,跳过继续
2026-07-06 20:12:24 +08:00
0c1ccdfc79 fix: 修复 _email_poller 线程静默死亡问题
- IMAP 连接加 30s socket timeout,防止 _send_id_command 等操作永久阻塞
- _email_poller 加顶层 try/except,异常后 sleep 5s 继续循环
- @retry() 加指数退避延迟(1s,2s,4s...),避免重试风暴
2026-07-06 19:54:23 +08:00
c684090b9c feat: handle Reply-To header, add account_email field, decode address headers 2026-07-02 22:37:15 +08:00
d7daddf491 feat: IMAP 支持 SSL/STARTTLS 配置 2026-07-02 22:18:51 +08:00
05135ee0cc chore: 添加 requirements.txt 2026-07-02 22:05:07 +08:00
ad950ff367 feat: 收件人/发件人改用邮件原始头数据
- sender 从 decode 改为使用原始 From 头
- 新增 recipient 字段,使用原始 To 头
- 移除 ai_process 中对 acct.username 的依赖
2026-07-02 22:04:27 +08:00
2ebcad0a70 feat: include reply content in send confirmation message 2026-07-02 21:01:05 +08:00
d334b6f3eb feat: enhance logging detail across all modules
- email_client: log IMAP connection, login, UNSEEN count, each email
  details (subject/sender/body size), mark-as-seen progress
- ai_client: log AI request params, timing, token usage, response size
- smtp_client: log SMTP connect, login, send details
- tg_bot: log all callback actions with subject context, message states
- main: periodic queue depth report (email_queue/tg_queue/pending_uids)
2026-07-02 21:00:40 +08:00
934d6a7545 fix: thread-safe logging with QueueHandler + QueueListener
- Replace logging.basicConfig with QueueHandler/QueueListener
  so all log output writes from a single thread
- Add %(threadName)s to format for thread identification
2026-07-02 20:57:33 +08:00
be412168bb fix: also delete user's message when cleaning up reply/hint flow 2026-07-02 20:56:30 +08:00
08a1a32367 fix: use original email From header for reply address instead of AI output
- send_summary now accepts original_sender (raw From header)
- Context stores original_sender separately from AI-generated sender
- _do_send_reply tries original_sender first, then AI sender fallback
2026-07-02 20:55:40 +08:00
ded61e25c1 refactor: split AI processor and TG worker into separate threads
- Three independent threads: email poller, AI processor, TG worker
- Two queues: _email_queue (raw emails→AI), _tg_queue (AI results→TG)
- summarizer.py split: ai_process() (AI only) + tg_send_and_mark() (TG only)
- AI retries no longer affect TG polling or email polling
2026-07-02 20:52:34 +08:00
1be3aadb08 refactor: separate email poll, email process, TG poll into threads
- main.py spawns 3 daemon threads: email poller, email processor, TG poller
- Each thread runs independently so retries don't block other operations
- _pending_uids set + lock prevents duplicate queueing across poll cycles
- summarizer.py split into poll_accounts() (generator) and process_email()
- Graceful shutdown via shared _running flag
2026-07-02 20:48:05 +08:00
09d11a6c03 feat: cancel button, regenerate AI replies, custom reply hints
- Reply prompt now has an inline '取消回复' button instead of typing text
- AI reply selection adds '换一批' button: passes all previous suggestions
  to AI to generate different replies
- '我想说:' button: lets user type a hint/tone instruction, AI tailors
  suggestions accordingly
- Cancel button also works on prompt/hint messages (deletes them)
- Extract _show_ai_suggestions helper for reuse
2026-07-02 20:45:40 +08:00
18db9caa8b feat: cancel reply flow with cleanup & remove AI reply confirmation
- send_text now returns message_id for tracking
- Reply prompt includes cancel hint; typing '取消'/'/cancel' clears it
- Prompt message auto-deleted after reply sent or cancelled
- AI reply suggestions always send immediately on tap (no confirm step)
- Removed _confirm_keyboard, CALLBACK_CONFIRM_REPLY handler
2026-07-02 20:41:56 +08:00
3d33aeb0dd fix: parse sender email properly & show confirmation tag clearly
- Use email.utils.parseaddr() to extract pure email from sender
  field (was passing raw 'Name <email>' to SMTP, causing 550 error)
- AI reply selection now shows confirmation status as bold tag
  on its own line for better visibility
2026-07-02 20:35:28 +08:00
0d9237ffdd fix: handle 400 errors from Telegram callback interactions
- Remove redundant editMessageText in CALLBACK_REPLY (text unchanged)
- Wrap answerCallbackQuery in try/except (non-critical)
- Wrap each update in its own try/except to ensure last_update_id
  always advances, preventing infinite retry loops
2026-07-02 20:32:25 +08:00
870ab4a59a refactor: merge reply suggestions into single AI request
- Summary prompt now includes can_reply + reply_suggestions fields
- Removed separate generate_reply_suggestions function and REPLY_PROMPT
- tg_bot reads reply suggestions from cached summary_data
- AI Reply button conditionally hidden based on can_reply
2026-07-02 20:22:54 +08:00
0dbc7ee661 feat: interactive inline buttons, SMTP reply, AI reply suggestions
- Inline keyboard: view original / back to summary toggle per message
- SMTP config per account for sending replies
- Reply button: click, type message, auto-send via SMTP
- AI Reply button: generates 3 suggestions via DeepSeek
  - Simple replies (OK, Got it) send immediately on tap
  - Substantive replies require confirm before send
  - Hides AI Reply button when AI determines reply unnecessary
- Telegram long polling integrated into main loop for real-time interaction
2026-07-02 20:21:06 +08:00
15 changed files with 1517 additions and 168 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,22 +0,0 @@
email_accounts:
- imap_server: "imap.gmail.com"
imap_port: 993
username: "your_email@gmail.com"
password: "your_app_password"
- imap_server: "imap.qq.com"
imap_port: 993
username: "your_email@qq.com"
password: "your_authorization_code"
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

170
main.py
View File

@@ -1,24 +1,140 @@
import logging
import logging.handlers
import queue as queue_module
import signal
import sys
import threading
import time
from src.config import load_config
from src.summarizer import process_all
from concurrent.futures import ThreadPoolExecutor
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
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
# Thread-safe logging
_log_queue = queue_module.Queue(-1)
_queue_handler = logging.handlers.QueueHandler(_log_queue)
_console_handler = logging.StreamHandler()
_console_handler.setFormatter(logging.Formatter(
"%(asctime)s [%(levelname)s] [%(threadName)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
))
_log_listener = logging.handlers.QueueListener(_log_queue, _console_handler)
root = logging.getLogger()
root.addHandler(_queue_handler)
root.setLevel(logging.INFO)
_log_listener.start()
logger = logging.getLogger("main")
_running = True
_shutdown_event = threading.Event()
# 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 (user, info_dict)
_tg_queue: queue_module.Queue = queue_module.Queue()
def _signal_handler(signum, frame):
global _running
logger.info("收到退出信号,正在停止...")
_running = False
_shutdown_event.set()
def _email_poller(cfg: Config):
logger.info("邮件轮询线程已启动")
while _running:
try:
for user in cfg.users:
if not _running:
return
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:
_shutdown_event.wait(cfg.polling_interval)
def _ai_processor(cfg: Config):
logger.info("AI 处理线程已启动 (并行 workers=10)")
max_workers = 10
with ThreadPoolExecutor(max_workers=max_workers) as executor:
pending_futures = {}
while _running:
while len(pending_futures) < max_workers:
try:
user, acct_idx, mail, tg_msg_id = _email_queue.get_nowait()
except queue_module.Empty:
break
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:
user, acct_idx, mail = pending_futures.pop(future)
try:
info = future.result()
if info:
_tg_queue.put((user, info))
except Exception as e:
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)
mark_failed(uid_str)
time.sleep(0.1 if pending_futures else 0.5)
def _tg_poller(cfg: Config):
logger.info("TG 轮询线程已启动(按钮回调专用)")
last_update_id = 0
while _running:
try:
last_update_id = poll_telegram(cfg.bot_token, cfg, last_update_id)
except Exception as e:
logger.error(f"TG 轮询错误: {e}", exc_info=True)
if _running:
_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():
@@ -26,22 +142,40 @@ 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()
while _running:
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_poller, args=(cfg,), daemon=True),
threading.Thread(target=_tg_sender, args=(cfg,), daemon=True),
]
for t in threads:
t.start()
logger.info("所有线程已启动,进入主循环")
tick = 0
try:
process_all(cfg)
except Exception as e:
logger.error(f"轮询出错: {e}", exc_info=True)
if _running:
for _ in range(cfg.polling.interval_seconds):
if not _running:
break
time.sleep(1)
while _running:
tick += 1
if tick % 30 == 0:
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)
_log_listener.stop()
logger.info("机器人已停止")

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
PyYAML>=6.0
requests>=2.28.0

View File

@@ -1,30 +1,48 @@
import json
import logging
import time
from typing import Any
import requests
from src.config import AIConfig
from src.retry import retry
SYSTEM_PROMPT = """你是一个邮件摘要助手。请分析邮件内容并以 JSON 格式返回结构化摘要。
logger = logging.getLogger(__name__)
SYSTEM_PROMPT = """你是一个邮件摘要助手。请分析邮件内容并以 JSON 格式返回结构化摘要。所有输出subject、summary 等)必须使用简体中文。
返回格式必须严格遵循以下 JSON schema
{
"subject": "你概括的简短标题10字以内",
"recipient": "收件邮箱地址",
"sender": "发件人",
"category": "normal/promotion/notification",
"plain_text": "邮件全文的纯文本版本去除HTML标签保留所有文字内容保持原始段落结构不删减不修改任何内容",
"summary": "50字以内的核心摘要",
"priority": "high/medium/low",
"action_required": true/false,
"action_items": ["待办事项1", "待办事项2"],
"key_points": ["关键要点1", "关键要点2"]
"verification_code": "",
"app_name": "",
"links": [{"text": "按钮文字", "url": "https://..."}],
"can_reply": true/false
}
其中 subject 字段不能照抄原邮件主题,必须是你总结生成的简短标题。
其中
- subject 不能照抄原邮件主题,必须是你总结生成的简短标题
- category: 邮件类型。推广邮件(营销、促销、广告、 newsletter 推广等)标记为 "promotion";验证码邮件标记为 "notification";普通邮件标记为 "normal"
- 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 为 false
- can_reply 为 false 表示无需回复或不适合建议回复
只返回 JSON不要包含任何其他文字。"""
@retry()
def summarize_email(ai_cfg: AIConfig, recipient: str, subject: str, sender: str, body: str) -> dict[str, Any]:
content = f"收件人: {recipient}\n发件人: {sender}\n主题: {subject}\n正文:\n{body[:4000]}"
def summarize_email(ai_cfg: AIConfig, recipient: str, subject: str, sender: str, body: str, account: str = "") -> dict[str, Any]:
body_preview = body[:80].replace("\n", " ")
logger.info("AI 摘要请求: sender=%s subject=%s body_preview=%s", sender, subject, body_preview)
content = f"收件人: {recipient}\n发件人: {sender}\n主题: {subject}\n账号: {account}\n正文:\n{body}"
payload = {
"model": ai_cfg.model,
@@ -35,18 +53,118 @@ def summarize_email(ai_cfg: AIConfig, recipient: str, subject: str, sender: str,
"response_format": {"type": "json_object"},
}
return _call_deepseek(ai_cfg, payload)
PROMO_DETAIL_PROMPT = """你是一个邮件分析助手。用户想了解更多关于这封推广邮件的详情。
请根据邮件原文,输出 3-5 条关键信息,每条一句话,简洁明了。包括:优惠力度、适用条件、截止时间、核心卖点等用户做决策需要知道的信息。所有输出使用简体中文。
只返回一个 JSON: {"details": ["信息1", "信息2", ...]}
只返回 JSON不要包含任何其他文字。"""
@retry()
def expand_promo_detail(ai_cfg: AIConfig, sender: str, subject: str, body: str) -> list[str]:
logger.info("AI 推广详情请求: sender=%s subject=%s", sender, subject)
content = f"发件人: {sender}\n主题: {subject}\n正文:\n{body}"
payload = {
"model": ai_cfg.model,
"messages": [
{"role": "system", "content": PROMO_DETAIL_PROMPT},
{"role": "user", "content": content},
],
"response_format": {"type": "json_object"},
}
result = _call_deepseek(ai_cfg, payload)
return result.get("details", [])
MORE_REPLIES_PROMPT_SHORT = """你是一个邮件回复助手。根据以下邮件内容生成3个完全不同的简洁回复。
规则:
- 简洁模式:直接说要点,不需要称呼、落款、邮件格式。像微信/短信一样简短。
- 必须使用与邮件相同的语言回复(英文邮件用英文,中文邮件用中文)。
- 不要添加任何占位符如 [Your Name]。
以下是一些已经生成过的回复,请生成全新的回复,不要与已有回复重复。
返回 JSON:
{
"suggestions": [
{"text": "回复内容"},
{"text": "回复内容"},
{"text": "回复内容"}
]
}
每条回复控制在 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 = "",
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": prompt},
{"role": "user", "content": content},
],
"response_format": {"type": "json_object"},
}
result = _call_deepseek(ai_cfg, payload)
return result.get("suggestions", [])
def _call_deepseek(ai_cfg: AIConfig, payload: dict) -> dict:
model = payload.get("model", ai_cfg.model)
logger.info("DeepSeek API 请求: model=%s", model)
t0 = time.time()
headers = {
"Authorization": f"Bearer {ai_cfg.api_key}",
"Content-Type": "application/json",
}
resp = requests.post(
f"{ai_cfg.base_url.rstrip('/')}/chat/completions",
headers=headers,
json=payload,
timeout=60,
timeout=120,
)
elapsed = time.time() - t0
resp.raise_for_status()
result = resp.json()
usage = result.get("usage", {})
prompt_tokens = usage.get("prompt_tokens", "?")
completion_tokens = usage.get("completion_tokens", "?")
raw = result["choices"][0]["message"]["content"]
logger.info("DeepSeek API 响应: %.2fs, token=%d+%d, 输出长度=%d",
elapsed, prompt_tokens, completion_tokens, len(raw))
return json.loads(raw)

View File

@@ -1,14 +1,30 @@
from dataclasses import dataclass, field
from typing import List
import yaml
from typing import List, Optional
import json
@dataclass
class ImapConfig:
server: str
port: int = 993
ssl: bool = True
starttls: bool = False
@dataclass
class SmtpConfig:
server: str
port: int = 465
ssl: bool = True
starttls: bool = False
@dataclass
class EmailAccount:
imap_server: str
imap_port: int
username: str
password: str
imap: ImapConfig
smtp: SmtpConfig
@dataclass
@@ -19,36 +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 = [EmailAccount(**a) for a in raw["email_accounts"]]
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

@@ -1,19 +1,29 @@
import imaplib
import email
import logging
import re
from email.header import decode_header
from email.utils import parsedate_to_datetime
from email.utils import parsedate_to_datetime, parseaddr
from typing import Optional
from src.config import EmailAccount
from src.retry import retry
logger = logging.getLogger(__name__)
class Email:
def __init__(self, uid: bytes, subject: str, sender: str, body: str, date: str):
def __init__(self, uid: bytes, subject: str, sender: str, recipient: str,
body: str, date: str, reply_to: str = "", account_email: str = "",
folder: str = "INBOX"):
self.uid = uid
self.subject = subject
self.sender = sender
self.recipient = recipient
self.body = body
self.date = date
self.reply_to = reply_to
self.account_email = account_email
self.folder = folder
def _decode_str(s: str) -> str:
@@ -30,6 +40,18 @@ def _decode_str(s: str) -> str:
return "".join(result)
def _decode_address_header(header_value: str) -> str:
if not header_value:
return ""
decoded = _decode_str(header_value)
name, addr = parseaddr(decoded)
if not addr:
return name
if name:
return f"{name} <{addr}>"
return addr
def _get_text_from_msg(msg) -> str:
if msg.is_multipart():
for part in msg.walk():
@@ -68,46 +90,126 @@ def _select_mailbox(conn, mailbox: str = "INBOX"):
raise RuntimeError(f"无法选择邮箱 {mailbox}: {data}")
def _login_and_prepare(account: EmailAccount):
conn = imaplib.IMAP4_SSL(account.imap_server, account.imap_port)
def _login_and_prepare(account: EmailAccount, timeout: int = 30, select_inbox: bool = True):
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(imap.server, imap.port, timeout=timeout)
if imap.starttls:
conn.starttls()
conn.login(account.username, account.password)
logger.info("登录成功: %s", account.username)
if _check_provider(account.username, _PROVIDERS_NEED_ID):
logger.info("发送 ID 命令 (126/163 兼容)")
_send_id_command(conn)
if select_inbox:
_select_mailbox(conn)
logger.info("已选择 INBOX")
return conn
@retry()
def fetch_unseen_emails(account: EmailAccount) -> list[Email]:
conn = _login_and_prepare(account)
def fetch_unseen_emails(account: EmailAccount, skip_uids: set[bytes] | None = None) -> list[Email]:
conn = _login_and_prepare(account, select_inbox=False)
if skip_uids is None:
skip_uids = set()
# 列出所有文件夹,跳过 NoSelect 文件夹
_, folder_data = conn.list()
folders = []
if folder_data:
for item in folder_data:
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))
if not folders:
folders = ["INBOX"]
logger.info("扫描文件夹: %s", ", ".join(folders))
emails = []
skipped = 0
for folder in folders:
try:
status, _ = conn.select(folder, readonly=True)
if status != "OK":
logger.debug("无法选择文件夹: %s", folder)
continue
_, data = conn.uid("SEARCH", None, "UNSEEN")
uids = data[0].split() if data[0] else []
emails = []
if not uids:
continue
for uid in uids:
# 先过滤掉正在处理的 UID避免下载不需要的邮件
new_uids = [uid for uid in uids if uid not in skip_uids]
skipped += len(uids) - len(new_uids)
if not new_uids:
continue
logger.info(" %s: %d 封未读 (%d 封跳过)", folder, len(new_uids), len(uids) - len(new_uids))
for uid in new_uids:
try:
_, msg_data = conn.uid("FETCH", uid, "RFC822")
if msg_data[0] is None:
logger.warning("UID %s FETCH 返回空,跳过", uid)
continue
raw_email = msg_data[0][1]
msg = email.message_from_bytes(raw_email)
subject = _decode_str(msg.get("Subject", ""))
sender = _decode_str(msg.get("From", ""))
sender = _decode_address_header(msg.get("From", ""))
recipient = _decode_address_header(msg.get("To", ""))
reply_to = _decode_address_header(msg.get("Reply-To", ""))
date_str = msg.get("Date", "")
body = _get_text_from_msg(msg)
body_len = len(body)
emails.append(Email(uid=uid, subject=subject, sender=sender, body=body, date=date_str))
logger.info(" 邮件: [%s] from=%s to=%s reply-to=%s (%d 字符)", subject, sender, recipient, reply_to, body_len)
emails.append(Email(uid=uid, subject=subject, sender=sender, recipient=recipient,
body=body, date=date_str, reply_to=reply_to, account_email=account.username,
folder=folder))
# FETCH 可能被服务器自动标已读,立即标回未读
conn.uid("STORE", uid, "-FLAGS", "\\Seen")
except Exception as e:
logger.error(" UID %s FETCH 失败,跳过: %s", uid, e)
continue
except Exception as e:
logger.warning("扫描文件夹 %s 失败: %s", folder, e)
continue
try:
conn.logout()
except Exception:
pass
logger.info("共获取 %d 封新邮件", len(emails))
return emails
@retry()
def mark_as_seen(account: EmailAccount, uids: list[bytes]):
if not uids:
def mark_as_seen(account: EmailAccount, uids_with_folder: list[tuple[bytes, str]]):
if not uids_with_folder:
return
conn = _login_and_prepare(account)
logger.info("标记 %d 封邮件为已读", len(uids_with_folder))
conn = _login_and_prepare(account, select_inbox=False)
# 按文件夹分组
from collections import defaultdict
by_folder: dict[str, list[bytes]] = defaultdict(list)
for uid, folder in uids_with_folder:
by_folder[folder].append(uid)
for folder, uids in by_folder.items():
try:
conn.select(folder, readonly=False)
for uid in uids:
conn.uid("STORE", uid, "+FLAGS", "\\Seen")
logger.info(" %s: 标记 %d", folder, len(uids))
except Exception as e:
logger.error(" 标记 %s 失败: %s", folder, e)
conn.logout()
logger.info("标记完成")

View File

@@ -1,10 +1,11 @@
import logging
import time
from functools import wraps
logger = logging.getLogger(__name__)
def retry(max_tries=10):
def retry(max_tries=10, base_delay=1, max_delay=60):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
@@ -15,10 +16,12 @@ def retry(max_tries=10):
except Exception as e:
last_exc = e
if attempt < max_tries:
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
logger.warning(
"%s 失败(第%d次)正在重试: %s",
func.__name__, attempt, e,
"%s 失败(第%d次)%d秒后重试: %s",
func.__name__, attempt, delay, e,
)
time.sleep(delay)
else:
logger.error(
"%s 失败 %d 次,放弃: %s",

67
src/smtp_client.py Normal file
View File

@@ -0,0 +1,67 @@
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
logger = logging.getLogger(__name__)
@retry()
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.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.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 字符 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["From"] = account.username
msg["To"] = to_addr
msg["Subject"] = reply_subject
conn.sendmail(account.username, [to_addr], msg.as_string())
conn.quit()
logger.info("SMTP 发送完成")

View File

@@ -1,43 +1,111 @@
import logging
from src.config import Config
from src.email_client import fetch_unseen_emails, mark_as_seen
import re
import threading
from typing import Generator, Optional
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_message, format_summary
from src.tg_bot import send_summary, send_thinking, edit_message, format_summary
logger = logging.getLogger(__name__)
_MD_SPECIAL = re.compile(r"([_*\[\]()~`>#+\-=|{}.!\\])")
def process_all(cfg: Config):
for acct in 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:
_process_account(cfg, acct)
except Exception as e:
logger.error(f"处理邮箱 {acct.username} 时出错: {e}", exc_info=True)
def _process_account(cfg: Config, acct):
logger.info(f"检查邮箱: {acct.username}")
emails = fetch_unseen_emails(acct)
if not emails:
logger.info(f" 没有新邮件")
return
logger.info(f" 发现 {len(emails)} 封新邮件")
seen_uids = []
logger.info("检查邮箱: %s (user=%s)", acct.username, user.chat_id)
emails = fetch_unseen_emails(acct, skip_uids=skip_uids)
if emails:
logger.info(" 发现 %d 封新邮件", len(emails))
for mail in emails:
try:
logger.info(f" 正在摘要: {mail.subject}")
summary = summarize_email(cfg.ai, acct.username, mail.subject, mail.sender, mail.body)
summary["recipient"] = acct.username
text = format_summary(summary)
send_message(cfg.telegram, text)
seen_uids.append(mail.uid)
logger.info(f" 已发送到 Telegram")
yield idx, mail
except Exception as e:
logger.error(f" 处理邮件 '{mail.subject}' 失败: {e}", exc_info=True)
seen_uids.append(mail.uid)
logger.error("轮询 %s 失败: %s", acct.username, e, exc_info=True)
if seen_uids:
mark_as_seen(acct, seen_uids)
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}")
# 阶段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,
"original_body": mail.body,
"original_sender": mail.sender,
"original_recipient": mail.recipient,
"original_reply_to": mail.reply_to,
"acct_idx": acct_idx,
"account_email": mail.account_email,
"uid": mail.uid,
"folder": mail.folder,
"thinking_msg_id": tg_msg_id if tg_msg_id else 0,
}
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.bot_token, user.chat_id, thinking_msg_id,
info["text"], _summary_keyboard(can_reply, is_promotion, links))
save_email_context(thinking_msg_id, {
"summary_text": info["text"],
"summary_data": info["data"],
"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.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", ""))
# 标记已读放到后台线程
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,58 +1,689 @@
import json
import logging
import threading
from concurrent.futures import ThreadPoolExecutor
from email.utils import parseaddr
import requests
from src.config import TelegramConfig
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__)
_ai_pool = ThreadPoolExecutor(max_workers=3, thread_name_prefix="ai_regen")
# ── Public API ──────────────────────────────────────────
@retry()
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(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"]
save_email_context(msg_id, {
"summary_text": summary_text,
"summary_data": summary_data,
"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,
"account_idx": account_idx,
"account_email": account_email,
"sender": summary_data.get("sender", ""),
"subject": summary_data.get("subject", ""),
"can_reply": can_reply,
})
return msg_id
@retry()
def send_message(tg_cfg: TelegramConfig, text: str):
url = f"https://api.telegram.org/bot{tg_cfg.bot_token}/sendMessage"
payload = {
"chat_id": tg_cfg.chat_id,
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(bot_token, "sendMessage", payload)
return result["result"]["message_id"]
def send_thinking(bot_token: str, chat_id: str, text: str = "💭 思考中…") -> int:
result = _tg_req(bot_token, "sendMessage", {
"chat_id": chat_id,
"text": text,
"parse_mode": "MarkdownV2",
})
return result["result"]["message_id"]
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,
"text": text, "parse_mode": "MarkdownV2",
}
resp = requests.post(url, json=payload, timeout=30)
if reply_markup:
payload["reply_markup"] = reply_markup
_tg_req(bot_token, "editMessageText", payload)
def delete_message(bot_token: str, chat_id: str, msg_id: int):
try:
_tg_req(bot_token, "deleteMessage", {
"chat_id": chat_id, "message_id": msg_id,
})
except Exception:
pass
def poll_telegram(bot_token: str, cfg: Config, last_update_id: int) -> int:
try:
resp = requests.get(
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()
if not data.get("ok"):
return last_update_id
for update in data.get("result", []):
uid = update["update_id"]
try:
_handle_update(bot_token, cfg, update)
except Exception as e:
logger.warning("处理 update %d 失败: %s", uid, e)
last_update_id = uid + 1
except Exception as e:
logger.warning("Telegram polling error: %s", e)
return last_update_id
_priority_icon = {"high": "🔴", "medium": "🟡", "low": "🟢"}
# ── Formatting ──────────────────────────────────────────
def format_summary(data: dict, account_email: str = "") -> str:
category = data.get("category", "normal")
if category == "promotion":
return _format_promotion(data, account_email)
if category == "notification" and data.get("verification_code"):
return _format_verification(data, account_email)
return _format_normal(data, account_email)
def format_summary(data: dict) -> str:
priority = data.get("priority", "medium")
icon = _priority_icon.get(priority, "")
def _format_promotion(data: dict, account_email: str = "") -> str:
summary = data.get("summary", "")
lines = [
f"*📧 {_escape(data.get('subject', '邮件摘要'))}*",
f"📢 *推广*",
f"━━━━━━━━━━━━━━━━━━",
f"*收件账户:* {_escape(account_email)}",
"",
f"{_escape(summary)}",
]
return "\n".join(lines)
def _format_verification(data: dict, account_email: str = "") -> str:
code = data.get("verification_code", "")
app = data.get("app_name", "")
lines = [
f"🔑 *验证码*",
f"━━━━━━━━━━━━━━━━━━",
f"*收件账户:* {_escape(account_email)}",
f"*应用:* {_escape(app)}",
f"*验证码:* `{_escape(code)}`",
]
return "\n".join(lines)
def _format_normal(data: dict, account_email: str = "") -> str:
lines = [
f"📧 *{_escape(data.get('subject', '邮件摘要'))}*",
f"━━━━━━━━━━━━━━━━━━",
f"*收件账户:* {_escape(account_email)}",
f"*收件人:* {_escape(data.get('recipient', '未知'))}",
f"*发件人:* {_escape(data.get('sender', '未知'))}",
f"*优先级:* {icon} {priority.upper()}",
"",
_escape(data.get("summary", "")),
"",
]
if data.get("action_required"):
lines.append(f"*📌 需要处理:* 是")
items = data.get("action_items", [])
if items:
lines.append(f"*待办事项:*")
for item in items:
lines.append(f"{_escape(item)}")
lines.append("")
points = data.get("key_points", [])
if points:
lines.append(f"*关键要点:*")
for p in points:
lines.append(f"{_escape(p)}")
return "\n".join(lines)
def _escape(text: str) -> str:
special = "_*[]()~`>#+-=|{}.!"
for ch in special:
for ch in "_*[]()~`>#+-=|{}.!":
text = text.replace(ch, f"\\{ch}")
return text
# ── Inline keyboards ────────────────────────────────────
CALLBACK_VIEW_ORIG = "v"
CALLBACK_VIEW_SUMM = "s"
CALLBACK_REPLY = "r"
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"
CALLBACK_PROMO_DETAIL = "pd"
def _summary_keyboard(can_reply: bool = True, is_promotion: bool = False, links: list = None) -> dict:
if is_promotion:
return {"inline_keyboard": [
[{"text": "告诉我更多", "callback_data": CALLBACK_PROMO_DETAIL}],
[{"text": "查看原文", "callback_data": CALLBACK_VIEW_ORIG}],
]}
kb = [
[
{"text": "查看原文", "callback_data": CALLBACK_VIEW_ORIG},
{"text": "回复", "callback_data": CALLBACK_REPLY},
],
]
if can_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]
# 校验 URL合法 http/https无多重协议头长度合理
if url.startswith("http") and url.count("://") == 1 and len(url) < 200:
kb.append([{"text": f"🔗 {text}", "url": url}])
return {"inline_keyboard": kb}
def _orig_keyboard(can_reply: bool = True) -> dict:
kb = [
[
{"text": "返回摘要", "callback_data": CALLBACK_VIEW_SUMM},
{"text": "回复", "callback_data": CALLBACK_REPLY},
],
]
if can_reply:
kb.append([
{"text": "AI完整回复", "callback_data": CALLBACK_AI_REPLY_FULL},
{"text": "AI简洁回复", "callback_data": CALLBACK_AI_REPLY_SHORT},
])
return {"inline_keyboard": kb}
def _cancel_keyboard() -> dict:
return {
"inline_keyboard": [
[{"text": "取消回复", "callback_data": CALLBACK_CANCEL}],
]
}
def _ai_reply_keyboard(msg_id: int, suggestions: list) -> dict:
kb = []
for i, s in enumerate(suggestions):
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}"},
])
kb.append([{"text": "取消", "callback_data": CALLBACK_CANCEL}])
return {"inline_keyboard": kb}
# ── Telegram API ────────────────────────────────────────
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:
logger.error("Telegram API %s 失败: %s %s", method, r.status_code, r.text[:500])
r.raise_for_status()
result = r.json()
if not result.get("ok"):
raise RuntimeError(f"Telegram API error: {result}")
return result
# ── Update handling ─────────────────────────────────────
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(bot_token, cfg, update["callback_query"])
elif "message" in update:
_handle_message(bot_token, cfg, update["message"])
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(bot_token, "answerCallbackQuery", {"callback_query_id": cb_id})
except Exception:
pass
parts = data.split("|")
action = parts[0]
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.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),
})
logger.info(" 切换为原文视图")
elif action == CALLBACK_VIEW_SUMM and ctx:
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),
})
logger.info(" 切换回摘要视图")
elif action == CALLBACK_REPLY and ctx:
prompt_msg_id = send_text(
bot_token, str(chat_id), "请输入你的回复内容:",
reply_markup=_cancel_keyboard(),
)
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 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)
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 = 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]
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 = load_conversation(chat_id)
if not conv or conv.get("summary_msg_id") != msg_id:
return
logger.info(" 换一批 (已有 %d 条历史)", len(conv.get("all_suggestions", [])))
# AI 处理中
_tg_req(bot_token, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id,
"text": "🤖 *AI思考中…*", "parse_mode": "MarkdownV2",
})
# 丢到 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(
bot_token, str(chat_id), "请输入你对回复的提示/要求:",
reply_markup=_cancel_keyboard(),
)
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:
delete_conversation(chat_id)
# 清除回复建议缓存,重新选择时会重新生成
if ctx:
ctx["summary_data"]["reply_suggestions"] = []
save_email_context(msg_id, ctx)
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),
})
logger.info(" 取消, 恢复摘要视图")
else:
delete_message(bot_token, str(chat_id), msg_id)
logger.info(" 取消, 删除提示消息")
elif action == CALLBACK_PROMO_DETAIL and ctx:
_tg_req(bot_token, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id,
"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(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 "暂无更多详情"
expanded = (
f"📢 *推广*\n"
f"━━━━━━━━━━━━━━━━━━\n"
f"*收件账户:* {_escape(ctx.get('account_email', ''))}\n"
f"\n{_escape(summary)}\n"
f"\n*📋 详情:*\n{detail_lines}"
)
_tg_req(bot_token, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id,
"text": expanded, "parse_mode": "MarkdownV2",
"reply_markup": {"inline_keyboard": [
[{"text": "收起", "callback_data": CALLBACK_VIEW_SUMM}],
[{"text": "查看原文", "callback_data": CALLBACK_VIEW_ORIG}],
]},
})
logger.info(" 显示推广详情")
def _handle_message(bot_token: str, cfg: Config, msg: dict):
chat_id = msg["chat"]["id"]
text = msg.get("text") or msg.get("caption") or ""
text = text.strip()
conv = load_conversation(chat_id)
if not conv:
return
user_msg_id = msg["message_id"]
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 media=%d", state, text[:60] if text else "(无)", len(media))
if state == "awaiting_reply":
ctx = load_email_context(conv["summary_msg_id"])
if not ctx:
delete_conversation(chat_id)
delete_message(bot_token, chat_id_str, user_msg_id)
if prompt_msg_id:
delete_message(bot_token, chat_id_str, prompt_msg_id)
logger.info(" 上下文已丢失,清理")
return
delete_message(bot_token, chat_id_str, user_msg_id)
if prompt_msg_id:
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 = load_email_context(conv["summary_msg_id"])
if not ctx:
delete_conversation(chat_id)
delete_message(bot_token, chat_id_str, user_msg_id)
if prompt_msg_id:
delete_message(bot_token, chat_id_str, prompt_msg_id)
return
delete_message(bot_token, chat_id_str, user_msg_id)
if prompt_msg_id:
delete_message(bot_token, chat_id_str, prompt_msg_id)
# 更新消息为 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(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(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 _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(bot_token, str(chat_id), "❌ 该邮箱未配置 SMTP无法发送回复。")
return
to_addr = parseaddr(ctx.get("original_reply_to", ""))[1]
if not to_addr:
to_addr = parseaddr(ctx["original_sender"])[1]
if not to_addr:
to_addr = parseaddr(ctx["sender"])[1]
if not to_addr:
to_addr = ctx["sender"]
subject = ctx["subject"]
# 下载 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)