| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047 |
- from __future__ import annotations
- import asyncio
- import hashlib
- import re
- from datetime import UTC, datetime, timedelta
- from typing import Any
- from uuid import uuid4
- from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
- from pymongo import ASCENDING, DESCENDING
- from pymongo.errors import DuplicateKeyError
- from wbb import BOT_PROFILE_ID, db
- connectionsdb = db.business_assistant_connections
- settingsdb = db.business_assistant_settings
- knowledgedb = db.business_assistant_knowledge
- conversationsdb = db.business_assistant_conversations
- messagesdb = db.business_assistant_messages
- usagedb = db.business_assistant_usage
- updatesdb = db.business_assistant_updates
- deadlettersdb = db.business_assistant_dead_letters
- runtimedb = db.business_assistant_runtime
- DEFAULT_ACCOUNT_SETTINGS: dict[str, Any] = {
- "assistant_enabled": False,
- "system_prompt": (
- "你是该 Telegram 账号的智能接待秘书。回答要简洁、礼貌,只能依据提供的知识条目陈述业务事实。"
- ),
- "language": "zh-CN",
- "tone": "professional",
- "account_daily_limit": 200,
- "customer_daily_limit": 20,
- "human_pause_hours": 24,
- "notification_destination": "owner",
- "ops_group_id": 0,
- "timezone": "Asia/Shanghai",
- "digest_enabled": False,
- "digest_time": "09:00",
- "handoff_message": "这个问题需要人工确认,我已经通知负责人,请稍候。",
- "unsupported_message": "已收到你的消息,这类内容需要人工处理,我已经通知负责人。",
- }
- VALID_NOTIFICATION_DESTINATIONS = {"owner", "ops", "both"}
- VALID_TONES = {"professional", "friendly", "concise"}
- HANDOFF_TERMS = (
- "人工",
- "真人",
- "客服",
- "负责人",
- "转人工",
- "human",
- "agent",
- )
- SENSITIVE_TERMS = (
- "承诺",
- "保证",
- "投诉",
- "退款",
- "退钱",
- "赔偿",
- "律师",
- "起诉",
- "支付失败",
- "账号被盗",
- "密码",
- "验证码",
- )
- _index_lock = asyncio.Lock()
- _indexes_ready = False
- class AssistantDataError(ValueError):
- def __init__(self, code: str, message: str):
- super().__init__(message)
- self.code = code
- def utc_now() -> datetime:
- return datetime.now(UTC)
- def as_utc(value: datetime) -> datetime:
- return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
- def _scope(filters: dict[str, Any] | None = None, /, **values: Any) -> dict[str, Any]:
- return {"bot_id": BOT_PROFILE_ID, **(filters or {}), **values}
- def clean_text(
- value: Any,
- *,
- max_length: int,
- required: bool = False,
- preserve_lines: bool = False,
- ) -> str:
- text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", str(value or ""))
- text = text.strip()
- if not preserve_lines:
- text = " ".join(text.split())
- if required and not text:
- raise AssistantDataError("required_field", "必填内容不能为空。")
- if len(text) > max_length:
- raise AssistantDataError("text_too_long", f"内容不能超过 {max_length} 个字符。")
- return text
- def _bounded_int(
- value: Any,
- name: str,
- *,
- minimum: int,
- maximum: int,
- ) -> int:
- try:
- parsed = int(value)
- except (TypeError, ValueError) as exc:
- raise AssistantDataError("invalid_setting", f"{name} 必须是整数。") from exc
- if not minimum <= parsed <= maximum:
- raise AssistantDataError(
- "invalid_setting",
- f"{name} 必须在 {minimum} 到 {maximum} 之间。",
- )
- return parsed
- def _normalize_string_list(value: Any, *, max_items: int, max_length: int) -> list[str]:
- values = value if isinstance(value, (list, tuple, set)) else str(value or "").split(",")
- normalized: list[str] = []
- seen: set[str] = set()
- for item in values:
- text = clean_text(item, max_length=max_length)
- key = text.casefold()
- if text and key not in seen:
- normalized.append(text)
- seen.add(key)
- if len(normalized) > max_items:
- raise AssistantDataError("too_many_items", f"最多允许 {max_items} 项。")
- return normalized
- async def ensure_assistant_indexes() -> None:
- global _indexes_ready
- if _indexes_ready:
- return
- async with _index_lock:
- if _indexes_ready:
- return
- await connectionsdb.create_index(
- [("bot_id", ASCENDING), ("connection_id", ASCENDING)], unique=True
- )
- await connectionsdb.create_index(
- [("bot_id", ASCENDING), ("updated_at", DESCENDING)]
- )
- await settingsdb.create_index(
- [("bot_id", ASCENDING), ("connection_id", ASCENDING)], unique=True
- )
- await knowledgedb.create_index(
- [("bot_id", ASCENDING), ("entry_id", ASCENDING)], unique=True
- )
- await knowledgedb.create_index(
- [
- ("bot_id", ASCENDING),
- ("connection_id", ASCENDING),
- ("enabled", ASCENDING),
- ("priority", DESCENDING),
- ]
- )
- await conversationsdb.create_index(
- [("bot_id", ASCENDING), ("conversation_id", ASCENDING)], unique=True
- )
- await conversationsdb.create_index(
- [
- ("bot_id", ASCENDING),
- ("connection_id", ASCENDING),
- ("chat_id", ASCENDING),
- ],
- unique=True,
- )
- await conversationsdb.create_index(
- [
- ("bot_id", ASCENDING),
- ("connection_id", ASCENDING),
- ("status", ASCENDING),
- ("updated_at", DESCENDING),
- ]
- )
- await messagesdb.create_index(
- [("bot_id", ASCENDING), ("message_key", ASCENDING)], unique=True
- )
- await messagesdb.create_index(
- [
- ("bot_id", ASCENDING),
- ("conversation_id", ASCENDING),
- ("created_at", ASCENDING),
- ]
- )
- await messagesdb.create_index("expires_at", expireAfterSeconds=0)
- await usagedb.create_index(
- [
- ("bot_id", ASCENDING),
- ("connection_id", ASCENDING),
- ("chat_id", ASCENDING),
- ("day", ASCENDING),
- ],
- unique=True,
- )
- await updatesdb.create_index(
- [("bot_id", ASCENDING), ("update_id", ASCENDING)], unique=True
- )
- await updatesdb.create_index("expires_at", expireAfterSeconds=0)
- await deadlettersdb.create_index(
- [("bot_id", ASCENDING), ("update_id", ASCENDING)], unique=True
- )
- await deadlettersdb.create_index(
- [("bot_id", ASCENDING), ("created_at", DESCENDING)]
- )
- await deadlettersdb.create_index("expires_at", expireAfterSeconds=0)
- await runtimedb.create_index(
- [("bot_id", ASCENDING), ("runtime_id", ASCENDING)], unique=True
- )
- _indexes_ready = True
- def _public_user(user: Any) -> dict[str, Any]:
- value = user if isinstance(user, dict) else {}
- return {
- "id": int(value.get("id") or 0),
- "username": clean_text(value.get("username"), max_length=64),
- "first_name": clean_text(value.get("first_name"), max_length=128),
- "last_name": clean_text(value.get("last_name"), max_length=128),
- }
- async def upsert_business_connection(payload: dict[str, Any]) -> dict[str, Any]:
- await ensure_assistant_indexes()
- connection_id = clean_text(payload.get("id"), max_length=128, required=True)
- now = utc_now()
- rights = payload.get("rights") if isinstance(payload.get("rights"), dict) else {}
- document = {
- "bot_id": BOT_PROFILE_ID,
- "connection_id": connection_id,
- "user": _public_user(payload.get("user")),
- "user_chat_id": int(payload.get("user_chat_id") or 0),
- "rights": {str(key): bool(value) for key, value in rights.items() if value is True},
- "is_enabled": bool(payload.get("is_enabled")),
- "connected_at": datetime.fromtimestamp(int(payload.get("date") or 0), UTC)
- if payload.get("date")
- else now,
- "last_event_at": now,
- "updated_at": now,
- }
- await connectionsdb.update_one(
- _scope(connection_id=connection_id),
- {"$set": document, "$setOnInsert": {"created_at": now}},
- upsert=True,
- )
- await settingsdb.update_one(
- _scope(connection_id=connection_id),
- {
- "$setOnInsert": {
- "bot_id": BOT_PROFILE_ID,
- "connection_id": connection_id,
- **DEFAULT_ACCOUNT_SETTINGS,
- "created_at": now,
- "updated_at": now,
- }
- },
- upsert=True,
- )
- return await get_business_connection(connection_id) or document
- async def get_business_connection(connection_id: str) -> dict[str, Any] | None:
- await ensure_assistant_indexes()
- return await connectionsdb.find_one(_scope(connection_id=str(connection_id)))
- async def touch_business_connection(
- connection_id: str, *, error: str = "", is_enabled: bool | None = None
- ) -> dict[str, Any] | None:
- now = utc_now()
- values: dict[str, Any] = {
- "last_event_at": now,
- "updated_at": now,
- "last_error": clean_text(error, max_length=1000),
- }
- if is_enabled is not None:
- values["is_enabled"] = bool(is_enabled)
- await connectionsdb.update_one(
- _scope(connection_id=str(connection_id)),
- {"$set": values},
- )
- return await get_business_connection(connection_id)
- async def list_business_connections(
- *, page: int = 1, page_size: int = 20
- ) -> tuple[list[dict[str, Any]], int]:
- await ensure_assistant_indexes()
- page = max(1, int(page))
- page_size = max(1, min(int(page_size), 100))
- filters = _scope()
- total = await connectionsdb.count_documents(filters)
- cursor = (
- connectionsdb.find(filters)
- .sort("updated_at", DESCENDING)
- .skip((page - 1) * page_size)
- .limit(page_size)
- )
- items = []
- async for item in cursor:
- item["settings"] = await get_account_settings(item["connection_id"])
- items.append(item)
- return items, total
- async def get_account_settings(connection_id: str) -> dict[str, Any]:
- await ensure_assistant_indexes()
- stored = await settingsdb.find_one(_scope(connection_id=str(connection_id))) or {}
- return {
- **DEFAULT_ACCOUNT_SETTINGS,
- **{key: value for key, value in stored.items() if key != "_id"},
- "connection_id": str(connection_id),
- }
- def normalize_account_settings(
- values: dict[str, Any], *, previous: dict[str, Any] | None = None
- ) -> dict[str, Any]:
- current = {**DEFAULT_ACCOUNT_SETTINGS, **(previous or {})}
- if "assistant_enabled" in values:
- current["assistant_enabled"] = bool(values.get("assistant_enabled"))
- if "system_prompt" in values:
- current["system_prompt"] = clean_text(
- values.get("system_prompt"), max_length=4000, required=True, preserve_lines=True
- )
- if "language" in values:
- current["language"] = clean_text(values.get("language"), max_length=32, required=True)
- if "tone" in values:
- tone = str(values.get("tone") or "")
- if tone not in VALID_TONES:
- raise AssistantDataError("invalid_setting", "接待语气无效。")
- current["tone"] = tone
- if "account_daily_limit" in values:
- current["account_daily_limit"] = _bounded_int(
- values.get("account_daily_limit"), "账号每日额度", minimum=1, maximum=100000
- )
- if "customer_daily_limit" in values:
- current["customer_daily_limit"] = _bounded_int(
- values.get("customer_daily_limit"), "客户每日额度", minimum=1, maximum=1000
- )
- if "human_pause_hours" in values:
- current["human_pause_hours"] = _bounded_int(
- values.get("human_pause_hours"), "人工暂停小时数", minimum=1, maximum=168
- )
- if "notification_destination" in values:
- destination = str(values.get("notification_destination") or "")
- if destination not in VALID_NOTIFICATION_DESTINATIONS:
- raise AssistantDataError("invalid_setting", "通知目的地无效。")
- current["notification_destination"] = destination
- if "ops_group_id" in values:
- try:
- current["ops_group_id"] = int(values.get("ops_group_id") or 0)
- except (TypeError, ValueError) as exc:
- raise AssistantDataError("invalid_setting", "运营群 ID 必须是整数。") from exc
- if "timezone" in values:
- timezone_name = clean_text(values.get("timezone"), max_length=64, required=True)
- try:
- ZoneInfo(timezone_name)
- except ZoneInfoNotFoundError as exc:
- raise AssistantDataError("invalid_setting", "时区名称无效。") from exc
- current["timezone"] = timezone_name
- if "digest_enabled" in values:
- current["digest_enabled"] = bool(values.get("digest_enabled"))
- if "digest_time" in values:
- digest_time = str(values.get("digest_time") or "")
- if not re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", digest_time):
- raise AssistantDataError("invalid_setting", "简报时间必须是 HH:MM。")
- current["digest_time"] = digest_time
- for key, max_length in (("handoff_message", 1000), ("unsupported_message", 1000)):
- if key in values:
- current[key] = clean_text(values.get(key), max_length=max_length, required=True)
- return {key: current[key] for key in DEFAULT_ACCOUNT_SETTINGS}
- async def update_account_settings(
- connection_id: str, values: dict[str, Any]
- ) -> dict[str, Any]:
- connection = await get_business_connection(connection_id)
- if not connection:
- raise AssistantDataError("connection_not_found", "未找到 Business 连接。")
- previous = await get_account_settings(connection_id)
- normalized = normalize_account_settings(values, previous=previous)
- now = utc_now()
- await settingsdb.update_one(
- _scope(connection_id=str(connection_id)),
- {
- "$set": {**normalized, "updated_at": now},
- "$setOnInsert": {
- "bot_id": BOT_PROFILE_ID,
- "connection_id": str(connection_id),
- "created_at": now,
- },
- },
- upsert=True,
- )
- return await get_account_settings(connection_id)
- async def create_knowledge_entry(
- connection_id: str, values: dict[str, Any]
- ) -> dict[str, Any]:
- if not await get_business_connection(connection_id):
- raise AssistantDataError("connection_not_found", "未找到 Business 连接。")
- now = utc_now()
- entry = {
- "bot_id": BOT_PROFILE_ID,
- "entry_id": uuid4().hex,
- "connection_id": str(connection_id),
- "question": clean_text(values.get("question"), max_length=300, required=True),
- "aliases": _normalize_string_list(values.get("aliases"), max_items=20, max_length=200),
- "keywords": _normalize_string_list(values.get("keywords"), max_items=30, max_length=50),
- "answer": clean_text(
- values.get("answer"), max_length=4000, required=True, preserve_lines=True
- ),
- "tags": _normalize_string_list(values.get("tags"), max_items=20, max_length=30),
- "priority": _bounded_int(
- values.get("priority", 0), "知识优先级", minimum=-1000, maximum=1000
- ),
- "enabled": bool(values.get("enabled", True)),
- "created_at": now,
- "updated_at": now,
- }
- await knowledgedb.insert_one(entry)
- return entry
- async def update_knowledge_entry(entry_id: str, values: dict[str, Any]) -> dict[str, Any]:
- filters = _scope(entry_id=str(entry_id))
- current = await knowledgedb.find_one(filters)
- if not current:
- raise AssistantDataError("knowledge_not_found", "未找到知识条目。")
- update: dict[str, Any] = {}
- if "question" in values:
- update["question"] = clean_text(values.get("question"), max_length=300, required=True)
- if "aliases" in values:
- update["aliases"] = _normalize_string_list(
- values.get("aliases"), max_items=20, max_length=200
- )
- if "keywords" in values:
- update["keywords"] = _normalize_string_list(
- values.get("keywords"), max_items=30, max_length=50
- )
- if "answer" in values:
- update["answer"] = clean_text(
- values.get("answer"), max_length=4000, required=True, preserve_lines=True
- )
- if "tags" in values:
- update["tags"] = _normalize_string_list(values.get("tags"), max_items=20, max_length=30)
- if "priority" in values:
- update["priority"] = _bounded_int(
- values.get("priority"), "知识优先级", minimum=-1000, maximum=1000
- )
- if "enabled" in values:
- update["enabled"] = bool(values.get("enabled"))
- if not update:
- raise AssistantDataError("unchanged", "没有可保存的知识条目字段。")
- update["updated_at"] = utc_now()
- await knowledgedb.update_one(filters, {"$set": update})
- return await knowledgedb.find_one(filters) or current
- async def delete_knowledge_entry(entry_id: str) -> None:
- result = await knowledgedb.delete_one(_scope(entry_id=str(entry_id)))
- if not result.deleted_count:
- raise AssistantDataError("knowledge_not_found", "未找到知识条目。")
- async def list_knowledge_entries(
- connection_id: str,
- *,
- query: str = "",
- page: int = 1,
- page_size: int = 20,
- ) -> tuple[list[dict[str, Any]], int]:
- await ensure_assistant_indexes()
- filters: dict[str, Any] = _scope(connection_id=str(connection_id))
- normalized_query = clean_text(query, max_length=100)
- if normalized_query:
- pattern = re.escape(normalized_query)
- filters["$or"] = [
- {"question": {"$regex": pattern, "$options": "i"}},
- {"aliases": {"$regex": pattern, "$options": "i"}},
- {"keywords": {"$regex": pattern, "$options": "i"}},
- {"tags": {"$regex": pattern, "$options": "i"}},
- ]
- page = max(1, int(page))
- page_size = max(1, min(int(page_size), 100))
- total = await knowledgedb.count_documents(filters)
- cursor = (
- knowledgedb.find(filters)
- .sort([("priority", DESCENDING), ("updated_at", DESCENDING)])
- .skip((page - 1) * page_size)
- .limit(page_size)
- )
- return [item async for item in cursor], total
- def _search_text(value: str) -> str:
- return re.sub(r"[^\w\u3400-\u9fff]+", "", value.casefold())
- async def match_knowledge(
- connection_id: str, query: str, *, limit: int = 8
- ) -> list[dict[str, Any]]:
- await ensure_assistant_indexes()
- normalized_query = _search_text(query)
- if not normalized_query:
- return []
- entries = [
- item
- async for item in knowledgedb.find(
- _scope(connection_id=str(connection_id), enabled=True)
- )
- ]
- scored: list[tuple[int, dict[str, Any]]] = []
- for entry in entries:
- score = int(entry.get("priority") or 0)
- phrases = [entry.get("question", ""), *(entry.get("aliases") or [])]
- for phrase in phrases:
- normalized_phrase = _search_text(str(phrase))
- if not normalized_phrase:
- continue
- if normalized_query == normalized_phrase:
- score += 1000
- elif normalized_phrase in normalized_query:
- score += 300 + min(len(normalized_phrase), 100)
- elif normalized_query in normalized_phrase and len(normalized_query) >= 4:
- score += 120
- for keyword in entry.get("keywords") or []:
- normalized_keyword = _search_text(str(keyword))
- if normalized_keyword and normalized_keyword in normalized_query:
- score += 80
- if score > int(entry.get("priority") or 0):
- scored.append((score, entry))
- scored.sort(key=lambda item: (item[0], item[1].get("priority", 0)), reverse=True)
- return [{**entry, "match_score": score} for score, entry in scored[: max(1, limit)]]
- def classify_handoff(text: str) -> str | None:
- normalized = text.casefold()
- if any(term in normalized for term in HANDOFF_TERMS):
- return "customer_requested_human"
- if any(term in normalized for term in SENSITIVE_TERMS):
- return "sensitive_request"
- return None
- def conversation_id_for(connection_id: str, chat_id: int) -> str:
- digest = hashlib.sha256(f"{BOT_PROFILE_ID}:{connection_id}:{int(chat_id)}".encode()).hexdigest()
- return digest[:32]
- async def get_or_create_conversation(
- connection_id: str,
- chat_id: int,
- *,
- customer: dict[str, Any] | None = None,
- ) -> dict[str, Any]:
- await ensure_assistant_indexes()
- conversation_id = conversation_id_for(connection_id, chat_id)
- now = utc_now()
- update: dict[str, Any] = {"updated_at": now, "last_message_at": now}
- if customer:
- update["customer"] = _public_user(customer)
- await conversationsdb.update_one(
- _scope(conversation_id=conversation_id),
- {
- "$set": update,
- "$setOnInsert": {
- "bot_id": BOT_PROFILE_ID,
- "conversation_id": conversation_id,
- "connection_id": str(connection_id),
- "chat_id": int(chat_id),
- "status": "auto",
- "summary": "",
- "handoff_reason": "",
- "created_at": now,
- },
- },
- upsert=True,
- )
- return await conversationsdb.find_one(_scope(conversation_id=conversation_id)) or {}
- async def get_conversation(conversation_id: str) -> dict[str, Any] | None:
- await ensure_assistant_indexes()
- return await conversationsdb.find_one(
- _scope(conversation_id=str(conversation_id))
- )
- async def get_conversation_by_chat(
- connection_id: str, chat_id: int
- ) -> dict[str, Any] | None:
- await ensure_assistant_indexes()
- return await conversationsdb.find_one(
- _scope(connection_id=str(connection_id), chat_id=int(chat_id))
- )
- async def append_conversation_message(
- conversation_id: str,
- *,
- direction: str,
- telegram_message_id: int,
- text: str,
- sender_id: int = 0,
- metadata: dict[str, Any] | None = None,
- ) -> dict[str, Any]:
- await ensure_assistant_indexes()
- if direction not in {"incoming", "assistant", "human"}:
- raise AssistantDataError("invalid_direction", "会话消息方向无效。")
- now = utc_now()
- message_key = f"{conversation_id}:{direction}:{int(telegram_message_id)}"
- document = {
- "bot_id": BOT_PROFILE_ID,
- "message_key": message_key,
- "conversation_id": str(conversation_id),
- "direction": direction,
- "telegram_message_id": int(telegram_message_id),
- "sender_id": int(sender_id or 0),
- "text": clean_text(text, max_length=12000, preserve_lines=True),
- "metadata": metadata or {},
- "created_at": now,
- "expires_at": now + timedelta(days=30),
- }
- try:
- await messagesdb.insert_one(document)
- except DuplicateKeyError:
- return await messagesdb.find_one(_scope(message_key=message_key)) or document
- await conversationsdb.update_one(
- _scope(conversation_id=str(conversation_id)),
- {"$set": {"last_message_at": now, "updated_at": now}},
- )
- return document
- async def recent_conversation_messages(
- conversation_id: str, *, limit: int = 12
- ) -> list[dict[str, Any]]:
- cursor = (
- messagesdb.find(_scope(conversation_id=str(conversation_id)))
- .sort([("created_at", DESCENDING), ("_id", DESCENDING)])
- .limit(max(1, min(int(limit), 50)))
- )
- items = [item async for item in cursor]
- items.reverse()
- return items
- async def set_conversation_handoff(
- conversation_id: str,
- reason: str,
- *,
- summary: str = "",
- ) -> dict[str, Any]:
- now = utc_now()
- update: dict[str, Any] = {
- "status": "handoff",
- "handoff_reason": clean_text(reason, max_length=200, required=True),
- "paused_until": None,
- "handoff_at": now,
- "updated_at": now,
- }
- if summary:
- update["summary"] = clean_text(summary, max_length=4000, preserve_lines=True)
- result = await conversationsdb.find_one_and_update(
- _scope(conversation_id=str(conversation_id)),
- {"$set": update},
- return_document=True,
- )
- if not result:
- raise AssistantDataError("conversation_not_found", "未找到客户会话。")
- return result
- async def pause_conversation_for_human(
- conversation_id: str, *, hours: int
- ) -> dict[str, Any]:
- now = utc_now()
- result = await conversationsdb.find_one_and_update(
- _scope(conversation_id=str(conversation_id)),
- {
- "$set": {
- "status": "human_paused",
- "handoff_reason": "human_reply_detected",
- "paused_until": now + timedelta(hours=max(1, min(int(hours), 168))),
- "updated_at": now,
- }
- },
- return_document=True,
- )
- if not result:
- raise AssistantDataError("conversation_not_found", "未找到客户会话。")
- return result
- async def resume_conversation(conversation_id: str) -> dict[str, Any]:
- result = await conversationsdb.find_one_and_update(
- _scope(conversation_id=str(conversation_id)),
- {
- "$set": {
- "status": "auto",
- "handoff_reason": "",
- "paused_until": None,
- "updated_at": utc_now(),
- }
- },
- return_document=True,
- )
- if not result:
- raise AssistantDataError("conversation_not_found", "未找到客户会话。")
- return result
- async def pause_conversation(conversation_id: str) -> dict[str, Any]:
- return await set_conversation_handoff(conversation_id, "admin_paused")
- async def close_conversation(conversation_id: str) -> dict[str, Any]:
- result = await conversationsdb.find_one_and_update(
- _scope(conversation_id=str(conversation_id)),
- {"$set": {"status": "closed", "closed_at": utc_now(), "updated_at": utc_now()}},
- return_document=True,
- )
- if not result:
- raise AssistantDataError("conversation_not_found", "未找到客户会话。")
- return result
- async def clear_conversation(conversation_id: str) -> dict[str, Any]:
- current = await get_conversation(conversation_id)
- if not current:
- raise AssistantDataError("conversation_not_found", "未找到客户会话。")
- await messagesdb.delete_many(_scope(conversation_id=str(conversation_id)))
- await conversationsdb.update_one(
- _scope(conversation_id=str(conversation_id)),
- {
- "$set": {
- "summary": "",
- "handoff_reason": "",
- "status": "auto",
- "paused_until": None,
- "cleared_at": utc_now(),
- "updated_at": utc_now(),
- }
- },
- )
- return await get_conversation(conversation_id) or current
- async def update_conversation_summary(
- conversation_id: str, summary: str
- ) -> None:
- cleaned = clean_text(summary, max_length=4000, preserve_lines=True)
- if cleaned:
- await conversationsdb.update_one(
- _scope(conversation_id=str(conversation_id)),
- {"$set": {"summary": cleaned, "updated_at": utc_now()}},
- )
- async def list_conversations(
- *,
- connection_id: str = "",
- status: str = "",
- page: int = 1,
- page_size: int = 20,
- ) -> tuple[list[dict[str, Any]], int]:
- await ensure_assistant_indexes()
- filters: dict[str, Any] = _scope()
- if connection_id:
- filters["connection_id"] = str(connection_id)
- if status:
- if status not in {"auto", "handoff", "human_paused", "closed"}:
- raise AssistantDataError("invalid_status", "会话状态无效。")
- filters["status"] = status
- page = max(1, int(page))
- page_size = max(1, min(int(page_size), 100))
- total = await conversationsdb.count_documents(filters)
- cursor = (
- conversationsdb.find(filters)
- .sort("updated_at", DESCENDING)
- .skip((page - 1) * page_size)
- .limit(page_size)
- )
- return [item async for item in cursor], total
- async def conversation_detail(conversation_id: str) -> dict[str, Any]:
- conversation = await get_conversation(conversation_id)
- if not conversation:
- raise AssistantDataError("conversation_not_found", "未找到客户会话。")
- conversation["messages"] = await recent_conversation_messages(
- conversation_id, limit=50
- )
- return conversation
- def _usage_day(now: datetime, timezone_name: str) -> str:
- try:
- zone = ZoneInfo(timezone_name)
- except ZoneInfoNotFoundError:
- zone = ZoneInfo("Asia/Shanghai")
- return as_utc(now).astimezone(zone).date().isoformat()
- async def reserve_ai_usage(
- connection_id: str,
- chat_id: int,
- settings: dict[str, Any],
- *,
- now: datetime | None = None,
- ) -> tuple[bool, str]:
- await ensure_assistant_indexes()
- current_time = as_utc(now or utc_now())
- day = _usage_day(current_time, str(settings.get("timezone") or "Asia/Shanghai"))
- account_limit = int(settings.get("account_daily_limit") or 200)
- customer_limit = int(settings.get("customer_daily_limit") or 20)
- account_filter = _scope(
- connection_id=str(connection_id), chat_id=0, day=day
- )
- customer_filter = _scope(
- connection_id=str(connection_id), chat_id=int(chat_id), day=day
- )
- for filters, usage_scope in (
- (account_filter, "account"),
- (customer_filter, "customer"),
- ):
- await usagedb.update_one(
- filters,
- {
- "$setOnInsert": {
- **filters,
- "scope": usage_scope,
- "call_count": 0,
- "created_at": current_time,
- "updated_at": current_time,
- }
- },
- upsert=True,
- )
- account = await usagedb.find_one_and_update(
- {**account_filter, "call_count": {"$lt": account_limit}},
- {"$inc": {"call_count": 1}, "$set": {"updated_at": current_time}},
- return_document=True,
- )
- if not account:
- return False, "account_daily_limit"
- customer = await usagedb.find_one_and_update(
- {**customer_filter, "call_count": {"$lt": customer_limit}},
- {"$inc": {"call_count": 1}, "$set": {"updated_at": current_time}},
- return_document=True,
- )
- if not customer:
- await usagedb.update_one(
- {**account_filter, "call_count": {"$gt": 0}},
- {"$inc": {"call_count": -1}, "$set": {"updated_at": current_time}},
- )
- return False, "customer_daily_limit"
- return True, ""
- async def usage_metrics(*, connection_id: str = "", day: str = "") -> dict[str, Any]:
- await ensure_assistant_indexes()
- match = _scope()
- if connection_id:
- match["connection_id"] = str(connection_id)
- if day:
- match["day"] = str(day)
- calls = 0
- customers: set[tuple[str, int]] = set()
- customer_match = {
- **match,
- "scope": {"$ne": "account"},
- "chat_id": {"$ne": 0},
- "call_count": {"$gt": 0},
- }
- async for item in usagedb.find(customer_match):
- calls += int(item.get("call_count") or 0)
- customers.add((str(item.get("connection_id")), int(item.get("chat_id") or 0)))
- conversation_filters = _scope()
- if connection_id:
- conversation_filters["connection_id"] = str(connection_id)
- return {
- "ai_calls": calls,
- "customers": len(customers),
- "conversations": await conversationsdb.count_documents(conversation_filters),
- "handoffs": await conversationsdb.count_documents(
- {**conversation_filters, "status": "handoff"}
- ),
- "human_paused": await conversationsdb.count_documents(
- {**conversation_filters, "status": "human_paused"}
- ),
- }
- async def claim_update(update_id: int) -> bool:
- await ensure_assistant_indexes()
- now = utc_now()
- try:
- await updatesdb.insert_one(
- {
- "bot_id": BOT_PROFILE_ID,
- "update_id": int(update_id),
- "status": "processing",
- "attempts": 1,
- "created_at": now,
- "updated_at": now,
- "expires_at": now + timedelta(days=7),
- }
- )
- return True
- except DuplicateKeyError:
- filters = _scope(update_id=int(update_id))
- current = await updatesdb.find_one(filters) or {}
- if current.get("status") == "done":
- return False
- await updatesdb.update_one(
- filters,
- {"$inc": {"attempts": 1}, "$set": {"updated_at": now}},
- )
- return True
- async def mark_update_done(update_id: int) -> None:
- await updatesdb.update_one(
- _scope(update_id=int(update_id)),
- {"$set": {"status": "done", "updated_at": utc_now(), "last_error": ""}},
- )
- async def mark_update_failed(update_id: int, error: str) -> int:
- await updatesdb.update_one(
- _scope(update_id=int(update_id)),
- {"$set": {"status": "failed", "updated_at": utc_now(), "last_error": error[:1000]}},
- )
- current = await updatesdb.find_one(_scope(update_id=int(update_id))) or {}
- return int(current.get("attempts") or 1)
- async def dead_letter_update(update: dict[str, Any], error: str) -> None:
- update_id = int(update.get("update_id") or 0)
- now = utc_now()
- await deadlettersdb.update_one(
- _scope(update_id=update_id),
- {
- "$set": {
- "bot_id": BOT_PROFILE_ID,
- "payload": update,
- "error": error[:2000],
- "updated_at": now,
- "expires_at": now + timedelta(days=30),
- },
- "$setOnInsert": {"created_at": now},
- },
- upsert=True,
- )
- await mark_update_done(update_id)
- async def runtime_status() -> dict[str, Any]:
- await ensure_assistant_indexes()
- stored = await runtimedb.find_one(
- _scope(runtime_id="business_assistant")
- ) or {}
- return {
- "runtime_id": "business_assistant",
- "polling_state": "stopped",
- "business_mode_supported": False,
- "webhook_conflict": False,
- "webhook_url": "",
- "model_configured": False,
- "last_error": "",
- **{key: value for key, value in stored.items() if key != "_id"},
- }
- async def update_runtime_status(values: dict[str, Any]) -> dict[str, Any]:
- await ensure_assistant_indexes()
- insert_values = {
- "bot_id": BOT_PROFILE_ID,
- "runtime_id": "business_assistant",
- "created_at": utc_now(),
- }
- if "offset" not in values:
- insert_values["offset"] = 0
- await runtimedb.update_one(
- _scope(runtime_id="business_assistant"),
- {
- "$set": {**values, "updated_at": utc_now()},
- "$setOnInsert": insert_values,
- },
- upsert=True,
- )
- return await runtime_status()
- async def load_update_offset() -> int:
- return int((await runtime_status()).get("offset") or 0)
- async def save_update_offset(offset: int) -> None:
- await update_runtime_status({"offset": int(offset), "last_poll_at": utc_now()})
- async def due_digest_connections(now: datetime | None = None) -> list[dict[str, Any]]:
- current = as_utc(now or utc_now())
- results: list[dict[str, Any]] = []
- async for settings in settingsdb.find(
- _scope(digest_enabled=True, assistant_enabled=True)
- ):
- try:
- local = current.astimezone(ZoneInfo(str(settings.get("timezone"))))
- except ZoneInfoNotFoundError:
- local = current.astimezone(ZoneInfo("Asia/Shanghai"))
- day = local.date().isoformat()
- if local.strftime("%H:%M") != str(settings.get("digest_time") or "09:00"):
- continue
- if settings.get("last_digest_day") == day:
- continue
- connection = await get_business_connection(settings["connection_id"])
- if connection and connection.get("is_enabled"):
- results.append({"connection": connection, "settings": settings, "day": day})
- return results
- async def mark_digest_sent(connection_id: str, day: str) -> None:
- await settingsdb.update_one(
- _scope(connection_id=str(connection_id)),
- {"$set": {"last_digest_day": str(day), "last_digest_at": utc_now()}},
- )
|