voiceroute.py 0.1.0.dev0__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.
voiceroute/__init__.py ADDED
@@ -0,0 +1,149 @@
1
+ """Async Python SDK and bot framework for VoiceRoute."""
2
+
3
+ from voiceroute.bot import Bot
4
+ from voiceroute.client import VoiceRouteClient
5
+ from voiceroute.commands import (
6
+ BucketType,
7
+ Check,
8
+ Command,
9
+ CommandInvocation,
10
+ CommandParameter,
11
+ CommandPipeline,
12
+ CommandRegistry,
13
+ ConcurrencyLimit,
14
+ Context,
15
+ Cooldown,
16
+ Middleware,
17
+ NextMiddleware,
18
+ PrefixCommandAdapter,
19
+ check,
20
+ cooldown,
21
+ max_concurrency,
22
+ )
23
+ from voiceroute.errors import (
24
+ BadArgument,
25
+ BadRequest,
26
+ BotAlreadyRunning,
27
+ BotClosed,
28
+ CheckFailure,
29
+ CommandError,
30
+ CommandNotFound,
31
+ CommandOnCooldown,
32
+ CommandParsingError,
33
+ Conflict,
34
+ CursorStoreError,
35
+ ExtensionAlreadyLoaded,
36
+ ExtensionError,
37
+ ExtensionNotLoaded,
38
+ ExtensionSetupError,
39
+ Forbidden,
40
+ HTTPError,
41
+ MaxConcurrencyReached,
42
+ MissingRequiredArgument,
43
+ NoChannelsAvailable,
44
+ NotFound,
45
+ RateLimited,
46
+ ServiceUnavailable,
47
+ SSEError,
48
+ TooManyArguments,
49
+ Unauthorized,
50
+ UnsupportedCommandParameter,
51
+ ValidationError,
52
+ VoiceRouteError,
53
+ )
54
+ from voiceroute.events import (
55
+ AccessLost,
56
+ BotEvent,
57
+ CursorStore,
58
+ EventDelivery,
59
+ EventSource,
60
+ EventTransport,
61
+ FileCursorStore,
62
+ LegacySSETransport,
63
+ MemoryCursorStore,
64
+ MessageUpserted,
65
+ RedisCursorStore,
66
+ SQLiteCursorStore,
67
+ StreamCheckpoint,
68
+ )
69
+ from voiceroute.extensions import Extension, ExtensionCommand, command, listener
70
+ from voiceroute.models import BotIdentity, Channel, Community, Message, MessagePage, User
71
+ from voiceroute.runtime import RuntimeState
72
+ from voiceroute.transport import RateLimiter
73
+
74
+ __all__ = [
75
+ "AccessLost",
76
+ "BadArgument",
77
+ "BadRequest",
78
+ "Bot",
79
+ "BotAlreadyRunning",
80
+ "BotClosed",
81
+ "BotEvent",
82
+ "BotIdentity",
83
+ "BucketType",
84
+ "Channel",
85
+ "Check",
86
+ "CheckFailure",
87
+ "Command",
88
+ "CommandError",
89
+ "CommandInvocation",
90
+ "CommandNotFound",
91
+ "CommandOnCooldown",
92
+ "CommandParameter",
93
+ "CommandParsingError",
94
+ "CommandPipeline",
95
+ "CommandRegistry",
96
+ "Community",
97
+ "ConcurrencyLimit",
98
+ "Conflict",
99
+ "Context",
100
+ "Cooldown",
101
+ "CursorStore",
102
+ "CursorStoreError",
103
+ "EventDelivery",
104
+ "EventSource",
105
+ "EventTransport",
106
+ "Extension",
107
+ "ExtensionAlreadyLoaded",
108
+ "ExtensionCommand",
109
+ "ExtensionError",
110
+ "ExtensionNotLoaded",
111
+ "ExtensionSetupError",
112
+ "FileCursorStore",
113
+ "Forbidden",
114
+ "HTTPError",
115
+ "LegacySSETransport",
116
+ "MaxConcurrencyReached",
117
+ "MemoryCursorStore",
118
+ "Message",
119
+ "MessagePage",
120
+ "MessageUpserted",
121
+ "Middleware",
122
+ "MissingRequiredArgument",
123
+ "NextMiddleware",
124
+ "NoChannelsAvailable",
125
+ "NotFound",
126
+ "PrefixCommandAdapter",
127
+ "RateLimited",
128
+ "RateLimiter",
129
+ "RedisCursorStore",
130
+ "RuntimeState",
131
+ "SQLiteCursorStore",
132
+ "SSEError",
133
+ "ServiceUnavailable",
134
+ "StreamCheckpoint",
135
+ "TooManyArguments",
136
+ "Unauthorized",
137
+ "UnsupportedCommandParameter",
138
+ "User",
139
+ "ValidationError",
140
+ "VoiceRouteClient",
141
+ "VoiceRouteError",
142
+ "check",
143
+ "command",
144
+ "cooldown",
145
+ "listener",
146
+ "max_concurrency",
147
+ ]
148
+
149
+ __version__ = "0.1.0.dev0"
@@ -0,0 +1,8 @@
1
+ """Domain-specific VoiceRoute API clients."""
2
+
3
+ from voiceroute.api.bots import BotsAPI
4
+ from voiceroute.api.communities import CommunitiesAPI
5
+ from voiceroute.api.events import EventsAPI
6
+ from voiceroute.api.messages import MessagesAPI
7
+
8
+ __all__ = ["BotsAPI", "CommunitiesAPI", "EventsAPI", "MessagesAPI"]
voiceroute/api/bots.py ADDED
@@ -0,0 +1,40 @@
1
+ """Bot profile endpoints."""
2
+
3
+ from voiceroute.models import BotIdentity, User
4
+ from voiceroute.transport import HTTPTransport
5
+
6
+
7
+ class BotsAPI:
8
+ """Operations for the currently authenticated bot."""
9
+
10
+ def __init__(self, transport: HTTPTransport) -> None:
11
+ self._transport = transport
12
+
13
+ async def get_current(self) -> BotIdentity:
14
+ payload = await self._transport.request("GET", "/api/bots/@me")
15
+ if payload is None:
16
+ raise RuntimeError("VoiceRoute returned no bot identity")
17
+ return BotIdentity.from_dict(payload)
18
+
19
+ async def update_current(
20
+ self,
21
+ *,
22
+ display_name: str | None = None,
23
+ bio: str | None = None,
24
+ custom_status: str | None = None,
25
+ accent_color: str | None = None,
26
+ ) -> User:
27
+ data = {
28
+ key: value
29
+ for key, value in {
30
+ "displayName": display_name,
31
+ "bio": bio,
32
+ "customStatus": custom_status,
33
+ "accentColor": accent_color,
34
+ }.items()
35
+ if value is not None
36
+ }
37
+ payload = await self._transport.request("PATCH", "/api/bots/@me", json=data)
38
+ if payload is None:
39
+ raise RuntimeError("VoiceRoute returned no updated bot profile")
40
+ return User.from_dict(payload["user"])
@@ -0,0 +1,36 @@
1
+ """Community and channel endpoints available to bots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import builtins
6
+
7
+ from voiceroute.models import Channel, Community
8
+ from voiceroute.transport import HTTPTransport
9
+
10
+
11
+ class CommunitiesAPI:
12
+ """Read communities and their bot-visible channels."""
13
+
14
+ def __init__(self, transport: HTTPTransport) -> None:
15
+ self._transport = transport
16
+
17
+ async def list(self) -> list[Community]:
18
+ payload = await self._transport.request("GET", "/api/communities")
19
+ if payload is None:
20
+ return []
21
+ return [Community.from_dict(item) for item in payload.get("items", [])]
22
+
23
+ async def get(self, community_id: str) -> Community:
24
+ payload = await self._transport.request("GET", f"/api/communities/{community_id}")
25
+ if payload is None:
26
+ raise RuntimeError("VoiceRoute returned no community")
27
+ data = payload.get("community", payload)
28
+ return Community.from_dict(data)
29
+
30
+ async def list_channels(self, community_id: str) -> builtins.list[Channel]:
31
+ payload = await self._transport.request("GET", f"/api/communities/{community_id}/channels")
32
+ if payload is None:
33
+ return []
34
+ return [
35
+ Channel.from_dict(item, community_id=community_id) for item in payload.get("items", [])
36
+ ]
@@ -0,0 +1,86 @@
1
+ """Resumable bot event streams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from collections.abc import AsyncIterator
8
+
9
+ from voiceroute.errors import RateLimited, ServiceUnavailable, SSEError, TransportError
10
+ from voiceroute.events.models import AccessLost, BotEvent, MessageUpserted
11
+ from voiceroute.events.sse import SSEDecoder
12
+ from voiceroute.models import Message
13
+ from voiceroute.transport import HTTPTransport
14
+
15
+
16
+ class EventsAPI:
17
+ """Consume authenticated channel message events with cursor recovery."""
18
+
19
+ def __init__(self, transport: HTTPTransport) -> None:
20
+ self._transport = transport
21
+
22
+ async def listen_messages(
23
+ self,
24
+ community_id: str,
25
+ channel_id: str,
26
+ *,
27
+ after_seq: str | None = None,
28
+ cursor: str | None = None,
29
+ reconnect: bool = True,
30
+ max_backoff: float = 30.0,
31
+ ) -> AsyncIterator[BotEvent]:
32
+ if max_backoff <= 0:
33
+ raise ValueError("max_backoff must be positive")
34
+
35
+ last_cursor = cursor
36
+ backoff = 1.0
37
+ path = f"/api/communities/{community_id}/channels/{channel_id}/events"
38
+
39
+ while True:
40
+ params = {
41
+ "after": after_seq if last_cursor is None else None,
42
+ "changeCursor": last_cursor,
43
+ }
44
+ headers = {"Last-Event-ID": last_cursor} if last_cursor is not None else None
45
+ decoder = SSEDecoder()
46
+
47
+ retry_delay: float | None = None
48
+ try:
49
+ async with self._transport.stream(path, params=params, headers=headers) as response:
50
+ async for line in response.aiter_lines():
51
+ frame = decoder.feed_line(line)
52
+ if frame is None:
53
+ continue
54
+ if frame.id is not None:
55
+ last_cursor = frame.id
56
+ backoff = 1.0
57
+
58
+ event = self._decode(frame.event, frame.data, last_cursor)
59
+ yield event
60
+ if isinstance(event, AccessLost):
61
+ return
62
+ except RateLimited as exc:
63
+ retry_delay = exc.retry_after
64
+ except (ServiceUnavailable, TransportError):
65
+ pass
66
+
67
+ if not reconnect:
68
+ return
69
+ await asyncio.sleep(retry_delay if retry_delay is not None else backoff)
70
+ backoff = min(backoff * 2, max_backoff)
71
+
72
+ @staticmethod
73
+ def _decode(event_name: str, data: str, cursor: str | None) -> BotEvent:
74
+ try:
75
+ payload = json.loads(data)
76
+ except json.JSONDecodeError as exc:
77
+ raise SSEError("VoiceRoute SSE event contains invalid JSON") from exc
78
+
79
+ if event_name == "message":
80
+ if not isinstance(payload, dict):
81
+ raise SSEError("VoiceRoute message event must contain a JSON object")
82
+ return MessageUpserted(Message.from_dict(payload), cursor)
83
+ if event_name == "access-lost":
84
+ reason = payload.get("reason") if isinstance(payload, dict) else None
85
+ return AccessLost(cursor=cursor, reason=str(reason) if reason is not None else None)
86
+ raise SSEError(f"unsupported VoiceRoute SSE event: {event_name}")
@@ -0,0 +1,215 @@
1
+ """Community text message endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from collections.abc import Sequence
7
+
8
+ from voiceroute.models import Message, MessagePage
9
+ from voiceroute.transport import HTTPTransport
10
+
11
+
12
+ class MessagesAPI:
13
+ """Read and send messages in bot-visible text channels."""
14
+
15
+ def __init__(self, transport: HTTPTransport) -> None:
16
+ self._transport = transport
17
+
18
+ async def history(
19
+ self,
20
+ community_id: str,
21
+ channel_id: str,
22
+ *,
23
+ limit: int = 50,
24
+ before_seq: str | None = None,
25
+ after_seq: str | None = None,
26
+ ) -> MessagePage:
27
+ _validate_limit(limit)
28
+ if before_seq is not None and after_seq is not None:
29
+ raise ValueError("before_seq and after_seq cannot be used together")
30
+
31
+ params: dict[str, str | int | None] = {
32
+ "limit": limit,
33
+ "beforeSeq": before_seq,
34
+ "afterSeq": after_seq,
35
+ }
36
+ payload = await self._transport.request(
37
+ "GET",
38
+ self._messages_path(community_id, channel_id),
39
+ params=params,
40
+ )
41
+ if payload is None:
42
+ return MessagePage(())
43
+ return _message_page(payload)
44
+
45
+ async def send(
46
+ self,
47
+ community_id: str,
48
+ channel_id: str,
49
+ body: str = "",
50
+ *,
51
+ reply_to_seq: str | None = None,
52
+ attachment_ids: Sequence[str] = (),
53
+ sticker_ids: Sequence[str] = (),
54
+ client_message_id: str | uuid.UUID | None = None,
55
+ ) -> Message:
56
+ if len(body) > 4000:
57
+ raise ValueError("message body cannot exceed 4000 characters")
58
+ if len(attachment_ids) > 4:
59
+ raise ValueError("a message cannot contain more than four attachments")
60
+ if not body and not attachment_ids and not sticker_ids:
61
+ raise ValueError("a message must contain text, an attachment, or a sticker")
62
+
63
+ operation_id = str(client_message_id or uuid.uuid4())
64
+ payload = await self._transport.request(
65
+ "POST",
66
+ self._messages_path(community_id, channel_id),
67
+ json={
68
+ "clientMessageId": operation_id,
69
+ "body": body,
70
+ "replyToSeq": reply_to_seq,
71
+ "attachmentIds": list(attachment_ids),
72
+ "stickerIds": list(sticker_ids),
73
+ },
74
+ retryable=True,
75
+ )
76
+ if payload is None:
77
+ raise RuntimeError("VoiceRoute returned no sent message")
78
+ return Message.from_dict(payload["message"])
79
+
80
+ async def edit(
81
+ self,
82
+ community_id: str,
83
+ channel_id: str,
84
+ message_seq: str,
85
+ body: str,
86
+ *,
87
+ version: int,
88
+ ) -> Message:
89
+ """Replace the text of an existing message."""
90
+ if not body:
91
+ raise ValueError("edited message body cannot be empty")
92
+ if len(body) > 4000:
93
+ raise ValueError("message body cannot exceed 4000 characters")
94
+ payload = await self._transport.request(
95
+ "PATCH",
96
+ self._message_path(community_id, channel_id, message_seq),
97
+ json={"body": body, "version": version},
98
+ )
99
+ return _required_message(payload, "edited")
100
+
101
+ async def delete(self, community_id: str, channel_id: str, message_seq: str) -> None:
102
+ """Delete a message. Repeating the same request is safe at transport level."""
103
+ await self._transport.request(
104
+ "DELETE",
105
+ self._message_path(community_id, channel_id, message_seq),
106
+ retryable=True,
107
+ )
108
+
109
+ async def set_pin(
110
+ self,
111
+ community_id: str,
112
+ channel_id: str,
113
+ message_seq: str,
114
+ *,
115
+ pinned: bool = True,
116
+ ) -> Message:
117
+ """Set the desired pin state for a message."""
118
+ payload = await self._transport.request(
119
+ "PUT",
120
+ f"{self._message_path(community_id, channel_id, message_seq)}/pin",
121
+ json={"pinned": pinned},
122
+ retryable=True,
123
+ )
124
+ return _required_message(payload, "pin update")
125
+
126
+ async def set_reaction(
127
+ self,
128
+ community_id: str,
129
+ channel_id: str,
130
+ message_seq: str,
131
+ emoji: str,
132
+ *,
133
+ active: bool = True,
134
+ ) -> Message:
135
+ """Set the current bot's desired reaction state."""
136
+ if not emoji or len(emoji) > 64:
137
+ raise ValueError("emoji must contain between 1 and 64 characters")
138
+ payload = await self._transport.request(
139
+ "PUT",
140
+ f"{self._message_path(community_id, channel_id, message_seq)}/reactions",
141
+ json={"emoji": emoji, "active": active},
142
+ retryable=True,
143
+ )
144
+ return _required_message(payload, "reaction update")
145
+
146
+ async def pins(
147
+ self,
148
+ community_id: str,
149
+ channel_id: str,
150
+ ) -> MessagePage:
151
+ """Read pinned messages in a channel."""
152
+ payload = await self._transport.request(
153
+ "GET",
154
+ f"{self._channel_path(community_id, channel_id)}/pins",
155
+ )
156
+ return _message_page(payload)
157
+
158
+ async def search(
159
+ self,
160
+ community_id: str,
161
+ channel_id: str,
162
+ query: str,
163
+ *,
164
+ before_seq: str | None = None,
165
+ ) -> MessagePage:
166
+ """Search visible messages in a channel."""
167
+ if not query.strip():
168
+ raise ValueError("search query cannot be empty")
169
+ payload = await self._transport.request(
170
+ "GET",
171
+ f"{self._channel_path(community_id, channel_id)}/search",
172
+ params={"q": query, "beforeSeq": before_seq},
173
+ )
174
+ return _message_page(payload)
175
+
176
+ @staticmethod
177
+ def _messages_path(community_id: str, channel_id: str) -> str:
178
+ return f"/api/communities/{community_id}/channels/{channel_id}/messages"
179
+
180
+ @staticmethod
181
+ def _channel_path(community_id: str, channel_id: str) -> str:
182
+ return f"/api/communities/{community_id}/channels/{channel_id}"
183
+
184
+ @classmethod
185
+ def _message_path(cls, community_id: str, channel_id: str, message_seq: str) -> str:
186
+ return f"{cls._messages_path(community_id, channel_id)}/{message_seq}"
187
+
188
+
189
+ def _optional_cursor(value: object) -> str | None:
190
+ return None if value is None else str(value)
191
+
192
+
193
+ def _validate_limit(limit: int) -> None:
194
+ if not 1 <= limit <= 100:
195
+ raise ValueError("limit must be between 1 and 100")
196
+
197
+
198
+ def _message_page(payload: dict[str, object] | None) -> MessagePage:
199
+ if payload is None:
200
+ return MessagePage(())
201
+ raw_items = payload.get("items", payload.get("messages", []))
202
+ items = raw_items if isinstance(raw_items, list) else []
203
+ return MessagePage(
204
+ items=tuple(Message.from_dict(item) for item in items if isinstance(item, dict)),
205
+ next_before_seq=_optional_cursor(payload.get("nextBeforeSeq")),
206
+ )
207
+
208
+
209
+ def _required_message(payload: dict[str, object] | None, operation: str) -> Message:
210
+ if payload is None:
211
+ raise RuntimeError(f"VoiceRoute returned no message after {operation}")
212
+ raw_message = payload.get("message", payload)
213
+ if not isinstance(raw_message, dict):
214
+ raise RuntimeError(f"VoiceRoute returned an invalid message after {operation}")
215
+ return Message.from_dict(raw_message)