Ctrl K
ComunidadGuías…

butler/tools/contacts.py

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

Archivo original ↗Preguntar a KiBot
"""Personal contacts manager — cross-project address book.

Contacts are tenant-scoped, stored under
``~/.butler/tenants/<tenant>/contacts/`` as one JSON file per entry.

Unlike cognitive memory (butler_remember), contacts are structured records
with phone/email/address fields, optimized for name-based lookup.
"""

from __future__ import annotations

import json
import logging
import time
import uuid
from pathlib import Path
from typing import Any, Callable, cast

from butler.tools.pim_schema import (
    CONTACT_CATEGORIES as _VALID_CATEGORIES,
    MAX_CONTACTS as _MAX_CONTACTS,
    MAX_CONTACT_NOTES as _MAX_CONTACT_NOTES,
    MAX_CONTACT_TAGS as _MAX_CONTACT_TAGS,
    MAX_EMAILS as _MAX_EMAILS,
    MAX_PHONES as _MAX_PHONES,
    MAX_TAG_LEN as _MAX_TAG_LEN,
)
from butler.tools.tenant_store import TenantStore

logger = logging.getLogger(__name__)

_base_store = TenantStore("contacts", env_toggle="BUTLER_CONTACTS_ENABLED")
_store = _base_store


def _contacts_enabled() -> bool:
    return bool(_store.enabled())


def _contacts_dir() -> Path:
    return Path(_base_store.storage_dir())


def _normalize_phones(raw: Any) -> list[str]:
    if not raw:
        return []
    if isinstance(raw, str):
        raw = [p.strip() for p in raw.replace(",", ",").replace(";", ",").split(",")]
    if not isinstance(raw, list):
        return []
    phones: list[str] = []
    for p in raw:
        s = str(p).strip()
        if s and s not in phones:
            phones.append(s)
        if len(phones) >= _MAX_PHONES:
            break
    return phones


def _normalize_emails(raw: Any) -> list[str]:
    if not raw:
        return []
    if isinstance(raw, str):
        raw = [e.strip() for e in raw.replace(",", ",").replace(";", ",").split(",")]
    if not isinstance(raw, list):
        return []
    emails: list[str] = []
    for e in raw:
        s = str(e).strip()
        if s and s not in emails:
            emails.append(s)
        if len(emails) >= _MAX_EMAILS:
            break
    return emails


def _normalize_tags(raw: Any) -> list[str]:
    if not raw:
        return []
    if isinstance(raw, str):
        raw = [t.strip() for t in raw.replace(",", ",").split(",")]
    if not isinstance(raw, list):
        return []
    tags: list[str] = []
    for t in raw:
        s = str(t).strip()[:_MAX_TAG_LEN]
        if s and s not in tags:
            tags.append(s)
        if len(tags) >= _MAX_CONTACT_TAGS:
            break
    return tags


def _normalize_category(raw: Any) -> str:
    val = str(raw or "").strip().lower()
    return val if val in _VALID_CATEGORIES else "personal"


def _find_by_name(name: str) -> list[dict[str, Any]]:
    """Find contacts by exact or partial name match."""
    q = name.strip().lower()
    if not q:
        return []
    all_contacts = _store.load_all()
    exact = [c for c in all_contacts if c.get("name", "").lower() == q]
    if exact:
        return exact
    return [c for c in all_contacts if q in c.get("name", "").lower()]


def _check_duplicate(name: str, exclude_id: str = "") -> dict[str, Any] | None:
    """Check if a contact with the same name already exists."""
    q = name.strip().lower()
    for c in _store.load_all():
        if c.get("name", "").lower() == q and c.get("id") != exclude_id:
            return cast(dict[str, Any], c)
    return None


# ── Tool Handlers ──────────────────────────────────────────────


