     1→"""Slack Interactive Gateway — Block Kit control panel for zhihu-hunter.
     2→
     3→Manages two human-in-the-loop approval gates:
     4→
     5→    Gate 1  [✍ 生成草稿] / [🚫 忽略]
     6→            Triggered when ZhihuHunter surfaces candidate questions.
     7→
     8→    Gate 2  [✅ 确认发布] / [🔄 重新生成] / [❌ 取消]
     9→            Triggered after an answer draft has been generated.
    10→
    11→Usage:
    12→    gateway = SlackInteractiveGateway(slack_client, hunter, engine, channel)
    13→    gateway.register(app)   # call once at bot startup
    14→    gateway.post_question_cards(questions)  # call from dispatcher
    15→"""
    16→
    17→import json
    18→import uuid
    19→from pathlib import Path
    20→from typing import Optional
    21→
    22→from slack_bolt import App
    23→from slack_sdk import WebClient
    24→
    25→from health.utils.logging_config import setup_logger
    26→from slack_bot.zhihu.zhihu_hunter import AnswerDraft, ZhihuHunter, ZhihuQuestion
    27→from slack_bot.zhihu.zhihu_playwright_engine import ZhihuPlaywrightEngine
    28→
    29→logger = setup_logger(__name__)
    30→
    31→
    32→# ── Action IDs ─────────────────────────────────────────────────────────────────
    33→_ACT_GENERATE = "zhihu_generate_draft"
    34→_ACT_IGNORE   = "zhihu_ignore"
    35→_ACT_PUBLISH  = "zhihu_publish"
    36→_ACT_REGEN    = "zhihu_regenerate"
    37→_ACT_CANCEL   = "zhihu_cancel"
    38→_VIEW_REGEN   = "zhihu_regen_modal"
    39→
    40→# Characters shown in the draft preview section (Slack block limit: 3000)
    41→_PREVIEW_CHARS = 800
    42→
    43→
    44→class SlackInteractiveGateway:
    45→    """Block Kit gateway providing two approval gates for the zhihu-hunter workflow.
    46→
    47→    Args:
    48→        slack_client: Authenticated WebClient (same token as the Bolt App).
    49→        hunter: ZhihuHunter instance for draft generation.
    50→        engine: ZhihuPlaywrightEngine instance for publishing.
    51→        notify_channel: Default Slack channel/DM for outbound cards.
    52→        state_dir: Directory for storing draft JSON files between interactions.
    53→            Defaults to <project_root>/data/zhihu_drafts.
    54→    """
    55→
    56→    def __init__(
    57→        self,
    58→        slack_client: WebClient,
    59→        hunter: ZhihuHunter,
    60→        engine: ZhihuPlaywrightEngine,
    61→        notify_channel: str,
    62→        state_dir: Optional[Path] = None,
    63→    ) -> None:
    64→        self.client        = slack_client
    65→        self.hunter        = hunter
    66→        self.engine        = engine
    67→        self.notify_channel = notify_channel
    68→        self.state_dir     = state_dir or (
    69→            Path(__file__).resolve().parents[3] / "data" / "zhihu_drafts"
    70→        )
    71→        self.state_dir.mkdir(parents=True, exist_ok=True)
    72→
    73→    # ── Registration ───────────────────────────────────────────────────────────
    74→
    75→    def register(self, app: App) -> None:
    76→        """Attach all action handlers to a Slack Bolt App instance.
    77→
    78→        Call this once during bot startup, after the App object is created.
    79→
    80→        Args:
    81→            app: The Slack Bolt App that receives Socket Mode events.
    82→        """
    83→        app.action(_ACT_GENERATE)(self._handle_generate_draft)
    84→        app.action(_ACT_IGNORE)(self._handle_ignore)
    85→        app.action(_ACT_PUBLISH)(self._handle_publish)
    86→        app.action(_ACT_REGEN)(self._handle_regenerate)
    87→        app.action(_ACT_CANCEL)(self._handle_cancel)
    88→        app.view(_VIEW_REGEN)(self._handle_regenerate_submit)
    89→        logger.info("ZhihuHunter: Slack action handlers registered")
    90→
    91→    # ── Outbound: Gate 1 ───────────────────────────────────────────────────────
    92→
    93→    def post_question_cards(
    94→        self,
    95→        questions: list[ZhihuQuestion],
    96→        channel: Optional[str] = None,
    97→    ) -> None:
    98→        """Post one Block Kit card per discovered question (Gate 1).
    99→
   100→        Each card includes [✍ 生成草稿] and [🚫 忽略] buttons.
   101→
   102→        Args:
   103→            questions: Candidate questions returned by ZhihuHunter.scan_and_hunt().
   104→            channel: Override channel. Defaults to self.notify_channel.
   105→        """
   106→        ch = channel or self.notify_channel
   107→        if not questions:
   108→            self.client.chat_postMessage(
   109→                channel=ch,
   110→                text="🔍 本次未发现符合条件的知乎问题，下次再试。",
   111→            )
   112→            return
   113→
   114→        self.client.chat_postMessage(
   115→            channel=ch,
   116→            text=f"🔍 发现 *{len(questions)}* 个知乎相关问题，请逐一确认：",
   117→        )
   118→        for q in questions:
   119→            try:
   120→                self.client.chat_postMessage(
   121→                    channel=ch,
   122→                    blocks=self._question_blocks(q),
   123→                    text=f"🔍 知乎问题: {q.title}",
   124→                )
   125→            except Exception as e:
   126→                logger.error(f"Failed to post question card for '{q.title}': {e}")
   127→
   128→    # ── Outbound: Gate 2 ───────────────────────────────────────────────────────
   129→
   130→    def post_draft_card(
   131→        self,
   132→        draft: AnswerDraft,
   133→        channel: str,
   134→        thread_ts: str,
   135→    ) -> None:
   136→        """Post an answer draft card as a thread reply (Gate 2).
   137→
   138→        Includes [✅ 确认发布], [🔄 重新生成], and [❌ 取消] buttons.
   139→
   140→        Args:
   141→            draft: Generated AnswerDraft.
   142→            channel: Channel containing the original question card.
   143→            thread_ts: ts of the question card message (reply target).
   144→        """
   145→        draft_id = self._save_draft(draft)
   146→        try:
   147→            self.client.chat_postMessage(
   148→                channel=channel,
   149→                thread_ts=thread_ts,
   150→                blocks=self._draft_blocks(draft, draft_id),
   151→                text=f"📝 草稿已生成，等待确认发布: {draft.question.title}",
   152→            )
   153→        except Exception as e:
   154→            logger.error(f"Failed to post draft card: {e}")
   155→
   156→    # ── Action Handlers ────────────────────────────────────────────────────────
   157→
   158→    def _handle_generate_draft(self, ack, body, client) -> None:
   159→        """Gate 1 → [✍ 生成草稿]: generate draft and post as thread reply."""
   160→        ack()
   161→        channel  = body["channel"]["id"]
   162→        msg_ts   = body["message"]["ts"]
   163→        raw_val  = body["actions"][0]["value"]
   164→
   165→        # Immediately show loading state
   166→        client.chat_update(
   167→            channel=channel,
   168→            ts=msg_ts,
   169→            blocks=self._loading_block("✍ 草稿生成中，请稍候..."),
   170→            text="草稿生成中...",
   171→        )
   172→
   173→        try:
   174→            question = ZhihuQuestion.model_validate_json(raw_val)
   175→            draft    = self.hunter.draft_answer(question)
   176→
   177→            # Persist to vault for manual fallback publishing
   178→            vault_file = self._save_to_vault(draft)
   179→            if vault_file:
   180→                draft = draft.model_copy(update={"vault_file": str(vault_file)})
   181→
   182→            draft_id = self._save_draft(draft)
   183→
   184→            # Post draft as thread reply
   185→            client.chat_postMessage(
   186→                channel=channel,
   187→                thread_ts=msg_ts,
   188→                blocks=self._draft_blocks(draft, draft_id),
   189→                text=f"📝 草稿已生成: {question.title}",
   190→            )
   191→
   192→            # Update original question card to show "done" state
   193→            client.chat_update(
   194→                channel=channel,
   195→                ts=msg_ts,
   196→                blocks=self._done_block(
   197→                    f"📩 草稿已生成，请在上方回复中查看并确认。\n*<{question.url}|{question.title}>*"
   198→                ),
   199→                text="草稿已生成",
   200→            )
   201→
   202→        except Exception as e:
   203→            logger.error(f"Draft generation failed: {e}", exc_info=True)
   204→            client.chat_update(
   205→                channel=channel,
   206→                ts=msg_ts,
   207→                blocks=self._error_block(f"草稿生成失败：{e}"),
   208→                text="草稿生成失败",
   209→            )
   210→
   211→    def _handle_ignore(self, ack, respond) -> None:
   212→        """Gate 1 → [🚫 忽略]: dismiss the question card."""
   213→        ack()
   214→        respond(
   215→            replace_original=True,
   216→            text="🚫 已忽略",
   217→            blocks=self._done_block("🚫 已忽略此问题"),
   218→        )
   219→
   220→    def _handle_publish(self, ack, body, client) -> None:
   221→        """Gate 2 → [✅ 确认发布]: login to Zhihu and publish the answer."""
   222→        ack()
   223→        channel  = body["channel"]["id"]
   224→        msg_ts   = body["message"]["ts"]
   225→        draft_id = body["actions"][0]["value"]
   226→
   227→        client.chat_update(
   228→            channel=channel,
   229→            ts=msg_ts,
   230→            blocks=self._loading_block("⏳ 正在登录知乎并发布，请稍候..."),
   231→            text="发布中...",
   232→        )
   233→
   234→        try:
   235→            draft = self._load_draft(draft_id)
   236→            if draft is None:
   237→                raise RuntimeError(f"草稿文件不存在（id={draft_id}），可能已过期")
   238→
   239→            # Ensure valid Zhihu session (may send QR to Slack)
   240→            if not self.engine.ensure_logged_in():
   241→                raise RuntimeError("知乎登录失败，请扫码后重试")
   242→
   243→            answer_url = self.engine.publish_answer(
   244→                question_url=draft.question.url,
   245→                answer_text=draft.content,
   246→            )
   247→
   248→            client.chat_update(
   249→                channel=channel,
   250→                ts=msg_ts,
   251→                blocks=self._done_block(
   252→                    f"✅ *发布成功！*\n"
   253→                    f"问题: *<{draft.question.url}|{draft.question.title}>*\n"
   254→                    f"回答: <{answer_url}|查看已发布回答>"
   255→                ),
   256→                text=f"✅ 已发布: {answer_url}",
   257→            )
   258→            # Update vault file status to published
   259→            if draft.vault_file:
   260→                self._update_vault_published(draft.vault_file, answer_url)
   261→            # Clean up draft file
   262→            self._delete_draft(draft_id)
   263→
   264→        except Exception as e:
   265→            logger.error(f"Publish failed: {e}", exc_info=True)
   266→            client.chat_update(
   267→                channel=channel,
   268→                ts=msg_ts,
   269→                blocks=self._error_block(
   270→                    f"发布失败：{e}\n你可以重新点击发布，或联系排查日志。"
   271→                ),
   272→                text="发布失败",
   273→            )
   274→
   275→    def _handle_regenerate(self, ack, body, client) -> None:
   276→        """Gate 2 → [🔄 重新生成]: open a modal for user to enter revision feedback."""
   277→        ack()
   278→        channel  = body["channel"]["id"]
   279→        msg_ts   = body["message"]["ts"]
   280→        raw_val  = body["actions"][0]["value"]
   281→
   282→        try:
   283→            question = ZhihuQuestion.model_validate_json(raw_val)
   284→        except Exception:
   285→            question_title = "（问题信息丢失）"
   286→        else:
   287→            question_title = question.title
   288→
   289→        private_metadata = json.dumps({
   290→            "channel":    channel,
   291→            "msg_ts":     msg_ts,
   292→            "regen_value": raw_val,
   293→        })
   294→
   295→        client.views_open(
   296→            trigger_id=body["trigger_id"],
   297→            view={
   298→                "type": "modal",
   299→                "callback_id": _VIEW_REGEN,
   300→                "title": {"type": "plain_text", "text": "重新生成草稿"},
   301→                "submit": {"type": "plain_text", "text": "生成"},
   302→                "close":  {"type": "plain_text", "text": "取消"},
   303→                "private_metadata": private_metadata,
   304→                "blocks": [
   305→                    {
   306→                        "type": "section",
   307→                        "text": {
   308→                            "type": "mrkdwn",
   309→                            "text": f"*{question_title}*\n\n请输入修改意见，Bot 会据此重新生成草稿。",
   310→                        },
   311→                    },
   312→                    {
   313→                        "type": "input",
   314→                        "block_id": "feedback_block",
   315→                        "optional": True,
   316→                        "label": {"type": "plain_text", "text": "修改意见"},
   317→                        "element": {
   318→                            "type": "plain_text_input",
   319→                            "action_id": "feedback_input",
   320→                            "multiline": True,
   321→                            "placeholder": {
   322→                                "type": "plain_text",
   323→                                "text": "例：内容太技术了，偏向大众一些；字数控制在500字以内；结尾加一句诗",
   324→                            },
   325→                        },
   326→                    },
   327→                ],
   328→            },
   329→        )
   330→
   331→    def _handle_regenerate_submit(self, ack, body, client) -> None:
   332→        """Modal submit → regenerate draft with user feedback."""
   333→        ack()
   334→        metadata  = json.loads(body["view"]["private_metadata"])
   335→        channel   = metadata["channel"]
   336→        msg_ts    = metadata["msg_ts"]
   337→        raw_val   = metadata["regen_value"]
   338→        feedback  = (
   339→            body["view"]["state"]["values"]
   340→            .get("feedback_block", {})
   341→            .get("feedback_input", {})
   342→            .get("value") or ""
   343→        )
   344→
   345→        client.chat_update(
   346→            channel=channel,
   347→            ts=msg_ts,
   348→            blocks=self._loading_block("🔄 重新生成中，请稍候..."),
   349→            text="重新生成中...",
   350→        )
   351→
   352→        try:
   353→            question = ZhihuQuestion.model_validate_json(raw_val)
   354→            draft    = self.hunter.draft_answer(question, feedback=feedback)
   355→
   356→            # Persist to vault (overwrites previous draft file for same question)
   357→            vault_file = self._save_to_vault(draft)
   358→            if vault_file:
   359→                draft = draft.model_copy(update={"vault_file": str(vault_file)})
   360→
   361→            draft_id = self._save_draft(draft)
   362→
   363→            client.chat_update(
   364→                channel=channel,
   365→                ts=msg_ts,
   366→                blocks=self._draft_blocks(draft, draft_id),
   367→                text=f"📝 草稿已重新生成: {question.title}",
   368→            )
   369→
   370→        except Exception as e:
   371→            logger.error(f"Regenerate submit failed: {e}", exc_info=True)
   372→            client.chat_update(
   373→                channel=channel,
   374→                ts=msg_ts,
   375→                blocks=self._error_block(f"重新生成失败：{e}"),
   376→                text="重新生成失败",
   377→            )
   378→
   379→    def _handle_cancel(self, ack, respond) -> None:
   380→        """Gate 2 → [❌ 取消]: dismiss the draft card."""
   381→        ack()
   382→        respond(
   383→            replace_original=True,
   384→            text="❌ 已取消",
   385→            blocks=self._done_block("❌ 已取消本次发布"),
   386→        )
   387→
   388→    # ── Block Kit Builders ─────────────────────────────────────────────────────
   389→
   390→    def _question_blocks(self, question: ZhihuQuestion) -> list[dict]:
   391→        """Build Gate 1 Block Kit blocks for a single question.
   392→
   393→        Args:
   394→            question: ZhihuQuestion to display.
   395→
   396→        Returns:
   397→            List of Slack Block Kit block dicts.
   398→        """
   399→        # Truncate snippet so the button value stays < 2000 chars
   400→        q_for_button = question.model_copy(
   401→            update={"snippet": (question.snippet or "")[:100]}
   402→        )
   403→        btn_value = q_for_button.model_dump_json()
   404→
   405→        body_lines = [f"*<{question.url}|{question.title}>*"]
   406→        if question.snippet:
   407→            body_lines.append(f"> {question.snippet[:200]}")
   408→        if question.keywords:
   409→            body_lines.append(f"_关键词：{' · '.join(question.keywords)}_")
   410→
   411→        return [
   412→            {
   413→                "type": "section",
   414→                "text": {"type": "mrkdwn", "text": "\n".join(body_lines)},
   415→            },
   416→            {
   417→                "type": "actions",
   418→                "elements": [
   419→                    {
   420→                        "type": "button",
   421→                        "text": {"type": "plain_text", "text": "✍ 生成草稿", "emoji": True},
   422→                        "style": "primary",
   423→                        "action_id": _ACT_GENERATE,
   424→                        "value": btn_value,
   425→                    },
   426→                    {
   427→                        "type": "button",
   428→                        "text": {"type": "plain_text", "text": "🚫 忽略", "emoji": True},
   429→                        "action_id": _ACT_IGNORE,
   430→                        "value": "ignore",
   431→                    },
   432→                ],
   433→            },
   434→        ]
   435→
   436→    def _draft_blocks(self, draft: AnswerDraft, draft_id: str) -> list[dict]:
   437→        """Build Gate 2 Block Kit blocks for a draft answer.
   438→
   439→        Args:
   440→            draft: AnswerDraft to display.
   441→            draft_id: UUID key used as the publish button value.
   442→
   443→        Returns:
   444→            List of Slack Block Kit block dicts.
   445→        """
   446→        preview = draft.content[:_PREVIEW_CHARS]
   447→        if len(draft.content) > _PREVIEW_CHARS:
   448→            preview += f"\n_…（共 {len(draft.content)} 字，发布时使用全文）_"
   449→
   450→        # Truncate question for regenerate button value
   451→        q_for_btn = draft.question.model_copy(
   452→            update={"snippet": (draft.question.snippet or "")[:100]}
   453→        )
   454→        regen_value = q_for_btn.model_dump_json()
   455→
   456→        blocks: list[dict] = [
   457→            {
   458→                "type": "section",
   459→                "text": {
   460→                    "type": "mrkdwn",
   461→                    "text": (
   462→                        f"📝 *草稿已生成* | "
   463→                        f"<{draft.question.url}|{draft.question.title}>"
   464→                    ),
   465→                },
   466→            },
   467→            {"type": "divider"},
   468→            {
   469→                "type": "section",
   470→                "text": {"type": "mrkdwn", "text": preview},
   471→            },
   472→        ]
   473→
   474→        if draft.rag_sources:
   475→            blocks.append({
   476→                "type": "context",
   477→                "elements": [{
   478→                    "type": "mrkdwn",
   479→                    "text": f"📚 RAG 来源：{' · '.join(draft.rag_sources[:5])}",
   480→                }],
   481→            })
   482→
   483→        blocks += [
   484→            {"type": "divider"},
   485→            {
   486→                "type": "actions",
   487→                "elements": [
   488→                    {
   489→                        "type": "button",
   490→                        "text": {"type": "plain_text", "text": "✅ 确认发布", "emoji": True},
   491→                        "style": "primary",
   492→                        "action_id": _ACT_PUBLISH,
   493→                        "value": draft_id,
   494→                    },
   495→                    {
   496→                        "type": "button",
   497→                        "text": {"type": "plain_text", "text": "🔄 重新生成", "emoji": True},
   498→                        "action_id": _ACT_REGEN,
   499→                        "value": regen_value,
   500→                    },
   501→                    {
   502→                        "type": "button",
   503→                        "text": {"type": "plain_text", "text": "❌ 取消", "emoji": True},
   504→                        "style": "danger",
   505→                        "action_id": _ACT_CANCEL,
   506→                        "value": "cancel",
   507→                    },
   508→                ],
   509→            },
   510→        ]
   511→        return blocks
   512→
   513→    def _loading_block(self, message: str) -> list[dict]:
   514→        """Simple single-section block for loading state.
   515→
   516→        Args:
   517→            message: Text to display (supports mrkdwn).
   518→
   519→        Returns:
   520→            List with one section block.
   521→        """
   522→        return [{"type": "section", "text": {"type": "mrkdwn", "text": message}}]
   523→
   524→    def _done_block(self, message: str) -> list[dict]:
   525→        """Single-section block for a completed/dismissed state.
   526→
   527→        Args:
   528→            message: Text to display (supports mrkdwn).
   529→
   530→        Returns:
   531→            List with one section block (no action buttons).
   532→        """
   533→        return [{"type": "section", "text": {"type": "mrkdwn", "text": message}}]
   534→
   535→    def _error_block(self, message: str) -> list[dict]:
   536→        """Single-section block for error display.
   537→
   538→        Args:
   539→            message: Error description (supports mrkdwn).
   540→
   541→        Returns:
   542→            List with one section block prefixed with ⚠.
   543→        """
   544→        return [{
   545→            "type": "section",
   546→            "text": {"type": "mrkdwn", "text": f"⚠ {message}"},
   547→        }]
   548→
   549→    # ── Draft State ────────────────────────────────────────────────────────────
   550→
   551→    def _save_to_vault(self, draft: AnswerDraft) -> Optional[Path]:
   552→        """Persist a draft as a Markdown file in <vault>/zhihu/.
   553→
   554→        Args:
   555→            draft: The generated AnswerDraft to save.
   556→
   557→        Returns:
   558→            Absolute path of the written file, or None on failure.
   559→        """
   560→        import re
   561→        from datetime import date as _date
   562→
   563→        try:
   564→            zhihu_dir = self.hunter.vault_path / "zhihu"
   565→            zhihu_dir.mkdir(parents=True, exist_ok=True)
   566→
   567→            date_str = _date.today().strftime("%Y-%m-%d")
   568→            slug = re.sub(r"[^\w\u4e00-\u9fff]+", "-", draft.question.title)[:40].strip("-")
   569→            file_path = zhihu_dir / f"{date_str}-{slug}.md"
   570→
   571→            file_path.write_text(
   572→                f"---\n"
   573→                f"title: \"{draft.question.title}\"\n"
   574→                f"url: {draft.question.url}\n"
   575→                f"date: {date_str}\n"
   576→                f"status: draft\n"
   577→                f"tags: [zhihu]\n"
   578→                f"---\n\n"
   579→                f"# {draft.question.title}\n\n"
   580→                f"{draft.content}\n",
   581→                encoding="utf-8",
   582→            )
   583→            logger.info(f"Draft saved to vault: {file_path}")
   584→            return file_path
   585→        except Exception as e:
   586→            logger.warning(f"Failed to save draft to vault: {e}")
   587→            return None
   588→
   589→    def _update_vault_published(self, vault_file: str, answer_url: str) -> None:
   590→        """Update the vault file's frontmatter status to published.
   591→
   592→        Args:
   593→            vault_file: Absolute path string stored in AnswerDraft.vault_file.
   594→            answer_url: Published answer URL to record.
   595→        """
   596→        try:
   597→            fp = Path(vault_file)
   598→            if not fp.exists():
   599→                return
   600→            content = fp.read_text(encoding="utf-8")
   601→            content = content.replace(
   602→                "status: draft",
   603→                f"status: published\nanswer_url: {answer_url}",
   604→                1,
   605→            )
   606→            fp.write_text(content, encoding="utf-8")
   607→            logger.info(f"Vault file marked published: {fp}")
   608→        except Exception as e:
   609→            logger.warning(f"Failed to update vault file: {e}")
   610→
   611→    def _save_draft(self, draft: AnswerDraft) -> str:
   612→        """Persist a draft to disk and return its UUID key.
   613→
   614→        Args:
   615→            draft: AnswerDraft to save.
   616→
   617→        Returns:
   618→            UUID string key (used as publish button value).
   619→        """
   620→        draft_id  = uuid.uuid4().hex[:12]
   621→        file_path = self.state_dir / f"{draft_id}.json"
   622→        file_path.write_text(draft.model_dump_json(), encoding="utf-8")
   623→        logger.debug(f"Draft saved: {file_path}")
   624→        return draft_id
   625→
   626→    def _load_draft(self, draft_id: str) -> Optional[AnswerDraft]:
   627→        """Load a draft by its UUID key.
   628→
   629→        Args:
   630→            draft_id: Key returned by _save_draft().
   631→
   632→        Returns:
   633→            AnswerDraft if found, None if the file does not exist.
   634→        """
   635→        file_path = self.state_dir / f"{draft_id}.json"
   636→        if not file_path.exists():
   637→            return None
   638→        try:
   639→            return AnswerDraft.model_validate_json(file_path.read_text(encoding="utf-8"))
   640→        except Exception as e:
   641→            logger.error(f"Failed to load draft {draft_id}: {e}")
   642→            return None
   643→
   644→    def _delete_draft(self, draft_id: str) -> None:
   645→        """Remove a draft file after successful publish.
   646→
   647→        Args:
   648→            draft_id: Key of the draft to delete.
   649→        """
   650→        file_path = self.state_dir / f"{draft_id}.json"
   651→        try:
   652→            file_path.unlink(missing_ok=True)
   653→        except Exception as e:
   654→            logger.warning(f"Could not delete draft file {draft_id}: {e}")
   655→


