From 5bd5d724d9e5761d3d2f05f188bafed3154accc0 Mon Sep 17 00:00:00 2001
From: Stewitch <2207582235@qq.com>
Date: Sun, 18 Jan 2026 16:09:40 +0800
Subject: [PATCH 1/4] feat: update logging system and enhance bot logging
capabilities
---
.gitignore | 4 +-
README.md | 7 +-
README.zh-CN.md | 7 +-
bot_sdk/__init__.py | 3 +-
bot_sdk/async_zulip.py | 14 ++--
bot_sdk/bot.py | 59 ++++++++-------
bot_sdk/cli.py | 12 ++-
bot_sdk/console.py | 74 +++++++++++++-----
bot_sdk/loader.py | 29 +++++--
bot_sdk/log.py | 143 +++++++++++++++++++++++++++++++++++
bot_sdk/logging.py | 22 ------
bot_sdk/runner.py | 35 ++++++---
bots/counter_bot/__init__.py | 15 ++--
bots/echo_bot/__init__.py | 7 +-
docs/logging.md | 92 +++++++++++++++++++---
docs/zh/logging.md | 93 ++++++++++-------------
pyproject.toml | 2 +-
setup.py | 2 +-
uv.lock | 2 +-
19 files changed, 435 insertions(+), 187 deletions(-)
create mode 100644 bot_sdk/log.py
delete mode 100644 bot_sdk/logging.py
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..888c714 100644
--- a/bot_sdk/__init__.py
+++ b/bot_sdk/__init__.py
@@ -12,7 +12,7 @@
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 (
@@ -94,4 +94,5 @@
"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..2900e91 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/"
@@ -214,7 +213,7 @@ def custom_error_handling(self: argparse.ArgumentParser, message: str) -> None:
def generate_option_group(parser: optparse.OptionParser, prefix: str = "") -> optparse.OptionGroup:
- logging.warning(
+ 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."""
@@ -540,10 +539,13 @@ def end_error_retry(succeeded: bool) -> None:
kwargs = {kwarg: query_state["request"]}
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,
)
@@ -561,7 +563,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
diff --git a/bot_sdk/bot.py b/bot_sdk/bot.py
index 1851338..8969d3a 100644
--- a/bot_sdk/bot.py
+++ b/bot_sdk/bot.py
@@ -7,9 +7,9 @@
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
@@ -38,7 +38,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 +64,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."""
@@ -106,7 +107,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 +142,7 @@ 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 +160,10 @@ 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 +173,19 @@ 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
@@ -221,10 +222,10 @@ 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 +239,12 @@ 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.
@@ -306,7 +307,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.
@@ -481,15 +482,15 @@ 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.')}")
except Exception as exc:
- logger.warning(f"Reload failed: {exc}")
+ self.logger.warning(f"Reload failed: {exc}")
await self.send_reply(
message,
f"❌ "
@@ -505,7 +506,7 @@ async def _handle_stop(self, invocation: CommandInvocation, message: Message, bo
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
@@ -524,7 +525,7 @@ async def _handle_stop(self, invocation: CommandInvocation, message: Message, bo
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.
@@ -555,14 +556,14 @@ 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.
@@ -572,7 +573,7 @@ async def on_event(self, event: Event) -> None:
"""
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
@@ -587,7 +588,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,13 +596,13 @@ 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}")
+ 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)
@@ -610,10 +611,10 @@ async def on_event(self, event: Event) -> None:
return
await self.command_parser.dispatch(command_invocation, message=event.message, bot=self)
except Exception as exc:
- logger.warning(f"Command dispatch failed: {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
diff --git a/bot_sdk/cli.py b/bot_sdk/cli.py
index a3545ec..cdd5541 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, get_bot_logger
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,14 @@ 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")
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:
diff --git a/bot_sdk/console.py b/bot_sdk/console.py
index 239b526..0b46dac 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
@@ -71,10 +72,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]:
@@ -105,6 +107,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))
@@ -533,7 +537,7 @@ async def run_console(config_path: str = "bots.yaml", bots_dir: str = "bots", lo
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 +555,35 @@ 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)
@@ -610,22 +638,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
+ # 没有正在编辑的命令时,用于滚动日志(包括鼠标滚轮映射的 Up)
+ 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/loader.py b/bot_sdk/loader.py
index 2786ec8..6a773b1 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]
@@ -74,12 +74,27 @@ 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
diff --git a/bot_sdk/log.py b/bot_sdk/log.py
new file mode 100644
index 0000000..3442752
--- /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", "LoggerType"]
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/runner.py b/bot_sdk/runner.py
index 592d48c..83a347c 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,15 @@ 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 +74,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,11 +101,11 @@ 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,
@@ -111,7 +118,7 @@ def _cleanup(t: asyncio.Task[None]) -> None:
try:
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 +126,7 @@ 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 +140,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 +155,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/bots/counter_bot/__init__.py b/bots/counter_bot/__init__.py
index e362759..2720e23 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",
@@ -42,7 +37,7 @@ def __init__(self, client):
handler=self._handle_stats,
)
)
-
+
async def _handle_count(self, invocation: CommandInvocation, message: Message, bot: BaseBot) -> None:
"""Increment and display counter using cached storage."""
if not self.storage:
@@ -98,12 +93,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..e71d4dd 100644
--- a/bots/echo_bot/__init__.py
+++ b/bots/echo_bot/__init__.py
@@ -1,12 +1,9 @@
-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",
@@ -22,7 +19,7 @@ async def _handle_echo(self, invocation: CommandInvocation, message: Message, bo
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/pyproject.toml b/pyproject.toml
index 6c891e4..88e8d03 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "async-zulip-bot-sdk"
-version = "1.2.0"
+version = "1.3.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
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..86d7bd3 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" },
From dac02a5806c48d7673cd8e2dbc3479ebb3da3cc9 Mon Sep 17 00:00:00 2001
From: Stewitch <2207582235@qq.com>
Date: Tue, 10 Feb 2026 11:40:08 +0800
Subject: [PATCH 2/4] chore: clean code, use ruff to format the code
---
bot_sdk/__init__.py | 122 +++----
bot_sdk/async_zulip.py | 327 +++++++++++++-----
bot_sdk/bot.py | 271 +++++++++++----
bot_sdk/cli.py | 22 +-
bot_sdk/commands.py | 91 +++--
bot_sdk/config.py | 1 +
bot_sdk/console.py | 91 ++---
bot_sdk/db/cli.py | 35 +-
bot_sdk/db/database.py | 11 +-
.../db/migrations/versions/0001_initial.py | 2 +-
bot_sdk/i18n.py | 8 +-
bot_sdk/loader.py | 17 +-
bot_sdk/models/api/request.py | 11 +-
bot_sdk/models/api/types.py | 14 +-
bot_sdk/permissions.py | 9 +-
bot_sdk/runner.py | 23 +-
bot_sdk/storage.py | 70 ++--
bots/counter_bot/__init__.py | 20 +-
bots/echo_bot/__init__.py | 9 +-
main.py | 2 +-
pyproject.toml | 3 +-
uv.lock | 27 ++
22 files changed, 850 insertions(+), 336 deletions(-)
diff --git a/bot_sdk/__init__.py b/bot_sdk/__init__.py
index 888c714..8c6ff76 100644
--- a/bot_sdk/__init__.py
+++ b/bot_sdk/__init__.py
@@ -1,14 +1,14 @@
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
@@ -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,49 +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",
- "get_bot_logger"
+ "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 2900e91..555e32c 100644
--- a/bot_sdk/async_zulip.py
+++ b/bot_sdk/async_zulip.py
@@ -163,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",
@@ -173,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",
@@ -212,7 +222,9 @@ def custom_error_handling(self: argparse.ArgumentParser, message: str) -> None:
return parser
-def generate_option_group(parser: optparse.OptionParser, prefix: str = "") -> optparse.OptionGroup:
+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
@@ -220,16 +232,24 @@ def generate_option_group(parser: optparse.OptionParser, prefix: str = "") -> op
)
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",
@@ -306,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"
@@ -402,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"
@@ -433,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
@@ -495,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()
@@ -539,7 +568,7 @@ def end_error_retry(succeeded: bool) -> None:
kwargs = {kwarg: query_state["request"]}
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}")
@@ -552,7 +581,9 @@ def end_error_retry(succeeded: bool) -> None:
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
@@ -647,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)
@@ -667,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
@@ -684,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:
@@ -704,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])
@@ -747,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",
@@ -763,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(
@@ -782,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")
@@ -796,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)
)
@@ -830,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)
@@ -849,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]:
@@ -874,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)
@@ -903,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",
@@ -936,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(
@@ -966,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",
@@ -981,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)
@@ -1019,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",
@@ -1034,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 = {
@@ -1044,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:
@@ -1060,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 8969d3a..309dd0c 100644
--- a/bot_sdk/bot.py
+++ b/bot_sdk/bot.py
@@ -12,14 +12,24 @@
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
@@ -73,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.
@@ -142,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)
- self.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)
@@ -160,10 +172,14 @@ async def _init_i18n(self) -> None:
self.language = lang
try:
self.i18n = build_i18n_for_bot(lang, self.__class__.__module__)
- self.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
- self.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."""
@@ -173,11 +189,15 @@ async def _load_settings(self) -> None:
mod_file = inspect.getfile(mod)
default_path = Path(mod_file).parent / "bot.yaml"
except Exception:
- self.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():
- self.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
@@ -194,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
@@ -225,7 +251,9 @@ async def _load_identity(self) -> None:
self.logger.debug("Loaded bot profile from storage cache")
email = profile_data.get("email")
else:
- self.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,
@@ -239,7 +267,9 @@ 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)
- self.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."""
@@ -260,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),
@@ -351,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
@@ -382,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")
@@ -395,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)
@@ -406,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:
@@ -429,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)
@@ -447,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)
@@ -466,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:
@@ -484,11 +594,15 @@ async def _handle_reload(self, invocation: CommandInvocation, message: Message,
self.settings = load_bot_local_config(settings_path)
self.logger.info(f"Reloaded bot settings from {settings_path}")
else:
- self.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:
self.logger.warning(f"Reload failed: {exc}")
await self.send_reply(
@@ -497,12 +611,20 @@ async def _handle_reload(self, invocation: CommandInvocation, message: Message,
+ 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:
@@ -518,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:
- self.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.
@@ -537,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):
@@ -567,7 +701,7 @@ async def on_stop(self) -> None:
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.
"""
@@ -580,7 +714,11 @@ async def on_event(self, event: Event) -> None:
# 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
@@ -597,29 +735,40 @@ async def on_event(self, event: Event) -> None:
command_invocation = self.parse_command(event.message)
except Exception as exc: # CommandError and others
self.logger.warning(f"Command parsing failed: {exc}")
- await self.send_reply(event.message, self.tr("Command error: {error}", error=str(exc)))
+ await self.send_reply(
+ event.message, self.tr("Command error: {error}", error=str(exc))
+ )
return
if command_invocation is not None:
try:
- self.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:
self.logger.warning(f"Command dispatch failed: {exc}")
- await self.send_reply(event.message, self.tr("Command error: {error}", error=str(exc)))
+ await self.send_reply(
+ event.message, self.tr("Command error: {error}", error=str(exc))
+ )
else:
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:
@@ -627,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:
@@ -638,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 cdd5541..13c64c1 100644
--- a/bot_sdk/cli.py
+++ b/bot_sdk/cli.py
@@ -9,7 +9,7 @@
from loguru import logger
from .runner import BotRunner
-from .log import setup_logging, get_bot_logger
+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
@@ -54,7 +54,9 @@ def _run_bots(config_path: str = "bots.yaml", verbose: bool = False) -> None:
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, log_level=log_level))
@@ -67,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="?",
@@ -76,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 0b46dac..a9bd4ca 100644
--- a/bot_sdk/console.py
+++ b/bot_sdk/console.py
@@ -50,20 +50,6 @@
]
-COMMAND_LIST = [
- "run",
- "stop",
- "reload",
- "status",
- "bots",
- "help",
- "exit",
- "quit",
- "makemigrations",
- "migrate",
-]
-
-
@dataclass
class ManagedBot:
spec: BotSpec
@@ -94,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:
@@ -180,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
@@ -200,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
@@ -425,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
@@ -483,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
@@ -521,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:
@@ -530,10 +533,14 @@ 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)
@@ -561,7 +568,9 @@ def log_sink(message: str) -> None:
# 重新添加 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_filter = lambda record: (
+ record.get("extra", {}).get("bot_name") == "SYSTEM"
+ )
system_logger.add(
LOG_FOLDER / "system.log",
level=log_level,
@@ -583,7 +592,9 @@ def log_sink(message: str) -> None:
"{name}:{line} | "
"{message}"
)
- logger.add(log_sink, format=fmt, level=log_level, enqueue=True, colorize=True)
+ logger.add(
+ log_sink, format=fmt, level=log_level, enqueue=True, colorize=True
+ )
def output_to_logs(msg: str) -> None:
logger.info(msg)
@@ -622,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
@@ -638,7 +651,6 @@ 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
- # 没有正在编辑的命令时,用于滚动日志(包括鼠标滚轮映射的 Up)
if not command_buffer and history_idx == 0:
ui.scroll_offset += 5
else:
@@ -649,7 +661,6 @@ def output_to_logs(msg: str) -> None:
command_buffer = command_history[history_idx]
history_idx += 1
elif sc == b"P": # Down
- # 同理,如果没有命令历史在浏览,就用来向下滚动日志
if not command_buffer and history_idx == 0:
ui.scroll_offset = max(0, ui.scroll_offset - 5)
else:
@@ -658,7 +669,9 @@ def output_to_logs(msg: str) -> None:
if history_idx == 0:
command_buffer = ""
else:
- command_buffer = command_history[history_idx - 1]
+ command_buffer = command_history[
+ history_idx - 1
+ ]
else:
command_buffer = ""
history_idx = 0
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 6a773b1..564325a 100644
--- a/bot_sdk/loader.py
+++ b/bot_sdk/loader.py
@@ -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,7 +79,9 @@ def discover_bot_factories(
return specs
-def _bind_factory(factory: Callable[..., BaseBot], bot_config: dict[str, Any]) -> Callable[[Any, Any], BaseBot]:
+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 = [
@@ -99,7 +106,9 @@ def wrapper(client: Any, logger: Any) -> BaseBot:
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/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 83a347c..4248cdc 100644
--- a/bot_sdk/runner.py
+++ b/bot_sdk/runner.py
@@ -53,7 +53,9 @@ async def start(self) -> None:
if hasattr(self.bot, "set_runner"):
self.bot.set_runner(self)
await self.bot.post_init()
- self._bot_logger.info("Bot '{}' started with event types {}", self.bot_name, 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()
@@ -101,7 +103,9 @@ def _cleanup(t: asyncio.Task[None]) -> None:
return
exc = t.exception()
if exc:
- self._bot_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)
@@ -111,12 +115,19 @@ def _cleanup(t: asyncio.Task[None]) -> None:
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:
self._bot_logger.info("Stop requested; cancelling event stream")
if self._longpoll_task:
@@ -126,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
- self._bot_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:
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 2720e23..3812e05 100644
--- a/bots/counter_bot/__init__.py
+++ b/bots/counter_bot/__init__.py
@@ -12,7 +12,7 @@
class CounterBot(BaseBot):
"""A simple bot that counts messages using persistent storage."""
-
+
def register_commands(self) -> None:
self.command_parser.register_spec(
CommandSpec(
@@ -21,7 +21,7 @@ def register_commands(self) -> None:
handler=self._handle_count,
)
)
-
+
self.command_parser.register_spec(
CommandSpec(
name="reset",
@@ -29,7 +29,7 @@ def register_commands(self) -> None:
handler=self._handle_reset,
)
)
-
+
self.command_parser.register_spec(
CommandSpec(
name="stats",
@@ -37,8 +37,10 @@ def register_commands(self) -> None:
handler=self._handle_stats,
)
)
-
- 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!")
@@ -60,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!")
@@ -69,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!")
diff --git a/bots/echo_bot/__init__.py b/bots/echo_bot/__init__.py
index e71d4dd..6025010 100644
--- a/bots/echo_bot/__init__.py
+++ b/bots/echo_bot/__init__.py
@@ -2,7 +2,6 @@
class EchoBot(BaseBot):
-
def register_commands(self) -> None:
self.command_parser.register_spec(
CommandSpec(
@@ -13,9 +12,13 @@ def register_commands(self) -> None:
)
)
- 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):
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 88e8d03..cb72042 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "async-zulip-bot-sdk"
version = "1.3.0"
-description = "Add your description here"
+description = "Async, type-safe Zulip bot development framework"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
@@ -15,6 +15,7 @@ dependencies = [
"ruamel-yaml>=0.19.1",
"rich>=14.2.0",
"prompt-toolkit>=3.0.52",
+ "ruff>=0.15.0",
]
[project.scripts]
diff --git a/uv.lock b/uv.lock
index 86d7bd3..3ded7b9 100644
--- a/uv.lock
+++ b/uv.lock
@@ -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"
From 85108beee4174f7538acee92a0c30cfeb99a88a2 Mon Sep 17 00:00:00 2001
From: Stewitch <2207582235@qq.com>
Date: Tue, 10 Feb 2026 11:44:53 +0800
Subject: [PATCH 3/4] Update bot_sdk/log.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
---
bot_sdk/log.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/bot_sdk/log.py b/bot_sdk/log.py
index 3442752..dbe04b6 100644
--- a/bot_sdk/log.py
+++ b/bot_sdk/log.py
@@ -140,4 +140,4 @@ def get_bot_logger(
return bot_logger
-__all__ = ["setup_logging", "logger", "get_bot_logger", "LoggerType"]
+__all__ = ["setup_logging", "logger", "get_bot_logger", "Logger"]
From 3dbbd41abff194d9ddd0ee049bd27996f382e542 Mon Sep 17 00:00:00 2001
From: Stewitch <2207582235@qq.com>
Date: Tue, 10 Feb 2026 11:49:52 +0800
Subject: [PATCH 4/4] update dependencies
---
pyproject.toml | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
diff --git a/pyproject.toml b/pyproject.toml
index cb72042..45793f4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,8 +15,19 @@ dependencies = [
"ruamel-yaml>=0.19.1",
"rich>=14.2.0",
"prompt-toolkit>=3.0.52",
- "ruff>=0.15.0",
]
[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",
+]