borgee-plugin-sdk 0.1.2__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.
- borgee_plugin_sdk/__init__.py +83 -0
- borgee_plugin_sdk/_connection.py +1680 -0
- borgee_plugin_sdk/_wire/__init__.py +5 -0
- borgee_plugin_sdk/_wire/frames_generated.py +272 -0
- borgee_plugin_sdk/client.py +400 -0
- borgee_plugin_sdk/cursor.py +220 -0
- borgee_plugin_sdk/errors.py +61 -0
- borgee_plugin_sdk/models.py +326 -0
- borgee_plugin_sdk/py.typed +1 -0
- borgee_plugin_sdk/testing/__init__.py +16 -0
- borgee_plugin_sdk/testing/fake_transport.py +125 -0
- borgee_plugin_sdk-0.1.2.dist-info/METADATA +72 -0
- borgee_plugin_sdk-0.1.2.dist-info/RECORD +16 -0
- borgee_plugin_sdk-0.1.2.dist-info/WHEEL +5 -0
- borgee_plugin_sdk-0.1.2.dist-info/licenses/LICENSE +21 -0
- borgee_plugin_sdk-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Durable monotonic cursor stores."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import fcntl
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import stat
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from collections.abc import Callable, Mapping
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Protocol, TypeVar, runtime_checkable
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class CursorStore(Protocol):
|
|
23
|
+
async def read(self, key: str) -> int: ...
|
|
24
|
+
async def write(self, key: str, cursor: int) -> None: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MemoryCursorStore:
|
|
28
|
+
"""Process-local cursor storage with monotonic writes."""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._values: dict[str, int] = {}
|
|
32
|
+
self._lock = asyncio.Lock()
|
|
33
|
+
|
|
34
|
+
async def read(self, key: str) -> int:
|
|
35
|
+
async with self._lock:
|
|
36
|
+
return self._values.get(key, 0)
|
|
37
|
+
|
|
38
|
+
async def write(self, key: str, cursor: int) -> None:
|
|
39
|
+
_validate_cursor(key, cursor)
|
|
40
|
+
async with self._lock:
|
|
41
|
+
self._values[key] = max(cursor, self._values.get(key, 0))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _OperationCancelled(Exception):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class FileCursorStore:
|
|
49
|
+
"""POSIX cursor file protected by a separate advisory lock file."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, path: str | os.PathLike[str], *, lock_timeout: float = 5.0) -> None:
|
|
52
|
+
if lock_timeout <= 0:
|
|
53
|
+
raise ValueError("lock_timeout must be positive")
|
|
54
|
+
self.path = Path(path)
|
|
55
|
+
self.lock_timeout = lock_timeout
|
|
56
|
+
self._lock_path = self.path.with_name(self.path.name + ".lock")
|
|
57
|
+
|
|
58
|
+
async def read(self, key: str) -> int:
|
|
59
|
+
if not key:
|
|
60
|
+
raise ValueError("cursor key must not be empty")
|
|
61
|
+
cancel = threading.Event()
|
|
62
|
+
return await self._run_worker(self._read_sync, key, cancel)
|
|
63
|
+
|
|
64
|
+
async def write(self, key: str, cursor: int) -> None:
|
|
65
|
+
_validate_cursor(key, cursor)
|
|
66
|
+
cancel = threading.Event()
|
|
67
|
+
await self._run_worker(self._write_sync, key, cursor, cancel)
|
|
68
|
+
|
|
69
|
+
async def _run_worker(self, function: Callable[..., T], *args: object) -> T:
|
|
70
|
+
cancel = args[-1]
|
|
71
|
+
assert isinstance(cancel, threading.Event)
|
|
72
|
+
worker: asyncio.Task[T] = asyncio.create_task(asyncio.to_thread(function, *args))
|
|
73
|
+
try:
|
|
74
|
+
return await asyncio.shield(worker)
|
|
75
|
+
except asyncio.CancelledError:
|
|
76
|
+
cancel.set()
|
|
77
|
+
with contextlib.suppress(_OperationCancelled):
|
|
78
|
+
await asyncio.shield(worker)
|
|
79
|
+
raise
|
|
80
|
+
|
|
81
|
+
def _read_sync(self, key: str, cancel: threading.Event) -> int:
|
|
82
|
+
self._ensure_secure_parent()
|
|
83
|
+
lock_fd = self._acquire_lock(fcntl.LOCK_SH, cancel)
|
|
84
|
+
try:
|
|
85
|
+
self._validate_existing_file(self.path)
|
|
86
|
+
values = self._read_values()
|
|
87
|
+
return values.get(key, 0)
|
|
88
|
+
finally:
|
|
89
|
+
self._release_lock(lock_fd)
|
|
90
|
+
|
|
91
|
+
def _write_sync(self, key: str, cursor: int, cancel: threading.Event) -> None:
|
|
92
|
+
self._ensure_secure_parent()
|
|
93
|
+
lock_fd = self._acquire_lock(fcntl.LOCK_EX, cancel)
|
|
94
|
+
temp_path: Path | None = None
|
|
95
|
+
try:
|
|
96
|
+
self._validate_existing_file(self.path)
|
|
97
|
+
values = self._read_values()
|
|
98
|
+
if cursor <= values.get(key, 0):
|
|
99
|
+
return
|
|
100
|
+
values[key] = cursor
|
|
101
|
+
encoded = (json.dumps(values, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
|
102
|
+
temp_path = self.path.with_name(f".{self.path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
|
103
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
104
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
105
|
+
flags |= os.O_NOFOLLOW
|
|
106
|
+
fd = os.open(temp_path, flags, 0o600)
|
|
107
|
+
try:
|
|
108
|
+
os.fchmod(fd, 0o600)
|
|
109
|
+
view = memoryview(encoded)
|
|
110
|
+
while view:
|
|
111
|
+
written = os.write(fd, view)
|
|
112
|
+
view = view[written:]
|
|
113
|
+
os.fsync(fd)
|
|
114
|
+
finally:
|
|
115
|
+
os.close(fd)
|
|
116
|
+
if cancel.is_set():
|
|
117
|
+
raise _OperationCancelled
|
|
118
|
+
|
|
119
|
+
# Once rename starts, the worker must finish directory fsync. The
|
|
120
|
+
# async wrapper shields and awaits this old-or-new durable commit.
|
|
121
|
+
os.replace(temp_path, self.path)
|
|
122
|
+
temp_path = None
|
|
123
|
+
directory_fd = os.open(self.path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
124
|
+
try:
|
|
125
|
+
os.fsync(directory_fd)
|
|
126
|
+
finally:
|
|
127
|
+
os.close(directory_fd)
|
|
128
|
+
finally:
|
|
129
|
+
if temp_path is not None:
|
|
130
|
+
with contextlib.suppress(OSError):
|
|
131
|
+
temp_path.unlink()
|
|
132
|
+
self._release_lock(lock_fd)
|
|
133
|
+
|
|
134
|
+
def _ensure_secure_parent(self) -> None:
|
|
135
|
+
parent = self.path.parent
|
|
136
|
+
try:
|
|
137
|
+
info = parent.lstat()
|
|
138
|
+
except FileNotFoundError:
|
|
139
|
+
try:
|
|
140
|
+
parent.mkdir(mode=0o700, parents=True, exist_ok=False)
|
|
141
|
+
except FileExistsError:
|
|
142
|
+
# Another process may have created the same parent after our
|
|
143
|
+
# lstat. Re-read it below and apply the full ownership, mode,
|
|
144
|
+
# symlink, and directory checks before using it.
|
|
145
|
+
pass
|
|
146
|
+
info = parent.lstat()
|
|
147
|
+
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
|
|
148
|
+
raise OSError(f"cursor directory is not a real directory: {parent}")
|
|
149
|
+
if info.st_uid != os.geteuid():
|
|
150
|
+
raise PermissionError(f"cursor directory is not owned by the current user: {parent}")
|
|
151
|
+
if stat.S_IMODE(info.st_mode) & 0o077:
|
|
152
|
+
raise PermissionError(f"cursor directory permissions must be 0700: {parent}")
|
|
153
|
+
|
|
154
|
+
def _validate_existing_file(self, path: Path) -> None:
|
|
155
|
+
try:
|
|
156
|
+
info = path.lstat()
|
|
157
|
+
except FileNotFoundError:
|
|
158
|
+
return
|
|
159
|
+
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
|
|
160
|
+
raise OSError(f"cursor path is not a regular file: {path}")
|
|
161
|
+
if info.st_uid != os.geteuid():
|
|
162
|
+
raise PermissionError(f"cursor file is not owned by the current user: {path}")
|
|
163
|
+
if stat.S_IMODE(info.st_mode) & 0o077:
|
|
164
|
+
raise PermissionError(f"cursor file permissions must not grant group or other access: {path}")
|
|
165
|
+
|
|
166
|
+
def _acquire_lock(self, operation: int, cancel: threading.Event) -> int:
|
|
167
|
+
self._validate_existing_file(self._lock_path)
|
|
168
|
+
flags = os.O_RDWR | os.O_CREAT
|
|
169
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
170
|
+
flags |= os.O_NOFOLLOW
|
|
171
|
+
fd = os.open(self._lock_path, flags, 0o600)
|
|
172
|
+
try:
|
|
173
|
+
os.fchmod(fd, 0o600)
|
|
174
|
+
deadline = time.monotonic() + self.lock_timeout
|
|
175
|
+
while True:
|
|
176
|
+
if cancel.is_set():
|
|
177
|
+
raise _OperationCancelled
|
|
178
|
+
try:
|
|
179
|
+
fcntl.flock(fd, operation | fcntl.LOCK_NB)
|
|
180
|
+
return fd
|
|
181
|
+
except BlockingIOError:
|
|
182
|
+
if time.monotonic() >= deadline:
|
|
183
|
+
raise TimeoutError(f"cursor lock timed out after {self.lock_timeout:.3f}s") from None
|
|
184
|
+
cancel.wait(min(0.01, max(0.0, deadline - time.monotonic())))
|
|
185
|
+
except BaseException:
|
|
186
|
+
os.close(fd)
|
|
187
|
+
raise
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def _release_lock(fd: int) -> None:
|
|
191
|
+
with contextlib.suppress(OSError):
|
|
192
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
193
|
+
os.close(fd)
|
|
194
|
+
|
|
195
|
+
def _read_values(self) -> dict[str, int]:
|
|
196
|
+
try:
|
|
197
|
+
raw = self.path.read_text(encoding="utf-8")
|
|
198
|
+
except FileNotFoundError:
|
|
199
|
+
return {}
|
|
200
|
+
value = json.loads(raw)
|
|
201
|
+
if not isinstance(value, Mapping):
|
|
202
|
+
raise ValueError("cursor file must contain a JSON object")
|
|
203
|
+
result: dict[str, int] = {}
|
|
204
|
+
for key, cursor in value.items():
|
|
205
|
+
if (
|
|
206
|
+
not isinstance(key, str)
|
|
207
|
+
or not isinstance(cursor, int)
|
|
208
|
+
or isinstance(cursor, bool)
|
|
209
|
+
or cursor < 0
|
|
210
|
+
):
|
|
211
|
+
raise ValueError("cursor file contains an invalid entry")
|
|
212
|
+
result[key] = cursor
|
|
213
|
+
return result
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _validate_cursor(key: str, cursor: int) -> None:
|
|
217
|
+
if not key:
|
|
218
|
+
raise ValueError("cursor key must not be empty")
|
|
219
|
+
if not isinstance(cursor, int) or isinstance(cursor, bool) or cursor < 0:
|
|
220
|
+
raise ValueError("cursor must be a non-negative integer")
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Public SDK exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BorgeeError(Exception):
|
|
7
|
+
"""Base SDK error with a stable machine-readable code."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, code: str = "borgee.error") -> None:
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.code = code
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PermissionDeniedError(BorgeeError):
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
message: str = "permission denied",
|
|
18
|
+
*,
|
|
19
|
+
attempted_action: str = "",
|
|
20
|
+
required_capability: str = "",
|
|
21
|
+
current_scope: str = "",
|
|
22
|
+
) -> None:
|
|
23
|
+
super().__init__(message, "bpp.permission_denied")
|
|
24
|
+
self.attempted_action = attempted_action
|
|
25
|
+
self.required_capability = required_capability
|
|
26
|
+
self.current_scope = current_scope
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NotOnlineError(BorgeeError):
|
|
30
|
+
def __init__(self, message: str = "plugin is not online") -> None:
|
|
31
|
+
super().__init__(message, "bpp.not_online")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ProtocolError(BorgeeError):
|
|
35
|
+
def __init__(self, message: str, code: str = "bpp.protocol_error") -> None:
|
|
36
|
+
super().__init__(message, code)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ProtocolCapacityError(ProtocolError):
|
|
40
|
+
def __init__(self, message: str = "protocol capacity exceeded") -> None:
|
|
41
|
+
super().__init__(message, "bpp.protocol_capacity")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class StaleDeliveryError(BorgeeError):
|
|
45
|
+
def __init__(self, message: str = "delivery belongs to a stale connection") -> None:
|
|
46
|
+
super().__init__(message, "bpp.stale_delivery")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ResumeAckError(ProtocolError):
|
|
50
|
+
def __init__(self, message: str, code: str = "bpp.resume_ack_invalid") -> None:
|
|
51
|
+
super().__init__(message, code)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PreResumeError(ProtocolError):
|
|
55
|
+
def __init__(self, message: str, code: str = "bpp.pre_resume_failed") -> None:
|
|
56
|
+
super().__init__(message, code)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class CursorStoreError(BorgeeError):
|
|
60
|
+
def __init__(self, message: str, code: str = "bpp.cursor_store_failed") -> None:
|
|
61
|
+
super().__init__(message, code)
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Public domain models for the Python plugin SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import Any, Protocol, TypeAlias, runtime_checkable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _Missing:
|
|
12
|
+
__slots__ = ()
|
|
13
|
+
|
|
14
|
+
def __repr__(self) -> str:
|
|
15
|
+
return "MISSING"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
MISSING = _Missing()
|
|
19
|
+
MissingType: TypeAlias = _Missing
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ReplayMode(StrEnum):
|
|
23
|
+
FULL = "full"
|
|
24
|
+
SUMMARY = "summary"
|
|
25
|
+
LATEST_N = "latest_n"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConnectionStatus(StrEnum):
|
|
29
|
+
CONNECTING = "connecting"
|
|
30
|
+
RESUMING = "resuming"
|
|
31
|
+
ONLINE = "online"
|
|
32
|
+
RECONNECTING = "reconnecting"
|
|
33
|
+
ERROR = "error"
|
|
34
|
+
CLOSED = "closed"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class UserRole(StrEnum):
|
|
38
|
+
MEMBER = "member"
|
|
39
|
+
AGENT = "agent"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class InboundKind(StrEnum):
|
|
43
|
+
MESSAGE = "message"
|
|
44
|
+
EDITED = "edited"
|
|
45
|
+
DELETED = "deleted"
|
|
46
|
+
REACTION = "reaction"
|
|
47
|
+
MENTION = "mention"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TaskOutcome(StrEnum):
|
|
51
|
+
COMPLETED = "completed"
|
|
52
|
+
FAILED = "failed"
|
|
53
|
+
CANCELLED = "cancelled"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class FaultReason(StrEnum):
|
|
57
|
+
API_KEY_INVALID = "api_key_invalid"
|
|
58
|
+
QUOTA_EXCEEDED = "quota_exceeded"
|
|
59
|
+
NETWORK_UNREACHABLE = "network_unreachable"
|
|
60
|
+
RUNTIME_CRASHED = "runtime_crashed"
|
|
61
|
+
RUNTIME_TIMEOUT = "runtime_timeout"
|
|
62
|
+
UNKNOWN = "unknown"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ConfigApplyStatus(StrEnum):
|
|
66
|
+
APPLIED = "applied"
|
|
67
|
+
REJECTED = "rejected"
|
|
68
|
+
STALE = "stale"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class ConnectionState:
|
|
73
|
+
status: ConnectionStatus
|
|
74
|
+
attempt: int = 0
|
|
75
|
+
reason: FaultReason | None = None
|
|
76
|
+
code: str | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class User:
|
|
81
|
+
id: str
|
|
82
|
+
display_name: str
|
|
83
|
+
role: str
|
|
84
|
+
permissions: tuple[str, ...]
|
|
85
|
+
avatar_url: str | None = None
|
|
86
|
+
email: str | None = None
|
|
87
|
+
created_at: int | None = None
|
|
88
|
+
last_seen_at: int | None = None
|
|
89
|
+
require_mention: bool | None = None
|
|
90
|
+
owner_id: str | None = None
|
|
91
|
+
deleted_at: int | None = None
|
|
92
|
+
disabled: bool | None = None
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def kind(self) -> str:
|
|
96
|
+
return "agent" if self.role == UserRole.AGENT.value else "user"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True)
|
|
100
|
+
class Message:
|
|
101
|
+
id: str
|
|
102
|
+
channel_id: str
|
|
103
|
+
author_id: str
|
|
104
|
+
body: str
|
|
105
|
+
created_at: int
|
|
106
|
+
content_type: str | None = None
|
|
107
|
+
reply_to_id: str | None = None
|
|
108
|
+
edited_at: int | None = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True, slots=True)
|
|
112
|
+
class SentMessage:
|
|
113
|
+
message_id: str
|
|
114
|
+
cursor: int
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True, slots=True)
|
|
118
|
+
class CreatedDM:
|
|
119
|
+
channel_id: str
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True, slots=True)
|
|
123
|
+
class ResponseFrame:
|
|
124
|
+
"""Presence-sensitive plugin response envelope."""
|
|
125
|
+
|
|
126
|
+
id: str
|
|
127
|
+
data: Any | MissingType = field(default=MISSING)
|
|
128
|
+
error: str | MissingType = field(default=MISSING)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def has_data(self) -> bool:
|
|
132
|
+
return self.data is not MISSING
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def has_error(self) -> bool:
|
|
136
|
+
return self.error is not MISSING
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def from_wire(cls, value: Any) -> ResponseFrame:
|
|
140
|
+
if not isinstance(value, dict) or not isinstance(value.get("id"), str):
|
|
141
|
+
raise TypeError("response frame must contain a string id")
|
|
142
|
+
has_data = "data" in value
|
|
143
|
+
has_error = "error" in value
|
|
144
|
+
if has_data == has_error:
|
|
145
|
+
raise ValueError("response frame must contain exactly one of data or error")
|
|
146
|
+
if has_error and (not isinstance(value["error"], str) or not value["error"]):
|
|
147
|
+
raise TypeError("response error must be a non-empty string")
|
|
148
|
+
return cls(
|
|
149
|
+
id=value["id"],
|
|
150
|
+
data=value["data"] if has_data else MISSING,
|
|
151
|
+
error=value["error"] if has_error else MISSING,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True, slots=True)
|
|
156
|
+
class InboundReaction:
|
|
157
|
+
emoji: str
|
|
158
|
+
user_id: str
|
|
159
|
+
added: bool
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True, slots=True)
|
|
163
|
+
class InboundMessageEvent:
|
|
164
|
+
kind: InboundKind
|
|
165
|
+
cursor: int
|
|
166
|
+
channel_id: str
|
|
167
|
+
created_at: int
|
|
168
|
+
channel_type: str | None = None
|
|
169
|
+
message: Message | None = None
|
|
170
|
+
message_id: str | None = None
|
|
171
|
+
reaction: InboundReaction | None = None
|
|
172
|
+
author_name: str | None = None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass(frozen=True, slots=True)
|
|
176
|
+
class ReplaySummaryEvent:
|
|
177
|
+
cursor: int
|
|
178
|
+
missed_count: int
|
|
179
|
+
since_cursor: int
|
|
180
|
+
through_cursor: int
|
|
181
|
+
summary: str
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
DeliveryEvent: TypeAlias = InboundMessageEvent | ReplaySummaryEvent
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True, slots=True)
|
|
188
|
+
class ConfigUpdate:
|
|
189
|
+
agent_id: str
|
|
190
|
+
schema_version: int
|
|
191
|
+
blob: Any
|
|
192
|
+
idempotency_key: str
|
|
193
|
+
cursor: int
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True, slots=True)
|
|
197
|
+
class ConfigApplyResult:
|
|
198
|
+
status: ConfigApplyStatus
|
|
199
|
+
reason: FaultReason | None = None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(slots=True)
|
|
203
|
+
class ConfigApplyContext:
|
|
204
|
+
cancelled: asyncio.Event
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True, slots=True)
|
|
208
|
+
class ServerRequest:
|
|
209
|
+
action: str
|
|
210
|
+
params: Any
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@runtime_checkable
|
|
214
|
+
class Logger(Protocol):
|
|
215
|
+
def debug(self, message: str, *args: Any) -> None: ...
|
|
216
|
+
def info(self, message: str, *args: Any) -> None: ...
|
|
217
|
+
def warning(self, message: str, *args: Any) -> None: ...
|
|
218
|
+
def error(self, message: str, *args: Any) -> None: ...
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@dataclass(frozen=True, slots=True)
|
|
222
|
+
class Task:
|
|
223
|
+
id: str
|
|
224
|
+
channel_id: str
|
|
225
|
+
guild_id: str
|
|
226
|
+
seq: int
|
|
227
|
+
title: str
|
|
228
|
+
description: str
|
|
229
|
+
status: str
|
|
230
|
+
assignee_id: str | None
|
|
231
|
+
created_by: str
|
|
232
|
+
message_id: str | None
|
|
233
|
+
thread_id: str | None
|
|
234
|
+
created_at: int
|
|
235
|
+
updated_at: int
|
|
236
|
+
heartbeat_prompt: str
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def task_from_wire(value: Any) -> Task:
|
|
240
|
+
"""Deserialize a task wire dict into a Task dataclass."""
|
|
241
|
+
if not isinstance(value, dict):
|
|
242
|
+
raise TypeError("task result must be an object")
|
|
243
|
+
task_id = value.get("id")
|
|
244
|
+
if not isinstance(task_id, str) or not task_id:
|
|
245
|
+
raise TypeError("task id must be a non-empty string")
|
|
246
|
+
return Task(
|
|
247
|
+
id=task_id,
|
|
248
|
+
channel_id=value.get("channel_id", ""),
|
|
249
|
+
guild_id=value.get("guild_id", ""),
|
|
250
|
+
seq=value.get("seq", 0),
|
|
251
|
+
title=value.get("title", ""),
|
|
252
|
+
description=value.get("description", ""),
|
|
253
|
+
status=value.get("status", ""),
|
|
254
|
+
assignee_id=value.get("assignee_id"),
|
|
255
|
+
created_by=value.get("created_by", ""),
|
|
256
|
+
message_id=value.get("message_id"),
|
|
257
|
+
thread_id=value.get("thread_id"),
|
|
258
|
+
created_at=value.get("created_at", 0),
|
|
259
|
+
updated_at=value.get("updated_at", 0),
|
|
260
|
+
heartbeat_prompt=value.get("heartbeat_prompt", ""),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def user_from_wire(value: Any) -> User:
|
|
265
|
+
if not isinstance(value, dict):
|
|
266
|
+
raise TypeError("get_me result must be an object")
|
|
267
|
+
required = ("id", "display_name", "role", "permissions")
|
|
268
|
+
if any(key not in value for key in required):
|
|
269
|
+
raise TypeError("get_me result is missing a required user field")
|
|
270
|
+
user_id = _required_non_empty_string(value["id"], "id")
|
|
271
|
+
display_name = _required_non_empty_string(value["display_name"], "display_name")
|
|
272
|
+
role = _required_non_empty_string(value["role"], "role")
|
|
273
|
+
if role not in {item.value for item in UserRole}:
|
|
274
|
+
raise TypeError("user role must be member or agent")
|
|
275
|
+
permissions = value["permissions"]
|
|
276
|
+
if not isinstance(permissions, list):
|
|
277
|
+
raise TypeError("user permissions must be a string list")
|
|
278
|
+
if not all(isinstance(item, str) and bool(item.strip()) for item in permissions):
|
|
279
|
+
raise TypeError("user permissions must contain only non-empty strings")
|
|
280
|
+
return User(
|
|
281
|
+
id=user_id,
|
|
282
|
+
display_name=display_name,
|
|
283
|
+
role=role,
|
|
284
|
+
permissions=tuple(permissions),
|
|
285
|
+
avatar_url=_optional_string_field(value, "avatar_url"),
|
|
286
|
+
email=_optional_string_field(value, "email"),
|
|
287
|
+
created_at=_optional_integer_field(value, "created_at"),
|
|
288
|
+
last_seen_at=_optional_integer_field(value, "last_seen_at"),
|
|
289
|
+
require_mention=_optional_boolean_field(value, "require_mention"),
|
|
290
|
+
owner_id=_optional_string_field(value, "owner_id"),
|
|
291
|
+
deleted_at=_optional_integer_field(value, "deleted_at"),
|
|
292
|
+
disabled=_optional_boolean_field(value, "disabled"),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _required_non_empty_string(value: Any, field_name: str) -> str:
|
|
297
|
+
if not isinstance(value, str) or not value.strip():
|
|
298
|
+
raise TypeError(f"user {field_name} must be a non-empty string")
|
|
299
|
+
return value
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _optional_string_field(value: dict[Any, Any], field_name: str) -> str | None:
|
|
303
|
+
field = value.get(field_name)
|
|
304
|
+
if field is None:
|
|
305
|
+
return None
|
|
306
|
+
if not isinstance(field, str):
|
|
307
|
+
raise TypeError(f"user {field_name} must be a string or null")
|
|
308
|
+
return field
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _optional_integer_field(value: dict[Any, Any], field_name: str) -> int | None:
|
|
312
|
+
field = value.get(field_name)
|
|
313
|
+
if field is None:
|
|
314
|
+
return None
|
|
315
|
+
if not isinstance(field, int) or isinstance(field, bool) or field < 0:
|
|
316
|
+
raise TypeError(f"user {field_name} must be a non-negative integer or null")
|
|
317
|
+
return field
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _optional_boolean_field(value: dict[Any, Any], field_name: str) -> bool | None:
|
|
321
|
+
field = value.get(field_name)
|
|
322
|
+
if field is None:
|
|
323
|
+
return None
|
|
324
|
+
if not isinstance(field, bool):
|
|
325
|
+
raise TypeError(f"user {field_name} must be a boolean or null")
|
|
326
|
+
return field
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Testing helpers. Internal transports are intentionally absent from the root API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..client import BorgeePluginClient, BorgeePluginOptions
|
|
6
|
+
from .fake_transport import FakeDelivery, FakeTransport, RecordedAction
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_test_client(
|
|
10
|
+
options: BorgeePluginOptions,
|
|
11
|
+
transport: FakeTransport,
|
|
12
|
+
) -> BorgeePluginClient:
|
|
13
|
+
return BorgeePluginClient(options, transport) # type: ignore[arg-type]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__all__ = ["FakeDelivery", "FakeTransport", "RecordedAction", "create_test_client"]
|