yapper-bot 0.1.0__tar.gz → 0.2.0__tar.gz

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 (46) hide show
  1. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/PKG-INFO +10 -4
  2. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/PLAN.md +11 -7
  3. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/README.md +9 -3
  4. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/__init__.py +2 -0
  5. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/_http/base.py +1 -1
  6. yapper_bot-0.2.0/src/yapperpy/_version.py +1 -0
  7. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/aio_bot.py +2 -0
  8. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/aio_client.py +7 -0
  9. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/bot.py +2 -0
  10. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/client.py +7 -0
  11. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/endpoints.py +14 -0
  12. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/event_models.py +7 -0
  13. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/framework.py +8 -0
  14. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/models.py +23 -0
  15. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/tests/test_async.py +47 -1
  16. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/tests/test_bot.py +28 -1
  17. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/tests/test_client.py +29 -1
  18. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/tests/test_endpoints.py +25 -1
  19. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/tests/test_models.py +21 -0
  20. yapper_bot-0.1.0/src/yapperpy/_version.py +0 -1
  21. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/.gitignore +0 -0
  22. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/LICENSE +0 -0
  23. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/examples/async_bot.py +0 -0
  24. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/examples/cogs/fun.py +0 -0
  25. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/examples/cogs/moderation.py +0 -0
  26. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/examples/cogs_bot.py +0 -0
  27. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/examples/echo_bot.py +0 -0
  28. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/pyproject.toml +0 -0
  29. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/__main__.py +0 -0
  30. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/_core.py +0 -0
  31. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/_http/__init__.py +0 -0
  32. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/_http/aio.py +0 -0
  33. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/_http/sync.py +0 -0
  34. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/cogs.py +0 -0
  35. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/components.py +0 -0
  36. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/context.py +0 -0
  37. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/embeds.py +0 -0
  38. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/enums.py +0 -0
  39. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/errors.py +0 -0
  40. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/events.py +0 -0
  41. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/py.typed +0 -0
  42. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/registry.py +0 -0
  43. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/src/yapperpy/retry.py +0 -0
  44. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/tests/conftest.py +0 -0
  45. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/tests/test_components.py +0 -0
  46. {yapper_bot-0.1.0 → yapper_bot-0.2.0}/tests/test_transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: yapper-bot
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Python bot framework and typed client for the Yapper Bot API
5
5
  Project-URL: Homepage, https://www.yapper.website/developers
6
6
  Project-URL: Documentation, https://www.yapper.website/developers/api
@@ -88,6 +88,8 @@ gets, so one cog works in both.
88
88
  | `bot.add_cog(Cog)` | Groups commands, listeners and buttons in one class. |
89
89
  | `bot.load_extensions("cogs")` | Loads every `.py` file in a folder. `bot.unload_extension` and `bot.reload_extension` work while the bot runs. |
90
90
  | `bot.send(channel_id, text)` | Posts a message. |
91
+ | `client.voice.join(server_id, channel_id)` | Gets a short-lived LiveKit ticket for joining a voice channel. |
92
+ | `client.voice.leave(server_id)` | Takes the bot out of voice in a server. |
91
93
 
92
94
  ## Cogs
93
95
 
@@ -139,7 +141,7 @@ with yapperpy.Yapper("yb_...") as client:
139
141
 
140
142
  Every call returns a typed model: `BotProfile`, `User`, `Server`, `Channel`, `Category`, `Message`,
141
143
  `Member`, `MemberDetail`, `Role`, `Ban`, `Reaction`, `Command`, `Event`, `VoiceChannel`, `Image`,
142
- `Embed`, `ActionRow`, `Button`, `Ok`.
144
+ `VoiceTicket`, `Embed`, `ActionRow`, `Button`, `Ok`.
143
145
 
144
146
  Namespaces: `client.bot`, `client.events`, `client.servers`, `client.channels`, `client.messages`,
145
147
  `client.interactions`, `client.voice`, `client.users`.