Perfect! Now let me check for any existing code that might fetch the full question content or description:


Bash

grep -rn "fetch.*question\|get.*description\|get.*content\|question.*description" /root/projects/butler/slack_bot/zhihu/ 2>/dev/null | grep -v ".pyc" | head -30


/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py:252:        title = self._fetch_question_title(clean_url)
/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py:256:    def _fetch_question_title(self, url: str) -> str:
/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py:338:                f"- {r['title']} ({r['url'].split('/')[2]}): {r.get('content', '')[:250]}"
/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py:411:        recent_context = self._fetch_recent_web_context(question.title)
/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py:454:        # Article-only scans get a larger per-file budget (higher priority content)
/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py:646:                            snippet=item.get("content", "")[:200] or None,


Perfect! Now let me check if there's any state file or cookie configuration documentation:


ls -la /root/projects/butler/data/ 2>/dev/null; echo "---"; cat /root/projects/butler/.gitignore 2>/dev/null | grep -i "zhihu\|cookie\|state" || echo "No .gitignore entries found for Zhihu"


total 12
drwxr-xr-x  3  501 games   54 Feb  6 15:21 .
drwxr-xr-x 20 root root  4096 Mar 10 13:43 ..
drwxr-xr-x  9  501 games 4096 Mar 16 11:33 health
-rw-r--r--  1  501 games  233 Feb  6 15:21 proxy_health_history.jsonl
---
No .gitignore entries found for Zhihu


