├── .gitignore ├── src ├── core │ ├── __init__.py │ └── command_handler.py ├── reqs.txt ├── utils │ ├── __init__.py │ ├── verify.py │ └── constants.py ├── example.env ├── upsert.py ├── main.py └── commands.py ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | __pycache__ -------------------------------------------------------------------------------- /src/core/__init__.py: -------------------------------------------------------------------------------- 1 | from .command_handler import * -------------------------------------------------------------------------------- /src/reqs.txt: -------------------------------------------------------------------------------- 1 | fastapi 2 | uvicorn 3 | requests 4 | python-dotenv -------------------------------------------------------------------------------- /src/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .constants import * 2 | from .verify import * -------------------------------------------------------------------------------- /src/example.env: -------------------------------------------------------------------------------- 1 | CLIENT_PUBLIC_KEY = "" 2 | APPLICATION_ID = "" 3 | TOKEN = "" 4 | -------------------------------------------------------------------------------- /src/core/command_handler.py: -------------------------------------------------------------------------------- 1 | from commands import commands 2 | 3 | 4 | def get_command(json_data: dict): 5 | for command in commands: 6 | if command.name == json_data["data"]["name"]: 7 | return command 8 | return None 9 | 10 | 11 | class CommandHandler: 12 | def __init__(self, json_data: dict): 13 | self.json_data = json_data 14 | 15 | async def execute(self): 16 | command = get_command(self.json_data) 17 | if command is None: 18 | return None 19 | result = await command.respond(self.json_data) 20 | return result 21 | -------------------------------------------------------------------------------- /src/upsert.py: -------------------------------------------------------------------------------- 1 | from dotenv import load_dotenv 2 | import os 3 | from requests import Request, Session 4 | from commands import commands 5 | 6 | load_dotenv() 7 | 8 | TOKEN = os.environ.get("TOKEN") 9 | APPLICATION_ID = os.environ.get("APPLICATION_ID") 10 | 11 | s = Session() 12 | s.headers.update({"Authorization": f"Bot {TOKEN}"}) 13 | 14 | r = s.send( 15 | s.prepare_request( 16 | Request( 17 | "PUT", 18 | f"https://discord.com/api/v10/applications/{APPLICATION_ID}/commands", 19 | json=[c.to_dict() for c in commands], 20 | ) 21 | ) 22 | ) 23 | 24 | print(f"{r.json()}") 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Gaurav 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 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | from fastapi import FastAPI, Request 3 | from starlette.middleware import Middleware 4 | from utils import ( 5 | InteractionType, 6 | InteractionResponseType, 7 | InteractionResponseFlags, 8 | CustomHeaderMiddleware, 9 | ) 10 | from core import CommandHandler 11 | 12 | app = FastAPI(middleware=[Middleware(CustomHeaderMiddleware)]) 13 | 14 | 15 | @app.post("/") 16 | async def interactions(request: Request): 17 | json_data = await request.json() 18 | 19 | if json_data["type"] == InteractionType.PING: 20 | # A ping test sent by discord to check if your server works 21 | return {"type": InteractionResponseType.PONG} 22 | 23 | if json_data["type"] == InteractionType.APPLICATION_COMMAND: 24 | # We only want to handle slash commands 25 | handler = CommandHandler(json_data) 26 | result = await handler.execute() 27 | if result is not None: 28 | return result 29 | 30 | # No result means either the command is not found or the command is not registered 31 | # Or you havent implemented the command yet 32 | # Or you forgot to return the result 33 | # Or idk just check it man i cant do everything for you 34 | 35 | return { 36 | "type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 37 | "data": { 38 | "content": "Hello Buddy, This is a by default message for any unrecognized interaction.", 39 | "flags": InteractionResponseFlags.EPHEMERAL, 40 | }, 41 | } 42 | 43 | 44 | if __name__ == "__main__": 45 | uvicorn.run(app, host="0.0.0.0", port=3000) 46 | -------------------------------------------------------------------------------- /src/utils/verify.py: -------------------------------------------------------------------------------- 1 | from nacl.signing import VerifyKey 2 | from starlette.middleware.base import BaseHTTPMiddleware 3 | from fastapi import Request, Response 4 | from dotenv import load_dotenv 5 | import os 6 | 7 | load_dotenv() 8 | 9 | CLIENT_PUBLIC_KEY = os.environ.get("CLIENT_PUBLIC_KEY") 10 | 11 | 12 | def verify_key( 13 | raw_body: bytes, signature: str, timestamp: str, client_public_key: str 14 | ) -> bool: 15 | message = timestamp.encode() + raw_body 16 | try: 17 | vk = VerifyKey(bytes.fromhex(client_public_key)) 18 | vk.verify(message, bytes.fromhex(signature)) 19 | return True 20 | except Exception as ex: 21 | print(ex) 22 | return False 23 | 24 | 25 | async def set_body(request: Request, body: bytes): 26 | async def receive(): 27 | return {"type": "http.request", "body": body} 28 | 29 | request._receive = receive 30 | 31 | 32 | async def get_body(request: Request) -> bytes: 33 | body = await request.body() 34 | await set_body(request, body) 35 | return body 36 | 37 | 38 | class CustomHeaderMiddleware(BaseHTTPMiddleware): 39 | async def dispatch(self, request, call_next): 40 | signature = request.headers["X-Signature-Ed25519"] 41 | timestamp = request.headers["X-Signature-Timestamp"] 42 | request_body = await get_body(request) 43 | if ( 44 | signature is None 45 | or timestamp is None 46 | or not verify_key(request_body, signature, timestamp, CLIENT_PUBLIC_KEY) 47 | ): 48 | return Response("Bad request signature", status_code=401) 49 | response = await call_next(request) 50 | return response 51 | -------------------------------------------------------------------------------- /src/utils/constants.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class InteractionType: 5 | PING = 1 6 | APPLICATION_COMMAND = 2 7 | MESSAGE_COMPONENT = 3 8 | APPLICATION_COMMAND_AUTOCOMPLETE = 4 9 | MODAL_SUBMIT = 5 10 | 11 | 12 | class InteractionResponseType: 13 | PONG = 1 14 | CHANNEL_MESSAGE_WITH_SOURCE = 4 15 | DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5 16 | DEFERRED_UPDATE_MESSAGE = 6 17 | UPDATE_MESSAGE = 7 18 | APPLICATION_COMMAND_AUTOCOMPLETE_RESULT = 8 19 | MODAL = 9 20 | 21 | 22 | class InteractionResponseFlags: 23 | EPHEMERAL = 1 << 6 24 | 25 | 26 | class ApplicationCommandOptionType(Enum): 27 | SUB_COMMAND = 1 28 | SUB_COMMAND_GROUP = 2 29 | STRING = 3 30 | INTEGER = 4 31 | BOOLEAN = 5 32 | USER = 6 33 | CHANNEL = 7 34 | ROLE = 8 35 | 36 | 37 | class Option: 38 | def __init__( 39 | self, 40 | name: str, 41 | type: ApplicationCommandOptionType, 42 | description: str = "...", 43 | required: bool = False, 44 | ): 45 | self.name = name 46 | self.description = description 47 | self.type = type 48 | self.required = required 49 | 50 | def to_dict(self): 51 | return { 52 | "name": self.name, 53 | "description": self.description, 54 | "type": self.type.value, 55 | "required": self.required, 56 | } 57 | 58 | 59 | class SlashCommand: 60 | def __init__(self, name: str, description: str = "...", options: list = None): 61 | self.name = name 62 | self.description = description 63 | self.options = options or [] 64 | 65 | async def respond(self, json_data: dict): 66 | # This function is async just so that fastapi supports async poggies 67 | ... 68 | 69 | def to_dict(self): 70 | return { 71 | "type": 1, # Slash command 72 | "name": self.name, 73 | "description": self.description, 74 | "options": [option.to_dict() for option in self.options], 75 | } 76 | -------------------------------------------------------------------------------- /src/commands.py: -------------------------------------------------------------------------------- 1 | from utils import ( 2 | SlashCommand, 3 | Option, 4 | InteractionResponseFlags, 5 | InteractionResponseType, 6 | ApplicationCommandOptionType, 7 | ) 8 | 9 | 10 | class HelloCommand(SlashCommand): 11 | def __init__(self): 12 | super().__init__( 13 | name="hello", 14 | description="Say hello to someone", 15 | options=[ 16 | Option( 17 | name="user", 18 | type=ApplicationCommandOptionType.USER, 19 | description="The user to say hello", 20 | required=True, 21 | ), 22 | ], 23 | ) 24 | 25 | async def respond(self, json_data: dict): 26 | # This function is async just so that fastapi supports async poggies 27 | user_id = json_data["data"]["options"][0]["value"] 28 | return { 29 | "type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 30 | "data": { 31 | "content": f"Hello <@!{user_id}>", 32 | "flags": InteractionResponseFlags.EPHEMERAL, 33 | }, 34 | } 35 | 36 | 37 | class ByeCommand(SlashCommand): 38 | def __init__(self): 39 | super().__init__( 40 | name="bye", 41 | description="Say bye to someone", 42 | options=[ 43 | Option( 44 | name="user", 45 | type=ApplicationCommandOptionType.USER, 46 | description="The user to say bye", 47 | required=True, 48 | ), 49 | ], 50 | ) 51 | 52 | async def respond(self, json_data: dict): 53 | # This function is async just so that fastapi supports async poggies 54 | user_id = json_data["data"]["options"][0]["value"] 55 | return { 56 | "type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 57 | "data": { 58 | "content": f"Bye <@!{user_id}>", 59 | "flags": InteractionResponseFlags.EPHEMERAL, 60 | }, 61 | } 62 | 63 | 64 | commands = [HelloCommand(), ByeCommand()] 65 | # NOTE: Please updates this `commands` list with your commands 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [![Contributors][contributors-shield]][contributors-url] 4 | [![Forks][forks-shield]][forks-url] 5 | [![Stargazers][stars-shield]][stars-url] 6 | [![Issues][issues-shield]][issues-url] 7 | [![MIT License][license-shield]][license-url] 8 | 9 | 10 |
11 |
12 |

