diff --git a/astrbot/core/__init__.py b/astrbot/core/__init__.py index 16f108ece..272a0f417 100644 --- a/astrbot/core/__init__.py +++ b/astrbot/core/__init__.py @@ -1,5 +1,4 @@ import os -import asyncio from .log import LogManager, LogBroker # noqa from astrbot.core.utils.t2i.renderer import HtmlRenderer from astrbot.core.utils.shared_preferences import SharedPreferences diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index d24d701c6..40a602d3d 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -6,8 +6,8 @@ import os from astrbot.core.utils.astrbot_path import get_astrbot_data_path -VERSION = "3.5.22" -DB_PATH = os.path.join(get_astrbot_data_path(), "data_v3.db") +VERSION = "4.0.0" +DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") # 默认配置 DEFAULT_CONFIG = { diff --git a/astrbot/core/conversation_mgr.py b/astrbot/core/conversation_mgr.py index b665488e4..62079b344 100644 --- a/astrbot/core/conversation_mgr.py +++ b/astrbot/core/conversation_mgr.py @@ -5,13 +5,12 @@ AstrBot 会话-对话管理器, 维护两个本地存储, 其中一个是 json 在一个会话中可以建立多个对话, 并且支持对话的切换和删除 """ -import uuid import json import asyncio from astrbot.core import sp from typing import Dict, List from astrbot.core.db import BaseDatabase -from astrbot.core.db.po import Conversation +from astrbot.core.db.po import Conversation, ConversationV2 class ConversationManager: @@ -38,7 +37,29 @@ class ConversationManager: """保存会话对话映射关系到存储中""" sp.put("session_conversation", self.session_conversations) - async def new_conversation(self, unified_msg_origin: str) -> str: + def _convert_conv_from_v2_to_v1(self, conv_v2: ConversationV2) -> Conversation: + """将 ConversationV2 对象转换为 Conversation 对象""" + created_at = int(conv_v2.created_at.timestamp()) + updated_at = int(conv_v2.updated_at.timestamp()) + return Conversation( + platform_id=conv_v2.platform_id, + user_id=conv_v2.user_id, + cid=conv_v2.conversation_id, + history=json.dumps(conv_v2.content or []), + title=conv_v2.title, + persona_id=conv_v2.persona_id, + created_at=created_at, + updated_at=updated_at, + ) + + async def new_conversation( + self, + unified_msg_origin: str, + platform_id: str = None, + content: list[dict] = None, + title: str = None, + persona_id: str = None, + ) -> str: """新建对话,并将当前会话的对话转移到新对话 Args: @@ -46,11 +67,23 @@ class ConversationManager: Returns: conversation_id (str): 对话 ID, 是 uuid 格式的字符串 """ - conversation_id = str(uuid.uuid4()) - self.db.new_conversation(user_id=unified_msg_origin, cid=conversation_id) - self.session_conversations[unified_msg_origin] = conversation_id + if not platform_id: + # 如果没有提供 platform_id,则从 unified_msg_origin 中解析 + parts = unified_msg_origin.split(":") + if len(parts) >= 3: + platform_id = parts[0] + if not platform_id: + platform_id = "unknown" + conv = await self.db.create_conversation( + user_id=unified_msg_origin, + platform_id=platform_id, + content=content, + title=title, + persona_id=persona_id, + ) + self.session_conversations[unified_msg_origin] = conv.conversation_id sp.put("session_conversation", self.session_conversations) - return conversation_id + return str(conv.conversation_id) async def switch_conversation(self, unified_msg_origin: str, conversation_id: str): """切换会话的对话 @@ -71,11 +104,16 @@ class ConversationManager: unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id conversation_id (str): 对话 ID, 是 uuid 格式的字符串 """ - conversation_id = self.session_conversations.get(unified_msg_origin) + f = False + if not conversation_id: + conversation_id = self.session_conversations.get(unified_msg_origin) + if conversation_id: + f = True if conversation_id: - self.db.delete_conversation(user_id=unified_msg_origin, cid=conversation_id) - del self.session_conversations[unified_msg_origin] - sp.put("session_conversation", self.session_conversations) + await self.db.delete_conversation(cid=conversation_id) + if f: + self.session_conversations.pop(unified_msg_origin, None) + sp.put("session_conversation", self.session_conversations) async def get_curr_conversation_id(self, unified_msg_origin: str) -> str: """获取会话当前的对话 ID @@ -92,7 +130,7 @@ class ConversationManager: unified_msg_origin: str, conversation_id: str, create_if_not_exists: bool = False, - ) -> Conversation: + ) -> Conversation | None: """获取会话的对话 Args: @@ -101,27 +139,74 @@ class ConversationManager: Returns: conversation (Conversation): 对话对象 """ - conv = self.db.get_conversation_by_user_id(unified_msg_origin, conversation_id) + conv = await self.db.get_conversation_by_id(cid=conversation_id) if not conv and create_if_not_exists: # 如果对话不存在且需要创建,则新建一个对话 conversation_id = await self.new_conversation(unified_msg_origin) - return self.db.get_conversation_by_user_id( - unified_msg_origin, conversation_id - ) - return self.db.get_conversation_by_user_id(unified_msg_origin, conversation_id) + conv = await self.db.get_conversation_by_id(cid=conversation_id) + conv_res = None + if conv: + conv_res = self._convert_conv_from_v2_to_v1(conv) + return conv_res - async def get_conversations(self, unified_msg_origin: str) -> List[Conversation]: - """获取会话的所有对话 + async def get_conversations( + self, unified_msg_origin: str = None, platform_id: str = None + ) -> List[Conversation]: + """获取对话列表 Args: - unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id + unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id,可选 + platform_id (str): 平台 ID, 可选参数, 用于过滤对话 Returns: conversations (List[Conversation]): 对话对象列表 """ - return self.db.get_conversations(unified_msg_origin) + convs = await self.db.get_conversations( + user_id=unified_msg_origin, platform_id=platform_id + ) + convs_res = [] + for conv in convs: + conv_res = self._convert_conv_from_v2_to_v1(conv) + convs_res.append(conv_res) + return convs_res + + async def get_filtered_conversations( + self, + page: int = 1, + page_size: int = 20, + platform_ids: list[str] | None = None, + search_query: str = "", + **kwargs, + ) -> tuple[list[Conversation], int]: + """获取过滤后的对话列表 + + Args: + page (int): 页码, 默认为 1 + page_size (int): 每页大小, 默认为 20 + platform_ids (list[str]): 平台 ID 列表, 可选 + search_query (str): 搜索查询字符串, 可选 + Returns: + conversations (list[Conversation]): 对话对象列表 + """ + convs, cnt = await self.db.get_filtered_conversations( + page=page, + page_size=page_size, + platform_ids=platform_ids, + search_query=search_query, + **kwargs, + ) + convs_res = [] + for conv in convs: + conv_res = self._convert_conv_from_v2_to_v1(conv) + convs_res.append(conv_res) + return convs_res, cnt async def update_conversation( - self, unified_msg_origin: str, conversation_id: str, history: List[Dict] + self, + unified_msg_origin: str, + conversation_id: str = None, + history: list[dict] = None, + title: str = None, + persona_id: str = None, ): """更新会话的对话 @@ -130,40 +215,52 @@ class ConversationManager: conversation_id (str): 对话 ID, 是 uuid 格式的字符串 history (List[Dict]): 对话历史记录, 是一个字典列表, 每个字典包含 role 和 content 字段 """ + if not conversation_id: + # 如果没有提供 conversation_id,则从 session_conversations 中获取当前的 + conversation_id = self.session_conversations.get(unified_msg_origin) if conversation_id: - self.db.update_conversation( - user_id=unified_msg_origin, + await self.db.update_conversation( cid=conversation_id, - history=json.dumps(history), + title=title, + persona_id=persona_id, + content=history or [], ) - async def update_conversation_title(self, unified_msg_origin: str, title: str): + async def update_conversation_title( + self, unified_msg_origin: str, title: str, conversation_id: str = None + ): """更新会话的对话标题 Args: unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id title (str): 对话标题 + + Deprecated: + Use `update_conversation` with `title` parameter instead. """ - conversation_id = self.session_conversations.get(unified_msg_origin) - if conversation_id: - self.db.update_conversation_title( - user_id=unified_msg_origin, cid=conversation_id, title=title - ) + await self.update_conversation( + unified_msg_origin=unified_msg_origin, + conversation_id=conversation_id, + title=title, + ) async def update_conversation_persona_id( - self, unified_msg_origin: str, persona_id: str + self, unified_msg_origin: str, persona_id: str, conversation_id: str = None ): """更新会话的对话 Persona ID Args: unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id persona_id (str): 对话 Persona ID + + Deprecated: + Use `update_conversation` with `persona_id` parameter instead. """ - conversation_id = self.session_conversations.get(unified_msg_origin) - if conversation_id: - self.db.update_conversation_persona_id( - user_id=unified_msg_origin, cid=conversation_id, persona_id=persona_id - ) + await self.update_conversation( + unified_msg_origin=unified_msg_origin, + conversation_id=conversation_id, + persona_id=persona_id, + ) async def get_human_readable_context( self, unified_msg_origin, conversation_id, page=1, page_size=10 diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index eccffbd64..8412d5bea 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -29,8 +29,10 @@ from astrbot.core.updator import AstrBotUpdator from astrbot.core import logger from astrbot.core.config.default import VERSION from astrbot.core.conversation_mgr import ConversationManager +from astrbot.core.platform_message_history_mgr import PlatformMessageHistoryManager from astrbot.core.star.star_handler import star_handlers_registry, EventType from astrbot.core.star.star_handler import star_map +from astrbot.core.db.migration.helper import do_migration_v4 class AstrBotCoreLifecycle: @@ -66,6 +68,9 @@ class AstrBotCoreLifecycle: else: logger.setLevel(self.astrbot_config["log_level"]) # 设置日志级别 + await self.db.initialize() + await do_migration_v4(self.db, {}) + # 初始化事件队列 self.event_queue = Queue() @@ -78,6 +83,9 @@ class AstrBotCoreLifecycle: # 初始化对话管理器 self.conversation_manager = ConversationManager(self.db) + # 初始化平台消息历史管理器 + self.platform_message_history_manager = PlatformMessageHistoryManager(self.db) + # 初始化提供给插件的上下文 self.star_context = Context( self.event_queue, diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py index 6688dcced..53fccacfc 100644 --- a/astrbot/core/db/__init__.py +++ b/astrbot/core/db/__init__.py @@ -1,7 +1,20 @@ import abc +import datetime +import typing as T +from deprecated import deprecated from dataclasses import dataclass -from typing import List, Dict, Any, Tuple -from astrbot.core.db.po import Stats, LLMHistory, ATRIVision, Conversation +from astrbot.core.db.po import ( + Stats, + PlatformStat, + ConversationV2, + PlatformMessageHistory, + Attachment, + Persona, + Preference, +) +from contextlib import asynccontextmanager +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker @dataclass @@ -10,152 +23,226 @@ class BaseDatabase(abc.ABC): 数据库基类 """ + DATABASE_URL = "" + def __init__(self) -> None: + self.engine = create_async_engine( + self.DATABASE_URL, + echo=False, + future=True, + ) + self.AsyncSessionLocal = sessionmaker( + self.engine, class_=AsyncSession, expire_on_commit=False + ) + + async def initialize(self): + """初始化数据库连接""" pass - def insert_base_metrics(self, metrics: dict): - """插入基础指标数据""" - self.insert_platform_metrics(metrics["platform_stats"]) - self.insert_plugin_metrics(metrics["plugin_stats"]) - self.insert_command_metrics(metrics["command_stats"]) - self.insert_llm_metrics(metrics["llm_stats"]) - - @abc.abstractmethod - def insert_platform_metrics(self, metrics: dict): - """插入平台指标数据""" - raise NotImplementedError - - @abc.abstractmethod - def insert_plugin_metrics(self, metrics: dict): - """插入插件指标数据""" - raise NotImplementedError - - @abc.abstractmethod - def insert_command_metrics(self, metrics: dict): - """插入指令指标数据""" - raise NotImplementedError - - @abc.abstractmethod - def insert_llm_metrics(self, metrics: dict): - """插入 LLM 指标数据""" - raise NotImplementedError - - @abc.abstractmethod - def update_llm_history(self, session_id: str, content: str, provider_type: str): - """更新 LLM 历史记录。当不存在 session_id 时插入""" - raise NotImplementedError - - @abc.abstractmethod - def get_llm_history( - self, session_id: str = None, provider_type: str = None - ) -> List[LLMHistory]: - """获取 LLM 历史记录, 如果 session_id 为 None, 返回所有""" - raise NotImplementedError + @asynccontextmanager + async def get_db(self) -> T.AsyncGenerator[AsyncSession, None]: + """Get a database session.""" + if not self.inited: + await self.initialize() + self.inited = True + async with self.AsyncSessionLocal() as session: + yield session + @deprecated(version="4.0.0", reason="Use get_platform_stats instead") @abc.abstractmethod def get_base_stats(self, offset_sec: int = 86400) -> Stats: """获取基础统计数据""" raise NotImplementedError + @deprecated(version="4.0.0", reason="Use get_platform_stats instead") @abc.abstractmethod def get_total_message_count(self) -> int: """获取总消息数""" raise NotImplementedError + @deprecated(version="4.0.0", reason="Use get_platform_stats instead") @abc.abstractmethod def get_grouped_base_stats(self, offset_sec: int = 86400) -> Stats: """获取基础统计数据(合并)""" raise NotImplementedError - @abc.abstractmethod - def insert_atri_vision_data(self, vision_data: ATRIVision): - """插入 ATRI 视觉数据""" - raise NotImplementedError + # New methods in v4.0.0 @abc.abstractmethod - def get_atri_vision_data(self) -> List[ATRIVision]: - """获取 ATRI 视觉数据""" - raise NotImplementedError + async def insert_platform_stats( + self, + platform_id: str, + platform_type: str, + count: int = 1, + timestamp: datetime.datetime = None, + ) -> None: + """Insert a new platform statistic record.""" + ... @abc.abstractmethod - def get_atri_vision_data_by_path_or_id( - self, url_or_path: str, id: str - ) -> ATRIVision: - """通过 url 或 path 获取 ATRI 视觉数据""" - raise NotImplementedError + async def count_platform_stats(self) -> int: + """Count the number of platform statistics records.""" + ... @abc.abstractmethod - def get_conversation_by_user_id(self, user_id: str, cid: str) -> Conversation: - """通过 user_id 和 cid 获取 Conversation""" - raise NotImplementedError + async def get_platform_stats(self, offset_sec: int = 86400) -> list[PlatformStat]: + """Get platform statistics within the specified offset in seconds and group by platform_id.""" + ... @abc.abstractmethod - def new_conversation(self, user_id: str, cid: str): - """新建 Conversation""" - raise NotImplementedError + async def get_conversations( + self, user_id: str = None, platform_id: str = None + ) -> list[ConversationV2]: + """Get all conversations for a specific user and platform_id(optional). - @abc.abstractmethod - def get_conversations(self, user_id: str) -> List[Conversation]: - raise NotImplementedError - - @abc.abstractmethod - def update_conversation(self, user_id: str, cid: str, history: str): - """更新 Conversation""" - raise NotImplementedError - - @abc.abstractmethod - def delete_conversation(self, user_id: str, cid: str): - """删除 Conversation""" - raise NotImplementedError - - @abc.abstractmethod - def update_conversation_title(self, user_id: str, cid: str, title: str): - """更新 Conversation 标题""" - raise NotImplementedError - - @abc.abstractmethod - def update_conversation_persona_id(self, user_id: str, cid: str, persona_id: str): - """更新 Conversation Persona ID""" - raise NotImplementedError - - @abc.abstractmethod - def get_all_conversations( - self, page: int = 1, page_size: int = 20 - ) -> Tuple[List[Dict[str, Any]], int]: - """获取所有对话,支持分页 - - Args: - page: 页码,从1开始 - page_size: 每页数量 - - Returns: - Tuple[List[Dict[str, Any]], int]: 返回一个元组,包含对话列表和总对话数 + content is not included in the result. """ - raise NotImplementedError + ... @abc.abstractmethod - def get_filtered_conversations( + async def get_conversation_by_id(self, cid: str) -> ConversationV2: + """Get a specific conversation by its ID.""" + ... + + @abc.abstractmethod + async def get_all_conversations( + self, page: int = 1, page_size: int = 20 + ) -> list[ConversationV2]: + """Get all conversations with pagination.""" + ... + + @abc.abstractmethod + async def get_filtered_conversations( self, page: int = 1, page_size: int = 20, - platforms: List[str] = None, - message_types: List[str] = None, - search_query: str = None, - exclude_ids: List[str] = None, - exclude_platforms: List[str] = None, - ) -> Tuple[List[Dict[str, Any]], int]: - """获取筛选后的对话列表 + platform_ids: list[str] | None = None, + search_query: str = "", + **kwargs, + ) -> tuple[list[ConversationV2], int]: + """Get conversations filtered by platform IDs and search query.""" + ... - Args: - page: 页码 - page_size: 每页数量 - platforms: 平台筛选列表 - message_types: 消息类型筛选列表 - search_query: 搜索关键词 - exclude_ids: 排除的用户ID列表 - exclude_platforms: 排除的平台列表 + @abc.abstractmethod + async def create_conversation( + self, + user_id: str, + platform_id: str, + content: list[dict] = None, + title: str = None, + persona_id: str = None, + cid: str = None, + created_at: datetime.datetime = None, + updated_at: datetime.datetime = None, + ) -> ConversationV2: + """Create a new conversation.""" + ... - Returns: - Tuple[List[Dict[str, Any]], int]: 返回一个元组,包含对话列表和总对话数 - """ - raise NotImplementedError + @abc.abstractmethod + async def update_conversation( + self, + cid: str, + title: str = None, + persona_id: str = None, + content: list[dict] = None, + ) -> None: + """Update a conversation's history.""" + ... + + @abc.abstractmethod + async def delete_conversation(self, cid: str) -> None: + """Delete a conversation by its ID.""" + ... + + @abc.abstractmethod + async def insert_platform_message_history( + self, + platform_id: str, + user_id: str, + content: list[dict], + sender_id: str = None, + sender_name: str = None, + ) -> None: + """Insert a new platform message history record.""" + ... + + @abc.abstractmethod + async def delete_platform_message_offset( + self, platform_id: str, user_id: str, offset_sec: int = 86400 + ) -> None: + """Delete platform message history records older than the specified offset.""" + ... + + @abc.abstractmethod + async def get_platform_message_history( + self, + platform_id: str, + user_id: str, + page: int = 1, + page_size: int = 20, + ) -> list[PlatformMessageHistory]: + """Get platform message history for a specific user.""" + ... + + @abc.abstractmethod + async def insert_attachment( + self, + path: str, + type: str, + mime_type: str, + ): + """Insert a new attachment record.""" + ... + + @abc.abstractmethod + async def get_attachment_by_id(self, attachment_id: str) -> Attachment: + """Get an attachment by its ID.""" + ... + + @abc.abstractmethod + async def insert_persona( + self, + persona_id: str, + system_prompt: str, + begin_dialogs: list[str] = None, + ) -> Persona: + """Insert a new persona record.""" + ... + + @abc.abstractmethod + async def get_persona_by_id(self, persona_id: str) -> Persona: + """Get a persona by its ID.""" + ... + + @abc.abstractmethod + async def get_personas(self) -> list[Persona]: + """Get all personas for a specific bot.""" + ... + + @abc.abstractmethod + async def insert_preference_or_update(self, key: str, value: str) -> Preference: + """Insert a new preference record.""" + ... + + @abc.abstractmethod + async def get_preference(self, key: str) -> Preference: + """Get a preference by bot ID and key.""" + ... + + # @abc.abstractmethod + # async def insert_llm_message( + # self, + # cid: str, + # role: str, + # content: list, + # tool_calls: list = None, + # tool_call_id: str = None, + # parent_id: str = None, + # ) -> LLMMessage: + # """Insert a new LLM message into the conversation.""" + # ... + + # @abc.abstractmethod + # async def get_llm_messages(self, cid: str) -> list[LLMMessage]: + # """Get all LLM messages for a specific conversation.""" + # ... diff --git a/astrbot/core/db/migration/helper.py b/astrbot/core/db/migration/helper.py new file mode 100644 index 000000000..d4b9c99f9 --- /dev/null +++ b/astrbot/core/db/migration/helper.py @@ -0,0 +1,51 @@ +import os +from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.db import BaseDatabase +from astrbot.api import logger +from .migra_3_to_4 import ( + migration_conversation_table, + migration_platform_table, + migration_webchat_data, +) + + +async def check_migration_needed_v4(db_helper: BaseDatabase) -> bool: + """ + 检查是否需要进行数据库迁移 + 如果存在 data_v3.db 并且 preference 中没有 migration_done_v4,则需要进行迁移。 + """ + data_v3_exists = os.path.exists(get_astrbot_data_path()) + if not data_v3_exists: + return False + migration_done = await db_helper.get_preference("migration_done_v4") + if migration_done: + return False + return True + + +async def do_migration_v4( + db_helper: BaseDatabase, platform_id_map: dict[str, dict[str, str]] +): + """ + 执行数据库迁移 + 迁移旧的 webchat_conversation 表到新的 conversation 表。 + 迁移旧的 platform 到新的 platform_stats 表。 + """ + if not await check_migration_needed_v4(db_helper): + return + + logger.info("开始执行数据库迁移...") + + # 执行会话表迁移 + await migration_conversation_table(db_helper, platform_id_map) + + # 执行平台统计表迁移 + await migration_platform_table(db_helper, platform_id_map) + + # 执行 WebChat 数据迁移 + await migration_webchat_data(db_helper, platform_id_map) + + # 标记迁移完成 + await db_helper.insert_preference_or_update("migration_done_v4", "true") + + logger.info("数据库迁移完成。") diff --git a/astrbot/core/db/migration/migra_3_to_4.py b/astrbot/core/db/migration/migra_3_to_4.py new file mode 100644 index 000000000..b6de3214e --- /dev/null +++ b/astrbot/core/db/migration/migra_3_to_4.py @@ -0,0 +1,214 @@ +import json +import datetime +from .. import BaseDatabase +from .sqlite_v3 import SQLiteDatabase as SQLiteV3DatabaseV3 +from astrbot.core.config.default import DB_PATH +from astrbot.api import logger +from astrbot.core.platform.astr_message_event import MessageSesion +from sqlalchemy.ext.asyncio import AsyncSession +from astrbot.core.db.po import ( + ConversationV2, + PlatformMessageHistory, +) +from sqlalchemy import text + +""" +1. 迁移旧的 webchat_conversation 表到新的 conversation 表。 +2. 迁移旧的 platform 到新的 platform_stats 表。 +""" + + +def get_platform_id( + platform_id_map: dict[str, dict[str, str]], old_platform_name: str +) -> str: + return platform_id_map.get( + old_platform_name, + {"platform_id": old_platform_name, "platform_type": old_platform_name}, + ).get("platform_id", old_platform_name) + + +def get_platform_type( + platform_id_map: dict[str, dict[str, str]], old_platform_name: str +) -> str: + return platform_id_map.get( + old_platform_name, + {"platform_id": old_platform_name, "platform_type": old_platform_name}, + ).get("platform_type", old_platform_name) + + +async def migration_conversation_table( + db_helper: BaseDatabase, platform_id_map: dict[str, dict[str, str]] +): + db_helper_v3 = SQLiteV3DatabaseV3( + db_path=DB_PATH.replace("data_v4.db", "data_v3.db") + ) + conversations, total_cnt = db_helper_v3.get_all_conversations( + page=1, page_size=10000000 + ) + logger.info(f"迁移 {total_cnt} 条旧的会话数据到新的表中...") + + async with db_helper.get_db() as dbsession: + dbsession: AsyncSession + async with dbsession.begin(): + for conversation in conversations: + try: + conv = db_helper_v3.get_conversation_by_user_id( + user_id=conversation.get("user_id", "unknown"), + cid=conversation.get("cid", "unknown"), + ) + if not conv: + logger.warning( + f"未找到该条旧会话对应的具体数据: {conversation}, 跳过。" + ) + if ":" not in conv.user_id: + logger.warning( + f"跳过 user_id 为 {conv.user_id} 的会话,它可能是 WebChat 的消息历史记录。" + ) + continue + session = MessageSesion.from_str(session_str=conv.user_id) + platform_id = get_platform_id( + platform_id_map, session.platform_name + ) + session.platform_name = platform_id # 更新平台名称为新的 ID + conv_v2 = ConversationV2( + user_id=str(session), + content=json.loads(conv.history) if conv.history else [], + platform_id=platform_id, + title=conv.title, + persona_id=conv.persona_id, + conversation_id=conv.cid, + created_at=datetime.datetime.fromtimestamp(conv.created_at), + updated_at=datetime.datetime.fromtimestamp(conv.updated_at), + ) + dbsession.add(conv_v2) + if conv_v2: + logger.info(f"迁移旧会话 {conv.cid} 到新表成功。") + except Exception as e: + logger.error( + f"迁移旧会话 {conversation.get('cid', 'unknown')} 失败: {e}", + exc_info=True, + ) + + +async def migration_platform_table( + db_helper: BaseDatabase, platform_id_map: dict[str, str] +): + db_helper_v3 = SQLiteV3DatabaseV3( + db_path=DB_PATH.replace("data_v4.db", "data_v3.db") + ) + secs_from_2023_4_10_to_now = ( + datetime.datetime.now(datetime.timezone.utc) + - datetime.datetime(2023, 4, 10, tzinfo=datetime.timezone.utc) + ).total_seconds() + offset_sec = int(secs_from_2023_4_10_to_now) + logger.info(f"迁移旧平台数据,offset_sec: {offset_sec} 秒。") + stats = db_helper_v3.get_base_stats(offset_sec=offset_sec) + logger.info(f"迁移 {len(stats.platform)} 条旧的平台数据到新的表中...") + platform_stats_v3 = stats.platform + + if not platform_stats_v3: + logger.warning("没有找到旧平台数据,跳过迁移。") + return + + first_time_stamp = platform_stats_v3[0].timestamp + end_time_stamp = platform_stats_v3[-1].timestamp + start_time = first_time_stamp - (first_time_stamp % 3600) # 向下取整到小时 + end_time = end_time_stamp + (3600 - (end_time_stamp % 3600)) # 向上取整到小时 + + idx = 0 + + async with db_helper.get_db() as dbsession: + dbsession: AsyncSession + async with dbsession.begin(): + for bucket_end in range(start_time, end_time, 3600): + cnt = 0 + while ( + idx < len(platform_stats_v3) + and platform_stats_v3[idx].timestamp < bucket_end + ): + cnt += platform_stats_v3[idx].count + idx += 1 + if cnt == 0: + continue + platform_id = get_platform_id( + platform_id_map, platform_stats_v3[idx].name + ) + platform_type = get_platform_type( + platform_id_map, platform_stats_v3[idx].name + ) + logger.info( + f"迁移平台统计数据: {platform_id}, {platform_type}, 时间戳: {bucket_end}, 计数: {cnt}" + ) + try: + await dbsession.execute( + text(""" + INSERT INTO platform_stats (timestamp, platform_id, platform_type, count) + VALUES (:timestamp, :platform_id, :platform_type, :count) + ON CONFLICT(timestamp, platform_id, platform_type) DO UPDATE SET + count = platform_stats.count + EXCLUDED.count + """), + { + "timestamp": datetime.datetime.fromtimestamp( + bucket_end, tz=datetime.timezone.utc + ), + "platform_id": platform_id, + "platform_type": platform_type, + "count": cnt, + }, + ) + except Exception: + logger.error( + f"迁移平台统计数据失败: {platform_id}, {platform_type}, 时间戳: {bucket_end}", + exc_info=True, + ) + + +async def migration_webchat_data( + db_helper: BaseDatabase, platform_id_map: dict[str, dict[str, str]] +): + """迁移 WebChat 的历史记录到新的 PlatformMessageHistory 表中""" + db_helper_v3 = SQLiteV3DatabaseV3( + db_path=DB_PATH.replace("data_v4.db", "data_v3.db") + ) + conversations, total_cnt = db_helper_v3.get_all_conversations( + page=1, page_size=10000000 + ) + logger.info(f"迁移 {total_cnt} 条旧的 WebChat 会话数据到新的表中...") + + async with db_helper.get_db() as dbsession: + dbsession: AsyncSession + async with dbsession.begin(): + for conversation in conversations: + try: + conv = db_helper_v3.get_conversation_by_user_id( + user_id=conversation.get("user_id", "unknown"), + cid=conversation.get("cid", "unknown"), + ) + if not conv: + logger.warning( + f"未找到该条旧会话对应的具体数据: {conversation}, 跳过。" + ) + if ":" in conv.user_id: + logger.warning( + f"跳过 user_id 为 {conv.user_id} 的会话,它不是 WebChat 的消息历史记录。" + ) + continue + platform_id = "webchat" + history = json.loads(conv.history) if conv.history else [] + for msg in history: + type_ = msg.get("type") # user type, "bot" or "user" + new_history = PlatformMessageHistory( + platform_id=platform_id, + user_id=conv.cid, # we use conv.cid as user_id for webchat + content=msg, + sender_id=type_, + sender_name=type_, + ) + dbsession.add(new_history) + + logger.info(f"迁移旧 WebChat 会话 {conv.cid} 到新表成功。") + except Exception: + logger.error( + f"迁移旧 WebChat 会话 {conversation.get('cid', 'unknown')} 失败", + exc_info=True, + ) diff --git a/astrbot/core/db/migration/sqlite_v3.py b/astrbot/core/db/migration/sqlite_v3.py new file mode 100644 index 000000000..e7e734abd --- /dev/null +++ b/astrbot/core/db/migration/sqlite_v3.py @@ -0,0 +1,493 @@ +import sqlite3 +import time +from astrbot.core.db.po import Platform, Stats +from typing import Tuple, List, Dict, Any +from dataclasses import dataclass + +@dataclass +class Conversation: + """LLM 对话存储 + + 对于网页聊天,history 存储了包括指令、回复、图片等在内的所有消息。 + 对于其他平台的聊天,不存储非 LLM 的回复(因为考虑到已经存储在各自的平台上)。 + """ + + user_id: str + cid: str + history: str = "" + """字符串格式的列表。""" + created_at: int = 0 + updated_at: int = 0 + title: str = "" + persona_id: str = "" + + +INIT_SQL = """ +CREATE TABLE IF NOT EXISTS platform( + name VARCHAR(32), + count INTEGER, + timestamp INTEGER +); +CREATE TABLE IF NOT EXISTS llm( + name VARCHAR(32), + count INTEGER, + timestamp INTEGER +); +CREATE TABLE IF NOT EXISTS plugin( + name VARCHAR(32), + count INTEGER, + timestamp INTEGER +); +CREATE TABLE IF NOT EXISTS command( + name VARCHAR(32), + count INTEGER, + timestamp INTEGER +); +CREATE TABLE IF NOT EXISTS llm_history( + provider_type VARCHAR(32), + session_id VARCHAR(32), + content TEXT +); + +-- ATRI +CREATE TABLE IF NOT EXISTS atri_vision( + id TEXT, + url_or_path TEXT, + caption TEXT, + is_meme BOOLEAN, + keywords TEXT, + platform_name VARCHAR(32), + session_id VARCHAR(32), + sender_nickname VARCHAR(32), + timestamp INTEGER +); + +CREATE TABLE IF NOT EXISTS webchat_conversation( + user_id TEXT, -- 会话 id + cid TEXT, -- 对话 id + history TEXT, + created_at INTEGER, + updated_at INTEGER, + title TEXT, + persona_id TEXT +); + +PRAGMA encoding = 'UTF-8'; +""" + + +class SQLiteDatabase(): + def __init__(self, db_path: str) -> None: + super().__init__() + self.db_path = db_path + + sql = INIT_SQL + + # 初始化数据库 + self.conn = self._get_conn(self.db_path) + c = self.conn.cursor() + c.executescript(sql) + self.conn.commit() + + # 检查 webchat_conversation 的 title 字段是否存在 + c.execute( + """ + PRAGMA table_info(webchat_conversation) + """ + ) + res = c.fetchall() + has_title = False + has_persona_id = False + for row in res: + if row[1] == "title": + has_title = True + if row[1] == "persona_id": + has_persona_id = True + if not has_title: + c.execute( + """ + ALTER TABLE webchat_conversation ADD COLUMN title TEXT; + """ + ) + self.conn.commit() + if not has_persona_id: + c.execute( + """ + ALTER TABLE webchat_conversation ADD COLUMN persona_id TEXT; + """ + ) + self.conn.commit() + + c.close() + + def _get_conn(self, db_path: str) -> sqlite3.Connection: + conn = sqlite3.connect(self.db_path) + conn.text_factory = str + return conn + + def _exec_sql(self, sql: str, params: Tuple = None): + conn = self.conn + try: + c = self.conn.cursor() + except sqlite3.ProgrammingError: + conn = self._get_conn(self.db_path) + c = conn.cursor() + + if params: + c.execute(sql, params) + c.close() + else: + c.execute(sql) + c.close() + + conn.commit() + + def insert_platform_metrics(self, metrics: dict): + for k, v in metrics.items(): + self._exec_sql( + """ + INSERT INTO platform(name, count, timestamp) VALUES (?, ?, ?) + """, + (k, v, int(time.time())), + ) + + def insert_llm_metrics(self, metrics: dict): + for k, v in metrics.items(): + self._exec_sql( + """ + INSERT INTO llm(name, count, timestamp) VALUES (?, ?, ?) + """, + (k, v, int(time.time())), + ) + + def get_base_stats(self, offset_sec: int = 86400) -> Stats: + """获取 offset_sec 秒前到现在的基础统计数据""" + where_clause = f" WHERE timestamp >= {int(time.time()) - offset_sec}" + + try: + c = self.conn.cursor() + except sqlite3.ProgrammingError: + c = self._get_conn(self.db_path).cursor() + + c.execute( + """ + SELECT * FROM platform + """ + + where_clause + ) + + platform = [] + for row in c.fetchall(): + platform.append(Platform(*row)) + + c.close() + + return Stats(platform=platform) + + def get_total_message_count(self) -> int: + try: + c = self.conn.cursor() + except sqlite3.ProgrammingError: + c = self._get_conn(self.db_path).cursor() + + c.execute( + """ + SELECT SUM(count) FROM platform + """ + ) + res = c.fetchone() + c.close() + return res[0] + + def get_grouped_base_stats(self, offset_sec: int = 86400) -> Stats: + """获取 offset_sec 秒前到现在的基础统计数据(合并)""" + where_clause = f" WHERE timestamp >= {int(time.time()) - offset_sec}" + + try: + c = self.conn.cursor() + except sqlite3.ProgrammingError: + c = self._get_conn(self.db_path).cursor() + + c.execute( + """ + SELECT name, SUM(count), timestamp FROM platform + """ + + where_clause + + " GROUP BY name" + ) + + platform = [] + for row in c.fetchall(): + platform.append(Platform(*row)) + + c.close() + + return Stats(platform, [], []) + + def get_conversation_by_user_id(self, user_id: str, cid: str) -> Conversation: + try: + c = self.conn.cursor() + except sqlite3.ProgrammingError: + c = self._get_conn(self.db_path).cursor() + + c.execute( + """ + SELECT * FROM webchat_conversation WHERE user_id = ? AND cid = ? + """, + (user_id, cid), + ) + + res = c.fetchone() + c.close() + + if not res: + return + + return Conversation(*res) + + def new_conversation(self, user_id: str, cid: str): + history = "[]" + updated_at = int(time.time()) + created_at = updated_at + self._exec_sql( + """ + INSERT INTO webchat_conversation(user_id, cid, history, updated_at, created_at) VALUES (?, ?, ?, ?, ?) + """, + (user_id, cid, history, updated_at, created_at), + ) + + def get_conversations(self, user_id: str) -> Tuple: + try: + c = self.conn.cursor() + except sqlite3.ProgrammingError: + c = self._get_conn(self.db_path).cursor() + + c.execute( + """ + SELECT cid, created_at, updated_at, title, persona_id FROM webchat_conversation WHERE user_id = ? ORDER BY updated_at DESC + """, + (user_id,), + ) + + res = c.fetchall() + c.close() + conversations = [] + for row in res: + cid = row[0] + created_at = row[1] + updated_at = row[2] + title = row[3] + persona_id = row[4] + conversations.append( + Conversation("", cid, "[]", created_at, updated_at, title, persona_id) + ) + return conversations + + def update_conversation(self, user_id: str, cid: str, history: str): + """更新对话,并且同时更新时间""" + updated_at = int(time.time()) + self._exec_sql( + """ + UPDATE webchat_conversation SET history = ?, updated_at = ? WHERE user_id = ? AND cid = ? + """, + (history, updated_at, user_id, cid), + ) + + def update_conversation_title(self, user_id: str, cid: str, title: str): + self._exec_sql( + """ + UPDATE webchat_conversation SET title = ? WHERE user_id = ? AND cid = ? + """, + (title, user_id, cid), + ) + + def update_conversation_persona_id(self, user_id: str, cid: str, persona_id: str): + self._exec_sql( + """ + UPDATE webchat_conversation SET persona_id = ? WHERE user_id = ? AND cid = ? + """, + (persona_id, user_id, cid), + ) + + def delete_conversation(self, user_id: str, cid: str): + self._exec_sql( + """ + DELETE FROM webchat_conversation WHERE user_id = ? AND cid = ? + """, + (user_id, cid), + ) + + def get_all_conversations( + self, page: int = 1, page_size: int = 20 + ) -> Tuple[List[Dict[str, Any]], int]: + """获取所有对话,支持分页,按更新时间降序排序""" + try: + c = self.conn.cursor() + except sqlite3.ProgrammingError: + c = self._get_conn(self.db_path).cursor() + + try: + # 获取总记录数 + c.execute(""" + SELECT COUNT(*) FROM webchat_conversation + """) + total_count = c.fetchone()[0] + + # 计算偏移量 + offset = (page - 1) * page_size + + # 获取分页数据,按更新时间降序排序 + c.execute( + """ + SELECT user_id, cid, created_at, updated_at, title, persona_id + FROM webchat_conversation + ORDER BY updated_at DESC + LIMIT ? OFFSET ? + """, + (page_size, offset), + ) + + rows = c.fetchall() + + conversations = [] + + for row in rows: + user_id, cid, created_at, updated_at, title, persona_id = row + # 确保 cid 是字符串类型且至少有8个字符,否则使用一个默认值 + safe_cid = str(cid) if cid else "unknown" + display_cid = safe_cid[:8] if len(safe_cid) >= 8 else safe_cid + + conversations.append( + { + "user_id": user_id or "", + "cid": safe_cid, + "title": title or f"对话 {display_cid}", + "persona_id": persona_id or "", + "created_at": created_at or 0, + "updated_at": updated_at or 0, + } + ) + + return conversations, total_count + + except Exception as _: + # 返回空列表和0,确保即使出错也有有效的返回值 + return [], 0 + finally: + c.close() + + def get_filtered_conversations( + self, + page: int = 1, + page_size: int = 20, + platforms: List[str] = None, + message_types: List[str] = None, + search_query: str = None, + exclude_ids: List[str] = None, + exclude_platforms: List[str] = None, + ) -> Tuple[List[Dict[str, Any]], int]: + """获取筛选后的对话列表""" + try: + c = self.conn.cursor() + except sqlite3.ProgrammingError: + c = self._get_conn(self.db_path).cursor() + + try: + # 构建查询条件 + where_clauses = [] + params = [] + + # 平台筛选 + if platforms and len(platforms) > 0: + platform_conditions = [] + for platform in platforms: + platform_conditions.append("user_id LIKE ?") + params.append(f"{platform}:%") + + if platform_conditions: + where_clauses.append(f"({' OR '.join(platform_conditions)})") + + # 消息类型筛选 + if message_types and len(message_types) > 0: + message_type_conditions = [] + for msg_type in message_types: + message_type_conditions.append("user_id LIKE ?") + params.append(f"%:{msg_type}:%") + + if message_type_conditions: + where_clauses.append(f"({' OR '.join(message_type_conditions)})") + + # 搜索关键词 + if search_query: + search_query = search_query.encode("unicode_escape").decode("utf-8") + where_clauses.append( + "(title LIKE ? OR user_id LIKE ? OR cid LIKE ? OR history LIKE ?)" + ) + search_param = f"%{search_query}%" + params.extend([search_param, search_param, search_param, search_param]) + + # 排除特定用户ID + if exclude_ids and len(exclude_ids) > 0: + for exclude_id in exclude_ids: + where_clauses.append("user_id NOT LIKE ?") + params.append(f"{exclude_id}%") + + # 排除特定平台 + if exclude_platforms and len(exclude_platforms) > 0: + for exclude_platform in exclude_platforms: + where_clauses.append("user_id NOT LIKE ?") + params.append(f"{exclude_platform}:%") + + # 构建完整的 WHERE 子句 + where_sql = " WHERE " + " AND ".join(where_clauses) if where_clauses else "" + + # 构建计数查询 + count_sql = f"SELECT COUNT(*) FROM webchat_conversation{where_sql}" + + # 获取总记录数 + c.execute(count_sql, params) + total_count = c.fetchone()[0] + + # 计算偏移量 + offset = (page - 1) * page_size + + # 构建分页数据查询 + data_sql = f""" + SELECT user_id, cid, created_at, updated_at, title, persona_id + FROM webchat_conversation + {where_sql} + ORDER BY updated_at DESC + LIMIT ? OFFSET ? + """ + query_params = params + [page_size, offset] + + # 获取分页数据 + c.execute(data_sql, query_params) + rows = c.fetchall() + + conversations = [] + + for row in rows: + user_id, cid, created_at, updated_at, title, persona_id = row + # 确保 cid 是字符串类型,否则使用一个默认值 + safe_cid = str(cid) if cid else "unknown" + display_cid = safe_cid[:8] if len(safe_cid) >= 8 else safe_cid + + conversations.append( + { + "user_id": user_id or "", + "cid": safe_cid, + "title": title or f"对话 {display_cid}", + "persona_id": persona_id or "", + "created_at": created_at or 0, + "updated_at": updated_at or 0, + } + ) + + return conversations, total_count + + except Exception as _: + # 返回空列表和0,确保即使出错也有有效的返回值 + return [], 0 + finally: + c.close() diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py index 49adb2781..30f7188a1 100644 --- a/astrbot/core/db/po.py +++ b/astrbot/core/db/po.py @@ -1,7 +1,190 @@ -"""指标数据""" +import uuid +from datetime import datetime, timezone from dataclasses import dataclass, field -from typing import List +from sqlmodel import ( + SQLModel, + Text, + JSON, + UniqueConstraint, + Field, +) +from typing import Optional + + +class PlatformStat(SQLModel, table=True): + """This class represents the statistics of bot usage across different platforms. + + Note: In astrbot v4, we moved `platform` table to here. + """ + + __tablename__ = "platform_stats" + + id: int = Field(primary_key=True, sa_column_kwargs={"autoincrement": True}) + timestamp: datetime = Field(nullable=False) + platform_id: str = Field(nullable=False) + platform_type: str = Field(nullable=False) # such as "aiocqhttp", "slack", etc. + count: int = Field(default=0, nullable=False) + + __table_args__ = ( + UniqueConstraint( + "timestamp", + "platform_id", + "platform_type", + name="uix_platform_stats", + ), + ) + + +class ConversationV2(SQLModel, table=True): + __tablename__ = "conversations" + + inner_conversation_id: int = Field(primary_key=True, sa_column_kwargs={"autoincrement": True}) + conversation_id: str = Field( + max_length=36, + nullable=False, + unique=True, + default_factory=lambda: str(uuid.uuid4()) + ) + platform_id: str = Field(nullable=False) + user_id: str = Field(nullable=False) + content: Optional[list] = Field(default=None, sa_type=JSON) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + sa_column_kwargs={"onupdate": datetime.now(timezone.utc)}, + ) + title: Optional[str] = Field(default=None, max_length=255) + persona_id: Optional[str] = Field(default=None) + + __table_args__ = ( + UniqueConstraint( + "conversation_id", + name="uix_conversation_id", + ), + ) + + +class Persona(SQLModel, table=True): + """Persona is a set of instructions for LLMs to follow. + + It can be used to customize the behavior of LLMs. + """ + + __tablename__ = "personas" + + id: int = Field(primary_key=True, sa_column_kwargs={"autoincrement": True}) + persona_id: str = Field(max_length=255, nullable=False) + system_prompt: str = Field(sa_type=Text, nullable=False) + begin_dialogs: Optional[list] = Field(default=None, sa_type=JSON) + """a list of strings, each representing a dialog to start with""" + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + sa_column_kwargs={"onupdate": datetime.now(timezone.utc)}, + ) + + __table_args__ = ( + UniqueConstraint( + "persona_id", + name="uix_persona_id", + ), + ) + + +class Preference(SQLModel, table=True): + """This class represents user preferences for bots.""" + + __tablename__ = "preferences" + + key: str = Field(primary_key=True, nullable=False) + value: str = Field(sa_type=Text, nullable=False) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + sa_column_kwargs={"onupdate": datetime.now(timezone.utc)}, + ) + + +class PlatformMessageHistory(SQLModel, table=True): + """This class represents the message history for a specific platform. + + It is used to store messages that are not LLM-generated, such as user messages + or platform-specific messages. + """ + + __tablename__ = "platform_message_history" + + id: int = Field(primary_key=True, sa_column_kwargs={"autoincrement": True}) + platform_id: str = Field(nullable=False) + user_id: str = Field(nullable=False) # An id of group, user in platform + sender_id: Optional[str] = Field(default=None) # ID of the sender in the platform + sender_name: Optional[str] = Field(default=None) # Name of the sender in the platform + content: dict = Field(sa_type=JSON, nullable=False) # a message chain list + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + sa_column_kwargs={"onupdate": datetime.now(timezone.utc)}, + ) + + +class Attachment(SQLModel, table=True): + """This class represents attachments for messages in AstrBot. + + Attachments can be images, files, or other media types. + """ + + __tablename__ = "attachments" + + inner_attachment_id: int = Field(primary_key=True, sa_column_kwargs={"autoincrement": True}) + attachment_id: str = Field( + max_length=36, + nullable=False, + unique=True, + default_factory=lambda: str(uuid.uuid4()) + ) + path: str = Field(nullable=False) # Path to the file on disk + type: str = Field(nullable=False) # Type of the file (e.g., 'image', 'file') + mime_type: str = Field(nullable=False) # MIME type of the file + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + sa_column_kwargs={"onupdate": datetime.now(timezone.utc)}, + ) + + __table_args__ = ( + UniqueConstraint( + "attachment_id", + name="uix_attachment_id", + ), + ) + + +@dataclass +class Conversation: + """LLM 对话类 + + 对于 WebChat,history 存储了包括指令、回复、图片等在内的所有消息。 + 对于其他平台的聊天,不存储非 LLM 的回复(因为考虑到已经存储在各自的平台上)。 + + 在 v4.0.0 版本及之后,WebChat 的历史记录被迁移至 `PlatformMessageHistory` 表中, + """ + + platform_id: str + user_id: str + cid: str + """对话 ID, 是 uuid 格式的字符串""" + history: str = "" + """字符串格式的对话列表。""" + title: str = "" + persona_id: str = "" + created_at: int = 0 + updated_at: int = 0 + + +# ==== +# Deprecated, and will be removed in future versions. +# ==== @dataclass @@ -13,77 +196,6 @@ class Platform: timestamp: int -@dataclass -class Provider: - """供应商使用统计数据""" - - name: str - count: int - timestamp: int - - -@dataclass -class Plugin: - """插件使用统计数据""" - - name: str - count: int - timestamp: int - - -@dataclass -class Command: - """命令使用统计数据""" - - name: str - count: int - timestamp: int - - @dataclass class Stats: - platform: List[Platform] = field(default_factory=list) - command: List[Command] = field(default_factory=list) - llm: List[Provider] = field(default_factory=list) - - -@dataclass -class LLMHistory: - """LLM 聊天时持久化的信息""" - - provider_type: str - session_id: str - content: str - - -@dataclass -class ATRIVision: - """Deprecated""" - - id: str - url_or_path: str - caption: str - is_meme: bool - keywords: List[str] - platform_name: str - session_id: str - sender_nickname: str - timestamp: int = -1 - - -@dataclass -class Conversation: - """LLM 对话存储 - - 对于网页聊天,history 存储了包括指令、回复、图片等在内的所有消息。 - 对于其他平台的聊天,不存储非 LLM 的回复(因为考虑到已经存储在各自的平台上)。 - """ - - user_id: str - cid: str - history: str = "" - """字符串格式的列表。""" - created_at: int = 0 - updated_at: int = 0 - title: str = "" - persona_id: str = "" + platform: list[Platform] = field(default_factory=list) diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index 2abba1de9..0308807da 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -1,567 +1,461 @@ -import sqlite3 -import os -import time -from astrbot.core.db.po import Platform, Stats, LLMHistory, ATRIVision, Conversation -from . import BaseDatabase -from typing import Tuple, List, Dict, Any +import asyncio +import typing as T +import threading +from datetime import datetime, timedelta +from astrbot.core.db import BaseDatabase +from astrbot.core.db.po import ( + ConversationV2, + PlatformStat, + PlatformMessageHistory, + Attachment, + Persona, + Preference, + Stats as DeprecatedStats, + Platform as DeprecatedPlatformStat, + SQLModel, +) + +from sqlalchemy import select, update, delete, text +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql import func class SQLiteDatabase(BaseDatabase): def __init__(self, db_path: str) -> None: - super().__init__() self.db_path = db_path + self.DATABASE_URL = f"sqlite+aiosqlite:///{db_path}" + self.inited = False + super().__init__() - with open( - os.path.dirname(__file__) + "/sqlite_init.sql", "r", encoding="utf-8" - ) as f: - sql = f.read() + async def initialize(self) -> None: + """Initialize the database by creating tables if they do not exist.""" + async with self.engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + await conn.commit() - # 初始化数据库 - self.conn = self._get_conn(self.db_path) - c = self.conn.cursor() - c.executescript(sql) - self.conn.commit() + # ==== + # Platform Statistics + # ==== - # 检查 webchat_conversation 的 title 字段是否存在 - c.execute( - """ - PRAGMA table_info(webchat_conversation) - """ - ) - res = c.fetchall() - has_title = False - has_persona_id = False - for row in res: - if row[1] == "title": - has_title = True - if row[1] == "persona_id": - has_persona_id = True - if not has_title: - c.execute( - """ - ALTER TABLE webchat_conversation ADD COLUMN title TEXT; - """ - ) - self.conn.commit() - if not has_persona_id: - c.execute( - """ - ALTER TABLE webchat_conversation ADD COLUMN persona_id TEXT; - """ - ) - self.conn.commit() - - c.close() - - def _get_conn(self, db_path: str) -> sqlite3.Connection: - conn = sqlite3.connect(self.db_path) - conn.text_factory = str - return conn - - def _exec_sql(self, sql: str, params: Tuple = None): - conn = self.conn - try: - c = self.conn.cursor() - except sqlite3.ProgrammingError: - conn = self._get_conn(self.db_path) - c = conn.cursor() - - if params: - c.execute(sql, params) - c.close() - else: - c.execute(sql) - c.close() - - conn.commit() - - def insert_platform_metrics(self, metrics: dict): - for k, v in metrics.items(): - self._exec_sql( - """ - INSERT INTO platform(name, count, timestamp) VALUES (?, ?, ?) - """, - (k, v, int(time.time())), - ) - - def insert_plugin_metrics(self, metrics: dict): - pass - - def insert_command_metrics(self, metrics: dict): - for k, v in metrics.items(): - self._exec_sql( - """ - INSERT INTO command(name, count, timestamp) VALUES (?, ?, ?) - """, - (k, v, int(time.time())), - ) - - def insert_llm_metrics(self, metrics: dict): - for k, v in metrics.items(): - self._exec_sql( - """ - INSERT INTO llm(name, count, timestamp) VALUES (?, ?, ?) - """, - (k, v, int(time.time())), - ) - - def update_llm_history(self, session_id: str, content: str, provider_type: str): - res = self.get_llm_history(session_id, provider_type) - if res: - self._exec_sql( - """ - UPDATE llm_history SET content = ? WHERE session_id = ? AND provider_type = ? - """, - (content, session_id, provider_type), - ) - else: - self._exec_sql( - """ - INSERT INTO llm_history(provider_type, session_id, content) VALUES (?, ?, ?) - """, - (provider_type, session_id, content), - ) - - def get_llm_history( - self, session_id: str = None, provider_type: str = None - ) -> Tuple: - try: - c = self.conn.cursor() - except sqlite3.ProgrammingError: - c = self._get_conn(self.db_path).cursor() - - conditions = [] - params = [] - - if session_id: - conditions.append("session_id = ?") - params.append(session_id) - - if provider_type: - conditions.append("provider_type = ?") - params.append(provider_type) - - sql = "SELECT * FROM llm_history" - if conditions: - sql += " WHERE " + " AND ".join(conditions) - - c.execute(sql, params) - - res = c.fetchall() - histories = [] - for row in res: - histories.append(LLMHistory(*row)) - c.close() - return histories - - def get_base_stats(self, offset_sec: int = 86400) -> Stats: - """获取 offset_sec 秒前到现在的基础统计数据""" - where_clause = f" WHERE timestamp >= {int(time.time()) - offset_sec}" - - try: - c = self.conn.cursor() - except sqlite3.ProgrammingError: - c = self._get_conn(self.db_path).cursor() - - c.execute( - """ - SELECT * FROM platform - """ - + where_clause - ) - - platform = [] - for row in c.fetchall(): - platform.append(Platform(*row)) - - # c.execute( - # ''' - # SELECT * FROM command - # ''' + where_clause - # ) - - # command = [] - # for row in c.fetchall(): - # command.append(Command(*row)) - - # c.execute( - # ''' - # SELECT * FROM llm - # ''' + where_clause - # ) - - # llm = [] - # for row in c.fetchall(): - # llm.append(Provider(*row)) - - c.close() - - return Stats(platform, [], []) - - def get_total_message_count(self) -> int: - try: - c = self.conn.cursor() - except sqlite3.ProgrammingError: - c = self._get_conn(self.db_path).cursor() - - c.execute( - """ - SELECT SUM(count) FROM platform - """ - ) - res = c.fetchone() - c.close() - return res[0] - - def get_grouped_base_stats(self, offset_sec: int = 86400) -> Stats: - """获取 offset_sec 秒前到现在的基础统计数据(合并)""" - where_clause = f" WHERE timestamp >= {int(time.time()) - offset_sec}" - - try: - c = self.conn.cursor() - except sqlite3.ProgrammingError: - c = self._get_conn(self.db_path).cursor() - - c.execute( - """ - SELECT name, SUM(count), timestamp FROM platform - """ - + where_clause - + " GROUP BY name" - ) - - platform = [] - for row in c.fetchall(): - platform.append(Platform(*row)) - - c.close() - - return Stats(platform, [], []) - - def get_conversation_by_user_id(self, user_id: str, cid: str) -> Conversation: - try: - c = self.conn.cursor() - except sqlite3.ProgrammingError: - c = self._get_conn(self.db_path).cursor() - - c.execute( - """ - SELECT * FROM webchat_conversation WHERE user_id = ? AND cid = ? - """, - (user_id, cid), - ) - - res = c.fetchone() - c.close() - - if not res: - return - - return Conversation(*res) - - def new_conversation(self, user_id: str, cid: str): - history = "[]" - updated_at = int(time.time()) - created_at = updated_at - self._exec_sql( - """ - INSERT INTO webchat_conversation(user_id, cid, history, updated_at, created_at) VALUES (?, ?, ?, ?, ?) - """, - (user_id, cid, history, updated_at, created_at), - ) - - def get_conversations(self, user_id: str) -> Tuple: - try: - c = self.conn.cursor() - except sqlite3.ProgrammingError: - c = self._get_conn(self.db_path).cursor() - - c.execute( - """ - SELECT cid, created_at, updated_at, title, persona_id FROM webchat_conversation WHERE user_id = ? ORDER BY updated_at DESC - """, - (user_id,), - ) - - res = c.fetchall() - c.close() - conversations = [] - for row in res: - cid = row[0] - created_at = row[1] - updated_at = row[2] - title = row[3] - persona_id = row[4] - conversations.append( - Conversation("", cid, "[]", created_at, updated_at, title, persona_id) - ) - return conversations - - def update_conversation(self, user_id: str, cid: str, history: str): - """更新对话,并且同时更新时间""" - updated_at = int(time.time()) - self._exec_sql( - """ - UPDATE webchat_conversation SET history = ?, updated_at = ? WHERE user_id = ? AND cid = ? - """, - (history, updated_at, user_id, cid), - ) - - def update_conversation_title(self, user_id: str, cid: str, title: str): - self._exec_sql( - """ - UPDATE webchat_conversation SET title = ? WHERE user_id = ? AND cid = ? - """, - (title, user_id, cid), - ) - - def update_conversation_persona_id(self, user_id: str, cid: str, persona_id: str): - self._exec_sql( - """ - UPDATE webchat_conversation SET persona_id = ? WHERE user_id = ? AND cid = ? - """, - (persona_id, user_id, cid), - ) - - def delete_conversation(self, user_id: str, cid: str): - self._exec_sql( - """ - DELETE FROM webchat_conversation WHERE user_id = ? AND cid = ? - """, - (user_id, cid), - ) - - def insert_atri_vision_data(self, vision: ATRIVision): - ts = int(time.time()) - keywords = ",".join(vision.keywords) - self._exec_sql( - """ - INSERT INTO atri_vision(id, url_or_path, caption, is_meme, keywords, platform_name, session_id, sender_nickname, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - vision.id, - vision.url_or_path, - vision.caption, - vision.is_meme, - keywords, - vision.platform_name, - vision.session_id, - vision.sender_nickname, - ts, - ), - ) - - def get_atri_vision_data(self) -> Tuple: - try: - c = self.conn.cursor() - except sqlite3.ProgrammingError: - c = self._get_conn(self.db_path).cursor() - - c.execute( - """ - SELECT * FROM atri_vision - """ - ) - - res = c.fetchall() - visions = [] - for row in res: - visions.append(ATRIVision(*row)) - c.close() - return visions - - def get_atri_vision_data_by_path_or_id( - self, url_or_path: str, id: str - ) -> ATRIVision: - try: - c = self.conn.cursor() - except sqlite3.ProgrammingError: - c = self._get_conn(self.db_path).cursor() - - c.execute( - """ - SELECT * FROM atri_vision WHERE url_or_path = ? OR id = ? - """, - (url_or_path, id), - ) - - res = c.fetchone() - c.close() - if res: - return ATRIVision(*res) - return None - - def get_all_conversations( - self, page: int = 1, page_size: int = 20 - ) -> Tuple[List[Dict[str, Any]], int]: - """获取所有对话,支持分页,按更新时间降序排序""" - try: - c = self.conn.cursor() - except sqlite3.ProgrammingError: - c = self._get_conn(self.db_path).cursor() - - try: - # 获取总记录数 - c.execute(""" - SELECT COUNT(*) FROM webchat_conversation - """) - total_count = c.fetchone()[0] - - # 计算偏移量 - offset = (page - 1) * page_size - - # 获取分页数据,按更新时间降序排序 - c.execute( - """ - SELECT user_id, cid, created_at, updated_at, title, persona_id - FROM webchat_conversation - ORDER BY updated_at DESC - LIMIT ? OFFSET ? - """, - (page_size, offset), - ) - - rows = c.fetchall() - - conversations = [] - - for row in rows: - user_id, cid, created_at, updated_at, title, persona_id = row - # 确保 cid 是字符串类型且至少有8个字符,否则使用一个默认值 - safe_cid = str(cid) if cid else "unknown" - display_cid = safe_cid[:8] if len(safe_cid) >= 8 else safe_cid - - conversations.append( - { - "user_id": user_id or "", - "cid": safe_cid, - "title": title or f"对话 {display_cid}", - "persona_id": persona_id or "", - "created_at": created_at or 0, - "updated_at": updated_at or 0, - } - ) - - return conversations, total_count - - except Exception as _: - # 返回空列表和0,确保即使出错也有有效的返回值 - return [], 0 - finally: - c.close() - - def get_filtered_conversations( + async def insert_platform_stats( self, - page: int = 1, - page_size: int = 20, - platforms: List[str] = None, - message_types: List[str] = None, - search_query: str = None, - exclude_ids: List[str] = None, - exclude_platforms: List[str] = None, - ) -> Tuple[List[Dict[str, Any]], int]: - """获取筛选后的对话列表""" - try: - c = self.conn.cursor() - except sqlite3.ProgrammingError: - c = self._get_conn(self.db_path).cursor() - - try: - # 构建查询条件 - where_clauses = [] - params = [] - - # 平台筛选 - if platforms and len(platforms) > 0: - platform_conditions = [] - for platform in platforms: - platform_conditions.append("user_id LIKE ?") - params.append(f"{platform}:%") - - if platform_conditions: - where_clauses.append(f"({' OR '.join(platform_conditions)})") - - # 消息类型筛选 - if message_types and len(message_types) > 0: - message_type_conditions = [] - for msg_type in message_types: - message_type_conditions.append("user_id LIKE ?") - params.append(f"%:{msg_type}:%") - - if message_type_conditions: - where_clauses.append(f"({' OR '.join(message_type_conditions)})") - - # 搜索关键词 - if search_query: - search_query = search_query.encode("unicode_escape").decode("utf-8") - where_clauses.append( - "(title LIKE ? OR user_id LIKE ? OR cid LIKE ? OR history LIKE ?)" - ) - search_param = f"%{search_query}%" - params.extend([search_param, search_param, search_param, search_param]) - - # 排除特定用户ID - if exclude_ids and len(exclude_ids) > 0: - for exclude_id in exclude_ids: - where_clauses.append("user_id NOT LIKE ?") - params.append(f"{exclude_id}%") - - # 排除特定平台 - if exclude_platforms and len(exclude_platforms) > 0: - for exclude_platform in exclude_platforms: - where_clauses.append("user_id NOT LIKE ?") - params.append(f"{exclude_platform}:%") - - # 构建完整的 WHERE 子句 - where_sql = " WHERE " + " AND ".join(where_clauses) if where_clauses else "" - - # 构建计数查询 - count_sql = f"SELECT COUNT(*) FROM webchat_conversation{where_sql}" - - # 获取总记录数 - c.execute(count_sql, params) - total_count = c.fetchone()[0] - - # 计算偏移量 - offset = (page - 1) * page_size - - # 构建分页数据查询 - data_sql = f""" - SELECT user_id, cid, created_at, updated_at, title, persona_id - FROM webchat_conversation - {where_sql} - ORDER BY updated_at DESC - LIMIT ? OFFSET ? - """ - query_params = params + [page_size, offset] - - # 获取分页数据 - c.execute(data_sql, query_params) - rows = c.fetchall() - - conversations = [] - - for row in rows: - user_id, cid, created_at, updated_at, title, persona_id = row - # 确保 cid 是字符串类型,否则使用一个默认值 - safe_cid = str(cid) if cid else "unknown" - display_cid = safe_cid[:8] if len(safe_cid) >= 8 else safe_cid - - conversations.append( + platform_id: str, + platform_type: str, + count: int = 1, + timestamp: datetime = None, + ) -> None: + """Insert a new platform statistic record.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + if timestamp is None: + timestamp = datetime.now().replace( + minute=0, second=0, microsecond=0 + ) + current_hour = timestamp + await session.execute( + text(""" + INSERT INTO platform_stats (timestamp, platform_id, platform_type, count) + VALUES (:timestamp, :platform_id, :platform_type, :count) + ON CONFLICT(timestamp, platform_id, platform_type) DO UPDATE SET + count = platform_stats.count + EXCLUDED.count + """), { - "user_id": user_id or "", - "cid": safe_cid, - "title": title or f"对话 {display_cid}", - "persona_id": persona_id or "", - "created_at": created_at or 0, - "updated_at": updated_at or 0, - } + "timestamp": current_hour, + "platform_id": platform_id, + "platform_type": platform_type, + "count": count, + }, ) - return conversations, total_count + async def count_platform_stats(self) -> int: + """Count the number of platform statistics records.""" + async with self.get_db() as session: + session: AsyncSession + result = await session.execute( + select(func.count(PlatformStat.platform_id)).select_from(PlatformStat) + ) + count = result.scalar_one_or_none() + return count if count is not None else 0 - except Exception as _: - # 返回空列表和0,确保即使出错也有有效的返回值 - return [], 0 - finally: - c.close() + async def get_platform_stats(self, offset_sec: int = 86400) -> T.List[PlatformStat]: + """Get platform statistics within the specified offset in seconds and group by platform_id.""" + async with self.get_db() as session: + session: AsyncSession + now = datetime.now() + start_time = now - timedelta(seconds=offset_sec) + result = await session.execute( + text(""" + SELECT * FROM platform_stats + WHERE timestamp >= :start_time + ORDER BY timestamp DESC + GROUP BY platform_id + """), + {"start_time": start_time}, + ) + return result.scalars().all() + + # ==== + # Conversation Management + # ==== + + async def get_conversations( + self, user_id=None, platform_id=None + ): + async with self.get_db() as session: + session: AsyncSession + query = select(ConversationV2) + + if user_id: + query = query.where(ConversationV2.user_id == user_id) + if platform_id: + query = query.where(ConversationV2.platform_id == platform_id) + # order by + query = query.order_by(ConversationV2.created_at.desc()) + result = await session.execute(query) + + return result.scalars().all() + + async def get_conversation_by_id(self, cid): + async with self.get_db() as session: + session: AsyncSession + query = select(ConversationV2).where(ConversationV2.conversation_id == cid) + result = await session.execute(query) + return result.scalar_one_or_none() + + async def get_all_conversations(self, page=1, page_size=20): + async with self.get_db() as session: + session: AsyncSession + offset = (page - 1) * page_size + result = await session.execute( + select(ConversationV2) + .order_by(ConversationV2.created_at.desc()) + .offset(offset) + .limit(page_size) + ) + return result.scalars().all() + + async def get_filtered_conversations( + self, + page=1, + page_size=20, + platform_ids=None, + search_query="", + **kwargs, + ): + async with self.get_db() as session: + session: AsyncSession + # Build the base query with filters + base_query = select(ConversationV2) + + if platform_ids: + base_query = base_query.where( + ConversationV2.platform_id.in_(platform_ids) + ) + if search_query: + base_query = base_query.where( + ConversationV2.title.ilike(f"%{search_query}%") + ) + + # Get total count matching the filters + count_query = select(func.count()).select_from(base_query.subquery()) + total_count = await session.execute(count_query) + total = total_count.scalar_one() + + # Get paginated results + offset = (page - 1) * page_size + result_query = ( + base_query.order_by(ConversationV2.created_at.desc()) + .offset(offset) + .limit(page_size) + ) + result = await session.execute(result_query) + conversations = result.scalars().all() + + return conversations, total + + async def create_conversation( + self, + user_id, + platform_id, + content=None, + title=None, + persona_id=None, + cid=None, + created_at=None, + updated_at=None, + ): + kwargs = {} + if cid: + kwargs["conversation_id"] = cid + if created_at: + kwargs["created_at"] = created_at + if updated_at: + kwargs["updated_at"] = updated_at + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + new_conversation = ConversationV2( + user_id=user_id, + content=content or [], + platform_id=platform_id, + title=title, + persona_id=persona_id, + **kwargs, + ) + session.add(new_conversation) + return new_conversation + + async def update_conversation(self, cid, title=None, persona_id=None, content=None): + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + query = update(ConversationV2).where( + ConversationV2.conversation_id == cid + ) + values = {} + if title is not None: + values["title"] = title + if persona_id is not None: + values["persona_id"] = persona_id + if content is not None: + values["content"] = content + if not values: + return + query = query.values(**values) + await session.execute(query) + return await self.get_conversation_by_id(cid) + + async def delete_conversation(self, cid): + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + await session.execute( + delete(ConversationV2).where(ConversationV2.conversation_id == cid) + ) + + async def insert_platform_message_history( + self, + platform_id, + user_id, + content, + sender_id=None, + sender_name=None, + ): + """Insert a new platform message history record.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + new_history = PlatformMessageHistory( + platform_id=platform_id, + user_id=user_id, + content=content, + sender_id=sender_id, + sender_name=sender_name, + ) + session.add(new_history) + return new_history + + async def delete_platform_message_offset( + self, platform_id, user_id, offset_sec=86400 + ): + """Delete platform message history records older than the specified offset.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + now = datetime.now() + cutoff_time = now - timedelta(seconds=offset_sec) + await session.execute( + delete(PlatformMessageHistory).where( + PlatformMessageHistory.platform_id == platform_id, + PlatformMessageHistory.user_id == user_id, + PlatformMessageHistory.created_at < cutoff_time, + ) + ) + + async def get_platform_message_history( + self, platform_id, user_id, page=1, page_size=20 + ): + """Get platform message history records.""" + async with self.get_db() as session: + session: AsyncSession + offset = (page - 1) * page_size + query = ( + select(PlatformMessageHistory) + .where( + PlatformMessageHistory.platform_id == platform_id, + PlatformMessageHistory.user_id == user_id, + ) + .order_by(PlatformMessageHistory.created_at.desc()) + ) + result = await session.execute(query.offset(offset).limit(page_size)) + return result.scalars().all() + + async def insert_attachment(self, path, type, mime_type): + """Insert a new attachment record.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + new_attachment = Attachment( + path=path, + type=type, + mime_type=mime_type, + ) + session.add(new_attachment) + return new_attachment + + async def get_attachment_by_id(self, attachment_id): + """Get an attachment by its ID.""" + async with self.get_db() as session: + session: AsyncSession + query = select(Attachment).where(Attachment.id == attachment_id) + result = await session.execute(query) + return result.scalar_one_or_none() + + async def insert_persona(self, persona_id, system_prompt, begin_dialogs=None): + """Insert a new persona record.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + new_persona = Persona( + persona_id=persona_id, + system_prompt=system_prompt, + begin_dialogs=begin_dialogs or [], + ) + session.add(new_persona) + return new_persona + + async def get_persona_by_id(self, persona_id): + """Get a persona by its ID.""" + async with self.get_db() as session: + session: AsyncSession + query = select(Persona).where(Persona.persona_id == persona_id) + result = await session.execute(query) + return result.scalar_one_or_none() + + async def get_personas(self): + """Get all personas for a specific bot.""" + async with self.get_db() as session: + session: AsyncSession + query = select(Persona) + result = await session.execute(query) + return result.scalars().all() + + async def insert_preference_or_update(self, key, value): + """Insert a new preference record or update if it exists.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + query = select(Preference).where(Preference.key == key) + result = await session.execute(query) + existing_preference = result.scalar_one_or_none() + if existing_preference: + existing_preference.value = value + else: + new_preference = Preference(key=key, value=value) + session.add(new_preference) + return existing_preference or new_preference + + async def get_preference(self, key): + """Get a preference by key.""" + async with self.get_db() as session: + session: AsyncSession + query = select(Preference).where(Preference.key == key) + result = await session.execute(query) + return result.scalar_one_or_none() + + # ==== + # Deprecated Methods + # ==== + + def get_base_stats(self, offset_sec=86400): + """Get base statistics within the specified offset in seconds.""" + + async def _inner(): + async with self.get_db() as session: + session: AsyncSession + now = datetime.now() + start_time = now - timedelta(seconds=offset_sec) + result = await session.execute( + select(PlatformStat).where(PlatformStat.timestamp >= start_time) + ) + all_datas = result.scalars().all() + deprecated_stats = DeprecatedStats() + for data in all_datas: + deprecated_stats.platform.append( + DeprecatedPlatformStat( + name=data.platform_id, + count=data.count, + timestamp=data.timestamp.timestamp(), + ) + ) + return deprecated_stats + + result = None + + def runner(): + nonlocal result + result = asyncio.run(_inner()) + + t = threading.Thread(target=runner) + t.start() + t.join() + return result + + def get_total_message_count(self): + """Get the total message count from platform statistics.""" + + async def _inner(): + async with self.get_db() as session: + session: AsyncSession + result = await session.execute( + select(func.sum(PlatformStat.count)).select_from(PlatformStat) + ) + total_count = result.scalar_one_or_none() + return total_count if total_count is not None else 0 + + result = None + + def runner(): + nonlocal result + result = asyncio.run(_inner()) + + t = threading.Thread(target=runner) + t.start() + t.join() + return result + + def get_grouped_base_stats(self, offset_sec=86400): + # group by platform_id + async def _inner(): + async with self.get_db() as session: + session: AsyncSession + now = datetime.now() + start_time = now - timedelta(seconds=offset_sec) + result = await session.execute( + select(PlatformStat.platform_id, func.sum(PlatformStat.count)) + .where(PlatformStat.timestamp >= start_time) + .group_by(PlatformStat.platform_id) + ) + grouped_stats = result.all() + deprecated_stats = DeprecatedStats() + for platform_id, count in grouped_stats: + deprecated_stats.platform.append( + DeprecatedPlatformStat( + name=platform_id, + count=count, + timestamp=start_time.timestamp(), + ) + ) + return deprecated_stats + + result = None + + def runner(): + nonlocal result + result = asyncio.run(_inner()) + + t = threading.Thread(target=runner) + t.start() + t.join() + return result diff --git a/astrbot/core/db/sqlite_init.sql b/astrbot/core/db/sqlite_init.sql deleted file mode 100644 index a1ebc54b5..000000000 --- a/astrbot/core/db/sqlite_init.sql +++ /dev/null @@ -1,50 +0,0 @@ -CREATE TABLE IF NOT EXISTS platform( - name VARCHAR(32), - count INTEGER, - timestamp INTEGER -); -CREATE TABLE IF NOT EXISTS llm( - name VARCHAR(32), - count INTEGER, - timestamp INTEGER -); -CREATE TABLE IF NOT EXISTS plugin( - name VARCHAR(32), - count INTEGER, - timestamp INTEGER -); -CREATE TABLE IF NOT EXISTS command( - name VARCHAR(32), - count INTEGER, - timestamp INTEGER -); -CREATE TABLE IF NOT EXISTS llm_history( - provider_type VARCHAR(32), - session_id VARCHAR(32), - content TEXT -); - --- ATRI -CREATE TABLE IF NOT EXISTS atri_vision( - id TEXT, - url_or_path TEXT, - caption TEXT, - is_meme BOOLEAN, - keywords TEXT, - platform_name VARCHAR(32), - session_id VARCHAR(32), - sender_nickname VARCHAR(32), - timestamp INTEGER -); - -CREATE TABLE IF NOT EXISTS webchat_conversation( - user_id TEXT, -- 会话 id - cid TEXT, -- 对话 id - history TEXT, - created_at INTEGER, - updated_at INTEGER, - title TEXT, - persona_id TEXT -); - -PRAGMA encoding = 'UTF-8'; \ No newline at end of file diff --git a/astrbot/core/pipeline/process_stage/method/llm_request.py b/astrbot/core/pipeline/process_stage/method/llm_request.py index 2d9392e6d..1344d4dec 100644 --- a/astrbot/core/pipeline/process_stage/method/llm_request.py +++ b/astrbot/core/pipeline/process_stage/method/llm_request.py @@ -266,12 +266,12 @@ class LLMRequestSubStage(Stage): else: async for _ in requesting(): yield + await self._save_to_history(event, req, tool_loop_agent.get_final_llm_resp()) # 异步处理 WebChat 特殊情况 if event.get_platform_name() == "webchat": asyncio.create_task(self._handle_webchat(event, req, provider)) - await self._save_to_history(event, req, tool_loop_agent.get_final_llm_resp()) async def _handle_webchat( self, event: AstrMessageEvent, req: ProviderRequest, prov: Provider diff --git a/astrbot/core/platform/astr_message_event.py b/astrbot/core/platform/astr_message_event.py index 7a3102de5..e565c13ce 100644 --- a/astrbot/core/platform/astr_message_event.py +++ b/astrbot/core/platform/astr_message_event.py @@ -26,7 +26,7 @@ from .platform_metadata import PlatformMetadata @dataclass -class MessageSesion: +class MessageSession: platform_name: str message_type: MessageType session_id: str @@ -37,8 +37,9 @@ class MessageSesion: @staticmethod def from_str(session_str: str): platform_name, message_type, session_id = session_str.split(":") - return MessageSesion(platform_name, MessageType(message_type), session_id) + return MessageSession(platform_name, MessageType(message_type), session_id) +MessageSesion = MessageSession # back compatibility class AstrMessageEvent(abc.ABC): def __init__( diff --git a/astrbot/core/platform_message_history_mgr.py b/astrbot/core/platform_message_history_mgr.py new file mode 100644 index 000000000..16e59a5cc --- /dev/null +++ b/astrbot/core/platform_message_history_mgr.py @@ -0,0 +1,47 @@ +from astrbot.core.db import BaseDatabase +from astrbot.core.db.po import PlatformMessageHistory + + +class PlatformMessageHistoryManager: + def __init__(self, db_helper: BaseDatabase): + self.db = db_helper + + async def insert( + self, + platform_id: str, + user_id: str, + content: list[dict], # TODO: parse from message chain + sender_id: str = None, + sender_name: str = None, + ): + """Insert a new platform message history record.""" + await self.db.insert_platform_message_history( + platform_id=platform_id, + user_id=user_id, + content=content, + sender_id=sender_id, + sender_name=sender_name, + ) + + async def get( + self, + platform_id: str, + user_id: str, + page: int = 1, + page_size: int = 200, + ) -> list[PlatformMessageHistory]: + """Get platform message history for a specific user.""" + history = await self.db.get_platform_message_history( + platform_id=platform_id, + user_id=user_id, + page=page, + page_size=page_size, + ) + history.reverse() + return history + + async def delete(self, platform_id: str, user_id: str, offset_sec: int = 86400): + """Delete platform message history records older than the specified offset.""" + await self.db.delete_platform_message_offset( + platform_id=platform_id, user_id=user_id, offset_sec=offset_sec + ) diff --git a/astrbot/core/utils/metrics.py b/astrbot/core/utils/metrics.py index a3a73fcc8..7fe9bde05 100644 --- a/astrbot/core/utils/metrics.py +++ b/astrbot/core/utils/metrics.py @@ -58,9 +58,10 @@ class Metric: pass try: if "adapter_name" in kwargs: - db_helper.insert_platform_metrics({kwargs["adapter_name"]: 1}) - if "llm_name" in kwargs: - db_helper.insert_llm_metrics({kwargs["llm_name"]: 1}) + await db_helper.insert_platform_stats( + platform_id=kwargs["adapter_name"], + platform_type=kwargs.get("adapter_type", "unknown"), + ) except Exception as e: logger.error(f"保存指标到数据库失败: {e}") pass diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py index 651f1b65c..dde4c7644 100644 --- a/astrbot/dashboard/routes/chat.py +++ b/astrbot/dashboard/routes/chat.py @@ -9,6 +9,7 @@ import asyncio from astrbot.core import logger from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.platform.astr_message_event import MessageSession class ChatRoute(Route): @@ -31,13 +32,14 @@ class ChatRoute(Route): "/chat/post_file": ("POST", self.post_file), "/chat/status": ("GET", self.status), } - self.db = db self.core_lifecycle = core_lifecycle self.register_routes() self.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs") os.makedirs(self.imgs_dir, exist_ok=True) self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"] + self.conv_mgr = core_lifecycle.conversation_manager + self.platform_history_mgr = core_lifecycle.platform_message_history_manager async def status(self): has_llm_enabled = ( @@ -131,24 +133,23 @@ class ChatRoute(Route): if not conversation_id: return Response().error("conversation_id is empty").__dict__ - # Get conversation-specific queues - back_queue = webchat_queue_mgr.get_or_create_back_queue(conversation_id) - # append user message - conversation = self.db.get_conversation_by_user_id(username, conversation_id) - try: - history = json.loads(conversation.history) - except BaseException as e: - logger.error(f"Failed to parse conversation history: {e}") - history = [] + webchat_conv_id = await self._get_webchat_conv_id_from_conv_id(conversation_id) + + # Get conversation-specific queues + back_queue = webchat_queue_mgr.get_or_create_back_queue(webchat_conv_id) + new_his = {"type": "user", "message": message} if image_url: new_his["image_url"] = image_url if audio_url: new_his["audio_url"] = audio_url - history.append(new_his) - self.db.update_conversation( - username, conversation_id, history=json.dumps(history) + await self.platform_history_mgr.insert( + platform_id="webchat", + user_id=webchat_conv_id, + content=new_his, + sender_id=username, + sender_name=username, ) async def stream(): @@ -164,7 +165,6 @@ class ChatRoute(Route): result_text = result["data"] type = result.get("type") - cid = result.get("cid") streaming = result.get("streaming", False) yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n" await asyncio.sleep(0.05) @@ -173,17 +173,13 @@ class ChatRoute(Route): break elif (streaming and type == "complete") or not streaming: # append bot message - conversation = self.db.get_conversation_by_user_id( - username, cid - ) - try: - history = json.loads(conversation.history) - except BaseException as e: - logger.error(f"Failed to parse conversation history: {e}") - history = [] - history.append({"type": "bot", "message": result_text}) - self.db.update_conversation( - username, cid, history=json.dumps(history) + new_his = {"type": "bot", "message": result_text} + await self.platform_history_mgr.insert( + platform_id="webchat", + user_id=webchat_conv_id, + content=new_his, + sender_id="bot", + sender_name="bot", ) except BaseException as _: @@ -191,11 +187,11 @@ class ChatRoute(Route): return # Put message to conversation-specific queue - chat_queue = webchat_queue_mgr.get_or_create_queue(conversation_id) + chat_queue = webchat_queue_mgr.get_or_create_queue(webchat_conv_id) await chat_queue.put( ( username, - conversation_id, + webchat_conv_id, { "message": message, "image_url": image_url, # list @@ -217,25 +213,51 @@ class ChatRoute(Route): ) return response + async def _get_webchat_conv_id_from_conv_id(self, conversation_id: str) -> str: + """从对话 ID 中提取 WebChat 会话 ID + + NOTE: 关于这里为什么要单独做一个 WebChat 的 Conversation ID 出来,这个是为了向前兼容。 + """ + conversation = await self.conv_mgr.get_conversation( + unified_msg_origin="webchat", conversation_id=conversation_id + ) + if not conversation: + raise ValueError(f"Conversation with ID {conversation_id} not found.") + conv_user_id = conversation.user_id + webchat_session_id = MessageSession.from_str(conv_user_id).session_id + if "!" not in webchat_session_id: + raise ValueError(f"Invalid conv user ID: {conv_user_id}") + return webchat_session_id.split("!")[-1] + async def delete_conversation(self): - username = g.get("username", "guest") conversation_id = request.args.get("conversation_id") if not conversation_id: return Response().error("Missing key: conversation_id").__dict__ + username = g.get("username", "guest") # Clean up queues when deleting conversation webchat_queue_mgr.remove_queues(conversation_id) - self.db.delete_conversation(username, conversation_id) + webchat_conv_id = await self._get_webchat_conv_id_from_conv_id(conversation_id) + await self.conv_mgr.delete_conversation( + unified_msg_origin=f"webchat:FriendMessage:webchat!{username}!{webchat_conv_id}", + conversation_id=conversation_id, + ) + await self.platform_history_mgr.delete( + platform_id="webchat", user_id=webchat_conv_id, offset_sec=99999999 + ) return Response().ok().__dict__ async def new_conversation(self): username = g.get("username", "guest") - conversation_id = str(uuid.uuid4()) - self.db.new_conversation(username, conversation_id) - return Response().ok(data={"conversation_id": conversation_id}).__dict__ + webchat_conv_id = str(uuid.uuid4()) + conv_id = await self.conv_mgr.new_conversation( + unified_msg_origin=f"webchat:FriendMessage:webchat!{username}!{webchat_conv_id}", + platform_id="webchat", + content=[], + ) + return Response().ok(data={"conversation_id": conv_id}).__dict__ async def rename_conversation(self): - username = g.get("username", "guest") post_data = await request.json if "conversation_id" not in post_data or "title" not in post_data: return Response().error("Missing key: conversation_id or title").__dict__ @@ -243,20 +265,42 @@ class ChatRoute(Route): conversation_id = post_data["conversation_id"] title = post_data["title"] - self.db.update_conversation_title(username, conversation_id, title=title) + await self.conv_mgr.update_conversation( + unified_msg_origin="webchat", # fake + conversation_id=conversation_id, + title=title, + ) return Response().ok(message="重命名成功!").__dict__ async def get_conversations(self): - username = g.get("username", "guest") - conversations = self.db.get_conversations(username) - return Response().ok(data=conversations).__dict__ + conversations = await self.conv_mgr.get_conversations(platform_id="webchat") + # remove content + conversations_ = [] + for conv in conversations: + conv.history = None + conversations_.append(conv) + return Response().ok(data=conversations_).__dict__ async def get_conversation(self): - username = g.get("username", "guest") conversation_id = request.args.get("conversation_id") if not conversation_id: return Response().error("Missing key: conversation_id").__dict__ - conversation = self.db.get_conversation_by_user_id(username, conversation_id) + webchat_conv_id = await self._get_webchat_conv_id_from_conv_id(conversation_id) - return Response().ok(data=conversation).__dict__ + # Get platform message history + history_ls = await self.platform_history_mgr.get( + platform_id="webchat", user_id=webchat_conv_id, page=1, page_size=1000 + ) + + history_res = [history.model_dump() for history in history_ls] + + return ( + Response() + .ok( + data={ + "history": history_res, + } + ) + .__dict__ + ) diff --git a/astrbot/dashboard/routes/conversation.py b/astrbot/dashboard/routes/conversation.py index dde6f9a5a..fb5d3e10e 100644 --- a/astrbot/dashboard/routes/conversation.py +++ b/astrbot/dashboard/routes/conversation.py @@ -29,6 +29,7 @@ class ConversationRoute(Route): ), } self.db_helper = db_helper + self.conv_mgr = core_lifecycle.conversation_manager self.core_lifecycle = core_lifecycle self.register_routes() @@ -54,7 +55,6 @@ class ConversationRoute(Route): exclude_platforms.split(",") if exclude_platforms else [] ) - # 限制页面大小,防止请求过大数据 if page < 1: page = 1 if page_size < 1: @@ -62,9 +62,11 @@ class ConversationRoute(Route): if page_size > 100: page_size = 100 - # 使用数据库的分页方法获取会话列表和总数,传入筛选条件 try: - conversations, total_count = self.db_helper.get_filtered_conversations( + ( + conversations, + total_count, + ) = await self.conv_mgr.get_filtered_conversations( page=page, page_size=page_size, platforms=platform_list, @@ -108,7 +110,9 @@ class ConversationRoute(Route): if not user_id or not cid: return Response().error("缺少必要参数: user_id 和 cid").__dict__ - conversation = self.db_helper.get_conversation_by_user_id(user_id, cid) + conversation = await self.conv_mgr.get_conversation( + unified_msg_origin=user_id, conversation_id=cid + ) if not conversation: return Response().error("对话不存在").__dict__ @@ -143,14 +147,18 @@ class ConversationRoute(Route): if not user_id or not cid: return Response().error("缺少必要参数: user_id 和 cid").__dict__ - conversation = self.db_helper.get_conversation_by_user_id(user_id, cid) + conversation = await self.conv_mgr.get_conversation( + unified_msg_origin=user_id, conversation_id=cid + ) if not conversation: return Response().error("对话不存在").__dict__ - if title is not None: - self.db_helper.update_conversation_title(user_id, cid, title) - if persona_id is not None: - self.db_helper.update_conversation_persona_id(user_id, cid, persona_id) - + if title is not None or persona_id is not None: + await self.conv_mgr.update_conversation( + unified_msg_origin=user_id, + conversation_id=cid, + title=title, + persona_id=persona_id, + ) return Response().ok({"message": "对话信息更新成功"}).__dict__ except Exception as e: @@ -201,11 +209,17 @@ class ConversationRoute(Route): Response().error("history 必须是有效的 JSON 字符串或数组").__dict__ ) - conversation = self.db_helper.get_conversation_by_user_id(user_id, cid) + conversation = await self.conv_mgr.get_conversation( + unified_msg_origin=user_id, conversation_id=cid + ) if not conversation: return Response().error("对话不存在").__dict__ - self.db_helper.update_conversation(user_id, cid, history) + history = json.loads(history) if isinstance(history, str) else history + + await self.conv_mgr.update_conversation( + unified_msg_origin=user_id, conversation_id=cid, history=history + ) return Response().ok({"message": "对话历史更新成功"}).__dict__ diff --git a/astrbot/dashboard/routes/session_management.py b/astrbot/dashboard/routes/session_management.py index fdcbdbf73..86b526be3 100644 --- a/astrbot/dashboard/routes/session_management.py +++ b/astrbot/dashboard/routes/session_management.py @@ -32,7 +32,7 @@ class SessionManagementRoute(Route): "/session/update_name": ("POST", self.update_session_name), "/session/update_status": ("POST", self.update_session_status), } - self.db_helper = db_helper + self.conv_mgr = core_lifecycle.conversation_manager self.core_lifecycle = core_lifecycle self.register_routes() @@ -90,8 +90,8 @@ class SessionManagementRoute(Route): } # 获取对话信息 - conversation = self.db_helper.get_conversation_by_user_id( - session_id, conversation_id + conversation = await self.conv_mgr.get_conversation( + unified_msg_origin=session_id, conversation_id=conversation_id ) if conversation: session_info["persona_id"] = conversation.persona_id @@ -358,8 +358,8 @@ class SessionManagementRoute(Route): ) # 获取对话信息 - conversation = self.db_helper.get_conversation_by_user_id( - session_id, conversation_id + conversation = await self.conv_mgr.get_conversation( + unified_msg_origin=session_id, conversation_id=conversation_id ) if conversation: session_info["persona_id"] = conversation.persona_id diff --git a/astrbot/dashboard/routes/stat.py b/astrbot/dashboard/routes/stat.py index 79397290e..5bc401a0d 100644 --- a/astrbot/dashboard/routes/stat.py +++ b/astrbot/dashboard/routes/stat.py @@ -86,7 +86,7 @@ class StatRoute(Route): message_time_based_stats = [] idx = 0 - for bucket_end in range(start_time, now, 1800): + for bucket_end in range(start_time, now, 3600): cnt = 0 while ( idx < len(stat.platform) diff --git a/dashboard/src/views/ChatPage.vue b/dashboard/src/views/ChatPage.vue index 099cf09c0..208a751ab 100644 --- a/dashboard/src/views/ChatPage.vue +++ b/dashboard/src/views/ChatPage.vue @@ -147,24 +147,24 @@
-
+
-
{{ msg.message }}
+
{{ msg.content.message }}
-
-
+
-
+
@@ -179,22 +179,22 @@
-
-
-
+
-
+
@@ -203,7 +203,7 @@
@@ -733,40 +733,42 @@ export default { } else { this.$router.push(`/chat/${cid[0]}`); } + return } axios.get('/api/chat/get_conversation?conversation_id=' + cid[0]).then(async response => { this.currCid = cid[0]; - let message = JSON.parse(response.data.data.history); - for (let i = 0; i < message.length; i++) { - if (message[i].message.startsWith('[IMAGE]')) { - let img = message[i].message.replace('[IMAGE]', ''); + let history = response.data.data.history; + for (let i = 0; i < history.length; i++) { + let content = history[i].content; + if (content.message.startsWith('[IMAGE]')) { + let img = content.message.replace('[IMAGE]', ''); const imageUrl = await this.getMediaFile(img); - if (!message[i].embedded_images) { - message[i].embedded_images = []; + if (!content.embedded_images) { + content.embedded_images = []; } - message[i].embedded_images.push(imageUrl); - message[i].message = ''; // 清空message,避免显示标记文本 + content.embedded_images.push(imageUrl); + content.message = ''; // 清空message,避免显示标记文本 } - if (message[i].message.startsWith('[RECORD]')) { - let audio = message[i].message.replace('[RECORD]', ''); + if (content.message.startsWith('[RECORD]')) { + let audio = content.message.replace('[RECORD]', ''); const audioUrl = await this.getMediaFile(audio); - message[i].embedded_audio = audioUrl; - message[i].message = ''; // 清空message,避免显示标记文本 + content.embedded_audio = audioUrl; + content.message = ''; // 清空message,避免显示标记文本 } - if (message[i].image_url && message[i].image_url.length > 0) { - for (let j = 0; j < message[i].image_url.length; j++) { - message[i].image_url[j] = await this.getMediaFile(message[i].image_url[j]); + if (content.image_url && content.image_url.length > 0) { + for (let j = 0; j < content.image_url.length; j++) { + content.image_url[j] = await this.getMediaFile(content.image_url[j]); } } - if (message[i].audio_url) { - message[i].audio_url = await this.getMediaFile(message[i].audio_url); + if (content.audio_url) { + content.audio_url = await this.getMediaFile(content.audio_url); } } - this.messages = message; + this.messages = history; this.initCodeCopyButtons(); this.initImageClickEvents(); }).catch(err => { @@ -876,7 +878,9 @@ export default { } } - this.messages.push(userMessage); + this.messages.push({ + "content": userMessage, + }); this.scrollToBottom(); this.loadingChat = true @@ -960,7 +964,9 @@ export default { message: '', embedded_images: [imageUrl] } - this.messages.push(bot_resp); + this.messages.push({ + "content": bot_resp + }); } else if (chunk_json.type === 'record') { let audio = chunk_json.data.replace('[RECORD]', ''); const audioUrl = await this.getMediaFile(audio); @@ -969,14 +975,18 @@ export default { message: '', embedded_audio: audioUrl } - this.messages.push(bot_resp); + this.messages.push({ + "content": bot_resp + }); } else if (chunk_json.type === 'plain') { if (!in_streaming) { message_obj = { type: 'bot', message: this.ref(chunk_json.data), } - this.messages.push(message_obj); + this.messages.push({ + "content": message_obj + }); in_streaming = true; } else { message_obj.message.value += chunk_json.data; @@ -1096,7 +1106,7 @@ export default { // 复制bot消息到剪贴板 copyBotMessage(message, messageIndex) { // 获取对应的消息对象 - const msgObj = this.messages[messageIndex]; + const msgObj = this.messages[messageIndex].content; let textToCopy = ''; // 如果有文本消息,添加到复制内容中 diff --git a/pyproject.toml b/pyproject.toml index ddc0bc9fb..12639b5c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "cryptography>=44.0.3", "dashscope>=1.23.2", "defusedxml>=0.7.1", + "deprecated>=1.2.18", "dingtalk-stream>=0.22.1", "docstring-parser>=0.16", "faiss-cpu>=1.10.0", @@ -42,6 +43,8 @@ dependencies = [ "readability-lxml>=0.8.4.1", "silk-python>=0.2.6", "slack-sdk>=3.35.0", + "sqlalchemy[asyncio]>=2.0.41", + "sqlmodel>=0.0.24", "telegramify-markdown>=0.5.1", "watchfiles>=1.0.5", "websockets>=15.0.1", diff --git a/uv.lock b/uv.lock index cc8a50241..b2c2b0f3d 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,4 @@ version = 1 -revision = 1 requires-python = ">=3.10" [[package]] @@ -220,6 +219,7 @@ dependencies = [ { name = "cryptography" }, { name = "dashscope" }, { name = "defusedxml" }, + { name = "deprecated" }, { name = "dingtalk-stream" }, { name = "docstring-parser" }, { name = "faiss-cpu" }, @@ -244,6 +244,8 @@ dependencies = [ { name = "readability-lxml" }, { name = "silk-python" }, { name = "slack-sdk" }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "sqlmodel" }, { name = "telegramify-markdown" }, { name = "watchfiles" }, { name = "websockets" }, @@ -265,6 +267,7 @@ requires-dist = [ { name = "cryptography", specifier = ">=44.0.3" }, { name = "dashscope", specifier = ">=1.23.2" }, { name = "defusedxml", specifier = ">=0.7.1" }, + { name = "deprecated", specifier = ">=1.2.18" }, { name = "dingtalk-stream", specifier = ">=0.22.1" }, { name = "docstring-parser", specifier = ">=0.16" }, { name = "faiss-cpu", specifier = ">=1.10.0" }, @@ -289,6 +292,8 @@ requires-dist = [ { name = "readability-lxml", specifier = ">=0.8.4.1" }, { name = "silk-python", specifier = ">=0.2.6" }, { name = "slack-sdk", specifier = ">=3.35.0" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.41" }, + { name = "sqlmodel", specifier = ">=0.0.24" }, { name = "telegramify-markdown", specifier = ">=0.5.1" }, { name = "watchfiles", specifier = ">=1.0.5" }, { name = "websockets", specifier = ">=15.0.1" }, @@ -591,6 +596,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 }, ] +[[package]] +name = "deprecated" +version = "1.2.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/97/06afe62762c9a8a86af0cfb7bfdab22a43ad17138b07af5b1a58442690a2/deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d", size = 2928744 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998 }, +] + [[package]] name = "dingtalk-stream" version = "0.22.1" @@ -828,6 +845,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/a6/c1fe6a46a7ac2d3b08acfe88ce3d2b12cd8351c697ee4b300bfa350b7c9a/googlesearch_python-1.3.0-py3-none-any.whl", hash = "sha256:808c4dd390dc4c6a1cfba2f5151f5ef16dceb0a200d9770b388dcd39162b4e19", size = 5563 }, ] +[[package]] +name = "greenlet" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/92/bb85bd6e80148a4d2e0c59f7c0c2891029f8fd510183afc7d8d2feeed9b6/greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365", size = 185752 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/db/b4c12cff13ebac2786f4f217f06588bccd8b53d260453404ef22b121fc3a/greenlet-3.2.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:1afd685acd5597349ee6d7a88a8bec83ce13c106ac78c196ee9dde7c04fe87be", size = 268977 }, + { url = "https://files.pythonhosted.org/packages/52/61/75b4abd8147f13f70986df2801bf93735c1bd87ea780d70e3b3ecda8c165/greenlet-3.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:761917cac215c61e9dc7324b2606107b3b292a8349bdebb31503ab4de3f559ac", size = 627351 }, + { url = "https://files.pythonhosted.org/packages/35/aa/6894ae299d059d26254779a5088632874b80ee8cf89a88bca00b0709d22f/greenlet-3.2.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a433dbc54e4a37e4fff90ef34f25a8c00aed99b06856f0119dcf09fbafa16392", size = 638599 }, + { url = "https://files.pythonhosted.org/packages/30/64/e01a8261d13c47f3c082519a5e9dbf9e143cc0498ed20c911d04e54d526c/greenlet-3.2.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:72e77ed69312bab0434d7292316d5afd6896192ac4327d44f3d613ecb85b037c", size = 634482 }, + { url = "https://files.pythonhosted.org/packages/47/48/ff9ca8ba9772d083a4f5221f7b4f0ebe8978131a9ae0909cf202f94cd879/greenlet-3.2.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:68671180e3849b963649254a882cd544a3c75bfcd2c527346ad8bb53494444db", size = 633284 }, + { url = "https://files.pythonhosted.org/packages/e9/45/626e974948713bc15775b696adb3eb0bd708bec267d6d2d5c47bb47a6119/greenlet-3.2.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49c8cfb18fb419b3d08e011228ef8a25882397f3a859b9fe1436946140b6756b", size = 582206 }, + { url = "https://files.pythonhosted.org/packages/b1/8e/8b6f42c67d5df7db35b8c55c9a850ea045219741bb14416255616808c690/greenlet-3.2.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:efc6dc8a792243c31f2f5674b670b3a95d46fa1c6a912b8e310d6f542e7b0712", size = 1111412 }, + { url = "https://files.pythonhosted.org/packages/05/46/ab58828217349500a7ebb81159d52ca357da747ff1797c29c6023d79d798/greenlet-3.2.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:731e154aba8e757aedd0781d4b240f1225b075b4409f1bb83b05ff410582cf00", size = 1135054 }, + { url = "https://files.pythonhosted.org/packages/68/7f/d1b537be5080721c0f0089a8447d4ef72839039cdb743bdd8ffd23046e9a/greenlet-3.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:96c20252c2f792defe9a115d3287e14811036d51e78b3aaddbee23b69b216302", size = 296573 }, + { url = "https://files.pythonhosted.org/packages/fc/2e/d4fcb2978f826358b673f779f78fa8a32ee37df11920dc2bb5589cbeecef/greenlet-3.2.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:784ae58bba89fa1fa5733d170d42486580cab9decda3484779f4759345b29822", size = 270219 }, + { url = "https://files.pythonhosted.org/packages/16/24/929f853e0202130e4fe163bc1d05a671ce8dcd604f790e14896adac43a52/greenlet-3.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0921ac4ea42a5315d3446120ad48f90c3a6b9bb93dd9b3cf4e4d84a66e42de83", size = 630383 }, + { url = "https://files.pythonhosted.org/packages/d1/b2/0320715eb61ae70c25ceca2f1d5ae620477d246692d9cc284c13242ec31c/greenlet-3.2.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d2971d93bb99e05f8c2c0c2f4aa9484a18d98c4c3bd3c62b65b7e6ae33dfcfaf", size = 642422 }, + { url = "https://files.pythonhosted.org/packages/bd/49/445fd1a210f4747fedf77615d941444349c6a3a4a1135bba9701337cd966/greenlet-3.2.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c667c0bf9d406b77a15c924ef3285e1e05250948001220368e039b6aa5b5034b", size = 638375 }, + { url = "https://files.pythonhosted.org/packages/7e/c8/ca19760cf6eae75fa8dc32b487e963d863b3ee04a7637da77b616703bc37/greenlet-3.2.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:592c12fb1165be74592f5de0d70f82bc5ba552ac44800d632214b76089945147", size = 637627 }, + { url = "https://files.pythonhosted.org/packages/65/89/77acf9e3da38e9bcfca881e43b02ed467c1dedc387021fc4d9bd9928afb8/greenlet-3.2.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29e184536ba333003540790ba29829ac14bb645514fbd7e32af331e8202a62a5", size = 585502 }, + { url = "https://files.pythonhosted.org/packages/97/c6/ae244d7c95b23b7130136e07a9cc5aadd60d59b5951180dc7dc7e8edaba7/greenlet-3.2.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:93c0bb79844a367782ec4f429d07589417052e621aa39a5ac1fb99c5aa308edc", size = 1114498 }, + { url = "https://files.pythonhosted.org/packages/89/5f/b16dec0cbfd3070658e0d744487919740c6d45eb90946f6787689a7efbce/greenlet-3.2.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:751261fc5ad7b6705f5f76726567375bb2104a059454e0226e1eef6c756748ba", size = 1139977 }, + { url = "https://files.pythonhosted.org/packages/66/77/d48fb441b5a71125bcac042fc5b1494c806ccb9a1432ecaa421e72157f77/greenlet-3.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:83a8761c75312361aa2b5b903b79da97f13f556164a7dd2d5448655425bd4c34", size = 297017 }, + { url = "https://files.pythonhosted.org/packages/f3/94/ad0d435f7c48debe960c53b8f60fb41c2026b1d0fa4a99a1cb17c3461e09/greenlet-3.2.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:25ad29caed5783d4bd7a85c9251c651696164622494c00802a139c00d639242d", size = 271992 }, + { url = "https://files.pythonhosted.org/packages/93/5d/7c27cf4d003d6e77749d299c7c8f5fd50b4f251647b5c2e97e1f20da0ab5/greenlet-3.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88cd97bf37fe24a6710ec6a3a7799f3f81d9cd33317dcf565ff9950c83f55e0b", size = 638820 }, + { url = "https://files.pythonhosted.org/packages/c6/7e/807e1e9be07a125bb4c169144937910bf59b9d2f6d931578e57f0bce0ae2/greenlet-3.2.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:baeedccca94880d2f5666b4fa16fc20ef50ba1ee353ee2d7092b383a243b0b0d", size = 653046 }, + { url = "https://files.pythonhosted.org/packages/9d/ab/158c1a4ea1068bdbc78dba5a3de57e4c7aeb4e7fa034320ea94c688bfb61/greenlet-3.2.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:be52af4b6292baecfa0f397f3edb3c6092ce071b499dd6fe292c9ac9f2c8f264", size = 647701 }, + { url = "https://files.pythonhosted.org/packages/cc/0d/93729068259b550d6a0288da4ff72b86ed05626eaf1eb7c0d3466a2571de/greenlet-3.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0cc73378150b8b78b0c9fe2ce56e166695e67478550769536a6742dca3651688", size = 649747 }, + { url = "https://files.pythonhosted.org/packages/f6/f6/c82ac1851c60851302d8581680573245c8fc300253fc1ff741ae74a6c24d/greenlet-3.2.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:706d016a03e78df129f68c4c9b4c4f963f7d73534e48a24f5f5a7101ed13dbbb", size = 605461 }, + { url = "https://files.pythonhosted.org/packages/98/82/d022cf25ca39cf1200650fc58c52af32c90f80479c25d1cbf57980ec3065/greenlet-3.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:419e60f80709510c343c57b4bb5a339d8767bf9aef9b8ce43f4f143240f88b7c", size = 1121190 }, + { url = "https://files.pythonhosted.org/packages/f5/e1/25297f70717abe8104c20ecf7af0a5b82d2f5a980eb1ac79f65654799f9f/greenlet-3.2.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:93d48533fade144203816783373f27a97e4193177ebaaf0fc396db19e5d61163", size = 1149055 }, + { url = "https://files.pythonhosted.org/packages/1f/8f/8f9e56c5e82eb2c26e8cde787962e66494312dc8cb261c460e1f3a9c88bc/greenlet-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:7454d37c740bb27bdeddfc3f358f26956a07d5220818ceb467a483197d84f849", size = 297817 }, + { url = "https://files.pythonhosted.org/packages/b1/cf/f5c0b23309070ae93de75c90d29300751a5aacefc0a3ed1b1d8edb28f08b/greenlet-3.2.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:500b8689aa9dd1ab26872a34084503aeddefcb438e2e7317b89b11eaea1901ad", size = 270732 }, + { url = "https://files.pythonhosted.org/packages/48/ae/91a957ba60482d3fecf9be49bc3948f341d706b52ddb9d83a70d42abd498/greenlet-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a07d3472c2a93117af3b0136f246b2833fdc0b542d4a9799ae5f41c28323faef", size = 639033 }, + { url = "https://files.pythonhosted.org/packages/6f/df/20ffa66dd5a7a7beffa6451bdb7400d66251374ab40b99981478c69a67a8/greenlet-3.2.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8704b3768d2f51150626962f4b9a9e4a17d2e37c8a8d9867bbd9fa4eb938d3b3", size = 652999 }, + { url = "https://files.pythonhosted.org/packages/51/b4/ebb2c8cb41e521f1d72bf0465f2f9a2fd803f674a88db228887e6847077e/greenlet-3.2.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5035d77a27b7c62db6cf41cf786cfe2242644a7a337a0e155c80960598baab95", size = 647368 }, + { url = "https://files.pythonhosted.org/packages/8e/6a/1e1b5aa10dced4ae876a322155705257748108b7fd2e4fae3f2a091fe81a/greenlet-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2d8aa5423cd4a396792f6d4580f88bdc6efcb9205891c9d40d20f6e670992efb", size = 650037 }, + { url = "https://files.pythonhosted.org/packages/26/f2/ad51331a157c7015c675702e2d5230c243695c788f8f75feba1af32b3617/greenlet-3.2.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c724620a101f8170065d7dded3f962a2aea7a7dae133a009cada42847e04a7b", size = 608402 }, + { url = "https://files.pythonhosted.org/packages/26/bc/862bd2083e6b3aff23300900a956f4ea9a4059de337f5c8734346b9b34fc/greenlet-3.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:873abe55f134c48e1f2a6f53f7d1419192a3d1a4e873bace00499a4e45ea6af0", size = 1119577 }, + { url = "https://files.pythonhosted.org/packages/86/94/1fc0cc068cfde885170e01de40a619b00eaa8f2916bf3541744730ffb4c3/greenlet-3.2.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:024571bbce5f2c1cfff08bf3fbaa43bbc7444f580ae13b0099e95d0e6e67ed36", size = 1147121 }, + { url = "https://files.pythonhosted.org/packages/27/1a/199f9587e8cb08a0658f9c30f3799244307614148ffe8b1e3aa22f324dea/greenlet-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5195fb1e75e592dd04ce79881c8a22becdfa3e6f500e7feb059b1e6fdd54d3e3", size = 297603 }, + { url = "https://files.pythonhosted.org/packages/d8/ca/accd7aa5280eb92b70ed9e8f7fd79dc50a2c21d8c73b9a0856f5b564e222/greenlet-3.2.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3d04332dddb10b4a211b68111dabaee2e1a073663d117dc10247b5b1642bac86", size = 271479 }, + { url = "https://files.pythonhosted.org/packages/55/71/01ed9895d9eb49223280ecc98a557585edfa56b3d0e965b9fa9f7f06b6d9/greenlet-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8186162dffde068a465deab08fc72c767196895c39db26ab1c17c0b77a6d8b97", size = 683952 }, + { url = "https://files.pythonhosted.org/packages/ea/61/638c4bdf460c3c678a0a1ef4c200f347dff80719597e53b5edb2fb27ab54/greenlet-3.2.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f4bfbaa6096b1b7a200024784217defedf46a07c2eee1a498e94a1b5f8ec5728", size = 696917 }, + { url = "https://files.pythonhosted.org/packages/22/cc/0bd1a7eb759d1f3e3cc2d1bc0f0b487ad3cc9f34d74da4b80f226fde4ec3/greenlet-3.2.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ed6cfa9200484d234d8394c70f5492f144b20d4533f69262d530a1a082f6ee9a", size = 692443 }, + { url = "https://files.pythonhosted.org/packages/67/10/b2a4b63d3f08362662e89c103f7fe28894a51ae0bc890fabf37d1d780e52/greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892", size = 692995 }, + { url = "https://files.pythonhosted.org/packages/5a/c6/ad82f148a4e3ce9564056453a71529732baf5448ad53fc323e37efe34f66/greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141", size = 655320 }, + { url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236 }, +] + [[package]] name = "h11" version = "0.14.0" @@ -1709,6 +1777,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/2a/25e0be2b509c28375c7f75c7e8d8d060773f2cce4856a1654276e3202339/pycryptodome-3.22.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d21c1eda2f42211f18a25db4eaf8056c94a8563cd39da3683f89fe0d881fb772", size = 2262255 }, { url = "https://files.pythonhosted.org/packages/41/58/60917bc4bbd91712e53ce04daf237a74a0ad731383a01288130672994328/pycryptodome-3.22.0-cp37-abi3-win32.whl", hash = "sha256:f02baa9f5e35934c6e8dcec91fcde96612bdefef6e442813b8ea34e82c84bbfb", size = 1763403 }, { url = "https://files.pythonhosted.org/packages/55/f4/244c621afcf7867e23f63cfd7a9630f14cfe946c9be7e566af6c3915bcde/pycryptodome-3.22.0-cp37-abi3-win_amd64.whl", hash = "sha256:d086aed307e96d40c23c42418cbbca22ecc0ab4a8a0e24f87932eeab26c08627", size = 1794568 }, + { url = "https://files.pythonhosted.org/packages/cd/13/16d3a83b07f949a686f6cfd7cfc60e57a769ff502151ea140ad67b118e26/pycryptodome-3.22.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:98fd9da809d5675f3a65dcd9ed384b9dc67edab6a4cda150c5870a8122ec961d", size = 1700779 }, + { url = "https://files.pythonhosted.org/packages/13/af/16d26f7dfc5fd7696ea2c91448f937b51b55312b5bed44f777563e32a4fe/pycryptodome-3.22.0-pp27-pypy_73-win32.whl", hash = "sha256:37ddcd18284e6b36b0a71ea495a4c4dca35bb09ccc9bfd5b91bfaf2321f131c1", size = 1775230 }, { url = "https://files.pythonhosted.org/packages/37/c3/e3423e72669ca09f141aae493e1feaa8b8475859898b04f57078280a61c4/pycryptodome-3.22.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b4bdce34af16c1dcc7f8c66185684be15f5818afd2a82b75a4ce6b55f9783e13", size = 1618698 }, { url = "https://files.pythonhosted.org/packages/f9/b7/35eec0b3919cafea362dcb68bb0654d9cb3cde6da6b7a9d8480ce0bf203a/pycryptodome-3.22.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2988ffcd5137dc2d27eb51cd18c0f0f68e5b009d5fec56fbccb638f90934f333", size = 1666957 }, { url = "https://files.pythonhosted.org/packages/b0/1f/f49bccdd8d61f1da4278eb0d6aee7f988f1a6ec4056b0c2dc51eda45ae27/pycryptodome-3.22.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e653519dedcd1532788547f00eeb6108cc7ce9efdf5cc9996abce0d53f95d5a9", size = 1659242 }, @@ -2088,6 +2158,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/c2/fe97d779f3ef3b15f05c94a2f1e3d21732574ed441687474db9d342a7315/soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9", size = 36186 }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.41" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/12/d7c445b1940276a828efce7331cb0cb09d6e5f049651db22f4ebb0922b77/sqlalchemy-2.0.41-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b1f09b6821406ea1f94053f346f28f8215e293344209129a9c0fcc3578598d7b", size = 2117967 }, + { url = "https://files.pythonhosted.org/packages/6f/b8/cb90f23157e28946b27eb01ef401af80a1fab7553762e87df51507eaed61/sqlalchemy-2.0.41-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1936af879e3db023601196a1684d28e12f19ccf93af01bf3280a3262c4b6b4e5", size = 2107583 }, + { url = "https://files.pythonhosted.org/packages/9e/c2/eef84283a1c8164a207d898e063edf193d36a24fb6a5bb3ce0634b92a1e8/sqlalchemy-2.0.41-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2ac41acfc8d965fb0c464eb8f44995770239668956dc4cdf502d1b1ffe0d747", size = 3186025 }, + { url = "https://files.pythonhosted.org/packages/bd/72/49d52bd3c5e63a1d458fd6d289a1523a8015adedbddf2c07408ff556e772/sqlalchemy-2.0.41-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81c24e0c0fde47a9723c81d5806569cddef103aebbf79dbc9fcbb617153dea30", size = 3186259 }, + { url = "https://files.pythonhosted.org/packages/4f/9e/e3ffc37d29a3679a50b6bbbba94b115f90e565a2b4545abb17924b94c52d/sqlalchemy-2.0.41-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23a8825495d8b195c4aa9ff1c430c28f2c821e8c5e2d98089228af887e5d7e29", size = 3126803 }, + { url = "https://files.pythonhosted.org/packages/8a/76/56b21e363f6039978ae0b72690237b38383e4657281285a09456f313dd77/sqlalchemy-2.0.41-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:60c578c45c949f909a4026b7807044e7e564adf793537fc762b2489d522f3d11", size = 3148566 }, + { url = "https://files.pythonhosted.org/packages/3b/92/11b8e1b69bf191bc69e300a99badbbb5f2f1102f2b08b39d9eee2e21f565/sqlalchemy-2.0.41-cp310-cp310-win32.whl", hash = "sha256:118c16cd3f1b00c76d69343e38602006c9cfb9998fa4f798606d28d63f23beda", size = 2086696 }, + { url = "https://files.pythonhosted.org/packages/5c/88/2d706c9cc4502654860f4576cd54f7db70487b66c3b619ba98e0be1a4642/sqlalchemy-2.0.41-cp310-cp310-win_amd64.whl", hash = "sha256:7492967c3386df69f80cf67efd665c0f667cee67032090fe01d7d74b0e19bb08", size = 2110200 }, + { url = "https://files.pythonhosted.org/packages/37/4e/b00e3ffae32b74b5180e15d2ab4040531ee1bef4c19755fe7926622dc958/sqlalchemy-2.0.41-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6375cd674fe82d7aa9816d1cb96ec592bac1726c11e0cafbf40eeee9a4516b5f", size = 2121232 }, + { url = "https://files.pythonhosted.org/packages/ef/30/6547ebb10875302074a37e1970a5dce7985240665778cfdee2323709f749/sqlalchemy-2.0.41-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f8c9fdd15a55d9465e590a402f42082705d66b05afc3ffd2d2eb3c6ba919560", size = 2110897 }, + { url = "https://files.pythonhosted.org/packages/9e/21/59df2b41b0f6c62da55cd64798232d7349a9378befa7f1bb18cf1dfd510a/sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32f9dc8c44acdee06c8fc6440db9eae8b4af8b01e4b1aee7bdd7241c22edff4f", size = 3273313 }, + { url = "https://files.pythonhosted.org/packages/62/e4/b9a7a0e5c6f79d49bcd6efb6e90d7536dc604dab64582a9dec220dab54b6/sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c11ceb9a1f482c752a71f203a81858625d8df5746d787a4786bca4ffdf71c6", size = 3273807 }, + { url = "https://files.pythonhosted.org/packages/39/d8/79f2427251b44ddee18676c04eab038d043cff0e764d2d8bb08261d6135d/sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:911cc493ebd60de5f285bcae0491a60b4f2a9f0f5c270edd1c4dbaef7a38fc04", size = 3209632 }, + { url = "https://files.pythonhosted.org/packages/d4/16/730a82dda30765f63e0454918c982fb7193f6b398b31d63c7c3bd3652ae5/sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03968a349db483936c249f4d9cd14ff2c296adfa1290b660ba6516f973139582", size = 3233642 }, + { url = "https://files.pythonhosted.org/packages/04/61/c0d4607f7799efa8b8ea3c49b4621e861c8f5c41fd4b5b636c534fcb7d73/sqlalchemy-2.0.41-cp311-cp311-win32.whl", hash = "sha256:293cd444d82b18da48c9f71cd7005844dbbd06ca19be1ccf6779154439eec0b8", size = 2086475 }, + { url = "https://files.pythonhosted.org/packages/9d/8e/8344f8ae1cb6a479d0741c02cd4f666925b2bf02e2468ddaf5ce44111f30/sqlalchemy-2.0.41-cp311-cp311-win_amd64.whl", hash = "sha256:3d3549fc3e40667ec7199033a4e40a2f669898a00a7b18a931d3efb4c7900504", size = 2110903 }, + { url = "https://files.pythonhosted.org/packages/3e/2a/f1f4e068b371154740dd10fb81afb5240d5af4aa0087b88d8b308b5429c2/sqlalchemy-2.0.41-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:81f413674d85cfd0dfcd6512e10e0f33c19c21860342a4890c3a2b59479929f9", size = 2119645 }, + { url = "https://files.pythonhosted.org/packages/9b/e8/c664a7e73d36fbfc4730f8cf2bf930444ea87270f2825efbe17bf808b998/sqlalchemy-2.0.41-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:598d9ebc1e796431bbd068e41e4de4dc34312b7aa3292571bb3674a0cb415dd1", size = 2107399 }, + { url = "https://files.pythonhosted.org/packages/5c/78/8a9cf6c5e7135540cb682128d091d6afa1b9e48bd049b0d691bf54114f70/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a104c5694dfd2d864a6f91b0956eb5d5883234119cb40010115fd45a16da5e70", size = 3293269 }, + { url = "https://files.pythonhosted.org/packages/3c/35/f74add3978c20de6323fb11cb5162702670cc7a9420033befb43d8d5b7a4/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6145afea51ff0af7f2564a05fa95eb46f542919e6523729663a5d285ecb3cf5e", size = 3303364 }, + { url = "https://files.pythonhosted.org/packages/6a/d4/c990f37f52c3f7748ebe98883e2a0f7d038108c2c5a82468d1ff3eec50b7/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b46fa6eae1cd1c20e6e6f44e19984d438b6b2d8616d21d783d150df714f44078", size = 3229072 }, + { url = "https://files.pythonhosted.org/packages/15/69/cab11fecc7eb64bc561011be2bd03d065b762d87add52a4ca0aca2e12904/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae", size = 3268074 }, + { url = "https://files.pythonhosted.org/packages/5c/ca/0c19ec16858585d37767b167fc9602593f98998a68a798450558239fb04a/sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6", size = 2084514 }, + { url = "https://files.pythonhosted.org/packages/7f/23/4c2833d78ff3010a4e17f984c734f52b531a8c9060a50429c9d4b0211be6/sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0", size = 2111557 }, + { url = "https://files.pythonhosted.org/packages/d3/ad/2e1c6d4f235a97eeef52d0200d8ddda16f6c4dd70ae5ad88c46963440480/sqlalchemy-2.0.41-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eeb195cdedaf17aab6b247894ff2734dcead6c08f748e617bfe05bd5a218443", size = 2115491 }, + { url = "https://files.pythonhosted.org/packages/cf/8d/be490e5db8400dacc89056f78a52d44b04fbf75e8439569d5b879623a53b/sqlalchemy-2.0.41-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d4ae769b9c1c7757e4ccce94b0641bc203bbdf43ba7a2413ab2523d8d047d8dc", size = 2102827 }, + { url = "https://files.pythonhosted.org/packages/a0/72/c97ad430f0b0e78efaf2791342e13ffeafcbb3c06242f01a3bb8fe44f65d/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a62448526dd9ed3e3beedc93df9bb6b55a436ed1474db31a2af13b313a70a7e1", size = 3225224 }, + { url = "https://files.pythonhosted.org/packages/5e/51/5ba9ea3246ea068630acf35a6ba0d181e99f1af1afd17e159eac7e8bc2b8/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc56c9788617b8964ad02e8fcfeed4001c1f8ba91a9e1f31483c0dffb207002a", size = 3230045 }, + { url = "https://files.pythonhosted.org/packages/78/2f/8c14443b2acea700c62f9b4a8bad9e49fc1b65cfb260edead71fd38e9f19/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c153265408d18de4cc5ded1941dcd8315894572cddd3c58df5d5b5705b3fa28d", size = 3159357 }, + { url = "https://files.pythonhosted.org/packages/fc/b2/43eacbf6ccc5276d76cea18cb7c3d73e294d6fb21f9ff8b4eef9b42bbfd5/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23", size = 3197511 }, + { url = "https://files.pythonhosted.org/packages/fa/2e/677c17c5d6a004c3c45334ab1dbe7b7deb834430b282b8a0f75ae220c8eb/sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f", size = 2082420 }, + { url = "https://files.pythonhosted.org/packages/e9/61/e8c1b9b6307c57157d328dd8b8348ddc4c47ffdf1279365a13b2b98b8049/sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df", size = 2108329 }, + { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224 }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + +[[package]] +name = "sqlmodel" +version = "0.0.24" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/4b/c2ad0496f5bdc6073d9b4cef52be9c04f2b37a5773441cc6600b1857648b/sqlmodel-0.0.24.tar.gz", hash = "sha256:cc5c7613c1a5533c9c7867e1aab2fd489a76c9e8a061984da11b4e613c182423", size = 116780 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/91/484cd2d05569892b7fef7f5ceab3bc89fb0f8a8c0cde1030d383dbc5449c/sqlmodel-0.0.24-py3-none-any.whl", hash = "sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193", size = 28622 }, +] + [[package]] name = "sse-starlette" version = "2.2.1" @@ -2415,6 +2548,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498 }, ] +[[package]] +name = "wrapt" +version = "1.17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/fc/e91cc220803d7bc4db93fb02facd8461c37364151b8494762cc88b0fbcef/wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3", size = 55531 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/d1/1daec934997e8b160040c78d7b31789f19b122110a75eca3d4e8da0049e1/wrapt-1.17.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d57c572081fed831ad2d26fd430d565b76aa277ed1d30ff4d40670b1c0dd984", size = 53307 }, + { url = "https://files.pythonhosted.org/packages/1b/7b/13369d42651b809389c1a7153baa01d9700430576c81a2f5c5e460df0ed9/wrapt-1.17.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5e251054542ae57ac7f3fba5d10bfff615b6c2fb09abeb37d2f1463f841ae22", size = 38486 }, + { url = "https://files.pythonhosted.org/packages/62/bf/e0105016f907c30b4bd9e377867c48c34dc9c6c0c104556c9c9126bd89ed/wrapt-1.17.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80dd7db6a7cb57ffbc279c4394246414ec99537ae81ffd702443335a61dbf3a7", size = 38777 }, + { url = "https://files.pythonhosted.org/packages/27/70/0f6e0679845cbf8b165e027d43402a55494779295c4b08414097b258ac87/wrapt-1.17.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a6e821770cf99cc586d33833b2ff32faebdbe886bd6322395606cf55153246c", size = 83314 }, + { url = "https://files.pythonhosted.org/packages/0f/77/0576d841bf84af8579124a93d216f55d6f74374e4445264cb378a6ed33eb/wrapt-1.17.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b60fb58b90c6d63779cb0c0c54eeb38941bae3ecf7a73c764c52c88c2dcb9d72", size = 74947 }, + { url = "https://files.pythonhosted.org/packages/90/ec/00759565518f268ed707dcc40f7eeec38637d46b098a1f5143bff488fe97/wrapt-1.17.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b870b5df5b71d8c3359d21be8f0d6c485fa0ebdb6477dda51a1ea54a9b558061", size = 82778 }, + { url = "https://files.pythonhosted.org/packages/f8/5a/7cffd26b1c607b0b0c8a9ca9d75757ad7620c9c0a9b4a25d3f8a1480fafc/wrapt-1.17.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4011d137b9955791f9084749cba9a367c68d50ab8d11d64c50ba1688c9b457f2", size = 81716 }, + { url = "https://files.pythonhosted.org/packages/7e/09/dccf68fa98e862df7e6a60a61d43d644b7d095a5fc36dbb591bbd4a1c7b2/wrapt-1.17.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1473400e5b2733e58b396a04eb7f35f541e1fb976d0c0724d0223dd607e0f74c", size = 74548 }, + { url = "https://files.pythonhosted.org/packages/b7/8e/067021fa3c8814952c5e228d916963c1115b983e21393289de15128e867e/wrapt-1.17.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3cedbfa9c940fdad3e6e941db7138e26ce8aad38ab5fe9dcfadfed9db7a54e62", size = 81334 }, + { url = "https://files.pythonhosted.org/packages/4b/0d/9d4b5219ae4393f718699ca1c05f5ebc0c40d076f7e65fd48f5f693294fb/wrapt-1.17.2-cp310-cp310-win32.whl", hash = "sha256:582530701bff1dec6779efa00c516496968edd851fba224fbd86e46cc6b73563", size = 36427 }, + { url = "https://files.pythonhosted.org/packages/72/6a/c5a83e8f61aec1e1aeef939807602fb880e5872371e95df2137142f5c58e/wrapt-1.17.2-cp310-cp310-win_amd64.whl", hash = "sha256:58705da316756681ad3c9c73fd15499aa4d8c69f9fd38dc8a35e06c12468582f", size = 38774 }, + { url = "https://files.pythonhosted.org/packages/cd/f7/a2aab2cbc7a665efab072344a8949a71081eed1d2f451f7f7d2b966594a2/wrapt-1.17.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ff04ef6eec3eee8a5efef2401495967a916feaa353643defcc03fc74fe213b58", size = 53308 }, + { url = "https://files.pythonhosted.org/packages/50/ff/149aba8365fdacef52b31a258c4dc1c57c79759c335eff0b3316a2664a64/wrapt-1.17.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4db983e7bca53819efdbd64590ee96c9213894272c776966ca6306b73e4affda", size = 38488 }, + { url = "https://files.pythonhosted.org/packages/65/46/5a917ce85b5c3b490d35c02bf71aedaa9f2f63f2d15d9949cc4ba56e8ba9/wrapt-1.17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9abc77a4ce4c6f2a3168ff34b1da9b0f311a8f1cfd694ec96b0603dff1c79438", size = 38776 }, + { url = "https://files.pythonhosted.org/packages/ca/74/336c918d2915a4943501c77566db41d1bd6e9f4dbc317f356b9a244dfe83/wrapt-1.17.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b929ac182f5ace000d459c59c2c9c33047e20e935f8e39371fa6e3b85d56f4a", size = 83776 }, + { url = "https://files.pythonhosted.org/packages/09/99/c0c844a5ccde0fe5761d4305485297f91d67cf2a1a824c5f282e661ec7ff/wrapt-1.17.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f09b286faeff3c750a879d336fb6d8713206fc97af3adc14def0cdd349df6000", size = 75420 }, + { url = "https://files.pythonhosted.org/packages/b4/b0/9fc566b0fe08b282c850063591a756057c3247b2362b9286429ec5bf1721/wrapt-1.17.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7ed2d9d039bd41e889f6fb9364554052ca21ce823580f6a07c4ec245c1f5d6", size = 83199 }, + { url = "https://files.pythonhosted.org/packages/9d/4b/71996e62d543b0a0bd95dda485219856def3347e3e9380cc0d6cf10cfb2f/wrapt-1.17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:129a150f5c445165ff941fc02ee27df65940fcb8a22a61828b1853c98763a64b", size = 82307 }, + { url = "https://files.pythonhosted.org/packages/39/35/0282c0d8789c0dc9bcc738911776c762a701f95cfe113fb8f0b40e45c2b9/wrapt-1.17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1fb5699e4464afe5c7e65fa51d4f99e0b2eadcc176e4aa33600a3df7801d6662", size = 75025 }, + { url = "https://files.pythonhosted.org/packages/4f/6d/90c9fd2c3c6fee181feecb620d95105370198b6b98a0770cba090441a828/wrapt-1.17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9a2bce789a5ea90e51a02dfcc39e31b7f1e662bc3317979aa7e5538e3a034f72", size = 81879 }, + { url = "https://files.pythonhosted.org/packages/8f/fa/9fb6e594f2ce03ef03eddbdb5f4f90acb1452221a5351116c7c4708ac865/wrapt-1.17.2-cp311-cp311-win32.whl", hash = "sha256:4afd5814270fdf6380616b321fd31435a462019d834f83c8611a0ce7484c7317", size = 36419 }, + { url = "https://files.pythonhosted.org/packages/47/f8/fb1773491a253cbc123c5d5dc15c86041f746ed30416535f2a8df1f4a392/wrapt-1.17.2-cp311-cp311-win_amd64.whl", hash = "sha256:acc130bc0375999da18e3d19e5a86403667ac0c4042a094fefb7eec8ebac7cf3", size = 38773 }, + { url = "https://files.pythonhosted.org/packages/a1/bd/ab55f849fd1f9a58ed7ea47f5559ff09741b25f00c191231f9f059c83949/wrapt-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d5e2439eecc762cd85e7bd37161d4714aa03a33c5ba884e26c81559817ca0925", size = 53799 }, + { url = "https://files.pythonhosted.org/packages/53/18/75ddc64c3f63988f5a1d7e10fb204ffe5762bc663f8023f18ecaf31a332e/wrapt-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fc7cb4c1c744f8c05cd5f9438a3caa6ab94ce8344e952d7c45a8ed59dd88392", size = 38821 }, + { url = "https://files.pythonhosted.org/packages/48/2a/97928387d6ed1c1ebbfd4efc4133a0633546bec8481a2dd5ec961313a1c7/wrapt-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fdbdb757d5390f7c675e558fd3186d590973244fab0c5fe63d373ade3e99d40", size = 38919 }, + { url = "https://files.pythonhosted.org/packages/73/54/3bfe5a1febbbccb7a2f77de47b989c0b85ed3a6a41614b104204a788c20e/wrapt-1.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb1d0dbf99411f3d871deb6faa9aabb9d4e744d67dcaaa05399af89d847a91d", size = 88721 }, + { url = "https://files.pythonhosted.org/packages/25/cb/7262bc1b0300b4b64af50c2720ef958c2c1917525238d661c3e9a2b71b7b/wrapt-1.17.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d18a4865f46b8579d44e4fe1e2bcbc6472ad83d98e22a26c963d46e4c125ef0b", size = 80899 }, + { url = "https://files.pythonhosted.org/packages/2a/5a/04cde32b07a7431d4ed0553a76fdb7a61270e78c5fd5a603e190ac389f14/wrapt-1.17.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc570b5f14a79734437cb7b0500376b6b791153314986074486e0b0fa8d71d98", size = 89222 }, + { url = "https://files.pythonhosted.org/packages/09/28/2e45a4f4771fcfb109e244d5dbe54259e970362a311b67a965555ba65026/wrapt-1.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6d9187b01bebc3875bac9b087948a2bccefe464a7d8f627cf6e48b1bbae30f82", size = 86707 }, + { url = "https://files.pythonhosted.org/packages/c6/d2/dcb56bf5f32fcd4bd9aacc77b50a539abdd5b6536872413fd3f428b21bed/wrapt-1.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9e8659775f1adf02eb1e6f109751268e493c73716ca5761f8acb695e52a756ae", size = 79685 }, + { url = "https://files.pythonhosted.org/packages/80/4e/eb8b353e36711347893f502ce91c770b0b0929f8f0bed2670a6856e667a9/wrapt-1.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8b2816ebef96d83657b56306152a93909a83f23994f4b30ad4573b00bd11bb9", size = 87567 }, + { url = "https://files.pythonhosted.org/packages/17/27/4fe749a54e7fae6e7146f1c7d914d28ef599dacd4416566c055564080fe2/wrapt-1.17.2-cp312-cp312-win32.whl", hash = "sha256:468090021f391fe0056ad3e807e3d9034e0fd01adcd3bdfba977b6fdf4213ea9", size = 36672 }, + { url = "https://files.pythonhosted.org/packages/15/06/1dbf478ea45c03e78a6a8c4be4fdc3c3bddea5c8de8a93bc971415e47f0f/wrapt-1.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:ec89ed91f2fa8e3f52ae53cd3cf640d6feff92ba90d62236a81e4e563ac0e991", size = 38865 }, + { url = "https://files.pythonhosted.org/packages/ce/b9/0ffd557a92f3b11d4c5d5e0c5e4ad057bd9eb8586615cdaf901409920b14/wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125", size = 53800 }, + { url = "https://files.pythonhosted.org/packages/c0/ef/8be90a0b7e73c32e550c73cfb2fa09db62234227ece47b0e80a05073b375/wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998", size = 38824 }, + { url = "https://files.pythonhosted.org/packages/36/89/0aae34c10fe524cce30fe5fc433210376bce94cf74d05b0d68344c8ba46e/wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5", size = 38920 }, + { url = "https://files.pythonhosted.org/packages/3b/24/11c4510de906d77e0cfb5197f1b1445d4fec42c9a39ea853d482698ac681/wrapt-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cbabee4f083b6b4cd282f5b817a867cf0b1028c54d445b7ec7cfe6505057cf8", size = 88690 }, + { url = "https://files.pythonhosted.org/packages/71/d7/cfcf842291267bf455b3e266c0c29dcb675b5540ee8b50ba1699abf3af45/wrapt-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49703ce2ddc220df165bd2962f8e03b84c89fee2d65e1c24a7defff6f988f4d6", size = 80861 }, + { url = "https://files.pythonhosted.org/packages/d5/66/5d973e9f3e7370fd686fb47a9af3319418ed925c27d72ce16b791231576d/wrapt-1.17.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8112e52c5822fc4253f3901b676c55ddf288614dc7011634e2719718eaa187dc", size = 89174 }, + { url = "https://files.pythonhosted.org/packages/a7/d3/8e17bb70f6ae25dabc1aaf990f86824e4fd98ee9cadf197054e068500d27/wrapt-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fee687dce376205d9a494e9c121e27183b2a3df18037f89d69bd7b35bcf59e2", size = 86721 }, + { url = "https://files.pythonhosted.org/packages/6f/54/f170dfb278fe1c30d0ff864513cff526d624ab8de3254b20abb9cffedc24/wrapt-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:18983c537e04d11cf027fbb60a1e8dfd5190e2b60cc27bc0808e653e7b218d1b", size = 79763 }, + { url = "https://files.pythonhosted.org/packages/4a/98/de07243751f1c4a9b15c76019250210dd3486ce098c3d80d5f729cba029c/wrapt-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:703919b1633412ab54bcf920ab388735832fdcb9f9a00ae49387f0fe67dad504", size = 87585 }, + { url = "https://files.pythonhosted.org/packages/f9/f0/13925f4bd6548013038cdeb11ee2cbd4e37c30f8bfd5db9e5a2a370d6e20/wrapt-1.17.2-cp313-cp313-win32.whl", hash = "sha256:abbb9e76177c35d4e8568e58650aa6926040d6a9f6f03435b7a522bf1c487f9a", size = 36676 }, + { url = "https://files.pythonhosted.org/packages/bf/ae/743f16ef8c2e3628df3ddfd652b7d4c555d12c84b53f3d8218498f4ade9b/wrapt-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:69606d7bb691b50a4240ce6b22ebb319c1cfb164e5f6569835058196e0f3a845", size = 38871 }, + { url = "https://files.pythonhosted.org/packages/3d/bc/30f903f891a82d402ffb5fda27ec1d621cc97cb74c16fea0b6141f1d4e87/wrapt-1.17.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4a721d3c943dae44f8e243b380cb645a709ba5bd35d3ad27bc2ed947e9c68192", size = 56312 }, + { url = "https://files.pythonhosted.org/packages/8a/04/c97273eb491b5f1c918857cd26f314b74fc9b29224521f5b83f872253725/wrapt-1.17.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:766d8bbefcb9e00c3ac3b000d9acc51f1b399513f44d77dfe0eb026ad7c9a19b", size = 40062 }, + { url = "https://files.pythonhosted.org/packages/4e/ca/3b7afa1eae3a9e7fefe499db9b96813f41828b9fdb016ee836c4c379dadb/wrapt-1.17.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e496a8ce2c256da1eb98bd15803a79bee00fc351f5dfb9ea82594a3f058309e0", size = 40155 }, + { url = "https://files.pythonhosted.org/packages/89/be/7c1baed43290775cb9030c774bc53c860db140397047cc49aedaf0a15477/wrapt-1.17.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d615e4fe22f4ad3528448c193b218e077656ca9ccb22ce2cb20db730f8d306", size = 113471 }, + { url = "https://files.pythonhosted.org/packages/32/98/4ed894cf012b6d6aae5f5cc974006bdeb92f0241775addad3f8cd6ab71c8/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5aaeff38654462bc4b09023918b7f21790efb807f54c000a39d41d69cf552cb", size = 101208 }, + { url = "https://files.pythonhosted.org/packages/ea/fd/0c30f2301ca94e655e5e057012e83284ce8c545df7661a78d8bfca2fac7a/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7d15bbd2bc99e92e39f49a04653062ee6085c0e18b3b7512a4f2fe91f2d681", size = 109339 }, + { url = "https://files.pythonhosted.org/packages/75/56/05d000de894c4cfcb84bcd6b1df6214297b8089a7bd324c21a4765e49b14/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3890b508a23299083e065f435a492b5435eba6e304a7114d2f919d400888cc6", size = 110232 }, + { url = "https://files.pythonhosted.org/packages/53/f8/c3f6b2cf9b9277fb0813418e1503e68414cd036b3b099c823379c9575e6d/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c8b293cd65ad716d13d8dd3624e42e5a19cc2a2f1acc74b30c2c13f15cb61a6", size = 100476 }, + { url = "https://files.pythonhosted.org/packages/a7/b1/0bb11e29aa5139d90b770ebbfa167267b1fc548d2302c30c8f7572851738/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c82b8785d98cdd9fed4cac84d765d234ed3251bd6afe34cb7ac523cb93e8b4f", size = 106377 }, + { url = "https://files.pythonhosted.org/packages/6a/e1/0122853035b40b3f333bbb25f1939fc1045e21dd518f7f0922b60c156f7c/wrapt-1.17.2-cp313-cp313t-win32.whl", hash = "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555", size = 37986 }, + { url = "https://files.pythonhosted.org/packages/09/5e/1655cf481e079c1f22d0cabdd4e51733679932718dc23bf2db175f329b76/wrapt-1.17.2-cp313-cp313t-win_amd64.whl", hash = "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c", size = 40750 }, + { url = "https://files.pythonhosted.org/packages/2d/82/f56956041adef78f849db6b289b282e72b55ab8045a75abad81898c28d19/wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8", size = 23594 }, +] + [[package]] name = "wsproto" version = "1.2.0"