diff --git a/.gitignore b/.gitignore index 09b25f4..010176b 100644 --- a/.gitignore +++ b/.gitignore @@ -223,4 +223,6 @@ bots.yaml bot_data/ -bot.yaml \ No newline at end of file +bot.yaml + +logs/ \ No newline at end of file diff --git a/README.md b/README.md index 5a05485..d3d671e 100644 --- a/README.md +++ b/README.md @@ -111,11 +111,12 @@ from bot_sdk import ( Message, CommandSpec, CommandArgument, - setup_logging + setup_logging, ) class MyBot(BaseBot): def __init__(self, client): + # BaseBot will receive a bot-specific logger when used via BotRunner / console super().__init__(client) # Register commands (prefixes come from bot.yaml) self.command_parser.register_spec( @@ -129,7 +130,8 @@ class MyBot(BaseBot): async def on_start(self): """Called when bot starts""" - print(f"Bot started! User ID: {self._user_id}") + # Prefer structured logging over print + self.logger.info("Bot started! user_id={}", self._user_id) async def handle_echo(self, invocation, message, bot): """Handle echo command""" @@ -138,6 +140,7 @@ class MyBot(BaseBot): async def on_message(self, message: Message): """Handle non-command messages""" + self.logger.debug("Incoming message: {}", message.content[:50]) await self.send_reply(message, "Try !help to see available commands!") BOT_CLASS = MyBot diff --git a/README.zh-CN.md b/README.zh-CN.md index 82f8900..c90da66 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -109,11 +109,12 @@ from bot_sdk import ( Message, CommandSpec, CommandArgument, - setup_logging + setup_logging, ) class MyBot(BaseBot): def __init__(self, client): + # 通过 BotRunner / 控制台启动时,BaseBot 会注入带有 Bot 名称标签的 logger super().__init__(client) # 注册命令(前缀来源于 bot.yaml) self.command_parser.register_spec( @@ -127,7 +128,8 @@ class MyBot(BaseBot): async def on_start(self): """启动时调用""" - print(f"Bot started! User ID: {self._user_id}") + # 推荐使用结构化日志而不是 print + self.logger.info("Bot started! user_id={}", self._user_id) async def handle_echo(self, invocation, message, bot): """处理 echo 命令""" @@ -136,6 +138,7 @@ class MyBot(BaseBot): async def on_message(self, message: Message): """处理非命令消息""" + self.logger.debug("Incoming message: {}", message.content[:50]) await self.send_reply(message, "尝试使用 !help 查看可用命令!") BOT_CLASS = MyBot diff --git a/bot_sdk/__init__.py b/bot_sdk/__init__.py index 02d2d68..8c6ff76 100644 --- a/bot_sdk/__init__.py +++ b/bot_sdk/__init__.py @@ -1,18 +1,18 @@ from .async_zulip import AsyncClient from .bot import BaseBot from .commands import ( - CommandArgument, - CommandError, - CommandInvocation, - CommandParser, - CommandSpec, - InvalidArgumentsError, - UnknownCommandError, - OptionValidator, + CommandArgument, + CommandError, + CommandInvocation, + CommandParser, + CommandSpec, + InvalidArgumentsError, + UnknownCommandError, + OptionValidator, Validator, ) from .runner import BotRunner -from .logging import setup_logging +from .log import setup_logging, get_bot_logger from .i18n import I18n, build_i18n_for_bot from .storage import BotStorage, CachedStorage from .config import ( @@ -21,20 +21,20 @@ save_config, ) from .models import ( - Event, - EventsResponse, - Message, - PrivateMessageRequest, - PrivateRecipient, - RegisterResponse, - UserProfileResponse, - SendMessageResponse, - StreamMessageRequest, - ProfileFieldValue, - User, + Event, + EventsResponse, + Message, + PrivateMessageRequest, + PrivateRecipient, + RegisterResponse, + UserProfileResponse, + SendMessageResponse, + StreamMessageRequest, + ProfileFieldValue, + User, UpdatePresenceRequest, GetUserGroupsRequest, - GetUserGroupsResponse, + GetUserGroupsResponse, DataModel, Timestamped, IDModel, @@ -50,48 +50,49 @@ ) __all__ = [ - "AsyncClient", - "BaseBot", - "CommandParser", - "CommandSpec", - "CommandArgument", - "CommandInvocation", - "CommandError", - "InvalidArgumentsError", - "UnknownCommandError", - "BotRunner", - "setup_logging", - "BotStorage", - "CachedStorage", + "AsyncClient", + "BaseBot", + "CommandParser", + "CommandSpec", + "CommandArgument", + "CommandInvocation", + "CommandError", + "InvalidArgumentsError", + "UnknownCommandError", + "BotRunner", + "setup_logging", + "BotStorage", + "CachedStorage", "StorageConfig", - "Event", - "EventsResponse", - "Message", - "PrivateRecipient", - "StreamMessageRequest", - "PrivateMessageRequest", - "SendMessageResponse", - "UserProfileResponse", - "RegisterResponse", - "ProfileFieldValue", + "Event", + "EventsResponse", + "Message", + "PrivateRecipient", + "StreamMessageRequest", + "PrivateMessageRequest", + "SendMessageResponse", + "UserProfileResponse", + "RegisterResponse", + "ProfileFieldValue", "UpdatePresenceRequest", - "User", + "User", "GetUserGroupsRequest", - "GetUserGroupsResponse", + "GetUserGroupsResponse", "Validator", - "OptionValidator", - "DataModel", - "Timestamped", - "IDModel", - "SoftDelete", - "Base", - "make_sqlite_url", - "create_engine", - "create_sessionmaker", - "session_scope", - "AsyncRepository", - "I18n", - "build_i18n_for_bot", - "load_config", - "save_config", + "OptionValidator", + "DataModel", + "Timestamped", + "IDModel", + "SoftDelete", + "Base", + "make_sqlite_url", + "create_engine", + "create_sessionmaker", + "session_scope", + "AsyncRepository", + "I18n", + "build_i18n_for_bot", + "load_config", + "save_config", + "get_bot_logger", ] diff --git a/bot_sdk/async_zulip.py b/bot_sdk/async_zulip.py index 45484a7..555e32c 100644 --- a/bot_sdk/async_zulip.py +++ b/bot_sdk/async_zulip.py @@ -27,7 +27,6 @@ async def main(): import argparse import asyncio import json -import logging import optparse import os import platform @@ -74,7 +73,7 @@ async def main(): __version__ = "0.9.1-async" -logger = logging.getLogger(__name__) +from loguru import logger API_VERSTRING = "v1/" @@ -164,9 +163,13 @@ def custom_error_handling(self: argparse.ArgumentParser, message: str) -> None: ) group = parser.add_argument_group("Zulip API configuration") - group.add_argument("--site", dest="zulip_site", help="Zulip server URI", default=None) + group.add_argument( + "--site", dest="zulip_site", help="Zulip server URI", default=None + ) group.add_argument("--api-key", dest="zulip_api_key", action="store") - group.add_argument("--user", dest="zulip_email", help="Email address of the calling bot or user.") + group.add_argument( + "--user", dest="zulip_email", help="Email address of the calling bot or user." + ) group.add_argument( "--config-file", action="store", @@ -174,9 +177,15 @@ def custom_error_handling(self: argparse.ArgumentParser, message: str) -> None: help="""Location of an ini file containing the above information. (default ~/.zuliprc)""", ) - group.add_argument("-v", "--verbose", action="store_true", help="Provide detailed output.") group.add_argument( - "--client", action="store", default=None, dest="zulip_client", help=argparse.SUPPRESS + "-v", "--verbose", action="store_true", help="Provide detailed output." + ) + group.add_argument( + "--client", + action="store", + default=None, + dest="zulip_client", + help=argparse.SUPPRESS, ) group.add_argument( "--insecure", @@ -213,24 +222,34 @@ def custom_error_handling(self: argparse.ArgumentParser, message: str) -> None: return parser -def generate_option_group(parser: optparse.OptionParser, prefix: str = "") -> optparse.OptionGroup: - logging.warning( +def generate_option_group( + parser: optparse.OptionParser, prefix: str = "" +) -> optparse.OptionGroup: + logger.warning( """zulip.generate_option_group is based on optparse, which is now deprecated. We recommend migrating to argparse and using zulip.add_default_arguments instead.""" ) group = optparse.OptionGroup(parser, "Zulip API configuration") - group.add_option(f"--{prefix}site", dest="zulip_site", help="Zulip server URI", default=None) + group.add_option( + f"--{prefix}site", dest="zulip_site", help="Zulip server URI", default=None + ) group.add_option(f"--{prefix}api-key", dest="zulip_api_key", action="store") - group.add_option(f"--{prefix}user", dest="zulip_email", help="Email address of the calling bot or user.") + group.add_option( + f"--{prefix}user", + dest="zulip_email", + help="Email address of the calling bot or user.", + ) group.add_option( f"--{prefix}config-file", action="store", dest="zulip_config_file", help="Location of an ini file containing the\nabove information. (default ~/.zuliprc)", ) - group.add_option("-v", "--verbose", action="store_true", help="Provide detailed output.") + group.add_option( + "-v", "--verbose", action="store_true", help="Provide detailed output." + ) group.add_option( f"--{prefix}client", action="store", @@ -307,7 +326,9 @@ def get_default_config_filename() -> Optional[str]: return None config_file = os.path.join(os.environ["HOME"], ".zuliprc") - if not os.path.exists(config_file) and os.path.exists(os.path.join(os.environ["HOME"], ".humbugrc")): + if not os.path.exists(config_file) and os.path.exists( + os.path.join(os.environ["HOME"], ".humbugrc") + ): raise ZulipError( "The Zulip API configuration file is now ~/.zuliprc; please run:\n\n" " mv ~/.humbugrc ~/.zuliprc\n" @@ -403,7 +424,9 @@ def __init__( site = site.rstrip("/") self.base_url = site else: - raise MissingURLError("Missing Zulip server URL; specify via --site or ~/.zuliprc.") + raise MissingURLError( + "Missing Zulip server URL; specify via --site or ~/.zuliprc." + ) if not self.base_url.endswith("/api"): self.base_url += "/api" @@ -434,7 +457,9 @@ def __init__( if not os.path.isfile(client_cert): raise ConfigNotFoundError(f"client cert '{client_cert}' does not exist") if client_cert_key is not None and not os.path.isfile(client_cert_key): - raise ConfigNotFoundError(f"client cert key '{client_cert_key}' does not exist") + raise ConfigNotFoundError( + f"client cert key '{client_cert_key}' does not exist" + ) self.client_cert = client_cert self.client_cert_key = client_cert_key @@ -496,7 +521,10 @@ async def do_api_query( request_timeout = 90.0 if longpolling else timeout or 15.0 - request = {key: val if isinstance(val, str) else json.dumps(val) for key, val in orig_request.items()} + request = { + key: val if isinstance(val, str) else json.dumps(val) + for key, val in orig_request.items() + } req_files = [(f.name, f) for f in files] await self.ensure_session() @@ -541,16 +569,21 @@ def end_error_retry(succeeded: bool) -> None: if files: kwargs["files"] = req_files + full_url = urllib.parse.urljoin(self.base_url, url) + logger.debug(f"Making {method} request to {full_url} with {kwargs}") + res = await self.session.request( method, - urllib.parse.urljoin(self.base_url, url), + full_url, timeout=request_timeout, **kwargs, ) self.has_connected = True - if str(res.status_code).startswith("5") and error_retry(f" (server {res.status_code})"): + if str(res.status_code).startswith("5") and error_retry( + f" (server {res.status_code})" + ): await asyncio.sleep(1) continue @@ -561,7 +594,9 @@ def end_error_retry(succeeded: bool) -> None: raise e except httpx.ConnectError as e: if not self.has_connected: - raise UnrecoverableNetworkError("cannot connect to server " + self.base_url) from e + raise UnrecoverableNetworkError( + "cannot connect to server " + full_url + ) from e if error_retry(""): await asyncio.sleep(1) continue @@ -643,10 +678,18 @@ async def do_register() -> Tuple[str, int]: queue_id, last_event_id = await do_register() try: - res = await self.get_events(queue_id=queue_id, last_event_id=last_event_id) - except (httpx.TimeoutException, httpx.ConnectError, httpx.RemoteProtocolError): + res = await self.get_events( + queue_id=queue_id, last_event_id=last_event_id + ) + except ( + httpx.TimeoutException, + httpx.ConnectError, + httpx.RemoteProtocolError, + ): if self.verbose: - print(f"Connection error fetching events:\n{traceback.format_exc()}") + print( + f"Connection error fetching events:\n{traceback.format_exc()}" + ) # RemoteProtocolError often indicates dropped long-poll; re-register next loop queue_id = None await asyncio.sleep(1) @@ -663,7 +706,9 @@ async def do_register() -> Tuple[str, int]: else: if self.verbose: print(f"Server returned error:\n{res.msg}") - if res.model_extra.get("code") == "BAD_EVENT_QUEUE_ID" or res.msg.startswith( + if res.model_extra.get( + "code" + ) == "BAD_EVENT_QUEUE_ID" or res.msg.startswith( "Bad event queue id:" ): queue_id = None @@ -680,7 +725,8 @@ async def do_register() -> Tuple[str, int]: async def call_on_each_message( self, - callback: Callable[[Dict[str, Any]], Awaitable[None]] | Callable[[Dict[str, Any]], None], + callback: Callable[[Dict[str, Any]], Awaitable[None]] + | Callable[[Dict[str, Any]], None], **kwargs: object, ) -> None: async def event_callback(event: Event) -> None: @@ -700,31 +746,50 @@ async def register( if narrow is None: narrow = [] request = dict(event_types=event_types, narrow=narrow, **kwargs) - return RegisterResponse.model_validate(await self.call_endpoint(url="register", request=request)) + return RegisterResponse.model_validate( + await self.call_endpoint(url="register", request=request) + ) async def get_events(self, **request: Any) -> EventsResponse: return EventsResponse.model_validate( - await self.call_endpoint(url="events", method="GET", longpolling=True, request=request) + await self.call_endpoint( + url="events", method="GET", longpolling=True, request=request + ) ) - async def deregister(self, queue_id: str, timeout: Optional[float] = None) -> Dict[str, Any]: + async def deregister( + self, queue_id: str, timeout: Optional[float] = None + ) -> Dict[str, Any]: request = dict(queue_id=queue_id) - return await self.call_endpoint(url="events", method="DELETE", request=request, timeout=timeout) + return await self.call_endpoint( + url="events", method="DELETE", request=request, timeout=timeout + ) async def get_messages(self, message_filters: Dict[str, Any]) -> Dict[str, Any]: - return await self.call_endpoint(url="messages", method="GET", request=message_filters) + return await self.call_endpoint( + url="messages", method="GET", request=message_filters + ) - async def check_messages_match_narrow(self, **request: Dict[str, Any]) -> Dict[str, Any]: - return await self.call_endpoint(url="messages/matches_narrow", method="GET", request=request) + async def check_messages_match_narrow( + self, **request: Dict[str, Any] + ) -> Dict[str, Any]: + return await self.call_endpoint( + url="messages/matches_narrow", method="GET", request=request + ) async def get_raw_message(self, message_id: int) -> Dict[str, str]: return await self.call_endpoint(url=f"messages/{message_id}", method="GET") - async def send_message(self, message_data: Dict[str, Any] | StreamMessageRequest | PrivateMessageRequest) -> SendMessageResponse: + async def send_message( + self, + message_data: Dict[str, Any] | StreamMessageRequest | PrivateMessageRequest, + ) -> SendMessageResponse: payload = message_data if hasattr(message_data, "model_dump"): payload = message_data.model_dump(exclude_none=True) - return SendMessageResponse.model_validate(await self.call_endpoint(url="messages", request=payload)) + return SendMessageResponse.model_validate( + await self.call_endpoint(url="messages", request=payload) + ) async def upload_file(self, file: IO[Any]) -> Dict[str, Any]: return await self.call_endpoint(url="user_uploads", files=[file]) @@ -743,15 +808,21 @@ async def delete_message(self, message_id: int) -> Dict[str, Any]: return await self.call_endpoint(url=f"messages/{message_id}", method="DELETE") async def update_message_flags(self, update_data: Dict[str, Any]) -> Dict[str, Any]: - return await self.call_endpoint(url="messages/flags", method="POST", request=update_data) + return await self.call_endpoint( + url="messages/flags", method="POST", request=update_data + ) async def mark_all_as_read(self) -> Dict[str, Any]: return await self.call_endpoint(url="mark_all_as_read", method="POST") async def mark_stream_as_read(self, stream_id: int) -> Dict[str, Any]: - return await self.call_endpoint(url="mark_stream_as_read", method="POST", request={"stream_id": stream_id}) + return await self.call_endpoint( + url="mark_stream_as_read", method="POST", request={"stream_id": stream_id} + ) - async def mark_topic_as_read(self, stream_id: int, topic_name: str) -> Dict[str, Any]: + async def mark_topic_as_read( + self, stream_id: int, topic_name: str + ) -> Dict[str, Any]: return await self.call_endpoint( url="mark_topic_as_read", method="POST", @@ -759,7 +830,9 @@ async def mark_topic_as_read(self, stream_id: int, topic_name: str) -> Dict[str, ) async def get_message_history(self, message_id: int) -> Dict[str, Any]: - return await self.call_endpoint(url=f"messages/{message_id}/history", method="GET") + return await self.call_endpoint( + url=f"messages/{message_id}/history", method="GET" + ) async def add_reaction(self, reaction_data: Dict[str, Any]) -> Dict[str, Any]: return await self.call_endpoint( @@ -778,11 +851,17 @@ async def remove_reaction(self, reaction_data: Dict[str, Any]) -> Dict[str, Any] async def get_realm_emoji(self) -> Dict[str, Any]: return await self.call_endpoint(url="realm/emoji", method="GET") - async def upload_custom_emoji(self, emoji_name: str, file_obj: IO[Any]) -> Dict[str, Any]: - return await self.call_endpoint(f"realm/emoji/{emoji_name}", method="POST", files=[file_obj]) + async def upload_custom_emoji( + self, emoji_name: str, file_obj: IO[Any] + ) -> Dict[str, Any]: + return await self.call_endpoint( + f"realm/emoji/{emoji_name}", method="POST", files=[file_obj] + ) async def delete_custom_emoji(self, emoji_name: str) -> Dict[str, Any]: - return await self.call_endpoint(url=f"realm/emoji/{emoji_name}", method="DELETE") + return await self.call_endpoint( + url=f"realm/emoji/{emoji_name}", method="DELETE" + ) async def get_realm_linkifiers(self) -> Dict[str, Any]: return await self.call_endpoint(url="realm/linkifiers", method="GET") @@ -792,30 +871,46 @@ async def add_realm_filter(self, pattern: str, url_template: str) -> Dict[str, A # Feature level not known in async client; keep both keys for compatibility. data["url_template"] = url_template data["url_format_string"] = url_template - return await self.call_endpoint(url="realm/filters", method="POST", request=data) + return await self.call_endpoint( + url="realm/filters", method="POST", request=data + ) async def remove_realm_filter(self, filter_id: int) -> Dict[str, Any]: - return await self.call_endpoint(url=f"realm/filters/{filter_id}", method="DELETE") + return await self.call_endpoint( + url=f"realm/filters/{filter_id}", method="DELETE" + ) async def get_realm_profile_fields(self) -> Dict[str, Any]: return await self.call_endpoint(url="realm/profile_fields", method="GET") async def create_realm_profile_field(self, **request: Any) -> Dict[str, Any]: - return await self.call_endpoint(url="realm/profile_fields", method="POST", request=request) + return await self.call_endpoint( + url="realm/profile_fields", method="POST", request=request + ) async def remove_realm_profile_field(self, field_id: int) -> Dict[str, Any]: - return await self.call_endpoint(url=f"realm/profile_fields/{field_id}", method="DELETE") + return await self.call_endpoint( + url=f"realm/profile_fields/{field_id}", method="DELETE" + ) async def reorder_realm_profile_fields(self, **request: Any) -> Dict[str, Any]: - return await self.call_endpoint(url="realm/profile_fields", method="PATCH", request=request) + return await self.call_endpoint( + url="realm/profile_fields", method="PATCH", request=request + ) - async def update_realm_profile_field(self, field_id: int, **request: Any) -> Dict[str, Any]: - return await self.call_endpoint(url=f"realm/profile_fields/{field_id}", method="PATCH", request=request) + async def update_realm_profile_field( + self, field_id: int, **request: Any + ) -> Dict[str, Any]: + return await self.call_endpoint( + url=f"realm/profile_fields/{field_id}", method="PATCH", request=request + ) async def get_server_settings(self) -> Dict[str, Any]: return await self.call_endpoint(url="server_settings", method="GET") - async def get_profile(self, request: Optional[Dict[str, Any]] = None) -> UserProfileResponse: + async def get_profile( + self, request: Optional[Dict[str, Any]] = None + ) -> UserProfileResponse: return UserProfileResponse.model_validate( await self.call_endpoint(url="users/me", method="GET", request=request) ) @@ -826,10 +921,14 @@ async def get_user_presence(self, email: str) -> Dict[str, Any]: async def get_realm_presence(self) -> Dict[str, Any]: return await self.call_endpoint(url="realm/presence", method="GET") - async def update_presence(self, request: Dict[str, Any] | UpdatePresenceRequest) -> Dict[str, Any]: + async def update_presence( + self, request: Dict[str, Any] | UpdatePresenceRequest + ) -> Dict[str, Any]: if hasattr(request, "model_dump"): request = request.model_dump(exclude_none=True) - return await self.call_endpoint(url="users/me/presence", method="POST", request=request) + return await self.call_endpoint( + url="users/me/presence", method="POST", request=request + ) async def get_streams(self, **request: Any) -> Dict[str, Any]: return await self.call_endpoint(url="streams", method="GET", request=request) @@ -845,24 +944,36 @@ async def delete_stream(self, stream_id: int) -> Dict[str, Any]: return await self.call_endpoint(url=f"streams/{stream_id}", method="DELETE") async def add_default_stream(self, stream_id: int) -> Dict[str, Any]: - return await self.call_endpoint(url="default_streams", method="POST", request={"stream_id": stream_id}) + return await self.call_endpoint( + url="default_streams", method="POST", request={"stream_id": stream_id} + ) async def get_user_by_id(self, user_id: int, **request: Any) -> Dict[str, Any]: - return await self.call_endpoint(url=f"users/{user_id}", method="GET", request=request) + return await self.call_endpoint( + url=f"users/{user_id}", method="GET", request=request + ) async def deactivate_user_by_id(self, user_id: int) -> Dict[str, Any]: return await self.call_endpoint(url=f"users/{user_id}", method="DELETE") async def reactivate_user_by_id(self, user_id: int) -> Dict[str, Any]: - return await self.call_endpoint(url=f"users/{user_id}/reactivate", method="POST") + return await self.call_endpoint( + url=f"users/{user_id}/reactivate", method="POST" + ) async def update_user_by_id(self, user_id: int, **request: Any) -> Dict[str, Any]: - return await self.call_endpoint(url=f"users/{user_id}", method="PATCH", request=request) + return await self.call_endpoint( + url=f"users/{user_id}", method="PATCH", request=request + ) - async def get_users(self, request: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + async def get_users( + self, request: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: return await self.call_endpoint(url="users", method="GET", request=request) - async def get_members(self, request: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + async def get_members( + self, request: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: return await self.get_users(request=request) async def get_alert_words(self) -> Dict[str, Any]: @@ -870,24 +981,38 @@ async def get_alert_words(self) -> Dict[str, Any]: async def add_alert_words(self, alert_words: List[str]) -> Dict[str, Any]: return await self.call_endpoint( - url="users/me/alert_words", method="POST", request={"alert_words": alert_words} + url="users/me/alert_words", + method="POST", + request={"alert_words": alert_words}, ) async def remove_alert_words(self, alert_words: List[str]) -> Dict[str, Any]: return await self.call_endpoint( - url="users/me/alert_words", method="DELETE", request={"alert_words": alert_words} + url="users/me/alert_words", + method="DELETE", + request={"alert_words": alert_words}, ) - async def get_subscriptions(self, request: Optional[Dict[str, Any]] = None) -> SubscriptionsResponse: + async def get_subscriptions( + self, request: Optional[Dict[str, Any]] = None + ) -> SubscriptionsResponse: return SubscriptionsResponse.model_validate( - await self.call_endpoint(url="users/me/subscriptions", method="GET", request=request) + await self.call_endpoint( + url="users/me/subscriptions", method="GET", request=request + ) ) - async def list_subscriptions(self, request: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - logger.warning("list_subscriptions() is deprecated. Please use get_subscriptions() instead.") + async def list_subscriptions( + self, request: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + logger.warning( + "list_subscriptions() is deprecated. Please use get_subscriptions() instead." + ) return await self.get_subscriptions(request) - async def add_subscriptions(self, streams: Iterable[Dict[str, Any]], **kwargs: Any) -> Dict[str, Any]: + async def add_subscriptions( + self, streams: Iterable[Dict[str, Any]], **kwargs: Any + ) -> Dict[str, Any]: request = dict(subscriptions=streams, **kwargs) return await self.call_endpoint(url="users/me/subscriptions", request=request) @@ -899,22 +1024,34 @@ async def remove_subscriptions( request: Dict[str, object] = dict(subscriptions=streams) if principals is not None: request["principals"] = principals - return await self.call_endpoint(url="users/me/subscriptions", method="DELETE", request=request) + return await self.call_endpoint( + url="users/me/subscriptions", method="DELETE", request=request + ) - async def get_subscription_status(self, user_id: int, stream_id: int) -> Dict[str, Any]: - return await self.call_endpoint(url=f"users/{user_id}/subscriptions/{stream_id}", method="GET") + async def get_subscription_status( + self, user_id: int, stream_id: int + ) -> Dict[str, Any]: + return await self.call_endpoint( + url=f"users/{user_id}/subscriptions/{stream_id}", method="GET" + ) async def mute_topic(self, request: Dict[str, Any]) -> Dict[str, Any]: - return await self.call_endpoint(url="users/me/subscriptions/muted_topics", method="PATCH", request=request) + return await self.call_endpoint( + url="users/me/subscriptions/muted_topics", method="PATCH", request=request + ) - async def update_subscription_settings(self, subscription_data: List[Dict[str, Any]]) -> Dict[str, Any]: + async def update_subscription_settings( + self, subscription_data: List[Dict[str, Any]] + ) -> Dict[str, Any]: return await self.call_endpoint( url="users/me/subscriptions/properties", method="POST", request={"subscription_data": subscription_data}, ) - async def update_notification_settings(self, notification_settings: Dict[str, Any]) -> Dict[str, Any]: + async def update_notification_settings( + self, notification_settings: Dict[str, Any] + ) -> Dict[str, Any]: return await self.call_endpoint( url="settings/notifications", method="PATCH", @@ -932,25 +1069,33 @@ async def get_stream(self, stream_id: int) -> ChannelResponse: ) async def get_stream_topics(self, stream_id: int) -> Dict[str, Any]: - return await self.call_endpoint(url=f"users/me/{stream_id}/topics", method="GET") + return await self.call_endpoint( + url=f"users/me/{stream_id}/topics", method="GET" + ) async def get_stream_email_address(self, stream_id: int) -> Dict[str, Any]: - return await self.call_endpoint(url=f"streams/{stream_id}/email_address", method="GET") + return await self.call_endpoint( + url=f"streams/{stream_id}/email_address", method="GET" + ) - async def get_user_groups(self, request: Optional[Dict[str, Any] | GetUserGroupsRequest] = None) -> GetUserGroupsResponse: + async def get_user_groups( + self, request: Optional[Dict[str, Any] | GetUserGroupsRequest] = None + ) -> GetUserGroupsResponse: payload = {} if request is not None: if hasattr(request, "model_dump"): payload = request.model_dump(exclude_none=True) else: payload = {k: v for k, v in request.items() if v is not None} - + return GetUserGroupsResponse.model_validate( await self.call_endpoint(url="user_groups", method="GET", request=payload) ) async def create_user_group(self, group_data: Dict[str, Any]) -> Dict[str, Any]: - return await self.call_endpoint(url="user_groups/create", method="POST", request=group_data) + return await self.call_endpoint( + url="user_groups/create", method="POST", request=group_data + ) async def update_user_group(self, group_data: Dict[str, Any]) -> Dict[str, Any]: return await self.call_endpoint( @@ -962,7 +1107,9 @@ async def update_user_group(self, group_data: Dict[str, Any]) -> Dict[str, Any]: async def remove_user_group(self, group_id: int) -> Dict[str, Any]: return await self.call_endpoint(url=f"user_groups/{group_id}", method="DELETE") - async def update_user_group_members(self, user_group_id: int, group_data: Dict[str, Any]) -> Dict[str, Any]: + async def update_user_group_members( + self, user_group_id: int, group_data: Dict[str, Any] + ) -> Dict[str, Any]: return await self.call_endpoint( url=f"user_groups/{user_group_id}/members", method="POST", @@ -977,17 +1124,29 @@ async def get_subscribers(self, **request: Any) -> Dict[str, Any]: url = "streams/%d/members" % (stream_id,) return await self.call_endpoint(url=url, method="GET", request=request) - async def render_message(self, request: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - return await self.call_endpoint(url="messages/render", method="POST", request=request) + async def render_message( + self, request: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + return await self.call_endpoint( + url="messages/render", method="POST", request=request + ) - async def create_user(self, request: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + async def create_user( + self, request: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: return await self.call_endpoint(method="POST", url="users", request=request) async def update_storage(self, request: Dict[str, Any]) -> Dict[str, Any]: - return await self.call_endpoint(url="bot_storage", method="PUT", request=request) + return await self.call_endpoint( + url="bot_storage", method="PUT", request=request + ) - async def get_storage(self, request: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - return await self.call_endpoint(url="bot_storage", method="GET", request=request) + async def get_storage( + self, request: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + return await self.call_endpoint( + url="bot_storage", method="GET", request=request + ) async def set_typing_status(self, request: Dict[str, Any]) -> Dict[str, Any]: return await self.call_endpoint(url="typing", method="POST", request=request) @@ -1015,7 +1174,9 @@ async def move_topic( if message_id is None: if propagate_mode != "change_all": - raise AttributeError('A message_id must be provided if propagate_mode is not "change_all"') + raise AttributeError( + 'A message_id must be provided if propagate_mode is not "change_all"' + ) result = await self.get_messages( { "anchor": "newest", @@ -1030,7 +1191,10 @@ async def move_topic( if result.get("result") != "success": return result if len(result.get("messages", [])) <= 0: - return {"result": "error", "msg": f'No messages found in topic: "{topic}"'} + return { + "result": "error", + "msg": f'No messages found in topic: "{topic}"', + } message_id = result["messages"][0]["id"] request = { @@ -1040,7 +1204,9 @@ async def move_topic( "send_notification_to_old_thread": notify_old_topic, "send_notification_to_new_thread": notify_new_topic, } - return await self.call_endpoint(url=f"messages/{message_id}", method="PATCH", request=request) + return await self.call_endpoint( + url=f"messages/{message_id}", method="PATCH", request=request + ) async def aclose(self) -> None: if self.session: @@ -1056,7 +1222,12 @@ def __init__(self, type: str, to: str, subject: str, **kwargs: Any) -> None: self.subject = subject async def write(self, content: str) -> None: - message = {"type": self.type, "to": self.to, "subject": self.subject, "content": content} + message = { + "type": self.type, + "to": self.to, + "subject": self.subject, + "content": content, + } await self.client.send_message(message) async def flush(self) -> None: diff --git a/bot_sdk/bot.py b/bot_sdk/bot.py index 1851338..309dd0c 100644 --- a/bot_sdk/bot.py +++ b/bot_sdk/bot.py @@ -7,19 +7,29 @@ from pathlib import Path from typing import TYPE_CHECKING, AsyncIterator, Optional, Any -from loguru import logger from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker +from .log import Logger from .async_zulip import AsyncClient from .commands import CommandArgument, CommandInvocation, CommandParser, CommandSpec -from .config import BotLocalConfig, StorageConfig, load_bot_local_config, save_bot_local_config -from .db.database import create_engine, create_sessionmaker, make_sqlite_url, session_scope +from .config import ( + BotLocalConfig, + StorageConfig, + load_bot_local_config, + save_bot_local_config, +) +from .db.database import ( + create_engine, + create_sessionmaker, + make_sqlite_url, + session_scope, +) from .models import ( Event, Message, PrivateMessageRequest, StreamMessageRequest, - UpdatePresenceRequest + UpdatePresenceRequest, ) from .permissions import PermissionPolicy from .storage import BotStorage @@ -38,7 +48,8 @@ class BaseBot(abc.ABC): `bot.yaml` instead of subclass attributes. """ - def __init__(self, client: AsyncClient) -> None: + def __init__(self, client: AsyncClient, logger: Logger) -> None: + self.logger = logger self.client = client # These will be populated from bot.yaml in _load_settings self.command_prefixes: tuple[str, ...] = tuple() @@ -63,7 +74,7 @@ def __init__(self, client: AsyncClient) -> None: # ORM engine/session factory (initialized only when enable_orm is True) self._orm_engine: Optional[AsyncEngine] = None self._orm_session_factory: Optional[async_sessionmaker[AsyncSession]] = None - logger.debug(f"Initialized bot {self.__class__.__name__}") + self.logger.debug(f"Initialized bot {self.__class__.__name__}") def set_runner(self, runner: "BotRunner") -> None: """Called by BotRunner to allow commands to signal runner actions.""" @@ -72,7 +83,7 @@ def set_runner(self, runner: "BotRunner") -> None: def set_storage_config(self, storage_config: Optional[StorageConfig]) -> None: """Inject per-bot storage configuration from runner/config layer.""" self.storage_config = storage_config - + async def post_init(self) -> None: """Hook for post-initialization logic. Override if needed. @@ -106,7 +117,7 @@ async def _init_storage(self) -> None: auto_flush_retry=auto_cfg.auto_flush_retry, auto_flush_max_retries=auto_cfg.auto_flush_max_retries, ) - logger.info(f"Initialized storage at {self.storage_path}") + self.logger.info(f"Initialized storage at {self.storage_path}") # Initialize permissions helper self.perms = PermissionPolicy(self.client, self.storage) @@ -141,7 +152,9 @@ async def _init_orm(self) -> None: self.orm_db_path = f"bot_data/{db_name}.sqlite" db_url = make_sqlite_url(self.orm_db_path) - logger.info(f"Initializing ORM database for {self.__class__.__name__} at {db_url}") + self.logger.info( + f"Initializing ORM database for {self.__class__.__name__} at {db_url}" + ) self._orm_engine = create_engine(db_url) self._orm_session_factory = create_sessionmaker(self._orm_engine) @@ -159,10 +172,14 @@ async def _init_i18n(self) -> None: self.language = lang try: self.i18n = build_i18n_for_bot(lang, self.__class__.__module__) - logger.info(f"Initialized i18n for {self.__class__.__name__} with language '{lang}'") + self.logger.info( + f"Initialized i18n for {self.__class__.__name__} with language '{lang}'" + ) except Exception as exc: # pragma: no cover - i18n should not block bot startup self.i18n = None - logger.warning(f"Failed to initialize i18n for {self.__class__.__name__}: {exc}") + self.logger.warning( + f"Failed to initialize i18n for {self.__class__.__name__}: {exc}" + ) async def _load_settings(self) -> None: """Load per-bot settings YAML next to the bot module by default.""" @@ -172,19 +189,23 @@ async def _load_settings(self) -> None: mod_file = inspect.getfile(mod) default_path = Path(mod_file).parent / "bot.yaml" except Exception: - logger.warning("Failed to determine bot module path, using ./bot.yaml for settings") + self.logger.warning( + "Failed to determine bot module path, using ./bot.yaml for settings" + ) default_path = Path("bot.yaml") # Load settings if not default_path.exists(): - logger.info(f"No bot settings file found at {default_path}, using defaults.") + self.logger.info( + f"No bot settings file found at {default_path}, using defaults." + ) self.settings = BotLocalConfig() self._settings_path = default_path # type: ignore[attr-defined] await self.save_settings() # Save default config - logger.info(f"Created default bot settings at {default_path}") + self.logger.info(f"Created default bot settings at {default_path}") else: self.settings = load_bot_local_config(default_path) self._settings_path = default_path # type: ignore[attr-defined] - logger.info(f"Loaded bot settings from {default_path}") + self.logger.info(f"Loaded bot settings from {default_path}") # Apply settings to runtime fields (YAML is the single source of truth now) overrides = self.settings @@ -193,17 +214,23 @@ async def _load_settings(self) -> None: # Fallback to safe default if config left empty self.command_prefixes = ("!",) self.enable_mention_commands = bool( - overrides.enable_mention_commands if overrides.enable_mention_commands is not None else True + overrides.enable_mention_commands + if overrides.enable_mention_commands is not None + else True ) self.auto_help_command = bool( - overrides.auto_help_command if overrides.auto_help_command is not None else True + overrides.auto_help_command + if overrides.auto_help_command is not None + else True ) self.enable_storage = bool( overrides.enable_storage if overrides.enable_storage is not None else True ) self.storage_path = overrides.storage_path self.storage_config = overrides.storage or StorageConfig() - self.enable_orm = bool(overrides.enable_orm) if overrides.enable_orm is not None else False + self.enable_orm = ( + bool(overrides.enable_orm) if overrides.enable_orm is not None else False + ) self.orm_db_path = overrides.orm_db_path # Recreate command parser to honor updated prefixes/mentions/help flags @@ -221,10 +248,12 @@ async def _load_identity(self) -> None: if self.storage: profile_data = await self.storage.get("__profile__") if profile_data: - logger.debug("Loaded bot profile from storage cache") + self.logger.debug("Loaded bot profile from storage cache") email = profile_data.get("email") else: - logger.debug("Fetching bot profile for command parser identity aliases") + self.logger.debug( + "Fetching bot profile for command parser identity aliases" + ) profile = await self.client.get_profile() profile_data = { "user_id": profile.user_id, @@ -238,12 +267,14 @@ async def _load_identity(self) -> None: self._user_id = profile_data["user_id"] self._user_name = profile_data["full_name"] self.command_parser.add_identity_aliases(full_name=self._user_name, email=email) - logger.info(f"{self.__class__.__name__} started with user_id: {self._user_id}") + self.logger.info( + f"{self.__class__.__name__} started with user_id: {self._user_id}" + ) async def _set_presence(self) -> None: """Set active presence on startup.""" await self.client.update_presence(UpdatePresenceRequest(status="active")) - logger.info("Set presence to active") + self.logger.info("Set presence to active") async def _register_commands(self) -> None: """Register built-in and bot-specific commands. @@ -259,15 +290,34 @@ async def _register_commands(self) -> None: ) ) # Permissions management command (restricted by min_level) - default_levels = (self.settings.role_levels if self.settings else {"user": 1, "admin": 50, "owner": 100, "bot_owner": 200}) + default_levels = ( + self.settings.role_levels + if self.settings + else {"user": 1, "admin": 50, "owner": 100, "bot_owner": 200} + ) self.command_parser.register_spec( CommandSpec( name="perm", description=self.tr("Manage bot permissions"), args=[ - CommandArgument("action", str, required=True, description="Action: set_owner | roles_show | roles_set | allow_stop | deny_stop"), - CommandArgument("arg1", str, required=False, description="First argument (user_id or role) depending on action"), - CommandArgument("arg2", str, required=False, description="Second argument (level) for roles_set"), + CommandArgument( + "action", + str, + required=True, + description="Action: set_owner | roles_show | roles_set | allow_stop | deny_stop", + ), + CommandArgument( + "arg1", + str, + required=False, + description="First argument (user_id or role) depending on action", + ), + CommandArgument( + "arg2", + str, + required=False, + description="Second argument (level) for roles_set", + ), ], handler=self._handle_perm, min_level=default_levels.get("bot_owner", 200), @@ -306,7 +356,7 @@ async def save_settings(self) -> None: try: save_bot_local_config(self._settings_path, self.settings) except Exception: - logger.warning("Failed to save bot settings") + self.logger.warning("Failed to save bot settings") def tr(self, key: str, **kwargs: Any) -> str: """Translate a user-facing string using the bot's i18n helper. @@ -350,15 +400,25 @@ async def orm_session(self) -> AsyncIterator[AsyncSession]: async with session_scope(self._orm_session_factory) as session: yield session - async def _handle_whoami(self, invocation: CommandInvocation, message: Message, bot: BaseBot) -> None: + async def _handle_whoami( + self, invocation: CommandInvocation, message: Message, bot: BaseBot + ) -> None: """Show permission-related info for the caller.""" user_id = message.sender_id # Determine level by config priorities - role_levels = (self.settings.role_levels if self.settings else {"user": 1, "admin": 50, "owner": 100, "bot_owner": 200}) + role_levels = ( + self.settings.role_levels + if self.settings + else {"user": 1, "admin": 50, "owner": 100, "bot_owner": 200} + ) level = role_levels.get("user", 1) roles: list[str] = ["user"] # Bot owner - if self.settings and self.settings.owner_user_id and user_id == self.settings.owner_user_id: + if ( + self.settings + and self.settings.owner_user_id + and user_id == self.settings.owner_user_id + ): level = max(level, role_levels.get("bot_owner", 200)) roles.append("bot_owner") # Org admin/owner @@ -381,7 +441,9 @@ async def _handle_whoami(self, invocation: CommandInvocation, message: Message, ) await self.send_reply(message, text) - async def _handle_perm(self, invocation: CommandInvocation, message: Message, bot: BaseBot) -> None: + async def _handle_perm( + self, invocation: CommandInvocation, message: Message, bot: BaseBot + ) -> None: action = (invocation.args.get("action") or "").lower() arg1 = invocation.args.get("arg1") arg2 = invocation.args.get("arg2") @@ -394,7 +456,9 @@ def err(msg: str) -> str: if action == "set_owner": if not arg1: - await self.send_reply(message, err(self.tr("Usage: !perm set_owner "))) + await self.send_reply( + message, err(self.tr("Usage: !perm set_owner ")) + ) return try: new_owner = int(arg1) @@ -405,18 +469,32 @@ def err(msg: str) -> str: self.settings = BotLocalConfig() self.settings.owner_user_id = new_owner await self.save_settings() - await self.send_reply(message, ok(self.tr("Bot owner set to {user_id}", user_id=new_owner))) + await self.send_reply( + message, ok(self.tr("Bot owner set to {user_id}", user_id=new_owner)) + ) return if action == "roles_show": - role_levels = (self.settings.role_levels if self.settings else {"user": 1, "admin": 50, "owner": 100, "bot_owner": 200}) - lines = [f"{k}: {v}" for k, v in sorted(role_levels.items(), key=lambda kv: kv[1])] - await self.send_reply(message, self.tr("Current role levels:\n{lines}", lines="\n".join(lines))) + role_levels = ( + self.settings.role_levels + if self.settings + else {"user": 1, "admin": 50, "owner": 100, "bot_owner": 200} + ) + lines = [ + f"{k}: {v}" + for k, v in sorted(role_levels.items(), key=lambda kv: kv[1]) + ] + await self.send_reply( + message, + self.tr("Current role levels:\n{lines}", lines="\n".join(lines)), + ) return if action == "roles_set": if not arg1 or not arg2: - await self.send_reply(message, err(self.tr("Usage: !perm roles_set "))) + await self.send_reply( + message, err(self.tr("Usage: !perm roles_set ")) + ) return role = str(arg1) try: @@ -428,12 +506,21 @@ def err(msg: str) -> str: self.settings = BotLocalConfig() self.settings.role_levels[role] = level await self.save_settings() - await self.send_reply(message, ok(self.tr("Role '{role}' level set to {level}", role=role, level=level))) + await self.send_reply( + message, + ok( + self.tr( + "Role '{role}' level set to {level}", role=role, level=level + ) + ), + ) return if action == "allow_stop": if not arg1: - await self.send_reply(message, err(self.tr("Usage: !perm allow_stop "))) + await self.send_reply( + message, err(self.tr("Usage: !perm allow_stop ")) + ) return try: uid = int(arg1) @@ -446,14 +533,27 @@ def err(msg: str) -> str: if uid not in acl: acl.append(uid) await self.storage.put("acl.stop", acl) - await self.send_reply(message, ok(self.tr("User {user_id} allowed to stop bot", user_id=uid))) + await self.send_reply( + message, + ok(self.tr("User {user_id} allowed to stop bot", user_id=uid)), + ) else: - await self.send_reply(message, ok(self.tr("User {user_id} is already allowed to stop bot", user_id=uid))) + await self.send_reply( + message, + ok( + self.tr( + "User {user_id} is already allowed to stop bot", + user_id=uid, + ) + ), + ) return if action == "deny_stop": if not arg1: - await self.send_reply(message, err(self.tr("Usage: !perm deny_stop "))) + await self.send_reply( + message, err(self.tr("Usage: !perm deny_stop ")) + ) return try: uid = int(arg1) @@ -465,12 +565,23 @@ def err(msg: str) -> str: acl = await self.storage.get("acl.stop", []) acl = [x for x in acl if x != uid] await self.storage.put("acl.stop", acl) - await self.send_reply(message, ok(self.tr("User {user_id} denied to stop bot", user_id=uid))) + await self.send_reply( + message, ok(self.tr("User {user_id} denied to stop bot", user_id=uid)) + ) return - await self.send_reply(message, err(self.tr("Unknown action. Use: set_owner | roles_show | roles_set | allow_stop | deny_stop"))) + await self.send_reply( + message, + err( + self.tr( + "Unknown action. Use: set_owner | roles_show | roles_set | allow_stop | deny_stop" + ) + ), + ) - async def _handle_reload(self, invocation: CommandInvocation, message: Message, bot: BaseBot) -> None: + async def _handle_reload( + self, invocation: CommandInvocation, message: Message, bot: BaseBot + ) -> None: """Reload bot settings (bot.yaml) and i18n files without restart.""" # Reload settings from disk if we know where they live. try: @@ -481,31 +592,43 @@ async def _handle_reload(self, invocation: CommandInvocation, message: Message, try: if settings_path is not None and settings_path.exists(): self.settings = load_bot_local_config(settings_path) - logger.info(f"Reloaded bot settings from {settings_path}") + self.logger.info(f"Reloaded bot settings from {settings_path}") else: - logger.warning("No settings path available for reload; skipping settings reload") + self.logger.warning( + "No settings path available for reload; skipping settings reload" + ) # Reinitialize i18n based on the possibly updated language. await self._init_i18n() - await self.send_reply(message, f"✅ {self.tr('Configuration and translations reloaded.')}") + await self.send_reply( + message, f"✅ {self.tr('Configuration and translations reloaded.')}" + ) except Exception as exc: - logger.warning(f"Reload failed: {exc}") + self.logger.warning(f"Reload failed: {exc}") await self.send_reply( message, f"❌ " + self.tr("Failed to reload configuration: {error}", error=str(exc)), ) - async def _handle_stop(self, invocation: CommandInvocation, message: Message, bot: BaseBot) -> None: + async def _handle_stop( + self, invocation: CommandInvocation, message: Message, bot: BaseBot + ) -> None: """Stop the bot if the caller is authorized.""" requester = message.sender_id # Compute unified permission level (handles bot_owner, org owner/admin) and fallback ACL. - role_levels = (self.settings.role_levels if self.settings else {"user": 1, "admin": 50, "owner": 100, "bot_owner": 200}) - stop_min = role_levels.get("admin", 50) # admins/owners/bot_owner all meet or exceed this + role_levels = ( + self.settings.role_levels + if self.settings + else {"user": 1, "admin": 50, "owner": 100, "bot_owner": 200} + ) + stop_min = role_levels.get( + "admin", 50 + ) # admins/owners/bot_owner all meet or exceed this try: user_level = await self._compute_user_level(requester) except Exception as exc: - logger.warning(f"Stop permission level check failed: {exc}") + self.logger.warning(f"Stop permission level check failed: {exc}") user_level = 0 acl_allowed = False @@ -517,14 +640,18 @@ async def _handle_stop(self, invocation: CommandInvocation, message: Message, bo acl_allowed = False if user_level < stop_min and not acl_allowed: - await self.send_reply(message, self.tr("Permission denied: you cannot stop this bot.")) + await self.send_reply( + message, self.tr("Permission denied: you cannot stop this bot.") + ) return await self.send_reply(message, self.tr("🛑 Stopping the bot...")) if self._runner: self._runner.request_stop(reason=f"requested by user {requester}") else: - logger.warning("Stop requested but runner reference is missing; bot may keep running") + self.logger.warning( + "Stop requested but runner reference is missing; bot may keep running" + ) async def get_user_level(self, user_id: int) -> int: """Public helper for resolving a user's permission level. @@ -536,9 +663,17 @@ async def get_user_level(self, user_id: int) -> int: return await self._compute_user_level(user_id) async def _compute_user_level(self, user_id: int) -> int: - role_levels = (self.settings.role_levels if self.settings else {"user": 1, "admin": 50, "owner": 100, "bot_owner": 200}) + role_levels = ( + self.settings.role_levels + if self.settings + else {"user": 1, "admin": 50, "owner": 100, "bot_owner": 200} + ) level = role_levels.get("user", 1) - if self.settings and self.settings.owner_user_id and user_id == self.settings.owner_user_id: + if ( + self.settings + and self.settings.owner_user_id + and user_id == self.settings.owner_user_id + ): level = max(level, role_levels.get("bot_owner", 200)) try: if self.perms and await self.perms.is_owner(user_id): @@ -555,31 +690,35 @@ async def on_start(self) -> None: async def on_stop(self) -> None: """Hook for cleanup logic. Override if needed.""" - logger.info("Bot stopping, performing cleanup...") + self.logger.info("Bot stopping, performing cleanup...") await self.save_settings() # Dispose ORM engine if it was initialized if self._orm_engine is not None: try: await self._orm_engine.dispose() except Exception as exc: - logger.warning(f"Failed to dispose ORM engine cleanly: {exc}") + self.logger.warning(f"Failed to dispose ORM engine cleanly: {exc}") async def on_event(self, event: Event) -> None: """Main event handler. Default implementation dispatches commands and messages. - + Override to handle other event types or customize behavior. But remember to call super().on_event(event) to retain command/message handling. """ if event.type == "message" and event.message is not None: if event.message.sender_id == self._user_id: - logger.debug("Ignoring message from self") + self.logger.debug("Ignoring message from self") return # Ignore messages from ourselves # Early permission check based on command name only, so that # users without sufficient level get a clear denial even if # their arguments are missing or malformed. try: raw_text = (event.message.content or "").strip() - spec = self.command_parser.find_command_spec(raw_text) if raw_text else None + spec = ( + self.command_parser.find_command_spec(raw_text) + if raw_text + else None + ) except Exception: spec = None @@ -587,7 +726,7 @@ async def on_event(self, event: Event) -> None: try: user_level = await self._compute_user_level(event.message.sender_id) except Exception as exc: - logger.warning(f"Permission pre-check failed: {exc}") + self.logger.warning(f"Permission pre-check failed: {exc}") user_level = 0 if user_level < spec.min_level: # type: ignore[attr-defined] await self.send_reply(event.message, self.tr("Permission denied.")) @@ -595,30 +734,41 @@ async def on_event(self, event: Event) -> None: try: command_invocation = self.parse_command(event.message) except Exception as exc: # CommandError and others - logger.warning(f"Command parsing failed: {exc}") - await self.send_reply(event.message, self.tr("Command error: {error}", error=str(exc))) + self.logger.warning(f"Command parsing failed: {exc}") + await self.send_reply( + event.message, self.tr("Command error: {error}", error=str(exc)) + ) return if command_invocation is not None: try: - logger.debug(f"Dispatching command: {command_invocation.name} with args {command_invocation.args}") + self.logger.debug( + f"Dispatching command: {command_invocation.name} with args {command_invocation.args}" + ) spec = command_invocation.spec if spec and getattr(spec, "min_level", None) is not None: - user_level = await self._compute_user_level(event.message.sender_id) + user_level = await self._compute_user_level( + event.message.sender_id + ) if user_level < spec.min_level: # type: ignore[attr-defined] - await self.send_reply(event.message, self.tr("Permission denied.")) + await self.send_reply( + event.message, self.tr("Permission denied.") + ) return - await self.command_parser.dispatch(command_invocation, message=event.message, bot=self) + await self.command_parser.dispatch( + command_invocation, message=event.message, bot=self + ) except Exception as exc: - logger.warning(f"Command dispatch failed: {exc}") - await self.send_reply(event.message, self.tr("Command error: {error}", error=str(exc))) + self.logger.warning(f"Command dispatch failed: {exc}") + await self.send_reply( + event.message, self.tr("Command error: {error}", error=str(exc)) + ) else: - logger.debug(f"Received message: {event.message}") + self.logger.debug(f"Received message: {event.message}") await self.on_message(event.message) @abc.abstractmethod - async def on_message(self, message: Message) -> None: - ... + async def on_message(self, message: Message) -> None: ... # Legacy hook retained for backwards compatibility; prefer per-command handlers. async def on_command(self, command: CommandInvocation, message: Message) -> None: @@ -626,7 +776,9 @@ async def on_command(self, command: CommandInvocation, message: Message) -> None def parse_command(self, message: Message) -> CommandInvocation | None: if not self.command_parser: - raise RuntimeError("Command parser is not initialized; ensure post_init has run") + raise RuntimeError( + "Command parser is not initialized; ensure post_init has run" + ) return self.command_parser.parse_message(message) async def send_reply(self, original: Message, content: str) -> None: @@ -637,13 +789,17 @@ async def send_reply(self, original: Message, content: str) -> None: # should not happen for private, but tolerate to = [] else: - to = [r.id for r in recipients_raw if getattr(r, "id", None) is not None] + to = [ + r.id for r in recipients_raw if getattr(r, "id", None) is not None + ] payload = PrivateMessageRequest(to=to, content=content) else: topic = original.topic_or_subject or "general" if original.stream_id is None: raise ValueError("stream_id missing on stream message") - payload = StreamMessageRequest(to=original.stream_id, topic=topic, content=content) + payload = StreamMessageRequest( + to=original.stream_id, topic=topic, content=content + ) await self.client.send_message(payload.model_dump(exclude_none=True)) diff --git a/bot_sdk/cli.py b/bot_sdk/cli.py index a3545ec..13c64c1 100644 --- a/bot_sdk/cli.py +++ b/bot_sdk/cli.py @@ -8,19 +8,22 @@ from loguru import logger -from . import BotRunner, setup_logging +from .runner import BotRunner +from .log import setup_logging from .config import AppConfig, load_config from .console import run_console from .db.cli import make_bot_migrations, run_bot_migrations from .loader import BotSpec, discover_bot_factories -async def run_all_bots(bot_specs: Iterable[BotSpec]) -> None: +async def run_all_bots(bot_specs: Iterable[BotSpec], log_level: str = "INFO") -> None: runners = [ BotRunner( spec.factory, client_kwargs={"config_file": spec.zuliprc}, event_types=spec.event_types, + bot_name=spec.name, + log_level=log_level, ) for spec in bot_specs ] @@ -47,13 +50,16 @@ async def run_all_bots(bot_specs: Iterable[BotSpec]) -> None: def _run_bots(config_path: str = "bots.yaml", verbose: bool = False) -> None: - setup_logging("DEBUG" if verbose else "INFO") + log_level = "DEBUG" if verbose else "INFO" + setup_logging(log_level) path_obj: Path = Path(config_path) if not path_obj.exists(): - raise FileNotFoundError(f"{path_obj} not found; please create it to list bots to launch") + raise FileNotFoundError( + f"{path_obj} not found; please create it to list bots to launch" + ) app_config = load_config(str(path_obj), AppConfig) bot_specs = discover_bot_factories(app_config) - asyncio.run(run_all_bots(bot_specs)) + asyncio.run(run_all_bots(bot_specs, log_level=log_level)) def _run_console_mode(config_path: str = "bots.yaml", verbose: bool = False) -> None: @@ -63,7 +69,9 @@ def _run_console_mode(config_path: str = "bots.yaml", verbose: bool = False) -> def _parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Async Zulip bot runner and migration helper") + parser = argparse.ArgumentParser( + description="Async Zulip bot runner and migration helper" + ) parser.add_argument( "command", nargs="?", @@ -72,9 +80,15 @@ def _parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace: help="Command to execute: interactive console (default), run all bots, generate migrations, or apply migrations", ) parser.add_argument("--bot", help="Bot name (for makemigrations)") - parser.add_argument("--message", "-m", help="Migration message (for makemigrations)") - parser.add_argument("--revision", default="head", help="Target revision for migrate (default: head)") - parser.add_argument("--verbose", "-v", action="store_true", help="Enable debug logging output") + parser.add_argument( + "--message", "-m", help="Migration message (for makemigrations)" + ) + parser.add_argument( + "--revision", default="head", help="Target revision for migrate (default: head)" + ) + parser.add_argument( + "--verbose", "-v", action="store_true", help="Enable debug logging output" + ) parser.add_argument( "--config", default="bots.yaml", diff --git a/bot_sdk/commands.py b/bot_sdk/commands.py index db6e258..9fe7d21 100644 --- a/bot_sdk/commands.py +++ b/bot_sdk/commands.py @@ -1,7 +1,17 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Protocol, Sequence +from typing import ( + Any, + Awaitable, + Callable, + Dict, + Iterable, + List, + Optional, + Protocol, + Sequence, +) from .models import Message @@ -27,15 +37,13 @@ def __init__(self, command: str, message: str) -> None: class SupportsSendReply(Protocol): - async def send_reply(self, message: Any, content: str) -> Any: - ... + async def send_reply(self, message: Any, content: str) -> Any: ... class Validator(Protocol): """Callable validator that may coerce or reject a value.""" - def __call__(self, value: Any) -> Any: - ... + def __call__(self, value: Any) -> Any: ... def help_hint(self) -> Optional[str]: # optional, but recommended ... @@ -49,7 +57,9 @@ class OptionValidator: value. """ - def __init__(self, options: Iterable[Any], *, case_insensitive: bool = False) -> None: + def __init__( + self, options: Iterable[Any], *, case_insensitive: bool = False + ) -> None: opts = list(options) if not opts: raise ValueError("options must not be empty") @@ -108,7 +118,9 @@ class CommandSpec: args: List[CommandArgument] = field(default_factory=list) aliases: List[str] = field(default_factory=list) allow_extra: bool = False - handler: Optional[Callable[["CommandInvocation", Any, SupportsSendReply], Awaitable[None] | None]] = None + handler: Optional[ + Callable[["CommandInvocation", Any, SupportsSendReply], Awaitable[None] | None] + ] = None show_in_help: bool = True # Minimum permission level required to execute this command (optional) min_level: Optional[int] = None @@ -159,7 +171,14 @@ def __init__( # order does not affect the final text. description="Show available commands", aliases=["?"], - args=[CommandArgument("command", str, required=False, description="Command name for detailed help")], + args=[ + CommandArgument( + "command", + str, + required=False, + description="Command name for detailed help", + ) + ], handler=self._handle_help, ) ) @@ -207,7 +226,9 @@ def parse_text(self, text: str) -> CommandInvocation: if spec is None: raise UnknownCommandError(f"Unknown command: {command_name}") parsed_args = self._parse_args(tokens[1:], spec) - return CommandInvocation(name=spec.name, args=parsed_args, tokens=tokens, spec=spec) + return CommandInvocation( + name=spec.name, args=parsed_args, tokens=tokens, spec=spec + ) def find_command_spec(self, text: str) -> Optional[CommandSpec]: """Return the CommandSpec for a raw message text, if any. @@ -229,7 +250,9 @@ def find_command_spec(self, text: str) -> Optional[CommandSpec]: command_name = self.alias_index.get(name_token, name_token) return self.specs.get(command_name) - async def dispatch(self, invocation: CommandInvocation, *, message: Any, bot: SupportsSendReply) -> None: + async def dispatch( + self, invocation: CommandInvocation, *, message: Any, bot: SupportsSendReply + ) -> None: handler = invocation.spec.handler if handler is None: raise CommandError(f"No handler registered for command: {invocation.name}") @@ -241,14 +264,14 @@ def _strip_prefix_or_mention(self, text: str) -> Optional[str]: # Fast path: prefix match for prefix in self.prefixes: if text.startswith(prefix): - return text[len(prefix):].strip() + return text[len(prefix) :].strip() # Mention-based trigger if self.enable_mentions and self.mention_aliases: lowered = text.lower() for alias in self.mention_aliases: if lowered.startswith(alias): - return text[len(alias):].lstrip(" :,-") + return text[len(alias) :].lstrip(" :,-") return None def _parse_args(self, args_tokens: List[str], spec: CommandSpec) -> Dict[str, Any]: @@ -257,14 +280,18 @@ def _parse_args(self, args_tokens: List[str], spec: CommandSpec) -> Dict[str, An for arg_spec in spec.args: if arg_spec.multiple: remaining = args_tokens[idx:] - parsed[arg_spec.name] = [self._convert_value(token, arg_spec) for token in remaining] + parsed[arg_spec.name] = [ + self._convert_value(token, arg_spec) for token in remaining + ] idx = len(args_tokens) break if idx >= len(args_tokens): if arg_spec.required: usage = self._format_usage(spec) - msg = self._tr("Missing argument: {name}").format(name=arg_spec.name) + msg = self._tr("Missing argument: {name}").format( + name=arg_spec.name + ) usage_line = self._tr("Usage: {usage}").format(usage=usage) raise InvalidArgumentsError(spec.name, f"{msg}\n{usage_line}") parsed[arg_spec.name] = None @@ -331,7 +358,11 @@ def generate_help(self, *, user_level: Optional[int] = None) -> str: continue # If a user level is provided and the command requires a higher # level, hide it from the help output to keep things simple. - if user_level is not None and spec.min_level is not None and spec.min_level > user_level: + if ( + user_level is not None + and spec.min_level is not None + and spec.min_level > user_level + ): continue summary = self._format_usage(spec) if spec.description: @@ -339,7 +370,9 @@ def generate_help(self, *, user_level: Optional[int] = None) -> str: lines.append(summary) return "\n".join(lines) if lines else "No commands registered." - async def _handle_help(self, invocation: CommandInvocation, message: Any, bot: SupportsSendReply) -> None: + async def _handle_help( + self, invocation: CommandInvocation, message: Any, bot: SupportsSendReply + ) -> None: # Default help handler: reply with generated help text. # Try to obtain the caller's permission level if the bot exposes it. user_level: Optional[int] = None @@ -368,19 +401,33 @@ async def _handle_help(self, invocation: CommandInvocation, message: Any, bot: S # Try to use bot-level i18n if available. tr = getattr(bot, "tr", None) if callable(tr): - await bot.send_reply(message, tr("Unknown command: {name}", name=str(target))) + await bot.send_reply( + message, tr("Unknown command: {name}", name=str(target)) + ) else: await bot.send_reply(message, f"Unknown command: {target}") return # If we know the user's level and the command requires a higher level, # do not reveal full details. - if user_level is not None and spec.min_level is not None and user_level < spec.min_level: + if ( + user_level is not None + and spec.min_level is not None + and user_level < spec.min_level + ): tr = getattr(bot, "tr", None) if callable(tr): - await bot.send_reply(message, tr("You do not have permission to use command: {name}", name=spec.name)) + await bot.send_reply( + message, + tr( + "You do not have permission to use command: {name}", + name=spec.name, + ), + ) else: - await bot.send_reply(message, f"You do not have permission to use command: {spec.name}") + await bot.send_reply( + message, f"You do not have permission to use command: {spec.name}" + ) return detail = self._format_spec_detail(spec) @@ -412,7 +459,9 @@ def _format_spec_detail(self, spec: CommandSpec) -> str: if spec.args: lines.append(self._tr("Args:")) for arg in spec.args: - requirement = self._tr("required") if arg.required else self._tr("optional") + requirement = ( + self._tr("required") if arg.required else self._tr("optional") + ) multi = f" ({self._tr('multiple')})" if arg.multiple else "" desc = f" - {arg.name}: {requirement}{multi}" validator_hint = self._format_validator_hint(arg.validator) diff --git a/bot_sdk/config.py b/bot_sdk/config.py index 851a29a..57d0952 100644 --- a/bot_sdk/config.py +++ b/bot_sdk/config.py @@ -43,6 +43,7 @@ def load_config(path: str | Path, model: type[BaseModel]) -> BaseModel: data = yaml.load(f) or {} return model.model_validate(data) + def save_config(path: str | Path, config: BaseModel) -> None: yaml = YAML() yaml.default_flow_style = False diff --git a/bot_sdk/console.py b/bot_sdk/console.py index 239b526..a9bd4ca 100644 --- a/bot_sdk/console.py +++ b/bot_sdk/console.py @@ -11,6 +11,7 @@ from .config import AppConfig, load_config from .loader import BotSpec, discover_bot_factories +from .log import DEFAULT_EXTRA, FILE_FORMAT, LOG_FOLDER, _coerce_compression from .runner import BotRunner from .db.cli import make_bot_migrations, run_bot_migrations @@ -49,20 +50,6 @@ ] -COMMAND_LIST = [ - "run", - "stop", - "reload", - "status", - "bots", - "help", - "exit", - "quit", - "makemigrations", - "migrate", -] - - @dataclass class ManagedBot: spec: BotSpec @@ -71,10 +58,11 @@ class ManagedBot: class BotManager: - def __init__(self, specs: Iterable[BotSpec]) -> None: + def __init__(self, specs: Iterable[BotSpec], log_level: str = "INFO") -> None: self._specs = {spec.name: spec for spec in specs} self._running: dict[str, ManagedBot] = {} self._lock = asyncio.Lock() + self._log_level = log_level @property def available_bots(self) -> list[str]: @@ -92,7 +80,6 @@ async def start_all(self) -> list[str]: results = await asyncio.gather(*tasks) return results - async def start_bot(self, name: str) -> str: async with self._lock: if name in self._running: @@ -105,6 +92,8 @@ async def start_bot(self, name: str) -> str: spec.factory, client_kwargs={"config_file": spec.zuliprc}, event_types=spec.event_types, + bot_name=spec.name, + log_level=self._log_level, ) managed = ManagedBot(spec=spec, runner=runner) task = asyncio.create_task(self._run_runner(name, managed)) @@ -176,7 +165,9 @@ class is only responsible for rendering the static chrome so that Live can refresh it without touching the editing line. """ - def __init__(self, manager: BotManager, log_buffer: LogBuffer, console: Console) -> None: + def __init__( + self, manager: BotManager, log_buffer: LogBuffer, console: Console + ) -> None: self.manager = manager self.log_buffer = log_buffer self.console = console @@ -196,39 +187,51 @@ def render(self, command_buffer: str = ""): # Calculate visible log lines based on console height # Approx height available for logs = total - status(5) - prompt(2) - borders(~7) log_height = max(8, self.console.height - 9) - + lines = self.log_buffer.lines total_lines = len(lines) - + # Calculate slice if self.scroll_offset > total_lines - log_height: - self.scroll_offset = max(0, total_lines - log_height) - + self.scroll_offset = max(0, total_lines - log_height) + start_idx = max(0, total_lines - log_height - self.scroll_offset) end_idx = total_lines - self.scroll_offset if self.scroll_offset > 0 else None - + visible_chunk = lines[start_idx:end_idx] log_content = Text.from_ansi("\n".join(visible_chunk)) - + title = f"Logs ({self.scroll_offset})" if self.scroll_offset > 0 else "Logs" - layout["logs"].update(Panel(log_content, title=title, border_style="cyan", padding=(0, 1))) + layout["logs"].update( + Panel(log_content, title=title, border_style="cyan", padding=(0, 1)) + ) status_table = Table.grid(expand=True, padding=(0, 10)) status_table.add_column(justify="left", no_wrap=True, style="bold") status_table.add_column(justify="left", ratio=1, no_wrap=False) - status_table.add_row("Running", ", ".join(self.manager.running_bots) or "none", style="green") - status_table.add_row("Available", ", ".join(self.manager.available_bots) or "none", style="cyan") - help_text = _command_help_suggestions(command_buffer, self.manager.available_bots) + status_table.add_row( + "Running", ", ".join(self.manager.running_bots) or "none", style="green" + ) + status_table.add_row( + "Available", ", ".join(self.manager.available_bots) or "none", style="cyan" + ) + help_text = _command_help_suggestions( + command_buffer, self.manager.available_bots + ) help_state = _command_help_state(command_buffer, self.manager.available_bots) help_cell = _render_help_cell(help_text, help_state) status_table.add_row("Command Help", help_cell, style="magenta") - layout["status"].update(Panel(status_table, title="Status", border_style="magenta", padding=(0, 1))) + layout["status"].update( + Panel(status_table, title="Status", border_style="magenta", padding=(0, 1)) + ) # Add cursor effect prompt_content = Text("bot-console> ", style="bold yellow") prompt_content.append(command_buffer) prompt_content.append("█", style="blink") - layout["prompt"].update(Panel(prompt_content, title="Command", border_style="green", padding=(0, 1))) + layout["prompt"].update( + Panel(prompt_content, title="Command", border_style="green", padding=(0, 1)) + ) return layout @@ -421,7 +424,9 @@ async def _handle_command( _print_help(output_func, manager) return False if cmd == "bots": - output_func(f"Configured bots: {', '.join(manager.available_bots) if manager.available_bots else 'none'}") + output_func( + f"Configured bots: {', '.join(manager.available_bots) if manager.available_bots else 'none'}" + ) return False if cmd == "status": running = manager.running_bots @@ -479,7 +484,7 @@ async def _handle_command( # Stop bot if running to release file locks (important for migrations/reloads) if bot_name in manager.running_bots: await manager.stop_bot(bot_name, reason="reload") - + result = await manager.reload_bot(spec) output_func(result) return False @@ -517,7 +522,9 @@ async def _handle_command( return False -async def _load_spec(bot_name: str, config_path: Path, bots_dir: str, reload_modules: bool) -> Optional[BotSpec]: +async def _load_spec( + bot_name: str, config_path: Path, bots_dir: str, reload_modules: bool +) -> Optional[BotSpec]: app_config = load_config(str(config_path), AppConfig) specs = discover_bot_factories(app_config, bots_dir, reload_modules=reload_modules) for spec in specs: @@ -526,14 +533,18 @@ async def _load_spec(bot_name: str, config_path: Path, bots_dir: str, reload_mod return None -async def run_console(config_path: str = "bots.yaml", bots_dir: str = "bots", log_level: str = "INFO") -> None: +async def run_console( + config_path: str = "bots.yaml", bots_dir: str = "bots", log_level: str = "INFO" +) -> None: cfg_path = Path(config_path) if not cfg_path.exists(): - raise FileNotFoundError("bots.yaml not found; please create it to list bots to launch") + raise FileNotFoundError( + "bots.yaml not found; please create it to list bots to launch" + ) app_config = load_config(str(cfg_path), AppConfig) specs = discover_bot_factories(app_config, bots_dir) - manager: BotManager = BotManager(specs) + manager: BotManager = BotManager(specs, log_level=log_level) # Windows: keep Rich-based full-screen console with msvcrt key handling. use_rich_windows = _RICH_AVAILABLE and sys.platform.startswith("win") @@ -551,11 +562,39 @@ async def run_console(config_path: str = "bots.yaml", bots_dir: str = "bots", lo def log_sink(message: str) -> None: log_buffer.append(message) + # 在 Rich 控制台模式下,移除 stdout sink,保留文件 sink,并改为把日志写入 Rich 日志面板 logger.remove() - # Restore styling and ensure ANSI codes are preserved for Text.from_ansi + logger.configure(extra=DEFAULT_EXTRA) + + # 重新添加 system.log 文件 sink(与 setup_logging 一致,但不再写 stdout) + system_logger = logger.bind(bot_name="SYSTEM") + system_filter = lambda record: ( + record.get("extra", {}).get("bot_name") == "SYSTEM" + ) + system_logger.add( + LOG_FOLDER / "system.log", + level=log_level, + format=FILE_FORMAT, + enqueue=True, + rotation="12:00", + retention="1 week", + compression=_coerce_compression(True), + colorize=False, + filter=system_filter, + ) + + # 额外添加一个 Rich UI sink:所有级别的日志都会进 Logs 面板 timefmt = "%Y-%m-%d %H:%M:%S" - fmt = "{time:"+ timefmt +"} |[{level}]| {name} | line: {line} | {message}" - logger.add(log_sink, format=fmt, level=log_level, enqueue=False, colorize=True) + fmt = ( + "{extra[bot_name]} | " + "{time:" + timefmt + "} | " + "[{level}] | " + "{name}:{line} | " + "{message}" + ) + logger.add( + log_sink, format=fmt, level=log_level, enqueue=True, colorize=True + ) def output_to_logs(msg: str) -> None: logger.info(msg) @@ -594,7 +633,9 @@ def output_to_logs(msg: str) -> None: command_history.insert(0, cmd) live.update(ui.render(command_buffer), refresh=True) logger.info(f"> {cmd}") # Echo command to logs - should_exit = await _handle_command(cmd, manager, cfg_path, bots_dir, output_to_logs) + should_exit = await _handle_command( + cmd, manager, cfg_path, bots_dir, output_to_logs + ) if should_exit: await manager.stop_all() return @@ -610,22 +651,30 @@ def output_to_logs(msg: str) -> None: elif ch in {b"\xe0", b"\x00"}: # Arrow keys prefix sc = msvcrt.getch() if sc == b"H": # Up - if history_idx < len(command_history): - if history_idx == 0: - # (Optional) could save current draft - pass - command_buffer = command_history[history_idx] - history_idx += 1 + if not command_buffer and history_idx == 0: + ui.scroll_offset += 5 + else: + if history_idx < len(command_history): + if history_idx == 0: + # (Optional) could save current draft + pass + command_buffer = command_history[history_idx] + history_idx += 1 elif sc == b"P": # Down - if history_idx > 0: - history_idx -= 1 - if history_idx == 0: - command_buffer = "" - else: - command_buffer = command_history[history_idx - 1] + if not command_buffer and history_idx == 0: + ui.scroll_offset = max(0, ui.scroll_offset - 5) else: - command_buffer = "" - history_idx = 0 + if history_idx > 0: + history_idx -= 1 + if history_idx == 0: + command_buffer = "" + else: + command_buffer = command_history[ + history_idx - 1 + ] + else: + command_buffer = "" + history_idx = 0 elif sc == b"I": # Page Up ui.scroll_offset += 5 elif sc == b"Q": # Page Down diff --git a/bot_sdk/db/cli.py b/bot_sdk/db/cli.py index c19d296..4d59123 100644 --- a/bot_sdk/db/cli.py +++ b/bot_sdk/db/cli.py @@ -21,16 +21,24 @@ def _ensure_migration_templates(migrations_dir: Path) -> None: target_env = migrations_dir / "env.py" if not target_env.exists(): if not template_env.exists(): - raise FileNotFoundError(f"Alembic template env.py not found at {template_env}") - target_env.write_text(template_env.read_text(encoding="utf-8"), encoding="utf-8") + raise FileNotFoundError( + f"Alembic template env.py not found at {template_env}" + ) + target_env.write_text( + template_env.read_text(encoding="utf-8"), encoding="utf-8" + ) template_script = Path("bot_sdk") / "db" / "migrations" / "script.py.mako" target_script = migrations_dir / "script.py.mako" if not target_script.exists() and template_script.exists(): - target_script.write_text(template_script.read_text(encoding="utf-8"), encoding="utf-8") + target_script.write_text( + template_script.read_text(encoding="utf-8"), encoding="utf-8" + ) -def ensure_bot_orm_enabled(bot_name: str, bots_dir: str = "bots") -> Optional[type[BaseBot]]: +def ensure_bot_orm_enabled( + bot_name: str, bots_dir: str = "bots" +) -> Optional[type[BaseBot]]: """Validate that the target bot exists and has ORM enabled. Returns the resolved BaseBot subclass when available so callers can @@ -43,11 +51,16 @@ def ensure_bot_orm_enabled(bot_name: str, bots_dir: str = "bots") -> Optional[ty raise FileNotFoundError("bots.yaml not found; cannot resolve bot configuration") app_config = load_config(str(config_path), AppConfig) - bot_cfg = next((b for b in app_config.bots if b.name == bot_name and b.enabled), None) + bot_cfg = next( + (b for b in app_config.bots if b.name == bot_name and b.enabled), None + ) if bot_cfg is None: raise SystemExit(f"Bot '{bot_name}' not found or disabled in bots.yaml") - module_name = bot_cfg.module or f"{bots_dir.replace('/', '.').replace('\\', '.')}" + f".{bot_cfg.name}" + module_name = ( + bot_cfg.module + or f"{bots_dir.replace('/', '.').replace('\\', '.')}" + f".{bot_cfg.name}" + ) module = importlib.import_module(module_name) bot_type: Optional[type[BaseBot]] = None @@ -110,7 +123,7 @@ def make_bot_migrations(bot_name: str, message: Optional[str] = None) -> None: script_location to ``bots//migrations`` so that each bot keeps its own migration history. """ - + # Ensure setup_logging logic is handled by caller (main or console) or defaults exist. # We won't call setup_logging here to avoid overriding caller's config. @@ -168,7 +181,9 @@ def run_bot_migrations(bot_name: str, revision: str = "head") -> None: migrations_dir = bot_dir / "migrations" if not migrations_dir.exists(): - raise FileNotFoundError(f"Migrations directory not found for bot {bot_name}: {migrations_dir}") + raise FileNotFoundError( + f"Migrations directory not found for bot {bot_name}: {migrations_dir}" + ) # Ensure env.py and script.py.mako exist (required by Alembic script environment) _ensure_migration_templates(migrations_dir) @@ -183,5 +198,7 @@ def run_bot_migrations(bot_name: str, revision: str = "head") -> None: cfg = Config("alembic.ini") cfg.set_main_option("script_location", migrations_dir.as_posix()) - logger.info(f"Applying Alembic migrations for bot '{bot_name}' to revision '{revision}'") + logger.info( + f"Applying Alembic migrations for bot '{bot_name}' to revision '{revision}'" + ) command.upgrade(cfg, revision) diff --git a/bot_sdk/db/database.py b/bot_sdk/db/database.py index bfb9b81..8687e03 100644 --- a/bot_sdk/db/database.py +++ b/bot_sdk/db/database.py @@ -4,7 +4,12 @@ from pathlib import Path from typing import AsyncIterator -from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) from sqlalchemy.orm import DeclarativeBase __all__ = [ @@ -40,7 +45,9 @@ def create_sessionmaker(engine: AsyncEngine) -> async_sessionmaker[AsyncSession] @asynccontextmanager -async def session_scope(session_factory: async_sessionmaker[AsyncSession]) -> AsyncIterator[AsyncSession]: +async def session_scope( + session_factory: async_sessionmaker[AsyncSession], +) -> AsyncIterator[AsyncSession]: """Async context manager that yields a session and commits/rolls back safely.""" session = session_factory() try: diff --git a/bot_sdk/db/migrations/versions/0001_initial.py b/bot_sdk/db/migrations/versions/0001_initial.py index cd78ffe..230510e 100644 --- a/bot_sdk/db/migrations/versions/0001_initial.py +++ b/bot_sdk/db/migrations/versions/0001_initial.py @@ -1,7 +1,7 @@ """Initial empty migration placeholder. Revision ID: 0001_initial -Revises: +Revises: Create Date: 2026-01-10 """ diff --git a/bot_sdk/i18n.py b/bot_sdk/i18n.py index 5025aa4..f470f2a 100644 --- a/bot_sdk/i18n.py +++ b/bot_sdk/i18n.py @@ -39,7 +39,9 @@ def _load_from_paths(self, paths: Sequence[Path]) -> None: continue primary.update(_load_language_file(base_dir, self.language)) if self.language != self.default_language: - fallback.update(_load_language_file(base_dir, self.default_language)) + fallback.update( + _load_language_file(base_dir, self.default_language) + ) except Exception as exc: # pragma: no cover - defensive logging only logger.warning(f"Failed to load i18n files from {base}: {exc}") self._translations = primary @@ -88,7 +90,9 @@ def build_i18n_for_bot(language: str, bot_module_name: str) -> I18n: bot_dir = Path(mod_file).parent search_paths.append(bot_dir / "i18n") except Exception: # pragma: no cover - fall back to SDK-only i18n - logger.debug(f"Could not resolve module path for {bot_module_name}; using SDK i18n only") + logger.debug( + f"Could not resolve module path for {bot_module_name}; using SDK i18n only" + ) # SDK-level i18n directory. sdk_dir = Path(__file__).resolve().parent diff --git a/bot_sdk/loader.py b/bot_sdk/loader.py index 2786ec8..564325a 100644 --- a/bot_sdk/loader.py +++ b/bot_sdk/loader.py @@ -16,7 +16,7 @@ @dataclass class BotSpec: name: str - factory: Callable[[Any], BaseBot] + factory: Callable[[Any, Any], BaseBot] zuliprc: str event_types: List[str] storage: Optional[StorageConfig] @@ -46,7 +46,10 @@ def discover_bot_factories( for bot_cfg in config.bots: if not bot_cfg.enabled: continue - module_name = bot_cfg.module or f"{bots_dir.replace('/', '.').replace('\\', '.')}" + f".{bot_cfg.name}" + module_name = ( + bot_cfg.module + or f"{bots_dir.replace('/', '.').replace('\\', '.')}" + f".{bot_cfg.name}" + ) try: if reload_modules and module_name in sys.modules: module = importlib.reload(sys.modules[module_name]) @@ -61,7 +64,9 @@ def discover_bot_factories( raise RuntimeError(f"No bot factory/class found in module {module_name}") zuliprc_path = Path(bot_cfg.zuliprc or base_path / bot_cfg.name / "zuliprc") if not zuliprc_path.exists(): - raise FileNotFoundError(f"zuliprc not found for bot {bot_cfg.name}: {zuliprc_path}") + raise FileNotFoundError( + f"zuliprc not found for bot {bot_cfg.name}: {zuliprc_path}" + ) specs.append( BotSpec( name=bot_cfg.name, @@ -74,17 +79,36 @@ def discover_bot_factories( return specs -def _bind_factory(factory: Callable[..., BaseBot], bot_config: dict[str, Any]) -> Callable[[Any], BaseBot]: - def wrapper(client: Any) -> BaseBot: - try: - return factory(client, bot_config) - except TypeError: - return factory(client) +def _bind_factory( + factory: Callable[..., BaseBot], bot_config: dict[str, Any] +) -> Callable[[Any, Any], BaseBot]: + def wrapper(client: Any, logger: Any) -> BaseBot: + # Try common signatures in order while avoiding swallowing unrelated TypeErrors. + attempts = [ + lambda: factory(client, bot_config, logger), + lambda: factory(client, logger), + lambda: factory(client, bot_config), + lambda: factory(client), + ] + + last_exc: Optional[TypeError] = None + for attempt in attempts: + try: + return attempt() + except TypeError as exc: + # Swallow signature mismatches and try the next shape. + last_exc = exc + continue + # If all attempts failed, surface the last TypeError for debugging. + assert last_exc is not None + raise last_exc return wrapper -def _extract_factory(module, class_name: Optional[str] = None) -> Optional[Callable[[Any], BaseBot]]: +def _extract_factory( + module, class_name: Optional[str] = None +) -> Optional[Callable[[Any], BaseBot]]: if callable(getattr(module, "create_bot", None)): return module.create_bot if class_name: diff --git a/bot_sdk/log.py b/bot_sdk/log.py new file mode 100644 index 0000000..dbe04b6 --- /dev/null +++ b/bot_sdk/log.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +from loguru import logger +from loguru._logger import Logger + +TIME_FORMAT = "%Y-%m-%d %H:%M:%S" +LOG_FOLDER = Path(__file__).resolve().parent.parent / "logs" +DEFAULT_EXTRA = {"bot_name": "SYSTEM"} + +CONSOLE_FORMAT = ( + "{extra[bot_name]} | " + "{time:" + TIME_FORMAT + "} | " + "[{level}] | " + "{name}:{line} | " + "{message}" +) + +FILE_FORMAT = ( + "{extra[bot_name]} | " + "{time:" + TIME_FORMAT + "} | " + "[{level}] | " + "{name}:{line} | " + "{message}" +) + + +def _json_formatter(record: dict) -> str: + payload = { + "bot": record["extra"].get("bot_name", "UNKNOWN"), + "level": record["level"].name, + "time": record["time"].strftime(TIME_FORMAT), + "name": record["name"], + "line": record["line"], + "message": record.get("message", ""), + "extra": record.get("extra", {}), + } + return json.dumps(payload, ensure_ascii=True) + + +def _ensure_log_dir() -> None: + LOG_FOLDER.mkdir(parents=True, exist_ok=True) + + +def _coerce_compression(compression: bool | str | None) -> str | None: + if compression is True: + return "zip" + if compression is False: + return None + return compression + + +def setup_logging( + level: str = "INFO", + json_logs: bool = False, + backtrace: bool = False, + rotation: str | None = "12:00", + retention: str | None = "1 week", + compression: bool | str | None = True, +) -> None: + """Configure loguru sinks for system logs. + + System logs go to stdout and to ``logs/system.log`` with a SYSTEM tag. + """ + + _ensure_log_dir() + level = level.upper() + logger.remove() + logger.configure(extra=DEFAULT_EXTRA) + + format_str = CONSOLE_FORMAT + colorize = True + file_format = FILE_FORMAT + if json_logs: + format_str = _json_formatter + file_format = _json_formatter + colorize = False + + system_logger = logger.bind(bot_name="SYSTEM") + system_filter = lambda record: record.get("extra", {}).get("bot_name") == "SYSTEM" + + system_logger.add( + sys.stdout, + level=level, + format=format_str, + backtrace=backtrace, + diagnose=False, + enqueue=True, + colorize=colorize, + ) + system_logger.add( + LOG_FOLDER / "system.log", + level=level, + format=file_format, + enqueue=True, + rotation=rotation, + retention=retention, + compression=_coerce_compression(compression), + colorize=False, + filter=system_filter, + ) + + +def get_bot_logger( + bot_name: str | None = None, + level: str = "INFO", + rotation: str | None = "12:00", + retention: str | None = "1 week", + compression: bool | str | None = True, + backtrace: bool = False, +) -> Logger: + """Return a logger bound to a specific bot name. + + Adds a per-bot file sink under ``logs/.log`` and prefixes all + messages with the bot tag. Console output is handled by ``setup_logging``. + """ + + _ensure_log_dir() + level = level.upper() + bot_label = bot_name or "UNKNOWN_BOT" + filename = f"{bot_label}.log" if bot_name else "bots.log" + + bot_logger = logger.bind(bot_name=bot_label) + bot_filter = lambda record: record.get("extra", {}).get("bot_name") == bot_label + bot_logger.add( + LOG_FOLDER / filename, + level=level, + format=FILE_FORMAT, + enqueue=True, + rotation=rotation, + retention=retention, + compression=_coerce_compression(compression), + backtrace=backtrace, + colorize=False, + filter=bot_filter, + ) + return bot_logger + + +__all__ = ["setup_logging", "logger", "get_bot_logger", "Logger"] diff --git a/bot_sdk/logging.py b/bot_sdk/logging.py deleted file mode 100644 index 1904285..0000000 --- a/bot_sdk/logging.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations - -import sys - -from loguru import logger - - -def setup_logging(level: str = "INFO", json_logs: bool = False, backtrace: bool = False) -> None: - """Configure loguru sinks for stdout. - - Called once at process start. Level accepts standard loguru strings (INFO, DEBUG...). - """ - - logger.remove() - timefmt = "%Y-%m-%d %H:%M:%S" - fmt = "{time:"+ timefmt +"} |[{level}]| {name} | line: {line} | {message}" - if json_logs: - fmt = "{level}\t{time}\t{message}\t{extra}" - logger.add(sys.stdout, level=level.upper(), format=fmt, backtrace=backtrace, diagnose=False) - - -__all__ = ["setup_logging", "logger"] diff --git a/bot_sdk/models/api/request.py b/bot_sdk/models/api/request.py index 59ed265..133e19d 100644 --- a/bot_sdk/models/api/request.py +++ b/bot_sdk/models/api/request.py @@ -21,8 +21,8 @@ class PrivateMessageRequest(BaseModel): content: str model_config = ConfigDict(extra="allow") - - + + class UpdatePresenceRequest(BaseModel): status: Literal["active", "idle"] new_user_input: Optional[bool] = None @@ -39,4 +39,9 @@ class GetUserGroupsRequest(BaseModel): model_config = ConfigDict(extra="allow") -__all__ = ["StreamMessageRequest", "PrivateMessageRequest", "UpdatePresenceRequest", "GetUserGroupsRequest"] +__all__ = [ + "StreamMessageRequest", + "PrivateMessageRequest", + "UpdatePresenceRequest", + "GetUserGroupsRequest", +] diff --git a/bot_sdk/models/api/types.py b/bot_sdk/models/api/types.py index 5f2d9e9..b901b9a 100644 --- a/bot_sdk/models/api/types.py +++ b/bot_sdk/models/api/types.py @@ -73,6 +73,7 @@ class User(BaseModel): model_config = ConfigDict(extra="allow") + class Channel(BaseModel): stream_id: int name: str @@ -95,6 +96,7 @@ class Channel(BaseModel): class GroupSettingValue(BaseModel): """A group-setting value that can be either a group ID or an object with direct members/subgroups.""" + direct_members: Optional[List[int]] = None direct_subgroups: Optional[List[int]] = None @@ -103,6 +105,7 @@ class GroupSettingValue(BaseModel): class UserGroup(BaseModel): """Represents a user group in Zulip organization.""" + id: int name: str description: str @@ -122,4 +125,13 @@ class UserGroup(BaseModel): model_config = ConfigDict(extra="allow") -__all__ = ["Message", "Event", "PrivateRecipient", "ProfileFieldValue", "User", "Channel", "GroupSettingValue", "UserGroup"] +__all__ = [ + "Message", + "Event", + "PrivateRecipient", + "ProfileFieldValue", + "User", + "Channel", + "GroupSettingValue", + "UserGroup", +] diff --git a/bot_sdk/permissions.py b/bot_sdk/permissions.py index 2181e74..4184241 100644 --- a/bot_sdk/permissions.py +++ b/bot_sdk/permissions.py @@ -41,7 +41,9 @@ async def _load_groups(self) -> List[UserGroup]: cache: Optional[Dict[str, Any]] = await self.storage.get("__user_groups__") if cache and (time.time() - float(cache.get("ts", 0))) < self._cache_ttl: try: - groups = [UserGroup.model_validate(g) for g in cache.get("groups", [])] + groups = [ + UserGroup.model_validate(g) for g in cache.get("groups", []) + ] return groups except Exception: pass @@ -57,7 +59,10 @@ async def _load_groups(self) -> List[UserGroup]: if self.storage: await self.storage.put( "__user_groups__", - {"ts": time.time(), "groups": [g.model_dump(exclude_none=True) for g in groups]}, + { + "ts": time.time(), + "groups": [g.model_dump(exclude_none=True) for g in groups], + }, ) return groups diff --git a/bot_sdk/runner.py b/bot_sdk/runner.py index 592d48c..4248cdc 100644 --- a/bot_sdk/runner.py +++ b/bot_sdk/runner.py @@ -5,7 +5,7 @@ from .async_zulip import AsyncClient from .bot import BaseBot -from .logging import logger +from .log import Logger, get_bot_logger, logger class BotRunner: @@ -13,17 +13,21 @@ class BotRunner: def __init__( self, - bot_factory: Callable[[AsyncClient], BaseBot], + bot_factory: Callable[[AsyncClient, Logger], BaseBot], *, event_types: Optional[List[str]] = None, narrow: Optional[List[List[str]]] = None, client_kwargs: Optional[Dict[str, Any]] = None, max_concurrency: int = 8, + bot_name: Optional[str] = None, + log_level: str = "INFO", ) -> None: self.bot_factory = bot_factory self.event_types = event_types or ["message"] self.narrow = narrow or [] self.client_kwargs = client_kwargs or {} + self.bot_name = bot_name or "UNKNOWN_BOT" + self._log_level = log_level self.client: Optional[AsyncClient] = None self.bot: Optional[BaseBot] = None self._semaphore = asyncio.Semaphore(max_concurrency) @@ -31,6 +35,7 @@ def __init__( self._max_concurrency = max_concurrency self._stop_event = asyncio.Event() self._longpoll_task: Optional[asyncio.Task[None]] = None + self._bot_logger: Logger = logger.bind(bot_name=self.bot_name) async def __aenter__(self) -> "BotRunner": await self.start() @@ -40,13 +45,17 @@ async def __aexit__(self, exc_type, exc, tb) -> None: await self.stop() async def start(self) -> None: + self._bot_logger = get_bot_logger(self.bot_name, level=self._log_level) + self.client = AsyncClient(**self.client_kwargs) - self.bot = self.bot_factory(self.client) + self.bot = self.bot_factory(self.client, self._bot_logger) # Give the bot a back-reference so commands can trigger runner-level actions (e.g., stop). if hasattr(self.bot, "set_runner"): self.bot.set_runner(self) await self.bot.post_init() - logger.info("Bot started with event types {}", self.event_types) + self._bot_logger.info( + "Bot '{}' started with event types {}", self.bot_name, self.event_types + ) await self.client.ensure_session() await self.bot.on_start() @@ -67,7 +76,7 @@ async def stop(self) -> None: await self.bot.on_stop() if self.client: await self.client.aclose() - logger.info("Bot stopped") + self._bot_logger.info("Bot '{}' stopped", self.bot_name) async def run_forever(self) -> None: if not self.client or not self.bot: @@ -94,24 +103,33 @@ def _cleanup(t: asyncio.Task[None]) -> None: return exc = t.exception() if exc: - logger.exception("Unhandled error in bot event task: {}", exc) + self._bot_logger.exception( + "Unhandled error in bot event task: {}", exc + ) task.add_done_callback(_cleanup) - logger.info( + self._bot_logger.info( "Starting event loop with max_concurrency={} and event_types={}", self._max_concurrency, self.event_types, ) self._longpoll_task = asyncio.create_task( - self.client.call_on_each_event(_handle_event, self.event_types, self.narrow, stop_event=self._stop_event) + self.client.call_on_each_event( + _handle_event, + self.event_types, + self.narrow, + stop_event=self._stop_event, + ) ) stop_waiter = asyncio.create_task(self._stop_event.wait()) try: - done, pending = await asyncio.wait({self._longpoll_task, stop_waiter}, return_when=asyncio.FIRST_COMPLETED) + done, pending = await asyncio.wait( + {self._longpoll_task, stop_waiter}, return_when=asyncio.FIRST_COMPLETED + ) if stop_waiter in done: - logger.info("Stop requested; cancelling event stream") + self._bot_logger.info("Stop requested; cancelling event stream") if self._longpoll_task: self._longpoll_task.cancel() elif self._longpoll_task in done: @@ -119,7 +137,9 @@ def _cleanup(t: asyncio.Task[None]) -> None: exc = self._longpoll_task.exception() if self._longpoll_task else None if exc: raise exc - logger.warning("Event stream completed unexpectedly; shutting down") + self._bot_logger.warning( + "Event stream completed unexpectedly; shutting down" + ) self._stop_event.set() finally: if self._longpoll_task: @@ -133,7 +153,7 @@ def request_stop(self, *, reason: Optional[str] = None) -> None: if self._stop_event.is_set(): return if reason: - logger.info("Stop requested: {}", reason) + self._bot_logger.info("Stop requested: {}", reason) self._stop_event.set() if self._longpoll_task: self._longpoll_task.cancel() @@ -148,7 +168,13 @@ def run_bot( ) -> None: """Convenience entrypoint.""" - runner = BotRunner(lambda c: bot_cls(c), event_types=event_types, narrow=narrow, client_kwargs=client_kwargs) + runner = BotRunner( + lambda c, log: bot_cls(c, log), + event_types=event_types, + narrow=narrow, + client_kwargs=client_kwargs, + bot_name=bot_cls.__name__, + ) async def _run() -> None: async with runner: diff --git a/bot_sdk/storage.py b/bot_sdk/storage.py index f2654e8..267a5ec 100644 --- a/bot_sdk/storage.py +++ b/bot_sdk/storage.py @@ -26,7 +26,7 @@ class BotStorage: """ Persistent key-value storage backed by SQLite. - + Provides dictionary-like interface for bot state persistence. """ @@ -42,7 +42,7 @@ def __init__( ) -> None: """ Initialize bot storage. - + Args: db_path: Path to SQLite database file namespace: Storage namespace to isolate different bots/contexts @@ -53,7 +53,9 @@ def __init__( self._marshal = json.dumps self._demarshal = json.loads self._auto_cache = ( - _AutoCache(self, auto_flush_interval, auto_flush_retry, auto_flush_max_retries) + _AutoCache( + self, auto_flush_interval, auto_flush_retry, auto_flush_max_retries + ) if auto_cache else None ) @@ -88,7 +90,9 @@ async def _init_db(self) -> None: await db.close() self._initialized = True - logger.debug(f"Initialized storage at {self.db_path} with namespace '{self.namespace}'") + logger.debug( + f"Initialized storage at {self.db_path} with namespace '{self.namespace}'" + ) async def _connect(self) -> aiosqlite.Connection: """Open a connection with SQLite pragmas tuned for concurrency (WAL).""" @@ -101,7 +105,7 @@ async def _connect(self) -> aiosqlite.Connection: async def put(self, key: str, value: Any) -> None: """ Store a value for the given key. - + Args: key: Storage key (string) value: Any JSON-serializable value @@ -117,11 +121,11 @@ async def put(self, key: str, value: Any) -> None: async def get(self, key: str, default: Any = None) -> Any: """ Retrieve value for the given key. - + Args: key: Storage key default: Default value if key doesn't exist - + Returns: Stored value or default """ @@ -145,10 +149,10 @@ async def get(self, key: str, default: Any = None) -> Any: async def contains(self, key: str) -> bool: """ Check if key exists in storage. - + Args: key: Storage key - + Returns: True if key exists, False otherwise """ @@ -157,10 +161,14 @@ async def contains(self, key: str) -> bool: if self._auto_cache: cached = self._auto_cache.get(key) if cached is _Deleted: - logger.trace(f"Storage [{self.namespace}]: contains('{key}') -> False (cached delete)") + logger.trace( + f"Storage [{self.namespace}]: contains('{key}') -> False (cached delete)" + ) return False if cached is not _Missing: - logger.trace(f"Storage [{self.namespace}]: contains('{key}') -> True (cached)") + logger.trace( + f"Storage [{self.namespace}]: contains('{key}') -> True (cached)" + ) return True exists = await self._contains_direct(key) @@ -170,10 +178,10 @@ async def contains(self, key: str) -> bool: async def delete(self, key: str) -> bool: """ Delete a key from storage. - + Args: key: Storage key - + Returns: True if key was deleted, False if it didn't exist """ @@ -192,7 +200,7 @@ async def delete(self, key: str) -> bool: async def keys(self) -> List[str]: """ Get all keys in current namespace. - + Returns: List of all keys """ @@ -217,7 +225,7 @@ async def clear(self) -> None: def set_marshal(self, marshal_fn: callable, demarshal_fn: callable) -> None: """ Set custom marshaling functions for serialization. - + Args: marshal_fn: Function to serialize values (value -> str) demarshal_fn: Function to deserialize values (str -> value) @@ -318,17 +326,17 @@ async def _clear_direct(self) -> None: async def cached(self, keys: Optional[List[str]] = None): """ Context manager for cached storage operations. - + Minimizes database round-trips by: - Pre-fetching specified keys - Batching writes until flush or context exit - + Args: keys: List of keys to pre-fetch (None = don't pre-fetch) - + Yields: CachedStorage instance - + Example: async with storage.cached(["counter", "users"]) as cache: count = cache.get("counter", 0) @@ -346,7 +354,7 @@ async def cached(self, keys: Optional[List[str]] = None): class CachedStorage: """ Cached wrapper around BotStorage for batch operations. - + Minimizes database I/O by caching reads and batching writes. """ @@ -369,7 +377,7 @@ async def _prefetch(self) -> None: def put(self, key: str, value: Any) -> None: """ Store value in cache (will be flushed later). - + Args: key: Storage key value: Any JSON-serializable value @@ -380,14 +388,14 @@ def put(self, key: str, value: Any) -> None: def get(self, key: str, default: Any = None) -> Any: """ Get value from cache. - + Note: Only returns values that are in cache. If you need a key that wasn't pre-fetched, you must include it in the cached() keys list. - + Args: key: Storage key default: Default value if key doesn't exist in cache - + Returns: Cached value or default """ @@ -400,18 +408,18 @@ def get(self, key: str, default: Any = None) -> Any: f"Cache miss for '{key}' - key was not pre-fetched. " "Include it in cached() keys list for better performance." ) - + return default def contains(self, key: str) -> bool: """ Check if key exists in cache. - + Note: Only checks cache, not underlying storage. - + Args: key: Storage key - + Returns: True if key is in cache """ @@ -420,7 +428,7 @@ def contains(self, key: str) -> bool: async def flush_one(self, key: str) -> None: """ Flush a single key's changes to storage. - + Args: key: Storage key to flush """ @@ -456,7 +464,9 @@ class _AutoCache: Designed to yield to ORM usage by retrying when locked. """ - def __init__(self, storage: BotStorage, interval: float, retry_delay: float, max_retries: int) -> None: + def __init__( + self, storage: BotStorage, interval: float, retry_delay: float, max_retries: int + ) -> None: self._storage = storage self._interval = interval self._retry_delay = retry_delay diff --git a/bots/counter_bot/__init__.py b/bots/counter_bot/__init__.py index e362759..3812e05 100644 --- a/bots/counter_bot/__init__.py +++ b/bots/counter_bot/__init__.py @@ -7,18 +7,13 @@ !stats - Show detailed statistics """ -from loguru import logger - from bot_sdk import BaseBot, CommandInvocation, CommandSpec, Message class CounterBot(BaseBot): """A simple bot that counts messages using persistent storage.""" - def __init__(self, client): - super().__init__(client) - - # Register commands + def register_commands(self) -> None: self.command_parser.register_spec( CommandSpec( name="count", @@ -26,7 +21,7 @@ def __init__(self, client): handler=self._handle_count, ) ) - + self.command_parser.register_spec( CommandSpec( name="reset", @@ -34,7 +29,7 @@ def __init__(self, client): handler=self._handle_reset, ) ) - + self.command_parser.register_spec( CommandSpec( name="stats", @@ -43,7 +38,9 @@ def __init__(self, client): ) ) - async def _handle_count(self, invocation: CommandInvocation, message: Message, bot: BaseBot) -> None: + async def _handle_count( + self, invocation: CommandInvocation, message: Message, bot: BaseBot + ) -> None: """Increment and display counter using cached storage.""" if not self.storage: await self.send_reply(message, "❌ Storage is not enabled!") @@ -65,7 +62,9 @@ async def _handle_count(self, invocation: CommandInvocation, message: Message, b message, f"🔢 Count: **{counter}** (Total messages: {total})" ) - async def _handle_reset(self, invocation: CommandInvocation, message: Message, bot: BaseBot) -> None: + async def _handle_reset( + self, invocation: CommandInvocation, message: Message, bot: BaseBot + ) -> None: """Reset counter to zero.""" if not self.storage: await self.send_reply(message, "❌ Storage is not enabled!") @@ -74,7 +73,9 @@ async def _handle_reset(self, invocation: CommandInvocation, message: Message, b await self.storage.put("counter", 0) await self.send_reply(message, "✅ Counter reset to 0") - async def _handle_stats(self, invocation: CommandInvocation, message: Message, bot: BaseBot) -> None: + async def _handle_stats( + self, invocation: CommandInvocation, message: Message, bot: BaseBot + ) -> None: """Show detailed statistics.""" if not self.storage: await self.send_reply(message, "❌ Storage is not enabled!") @@ -98,12 +99,12 @@ async def _handle_stats(self, invocation: CommandInvocation, message: Message, b async def on_message(self, message: Message) -> None: """Handle non-command messages.""" # Only respond to simple text, commands are handled automatically - logger.debug(f"CounterBot received message: {message.content[:50]}") + self.logger.debug(f"CounterBot received message: {message.content[:50]}") # Factory function for main.py to discover -def create_bot(client): - return CounterBot(client) +def create_bot(client, logger): + return CounterBot(client, logger) BOT_CLASS = CounterBot diff --git a/bots/echo_bot/__init__.py b/bots/echo_bot/__init__.py index 677dbae..6025010 100644 --- a/bots/echo_bot/__init__.py +++ b/bots/echo_bot/__init__.py @@ -1,12 +1,8 @@ -from loguru import logger - from bot_sdk import BaseBot, CommandArgument, CommandInvocation, CommandSpec, Message class EchoBot(BaseBot): - - def __init__(self, client): - super().__init__(client) + def register_commands(self) -> None: self.command_parser.register_spec( CommandSpec( name="echo", @@ -16,13 +12,17 @@ def __init__(self, client): ) ) - async def _handle_echo(self, invocation: CommandInvocation, message: Message, bot: BaseBot): + async def _handle_echo( + self, invocation: CommandInvocation, message: Message, bot: BaseBot + ): text_parts = invocation.args.get("text") or [] - payload = " ".join(text_parts) if isinstance(text_parts, list) else str(text_parts) + payload = ( + " ".join(text_parts) if isinstance(text_parts, list) else str(text_parts) + ) await self.send_reply(message, payload) async def on_message(self, message: Message): - logger.debug("Sending echo reply") + self.logger.debug("Sending echo reply") await self.send_reply(message, f"Echo: {message.content}") diff --git a/docs/logging.md b/docs/logging.md index c88ac32..744c297 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -1,30 +1,84 @@ # Logging -SDK logging utilities for bots. +SDK logging utilities for system and bots. -The SDK uses [Loguru](https://github.com/Delgan/loguru) for logging, providing structured and colored output out of the box. +The SDK uses [Loguru](https://github.com/Delgan/loguru) for logging and separates **system** logs from **bot** logs using tags and different sinks. -## Logger setup +At a high level: + +- System logs (SDK internals, console, HTTP client, etc.) are tagged as `SYSTEM` and written to `logs/system.log`. +- Each bot gets its own tagged logger and optional per-bot log file like `logs/echo_bot.log`. +- The interactive console shows a merged, colored view of all logs in the `Logs` panel. + +## System logger setup ```python from bot_sdk import setup_logging +# Call once at process start (e.g. in main.py) setup_logging(level="INFO", json_logs=False) ``` ### Parameters - **level**: Log level (e.g. `DEBUG`, `INFO`, `WARNING`, `ERROR`). -- **json_logs**: Output JSON lines when `True` (useful for production/ingestion). +- **json_logs**: When `True`, use a JSON formatter (useful for log ingestion); when `False`, use a human-readable colored format. + +`setup_logging()` will: + +- Ensure a `logs/` directory exists next to your project. +- Configure Loguru with a `SYSTEM` tag (`extra["bot_name"] = "SYSTEM"`). +- Add sinks for: + - `stdout` (human-readable, colored output). + - `logs/system.log` (plain text, one line per record, filtered to `bot_name == "SYSTEM"`). + +## Bot loggers + +Bots use their own tagged loggers so you can distinguish messages per bot and write to per-bot log files. + +```python +from bot_sdk import get_bot_logger + +bot_logger = get_bot_logger("echo_bot", level="INFO") + +bot_logger.info("EchoBot starting up") +bot_logger.debug("Some internal state: {}", {"foo": 1}) +``` -### Console Integration +### Behavior -When running the interactive console (`main.py`): -- Logs are automatically captured and displayed in the "Logs" panel. -- Console UI (Rich) preserves ANSI colors for readability. -- Log levels can be filtered similarly. +- All bot loggers share the same global Loguru instance, but are bound with `extra["bot_name"] = `. +- Every bot can have its own file sink: + - `logs/.log` when a name is provided (e.g. `logs/echo_bot.log`). + - `logs/bots.log` as a shared fallback when no name is given. +- Console output still shows system and bot logs together, but with a visible tag prefix so you can see which bot produced which line. -## Example +In most cases you **don't need to call `get_bot_logger` manually**: + +- When using `BaseBot` via the SDK (e.g. `BotRunner` or the interactive console), the framework creates a bot-specific logger and injects it into your bot instance. +- Inside a bot you can simply use `self.logger`: + +```python +from bot_sdk import BaseBot, Message + +class MyBot(BaseBot): + async def on_message(self, message: Message): + self.logger.info("Received message from {}", message.sender_full_name) + await self.send_reply(message, "Hello!") +``` + +## Console integration + +When running the interactive console (`async-zulip-bot` / `main.py`): + +- The `Logs` panel shows a live view of all Loguru output (system + all bots). +- Each line is prefixed with a tag (e.g. `SYSTEM`, `echo_bot`) so you can see log origin at a glance. +- Rich preserves ANSI colors and formatting for readability. +- PageUp/PageDown (and mouse wheel when supported) scroll through the log history; bot management commands are entered at the bottom prompt. + +## Minimal examples + +### Simple script with system logs ```python from bot_sdk import setup_logging @@ -32,7 +86,21 @@ from loguru import logger if __name__ == "__main__": setup_logging(level="DEBUG") - logger.info("Bot starting...") - # Your bot startup here + logger.info("SDK starting...") + # Your startup logic here +``` + +### Bot using injected logger + +```python +from bot_sdk import BaseBot, Message + +class LoggingBot(BaseBot): + async def on_start(self) -> None: + self.logger.info("{} started", self.__class__.__name__) + + async def on_message(self, message: Message) -> None: + self.logger.debug("Incoming message: {}", message.content[:50]) + await self.send_reply(message, "Got it!") ``` diff --git a/docs/zh/logging.md b/docs/zh/logging.md index 94a273c..3a4e298 100644 --- a/docs/zh/logging.md +++ b/docs/zh/logging.md @@ -1,42 +1,50 @@ # 日志系统 API -Bot SDK 使用 [Loguru](https://github.com/Delgan/loguru) 作为日志库,提供简单而强大的日志功能。 +Bot SDK 使用 [Loguru](https://github.com/Delgan/loguru) 作为日志库,并将 **系统日志** 与 **Bot 日志** 分离,通过标签和不同的 sink 进行管理。 + +整体设计: + +- SDK 内部、控制台、HTTP 客户端等日志统一标记为 `SYSTEM`,写入 `logs/system.log`。 +- 每个 Bot 会拥有自己的带标签的 logger,并可写入独立的日志文件,如 `logs/echo_bot.log`。 +- 交互式控制台中的 `Logs` 面板展示所有日志的合并视图,并通过标签区分来源。 ## 控制台集成 -当运行交互式控制台 (`main.py`) 时: -- 日志会自动被捕获并显示在 TUI 的 "Logs" 面板中。 -- 控制台 UI (基于 Rich) 会保留 ANSI 颜色,提供良好的阅读体验。 -- 支持通过 `PageUp`/`PageDown` 滚动查看历史日志。 +当运行交互式控制台(`async-zulip-bot` 或 `main.py`)时: + +- 所有 Loguru 日志(系统 + 各 Bot)都会显示在 TUI 的 `Logs` 面板中。 +- 每一行前面会带有一个标签(如 `SYSTEM`、`echo_bot`),用于区分日志来源。 +- 控制台 UI(基于 Rich)会保留 ANSI 颜色,提供良好的阅读体验。 +- 可以通过 `PageUp`/`PageDown`(以及在部分终端中通过鼠标滚轮)滚动查看历史日志。 ## 快速开始 -### 基础用法 +### 基础用法(系统日志) ```python from bot_sdk import setup_logging -# 设置日志(推荐在程序入口调用) -setup_logging() +# 设置系统级日志(推荐在程序入口调用) +setup_logging(level="INFO", json_logs=False) ``` -### 在 Bot 中使用 +### 在 Bot 中使用(推荐用 self.logger) ```python from bot_sdk import BaseBot, Message -from loguru import logger class MyBot(BaseBot): async def on_message(self, message: Message): - logger.info(f"Received message from {message.sender_full_name}") - logger.debug(f"Message content: {message.content}") + # 使用注入的 bot 专属 logger,带有 Bot 名称标签 + self.logger.info("Received message from {}", message.sender_full_name) + self.logger.debug("Message content: {}", message.content) try: await self.send_reply(message, "Hello!") - logger.success("Reply sent successfully") + self.logger.success("Reply sent successfully") except Exception as e: - logger.error(f"Failed to send reply: {e}") - logger.exception("Full traceback:") + self.logger.error("Failed to send reply: {}", e) + self.logger.exception("Full traceback:") ``` ## setup_logging() @@ -46,20 +54,16 @@ from bot_sdk import setup_logging setup_logging( level: str = "INFO", - format: Optional[str] = None, - colorize: bool = True, - **kwargs + json_logs: bool = False, ) -> None ``` -配置日志系统。 +配置系统日志。 ### 参数 -- **level** (`str`): 日志级别(默认 "INFO") -- **format** (`str`, 可选): 自定义日志格式 -- **colorize** (`bool`): 是否启用彩色输出(默认 `True`) -- **kwargs**: 传递给 loguru 的其他参数 +- **level** (`str`): 日志级别(默认 `"INFO"`) +- **json_logs** (`bool`): 是否使用 JSON 行格式输出(便于日志收集/分析),默认关闭时使用人类友好的彩色文本格式。 ### 日志级别 @@ -77,21 +81,10 @@ setup_logging( ```python from bot_sdk import setup_logging +from loguru import logger -# 基础设置 -setup_logging() - -# 设置为 DEBUG 级别 setup_logging(level="DEBUG") - -# 禁用彩色输出 -setup_logging(colorize=False) - -# 自定义格式 -setup_logging( - level="INFO", - format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}" -) +logger.info("SDK 初始化完成") ``` ## 日志方法 @@ -247,28 +240,20 @@ if __name__ == "__main__": run_bot(DetailedBot) ``` -### 日志到文件 +### Bot 专属日志文件 -```python -from bot_sdk import BaseBot, Message, run_bot -from loguru import logger +多数情况下 SDK 会自动为每个 Bot 创建对应的文件 sink,无需手动配置: -# 配置日志到文件 -logger.add( - "bot_{time:YYYY-MM-DD}.log", - rotation="1 day", # 每天轮换 - retention="7 days", # 保留 7 天 - level="INFO", - format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}" -) +- `SYSTEM` 日志写入:`logs/system.log` +- Bot 日志写入:`logs/.log`(例如 `logs/echo_bot.log`) -class FileLoggingBot(BaseBot): - async def on_message(self, message: Message): - logger.info(f"Received: {message.content}") - await self.send_reply(message, "Logged!") +如果你需要在独立脚本中手动获取 bot logger,可以使用: -if __name__ == "__main__": - run_bot(FileLoggingBot) +```python +from bot_sdk import get_bot_logger + +logger = get_bot_logger("echo_bot", level="INFO") +logger.info("EchoBot starting...") ``` ### 结构化日志 diff --git a/main.py b/main.py index c26181c..841dbfa 100644 --- a/main.py +++ b/main.py @@ -2,4 +2,4 @@ if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/pyproject.toml b/pyproject.toml index 6c891e4..45793f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "async-zulip-bot-sdk" -version = "1.2.0" -description = "Add your description here" +version = "1.3.0" +description = "Async, type-safe Zulip bot development framework" readme = "README.md" requires-python = ">=3.12" dependencies = [ @@ -19,3 +19,15 @@ dependencies = [ [project.scripts] async-zulip-bot = "bot_sdk.cli:main" + +[project.optional-dependencies] +dev = [ + "ruff>=0.15.0", + "black>=26.1.0", + "isort>=7.0.0", + "mypy>=1.19.1", + "pytest>=9.0.2", + "pytest-asyncio>=1.3.0", + "pytest-cov>=7.0.0", + "pre-commit>=4.5.1", +] diff --git a/setup.py b/setup.py index 5f29e6d..48760da 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="async-zulip-bot-sdk", - version="1.2.0", + version="1.3.0", author="Stewitch", author_email="sunksugar24@gmail.com", description="Async, type-safe Zulip bot development framework in Python", diff --git a/uv.lock b/uv.lock index 0465c90..3ded7b9 100644 --- a/uv.lock +++ b/uv.lock @@ -49,7 +49,7 @@ wheels = [ [[package]] name = "async-zulip-bot-sdk" -version = "1.1.0" +version = "1.3.0" source = { virtual = "." } dependencies = [ { name = "aiosqlite" }, @@ -61,6 +61,7 @@ dependencies = [ { name = "pydantic" }, { name = "rich" }, { name = "ruamel-yaml" }, + { name = "ruff" }, { name = "sqlalchemy" }, ] @@ -75,6 +76,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.12.5" }, { name = "rich", specifier = ">=14.2.0" }, { name = "ruamel-yaml", specifier = ">=0.19.1" }, + { name = "ruff", specifier = ">=0.15.0" }, { name = "sqlalchemy", specifier = ">=2.0.0" }, ] @@ -428,6 +430,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, ] +[[package]] +name = "ruff" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" }, + { url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" }, + { url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" }, + { url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" }, + { url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" }, + { url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" }, + { url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.45"