| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703 |
- from __future__ import annotations
- import asyncio
- import hashlib
- import json
- 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
- knowledge_sourcesdb = db.business_assistant_knowledge_sources
- source_eventsdb = db.business_assistant_source_events
- knowledge_candidatesdb = db.business_assistant_knowledge_candidates
- 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"}
- VALID_SOURCE_TYPES = {"channel", "group", "business"}
- VALID_PUBLICATION_MODES = {"review", "auto"}
- VALID_AUTHOR_POLICIES = {"admins_or_allowlist", "admins", "allowlist", "all"}
- VALID_CANDIDATE_STATUSES = {"pending", "published", "rejected", "stale"}
- 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
- def _normalize_int_list(value: Any, *, max_items: int = 100) -> list[int]:
- values = value if isinstance(value, (list, tuple, set)) else str(value or "").split(",")
- normalized: list[int] = []
- for item in values:
- if item in {None, ""}:
- continue
- try:
- parsed = int(item)
- except (TypeError, ValueError) as exc:
- raise AssistantDataError("invalid_source", "白名单用户 ID 必须是整数。") from exc
- if parsed and parsed not in normalized:
- normalized.append(parsed)
- 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 knowledge_sourcesdb.create_index(
- [("bot_id", ASCENDING), ("source_id", ASCENDING)], unique=True
- )
- await knowledge_sourcesdb.create_index(
- [("bot_id", ASCENDING), ("source_key", ASCENDING)], unique=True
- )
- await knowledge_sourcesdb.create_index(
- [
- ("bot_id", ASCENDING),
- ("connection_id", ASCENDING),
- ("enabled", ASCENDING),
- ("updated_at", DESCENDING),
- ]
- )
- await source_eventsdb.create_index(
- [("bot_id", ASCENDING), ("event_id", ASCENDING)], unique=True
- )
- await source_eventsdb.create_index(
- [
- ("bot_id", ASCENDING),
- ("source_id", ASCENDING),
- ("event_key", ASCENDING),
- ],
- unique=True,
- )
- await source_eventsdb.create_index("expires_at", expireAfterSeconds=0)
- await knowledge_candidatesdb.create_index(
- [("bot_id", ASCENDING), ("candidate_id", ASCENDING)], unique=True
- )
- await knowledge_candidatesdb.create_index(
- [
- ("bot_id", ASCENDING),
- ("event_id", ASCENDING),
- ("item_index", ASCENDING),
- ],
- unique=True,
- )
- await knowledge_candidatesdb.create_index(
- [
- ("bot_id", ASCENDING),
- ("connection_id", ASCENDING),
- ("status", ASCENDING),
- ("updated_at", DESCENDING),
- ]
- )
- 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)),
- "source_candidate_id": clean_text(
- values.get("source_candidate_id"), max_length=64
- ),
- "source_references": values.get("source_references")
- if isinstance(values.get("source_references"), list)
- else [],
- "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 "source_candidate_id" in values:
- update["source_candidate_id"] = clean_text(
- values.get("source_candidate_id"), max_length=64
- )
- if "source_references" in values:
- references = values.get("source_references")
- if not isinstance(references, list):
- raise AssistantDataError("invalid_knowledge", "知识来源引用必须是数组。")
- update["source_references"] = references[:20]
- 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 _knowledge_source_key(connection_id: str, source_type: str, chat_id: int) -> str:
- return f"{connection_id}:{source_type}:{int(chat_id)}"
- def normalize_knowledge_source(
- values: dict[str, Any], *, previous: dict[str, Any] | None = None
- ) -> dict[str, Any]:
- current = {**(previous or {}), **values}
- source_type = str(current.get("source_type") or "")
- if source_type not in VALID_SOURCE_TYPES:
- raise AssistantDataError("invalid_source", "知识来源类型无效。")
- try:
- chat_id = int(current.get("chat_id") or 0)
- except (TypeError, ValueError) as exc:
- raise AssistantDataError("invalid_source", "来源聊天 ID 必须是整数。") from exc
- if source_type == "business":
- chat_id = 0
- elif not chat_id:
- raise AssistantDataError("invalid_source", "频道或群组来源必须填写聊天 ID。")
- publication_mode = str(current.get("publication_mode") or "review")
- if publication_mode not in VALID_PUBLICATION_MODES:
- raise AssistantDataError("invalid_source", "知识发布方式无效。")
- author_policy = str(current.get("author_policy") or "admins_or_allowlist")
- if author_policy not in VALID_AUTHOR_POLICIES:
- raise AssistantDataError("invalid_source", "来源作者策略无效。")
- linked_chat_id = 0
- try:
- linked_chat_id = int(current.get("linked_chat_id") or 0)
- except (TypeError, ValueError) as exc:
- raise AssistantDataError("invalid_source", "关联讨论群 ID 必须是整数。") from exc
- return {
- "source_type": source_type,
- "chat_id": chat_id,
- "title": clean_text(
- current.get("title") or ("Business 日常对话" if source_type == "business" else ""),
- max_length=200,
- required=True,
- ),
- "publication_mode": publication_mode,
- "author_policy": author_policy,
- "allowed_user_ids": _normalize_int_list(current.get("allowed_user_ids")),
- "include_linked_chat": bool(current.get("include_linked_chat", False))
- if source_type == "channel"
- else False,
- "linked_chat_id": linked_chat_id if source_type == "channel" else 0,
- "linked_chat_title": clean_text(
- current.get("linked_chat_title"), max_length=200
- )
- if source_type == "channel"
- else "",
- "backfill_days": _bounded_int(
- current.get("backfill_days", 30), "历史回溯天数", minimum=0, maximum=365
- ),
- "backfill_limit": _bounded_int(
- current.get("backfill_limit", 100), "历史回溯条数", minimum=1, maximum=500
- ),
- "enabled": bool(current.get("enabled", True)),
- "access_status": clean_text(current.get("access_status"), max_length=64),
- }
- async def create_knowledge_source(
- connection_id: str, values: dict[str, Any]
- ) -> dict[str, Any]:
- await ensure_assistant_indexes()
- if not await get_business_connection(connection_id):
- raise AssistantDataError("connection_not_found", "未找到 Business 连接。")
- normalized = normalize_knowledge_source(values)
- now = utc_now()
- source = {
- "bot_id": BOT_PROFILE_ID,
- "source_id": uuid4().hex,
- "source_key": _knowledge_source_key(
- str(connection_id), normalized["source_type"], normalized["chat_id"]
- ),
- "connection_id": str(connection_id),
- **normalized,
- "sync_status": "idle",
- "last_error": "",
- "created_at": now,
- "updated_at": now,
- }
- try:
- await knowledge_sourcesdb.insert_one(source)
- except DuplicateKeyError as exc:
- raise AssistantDataError("source_exists", "该连接已配置相同的知识来源。") from exc
- return source
- async def get_knowledge_source(source_id: str) -> dict[str, Any] | None:
- await ensure_assistant_indexes()
- return await knowledge_sourcesdb.find_one(_scope(source_id=str(source_id)))
- async def update_knowledge_source(
- source_id: str, values: dict[str, Any]
- ) -> dict[str, Any]:
- current = await get_knowledge_source(source_id)
- if not current:
- raise AssistantDataError("source_not_found", "未找到知识来源。")
- for field in ("connection_id", "source_type", "chat_id"):
- if field in values and str(values[field]) != str(current[field]):
- raise AssistantDataError("immutable_source", "来源连接、类型和聊天 ID 不可修改。")
- normalized = normalize_knowledge_source(values, previous=current)
- await knowledge_sourcesdb.update_one(
- _scope(source_id=str(source_id)),
- {"$set": {**normalized, "updated_at": utc_now()}},
- )
- return await get_knowledge_source(source_id) or current
- async def list_knowledge_sources(
- connection_id: str,
- *,
- page: int = 1,
- page_size: int = 20,
- ) -> tuple[list[dict[str, Any]], int]:
- await ensure_assistant_indexes()
- filters = _scope(connection_id=str(connection_id))
- page = max(1, int(page))
- page_size = max(1, min(int(page_size), 100))
- total = await knowledge_sourcesdb.count_documents(filters)
- cursor = (
- knowledge_sourcesdb.find(filters)
- .sort("updated_at", DESCENDING)
- .skip((page - 1) * page_size)
- .limit(page_size)
- )
- return [item async for item in cursor], total
- async def list_sources_for_chat(
- chat_id: int, *, enabled_only: bool = True
- ) -> list[dict[str, Any]]:
- await ensure_assistant_indexes()
- values: dict[str, Any] = {
- "$or": [
- {"chat_id": int(chat_id)},
- {"include_linked_chat": True, "linked_chat_id": int(chat_id)},
- ]
- }
- if enabled_only:
- values["enabled"] = True
- filters = _scope(values)
- return [item async for item in knowledge_sourcesdb.find(filters)]
- async def list_enabled_sources_for_chat(chat_id: int) -> list[dict[str, Any]]:
- return await list_sources_for_chat(chat_id, enabled_only=True)
- async def list_enabled_business_sources(connection_id: str) -> list[dict[str, Any]]:
- await ensure_assistant_indexes()
- return [
- item
- async for item in knowledge_sourcesdb.find(
- _scope(
- connection_id=str(connection_id),
- source_type="business",
- enabled=True,
- )
- )
- ]
- async def list_business_sources(connection_id: str) -> list[dict[str, Any]]:
- await ensure_assistant_indexes()
- return [
- item
- async for item in knowledge_sourcesdb.find(
- _scope(connection_id=str(connection_id), source_type="business")
- )
- ]
- async def update_knowledge_source_sync(
- source_id: str,
- *,
- status: str,
- error: str = "",
- processed: int | None = None,
- ) -> dict[str, Any]:
- values: dict[str, Any] = {
- "sync_status": clean_text(status, max_length=32, required=True),
- "last_error": clean_text(error, max_length=1000),
- "updated_at": utc_now(),
- }
- if status == "running":
- values["last_sync_started_at"] = utc_now()
- if status in {"completed", "failed"}:
- values["last_sync_finished_at"] = utc_now()
- if processed is not None:
- values["last_sync_processed"] = max(0, int(processed))
- result = await knowledge_sourcesdb.update_one(
- _scope(source_id=str(source_id)), {"$set": values}
- )
- if not result.matched_count:
- raise AssistantDataError("source_not_found", "未找到知识来源。")
- return await get_knowledge_source(source_id) or {}
- async def touch_knowledge_source(source_id: str, *, error: str = "") -> None:
- await knowledge_sourcesdb.update_one(
- _scope(source_id=str(source_id)),
- {
- "$set": {
- "last_event_at": utc_now(),
- "last_error": clean_text(error, max_length=1000),
- "updated_at": utc_now(),
- }
- },
- )
- async def _disable_candidate_entries(filters: dict[str, Any]) -> None:
- entry_ids = [
- str(item.get("knowledge_entry_id") or "")
- async for item in knowledge_candidatesdb.find(filters)
- if item.get("knowledge_entry_id")
- ]
- if entry_ids:
- await knowledgedb.update_many(
- _scope({"entry_id": {"$in": entry_ids}}),
- {"$set": {"enabled": False, "updated_at": utc_now()}},
- )
- async def delete_knowledge_source(source_id: str) -> None:
- source = await get_knowledge_source(source_id)
- if not source:
- raise AssistantDataError("source_not_found", "未找到知识来源。")
- candidate_filters = _scope(source_id=str(source_id))
- await _disable_candidate_entries(candidate_filters)
- await knowledge_candidatesdb.update_many(
- candidate_filters,
- {"$set": {"status": "stale", "stale_reason": "source_deleted", "updated_at": utc_now()}},
- )
- await source_eventsdb.update_many(
- _scope(source_id=str(source_id)),
- {"$set": {"deleted": True, "delete_reason": "source_deleted", "updated_at": utc_now()}},
- )
- await knowledge_sourcesdb.delete_one(_scope(source_id=str(source_id)))
- def source_event_id_for(source_id: str, event_key: str) -> str:
- digest = hashlib.sha256(
- f"{BOT_PROFILE_ID}:{source_id}:{event_key}".encode()
- ).hexdigest()
- return digest[:32]
- async def record_source_event(
- source: dict[str, Any],
- *,
- event_key: str,
- content: str,
- metadata: dict[str, Any] | None = None,
- ) -> tuple[dict[str, Any], bool]:
- await ensure_assistant_indexes()
- cleaned_content = clean_text(
- content, max_length=20000, required=True, preserve_lines=True
- )
- cleaned_key = clean_text(event_key, max_length=300, required=True)
- event_metadata = metadata if isinstance(metadata, dict) else {}
- content_hash = hashlib.sha256(
- json.dumps(
- {"content": cleaned_content, "metadata": event_metadata},
- ensure_ascii=False,
- sort_keys=True,
- default=str,
- ).encode()
- ).hexdigest()
- filters = _scope(source_id=str(source["source_id"]), event_key=cleaned_key)
- current = await source_eventsdb.find_one(filters)
- if current and current.get("content_hash") == content_hash and not current.get("deleted"):
- return current, False
- now = utc_now()
- event_id = str(current.get("event_id")) if current else source_event_id_for(
- str(source["source_id"]), cleaned_key
- )
- version = int(current.get("version") or 0) + 1 if current else 1
- document = {
- "bot_id": BOT_PROFILE_ID,
- "event_id": event_id,
- "source_id": str(source["source_id"]),
- "connection_id": str(source["connection_id"]),
- "event_key": cleaned_key,
- "content": cleaned_content,
- "content_hash": content_hash,
- "metadata": event_metadata,
- "version": version,
- "deleted": False,
- "extraction_status": "pending",
- "extraction_error": "",
- "updated_at": now,
- "expires_at": now + timedelta(days=30),
- }
- await source_eventsdb.update_one(
- filters,
- {"$set": document, "$setOnInsert": {"created_at": now}},
- upsert=True,
- )
- return await source_eventsdb.find_one(filters) or document, True
- async def get_source_event(event_id: str) -> dict[str, Any] | None:
- await ensure_assistant_indexes()
- return await source_eventsdb.find_one(_scope(event_id=str(event_id)))
- async def get_source_event_by_key(
- source_id: str, event_key: str
- ) -> dict[str, Any] | None:
- await ensure_assistant_indexes()
- return await source_eventsdb.find_one(
- _scope(source_id=str(source_id), event_key=str(event_key))
- )
- async def update_source_event_extraction(
- event_id: str, *, status: str, error: str = "", item_count: int = 0
- ) -> None:
- await source_eventsdb.update_one(
- _scope(event_id=str(event_id)),
- {
- "$set": {
- "extraction_status": clean_text(status, max_length=32, required=True),
- "extraction_error": clean_text(error, max_length=1000),
- "extracted_item_count": max(0, int(item_count)),
- "extracted_at": utc_now(),
- "updated_at": utc_now(),
- }
- },
- )
- async def invalidate_source_event_candidates(
- event_id: str, *, reason: str
- ) -> None:
- filters = _scope(event_id=str(event_id))
- await _disable_candidate_entries(filters)
- await knowledge_candidatesdb.update_many(
- filters,
- {
- "$set": {
- "status": "stale",
- "stale_reason": clean_text(reason, max_length=100),
- "updated_at": utc_now(),
- }
- },
- )
- async def mark_source_event_deleted(source_id: str, event_key: str) -> bool:
- event = await source_eventsdb.find_one(
- _scope(source_id=str(source_id), event_key=str(event_key))
- )
- if not event:
- return False
- await source_eventsdb.update_one(
- _scope(event_id=str(event["event_id"])),
- {"$set": {"deleted": True, "updated_at": utc_now()}},
- )
- await invalidate_source_event_candidates(
- str(event["event_id"]), reason="source_message_deleted"
- )
- return True
- def _normalize_candidate_item(item: dict[str, Any]) -> dict[str, Any]:
- try:
- confidence = float(item.get("confidence") or 0)
- except (TypeError, ValueError):
- confidence = 0
- return {
- "question": clean_text(item.get("question"), max_length=300, required=True),
- "aliases": _normalize_string_list(item.get("aliases"), max_items=20, max_length=200),
- "keywords": _normalize_string_list(item.get("keywords"), max_items=30, max_length=50),
- "answer": clean_text(
- item.get("answer"), max_length=4000, required=True, preserve_lines=True
- ),
- "tags": _normalize_string_list(item.get("tags"), max_items=20, max_length=30),
- "confidence": max(0.0, min(confidence, 1.0)),
- }
- async def replace_event_candidates(
- source: dict[str, Any],
- event: dict[str, Any],
- items: list[dict[str, Any]],
- *,
- auto_publish: bool,
- ) -> list[dict[str, Any]]:
- await invalidate_source_event_candidates(
- str(event["event_id"]), reason="source_message_changed"
- )
- existing = {
- int(item.get("item_index") or 0): item
- async for item in knowledge_candidatesdb.find(
- _scope(event_id=str(event["event_id"]))
- )
- }
- results: list[dict[str, Any]] = []
- metadata = event.get("metadata") if isinstance(event.get("metadata"), dict) else {}
- for index, raw_item in enumerate(items[:5]):
- normalized = _normalize_candidate_item(raw_item)
- current = existing.get(index) or {}
- now = utc_now()
- candidate_id = str(current.get("candidate_id") or uuid4().hex)
- candidate = {
- "bot_id": BOT_PROFILE_ID,
- "candidate_id": candidate_id,
- "connection_id": str(source["connection_id"]),
- "source_id": str(source["source_id"]),
- "event_id": str(event["event_id"]),
- "item_index": index,
- **normalized,
- "status": "pending",
- "stale_reason": "",
- "knowledge_entry_id": str(current.get("knowledge_entry_id") or ""),
- "source_snapshot": {
- "title": str(source.get("title") or ""),
- "source_type": str(source.get("source_type") or ""),
- "chat_id": int(metadata.get("chat_id") or source.get("chat_id") or 0),
- "message_id": int(metadata.get("message_id") or 0),
- "author_id": int(metadata.get("author_id") or 0),
- },
- "updated_at": now,
- }
- await knowledge_candidatesdb.update_one(
- _scope(event_id=str(event["event_id"]), item_index=index),
- {"$set": candidate, "$setOnInsert": {"created_at": now}},
- upsert=True,
- )
- if auto_publish:
- candidate = await publish_knowledge_candidate(candidate_id)
- else:
- candidate = await get_knowledge_candidate(candidate_id) or candidate
- results.append(candidate)
- await update_source_event_extraction(
- str(event["event_id"]), status="completed", item_count=len(results)
- )
- return results
- async def get_knowledge_candidate(candidate_id: str) -> dict[str, Any] | None:
- await ensure_assistant_indexes()
- return await knowledge_candidatesdb.find_one(
- _scope(candidate_id=str(candidate_id))
- )
- async def publish_knowledge_candidate(candidate_id: str) -> dict[str, Any]:
- candidate = await get_knowledge_candidate(candidate_id)
- if not candidate:
- raise AssistantDataError("candidate_not_found", "未找到知识候选。")
- if candidate.get("status") == "stale":
- raise AssistantDataError("candidate_stale", "来源已变更或删除,不能发布该候选。")
- if not await get_knowledge_source(str(candidate.get("source_id") or "")):
- await knowledge_candidatesdb.update_one(
- _scope(candidate_id=str(candidate_id)),
- {
- "$set": {
- "status": "stale",
- "stale_reason": "source_deleted",
- "updated_at": utc_now(),
- }
- },
- )
- raise AssistantDataError("source_not_found", "知识来源已删除,不能发布该候选。")
- reference = {
- **(candidate.get("source_snapshot") or {}),
- "source_id": str(candidate.get("source_id") or ""),
- "event_id": str(candidate.get("event_id") or ""),
- }
- values = {
- key: candidate.get(key)
- for key in ("question", "aliases", "keywords", "answer", "tags")
- }
- values.update(
- {
- "priority": round(float(candidate.get("confidence") or 0) * 100),
- "enabled": True,
- "source_candidate_id": str(candidate_id),
- "source_references": [reference],
- }
- )
- entry_id = str(candidate.get("knowledge_entry_id") or "")
- if entry_id:
- try:
- entry = await update_knowledge_entry(entry_id, values)
- except AssistantDataError as exc:
- if exc.code != "knowledge_not_found":
- raise
- entry = await create_knowledge_entry(str(candidate["connection_id"]), values)
- else:
- entry = await create_knowledge_entry(str(candidate["connection_id"]), values)
- await knowledge_candidatesdb.update_one(
- _scope(candidate_id=str(candidate_id)),
- {
- "$set": {
- "status": "published",
- "knowledge_entry_id": str(entry["entry_id"]),
- "published_at": utc_now(),
- "updated_at": utc_now(),
- }
- },
- )
- return await get_knowledge_candidate(candidate_id) or candidate
- async def reject_knowledge_candidate(candidate_id: str) -> dict[str, Any]:
- candidate = await get_knowledge_candidate(candidate_id)
- if not candidate:
- raise AssistantDataError("candidate_not_found", "未找到知识候选。")
- entry_id = str(candidate.get("knowledge_entry_id") or "")
- if entry_id:
- await knowledgedb.update_one(
- _scope(entry_id=entry_id),
- {"$set": {"enabled": False, "updated_at": utc_now()}},
- )
- await knowledge_candidatesdb.update_one(
- _scope(candidate_id=str(candidate_id)),
- {
- "$set": {
- "status": "rejected",
- "rejected_at": utc_now(),
- "updated_at": utc_now(),
- }
- },
- )
- return await get_knowledge_candidate(candidate_id) or candidate
- async def list_knowledge_candidates(
- connection_id: str,
- *,
- status: str = "",
- source_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))
- if status:
- if status not in VALID_CANDIDATE_STATUSES:
- raise AssistantDataError("invalid_status", "知识候选状态无效。")
- filters["status"] = status
- if source_id:
- filters["source_id"] = str(source_id)
- normalized_query = clean_text(query, max_length=100)
- if normalized_query:
- pattern = re.escape(normalized_query)
- filters["$or"] = [
- {"question": {"$regex": pattern, "$options": "i"}},
- {"answer": {"$regex": pattern, "$options": "i"}},
- {"keywords": {"$regex": pattern, "$options": "i"}},
- ]
- page = max(1, int(page))
- page_size = max(1, min(int(page_size), 100))
- total = await knowledge_candidatesdb.count_documents(filters)
- cursor = (
- knowledge_candidatesdb.find(filters)
- .sort("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()}},
- )
|