diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c04fdc36..73676ff8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,80 +1,100 @@ -name: Dance +name: "DANCE" on: push: branches: - - main - -permissions: - contents: read + - main + paths: + - 'disco/**' + workflow_dispatch: jobs: build: - - runs-on: ubuntu-latest + name: Build + runs-on: self-hosted steps: - - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v4 - with: - python-version: "3.x" - - name: Install Dependencies - run: | - python -m pip install --no-cache-dir --upgrade pip - python -m pip install --no-cache-dir --upgrade build - - name: Build binary wheel and source tarball - run: python -m build - - name: Store distribution packages - uses: actions/upload-artifact@v3 - with: - name: python-package-distributions - path: dist/ + - name: Pull Repository + uses: actions/checkout@v4 + - name: Setup Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install Dependencies + run: | + python -m pip install --no-cache-dir --upgrade pip + python -m pip install --no-cache-dir --upgrade setuptools + python -m pip install --no-cache-dir --upgrade wheel + python -m pip install --no-cache-dir --upgrade -r requirements.txt + python -m pip install --no-cache-dir --upgrade build + - name: Build Wheel and Tarball Packages + run: python3 -m build + - name: Store distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + publish: + name: Publish needs: - - build - runs-on: ubuntu-latest + - build + runs-on: self-hosted environment: name: pypi - url: https://pypi.org/p/betterdisco + url: https://pypi.org/p/betterdisco-py permissions: id-token: write + steps: - - name: Download distributions - uses: actions/download-artifact@v3 + - name: Download Distributions + uses: actions/download-artifact@v4 with: name: python-package-distributions path: dist/ - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 + with: + skip-existing: true + github-release: - name: >- - Sign with Sigstore, upload to GitHub Releases + name: Sign and Release needs: - - publish-to-pypi - runs-on: ubuntu-latest + - build + runs-on: self-hosted + permissions: - contents: write - id-token: write + contents: write # IMPORTANT: mandatory for making GitHub Releases + id-token: write # IMPORTANT: mandatory for sigstore + steps: - - name: Download distributions - uses: actions/download-artifact@v3 + - name: Setup Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Download Distributions + uses: actions/download-artifact@v4 with: name: python-package-distributions path: dist/ - - name: Sign dists with Sigstore - uses: sigstore/gh-action-sigstore-python@v1.2.3 + - name: Sign + uses: sigstore/gh-action-sigstore-python@v2.1.1 with: - inputs: >- + inputs: | ./dist/*.tar.gz ./dist/*.whl - - name: Create GitHub Release + - name: Get Version from Commit Title + id: get_version + run: | + VERSION=$(git log --format=%B -n 1 HEAD | head -n 1) + echo "::set-output name=VERSION::$VERSION" + - name: Create Release # TODO: make this better env: GITHUB_TOKEN: ${{ github.token }} - run: >- - gh release create '${{ github.ref_name }}' --repo '${{ github.repository }}' --notes "" - - name: Upload artifact signatures to GitHub Releases + run: | + gh release create '${{ steps.get_version.outputs.VERSION }}' --repo '${{ github.repository }}' --notes "" + - name: Upload Release env: GITHUB_TOKEN: ${{ github.token }} - run: >- - gh release upload '${{ github.ref_name }}' dist/** --repo '${{ github.repository }}' + run: | + gh release upload '${{ steps.get_version.outputs.VERSION }}' dist/** --repo '${{ github.repository }}' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 7f3c5478..dac1fba4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,17 +2,17 @@ name: "CodeQL" on: push: - branches: [ "main", "staging/dooley" ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ "main", "staging/dooley" ] - schedule: - - cron: '23 17 * * 1' + branches: + - main + - staging/dooley + paths: + - 'disco/**' + workflow_dispatch: jobs: analyze: name: Analyze - runs-on: ubuntu-latest + runs-on: self-hosted permissions: actions: read contents: read @@ -28,12 +28,12 @@ jobs: # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - - name: Checkout repository + - name: Pull Repository uses: actions/checkout@v3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -47,7 +47,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # ℹī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -60,6 +60,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: "/language:${{matrix.language}}" diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 8afbefe8..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -include README.md -include requirements.txt diff --git a/README.md b/README.md index 33ed3fa8..4ba31281 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,33 @@ # BetterDisco +_BetterDisco_ is an up-to-date modernized fork of Disco, a library witten by b1nzy, the creator of Discord's API, iirc. +_Disco_ is a _library_, written in Python 3 to interface with [Discord's API](https://discord.com/developers/docs/intro) as _efficiently_ and _effectively_ as possible. +Disco is _expressive_, and contains a _functional interface_. +Disco is built for _performance_ and _efficiency_. +Disco is _scalable_ and works well in large and small deployments. +Disco is _configurable_ and _modular_. +Disco contains evented network and IO pipes, courtesy of `gevent`. +_Buzzwords **100**._ WYSIWYG. -Disco is an extensive and extendable Python 3.x library for the [Discord API](https://discord.com/developers/docs/intro). Disco boasts the following major features: - -- Expressive, functional interface that gets out of the way -- Built for high-performance and efficiency -- Configurable and modular, take the bits you need -- Full support for Python 3.x -- Evented networking and IO using Gevent ## Installation +Disco is designed to run both as a generic-use library, and as a standalone bot toolkit. Installing disco is as easy as running `pip install betterdisco-py --upgrade --no-cache-dir`, however, additional options are available for extended features, performance, and support: -Disco was built to run both as a generic-use library, and a standalone bot toolkit. Installing disco is as easy as running `pip install disco-py`, however some extra packages are recommended for power-users, namely: +| _This_ | Installs _these_ | _Why?_ | +|-------------------------------|-------------------------------------------------------------|--------------------------------------------------------------------------------| +| `betterdisco-py` | `gevent`, `requests`, `websocket-client` | Required for base Disco functionality. | +| `betterdisco-py[http]` | `flask` | Useful for hosting an API to interface with your bot. | +| `betterdisco-py[performance]` | `erlpack`, `isal`, `regex`, `pylibyaml`, `ujson`, `wsaccel` | Useful for performance improvement in several areas. _I am speed._ | +| `betterdisco-py[sharding]` | `gipc`, `dill` | Required for auto-sharding and inter-process communication. | +| `betterdisco-py[voice]` | `libnacl` | Required for VC connectivity and features. | +| `betterdisco-py[yaml]` | `pyyaml` | Required for YAML support, particularly if using `config.yaml`. | +| `betterdisco-py[all]` | _**All of the above**, unless otherwise noted._ | **All additional packages**, for the poweruser that _absolutely needs it all_. | -|Name|Reason| -|----|------| -|requests[security]|adds packages for a proper SSL implementation| -|earl-etf (3.x)|ETF parser run with the --encoder=etf flag| -|gipc|Gevent IPC, required for autosharding| ## Examples - -Simple bot using the builtin bot authoring tools: +Simple bot using the built-in bot authoring tools: ```python -from disco.bot import Bot, Plugin +from disco.bot import Plugin class SimplePlugin(Plugin): @@ -35,18 +39,20 @@ class SimplePlugin(Plugin): # They also provide an easy-to-use command component @Plugin.command('ping') def on_ping_command(self, event): - event.msg.reply('Pong!') + event.reply('Pong!') # Which includes command argument parsing @Plugin.command('echo', '') def on_echo_command(self, event, content): - event.msg.reply(content) + event.reply(content) ``` Using the default bot configuration, we can now run this script like so: `python -m disco.cli --token="MY_DISCORD_TOKEN" --run-bot --plugin simpleplugin` -And commands can be triggered by mentioning the bot (configured by the BotConfig.command\_require\_mention flag): +And commands can be triggered by mentioning the bot (configured by the BotConfig.command_require_mention flag): ![](http://i.imgur.com/Vw6T8bi.png) + +### For further information and configuration options, please refer to our documentation first and foremost. \ No newline at end of file diff --git a/disco/__init__.py b/disco/__init__.py index 1e5a6058..12abebbd 100644 --- a/disco/__init__.py +++ b/disco/__init__.py @@ -1 +1 @@ -VERSION = '1.0.0' +VERSION = "2024.10.10" diff --git a/disco/api/client.py b/disco/api/client.py index 685b92f0..cd6f7dd9 100644 --- a/disco/api/client.py +++ b/disco/api/client.py @@ -69,6 +69,9 @@ def __init__(self, token, client=None): self._captures = local() + def __repr__(self): + return ''.format(f' shard_id={self.client.config.shard_id}' if self.client else '') + def _after_requests(self, response): if not hasattr(self._captures, 'responses'): return diff --git a/disco/bot/bot.py b/disco/bot/bot.py index 1b8885d7..77b0e9a8 100644 --- a/disco/bot/bot.py +++ b/disco/bot/bot.py @@ -208,6 +208,9 @@ def __init__(self, client, config=None): level = int(level) if str(level).isdigit() else get_enum_value_by_name(CommandLevels, level) self.config.levels[entity_id] = level + def __repr__(self): + return f'' + @classmethod def from_cli(cls, *plugins): """ @@ -577,7 +580,7 @@ def add_plugin_module(self, path, config=None): self.add_plugin(plugin, config) if not loaded: - raise Exception(f'Could not find any plugins to load within module {path}') + self.log.error(f'Could not find plugins to load within module {path}') def load_plugin_config(self, cls): name = cls.__name__.lower() diff --git a/disco/client.py b/disco/client.py index 6009fc11..e3b6327b 100644 --- a/disco/client.py +++ b/disco/client.py @@ -111,6 +111,9 @@ def __init__(self, config): localf=lambda: self.manhole_locals) self.manhole.start() + def __repr__(self): + return ''.format(f' bot_id={self.state.me.id}' if self.state and self.state.me else '', f' shard_id={self.config.shard_id}') + def update_presence(self, status, game=None, afk=False, since=0.0): """ Updates the current client's presence. diff --git a/disco/gateway/client.py b/disco/gateway/client.py index 8e013b70..e0e08d2e 100644 --- a/disco/gateway/client.py +++ b/disco/gateway/client.py @@ -1,7 +1,7 @@ from gevent import sleep as gevent_sleep, spawn as gevent_spawn from gevent.event import Event as GeventEvent from platform import system as platform_system -from time import perf_counter as time_perf_counter, perf_counter_ns as time_perf_counter_ns +from time import time, perf_counter_ns as time_perf_counter_ns from websocket import ABNF, WebSocketConnectionClosedException, WebSocketTimeoutException from zlib import decompress as zlib_decompress, decompressobj as zlib_decompressobj @@ -76,6 +76,9 @@ def __init__(self, client, max_reconnects=5, encoder='json', zlib_stream_enabled self._last_heartbeat = 0 self.latency = -1 + def __repr__(self): + return f'' + def send(self, op, data): if not self.ws.is_closed: self.limiter.check() @@ -98,7 +101,7 @@ def heartbeat_task(self, interval): self.ws.close(status=1000) self.client.gw.on_close(0, 'HEARTBEAT failure') return - self._last_heartbeat = time_perf_counter() + self._last_heartbeat = time() self._send(OPCode.HEARTBEAT, self.seq) self._heartbeat_acknowledged = False @@ -125,7 +128,7 @@ def handle_heartbeat(self, _): def handle_heartbeat_acknowledge(self, _): self.log.debug('Received HEARTBEAT_ACK') self._heartbeat_acknowledged = True - self.latency = float('{:.2f}'.format((time_perf_counter() - self._last_heartbeat) * 1000)) + self.latency = self.ws.last_pong_tm and float('{:.2f}'.format((self.ws.last_pong_tm - self.ws.last_ping_tm) * 1000)) def handle_reconnect(self, _): self.log.warning('Received RECONNECT request; resuming') @@ -175,7 +178,7 @@ def connect_and_run(self, gateway_url=None): self.ws.emitter.on('on_close', self.on_close) self.ws.emitter.on('on_message', self.on_message) - self.ws.run_forever() + self.ws.run_forever(ping_interval=60, ping_timeout=5) def on_message(self, msg): if self.zlib_stream_enabled: diff --git a/disco/state.py b/disco/state.py index f44712b5..c6e90478 100644 --- a/disco/state.py +++ b/disco/state.py @@ -140,6 +140,9 @@ def __init__(self, client, config): self.listeners = [] self.bind() + def __repr__(self): + return f'' + def unbind(self): """ Unbinds all bound event listeners for this state object. @@ -172,7 +175,7 @@ def on_user_update(self, event): self.me.inplace_update(event.user) def on_message_create(self, event): - if self.config.cache_users and event.message.author.id not in self.users: + if self.config.cache_users and event.message.author.id not in self.users and not event.message.webhook_id: self.users[event.message.author.id] = event.message.author if self.config.sync_guild_members and event.message.member: diff --git a/disco/voice/client.py b/disco/voice/client.py index dfe26da9..84a7cc52 100644 --- a/disco/voice/client.py +++ b/disco/voice/client.py @@ -1,5 +1,5 @@ from gevent import sleep as gevent_sleep, spawn as gevent_spawn -from time import perf_counter as time_perf_counter, time +from time import time from collections import namedtuple as namedtuple from websocket import WebSocketConnectionClosedException, WebSocketTimeoutException @@ -87,6 +87,9 @@ def __init__(self, client, server_id, is_dm=False, max_reconnects=5, encoder='js self.video_enabled = video_enabled self.media = None + self.deaf = False + self.mute = False + self.proxy = None # Set the VoiceClient in the state's voice clients @@ -117,7 +120,7 @@ def __init__(self, client, server_id, is_dm=False, max_reconnects=5, encoder='js self.ip = None self.port = None self.enc_modes = None - self.experiments = None + self.experiments = [] # self.streams = None self.sdp = None self.mode = None @@ -125,6 +128,7 @@ def __init__(self, client, server_id, is_dm=False, max_reconnects=5, encoder='js self.audio_codec = None self.video_codec = None self.transport_id = None + self.keyframe_interval = None self.secure_frames_version = None self.seq = -1 @@ -137,6 +141,8 @@ def __init__(self, client, server_id, is_dm=False, max_reconnects=5, encoder='js self._heartbeat_acknowledged = True self._identified = False self._safe_reconnect_state = False + self._creation_time = time() + self._ws_creation_time = None # Latency self._last_heartbeat = 0 @@ -147,11 +153,8 @@ def __init__(self, client, server_id, is_dm=False, max_reconnects=5, encoder='js self.video_ssrcs = {} self.rtx_ssrcs = {} - self.deaf = False - self.mute = False - def __repr__(self): - return ''.format(self.server_id, self.channel_id) + return f'' @cached_property def guild(self): @@ -219,7 +222,7 @@ def connect_and_run(self, gateway_url=None): self.ws.emitter.on('on_error', self.on_error) self.ws.emitter.on('on_close', self.on_close) self.ws.emitter.on('on_message', self.on_message) - self.ws.run_forever() + self.ws.run_forever(ping_interval=60, ping_timeout=5) def heartbeat_task(self, interval): while True: @@ -229,7 +232,7 @@ def heartbeat_task(self, interval): self.ws.close(status=4000) self.on_close(0, 'HEARTBEAT failure') return - self._last_heartbeat = time_perf_counter() + self._last_heartbeat = time() self.send(VoiceOPCode.HEARTBEAT, {'seq_ack': self.seq, 't': int(time())}) self._heartbeat_acknowledged = False @@ -241,7 +244,7 @@ def handle_heartbeat(self, _): def handle_heartbeat_acknowledge(self, _): self.log.debug('[{}] Received WS HEARTBEAT_ACK'.format(self.channel_id)) self._heartbeat_acknowledged = True - self.latency = float('{:.2f}'.format((time_perf_counter() - self._last_heartbeat) * 1000)) + self.latency = self.ws.last_pong_tm and float('{:.2f}'.format((self.ws.last_pong_tm - self.ws.last_ping_tm) * 1000)) def set_speaking(self, voice=False, soundshare=False, priority=False, delay=0): value = SpeakingFlags.NONE @@ -302,12 +305,14 @@ def on_voice_codecs(self, data): self.video_codec = data['video_codec'] if 'media_session_id' in data.keys(): self.transport_id = data['media_session_id'] + if 'keyframe_interval' in data.keys(): + self.keyframe_interval = data['keyframe_interval'] # Set the UDP's RTP Audio Header's Payload Type - self.udp.set_audio_codec(data['audio_codec']) + # self.udp.set_audio_codec(data['audio_codec']) # bypass because the audio codec will always be opus def on_voice_hello(self, packet): - self.log.info('[{}] Received Voice HELLO payload, starting heartbeater'.format(self.channel_id)) + self.log.info('[{}] Received HELLO payload, starting heartbeater'.format(self.channel_id)) self._heartbeat_task = gevent_spawn(self.heartbeat_task, packet['heartbeat_interval']) self.set_state(VoiceState.AUTHENTICATED) @@ -349,7 +354,7 @@ def on_voice_ready(self, data): codecs.append({ 'name': codec, 'payload_type': RTPPayloadTypes.get(codec).value, - 'priority': idx, + 'priority': 1000 + idx, 'type': 'audio', }) @@ -362,7 +367,7 @@ def on_voice_ready(self, data): 'encode': False, 'name': codec, 'payload_type': ptype.value, - 'priority': idx, + 'priority': 1000 * idx, 'rtxPayloadType': ptype.value + 1, 'type': 'video', }) @@ -376,7 +381,7 @@ def on_voice_ready(self, data): 'mode': self.mode, }, 'codecs': codecs, - 'experiments': [], + 'experiments': self.experiments, }) self.send(VoiceOPCode.CLIENT_CONNECT, { 'audio_ssrc': self.ssrc, @@ -396,17 +401,19 @@ def on_voice_sdp(self, sdp): self.mode = sdp['mode'] # UDP-only, does not apply to webRTC self.audio_codec = sdp['audio_codec'] - if self.video_enabled: - self.video_codec = sdp['video_codec'] self.transport_id = sdp['media_session_id'] # analytics self.secure_frames_version = sdp['secure_frames_version'] - # self.sdp = sdp['sdp'] # webRTC only - # self.keyframe_interval = sdp['keyframe_interval'] + if 'sdp' in sdp.keys(): + self.sdp = sdp['sdp'] # webRTC only # Set the UDP's RTP Audio Header's Payload Type self.udp.set_audio_codec(sdp['audio_codec']) + if self.video_enabled: + self.video_codec = sdp['video_codec'] self.udp.set_video_codec(sdp['video_codec']) + if 'keyframe_interval' in sdp.keys(): + self.keyframe_interval = sdp['keyframe_interval'] # Create a secret box for encryption/decryption self.udp.setup_encryption(bytes(bytearray(sdp['secret_key']))) # UDP only @@ -496,10 +503,10 @@ def on_message(self, msg): try: data = self.encoder.decode(msg) self.packets.emit(data['op'], data['d']) - if 'seq' in data: + if 'seq' in data.keys(): self.seq = data['seq'] except Exception: - self.log.exception('Failed to parse voice gateway message: ') + self.log.error('Failed to parse voice gateway message: ') def on_error(self, error): if isinstance(error, WebSocketTimeoutException): @@ -600,8 +607,9 @@ def connect(self, channel_id, timeout=10, **kwargs): if not self.state_emitter.once(VoiceState.CONNECTED, timeout=timeout): self.disconnect() - raise VoiceException('Failed to connect to voice', self) + self.log.error(f'[{self.channel_id}] Failed to connect to voice') else: + self._ws_creation_time = time() return self def disconnect(self): diff --git a/disco/voice/packets.py b/disco/voice/packets.py index b5d25dc0..2c0e273a 100644 --- a/disco/voice/packets.py +++ b/disco/voice/packets.py @@ -9,12 +9,13 @@ class VoiceOPCode: RESUME = 7 HELLO = 8 RESUMED = 9 + CLIENT_CONNECT = 11 VIDEO = 12 CLIENT_DISCONNECT = 13 CODECS = 14 MEDIA_SINK_WANTS = 15 VOICE_BACKEND_VERSION = 16 CHANNEL_OPTIONS_UPDATE = 17 - CLIENT_CONNECT = 18 + CLIENT_FLAGS = 18 SPEED_TEST = 19 PLATFORM = 20 diff --git a/disco/voice/udp.py b/disco/voice/udp.py index d39fceaa..6aa7fc84 100644 --- a/disco/voice/udp.py +++ b/disco/voice/udp.py @@ -366,7 +366,7 @@ def connect(self, host, port, timeout=10, addrinfo=None): return None, None # Read IP and port - ip = str(data[8:]).split('\x00', 1)[0] + ip = str(data[8:].split(b'\x00', 1)[0], "utf=8") port = struct_unpack('= 3.0.3",] +performance = ["erlpack >= 1.0.0", "isal >= 1.7.0", "regex >= 2024.7.24", "pylibyaml >= 0.1.0", "ujson >= 5.10.0", "wsaccel >= 0.6.6",] +redis = ["redis >= 5.0.8", "hiredis >= 3.0.0",] +sharding = ["gipc >= 1.6.0", "dill >= 0.3.8",] +voice = ["libnacl >= 2.1.0",] +yaml = ["pyyaml >= 6.0.2",] + +[project.urls] +Repository = "https://github.com/elderlabs/BetterDisco.git" + +[build-system] +requires = ["setuptools >= 75.1.0", "wheel >= 0.44.0",] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.dynamic] +dependencies = {file = "requirements.txt"} + +[tool.setuptools.packages.find] +include = ["disco*",] +namespaces = false diff --git a/requirements.txt b/requirements.txt index 1e82bf17..2d135a9a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -gevent==24.2.1 +gevent==24.10.2 requests==2.32.3 websocket-client==1.8.0 \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index d5132d72..00000000 --- a/setup.py +++ /dev/null @@ -1,58 +0,0 @@ -from setuptools import setup, find_packages - -from disco import VERSION - -with open('requirements.txt') as f: - requirements = f.readlines() - f.close() - -with open('README.md') as f: - readme = f.read() - f.close() - -extras_require = { - 'voice': ['pynacl>=1.5.0', 'libnacl>=2.1.0'], - 'http': ['flask>=2.1.1'], - 'yaml': ['pyyaml>=5.3.1'], - 'music': ['yt-dlp>=2022.3.8.2'], - 'performance': [ - 'erlpack>=1.0.0', - 'regex>=2022.3.15', - 'pylibyaml>=0.1.0', - 'ujson>=5.2.0', - 'wsaccel>=0.6.3', - ], - 'sharding': ['gipc>=1.6.0', 'dill>=0.3.6'], -} - -setup( - name='betterdisco-py', - author='The BetterDisco Team; b1nzy', - url='https://github.com/elderlabs/betterdisco', - version=VERSION, - packages=find_packages(include=['disco*']), - license='MIT', - description='A Python library for Discord', - long_description=readme, - long_description_content_type="text/markdown", - include_package_data=True, - install_requires=requirements, - extras_require=extras_require, - classifiers=[ - 'Development Status :: 4 - Beta', - 'License :: OSI Approved :: MIT License', - 'Intended Audience :: Developers', - 'Natural Language :: English', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - 'Topic :: Internet', - 'Topic :: Software Development :: Libraries', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Topic :: Utilities', - ], - python_requires='>=3.8', -)