Discord http Interaction Bot

13 | 14 |

15 | An awesome open source project to demonstrate usage of discord http interactions in python! 16 |
17 | · 18 | Report Bug 19 | · 20 | Request Feature 21 |

22 |
23 | 24 | ## About The Project 25 | 26 | This repository is an open source project to demonstrate the usage of discord http interactions in python. 27 | There are many repos using discord gateway system to create bots, but this repo is different. This repo uses discord http interactions to create a bot. This is a very undiscovered feature of discord and is still underrated. This repo is created to demonstrate the usage of this feature. 28 | 29 | Here's why: 30 | * There's no repo on github that demonstrates the usage of discord http interactions in python. 31 | * Everything in this repo is well documented and explained. 32 | * This repo is open source and free to use. 33 | * This repo is created to help people who are new to discord bots and want to learn how to create one. 34 | * Http interactions can be used to host serverless bots, it saves us a lot of hosting resources and money. 35 | 36 | A great example is Dyno bot on discord which is using gateway and http interactions both simontaneously to save hosting resources and money. You can do the same, just with some knowledge of python, discord http interactions and some creativity. 37 | 38 | Of course, no one will serve all projects since your needs may be different. So I'll be adding more in the near future. You may also suggest changes by forking this repo and creating a pull request or opening an issue. Thanks to all the people have contributed to expanding this project! 39 | 40 |

