Ctrl K
ComunidadGuías…

butler/gateway/wechat_text_export.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Package long Owner-facing text as .txt/.md exports for WeChat file delivery."""

from __future__ import annotations

import os
import re
from datetime import datetime, timezone
from pathlib import Path

from typing import Any

from butler.configuration.settings import get_butler_home
from butler.defaults.env_defaults import (
    WECHAT_ATTACH_MIN_CHARS,
    WECHAT_ATTACH_BRIEF_CHARS,
    WECHAT_ATTACH_DELEGATE_DEFAULT,
    WECHAT_ATTACH_DETAIL_DEFAULT,
    WECHAT_ATTACH_DIAGNOSTIC_DEFAULT,
    WECHAT_ATTACH_RUNTIME_DEFAULT,
    WECHAT_ATTACH_SUFFIX_DEFAULT,
)
from butler.gateway.outbound_files import (
    append_wechat_file_delivery_line,
    export_wechat_file_enabled,
    export_wechat_max_bytes,
)
from butler.gateway.pii_scrub import scrub_outbound_text
from butler.report import format_detail, format_for_wechat
from butler.utilities.env_parse import env_truthy, int_env


def is_wechat_platform(platform: str) -> bool:
    return str(platform or "").strip().lower() in ("wechat", "weixin")


def attach_min_chars() -> int:
    return int(int_env("BUTLER_WECHAT_ATTACH_MIN_CHARS", WECHAT_ATTACH_MIN_CHARS, min=0))


def wechat_attach_brief_chars() -> int:
    """Max chars for WeChat chat bubble when a file attachment is also sent."""
    return int(int_env("BUTLER_WECHAT_ATTACH_BRIEF_CHARS", WECHAT_ATTACH_BRIEF_CHARS, min=80))


def _cap_wechat_attach_summary(text: str) -> str:
    cap = wechat_attach_brief_chars()
    body = (text or "").strip()
    if len(body) <= cap:
        return body
    return body[: cap - 1].rstrip() + "…"


def attach_delegate_enabled() -> bool:
    return bool(env_truthy("BUTLER_WECHAT_ATTACH_DELEGATE", default=WECHAT_ATTACH_DELEGATE_DEFAULT) and export_wechat_file_enabled())


def attach_detail_enabled() -> bool:
    return bool(env_truthy("BUTLER_WECHAT_ATTACH_DETAIL", default=WECHAT_ATTACH_DETAIL_DEFAULT) and export_wechat_file_enabled())


def attach_diagnostic_enabled() -> bool:
    return bool(env_truthy("BUTLER_WECHAT_ATTACH_DIAGNOSTIC", default=WECHAT_ATTACH_DIAGNOSTIC_DEFAULT) and export_wechat_file_enabled())


def attach_runtime_enabled() -> bool:
    return bool(env_truthy("BUTLER_WECHAT_ATTACH_RUNTIME", default=WECHAT_ATTACH_RUNTIME_DEFAULT) and export_wechat_file_enabled())


def wechat_attach_suffix() -> str:
    """WeChat file attachment extension; default ``.txt`` (phone-friendly plain text)."""
    raw = os.getenv("BUTLER_WECHAT_ATTACH_SUFFIX", WECHAT_ATTACH_SUFFIX_DEFAULT).strip().lower()
    if raw in ("txt", ".txt"):
        return ".txt"
    if raw in ("md", "markdown", ".md", ".markdown"):
        return ".md" if raw.startswith(".") else f".{raw}"
    return ".txt"


def _safe_segment(value: str) -> str:
    raw = str(value or "").strip() or "export"
    return re.sub(r"[^a-zA-Z0-9._+-]+", "_", raw)[:80] or "export"


def _export_dir(workspace: Path | None) -> Path:
    if workspace is not None:
        out = Path(workspace) / ".butler" / "exports"
    else:
        out = get_butler_home() / "exports"
    out.mkdir(parents=True, exist_ok=True)
    return out


def write_text_export(
    body: str,
    *,
    name_prefix: str,
    workspace: Path | None = None,
    suffix: str | None = None,
) -> Path | None:
    """Write scrubbed text under exports/; return path or None on failure."""
    text = scrub_outbound_text(str(body or ""))
    if not text.strip():
        return None

    ext = suffix if suffix is not None else wechat_attach_suffix()
    ext = ext if ext.startswith(".") else f".{ext}"
    if ext.lower() not in (".md", ".markdown", ".txt"):
        ext = wechat_attach_suffix()

    max_bytes = export_wechat_max_bytes()
    encoded = text.encode("utf-8")
    if len(encoded) > max_bytes:
        text = (
            encoded[: max_bytes - 80].decode("utf-8", errors="ignore")
            + "\n\n…(已截断至微信附件大小上限)\n"
        )

    stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
    name = f"{_safe_segment(name_prefix)}_{stamp}{ext}"
    path = _export_dir(workspace) / name
    try:
        path.write_text(text, encoding="utf-8")
    except OSError:
        return None
    return path


def maybe_attach_wechat_file(
    chat_text: str,
    full_body: str,
    *,
    platform: str,
    name_prefix: str,
    workspace: Path | None = None,
    enabled: bool = True,
    min_chars: int | None = None,
    suffix: str | None = None,
    attach_hint: str = "(完整内容见附件)",
) -> str:
    """
    On WeChat, when ``full_body`` is long enough, write it to exports/ and
    append a deliverable path line for ``WeChatAdapter.send()``.
    """
    if not is_wechat_platform(platform):
        return chat_text if chat_text.strip() else full_body
    if not enabled or not export_wechat_file_enabled():
        return chat_text if chat_text.strip() else full_body

    full = str(full_body or "")
    threshold = attach_min_chars() if min_chars is None else max(0, int(min_chars))
    if len(full) < threshold:
        return chat_text if chat_text.strip() else full

    path = write_text_export(
        full,
        name_prefix=name_prefix,
        workspace=workspace,
        suffix=suffix,
    )
    if path is None:
        return chat_text if chat_text.strip() else full

    summary = (chat_text or "").strip()
    if not summary:
        summary = full[: min(wechat_attach_brief_chars(), threshold)]
    summary = _cap_wechat_attach_summary(summary)
    if attach_hint not in summary:
        summary = f"{summary}\n\n{attach_hint}"

    return str(append_wechat_file_delivery_line(summary, path))


def build_delegate_completion_message(
    report: Any,
    *,
    prefix: str = "",
    platform: str = "wechat",
    workspace: Path | None = None,
) -> str:
    """Compact WeChat text plus optional full report file attachment."""
    body = format_for_wechat(report)
    chat = f"{prefix}\n\n{body}".strip() if prefix else body
    if not attach_delegate_enabled() or not is_wechat_platform(platform):
        return chat

    tid = str(getattr(report, "task_id", "") or "report").strip() or "report"
    full = format_detail(report)
    return maybe_attach_wechat_file(
        chat,
        full,
        platform=platform,
        name_prefix=f"delegate_{tid}",
        workspace=workspace,
        enabled=True,
        attach_hint="(完整委派报告见 .txt 附件)",
    )


__all__ = [
    "attach_delegate_enabled",
    "attach_detail_enabled",
    "attach_diagnostic_enabled",
    "attach_runtime_enabled",
    "attach_min_chars",
    "wechat_attach_brief_chars",
    "build_delegate_completion_message",
    "is_wechat_platform",
    "maybe_attach_wechat_file",
    "wechat_attach_suffix",
    "write_text_export",
]