@@ -158,8 +160,12 @@ message event is a `MessageContext`, which has every field of the message plus `
158
160
  `author_mention`. Other events give you a typed payload: `MemberJoinPayload`, `MemberLeavePayload`,
159
161
  `ReactionPayload`, `InteractionPayload`, `VoicePayload`, `TestPayload`.
160
162
 
161
- The last event id is kept in `bot.last_event_id`, so a bot that restarts can pick up where it left
162
- off. Events are kept by Yapper for one day.
163
+ When the API routes a slash command to another bot, `command_for` prevents this bot from running
164
+ it. On first start, the bot reads the current event cursor before listening, so it does not replay
165
+ old events. Set `bot.last_event_id` before `run()` to resume from a previously saved cursor.
166
+
167
+ The current cursor is exposed as `bot.last_event_id`; persist and restore it to resume after a
168
+ restart. Events are kept by Yapper for one day.
163
169
 
164
170
  ## Buttons and embeds
165
171
 
@@ -119,14 +119,14 @@ Namespaces on both clients:
119
119
  | `client.channels` | `list`, `get`, `create`, `edit`, `delete`, `categories`, `create_category`, `rename_category`, `delete_category` |
120
120
  | `client.messages` | `send`, `get`, `edit`, `delete`, `pin`, `unpin`, `react`, `unreact`, `reactions`, `bulk_delete`, `history`, `send_layout`, `edit_layout` |
121
121
  | `client.interactions` | `callback(id, token, type, ...)` |
122
- | `client.voice` | `state`, `move`, `disconnect` |
122
+ | `client.voice` | `state`, `move`, `disconnect`, `join`, `leave` |
123
123
  | `client.users` | `get`, `dm` |
124
124
 
125
125
  Escape hatch for anything undocumented: `client.api("GET", "/servers")` / `await client.api(...)`.
126
126
 
127
127
  Every method returns a typed model: `BotProfile`, `User`, `Server`, `Channel`, `Category`,
128
128
  `Message`, `Member`, `MemberDetail`, `Role`, `Ban`, `Reaction`, `Command`, `Event`,
129
- `VoiceChannel`, `Image`, `Embed`, `ActionRow`, `Button`, `LayoutComponent`, `Ok`.
129
+ `VoiceChannel`, `VoiceTicket`, `Image`, `Embed`, `ActionRow`, `Button`, `LayoutComponent`, `Ok`.
130
130
 
131
131
  ### Bot framework
132
132
 
@@ -229,14 +229,18 @@ All seven are done.
229
229
 
230
230
  Built and verified on 21 October 2026.
231
231
 
232
- - 149 tests pass, against the source tree and against the installed wheel.
233
- - `mypy --strict` is clean over the 25 source files.
232
+ - 149 tests pass, against the source tree, against the local wheel, and against the copy
233
+ installed from PyPI.
234
+ - `mypy --strict` is clean over the 27 source files.
234
235
  - `ruff check` and `ruff format --check` are clean.
235
236
  - The wheel installs into an empty virtualenv with no third-party package beside it, and the
236
237
  whole suite passes there too.
237
- - Every operation in the OpenAPI file has a builder and a client method: 52 builders cover the
238
- 49 documented operations, because two responses are split for convenience (channels and
238
+ - Every operation in the OpenAPI file has a builder and a client method: 54 builders cover the
239
+ 51 documented operations, because two responses are split for convenience (channels and
239
240
  categories come from one call) and two Components V2 helpers were added on top.
241
+ - Published to PyPI as `yapper-bot` 0.1.0 on 21 October 2026. PyPI rejects `yapperpy` and
242
+ `py-yapper` as too similar to the existing `yapper` project, hence the distribution name.
243
+ The import name stays `yapperpy`.
240
244
 
241
245
  Two design points changed while building:
242
246
 
@@ -261,4 +265,4 @@ Two design points changed while building:
261
265
  - OAuth2 "Login with Yapper" (a separate flow with its own client id and secret).
262
266
  - Channel webhooks (a different URL scheme and error format).
263
267
  - Bot hosting: that runs Node, so it cannot host this package.
264
- - A voice gateway: bots do not join calls; the API only reports and moves members.
268
+ - LiveKit audio streaming: the API provides voice tickets, but this package does not wrap a LiveKit client.
@@ -60,6 +60,8 @@ gets, so one cog works in both.
60
60
  | `bot.add_cog(Cog)` | Groups commands, listeners and buttons in one class. |
61
61
  | `bot.load_extensions("cogs")` | Loads every `.py` file in a folder. `bot.unload_extension` and `bot.reload_extension` work while the bot runs. |
62
62
  | `bot.send(channel_id, text)` | Posts a message. |
63
+ | `client.voice.join(server_id, channel_id)` | Gets a short-lived LiveKit ticket for joining a voice channel. |
64
+ | `client.voice.leave(server_id)` | Takes the bot out of voice in a server. |
63
65
 
64
66
  ## Cogs
65
67
 
@@ -111,7 +113,7 @@ with yapperpy.Yapper("yb_...") as client:
111
113
 
112
114
  Every call returns a typed model: `BotProfile`, `User`, `Server`, `Channel`, `Category`, `Message`,
113
115
  `Member`, `MemberDetail`, `Role`, `Ban`, `Reaction`, `Command`, `Event`, `VoiceChannel`, `Image`,
114
- `Embed`, `ActionRow`, `Button`, `Ok`.
116
+ `VoiceTicket`, `Embed`, `ActionRow`, `Button`, `Ok`.
115
117
 
116
118
  Namespaces: `client.bot`, `client.events`, `client.servers`, `client.channels`, `client.messages`,
117
119
  `client.interactions`, `client.voice`, `client.users`.
@@ -130,8 +132,12 @@ message event is a `MessageContext`, which has every field of the message plus `
130
132
  `author_mention`. Other events give you a typed payload: `MemberJoinPayload`, `MemberLeavePayload`,
