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
This commit is contained in:
2026-07-02 20:21:06 +08:00
parent 279f8c50e7
commit 0dbc7ee661
7 changed files with 410 additions and 48 deletions

View File

@@ -1,30 +1,79 @@
import json
import logging
import requests
from src.config import TelegramConfig
from src.config import TelegramConfig, Config
from src.ai_client import generate_reply_suggestions
from src.smtp_client import send_reply
from src.retry import retry
@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,
"text": text,
"parse_mode": "MarkdownV2",
}
resp = requests.post(url, json=payload, timeout=30)
resp.raise_for_status()
logger = logging.getLogger(__name__)
_priority_icon = {"high": "🔴", "medium": "🟡", "low": "🟢"}
# msg_id -> email context
_email_contexts: dict[int, dict] = {}
# chat_id -> conversation state
_conversations: dict[int, dict] = {}
# ── Public API ──────────────────────────────────────────
@retry()
def send_summary(tg_cfg: TelegramConfig, chat_id: str, summary_text: str,
summary_data: dict, original_body: str, account_idx: int) -> int:
result = _tg_req(tg_cfg, "sendMessage", {
"chat_id": chat_id,
"text": summary_text,
"parse_mode": "MarkdownV2",
"reply_markup": _summary_keyboard(),
})
msg_id = result["result"]["message_id"]
_email_contexts[msg_id] = {
"summary_text": summary_text,
"summary_data": summary_data,
"original_body": original_body[:10000],
"account_idx": account_idx,
"sender": summary_data.get("sender", ""),
"subject": summary_data.get("subject", ""),
}
return msg_id
@retry()
def send_text(tg_cfg: TelegramConfig, chat_id: str, text: str):
_tg_req(tg_cfg, "sendMessage", {
"chat_id": chat_id,
"text": text,
})
def poll_telegram(tg_cfg: TelegramConfig, 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,
)
resp.raise_for_status()
data = resp.json()
if not data.get("ok"):
return last_update_id
for update in data.get("result", []):
_handle_update(tg_cfg, cfg, update)
last_update_id = update["update_id"] + 1
except Exception as e:
logger.warning("Telegram polling error: %s", e)
return last_update_id
# ── Formatting ──────────────────────────────────────────
def format_summary(data: dict) -> str:
priority = data.get("priority", "medium")
icon = _priority_icon.get(priority, "")
lines = [
f"*📧 {_escape(data.get('subject', '邮件摘要'))}*",
f"━━━━━━━━━━━━━━━━━━",
"━━━━━━━━━━━━━━━━━━",
f"*收件人:* {_escape(data.get('recipient', '未知'))}",
f"*发件人:* {_escape(data.get('sender', '未知'))}",
f"*优先级:* {icon} {priority.upper()}",
@@ -32,27 +81,238 @@ def format_summary(data: dict) -> str:
_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("*📌 需要处理:* 是")
for item in data.get("action_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)}")
for p in data.get("key_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 = "a"
CALLBACK_SELECT_REPLY = "sr"
CALLBACK_CONFIRM_REPLY = "cr"
CALLBACK_CANCEL = "c"
def _summary_keyboard() -> dict:
return {
"inline_keyboard": [
[
{"text": "查看原文", "callback_data": CALLBACK_VIEW_ORIG},
{"text": "回复", "callback_data": CALLBACK_REPLY},
],
[
{"text": "AI回复", "callback_data": CALLBACK_AI_REPLY},
],
]
}
def _orig_keyboard() -> dict:
return {
"inline_keyboard": [
[
{"text": "返回摘要", "callback_data": CALLBACK_VIEW_SUMM},
{"text": "回复", "callback_data": CALLBACK_REPLY},
],
[
{"text": "AI回复", "callback_data": CALLBACK_AI_REPLY},
],
]
}
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}])
kb.append([{"text": "取消", "callback_data": CALLBACK_CANCEL}])
return {"inline_keyboard": kb}
def _confirm_keyboard(msg_id: int, idx: int) -> dict:
return {
"inline_keyboard": [
[
{"text": "确认发送", "callback_data": f"{CALLBACK_CONFIRM_REPLY}|{msg_id}|{idx}"},
{"text": "取消", "callback_data": CALLBACK_CANCEL},
],
]
}
# ── 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}"
r = requests.post(url, json=payload, timeout=30)
r.raise_for_status()
result = r.json()
if not result.get("ok"):
raise RuntimeError(f"Telegram API error: {result}")
return result
# ── Update handling ─────────────────────────────────────
def _handle_update(tg_cfg: TelegramConfig, 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"])
def _handle_callback(tg_cfg: TelegramConfig, cfg: Config, cb: dict):
chat_id = cb["message"]["chat"]["id"]
msg_id = cb["message"]["message_id"]
data = cb["data"]
cb_id = cb["id"]
_tg_req(tg_cfg, "answerCallbackQuery", {"callback_query_id": cb_id})
parts = data.split("|")
action = parts[0]
ctx = _email_contexts.get(msg_id)
if action == CALLBACK_VIEW_ORIG and ctx:
text = f"*📄 原文 \\- {_escape(ctx['subject'])}*\n\n{_escape(ctx['original_body'][:3500])}"
_tg_req(tg_cfg, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id,
"text": text, "parse_mode": "MarkdownV2",
"reply_markup": _orig_keyboard(),
})
elif action == CALLBACK_VIEW_SUMM and ctx:
_tg_req(tg_cfg, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id,
"text": ctx["summary_text"], "parse_mode": "MarkdownV2",
"reply_markup": _summary_keyboard(),
})
elif action == CALLBACK_REPLY and ctx:
_conversations[chat_id] = {"state": "awaiting_reply", "msg_id": msg_id}
_tg_req(tg_cfg, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id,
"text": ctx["summary_text"], "parse_mode": "MarkdownV2",
"reply_markup": _summary_keyboard(),
})
send_text(tg_cfg, str(chat_id), "请输入你的回复内容:")
elif action == CALLBACK_AI_REPLY and ctx:
try:
acct = cfg.email_accounts[ctx["account_idx"]]
suggestions = generate_reply_suggestions(
cfg.ai, ctx["sender"], ctx["subject"], ctx["original_body"],
)
except Exception as e:
send_text(tg_cfg, str(chat_id), f"AI 生成回复失败: {e}")
return
if not suggestions.get("can_reply") or not suggestions.get("suggestions"):
send_text(tg_cfg, str(chat_id), "此邮件无需回复,已隐藏 AI 回复功能。")
return
_conversations[chat_id] = {
"state": "ai_reply_selection",
"msg_id": msg_id,
"ai_suggestions": suggestions["suggestions"],
}
text = "*AI 建议回复,请选择:*\n\n"
for i, s in enumerate(suggestions["suggestions"], 1):
tag = "✅ 可直接发送" if s.get("is_simple") else "📝 需确认"
text += f"{i}\\. {_escape(s['text'])} _{tag}_\n"
_tg_req(tg_cfg, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id,
"text": text, "parse_mode": "MarkdownV2",
"reply_markup": _ai_reply_keyboard(msg_id, suggestions["suggestions"]),
})
elif action == CALLBACK_SELECT_REPLY and ctx:
idx = int(parts[2])
conv = _conversations.get(chat_id)
if not conv or conv.get("msg_id") != msg_id:
return
suggestions = conv.get("ai_suggestions", [])
if idx >= len(suggestions):
return
sel = suggestions[idx]
if sel.get("is_simple"):
_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)
else:
_conversations[chat_id]["selected_idx"] = idx
_tg_req(tg_cfg, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id,
"text": f"确认发送此回复?\n\n{_escape(sel['text'])}",
"parse_mode": "MarkdownV2",
"reply_markup": _confirm_keyboard(msg_id, idx),
})
elif action == CALLBACK_CONFIRM_REPLY and ctx:
idx = int(parts[2])
conv = _conversations.get(chat_id)
if not conv or conv.get("msg_id") != msg_id:
return
suggestions = conv.get("ai_suggestions", [])
if idx >= len(suggestions):
return
_do_send_reply(tg_cfg, cfg, chat_id, msg_id, ctx, suggestions[idx]["text"])
send_text(tg_cfg, str(chat_id), "✅ 回复已发送!")
_conversations.pop(chat_id, None)
elif action == CALLBACK_CANCEL:
_conversations.pop(chat_id, None)
if ctx:
_tg_req(tg_cfg, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id,
"text": ctx["summary_text"], "parse_mode": "MarkdownV2",
"reply_markup": _summary_keyboard(),
})
def _handle_message(tg_cfg: TelegramConfig, cfg: Config, msg: dict):
chat_id = msg["chat"]["id"]
text = msg["text"].strip()
conv = _conversations.get(chat_id)
if not conv or conv.get("state") != "awaiting_reply":
return
ctx = _email_contexts.get(conv["msg_id"])
if not ctx:
_conversations.pop(chat_id, None)
return
_do_send_reply(tg_cfg, cfg, chat_id, conv["msg_id"], ctx, text)
send_text(tg_cfg, str(chat_id), "✅ 回复已发送!")
_conversations.pop(chat_id, None)
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"]]
if not acct.smtp:
send_text(tg_cfg, str(chat_id), "❌ 该邮箱未配置 SMTP无法发送回复。")
return
to_addr = ctx["sender"]
subject = ctx["subject"]
send_reply(acct, to_addr, subject, reply_text)