def tool_contact_add(
    name: str,
    phone: Any = None,
    email: Any = None,
    address: str = "",
    category: str = "",
    tags: Any = None,
    notes: str = "",
    **_: Any,
) -> str:
    if not _contacts_enabled():
        return json.dumps({"ok": False, "error": "BUTLER_CONTACTS_ENABLED=0"})

    name = (name or "").strip()[:100]
    if not name:
        return json.dumps({"ok": False, "error": "name is required"})

    total = len(_store.load_all())
    if total >= _MAX_CONTACTS:
        return json.dumps({
            "ok": False,
            "error": f"Contact limit reached ({_MAX_CONTACTS}). Delete some first.",
        })

    dup = _check_duplicate(name)
    now = time.time()
    cid = uuid.uuid4().hex[:10]

    contact: dict[str, Any] = {
        "id": cid,
        "name": name,
        "phone": _normalize_phones(phone),
        "email": _normalize_emails(email),
        "address": (address or "").strip()[:500],
        "category": _normalize_category(category),
        "tags": _normalize_tags(tags),
        "notes": (notes or "").strip()[:_MAX_CONTACT_NOTES],
        "created_at": now,
        "updated_at": now,
    }
    _store.save(contact)

    result: dict[str, Any] = {
        "ok": True,
        "contact_id": cid,
        "name": name,
    }
    if dup:
        result["warning"] = f"已存在同名联系人 [{dup['id'][:4]}] {dup['name']},如需合并请更新该条目"
    return json.dumps(result, ensure_ascii=False)


def tool_contact_find(
    query: str = "",
    category: str = "",
    limit: int = 20,
    **_: Any,
) -> str:
    if not _contacts_enabled():
        return json.dumps({"ok": False, "error": "BUTLER_CONTACTS_ENABLED=0"})

    q = (query or "").strip().lower()
    all_contacts = _store.load_all()

    if q:
        matched: list[dict[str, Any]] = []
        for c in all_contacts:
            searchable = " ".join([
                c.get("name", ""),
                " ".join(c.get("phone", [])),
                " ".join(c.get("email", [])),
                c.get("address", ""),
                " ".join(c.get("tags", [])),
                c.get("notes", ""),
            ]).lower()
            if q in searchable:
                matched.append(c)
    else:
        matched = list(all_contacts)

    if category:
        cat = _normalize_category(category)
        matched = [c for c in matched if c.get("category") == cat]

    matched.sort(key=lambda c: c.get("name", "").lower())
    limit = max(1, min(int(limit or 20), 50))

    return json.dumps({
        "ok": True,
        "query": query or "",
        "count": len(matched),
        "contacts": matched[:limit],
    }, ensure_ascii=False)


def tool_contact_update(
    contact_id: str,
    name: str = "",
    phone: Any = None,
    email: Any = None,
    address: str = "",
    category: str = "",
    tags: Any = None,
    notes: str = "",
    **_: Any,
) -> str:
    if not _contacts_enabled():
        return json.dumps({"ok": False, "error": "BUTLER_CONTACTS_ENABLED=0"})

    cid = (contact_id or "").strip()
    if not cid:
        return json.dumps({"ok": False, "error": "contact_id is required"})

    contact = _store.find_by_prefix(cid)
    if contact is None:
        return json.dumps({"ok": False, "error": f"Contact '{cid}' not found"})
    cid = contact["id"]

    changed = False
    if name and name.strip():
        contact["name"] = name.strip()
        changed = True
    if phone is not None:
        contact["phone"] = _normalize_phones(phone)
        changed = True
    if email is not None:
        contact["email"] = _normalize_emails(email)
        changed = True
    if address is not None and address != "":
        contact["address"] = address.strip()[:500]
        changed = True
    if category and category.strip():
        new_cat = _normalize_category(category)
        if new_cat != contact.get("category"):
            contact["category"] = new_cat
            changed = True
    if tags is not None:
        contact["tags"] = _normalize_tags(tags)
        changed = True
    if notes is not None and notes != "":
        contact["notes"] = notes.strip()[:_MAX_CONTACT_NOTES]
        changed = True

    if changed:
        contact["updated_at"] = time.time()
        _store.save(contact)

    return json.dumps({
        "ok": True,
        "contact_id": cid,
        "updated": changed,
        "name": contact.get("name"),
    }, ensure_ascii=False)