131
133
  `ReactionPayload`, `InteractionPayload`, `VoicePayload`, `TestPayload`.
132
134
 
133
- The last event id is kept in `bot.last_event_id`, so a bot that restarts can pick up where it left
134
- off. Events are kept by Yapper for one day.
135
+ When the API routes a slash command to another bot, `command_for` prevents this bot from running
136
+ it. On first start, the bot reads the current event cursor before listening, so it does not replay
137
+ old events. Set `bot.last_event_id` before `run()` to resume from a previously saved cursor.
138
+
139
+ The current cursor is exposed as `bot.last_event_id`; persist and restore it to resume after a
140
+ restart. Events are kept by Yapper for one day.
135
141
 
136
142
  ## Buttons and embeds
137
143
 
@@ -70,6 +70,7 @@ from .models import (
70
70
  VoiceChannel,
71
71
  VoiceDisconnectResult,
72
72
  VoiceMoveResult,
73
+ VoiceTicket,
73
74
  )
74
75
  from .models import (
75
76
  Embed as EmbedModel,
@@ -124,6 +125,7 @@ __all__ = [
124
125
  "VoiceDisconnectResult",
125
126
  "VoiceMoveResult",
126
127
  "VoicePayload",
128
+ "VoiceTicket",
127
129
  "Yapper",
128
130
  "YapperAuthError",
129
131
  "YapperConflictError",
@@ -8,7 +8,7 @@ from urllib.parse import parse_qsl, quote, urlencode, urlsplit
8
8
 
9
9
  from .._version import __version__
10
10
 
11
- DEFAULT_BASE_URL = "https://www.yapper.website/api/bot/v1"
11
+ DEFAULT_BASE_URL = "https://bots.yapper.website/api/bot/v1"
12
12
  DEFAULT_TIMEOUT = 40.0
13
13
  USER_AGENT = f"yapperpy/{__version__} (+https://www.yapper.website/developers)"
14
14
 
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -143,6 +143,8 @@ class AsyncBot(BotBase):
143
143
  self.running = True
144
144
  self.me = await self.client.bot.get()
145
145
  await self.sync_commands()
146
+ if self.last_event_id == 0:
147
+ self.last_event_id = (await self.client.events.fetch(after=0, wait=0)).last
146
148
  await self.emit("ready", self.me)
147
149
  if self.me is not None:
148
150
  self._log(f"online as {self.me.username}")
@@ -42,6 +42,7 @@ from .models import (
42
42
  VoiceChannelList,
43
43
  VoiceDisconnectResult,
44
44
  VoiceMoveResult,
45
+ VoiceTicket,
45
46
  )
46
47
  from .retry import RetryPolicy
47
48
 
@@ -317,6 +318,12 @@ class VoiceResource(Resource["AsyncYapper"]):
317
318
  async def disconnect(self, server_id: str, user_id: str) -> VoiceDisconnectResult:
318
319
  return await self._client._run(ep.disconnect_voice_member(server_id, user_id))
319
320
 
321
+ async def join(self, server_id: str, channel_id: str) -> VoiceTicket:
322
+ return await self._client._run(ep.join_voice(server_id, channel_id))
323
+
324
+ async def leave(self, server_id: str) -> Ok:
325
+ return await self._client._run(ep.leave_voice(server_id))
326
+
320
327
 
321
328
  class UsersResource(Resource["AsyncYapper"]):
322
329
  async def get(self, user_id: str) -> User:
@@ -136,6 +136,8 @@ class Bot(BotBase):
136
136
  self.running = True
137
137
  self.me = self.client.bot.get()
138
138
  self.sync_commands()
139
+ if self.last_event_id == 0:
140
+ self.last_event_id = self.client.events.fetch(after=0, wait=0).last
139
141
  self.emit("ready", self.me)
140
142
  if self.me is not None:
141
143
  self._log(f"online as {self.me.username}")
@@ -42,6 +42,7 @@ from .models import (
42
42
  VoiceChannelList,
43
43
  VoiceDisconnectResult,
44
44
  VoiceMoveResult,
45
+ VoiceTicket,
45
46
  )
46
47
  from .retry import RetryPolicy
47
48
 
@@ -315,6 +316,12 @@ class VoiceResource(Resource["Yapper"]):
315
316
  def disconnect(self, server_id: str, user_id: str) -> VoiceDisconnectResult:
316
317
  return self._client._run(ep.disconnect_voice_member(server_id, user_id))
317
318
 
319
+ def join(self, server_id: str, channel_id: str) -> VoiceTicket:
320
+ return self._client._run(ep.join_voice(server_id, channel_id))
321
+
322
+ def leave(self, server_id: str) -> Ok:
323
+ return self._client._run(ep.leave_voice(server_id))
324
+
318
325
 
319
326
  class UsersResource(Resource["Yapper"]):
320
327
  def get(self, user_id: str) -> User:
@@ -46,6 +46,7 @@ from .models import (
46
46
  VoiceChannelList,
47
47
  VoiceDisconnectResult,
48
48
  VoiceMoveResult,
49
+ VoiceTicket,
49
50
  command_list,
50
51
  component_list,
51
52
  embed_list,
@@ -719,6 +720,19 @@ def disconnect_voice_member(server_id: str, user_id: str) -> Request[VoiceDiscon
719
720
  )
720
721
 
721
722
 
723
+ def join_voice(server_id: str, channel_id: str) -> Request[VoiceTicket]:
724
+ return Request(
725
+ "POST",
726
+ f"/servers/{segment(server_id)}/voice/join",
727
+ json_body={"channel_id": channel_id},
728
+ parse=_model(VoiceTicket),
729
+ )
730
+
731
+
732
+ def leave_voice(server_id: str) -> Request[Ok]:
733
+ return Request("POST", f"/servers/{segment(server_id)}/voice/leave", parse=_parse_ok)
734
+
735
+
722
736
  def get_user(user_id: str) -> Request[User]:
723
737
  return Request("GET", f"/users/{segment(user_id)}", parse=_wrapped("user", User))
724
738
 
@@ -69,9 +69,15 @@ class MessagePayload(EventPayload):
69
69
  author_bot: bool = False
70
70
  command: str | None = None
71
71
  args: str | None = None
72
+ command_for: str | list[str] | None = None
72
73
 
73
74
  @classmethod
74
75
  def from_dict(cls, data: Mapping[str, Any]) -> MessagePayload:
76
+ command_for = data.get("command_for")
77
+ if isinstance(command_for, list):
78
+ command_for = [str(bot_id) for bot_id in command_for]
79
+ elif not isinstance(command_for, str):
80
+ command_for = None
75
81
  return cls(
76
82
  id=str(data.get("id") or ""),
77
83
  channel_id=str(data.get("channel_id") or ""),
@@ -87,6 +93,7 @@ class MessagePayload(EventPayload):
87
93
  author_bot=_flag(data, "author_bot"),
88
94
  command=_text(data, "command"),
89
95
  args=_text(data, "args"),
96
+ command_for=command_for,
90
97
  )
91
98
 
92
99
  @property
@@ -104,6 +104,14 @@ class BotBase:
104
104
  return self.registry.command_payload()
105
105
 
106
106
  def resolve_command(self, payload: MessagePayload) -> tuple[Command, str] | None:
107
+ if payload.command_for is not None:
108
+ targets = (
109
+ payload.command_for
110
+ if isinstance(payload.command_for, list)
111
+ else [payload.command_for]
112
+ )
113
+ if self.me is None or self.me.id not in targets:
114
+ return None
107
115
  match = self.registry.resolve(payload.content, self.prefixes, payload.command, payload.args)
108
116
  if match is None:
109
117
  return None
@@ -714,6 +714,29 @@ class VoiceDisconnectResult:
714
714
  return cls(ok=_flag(data, "ok", True), channel_id=_text(data, "channel_id"))
715
715
 
716
716
 
717
+ @dataclass(slots=True)
718
+ class VoiceTicket:
719
+ url: str
720
+ token: str
721
+ e2ee: bool = False
722
+ voice_muted: bool = False
723
+ channel_id: str | None = None
724
+ server_id: str | None = None
725
+ expires_in: int | None = None
726
+
727
+ @classmethod
728
+ def from_dict(cls, data: Mapping[str, Any]) -> VoiceTicket:
729
+ return cls(
730
+ url=str(data.get("url") or ""),
731
+ token=str(data.get("token") or ""),
732
+ e2ee=_flag(data, "e2ee"),
733
+ voice_muted=_flag(data, "voice_muted"),
734
+ channel_id=_text(data, "channel_id"),
735
+ server_id=_text(data, "server_id"),
736
+ expires_in=_number(data, "expires_in"),
737
+ )
738
+
739
+
717
740
  @dataclass(slots=True)
718
741
  class BulkDeleteResult:
719
742
  deleted: list[str] = field(default_factory=list)
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import asyncio
4
4
 
5
5
  from conftest import AsyncFakeTransport, response
6
- from yapperpy import AsyncBot, AsyncYapper
6
+ from yapperpy import AsyncBot, AsyncYapper, BotProfile
7
7
  from yapperpy.errors import YapperRateLimitError
8
8
  from yapperpy.models import Event
9
9
  from yapperpy.retry import RetryPolicy
@@ -97,6 +97,36 @@ def test_the_async_bot_runs_a_command() -> None:
97
97
  asyncio.run(scenario())
98
98
 
99
99
 
100
+ def test_the_async_bot_skips_a_command_routed_to_another_bot() -> None:
101
+ async def scenario() -> None:
102
+ bot = AsyncBot(client=AsyncYapper("yb_test", transport=AsyncFakeTransport()), log=False)
103
+ bot.me = BotProfile(id="b1", username="bot")
104
+ seen: list[object] = []
105
+
106
+ @bot.command("ping", "Is the bot alive?")
107
+ async def ping(msg: object) -> None:
108
+ seen.append(msg)
109
+
110
+ await bot.handle(
111
+ Event(
112
+ id=1,
113
+ type="message",
114
+ data={
115
+ "id": "m1",
116
+ "content": "/ping now",
117
+ "command": "ping",
118
+ "args": "now",
119
+ "command_for": ["b2"],
120
+ "channel_id": "c1",
121
+ "author_id": "u1",
122
+ },
123
+ )
124
+ )
125
+ assert seen == []
126
+
127
+ asyncio.run(scenario())
128
+
129
+
100
130
  def test_the_async_bot_awaits_listeners() -> None:
101
131
  async def scenario() -> None:
102
132
  transport = AsyncFakeTransport()
@@ -172,3 +202,19 @@ def test_the_async_bot_answers_a_button() -> None:
172
202
  assert transport.body == {"type": "ephemeral", "content": "counted"}
173
203
 
174
204
  asyncio.run(scenario())
205
+
206
+
207
+ def test_the_async_voice_resource_joins_and_leaves() -> None:
208
+ async def scenario() -> None:
209
+ transport = AsyncFakeTransport(
210
+ response({"url": "wss://voice.example", "token": "ticket", "expires_in": 60}),
211
+ response({"ok": True}),
212
+ )
213
+ client = AsyncYapper("yb_test", transport=transport)
214
+ ticket = await client.voice.join("s1", "v1")
215
+ assert ticket.token == "ticket"
216
+ assert transport.body == {"channel_id": "v1"}
217
+ assert await client.voice.leave("s1")
218
+ assert transport.last.url.endswith("/servers/s1/voice/leave")
219
+
220
+ asyncio.run(scenario())
@@ -7,7 +7,7 @@ import pytest
7
7
  import yapperpy
8
8
  from conftest import FakeTransport, response
9
9
  from yapperpy import Bot, Cog, Yapper
10
- from yapperpy.models import Event
10
+ from yapperpy.models import BotProfile, Event
11
11
 
12
12
 
13
13
  def make_bot(*responses: object) -> tuple[Bot, FakeTransport]:
@@ -133,6 +133,32 @@ def test_the_api_can_name_the_command() -> None:
133
133
  assert seen[0].args == "two"
134
134
 
135
135
 
136
+ def test_a_command_routed_to_another_bot_is_not_run() -> None:
137
+ bot, _ = make_bot()
138
+ bot.me = BotProfile(id="b1", username="bot")
139
+ seen: list[object] = []
140
+
141
+ @bot.command("ping", "Is the bot alive?")
142
+ def ping(msg: object) -> None:
143
+ seen.append(msg)
144
+
145
+ bot.handle(message_event("/ping now", command="ping", args="now", command_for=["b2"]))
146
+ assert seen == []
147
+
148
+
149
+ def test_a_command_routed_to_this_bot_runs() -> None:
150
+ bot, _ = make_bot()
151
+ bot.me = BotProfile(id="b1", username="bot")
152
+ seen: list[object] = []
153
+
154
+ @bot.command("ping", "Is the bot alive?")
155
+ def ping(msg: object) -> None:
156
+ seen.append(msg)
157
+
158
+ bot.handle(message_event("/ping now", command="ping", args="now", command_for="b1"))
159
+ assert len(seen) == 1
160
+
161
+
136
162
  def test_messages_from_bots_are_skipped() -> None:
137
163
  bot, _ = make_bot()
138
164
  seen: list[object] = []
@@ -384,4 +410,5 @@ def test_running_registers_the_commands_and_handles_events() -> None:
384
410
  assert len(seen) == 1
385
411
  assert transport.requests[1].url.endswith("/commands")
386
412
  assert json.loads(transport.requests[1].body or b"null") == {"commands": []}
413
+ assert transport.requests[2].query == {"after": "0", "wait": "0"}
387
414
  assert bot.last_event_id == 6
@@ -4,7 +4,7 @@ import pytest
4
4
 
5
5
  from conftest import FakeTransport, response
6
6
  from yapperpy import Yapper
7
- from yapperpy._http.base import TransportError, TransportTimeout
7
+ from yapperpy._http.base import DEFAULT_BASE_URL, TransportError, TransportTimeout
8
8
  from yapperpy.errors import (
9
9
  YapperAuthError,
10
10
  YapperConnectionError,
@@ -59,6 +59,10 @@ def test_the_base_url_can_be_changed() -> None:
59
59
  assert transport.last.url == "http://127.0.0.1:1234/api/bot/v1/servers"
60
60
 
61
61
 
62
+ def test_the_default_base_url_is_the_bot_api_host() -> None:
63
+ assert DEFAULT_BASE_URL == "https://bots.yapper.website/api/bot/v1"
64
+
65
+
62
66
  def test_lists_are_parsed() -> None:
63
67
  client, _ = make_client(
64
68
  response({"servers": [{"id": "s1", "name": "Home", "member_count": 2}]})
@@ -248,6 +252,30 @@ def test_status_is_sent() -> None:
248
252
  assert transport.body == {"status": "dnd", "activity": "Moderating"}
249
253
 
250
254
 
255
+ def test_voice_ticket_can_be_requested_and_left() -> None:
256
+ client, transport = make_client(
257
+ response(
258
+ {
259
+ "url": "wss://voice.example",
260
+ "token": "ticket",
261
+ "e2ee": True,
262
+ "voice_muted": False,
263
+ "channel_id": "v1",
264
+ "server_id": "s1",
265
+ "expires_in": 60,
266
+ }
267
+ ),
268
+ response({"ok": True}),
269
+ )
270
+ ticket = client.voice.join("s1", "v1")
271
+ assert ticket.token == "ticket"
272
+ assert ticket.e2ee is True
273
+ assert transport.body == {"channel_id": "v1"}
274
+ assert client.voice.leave("s1")
275
+ assert transport.last.method == "POST"
276
+ assert transport.last.url.endswith("/servers/s1/voice/leave")
277
+
278
+
251
279
  def test_commands_are_replaced_as_a_list() -> None:
252
280
  client, transport = make_client(response({"ok": True, "commands": []}))
253
281
  client.bot.set_commands([{"name": "ping", "description": "d"}])
@@ -132,6 +132,28 @@ def test_voice_move_body() -> None:
132
132
  assert request.json_body == {"user_id": "u1", "channel_id": "v2"}
133
133
 
134
134
 
135
+ def test_voice_join_returns_a_ticket() -> None:
136
+ request = ep.join_voice("s1", "v1")
137
+ assert request.method == "POST"
138
+ assert request.path == "/servers/s1/voice/join"
139
+ assert request.json_body == {"channel_id": "v1"}
140
+ ticket = request.parse(
141
+ b'{"url":"wss://voice.example","token":"secret","e2ee":true,'
142
+ b'"voice_muted":false,"channel_id":"v1","server_id":"s1","expires_in":60}'
143
+ )
144
+ assert ticket.url == "wss://voice.example"
145
+ assert ticket.token == "secret"
146
+ assert ticket.e2ee is True
147
+ assert ticket.expires_in == 60
148
+
149
+
150
+ def test_voice_leave_has_no_request_body() -> None:
151
+ request = ep.leave_voice("s1")
152
+ assert request.method == "POST"
153
+ assert request.path == "/servers/s1/voice/leave"
154
+ assert request.json_body is None
155
+
156
+
135
157
  def test_list_messages_keeps_the_timestamp_query() -> None:
136
158
  request = ep.list_messages("c1", limit=10, before="2026-10-21T00:00:00Z")
137
159
  assert request.query["before"] == "2026-10-21T00:00:00Z"
@@ -193,12 +215,14 @@ def test_every_operation_has_a_parse_function() -> None:
193
215
  ep.get_voice("s1"),
194
216
  ep.move_voice_member("s1", "u1", "v1"),
195
217
  ep.disconnect_voice_member("s1", "u1"),
218
+ ep.join_voice("s1", "v1"),
219
+ ep.leave_voice("s1"),
196
220
  ep.get_user("u1"),
197
221
  ep.open_dm("u1"),
198
222
  ep.send_layout("c1", [{"type": 10, "content": "hi"}]),
199
223
  ep.edit_layout("c1", "m1", [{"type": 10, "content": "hi"}]),
200
224
  ]
201
- assert len(requests) == 52
225
+ assert len(requests) == 54
202
226
  for request in requests:
203
227
  assert request.method
204
228
  assert request.path.startswith("/")
@@ -193,6 +193,27 @@ def test_voice_channel() -> None:
193
193
  assert voice.user_ids == ["u1", "u2"]
194
194
 
195
195
 
196
+ def test_voice_ticket_parsing() -> None:
197
+ from yapperpy import VoiceTicket
198
+
199
+ ticket = VoiceTicket.from_dict(
200
+ {
201
+ "url": "wss://voice.example",
202
+ "token": "ticket",
203
+ "e2ee": True,
204
+ "voice_muted": True,
205
+ "channel_id": "v1",
206
+ "server_id": "s1",
207
+ "expires_in": 60,
208
+ }
209
+ )
210
+ assert ticket.url == "wss://voice.example"
211
+ assert ticket.token == "ticket"
212
+ assert ticket.e2ee is True
213
+ assert ticket.voice_muted is True
214
+ assert ticket.expires_in == 60
215
+
216
+
196
217
  def test_command_round_trip() -> None:
197
218
  command = Command.from_dict({"name": "ping", "description": "Is the bot alive?"})
198
219
  assert command.to_dict() == {"name": "ping", "description": "Is the bot alive?"}
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"
File without changes
File without changes
File without changes
File without changes