Ctrl K
ComunidadGuías…

src/webui/services/_common.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""WebUI services 共享的常量与工具。"""

from typing import Any

_GAME_KEY_SEP = "|"
_INVALID_GAME_KEY = ("__invalid_game_key__", "", "")

# 跨 HTTP、SSE 与 service 共用的输入边界。业务限制放在 service 层,
# routes 仅重新导入,避免不同入口各自维护一份数值。
MAX_LOREBOOK_CHARS = 5000
MAX_ACTION_CHARS = 1000
MAX_ACTIONS_PER_TURN = 3
MAX_SEED_CHARS = 2000


def professional_character_validation_error(exc: ValueError) -> tuple[str, str]:
    """Return a stable API error while keeping professional validation fail-closed."""

    code = str(getattr(exc, "error_code", "") or "")
    if code == "PROFESSIONAL_CHARACTER_BLUEPRINT_REQUIRED":
        return code, (
            "专业角色缺少可重建的规则数据,请使用当前规则的专业建卡器完成后再创建或加入。"
            "旧角色卡不能仅凭展示属性安全转换。"
        )
    return "INVALID_PROFESSIONAL_CHARACTER", str(exc)


def _is_safe_game_key_part(s: str) -> bool:
    """校验 game_key 片段不可影响存档路径结构。"""
    return "/" not in s and "\\" not in s and ".." not in s and "#" not in s and ":" not in s


def _parse_game_key(game_key: str) -> tuple[str, str, str]:
    """解析公开 game_key,并拒绝路径穿越片段。

    A game key has at most three ``|`` parts. Extra parts used to be dropped,
    which let ``a|b|c|anything`` alias the same game under a different key
    (and slip past per-game state keyed by the raw string); they are refused.
    """
    raw = str(game_key or "")
    parts = raw.split(_GAME_KEY_SEP)
    if len(parts) > 3:
        return _INVALID_GAME_KEY
    parsed = tuple(parts) if len(parts) == 3 else (raw, "", "")
    if not all(_is_safe_game_key_part(part) for part in parsed):
        return _INVALID_GAME_KEY
    return parsed


def canonical_game_key(game_key: str) -> str:
    """The one spelling of a public game key, for keying per-game state."""
    return _GAME_KEY_SEP.join(_parse_game_key(game_key))


def _is_safe_world_id(s: str) -> bool:
    """校验 world_id 不含路径遍历字符(允许中文等正常字符)。"""
    return bool(s) and "/" not in s and "\\" not in s and ".." not in s


def resolve_default_scene_image(
    resolver: Any,
    world_id: str,
    rule_id: str,
    selection_key: str = "",
) -> dict[str, Any]:
    """Call the optional keyed default-image resolver without breaking old adapters.

    Third-party lifecycle test adapters and older integrations still expose the
    original two-argument callable. The keyed call is used by the built-in API
    so each new save can select a stable member from its ruleset/world pool;
    the fallback keeps those adapters source-compatible.
    """

    if selection_key:
        try:
            return resolver(world_id, rule_id, selection_key)
        except TypeError:
            pass
    return resolver(world_id, rule_id)


def is_game_gm(inst: Any, user_id: str, owner_authenticated: bool = False) -> bool:
    """GM 判定统一入口:该局主 GM,或已登录 owner(多人共用管理员账号都算 GM)。

    所有 GM 门控必须走这里,避免散落的 ``== gm_uid`` 各自为政;
    后续多 GM(co_gm_uids)也只需扩展这一个函数。
    """
    if inst is None or not user_id:
        return False
    if user_id == getattr(inst, "gm_uid", ""):
        return True
    return bool(owner_authenticated)