def tool_contact_delete(contact_id: str, **_: Any) -> str:
    if not _contacts_enabled():
        return json.dumps({"ok": False, "error": "BUTLER_CONTACTS_ENABLED=0"})

    cid = (contact_id or "").strip()
    if not cid:
        return json.dumps({"ok": False, "error": "contact_id is required"})

    contact = _store.find_by_prefix(cid)
    if contact is not None and _store.delete(contact["id"]):
        return json.dumps({"ok": True, "deleted": contact["id"]})

    return json.dumps({"ok": False, "error": f"Contact '{cid}' not found"})


def tool_contact_list(
    category: str = "",
    limit: int = 30,
    **_: Any,
) -> str:
    if not _contacts_enabled():
        return json.dumps({"ok": False, "error": "BUTLER_CONTACTS_ENABLED=0"})

    all_contacts = _store.load_all()
    filtered = list(all_contacts)

    if category:
        cat = _normalize_category(category)
        filtered = [c for c in filtered if c.get("category") == cat]

    filtered.sort(key=lambda c: c.get("name", "").lower())
    limit = max(1, min(int(limit or 30), 50))

    cat_counts: dict[str, int] = {}
    for c in all_contacts:
        k = c.get("category", "personal")
        cat_counts[k] = cat_counts.get(k, 0) + 1

    return json.dumps({
        "ok": True,
        "total": len(all_contacts),
        "count": len(filtered),
        "categories": cat_counts,
        "contacts": filtered[:limit],
    }, ensure_ascii=False)


# ── WeChat Display ─────────────────────────────────────────────


def format_contacts_for_wechat(arg: str = "", *, limit: int = 20) -> str:
    if not _contacts_enabled():
        return "通讯录功能未启用 (BUTLER_CONTACTS_ENABLED=0)"

    arg = (arg or "").strip()

    if arg.startswith("添加 ") or arg.startswith("add "):
        rest = arg.split(maxsplit=1)[1] if " " in arg else ""
        if rest:
            raw = tool_contact_add(name=rest)
            data = json.loads(raw)
            if data.get("ok"):
                msg = f"✅ 联系人已添加: {rest} [{data['contact_id'][:4]}]"
                if data.get("warning"):
                    msg += f"\n⚠️ {data['warning']}"
                return msg
            return f"❌ {data.get('error', '添加失败')}"
        return "用法: /通讯录 添加 <姓名>"

    if arg.startswith("找 ") or arg.startswith("查 ") or arg.startswith("find "):
        query = arg.split(maxsplit=1)[1] if " " in arg else ""
        if query:
            return _format_find_result(query, limit=limit)
        return "用法: /通讯录 找 <姓名或关键词>"

    if arg.startswith("删除 ") or arg.startswith("delete "):
        cid = arg.split(maxsplit=1)[1].strip() if " " in arg else ""
        if cid:
            raw = tool_contact_delete(contact_id=cid)
            data = json.loads(raw)
            if data.get("ok"):
                return f"✅ 联系人 [{data['deleted'][:4]}] 已删除"
            return f"❌ {data.get('error', '操作失败')}"
        return "用法: /通讯录 删除 <id>"

    return _format_contact_list(limit=limit)


def _format_contact_list(*, limit: int = 20) -> str:
    all_contacts = _store.load_all()
    all_contacts.sort(key=lambda c: c.get("name", "").lower())

    if not all_contacts:
        return "📒 通讯录为空\n\n添加: /通讯录 添加 <姓名>\n或对话中说「帮我记一下XX的联系方式」"

    lines = [f"📒 通讯录 ({len(all_contacts)}人)\n"]
    cat_icons = {
        "personal": "👤", "work": "💼", "service": "🔧",
        "medical": "🏥", "other": "📋",
    }

    for c in all_contacts[:limit]:
        icon = cat_icons.get(c.get("category", "personal"), "👤")
        cid = c["id"][:4]
        name = c.get("name", "")
        line = f"{icon} [{cid}] {name}"

        details = []
        phones = c.get("phone", [])
        if phones:
            details.append(f"📞 {phones[0]}")
        emails = c.get("email", [])
        if emails:
            details.append(f"📧 {emails[0]}")
        if details:
            line += f"  {'  '.join(details)}"
        lines.append(line)

    if len(all_contacts) > limit:
        lines.append(f"\n... 还有 {len(all_contacts) - limit} 人")
    lines.append("\n/通讯录 找 <姓名>  查找联系人")
    return "\n".join(lines)


