|
|
@@ -0,0 +1,1047 @@
|
|
|
+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()}},
|
|
|
+ )
|