(back to top)

41 | 42 | 43 | 44 | ### Built With 45 | 46 | * [Fastapi](https://fastapi.tiangolo.com/) 47 | 48 |

(back to top)

49 | 50 | ## Getting Started 51 | 52 | 1. Clone this repo on your local machine 53 | ```sh 54 | git clone https://github.com/Binacy/discord-http-interaction-bot 55 | ``` 56 | 2. Install the requirements (Sorry i use reqs instead of requirements) 57 | ```sh 58 | pip3 install -U -r reqs.txt 59 | ``` 60 | 3. Create a discord application and bot on [Discord Developer Portal](https://discord.com/developers/applications) 61 | 4. Rename the [example.env](https://github.com/Binacy/discord-http-interaction-bot/blob/main/src/example.env) file to .env 62 | 5. Complete the .env file with your bot token, application id and public key from the discord developer portal. 63 | 6. Edit commands in [commands.py](https://github.com/Binacy/discord-http-interaction-bot/blob/main/src/commands.py) 64 | * Note: You will have to run upsert.py before main.py to upload your slash commands to discord. 65 | 7. Run the bot 66 | ```sh 67 | python3 main.py 68 | ``` 69 | 8. Sorry, you are not done yet! you have to use a server like [ngrok](https://ngrok.com/) to start a local webserver. 70 | 9. Please install any server like ngrok and start a local webserver on port 3000. 71 | ```sh 72 | ngrok http 3000 73 | ``` 74 | 10. Copy the https url from the ngrok terminal and paste it in the discord developer portal in the interaction section. 75 | 11. You are done! Now you can use your bot in any server you want. 76 | 12. Ah! not done yet, You can use web services like vercel to host your bot 24/7 for free. You can also use heroku. 77 | 13. This is serverless hosting so it wont cost you much :wink: 78 | 79 |

