├── .gitignore ├── Discovery.md ├── LICENSE ├── README.md ├── Spacefile ├── example.env ├── main.py ├── requirements.txt └── src ├── actions └── presence.py ├── assets └── logo.png ├── bot.py ├── commands ├── ask.py └── ping.py ├── micros └── main.py └── utils └── helpers.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /Discovery.md: -------------------------------------------------------------------------------- 1 | --- 2 | app_name: "ChatGPT" 3 | title: "ChatGPT Serverless Discord Bot" 4 | tagline: "Your personal ChatGPT Discord bot." 5 | theme_color: "#74aa9c" 6 | git: "https://github.com/imptype/serverless-chatgpt-discord-bot" 7 | homepage: "https://deta.space" 8 | --- 9 | 10 | With this, you can run your own instance of a ChatGPT Discord bot. 11 | 12 | ### Installation steps: 13 | 1. First install the app. 14 | 2. Enter the required environment variables: 15 | - `DISCORD_APPLICATION_ID` - Your discord app's ID. 16 | - `DISCORD_PUBLIC_KEY` - Your discord app's public key. 17 | - `DISCORD_BOT_TOKEN` - Your bot's token. 18 | - `OPENAI_API_KEY` - An API key from OpenAI's API. 19 | - `SYNC_PASSWORD` - A password you set to sync commands. 20 | 3. You can overwrite `CHATGPT_SYSTEM_MESSAGE` to anything you want to transform ChatGPT's personality. 21 | 4. Set the `Interactions Endpoint URL` in your discord app's general information to `/bot/interactions`. 22 | 5. Visit `/bot/api/dash` to register the slash commands for the first time. 23 | 24 | Run `/ping` to make sure it's working! Enjoy! 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 imp 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Information 2 | This Discord bot is **SERVERLESS** which means it can run for **FREE** and be **ALWAYS online** on [Deta Space](https://deta.space)! 3 | You can also treat this repository as a template for making serverless bots with the [discohook](https://github.com/jnsougata/discohook) library. 4 | 5 | ### Table of Contents 6 | - [Information](#information) 7 | - [Table of Contents](#table-of-contents) 8 | - [Features](#features) 9 | - [File Structure](#file-structure) 10 | - [Requirements](#requirements) 11 | - [Running Online](#running-online) 12 | - [Running Locally](#running-locally) 13 | - [Links and Resources](#links-and-resources) 14 | 15 | ## Features 16 | - `/ping` - a simple command that tells you the bot's latency. 17 | - `/ask [model]` - a command that uses [OpenAI's API](https://openai.com/blog/openai-api) (ChatGPT). 18 | - A status message `Listening to /ask! | OpenAI` via [scheduled actions](https://deta.space/docs/en/basics/micros#scheduled-actions). 19 | - And you can easily create and add more commands yourself! 20 | 21 | ## File Structure 22 | ``` 23 | . 24 | ├─ src/ # Source code 25 | │ ├─ actions/ # Files used for scheduled actions 26 | │ │ └─ presence.py # Presence updater (bot status) 27 | │ ├─ assets/ # All asset files 28 | │ │ └─ logo.png # Logo used for space app 29 | │ ├─ commands/ # All command files 30 | │ │ ├─ ask.py # Ask command 31 | │ │ └─ ping.py # Ping command 32 | │ ├─ utils/ # Contains any extra utility files 33 | │ │ └─ helpers.py # Useful functions 34 | │ └─ bot.py # Contains the discohook bot 35 | ├─ .gitignore # Hides certain files 36 | ├─ Discovery.md # Defines app's space discovery page 37 | ├─ LICENSE # License 38 | ├─ README.md # Defines this README page 39 | ├─ Spacefile # Space app configuration 40 | ├─ example.env # Example of an .env file 41 | ├─ main.py # Entry point 42 | └─ requirements.txt # Library dependencies 43 | ``` 44 | 45 | ## Requirements 46 | - **Discord Application:** Create an app for **FREE** at [Discord Developer Portal](https://discord.com/developers/applications). 47 | - **Deta Space account:** Create an account for **FREE** at [Deta Space](https://deta.space/), username + password. 48 | - **OpenAI API Key:** Create a developer account for **FREE** at [OpenAI](https://platform.openai.com/overview), free $18 in credits. 49 | - [**discohook**](https://github.com/jnsougata/discohook): A github library used to make async serverless Discord bots. 50 | - [**deta**](https://github.com/jnsougata/discohook): A github library used to make async [Deta Space's Base HTTP API](https://deta.space/docs/en/reference/base/HTTP) requests. 51 | - The database is only used to store the websocket resume data for the status message. 52 | - [**uvicorn**](https://pypi.org/project/uvicorn/): A PyPI library used to run an ASGI webserver. 53 | - [**python-dotenv**](https://pypi.org/project/python-dotenv/): A PyPI library used to help load variables from an `.env` file. 54 | - This is only used when developing the bot locally. 55 | - [**openai**](https://pypi.org/project/openai/): A PyPI library used to make async [OpenAI API](https://platform.openai.com/docs/api-reference?lang=python) requests. 56 | 57 | ## Running Online 58 | 1. Install the space app from the [app's discovery page](https://deta.space/discovery/@imp1/chatgpt). 59 | Alternatively you could build the space app yourself: 60 | 1. [Clone](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) this repository. 61 | 2. Install the [Space CLI](https://deta.space/docs/en/basics/cli). 62 | 3. Make sure you're in the project folder: `$cd ` 63 | 4. Create a space app: `$space new` 64 | 5. Push the space app: `$space push` 65 | 2. Enter the environment variables (Space App Settings ➔ Configuration). 66 | - `DISCORD_APPLICATION_ID` - Your discord app's ID. 67 | - `DISCORD_PUBLIC_KEY` - Your discord app's public key. 68 | - `DISCORD_BOT_TOKEN` - Your bot's token. 69 | - `OPENAI_API_KEY` - An API key from OpenAI's API. 70 | - `SYNC_PASSWORD` - A password you set to sync commands later on. 71 | - Other environment variables are optional. 72 | 3. Set `Interactions Endpoint URL` to `/interactions`. 73 | - This is located in: `https://discord.com/developers/applications/{application_id}/information` 74 | - A Micro URL looks like this: `https://chatgpt-1-a1234567.deta.app` 75 | 4. Visit `/api/dash` to register the slash commands for the first time. 76 | - You need to type the value of `SYNC_PASSWORD` you set in your env vars. 77 | 5. Run `/ping` to make sure it's working! Enjoy! 78 | 79 | ## Running Locally 80 | You only need to run the bot locally if you plan to **develop new commands** for the bot. 81 | This is because `$space push`-ing each time would make development take forever. 82 | 1. [Clone](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) this repository. 83 | 2. Install the [Space CLI](https://deta.space/docs/en/basics/cli). 84 | 3. Make sure you're in the project folder: `$cd ` 85 | 4. Install the library dependencies. 86 | 1. Make a virtual environment: `$python -m venv venv` 87 | 2. Enter the virtual environment: `$source venv/bin/activate` 88 | 3. Install requirements: `$pip install -r requirements.txt` 89 | 4. To leave the virtual environment later run `$deactivate`. 90 | 5. Rename `example.env` to `.env` file and update its contents. 91 | - Environment variables in comments are optional. 92 | 6. Run `$space dev` to start both `main` and `bot` micros. 93 | 7. In another terminal, start a reverse proxy/tunnel because your `https://localhost` can't be accessed by Discord. 94 | - **Via [Deta Space](https://deta.space)**: 95 | - Run `space reverse proxy` and the URL is your micro's URL: `https://-1-a1234567.deta.app` 96 | - If you have a main bot and a test bot, use `space link` to switch to a test space app. 97 | - **Via [Ngrok](https://ngrok.com)**: 98 | - [Setup Ngrok](https://ngrok.com/docs/getting-started). Create an account, install CLI, set auth-token. 99 | - Run `ngrok http 4200` to get a URL like `https://a1b2-34-567-890-123.ngrok-free.app`. 100 | - Note the Free Tier has a ratelimit of 60 requests per minute and URLs are ephemeral. 101 | - **Via [Cloudflare](https://cloudflare.com)**: 102 | - [Setup Cloudflare](https://developers.cloudflare.com/pages/how-to/preview-with-cloudflare-tunnel). Install CLI and optionally link an account. 103 | - Run `cloudflared tunnel --url https://localhost:4200` to get a URL like `https://ab-quick-brown-fox.trycloudflare.com`. 104 | - No request ratelimit (AFAIK) but URLs are still ephemeral. 105 | - Note it's technically against their ToS to host anything other than basic HTML pages on the free plan. 106 | - Use this for development only if you need a higher request ratelimit. 107 | - **List of [other solutions](https://github.com/anderspitman/awesome-tunneling)**. 108 | - For all the above, you can do `CTRL+C` to stop them. 109 | 8. Set the `Interactions Endpoint URL` to `/interactions`. 110 | - This is located in: `https://discord.com/developers/applications/{application_id}/information` 111 | - The URL is from the previous step, for space it's this: `https://-1-a1234567.deta.app` 112 | 9. Finally you can now start live editing. 113 | Uvicorn is set to `--reload` so any edits you make automatically restarts the webserver. 114 | 10. To stop running do `CTRL+C`. 115 | 116 | When you're ready, you can run `$space push` to update the space app. 117 | 118 | ## Links and Resources 119 | - **Deta Space Documentation:** https://deta.space/docs 120 | - **Deta Discord:** https://discord.gg/deta 121 | - **Discohook Discord:** https://discord.gg/xEEpJvE9py 122 | - **Discord API Documentation:** https://discord.com/developers/docs 123 | - **OpenAI API Documenation:** https://platform.openai.com/docs/api-reference?lang=python 124 | - **Space App Discovery Page:** https://deta.space/discovery/@imp1/chatgpt 125 | -------------------------------------------------------------------------------- /Spacefile: -------------------------------------------------------------------------------- 1 | # Spacefile Docs: https://go.deta.dev/docs/spacefile/v0 2 | v: 0 3 | icon: src/assets/logo.png 4 | micros: 5 | - name: main 6 | src: . 7 | engine: python3.9 8 | primary: true 9 | include: 10 | - src/ 11 | - main.py 12 | run: uvicorn main:app --log-level warning 13 | dev: uvicorn main:app --reload 14 | public_routes: 15 | - '/interactions' 16 | presets: 17 | env: 18 | - name: DISCORD_APPLICATION_ID 19 | description: Discord Application's ID 20 | - name: DISCORD_PUBLIC_KEY 21 | description: Discord Application's Public Key 22 | - name: DISCORD_BOT_TOKEN 23 | description: Discord Application/Bot Token (duplicate) 24 | - name: SYNC_PASSWORD 25 | description: A password you set to sync commands 26 | - name: OPENAI_API_KEY 27 | description: OpenAI's API Key 28 | - name: ERROR_LOG_WEBHOOK_URL 29 | description: Discord Webhook URL (for bot error logs) 30 | default: '' 31 | - name: CHATGPT_SYSTEM_MESSAGE 32 | description: System Message (to give to ChatGPT) 33 | default: "You are ChatGPT, a large language model trained by OpenAI, using GPT-3.5. Answer as concisely yet as informative as possible. Don't frequently say that you're an AI language model. Knowledge cutoff: 2021-09-01." 34 | - name: PRESENCE_LOG_WEBHOOK_URL 35 | description: Discord Webhook URL (for presence logs) 36 | default: '' 37 | - name: ALT_DETA_PROJECT_KEY 38 | description: Alternative Deta Project Key (or uses default) 39 | default: '' 40 | actions: 41 | - id: 'presence' 42 | name: 'Presence' 43 | description: 'Gives the bot a status' 44 | trigger: 'schedule' 45 | default_interval: '* * * * *' -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | DISCORD_BOT_TOKEN= 2 | DISCORD_APPLICATION_ID= 3 | DISCORD_PUBLIC_KEY= 4 | OPENAI_API_KEY= 5 | SYNC_PASSWORD= 6 | # ERROR_LOG_WEBHOOK_URL= 7 | # PRESENCE_LOG_WEBHOOK_URL= 8 | # ALT_DETA_PROJECT_KEY= 9 | # CHATGPT_SYSTEM_MESSAGE= -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from dotenv import load_dotenv 2 | 3 | # Load env if running locally 4 | load_dotenv(override = True) 5 | 6 | from src.bot import run 7 | 8 | app = run() -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | git+https://github.com/jnsougata/discohook@4f680db 2 | git+https://github.com/jnsougata/deta@eb0a09f 3 | uvicorn==0.22.0 4 | openai==0.27.7 5 | python-dotenv==1.0.0 -------------------------------------------------------------------------------- /src/actions/presence.py: -------------------------------------------------------------------------------- 1 | import os 2 | import datetime 3 | import logging 4 | import asyncio 5 | import deta # https://github.com/jnsougata/deta 6 | import discohook # https://github.com/jnsougata/discohook 7 | 8 | activity_name = '/ask! | OpenAI' 9 | activity_type = 2 # listening to, https://discord.com/developers/docs/topics/gateway-events#activity-object 10 | status = 'online' 11 | 12 | token = os.getenv('DISCORD_BOT_TOKEN') 13 | webhook_url = os.getenv('PRESENCE_LOG_WEBHOOK_URL') 14 | deta_project_key = os.getenv('ALT_DETA_PROJECT_KEY', os.getenv('DETA_PROJECT_KEY')) 15 | deta_base_name = 'presence_base' 16 | deta_base_key = 'resume' 17 | deta_base_value = 'value' 18 | gateway_url = 'wss://gateway.discord.gg' 19 | loop_timeout = 20 # max time for scheduled action 20 | 21 | logger = logging.getLogger(__name__) 22 | handler = logging.StreamHandler() 23 | handler.setFormatter(logging.Formatter( 24 | '[%(asctime)s.%(msecs)03d] %(message)s', 25 | '%Y-%m-%d %H:%M:%S' 26 | )) 27 | logger.addHandler(handler) 28 | logger.setLevel(logging.INFO) # set to DEBUG to debug 29 | 30 | presence_payload = { 31 | 'activities' : [{ 32 | 'name': activity_name, 33 | 'type': activity_type 34 | }], 35 | 'status' : status, 36 | 'since' : 0, 37 | 'afk' : False 38 | } 39 | 40 | async def identify(ws): 41 | await ws.send_json({ 42 | 'op' : 2, 43 | 'd' : { 44 | 'token' : token, 45 | 'intents' : 0, # recieve no events 46 | 'properties' : { 47 | 'os' : 'linux', 48 | 'browser' : 'disco', 49 | 'device' : 'disco' 50 | }, 51 | 'presence' : presence_payload 52 | } 53 | }) 54 | 55 | async def presence_update(ws): 56 | await ws.send_json({ 57 | 'op' : 3, 58 | 'd' : presence_payload 59 | }) 60 | 61 | async def resume(ws, session_id, s): 62 | await ws.send_json({ 63 | 'op' : 6, 64 | 'd' : { 65 | 'token' : token, 66 | 'session_id' : session_id, 67 | 'seq' : s 68 | } 69 | }) 70 | 71 | # Resume data stored in 1 record in deta base, looks like this 72 | # key = resume | value = {'url' : 'ws://etc.', 'session_id' : 'asdasd', 's' : s} or record does not exist 73 | 74 | async def run(session): 75 | logger.debug('Enter function') 76 | task = asyncio.create_task(loop(session)) 77 | try: 78 | await asyncio.wait_for(task, timeout = loop_timeout + 1) 79 | except asyncio.TimeoutError: # for local testing, prevents accidental infinite loop 80 | logger.info('Timed out!') 81 | 82 | async def loop(session): 83 | assert deta_project_key, 'Deta project key not found, give the variable a value if you\'re running this locally.' 84 | base = deta.Deta(deta_project_key, session = session).base(deta_base_name) 85 | try: 86 | data = (await base.get(deta_base_key))[deta_base_value] 87 | url = data['url'] 88 | except deta.NotFound: # will not exist for first time 89 | data = None 90 | url = gateway_url 91 | while True: 92 | logger.debug('Connecting to URL: {}'.format(url)) 93 | ws = await session.ws_connect(url) 94 | async for msg in ws: 95 | msg = msg.json() 96 | 97 | if msg['op'] == 0: # Dispatch 98 | 99 | if msg['t'] == 'READY': # contains guild count, save it somewhere to use 100 | logger.debug('Ready') 101 | data = { 102 | 'url' : msg['d']['resume_gateway_url'], 103 | 'session_id' : msg['d']['session_id'] 104 | } 105 | # count = len(msg['d']['guilds']) 106 | 107 | elif msg['t'] == 'RESUMED': 108 | logger.debug('Resumed + send PRESENCE UPDATE') 109 | await presence_update(ws) 110 | 111 | data['s'] = msg['s'] 112 | record = deta.Record({deta_base_value : data}, key = deta_base_key) 113 | await base.put(record) # updates resume data 114 | continue 115 | 116 | elif msg['op'] == 9: # Invalid session 117 | logger.debug('Invalid session, reconnect: {}'.format(msg['d'])) 118 | if not msg['d']: # cant reconnect bool 119 | await base.delete(deta_base_key) # deletes resume data 120 | data = None 121 | url = gateway_url 122 | await ws.close() 123 | continue 124 | 125 | elif msg['op'] == 10: # Hello 126 | if data: 127 | logger.debug('Send RESUME') 128 | await resume(ws, data['session_id'], data['s']) 129 | content = 'Resume' 130 | else: 131 | logger.debug('Send IDENTIFY') 132 | await identify(ws) 133 | content = 'Identify <------ !!' 134 | content = '[`{}`] {}'.format(datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3], content) 135 | if webhook_url: 136 | await session.post(webhook_url, json = {'content' : content}) 137 | continue 138 | 139 | logger.warning('Unhandled message: {}'.format(msg)) 140 | await asyncio.sleep(1) 141 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imptype/serverless-chatgpt-discord-bot/fd4e011058e4bba17d9f46b20c275be735fe5c38/src/assets/logo.png -------------------------------------------------------------------------------- /src/bot.py: -------------------------------------------------------------------------------- 1 | import os 2 | import asyncio 3 | import contextlib 4 | import aiohttp 5 | import discohook 6 | from starlette.responses import JSONResponse, PlainTextResponse 7 | from .commands.ask import ask_command 8 | from .commands.ping import ping_command 9 | from .actions import presence 10 | from .utils.helpers import log_error 11 | 12 | def run(): 13 | 14 | # Lifespan to cleanup sessions during development 15 | @contextlib.asynccontextmanager 16 | async def lifespan(app): 17 | async with aiohttp.ClientSession() as app.session: 18 | try: 19 | yield 20 | finally: 21 | if app.http.session: # close bot session 22 | await app.http.session.close() 23 | 24 | # Define the bot 25 | app = discohook.Client( 26 | application_id = int(os.getenv('DISCORD_APPLICATION_ID')), 27 | public_key = os.getenv('DISCORD_PUBLIC_KEY'), 28 | token = os.getenv('DISCORD_BOT_TOKEN'), 29 | password = os.getenv('SYNC_PASSWORD'), 30 | lifespan = lifespan 31 | ) 32 | 33 | # Interactions error handler 34 | @app.on_interaction_error() 35 | async def on_interaction_error(interaction, error): 36 | if interaction.responded: 37 | await interaction.followup('Sorry, an error has occurred (after responding).') 38 | else: 39 | await interaction.response('Sorry, an error has occurred.') 40 | await log_error(app.session, error) 41 | 42 | # Server error handler 43 | @app.on_error() 44 | async def on_error(request, error): 45 | await log_error(app.session, error) 46 | return JSONResponse({'error' : str(error)}) 47 | 48 | # Add commands 49 | app.add_commands( 50 | ask_command, 51 | ping_command 52 | ) 53 | 54 | # Root 55 | @app.route('/', methods = ['GET']) 56 | async def root(request): 57 | return PlainTextResponse('Your micro is online. See the Discovery page to finish setting up!') 58 | 59 | # Actions handler 60 | @app.route('/__space/v0/actions', methods = ['POST']) 61 | async def actions(request): 62 | data = await request.json() 63 | event = data['event'] 64 | if event['id'] == 'presence': 65 | await presence.run(app.session) 66 | 67 | return app -------------------------------------------------------------------------------- /src/commands/ask.py: -------------------------------------------------------------------------------- 1 | import os 2 | import discohook 3 | import openai 4 | from ..utils.helpers import chunks 5 | 6 | openai.api_key = os.getenv('OPENAI_API_KEY') 7 | system_content = os.getenv('CHATGPT_SYSTEM_MESSAGE') 8 | 9 | models = ['gpt-3.5-turbo-0301', 'gpt-3.5-turbo'] 10 | 11 | options = [ 12 | discohook.Option.string('prompt', 'Type your question here.', required = True), 13 | discohook.Option.string('model', 'The AI model to use.', choices = [ 14 | discohook.Choice(model.replace('.', '-'), model) for model in models # . not allowed in name 15 | ]) 16 | ] 17 | 18 | @discohook.command.slash('ask', description = 'Ask ChatGPT anything!', options = options) 19 | async def ask_command(interaction, prompt, model): 20 | 21 | await interaction.response.defer() # because it takes more than 3 seconds! 22 | 23 | if (await openai.Moderation.acreate(prompt)).results[0].flagged: 24 | return await interaction.response.followup('Sorry, your prompt was flagged.') 25 | 26 | if not model: 27 | model = models[-1] 28 | 29 | completion = await openai.ChatCompletion.acreate( 30 | model = model, 31 | messages = [ 32 | {'role': 'system', 'content' : system_content}, 33 | {'role': 'user', 'content': prompt} 34 | ] 35 | ) 36 | 37 | text = completion.choices[0].message.content 38 | 39 | for text in chunks(text, 2000)[:10]: 40 | await interaction.response.followup(text) 41 | -------------------------------------------------------------------------------- /src/commands/ping.py: -------------------------------------------------------------------------------- 1 | import time 2 | import discohook 3 | from ..utils.helpers import snowflake_time 4 | 5 | @discohook.command.slash('ping', description = 'Ping test the bot.') 6 | async def ping_command(interaction): 7 | created_at = snowflake_time(int(interaction.id)) 8 | now = time.time() 9 | since = now - created_at 10 | content = 'Pong! Latency: `{:.2f}ms`'.format(since * 1000) 11 | await interaction.response.send(content) -------------------------------------------------------------------------------- /src/micros/main.py: -------------------------------------------------------------------------------- 1 | from dotenv import load_dotenv 2 | 3 | # Load env if running locally 4 | load_dotenv(override = True) # '.env' file is not included when deployed to Space 5 | 6 | import asyncio 7 | import contextlib 8 | import aiohttp 9 | from fastapi import FastAPI, Request 10 | from fastapi.responses import Response 11 | from ..actions import presence 12 | from ..utils.helpers import log_error 13 | 14 | # Lifespan to attach .session attribute, cancel + shutdown is for local testing 15 | @contextlib.asynccontextmanager 16 | async def lifespan(app): 17 | app.session = aiohttp.ClientSession() 18 | try: 19 | yield 20 | except asyncio.CancelledError: 21 | print('Ignoring cancelled error. (CTRL+C)') 22 | await app.session.close() 23 | 24 | app = FastAPI(lifespan = lifespan) 25 | 26 | # Error handler 27 | @app.middleware('http') 28 | async def middleware(request, call_next): 29 | try: 30 | return await call_next(request) 31 | except Exception as error: 32 | await log_error(app.session, error) 33 | return Response('Internal server error', status_code = 500) 34 | 35 | # Root 36 | @app.get('/') 37 | async def root(): 38 | return 'There is no app here, this just runs the serverless bot behind-the-scenes. See the Discovery page for setting up!' 39 | 40 | # Actions handler 41 | @app.post('/__space/v0/actions') 42 | async def actions(request : Request): 43 | data = await request.json() 44 | event = data['event'] 45 | if event['id'] == 'presence': 46 | await presence.run(app.session) 47 | -------------------------------------------------------------------------------- /src/utils/helpers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import traceback 3 | import datetime 4 | 5 | webhook_url = os.getenv('ERROR_LOG_WEBHOOK_URL') 6 | discord_epoch = 1420070400000 7 | 8 | def snowflake_time(snowflake_id): 9 | return ((snowflake_id >> 22) + discord_epoch) / 1000 10 | 11 | async def log_error(session, error): 12 | text = ''.join(traceback.format_exception(type(error), error, error.__traceback__)) 13 | text = '[{}] {}\n{}'.format(datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3], error, text) 14 | print(text) # console 15 | if webhook_url: 16 | await session.post(webhook_url, json = {'content' : text[:2000]}) 17 | 18 | def chunks(text, n): 19 | return [text[i:i+n] for i in range(0, len(text), n)] --------------------------------------------------------------------------------