nonebot-plugin-onebot-luckperms 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. nonebot_plugin_onebot_luckperms/__init__.py +127 -0
  2. nonebot_plugin_onebot_luckperms/adapter/__init__.py +17 -0
  3. nonebot_plugin_onebot_luckperms/adapter/context.py +31 -0
  4. nonebot_plugin_onebot_luckperms/adapter/identity.py +64 -0
  5. nonebot_plugin_onebot_luckperms/adapter/permission.py +157 -0
  6. nonebot_plugin_onebot_luckperms/commands/__init__.py +3 -0
  7. nonebot_plugin_onebot_luckperms/commands/admin.py +857 -0
  8. nonebot_plugin_onebot_luckperms/config.py +20 -0
  9. nonebot_plugin_onebot_luckperms/core/__init__.py +20 -0
  10. nonebot_plugin_onebot_luckperms/core/cache.py +44 -0
  11. nonebot_plugin_onebot_luckperms/core/context_provider.py +74 -0
  12. nonebot_plugin_onebot_luckperms/core/engine.py +162 -0
  13. nonebot_plugin_onebot_luckperms/core/exceptions.py +6 -0
  14. nonebot_plugin_onebot_luckperms/core/models.py +123 -0
  15. nonebot_plugin_onebot_luckperms/core/registry.py +72 -0
  16. nonebot_plugin_onebot_luckperms/message.py +126 -0
  17. nonebot_plugin_onebot_luckperms/py.typed +0 -0
  18. nonebot_plugin_onebot_luckperms/storage/__init__.py +29 -0
  19. nonebot_plugin_onebot_luckperms/storage/memory.py +46 -0
  20. nonebot_plugin_onebot_luckperms/storage/protocol.py +21 -0
  21. nonebot_plugin_onebot_luckperms/storage/redis.py +162 -0
  22. nonebot_plugin_onebot_luckperms/storage/sqlite.py +189 -0
  23. nonebot_plugin_onebot_luckperms/webeditor/__init__.py +9 -0
  24. nonebot_plugin_onebot_luckperms/webeditor/bytebin.py +71 -0
  25. nonebot_plugin_onebot_luckperms/webeditor/manager.py +228 -0
  26. nonebot_plugin_onebot_luckperms/webeditor/session.py +109 -0
  27. nonebot_plugin_onebot_luckperms/webeditor/websocket.py +180 -0
  28. nonebot_plugin_onebot_luckperms-0.1.0.dist-info/METADATA +23 -0
  29. nonebot_plugin_onebot_luckperms-0.1.0.dist-info/RECORD +32 -0
  30. nonebot_plugin_onebot_luckperms-0.1.0.dist-info/WHEEL +5 -0
  31. nonebot_plugin_onebot_luckperms-0.1.0.dist-info/licenses/LICENSE +21 -0
  32. nonebot_plugin_onebot_luckperms-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,20 @@
