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 +149 -0
- voiceroute/api/__init__.py +8 -0
- voiceroute/api/bots.py +40 -0
- voiceroute/api/communities.py +36 -0
- voiceroute/api/events.py +86 -0
- voiceroute/api/messages.py +215 -0
- voiceroute/bot.py +535 -0
- voiceroute/client.py +48 -0
- voiceroute/commands/__init__.py +41 -0
- voiceroute/commands/context.py +81 -0
- voiceroute/commands/core.py +263 -0
- voiceroute/commands/pipeline.py +257 -0
- voiceroute/commands/prefix.py +44 -0
- voiceroute/config.py +18 -0
- voiceroute/errors.py +191 -0
- voiceroute/events/__init__.py +38 -0
- voiceroute/events/cursors.py +245 -0
- voiceroute/events/models.py +24 -0
- voiceroute/events/sse.py +63 -0
- voiceroute/events/transport.py +172 -0
- voiceroute/extensions.py +79 -0
- voiceroute/models/__init__.py +8 -0
- voiceroute/models/bot.py +21 -0
- voiceroute/models/community.py +68 -0
- voiceroute/models/message.py +75 -0
- voiceroute/models/user.py +23 -0
- voiceroute/runtime.py +13 -0
- voiceroute/transport/__init__.py +6 -0
- voiceroute/transport/auth.py +18 -0
- voiceroute/transport/http.py +204 -0
- voiceroute/transport/rate_limit.py +45 -0
- voiceroute_py-0.1.0.dev0.dist-info/METADATA +170 -0
- voiceroute_py-0.1.0.dev0.dist-info/RECORD +35 -0
- voiceroute_py-0.1.0.dev0.dist-info/WHEEL +4 -0
- voiceroute_py-0.1.0.dev0.dist-info/licenses/LICENSE +22 -0
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
|
+
]
|
voiceroute/api/events.py
ADDED
|
@@ -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)
|