Now let me check the obsidian dispatcher to understand how the Zhihu mode is triggered:


grep -n "zhihu\|Zhihu" /root/projects/butler/slack_bot/obsidian/dispatcher.py -i | head -30


8:from slack_bot.obsidian.generators import WritingAssistant, ReplyGenerator, DecisionSupport, SearchAnalyzer, DeAIReviser, ZhihuGenerator
46:    MODE_ZHIHU = "zhihu"
48:    MODE_ZHIHU_HUNTER = "zhihu-hunter"
50:    VALID_MODES = {MODE_WRITE, MODE_REPLY, MODE_DECIDE, MODE_SEARCH, MODE_DEAI, MODE_ZHIHU, MODE_NOTE, MODE_ZHIHU_HUNTER}
73:            self.MODE_ZHIHU: ZhihuGenerator(self.indexer, self.vector_store),
79:        # Initialize Zhihu Hunter components
80:        from slack_bot.zhihu.zhihu_hunter import ZhihuHunter
81:        from slack_bot.zhihu.zhihu_playwright_engine import ZhihuPlaywrightEngine
82:        from slack_bot.zhihu.slack_interactive_gateway import SlackInteractiveGateway
84:        zhihu_notify_channel = os.environ.get("ZHIHU_NOTIFY_CHANNEL", "")
85:        self.zhihu_hunter = ZhihuHunter(
90:        _zhihu_engine = ZhihuPlaywrightEngine(
92:            notify_channel=zhihu_notify_channel,
94:        self.zhihu_gateway = SlackInteractiveGateway(
96:            hunter=self.zhihu_hunter,
97:            engine=_zhihu_engine,
98:            notify_channel=zhihu_notify_channel,
133:            self._reply(channel_id, "⚠ No mode selected. Use `mode write/reply/decide/search/deai/zhihu/note/zhihu-hunter` to start.", response_ts)
146:        # MODE_ZHIHU_HUNTER: scan vault for keywords, surface Zhihu question candidates
156:        #   https://www.zhihu.com/question/... → direct URL: answer this specific question
159:        if current_mode == self.MODE_ZHIHU_HUNTER:
160:            from slack_bot.zhihu.zhihu_hunter import DIR_SHORTCUTS
168:                    questions = self.zhihu_hunter.scan_and_hunt(since_days=7)
170:                elif words and 'zhihu.com/question/' in words[0]:
179:                    questions = self.zhihu_hunter.hunt_direct_url(url)
182:                    logger.info(f"ZhihuHunter: direct URL {url!r} → {questions[0].title!r}" +
193:                    questions = self.zhihu_hunter.scan_glob_files(pattern)
194:                    logger.info(f"ZhihuHunter: glob '{pattern}' → {len(questions)} questions")
205:                    questions = self.zhihu_hunter.scan_single_file(rel_path)
206:                    logger.info(f"ZhihuHunter: single-file scan '{rel_path}' → {len(questions)} questions")


Let me read more of the obsidian dispatcher to see the full Zhihu Hunter implementation:


   156→        #   https://www.zhihu.com/question/... → direct URL: answer this specific question
   157→        #
   158→        # Dir shortcuts: notes / article / clippings / claw / deepfact / deep-dive
   159→        if current_mode == self.MODE_ZHIHU_HUNTER:
   160→            from slack_bot.zhihu.zhihu_hunter import DIR_SHORTCUTS
   161→            try:
   162→                trigger_words = {"开始", "hunt", "扫描", "start", "go"}
   163→                words = [w for w in text.strip().split() if w.lower() not in trigger_words]
   164→
   165→                if not words:
   166→                    # Bare trigger → full auto scan
   167→                    self._reply(channel_id, "🔍 正在扫描知乎候选问题（所有目录）...", response_ts)
   168→                    questions = self.zhihu_hunter.scan_and_hunt(since_days=7)
   169→
   170→                elif words and 'zhihu.com/question/' in words[0]:
   171→                    # Direct URL paste — user was invited or found a question to answer.
   172→                    # Any text after the URL is treated as an optional answer outline.
   173→                    # Strip trailing Slack/HTML junk chars (>, ", etc.) from the URL.
   174→                    import re as _re
   175→                    url = _re.sub(r'[>\'")\].,;]+$', '', words[0])
   176→                    outline = " ".join(words[1:])[:500] if len(words) > 1 else ""
   177→                    hint = f"（已记录回答思路）" if outline else ""
   178→                    self._reply(channel_id, f"🔍 获取问题标题，准备生成草稿{hint}...", response_ts)
   179→                    questions = self.zhihu_hunter.hunt_direct_url(url)
   180→                    if outline and questions:
   181→                        questions[0].outline = outline
   182→                    logger.info(f"ZhihuHunter: direct URL {url!r} → {questions[0].title!r}" +
   183→                                (f" outline={outline[:40]!r}" if outline else ""))
   184→
   185→                elif words and ('*' in words[0] or '?' in words[0]):
   186→                    # Glob pattern → combine all matched files for keyword extraction
   187→                    pattern = words[0]
   188→                    self._reply(
   189→                        channel_id,
   190→                        f"🔍 匹配 `{pattern}` 中的文件，提取观点搜索知乎问题...",
   191→                        response_ts,
   192→                    )
   193→                    questions = self.zhihu_hunter.scan_glob_files(pattern)
   194→                    logger.info(f"ZhihuHunter: glob '{pattern}' → {len(questions)} questions")
   195→
   196→                elif words and ('/' in words[0] or words[0].endswith('.md')):
   197→                    # Single-file mode: extract questions from one specific file,
   198→                    # and pass source_file so answers stay grounded in that article.
   199→                    rel_path = words[0]
   200→                    self._reply(
   201→                        channel_id,
   202→                        f"🔍 从 `{rel_path}` 提取观点，搜索匹配知乎问题...",
   203→                        response_ts,
   204→                    )
   205→                    questions = self.zhihu_hunter.scan_single_file(rel_path)
   206→                    logger.info(f"ZhihuHunter: single-file scan '{rel_path}' → {len(questions)} questions")
   207→
   208→                elif words[0].lower() in DIR_SHORTCUTS:
   209→                    # Dir-scoped scan with optional topic hint
   210→                    rel_dir = DIR_SHORTCUTS[words[0].lower()]
   211→                    topic_hint = " ".join(words[1:])
   212→                    hint_desc = f"，聚焦：{topic_hint}" if topic_hint else ""
   213→                    self._reply(
   214→                        channel_id,
   215→                        f"🔍 扫描 {rel_dir} 目录{hint_desc}，请稍候...",
   216→                        response_ts,
   217→                    )
   218→                    questions = self.zhihu_hunter.scan_and_hunt(
   219→                        since_days=7, dirs=[rel_dir], topic_hint=topic_hint
   220→                    )
   221→
   222→                else:
   223→                    # Direct keywords → skip vault scan entirely
   224→                    self._reply(channel_id, f"🔍 使用指定关键词搜索：{' · '.join(words)}", response_ts)
   225→                    questions = self.zhihu_hunter._find_questions(words)
   226→                    logger.info(f"ZhihuHunter: manual keywords {words} → {len(questions)} questions")
   227→
   228→                self.zhihu_gateway.post_question_cards(questions, channel=channel_id)
   229→            except Exception as e:
   230→                logger.error(f"Zhihu hunt failed: {e}", exc_info=True)
   231→                self._reply(channel_id, f"❌ 扫描失败：{e}", response_ts)
   232→            return
   233→
   234→        # Get history
   235→        history = storage.get_context()
   236→        generator = self.generators[current_mode]
   237→
   238→        try:
   239→            logger.info(f"Generating Obsidian response in mode: {current_mode}")
   240→            # The chat method handles first turn logic and returns updated history
   241→            # But wait, our generators' chat method appends to history internally.
   242→            # In our Slack setup, we want to append the result to ContextStorage.
   243→
   244→            # Note: generator.chat(text, history) returns (response, new_history_list)
   245→            # new_history_list contains [...history, {user: rich_prompt}, {assistant: response}]
   246→            response_text, updated_history = generator.chat(text, history)
   247→
   248→            # Since generator.chat already did the LLM call and returned history,
   249→            # we just need to save the ONLY NEW messages to our storage.
   250→            # However, during FIRST TURN, text is replaced by rich_prompt.
   251→
   252→            # For simplicity: completely overwrite storage with updated_history
   253→            # ContextStorage doesn't support overwrite easily, but we can clear and re-add.
   254→            # Better: Modify ObsidianContextStorage to support set_context.
   255→


Now let me create a comprehensive summary document based on my findings. Let me also check the ZhihuGenerator to understand how it currently works:


   519→class ZhihuGenerator(BaseGenerator):
   520→    """Generate Zhihu-style answers based on style guide and user's knowledge base."""
   521→
   522→    def chat(self, user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
   523→
   524→        is_first_turn = (len(history) == 0)
   525→        built_rich_prompt = False  # tracks whether we ran the full question->draft pipeline
   526→
   527→        # Pre-load rule files (needed for both generation and second-pass review)
   528→        zhihu_bot_style = self._read_file("zhihu-bot-style.md")
   529→        powell_revise_guide = self._read_file("POWELL_REVISE.md")
   530→
   531→        # Treat as a new question if:
   532→        # - history is empty (genuine first turn), OR
   533→        # - user input contains "|" (explicit "Question | Core Ideas" format), OR
   534→        # - user input starts with known question triggers like "回答这个问题"
   535→        is_new_question = (
   536→            is_first_turn
   537→            or "|" in user_input
   538→            or user_input.strip().startswith("回答这个问题")
   539→        )
   540→
   541→        if is_new_question:
   542→            # === First Turn: Build Rich Prompt ===
   543→            # Expected input format: "Question | Core Ideas"
   544→            parts = user_input.split("|", 1)
   545→            question = parts[0].strip()
   546→            core_ideas = parts[1].strip() if len(parts) > 1 else ""
   547→
   548→            # 1. Load Style Guide (zhihu-style.md serves as writing standard, NOT as material)
   549→            zhihu_style_guide = self._read_file("zhihu-style.md")
   550→            # Bot-specific overrides (plain-text output, no bracket explanations, etc.)
   551→            if zhihu_bot_style:
   552→                zhihu_style_guide = zhihu_style_guide + "\n\n" + zhihu_bot_style
   553→
   554→            # 2. RAG from Obsidian vault for actual content material
   555→            rag_notes = self.indexer.search(question, limit=5)
   556→            rag_text = "\n\n".join(rag_notes)
   557→
   558→            # 3. Load methodology
   559→            methodology = self._read_file("methodology.md")
   560→
   561→            semantic_context = self._semantic_search(question, top_k=3)
   562→
   563→            prompt = f"""You are answering a Zhihu question. Your goal is to write an authentic, experience-driven answer that sounds like a real human, not AI-generated.
   564→
   565→=== YOUR IDENTITY & METHODOLOGY ===
   566→{methodology}
   567→
   568→=== ANTI-AI-TONE GUIDE (APPLY WHILE WRITING, NOT JUST AT THE END) ===
   569→{powell_revise_guide}
   570→
   571→=== ZHIHU STYLE GUIDE (FOLLOW THESE WRITING PRINCIPLES) ===
   572→{zhihu_style_guide}
   573→
   574→CRITICAL: The style guide above defines HOW to write (tone, structure, language patterns).
   575→Do NOT treat zhihu-sample content as factual material. It's ONLY for style reference.
   576→
   577→=== RELEVANT KNOWLEDGE FROM YOUR OBSIDIAN VAULT (USE AS ACTUAL MATERIAL) ===
   578→{rag_text}
   579→
   580→{semantic_context}
   581→
   582→=== THE QUESTION ===
   583→{question}
   584→
   585→=== YOUR CORE IDEAS ===
   586→{core_ideas}
   587→
   588→=== TASK ===
   589→1. Answer the question based on YOUR knowledge (from Obsidian vault) and core ideas
   590→2. FOLLOW THE ANTI-AI-TONE GUIDE FIRST, then the Zhihu style guide
   591→3. Share real experiences, examples, and insights from the vault material
   592→4. Write in Chinese (unless question implies English)
   593→5. Minimize first person "我": use it sparingly, not as filler. Observations and judgments can stand without "我认为" or "我觉得" as prefix.
   594→6. Be direct and opinionated. Attach a concrete evaluation to every fact or feature you mention (e.g. "暴力但有效", "极度脆弱"). Pure feature listing is forbidden.
   595→7. AVOID AT ALL COSTS: AI tone, empty buzzwords, hollow phrases, and 宏大叙事 (不可逆的浪潮, 范式转移, 时代大背景).
   596→8. If recent web context is provided, weave it in naturally — like someone who reads a lot online
   597→9. STRICT: Never add bracket explanations for any term. Write "大语言模型" not "大语言模型（LLM）". If a term needs explaining, use a sentence, not parentheses.
   598→10. No public account names, no self-promotion, no引流 of any kind — this is the highest priority rule.
   599→11. STRICT: Never reveal personal identity or professional background. Do not mention employer, team, job title, product name, or any detail that could identify who you are.
   600→12. STRICT: Never reference past projects, articles, or talks by name.
   601→13. No poetry, quotes, or "升华" endings. No首尾呼应 (repeating the opening point at the end). Stop when the point is made.
   602→14. LENGTH: 500–1000 Chinese characters. Cut a point, not quality.
   603→
   604→=== BEFORE SUBMITTING, CHECK THESE ===
   605→- Is there any AI-generated tone or phrasing?
   606→- Are there any buzzwords or jargon that need to be replaced with plain language?
   607→- Is the answer direct and opinionated, not neutral or "balanced"?
   608→- Does it sound like a real human wrote it, not a machine?
   609→
   610→Begin writing the answer:
   611→"""
   612→
   613→            logger.info(f"Generating Zhihu answer for: {question[:50]}...")
   614→            actual_user_message = prompt
   615→            built_rich_prompt = True
   616→
   617→        else:
   618→            # === Follow-up Turn: User gives refinement instructions (e.g. "make it shorter") ===


Perfect! Now I have a comprehensive understanding of the Zhihu integration. Let me create a final summary document:

## Summary: Zhihu Question Content Fetching Architecture

I've thoroughly analyzed the project's Zhihu integration. Here are my key findings:

### 1. **The `_fetch_question_title()` Method** (`/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py`, lines 256-307)

This method fetches **only the question title**, not the full description. Here's what it does:

```python
def _fetch_question_title(self, url: str) -> str:
    """Fetch the question title via Playwright (with saved session cookies).

    Falls back to a URL-derived label if Playwright is unavailable or fails.
    """
```

**Key behaviors:**
- Uses Playwright with `playwright-stealth` to avoid bot detection
- Sets Windows user-agent: `Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/122.0.0.0`
- Loads saved cookies from `/root/projects/butler/data/zhihu_state.json` if available
- Navigates to the question URL with `wait_until="domcontentloaded"` and 15-second timeout
- Waits 2 seconds for React to render
- Extracts the page `title()` which comes in format: `"问题标题 - 知乎"`
- Strips `" - 知乎"` suffix to get clean title
- **Falls back to `知乎问题 #{question_id}`** if Playwright fails

### 2. **Playwright-based Scraping** (`/root/projects/butler/slack_bot/zhihu/zhihu_playwright_engine.py`)

This is a comprehensive Zhihu browser automation engine that handles:

**Authentication:**
- Persistent cookies stored in `/root/projects/butler/data/zhihu_state.json` (JSON format)
- QR-code login flow with Slack notifications (sends QR code to user)
- Automatic session validation before operations
- Stealth mode to bypass anti-bot detection

**Answer Publishing Pipeline:**
1. Navigate to Zhihu home page first (to "warm up" the session)
2. Navigate to question URL with timeout handling
3. Detect 404 pages and login redirects
4. Find and click "写回答" (write answer) button with multiple CSS selector strategies
5. Fill answer text using three strategies (in order):
   - `execCommand('insertText')` - most reliable for Draft.js
   - Synthetic ClipboardEvent paste
   - Keyboard.type() with 8ms delay per character
6. Find and click publish button
7. Extract answer URL from published page

**Key selectors for DOM manipulation:**
- QR code: `.SignFlow-qrcode img`, `.LoginQrcode img`, canvas variants
- Write answer button: multiple selectors + JavaScript TreeWalker fallback (handles zero-width characters)
- Editor: `.DraftEditor-content[contenteditable='true']`, `.RichText [contenteditable='true']`, etc.
- Publish button: `button:has-text('发布回答')`, `.AnswerForm button:has-text('发布')`, etc.

### 3. **What's NOT Implemented: Full Question Content Fetching**

**Currently, there is NO method to fetch the full question description/details.** The flow only captures:

1. **From Tavily search results** (lines 626-675):
   - Title (from search result)
   - URL
   - Snippet (up to 200 chars from `item.get("content")`)

2. **From direct URL** (line 252):
   - Title only (via `_fetch_question_title()`)
   - URL

3. **What's available but not used:**
   - The `ZhihuQuestion` model (lines 61-72) has an optional `snippet` field
   - Tavily returns `content` which is used as the snippet
   - There's NO field for full question description

### 4. **Cookie Management**

- **Location:** `/root/projects/butler/data/zhihu_state.json`
- **Format:** JSON with `{"cookies": [...], "saved_at": timestamp}`
- **Loaded automatically** by Playwright in both `_fetch_question_title()` (line 283-287) and `publish_answer()` (line 141)
- **NO environment variable** like `ZHIHU_COOKIE` - it's only file-based
- **Only config:** `ZHIHU_NOTIFY_CHANNEL=D0AACF9G4J0` in `.env` (for Slack notifications)

### 5. **Architecture Flow**

```
User inputs URL/keywords
    ↓
ZhihuHunter.hunt_direct_url() or scan_and_hunt()
    ↓
_fetch_question_title() [Playwright] → title only
    ↓
Tavily API search → title + snippet (200 chars)
    ↓
ZhihuQuestion model (with optional snippet field)
    ↓
SlackInteractiveGateway.post_question_cards() → Slack Block Kit UI
    ↓
User clicks "生成草稿" (Generate Draft)
    ↓
ZhihuHunter.draft_answer() [uses title + snippet + vault RAG, NOT full question]
    ↓
Generate answer draft
    ↓
User clicks "确认发布" (Confirm Publish)
    ↓
ZhihuPlaywrightEngine.publish_answer() → Playwright automation
```

### 6. **Key Limitations**

1. **Only title + snippet available** - the full question description is never fetched
2. **Snippet is limited to 200 chars** - from Tavily, not from Zhihu directly
3. **No direct Zhihu API access** - relies on web scraping via Playwright
4. **Can't fetch without valid session** - Zhihu blocks headless browsers accessing full content without authentication
5. **No method to extract question details** from the Playwright page after loading

### 7. **What Might Be Failing**

If you're trying to fetch full question content, the issues could be:

1. **Invalid/expired cookies** - `zhihu_state.json` may be outdated (Zhihu cookies expire)
2. **Zhihu DOM changed** - selectors in lines 25-72 of `zhihu_playwright_engine.py` may be stale
3. **Anti-bot detection** - even with stealth mode, Zhihu's anti-scraping may block Playwright
4. **Missing question description field** - no field exists in `ZhihuQuestion` model to store it
5. **Playwright timeout** - 15-second timeout in `_fetch_question_title()` may be insufficient

### File Paths Summary

- **Main hunter:** `/root/projects/butler/slack_bot/zhihu/zhihu_hunter.py` (676 lines)
- **Playwright engine:** `/root/projects/butler/slack_bot/zhihu/zhihu_playwright_engine.py` (706 lines)
- **Interactive Slack gateway:** `/root/projects/butler/slack_bot/zhihu/slack_interactive_gateway.py` (655 lines)
- **Obsidian dispatcher integration:** `/root/projects/butler/slack_bot/obsidian/dispatcher.py` (starts at line 159 for Zhihu mode)
- **Zhihu style guides:** `/root/projects/butler/zhihu-bot-style.md`, `/root/projects/butler/zhihu-style.md`
- **Configuration:** `.env` contains `ZHIHU_NOTIFY_CHANNEL` only
- **Cookies:** `/root/projects/butler/data/zhihu_state.json` (only created after first QR login)