1
+ from typing import List, Literal
2
+ from pydantic import BaseModel, Field, ConfigDict
3
+
4
+
5
+ class OBLPConfig(BaseModel):
6
+ model_config = ConfigDict(extra="ignore")
7
+
8
+ store_type: Literal["memory", "sqlite", "redis"] = "sqlite"
9
+ sqlite_path: str = "./data/oblp/permissions.db"
10
+ redis_url: str = "redis://localhost:6379/0"
11
+ default_group_owner: List[str] = Field(default_factory=lambda: ["luckperms.help"])
12
+ default_group_admin: List[str] = Field(default_factory=list)
13
+ default_group_member: List[str] = Field(default_factory=list)
14
+ superuser_inherit: List[str] = Field(default_factory=lambda: ["luckperms.*"])
15
+ cache_ttl: int = 300
16
+ debug_mode: bool = False
17
+ message_file: str = "./data/oblp/messages.yml"
18
+
19
+
20
+ oblp_config = OBLPConfig()
@@ -0,0 +1,20 @@
1
+ from .models import ContextSet, PermissionNode, User, Group, QueryOptions
2
+ from .engine import PermissionEngine
3
+ from .registry import NodeRegistry, register_node
4
+ from .exceptions import CircularInheritanceError, PermissionException
5
+ from .context_provider import register_context_provider, ContextProvider
6
+
7
+ __all__ = [
8
+ "ContextSet",
9
+ "PermissionNode",
10
+ "User",
11
+ "Group",
12
+ "QueryOptions",
13
+ "PermissionEngine",
14
+ "NodeRegistry",
15
+ "register_node",
16
+ "CircularInheritanceError",
17
+ "PermissionException",
18
+ "register_context_provider",
19
+ "ContextProvider",
20
+ ]
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Dict, Optional, Tuple
5
+
6
+ from ..config import oblp_config
7
+
8
+
9
+ class PermissionCache:
10
+ _cache: Dict[str, Tuple[float, dict]] = {}
11
+
12
+ @classmethod
13
+ def get(cls, key: str) -> Optional[dict]:
14
+ ttl = oblp_config.cache_ttl
15
+ if ttl <= 0:
16
+ return None
17
+ entry = cls._cache.get(key)
18
+ if entry is None:
19
+ return None
20
+ ts, value = entry
21
+ if time.time() - ts > ttl:
22
+ del cls._cache[key]
23
+ return None
24
+ return value
25
+
26
+ @classmethod
27
+ def set(cls, key: str, value: dict):
28
+ ttl = oblp_config.cache_ttl
29
+ if ttl <= 0:
30
+ return
31
+ cls._cache[key] = (time.time(), value)
32
+
33
+ @classmethod
34
+ def invalidate(cls, key: str):
35
+ cls._cache.pop(key, None)
36
+
37
+ @classmethod
38
+ def invalidate_all(cls):
39
+ cls._cache.clear()
40
+
41
+ @classmethod
42
+ def make_key(cls, user_id: str, ctx: "ContextSet") -> str:
43
+ ctx_str = "&".join(f"{k}={v}" for k, v in sorted(ctx.data.items()))
44
+ return f"{user_id}:{ctx_str}"
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Dict, List, Protocol
5
+
6
+ from nonebot.adapters import Bot, Event
7
+
8
+ from .models import ContextSet
9
+
10
+ logger = logging.getLogger("oblp")
11
+
12
+
13
+ class DuplicateProviderError(Exception):
14
+ """Raised when a ContextProvider registers a key already claimed by another provider."""
15
+
16
+
17
+ class ContextProvider(Protocol):
18
+ async def __call__(
19
+ self, bot: Bot, event: Event, current_ctx: ContextSet
20
+ ) -> Dict[str, str]:
21
+ ...
22
+
23
+
24
+ _context_providers: List[ContextProvider] = []
25
+ _claimed_keys: Dict[str, str] = {} # key → provider_identity
26
+
27
+
28
+ def _provider_identity(provider: ContextProvider) -> str:
29
+ return f"{type(provider).__module__}.{type(provider).__qualname__}"
30
+
31
+
32
+ def register_context_provider(provider: ContextProvider):
33
+ identity = _provider_identity(provider)
34
+
35
+ # Probe which keys this provider returns by running it with an empty context
36
+ # (We can't call it here since it needs bot/event. Instead, require provider
37
+ # to declare its keys statically via a `context_keys` class attribute.)
38
+
39
+ if hasattr(provider, "context_keys"):
40
+ keys: set[str] = provider.context_keys
41
+ else:
42
+ keys = set()
43
+
44
+ if not keys:
45
+ logger.warning(
46
+ f"ContextProvider '{identity}' does not declare `context_keys`. "
47
+ f"Add a class-level `context_keys = {{\"your_key\"}}` to prevent conflicts."
48
+ )
49
+
50
+ conflicts = []
51
+ for k in keys:
52
+ if k in _claimed_keys:
53
+ conflicts.append((k, _claimed_keys[k]))
54
+
55
+ if conflicts:
56
+ detail = "; ".join(f"key='{k}' already claimed by {owner}" for k, owner in conflicts)
57
+ raise DuplicateProviderError(
58
+ f"ContextProvider '{identity}' conflicts: {detail}"
59
+ )
60
+
61
+ for k in keys:
62
+ _claimed_keys[k] = identity
63
+
64
+ _context_providers.append(provider)
65
+ logger.info(f"ContextProvider registered: {identity} keys={keys}")
66
+
67
+
68
+ def get_context_providers() -> List[ContextProvider]:
69
+ return list(_context_providers)
70
+
71
+
72
+ def clear_providers():
73
+ _context_providers.clear()
74
+ _claimed_keys.clear()
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, List, Optional
4
+
5
+ from .models import User, PermissionNode, QueryOptions, ContextSet
6
+ from .registry import NodeRegistry
7
+
8
+
9
+ def _match_segments(pattern: str, target: str) -> bool:
10
+ """Segment-based wildcard matching.
11
+
12
+ * matches zero or more segments (multi-segment wildcard).
13
+ ** alias for *, identical behavior.
14
+ Literals must match exactly.
15
+
16
+ Per our spec, * matches "a.b" and also "a.b.c" (not single-segment).
17
+ """
18
+ pat_parts = pattern.split(".")
19
+ tgt_parts = target.split(".")
20
+ return _match_dp(pat_parts, tgt_parts)
21
+
22
+
23
+ def _match_dp(p: list[str], t: list[str]) -> bool:
24
+ n, m = len(p), len(t)
25
+ dp = [[False] * (m + 1) for _ in range(n + 1)]
26
+ dp[0][0] = True
27
+ for i in range(1, n + 1):
28
+ dp[i][0] = dp[i - 1][0] if p[i - 1] in ("*", "**") else False
29
+ for i in range(1, n + 1):
30
+ for j in range(1, m + 1):
31
+ if p[i - 1] in ("*", "**"):
32
+ dp[i][j] = dp[i - 1][j] or dp[i][j - 1]
33
+ elif p[i - 1] == t[j - 1]:
34
+ dp[i][j] = dp[i - 1][j - 1]
35
+ else:
36
+ dp[i][j] = False
37
+ return dp[n][m]
38
+
39
+
40
+ class PermissionEngine:
41
+ @staticmethod
42
+ def check(user_nodes: Dict[str, bool], target: str) -> bool:
43
+ # Stage 1: Exact match (highest priority)
44
+ if target in user_nodes:
45
+ return user_nodes[target]
46
+
47
+ # Stage 2: Parent deny inheritance (security critical)
48
+ parts = target.split(".")
49
+ for i in range(len(parts) - 1, 0, -1):
50
+ parent = ".".join(parts[:i])
51
+ if parent in user_nodes and not user_nodes[parent]:
52
+ return False
53
+
54
+ # Stage 3: Wildcard match (segment-based)
55
+ for pattern, value in user_nodes.items():
56
+ if "*" in pattern and _match_segments(pattern, target):
57
+ return value
58
+
59
+ # Stage 4: Prefix inheritance (allow only)
60
+ for node_key, value in user_nodes.items():
61
+ if value and target.startswith(node_key + "."):
62
+ return True
63
+
64
+ # Stage 5: Inheritance chain via NodeRegistry
65
+ for ancestor in NodeRegistry.get_ancestors(target):
66
+ if ancestor in user_nodes:
67
+ return user_nodes[ancestor]
68
+
69
+ # Stage 6: Default deny
70
+ return False
71
+
72
+ @staticmethod
73
+ async def resolve_effective_nodes(
74
+ user: User,
75
+ store,
76
+ options: QueryOptions,
77
+ current_ctx: Optional[ContextSet] = None,
78
+ ) -> Dict[str, bool]:
79
+ candidates: List[tuple[int, PermissionNode]] = []
80
+ # sort_key: user nodes = 0, group nodes = 1 + (10000 - weight)
81
+ # This ensures user's own nodes always beat any group-inherited node.
82
+
83
+ for node in user.nodes:
84
+ candidates.append((0, node))
85
+
86
+ if "include_inherited" in options.flags:
87
+ for group_name in user.groups:
88
+ group = await store.get_group(group_name)
89
+ if group is None:
90
+ continue
91
+ try:
92
+ group_nodes = await group.get_effective_nodes(store)
93
+ except Exception:
94
+ continue
95
+ for node in group_nodes:
96
+ sort_key = 1 + (10000 - group.weight)
97
+ candidates.append((sort_key, node))
98
+
99
+ candidates.sort(key=lambda x: x[0])
100
+
101
+ ctx_for_match = options.contexts
102
+ grouped: Dict[str, List[tuple[int, PermissionNode]]] = {}
103
+ for sort_key, node in candidates:
104
+ if node.is_expired():
105
+ continue
106
+ if options.mode == "contextual" and not node.applies_in(ctx_for_match):
107
+ continue
108
+ grouped.setdefault(node.key, []).append((sort_key, node))
109
+
110
+ seen: Dict[str, bool] = {}
111
+
112
+ for key, entries in grouped.items():
113
+ best = None
114
+ best_key = None
115
+ best_match = -1
116
+
117
+ for sort_key, node in entries:
118
+ if options.mode == "contextual" and not node.contexts.is_empty():
119
+ match_cnt = sum(
120
+ 1 for k in node.contexts.data
121
+ if ctx_for_match.data.get(k) == node.contexts.data[k]
122
+ )
123
+ else:
124
+ match_cnt = 0
125
+
126
+ if best is None:
127
+ best = node
128
+ best_key = sort_key
129
+ best_match = match_cnt
130
+ elif sort_key < best_key:
131
+ best = node
132
+ best_key = sort_key
133
+ best_match = match_cnt
134
+ elif sort_key == best_key and match_cnt > best_match:
135
+ best = node
136
+ best_match = match_cnt
137
+
138
+ if best is not None:
139
+ seen[key] = best.value
140
+
141
+ # Parent deny inheritance
142
+ for key in list(seen.keys()):
143
+ if not seen[key]:
144
+ continue
145
+ parts = key.split(".")
146
+ for i in range(len(parts) - 1, 0, -1):
147
+ parent = ".".join(parts[:i])
148
+ if parent in seen and not seen[parent]:
149
+ del seen[key]
150
+ break
151
+
152
+ # NodeRegistry defaults
153
+ ctx_for_defaults = current_ctx or options.contexts
154
+ for info in NodeRegistry.list_all():
155
+ key = info["key"]
156
+ if key in seen:
157
+ continue
158
+ default_ctx: ContextSet = info.get("contexts", ContextSet())
159
+ if ctx_for_defaults.matches(default_ctx):
160
+ seen[key] = info["default"]
161
+
162
+ return seen
@@ -0,0 +1,6 @@
1
+ class PermissionException(Exception):
2
+ """Base exception for the luckperms permission system."""
3
+
4
+
5
+ class CircularInheritanceError(PermissionException):
6
+ """Raised when circular group inheritance is detected."""
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass, field
5
+ from typing import Dict, List, Optional, Set, Literal
6
+
7
+ from .exceptions import CircularInheritanceError
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class ContextSet:
12
+ data: Dict[str, str] = field(default_factory=dict)
13
+
14
+ def with_context(self, key: str, value: str) -> ContextSet:
15
+ return ContextSet(data={**self.data, key: value})
16
+
17
+ def matches(self, requirement: ContextSet) -> bool:
18
+ if requirement.is_empty():
19
+ return True
20
+ for k, v in requirement.data.items():
21
+ if self.data.get(k) != v:
22
+ return False
23
+ return True
24
+
25
+ def is_empty(self) -> bool:
26
+ return len(self.data) == 0
27
+
28
+ @classmethod
29
+ def from_dict(cls, d: Dict[str, str]) -> ContextSet:
30
+ return cls(data=dict(d))
31
+
32
+ def to_dict(self) -> Dict[str, str]:
33
+ return dict(self.data)
34
+
35
+
36
+ @dataclass
37
+ class PermissionNode:
38
+ key: str
39
+ value: bool = True
40
+ expiry: Optional[int] = None
41
+ contexts: ContextSet = field(default_factory=ContextSet)
42
+
43
+ def is_expired(self) -> bool:
44
+ if self.expiry is None:
45
+ return False
46
+ return time.time() > self.expiry
47
+
48
+ def applies_in(self, ctx: ContextSet) -> bool:
49
+ return ctx.matches(self.contexts)
50
+
51
+
52
+ @dataclass
53
+ class QueryOptions:
54
+ mode: Literal["contextual", "all"] = "contextual"
55
+ contexts: ContextSet = field(default_factory=ContextSet)
56
+ flags: Set[str] = field(default_factory=lambda: {"include_inherited", "resolve_inheritance"})
57
+
58
+
59
+ @dataclass
60
+ class Group:
61
+ name: str
62
+ display_name: Optional[str] = None
63
+ weight: int = 0
64
+ nodes: List[PermissionNode] = field(default_factory=list)
65
+ parents: List[str] = field(default_factory=list)
66
+
67
+ async def get_effective_nodes(
68
+ self,
69
+ store,
70
+ visited: Optional[Set[str]] = None,
71
+ ) -> List[PermissionNode]:
72
+ if visited is None:
73
+ visited = set()
74
+ if self.name in visited:
75
+ raise CircularInheritanceError(
76
+ f"Circular inheritance detected: group '{self.name}' already visited"
77
+ )
78
+ visited.add(self.name)
79
+
80
+ result = list(self.nodes)
81
+
82
+ for parent_name in self.parents:
83
+ parent = await store.get_group(parent_name)
84
+ if parent is None:
85
+ continue
86
+ if parent.name in visited:
87
+ raise CircularInheritanceError(
88
+ f"Circular inheritance detected: group '{parent.name}' already visited"
89
+ )
90
+ parent_nodes = await parent.get_effective_nodes(store, visited)
91
+ result.extend(parent_nodes)
92
+
93
+ return result
94
+
95
+
96
+ @dataclass
97
+ class User:
98
+ user_id: str
99
+ username: Optional[str] = None
100
+ primary_group: Optional[str] = None
101
+ groups: List[str] = field(default_factory=list)
102
+ nodes: List[PermissionNode] = field(default_factory=list)
103
+
104
+ async def get_effective_nodes(
105
+ self,
106
+ store,
107
+ options: QueryOptions,
108
+ current_ctx: Optional["ContextSet"] = None,
109
+ ) -> Dict[str, bool]:
110
+ from .engine import PermissionEngine
111
+ return await PermissionEngine.resolve_effective_nodes(self, store, options, current_ctx)
112
+
113
+ async def has_permission(
114
+ self,
115
+ node_key: str,
116
+ store,
117
+ options: QueryOptions,
118
+ ) -> bool:
119
+ from .engine import PermissionEngine
120
+ return PermissionEngine.check(
121
+ await self.get_effective_nodes(store, options),
122
+ node_key,
123
+ )
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Dict, List, Optional
5
+
6
+ from .models import PermissionNode, ContextSet
7
+
8
+ logger = logging.getLogger("oblp")
9
+
10
+
11
+ class NodeRegistry:
12
+ _nodes: Dict[str, dict] = {}
13
+
14
+ @classmethod
15
+ def register(
16
+ cls,
17
+ key: str,
18
+ description: str = "",
19
+ default: bool = False,
20
+ contexts: Optional[ContextSet] = None,
21
+ ):
22
+ if key in cls._nodes:
23
+ existing = cls._nodes[key]
24
+ if existing.get("description") != description or existing.get("contexts") != (contexts or ContextSet()):
25
+ logger.warning(
26
+ f"Node '{key}' already registered with different parameters, "
27
+ f"keeping first registration"
28
+ )
29
+ return
30
+ cls._nodes[key] = {
31
+ "key": key,
32
+ "description": description,
33
+ "default": default,
34
+ "contexts": contexts or ContextSet(),
35
+ }
36
+
37
+ @classmethod
38
+ def get(cls, key: str) -> Optional[dict]:
39
+ return cls._nodes.get(key)
40
+
41
+ @classmethod
42
+ def list_all(cls) -> List[dict]:
43
+ return list(cls._nodes.values())
44
+
45
+ @classmethod
46
+ def get_ancestors(cls, key: str) -> List[str]:
47
+ parts = key.split(".")
48
+ ancestors = []
49
+ for i in range(len(parts) - 1, 0, -1):
50
+ ancestors.append(".".join(parts[:i]))
51
+ return ancestors
52
+
53
+ @classmethod
54
+ def get_default(cls, key: str) -> bool:
55
+ info = cls._nodes.get(key)
56
+ if info is None:
57
+ for pattern, val in cls._nodes.items():
58
+ if "*" in pattern:
59
+ import fnmatch
60
+ if fnmatch.fnmatch(key, pattern):
61
+ return val.get("default", False)
62
+ return False
63
+ return info.get("default", False)
64
+
65
+
66
+ def register_node(
67
+ key: str,
68
+ description: str = "",
69
+ default: bool = False,
70
+ contexts: Optional[ContextSet] = None,
71
+ ):
72
+ NodeRegistry.register(key, description, default, contexts)
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Dict, Optional
7
+ import yaml
8
+
9
+ logger = logging.getLogger("oblp")
10
+
11
+ _DEFAULT_MESSAGES: Dict[str, str] = {
12
+ "deny": "Permission denied. You do not have the required permission node.",
13
+ "deny_hint": "You can ask the bot administrator to grant you the required permission.",
14
+ "data_reloaded": "Data reloaded.",
15
+ "unknown_command": "Unknown command: {cmd}. Use /lp help",
16
+ "not_initialized": "OBLP not initialized.",
17
+ "user_not_found": "User {user_id} not found.",
18
+ "user_created": "User {user_id} created.",
19
+ "user_deleted": "User {user_id} deleted.",
20
+ "user_cloned": "User {user_id} cloned to {target}.",
21
+ "user_cleared": "User {user_id} permissions cleared.",
22
+ "user_promoted": "User {user_id} promoted to {track}.",
23
+ "user_demoted": "User {user_id} demoted from {track}.",
24
+ "perm_set": "{entity}: {action} {node}",
25
+ "perm_set_context": "{entity}: {action} {node} (ctx: {context})",
26
+ "perm_temp": "{entity}: temp {action} {node} ({duration})",
27
+ "perm_removed": "{entity}: removed {node}",
28
+ "perm_cleared": "{entity}: permissions cleared",
29
+ "perm_no_nodes": "No permission nodes.",
30
+ "perm_check_result": "{entity} check {node}: {result}",
31
+ "perm_check_allow": "ALLOW",
32
+ "perm_check_deny": "DENY",
33
+ "group_not_found": "Group {name} not found.",
34
+ "group_created": "Group {name} created (weight={weight}).",
35
+ "group_deleted": "Group {name} deleted.",
36
+ "group_already_exists": "Group {name} already exists.",
37
+ "group_renamed": "Group {name} renamed to {new_name}.",
38
+ "group_cloned": "Group {name} cloned to {new_name}.",
39
+ "group_weight_set": "Group {name} weight set to {weight}.",
40
+ "group_displayname_set": "Group {name} display name set to {display_name}.",
41
+ "group_cleared": "Group {name} permissions cleared.",
42
+ "group_no_members": "No members in group {name}.",
43
+ "parent_added": "Added parent {parent} to {entity}.",
44
+ "parent_removed": "Removed parent {parent} from {entity}.",
45
+ "parent_cleared": "{entity}: parents cleared",
46
+ "parent_no_parents": "{entity} has no parents.",
47
+ "primary_group_set": "User {entity} primary group set to {group}.",
48
+ "editor_opened": "Web Editor opened: {url}",
49
+ "editor_failed": "Editor failed: {error}",
50
+ "editor_apply_ok": "Applied edits for code: {code}",
51
+ "editor_apply_failed": "Apply failed: {error}",
52
+ }
53
+
54
+ _messages: Dict[str, str] = {}
55
+ _loaded_path: Optional[Path] = None
56
+
57
+
58
+ async def load_messages(path: Optional[str] = None):
59
+ global _messages, _loaded_path
60
+ _messages = dict(_DEFAULT_MESSAGES)
61
+
62
+ if not path:
63
+ return
64
+
65
+ p = Path(path)
66
+
67
+ # If file doesn't exist, try to export defaults
68
+ if not await asyncio.to_thread(p.exists):
69
+ try:
70
+ await _export_defaults(str(p))
71
+ logger.info(f"Default message file created at {path}")
72
+ except Exception as e:
73
+ logger.warning(f"Could not create message file at {path}: {e}")
74
+ return
75
+
76
+ try:
77
+ loop = asyncio.get_event_loop()
78
+ raw = await loop.run_in_executor(None, _read_file, p)
79
+ if not raw.strip():
80
+ logger.warning(f"Message file {path} is empty, using defaults")
81
+ return
82
+ custom = yaml.safe_load(raw) or {}
83
+ if not isinstance(custom, dict):
84
+ logger.warning(f"Message file {path} is not a valid YAML mapping, using defaults")
85
+ return
86
+ for k, v in custom.items():
87
+ if k in _messages and isinstance(v, str):
88
+ _messages[k] = v
89
+ _loaded_path = p
90
+ logger.info(f"Messages loaded from {path} ({len(custom)} overrides)")
91
+ except yaml.YAMLError as e:
92
+ logger.error(f"YAML parse error in {path}: {e}")
93
+ except Exception as e:
94
+ logger.error(f"Failed to load message file {path}: {e}")
95
+
96
+
97
+ def _read_file(p: Path) -> str:
98
+ with open(p, "r", encoding="utf-8") as f:
99
+ return f.read()
100
+
101
+
102
+ def msg(key: str, **kwargs) -> str:
103
+ template = _messages.get(key, _DEFAULT_MESSAGES.get(key, key))
104
+ if kwargs:
105
+ try:
106
+ return template.format(**kwargs)
107
+ except KeyError as e:
108
+ logger.warning(f"Missing placeholder {e} in message '{key}'")
109
+ return template
110
+ return template
111
+
112
+
113
+ async def _export_defaults(path: str):
114
+ """Write default messages to a YAML file as a template for users."""
115
+ p = Path(path)
116
+ await asyncio.to_thread(p.parent.mkdir, parents=True, exist_ok=True)
117
+ await asyncio.to_thread(_write_yaml, p, _DEFAULT_MESSAGES)
118
+ logger.info(f"Default message template exported to {path}")
119
+
120
+
121
+ def _write_yaml(p: Path, data: dict):
122
+ with open(p, "w", encoding="utf-8") as f:
123
+ yaml.dump(
124
+ data,
125
+ f, allow_unicode=True, default_flow_style=False, sort_keys=False,
126
+ )
File without changes
@@ -0,0 +1,29 @@
1
+ from typing import Optional
2
+
3
+ from .protocol import PermissionStore
4
+ from .memory import MemoryStore
5
+ from .sqlite import SQLiteStore
6
+
7
+ _store: Optional[PermissionStore] = None
8
+
9
+
10
+ def _get_redis_store(**kwargs):
11
+ from .redis import RedisStore
12
+ return RedisStore(url=kwargs.get("redis_url", "redis://localhost:6379/0"))
13
+
14
+
15
+ def init_store(store_type: str, **kwargs) -> PermissionStore:
16
+ global _store
17
+ if store_type == "memory":
18
+ _store = MemoryStore()
19
+ elif store_type == "sqlite":
20
+ _store = SQLiteStore(db_path=kwargs.get("db_path", "./data/oblp/permissions.db"))
21
+ elif store_type == "redis":
22
+ _store = _get_redis_store(**kwargs)
23
+ else:
24
+ raise ValueError(f"Unknown store type: {store_type}")
25
+ return _store
26
+
27
+
28
+ def get_store() -> Optional[PermissionStore]:
29
+ return _store