Skip to content

Commit e7b7a2f

Browse files
authored
feat: Uploading/Attaching files to channel messages (#653)
1 parent 5f24360 commit e7b7a2f

File tree

7 files changed

+100
-15
lines changed

7 files changed

+100
-15
lines changed

interactions/api/http/message.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from typing import List, Optional, Union
22

3+
from aiohttp import MultipartWriter
4+
35
from ...api.cache import Cache, Item
46
from ..models.message import Embed, Message
5-
from ..models.misc import Snowflake
7+
from ..models.misc import MISSING, File, Snowflake
68
from .request import _Request
79
from .route import Route
810

@@ -56,16 +58,38 @@ async def send_message(
5658

5759
return await self.create_message(payload, channel_id)
5860

59-
async def create_message(self, payload: dict, channel_id: int) -> dict:
61+
async def create_message(
62+
self, payload: dict, channel_id: int, files: Optional[List[File]] = MISSING
63+
) -> dict:
6064
"""
6165
Send a message to the specified channel.
6266
6367
:param payload: Dictionary contents of a message. (i.e. message payload)
6468
:param channel_id: Channel snowflake ID.
69+
:param files: An optional list of files to send attached to the message.
6570
:return dict: Dictionary representing a message (?)
6671
"""
72+
73+
data = None
74+
if files is not MISSING and len(files) > 0:
75+
76+
data = MultipartWriter("form-data")
77+
part = data.append_json(payload)
78+
part.set_content_disposition("form-data", name="payload_json")
79+
payload = None
80+
81+
for id, file in enumerate(files):
82+
part = data.append(
83+
file._fp,
84+
)
85+
part.set_content_disposition(
86+
"form-data", name="files[" + str(id) + "]", filename=file._filename
87+
)
88+
6789
request = await self._req.request(
68-
Route("POST", "/channels/{channel_id}/messages", channel_id=channel_id), json=payload
90+
Route("POST", "/channels/{channel_id}/messages", channel_id=channel_id),
91+
json=payload,
92+
data=data,
6993
)
7094
if request.get("id"):
7195
self.cache.messages.add(Item(id=request["id"], value=Message(**request)))

interactions/api/http/message.pyi

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ from typing import List, Optional, Union
22

33
from ...api.cache import Cache
44
from ..models.message import Embed, Message
5-
from ..models.misc import Snowflake
5+
from ..models.misc import Snowflake, File
66
from .request import _Request
77

88

@@ -23,7 +23,7 @@ class _MessageRequest:
2323
allowed_mentions=None, # don't know type
2424
message_reference: Optional[Message] = None,
2525
) -> dict: ...
26-
async def create_message(self, payload: dict, channel_id: int) -> dict: ...
26+
async def create_message(self, payload: dict, channel_id: int, files: Optional[List[File]]) -> dict: ...
2727
async def get_message(self, channel_id: int, message_id: int) -> Optional[dict]: ...
2828
async def delete_message(
2929
self, channel_id: int, message_id: int, reason: Optional[str] = None

interactions/api/http/request.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ async def request(self, route: Route, **kwargs) -> Optional[Any]:
9898
"""
9999

100100
kwargs["headers"] = {**self._headers, **kwargs.get("headers", {})}
101+
101102
if kwargs.get("json"):
102103
kwargs["headers"]["Content-Type"] = "application/json"
103104

interactions/api/models/channel.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from enum import IntEnum
33
from typing import Callable, List, Optional, Union
44

5-
from .misc import MISSING, DictSerializerMixin, Overwrite, Snowflake
5+
from .misc import MISSING, DictSerializerMixin, File, Overwrite, Snowflake
66

77

88
class ChannelType(IntEnum):
@@ -199,7 +199,7 @@ async def send(
199199
content: Optional[str] = MISSING,
200200
*,
201201
tts: Optional[bool] = MISSING,
202-
# attachments: Optional[List[Any]] = None, # TODO: post-v4: Replace with own file type.
202+
files: Optional[Union[File, List[File]]] = MISSING,
203203
embeds: Optional[Union["Embed", List["Embed"]]] = MISSING, # noqa
204204
allowed_mentions: Optional["MessageInteraction"] = MISSING, # noqa
205205
components: Optional[
@@ -220,6 +220,8 @@ async def send(
220220
:type content: Optional[str]
221221
:param tts?: Whether the message utilizes the text-to-speech Discord programme or not.
222222
:type tts: Optional[bool]
223+
:param files?: A file or list of files to be attached to the message.
224+
:type files: Optional[Union[File, List[File]]]
223225
:param embeds?: An embed, or list of embeds for the message.
224226
:type embeds: Optional[Union[Embed, List[Embed]]]
225227
:param allowed_mentions?: The message interactions/mention limits that the message can refer to.
@@ -251,18 +253,26 @@ async def send(
251253
else:
252254
_components = _build_components(components=components)
253255

254-
# TODO: post-v4: Add attachments into Message obj.
256+
if not files or files is MISSING:
257+
_files = []
258+
elif isinstance(files, list):
259+
_files = [file._json_payload(id) for id, file in enumerate(files)]
260+
else:
261+
_files = [files._json_payload(0)]
262+
files = [files]
263+
255264
payload = Message(
256265
content=_content,
257266
tts=_tts,
258-
# file=file,
259-
# attachments=_attachments,
267+
attachments=_files,
260268
embeds=_embeds,
261269
allowed_mentions=_allowed_mentions,
262270
components=_components,
263271
)
264272

265-
res = await self._client.create_message(channel_id=int(self.id), payload=payload._json)
273+
res = await self._client.create_message(
274+
channel_id=int(self.id), payload=payload._json, files=files
275+
)
266276
return Message(**res, _client=self._client)
267277

268278
async def delete(self) -> None:

interactions/api/models/channel.pyi

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ from typing import List, Optional, Union, Callable
55
from .guild import Invite, InviteTargetType
66
from .message import Message, Embed, MessageInteraction
77
from ...models.component import ActionRow, Button, SelectMenu
8-
from .misc import DictSerializerMixin, Overwrite, Snowflake, MISSING
8+
from .misc import DictSerializerMixin, Overwrite, Snowflake, MISSING, File
99
from .user import User
1010
from ..http.client import HTTPClient
1111

@@ -77,7 +77,7 @@ class Channel(DictSerializerMixin):
7777
content: Optional[str] = MISSING,
7878
*,
7979
tts: Optional[bool] = MISSING,
80-
# attachments: Optional[List[Any]] = None, # TODO: post-v4: Replace with own file type.
80+
files: Optional[Union[File, List[File]]] = MISSING,
8181
embeds: Optional[Union[Embed, List[Embed]]] = MISSING,
8282
allowed_mentions: Optional[MessageInteraction] = MISSING,
8383
components: Optional[

interactions/api/models/misc.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
# TODO: Reorganise mixins to its own thing, currently placed here because circular import sucks.
55
# also, it should be serialiser* but idk, fl0w'd say something if I left it like that. /shrug
66
import datetime
7+
from io import IOBase
78
from logging import Logger
89
from math import floor
9-
from typing import Union
10+
from os.path import basename
11+
from typing import Optional, Union
1012

1113
from interactions.base import get_logger
1214

@@ -232,3 +234,39 @@ class MISSING:
232234
"""A pseudosentinel based from an empty object. This does violate PEP, but, I don't care."""
233235

234236
...
237+
238+
239+
class File(object):
240+
"""
241+
A File object to be sent as an attachment along with a message.
242+
243+
If an fp is not given, this will try to open & send a local file at the location
244+
specified in the 'filename' parameter.
245+
246+
.. note::
247+
If a description is not given the file's basename is used instead.
248+
"""
249+
250+
def __init__(
251+
self, filename: str, fp: Optional[IOBase] = MISSING, description: Optional[str] = MISSING
252+
):
253+
254+
if not isinstance(filename, str):
255+
raise TypeError(
256+
"File's first parameter 'filename' must be a string, not " + str(type(filename))
257+
)
258+
259+
if not fp or fp is MISSING:
260+
self._fp = open(filename, "rb")
261+
else:
262+
self._fp = fp
263+
264+
self._filename = basename(filename)
265+
266+
if not description or description is MISSING:
267+
self._description = self._filename
268+
else:
269+
self._description = description
270+
271+
def _json_payload(self, id):
272+
return {"id": id, "description": self._description, "filename": self._filename}

interactions/api/models/misc.pyi

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
22
from datetime import datetime
33
from typing import Optional, Union
4-
4+
from io import IOBase
55

66
log: logging.Logger
77

@@ -62,3 +62,15 @@ class Format:
6262
def stylize(self, format: str, **kwargs) -> str: ...
6363

6464
class MISSING: ...
65+
66+
class File(object):
67+
_filename: str
68+
_fp: IOBase
69+
_description: str
70+
def __init__(
71+
self,
72+
filename: str,
73+
fp: Optional[IOBase] = MISSING,
74+
description: Optional[str] = MISSING
75+
) -> None: ...
76+
def _json_payload(self, id) -> dict: ...

0 commit comments

Comments
 (0)