def _format_find_result(query: str, *, limit: int = 20) -> str:
    raw = tool_contact_find(query=query, limit=limit)
    data = json.loads(raw)
    contacts = data.get("contacts", [])
    if not contacts:
        return f"🔍 未找到「{query}」相关联系人"

    lines = [f"🔍 搜索「{query}」找到 {len(contacts)} 人\n"]
    for c in contacts:
        cid = c["id"][:4]
        name = c.get("name", "")
        line = f"[{cid}] {name}"
        details = []
        for p in c.get("phone", []):
            details.append(f"📞{p}")
        for e in c.get("email", []):
            details.append(f"📧{e}")
        if c.get("address"):
            details.append(f"📍{c['address'][:30]}")
        if details:
            line += f"\n   {'  '.join(details)}"
        if c.get("notes"):
            line += f"\n   💬 {c['notes'][:50]}"
        lines.append(line)
    return "\n".join(lines)


# ── Registration ───────────────────────────────────────────────


def register_contact_tools(register: Callable[..., None]) -> None:
    if not _contacts_enabled():
        return

    register(
        name="contact_add",
        description=(
            "【mutation·create】Insert a new contact card (name, phone, email, tags, notes). "
            "场景:首次记录某人联系方式。不执行模糊查找。"
        ),
        schema={
            "type": "object",
            "properties": {
                "name": {"type": "string", "description": "联系人姓名"},
                "phone": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "电话号码列表",
                },
                "email": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "邮箱列表",
                },
                "address": {"type": "string", "description": "地址"},
                "category": {
                    "type": "string",
                    "enum": list(_VALID_CATEGORIES),
                    "description": "分类: personal/work/service/medical/other",
                },
                "tags": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "标签列表(如:牙科、装修、大学同学)",
                },
                "notes": {"type": "string", "description": "备注信息"},
            },
            "required": ["name"],
        },
        handler=tool_contact_add,
        toolset="contacts",
    )

    register(
        name="contact_find",
        description=(
            "【read-only·lookup】Fuzzy search existing contact cards by query/category. "
            "场景:找电话/邮箱。不创建新卡片。"
        ),
        schema={
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "搜索关键词(姓名/电话/标签/备注)"},
                "category": {
                    "type": "string",
                    "enum": list(_VALID_CATEGORIES),
                    "description": "按分类筛选",
                },
                "limit": {"type": "integer", "description": "最多返回条数"},
            },
            "required": ["query"],
        },
        handler=tool_contact_find,
        toolset="contacts",
    )

    register(
        name="contact_update",
        description="更新联系人信息。可修改电话、邮箱、地址、备注等。",
        schema={
            "type": "object",
            "properties": {
                "contact_id": {"type": "string", "description": "联系人 ID(支持前缀匹配)"},
                "name": {"type": "string", "description": "新姓名"},
                "phone": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "新电话列表(替换原有)",
                },
                "email": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "新邮箱列表(替换原有)",
                },
                "address": {"type": "string", "description": "新地址"},
                "category": {
                    "type": "string",
                    "enum": list(_VALID_CATEGORIES),
                    "description": "新分类",
                },
                "tags": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "新标签列表(替换原有)",
                },
                "notes": {"type": "string", "description": "新备注"},
            },
            "required": ["contact_id"],
        },
        handler=tool_contact_update,
        toolset="contacts",
    )

    register(
        name="contact_delete",
        description="删除一个联系人。",
        schema={
            "type": "object",
            "properties": {
                "contact_id": {"type": "string", "description": "联系人 ID(支持前缀匹配)"},
            },
            "required": ["contact_id"],
        },
        handler=tool_contact_delete,
        toolset="contacts",
    )

    register(
        name="contact_list",
        description="列出所有联系人,可按分类筛选。",
        schema={
            "type": "object",
            "properties": {
                "category": {
                    "type": "string",
                    "enum": list(_VALID_CATEGORIES),
                    "description": "按分类筛选",
                },
                "limit": {"type": "integer", "description": "最多返回条数"},
            },
        },
        handler=tool_contact_list,
        toolset="contacts",
    )