diff --git a/disagreement/http.py b/disagreement/http.py index 8b3f35a..b2855a4 100644 --- a/disagreement/http.py +++ b/disagreement/http.py @@ -421,6 +421,92 @@ class HTTPClient: await self.request("DELETE", f"/webhooks/{webhook_id}") + async def execute_webhook( + self, + webhook_id: "Snowflake", + token: str, + *, + content: Optional[str] = None, + tts: bool = False, + embeds: Optional[List[Dict[str, Any]]] = None, + components: Optional[List[Dict[str, Any]]] = None, + allowed_mentions: Optional[dict] = None, + attachments: Optional[List[Any]] = None, + files: Optional[List[Any]] = None, + flags: Optional[int] = None, + username: Optional[str] = None, + avatar_url: Optional[str] = None, + ) -> Dict[str, Any]: + """Executes a webhook and returns the created message.""" + + payload: Dict[str, Any] = {} + if content is not None: + payload["content"] = content + if tts: + payload["tts"] = True + if embeds: + payload["embeds"] = embeds + if components: + payload["components"] = components + if allowed_mentions: + payload["allowed_mentions"] = allowed_mentions + if username: + payload["username"] = username + if avatar_url: + payload["avatar_url"] = avatar_url + + all_files: List["File"] = [] + if attachments is not None: + payload["attachments"] = [] + for a in attachments: + if hasattr(a, "data") and hasattr(a, "filename"): + idx = len(all_files) + all_files.append(a) + payload["attachments"].append({"id": idx, "filename": a.filename}) + else: + payload["attachments"].append( + a.to_dict() if hasattr(a, "to_dict") else a + ) + if files is not None: + for f in files: + if hasattr(f, "data") and hasattr(f, "filename"): + idx = len(all_files) + all_files.append(f) + if "attachments" not in payload: + payload["attachments"] = [] + payload["attachments"].append({"id": idx, "filename": f.filename}) + else: + raise TypeError("files must be File objects") + if flags: + payload["flags"] = flags + + if all_files: + form = aiohttp.FormData() + form.add_field( + "payload_json", json.dumps(payload), content_type="application/json" + ) + for idx, f in enumerate(all_files): + form.add_field( + f"files[{idx}]", + f.data, + filename=f.filename, + content_type="application/octet-stream", + ) + return await self.request( + "POST", + f"/webhooks/{webhook_id}/{token}", + payload=form, + is_json=False, + use_auth_header=False, + ) + + return await self.request( + "POST", + f"/webhooks/{webhook_id}/{token}", + payload=payload, + use_auth_header=False, + ) + async def get_user(self, user_id: "Snowflake") -> Dict[str, Any]: """Fetches a user object for a given user ID.""" return await self.request("GET", f"/users/{user_id}") diff --git a/disagreement/interactions.py b/disagreement/interactions.py index ee3ae4a..35fab77 100644 --- a/disagreement/interactions.py +++ b/disagreement/interactions.py @@ -506,6 +506,7 @@ class InteractionCallbackData: self.content: Optional[str] = data.get("content") self.embeds: Optional[List[Embed]] = ( [Embed(e) for e in data.get("embeds", [])] if data.get("embeds") else None + [Embed(e) for e in data.get("embeds", [])] if data.get("embeds") else None ) self.allowed_mentions: Optional[AllowedMentions] = ( AllowedMentions(data["allowed_mentions"]) @@ -573,5 +574,5 @@ class InteractionResponsePayload: def __repr__(self) -> str: return f"" - def __getitem__(self, item: str) -> Any: - return self.to_dict()[item] + def __getitem__(self, key: str) -> Any: + return self.to_dict()[key] diff --git a/disagreement/models.py b/disagreement/models.py index 807eea8..6887324 100644 --- a/disagreement/models.py +++ b/disagreement/models.py @@ -1412,6 +1412,57 @@ class Webhook: return cls({"id": webhook_id, "token": token, "url": url}) + async def send( + self, + content: Optional[str] = None, + *, + username: Optional[str] = None, + avatar_url: Optional[str] = None, + tts: bool = False, + embed: Optional["Embed"] = None, + embeds: Optional[List["Embed"]] = None, + components: Optional[List["ActionRow"]] = None, + allowed_mentions: Optional[Dict[str, Any]] = None, + attachments: Optional[List[Any]] = None, + files: Optional[List[Any]] = None, + flags: Optional[int] = None, + ) -> "Message": + """Send a message using this webhook.""" + + if not self._client: + raise DisagreementException("Webhook is not bound to a Client") + assert self.token is not None, "Webhook token missing" + + if embed and embeds: + raise ValueError("Cannot provide both embed and embeds.") + + final_embeds_payload: Optional[List[Dict[str, Any]]] = None + if embed: + final_embeds_payload = [embed.to_dict()] + elif embeds: + final_embeds_payload = [e.to_dict() for e in embeds] + + components_payload: Optional[List[Dict[str, Any]]] = None + if components: + components_payload = [c.to_dict() for c in components] + + message_data = await self._client._http.execute_webhook( + self.id, + self.token, + content=content, + tts=tts, + embeds=final_embeds_payload, + components=components_payload, + allowed_mentions=allowed_mentions, + attachments=attachments, + files=files, + flags=flags, + username=username, + avatar_url=avatar_url, + ) + + return self._client.parse_message(message_data) + # --- Message Components --- diff --git a/docs/webhooks.md b/docs/webhooks.md index eb677eb..1cfbfb4 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -42,3 +42,12 @@ from disagreement.models import Webhook webhook = Webhook.from_url("https://discord.com/api/webhooks/123/token") print(webhook.id, webhook.token) ``` + +## Send a message through a Webhook + +Once you have a `Webhook` instance bound to a :class:`Client`, you can send messages using it: + +```python +webhook = await client.create_webhook("123", {"name": "Bot Webhook"}) +await webhook.send(content="Hello from my webhook!", username="Bot") +``` diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 253ac30..129f658 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -149,3 +149,46 @@ def test_webhook_from_url_parses_id_and_token(): assert webhook.id == "123" assert webhook.token == "token" assert webhook.url == url + + +@pytest.mark.asyncio +async def test_execute_webhook_calls_request(): + http = HTTPClient(token="t") + http.request = AsyncMock(return_value={"id": "1"}) + await http.execute_webhook("1", "tok", content="hi") + http.request.assert_called_once_with( + "POST", + "/webhooks/1/tok", + payload={"content": "hi"}, + use_auth_header=False, + ) + + +@pytest.mark.asyncio +async def test_webhook_send_uses_http(): + from types import SimpleNamespace + from disagreement.client import Client + from disagreement.models import Webhook, Message + + http = SimpleNamespace( + execute_webhook=AsyncMock( + return_value={ + "id": "2", + "channel_id": "c", + "author": {"id": "1", "username": "u", "discriminator": "0001"}, + "content": "hi", + "timestamp": "t", + } + ) + ) + client = Client.__new__(Client) + client._http = http + client._messages = {} + client._webhooks = {} + + webhook = Webhook({"id": "1", "token": "tok"}, client_instance=client) + + msg = await webhook.send(content="hi") + + http.execute_webhook.assert_awaited_once() + assert isinstance(msg, Message)