(back to top)

80 | 81 | ## Contributing 82 | 83 | Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. 84 | 85 | If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". 86 | Don't forget to give the project a star! Thanks again! 87 | 88 | 1. Fork the Project 89 | 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) 90 | 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) 91 | 4. Push to the Branch (`git push origin feature/AmazingFeature`) 92 | 5. Open a Pull Request 93 | 94 |

(back to top)

95 | 96 | ## License 97 | 98 | Distributed under the MIT License. See [LICENSE](https://github.com/Binacy/discord-http-interaction-bot/blob/main/LICENSE) for more information. 99 | 100 |

(back to top)

101 | 102 | ## Contact 103 | 104 | Discord - [@Binacy](https://discord.com/users/1211202988518146050) 105 | 106 |

(back to top)

107 | 108 | ## Acknowledgments 109 | 110 | Special thanks to [Aditya](https://github.com/Xenofic) and [Amey](https://github.com/AmeyWale) for helping me with this project. 111 | 112 |

(back to top)

113 | 114 | [contributors-shield]: https://img.shields.io/github/contributors/Binacy/discord-http-interaction-bot.svg?style=for-the-badge 115 | [contributors-url]: https://github.com/Binacy/discord-http-interaction-bot/graphs/contributors 116 | [forks-shield]: https://img.shields.io/github/forks/Binacy/discord-http-interaction-bot.svg?style=for-the-badge 117 | [forks-url]: https://github.com/Binacy/discord-http-interaction-bot/network/members 118 | [stars-shield]: https://img.shields.io/github/stars/Binacy/discord-http-interaction-bot.svg?style=for-the-badge 119 | [stars-url]: https://github.com/Binacy/discord-http-interaction-bot/stargazers 120 | [issues-shield]: https://img.shields.io/github/issues/Binacy/discord-http-interaction-bot.svg?style=for-the-badge 121 | [issues-url]: https://github.com/Binacy/discord-http-interaction-bot/issues 122 | [license-shield]: https://img.shields.io/github/license/Binacy/discord-http-interaction-bot.svg?style=for-the-badge 123 | [license-url]: https://github.com/Binacy/discord-http-interaction-bot/blob/main/LICENSE 124 | --------------------------------------------------------------------------------