diff --git a/discord/__init__.py b/discord/__init__.py index 7b31c7f81..53016f43a 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -30,6 +30,7 @@ from .errors import * from .calls import CallMessage, GroupCall from .permissions import Permissions, PermissionOverwrite from .role import Role +from .file import File from .colour import Color, Colour from .invite import Invite from .object import Object diff --git a/discord/abc.py b/discord/abc.py index 7472c1038..9c94c5dcc 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -37,6 +37,7 @@ from .errors import InvalidArgument from .permissions import PermissionOverwrite, Permissions from .role import Role from .invite import Invite +from .file import File from . import utils, compat class _Undefined: @@ -536,7 +537,7 @@ class Messageable(metaclass=abc.ABCMeta): raise NotImplementedError @asyncio.coroutine - def send(self, content=None, *, tts=False, embed=None, file=None, filename=None, delete_after=None): + def send(self, content=None, *, tts=False, embed=None, file=None, files=None, delete_after=None): """|coro| Sends a message to the destination with the content given. @@ -545,19 +546,10 @@ class Messageable(metaclass=abc.ABCMeta): If the content is set to ``None`` (the default), then the ``embed`` parameter must be provided. - The ``file`` parameter should be either a string denoting the location for a - file or a *file-like object*. The *file-like object* passed is **not closed** - at the end of execution. You are responsible for closing it yourself. - - .. note:: - - If the file-like object passed is opened via ``open`` then the modes - 'rb' should be used. - - The ``filename`` parameter is the filename of the file. - If this is not given then it defaults to ``file.name`` or if ``file`` is a string - then the ``filename`` will default to the string given. You can overwrite - this value by passing this in. + To upload a single file, the ``file`` parameter should be used with a + single :class:`File` object. To upload multiple files, the ``files`` + parameter should be used with a list of :class:`File` objects. + **Specifying both parameters will lead to an exception**. If the ``embed`` parameter is provided, it must be of type :class:`Embed` and it must be a rich embed type. @@ -570,12 +562,11 @@ class Messageable(metaclass=abc.ABCMeta): Indicates if the message should be sent using text-to-speech. embed: :class:`Embed` The rich embed for the content. - file: file-like object or filename - The *file-like object* or file path to send. - filename: str - The filename of the file. Defaults to ``file.name`` if it's available. - If this is provided, you must also provide the ``file`` parameter or it - is silently ignored. + file: :class:`File` + The file to upload. + files: List[:class:`File`] + A list of files to upload. Must be a minimum of 2 and a + maximum of 10. delete_after: float If provided, the number of seconds to wait in the background before deleting the message we just sent. If the deletion fails, @@ -587,6 +578,9 @@ class Messageable(metaclass=abc.ABCMeta): Sending the message failed. Forbidden You do not have the proper permissions to send the message. + InvalidArgument + The ``files`` list is not of the appropriate size or + you specified both ``file`` and ``files``. Returns --------- @@ -600,17 +594,29 @@ class Messageable(metaclass=abc.ABCMeta): if embed is not None: embed = embed.to_dict() + if file is not None and files is not None: + raise InvalidArgument('cannot pass both file and files parameter to send()') + if file is not None: + if not isinstance(file, File): + raise InvalidArgument('file parameter must be File') + + try: + data = yield from state.http.send_files(channel.id, files=[(file.open_file(), file.filename)], + content=content, tts=tts, embed=embed) + finally: + file.close() + + elif files is not None: + if len(files) < 2 or len(files) > 10: + raise InvalidArgument('files parameter must be a list of 2 to 10 elements') + try: - with open(file, 'rb') as f: - buffer = io.BytesIO(f.read()) - if filename is None: - _, filename = os.path.split(file) - except TypeError: - buffer = file - - data = yield from state.http.send_file(channel.id, buffer, filename=filename, content=content, - tts=tts, embed=embed) + param = [(f.open_file(), f.filename) for f in files] + data = yield from state.http.send_files(channel.id, files=param, content=content, tts=tts, embed=embed) + finally: + for f in files: + f.close() else: data = yield from state.http.send_message(channel.id, content, tts=tts, embed=embed) diff --git a/discord/file.py b/discord/file.py new file mode 100644 index 000000000..98d11412c --- /dev/null +++ b/discord/file.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import os.path + +class File: + """A parameter object used for :meth:`abc.Messageable.send` + for sending file objects. + + Attributes + ----------- + fp: Union[str, BinaryIO] + A file-like object opened in binary mode and read mode + or a filename representing a file in the hard drive to + open. + + .. note:: + + If the file-like object passed is opened via ``open`` then the + modes 'rb' should be used. + + To pass binary data, consider usage of ``io.BytesIO``. + + filename: Optional[str] + The filename to display when uploading to Discord. + If this is not given then it defaults to ``fp.name`` or if ``fp`` is + a string then the ``filename`` will default to the string given. + """ + + __slots__ = ('fp', 'filename', '_true_fp') + + def __init__(self, fp, filename=None): + self.fp = fp + self._true_fp = None + + if filename is None: + if isinstance(fp, str): + _, self.filename = os.path.split(fp) + else: + self.filename = getattr(fp, 'name', None) + + def open_file(self): + fp = self.fp + if isinstance(fp, str): + self._true_fp = fp = open(fp, 'rb') + return fp + + def close(self): + if self._true_fp: + self._true_fp.close() diff --git a/discord/http.py b/discord/http.py index 9f43faad0..e5d0c0949 100644 --- a/discord/http.py +++ b/discord/http.py @@ -306,7 +306,7 @@ class HTTPClient: def send_typing(self, channel_id): return self.request(Route('POST', '/channels/{channel_id}/typing', channel_id=channel_id)) - def send_file(self, channel_id, buffer, *, filename=None, content=None, tts=False, embed=None): + def send_files(self, channel_id, *, files, content=None, tts=False, embed=None): r = Route('POST', '/channels/{channel_id}/messages', channel_id=channel_id) form = aiohttp.FormData() @@ -317,7 +317,12 @@ class HTTPClient: payload['embed'] = embed form.add_field('payload_json', utils.to_json(payload)) - form.add_field('file', buffer, filename=filename, content_type='application/octet-stream') + if len(files) == 1: + fp = files[0] + form.add_field('file', fp[0], filename=fp[1], content_type='application/octet-stream') + else: + for index, (buffer, filename) in enumerate(files): + form.add_field('file%s' % index, buffer, filename=filename, content_type='application/octet-stream') return self.request(r, data=form) diff --git a/docs/api.rst b/docs/api.rst index 6da9c1ed5..6581cc97e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -827,6 +827,12 @@ Embed .. autoclass:: Embed :members: +File +~~~~~ + +.. autoclass:: File + :members: + CallMessage ~~~~~~~~~~~~