"""知识库问答 handler""" import asyncio import html import logging import re from telegram import Update from telegram.ext import CommandHandler, ContextTypes, filters import config from services.message_utils import record_bot_message logger = logging.getLogger("spam_guard") def looks_like_markdown(text: str) -> bool: return bool(re.search(r"(^|\n)(#{1,3}\s|[-*]\s|\d+\.\s|```)|\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`", text)) def optimize_plain_answer(text: str) -> str: """非 Markdown 回答也做一点可读性整理。""" text = text.strip() if looks_like_markdown(text): return text lines = [line.strip() for line in text.splitlines() if line.strip()] if len(lines) <= 1: return f"*回答*\n{text}" return "*回答*\n" + "\n".join(f"- {line}" if not line.startswith(("-", "•", "✅", "⚠️", "📚")) else line for line in lines) def markdown_to_html(text: str) -> str: """把常见 Markdown 转成 Telegram HTML,规避 Markdown 解析器对 _/链接/长文本的误判。""" code_blocks: list[str] = [] def stash_code_block(match: re.Match) -> str: code = html.escape(match.group(1).strip()) code_blocks.append(f"
{code}
") return f"@@CODE_BLOCK_{len(code_blocks) - 1}@@" text = re.sub(r"```(?:\w+)?\n?(.*?)```", stash_code_block, text, flags=re.S) text = html.escape(text) text = re.sub(r"^#{1,3}\s+(.+)$", r"\1", text, flags=re.M) text = re.sub(r"\*\*([^*\n]+)\*\*", r"\1", text) text = re.sub(r"(?\1", text) text = re.sub(r"`([^`\n]+)`", r"\1", text) for i, block in enumerate(code_blocks): text = text.replace(f"@@CODE_BLOCK_{i}@@", block) return text def compact_answer(text: str, max_len: int = 3800) -> str: """尽量压到一条 Telegram 消息;超长时保留开头和关键尾部。""" text = optimize_plain_answer(text) if len(text) <= max_len: return text tail_markers = ["**文档**", "📚", "相关文档", "文档"] tail = "" for marker in tail_markers: idx = text.rfind(marker) if idx > max_len // 2: tail = text[idx:].strip() break head_len = max_len - len(tail) - 80 if tail else max_len - 40 head = text[:max(1000, head_len)].rstrip() return head + "\n\n...(内容较长已压缩,可追问具体步骤)" + (("\n\n" + tail) if tail else "") def split_answer(text: str, max_len: int = 3900, max_parts: int = 2) -> list[str]: """优先单条;实在超长最多拆 2 条。""" if len(text) <= max_len: return [text] parts = [] rest = text while rest and len(parts) < max_parts: cut = min(len(rest), max_len) if cut < len(rest): better = rest.rfind("\n\n", 0, cut) if better > max_len // 2: cut = better parts.append(rest[:cut].strip()) rest = rest[cut:].strip() if rest and parts: parts[-1] += "\n\n...(后续内容已省略,可继续追问)" return parts async def safe_send(update: Update, context: ContextTypes.DEFAULT_TYPE, text: str, markdown: bool | None = None): """优先回复原消息;Markdown 内容转 HTML 发送,失败自动降级纯文本。""" chat_id = update.effective_chat.id use_markup = looks_like_markdown(text) if markdown is None else markdown send_text = markdown_to_html(text) if use_markup else text async def _record(msg, body): await record_bot_message(msg, body) return msg if use_markup: try: return await _record(await update.message.reply_text(send_text, parse_mode="HTML", disable_web_page_preview=True), send_text) except Exception as e: if "Message to be replied not found" in str(e): try: return await _record(await context.bot.send_message(chat_id=chat_id, text=send_text, parse_mode="HTML", disable_web_page_preview=True), send_text) except Exception: return await _record(await context.bot.send_message(chat_id=chat_id, text=text, disable_web_page_preview=True), text) logger.warning(f"HTML 格式发送失败,降级普通文本: {e}") try: return await _record(await update.message.reply_text(text, disable_web_page_preview=True), text) except Exception as e: if "Message to be replied not found" not in str(e): logger.warning(f"发送回复失败,降级普通发送: {e}") return await _record(await context.bot.send_message(chat_id=chat_id, text=text, disable_web_page_preview=True), text) async def cmd_ask(update: Update, context: ContextTypes.DEFAULT_TYPE): """向知识库提问""" query = " ".join(context.args) if context.args else "" if not query: reply = await safe_send(update, context, "❓ 用法: /ask <你的问题>\n" "例如: /ask 怎么安装收割机\n" f"文档地址: {config.SITE_URL}" ) asyncio.create_task(auto_delete(update.message, seconds=60)) asyncio.create_task(auto_delete(reply, seconds=60)) return logger.debug(f"📩 用户提问: [{update.message.from_user.username}] {query[:80]}") searching_msg = await safe_send(update, context, "🔍 正在搜索知识库并整理详细答案...") asyncio.create_task(auto_delete(update.message, seconds=60)) try: from services.knowledge_base import ask_knowledge_base answer = compact_answer(await ask_knowledge_base(query)) replies = [] parts = split_answer(answer) for i, part in enumerate(parts, 1): prefix = f"*回答 {i}/{len(parts)}*\n\n" if len(parts) > 1 else "" reply = await safe_send(update, context, prefix + part, markdown=looks_like_markdown(prefix + part)) replies.append(reply) for reply in replies: asyncio.create_task(auto_delete(reply, seconds=60)) asyncio.create_task(auto_delete(searching_msg, seconds=0)) except Exception as e: logger.error(f"❌ 回答失败: {e}") reply = await safe_send(update, context, f"❌ 回答失败: {e}") asyncio.create_task(auto_delete(reply, seconds=60)) asyncio.create_task(auto_delete(searching_msg, seconds=0)) async def auto_delete(message, seconds=60): try: await asyncio.sleep(seconds) await message.delete() except Exception: pass def register_knowledge(app): app.add_handler(CommandHandler("ask", cmd_ask, filters.ChatType.GROUPS))